@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.
- package/LICENSE +21 -0
- package/README.md +2759 -2
- package/android/build.gradle +107 -0
- package/android/consumer-rules.pro +42 -0
- package/android/src/main/AndroidManifest.xml +52 -0
- package/android/src/main/java/com/brickssoft/locationtracking/LocationTrackingPlugin.kt +431 -0
- package/android/src/main/java/com/brickssoft/locationtracking/api/LocationTrackingListener.kt +52 -0
- package/android/src/main/java/com/brickssoft/locationtracking/api/LocationTrackingNative.kt +267 -0
- package/android/src/main/java/com/brickssoft/locationtracking/api/NativeListeners.kt +270 -0
- package/android/src/main/java/com/brickssoft/locationtracking/api/NativeThread.kt +57 -0
- package/android/src/main/java/com/brickssoft/locationtracking/bridge/BridgeServices.kt +64 -0
- package/android/src/main/java/com/brickssoft/locationtracking/bridge/CallResponder.kt +41 -0
- package/android/src/main/java/com/brickssoft/locationtracking/bridge/OptionParsers.kt +281 -0
- package/android/src/main/java/com/brickssoft/locationtracking/bridge/PluginHandlers.kt +541 -0
- package/android/src/main/java/com/brickssoft/locationtracking/bridge/PluginPermissionHost.kt +76 -0
- package/android/src/main/java/com/brickssoft/locationtracking/bridge/WatchRegistry.kt +84 -0
- package/android/src/main/java/com/brickssoft/locationtracking/config/Config.kt +231 -0
- package/android/src/main/java/com/brickssoft/locationtracking/config/ConfigJson.kt +574 -0
- package/android/src/main/java/com/brickssoft/locationtracking/config/ConfigStore.kt +31 -0
- package/android/src/main/java/com/brickssoft/locationtracking/config/ConfigValidator.kt +170 -0
- package/android/src/main/java/com/brickssoft/locationtracking/config/RuntimeStateJson.kt +73 -0
- package/android/src/main/java/com/brickssoft/locationtracking/config/SharedPrefsConfigStore.kt +135 -0
- package/android/src/main/java/com/brickssoft/locationtracking/config/State.kt +45 -0
- package/android/src/main/java/com/brickssoft/locationtracking/core/AppDispatchers.kt +21 -0
- package/android/src/main/java/com/brickssoft/locationtracking/core/Clock.kt +41 -0
- package/android/src/main/java/com/brickssoft/locationtracking/core/Components.kt +217 -0
- package/android/src/main/java/com/brickssoft/locationtracking/core/Constants.kt +78 -0
- package/android/src/main/java/com/brickssoft/locationtracking/core/Errors.kt +27 -0
- package/android/src/main/java/com/brickssoft/locationtracking/core/EventBus.kt +39 -0
- package/android/src/main/java/com/brickssoft/locationtracking/core/ForceStopProbe.kt +63 -0
- package/android/src/main/java/com/brickssoft/locationtracking/core/Iso8601.kt +63 -0
- package/android/src/main/java/com/brickssoft/locationtracking/core/Logger.kt +68 -0
- package/android/src/main/java/com/brickssoft/locationtracking/core/RecordHooks.kt +47 -0
- package/android/src/main/java/com/brickssoft/locationtracking/core/TrackingEvent.kt +64 -0
- package/android/src/main/java/com/brickssoft/locationtracking/data/SqlSupport.kt +79 -0
- package/android/src/main/java/com/brickssoft/locationtracking/data/SqliteGeofenceStore.kt +144 -0
- package/android/src/main/java/com/brickssoft/locationtracking/data/SqliteLocationStore.kt +215 -0
- package/android/src/main/java/com/brickssoft/locationtracking/data/Stores.kt +45 -0
- package/android/src/main/java/com/brickssoft/locationtracking/data/TrackingDatabase.kt +111 -0
- package/android/src/main/java/com/brickssoft/locationtracking/device/DefaultDeviceInfoProvider.kt +104 -0
- package/android/src/main/java/com/brickssoft/locationtracking/device/DefaultDeviceMonitor.kt +335 -0
- package/android/src/main/java/com/brickssoft/locationtracking/device/DeviceMonitor.kt +36 -0
- package/android/src/main/java/com/brickssoft/locationtracking/device/DeviceStateMapping.kt +69 -0
- package/android/src/main/java/com/brickssoft/locationtracking/engine/DefaultTrackingEngine.kt +1281 -0
- package/android/src/main/java/com/brickssoft/locationtracking/engine/ElasticDistance.kt +68 -0
- package/android/src/main/java/com/brickssoft/locationtracking/engine/LocationRequests.kt +74 -0
- package/android/src/main/java/com/brickssoft/locationtracking/engine/MotionStateMachine.kt +291 -0
- package/android/src/main/java/com/brickssoft/locationtracking/engine/StationaryRegion.kt +41 -0
- package/android/src/main/java/com/brickssoft/locationtracking/engine/TrackingEngine.kt +60 -0
- package/android/src/main/java/com/brickssoft/locationtracking/engine/TrackingTimers.kt +90 -0
- package/android/src/main/java/com/brickssoft/locationtracking/geofence/DefaultGeofenceManager.kt +899 -0
- package/android/src/main/java/com/brickssoft/locationtracking/geofence/DwellTracker.kt +80 -0
- package/android/src/main/java/com/brickssoft/locationtracking/geofence/GeofenceManager.kt +33 -0
- package/android/src/main/java/com/brickssoft/locationtracking/geofence/PolygonMath.kt +194 -0
- package/android/src/main/java/com/brickssoft/locationtracking/heartbeat/DefaultHeartbeatScheduler.kt +713 -0
- package/android/src/main/java/com/brickssoft/locationtracking/heartbeat/HeartbeatAlarmReceiver.kt +69 -0
- package/android/src/main/java/com/brickssoft/locationtracking/heartbeat/HeartbeatAlarms.kt +100 -0
- package/android/src/main/java/com/brickssoft/locationtracking/heartbeat/HeartbeatScheduler.kt +25 -0
- package/android/src/main/java/com/brickssoft/locationtracking/heartbeat/HeartbeatWindow.kt +179 -0
- package/android/src/main/java/com/brickssoft/locationtracking/http/AuthorizationManager.kt +213 -0
- package/android/src/main/java/com/brickssoft/locationtracking/http/BodyBuilder.kt +73 -0
- package/android/src/main/java/com/brickssoft/locationtracking/http/HttpSupport.kt +118 -0
- package/android/src/main/java/com/brickssoft/locationtracking/http/HttpSyncer.kt +18 -0
- package/android/src/main/java/com/brickssoft/locationtracking/http/OkHttpSyncer.kt +551 -0
- package/android/src/main/java/com/brickssoft/locationtracking/http/StrictJson.kt +135 -0
- package/android/src/main/java/com/brickssoft/locationtracking/http/SyncPolicy.kt +109 -0
- package/android/src/main/java/com/brickssoft/locationtracking/http/TemplateRenderer.kt +143 -0
- package/android/src/main/java/com/brickssoft/locationtracking/logging/FileLogger.kt +461 -0
- package/android/src/main/java/com/brickssoft/locationtracking/logging/LogFileProvider.kt +9 -0
- package/android/src/main/java/com/brickssoft/locationtracking/logging/LogFormatter.kt +148 -0
- package/android/src/main/java/com/brickssoft/locationtracking/logging/LogStore.kt +30 -0
- package/android/src/main/java/com/brickssoft/locationtracking/model/DeviceModels.kt +176 -0
- package/android/src/main/java/com/brickssoft/locationtracking/model/GeofenceSpec.kt +25 -0
- package/android/src/main/java/com/brickssoft/locationtracking/model/ModelJson.kt +550 -0
- package/android/src/main/java/com/brickssoft/locationtracking/model/Record.kt +106 -0
- package/android/src/main/java/com/brickssoft/locationtracking/model/TrackedLocation.kt +53 -0
- package/android/src/main/java/com/brickssoft/locationtracking/permission/BackgroundRationaleDialog.kt +85 -0
- package/android/src/main/java/com/brickssoft/locationtracking/permission/CurrentActivityTracker.kt +72 -0
- package/android/src/main/java/com/brickssoft/locationtracking/permission/DefaultPermissionManager.kt +333 -0
- package/android/src/main/java/com/brickssoft/locationtracking/permission/PermissionManager.kt +54 -0
- package/android/src/main/java/com/brickssoft/locationtracking/position/DefaultPositionService.kt +277 -0
- package/android/src/main/java/com/brickssoft/locationtracking/position/PositionService.kt +39 -0
- package/android/src/main/java/com/brickssoft/locationtracking/position/PositionWatch.kt +115 -0
- package/android/src/main/java/com/brickssoft/locationtracking/processing/DefaultLocationProcessor.kt +93 -0
- package/android/src/main/java/com/brickssoft/locationtracking/processing/DefaultOdometer.kt +99 -0
- package/android/src/main/java/com/brickssoft/locationtracking/processing/DefaultRecordFactory.kt +166 -0
- package/android/src/main/java/com/brickssoft/locationtracking/processing/Geo.kt +56 -0
- package/android/src/main/java/com/brickssoft/locationtracking/processing/KalmanFilter.kt +72 -0
- package/android/src/main/java/com/brickssoft/locationtracking/processing/Processing.kt +48 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/Backends.kt +187 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/DefaultProviderFactory.kt +169 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/ProviderPackaging.kt +135 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/android/AndroidActivityBackend.kt +25 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/android/AndroidGeofenceBackend.kt +181 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/android/AndroidGeofenceReceiver.kt +115 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/android/AndroidLocationBackend.kt +265 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/android/AndroidProviderBundle.kt +31 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/android/LocationProviders.kt +98 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsActivityBackend.kt +80 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsActivityReceiver.kt +62 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsErrors.kt +72 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsGeofenceBackend.kt +152 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsGeofenceReceiver.kt +78 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsLocationBackend.kt +176 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsProviderBundle.kt +62 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsReceiverSupport.kt +88 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsTasks.kt +55 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsActivityBackend.kt +71 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsActivityReceiver.kt +57 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsErrors.kt +76 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsGeofenceBackend.kt +116 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsGeofenceReceiver.kt +67 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsIntents.kt +83 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsLocationBackend.kt +197 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsProviderBundle.kt +55 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsTasks.kt +44 -0
- package/android/src/main/java/com/brickssoft/locationtracking/record/RecordSink.kt +95 -0
- package/android/src/main/java/com/brickssoft/locationtracking/service/BootCountStore.kt +44 -0
- package/android/src/main/java/com/brickssoft/locationtracking/service/BootReceiver.kt +175 -0
- package/android/src/main/java/com/brickssoft/locationtracking/service/DefaultServiceController.kt +196 -0
- package/android/src/main/java/com/brickssoft/locationtracking/service/LocationTrackingService.kt +319 -0
- package/android/src/main/java/com/brickssoft/locationtracking/service/NotificationActionReceiver.kt +42 -0
- package/android/src/main/java/com/brickssoft/locationtracking/service/NotificationFactory.kt +338 -0
- package/android/src/main/java/com/brickssoft/locationtracking/service/ServiceCommands.kt +204 -0
- package/android/src/main/java/com/brickssoft/locationtracking/service/ServiceController.kt +22 -0
- package/android/src/main/java/com/brickssoft/locationtracking/service/ServiceDeps.kt +61 -0
- package/android/src/main/java/com/brickssoft/locationtracking/service/WhileInUseStartCheck.kt +115 -0
- package/android/src/main/java/com/brickssoft/locationtracking/settings/DefaultDeviceSettings.kt +79 -0
- package/android/src/main/java/com/brickssoft/locationtracking/settings/DeviceSettings.kt +21 -0
- package/android/src/main/java/com/brickssoft/locationtracking/settings/OemPowerManagers.kt +198 -0
- package/android/src/main/res/drawable/lt_ic_notification.xml +11 -0
- package/android/src/main/res/values/lt_strings.xml +11 -0
- package/android/src/main/res/xml/lt_file_paths.xml +5 -0
- package/dist/esm/definitions.d.ts +719 -0
- package/dist/esm/definitions.js +2 -0
- package/dist/esm/definitions.js.map +1 -0
- package/dist/esm/events.d.ts +50 -0
- package/dist/esm/events.js +52 -0
- package/dist/esm/events.js.map +1 -0
- package/dist/esm/index.d.ts +3 -0
- package/dist/esm/index.js +5 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/plugin.d.ts +3 -0
- package/dist/esm/plugin.js +10 -0
- package/dist/esm/plugin.js.map +1 -0
- package/dist/esm/web/config.d.ts +19 -0
- package/dist/esm/web/config.js +243 -0
- package/dist/esm/web/config.js.map +1 -0
- package/dist/esm/web/device.d.ts +9 -0
- package/dist/esm/web/device.js +90 -0
- package/dist/esm/web/device.js.map +1 -0
- package/dist/esm/web/errors.d.ts +14 -0
- package/dist/esm/web/errors.js +19 -0
- package/dist/esm/web/errors.js.map +1 -0
- package/dist/esm/web/geolocation.d.ts +26 -0
- package/dist/esm/web/geolocation.js +117 -0
- package/dist/esm/web/geolocation.js.map +1 -0
- package/dist/esm/web/location.d.ts +7 -0
- package/dist/esm/web/location.js +70 -0
- package/dist/esm/web/location.js.map +1 -0
- package/dist/esm/web.d.ts +164 -0
- package/dist/esm/web.js +328 -0
- package/dist/esm/web.js.map +1 -0
- package/dist/plugin.cjs.js +945 -0
- package/dist/plugin.cjs.js.map +1 -0
- package/dist/plugin.js +948 -0
- package/dist/plugin.js.map +1 -0
- package/package.json +60 -4
package/android/src/main/java/com/brickssoft/locationtracking/heartbeat/DefaultHeartbeatScheduler.kt
ADDED
|
@@ -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
|
+
}
|