Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 14 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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`.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Pair<String, () -> 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<String, Any>, 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<String, Map<String, Double>> = 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>")
}
}
24 changes: 24 additions & 0 deletions android/src/main/kotlin/dev/locus/core/LocusContainer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
4 changes: 4 additions & 0 deletions android/src/main/kotlin/dev/locus/core/StateManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -42,6 +43,9 @@ class StateManager(context: Context) {
locationStore.readLocations(limit)
.mapNotNull { record -> buildPayloadFromRecord(record).takeIf { it.isNotEmpty() } }

internal fun queryStoredLocations(query: StoredLocationQuery): List<Map<String, Any>> =
locationStore.queryLocations(query).map { record -> buildPayloadFromRecord(record) }

fun storeLocationPayload(payload: Map<String, Any>, maxDays: Int, maxRecords: Int) {
locationStore.insertPayload(payload, maxDays, maxRecords)
}
Expand Down
Loading
Loading