@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,55 @@
1
+ package com.brickssoft.locationtracking.provider.hms
2
+
3
+ import android.content.Context
4
+ import com.brickssoft.locationtracking.core.Logger
5
+ import com.brickssoft.locationtracking.model.ProviderKind
6
+ import com.brickssoft.locationtracking.provider.ActivityBackend
7
+ import com.brickssoft.locationtracking.provider.GeofenceBackend
8
+ import com.brickssoft.locationtracking.provider.LocationBackend
9
+ import com.brickssoft.locationtracking.provider.ProviderBundle
10
+ import com.huawei.hms.api.ConnectionResult
11
+ import com.huawei.hms.api.HuaweiApiAvailability
12
+
13
+ /**
14
+ * HMS (Huawei Location Kit) backends. Created reflectively through the public `(Context)` constructor, only after
15
+ * the HMS SDK class was found on the classpath. Construction touches no HMS API; each backend is created on first
16
+ * use and then reused, so listener and registration state survives repeated `location()`/`activity()`/`geofence()`
17
+ * calls.
18
+ *
19
+ * @param availabilityCheck returns an HMS `ConnectionResult` code for the device; injectable for tests.
20
+ */
21
+ class HmsProviderBundle @JvmOverloads constructor(
22
+ context: Context,
23
+ private val availabilityCheck: (Context) -> Int = { ctx ->
24
+ HuaweiApiAvailability.getInstance().isHuaweiMobileServicesAvailable(ctx)
25
+ },
26
+ ) : ProviderBundle {
27
+ private val appContext: Context = context.applicationContext ?: context
28
+
29
+ override val kind: ProviderKind = ProviderKind.HMS
30
+
31
+ private val locationBackend by lazy { HmsLocationBackend(appContext) }
32
+ private val activityBackend by lazy { HmsActivityBackend(appContext) }
33
+ private val geofenceBackend by lazy { HmsGeofenceBackend(appContext) }
34
+
35
+ /** True if HMS Core reports SUCCESS; any failure (including a missing SDK class) counts as unavailable. */
36
+ override fun isAvailable(): Boolean =
37
+ try {
38
+ val result = availabilityCheck(appContext)
39
+ if (result != ConnectionResult.SUCCESS) Logger.d(TAG, "HMS Core unavailable: result $result")
40
+ result == ConnectionResult.SUCCESS
41
+ } catch (t: Throwable) {
42
+ Logger.w(TAG, "HMS availability check failed", t)
43
+ false
44
+ }
45
+
46
+ override fun location(): LocationBackend = locationBackend
47
+
48
+ override fun activity(): ActivityBackend = activityBackend
49
+
50
+ override fun geofence(): GeofenceBackend = geofenceBackend
51
+
52
+ private companion object {
53
+ const val TAG = "LT.HmsBundle"
54
+ }
55
+ }
@@ -0,0 +1,44 @@
1
+ package com.brickssoft.locationtracking.provider.hms
2
+
3
+ import com.brickssoft.locationtracking.core.TrackingException
4
+ import com.huawei.hmf.tasks.Task
5
+ import com.huawei.hmf.tasks.TaskExecutors
6
+ import kotlinx.coroutines.suspendCancellableCoroutine
7
+ import kotlin.coroutines.cancellation.CancellationException
8
+ import kotlin.coroutines.resume
9
+ import kotlin.coroutines.resumeWithException
10
+
11
+ /**
12
+ * Suspends until this HMS task completes and returns its result (which may be null for `Task<Void>` or an absent
13
+ * last location).
14
+ *
15
+ * - A failed task rethrows its exception unchanged (usually `com.huawei.hms.common.ApiException`).
16
+ * - A task canceled by HMS throws [HmsTaskCanceledException], deliberately not a [CancellationException]: the
17
+ * caller's coroutine was not cancelled, so the failure must not be mistaken for cancellation.
18
+ * - Cancelling the coroutine stops waiting; HMS tasks cannot be cancelled, so a late result is ignored.
19
+ *
20
+ * Listeners run on the completing thread ([TaskExecutors.immediate]); the continuation then resumes on the
21
+ * caller's dispatcher.
22
+ */
23
+ internal suspend fun <T> Task<T>.await(): T = suspendCancellableCoroutine { cont ->
24
+ val executor = TaskExecutors.immediate()
25
+ addOnSuccessListener(executor) { result -> cont.resume(result) }
26
+ addOnFailureListener(executor) { e -> cont.resumeWithException(e) }
27
+ addOnCanceledListener(executor) { cont.resumeWithException(HmsTaskCanceledException()) }
28
+ }
29
+
30
+ /** An HMS task was canceled by the SDK; [hmsCall] maps it to `UNAVAILABLE`. */
31
+ internal class HmsTaskCanceledException : Exception("HMS task was canceled")
32
+
33
+ /**
34
+ * Runs [block] and converts every failure except coroutine cancellation into a [TrackingException]
35
+ * (see [toTrackingException]). [operation] names the HMS call in the message.
36
+ */
37
+ internal inline fun <T> hmsCall(operation: String, block: () -> T): T =
38
+ try {
39
+ block()
40
+ } catch (e: CancellationException) {
41
+ throw e
42
+ } catch (e: Exception) {
43
+ throw e.toTrackingException(operation)
44
+ }
@@ -0,0 +1,95 @@
1
+ package com.brickssoft.locationtracking.record
2
+
3
+ import com.brickssoft.locationtracking.config.ConfigStore
4
+ import com.brickssoft.locationtracking.core.EventBus
5
+ import com.brickssoft.locationtracking.core.Logger
6
+ import com.brickssoft.locationtracking.core.RecordHooks
7
+ import com.brickssoft.locationtracking.core.TrackingEvent
8
+ import com.brickssoft.locationtracking.data.LocationStore
9
+ import com.brickssoft.locationtracking.heartbeat.HeartbeatScheduler
10
+ import com.brickssoft.locationtracking.http.HttpSyncer
11
+ import com.brickssoft.locationtracking.model.Record
12
+ import com.brickssoft.locationtracking.model.RecordEvent
13
+ import kotlin.coroutines.cancellation.CancellationException
14
+
15
+ /** Single entry point for persisting a record. */
16
+ interface RecordSink {
17
+ /** Persists [record], updates runtime state, restarts the heartbeat window, emits events and triggers sync. */
18
+ suspend fun submit(record: Record): Record
19
+ }
20
+
21
+ /**
22
+ * Default [RecordSink]. Order of operations:
23
+ * 1. `store.insert(record)`, then `hooks.dispatch(record)` (round 2, see below);
24
+ * 2. `updateRuntime { lastRecordAt/Elapsed/BootCount, lastLocation = record.location ?: it.lastLocation,
25
+ * lastHeartbeatAt if HEARTBEAT }`;
26
+ * 3. `heartbeat.onRecordRecorded(record)`;
27
+ * 4. emit: LOCATION / CURRENT_POSITION / WATCH_POSITION -> Location; MOTIONCHANGE -> Location + MotionChange;
28
+ * HEARTBEAT -> Heartbeat; every other event is emitted by its producer;
29
+ * 5. `syncer.onRecordInserted(record)`.
30
+ *
31
+ * If the insert fails, the failure is logged, steps 2-4 still run (live listeners still get the record) and
32
+ * step 5 is skipped because the record is not queued.
33
+ *
34
+ * Round 2: [hooks] receives every record right after step 1, also when the insert failed or the caller was cancelled
35
+ * during the insert (a companion listener keeps its own audit trail, so a local database failure must not hide the
36
+ * record from it). Because this happens before step 4, a native listener receives a record before the events that
37
+ * carry it (see `api/NativeListeners`).
38
+ */
39
+ class DefaultRecordSink(
40
+ private val store: LocationStore,
41
+ private val configStore: ConfigStore,
42
+ private val heartbeat: HeartbeatScheduler,
43
+ private val syncer: HttpSyncer,
44
+ private val events: EventBus,
45
+ private val hooks: RecordHooks = RecordHooks(),
46
+ ) : RecordSink {
47
+ override suspend fun submit(record: Record): Record {
48
+ val stored = try {
49
+ store.insert(record)
50
+ true
51
+ } catch (e: CancellationException) {
52
+ // The caller was cancelled during the insert; the row may be committed already (a blocking SQLite write
53
+ // finishes before the coroutine sees the cancellation). The companion keeps its own audit, so it gets the
54
+ // record in either case, like after a failed insert.
55
+ hooks.dispatch(record)
56
+ throw e
57
+ } catch (e: Exception) {
58
+ Logger.e(TAG, "failed to persist ${record.event.wire} record ${record.uuid}", e)
59
+ false
60
+ }
61
+
62
+ hooks.dispatch(record)
63
+
64
+ configStore.updateRuntime { runtime ->
65
+ runtime.copy(
66
+ lastRecordAt = record.recordedAt,
67
+ lastRecordElapsed = record.elapsedRealtimeMs,
68
+ lastRecordBootCount = record.bootCount,
69
+ lastLocation = record.location ?: runtime.lastLocation,
70
+ lastHeartbeatAt = if (record.event == RecordEvent.HEARTBEAT) record.recordedAt else runtime.lastHeartbeatAt,
71
+ )
72
+ }
73
+
74
+ heartbeat.onRecordRecorded(record)
75
+
76
+ when (record.event) {
77
+ RecordEvent.LOCATION, RecordEvent.CURRENT_POSITION, RecordEvent.WATCH_POSITION ->
78
+ events.emit(TrackingEvent.Location(record))
79
+ RecordEvent.MOTIONCHANGE -> {
80
+ events.emit(TrackingEvent.Location(record))
81
+ events.emit(TrackingEvent.MotionChange(record.isMoving, record))
82
+ }
83
+ RecordEvent.HEARTBEAT -> events.emit(TrackingEvent.Heartbeat(record))
84
+ RecordEvent.GEOFENCE, RecordEvent.TRACKING_START, RecordEvent.TRACKING_STOP, RecordEvent.PROVIDERCHANGE ->
85
+ Unit // emitted by the producer
86
+ }
87
+
88
+ if (stored) syncer.onRecordInserted(record)
89
+ return record
90
+ }
91
+
92
+ private companion object {
93
+ const val TAG = "LT.RecordSink"
94
+ }
95
+ }
@@ -0,0 +1,44 @@
1
+ package com.brickssoft.locationtracking.service
2
+
3
+ import android.annotation.SuppressLint
4
+ import android.content.Context
5
+
6
+ /**
7
+ * Boot counts (`Settings.Global.BOOT_COUNT`) that [BootReceiver] uses to tell a real boot from a repeated or fake boot
8
+ * broadcast, in the SharedPreferences file [PREFS_NAME] (owned by the service package):
9
+ * - [lastHandled]: the boot count of the last boot broadcast [BootReceiver] handled;
10
+ * - [lastServiceStart]: the boot count during which [DefaultServiceController] last had a start of the location service
11
+ * accepted by Android (a tracking session was started or restored during that boot).
12
+ *
13
+ * The file is loaded on first use (disk I/O); call these methods off the main thread.
14
+ */
15
+ internal class BootCountStore(context: Context) {
16
+ private val context: Context = context.applicationContext ?: context
17
+
18
+ private val prefs by lazy { this.context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) }
19
+
20
+ /** The boot count of the last handled boot broadcast, or null if none was stored. */
21
+ fun lastHandled(): Int? = read(KEY_LAST_HANDLED)
22
+
23
+ /** Stores [bootCount] as handled; written synchronously (`commit`), so it survives a process death right after. */
24
+ @SuppressLint("ApplySharedPref") // deliberate: called off the main thread, and the value must be on disk
25
+ fun setLastHandled(bootCount: Int) {
26
+ prefs.edit().putInt(KEY_LAST_HANDLED, bootCount).commit()
27
+ }
28
+
29
+ /** The boot count during which the location service was last started, or null if none was stored. */
30
+ fun lastServiceStart(): Int? = read(KEY_LAST_SERVICE_START)
31
+
32
+ /** Records that the location service was started during boot [bootCount]; writes only when the value changes. */
33
+ fun setServiceStarted(bootCount: Int) {
34
+ if (read(KEY_LAST_SERVICE_START) != bootCount) prefs.edit().putInt(KEY_LAST_SERVICE_START, bootCount).apply()
35
+ }
36
+
37
+ private fun read(key: String): Int? = if (prefs.contains(key)) prefs.getInt(key, -1) else null
38
+
39
+ companion object {
40
+ const val PREFS_NAME = "location_tracking_boot"
41
+ private const val KEY_LAST_HANDLED = "last_handled_boot_count"
42
+ private const val KEY_LAST_SERVICE_START = "last_service_start_boot_count"
43
+ }
44
+ }
@@ -0,0 +1,175 @@
1
+ package com.brickssoft.locationtracking.service
2
+
3
+ import android.content.BroadcastReceiver
4
+ import android.content.Context
5
+ import android.content.Intent
6
+ import androidx.annotation.VisibleForTesting
7
+ import com.brickssoft.locationtracking.config.ConfigStore
8
+ import com.brickssoft.locationtracking.core.Logger
9
+ import com.brickssoft.locationtracking.engine.TrackingEngine
10
+ import kotlinx.coroutines.delay
11
+ import kotlinx.coroutines.launch
12
+ import java.util.concurrent.atomic.AtomicBoolean
13
+ import kotlin.coroutines.cancellation.CancellationException
14
+
15
+ /**
16
+ * Resumes tracking after a reboot (`BOOT_COMPLETED`, both `QUICKBOOT_POWERON` variants) or an app update
17
+ * (`MY_PACKAGE_REPLACED`).
18
+ *
19
+ * - Tracking enabled and `app.startOnBoot` → `engine.restore("boot" | "package_replaced")`.
20
+ * - Tracking enabled but not `startOnBoot` → `engine.endWithoutRestore("reboot" | "package_replaced")` records
21
+ * `tracking_stop` with that reason (so the server learns why heartbeats stopped) and clears `enabled`.
22
+ * - Tracking not enabled → nothing.
23
+ *
24
+ * Devices that send several boot broadcasts are handled once per process.
25
+ *
26
+ * **Boot count gate.** The receiver is exported (plugin manifest) and `QUICKBOOT_POWERON` is not a protected
27
+ * broadcast, so any app, or `adb shell am broadcast`, can send it without a reboot. A boot action is therefore
28
+ * handled only when `Settings.Global.BOOT_COUNT` (via `Clock.bootCount()`) shows a boot that has not been seen yet;
29
+ * it is ignored when the current boot count
30
+ * - equals the boot count of the last boot broadcast this receiver handled (stored in [BootCountStore] after handling,
31
+ * also when tracking was not enabled or the restore failed), or
32
+ * - equals the boot count during which [DefaultServiceController] last started the location service
33
+ * ([BootCountStore.lastServiceStart]): a tracking session was already started or restored during this boot, so a
34
+ * boot restore would duplicate `tracking_start` (or end a running audit trail with `tracking_stop: reboot`). This
35
+ * also covers an app that was installed and started without a reboot since.
36
+ *
37
+ * If the boot count cannot be read (-1), every boot action is handled as before. `MY_PACKAGE_REPLACED` is not gated.
38
+ */
39
+ class BootReceiver : BroadcastReceiver() {
40
+ override fun onReceive(context: Context, intent: Intent) {
41
+ val reason = reasonFor(intent.action) ?: return
42
+ val deps = try {
43
+ ServiceDeps.from(context)
44
+ } catch (e: Exception) {
45
+ Logger.e(TAG, "components unavailable", e)
46
+ return
47
+ }
48
+ if (reason == REASON_BOOT && !bootHandled.compareAndSet(false, true)) {
49
+ Logger.d(TAG, "${intent.action}: boot already handled")
50
+ return
51
+ }
52
+ // Null when onReceive is called directly instead of by the system.
53
+ val pending: PendingResult? = goAsync()
54
+ val finished = AtomicBoolean(false)
55
+ val finish = { if (finished.compareAndSet(false, true)) pending?.finish() }
56
+ val bootCounts = BootCountStore(context)
57
+ val work = deps.scope.launch {
58
+ try {
59
+ if (reason == REASON_BOOT) {
60
+ handleBoot(intent.action, deps, bootCounts)
61
+ } else {
62
+ handle(reason, deps.configStore) { deps.engine.value }
63
+ }
64
+ } catch (e: CancellationException) {
65
+ throw e
66
+ } catch (e: Exception) {
67
+ Logger.e(TAG, "$reason: restoring tracking failed", e)
68
+ }
69
+ }
70
+ // Release the broadcast before its ~10 s limit even if the restore takes longer; the restore keeps
71
+ // running (by then its foreground service keeps the process alive).
72
+ val watchdog = deps.scope.launch {
73
+ delay(FINISH_BUDGET_MS)
74
+ Logger.w(TAG, "$reason: restore still running; releasing the broadcast")
75
+ finish()
76
+ }
77
+ // Also runs if the work is cancelled before it starts, unlike a finally block inside it.
78
+ work.invokeOnCompletion {
79
+ watchdog.cancel()
80
+ finish()
81
+ }
82
+ }
83
+
84
+ /** Applies the boot count gate, then [handle]; stores the boot count once it was handled (also on failure). */
85
+ private suspend fun handleBoot(action: String?, deps: ServiceDeps, bootCounts: BootCountStore) {
86
+ val bootCount = deps.clock.bootCount()
87
+ ignoredBootReason(bootCount, bootCounts.lastHandled(), bootCounts.lastServiceStart())?.let {
88
+ Logger.i(TAG, "$action ignored: $it")
89
+ return
90
+ }
91
+ var failure: Exception? = null
92
+ try {
93
+ handle(REASON_BOOT, deps.configStore) { deps.engine.value }
94
+ } catch (e: CancellationException) {
95
+ throw e
96
+ } catch (e: Exception) {
97
+ failure = e
98
+ }
99
+ if (bootCount >= 0) bootCounts.setLastHandled(bootCount)
100
+ failure?.let { throw it }
101
+ }
102
+
103
+ /** What [handle] did. */
104
+ internal enum class Outcome { RESTORED, DISABLED, IGNORED }
105
+
106
+ internal companion object {
107
+ private const val TAG = "LT.Boot"
108
+ const val REASON_BOOT = "boot"
109
+ const val STOP_REASON_REBOOT = "reboot"
110
+ const val REASON_PACKAGE_REPLACED = "package_replaced"
111
+
112
+ /** How long the broadcast is held for the restore (receivers must finish within about 10 s). */
113
+ const val FINISH_BUDGET_MS = 8_000L
114
+
115
+ private val BOOT_ACTIONS = setOf(
116
+ Intent.ACTION_BOOT_COMPLETED,
117
+ "android.intent.action.QUICKBOOT_POWERON",
118
+ "com.htc.intent.action.QUICKBOOT_POWERON",
119
+ )
120
+
121
+ private val bootHandled = AtomicBoolean(false)
122
+
123
+ /** `"boot"`, `"package_replaced"`, or null for any other action. */
124
+ fun reasonFor(action: String?): String? = when (action) {
125
+ in BOOT_ACTIONS -> REASON_BOOT
126
+ Intent.ACTION_MY_PACKAGE_REPLACED -> REASON_PACKAGE_REPLACED
127
+ else -> null
128
+ }
129
+
130
+ /**
131
+ * Why a boot action must be ignored, or null to handle it. [bootCount] is the current `BOOT_COUNT` (-1 if it
132
+ * cannot be read: always handled), [lastHandled] the boot count of the last handled boot broadcast (null if
133
+ * none), [lastServiceStart] the boot count during which the location service was last started (null if none).
134
+ */
135
+ fun ignoredBootReason(bootCount: Int, lastHandled: Int?, lastServiceStart: Int?): String? = when {
136
+ bootCount < 0 -> null
137
+ bootCount == lastHandled -> "boot $bootCount was already handled (no reboot since)"
138
+ bootCount == lastServiceStart -> "tracking was already started during boot $bootCount (no reboot since)"
139
+ else -> null
140
+ }
141
+
142
+ /** `tracking_stop` reason when tracking is not resumed: `"reboot"` or `"package_replaced"`. */
143
+ fun stopReasonFor(reason: String): String = if (reason == REASON_BOOT) STOP_REASON_REBOOT else reason
144
+
145
+ /** The boot decision; [engine] is only resolved when tracking was enabled. */
146
+ suspend fun handle(reason: String, configStore: ConfigStore, engine: () -> TrackingEngine): Outcome {
147
+ if (!configStore.runtime.value.enabled) {
148
+ Logger.d(TAG, "$reason: tracking is not enabled")
149
+ return Outcome.IGNORED
150
+ }
151
+ if (!configStore.config.value.app.startOnBoot) {
152
+ Logger.i(TAG, "$reason: tracking was enabled but app.startOnBoot is false; tracking does not resume")
153
+ try {
154
+ // Clears `enabled` together with the tracking_stop record. It deliberately does nothing when a
155
+ // session is already running in this process (e.g. START_STICKY or start() before this broadcast).
156
+ engine().endWithoutRestore(stopReasonFor(reason))
157
+ } catch (e: CancellationException) {
158
+ throw e
159
+ } catch (e: Exception) {
160
+ // Recording failed: tracking does not resume, so the persisted state must still say so.
161
+ configStore.updateRuntime { it.copy(enabled = false) }
162
+ throw e
163
+ }
164
+ return Outcome.DISABLED
165
+ }
166
+ Logger.i(TAG, "$reason: restoring tracking")
167
+ engine().restore(reason)
168
+ return Outcome.RESTORED
169
+ }
170
+
171
+ /** Test hook: forgets that a boot broadcast was handled in this process. */
172
+ @VisibleForTesting
173
+ fun resetBootHandled() = bootHandled.set(false)
174
+ }
175
+ }
@@ -0,0 +1,196 @@
1
+ package com.brickssoft.locationtracking.service
2
+
3
+ import android.Manifest
4
+ import android.annotation.SuppressLint
5
+ import android.content.Context
6
+ import android.content.Intent
7
+ import android.content.pm.PackageManager
8
+ import android.os.Build
9
+ import androidx.core.app.NotificationManagerCompat
10
+ import androidx.core.content.ContextCompat
11
+ import com.brickssoft.locationtracking.config.ConfigStore
12
+ import com.brickssoft.locationtracking.core.Constants
13
+ import com.brickssoft.locationtracking.core.Clock
14
+ import com.brickssoft.locationtracking.core.EventBus
15
+ import com.brickssoft.locationtracking.core.Logger
16
+ import com.brickssoft.locationtracking.core.SystemClockImpl
17
+
18
+ /**
19
+ * Starts and stops [LocationTrackingService]. The start/stop state machine is described in [ServiceCommands].
20
+ *
21
+ * - [start] sends a start command with `startForegroundService` (`startService` below API 26). It returns false
22
+ * (and logs), without sending anything, when:
23
+ * - Android 14+ and no location permission is granted (the service could not enter the foreground);
24
+ * - Android 14+, the service is not in the foreground, no background location permission and no visible activity
25
+ * ([WhileInUseStartCheck]): Android would throw `SecurityException` from `startForeground`, because while-in-use
26
+ * location access does not apply in the background. (A start while the service is already in the foreground
27
+ * keeps the service's while-in-use access, so it is not checked.)
28
+ *
29
+ * It also returns false when Android refuses the start, for example `ForegroundServiceStartNotAllowedException` for a
30
+ * background start on Android 12+, or when the service is not found. When a start is already pending (sent, not yet
31
+ * in the foreground) and no stop was requested after it, it returns true without sending a second start.
32
+ *
33
+ * On false the engine records `tracking_stop` with reason `service_start_failed`: `start()` then rejects with
34
+ * `PERMISSION_DENIED`, and `restore()` ends the session.
35
+ * - [stop] sends a stop command with `startService`, so the stop is handled after every start sent before it (each of
36
+ * which enters the foreground first). If Android refuses the stop command, `stopService` is used only when no start
37
+ * is pending; while a start is pending, the service stops itself right after it has entered the foreground. So
38
+ * `stopService` never races a pending start (that race makes the system crash the app with
39
+ * `ForegroundServiceDidNotStartInTimeException`).
40
+ * - Each accepted start records the current boot count in [BootCountStore] (read by [BootReceiver]'s boot count gate).
41
+ * - [isRunning] is true while the service is in the foreground: from a successful `startForeground` until the
42
+ * service is destroyed or `startForeground` fails.
43
+ * - [refreshNotification] re-posts the notification from the current config while the service runs.
44
+ * - [showResumeNotification] / [cancelResumeNotification] post and remove the resume notification
45
+ * (`notification.resume`), whose tap starts the service with [LocationTrackingService.ACTION_RESUME].
46
+ */
47
+ class DefaultServiceController(
48
+ context: Context,
49
+ private val configStore: ConfigStore,
50
+ @Suppress("unused") private val events: EventBus,
51
+ private val clock: Clock = SystemClockImpl(context),
52
+ ) : ServiceController {
53
+ private val context: Context = context.applicationContext ?: context
54
+ private val whileInUse = WhileInUseStartCheck(this.context, clock)
55
+ private val bootCounts = BootCountStore(this.context)
56
+
57
+ /** Serializes [start] and [stop], so the sequence numbers reach Android in order. */
58
+ private val sendLock = Any()
59
+
60
+ override val isRunning: Boolean get() = LocationTrackingService.isRunning
61
+
62
+ override fun start(): Boolean = synchronized(sendLock) { startLocked() }
63
+
64
+ private fun startLocked(): Boolean {
65
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE && !hasLocationPermission()) {
66
+ Logger.w(TAG, "no location permission: Android 14+ does not allow a location foreground service")
67
+ return false
68
+ }
69
+ if (ServiceCommands.isStartPendingAndLatest) {
70
+ Logger.d(TAG, "a start is already pending; not sending another")
71
+ return true
72
+ }
73
+ if (!isRunning) {
74
+ whileInUse.refusalReason()?.let { reason ->
75
+ Logger.w(TAG, "$reason: the location foreground service would be refused; not starting it")
76
+ return false
77
+ }
78
+ }
79
+ val seq = ServiceCommands.nextSeq()
80
+ val command = ServiceCommands.stamp(serviceIntent(), seq, foregroundRequired = true)
81
+ ServiceCommands.putNotification(command, configStore.config.value.notification)
82
+ return try {
83
+ val component = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
84
+ context.startForegroundService(command)
85
+ } else {
86
+ context.startService(command)
87
+ }
88
+ if (component == null) {
89
+ // Never delivered, so it must not count as pending.
90
+ Logger.e(TAG, "the foreground service was not found (is it declared in the manifest?)")
91
+ return false
92
+ }
93
+ ServiceCommands.startSent(seq)
94
+ // Android's deadline for startForeground() runs from here (the e2e scenario P-L13 reads this line).
95
+ Logger.d(TAG, "foreground service start sent (seq $seq)")
96
+ markServiceStarted()
97
+ true
98
+ } catch (e: Exception) {
99
+ if (isForegroundStartNotAllowed(e)) {
100
+ Logger.w(TAG, "the OS refused to start the foreground service from the background", e)
101
+ } else {
102
+ Logger.e(TAG, "could not start the foreground service", e)
103
+ }
104
+ false
105
+ }
106
+ }
107
+
108
+ override fun stop() {
109
+ synchronized(sendLock) { stopLocked() }
110
+ }
111
+
112
+ private fun stopLocked() {
113
+ val seq = ServiceCommands.nextSeq()
114
+ ServiceCommands.stopRequested(seq)
115
+ val stopCommand =
116
+ ServiceCommands.stamp(serviceIntent().setAction(LocationTrackingService.ACTION_STOP), seq, foregroundRequired = false)
117
+ try {
118
+ context.startService(stopCommand)
119
+ return
120
+ } catch (e: Exception) {
121
+ // IllegalStateException: an app in the background may not start a service that is not running.
122
+ Logger.d(TAG, "stop command refused (${e.javaClass.simpleName})")
123
+ }
124
+ if (ServiceCommands.isStartPending) {
125
+ Logger.i(TAG, "a start is pending: the service stops itself after it has entered the foreground")
126
+ return
127
+ }
128
+ try {
129
+ context.stopService(serviceIntent())
130
+ } catch (e: Exception) {
131
+ Logger.w(TAG, "could not stop the foreground service", e)
132
+ }
133
+ }
134
+
135
+ override fun refreshNotification() {
136
+ try {
137
+ LocationTrackingService.refresh(context, configStore)
138
+ } catch (e: Exception) {
139
+ Logger.w(TAG, "could not refresh the notification", e)
140
+ }
141
+ }
142
+
143
+ @SuppressLint("MissingPermission") // areNotificationsEnabled() is false without POST_NOTIFICATIONS
144
+ override fun showResumeNotification(deadline: Long?) {
145
+ try {
146
+ val manager = NotificationManagerCompat.from(context)
147
+ if (!manager.areNotificationsEnabled()) {
148
+ Logger.w(TAG, "notifications are not allowed (POST_NOTIFICATIONS); no resume notification")
149
+ return
150
+ }
151
+ val timeout = deadline?.let { (it - clock.now()).coerceAtLeast(1L) }
152
+ val notification = NotificationFactory(context).buildResume(configStore.config.value.notification, timeout)
153
+ manager.notify(Constants.RESUME_NOTIFICATION_ID, notification)
154
+ Logger.i(TAG, "resume notification posted")
155
+ } catch (e: Exception) {
156
+ Logger.w(TAG, "could not post the resume notification", e)
157
+ }
158
+ }
159
+
160
+ override fun cancelResumeNotification() {
161
+ try {
162
+ NotificationManagerCompat.from(context).cancel(Constants.RESUME_NOTIFICATION_ID)
163
+ } catch (e: Exception) {
164
+ Logger.w(TAG, "could not cancel the resume notification", e)
165
+ }
166
+ }
167
+
168
+ /** Lets [BootReceiver] ignore a later boot broadcast of this boot (a session already ran in this boot). */
169
+ private fun markServiceStarted() {
170
+ try {
171
+ val bootCount = clock.bootCount()
172
+ if (bootCount >= 0) bootCounts.setServiceStarted(bootCount)
173
+ } catch (e: Exception) {
174
+ Logger.w(TAG, "could not record the boot count of this start", e)
175
+ }
176
+ }
177
+
178
+ private fun serviceIntent(): Intent = Intent(context, LocationTrackingService::class.java)
179
+
180
+ private fun hasLocationPermission(): Boolean =
181
+ listOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION).any {
182
+ ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED
183
+ }
184
+
185
+ internal companion object {
186
+ private const val TAG = "LT.ServiceController"
187
+ private const val FGS_NOT_ALLOWED = "android.app.ForegroundServiceStartNotAllowedException"
188
+
189
+ /**
190
+ * True for `ForegroundServiceStartNotAllowedException` (API 31+). Compared by name: the class does not
191
+ * exist below API 31, so it must not appear in a catch clause or type check.
192
+ */
193
+ fun isForegroundStartNotAllowed(e: Throwable): Boolean =
194
+ Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && e.javaClass.name == FGS_NOT_ALLOWED
195
+ }
196
+ }