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 <noreply@anthropic.com>
This commit is contained in:
Ole-Morten Duesund 2026-03-08 16:14:19 +01:00
commit 27cad094e7
44 changed files with 2222 additions and 0 deletions

11
.gitignore vendored Normal file
View file

@ -0,0 +1,11 @@
*.iml
.gradle
/local.properties
/.idea
.DS_Store
/build
/captures
.externalNativeBuild
.cxx
local.properties
/app/build

43
CLAUDE.md Normal file
View file

@ -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`

70
app/build.gradle.kts Normal file
View file

@ -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")
}

12
app/proguard-rules.pro vendored Normal file
View file

@ -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.**

View file

@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<!-- Write to external storage for OSMDroid tile cache on older devices -->
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
<application
android:name=".TilfluktsromApp"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.Tilfluktsrom"
android:networkSecurityConfig="@xml/network_security_config">
<activity
android:name=".MainActivity"
android:exported="true"
android:windowSoftInputMode="adjustPan">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View file

@ -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<Shelter> = emptyList()
private var nearestShelters: List<ShelterWithDistance> = emptyList()
private var selectedShelterIndex = 0
private var deviceHeading = 0f
private var isCompassMode = false
private var locationJob: Job? = null
private var shelterMarkers: MutableList<Marker> = 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
}
}

View file

@ -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")
}
}
}

View file

@ -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
}
}
}

View file

@ -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
)

View file

@ -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<List<Shelter>>
@Query("SELECT * FROM shelters")
suspend fun getAllSheltersList(): List<Shelter>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(shelters: List<Shelter>)
@Query("DELETE FROM shelters")
suspend fun deleteAll()
@Query("SELECT COUNT(*) FROM shelters")
suspend fun count(): Int
}

View file

@ -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 }
}
}
}
}

View file

@ -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<Shelter> {
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<Shelter> {
val root = JSONObject(json)
val features = root.getJSONArray("features")
val shelters = mutableListOf<Shelter>()
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
}
}

View file

@ -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<List<Shelter>> = 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
}
}
}

View file

@ -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<Location> = 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
}
}

View file

@ -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<Shelter>,
latitude: Double,
longitude: Double,
count: Int = 3
): List<ShelterWithDistance> {
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)
}
}

View file

@ -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()
}
}

View file

@ -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<ShelterWithDistance, ShelterListAdapter.ViewHolder>(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<ShelterWithDistance>() {
override fun areItemsTheSame(a: ShelterWithDistance, b: ShelterWithDistance) =
a.shelter.lokalId == b.shelter.lokalId
override fun areContentsTheSame(a: ShelterWithDistance, b: ShelterWithDistance) =
a == b
}
}
}

View file

@ -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)
)
}
}

View file

@ -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"
}
}
}

View file

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFF"
android:pathData="M12,2 C6.48,2 2,6.48 2,12 C2,17.52 6.48,22 12,22 C17.52,22 22,17.52 22,12 C22,6.48 17.52,2 12,2 Z M12,20 C7.59,20 4,16.41 4,12 C4,7.59 7.59,4 12,4 C16.41,4 20,7.59 20,12 C20,16.41 16.41,20 12,20 Z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M14.5,12 L12,2 L9.5,12 L12,22 Z" />
<path
android:fillColor="#CCFFFFFF"
android:pathData="M12,2 L14.5,12 L12,22 Z" />
</vector>

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#1A1A2E"
android:pathData="M0,0 L108,0 L108,108 L0,108 Z" />
</vector>

View file

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- Shelter shield icon centered in adaptive icon safe zone -->
<path
android:fillColor="#FF6B35"
android:pathData="M54,24 L30,36 L30,54 C30,70 39,83 54,87 C69,83 78,70 78,54 L78,36 Z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M51,42 L57,42 L57,51 L66,51 L66,57 L57,57 L57,66 L51,66 L51,57 L42,57 L42,51 L51,51 Z" />
</vector>

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFF"
android:pathData="M20.5,3 L20.34,3.03 L15,5.1 L9,3 L3.36,4.9 C3.15,4.97 3,5.15 3,5.38 L3,20.5 C3,20.78 3.22,21 3.5,21 L3.66,20.97 L9,18.9 L15,21 L20.64,19.1 C20.85,19.03 21,18.85 21,18.62 L21,3.5 C21,3.22 20.78,3 20.5,3 Z M15,19 L9,16.89 L9,5 L15,7.11 Z" />
</vector>

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFF"
android:pathData="M17.65,6.35 C16.2,4.9 14.21,4 12,4 C7.58,4 4.01,7.58 4.01,12 C4.01,16.42 7.58,20 12,20 C15.73,20 18.84,17.45 19.73,14 L17.65,14 C16.83,16.33 14.61,18 12,18 C8.69,18 6,15.31 6,12 C6,8.69 8.69,6 12,6 C13.66,6 15.14,6.69 16.22,7.78 L13,11 L20,11 L20,4 Z" />
</vector>

View file

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Shelter marker icon: a shield/bunker symbol -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<!-- Shield body -->
<path
android:fillColor="#FF6B35"
android:pathData="M12,2 L3,7 L3,12 C3,17.5 6.8,22.7 12,24 C17.2,22.7 21,17.5 21,12 L21,7 Z" />
<!-- Inner cross/plus symbol for shelter -->
<path
android:fillColor="#FFFFFF"
android:pathData="M11,8 L13,8 L13,11 L16,11 L16,13 L13,13 L13,16 L11,16 L11,13 L8,13 L8,11 L11,11 Z" />
</vector>

View file

@ -0,0 +1,192 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/background"
tools:context=".MainActivity">
<!-- Status bar at top -->
<LinearLayout
android:id="@+id/statusBar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/status_bar_bg"
android:gravity="center_vertical"
android:orientation="horizontal"
android:padding="8dp"
app:layout_constraintTop_toTopOf="parent">
<TextView
android:id="@+id/statusText"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textColor="@color/status_text"
android:textSize="12sp"
tools:text="@string/status_ready" />
<ImageButton
android:id="@+id/refreshButton"
android:layout_width="36dp"
android:layout_height="36dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/action_refresh"
android:src="@drawable/ic_refresh"
app:tint="@color/status_text" />
</LinearLayout>
<!-- Map view (main content) -->
<org.osmdroid.views.MapView
android:id="@+id/mapView"
android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_constraintTop_toBottomOf="@id/statusBar"
app:layout_constraintBottom_toTopOf="@id/bottomSheet" />
<!-- Direction arrow overlay (shown when toggled) -->
<FrameLayout
android:id="@+id/compassContainer"
android:layout_width="match_parent"
android:layout_height="0dp"
android:background="@color/compass_bg"
android:visibility="gone"
app:layout_constraintTop_toBottomOf="@id/statusBar"
app:layout_constraintBottom_toTopOf="@id/bottomSheet">
<no.naiv.tilfluktsrom.ui.DirectionArrowView
android:id="@+id/directionArrow"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<TextView
android:id="@+id/compassDistanceText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|center_horizontal"
android:layout_marginBottom="32dp"
android:textColor="@color/white"
android:textSize="48sp"
android:textStyle="bold"
tools:text="1.2 km" />
<TextView
android:id="@+id/compassAddressText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="top|center_horizontal"
android:layout_marginTop="24dp"
android:textAlignment="center"
android:textColor="@color/white"
android:textSize="18sp"
tools:text="Storgata 1" />
</FrameLayout>
<!-- Toggle button: map <-> compass -->
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/toggleViewFab"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="16dp"
android:contentDescription="@string/action_toggle_view"
android:src="@drawable/ic_compass"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toTopOf="@id/bottomSheet"
app:backgroundTint="@color/shelter_primary"
app:tint="@color/white" />
<!-- Bottom sheet with shelter info -->
<LinearLayout
android:id="@+id/bottomSheet"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/bottom_sheet_bg"
android:orientation="vertical"
android:padding="12dp"
android:elevation="8dp"
app:layout_constraintBottom_toBottomOf="parent">
<!-- Selected shelter summary -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal">
<no.naiv.tilfluktsrom.ui.DirectionArrowView
android:id="@+id/miniArrow"
android:layout_width="48dp"
android:layout_height="48dp" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="12dp"
android:orientation="vertical">
<TextView
android:id="@+id/selectedShelterAddress"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/text_primary"
android:textSize="16sp"
android:textStyle="bold"
tools:text="Storgata 1" />
<TextView
android:id="@+id/selectedShelterDetails"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/text_secondary"
android:textSize="13sp"
tools:text="1.2 km - 400 plasser - Rom 776" />
</LinearLayout>
</LinearLayout>
<!-- Nearest shelters list -->
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/shelterList"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:clipToPadding="false" />
</LinearLayout>
<!-- Loading overlay for initial data download -->
<FrameLayout
android:id="@+id/loadingOverlay"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/loading_bg"
android:clickable="true"
android:focusable="true"
android:visibility="gone">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:gravity="center"
android:orientation="vertical">
<ProgressBar
android:id="@+id/loadingProgress"
android:layout_width="64dp"
android:layout_height="64dp"
android:indeterminate="true" />
<TextView
android:id="@+id/loadingText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:textColor="@color/white"
android:textSize="16sp"
tools:text="@string/loading_shelters" />
</LinearLayout>
</FrameLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -0,0 +1,54 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackground"
android:orientation="vertical"
android:paddingHorizontal="12dp"
android:paddingVertical="8dp">
<TextView
android:id="@+id/shelterAddress"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/text_primary"
android:textSize="14sp"
android:textStyle="bold"
tools:text="Storgata 1 - Bunker (off)" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<TextView
android:id="@+id/shelterDistance"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/shelter_primary"
android:textSize="13sp"
android:textStyle="bold"
tools:text="1.2 km" />
<TextView
android:id="@+id/shelterCapacity"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:textColor="@color/text_secondary"
android:textSize="12sp"
tools:text="400 plasser" />
<TextView
android:id="@+id/shelterRoomNr"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:textColor="@color/text_secondary"
android:textSize="12sp"
tools:text="Rom 776" />
</LinearLayout>
</LinearLayout>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View file

@ -0,0 +1,39 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Tilfluktsrom</string>
<!-- Statusmeldinger -->
<string name="status_ready">Klar</string>
<string name="status_loading">Laster tilfluktsromdata&#8230;</string>
<string name="status_updating">Oppdaterer&#8230;</string>
<string name="status_offline">Frakoblet modus</string>
<string name="status_shelters_loaded">%d tilfluktsrom lastet</string>
<string name="status_no_location">Venter på GPS&#8230;</string>
<string name="status_caching_map">Lagrer kart for frakoblet bruk&#8230;</string>
<!-- Lasteskjerm -->
<string name="loading_shelters">Laster ned tilfluktsromdata&#8230;</string>
<string name="loading_map">Lagrer kartfliser&#8230;</string>
<string name="loading_first_time">Gjør klar for første gangs bruk&#8230;</string>
<!-- Tilfluktsrominfo -->
<string name="shelter_capacity">%d plasser</string>
<string name="shelter_room_nr">Rom %d</string>
<string name="nearest_shelter">Nærmeste tilfluktsrom</string>
<string name="no_shelters">Ingen tilfluktsromdata tilgjengelig</string>
<!-- Handlinger -->
<string name="action_refresh">Oppdater data</string>
<string name="action_toggle_view">Bytt mellom kart og kompassvisning</string>
<!-- Tillatelser -->
<string name="permission_location_title">Posisjonstillatelse kreves</string>
<string name="permission_location_message">Denne appen trenger din posisjon for å finne nærmeste tilfluktsrom. Vennligst gi tilgang til posisjon.</string>
<string name="permission_denied">Posisjonstillatelse avslått. Appen kan ikke finne tilfluktsrom i nærheten uten den.</string>
<!-- Feil -->
<string name="error_download_failed">Kunne ikke laste ned tilfluktsromdata. Sjekk internettforbindelsen.</string>
<string name="error_no_data_offline">Ingen lagrede data tilgjengelig. Koble til internett for å laste ned tilfluktsromdata.</string>
<string name="update_success">Tilfluktsromdata oppdatert</string>
<string name="update_failed">Oppdatering mislyktes — bruker lagrede data</string>
</resources>

View file

@ -0,0 +1,39 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Tilfluktsrom</string>
<!-- Statusmeldingar -->
<string name="status_ready">Klar</string>
<string name="status_loading">Lastar tilfluktsromdata&#8230;</string>
<string name="status_updating">Oppdaterer&#8230;</string>
<string name="status_offline">Fråkopla modus</string>
<string name="status_shelters_loaded">%d tilfluktsrom lasta</string>
<string name="status_no_location">Ventar på GPS&#8230;</string>
<string name="status_caching_map">Lagrar kart for fråkopla bruk&#8230;</string>
<!-- Lasteskjerm -->
<string name="loading_shelters">Lastar ned tilfluktsromdata&#8230;</string>
<string name="loading_map">Lagrar kartfliser&#8230;</string>
<string name="loading_first_time">Gjer klar for fyrste gongs bruk&#8230;</string>
<!-- Tilfluktsrominfo -->
<string name="shelter_capacity">%d plassar</string>
<string name="shelter_room_nr">Rom %d</string>
<string name="nearest_shelter">Næraste tilfluktsrom</string>
<string name="no_shelters">Ingen tilfluktsromdata tilgjengeleg</string>
<!-- Handlingar -->
<string name="action_refresh">Oppdater data</string>
<string name="action_toggle_view">Byt mellom kart og kompassvising</string>
<!-- Løyve -->
<string name="permission_location_title">Posisjonsløyve krevst</string>
<string name="permission_location_message">Denne appen treng posisjonen din for å finne næraste tilfluktsrom. Ver venleg og gje tilgang til posisjon.</string>
<string name="permission_denied">Posisjonsløyve avslått. Appen kan ikkje finne tilfluktsrom i nærleiken utan det.</string>
<!-- Feil -->
<string name="error_download_failed">Kunne ikkje laste ned tilfluktsromdata. Sjekk internettilkoplinga.</string>
<string name="error_no_data_offline">Ingen lagra data tilgjengeleg. Kopla til internett for å laste ned tilfluktsromdata.</string>
<string name="update_success">Tilfluktsromdata oppdatert</string>
<string name="update_failed">Oppdatering mislukkast — brukar lagra data</string>
</resources>

View file

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Emergency-themed color palette: high contrast, easily visible -->
<color name="shelter_primary">#FF6B35</color>
<color name="shelter_primary_dark">#E55A2B</color>
<color name="shelter_accent">#FFC107</color>
<color name="background">#1A1A2E</color>
<color name="status_bar_bg">#16213E</color>
<color name="status_text">#B0BEC5</color>
<color name="bottom_sheet_bg">#1A1A2E</color>
<color name="compass_bg">#0F0F23</color>
<color name="loading_bg">#CC000000</color>
<color name="text_primary">#ECEFF1</color>
<color name="text_secondary">#90A4AE</color>
<color name="white">#FFFFFF</color>
<color name="black">#000000</color>
</resources>

View file

@ -0,0 +1,39 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Tilfluktsrom</string>
<!-- Status messages -->
<string name="status_ready">Ready</string>
<string name="status_loading">Loading shelter data&#8230;</string>
<string name="status_updating">Updating&#8230;</string>
<string name="status_offline">Offline mode</string>
<string name="status_shelters_loaded">%d shelters loaded</string>
<string name="status_no_location">Waiting for GPS&#8230;</string>
<string name="status_caching_map">Caching map for offline use&#8230;</string>
<!-- Loading screen -->
<string name="loading_shelters">Downloading shelter data&#8230;</string>
<string name="loading_map">Caching map tiles&#8230;</string>
<string name="loading_first_time">Setting up for first use&#8230;</string>
<!-- Shelter info -->
<string name="shelter_capacity">%d places</string>
<string name="shelter_room_nr">Room %d</string>
<string name="nearest_shelter">Nearest shelter</string>
<string name="no_shelters">No shelter data available</string>
<!-- Actions -->
<string name="action_refresh">Refresh data</string>
<string name="action_toggle_view">Toggle map/compass view</string>
<!-- Permissions -->
<string name="permission_location_title">Location permission required</string>
<string name="permission_location_message">This app needs your location to find the nearest shelter. Please grant location access.</string>
<string name="permission_denied">Location permission denied. The app cannot find nearby shelters without it.</string>
<!-- Errors -->
<string name="error_download_failed">Could not download shelter data. Check your internet connection.</string>
<string name="error_no_data_offline">No cached data available. Connect to the internet to download shelter data.</string>
<string name="update_success">Shelter data updated</string>
<string name="update_failed">Update failed — using cached data</string>
</resources>

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.Tilfluktsrom" parent="Theme.Material3.DayNight.NoActionBar">
<item name="colorPrimary">@color/shelter_primary</item>
<item name="colorPrimaryDark">@color/shelter_primary_dark</item>
<item name="colorAccent">@color/shelter_accent</item>
<item name="android:windowBackground">@color/background</item>
<item name="android:statusBarColor">@color/status_bar_bg</item>
<item name="android:navigationBarColor">@color/background</item>
</style>
</resources>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<!-- Allow cleartext for OSMDroid tile servers that may use HTTP -->
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">tile.openstreetmap.org</domain>
</domain-config>
</network-security-config>

5
build.gradle.kts Normal file
View file

@ -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
}

5
gradle.properties Normal file
View file

@ -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

BIN
gradle/wrapper/gradle-wrapper.jar vendored Normal file

Binary file not shown.

View file

@ -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

176
gradlew vendored Executable file
View file

@ -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" "$@"

84
gradlew.bat vendored Normal file
View file

@ -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

19
settings.gradle.kts Normal file
View file

@ -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")