@bricks-soft/capacitor-location-tracking 0.0.0-stage → 8.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +2759 -2
- package/android/build.gradle +106 -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 +59 -4
|
@@ -0,0 +1,1281 @@
|
|
|
1
|
+
package com.brickssoft.locationtracking.engine
|
|
2
|
+
|
|
3
|
+
import com.brickssoft.locationtracking.config.Config
|
|
4
|
+
import com.brickssoft.locationtracking.config.ConfigStore
|
|
5
|
+
import com.brickssoft.locationtracking.config.GeolocationConfig
|
|
6
|
+
import com.brickssoft.locationtracking.config.RuntimeState
|
|
7
|
+
import com.brickssoft.locationtracking.config.State
|
|
8
|
+
import com.brickssoft.locationtracking.config.TrackingMode
|
|
9
|
+
import com.brickssoft.locationtracking.core.Clock
|
|
10
|
+
import com.brickssoft.locationtracking.core.Constants
|
|
11
|
+
import com.brickssoft.locationtracking.core.ErrorCode
|
|
12
|
+
import com.brickssoft.locationtracking.core.EventBus
|
|
13
|
+
import com.brickssoft.locationtracking.core.ForceStopProbe
|
|
14
|
+
import com.brickssoft.locationtracking.core.Logger
|
|
15
|
+
import com.brickssoft.locationtracking.core.Subscription
|
|
16
|
+
import com.brickssoft.locationtracking.core.TrackingEvent
|
|
17
|
+
import com.brickssoft.locationtracking.core.TrackingException
|
|
18
|
+
import com.brickssoft.locationtracking.device.DeviceMonitor
|
|
19
|
+
import com.brickssoft.locationtracking.geofence.GeofenceManager
|
|
20
|
+
import com.brickssoft.locationtracking.heartbeat.HeartbeatScheduler
|
|
21
|
+
import com.brickssoft.locationtracking.http.HttpSyncer
|
|
22
|
+
import com.brickssoft.locationtracking.model.ActivitySample
|
|
23
|
+
import com.brickssoft.locationtracking.model.ActivityType
|
|
24
|
+
import com.brickssoft.locationtracking.model.GeofenceAction
|
|
25
|
+
import com.brickssoft.locationtracking.model.PermissionLevel
|
|
26
|
+
import com.brickssoft.locationtracking.model.ProviderState
|
|
27
|
+
import com.brickssoft.locationtracking.model.Record
|
|
28
|
+
import com.brickssoft.locationtracking.model.RecordEvent
|
|
29
|
+
import com.brickssoft.locationtracking.model.TrackedLocation
|
|
30
|
+
import com.brickssoft.locationtracking.permission.PermissionManager
|
|
31
|
+
import com.brickssoft.locationtracking.processing.FilterResult
|
|
32
|
+
import com.brickssoft.locationtracking.processing.LocationProcessor
|
|
33
|
+
import com.brickssoft.locationtracking.processing.Odometer
|
|
34
|
+
import com.brickssoft.locationtracking.processing.RecordFactory
|
|
35
|
+
import com.brickssoft.locationtracking.provider.ActivityBackend
|
|
36
|
+
import com.brickssoft.locationtracking.provider.GeofenceBackend
|
|
37
|
+
import com.brickssoft.locationtracking.provider.LocationBackend
|
|
38
|
+
import com.brickssoft.locationtracking.provider.LocationListener
|
|
39
|
+
import com.brickssoft.locationtracking.provider.LocationRequestSpec
|
|
40
|
+
import com.brickssoft.locationtracking.provider.OsGeofence
|
|
41
|
+
import com.brickssoft.locationtracking.provider.OsGeofenceTransition
|
|
42
|
+
import com.brickssoft.locationtracking.provider.ProviderFactory
|
|
43
|
+
import com.brickssoft.locationtracking.record.RecordSink
|
|
44
|
+
import com.brickssoft.locationtracking.service.ServiceController
|
|
45
|
+
import kotlinx.coroutines.CoroutineScope
|
|
46
|
+
import kotlinx.coroutines.Job
|
|
47
|
+
import kotlinx.coroutines.launch
|
|
48
|
+
import kotlinx.coroutines.sync.Mutex
|
|
49
|
+
import kotlinx.coroutines.sync.withLock
|
|
50
|
+
import kotlinx.coroutines.withContext
|
|
51
|
+
import kotlinx.coroutines.withTimeoutOrNull
|
|
52
|
+
import org.json.JSONObject
|
|
53
|
+
import kotlin.coroutines.ContinuationInterceptor
|
|
54
|
+
import kotlin.coroutines.CoroutineContext
|
|
55
|
+
import kotlin.coroutines.EmptyCoroutineContext
|
|
56
|
+
import kotlin.coroutines.cancellation.CancellationException
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Default [TrackingEngine]: runs the tracking session (foreground service, location and activity backends,
|
|
60
|
+
* heartbeat, geofences), the STATIONARY / MOVING [MotionStateMachine], the location pipeline and the
|
|
61
|
+
* `tracking_start` / `tracking_stop` / `motionchange` records.
|
|
62
|
+
*
|
|
63
|
+
* Every state change runs on [scope]'s dispatcher (`dispatchers.engine`, single-threaded) while holding one
|
|
64
|
+
* mutex, so public calls, backend callbacks (any thread) and timers are serialized.
|
|
65
|
+
*
|
|
66
|
+
* Location pipeline, for each fix while tracking: `processor` -> (LOCATION mode) motion state machine ->
|
|
67
|
+
* `odometer` -> `geofences.onLocation` -> elastic distance check against the last recorded fix ->
|
|
68
|
+
* `recordFactory` -> `recordSink`. While STATIONARY a fix only feeds the state machine and the geofences
|
|
69
|
+
* (no odometer, no record), so GPS jitter while parked is neither recorded nor counted.
|
|
70
|
+
*
|
|
71
|
+
* Stationary GPS-off mode (architecture round 2, §3). While STATIONARY in the LOCATION mode the engine makes no
|
|
72
|
+
* location request that computes fixes: it registers the OS geofence [StationaryRegion] (id
|
|
73
|
+
* `Constants.STATIONARY_REGION_ID`, EXIT only) around the stationary anchor and requests PASSIVE updates, which only
|
|
74
|
+
* forward fixes that other apps cause. The foreground service and the heartbeat keep running. The heartbeat carries
|
|
75
|
+
* `runtime.lastLocation`, which is the anchor fix while STATIONARY (it changes only when the anchor changes, see
|
|
76
|
+
* [onStationaryFix]), so heartbeats show the anchor with the time it was acquired. STATIONARY ends (GPS on again,
|
|
77
|
+
* the configured request, a `motionchange` with `is_moving: true`) on the first of: the region's EXIT
|
|
78
|
+
* ([onStationaryRegionTransition]), a fix that is certainly outside the stationary radius (see
|
|
79
|
+
* [MotionStateMachine]), a confident moving activity lasting `motionTriggerDelay`, or `changePace(true)`. If the
|
|
80
|
+
* region cannot be registered (no background location permission, too many geofences, a backend error), or no
|
|
81
|
+
* current anchor is known (see [syncStationaryRegion]), a LOW-power request (at most one fix per 3 minutes) replaces
|
|
82
|
+
* the PASSIVE one. While `geofences.needsContinuousLocation` is true the configured request is used, as before.
|
|
83
|
+
*/
|
|
84
|
+
class DefaultTrackingEngine(
|
|
85
|
+
private val configStore: ConfigStore,
|
|
86
|
+
private val providers: ProviderFactory,
|
|
87
|
+
private val processor: LocationProcessor,
|
|
88
|
+
private val odometer: Odometer,
|
|
89
|
+
private val recordFactory: RecordFactory,
|
|
90
|
+
private val recordSink: RecordSink,
|
|
91
|
+
private val heartbeat: HeartbeatScheduler,
|
|
92
|
+
private val geofences: GeofenceManager,
|
|
93
|
+
private val serviceController: ServiceController,
|
|
94
|
+
private val device: DeviceMonitor,
|
|
95
|
+
private val syncer: HttpSyncer,
|
|
96
|
+
private val permissions: PermissionManager,
|
|
97
|
+
private val events: EventBus,
|
|
98
|
+
private val clock: Clock,
|
|
99
|
+
private val scope: CoroutineScope,
|
|
100
|
+
/** Background triggers do not restore tracking in a process started after a user force stop. */
|
|
101
|
+
private val forceStopProbe: ForceStopProbe = ForceStopProbe.NEVER,
|
|
102
|
+
) : TrackingEngine {
|
|
103
|
+
private val mutex = Mutex()
|
|
104
|
+
private val engineContext: CoroutineContext =
|
|
105
|
+
scope.coroutineContext[ContinuationInterceptor] ?: EmptyCoroutineContext
|
|
106
|
+
private val timers = TrackingTimers(scope, clock, mutex)
|
|
107
|
+
|
|
108
|
+
/** The tracking session running in this process, or null. Guarded by [mutex]. */
|
|
109
|
+
private var session: Session? = null
|
|
110
|
+
private var nextSessionId = 1L
|
|
111
|
+
|
|
112
|
+
private val config: Config get() = configStore.config.value
|
|
113
|
+
private val runtime: RuntimeState get() = configStore.runtime.value
|
|
114
|
+
|
|
115
|
+
// ------------------------------------------------------------------ config
|
|
116
|
+
|
|
117
|
+
override suspend fun ready(config: JSONObject?, reset: Boolean): State = serialized {
|
|
118
|
+
val old = this.config
|
|
119
|
+
val new = configStore.ready(config, reset)
|
|
120
|
+
configStore.updateRuntime { it.copy(didReady = true) }
|
|
121
|
+
onConfigChanged(old, new)
|
|
122
|
+
if (session == null && runtime.enabled) {
|
|
123
|
+
Logger.i(TAG, "ready: tracking is enabled but not running in this process; restoring")
|
|
124
|
+
restoreLocked(REASON_RESTORE)
|
|
125
|
+
}
|
|
126
|
+
state()
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
override suspend fun setConfig(config: JSONObject): State = serialized {
|
|
130
|
+
val old = this.config
|
|
131
|
+
onConfigChanged(old, configStore.merge(config))
|
|
132
|
+
state()
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
override suspend fun reset(config: JSONObject?): State = serialized {
|
|
136
|
+
val old = this.config
|
|
137
|
+
onConfigChanged(old, configStore.reset(config))
|
|
138
|
+
state()
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ------------------------------------------------------------------ tracking
|
|
142
|
+
|
|
143
|
+
override suspend fun start(): State = serialized {
|
|
144
|
+
startLocked(TrackingMode.LOCATION)
|
|
145
|
+
state()
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
override suspend fun startGeofences(): State = serialized {
|
|
149
|
+
startLocked(TrackingMode.GEOFENCES)
|
|
150
|
+
state()
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
override suspend fun stop(): State = serialized {
|
|
154
|
+
// A paused session must not be resumed after the app stopped tracking (for example on logout).
|
|
155
|
+
guarded("serviceController.cancelResumeNotification") { serviceController.cancelResumeNotification() }
|
|
156
|
+
stopLocked(REASON_STOP)
|
|
157
|
+
state()
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
override suspend fun changePace(isMoving: Boolean) = serialized {
|
|
161
|
+
val s = session
|
|
162
|
+
when {
|
|
163
|
+
s == null -> Logger.i(TAG, "changePace($isMoving) ignored: tracking is not enabled")
|
|
164
|
+
s.mode != TrackingMode.LOCATION -> Logger.i(TAG, "changePace($isMoving) ignored in geofences mode")
|
|
165
|
+
else -> {
|
|
166
|
+
Logger.i(TAG, "changePace($isMoving)")
|
|
167
|
+
val location = if (isMoving != s.motion.isMoving) bestKnownLocation(s) else null
|
|
168
|
+
execute(s, s.motion.force(isMoving, location))
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
override fun state(): State = State(configStore.config.value, configStore.runtime.value, providers.kind)
|
|
174
|
+
|
|
175
|
+
override suspend fun restore(reason: String) = serialized { restoreLocked(reason) }
|
|
176
|
+
|
|
177
|
+
override suspend fun endWithoutRestore(reason: String) = serialized {
|
|
178
|
+
if (session != null || !runtime.enabled) return@serialized
|
|
179
|
+
Logger.i(TAG, "tracking is not resumed after $reason (app.startOnBoot is false)")
|
|
180
|
+
stopLocked(reason)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* The `tracking_stop` reason is `permission_denied` when foreground location permission is no longer granted
|
|
185
|
+
* (on Android 14+ the system's START_STICKY restart after a revoked permission fails in `startForeground`),
|
|
186
|
+
* otherwise `service_start_failed`.
|
|
187
|
+
*/
|
|
188
|
+
override suspend fun onServiceStartFailed(error: String) = serialized {
|
|
189
|
+
if (session == null && !runtime.enabled) return@serialized
|
|
190
|
+
val reason = if (permissions.hasForegroundLocation()) REASON_SERVICE_START_FAILED else REASON_PERMISSION_DENIED
|
|
191
|
+
Logger.w(TAG, "the foreground service failed to start ($error); stopping tracking with reason $reason")
|
|
192
|
+
stopLocked(reason)
|
|
193
|
+
if (reason == REASON_SERVICE_START_FAILED) offerResume()
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
override suspend fun resumeFromNotification() = serialized {
|
|
197
|
+
guarded("serviceController.cancelResumeNotification") { serviceController.cancelResumeNotification() }
|
|
198
|
+
val deadline = elapsedStopAt()
|
|
199
|
+
when {
|
|
200
|
+
session != null -> Logger.i(TAG, "resume notification: tracking already runs")
|
|
201
|
+
runtime.enabled -> restoreLocked(REASON_RESUME_NOTIFICATION)
|
|
202
|
+
!config.notification.resume.enabled -> stopResumeService("notification.resume is disabled")
|
|
203
|
+
deadline != null && deadline <= clock.now() -> stopResumeService("stopAfterElapsedMinutes has passed")
|
|
204
|
+
!permissions.hasForegroundLocation() -> stopResumeService("location permission is not granted")
|
|
205
|
+
else -> {
|
|
206
|
+
Logger.i(TAG, "resume notification: resuming ${runtime.trackingMode.wire} tracking")
|
|
207
|
+
configStore.updateRuntime { it.copy(enabled = true) }
|
|
208
|
+
restoreLocked(REASON_RESUME_NOTIFICATION)
|
|
209
|
+
if (session != null) events.emit(TrackingEvent.EnabledChange(true))
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
private fun stopResumeService(why: String) {
|
|
215
|
+
Logger.i(TAG, "resume notification: not resuming ($why)")
|
|
216
|
+
guarded("serviceController.stop") { serviceController.stop() }
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* After Android refused to restore tracking from the background: posts the resume notification if
|
|
221
|
+
* `notification.resume.enabled`, unless the session's `stopAfterElapsedMinutes` has already passed.
|
|
222
|
+
*/
|
|
223
|
+
private fun offerResume() {
|
|
224
|
+
if (!config.notification.resume.enabled) return
|
|
225
|
+
val deadline = elapsedStopAt()
|
|
226
|
+
if (deadline != null && deadline <= clock.now()) {
|
|
227
|
+
Logger.i(TAG, "no resume notification: stopAfterElapsedMinutes has passed")
|
|
228
|
+
return
|
|
229
|
+
}
|
|
230
|
+
guarded("serviceController.showResumeNotification") { serviceController.showResumeNotification(deadline) }
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** When `stopAfterElapsedMinutes` ends the session started at `trackingStartedAt`; null without a limit. */
|
|
234
|
+
private fun elapsedStopAt(): Long? {
|
|
235
|
+
val minutes = config.geolocation.stopAfterElapsedMinutes
|
|
236
|
+
val startedAt = runtime.trackingStartedAt ?: return null
|
|
237
|
+
return if (minutes > 0) startedAt + minutes * MINUTE_MS else null
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
override suspend fun onTerminate() = serialized {
|
|
241
|
+
when {
|
|
242
|
+
!config.app.stopOnTerminate -> Logger.i(TAG, "task removed: stopOnTerminate is false; tracking continues")
|
|
243
|
+
session != null || runtime.enabled -> {
|
|
244
|
+
Logger.i(TAG, "task removed: stopOnTerminate is true; stopping")
|
|
245
|
+
stopLocked(REASON_TERMINATE)
|
|
246
|
+
}
|
|
247
|
+
else -> Unit
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ------------------------------------------------------------------ ActivitySink
|
|
252
|
+
|
|
253
|
+
override suspend fun onActivitySamples(samples: List<ActivitySample>) {
|
|
254
|
+
if (samples.isEmpty()) return
|
|
255
|
+
serialized { handleActivity(samples) }
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ------------------------------------------------------------------ StationaryRegionSink
|
|
259
|
+
|
|
260
|
+
override suspend fun onStationaryRegionTransition(transition: OsGeofenceTransition) = serialized {
|
|
261
|
+
handleStationaryRegionTransition(transition)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ------------------------------------------------------------------ start / stop / restore
|
|
265
|
+
|
|
266
|
+
private suspend fun startLocked(mode: TrackingMode) {
|
|
267
|
+
val reason = if (mode == TrackingMode.LOCATION) REASON_START else REASON_START_GEOFENCES
|
|
268
|
+
val current = session
|
|
269
|
+
if (current != null) {
|
|
270
|
+
if (current.mode == mode) {
|
|
271
|
+
Logger.d(TAG, "${mode.wire} tracking is already running")
|
|
272
|
+
} else {
|
|
273
|
+
switchMode(current, mode, reason)
|
|
274
|
+
}
|
|
275
|
+
return
|
|
276
|
+
}
|
|
277
|
+
if (!permissions.hasForegroundLocation()) {
|
|
278
|
+
throw TrackingException(ErrorCode.PERMISSION_DENIED, "Location permission is not granted")
|
|
279
|
+
}
|
|
280
|
+
if (mode == TrackingMode.GEOFENCES && !permissions.hasBackgroundLocation()) {
|
|
281
|
+
Logger.w(TAG, "startGeofences without background location permission: geofences may not fire in background")
|
|
282
|
+
}
|
|
283
|
+
val wasEnabled = runtime.enabled
|
|
284
|
+
val startedAt = clock.now()
|
|
285
|
+
configStore.updateRuntime {
|
|
286
|
+
it.copy(enabled = true, trackingMode = mode, isMoving = false, trackingStartedAt = startedAt)
|
|
287
|
+
}
|
|
288
|
+
if (!startService()) {
|
|
289
|
+
// Foreground location is granted (checked above): Android refused the foreground service, for example a
|
|
290
|
+
// start from the background, or Android 14+ with only while-in-use location permission. The error code
|
|
291
|
+
// stays PERMISSION_DENIED for the JS API; the audit record gives the precise reason.
|
|
292
|
+
configStore.updateRuntime { it.copy(enabled = false) }
|
|
293
|
+
if (wasEnabled) releaseOrphanedRegistrations()
|
|
294
|
+
submit(RecordEvent.TRACKING_STOP, bestKnownLocation(null), reason = REASON_SERVICE_START_FAILED)
|
|
295
|
+
throw TrackingException(
|
|
296
|
+
ErrorCode.PERMISSION_DENIED,
|
|
297
|
+
"The location foreground service could not be started (Android refused a start from the background)",
|
|
298
|
+
)
|
|
299
|
+
}
|
|
300
|
+
// A process that died while tracking may have left its stationary region with the OS.
|
|
301
|
+
val s = activate(mode, reason, leftoverRegion = wasEnabled)
|
|
302
|
+
events.emit(TrackingEvent.EnabledChange(true))
|
|
303
|
+
if (mode == TrackingMode.LOCATION) launchInitialFix(s)
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Cold process with `runtime.enabled`: restarts everything in the persisted mode, without permission prompts. If the
|
|
308
|
+
* session's `stopAfterElapsedMinutes` deadline has passed, it records that stop instead and starts nothing.
|
|
309
|
+
*/
|
|
310
|
+
private suspend fun restoreLocked(reason: String) {
|
|
311
|
+
if (!runtime.enabled) {
|
|
312
|
+
Logger.i(TAG, "restore($reason): tracking is not enabled; nothing to restore")
|
|
313
|
+
return
|
|
314
|
+
}
|
|
315
|
+
if (session != null) {
|
|
316
|
+
if (!serviceController.isRunning) {
|
|
317
|
+
Logger.w(TAG, "restore($reason): tracking runs but the foreground service does not; restarting it")
|
|
318
|
+
if (!startService()) {
|
|
319
|
+
Logger.w(TAG, "restore($reason): the foreground service was refused; stopping tracking")
|
|
320
|
+
stopLocked(REASON_SERVICE_START_FAILED)
|
|
321
|
+
offerResume()
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
return
|
|
325
|
+
}
|
|
326
|
+
// The session ended while no process ran its timer (the phone was off, or the app was killed or updated).
|
|
327
|
+
val deadline = elapsedStopAt()
|
|
328
|
+
if (deadline != null && deadline <= clock.now()) {
|
|
329
|
+
Logger.i(TAG, "restore($reason): stopAfterElapsedMinutes passed while tracking was not running; stopping")
|
|
330
|
+
stopLocked(REASON_STOP_AFTER_ELAPSED)
|
|
331
|
+
return
|
|
332
|
+
}
|
|
333
|
+
if (!permissions.hasForegroundLocation()) {
|
|
334
|
+
Logger.w(TAG, "restore($reason): location permission is no longer granted; stopping")
|
|
335
|
+
stopLocked(REASON_PERMISSION_DENIED)
|
|
336
|
+
return
|
|
337
|
+
}
|
|
338
|
+
val mode = runtime.trackingMode
|
|
339
|
+
val startedAt = runtime.trackingStartedAt ?: clock.now()
|
|
340
|
+
configStore.updateRuntime {
|
|
341
|
+
it.copy(enabled = true, trackingMode = mode, isMoving = false, trackingStartedAt = startedAt)
|
|
342
|
+
}
|
|
343
|
+
// The system may have restarted the service itself (START_STICKY), which then calls restore(): starting it again
|
|
344
|
+
// from the background could be refused on Android 12+ although it is already in the foreground.
|
|
345
|
+
if (!serviceController.isRunning && !startService()) {
|
|
346
|
+
// Without the foreground service Android throttles background location and the heartbeat alarms would
|
|
347
|
+
// keep retrying; end the session with an explicit audit record instead.
|
|
348
|
+
Logger.w(TAG, "restore($reason): the foreground service was refused; stopping tracking")
|
|
349
|
+
stopLocked(REASON_SERVICE_START_FAILED)
|
|
350
|
+
offerResume()
|
|
351
|
+
return
|
|
352
|
+
}
|
|
353
|
+
// GMS and HMS keep geofences across a process death: the stationary region of the dead process may remain.
|
|
354
|
+
val s = activate(mode, reason, leftoverRegion = true)
|
|
355
|
+
if (mode == TrackingMode.LOCATION) launchInitialFix(s)
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Starts a new session: components, backends, location request, then the `tracking_start` record.
|
|
360
|
+
* [leftoverRegion]: an earlier process may have left the stationary region registered with the OS.
|
|
361
|
+
*/
|
|
362
|
+
private suspend fun activate(mode: TrackingMode, reason: String, leftoverRegion: Boolean): Session {
|
|
363
|
+
val s = Session(nextSessionId++, mode, MotionSettings.from(config))
|
|
364
|
+
s.leftoverRegion = leftoverRegion
|
|
365
|
+
session = s
|
|
366
|
+
guarded("processor.reset") { processor.reset() }
|
|
367
|
+
guarded("device.start") { device.start() }
|
|
368
|
+
guarded("syncer.start") { syncer.start() }
|
|
369
|
+
guarded("heartbeat.start") { heartbeat.start() }
|
|
370
|
+
guarded("geofences.onTrackingStarted") { geofences.onTrackingStarted(mode) }
|
|
371
|
+
configureMode(s)
|
|
372
|
+
watchGeofenceDemand(s)
|
|
373
|
+
s.eventSubscription = events.subscribe { event -> onEvent(s, event) }
|
|
374
|
+
guarded("serviceController.cancelResumeNotification") { serviceController.cancelResumeNotification() }
|
|
375
|
+
submit(RecordEvent.TRACKING_START, bestKnownLocation(s), reason = reason)
|
|
376
|
+
Logger.i(TAG, "tracking started: session=${s.id} mode=${mode.wire} reason=$reason backend=${providers.kind.wire}")
|
|
377
|
+
return s
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** `start()` while `startGeofences()` runs, or the reverse. */
|
|
381
|
+
private suspend fun switchMode(s: Session, mode: TrackingMode, reason: String) {
|
|
382
|
+
if (!permissions.hasForegroundLocation()) {
|
|
383
|
+
throw TrackingException(ErrorCode.PERMISSION_DENIED, "Location permission is not granted")
|
|
384
|
+
}
|
|
385
|
+
Logger.i(TAG, "switching tracking mode ${s.mode.wire} -> ${mode.wire}")
|
|
386
|
+
s.initialFixJob?.cancel()
|
|
387
|
+
if (s.mode == TrackingMode.LOCATION && s.motion.isMoving) {
|
|
388
|
+
// Motion tracking ends with the location mode.
|
|
389
|
+
recordMotionChange(s, isMoving = false, bestKnownLocation(s))
|
|
390
|
+
}
|
|
391
|
+
s.mode = mode
|
|
392
|
+
val startedAt = clock.now()
|
|
393
|
+
configStore.updateRuntime { it.copy(trackingMode = mode, isMoving = false, trackingStartedAt = startedAt) }
|
|
394
|
+
guarded("geofences.onTrackingStarted") { geofences.onTrackingStarted(mode) }
|
|
395
|
+
configureMode(s)
|
|
396
|
+
submit(RecordEvent.TRACKING_START, bestKnownLocation(s), reason = reason)
|
|
397
|
+
if (mode == TrackingMode.LOCATION) launchInitialFix(s)
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Resets the motion state to STATIONARY (without an anchor until the initial fix) and applies the mode's activity
|
|
402
|
+
* updates, stationary region, request and timers.
|
|
403
|
+
*/
|
|
404
|
+
private suspend fun configureMode(s: Session) {
|
|
405
|
+
timers.cancel(TimerKind.STOP_TIMEOUT)
|
|
406
|
+
timers.cancel(TimerKind.MOTION_TRIGGER)
|
|
407
|
+
s.motion.reset()
|
|
408
|
+
s.motionRecorded = false
|
|
409
|
+
s.lastRecorded = null
|
|
410
|
+
s.lastActivityType = null
|
|
411
|
+
if (s.mode == TrackingMode.LOCATION) startActivityUpdates(s) else stopActivityUpdates(s)
|
|
412
|
+
s.needsContinuousLocation = geofences.needsContinuousLocation.value
|
|
413
|
+
applyMotionRequests(s)
|
|
414
|
+
scheduleElapsedStop(s)
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Stops tracking: data sources first, then the `tracking_stop` record (before the service stops, so it is
|
|
419
|
+
* queued and its upload starts while the process still has foreground priority), then the service,
|
|
420
|
+
* heartbeat and geofences. Without a session (cold process) it releases what the previous process left
|
|
421
|
+
* registered with the OS.
|
|
422
|
+
*/
|
|
423
|
+
private suspend fun stopLocked(reason: String) {
|
|
424
|
+
val s = session
|
|
425
|
+
if (s == null && !runtime.enabled) {
|
|
426
|
+
Logger.d(TAG, "stop($reason): tracking is not enabled")
|
|
427
|
+
return
|
|
428
|
+
}
|
|
429
|
+
session = null
|
|
430
|
+
timers.cancelAll()
|
|
431
|
+
if (s != null) {
|
|
432
|
+
s.initialFixJob?.cancel()
|
|
433
|
+
s.geofenceWatchJob?.cancel()
|
|
434
|
+
s.eventSubscription?.cancel()
|
|
435
|
+
removeLocationUpdates(s)
|
|
436
|
+
stopActivityUpdates(s)
|
|
437
|
+
} else {
|
|
438
|
+
stopActivityBackend()
|
|
439
|
+
}
|
|
440
|
+
submit(RecordEvent.TRACKING_STOP, bestKnownLocation(s), reason = reason)
|
|
441
|
+
// After the record, so a slow OS call cannot delay it. Without a session (cold process): GMS and HMS keep
|
|
442
|
+
// geofences across a process death, so the previous process may have left the region.
|
|
443
|
+
if (s != null) removeStationaryRegion(s) else removeStationaryRegionFrom(null)
|
|
444
|
+
guarded("serviceController.stop") { serviceController.stop() }
|
|
445
|
+
guarded("heartbeat.stop") { heartbeat.stop() }
|
|
446
|
+
guarded("geofences.onTrackingStopped") { geofences.onTrackingStopped() }
|
|
447
|
+
configStore.updateRuntime { it.copy(enabled = false, isMoving = false) }
|
|
448
|
+
events.emit(TrackingEvent.EnabledChange(false))
|
|
449
|
+
Logger.i(TAG, "tracking stopped: reason=$reason")
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** A refused start in a cold process: drop the previous process's activity, alarm and geofence registrations. */
|
|
453
|
+
/**
|
|
454
|
+
* A background trigger ([trigger]) woke a process in which tracking is enabled but not running. Restores tracking,
|
|
455
|
+
* except in a process that started after a user force stop: then it releases the leftover OS registrations (so they
|
|
456
|
+
* stop waking the app) and leaves `enabled` set, so opening the app (`ready()`) restores tracking. Returns whether
|
|
457
|
+
* a session runs afterwards.
|
|
458
|
+
*/
|
|
459
|
+
private suspend fun restoreFromBackgroundTrigger(trigger: String): Boolean {
|
|
460
|
+
if (forceStopProbe.startedAfterForceStop()) {
|
|
461
|
+
Logger.i(TAG, "$trigger after a force stop: tracking stays off until the app is opened")
|
|
462
|
+
releaseOrphanedRegistrations()
|
|
463
|
+
return false
|
|
464
|
+
}
|
|
465
|
+
Logger.i(TAG, "$trigger in a process where tracking is enabled but not running; restoring")
|
|
466
|
+
restoreLocked(REASON_RESTORE)
|
|
467
|
+
return session != null
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
private suspend fun releaseOrphanedRegistrations() {
|
|
471
|
+
stopActivityBackend()
|
|
472
|
+
removeStationaryRegionFrom(null)
|
|
473
|
+
guarded("heartbeat.stop") { heartbeat.stop() }
|
|
474
|
+
guarded("geofences.onTrackingStopped") { geofences.onTrackingStopped() }
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
private fun startService(): Boolean = try {
|
|
478
|
+
serviceController.start()
|
|
479
|
+
} catch (e: Exception) {
|
|
480
|
+
Logger.e(TAG, "foreground service start failed", e)
|
|
481
|
+
false
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// ------------------------------------------------------------------ config changes
|
|
485
|
+
|
|
486
|
+
private suspend fun onConfigChanged(old: Config, new: Config) {
|
|
487
|
+
if (old == new) return
|
|
488
|
+
val s = session
|
|
489
|
+
val providerChanged = old.locationProvider != new.locationProvider
|
|
490
|
+
if (providerChanged) switchProvider(s, old, new)
|
|
491
|
+
if (old.notification.resume.enabled && !new.notification.resume.enabled) {
|
|
492
|
+
guarded("serviceController.cancelResumeNotification") { serviceController.cancelResumeNotification() }
|
|
493
|
+
}
|
|
494
|
+
if (s == null) return
|
|
495
|
+
if (!providerChanged && activitySettingsChanged(old, new)) {
|
|
496
|
+
stopActivityUpdates(s)
|
|
497
|
+
startActivityUpdates(s)
|
|
498
|
+
}
|
|
499
|
+
execute(s, s.motion.updateSettings(MotionSettings.from(new)))
|
|
500
|
+
if (session !== s) return
|
|
501
|
+
applyMotionRequests(s)
|
|
502
|
+
if (old.geolocation.stopAfterElapsedMinutes != new.geolocation.stopAfterElapsedMinutes) scheduleElapsedStop(s)
|
|
503
|
+
if (old.notification != new.notification) {
|
|
504
|
+
guarded("serviceController.refreshNotification") { serviceController.refreshNotification() }
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
private fun activitySettingsChanged(old: Config, new: Config): Boolean =
|
|
509
|
+
old.activity.disableMotionActivityUpdates != new.activity.disableMotionActivityUpdates ||
|
|
510
|
+
old.activity.activityRecognitionInterval != new.activity.activityRecognitionInterval
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* `locationProvider` changed: re-selects the backend. While tracking, listeners, activity updates, the
|
|
514
|
+
* stationary region and geofences are removed from the current backend first and registered again on the
|
|
515
|
+
* selected one.
|
|
516
|
+
*/
|
|
517
|
+
private suspend fun switchProvider(s: Session?, old: Config, new: Config) {
|
|
518
|
+
if (s != null) {
|
|
519
|
+
removeLocationUpdates(s)
|
|
520
|
+
stopActivityUpdates(s)
|
|
521
|
+
removeStationaryRegion(s)
|
|
522
|
+
guarded("geofences.onTrackingStopped") { geofences.onTrackingStopped() }
|
|
523
|
+
}
|
|
524
|
+
val changed = try {
|
|
525
|
+
providers.reselect()
|
|
526
|
+
} catch (e: Exception) {
|
|
527
|
+
Logger.e(TAG, "provider reselection failed", e)
|
|
528
|
+
false
|
|
529
|
+
}
|
|
530
|
+
Logger.i(
|
|
531
|
+
TAG,
|
|
532
|
+
"locationProvider ${old.locationProvider.wire} -> ${new.locationProvider.wire}: " +
|
|
533
|
+
"backend ${providers.kind.wire}${if (changed) " (changed)" else " (unchanged)"}",
|
|
534
|
+
)
|
|
535
|
+
if (s != null) {
|
|
536
|
+
guarded("geofences.onTrackingStarted") { geofences.onTrackingStarted(s.mode) }
|
|
537
|
+
startActivityUpdates(s)
|
|
538
|
+
// The region is registered on the selected backend, also after a failure on the previous one.
|
|
539
|
+
applyMotionRequests(s, force = true)
|
|
540
|
+
}
|
|
541
|
+
if (changed) guarded("device.checkProviderState") { device.checkProviderState(PROVIDER_CHECK_REASON) }
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// ------------------------------------------------------------------ backends
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* Brings the location request and the stationary region in line with the motion state. When the configured
|
|
548
|
+
* (MOVING) request is wanted it is applied first, so GPS is on before the OS call that removes the region.
|
|
549
|
+
* [force] and [checkLimit]: see [syncStationaryRegion].
|
|
550
|
+
*/
|
|
551
|
+
private suspend fun applyMotionRequests(s: Session, force: Boolean = false, checkLimit: Boolean = false) {
|
|
552
|
+
if (!isStationary(s)) applyLocationRequest(s)
|
|
553
|
+
syncStationaryRegion(s, force, checkLimit)
|
|
554
|
+
applyLocationRequest(s)
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/** STATIONARY in the LOCATION mode: the state in which the stationary region is wanted. */
|
|
558
|
+
private fun isStationary(s: Session): Boolean =
|
|
559
|
+
session === s && s.mode == TrackingMode.LOCATION && !s.motion.isMoving
|
|
560
|
+
|
|
561
|
+
private fun applyLocationRequest(s: Session) {
|
|
562
|
+
val spec = LocationRequests.desired(
|
|
563
|
+
s.mode,
|
|
564
|
+
s.motion.isMoving,
|
|
565
|
+
s.needsContinuousLocation,
|
|
566
|
+
config.geolocation,
|
|
567
|
+
// PASSIVE while the OS watches the region, and while the initial fix (its own request) is pending.
|
|
568
|
+
passive = s.region != null || !s.motionRecorded,
|
|
569
|
+
)
|
|
570
|
+
val backend = providers.location()
|
|
571
|
+
val current = s.locationBackend
|
|
572
|
+
if (spec == s.appliedSpec && (spec == null || current === backend)) return
|
|
573
|
+
if (current != null && (spec == null || current !== backend)) removeLocationUpdates(s)
|
|
574
|
+
if (spec == null) {
|
|
575
|
+
Logger.d(TAG, "location request: none")
|
|
576
|
+
return
|
|
577
|
+
}
|
|
578
|
+
try {
|
|
579
|
+
backend.requestUpdates(spec, s.listener)
|
|
580
|
+
s.locationBackend = backend
|
|
581
|
+
s.appliedSpec = spec
|
|
582
|
+
Logger.d(TAG, "location request: $spec on ${backend.kind.wire}")
|
|
583
|
+
} catch (e: Exception) {
|
|
584
|
+
Logger.e(TAG, "location request failed", e)
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
private fun removeLocationUpdates(s: Session) {
|
|
589
|
+
val backend = s.locationBackend ?: return
|
|
590
|
+
s.locationBackend = null
|
|
591
|
+
s.appliedSpec = null
|
|
592
|
+
guarded("removeUpdates") { backend.removeUpdates(s.listener) }
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* Registers the stationary region while the session is STATIONARY in the LOCATION mode, and removes it otherwise.
|
|
597
|
+
*
|
|
598
|
+
* The region is centred on the motion anchor and is registered only when:
|
|
599
|
+
* - the initial `motionchange` after start / restore has been recorded (so the fresh initial fix is the centre);
|
|
600
|
+
* - the anchor was current when it became the anchor (see [isAnchorConfirmed]): with `initialTriggerEntry =
|
|
601
|
+
* false` the OS never reports EXIT for a device that is already outside, so a region around an old fix could
|
|
602
|
+
* leave the device STATIONARY with GPS off after it moved away;
|
|
603
|
+
* - it is not up to date already: registered on the current backend with the configured radius and a centre at
|
|
604
|
+
* most [REGION_RECENTER_M] from the anchor. [force] registers it again anyway (location services came back,
|
|
605
|
+
* and GMS / HMS may have dropped it);
|
|
606
|
+
* - no earlier registration of this stationary period failed (not retried until [force] or leaving STATIONARY);
|
|
607
|
+
* - the user geofences leave the OS a free slot (see [userGeofenceSlotsFull]; checked before registering and,
|
|
608
|
+
* with [checkLimit], for a registered region, which is then removed).
|
|
609
|
+
* Otherwise STATIONARY uses the low-power fallback request. Never throws, except cancellation.
|
|
610
|
+
*/
|
|
611
|
+
private suspend fun syncStationaryRegion(s: Session, force: Boolean = false, checkLimit: Boolean = false) {
|
|
612
|
+
if (!isStationary(s)) {
|
|
613
|
+
removeStationaryRegion(s)
|
|
614
|
+
s.regionFailed = false
|
|
615
|
+
return
|
|
616
|
+
}
|
|
617
|
+
if (!s.motionRecorded) return
|
|
618
|
+
val anchor = s.motion.anchor ?: return
|
|
619
|
+
if (checkLimit && s.region != null && userGeofenceSlotsFull()) {
|
|
620
|
+
Logger.w(TAG, "the user geofences need every OS geofence slot: removing the stationary region")
|
|
621
|
+
removeStationaryRegion(s)
|
|
622
|
+
return
|
|
623
|
+
}
|
|
624
|
+
val backend = try {
|
|
625
|
+
providers.geofence()
|
|
626
|
+
} catch (e: CancellationException) {
|
|
627
|
+
throw e
|
|
628
|
+
} catch (e: Exception) {
|
|
629
|
+
Logger.w(TAG, "no geofence backend for the stationary region; using the low-power request instead", e)
|
|
630
|
+
s.region = null
|
|
631
|
+
s.regionFailed = true
|
|
632
|
+
return
|
|
633
|
+
}
|
|
634
|
+
val radius = StationaryRegion.radius(config.geolocation.stationaryRadius)
|
|
635
|
+
val registered = s.region
|
|
636
|
+
val upToDate = registered != null &&
|
|
637
|
+
registered.radius == radius &&
|
|
638
|
+
s.regionBackend?.kind == backend.kind &&
|
|
639
|
+
distanceMeters(registered.latitude, registered.longitude, anchor.latitude, anchor.longitude) <=
|
|
640
|
+
REGION_RECENTER_M
|
|
641
|
+
if (upToDate && !force) return
|
|
642
|
+
if (s.regionFailed && !force) return
|
|
643
|
+
if (!isAnchorConfirmed(s, anchor)) {
|
|
644
|
+
Logger.i(TAG, "stationary anchor is ${ageMs(anchor) / 1000} s old: low-power requests until a current fix")
|
|
645
|
+
return
|
|
646
|
+
}
|
|
647
|
+
if (userGeofenceSlotsFull()) {
|
|
648
|
+
Logger.w(TAG, "the user geofences need every OS geofence slot: no stationary region, low-power requests")
|
|
649
|
+
removeStationaryRegion(s)
|
|
650
|
+
return
|
|
651
|
+
}
|
|
652
|
+
if (s.regionBackend?.let { it.kind != backend.kind } == true) removeStationaryRegion(s)
|
|
653
|
+
registerStationaryRegion(s, backend, StationaryRegion.around(anchor, config.geolocation.stationaryRadius))
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* True if [anchor] was current (at most [ANCHOR_MAX_AGE_MS] old) when it became the anchor. The device is
|
|
658
|
+
* believed not to have moved since, so a confirmed anchor stays confirmed for the whole stationary period.
|
|
659
|
+
*/
|
|
660
|
+
private fun isAnchorConfirmed(s: Session, anchor: TrackedLocation): Boolean {
|
|
661
|
+
if (anchor === s.confirmedAnchor) return true
|
|
662
|
+
if (!isCurrent(anchor)) return false
|
|
663
|
+
s.confirmedAnchor = anchor
|
|
664
|
+
return true
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/** At most [ANCHOR_MAX_AGE_MS] old by the wall clock (a fix from the future counts as current). */
|
|
668
|
+
private fun isCurrent(fix: TrackedLocation): Boolean = ageMs(fix) <= ANCHOR_MAX_AGE_MS
|
|
669
|
+
|
|
670
|
+
private fun ageMs(fix: TrackedLocation): Long = clock.now() - fix.time
|
|
671
|
+
|
|
672
|
+
/**
|
|
673
|
+
* True when the stored user geofences need every OS slot but one ([Constants.MAX_GEOFENCES] - 1 or more): GMS
|
|
674
|
+
* accepts at most 100 geofences per app, and the stationary region must not make the user's last `add` fail.
|
|
675
|
+
*/
|
|
676
|
+
private suspend fun userGeofenceSlotsFull(): Boolean = try {
|
|
677
|
+
geofences.list().size >= Constants.MAX_GEOFENCES - 1
|
|
678
|
+
} catch (e: CancellationException) {
|
|
679
|
+
throw e
|
|
680
|
+
} catch (e: Exception) {
|
|
681
|
+
Logger.w(TAG, "cannot count the user geofences", e)
|
|
682
|
+
false
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/** Adds [region] to [backend] (adding the same id replaces it); on failure the low-power fallback applies. */
|
|
686
|
+
private suspend fun registerStationaryRegion(s: Session, backend: GeofenceBackend, region: OsGeofence) {
|
|
687
|
+
// Remembered before the call: after a timeout the OS may still complete the registration.
|
|
688
|
+
s.regionBackend = backend
|
|
689
|
+
s.leftoverRegion = false // the same id: a region left by an earlier process is replaced
|
|
690
|
+
val failure: Exception? = try {
|
|
691
|
+
val done = withTimeoutOrNull(OS_GEOFENCE_TIMEOUT_MS) { backend.add(listOf(region)) }
|
|
692
|
+
if (done != null) {
|
|
693
|
+
null
|
|
694
|
+
} else {
|
|
695
|
+
TrackingException(ErrorCode.TIMEOUT, "no answer within $OS_GEOFENCE_TIMEOUT_MS ms")
|
|
696
|
+
}
|
|
697
|
+
} catch (e: CancellationException) {
|
|
698
|
+
throw e
|
|
699
|
+
} catch (e: Exception) {
|
|
700
|
+
e
|
|
701
|
+
}
|
|
702
|
+
if (failure == null) {
|
|
703
|
+
s.region = region
|
|
704
|
+
s.regionFailed = false
|
|
705
|
+
Logger.i(
|
|
706
|
+
TAG,
|
|
707
|
+
"stationary region registered on ${backend.kind.wire}: radius ${region.radius} m around " +
|
|
708
|
+
"(${region.latitude}, ${region.longitude}); GPS off, passive location updates",
|
|
709
|
+
)
|
|
710
|
+
} else {
|
|
711
|
+
s.region = null
|
|
712
|
+
s.regionFailed = true
|
|
713
|
+
Logger.w(
|
|
714
|
+
TAG,
|
|
715
|
+
"stationary region could not be registered on ${backend.kind.wire} (${failure.message}); " +
|
|
716
|
+
"using a low-power request (at most one fix per 3 minutes) instead of passive updates",
|
|
717
|
+
failure,
|
|
718
|
+
)
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/**
|
|
723
|
+
* Removes the stationary region this session registered (or tried to register), or one an earlier process may
|
|
724
|
+
* have left ([Session.leftoverRegion]), from the OS.
|
|
725
|
+
*/
|
|
726
|
+
private suspend fun removeStationaryRegion(s: Session) {
|
|
727
|
+
val backend = s.regionBackend
|
|
728
|
+
if (backend == null && !s.leftoverRegion) return
|
|
729
|
+
s.regionBackend = null
|
|
730
|
+
s.region = null
|
|
731
|
+
s.leftoverRegion = false
|
|
732
|
+
removeStationaryRegionFrom(backend)
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/**
|
|
736
|
+
* Removes [Constants.STATIONARY_REGION_ID] from [backend] (null: the current backend). Removing an id that is not
|
|
737
|
+
* registered is harmless. Failures are logged, never thrown (except cancellation).
|
|
738
|
+
*/
|
|
739
|
+
private suspend fun removeStationaryRegionFrom(backend: GeofenceBackend?) {
|
|
740
|
+
try {
|
|
741
|
+
val target = backend ?: providers.geofence()
|
|
742
|
+
val done = withTimeoutOrNull(OS_GEOFENCE_TIMEOUT_MS) {
|
|
743
|
+
target.remove(listOf(Constants.STATIONARY_REGION_ID))
|
|
744
|
+
}
|
|
745
|
+
if (done == null) {
|
|
746
|
+
Logger.w(TAG, "removing the stationary region got no answer within $OS_GEOFENCE_TIMEOUT_MS ms")
|
|
747
|
+
} else {
|
|
748
|
+
Logger.d(TAG, "stationary region removed from ${target.kind.wire}")
|
|
749
|
+
}
|
|
750
|
+
} catch (e: CancellationException) {
|
|
751
|
+
throw e
|
|
752
|
+
} catch (e: Exception) {
|
|
753
|
+
Logger.w(TAG, "failed to remove the stationary region", e)
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
private fun startActivityUpdates(s: Session) {
|
|
758
|
+
if (s.mode != TrackingMode.LOCATION || s.activityBackend != null) return
|
|
759
|
+
val settings = config.activity
|
|
760
|
+
if (settings.disableMotionActivityUpdates) {
|
|
761
|
+
Logger.d(TAG, "activity updates disabled by config")
|
|
762
|
+
return
|
|
763
|
+
}
|
|
764
|
+
val backend = providers.activity()
|
|
765
|
+
if (!backend.isSupported) {
|
|
766
|
+
Logger.i(TAG, "activity recognition is not supported by the ${backend.kind.wire} backend")
|
|
767
|
+
return
|
|
768
|
+
}
|
|
769
|
+
if (!permissions.hasActivityRecognition()) {
|
|
770
|
+
Logger.w(TAG, "activity recognition permission is not granted; motion detection uses location only")
|
|
771
|
+
return
|
|
772
|
+
}
|
|
773
|
+
val started = try {
|
|
774
|
+
backend.start(settings.activityRecognitionInterval)
|
|
775
|
+
} catch (e: Exception) {
|
|
776
|
+
Logger.e(TAG, "activity updates failed to start", e)
|
|
777
|
+
false
|
|
778
|
+
}
|
|
779
|
+
if (started) s.activityBackend = backend else Logger.w(TAG, "activity updates could not be requested")
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
private fun stopActivityUpdates(s: Session) {
|
|
783
|
+
val backend = s.activityBackend ?: return
|
|
784
|
+
s.activityBackend = null
|
|
785
|
+
guarded("activity.stop") { backend.stop() }
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
private fun stopActivityBackend() {
|
|
789
|
+
guarded("activity.stop") {
|
|
790
|
+
val backend = providers.activity()
|
|
791
|
+
if (backend.isSupported) backend.stop()
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
/** Polygon geofences need continuous fixes while the device is inside one's enclosing circle. */
|
|
796
|
+
private fun watchGeofenceDemand(s: Session) {
|
|
797
|
+
s.geofenceWatchJob?.cancel()
|
|
798
|
+
s.geofenceWatchJob = scope.launch {
|
|
799
|
+
geofences.needsContinuousLocation.collect { needs ->
|
|
800
|
+
mutex.withLock {
|
|
801
|
+
if (session === s && s.needsContinuousLocation != needs) {
|
|
802
|
+
s.needsContinuousLocation = needs
|
|
803
|
+
Logger.d(TAG, "polygon geofences ${if (needs) "need" else "no longer need"} continuous location")
|
|
804
|
+
applyLocationRequest(s)
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
private fun scheduleElapsedStop(s: Session) {
|
|
812
|
+
val minutes = config.geolocation.stopAfterElapsedMinutes
|
|
813
|
+
if (minutes <= 0) {
|
|
814
|
+
timers.cancel(TimerKind.STOP_AFTER_ELAPSED)
|
|
815
|
+
return
|
|
816
|
+
}
|
|
817
|
+
val startedAt = runtime.trackingStartedAt ?: clock.now()
|
|
818
|
+
timers.schedule(TimerKind.STOP_AFTER_ELAPSED, startedAt + minutes * MINUTE_MS - clock.now()) {
|
|
819
|
+
if (session === s) {
|
|
820
|
+
Logger.i(TAG, "stopAfterElapsedMinutes ($minutes) reached")
|
|
821
|
+
guarded("stop after elapsed") { stopLocked(REASON_STOP_AFTER_ELAPSED) }
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
// ------------------------------------------------------------------ location pipeline
|
|
827
|
+
|
|
828
|
+
private fun dispatchLocations(s: Session, locations: List<TrackedLocation>) {
|
|
829
|
+
if (locations.isEmpty()) return
|
|
830
|
+
val batch = locations.toList()
|
|
831
|
+
scope.launch {
|
|
832
|
+
mutex.withLock {
|
|
833
|
+
for (raw in batch) {
|
|
834
|
+
if (session !== s) return@withLock
|
|
835
|
+
guarded("location fix") { handleFix(s, raw) }
|
|
836
|
+
}
|
|
837
|
+
// After the batch, so motion evidence in it wins over a stop timeout that expired while asleep.
|
|
838
|
+
fireDueTimers(s)
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
/**
|
|
844
|
+
* `delay()` does not advance while the CPU sleeps, so overdue timers are also checked on every fix, activity
|
|
845
|
+
* sample and heartbeat (whose alarm wakes the device every few minutes).
|
|
846
|
+
*/
|
|
847
|
+
private suspend fun fireDueTimers(s: Session) {
|
|
848
|
+
if (session === s) guarded("timers") { timers.fireDue() }
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
private fun onEvent(s: Session, event: TrackingEvent) {
|
|
852
|
+
when (event) {
|
|
853
|
+
is TrackingEvent.Heartbeat -> scope.launch { mutex.withLock { fireDueTimers(s) } }
|
|
854
|
+
is TrackingEvent.ProviderChange -> scope.launch { mutex.withLock { onProviderChange(s, event.state) } }
|
|
855
|
+
// The user geofences may now need the OS slot of the stationary region, or leave it free again.
|
|
856
|
+
is TrackingEvent.GeofencesChange -> scope.launch {
|
|
857
|
+
mutex.withLock { if (session === s) applyMotionRequests(s, checkLimit = true) }
|
|
858
|
+
}
|
|
859
|
+
else -> Unit
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
/**
|
|
864
|
+
* GMS and HMS drop every registered geofence when location services are switched off, and geofences registered
|
|
865
|
+
* without background permission may not fire. Registering all stored geofences again when location comes back
|
|
866
|
+
* (or the permission level changes) keeps geofence audit records flowing. Re-adding the same ids is idempotent.
|
|
867
|
+
*
|
|
868
|
+
* The stationary region is registered again on every provider change while location is enabled (any change of
|
|
869
|
+
* the location settings or of the backend may have made the OS drop it). A registration that failed before (for
|
|
870
|
+
* example without background location permission) is tried again; on success the low-power fallback request
|
|
871
|
+
* becomes the passive one.
|
|
872
|
+
*/
|
|
873
|
+
private suspend fun onProviderChange(s: Session, state: ProviderState) {
|
|
874
|
+
if (session !== s) return
|
|
875
|
+
val wasEnabled = s.providerEnabled
|
|
876
|
+
val oldPermission = s.providerPermission
|
|
877
|
+
s.providerEnabled = state.enabled
|
|
878
|
+
s.providerPermission = state.permission
|
|
879
|
+
if (!state.enabled) return
|
|
880
|
+
if (wasEnabled != true || oldPermission != state.permission) {
|
|
881
|
+
Logger.i(TAG, "location provider available again (${state.permission.wire}); re-registering geofences")
|
|
882
|
+
guarded("geofences.onTrackingStarted") { geofences.onTrackingStarted(s.mode) }
|
|
883
|
+
}
|
|
884
|
+
if (isStationary(s)) applyMotionRequests(s, force = true)
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
private suspend fun handleFix(s: Session, raw: TrackedLocation) {
|
|
888
|
+
val fix = acceptedOrNull(raw, isMoving = s.motion.isMoving, what = "fix") ?: return
|
|
889
|
+
s.lastFix = fix
|
|
890
|
+
if (s.mode == TrackingMode.GEOFENCES) {
|
|
891
|
+
guarded("odometer") { odometer.onLocation(fix) }
|
|
892
|
+
notifyGeofences(fix)
|
|
893
|
+
persistLastLocation(s, fix)
|
|
894
|
+
return
|
|
895
|
+
}
|
|
896
|
+
val wasMoving = s.motion.isMoving
|
|
897
|
+
val anchorBefore = s.motion.anchor
|
|
898
|
+
val actions = s.motion.onLocation(fix)
|
|
899
|
+
if (!wasMoving && !s.motion.isMoving) {
|
|
900
|
+
// STATIONARY: no record, no odometer; `runtime.lastLocation` changes only with the anchor.
|
|
901
|
+
notifyGeofences(fix)
|
|
902
|
+
onStationaryFix(s, fix, anchorBefore)
|
|
903
|
+
return
|
|
904
|
+
}
|
|
905
|
+
guarded("odometer") { odometer.onLocation(fix) }
|
|
906
|
+
notifyGeofences(fix)
|
|
907
|
+
// Leaving STATIONARY records this fix as the `motionchange` (is_moving=true).
|
|
908
|
+
execute(s, actions)
|
|
909
|
+
if (session !== s || !wasMoving) return
|
|
910
|
+
if (ElasticDistance.shouldRecord(s.lastRecorded, fix, config.geolocation)) {
|
|
911
|
+
submit(RecordEvent.LOCATION, fix)
|
|
912
|
+
s.lastRecorded = fix
|
|
913
|
+
s.lastLocationPersistedAt = clock.elapsedRealtime()
|
|
914
|
+
} else {
|
|
915
|
+
persistLastLocation(s, fix)
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
private fun notifyGeofences(fix: TrackedLocation) {
|
|
920
|
+
guarded("geofences.onLocation") { geofences.onLocation(fix) }
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
/** The processed [raw] fix, or null (logged) if the processor rejects it. */
|
|
924
|
+
private fun acceptedOrNull(raw: TrackedLocation, isMoving: Boolean, what: String): TrackedLocation? =
|
|
925
|
+
when (val result = processor.process(raw, isMoving)) {
|
|
926
|
+
is FilterResult.Accepted -> result.location
|
|
927
|
+
is FilterResult.Rejected -> {
|
|
928
|
+
Logger.d(TAG, "$what rejected: ${result.reason}")
|
|
929
|
+
null
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
/**
|
|
934
|
+
* A STATIONARY fix that did not leave STATIONARY. It changes the anchor when:
|
|
935
|
+
* - there was none (nothing was known when STATIONARY began): the fix becomes the anchor;
|
|
936
|
+
* - the anchor is not confirmed (it was more than [ANCHOR_MAX_AGE_MS] old when it became the anchor) and the fix
|
|
937
|
+
* is current and accurate enough: the fix replaces it;
|
|
938
|
+
* - the motion state machine replaced the anchor with this more accurate fix.
|
|
939
|
+
* A new anchor becomes `runtime.lastLocation` (the location of the heartbeats) and the stationary region follows
|
|
940
|
+
* it (registered around a new confirmed anchor, moved only when the centre is more than [REGION_RECENTER_M]
|
|
941
|
+
* away). Otherwise `runtime.lastLocation` keeps the anchor fix with its acquisition time.
|
|
942
|
+
*/
|
|
943
|
+
private suspend fun onStationaryFix(s: Session, fix: TrackedLocation, anchorBefore: TrackedLocation?) {
|
|
944
|
+
var anchor = s.motion.anchor ?: return
|
|
945
|
+
val replacesOldAnchor = anchor === anchorBefore && !isAnchorConfirmed(s, anchor) && isCurrent(fix) &&
|
|
946
|
+
s.motion.isAccurateEnough(fix)
|
|
947
|
+
if (replacesOldAnchor) {
|
|
948
|
+
Logger.i(TAG, "a current fix replaces the stationary anchor, which was ${ageMs(anchor) / 1000} s old")
|
|
949
|
+
s.motion.setAnchor(fix)
|
|
950
|
+
anchor = fix
|
|
951
|
+
}
|
|
952
|
+
if (anchor === anchorBefore) return
|
|
953
|
+
configStore.updateRuntime { it.copy(lastLocation = anchor) }
|
|
954
|
+
applyMotionRequests(s)
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
/**
|
|
958
|
+
* An OS transition of the stationary region. Only EXIT matters: while STATIONARY it switches to MOVING (the
|
|
959
|
+
* transition's fix, when the processor accepts it, is the `motionchange` location and goes to the odometer and
|
|
960
|
+
* the geofences like the fix that leaves the stationary radius). An EXIT whose fix is certainly inside the
|
|
961
|
+
* registered region belongs to an earlier region (delivered late) and is ignored. In a process where tracking
|
|
962
|
+
* is enabled but not running (the geofence PendingIntent started it) tracking is restored first. A region
|
|
963
|
+
* reported while tracking is off, or while the session is not STATIONARY, is removed from the OS.
|
|
964
|
+
*/
|
|
965
|
+
private suspend fun handleStationaryRegionTransition(transition: OsGeofenceTransition) {
|
|
966
|
+
if (transition.action != GeofenceAction.EXIT) {
|
|
967
|
+
Logger.d(TAG, "stationary region ${transition.action} ignored")
|
|
968
|
+
return
|
|
969
|
+
}
|
|
970
|
+
if (session == null) {
|
|
971
|
+
if (!runtime.enabled) {
|
|
972
|
+
Logger.i(TAG, "stationary region EXIT while tracking is off; removing the region")
|
|
973
|
+
removeStationaryRegionFrom(null)
|
|
974
|
+
return
|
|
975
|
+
}
|
|
976
|
+
if (!restoreFromBackgroundTrigger("stationary region EXIT")) return
|
|
977
|
+
}
|
|
978
|
+
val s = session ?: return
|
|
979
|
+
// The OS has the region: this session removes it when it leaves STATIONARY or stops.
|
|
980
|
+
if (s.regionBackend == null) s.leftoverRegion = true
|
|
981
|
+
if (!isStationary(s)) {
|
|
982
|
+
Logger.d(TAG, "stationary region EXIT ignored: the session is not STATIONARY")
|
|
983
|
+
removeStationaryRegion(s)
|
|
984
|
+
return
|
|
985
|
+
}
|
|
986
|
+
val region = s.region
|
|
987
|
+
val reported = transition.location
|
|
988
|
+
if (region != null && reported != null &&
|
|
989
|
+
distanceMeters(region.latitude, region.longitude, reported.latitude, reported.longitude) +
|
|
990
|
+
reported.accuracyMeters <= region.radius
|
|
991
|
+
) {
|
|
992
|
+
Logger.i(TAG, "stationary region EXIT ignored: its fix is inside the registered region (a late EXIT)")
|
|
993
|
+
return
|
|
994
|
+
}
|
|
995
|
+
Logger.i(TAG, "stationary region EXIT: leaving STATIONARY")
|
|
996
|
+
val fix = reported?.let { acceptedOrNull(it, isMoving = false, what = "stationary region EXIT fix") }
|
|
997
|
+
if (fix != null) {
|
|
998
|
+
s.lastFix = newest(s.lastFix, fix)
|
|
999
|
+
guarded("odometer") { odometer.onLocation(fix) }
|
|
1000
|
+
notifyGeofences(fix)
|
|
1001
|
+
}
|
|
1002
|
+
execute(s, s.motion.force(true, fix ?: bestKnownLocation(s)))
|
|
1003
|
+
fireDueTimers(s)
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
/**
|
|
1007
|
+
* Keeps `runtime.lastLocation` (used by heartbeats) fresh for MOVING and GEOFENCES-mode fixes that are not
|
|
1008
|
+
* recorded, at most every 10 s. STATIONARY fixes never call it: the heartbeats keep the anchor fix.
|
|
1009
|
+
*/
|
|
1010
|
+
private fun persistLastLocation(s: Session, fix: TrackedLocation) {
|
|
1011
|
+
val now = clock.elapsedRealtime()
|
|
1012
|
+
val last = s.lastLocationPersistedAt
|
|
1013
|
+
if (last != null && now - last < LAST_LOCATION_PERSIST_INTERVAL_MS) return
|
|
1014
|
+
s.lastLocationPersistedAt = now
|
|
1015
|
+
configStore.updateRuntime { it.copy(lastLocation = fix) }
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
// ------------------------------------------------------------------ motion
|
|
1019
|
+
|
|
1020
|
+
private suspend fun handleActivity(samples: List<ActivitySample>) {
|
|
1021
|
+
if (session == null) {
|
|
1022
|
+
if (!runtime.enabled) {
|
|
1023
|
+
Logger.v(TAG, "activity ignored: tracking is not enabled")
|
|
1024
|
+
return
|
|
1025
|
+
}
|
|
1026
|
+
// The activity PendingIntent woke a process that was killed while tracking.
|
|
1027
|
+
if (!restoreFromBackgroundTrigger("activity update")) return
|
|
1028
|
+
}
|
|
1029
|
+
val s = session ?: return
|
|
1030
|
+
val best = samples.maxBy { it.confidence }
|
|
1031
|
+
val threshold = config.activity.minimumActivityRecognitionConfidence
|
|
1032
|
+
if (best.confidence >= threshold) {
|
|
1033
|
+
persistActivity(s, best)
|
|
1034
|
+
if (s.lastActivityType != best.type) {
|
|
1035
|
+
s.lastActivityType = best.type
|
|
1036
|
+
Logger.d(TAG, "activity: ${best.type.wire} (${best.confidence})")
|
|
1037
|
+
events.emit(TrackingEvent.ActivityChange(best))
|
|
1038
|
+
}
|
|
1039
|
+
if (s.mode == TrackingMode.LOCATION) execute(s, s.motion.onActivity(best))
|
|
1040
|
+
} else {
|
|
1041
|
+
Logger.v(TAG, "activity ${best.type.wire} (${best.confidence}) below confidence $threshold")
|
|
1042
|
+
}
|
|
1043
|
+
fireDueTimers(s)
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
/** `runtime.activity`: a new type is written at once, a confidence-only change at most once a minute. */
|
|
1047
|
+
private fun persistActivity(s: Session, sample: ActivitySample) {
|
|
1048
|
+
val current = runtime.activity
|
|
1049
|
+
if (current == sample) return
|
|
1050
|
+
val now = clock.elapsedRealtime()
|
|
1051
|
+
val last = s.lastActivityPersistedAt
|
|
1052
|
+
if (current.type == sample.type && last != null && now - last < ACTIVITY_PERSIST_INTERVAL_MS) return
|
|
1053
|
+
s.lastActivityPersistedAt = now
|
|
1054
|
+
configStore.updateRuntime { it.copy(activity = sample) }
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
private suspend fun execute(s: Session, actions: List<MotionAction>) {
|
|
1058
|
+
for (action in actions) {
|
|
1059
|
+
if (session !== s) return
|
|
1060
|
+
when (action) {
|
|
1061
|
+
is MotionAction.StartStopTimer ->
|
|
1062
|
+
timers.schedule(TimerKind.STOP_TIMEOUT, action.delayMs) { onStopTimeout(s) }
|
|
1063
|
+
MotionAction.CancelStopTimer -> timers.cancel(TimerKind.STOP_TIMEOUT)
|
|
1064
|
+
is MotionAction.StartMotionTrigger ->
|
|
1065
|
+
timers.schedule(TimerKind.MOTION_TRIGGER, action.delayMs) { onMotionTrigger(s) }
|
|
1066
|
+
MotionAction.CancelMotionTrigger -> timers.cancel(TimerKind.MOTION_TRIGGER)
|
|
1067
|
+
is MotionAction.EnterMoving -> {
|
|
1068
|
+
Logger.i(TAG, "motion: STATIONARY -> MOVING")
|
|
1069
|
+
val location = action.location ?: bestKnownLocation(s)
|
|
1070
|
+
// The configured request (GPS on) at once; the stationary region is removed after the record,
|
|
1071
|
+
// so a slow OS call delays neither.
|
|
1072
|
+
applyLocationRequest(s)
|
|
1073
|
+
recordMotionChange(s, isMoving = true, location)
|
|
1074
|
+
applyMotionRequests(s)
|
|
1075
|
+
}
|
|
1076
|
+
is MotionAction.EnterStationary -> {
|
|
1077
|
+
Logger.i(TAG, "motion: MOVING -> STATIONARY${if (action.automatic) " (stop timeout)" else ""}")
|
|
1078
|
+
recordMotionChange(s, isMoving = false, action.location)
|
|
1079
|
+
if (action.automatic && config.geolocation.stopOnStationary) {
|
|
1080
|
+
stopLocked(REASON_STOP_ON_STATIONARY)
|
|
1081
|
+
} else {
|
|
1082
|
+
// The stationary region around the anchor and passive updates (GPS off).
|
|
1083
|
+
applyMotionRequests(s)
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
private suspend fun onStopTimeout(s: Session) {
|
|
1091
|
+
if (session !== s || !s.motion.isMoving) return
|
|
1092
|
+
guarded("stop timeout") { execute(s, s.motion.onStopTimeout(bestKnownLocation(s))) }
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
private suspend fun onMotionTrigger(s: Session) {
|
|
1096
|
+
if (session !== s) return
|
|
1097
|
+
guarded("motion trigger") { execute(s, s.motion.onMotionTriggerElapsed()) }
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
private suspend fun recordMotionChange(s: Session, isMoving: Boolean, location: TrackedLocation?) {
|
|
1101
|
+
configStore.updateRuntime { it.copy(isMoving = isMoving) }
|
|
1102
|
+
submit(RecordEvent.MOTIONCHANGE, location, isMoving = isMoving)
|
|
1103
|
+
s.motionRecorded = true
|
|
1104
|
+
if (location != null) {
|
|
1105
|
+
s.lastRecorded = location
|
|
1106
|
+
s.lastLocationPersistedAt = clock.elapsedRealtime()
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
/** After start: a fresh fix (or the last known one) is recorded as the initial `motionchange` (is_moving=false). */
|
|
1111
|
+
private fun launchInitialFix(s: Session) {
|
|
1112
|
+
s.initialFixJob?.cancel()
|
|
1113
|
+
val geolocation = config.geolocation
|
|
1114
|
+
s.initialFixJob = scope.launch {
|
|
1115
|
+
val fresh = currentLocation(geolocation)
|
|
1116
|
+
mutex.withLock {
|
|
1117
|
+
// Skipped if tracking stopped, the mode changed, or a transition was already recorded.
|
|
1118
|
+
if (session !== s || s.mode != TrackingMode.LOCATION || s.motionRecorded) return@withLock
|
|
1119
|
+
guarded("initial motionchange") { recordInitialMotionChange(s, fresh) }
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
private suspend fun recordInitialMotionChange(s: Session, fresh: TrackedLocation?) {
|
|
1125
|
+
val accepted = fresh?.let { acceptedOrNull(it, isMoving = false, what = "initial fix") }
|
|
1126
|
+
if (accepted != null) {
|
|
1127
|
+
s.lastFix = newest(s.lastFix, accepted)
|
|
1128
|
+
notifyGeofences(accepted)
|
|
1129
|
+
// The fresh fix is the anchor, also when a passive fix arrived first and became the anchor.
|
|
1130
|
+
s.motion.setAnchor(accepted)
|
|
1131
|
+
}
|
|
1132
|
+
val location = accepted ?: bestKnownLocation(s)
|
|
1133
|
+
s.motion.offerAnchor(location)
|
|
1134
|
+
recordMotionChange(s, isMoving = false, location)
|
|
1135
|
+
if (s.motion.anchor == null) {
|
|
1136
|
+
Logger.i(TAG, "no location is known: low-power requests until a first fix becomes the stationary anchor")
|
|
1137
|
+
}
|
|
1138
|
+
// Entering STATIONARY after start / restore: the stationary region around the anchor, passive updates.
|
|
1139
|
+
applyMotionRequests(s)
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
private suspend fun currentLocation(geolocation: GeolocationConfig): TrackedLocation? = try {
|
|
1143
|
+
withTimeoutOrNull(geolocation.locationTimeout.coerceAtLeast(0) + CURRENT_LOCATION_GRACE_MS) {
|
|
1144
|
+
providers.location().getCurrentLocation(geolocation.desiredAccuracy, geolocation.locationTimeout)
|
|
1145
|
+
}
|
|
1146
|
+
} catch (e: CancellationException) {
|
|
1147
|
+
throw e
|
|
1148
|
+
} catch (e: Exception) {
|
|
1149
|
+
Logger.w(TAG, "current location failed", e)
|
|
1150
|
+
null
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
/**
|
|
1154
|
+
* The newest of the last accepted fix, `runtime.lastLocation` and the backend's last location. The backend's
|
|
1155
|
+
* location (not vetted by the processor, possibly a coarse network fix) only counts if its accuracy is within
|
|
1156
|
+
* `filter.trackingAccuracyThreshold`, unless nothing else is known.
|
|
1157
|
+
*/
|
|
1158
|
+
private suspend fun bestKnownLocation(s: Session?): TrackedLocation? {
|
|
1159
|
+
val fromBackend = try {
|
|
1160
|
+
withTimeoutOrNull(LAST_LOCATION_TIMEOUT_MS) { providers.location().getLastLocation() }
|
|
1161
|
+
} catch (e: CancellationException) {
|
|
1162
|
+
throw e
|
|
1163
|
+
} catch (e: Exception) {
|
|
1164
|
+
Logger.w(TAG, "last location failed", e)
|
|
1165
|
+
null
|
|
1166
|
+
}
|
|
1167
|
+
val threshold = config.geolocation.filter.trackingAccuracyThreshold
|
|
1168
|
+
val vetted = fromBackend?.takeIf { threshold <= 0.0 || it.accuracyMeters <= threshold }
|
|
1169
|
+
return listOfNotNull(s?.lastFix, runtime.lastLocation, vetted).maxByOrNull { it.time } ?: fromBackend
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
private suspend fun submit(
|
|
1173
|
+
event: RecordEvent,
|
|
1174
|
+
location: TrackedLocation?,
|
|
1175
|
+
reason: String? = null,
|
|
1176
|
+
isMoving: Boolean? = null,
|
|
1177
|
+
): Record? = try {
|
|
1178
|
+
var record = recordFactory.create(event, location, reason = reason)
|
|
1179
|
+
if (isMoving != null && record.isMoving != isMoving) record = record.copy(isMoving = isMoving)
|
|
1180
|
+
recordSink.submit(record)
|
|
1181
|
+
} catch (e: CancellationException) {
|
|
1182
|
+
throw e
|
|
1183
|
+
} catch (e: Exception) {
|
|
1184
|
+
Logger.e(TAG, "failed to record ${event.wire}", e)
|
|
1185
|
+
null
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
private suspend fun <T> serialized(block: suspend () -> T): T =
|
|
1189
|
+
withContext(engineContext) { mutex.withLock { block() } }
|
|
1190
|
+
|
|
1191
|
+
/** Runs [block], logging (not propagating) any failure except cancellation. */
|
|
1192
|
+
private inline fun guarded(what: String, block: () -> Unit) {
|
|
1193
|
+
try {
|
|
1194
|
+
block()
|
|
1195
|
+
} catch (e: CancellationException) {
|
|
1196
|
+
throw e
|
|
1197
|
+
} catch (e: Exception) {
|
|
1198
|
+
Logger.e(TAG, "$what failed", e)
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
/** State of one tracking session in this process. Guarded by [mutex]. */
|
|
1203
|
+
private inner class Session(val id: Long, var mode: TrackingMode, settings: MotionSettings) {
|
|
1204
|
+
val motion = MotionStateMachine(settings)
|
|
1205
|
+
val listener = LocationListener { locations -> dispatchLocations(this@Session, locations) }
|
|
1206
|
+
var locationBackend: LocationBackend? = null
|
|
1207
|
+
var appliedSpec: LocationRequestSpec? = null
|
|
1208
|
+
var activityBackend: ActivityBackend? = null
|
|
1209
|
+
var needsContinuousLocation = false
|
|
1210
|
+
|
|
1211
|
+
/** The stationary region registered with the OS (null: none; STATIONARY then uses the low-power request). */
|
|
1212
|
+
var region: OsGeofence? = null
|
|
1213
|
+
|
|
1214
|
+
/** The backend the stationary region was added to (also after a failed or timed-out add), for its removal. */
|
|
1215
|
+
var regionBackend: GeofenceBackend? = null
|
|
1216
|
+
|
|
1217
|
+
/** The last registration of the stationary region failed; not retried until this is cleared. */
|
|
1218
|
+
var regionFailed = false
|
|
1219
|
+
|
|
1220
|
+
/** An earlier process may have left the stationary region with the OS; removed with this session's. */
|
|
1221
|
+
var leftoverRegion = false
|
|
1222
|
+
|
|
1223
|
+
/** The motion anchor that was current when it became the anchor (see `isAnchorConfirmed`). */
|
|
1224
|
+
var confirmedAnchor: TrackedLocation? = null
|
|
1225
|
+
|
|
1226
|
+
/** Last fix accepted by the processor. */
|
|
1227
|
+
var lastFix: TrackedLocation? = null
|
|
1228
|
+
|
|
1229
|
+
/** Location of the last `location` / `motionchange` record (reference of the elastic distance filter). */
|
|
1230
|
+
var lastRecorded: TrackedLocation? = null
|
|
1231
|
+
var lastLocationPersistedAt: Long? = null
|
|
1232
|
+
var lastActivityType: ActivityType? = null
|
|
1233
|
+
var lastActivityPersistedAt: Long? = null
|
|
1234
|
+
var motionRecorded = false
|
|
1235
|
+
var initialFixJob: Job? = null
|
|
1236
|
+
var geofenceWatchJob: Job? = null
|
|
1237
|
+
var eventSubscription: Subscription? = null
|
|
1238
|
+
|
|
1239
|
+
/** Last provider state seen through `ProviderChange` events (null until the first event of this session). */
|
|
1240
|
+
var providerEnabled: Boolean? = null
|
|
1241
|
+
var providerPermission: PermissionLevel? = null
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
private companion object {
|
|
1245
|
+
const val TAG = "LT.Engine"
|
|
1246
|
+
|
|
1247
|
+
const val REASON_START = "start"
|
|
1248
|
+
const val REASON_START_GEOFENCES = "start_geofences"
|
|
1249
|
+
const val REASON_RESTORE = "restore"
|
|
1250
|
+
const val REASON_STOP = "stop"
|
|
1251
|
+
const val REASON_STOP_ON_STATIONARY = "stop_on_stationary"
|
|
1252
|
+
const val REASON_STOP_AFTER_ELAPSED = "stop_after_elapsed"
|
|
1253
|
+
const val REASON_TERMINATE = "terminate"
|
|
1254
|
+
const val REASON_PERMISSION_DENIED = "permission_denied"
|
|
1255
|
+
const val REASON_SERVICE_START_FAILED = "service_start_failed"
|
|
1256
|
+
const val REASON_RESUME_NOTIFICATION = "resume_notification"
|
|
1257
|
+
|
|
1258
|
+
/** `device.checkProviderState` reason after the backend changed. */
|
|
1259
|
+
const val PROVIDER_CHECK_REASON = "reselect"
|
|
1260
|
+
|
|
1261
|
+
const val MINUTE_MS = 60_000L
|
|
1262
|
+
const val LAST_LOCATION_TIMEOUT_MS = 3_000L
|
|
1263
|
+
const val CURRENT_LOCATION_GRACE_MS = 5_000L
|
|
1264
|
+
const val LAST_LOCATION_PERSIST_INTERVAL_MS = 10_000L
|
|
1265
|
+
const val ACTIVITY_PERSIST_INTERVAL_MS = 60_000L
|
|
1266
|
+
|
|
1267
|
+
/** Longest wait for the OS to add or remove the stationary region (the engine's mutex is held meanwhile). */
|
|
1268
|
+
const val OS_GEOFENCE_TIMEOUT_MS = 10_000L
|
|
1269
|
+
|
|
1270
|
+
/**
|
|
1271
|
+
* An anchor older than this when it becomes the anchor is not confirmed: no stationary region is registered
|
|
1272
|
+
* around it until a current fix replaces it (the low-power request provides one).
|
|
1273
|
+
*/
|
|
1274
|
+
const val ANCHOR_MAX_AGE_MS = 10 * MINUTE_MS
|
|
1275
|
+
|
|
1276
|
+
/** The stationary region is moved when the anchor is farther than this from its centre. */
|
|
1277
|
+
const val REGION_RECENTER_M = 50.0
|
|
1278
|
+
|
|
1279
|
+
fun newest(a: TrackedLocation?, b: TrackedLocation): TrackedLocation = if (a == null || b.time >= a.time) b else a
|
|
1280
|
+
}
|
|
1281
|
+
}
|