@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
package/android/src/main/java/com/brickssoft/locationtracking/geofence/DefaultGeofenceManager.kt
ADDED
|
@@ -0,0 +1,899 @@
|
|
|
1
|
+
package com.brickssoft.locationtracking.geofence
|
|
2
|
+
|
|
3
|
+
import com.brickssoft.locationtracking.config.ConfigStore
|
|
4
|
+
import com.brickssoft.locationtracking.config.TrackingMode
|
|
5
|
+
import com.brickssoft.locationtracking.core.Clock
|
|
6
|
+
import com.brickssoft.locationtracking.core.Constants
|
|
7
|
+
import com.brickssoft.locationtracking.core.ErrorCode
|
|
8
|
+
import com.brickssoft.locationtracking.core.EventBus
|
|
9
|
+
import com.brickssoft.locationtracking.core.Logger
|
|
10
|
+
import com.brickssoft.locationtracking.core.TrackingEvent
|
|
11
|
+
import com.brickssoft.locationtracking.core.TrackingException
|
|
12
|
+
import com.brickssoft.locationtracking.data.GeofenceRuntime
|
|
13
|
+
import com.brickssoft.locationtracking.data.GeofenceStore
|
|
14
|
+
import com.brickssoft.locationtracking.model.GeofenceAction
|
|
15
|
+
import com.brickssoft.locationtracking.model.GeofenceHit
|
|
16
|
+
import com.brickssoft.locationtracking.model.GeofenceSpec
|
|
17
|
+
import com.brickssoft.locationtracking.model.RecordEvent
|
|
18
|
+
import com.brickssoft.locationtracking.model.TrackedLocation
|
|
19
|
+
import com.brickssoft.locationtracking.processing.RecordFactory
|
|
20
|
+
import com.brickssoft.locationtracking.provider.GeofenceBackend
|
|
21
|
+
import com.brickssoft.locationtracking.provider.OsGeofence
|
|
22
|
+
import com.brickssoft.locationtracking.provider.OsGeofenceTransition
|
|
23
|
+
import com.brickssoft.locationtracking.provider.ProviderFactory
|
|
24
|
+
import com.brickssoft.locationtracking.provider.StationaryRegionSink
|
|
25
|
+
import com.brickssoft.locationtracking.record.RecordSink
|
|
26
|
+
import kotlinx.coroutines.CoroutineScope
|
|
27
|
+
import kotlinx.coroutines.Job
|
|
28
|
+
import kotlinx.coroutines.delay
|
|
29
|
+
import kotlinx.coroutines.flow.MutableStateFlow
|
|
30
|
+
import kotlinx.coroutines.flow.StateFlow
|
|
31
|
+
import kotlinx.coroutines.flow.asStateFlow
|
|
32
|
+
import kotlinx.coroutines.launch
|
|
33
|
+
import kotlinx.coroutines.sync.Mutex
|
|
34
|
+
import kotlinx.coroutines.sync.withLock
|
|
35
|
+
import kotlin.coroutines.cancellation.CancellationException
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Default [GeofenceManager]: validation, persistence, OS registration, polygon hit-testing and dwell synthesis.
|
|
39
|
+
*
|
|
40
|
+
* - **Circles** are registered as they are and their transitions come from the OS. When the backend has no dwell
|
|
41
|
+
* support, DWELL is synthesized with [DwellTracker] (continuously inside for `loiteringDelay` after ENTER).
|
|
42
|
+
* - **Polygons** are registered as their padded enclosing circle ([PolygonMath.enclosingCircle]) with ENTER, EXIT
|
|
43
|
+
* and an initial ENTER trigger. The circle state only drives [needsContinuousLocation]. Polygon ENTER and EXIT
|
|
44
|
+
* come from [onLocation] by ray casting, with hysteresis: a fix changes the state when its accuracy circle lies
|
|
45
|
+
* entirely on one side of the boundary, or when two consecutive fixes agree. Leaving the circle also means
|
|
46
|
+
* leaving the polygon. Polygon DWELL is always synthesized.
|
|
47
|
+
* - `initialTriggerEntry=false` silences the first polygon classification when it reflects the state at
|
|
48
|
+
* registration: when it happens within [INITIAL_TRIGGER_WINDOW_MS] of registering, or follows a circle ENTER
|
|
49
|
+
* received within that window (the OS's initial trigger). A circle ENTER after the window, or a fresh fix
|
|
50
|
+
* outside the enclosing circle at registration, means the polygon was outside. Without a fresh fix, a real
|
|
51
|
+
* entry within the window cannot be told apart from the initial state and is silent too.
|
|
52
|
+
* - Every notified transition becomes a `geofence` record (submitted to [RecordSink], so it is persisted and
|
|
53
|
+
* uploaded) plus a [TrackingEvent.Geofence].
|
|
54
|
+
*
|
|
55
|
+
* Per-geofence runtime ([GeofenceRuntime]) is persisted, restored when first loaded while tracking is enabled and
|
|
56
|
+
* reset by [onTrackingStopped]. `insideCircle` is the state of the (enclosing) circle. `insidePolygon` is the
|
|
57
|
+
* polygon state. `enteredAt` is the wall time at which the current inside state began (circles) or at which the
|
|
58
|
+
* current polygon state was established (polygons: null = not classified yet, [ENTERED_BEFORE_MONITORING] = a
|
|
59
|
+
* silent initial inside). A restored state may be stale, so a restored circle ENTER is not treated as a duplicate,
|
|
60
|
+
* and a fix that is certainly outside a restored inside circle reports the EXIT missed while the process was
|
|
61
|
+
* gone. A synthesized dwell is re-armed after a restart only if its deadline has not passed.
|
|
62
|
+
*
|
|
63
|
+
* State changes are serialized with a [Mutex]; [onLocation] only launches work on [scope].
|
|
64
|
+
*/
|
|
65
|
+
class DefaultGeofenceManager(
|
|
66
|
+
private val geofenceStore: GeofenceStore,
|
|
67
|
+
private val providers: ProviderFactory,
|
|
68
|
+
private val configStore: ConfigStore,
|
|
69
|
+
private val recordFactory: RecordFactory,
|
|
70
|
+
private val recordSink: RecordSink,
|
|
71
|
+
private val events: EventBus,
|
|
72
|
+
private val clock: Clock,
|
|
73
|
+
private val scope: CoroutineScope,
|
|
74
|
+
// Unit 2 routes STATIONARY_REGION_ID transitions here (never stored, recorded or emitted).
|
|
75
|
+
@Suppress("unused")
|
|
76
|
+
private val stationarySink: Lazy<StationaryRegionSink> = lazyOf(StationaryRegionSink.NONE),
|
|
77
|
+
) : GeofenceManager {
|
|
78
|
+
private class Entry(val spec: GeofenceSpec, var runtime: GeofenceRuntime) {
|
|
79
|
+
/** Circle: the OS reported (or a fix confirmed) the state in this process; restored state may be stale. */
|
|
80
|
+
var observed = false
|
|
81
|
+
|
|
82
|
+
/** Polygon: an unclassified state is the initial state until this time (the OS initial-trigger window). */
|
|
83
|
+
var initialUntil = Long.MIN_VALUE
|
|
84
|
+
|
|
85
|
+
/** Polygon: the OS initial circle ENTER arrived, so the first classification is the initial state. */
|
|
86
|
+
var initialLatched = false
|
|
87
|
+
|
|
88
|
+
/** Polygon: the last fix classified, so the same fix from two sources counts once. */
|
|
89
|
+
var lastFix: TrackedLocation? = null
|
|
90
|
+
|
|
91
|
+
/** Polygon hysteresis: the unconfirmed classification and how many consecutive fixes agreed with it. */
|
|
92
|
+
var candidate: Boolean? = null
|
|
93
|
+
var candidateCount = 0
|
|
94
|
+
|
|
95
|
+
fun resetCandidate() {
|
|
96
|
+
candidate = null
|
|
97
|
+
candidateCount = 0
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
private val mutex = Mutex()
|
|
102
|
+
|
|
103
|
+
// Guarded by [mutex].
|
|
104
|
+
private val entries = LinkedHashMap<String, Entry>()
|
|
105
|
+
private var registeredBackend: GeofenceBackend? = null
|
|
106
|
+
|
|
107
|
+
/** The last fix of the location stream (for ordering). */
|
|
108
|
+
private var lastStreamFix: TrackedLocation? = null
|
|
109
|
+
|
|
110
|
+
/** A later registration of every geofence, after the backend answered UNAVAILABLE ([scheduleRetryLocked]). */
|
|
111
|
+
private var retryJob: Job? = null
|
|
112
|
+
private var retryAttempt = 0
|
|
113
|
+
|
|
114
|
+
/** True after [onTrackingStarted], false after [onTrackingStopped], null in a process that saw neither. */
|
|
115
|
+
@Volatile
|
|
116
|
+
private var session: Boolean? = null
|
|
117
|
+
|
|
118
|
+
@Volatile
|
|
119
|
+
private var loaded = false
|
|
120
|
+
|
|
121
|
+
/** True while fixes matter: active polygons, or restored inside circles to reconcile. */
|
|
122
|
+
@Volatile
|
|
123
|
+
private var wantsFixes = false
|
|
124
|
+
|
|
125
|
+
@Volatile
|
|
126
|
+
private var lastFix: TrackedLocation? = null
|
|
127
|
+
|
|
128
|
+
private val dwell = DwellTracker(scope, clock) { id, token -> mutex.withLock { fireDwellLocked(id, token) } }
|
|
129
|
+
|
|
130
|
+
private val needs = MutableStateFlow(false)
|
|
131
|
+
override val needsContinuousLocation: StateFlow<Boolean> = needs.asStateFlow()
|
|
132
|
+
|
|
133
|
+
override suspend fun add(geofences: List<GeofenceSpec>) {
|
|
134
|
+
if (geofences.isEmpty()) return
|
|
135
|
+
val incoming = LinkedHashMap<String, GeofenceSpec>()
|
|
136
|
+
for (spec in geofences) validated(spec).let { incoming[it.identifier] = it }
|
|
137
|
+
val specs = incoming.values.toList()
|
|
138
|
+
|
|
139
|
+
mutex.withLock {
|
|
140
|
+
ensureLoadedLocked()
|
|
141
|
+
val newIds = specs.map { it.identifier }.filter { it !in entries }
|
|
142
|
+
val total = entries.size + newIds.size
|
|
143
|
+
if (total > Constants.MAX_GEOFENCES) {
|
|
144
|
+
throw TrackingException(
|
|
145
|
+
ErrorCode.TOO_MANY_GEOFENCES,
|
|
146
|
+
"at most ${Constants.MAX_GEOFENCES} geofences are supported (this would make $total)",
|
|
147
|
+
)
|
|
148
|
+
}
|
|
149
|
+
val replaced = specs.mapNotNull { entries[it.identifier]?.spec }
|
|
150
|
+
val backend = if (configStore.runtime.value.enabled) backendLocked() else null
|
|
151
|
+
if (backend != null) {
|
|
152
|
+
try {
|
|
153
|
+
val deactivated = replaced.filter { isActive(it) }.map { it.identifier } -
|
|
154
|
+
specs.filter { isActive(it) }.map { it.identifier }.toSet()
|
|
155
|
+
if (deactivated.isNotEmpty()) backend.remove(deactivated)
|
|
156
|
+
registerLocked(backend, specs, throwOnError = true)
|
|
157
|
+
} catch (e: CancellationException) {
|
|
158
|
+
throw e
|
|
159
|
+
} catch (e: Exception) {
|
|
160
|
+
rollbackRegistrationLocked(backend, newIds, replaced)
|
|
161
|
+
throw e.toTrackingException(ErrorCode.INTERNAL, "failed to register geofences")
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
try {
|
|
165
|
+
geofenceStore.upsert(specs)
|
|
166
|
+
} catch (e: CancellationException) {
|
|
167
|
+
throw e
|
|
168
|
+
} catch (e: Exception) {
|
|
169
|
+
if (backend != null) rollbackRegistrationLocked(backend, newIds, replaced)
|
|
170
|
+
throw e.toTrackingException(ErrorCode.IO_ERROR, "failed to store geofences")
|
|
171
|
+
}
|
|
172
|
+
val now = clock.now()
|
|
173
|
+
for (spec in specs) {
|
|
174
|
+
val previous = entries[spec.identifier]
|
|
175
|
+
dwell.cancel(spec.identifier)
|
|
176
|
+
val entry = Entry(spec, UNKNOWN)
|
|
177
|
+
entries[spec.identifier] = entry
|
|
178
|
+
if (previous != null && previous.runtime != UNKNOWN) persistRuntime(spec.identifier, UNKNOWN)
|
|
179
|
+
if (backend != null) openInitialWindowLocked(entry, now)
|
|
180
|
+
}
|
|
181
|
+
refreshDerivedLocked()
|
|
182
|
+
Logger.i(TAG, "added ${specs.size} geofence(s): ${specs.joinToString { it.identifier }}")
|
|
183
|
+
events.emit(TrackingEvent.GeofencesChange(on = specs, off = emptyList()))
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
override suspend fun remove(ids: List<String>) {
|
|
188
|
+
if (ids.isEmpty()) return
|
|
189
|
+
mutex.withLock {
|
|
190
|
+
ensureLoadedLocked()
|
|
191
|
+
val requested = ids.distinct()
|
|
192
|
+
val existing = requested.filter { it in entries }
|
|
193
|
+
if (existing.size < requested.size) {
|
|
194
|
+
Logger.d(TAG, "remove: unknown geofence(s) ${(requested - existing.toSet()).joinToString()}")
|
|
195
|
+
}
|
|
196
|
+
if (existing.isEmpty()) return
|
|
197
|
+
try {
|
|
198
|
+
geofenceStore.remove(existing)
|
|
199
|
+
} catch (e: CancellationException) {
|
|
200
|
+
throw e
|
|
201
|
+
} catch (e: Exception) {
|
|
202
|
+
throw e.toTrackingException(ErrorCode.IO_ERROR, "failed to remove geofences")
|
|
203
|
+
}
|
|
204
|
+
if (configStore.runtime.value.enabled) {
|
|
205
|
+
removeFromOsLocked(backendLocked(), existing.filter { isActive(entries.getValue(it).spec) })
|
|
206
|
+
}
|
|
207
|
+
for (id in existing) {
|
|
208
|
+
dwell.cancel(id)
|
|
209
|
+
entries.remove(id)
|
|
210
|
+
}
|
|
211
|
+
refreshDerivedLocked()
|
|
212
|
+
Logger.i(TAG, "removed ${existing.size} geofence(s): ${existing.joinToString()}")
|
|
213
|
+
events.emit(TrackingEvent.GeofencesChange(on = emptyList(), off = existing))
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
override suspend fun removeAll() {
|
|
218
|
+
mutex.withLock {
|
|
219
|
+
ensureLoadedLocked()
|
|
220
|
+
val ids = entries.keys.toList()
|
|
221
|
+
val registered = registrableIdsLocked()
|
|
222
|
+
try {
|
|
223
|
+
geofenceStore.removeAll()
|
|
224
|
+
} catch (e: CancellationException) {
|
|
225
|
+
throw e
|
|
226
|
+
} catch (e: Exception) {
|
|
227
|
+
throw e.toTrackingException(ErrorCode.IO_ERROR, "failed to remove geofences")
|
|
228
|
+
}
|
|
229
|
+
if (configStore.runtime.value.enabled) {
|
|
230
|
+
// The stored ids only: `backend.removeAll()` would also drop the engine's stationary region, which
|
|
231
|
+
// shares the backend's PendingIntent (round 2, §1 and §3).
|
|
232
|
+
val current = providers.geofence()
|
|
233
|
+
for (backend in listOfNotNull(registeredBackend, current).distinctBy { it.kind }) {
|
|
234
|
+
removeFromOsLocked(backend, registered)
|
|
235
|
+
}
|
|
236
|
+
registeredBackend = current
|
|
237
|
+
}
|
|
238
|
+
dwell.cancelAll()
|
|
239
|
+
entries.clear()
|
|
240
|
+
refreshDerivedLocked()
|
|
241
|
+
if (ids.isEmpty()) return
|
|
242
|
+
Logger.i(TAG, "removed all ${ids.size} geofence(s)")
|
|
243
|
+
events.emit(TrackingEvent.GeofencesChange(on = emptyList(), off = ids))
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
override suspend fun list(): List<GeofenceSpec> = geofenceStore.all()
|
|
248
|
+
|
|
249
|
+
override suspend fun get(id: String): GeofenceSpec? = geofenceStore.get(id)
|
|
250
|
+
|
|
251
|
+
override suspend fun onTrackingStarted(mode: TrackingMode) {
|
|
252
|
+
mutex.withLock {
|
|
253
|
+
session = true
|
|
254
|
+
try {
|
|
255
|
+
ensureLoadedLocked()
|
|
256
|
+
} catch (e: CancellationException) {
|
|
257
|
+
throw e
|
|
258
|
+
} catch (e: Exception) {
|
|
259
|
+
Logger.e(TAG, "cannot load geofences; none registered", e)
|
|
260
|
+
return
|
|
261
|
+
}
|
|
262
|
+
val current = providers.geofence()
|
|
263
|
+
registeredBackend?.takeIf { it.kind != current.kind }?.let {
|
|
264
|
+
removeFromOsLocked(it, registrableIdsLocked())
|
|
265
|
+
}
|
|
266
|
+
registeredBackend = current
|
|
267
|
+
cancelRetryLocked()
|
|
268
|
+
if (registerAllLocked(current)) scheduleRetryLocked()
|
|
269
|
+
refreshDwellTimersLocked()
|
|
270
|
+
refreshDerivedLocked()
|
|
271
|
+
Logger.i(TAG, "tracking started (${mode.wire}): ${entries.size} geofence(s), backend ${current.kind.wire}")
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
override suspend fun onTrackingStopped() {
|
|
276
|
+
mutex.withLock {
|
|
277
|
+
session = false
|
|
278
|
+
cancelRetryLocked()
|
|
279
|
+
dwell.cancelAll()
|
|
280
|
+
lastStreamFix = null
|
|
281
|
+
try {
|
|
282
|
+
ensureLoadedLocked()
|
|
283
|
+
} catch (e: CancellationException) {
|
|
284
|
+
throw e
|
|
285
|
+
} catch (e: Exception) {
|
|
286
|
+
Logger.e(TAG, "cannot load geofences to unregister them", e)
|
|
287
|
+
refreshDerivedLocked()
|
|
288
|
+
return
|
|
289
|
+
}
|
|
290
|
+
removeFromOsLocked(registeredBackend ?: providers.geofence(), registrableIdsLocked())
|
|
291
|
+
registeredBackend = null
|
|
292
|
+
for (entry in entries.values) {
|
|
293
|
+
entry.observed = false
|
|
294
|
+
entry.initialLatched = false
|
|
295
|
+
entry.lastFix = null
|
|
296
|
+
entry.resetCandidate()
|
|
297
|
+
if (entry.runtime != UNKNOWN) persistRuntime(entry, UNKNOWN)
|
|
298
|
+
}
|
|
299
|
+
refreshDerivedLocked()
|
|
300
|
+
Logger.i(TAG, "tracking stopped: ${entries.size} geofence(s) unregistered")
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
override fun onLocation(location: TrackedLocation) {
|
|
305
|
+
val previous = lastFix
|
|
306
|
+
if (previous == null || location.time >= previous.time) lastFix = location
|
|
307
|
+
if (session == false || !configStore.runtime.value.enabled) return
|
|
308
|
+
if (loaded && !wantsFixes && !dwell.hasArmed()) return
|
|
309
|
+
scope.launch {
|
|
310
|
+
mutex.withLock {
|
|
311
|
+
try {
|
|
312
|
+
handleLocationLocked(location)
|
|
313
|
+
} catch (e: CancellationException) {
|
|
314
|
+
throw e
|
|
315
|
+
} catch (e: Exception) {
|
|
316
|
+
Logger.e(TAG, "failed to process location for geofences", e)
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
override suspend fun onGeofenceTransitions(transitions: List<OsGeofenceTransition>) {
|
|
323
|
+
if (transitions.isEmpty()) return
|
|
324
|
+
// The engine's stationary region (round 2, §3) is routed before any other check: its transitions are never
|
|
325
|
+
// stored, recorded, emitted or counted. They are handed to the engine after the user geofences of the same
|
|
326
|
+
// batch, so the engine's work (a restore, OS calls) does not delay the geofence audit records, and outside
|
|
327
|
+
// [mutex], because the engine holds its own lock while it calls onTrackingStarted / onTrackingStopped.
|
|
328
|
+
val (stationary, others) = transitions.partition { it.id == Constants.STATIONARY_REGION_ID }
|
|
329
|
+
if (others.isNotEmpty()) {
|
|
330
|
+
mutex.withLock {
|
|
331
|
+
if (session == false || !configStore.runtime.value.enabled) {
|
|
332
|
+
Logger.w(TAG, "ignoring ${others.size} geofence transition(s): tracking is stopped")
|
|
333
|
+
return@withLock
|
|
334
|
+
}
|
|
335
|
+
try {
|
|
336
|
+
ensureLoadedLocked()
|
|
337
|
+
} catch (e: CancellationException) {
|
|
338
|
+
throw e
|
|
339
|
+
} catch (e: Exception) {
|
|
340
|
+
Logger.e(TAG, "cannot load geofences; dropping ${others.size} transition(s)", e)
|
|
341
|
+
return@withLock
|
|
342
|
+
}
|
|
343
|
+
for (transition in others) {
|
|
344
|
+
val entry = entries[transition.id]
|
|
345
|
+
if (entry == null) {
|
|
346
|
+
Logger.w(TAG, "ignoring ${transition.action} for unknown geofence '${transition.id}'")
|
|
347
|
+
continue
|
|
348
|
+
}
|
|
349
|
+
Logger.d(TAG, "OS transition ${transition.action} '${transition.id}'")
|
|
350
|
+
if (entry.spec.isPolygon) {
|
|
351
|
+
onPolygonCircleTransitionLocked(entry, transition)
|
|
352
|
+
} else {
|
|
353
|
+
onCircleTransitionLocked(entry, transition)
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
refreshDerivedLocked()
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
for (transition in stationary) {
|
|
360
|
+
try {
|
|
361
|
+
stationarySink.value.onStationaryRegionTransition(transition)
|
|
362
|
+
} catch (e: CancellationException) {
|
|
363
|
+
throw e
|
|
364
|
+
} catch (e: Exception) {
|
|
365
|
+
Logger.e(TAG, "failed to deliver ${transition.action} of the stationary region", e)
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// ---- circles
|
|
371
|
+
|
|
372
|
+
private suspend fun onCircleTransitionLocked(entry: Entry, transition: OsGeofenceTransition) {
|
|
373
|
+
val spec = entry.spec
|
|
374
|
+
val synthesizeDwell = synthesizesDwell(spec, activeBackend())
|
|
375
|
+
val wasObserved = entry.observed
|
|
376
|
+
entry.observed = true
|
|
377
|
+
when (transition.action) {
|
|
378
|
+
GeofenceAction.ENTER -> {
|
|
379
|
+
// With ENTER and EXIT both registered, a second ENTER while inside (the OS initial trigger after
|
|
380
|
+
// re-registering) is a duplicate. Restored state may be stale, so it only counts once observed.
|
|
381
|
+
val tracksState = (spec.notifyOnEntry || synthesizeDwell) && (spec.notifyOnExit || synthesizeDwell)
|
|
382
|
+
if (tracksState && wasObserved && entry.runtime.insideCircle) {
|
|
383
|
+
Logger.d(TAG, "duplicate ENTER '${spec.identifier}' ignored")
|
|
384
|
+
return
|
|
385
|
+
}
|
|
386
|
+
val now = clock.now()
|
|
387
|
+
persistRuntime(entry, GeofenceRuntime(insideCircle = true, insidePolygon = false, enteredAt = now))
|
|
388
|
+
if (spec.notifyOnEntry) notifyLocked(spec, GeofenceAction.ENTER, transition.location)
|
|
389
|
+
if (synthesizeDwell) dwell.arm(spec.identifier, now + spec.loiteringDelay, token = now)
|
|
390
|
+
}
|
|
391
|
+
GeofenceAction.EXIT -> {
|
|
392
|
+
dwell.cancel(spec.identifier)
|
|
393
|
+
persistRuntime(entry, UNKNOWN)
|
|
394
|
+
if (spec.notifyOnExit) notifyLocked(spec, GeofenceAction.EXIT, transition.location)
|
|
395
|
+
}
|
|
396
|
+
GeofenceAction.DWELL -> {
|
|
397
|
+
dwell.cancel(spec.identifier)
|
|
398
|
+
if (!entry.runtime.insideCircle) {
|
|
399
|
+
val now = clock.now()
|
|
400
|
+
persistRuntime(entry, GeofenceRuntime(insideCircle = true, insidePolygon = false, enteredAt = now))
|
|
401
|
+
}
|
|
402
|
+
if (spec.notifyOnDwell) notifyLocked(spec, GeofenceAction.DWELL, transition.location)
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** A restored inside circle is checked against fixes: certainly outside means the OS EXIT was lost. */
|
|
408
|
+
private suspend fun reconcileCircleLocked(entry: Entry, location: TrackedLocation) {
|
|
409
|
+
val spec = entry.spec
|
|
410
|
+
val accuracy = accuracyOf(location)
|
|
411
|
+
val fromCenter = distanceFromCenter(spec, location)
|
|
412
|
+
when {
|
|
413
|
+
fromCenter - accuracy > spec.radius -> {
|
|
414
|
+
Logger.i(TAG, "'${spec.identifier}' was left while tracking was not running")
|
|
415
|
+
entry.observed = true
|
|
416
|
+
dwell.cancel(spec.identifier)
|
|
417
|
+
persistRuntime(entry, UNKNOWN)
|
|
418
|
+
if (spec.notifyOnExit) notifyLocked(spec, GeofenceAction.EXIT, location)
|
|
419
|
+
}
|
|
420
|
+
fromCenter + accuracy <= spec.radius -> entry.observed = true
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// ---- polygons
|
|
425
|
+
|
|
426
|
+
private suspend fun onPolygonCircleTransitionLocked(entry: Entry, transition: OsGeofenceTransition) {
|
|
427
|
+
when (transition.action) {
|
|
428
|
+
GeofenceAction.ENTER -> {
|
|
429
|
+
var runtime = entry.runtime.copy(insideCircle = true)
|
|
430
|
+
if (runtime.enteredAt == null) {
|
|
431
|
+
if (clock.now() <= entry.initialUntil) {
|
|
432
|
+
entry.initialLatched = true // the OS initial trigger: we were in the circle at registration
|
|
433
|
+
} else {
|
|
434
|
+
runtime = runtime.copy(insidePolygon = false, enteredAt = clock.now()) // a real entry
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
if (runtime != entry.runtime) persistRuntime(entry, runtime)
|
|
438
|
+
val location = transition.location ?: return
|
|
439
|
+
val previous = lastStreamFix
|
|
440
|
+
if (previous == null || location.time >= previous.time) evaluatePolygonLocked(entry, location)
|
|
441
|
+
}
|
|
442
|
+
GeofenceAction.EXIT -> {
|
|
443
|
+
// The enclosing circle contains the polygon, so leaving it means leaving the polygon.
|
|
444
|
+
val wasInside = entry.runtime.insidePolygon
|
|
445
|
+
val stateSince = entry.runtime.enteredAt.takeUnless { wasInside } ?: clock.now()
|
|
446
|
+
dwell.cancel(entry.spec.identifier)
|
|
447
|
+
entry.resetCandidate()
|
|
448
|
+
entry.initialLatched = false
|
|
449
|
+
persistRuntime(entry, GeofenceRuntime(insideCircle = false, insidePolygon = false, stateSince))
|
|
450
|
+
if (wasInside && entry.spec.notifyOnExit) {
|
|
451
|
+
notifyLocked(entry.spec, GeofenceAction.EXIT, transition.location)
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
GeofenceAction.DWELL -> Logger.d(TAG, "ignoring OS DWELL for polygon '${entry.spec.identifier}'")
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
private suspend fun handleLocationLocked(location: TrackedLocation) {
|
|
459
|
+
if (session == false || !configStore.runtime.value.enabled) return
|
|
460
|
+
ensureLoadedLocked()
|
|
461
|
+
val previous = lastStreamFix
|
|
462
|
+
if (previous != null) {
|
|
463
|
+
val olderBy = previous.time - location.time
|
|
464
|
+
// Skip a fix older than the previous one (unless the gap is so large that the earlier timestamp must have
|
|
465
|
+
// been bogus) and the same fix delivered twice.
|
|
466
|
+
if (olderBy in 1..MAX_REORDER_MS || (olderBy == 0L && sameFix(previous, location))) return
|
|
467
|
+
}
|
|
468
|
+
lastStreamFix = location
|
|
469
|
+
for (entry in entries.values) {
|
|
470
|
+
when {
|
|
471
|
+
entry.spec.isPolygon -> evaluatePolygonLocked(entry, location)
|
|
472
|
+
!entry.observed && entry.runtime.insideCircle && isActive(entry.spec) ->
|
|
473
|
+
reconcileCircleLocked(entry, location)
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
for ((id, token) in dwell.takeDue()) fireDwellLocked(id, token)
|
|
477
|
+
refreshDerivedLocked()
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/** Classifies [location] against the polygon of [entry] and applies a confirmed state change. */
|
|
481
|
+
private suspend fun evaluatePolygonLocked(entry: Entry, location: TrackedLocation) {
|
|
482
|
+
val spec = entry.spec
|
|
483
|
+
val vertices = spec.vertices ?: return
|
|
484
|
+
if (!isActive(spec)) return
|
|
485
|
+
entry.lastFix?.let { if (sameFix(it, location)) return } // e.g. from an OS transition, then the stream
|
|
486
|
+
entry.lastFix = location
|
|
487
|
+
val accuracy = accuracyOf(location)
|
|
488
|
+
val fromCenter = distanceFromCenter(spec, location)
|
|
489
|
+
val inside: Boolean
|
|
490
|
+
val certain: Boolean
|
|
491
|
+
if (fromCenter - accuracy > spec.radius) {
|
|
492
|
+
inside = false
|
|
493
|
+
certain = true
|
|
494
|
+
// Certainly outside the enclosing circle: covers a late or lost OS EXIT and stale restored state.
|
|
495
|
+
if (entry.runtime.insideCircle) persistRuntime(entry, entry.runtime.copy(insideCircle = false))
|
|
496
|
+
} else {
|
|
497
|
+
inside = PolygonMath.contains(vertices, location.latitude, location.longitude)
|
|
498
|
+
certain = accuracy == 0.0 ||
|
|
499
|
+
PolygonMath.distanceToBoundary(vertices, location.latitude, location.longitude) >= accuracy
|
|
500
|
+
}
|
|
501
|
+
val known = entry.runtime.enteredAt != null
|
|
502
|
+
if (known && inside == entry.runtime.insidePolygon) {
|
|
503
|
+
entry.resetCandidate()
|
|
504
|
+
return
|
|
505
|
+
}
|
|
506
|
+
if (!certain) {
|
|
507
|
+
if (entry.candidate == inside) {
|
|
508
|
+
entry.candidateCount++
|
|
509
|
+
} else {
|
|
510
|
+
entry.candidate = inside
|
|
511
|
+
entry.candidateCount = 1
|
|
512
|
+
}
|
|
513
|
+
if (entry.candidateCount < CONSISTENT_FIXES) return
|
|
514
|
+
}
|
|
515
|
+
entry.resetCandidate()
|
|
516
|
+
|
|
517
|
+
val now = clock.now()
|
|
518
|
+
val silent = !known && !configStore.config.value.geofence.initialTriggerEntry &&
|
|
519
|
+
(entry.initialLatched || now <= entry.initialUntil)
|
|
520
|
+
entry.initialLatched = false
|
|
521
|
+
if (inside && silent) {
|
|
522
|
+
persistRuntime(entry, entry.runtime.copy(insidePolygon = true, enteredAt = ENTERED_BEFORE_MONITORING))
|
|
523
|
+
} else if (inside) {
|
|
524
|
+
persistRuntime(entry, entry.runtime.copy(insidePolygon = true, enteredAt = now))
|
|
525
|
+
if (spec.notifyOnEntry) notifyLocked(spec, GeofenceAction.ENTER, location)
|
|
526
|
+
if (spec.notifyOnDwell) dwell.arm(spec.identifier, now + spec.loiteringDelay, token = now)
|
|
527
|
+
} else {
|
|
528
|
+
dwell.cancel(spec.identifier)
|
|
529
|
+
persistRuntime(entry, entry.runtime.copy(insidePolygon = false, enteredAt = now))
|
|
530
|
+
if (known && spec.notifyOnExit) notifyLocked(spec, GeofenceAction.EXIT, location)
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// ---- dwell
|
|
535
|
+
|
|
536
|
+
/** Emits a synthesized DWELL if [id] is still inside since [token] (the `enteredAt` the timer was armed with). */
|
|
537
|
+
private suspend fun fireDwellLocked(id: String, token: Long) {
|
|
538
|
+
if (session == false || !configStore.runtime.value.enabled) return
|
|
539
|
+
val entry = entries[id] ?: return
|
|
540
|
+
val inside = if (entry.spec.isPolygon) entry.runtime.insidePolygon else entry.runtime.insideCircle
|
|
541
|
+
if (!inside || entry.runtime.enteredAt != token || !entry.spec.notifyOnDwell) return
|
|
542
|
+
notifyLocked(entry.spec, GeofenceAction.DWELL, null)
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/** Aligns synthesized dwell timers with the current backend and the (possibly restored) runtime. */
|
|
546
|
+
private fun refreshDwellTimersLocked() {
|
|
547
|
+
val now = clock.now()
|
|
548
|
+
val backend = activeBackend()
|
|
549
|
+
for (entry in entries.values) {
|
|
550
|
+
val spec = entry.spec
|
|
551
|
+
val synthesized = spec.notifyOnDwell && (spec.isPolygon || synthesizesDwell(spec, backend))
|
|
552
|
+
if (!synthesized) {
|
|
553
|
+
dwell.cancel(spec.identifier)
|
|
554
|
+
continue
|
|
555
|
+
}
|
|
556
|
+
if (dwell.isArmed(spec.identifier)) continue
|
|
557
|
+
val enteredAt = entry.runtime.enteredAt ?: continue
|
|
558
|
+
val inside = if (spec.isPolygon) entry.runtime.insidePolygon else entry.runtime.insideCircle
|
|
559
|
+
val deadline = enteredAt + spec.loiteringDelay
|
|
560
|
+
// A deadline that passed while the process was gone is assumed to have fired already.
|
|
561
|
+
if (inside && enteredAt != ENTERED_BEFORE_MONITORING && deadline > now) {
|
|
562
|
+
dwell.arm(spec.identifier, deadline, token = enteredAt)
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// ---- records
|
|
568
|
+
|
|
569
|
+
/** Creates, submits and emits a geofence record. Never throws (except cancellation). */
|
|
570
|
+
private suspend fun notifyLocked(spec: GeofenceSpec, action: GeofenceAction, location: TrackedLocation?) {
|
|
571
|
+
try {
|
|
572
|
+
val hit = GeofenceHit(spec.identifier, action, spec.extras)
|
|
573
|
+
val record = recordFactory.create(RecordEvent.GEOFENCE, location ?: bestKnownLocation(), geofence = hit)
|
|
574
|
+
try {
|
|
575
|
+
recordSink.submit(record)
|
|
576
|
+
} catch (e: CancellationException) {
|
|
577
|
+
throw e
|
|
578
|
+
} catch (e: Exception) {
|
|
579
|
+
Logger.e(TAG, "failed to submit geofence record ${record.uuid}", e)
|
|
580
|
+
}
|
|
581
|
+
Logger.i(TAG, "geofence $action '${spec.identifier}'")
|
|
582
|
+
events.emit(TrackingEvent.Geofence(spec.identifier, action, record, spec.extras))
|
|
583
|
+
} catch (e: CancellationException) {
|
|
584
|
+
throw e
|
|
585
|
+
} catch (e: Exception) {
|
|
586
|
+
Logger.e(TAG, "failed to record geofence $action '${spec.identifier}'", e)
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
private fun bestKnownLocation(): TrackedLocation? {
|
|
591
|
+
val fix = lastFix
|
|
592
|
+
val last = configStore.runtime.value.lastLocation
|
|
593
|
+
return when {
|
|
594
|
+
fix == null -> last
|
|
595
|
+
last == null -> fix
|
|
596
|
+
else -> if (fix.time >= last.time) fix else last
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// ---- OS registration
|
|
601
|
+
|
|
602
|
+
private fun activeBackend(): GeofenceBackend = registeredBackend ?: providers.geofence()
|
|
603
|
+
|
|
604
|
+
/** The backend to register with; moves the registrations if the provider changed since they were made. */
|
|
605
|
+
private suspend fun backendLocked(): GeofenceBackend {
|
|
606
|
+
val current = providers.geofence()
|
|
607
|
+
val previous = registeredBackend
|
|
608
|
+
registeredBackend = current
|
|
609
|
+
if (previous != null && previous.kind != current.kind) {
|
|
610
|
+
Logger.i(TAG, "geofence backend changed ${previous.kind.wire} -> ${current.kind.wire}")
|
|
611
|
+
removeFromOsLocked(previous, registrableIdsLocked())
|
|
612
|
+
cancelRetryLocked()
|
|
613
|
+
if (registerAllLocked(current) && session == true) scheduleRetryLocked()
|
|
614
|
+
refreshDwellTimersLocked()
|
|
615
|
+
}
|
|
616
|
+
return current
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* Registers every stored geofence; the OS's initial triggers follow, so polygons open their initial window.
|
|
621
|
+
* Returns true when the backend answered UNAVAILABLE for a batch (see [scheduleRetryLocked]).
|
|
622
|
+
*/
|
|
623
|
+
private suspend fun registerAllLocked(backend: GeofenceBackend): Boolean {
|
|
624
|
+
val now = clock.now()
|
|
625
|
+
for (entry in entries.values) openInitialWindowLocked(entry, now)
|
|
626
|
+
return registerLocked(backend, entries.values.map { it.spec }, throwOnError = false)
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/**
|
|
630
|
+
* Registers every geofence again after [RETRY_DELAYS_MS] (the next delay on each failure), because the backend
|
|
631
|
+
* answered UNAVAILABLE. Google Play services answers GEOFENCE_NOT_AVAILABLE while its network location is off,
|
|
632
|
+
* and the plugin registers again within about a second of location services coming back on, which can be before
|
|
633
|
+
* Play services has switched its network location on again (CI emulator, API 29, P-P06). Without a retry the
|
|
634
|
+
* geofences stayed unregistered until tracking started again. Stops at the first registration without
|
|
635
|
+
* UNAVAILABLE, when tracking stops or starts again, or after the last delay.
|
|
636
|
+
*/
|
|
637
|
+
private fun scheduleRetryLocked() {
|
|
638
|
+
if (retryAttempt >= RETRY_DELAYS_MS.size) {
|
|
639
|
+
Logger.w(TAG, "geofence backend still unavailable after ${RETRY_DELAYS_MS.size} retries; giving up until tracking starts again")
|
|
640
|
+
return
|
|
641
|
+
}
|
|
642
|
+
val delayMs = RETRY_DELAYS_MS[retryAttempt]
|
|
643
|
+
retryAttempt += 1
|
|
644
|
+
val attempt = retryAttempt
|
|
645
|
+
Logger.i(TAG, "geofence backend unavailable; registering the geofences again in ${delayMs / 1000} s (retry $attempt)")
|
|
646
|
+
retryJob?.cancel()
|
|
647
|
+
retryJob = scope.launch {
|
|
648
|
+
delay(delayMs)
|
|
649
|
+
mutex.withLock {
|
|
650
|
+
if (session != true || retryAttempt != attempt) return@withLock
|
|
651
|
+
val backend = registeredBackend ?: return@withLock
|
|
652
|
+
if (registerAllLocked(backend)) {
|
|
653
|
+
scheduleRetryLocked()
|
|
654
|
+
} else {
|
|
655
|
+
Logger.i(TAG, "geofences registered again (retry $attempt)")
|
|
656
|
+
retryAttempt = 0
|
|
657
|
+
refreshDwellTimersLocked()
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
private fun cancelRetryLocked() {
|
|
664
|
+
retryJob?.cancel()
|
|
665
|
+
retryJob = null
|
|
666
|
+
retryAttempt = 0
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* Starts the initial-trigger window of an unclassified polygon being registered. A fresh known fix that is
|
|
671
|
+
* certainly outside its enclosing circle settles the state as outside instead, so an entry soon after
|
|
672
|
+
* registration is not mistaken for the OS's initial trigger.
|
|
673
|
+
*/
|
|
674
|
+
private suspend fun openInitialWindowLocked(entry: Entry, now: Long) {
|
|
675
|
+
entry.initialUntil = now + INITIAL_TRIGGER_WINDOW_MS
|
|
676
|
+
entry.initialLatched = false
|
|
677
|
+
val spec = entry.spec
|
|
678
|
+
if (!spec.isPolygon || entry.runtime.enteredAt != null) return
|
|
679
|
+
val fix = bestKnownLocation() ?: return
|
|
680
|
+
if (now - fix.time > INITIAL_TRIGGER_WINDOW_MS) return
|
|
681
|
+
if (distanceFromCenter(spec, fix) - accuracyOf(fix) > spec.radius) {
|
|
682
|
+
persistRuntime(entry, GeofenceRuntime(insideCircle = false, insidePolygon = false, enteredAt = now))
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/** Returns true when a batch failed with UNAVAILABLE (only when [throwOnError] is false). */
|
|
687
|
+
private suspend fun registerLocked(backend: GeofenceBackend, specs: List<GeofenceSpec>, throwOnError: Boolean): Boolean {
|
|
688
|
+
val initialTriggerEntry = configStore.config.value.geofence.initialTriggerEntry
|
|
689
|
+
val regions = specs.mapNotNull { toOsGeofence(it, backend, initialTriggerEntry) }
|
|
690
|
+
var unavailable = false
|
|
691
|
+
for (batch in regions.chunked(BATCH_SIZE)) {
|
|
692
|
+
try {
|
|
693
|
+
backend.add(batch)
|
|
694
|
+
} catch (e: CancellationException) {
|
|
695
|
+
throw e
|
|
696
|
+
} catch (e: Exception) {
|
|
697
|
+
if (throwOnError) throw e
|
|
698
|
+
Logger.e(TAG, "failed to register ${batch.size} geofence(s) with ${backend.kind.wire}", e)
|
|
699
|
+
if (e is TrackingException && e.code == ErrorCode.UNAVAILABLE) unavailable = true
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
return unavailable
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
private suspend fun removeFromOsLocked(backend: GeofenceBackend, ids: List<String>) {
|
|
706
|
+
for (batch in ids.chunked(BATCH_SIZE)) {
|
|
707
|
+
try {
|
|
708
|
+
backend.remove(batch)
|
|
709
|
+
} catch (e: CancellationException) {
|
|
710
|
+
throw e
|
|
711
|
+
} catch (e: Exception) {
|
|
712
|
+
Logger.w(TAG, "failed to unregister ${batch.size} geofence(s) from ${backend.kind.wire}", e)
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/** Best effort: unregisters [newIds] and re-registers the [replaced] versions after a failed add. */
|
|
718
|
+
private suspend fun rollbackRegistrationLocked(
|
|
719
|
+
backend: GeofenceBackend,
|
|
720
|
+
newIds: List<String>,
|
|
721
|
+
replaced: List<GeofenceSpec>,
|
|
722
|
+
) {
|
|
723
|
+
removeFromOsLocked(backend, newIds)
|
|
724
|
+
registerLocked(backend, replaced, throwOnError = false)
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
/** Ids of the geofences that [toOsGeofence] registers. */
|
|
728
|
+
private fun registrableIdsLocked(): List<String> =
|
|
729
|
+
entries.values.filter { isActive(it.spec) }.map { it.spec.identifier }
|
|
730
|
+
|
|
731
|
+
// ---- state
|
|
732
|
+
|
|
733
|
+
private suspend fun ensureLoadedLocked() {
|
|
734
|
+
if (loaded) return
|
|
735
|
+
val specs = geofenceStore.all()
|
|
736
|
+
val runtimes = geofenceStore.runtimes()
|
|
737
|
+
// Runtime is only meaningful while tracking; stale values from a stopped session are discarded.
|
|
738
|
+
val keepRuntime = configStore.runtime.value.enabled
|
|
739
|
+
entries.clear()
|
|
740
|
+
for (stored in specs) {
|
|
741
|
+
val spec = if (stored.isPolygon && !(stored.radius > 0f)) withEnclosingCircle(stored) else stored
|
|
742
|
+
val runtime = runtimes[spec.identifier]
|
|
743
|
+
entries[spec.identifier] = Entry(spec, if (keepRuntime) runtime ?: UNKNOWN else UNKNOWN)
|
|
744
|
+
if (!keepRuntime && runtime != null && runtime != UNKNOWN) persistRuntime(spec.identifier, UNKNOWN)
|
|
745
|
+
}
|
|
746
|
+
loaded = true
|
|
747
|
+
refreshDerivedLocked()
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
private fun refreshDerivedLocked() {
|
|
751
|
+
val polygons = entries.values.filter { it.spec.isPolygon && isActive(it.spec) }
|
|
752
|
+
wantsFixes = polygons.isNotEmpty() ||
|
|
753
|
+
entries.values.any { !it.spec.isPolygon && !it.observed && it.runtime.insideCircle && isActive(it.spec) }
|
|
754
|
+
needs.value = session == true && polygons.any { it.runtime.insideCircle || it.runtime.insidePolygon }
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
private suspend fun persistRuntime(entry: Entry, runtime: GeofenceRuntime) {
|
|
758
|
+
entry.runtime = runtime
|
|
759
|
+
persistRuntime(entry.spec.identifier, runtime)
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
private suspend fun persistRuntime(id: String, runtime: GeofenceRuntime) {
|
|
763
|
+
try {
|
|
764
|
+
geofenceStore.setRuntime(id, runtime)
|
|
765
|
+
} catch (e: CancellationException) {
|
|
766
|
+
throw e
|
|
767
|
+
} catch (e: Exception) {
|
|
768
|
+
Logger.w(TAG, "failed to persist runtime of geofence '$id'", e)
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
private companion object {
|
|
773
|
+
const val TAG = "LT.Geofences"
|
|
774
|
+
|
|
775
|
+
/** Geofences per OS registration call. */
|
|
776
|
+
const val BATCH_SIZE = 25
|
|
777
|
+
|
|
778
|
+
/** Delays of the registrations after the backend answered UNAVAILABLE (about 8.7 minutes in all). */
|
|
779
|
+
val RETRY_DELAYS_MS = longArrayOf(10_000L, 30_000L, 60_000L, 120_000L, 300_000L)
|
|
780
|
+
|
|
781
|
+
/** Consecutive agreeing fixes that confirm a polygon transition when accuracy straddles the boundary. */
|
|
782
|
+
const val CONSISTENT_FIXES = 2
|
|
783
|
+
|
|
784
|
+
/** How long after registration the OS's initial circle ENTER is expected. */
|
|
785
|
+
const val INITIAL_TRIGGER_WINDOW_MS = 120_000L
|
|
786
|
+
|
|
787
|
+
/** A fix older than the last evaluated one by up to this much is out of order and skipped. */
|
|
788
|
+
const val MAX_REORDER_MS = 120_000L
|
|
789
|
+
|
|
790
|
+
/** `enteredAt` of a polygon found inside by its silent initial classification (no ENTER, no DWELL). */
|
|
791
|
+
const val ENTERED_BEFORE_MONITORING = 0L
|
|
792
|
+
|
|
793
|
+
/** Longest identifier accepted (the GMS / HMS request-id limit). */
|
|
794
|
+
const val MAX_IDENTIFIER_LENGTH = 100
|
|
795
|
+
|
|
796
|
+
/** Smallest polygon area (m²) that is not considered degenerate. */
|
|
797
|
+
const val MIN_POLYGON_AREA_M2 = 1.0
|
|
798
|
+
|
|
799
|
+
/** Runtime of a geofence whose state is not known. */
|
|
800
|
+
val UNKNOWN = GeofenceRuntime(insideCircle = false, insidePolygon = false, enteredAt = null)
|
|
801
|
+
|
|
802
|
+
fun isActive(spec: GeofenceSpec): Boolean = spec.notifyOnEntry || spec.notifyOnExit || spec.notifyOnDwell
|
|
803
|
+
|
|
804
|
+
/** Circle DWELL is synthesized when the backend cannot report it. */
|
|
805
|
+
fun synthesizesDwell(spec: GeofenceSpec, backend: GeofenceBackend): Boolean =
|
|
806
|
+
!spec.isPolygon && spec.notifyOnDwell && !backend.supportsDwell
|
|
807
|
+
|
|
808
|
+
fun sameFix(a: TrackedLocation, b: TrackedLocation): Boolean =
|
|
809
|
+
a.time == b.time && a.latitude == b.latitude && a.longitude == b.longitude
|
|
810
|
+
|
|
811
|
+
fun distanceFromCenter(spec: GeofenceSpec, location: TrackedLocation): Double =
|
|
812
|
+
PolygonMath.distanceMeters(spec.latitude, spec.longitude, location.latitude, location.longitude)
|
|
813
|
+
|
|
814
|
+
fun accuracyOf(location: TrackedLocation): Double =
|
|
815
|
+
location.accuracy.toDouble().takeIf { it.isFinite() && it > 0 } ?: 0.0
|
|
816
|
+
|
|
817
|
+
fun toOsGeofence(spec: GeofenceSpec, backend: GeofenceBackend, initialTriggerEntry: Boolean): OsGeofence? {
|
|
818
|
+
if (!isActive(spec)) return null
|
|
819
|
+
val loiteringDelay = spec.loiteringDelay.coerceIn(0L, Int.MAX_VALUE.toLong()).toInt()
|
|
820
|
+
if (spec.isPolygon) {
|
|
821
|
+
// The circle only tells us when hit-testing is needed; the initial ENTER is always wanted.
|
|
822
|
+
return OsGeofence(
|
|
823
|
+
id = spec.identifier,
|
|
824
|
+
latitude = spec.latitude,
|
|
825
|
+
longitude = spec.longitude,
|
|
826
|
+
radius = spec.radius,
|
|
827
|
+
onEntry = true,
|
|
828
|
+
onExit = true,
|
|
829
|
+
onDwell = false,
|
|
830
|
+
loiteringDelayMs = loiteringDelay,
|
|
831
|
+
initialTriggerEntry = true,
|
|
832
|
+
)
|
|
833
|
+
}
|
|
834
|
+
val synthesizeDwell = synthesizesDwell(spec, backend)
|
|
835
|
+
return OsGeofence(
|
|
836
|
+
id = spec.identifier,
|
|
837
|
+
latitude = spec.latitude,
|
|
838
|
+
longitude = spec.longitude,
|
|
839
|
+
radius = spec.radius,
|
|
840
|
+
onEntry = spec.notifyOnEntry || synthesizeDwell,
|
|
841
|
+
onExit = spec.notifyOnExit || synthesizeDwell,
|
|
842
|
+
onDwell = spec.notifyOnDwell && backend.supportsDwell,
|
|
843
|
+
loiteringDelayMs = loiteringDelay,
|
|
844
|
+
initialTriggerEntry = initialTriggerEntry,
|
|
845
|
+
)
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
/**
|
|
849
|
+
* Checks [spec] and, for a polygon, fills in its enclosing circle.
|
|
850
|
+
*
|
|
851
|
+
* @throws TrackingException INVALID_ARGUMENT
|
|
852
|
+
*/
|
|
853
|
+
fun validated(spec: GeofenceSpec): GeofenceSpec {
|
|
854
|
+
val id = spec.identifier
|
|
855
|
+
if (id.isBlank()) invalid("geofence identifier is required")
|
|
856
|
+
if (id == Constants.STATIONARY_REGION_ID) {
|
|
857
|
+
invalid("geofence identifier '$id' is reserved for the plugin's stationary region")
|
|
858
|
+
}
|
|
859
|
+
if (id.length > MAX_IDENTIFIER_LENGTH) {
|
|
860
|
+
invalid("geofence identifier is longer than $MAX_IDENTIFIER_LENGTH characters")
|
|
861
|
+
}
|
|
862
|
+
if (spec.loiteringDelay < 0) invalid("geofence '$id': loiteringDelay must be >= 0")
|
|
863
|
+
val vertices = spec.vertices
|
|
864
|
+
if (vertices == null) {
|
|
865
|
+
if (!PolygonMath.isValidLatLng(spec.latitude, spec.longitude)) {
|
|
866
|
+
invalid("geofence '$id': invalid latitude/longitude (${spec.latitude}, ${spec.longitude})")
|
|
867
|
+
}
|
|
868
|
+
if (!(spec.radius.isFinite() && spec.radius > 0f)) invalid("geofence '$id': radius must be > 0")
|
|
869
|
+
return spec
|
|
870
|
+
}
|
|
871
|
+
if (vertices.size < 3) invalid("geofence '$id': a polygon needs at least 3 vertices")
|
|
872
|
+
vertices.forEachIndexed { i, v ->
|
|
873
|
+
if (!PolygonMath.isValidLatLng(v.latitude, v.longitude)) {
|
|
874
|
+
invalid("geofence '$id': invalid vertices[$i] (${v.latitude}, ${v.longitude})")
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
if (vertices.maxOf { it.longitude } - vertices.minOf { it.longitude } > 180.0) {
|
|
878
|
+
invalid("geofence '$id': polygons crossing the anti-meridian are not supported")
|
|
879
|
+
}
|
|
880
|
+
if (vertices.distinct().size < 3 || PolygonMath.areaSquareMeters(vertices) < MIN_POLYGON_AREA_M2) {
|
|
881
|
+
invalid("geofence '$id': the polygon has no area")
|
|
882
|
+
}
|
|
883
|
+
return withEnclosingCircle(spec)
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
fun withEnclosingCircle(spec: GeofenceSpec): GeofenceSpec {
|
|
887
|
+
val circle = PolygonMath.enclosingCircle(spec.vertices ?: return spec)
|
|
888
|
+
return spec.copy(latitude = circle.latitude, longitude = circle.longitude, radius = circle.radius.toFloat())
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
fun invalid(message: String): Nothing = throw TrackingException(ErrorCode.INVALID_ARGUMENT, message)
|
|
892
|
+
|
|
893
|
+
fun Exception.toTrackingException(code: ErrorCode, message: String): TrackingException = when (this) {
|
|
894
|
+
is TrackingException -> this
|
|
895
|
+
is SecurityException -> TrackingException(ErrorCode.PERMISSION_DENIED, "$message: ${this.message}", this)
|
|
896
|
+
else -> TrackingException(code, "$message: ${this.message}", this)
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
}
|