diff --git a/.gitignore b/.gitignore index f827122..e3bdbed 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,3 @@ .cxx local.properties /app/build -keystore.properties diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md deleted file mode 100644 index c544235..0000000 --- a/ARCHITECTURE.md +++ /dev/null @@ -1,455 +0,0 @@ -# Architecture - -This document describes the architecture of Tilfluktsrom, a Norwegian emergency shelter finder available as an Android app and a Progressive Web App (PWA). Both platforms share the same design philosophy — offline-first, no hard external dependencies — but are implemented independently. - -## Table of Contents - -- [Design Principles](#design-principles) -- [Data Pipeline](#data-pipeline) -- [Android App](#android-app) - - [Package Structure](#package-structure) - - [Data Layer](#data-layer) - - [Location & Navigation](#location--navigation) - - [Compass System](#compass-system) - - [Map & Tile Caching](#map--tile-caching) - - [Build Variants](#build-variants) - - [Home Screen Widget](#home-screen-widget) - - [Deep Linking](#deep-linking) -- [Progressive Web App](#progressive-web-app) - - [Module Structure](#module-structure) - - [Build System](#build-system) - - [Data Layer (PWA)](#data-layer-pwa) - - [Location & Compass (PWA)](#location--compass-pwa) - - [Map & Offline Tiles (PWA)](#map--offline-tiles-pwa) - - [Service Worker](#service-worker) - - [UI Components](#ui-components) -- [Shared Algorithms](#shared-algorithms) - - [UTM33N → WGS84 Conversion](#utm33n--wgs84-conversion) - - [Haversine Distance & Bearing](#haversine-distance--bearing) -- [Offline Capability Summary](#offline-capability-summary) -- [Security & Privacy](#security--privacy) -- [Internationalization](#internationalization) - ---- - -## Design Principles - -### Offline-First -This is an emergency app. Core functionality — finding the nearest shelter, compass navigation, distance display — must work without internet after initial setup. Network is used only for initial data download, periodic refresh, and map tile caching. - -### De-Google Compatibility (Android) -The Android app runs on devices without Google Play Services (LineageOS, GrapheneOS, /e/OS). Every Google-specific API has an AOSP fallback. Play Services improve accuracy and battery life when available, but are never required. - -### Minimal Dependencies -Both platforms use few, well-chosen libraries. No heavy frameworks, no external CDNs at runtime. The PWA bundles everything locally; the Android app uses only OSMDroid, Room, OkHttp, and WorkManager. - -### Data Sovereignty -Shelter data comes directly from Geonorge (the Norwegian mapping authority). No intermediate servers. The app fetches, converts, and caches the data locally. - ---- - -## Data Pipeline - -Both platforms consume the same upstream data source: - -``` -Geonorge ZIP (EPSG:25833 UTM33N) - ↓ Download (~320KB) - ↓ Extract GeoJSON from ZIP - ↓ Parse features - ↓ Convert UTM33N → WGS84 (Karney method) - ↓ Validate (Norway bounding box, required fields) - ↓ Store locally -``` - -**Source URL:** `https://nedlasting.geonorge.no/geonorge/Samfunnssikkerhet/TilfluktsromOffentlige/GeoJSON/Samfunnssikkerhet_0000_Norge_25833_TilfluktsromOffentlige_GeoJSON.zip` - -**Fields per shelter:** - -| Field | Description | -|------------|--------------------------------------| -| `lokalId` | Unique identifier (UUID) | -| `romnr` | Shelter room number | -| `plasser` | Capacity (number of people) | -| `adresse` | Street address | -| `latitude` | WGS84 latitude (converted from UTM) | -| `longitude`| WGS84 longitude (converted from UTM) | - -**When conversion happens:** -- **Android:** At runtime during data download (`ShelterGeoJsonParser`) -- **PWA:** At build time (`scripts/fetch-shelters.ts`), output is pre-converted `shelters.json` - ---- - -## Android App - -**Language:** Kotlin · **Min SDK:** 26 (Android 8.0) · **Target SDK:** 35 -**Build:** Gradle 8.7, AGP 8.5.2, KSP for Room annotation processing - -### Package Structure - -``` -no.naiv.tilfluktsrom/ -├── TilfluktsromApp.kt # Application class (OSMDroid config) -├── MainActivity.kt # Central UI controller (~870 lines) -├── data/ -│ ├── Shelter.kt # Room entity -│ ├── ShelterDatabase.kt # Room database singleton -│ ├── ShelterDao.kt # Data access object (Flow queries) -│ ├── ShelterRepository.kt # Repository: bundled seed + network refresh -│ ├── ShelterGeoJsonParser.kt # GeoJSON ZIP → Shelter list -│ └── MapCacheManager.kt # Offline map tile pre-caching -├── location/ -│ ├── LocationProvider.kt # GPS provider (flavor-specific) -│ └── ShelterFinder.kt # Nearest N shelters by Haversine -├── ui/ -│ ├── DirectionArrowView.kt # Custom compass arrow View -│ ├── ShelterListAdapter.kt # RecyclerView adapter for shelter list -│ ├── CivilDefenseInfoDialog.kt # Emergency instructions -│ └── AboutDialog.kt # Privacy and copyright -├── util/ -│ ├── CoordinateConverter.kt # UTM33N → WGS84 (Karney method) -│ └── DistanceUtils.kt # Haversine distance and bearing -└── widget/ - ├── ShelterWidgetProvider.kt # Home screen widget (flavor-specific) - └── WidgetUpdateWorker.kt # WorkManager periodic update -``` - -Files under `location/` and `widget/` have separate implementations per build variant: -- `app/src/standard/java/` — Google Play Services variant -- `app/src/fdroid/java/` — AOSP-only variant - -### Data Layer - -**Storage:** Room (SQLite) with a single `shelters` table. - -**Loading strategy (three-layer fallback):** - -1. **Bundled asset** (`assets/shelters.json`): Pre-converted WGS84 data, loaded on first launch via `ShelterRepository.seedFromAsset()`. Marked as stale (timestamp=0) so a network refresh is attempted when possible. - -2. **Room database**: ~556 shelters cached locally. Reactive updates via Kotlin Flow. Atomic refresh: `deleteAll()` + `insertAll()` in a single transaction. - -3. **Network refresh**: Downloads the Geonorge ZIP via OkHttp, parses with `ShelterGeoJsonParser`. Staleness threshold: 7 days. Runs in the background; failure does not block the UI. - -**GeoJSON parsing pipeline:** -``` -OkHttp response stream - → ZipInputStream (find .geojson entry, 10MB size limit) - → JSON parsing (features array) - → Per feature: - → Extract UTM33N coordinates - → CoordinateConverter.utm33nToWgs84() - → Validate: lokalId not blank, plasser ≥ 0, within Norway bounds - → Create Shelter entity (skip malformed features with warning) -``` - -### Location & Navigation - -**LocationProvider** abstracts GPS access with two flavor implementations: - -| Flavor | Primary | Fallback | -|-----------|----------------------------------|-------------------| -| standard | FusedLocationProviderClient | LocationManager | -| fdroid | LocationManager | — | - -Both emit location updates via Kotlin Flow. Update interval: 5 seconds, fastest 2 seconds. - -**ShelterFinder** takes the user's position and all shelters, computes Haversine distance and initial bearing for each, sorts by distance, and returns the N nearest (default: 3) as `ShelterWithDistance` objects. - -### Compass System - -**Sensor priority:** -1. Rotation Vector sensor (`TYPE_ROTATION_VECTOR`) — most accurate, single sensor -2. Accelerometer + Magnetometer — low-pass filtered (α=0.25) for smoothing -3. No compass available — error message shown - -**Direction calculation:** -``` -arrowAngle = shelterBearing − deviceHeading -``` - -**DirectionArrowView** is a custom View that draws: -- A large arrow rotated by `arrowAngle`, pointing toward the shelter -- An optional north indicator on the perimeter for compass calibration - -### Map & Tile Caching - -**Map library:** OSMDroid (OpenStreetMap, no Google dependency). - -**Tile caching:** OSMDroid's built-in `SqlTileWriter` passively caches every tile loaded. `MapCacheManager` supplements this with active pre-caching: - -- Pans the MapView across a 3×3 grid at zoom levels 10, 12, 14, 16 -- 300ms delay between pans (respects OSM tile usage policy) -- Covers ~15km radius around the user -- Progress reported via callback for UI display - -Tile cache stored in app-specific internal storage (`osmdroidBasePath`). - -### Build Variants - -``` -productFlavors { - standard { } // Google Play Services + AOSP fallback - fdroid { } // AOSP only -} -``` - -The `standard` flavor adds `com.google.android.gms:play-services-location`. Runtime detection via `GoogleApiAvailability` determines which code path runs. - -Both flavors produce identical user experiences — `standard` achieves faster GPS fixes and better battery efficiency when Play Services are present. - -### Home Screen Widget - -**ShelterWidgetProvider** displays the nearest shelter's address, capacity, and distance. Updated by: - -1. **MainActivity** — sends latest location on each GPS update -2. **WorkManager** — `WidgetUpdateWorker` runs every 15 minutes, requests a fresh location fix -3. **Manual** — user taps refresh button on the widget - -**Location resolution (priority order):** -1. Location from intent (WorkManager or MainActivity) -2. FusedLocationProviderClient cache (standard) -3. Active GPS request (10s timeout) -4. LocationManager cache -5. SharedPreferences saved location (max 24h old) - -### Deep Linking - -**HTTPS App Links:** `https://tilfluktsrom.naiv.no/shelter/{lokalId}` - -The domain is configured in one place: `DEEP_LINK_DOMAIN` in `build.gradle.kts` (exposed as `BuildConfig.DEEP_LINK_DOMAIN` and manifest placeholder `${deepLinkHost}`). - -- `autoVerify="true"` on the HTTPS intent filter triggers Android's App Links verification at install time -- Verification requires `/.well-known/assetlinks.json` to be served by the PWA (in `pwa/public/.well-known/`) -- If the app is installed and verified, `/shelter/*` links open the app directly (no disambiguation dialog) -- If not installed, the link opens in the browser, where the PWA handles it - -Share messages include the HTTPS URL, which SMS apps auto-link as a tappable URL. - ---- - -## Progressive Web App - -**Stack:** TypeScript, Vite 5, Leaflet, idb (IndexedDB wrapper), vite-plugin-pwa -**Package manager:** bun - -### Module Structure - -``` -pwa/ -├── index.html # SPA shell, CSP headers, semantic layout -├── vite.config.ts # Build config, PWA plugin, tile caching rules -├── manifest.webmanifest # PWA metadata and icons -├── scripts/ -│ └── fetch-shelters.ts # Build-time: download + convert shelter data -├── public/ -│ └── data/shelters.json # Pre-processed shelter data (build artifact) -└── src/ - ├── main.ts # Entry point, SW registration, locale init - ├── app.ts # Main controller (~400 lines) - ├── types.ts # Shelter, ShelterWithDistance, LatLon interfaces - ├── data/ - │ ├── shelter-repository.ts # Fetch + IndexedDB storage - │ └── shelter-db.ts # IndexedDB wrapper (idb library) - ├── location/ - │ ├── location-provider.ts # navigator.geolocation wrapper - │ ├── compass-provider.ts # DeviceOrientationEvent (iOS/Android) - │ └── shelter-finder.ts # Haversine nearest-N calculation - ├── ui/ - │ ├── map-view.ts # Leaflet map, custom SVG markers - │ ├── compass-view.ts # Canvas-based direction arrow - │ ├── shelter-list.ts # Bottom sheet shelter list - │ ├── loading-overlay.ts # Modal spinner / cache prompt - │ ├── about-dialog.ts # Privacy, data info, cache clear - │ ├── civil-defense-dialog.ts # DSB 5-step emergency guide - │ └── status-bar.ts # Status text and refresh button - ├── cache/ - │ └── map-cache-manager.ts # Tile pre-caching via programmatic panning - ├── i18n/ - │ ├── i18n.ts # Locale detection, string substitution - │ ├── en.ts # English strings - │ ├── nb.ts # Bokmål strings - │ └── nn.ts # Nynorsk strings - ├── util/ - │ └── distance-utils.ts # Haversine distance, bearing, formatting - └── main.css # Dark theme, Leaflet overrides, dialogs -``` - -### Build System - -**Vite** bundles the app with content-hashed filenames for cache busting. - -**Key configuration:** -- Base path: `./` (relative, deployable anywhere) -- `__BUILD_REVISION__` define: ISO timestamp injected at build time, used to invalidate service worker caches -- **vite-plugin-pwa**: Generates the service worker with Workbox. Precaches all static assets. Runtime-caches OSM tile requests with CacheFirst strategy (30-day expiry, max 5000 entries). - -**Build-time data preprocessing:** -`scripts/fetch-shelters.ts` downloads the Geonorge ZIP, extracts the GeoJSON, converts UTM33N→WGS84, validates, and writes `public/data/shelters.json`. This means coordinate conversion is a build step, not a runtime cost. - -```bash -bun run fetch-shelters # Download and convert shelter data -bun run build # TypeScript check + Vite build + SW generation -``` - -### Data Layer (PWA) - -**Storage:** IndexedDB via the `idb` library. - -**Schema:** -- Object store `shelters` (keyPath: `lokalId`) — full shelter records -- Object store `metadata` — `lastUpdate` timestamp - -**Loading strategy:** -1. Check IndexedDB for cached shelters -2. If empty or stale (>7 days): fetch `shelters.json` (precached by service worker) -3. Replace all records in a single transaction -4. Reactive: UI reads from IndexedDB after load - -Unlike the Android app, the PWA does not perform coordinate conversion at runtime — `shelters.json` is pre-converted at build time and served as a static asset. - -### Location & Compass (PWA) - -**Location:** `navigator.geolocation.watchPosition()` with high accuracy enabled, 10s maximum age, 30s timeout. - -**Compass:** DeviceOrientationEvent with platform-specific handling: - -| Platform | API | Heading Source | -|----------------|------------------------------|--------------------------| -| iOS Safari | `deviceorientation` | `webkitCompassHeading` | -| Android Chrome | `deviceorientationabsolute` | `(360 − alpha) % 360` | - -iOS 13+ requires an explicit permission request (`DeviceOrientationEvent.requestPermission()`), triggered by a user gesture. - -Low-pass filter (smoothing factor 0.3) with 0/360° wraparound handling ensures fluid rotation. - -### Map & Offline Tiles (PWA) - -**Map library:** Leaflet with OpenStreetMap tiles. No CDN — Leaflet is bundled from `node_modules`. - -**Custom markers:** SVG-based `divIcon` elements — orange house with "T" for shelters, blue circle for user location. Selected shelter uses a larger yellow marker. - -**Auto-fit logic:** When a shelter is selected, the map fits bounds to show both user and shelter (30% padding), unless the user has manually panned or zoomed. A "reset view" button appears after manual interaction. - -**Tile pre-caching:** `MapCacheManager` uses the same approach as the Android app — programmatically pans the map across a 3×3 grid at 4 zoom levels. The service worker's runtime cache intercepts and stores the tile requests. Cache location stored in localStorage (rounded to ~11km precision for privacy). - -### Service Worker - -Generated by vite-plugin-pwa (Workbox): - -- **Precaching:** All static assets (JS, CSS, HTML, JSON, images) with content-hash versioning -- **Runtime caching:** OSM tiles from `{a,b,c}.tile.openstreetmap.org` with CacheFirst strategy, 30-day TTL, max 5000 entries -- **Navigation fallback:** Serves `index.html` for all navigation requests (SPA behavior) -- **Auto-update:** New service worker activates automatically; `controllerchange` event notifies the user - -`main.ts` injects the build revision into the service worker context, ensuring each build invalidates stale caches. It also requests persistent storage (`navigator.storage.persist()`) to prevent the browser from evicting cached data. - -### UI Components - -The PWA uses no framework — all UI is vanilla TypeScript manipulating the DOM. - -| Component | Description | -|--------------------------|----------------------------------------------------------| -| `app.ts` | Main controller: wires components, manages state | -| `map-view.ts` | Leaflet map with custom markers, auto-fit, interaction tracking | -| `compass-view.ts` | Canvas-rendered arrow with north indicator, requestAnimationFrame | -| `shelter-list.ts` | Bottom sheet with 3 nearest shelters, click selection | -| `loading-overlay.ts` | Modal: spinner during load, OK/Skip for cache prompt | -| `about-dialog.ts` | Privacy statement, data sources, "clear cache" button | -| `civil-defense-dialog.ts`| DSB emergency instructions (5 steps) | -| `status-bar.ts` | Data freshness indicator, refresh button | - -Dialogs are created dynamically and implement focus management (save/restore previous focus, trap focus inside modal). - ---- - -## Shared Algorithms - -Both platforms implement these algorithms independently (no shared code), ensuring each platform has zero runtime dependencies on the other. - -### UTM33N → WGS84 Conversion - -**Algorithm:** Karney series expansion method. - -Converts EUREF89 / UTM zone 33N (EPSG:25833) to WGS84 (EPSG:4326). - -**Constants:** -- Semi-major axis: 6,378,137 m -- Flattening: 1/298.257223563 -- UTM zone 33 central meridian: 15° E -- Scale factor: 0.9996 -- False easting: 500,000 m - -**Steps:** -1. Remove false easting/northing -2. Compute footprint latitude using series expansion -3. Apply iterative corrections for latitude and longitude -4. Add central meridian offset - -**Validation:** Reject results outside Norway bounding box (57–72°N, 3–33°E). - -**Implementations:** -- Android: `util/CoordinateConverter.kt` (runtime) -- PWA: `scripts/fetch-shelters.ts` (build-time) - -### Haversine Distance & Bearing - -**Distance** between two WGS84 points: -``` -a = sin²(Δφ/2) + cos(φ₁) · cos(φ₂) · sin²(Δλ/2) -d = 2R · atan2(√a, √(1−a)) -``` -Where R = 6,371 km (Earth mean radius). - -**Initial bearing** from point 1 to point 2: -``` -θ = atan2(sin(Δλ) · cos(φ₂), cos(φ₁) · sin(φ₂) − sin(φ₁) · cos(φ₂) · cos(Δλ)) -``` -Result in degrees: 0° = north, 90° = east, 180° = south, 270° = west. - -**Implementations:** -- Android: `util/DistanceUtils.kt` -- PWA: `util/distance-utils.ts` - ---- - -## Offline Capability Summary - -| Capability | Android | PWA | -|-------------------------|----------------------------------|------------------------------------| -| Shelter data | Room DB + bundled asset seed | IndexedDB + precached JSON | -| Map tiles | OSMDroid SqlTileWriter + active pre-cache | Service worker CacheFirst + active pre-cache | -| GPS | Device hardware (no network) | Device hardware (no network) | -| Compass | Device sensors | DeviceOrientationEvent | -| Distance/bearing math | Local computation | Local computation | -| Network required for | Initial refresh, new tiles | Initial page load, new tiles | -| Staleness threshold | 7 days | 7 days | - ---- - -## Security & Privacy - -- **No tracking:** No analytics, no telemetry, no external requests beyond Geonorge data and OSM tiles -- **No user accounts:** No registration, no login, no server-side storage -- **Location stays local:** GPS coordinates are used only for distance calculation and never transmitted -- **HTTPS enforced:** Android network security config restricts to TLS; PWA CSP headers restrict connections -- **ZIP bomb protection:** 10MB limit on uncompressed GeoJSON (Android parser) -- **CSP headers (PWA):** Restrictive Content-Security-Policy blocks external scripts; `img-src` allows only OSM tile servers -- **Input validation:** Shelter data validated at parse time (bounds check, required fields, non-negative capacity) -- **Minimal permissions:** Location only; no contacts, camera, storage (except legacy tile cache on Android ≤9) - ---- - -## Internationalization - -Both platforms support three languages: - -| Language | Android | PWA | -|---------------------|----------------------|----------------| -| English (default) | `values/strings.xml` | `i18n/en.ts` | -| Norwegian Bokmål | `values-nb/strings.xml` | `i18n/nb.ts` | -| Norwegian Nynorsk | `values-nn/strings.xml` | `i18n/nn.ts` | - -**Android:** Uses Android's built-in locale resolution. Per-app language selection supported on Android 13+ via `locales_config.xml`. - -**PWA:** Detects browser language (`navigator.language`). String substitution supports `%d` (number) and `%s` (string) placeholders. Falls back to English for unsupported locales. diff --git a/CLAUDE.md b/CLAUDE.md index df4f859..261261a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -53,62 +53,6 @@ no.naiv.tilfluktsrom/ ./gradlew assembleDebug ``` -## Build Variants -- **standard**: Includes Google Play Services for better GPS accuracy -- **fdroid**: AOSP-only, no Google dependencies - -## Distribution -- **Forgejo** (primary): `kode.naiv.no/olemd/tilfluktsrom` — releases with both APK variants + PWA tarball -- **GitHub** (mirror): `github.com/olemd/tilfluktsrom` — automatically mirrored from Forgejo, do not push manually -- **F-Droid**: Metadata maintained in a separate fdroiddata repo (GitLab fork). F-Droid builds from source using the `fdroid` variant and signs with the F-Droid key. - -## Release Process -When creating a new release: -1. Bump `versionCode` and `versionName` in `app/build.gradle.kts` -2. Update User-Agent string in `ShelterRepository.kt` to match new version -3. Build Android APKs: `./gradlew assembleStandardRelease assembleFdroidRelease` -4. Build PWA: `cd pwa && bun scripts/fetch-shelters.ts && bun run build && tar -czf /tmp/tilfluktsrom-vX.Y.Z-pwa.tar.gz -C dist .` -5. Commit, push, then create Forgejo release with all three artifacts: - ```bash - fj release create --create-tag vX.Y.Z --branch main \ - --attach "/tmp/tilfluktsrom-vX.Y.Z-standard.apk:tilfluktsrom-vX.Y.Z-standard.apk" \ - --attach "/tmp/tilfluktsrom-vX.Y.Z-fdroid.apk:tilfluktsrom-vX.Y.Z-fdroid.apk" \ - --body "release notes" "vX.Y.Z" - fj release asset create vX.Y.Z /tmp/tilfluktsrom-vX.Y.Z-pwa.tar.gz tilfluktsrom-vX.Y.Z-pwa.tar.gz - ``` -6. Install on phone: `adb install -r app/build/outputs/apk/standard/release/app-standard-release.apk` - -## Screenshots - -Use the Android emulator and Maestro to take screenshots for the README and fastlane metadata. - -### Emulator setup -- AVD: `tilfluktsrom` (Pixel 6, API 35, google_apis/x86_64) -- Start headless: `~/android-sdk/emulator/emulator -avd tilfluktsrom -no-window -no-audio -no-boot-anim -gpu swiftshader_indirect &` -- When both a physical device and emulator are connected, use `-s emulator-5554` with adb -- Set fake GPS: `adb -s emulator-5554 emu geo fix ` (note: longitude first) -- Grant permissions before launch: `adb -s emulator-5554 shell pm grant no.naiv.tilfluktsrom android.permission.ACCESS_FINE_LOCATION` -- Always cache map tiles ("Lagre kart") — never skip caching for screenshots - -### Maestro (v2.3.0) -- Installed at `~/.maestro/bin/maestro` -- Use Maestro flows for repeatable screenshot sequences instead of manual `adb shell input tap` coordinates -- Maestro can target a specific device: `maestro --device emulator-5554 test flow.yaml` -- Place flows in a `maestro/` directory if creating reusable screenshot workflows - -### Screenshot destinations -Screenshots go in all three fastlane locale directories: -- `fastlane/metadata/android/en-US/images/phoneScreenshots/` -- `fastlane/metadata/android/nb-NO/images/phoneScreenshots/` -- `fastlane/metadata/android/nn-NO/images/phoneScreenshots/` - -Current screenshots: -1. `1_map_view.png` — Map with shelter markers -2. `2_shelter_selected.png` — Selected shelter with direction arrow -3. `3_compass_view.png` — Compass navigation with north indicator -4. `4_civil_defense_info.png` — Civil defense instructions -5. `5_about.png` — About page with privacy statement - ## i18n - Default (English): `res/values/strings.xml` - Norwegian Bokmål: `res/values-nb/strings.xml` diff --git a/README.md b/README.md index 2783a9d..8ddb151 100644 --- a/README.md +++ b/README.md @@ -2,35 +2,13 @@ Finn nærmeste offentlige tilfluktsrom i Norge. Appen er bygd for nødsituasjoner og fungerer uten internett etter første gangs bruk. -

- Kartvisning med tilfluktsrom i Bergen sentrum - Valgt tilfluktsrom med avstand og retningspil - Kompassnavigasjon med nordindikator - Sivilforsvarsinfo: hva du skal gjøre om alarmen går - Om-side med personvernerklæring -

- -## Slik fungerer appen - -**Kartvisning** — Appen viser alle 556 offentlige tilfluktsrom i Norge på et OpenStreetMap-kart. De tre nærmeste tilfluktsrommene vises i bunnen med avstand, kapasitet og romnummer. Trykk på en kartmarkering eller et listeelement for å velge et tilfluktsrom. - -**Kompassnavigasjon** — Trykk på kompassknappen for å bytte til retningspil-visning. En stor pil peker mot det valgte tilfluktsrommet, med avstand i meter eller kilometer. En diskret nordindikator vises på kanten slik at du kan verifisere kompasskalibreringen. Fungerer uten internett — bare GPS og kompassensor. - -**Sivilforsvarsinfo** — Trykk på info-knappen for å se trinn-for-trinn-veiledning fra DSB om hva du skal gjøre når alarmen går: viktig melding-signal, flyalarm, finn dekning, lytt til NRK på DAB-radio, og faren over. - -**Om og personvern** — Appen samler ikke inn persondata. Alt skjer lokalt på enheten. Se om-siden i appen for fullstendig personvernerklæring, datakilder og opphavsrett. - ## Funksjoner -- **Finn nærmeste tilfluktsrom** — viser de tre nærmeste med avstand og kapasitet -- **Kompassnavigasjon** — retningspil som peker mot valgt tilfluktsrom, med nordindikator +- **Finn nærmeste tilfluktsrom** — viser de tre nærmeste tilfluktsrommene med avstand og kapasitet +- **Kompassnavigasjon** — retningspil som peker mot valgt tilfluktsrom - **Frakoblet kart** — kartfliser lagres automatisk for bruk uten nett -- **Velg fritt** — trykk på en markering i kartet for å navigere dit -- **Del tilfluktsrom** — send adresse, kapasitet og koordinater til andre -- **Sivilforsvarsinfo** — veiledning fra DSB om hva du skal gjøre når alarmen går -- **Hjemmeskjerm-widget** — viser nærmeste tilfluktsrom uten å åpne appen +- **Velg fritt** — trykk på en hvilken som helst markør i kartet for å navigere dit - **Flerspråklig** — engelsk, bokmål og nynorsk -- **Tilgjengelighet** — TalkBack-støtte, fokusindikatorer og tilstrekkelig kontrast ## Plattformer @@ -56,7 +34,7 @@ Progressiv nettapp med Vite, TypeScript og Leaflet. Kan installeres på alle enh ## Datakilde -Tilfluktsromdata er offentlig informasjon fra [DSB](https://www.dsb.no/) (Direktoratet for samfunnssikkerhet og beredskap), distribuert via [Geonorge](https://www.geonorge.no/) som GeoJSON i UTM33N-projeksjon (EPSG:25833). Koordinatene konverteres til WGS84 (bredde-/lengdegrad) for visning i kartet. +Tilfluktsromdata lastes ned fra [Geonorge](https://www.geonorge.no/) som GeoJSON i UTM33N-projeksjon (EPSG:25833). Koordinatene konverteres til WGS84 (bredde-/lengdegrad) for visning i kartet. Datasettet inneholder ca. 556 offentlige tilfluktsrom med adresse, romnummer og kapasitet (antall plasser). @@ -69,8 +47,7 @@ tilfluktsrom/ │ ├── java/.../ │ │ ├── data/ # Room-database, nedlasting, GeoJSON-parser │ │ ├── location/ # GPS, nærmeste tilfluktsrom -│ │ ├── ui/ # Retningspil, liste-adapter, om-dialog -│ │ ├── widget/ # Hjemmeskjerm-widget +│ │ ├── ui/ # Retningspil, liste-adapter │ │ └── util/ # UTM→WGS84-konvertering, avstandsberegning │ └── res/ # Layout, strenger (en/nb/nn), ikoner ├── pwa/ # Nettapp (TypeScript) @@ -95,16 +72,10 @@ Appen er designet etter «offline-first»-prinsippet: ## Sikkerhet -- All nettverkstrafikk går over HTTPS (klartekst er deaktivert) -- Content Security Policy (CSP) i PWA-versjonen +- All nettverkstrafikk går over HTTPS - Tilfluktsromdata valideres ved parsing (koordinater innenfor Norge, gyldige felt) - Databaseoppdateringer er atomiske (transaksjon) for å unngå datatap -- Lagret GPS-posisjon utløper automatisk etter 24 timer -- Egendefinert User-Agent forhindrer enhetsfingeravtrykk - -## Personvern - -Appen samler ikke inn, sender eller deler noen personopplysninger. Det finnes ingen analyse, sporing eller tredjepartstjenester. GPS-posisjonen brukes bare lokalt på enheten for å finne nærmeste tilfluktsrom, og sendes aldri til noen server. Se om-siden i appen for fullstendig personvernerklæring. +- Ingen persondata lagres — kun tilfluktsromdata og kartfliser ## Opphavsrett @@ -115,7 +86,3 @@ Copyright (c) Ole-Morten Duesund Kildekoden er lisensiert under [Mozilla Public License 2.0](LICENSE). Appen bruker åpne data og tjenester fra flere kilder. Se [SOURCES.md](SOURCES.md) for en fullstendig oversikt over datakilder, URL-er og lisenser. - -## Se også - -- [STANDING_ON_SHOULDERS.md](STANDING_ON_SHOULDERS.md) — estimat over de ~119 000 menneskene som har gjort denne appen mulig diff --git a/STANDING_ON_SHOULDERS.md b/STANDING_ON_SHOULDERS.md index f61b1e2..e33c1bd 100644 --- a/STANDING_ON_SHOULDERS.md +++ b/STANDING_ON_SHOULDERS.md @@ -5,107 +5,96 @@ Tilfluktsrom is a small Android app — about 900 source files — that helps Norwegians find their nearest public shelter in an emergency. One person built it in under a day. But that was only possible because of the accumulated work of -roughly **119,000 people**, spanning decades, countries, and disciplines. +roughly **100,000–120,000 identifiable people**, spanning decades, countries, and +disciplines. -This document traces the human effort behind every layer of the stack, with -sources for each estimate. - -```mermaid -pie title People behind each layer - "Map data (OSM)" : 50000 - "Linux kernel" : 20000 - "Physical infrastructure" : 10500 - "Build tools & dev infra" : 6700 - "AI-assisted development" : 6000 - "Shelter data & builders" : 6000 - "Internet & standards" : 5250 - "OS & runtimes (excl. Linux)" : 3800 - "Libraries (Android)" : 2100 - "Libraries (PWA)" : 1750 - "Hosting & distribution" : 5700 - "Programming languages" : 1600 -``` +This document traces the human effort behind every layer of the stack. --- ## Layer 0: Physical Infrastructure — GPS & Sensors (~10,500 people) -| Component | Role | Est. people | Source | -|---|---|---|---| -| GPS constellation | 31 satellites, maintained by US Space Force | ~5,000 | Industry estimate; [GPS.gov](https://www.gps.gov/) | -| Magnetometer/compass sensors | Enable the direction arrow to point at shelters | ~500 | Industry estimate | -| ARM architecture | The CPU instruction set running every Android device | ~5,000 | [Arm had 8,330 employees in 2025](https://www.macrotrends.net/stocks/charts/ARM/arm-holdings/number-of-employees); ~5,000 estimated over the architecture's 40-year history | +| Component | Role | Est. people | +|---|---|---| +| GPS constellation | 31 satellites, maintained by US Space Force | ~5,000 | +| Magnetometer/compass sensors | Enable the direction arrow to point at shelters | ~500 | +| ARM architecture | The CPU instruction set running every Android device | ~5,000 | Before a single line of code runs, hardware designed by tens of thousands of engineers must be in orbit, in your pocket, and on the circuit board. ## Layer 1: Internet & Standards (~5,250 people) -| Component | Role | Est. people | Source | -|---|---|---|---| -| TCP/IP, DNS, HTTP, TLS | The protocols that carry shelter data from server to phone | ~5,000 | Cumulative IETF/W3C contributors over decades | -| GeoJSON specification | The format the shelter data is published in (IETF RFC 7946) | ~50 | [RFC 7946 authors + WG](https://datatracker.ietf.org/doc/html/rfc7946) | -| EPSG / coordinate reference systems | The math behind UTM33N → WGS84 coordinate conversion | ~200 | [IOGP Geomatics Committee](https://epsg.org/) | +| Component | Role | Est. people | +|---|---|---| +| TCP/IP, DNS, HTTP, TLS | The protocols that carry shelter data from server to phone | ~5,000 | +| GeoJSON specification | The format the shelter data is published in (IETF RFC 7946) | ~50 | +| EPSG / coordinate reference systems | The math behind UTM33N → WGS84 coordinate conversion | ~200 | -## Layer 2: Operating Systems & Runtimes (~23,800 people) +## Layer 2: Operating Systems & Runtimes (~27,200 people) -| Component | Role | Est. people | Source | -|---|---|---|---| -| Linux kernel | Foundation of Android | ~20,000 | [Linux Foundation: ~20,000+ unique contributors since 2005](https://www.linuxfoundation.org/blog/blog/2017-linux-kernel-report-highlights-developers-roles-accelerating-pace-change) | -| Android (AOSP) | Mobile OS, incl. ART runtime | ~2,000 | [ResearchGate study: ~1,563 contributors](https://www.researchgate.net/figure/Top-Companies-Contributing-to-Android-Projects_tbl3_236631958); likely higher now | -| OpenJDK | The Java runtime Kotlin compiles to | ~1,800 | [GitHub: ~1,779 contributors](https://github.com/openjdk/jdk) | +| Component | Role | Est. people | +|---|---|---| +| Linux kernel | Foundation of Android; ~20,000 documented unique contributors | ~20,000 | +| Android (AOSP) | The mobile OS, built on Linux by Google + community | ~5,000 | +| JVM / OpenJDK + Java | The language runtime Kotlin compiles to | ~2,000 | +| ART (Android Runtime) | Replaced Dalvik; runs the compiled bytecode | ~200 | -## Layer 3: Programming Languages (~1,600 people) +## Layer 3: Programming Languages (~1,200 people) -| Language | Origin | Contributors | Source | -|---|---|---|---| -| Kotlin | JetBrains + community | ~765 | [GitHub: JetBrains/kotlin](https://github.com/JetBrains/kotlin) | -| TypeScript | Microsoft + community (for the PWA) | ~823 | [GitHub: microsoft/TypeScript](https://github.com/microsoft/TypeScript) | +| Language | Origin | Est. people | +|---|---|---| +| Kotlin | JetBrains (Czech Republic/Russia) + community | ~500 | +| TypeScript | Microsoft + community (for the PWA) | ~500 | +| Groovy / Kotlin DSL | Gradle build scripts | ~200 | -## Layer 4: Build Tools & Dev Infrastructure (~6,700 people) +## Layer 4: Build Tools & Dev Infrastructure (~5,400 people) -| Tool | Role | Contributors | Source | -|---|---|---|---| -| Gradle | Build automation | ~869 | [GitHub: gradle/gradle](https://github.com/gradle/gradle) | -| Android Gradle Plugin | Android-specific build pipeline | ~200 | Google internal; estimate | -| KSP (Kotlin Symbol Processing) | Code generation for Room database | ~100 | Estimate based on [GitHub: google/ksp](https://github.com/google/ksp) | -| R8 / ProGuard | Release minification and optimization | ~100 | Estimate | -| Vite | PWA bundler | ~1,100 | [GitHub: vitejs/vite](https://github.com/vitejs/vite) | -| Bun | Package manager and JS runtime | ~733 | [GitHub: oven-sh/bun](https://github.com/oven-sh/bun) | -| Git | Version control | ~1,820 | [GitHub: git/git](https://github.com/git/git) | -| Android Studio / IntelliJ | IDE | ~1,500 | Estimate; JetBrains + Google | -| Maven Central, Google Maven, npm | Package registry infrastructure | ~300 | Estimate | +| Tool | Role | Est. people | +|---|---|---| +| Gradle | Build automation | ~500 | +| Android Gradle Plugin | Android-specific build pipeline | ~200 | +| KSP (Kotlin Symbol Processing) | Code generation for Room database | ~100 | +| R8 / ProGuard | Release minification and optimization | ~100 | +| Vite | PWA bundler | ~800 | +| Bun | Package manager and JS runtime | ~400 | +| Git | Version control | ~1,500 | +| Android Studio / IntelliJ | IDE (JetBrains + Google) | ~1,500 | +| Maven Central, Google Maven, npm | Package registry infrastructure | ~300 | -## Layer 5: Libraries — Android App (~2,100 people) +## Layer 5: Libraries — Android App (~2,550 people) -| Library | What it does | Contributors | Source | -|---|---|---|---| -| AndroidX (Core, AppCompat, Room, WorkManager, etc.) | UI, architecture, database, scheduling | ~1,000 | [GitHub: androidx/androidx](https://github.com/androidx/androidx) monorepo | -| Material Design Components | Visual design language and components | ~199 | [GitHub: material-components-android](https://github.com/material-components/material-components-android) | -| Kotlinx Coroutines | Async data loading without blocking the UI | ~308 | [GitHub: Kotlin/kotlinx.coroutines](https://github.com/Kotlin/kotlinx.coroutines) | -| OkHttp | Downloads the GeoJSON ZIP from Geonorge | ~287 | [GitHub: square/okhttp](https://github.com/square/okhttp) | -| OSMDroid | Offline OpenStreetMap rendering | ~105 | [GitHub: osmdroid/osmdroid](https://github.com/osmdroid/osmdroid) | -| Play Services Location | FusedLocationProvider for precise GPS | ~200 | Google internal; estimate | -| SQLite | The embedded database engine | **~4** | [sqlite.org/crew.html](https://sqlite.org/crew.html) — the most deployed database in the world, maintained by 3–4 people | +| Library | What it does | Est. people | +|---|---|---| +| AndroidX (Core, AppCompat, Activity, Lifecycle) | UI and app architecture foundation | ~800 | +| Material Design | Visual design language, research, and components | ~500 | +| ConstraintLayout | Flexible screen layouts | ~100 | +| Room | Type-safe SQLite wrapper for the shelter cache | ~200 | +| WorkManager | Periodic home screen widget updates | ~150 | +| Kotlinx Coroutines | Async data loading without blocking the UI | ~200 | +| OkHttp (Square) | Downloads the GeoJSON ZIP from Geonorge | ~200 | +| OSMDroid | Offline OpenStreetMap rendering | ~150 | +| Play Services Location | FusedLocationProvider for precise GPS | ~200 | +| SQLite | The embedded database engine | ~50 | -## Layer 6: Libraries — PWA (~1,750 people) +## Layer 6: Libraries — PWA (~1,350 people) -| Library | Role | Contributors | Source | -|---|---|---|---| -| Leaflet | Interactive web maps (created in Ukraine) | ~865 | [GitHub: Leaflet/Leaflet](https://github.com/Leaflet/Leaflet) | -| leaflet.offline | Offline tile caching | ~20 | Estimate based on GitHub | -| idb | IndexedDB wrapper for offline storage | ~30 | Estimate based on GitHub | -| vite-plugin-pwa | Service worker and Workbox integration | ~100 | Estimate based on GitHub | -| Vitest | Test framework | ~718 | [GitHub: vitest-dev/vitest](https://github.com/vitest-dev/vitest) | +| Library | Role | Est. people | +|---|---|---| +| Leaflet | Interactive web maps (created in Ukraine) | ~800 | +| leaflet.offline | Offline tile caching | ~20 | +| idb | IndexedDB wrapper for offline storage | ~30 | +| vite-plugin-pwa | Service worker and Workbox integration | ~100 | +| Vitest | Test framework | ~400 | ## Layer 7: Data — The Content That Makes It Useful (~56,000 people) -| Source | Role | Est. people | Source link | -|---|---|---|---| -| OpenStreetMap | Global map data | ~50,000 | [~2.25M have ever edited; ~50,000 active monthly](https://wiki.openstreetmap.org/wiki/Stats) | -| Kartverket / Geonorge | Norwegian Mapping Authority; national geodata infrastructure | ~800 | [kartverket.no](https://www.kartverket.no/) | -| DSB | Created and maintains the public shelter registry | ~200 | [dsb.no](https://www.dsb.no/) | -| The shelter builders | Construction, engineering, civil defense planning since the Cold War | ~5,000 | Estimate based on ~556 shelters built 1950s–80s | +| Source | Role | Est. people | +|---|---|---| +| OpenStreetMap | Global map data; ~2M registered mappers, ~10,000+ active in Norway | ~50,000 | +| Kartverket / Geonorge | Norwegian Mapping Authority; national geodata infrastructure | ~800 | +| DSB (Direktoratet for samfunnssikkerhet og beredskap) | Created and maintains the public shelter registry | ~200 | +| The shelter builders | Construction, engineering, civil defense planning since the Cold War | ~5,000+ | The app's data exists because of Cold War civil defense planning. The shelters were built in the 1950s–80s, digitized by DSB, published via Geonorge's open @@ -114,20 +103,18 @@ GeoJSON file. ## Layer 8: AI-Assisted Development (~6,000 people) -| Component | Role | Est. people | Source | -|---|---|---|---| -| Anthropic / Claude | Researchers, engineers, safety team | ~1,000 | [anthropic.com](https://www.anthropic.com/) | -| ML research lineage | Transformers, attention, RLHF, scaling laws — across academia & industry | ~5,000 | Estimate across all contributing institutions | -| Training data | The collective written output of humanity | incalculable | | +| Component | Role | Est. people | +|---|---|---| +| Anthropic / Claude | Researchers, engineers, safety team | ~1,000 | +| ML research lineage | Transformers, attention, RLHF, scaling laws — across academia & industry | ~5,000 | +| Training data | The collective written output of humanity | incalculable | -## Layer 9: Hosting & Distribution (~5,700 people) +## Layer 9: Distribution (~500 people) -| Component | Role | Contributors | Source | -|---|---|---|---| -| Forgejo / Gitea | Hosts this project at kode.naiv.no; Forgejo forked from Gitea in 2022 | ~800 | [Forgejo: ~230 contributors](https://codeberg.org/forgejo/forgejo); [Gitea: go-gitea/gitea](https://github.com/go-gitea/gitea) | -| GitHub | Mirror repo + hosts nearly all upstream dependencies | ~3,000 | [~5,000 employees, ~50% engineers](https://kinsta.com/blog/github-statistics/) | -| F-Droid | Open-source app store infrastructure and review | ~150 | [GitLab: fdroid](https://gitlab.com/fdroid); estimate | -| Fastlane | Metadata and screenshot tooling | ~1,524 | [GitHub: fastlane/fastlane](https://github.com/fastlane/fastlane) | +| Component | Role | Est. people | +|---|---|---| +| F-Droid | Open-source app store infrastructure and review | ~300 | +| Fastlane | Metadata and screenshot tooling | ~200 | --- @@ -137,15 +124,15 @@ GeoJSON file. |---|---| | Physical infrastructure (GPS, ARM, sensors) | ~10,500 | | Internet & standards | ~5,250 | -| Operating systems & runtimes | ~23,800 | -| Programming languages | ~1,600 | -| Build tools & dev infrastructure | ~6,700 | -| Direct libraries (Android) | ~2,100 | -| Direct libraries (PWA) | ~1,750 | +| Operating systems & runtimes | ~27,200 | +| Programming languages | ~1,200 | +| Build tools & dev infrastructure | ~5,400 | +| Direct libraries (Android) | ~2,550 | +| Direct libraries (PWA) | ~1,350 | | Data (maps, shelters, geodesy) | ~56,000 | | AI-assisted development | ~6,000 | -| Hosting & distribution | ~5,700 | -| **Conservative total** | **~119,000** | +| Distribution | ~500 | +| **Conservative total** | **~116,000** | This is conservative. It excludes: @@ -160,26 +147,9 @@ Including OpenStreetMap's full contributor base and hardware, the number crosses --- -## Notable details - -- **SQLite** — the most widely deployed database engine in the world (in every - phone, browser, and operating system) — is maintained by - [3–4 people](https://sqlite.org/crew.html). It powers every shelter lookup - in this app. - -- **Leaflet** — the JavaScript mapping library used by the PWA — was created by - [Volodymyr Agafonkin](https://agafonkin.com/) in Kyiv, Ukraine. An emergency - shelter app built with a mapping library from a country at war. - -- **OpenStreetMap** has ~10 million registered accounts, but only ~50,000 are - active in any given month. The map tiles this app displays are the work of a - dedicated minority. - ---- - ## Perspective -For every line of application code, roughly 119,000 people made the tools, data, +For every line of application code, roughly 100,000 people made the tools, data, and infrastructure that line depends on. No single company, country, or organization could have built this stack alone. Linux (Finland → global), Kotlin (Czech Republic/Russia → JetBrains), OSM (UK → global), GPS (US military → diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5992c73..d905c26 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -6,6 +6,11 @@ plugins { id("com.google.devtools.ksp") } +// Read version from shared version.properties +val versionProps = Properties().apply { + rootProject.file("version.properties").inputStream().use { load(it) } +} + android { namespace = "no.naiv.tilfluktsrom" compileSdk = 35 @@ -14,45 +19,27 @@ android { applicationId = "no.naiv.tilfluktsrom" minSdk = 26 targetSdk = 35 - versionCode = 12 - versionName = "1.8.0" + versionCode = versionProps.getProperty("versionCode").toInt() + versionName = "${versionProps.getProperty("versionMajor")}." + + "${versionProps.getProperty("versionMinor")}." + + versionProps.getProperty("versionPatch") - // Deep link domain — single source of truth for manifest + Kotlin code - val deepLinkDomain = "tilfluktsrom.naiv.no" - buildConfigField("String", "DEEP_LINK_DOMAIN", "\"$deepLinkDomain\"") - manifestPlaceholders["deepLinkHost"] = deepLinkDomain - } - - dependenciesInfo { - includeInApk = false - includeInBundle = false + // Make version available in BuildConfig + buildConfigField("String", "VERSION_DISPLAY", "\"$versionName\"") } signingConfigs { create("release") { - val keystorePropsFile = rootProject.file("keystore.properties") - if (keystorePropsFile.exists()) { - val keystoreProps = Properties().apply { - keystorePropsFile.inputStream().use { load(it) } - } - storeFile = file(keystoreProps.getProperty("storeFile")) - storePassword = keystoreProps.getProperty("storePassword") - keyAlias = keystoreProps.getProperty("keyAlias") - keyPassword = keystoreProps.getProperty("keyPassword") + val keystorePath = System.getProperty("user.home") + "/.android/tilfluktsrom-release.jks" + if (file(keystorePath).exists()) { + storeFile = file(keystorePath) + storePassword = "tilfluktsrom" + keyAlias = "tilfluktsrom" + keyPassword = "tilfluktsrom" } } } - flavorDimensions += "distribution" - productFlavors { - create("standard") { - dimension = "distribution" - } - create("fdroid") { - dimension = "distribution" - } - } - buildTypes { release { isMinifyEnabled = true @@ -101,8 +88,8 @@ dependencies { // OSMDroid (offline-capable OpenStreetMap) implementation("org.osmdroid:osmdroid-android:6.1.20") - // Google Play Services Location (precise GPS) — standard flavor only - "standardImplementation"("com.google.android.gms:play-services-location:21.3.0") + // Google Play Services Location (precise GPS) + implementation("com.google.android.gms:play-services-location:21.3.0") // WorkManager (periodic widget updates) implementation("androidx.work:work-runtime-ktx:2.9.1") diff --git a/app/src/fdroid/java/no/naiv/tilfluktsrom/location/LocationProvider.kt b/app/src/fdroid/java/no/naiv/tilfluktsrom/location/LocationProvider.kt deleted file mode 100644 index 87e19af..0000000 --- a/app/src/fdroid/java/no/naiv/tilfluktsrom/location/LocationProvider.kt +++ /dev/null @@ -1,119 +0,0 @@ -package no.naiv.tilfluktsrom.location - -import android.Manifest -import android.content.Context -import android.content.pm.PackageManager -import android.location.Location -import android.location.LocationListener -import android.location.LocationManager -import android.os.Bundle -import android.os.Looper -import android.util.Log -import androidx.core.content.ContextCompat -import kotlinx.coroutines.channels.awaitClose -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.callbackFlow - -/** - * Provides GPS location updates using AOSP LocationManager. - * - * F-Droid flavor: no Google Play Services dependency. Uses GPS + Network providers - * directly via LocationManager (available on all Android 8.0+ devices). - */ -class LocationProvider(private val context: Context) { - - companion object { - private const val TAG = "LocationProvider" - private const val UPDATE_INTERVAL_MS = 5000L - } - - init { - Log.d(TAG, "Location backend: LocationManager (F-Droid build)") - } - - /** - * Stream of location updates. Emits the last known location first (if available), - * then continuous updates. Throws SecurityException if permission is not granted. - */ - fun locationUpdates(): Flow = callbackFlow { - if (!hasLocationPermission()) { - close(SecurityException("Location permission not granted")) - return@callbackFlow - } - - val locationManager = context.getSystemService(Context.LOCATION_SERVICE) - as? LocationManager - - if (locationManager == null) { - close(IllegalStateException("LocationManager not available")) - return@callbackFlow - } - - // Emit best last known location immediately (pick most recent of GPS/Network) - try { - val lastGps = locationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER) - val lastNetwork = locationManager.getLastKnownLocation(LocationManager.NETWORK_PROVIDER) - val best = listOfNotNull(lastGps, lastNetwork).maxByOrNull { it.time } - if (best != null) { - val result = trySend(best) - if (result.isFailure) { - Log.w(TAG, "Failed to emit last known location") - } - } - } catch (e: SecurityException) { - Log.e(TAG, "SecurityException getting last known location", e) - } - - // LocationListener compatible with API 26-28 (onStatusChanged required before API 29) - val listener = object : LocationListener { - override fun onLocationChanged(location: Location) { - val sendResult = trySend(location) - if (sendResult.isFailure) { - Log.w(TAG, "Failed to emit location update") - } - } - - // Required for API 26-28 compatibility (deprecated from API 29+) - @Deprecated("Deprecated in API 29") - override fun onStatusChanged(provider: String?, status: Int, extras: Bundle?) {} - override fun onProviderEnabled(provider: String) {} - override fun onProviderDisabled(provider: String) {} - } - - try { - // Request from both providers: GPS is accurate, Network gives faster first fix - if (locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER)) { - locationManager.requestLocationUpdates( - LocationManager.GPS_PROVIDER, - UPDATE_INTERVAL_MS, - 0f, - listener, - Looper.getMainLooper() - ) - } - if (locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER)) { - locationManager.requestLocationUpdates( - LocationManager.NETWORK_PROVIDER, - UPDATE_INTERVAL_MS, - 0f, - listener, - Looper.getMainLooper() - ) - } - } catch (e: SecurityException) { - Log.e(TAG, "SecurityException requesting location updates", e) - close(e) - return@callbackFlow - } - - awaitClose { - locationManager.removeUpdates(listener) - } - } - - fun hasLocationPermission(): Boolean { - return ContextCompat.checkSelfPermission( - context, Manifest.permission.ACCESS_FINE_LOCATION - ) == PackageManager.PERMISSION_GRANTED - } -} diff --git a/app/src/fdroid/java/no/naiv/tilfluktsrom/widget/ShelterWidgetProvider.kt b/app/src/fdroid/java/no/naiv/tilfluktsrom/widget/ShelterWidgetProvider.kt deleted file mode 100644 index 1f42882..0000000 --- a/app/src/fdroid/java/no/naiv/tilfluktsrom/widget/ShelterWidgetProvider.kt +++ /dev/null @@ -1,268 +0,0 @@ -package no.naiv.tilfluktsrom.widget - -import android.Manifest -import android.app.PendingIntent -import android.appwidget.AppWidgetManager -import android.appwidget.AppWidgetProvider -import android.content.ComponentName -import android.content.Context -import android.content.Intent -import android.content.pm.PackageManager -import android.location.Location -import android.location.LocationManager -import android.os.Build -import android.os.CancellationSignal -import android.text.format.DateFormat -import android.util.Log -import android.widget.RemoteViews -import androidx.core.content.ContextCompat -import no.naiv.tilfluktsrom.MainActivity -import no.naiv.tilfluktsrom.R -import no.naiv.tilfluktsrom.data.ShelterDatabase -import no.naiv.tilfluktsrom.location.ShelterFinder -import no.naiv.tilfluktsrom.util.DistanceUtils -import java.util.concurrent.TimeUnit - -/** - * Home screen widget showing the nearest shelter with distance. - * - * F-Droid flavor: uses LocationManager only (no Google Play Services). - */ -class ShelterWidgetProvider : AppWidgetProvider() { - - companion object { - private const val TAG = "ShelterWidget" - const val ACTION_REFRESH = "no.naiv.tilfluktsrom.widget.REFRESH" - private const val EXTRA_LATITUDE = "lat" - private const val EXTRA_LONGITUDE = "lon" - - fun requestUpdate(context: Context) { - val intent = Intent(context, ShelterWidgetProvider::class.java).apply { - action = ACTION_REFRESH - } - context.sendBroadcast(intent) - } - - fun requestUpdateWithLocation(context: Context, latitude: Double, longitude: Double) { - val intent = Intent(context, ShelterWidgetProvider::class.java).apply { - action = ACTION_REFRESH - putExtra(EXTRA_LATITUDE, latitude) - putExtra(EXTRA_LONGITUDE, longitude) - } - context.sendBroadcast(intent) - } - } - - override fun onEnabled(context: Context) { - super.onEnabled(context) - WidgetUpdateWorker.schedule(context) - } - - override fun onDisabled(context: Context) { - super.onDisabled(context) - WidgetUpdateWorker.cancel(context) - } - - override fun onUpdate( - context: Context, - appWidgetManager: AppWidgetManager, - appWidgetIds: IntArray - ) { - WidgetUpdateWorker.schedule(context) - updateAllWidgetsAsync(context, null) - } - - override fun onReceive(context: Context, intent: Intent) { - super.onReceive(context, intent) - - if (intent.action == ACTION_REFRESH) { - val providedLocation = if (intent.hasExtra(EXTRA_LATITUDE)) { - Location("widget").apply { - latitude = intent.getDoubleExtra(EXTRA_LATITUDE, 0.0) - longitude = intent.getDoubleExtra(EXTRA_LONGITUDE, 0.0) - } - } else null - - updateAllWidgetsAsync(context, providedLocation) - } - } - - private fun updateAllWidgetsAsync(context: Context, providedLocation: Location?) { - val pendingResult = goAsync() - Thread { - try { - val appWidgetManager = AppWidgetManager.getInstance(context) - val widgetIds = appWidgetManager.getAppWidgetIds( - ComponentName(context, ShelterWidgetProvider::class.java) - ) - val location = providedLocation ?: getBestLocation(context) - for (appWidgetId in widgetIds) { - updateWidget(context, appWidgetManager, appWidgetId, location) - } - } catch (e: Exception) { - Log.e(TAG, "Failed to update widgets", e) - } finally { - pendingResult.finish() - } - }.start() - } - - private fun updateWidget( - context: Context, - appWidgetManager: AppWidgetManager, - appWidgetId: Int, - location: Location? - ) { - val views = RemoteViews(context.packageName, R.layout.widget_nearest_shelter) - - val openAppIntent = Intent(context, MainActivity::class.java) - val openAppPending = PendingIntent.getActivity( - context, 0, openAppIntent, PendingIntent.FLAG_IMMUTABLE - ) - views.setOnClickPendingIntent(R.id.widgetRoot, openAppPending) - - val refreshIntent = Intent(context, ShelterWidgetProvider::class.java).apply { - action = ACTION_REFRESH - } - val refreshPending = PendingIntent.getBroadcast( - context, 0, refreshIntent, PendingIntent.FLAG_IMMUTABLE - ) - views.setOnClickPendingIntent(R.id.widgetRefreshButton, refreshPending) - - if (ContextCompat.checkSelfPermission( - context, Manifest.permission.ACCESS_FINE_LOCATION - ) != PackageManager.PERMISSION_GRANTED - ) { - showFallback(context, views, context.getString(R.string.widget_open_app)) - appWidgetManager.updateAppWidget(appWidgetId, views) - return - } - - if (location == null) { - showFallback(context, views, context.getString(R.string.widget_no_location)) - appWidgetManager.updateAppWidget(appWidgetId, views) - return - } - - val shelters = try { - val dao = ShelterDatabase.getInstance(context).shelterDao() - kotlinx.coroutines.runBlocking { dao.getAllSheltersList() } - } catch (e: Exception) { - Log.e(TAG, "Failed to query shelters", e) - emptyList() - } - - if (shelters.isEmpty()) { - showFallback(context, views, context.getString(R.string.widget_no_data)) - appWidgetManager.updateAppWidget(appWidgetId, views) - return - } - - val nearest = ShelterFinder.findNearest( - shelters, location.latitude, location.longitude, 1 - ).firstOrNull() - - if (nearest == null) { - showFallback(context, views, context.getString(R.string.widget_no_data)) - appWidgetManager.updateAppWidget(appWidgetId, views) - return - } - - views.setTextViewText(R.id.widgetAddress, nearest.shelter.adresse) - views.setTextViewText( - R.id.widgetDetails, - context.getString(R.string.shelter_capacity, nearest.shelter.plasser) - ) - views.setTextViewText( - R.id.widgetDistance, - DistanceUtils.formatDistance(nearest.distanceMeters) - ) - views.setTextViewText(R.id.widgetTimestamp, formatTimestamp(context)) - - appWidgetManager.updateAppWidget(appWidgetId, views) - } - - private fun showFallback(context: Context, views: RemoteViews, message: String) { - views.setTextViewText(R.id.widgetAddress, message) - views.setTextViewText(R.id.widgetDetails, "") - views.setTextViewText(R.id.widgetDistance, "") - views.setTextViewText(R.id.widgetTimestamp, formatTimestamp(context)) - } - - private fun formatTimestamp(context: Context): String { - val format = DateFormat.getTimeFormat(context) - val timeStr = format.format(System.currentTimeMillis()) - return context.getString(R.string.widget_updated_at, timeStr) - } - - /** - * Get the best available location via LocationManager or SharedPreferences. - * Safe to call from a background thread. - */ - private fun getBestLocation(context: Context): Location? { - if (ContextCompat.checkSelfPermission( - context, Manifest.permission.ACCESS_FINE_LOCATION - ) != PackageManager.PERMISSION_GRANTED - ) return null - - val lmLocation = getLocationManagerLocation(context) - if (lmLocation != null) return lmLocation - - return getSavedLocation(context) - } - - /** Returns null if older than 24 hours to avoid retaining stale location data. */ - private fun getSavedLocation(context: Context): Location? { - val prefs = context.getSharedPreferences("widget_prefs", Context.MODE_PRIVATE) - if (!prefs.contains("last_lat")) return null - val age = System.currentTimeMillis() - prefs.getLong("last_time", 0L) - if (age > 24 * 60 * 60 * 1000L) return null - return Location("saved").apply { - latitude = prefs.getFloat("last_lat", 0f).toDouble() - longitude = prefs.getFloat("last_lon", 0f).toDouble() - } - } - - private fun getLocationManagerLocation(context: Context): Location? { - val locationManager = context.getSystemService(Context.LOCATION_SERVICE) - as? LocationManager ?: return null - - try { - val lastGps = locationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER) - val lastNetwork = locationManager.getLastKnownLocation(LocationManager.NETWORK_PROVIDER) - val cached = listOfNotNull(lastGps, lastNetwork).maxByOrNull { it.time } - if (cached != null) return cached - } catch (e: SecurityException) { - Log.e(TAG, "SecurityException getting last known location", e) - return null - } - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - val provider = when { - locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER) -> - LocationManager.NETWORK_PROVIDER - locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER) -> - LocationManager.GPS_PROVIDER - else -> return null - } - try { - val latch = java.util.concurrent.CountDownLatch(1) - var result: Location? = null - val signal = CancellationSignal() - locationManager.getCurrentLocation( - provider, signal, context.mainExecutor - ) { location -> - result = location - latch.countDown() - } - latch.await(10, TimeUnit.SECONDS) - signal.cancel() - return result - } catch (e: Exception) { - Log.e(TAG, "Active location request failed", e) - } - } - - return null - } -} diff --git a/app/src/fdroid/java/no/naiv/tilfluktsrom/widget/WidgetUpdateWorker.kt b/app/src/fdroid/java/no/naiv/tilfluktsrom/widget/WidgetUpdateWorker.kt deleted file mode 100644 index 8d482f4..0000000 --- a/app/src/fdroid/java/no/naiv/tilfluktsrom/widget/WidgetUpdateWorker.kt +++ /dev/null @@ -1,136 +0,0 @@ -package no.naiv.tilfluktsrom.widget - -import android.Manifest -import android.content.Context -import android.content.pm.PackageManager -import android.location.Location -import android.location.LocationManager -import android.os.Build -import android.os.CancellationSignal -import android.util.Log -import androidx.core.content.ContextCompat -import androidx.work.CoroutineWorker -import androidx.work.ExistingPeriodicWorkPolicy -import androidx.work.OneTimeWorkRequestBuilder -import androidx.work.PeriodicWorkRequestBuilder -import androidx.work.WorkManager -import androidx.work.WorkerParameters -import kotlinx.coroutines.suspendCancellableCoroutine -import kotlinx.coroutines.withTimeoutOrNull -import java.util.concurrent.TimeUnit -import kotlin.coroutines.resume - -/** - * Periodic background worker that refreshes the home screen widget. - * - * F-Droid flavor: uses LocationManager only (no Google Play Services). - */ -class WidgetUpdateWorker( - context: Context, - params: WorkerParameters -) : CoroutineWorker(context, params) { - - companion object { - private const val TAG = "WidgetUpdateWorker" - private const val WORK_NAME = "widget_update" - private const val LOCATION_TIMEOUT_MS = 10_000L - - fun schedule(context: Context) { - val request = PeriodicWorkRequestBuilder( - 15, TimeUnit.MINUTES - ).build() - - WorkManager.getInstance(context).enqueueUniquePeriodicWork( - WORK_NAME, - ExistingPeriodicWorkPolicy.KEEP, - request - ) - } - - fun runOnce(context: Context) { - val request = OneTimeWorkRequestBuilder().build() - WorkManager.getInstance(context).enqueue(request) - } - - fun cancel(context: Context) { - WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME) - } - } - - override suspend fun doWork(): Result { - val location = requestFreshLocation() ?: getSavedLocation() - if (location != null) { - ShelterWidgetProvider.requestUpdateWithLocation( - applicationContext, location.latitude, location.longitude - ) - } else { - ShelterWidgetProvider.requestUpdate(applicationContext) - } - return Result.success() - } - - /** Returns null if older than 24 hours. */ - private fun getSavedLocation(): Location? { - val prefs = applicationContext.getSharedPreferences("widget_prefs", Context.MODE_PRIVATE) - if (!prefs.contains("last_lat")) return null - val age = System.currentTimeMillis() - prefs.getLong("last_time", 0L) - if (age > 24 * 60 * 60 * 1000L) return null - return Location("saved").apply { - latitude = prefs.getFloat("last_lat", 0f).toDouble() - longitude = prefs.getFloat("last_lon", 0f).toDouble() - } - } - - private suspend fun requestFreshLocation(): Location? { - val context = applicationContext - if (ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) - != PackageManager.PERMISSION_GRANTED - ) return null - - return requestViaLocationManager() - } - - private suspend fun requestViaLocationManager(): Location? { - val locationManager = applicationContext.getSystemService(Context.LOCATION_SERVICE) - as? LocationManager ?: return null - - val provider = when { - locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER) -> - LocationManager.GPS_PROVIDER - locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER) -> - LocationManager.NETWORK_PROVIDER - else -> return null - } - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - return requestCurrentLocation(locationManager, provider) - } - // API 26-29: fall back to passive cache - return try { - locationManager.getLastKnownLocation(provider) - } catch (e: SecurityException) { - null - } - } - - private suspend fun requestCurrentLocation(locationManager: LocationManager, provider: String): Location? { - return try { - withTimeoutOrNull(LOCATION_TIMEOUT_MS) { - suspendCancellableCoroutine { cont -> - val signal = CancellationSignal() - locationManager.getCurrentLocation( - provider, - signal, - applicationContext.mainExecutor - ) { location -> - if (cont.isActive) cont.resume(location) - } - cont.invokeOnCancellation { signal.cancel() } - } - } - } catch (e: SecurityException) { - Log.e(TAG, "SecurityException requesting location via LocationManager", e) - null - } - } -} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index e522483..58bf2ed 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -29,14 +29,13 @@ - + + android:scheme="tilfluktsrom" + android:host="shelter" /> diff --git a/app/src/main/java/no/naiv/tilfluktsrom/MainActivity.kt b/app/src/main/java/no/naiv/tilfluktsrom/MainActivity.kt index 5f80847..2f11f27 100644 --- a/app/src/main/java/no/naiv/tilfluktsrom/MainActivity.kt +++ b/app/src/main/java/no/naiv/tilfluktsrom/MainActivity.kt @@ -143,14 +143,12 @@ class MainActivity : AppCompatActivity(), SensorEventListener { } /** - * Handle https://{domain}/shelter/{lokalId} deep link. + * Handle tilfluktsrom://shelter/{lokalId} deep link. * If shelters are already loaded, select immediately; otherwise store as pending. */ private fun handleDeepLinkIntent(intent: Intent?) { val uri = intent?.data ?: return - if (uri.scheme != "https" || - uri.host != BuildConfig.DEEP_LINK_DOMAIN || - uri.path?.startsWith("/shelter/") != true) return + if (uri.scheme != "tilfluktsrom" || uri.host != "shelter") return val lokalId = uri.lastPathSegment ?: return // Clear intent data so config changes don't re-trigger @@ -524,11 +522,10 @@ class MainActivity : AppCompatActivity(), SensorEventListener { R.string.direction_arrow_description, distanceText ) - // Update compass view (large arrow gets a north indicator) + // Update compass view binding.compassDistanceText.text = distanceText binding.compassAddressText.text = selected.shelter.adresse binding.directionArrow.setDirection(arrowAngle) - binding.directionArrow.setNorthAngle(-deviceHeading) binding.directionArrow.contentDescription = getString( R.string.direction_arrow_description, distanceText ) @@ -671,8 +668,8 @@ class MainActivity : AppCompatActivity(), SensorEventListener { /** * Share the currently selected shelter via ACTION_SEND. - * Includes address, capacity, geo: URI, and an HTTPS deep link - * that opens the app (if installed) or the PWA (in browser). + * Includes address, capacity, geo: URI (for non-app recipients), + * and a tilfluktsrom:// deep link (for app users). */ private fun shareShelter() { val selected = selectedShelter @@ -682,14 +679,12 @@ class MainActivity : AppCompatActivity(), SensorEventListener { } val shelter = selected.shelter - val deepLink = "https://${BuildConfig.DEEP_LINK_DOMAIN}/shelter/${shelter.lokalId}" val body = getString( R.string.share_body, shelter.adresse, shelter.plasser, shelter.latitude, - shelter.longitude, - deepLink + shelter.longitude ) val shareIntent = Intent(Intent.ACTION_SEND).apply { @@ -845,7 +840,6 @@ class MainActivity : AppCompatActivity(), SensorEventListener { val arrowAngle = bearing - deviceHeading binding.directionArrow.setDirection(arrowAngle) - binding.directionArrow.setNorthAngle(-deviceHeading) binding.miniArrow.setDirection(arrowAngle) } diff --git a/app/src/main/java/no/naiv/tilfluktsrom/data/ShelterRepository.kt b/app/src/main/java/no/naiv/tilfluktsrom/data/ShelterRepository.kt index 048a93d..4298c39 100644 --- a/app/src/main/java/no/naiv/tilfluktsrom/data/ShelterRepository.kt +++ b/app/src/main/java/no/naiv/tilfluktsrom/data/ShelterRepository.kt @@ -7,7 +7,6 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.withContext -import okhttp3.Interceptor import okhttp3.OkHttpClient import okhttp3.Request import org.json.JSONArray @@ -44,11 +43,6 @@ class ShelterRepository(private val context: Context) { private val client = OkHttpClient.Builder() .connectTimeout(30, TimeUnit.SECONDS) .readTimeout(60, TimeUnit.SECONDS) - .addInterceptor(Interceptor { chain -> - chain.proceed(chain.request().newBuilder() - .header("User-Agent", "Tilfluktsrom/1.8.0") - .build()) - }) .build() /** Reactive stream of all shelters from local cache. */ diff --git a/app/src/standard/java/no/naiv/tilfluktsrom/location/LocationProvider.kt b/app/src/main/java/no/naiv/tilfluktsrom/location/LocationProvider.kt similarity index 100% rename from app/src/standard/java/no/naiv/tilfluktsrom/location/LocationProvider.kt rename to app/src/main/java/no/naiv/tilfluktsrom/location/LocationProvider.kt diff --git a/app/src/main/java/no/naiv/tilfluktsrom/ui/AboutDialog.kt b/app/src/main/java/no/naiv/tilfluktsrom/ui/AboutDialog.kt deleted file mode 100644 index 712562e..0000000 --- a/app/src/main/java/no/naiv/tilfluktsrom/ui/AboutDialog.kt +++ /dev/null @@ -1,43 +0,0 @@ -package no.naiv.tilfluktsrom.ui - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.view.WindowManager -import androidx.fragment.app.DialogFragment -import no.naiv.tilfluktsrom.R - -/** - * Full-screen dialog showing app info, privacy statement, and copyright. - * Static content — all text comes from string resources for offline use and i18n. - */ -class AboutDialog : DialogFragment() { - - companion object { - const val TAG = "AboutDialog" - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setStyle(STYLE_NO_TITLE, R.style.Theme_Tilfluktsrom_Dialog) - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - return inflater.inflate(R.layout.dialog_about, container, false) - } - - override fun onStart() { - super.onStart() - dialog?.window?.apply { - setLayout( - WindowManager.LayoutParams.MATCH_PARENT, - WindowManager.LayoutParams.WRAP_CONTENT - ) - } - } -} diff --git a/app/src/main/java/no/naiv/tilfluktsrom/ui/CivilDefenseInfoDialog.kt b/app/src/main/java/no/naiv/tilfluktsrom/ui/CivilDefenseInfoDialog.kt index 053497b..dd5aa2d 100644 --- a/app/src/main/java/no/naiv/tilfluktsrom/ui/CivilDefenseInfoDialog.kt +++ b/app/src/main/java/no/naiv/tilfluktsrom/ui/CivilDefenseInfoDialog.kt @@ -1,5 +1,6 @@ package no.naiv.tilfluktsrom.ui +import android.app.Dialog import android.os.Bundle import android.view.LayoutInflater import android.view.View @@ -31,13 +32,6 @@ class CivilDefenseInfoDialog : DialogFragment() { return inflater.inflate(R.layout.dialog_civil_defense, container, false) } - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - view.findViewById(R.id.aboutLink)?.setOnClickListener { - AboutDialog().show(parentFragmentManager, AboutDialog.TAG) - } - } - override fun onStart() { super.onStart() dialog?.window?.apply { diff --git a/app/src/main/java/no/naiv/tilfluktsrom/ui/DirectionArrowView.kt b/app/src/main/java/no/naiv/tilfluktsrom/ui/DirectionArrowView.kt index aa50e8f..edd3154 100644 --- a/app/src/main/java/no/naiv/tilfluktsrom/ui/DirectionArrowView.kt +++ b/app/src/main/java/no/naiv/tilfluktsrom/ui/DirectionArrowView.kt @@ -17,9 +17,6 @@ import no.naiv.tilfluktsrom.R * rotationAngle = shelterBearing - deviceHeading * This gives the direction the user needs to walk, adjusted for which * way they're currently facing. - * - * Optionally draws a discrete north indicator on the perimeter so users - * can validate compass calibration against a known direction. */ class DirectionArrowView @JvmOverloads constructor( context: Context, @@ -28,7 +25,6 @@ class DirectionArrowView @JvmOverloads constructor( ) : View(context, attrs, defStyleAttr) { private var rotationAngle = 0f - private var northAngle = Float.NaN private val arrowPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = context.getColor(R.color.shelter_primary) @@ -41,18 +37,7 @@ class DirectionArrowView @JvmOverloads constructor( strokeWidth = 4f } - private val northPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { - color = 0x99CFD8DC.toInt() // text_secondary at ~60% opacity - style = Paint.Style.FILL - } - - private val northTextPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { - color = 0x99CFD8DC.toInt() - textAlign = Paint.Align.CENTER - } - private val arrowPath = Path() - private val northPath = Path() /** * Set the rotation angle in degrees. @@ -63,16 +48,6 @@ class DirectionArrowView @JvmOverloads constructor( invalidate() } - /** - * Set the angle to north in the view's coordinate space. - * This is typically -deviceHeading (where north is on screen). - * Set to Float.NaN to hide the north indicator. - */ - fun setNorthAngle(degrees: Float) { - northAngle = degrees - invalidate() - } - override fun onDraw(canvas: Canvas) { super.onDraw(canvas) @@ -80,11 +55,6 @@ class DirectionArrowView @JvmOverloads constructor( val cy = height / 2f val size = minOf(width, height) * 0.4f - // Draw north indicator first (behind the main arrow) - if (!northAngle.isNaN()) { - drawNorthIndicator(canvas, cx, cy, size) - } - canvas.save() canvas.rotate(rotationAngle, cx, cy) @@ -104,32 +74,4 @@ class DirectionArrowView @JvmOverloads constructor( canvas.restore() } - - /** - * Draw a small north indicator: a tiny triangle and "N" label - * placed on the perimeter of the view, pointing inward toward center. - */ - private fun drawNorthIndicator(canvas: Canvas, cx: Float, cy: Float, arrowSize: Float) { - val radius = arrowSize * 1.35f - val tickSize = arrowSize * 0.1f - - // Scale "N" text relative to the view - northTextPaint.textSize = arrowSize * 0.18f - - canvas.save() - canvas.rotate(northAngle, cx, cy) - - // Small triangle at the top of the perimeter circle - northPath.reset() - northPath.moveTo(cx, cy - radius) - northPath.lineTo(cx - tickSize, cy - radius - tickSize * 1.8f) - northPath.lineTo(cx + tickSize, cy - radius - tickSize * 1.8f) - northPath.close() - canvas.drawPath(northPath, northPaint) - - // "N" label just outside the triangle - canvas.drawText("N", cx, cy - radius - tickSize * 2.2f, northTextPaint) - - canvas.restore() - } } diff --git a/app/src/standard/java/no/naiv/tilfluktsrom/widget/ShelterWidgetProvider.kt b/app/src/main/java/no/naiv/tilfluktsrom/widget/ShelterWidgetProvider.kt similarity index 98% rename from app/src/standard/java/no/naiv/tilfluktsrom/widget/ShelterWidgetProvider.kt rename to app/src/main/java/no/naiv/tilfluktsrom/widget/ShelterWidgetProvider.kt index 42a9fdc..119f475 100644 --- a/app/src/standard/java/no/naiv/tilfluktsrom/widget/ShelterWidgetProvider.kt +++ b/app/src/main/java/no/naiv/tilfluktsrom/widget/ShelterWidgetProvider.kt @@ -252,13 +252,10 @@ class ShelterWidgetProvider : AppWidgetProvider() { return getSavedLocation(context) } - /** Read the last GPS fix persisted by MainActivity to SharedPreferences. - * Returns null if older than 24 hours to avoid retaining stale location data. */ + /** Read the last GPS fix persisted by MainActivity to SharedPreferences. */ private fun getSavedLocation(context: Context): Location? { val prefs = context.getSharedPreferences("widget_prefs", Context.MODE_PRIVATE) if (!prefs.contains("last_lat")) return null - val age = System.currentTimeMillis() - prefs.getLong("last_time", 0L) - if (age > 24 * 60 * 60 * 1000L) return null return Location("saved").apply { latitude = prefs.getFloat("last_lat", 0f).toDouble() longitude = prefs.getFloat("last_lon", 0f).toDouble() diff --git a/app/src/standard/java/no/naiv/tilfluktsrom/widget/WidgetUpdateWorker.kt b/app/src/main/java/no/naiv/tilfluktsrom/widget/WidgetUpdateWorker.kt similarity index 96% rename from app/src/standard/java/no/naiv/tilfluktsrom/widget/WidgetUpdateWorker.kt rename to app/src/main/java/no/naiv/tilfluktsrom/widget/WidgetUpdateWorker.kt index 91e5d3b..f89993a 100644 --- a/app/src/standard/java/no/naiv/tilfluktsrom/widget/WidgetUpdateWorker.kt +++ b/app/src/main/java/no/naiv/tilfluktsrom/widget/WidgetUpdateWorker.kt @@ -84,13 +84,10 @@ class WidgetUpdateWorker( return Result.success() } - /** Read the last GPS fix persisted by MainActivity. - * Returns null if older than 24 hours. */ + /** Read the last GPS fix persisted by MainActivity. */ private fun getSavedLocation(): Location? { val prefs = applicationContext.getSharedPreferences("widget_prefs", Context.MODE_PRIVATE) if (!prefs.contains("last_lat")) return null - val age = System.currentTimeMillis() - prefs.getLong("last_time", 0L) - if (age > 24 * 60 * 60 * 1000L) return null return Location("saved").apply { latitude = prefs.getFloat("last_lat", 0f).toDouble() longitude = prefs.getFloat("last_lon", 0f).toDouble() diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 4c8701e..d5c3c30 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -31,15 +31,14 @@ android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" - android:accessibilityLiveRegion="polite" android:textColor="@color/status_text" android:textSize="12sp" tools:text="@string/status_ready" /> @@ -82,7 +80,6 @@ android:layout_width="match_parent" android:layout_height="0dp" android:background="@color/compass_bg" - android:contentDescription="@string/a11y_compass" android:visibility="gone" app:layout_constraintTop_toBottomOf="@id/statusBar" app:layout_constraintBottom_toTopOf="@id/bottomSheet"> @@ -226,8 +223,8 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/dialog_civil_defense.xml b/app/src/main/res/layout/dialog_civil_defense.xml index b4d0691..29333d4 100644 --- a/app/src/main/res/layout/dialog_civil_defense.xml +++ b/app/src/main/res/layout/dialog_civil_defense.xml @@ -114,16 +114,14 @@ android:textSize="12sp" android:textStyle="italic" /> - + + android:layout_height="wrap_content" + android:layout_marginTop="16dp" + android:text="@string/app_copyright" + android:textColor="@color/text_secondary" + android:textSize="11sp" /> diff --git a/app/src/main/res/layout/widget_nearest_shelter.xml b/app/src/main/res/layout/widget_nearest_shelter.xml index 9992358..06e81bb 100644 --- a/app/src/main/res/layout/widget_nearest_shelter.xml +++ b/app/src/main/res/layout/widget_nearest_shelter.xml @@ -65,10 +65,9 @@ diff --git a/app/src/main/res/values-nb/strings.xml b/app/src/main/res/values-nb/strings.xml index 8f0f2d3..bab61b6 100644 --- a/app/src/main/res/values-nb/strings.xml +++ b/app/src/main/res/values-nb/strings.xml @@ -60,15 +60,13 @@ Tilfluktsrom - Tilfluktsrom: %1$s\n%2$d plasser\n%3$.6f, %4$.6f\ngeo:%3$.6f,%4$.6f\n%5$s + Tilfluktsrom: %1$s\n%2$d plasser\n%3$.6f, %4$.6f\ngeo:%3$.6f,%4$.6f Ingen tilfluktsrom valgt Retning til tilfluktsrom, %s unna %1$s, %2$s, %3$d plasser Upresist kompass - %s - Tilfluktsromkart - Kompassnavigasjon Sivilforsvarsinformasjon @@ -85,18 +83,6 @@ Én sammenhengende tone på omtrent 30 sekunder. Faren eller angrepet er over. Fortsett å følge instruksjoner fra myndighetene. Kilde: DSB (Direktoratet for samfunnssikkerhet og beredskap) - - Om Tilfluktsrom - Tilfluktsrom hjelper deg med å finne nærmeste offentlige tilfluktsrom i Norge. Appen er laget for å fungere uten internett etter første oppsett — du trenger ikke nett for å finne tilfluktsrom, navigere med kompass eller dele posisjonen din. - Personvern - Denne appen samler ikke inn, sender eller deler noen personopplysninger. Det finnes ingen analyse, sporing eller tredjepartstjenester.\n\nGPS-posisjonen din brukes bare lokalt på enheten din for å finne tilfluktsrom i nærheten, og sendes aldri til noen server. - Datakilder - Tilfluktsromdata er offentlig informasjon fra DSB (Direktoratet for samfunnssikkerhet og beredskap), distribuert via Geonorge. Kartfliser lastes fra OpenStreetMap. Begge lagres lokalt for frakoblet bruk. - Lagret på enheten din - • Tilfluktsromdatabase (offentlige data fra DSB)\n• Kartfliser for frakoblet bruk\n• Din siste GPS-posisjon (for hjemmeskjerm-widgeten)\n\nIngen data forlater enheten din bortsett fra forespørsler om å laste ned tilfluktsromdata og kartfliser. - Åpen kildekode — kode.naiv.no/olemd/tilfluktsrom - Om denne appen - Opphavsrett © Ole-Morten Duesund diff --git a/app/src/main/res/values-nn/strings.xml b/app/src/main/res/values-nn/strings.xml index 088398a..a4de381 100644 --- a/app/src/main/res/values-nn/strings.xml +++ b/app/src/main/res/values-nn/strings.xml @@ -60,15 +60,13 @@ Tilfluktsrom - Tilfluktsrom: %1$s\n%2$d plassar\n%3$.6f, %4$.6f\ngeo:%3$.6f,%4$.6f\n%5$s + Tilfluktsrom: %1$s\n%2$d plassar\n%3$.6f, %4$.6f\ngeo:%3$.6f,%4$.6f Ingen tilfluktsrom valt Retning til tilfluktsrom, %s unna %1$s, %2$s, %3$d plassar Upresis kompass - %s - Tilfluktsromkart - Kompassnavigasjon Sivilforsvarsinformasjon @@ -85,18 +83,6 @@ Éin samanhengande tone på omtrent 30 sekund. Faren eller åtaket er over. Hald fram med å følgje instruksjonar frå styresmaktene. Kjelde: DSB (Direktoratet for samfunnstryggleik og beredskap) - - Om Tilfluktsrom - Tilfluktsrom hjelper deg med å finne næraste offentlege tilfluktsrom i Noreg. Appen er laga for å fungere utan internett etter fyrste oppsett — du treng ikkje nett for å finne tilfluktsrom, navigere med kompass eller dele posisjonen din. - Personvern - Denne appen samlar ikkje inn, sender eller deler nokon personopplysingar. Det finst ingen analyse, sporing eller tredjepartstenester.\n\nGPS-posisjonen din vert berre brukt lokalt på eininga di for å finne tilfluktsrom i nærleiken, og vert aldri sendt til nokon tenar. - Datakjelder - Tilfluktsromdata er offentleg informasjon frå DSB (Direktoratet for samfunnstryggleik og beredskap), distribuert via Geonorge. Kartfliser vert lasta frå OpenStreetMap. Begge vert lagra lokalt for fråkopla bruk. - Lagra på eininga di - • Tilfluktsromdatabase (offentlege data frå DSB)\n• Kartfliser for fråkopla bruk\n• Din siste GPS-posisjon (for heimeskjerm-widgeten)\n\nIngen data forlèt eininga di bortsett frå førespurnader om å laste ned tilfluktsromdata og kartfliser. - Open kjeldekode — kode.naiv.no/olemd/tilfluktsrom - Om denne appen - Opphavsrett © Ole-Morten Duesund diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 34afc98..43b3945 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -60,15 +60,13 @@ Emergency shelter - Shelter: %1$s\n%2$d places\n%3$.6f, %4$.6f\ngeo:%3$.6f,%4$.6f\n%5$s + Shelter: %1$s\n%2$d places\n%3$.6f, %4$.6f\ngeo:%3$.6f,%4$.6f No shelter selected Direction to shelter, %s away %1$s, %2$s, %3$d places Low accuracy - %s - Shelter map - Compass navigation Civil defense information @@ -85,18 +83,6 @@ One continuous tone lasting approximately 30 seconds. The danger or attack is over. Continue to follow instructions from authorities. Source: DSB (Norwegian Directorate for Civil Protection) - - About Tilfluktsrom - Tilfluktsrom helps you find the nearest public shelter in Norway. The app is designed to work offline after initial setup — no internet required to find shelters, navigate by compass, or share your location. - Privacy - This app does not collect, transmit, or share any personal data. There are no analytics, tracking, or third-party services.\n\nYour GPS location is used only on your device to find nearby shelters and is never sent to any server. - Data sources - Shelter data is public information from DSB (Norwegian Directorate for Civil Protection), distributed via Geonorge. Map tiles are loaded from OpenStreetMap. Both are cached locally for offline use. - Stored on your device - • Shelter database (public data from DSB)\n• Map tiles for offline use\n• Your last GPS position (for the home screen widget)\n\nNo data leaves your device except requests to download shelter data and map tiles. - Open source — kode.naiv.no/olemd/tilfluktsrom - About this app - Copyright © Ole-Morten Duesund diff --git a/fastlane/metadata/android/en-US/changelogs/9.txt b/fastlane/metadata/android/en-US/changelogs/9.txt deleted file mode 100644 index 5c85529..0000000 --- a/fastlane/metadata/android/en-US/changelogs/9.txt +++ /dev/null @@ -1,2 +0,0 @@ -- Add F-Droid build flavor without Google Play Services -- Move signing config out of source code diff --git a/fastlane/metadata/android/en-US/images/icon.png b/fastlane/metadata/android/en-US/images/icon.png deleted file mode 100644 index f156be8..0000000 Binary files a/fastlane/metadata/android/en-US/images/icon.png and /dev/null differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/1_map_view.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/1_map_view.png index 39b32bd..5d9675f 100644 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/1_map_view.png and b/fastlane/metadata/android/en-US/images/phoneScreenshots/1_map_view.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/2_shelter_selected.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/2_shelter_selected.png index c44f0cb..85dcd32 100644 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/2_shelter_selected.png and b/fastlane/metadata/android/en-US/images/phoneScreenshots/2_shelter_selected.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/3_compass_view.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/3_compass_view.png index ea6c599..22f723e 100644 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/3_compass_view.png and b/fastlane/metadata/android/en-US/images/phoneScreenshots/3_compass_view.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/4_civil_defense_info.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/4_civil_defense_info.png index a5d2682..c732755 100644 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/4_civil_defense_info.png and b/fastlane/metadata/android/en-US/images/phoneScreenshots/4_civil_defense_info.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/5_about.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/5_about.png deleted file mode 100644 index 584592e..0000000 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/5_about.png and /dev/null differ diff --git a/fastlane/metadata/android/en-US/short_description.txt b/fastlane/metadata/android/en-US/short_description.txt index ae7bd40..1c45bf5 100644 --- a/fastlane/metadata/android/en-US/short_description.txt +++ b/fastlane/metadata/android/en-US/short_description.txt @@ -1 +1 @@ -Find the nearest public emergency shelter in Norway — works offline +Find the nearest public emergency shelter in Norway. Works offline. diff --git a/fastlane/metadata/android/nb-NO/changelogs/9.txt b/fastlane/metadata/android/nb-NO/changelogs/9.txt deleted file mode 100644 index 49d8038..0000000 --- a/fastlane/metadata/android/nb-NO/changelogs/9.txt +++ /dev/null @@ -1,2 +0,0 @@ -- Legg til F-Droid-byggvariant uten Google Play Services -- Flytt signeringskonfigurasjon ut av kildekoden diff --git a/fastlane/metadata/android/nb-NO/images/icon.png b/fastlane/metadata/android/nb-NO/images/icon.png deleted file mode 100644 index f156be8..0000000 Binary files a/fastlane/metadata/android/nb-NO/images/icon.png and /dev/null differ diff --git a/fastlane/metadata/android/nb-NO/images/phoneScreenshots/1_map_view.png b/fastlane/metadata/android/nb-NO/images/phoneScreenshots/1_map_view.png index 39b32bd..bddc652 100644 Binary files a/fastlane/metadata/android/nb-NO/images/phoneScreenshots/1_map_view.png and b/fastlane/metadata/android/nb-NO/images/phoneScreenshots/1_map_view.png differ diff --git a/fastlane/metadata/android/nb-NO/images/phoneScreenshots/2_shelter_selected.png b/fastlane/metadata/android/nb-NO/images/phoneScreenshots/2_shelter_selected.png index c44f0cb..2fe8b38 100644 Binary files a/fastlane/metadata/android/nb-NO/images/phoneScreenshots/2_shelter_selected.png and b/fastlane/metadata/android/nb-NO/images/phoneScreenshots/2_shelter_selected.png differ diff --git a/fastlane/metadata/android/nb-NO/images/phoneScreenshots/3_compass_view.png b/fastlane/metadata/android/nb-NO/images/phoneScreenshots/3_compass_view.png index ea6c599..a89d2a2 100644 Binary files a/fastlane/metadata/android/nb-NO/images/phoneScreenshots/3_compass_view.png and b/fastlane/metadata/android/nb-NO/images/phoneScreenshots/3_compass_view.png differ diff --git a/fastlane/metadata/android/nb-NO/images/phoneScreenshots/4_civil_defense_info.png b/fastlane/metadata/android/nb-NO/images/phoneScreenshots/4_civil_defense_info.png index a5d2682..9b5edb0 100644 Binary files a/fastlane/metadata/android/nb-NO/images/phoneScreenshots/4_civil_defense_info.png and b/fastlane/metadata/android/nb-NO/images/phoneScreenshots/4_civil_defense_info.png differ diff --git a/fastlane/metadata/android/nb-NO/images/phoneScreenshots/5_about.png b/fastlane/metadata/android/nb-NO/images/phoneScreenshots/5_about.png deleted file mode 100644 index 584592e..0000000 Binary files a/fastlane/metadata/android/nb-NO/images/phoneScreenshots/5_about.png and /dev/null differ diff --git a/fastlane/metadata/android/nb-NO/short_description.txt b/fastlane/metadata/android/nb-NO/short_description.txt index 09ec3b6..d376c35 100644 --- a/fastlane/metadata/android/nb-NO/short_description.txt +++ b/fastlane/metadata/android/nb-NO/short_description.txt @@ -1 +1 @@ -Finn nærmeste offentlige tilfluktsrom i Norge — fungerer uten nett +Finn nærmeste offentlige tilfluktsrom i Norge. Fungerer uten nett. diff --git a/fastlane/metadata/android/nn-NO/changelogs/9.txt b/fastlane/metadata/android/nn-NO/changelogs/9.txt deleted file mode 100644 index b71da28..0000000 --- a/fastlane/metadata/android/nn-NO/changelogs/9.txt +++ /dev/null @@ -1,2 +0,0 @@ -- Legg til F-Droid-byggvariant utan Google Play Services -- Flytt signeringskonfigurasjon ut av kjeldekoden diff --git a/fastlane/metadata/android/nn-NO/images/icon.png b/fastlane/metadata/android/nn-NO/images/icon.png deleted file mode 100644 index f156be8..0000000 Binary files a/fastlane/metadata/android/nn-NO/images/icon.png and /dev/null differ diff --git a/fastlane/metadata/android/nn-NO/images/phoneScreenshots/1_map_view.png b/fastlane/metadata/android/nn-NO/images/phoneScreenshots/1_map_view.png index 39b32bd..2a3b179 100644 Binary files a/fastlane/metadata/android/nn-NO/images/phoneScreenshots/1_map_view.png and b/fastlane/metadata/android/nn-NO/images/phoneScreenshots/1_map_view.png differ diff --git a/fastlane/metadata/android/nn-NO/images/phoneScreenshots/2_shelter_selected.png b/fastlane/metadata/android/nn-NO/images/phoneScreenshots/2_shelter_selected.png index c44f0cb..037c472 100644 Binary files a/fastlane/metadata/android/nn-NO/images/phoneScreenshots/2_shelter_selected.png and b/fastlane/metadata/android/nn-NO/images/phoneScreenshots/2_shelter_selected.png differ diff --git a/fastlane/metadata/android/nn-NO/images/phoneScreenshots/3_compass_view.png b/fastlane/metadata/android/nn-NO/images/phoneScreenshots/3_compass_view.png index ea6c599..1128bb9 100644 Binary files a/fastlane/metadata/android/nn-NO/images/phoneScreenshots/3_compass_view.png and b/fastlane/metadata/android/nn-NO/images/phoneScreenshots/3_compass_view.png differ diff --git a/fastlane/metadata/android/nn-NO/images/phoneScreenshots/4_civil_defense_info.png b/fastlane/metadata/android/nn-NO/images/phoneScreenshots/4_civil_defense_info.png index a5d2682..da75ca3 100644 Binary files a/fastlane/metadata/android/nn-NO/images/phoneScreenshots/4_civil_defense_info.png and b/fastlane/metadata/android/nn-NO/images/phoneScreenshots/4_civil_defense_info.png differ diff --git a/fastlane/metadata/android/nn-NO/images/phoneScreenshots/5_about.png b/fastlane/metadata/android/nn-NO/images/phoneScreenshots/5_about.png deleted file mode 100644 index 584592e..0000000 Binary files a/fastlane/metadata/android/nn-NO/images/phoneScreenshots/5_about.png and /dev/null differ diff --git a/fastlane/metadata/android/nn-NO/short_description.txt b/fastlane/metadata/android/nn-NO/short_description.txt index e567414..462d4b2 100644 --- a/fastlane/metadata/android/nn-NO/short_description.txt +++ b/fastlane/metadata/android/nn-NO/short_description.txt @@ -1 +1 @@ -Finn næraste offentlege tilfluktsrom i Noreg — fungerer utan nett +Finn næraste offentlege tilfluktsrom i Noreg. Fungerer utan nett. diff --git a/pwa/.gitignore b/pwa/.gitignore index 35ca4c8..b947077 100644 --- a/pwa/.gitignore +++ b/pwa/.gitignore @@ -1,3 +1,2 @@ node_modules/ dist/ -public/data/shelters.json diff --git a/pwa/index.html b/pwa/index.html index 7440b65..bfbd7e6 100644 --- a/pwa/index.html +++ b/pwa/index.html @@ -1,68 +1,68 @@ - + - Tilfluktsrom - - - + + + +
- +
-
-
-
+ - -