commit 27cad094e7de317b49ffc49d3c1abe0999c37bf7 Author: Ole-Morten Duesund Date: Sun Mar 8 16:14:19 2026 +0100 Initial commit: Norwegian emergency shelter finder app Android app (Kotlin) that locates the nearest public shelter (tilfluktsrom) in Norway. Designed for offline-first emergency use. Features: - Downloads and caches all 556 Norwegian shelter locations from Geonorge - GPS-based nearest shelter finding with distance and bearing - OSMDroid map with offline tile caching for surroundings - Large directional compass arrow pointing to selected shelter - Compass sensor integration for real-time direction updates - Shows 3 nearest shelters with distance, capacity, and address - Toggle between map view and compass/arrow view - Auto-caches map tiles on first launch for offline use - Weekly background data refresh with manual force-update - i18n: Norwegian Bokmål, Nynorsk, and English Technical: - EPSG:25833 (UTM33N) → WGS84 coordinate conversion - Haversine distance and bearing calculations - Room database for shelter persistence - Fused Location Provider for precise GPS - Sensor fusion (rotation vector with accel+mag fallback) for compass Co-Authored-By: Claude Opus 4.6 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e3bdbed --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +*.iml +.gradle +/local.properties +/.idea +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties +/app/build diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..1775270 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,43 @@ +# Tilfluktsrom - Norwegian Emergency Shelter Finder + +## Project Overview +Android app (Kotlin) that helps find the nearest public shelter (tilfluktsrom) in Norway during emergencies. Offline-first design: must work without internet after initial data cache. + +## Architecture +- **Language**: Kotlin, targeting Android API 26+ (Android 8.0+) +- **Build**: Gradle 8.7, AGP 8.5.2, KSP for Room annotation processing +- **Maps**: OSMDroid (offline-capable OpenStreetMap) +- **Database**: Room (SQLite) for shelter data cache +- **HTTP**: OkHttp for data downloads +- **Location**: Google Play Services Fused Location Provider +- **UI**: Traditional Views with ViewBinding + +## Key Data Flow +1. Shelter data downloaded as GeoJSON ZIP from Geonorge (EPSG:25833 UTM33N) +2. Coordinates converted to WGS84 (lat/lon) via `CoordinateConverter` +3. Stored in Room database for offline access +4. Nearest shelters found using Haversine distance calculation +5. Direction arrow uses device compass bearing minus shelter bearing + +## Data Sources +- **Shelter data**: `https://nedlasting.geonorge.no/geonorge/Samfunnssikkerhet/TilfluktsromOffentlige/GeoJSON/...` +- **Map tiles**: OpenStreetMap via OSMDroid (auto-cached for offline use) + +## Package Structure +``` +no.naiv.tilfluktsrom/ +├── data/ # Room entities, DAO, repository, GeoJSON parser, map cache +├── location/ # GPS location provider, nearest shelter finder +├── ui/ # Custom views (DirectionArrowView), adapters +└── util/ # Coordinate conversion (UTM→WGS84), distance calculations +``` + +## Building +```bash +./gradlew assembleDebug +``` + +## i18n +- Default (English): `res/values/strings.xml` +- Norwegian Bokmål: `res/values-nb/strings.xml` +- Norwegian Nynorsk: `res/values-nn/strings.xml` diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..1496967 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,70 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("com.google.devtools.ksp") +} + +android { + namespace = "no.naiv.tilfluktsrom" + compileSdk = 35 + + defaultConfig { + applicationId = "no.naiv.tilfluktsrom" + minSdk = 26 + targetSdk = 35 + versionCode = 1 + versionName = "1.0.0" + } + + buildTypes { + release { + isMinifyEnabled = true + isShrinkResources = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + + buildFeatures { + viewBinding = true + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + } +} + +dependencies { + // AndroidX + implementation("androidx.core:core-ktx:1.13.1") + implementation("androidx.appcompat:appcompat:1.7.0") + implementation("androidx.activity:activity-ktx:1.9.3") + implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.7") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7") + implementation("com.google.android.material:material:1.12.0") + implementation("androidx.constraintlayout:constraintlayout:2.2.0") + + // Room (local database for shelter cache) + implementation("androidx.room:room-runtime:2.6.1") + implementation("androidx.room:room-ktx:2.6.1") + ksp("androidx.room:room-compiler:2.6.1") + + // OkHttp (HTTP client for data downloads) + implementation("com.squareup.okhttp3:okhttp:4.12.0") + + // OSMDroid (offline-capable OpenStreetMap) + implementation("org.osmdroid:osmdroid-android:6.1.20") + + // Google Play Services Location (precise GPS) + implementation("com.google.android.gms:play-services-location:21.3.0") + + // Coroutines + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1") +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..4f6449b --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,12 @@ +# OSMDroid +-keep class org.osmdroid.** { *; } +-dontwarn org.osmdroid.** + +# Room +-keep class * extends androidx.room.RoomDatabase +-keep @androidx.room.Entity class * +-dontwarn androidx.room.paging.** + +# OkHttp +-dontwarn okhttp3.** +-dontwarn okio.** diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a3e8f8d --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/no/naiv/tilfluktsrom/MainActivity.kt b/app/src/main/java/no/naiv/tilfluktsrom/MainActivity.kt new file mode 100644 index 0000000..edc8bba --- /dev/null +++ b/app/src/main/java/no/naiv/tilfluktsrom/MainActivity.kt @@ -0,0 +1,428 @@ +package no.naiv.tilfluktsrom + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.hardware.Sensor +import android.hardware.SensorEvent +import android.hardware.SensorEventListener +import android.hardware.SensorManager +import android.location.Location +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import android.os.Bundle +import android.util.Log +import android.view.View +import android.widget.Toast +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.ContextCompat +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.LinearLayoutManager +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import no.naiv.tilfluktsrom.data.MapCacheManager +import no.naiv.tilfluktsrom.data.Shelter +import no.naiv.tilfluktsrom.data.ShelterRepository +import no.naiv.tilfluktsrom.databinding.ActivityMainBinding +import no.naiv.tilfluktsrom.location.LocationProvider +import no.naiv.tilfluktsrom.location.ShelterFinder +import no.naiv.tilfluktsrom.location.ShelterWithDistance +import no.naiv.tilfluktsrom.ui.ShelterListAdapter +import no.naiv.tilfluktsrom.util.DistanceUtils +import org.osmdroid.util.GeoPoint +import org.osmdroid.views.CustomZoomButtonsController +import org.osmdroid.views.overlay.Marker +import org.osmdroid.views.overlay.mylocation.GpsMyLocationProvider +import org.osmdroid.views.overlay.mylocation.MyLocationNewOverlay + +class MainActivity : AppCompatActivity(), SensorEventListener { + + companion object { + private const val TAG = "MainActivity" + private const val DEFAULT_ZOOM = 14.0 + private const val NEAREST_COUNT = 3 + } + + private lateinit var binding: ActivityMainBinding + private lateinit var repository: ShelterRepository + private lateinit var locationProvider: LocationProvider + private lateinit var mapCacheManager: MapCacheManager + private lateinit var sensorManager: SensorManager + private lateinit var shelterAdapter: ShelterListAdapter + + private var myLocationOverlay: MyLocationNewOverlay? = null + private var currentLocation: Location? = null + private var allShelters: List = emptyList() + private var nearestShelters: List = emptyList() + private var selectedShelterIndex = 0 + private var deviceHeading = 0f + private var isCompassMode = false + private var locationJob: Job? = null + private var shelterMarkers: MutableList = mutableListOf() + + private val locationPermissionRequest = registerForActivityResult( + ActivityResultContracts.RequestMultiplePermissions() + ) { permissions -> + val fineGranted = permissions[Manifest.permission.ACCESS_FINE_LOCATION] == true + val coarseGranted = permissions[Manifest.permission.ACCESS_COARSE_LOCATION] == true + + if (fineGranted || coarseGranted) { + startLocationUpdates() + } else { + Toast.makeText(this, R.string.permission_denied, Toast.LENGTH_LONG).show() + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivityMainBinding.inflate(layoutInflater) + setContentView(binding.root) + + repository = ShelterRepository(this) + locationProvider = LocationProvider(this) + mapCacheManager = MapCacheManager(this) + sensorManager = getSystemService(Context.SENSOR_SERVICE) as SensorManager + + setupMap() + setupShelterList() + setupButtons() + loadData() + } + + private fun setupMap() { + binding.mapView.apply { + setMultiTouchControls(true) + zoomController.setVisibility(CustomZoomButtonsController.Visibility.SHOW_AND_FADEOUT) + controller.setZoom(DEFAULT_ZOOM) + // Default center: roughly central Norway + controller.setCenter(GeoPoint(59.9, 10.7)) + + // Add user location overlay + myLocationOverlay = MyLocationNewOverlay( + GpsMyLocationProvider(this@MainActivity), this + ).apply { + enableMyLocation() + } + overlays.add(myLocationOverlay) + } + } + + private fun setupShelterList() { + shelterAdapter = ShelterListAdapter { selected -> + val idx = nearestShelters.indexOf(selected) + if (idx >= 0) { + selectedShelterIndex = idx + updateSelectedShelter() + } + } + + binding.shelterList.apply { + layoutManager = LinearLayoutManager(this@MainActivity) + adapter = shelterAdapter + } + } + + private fun setupButtons() { + binding.toggleViewFab.setOnClickListener { + isCompassMode = !isCompassMode + if (isCompassMode) { + binding.mapView.visibility = View.GONE + binding.compassContainer.visibility = View.VISIBLE + binding.toggleViewFab.setImageResource(R.drawable.ic_map) + } else { + binding.mapView.visibility = View.VISIBLE + binding.compassContainer.visibility = View.GONE + binding.toggleViewFab.setImageResource(R.drawable.ic_compass) + } + } + + binding.refreshButton.setOnClickListener { + forceRefresh() + } + } + + private fun loadData() { + lifecycleScope.launch { + val hasData = repository.hasCachedData() + + if (!hasData) { + if (!isNetworkAvailable()) { + binding.statusText.text = getString(R.string.error_no_data_offline) + return@launch + } + showLoading(getString(R.string.loading_shelters)) + val success = repository.refreshData() + hideLoading() + + if (!success) { + binding.statusText.text = getString(R.string.error_download_failed) + return@launch + } + } + + // Observe shelter data reactively + launch { + repository.getAllShelters().collectLatest { shelters -> + allShelters = shelters + binding.statusText.text = getString(R.string.status_shelters_loaded, shelters.size) + updateShelterMarkers() + currentLocation?.let { updateNearestShelters(it) } + } + } + + // Request location and start updates + requestLocationPermission() + + // Check for stale data in background + if (hasData && repository.isDataStale() && isNetworkAvailable()) { + launch { + val success = repository.refreshData() + if (success) { + Toast.makeText(this@MainActivity, R.string.update_success, Toast.LENGTH_SHORT).show() + } + } + } + } + } + + private fun requestLocationPermission() { + if (locationProvider.hasLocationPermission()) { + startLocationUpdates() + } else { + locationPermissionRequest.launch( + arrayOf( + Manifest.permission.ACCESS_FINE_LOCATION, + Manifest.permission.ACCESS_COARSE_LOCATION + ) + ) + } + } + + private fun startLocationUpdates() { + locationJob?.cancel() + locationJob = lifecycleScope.launch { + locationProvider.locationUpdates().collectLatest { location -> + currentLocation = location + updateNearestShelters(location) + + // Center map on first location fix + if (nearestShelters.isEmpty()) { + binding.mapView.controller.animateTo( + GeoPoint(location.latitude, location.longitude) + ) + } + + // Cache map tiles on first launch + if (!mapCacheManager.hasCacheForLocation(location.latitude, location.longitude)) { + if (isNetworkAvailable()) { + cacheMapTiles(location.latitude, location.longitude) + } + } + } + } + } + + private fun updateNearestShelters(location: Location) { + if (allShelters.isEmpty()) return + + nearestShelters = ShelterFinder.findNearest( + allShelters, location.latitude, location.longitude, NEAREST_COUNT + ) + + shelterAdapter.submitList(nearestShelters) + selectedShelterIndex = 0 + shelterAdapter.selectPosition(0) + updateSelectedShelter() + } + + private fun updateSelectedShelter() { + if (nearestShelters.isEmpty()) return + + val selected = nearestShelters[selectedShelterIndex] + val distanceText = DistanceUtils.formatDistance(selected.distanceMeters) + + // Update bottom sheet + binding.selectedShelterAddress.text = selected.shelter.adresse + binding.selectedShelterDetails.text = getString( + R.string.shelter_room_nr, selected.shelter.romnr + ) + " - " + getString( + R.string.shelter_capacity, selected.shelter.plasser + ) + " - " + distanceText + + // Update mini arrow in bottom sheet + val bearing = selected.bearingDegrees.toFloat() + binding.miniArrow.setDirection(bearing - deviceHeading) + + // Update compass view + binding.compassDistanceText.text = distanceText + binding.compassAddressText.text = selected.shelter.adresse + binding.directionArrow.setDirection(bearing - deviceHeading) + + // Center map on shelter if in map mode + if (!isCompassMode) { + highlightShelterOnMap(selected) + } + } + + private fun updateShelterMarkers() { + // Remove old markers + shelterMarkers.forEach { binding.mapView.overlays.remove(it) } + shelterMarkers.clear() + + // Add markers for all shelters + allShelters.forEach { shelter -> + val marker = Marker(binding.mapView).apply { + position = GeoPoint(shelter.latitude, shelter.longitude) + setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM) + title = shelter.adresse + snippet = getString(R.string.shelter_capacity, shelter.plasser) + + " - " + getString(R.string.shelter_room_nr, shelter.romnr) + icon = ContextCompat.getDrawable(this@MainActivity, R.drawable.ic_shelter) + } + shelterMarkers.add(marker) + binding.mapView.overlays.add(marker) + } + + binding.mapView.invalidate() + } + + private fun highlightShelterOnMap(selected: ShelterWithDistance) { + val shelterPoint = GeoPoint(selected.shelter.latitude, selected.shelter.longitude) + + // If we have location, show both user and shelter in view + currentLocation?.let { loc -> + val userPoint = GeoPoint(loc.latitude, loc.longitude) + val boundingBox = org.osmdroid.util.BoundingBox.fromGeoPoints( + listOf(userPoint, shelterPoint) + ) + // Add padding so markers aren't at the edge + binding.mapView.zoomToBoundingBox(boundingBox.increaseByScale(1.5f), true) + } ?: run { + binding.mapView.controller.animateTo(shelterPoint) + } + } + + private fun cacheMapTiles(latitude: Double, longitude: Double) { + lifecycleScope.launch { + binding.statusText.text = getString(R.string.status_caching_map) + mapCacheManager.cacheMapArea( + binding.mapView, latitude, longitude + ) { progress -> + binding.statusText.text = getString(R.string.status_caching_map) + + " (${(progress * 100).toInt()}%)" + } + binding.statusText.text = getString(R.string.status_shelters_loaded, allShelters.size) + } + } + + private fun forceRefresh() { + if (!isNetworkAvailable()) { + Toast.makeText(this, R.string.error_download_failed, Toast.LENGTH_SHORT).show() + return + } + + lifecycleScope.launch { + binding.statusText.text = getString(R.string.status_updating) + val success = repository.refreshData() + if (success) { + Toast.makeText(this@MainActivity, R.string.update_success, Toast.LENGTH_SHORT).show() + } else { + Toast.makeText(this@MainActivity, R.string.update_failed, Toast.LENGTH_SHORT).show() + } + } + } + + private fun showLoading(message: String) { + binding.loadingOverlay.visibility = View.VISIBLE + binding.loadingText.text = message + } + + private fun hideLoading() { + binding.loadingOverlay.visibility = View.GONE + } + + private fun isNetworkAvailable(): Boolean { + val cm = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + val network = cm.activeNetwork ?: return false + val caps = cm.getNetworkCapabilities(network) ?: return false + return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + } + + // --- Sensor handling for compass --- + + override fun onResume() { + super.onResume() + binding.mapView.onResume() + + // Register for rotation vector (best compass source) + val rotationSensor = sensorManager.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR) + if (rotationSensor != null) { + sensorManager.registerListener(this, rotationSensor, SensorManager.SENSOR_DELAY_UI) + } else { + // Fallback to accelerometer + magnetometer + sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)?.let { + sensorManager.registerListener(this, it, SensorManager.SENSOR_DELAY_UI) + } + sensorManager.getDefaultSensor(Sensor.TYPE_MAGNETIC_FIELD)?.let { + sensorManager.registerListener(this, it, SensorManager.SENSOR_DELAY_UI) + } + } + } + + override fun onPause() { + super.onPause() + binding.mapView.onPause() + sensorManager.unregisterListener(this) + } + + private val gravity = FloatArray(3) + private val geomagnetic = FloatArray(3) + private val rotationMatrix = FloatArray(9) + private val orientation = FloatArray(3) + + override fun onSensorChanged(event: SensorEvent) { + when (event.sensor.type) { + Sensor.TYPE_ROTATION_VECTOR -> { + SensorManager.getRotationMatrixFromVector(rotationMatrix, event.values) + SensorManager.getOrientation(rotationMatrix, orientation) + deviceHeading = Math.toDegrees(orientation[0].toDouble()).toFloat() + if (deviceHeading < 0) deviceHeading += 360f + updateDirectionArrows() + } + Sensor.TYPE_ACCELEROMETER -> { + System.arraycopy(event.values, 0, gravity, 0, 3) + updateFromAccelMag() + } + Sensor.TYPE_MAGNETIC_FIELD -> { + System.arraycopy(event.values, 0, geomagnetic, 0, 3) + updateFromAccelMag() + } + } + } + + private fun updateFromAccelMag() { + val r = FloatArray(9) + if (SensorManager.getRotationMatrix(r, null, gravity, geomagnetic)) { + SensorManager.getOrientation(r, orientation) + deviceHeading = Math.toDegrees(orientation[0].toDouble()).toFloat() + if (deviceHeading < 0) deviceHeading += 360f + updateDirectionArrows() + } + } + + private fun updateDirectionArrows() { + if (nearestShelters.isEmpty()) return + val selected = nearestShelters[selectedShelterIndex] + val bearing = selected.bearingDegrees.toFloat() + val arrowAngle = bearing - deviceHeading + + binding.directionArrow.setDirection(arrowAngle) + binding.miniArrow.setDirection(arrowAngle) + } + + override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) { + // No-op + } +} diff --git a/app/src/main/java/no/naiv/tilfluktsrom/TilfluktsromApp.kt b/app/src/main/java/no/naiv/tilfluktsrom/TilfluktsromApp.kt new file mode 100644 index 0000000..06d13f0 --- /dev/null +++ b/app/src/main/java/no/naiv/tilfluktsrom/TilfluktsromApp.kt @@ -0,0 +1,19 @@ +package no.naiv.tilfluktsrom + +import android.app.Application +import org.osmdroid.config.Configuration + +class TilfluktsromApp : Application() { + + override fun onCreate() { + super.onCreate() + + // Configure OSMDroid: set user agent and tile cache path + Configuration.getInstance().apply { + userAgentValue = packageName + // Use app-specific internal storage for tile cache + osmdroidBasePath = filesDir + osmdroidTileCache = java.io.File(filesDir, "tiles") + } + } +} diff --git a/app/src/main/java/no/naiv/tilfluktsrom/data/MapCacheManager.kt b/app/src/main/java/no/naiv/tilfluktsrom/data/MapCacheManager.kt new file mode 100644 index 0000000..cce3634 --- /dev/null +++ b/app/src/main/java/no/naiv/tilfluktsrom/data/MapCacheManager.kt @@ -0,0 +1,155 @@ +package no.naiv.tilfluktsrom.data + +import android.content.Context +import android.util.Log +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.osmdroid.tileprovider.cachemanager.CacheManager +import org.osmdroid.tileprovider.modules.SqlTileWriter +import org.osmdroid.util.BoundingBox +import org.osmdroid.util.GeoPoint +import org.osmdroid.views.MapView + +/** + * Manages offline map tile caching for the surrounding area. + * + * On first launch, downloads map tiles for a region around the user's location. + * On subsequent launches, checks if the current location is within the cached area. + */ +class MapCacheManager(private val context: Context) { + + companion object { + private const val TAG = "MapCacheManager" + private const val PREFS_NAME = "map_cache_prefs" + private const val KEY_CACHED_LAT = "cached_center_lat" + private const val KEY_CACHED_LON = "cached_center_lon" + private const val KEY_CACHE_RADIUS = "cache_radius_km" + private const val KEY_CACHE_COMPLETE = "cache_complete" + + // Cache tiles for ~15km radius at useful zoom levels + private const val CACHE_RADIUS_DEGREES = 0.15 // ~15km + private const val MIN_ZOOM = 10 + private const val MAX_ZOOM = 16 + } + + private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + + /** + * Check if we have a map cache that covers the given location. + */ + fun hasCacheForLocation(latitude: Double, longitude: Double): Boolean { + if (!prefs.getBoolean(KEY_CACHE_COMPLETE, false)) return false + + val cachedLat = Double.fromBits(prefs.getLong(KEY_CACHED_LAT, 0)) + val cachedLon = Double.fromBits(prefs.getLong(KEY_CACHED_LON, 0)) + val radius = prefs.getFloat(KEY_CACHE_RADIUS, 0f).toDouble() + + if (radius == 0.0) return false + + // Check if current location is within the cached region (with some margin) + val margin = radius * 0.3 + return Math.abs(latitude - cachedLat) < (radius - margin) && + Math.abs(longitude - cachedLon) < (radius - margin) + } + + /** + * Download map tiles for the area around the given location. + * Reports progress via callback (0.0 to 1.0). + */ + suspend fun cacheMapArea( + mapView: MapView, + latitude: Double, + longitude: Double, + onProgress: (Float) -> Unit = {} + ): Boolean = withContext(Dispatchers.Main) { + try { + Log.i(TAG, "Starting map tile cache for area around $latitude, $longitude") + + val boundingBox = BoundingBox( + latitude + CACHE_RADIUS_DEGREES, + longitude + CACHE_RADIUS_DEGREES, + latitude - CACHE_RADIUS_DEGREES, + longitude - CACHE_RADIUS_DEGREES + ) + + val cacheManager = CacheManager(mapView) + var complete = false + var success = false + + cacheManager.downloadAreaAsync( + context, + boundingBox, + MIN_ZOOM, + MAX_ZOOM, + object : CacheManager.CacheManagerCallback { + override fun onTaskComplete() { + Log.i(TAG, "Map cache download complete") + success = true + complete = true + } + + override fun onTaskFailed(errors: Int) { + Log.w(TAG, "Map cache download completed with $errors errors") + // Consider partial success if most tiles downloaded + success = errors < 50 + complete = true + } + + override fun updateProgress( + progress: Int, + currentZoomLevel: Int, + zoomMin: Int, + zoomMax: Int + ) { + val totalZoomLevels = zoomMax - zoomMin + 1 + val zoomProgress = (currentZoomLevel - zoomMin).toFloat() / totalZoomLevels + onProgress(zoomProgress + (progress / 100f) / totalZoomLevels) + } + + override fun downloadStarted() { + Log.i(TAG, "Map cache download started") + } + + override fun setPossibleTilesInArea(total: Int) { + Log.i(TAG, "Total tiles to download: $total") + } + } + ) + + // Wait for completion (the async callback runs on main thread) + withContext(Dispatchers.IO) { + while (!complete) { + Thread.sleep(500) + } + } + + if (success) { + prefs.edit() + .putLong(KEY_CACHED_LAT, latitude.toBits()) + .putLong(KEY_CACHED_LON, longitude.toBits()) + .putFloat(KEY_CACHE_RADIUS, CACHE_RADIUS_DEGREES.toFloat()) + .putBoolean(KEY_CACHE_COMPLETE, true) + .apply() + } + + success + } catch (e: Exception) { + Log.e(TAG, "Failed to cache map tiles", e) + false + } + } + + /** + * Get the approximate number of cached tiles. + */ + fun getCachedTileCount(): Long { + return try { + val writer = SqlTileWriter() + val count = writer.getRowCount(null) + writer.onDetach() + count + } catch (e: Exception) { + 0L + } + } +} diff --git a/app/src/main/java/no/naiv/tilfluktsrom/data/Shelter.kt b/app/src/main/java/no/naiv/tilfluktsrom/data/Shelter.kt new file mode 100644 index 0000000..e3e9107 --- /dev/null +++ b/app/src/main/java/no/naiv/tilfluktsrom/data/Shelter.kt @@ -0,0 +1,19 @@ +package no.naiv.tilfluktsrom.data + +import androidx.room.Entity +import androidx.room.PrimaryKey + +/** + * A public shelter (offentlig tilfluktsrom). + * Coordinates are stored in WGS84 (EPSG:4326) after conversion from UTM33N. + */ +@Entity(tableName = "shelters") +data class Shelter( + @PrimaryKey + val lokalId: String, + val romnr: Int, + val plasser: Int, + val adresse: String, + val latitude: Double, + val longitude: Double +) diff --git a/app/src/main/java/no/naiv/tilfluktsrom/data/ShelterDao.kt b/app/src/main/java/no/naiv/tilfluktsrom/data/ShelterDao.kt new file mode 100644 index 0000000..0a1f2fb --- /dev/null +++ b/app/src/main/java/no/naiv/tilfluktsrom/data/ShelterDao.kt @@ -0,0 +1,26 @@ +package no.naiv.tilfluktsrom.data + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import kotlinx.coroutines.flow.Flow + +@Dao +interface ShelterDao { + + @Query("SELECT * FROM shelters") + fun getAllShelters(): Flow> + + @Query("SELECT * FROM shelters") + suspend fun getAllSheltersList(): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAll(shelters: List) + + @Query("DELETE FROM shelters") + suspend fun deleteAll() + + @Query("SELECT COUNT(*) FROM shelters") + suspend fun count(): Int +} diff --git a/app/src/main/java/no/naiv/tilfluktsrom/data/ShelterDatabase.kt b/app/src/main/java/no/naiv/tilfluktsrom/data/ShelterDatabase.kt new file mode 100644 index 0000000..8babea4 --- /dev/null +++ b/app/src/main/java/no/naiv/tilfluktsrom/data/ShelterDatabase.kt @@ -0,0 +1,27 @@ +package no.naiv.tilfluktsrom.data + +import android.content.Context +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase + +@Database(entities = [Shelter::class], version = 1, exportSchema = false) +abstract class ShelterDatabase : RoomDatabase() { + + abstract fun shelterDao(): ShelterDao + + companion object { + @Volatile + private var INSTANCE: ShelterDatabase? = null + + fun getInstance(context: Context): ShelterDatabase { + return INSTANCE ?: synchronized(this) { + INSTANCE ?: Room.databaseBuilder( + context.applicationContext, + ShelterDatabase::class.java, + "shelters.db" + ).build().also { INSTANCE = it } + } + } + } +} diff --git a/app/src/main/java/no/naiv/tilfluktsrom/data/ShelterGeoJsonParser.kt b/app/src/main/java/no/naiv/tilfluktsrom/data/ShelterGeoJsonParser.kt new file mode 100644 index 0000000..833bd45 --- /dev/null +++ b/app/src/main/java/no/naiv/tilfluktsrom/data/ShelterGeoJsonParser.kt @@ -0,0 +1,72 @@ +package no.naiv.tilfluktsrom.data + +import no.naiv.tilfluktsrom.util.CoordinateConverter +import org.json.JSONObject +import java.io.ByteArrayOutputStream +import java.io.InputStream +import java.util.zip.ZipInputStream + +/** + * Parses shelter GeoJSON data from the Geonorge ZIP download. + * Converts coordinates from UTM33N (EPSG:25833) to WGS84 (EPSG:4326). + */ +object ShelterGeoJsonParser { + + /** + * Extract and parse GeoJSON from a ZIP input stream. + */ + fun parseFromZip(zipStream: InputStream): List { + val json = extractGeoJsonFromZip(zipStream) + return parseGeoJson(json) + } + + private fun extractGeoJsonFromZip(zipStream: InputStream): String { + ZipInputStream(zipStream).use { zis -> + var entry = zis.nextEntry + while (entry != null) { + if (entry.name.endsWith(".geojson") || entry.name.endsWith(".json")) { + val buffer = ByteArrayOutputStream() + zis.copyTo(buffer) + return buffer.toString(Charsets.UTF_8.name()) + } + entry = zis.nextEntry + } + } + throw IllegalStateException("No GeoJSON file found in ZIP archive") + } + + /** + * Parse raw GeoJSON string into Shelter objects. + */ + fun parseGeoJson(json: String): List { + val root = JSONObject(json) + val features = root.getJSONArray("features") + val shelters = mutableListOf() + + for (i in 0 until features.length()) { + val feature = features.getJSONObject(i) + val geometry = feature.getJSONObject("geometry") + val properties = feature.getJSONObject("properties") + + val coordinates = geometry.getJSONArray("coordinates") + val easting = coordinates.getDouble(0) + val northing = coordinates.getDouble(1) + + // Convert UTM33N to WGS84 + val latLon = CoordinateConverter.utm33nToWgs84(easting, northing) + + shelters.add( + Shelter( + lokalId = properties.optString("lokalId", "unknown-$i"), + romnr = properties.optInt("romnr", 0), + plasser = properties.optInt("plasser", 0), + adresse = properties.optString("adresse", ""), + latitude = latLon.latitude, + longitude = latLon.longitude + ) + ) + } + + return shelters + } +} diff --git a/app/src/main/java/no/naiv/tilfluktsrom/data/ShelterRepository.kt b/app/src/main/java/no/naiv/tilfluktsrom/data/ShelterRepository.kt new file mode 100644 index 0000000..7c083f7 --- /dev/null +++ b/app/src/main/java/no/naiv/tilfluktsrom/data/ShelterRepository.kt @@ -0,0 +1,93 @@ +package no.naiv.tilfluktsrom.data + +import android.content.Context +import android.util.Log +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import java.util.concurrent.TimeUnit + +/** + * Repository managing shelter data: local Room cache + remote Geonorge download. + * Offline-first: always returns cached data when available, updates in background. + */ +class ShelterRepository(context: Context) { + + companion object { + private const val TAG = "ShelterRepository" + private const val PREFS_NAME = "shelter_prefs" + private const val KEY_LAST_UPDATE = "last_update_ms" + private const val UPDATE_INTERVAL_MS = 7 * 24 * 60 * 60 * 1000L // 7 days + + // Geonorge GeoJSON download (ZIP containing all Norwegian shelters) + private const val SHELTER_DATA_URL = + "https://nedlasting.geonorge.no/geonorge/Samfunnssikkerhet/" + + "TilfluktsromOffentlige/GeoJSON/" + + "Samfunnssikkerhet_0000_Norge_25833_TilfluktsromOffentlige_GeoJSON.zip" + } + + private val db = ShelterDatabase.getInstance(context) + private val dao = db.shelterDao() + private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + + private val client = OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(60, TimeUnit.SECONDS) + .build() + + /** Reactive stream of all shelters from local cache. */ + fun getAllShelters(): Flow> = dao.getAllShelters() + + /** Check if we have cached shelter data. */ + suspend fun hasCachedData(): Boolean = dao.count() > 0 + + /** Check if the cached data is stale and should be refreshed. */ + fun isDataStale(): Boolean { + val lastUpdate = prefs.getLong(KEY_LAST_UPDATE, 0) + return System.currentTimeMillis() - lastUpdate > UPDATE_INTERVAL_MS + } + + /** + * Download shelter data from Geonorge and cache it locally. + * Returns true on success, false on failure. + */ + suspend fun refreshData(): Boolean = withContext(Dispatchers.IO) { + try { + Log.i(TAG, "Downloading shelter data from Geonorge...") + + val request = Request.Builder() + .url(SHELTER_DATA_URL) + .header("User-Agent", "Tilfluktsrom-Android/1.0") + .build() + + val response = client.newCall(request).execute() + if (!response.isSuccessful) { + Log.e(TAG, "Download failed: HTTP ${response.code}") + return@withContext false + } + + val body = response.body ?: run { + Log.e(TAG, "Empty response body") + return@withContext false + } + + val shelters = body.byteStream().use { stream -> + ShelterGeoJsonParser.parseFromZip(stream) + } + + Log.i(TAG, "Parsed ${shelters.size} shelters, saving to database...") + + dao.deleteAll() + dao.insertAll(shelters) + + prefs.edit().putLong(KEY_LAST_UPDATE, System.currentTimeMillis()).apply() + Log.i(TAG, "Shelter data updated successfully") + true + } catch (e: Exception) { + Log.e(TAG, "Failed to refresh shelter data", e) + false + } + } +} diff --git a/app/src/main/java/no/naiv/tilfluktsrom/location/LocationProvider.kt b/app/src/main/java/no/naiv/tilfluktsrom/location/LocationProvider.kt new file mode 100644 index 0000000..c4a350a --- /dev/null +++ b/app/src/main/java/no/naiv/tilfluktsrom/location/LocationProvider.kt @@ -0,0 +1,93 @@ +package no.naiv.tilfluktsrom.location + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.location.Location +import android.os.Looper +import android.util.Log +import androidx.core.content.ContextCompat +import com.google.android.gms.location.FusedLocationProviderClient +import com.google.android.gms.location.LocationCallback +import com.google.android.gms.location.LocationRequest +import com.google.android.gms.location.LocationResult +import com.google.android.gms.location.LocationServices +import com.google.android.gms.location.Priority +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow + +/** + * Provides GPS location updates using the Fused Location Provider. + * Emits location updates as a Flow for reactive consumption. + */ +class LocationProvider(private val context: Context) { + + companion object { + private const val TAG = "LocationProvider" + private const val UPDATE_INTERVAL_MS = 5000L + private const val FASTEST_INTERVAL_MS = 2000L + } + + private val fusedClient: FusedLocationProviderClient = + LocationServices.getFusedLocationProviderClient(context) + + /** + * Stream of location updates. Emits the last known location first (if available), + * then continuous updates. + */ + fun locationUpdates(): Flow = callbackFlow { + if (!hasLocationPermission()) { + Log.w(TAG, "Location permission not granted") + close() + return@callbackFlow + } + + // Try to get last known location for immediate display + try { + fusedClient.lastLocation.addOnSuccessListener { location -> + if (location != null) { + trySend(location) + } + } + } catch (e: SecurityException) { + Log.e(TAG, "SecurityException getting last location", e) + } + + val locationRequest = LocationRequest.Builder( + Priority.PRIORITY_HIGH_ACCURACY, + UPDATE_INTERVAL_MS + ).apply { + setMinUpdateIntervalMillis(FASTEST_INTERVAL_MS) + setWaitForAccurateLocation(false) + }.build() + + val callback = object : LocationCallback() { + override fun onLocationResult(result: LocationResult) { + result.lastLocation?.let { trySend(it) } + } + } + + try { + fusedClient.requestLocationUpdates( + locationRequest, + callback, + Looper.getMainLooper() + ) + } catch (e: SecurityException) { + Log.e(TAG, "SecurityException requesting location updates", e) + close() + return@callbackFlow + } + + awaitClose { + fusedClient.removeLocationUpdates(callback) + } + } + + fun hasLocationPermission(): Boolean { + return ContextCompat.checkSelfPermission( + context, Manifest.permission.ACCESS_FINE_LOCATION + ) == PackageManager.PERMISSION_GRANTED + } +} diff --git a/app/src/main/java/no/naiv/tilfluktsrom/location/ShelterFinder.kt b/app/src/main/java/no/naiv/tilfluktsrom/location/ShelterFinder.kt new file mode 100644 index 0000000..7deac77 --- /dev/null +++ b/app/src/main/java/no/naiv/tilfluktsrom/location/ShelterFinder.kt @@ -0,0 +1,47 @@ +package no.naiv.tilfluktsrom.location + +import no.naiv.tilfluktsrom.data.Shelter +import no.naiv.tilfluktsrom.util.DistanceUtils + +/** + * Result containing a shelter and its distance/bearing from the user. + */ +data class ShelterWithDistance( + val shelter: Shelter, + val distanceMeters: Double, + val bearingDegrees: Double +) + +/** + * Finds the nearest shelters to a given location. + */ +object ShelterFinder { + + /** + * Find the N nearest shelters to the given location. + * Returns results sorted by distance (nearest first). + */ + fun findNearest( + shelters: List, + latitude: Double, + longitude: Double, + count: Int = 3 + ): List { + return shelters + .map { shelter -> + ShelterWithDistance( + shelter = shelter, + distanceMeters = DistanceUtils.distanceMeters( + latitude, longitude, + shelter.latitude, shelter.longitude + ), + bearingDegrees = DistanceUtils.bearingDegrees( + latitude, longitude, + shelter.latitude, shelter.longitude + ) + ) + } + .sortedBy { it.distanceMeters } + .take(count) + } +} diff --git a/app/src/main/java/no/naiv/tilfluktsrom/ui/DirectionArrowView.kt b/app/src/main/java/no/naiv/tilfluktsrom/ui/DirectionArrowView.kt new file mode 100644 index 0000000..edd3154 --- /dev/null +++ b/app/src/main/java/no/naiv/tilfluktsrom/ui/DirectionArrowView.kt @@ -0,0 +1,77 @@ +package no.naiv.tilfluktsrom.ui + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.Path +import android.util.AttributeSet +import android.view.View +import no.naiv.tilfluktsrom.R + +/** + * Custom view that displays a large directional arrow pointing toward + * the target shelter. The arrow rotates based on the bearing to the + * shelter relative to the device heading (compass). + * + * rotationAngle = shelterBearing - deviceHeading + * This gives the direction the user needs to walk, adjusted for which + * way they're currently facing. + */ +class DirectionArrowView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : View(context, attrs, defStyleAttr) { + + private var rotationAngle = 0f + + private val arrowPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = context.getColor(R.color.shelter_primary) + style = Paint.Style.FILL + } + + private val outlinePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.WHITE + style = Paint.Style.STROKE + strokeWidth = 4f + } + + private val arrowPath = Path() + + /** + * Set the rotation angle in degrees. + * 0 = pointing up (north/forward), positive = clockwise. + */ + fun setDirection(degrees: Float) { + rotationAngle = degrees + invalidate() + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + + val cx = width / 2f + val cy = height / 2f + val size = minOf(width, height) * 0.4f + + canvas.save() + canvas.rotate(rotationAngle, cx, cy) + + // Draw arrow pointing up + arrowPath.reset() + arrowPath.moveTo(cx, cy - size) // tip + arrowPath.lineTo(cx + size * 0.5f, cy + size * 0.3f) // right + arrowPath.lineTo(cx + size * 0.15f, cy + size * 0.1f) + arrowPath.lineTo(cx + size * 0.15f, cy + size * 0.7f) // right tail + arrowPath.lineTo(cx - size * 0.15f, cy + size * 0.7f) // left tail + arrowPath.lineTo(cx - size * 0.15f, cy + size * 0.1f) + arrowPath.lineTo(cx - size * 0.5f, cy + size * 0.3f) // left + arrowPath.close() + + canvas.drawPath(arrowPath, arrowPaint) + canvas.drawPath(arrowPath, outlinePaint) + + canvas.restore() + } +} diff --git a/app/src/main/java/no/naiv/tilfluktsrom/ui/ShelterListAdapter.kt b/app/src/main/java/no/naiv/tilfluktsrom/ui/ShelterListAdapter.kt new file mode 100644 index 0000000..55655f2 --- /dev/null +++ b/app/src/main/java/no/naiv/tilfluktsrom/ui/ShelterListAdapter.kt @@ -0,0 +1,77 @@ +package no.naiv.tilfluktsrom.ui + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import no.naiv.tilfluktsrom.R +import no.naiv.tilfluktsrom.databinding.ItemShelterBinding +import no.naiv.tilfluktsrom.location.ShelterWithDistance +import no.naiv.tilfluktsrom.util.DistanceUtils + +/** + * Adapter for the list of nearest shelters shown in the bottom sheet. + */ +class ShelterListAdapter( + private val onShelterSelected: (ShelterWithDistance) -> Unit +) : ListAdapter(DIFF_CALLBACK) { + + private var selectedPosition = 0 + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val binding = ItemShelterBinding.inflate( + LayoutInflater.from(parent.context), parent, false + ) + return ViewHolder(binding) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + holder.bind(getItem(position), position == selectedPosition) + } + + fun selectPosition(position: Int) { + val old = selectedPosition + selectedPosition = position + notifyItemChanged(old) + notifyItemChanged(position) + } + + inner class ViewHolder( + private val binding: ItemShelterBinding + ) : RecyclerView.ViewHolder(binding.root) { + + fun bind(item: ShelterWithDistance, isSelected: Boolean) { + val ctx = binding.root.context + binding.shelterAddress.text = item.shelter.adresse + binding.shelterDistance.text = DistanceUtils.formatDistance(item.distanceMeters) + binding.shelterCapacity.text = ctx.getString( + R.string.shelter_capacity, item.shelter.plasser + ) + binding.shelterRoomNr.text = ctx.getString( + R.string.shelter_room_nr, item.shelter.romnr + ) + + binding.root.isSelected = isSelected + binding.root.alpha = if (isSelected) 1.0f else 0.7f + + binding.root.setOnClickListener { + val pos = adapterPosition + if (pos != RecyclerView.NO_POSITION) { + selectPosition(pos) + onShelterSelected(getItem(pos)) + } + } + } + } + + companion object { + private val DIFF_CALLBACK = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(a: ShelterWithDistance, b: ShelterWithDistance) = + a.shelter.lokalId == b.shelter.lokalId + + override fun areContentsTheSame(a: ShelterWithDistance, b: ShelterWithDistance) = + a == b + } + } +} diff --git a/app/src/main/java/no/naiv/tilfluktsrom/util/CoordinateConverter.kt b/app/src/main/java/no/naiv/tilfluktsrom/util/CoordinateConverter.kt new file mode 100644 index 0000000..ec1a2d7 --- /dev/null +++ b/app/src/main/java/no/naiv/tilfluktsrom/util/CoordinateConverter.kt @@ -0,0 +1,73 @@ +package no.naiv.tilfluktsrom.util + +import kotlin.math.* + +/** + * Converts UTM zone 33N (EPSG:25833) coordinates to WGS84 (EPSG:4326). + * + * The shelter data from Geonorge uses EUREF89 UTM zone 33N. EUREF89 is + * identical to WGS84 for all practical purposes (sub-meter difference). + * + * The conversion uses the Karney method (series expansion) for accuracy. + */ +object CoordinateConverter { + + // WGS84 ellipsoid parameters + private const val A = 6378137.0 // semi-major axis (meters) + private const val F = 1.0 / 298.257223563 // flattening + private const val E2 = 2 * F - F * F // eccentricity squared + + // UTM parameters + private const val K0 = 0.9996 // scale factor + private const val FALSE_EASTING = 500000.0 + private const val FALSE_NORTHING = 0.0 // northern hemisphere + private const val ZONE_33_CENTRAL_MERIDIAN = 15.0 // degrees + + data class LatLon(val latitude: Double, val longitude: Double) + + /** + * Convert UTM33N easting/northing to WGS84 latitude/longitude. + */ + fun utm33nToWgs84(easting: Double, northing: Double): LatLon { + val x = easting - FALSE_EASTING + val y = northing - FALSE_NORTHING + + val e1 = (1 - sqrt(1 - E2)) / (1 + sqrt(1 - E2)) + + val m = y / K0 + val mu = m / (A * (1 - E2 / 4 - 3 * E2 * E2 / 64 - 5 * E2 * E2 * E2 / 256)) + + // Footprint latitude using series expansion + val phi1 = mu + + (3 * e1 / 2 - 27 * e1.pow(3) / 32) * sin(2 * mu) + + (21 * e1 * e1 / 16 - 55 * e1.pow(4) / 32) * sin(4 * mu) + + (151 * e1.pow(3) / 96) * sin(6 * mu) + + (1097 * e1.pow(4) / 512) * sin(8 * mu) + + val sinPhi1 = sin(phi1) + val cosPhi1 = cos(phi1) + val tanPhi1 = tan(phi1) + + val n1 = A / sqrt(1 - E2 * sinPhi1 * sinPhi1) + val t1 = tanPhi1 * tanPhi1 + val c1 = (E2 / (1 - E2)) * cosPhi1 * cosPhi1 + val r1 = A * (1 - E2) / (1 - E2 * sinPhi1 * sinPhi1).pow(1.5) + val d = x / (n1 * K0) + + val lat = phi1 - (n1 * tanPhi1 / r1) * ( + d * d / 2 - + (5 + 3 * t1 + 10 * c1 - 4 * c1 * c1 - 9 * E2 / (1 - E2)) * d.pow(4) / 24 + + (61 + 90 * t1 + 298 * c1 + 45 * t1 * t1 - 252 * E2 / (1 - E2) - 3 * c1 * c1) * d.pow(6) / 720 + ) + + val lon = (d - + (1 + 2 * t1 + c1) * d.pow(3) / 6 + + (5 - 2 * c1 + 28 * t1 - 3 * c1 * c1 + 8 * E2 / (1 - E2) + 24 * t1 * t1) * d.pow(5) / 120 + ) / cosPhi1 + + return LatLon( + latitude = Math.toDegrees(lat), + longitude = ZONE_33_CENTRAL_MERIDIAN + Math.toDegrees(lon) + ) + } +} diff --git a/app/src/main/java/no/naiv/tilfluktsrom/util/DistanceUtils.kt b/app/src/main/java/no/naiv/tilfluktsrom/util/DistanceUtils.kt new file mode 100644 index 0000000..3e66239 --- /dev/null +++ b/app/src/main/java/no/naiv/tilfluktsrom/util/DistanceUtils.kt @@ -0,0 +1,47 @@ +package no.naiv.tilfluktsrom.util + +import kotlin.math.* + +/** + * Distance and bearing calculations using the Haversine formula. + */ +object DistanceUtils { + + private const val EARTH_RADIUS_METERS = 6371000.0 + + /** + * Calculate the distance in meters between two WGS84 points. + */ + fun distanceMeters(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double { + val dLat = Math.toRadians(lat2 - lat1) + val dLon = Math.toRadians(lon2 - lon1) + val a = sin(dLat / 2).pow(2) + + cos(Math.toRadians(lat1)) * cos(Math.toRadians(lat2)) * + sin(dLon / 2).pow(2) + return EARTH_RADIUS_METERS * 2 * atan2(sqrt(a), sqrt(1 - a)) + } + + /** + * Calculate the initial bearing (in degrees, 0=north, clockwise) + * from point 1 to point 2. + */ + fun bearingDegrees(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double { + val phi1 = Math.toRadians(lat1) + val phi2 = Math.toRadians(lat2) + val dLambda = Math.toRadians(lon2 - lon1) + val y = sin(dLambda) * cos(phi2) + val x = cos(phi1) * sin(phi2) - sin(phi1) * cos(phi2) * cos(dLambda) + return (Math.toDegrees(atan2(y, x)) + 360) % 360 + } + + /** + * Format distance for display: meters if <1km, otherwise km with one decimal. + */ + fun formatDistance(meters: Double): String { + return if (meters < 1000) { + "${meters.toInt()} m" + } else { + "${"%.1f".format(meters / 1000)} km" + } + } +} diff --git a/app/src/main/res/drawable/ic_compass.xml b/app/src/main/res/drawable/ic_compass.xml new file mode 100644 index 0000000..5163ecb --- /dev/null +++ b/app/src/main/res/drawable/ic_compass.xml @@ -0,0 +1,19 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..2753f2f --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..8461e34 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,16 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/ic_map.xml b/app/src/main/res/drawable/ic_map.xml new file mode 100644 index 0000000..ed31f65 --- /dev/null +++ b/app/src/main/res/drawable/ic_map.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_refresh.xml b/app/src/main/res/drawable/ic_refresh.xml new file mode 100644 index 0000000..cf10af1 --- /dev/null +++ b/app/src/main/res/drawable/ic_refresh.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_shelter.xml b/app/src/main/res/drawable/ic_shelter.xml new file mode 100644 index 0000000..ae032b7 --- /dev/null +++ b/app/src/main/res/drawable/ic_shelter.xml @@ -0,0 +1,18 @@ + + + + + + + + + + diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..779481f --- /dev/null +++ b/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,192 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_shelter.xml b/app/src/main/res/layout/item_shelter.xml new file mode 100644 index 0000000..d150b81 --- /dev/null +++ b/app/src/main/res/layout/item_shelter.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.xml b/app/src/main/res/mipmap-hdpi/ic_launcher.xml new file mode 100644 index 0000000..6b78462 --- /dev/null +++ b/app/src/main/res/mipmap-hdpi/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.xml b/app/src/main/res/mipmap-xhdpi/ic_launcher.xml new file mode 100644 index 0000000..6b78462 --- /dev/null +++ b/app/src/main/res/mipmap-xhdpi/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.xml b/app/src/main/res/mipmap-xxhdpi/ic_launcher.xml new file mode 100644 index 0000000..6b78462 --- /dev/null +++ b/app/src/main/res/mipmap-xxhdpi/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.xml b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.xml new file mode 100644 index 0000000..6b78462 --- /dev/null +++ b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/values-nb/strings.xml b/app/src/main/res/values-nb/strings.xml new file mode 100644 index 0000000..dd4080a --- /dev/null +++ b/app/src/main/res/values-nb/strings.xml @@ -0,0 +1,39 @@ + + + Tilfluktsrom + + + Klar + Laster tilfluktsromdata… + Oppdaterer… + Frakoblet modus + %d tilfluktsrom lastet + Venter på GPS… + Lagrer kart for frakoblet bruk… + + + Laster ned tilfluktsromdata… + Lagrer kartfliser… + Gjør klar for første gangs bruk… + + + %d plasser + Rom %d + Nærmeste tilfluktsrom + Ingen tilfluktsromdata tilgjengelig + + + Oppdater data + Bytt mellom kart og kompassvisning + + + Posisjonstillatelse kreves + Denne appen trenger din posisjon for å finne nærmeste tilfluktsrom. Vennligst gi tilgang til posisjon. + Posisjonstillatelse avslått. Appen kan ikke finne tilfluktsrom i nærheten uten den. + + + Kunne ikke laste ned tilfluktsromdata. Sjekk internettforbindelsen. + Ingen lagrede data tilgjengelig. Koble til internett for å laste ned tilfluktsromdata. + Tilfluktsromdata oppdatert + Oppdatering mislyktes — bruker lagrede data + diff --git a/app/src/main/res/values-nn/strings.xml b/app/src/main/res/values-nn/strings.xml new file mode 100644 index 0000000..92b361d --- /dev/null +++ b/app/src/main/res/values-nn/strings.xml @@ -0,0 +1,39 @@ + + + Tilfluktsrom + + + Klar + Lastar tilfluktsromdata… + Oppdaterer… + Fråkopla modus + %d tilfluktsrom lasta + Ventar på GPS… + Lagrar kart for fråkopla bruk… + + + Lastar ned tilfluktsromdata… + Lagrar kartfliser… + Gjer klar for fyrste gongs bruk… + + + %d plassar + Rom %d + Næraste tilfluktsrom + Ingen tilfluktsromdata tilgjengeleg + + + Oppdater data + Byt mellom kart og kompassvising + + + Posisjonsløyve krevst + Denne appen treng posisjonen din for å finne næraste tilfluktsrom. Ver venleg og gje tilgang til posisjon. + Posisjonsløyve avslått. Appen kan ikkje finne tilfluktsrom i nærleiken utan det. + + + Kunne ikkje laste ned tilfluktsromdata. Sjekk internettilkoplinga. + Ingen lagra data tilgjengeleg. Kopla til internett for å laste ned tilfluktsromdata. + Tilfluktsromdata oppdatert + Oppdatering mislukkast — brukar lagra data + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..cb10124 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,20 @@ + + + + #FF6B35 + #E55A2B + #FFC107 + + #1A1A2E + #16213E + #B0BEC5 + #1A1A2E + #0F0F23 + #CC000000 + + #ECEFF1 + #90A4AE + + #FFFFFF + #000000 + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..b357285 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,39 @@ + + + Tilfluktsrom + + + Ready + Loading shelter data… + Updating… + Offline mode + %d shelters loaded + Waiting for GPS… + Caching map for offline use… + + + Downloading shelter data… + Caching map tiles… + Setting up for first use… + + + %d places + Room %d + Nearest shelter + No shelter data available + + + Refresh data + Toggle map/compass view + + + Location permission required + This app needs your location to find the nearest shelter. Please grant location access. + Location permission denied. The app cannot find nearby shelters without it. + + + Could not download shelter data. Check your internet connection. + No cached data available. Connect to the internet to download shelter data. + Shelter data updated + Update failed — using cached data + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..b64da64 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,11 @@ + + + + diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000..a566555 --- /dev/null +++ b/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,7 @@ + + + + + tile.openstreetmap.org + + diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..38bac25 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,5 @@ +plugins { + id("com.android.application") version "8.5.2" apply false + id("org.jetbrains.kotlin.android") version "1.9.24" apply false + id("com.google.devtools.ksp") version "1.9.24-1.0.20" apply false +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..8679d5b --- /dev/null +++ b/gradle.properties @@ -0,0 +1,5 @@ +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +android.useAndroidX=true +kotlin.code.style=official +android.nonTransitiveRClass=true +android.suppressUnsupportedCompileSdk=35 diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..4844ffe Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..336873a --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStorePath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..17a9170 --- /dev/null +++ b/gradlew @@ -0,0 +1,176 @@ +#!/usr/bin/env sh + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=$(save "$@") + +# Collect all arguments for the java command, following the shell quoting and substitution rules +if $JAVACMD --add-opens java.base/java.lang=ALL-UNNAMED -version ; then + DEFAULT_JVM_OPTS="--add-opens java.base/java.lang=ALL-UNNAMED $DEFAULT_JVM_OPTS" +fi + +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong +if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then + cd "$(dirname "$0")" +fi + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..e95643d --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,84 @@ +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS= + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windows variants + +if not "%OS%" == "Windows_NT" goto win9xME_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..286f76b --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,19 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +@Suppress("UnstableApiUsage") +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "Tilfluktsrom" +include(":app")