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

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 +107 -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 +60 -4
@@ -0,0 +1,713 @@
1
+ package com.brickssoft.locationtracking.heartbeat
2
+
3
+ import android.app.AlarmManager
4
+ import android.content.BroadcastReceiver
5
+ import android.content.Context
6
+ import android.content.Intent
7
+ import android.content.IntentFilter
8
+ import android.content.SharedPreferences
9
+ import android.os.Handler
10
+ import android.os.Looper
11
+ import android.os.PowerManager
12
+ import androidx.core.content.ContextCompat
13
+ import com.brickssoft.locationtracking.config.ConfigStore
14
+ import com.brickssoft.locationtracking.core.Clock
15
+ import com.brickssoft.locationtracking.core.Constants
16
+ import com.brickssoft.locationtracking.core.LogLevel
17
+ import com.brickssoft.locationtracking.core.Logger
18
+ import com.brickssoft.locationtracking.data.LocationStore
19
+ import com.brickssoft.locationtracking.device.DeviceMonitor
20
+ import com.brickssoft.locationtracking.model.HeartbeatMeta
21
+ import com.brickssoft.locationtracking.model.HeartbeatStatus
22
+ import com.brickssoft.locationtracking.model.HeartbeatStrategy
23
+ import com.brickssoft.locationtracking.model.Record
24
+ import com.brickssoft.locationtracking.model.RecordEvent
25
+ import com.brickssoft.locationtracking.model.TrackedLocation
26
+ import com.brickssoft.locationtracking.processing.RecordFactory
27
+ import com.brickssoft.locationtracking.provider.ProviderFactory
28
+ import com.brickssoft.locationtracking.record.RecordSink
29
+ import kotlinx.coroutines.CoroutineScope
30
+ import kotlinx.coroutines.Job
31
+ import kotlinx.coroutines.flow.combine
32
+ import kotlinx.coroutines.flow.distinctUntilChanged
33
+ import kotlinx.coroutines.launch
34
+ import kotlinx.coroutines.sync.Mutex
35
+ import kotlinx.coroutines.sync.withLock
36
+ import kotlinx.coroutines.withTimeoutOrNull
37
+ import kotlin.coroutines.cancellation.CancellationException
38
+
39
+ /**
40
+ * Alarm-based [HeartbeatScheduler] (architecture §3 "Heartbeat algorithm"; round 2: docs/e2e/architecture.md §5).
41
+ *
42
+ * While tracking is enabled (`runtime.enabled`, either mode) and `heartbeat.enabled`, one window is kept armed.
43
+ * If no record was created for `heartbeat.minInterval`, a `heartbeat` record carrying the last known location
44
+ * is submitted through the [RecordSink]. The sink persists it, restarts the window ([onRecordRecorded]), passes it to
45
+ * the record hooks (the native listeners of companion plugins), emits the Heartbeat event and uploads it immediately
46
+ * (priority record; if the upload fails it stays queued).
47
+ *
48
+ * Alarms always use `ELAPSED_REALTIME_WAKEUP`:
49
+ * - [HeartbeatStrategy.EXACT] when `canScheduleExactAlarms()` (battery-exempt): one exact allow-while-idle
50
+ * PendingIntent alarm. A [SecurityException] falls back to the next strategy.
51
+ * - Otherwise an in-process exact listener alarm plus an inexact allow-while-idle PendingIntent backup
52
+ * ([HeartbeatStrategy.LISTENER_WITH_BACKUP]); in deep idle the backup is paced at least 9 min after the
53
+ * previous backup fire ([HeartbeatStrategy.IDLE_PACED]).
54
+ *
55
+ * Alarms fire into [onAlarm] (listener: here; PendingIntent: [HeartbeatAlarmReceiver]), which is serialized, so
56
+ * a listener and a backup firing for the same window produce one heartbeat.
57
+ *
58
+ * **Timestamps.** A heartbeat's `recorded_at` is the time it is created. Its location is the last known fix, unchanged,
59
+ * so its `timestamp` is the time that fix was acquired: a phone that has been stationary for an hour sends heartbeats
60
+ * with new `recorded_at` values and the same hour-old `timestamp`.
61
+ *
62
+ * **Metadata.** Every heartbeat record carries a [HeartbeatMeta] (`record.heartbeat`). The next window is armed before
63
+ * the record is submitted, so the metadata describes exactly what AlarmManager holds: the strategy, the config
64
+ * intervals, `nextAt` (when the next heartbeat is due if no other record is created) and the battery-exemption and
65
+ * deep-idle flags. [status] reports the same values at the same moment.
66
+ *
67
+ * **Cost.**
68
+ * - AlarmManager is called only when [HeartbeatWindow.needsRearm] says so: a changed strategy, a trigger that moved
69
+ * earlier, a due time that moved later by at least [HeartbeatWindow.REARM_THRESHOLD_MS], or any move of an
70
+ * idle-paced backup. A record every 5 s therefore sets the alarms once per 30 s instead of once per record. An alarm
71
+ * left in place fires up to 30 s early; [onAlarm] then finds the window not due, creates no heartbeat and arms the
72
+ * real due time.
73
+ * - One partial wake lock per alarm delivery. It is released when the heartbeat has been submitted (the upload that
74
+ * the submit starts holds no wake lock) and times out after [WAKE_LOCK_TIMEOUT_MS] if something hangs.
75
+ * - The backend's last known location is requested only when `runtime.lastLocation` is null, and not again within
76
+ * [LAST_LOCATION_RETRY_MS] after the backend answered that it has none (a timeout or an error is retried at the
77
+ * next heartbeat).
78
+ * - The provider state check runs once per heartbeat attempt (an alarm that finds the window due), as before. It is the
79
+ * only check that sees permission changes that neither kill the process nor send a broadcast; early alarms skip it.
80
+ *
81
+ * @param alarms AlarmManager access; injectable for tests.
82
+ */
83
+ class DefaultHeartbeatScheduler(
84
+ context: Context,
85
+ private val configStore: ConfigStore,
86
+ private val device: DeviceMonitor,
87
+ private val providers: ProviderFactory,
88
+ private val locationStore: LocationStore,
89
+ private val recordFactory: RecordFactory,
90
+ private val recordSink: Lazy<RecordSink>,
91
+ private val clock: Clock,
92
+ private val scope: CoroutineScope,
93
+ private val alarms: HeartbeatAlarms = SystemHeartbeatAlarms(context),
94
+ ) : HeartbeatScheduler {
95
+ private enum class Lifecycle {
96
+ /** Fresh process: never started. An alarm may still arrive from a previous process. */
97
+ NEW,
98
+ STARTED,
99
+ STOPPED,
100
+ }
101
+
102
+ private val appContext: Context = context.applicationContext ?: context
103
+ private val prefs: SharedPreferences by lazy {
104
+ appContext.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
105
+ }
106
+ private val powerManager: PowerManager? by lazy { appContext.getSystemService(Context.POWER_SERVICE) as? PowerManager }
107
+ private val mainHandler: Handler by lazy { Handler(Looper.getMainLooper()) }
108
+
109
+ /** Guards every field below and every AlarmManager call. */
110
+ private val lock = Any()
111
+ private var lifecycle = Lifecycle.NEW
112
+
113
+ /** The window whose trigger times AlarmManager holds (after a refused exact alarm: the fallback), or null. */
114
+ private var armed: HeartbeatWindow? = null
115
+
116
+ /**
117
+ * The window the heartbeat follows now: the last evaluated window, with the strategy that is armed. It differs
118
+ * from [armed] when a record moved the due time by less than [HeartbeatWindow.REARM_THRESHOLD_MS] and the alarms
119
+ * were left in place. [status] reports it.
120
+ */
121
+ private var current: HeartbeatWindow? = null
122
+
123
+ /** True if the last arm asked for an exact alarm and AlarmManager refused it. The next re-arm asks again. */
124
+ private var exactRefused = false
125
+
126
+ /** Incremented on every arm and cancel; lets [onAlarm] tell whether anything re-armed since the alarm. */
127
+ private var generation = 0L
128
+
129
+ /** True once the alarms were cancelled and nothing was armed since. */
130
+ private var alarmsCleared = false
131
+
132
+ /** Window base while no record exists yet, so an alarm can find the window due. */
133
+ private var noRecordAnchorElapsed: Long? = null
134
+
135
+ /** Last heartbeat attempt: the next one is not due before one min interval later, even if this one failed. */
136
+ private var lastAttemptElapsed: Long? = null
137
+
138
+ /** Elapsed time of the last backend last-location request that returned no location, or null. */
139
+ private var lastLocationMissElapsed: Long? = null
140
+
141
+ /** In-memory copy of the persisted last backup fire (elapsed, boot count); loaded lazily. */
142
+ private var lastBackupFire: Pair<Long, Int>? = null
143
+ private var lastBackupFireLoaded = false
144
+ private var configJob: Job? = null
145
+ private var idleReceiverRegistered = false
146
+
147
+ /** Serializes alarm handling: the listener and the backup alarm may fire for the same window. */
148
+ private val alarmMutex = Mutex()
149
+
150
+ private val listener = AlarmManager.OnAlarmListener {
151
+ // The alarm's own wake lock ends when this returns: hold ours across the hop to the coroutine and through the
152
+ // handling (the handling takes no second wake lock).
153
+ val wakeLock = acquireWakeLock()
154
+ scope.launch { runAlarm(HeartbeatTrigger.LISTENER_ALARM) }.invokeOnCompletion { release(wakeLock) }
155
+ }
156
+
157
+ private val idleReceiver = object : BroadcastReceiver() {
158
+ override fun onReceive(context: Context, intent: Intent) {
159
+ Logger.d(TAG, "device idle mode changed")
160
+ reschedule("idle-change", force = false)
161
+ }
162
+ }
163
+
164
+ override fun start() {
165
+ synchronized(lock) {
166
+ if (lifecycle != Lifecycle.STARTED) {
167
+ lifecycle = Lifecycle.STARTED
168
+ registerIdleReceiverLocked()
169
+ observeConfigLocked()
170
+ }
171
+ rescheduleLocked("start", force = false, fromAlarm = false)
172
+ }
173
+ }
174
+
175
+ override fun stop() {
176
+ synchronized(lock) {
177
+ lifecycle = Lifecycle.STOPPED
178
+ configJob?.cancel()
179
+ configJob = null
180
+ unregisterIdleReceiverLocked()
181
+ cancelAlarmsLocked()
182
+ noRecordAnchorElapsed = null
183
+ lastAttemptElapsed = null
184
+ lastLocationMissElapsed = null
185
+ clearPersistedSchedule()
186
+ }
187
+ Logger.d(TAG, "stopped")
188
+ }
189
+
190
+ override fun onRecordRecorded(record: Record) {
191
+ synchronized(lock) {
192
+ noRecordAnchorElapsed = null
193
+ rescheduleLocked("record:${record.event.wire}", force = false, fromAlarm = false)
194
+ }
195
+ }
196
+
197
+ /** Handles an alarm while holding a partial wake lock (the receiver path; the listener holds its own). */
198
+ override suspend fun onAlarm(trigger: HeartbeatTrigger) {
199
+ val wakeLock = acquireWakeLock()
200
+ try {
201
+ runAlarm(trigger)
202
+ } finally {
203
+ release(wakeLock)
204
+ }
205
+ }
206
+
207
+ override suspend fun status(): HeartbeatStatus {
208
+ val heartbeat = configStore.config.value.heartbeat
209
+ val runtime = configStore.runtime.value
210
+ val (strategy, next) = synchronized(lock) { scheduleSnapshotLocked() }
211
+ val pending = try {
212
+ locationStore.count(setOf(RecordEvent.HEARTBEAT))
213
+ } catch (e: CancellationException) {
214
+ throw e
215
+ } catch (e: Exception) {
216
+ Logger.w(TAG, "cannot count pending heartbeats", e)
217
+ 0
218
+ }
219
+ return HeartbeatStatus(
220
+ enabled = heartbeat.enabled,
221
+ minInterval = heartbeat.minInterval,
222
+ maxInterval = heartbeat.maxInterval,
223
+ lastRecordAt = runtime.lastRecordAt,
224
+ lastHeartbeatAt = runtime.lastHeartbeatAt,
225
+ nextHeartbeatAt = next,
226
+ strategy = strategy,
227
+ canScheduleExactAlarms = device.canScheduleExactAlarms(),
228
+ isIgnoringBatteryOptimizations = device.isIgnoringBatteryOptimizations(),
229
+ isDeviceIdleMode = device.isDeviceIdleMode(),
230
+ isPowerSaveMode = device.isPowerSaveMode(),
231
+ pendingHeartbeats = pending,
232
+ )
233
+ }
234
+
235
+ // ---- alarm handling
236
+
237
+ private suspend fun runAlarm(trigger: HeartbeatTrigger) {
238
+ alarmMutex.withLock { handleAlarm(trigger) }
239
+ }
240
+
241
+ private suspend fun handleAlarm(trigger: HeartbeatTrigger) {
242
+ val nowElapsed = clock.elapsedRealtime()
243
+ // Every backup delivery counts toward the OS allow-while-idle quota, whatever happens next.
244
+ if (trigger == HeartbeatTrigger.BACKUP_ALARM) persistBackupFire(nowElapsed, clock.bootCount())
245
+
246
+ var entryGeneration = 0L
247
+ val window = synchronized(lock) {
248
+ entryGeneration = generation
249
+ if (lifecycle == Lifecycle.STOPPED || !isActive()) {
250
+ Logger.d(TAG, "$trigger ignored: heartbeat or tracking disabled")
251
+ cancelAlarmsLocked()
252
+ clearPersistedSchedule()
253
+ null
254
+ } else {
255
+ computeWindowLocked(device.canScheduleExactAlarms(), anchor = true)
256
+ }
257
+ } ?: return
258
+
259
+ if (!window.shouldFire(nowElapsed)) {
260
+ // Expected after records moved the due time by less than the re-arm threshold.
261
+ Logger.d(TAG, "$trigger: not due for ${(window.dueElapsed - nowElapsed) / 1_000} s; re-arming")
262
+ // The alarm that fired is consumed: re-arm even if the window did not change.
263
+ reschedule("early-$trigger", force = true, fromAlarm = true)
264
+ return
265
+ }
266
+ try {
267
+ createHeartbeat(trigger, window, nowElapsed)
268
+ } finally {
269
+ synchronized(lock) {
270
+ // createHeartbeat arms the next window before it submits, and a record or stop() in the meantime
271
+ // re-armed or cancelled; re-arm here only if nothing did since the alarm (no heartbeat was created).
272
+ if (generation == entryGeneration) rescheduleLocked("after-$trigger", force = true, fromAlarm = true)
273
+ }
274
+ }
275
+ }
276
+
277
+ private suspend fun createHeartbeat(trigger: HeartbeatTrigger, window: HeartbeatWindow, nowElapsed: Long) {
278
+ val lateMs = nowElapsed - window.deadlineElapsed
279
+ if (lateMs > HeartbeatWindow.FIRE_TOLERANCE_MS) {
280
+ // Expected while idle-paced (documented limitation); anything else is worth a warning.
281
+ val level = if (window.strategy == HeartbeatStrategy.IDLE_PACED) LogLevel.INFO else LogLevel.WARN
282
+ Logger.log(level, TAG, "heartbeat is ${lateMs / 1_000} s past maxInterval ($trigger, ${window.strategy.wire})")
283
+ }
284
+ try {
285
+ if (withTimeoutOrNull(PROVIDER_CHECK_TIMEOUT_MS) { device.checkProviderState("heartbeat") } == null) {
286
+ Logger.w(TAG, "provider state check timed out")
287
+ }
288
+ } catch (e: CancellationException) {
289
+ throw e
290
+ } catch (e: Exception) {
291
+ Logger.w(TAG, "provider state check failed", e)
292
+ }
293
+ try {
294
+ val location = heartbeatLocation(trigger)
295
+ // The calls above suspend: a record (e.g. providerchange) may have restarted the window, or stop()
296
+ // may have run. Decide again. Only the due time matters here, so the device flags of the entry are reused.
297
+ var seenLastRecord: Pair<Long?, Long?>? = null
298
+ val stillDue = synchronized(lock) {
299
+ val nowAgain = clock.elapsedRealtime()
300
+ val due = lifecycle != Lifecycle.STOPPED && isActive() &&
301
+ computeWindowLocked(
302
+ canExact = window.strategy == HeartbeatStrategy.EXACT,
303
+ anchor = true,
304
+ isIdle = window.strategy == HeartbeatStrategy.IDLE_PACED,
305
+ ).shouldFire(nowAgain)
306
+ if (due) {
307
+ lastAttemptElapsed = nowAgain
308
+ seenLastRecord = lastRecordMarker()
309
+ }
310
+ due
311
+ }
312
+ if (!stillDue) {
313
+ Logger.d(TAG, "$trigger: superseded by a newer record or stop()")
314
+ return
315
+ }
316
+ val created = recordFactory.create(RecordEvent.HEARTBEAT, location)
317
+ // Another thread may have submitted a record, or stopped tracking, while the record was being built.
318
+ val record = synchronized(lock) {
319
+ val superseded = lifecycle == Lifecycle.STOPPED || !isActive() || lastRecordMarker() != seenLastRecord
320
+ if (superseded) null else created.copy(heartbeat = armAfterLocked(created))
321
+ }
322
+ if (record == null) {
323
+ Logger.d(TAG, "$trigger: superseded by a newer record or stop() while the heartbeat was built")
324
+ return
325
+ }
326
+ recordSink.value.submit(record)
327
+ val meta = record.heartbeat
328
+ Logger.i(
329
+ TAG,
330
+ "heartbeat ${record.uuid} ($trigger, next ${meta?.strategy?.wire} in " +
331
+ "${meta?.nextAt?.let { (it - record.recordedAt) / 1_000 }} s, location=${location != null})",
332
+ )
333
+ } catch (e: CancellationException) {
334
+ throw e
335
+ } catch (e: Exception) {
336
+ Logger.e(TAG, "failed to create heartbeat ($trigger)", e)
337
+ }
338
+ }
339
+
340
+ /**
341
+ * Arms the window that starts at [record] (the heartbeat about to be submitted) and returns the record's
342
+ * metadata. Arming first makes the metadata describe what AlarmManager holds (also after a refused exact alarm);
343
+ * the sink's [onRecordRecorded] then computes the same window and sets no alarm.
344
+ */
345
+ private fun armAfterLocked(record: Record): HeartbeatMeta {
346
+ val isIdle = device.isDeviceIdleMode()
347
+ val next = computeWindowLocked(device.canScheduleExactAlarms(), anchor = true, isIdle = isIdle, lastRecord = record)
348
+ val armedWindow = armLocked(next, "heartbeat", isIdle = isIdle, lastRecord = record)
349
+ val config = configStore.config.value.heartbeat
350
+ val inMs = (armedWindow.expectedFireElapsed - record.elapsedRealtimeMs).coerceAtLeast(0L)
351
+ return HeartbeatMeta(
352
+ strategy = armedWindow.strategy,
353
+ minInterval = config.minInterval,
354
+ maxInterval = config.maxInterval,
355
+ nextAt = record.recordedAt + inMs,
356
+ batteryExempt = device.isIgnoringBatteryOptimizations(),
357
+ deviceIdle = isIdle,
358
+ )
359
+ }
360
+
361
+ /**
362
+ * The heartbeat's location: `runtime.lastLocation` (kept fresh by the engine), else the backend's last known
363
+ * location. After the backend answered that it has no location, it is not asked again within
364
+ * [LAST_LOCATION_RETRY_MS]: the engine's own fixes reach `runtime.lastLocation` directly, so a new request would
365
+ * almost always answer the same. A timeout or an exception is not remembered; the next heartbeat asks again.
366
+ */
367
+ private suspend fun heartbeatLocation(trigger: HeartbeatTrigger): TrackedLocation? {
368
+ configStore.runtime.value.lastLocation?.let { return it }
369
+ val nowElapsed = clock.elapsedRealtime()
370
+ val lastMiss = synchronized(lock) { lastLocationMissElapsed }
371
+ if (lastMiss != null && nowElapsed - lastMiss in 0L until LAST_LOCATION_RETRY_MS) {
372
+ Logger.d(TAG, "$trigger: no known location; the backend had none ${(nowElapsed - lastMiss) / 1_000} s ago")
373
+ return null
374
+ }
375
+ var answered = false
376
+ val found = try {
377
+ withTimeoutOrNull(LAST_LOCATION_TIMEOUT_MS) {
378
+ providers.location().getLastLocation().also { answered = true }
379
+ }
380
+ } catch (e: CancellationException) {
381
+ throw e
382
+ } catch (e: Exception) {
383
+ Logger.w(TAG, "last known location unavailable", e)
384
+ null
385
+ }
386
+ if (!answered) Logger.w(TAG, "$trigger: the last known location request failed or timed out")
387
+ synchronized(lock) { lastLocationMissElapsed = if (found == null && answered) nowElapsed else null }
388
+ return found
389
+ }
390
+
391
+ // ---- scheduling
392
+
393
+ private fun reschedule(reason: String, force: Boolean, fromAlarm: Boolean = false) {
394
+ synchronized(lock) { rescheduleLocked(reason, force, fromAlarm) }
395
+ }
396
+
397
+ /**
398
+ * Arms, re-arms or cancels the alarms for the current state. Without [force], the armed alarms stay when
399
+ * [HeartbeatWindow.needsRearm] is false (only [current] is updated). [fromAlarm] allows arming in a process whose
400
+ * scheduler was never started (the alarm outlived its process; the receiver then restores tracking).
401
+ */
402
+ private fun rescheduleLocked(reason: String, force: Boolean, fromAlarm: Boolean) {
403
+ val mayArm = when (lifecycle) {
404
+ Lifecycle.STARTED -> true
405
+ Lifecycle.NEW -> fromAlarm || armed != null
406
+ Lifecycle.STOPPED -> false
407
+ }
408
+ if (!mayArm) return
409
+ if (!isActive()) {
410
+ if (!alarmsCleared) {
411
+ Logger.d(TAG, "cancelling alarms ($reason): heartbeat or tracking disabled")
412
+ cancelAlarmsLocked()
413
+ clearPersistedSchedule()
414
+ }
415
+ return
416
+ }
417
+ val canExact = device.canScheduleExactAlarms()
418
+ val isIdle = device.isDeviceIdleMode()
419
+ // After a refused exact alarm the fallback stays until the window moves enough to re-arm anyway.
420
+ val evaluated = computeWindowLocked(canExact && !exactRefused, anchor = true, isIdle = isIdle)
421
+ val armedNow = armed
422
+ if (!force && armedNow != null && !evaluated.needsRearm(armedNow)) {
423
+ current = evaluated
424
+ return
425
+ }
426
+ // A re-arm asks for the exact alarm again.
427
+ val requested = if (canExact && exactRefused) computeWindowLocked(true, anchor = true, isIdle = isIdle) else evaluated
428
+ armLocked(requested, reason, isIdle = isIdle)
429
+ }
430
+
431
+ /**
432
+ * Sets the alarms of [window] and returns the window actually armed ([window], or the listener-and-backup
433
+ * fallback when the exact alarm is refused). Nothing is cancelled first: AlarmManager replaces an alarm with the
434
+ * same PendingIntent (exact and backup share one) or the same listener. Only a listener left over from a
435
+ * non-exact schedule is cancelled when switching to exact.
436
+ *
437
+ * @param isIdle the deep-idle reading [window] was computed with (used for the fallback window).
438
+ * @param lastRecord the record [window] starts at, if it is not yet in `runtime` (used for the fallback window).
439
+ */
440
+ private fun armLocked(
441
+ window: HeartbeatWindow,
442
+ reason: String,
443
+ isIdle: Boolean = device.isDeviceIdleMode(),
444
+ lastRecord: Record? = null,
445
+ ): HeartbeatWindow {
446
+ val previous = armed
447
+ var effective = window
448
+ var refused = false
449
+ if (window.strategy == HeartbeatStrategy.EXACT) {
450
+ try {
451
+ alarms.setExactAndAllowWhileIdle(
452
+ AlarmManager.ELAPSED_REALTIME_WAKEUP,
453
+ window.dueElapsed,
454
+ HeartbeatIntents.pendingIntent(appContext, HeartbeatTrigger.EXACT_ALARM),
455
+ )
456
+ if (previous == null || previous.strategy != HeartbeatStrategy.EXACT) cancelListenerLocked()
457
+ } catch (e: RuntimeException) {
458
+ // SecurityException when exact alarms are not allowed after all (re-checked on every arm).
459
+ Logger.w(TAG, "exact alarm refused; falling back to listener + backup alarms", e)
460
+ refused = true
461
+ effective = computeWindowLocked(canExact = false, anchor = true, isIdle = isIdle, lastRecord = lastRecord)
462
+ }
463
+ }
464
+ if (effective.strategy != HeartbeatStrategy.EXACT) {
465
+ try {
466
+ alarms.setExact(
467
+ AlarmManager.ELAPSED_REALTIME_WAKEUP,
468
+ effective.dueElapsed,
469
+ LISTENER_TAG,
470
+ listener,
471
+ mainHandler,
472
+ )
473
+ } catch (e: RuntimeException) {
474
+ Logger.e(TAG, "cannot set the listener alarm", e)
475
+ }
476
+ try {
477
+ alarms.setAndAllowWhileIdle(
478
+ AlarmManager.ELAPSED_REALTIME_WAKEUP,
479
+ effective.backupAtElapsed,
480
+ HeartbeatIntents.pendingIntent(appContext, HeartbeatTrigger.BACKUP_ALARM),
481
+ )
482
+ } catch (e: RuntimeException) {
483
+ Logger.e(TAG, "cannot set the backup alarm", e)
484
+ }
485
+ }
486
+ armed = effective
487
+ current = effective
488
+ exactRefused = refused
489
+ generation++
490
+ alarmsCleared = false
491
+ persistSchedule(effective)
492
+ val nowElapsed = clock.elapsedRealtime()
493
+ Logger.d(
494
+ TAG,
495
+ "armed ($reason): ${effective.strategy.wire}, due in ${(effective.dueElapsed - nowElapsed) / 1_000} s, " +
496
+ "backup in ${(effective.backupAtElapsed - nowElapsed) / 1_000} s",
497
+ )
498
+ return effective
499
+ }
500
+
501
+ /** Cancels both alarms, including a PendingIntent alarm armed by a previous process. */
502
+ private fun cancelAlarmsLocked() {
503
+ cancelListenerLocked()
504
+ try {
505
+ HeartbeatIntents.existing(appContext)?.let { alarms.cancel(it) }
506
+ } catch (e: RuntimeException) {
507
+ Logger.w(TAG, "cannot cancel the heartbeat alarm", e)
508
+ }
509
+ armed = null
510
+ current = null
511
+ exactRefused = false
512
+ generation++
513
+ alarmsCleared = true
514
+ }
515
+
516
+ private fun cancelListenerLocked() {
517
+ try {
518
+ alarms.cancel(listener)
519
+ } catch (e: RuntimeException) {
520
+ Logger.w(TAG, "cannot cancel the listener alarm", e)
521
+ }
522
+ }
523
+
524
+ private fun isActive(): Boolean = configStore.runtime.value.enabled && configStore.config.value.heartbeat.enabled
525
+
526
+ /**
527
+ * @param anchor remember "now" as the window base while there is no record (false for read-only previews).
528
+ * @param lastRecord the record the window starts at, if it is not yet in `runtime` (a heartbeat before submit).
529
+ */
530
+ private fun computeWindowLocked(
531
+ canExact: Boolean,
532
+ anchor: Boolean,
533
+ isIdle: Boolean = device.isDeviceIdleMode(),
534
+ lastRecord: Record? = null,
535
+ ): HeartbeatWindow {
536
+ val runtime = configStore.runtime.value
537
+ val heartbeat = configStore.config.value.heartbeat
538
+ val now = clock.now()
539
+ val nowElapsed = clock.elapsedRealtime()
540
+ val bootCount = clock.bootCount()
541
+ val lastRecordAt = lastRecord?.recordedAt ?: runtime.lastRecordAt
542
+ if (anchor && lastRecordAt == null && noRecordAnchorElapsed == null) noRecordAnchorElapsed = nowElapsed
543
+ return HeartbeatWindow.compute(
544
+ lastRecordAt = lastRecordAt,
545
+ lastRecordElapsed = if (lastRecord != null) lastRecord.elapsedRealtimeMs else runtime.lastRecordElapsed,
546
+ lastRecordBootCount = if (lastRecord != null) lastRecord.bootCount else runtime.lastRecordBootCount,
547
+ now = now,
548
+ nowElapsed = nowElapsed,
549
+ bootCount = bootCount,
550
+ minIntervalSec = heartbeat.minInterval,
551
+ maxIntervalSec = heartbeat.maxInterval,
552
+ isIdle = isIdle,
553
+ canExact = canExact,
554
+ lastBackupFireElapsed = lastBackupFireElapsed(bootCount),
555
+ enabled = runtime.enabled && heartbeat.enabled,
556
+ noRecordBaseElapsed = noRecordAnchorElapsed,
557
+ lastAttemptElapsed = lastAttemptElapsed,
558
+ trackingStartedAt = runtime.trackingStartedAt,
559
+ )
560
+ }
561
+
562
+ /**
563
+ * (strategy, nextHeartbeatAt) for [status]: the window the heartbeat follows in this process, else what a previous
564
+ * process armed, else a preview.
565
+ */
566
+ private fun scheduleSnapshotLocked(): Pair<HeartbeatStrategy, Long?> {
567
+ if (lifecycle == Lifecycle.STOPPED || !isActive()) return HeartbeatStrategy.DISABLED to null
568
+ val now = clock.now()
569
+ current?.let { window ->
570
+ val inMs = (window.expectedFireElapsed - clock.elapsedRealtime()).coerceAtLeast(0L)
571
+ return window.strategy to now + inMs
572
+ }
573
+ persistedSchedule(clock.bootCount())?.let { (strategy, next) ->
574
+ if (configStore.runtime.value.lastRecordAt == null) return strategy to maxOf(next, now)
575
+ // The previous process may have kept its alarms up to REARM_THRESHOLD_MS before the real due time, and the
576
+ // persisted time is the alarm's. The real due time follows from the last record and the armed strategy.
577
+ val window = computeWindowLocked(
578
+ canExact = strategy == HeartbeatStrategy.EXACT,
579
+ anchor = false,
580
+ isIdle = strategy == HeartbeatStrategy.IDLE_PACED,
581
+ )
582
+ return strategy to (window.nextHeartbeatAt ?: maxOf(next, now))
583
+ }
584
+ val preview = computeWindowLocked(device.canScheduleExactAlarms(), anchor = false)
585
+ return preview.strategy to preview.nextHeartbeatAt
586
+ }
587
+
588
+ /** Identifies the last record in `runtime`; it changes whenever a record is submitted. */
589
+ private fun lastRecordMarker(): Pair<Long?, Long?> =
590
+ configStore.runtime.value.let { it.lastRecordAt to it.lastRecordElapsed }
591
+
592
+ private fun observeConfigLocked() {
593
+ configJob?.cancel()
594
+ configJob = scope.launch {
595
+ combine(configStore.config, configStore.runtime) { config, runtime -> config.heartbeat to runtime.enabled }
596
+ .distinctUntilChanged()
597
+ .collect { reschedule("config", force = false) }
598
+ }
599
+ }
600
+
601
+ private fun registerIdleReceiverLocked() {
602
+ if (idleReceiverRegistered) return
603
+ try {
604
+ ContextCompat.registerReceiver(
605
+ appContext,
606
+ idleReceiver,
607
+ IntentFilter(PowerManager.ACTION_DEVICE_IDLE_MODE_CHANGED),
608
+ ContextCompat.RECEIVER_NOT_EXPORTED,
609
+ )
610
+ idleReceiverRegistered = true
611
+ } catch (e: RuntimeException) {
612
+ Logger.w(TAG, "cannot observe device idle mode", e)
613
+ }
614
+ }
615
+
616
+ private fun unregisterIdleReceiverLocked() {
617
+ if (!idleReceiverRegistered) return
618
+ idleReceiverRegistered = false
619
+ try {
620
+ appContext.unregisterReceiver(idleReceiver)
621
+ } catch (e: RuntimeException) {
622
+ Logger.w(TAG, "cannot unregister the idle receiver", e)
623
+ }
624
+ }
625
+
626
+ // ---- wake lock
627
+
628
+ private fun acquireWakeLock(): PowerManager.WakeLock? = try {
629
+ powerManager?.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, WAKE_LOCK_TAG)?.apply {
630
+ setReferenceCounted(false)
631
+ acquire(WAKE_LOCK_TIMEOUT_MS)
632
+ }
633
+ } catch (e: RuntimeException) {
634
+ Logger.w(TAG, "cannot acquire the wake lock", e)
635
+ null
636
+ }
637
+
638
+ private fun release(wakeLock: PowerManager.WakeLock?) {
639
+ try {
640
+ if (wakeLock?.isHeld == true) wakeLock.release()
641
+ } catch (e: RuntimeException) {
642
+ Logger.w(TAG, "cannot release the wake lock", e)
643
+ }
644
+ }
645
+
646
+ // ---- persistence (own `hb_` keys in the shared plugin prefs)
647
+
648
+ private fun persistBackupFire(elapsed: Long, bootCount: Int) {
649
+ synchronized(lock) {
650
+ lastBackupFire = elapsed to bootCount
651
+ lastBackupFireLoaded = true
652
+ }
653
+ prefs.edit().putLong(KEY_LAST_BACKUP_ELAPSED, elapsed).putInt(KEY_LAST_BACKUP_BOOT, bootCount).apply()
654
+ }
655
+
656
+ /** The last backup fire in this boot, or null. Call with [lock] held. */
657
+ private fun lastBackupFireElapsed(bootCount: Int): Long? {
658
+ if (!lastBackupFireLoaded) {
659
+ val elapsed = prefs.getLong(KEY_LAST_BACKUP_ELAPSED, -1L)
660
+ lastBackupFire = if (elapsed >= 0L) elapsed to prefs.getInt(KEY_LAST_BACKUP_BOOT, Int.MIN_VALUE) else null
661
+ lastBackupFireLoaded = true
662
+ }
663
+ return lastBackupFire?.takeIf { it.second == bootCount }?.first
664
+ }
665
+
666
+ /** Persisted on every arm (not on evaluations that keep the alarms), so a new process can report what was armed. */
667
+ private fun persistSchedule(window: HeartbeatWindow) {
668
+ val next = window.nextHeartbeatAt ?: return clearPersistedSchedule()
669
+ prefs.edit()
670
+ .putString(KEY_STRATEGY, window.strategy.wire)
671
+ .putLong(KEY_NEXT_AT, next)
672
+ .putInt(KEY_SCHEDULE_BOOT, clock.bootCount())
673
+ .apply()
674
+ }
675
+
676
+ private fun clearPersistedSchedule() {
677
+ prefs.edit().remove(KEY_STRATEGY).remove(KEY_NEXT_AT).remove(KEY_SCHEDULE_BOOT).apply()
678
+ }
679
+
680
+ /** What a previous process armed, if it did so in this boot (a reboot wipes every alarm). */
681
+ private fun persistedSchedule(bootCount: Int): Pair<HeartbeatStrategy, Long>? {
682
+ if (prefs.getInt(KEY_SCHEDULE_BOOT, Int.MIN_VALUE) != bootCount) return null
683
+ val strategy = HeartbeatStrategy.fromWire(prefs.getString(KEY_STRATEGY, null)) ?: return null
684
+ val next = prefs.getLong(KEY_NEXT_AT, -1L).takeIf { it >= 0L } ?: return null
685
+ return strategy to next
686
+ }
687
+
688
+ internal companion object {
689
+ const val TAG = "LT.Heartbeat"
690
+ const val LISTENER_TAG = "lt-heartbeat"
691
+ const val WAKE_LOCK_TAG = "LocationTracking:heartbeat"
692
+
693
+ /**
694
+ * Upper bound of the heartbeat wake lock. The lock is released as soon as the heartbeat is submitted
695
+ * (milliseconds normally); the bound only matters when something hangs, and a shorter one could let the CPU
696
+ * sleep before a slow submit completes.
697
+ */
698
+ const val WAKE_LOCK_TIMEOUT_MS = 60_000L
699
+
700
+ // Both run inside the receiver's goAsync() budget.
701
+ const val LAST_LOCATION_TIMEOUT_MS = 3_000L
702
+ const val PROVIDER_CHECK_TIMEOUT_MS = 3_000L
703
+
704
+ /** After the backend returned no last location, it is not asked again for this long. */
705
+ const val LAST_LOCATION_RETRY_MS = 10 * 60_000L
706
+
707
+ const val KEY_LAST_BACKUP_ELAPSED = "hb_last_backup_fire_elapsed"
708
+ const val KEY_LAST_BACKUP_BOOT = "hb_last_backup_fire_boot"
709
+ const val KEY_STRATEGY = "hb_strategy"
710
+ const val KEY_NEXT_AT = "hb_next_heartbeat_at"
711
+ const val KEY_SCHEDULE_BOOT = "hb_schedule_boot"
712
+ }
713
+ }