@bricks-soft/capacitor-location-tracking 0.0.0-stage → 8.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +2759 -2
- package/android/build.gradle +107 -0
- package/android/consumer-rules.pro +42 -0
- package/android/src/main/AndroidManifest.xml +52 -0
- package/android/src/main/java/com/brickssoft/locationtracking/LocationTrackingPlugin.kt +431 -0
- package/android/src/main/java/com/brickssoft/locationtracking/api/LocationTrackingListener.kt +52 -0
- package/android/src/main/java/com/brickssoft/locationtracking/api/LocationTrackingNative.kt +267 -0
- package/android/src/main/java/com/brickssoft/locationtracking/api/NativeListeners.kt +270 -0
- package/android/src/main/java/com/brickssoft/locationtracking/api/NativeThread.kt +57 -0
- package/android/src/main/java/com/brickssoft/locationtracking/bridge/BridgeServices.kt +64 -0
- package/android/src/main/java/com/brickssoft/locationtracking/bridge/CallResponder.kt +41 -0
- package/android/src/main/java/com/brickssoft/locationtracking/bridge/OptionParsers.kt +281 -0
- package/android/src/main/java/com/brickssoft/locationtracking/bridge/PluginHandlers.kt +541 -0
- package/android/src/main/java/com/brickssoft/locationtracking/bridge/PluginPermissionHost.kt +76 -0
- package/android/src/main/java/com/brickssoft/locationtracking/bridge/WatchRegistry.kt +84 -0
- package/android/src/main/java/com/brickssoft/locationtracking/config/Config.kt +231 -0
- package/android/src/main/java/com/brickssoft/locationtracking/config/ConfigJson.kt +574 -0
- package/android/src/main/java/com/brickssoft/locationtracking/config/ConfigStore.kt +31 -0
- package/android/src/main/java/com/brickssoft/locationtracking/config/ConfigValidator.kt +170 -0
- package/android/src/main/java/com/brickssoft/locationtracking/config/RuntimeStateJson.kt +73 -0
- package/android/src/main/java/com/brickssoft/locationtracking/config/SharedPrefsConfigStore.kt +135 -0
- package/android/src/main/java/com/brickssoft/locationtracking/config/State.kt +45 -0
- package/android/src/main/java/com/brickssoft/locationtracking/core/AppDispatchers.kt +21 -0
- package/android/src/main/java/com/brickssoft/locationtracking/core/Clock.kt +41 -0
- package/android/src/main/java/com/brickssoft/locationtracking/core/Components.kt +217 -0
- package/android/src/main/java/com/brickssoft/locationtracking/core/Constants.kt +78 -0
- package/android/src/main/java/com/brickssoft/locationtracking/core/Errors.kt +27 -0
- package/android/src/main/java/com/brickssoft/locationtracking/core/EventBus.kt +39 -0
- package/android/src/main/java/com/brickssoft/locationtracking/core/ForceStopProbe.kt +63 -0
- package/android/src/main/java/com/brickssoft/locationtracking/core/Iso8601.kt +63 -0
- package/android/src/main/java/com/brickssoft/locationtracking/core/Logger.kt +68 -0
- package/android/src/main/java/com/brickssoft/locationtracking/core/RecordHooks.kt +47 -0
- package/android/src/main/java/com/brickssoft/locationtracking/core/TrackingEvent.kt +64 -0
- package/android/src/main/java/com/brickssoft/locationtracking/data/SqlSupport.kt +79 -0
- package/android/src/main/java/com/brickssoft/locationtracking/data/SqliteGeofenceStore.kt +144 -0
- package/android/src/main/java/com/brickssoft/locationtracking/data/SqliteLocationStore.kt +215 -0
- package/android/src/main/java/com/brickssoft/locationtracking/data/Stores.kt +45 -0
- package/android/src/main/java/com/brickssoft/locationtracking/data/TrackingDatabase.kt +111 -0
- package/android/src/main/java/com/brickssoft/locationtracking/device/DefaultDeviceInfoProvider.kt +104 -0
- package/android/src/main/java/com/brickssoft/locationtracking/device/DefaultDeviceMonitor.kt +335 -0
- package/android/src/main/java/com/brickssoft/locationtracking/device/DeviceMonitor.kt +36 -0
- package/android/src/main/java/com/brickssoft/locationtracking/device/DeviceStateMapping.kt +69 -0
- package/android/src/main/java/com/brickssoft/locationtracking/engine/DefaultTrackingEngine.kt +1281 -0
- package/android/src/main/java/com/brickssoft/locationtracking/engine/ElasticDistance.kt +68 -0
- package/android/src/main/java/com/brickssoft/locationtracking/engine/LocationRequests.kt +74 -0
- package/android/src/main/java/com/brickssoft/locationtracking/engine/MotionStateMachine.kt +291 -0
- package/android/src/main/java/com/brickssoft/locationtracking/engine/StationaryRegion.kt +41 -0
- package/android/src/main/java/com/brickssoft/locationtracking/engine/TrackingEngine.kt +60 -0
- package/android/src/main/java/com/brickssoft/locationtracking/engine/TrackingTimers.kt +90 -0
- package/android/src/main/java/com/brickssoft/locationtracking/geofence/DefaultGeofenceManager.kt +899 -0
- package/android/src/main/java/com/brickssoft/locationtracking/geofence/DwellTracker.kt +80 -0
- package/android/src/main/java/com/brickssoft/locationtracking/geofence/GeofenceManager.kt +33 -0
- package/android/src/main/java/com/brickssoft/locationtracking/geofence/PolygonMath.kt +194 -0
- package/android/src/main/java/com/brickssoft/locationtracking/heartbeat/DefaultHeartbeatScheduler.kt +713 -0
- package/android/src/main/java/com/brickssoft/locationtracking/heartbeat/HeartbeatAlarmReceiver.kt +69 -0
- package/android/src/main/java/com/brickssoft/locationtracking/heartbeat/HeartbeatAlarms.kt +100 -0
- package/android/src/main/java/com/brickssoft/locationtracking/heartbeat/HeartbeatScheduler.kt +25 -0
- package/android/src/main/java/com/brickssoft/locationtracking/heartbeat/HeartbeatWindow.kt +179 -0
- package/android/src/main/java/com/brickssoft/locationtracking/http/AuthorizationManager.kt +213 -0
- package/android/src/main/java/com/brickssoft/locationtracking/http/BodyBuilder.kt +73 -0
- package/android/src/main/java/com/brickssoft/locationtracking/http/HttpSupport.kt +118 -0
- package/android/src/main/java/com/brickssoft/locationtracking/http/HttpSyncer.kt +18 -0
- package/android/src/main/java/com/brickssoft/locationtracking/http/OkHttpSyncer.kt +551 -0
- package/android/src/main/java/com/brickssoft/locationtracking/http/StrictJson.kt +135 -0
- package/android/src/main/java/com/brickssoft/locationtracking/http/SyncPolicy.kt +109 -0
- package/android/src/main/java/com/brickssoft/locationtracking/http/TemplateRenderer.kt +143 -0
- package/android/src/main/java/com/brickssoft/locationtracking/logging/FileLogger.kt +461 -0
- package/android/src/main/java/com/brickssoft/locationtracking/logging/LogFileProvider.kt +9 -0
- package/android/src/main/java/com/brickssoft/locationtracking/logging/LogFormatter.kt +148 -0
- package/android/src/main/java/com/brickssoft/locationtracking/logging/LogStore.kt +30 -0
- package/android/src/main/java/com/brickssoft/locationtracking/model/DeviceModels.kt +176 -0
- package/android/src/main/java/com/brickssoft/locationtracking/model/GeofenceSpec.kt +25 -0
- package/android/src/main/java/com/brickssoft/locationtracking/model/ModelJson.kt +550 -0
- package/android/src/main/java/com/brickssoft/locationtracking/model/Record.kt +106 -0
- package/android/src/main/java/com/brickssoft/locationtracking/model/TrackedLocation.kt +53 -0
- package/android/src/main/java/com/brickssoft/locationtracking/permission/BackgroundRationaleDialog.kt +85 -0
- package/android/src/main/java/com/brickssoft/locationtracking/permission/CurrentActivityTracker.kt +72 -0
- package/android/src/main/java/com/brickssoft/locationtracking/permission/DefaultPermissionManager.kt +333 -0
- package/android/src/main/java/com/brickssoft/locationtracking/permission/PermissionManager.kt +54 -0
- package/android/src/main/java/com/brickssoft/locationtracking/position/DefaultPositionService.kt +277 -0
- package/android/src/main/java/com/brickssoft/locationtracking/position/PositionService.kt +39 -0
- package/android/src/main/java/com/brickssoft/locationtracking/position/PositionWatch.kt +115 -0
- package/android/src/main/java/com/brickssoft/locationtracking/processing/DefaultLocationProcessor.kt +93 -0
- package/android/src/main/java/com/brickssoft/locationtracking/processing/DefaultOdometer.kt +99 -0
- package/android/src/main/java/com/brickssoft/locationtracking/processing/DefaultRecordFactory.kt +166 -0
- package/android/src/main/java/com/brickssoft/locationtracking/processing/Geo.kt +56 -0
- package/android/src/main/java/com/brickssoft/locationtracking/processing/KalmanFilter.kt +72 -0
- package/android/src/main/java/com/brickssoft/locationtracking/processing/Processing.kt +48 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/Backends.kt +187 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/DefaultProviderFactory.kt +169 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/ProviderPackaging.kt +135 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/android/AndroidActivityBackend.kt +25 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/android/AndroidGeofenceBackend.kt +181 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/android/AndroidGeofenceReceiver.kt +115 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/android/AndroidLocationBackend.kt +265 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/android/AndroidProviderBundle.kt +31 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/android/LocationProviders.kt +98 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsActivityBackend.kt +80 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsActivityReceiver.kt +62 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsErrors.kt +72 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsGeofenceBackend.kt +152 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsGeofenceReceiver.kt +78 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsLocationBackend.kt +176 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsProviderBundle.kt +62 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsReceiverSupport.kt +88 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsTasks.kt +55 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsActivityBackend.kt +71 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsActivityReceiver.kt +57 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsErrors.kt +76 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsGeofenceBackend.kt +116 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsGeofenceReceiver.kt +67 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsIntents.kt +83 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsLocationBackend.kt +197 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsProviderBundle.kt +55 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsTasks.kt +44 -0
- package/android/src/main/java/com/brickssoft/locationtracking/record/RecordSink.kt +95 -0
- package/android/src/main/java/com/brickssoft/locationtracking/service/BootCountStore.kt +44 -0
- package/android/src/main/java/com/brickssoft/locationtracking/service/BootReceiver.kt +175 -0
- package/android/src/main/java/com/brickssoft/locationtracking/service/DefaultServiceController.kt +196 -0
- package/android/src/main/java/com/brickssoft/locationtracking/service/LocationTrackingService.kt +319 -0
- package/android/src/main/java/com/brickssoft/locationtracking/service/NotificationActionReceiver.kt +42 -0
- package/android/src/main/java/com/brickssoft/locationtracking/service/NotificationFactory.kt +338 -0
- package/android/src/main/java/com/brickssoft/locationtracking/service/ServiceCommands.kt +204 -0
- package/android/src/main/java/com/brickssoft/locationtracking/service/ServiceController.kt +22 -0
- package/android/src/main/java/com/brickssoft/locationtracking/service/ServiceDeps.kt +61 -0
- package/android/src/main/java/com/brickssoft/locationtracking/service/WhileInUseStartCheck.kt +115 -0
- package/android/src/main/java/com/brickssoft/locationtracking/settings/DefaultDeviceSettings.kt +79 -0
- package/android/src/main/java/com/brickssoft/locationtracking/settings/DeviceSettings.kt +21 -0
- package/android/src/main/java/com/brickssoft/locationtracking/settings/OemPowerManagers.kt +198 -0
- package/android/src/main/res/drawable/lt_ic_notification.xml +11 -0
- package/android/src/main/res/values/lt_strings.xml +11 -0
- package/android/src/main/res/xml/lt_file_paths.xml +5 -0
- package/dist/esm/definitions.d.ts +719 -0
- package/dist/esm/definitions.js +2 -0
- package/dist/esm/definitions.js.map +1 -0
- package/dist/esm/events.d.ts +50 -0
- package/dist/esm/events.js +52 -0
- package/dist/esm/events.js.map +1 -0
- package/dist/esm/index.d.ts +3 -0
- package/dist/esm/index.js +5 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/plugin.d.ts +3 -0
- package/dist/esm/plugin.js +10 -0
- package/dist/esm/plugin.js.map +1 -0
- package/dist/esm/web/config.d.ts +19 -0
- package/dist/esm/web/config.js +243 -0
- package/dist/esm/web/config.js.map +1 -0
- package/dist/esm/web/device.d.ts +9 -0
- package/dist/esm/web/device.js +90 -0
- package/dist/esm/web/device.js.map +1 -0
- package/dist/esm/web/errors.d.ts +14 -0
- package/dist/esm/web/errors.js +19 -0
- package/dist/esm/web/errors.js.map +1 -0
- package/dist/esm/web/geolocation.d.ts +26 -0
- package/dist/esm/web/geolocation.js +117 -0
- package/dist/esm/web/geolocation.js.map +1 -0
- package/dist/esm/web/location.d.ts +7 -0
- package/dist/esm/web/location.js +70 -0
- package/dist/esm/web/location.js.map +1 -0
- package/dist/esm/web.d.ts +164 -0
- package/dist/esm/web.js +328 -0
- package/dist/esm/web.js.map +1 -0
- package/dist/plugin.cjs.js +945 -0
- package/dist/plugin.cjs.js.map +1 -0
- package/dist/plugin.js +948 -0
- package/dist/plugin.js.map +1 -0
- package/package.json +60 -4
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
package com.brickssoft.locationtracking.engine
|
|
2
|
+
|
|
3
|
+
import com.brickssoft.locationtracking.config.GeolocationConfig
|
|
4
|
+
import com.brickssoft.locationtracking.model.TrackedLocation
|
|
5
|
+
import kotlin.math.asin
|
|
6
|
+
import kotlin.math.cos
|
|
7
|
+
import kotlin.math.floor
|
|
8
|
+
import kotlin.math.max
|
|
9
|
+
import kotlin.math.min
|
|
10
|
+
import kotlin.math.sin
|
|
11
|
+
import kotlin.math.sqrt
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Elastic distance filter: the faster the device moves, the farther apart recorded locations are.
|
|
15
|
+
*
|
|
16
|
+
* `d = distanceFilter * max(1, round(speed / 5.0) * elasticityMultiplier)`, or plain `distanceFilter` when
|
|
17
|
+
* elasticity is disabled. `round` rounds halves up; a missing, negative or non-finite speed counts as 0.
|
|
18
|
+
*/
|
|
19
|
+
internal object ElasticDistance {
|
|
20
|
+
/** Speed step (m/s) of the elastic multiplier. */
|
|
21
|
+
private const val SPEED_STEP_MPS = 5.0
|
|
22
|
+
|
|
23
|
+
/** The minimum distance (m) between two recorded locations at [speedMps]. */
|
|
24
|
+
fun threshold(
|
|
25
|
+
distanceFilter: Double,
|
|
26
|
+
speedMps: Float?,
|
|
27
|
+
elasticityMultiplier: Double,
|
|
28
|
+
disableElasticity: Boolean,
|
|
29
|
+
): Double {
|
|
30
|
+
val base = if (distanceFilter.isFinite()) max(0.0, distanceFilter) else 0.0
|
|
31
|
+
if (disableElasticity) return base
|
|
32
|
+
val speed = speedMps?.toDouble()?.takeIf { it.isFinite() && it > 0.0 } ?: 0.0
|
|
33
|
+
val multiplier = if (elasticityMultiplier.isFinite()) elasticityMultiplier else 1.0
|
|
34
|
+
val factor = max(1.0, floor(speed / SPEED_STEP_MPS + 0.5) * multiplier)
|
|
35
|
+
return base * factor
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** [threshold] with the settings of [config], at the speed of [location]. */
|
|
39
|
+
fun threshold(config: GeolocationConfig, location: TrackedLocation): Double =
|
|
40
|
+
threshold(config.distanceFilter, location.speed, config.elasticityMultiplier, config.disableElasticity)
|
|
41
|
+
|
|
42
|
+
/** True if [candidate] is far enough from [lastRecorded] (or nothing was recorded yet) to be recorded. */
|
|
43
|
+
fun shouldRecord(lastRecorded: TrackedLocation?, candidate: TrackedLocation, config: GeolocationConfig): Boolean {
|
|
44
|
+
if (lastRecorded == null) return true
|
|
45
|
+
return distanceMeters(lastRecorded, candidate) >= threshold(config, candidate)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Mean Earth radius (m), IUGG. */
|
|
50
|
+
private const val EARTH_RADIUS_M = 6_371_008.8
|
|
51
|
+
|
|
52
|
+
/** Great-circle (haversine) distance in meters between two fixes. */
|
|
53
|
+
internal fun distanceMeters(a: TrackedLocation, b: TrackedLocation): Double =
|
|
54
|
+
distanceMeters(a.latitude, a.longitude, b.latitude, b.longitude)
|
|
55
|
+
|
|
56
|
+
/** Great-circle (haversine) distance in meters. */
|
|
57
|
+
internal fun distanceMeters(lat1: Double, lng1: Double, lat2: Double, lng2: Double): Double {
|
|
58
|
+
val phi1 = Math.toRadians(lat1)
|
|
59
|
+
val phi2 = Math.toRadians(lat2)
|
|
60
|
+
val dPhi = Math.toRadians(lat2 - lat1)
|
|
61
|
+
val dLambda = Math.toRadians(lng2 - lng1)
|
|
62
|
+
val h = sin(dPhi / 2) * sin(dPhi / 2) + cos(phi1) * cos(phi2) * sin(dLambda / 2) * sin(dLambda / 2)
|
|
63
|
+
return 2 * EARTH_RADIUS_M * asin(min(1.0, sqrt(h)))
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Horizontal accuracy (m) of a fix; unknown (non-finite or negative) counts as 0. */
|
|
67
|
+
internal val TrackedLocation.accuracyMeters: Double
|
|
68
|
+
get() = accuracy.toDouble().takeIf { it.isFinite() && it > 0.0 } ?: 0.0
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
package com.brickssoft.locationtracking.engine
|
|
2
|
+
|
|
3
|
+
import com.brickssoft.locationtracking.config.GeolocationConfig
|
|
4
|
+
import com.brickssoft.locationtracking.config.TrackingMode
|
|
5
|
+
import com.brickssoft.locationtracking.model.DesiredAccuracy
|
|
6
|
+
import com.brickssoft.locationtracking.provider.LocationRequestSpec
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The continuous location request the engine wants in each situation.
|
|
10
|
+
*
|
|
11
|
+
* Requests never ask the backend to filter by distance: the engine applies the (elastic) distance filter itself,
|
|
12
|
+
* and stop detection, polygon geofences and overdue timers need fixes to keep arriving while the device does not
|
|
13
|
+
* move.
|
|
14
|
+
*/
|
|
15
|
+
internal object LocationRequests {
|
|
16
|
+
/**
|
|
17
|
+
* STATIONARY with the stationary region registered: PASSIVE updates, delivered at most every 30-60 s. A passive
|
|
18
|
+
* request makes the backend compute nothing; it only forwards fixes that other apps cause.
|
|
19
|
+
*/
|
|
20
|
+
const val PASSIVE_INTERVAL_MS = 60_000L
|
|
21
|
+
const val PASSIVE_FASTEST_INTERVAL_MS = 30_000L
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* STATIONARY without the stationary region (registration failed, or no current anchor is known): one LOW-power
|
|
25
|
+
* fix (Wi-Fi / cell, no GPS) at most every 3 minutes.
|
|
26
|
+
*/
|
|
27
|
+
const val FALLBACK_INTERVAL_MS = 180_000L
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* - LOCATION + MOVING, or [needsContinuousLocation] (inside a polygon geofence's enclosing circle):
|
|
31
|
+
* [moving].
|
|
32
|
+
* - LOCATION + STATIONARY: the [passive] request when the `passive` argument is true (the OS watches the
|
|
33
|
+
* stationary region, or the initial fix is still being computed by its own request), otherwise [fallback].
|
|
34
|
+
* - GEOFENCES otherwise: no request (null).
|
|
35
|
+
*/
|
|
36
|
+
fun desired(
|
|
37
|
+
mode: TrackingMode,
|
|
38
|
+
isMoving: Boolean,
|
|
39
|
+
needsContinuousLocation: Boolean,
|
|
40
|
+
config: GeolocationConfig,
|
|
41
|
+
passive: Boolean,
|
|
42
|
+
): LocationRequestSpec? = when {
|
|
43
|
+
mode == TrackingMode.LOCATION && isMoving -> moving(config)
|
|
44
|
+
needsContinuousLocation -> moving(config)
|
|
45
|
+
mode == TrackingMode.LOCATION -> if (passive) passive(config) else fallback(config)
|
|
46
|
+
else -> null
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The configured accuracy and intervals. */
|
|
50
|
+
fun moving(config: GeolocationConfig): LocationRequestSpec = LocationRequestSpec(
|
|
51
|
+
accuracy = config.desiredAccuracy,
|
|
52
|
+
intervalMs = config.locationUpdateInterval.coerceAtLeast(0),
|
|
53
|
+
fastestIntervalMs = config.fastestLocationUpdateInterval.coerceAtLeast(0),
|
|
54
|
+
distanceFilterM = 0f,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
/** PASSIVE: interval at least [PASSIVE_INTERVAL_MS], fastest interval at least [PASSIVE_FASTEST_INTERVAL_MS]. */
|
|
58
|
+
fun passive(config: GeolocationConfig): LocationRequestSpec {
|
|
59
|
+
val interval = maxOf(PASSIVE_INTERVAL_MS, config.locationUpdateInterval)
|
|
60
|
+
val fastest = minOf(interval, maxOf(PASSIVE_FASTEST_INTERVAL_MS, config.fastestLocationUpdateInterval))
|
|
61
|
+
return LocationRequestSpec(DesiredAccuracy.PASSIVE, interval, fastest, distanceFilterM = 0f)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* LOW (or PASSIVE when that is the configured accuracy), interval and fastest interval at least
|
|
66
|
+
* [FALLBACK_INTERVAL_MS], so at most one fix per 3 minutes.
|
|
67
|
+
*/
|
|
68
|
+
fun fallback(config: GeolocationConfig): LocationRequestSpec {
|
|
69
|
+
val accuracy =
|
|
70
|
+
if (config.desiredAccuracy == DesiredAccuracy.PASSIVE) DesiredAccuracy.PASSIVE else DesiredAccuracy.LOW
|
|
71
|
+
val interval = maxOf(FALLBACK_INTERVAL_MS, config.locationUpdateInterval)
|
|
72
|
+
return LocationRequestSpec(accuracy, interval, interval, distanceFilterM = 0f)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
package com.brickssoft.locationtracking.engine
|
|
2
|
+
|
|
3
|
+
import com.brickssoft.locationtracking.config.Config
|
|
4
|
+
import com.brickssoft.locationtracking.model.ActivitySample
|
|
5
|
+
import com.brickssoft.locationtracking.model.ActivityType
|
|
6
|
+
import com.brickssoft.locationtracking.model.TrackedLocation
|
|
7
|
+
import kotlin.math.max
|
|
8
|
+
|
|
9
|
+
/** Motion state of the LOCATION tracking mode. */
|
|
10
|
+
internal enum class MotionState {
|
|
11
|
+
STATIONARY,
|
|
12
|
+
MOVING,
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The config values the [MotionStateMachine] needs.
|
|
17
|
+
*
|
|
18
|
+
* @property stopTimeoutMs time without evidence of motion after which MOVING becomes STATIONARY (at least 1 min).
|
|
19
|
+
* @property trackingAccuracyThreshold `geolocation.filter.trackingAccuracyThreshold` (m): a STATIONARY fix with a
|
|
20
|
+
* worse accuracy never leaves STATIONARY by itself; 0 or less turns this check off.
|
|
21
|
+
*/
|
|
22
|
+
internal data class MotionSettings(
|
|
23
|
+
val stationaryRadius: Double = 25.0,
|
|
24
|
+
val minimumConfidence: Int = 75,
|
|
25
|
+
val motionTriggerDelayMs: Long = 0,
|
|
26
|
+
val stopTimeoutMs: Long = 5 * MINUTE_MS,
|
|
27
|
+
val disableStopDetection: Boolean = false,
|
|
28
|
+
val trackingAccuracyThreshold: Double = 100.0,
|
|
29
|
+
) {
|
|
30
|
+
companion object {
|
|
31
|
+
private const val MINUTE_MS = 60_000L
|
|
32
|
+
|
|
33
|
+
fun from(config: Config): MotionSettings = MotionSettings(
|
|
34
|
+
stationaryRadius = config.geolocation.stationaryRadius.takeIf { it.isFinite() }?.coerceAtLeast(0.0) ?: 0.0,
|
|
35
|
+
minimumConfidence = config.activity.minimumActivityRecognitionConfidence,
|
|
36
|
+
motionTriggerDelayMs = config.activity.motionTriggerDelay.coerceAtLeast(0),
|
|
37
|
+
// A zero timeout would flip back to STATIONARY right after every fix; one minute is the floor.
|
|
38
|
+
stopTimeoutMs = config.geolocation.stopTimeout.coerceAtLeast(1) * MINUTE_MS,
|
|
39
|
+
disableStopDetection = config.activity.disableStopDetection,
|
|
40
|
+
trackingAccuracyThreshold =
|
|
41
|
+
config.geolocation.filter.trackingAccuracyThreshold.takeIf { it.isFinite() } ?: 0.0,
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** What the engine must do after feeding an input to the [MotionStateMachine]. Actions are listed in order. */
|
|
47
|
+
internal sealed interface MotionAction {
|
|
48
|
+
/** The state became MOVING; [location] is the fix that triggered it, or null (activity, changePace). */
|
|
49
|
+
data class EnterMoving(val location: TrackedLocation?) : MotionAction
|
|
50
|
+
|
|
51
|
+
/** The state became STATIONARY at [location]; [automatic] is true for stop detection (not changePace). */
|
|
52
|
+
data class EnterStationary(val location: TrackedLocation?, val automatic: Boolean) : MotionAction
|
|
53
|
+
|
|
54
|
+
/** (Re)starts the stop-timeout timer; it must call [MotionStateMachine.onStopTimeout] when it fires. */
|
|
55
|
+
data class StartStopTimer(val delayMs: Long) : MotionAction
|
|
56
|
+
|
|
57
|
+
data object CancelStopTimer : MotionAction
|
|
58
|
+
|
|
59
|
+
/** Starts the motion-trigger timer; it must call [MotionStateMachine.onMotionTriggerElapsed] when it fires. */
|
|
60
|
+
data class StartMotionTrigger(val delayMs: Long) : MotionAction
|
|
61
|
+
|
|
62
|
+
data object CancelMotionTrigger : MotionAction
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* STATIONARY / MOVING state machine. Pure: no Android, no clock, no timers of its own; the engine runs the
|
|
67
|
+
* timers it asks for and reports back when they fire.
|
|
68
|
+
*
|
|
69
|
+
* - **STATIONARY** has an anchor (the first fix, or the location where the device stopped). It becomes MOVING
|
|
70
|
+
* when a fix is certainly outside the stationary radius (`distance(anchor, fix) - fix accuracy >
|
|
71
|
+
* stationaryRadius`) and its accuracy is at most `trackingAccuracyThreshold`, or when a moving activity
|
|
72
|
+
* (walking, running, on_foot, on_bicycle, in_vehicle) at or above the confidence threshold lasts for
|
|
73
|
+
* `motionTriggerDelay` (a confident `still` cancels the pending trigger). A fix that is certainly outside but
|
|
74
|
+
* coarser than the threshold changes nothing. A fix that is not certainly outside and is more accurate than the
|
|
75
|
+
* anchor replaces it. (The engine also leaves STATIONARY on the EXIT of its OS stationary region, through
|
|
76
|
+
* [force].)
|
|
77
|
+
* - **MOVING**: stop detection (unless disabled) keeps the stop-timeout timer running. Every piece of evidence
|
|
78
|
+
* of motion restarts it: a fix more than `max(stationaryRadius, accuracy)` from the last such fix, or a
|
|
79
|
+
* confident moving activity. A confident `still` or a fix without displacement only makes sure it is armed,
|
|
80
|
+
* so the device becomes STATIONARY `stopTimeout` after the last evidence of motion, even when no more
|
|
81
|
+
* fixes arrive.
|
|
82
|
+
*/
|
|
83
|
+
internal class MotionStateMachine(settings: MotionSettings = MotionSettings()) {
|
|
84
|
+
var settings: MotionSettings = settings
|
|
85
|
+
private set
|
|
86
|
+
|
|
87
|
+
var state: MotionState = MotionState.STATIONARY
|
|
88
|
+
private set
|
|
89
|
+
|
|
90
|
+
val isMoving: Boolean get() = state == MotionState.MOVING
|
|
91
|
+
|
|
92
|
+
/** STATIONARY: the reference point of the stationary radius (null until the first fix). */
|
|
93
|
+
var anchor: TrackedLocation? = null
|
|
94
|
+
private set
|
|
95
|
+
|
|
96
|
+
/** MOVING: the last fix that showed displacement. */
|
|
97
|
+
var motionAnchor: TrackedLocation? = null
|
|
98
|
+
private set
|
|
99
|
+
|
|
100
|
+
var isStopTimerArmed: Boolean = false
|
|
101
|
+
private set
|
|
102
|
+
|
|
103
|
+
var isMotionTriggerArmed: Boolean = false
|
|
104
|
+
private set
|
|
105
|
+
|
|
106
|
+
/** Forgets everything and starts over in [state]. The caller cancels its timers. */
|
|
107
|
+
fun reset(state: MotionState = MotionState.STATIONARY, anchor: TrackedLocation? = null) {
|
|
108
|
+
this.state = state
|
|
109
|
+
this.anchor = if (state == MotionState.STATIONARY) anchor else null
|
|
110
|
+
motionAnchor = if (state == MotionState.MOVING) anchor else null
|
|
111
|
+
isStopTimerArmed = false
|
|
112
|
+
isMotionTriggerArmed = false
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** STATIONARY without an anchor: [location] becomes the anchor. */
|
|
116
|
+
fun offerAnchor(location: TrackedLocation?) {
|
|
117
|
+
if (state == MotionState.STATIONARY && anchor == null && location != null) anchor = location
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** STATIONARY: [location] becomes the anchor, replacing any earlier one (the fresh initial fix after start). */
|
|
121
|
+
fun setAnchor(location: TrackedLocation) {
|
|
122
|
+
if (state == MotionState.STATIONARY) anchor = location
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Applies new settings. MOVING: may arm, cancel or restart the stop timer. STATIONARY: a pending motion
|
|
127
|
+
* trigger is cancelled when its delay changed, so the next moving activity starts over with the new delay.
|
|
128
|
+
*/
|
|
129
|
+
fun updateSettings(newSettings: MotionSettings): List<MotionAction> {
|
|
130
|
+
val old = settings
|
|
131
|
+
settings = newSettings
|
|
132
|
+
if (state != MotionState.MOVING) {
|
|
133
|
+
if (!isMotionTriggerArmed || newSettings.motionTriggerDelayMs == old.motionTriggerDelayMs) return emptyList()
|
|
134
|
+
isMotionTriggerArmed = false
|
|
135
|
+
return listOf(MotionAction.CancelMotionTrigger)
|
|
136
|
+
}
|
|
137
|
+
return when {
|
|
138
|
+
newSettings.disableStopDetection -> cancelStopTimer()
|
|
139
|
+
!isStopTimerArmed || newSettings.stopTimeoutMs != old.stopTimeoutMs -> restartStopTimer()
|
|
140
|
+
else -> emptyList()
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** An accepted fix. */
|
|
145
|
+
fun onLocation(fix: TrackedLocation): List<MotionAction> = when (state) {
|
|
146
|
+
MotionState.STATIONARY -> {
|
|
147
|
+
val current = anchor
|
|
148
|
+
when {
|
|
149
|
+
current == null -> {
|
|
150
|
+
anchor = fix
|
|
151
|
+
emptyList()
|
|
152
|
+
}
|
|
153
|
+
isCertainlyOutside(current, fix) -> if (isAccurateEnough(fix)) enterMoving(fix) else emptyList()
|
|
154
|
+
else -> {
|
|
155
|
+
if (fix.accuracyMeters < current.accuracyMeters) anchor = fix
|
|
156
|
+
emptyList()
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
MotionState.MOVING -> {
|
|
161
|
+
val reference = motionAnchor
|
|
162
|
+
when {
|
|
163
|
+
reference == null -> {
|
|
164
|
+
motionAnchor = fix
|
|
165
|
+
ensureStopTimer()
|
|
166
|
+
}
|
|
167
|
+
distanceMeters(reference, fix) > max(settings.stationaryRadius, fix.accuracyMeters) -> {
|
|
168
|
+
motionAnchor = fix
|
|
169
|
+
restartStopTimer()
|
|
170
|
+
}
|
|
171
|
+
else -> ensureStopTimer()
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** An activity sample; samples below the confidence threshold are ignored. */
|
|
177
|
+
fun onActivity(sample: ActivitySample): List<MotionAction> {
|
|
178
|
+
if (sample.confidence < settings.minimumConfidence) return emptyList()
|
|
179
|
+
val moving = sample.type in MOVING_ACTIVITIES
|
|
180
|
+
val still = sample.type == ActivityType.STILL
|
|
181
|
+
return when (state) {
|
|
182
|
+
MotionState.STATIONARY -> when {
|
|
183
|
+
moving && settings.motionTriggerDelayMs <= 0 -> enterMoving(null)
|
|
184
|
+
moving && !isMotionTriggerArmed -> {
|
|
185
|
+
isMotionTriggerArmed = true
|
|
186
|
+
listOf(MotionAction.StartMotionTrigger(settings.motionTriggerDelayMs))
|
|
187
|
+
}
|
|
188
|
+
still && isMotionTriggerArmed -> {
|
|
189
|
+
isMotionTriggerArmed = false
|
|
190
|
+
listOf(MotionAction.CancelMotionTrigger)
|
|
191
|
+
}
|
|
192
|
+
else -> emptyList()
|
|
193
|
+
}
|
|
194
|
+
MotionState.MOVING -> when {
|
|
195
|
+
moving -> restartStopTimer()
|
|
196
|
+
still -> ensureStopTimer()
|
|
197
|
+
else -> emptyList()
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** The motion-trigger timer fired. */
|
|
203
|
+
fun onMotionTriggerElapsed(): List<MotionAction> {
|
|
204
|
+
val wasArmed = isMotionTriggerArmed
|
|
205
|
+
isMotionTriggerArmed = false
|
|
206
|
+
return if (wasArmed && state == MotionState.STATIONARY) enterMoving(null) else emptyList()
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** The stop-timeout timer fired; [location] (the best known fix) becomes the stationary anchor. */
|
|
210
|
+
fun onStopTimeout(location: TrackedLocation?): List<MotionAction> {
|
|
211
|
+
val wasArmed = isStopTimerArmed
|
|
212
|
+
isStopTimerArmed = false
|
|
213
|
+
return if (wasArmed && state == MotionState.MOVING) enterStationary(location, automatic = true) else emptyList()
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** `changePace`: forces a transition; [location] is the best known fix. */
|
|
217
|
+
fun force(isMoving: Boolean, location: TrackedLocation?): List<MotionAction> = when {
|
|
218
|
+
isMoving && state == MotionState.MOVING -> restartStopTimer()
|
|
219
|
+
isMoving -> enterMoving(location)
|
|
220
|
+
state == MotionState.MOVING -> enterStationary(location, automatic = false)
|
|
221
|
+
else -> emptyList()
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** True if [fix] lies outside the stationary radius around [anchor] even at the far edge of its accuracy. */
|
|
225
|
+
private fun isCertainlyOutside(anchor: TrackedLocation, fix: TrackedLocation): Boolean =
|
|
226
|
+
distanceMeters(anchor, fix) - fix.accuracyMeters > settings.stationaryRadius
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* False for a fix coarser than `trackingAccuracyThreshold`: such a fix never leaves STATIONARY by itself. (The
|
|
230
|
+
* engine's location processor already rejects such fixes; this keeps the rule of architecture round 2, §3 true
|
|
231
|
+
* for any processor.)
|
|
232
|
+
*/
|
|
233
|
+
fun isAccurateEnough(fix: TrackedLocation): Boolean {
|
|
234
|
+
val threshold = settings.trackingAccuracyThreshold
|
|
235
|
+
return threshold <= 0.0 || fix.accuracyMeters <= threshold
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
private fun enterMoving(location: TrackedLocation?): List<MotionAction> {
|
|
239
|
+
val actions = ArrayList<MotionAction>(3)
|
|
240
|
+
if (isMotionTriggerArmed) {
|
|
241
|
+
isMotionTriggerArmed = false
|
|
242
|
+
actions += MotionAction.CancelMotionTrigger
|
|
243
|
+
}
|
|
244
|
+
state = MotionState.MOVING
|
|
245
|
+
anchor = null
|
|
246
|
+
motionAnchor = location
|
|
247
|
+
actions += MotionAction.EnterMoving(location)
|
|
248
|
+
actions += restartStopTimer()
|
|
249
|
+
return actions
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
private fun enterStationary(location: TrackedLocation?, automatic: Boolean): List<MotionAction> {
|
|
253
|
+
val actions = ArrayList<MotionAction>(3)
|
|
254
|
+
actions += cancelStopTimer()
|
|
255
|
+
if (isMotionTriggerArmed) {
|
|
256
|
+
isMotionTriggerArmed = false
|
|
257
|
+
actions += MotionAction.CancelMotionTrigger
|
|
258
|
+
}
|
|
259
|
+
state = MotionState.STATIONARY
|
|
260
|
+
motionAnchor = null
|
|
261
|
+
anchor = location
|
|
262
|
+
actions += MotionAction.EnterStationary(location, automatic)
|
|
263
|
+
return actions
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
private fun restartStopTimer(): List<MotionAction> {
|
|
267
|
+
if (settings.disableStopDetection) return emptyList()
|
|
268
|
+
isStopTimerArmed = true
|
|
269
|
+
return listOf(MotionAction.StartStopTimer(settings.stopTimeoutMs))
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
private fun ensureStopTimer(): List<MotionAction> =
|
|
273
|
+
if (isStopTimerArmed) emptyList() else restartStopTimer()
|
|
274
|
+
|
|
275
|
+
private fun cancelStopTimer(): List<MotionAction> {
|
|
276
|
+
if (!isStopTimerArmed) return emptyList()
|
|
277
|
+
isStopTimerArmed = false
|
|
278
|
+
return listOf(MotionAction.CancelStopTimer)
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
companion object {
|
|
282
|
+
/** Activities that count as motion. */
|
|
283
|
+
val MOVING_ACTIVITIES: Set<ActivityType> = setOf(
|
|
284
|
+
ActivityType.WALKING,
|
|
285
|
+
ActivityType.RUNNING,
|
|
286
|
+
ActivityType.ON_FOOT,
|
|
287
|
+
ActivityType.ON_BICYCLE,
|
|
288
|
+
ActivityType.IN_VEHICLE,
|
|
289
|
+
)
|
|
290
|
+
}
|
|
291
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
package com.brickssoft.locationtracking.engine
|
|
2
|
+
|
|
3
|
+
import com.brickssoft.locationtracking.core.Constants
|
|
4
|
+
import com.brickssoft.locationtracking.model.TrackedLocation
|
|
5
|
+
import com.brickssoft.locationtracking.provider.OsGeofence
|
|
6
|
+
import kotlin.math.max
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The engine's own OS geofence while STATIONARY (architecture round 2, §3).
|
|
10
|
+
*
|
|
11
|
+
* While the device is STATIONARY the engine stops its own location requests (GPS off) and registers one circular
|
|
12
|
+
* geofence around the stationary anchor with the OS geofencing service of the selected backend. The OS reports the
|
|
13
|
+
* EXIT of that circle to `DefaultGeofenceManager`, which forwards it to the engine (`onStationaryRegionTransition`);
|
|
14
|
+
* the engine then switches to MOVING. The region is never stored, recorded or emitted as a user geofence.
|
|
15
|
+
*/
|
|
16
|
+
internal object StationaryRegion {
|
|
17
|
+
/**
|
|
18
|
+
* Smallest radius of the region, in meters. OS geofencing (GMS, HMS, Android proximity alerts) works with
|
|
19
|
+
* Wi-Fi and cell locations whose error is often 50-150 m, so a smaller circle would report EXIT without movement.
|
|
20
|
+
*/
|
|
21
|
+
const val MIN_RADIUS_M = 150.0
|
|
22
|
+
|
|
23
|
+
/** Region radius in meters: `max(stationaryRadius, MIN_RADIUS_M)`; a non-finite radius counts as 0. */
|
|
24
|
+
fun radius(stationaryRadius: Double): Float {
|
|
25
|
+
val configured = stationaryRadius.takeIf { it.isFinite() } ?: 0.0
|
|
26
|
+
return max(configured, MIN_RADIUS_M).toFloat()
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The region around [anchor]: EXIT only, no initial trigger, id [Constants.STATIONARY_REGION_ID]. */
|
|
30
|
+
fun around(anchor: TrackedLocation, stationaryRadius: Double): OsGeofence = OsGeofence(
|
|
31
|
+
id = Constants.STATIONARY_REGION_ID,
|
|
32
|
+
latitude = anchor.latitude,
|
|
33
|
+
longitude = anchor.longitude,
|
|
34
|
+
radius = radius(stationaryRadius),
|
|
35
|
+
onEntry = false,
|
|
36
|
+
onExit = true,
|
|
37
|
+
onDwell = false,
|
|
38
|
+
loiteringDelayMs = 0,
|
|
39
|
+
initialTriggerEntry = false,
|
|
40
|
+
)
|
|
41
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
package com.brickssoft.locationtracking.engine
|
|
2
|
+
|
|
3
|
+
import com.brickssoft.locationtracking.config.State
|
|
4
|
+
import com.brickssoft.locationtracking.provider.ActivitySink
|
|
5
|
+
import com.brickssoft.locationtracking.provider.OsGeofenceTransition
|
|
6
|
+
import com.brickssoft.locationtracking.provider.StationaryRegionSink
|
|
7
|
+
import org.json.JSONObject
|
|
8
|
+
|
|
9
|
+
/** The tracking state machine. All state changes happen on `dispatchers.engine`. */
|
|
10
|
+
interface TrackingEngine : ActivitySink, StationaryRegionSink {
|
|
11
|
+
suspend fun ready(config: JSONObject?, reset: Boolean): State
|
|
12
|
+
|
|
13
|
+
suspend fun setConfig(config: JSONObject): State
|
|
14
|
+
|
|
15
|
+
suspend fun reset(config: JSONObject?): State
|
|
16
|
+
|
|
17
|
+
/** @throws com.brickssoft.locationtracking.core.TrackingException PERMISSION_DENIED if no foreground location. */
|
|
18
|
+
suspend fun start(): State
|
|
19
|
+
|
|
20
|
+
suspend fun startGeofences(): State
|
|
21
|
+
|
|
22
|
+
suspend fun stop(): State
|
|
23
|
+
|
|
24
|
+
suspend fun changePace(isMoving: Boolean)
|
|
25
|
+
|
|
26
|
+
fun state(): State
|
|
27
|
+
|
|
28
|
+
/** "restore" | "boot" | "package_replaced": cold process while enabled. */
|
|
29
|
+
suspend fun restore(reason: String)
|
|
30
|
+
|
|
31
|
+
/** Task removed. */
|
|
32
|
+
suspend fun onTerminate()
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The foreground service could not enter the foreground after `ServiceController.start()` had returned true,
|
|
36
|
+
* for example a start from the background on Android 12+ without an exemption, or on Android 14+ with only
|
|
37
|
+
* while-in-use location permission. While tracking is enabled this stops it with the `tracking_stop` reason
|
|
38
|
+
* `service_start_failed`, so the server sees why the audit trail ends.
|
|
39
|
+
*/
|
|
40
|
+
suspend fun onServiceStartFailed(error: String) = Unit
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Tracking was enabled in a process that ended with a reboot or an app update, and `app.startOnBoot` is false, so
|
|
44
|
+
* it is not resumed. Records `tracking_stop` with [reason] (`reboot` | `package_replaced`), so the server learns why
|
|
45
|
+
* the heartbeats stopped, and clears `enabled`.
|
|
46
|
+
*/
|
|
47
|
+
suspend fun endWithoutRestore(reason: String) = Unit
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The resume notification was tapped and the service is in the foreground: resumes the session that Android refused
|
|
51
|
+
* to restore (`tracking_start` reason `resume_notification`), or stops the service if there is nothing to resume.
|
|
52
|
+
*/
|
|
53
|
+
suspend fun resumeFromNotification() = Unit
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A transition of the engine's stationary region (`Constants.STATIONARY_REGION_ID`), routed by the
|
|
57
|
+
* GeofenceManager. Round 2, unit 2 implements it (EXIT = the device left the stationary anchor). Default: ignored.
|
|
58
|
+
*/
|
|
59
|
+
override suspend fun onStationaryRegionTransition(transition: OsGeofenceTransition) = Unit
|
|
60
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
package com.brickssoft.locationtracking.engine
|
|
2
|
+
|
|
3
|
+
import com.brickssoft.locationtracking.core.Clock
|
|
4
|
+
import kotlinx.coroutines.CoroutineScope
|
|
5
|
+
import kotlinx.coroutines.Job
|
|
6
|
+
import kotlinx.coroutines.delay
|
|
7
|
+
import kotlinx.coroutines.launch
|
|
8
|
+
import kotlinx.coroutines.sync.Mutex
|
|
9
|
+
import kotlinx.coroutines.sync.withLock
|
|
10
|
+
import java.util.EnumMap
|
|
11
|
+
|
|
12
|
+
/** The engine's timers. */
|
|
13
|
+
internal enum class TimerKind {
|
|
14
|
+
/** MOVING -> STATIONARY after `stopTimeout` without evidence of motion. */
|
|
15
|
+
STOP_TIMEOUT,
|
|
16
|
+
|
|
17
|
+
/** STATIONARY -> MOVING once a moving activity has lasted `motionTriggerDelay`. */
|
|
18
|
+
MOTION_TRIGGER,
|
|
19
|
+
|
|
20
|
+
/** `stopAfterElapsedMinutes`, measured from `trackingStartedAt`. */
|
|
21
|
+
STOP_AFTER_ELAPSED,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* One-shot timers built on coroutines ([delay] in [scope]) and [Clock], without Handlers.
|
|
26
|
+
*
|
|
27
|
+
* Each timer has a deadline on the elapsed-realtime clock. A timer's action runs while holding [guard] (the
|
|
28
|
+
* engine's mutex), and only if the timer is still the current one of its kind, so a cancel or reschedule made
|
|
29
|
+
* under [guard] always wins over a timer that has already woken up. `delay()` does not advance while the CPU
|
|
30
|
+
* sleeps, so the engine also calls [fireDue] after every batch of fixes, every activity sample, every stationary-region
|
|
31
|
+
* exit and every heartbeat.
|
|
32
|
+
*
|
|
33
|
+
* Every method except the timer coroutines themselves must be called while holding [guard].
|
|
34
|
+
*/
|
|
35
|
+
internal class TrackingTimers(
|
|
36
|
+
private val scope: CoroutineScope,
|
|
37
|
+
private val clock: Clock,
|
|
38
|
+
private val guard: Mutex,
|
|
39
|
+
) {
|
|
40
|
+
private class Entry(val deadline: Long, val action: suspend () -> Unit) {
|
|
41
|
+
var job: Job? = null
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
private val entries = EnumMap<TimerKind, Entry>(TimerKind::class.java)
|
|
45
|
+
|
|
46
|
+
/** (Re)schedules [kind] to run [action] (while holding the guard) after [delayMs]; a negative delay fires ASAP. */
|
|
47
|
+
fun schedule(kind: TimerKind, delayMs: Long, action: suspend () -> Unit) {
|
|
48
|
+
cancel(kind)
|
|
49
|
+
val delayMillis = delayMs.coerceAtLeast(0)
|
|
50
|
+
val entry = Entry(clock.elapsedRealtime() + delayMillis, action)
|
|
51
|
+
entries[kind] = entry
|
|
52
|
+
entry.job = scope.launch {
|
|
53
|
+
delay(delayMillis)
|
|
54
|
+
guard.withLock { fireIfCurrent(kind, entry) }
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
fun cancel(kind: TimerKind) {
|
|
59
|
+
entries.remove(kind)?.job?.cancel()
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
fun cancelAll() {
|
|
63
|
+
val all = entries.values.toList()
|
|
64
|
+
entries.clear()
|
|
65
|
+
all.forEach { it.job?.cancel() }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
fun isArmed(kind: TimerKind): Boolean = entries.containsKey(kind)
|
|
69
|
+
|
|
70
|
+
/** Milliseconds until [kind] fires (0 if overdue), or null if it is not armed. */
|
|
71
|
+
fun remainingMs(kind: TimerKind): Long? =
|
|
72
|
+
entries[kind]?.let { (it.deadline - clock.elapsedRealtime()).coerceAtLeast(0) }
|
|
73
|
+
|
|
74
|
+
/** Runs every timer whose deadline has passed, in [TimerKind] order. */
|
|
75
|
+
suspend fun fireDue() {
|
|
76
|
+
for (kind in TimerKind.entries) {
|
|
77
|
+
val entry = entries[kind] ?: continue
|
|
78
|
+
if (entry.deadline > clock.elapsedRealtime()) continue
|
|
79
|
+
entry.job?.cancel()
|
|
80
|
+
fireIfCurrent(kind, entry)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
private suspend fun fireIfCurrent(kind: TimerKind, entry: Entry) {
|
|
85
|
+
if (entries[kind] !== entry) return
|
|
86
|
+
// Removed before running, so the action may cancel or reschedule timers (including this kind) freely.
|
|
87
|
+
entries.remove(kind)
|
|
88
|
+
entry.action()
|
|
89
|
+
}
|
|
90
|
+
}
|