From 056ff994426b3dfc2ddb7c0cfde6d99536078ff5 Mon Sep 17 00:00:00 2001 From: Juan Ezquerro LLanes Date: Wed, 30 Sep 2026 02:19:00 +0200 Subject: [PATCH] fix: correct geofence window handling and protect user data on upgrade Breaks that could reach users, all verified against a real device holding a v3 database with existing data: - Database: `fallbackToDestructiveMigration` was active with no registered migrations, so every app update wiped all locations and reminders. Adds the full 1->5 chain; each step is validated by MigrationTestHelper against the exported schema. - Notifications: `POST_NOTIFICATIONS` was checked without an SDK guard. The permission does not exist below API 33, so the check always reported denied and no notification was ever posted across the whole supported range (minSdk 24). - Master geofence: registered with only `GEOFENCE_TRANSITION_EXIT` while requesting `INITIAL_TRIGGER_ENTER`, a combination GMS silently ignores. - Spatial search: the bounding-box query only handled the antimeridian when minLon > maxLon, so a window near +/-180 discarded every point on the far side. The window is now tested at +/-360 degrees. - Worker: `ExistingWorkPolicy.REPLACE` could cancel a running recalculation between removing and re-registering geofences, leaving none registered. Expedited and debounced work now use separate names and policies. - Worker: derived state was persisted before GMS accepted the new window, so the UI could claim geofences that did not exist. - Errors: `catch (e: Exception)` swallowed `CancellationException` in the repositories, ViewModels, worker and broadcast receiver. - Messages: `MutableSharedFlow(replay = 0)` discarded error events emitted while nothing was collecting, which is exactly when they matter. Replaced with a buffered channel. - `SettingsManager` treated 0.0 as "unset" for the last recalculation centre, making the valid coordinate (0.0, 0.0) unrepresentable, and wrote the two coordinates in separate transactions that could be read torn. - Aliases were stored untrimmed while the unique index is `COLLATE NOCASE`, so the UI reported "alias available" and SQLite then rejected the insert. New writes are trimmed; a v4->v5 migration fixes existing rows, skipping any that would collide on the unique index or become blank. - App Functions mutations never triggered a recalculation, so reminders created by the assistant had no effect until the app was next launched. - Notifications shared the reminder id as a `PendingIntent` request code, so colliding ids overwrote each other's intent extras. Cleanups: remove the unused navigation and Hilt-navigation dependencies, two dead DI providers, two dead `GeofenceManager` methods, a dead DAO query, an uncollected ViewModel flow, and 14 unused string resources across all 21 locales. Extract `BaseViewModel`, `ReminderStatusChips` and `ThemeSetting.resolve` to remove repeated ViewModel and Compose logic, and deduplicate the three geofence builders. Compose: remember the dynamic color scheme, replace the `as Activity` cast with a safe one, restore the navigation-bar icon appearance, make the tab switch exhaustive, add a back handler, keep form state across rotation, derive map overlay colours from the theme, and call `MapView.onDetach()` when a map leaves composition so its tile thread pool is not leaked. Tests: 142 unit tests and 8 instrumented tests pass, lint is clean. Adds MigrationTest coverage for the alias-trimming SQL, since a correlated subquery that forgets to qualify its outer reference makes the guard permanently true and the migration silently trims nothing. --- app/build.gradle.kts | 21 +- .../4.json | 171 +++++++++ .../5.json | 171 +++++++++ .../geotify/GeofenceRegistrationTest.kt | 77 +++- .../dev/arrase/geotify/data/MigrationTest.kt | 134 +++++++ app/src/main/AndroidManifest.xml | 13 +- .../dev/arrase/geotify/GeotifyApplication.kt | 12 - .../java/dev/arrase/geotify/MainActivity.kt | 41 +- .../appfunction/GeotifyAppFunctions.kt | 22 +- .../arrase/geotify/data/GeotifyDatabase.kt | 9 +- .../arrase/geotify/data/LocationRepository.kt | 66 +++- .../dev/arrase/geotify/data/Migrations.kt | 86 +++++ .../arrase/geotify/data/ReminderRepository.kt | 32 +- .../arrase/geotify/data/SettingsManager.kt | 128 ++++--- .../arrase/geotify/data/dao/LocationDao.kt | 23 +- .../arrase/geotify/data/dao/ReminderDao.kt | 30 +- .../geotify/data/entity/ReminderEntity.kt | 24 +- .../java/dev/arrase/geotify/di/AppModule.kt | 21 +- .../dev/arrase/geotify/di/BindingsModule.kt | 3 - .../arrase/geotify/di/CoroutineDispatchers.kt | 9 +- .../geotify/domain/SpatialSearchUseCase.kt | 100 +++-- .../geofence/AndroidGeofenceManager.kt | 201 ++++------ .../geotify/geofence/BootCompletedReceiver.kt | 22 +- .../geofence/GeofenceBroadcastReceiver.kt | 105 +++-- .../geotify/geofence/GeofenceManager.kt | 27 +- .../geotify/geofence/GeofenceOrchestrator.kt | 53 +-- .../geofence/GeofenceRecalculationWorker.kt | 208 ++++++---- .../notification/NotificationHelper.kt | 30 +- .../dev/arrase/geotify/ui/BaseViewModel.kt | 53 +++ .../dev/arrase/geotify/ui/MainViewModel.kt | 12 +- .../main/java/dev/arrase/geotify/ui/UiText.kt | 37 +- .../arrase/geotify/ui/component/EmptyState.kt | 32 +- .../geotify/ui/component/LocationMapView.kt | 45 ++- .../geotify/ui/component/LocationRow.kt | 20 +- .../arrase/geotify/ui/component/MapPicker.kt | 11 +- .../arrase/geotify/ui/component/MapStyles.kt | 58 ++- .../geotify/ui/component/ReminderMapView.kt | 73 ++-- .../geotify/ui/component/ReminderRow.kt | 49 +-- .../ui/component/ReminderStatusChips.kt | 75 ++++ .../ui/component/SwipeToDeleteBackground.kt | 12 +- .../ui/navigation/GeotifyNavigation.kt | 9 +- .../geotify/ui/screen/LocationsScreen.kt | 85 ++--- .../geotify/ui/screen/LocationsViewModel.kt | 94 +++-- .../geotify/ui/screen/RemindersScreen.kt | 139 ++----- .../geotify/ui/screen/RemindersViewModel.kt | 112 +++--- .../geotify/ui/screen/SettingsScreen.kt | 34 +- .../geotify/ui/screen/SettingsViewModel.kt | 100 +++-- .../java/dev/arrase/geotify/ui/theme/Theme.kt | 32 +- .../geotify/ui/theme/ThemeSettingExt.kt | 10 + .../arrase/geotify/util/ContextExtensions.kt | 26 +- app/src/main/res/values-ar/strings.xml | 25 +- app/src/main/res/values-cs/strings.xml | 23 +- app/src/main/res/values-da/strings.xml | 21 +- app/src/main/res/values-de/strings.xml | 21 +- app/src/main/res/values-es/strings.xml | 22 +- app/src/main/res/values-fr/strings.xml | 22 +- app/src/main/res/values-hi/strings.xml | 21 +- app/src/main/res/values-hu/strings.xml | 21 +- .../res/{values-id => values-in}/strings.xml | 20 +- app/src/main/res/values-it/strings.xml | 22 +- app/src/main/res/values-ja/strings.xml | 20 +- app/src/main/res/values-ko/strings.xml | 20 +- app/src/main/res/values-nl/strings.xml | 21 +- app/src/main/res/values-pl/strings.xml | 23 +- app/src/main/res/values-pt/strings.xml | 22 +- app/src/main/res/values-ru/strings.xml | 23 +- app/src/main/res/values-sv/strings.xml | 21 +- app/src/main/res/values-th/strings.xml | 20 +- app/src/main/res/values-tr/strings.xml | 21 +- app/src/main/res/values-vi/strings.xml | 20 +- app/src/main/res/values-zh/strings.xml | 20 +- app/src/main/res/values/colors.xml | 9 +- app/src/main/res/values/strings.xml | 21 +- .../dev/arrase/geotify/GeofenceLimitTest.kt | 108 ------ .../appfunction/GeotifyAppFunctionsTest.kt | 8 + .../arrase/geotify/data/GeofenceLimitTest.kt | 76 ++++ .../geotify/data/LocationRepositoryTest.kt | 66 +++- .../geotify/data/ReminderRepositoryTest.kt | 64 ++++ .../arrase/geotify/data/entity/EntityTest.kt | 3 +- .../domain/SpatialSearchUseCaseTest.kt | 210 +++++----- .../ui/screen/LocationsViewModelTest.kt | 305 ++++++--------- .../ui/screen/RemindersViewModelTest.kt | 358 +++++++++--------- .../ui/screen/SettingsViewModelTest.kt | 21 +- gradle/libs.versions.toml | 5 +- 84 files changed, 2805 insertions(+), 1855 deletions(-) create mode 100644 app/schemas/dev.arrase.geotify.data.GeotifyDatabase/4.json create mode 100644 app/schemas/dev.arrase.geotify.data.GeotifyDatabase/5.json create mode 100644 app/src/androidTest/java/dev/arrase/geotify/data/MigrationTest.kt create mode 100644 app/src/main/java/dev/arrase/geotify/data/Migrations.kt create mode 100644 app/src/main/java/dev/arrase/geotify/ui/BaseViewModel.kt create mode 100644 app/src/main/java/dev/arrase/geotify/ui/component/ReminderStatusChips.kt create mode 100644 app/src/main/java/dev/arrase/geotify/ui/theme/ThemeSettingExt.kt rename app/src/main/res/{values-id => values-in}/strings.xml (90%) delete mode 100644 app/src/test/java/dev/arrase/geotify/GeofenceLimitTest.kt create mode 100644 app/src/test/java/dev/arrase/geotify/data/GeofenceLimitTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 129eec7..df32b79 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -42,6 +42,11 @@ fun getSigningSecret(entryName: String, propertyKey: String, envVar: String): St } android { + sourceSets { + // MigrationTestHelper reads the exported schemas from the test APK's assets. + getByName("androidTest").assets.srcDir("$projectDir/schemas") + } + namespace = "dev.arrase.geotify" compileSdk = 37 @@ -49,8 +54,8 @@ android { applicationId = "dev.arrase.geotify" minSdk = 24 targetSdk = 36 - versionCode = 14 - versionName = "1.0.0-beta.7" + versionCode = 15 + versionName = "1.0.0-beta.8" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } @@ -103,6 +108,13 @@ ksp { } dependencies { + // room-testing's generated schema parser is compiled against the serialization 1.8.x API, so + // the whole build resolves to that version to satisfy Gradle's consistent resolution between + // the main and androidTest classpaths. + constraints { + add("implementation", "org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.1") + } + // Compose BOM implementation(platform(libs.androidx.compose.bom)) implementation(libs.androidx.compose.ui) @@ -117,12 +129,11 @@ dependencies { implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.lifecycle.runtime.compose) implementation(libs.androidx.lifecycle.viewmodel.compose) - implementation(libs.androidx.navigation.compose) // Hilt implementation(libs.hilt.android) ksp(libs.hilt.compiler) - implementation(libs.androidx.hilt.navigation.compose) + implementation(libs.androidx.hilt.lifecycle.viewmodel.compose) // DataStore implementation(libs.androidx.datastore.preferences) @@ -156,10 +167,12 @@ dependencies { androidTestImplementation(libs.androidx.compose.ui.test.junit4) androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.room.testing) debugImplementation(libs.androidx.compose.ui.test.manifest) debugImplementation(libs.androidx.compose.ui.tooling) } + tasks.register("jacocoTestReport") { dependsOn("testDebugUnitTest") reports { diff --git a/app/schemas/dev.arrase.geotify.data.GeotifyDatabase/4.json b/app/schemas/dev.arrase.geotify.data.GeotifyDatabase/4.json new file mode 100644 index 0000000..2510079 --- /dev/null +++ b/app/schemas/dev.arrase.geotify.data.GeotifyDatabase/4.json @@ -0,0 +1,171 @@ +{ + "formatVersion": 1, + "database": { + "version": 4, + "identityHash": "9d831e611a5440fa11e057484fe587e8", + "entities": [ + { + "tableName": "locations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `alias` TEXT NOT NULL COLLATE NOCASE, `latitude` REAL NOT NULL, `longitude` REAL NOT NULL, `radius_meters` REAL NOT NULL, `notification_responsiveness_ms` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "alias", + "columnName": "alias", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "latitude", + "columnName": "latitude", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "longitude", + "columnName": "longitude", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "radiusMeters", + "columnName": "radius_meters", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "notificationResponsivenessMs", + "columnName": "notification_responsiveness_ms", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_locations_alias", + "unique": true, + "columnNames": [ + "alias" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_locations_alias` ON `${TABLE_NAME}` (`alias`)" + }, + { + "name": "index_locations_latitude_longitude", + "unique": false, + "columnNames": [ + "latitude", + "longitude" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_locations_latitude_longitude` ON `${TABLE_NAME}` (`latitude`, `longitude`)" + } + ] + }, + { + "tableName": "reminders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `location_id` TEXT NOT NULL, `message` TEXT NOT NULL, `transition_type` INTEGER NOT NULL, `is_active` INTEGER NOT NULL, `created_at` INTEGER NOT NULL, `is_in_range` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`), FOREIGN KEY(`location_id`) REFERENCES `locations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "locationId", + "columnName": "location_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "message", + "columnName": "message", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "transitionType", + "columnName": "transition_type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isActive", + "columnName": "is_active", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isInRange", + "columnName": "is_in_range", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_reminders_location_id", + "unique": false, + "columnNames": [ + "location_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_reminders_location_id` ON `${TABLE_NAME}` (`location_id`)" + }, + { + "name": "index_reminders_is_active_created_at", + "unique": false, + "columnNames": [ + "is_active", + "created_at" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_reminders_is_active_created_at` ON `${TABLE_NAME}` (`is_active`, `created_at`)" + } + ], + "foreignKeys": [ + { + "table": "locations", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "location_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '9d831e611a5440fa11e057484fe587e8')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/dev.arrase.geotify.data.GeotifyDatabase/5.json b/app/schemas/dev.arrase.geotify.data.GeotifyDatabase/5.json new file mode 100644 index 0000000..2864a9c --- /dev/null +++ b/app/schemas/dev.arrase.geotify.data.GeotifyDatabase/5.json @@ -0,0 +1,171 @@ +{ + "formatVersion": 1, + "database": { + "version": 5, + "identityHash": "9d831e611a5440fa11e057484fe587e8", + "entities": [ + { + "tableName": "locations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `alias` TEXT NOT NULL COLLATE NOCASE, `latitude` REAL NOT NULL, `longitude` REAL NOT NULL, `radius_meters` REAL NOT NULL, `notification_responsiveness_ms` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "alias", + "columnName": "alias", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "latitude", + "columnName": "latitude", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "longitude", + "columnName": "longitude", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "radiusMeters", + "columnName": "radius_meters", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "notificationResponsivenessMs", + "columnName": "notification_responsiveness_ms", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_locations_alias", + "unique": true, + "columnNames": [ + "alias" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_locations_alias` ON `${TABLE_NAME}` (`alias`)" + }, + { + "name": "index_locations_latitude_longitude", + "unique": false, + "columnNames": [ + "latitude", + "longitude" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_locations_latitude_longitude` ON `${TABLE_NAME}` (`latitude`, `longitude`)" + } + ] + }, + { + "tableName": "reminders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `location_id` TEXT NOT NULL, `message` TEXT NOT NULL, `transition_type` INTEGER NOT NULL, `is_active` INTEGER NOT NULL, `created_at` INTEGER NOT NULL, `is_in_range` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`), FOREIGN KEY(`location_id`) REFERENCES `locations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "locationId", + "columnName": "location_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "message", + "columnName": "message", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "transitionType", + "columnName": "transition_type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isActive", + "columnName": "is_active", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isInRange", + "columnName": "is_in_range", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_reminders_location_id", + "unique": false, + "columnNames": [ + "location_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_reminders_location_id` ON `${TABLE_NAME}` (`location_id`)" + }, + { + "name": "index_reminders_is_active_created_at", + "unique": false, + "columnNames": [ + "is_active", + "created_at" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_reminders_is_active_created_at` ON `${TABLE_NAME}` (`is_active`, `created_at`)" + } + ], + "foreignKeys": [ + { + "table": "locations", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "location_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '9d831e611a5440fa11e057484fe587e8')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/dev/arrase/geotify/GeofenceRegistrationTest.kt b/app/src/androidTest/java/dev/arrase/geotify/GeofenceRegistrationTest.kt index d55ddbc..cf044e4 100644 --- a/app/src/androidTest/java/dev/arrase/geotify/GeofenceRegistrationTest.kt +++ b/app/src/androidTest/java/dev/arrase/geotify/GeofenceRegistrationTest.kt @@ -1,35 +1,76 @@ package dev.arrase.geotify -import androidx.test.platform.app.InstrumentationRegistry +import android.Manifest +import android.content.pm.PackageManager +import androidx.core.content.ContextCompat import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.google.android.gms.location.Geofence +import dev.arrase.geotify.data.SettingsManager import dev.arrase.geotify.data.entity.LocationEntity import dev.arrase.geotify.geofence.AndroidGeofenceManager import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Assume.assumeTrue import org.junit.Test import org.junit.runner.RunWith -import com.google.android.gms.location.Geofence +/** + * Exercises the real Play services geofencing client. Play services exposes no way to query the + * registered set, so these tests assert the contract the app depends on: registration is refused + * (not silently swallowed) when permissions are missing, and accepted otherwise. + */ @RunWith(AndroidJUnit4::class) class GeofenceRegistrationTest { + private val context = InstrumentationRegistry.getInstrumentation().targetContext + private val manager = AndroidGeofenceManager(context, SettingsManager(context)) + + private val location = LocationEntity( + id = "instrumentation_test_location", + alias = "TestLocation", + latitude = 39.950914, + longitude = -0.062596, + radiusMeters = 100f + ) + @Test - fun testRegisterGeofence() = runBlocking { - val appContext = InstrumentationRegistry.getInstrumentation().targetContext - val settingsManager = dev.arrase.geotify.data.SettingsManager(appContext) - val manager = AndroidGeofenceManager(appContext, settingsManager) - val location = LocationEntity( - id = "test_location_id", - alias = "TestLocation", - latitude = 39.950914, - longitude = -0.062596, - radiusMeters = 100f - ) + fun slidingWindow_isAcceptedByPlayServices_andCanBeCleared() = runBlocking { + assumeTrue("Location permission not granted", hasLocationPermission()) + try { - manager.registerGeofenceForLocation(location, Geofence.GEOFENCE_TRANSITION_ENTER) - println("Geofence registered successfully in test") - } catch (e: Exception) { - e.printStackTrace() - throw e + val registered = manager.registerSlidingWindowGeofences( + locations = mapOf(location to Geofence.GEOFENCE_TRANSITION_ENTER), + centerLat = location.latitude, + centerLon = location.longitude, + innerRadiusMeters = 500f + ) + assertTrue("GMS rejected the sliding window request", registered) + + // Must not throw when a window is already registered. + manager.removeAllGeofences() + } finally { + // Never leave a geofence behind: it consumes a slot of the 100-per-app budget. + runCatching { manager.removeAllGeofences() } } } + + @Test + fun registrationWithoutPermission_reportsFailureInsteadOfRegistering() = runBlocking { + assumeTrue("Test requires the permission to be absent", !hasLocationPermission()) + + assertFalse( + "Registration should be refused without permissions", + manager.registerMasterGeofence( + centerLat = location.latitude, + centerLon = location.longitude, + innerRadiusMeters = 500f + ) + ) + } + + private fun hasLocationPermission(): Boolean = + ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == + PackageManager.PERMISSION_GRANTED } diff --git a/app/src/androidTest/java/dev/arrase/geotify/data/MigrationTest.kt b/app/src/androidTest/java/dev/arrase/geotify/data/MigrationTest.kt new file mode 100644 index 0000000..3c8acf3 --- /dev/null +++ b/app/src/androidTest/java/dev/arrase/geotify/data/MigrationTest.kt @@ -0,0 +1,134 @@ +package dev.arrase.geotify.data + +import androidx.room.testing.MigrationTestHelper +import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Exercises the real SQL of every migration step against a database built from the exported + * schema, on a device. + * + * The alias-trimming step is covered exhaustively because it is easy to write a correlated + * subquery that silently matches nothing: the migration then completes, the app opens fine, and + * nothing is cleaned up at all. Only real SQLite can catch that. + */ +@RunWith(AndroidJUnit4::class) +class MigrationTest { + + @get:Rule + val helper = MigrationTestHelper( + InstrumentationRegistry.getInstrumentation(), + // Only used to locate the exported schemas under the test APK's assets, which Room + // writes under the database class name. The database file itself is named per test. + GeotifyDatabase::class.java.canonicalName, + FrameworkSQLiteOpenHelperFactory() + ) + + @Test + fun migratesFromV1ToCurrent() { + helper.createDatabase(TEST_DB, 1).use { db -> + db.execSQL( + """ + INSERT INTO locations + (id, alias, latitude, longitude, radius_meters, notification_responsiveness_ms) + VALUES ('l1', 'Casa', 39.95, -0.06, 100.0, 0) + """.trimIndent() + ) + } + + val migrated = helper.runMigrationsAndValidate( + TEST_DB, CURRENT_VERSION, validateDroppedTables = true, *Migrations.all() + ) + + // runMigrationsAndValidate checks the schema; the row must also have survived the chain. + assertEquals(listOf("Casa"), migrated.readAliases()) + migrated.close() + } + + @Test + fun v4ToV5_trimsAliasesThatGainedWhitespace() { + helper.createDatabase(TEST_DB, 4).use { db -> + listOf( + "l1" to "Casa", + "l2" to "Consum ", + "l3" to "Mercadona ", + "l4" to "\tPipican\n", + "l5" to " Plaza " + ).forEach { (id, alias) -> db.insertLocation(id, alias) } + } + + val migrated = helper.runMigrationsAndValidate( + TEST_DB, CURRENT_VERSION, validateDroppedTables = true, *Migrations.all() + ) + + assertEquals( + listOf("Casa", "Consum", "Mercadona", "Pipican", "Plaza"), + migrated.readAliases() + ) + migrated.close() + } + + @Test + fun v4ToV5_keepsCollidingAliasesSoTheUniqueIndexIsNotViolated() { + // The old duplicate check compared aliases without trimming, so both could exist. + helper.createDatabase(TEST_DB, 4).use { db -> + listOf("l1" to "Shop", "l2" to "Shop ", "l3" to "Cafe ") + .forEach { (id, alias) -> db.insertLocation(id, alias) } + } + + val migrated = helper.runMigrationsAndValidate( + TEST_DB, CURRENT_VERSION, validateDroppedTables = true, *Migrations.all() + ) + + // 'Shop' vs 'Shop ' collide and are both left alone; the unrelated row is still fixed. + assertEquals(listOf("Shop", "Shop ", "Cafe"), migrated.readAliases()) + migrated.close() + } + + @Test + fun v4ToV5_doesNotBlankAnAllWhitespaceAlias() { + helper.createDatabase(TEST_DB, 4).use { db -> db.insertLocation("l1", " ") } + + val migrated = helper.runMigrationsAndValidate( + TEST_DB, CURRENT_VERSION, validateDroppedTables = true, *Migrations.all() + ) + + assertEquals(listOf(" "), migrated.readAliases()) + migrated.close() + } + + @Test + fun v4ToV5_leavesCleanAliasesUntouched() { + helper.createDatabase(TEST_DB, 4).use { db -> db.insertLocation("l1", "Mi Casa Bonita") } + + val migrated = helper.runMigrationsAndValidate( + TEST_DB, CURRENT_VERSION, validateDroppedTables = true, *Migrations.all() + ) + + assertEquals(listOf("Mi Casa Bonita"), migrated.readAliases()) + migrated.close() + } + + private fun androidx.sqlite.db.SupportSQLiteDatabase.insertLocation(id: String, alias: String) { + execSQL( + "INSERT INTO locations " + + "(id, alias, latitude, longitude, radius_meters, notification_responsiveness_ms) " + + "VALUES ('$id', '${alias.replace("'", "''")}', 39.95, -0.06, 100.0, 0)" + ) + } + + private fun androidx.sqlite.db.SupportSQLiteDatabase.readAliases(): List = + query("SELECT alias FROM locations ORDER BY rowid").use { cursor -> + buildList { while (cursor.moveToNext()) add(cursor.getString(0)) } + } + + private companion object { + const val TEST_DB = "migration-test" + const val CURRENT_VERSION = 5 + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index b4f798b..3ac5d90 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -49,21 +49,20 @@ - + - - - - + android:exported="false" /> - + + diff --git a/app/src/main/java/dev/arrase/geotify/GeotifyApplication.kt b/app/src/main/java/dev/arrase/geotify/GeotifyApplication.kt index 538bdf2..479ff5f 100644 --- a/app/src/main/java/dev/arrase/geotify/GeotifyApplication.kt +++ b/app/src/main/java/dev/arrase/geotify/GeotifyApplication.kt @@ -2,28 +2,20 @@ package dev.arrase.geotify import android.app.Application import androidx.appfunctions.service.AppFunctionConfiguration -import android.util.Log import dagger.hilt.android.HiltAndroidApp import dev.arrase.geotify.appfunction.GeotifyAppFunctions -import dev.arrase.geotify.geofence.GeofenceOrchestrator import dev.arrase.geotify.notification.NotificationHelper import javax.inject.Inject @HiltAndroidApp class GeotifyApplication : Application(), AppFunctionConfiguration.Provider { - @Inject - lateinit var geofenceOrchestrator: GeofenceOrchestrator - @Inject lateinit var geotifyAppFunctions: GeotifyAppFunctions override fun onCreate() { super.onCreate() - Log.i(TAG, "Initializing notification channels...") NotificationHelper.createNotificationChannels(this) - Log.i(TAG, "Enqueuing geofence recalculation...") - geofenceOrchestrator.triggerExpeditedRecalculation() } override val appFunctionConfiguration: AppFunctionConfiguration @@ -32,8 +24,4 @@ class GeotifyApplication : Application(), AppFunctionConfiguration.Provider { geotifyAppFunctions } .build() - - companion object { - private const val TAG = "GeotifyApp" - } } diff --git a/app/src/main/java/dev/arrase/geotify/MainActivity.kt b/app/src/main/java/dev/arrase/geotify/MainActivity.kt index 2b25ae7..6c5f31d 100644 --- a/app/src/main/java/dev/arrase/geotify/MainActivity.kt +++ b/app/src/main/java/dev/arrase/geotify/MainActivity.kt @@ -1,53 +1,66 @@ package dev.arrase.geotify +import android.content.Intent import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.viewModels -import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.runtime.getValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle import dagger.hilt.android.AndroidEntryPoint -import dev.arrase.geotify.data.ThemeSetting +import dev.arrase.geotify.geofence.GeofenceOrchestrator import dev.arrase.geotify.permission.PermissionGate import dev.arrase.geotify.ui.MainViewModel import dev.arrase.geotify.ui.navigation.GeotifyNavHost import dev.arrase.geotify.ui.navigation.GeotifyTab import dev.arrase.geotify.ui.theme.GeotifyTheme +import dev.arrase.geotify.ui.theme.resolve +import javax.inject.Inject @AndroidEntryPoint class MainActivity : ComponentActivity() { + @Inject + lateinit var geofenceOrchestrator: GeofenceOrchestrator + private val viewModel: MainViewModel by viewModels() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() - val initialTab = if (intent.getStringExtra(EXTRA_TAB) == TAB_LOCATIONS) { - GeotifyTab.Locations - } else { - GeotifyTab.Reminders + // Only on a user-initiated launch: a process started by a broadcast receiver must not + // spend battery re-registering geofences that the receiver itself is about to refresh. + if (savedInstanceState == null) { + geofenceOrchestrator.triggerExpeditedRecalculation() } + setContent { val appTheme by viewModel.appTheme.collectAsStateWithLifecycle() - val useDarkTheme = when (appTheme) { - ThemeSetting.SYSTEM -> androidx.compose.foundation.isSystemInDarkTheme() - ThemeSetting.LIGHT -> false - ThemeSetting.DARK -> true - } - GeotifyTheme(darkTheme = useDarkTheme) { + GeotifyTheme(darkTheme = appTheme.resolve(isSystemInDarkTheme())) { PermissionGate { - GeotifyNavHost(initialTab = initialTab) + GeotifyNavHost(initialTab = requestedTab(intent)) } } } } + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + } + companion object { + /** Intent extra carrying a [GeotifyTab] name. */ const val EXTRA_TAB = "tab" - const val TAB_LOCATIONS = "locations" + + private fun requestedTab(intent: Intent?): GeotifyTab { + val requested = intent?.getStringExtra(EXTRA_TAB) ?: return GeotifyTab.Reminders + return GeotifyTab.entries.firstOrNull { it.name == requested } ?: GeotifyTab.Reminders + } } } diff --git a/app/src/main/java/dev/arrase/geotify/appfunction/GeotifyAppFunctions.kt b/app/src/main/java/dev/arrase/geotify/appfunction/GeotifyAppFunctions.kt index cf3ade7..bf9d9f4 100644 --- a/app/src/main/java/dev/arrase/geotify/appfunction/GeotifyAppFunctions.kt +++ b/app/src/main/java/dev/arrase/geotify/appfunction/GeotifyAppFunctions.kt @@ -5,9 +5,11 @@ import androidx.appfunctions.AppFunctionInvalidArgumentException import androidx.appfunctions.AppFunctionSerializable import androidx.appfunctions.service.AppFunction import com.google.android.gms.location.Geofence +import dev.arrase.geotify.data.DuplicateAliasException import dev.arrase.geotify.data.LocationRepository import dev.arrase.geotify.data.ReminderRepository import dev.arrase.geotify.data.entity.triggerTypeString +import dev.arrase.geotify.geofence.GeofenceOrchestrator import dev.arrase.geotify.location.LocationProvider import javax.inject.Inject @@ -70,6 +72,7 @@ data class SavedReminder( class GeotifyAppFunctions @Inject constructor( private val locationRepository: LocationRepository, private val reminderRepository: ReminderRepository, + private val geofenceOrchestrator: GeofenceOrchestrator, private val locationProvider: LocationProvider ) { @@ -97,10 +100,16 @@ class GeotifyAppFunctions @Inject constructor( if (location != null) { locationRepository.saveLocation(alias, location.latitude, location.longitude) + geofenceOrchestrator.triggerRecalculation() SaveLocationResult(alias, "Location successfully saved.") } else { SaveLocationResult(alias, "Failed to obtain GPS fix.") } + } catch (e: DuplicateAliasException) { + SaveLocationResult( + alias, + "Alias already in use. Choose a unique name or delete the existing one first." + ) } catch (e: Exception) { SaveLocationResult(alias, "Error: ${e.message}") } @@ -132,11 +141,12 @@ class GeotifyAppFunctions @Inject constructor( } val reminder = reminderRepository.createReminder(location, payloadMessage, transitionType) + geofenceOrchestrator.triggerRecalculation() return CreateReminderResult( reminderId = reminder.id, targetAlias = targetAlias, payloadMessage = payloadMessage, - triggerType = if (triggerOnArrival) "arrival" else "departure" + triggerType = reminder.triggerTypeString ) } @@ -165,8 +175,9 @@ class GeotifyAppFunctions @Inject constructor( alias: String ): DeleteResult { locationRepository.findLocationByAlias(alias) ?: throwAliasNotFound(alias) - locationRepository.deleteLocation(alias) - return DeleteResult(alias, deleted = true) + val deleted = locationRepository.deleteLocation(alias) + geofenceOrchestrator.triggerRecalculation() + return DeleteResult(alias, deleted = deleted) } /** @@ -211,8 +222,9 @@ class GeotifyAppFunctions @Inject constructor( ) } - reminderRepository.cancelReminder(matched.id) - return DeleteResult(targetAlias, deleted = true) + val deleted = reminderRepository.cancelReminder(matched.id) + geofenceOrchestrator.triggerRecalculation() + return DeleteResult(targetAlias, deleted = deleted) } /** diff --git a/app/src/main/java/dev/arrase/geotify/data/GeotifyDatabase.kt b/app/src/main/java/dev/arrase/geotify/data/GeotifyDatabase.kt index f882bf9..f54fde7 100644 --- a/app/src/main/java/dev/arrase/geotify/data/GeotifyDatabase.kt +++ b/app/src/main/java/dev/arrase/geotify/data/GeotifyDatabase.kt @@ -9,11 +9,16 @@ import dev.arrase.geotify.data.entity.ReminderEntity @Database( entities = [LocationEntity::class, ReminderEntity::class], - version = 3, + version = 5, exportSchema = true ) abstract class GeotifyDatabase : RoomDatabase() { abstract fun locationDao(): LocationDao + abstract fun reminderDao(): ReminderDao -} + + companion object { + const val NAME = "geotify.db" + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/arrase/geotify/data/LocationRepository.kt b/app/src/main/java/dev/arrase/geotify/data/LocationRepository.kt index 0adb42d..6895a4a 100644 --- a/app/src/main/java/dev/arrase/geotify/data/LocationRepository.kt +++ b/app/src/main/java/dev/arrase/geotify/data/LocationRepository.kt @@ -25,27 +25,32 @@ class LocationRepository @Inject constructor( radiusMeters: Float = 150f, notificationResponsivenessMs: Int = 0 ): LocationEntity = withContext(ioDispatcher) { - require(latitude in -90.0..90.0) { "Latitude must be between -90.0 and 90.0" } - require(longitude in -180.0..180.0) { "Longitude must be between -180.0 and 180.0" } - require(radiusMeters >= 50f) { "Geofence radius must be at least 50 meters" } - require(notificationResponsivenessMs >= 0) { "Notification responsiveness must be non-negative" } + val normalizedAlias = alias.trim() + require(normalizedAlias.isNotEmpty()) { "Alias must not be blank" } + validate(latitude, longitude, radiusMeters, notificationResponsivenessMs) val entity = LocationEntity( id = UUID.randomUUID().toString(), - alias = alias, + alias = normalizedAlias, latitude = latitude, longitude = longitude, radiusMeters = radiusMeters, notificationResponsivenessMs = notificationResponsivenessMs ) - locationDao.insert(entity) + if (locationDao.insert(entity) == -1L) { + throw DuplicateAliasException(normalizedAlias) + } entity } suspend fun updateLocation(location: LocationEntity) = withContext(ioDispatcher) { - require(location.latitude in -90.0..90.0) { "Latitude must be between -90.0 and 90.0" } - require(location.longitude in -180.0..180.0) { "Longitude must be between -180.0 and 180.0" } - require(location.radiusMeters >= 50f) { "Geofence radius must be at least 50 meters" } - locationDao.update(location) + require(location.alias.isNotBlank()) { "Alias must not be blank" } + validate( + location.latitude, + location.longitude, + location.radiusMeters, + location.notificationResponsivenessMs + ) + locationDao.update(location.copy(alias = location.alias.trim())) } suspend fun getAllLocations(): List = withContext(ioDispatcher) { @@ -60,11 +65,48 @@ class LocationRepository @Inject constructor( locationDao.findById(id) } + /** + * Bounding-box prefilter. May return candidates outside [minLat]..[maxLat] range when the + * longitude window straddles the antimeridian; callers must still check exact distance. + */ + suspend fun findLocationsInBoundingBox( + minLat: Double, + maxLat: Double, + minLon: Double, + maxLon: Double + ): List = withContext(ioDispatcher) { + locationDao.getLocationsInBoundingBox(minLat, maxLat, minLon, maxLon) + } + suspend fun getAllAliases(): List = withContext(ioDispatcher) { locationDao.getAllAliases() } - suspend fun deleteLocation(alias: String) = withContext(ioDispatcher) { - locationDao.deleteByAlias(alias) + /** @return `true` if a location was removed. */ + suspend fun deleteLocation(alias: String): Boolean = withContext(ioDispatcher) { + locationDao.deleteByAlias(alias) > 0 + } + + private fun validate( + latitude: Double, + longitude: Double, + radiusMeters: Float, + notificationResponsivenessMs: Int + ) { + require(latitude in -90.0..90.0) { "Latitude must be between -90.0 and 90.0" } + require(longitude in -180.0..180.0) { "Longitude must be between -180.0 and 180.0" } + require(radiusMeters >= MIN_RADIUS_METERS) { + "Geofence radius must be at least $MIN_RADIUS_METERS meters" + } + require(notificationResponsivenessMs >= 0) { "Notification responsiveness must be non-negative" } + } + + private companion object { + const val MIN_RADIUS_METERS = 50f } } + +/** Thrown when saving a location whose alias is already taken (case-insensitive). */ +class DuplicateAliasException(alias: String) : + IllegalArgumentException("Alias '$alias' already exists") + diff --git a/app/src/main/java/dev/arrase/geotify/data/Migrations.kt b/app/src/main/java/dev/arrase/geotify/data/Migrations.kt new file mode 100644 index 0000000..a51b0f4 --- /dev/null +++ b/app/src/main/java/dev/arrase/geotify/data/Migrations.kt @@ -0,0 +1,86 @@ +package dev.arrase.geotify.data + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +/** + * Schema history for [GeotifyDatabase]. + * + * Every step from the first released version is declared here so that upgrading never destroys + * user data. Adding a version means bumping [GeotifyDatabase.version] and appending the matching + * entry here. + */ +internal object Migrations { + + /** Ordered migration chain, from the first released schema to the current one. */ + fun all(): Array = arrayOf(V1_TO_V2, V2_TO_V3, V3_TO_V4, V4_TO_V5) + + /** v2: composite index backing the spatial-search bounding-box query. */ + private val V1_TO_V2 = object : Migration(1, 2) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + "CREATE INDEX IF NOT EXISTS index_locations_latitude_longitude " + + "ON locations (latitude, longitude)" + ) + } + } + + /** v3: `is_in_range`, tracking whether a reminder's geofence is currently monitored. */ + private val V2_TO_V3 = object : Migration(2, 3) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + "ALTER TABLE reminders ADD COLUMN is_in_range INTEGER NOT NULL DEFAULT 0" + ) + } + } + + /** v4: index covering the active-reminder filter and its `ORDER BY`. */ + private val V3_TO_V4 = object : Migration(3, 4) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + "CREATE INDEX IF NOT EXISTS index_reminders_is_active_created_at " + + "ON reminders (is_active, created_at)" + ) + } + } + + /** + * v5: data cleanup only, no schema change. + * + * Aliases used to be persisted exactly as typed, so rows created before the repository + * started trimming them can carry leading or trailing whitespace (e.g. `"Mercadona "`). + * The uniqueness index is `COLLATE NOCASE`, which ignores case but not surrounding spaces, so + * such a row could not be found by its own alias and blocked creating the properly-spaced one. + * + * A row is left untouched when trimming it would either create a collision with another row + * or leave the alias empty. The former UI compared aliases without trimming, so `"Shop"` and + * `"Shop "` could both exist, and trimming those would violate the unique index and abort the + * whole statement, leaving the app unable to open its database. Such rows keep their original + * value for the user to fix, rather than being merged or blanked. + */ + private val V4_TO_V5 = object : Migration(4, 5) { + override fun migrate(db: SupportSQLiteDatabase) { + // SQLite's TRIM() drops spaces only, while the repository trims any whitespace via + // Kotlin's String.trim(), so the same character list is given explicitly to keep + // existing rows consistent with newly saved ones. + // + // `locations.alias` must stay qualified inside the subquery: an unqualified `alias` + // would bind to `other.alias`, making the guard always false and silently trimming + // nothing. + val outerTrim = "TRIM(locations.alias, ' ' || char(9) || char(10) || char(13))" + db.execSQL( + """ + UPDATE locations + SET alias = $outerTrim + WHERE locations.alias <> $outerTrim + AND $outerTrim <> '' + AND NOT EXISTS ( + SELECT 1 FROM locations other + WHERE other.id <> locations.id + AND other.alias = $outerTrim COLLATE NOCASE + ) + """.trimIndent() + ) + } + } +} diff --git a/app/src/main/java/dev/arrase/geotify/data/ReminderRepository.kt b/app/src/main/java/dev/arrase/geotify/data/ReminderRepository.kt index c454c35..cd47d66 100644 --- a/app/src/main/java/dev/arrase/geotify/data/ReminderRepository.kt +++ b/app/src/main/java/dev/arrase/geotify/data/ReminderRepository.kt @@ -1,5 +1,6 @@ package dev.arrase.geotify.data +import com.google.android.gms.location.Geofence import dev.arrase.geotify.data.dao.ReminderDao import dev.arrase.geotify.data.entity.LocationEntity import dev.arrase.geotify.data.entity.LocationReminderCount @@ -23,16 +24,19 @@ class ReminderRepository @Inject constructor( fun observeActiveReminderCounts(): Flow> = reminderDao.observeActiveReminderCounts() - suspend fun createReminder( location: LocationEntity, message: String, transitionType: Int ): ReminderEntity = withContext(ioDispatcher) { + require(message.isNotBlank()) { "Reminder message must not be blank" } + require(transitionType in SUPPORTED_TRANSITIONS) { + "Unsupported geofence transition type: $transitionType" + } val reminder = ReminderEntity( id = UUID.randomUUID().toString(), locationId = location.id, - message = message, + message = message.trim(), transitionType = transitionType, createdAt = System.currentTimeMillis() ) @@ -41,6 +45,10 @@ class ReminderRepository @Inject constructor( } suspend fun updateReminder(reminder: ReminderEntity) = withContext(ioDispatcher) { + require(reminder.message.isNotBlank()) { "Reminder message must not be blank" } + require(reminder.transitionType in SUPPORTED_TRANSITIONS) { + "Unsupported geofence transition type: ${reminder.transitionType}" + } reminderDao.update(reminder) } @@ -48,20 +56,28 @@ class ReminderRepository @Inject constructor( reminderDao.deactivate(reminderId) } - suspend fun cancelReminder(reminderId: String) = withContext(ioDispatcher) { - reminderDao.deleteById(reminderId) + /** @return `true` if a reminder was removed. */ + suspend fun cancelReminder(reminderId: String): Boolean = withContext(ioDispatcher) { + reminderDao.deleteById(reminderId) > 0 } - suspend fun getActiveReminders(): List = withContext(ioDispatcher) { reminderDao.getActiveReminders() } - suspend fun getActiveRemindersForLocation(locationId: String): List = withContext(ioDispatcher) { - reminderDao.getActiveByLocationId(locationId) - } + suspend fun getActiveRemindersForLocation(locationId: String): List = + withContext(ioDispatcher) { + reminderDao.getActiveByLocationId(locationId) + } suspend fun updateInRangeStatus(locationIds: List) = withContext(ioDispatcher) { reminderDao.updateInRangeStatus(locationIds) } + + private companion object { + val SUPPORTED_TRANSITIONS = setOf( + Geofence.GEOFENCE_TRANSITION_ENTER, + Geofence.GEOFENCE_TRANSITION_EXIT + ) + } } diff --git a/app/src/main/java/dev/arrase/geotify/data/SettingsManager.kt b/app/src/main/java/dev/arrase/geotify/data/SettingsManager.kt index 8b57edd..7e4c916 100644 --- a/app/src/main/java/dev/arrase/geotify/data/SettingsManager.kt +++ b/app/src/main/java/dev/arrase/geotify/data/SettingsManager.kt @@ -4,15 +4,17 @@ import android.content.Context import android.util.Log import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.doublePreferencesKey import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.emptyPreferences -import androidx.datastore.preferences.core.doublePreferencesKey import androidx.datastore.preferences.core.floatPreferencesKey import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStore import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.FlowCollector import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.map import java.io.IOException @@ -30,8 +32,6 @@ class SettingsManager @Inject constructor( @param:ApplicationContext private val context: Context ) { - // ── Read Preferences ── - val appTheme: Flow = preference(KEY_APP_THEME, SettingsDefaults.APP_THEME.name) .map { name -> ThemeSetting.entries.find { it.name == name } ?: SettingsDefaults.APP_THEME } @@ -42,19 +42,37 @@ class SettingsManager @Inject constructor( val innerRadiusR: Flow = preference(KEY_INNER_RADIUS_R, SettingsDefaults.INNER_RADIUS_R) - val locationCacheTimeoutSecs: Flow = preference(KEY_LOCATION_CACHE_TIMEOUT_SECS, SettingsDefaults.LOCATION_CACHE_TIMEOUT_SECS) - - val recalculationDebounceSecs: Flow = preference(KEY_DEBOUNCE_DELAY_SECS, SettingsDefaults.RECALCULATION_DEBOUNCE_SECS) - - val masterGeofenceResponsivenessSecs: Flow = preference(KEY_MASTER_RESPONSIVENESS_SECS, SettingsDefaults.MASTER_GEOFENCE_RESPONSIVENESS_SECS) - - val poiGeofenceResponsivenessSecs: Flow = preference(KEY_POI_RESPONSIVENESS_SECS, SettingsDefaults.POI_GEOFENCE_RESPONSIVENESS_SECS) - - val lastRecalcLat: Flow = preference(KEY_LAST_RECALC_LAT, 0.0) - .map { if (it == 0.0) null else it } - - val lastRecalcLng: Flow = preference(KEY_LAST_RECALC_LNG, 0.0) - .map { if (it == 0.0) null else it } + val locationCacheTimeoutSecs: Flow = + preference(KEY_LOCATION_CACHE_TIMEOUT_SECS, SettingsDefaults.LOCATION_CACHE_TIMEOUT_SECS) + + val recalculationDebounceSecs: Flow = + preference(KEY_DEBOUNCE_DELAY_SECS, SettingsDefaults.RECALCULATION_DEBOUNCE_SECS) + + val masterGeofenceResponsivenessSecs: Flow = + preference(KEY_MASTER_RESPONSIVENESS_SECS, SettingsDefaults.MASTER_GEOFENCE_RESPONSIVENESS_SECS) + + val poiGeofenceResponsivenessSecs: Flow = + preference(KEY_POI_RESPONSIVENESS_SECS, SettingsDefaults.POI_GEOFENCE_RESPONSIVENESS_SECS) + + /** + * Centre of the most recent geofence recalculation, or `null` if none has run yet. + * Backed by an explicit presence flag so that valid coordinates such as (0.0, 0.0) + * are not mistaken for "unset". + */ + val lastRecalcLocation: Flow = context.dataStore.data + .catch { exception -> recoverFrom(exception, KEY_HAS_LAST_RECALC) } + .map { preferences -> + // The flag and both coordinates are written in one atomic `edit`, so when the flag + // is set the coordinates are guaranteed to be present. + if (preferences[KEY_HAS_LAST_RECALC] != true) { + null + } else { + LatLng( + latitude = checkNotNull(preferences[KEY_LAST_RECALC_LAT]), + longitude = checkNotNull(preferences[KEY_LAST_RECALC_LNG]) + ) + } + } // ── Write Preferences ── @@ -66,54 +84,68 @@ class SettingsManager @Inject constructor( suspend fun setInnerRadiusR(radius: Float) = setPreference(KEY_INNER_RADIUS_R, radius) - suspend fun setLocationCacheTimeoutSecs(secs: Int) = setPreference(KEY_LOCATION_CACHE_TIMEOUT_SECS, secs) + suspend fun setLocationCacheTimeoutSecs(secs: Int) = + setPreference(KEY_LOCATION_CACHE_TIMEOUT_SECS, secs) suspend fun setRecalculationDebounceSecs(secs: Int) = setPreference(KEY_DEBOUNCE_DELAY_SECS, secs) - suspend fun setMasterGeofenceResponsivenessSecs(secs: Int) = setPreference(KEY_MASTER_RESPONSIVENESS_SECS, secs) + suspend fun setMasterGeofenceResponsivenessSecs(secs: Int) = + setPreference(KEY_MASTER_RESPONSIVENESS_SECS, secs) - suspend fun setPoiGeofenceResponsivenessSecs(secs: Int) = setPreference(KEY_POI_RESPONSIVENESS_SECS, secs) + suspend fun setPoiGeofenceResponsivenessSecs(secs: Int) = + setPreference(KEY_POI_RESPONSIVENESS_SECS, secs) - suspend fun setLastRecalcLocation(lat: Double, lng: Double) { - setPreference(KEY_LAST_RECALC_LAT, lat) - setPreference(KEY_LAST_RECALC_LNG, lng) + /** Stores both coordinates in a single atomic transaction. */ + suspend fun setLastRecalcLocation(latitude: Double, longitude: Double) { + context.dataStore.edit { preferences -> + preferences[KEY_LAST_RECALC_LAT] = latitude + preferences[KEY_LAST_RECALC_LNG] = longitude + preferences[KEY_HAS_LAST_RECALC] = true + } } // ── Private Helpers ── private fun preference(key: Preferences.Key, default: T): Flow = context.dataStore.data - .catch { exception -> - if (exception is IOException) { - Log.e(TAG, "Error reading preference: $key", exception) - emit(emptyPreferences()) - } else { - throw exception - } - } + .catch { exception -> recoverFrom(exception, key) } .map { preferences -> preferences[key] ?: default } private suspend fun setPreference(key: Preferences.Key, value: T) { - try { - context.dataStore.edit { preferences -> - preferences[key] = value - } - } catch (e: Exception) { - Log.e(TAG, "Error writing preference: $key", e) + context.dataStore.edit { preferences -> preferences[key] = value } + } + + /** + * Swallows the corruption error thrown when the DataStore file cannot be parsed, so a single + * unreadable preference does not permanently break the app. Any other failure propagates. + */ + private suspend fun FlowCollector.recoverFrom( + exception: Throwable, + key: Preferences.Key<*> + ) { + if (exception is IOException) { + Log.e(TAG, "Error reading preference: $key", exception) + emit(emptyPreferences()) + } else { + throw exception } } - companion object { - private const val TAG = "SettingsManager" - private val KEY_APP_THEME = stringPreferencesKey("app_theme") - private val KEY_MAP_THEME = stringPreferencesKey("map_theme") - private val KEY_OUTER_RADIUS_N = floatPreferencesKey("outer_radius_n_km") - private val KEY_INNER_RADIUS_R = floatPreferencesKey("inner_radius_r_km") - private val KEY_LOCATION_CACHE_TIMEOUT_SECS = intPreferencesKey("location_cache_timeout_secs") - private val KEY_DEBOUNCE_DELAY_SECS = intPreferencesKey("debounce_delay_secs") - private val KEY_MASTER_RESPONSIVENESS_SECS = intPreferencesKey("master_responsiveness_secs") - private val KEY_POI_RESPONSIVENESS_SECS = intPreferencesKey("poi_responsiveness_secs") - private val KEY_LAST_RECALC_LAT = doublePreferencesKey("last_recalc_lat") - private val KEY_LAST_RECALC_LNG = doublePreferencesKey("last_recalc_lng") + private companion object { + const val TAG = "SettingsManager" + val KEY_APP_THEME = stringPreferencesKey("app_theme") + val KEY_MAP_THEME = stringPreferencesKey("map_theme") + val KEY_OUTER_RADIUS_N = floatPreferencesKey("outer_radius_n_km") + val KEY_INNER_RADIUS_R = floatPreferencesKey("inner_radius_r_km") + val KEY_LOCATION_CACHE_TIMEOUT_SECS = intPreferencesKey("location_cache_timeout_secs") + val KEY_DEBOUNCE_DELAY_SECS = intPreferencesKey("debounce_delay_secs") + val KEY_MASTER_RESPONSIVENESS_SECS = intPreferencesKey("master_responsiveness_secs") + val KEY_POI_RESPONSIVENESS_SECS = intPreferencesKey("poi_responsiveness_secs") + val KEY_LAST_RECALC_LAT = doublePreferencesKey("last_recalc_lat") + val KEY_LAST_RECALC_LNG = doublePreferencesKey("last_recalc_lng") + val KEY_HAS_LAST_RECALC = booleanPreferencesKey("has_last_recalc") } } + +/** Immutable latitude/longitude pair. */ +data class LatLng(val latitude: Double, val longitude: Double) diff --git a/app/src/main/java/dev/arrase/geotify/data/dao/LocationDao.kt b/app/src/main/java/dev/arrase/geotify/data/dao/LocationDao.kt index 262f03b..6c9ca10 100644 --- a/app/src/main/java/dev/arrase/geotify/data/dao/LocationDao.kt +++ b/app/src/main/java/dev/arrase/geotify/data/dao/LocationDao.kt @@ -26,8 +26,9 @@ interface LocationDao { @Query("SELECT alias FROM locations ORDER BY alias ASC") suspend fun getAllAliases(): List - @Insert(onConflict = OnConflictStrategy.ABORT) - suspend fun insert(location: LocationEntity) + /** @return the new row id, or `-1` if the alias already exists. */ + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insert(location: LocationEntity): Long @Update suspend fun update(location: LocationEntity) @@ -35,13 +36,21 @@ interface LocationDao { @Query("DELETE FROM locations WHERE alias = :alias COLLATE NOCASE") suspend fun deleteByAlias(alias: String): Int + /** + * Bounding-box prefilter for [dev.arrase.geotify.domain.SpatialSearchUseCase]. + * + * The longitude window is tested three times — as given, shifted by -360 and by +360 — so that a + * window straddling the antimeridian (e.g. 174..184) still matches points stored on the western + * side (e.g. -179). Results may be a superset of the true radius; callers must still apply an exact + * distance check. + */ @Query(""" - SELECT * FROM locations - WHERE latitude BETWEEN :minLat AND :maxLat + SELECT * FROM locations + WHERE latitude BETWEEN :minLat AND :maxLat AND ( - (:minLon <= :maxLon AND longitude BETWEEN :minLon AND :maxLon) - OR - (:minLon > :maxLon AND (longitude >= :minLon OR longitude <= :maxLon)) + (longitude BETWEEN :minLon AND :maxLon) + OR (longitude BETWEEN :minLon - 360.0 AND :maxLon - 360.0) + OR (longitude BETWEEN :minLon + 360.0 AND :maxLon + 360.0) ) """) suspend fun getLocationsInBoundingBox(minLat: Double, maxLat: Double, minLon: Double, maxLon: Double): List diff --git a/app/src/main/java/dev/arrase/geotify/data/dao/ReminderDao.kt b/app/src/main/java/dev/arrase/geotify/data/dao/ReminderDao.kt index b87e903..dbb517a 100644 --- a/app/src/main/java/dev/arrase/geotify/data/dao/ReminderDao.kt +++ b/app/src/main/java/dev/arrase/geotify/data/dao/ReminderDao.kt @@ -16,28 +16,15 @@ interface ReminderDao { @Query("SELECT location_id, COUNT(*) as count FROM reminders WHERE is_active = 1 GROUP BY location_id") fun observeActiveReminderCounts(): Flow> - @Query("SELECT COUNT(DISTINCT location_id) FROM reminders WHERE is_active = 1") - suspend fun getActiveGeofenceCount(): Int - - - - @Query( - """ - SELECT r.* FROM reminders r - INNER JOIN locations l ON r.location_id = l.id - ORDER BY r.is_active DESC, r.created_at DESC - """ - ) + @Query("SELECT * FROM reminders ORDER BY is_active DESC, created_at DESC") fun observeAll(): Flow> @Query("SELECT * FROM reminders WHERE is_active = 1") suspend fun getActiveReminders(): List - @Query("SELECT * FROM reminders WHERE location_id = :locationId AND is_active = 1") suspend fun getActiveByLocationId(locationId: String): List - @Insert(onConflict = OnConflictStrategy.ABORT) suspend fun insert(reminder: ReminderEntity) @@ -50,18 +37,17 @@ interface ReminderDao { @Query("DELETE FROM reminders WHERE id = :id") suspend fun deleteById(id: String): Int - - @Query("UPDATE reminders SET is_in_range = 0") - suspend fun clearAllInRange() - - @Query("UPDATE reminders SET is_in_range = 1 WHERE location_id IN (:locationIds) AND is_active = 1") - suspend fun setInRangeForLocations(locationIds: List) - @Transaction suspend fun updateInRangeStatus(locationIds: List) { clearAllInRange() if (locationIds.isNotEmpty()) { - setInRangeForLocations(locationIds) + setInRangeForLocationIds(locationIds) } } + + @Query("UPDATE reminders SET is_in_range = 0") + suspend fun clearAllInRange() + + @Query("UPDATE reminders SET is_in_range = 1 WHERE location_id IN (:locationIds) AND is_active = 1") + suspend fun setInRangeForLocationIds(locationIds: List) } diff --git a/app/src/main/java/dev/arrase/geotify/data/entity/ReminderEntity.kt b/app/src/main/java/dev/arrase/geotify/data/entity/ReminderEntity.kt index f54f855..4c8ead3 100644 --- a/app/src/main/java/dev/arrase/geotify/data/entity/ReminderEntity.kt +++ b/app/src/main/java/dev/arrase/geotify/data/entity/ReminderEntity.kt @@ -5,6 +5,7 @@ import androidx.room.Entity import androidx.room.ForeignKey import androidx.room.Index import androidx.room.PrimaryKey +import com.google.android.gms.location.Geofence @Entity( tableName = "reminders", @@ -16,7 +17,7 @@ import androidx.room.PrimaryKey onDelete = ForeignKey.CASCADE ) ], - indices = [Index(value = ["location_id"])] + indices = [Index(value = ["location_id"]), Index(value = ["is_active", "created_at"])] ) data class ReminderEntity( @PrimaryKey @@ -35,16 +36,29 @@ data class ReminderEntity( ) val ReminderEntity.isArrival: Boolean - get() = transitionType == com.google.android.gms.location.Geofence.GEOFENCE_TRANSITION_ENTER + get() = transitionType == Geofence.GEOFENCE_TRANSITION_ENTER val ReminderEntity.isDeparture: Boolean - get() = transitionType == com.google.android.gms.location.Geofence.GEOFENCE_TRANSITION_EXIT + get() = transitionType == Geofence.GEOFENCE_TRANSITION_EXIT +/** Stable, human-readable label for [transitionType], used by App Functions responses. */ val ReminderEntity.triggerTypeString: String - get() = if (isArrival) "arrival" else "departure" + get() = when (transitionType) { + Geofence.GEOFENCE_TRANSITION_ENTER -> "arrival" + Geofence.GEOFENCE_TRANSITION_EXIT -> "departure" + else -> "unknown" + } + +/** + * Stable Android notification id for this reminder. + * + * Notification ids double as `PendingIntent` request codes, so two reminders sharing an id would + * make the second notification overwrite the first one's intent extras. Reminders are created one + * at a time by the user, so a millisecond timestamp is unique in practice and cheap to derive. + */ +fun ReminderEntity.notificationId(): Int = createdAt.toInt() xor id.hashCode() data class LocationReminderCount( @ColumnInfo(name = "location_id") val locationId: String, val count: Int ) - diff --git a/app/src/main/java/dev/arrase/geotify/di/AppModule.kt b/app/src/main/java/dev/arrase/geotify/di/AppModule.kt index 2705ded..24f5881 100644 --- a/app/src/main/java/dev/arrase/geotify/di/AppModule.kt +++ b/app/src/main/java/dev/arrase/geotify/di/AppModule.kt @@ -8,6 +8,7 @@ import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import dev.arrase.geotify.data.GeotifyDatabase +import dev.arrase.geotify.data.Migrations import dev.arrase.geotify.data.dao.LocationDao import dev.arrase.geotify.data.dao.ReminderDao import kotlinx.coroutines.CoroutineDispatcher @@ -25,25 +26,15 @@ object AppModule { @Provides @Singleton - @MainDispatcher - fun provideMainDispatcher(): CoroutineDispatcher = Dispatchers.Main - - @Provides - @Singleton - @DefaultDispatcher - fun provideDefaultDispatcher(): CoroutineDispatcher = Dispatchers.Default - - @Provides - @Singleton - fun provideDatabase(@ApplicationContext context: Context): GeotifyDatabase { - return Room.databaseBuilder( + fun provideDatabase(@ApplicationContext context: Context): GeotifyDatabase = + Room.databaseBuilder( context.applicationContext, GeotifyDatabase::class.java, - "geotify.db" + GeotifyDatabase.NAME ) - .fallbackToDestructiveMigration(dropAllTables = true) + // Every schema step is declared explicitly, so user data is never dropped on upgrade. + .addMigrations(*Migrations.all()) .build() - } @Provides fun provideLocationDao(database: GeotifyDatabase): LocationDao = database.locationDao() diff --git a/app/src/main/java/dev/arrase/geotify/di/BindingsModule.kt b/app/src/main/java/dev/arrase/geotify/di/BindingsModule.kt index c102321..d122489 100644 --- a/app/src/main/java/dev/arrase/geotify/di/BindingsModule.kt +++ b/app/src/main/java/dev/arrase/geotify/di/BindingsModule.kt @@ -8,7 +8,6 @@ import dev.arrase.geotify.geofence.AndroidGeofenceManager import dev.arrase.geotify.geofence.GeofenceManager import dev.arrase.geotify.location.DefaultLocationProvider import dev.arrase.geotify.location.LocationProvider -import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) @@ -16,10 +15,8 @@ import javax.inject.Singleton interface BindingsModule { @Binds - @Singleton fun bindGeofenceManager(impl: AndroidGeofenceManager): GeofenceManager @Binds - @Singleton fun bindLocationProvider(impl: DefaultLocationProvider): LocationProvider } diff --git a/app/src/main/java/dev/arrase/geotify/di/CoroutineDispatchers.kt b/app/src/main/java/dev/arrase/geotify/di/CoroutineDispatchers.kt index a58f43c..858be94 100644 --- a/app/src/main/java/dev/arrase/geotify/di/CoroutineDispatchers.kt +++ b/app/src/main/java/dev/arrase/geotify/di/CoroutineDispatchers.kt @@ -2,14 +2,7 @@ package dev.arrase.geotify.di import javax.inject.Qualifier +/** Marks the dispatcher used for disk and database work. */ @Qualifier @Retention(AnnotationRetention.BINARY) annotation class IoDispatcher - -@Qualifier -@Retention(AnnotationRetention.BINARY) -annotation class MainDispatcher - -@Qualifier -@Retention(AnnotationRetention.BINARY) -annotation class DefaultDispatcher diff --git a/app/src/main/java/dev/arrase/geotify/domain/SpatialSearchUseCase.kt b/app/src/main/java/dev/arrase/geotify/domain/SpatialSearchUseCase.kt index f49bda2..d4c83b8 100644 --- a/app/src/main/java/dev/arrase/geotify/domain/SpatialSearchUseCase.kt +++ b/app/src/main/java/dev/arrase/geotify/domain/SpatialSearchUseCase.kt @@ -1,61 +1,77 @@ package dev.arrase.geotify.domain import android.location.Location -import dev.arrase.geotify.data.dao.LocationDao +import dev.arrase.geotify.data.LocationRepository import dev.arrase.geotify.data.entity.LocationEntity +import dev.arrase.geotify.di.IoDispatcher +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext import javax.inject.Inject import kotlin.math.cos +/** + * Finds the locations closest to a centre point, closest first, capped at + * [MAX_POI_GEOFENCES]. Candidates are pre-filtered with a bounding box and then + * filtered by exact great-circle distance. + */ class SpatialSearchUseCase @Inject constructor( - private val locationDao: LocationDao + private val locationRepository: LocationRepository, + @param:IoDispatcher private val ioDispatcher: CoroutineDispatcher ) { - suspend fun execute( + + suspend operator fun invoke( centerLat: Double, centerLon: Double, - radiusN: Float - ): List { - val radiusInMeters = radiusN * 1000.0 - val latDegreesChange = radiusInMeters / 111320.0 - - val latRad = Math.toRadians(centerLat) - val cosLat = cos(latRad) - val lonDegreesChange = if (cosLat > 0.0) { - radiusInMeters / (111320.0 * cosLat) - } else { - 360.0 - } - - val minLat = centerLat - latDegreesChange - val maxLat = centerLat + latDegreesChange - val minLon = centerLon - lonDegreesChange - val maxLon = centerLon + lonDegreesChange - - val candidates = locationDao.getLocationsInBoundingBox(minLat, maxLat, minLon, maxLon) - - val resultsWithDistance = mutableListOf>() - val resultsBuffer = FloatArray(1) - - for (candidate in candidates) { - Location.distanceBetween( - centerLat, - centerLon, - candidate.latitude, - candidate.longitude, - resultsBuffer - ) - val dist = resultsBuffer[0] - if (dist <= radiusInMeters) { - resultsWithDistance.add(Pair(candidate, dist)) - } - } + radiusKm: Float + ): List = withContext(ioDispatcher) { + require(centerLat in -90.0..90.0) { "Latitude must be between -90.0 and 90.0" } + require(centerLon in -180.0..180.0) { "Longitude must be between -180.0 and 180.0" } + require(radiusKm >= 0f) { "Radius must be non-negative" } + + val radiusMeters = radiusKm * METERS_PER_KM + val latDelta = radiusMeters / METERS_PER_DEGREE + + // A degree of longitude shrinks towards the poles, where it reaches zero. Validation + // above guarantees `centerLat` is within [-90, 90], so `cosLat` is never negative. + val cosLat = cos(Math.toRadians(centerLat)) + val lonDelta = + if (cosLat < COS_LAT_EPSILON) 360.0 + else radiusMeters / (METERS_PER_DEGREE * cosLat) + + val candidates = locationRepository.findLocationsInBoundingBox( + minLat = centerLat - latDelta, + maxLat = centerLat + latDelta, + minLon = centerLon - lonDelta, + maxLon = centerLon + lonDelta + ) - return resultsWithDistance - .sortedBy { it.second } - .map { it.first } + val distanceBuffer = FloatArray(1) + candidates + .map { candidate -> + Location.distanceBetween( + centerLat, + centerLon, + candidate.latitude, + candidate.longitude, + distanceBuffer + ) + candidate to distanceBuffer[0] + } + .filter { (_, distance) -> distance <= radiusMeters } + .sortedBy { (_, distance) -> distance } + .map { (candidate, _) -> candidate } .take(MAX_POI_GEOFENCES) } companion object { + private const val METERS_PER_KM = 1000.0 + + /** Approximate meters in one degree of latitude. */ + private const val METERS_PER_DEGREE = 111_320.0 + + /** Below this |cos(lat)| the longitude span covers the whole globe. */ + private const val COS_LAT_EPSILON = 1e-6 + /** GMS allows a maximum of 100 geofences per app; 1 is reserved for the master geofence. */ const val MAX_POI_GEOFENCES = 99 } diff --git a/app/src/main/java/dev/arrase/geotify/geofence/AndroidGeofenceManager.kt b/app/src/main/java/dev/arrase/geotify/geofence/AndroidGeofenceManager.kt index 66a7d9b..67f971f 100644 --- a/app/src/main/java/dev/arrase/geotify/geofence/AndroidGeofenceManager.kt +++ b/app/src/main/java/dev/arrase/geotify/geofence/AndroidGeofenceManager.kt @@ -1,7 +1,7 @@ package dev.arrase.geotify.geofence -import android.annotation.SuppressLint import android.Manifest +import android.annotation.SuppressLint import android.app.PendingIntent import android.content.Context import android.content.Intent @@ -13,15 +13,14 @@ import com.google.android.gms.location.Geofence import com.google.android.gms.location.GeofencingClient import com.google.android.gms.location.GeofencingRequest import com.google.android.gms.location.LocationServices +import dev.arrase.geotify.data.SettingsManager import dev.arrase.geotify.data.entity.LocationEntity import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.first import kotlinx.coroutines.tasks.await import javax.inject.Inject import javax.inject.Singleton -import dev.arrase.geotify.data.SettingsManager -import kotlinx.coroutines.flow.first - @Singleton class AndroidGeofenceManager @Inject constructor( @param:ApplicationContext private val context: Context, @@ -38,7 +37,7 @@ class AndroidGeofenceManager @Inject constructor( */ private val geofencePendingIntent: PendingIntent by lazy { val intent = Intent(context, GeofenceBroadcastReceiver::class.java).apply { - action = "dev.arrase.geotify.ACTION_RECEIVE_GEOFENCE" + action = ACTION_RECEIVE_GEOFENCE } PendingIntent.getBroadcast( context, 0, intent, @@ -46,151 +45,107 @@ class AndroidGeofenceManager @Inject constructor( ) } - @SuppressLint("MissingPermission") - override suspend fun registerGeofenceForLocation(location: LocationEntity, transitionTypes: Int) { - Log.i("GeofenceManager", "registerGeofenceForLocation: alias=${location.alias}, id=${location.id}, transitionTypes=$transitionTypes") - if (!hasRequiredLocationPermissions()) return - - val defaultPoiResponsivenessMs = settingsManager.poiGeofenceResponsivenessSecs.first() * 1000 - val geofence = Geofence.Builder() - .setRequestId(location.id) - .setCircularRegion(location.latitude, location.longitude, location.radiusMeters) - .setExpirationDuration(Geofence.NEVER_EXPIRE) - .setTransitionTypes(transitionTypes) - .setNotificationResponsiveness(maxOf(defaultPoiResponsivenessMs, location.notificationResponsivenessMs)) - .build() - - val request = GeofencingRequest.Builder() - .setInitialTrigger(GeofencingRequest.INITIAL_TRIGGER_ENTER) - .addGeofence(geofence) - .build() - - Log.i("GeofenceManager", "Calling addGeofences for location ${location.alias} with radius ${location.radiusMeters}...") - try { - geofencingClient.addGeofences(request, geofencePendingIntent).await() - Log.i("GeofenceManager", "Successfully registered geofence in GMS for: ${location.alias}") - } catch (e: Exception) { - Log.e("GeofenceManager", "Failed to register geofence in GMS for: ${location.alias}", e) - throw e - } - } - - override suspend fun removeGeofences(requestIds: List) { - if (requestIds.isEmpty()) return - geofencingClient.removeGeofences(requestIds).await() - } - override suspend fun removeAllGeofences() { - Log.i("GeofenceManager", "removeAllGeofences: Purging all geofences registered with pending intent...") - try { - geofencingClient.removeGeofences(geofencePendingIntent).await() - Log.i("GeofenceManager", "Successfully removed all geofences") - } catch (e: Exception) { - Log.e("GeofenceManager", "Failed to remove all geofences", e) - throw e - } + Log.i(TAG, "Purging all geofences registered with pending intent...") + geofencingClient.removeGeofences(geofencePendingIntent).await() + Log.i(TAG, "Successfully removed all geofences") } - @SuppressLint("MissingPermission") override suspend fun registerSlidingWindowGeofences( locations: Map, centerLat: Double, centerLon: Double, innerRadiusMeters: Float - ) { - Log.i("GeofenceManager", "registerSlidingWindowGeofences: centerLat=$centerLat, centerLon=$centerLon, innerRadiusMeters=$innerRadiusMeters, locationsCount=${locations.size}") - if (!hasRequiredLocationPermissions()) return - - val requestBuilder = GeofencingRequest.Builder() + ): Boolean { + val request = GeofencingRequest.Builder() .setInitialTrigger(GeofencingRequest.INITIAL_TRIGGER_ENTER) - - // 1. Create and add Master Geofence - val masterResponsivenessMs = settingsManager.masterGeofenceResponsivenessSecs.first() * 1000 - val masterGeofence = Geofence.Builder() - .setRequestId("MASTER_GEOFENCE_TRIGGER") - .setCircularRegion(centerLat, centerLon, innerRadiusMeters) - .setExpirationDuration(Geofence.NEVER_EXPIRE) - .setTransitionTypes(Geofence.GEOFENCE_TRANSITION_EXIT) - .setNotificationResponsiveness(masterResponsivenessMs) - .build() - requestBuilder.addGeofence(masterGeofence) - - // 2. Create and add POIs geofences - val defaultPoiResponsivenessMs = settingsManager.poiGeofenceResponsivenessSecs.first() * 1000 - for ((location, transitionTypes) in locations) { - val geofence = Geofence.Builder() - .setRequestId(location.id) - .setCircularRegion(location.latitude, location.longitude, location.radiusMeters) - .setExpirationDuration(Geofence.NEVER_EXPIRE) - .setTransitionTypes(transitionTypes) - .setNotificationResponsiveness(maxOf(defaultPoiResponsivenessMs, location.notificationResponsivenessMs)) // batching - .build() - requestBuilder.addGeofence(geofence) - } - - val request = requestBuilder.build() - - Log.i("GeofenceManager", "Calling addGeofences for sliding window...") - try { - geofencingClient.addGeofences(request, geofencePendingIntent).await() - Log.i("GeofenceManager", "Successfully registered sliding window geofences in GMS") - } catch (e: Exception) { - Log.e("GeofenceManager", "Failed to register sliding window geofences in GMS", e) - throw e + .addGeofence(buildMasterGeofence(centerLat, centerLon, innerRadiusMeters)) + locations.forEach { (location, transitionTypes) -> + request.addGeofence(buildPoiGeofence(location, transitionTypes)) } + return submit(request.build(), "sliding window (${locations.size} POIs + master)") } - @SuppressLint("MissingPermission") override suspend fun registerMasterGeofence( centerLat: Double, centerLon: Double, innerRadiusMeters: Float - ) { - Log.i("GeofenceManager", "registerMasterGeofence: centerLat=$centerLat, centerLon=$centerLon, innerRadiusMeters=$innerRadiusMeters") - if (!hasRequiredLocationPermissions()) return - - val masterResponsivenessMs = settingsManager.masterGeofenceResponsivenessSecs.first() * 1000 - val masterGeofence = Geofence.Builder() - .setRequestId("MASTER_GEOFENCE_TRIGGER") - .setCircularRegion(centerLat, centerLon, innerRadiusMeters) - .setExpirationDuration(Geofence.NEVER_EXPIRE) - .setTransitionTypes(Geofence.GEOFENCE_TRANSITION_EXIT) - .setNotificationResponsiveness(masterResponsivenessMs) + ): Boolean { + // No initial trigger: the master geofence only listens for exits, and GMS ignores an + // initial trigger whose transition type is not in the geofence's own transition mask. + val request = GeofencingRequest.Builder() + .addGeofence(buildMasterGeofence(centerLat, centerLon, innerRadiusMeters)) .build() + return submit(request, "master only") + } - val request = GeofencingRequest.Builder() - .setInitialTrigger(GeofencingRequest.INITIAL_TRIGGER_ENTER) - .addGeofence(masterGeofence) + private suspend fun buildMasterGeofence( + centerLat: Double, + centerLon: Double, + innerRadiusMeters: Float + ): Geofence = Geofence.Builder() + .setRequestId(GeofenceManager.MASTER_REQUEST_ID) + .setCircularRegion(centerLat, centerLon, innerRadiusMeters) + .setExpirationDuration(Geofence.NEVER_EXPIRE) + .setTransitionTypes(Geofence.GEOFENCE_TRANSITION_EXIT) + .setNotificationResponsiveness( + settingsManager.masterGeofenceResponsivenessSecs.first() * MILLIS_PER_SECOND + ) + .build() + + private suspend fun buildPoiGeofence( + location: LocationEntity, + transitionTypes: Int + ): Geofence { + val defaultResponsiveness = + settingsManager.poiGeofenceResponsivenessSecs.first() * MILLIS_PER_SECOND + return Geofence.Builder() + .setRequestId(location.id) + .setCircularRegion(location.latitude, location.longitude, location.radiusMeters) + .setExpirationDuration(Geofence.NEVER_EXPIRE) + .setTransitionTypes(transitionTypes) + // GMS requires a non-zero responsiveness; fall back to the app-wide default. + .setNotificationResponsiveness( + maxOf(defaultResponsiveness, location.notificationResponsivenessMs) + ) .build() + } - Log.i("GeofenceManager", "Calling addGeofences for master geofence only...") - try { - geofencingClient.addGeofences(request, geofencePendingIntent).await() - Log.i("GeofenceManager", "Successfully registered master geofence only in GMS") - } catch (e: Exception) { - Log.e("GeofenceManager", "Failed to register master geofence in GMS", e) - throw e + /** @return `true` if GMS now holds this window, `false` if permissions prevented it. */ + @SuppressLint("MissingPermission") + private suspend fun submit(request: GeofencingRequest, description: String): Boolean { + if (!hasLocationPermissions()) { + Log.w(TAG, "Skipping registration of $description geofences: permissions not granted") + return false } + Log.i(TAG, "Registering $description geofences with GMS...") + geofencingClient.addGeofences(request, geofencePendingIntent).await() + Log.i(TAG, "Successfully registered $description geofences") + return true } - private fun hasRequiredLocationPermissions(): Boolean { - val fineLocationPermission = ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) - val backgroundLocationPermission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_BACKGROUND_LOCATION) + /** + * Geofencing needs a precise-enough fix, so `ACCESS_FINE_LOCATION` is required. Play services + * only grants it when the user chose precise location, not approximate. + */ + private fun hasLocationPermissions(): Boolean { + val fineLocation = ContextCompat.checkSelfPermission( + context, Manifest.permission.ACCESS_FINE_LOCATION + ) == PackageManager.PERMISSION_GRANTED + val backgroundLocation = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + ContextCompat.checkSelfPermission( + context, Manifest.permission.ACCESS_BACKGROUND_LOCATION + ) == PackageManager.PERMISSION_GRANTED } else { - PackageManager.PERMISSION_GRANTED + true } - if (fineLocationPermission != PackageManager.PERMISSION_GRANTED || - backgroundLocationPermission != PackageManager.PERMISSION_GRANTED - ) { - Log.w("GeofenceManager", MSG_PERMISSIONS_NOT_GRANTED) - return false - } - return true + return fineLocation && backgroundLocation } - companion object { - private const val MSG_PERMISSIONS_NOT_GRANTED = "Aborting registration: Permissions not granted!" + private companion object { + const val TAG = "GeotifyGeofence" + const val ACTION_RECEIVE_GEOFENCE = "dev.arrase.geotify.ACTION_RECEIVE_GEOFENCE" + const val MILLIS_PER_SECOND = 1_000 } } diff --git a/app/src/main/java/dev/arrase/geotify/geofence/BootCompletedReceiver.kt b/app/src/main/java/dev/arrase/geotify/geofence/BootCompletedReceiver.kt index c7942c1..8e9c2e8 100644 --- a/app/src/main/java/dev/arrase/geotify/geofence/BootCompletedReceiver.kt +++ b/app/src/main/java/dev/arrase/geotify/geofence/BootCompletedReceiver.kt @@ -12,6 +12,10 @@ import dagger.hilt.InstallIn import dagger.hilt.android.EntryPointAccessors import dagger.hilt.components.SingletonComponent +/** + * Re-registers the geofence window after the device boots or the app is updated, since Play + * services drops every geofence in both cases. + */ class BootCompletedReceiver : BroadcastReceiver() { @EntryPoint @@ -21,23 +25,23 @@ class BootCompletedReceiver : BroadcastReceiver() { } override fun onReceive(context: Context, intent: Intent) { - if (intent.action != Intent.ACTION_BOOT_COMPLETED) return + if (intent.action !in RELEVANT_ACTIONS) return if (ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED ) { - Log.w(TAG, "Location permission not granted. Skipping geofence re-registration after boot.") + Log.w(TAG, "Location permission not granted. Skipping geofence re-registration.") return } - Log.i(TAG, "Boot completed. Re-registering geofences...") - val entryPoint = EntryPointAccessors.fromApplication( - context, BootEntryPoint::class.java - ) - entryPoint.geofenceOrchestrator().triggerExpeditedRecalculation() + Log.i(TAG, "Triggering geofence re-registration after ${intent.action}") + EntryPointAccessors.fromApplication(context, BootEntryPoint::class.java) + .geofenceOrchestrator() + .triggerExpeditedRecalculation() } - companion object { - private const val TAG = "BootCompletedReceiver" + private companion object { + const val TAG = "GeotifyBootReceiver" + val RELEVANT_ACTIONS = setOf(Intent.ACTION_BOOT_COMPLETED, Intent.ACTION_MY_PACKAGE_REPLACED) } } diff --git a/app/src/main/java/dev/arrase/geotify/geofence/GeofenceBroadcastReceiver.kt b/app/src/main/java/dev/arrase/geotify/geofence/GeofenceBroadcastReceiver.kt index 1e32d92..0663903 100644 --- a/app/src/main/java/dev/arrase/geotify/geofence/GeofenceBroadcastReceiver.kt +++ b/app/src/main/java/dev/arrase/geotify/geofence/GeofenceBroadcastReceiver.kt @@ -12,9 +12,11 @@ import dagger.hilt.android.EntryPointAccessors import dagger.hilt.components.SingletonComponent import dev.arrase.geotify.data.LocationRepository import dev.arrase.geotify.data.ReminderRepository +import dev.arrase.geotify.data.entity.notificationId import dev.arrase.geotify.notification.NotificationHelper import dev.arrase.geotify.util.goAsyncCoroutine +/** Receives geofence transitions from Google Play services. */ class GeofenceBroadcastReceiver : BroadcastReceiver() { @EntryPoint @@ -26,93 +28,90 @@ class GeofenceBroadcastReceiver : BroadcastReceiver() { } override fun onReceive(context: Context, intent: Intent) { - Log.d(TAG, "onReceive triggered with intent: $intent") - val geofencingEvent = GeofencingEvent.fromIntent(intent) - if (geofencingEvent == null) { - Log.w(TAG, "GeofencingEvent is null in received intent") + val event = GeofencingEvent.fromIntent(intent) + if (event == null) { + Log.w(TAG, "Received an intent without a GeofencingEvent") return } - if (geofencingEvent.hasError()) { - Log.e(TAG, "Geofencing error code: ${geofencingEvent.errorCode}") + val entryPoint = EntryPointAccessors.fromApplication(context, ReceiverEntryPoint::class.java) + val orchestrator = entryPoint.geofenceOrchestrator() + + if (event.hasError()) { + // Errors such as GEOFENCE_TOO_MANY_GEOFENCES or GEOFENCE_NOT_REGISTERED leave the + // monitored set inconsistent; only a recalculation can recover from them. + Log.e(TAG, "Geofencing error code: ${event.errorCode}. Scheduling recalculation.") + orchestrator.triggerExpeditedRecalculation() return } - val triggeringGeofences = geofencingEvent.triggeringGeofences + val triggeringGeofences = event.triggeringGeofences if (triggeringGeofences.isNullOrEmpty()) { - Log.w(TAG, "No triggering geofences found in event") + Log.w(TAG, "GeofencingEvent contained no triggering geofences") return } - val transitionType = geofencingEvent.geofenceTransition - Log.d(TAG, "Triggered geofences count: ${triggeringGeofences.size}, transitionType: $transitionType") - - val entryPoint = EntryPointAccessors.fromApplication( - context, ReceiverEntryPoint::class.java - ) + val transitionType = event.geofenceTransition + Log.d(TAG, "${triggeringGeofences.size} geofence(s) triggered, transition=$transitionType") - val hasMasterExit = triggeringGeofences.any { it.requestId == "MASTER_GEOFENCE_TRIGGER" } && - transitionType == Geofence.GEOFENCE_TRANSITION_EXIT - if (hasMasterExit) { - Log.i(TAG, "Master geofence exit triggered. Enqueuing expedited recalculation...") - entryPoint.geofenceOrchestrator().triggerExpeditedRecalculation() + val masterExited = transitionType == Geofence.GEOFENCE_TRANSITION_EXIT && + triggeringGeofences.any { it.requestId == GeofenceManager.MASTER_REQUEST_ID } + if (masterExited) { + Log.i(TAG, "Left the recalculation area. Scheduling expedited recalculation.") + orchestrator.triggerExpeditedRecalculation() } - val poiGeofences = triggeringGeofences.filter { it.requestId != "MASTER_GEOFENCE_TRIGGER" } + val poiGeofences = triggeringGeofences + .filter { it.requestId != GeofenceManager.MASTER_REQUEST_ID } if (poiGeofences.isEmpty()) return goAsyncCoroutine { - try { - val locationRepo = entryPoint.locationRepository() - val reminderRepo = entryPoint.reminderRepository() - processPoiGeofences(context, locationRepo, reminderRepo, poiGeofences, transitionType) - } catch (e: Exception) { - Log.e(TAG, "Error processing geofence event", e) - } + runCatching { + processPoiTransitions( + context = context, + locationRepository = entryPoint.locationRepository(), + reminderRepository = entryPoint.reminderRepository(), + poiGeofences = poiGeofences, + transitionType = transitionType + ) + }.onFailure { Log.e(TAG, "Failed to process geofence event", it) } - // Re-evaluate geofences after deactivating triggered reminders. - // If no active reminders remain, this will purge all geofences to save battery. - entryPoint.geofenceOrchestrator().triggerExpeditedRecalculation() + // Re-evaluate the window now that the triggered reminders are no longer active. + orchestrator.triggerExpeditedRecalculation() } } - private suspend fun processPoiGeofences( + private suspend fun processPoiTransitions( context: Context, - locationRepo: LocationRepository, - reminderRepo: ReminderRepository, + locationRepository: LocationRepository, + reminderRepository: ReminderRepository, poiGeofences: List, transitionType: Int ) { - for (geofence in poiGeofences) { + poiGeofences.forEach { geofence -> val locationId = geofence.requestId - val location = locationRepo.findLocationById(locationId) + val location = locationRepository.findLocationById(locationId) if (location == null) { - Log.d(TAG, "Location not found in database for geofence ID: $locationId") - continue + Log.d(TAG, "No location stored for geofence $locationId; ignoring") + return@forEach } - Log.d(TAG, "Processing geofence for location: ${location.alias} (ID: $locationId)") - - val activeReminders = reminderRepo.getActiveRemindersForLocation(locationId) - Log.d(TAG, "Found ${activeReminders.size} active reminders for location ID: $locationId") - - val matchingReminders = activeReminders.filter { it.transitionType == transitionType } - Log.d(TAG, "Found ${matchingReminders.size} matching reminders for transitionType: $transitionType") - for (reminder in matchingReminders) { - Log.d(TAG, "Deactivating and showing notification for reminder ID: ${reminder.id}") - reminderRepo.deactivateReminder(reminder.id) + val due = reminderRepository.getActiveRemindersForLocation(locationId) + .filter { it.transitionType == transitionType } + due.forEach { reminder -> NotificationHelper.showGeofenceNotification( - context, - reminder.id.hashCode(), - location.alias, - reminder.message + context = context, + notificationId = reminder.notificationId(), + alias = location.alias, + message = reminder.message ) + reminderRepository.deactivateReminder(reminder.id) } } } - companion object { - private const val TAG = "GeofenceBroadcastReceiver" + private companion object { + const val TAG = "GeotifyGeofenceReceiver" } } diff --git a/app/src/main/java/dev/arrase/geotify/geofence/GeofenceManager.kt b/app/src/main/java/dev/arrase/geotify/geofence/GeofenceManager.kt index 1284d20..da3c611 100644 --- a/app/src/main/java/dev/arrase/geotify/geofence/GeofenceManager.kt +++ b/app/src/main/java/dev/arrase/geotify/geofence/GeofenceManager.kt @@ -2,19 +2,38 @@ package dev.arrase.geotify.geofence import dev.arrase.geotify.data.entity.LocationEntity +/** Registers and clears the geofences monitored by Google Play services. */ interface GeofenceManager { - suspend fun registerGeofenceForLocation(location: LocationEntity, transitionTypes: Int) - suspend fun removeGeofences(requestIds: List) + + /** Removes every geofence previously registered by this app. */ suspend fun removeAllGeofences() + + /** + * Replaces the monitored set with a master geofence plus one geofence per entry in [locations], + * keyed by location and mapped to its GMS transition-type bitmask. + * + * @return `true` if GMS accepted the new window, `false` if a required permission is missing. + */ suspend fun registerSlidingWindowGeofences( locations: Map, centerLat: Double, centerLon: Double, innerRadiusMeters: Float - ) + ): Boolean + + /** + * Registers only the master geofence, used when no POI is currently in range. + * + * @return `true` if GMS accepted it, `false` if a required permission is missing. + */ suspend fun registerMasterGeofence( centerLat: Double, centerLon: Double, innerRadiusMeters: Float - ) + ): Boolean + + companion object { + /** Request id of the geofence centred on the user, used to detect leaving the inner radius. */ + const val MASTER_REQUEST_ID = "MASTER_GEOFENCE_TRIGGER" + } } diff --git a/app/src/main/java/dev/arrase/geotify/geofence/GeofenceOrchestrator.kt b/app/src/main/java/dev/arrase/geotify/geofence/GeofenceOrchestrator.kt index c1325cb..23656fd 100644 --- a/app/src/main/java/dev/arrase/geotify/geofence/GeofenceOrchestrator.kt +++ b/app/src/main/java/dev/arrase/geotify/geofence/GeofenceOrchestrator.kt @@ -14,6 +14,15 @@ import java.util.concurrent.TimeUnit import javax.inject.Inject import javax.inject.Singleton +/** + * Schedules [GeofenceRecalculationWorker] runs. + * + * The two paths use separate unique work names so they cannot cancel one another, and the policy + * differs by intent: a recalculation replaces the whole GMS geofence set, so a debounced run that is + * already pending must be *replaced* to collapse a burst of edits into a single run starting after + * the last one, while an expedited run must be *kept* so that a fix already in progress is never + * interrupted midway. + */ @Singleton class GeofenceOrchestrator @Inject constructor( @param:ApplicationContext private val context: Context, @@ -21,50 +30,48 @@ class GeofenceOrchestrator @Inject constructor( ) { /** - * Enqueues an expedited recalculation worker immediately. - * Safe to call from non-suspend context (e.g. BroadcastReceiver.onReceive). + * Enqueues an expedited recalculation as soon as the platform allows. + * Safe to call from a non-suspending context such as `BroadcastReceiver.onReceive`. * - * Note on FOREGROUND_SERVICE: Under Android 12+, expedited work can run immediately even - * when the app is in the background. We use [OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST] - * to fall back to a regular work request if quota is exhausted. Since this work runs in - * milliseconds (purely local DB query and geofence updates), we do not need to bind a - * Foreground Service notification, avoiding a flashing notification to the user. + * Under Android 12+ expedited work may run immediately in the background. If the quota is + * exhausted the request degrades to a normal one; since the work is a local database query + * plus a GMS call it does not need a foreground-service notification. */ fun triggerExpeditedRecalculation() { - Log.i(TAG, "Enqueuing EXPEDITED geofence recalculation...") val request = OneTimeWorkRequestBuilder() .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) .build() - enqueue(request) + enqueue(EXPEDITED_WORK_NAME, ExistingWorkPolicy.KEEP, request) } /** - * Enqueues a debounced recalculation worker. - * The debounce delay is read from [SettingsManager] and uses [ExistingWorkPolicy.REPLACE] - * so rapid successive calls collapse into a single execution. + * Enqueues a recalculation after the user-configured debounce delay. Successive calls replace + * the pending one, so a burst of edits results in a single run once the user stops changing + * things. */ suspend fun triggerRecalculation() { val debounceSecs = settingsManager.recalculationDebounceSecs.first().toLong() - Log.i(TAG, "Enqueuing debounced geofence recalculation (delay=${debounceSecs}s)...") val request = OneTimeWorkRequestBuilder() .setInitialDelay(debounceSecs, TimeUnit.SECONDS) .build() - enqueue(request) + enqueue(DEBOUNCED_WORK_NAME, ExistingWorkPolicy.REPLACE, request) } - private fun enqueue(request: OneTimeWorkRequest) { + private fun enqueue( + uniqueWorkName: String, + policy: ExistingWorkPolicy, + request: OneTimeWorkRequest + ) { try { - WorkManager.getInstance(context) - .enqueueUniqueWork(WORK_NAME, ExistingWorkPolicy.REPLACE, request) + WorkManager.getInstance(context).enqueueUniqueWork(uniqueWorkName, policy, request) } catch (e: IllegalStateException) { - Log.w(TAG, "WorkManager not initialized (test environment). Skipping enqueue.") - } catch (e: Exception) { - Log.e(TAG, "Failed to enqueue recalculation worker", e) + Log.w(TAG, "WorkManager not initialized; skipping recalculation.", e) } } - companion object { - private const val TAG = "GeofenceOrchestrator" - private const val WORK_NAME = "geofence_recalculation" + private companion object { + const val TAG = "GeotifyOrchestrator" + const val EXPEDITED_WORK_NAME = "geofence_recalculation_expedited" + const val DEBOUNCED_WORK_NAME = "geofence_recalculation_debounced" } } diff --git a/app/src/main/java/dev/arrase/geotify/geofence/GeofenceRecalculationWorker.kt b/app/src/main/java/dev/arrase/geotify/geofence/GeofenceRecalculationWorker.kt index 25296ef..5654d46 100644 --- a/app/src/main/java/dev/arrase/geotify/geofence/GeofenceRecalculationWorker.kt +++ b/app/src/main/java/dev/arrase/geotify/geofence/GeofenceRecalculationWorker.kt @@ -2,6 +2,7 @@ package dev.arrase.geotify.geofence import android.Manifest import android.content.Context +import android.location.Location import android.content.pm.PackageManager import android.util.Log import androidx.core.content.ContextCompat @@ -12,12 +13,21 @@ import dagger.hilt.EntryPoint import dagger.hilt.InstallIn import dagger.hilt.android.EntryPointAccessors import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CancellationException import dev.arrase.geotify.data.ReminderRepository import dev.arrase.geotify.data.SettingsManager +import dev.arrase.geotify.data.entity.LocationEntity +import dev.arrase.geotify.data.entity.ReminderEntity import dev.arrase.geotify.domain.SpatialSearchUseCase import dev.arrase.geotify.location.LocationProvider import kotlinx.coroutines.flow.first +/** + * Recomputes the sliding window of monitored geofences around the user's current position. + * + * The new window is registered with GMS before any derived state is persisted, so a failure + * can never leave the database claiming geofences that do not exist. + */ class GeofenceRecalculationWorker( appContext: Context, workerParams: WorkerParameters @@ -39,95 +49,135 @@ class GeofenceRecalculationWorker( applicationContext, RecalculationWorkerEntryPoint::class.java ) - val reminderRepository = entryPoint.reminderRepository() - val settingsManager = entryPoint.settingsManager() - val spatialSearchUseCase = entryPoint.spatialSearchUseCase() - val locationProvider = entryPoint.locationProvider() - val geofenceManager = entryPoint.geofenceManager() - - try { - val fineLocationPermission = ContextCompat.checkSelfPermission( - applicationContext, - Manifest.permission.ACCESS_FINE_LOCATION - ) - if (fineLocationPermission != PackageManager.PERMISSION_GRANTED) { - Log.w(TAG, "Location permission not granted. Cannot recalculate sliding window.") - return Result.failure() - } - - val location = locationProvider.getCurrentLocation(Priority.PRIORITY_BALANCED_POWER_ACCURACY) - if (location == null) { - Log.w(TAG, "Could not obtain current location. Retrying...") - return Result.retry() - } - - val centerLat = location.latitude - val centerLon = location.longitude - - // Save the last recalculation center - settingsManager.setLastRecalcLocation(centerLat, centerLon) - - val outerRadiusN = settingsManager.outerRadiusN.first() - val innerRadiusR = settingsManager.innerRadiusR.first() - - Log.i(TAG, "Current Location: ($centerLat, $centerLon). Radii: N=$outerRadiusN km, r=$innerRadiusR km") - val spatialCandidates = spatialSearchUseCase.execute(centerLat, centerLon, outerRadiusN) - - val activeReminders = reminderRepository.getActiveReminders() - val activeLocationIds = activeReminders.map { it.locationId }.toSet() + if (!hasLocationPermission()) { + // Not a failure: the app re-enqueues this work on the next launch or data change. + Log.w(TAG, "Location permission not granted. Skipping sliding window recalculation.") + return Result.success() + } - val activeCandidates = spatialCandidates.filter { candidate -> - activeLocationIds.contains(candidate.id) + return try { + val location = entryPoint.locationProvider() + .getCurrentLocation(Priority.PRIORITY_BALANCED_POWER_ACCURACY) + ?: return retryOrGiveUp("Could not obtain current location") + + val settingsManager = entryPoint.settingsManager() + val outerRadiusKm = settingsManager.outerRadiusN.first() + val innerRadiusKm = settingsManager.innerRadiusR.first() + + val activeReminders = entryPoint.reminderRepository().getActiveReminders() + val candidates = entryPoint.spatialSearchUseCase()( + centerLat = location.latitude, + centerLon = location.longitude, + radiusKm = outerRadiusKm + ).filter { candidate -> activeReminders.any { it.locationId == candidate.id } } + + registerGeofences( + geofenceManager = entryPoint.geofenceManager(), + candidates = candidates, + activeReminders = activeReminders, + center = location, + innerRadiusMeters = innerRadiusKm * METERS_PER_KM + )?.let { + // Only persist derived state once GMS has actually accepted the new window, + // otherwise the UI would claim geofences that do not exist. + entryPoint.reminderRepository().updateInRangeStatus(candidates.map { it.id }) + settingsManager.setLastRecalcLocation(location.latitude, location.longitude) } - val activeCandidatesWithTransitions = activeCandidates.associateWith { candidate -> - activeReminders - .filter { it.locationId == candidate.id } - .map { it.transitionType } - .fold(0) { acc, type -> acc or type } - } + Result.success() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.e(TAG, "Failed to execute geofence recalculation", e) + retryOrGiveUp("Recalculation failed", e) + } + } - Log.i(TAG, "Found ${spatialCandidates.size} spatial candidate locations. Filtered to ${activeCandidates.size} with active reminders.") + /** + * Installs the new geofence window. + * + * @return `true` when GMS holds the new window, `false` when a required permission is + * missing, or `null` when there was nothing to register because no reminders are active. + */ + private suspend fun registerGeofences( + geofenceManager: GeofenceManager, + candidates: List, + activeReminders: List, + center: Location, + innerRadiusMeters: Float + ): Boolean? { + // A failure here usually means nothing was registered before, so carrying on is correct. + runCatching { geofenceManager.removeAllGeofences() } + .onFailure { Log.w(TAG, "Could not clear previous geofences", it) } + + if (activeReminders.isEmpty()) { + Log.i(TAG, "No active reminders. All geofences cleared to save battery.") + return null + } - Log.i(TAG, "Purging previous geofences...") - try { - geofenceManager.removeAllGeofences() - } catch (e: Exception) { - Log.w(TAG, "Error removing old geofences (might be none registered)", e) + return if (candidates.isNotEmpty()) { + val registered = geofenceManager.registerSlidingWindowGeofences( + locations = candidates.toTransitionMasks(activeReminders), + centerLat = center.latitude, + centerLon = center.longitude, + innerRadiusMeters = innerRadiusMeters + ) + if (registered) { + Log.i(TAG, "Registered ${candidates.size} POI geofences + master geofence") } - - reminderRepository.updateInRangeStatus(activeCandidates.map { it.id }) - - if (activeReminders.isEmpty()) { - Log.i(TAG, "No active reminders. Skipping geofence registration to save battery.") - } else if (activeCandidatesWithTransitions.isNotEmpty()) { - val innerRadiusMeters = innerRadiusR * 1000f - geofenceManager.registerSlidingWindowGeofences( - locations = activeCandidatesWithTransitions, - centerLat = centerLat, - centerLon = centerLon, - innerRadiusMeters = innerRadiusMeters - ) - Log.i(TAG, "Successfully registered ${activeCandidatesWithTransitions.size} POIs + Master Geofence") - } else { - val innerRadiusMeters = innerRadiusR * 1000f - geofenceManager.registerMasterGeofence( - centerLat = centerLat, - centerLon = centerLon, - innerRadiusMeters = innerRadiusMeters - ) - Log.i(TAG, "No candidates in range but active reminders exist. Registered Master Geofence only.") + registered + } else { + val registered = geofenceManager.registerMasterGeofence( + centerLat = center.latitude, + centerLon = center.longitude, + innerRadiusMeters = innerRadiusMeters + ) + if (registered) { + Log.i(TAG, "No POIs in range. Registered master geofence only.") } - - return Result.success() - } catch (e: Exception) { - Log.e(TAG, "Failed to execute geofence recalculation", e) - return Result.retry() + registered } } - companion object { - private const val TAG = "GeofenceRecalcWorker" + /** + * Retries with WorkManager's backoff, but gives up permanently after [MAX_ATTEMPTS] so a device + * that can never obtain a fix does not accumulate an unbounded retry chain. + */ + private fun retryOrGiveUp(reason: String, cause: Throwable? = null): Result = + if (runAttemptCount >= MAX_ATTEMPTS) { + Log.e(TAG, "$reason. Giving up after $MAX_ATTEMPTS attempts.", cause) + Result.failure() + } else { + Log.w(TAG, "$reason. Retrying (attempt ${runAttemptCount + 1}).", cause) + Result.retry() + } + + private fun hasLocationPermission(): Boolean = + ContextCompat.checkSelfPermission( + applicationContext, + Manifest.permission.ACCESS_FINE_LOCATION + ) == PackageManager.PERMISSION_GRANTED + + private companion object { + const val TAG = "GeofenceRecalcWorker" + const val METERS_PER_KM = 1000f + const val MAX_ATTEMPTS = 3 + + /** + * Collapses the reminders attached to each candidate into the single transition bitmask + * that its geofence must monitor (GMS expects the union of the desired transitions). + * Candidates are pre-filtered to locations that have at least one active reminder, so + * every mask resolves to at least one transition. + */ + fun List.toTransitionMasks( + reminders: List + ): Map { + val masksByLocation = HashMap() + reminders.forEach { reminder -> + masksByLocation.merge(reminder.locationId, reminder.transitionType, Int::or) + } + return associateWith { location -> masksByLocation.getValue(location.id) } + } } } diff --git a/app/src/main/java/dev/arrase/geotify/notification/NotificationHelper.kt b/app/src/main/java/dev/arrase/geotify/notification/NotificationHelper.kt index 7ba01fd..c39c696 100644 --- a/app/src/main/java/dev/arrase/geotify/notification/NotificationHelper.kt +++ b/app/src/main/java/dev/arrase/geotify/notification/NotificationHelper.kt @@ -7,20 +7,20 @@ import android.app.PendingIntent import android.content.Context import android.content.Intent import android.content.pm.PackageManager +import android.os.Build import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat import dev.arrase.geotify.MainActivity import dev.arrase.geotify.R +import dev.arrase.geotify.ui.navigation.GeotifyTab object NotificationHelper { const val CHANNEL_GEOFENCE = "geofence_reminders" - const val EXTRA_TAB = "tab" - const val TAB_REMINDERS = "reminders" fun createNotificationChannels(context: Context) { - if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val manager = context.getSystemService(NotificationManager::class.java) val geofenceChannel = NotificationChannel( @@ -38,11 +38,9 @@ object NotificationHelper { /** * Shows a geofence notification for a specific reminder. * - * @param notificationId An integer ID for this notification. Note: If using `reminderId.hashCode()`, - * there is a small theoretical risk of hash collision where another active reminder - * shares the same hashCode and overwrites its notification. For this app, this is - * a reasonable trade-off to map string UUIDs to 32-bit Android notification IDs, - * as the number of simultaneously active notifications is small. + * @param notificationId Android notification id. Also used as the [PendingIntent] request code, + * so it must be unique per notification — reusing a value across two notifications makes the + * second one overwrite the first's `PendingIntent` extras. */ fun showGeofenceNotification( context: Context, @@ -50,20 +48,26 @@ object NotificationHelper { alias: String, message: String ) { - if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) - != PackageManager.PERMISSION_GRANTED - ) return + // `POST_NOTIFICATIONS` is a runtime permission only from Android 13 (API 33). On older + // versions the permission does not exist and checking it always reports denied, so the + // check must be skipped entirely or notifications would never be posted. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && + ContextCompat.checkSelfPermission( + context, Manifest.permission.POST_NOTIFICATIONS + ) != PackageManager.PERMISSION_GRANTED + ) { + return + } val openIntent = Intent(context, MainActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP - putExtra(EXTRA_TAB, TAB_REMINDERS) + putExtra(MainActivity.EXTRA_TAB, GeotifyTab.Reminders.name) } val openPending = PendingIntent.getActivity( context, notificationId, openIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) - val notification = NotificationCompat.Builder(context, CHANNEL_GEOFENCE) .setSmallIcon(R.drawable.ic_notification) .setContentTitle(alias) diff --git a/app/src/main/java/dev/arrase/geotify/ui/BaseViewModel.kt b/app/src/main/java/dev/arrase/geotify/ui/BaseViewModel.kt new file mode 100644 index 0000000..7b23c05 --- /dev/null +++ b/app/src/main/java/dev/arrase/geotify/ui/BaseViewModel.kt @@ -0,0 +1,53 @@ +package dev.arrase.geotify.ui + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.stateIn + +/** How long a `stateIn` subscription stays alive after its last collector disappears. */ +private const val SUBSCRIPTION_TIMEOUT_MS = 5_000L + +/** + * Base for the app's ViewModels. Adds a buffered one-shot message channel and helpers for + * exposing settings as state and for running mutating work with uniform error reporting. + */ +abstract class BaseViewModel : ViewModel() { + + /** + * One-shot user messages. A [Channel] is used rather than a `SharedFlow` because a shared + * flow with no replay drops events emitted while nothing is collecting, which would silently + * lose exactly the error messages the user needs to see. + */ + protected val messages = Channel(Channel.BUFFERED) + val messagesFlow: Flow = messages.receiveAsFlow() + + /** Exposes a flow as state, replaying [initialValue] until the first emission. */ + protected fun Flow.settingFlow(initialValue: T): StateFlow = + stateIn(viewModelScope, SharingStarted.WhileSubscribed(SUBSCRIPTION_TIMEOUT_MS), initialValue) + + /** + * Runs [action], then [onSuccess]. Cancellation is never reported as a failure — only genuine + * errors are turned into a user-visible message. [action] is the last parameter so callers can + * pass it as a trailing lambda. + */ + protected suspend fun mutate( + errorFallback: String, + onSuccess: suspend () -> Unit, + action: suspend () -> Unit + ) { + try { + action() + onSuccess() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + messages.send(errorMessage(errorFallback, e)) + } + } +} diff --git a/app/src/main/java/dev/arrase/geotify/ui/MainViewModel.kt b/app/src/main/java/dev/arrase/geotify/ui/MainViewModel.kt index 1a6aab1..0d0b72f 100644 --- a/app/src/main/java/dev/arrase/geotify/ui/MainViewModel.kt +++ b/app/src/main/java/dev/arrase/geotify/ui/MainViewModel.kt @@ -1,21 +1,17 @@ package dev.arrase.geotify.ui -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel import dev.arrase.geotify.data.SettingsDefaults import dev.arrase.geotify.data.SettingsManager import dev.arrase.geotify.data.ThemeSetting -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.stateIn import javax.inject.Inject @HiltViewModel class MainViewModel @Inject constructor( settingsManager: SettingsManager -) : ViewModel() { +) : BaseViewModel() { - val appTheme: StateFlow = settingsManager.appTheme - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SettingsDefaults.APP_THEME) + val appTheme: StateFlow = + settingsManager.appTheme.settingFlow(SettingsDefaults.APP_THEME) } diff --git a/app/src/main/java/dev/arrase/geotify/ui/UiText.kt b/app/src/main/java/dev/arrase/geotify/ui/UiText.kt index b82e54c..cfc5183 100644 --- a/app/src/main/java/dev/arrase/geotify/ui/UiText.kt +++ b/app/src/main/java/dev/arrase/geotify/ui/UiText.kt @@ -1,18 +1,45 @@ package dev.arrase.geotify.ui import android.content.Context +import androidx.annotation.StringRes import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource +/** + * A user-facing message that may either be a literal string or a string resource resolved at + * composition time, so it honours the user's locale and configuration changes. + */ sealed interface UiText { data class DynamicString(val value: String) : UiText - data class StringResource(val resId: Int) : UiText + + data class StringResource( + @param:StringRes val resId: Int, + val args: List = emptyList() + ) : UiText { + constructor(@StringRes resId: Int, vararg formatArgs: Any) : this(resId, formatArgs.toList()) + } @Composable - fun asString(): String { - return when (this) { - is DynamicString -> value - is StringResource -> stringResource(resId) + fun asString(): String = when (this) { + is DynamicString -> value + is StringResource -> stringResource(resId, *args.toTypedArray()) + } + + fun resolve(context: Context): String = when (this) { + is DynamicString -> value + is StringResource -> if (args.isEmpty()) { + context.getString(resId) + } else { + context.getString(resId, *args.toTypedArray()) } } } + +/** + * Wraps a failure so the user always sees something meaningful: the cause's message when it has + * one, otherwise the supplied fallback. + */ +fun errorMessage(fallback: String, throwable: Throwable): UiText.DynamicString { + val detail = throwable.localizedMessage + return UiText.DynamicString(if (detail.isNullOrBlank()) fallback else "$fallback: $detail") +} diff --git a/app/src/main/java/dev/arrase/geotify/ui/component/EmptyState.kt b/app/src/main/java/dev/arrase/geotify/ui/component/EmptyState.kt index a12833a..62997aa 100644 --- a/app/src/main/java/dev/arrase/geotify/ui/component/EmptyState.kt +++ b/app/src/main/java/dev/arrase/geotify/ui/component/EmptyState.kt @@ -1,7 +1,5 @@ package dev.arrase.geotify.ui.component -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.tween import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -12,19 +10,16 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.scale import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +/** + * Placeholder shown when a list has no items. Callers are expected to wrap this in an + * `AnimatedVisibility`, which provides the enter/exit transition. + */ @Composable fun EmptyState( icon: ImageVector, @@ -32,25 +27,8 @@ fun EmptyState( suggestion: String, modifier: Modifier = Modifier ) { - var visible by remember { mutableStateOf(false) } - LaunchedEffect(Unit) { visible = true } - - val alpha by animateFloatAsState( - targetValue = if (visible) 1f else 0f, - animationSpec = tween(durationMillis = 600), - label = "emptyAlpha" - ) - val scale by animateFloatAsState( - targetValue = if (visible) 1f else 0.85f, - animationSpec = tween(durationMillis = 600), - label = "emptyScale" - ) - Column( - modifier = modifier - .fillMaxSize() - .scale(scale) - .alpha(alpha), + modifier = modifier.fillMaxSize(), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally ) { diff --git a/app/src/main/java/dev/arrase/geotify/ui/component/LocationMapView.kt b/app/src/main/java/dev/arrase/geotify/ui/component/LocationMapView.kt index 4900bae..0818fb6 100644 --- a/app/src/main/java/dev/arrase/geotify/ui/component/LocationMapView.kt +++ b/app/src/main/java/dev/arrase/geotify/ui/component/LocationMapView.kt @@ -14,11 +14,16 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.viewinterop.AndroidView +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner import dev.arrase.geotify.data.entity.LocationEntity import org.osmdroid.config.Configuration import org.osmdroid.events.MapEventsReceiver import org.osmdroid.tileprovider.tilesource.TileSourceFactory +import org.osmdroid.util.BoundingBox import org.osmdroid.util.GeoPoint +import org.osmdroid.views.CustomZoomButtonsController import org.osmdroid.views.MapView import org.osmdroid.views.overlay.MapEventsOverlay import org.osmdroid.views.overlay.Marker @@ -55,17 +60,16 @@ fun LocationMapView( if (locations.size == 1) { val loc = locations.first() map.controller.setCenter(GeoPoint(loc.latitude, loc.longitude)) - map.controller.setZoom(15.0) + map.controller.setZoom(DEFAULT_ZOOM) } else { val points = locations.map { GeoPoint(it.latitude, it.longitude) } map.post { try { - val box = org.osmdroid.util.BoundingBox.fromGeoPoints(points) - map.zoomToBoundingBox(box, true, 120) + map.zoomToBoundingBox(BoundingBox.fromGeoPoints(points), true, 120) } catch (e: Exception) { val loc = locations.first() map.controller.setCenter(GeoPoint(loc.latitude, loc.longitude)) - map.controller.setZoom(15.0) + map.controller.setZoom(DEFAULT_ZOOM) } } } @@ -81,26 +85,24 @@ fun LocationMapView( } } - // Lifecycle management: onDetach() must only be called on real Activity destruction, - // NOT when AnimatedVisibility hides this composable, because onDetach() permanently - // destroys the osmdroid tile cache writer making future MapView instances unable to load tiles. - val lifecycle = androidx.lifecycle.compose.LocalLifecycleOwner.current.lifecycle + // Lifecycle management. `onDetach()` shuts down this MapView's own tile-download executor, so it + // must be called whenever the view leaves composition (e.g. the user toggles back to the list) — + // otherwise each toggle leaks a thread pool. It is safe to call repeatedly: osmdroid's tile + // writer shares a static database, so detaching does not break later MapView instances. + val lifecycle = LocalLifecycleOwner.current.lifecycle DisposableEffect(mapViewRef, lifecycle) { val map = mapViewRef ?: return@DisposableEffect onDispose {} - val observer = androidx.lifecycle.LifecycleEventObserver { _, event -> + val observer = LifecycleEventObserver { _, event -> when (event) { - androidx.lifecycle.Lifecycle.Event.ON_RESUME -> map.onResume() - androidx.lifecycle.Lifecycle.Event.ON_PAUSE -> map.onPause() - androidx.lifecycle.Lifecycle.Event.ON_DESTROY -> map.onDetach() - else -> {} + Lifecycle.Event.ON_RESUME -> map.onResume() + Lifecycle.Event.ON_PAUSE -> map.onPause() + else -> Unit } } lifecycle.addObserver(observer) onDispose { lifecycle.removeObserver(observer) - // Only pause when leaving composition (e.g. AnimatedVisibility toggling). - // Do NOT call onDetach() here — it destroys the shared tile cache writer. - map.onPause() + map.onDetach() } } @@ -133,20 +135,22 @@ fun LocationMapView( ) } + val tileFilter = remember { darkTileFilter() } + AndroidView( factory = { ctx -> MapView(ctx).apply { setTileSource(TileSourceFactory.MAPNIK) setMultiTouchControls(true) - zoomController.setVisibility(org.osmdroid.views.CustomZoomButtonsController.Visibility.NEVER) - controller.setZoom(15.0) + zoomController.setVisibility(CustomZoomButtonsController.Visibility.NEVER) + controller.setZoom(DEFAULT_ZOOM) onResume() mapViewRef = this } }, modifier = modifier.fillMaxSize(), update = { map -> - applyTileThemeFilter(map, isDarkTheme) + map.applyTileThemeFilter(isDarkTheme, tileFilter) updateLocationOverlays( map = map, locations = locations, @@ -167,6 +171,9 @@ private data class LocationOverlayStyle( val defaultStrokeColor: Int ) +/** Zoom level used when the map cannot fit all points and falls back to a single location. */ +private const val DEFAULT_ZOOM = 15.0 + private fun updateLocationOverlays( map: MapView, diff --git a/app/src/main/java/dev/arrase/geotify/ui/component/LocationRow.kt b/app/src/main/java/dev/arrase/geotify/ui/component/LocationRow.kt index 1d4f8c9..6d6529a 100644 --- a/app/src/main/java/dev/arrase/geotify/ui/component/LocationRow.kt +++ b/app/src/main/java/dev/arrase/geotify/ui/component/LocationRow.kt @@ -9,10 +9,12 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription import dev.arrase.geotify.R import dev.arrase.geotify.data.entity.LocationEntity -import java.util.Locale @Composable fun LocationRow( @@ -29,9 +31,8 @@ fun LocationRow( }, supportingContent = { Text( - text = String.format( - Locale.US, - "%.5f, %.5f", + text = stringResource( + R.string.coordinates_format, location.latitude, location.longitude ), @@ -40,17 +41,24 @@ fun LocationRow( ) }, leadingContent = { + // Decorative: the alias is already the first thing a screen reader announces. Icon( imageVector = Icons.Filled.LocationOn, - contentDescription = stringResource(R.string.content_description_location), + contentDescription = null, tint = MaterialTheme.colorScheme.primary ) }, trailingContent = { if (activeReminderCount > 0) { + val description = pluralStringResource( + R.plurals.active_reminder_count, + activeReminderCount, + activeReminderCount + ) Badge( containerColor = MaterialTheme.colorScheme.primaryContainer, - contentColor = MaterialTheme.colorScheme.onPrimaryContainer + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.clearAndSetSemantics { contentDescription = description } ) { Text(activeReminderCount.toString()) } diff --git a/app/src/main/java/dev/arrase/geotify/ui/component/MapPicker.kt b/app/src/main/java/dev/arrase/geotify/ui/component/MapPicker.kt index 42423c5..8360433 100644 --- a/app/src/main/java/dev/arrase/geotify/ui/component/MapPicker.kt +++ b/app/src/main/java/dev/arrase/geotify/ui/component/MapPicker.kt @@ -53,6 +53,7 @@ import org.osmdroid.config.Configuration import org.osmdroid.events.MapEventsReceiver import org.osmdroid.tileprovider.tilesource.TileSourceFactory import org.osmdroid.util.GeoPoint +import org.osmdroid.views.CustomZoomButtonsController import org.osmdroid.views.MapView import org.osmdroid.views.overlay.MapEventsOverlay import org.osmdroid.views.overlay.Marker @@ -139,19 +140,21 @@ fun MapPicker( color = MaterialTheme.colorScheme.background ) { Box(modifier = modifier.fillMaxSize()) { + val tileFilter = remember { darkTileFilter() } + AndroidView( factory = { ctx -> MapView(ctx).apply { setTileSource(TileSourceFactory.MAPNIK) setMultiTouchControls(true) - zoomController.setVisibility(org.osmdroid.views.CustomZoomButtonsController.Visibility.NEVER) - controller.setZoom(16.0) + zoomController.setVisibility(CustomZoomButtonsController.Visibility.NEVER) + controller.setZoom(DEFAULT_ZOOM) mapViewRef = this } }, modifier = Modifier.fillMaxSize(), update = { map -> - applyTileThemeFilter(map, isDarkTheme) + map.applyTileThemeFilter(isDarkTheme, tileFilter) configureOverlays( map = map, selectedPoint = selectedPoint, @@ -358,3 +361,5 @@ private fun configureOverlays( map.invalidate() } +/** Zoom level the map opens at; the user pin is the subject of this dialog. */ +private const val DEFAULT_ZOOM = 16.0 diff --git a/app/src/main/java/dev/arrase/geotify/ui/component/MapStyles.kt b/app/src/main/java/dev/arrase/geotify/ui/component/MapStyles.kt index 99f2627..d7c4256 100644 --- a/app/src/main/java/dev/arrase/geotify/ui/component/MapStyles.kt +++ b/app/src/main/java/dev/arrase/geotify/ui/component/MapStyles.kt @@ -6,6 +6,7 @@ import android.graphics.Canvas import android.graphics.ColorMatrix import android.graphics.ColorMatrixColorFilter import android.graphics.drawable.Drawable +import android.util.LruCache import androidx.core.content.ContextCompat import androidx.core.graphics.createBitmap import androidx.core.graphics.drawable.DrawableCompat @@ -13,34 +14,51 @@ import androidx.core.graphics.drawable.toDrawable import dev.arrase.geotify.R import org.osmdroid.views.MapView -fun applyTileThemeFilter(map: MapView, isDarkTheme: Boolean) { - if (isDarkTheme) { - val filter = ColorMatrixColorFilter( - ColorMatrix( - floatArrayOf( - -0.1491f, -0.5005f, -0.0504f, 0f, 215f, - -0.1491f, -0.5005f, -0.0504f, 0f, 215f, - -0.1491f, -0.5005f, -0.0504f, 0f, 230f, - 0f, 0f, 0f, 1f, 0f - ) - ) +/** + * Colour filter that inverts and darkens the map tiles so they blend with the dark UI theme. + * Build it once inside a `remember`; [MapView.applyTileThemeFilter] only applies it for the dark + * theme and clears it otherwise. + */ +fun darkTileFilter(): ColorMatrixColorFilter = ColorMatrixColorFilter( + ColorMatrix( + floatArrayOf( + -0.1491f, -0.5005f, -0.0504f, 0f, 215f, + -0.1491f, -0.5005f, -0.0504f, 0f, 215f, + -0.1491f, -0.5005f, -0.0504f, 0f, 230f, + 0f, 0f, 0f, 1f, 0f ) - map.overlayManager.tilesOverlay.setColorFilter(filter) - } else { - map.overlayManager.tilesOverlay.setColorFilter(null) - } + ) +) + +/** + * Applies the dark-tile filter, or clears it for the light theme. Build the filter once with + * [darkTileFilter] inside a `remember` and pass it in, so it is not reallocated on every + * recomposition. + */ +fun MapView.applyTileThemeFilter(isDarkTheme: Boolean, filter: ColorMatrixColorFilter?) { + overlayManager.tilesOverlay.setColorFilter(if (isDarkTheme) filter else null) } +/** + * Marker icon tinted with [color]. Building one allocates a bitmap, so results are cached by + * colour, size and screen density. + */ +private val markerIconCache = LruCache(32) + fun getTintedMarkerIcon(context: Context, color: Int, sizeDp: Int = 38): Drawable { + val density = context.resources.displayMetrics.density + val cacheKey = "$color@${sizeDp}dp@${density}x" + markerIconCache.get(cacheKey)?.let { return it } + val drawable = ContextCompat.getDrawable(context, R.drawable.ic_location) ?: return color.toDrawable() - val density = context.resources.displayMetrics.density + val size = (sizeDp * density).toInt() val bitmap = createBitmap(size, size, Bitmap.Config.ARGB_8888) - val canvas = Canvas(bitmap) - drawable.setBounds(0, 0, size, size) val mutated = drawable.mutate() DrawableCompat.setTint(mutated, color) - mutated.draw(canvas) - return bitmap.toDrawable(context.resources) + mutated.setBounds(0, 0, size, size) + mutated.draw(Canvas(bitmap)) + + return bitmap.toDrawable(context.resources).also { markerIconCache.put(cacheKey, it) } } diff --git a/app/src/main/java/dev/arrase/geotify/ui/component/ReminderMapView.kt b/app/src/main/java/dev/arrase/geotify/ui/component/ReminderMapView.kt index d0d81c4..f428b6d 100644 --- a/app/src/main/java/dev/arrase/geotify/ui/component/ReminderMapView.kt +++ b/app/src/main/java/dev/arrase/geotify/ui/component/ReminderMapView.kt @@ -4,6 +4,7 @@ import android.graphics.DashPathEffect import android.graphics.drawable.Drawable import android.location.Location import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -12,10 +13,10 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.viewinterop.AndroidView -import androidx.core.graphics.toColorInt import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner @@ -100,7 +101,9 @@ fun ReminderMapView( } } - // Lifecycle observer + // Lifecycle management. `onDetach()` shuts down this MapView's own tile-download executor, so it + // must be called whenever the view leaves composition (e.g. the user toggles back to the list) — + // otherwise each toggle leaks a thread pool. val lifecycle = LocalLifecycleOwner.current.lifecycle DisposableEffect(mapViewRef, lifecycle) { val map = mapViewRef ?: return@DisposableEffect onDispose {} @@ -108,41 +111,47 @@ fun ReminderMapView( when (event) { Lifecycle.Event.ON_RESUME -> map.onResume() Lifecycle.Event.ON_PAUSE -> map.onPause() - Lifecycle.Event.ON_DESTROY -> map.onDetach() - else -> {} + else -> Unit } } lifecycle.addObserver(observer) onDispose { lifecycle.removeObserver(observer) - map.onPause() + map.onDetach() } } - // Custom pins and geofence colors - val activeColor = "#FF1744".toColorInt() - val inactiveColor = "#3F51B5".toColorInt() + // Custom pins and geofence colors. The active/inactive hues are semantic (they encode reminder + // state, not the app theme), while map chrome follows the Material theme so it stays legible on + // the darkened tiles used in dark mode. + val activeColor = MaterialTheme.colorScheme.error.toArgb() + val inactiveColor = MaterialTheme.colorScheme.tertiary.toArgb() + val slidingWindowColor = MaterialTheme.colorScheme.onSurface.toArgb() + val userLocationColor = MaterialTheme.colorScheme.primary.toArgb() - val activeMarkerIcon = remember(context) { + val activeMarkerIcon = remember(context, activeColor) { getTintedMarkerIcon(context, activeColor, sizeDp = 38) } - val inactiveMarkerIcon = remember(context) { + val inactiveMarkerIcon = remember(context, inactiveColor) { getTintedMarkerIcon(context, inactiveColor, sizeDp = 38) } - val userMarkerIcon = remember(context) { - getTintedMarkerIcon(context, "#2196F3".toColorInt(), sizeDp = 24) + val userMarkerIcon = remember(context, userLocationColor) { + getTintedMarkerIcon(context, userLocationColor, sizeDp = 24) } - val centerMarkerIcon = remember(context) { - getTintedMarkerIcon(context, android.graphics.Color.BLACK, sizeDp = 20) + val centerMarkerIcon = remember(context, slidingWindowColor) { + getTintedMarkerIcon(context, slidingWindowColor, sizeDp = 20) } - val activeFillColor = android.graphics.Color.argb(55, 255, 23, 68) - val inactiveFillColor = android.graphics.Color.argb(35, 63, 81, 181) + val activeFillColor = activeColor.withAlpha(FILL_ALPHA) + val inactiveFillColor = inactiveColor.withAlpha(FILL_ALPHA) val labelSlidingWindowCenter = stringResource(R.string.label_sliding_window_center) val labelMyLocation = stringResource(R.string.label_my_location) - val markerStyle = remember(activeMarkerIcon, inactiveMarkerIcon) { + val markerStyle = remember( + activeMarkerIcon, inactiveMarkerIcon, activeColor, inactiveColor, + activeFillColor, inactiveFillColor + ) { ReminderMarkerStyle( activeIcon = activeMarkerIcon, inactiveIcon = inactiveMarkerIcon, @@ -153,6 +162,8 @@ fun ReminderMapView( ) } + val tileFilter = remember { darkTileFilter() } + AndroidView( factory = { ctx -> MapView(ctx).apply { @@ -166,7 +177,7 @@ fun ReminderMapView( }, modifier = modifier.fillMaxSize(), update = { map -> - applyTileThemeFilter(map, isDarkTheme) + map.applyTileThemeFilter(isDarkTheme, tileFilter) map.overlays.clear() // Deselect single tap listener @@ -180,7 +191,13 @@ fun ReminderMapView( }) map.overlays.add(mapEventsOverlay) - drawSpatialCircles(map, data.spatialArea, centerMarkerIcon, labelSlidingWindowCenter) + drawSpatialCircles( + map = map, + spatialArea = data.spatialArea, + centerMarkerIcon = centerMarkerIcon, + centerLabel = labelSlidingWindowCenter, + slidingWindowColor = slidingWindowColor + ) drawUserLocationMarker(map, data.currentUserLocation, userMarkerIcon, labelMyLocation) drawReminderMarkers( map = map, @@ -243,7 +260,8 @@ private fun drawSpatialCircles( map: MapView, spatialArea: SpatialRecalculationArea, centerMarkerIcon: Drawable, - centerLabel: String + centerLabel: String, + slidingWindowColor: Int ) { val lat = spatialArea.latitude val lng = spatialArea.longitude @@ -253,8 +271,8 @@ private fun drawSpatialCircles( // 1a. Outer radius — spatial search area (dashed, subtle) val outerCircle = Polygon().apply { points = Polygon.pointsAsCircle(centerPoint, spatialArea.outerRadiusMeters.toDouble()) - fillPaint.color = android.graphics.Color.argb(25, 0, 0, 0) - outlinePaint.color = android.graphics.Color.argb(180, 60, 60, 60) + fillPaint.color = slidingWindowColor.withAlpha(25) + outlinePaint.color = slidingWindowColor.withAlpha(180) outlinePaint.strokeWidth = 3f outlinePaint.pathEffect = DashPathEffect(floatArrayOf(20f, 15f), 0f) } @@ -263,8 +281,8 @@ private fun drawSpatialCircles( // 1b. Inner radius — master geofence boundary (solid, prominent) val innerCircle = Polygon().apply { points = Polygon.pointsAsCircle(centerPoint, spatialArea.innerRadiusMeters.toDouble()) - fillPaint.color = android.graphics.Color.argb(18, 0, 0, 0) - outlinePaint.color = android.graphics.Color.BLACK + fillPaint.color = slidingWindowColor.withAlpha(18) + outlinePaint.color = slidingWindowColor outlinePaint.strokeWidth = 5f } map.overlays.add(innerCircle) @@ -371,3 +389,10 @@ private fun drawReminderMarkers( ) } } + +/** Opacity applied to the semi-transparent fill inside a geofence circle. */ +private const val FILL_ALPHA = 55 + +/** Returns this opaque ARGB color with only its alpha replaced. */ +private fun Int.withAlpha(alpha: Int): Int = + (this and 0x00FFFFFF) or (alpha.coerceIn(0, 255) shl 24) diff --git a/app/src/main/java/dev/arrase/geotify/ui/component/ReminderRow.kt b/app/src/main/java/dev/arrase/geotify/ui/component/ReminderRow.kt index 5d98380..dee75b6 100644 --- a/app/src/main/java/dev/arrase/geotify/ui/component/ReminderRow.kt +++ b/app/src/main/java/dev/arrase/geotify/ui/component/ReminderRow.kt @@ -3,15 +3,11 @@ package dev.arrase.geotify.ui.component import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Notifications -import androidx.compose.material3.AssistChipDefaults import androidx.compose.material3.Icon import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.SuggestionChip -import androidx.compose.material3.SuggestionChipDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -19,7 +15,6 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import dev.arrase.geotify.R import dev.arrase.geotify.data.entity.ReminderEntity -import dev.arrase.geotify.data.entity.isArrival @Composable fun ReminderRow( @@ -44,28 +39,10 @@ fun ReminderRow( ) } }, - supportingContent = if (reminder.isActive && reminder.isInRange) { - { - SuggestionChip( - onClick = {}, - label = { - Text( - text = stringResource(R.string.label_in_range), - style = MaterialTheme.typography.labelSmall - ) - }, - colors = SuggestionChipDefaults.suggestionChipColors( - containerColor = MaterialTheme.colorScheme.tertiaryContainer, - labelColor = MaterialTheme.colorScheme.onTertiaryContainer - ), - modifier = Modifier.height(24.dp) - ) - } - } else null, leadingContent = { Icon( imageVector = Icons.Filled.Notifications, - contentDescription = stringResource(R.string.content_description_reminder), + contentDescription = null, tint = if (reminder.isActive) { MaterialTheme.colorScheme.primary } else { @@ -73,29 +50,7 @@ fun ReminderRow( } ) }, - trailingContent = { - SuggestionChip( - onClick = {}, - label = { - Text( - text = if (reminder.isArrival) { - stringResource(R.string.label_transition_arrival) - } else { - stringResource(R.string.label_transition_departure) - }, - style = MaterialTheme.typography.labelSmall - ) - }, - icon = { - Icon( - imageVector = Icons.Filled.Notifications, - contentDescription = null, - modifier = Modifier.size(AssistChipDefaults.IconSize) - ) - }, - modifier = Modifier.height(24.dp) - ) - }, + trailingContent = { ReminderStatusChips(reminder) }, modifier = modifier ) } diff --git a/app/src/main/java/dev/arrase/geotify/ui/component/ReminderStatusChips.kt b/app/src/main/java/dev/arrase/geotify/ui/component/ReminderStatusChips.kt new file mode 100644 index 0000000..9438f86 --- /dev/null +++ b/app/src/main/java/dev/arrase/geotify/ui/component/ReminderStatusChips.kt @@ -0,0 +1,75 @@ +package dev.arrase.geotify.ui.component + +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Notifications +import androidx.compose.material3.AssistChipDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SuggestionChip +import androidx.compose.material3.SuggestionChipDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import dev.arrase.geotify.R +import dev.arrase.geotify.data.entity.ReminderEntity +import dev.arrase.geotify.data.entity.isArrival +import dev.arrase.geotify.data.entity.isDeparture + +/** + * Read-only chips describing a reminder's transition and, when it is being monitored, that it is + * within the current recalculation area. These are status indicators, not controls, so they expose + * no click behaviour. + */ +@Composable +fun ReminderStatusChips( + reminder: ReminderEntity, + modifier: Modifier = Modifier +) { + val transitionLabel = when { + reminder.isArrival -> stringResource(R.string.label_transition_arrival) + reminder.isDeparture -> stringResource(R.string.label_transition_departure) + else -> stringResource(R.string.label_transition_unknown) + } + + StatusChip(label = transitionLabel, modifier = modifier) + + if (reminder.isActive && reminder.isInRange) { + StatusChip( + label = stringResource(R.string.label_in_range), + containerColor = MaterialTheme.colorScheme.tertiaryContainer, + labelColor = MaterialTheme.colorScheme.onTertiaryContainer + ) + } +} + +@Composable +private fun StatusChip( + label: String, + modifier: Modifier = Modifier, + containerColor: Color = MaterialTheme.colorScheme.secondaryContainer, + labelColor: Color = MaterialTheme.colorScheme.onSecondaryContainer +) { + SuggestionChip( + onClick = {}, + label = { + Text(text = label, style = MaterialTheme.typography.labelSmall) + }, + colors = SuggestionChipDefaults.suggestionChipColors( + containerColor = containerColor, + labelColor = labelColor + ), + icon = { + Icon( + imageVector = Icons.Filled.Notifications, + contentDescription = null, + modifier = Modifier.size(AssistChipDefaults.IconSize) + ) + }, + modifier = modifier.height(24.dp) + ) +} diff --git a/app/src/main/java/dev/arrase/geotify/ui/component/SwipeToDeleteBackground.kt b/app/src/main/java/dev/arrase/geotify/ui/component/SwipeToDeleteBackground.kt index 829f087..32f9a13 100644 --- a/app/src/main/java/dev/arrase/geotify/ui/component/SwipeToDeleteBackground.kt +++ b/app/src/main/java/dev/arrase/geotify/ui/component/SwipeToDeleteBackground.kt @@ -13,7 +13,6 @@ import androidx.compose.material3.SwipeToDismissBox import androidx.compose.material3.SwipeToDismissBoxValue import androidx.compose.material3.rememberSwipeToDismissBoxState import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -40,6 +39,7 @@ fun SwipeToDeleteBackground( } } +/** Swipe-to-delete wrapper that only allows deleting by swiping from the trailing edge. */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun SwipeToDeleteContainer( @@ -50,17 +50,15 @@ fun SwipeToDeleteContainer( ) { val dismissState = rememberSwipeToDismissBoxState() - LaunchedEffect(dismissState.currentValue) { - if (dismissState.currentValue == SwipeToDismissBoxValue.EndToStart) { - onDelete() - } - } - SwipeToDismissBox( state = dismissState, backgroundContent = { SwipeToDeleteBackground(contentDescription = contentDescription) }, + // Fires once when the row settles past the threshold, and only for a real dismissal. + onDismiss = { value -> + if (value == SwipeToDismissBoxValue.EndToStart) onDelete() + }, enableDismissFromStartToEnd = false, modifier = modifier, content = { content() } diff --git a/app/src/main/java/dev/arrase/geotify/ui/navigation/GeotifyNavigation.kt b/app/src/main/java/dev/arrase/geotify/ui/navigation/GeotifyNavigation.kt index 0c94445..69f6aa0 100644 --- a/app/src/main/java/dev/arrase/geotify/ui/navigation/GeotifyNavigation.kt +++ b/app/src/main/java/dev/arrase/geotify/ui/navigation/GeotifyNavigation.kt @@ -1,5 +1,6 @@ package dev.arrase.geotify.ui.navigation +import androidx.activity.compose.BackHandler import androidx.annotation.StringRes import androidx.compose.animation.AnimatedContent import androidx.compose.animation.fadeIn @@ -51,6 +52,11 @@ fun GeotifyNavHost( var selectedTab by rememberSaveable { mutableStateOf(initialTab) } val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() + // Back from a secondary tab returns to the default one instead of leaving the app. + BackHandler(enabled = selectedTab != GeotifyTab.Reminders) { + selectedTab = GeotifyTab.Reminders + } + Scaffold( modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { @@ -85,9 +91,10 @@ fun GeotifyNavHost( transitionSpec = { fadeIn() togetherWith fadeOut() }, label = "tabContent" ) { tab -> + // Exhaustive `when` expression: adding a tab without rendering it fails to compile. when (tab) { - GeotifyTab.Locations -> LocationsScreen(viewModel = hiltViewModel()) GeotifyTab.Reminders -> RemindersScreen(viewModel = hiltViewModel()) + GeotifyTab.Locations -> LocationsScreen(viewModel = hiltViewModel()) GeotifyTab.Settings -> SettingsScreen(viewModel = hiltViewModel()) } } diff --git a/app/src/main/java/dev/arrase/geotify/ui/screen/LocationsScreen.kt b/app/src/main/java/dev/arrase/geotify/ui/screen/LocationsScreen.kt index bc5bdad..8bf3c32 100644 --- a/app/src/main/java/dev/arrase/geotify/ui/screen/LocationsScreen.kt +++ b/app/src/main/java/dev/arrase/geotify/ui/screen/LocationsScreen.kt @@ -10,7 +10,6 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -72,9 +71,8 @@ import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import dev.arrase.geotify.R -import dev.arrase.geotify.data.ThemeSetting import dev.arrase.geotify.data.entity.LocationEntity -import dev.arrase.geotify.ui.UiText +import dev.arrase.geotify.ui.theme.resolve import dev.arrase.geotify.ui.component.BackgroundLocationWarningBanner import dev.arrase.geotify.ui.component.DialogDismissButtons import dev.arrase.geotify.ui.component.EmptyState @@ -86,12 +84,6 @@ import dev.arrase.geotify.ui.component.ViewModeSwitcher import kotlinx.coroutines.launch import java.util.Locale -private fun isMapDark(themeSetting: ThemeSetting, isSystemDark: Boolean): Boolean = when (themeSetting) { - ThemeSetting.SYSTEM -> isSystemDark - ThemeSetting.LIGHT -> false - ThemeSetting.DARK -> true -} - private fun isValidCoordinate(coord: Double?, min: Double, max: Double): Boolean = coord != null && coord in min..max @@ -133,42 +125,42 @@ fun LocationsScreen( val context = LocalContext.current val mapThemeSetting by viewModel.mapTheme.collectAsStateWithLifecycle() val isSystemDark = androidx.compose.foundation.isSystemInDarkTheme() - val isMapDarkTheme = isMapDark(mapThemeSetting, isSystemDark) + val isMapDarkTheme = mapThemeSetting.resolve(isSystemDark) val locations by viewModel.locations.collectAsStateWithLifecycle() val activeReminderCounts by viewModel.activeReminderCounts.collectAsStateWithLifecycle() val snackbarHostState = remember { SnackbarHostState() } val scope = rememberCoroutineScope() LaunchedEffect(viewModel) { - viewModel.snackbarMessage.collect { uiText -> - val msg = when (uiText) { - is UiText.DynamicString -> uiText.value - is UiText.StringResource -> context.applicationContext.getString(uiText.resId) - } + viewModel.messagesFlow.collect { message -> snackbarHostState.showSnackbar( - message = msg, + message = message.resolve(context), duration = SnackbarDuration.Short ) } } - var showDialog by remember { mutableStateOf(false) } + var showDialog by rememberSaveable { mutableStateOf(false) } val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - var editingLocation by remember { mutableStateOf(null) } - var alias by remember { mutableStateOf("") } - var latitudeString by remember { mutableStateOf("") } - var longitudeString by remember { mutableStateOf("") } - var radiusMeters by remember { mutableFloatStateOf(150f) } - var responsivenessMinutes by remember { mutableFloatStateOf(0f) } - var showResponsivenessInfo by remember { mutableStateOf(false) } + var editingLocationId by rememberSaveable { mutableStateOf(null) } + var alias by rememberSaveable { mutableStateOf("") } + var latitudeString by rememberSaveable { mutableStateOf("") } + var longitudeString by rememberSaveable { mutableStateOf("") } + var radiusMeters by rememberSaveable { mutableFloatStateOf(150f) } + var responsivenessMinutes by rememberSaveable { mutableFloatStateOf(0f) } + var showResponsivenessInfo by rememberSaveable { mutableStateOf(false) } var isGpsLoading by remember { mutableStateOf(false) } var isMapView by rememberSaveable { mutableStateOf(false) } var selectedLocationOnMap by remember { mutableStateOf(null) } - var showMapPicker by remember { mutableStateOf(false) } + var showMapPicker by rememberSaveable { mutableStateOf(false) } + + val editingLocation = remember(editingLocationId, locations) { + locations.firstOrNull { it.id == editingLocationId } + } val aliasExists = remember(alias, editingLocation, locations) { - locations.any { it.alias.equals(alias, ignoreCase = true) && it.id != editingLocation?.id } + locations.any { it.alias.equals(alias.trim(), ignoreCase = true) && it.id != editingLocation?.id } } val lat = latitudeString.toDoubleOrNull() @@ -177,7 +169,7 @@ fun LocationsScreen( val isLongitudeValid = isValidCoordinate(lng, -180.0, 180.0) fun openFormForNew() { - editingLocation = null + editingLocationId = null alias = "" latitudeString = "" longitudeString = "" @@ -187,7 +179,7 @@ fun LocationsScreen( } fun openFormForEditing(location: LocationEntity) { - editingLocation = location + editingLocationId = location.id alias = location.alias latitudeString = location.latitude.toString() longitudeString = location.longitude.toString() @@ -240,24 +232,18 @@ fun LocationsScreen( onSave = { saveLocation(viewModel, editingLocation, alias, lat, lng, radiusMeters, responsivenessMinutes) showDialog = false - editingLocation = null + editingLocationId = null }, - onDelete = if (editingLocation != null) { + onDelete = editingLocation?.let { location -> { - viewModel.deleteLocation(editingLocation!!.alias) + viewModel.deleteLocation(location) showDialog = false - editingLocation = null - scope.launch { - snackbarHostState.showSnackbar( - message = context.applicationContext.getString(R.string.toast_location_deleted, alias), - duration = SnackbarDuration.Short - ) - } + editingLocationId = null } - } else null, + }, onCancel = { showDialog = false - editingLocation = null + editingLocationId = null }, onResponsivenessInfoChange = { showResponsivenessInfo = it } ) @@ -275,16 +261,10 @@ fun LocationsScreen( onMapSelected = { isMapView = true }, onLocationSelected = { selectedLocationOnMap = it }, onDeleteLocation = { location -> - viewModel.deleteLocation(location.alias) + viewModel.deleteLocation(location) if (selectedLocationOnMap?.id == location.id) { selectedLocationOnMap = null } - scope.launch { - snackbarHostState.showSnackbar( - message = context.applicationContext.getString(R.string.toast_location_deleted, location.alias), - duration = SnackbarDuration.Short - ) - } }, onEditLocation = { location -> openFormForEditing(location) }, onDismissSelectedLocation = { selectedLocationOnMap = null }, @@ -337,7 +317,7 @@ fun LocationsScreen( formActions = formActions, onDismissRequest = { showDialog = false - editingLocation = null + editingLocationId = null } ) } @@ -948,8 +928,13 @@ private fun SelectedLocationCard( color = MaterialTheme.colorScheme.onSurfaceVariant ) Text( - text = stringResource(R.string.label_latitude) + String.format(Locale.US, ": %.5f, ", location.latitude) + - stringResource(R.string.label_longitude) + String.format(Locale.US, ": %.5f", location.longitude), + text = stringResource( + R.string.label_coordinates_pair, + stringResource(R.string.label_latitude), + location.latitude, + stringResource(R.string.label_longitude), + location.longitude + ), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) diff --git a/app/src/main/java/dev/arrase/geotify/ui/screen/LocationsViewModel.kt b/app/src/main/java/dev/arrase/geotify/ui/screen/LocationsViewModel.kt index e01e8d0..aa90a47 100644 --- a/app/src/main/java/dev/arrase/geotify/ui/screen/LocationsViewModel.kt +++ b/app/src/main/java/dev/arrase/geotify/ui/screen/LocationsViewModel.kt @@ -1,8 +1,8 @@ package dev.arrase.geotify.ui.screen -import android.location.Location -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import dev.arrase.geotify.R import dev.arrase.geotify.data.LocationRepository import dev.arrase.geotify.data.ReminderRepository import dev.arrase.geotify.data.SettingsDefaults @@ -11,15 +11,10 @@ import dev.arrase.geotify.data.ThemeSetting import dev.arrase.geotify.data.entity.LocationEntity import dev.arrase.geotify.geofence.GeofenceOrchestrator import dev.arrase.geotify.location.LocationProvider +import dev.arrase.geotify.ui.BaseViewModel import dev.arrase.geotify.ui.UiText -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject @@ -29,54 +24,69 @@ class LocationsViewModel @Inject constructor( private val reminderRepository: ReminderRepository, private val geofenceOrchestrator: GeofenceOrchestrator, private val locationProvider: LocationProvider, - private val settingsManager: SettingsManager -) : ViewModel() { - - private val _snackbarMessage = MutableSharedFlow() - val snackbarMessage: SharedFlow = _snackbarMessage.asSharedFlow() + settingsManager: SettingsManager +) : BaseViewModel() { val locations: StateFlow> = locationRepository.observeLocations() - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + .settingFlow(emptyList()) - val activeReminderCounts: StateFlow> = reminderRepository.observeActiveReminderCounts() - .map { list -> list.associate { it.locationId to it.count } } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyMap()) + val activeReminderCounts: StateFlow> = + reminderRepository.observeActiveReminderCounts() + .map { counts -> counts.associate { it.locationId to it.count } } + .settingFlow(emptyMap()) - val mapTheme: StateFlow = settingsManager.mapTheme - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SettingsDefaults.MAP_THEME) + val mapTheme: StateFlow = + settingsManager.mapTheme.settingFlow(SettingsDefaults.MAP_THEME) - fun saveLocation(alias: String, latitude: Double, longitude: Double, radiusMeters: Float, notificationResponsivenessMs: Int) { - viewModelScope.launch { - try { - locationRepository.saveLocation(alias, latitude, longitude, radiusMeters, notificationResponsivenessMs) - geofenceOrchestrator.triggerRecalculation() - } catch (e: Exception) { - _snackbarMessage.emit(UiText.DynamicString(e.localizedMessage ?: "Unknown error saving location")) - } + fun saveLocation( + alias: String, + latitude: Double, + longitude: Double, + radiusMeters: Float, + notificationResponsivenessMs: Int + ) = viewModelScope.launch { + mutate(ERROR_SAVE, onSuccess = geofenceOrchestrator::triggerRecalculation) { + locationRepository.saveLocation( + alias = alias, + latitude = latitude, + longitude = longitude, + radiusMeters = radiusMeters, + notificationResponsivenessMs = notificationResponsivenessMs + ) } } - fun updateLocation(location: LocationEntity) { - viewModelScope.launch { - try { - locationRepository.updateLocation(location) - geofenceOrchestrator.triggerRecalculation() - } catch (e: Exception) { - _snackbarMessage.emit(UiText.DynamicString(e.localizedMessage ?: "Unknown error updating location")) - } + fun updateLocation(location: LocationEntity) = viewModelScope.launch { + mutate(ERROR_UPDATE, onSuccess = geofenceOrchestrator::triggerRecalculation) { + locationRepository.updateLocation(location) } } - fun deleteLocation(alias: String) { - viewModelScope.launch { - try { - locationRepository.deleteLocation(alias) + fun deleteLocation(location: LocationEntity) = viewModelScope.launch { + mutate( + errorFallback = ERROR_DELETE, + onSuccess = { geofenceOrchestrator.triggerRecalculation() - } catch (e: Exception) { - _snackbarMessage.emit(UiText.DynamicString(e.localizedMessage ?: "Unknown error deleting location")) + messages.send( + UiText.StringResource(R.string.location_deleted, location.alias) + ) + } + ) { + if (!locationRepository.deleteLocation(location.alias)) { + throw StaleLocationException(location.id) } } } - suspend fun getCurrentLocation(): Location? = locationProvider.getCurrentLocation() + suspend fun getCurrentLocation() = locationProvider.getCurrentLocation() + + private companion object { + const val ERROR_SAVE = "Could not save the location" + const val ERROR_UPDATE = "Could not update the location" + const val ERROR_DELETE = "Could not delete the location" + } } + +/** Raised when a delete affected no rows, i.e. the location was already gone. */ +private class StaleLocationException(id: String) : + IllegalStateException("Location $id no longer exists") diff --git a/app/src/main/java/dev/arrase/geotify/ui/screen/RemindersScreen.kt b/app/src/main/java/dev/arrase/geotify/ui/screen/RemindersScreen.kt index 438789c..3dbdaf3 100644 --- a/app/src/main/java/dev/arrase/geotify/ui/screen/RemindersScreen.kt +++ b/app/src/main/java/dev/arrase/geotify/ui/screen/RemindersScreen.kt @@ -61,7 +61,6 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -74,27 +73,20 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.google.android.gms.location.Geofence import dev.arrase.geotify.R -import dev.arrase.geotify.data.ThemeSetting import dev.arrase.geotify.data.entity.LocationEntity import dev.arrase.geotify.data.entity.ReminderEntity import dev.arrase.geotify.data.entity.isArrival -import dev.arrase.geotify.ui.UiText +import dev.arrase.geotify.ui.theme.resolve import dev.arrase.geotify.ui.component.BackgroundLocationWarningBanner import dev.arrase.geotify.ui.component.DialogDismissButtons import dev.arrase.geotify.ui.component.EmptyState import dev.arrase.geotify.ui.component.ReminderMapView import dev.arrase.geotify.ui.component.ReminderMapViewData import dev.arrase.geotify.ui.component.ReminderRow +import dev.arrase.geotify.ui.component.ReminderStatusChips import dev.arrase.geotify.ui.component.SpatialRecalculationArea import dev.arrase.geotify.ui.component.SwipeToDeleteContainer import dev.arrase.geotify.ui.component.ViewModeSwitcher -import kotlinx.coroutines.launch - -private fun isMapDark(themeSetting: ThemeSetting, isSystemDark: Boolean): Boolean = when (themeSetting) { - ThemeSetting.SYSTEM -> isSystemDark - ThemeSetting.LIGHT -> false - ThemeSetting.DARK -> true -} private fun saveReminder( viewModel: RemindersViewModel, @@ -126,24 +118,18 @@ fun RemindersScreen( val reminders by viewModel.reminders.collectAsStateWithLifecycle() val locations by viewModel.locations.collectAsStateWithLifecycle() val snackbarHostState = remember { SnackbarHostState() } - val scope = rememberCoroutineScope() val mapThemeSetting by viewModel.mapTheme.collectAsStateWithLifecycle() - val lastRecalcLat by viewModel.lastRecalcLat.collectAsStateWithLifecycle() - val lastRecalcLng by viewModel.lastRecalcLng.collectAsStateWithLifecycle() + val lastRecalcLocation by viewModel.lastRecalcLocation.collectAsStateWithLifecycle() val innerRadiusR by viewModel.innerRadiusR.collectAsStateWithLifecycle() val outerRadiusN by viewModel.outerRadiusN.collectAsStateWithLifecycle() val isSystemDark = androidx.compose.foundation.isSystemInDarkTheme() - val isMapDarkTheme = isMapDark(mapThemeSetting, isSystemDark) + val isMapDarkTheme = mapThemeSetting.resolve(isSystemDark) LaunchedEffect(viewModel) { - viewModel.snackbarMessage.collect { uiText -> - val msg = when (uiText) { - is UiText.DynamicString -> uiText.value - is UiText.StringResource -> context.applicationContext.getString(uiText.resId) - } + viewModel.messagesFlow.collect { message -> snackbarHostState.showSnackbar( - message = msg, + message = message.resolve(context), duration = SnackbarDuration.Short ) } @@ -178,14 +164,18 @@ fun RemindersScreen( } // Dialog State - var showDialog by remember { mutableStateOf(false) } + var showDialog by rememberSaveable { mutableStateOf(false) } val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - var editingReminder by remember { mutableStateOf(null) } - var selectedLocationId by remember { mutableStateOf("") } - var message by remember { mutableStateOf("") } - var transitionType by remember { mutableIntStateOf(Geofence.GEOFENCE_TRANSITION_ENTER) } + var editingReminderId by rememberSaveable { mutableStateOf(null) } + var selectedLocationId by rememberSaveable { mutableStateOf("") } + var message by rememberSaveable { mutableStateOf("") } + var transitionType by rememberSaveable { mutableIntStateOf(Geofence.GEOFENCE_TRANSITION_ENTER) } var dropdownExpanded by remember { mutableStateOf(false) } + val editingReminder = remember(editingReminderId, reminders) { + reminders.firstOrNull { it.id == editingReminderId } + } + val formState = ReminderFormState( editingReminder = editingReminder, selectedLocationId = selectedLocationId, @@ -205,25 +195,19 @@ fun RemindersScreen( onSave = { saveReminder(viewModel, editingReminder, selectedLocationId, message, transitionType) showDialog = false - editingReminder = null + editingReminderId = null }, onDelete = { val editing = editingReminder if (editing != null) { viewModel.cancelReminder(editing.id) showDialog = false - editingReminder = null - scope.launch { - snackbarHostState.showSnackbar( - message = context.applicationContext.getString(R.string.toast_reminder_deleted), - duration = SnackbarDuration.Short - ) - } + editingReminderId = null } }, onCancel = { showDialog = false - editingReminder = null + editingReminderId = null } ) @@ -240,52 +224,30 @@ fun RemindersScreen( locations = locations, selectedLocation = selectedLocationOnMap, spatialArea = SpatialRecalculationArea( - latitude = lastRecalcLat, - longitude = lastRecalcLng, - innerRadiusMeters = innerRadiusR * 1000f, - outerRadiusMeters = outerRadiusN * 1000f - ), - currentUserLocation = currentUserLocation + latitude = lastRecalcLocation?.latitude, + longitude = lastRecalcLocation?.longitude, + innerRadiusMeters = innerRadiusR * 1000f, + outerRadiusMeters = outerRadiusN * 1000f ), - isMapDarkTheme = isMapDarkTheme - ) + currentUserLocation = currentUserLocation + ), + isMapDarkTheme = isMapDarkTheme +) val contentActions = RemindersContentActions( onListSelected = { isMapView = false }, onMapSelected = { isMapView = true }, - onCancelActive = { reminder -> - viewModel.cancelReminder(reminder.id) - scope.launch { - snackbarHostState.showSnackbar( - message = context.applicationContext.getString(R.string.toast_reminder_cancelled), - duration = SnackbarDuration.Short - ) - } - }, - onDeleteCompleted = { reminder -> - viewModel.cancelReminder(reminder.id) - scope.launch { - snackbarHostState.showSnackbar( - message = context.applicationContext.getString(R.string.toast_reminder_deleted), - duration = SnackbarDuration.Short - ) - } - }, + onCancelActive = { reminder -> viewModel.cancelReminder(reminder.id) }, + onDeleteCompleted = { reminder -> viewModel.cancelReminder(reminder.id, cancelled = false) }, onEditReminder = { reminder -> - editingReminder = reminder + editingReminderId = reminder.id selectedLocationId = reminder.locationId message = reminder.message transitionType = reminder.transitionType showDialog = true }, onDeleteReminderOnMap = { reminder -> - viewModel.cancelReminder(reminder.id) - scope.launch { - snackbarHostState.showSnackbar( - message = context.applicationContext.getString(R.string.toast_reminder_deleted), - duration = SnackbarDuration.Short - ) - } + viewModel.cancelReminder(reminder.id, cancelled = false) val remaining = activeReminders.filter { it.locationId == selectedLocationOnMap?.id && it.id != reminder.id } if (remaining.isEmpty()) { selectedLocationOnMap = null @@ -294,7 +256,7 @@ fun RemindersScreen( onLocationSelected = { selectedLocationOnMap = it }, onDismissSelectedLocation = { selectedLocationOnMap = null }, onAddReminder = { - editingReminder = null + editingReminderId = null selectedLocationId = locations.firstOrNull()?.id ?: "" message = "" transitionType = Geofence.GEOFENCE_TRANSITION_ENTER @@ -331,7 +293,7 @@ fun RemindersScreen( locations = locations, onDismissRequest = { showDialog = false - editingReminder = null + editingReminderId = null } ) } @@ -891,42 +853,7 @@ private fun SelectedReminderItemRow( fontWeight = FontWeight.SemiBold ) Spacer(Modifier.height(4.dp)) - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - androidx.compose.material3.SuggestionChip( - onClick = {}, - label = { - Text( - text = if (reminder.isArrival) { - stringResource(R.string.label_transition_arrival) - } else { - stringResource(R.string.label_transition_departure) - }, - style = MaterialTheme.typography.labelSmall - ) - }, - modifier = Modifier.height(24.dp) - ) - - if (reminder.isInRange) { - androidx.compose.material3.SuggestionChip( - onClick = {}, - label = { - Text( - text = stringResource(R.string.label_in_range), - style = MaterialTheme.typography.labelSmall - ) - }, - colors = androidx.compose.material3.SuggestionChipDefaults.suggestionChipColors( - containerColor = MaterialTheme.colorScheme.tertiaryContainer, - labelColor = MaterialTheme.colorScheme.onTertiaryContainer - ), - modifier = Modifier.height(24.dp) - ) - } - } + ReminderStatusChips(reminder) } Row( diff --git a/app/src/main/java/dev/arrase/geotify/ui/screen/RemindersViewModel.kt b/app/src/main/java/dev/arrase/geotify/ui/screen/RemindersViewModel.kt index 5f5f32d..16d5bc4 100644 --- a/app/src/main/java/dev/arrase/geotify/ui/screen/RemindersViewModel.kt +++ b/app/src/main/java/dev/arrase/geotify/ui/screen/RemindersViewModel.kt @@ -1,25 +1,21 @@ package dev.arrase.geotify.ui.screen -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import dev.arrase.geotify.R +import dev.arrase.geotify.data.LatLng import dev.arrase.geotify.data.LocationRepository import dev.arrase.geotify.data.ReminderRepository -import dev.arrase.geotify.data.entity.LocationEntity import dev.arrase.geotify.data.SettingsDefaults import dev.arrase.geotify.data.SettingsManager import dev.arrase.geotify.data.ThemeSetting +import dev.arrase.geotify.data.entity.LocationEntity import dev.arrase.geotify.data.entity.ReminderEntity import dev.arrase.geotify.geofence.GeofenceOrchestrator import dev.arrase.geotify.location.LocationProvider +import dev.arrase.geotify.ui.BaseViewModel import dev.arrase.geotify.ui.UiText -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject @@ -29,72 +25,76 @@ class RemindersViewModel @Inject constructor( private val reminderRepository: ReminderRepository, private val geofenceOrchestrator: GeofenceOrchestrator, private val locationProvider: LocationProvider, - private val settingsManager: SettingsManager -) : ViewModel() { - - private val _snackbarMessage = MutableSharedFlow() - val snackbarMessage: SharedFlow = _snackbarMessage.asSharedFlow() + settingsManager: SettingsManager +) : BaseViewModel() { val locations: StateFlow> = locationRepository.observeLocations() - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + .settingFlow(emptyList()) val reminders: StateFlow> = reminderRepository.observeReminders() - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) - - val activeReminderCounts: StateFlow> = reminderRepository.observeActiveReminderCounts() - .map { list -> list.associate { it.locationId to it.count } } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyMap()) + .settingFlow(emptyList()) - val mapTheme: StateFlow = settingsManager.mapTheme - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SettingsDefaults.MAP_THEME) + val mapTheme: StateFlow = + settingsManager.mapTheme.settingFlow(SettingsDefaults.MAP_THEME) - val lastRecalcLat: StateFlow = settingsManager.lastRecalcLat - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) + val lastRecalcLocation: StateFlow = settingsManager.lastRecalcLocation + .settingFlow(null) - val lastRecalcLng: StateFlow = settingsManager.lastRecalcLng - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) + val innerRadiusR: StateFlow = + settingsManager.innerRadiusR.settingFlow(SettingsDefaults.INNER_RADIUS_R) - val innerRadiusR: StateFlow = settingsManager.innerRadiusR - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SettingsDefaults.INNER_RADIUS_R) + val outerRadiusN: StateFlow = + settingsManager.outerRadiusN.settingFlow(SettingsDefaults.OUTER_RADIUS_N) - val outerRadiusN: StateFlow = settingsManager.outerRadiusN - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SettingsDefaults.OUTER_RADIUS_N) - - suspend fun getCurrentLocation(): android.location.Location? { - return locationProvider.getCurrentLocation() - } - - fun createReminder(locationId: String, message: String, transitionType: Int) { + fun createReminder(locationId: String, message: String, transitionType: Int) = viewModelScope.launch { - try { - val location = locationRepository.findLocationById(locationId) ?: return@launch + val location = locationRepository.findLocationById(locationId) + if (location == null) { + messages.send(UiText.StringResource(R.string.err_location_missing)) + return@launch + } + mutate(ERROR_CREATE, onSuccess = geofenceOrchestrator::triggerRecalculation) { reminderRepository.createReminder(location, message, transitionType) - geofenceOrchestrator.triggerRecalculation() - } catch (e: Exception) { - _snackbarMessage.emit(UiText.DynamicString("Error: ${e.message}")) } } - } - fun updateReminder(reminder: ReminderEntity) { - viewModelScope.launch { - try { - reminderRepository.updateReminder(reminder) - geofenceOrchestrator.triggerRecalculation() - } catch (e: Exception) { - _snackbarMessage.emit(UiText.DynamicString("Error: ${e.message}")) - } + fun updateReminder(reminder: ReminderEntity) = viewModelScope.launch { + mutate(ERROR_UPDATE, onSuccess = geofenceOrchestrator::triggerRecalculation) { + reminderRepository.updateReminder(reminder) } } - fun cancelReminder(reminderId: String) { - viewModelScope.launch { - try { - reminderRepository.cancelReminder(reminderId) + /** + * Removes a reminder. [cancelled] distinguishes stopping an active reminder (it stays in the + * completed list) from deleting a completed one, so the confirmation matches the action. + */ + fun cancelReminder(reminderId: String, cancelled: Boolean = true) = viewModelScope.launch { + mutate( + errorFallback = ERROR_CANCEL, + onSuccess = { geofenceOrchestrator.triggerRecalculation() - } catch (e: Exception) { - _snackbarMessage.emit(UiText.DynamicString("Error: ${e.message}")) + messages.send( + UiText.StringResource( + if (cancelled) R.string.reminder_cancelled else R.string.reminder_deleted + ) + ) + } + ) { + if (!reminderRepository.cancelReminder(reminderId)) { + throw StaleReminderException(reminderId) } } } + + suspend fun getCurrentLocation() = locationProvider.getCurrentLocation() + + private companion object { + const val ERROR_CREATE = "Could not create the reminder" + const val ERROR_UPDATE = "Could not update the reminder" + const val ERROR_CANCEL = "Could not cancel the reminder" + } } + +/** Raised when a delete affected no rows, i.e. the reminder was already gone. */ +private class StaleReminderException(id: String) : + IllegalStateException("Reminder $id no longer exists") diff --git a/app/src/main/java/dev/arrase/geotify/ui/screen/SettingsScreen.kt b/app/src/main/java/dev/arrase/geotify/ui/screen/SettingsScreen.kt index d43c380..ec466e6 100644 --- a/app/src/main/java/dev/arrase/geotify/ui/screen/SettingsScreen.kt +++ b/app/src/main/java/dev/arrase/geotify/ui/screen/SettingsScreen.kt @@ -106,8 +106,9 @@ private fun SpatialRecalculationSection(viewModel: SettingsViewModel) { val outerRadiusN by viewModel.outerRadiusN.collectAsStateWithLifecycle() val innerRadiusR by viewModel.innerRadiusR.collectAsStateWithLifecycle() - var localOuterRadius by remember(outerRadiusN) { mutableFloatStateOf(outerRadiusN) } - var localInnerRadius by remember(innerRadiusR) { mutableFloatStateOf(innerRadiusR) } + // Seeded once: re-keying on the upstream flow would reset the slider mid-drag. + var localOuterRadius by remember { mutableFloatStateOf(outerRadiusN) } + var localInnerRadius by remember { mutableFloatStateOf(innerRadiusR.coerceIn(MIN_RADIUS_KM, outerRadiusN)) } var showRecalcInfo by remember { mutableStateOf(false) } SettingsCard( @@ -144,15 +145,16 @@ private fun SpatialRecalculationSection(viewModel: SettingsViewModel) { onValueChange = { newVal -> val cleanVal = round(newVal * 10f) / 10f localOuterRadius = cleanVal - if (localInnerRadius > cleanVal) { - localInnerRadius = cleanVal - } + // The inner radius must never exceed the outer one. + localInnerRadius = + localInnerRadius.coerceIn(MIN_RADIUS_KM, cleanVal) }, onValueChangeFinished = { - viewModel.setOuterRadiusN(localOuterRadius) - if (innerRadiusR > localOuterRadius) { - viewModel.setInnerRadiusR(localOuterRadius) - } + // Both radii feed the same recalculation, so they are persisted together. + viewModel.setRadii( + outerRadiusKm = localOuterRadius, + innerRadiusKm = localInnerRadius + ) }, valueRange = 1.0f..10.0f ) @@ -180,13 +182,16 @@ private fun SpatialRecalculationSection(viewModel: SettingsViewModel) { Slider( value = localInnerRadius, onValueChange = { newVal -> - val cleanVal = round(newVal * 10f) / 10f - localInnerRadius = cleanVal + localInnerRadius = (round(newVal * 10f) / 10f) + .coerceIn(MIN_RADIUS_KM, localOuterRadius) }, onValueChangeFinished = { - viewModel.setInnerRadiusR(localInnerRadius) + viewModel.setRadii( + outerRadiusKm = localOuterRadius, + innerRadiusKm = localInnerRadius + ) }, - valueRange = 0.5f..localOuterRadius + valueRange = MIN_RADIUS_KM..localOuterRadius ) } } @@ -519,3 +524,6 @@ private fun ThemeSelector( } } } + +/** Smallest selectable inner radius, in km. */ +private const val MIN_RADIUS_KM = 0.5f diff --git a/app/src/main/java/dev/arrase/geotify/ui/screen/SettingsViewModel.kt b/app/src/main/java/dev/arrase/geotify/ui/screen/SettingsViewModel.kt index 311e883..85f808f 100644 --- a/app/src/main/java/dev/arrase/geotify/ui/screen/SettingsViewModel.kt +++ b/app/src/main/java/dev/arrase/geotify/ui/screen/SettingsViewModel.kt @@ -1,15 +1,13 @@ package dev.arrase.geotify.ui.screen -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel import dev.arrase.geotify.data.SettingsDefaults import dev.arrase.geotify.data.SettingsManager import dev.arrase.geotify.data.ThemeSetting import dev.arrase.geotify.geofence.GeofenceOrchestrator -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.SharingStarted +import dev.arrase.geotify.ui.BaseViewModel import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject @@ -17,81 +15,77 @@ import javax.inject.Inject class SettingsViewModel @Inject constructor( private val settingsManager: SettingsManager, private val geofenceOrchestrator: GeofenceOrchestrator -) : ViewModel() { +) : BaseViewModel() { - val appTheme: StateFlow = settingsManager.appTheme - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SettingsDefaults.APP_THEME) + val appTheme: StateFlow = + settingsManager.appTheme.settingFlow(SettingsDefaults.APP_THEME) - val mapTheme: StateFlow = settingsManager.mapTheme - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SettingsDefaults.MAP_THEME) + val mapTheme: StateFlow = + settingsManager.mapTheme.settingFlow(SettingsDefaults.MAP_THEME) - val outerRadiusN: StateFlow = settingsManager.outerRadiusN - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SettingsDefaults.OUTER_RADIUS_N) + val outerRadiusN: StateFlow = + settingsManager.outerRadiusN.settingFlow(SettingsDefaults.OUTER_RADIUS_N) - val innerRadiusR: StateFlow = settingsManager.innerRadiusR - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SettingsDefaults.INNER_RADIUS_R) + val innerRadiusR: StateFlow = + settingsManager.innerRadiusR.settingFlow(SettingsDefaults.INNER_RADIUS_R) val locationCacheTimeoutSecs: StateFlow = settingsManager.locationCacheTimeoutSecs - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SettingsDefaults.LOCATION_CACHE_TIMEOUT_SECS) + .settingFlow(SettingsDefaults.LOCATION_CACHE_TIMEOUT_SECS) val recalculationDebounceSecs: StateFlow = settingsManager.recalculationDebounceSecs - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SettingsDefaults.RECALCULATION_DEBOUNCE_SECS) + .settingFlow(SettingsDefaults.RECALCULATION_DEBOUNCE_SECS) - val masterGeofenceResponsivenessSecs: StateFlow = settingsManager.masterGeofenceResponsivenessSecs - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SettingsDefaults.MASTER_GEOFENCE_RESPONSIVENESS_SECS) + val masterGeofenceResponsivenessSecs: StateFlow = + settingsManager.masterGeofenceResponsivenessSecs + .settingFlow(SettingsDefaults.MASTER_GEOFENCE_RESPONSIVENESS_SECS) val poiGeofenceResponsivenessSecs: StateFlow = settingsManager.poiGeofenceResponsivenessSecs - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SettingsDefaults.POI_GEOFENCE_RESPONSIVENESS_SECS) + .settingFlow(SettingsDefaults.POI_GEOFENCE_RESPONSIVENESS_SECS) - fun setAppTheme(theme: ThemeSetting) { - viewModelScope.launch { - settingsManager.setAppTheme(theme) - } + fun setAppTheme(theme: ThemeSetting) = persist { + settingsManager.setAppTheme(theme) } - fun setMapTheme(theme: ThemeSetting) { - viewModelScope.launch { - settingsManager.setMapTheme(theme) - } + fun setMapTheme(theme: ThemeSetting) = persist { + settingsManager.setMapTheme(theme) } - fun setOuterRadiusN(radius: Float) { - viewModelScope.launch { - settingsManager.setOuterRadiusN(radius) - geofenceOrchestrator.triggerRecalculation() - } + fun setLocationCacheTimeoutSecs(secs: Int) = persist { + settingsManager.setLocationCacheTimeoutSecs(secs) } - fun setInnerRadiusR(radius: Float) { - viewModelScope.launch { - settingsManager.setInnerRadiusR(radius) - geofenceOrchestrator.triggerRecalculation() - } + fun setRecalculationDebounceSecs(secs: Int) = persist { + settingsManager.setRecalculationDebounceSecs(secs) } - fun setLocationCacheTimeoutSecs(secs: Int) { - viewModelScope.launch { - settingsManager.setLocationCacheTimeoutSecs(secs) + /** + * The sliding window depends on both radii, so they are written together and the + * recalculation is triggered once, after both have been persisted. + */ + fun setRadii(outerRadiusKm: Float, innerRadiusKm: Float) = viewModelScope.launch { + mutate(ERROR_SAVE, onSuccess = geofenceOrchestrator::triggerRecalculation) { + settingsManager.setOuterRadiusN(outerRadiusKm) + settingsManager.setInnerRadiusR(innerRadiusKm) } } - fun setRecalculationDebounceSecs(secs: Int) { - viewModelScope.launch { - settingsManager.setRecalculationDebounceSecs(secs) - } + fun setMasterGeofenceResponsivenessSecs(secs: Int) = recalculate { + settingsManager.setMasterGeofenceResponsivenessSecs(secs) } - fun setMasterGeofenceResponsivenessSecs(secs: Int) { - viewModelScope.launch { - settingsManager.setMasterGeofenceResponsivenessSecs(secs) - geofenceOrchestrator.triggerRecalculation() - } + fun setPoiGeofenceResponsivenessSecs(secs: Int) = recalculate { + settingsManager.setPoiGeofenceResponsivenessSecs(secs) } - fun setPoiGeofenceResponsivenessSecs(secs: Int) { - viewModelScope.launch { - settingsManager.setPoiGeofenceResponsivenessSecs(secs) - geofenceOrchestrator.triggerRecalculation() - } + private fun persist(write: suspend () -> Unit) = viewModelScope.launch { + mutate(ERROR_SAVE, onSuccess = {}, action = write) + } + + private fun recalculate(write: suspend () -> Unit) = viewModelScope.launch { + mutate(ERROR_SAVE, onSuccess = geofenceOrchestrator::triggerRecalculation, action = write) + } + + private companion object { + const val ERROR_SAVE = "Could not save the setting" } } diff --git a/app/src/main/java/dev/arrase/geotify/ui/theme/Theme.kt b/app/src/main/java/dev/arrase/geotify/ui/theme/Theme.kt index 4a57604..f040ef8 100644 --- a/app/src/main/java/dev/arrase/geotify/ui/theme/Theme.kt +++ b/app/src/main/java/dev/arrase/geotify/ui/theme/Theme.kt @@ -1,12 +1,18 @@ package dev.arrase.geotify.ui.theme import android.app.Activity +import android.content.Context +import android.os.Build import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.ColorScheme import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView import androidx.core.view.WindowCompat @@ -17,19 +23,16 @@ fun GeotifyTheme( content: @Composable () -> Unit ) { val context = LocalContext.current - val colorScheme = when { - android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S -> { - if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) - } - darkTheme -> androidx.compose.material3.darkColorScheme() - else -> androidx.compose.material3.lightColorScheme() - } + val colorScheme = rememberColorScheme(context, darkTheme) val view = LocalView.current if (!view.isInEditMode) { SideEffect { - val window = (view.context as Activity).window - WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme + (view.context as? Activity)?.window?.let { window -> + val controller = WindowCompat.getInsetsController(window, view) + controller.isAppearanceLightStatusBars = !darkTheme + controller.isAppearanceLightNavigationBars = !darkTheme + } } } @@ -39,3 +42,14 @@ fun GeotifyTheme( content = content ) } + +/** Uses the wallpaper-derived palette on Android 12+, falling back to the baseline schemes. */ +@Composable +private fun rememberColorScheme(context: Context, darkTheme: Boolean): ColorScheme { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { + return if (darkTheme) darkColorScheme() else lightColorScheme() + } + return remember(context, darkTheme) { + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } +} diff --git a/app/src/main/java/dev/arrase/geotify/ui/theme/ThemeSettingExt.kt b/app/src/main/java/dev/arrase/geotify/ui/theme/ThemeSettingExt.kt new file mode 100644 index 0000000..306760f --- /dev/null +++ b/app/src/main/java/dev/arrase/geotify/ui/theme/ThemeSettingExt.kt @@ -0,0 +1,10 @@ +package dev.arrase.geotify.ui.theme + +import dev.arrase.geotify.data.ThemeSetting + +/** Resolves a [ThemeSetting] into a concrete boolean for a feature that only has light/dark. */ +fun ThemeSetting.resolve(systemIsDark: Boolean): Boolean = when (this) { + ThemeSetting.SYSTEM -> systemIsDark + ThemeSetting.LIGHT -> false + ThemeSetting.DARK -> true +} diff --git a/app/src/main/java/dev/arrase/geotify/util/ContextExtensions.kt b/app/src/main/java/dev/arrase/geotify/util/ContextExtensions.kt index e5b8702..250b602 100644 --- a/app/src/main/java/dev/arrase/geotify/util/ContextExtensions.kt +++ b/app/src/main/java/dev/arrase/geotify/util/ContextExtensions.kt @@ -4,29 +4,33 @@ import android.content.BroadcastReceiver import android.util.Log import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeout import kotlin.time.Duration.Companion.seconds /** - * Calls [goAsync] and launches a coroutine to perform background work within the - * BroadcastReceiver's lifecycle. Ensures [PendingResult.finish] is always called, - * even if the coroutine fails or times out. + * Runs [block] with the BroadcastReceiver kept alive via [goAsync], guaranteeing that + * `PendingResult.finish()` is always called. * - * Android kills BroadcastReceivers after ~10 seconds, so a 9-second timeout is enforced. + * Android only grants receivers about 10 seconds, so the work is bounded by a slightly shorter + * timeout. On timeout the coroutine is cancelled and the block unwinds, so any cleanup it still + * needs must happen in a `finally` block. */ -fun BroadcastReceiver.goAsyncCoroutine(block: suspend CoroutineScope.() -> Unit) { +fun BroadcastReceiver.goAsyncCoroutine(block: suspend () -> Unit) { val pendingResult = goAsync() - CoroutineScope(Dispatchers.IO + SupervisorJob()).launch { + CoroutineScope(Dispatchers.IO).launch { try { - withTimeout(9.seconds) { - block() - } + withTimeout(TIMEOUT) { block() } + } catch (e: TimeoutCancellationException) { + Log.e(TAG, "BroadcastReceiver coroutine timed out after $TIMEOUT", e) } catch (e: Exception) { - Log.e("GoAsyncCoroutine", "Error in BroadcastReceiver coroutine", e) + Log.e(TAG, "BroadcastReceiver coroutine failed", e) } finally { pendingResult.finish() } } } + +private const val TAG = "GoAsyncCoroutine" +private val TIMEOUT = 9.seconds diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index a2ac373..04578d0 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -6,7 +6,6 @@ حفظ تخطي فتح الإعدادات - استخدام إحداثيات GPS الحالية جاري استعلام GPS… المواقع التذكيرات @@ -36,12 +35,8 @@ تعديل الموقع تذكير جديد تعديل التذكير - تم حذف \"%1$s\" - تم إلغاء التذكير - تم حذف التذكير إضافة موقع إضافة تذكير - موقع تذكير إلغاء اختر الموقع @@ -63,7 +58,6 @@ الخريطة اختر على الخريطة استخدم GPS - محدد الخريطة اختر الموقع على الخريطة الموقع المحدد تأكيد الاختيار @@ -78,10 +72,6 @@ النظام فاتح داكن - تم الوصول إلى حد السياج الجغرافي (الحد الأقصى 100). لا يمكنك تفعيل المزيد. - لقد وصلت إلى الحد الأقصى وهو 100 سياج جغرافي نشط. - تم الوصول إلى حد السياج الجغرافي البالغ 100. يمكنك فقط إضافة تذكيرات للمواقع ذات السياجات النشطة. - تم الوصول إلى حد السياج الجغرافي البالغ 100. يرجى اختيار موقع به تذكيرات نشطة. استجابة الإشعارات %1$d دقيقة 0 دقيقة (فوري) @@ -116,4 +106,19 @@ رجوع التركيز على نظام تحديد المواقع + لم يعد هذا الموقع موجودًا + %1$s: %2$.5f، %3$s: %4$.5f + ‎%1$.5f، %2$.5f + + %d تذكير نشط + %d تذكير نشط + %d تذكيران نشطان + %d تذكيرات نشطة + %d تذكيرًا نشطًا + %d تذكير نشط + + تم حذف "%1$s" + تم إلغاء التذكير + تم حذف التذكير + غير محدد \ No newline at end of file diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 7dd2fdc..45416d0 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -6,7 +6,6 @@ Uložit Přeskočit Otevřít nastavení - Použít aktuální GPS souřadnice Dotazování GPS… Lokalizace Upomínky @@ -36,12 +35,8 @@ Upravit lokalizaci Nová upomínka Upravit upomínku - \"%1$s\" smazáno - Upomínka zrušena - Upomínka smazána Přidat lokalizaci Přidat upomínku - Lokalizace Upomínka Zrušit Vybrat lokalizaci @@ -63,7 +58,6 @@ Mapa Vybrat na mapě Použít GPS - Výběr mapy Vybrat lokalizaci na mapě Vybraná lokalizace Potvrdit výběr @@ -78,10 +72,6 @@ Systémové Světlé Tmavé - Dosáhnuto omezení geofencingu (maximálně 100). Nemůžete jej aktivovat více. - Dosáhli jste maximálního limitu 100 aktivních geofencingů. - Dosáhnuto omezení geofencingu 100. Můžete přidávat upomínky pouze k lokalizacím s aktivními geofencingy. - Dosáhnuto omezení geofencingu 100. Vyberte lokalizaci s aktivní upomínkou. Reaktivnost oznámení %1$d min 0 min (okamžitě) @@ -116,4 +106,17 @@ Zpět Vycentrovat na GPS + Tato lokalizace již neexistuje + %1$s: %2$.5f, %3$s: %4$.5f + %1$.5f, %2$.5f + + %d aktivní připomínka + %d aktivní připomínky + %d aktivních připomínek + %d aktivní připomínky + + "%1$s" smazáno + Připomínka zrušena + Připomínka smazána + Neznámé \ No newline at end of file diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index aac89cb..9d5bdcc 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -6,7 +6,6 @@ Gem Spring over Åbn indstillinger - Brug nuværende GPS-koordinater Henter GPS… Lokationer Påmindelser @@ -36,12 +35,8 @@ Rediger lokation Ny påmindelse Rediger påmindelse - \"%1$s\" slettet - Påmindelse annulleret - Påmindelse slettet Tilføj lokation Tilføj påmindelse - Lokation Påmindelse Annuller Vælg lokation @@ -63,7 +58,6 @@ Kort Vælg på kort Brug GPS - Kortvælger Vælg lokation på kort Valgt lokation Bekræft valg @@ -78,10 +72,6 @@ System Lyst Mørkt - Geofence-grænse nået (maksimalt 100). Du kan ikke aktivere flere. - Du har nået den maksimale grænse på 100 aktive geofences. - Geofence-grænse på 100 nået. Du kan kun tilføje påmindelser til lokationer med aktive geofences. - Geofence-grænse på 100 nået. Vælg venligst en lokation med aktive påmindelser. Notifikationsrespons %1$d min 0 min (Øjeblikkelig) @@ -116,4 +106,15 @@ Tilbage Centrer på GPS + Denne placering findes ikke længere + %1$s: %2$.5f, %3$s: %4$.5f + %1$.5f, %2$.5f + + %d aktiv påmindelse + %d aktive påmindelser + + "%1$s" slettet + Påmindelse annulleret + Påmindelse slettet + Ukendt \ No newline at end of file diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 987db92..599d1f1 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -6,7 +6,6 @@ Speichern Überspringen Einstellungen öffnen - Aktuelle GPS-Koordinaten nutzen GPS-Abfrage… Orte Erinnerungen @@ -36,12 +35,8 @@ Ort bearbeiten Neue Erinnerung Erinnerung bearbeiten - „%1$s“ gelöscht - Erinnerung abgebrochen - Erinnerung gelöscht Ort hinzufügen Erinnerung hinzufügen - Ort Erinnerung Abbrechen Ort auswählen @@ -63,7 +58,6 @@ Karte Auf Karte auswählen GPS nutzen - Kartenauswahl Ort auf Karte auswählen Ausgewählter Ort Auswahl bestätigen @@ -78,10 +72,6 @@ System Hell Dunkel - Geofence-Limit erreicht (maximal 100). Sie können keine weiteren aktivieren. - Sie haben das maximale Limit von 100 aktiven Geofences erreicht. - Geofence-Limit von 100 erreicht. Sie können Erinnerungen nur für Orte mit aktiven Geofences hinzufügen. - Geofence-Limit von 100 erreicht. Bitte wählen Sie einen Ort mit aktiven Erinnerungen. Reaktionszeit %1$d Min. 0 Min. (Sofort) @@ -114,4 +104,15 @@ Mein Standort Zurück Auf GPS zentrieren + Dieser Ort existiert nicht mehr + %1$s: %2$.5f, %3$s: %4$.5f + %1$.5f, %2$.5f + + %d aktive Erinnerung + %d aktive Erinnerungen + + "%1$s" gelöscht + Erinnerung abgebrochen + Erinnerung gelöscht + Unbekannt diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index e0ca59f..817b9b8 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -6,7 +6,6 @@ Guardar Omitir Abrir ajustes - Usar coordenadas GPS actuales Obteniendo GPS… Lugares Recordatorios @@ -36,12 +35,8 @@ Editar lugar Nuevo recordatorio Editar recordatorio - \"%1$s\" eliminado - Recordatorio cancelado - Recordatorio eliminado Añadir lugar Añadir recordatorio - Ubicación Recordatorio Cancelar Seleccionar lugar @@ -63,7 +58,6 @@ Mapa Seleccionar en mapa Usar GPS - Selector de mapa Seleccionar ubicación en el mapa Ubicación seleccionada Confirmar selección @@ -78,10 +72,6 @@ Sistema Claro Oscuro - Límite de geovallas alcanzado (máximo 100). No puedes activar más. - Has alcanzado el límite máximo de 100 geovallas activas. - Límite de 100 geovallas alcanzado. Solo puedes añadir recordatorios a ubicaciones con geovallas activas. - Límite de 100 geovallas alcanzado. Selecciona una ubicación con recordatorios activos. Tiempo de respuesta %1$d min 0 min (Instantáneo) @@ -114,4 +104,16 @@ Mi ubicación Atrás Centrar en GPS + Esa ubicación ya no existe + %1$s: %2$.5f, %3$s: %4$.5f + %1$.5f, %2$.5f + + %d recordatorio activo + %d de recordatorios activos + %d recordatorios activos + + "%1$s" eliminado + Recordatorio cancelado + Recordatorio eliminado + Desconocido diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 8f257fe..8ec7a08 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -6,7 +6,6 @@ Enregistrer Ignorer Ouvrir les paramètres - Utiliser les coordonnées GPS actuelles Recherche GPS… Lieux Rappels @@ -36,12 +35,8 @@ Modifier le lieu Nouveau rappel Modifier le rappel - \"%1$s\" supprimé - Rappel annulé - Rappel supprimé Ajouter un lieu Ajouter un rappel - Lieu Rappel Annuler Sélectionner un lieu @@ -63,7 +58,6 @@ Carte Sélectionner sur la carte Utiliser le GPS - Sélecteur de carte Sélectionner un lieu sur la carte Lieu sélectionné Confirmer la sélection @@ -78,10 +72,6 @@ Système Clair Sombre - Limite de géobarrières atteinte (maximum 100). Vous ne pouvez pas en activer plus. - Vous avez atteint la limite maximale de 100 géobarrières actives. - Limite de 100 géobarrières atteinte. Vous ne pouvez ajouter des rappels que pour les lieux ayant des géobarrières actives. - Limite de 100 géobarrières atteinte. Veuillez sélectionner un lieu avec des rappels actifs. Réactivité des notifications %1$d min 0 min (Instantané) @@ -114,4 +104,16 @@ Ma position Retour Centrer sur le GPS + Ce lieu n\'existe plus + %1$s: %2$.5f, %3$s: %4$.5f + %1$.5f, %2$.5f + + %d rappel actif + %d de rappels actifs + %d rappels actifs + + "%1$s" supprimé + Rappel annulé + Rappel supprimé + Inconnu diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index bbacea1..15159ac 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -6,7 +6,6 @@ सहेजें छोड़ें सेटिंग्स खोलें - वर्तमान GPS निर्देशांक का उपयोग करें GPS क्वेरी कर रहा है… स्थान रिमाइंडर @@ -36,12 +35,8 @@ स्थान संपादित करें नया रिमाइंडर रिमाइंडर संपादित करें - \"%1$s\" हटा दिया गया - रिमाइंडर रद्द कर दिया गया - रिमाइंडर हटा दिया गया स्थान जोड़ें रिमाइंडर जोड़ें - स्थान रिमाइंडर रद्द करें स्थान चुनें @@ -63,7 +58,6 @@ मानचित्र मानचित्र पर चुनें GPS का उपयोग करें - मैप पिकर मानचित्र पर स्थान चुनें चयनित स्थान चयन की पुष्टि करें @@ -78,10 +72,6 @@ सिस्टम लाइट डार्क - जियोफेंस सीमा तक पहुंच गई है (अधिकतम 100)। आप और सक्रिय नहीं कर सकते। - आप सक्रिय जियोफेंस की अधिकतम सीमा 100 तक पहुंच गए हैं। - जियोफेंस की सीमा 100 तक पहुंच गई है। आप केवल सक्रिय जियोफेंस वाले स्थानों पर रिमाइंडर जोड़ सकते हैं। - जियोफेंस की सीमा 100 तक पहुंच गई है। कृपया सक्रिय रिमाइंडर वाला स्थान चुनें। नोटिफिकेशन प्रतिक्रियाशीलता %1$d मिनट 0 मिनट (तत्काल) @@ -116,4 +106,15 @@ पीछे जीपीएस पर केंद्रित करें + वह स्थान अब मौजूद नहीं है + %1$s: %2$.5f, %3$s: %4$.5f + %1$.5f, %2$.5f + + %d सक्रिय अनुस्मारक + %d सक्रिय अनुस्मारक + + "%1$s" हटाया गया + अनुस्मारक रद्द किया गया + अनुस्मारक हटाया गया + अज्ञात \ No newline at end of file diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index 4c71616..00eeb05 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -6,7 +6,6 @@ Mentés Ugrás Beállítások megnyitása - Aktuális GPS koordináták használata GPS lekérdezés… Helyszínek Emlékeztetők @@ -36,12 +35,8 @@ Helyszín szerkesztése Új emlékeztető Emlékeztető szerkesztése - \"%1$s\" törölve - Emlékeztető megállítva - Emlékeztető törölve Helyszín hozzáadása Emlékeztető hozzáadása - Helyszín Emlékeztető Mégse Helyszín választása @@ -63,7 +58,6 @@ Térkép Választás térképen GPS használata - Térképválogató Helyszín választása térképen Kiválasztott helyszín Választás megerősítése @@ -78,10 +72,6 @@ Rendszer Világos Sötét - Elért a geofence korlát (maximum 100). Nem tud többet aktiválni. - Elért az aktív geofencek maximum 100-es korlátja. - Elért a geofence korlát 100. Csak olyan helyszínekhez adhat emlékeztetőt, amelyek aktív geofencenekkel rendelkeznek. - Elért a geofence korlát 100. Kérjük, válassza ki egy olyan helyszínt, amelyben aktív emlékeztető van. Értesítés válaszideje %1$d perc 0 perc (azonnali) @@ -116,4 +106,15 @@ Vissza Középpontba helyezés GPS alapján + Ez a hely már nem létezik + %1$s: %2$.5f, %3$s: %4$.5f + %1$.5f, %2$.5f + + %d aktív emlékeztető + %d aktív emlékeztető + + "%1$s" törölve + Emlékeztető törölve + Emlékeztető törölve + Ismeretlen \ No newline at end of file diff --git a/app/src/main/res/values-id/strings.xml b/app/src/main/res/values-in/strings.xml similarity index 90% rename from app/src/main/res/values-id/strings.xml rename to app/src/main/res/values-in/strings.xml index bbabfa0..6c09365 100644 --- a/app/src/main/res/values-id/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -6,7 +6,6 @@ Simpan Lewati Buka Pengaturan - Gunakan Koordinat GPS Saat Ini Sedang Mencari GPS… Lokasi Pengingat @@ -36,12 +35,8 @@ Edit Lokasi Pengingat Baru Edit Pengingat - \"%1$s\" dihapus - Pengingat dibatalkan - Pengingat dihapus Tambah Lokasi Tambah Pengingat - Lokasi Pengingat Batal Pilih Lokasi @@ -63,7 +58,6 @@ Peta Pilih di Peta Gunakan GPS - Pemilih Peta Pilih Lokasi di Peta Lokasi Terpilih Konfirmasi Pilihan @@ -78,10 +72,6 @@ Sistem Terang Gelap - Batas geofence tercapai (maksimal 100). Anda tidak dapat mengaktifkan lebih banyak lagi. - Anda telah mencapai batas maksimum 100 geofence aktif. - Batas geofence 100 tercapai. Anda hanya dapat menambahkan pengingat ke lokasi dengan geofence aktif. - Batas geofence 100 tercapai. Harap pilih lokasi dengan pengingat aktif. Responsivitas Notifikasi %1$d menit 0 menit (Instan) @@ -116,4 +106,14 @@ Kembali Pusatkan pada GPS + Lokasi tersebut tidak ada lagi + %1$s: %2$.5f, %3$s: %4$.5f + %1$.5f, %2$.5f + + %d pengingat aktif + + "%1$s" dihapus + Pengingat dibatalkan + Pengingat dihapus + Tidak diketahui \ No newline at end of file diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 12585af..e3ef0cb 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -6,7 +6,6 @@ Salva Ignora Apri impostazioni - Usa coordinate GPS correnti Rilevamento GPS… Luoghi Promemoria @@ -36,12 +35,8 @@ Modifica luogo Nuovo promemoria Modifica promemoria - \"%1$s\" eliminato - Promemoria annullato - Promemoria eliminato Aggiungi luogo Aggiungi promemoria - Posizione Promemoria Annulla Seleziona luogo @@ -63,7 +58,6 @@ Mappa Seleziona sulla mappa Usa GPS - Selettore mappa Seleziona posizione sulla mappa Posizione selezionata Conferma selezione @@ -78,10 +72,6 @@ Sistema Chiaro Scuro - Limite di geofence raggiunto (massimo 100). Non puoi attivarne altri. - Hai raggiunto il limite massimo di 100 geofence attivi. - Limite di 100 geofence raggiunto. Puoi solo aggiungere promemoria a luoghi con geofence attivi. - Limite di 100 geofence raggiunto. Seleziona un luogo con promemoria attivi. Reattività delle notifiche %1$d min 0 min (Istantaneo) @@ -114,4 +104,16 @@ La mia posizione Indietro Centra su GPS + Questa posizione non esiste più + %1$s: %2$.5f, %3$s: %4$.5f + %1$.5f, %2$.5f + + %d promemoria attivo + %d di promemoria attivi + %d promemoria attivi + + "%1$s" eliminato + Promemoria annullato + Promemoria eliminato + Sconosciuto diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 34bab82..ed485cd 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -6,7 +6,6 @@ 保存 スキップ 設定を開く - 現在のGPS座標を使用 GPSを照会中… 場所 リマインダー @@ -36,12 +35,8 @@ 場所を編集 新しいリマインダー リマインダーを編集 - \"%1$s\" を削除しました - リマインダーをキャンセルしました - リマインダーを削除しました 場所を追加 リマインダーを追加 - 場所 リマインダー キャンセル 場所を選択 @@ -63,7 +58,6 @@ 地図 地図上で選択 GPSを使用 - マップピッカー 地図上の場所を選択 選択された場所 選択を確定 @@ -78,10 +72,6 @@ システム ライト ダーク - ジオフェンスの制限に達しました(最大100)。これ以上アクティブにできません。 - アクティブなジオフェンスの最大制限100に達しました。 - ジオフェンスの制限(100)に達しました。アクティブなジオフェンスがある場所のみリマインダーを追加できます。 - ジオフェンスの制限(100)に達しました。アクティブなリマインダーがある場所を選択してください。 通知の応答性 %1$d 分 0 分(即時) @@ -116,4 +106,14 @@ 戻る GPS位置に合わせる + その場所は存在しません + %1$s: %2$.5f、%3$s: %4$.5f + %1$.5f、%2$.5f + + 有効なリマインダー %d 件 + + 「%1$s」を削除しました + リマインダーをキャンセルしました + リマインダーを削除しました + 不明 \ No newline at end of file diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 8e17d0d..906186a 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -6,7 +6,6 @@ 저장 건너뛰기 설정 열기 - 현재 GPS 좌표 사용 GPS 조회 중… 장소 리마인더 @@ -36,12 +35,8 @@ 장소 편집 새 리마인더 리마인더 편집 - \"%1$s\" 삭제됨 - 리마인더 취소됨 - 리마인더 삭제됨 장소 추가 리마인더 추가 - 장소 리마인더 취소 장소 선택 @@ -63,7 +58,6 @@ 지도 지도에서 선택 GPS 사용 - 지도 피커 지도에서 장소 선택 선택된 장소 선택 확인 @@ -78,10 +72,6 @@ 시스템 라이트 다크 - 지오펜스 제한에 도달했습니다 (최대 100개). 더 이상 활성화할 수 없습니다. - 활성 지오펜스 최대 제한인 100개에 도달했습니다. - 지오펜스 제한 100개에 도달했습니다. 활성 지오펜스가 있는 장소에만 리마인더를 추가할 수 있습니다. - 지오펜스 제한 100개에 도달했습니다. 활성 리마인더가 있는 장소를 선택해 주세요. 알림 응답성 %1$d 분 0 분 (즉시) @@ -116,4 +106,14 @@ 뒤로 GPS 기준으로 정렬 + 해당 위치가 더 이상 존재하지 않습니다 + %1$s: %2$.5f, %3$s: %4$.5f + %1$.5f, %2$.5f + + 활성 알림 %d개 + + "%1$s" 삭제됨 + 알림 취소됨 + 알림이 삭제되었습니다 + 알 수 없음 \ No newline at end of file diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index a2d74d5..d23ff8f 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -6,7 +6,6 @@ Opslaan Overslaan Instellingen openen - Gebruik huidige GPS-coördinaten GPS opvragen… Locaties Herinneringen @@ -36,12 +35,8 @@ Locatie bewerken Nieuwe herinnering Herinnering bewerken - \"%1$s\" verwijderd - Herinnering geannuleerd - Herinnering verwijderd Locatie toevoegen Herinnering toevoegen - Locatie Herinnering Annuleren Locatie selecteren @@ -63,7 +58,6 @@ Kaart Selecteren op kaart GPS gebruiken - Kaartkiezer Locatie selecteren op kaart Geselecteerde locatie Selectie bevestigen @@ -78,10 +72,6 @@ Systeem Licht Donker - Geofence-limiet bereikt (maximaal 100). Je kunt er geen meer activeren. - Je hebt de maximale limiet van 100 actieve geofences bereikt. - Geofence-limiet van 100 bereikt. Je kunt alleen herinneringen toevoegen aan locaties met actieve geofences. - Geofence-limiet van 100 bereikt. Selecteer een locatie met actieve herinneringen. Meldingsresponsiviteit %1$d min 0 min (Direct) @@ -116,4 +106,15 @@ Terug Centreren op GPS + Deze locatie bestaat niet meer + %1$s: %2$.5f, %3$s: %4$.5f + %1$.5f, %2$.5f + + %d actieve herinnering + %d actieve herinneringen + + "%1$s" verwijderd + Herinnering geannuleerd + Herinnering verwijderd + Onbekend \ No newline at end of file diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index dfbce7c..89d217d 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -6,7 +6,6 @@ Zapisz Pomiń Otwórz ustawienia - Użyj aktualnych współrzędnych GPS Pytanie o GPS… Lokalizacje Przypomnienia @@ -36,12 +35,8 @@ Edytuj lokalizację Nowe przypomnienie Edytuj przypomnienie - \"%1$s\" usunięte - Przypomnienie anulowane - Przypomnienie usunięte Dodaj lokalizację Dodaj przypomnienie - Lokalizacja Przypomnienie Anuluj Wybierz lokalizację @@ -63,7 +58,6 @@ Mapa Wybierz na mapie Użyj GPS - Wybór mapy Wybierz lokalizację na mapie Wybrana lokalizacja Potwierdź wybór @@ -78,10 +72,6 @@ Systemowy Jasny Ciemny - Osiągnięto limit geofencingu (maksymalnie 100). Nie możesz aktywować więcej. - Osiągnąłeś maksymalny limit 100 aktywnych geofencingów. - Osiągnięto limit geofencingu 100. Możesz dodawać przypomnienia tylko do lokalizacji z aktywnymi geofencingami. - Osiągnięto limit geofencingu 100. Wybierz lokalizację z aktywnymi przypomnieniami. Reaktywność powiadomień %1$d min 0 min (natychmiast) @@ -116,4 +106,17 @@ Wstecz Wyśrodkuj na GPS + Ta lokalizacja już nie istnieje + %1$s: %2$.5f, %3$s: %4$.5f + %1$.5f, %2$.5f + + %d aktywne przypomnienie + %d aktywne przypomnienia + %d aktywnych przypomnień + %d aktywnego przypomnienia + + Usunięto "%1$s" + Przypomnienie anulowane + Przypomnienie usunięte + Nieznany \ No newline at end of file diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 8ce3c93..c405072 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -6,7 +6,6 @@ Salvar Pular Abrir configurações - Usar coordenadas GPS atuais Buscando GPS… Locais Lembretes @@ -36,12 +35,8 @@ Editar local Novo lembrete Editar lembrete - \"%1$s\" excluído - Lembrete cancelado - Lembrete excluído Adicionar local Adicionar lembrete - Localização Lembrete Cancelar Selecionar local @@ -63,7 +58,6 @@ Mapa Selecionar no mapa Usar GPS - Seletor de mapa Selecionar localização no mapa Localização selecionada Confirmar seleção @@ -78,10 +72,6 @@ Sistema Claro Escuro - Limite de geocercas atingido (máximo 100). Você não pode ativar mais. - Você atingiu o limite máximo de 100 geocercas ativas. - Limite de 100 geocercas atingido. Você só pode adicionar lembretes a locais com geocercas ativas. - Limite de 100 geocercas atingido. Selecione um local com lembretes ativos. Tempo de resposta das notificações %1$d min 0 min (Instantâneo) @@ -114,4 +104,16 @@ Minha localização Voltar Centrar no GPS + Essa localização já não existe + %1$s: %2$.5f, %3$s: %4$.5f + %1$.5f, %2$.5f + + %d lembrete ativo + %d de lembretes ativos + %d lembretes ativos + + "%1$s" excluída + Lembrete cancelado + Lembrete excluído + Desconhecido diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 0fa3662..2f3ec29 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -6,7 +6,6 @@ Сохранить Пропустить Открыть настройки - Использовать текущие координаты GPS Запрос GPS… Места Напоминания @@ -36,12 +35,8 @@ Редактировать место Новое напоминание Редактировать напоминание - \"%1$s\" удалено - Напоминание отменено - Напоминание удалено Добавить место Добавить напоминание - Место Напоминание Отмена Выберите место @@ -63,7 +58,6 @@ Карта Выбрать на карте Использовать GPS - Выбор карты Выберите место на карте Выбранное место Подтвердить выбор @@ -78,10 +72,6 @@ Системная Светлая Темная - Достигнут лимит геозон (максимум 100). Невозможно активировать больше. - Вы достигли максимального лимита в 100 активных геозон. - Достигнут лимит геозон в 100. Вы можете добавлять напоминания только к местам с активными геозонами. - Достигнут лимит геозон в 100. Пожалуйста, выберите место с активными напоминаниями. Отклик уведомлений %1$d мин 0 мин (мгновенно) @@ -116,4 +106,17 @@ Назад Центрировать по GPS + Это местоположение больше не существует + %1$s: %2$.5f, %3$s: %4$.5f + %1$.5f, %2$.5f + + %d активное напоминание + %d активных напоминания + %d активных напоминаний + %d активного напоминания + + "%1$s" удалено + Напоминание отменено + Напоминание удалено + Неизвестно \ No newline at end of file diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index df35e50..f8fc97b 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -6,7 +6,6 @@ Spara Skippa Öppna inställningar - Använd nuvarande GPS-koordinater Frågar efter GPS… Platser Påminnelser @@ -36,12 +35,8 @@ Redigera plats Ny påminnelse Redigera påminnelse - \"%1$s\" taggen bort - Påminnelsen avbruten - Påminnelsen taggen bort Lägg till plats Lägg till påminnelse - Plats Påminnelse Avbryt Välj plats @@ -63,7 +58,6 @@ Karta Välj på karta Använd GPS - Kartväljare Välj plats på karta Vald plats Bekräfta val @@ -78,10 +72,6 @@ System Ljust Mörkt - Geofence-gräns nådd (max 100). Du kan inte aktivera fler. - Du har nått den maximala gränsen på 100 aktiva geofences. - Geofence-gräns på 100 nådd. Du kan bara lägga till påminnelser i platser med aktiva geofences. - Geofence-gräns på 100 nådd. Välj en plats med aktiva påminnelser. Aviseringsrespons %1$d min 0 min (Direkt) @@ -116,4 +106,15 @@ Tillbaka Centrera på GPS + Den platsen finns inte längre + %1$s: %2$.5f, %3$s: %4$.5f + %1$.5f, %2$.5f + + %d aktiv påminnelse + %d aktiva påminnelser + + "%1$s" borttagen + Påminnelse avbruten + Påminnelse borttagen + Okänd \ No newline at end of file diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index f21f4f8..b090cc3 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -6,7 +6,6 @@ บันทึก ข้าม เปิดการตั้งค่า - ใช้พิกัด GPS ปัจจุบัน กำลังสอบถาม GPS… สถานที่ การแจ้งเตือน @@ -36,12 +35,8 @@ แก้ไขสถานที่ การแจ้งเตือนใหม่ แก้ไขการแจ้งเตือน - \"%1$s\" ถูกลบแล้ว - ยกเลิกการแจ้งเตือนแล้ว - ลบการแจ้งเตือนแล้ว เพิ่มสถานที่ เพิ่มการแจ้งเตือน - สถานที่ การแจ้งเตือน ยกเลิก เลือกสถานที่ @@ -63,7 +58,6 @@ แผนที่ เลือกบนแผนที่ ใช้ GPS - ตัวเลือกแผนที่ เลือกสถานที่บนแผนที่ สถานที่ที่เลือก ยืนยันการเลือก @@ -78,10 +72,6 @@ ระบบ สว่าง มืด - ถึงขีดจำกัด Geofence (สูงสุด 100) ไม่สามารถเปิดใช้งานเพิ่มได้ - คุณถึงขีดจำกัดสูงสุดของ Geofence ที่ใช้งานอยู่คือ 100 แล้ว - ถึงขีดจำกัด Geofence 100 คุณสามารถเพิ่มการแจ้งเตือนได้เฉพาะในสถานที่ที่มี Geofence ทำงานอยู่เท่านั้น - ถึงขีดจำกัด Geofence 100 โปรดเลือกสถานที่ที่มีการแจ้งเตือนที่ใช้งานอยู่ ความเร็วในการตอบสนองของการแจ้งเตือน %1$d นาที 0 นาที (ทันที) @@ -116,4 +106,14 @@ ย้อนกลับ จัดกึ่งกลางตาม GPS + ไม่พบสถานที่นี้แล้ว + %1$s: %2$.5f, %3$s: %4$.5f + %1$.5f, %2$.5f + + การแจ้งเตือนที่ใช้งานอยู่ %d รายการ + + ลบ "%1$s" แล้ว + ยกเลิกการแจ้งเตือนแล้ว + ลบการแจ้งเตือนแล้ว + ไม่ทราบ \ No newline at end of file diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 61872f8..db3c544 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -6,7 +6,6 @@ Kaydet Atla Ayarları Aç - Mevcut GPS Koordinatlarını Kullan GPS Sorgulanıyor… Konumlar Hatırlatıcılar @@ -36,12 +35,8 @@ Konumu Düzenle Yeni Hatırlatıcı Hatırlatıcıyı Düzenle - \"%1$s\" silindi - Hatırlatıcı iptal edildi - Hatırlatıcı silindi Konum Ekle Hatırlatıcı Ekle - Konum Hatırlatıcı İptal Konum Seçin @@ -63,7 +58,6 @@ Harita Haritada Seç GPS Kullan - Harita Seçici Haritadan Konum Seçin Seçilen Konum Seçimi Onayla @@ -78,10 +72,6 @@ Sistem Açık Koyu - Geofence sınırı aşıldı (maksimum 100). Daha fazlasını aktif edemezsiniz. - Aktif geofence sayınız maksimum sınır olan 100\'e ulaştı. - Geofence sınırı 100\'e ulaşıldı. Sadece aktif geofence\'i olan konumlara hatırlatıcı ekleyebilirsiniz. - Geofence sınırı 100\'e ulaşıldı. Lütfen aktif hatırlatıcısı olan bir konum seçin. Bildirim Yanıt Süresi %1$d dk 0 dk (Anında) @@ -116,4 +106,15 @@ Geri GPS üzerinde merkezle + Bu konum artık mevcut değil + %1$s: %2$.5f, %3$s: %4$.5f + %1$.5f, %2$.5f + + %d etkin hatırlatıcı + %d etkin hatırlatıcı + + "%1$s" silindi + Hatırlatıcı iptal edildi + Hatırlatıcı silindi + Bilinmiyor \ No newline at end of file diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index bdec4e5..fc3408a 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -6,7 +6,6 @@ Lưu Bỏ qua Mở Cài đặt - Sử dụng Tọa độ GPS Hiện tại Đang truy vấn GPS… Địa điểm Nhắc nhở @@ -36,12 +35,8 @@ Chỉnh sửa địa điểm Nhắc nhở mới Chỉnh sửa nhắc nhở - \"%1$s\" đã bị xóa - Nhắc nhở đã bị hủy - Nhắc nhở đã bị xóa Thêm địa điểm Thêm nhắc nhở - Địa điểm Nhắc nhở Hủy Chọn địa điểm @@ -63,7 +58,6 @@ Bản đồ Chọn trên bản đồ Sử dụng GPS - Bộ chọn bản đồ Chọn địa điểm trên bản đồ Địa điểm đã chọn Xác nhận lựa chọn @@ -78,10 +72,6 @@ Hệ thống Sáng Tối - Đã đạt giới hạn hàng rào địa lý (tối đa 100). Không thể kích hoạt thêm. - Bạn đã đạt giới hạn tối đa là 100 hàng rào địa lý đang hoạt động. - Đã đạt giới hạn hàng rào địa lý là 100. Bạn chỉ có thể thêm nhắc nhở vào các địa điểm có hàng rào địa lý đang hoạt động. - Đã đạt giới hạn hàng rào địa lý là 100. Vui lòng chọn một địa điểm có nhắc nhở đang hoạt động. Độ phản hồi thông báo %1$d phút 0 phút (Tức thì) @@ -116,4 +106,14 @@ Quay lại Căn giữa theo GPS + Địa điểm đó không còn tồn tại + %1$s: %2$.5f, %3$s: %4$.5f + %1$.5f, %2$.5f + + %d lời nhắc đang hoạt động + + Đã xóa "%1$s" + Đã hủy lời nhắc + Đã xóa lời nhắc + Không xác định \ No newline at end of file diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index e35db0c..43d999d 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -6,7 +6,6 @@ 保存 跳过 打开设置 - 使用当前 GPS 坐标 正在查询 GPS… 位置 提醒 @@ -36,12 +35,8 @@ 编辑位置 新提醒 编辑提醒 - \"%1$s\" 已删除 - 提醒已取消 - 提醒已删除 添加位置 添加提醒 - 位置 提醒 取消 选择位置 @@ -63,7 +58,6 @@ 地图 在地图上选择 使用 GPS - 地图选择器 在地图上选择位置 已选位置 确认选择 @@ -78,10 +72,6 @@ 系统 浅色 深色 - 达到地理围栏限制(最大 100 个)。无法激活更多。 - 您已达到 100 个活动地理围栏的最大限制。 - 达到 100 个地理围栏限制。您只能向具有活动地理围栏的位置添加提醒。 - 达到 100 个地理围栏限制。请选择具有活动提醒的位置。 通知响应速度 %1$d 分钟 0 分钟(即时) @@ -116,4 +106,14 @@ 返回 对齐到GPS + 该位置已不存在 + %1$s: %2$.5f, %3$s: %4$.5f + %1$.5f, %2$.5f + + %d 个活动提醒 + + 已删除“%1$s” + 已取消提醒 + 已删除提醒 + 未知 \ No newline at end of file diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 432652d..b7700e4 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -1,11 +1,4 @@ - #FFBB86FC - #FF6200EE - #FF3700B3 - #FF03DAC5 - #FF018786 - #FF000000 - #FFFFFFFF #2489D9 - \ No newline at end of file + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a7a26b2..fbd0cd3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -6,7 +6,6 @@ Save Skip Open Settings - Use Current GPS Coordinates Querying GPS… Locations Reminders @@ -36,12 +35,8 @@ Edit Location New Reminder Edit Reminder - \"%1$s\" deleted - Reminder cancelled - Reminder deleted Add Location Add Reminder - Location Reminder Cancel Select Location @@ -57,18 +52,24 @@ Geofence Reminders Notifications triggered when you enter or leave a saved location Failed to obtain GPS coordinates. Please make sure location services are enabled. + That location no longer exists Background location permission is missing. Geofences will not trigger until you grant \"Allow all the time\" permission. Fix List Map Select on Map Use GPS - Map Picker Select Location on Map Selected Location Confirm Selection Latitude Longitude + %1$s: %2$.5f, %3$s: %4$.5f + %1$.5f, %2$.5f + + %d active reminder + %d active reminders + Settings App Theme Choose whether the application uses light, dark, or system theme. @@ -78,10 +79,6 @@ System Light Dark - Geofence limit reached (maximum 100). You cannot activate more. - You have reached the maximum limit of 100 active geofences. - Geofence limit of 100 reached. You can only add reminders to locations with active geofences. - Geofence limit of 100 reached. Please select a location with active reminders. Notification Responsiveness %1$d min 0 min (Instant) @@ -114,4 +111,8 @@ My Location Back Center on GPS + "%1$s" deleted + Reminder cancelled + Reminder deleted + Unknown \ No newline at end of file diff --git a/app/src/test/java/dev/arrase/geotify/GeofenceLimitTest.kt b/app/src/test/java/dev/arrase/geotify/GeofenceLimitTest.kt deleted file mode 100644 index 379047f..0000000 --- a/app/src/test/java/dev/arrase/geotify/GeofenceLimitTest.kt +++ /dev/null @@ -1,108 +0,0 @@ -package dev.arrase.geotify - -import dev.arrase.geotify.data.ReminderRepository -import dev.arrase.geotify.data.dao.ReminderDao -import dev.arrase.geotify.data.entity.LocationEntity -import dev.arrase.geotify.data.entity.LocationReminderCount -import dev.arrase.geotify.data.entity.ReminderEntity -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.runBlocking -import org.junit.Assert.* -import org.junit.Test - -class GeofenceLimitTest { - - private val locationsList = mutableListOf() - private val remindersList = mutableListOf() - - private val fakeReminderDao = object : ReminderDao { - override fun observeActiveReminderCounts(): Flow> = flowOf(emptyList()) - override fun observeAll(): Flow> = flowOf(remindersList) - override suspend fun getActiveReminders(): List = - remindersList.filter { it.isActive } - - override suspend fun getActiveByLocationId(locationId: String): List = - remindersList.filter { it.locationId == locationId && it.isActive } - - override suspend fun insert(reminder: ReminderEntity) { - remindersList.add(reminder) - } - override suspend fun update(reminder: ReminderEntity) { - val idx = remindersList.indexOfFirst { it.id == reminder.id } - if (idx != -1) remindersList[idx] = reminder - } - override suspend fun deactivate(id: String) { - val idx = remindersList.indexOfFirst { it.id == id } - if (idx != -1) { - remindersList[idx] = remindersList[idx].copy(isActive = false) - } - } - override suspend fun deleteById(id: String): Int { - val count = remindersList.size - remindersList.removeAll { it.id == id } - return count - remindersList.size - } - - override suspend fun getActiveGeofenceCount(): Int = - remindersList.filter { it.isActive }.map { it.locationId }.distinct().size - override suspend fun clearAllInRange() {} - override suspend fun setInRangeForLocations(locationIds: List) {} - } - - private val reminderRepository = ReminderRepository( - fakeReminderDao, - kotlinx.coroutines.Dispatchers.Unconfined - ) - - @Test - fun testCreateManyRemindersWithoutLimit() = runBlocking { - locationsList.clear() - remindersList.clear() - - val locations = (1..150).map { i -> - LocationEntity( - id = "loc_$i", - alias = "Location $i", - latitude = 0.0, - longitude = 0.0, - radiusMeters = 100f - ).also { locationsList.add(it) } - } - - for (i in 0 until 150) { - val reminder = reminderRepository.createReminder(locations[i], "Reminder $i", 1) - assertNotNull(reminder) - } - assertEquals(150, fakeReminderDao.getActiveGeofenceCount()) - } - - @Test - fun testDeactivateAndUpdateReminder() = runBlocking { - locationsList.clear() - remindersList.clear() - - val locations = (1..150).map { i -> - LocationEntity( - id = "loc_$i", - alias = "Location $i", - latitude = 0.0, - longitude = 0.0, - radiusMeters = 100f - ).also { locationsList.add(it) } - } - - for (i in 0 until 149) { - reminderRepository.createReminder(locations[i], "Reminder $i", 1) - } - - val reminder150 = reminderRepository.createReminder(locations[149], "Reminder 150", 1) - assertEquals(150, fakeReminderDao.getActiveGeofenceCount()) - - reminderRepository.deactivateReminder(reminder150.id) - assertEquals(149, fakeReminderDao.getActiveGeofenceCount()) - - reminderRepository.updateReminder(reminder150.copy(isActive = true)) - assertEquals(150, fakeReminderDao.getActiveGeofenceCount()) - } -} diff --git a/app/src/test/java/dev/arrase/geotify/appfunction/GeotifyAppFunctionsTest.kt b/app/src/test/java/dev/arrase/geotify/appfunction/GeotifyAppFunctionsTest.kt index e34c1ef..dbf237f 100644 --- a/app/src/test/java/dev/arrase/geotify/appfunction/GeotifyAppFunctionsTest.kt +++ b/app/src/test/java/dev/arrase/geotify/appfunction/GeotifyAppFunctionsTest.kt @@ -8,6 +8,7 @@ import dev.arrase.geotify.data.LocationRepository import dev.arrase.geotify.data.ReminderRepository import dev.arrase.geotify.data.entity.LocationEntity import dev.arrase.geotify.data.entity.ReminderEntity +import dev.arrase.geotify.geofence.GeofenceOrchestrator import dev.arrase.geotify.location.LocationProvider import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals @@ -47,6 +48,7 @@ class GeotifyAppFunctionsTest { private val locationRepository: LocationRepository = mock() private val reminderRepository: ReminderRepository = mock() private val locationProvider: LocationProvider = mock() + private val geofenceOrchestrator: GeofenceOrchestrator = mock() private val appFunctionContext: AppFunctionContext = mock() private lateinit var appFunctions: GeotifyAppFunctions @@ -56,8 +58,14 @@ class GeotifyAppFunctionsTest { appFunctions = GeotifyAppFunctions( locationRepository = locationRepository, reminderRepository = reminderRepository, + geofenceOrchestrator = geofenceOrchestrator, locationProvider = locationProvider ) + runBlocking { + // Deletions report the number of affected rows. + whenever(locationRepository.deleteLocation(any())).thenReturn(true) + whenever(reminderRepository.cancelReminder(any())).thenReturn(true) + } } // ── saveCurrentLocation tests ── diff --git a/app/src/test/java/dev/arrase/geotify/data/GeofenceLimitTest.kt b/app/src/test/java/dev/arrase/geotify/data/GeofenceLimitTest.kt new file mode 100644 index 0000000..3444806 --- /dev/null +++ b/app/src/test/java/dev/arrase/geotify/data/GeofenceLimitTest.kt @@ -0,0 +1,76 @@ +package dev.arrase.geotify.data + +import com.google.android.gms.location.Geofence +import dev.arrase.geotify.data.dao.ReminderDao +import dev.arrase.geotify.data.entity.LocationEntity +import dev.arrase.geotify.data.entity.ReminderEntity +import dev.arrase.geotify.domain.SpatialSearchUseCase +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +/** + * The app never blocks reminder creation; instead the geofence window registered with Play services + * is capped, and only the closest locations are monitored. These tests pin that budget down so it + * cannot drift past the GMS limit of 100 geofences per app. + */ +class GeofenceLimitTest { + + private val reminderDao: ReminderDao = mock() + private val repository = ReminderRepository(reminderDao, Dispatchers.Unconfined) + + private val locations = (1..150).map { index -> + LocationEntity( + id = "loc_$index", + alias = "Location $index", + latitude = 0.0, + longitude = 0.0, + radiusMeters = 100f + ) + } + + @Test + fun `geofence budget leaves exactly one slot for the master geofence`() { + assertEquals(100, SpatialSearchUseCase.MAX_POI_GEOFENCES + 1) + } + + @Test + fun `creating more reminders than the budget is allowed and tracked`() = runBlocking { + val created = locations.mapIndexed { index, location -> + repository.createReminder(location, "Reminder $index", Geofence.GEOFENCE_TRANSITION_ENTER) + } + + assertEquals(150, created.size) + assertTrue(created.size > SpatialSearchUseCase.MAX_POI_GEOFENCES) + created.forEach { verify(reminderDao).insert(it) } + } + + @Test + fun `deactivating and reactivating a reminder round-trips its state`() = runBlocking { + val reminder = repository.createReminder( + locations.first(), + "Buy milk", + Geofence.GEOFENCE_TRANSITION_ENTER + ) + + repository.deactivateReminder(reminder.id) + verify(reminderDao).deactivate(reminder.id) + + val reactivated: ReminderEntity = reminder.copy(isActive = true) + repository.updateReminder(reactivated) + verify(reminderDao).update(reactivated) + } + + @Test + fun `getActiveReminders returns exactly what the dao reports`() = runBlocking { + val active = listOf(ReminderEntity("r1", "loc_1", "Msg", 1, true, 0L)) + whenever(reminderDao.getActiveReminders()).thenReturn(active) + + assertEquals(active, repository.getActiveReminders()) + } +} diff --git a/app/src/test/java/dev/arrase/geotify/data/LocationRepositoryTest.kt b/app/src/test/java/dev/arrase/geotify/data/LocationRepositoryTest.kt index bfeaa42..ecc8fcd 100644 --- a/app/src/test/java/dev/arrase/geotify/data/LocationRepositoryTest.kt +++ b/app/src/test/java/dev/arrase/geotify/data/LocationRepositoryTest.kt @@ -7,9 +7,11 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.mockito.kotlin.any @@ -29,6 +31,11 @@ class LocationRepositoryTest { locationDao = locationDao, ioDispatcher = Dispatchers.Unconfined ) + runBlocking { + // Room returns the new row id, or -1 when the unique alias conflicts. + whenever(locationDao.insert(any())).thenReturn(1L) + whenever(locationDao.deleteByAlias(any())).thenReturn(1) + } } @Test @@ -69,6 +76,44 @@ class LocationRepositoryTest { } } + @Test + fun saveLocation_trimsTheAlias() { + runBlocking { + val result = repository.saveLocation(" Home ", 40.0, -3.0) + + assertEquals("Home", result.alias) + } + } + + @Test + fun saveLocation_rejectsABlankAlias() { + assertThrows(IllegalArgumentException::class.java) { + runBlocking { repository.saveLocation(" ", 40.0, -3.0) } + } + } + + @Test + fun saveLocation_throwsDuplicateAliasExceptionWhenTheAliasIsTaken() { + runBlocking { whenever(locationDao.insert(any())).thenReturn(-1L) } + + val error = assertThrows(DuplicateAliasException::class.java) { + runBlocking { repository.saveLocation("Home", 40.0, -3.0) } + } + assertTrue(error.message!!.contains("Home")) + } + + @Test + fun updateLocation_rejectsNegativeNotificationResponsiveness() { + val location = LocationEntity( + id = "1", alias = "Home", latitude = 40.0, longitude = -3.0, + notificationResponsivenessMs = -1 + ) + + assertThrows(IllegalArgumentException::class.java) { + runBlocking { repository.updateLocation(location) } + } + } + @Test fun saveLocation_withLatitudeBelowMinimum_throwsIllegalArgumentException() { assertThrows(IllegalArgumentException::class.java) { @@ -277,12 +322,31 @@ class LocationRepositoryTest { @Test fun deleteLocation_callsDaoDeleteByAlias() { runBlocking { - repository.deleteLocation("OldPlace") + assertTrue(repository.deleteLocation("OldPlace")) verify(locationDao).deleteByAlias("OldPlace") } } + @Test + fun deleteLocation_reportsWhenNothingWasRemoved() { + runBlocking { whenever(locationDao.deleteByAlias(any())).thenReturn(0) } + + runBlocking { assertFalse(repository.deleteLocation("Missing")) } + } + + @Test + fun findLocationsInBoundingBox_delegatesToDao() { + runBlocking { + val matches = listOf(LocationEntity("1", "Near", 0.0, 0.0)) + whenever( + locationDao.getLocationsInBoundingBox(1.0, 2.0, 3.0, 4.0) + ).thenReturn(matches) + + assertEquals(matches, repository.findLocationsInBoundingBox(1.0, 2.0, 3.0, 4.0)) + } + } + @Test fun observeLocations_delegatesToDaoObserveAll() { runBlocking { diff --git a/app/src/test/java/dev/arrase/geotify/data/ReminderRepositoryTest.kt b/app/src/test/java/dev/arrase/geotify/data/ReminderRepositoryTest.kt index ffd2ed3..a013d1c 100644 --- a/app/src/test/java/dev/arrase/geotify/data/ReminderRepositoryTest.kt +++ b/app/src/test/java/dev/arrase/geotify/data/ReminderRepositoryTest.kt @@ -1,5 +1,6 @@ package dev.arrase.geotify.data +import com.google.android.gms.location.Geofence import dev.arrase.geotify.data.dao.ReminderDao import dev.arrase.geotify.data.entity.LocationEntity import dev.arrase.geotify.data.entity.LocationReminderCount @@ -9,10 +10,13 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull import org.junit.Assert.assertTrue +import org.junit.Assert.fail import org.junit.Before import org.junit.Test +import org.mockito.kotlin.any import org.mockito.kotlin.mock import org.mockito.kotlin.verify import org.mockito.kotlin.whenever @@ -28,6 +32,7 @@ class ReminderRepositoryTest { reminderDao = reminderDao, ioDispatcher = Dispatchers.Unconfined ) + runBlocking { whenever(reminderDao.deleteById(any())).thenReturn(1) } } @Test @@ -150,6 +155,65 @@ class ReminderRepositoryTest { } } + @Test + fun createReminder_rejectsBlankMessage() { + runBlocking { + val location = LocationEntity("loc-abc", "Supermarket", 40.0, -3.0) + try { + repository.createReminder(location, " ", 1) + fail("expected IllegalArgumentException") + } catch (e: IllegalArgumentException) { + assertTrue(e.message!!.contains("message")) + } + } + } + + @Test + fun createReminder_rejectsUnsupportedTransitionType() { + runBlocking { + val location = LocationEntity("loc-abc", "Supermarket", 40.0, -3.0) + try { + repository.createReminder(location, "Buy milk", Geofence.GEOFENCE_TRANSITION_DWELL) + fail("expected IllegalArgumentException") + } catch (e: IllegalArgumentException) { + assertTrue(e.message!!.contains("transition")) + } + } + } + + @Test + fun updateReminder_rejectsUnsupportedTransitionType() { + runBlocking { + val reminder = ReminderEntity("rem-1", "loc-1", "Msg", 42, true, 0L) + try { + repository.updateReminder(reminder) + fail("expected IllegalArgumentException") + } catch (e: IllegalArgumentException) { + assertTrue(e.message!!.contains("transition")) + } + } + } + + @Test + fun cancelReminder_reportsWhetherARowWasDeleted() { + runBlocking { + whenever(reminderDao.deleteById("rem-1")).thenReturn(1) + assertTrue(repository.cancelReminder("rem-1")) + + whenever(reminderDao.deleteById("missing")).thenReturn(0) + assertFalse(repository.cancelReminder("missing")) + } + } + + @Test + fun createReminder_trimsTheMessage() { + runBlocking { + val location = LocationEntity("loc-abc", "Supermarket", 40.0, -3.0) + val reminder = repository.createReminder(location, " Buy milk ", 1) + assertEquals("Buy milk", reminder.message) + } + } + @Test fun observeActiveReminderCounts_delegatesToDao() { runBlocking { diff --git a/app/src/test/java/dev/arrase/geotify/data/entity/EntityTest.kt b/app/src/test/java/dev/arrase/geotify/data/entity/EntityTest.kt index a1d47de..e35702b 100644 --- a/app/src/test/java/dev/arrase/geotify/data/entity/EntityTest.kt +++ b/app/src/test/java/dev/arrase/geotify/data/entity/EntityTest.kt @@ -146,7 +146,8 @@ class EntityTest { assertFalse(reminder.isArrival) assertFalse(reminder.isDeparture) - assertEquals("departure", reminder.triggerTypeString) + // Unsupported transitions must not be reported as a departure. + assertEquals("unknown", reminder.triggerTypeString) } @Test diff --git a/app/src/test/java/dev/arrase/geotify/domain/SpatialSearchUseCaseTest.kt b/app/src/test/java/dev/arrase/geotify/domain/SpatialSearchUseCaseTest.kt index 8dbc09e..7fbad93 100644 --- a/app/src/test/java/dev/arrase/geotify/domain/SpatialSearchUseCaseTest.kt +++ b/app/src/test/java/dev/arrase/geotify/domain/SpatialSearchUseCaseTest.kt @@ -1,12 +1,15 @@ package dev.arrase.geotify.domain import android.location.Location -import dev.arrase.geotify.data.dao.LocationDao +import dev.arrase.geotify.data.LocationRepository import dev.arrase.geotify.data.entity.LocationEntity -import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue +import org.junit.Assert.fail import org.junit.Before import org.junit.Test import org.mockito.ArgumentCaptor @@ -18,32 +21,28 @@ import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import kotlin.math.cos +@OptIn(ExperimentalCoroutinesApi::class) class SpatialSearchUseCaseTest { - private val locationDao: LocationDao = mock() + private val locationRepository: LocationRepository = mock() private lateinit var useCase: SpatialSearchUseCase private lateinit var mockedLocation: MockedStatic private var distanceCalculator: (Double, Double, Double, Double) -> Float = { _, _, _, _ -> 0f } @Before fun setUp() { - useCase = SpatialSearchUseCase(locationDao) + useCase = SpatialSearchUseCase(locationRepository, UnconfinedTestDispatcher()) mockedLocation = mockStatic(Location::class.java) mockedLocation.`when` { - Location.distanceBetween( - any(), - any(), - any(), - any(), - any() - ) + Location.distanceBetween(any(), any(), any(), any(), any()) }.thenAnswer { invocation -> - val startLat = invocation.getArgument(0) - val startLon = invocation.getArgument(1) - val destLat = invocation.getArgument(2) - val destLon = invocation.getArgument(3) val buffer = invocation.getArgument(4) - buffer[0] = distanceCalculator(startLat, startLon, destLat, destLon) + buffer[0] = distanceCalculator( + invocation.getArgument(0), + invocation.getArgument(1), + invocation.getArgument(2), + invocation.getArgument(3) + ) null } } @@ -54,132 +53,143 @@ class SpatialSearchUseCaseTest { } @Test - fun execute_computesCorrectBoundingBox() = runBlocking { + fun `computes the expected bounding box`() = runTest { val centerLat = 40.0 val centerLon = -3.0 val radiusKm = 10f - val radiusMeters = 10000.0 - val latDegreesChange = radiusMeters / 111320.0 - val cosLat = cos(Math.toRadians(centerLat)) - val lonDegreesChange = radiusMeters / (111320.0 * cosLat) + val latDelta = radiusKm * 1000.0 / 111_320.0 + val lonDelta = radiusKm * 1000.0 / (111_320.0 * cos(Math.toRadians(centerLat))) - val expectedMinLat = centerLat - latDegreesChange - val expectedMaxLat = centerLat + latDegreesChange - val expectedMinLon = centerLon - lonDegreesChange - val expectedMaxLon = centerLon + lonDegreesChange + givenNoCandidates() - whenever(locationDao.getLocationsInBoundingBox(any(), any(), any(), any())) - .thenReturn(emptyList()) + useCase(centerLat, centerLon, radiusKm) - useCase.execute(centerLat, centerLon, radiusKm) + val box = captureBoundingBox() + assertEquals(centerLat - latDelta, box.minLat, 0.0001) + assertEquals(centerLat + latDelta, box.maxLat, 0.0001) + assertEquals(centerLon - lonDelta, box.minLon, 0.0001) + assertEquals(centerLon + lonDelta, box.maxLon, 0.0001) + } - val minLatCaptor = ArgumentCaptor.forClass(Double::class.java) - val maxLatCaptor = ArgumentCaptor.forClass(Double::class.java) - val minLonCaptor = ArgumentCaptor.forClass(Double::class.java) - val maxLonCaptor = ArgumentCaptor.forClass(Double::class.java) + @Test + fun `spans all longitudes at the poles`() = runTest { + givenNoCandidates() - verify(locationDao).getLocationsInBoundingBox( - minLatCaptor.capture(), - maxLatCaptor.capture(), - minLonCaptor.capture(), - maxLonCaptor.capture() - ) + useCase(centerLat = 90.0, centerLon = 0.0, radiusKm = 5f) - assertEquals(expectedMinLat, minLatCaptor.value, 0.0001) - assertEquals(expectedMaxLat, maxLatCaptor.value, 0.0001) - assertEquals(expectedMinLon, minLonCaptor.value, 0.0001) - assertEquals(expectedMaxLon, maxLonCaptor.value, 0.0001) + val box = captureBoundingBox() + assertEquals(-360.0, box.minLon, 0.0001) + assertEquals(360.0, box.maxLon, 0.0001) } @Test - fun execute_whenCosLatNonPositive_uses360DegreesChange() = runBlocking { - val centerLat = 100.0 - val centerLon = 10.0 - val radiusKm = 5f - - whenever(locationDao.getLocationsInBoundingBox(any(), any(), any(), any())) - .thenReturn(emptyList()) + fun `produces a window that crosses the antimeridian near longitude 180`() = runTest { + givenNoCandidates() - useCase.execute(centerLat, centerLon, radiusKm) + // 500 km at the equator is ~4.5 degrees of longitude, enough to pass 180 from 179. + useCase(centerLat = 0.0, centerLon = 179.0, radiusKm = 500f) - val minLonCaptor = ArgumentCaptor.forClass(Double::class.java) - val maxLonCaptor = ArgumentCaptor.forClass(Double::class.java) - - verify(locationDao).getLocationsInBoundingBox( - any(), - any(), - minLonCaptor.capture(), - maxLonCaptor.capture() - ) + val box = captureBoundingBox() + assertTrue("expected maxLon > 180 but was ${box.maxLon}", box.maxLon > 180.0) + assertTrue(box.minLon > 0.0) + } - assertEquals(centerLon - 360.0, minLonCaptor.value, 0.0001) - assertEquals(centerLon + 360.0, maxLonCaptor.value, 0.0001) + @Test + fun `rejects an out-of-range latitude`() = runTest { + assertRejects { useCase(centerLat = 91.0, centerLon = 0.0, radiusKm = 1f) } } @Test - fun execute_filtersCandidatesOutsideRadius_andSortsByDistanceAscending() = runBlocking { - val candidateClose = LocationEntity("1", "Close", 40.01, -3.01) - val candidateMedium = LocationEntity("2", "Medium", 40.02, -3.02) - val candidateFar = LocationEntity("3", "Far", 40.10, -3.10) + fun `rejects an out-of-range longitude`() = runTest { + assertRejects { useCase(centerLat = 0.0, centerLon = 181.0, radiusKm = 1f) } + } - val candidates = listOf(candidateMedium, candidateFar, candidateClose) - whenever(locationDao.getLocationsInBoundingBox(any(), any(), any(), any())) - .thenReturn(candidates) + @Test + fun `rejects a negative radius`() = runTest { + assertRejects { useCase(centerLat = 0.0, centerLon = 0.0, radiusKm = -1f) } + } + @Test + fun `filters candidates beyond the radius and sorts by distance`() = runTest { + val close = location("1", 40.01, -3.01) + val medium = location("2", 40.02, -3.02) + val far = location("3", 40.10, -3.10) + givenCandidates(medium, far, close) distanceCalculator = { _, _, destLat, _ -> when (destLat) { - 40.01 -> 1000f - 40.02 -> 3000f - 40.10 -> 8000f - else -> 0f + 40.01 -> 1_000f + 40.02 -> 3_000f + else -> 8_000f } } - val result = useCase.execute(40.0, -3.0, 5.0f) + val result = useCase(40.0, -3.0, 5.0f) - assertEquals(2, result.size) - assertEquals(candidateClose, result[0]) - assertEquals(candidateMedium, result[1]) + assertEquals(listOf(close, medium), result) } @Test - fun execute_limitsResultsToMaxPoiGeofences() = runBlocking { - val candidates = (0 until 120).map { i -> - LocationEntity( - id = "id-$i", - alias = "Alias $i", - latitude = 40.0 + (i * 0.001), - longitude = -3.0 - ) + fun `limits results to the GMS geofence budget`() = runTest { + val candidates = (0 until 120).map { index -> + location("id-$index", 40.0 + index * 0.001, -3.0) } - whenever(locationDao.getLocationsInBoundingBox(any(), any(), any(), any())) - .thenReturn(candidates) - + givenCandidates(*candidates.toTypedArray()) distanceCalculator = { _, _, destLat, _ -> - val index = Math.round((destLat - 40.0) / 0.001).toInt() - index * 10f + (Math.round((destLat - 40.0) / 0.001) * 10).toFloat() } - val result = useCase.execute(40.0, -3.0, 10.0f) + val result = useCase(40.0, -3.0, 10.0f) assertEquals(SpatialSearchUseCase.MAX_POI_GEOFENCES, result.size) - assertEquals(99, result.size) assertEquals("id-0", result.first().id) assertEquals("id-98", result.last().id) } @Test - fun execute_whenAllCandidatesOutsideRadius_returnsEmptyList() = runBlocking { - val candidates = listOf( - LocationEntity("1", "Too Far", 40.5, -3.5) - ) - whenever(locationDao.getLocationsInBoundingBox(any(), any(), any(), any())) - .thenReturn(candidates) + fun `returns nothing when every candidate is out of range`() = runTest { + givenCandidates(location("1", 40.5, -3.5)) + distanceCalculator = { _, _, _, _ -> 20_000f } - distanceCalculator = { _, _, _, _ -> 20000f } + assertTrue(useCase(40.0, -3.0, 5.0f).isEmpty()) + } + + private suspend fun assertRejects(block: suspend () -> Unit) { + try { + block() + fail("expected IllegalArgumentException") + } catch (e: IllegalArgumentException) { + assertTrue(e.message.orEmpty().isNotBlank()) + } + } - val result = useCase.execute(40.0, -3.0, 5.0f) + private fun location(id: String, latitude: Double, longitude: Double) = + LocationEntity(id = id, alias = "Alias $id", latitude = latitude, longitude = longitude) - assertTrue(result.isEmpty()) + private suspend fun givenNoCandidates() { + whenever(locationRepository.findLocationsInBoundingBox(any(), any(), any(), any())) + .thenReturn(emptyList()) + } + + private suspend fun givenCandidates(vararg candidates: LocationEntity) { + whenever(locationRepository.findLocationsInBoundingBox(any(), any(), any(), any())) + .thenReturn(candidates.toList()) } + + private suspend fun captureBoundingBox(): BoundingBox { + val minLat = ArgumentCaptor.forClass(Double::class.java) + val maxLat = ArgumentCaptor.forClass(Double::class.java) + val minLon = ArgumentCaptor.forClass(Double::class.java) + val maxLon = ArgumentCaptor.forClass(Double::class.java) + verify(locationRepository).findLocationsInBoundingBox( + minLat.capture(), maxLat.capture(), minLon.capture(), maxLon.capture() + ) + return BoundingBox(minLat.value, maxLat.value, minLon.value, maxLon.value) + } + + private data class BoundingBox( + val minLat: Double, + val maxLat: Double, + val minLon: Double, + val maxLon: Double + ) } diff --git a/app/src/test/java/dev/arrase/geotify/ui/screen/LocationsViewModelTest.kt b/app/src/test/java/dev/arrase/geotify/ui/screen/LocationsViewModelTest.kt index 0305d55..0bcb058 100644 --- a/app/src/test/java/dev/arrase/geotify/ui/screen/LocationsViewModelTest.kt +++ b/app/src/test/java/dev/arrase/geotify/ui/screen/LocationsViewModelTest.kt @@ -1,6 +1,7 @@ package dev.arrase.geotify.ui.screen import android.location.Location +import dev.arrase.geotify.R import dev.arrase.geotify.data.LocationRepository import dev.arrase.geotify.data.ReminderRepository import dev.arrase.geotify.data.SettingsDefaults @@ -14,6 +15,7 @@ import dev.arrase.geotify.ui.UiText import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.launch import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.resetMain @@ -41,23 +43,15 @@ class LocationsViewModelTest { private val settingsManager: SettingsManager = mock() private val testDispatcher = UnconfinedTestDispatcher() - private lateinit var viewModel: LocationsViewModel + private val messages = mutableListOf() @Before fun setUp() { Dispatchers.setMain(testDispatcher) - + runBlocking { whenever(locationRepository.deleteLocation(any())).thenReturn(true) } whenever(locationRepository.observeLocations()).thenReturn(flowOf(emptyList())) whenever(reminderRepository.observeActiveReminderCounts()).thenReturn(flowOf(emptyList())) whenever(settingsManager.mapTheme).thenReturn(flowOf(SettingsDefaults.MAP_THEME)) - - viewModel = LocationsViewModel( - locationRepository = locationRepository, - reminderRepository = reminderRepository, - geofenceOrchestrator = geofenceOrchestrator, - locationProvider = locationProvider, - settingsManager = settingsManager - ) } @After @@ -65,224 +59,177 @@ class LocationsViewModelTest { Dispatchers.resetMain() } - // ── StateFlows initialization tests ── + private fun buildViewModel(): LocationsViewModel = LocationsViewModel( + locationRepository = locationRepository, + reminderRepository = reminderRepository, + geofenceOrchestrator = geofenceOrchestrator, + locationProvider = locationProvider, + settingsManager = settingsManager + ) + + private fun LocationsViewModel.collectingMessages(scope: kotlinx.coroutines.CoroutineScope) = + scope.launch(testDispatcher) { messagesFlow.collect { messages.add(it) } } + + // ── State exposure ── @Test - fun locations_emitsFlowFromRepository() { - runTest { - val entities = listOf(LocationEntity("1", "Casa", 40.0, -3.0)) - whenever(locationRepository.observeLocations()).thenReturn(flowOf(entities)) - - val vm = LocationsViewModel( - locationRepository, reminderRepository, geofenceOrchestrator, locationProvider, settingsManager - ) - backgroundScope.launch(testDispatcher) { vm.locations.collect {} } - - assertEquals(entities, vm.locations.value) - } + fun `locations emits the repository flow`() = runTest { + val entities = listOf(LocationEntity("1", "Casa", 40.0, -3.0)) + whenever(locationRepository.observeLocations()).thenReturn(flowOf(entities)) + + val vm = buildViewModel() + backgroundScope.launch(testDispatcher) { vm.locations.collect {} } + + assertEquals(entities, vm.locations.value) } @Test - fun activeReminderCounts_mapsAndEmitsCountsFromRepository() { - runTest { - val counts = listOf( - LocationReminderCount("loc-1", 3), - LocationReminderCount("loc-2", 1) - ) - whenever(reminderRepository.observeActiveReminderCounts()).thenReturn(flowOf(counts)) - - val vm = LocationsViewModel( - locationRepository, reminderRepository, geofenceOrchestrator, locationProvider, settingsManager - ) - backgroundScope.launch(testDispatcher) { vm.activeReminderCounts.collect {} } - - val expected = mapOf("loc-1" to 3, "loc-2" to 1) - assertEquals(expected, vm.activeReminderCounts.value) - } + fun `activeReminderCounts maps counts keyed by location id`() = runTest { + val counts = listOf(LocationReminderCount("loc-1", 3), LocationReminderCount("loc-2", 1)) + whenever(reminderRepository.observeActiveReminderCounts()).thenReturn(flowOf(counts)) + + val vm = buildViewModel() + backgroundScope.launch(testDispatcher) { vm.activeReminderCounts.collect {} } + + assertEquals(mapOf("loc-1" to 3, "loc-2" to 1), vm.activeReminderCounts.value) } @Test - fun mapTheme_emitsThemeFromSettings() { - runTest { - whenever(settingsManager.mapTheme).thenReturn(flowOf(ThemeSetting.DARK)) + fun `mapTheme emits the configured theme`() = runTest { + whenever(settingsManager.mapTheme).thenReturn(flowOf(ThemeSetting.DARK)) - val vm = LocationsViewModel( - locationRepository, reminderRepository, geofenceOrchestrator, locationProvider, settingsManager - ) - backgroundScope.launch(testDispatcher) { vm.mapTheme.collect {} } + val vm = buildViewModel() + backgroundScope.launch(testDispatcher) { vm.mapTheme.collect {} } - assertEquals(ThemeSetting.DARK, vm.mapTheme.value) - } + assertEquals(ThemeSetting.DARK, vm.mapTheme.value) } - // ── saveLocation tests ── + // ── saveLocation ── @Test - fun saveLocation_success_callsRepositoryAndTriggersRecalculation() { - runTest { - val messages = mutableListOf() - backgroundScope.launch(testDispatcher) { viewModel.snackbarMessage.collect { messages.add(it) } } + fun `saveLocation saves and recalculates`() = runTest { + val vm = buildViewModel().also { it.collectingMessages(backgroundScope) } - viewModel.saveLocation("Casa", 40.0, -3.0, 150f, 1000) + vm.saveLocation("Casa", 40.0, -3.0, 150f, 1000) - verify(locationRepository).saveLocation("Casa", 40.0, -3.0, 150f, 1000) - verify(geofenceOrchestrator).triggerRecalculation() - assertTrue(messages.isEmpty()) - } + verify(locationRepository).saveLocation("Casa", 40.0, -3.0, 150f, 1000) + verify(geofenceOrchestrator).triggerRecalculation() + assertTrue(messages.isEmpty()) } @Test - fun saveLocation_onRepositoryException_emitsErrorMessageToSnackbar() { - runTest { - whenever(locationRepository.saveLocation(any(), any(), any(), any(), any())) - .thenThrow(RuntimeException("Duplicate alias")) - val messages = mutableListOf() - backgroundScope.launch(testDispatcher) { viewModel.snackbarMessage.collect { messages.add(it) } } - - viewModel.saveLocation("Casa", 40.0, -3.0, 150f, 0) - - verify(geofenceOrchestrator, never()).triggerRecalculation() - assertEquals(1, messages.size) - assertEquals(UiText.DynamicString("Duplicate alias"), messages[0]) - } + fun `saveLocation surfaces the failure and skips recalculation`() = runTest { + whenever(locationRepository.saveLocation(any(), any(), any(), any(), any())) + .thenThrow(RuntimeException("Duplicate alias")) + val vm = buildViewModel().also { it.collectingMessages(backgroundScope) } + + vm.saveLocation("Casa", 40.0, -3.0, 150f, 0) + + verify(geofenceOrchestrator, never()).triggerRecalculation() + assertEquals( + listOf(UiText.DynamicString("Could not save the location: Duplicate alias")), + messages + ) } @Test - fun saveLocation_onExceptionWithoutMessage_emitsDefaultErrorMessage() { - runTest { - whenever(locationRepository.saveLocation(any(), any(), any(), any(), any())) - .thenThrow(RuntimeException()) - val messages = mutableListOf() - backgroundScope.launch(testDispatcher) { viewModel.snackbarMessage.collect { messages.add(it) } } - - viewModel.saveLocation("Casa", 40.0, -3.0, 150f, 0) - - assertEquals(1, messages.size) - assertEquals(UiText.DynamicString("Unknown error saving location"), messages[0]) - } - } + fun `saveLocation falls back to a generic message when the cause has none`() = runTest { + whenever(locationRepository.saveLocation(any(), any(), any(), any(), any())) + .thenThrow(RuntimeException()) + val vm = buildViewModel().also { it.collectingMessages(backgroundScope) } - // ── updateLocation tests ── + vm.saveLocation("Casa", 40.0, -3.0, 150f, 0) - @Test - fun updateLocation_success_callsRepositoryAndTriggersRecalculation() { - runTest { - val location = LocationEntity("1", "Trabajo", 41.0, 2.0) - val messages = mutableListOf() - backgroundScope.launch(testDispatcher) { viewModel.snackbarMessage.collect { messages.add(it) } } - - viewModel.updateLocation(location) - - verify(locationRepository).updateLocation(location) - verify(geofenceOrchestrator).triggerRecalculation() - assertTrue(messages.isEmpty()) - } + assertEquals(listOf(UiText.DynamicString("Could not save the location")), messages) } @Test - fun updateLocation_onRepositoryException_emitsErrorMessageToSnackbar() { - runTest { - val location = LocationEntity("1", "Trabajo", 41.0, 2.0) - whenever(locationRepository.updateLocation(location)) - .thenThrow(RuntimeException("Location not found")) - val messages = mutableListOf() - backgroundScope.launch(testDispatcher) { viewModel.snackbarMessage.collect { messages.add(it) } } - - viewModel.updateLocation(location) - - verify(geofenceOrchestrator, never()).triggerRecalculation() - assertEquals(1, messages.size) - assertEquals(UiText.DynamicString("Location not found"), messages[0]) - } - } + fun `a message emitted before collection starts is not lost`() = runTest { + whenever(locationRepository.saveLocation(any(), any(), any(), any(), any())) + .thenThrow(RuntimeException("boom")) + val vm = buildViewModel() - @Test - fun updateLocation_onExceptionWithoutMessage_emitsDefaultErrorMessage() { - runTest { - val location = LocationEntity("1", "Trabajo", 41.0, 2.0) - whenever(locationRepository.updateLocation(location)) - .thenThrow(RuntimeException()) - val messages = mutableListOf() - backgroundScope.launch(testDispatcher) { viewModel.snackbarMessage.collect { messages.add(it) } } - - viewModel.updateLocation(location) - - assertEquals(1, messages.size) - assertEquals(UiText.DynamicString("Unknown error updating location"), messages[0]) - } + // No collector yet: a SharedFlow would drop this event silently. + vm.saveLocation("Casa", 40.0, -3.0, 150f, 0) + vm.collectingMessages(backgroundScope) + + assertEquals(listOf(UiText.DynamicString("Could not save the location: boom")), messages) } - // ── deleteLocation tests ── + // ── updateLocation ── @Test - fun deleteLocation_success_callsRepositoryAndTriggersRecalculation() { - runTest { - val messages = mutableListOf() - backgroundScope.launch(testDispatcher) { viewModel.snackbarMessage.collect { messages.add(it) } } + fun `updateLocation saves and recalculates`() = runTest { + val location = LocationEntity("1", "Trabajo", 41.0, 2.0) + val vm = buildViewModel().also { it.collectingMessages(backgroundScope) } - viewModel.deleteLocation("Casa") + vm.updateLocation(location) - verify(locationRepository).deleteLocation("Casa") - verify(geofenceOrchestrator).triggerRecalculation() - assertTrue(messages.isEmpty()) - } + verify(locationRepository).updateLocation(location) + verify(geofenceOrchestrator).triggerRecalculation() + assertTrue(messages.isEmpty()) } @Test - fun deleteLocation_onRepositoryException_emitsErrorMessageToSnackbar() { - runTest { - whenever(locationRepository.deleteLocation("Casa")) - .thenThrow(RuntimeException("Delete constraint error")) - val messages = mutableListOf() - backgroundScope.launch(testDispatcher) { viewModel.snackbarMessage.collect { messages.add(it) } } - - viewModel.deleteLocation("Casa") - - verify(geofenceOrchestrator, never()).triggerRecalculation() - assertEquals(1, messages.size) - assertEquals(UiText.DynamicString("Delete constraint error"), messages[0]) - } + fun `updateLocation surfaces the failure and skips recalculation`() = runTest { + val location = LocationEntity("1", "Trabajo", 41.0, 2.0) + whenever(locationRepository.updateLocation(location)) + .thenThrow(RuntimeException("Location not found")) + val vm = buildViewModel().also { it.collectingMessages(backgroundScope) } + + vm.updateLocation(location) + + verify(geofenceOrchestrator, never()).triggerRecalculation() + assertEquals( + listOf(UiText.DynamicString("Could not update the location: Location not found")), + messages + ) } + // ── deleteLocation ── + @Test - fun deleteLocation_onExceptionWithoutMessage_emitsDefaultErrorMessage() { - runTest { - whenever(locationRepository.deleteLocation("Casa")) - .thenThrow(RuntimeException()) - val messages = mutableListOf() - backgroundScope.launch(testDispatcher) { viewModel.snackbarMessage.collect { messages.add(it) } } - - viewModel.deleteLocation("Casa") - - assertEquals(1, messages.size) - assertEquals(UiText.DynamicString("Unknown error deleting location"), messages[0]) - } - } + fun `deleteLocation removes by alias, recalculates and confirms`() = runTest { + val location = LocationEntity("1", "Casa", 40.0, -3.0) + val vm = buildViewModel().also { it.collectingMessages(backgroundScope) } - // ── getCurrentLocation tests ── + vm.deleteLocation(location) + + verify(locationRepository).deleteLocation("Casa") + verify(geofenceOrchestrator).triggerRecalculation() + assertEquals(listOf(UiText.StringResource(R.string.location_deleted, "Casa")), messages) + } @Test - fun getCurrentLocation_returnsLocationFromProvider() { - runTest { - val mockLocation: Location = mock() - whenever(locationProvider.getCurrentLocation()).thenReturn(mockLocation) + fun `deleteLocation surfaces the failure and skips recalculation`() = runTest { + whenever(locationRepository.deleteLocation("Casa")) + .thenThrow(RuntimeException("Delete constraint error")) + val vm = buildViewModel().also { it.collectingMessages(backgroundScope) } - val result = viewModel.getCurrentLocation() + vm.deleteLocation(LocationEntity("1", "Casa", 40.0, -3.0)) - assertEquals(mockLocation, result) - verify(locationProvider).getCurrentLocation() - } + verify(geofenceOrchestrator, never()).triggerRecalculation() + assertEquals( + listOf(UiText.DynamicString("Could not delete the location: Delete constraint error")), + messages + ) } + // ── getCurrentLocation ── + @Test - fun getCurrentLocation_whenProviderReturnsNull_returnsNull() { - runTest { - whenever(locationProvider.getCurrentLocation()).thenReturn(null) + fun `getCurrentLocation delegates to the provider`() = runTest { + val fix: Location = mock() + whenever(locationProvider.getCurrentLocation()).thenReturn(fix) - val result = viewModel.getCurrentLocation() + assertEquals(fix, buildViewModel().getCurrentLocation()) + verify(locationProvider).getCurrentLocation() + } + + @Test + fun `getCurrentLocation returns null when no fix is available`() = runTest { + whenever(locationProvider.getCurrentLocation()).thenReturn(null) - assertNull(result) - verify(locationProvider).getCurrentLocation() - } + assertNull(buildViewModel().getCurrentLocation()) } } diff --git a/app/src/test/java/dev/arrase/geotify/ui/screen/RemindersViewModelTest.kt b/app/src/test/java/dev/arrase/geotify/ui/screen/RemindersViewModelTest.kt index da87a0c..627109a 100644 --- a/app/src/test/java/dev/arrase/geotify/ui/screen/RemindersViewModelTest.kt +++ b/app/src/test/java/dev/arrase/geotify/ui/screen/RemindersViewModelTest.kt @@ -2,20 +2,23 @@ package dev.arrase.geotify.ui.screen import android.location.Location import com.google.android.gms.location.Geofence +import dev.arrase.geotify.R +import dev.arrase.geotify.data.LatLng import dev.arrase.geotify.data.LocationRepository import dev.arrase.geotify.data.ReminderRepository import dev.arrase.geotify.data.SettingsDefaults import dev.arrase.geotify.data.SettingsManager import dev.arrase.geotify.data.ThemeSetting import dev.arrase.geotify.data.entity.LocationEntity -import dev.arrase.geotify.data.entity.LocationReminderCount import dev.arrase.geotify.data.entity.ReminderEntity import dev.arrase.geotify.geofence.GeofenceOrchestrator import dev.arrase.geotify.location.LocationProvider import dev.arrase.geotify.ui.UiText +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.launch import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.resetMain @@ -43,28 +46,18 @@ class RemindersViewModelTest { private val settingsManager: SettingsManager = mock() private val testDispatcher = UnconfinedTestDispatcher() - private lateinit var viewModel: RemindersViewModel + private val messages = mutableListOf() @Before fun setUp() { Dispatchers.setMain(testDispatcher) - + runBlocking { whenever(reminderRepository.cancelReminder(any())).thenReturn(true) } whenever(locationRepository.observeLocations()).thenReturn(flowOf(emptyList())) whenever(reminderRepository.observeReminders()).thenReturn(flowOf(emptyList())) - whenever(reminderRepository.observeActiveReminderCounts()).thenReturn(flowOf(emptyList())) whenever(settingsManager.mapTheme).thenReturn(flowOf(SettingsDefaults.MAP_THEME)) - whenever(settingsManager.lastRecalcLat).thenReturn(flowOf(null)) - whenever(settingsManager.lastRecalcLng).thenReturn(flowOf(null)) + whenever(settingsManager.lastRecalcLocation).thenReturn(flowOf(null)) whenever(settingsManager.innerRadiusR).thenReturn(flowOf(SettingsDefaults.INNER_RADIUS_R)) whenever(settingsManager.outerRadiusN).thenReturn(flowOf(SettingsDefaults.OUTER_RADIUS_N)) - - viewModel = RemindersViewModel( - locationRepository = locationRepository, - reminderRepository = reminderRepository, - geofenceOrchestrator = geofenceOrchestrator, - locationProvider = locationProvider, - settingsManager = settingsManager - ) } @After @@ -72,231 +65,228 @@ class RemindersViewModelTest { Dispatchers.resetMain() } - // ── StateFlows initialization tests ── + private fun buildViewModel(): RemindersViewModel = RemindersViewModel( + locationRepository = locationRepository, + reminderRepository = reminderRepository, + geofenceOrchestrator = geofenceOrchestrator, + locationProvider = locationProvider, + settingsManager = settingsManager + ) + + private fun RemindersViewModel.collectingMessages(scope: CoroutineScope) = + scope.launch(testDispatcher) { messagesFlow.collect { messages.add(it) } } + + // ── State exposure ── @Test - fun locations_emitsFlowFromRepository() { - runTest { - val entities = listOf(LocationEntity("loc-1", "Casa", 40.0, -3.0)) - whenever(locationRepository.observeLocations()).thenReturn(flowOf(entities)) - - val vm = RemindersViewModel( - locationRepository, reminderRepository, geofenceOrchestrator, locationProvider, settingsManager - ) - backgroundScope.launch(testDispatcher) { vm.locations.collect {} } - - assertEquals(entities, vm.locations.value) - } + fun `locations emits the repository flow`() = runTest { + val entities = listOf(LocationEntity("loc-1", "Casa", 40.0, -3.0)) + whenever(locationRepository.observeLocations()).thenReturn(flowOf(entities)) + + val vm = buildViewModel() + backgroundScope.launch(testDispatcher) { vm.locations.collect {} } + + assertEquals(entities, vm.locations.value) } @Test - fun reminders_emitsFlowFromRepository() { - runTest { - val remindersList = listOf( - ReminderEntity("rem-1", "loc-1", "Comprar leche", Geofence.GEOFENCE_TRANSITION_ENTER, createdAt = 100L) - ) - whenever(reminderRepository.observeReminders()).thenReturn(flowOf(remindersList)) - - val vm = RemindersViewModel( - locationRepository, reminderRepository, geofenceOrchestrator, locationProvider, settingsManager - ) - backgroundScope.launch(testDispatcher) { vm.reminders.collect {} } - - assertEquals(remindersList, vm.reminders.value) - } + fun `reminders emits the repository flow`() = runTest { + val reminders = listOf( + ReminderEntity("rem-1", "loc-1", "Comprar leche", Geofence.GEOFENCE_TRANSITION_ENTER, createdAt = 100L) + ) + whenever(reminderRepository.observeReminders()).thenReturn(flowOf(reminders)) + + val vm = buildViewModel() + backgroundScope.launch(testDispatcher) { vm.reminders.collect {} } + + assertEquals(reminders, vm.reminders.value) } @Test - fun activeReminderCounts_mapsAndEmitsCountsFromRepository() { - runTest { - val counts = listOf( - LocationReminderCount("loc-1", 2), - LocationReminderCount("loc-2", 5) - ) - whenever(reminderRepository.observeActiveReminderCounts()).thenReturn(flowOf(counts)) - - val vm = RemindersViewModel( - locationRepository, reminderRepository, geofenceOrchestrator, locationProvider, settingsManager - ) - backgroundScope.launch(testDispatcher) { vm.activeReminderCounts.collect {} } - - val expected = mapOf("loc-1" to 2, "loc-2" to 5) - assertEquals(expected, vm.activeReminderCounts.value) - } + fun `settings flows expose their configured values`() = runTest { + whenever(settingsManager.mapTheme).thenReturn(flowOf(ThemeSetting.LIGHT)) + whenever(settingsManager.lastRecalcLocation).thenReturn(flowOf(LatLng(40.4168, -3.7038))) + whenever(settingsManager.innerRadiusR).thenReturn(flowOf(10f)) + whenever(settingsManager.outerRadiusN).thenReturn(flowOf(25f)) + + val vm = buildViewModel() + backgroundScope.launch(testDispatcher) { vm.mapTheme.collect {} } + backgroundScope.launch(testDispatcher) { vm.lastRecalcLocation.collect {} } + backgroundScope.launch(testDispatcher) { vm.innerRadiusR.collect {} } + backgroundScope.launch(testDispatcher) { vm.outerRadiusN.collect {} } + + assertEquals(ThemeSetting.LIGHT, vm.mapTheme.value) + assertEquals(LatLng(40.4168, -3.7038), vm.lastRecalcLocation.value) + assertEquals(10f, vm.innerRadiusR.value) + assertEquals(25f, vm.outerRadiusN.value) } @Test - fun settingsFlows_emitConfiguredSettings() { - runTest { - whenever(settingsManager.mapTheme).thenReturn(flowOf(ThemeSetting.LIGHT)) - whenever(settingsManager.lastRecalcLat).thenReturn(flowOf(40.4168)) - whenever(settingsManager.lastRecalcLng).thenReturn(flowOf(-3.7038)) - whenever(settingsManager.innerRadiusR).thenReturn(flowOf(10f)) - whenever(settingsManager.outerRadiusN).thenReturn(flowOf(25f)) - - val vm = RemindersViewModel( - locationRepository, reminderRepository, geofenceOrchestrator, locationProvider, settingsManager - ) - backgroundScope.launch(testDispatcher) { vm.mapTheme.collect {} } - backgroundScope.launch(testDispatcher) { vm.lastRecalcLat.collect {} } - backgroundScope.launch(testDispatcher) { vm.lastRecalcLng.collect {} } - backgroundScope.launch(testDispatcher) { vm.innerRadiusR.collect {} } - backgroundScope.launch(testDispatcher) { vm.outerRadiusN.collect {} } - - assertEquals(ThemeSetting.LIGHT, vm.mapTheme.value) - assertEquals(40.4168, vm.lastRecalcLat.value) - assertEquals(-3.7038, vm.lastRecalcLng.value) - assertEquals(10f, vm.innerRadiusR.value) - assertEquals(25f, vm.outerRadiusN.value) - } + fun `lastRecalcLocation reports null before the first recalculation`() = runTest { + val vm = buildViewModel() + backgroundScope.launch(testDispatcher) { vm.lastRecalcLocation.collect {} } + + assertNull(vm.lastRecalcLocation.value) } - // ── createReminder tests ── + // ── createReminder ── @Test - fun createReminder_whenLocationExists_createsReminderAndTriggersRecalculation() { - runTest { - val location = LocationEntity("loc-1", "Super", 40.0, -3.0) - whenever(locationRepository.findLocationById("loc-1")).thenReturn(location) + fun `createReminder creates and recalculates when the location exists`() = runTest { + val location = LocationEntity("loc-1", "Super", 40.0, -3.0) + whenever(locationRepository.findLocationById("loc-1")).thenReturn(location) + val vm = buildViewModel().also { it.collectingMessages(backgroundScope) } - val messages = mutableListOf() - backgroundScope.launch(testDispatcher) { viewModel.snackbarMessage.collect { messages.add(it) } } + vm.createReminder("loc-1", "Comprar café", Geofence.GEOFENCE_TRANSITION_ENTER) - viewModel.createReminder("loc-1", "Comprar café", Geofence.GEOFENCE_TRANSITION_ENTER) - - verify(reminderRepository).createReminder(location, "Comprar café", Geofence.GEOFENCE_TRANSITION_ENTER) - verify(geofenceOrchestrator).triggerRecalculation() - assertTrue(messages.isEmpty()) - } + verify(reminderRepository).createReminder(location, "Comprar café", Geofence.GEOFENCE_TRANSITION_ENTER) + verify(geofenceOrchestrator).triggerRecalculation() + assertTrue(messages.isEmpty()) } @Test - fun createReminder_whenLocationDoesNotExist_returnsEarlyWithoutCreatingOrRecalculating() { - runTest { - whenever(locationRepository.findLocationById("non-existent")).thenReturn(null) + fun `createReminder reports a missing location instead of failing silently`() = runTest { + whenever(locationRepository.findLocationById("gone")).thenReturn(null) + val vm = buildViewModel().also { it.collectingMessages(backgroundScope) } - val messages = mutableListOf() - backgroundScope.launch(testDispatcher) { viewModel.snackbarMessage.collect { messages.add(it) } } + vm.createReminder("gone", "Comprar café", Geofence.GEOFENCE_TRANSITION_ENTER) - viewModel.createReminder("non-existent", "Comprar café", Geofence.GEOFENCE_TRANSITION_ENTER) + verify(reminderRepository, never()).createReminder(any(), any(), any()) + verify(geofenceOrchestrator, never()).triggerRecalculation() + assertEquals(listOf(UiText.StringResource(R.string.err_location_missing)), messages) + } - verify(reminderRepository, never()).createReminder(any(), any(), any()) - verify(geofenceOrchestrator, never()).triggerRecalculation() - assertTrue(messages.isEmpty()) - } + @Test + fun `createReminder surfaces the failure and skips recalculation`() = runTest { + val location = LocationEntity("loc-1", "Super", 40.0, -3.0) + whenever(locationRepository.findLocationById("loc-1")).thenReturn(location) + whenever(reminderRepository.createReminder(location, "Comprar café", Geofence.GEOFENCE_TRANSITION_ENTER)) + .thenThrow(RuntimeException("Reminder insert failed")) + val vm = buildViewModel().also { it.collectingMessages(backgroundScope) } + + vm.createReminder("loc-1", "Comprar café", Geofence.GEOFENCE_TRANSITION_ENTER) + + verify(geofenceOrchestrator, never()).triggerRecalculation() + assertEquals( + listOf(UiText.DynamicString("Could not create the reminder: Reminder insert failed")), + messages + ) } @Test - fun createReminder_onRepositoryException_emitsErrorMessageToSnackbar() { - runTest { - val location = LocationEntity("loc-1", "Super", 40.0, -3.0) - whenever(locationRepository.findLocationById("loc-1")).thenReturn(location) - whenever(reminderRepository.createReminder(location, "Comprar café", Geofence.GEOFENCE_TRANSITION_ENTER)) - .thenThrow(RuntimeException("Reminder insert failed")) - - val messages = mutableListOf() - backgroundScope.launch(testDispatcher) { viewModel.snackbarMessage.collect { messages.add(it) } } - - viewModel.createReminder("loc-1", "Comprar café", Geofence.GEOFENCE_TRANSITION_ENTER) - - verify(geofenceOrchestrator, never()).triggerRecalculation() - assertEquals(1, messages.size) - assertEquals(UiText.DynamicString("Error: Reminder insert failed"), messages[0]) - } + fun `createReminder falls back to a generic message when the cause has none`() = runTest { + val location = LocationEntity("loc-1", "Super", 40.0, -3.0) + whenever(locationRepository.findLocationById("loc-1")).thenReturn(location) + whenever(reminderRepository.createReminder(location, "Comprar café", Geofence.GEOFENCE_TRANSITION_ENTER)) + .thenThrow(RuntimeException()) + val vm = buildViewModel().also { it.collectingMessages(backgroundScope) } + + vm.createReminder("loc-1", "Comprar café", Geofence.GEOFENCE_TRANSITION_ENTER) + + assertEquals(listOf(UiText.DynamicString("Could not create the reminder")), messages) } - // ── updateReminder tests ── + // ── updateReminder ── @Test - fun updateReminder_success_callsRepositoryAndTriggersRecalculation() { - runTest { - val reminder = ReminderEntity("rem-1", "loc-1", "Nuevo texto", Geofence.GEOFENCE_TRANSITION_EXIT, createdAt = 100L) - val messages = mutableListOf() - backgroundScope.launch(testDispatcher) { viewModel.snackbarMessage.collect { messages.add(it) } } - - viewModel.updateReminder(reminder) - - verify(reminderRepository).updateReminder(reminder) - verify(geofenceOrchestrator).triggerRecalculation() - assertTrue(messages.isEmpty()) - } + fun `updateReminder saves and recalculates`() = runTest { + val reminder = ReminderEntity("rem-1", "loc-1", "Nuevo texto", Geofence.GEOFENCE_TRANSITION_EXIT, createdAt = 100L) + val vm = buildViewModel().also { it.collectingMessages(backgroundScope) } + + vm.updateReminder(reminder) + + verify(reminderRepository).updateReminder(reminder) + verify(geofenceOrchestrator).triggerRecalculation() + assertTrue(messages.isEmpty()) } @Test - fun updateReminder_onRepositoryException_emitsErrorMessageToSnackbar() { - runTest { - val reminder = ReminderEntity("rem-1", "loc-1", "Nuevo texto", Geofence.GEOFENCE_TRANSITION_EXIT, createdAt = 100L) - whenever(reminderRepository.updateReminder(reminder)) - .thenThrow(RuntimeException("Update failed")) + fun `updateReminder surfaces the failure and skips recalculation`() = runTest { + val reminder = ReminderEntity("rem-1", "loc-1", "Nuevo texto", Geofence.GEOFENCE_TRANSITION_EXIT, createdAt = 100L) + whenever(reminderRepository.updateReminder(reminder)).thenThrow(RuntimeException("Update failed")) + val vm = buildViewModel().also { it.collectingMessages(backgroundScope) } - val messages = mutableListOf() - backgroundScope.launch(testDispatcher) { viewModel.snackbarMessage.collect { messages.add(it) } } + vm.updateReminder(reminder) - viewModel.updateReminder(reminder) - - verify(geofenceOrchestrator, never()).triggerRecalculation() - assertEquals(1, messages.size) - assertEquals(UiText.DynamicString("Error: Update failed"), messages[0]) - } + verify(geofenceOrchestrator, never()).triggerRecalculation() + assertEquals( + listOf(UiText.DynamicString("Could not update the reminder: Update failed")), + messages + ) } - // ── cancelReminder tests ── + // ── cancelReminder ── @Test - fun cancelReminder_success_callsRepositoryAndTriggersRecalculation() { - runTest { - val messages = mutableListOf() - backgroundScope.launch(testDispatcher) { viewModel.snackbarMessage.collect { messages.add(it) } } + fun `cancelReminder cancels, recalculates and confirms`() = runTest { + val vm = buildViewModel().also { it.collectingMessages(backgroundScope) } - viewModel.cancelReminder("rem-1") + vm.cancelReminder("rem-1") - verify(reminderRepository).cancelReminder("rem-1") - verify(geofenceOrchestrator).triggerRecalculation() - assertTrue(messages.isEmpty()) - } + verify(reminderRepository).cancelReminder("rem-1") + verify(geofenceOrchestrator).triggerRecalculation() + assertEquals(listOf(UiText.StringResource(R.string.reminder_cancelled)), messages) } @Test - fun cancelReminder_onRepositoryException_emitsErrorMessageToSnackbar() { - runTest { - whenever(reminderRepository.cancelReminder("rem-1")) - .thenThrow(RuntimeException("Cancel failed")) + fun `cancelReminder surfaces the failure and skips recalculation`() = runTest { + whenever(reminderRepository.cancelReminder("rem-1")).thenThrow(RuntimeException("Cancel failed")) + val vm = buildViewModel().also { it.collectingMessages(backgroundScope) } - val messages = mutableListOf() - backgroundScope.launch(testDispatcher) { viewModel.snackbarMessage.collect { messages.add(it) } } + vm.cancelReminder("rem-1") - viewModel.cancelReminder("rem-1") - - verify(geofenceOrchestrator, never()).triggerRecalculation() - assertEquals(1, messages.size) - assertEquals(UiText.DynamicString("Error: Cancel failed"), messages[0]) - } + verify(geofenceOrchestrator, never()).triggerRecalculation() + assertEquals( + listOf(UiText.DynamicString("Could not cancel the reminder: Cancel failed")), + messages + ) } - // ── getCurrentLocation tests ── - @Test - fun getCurrentLocation_returnsLocationFromProvider() { - runTest { - val mockLocation: Location = mock() - whenever(locationProvider.getCurrentLocation()).thenReturn(mockLocation) + fun `cancelReminder reports a failure when the reminder is already gone`() = runTest { + runBlocking { whenever(reminderRepository.cancelReminder("gone")).thenReturn(false) } + val vm = buildViewModel().also { it.collectingMessages(backgroundScope) } + + vm.cancelReminder("gone") - val result = viewModel.getCurrentLocation() + verify(geofenceOrchestrator, never()).triggerRecalculation() + assertEquals( + listOf(UiText.DynamicString("Could not cancel the reminder: Reminder gone no longer exists")), + messages + ) + } - assertEquals(mockLocation, result) - verify(locationProvider).getCurrentLocation() - } + @Test + fun `cancelling an active reminder and deleting a completed one are worded differently`() = runTest { + val vm = buildViewModel().also { it.collectingMessages(backgroundScope) } + + vm.cancelReminder("rem-1", cancelled = true) + vm.cancelReminder("rem-2", cancelled = false) + + assertEquals( + listOf( + UiText.StringResource(R.string.reminder_cancelled), + UiText.StringResource(R.string.reminder_deleted) + ), + messages + ) } + // ── getCurrentLocation ── + @Test - fun getCurrentLocation_whenProviderReturnsNull_returnsNull() { - runTest { - whenever(locationProvider.getCurrentLocation()).thenReturn(null) + fun `getCurrentLocation delegates to the provider`() = runTest { + val fix: Location = mock() + whenever(locationProvider.getCurrentLocation()).thenReturn(fix) + + assertEquals(fix, buildViewModel().getCurrentLocation()) + verify(locationProvider).getCurrentLocation() + } - val result = viewModel.getCurrentLocation() + @Test + fun `getCurrentLocation returns null when no fix is available`() = runTest { + whenever(locationProvider.getCurrentLocation()).thenReturn(null) - assertNull(result) - verify(locationProvider).getCurrentLocation() - } + assertNull(buildViewModel().getCurrentLocation()) } } diff --git a/app/src/test/java/dev/arrase/geotify/ui/screen/SettingsViewModelTest.kt b/app/src/test/java/dev/arrase/geotify/ui/screen/SettingsViewModelTest.kt index 867e8b9..ddb5d85 100644 --- a/app/src/test/java/dev/arrase/geotify/ui/screen/SettingsViewModelTest.kt +++ b/app/src/test/java/dev/arrase/geotify/ui/screen/SettingsViewModelTest.kt @@ -17,8 +17,10 @@ import org.junit.After import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Test +import org.mockito.kotlin.any import org.mockito.kotlin.mock import org.mockito.kotlin.never +import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever @@ -170,21 +172,24 @@ class SettingsViewModelTest { } @Test - fun setOuterRadiusN_callsSettingsManagerAndTriggersRecalculation() = runTest(testDispatcher) { - viewModel.setOuterRadiusN(3000f) + fun setRadii_persistsBothRadiiAndTriggersASingleRecalculation() = runTest(testDispatcher) { + viewModel.setRadii(outerRadiusKm = 3.0f, innerRadiusKm = 1.0f) advanceUntilIdle() - verify(settingsManager).setOuterRadiusN(3000f) - verify(geofenceOrchestrator).triggerRecalculation() + verify(settingsManager).setOuterRadiusN(3.0f) + verify(settingsManager).setInnerRadiusR(1.0f) + // One recalculation for the pair: the sliding window depends on both. + verify(geofenceOrchestrator, times(1)).triggerRecalculation() } @Test - fun setInnerRadiusR_callsSettingsManagerAndTriggersRecalculation() = runTest(testDispatcher) { - viewModel.setInnerRadiusR(1000f) + fun setRadii_skipsRecalculationWhenPersistingFails() = runTest(testDispatcher) { + whenever(settingsManager.setOuterRadiusN(any())).thenThrow(RuntimeException("disk full")) + + viewModel.setRadii(outerRadiusKm = 3.0f, innerRadiusKm = 1.0f) advanceUntilIdle() - verify(settingsManager).setInnerRadiusR(1000f) - verify(geofenceOrchestrator).triggerRecalculation() + verify(geofenceOrchestrator, never()).triggerRecalculation() } @Test diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8712ffd..700bcab 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -7,7 +7,6 @@ coreKtx = "1.19.1" activityCompose = "1.13.0" lifecycleRuntimeKtx = "2.11.0" lifecycleViewmodelCompose = "2.11.0" -navigationCompose = "2.10.0" room = "2.8.5" playServicesLocation = "21.4.0" appfunctions = "1.0.0-alpha09" @@ -27,7 +26,6 @@ androidx-activity-compose = { group = "androidx.activity", name = "activity-comp androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleRuntimeKtx" } androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleViewmodelCompose" } -androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" } androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" } androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } @@ -40,6 +38,7 @@ androidx-compose-material-icons-extended = { group = "androidx.compose.material" androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } +androidx-room-testing = { group = "androidx.room", name = "room-testing", version.ref = "room" } androidx-appfunctions = { group = "androidx.appfunctions", name = "appfunctions", version.ref = "appfunctions" } androidx-appfunctions-service = { group = "androidx.appfunctions", name = "appfunctions-service", version.ref = "appfunctions" } androidx-appfunctions-compiler = { group = "androidx.appfunctions", name = "appfunctions-compiler", version.ref = "appfunctions" } @@ -52,7 +51,7 @@ kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-cor osmdroid-android = { group = "org.osmdroid", name = "osmdroid-android", version = "6.1.20" } hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" } hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" } -androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version = "1.4.0" } +androidx-hilt-lifecycle-viewmodel-compose = { group = "androidx.hilt", name = "hilt-lifecycle-viewmodel-compose", version = "1.3.0" } androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" } androidx-work-runtime = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "work" } mockito-core = { group = "org.mockito", name = "mockito-core", version.ref = "mockito" }