From 27cad094e7de317b49ffc49d3c1abe0999c37bf7 Mon Sep 17 00:00:00 2001 From: Ole-Morten Duesund Date: Sun, 8 Mar 2026 16:14:19 +0100 Subject: [PATCH] Initial commit: Norwegian emergency shelter finder app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitignore | 11 + CLAUDE.md | 43 ++ app/build.gradle.kts | 70 +++ app/proguard-rules.pro | 12 + app/src/main/AndroidManifest.xml | 32 ++ .../java/no/naiv/tilfluktsrom/MainActivity.kt | 428 ++++++++++++++++++ .../no/naiv/tilfluktsrom/TilfluktsromApp.kt | 19 + .../naiv/tilfluktsrom/data/MapCacheManager.kt | 155 +++++++ .../java/no/naiv/tilfluktsrom/data/Shelter.kt | 19 + .../no/naiv/tilfluktsrom/data/ShelterDao.kt | 26 ++ .../naiv/tilfluktsrom/data/ShelterDatabase.kt | 27 ++ .../tilfluktsrom/data/ShelterGeoJsonParser.kt | 72 +++ .../tilfluktsrom/data/ShelterRepository.kt | 93 ++++ .../tilfluktsrom/location/LocationProvider.kt | 93 ++++ .../tilfluktsrom/location/ShelterFinder.kt | 47 ++ .../tilfluktsrom/ui/DirectionArrowView.kt | 77 ++++ .../tilfluktsrom/ui/ShelterListAdapter.kt | 77 ++++ .../tilfluktsrom/util/CoordinateConverter.kt | 73 +++ .../naiv/tilfluktsrom/util/DistanceUtils.kt | 47 ++ app/src/main/res/drawable/ic_compass.xml | 19 + .../res/drawable/ic_launcher_background.xml | 11 + .../res/drawable/ic_launcher_foreground.xml | 16 + app/src/main/res/drawable/ic_map.xml | 11 + app/src/main/res/drawable/ic_refresh.xml | 11 + app/src/main/res/drawable/ic_shelter.xml | 18 + app/src/main/res/layout/activity_main.xml | 192 ++++++++ app/src/main/res/layout/item_shelter.xml | 54 +++ app/src/main/res/mipmap-hdpi/ic_launcher.xml | 5 + app/src/main/res/mipmap-xhdpi/ic_launcher.xml | 5 + .../main/res/mipmap-xxhdpi/ic_launcher.xml | 5 + .../main/res/mipmap-xxxhdpi/ic_launcher.xml | 5 + app/src/main/res/values-nb/strings.xml | 39 ++ app/src/main/res/values-nn/strings.xml | 39 ++ app/src/main/res/values/colors.xml | 20 + app/src/main/res/values/strings.xml | 39 ++ app/src/main/res/values/themes.xml | 11 + .../main/res/xml/network_security_config.xml | 7 + build.gradle.kts | 5 + gradle.properties | 5 + gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 57186 bytes gradle/wrapper/gradle-wrapper.properties | 5 + gradlew | 176 +++++++ gradlew.bat | 84 ++++ settings.gradle.kts | 19 + 44 files changed, 2222 insertions(+) create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 app/build.gradle.kts create mode 100644 app/proguard-rules.pro create mode 100644 app/src/main/AndroidManifest.xml create mode 100644 app/src/main/java/no/naiv/tilfluktsrom/MainActivity.kt create mode 100644 app/src/main/java/no/naiv/tilfluktsrom/TilfluktsromApp.kt create mode 100644 app/src/main/java/no/naiv/tilfluktsrom/data/MapCacheManager.kt create mode 100644 app/src/main/java/no/naiv/tilfluktsrom/data/Shelter.kt create mode 100644 app/src/main/java/no/naiv/tilfluktsrom/data/ShelterDao.kt create mode 100644 app/src/main/java/no/naiv/tilfluktsrom/data/ShelterDatabase.kt create mode 100644 app/src/main/java/no/naiv/tilfluktsrom/data/ShelterGeoJsonParser.kt create mode 100644 app/src/main/java/no/naiv/tilfluktsrom/data/ShelterRepository.kt create mode 100644 app/src/main/java/no/naiv/tilfluktsrom/location/LocationProvider.kt create mode 100644 app/src/main/java/no/naiv/tilfluktsrom/location/ShelterFinder.kt create mode 100644 app/src/main/java/no/naiv/tilfluktsrom/ui/DirectionArrowView.kt create mode 100644 app/src/main/java/no/naiv/tilfluktsrom/ui/ShelterListAdapter.kt create mode 100644 app/src/main/java/no/naiv/tilfluktsrom/util/CoordinateConverter.kt create mode 100644 app/src/main/java/no/naiv/tilfluktsrom/util/DistanceUtils.kt create mode 100644 app/src/main/res/drawable/ic_compass.xml create mode 100644 app/src/main/res/drawable/ic_launcher_background.xml create mode 100644 app/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 app/src/main/res/drawable/ic_map.xml create mode 100644 app/src/main/res/drawable/ic_refresh.xml create mode 100644 app/src/main/res/drawable/ic_shelter.xml create mode 100644 app/src/main/res/layout/activity_main.xml create mode 100644 app/src/main/res/layout/item_shelter.xml create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher.xml create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher.xml create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher.xml create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher.xml create mode 100644 app/src/main/res/values-nb/strings.xml create mode 100644 app/src/main/res/values-nn/strings.xml create mode 100644 app/src/main/res/values/colors.xml create mode 100644 app/src/main/res/values/strings.xml create mode 100644 app/src/main/res/values/themes.xml create mode 100644 app/src/main/res/xml/network_security_config.xml create mode 100644 build.gradle.kts create mode 100644 gradle.properties create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100644 settings.gradle.kts 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 0000000000000000000000000000000000000000..4844ffe1ec1b11b61b80bafeb2fb7ceee388e545 GIT binary patch literal 57186 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 NKR?FsKk;3U=O0@HZASnA literal 0 HcmV?d00001 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")