diff --git a/AGENTS.md b/AGENTS.md index bf775d2..45b86a7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,17 +15,23 @@ flutter test --coverage dart run tool/sync_version.dart --check # version pins in sync with pubspec # Native lanes run from the example app, not from the plugin root -cd example/android && ./gradlew :locus:testDebugUnitTest :locus:lintDebug -cd example/android && ./gradlew :locus:connectedDebugAndroidTest # needs a running emulator +example/android/gradlew --project-dir example/android :locus:testDebugUnitTest :locus:lintDebug +example/android/gradlew --project-dir example/android :locus:assembleDebugAndroidTest +# Install the resulting dev.locus.test APK on a dedicated emulator, then: +adb shell am instrument -w dev.locus.test/dev.locus.core.ConfigSnapshotInstrumentation swift test --package-path ios +LOCUS_NATIVE_TEST_MODULE=locus swift test --package-path ios --scratch-path ios/.build-lowercase +cd example && flutter test # example widgets and harness bash tool/android_process_recovery_smoke.sh # process-death recovery smoke cd example && flutter run # the plugin has no runnable entry point ``` `.github/workflows/pipeline.yml` is the real gate: code quality, `pana` score threshold 130, -Android unit/lint/instrumentation on API 26/29/34/36, and iOS built under **both** CocoaPods and -SwiftPM. Release is tag-only; `publish.yml` publishes the tag to pub.dev after approval. +Android JVM tests/lint, instrumentation on API 26/29/34/36 emulators, and iOS helper tests plus +builds under **both** CocoaPods and SwiftPM. The release job runs on main/master pushes and creates +a release only when the pubspec version has no remote tag; `publish.yml` then publishes that tag to +pub.dev after approval. ## Structure @@ -62,7 +68,7 @@ background location delegates, persistent queue, geofence registration, activity emits typed events that `lib/src/core/event_mapper.dart` translates for Dart. - **Platform Interface pattern** — `LocusInterface` + `MethodChannelLocus` let host apps swap in - `MockLocus` via `Locus.setInstance(...)` for their own tests. + `MockLocus` via `Locus.setMockInstance(...)` for their own tests. - **Event-sourced state** — native is the source of truth. `Locus.isTracking()` calls the platform; Dart never caches tracking state. - **Headless execution** — callbacks registered via `Locus.registerHeadlessTask` run in a second @@ -94,6 +100,9 @@ Non-negotiables: read the line directly at build time. - **HTTP sync, queue/UUID generation, and OEM detection live in native code,** not Dart — reach them through `LocusChannels.methods` (`sync`, `getDiagnosticsMetadata`, …). +- **History queries filter and page natively.** The private `queryLocations` channel uses UTC + epoch microseconds, applies every predicate before LIMIT/OFFSET, and leaves legacy + `getLocations` unchanged. Do not add a fetch-all Dart fallback. See `doc/guides/querying-history.md`. - **iOS ships under two module names** (`locus` and `Locus`); native tests run against both. - `dart format` run directly may print package-resolution warnings; they are expected and the lints resolve correctly under `flutter analyze`. diff --git a/CHANGELOG.md b/CHANGELOG.md index b3a3073..310ae8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,11 +8,14 @@ All notable changes to this project will be documented in this file. - **Android: activity recognition can deliver results on Android 12+ (#61)** — The explicitly targeted activity PendingIntent is mutable so Google Play services can attach activity result extras. - **Permissions: preserve the settings fallback after permanent denial** — Use the permission request result when deciding whether to offer settings, as updated Android permission status queries no longer report permanent denial. +- **iOS: safely close storage after asynchronous history queries** — Releasing the last storage reference on its own database queue no longer synchronously dispatches back onto that queue. +- **History queries: native filtering, sorting and pagination (#47)** — All current query predicates are applied before paging on Android and iOS. Limited calls return only the selected page to Dart, and mixed native/Dart versions request a host rebuild instead of fetching all history. On iOS, queries run on a separate read-only connection so they never delay location writes, time ranges and paging use the indexed timestamp column, and an unreadable stored record is logged and excluded before paging instead of failing the query. ### Changed - Raise the `permission_handler` patch minimum to 13.0.2 and refresh its example resolution; update the example's AndroidX core overrides to 1.19.1. - The example app now uses `material_ui` and `google_fonts` 9 and requires Flutter 3.47 or newer; its Android build uses Gradle 9.8.2, the Android Gradle Plugin 9.5.0 and Kotlin 2.4.21. The plugin's own requirements are unchanged. +- Add example history filters, bounded paging, validation, and retry states; document query semantics and add Dart, widget, Android SQLite, and iOS SQLite regression coverage. ## [2.4.1] - 2026-08-23 diff --git a/README.md b/README.md index 79742ce..255c160 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ - **Battery Optimization**: Adaptive profiles based on speed, activity, and battery level. - **Automated Sync**: HTTP synchronization with retry logic and batching. - **Offline Reliability**: SQLite persistence to prevent data loss. +- **History Queries**: Filter and sort stored locations before native pagination. - **Headless Execution**: Process eligible background events while the Flutter UI engine is absent. ## Documentation @@ -53,6 +54,8 @@ For full documentation, visit the [Locus GitHub repository](https://github.com/w - **[Platform Behaviors](https://github.com/weorbis/locus/blob/main/doc/guides/platform-specific-behaviors.md)** - Android/iOS runtime differences. - **[HTTP Synchronization](https://github.com/weorbis/locus/blob/main/doc/guides/http-synchronization.md)** - Request formats, retry, and batching. - **[Performance Optimization](https://github.com/weorbis/locus/blob/main/doc/guides/performance-optimization.md)** - Tuning for battery and accuracy. +- **[Querying Stored History](https://github.com/weorbis/locus/blob/main/doc/guides/querying-history.md)** - Native filtering, ordering, pagination, and the example's storage controls. +- **[Testing](https://github.com/weorbis/locus/blob/main/doc/testing/unit-testing.md)** - Host-app mocks and SDK regression suites. - **[Activity Recognition](https://github.com/weorbis/locus/blob/main/doc/guides/activity-recognition.md)** - Activity types and best practices. - **[Event Streams Reference](https://github.com/weorbis/locus/blob/main/doc/reference/event-streams.md)** - When streams emit and how to subscribe safely. - **[Error Codes](https://github.com/weorbis/locus/blob/main/doc/reference/error-codes.md)** - Exception types and recovery guidance. diff --git a/android/src/androidTest/kotlin/dev/locus/core/ConfigSnapshotInstrumentation.kt b/android/src/androidTest/kotlin/dev/locus/core/ConfigSnapshotInstrumentation.kt index e7a8b59..0ff3cc0 100644 --- a/android/src/androidTest/kotlin/dev/locus/core/ConfigSnapshotInstrumentation.kt +++ b/android/src/androidTest/kotlin/dev/locus/core/ConfigSnapshotInstrumentation.kt @@ -8,6 +8,7 @@ import android.os.Bundle import android.util.Log import dev.locus.LocusPlugin import dev.locus.activity.ActivityPendingIntentChecks +import dev.locus.storage.NativeHistoryQueryChecks /** Runs the persisted-config parser contract against Android's real org.json. */ class ConfigSnapshotInstrumentation : Instrumentation() { @@ -24,7 +25,8 @@ class ConfigSnapshotInstrumentation : Instrumentation() { "testConfigSnapshotIsEncryptedAndRestored" to ::verifyEncryptedPersistence, "testLegacyPlaintextSnapshotIsReadAndMigrated" to ::verifyLegacyMigration, "testReadyConfigPreservesPreexistingPrivacyGuard" to ::verifyPrivacyGuardMerge, - ) + ActivityPendingIntentChecks(targetContext).checks() + ) + ActivityPendingIntentChecks(targetContext).checks() + + NativeHistoryQueryChecks(targetContext).checks() val failures = checks.mapIndexedNotNull { index, (name, check) -> reportCheckStarted(name, index + 1, checks.size) try { diff --git a/android/src/androidTest/kotlin/dev/locus/storage/NativeHistoryQueryChecks.kt b/android/src/androidTest/kotlin/dev/locus/storage/NativeHistoryQueryChecks.kt new file mode 100644 index 0000000..01f40df --- /dev/null +++ b/android/src/androidTest/kotlin/dev/locus/storage/NativeHistoryQueryChecks.kt @@ -0,0 +1,151 @@ +package dev.locus.storage + +import android.content.ContentValues +import android.content.Context +import android.content.ContextWrapper +import android.database.DatabaseErrorHandler +import android.database.sqlite.SQLiteDatabase +import java.io.File +import java.util.UUID + +/** Real Android SQLite checks; never opens a host application's database. */ +internal class NativeHistoryQueryChecks(private val context: Context) { + fun checks(): List Unit>> = listOf( + "testHistoryFiltersBeforePagingInBothOrders" to ::verifyFilteredPaging, + "testHistoryTimeEdgesRoundAtMicrosecondPrecision" to ::verifyTimeEdges, + "testHistoryPagingAndEmptyRanges" to ::verifyPaging, + "testHistoryNaNBoundsAndThresholds" to ::verifyNaN, + "testHistoryInvalidCoordinateFallback" to ::verifyCoordinateFallback, + ) + + private fun verifyFilteredPaging() = withStore { store -> + // These older excluded rows detect implementations that page before filtering. + repeat(100) { index -> insert(store, "excluded-$index", index.toLong(), moving = false) } + insert(store, "southwest", 200, latitude = 10.0, longitude = 20.0, accuracy = 10.0) + insert(store, "tie-first", 210, accuracy = 15.0) + insert(store, "tie-second", 210, accuracy = 15.0) + insert(store, "northeast", 220, latitude = 11.0, longitude = 21.0, accuracy = 20.0) + insert(store, "too-early", 199) + insert(store, "too-late", 221) + insert(store, "too-precise", 211, accuracy = 9.9) + insert(store, "too-imprecise", 211, accuracy = 20.1) + insert(store, "stationary", 211, moving = false) + insert(store, "south", 211, latitude = 9.9) + insert(store, "north", 211, latitude = 11.1) + insert(store, "west", 211, longitude = 19.9) + insert(store, "east", 211, longitude = 21.1) + val query = mapOf( + "from" to 200_000L, "to" to 220_000L, + "minAccuracy" to 20.0, "maxAccuracy" to 10.0, "isMoving" to true, + "bounds" to bounds(10.0, 20.0, 11.0, 21.0), + ) + val countBefore = store.readLocations(0).size + expectIds(store, query + mapOf("sortOrder" to "oldestFirst"), "southwest", "tie-first", "tie-second", "northeast") + expectIds(store, query, "northeast", "tie-first", "tie-second", "southwest") + expectIds(store, query + mapOf("sortOrder" to "oldestFirst", "offset" to 1, "limit" to 3), "tie-first", "tie-second", "northeast") + expectIds(store, query + mapOf("offset" to 1, "limit" to 3), "tie-first", "tie-second", "southwest") + expectIds(store, mapOf("isMoving" to false, "from" to 200_000L), "stationary") + equal(countBefore, store.readLocations(0).size, "queries must preserve every stored row") + } + + private fun verifyTimeEdges() = withStore { store -> + for (time in listOf(-2L, -1L, 0L, 1L, 2L)) insert(store, "time-$time", time) + expectIds(store, mapOf("from" to -999L, "to" to 999L), "time-0") + expectIds(store, mapOf("from" to -1_999L, "to" to -1_001L)) + expectIds(store, mapOf("from" to -2_000L, "to" to -1_000L), "time--1", "time--2") + expectIds(store, mapOf("from" to 1L, "to" to 1_999L), "time-1") + expectIds(store, mapOf("from" to 1_001L, "to" to 2_000L), "time-2") + expectIds(store, mapOf("from" to Long.MIN_VALUE, "to" to Long.MAX_VALUE), "time-2", "time-1", "time-0", "time--1", "time--2") + } + + private fun verifyPaging() = withStore { store -> + insert(store, "first", 1) + insert(store, "second", 2) + expectIds(store, mapOf("offset" to -20), "second", "first") + expectIds(store, mapOf("offset" to 1), "first") + expectIds(store, mapOf("offset" to Long.MAX_VALUE)) + for (limit in listOf(0L, -1L, Long.MIN_VALUE)) expectIds(store, mapOf("limit" to limit)) + expectIds(store, mapOf("from" to 3_000L, "to" to 1_000L)) + expectIds(store, mapOf("minAccuracy" to 10, "maxAccuracy" to 20)) + expectIds(store, mapOf("bounds" to bounds(11.0, 21.0, 10.0, 20.0))) + equal(2, store.readLocations(0).size, "empty queries preserve rows") + } + + private fun verifyNaN() = withStore { store -> + insert(store, "accurate", 1, accuracy = 5.0) + insert(store, "inaccurate", 2, accuracy = 50.0) + expectIds(store, mapOf("minAccuracy" to Double.NaN, "maxAccuracy" to Double.NaN), "inaccurate", "accurate") + expectIds(store, mapOf("minAccuracy" to Double.NaN, "maxAccuracy" to 10.0), "inaccurate") + expectIds(store, mapOf("minAccuracy" to 10.0, "maxAccuracy" to Double.NaN), "accurate") + for (edge in 0..3) { + val coordinates = mutableListOf(10.0, 20.0, 11.0, 21.0) + coordinates[edge] = Double.NaN + expectIds(store, mapOf("bounds" to bounds(coordinates[0], coordinates[1], coordinates[2], coordinates[3]))) + } + } + + private fun verifyCoordinateFallback() = withStore { store -> + insert(store, "invalid-latitude", 1, latitude = 91.0, longitude = 20.0, accuracy = 50.0) + insert(store, "invalid-longitude", 2, latitude = 10.0, longitude = -181.0, accuracy = 50.0) + insert(store, "null-coordinates", 3, latitude = null, longitude = null, accuracy = null) + insert(store, "valid", 4, latitude = 10.0, longitude = 20.0) + expectIds(store, mapOf("bounds" to bounds(0.0, 0.0, 0.0, 0.0)), "null-coordinates", "invalid-longitude", "invalid-latitude") + expectIds(store, mapOf("minAccuracy" to -1.0), "invalid-longitude", "invalid-latitude") + expectIds(store, mapOf("maxAccuracy" to 0.0), "valid", "null-coordinates") + } + + private fun requireTestContext() { + check(context.packageName == "dev.locus.test") { "Native checks require the isolated library test package" } + } + + private fun withStore(check: (LocationStore) -> Unit) { + requireTestContext() + val directory = File(context.cacheDir, "native-history-${UUID.randomUUID()}") + check(directory.mkdir()) { "Cannot create isolated history fixture directory" } + val isolated = object : ContextWrapper(context) { + override fun getDatabasePath(name: String): File = File(directory, name) + override fun openOrCreateDatabase(name: String, mode: Int, factory: SQLiteDatabase.CursorFactory?): SQLiteDatabase = + openOrCreateDatabase(name, mode, factory, null) + + override fun openOrCreateDatabase(name: String, mode: Int, factory: SQLiteDatabase.CursorFactory?, errorHandler: DatabaseErrorHandler?): SQLiteDatabase = + SQLiteDatabase.openOrCreateDatabase(getDatabasePath(name).path, factory, errorHandler) + } + val store = LocationStore(isolated) + try { + check(store) + } finally { + store.close() + // Only this check's new scratch database is eligible for cleanup. + directory.listFiles()?.filter { it.name.endsWith(".db") }?.forEach { database -> + check(SQLiteDatabase.deleteDatabase(database)) { "Cannot remove history fixture database" } + } + check(directory.delete()) { "Cannot remove history fixture directory" } + } + } + + private fun insert(store: LocationStore, id: String, timestamp: Long, latitude: Double? = 10.5, longitude: Double? = 20.5, accuracy: Double? = 15.0, moving: Boolean = true) { + val values = ContentValues().apply { + put("id", id) + put("timestamp", timestamp) + put("latitude", latitude) + put("longitude", longitude) + put("accuracy", accuracy) + put("is_moving", if (moving) 1 else 0) + } + store.writableDatabase.insertOrThrow("locations", null, values) + } + + private fun expectIds(store: LocationStore, arguments: Map, vararg ids: String) { + val result = store.queryLocations(StoredLocationQuery.fromArguments(arguments)) + equal(ids.toList(), result.map { it["id"] }, "history selection for $arguments") + } + + private fun bounds(south: Double, west: Double, north: Double, east: Double): Map> = mapOf( + "southwest" to mapOf("latitude" to south, "longitude" to west), + "northeast" to mapOf("latitude" to north, "longitude" to east), + ) + + private fun equal(expected: Any?, actual: Any?, label: String) { + if (expected != actual) throw AssertionError("$label: expected <$expected>, actual <$actual>") + } +} diff --git a/android/src/main/kotlin/dev/locus/core/LocusContainer.kt b/android/src/main/kotlin/dev/locus/core/LocusContainer.kt index ad2c54f..8234b42 100644 --- a/android/src/main/kotlin/dev/locus/core/LocusContainer.kt +++ b/android/src/main/kotlin/dev/locus/core/LocusContainer.kt @@ -18,10 +18,12 @@ import dev.locus.LocusPlugin import dev.locus.activity.MotionManager import dev.locus.geofence.GeofenceManager import dev.locus.location.LocationClient +import dev.locus.storage.StoredLocationQuery import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel import org.json.JSONObject import java.util.UUID +import java.util.concurrent.Executors /** * Process-lifetime holder for every long-lived native resource Locus owns: @@ -100,6 +102,7 @@ internal class LocusContainer private constructor( private val prefs: SharedPreferences = context.getSharedPreferences(LocusPlugin.PREFS_NAME, Context.MODE_PRIVATE) private val mainHandler = Handler(Looper.getMainLooper()) + private val locationQueryExecutor = Executors.newSingleThreadExecutor() private val callbackBroker by lazy { EngineCallbackBroker( bindings = bridgeRegistry, @@ -479,6 +482,7 @@ internal class LocusContainer private constructor( val limit = (call.arguments.asMap()?.get("limit") as? Number)?.toInt() ?: 0 result.success(stateManager.getStoredLocations(limit)) } + "queryLocations" -> queryLocations(call, result) "enqueue" -> handleEnqueue(call, result) "getQueue" -> { val limit = (call.arguments.asMap()?.get("limit") as? Number)?.toInt() ?: 0 @@ -722,6 +726,26 @@ internal class LocusContainer private constructor( // Command helpers (split out to keep handleMethodCall readable) // ------------------------------------------------------------------------- + private fun queryLocations(call: MethodCall, result: MethodChannel.Result) { + val query = try { + StoredLocationQuery.fromArguments(call.arguments) + } catch (error: IllegalArgumentException) { + result.error("INVALID_ARGUMENT", error.message, null) + return + } + locationQueryExecutor.execute { + try { + val locations = stateManager.queryStoredLocations(query) + mainHandler.post { result.success(locations) } + } catch (error: Exception) { + Log.e(TAG, "Failed to query stored locations", error) + mainHandler.post { + result.error("LOCATION_QUERY_ERROR", "Failed to query stored locations", error.message) + } + } + } + } + private fun handleEnqueue(call: MethodCall, result: MethodChannel.Result) { val args = call.arguments.asMap() if (args == null) { diff --git a/android/src/main/kotlin/dev/locus/core/StateManager.kt b/android/src/main/kotlin/dev/locus/core/StateManager.kt index a9c6cec..3378e44 100644 --- a/android/src/main/kotlin/dev/locus/core/StateManager.kt +++ b/android/src/main/kotlin/dev/locus/core/StateManager.kt @@ -8,6 +8,7 @@ import dev.locus.location.Odometer import dev.locus.storage.LocationStore import dev.locus.storage.LogStore import dev.locus.storage.QueueStore +import dev.locus.storage.StoredLocationQuery import org.json.JSONArray import org.json.JSONException import org.json.JSONObject @@ -42,6 +43,9 @@ class StateManager(context: Context) { locationStore.readLocations(limit) .mapNotNull { record -> buildPayloadFromRecord(record).takeIf { it.isNotEmpty() } } + internal fun queryStoredLocations(query: StoredLocationQuery): List> = + locationStore.queryLocations(query).map { record -> buildPayloadFromRecord(record) } + fun storeLocationPayload(payload: Map, maxDays: Int, maxRecords: Int) { locationStore.insertPayload(payload, maxDays, maxRecords) } diff --git a/android/src/main/kotlin/dev/locus/storage/LocationStore.kt b/android/src/main/kotlin/dev/locus/storage/LocationStore.kt index 509f199..3a16eaf 100644 --- a/android/src/main/kotlin/dev/locus/storage/LocationStore.kt +++ b/android/src/main/kotlin/dev/locus/storage/LocationStore.kt @@ -2,6 +2,8 @@ package dev.locus.storage import android.content.ContentValues import android.content.Context +import android.database.Cursor +import android.database.sqlite.SQLiteCursor import android.database.sqlite.SQLiteDatabase import android.database.sqlite.SQLiteOpenHelper import android.location.Location @@ -172,24 +174,9 @@ class LocationStore(context: Context) : SQLiteOpenHelper(context, DB_NAME, null, "timestamp ASC", limitValue ).use { cursor -> + val columns = LocationColumns(cursor) while (cursor.moveToNext()) { - val record = mutableMapOf( - "id" to cursor.getString(cursor.getColumnIndexOrThrow("id")), - "timestamp" to cursor.getLong(cursor.getColumnIndexOrThrow("timestamp")), - "latitude" to cursor.getDouble(cursor.getColumnIndexOrThrow("latitude")), - "longitude" to cursor.getDouble(cursor.getColumnIndexOrThrow("longitude")), - "accuracy" to cursor.getDouble(cursor.getColumnIndexOrThrow("accuracy")), - "speed" to cursor.getDouble(cursor.getColumnIndexOrThrow("speed")), - "heading" to cursor.getDouble(cursor.getColumnIndexOrThrow("heading")), - "altitude" to cursor.getDouble(cursor.getColumnIndexOrThrow("altitude")), - "is_moving" to (cursor.getInt(cursor.getColumnIndexOrThrow("is_moving")) == 1), - "activity_confidence" to cursor.getInt(cursor.getColumnIndexOrThrow("activity_confidence")), - "odometer" to cursor.getDouble(cursor.getColumnIndexOrThrow("odometer")) - ) - cursor.getString(cursor.getColumnIndexOrThrow("activity_type"))?.let { record["activity_type"] = it } - cursor.getString(cursor.getColumnIndexOrThrow("event"))?.let { record["event"] = it } - cursor.getString(cursor.getColumnIndexOrThrow("extras_json"))?.let { record["extras_json"] = it } - results.add(record) + results.add(columns.read(cursor)) } } } catch (e: Exception) { @@ -198,6 +185,101 @@ class LocationStore(context: Context) : SQLiteOpenHelper(context, DB_NAME, null, return results } + /** Apply every predicate before ordering and paging; read errors propagate to the caller. */ + internal fun queryLocations(query: StoredLocationQuery): List> { + if (query.limit?.let { it <= 0L } == true || query.bounds?.hasNaN == true) { + return emptyList() + } + + // Match Cursor's null-to-zero conversion and Dart's invalid-coordinate fallback. + val latitude = "COALESCE(latitude, 0)" + val longitude = "COALESCE(longitude, 0)" + val invalidCoords = "($latitude < -90 OR $latitude > 90 OR $longitude < -180 OR $longitude > 180)" + val normalizedLatitude = "CASE WHEN $invalidCoords THEN 0 ELSE $latitude END" + val normalizedLongitude = "CASE WHEN $invalidCoords THEN 0 ELSE $longitude END" + val accuracy = "CASE WHEN $invalidCoords THEN -1 ELSE COALESCE(accuracy, 0) END" + val predicates = mutableListOf() + val bindings = mutableListOf() + fun predicate(sql: String, value: Number) { + predicates.add(sql) + bindings.add(value) + } + + query.from?.let { micros -> + val millis = Math.floorDiv(micros, MICROS_PER_MILLISECOND) + val roundUp = if (Math.floorMod(micros, MICROS_PER_MILLISECOND) == 0L) 0L else 1L + predicate("COALESCE(timestamp, 0) >= ?", millis + roundUp) + } + query.to?.let { predicate("COALESCE(timestamp, 0) <= ?", Math.floorDiv(it, MICROS_PER_MILLISECOND)) } + query.minAccuracy?.takeUnless { it.isNaN() }?.let { predicate("($accuracy) <= ?", it) } + query.maxAccuracy?.takeUnless { it.isNaN() }?.let { predicate("($accuracy) >= ?", it) } + query.isMoving?.let { predicate("CASE WHEN is_moving = 1 THEN 1 ELSE 0 END = ?", if (it) 1L else 0L) } + query.bounds?.let { bounds -> + predicates.add("($normalizedLatitude) BETWEEN ? AND ?") + bindings.addAll(listOf(bounds.south, bounds.north)) + predicates.add("($normalizedLongitude) BETWEEN ? AND ?") + bindings.addAll(listOf(bounds.west, bounds.east)) + } + val direction = if (query.oldestFirst) "ASC" else "DESC" + val selection = if (predicates.isEmpty()) "" else " WHERE ${predicates.joinToString(" AND ")}" + val sql = "SELECT * FROM locations$selection ORDER BY COALESCE(timestamp, 0) $direction, rowid ASC LIMIT ? OFFSET ?" + bindings.add(query.limit ?: -1L) + bindings.add(query.offset) + val records = mutableListOf>() + // Bind numerically: string selectionArgs would change comparison affinity for CASE + // expressions, and string conversion would lose IEEE infinity semantics. + readableDatabase.rawQueryWithFactory({ _, driver, editTable, statement -> + bindings.forEachIndexed { index, value -> + when (value) { + is Double -> statement.bindDouble(index + 1, value) + else -> statement.bindLong(index + 1, value.toLong()) + } + } + SQLiteCursor(driver, editTable, statement) + }, sql, emptyArray(), "locations").use { cursor -> + val columns = LocationColumns(cursor) + while (cursor.moveToNext()) records.add(columns.read(cursor)) + } + return records + } + + private class LocationColumns(cursor: Cursor) { + private val id = cursor.getColumnIndexOrThrow("id") + private val timestamp = cursor.getColumnIndexOrThrow("timestamp") + private val latitude = cursor.getColumnIndexOrThrow("latitude") + private val longitude = cursor.getColumnIndexOrThrow("longitude") + private val accuracy = cursor.getColumnIndexOrThrow("accuracy") + private val speed = cursor.getColumnIndexOrThrow("speed") + private val heading = cursor.getColumnIndexOrThrow("heading") + private val altitude = cursor.getColumnIndexOrThrow("altitude") + private val isMoving = cursor.getColumnIndexOrThrow("is_moving") + private val activityConfidence = cursor.getColumnIndexOrThrow("activity_confidence") + private val odometer = cursor.getColumnIndexOrThrow("odometer") + private val activityType = cursor.getColumnIndexOrThrow("activity_type") + private val event = cursor.getColumnIndexOrThrow("event") + private val extrasJson = cursor.getColumnIndexOrThrow("extras_json") + + fun read(cursor: Cursor): Map { + val record = mutableMapOf( + "id" to cursor.getString(id), + "timestamp" to cursor.getLong(timestamp), + "latitude" to cursor.getDouble(latitude), + "longitude" to cursor.getDouble(longitude), + "accuracy" to cursor.getDouble(accuracy), + "speed" to cursor.getDouble(speed), + "heading" to cursor.getDouble(heading), + "altitude" to cursor.getDouble(altitude), + "is_moving" to (cursor.getInt(isMoving) == 1), + "activity_confidence" to cursor.getInt(activityConfidence), + "odometer" to cursor.getDouble(odometer) + ) + cursor.getString(activityType)?.let { record["activity_type"] = it } + cursor.getString(event)?.let { record["event"] = it } + cursor.getString(extrasJson)?.let { record["extras_json"] = it } + return record + } + } + fun deleteLocations(ids: List?) { if (ids.isNullOrEmpty()) return @@ -243,6 +325,7 @@ class LocationStore(context: Context) : SQLiteOpenHelper(context, DB_NAME, null, } companion object { + private const val MICROS_PER_MILLISECOND = 1000L private const val DB_NAME = "locus.db" private const val DB_VERSION = 3 } diff --git a/android/src/main/kotlin/dev/locus/storage/StoredLocationQuery.kt b/android/src/main/kotlin/dev/locus/storage/StoredLocationQuery.kt new file mode 100644 index 0000000..e3a9ae0 --- /dev/null +++ b/android/src/main/kotlin/dev/locus/storage/StoredLocationQuery.kt @@ -0,0 +1,79 @@ +package dev.locus.storage + +/** Private method-channel input; time edges use UTC epoch microseconds. */ +internal data class StoredLocationQuery( + val from: Long?, + val to: Long?, + val minAccuracy: Double?, + val maxAccuracy: Double?, + val isMoving: Boolean?, + val bounds: Bounds?, + val limit: Long?, + val offset: Long, + val oldestFirst: Boolean, +) { + data class Bounds( + val south: Double, + val west: Double, + val north: Double, + val east: Double, + ) { + val hasNaN: Boolean + get() = south.isNaN() || west.isNaN() || north.isNaN() || east.isNaN() + } + + companion object { + fun fromArguments(arguments: Any?): StoredLocationQuery { + val map = arguments.asMapOrThrow("query") + val bounds = map["bounds"]?.let { value -> + val rectangle = value.asMapOrThrow("bounds") + val southwest = rectangle["southwest"].asMapOrThrow("southwest") + val northeast = rectangle["northeast"].asMapOrThrow("northeast") + Bounds( + southwest.requiredDouble("latitude"), + southwest.requiredDouble("longitude"), + northeast.requiredDouble("latitude"), + northeast.requiredDouble("longitude"), + ) + } + val moving = map["isMoving"]?.let { value -> + value as? Boolean ?: throw IllegalArgumentException("Expected isMoving boolean") + } + val oldestFirst = when (map["sortOrder"]) { + null, "newestFirst" -> false + "oldestFirst" -> true + else -> throw IllegalArgumentException("Invalid sortOrder") + } + return StoredLocationQuery( + from = map.optionalLong("from"), + to = map.optionalLong("to"), + minAccuracy = map.optionalDouble("minAccuracy"), + maxAccuracy = map.optionalDouble("maxAccuracy"), + isMoving = moving, + bounds = bounds, + limit = map.optionalLong("limit"), + offset = (map.optionalLong("offset") ?: 0L).coerceAtLeast(0L), + oldestFirst = oldestFirst, + ) + } + + private fun Any?.asMapOrThrow(name: String): Map<*, *> = + this as? Map<*, *> ?: throw IllegalArgumentException("Expected $name map") + + private fun Map<*, *>.optionalLong(key: String): Long? = when (val value = this[key]) { + null -> null + is Int -> value.toLong() + is Long -> value + else -> throw IllegalArgumentException("Expected integral $key") + } + + private fun Map<*, *>.optionalDouble(key: String): Double? = when (val value = this[key]) { + null -> null + is Number -> value.toDouble() + else -> throw IllegalArgumentException("Expected numeric $key") + } + + private fun Map<*, *>.requiredDouble(key: String): Double = + optionalDouble(key) ?: throw IllegalArgumentException("Missing numeric $key") + } +} diff --git a/android/src/test/kotlin/dev/locus/storage/StoredLocationQueryTest.kt b/android/src/test/kotlin/dev/locus/storage/StoredLocationQueryTest.kt new file mode 100644 index 0000000..9b5636a --- /dev/null +++ b/android/src/test/kotlin/dev/locus/storage/StoredLocationQueryTest.kt @@ -0,0 +1,118 @@ +package dev.locus.storage + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test + +class StoredLocationQueryTest { + @Test + fun `empty query defaults to unlimited newest first without offset`() { + val query = StoredLocationQuery.fromArguments(emptyMap()) + assertNull(query.from) + assertNull(query.to) + assertNull(query.limit) + assertNull(query.bounds) + assertNull(query.isMoving) + assertEquals(0L, query.offset) + assertFalse(query.oldestFirst) + } + + @Test + fun `timestamps and paging accept both channel integer widths without truncation`() { + val query = StoredLocationQuery.fromArguments( + mapOf("from" to -1, "to" to Long.MAX_VALUE, "limit" to 3, "offset" to 2L), + ) + assertEquals(-1L, query.from) + assertEquals(Long.MAX_VALUE, query.to) + assertEquals(3L, query.limit) + assertEquals(2L, query.offset) + } + + @Test + fun `integral fields reject floating numbers strings and booleans`() { + for (key in listOf("from", "to", "limit", "offset")) { + for (value in listOf(1.0, 1.5, "1", true)) { + val error = assertThrows(IllegalArgumentException::class.java) { + StoredLocationQuery.fromArguments(mapOf(key to value)) + } + assertEquals("Expected integral $key", error.message) + } + } + } + + @Test + fun `negative offset clamps to zero while nonpositive limits remain empty requests`() { + for (limit in listOf(0L, -1L, Long.MIN_VALUE)) { + val query = StoredLocationQuery.fromArguments(mapOf("offset" to -3, "limit" to limit)) + assertEquals(0L, query.offset) + assertEquals(limit, query.limit) + } + } + + @Test + fun `motion requires a boolean and sort requires a supported value`() { + for (moving in listOf(true, false)) { + assertEquals(moving, StoredLocationQuery.fromArguments(mapOf("isMoving" to moving)).isMoving) + } + for (invalid in listOf(0, "true", "false")) { + assertThrows(IllegalArgumentException::class.java) { + StoredLocationQuery.fromArguments(mapOf("isMoving" to invalid)) + } + } + assertTrue(StoredLocationQuery.fromArguments(mapOf("sortOrder" to "oldestFirst")).oldestFirst) + assertFalse(StoredLocationQuery.fromArguments(mapOf("sortOrder" to "newestFirst")).oldestFirst) + for (invalid in listOf("ascending", "", true, 1)) { + assertThrows(IllegalArgumentException::class.java) { + StoredLocationQuery.fromArguments(mapOf("sortOrder" to invalid)) + } + } + } + + @Test + fun `bounds require complete numeric corners`() { + val southwest = mapOf("latitude" to 10, "longitude" to 20.5) + val northeast = mapOf("latitude" to 11.5, "longitude" to 21) + val query = StoredLocationQuery.fromArguments( + mapOf("bounds" to mapOf("southwest" to southwest, "northeast" to northeast)), + ) + assertEquals(StoredLocationQuery.Bounds(10.0, 20.5, 11.5, 21.0), query.bounds) + for (bounds in listOf( + "rectangle", + emptyMap(), + mapOf("southwest" to southwest), + mapOf("southwest" to "corner", "northeast" to northeast), + mapOf("southwest" to mapOf("latitude" to 10), "northeast" to northeast), + mapOf("southwest" to mapOf("latitude" to true, "longitude" to 20), "northeast" to northeast), + )) { + assertThrows(IllegalArgumentException::class.java) { + StoredLocationQuery.fromArguments(mapOf("bounds" to bounds)) + } + } + } + + @Test + fun `accuracy thresholds require numbers and preserve NaN for query semantics`() { + val query = StoredLocationQuery.fromArguments(mapOf("minAccuracy" to 20, "maxAccuracy" to Double.NaN)) + assertEquals(20.0, query.minAccuracy) + assertTrue(query.maxAccuracy?.isNaN() == true) + for (key in listOf("minAccuracy", "maxAccuracy")) { + for (invalid in listOf("20", false)) { + assertThrows(IllegalArgumentException::class.java) { + StoredLocationQuery.fromArguments(mapOf(key to invalid)) + } + } + } + } + + @Test + fun `nonmap channel arguments are rejected`() { + for (arguments in listOf(null, "query", 1, emptyList())) { + assertThrows(IllegalArgumentException::class.java) { + StoredLocationQuery.fromArguments(arguments) + } + } + } +} diff --git a/doc/guides/querying-history.md b/doc/guides/querying-history.md new file mode 100644 index 0000000..ff2e6cf --- /dev/null +++ b/doc/guides/querying-history.md @@ -0,0 +1,74 @@ +# Querying stored location history + +Use the existing location service to filter and page stored records: + +```dart +// Capture the window once and reuse it for subsequent pages. +final to = DateTime.now(); +final from = to.subtract(const Duration(hours: 24)); +const pageSize = 50; + +Future> readPage(int offset) => Locus.location.query( + LocationQuery( + from: from, + to: to, + isMoving: true, + minAccuracy: 20, + sortOrder: LocationSortOrder.newestFirst, + limit: pageSize, + offset: offset, + ), +); + +final firstPage = await readPage(0); +final secondPage = await readPage(pageSize); +``` + +Android and iOS apply every filter, then the timestamp ordering, then the +offset and limit in native storage. Only the selected page crosses into Dart. +This also applies to `Locus.location.getSummary(query: ...)`. + +- Time and rectangular bounds include their edges. Dates represent instants, + so local and UTC query dates identify the same moment. +- Accuracy values are meters: `minAccuracy: 20` keeps records with accuracy + **at most** 20 meters; `maxAccuracy: 5` keeps values **at least** 5 meters. + These retain the existing query field meanings. +- Negative offsets act as zero. A zero or negative limit returns an empty + list. Omitting a limit requests all matching records and can still produce + a large response. +- Records with equal timestamps use a deterministic native row tie-breaker. + Pages are separate reads, so inserts/deletes between requests can change + an offset-based page. +- Native scans and sorting can still take time. Time ranges and paging stay + fast, but on iOS an accuracy, motion or bounds filter decodes each stored + record in the time range, so combine it with `from`/`to` on large + histories. Queries run away from the platform UI thread; this is not a + promise of constant-time lookup. +- On iOS, a stored record that cannot be read (corrupt JSON or wrongly typed + fields) is logged and excluded before paging, so it neither fails the query + nor shortens a page. iOS queries use their own read-only database + connection, so a long query does not delay saving new locations. + +After updating the SDK, rebuild the Android/iOS host rather than relying on +hot reload. An old native bundle reports `UNSUPPORTED_NATIVE_QUERY`; the SDK +does not fall back to an incorrect page or a full-history Dart read. Database +errors propagate to the caller. + +The older `Locus.location.getLocations(limit: ...)` operation keeps its +existing platform behavior. Prefer `query` when ordering or filtering matters. + +## Try it in the example + +Open **Demos → Storage → Stored Locations**. Choose the window, order, motion, +page size, accuracy limit, and optional rectangular bounds, then select +**Apply query**. The card validates finite numbers and coordinate ranges, +retains one fixed time window across pages, and resets the page when filters +change. It requests one extra record to enable **Next page** only when another +record exists. Errors offer a retry without showing stale page contents. + +The example's **Clear all stored locations** action clears the entire store, +including records outside the query. The query itself only reads records. + +See [Testing](../testing/unit-testing.md) for the channel, native SQLite, and +example widget regressions. [Error codes](../reference/error-codes.md#history-query-errors) +describe rebuild, invalid-input, and storage failures. diff --git a/doc/reference/error-codes.md b/doc/reference/error-codes.md index 4f5c26a..5a25ff5 100644 --- a/doc/reference/error-codes.md +++ b/doc/reference/error-codes.md @@ -1,6 +1,6 @@ # Error Codes Reference -Last updated: January 7, 2026 +Last updated: November 3, 2026 Common exceptions and native error codes with causes and fixes. @@ -46,6 +46,22 @@ Common exceptions and native error codes with causes and fixes. - **5:** Invalid request → Validate payload and required fields. - **6:** Missing manifest permission → Add required `` declarations. +## History query errors + +`Locus.location.query` and `Locus.location.getSummary` can report these +`PlatformException.code` values: + +| Code | Cause | Recovery | +| --- | --- | --- | +| `UNSUPPORTED_NATIVE_QUERY` | The installed native plugin does not implement the query method. | Rebuild and reinstall the Android/iOS host with the updated SDK; hot reload is insufficient. | +| `INVALID_ARGUMENT` | Native query arguments have malformed fields, such as a wrong type or unsupported sort order. | Check the query fields and report the error if a valid public query produces it. | +| `INVALID_RESULT` | The native reply is not a list of valid location maps. | Rebuild matching Dart/native SDK versions and report the malformed response. | +| `LOCATION_QUERY_ERROR` | The SQLite query itself failed. Individual unreadable records are skipped, not reported here. | Surface the failure, check storage health, and retry after recovery. Preserve the data while investigating. | + +These calls do not silently retry through a full-history read. A nonpositive +public query limit returns an empty result without calling native storage. +See [Querying stored history](../guides/querying-history.md). + ## Event stream error codes - **ERR_MISSING_MANIFEST:** Permission not declared in the manifest. Emitted during `Locus.ready()` on Android when required permissions are missing from `AndroidManifest.xml`. - **ERR_PERMISSION_DENIED:** User has not granted a required runtime permission. Emitted on both Android and iOS when location or network permissions are denied. diff --git a/doc/testing/unit-testing.md b/doc/testing/unit-testing.md index 99ea0ce..c3dce1d 100644 --- a/doc/testing/unit-testing.md +++ b/doc/testing/unit-testing.md @@ -1,165 +1,143 @@ -# Unit Testing +# Testing Locus -## Testing Locus Features +Host-app tests can use `MockLocus` from the public +`package:locus/locus.dart` import. No separate testing package is needed. +Mocks exercise application behavior without native platform calls; they do +not establish that SQLite, permissions, or background delivery work on a device. -Locus provides a comprehensive mock implementation for testing without native platform calls. +## Test a host app with MockLocus -## Setup - -Import the mock package: - -```dart -import 'package:locus/locus.dart'; -import 'package:locus/testing.dart'; -``` - -## Using Mock Locus - -Replace real Locus with mock in tests: +Install the mock before using the Locus services, restore the previous provider +after each case, and dispose the mock's streams: ```dart import 'package:flutter_test/flutter_test.dart'; import 'package:locus/locus.dart'; -import 'package:locus/testing.dart'; void main() { + TestWidgetsFlutterBinding.ensureInitialized(); late MockLocus mock; + late LocusInterface previous; setUp(() { + previous = Locus.instance; mock = MockLocus(); + Locus.setMockInstance(mock); + }); + + tearDown(() async { + Locus.setMockInstance(previous); + await mock.dispose(); }); - test('location tracking works', () async { - // Simulate location update - mock.addLocation(Location( - coords: Coords(latitude: 37.7749, longitude: -122.4194), - timestamp: DateTime.now(), - accuracy: 10, - speedAccuracy: 0, - speed: 0, + test('selects the matching history page', () async { + mock.emitLocation(Location( + uuid: 'stationary', + timestamp: DateTime.utc(2026, 11, 3, 10), + coords: const Coords(latitude: 52, longitude: 4, accuracy: 5), + isMoving: false, + )); + mock.emitLocation(Location( + uuid: 'moving', + timestamp: DateTime.utc(2026, 11, 3, 11), + coords: const Coords(latitude: 52, longitude: 4, accuracy: 5), + isMoving: true, )); - // Verify location was recorded - expect(mock.locations, isNotEmpty); - }); + final page = await Locus.location.query(const LocationQuery( + isMoving: true, + sortOrder: LocationSortOrder.newestFirst, + limit: 1, + )); - test('geofence triggers events', () async { - // Create geofence - final geofence = Geofence( - id: 'test', - latitude: 37.7749, - longitude: -122.4194, - radius: 100, - ); - - // Add to mock - await mock.geofencing.add(geofence); - - // Simulate entry - mock.triggerGeofenceEvent( - GeofenceEvent.enter(geofence: geofence, timestamp: DateTime.now()), - ); - - // Verify event was triggered - expect(mock.geofenceEvents, isNotEmpty); + expect(page.map((location) => location.uuid), ['moving']); }); } ``` -## Simulating Scenarios - -### Simulate Location Updates - -```dart -mock.addLocation(Location( - coords: Coords(latitude: 40.7128, longitude: -74.0060), - timestamp: DateTime.now(), - accuracy: 5, -)); -``` +Use `mock.emitLocation`, `mock.emitGeofenceEvent`, `mock.emitActivityChange`, +and `mock.emitPowerStateChange` to supply events to application listeners. +Use concrete public model constructors, including required coordinates and UUIDs. +The mock does not reproduce native permission dialogs, SQL failures, or OS +process recovery. -### Simulate Geofence Events +## Run the SDK's Dart and example suites -```dart -mock.triggerGeofenceEvent( - GeofenceEvent.enter(geofence: geofence, timestamp: DateTime.now()), -); +From the repository root: -mock.triggerGeofenceEvent( - GeofenceEvent.exit(geofence: geofence, timestamp: DateTime.now()), -); +```bash +flutter test +flutter analyze --fatal-infos --fatal-warnings ``` -### Simulate Battery Changes +From `example/`: -```dart -mock.battery.updateState(PowerState( - level: 0.15, - isLow: true, - isCharging: false, -)); +```bash +flutter test +flutter analyze --fatal-infos --fatal-warnings ``` -### Simulate Activity Changes +The history regressions exercise different boundaries: -```dart -mock.activity.updateActivity(Activity.walking); -mock.activity.updateActivity(Activity.stationary); -``` +| Suite | What it checks | +| --- | --- | +| `test/unit/core/native_history_query_test.dart` | The public facade uses the native query channel; exact UTC microsecond arguments; bounded replies are not paged again; malformed replies, native errors, and unsupported hosts remain visible; summaries use the same path. | +| `test/unit/core/permission_assistant_test.dart` | The captured permission request result controls permanent-denial settings fallback without a later status query; ordinary denial and delegate errors remain visible. | +| `example/test/history_query_card_test.dart` | Fixed-window paging, filter resets, numeric/bounds validation, loading and retry states, disposal, clear-all scope, and a populated 320-pixel layout at twice the text scale. | -## Assertions +Dart channel tests simulate platform messages. They do not run the production +native plugin. The example card tests inject a query function so they can check +UI states without a device. -Test SDK behavior with custom assertions: +## Run native history checks -```dart -test('respects privacy zones', () async { - // Setup - final zone = PrivacyZone( - id: 'home', - latitude: 37.7749, - longitude: -122.4194, - radius: 100, - ); - - await mock.privacy.add(zone); - - // Simulate location inside zone - mock.addLocation(Location( - coords: Coords(latitude: 37.7749, longitude: -122.4194), - timestamp: DateTime.now(), - accuracy: 5, - )); - - // Verify location was filtered - expect(mock.publicLocations, isEmpty); -}); -``` +Android JVM argument checks reuse the existing JUnit dependency. From +`example/android/`: -## Testing Async Operations +```bash +./gradlew :locus:testDebugUnitTest :locus:assembleDebugAndroidTest +``` -Properly handle async SDK calls: +For real SQLite checks, use a dedicated emulator. Install +`example/build/locus/outputs/apk/androidTest/debug/locus-debug-androidTest.apk` +with `adb install -r`, then run: -```dart -test('initializes locus', () async { - await mock.ready(); - - final isStarted = await mock.start(); - expect(isStarted, isTrue); -}); +```bash +adb shell am instrument -w dev.locus.test/dev.locus.core.ConfigSnapshotInstrumentation ``` -## Coverage +The existing runner includes `NativeHistoryQueryChecks`: filters before paging, +sorting, inclusive edges, timestamp rounding, and normalized coordinates. It +requires the dedicated `dev.locus.test` package and uses temporary databases, +not the example app's history. -Run tests with coverage: +The iOS helper package runs actual SQLite queries against temporary fixture +files with isolated preferences: ```bash -flutter test --coverage +swift test --package-path ios +LOCUS_NATIVE_TEST_MODULE=locus swift test --package-path ios --scratch-path ios/.build-lowercase ``` -View coverage report: +`NativeHistoryQueryTests` covers filtering and pagination, submillisecond and +historical dates, local DST gaps/overlaps, malformed stored data, and release +of the last storage reference on its database queue. The helper package does +not compile Flutter-dependent plugin files; use normal CocoaPods and SwiftPM +example builds to check those integrations. -```bash -lcov --list coverage/lcov.info -``` +CI runs Dart tests, Android JVM tests and instrumentation compilation, both +Swift helper module names, and CocoaPods/SwiftPM builds. It currently does not +execute Android instrumentation or the example widget suite. No CI workflow +was changed for this follow-up. + +## Check the running example + +Build and run the example on an emulator or simulator, then open +**Demos → Storage → Stored Locations**. On synthetic history, inspect first, +next, and previous pages; change a filter; check invalid inputs, empty results, +and a retry. Keep the captured window fixed while paging. Use a dedicated +installation before exercising **Clear all stored locations**, which removes +records outside the query too. -**Next:** [Contributing](../../CONTRIBUTING.md) +See [Querying stored history](../guides/querying-history.md) for the query +contract and [Contributing](../../CONTRIBUTING.md) for repository setup. diff --git a/example/README.md b/example/README.md index 9df944c..ac48dfd 100644 --- a/example/README.md +++ b/example/README.md @@ -12,7 +12,7 @@ This is a demonstration application for the Locus Background Geolocation SDK. It - **Privacy Zones**: obfuscation zones and privacy events. - **Sync & Queues**: sync policies, queue management, and dynamic headers. - **Battery Optimization**: adaptive tracking presets and runway estimation. -- **Diagnostics & History**: diagnostics snapshot, stored locations, and summary stats. +- **Diagnostics & History**: diagnostics snapshot, native history filters, bounded pages, and summary stats. ## Getting Started @@ -34,4 +34,29 @@ flutter run ## Implementation Notes -The core logic for this example is located in `lib/main.dart`. It demonstrates the use of `Locus.ready()` with a high-accuracy configuration and listeners for various event streams. +`lib/main.dart` launches the app in `lib/app.dart`. The demo screens live in +`lib/demos/demos_screen.dart`; the history controls live in +`lib/demos/history_query_card.dart`. + +### Explore stored history + +Open **Demos → Storage → Stored Locations**. Choose a time window, timestamp +order, motion state, page size, optional accuracy limit, and optional geographic +bounds, then select **Apply query**. Accuracy is an upper bound in meters; +bounds include their edges and require south ≤ north and west ≤ east. + +Each apply captures a fixed end time so moving between pages does not move the +time window. Changing a filter clears the old result and returns to the first +page on the next apply. The card requests the page size plus one record to +decide whether **Next page** is available; it does not fetch the whole history. +New or deleted records can still shift offset-based pages. + +Loading disables actions; invalid inputs, native failures, empty results, and +retry actions are visible. **Clear all stored locations** removes every stored +record, including records outside the selected filters. Use a dedicated example +installation with synthetic data when checking this action. + +Rebuild the native host after updating the plugin. Hot reload alone cannot add +the native query method. See [Querying stored history](../doc/guides/querying-history.md) +for the SDK contract and [Testing](../doc/testing/unit-testing.md) for the +widget, channel, and native storage checks. diff --git a/example/lib/demos/demos_screen.dart b/example/lib/demos/demos_screen.dart index 7ff0364..713c61e 100644 --- a/example/lib/demos/demos_screen.dart +++ b/example/lib/demos/demos_screen.dart @@ -5,6 +5,7 @@ import 'package:locus/locus.dart'; import 'package:locus_example/demos/battery_demo.dart'; import 'package:locus_example/demos/diagnostics_demo.dart'; import 'package:locus_example/demos/geofencing_demo.dart'; +import 'package:locus_example/demos/history_query_card.dart'; import 'package:locus_example/demos/privacy_demo.dart'; import 'package:locus_example/demos/sync_demo.dart'; import 'package:locus_example/demos/tracking_demo.dart'; @@ -48,7 +49,6 @@ class _DemosScreenState extends State { AdaptiveSettings? _adaptiveSettings; AdaptiveTrackingConfig? _adaptiveTrackingConfig; PowerStateChangeEvent? _lastPowerEvent; - List _storedLocations = []; LocationSummary? _locationSummary; List _syncQueue = []; DiagnosticsSnapshot? _diagnostics; @@ -446,16 +446,9 @@ class _DemosScreenState extends State { } } - Future _loadLocations() async { - final locs = await Locus.location.getLocations(limit: 50); - setState(() => _storedLocations = locs); - _showSnackbar('Loaded ${locs.length} location(s)'); - } - Future _clearLocations() async { await Locus.location.destroyLocations(); - setState(() => _storedLocations = []); - _showSnackbar('Locations cleared'); + if (mounted) _showSnackbar('All stored locations cleared'); } Future _loadLogs() async { @@ -1112,11 +1105,7 @@ class _DemosScreenState extends State { return ListView( padding: const EdgeInsets.all(16), children: [ - StoredLocationsCard( - locations: _storedLocations, - onLoad: _loadLocations, - onClear: _clearLocations, - ), + StoredLocationsCard(onClear: _clearLocations), const SizedBox(height: 16), SyncQueueCard( queue: _syncQueue, diff --git a/example/lib/demos/diagnostics_demo.dart b/example/lib/demos/diagnostics_demo.dart index 3a66a89..a1ed4a1 100644 --- a/example/lib/demos/diagnostics_demo.dart +++ b/example/lib/demos/diagnostics_demo.dart @@ -24,126 +24,6 @@ String _formatDistance(double meters) { return '${meters.toStringAsFixed(0)} m'; } -String _formatLocation(Location loc) { - return '${loc.coords.latitude.toStringAsFixed(5)}, ${loc.coords.longitude.toStringAsFixed(5)} (±${loc.coords.accuracy.toStringAsFixed(0)}m)'; -} - -/// Stored locations card — load / clear plus a preview list of the latest -/// stored points. -class StoredLocationsCard extends StatelessWidget { - const StoredLocationsCard({ - super.key, - required this.locations, - required this.onLoad, - required this.onClear, - }); - - final List locations; - final VoidCallback onLoad; - final VoidCallback onClear; - - @override - Widget build(BuildContext context) { - return Column( - children: [ - Card( - child: Padding( - padding: const EdgeInsets.all(20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SectionHeader( - icon: Icons.storage_rounded, - title: 'Stored Locations', - trailing: Container( - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 4, - ), - decoration: BoxDecoration( - color: const Color(0xFFF0F0F0), - borderRadius: BorderRadius.circular(20), - ), - child: Text( - '${locations.length}', - style: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - ), - ), - ), - ), - const SizedBox(height: 16), - Row( - children: [ - Expanded( - child: ActionButton( - onPressed: onLoad, - icon: Icons.download_rounded, - label: 'Load', - filled: true, - ), - ), - const SizedBox(width: 12), - Expanded( - child: ActionButton( - onPressed: onClear, - icon: Icons.delete_outline_rounded, - label: 'Clear', - ), - ), - ], - ), - ], - ), - ), - ), - if (locations.isNotEmpty) ...[ - const SizedBox(height: 16), - Card( - child: ListView.separated( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - itemCount: locations.length.clamp(0, 20), - separatorBuilder: (_, _) => const Divider(height: 1), - itemBuilder: (_, i) { - final loc = locations[i]; - return ListTile( - leading: CircleAvatar( - radius: 16, - backgroundColor: Theme.of(context) - .colorScheme - .primaryContainer, - child: Text( - '${i + 1}', - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.primary, - ), - ), - ), - title: Text( - _formatLocation(loc), - style: const TextStyle( - fontSize: 13, - fontFamily: 'monospace', - ), - ), - subtitle: Text( - loc.timestamp.toLocal().toString().substring(0, 19), - style: const TextStyle(fontSize: 11), - ), - ); - }, - ), - ), - ], - ], - ); - } -} - /// Logs card — fetches recent SDK log entries on demand. class LogsCard extends StatelessWidget { const LogsCard({super.key, required this.logs, required this.onLoad}); diff --git a/example/lib/demos/history_query_card.dart b/example/lib/demos/history_query_card.dart new file mode 100644 index 0000000..ef03628 --- /dev/null +++ b/example/lib/demos/history_query_card.dart @@ -0,0 +1,444 @@ +import 'package:locus/locus.dart'; +import 'package:material_ui/material_ui.dart'; + +/// A bounded native history query, with a fixed time window across its pages. +class StoredLocationsCard extends StatefulWidget { + const StoredLocationsCard({super.key, required this.onClear, this.query}); + + final Future Function() onClear; + final Future> Function(LocationQuery)? query; + + @override + State createState() => _StoredLocationsCardState(); +} + +class _StoredLocationsCardState extends State { + final _form = GlobalKey(); + final _boundsExpansion = ExpansibleController(); + final _accuracy = TextEditingController(); + final _south = TextEditingController(); + final _west = TextEditingController(); + final _north = TextEditingController(); + final _east = TextEditingController(); + int _hours = 0; + int _pageSize = 10; + LocationSortOrder _order = LocationSortOrder.newestFirst; + bool? _moving; + bool _useBounds = false; + bool _invalidQuery = false; + bool _busy = false; + bool _hasNext = false; + int _request = 0; + int _offset = 0; + LocationQuery? _applied; + List? _rows; + String? _error; + + @override + void dispose() { + for (final controller in [_accuracy, _south, _west, _north, _east]) { + controller.dispose(); + } + _boundsExpansion.dispose(); + super.dispose(); + } + + void _changed(VoidCallback change) { + setState(() { + change(); + _invalidQuery = false; + _request++; + _busy = false; + _offset = 0; + _applied = null; + _resetResults(); + }); + } + + void _resetResults() { + _rows = null; + _error = null; + _hasNext = false; + } + + String? _number(String? value, double min, double max) { + final number = double.tryParse(value?.trim() ?? ''); + if (number == null || !number.isFinite || number < min || number > max) { + return 'Enter a number from $min to $max'; + } + return null; + } + + Future _apply() async { + if (!_form.currentState!.validate()) { + if (_useBounds) _boundsExpansion.expand(); + setState(() => _invalidQuery = true); + return; + } + _invalidQuery = false; + FocusScope.of(context).unfocus(); + final now = DateTime.now(); + _applied = LocationQuery( + from: _hours == 0 ? null : now.subtract(Duration(hours: _hours)), + to: now, + sortOrder: _order, + isMoving: _moving, + // The SDK's historical minAccuracy name means accuracy <= this value. + minAccuracy: double.tryParse(_accuracy.text.trim()), + bounds: _useBounds + ? LocationBounds( + southwest: Coords( + latitude: double.parse(_south.text), + longitude: double.parse(_west.text), + accuracy: 0, + ), + northeast: Coords( + latitude: double.parse(_north.text), + longitude: double.parse(_east.text), + accuracy: 0, + ), + ) + : null, + ); + await _load(0); + } + + Future _load(int offset) async { + final applied = _applied; + if (applied == null) return; + final request = ++_request; + final pageSize = _pageSize; + setState(() { + _busy = true; + _offset = offset; + _resetResults(); + }); + try { + final rows = await (widget.query ?? Locus.location.query)( + LocationQuery( + from: applied.from, + to: applied.to, + minAccuracy: applied.minAccuracy, + isMoving: applied.isMoving, + bounds: applied.bounds, + sortOrder: applied.sortOrder, + offset: offset, + // One bounded look-ahead record tells us if another page exists. + limit: pageSize + 1, + ), + ); + if (!mounted || request != _request) return; + setState(() { + _rows = rows.take(pageSize).toList(); + _hasNext = rows.length > pageSize; + _busy = false; + }); + } catch (error) { + if (!mounted || request != _request) return; + setState(() { + _error = 'Could not load history: $error'; + _busy = false; + }); + } + } + + Future _clear() async { + final request = ++_request; + setState(() { + _busy = true; + _applied = null; + _offset = 0; + _resetResults(); + }); + try { + await widget.onClear(); + if (!mounted || request != _request) return; + setState(() { + _busy = false; + _rows = const []; + }); + } catch (error) { + if (!mounted || request != _request) return; + setState(() { + _busy = false; + _error = 'Could not clear all locations: $error'; + }); + } + } + + Widget _field( + String label, + TextEditingController controller, + String? Function(String?) validate, + ) => TextFormField( + controller: controller, + enabled: !_busy, + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + signed: true, + ), + decoration: InputDecoration(labelText: label), + validator: validate, + onChanged: (_) => _changed(() {}), + ); + + Widget _select( + String label, + T value, + Map options, + ValueChanged change, + ) => DropdownButtonFormField( + initialValue: value, + isExpanded: true, + decoration: InputDecoration(labelText: label), + items: options.entries + .map( + (entry) => + DropdownMenuItem(value: entry.key, child: Text(entry.value)), + ) + .toList(), + onChanged: _busy + ? null + : (selected) { + if (selected != null) _changed(() => change(selected)); + }, + ); + + @override + Widget build(BuildContext context) { + final page = _offset ~/ _pageSize + 1; + return Card( + child: Padding( + padding: const EdgeInsets.all(20), + child: Form( + key: _form, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Not SectionHeader: this title must wrap on narrow screens. + Row( + children: [ + Icon( + Icons.storage_rounded, + size: 20, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 10), + const Expanded( + child: Text( + 'Stored Locations', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + const SizedBox(height: 12), + const Text( + 'Query stored history in bounded pages. Apply filters to start a new fixed time window.', + ), + const SizedBox(height: 12), + _select('Time range', _hours, const { + 0: 'All history', + 1: 'Last hour', + 24: 'Last day', + 168: 'Last week', + }, (value) => _hours = value), + _select('Order', _order, const { + LocationSortOrder.newestFirst: 'Newest first', + LocationSortOrder.oldestFirst: 'Oldest first', + }, (value) => _order = value), + _select( + 'Motion', + _moving == null ? 'all' : (_moving! ? 'moving' : 'stationary'), + const { + 'all': 'All motion', + 'moving': 'Moving', + 'stationary': 'Stationary', + }, + (value) => _moving = value == 'all' ? null : value == 'moving', + ), + _select('Page size', _pageSize, const { + 10: '10 locations', + 25: '25 locations', + 50: '50 locations', + }, (value) => _pageSize = value), + _field('Accuracy limit (meters, optional)', _accuracy, (value) { + if (value == null || value.trim().isEmpty) return null; + final number = double.tryParse(value.trim()); + return number == null || !number.isFinite || number < 0 + ? 'Enter a finite number of meters, 0 or greater' + : null; + }), + const SizedBox(height: 6), + const Text( + 'Include accuracy ≤ this limit. Lower values mean more precise locations.', + ), + ExpansionTile( + controller: _boundsExpansion, + maintainState: true, + tilePadding: EdgeInsets.zero, + title: const Text('Geographic bounds (optional)'), + children: [ + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Filter by bounds'), + value: _useBounds, + onChanged: _busy + ? null + : (value) => _changed(() => _useBounds = value), + ), + if (_useBounds) ...[ + const Text( + 'Inclusive ranges. South ≤ north and west ≤ east; no longitude wrapping.', + ), + _field( + 'South latitude', + _south, + (value) => _number(value, -90, 90), + ), + _field('North latitude', _north, (value) { + final error = _number(value, -90, 90); + if (error != null) return error; + final south = double.tryParse(_south.text); + return south != null && double.parse(value!) < south + ? 'North must be at least south' + : null; + }), + _field( + 'West longitude', + _west, + (value) => _number(value, -180, 180), + ), + _field('East longitude', _east, (value) { + final error = _number(value, -180, 180); + if (error != null) return error; + final west = double.tryParse(_west.text); + return west != null && double.parse(value!) < west + ? 'East must be at least west' + : null; + }), + ], + ], + ), + const SizedBox(height: 12), + Wrap( + spacing: 12, + runSpacing: 8, + children: [ + FilledButton.icon( + onPressed: _busy ? null : _apply, + icon: const Icon(Icons.search, size: 18), + label: const Text('Apply query'), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + ), + ), + OutlinedButton.icon( + onPressed: _busy ? null : _clear, + icon: const Icon(Icons.delete_outline_rounded, size: 18), + label: const Text('Clear all stored locations'), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + const Text( + 'Clear all removes every stored location, including those outside this query.', + ), + if (_invalidQuery) + Semantics( + liveRegion: true, + child: Text( + 'Check the highlighted fields before applying the query.', + style: TextStyle( + color: Theme.of(context).colorScheme.error, + ), + ), + ), + if (_busy) ...[ + const SizedBox(height: 16), + const LinearProgressIndicator( + semanticsLabel: 'Loading stored locations', + ), + ], + if (_error case final error?) ...[ + const SizedBox(height: 12), + Semantics( + liveRegion: true, + child: Text( + error, + style: TextStyle( + color: Theme.of(context).colorScheme.error, + ), + ), + ), + TextButton( + onPressed: _applied == null ? _clear : () => _load(_offset), + child: const Text('Retry'), + ), + ], + if (_applied != null) ...[ + const SizedBox(height: 12), + Text('Window ends ${_applied!.to!.toLocal()}'), + Text( + '${_busy ? 'Loading page' : 'Page'} $page', + semanticsLabel: 'History page $page', + ), + ], + if (_rows case final rows?) ...[ + if (rows.isEmpty) + const Padding( + padding: EdgeInsets.symmetric(vertical: 16), + child: Text( + 'No stored locations match. Adjust filters or record some locations.', + ), + ), + for (final (index, location) in rows.indexed) + ListTile( + contentPadding: EdgeInsets.zero, + title: Text( + '${_offset + index + 1}. ${location.coords.latitude.toStringAsFixed(5)}, ${location.coords.longitude.toStringAsFixed(5)}', + ), + subtitle: Text( + '${location.timestamp.toLocal()}\nAccuracy ${location.coords.accuracy.toStringAsFixed(0)} m', + ), + isThreeLine: true, + ), + ], + Wrap( + spacing: 12, + children: [ + TextButton.icon( + onPressed: !_busy && _error == null && _offset > 0 + ? () => _load(_offset - _pageSize) + : null, + icon: const Icon(Icons.chevron_left), + label: const Text('Previous page'), + ), + TextButton.icon( + onPressed: !_busy && _error == null && _hasNext + ? () => _load(_offset + _pageSize) + : null, + icon: const Icon(Icons.chevron_right), + label: const Text('Next page'), + ), + ], + ), + ], + ), + ), + ), + ); + } +} diff --git a/example/test/history_query_card_test.dart b/example/test/history_query_card_test.dart new file mode 100644 index 0000000..43129fa --- /dev/null +++ b/example/test/history_query_card_test.dart @@ -0,0 +1,301 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:locus/locus.dart'; +import 'package:locus_example/demos/history_query_card.dart'; +import 'package:material_ui/material_ui.dart'; + +List points(int count) => List.generate( + count, + (index) => Location( + uuid: 'point-$index', + timestamp: DateTime.utc(2026, 11, 3, 12, index), + coords: const Coords(latitude: 52, longitude: 4, accuracy: 5), + ), +); + +Future showCard( + WidgetTester tester, + Future> Function(LocationQuery) query, { + Future Function()? clear, + double textScale = 1, +}) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(colorSchemeSeed: const Color(0xFF2E5D4B)), + home: Scaffold( + body: MediaQuery( + data: MediaQueryData(textScaler: TextScaler.linear(textScale)), + child: SingleChildScrollView( + child: StoredLocationsCard( + query: query, + onClear: clear ?? () async {}, + ), + ), + ), + ), + ), + ); +} + +Future press(WidgetTester tester, String text) async { + final target = find.text(text); + await tester.ensureVisible(target); + await tester.tap(target); + await tester.pump(); +} + +Future select(WidgetTester tester, String current, String next) async { + await press(tester, current); + await tester.pumpAndSettle(); + await tester.tap(find.text(next).last); + await tester.pumpAndSettle(); +} + +void main() { + testWidgets('bounded paging keeps a fixed window and resets for filters', ( + tester, + ) async { + final queries = []; + await showCard(tester, (query) async { + queries.add(query); + return points(query.offset == 0 ? query.limit! : 1); + }); + await select(tester, 'All history', 'Last hour'); + await select(tester, 'All motion', 'Moving'); + await tester.enterText(find.byType(TextFormField), '25'); + await press(tester, 'Apply query'); + await tester.pumpAndSettle(); + expect(queries.single.limit, 11); + expect(queries.single.isMoving, isTrue); + expect(queries.single.minAccuracy, 25); + expect( + queries.single.to!.difference(queries.single.from!), + const Duration(hours: 1), + ); + await press(tester, 'Next page'); + await tester.pumpAndSettle(); + expect(queries.last.offset, 10); + expect(queries.last.to, queries.first.to); + expect(queries.last.from, queries.first.from); + expect(find.text('Page 2'), findsOneWidget); + expect( + tester + .widget(find.widgetWithText(TextButton, 'Next page')) + .onPressed, + isNull, + ); + await press(tester, 'Previous page'); + await tester.pumpAndSettle(); + expect(queries.last.offset, 0); + await select(tester, 'Newest first', 'Oldest first'); + expect(find.text('Page 1'), findsNothing); + await press(tester, 'Apply query'); + await tester.pumpAndSettle(); + expect(queries.last.sortOrder, LocationSortOrder.oldestFirst); + expect(queries.last.offset, 0); + await select(tester, '10 locations', '25 locations'); + await press(tester, 'Apply query'); + await tester.pumpAndSettle(); + expect(queries.last.limit, 26); + expect(queries.last.offset, 0); + }); + + testWidgets( + 'loading a new page disables pagers and ignores completion after disposal', + (tester) async { + final pending = Completer>(); + await showCard( + tester, + (query) => + query.offset == 0 ? Future.value(points(11)) : pending.future, + ); + await press(tester, 'Apply query'); + await tester.pumpAndSettle(); + await press(tester, 'Next page'); + expect(find.text('Loading page 2'), findsOneWidget); + expect(find.textContaining('52.00000, 4.00000'), findsNothing); + expect(find.byType(LinearProgressIndicator), findsOneWidget); + expect( + tester + .widget( + find.widgetWithText(FilledButton, 'Apply query'), + ) + .onPressed, + isNull, + ); + for (final label in ['Previous page', 'Next page']) { + expect( + tester + .widget(find.widgetWithText(TextButton, label)) + .onPressed, + isNull, + ); + } + expect( + tester + .widget( + find.widgetWithText(OutlinedButton, 'Clear all stored locations'), + ) + .onPressed, + isNull, + ); + await tester.pumpWidget(const MaterialApp(home: Text('Left Storage'))); + pending.complete(points(11)); + await tester.pumpAndSettle(); + expect(find.text('Left Storage'), findsOneWidget); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets( + 'error retry uses the same query and empty result explains next action', + (tester) async { + final queries = []; + await showCard(tester, (query) async { + queries.add(query); + if (queries.length == 1) throw StateError('Offline'); + return []; + }); + await press(tester, 'Apply query'); + await tester.pumpAndSettle(); + expect(find.textContaining('Could not load history:'), findsOneWidget); + await press(tester, 'Retry'); + await tester.pumpAndSettle(); + expect(queries.last.to, queries.first.to); + expect(queries.last.offset, 0); + expect(find.textContaining('No stored locations match.'), findsOneWidget); + expect(find.text('Retry'), findsNothing); + }, + ); + + testWidgets('accuracy rejects nonfinite and negative input before querying', ( + tester, + ) async { + var calls = 0; + await showCard(tester, (_) async { + calls++; + return []; + }); + for (final invalid in ['NaN', 'Infinity', '-1']) { + await tester.ensureVisible(find.byType(TextFormField)); + await tester.enterText(find.byType(TextFormField), invalid); + await press(tester, 'Apply query'); + expect( + find.text('Enter a finite number of meters, 0 or greater'), + findsOneWidget, + ); + } + expect(calls, 0); + }); + + testWidgets( + 'collapsed blank bounds reopen with field errors and do not query', + (tester) async { + var calls = 0; + await showCard(tester, (_) async { + calls++; + return []; + }); + await press(tester, 'Geographic bounds (optional)'); + await tester.pumpAndSettle(); + await press(tester, 'Filter by bounds'); + await tester.pumpAndSettle(); + await press(tester, 'Geographic bounds (optional)'); + await tester.pumpAndSettle(); + expect(find.text('South latitude'), findsNothing); + await press(tester, 'Apply query'); + await tester.pumpAndSettle(); + expect(calls, 0); + expect(tester.takeException(), isNull); + expect(find.text('South latitude'), findsOneWidget); + expect(find.text('Enter a number from -90.0 to 90.0'), findsNWidgets(2)); + expect( + find.text('Enter a number from -180.0 to 180.0'), + findsNWidgets(2), + ); + expect( + find.text('Check the highlighted fields before applying the query.'), + findsOneWidget, + ); + }, + ); + + testWidgets( + 'collapsed bounds validate ranges and ordering, valid inclusive bounds query', + (tester) async { + final queries = []; + await showCard(tester, (query) async { + queries.add(query); + return []; + }); + await press(tester, 'Geographic bounds (optional)'); + await tester.pumpAndSettle(); + await press(tester, 'Filter by bounds'); + await tester.pumpAndSettle(); + Future enter(String label, String value) async { + final input = find.widgetWithText(TextFormField, label); + await tester.ensureVisible(input); + await tester.enterText(input, value); + } + + await enter('South latitude', '91'); + await enter('North latitude', '10'); + await enter('West longitude', '20'); + await enter('East longitude', '10'); + await press(tester, 'Geographic bounds (optional)'); + await tester.pumpAndSettle(); + await press(tester, 'Apply query'); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + expect(queries, isEmpty); + expect(find.text('Enter a number from -90.0 to 90.0'), findsOneWidget); + expect(find.text('East must be at least west'), findsOneWidget); + await enter('South latitude', '-90'); + await enter('North latitude', '90'); + await enter('West longitude', '-180'); + await enter('East longitude', '180'); + await press(tester, 'Geographic bounds (optional)'); + await tester.pumpAndSettle(); + expect(find.text('South latitude'), findsNothing); + await press(tester, 'Apply query'); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + expect( + find.text('Check the highlighted fields before applying the query.'), + findsNothing, + ); + expect(queries.single.bounds!.southwest.latitude, -90); + expect(queries.single.bounds!.northeast.longitude, 180); + }, + ); + + testWidgets('narrow large-text layout and clear-all callback remain usable', ( + tester, + ) async { + tester.view.physicalSize = const Size(320, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + var clears = 0; + await showCard( + tester, + (_) async => points(1), + clear: () async { + clears++; + }, + textScale: 2, + ); + await press(tester, 'Apply query'); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + await press(tester, 'Clear all stored locations'); + await tester.pumpAndSettle(); + expect(clears, 1); + expect( + find.textContaining('including those outside this query'), + findsOneWidget, + ); + expect(tester.takeException(), isNull); + }); +} diff --git a/ios/Tests/NativeHistoryQueryTests.swift b/ios/Tests/NativeHistoryQueryTests.swift new file mode 100644 index 0000000..31c2a83 --- /dev/null +++ b/ios/Tests/NativeHistoryQueryTests.swift @@ -0,0 +1,354 @@ +import Foundation +import XCTest +#if canImport(Locus) +@testable import Locus +#elseif canImport(locus) +@testable import locus +#endif + +final class NativeHistoryQueryTests: XCTestCase { + private let epoch: Int64 = 1_783_936_800_000_000 + + func testEveryFilterRunsBeforePagingInBothSortOrders() async throws { + let fixture = try makeFixture() + for second in [0, 8] { + fixture.storage.insertLocation(location("inaccurate-\(second)", second: second, accuracy: 101)) + fixture.storage.insertLocation(location("too-accurate-\(second)", second: second, accuracy: 9)) + fixture.storage.insertLocation(location("stationary-\(second)", second: second, moving: false)) + fixture.storage.insertLocation(location("outside-\(second)", second: second, latitude: 60)) + } + fixture.storage.insertLocation(location("before", second: -1)) + fixture.storage.insertLocation(location("after", second: 9)) + for second in [2, 3, 4] { + fixture.storage.insertLocation(location("match-\(second)", second: second)) + } + let filters: [String: Any] = [ + "from": epoch, "to": epoch + 8_000_000, + "minAccuracy": 100, "maxAccuracy": 10, "isMoving": true, + "bounds": bounds(south: 51, west: 3, north: 53, east: 5), + "limit": 2, "offset": 1, + ] + for sortOrder in ["oldestFirst", "newestFirst"] { + var arguments = filters + arguments["sortOrder"] = sortOrder + let rows = try await query(fixture.storage, arguments) + let expected = sortOrder == "oldestFirst" ? ["match-3", "match-4"] : ["match-3", "match-2"] + XCTAssertEqual(ids(rows), expected, sortOrder) + } + XCTAssertEqual(fixture.storage.locationCount(), 13) + } + + func testBoundsAndAccuracyEdgesAreInclusive() async throws { + let fixture = try makeFixture() + fixture.storage.insertLocation(location("southwest", accuracy: 10, latitude: 51, longitude: 3)) + fixture.storage.insertLocation(location("northeast", accuracy: 100, latitude: 53, longitude: 5)) + fixture.storage.insertLocation(location("outside", latitude: 53.000001)) + let rows = try await query(fixture.storage, [ + "minAccuracy": 100, "maxAccuracy": 10, + "bounds": bounds(south: 51, west: 3, north: 53, east: 5), + ]) + XCTAssertEqual(ids(rows), ["southwest", "northeast"]) + } + + func testMicrosecondBoundariesAndTimezoneOffsetsAreInclusive() async throws { + let fixture = try makeFixture() + for (id, timestamp) in [ + ("before", "2026-07-13T10:00:00.123455Z"), + ("edge", "2026-07-13T12:00:00.123456+02:00"), + ("after", "2026-07-13T10:00:00.123457Z"), + ] { + fixture.storage.insertLocation(location(id, timestamp: timestamp)) + } + let edge = epoch + 123_456 + let rows = try await query(fixture.storage, ["from": edge, "to": edge]) + XCTAssertEqual(ids(rows), ["edge"]) + } + + func testNegativeEpochFractionsSortChronologically() async throws { + let fixture = try makeFixture() + fixture.storage.insertLocation(location("earlier", timestamp: "1969-12-31T23:59:59.999998Z")) + fixture.storage.insertLocation(location("edge", timestamp: "1969-12-31T23:59:59.999999Z")) + fixture.storage.insertLocation(location("epoch", timestamp: "1970-01-01T00:00:00Z")) + let rows = try await query(fixture.storage, ["from": Int64(-1), "to": Int64(-1)]) + XCTAssertEqual(ids(rows), ["edge"]) + let sorted = try await query(fixture.storage, ["sortOrder": "oldestFirst"]) + XCTAssertEqual(ids(sorted), ["earlier", "edge", "epoch"]) + } + + func testTiedTimestampsKeepInsertionOrderAcrossPagesAndSortDirections() async throws { + let fixture = try makeFixture() + for id in ["z", "a", "m"] { + fixture.storage.insertLocation(location(id)) + } + for sortOrder in ["oldestFirst", "newestFirst"] { + let first = try await query(fixture.storage, ["sortOrder": sortOrder, "limit": 2]) + let second = try await query(fixture.storage, ["sortOrder": sortOrder, "limit": 2, "offset": 2]) + XCTAssertEqual(ids(first) + ids(second), ["z", "a", "m"], sortOrder) + } + } + + func testNonpositiveLimitsAreEmptyAndNegativeOffsetStartsAtFirstMatch() async throws { + let fixture = try makeFixture() + fixture.storage.insertLocation(location("first")) + fixture.storage.insertLocation(location("second", second: 1)) + for limit in [0, -1] { + let rows = try await query(fixture.storage, ["limit": limit]) + XCTAssertTrue(rows.isEmpty) + } + let rows = try await query(fixture.storage, ["offset": -10, "limit": 1, "sortOrder": "oldestFirst"]) + XCTAssertEqual(ids(rows), ["first"]) + } + + func testOmittedLimitReturnsAllMatchesWithoutChangingLegacyReadsOrCount() async throws { + let fixture = try makeFixture() + let recordCount = 120 + for index in 0 ..< recordCount { + fixture.storage.insertLocation(location("row-\(index)", moving: index.isMultiple(of: 2))) + } + let before = fixture.storage.readLocations() + let rows = try await query(fixture.storage, ["isMoving": true]) + XCTAssertEqual(rows.count, recordCount / 2) + let allRows = try await query(fixture.storage) + XCTAssertEqual(allRows.count, recordCount) + XCTAssertEqual(fixture.storage.locationCount(), recordCount) + XCTAssertEqual(fixture.storage.readLocations() as NSArray, before as NSArray) + XCTAssertEqual(fixture.storage.readLocations(limit: 3).count, 3) + } + + func testMissingNullAndInvalidTimestampsUseTheCapturedFallback() async throws { + let fixture = try makeFixture() + var missing = location("missing") + missing.removeValue(forKey: "timestamp") + var null = location("null") + null["timestamp"] = NSNull() + for row in [missing, null, location("invalid", timestamp: "not-a-date")] { + fixture.storage.insertLocation(row) + } + let fallback: Int64 = 42 + let rows = try await query(fixture.storage, ["from": fallback, "to": fallback]) + XCTAssertEqual(ids(rows), ["missing", "null", "invalid"]) + } + + func testInvalidCoordinatesUseTheExistingZeroCoordinateAndNegativeAccuracyFallback() async throws { + let fixture = try makeFixture() + fixture.storage.insertLocation(location("invalid", accuracy: 50, latitude: 91, longitude: 4)) + fixture.storage.insertLocation(location("valid")) + let rows = try await query(fixture.storage, [ + "minAccuracy": -1, "maxAccuracy": -1, + "bounds": bounds(south: 0, west: 0, north: 0, east: 0), + ]) + XCTAssertEqual(ids(rows), ["invalid"]) + } + + func testMissingAndNullCoordinateFieldsUseZeroDefaults() async throws { + let fixture = try makeFixture() + var missing = location("missing") + missing.removeValue(forKey: "coords") + var null = location("null") + null["coords"] = ["latitude": NSNull(), "longitude": NSNull(), "accuracy": NSNull()] + fixture.storage.insertLocation(missing) + fixture.storage.insertLocation(null) + fixture.storage.insertLocation(location("ordinary")) + let rows = try await query(fixture.storage, [ + "minAccuracy": 0, "maxAccuracy": 0, + "bounds": bounds(south: 0, west: 0, north: 0, east: 0), + ]) + XCTAssertEqual(ids(rows), ["missing", "null"]) + } + + func testMalformedArgumentsAreRejectedInsteadOfReturningAnEmptyPage() { + let malformed: [Any] = [ + NSNull(), [:], ["timestampFallback": true], + ["timestampFallback": 42, "limit": 1.5], + ["timestampFallback": 42, "from": "42"], + ["timestampFallback": 42, "offset": true], + ["timestampFallback": 42, "sortOrder": "sideways"], + ["timestampFallback": 42, "isMoving": 1], + ["timestampFallback": 42, "bounds": ["southwest": ["latitude": 0]]], + ] + for arguments in malformed { + XCTAssertThrowsError(try StoredLocationQuery(arguments: arguments)) { error in + XCTAssertEqual((error as NSError).domain, "dev.locus.locationQuery") + } + } + } + + func testMalformedStoredValuesAreSkippedWithoutFailingTheQuery() async throws { + for field in ["timestamp", "latitude", "accuracy", "is_moving"] { + let fixture = try makeFixture() + var row = location("malformed") + if field == "latitude" || field == "accuracy" { + var coords = try XCTUnwrap(row["coords"] as? [String: Any]) + coords[field] = "invalid-number" + row["coords"] = coords + } else { + row[field] = field == "timestamp" ? 123 : "true" + } + fixture.storage.insertLocation(row) + fixture.storage.insertLocation(location("valid", second: 1)) + let rows = try await query(fixture.storage, ["isMoving": true]) + XCTAssertEqual(ids(rows), ["valid"], field) + XCTAssertEqual(fixture.storage.locationCount(), 2) + } + } + + func testQueryCompletesWhenItsQueueOwnsTheLastStorageReference() async throws { + var fixture: Fixture? = try makeFixture(retainUntilTeardown: false) + weak var releasedStorage: SQLiteStorage? + func storageQueue(_ label: String) throws -> DispatchQueue { + try XCTUnwrap( + try Mirror(reflecting: XCTUnwrap(fixture).storage).children + .first { $0.label == label }?.value as? DispatchQueue + ) + } + let queue = try storageQueue("queue") + let queryQueue = try storageQueue("queryQueue") + releasedStorage = fixture?.storage + let blocker = DispatchSemaphore(value: 0) + queryQueue.async { + XCTAssertEqual(blocker.wait(timeout: .now() + 5), .success) + } + let completed = expectation(description: "Query completes after its caller releases storage") + let query = try StoredLocationQuery(arguments: ["timestampFallback": Int64(42)]) + try XCTUnwrap(fixture).storage.queryLocations(query) { result in + XCTAssertTrue(Thread.isMainThread) + switch result { + case let .success(rows): + XCTAssertTrue(rows.isEmpty) + case let .failure(error): + XCTFail(error.localizedDescription) + } + completed.fulfill() + } + // The blocked query queue now owns the only strong reference through the + // pending query. Releasing that query must safely close on this queue. + fixture = nil + blocker.signal() + await fulfillment(of: [completed], timeout: 5) + queryQueue.sync {} + queue.sync {} + XCTAssertNil(releasedStorage) + } + + func testProlepticGregorianYear1500UsesExactUtcEpoch() async throws { + // Python datetime's proleptic Gregorian arithmetic, not Foundation's + // historical Gregorian cutover: 470 * 365 days plus Gregorian leap days. + try await assertTimestamp("1500-01-01T00:00:00Z", equals: -14_831_769_600_000_000) + } + + func testLargeNumericTimezoneOffsetIsAccepted() async throws { + try await assertTimestamp("2026-01-01T19:00:00+19", equals: 1_767_225_600_000_000) + } + + func testOverflowDayNormalizesLikeDartDateTimeParse() async throws { + try await assertTimestamp("2020-01-42T00:00:00Z", equals: 1_581_379_200_000_000) + } + + func testSuffixlessHistoricalTimestampUsesDartEquivalentYearOffset() async throws { + let restoreTimeZone = useSystemTimeZone("Europe/Amsterdam") + defer { restoreTimeZone() } + // Dart uses the equivalent modern year's +01:00 offset, not the + // historical Amsterdam local mean time recorded in the zone database. + try await assertTimestamp("1500-01-01T00:00:00", equals: -14_831_773_200_000_000) + } + + func testSuffixlessDstGapAndOverlapMatchDartInstantSelection() async throws { + let restoreTimeZone = useSystemTimeZone("Europe/Amsterdam") + defer { restoreTimeZone() } + try await assertTimestamp("2026-03-29T02:30:00", equals: 1_774_747_800_000_000) + try await assertTimestamp("2026-10-25T02:30:00", equals: 1_792_888_200_000_000) + } + + // Dart reads the system zone, so the test sets TZ rather than NSTimeZone.default. + private func useSystemTimeZone(_ identifier: String) -> () -> Void { + let original = getenv("TZ").map { String(cString: $0) } + setenv("TZ", identifier, 1) + tzset() + NSTimeZone.resetSystemTimeZone() + return { + if let original { setenv("TZ", original, 1) } else { unsetenv("TZ") } + tzset() + NSTimeZone.resetSystemTimeZone() + } + } + + private func assertTimestamp(_ timestamp: String, equals expected: Int64) async throws { + let fixture = try makeFixture() + fixture.storage.insertLocation(location("edge", timestamp: timestamp)) + let matching = try await query(fixture.storage, ["from": expected, "to": expected]) + XCTAssertEqual(ids(matching), ["edge"], timestamp) + let before = try await query(fixture.storage, ["to": expected - 1]) + let after = try await query(fixture.storage, ["from": expected + 1]) + XCTAssertTrue(before.isEmpty, timestamp) + XCTAssertTrue(after.isEmpty, timestamp) + } + + private func query(_ storage: SQLiteStorage, _ arguments: [String: Any] = [:]) async throws -> [[String: Any]] { + var arguments = arguments + arguments["timestampFallback"] = Int64(42) + let query = try StoredLocationQuery(arguments: arguments) + return try await withCheckedThrowingContinuation { continuation in + storage.queryLocations(query) { result in + XCTAssertTrue(Thread.isMainThread, "Query completions must return to the channel's main thread") + continuation.resume(with: result) + } + } + } + + private func location( + _ id: String, + second: Int = 0, + timestamp: String? = nil, + accuracy: Double = 25, + moving: Bool = true, + latitude: Double = 52, + longitude: Double = 4 + ) -> [String: Any] { + let date = Date(timeIntervalSince1970: Double(epoch / 1_000_000) + Double(second)) + return [ + "uuid": id, + "timestamp": timestamp ?? ISO8601DateFormatter().string(from: date), + "coords": ["latitude": latitude, "longitude": longitude, "accuracy": accuracy], + "is_moving": moving, + ] + } + + private func bounds(south: Double, west: Double, north: Double, east: Double) -> [String: Any] { + [ + "southwest": ["latitude": south, "longitude": west], + "northeast": ["latitude": north, "longitude": east], + ] + } + + private func ids(_ rows: [[String: Any]]) -> [String] { + rows.map { $0["uuid"] as? String ?? "" } + } + + private func makeFixture(retainUntilTeardown: Bool = true) throws -> Fixture { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("locus-history-tests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let suiteName = "dev.locus.history-tests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + let storage = SQLiteStorage( + databaseURL: directory.appendingPathComponent("storage.sqlite"), + userDefaults: defaults + ) + addTeardownBlock { + defaults.removePersistentDomain(forName: suiteName) + try? FileManager.default.removeItem(at: directory) + } + if retainUntilTeardown { + addTeardownBlock { + // Keep storage alive until its asynchronous work has drained, + // and release it on the test runner rather than its serial queue. + _ = storage.locationCount() + } + } + return Fixture(storage: storage) + } + + private struct Fixture { + let storage: SQLiteStorage + } +} diff --git a/ios/locus/Sources/locus/Storage/SQLiteStorage.swift b/ios/locus/Sources/locus/Storage/SQLiteStorage.swift index 7f69201..7dd97c3 100644 --- a/ios/locus/Sources/locus/Storage/SQLiteStorage.swift +++ b/ios/locus/Sources/locus/Storage/SQLiteStorage.swift @@ -11,9 +11,14 @@ class SQLiteStorage { private var db: OpaquePointer? private static let dbName = "locus_storage.sqlite" + private static let busyTimeoutMilliseconds: Int32 = 5_000 private let databaseURL: URL? private let userDefaults: UserDefaults private let queue = DispatchQueue(label: "dev.locus.sqlite", qos: .utility) + private let queryQueue = DispatchQueue(label: "dev.locus.sqlite.query", qos: .utility) + /// Only used on `queryQueue`. + private var readDb: OpaquePointer? + private var readSupportsJSON = false init( databaseURL: URL? = nil, @@ -27,10 +32,12 @@ class SQLiteStorage { } deinit { - queue.sync { - if let db = self.db { - sqlite3_close(db) - self.db = nil + // Every user of the connections holds `self`, so none is running now. + for connection in [db, readDb] { + guard let connection else { continue } + let closeResult = sqlite3_close(connection) + if closeResult != SQLITE_OK { + NSLog("[locus.SQLiteStorage] Database close failed: %d", closeResult) } } } @@ -64,7 +71,7 @@ class SQLiteStorage { sqlite3_exec(db, "PRAGMA journal_mode=WAL;", nil, nil, nil) sqlite3_exec(db, "PRAGMA synchronous=FULL;", nil, nil, nil) // Wait up to 5s on contention rather than failing fast with SQLITE_BUSY. - sqlite3_busy_timeout(db, 5_000) + sqlite3_busy_timeout(db, Self.busyTimeoutMilliseconds) protectDatabaseFiles() } @@ -433,6 +440,146 @@ class SQLiteStorage { return results } + /// Uses a separate read-only connection so long scans never block writes. + func queryLocations(_ query: StoredLocationQuery, + completion: @escaping (Result<[[String: Any]], Error>) -> Void) { + // Hop through the write queue so earlier writes are visible. + queue.async { + self.queryQueue.async { + let result = Result { try self.queryLocationsOnQueryQueue(query) } + DispatchQueue.main.async { completion(result) } + } + } + } + + /// Caller must hold `queryQueue`. + private func readConnectionOnQueryQueue() -> OpaquePointer? { + if let readDb { return readDb } + guard let databaseURL else { return nil } + var connection: OpaquePointer? + guard sqlite3_open_v2(databaseURL.path, &connection, SQLITE_OPEN_READONLY, nil) == SQLITE_OK else { + let message = connection.map { String(cString: sqlite3_errmsg($0)) } ?? "out of memory" + NSLog("[locus.SQLiteStorage] Read connection open failed: %@", message) + sqlite3_close(connection) + return nil + } + sqlite3_busy_timeout(connection, Self.busyTimeoutMilliseconds) + // Without JSON functions every row takes the per-row parser. + readSupportsJSON = sqlite3_exec(connection, "SELECT json_valid('{}')", nil, nil, nil) == SQLITE_OK + readDb = connection + return connection + } + + /// Rows whose payload timestamp equals the whole-second UTC `timestamp` column + /// filter and sort on that column; all others use the per-row parser. + /// Binds: from, to, limit, offset. + private func locationQuerySQL(for query: StoredLocationQuery) -> String { + // json_extract raises an error on malformed JSON. + let canonical = readSupportsJSON ? """ + CASE WHEN json_valid(payload) THEN + json_extract(payload, '$.timestamp') IS timestamp + AND strftime('%Y-%m-%dT%H:%M:%SZ', timestamp) IS timestamp + ELSE 0 END + """ : "0" + let columnMicros = "CAST(strftime('%s', timestamp) AS INTEGER) * \(StoredLocationQuery.microsPerSecond)" + let parsed = "locus_query_timestamp(payload)" + // LIMIT -1 stops SQLite inlining sort_key and evaluating it twice. + return """ + SELECT payload FROM ( + SELECT id, payload, + CASE WHEN \(canonical) THEN + CASE WHEN \(columnMicros) BETWEEN ? AND ? + THEN \(query.filtersPayload ? parsed : columnMicros) END + ELSE \(parsed) END AS sort_key + FROM locations + LIMIT -1 + ) + WHERE sort_key IS NOT NULL + ORDER BY sort_key \(query.ascending ? "ASC" : "DESC"), id ASC + LIMIT ? OFFSET ? + """ + } + + private func queryLocationsOnQueryQueue(_ query: StoredLocationQuery) throws -> [[String: Any]] { + if let limit = query.limit, limit <= 0 { return [] } + guard let db = readConnectionOnQueryQueue() else { + throw StoredLocationQuery.error("Location database is unavailable") + } + func databaseError(_ operation: String) -> NSError { + StoredLocationQuery.error("\(operation): \(String(cString: sqlite3_errmsg(db)))") + } + // SQLite owns the retained query until the function is unregistered. v2 + // also invokes the destructor if registration fails. + let context = Unmanaged.passRetained(query).toOpaque() + let registration = sqlite3_create_function_v2( + db, "locus_query_timestamp", 1, SQLITE_UTF8 | SQLITE_DETERMINISTIC, + context, { context, _, values in + // Release JSON/regex Foundation temporaries after each row, + // rather than retaining them until a large scan completes. + autoreleasepool { + guard let context, let values, let userData = sqlite3_user_data(context) else { + sqlite3_result_error(context, "Missing location query context", -1) + return + } + let query = Unmanaged.fromOpaque(userData).takeUnretainedValue() + if let timestamp = query.matchingTimestamp(values[0]) { + sqlite3_result_int64(context, timestamp) + } else { + sqlite3_result_null(context) + } + } + }, nil, nil, { context in + if let context { + Unmanaged.fromOpaque(context).release() + } + } + ) + guard registration == SQLITE_OK else { throw databaseError("Register location query") } + let result: Result<[[String: Any]], Error> = Result { + let sql = locationQuerySQL(for: query) + let bindings: [Int64] = [ + query.from ?? Int64.min, + query.to ?? Int64.max, + query.limit ?? -1, + query.offset, + ] + var statement: OpaquePointer? + guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else { + sqlite3_finalize(statement) + throw databaseError("Prepare location query") + } + defer { sqlite3_finalize(statement) } + for (index, value) in bindings.enumerated() { + guard sqlite3_bind_int64(statement, Int32(index + 1), value) == SQLITE_OK else { + throw databaseError("Bind location query") + } + } + var rows: [[String: Any]] = [] + while true { + let step = sqlite3_step(statement) + if step == SQLITE_DONE { break } + guard step == SQLITE_ROW else { throw databaseError("Read location query") } + guard let bytes = sqlite3_column_text(statement, 0), + let payload = try? JSONSerialization.jsonObject( + with: Data(bytes: bytes, count: Int(sqlite3_column_bytes(statement, 0))) + ) as? [String: Any] else { + NSLog("[locus.SQLiteStorage] queryLocations skipped an undecodable stored location") + continue + } + rows.append(payload) + } + return rows + } + // The statement has been finalized before unregistering and releasing its + // callback context, including on bind, step, and JSON decoding failures. + let unregistration = sqlite3_create_function_v2( + db, "locus_query_timestamp", 1, SQLITE_UTF8 | SQLITE_DETERMINISTIC, + nil, nil, nil, nil, nil + ) + guard unregistration == SQLITE_OK else { throw databaseError("Unregister location query") } + return try result.get() + } + func removeLocations(_ uuids: [String]) { guard !uuids.isEmpty else { return } diff --git a/ios/locus/Sources/locus/Storage/StorageManager.swift b/ios/locus/Sources/locus/Storage/StorageManager.swift index ea39e57..0bca859 100644 --- a/ios/locus/Sources/locus/Storage/StorageManager.swift +++ b/ios/locus/Sources/locus/Storage/StorageManager.swift @@ -41,6 +41,11 @@ class StorageManager { return sqliteStorage.readLocations() } + func queryLocations(_ query: StoredLocationQuery, + completion: @escaping (Result<[[String: Any]], Error>) -> Void) { + sqliteStorage.queryLocations(query, completion: completion) + } + func writeLocations(_ locations: [[String: Any]]) { // Clear and re-write all locations (thread-safe via SQLiteStorage queue) sqliteStorage.clearLocations() diff --git a/ios/locus/Sources/locus/Storage/StoredLocationQuery.swift b/ios/locus/Sources/locus/Storage/StoredLocationQuery.swift new file mode 100644 index 0000000..99a8a5e --- /dev/null +++ b/ios/locus/Sources/locus/Storage/StoredLocationQuery.swift @@ -0,0 +1,258 @@ +import Foundation +import CoreFoundation +import SQLite3 + +/// Arguments of the private `queryLocations` channel. +final class StoredLocationQuery { + let limit: Int64? + let offset: Int64 + let ascending: Bool + let from: Int64? + let to: Int64? + private let minAccuracy: Double? + private let maxAccuracy: Double? + private let isMoving: Bool? + private let bounds: (south: Double, west: Double, north: Double, east: Double)? + private let timestampFallback: Int64 + private let localTimeZone = TimeZone.current + + static let microsPerSecond: Int64 = 1_000_000 + private static let secondsPerMinute: Int64 = 60 + private static let secondsPerHour = secondsPerMinute * 60 + private static let secondsPerDay = secondsPerHour * 24 + // Parse fractions separately: Date's floating-point representation cannot + // preserve every epoch microsecond. Accept ordinary Dart ISO date/time forms. + private static let timestampPattern = try! NSRegularExpression(pattern: + #"^([+-]?[0-9]{4,6})-?([0-9]{2})-?([0-9]{2})(?:[Tt ]([0-9]{2})(?::?([0-9]{2}))?(?::?([0-9]{2}))?(?:[.,]([0-9]+))?(?: ?([Zz]|([+-])([0-9]{2})(?::?([0-9]{2}))?))?)?$"#) + + init(arguments: Any?) throws { + guard let args = arguments as? [String: Any] else { + throw Self.error("queryLocations requires a map") + } + from = try Self.integer(args["from"], name: "from") + to = try Self.integer(args["to"], name: "to") + limit = try Self.integer(args["limit"], name: "limit") + offset = max(0, try Self.integer(args["offset"], name: "offset") ?? 0) + guard let fallback = try Self.integer(args["timestampFallback"], name: "timestampFallback") else { + throw Self.error("timestampFallback is required") + } + timestampFallback = fallback + minAccuracy = try Self.number(args["minAccuracy"], name: "minAccuracy") + maxAccuracy = try Self.number(args["maxAccuracy"], name: "maxAccuracy") + isMoving = try Self.boolean(args["isMoving"], name: "isMoving") + switch (args["sortOrder"] ?? "newestFirst") as? String { + case "newestFirst": ascending = false + case "oldestFirst": ascending = true + default: throw Self.error("sortOrder must be newestFirst or oldestFirst") + } + if let value = args["bounds"], !(value is NSNull) { + guard let map = value as? [String: Any], + let southwest = map["southwest"] as? [String: Any], + let northeast = map["northeast"] as? [String: Any], + let south = try Self.number(southwest["latitude"], name: "bounds.southwest.latitude"), + let west = try Self.number(southwest["longitude"], name: "bounds.southwest.longitude"), + let north = try Self.number(northeast["latitude"], name: "bounds.northeast.latitude"), + let east = try Self.number(northeast["longitude"], name: "bounds.northeast.longitude") else { + throw Self.error("bounds requires southwest and northeast coordinates") + } + bounds = (south, west, north, east) + } else { + bounds = nil + } + } + + static func error(_ message: String) -> NSError { + NSError(domain: "dev.locus.locationQuery", code: 1, + userInfo: [NSLocalizedDescriptionKey: message]) + } + + private static func integer(_ value: Any?, name: String) throws -> Int64? { + guard let value, !(value is NSNull) else { return nil } + guard let number = value as? NSNumber, CFGetTypeID(number) != CFBooleanGetTypeID(), + !["f", "d"].contains(String(cString: number.objCType)), + let integer = Int64(number.stringValue) else { + throw error("\(name) must be a signed 64-bit integer") + } + return integer + } + + private static func number(_ value: Any?, name: String) throws -> Double? { + guard let value, !(value is NSNull) else { return nil } + guard let number = value as? NSNumber, CFGetTypeID(number) != CFBooleanGetTypeID() else { + throw error("\(name) must be numeric") + } + return number.doubleValue + } + + private static func boolean(_ value: Any?, name: String) throws -> Bool? { + guard let value, !(value is NSNull) else { return nil } + guard let number = value as? NSNumber, CFGetTypeID(number) == CFBooleanGetTypeID() else { + throw error("\(name) must be boolean") + } + return number.boolValue + } + + var filtersPayload: Bool { + minAccuracy != nil || maxAccuracy != nil || isMoving != nil || bounds != nil + } + + /// nil excludes the row; malformed rows are logged and skipped. + func matchingTimestamp(_ value: OpaquePointer?) -> Int64? { + guard let bytes = sqlite3_value_text(value) else { + NSLog("[locus.SQLiteStorage] queryLocations skipped a non-text payload") + return nil + } + do { + return try matchingTimestamp(payloadData: Data(bytes: bytes, count: Int(sqlite3_value_bytes(value)))) + } catch { + NSLog("[locus.SQLiteStorage] queryLocations skipped a malformed stored location: %@", + error.localizedDescription) + return nil + } + } + + private func matchingTimestamp(payloadData data: Data) throws -> Int64? { + guard let payload = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw Self.error("Stored location payload is not an object") + } + let timestamp: Int64 + if let raw = payload["timestamp"], !(raw is NSNull) { + guard let text = raw as? String else { + throw Self.error("Stored location timestamp must be text") + } + timestamp = parseTimestamp(text) ?? timestampFallback + } else { + timestamp = timestampFallback + } + if let from, timestamp < from { return nil } + if let to, timestamp > to { return nil } + let coords = payload["coords"] as? [String: Any] ?? [:] + var latitude = try Self.number(coords["latitude"], name: "stored latitude") ?? 0 + var longitude = try Self.number(coords["longitude"], name: "stored longitude") ?? 0 + let accuracy: Double + if latitude < -90 || latitude > 90 || longitude < -180 || longitude > 180 { + latitude = 0 + longitude = 0 + accuracy = -1 + } else { + accuracy = try Self.number(coords["accuracy"], name: "stored accuracy") ?? 0 + } + if let minAccuracy, accuracy > minAccuracy { return nil } + if let maxAccuracy, accuracy < maxAccuracy { return nil } + if let isMoving, try Self.boolean(payload["is_moving"], name: "stored is_moving") != isMoving { + return nil + } + if let bounds, + !(latitude >= bounds.south && latitude <= bounds.north && + longitude >= bounds.west && longitude <= bounds.east) { return nil } + return timestamp + } + + private func parseTimestamp(_ text: String) -> Int64? { + let range = NSRange(text.startIndex.. String? { + guard let range = Range(match.range(at: index), in: text) else { return nil } + return String(text[range]) + } + guard let rawYear = field(1).flatMap(Int.init), + let rawMonth = field(2).flatMap(Int.init), + let day = field(3).flatMap(Int.init) else { return nil } + // DateTime.parse normalizes overflowing fields instead of rejecting + // them. Normalize the month; civil-day/second arithmetic handles the rest. + let monthsPerYear = 12 + let zeroBasedMonth = rawMonth - 1 + let year = rawYear + (zeroBasedMonth >= 0 ? zeroBasedMonth : zeroBasedMonth - 11) / monthsPerYear + let month = ((zeroBasedMonth % monthsPerYear) + monthsPerYear) % monthsPerYear + 1 + let hour = field(4).flatMap(Int.init) ?? 0 + let minute = field(5).flatMap(Int.init) ?? 0 + let second = field(6).flatMap(Int.init) ?? 0 + let maximumEpochMicros: Int64 = 8_640_000_000_000_000_000 + let maximumEpochSeconds = maximumEpochMicros / Self.microsPerSecond + let wallSeconds = Self.daysFromCivil(year: year, month: month, day: day) * Self.secondsPerDay + + Int64(hour) * Self.secondsPerHour + Int64(minute) * Self.secondsPerMinute + Int64(second) + let offset: Int64 + if field(8) == nil { + offset = localTimeOffset(wallSeconds: wallSeconds, year: year) + } else { + let hours = field(10).flatMap(Int64.init) ?? 0 + let minutes = field(11).flatMap(Int64.init) ?? 0 + offset = (hours * Self.secondsPerHour + minutes * Self.secondsPerMinute) * (field(9) == "-" ? -1 : 1) + } + let seconds = wallSeconds - offset + // Check before multiplication, then include fractions in the final + // inclusive Dart DateTime range check (the maximum has no fraction). + guard seconds >= -maximumEpochSeconds, seconds <= maximumEpochSeconds else { return nil } + let fraction = String((field(7) ?? "").prefix(6)) + let micros = Int64(fraction.padding(toLength: 6, withPad: "0", startingAt: 0)) ?? 0 + let timestamp = seconds * Self.microsPerSecond + micros + guard timestamp >= -maximumEpochMicros, timestamp <= maximumEpochMicros else { return nil } + return timestamp + } + + /// Proleptic Gregorian days since 1970-01-01, using Howard Hinnant's + /// days-from-civil formula. Shared by UTC and local timestamp parsing. + private static func daysFromCivil(year: Int, month: Int, day: Int) -> Int64 { + let adjustedYear = Int64(year - (month <= 2 ? 1 : 0)) + let era = (adjustedYear >= 0 ? adjustedYear : adjustedYear - 399) / 400 + let yearOfEra = adjustedYear - era * 400 + let marchBasedMonth = Int64(month + (month > 2 ? -3 : 9)) + let dayOfYear = (153 * marchBasedMonth + 2) / 5 + Int64(day) - 1 + let dayOfEra = yearOfEra * 365 + yearOfEra / 4 - yearOfEra / 100 + dayOfYear + let daysPerEra: Int64 = 146_097 + let unixEpochCivilDay: Int64 = 719_468 + return era * daysPerEra + dayOfEra - unixEpochCivilDay + } + + /// Mirror Dart VM DateTime's equivalent-year and local-offset algorithms. + /// Historical/future offsets use an equivalent modern year outside the + /// signed-32-bit seconds range, rather than Foundation's historical rules. + private func localTimeOffset(wallSeconds: Int64, year: Int) -> Int64 { + let secondsPerDay = Self.secondsPerDay + let twoHours = 2 * Self.secondsPerHour + func zoneOffset(at seconds: Int64) -> Int64 { + let maximumPlatformSeconds: Int64 = 0x7FFF_FFFF + var equivalentSeconds = seconds + if seconds < -maximumPlatformSeconds || seconds > maximumPlatformSeconds { + // Offset probes are at most a few hours from the parsed wall + // time, but may cross its year boundary. + var probeYear = year + while seconds < Self.daysFromCivil(year: probeYear, month: 1, day: 1) * secondsPerDay { + probeYear -= 1 + } + while seconds >= Self.daysFromCivil(year: probeYear + 1, month: 1, day: 1) * secondsPerDay { + probeYear += 1 + } + let originalDays = Self.daysFromCivil(year: probeYear, month: 1, day: 1) + let weekday = ((originalDays + 4) % 7 + 7) % 7 // Sunday is zero. + let leap = probeYear % 4 == 0 && (probeYear % 100 != 0 || probeYear % 400 == 0) + let recentYear = (leap ? 1956 : 1967) + Int(weekday) * 12 + let cycleYears = 28 + let equivalentYear = 2008 + ((recentYear - 2008) % cycleYears + cycleYears) % cycleYears + let equivalentDays = Self.daysFromCivil(year: equivalentYear, month: 1, day: 1) + equivalentSeconds += (equivalentDays - originalDays) * secondsPerDay + } + return Int64(localTimeZone.secondsFromGMT(for: Date(timeIntervalSince1970: Double(equivalentSeconds)))) + } + var offset = zoneOffset(at: wallSeconds) + if offset != 0 { + let secondOffset = zoneOffset(at: wallSeconds - offset) + if secondOffset != offset { + let thirdOffset = zoneOffset(at: wallSeconds - secondOffset) + // A forward DST gap uses the earlier zone (the later instant). + return min(secondOffset, thirdOffset) + } + offset = secondOffset + } + let earlierOffset = zoneOffset(at: wallSeconds - offset - twoHours) + if earlierOffset > offset { + // An overlap uses the earliest valid instant, like Dart DateTime. + if earlierOffset == offset + twoHours || + zoneOffset(at: wallSeconds - earlierOffset) == earlierOffset { + return earlierOffset + } + } + return offset + } + +} diff --git a/ios/locus/Sources/locus/SwiftLocusPlugin.swift b/ios/locus/Sources/locus/SwiftLocusPlugin.swift index 1afe4c6..54ed6b3 100644 --- a/ios/locus/Sources/locus/SwiftLocusPlugin.swift +++ b/ios/locus/Sources/locus/SwiftLocusPlugin.swift @@ -422,6 +422,20 @@ public class SwiftLocusPlugin: NSObject, FlutterPlugin, LocationClientDelegate, result(configManager.persistedConfig) case "getDiagnosticsMetadata": result(buildDiagnosticsMetadata()) + case "queryLocations": + do { + let query = try StoredLocationQuery(arguments: call.arguments) + storage.queryLocations(query) { outcome in + switch outcome { + case .success(let locations): + result(locations) + case .failure(let error): + result(FlutterError(code: "LOCATION_QUERY_ERROR", message: error.localizedDescription, details: nil)) + } + } + } catch { + result(FlutterError(code: "INVALID_ARGUMENT", message: error.localizedDescription, details: nil)) + } case "getManufacturer": // Mirror of the Android handler used by DeviceOptimizationService. // iOS hardware is single-vendor; the "Don't Kill My App" map is diff --git a/lib/src/features/location/services/locus_location.dart b/lib/src/features/location/services/locus_location.dart index aed90d8..a894d18 100644 --- a/lib/src/features/location/services/locus_location.dart +++ b/lib/src/features/location/services/locus_location.dart @@ -56,31 +56,53 @@ class LocusLocation { /// Queries stored locations with filtering and pagination. static Future> queryLocations(LocationQuery query) async { - // Pull only what is needed based on limit/offset to reduce memory use final limit = query.limit; - final offset = query.offset; - final fetchLimit = limit != null ? limit + offset : null; + if (limit != null && limit <= 0) return const []; - final fetched = await getLocations(limit: fetchLimit); - final clampedOffset = offset.clamp(0, fetched.length); - final end = limit != null - ? (clampedOffset + limit).clamp(0, fetched.length) - : fetched.length; - final sliced = fetched.sublist(clampedOffset, end); + final arguments = { + ...query.toMap(), + // Integer instants preserve inclusive microsecond edges and local dates. + if (query.from case final from?) + 'from': from.toUtc().microsecondsSinceEpoch, + if (query.to case final to?) 'to': to.toUtc().microsecondsSinceEpoch, + 'offset': query.offset < 0 ? 0 : query.offset, + 'timestampFallback': DateTime.now().toUtc().microsecondsSinceEpoch, + }; - final adjustedQuery = LocationQuery( - from: query.from, - to: query.to, - minAccuracy: query.minAccuracy, - maxAccuracy: query.maxAccuracy, - isMoving: query.isMoving, - bounds: query.bounds, - sortOrder: query.sortOrder, - offset: 0, - limit: null, - ); + final Object? response; + try { + response = await LocusChannels.methods.invokeMethod( + 'queryLocations', + arguments, + ); + } on MissingPluginException { + throw PlatformException( + code: 'UNSUPPORTED_NATIVE_QUERY', + message: + 'Native location queries are unavailable. Rebuild the host app ' + 'with the current Locus Android or iOS plugin.', + ); + } + + if (response is! List) { + throw PlatformException( + code: 'INVALID_RESULT', + message: 'Expected a native location query result list.', + ); + } - return adjustedQuery.apply(sliced); + return response + .map((item) { + if (item is! Map || + item.keys.any((key) => key is! String)) { + throw PlatformException( + code: 'INVALID_RESULT', + message: 'Expected a location map in the native query result.', + ); + } + return Location.fromMap(JsonMap.from(item)); + }) + .toList(growable: false); } /// Gets a summary of location history. diff --git a/test/unit/core/native_history_query_test.dart b/test/unit/core/native_history_query_test.dart new file mode 100644 index 0000000..26716b8 --- /dev/null +++ b/test/unit/core/native_history_query_test.dart @@ -0,0 +1,216 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:locus/locus.dart'; +import 'package:locus/src/core/method_channel_locus.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + const channel = MethodChannel('locus/methods'); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + final calls = []; + late Future Function(MethodCall) respond; + + setUpAll(() async { + messenger.setMockMethodCallHandler(channel, (_) async => null); + expect(Locus.instance, isA()); + // Finish the default provider's asynchronous service registration first. + await Future.delayed(Duration.zero); + }); + setUp(() { + calls.clear(); + respond = (_) async => []; + messenger.setMockMethodCallHandler(channel, (call) async { + calls.add(call); + return respond(call); + }); + }); + tearDownAll(() => messenger.setMockMethodCallHandler(channel, null)); + + test( + 'public query forwards every predicate with UTC microsecond edges', + () async { + final query = fullQuery(); + await Locus.location.query(query); + expect(calls.map((call) => call.method), ['queryLocations']); + final arguments = queryArguments(calls.single); + expectQueryArguments(arguments, query); + expect(arguments['from'], -1999); + expect(arguments['timestampFallback'], isA()); + }, + ); + + test( + 'native page is returned without filtering sorting or paging twice', + () async { + respond = (_) async => [ + payload('selected-second', 2), + payload('selected-first', 1), + ]; + final locations = await Locus.location.query( + const LocationQuery( + limit: 2, + offset: 100, + sortOrder: LocationSortOrder.oldestFirst, + ), + ); + expect(locations.map((location) => location.uuid), [ + 'selected-second', + 'selected-first', + ]); + expect(calls, hasLength(1)); + }, + ); + + test( + 'omitted limit requests all matching rows and normalizes negative offset', + () async { + respond = (_) async => [ + payload('one', 1), + payload('two', 2), + payload('three', 3), + ]; + final locations = await Locus.location.query( + const LocationQuery(offset: -4), + ); + expect(locations, hasLength(3)); + final arguments = queryArguments(calls.single); + expect(arguments.containsKey('limit'), isFalse); + expect(arguments['offset'], 0); + expect(arguments['sortOrder'], 'newestFirst'); + }, + ); + + test('nonpositive limits return empty without calling native code', () async { + for (final limit in [0, -1, -100]) { + expect(await Locus.location.query(LocationQuery(limit: limit)), isEmpty); + } + expect(calls, isEmpty); + }); + + test( + 'malformed native lists rows and map keys report INVALID_RESULT', + () async { + for (final response in [ + null, + 'not a list', + {}, + [null], + ['not a row'], + [ + {1: 'not a string key'}, + ], + ]) { + respond = (_) async => response; + await expectLater( + Locus.location.query(const LocationQuery()), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'INVALID_RESULT', + ), + ), + ); + } + expect(calls.map((call) => call.method), everyElement('queryLocations')); + }, + ); + + test( + 'native platform errors retain their code message and details', + () async { + respond = (_) async => throw PlatformException( + code: 'DATABASE_READ_FAILED', + message: 'History cannot be read', + details: 'fixture failure', + ); + await expectLater( + Locus.location.query(const LocationQuery()), + throwsA( + isA() + .having((error) => error.code, 'code', 'DATABASE_READ_FAILED') + .having( + (error) => error.message, + 'message', + 'History cannot be read', + ) + .having((error) => error.details, 'details', 'fixture failure'), + ), + ); + }, + ); + + test( + 'missing native query reports unsupported without legacy fallback', + () async { + respond = (_) async => throw MissingPluginException('old native plugin'); + await expectLater( + Locus.location.query(const LocationQuery()), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'UNSUPPORTED_NATIVE_QUERY', + ), + ), + ); + expect(calls.map((call) => call.method), ['queryLocations']); + }, + ); + + test( + 'summary uses the same native query fields and summarizes its page', + () async { + final query = fullQuery(); + respond = (_) async => [payload('first', 1), payload('second', 2)]; + final summary = await Locus.location.getSummary(query: query); + expect(summary.locationCount, 2); + expect(calls.map((call) => call.method), ['queryLocations']); + expectQueryArguments(queryArguments(calls.single), query); + }, + ); +} + +LocationQuery fullQuery() => LocationQuery( + from: DateTime.fromMicrosecondsSinceEpoch(-1999, isUtc: true), + to: DateTime(2026, 11, 3, 12, 30, 40, 123, 456), + minAccuracy: 20, + maxAccuracy: 10, + isMoving: false, + bounds: const LocationBounds( + southwest: Coords(latitude: 10, longitude: 20, accuracy: 0), + northeast: Coords(latitude: 11, longitude: 21, accuracy: 0), + ), + limit: 2, + offset: 3, + sortOrder: LocationSortOrder.oldestFirst, +); + +void expectQueryArguments(Map arguments, LocationQuery query) { + final expected = Map.from(query.toMap()); + expected['from'] = query.from?.toUtc().microsecondsSinceEpoch; + expected['to'] = query.to?.toUtc().microsecondsSinceEpoch; + final actual = Map.from(arguments) + ..remove('timestampFallback'); + expect(actual, expected); +} + +Map queryArguments(MethodCall call) { + final Object? arguments = call.arguments; + if (arguments is! Map) { + throw StateError('Expected query map'); + } + return Map.from(arguments); +} + +JsonMap payload(String uuid, int second) => { + 'uuid': uuid, + 'timestamp': DateTime.utc(2026, 11, 3, 12, 0, second).toIso8601String(), + 'coords': { + 'latitude': 10.5, + 'longitude': 20.5, + 'accuracy': 15.0, + }, + 'is_moving': true, +};