@bricks-soft/capacitor-location-tracking 0.0.0-stage → 8.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (168) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +2759 -2
  3. package/android/build.gradle +106 -0
  4. package/android/consumer-rules.pro +42 -0
  5. package/android/src/main/AndroidManifest.xml +52 -0
  6. package/android/src/main/java/com/brickssoft/locationtracking/LocationTrackingPlugin.kt +431 -0
  7. package/android/src/main/java/com/brickssoft/locationtracking/api/LocationTrackingListener.kt +52 -0
  8. package/android/src/main/java/com/brickssoft/locationtracking/api/LocationTrackingNative.kt +267 -0
  9. package/android/src/main/java/com/brickssoft/locationtracking/api/NativeListeners.kt +270 -0
  10. package/android/src/main/java/com/brickssoft/locationtracking/api/NativeThread.kt +57 -0
  11. package/android/src/main/java/com/brickssoft/locationtracking/bridge/BridgeServices.kt +64 -0
  12. package/android/src/main/java/com/brickssoft/locationtracking/bridge/CallResponder.kt +41 -0
  13. package/android/src/main/java/com/brickssoft/locationtracking/bridge/OptionParsers.kt +281 -0
  14. package/android/src/main/java/com/brickssoft/locationtracking/bridge/PluginHandlers.kt +541 -0
  15. package/android/src/main/java/com/brickssoft/locationtracking/bridge/PluginPermissionHost.kt +76 -0
  16. package/android/src/main/java/com/brickssoft/locationtracking/bridge/WatchRegistry.kt +84 -0
  17. package/android/src/main/java/com/brickssoft/locationtracking/config/Config.kt +231 -0
  18. package/android/src/main/java/com/brickssoft/locationtracking/config/ConfigJson.kt +574 -0
  19. package/android/src/main/java/com/brickssoft/locationtracking/config/ConfigStore.kt +31 -0
  20. package/android/src/main/java/com/brickssoft/locationtracking/config/ConfigValidator.kt +170 -0
  21. package/android/src/main/java/com/brickssoft/locationtracking/config/RuntimeStateJson.kt +73 -0
  22. package/android/src/main/java/com/brickssoft/locationtracking/config/SharedPrefsConfigStore.kt +135 -0
  23. package/android/src/main/java/com/brickssoft/locationtracking/config/State.kt +45 -0
  24. package/android/src/main/java/com/brickssoft/locationtracking/core/AppDispatchers.kt +21 -0
  25. package/android/src/main/java/com/brickssoft/locationtracking/core/Clock.kt +41 -0
  26. package/android/src/main/java/com/brickssoft/locationtracking/core/Components.kt +217 -0
  27. package/android/src/main/java/com/brickssoft/locationtracking/core/Constants.kt +78 -0
  28. package/android/src/main/java/com/brickssoft/locationtracking/core/Errors.kt +27 -0
  29. package/android/src/main/java/com/brickssoft/locationtracking/core/EventBus.kt +39 -0
  30. package/android/src/main/java/com/brickssoft/locationtracking/core/ForceStopProbe.kt +63 -0
  31. package/android/src/main/java/com/brickssoft/locationtracking/core/Iso8601.kt +63 -0
  32. package/android/src/main/java/com/brickssoft/locationtracking/core/Logger.kt +68 -0
  33. package/android/src/main/java/com/brickssoft/locationtracking/core/RecordHooks.kt +47 -0
  34. package/android/src/main/java/com/brickssoft/locationtracking/core/TrackingEvent.kt +64 -0
  35. package/android/src/main/java/com/brickssoft/locationtracking/data/SqlSupport.kt +79 -0
  36. package/android/src/main/java/com/brickssoft/locationtracking/data/SqliteGeofenceStore.kt +144 -0
  37. package/android/src/main/java/com/brickssoft/locationtracking/data/SqliteLocationStore.kt +215 -0
  38. package/android/src/main/java/com/brickssoft/locationtracking/data/Stores.kt +45 -0
  39. package/android/src/main/java/com/brickssoft/locationtracking/data/TrackingDatabase.kt +111 -0
  40. package/android/src/main/java/com/brickssoft/locationtracking/device/DefaultDeviceInfoProvider.kt +104 -0
  41. package/android/src/main/java/com/brickssoft/locationtracking/device/DefaultDeviceMonitor.kt +335 -0
  42. package/android/src/main/java/com/brickssoft/locationtracking/device/DeviceMonitor.kt +36 -0
  43. package/android/src/main/java/com/brickssoft/locationtracking/device/DeviceStateMapping.kt +69 -0
  44. package/android/src/main/java/com/brickssoft/locationtracking/engine/DefaultTrackingEngine.kt +1281 -0
  45. package/android/src/main/java/com/brickssoft/locationtracking/engine/ElasticDistance.kt +68 -0
  46. package/android/src/main/java/com/brickssoft/locationtracking/engine/LocationRequests.kt +74 -0
  47. package/android/src/main/java/com/brickssoft/locationtracking/engine/MotionStateMachine.kt +291 -0
  48. package/android/src/main/java/com/brickssoft/locationtracking/engine/StationaryRegion.kt +41 -0
  49. package/android/src/main/java/com/brickssoft/locationtracking/engine/TrackingEngine.kt +60 -0
  50. package/android/src/main/java/com/brickssoft/locationtracking/engine/TrackingTimers.kt +90 -0
  51. package/android/src/main/java/com/brickssoft/locationtracking/geofence/DefaultGeofenceManager.kt +899 -0
  52. package/android/src/main/java/com/brickssoft/locationtracking/geofence/DwellTracker.kt +80 -0
  53. package/android/src/main/java/com/brickssoft/locationtracking/geofence/GeofenceManager.kt +33 -0
  54. package/android/src/main/java/com/brickssoft/locationtracking/geofence/PolygonMath.kt +194 -0
  55. package/android/src/main/java/com/brickssoft/locationtracking/heartbeat/DefaultHeartbeatScheduler.kt +713 -0
  56. package/android/src/main/java/com/brickssoft/locationtracking/heartbeat/HeartbeatAlarmReceiver.kt +69 -0
  57. package/android/src/main/java/com/brickssoft/locationtracking/heartbeat/HeartbeatAlarms.kt +100 -0
  58. package/android/src/main/java/com/brickssoft/locationtracking/heartbeat/HeartbeatScheduler.kt +25 -0
  59. package/android/src/main/java/com/brickssoft/locationtracking/heartbeat/HeartbeatWindow.kt +179 -0
  60. package/android/src/main/java/com/brickssoft/locationtracking/http/AuthorizationManager.kt +213 -0
  61. package/android/src/main/java/com/brickssoft/locationtracking/http/BodyBuilder.kt +73 -0
  62. package/android/src/main/java/com/brickssoft/locationtracking/http/HttpSupport.kt +118 -0
  63. package/android/src/main/java/com/brickssoft/locationtracking/http/HttpSyncer.kt +18 -0
  64. package/android/src/main/java/com/brickssoft/locationtracking/http/OkHttpSyncer.kt +551 -0
  65. package/android/src/main/java/com/brickssoft/locationtracking/http/StrictJson.kt +135 -0
  66. package/android/src/main/java/com/brickssoft/locationtracking/http/SyncPolicy.kt +109 -0
  67. package/android/src/main/java/com/brickssoft/locationtracking/http/TemplateRenderer.kt +143 -0
  68. package/android/src/main/java/com/brickssoft/locationtracking/logging/FileLogger.kt +461 -0
  69. package/android/src/main/java/com/brickssoft/locationtracking/logging/LogFileProvider.kt +9 -0
  70. package/android/src/main/java/com/brickssoft/locationtracking/logging/LogFormatter.kt +148 -0
  71. package/android/src/main/java/com/brickssoft/locationtracking/logging/LogStore.kt +30 -0
  72. package/android/src/main/java/com/brickssoft/locationtracking/model/DeviceModels.kt +176 -0
  73. package/android/src/main/java/com/brickssoft/locationtracking/model/GeofenceSpec.kt +25 -0
  74. package/android/src/main/java/com/brickssoft/locationtracking/model/ModelJson.kt +550 -0
  75. package/android/src/main/java/com/brickssoft/locationtracking/model/Record.kt +106 -0
  76. package/android/src/main/java/com/brickssoft/locationtracking/model/TrackedLocation.kt +53 -0
  77. package/android/src/main/java/com/brickssoft/locationtracking/permission/BackgroundRationaleDialog.kt +85 -0
  78. package/android/src/main/java/com/brickssoft/locationtracking/permission/CurrentActivityTracker.kt +72 -0
  79. package/android/src/main/java/com/brickssoft/locationtracking/permission/DefaultPermissionManager.kt +333 -0
  80. package/android/src/main/java/com/brickssoft/locationtracking/permission/PermissionManager.kt +54 -0
  81. package/android/src/main/java/com/brickssoft/locationtracking/position/DefaultPositionService.kt +277 -0
  82. package/android/src/main/java/com/brickssoft/locationtracking/position/PositionService.kt +39 -0
  83. package/android/src/main/java/com/brickssoft/locationtracking/position/PositionWatch.kt +115 -0
  84. package/android/src/main/java/com/brickssoft/locationtracking/processing/DefaultLocationProcessor.kt +93 -0
  85. package/android/src/main/java/com/brickssoft/locationtracking/processing/DefaultOdometer.kt +99 -0
  86. package/android/src/main/java/com/brickssoft/locationtracking/processing/DefaultRecordFactory.kt +166 -0
  87. package/android/src/main/java/com/brickssoft/locationtracking/processing/Geo.kt +56 -0
  88. package/android/src/main/java/com/brickssoft/locationtracking/processing/KalmanFilter.kt +72 -0
  89. package/android/src/main/java/com/brickssoft/locationtracking/processing/Processing.kt +48 -0
  90. package/android/src/main/java/com/brickssoft/locationtracking/provider/Backends.kt +187 -0
  91. package/android/src/main/java/com/brickssoft/locationtracking/provider/DefaultProviderFactory.kt +169 -0
  92. package/android/src/main/java/com/brickssoft/locationtracking/provider/ProviderPackaging.kt +135 -0
  93. package/android/src/main/java/com/brickssoft/locationtracking/provider/android/AndroidActivityBackend.kt +25 -0
  94. package/android/src/main/java/com/brickssoft/locationtracking/provider/android/AndroidGeofenceBackend.kt +181 -0
  95. package/android/src/main/java/com/brickssoft/locationtracking/provider/android/AndroidGeofenceReceiver.kt +115 -0
  96. package/android/src/main/java/com/brickssoft/locationtracking/provider/android/AndroidLocationBackend.kt +265 -0
  97. package/android/src/main/java/com/brickssoft/locationtracking/provider/android/AndroidProviderBundle.kt +31 -0
  98. package/android/src/main/java/com/brickssoft/locationtracking/provider/android/LocationProviders.kt +98 -0
  99. package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsActivityBackend.kt +80 -0
  100. package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsActivityReceiver.kt +62 -0
  101. package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsErrors.kt +72 -0
  102. package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsGeofenceBackend.kt +152 -0
  103. package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsGeofenceReceiver.kt +78 -0
  104. package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsLocationBackend.kt +176 -0
  105. package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsProviderBundle.kt +62 -0
  106. package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsReceiverSupport.kt +88 -0
  107. package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsTasks.kt +55 -0
  108. package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsActivityBackend.kt +71 -0
  109. package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsActivityReceiver.kt +57 -0
  110. package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsErrors.kt +76 -0
  111. package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsGeofenceBackend.kt +116 -0
  112. package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsGeofenceReceiver.kt +67 -0
  113. package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsIntents.kt +83 -0
  114. package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsLocationBackend.kt +197 -0
  115. package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsProviderBundle.kt +55 -0
  116. package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsTasks.kt +44 -0
  117. package/android/src/main/java/com/brickssoft/locationtracking/record/RecordSink.kt +95 -0
  118. package/android/src/main/java/com/brickssoft/locationtracking/service/BootCountStore.kt +44 -0
  119. package/android/src/main/java/com/brickssoft/locationtracking/service/BootReceiver.kt +175 -0
  120. package/android/src/main/java/com/brickssoft/locationtracking/service/DefaultServiceController.kt +196 -0
  121. package/android/src/main/java/com/brickssoft/locationtracking/service/LocationTrackingService.kt +319 -0
  122. package/android/src/main/java/com/brickssoft/locationtracking/service/NotificationActionReceiver.kt +42 -0
  123. package/android/src/main/java/com/brickssoft/locationtracking/service/NotificationFactory.kt +338 -0
  124. package/android/src/main/java/com/brickssoft/locationtracking/service/ServiceCommands.kt +204 -0
  125. package/android/src/main/java/com/brickssoft/locationtracking/service/ServiceController.kt +22 -0
  126. package/android/src/main/java/com/brickssoft/locationtracking/service/ServiceDeps.kt +61 -0
  127. package/android/src/main/java/com/brickssoft/locationtracking/service/WhileInUseStartCheck.kt +115 -0
  128. package/android/src/main/java/com/brickssoft/locationtracking/settings/DefaultDeviceSettings.kt +79 -0
  129. package/android/src/main/java/com/brickssoft/locationtracking/settings/DeviceSettings.kt +21 -0
  130. package/android/src/main/java/com/brickssoft/locationtracking/settings/OemPowerManagers.kt +198 -0
  131. package/android/src/main/res/drawable/lt_ic_notification.xml +11 -0
  132. package/android/src/main/res/values/lt_strings.xml +11 -0
  133. package/android/src/main/res/xml/lt_file_paths.xml +5 -0
  134. package/dist/esm/definitions.d.ts +719 -0
  135. package/dist/esm/definitions.js +2 -0
  136. package/dist/esm/definitions.js.map +1 -0
  137. package/dist/esm/events.d.ts +50 -0
  138. package/dist/esm/events.js +52 -0
  139. package/dist/esm/events.js.map +1 -0
  140. package/dist/esm/index.d.ts +3 -0
  141. package/dist/esm/index.js +5 -0
  142. package/dist/esm/index.js.map +1 -0
  143. package/dist/esm/plugin.d.ts +3 -0
  144. package/dist/esm/plugin.js +10 -0
  145. package/dist/esm/plugin.js.map +1 -0
  146. package/dist/esm/web/config.d.ts +19 -0
  147. package/dist/esm/web/config.js +243 -0
  148. package/dist/esm/web/config.js.map +1 -0
  149. package/dist/esm/web/device.d.ts +9 -0
  150. package/dist/esm/web/device.js +90 -0
  151. package/dist/esm/web/device.js.map +1 -0
  152. package/dist/esm/web/errors.d.ts +14 -0
  153. package/dist/esm/web/errors.js +19 -0
  154. package/dist/esm/web/errors.js.map +1 -0
  155. package/dist/esm/web/geolocation.d.ts +26 -0
  156. package/dist/esm/web/geolocation.js +117 -0
  157. package/dist/esm/web/geolocation.js.map +1 -0
  158. package/dist/esm/web/location.d.ts +7 -0
  159. package/dist/esm/web/location.js +70 -0
  160. package/dist/esm/web/location.js.map +1 -0
  161. package/dist/esm/web.d.ts +164 -0
  162. package/dist/esm/web.js +328 -0
  163. package/dist/esm/web.js.map +1 -0
  164. package/dist/plugin.cjs.js +945 -0
  165. package/dist/plugin.cjs.js.map +1 -0
  166. package/dist/plugin.js +948 -0
  167. package/dist/plugin.js.map +1 -0
  168. package/package.json +59 -4
@@ -0,0 +1,79 @@
1
+ package com.brickssoft.locationtracking.data
2
+
3
+ import android.database.sqlite.SQLiteDatabase
4
+ import android.database.sqlite.SQLiteStatement
5
+ import com.brickssoft.locationtracking.core.ErrorCode
6
+ import com.brickssoft.locationtracking.core.TrackingException
7
+ import org.json.JSONException
8
+ import kotlin.coroutines.cancellation.CancellationException
9
+
10
+ /** Maximum bound parameters per statement (old SQLite builds allow 999). */
11
+ internal const val MAX_SQL_PARAMS = 500
12
+
13
+ /** `?,?,...,?` with [count] placeholders. */
14
+ internal fun placeholders(count: Int): String = List(count) { "?" }.joinToString(",")
15
+
16
+ /** Runs [body] in an immediate transaction; commits if it returns normally, rolls back if it throws. */
17
+ internal inline fun <T> SQLiteDatabase.inTransaction(body: () -> T): T {
18
+ beginTransactionNonExclusive()
19
+ try {
20
+ val result = body()
21
+ setTransactionSuccessful()
22
+ return result
23
+ } finally {
24
+ endTransaction()
25
+ }
26
+ }
27
+
28
+ /** Deletes the rows whose [column] is one of [keys], in chunks of at most [MAX_SQL_PARAMS], in one transaction. */
29
+ internal fun SQLiteDatabase.deleteIn(table: String, column: String, keys: Collection<String>): Int = inTransaction {
30
+ var deleted = 0
31
+ for (chunk in keys.distinct().chunked(MAX_SQL_PARAMS)) {
32
+ deleted += delete(table, "$column IN (${placeholders(chunk.size)})", chunk.toTypedArray())
33
+ }
34
+ deleted
35
+ }
36
+
37
+ /** A row whose JSON could not be decoded: its key and the exact text that was read. */
38
+ internal class UndecodableRow(val key: String, val json: String)
39
+
40
+ /**
41
+ * Deletes each of [rows] only if its [jsonColumn] still holds the text that failed to decode, so a valid row
42
+ * written concurrently under the same key survives. Returns the number of rows deleted.
43
+ */
44
+ internal fun SQLiteDatabase.deleteUndecodable(
45
+ table: String,
46
+ keyColumn: String,
47
+ jsonColumn: String,
48
+ rows: List<UndecodableRow>,
49
+ ): Int = inTransaction {
50
+ rows.sumOf { delete(table, "$keyColumn = ? AND $jsonColumn = ?", arrayOf(it.key, it.json)) }
51
+ }
52
+
53
+ /** Compiles [sql], lets [bind] bind its arguments and runs it; returns the number of changed rows. */
54
+ internal inline fun SQLiteDatabase.executeUpdateDelete(sql: String, bind: SQLiteStatement.() -> Unit): Int =
55
+ compileStatement(sql).use { statement ->
56
+ statement.bind()
57
+ statement.executeUpdateDelete()
58
+ }
59
+
60
+ /** Runs [block] (JSON encoding); a `JSONException` (e.g. from NaN) becomes `TrackingException(INVALID_ARGUMENT)`. */
61
+ internal inline fun <T> encoding(what: String, block: () -> T): T = try {
62
+ block()
63
+ } catch (e: JSONException) {
64
+ throw TrackingException(ErrorCode.INVALID_ARGUMENT, "$what cannot be stored: ${e.message}", e)
65
+ }
66
+
67
+ /**
68
+ * Runs [block] and converts any failure except cancellation and [TrackingException] into
69
+ * `TrackingException(IO_ERROR)`, the only exception type that crosses component boundaries.
70
+ */
71
+ internal inline fun <T> ioErrors(what: String, block: () -> T): T = try {
72
+ block()
73
+ } catch (e: CancellationException) {
74
+ throw e
75
+ } catch (e: TrackingException) {
76
+ throw e
77
+ } catch (e: Exception) {
78
+ throw TrackingException(ErrorCode.IO_ERROR, "$what failed: ${e.message ?: e.javaClass.simpleName}", e)
79
+ }
@@ -0,0 +1,144 @@
1
+ package com.brickssoft.locationtracking.data
2
+
3
+ import android.content.ContentValues
4
+ import android.database.DatabaseUtils
5
+ import android.database.sqlite.SQLiteDatabase
6
+ import com.brickssoft.locationtracking.core.AppDispatchers
7
+ import com.brickssoft.locationtracking.core.Logger
8
+ import com.brickssoft.locationtracking.model.GeofenceJson
9
+ import com.brickssoft.locationtracking.model.GeofenceSpec
10
+ import com.brickssoft.locationtracking.model.JsonUtil
11
+ import kotlinx.coroutines.withContext
12
+ import org.json.JSONObject
13
+
14
+ /**
15
+ * SQLite geofence registry (table `geofences` of [TrackingDatabase]). All work runs on [AppDispatchers.io];
16
+ * failures are thrown as `TrackingException(IO_ERROR)`.
17
+ *
18
+ * - Specs are stored as `GeofenceJson`; [all] is ordered by identifier.
19
+ * - [upsert] replaces the spec but keeps an existing runtime; [remove] and [removeAll] delete the runtime too.
20
+ * - [setRuntime] stores `{insideCircle, insidePolygon, enteredAt}` and is ignored for an unknown identifier.
21
+ * - A row whose spec cannot be decoded is logged, skipped and deleted.
22
+ *
23
+ * Thread-safe: SQLiteDatabase serializes writes and [upsert] runs in one transaction. Reads run concurrently with
24
+ * writes (WAL).
25
+ */
26
+ class SqliteGeofenceStore(
27
+ private val database: TrackingDatabase,
28
+ private val dispatchers: AppDispatchers,
29
+ ) : GeofenceStore {
30
+ override suspend fun upsert(geofences: List<GeofenceSpec>) {
31
+ if (geofences.isEmpty()) return
32
+ io("upsert") { db ->
33
+ val rows = geofences.map { g ->
34
+ g.identifier to encoding("geofence '${g.identifier}'") { GeofenceJson.toJson(g).toString() }
35
+ }
36
+ db.inTransaction {
37
+ for ((id, json) in rows) {
38
+ // Update first so an existing runtime_json is kept (no UPSERT syntax before SQLite 3.24 / API 30).
39
+ val values = ContentValues(2).apply { put(GeofencesTable.JSON, json) }
40
+ val updated = db.update(GeofencesTable.NAME, values, "${GeofencesTable.ID} = ?", arrayOf(id))
41
+ if (updated == 0) {
42
+ values.put(GeofencesTable.ID, id)
43
+ db.insertOrThrow(GeofencesTable.NAME, null, values)
44
+ }
45
+ }
46
+ }
47
+ }
48
+ }
49
+
50
+ override suspend fun remove(ids: Collection<String>): Int {
51
+ if (ids.isEmpty()) return 0
52
+ return io("remove") { db -> db.deleteIn(GeofencesTable.NAME, GeofencesTable.ID, ids) }
53
+ }
54
+
55
+ override suspend fun removeAll(): Int = io("removeAll") { db ->
56
+ // "1" (not null) makes SQLite report the number of deleted rows.
57
+ db.delete(GeofencesTable.NAME, "1", null)
58
+ }
59
+
60
+ override suspend fun all(): List<GeofenceSpec> = io("all") { db -> query(db, null) }
61
+
62
+ override suspend fun get(id: String): GeofenceSpec? = io("get") { db -> query(db, id).firstOrNull() }
63
+
64
+ override suspend fun count(): Int = io("count") { db ->
65
+ DatabaseUtils.queryNumEntries(db, GeofencesTable.NAME).toInt()
66
+ }
67
+
68
+ override suspend fun setRuntime(id: String, runtime: GeofenceRuntime) {
69
+ io("setRuntime") { db ->
70
+ val values = ContentValues(1).apply {
71
+ put(GeofencesTable.RUNTIME_JSON, GeofenceRuntimeJson.toJson(runtime).toString())
72
+ }
73
+ val updated = db.update(GeofencesTable.NAME, values, "${GeofencesTable.ID} = ?", arrayOf(id))
74
+ if (updated == 0) Logger.d(TAG, "ignoring runtime of unknown geofence '$id'")
75
+ }
76
+ }
77
+
78
+ override suspend fun runtimes(): Map<String, GeofenceRuntime> = io("runtimes") { db ->
79
+ val result = LinkedHashMap<String, GeofenceRuntime>()
80
+ db.rawQuery(
81
+ "SELECT ${GeofencesTable.ID}, ${GeofencesTable.RUNTIME_JSON} FROM ${GeofencesTable.NAME} " +
82
+ "WHERE ${GeofencesTable.RUNTIME_JSON} IS NOT NULL ORDER BY ${GeofencesTable.ID}",
83
+ null,
84
+ ).use { cursor ->
85
+ while (cursor.moveToNext()) {
86
+ val id = cursor.getString(0)
87
+ val runtime = JsonUtil.parseObject(cursor.getString(1))?.let { GeofenceRuntimeJson.fromJson(it) }
88
+ if (runtime != null) result[id] = runtime else Logger.w(TAG, "skipping undecodable runtime of '$id'")
89
+ }
90
+ }
91
+ result
92
+ }
93
+
94
+ private suspend fun <T> io(what: String, block: (SQLiteDatabase) -> T): T = withContext(dispatchers.io) {
95
+ ioErrors("geofence store $what") { block(database.writableDatabase) }
96
+ }
97
+
98
+ /** Specs ordered by identifier ([id] = null: all). Undecodable rows are logged, skipped and deleted. */
99
+ private fun query(db: SQLiteDatabase, id: String?): List<GeofenceSpec> {
100
+ val result = ArrayList<GeofenceSpec>()
101
+ val undecodable = ArrayList<UndecodableRow>()
102
+ val where = if (id != null) " WHERE ${GeofencesTable.ID} = ?" else ""
103
+ db.rawQuery(
104
+ "SELECT ${GeofencesTable.ID}, ${GeofencesTable.JSON} FROM ${GeofencesTable.NAME}$where " +
105
+ "ORDER BY ${GeofencesTable.ID}",
106
+ id?.let { arrayOf(it) },
107
+ ).use { cursor ->
108
+ while (cursor.moveToNext()) {
109
+ val rowId = cursor.getString(0)
110
+ val json = cursor.getString(1)
111
+ try {
112
+ result += GeofenceJson.fromJson(JSONObject(json))
113
+ } catch (e: Exception) {
114
+ Logger.e(TAG, "skipping undecodable geofence '$rowId'", e)
115
+ undecodable += UndecodableRow(rowId, json)
116
+ }
117
+ }
118
+ }
119
+ if (undecodable.isNotEmpty()) {
120
+ val removed = db.deleteUndecodable(GeofencesTable.NAME, GeofencesTable.ID, GeofencesTable.JSON, undecodable)
121
+ Logger.w(TAG, "deleted $removed undecodable geofence(s)")
122
+ }
123
+ return result
124
+ }
125
+
126
+ private companion object {
127
+ const val TAG = "LT.GeofenceStore"
128
+ }
129
+ }
130
+
131
+ /** `runtime_json` codec: `{"insideCircle":bool,"insidePolygon":bool,"enteredAt":ms|null}`. */
132
+ internal object GeofenceRuntimeJson {
133
+ fun toJson(runtime: GeofenceRuntime): JSONObject = JSONObject()
134
+ .put("insideCircle", runtime.insideCircle)
135
+ .put("insidePolygon", runtime.insidePolygon)
136
+ .put("enteredAt", JsonUtil.orNull(runtime.enteredAt))
137
+
138
+ /** Lenient: missing flags are false, a missing `enteredAt` is null. */
139
+ fun fromJson(json: JSONObject): GeofenceRuntime = GeofenceRuntime(
140
+ insideCircle = JsonUtil.optBoolean(json, "insideCircle") ?: false,
141
+ insidePolygon = JsonUtil.optBoolean(json, "insidePolygon") ?: false,
142
+ enteredAt = JsonUtil.optLong(json, "enteredAt"),
143
+ )
144
+ }
@@ -0,0 +1,215 @@
1
+ package com.brickssoft.locationtracking.data
2
+
3
+ import android.content.ContentValues
4
+ import android.database.DatabaseUtils
5
+ import android.database.sqlite.SQLiteDatabase
6
+ import com.brickssoft.locationtracking.config.ConfigStore
7
+ import com.brickssoft.locationtracking.core.AppDispatchers
8
+ import com.brickssoft.locationtracking.core.Clock
9
+ import com.brickssoft.locationtracking.core.ErrorCode
10
+ import com.brickssoft.locationtracking.core.Logger
11
+ import com.brickssoft.locationtracking.core.TrackingException
12
+ import com.brickssoft.locationtracking.model.Record
13
+ import com.brickssoft.locationtracking.model.RecordEvent
14
+ import com.brickssoft.locationtracking.model.RecordJson
15
+ import kotlinx.coroutines.withContext
16
+ import org.json.JSONObject
17
+
18
+ /**
19
+ * SQLite record queue (table `records` of [TrackingDatabase]). All work runs on [AppDispatchers.io]; failures are
20
+ * thrown as `TrackingException(IO_ERROR)`.
21
+ *
22
+ * - Rows hold `RecordJson.toJson(record)` without `sent_at`. Inserting an existing uuid replaces the row (and
23
+ * resets its attempt counters).
24
+ * - [list] is oldest first (`recorded_at`, then insertion order). A row that cannot be decoded is logged, skipped
25
+ * and deleted (a limited page is refilled with the next rows), so a bad row cannot block the queue. [count] still
26
+ * includes such a row until a [list] has seen it.
27
+ * - Pruning runs on first use and after every [PRUNE_EVERY] inserts, for **every** event type: heartbeat and
28
+ * audit records are dropped like location records. First, records whose `recorded_at` is older than
29
+ * `persistence.maxDaysToPersist` days are deleted (skipped if that is <= 0); then, if
30
+ * `persistence.maxRecordsToPersist > 0`, only the newest N records (by `recorded_at`, then insertion order)
31
+ * are kept. Between prunes the queue may exceed `maxRecordsToPersist` by fewer than [PRUNE_EVERY] records.
32
+ * Pruning failures are logged, never thrown.
33
+ *
34
+ * Thread-safe: SQLiteDatabase serializes writes and multi-statement writes run in transactions; a lock guards the
35
+ * insert counter and pruning. Reads run concurrently with writes (WAL).
36
+ */
37
+ class SqliteLocationStore(
38
+ private val database: TrackingDatabase,
39
+ private val configStore: ConfigStore,
40
+ private val clock: Clock,
41
+ private val dispatchers: AppDispatchers,
42
+ ) : LocationStore {
43
+ private val pruneLock = Any()
44
+
45
+ @Volatile
46
+ private var initialPruneDone = false
47
+
48
+ /** Guarded by [pruneLock]. */
49
+ private var insertsSincePrune = 0
50
+
51
+ override suspend fun insert(record: Record) = io("insert") { db ->
52
+ val values = ContentValues(4).apply {
53
+ put(RecordsTable.UUID, record.uuid)
54
+ put(RecordsTable.EVENT, record.event.wire)
55
+ put(RecordsTable.RECORDED_AT, record.recordedAt)
56
+ put(RecordsTable.JSON, encoding("record ${record.uuid}") { RecordJson.toJson(record).toString() })
57
+ }
58
+ synchronized(pruneLock) {
59
+ val rowId = db.insertWithOnConflict(RecordsTable.NAME, null, values, SQLiteDatabase.CONFLICT_REPLACE)
60
+ if (rowId == -1L) throw TrackingException(ErrorCode.IO_ERROR, "record ${record.uuid} was not inserted")
61
+ insertsSincePrune++
62
+ if (insertsSincePrune >= PRUNE_EVERY) prune(db)
63
+ }
64
+ }
65
+
66
+ override suspend fun list(limit: Int, events: Set<RecordEvent>?): List<Record> {
67
+ if (limit == 0 || events?.isEmpty() == true) return emptyList()
68
+ return io("list") { db ->
69
+ var page = queryPage(db, limit, events)
70
+ var passes = 0
71
+ while (page.undecodable.isNotEmpty() && passes++ < MAX_REPAIR_PASSES) {
72
+ val removed =
73
+ db.deleteUndecodable(RecordsTable.NAME, RecordsTable.UUID, RecordsTable.JSON, page.undecodable)
74
+ Logger.w(TAG, "deleted $removed undecodable record(s)")
75
+ // Without a limit every row was read already; with one, re-query to refill the page.
76
+ if (limit < 0) break
77
+ page = queryPage(db, limit, events)
78
+ }
79
+ page.records
80
+ }
81
+ }
82
+
83
+ override suspend fun count(events: Set<RecordEvent>?): Int {
84
+ if (events?.isEmpty() == true) return 0
85
+ return io("count") { db ->
86
+ if (events == null) {
87
+ DatabaseUtils.queryNumEntries(db, RecordsTable.NAME).toInt()
88
+ } else {
89
+ DatabaseUtils.queryNumEntries(db, RecordsTable.NAME, eventFilter(events), eventArgs(events)).toInt()
90
+ }
91
+ }
92
+ }
93
+
94
+ override suspend fun delete(uuids: Collection<String>): Int {
95
+ if (uuids.isEmpty()) return 0
96
+ return io("delete") { db -> db.deleteIn(RecordsTable.NAME, RecordsTable.UUID, uuids) }
97
+ }
98
+
99
+ override suspend fun deleteAll(): Int = io("deleteAll") { db ->
100
+ // "1" (not null) makes SQLite report the number of deleted rows.
101
+ db.delete(RecordsTable.NAME, "1", null)
102
+ }
103
+
104
+ override suspend fun markAttempt(uuids: Collection<String>, at: Long) {
105
+ if (uuids.isEmpty()) return
106
+ io("markAttempt") { db ->
107
+ db.inTransaction {
108
+ // One parameter is `at`, so each chunk leaves room for it.
109
+ for (chunk in uuids.distinct().chunked(MAX_SQL_PARAMS - 1)) {
110
+ db.executeUpdateDelete(
111
+ "UPDATE ${RecordsTable.NAME} " +
112
+ "SET ${RecordsTable.ATTEMPTS} = ${RecordsTable.ATTEMPTS} + 1, " +
113
+ "${RecordsTable.LAST_ATTEMPT_AT} = ? " +
114
+ "WHERE ${RecordsTable.UUID} IN (${placeholders(chunk.size)})",
115
+ ) {
116
+ bindLong(1, at)
117
+ chunk.forEachIndexed { i, uuid -> bindString(i + 2, uuid) }
118
+ }
119
+ }
120
+ }
121
+ }
122
+ }
123
+
124
+ /** Opens the database on [AppDispatchers.io], prunes once per instance, then runs [block]. */
125
+ private suspend fun <T> io(what: String, block: (SQLiteDatabase) -> T): T = withContext(dispatchers.io) {
126
+ ioErrors("location store $what") {
127
+ val db = database.writableDatabase
128
+ pruneOnFirstUse(db)
129
+ block(db)
130
+ }
131
+ }
132
+
133
+ private fun pruneOnFirstUse(db: SQLiteDatabase) {
134
+ if (initialPruneDone) return
135
+ synchronized(pruneLock) {
136
+ if (!initialPruneDone) {
137
+ prune(db)
138
+ initialPruneDone = true
139
+ }
140
+ }
141
+ }
142
+
143
+ /** Deletes expired and surplus records (see class docs). Caller holds [pruneLock]. Never throws. */
144
+ private fun prune(db: SQLiteDatabase) {
145
+ insertsSincePrune = 0
146
+ val persistence = configStore.config.value.persistence
147
+ try {
148
+ var expired = 0
149
+ var surplus = 0
150
+ db.inTransaction {
151
+ if (persistence.maxDaysToPersist > 0) {
152
+ val cutoff = clock.now() - persistence.maxDaysToPersist * DAY_MS
153
+ expired = db.executeUpdateDelete(
154
+ "DELETE FROM ${RecordsTable.NAME} WHERE ${RecordsTable.RECORDED_AT} < ?",
155
+ ) { bindLong(1, cutoff) }
156
+ }
157
+ if (persistence.maxRecordsToPersist > 0) {
158
+ surplus = db.executeUpdateDelete(
159
+ "DELETE FROM ${RecordsTable.NAME} WHERE rowid IN (SELECT rowid FROM ${RecordsTable.NAME} " +
160
+ "ORDER BY ${RecordsTable.RECORDED_AT} DESC, rowid DESC LIMIT -1 OFFSET ?)",
161
+ ) { bindLong(1, persistence.maxRecordsToPersist.toLong()) }
162
+ }
163
+ }
164
+ if (expired + surplus > 0) {
165
+ Logger.i(
166
+ TAG,
167
+ "pruned $expired record(s) older than ${persistence.maxDaysToPersist} day(s) and " +
168
+ "$surplus over the limit of ${persistence.maxRecordsToPersist}",
169
+ )
170
+ }
171
+ } catch (e: Exception) {
172
+ Logger.e(TAG, "pruning failed", e)
173
+ }
174
+ }
175
+
176
+ private class Page(val records: List<Record>, val undecodable: List<UndecodableRow>)
177
+
178
+ private fun queryPage(db: SQLiteDatabase, limit: Int, events: Set<RecordEvent>?): Page {
179
+ val sql = StringBuilder("SELECT ${RecordsTable.UUID}, ${RecordsTable.JSON} FROM ${RecordsTable.NAME}")
180
+ if (events != null) sql.append(" WHERE ").append(eventFilter(events))
181
+ sql.append(" ORDER BY ${RecordsTable.RECORDED_AT} ASC, rowid ASC")
182
+ if (limit > 0) sql.append(" LIMIT ").append(limit)
183
+ val records = ArrayList<Record>()
184
+ val undecodable = ArrayList<UndecodableRow>()
185
+ db.rawQuery(sql.toString(), events?.let { eventArgs(it) }).use { cursor ->
186
+ while (cursor.moveToNext()) {
187
+ val uuid = cursor.getString(0)
188
+ val json = cursor.getString(1)
189
+ try {
190
+ records += RecordJson.fromJson(JSONObject(json))
191
+ } catch (e: Exception) {
192
+ Logger.e(TAG, "skipping undecodable record $uuid", e)
193
+ undecodable += UndecodableRow(uuid, json)
194
+ }
195
+ }
196
+ }
197
+ return Page(records, undecodable)
198
+ }
199
+
200
+ private fun eventFilter(events: Set<RecordEvent>): String =
201
+ "${RecordsTable.EVENT} IN (${placeholders(events.size)})"
202
+
203
+ private fun eventArgs(events: Set<RecordEvent>): Array<String> = events.map { it.wire }.toTypedArray()
204
+
205
+ companion object {
206
+ /** Pruning runs after this many inserts (and on first use). */
207
+ const val PRUNE_EVERY = 50
208
+
209
+ /** Bounds the refill loop of [list] when many undecodable rows are found. */
210
+ private const val MAX_REPAIR_PASSES = 10
211
+
212
+ private const val DAY_MS = 86_400_000L
213
+ private const val TAG = "LT.LocationStore"
214
+ }
215
+ }
@@ -0,0 +1,45 @@
1
+ package com.brickssoft.locationtracking.data
2
+
3
+ import com.brickssoft.locationtracking.model.GeofenceSpec
4
+ import com.brickssoft.locationtracking.model.Record
5
+ import com.brickssoft.locationtracking.model.RecordEvent
6
+
7
+ /**
8
+ * The record queue. Pruning (`maxDaysToPersist` / `maxRecordsToPersist`) is internal: on open and every 50 inserts.
9
+ */
10
+ interface LocationStore {
11
+ suspend fun insert(record: Record)
12
+
13
+ /** Oldest first; `limit < 0` = all; `events == null` = every event. */
14
+ suspend fun list(limit: Int = -1, events: Set<RecordEvent>? = null): List<Record>
15
+
16
+ suspend fun count(events: Set<RecordEvent>? = null): Int
17
+
18
+ suspend fun delete(uuids: Collection<String>): Int
19
+
20
+ suspend fun deleteAll(): Int
21
+
22
+ /** Increments `attempts` and sets `last_attempt_at`. */
23
+ suspend fun markAttempt(uuids: Collection<String>, at: Long)
24
+ }
25
+
26
+ /** Per-geofence runtime used for polygon hit-testing and dwell synthesis. */
27
+ data class GeofenceRuntime(val insideCircle: Boolean, val insidePolygon: Boolean, val enteredAt: Long?)
28
+
29
+ interface GeofenceStore {
30
+ suspend fun upsert(geofences: List<GeofenceSpec>)
31
+
32
+ suspend fun remove(ids: Collection<String>): Int
33
+
34
+ suspend fun removeAll(): Int
35
+
36
+ suspend fun all(): List<GeofenceSpec>
37
+
38
+ suspend fun get(id: String): GeofenceSpec?
39
+
40
+ suspend fun count(): Int
41
+
42
+ suspend fun setRuntime(id: String, runtime: GeofenceRuntime)
43
+
44
+ suspend fun runtimes(): Map<String, GeofenceRuntime>
45
+ }
@@ -0,0 +1,111 @@
1
+ package com.brickssoft.locationtracking.data
2
+
3
+ import android.content.Context
4
+ import android.database.sqlite.SQLiteDatabase
5
+ import android.database.sqlite.SQLiteOpenHelper
6
+ import com.brickssoft.locationtracking.core.Constants
7
+ import com.brickssoft.locationtracking.core.Logger
8
+
9
+ /**
10
+ * SQLiteOpenHelper for [Constants.DATABASE_NAME] (schema [VERSION]), shared by [SqliteLocationStore] and
11
+ * [SqliteGeofenceStore]. Write-ahead logging is enabled, so readers never block the single writer.
12
+ *
13
+ * Schema v1:
14
+ * - `records`: the upload queue. `json` is `RecordJson.toJson(record)` without `sent_at`; `event` and `recorded_at`
15
+ * duplicate the JSON for filtering, ordering and pruning; `attempts` / `last_attempt_at` track failed uploads.
16
+ * Indexed on `recorded_at` and on `(event, recorded_at)`.
17
+ * - `geofences`: `json` is `GeofenceJson.toJson(spec)`; `runtime_json` is `{insideCircle, insidePolygon, enteredAt}`
18
+ * or null.
19
+ *
20
+ * @param name database file name; `null` creates an in-memory database.
21
+ */
22
+ class TrackingDatabase(
23
+ context: Context,
24
+ name: String? = Constants.DATABASE_NAME,
25
+ ) : SQLiteOpenHelper(context.applicationContext ?: context, name, null, VERSION) {
26
+
27
+ init {
28
+ // Must be set before the database is first opened.
29
+ setWriteAheadLoggingEnabled(true)
30
+ }
31
+
32
+ override fun onCreate(db: SQLiteDatabase) {
33
+ db.execSQL(
34
+ "CREATE TABLE ${RecordsTable.NAME} (" +
35
+ "${RecordsTable.UUID} TEXT PRIMARY KEY NOT NULL, " +
36
+ "${RecordsTable.EVENT} TEXT NOT NULL, " +
37
+ "${RecordsTable.RECORDED_AT} INTEGER NOT NULL, " +
38
+ "${RecordsTable.JSON} TEXT NOT NULL, " +
39
+ "${RecordsTable.ATTEMPTS} INTEGER NOT NULL DEFAULT 0, " +
40
+ "${RecordsTable.LAST_ATTEMPT_AT} INTEGER)",
41
+ )
42
+ db.execSQL(
43
+ "CREATE INDEX ${RecordsTable.INDEX_RECORDED_AT} ON ${RecordsTable.NAME}(${RecordsTable.RECORDED_AT})",
44
+ )
45
+ db.execSQL(
46
+ "CREATE INDEX ${RecordsTable.INDEX_EVENT_RECORDED_AT} " +
47
+ "ON ${RecordsTable.NAME}(${RecordsTable.EVENT}, ${RecordsTable.RECORDED_AT})",
48
+ )
49
+ db.execSQL(
50
+ "CREATE TABLE ${GeofencesTable.NAME} (" +
51
+ "${GeofencesTable.ID} TEXT PRIMARY KEY NOT NULL, " +
52
+ "${GeofencesTable.JSON} TEXT NOT NULL, " +
53
+ "${GeofencesTable.RUNTIME_JSON} TEXT)",
54
+ )
55
+ Logger.i(TAG, "created database schema v$VERSION")
56
+ }
57
+
58
+ /**
59
+ * Migration strategy (only v1 exists today). `SQLiteOpenHelper` runs this inside a transaction.
60
+ *
61
+ * Every schema change bumps [VERSION] and adds one step here, applied in order:
62
+ * `for (v in oldVersion + 1..newVersion) when (v) { 2 -> migrateTo2(db) ... }`.
63
+ * - `records` must never be dropped or recreated: it holds queued heartbeat and audit records that have not
64
+ * been uploaded yet. Steps are additive (`ALTER TABLE ... ADD COLUMN` with a default, `CREATE INDEX IF NOT
65
+ * EXISTS`), or, for incompatible changes, create `records_new`, `INSERT INTO records_new SELECT ... FROM
66
+ * records`, drop the old table and rename the new one, all in this transaction.
67
+ * - The `json` column is self-describing, so new columns can be backfilled from it in the same step.
68
+ * - `geofences` follows the same rule (the app registered those regions and expects them back).
69
+ * - Steps must be idempotent (check `PRAGMA table_info` before `ADD COLUMN`, use `IF NOT EXISTS`), because
70
+ * [onDowngrade] keeps a newer schema and a later upgrade runs the same steps again.
71
+ */
72
+ override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
73
+ Logger.i(TAG, "database upgrade v$oldVersion -> v$newVersion: no migration steps defined")
74
+ }
75
+
76
+ /**
77
+ * Keeps the newer schema and its data (the default would make every open fail, losing the queue). Because
78
+ * migrations are additive, this version's statements still work on it.
79
+ */
80
+ override fun onDowngrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
81
+ Logger.w(TAG, "database downgrade v$oldVersion -> v$newVersion: keeping the newer schema and its data")
82
+ }
83
+
84
+ companion object {
85
+ /** Current schema version. */
86
+ const val VERSION = 1
87
+
88
+ private const val TAG = "LT.Database"
89
+ }
90
+ }
91
+
92
+ /** Column names of the `records` table. */
93
+ internal object RecordsTable {
94
+ const val NAME = "records"
95
+ const val UUID = "uuid"
96
+ const val EVENT = "event"
97
+ const val RECORDED_AT = "recorded_at"
98
+ const val JSON = "json"
99
+ const val ATTEMPTS = "attempts"
100
+ const val LAST_ATTEMPT_AT = "last_attempt_at"
101
+ const val INDEX_RECORDED_AT = "records_recorded_at"
102
+ const val INDEX_EVENT_RECORDED_AT = "records_event_recorded_at"
103
+ }
104
+
105
+ /** Column names of the `geofences` table. */
106
+ internal object GeofencesTable {
107
+ const val NAME = "geofences"
108
+ const val ID = "id"
109
+ const val JSON = "json"
110
+ const val RUNTIME_JSON = "runtime_json"
111
+ }