@bricks-soft/capacitor-location-tracking 8.0.1 → 8.2.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 (124) hide show
  1. package/BricksSoftCapacitorLocationTracking.podspec +18 -0
  2. package/Package.swift +13 -0
  3. package/README.md +476 -147
  4. package/android/build.gradle +3 -1
  5. package/android/src/main/AndroidManifest.xml +2 -0
  6. package/android/src/main/java/com/brickssoft/locationtracking/bridge/OptionParsers.kt +4 -1
  7. package/android/src/main/java/com/brickssoft/locationtracking/bridge/PluginHandlers.kt +14 -5
  8. package/android/src/main/java/com/brickssoft/locationtracking/config/Config.kt +10 -0
  9. package/android/src/main/java/com/brickssoft/locationtracking/config/ConfigJson.kt +70 -15
  10. package/android/src/main/java/com/brickssoft/locationtracking/config/ConfigStore.kt +6 -0
  11. package/android/src/main/java/com/brickssoft/locationtracking/config/ConfigValidator.kt +8 -3
  12. package/android/src/main/java/com/brickssoft/locationtracking/config/RuntimeStateJson.kt +10 -0
  13. package/android/src/main/java/com/brickssoft/locationtracking/config/SecretStore.kt +188 -0
  14. package/android/src/main/java/com/brickssoft/locationtracking/config/SharedPrefsConfigStore.kt +188 -17
  15. package/android/src/main/java/com/brickssoft/locationtracking/config/State.kt +11 -0
  16. package/android/src/main/java/com/brickssoft/locationtracking/core/Components.kt +21 -5
  17. package/android/src/main/java/com/brickssoft/locationtracking/core/ForceStopProbe.kt +20 -9
  18. package/android/src/main/java/com/brickssoft/locationtracking/core/TrackingEvent.kt +24 -2
  19. package/android/src/main/java/com/brickssoft/locationtracking/data/TrackingDatabase.kt +43 -1
  20. package/android/src/main/java/com/brickssoft/locationtracking/device/DefaultDeviceInfoProvider.kt +17 -0
  21. package/android/src/main/java/com/brickssoft/locationtracking/device/DeviceDetails.kt +167 -0
  22. package/android/src/main/java/com/brickssoft/locationtracking/engine/DefaultTrackingEngine.kt +191 -34
  23. package/android/src/main/java/com/brickssoft/locationtracking/engine/TrackingEngine.kt +6 -0
  24. package/android/src/main/java/com/brickssoft/locationtracking/engine/TrackingTimers.kt +3 -0
  25. package/android/src/main/java/com/brickssoft/locationtracking/heartbeat/DefaultHeartbeatScheduler.kt +52 -8
  26. package/android/src/main/java/com/brickssoft/locationtracking/heartbeat/HeartbeatAlarmReceiver.kt +22 -0
  27. package/android/src/main/java/com/brickssoft/locationtracking/http/AuthorizationManager.kt +78 -27
  28. package/android/src/main/java/com/brickssoft/locationtracking/http/ConfigChecks.kt +140 -0
  29. package/android/src/main/java/com/brickssoft/locationtracking/http/HttpSupport.kt +50 -12
  30. package/android/src/main/java/com/brickssoft/locationtracking/http/HttpSyncer.kt +17 -0
  31. package/android/src/main/java/com/brickssoft/locationtracking/http/OkHttpSyncer.kt +507 -92
  32. package/android/src/main/java/com/brickssoft/locationtracking/http/SyncPolicy.kt +6 -6
  33. package/android/src/main/java/com/brickssoft/locationtracking/http/TemplateRenderer.kt +23 -1
  34. package/android/src/main/java/com/brickssoft/locationtracking/http/UploadJobs.kt +217 -0
  35. package/android/src/main/java/com/brickssoft/locationtracking/http/UploadWait.kt +184 -0
  36. package/android/src/main/java/com/brickssoft/locationtracking/logging/FileLogger.kt +66 -7
  37. package/android/src/main/java/com/brickssoft/locationtracking/logging/LogRedaction.kt +31 -0
  38. package/android/src/main/java/com/brickssoft/locationtracking/model/DeviceModels.kt +44 -1
  39. package/android/src/main/java/com/brickssoft/locationtracking/model/ModelJson.kt +79 -8
  40. package/android/src/main/java/com/brickssoft/locationtracking/model/Record.kt +9 -0
  41. package/android/src/main/java/com/brickssoft/locationtracking/model/TrackedLocation.kt +12 -2
  42. package/android/src/main/java/com/brickssoft/locationtracking/position/DefaultPositionService.kt +10 -2
  43. package/android/src/main/java/com/brickssoft/locationtracking/position/PositionWatch.kt +24 -1
  44. package/android/src/main/java/com/brickssoft/locationtracking/processing/DefaultRecordFactory.kt +31 -10
  45. package/android/src/main/java/com/brickssoft/locationtracking/provider/Backends.kt +48 -3
  46. package/android/src/main/java/com/brickssoft/locationtracking/provider/DefaultProviderFactory.kt +60 -0
  47. package/android/src/main/java/com/brickssoft/locationtracking/provider/LocationSubscriptions.kt +180 -0
  48. package/android/src/main/java/com/brickssoft/locationtracking/provider/android/AndroidLocationBackend.kt +200 -126
  49. package/android/src/main/java/com/brickssoft/locationtracking/provider/android/LocationProviders.kt +20 -0
  50. package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsLocationBackend.kt +39 -51
  51. package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsProviderBundle.kt +8 -0
  52. package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsLocationBackend.kt +47 -53
  53. package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsProviderBundle.kt +34 -2
  54. package/android/src/main/java/com/brickssoft/locationtracking/record/RecordSink.kt +19 -1
  55. package/android/src/main/java/com/brickssoft/locationtracking/service/BootCountStore.kt +7 -0
  56. package/android/src/main/res/xml/lt_file_paths.xml +1 -1
  57. package/dist/esm/definitions.d.ts +117 -8
  58. package/dist/esm/definitions.js.map +1 -1
  59. package/dist/esm/events.d.ts +8 -2
  60. package/dist/esm/events.js +7 -1
  61. package/dist/esm/events.js.map +1 -1
  62. package/dist/esm/index.d.ts +1 -1
  63. package/dist/esm/index.js +1 -1
  64. package/dist/esm/index.js.map +1 -1
  65. package/dist/esm/web/config.js +10 -0
  66. package/dist/esm/web/config.js.map +1 -1
  67. package/dist/esm/web/device.d.ts +1 -1
  68. package/dist/esm/web/device.js +8 -1
  69. package/dist/esm/web/device.js.map +1 -1
  70. package/dist/esm/web.d.ts +0 -8
  71. package/dist/esm/web.js +12 -3
  72. package/dist/esm/web.js.map +1 -1
  73. package/dist/plugin.cjs.js +39 -5
  74. package/dist/plugin.cjs.js.map +1 -1
  75. package/dist/plugin.js +39 -5
  76. package/dist/plugin.js.map +1 -1
  77. package/ios/Sources/LocationTrackingPlugin/Api/BackgroundUpload.swift +24 -0
  78. package/ios/Sources/LocationTrackingPlugin/Api/LocationTrackingNative.swift +183 -0
  79. package/ios/Sources/LocationTrackingPlugin/Config/ConfigDefaults.swift +96 -0
  80. package/ios/Sources/LocationTrackingPlugin/Config/ConfigSchema.swift +74 -0
  81. package/ios/Sources/LocationTrackingPlugin/Config/ConfigValidator.swift +40 -0
  82. package/ios/Sources/LocationTrackingPlugin/Config/SecretStore.swift +35 -0
  83. package/ios/Sources/LocationTrackingPlugin/Config/TrackingConfig.swift +104 -0
  84. package/ios/Sources/LocationTrackingPlugin/Core/JSON.swift +50 -0
  85. package/ios/Sources/LocationTrackingPlugin/Core/Options.swift +26 -0
  86. package/ios/Sources/LocationTrackingPlugin/Core/PluginInfo.swift +6 -0
  87. package/ios/Sources/LocationTrackingPlugin/Core/TrackingClock.swift +11 -0
  88. package/ios/Sources/LocationTrackingPlugin/Core/TrackingCore.swift +298 -0
  89. package/ios/Sources/LocationTrackingPlugin/Data/TrackingStore.swift +155 -0
  90. package/ios/Sources/LocationTrackingPlugin/Device/DeviceReadings.swift +43 -0
  91. package/ios/Sources/LocationTrackingPlugin/Engine/CheckInNotifier.swift +92 -0
  92. package/ios/Sources/LocationTrackingPlugin/Engine/MotionStateMachine.swift +122 -0
  93. package/ios/Sources/LocationTrackingPlugin/Engine/SessionDeadline.swift +29 -0
  94. package/ios/Sources/LocationTrackingPlugin/Engine/TrackingEngine.swift +344 -0
  95. package/ios/Sources/LocationTrackingPlugin/Engine/TrackingTimers.swift +35 -0
  96. package/ios/Sources/LocationTrackingPlugin/Geofence/DwellTracker.swift +35 -0
  97. package/ios/Sources/LocationTrackingPlugin/Geofence/GeofenceEventAdapter.swift +88 -0
  98. package/ios/Sources/LocationTrackingPlugin/Geofence/GeofenceManager.swift +453 -0
  99. package/ios/Sources/LocationTrackingPlugin/Geofence/GeofenceMonitor.swift +207 -0
  100. package/ios/Sources/LocationTrackingPlugin/Geofence/GeofenceSpec.swift +103 -0
  101. package/ios/Sources/LocationTrackingPlugin/Geofence/GeofenceStore.swift +52 -0
  102. package/ios/Sources/LocationTrackingPlugin/Geofence/PolygonMath.swift +176 -0
  103. package/ios/Sources/LocationTrackingPlugin/Heartbeat/ExecutionAssertion.swift +33 -0
  104. package/ios/Sources/LocationTrackingPlugin/Heartbeat/HeartbeatWindow.swift +29 -0
  105. package/ios/Sources/LocationTrackingPlugin/Http/BodyBuilder.swift +52 -0
  106. package/ios/Sources/LocationTrackingPlugin/Http/HTTPTransport.swift +87 -0
  107. package/ios/Sources/LocationTrackingPlugin/Http/StrictJSON.swift +98 -0
  108. package/ios/Sources/LocationTrackingPlugin/Http/Syncer.swift +313 -0
  109. package/ios/Sources/LocationTrackingPlugin/Http/UploadBackoff.swift +26 -0
  110. package/ios/Sources/LocationTrackingPlugin/Location/RouteSource.swift +124 -0
  111. package/ios/Sources/LocationTrackingPlugin/LocationTrackingPlugin.swift +155 -0
  112. package/ios/Sources/LocationTrackingPlugin/Logging/LogExport.swift +73 -0
  113. package/ios/Sources/LocationTrackingPlugin/Logging/TrackingLog.swift +47 -0
  114. package/ios/Sources/LocationTrackingPlugin/Permission/LocationServices.swift +32 -0
  115. package/ios/Sources/LocationTrackingPlugin/Permission/Permissions.swift +102 -0
  116. package/ios/Sources/LocationTrackingPlugin/Position/PositionSource.swift +79 -0
  117. package/ios/Sources/LocationTrackingPlugin/Position/Positions.swift +33 -0
  118. package/ios/Sources/LocationTrackingPlugin/Position/SampleCollector.swift +31 -0
  119. package/ios/Sources/LocationTrackingPlugin/PrivacyInfo.xcprivacy +10 -0
  120. package/ios/Sources/LocationTrackingPlugin/Processing/LocationProcessor.swift +83 -0
  121. package/ios/Sources/LocationTrackingPlugin/Processing/TrackedLocation.swift +64 -0
  122. package/ios/Sources/LocationTrackingPlugin/Record/ExternalRecord.swift +54 -0
  123. package/ios/Sources/LocationTrackingPlugin/Record/RecordFactory.swift +27 -0
  124. package/package.json +10 -4
@@ -0,0 +1,167 @@
1
+ package com.brickssoft.locationtracking.device
2
+
3
+ import android.app.ActivityManager
4
+ import android.app.usage.UsageStatsManager
5
+ import android.content.Context
6
+ import android.content.Intent
7
+ import android.content.IntentFilter
8
+ import android.os.BatteryManager
9
+ import android.os.Build
10
+ import android.os.PowerManager
11
+ import android.os.StatFs
12
+ import android.provider.Settings
13
+ import com.brickssoft.locationtracking.core.Logger
14
+ import com.brickssoft.locationtracking.model.BatteryDetails
15
+ import com.brickssoft.locationtracking.model.DeviceSnapshot
16
+
17
+ /**
18
+ * Device readings carried by records (the device-information parts d1 to d7):
19
+ * - [deviceId]: `Settings.Secure.ANDROID_ID`, on every record (`device.device_id`); read once per process.
20
+ * - [forHeartbeat]: `background_restricted` and `standby_bucket` (Android 9+), `disk_free_bytes`, `thermal_status`
21
+ * (Android 10+). They explain gaps: a "Restricted" battery setting, a low standby bucket and a full disk each stop or
22
+ * delay records, and heartbeats are the records sent while nothing else is recorded.
23
+ * - [batteryDetails]: battery `temperature`, `health` and `plugged`, for heartbeat records.
24
+ * - [forTrackingStart]: phone `manufacturer`, `model`, `os_version`, `sdk_int`, `low_ram_device` and
25
+ * `ram_total_bytes`. They do not change during a session.
26
+ *
27
+ * Every reading is null when the Android version does not have it or reading it fails (logged); none needs a
28
+ * permission. Never throws.
29
+ */
30
+ interface DeviceDetails {
31
+ fun forHeartbeat(): DeviceSnapshot
32
+
33
+ fun forTrackingStart(): DeviceSnapshot
34
+
35
+ fun batteryDetails(): BatteryDetails?
36
+
37
+ fun deviceId(): String?
38
+
39
+ companion object {
40
+ /** No readings (tests, and callers without a Context). */
41
+ val NONE: DeviceDetails = object : DeviceDetails {
42
+ override fun forHeartbeat(): DeviceSnapshot = DeviceSnapshot()
43
+
44
+ override fun forTrackingStart(): DeviceSnapshot = DeviceSnapshot()
45
+
46
+ override fun batteryDetails(): BatteryDetails? = null
47
+
48
+ override fun deviceId(): String? = null
49
+ }
50
+ }
51
+ }
52
+
53
+ /** [DeviceDetails] from the platform services. */
54
+ class DefaultDeviceDetails(context: Context, private val sdkInt: Int = Build.VERSION.SDK_INT) : DeviceDetails {
55
+ private val appContext: Context = context.applicationContext ?: context
56
+
57
+ // One instance per process (Components); ANDROID_ID only changes on a factory reset or a new signing key.
58
+ private val deviceId: String? by lazy {
59
+ read("ANDROID_ID") { Settings.Secure.getString(appContext.contentResolver, Settings.Secure.ANDROID_ID) }
60
+ }
61
+
62
+ override fun forHeartbeat(): DeviceSnapshot = DeviceSnapshot(
63
+ backgroundRestricted = read("isBackgroundRestricted") {
64
+ if (sdkInt >= Build.VERSION_CODES.P) activityManager()?.isBackgroundRestricted else null
65
+ },
66
+ standbyBucket = read("getAppStandbyBucket") {
67
+ if (sdkInt >= Build.VERSION_CODES.P) {
68
+ appContext.getSystemService(UsageStatsManager::class.java)?.appStandbyBucket?.let(::standbyBucketName)
69
+ } else {
70
+ null
71
+ }
72
+ },
73
+ diskFreeBytes = read("StatFs") { StatFs(appContext.filesDir.path).availableBytes },
74
+ thermalStatus = read("getCurrentThermalStatus") {
75
+ if (sdkInt >= Build.VERSION_CODES.Q) {
76
+ appContext.getSystemService(PowerManager::class.java)?.currentThermalStatus?.let(::thermalStatusName)
77
+ } else {
78
+ null
79
+ }
80
+ },
81
+ )
82
+
83
+ override fun forTrackingStart(): DeviceSnapshot {
84
+ val memory = read("getMemoryInfo") {
85
+ activityManager()?.let { manager -> ActivityManager.MemoryInfo().also { manager.getMemoryInfo(it) } }
86
+ }
87
+ return DeviceSnapshot(
88
+ manufacturer = Build.MANUFACTURER.orEmpty(),
89
+ model = Build.MODEL.orEmpty(),
90
+ osVersion = Build.VERSION.RELEASE.orEmpty(),
91
+ sdkInt = sdkInt,
92
+ lowRamDevice = read("isLowRamDevice") { activityManager()?.isLowRamDevice },
93
+ ramTotalBytes = memory?.totalMem?.takeIf { it > 0 },
94
+ )
95
+ }
96
+
97
+ override fun deviceId(): String? = deviceId
98
+
99
+ override fun batteryDetails(): BatteryDetails? = read("battery details") {
100
+ val intent: Intent = appContext.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
101
+ ?: return@read null
102
+ val temperature = intent.getIntExtra(BatteryManager.EXTRA_TEMPERATURE, Int.MIN_VALUE)
103
+ BatteryDetails(
104
+ temperature = if (temperature == Int.MIN_VALUE) null else temperature / 10f,
105
+ health = healthName(intent.getIntExtra(BatteryManager.EXTRA_HEALTH, BatteryManager.BATTERY_HEALTH_UNKNOWN)),
106
+ plugged = pluggedName(intent.getIntExtra(BatteryManager.EXTRA_PLUGGED, 0)),
107
+ )
108
+ }
109
+
110
+ private fun activityManager(): ActivityManager? = appContext.getSystemService(ActivityManager::class.java)
111
+
112
+ private inline fun <T> read(what: String, block: () -> T?): T? = try {
113
+ block()
114
+ } catch (e: Exception) {
115
+ Logger.w(TAG, "$what unavailable", e)
116
+ null
117
+ }
118
+
119
+ internal companion object {
120
+ private const val TAG = "LT.DeviceDetails"
121
+
122
+ /** `UsageStatsManager` bucket values (EXEMPTED = 5 is not public API). */
123
+ fun standbyBucketName(bucket: Int): String = when {
124
+ bucket <= STANDBY_BUCKET_EXEMPTED -> "exempted"
125
+ bucket <= UsageStatsManager.STANDBY_BUCKET_ACTIVE -> "active"
126
+ bucket <= UsageStatsManager.STANDBY_BUCKET_WORKING_SET -> "working_set"
127
+ bucket <= UsageStatsManager.STANDBY_BUCKET_FREQUENT -> "frequent"
128
+ bucket <= UsageStatsManager.STANDBY_BUCKET_RARE -> "rare"
129
+ else -> "restricted"
130
+ }
131
+
132
+ private const val STANDBY_BUCKET_EXEMPTED = 5
133
+
134
+ fun thermalStatusName(status: Int): String = when (status) {
135
+ PowerManager.THERMAL_STATUS_NONE -> "none"
136
+ PowerManager.THERMAL_STATUS_LIGHT -> "light"
137
+ PowerManager.THERMAL_STATUS_MODERATE -> "moderate"
138
+ PowerManager.THERMAL_STATUS_SEVERE -> "severe"
139
+ PowerManager.THERMAL_STATUS_CRITICAL -> "critical"
140
+ PowerManager.THERMAL_STATUS_EMERGENCY -> "emergency"
141
+ PowerManager.THERMAL_STATUS_SHUTDOWN -> "shutdown"
142
+ else -> "unknown"
143
+ }
144
+
145
+ fun healthName(health: Int): String = when (health) {
146
+ BatteryManager.BATTERY_HEALTH_GOOD -> "good"
147
+ BatteryManager.BATTERY_HEALTH_OVERHEAT -> "overheat"
148
+ BatteryManager.BATTERY_HEALTH_DEAD -> "dead"
149
+ BatteryManager.BATTERY_HEALTH_OVER_VOLTAGE -> "over_voltage"
150
+ BatteryManager.BATTERY_HEALTH_COLD -> "cold"
151
+ BatteryManager.BATTERY_HEALTH_UNSPECIFIED_FAILURE -> "failure"
152
+ else -> "unknown"
153
+ }
154
+
155
+ /** `EXTRA_PLUGGED`; the dock value (8) is public only from Android 14. */
156
+ fun pluggedName(plugged: Int): String = when (plugged) {
157
+ 0 -> "none"
158
+ BatteryManager.BATTERY_PLUGGED_AC -> "ac"
159
+ BatteryManager.BATTERY_PLUGGED_USB -> "usb"
160
+ BatteryManager.BATTERY_PLUGGED_WIRELESS -> "wireless"
161
+ BATTERY_PLUGGED_DOCK -> "dock"
162
+ else -> "other"
163
+ }
164
+
165
+ private const val BATTERY_PLUGGED_DOCK = 8
166
+ }
167
+ }
@@ -1,5 +1,6 @@
1
1
  package com.brickssoft.locationtracking.engine
2
2
 
3
+ import android.os.Build
3
4
  import com.brickssoft.locationtracking.config.Config
4
5
  import com.brickssoft.locationtracking.config.ConfigStore
5
6
  import com.brickssoft.locationtracking.config.GeolocationConfig
@@ -22,6 +23,8 @@ import com.brickssoft.locationtracking.http.HttpSyncer
22
23
  import com.brickssoft.locationtracking.model.ActivitySample
23
24
  import com.brickssoft.locationtracking.model.ActivityType
24
25
  import com.brickssoft.locationtracking.model.GeofenceAction
26
+ import com.brickssoft.locationtracking.model.LocationProviderSetting
27
+ import com.brickssoft.locationtracking.model.ProviderKind
25
28
  import com.brickssoft.locationtracking.model.PermissionLevel
26
29
  import com.brickssoft.locationtracking.model.ProviderState
27
30
  import com.brickssoft.locationtracking.model.Record
@@ -99,6 +102,8 @@ class DefaultTrackingEngine(
99
102
  private val scope: CoroutineScope,
100
103
  /** Background triggers do not restore tracking in a process started after a user force stop. */
101
104
  private val forceStopProbe: ForceStopProbe = ForceStopProbe.NEVER,
105
+ /** `Build.VERSION.SDK_INT`; injectable for tests. */
106
+ private val sdkInt: Int = Build.VERSION.SDK_INT,
102
107
  ) : TrackingEngine {
103
108
  private val mutex = Mutex()
104
109
  private val engineContext: CoroutineContext =
@@ -109,6 +114,9 @@ class DefaultTrackingEngine(
109
114
  private var session: Session? = null
110
115
  private var nextSessionId = 1L
111
116
 
117
+ /** This process handled the user stop it started after (see [handleUserStop]). Guarded by [mutex]. */
118
+ private var userStopHandled = false
119
+
112
120
  private val config: Config get() = configStore.config.value
113
121
  private val runtime: RuntimeState get() = configStore.runtime.value
114
122
 
@@ -119,6 +127,8 @@ class DefaultTrackingEngine(
119
127
  val new = configStore.ready(config, reset)
120
128
  configStore.updateRuntime { it.copy(didReady = true) }
121
129
  onConfigChanged(old, new)
130
+ // Queued records are sent whether tracking is on or off (recommendation 10).
131
+ guarded("syncer.start") { syncer.start() }
122
132
  if (session == null && runtime.enabled) {
123
133
  Logger.i(TAG, "ready: tracking is enabled but not running in this process; restoring")
124
134
  restoreLocked(REASON_RESTORE)
@@ -172,7 +182,21 @@ class DefaultTrackingEngine(
172
182
 
173
183
  override fun state(): State = State(configStore.config.value, configStore.runtime.value, providers.kind)
174
184
 
175
- override suspend fun restore(reason: String) = serialized { restoreLocked(reason) }
185
+ /**
186
+ * A restore the user did not start: boot, app update, heartbeat alarm, or the service restarted by the system
187
+ * (sticky or a start command of an earlier process). After a user stop (recommendation 6) the heartbeat-alarm and
188
+ * service restores (reason `restore`) do not restore; boot and app-update restores are not checked: on Android 11
189
+ * and 12 an update kill is probably recorded like a user stop.
190
+ */
191
+ override suspend fun restore(reason: String) = serialized {
192
+ if (reason == REASON_RESTORE && session == null && runtime.enabled && forceStopProbe.startedAfterForceStop()) {
193
+ handleUserStop("restore after a user stop")
194
+ return@serialized
195
+ }
196
+ restoreLocked(reason, background = true)
197
+ }
198
+
199
+ override fun startedAfterUserStop(): Boolean = runtime.enabled && forceStopProbe.startedAfterForceStop()
176
200
 
177
201
  override suspend fun endWithoutRestore(reason: String) = serialized {
178
202
  if (session != null || !runtime.enabled) return@serialized
@@ -206,7 +230,7 @@ class DefaultTrackingEngine(
206
230
  Logger.i(TAG, "resume notification: resuming ${runtime.trackingMode.wire} tracking")
207
231
  configStore.updateRuntime { it.copy(enabled = true) }
208
232
  restoreLocked(REASON_RESUME_NOTIFICATION)
209
- if (session != null) events.emit(TrackingEvent.EnabledChange(true))
233
+ if (session != null) events.emit(TrackingEvent.EnabledChange(true, REASON_RESUME_NOTIFICATION))
210
234
  }
211
235
  }
212
236
  }
@@ -230,11 +254,24 @@ class DefaultTrackingEngine(
230
254
  guarded("serviceController.showResumeNotification") { serviceController.showResumeNotification(deadline) }
231
255
  }
232
256
 
233
- /** When `stopAfterElapsedMinutes` ends the session started at `trackingStartedAt`; null without a limit. */
257
+ /**
258
+ * When `stopAfterElapsedMinutes` ends the session (wall time, epoch ms); null without a limit. Within the boot in
259
+ * which the session started it is computed from elapsed realtime (`trackingStartedElapsed`), so setting the wall
260
+ * clock back or forward does not move it (recommendation 16); after a reboot, from the wall clock
261
+ * (`trackingStartedAt`).
262
+ */
234
263
  private fun elapsedStopAt(): Long? {
235
264
  val minutes = config.geolocation.stopAfterElapsedMinutes
236
- val startedAt = runtime.trackingStartedAt ?: return null
237
- return if (minutes > 0) startedAt + minutes * MINUTE_MS else null
265
+ if (minutes <= 0) return null
266
+ val r = runtime
267
+ val startedAt = r.trackingStartedAt ?: return null
268
+ val startedElapsed = r.trackingStartedElapsed
269
+ val startedBoot = r.trackingStartedBootCount
270
+ if (startedElapsed != null && startedBoot != null && startedBoot >= 0 && startedBoot == clock.bootCount()) {
271
+ val remaining = minutes * MINUTE_MS - (clock.elapsedRealtime() - startedElapsed)
272
+ return clock.now() + remaining
273
+ }
274
+ return startedAt + minutes * MINUTE_MS
238
275
  }
239
276
 
240
277
  override suspend fun onTerminate() = serialized {
@@ -281,17 +318,15 @@ class DefaultTrackingEngine(
281
318
  Logger.w(TAG, "startGeofences without background location permission: geofences may not fire in background")
282
319
  }
283
320
  val wasEnabled = runtime.enabled
284
- val startedAt = clock.now()
285
- configStore.updateRuntime {
286
- it.copy(enabled = true, trackingMode = mode, isMoving = false, trackingStartedAt = startedAt)
287
- }
321
+ upgradeBackendIfBetter(null)
322
+ configStore.updateRuntime { sessionStart(it).copy(enabled = true, trackingMode = mode, isMoving = false) }
288
323
  if (!startService()) {
289
324
  // Foreground location is granted (checked above): Android refused the foreground service, for example a
290
325
  // start from the background, or Android 14+ with only while-in-use location permission. The error code
291
326
  // stays PERMISSION_DENIED for the JS API; the audit record gives the precise reason.
292
327
  configStore.updateRuntime { it.copy(enabled = false) }
293
328
  if (wasEnabled) releaseOrphanedRegistrations()
294
- submit(RecordEvent.TRACKING_STOP, bestKnownLocation(null), reason = REASON_SERVICE_START_FAILED)
329
+ submitStop(null, REASON_SERVICE_START_FAILED)
295
330
  throw TrackingException(
296
331
  ErrorCode.PERMISSION_DENIED,
297
332
  "The location foreground service could not be started (Android refused a start from the background)",
@@ -299,7 +334,7 @@ class DefaultTrackingEngine(
299
334
  }
300
335
  // A process that died while tracking may have left its stationary region with the OS.
301
336
  val s = activate(mode, reason, leftoverRegion = wasEnabled)
302
- events.emit(TrackingEvent.EnabledChange(true))
337
+ events.emit(TrackingEvent.EnabledChange(true, reason))
303
338
  if (mode == TrackingMode.LOCATION) launchInitialFix(s)
304
339
  }
305
340
 
@@ -307,7 +342,7 @@ class DefaultTrackingEngine(
307
342
  * Cold process with `runtime.enabled`: restarts everything in the persisted mode, without permission prompts. If the
308
343
  * session's `stopAfterElapsedMinutes` deadline has passed, it records that stop instead and starts nothing.
309
344
  */
310
- private suspend fun restoreLocked(reason: String) {
345
+ private suspend fun restoreLocked(reason: String, background: Boolean = false) {
311
346
  if (!runtime.enabled) {
312
347
  Logger.i(TAG, "restore($reason): tracking is not enabled; nothing to restore")
313
348
  return
@@ -335,10 +370,21 @@ class DefaultTrackingEngine(
335
370
  stopLocked(REASON_PERMISSION_DENIED)
336
371
  return
337
372
  }
373
+ if (background && sdkInt in Build.VERSION_CODES.R..Build.VERSION_CODES.TIRAMISU &&
374
+ !permissions.hasBackgroundLocation()
375
+ ) {
376
+ // Recommendation 5: on Android 11 to 13 a location foreground service started from the background with
377
+ // only "while in use" permission is created but gets no location, so tracking would look on with no fixes.
378
+ // (Android 14+ refuses such a start in DefaultServiceController; Android 10 still gives it location.)
379
+ Logger.w(TAG, "restore($reason): Android 11-13 without background location permission; stopping")
380
+ stopLocked(REASON_SERVICE_START_FAILED)
381
+ offerResume()
382
+ return
383
+ }
338
384
  val mode = runtime.trackingMode
339
- val startedAt = runtime.trackingStartedAt ?: clock.now()
340
- configStore.updateRuntime {
341
- it.copy(enabled = true, trackingMode = mode, isMoving = false, trackingStartedAt = startedAt)
385
+ configStore.updateRuntime { current ->
386
+ val started = if (current.trackingStartedAt == null) sessionStart(current) else current
387
+ started.copy(enabled = true, trackingMode = mode, isMoving = false)
342
388
  }
343
389
  // The system may have restarted the service itself (START_STICKY), which then calls restore(): starting it again
344
390
  // from the background could be refused on Android 12+ although it is already in the foreground.
@@ -389,8 +435,8 @@ class DefaultTrackingEngine(
389
435
  recordMotionChange(s, isMoving = false, bestKnownLocation(s))
390
436
  }
391
437
  s.mode = mode
392
- val startedAt = clock.now()
393
- configStore.updateRuntime { it.copy(trackingMode = mode, isMoving = false, trackingStartedAt = startedAt) }
438
+ // A new stop window starts (owner decision on recommendation 16b: keep this behavior).
439
+ configStore.updateRuntime { sessionStart(it).copy(trackingMode = mode, isMoving = false) }
394
440
  guarded("geofences.onTrackingStarted") { geofences.onTrackingStarted(mode) }
395
441
  configureMode(s)
396
442
  submit(RecordEvent.TRACKING_START, bestKnownLocation(s), reason = reason)
@@ -437,7 +483,9 @@ class DefaultTrackingEngine(
437
483
  } else {
438
484
  stopActivityBackend()
439
485
  }
440
- submit(RecordEvent.TRACKING_STOP, bestKnownLocation(s), reason = reason)
486
+ submitStop(s, reason)
487
+ // What stays queued is uploaded by the upload job if this process dies first.
488
+ guarded("syncer.onTrackingStopped") { syncer.onTrackingStopped() }
441
489
  // After the record, so a slow OS call cannot delay it. Without a session (cold process): GMS and HMS keep
442
490
  // geofences across a process death, so the previous process may have left the region.
443
491
  if (s != null) removeStationaryRegion(s) else removeStationaryRegionFrom(null)
@@ -445,7 +493,7 @@ class DefaultTrackingEngine(
445
493
  guarded("heartbeat.stop") { heartbeat.stop() }
446
494
  guarded("geofences.onTrackingStopped") { geofences.onTrackingStopped() }
447
495
  configStore.updateRuntime { it.copy(enabled = false, isMoving = false) }
448
- events.emit(TrackingEvent.EnabledChange(false))
496
+ events.emit(TrackingEvent.EnabledChange(false, reason))
449
497
  Logger.i(TAG, "tracking stopped: reason=$reason")
450
498
  }
451
499
 
@@ -458,15 +506,33 @@ class DefaultTrackingEngine(
458
506
  */
459
507
  private suspend fun restoreFromBackgroundTrigger(trigger: String): Boolean {
460
508
  if (forceStopProbe.startedAfterForceStop()) {
461
- Logger.i(TAG, "$trigger after a force stop: tracking stays off until the app is opened")
462
- releaseOrphanedRegistrations()
509
+ handleUserStop("$trigger after a user stop")
463
510
  return false
464
511
  }
465
512
  Logger.i(TAG, "$trigger in a process where tracking is enabled but not running; restoring")
466
- restoreLocked(REASON_RESTORE)
513
+ restoreLocked(REASON_RESTORE, background = true)
467
514
  return session != null
468
515
  }
469
516
 
517
+ /**
518
+ * A background trigger in a process that started after the user stopped the app (force stop, or the Task
519
+ * Manager's Stop button on Android 13+, which leaves the alarms in place). Tracking is not restored: the leftover OS
520
+ * registrations and the heartbeat alarms are released, the service (if the system restarted it) is stopped, and
521
+ * `enabled` stays set, so opening the app (`ready()`) restores tracking. A `tracking_stop` record with reason
522
+ * `user_stopped` and `stopped_at` (the time of the user stop) is written once per user stop (recommendation 6).
523
+ */
524
+ private suspend fun handleUserStop(trigger: String) {
525
+ guarded("serviceController.stop") { serviceController.stop() }
526
+ if (userStopHandled) return
527
+ userStopHandled = true
528
+ Logger.i(TAG, "$trigger: tracking stays off until the app is opened")
529
+ releaseOrphanedRegistrations()
530
+ val stoppedAt = forceStopProbe.forceStopTime()
531
+ if (stoppedAt != null && runtime.userStopRecordedFor == stoppedAt) return
532
+ submitStop(null, REASON_USER_STOPPED, stoppedAt)
533
+ configStore.updateRuntime { it.copy(userStopRecordedFor = stoppedAt ?: clock.now()) }
534
+ }
535
+
470
536
  private suspend fun releaseOrphanedRegistrations() {
471
537
  stopActivityBackend()
472
538
  removeStationaryRegionFrom(null)
@@ -487,7 +553,9 @@ class DefaultTrackingEngine(
487
553
  if (old == new) return
488
554
  val s = session
489
555
  val providerChanged = old.locationProvider != new.locationProvider
490
- if (providerChanged) switchProvider(s, old, new)
556
+ if (providerChanged) {
557
+ switchProvider(s, "locationProvider ${old.locationProvider.wire} -> ${new.locationProvider.wire}")
558
+ }
491
559
  if (old.notification.resume.enabled && !new.notification.resume.enabled) {
492
560
  guarded("serviceController.cancelResumeNotification") { serviceController.cancelResumeNotification() }
493
561
  }
@@ -505,6 +573,31 @@ class DefaultTrackingEngine(
505
573
  }
506
574
  }
507
575
 
576
+ /**
577
+ * `locationProvider: 'auto'` (recommendation 15): when a better backend than the current one is available now (GMS
578
+ * over HMS over the platform; for example Play services finished updating), switch to it. Checked when tracking
579
+ * starts, on provider changes and on heartbeats.
580
+ */
581
+ private suspend fun upgradeBackendIfBetter(s: Session?) {
582
+ if (config.locationProvider != LocationProviderSetting.AUTO) return
583
+ val current = providers.kind
584
+ if (current == ProviderKind.GMS) return
585
+ val best = try {
586
+ providers.bestAvailable()
587
+ } catch (e: Exception) {
588
+ Logger.w(TAG, "cannot evaluate the location backends", e)
589
+ return
590
+ }
591
+ if (rank(best) <= rank(current)) return
592
+ switchProvider(s, "a better location backend is available (${current.wire} -> ${best.wire})")
593
+ }
594
+
595
+ private fun rank(kind: ProviderKind): Int = when (kind) {
596
+ ProviderKind.GMS -> 2
597
+ ProviderKind.HMS -> 1
598
+ ProviderKind.ANDROID -> 0
599
+ }
600
+
508
601
  private fun activitySettingsChanged(old: Config, new: Config): Boolean =
509
602
  old.activity.disableMotionActivityUpdates != new.activity.disableMotionActivityUpdates ||
510
603
  old.activity.activityRecognitionInterval != new.activity.activityRecognitionInterval
@@ -514,7 +607,7 @@ class DefaultTrackingEngine(
514
607
  * stationary region and geofences are removed from the current backend first and registered again on the
515
608
  * selected one.
516
609
  */
517
- private suspend fun switchProvider(s: Session?, old: Config, new: Config) {
610
+ private suspend fun switchProvider(s: Session?, why: String) {
518
611
  if (s != null) {
519
612
  removeLocationUpdates(s)
520
613
  stopActivityUpdates(s)
@@ -527,11 +620,7 @@ class DefaultTrackingEngine(
527
620
  Logger.e(TAG, "provider reselection failed", e)
528
621
  false
529
622
  }
530
- Logger.i(
531
- TAG,
532
- "locationProvider ${old.locationProvider.wire} -> ${new.locationProvider.wire}: " +
533
- "backend ${providers.kind.wire}${if (changed) " (changed)" else " (unchanged)"}",
534
- )
623
+ Logger.i(TAG, "$why: backend ${providers.kind.wire}${if (changed) " (changed)" else " (unchanged)"}")
535
624
  if (s != null) {
536
625
  guarded("geofences.onTrackingStarted") { geofences.onTrackingStarted(s.mode) }
537
626
  startActivityUpdates(s)
@@ -814,8 +903,8 @@ class DefaultTrackingEngine(
814
903
  timers.cancel(TimerKind.STOP_AFTER_ELAPSED)
815
904
  return
816
905
  }
817
- val startedAt = runtime.trackingStartedAt ?: clock.now()
818
- timers.schedule(TimerKind.STOP_AFTER_ELAPSED, startedAt + minutes * MINUTE_MS - clock.now()) {
906
+ val deadline = elapsedStopAt() ?: (clock.now() + minutes * MINUTE_MS)
907
+ timers.schedule(TimerKind.STOP_AFTER_ELAPSED, deadline - clock.now()) {
819
908
  if (session === s) {
820
909
  Logger.i(TAG, "stopAfterElapsedMinutes ($minutes) reached")
821
910
  guarded("stop after elapsed") { stopLocked(REASON_STOP_AFTER_ELAPSED) }
@@ -830,6 +919,7 @@ class DefaultTrackingEngine(
830
919
  val batch = locations.toList()
831
920
  scope.launch {
832
921
  mutex.withLock {
922
+ if (session === s) s.registrationFailures = 0
833
923
  for (raw in batch) {
834
924
  if (session !== s) return@withLock
835
925
  guarded("location fix") { handleFix(s, raw) }
@@ -840,6 +930,33 @@ class DefaultTrackingEngine(
840
930
  }
841
931
  }
842
932
 
933
+ private fun dispatchRegistrationError(s: Session, error: TrackingException) {
934
+ scope.launch { mutex.withLock { guarded("location request error") { onRegistrationFailed(s, error) } } }
935
+ }
936
+
937
+ /**
938
+ * The location request of [s] failed (recommendation 3): the backend no longer has it. Forget it, so the next
939
+ * [applyLocationRequest] registers again, emit a `locationerror` event, and request it again after a growing wait
940
+ * (10 s, doubling up to 5 minutes; reset by the next delivered fix).
941
+ */
942
+ private fun onRegistrationFailed(s: Session, error: TrackingException) {
943
+ if (session !== s) return
944
+ val kind = s.locationBackend?.kind ?: providers.kind
945
+ s.locationBackend = null
946
+ s.appliedSpec = null
947
+ s.registrationFailures++
948
+ val doublings = (s.registrationFailures - 1).coerceIn(0, 10)
949
+ val wait = minOf(REQUEST_RETRY_FIRST_MS shl doublings, REQUEST_RETRY_MAX_MS)
950
+ Logger.e(TAG, "location request on ${kind.wire} failed (${error.code}: ${error.message}); next try in ${wait / 1000} s")
951
+ events.emit(TrackingEvent.LocationError(error.code, error.message ?: error.code.name, kind, wait))
952
+ timers.schedule(TimerKind.REQUEST_RETRY, wait) {
953
+ if (session === s) {
954
+ Logger.i(TAG, "requesting location updates again after a failure")
955
+ applyLocationRequest(s)
956
+ }
957
+ }
958
+ }
959
+
843
960
  /**
844
961
  * `delay()` does not advance while the CPU sleeps, so overdue timers are also checked on every fix, activity
845
962
  * sample and heartbeat (whose alarm wakes the device every few minutes).
@@ -850,8 +967,18 @@ class DefaultTrackingEngine(
850
967
 
851
968
  private fun onEvent(s: Session, event: TrackingEvent) {
852
969
  when (event) {
853
- is TrackingEvent.Heartbeat -> scope.launch { mutex.withLock { fireDueTimers(s) } }
854
- is TrackingEvent.ProviderChange -> scope.launch { mutex.withLock { onProviderChange(s, event.state) } }
970
+ is TrackingEvent.Heartbeat -> scope.launch {
971
+ mutex.withLock {
972
+ fireDueTimers(s)
973
+ if (session === s) guarded("backend check") { upgradeBackendIfBetter(s) }
974
+ }
975
+ }
976
+ is TrackingEvent.ProviderChange -> scope.launch {
977
+ mutex.withLock {
978
+ onProviderChange(s, event.state)
979
+ if (session === s) guarded("backend check") { upgradeBackendIfBetter(s) }
980
+ }
981
+ }
855
982
  // The user geofences may now need the OS slot of the stationary region, or leave it free again.
856
983
  is TrackingEvent.GeofencesChange -> scope.launch {
857
984
  mutex.withLock { if (session === s) applyMotionRequests(s, checkLimit = true) }
@@ -1169,14 +1296,32 @@ class DefaultTrackingEngine(
1169
1296
  return listOfNotNull(s?.lastFix, runtime.lastLocation, vetted).maxByOrNull { it.time } ?: fromBackend
1170
1297
  }
1171
1298
 
1299
+ /**
1300
+ * The `tracking_stop` record with [reason] (and [stoppedAt] when the stop happened earlier), and the stop in
1301
+ * `runtime.lastStopReason` / `lastStopAt` (recommendation 9).
1302
+ */
1303
+ private suspend fun submitStop(s: Session?, reason: String, stoppedAt: Long? = null) {
1304
+ submit(RecordEvent.TRACKING_STOP, bestKnownLocation(s), reason = reason, stoppedAt = stoppedAt)
1305
+ configStore.updateRuntime { it.copy(lastStopReason = reason, lastStopAt = stoppedAt ?: clock.now()) }
1306
+ }
1307
+
1308
+ /** [runtime] with a new session start: wall time, elapsed realtime and boot count (`stopAfterElapsedMinutes`). */
1309
+ private fun sessionStart(runtime: RuntimeState): RuntimeState = runtime.copy(
1310
+ trackingStartedAt = clock.now(),
1311
+ trackingStartedElapsed = clock.elapsedRealtime(),
1312
+ trackingStartedBootCount = clock.bootCount(),
1313
+ )
1314
+
1172
1315
  private suspend fun submit(
1173
1316
  event: RecordEvent,
1174
1317
  location: TrackedLocation?,
1175
1318
  reason: String? = null,
1176
1319
  isMoving: Boolean? = null,
1320
+ stoppedAt: Long? = null,
1177
1321
  ): Record? = try {
1178
1322
  var record = recordFactory.create(event, location, reason = reason)
1179
1323
  if (isMoving != null && record.isMoving != isMoving) record = record.copy(isMoving = isMoving)
1324
+ if (stoppedAt != null) record = record.copy(stoppedAt = stoppedAt)
1180
1325
  recordSink.submit(record)
1181
1326
  } catch (e: CancellationException) {
1182
1327
  throw e
@@ -1202,7 +1347,14 @@ class DefaultTrackingEngine(
1202
1347
  /** State of one tracking session in this process. Guarded by [mutex]. */
1203
1348
  private inner class Session(val id: Long, var mode: TrackingMode, settings: MotionSettings) {
1204
1349
  val motion = MotionStateMachine(settings)
1205
- val listener = LocationListener { locations -> dispatchLocations(this@Session, locations) }
1350
+ val listener = object : LocationListener {
1351
+ override fun onLocations(locations: List<TrackedLocation>) = dispatchLocations(this@Session, locations)
1352
+
1353
+ override fun onError(error: TrackingException) = dispatchRegistrationError(this@Session, error)
1354
+ }
1355
+
1356
+ /** Consecutive failed location requests (see [onRegistrationFailed]). */
1357
+ var registrationFailures = 0
1206
1358
  var locationBackend: LocationBackend? = null
1207
1359
  var appliedSpec: LocationRequestSpec? = null
1208
1360
  var activityBackend: ActivityBackend? = null
@@ -1254,6 +1406,11 @@ class DefaultTrackingEngine(
1254
1406
  const val REASON_PERMISSION_DENIED = "permission_denied"
1255
1407
  const val REASON_SERVICE_START_FAILED = "service_start_failed"
1256
1408
  const val REASON_RESUME_NOTIFICATION = "resume_notification"
1409
+ const val REASON_USER_STOPPED = "user_stopped"
1410
+
1411
+ /** Waits before requesting location updates again after a failure: 10 s, doubling up to 5 minutes. */
1412
+ const val REQUEST_RETRY_FIRST_MS = 10_000L
1413
+ const val REQUEST_RETRY_MAX_MS = 5 * 60_000L
1257
1414
 
1258
1415
  /** `device.checkProviderState` reason after the backend changed. */
1259
1416
  const val PROVIDER_CHECK_REASON = "reselect"
@@ -52,6 +52,12 @@ interface TrackingEngine : ActivitySink, StationaryRegionSink {
52
52
  */
53
53
  suspend fun resumeFromNotification() = Unit
54
54
 
55
+ /**
56
+ * True when this process started after the user stopped the app (Task Manager Stop or force stop, see
57
+ * `ForceStopProbe`) while tracking was enabled: a background restore must not run (recommendation 6).
58
+ */
59
+ fun startedAfterUserStop(): Boolean = false
60
+
55
61
  /**
56
62
  * A transition of the engine's stationary region (`Constants.STATIONARY_REGION_ID`), routed by the
57
63
  * GeofenceManager. Round 2, unit 2 implements it (EXIT = the device left the stationary anchor). Default: ignored.
@@ -19,6 +19,9 @@ internal enum class TimerKind {
19
19
 
20
20
  /** `stopAfterElapsedMinutes`, measured from `trackingStartedAt`. */
21
21
  STOP_AFTER_ELAPSED,
22
+
23
+ /** The location request failed (recommendation 3): request it again. */
24
+ REQUEST_RETRY,
22
25
  }
23
26
 
24
27
  /**