@bricks-soft/capacitor-location-tracking 8.0.1 → 8.2.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/BricksSoftCapacitorLocationTracking.podspec +18 -0
- package/Package.swift +13 -0
- package/README.md +476 -147
- package/android/build.gradle +3 -1
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/java/com/brickssoft/locationtracking/bridge/OptionParsers.kt +4 -1
- package/android/src/main/java/com/brickssoft/locationtracking/bridge/PluginHandlers.kt +14 -5
- package/android/src/main/java/com/brickssoft/locationtracking/config/Config.kt +10 -0
- package/android/src/main/java/com/brickssoft/locationtracking/config/ConfigJson.kt +70 -15
- package/android/src/main/java/com/brickssoft/locationtracking/config/ConfigStore.kt +6 -0
- package/android/src/main/java/com/brickssoft/locationtracking/config/ConfigValidator.kt +8 -3
- package/android/src/main/java/com/brickssoft/locationtracking/config/RuntimeStateJson.kt +10 -0
- package/android/src/main/java/com/brickssoft/locationtracking/config/SecretStore.kt +188 -0
- package/android/src/main/java/com/brickssoft/locationtracking/config/SharedPrefsConfigStore.kt +188 -17
- package/android/src/main/java/com/brickssoft/locationtracking/config/State.kt +11 -0
- package/android/src/main/java/com/brickssoft/locationtracking/core/Components.kt +21 -5
- package/android/src/main/java/com/brickssoft/locationtracking/core/ForceStopProbe.kt +20 -9
- package/android/src/main/java/com/brickssoft/locationtracking/core/TrackingEvent.kt +24 -2
- package/android/src/main/java/com/brickssoft/locationtracking/data/TrackingDatabase.kt +43 -1
- package/android/src/main/java/com/brickssoft/locationtracking/device/DefaultDeviceInfoProvider.kt +17 -0
- package/android/src/main/java/com/brickssoft/locationtracking/device/DeviceDetails.kt +167 -0
- package/android/src/main/java/com/brickssoft/locationtracking/engine/DefaultTrackingEngine.kt +191 -34
- package/android/src/main/java/com/brickssoft/locationtracking/engine/TrackingEngine.kt +6 -0
- package/android/src/main/java/com/brickssoft/locationtracking/engine/TrackingTimers.kt +3 -0
- package/android/src/main/java/com/brickssoft/locationtracking/heartbeat/DefaultHeartbeatScheduler.kt +52 -8
- package/android/src/main/java/com/brickssoft/locationtracking/heartbeat/HeartbeatAlarmReceiver.kt +22 -0
- package/android/src/main/java/com/brickssoft/locationtracking/http/AuthorizationManager.kt +78 -27
- package/android/src/main/java/com/brickssoft/locationtracking/http/ConfigChecks.kt +140 -0
- package/android/src/main/java/com/brickssoft/locationtracking/http/HttpSupport.kt +50 -12
- package/android/src/main/java/com/brickssoft/locationtracking/http/HttpSyncer.kt +17 -0
- package/android/src/main/java/com/brickssoft/locationtracking/http/OkHttpSyncer.kt +507 -92
- package/android/src/main/java/com/brickssoft/locationtracking/http/SyncPolicy.kt +6 -6
- package/android/src/main/java/com/brickssoft/locationtracking/http/TemplateRenderer.kt +23 -1
- package/android/src/main/java/com/brickssoft/locationtracking/http/UploadJobs.kt +217 -0
- package/android/src/main/java/com/brickssoft/locationtracking/http/UploadWait.kt +184 -0
- package/android/src/main/java/com/brickssoft/locationtracking/logging/FileLogger.kt +66 -7
- package/android/src/main/java/com/brickssoft/locationtracking/logging/LogRedaction.kt +31 -0
- package/android/src/main/java/com/brickssoft/locationtracking/model/DeviceModels.kt +44 -1
- package/android/src/main/java/com/brickssoft/locationtracking/model/ModelJson.kt +79 -8
- package/android/src/main/java/com/brickssoft/locationtracking/model/Record.kt +9 -0
- package/android/src/main/java/com/brickssoft/locationtracking/model/TrackedLocation.kt +12 -2
- package/android/src/main/java/com/brickssoft/locationtracking/position/DefaultPositionService.kt +10 -2
- package/android/src/main/java/com/brickssoft/locationtracking/position/PositionWatch.kt +24 -1
- package/android/src/main/java/com/brickssoft/locationtracking/processing/DefaultRecordFactory.kt +31 -10
- package/android/src/main/java/com/brickssoft/locationtracking/provider/Backends.kt +48 -3
- package/android/src/main/java/com/brickssoft/locationtracking/provider/DefaultProviderFactory.kt +60 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/LocationSubscriptions.kt +180 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/android/AndroidLocationBackend.kt +200 -126
- package/android/src/main/java/com/brickssoft/locationtracking/provider/android/LocationProviders.kt +20 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsLocationBackend.kt +39 -51
- package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsProviderBundle.kt +8 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsLocationBackend.kt +47 -53
- package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsProviderBundle.kt +34 -2
- package/android/src/main/java/com/brickssoft/locationtracking/record/RecordSink.kt +19 -1
- package/android/src/main/java/com/brickssoft/locationtracking/service/BootCountStore.kt +7 -0
- package/android/src/main/res/xml/lt_file_paths.xml +1 -1
- package/dist/esm/definitions.d.ts +117 -8
- package/dist/esm/definitions.js.map +1 -1
- package/dist/esm/events.d.ts +8 -2
- package/dist/esm/events.js +7 -1
- package/dist/esm/events.js.map +1 -1
- package/dist/esm/index.d.ts +1 -1
- package/dist/esm/index.js +1 -1
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/web/config.js +10 -0
- package/dist/esm/web/config.js.map +1 -1
- package/dist/esm/web/device.d.ts +1 -1
- package/dist/esm/web/device.js +8 -1
- package/dist/esm/web/device.js.map +1 -1
- package/dist/esm/web.d.ts +0 -8
- package/dist/esm/web.js +12 -3
- package/dist/esm/web.js.map +1 -1
- package/dist/plugin.cjs.js +39 -5
- package/dist/plugin.cjs.js.map +1 -1
- package/dist/plugin.js +39 -5
- package/dist/plugin.js.map +1 -1
- package/ios/Sources/LocationTrackingPlugin/Api/BackgroundUpload.swift +24 -0
- package/ios/Sources/LocationTrackingPlugin/Api/LocationTrackingNative.swift +183 -0
- package/ios/Sources/LocationTrackingPlugin/Config/ConfigDefaults.swift +96 -0
- package/ios/Sources/LocationTrackingPlugin/Config/ConfigSchema.swift +74 -0
- package/ios/Sources/LocationTrackingPlugin/Config/ConfigValidator.swift +40 -0
- package/ios/Sources/LocationTrackingPlugin/Config/SecretStore.swift +35 -0
- package/ios/Sources/LocationTrackingPlugin/Config/TrackingConfig.swift +104 -0
- package/ios/Sources/LocationTrackingPlugin/Core/JSON.swift +50 -0
- package/ios/Sources/LocationTrackingPlugin/Core/Options.swift +26 -0
- package/ios/Sources/LocationTrackingPlugin/Core/PluginInfo.swift +6 -0
- package/ios/Sources/LocationTrackingPlugin/Core/TrackingClock.swift +11 -0
- package/ios/Sources/LocationTrackingPlugin/Core/TrackingCore.swift +298 -0
- package/ios/Sources/LocationTrackingPlugin/Data/TrackingStore.swift +155 -0
- package/ios/Sources/LocationTrackingPlugin/Device/DeviceReadings.swift +43 -0
- package/ios/Sources/LocationTrackingPlugin/Engine/CheckInNotifier.swift +92 -0
- package/ios/Sources/LocationTrackingPlugin/Engine/MotionStateMachine.swift +122 -0
- package/ios/Sources/LocationTrackingPlugin/Engine/SessionDeadline.swift +29 -0
- package/ios/Sources/LocationTrackingPlugin/Engine/TrackingEngine.swift +344 -0
- package/ios/Sources/LocationTrackingPlugin/Engine/TrackingTimers.swift +35 -0
- package/ios/Sources/LocationTrackingPlugin/Geofence/DwellTracker.swift +35 -0
- package/ios/Sources/LocationTrackingPlugin/Geofence/GeofenceEventAdapter.swift +88 -0
- package/ios/Sources/LocationTrackingPlugin/Geofence/GeofenceManager.swift +453 -0
- package/ios/Sources/LocationTrackingPlugin/Geofence/GeofenceMonitor.swift +207 -0
- package/ios/Sources/LocationTrackingPlugin/Geofence/GeofenceSpec.swift +103 -0
- package/ios/Sources/LocationTrackingPlugin/Geofence/GeofenceStore.swift +52 -0
- package/ios/Sources/LocationTrackingPlugin/Geofence/PolygonMath.swift +176 -0
- package/ios/Sources/LocationTrackingPlugin/Heartbeat/ExecutionAssertion.swift +33 -0
- package/ios/Sources/LocationTrackingPlugin/Heartbeat/HeartbeatWindow.swift +29 -0
- package/ios/Sources/LocationTrackingPlugin/Http/BodyBuilder.swift +52 -0
- package/ios/Sources/LocationTrackingPlugin/Http/HTTPTransport.swift +87 -0
- package/ios/Sources/LocationTrackingPlugin/Http/StrictJSON.swift +98 -0
- package/ios/Sources/LocationTrackingPlugin/Http/Syncer.swift +313 -0
- package/ios/Sources/LocationTrackingPlugin/Http/UploadBackoff.swift +26 -0
- package/ios/Sources/LocationTrackingPlugin/Location/RouteSource.swift +124 -0
- package/ios/Sources/LocationTrackingPlugin/LocationTrackingPlugin.swift +155 -0
- package/ios/Sources/LocationTrackingPlugin/Logging/LogExport.swift +73 -0
- package/ios/Sources/LocationTrackingPlugin/Logging/TrackingLog.swift +47 -0
- package/ios/Sources/LocationTrackingPlugin/Permission/LocationServices.swift +32 -0
- package/ios/Sources/LocationTrackingPlugin/Permission/Permissions.swift +102 -0
- package/ios/Sources/LocationTrackingPlugin/Position/PositionSource.swift +79 -0
- package/ios/Sources/LocationTrackingPlugin/Position/Positions.swift +33 -0
- package/ios/Sources/LocationTrackingPlugin/Position/SampleCollector.swift +31 -0
- package/ios/Sources/LocationTrackingPlugin/PrivacyInfo.xcprivacy +10 -0
- package/ios/Sources/LocationTrackingPlugin/Processing/LocationProcessor.swift +83 -0
- package/ios/Sources/LocationTrackingPlugin/Processing/TrackedLocation.swift +64 -0
- package/ios/Sources/LocationTrackingPlugin/Record/ExternalRecord.swift +54 -0
- package/ios/Sources/LocationTrackingPlugin/Record/RecordFactory.swift +27 -0
- package/package.json +10 -4
package/android/build.gradle
CHANGED
|
@@ -64,7 +64,9 @@ android {
|
|
|
64
64
|
all {
|
|
65
65
|
maxHeapSize = '1024m'
|
|
66
66
|
maxParallelForks = 1
|
|
67
|
-
|
|
67
|
+
// IPv4 only: where `localhost` is also ::1 (GitHub's Ubuntu runners), OkHttp tries ::1 after a failed try
|
|
68
|
+
// to MockWebServer (bound to 127.0.0.1), and the client does not fall back to the next address.
|
|
69
|
+
jvmArgs '-XX:+UseSerialGC', '-XX:+EnableDynamicAgentLoading', '-Djava.net.preferIPv4Stack=true'
|
|
68
70
|
testLogging {
|
|
69
71
|
events 'failed'
|
|
70
72
|
exceptionFormat 'full'
|
|
@@ -29,6 +29,8 @@
|
|
|
29
29
|
<application>
|
|
30
30
|
<service android:name="com.brickssoft.locationtracking.service.LocationTrackingService"
|
|
31
31
|
android:exported="false" android:foregroundServiceType="location" android:stopWithTask="false"/>
|
|
32
|
+
<service android:name="com.brickssoft.locationtracking.http.UploadJobService"
|
|
33
|
+
android:exported="false" android:permission="android.permission.BIND_JOB_SERVICE"/>
|
|
32
34
|
<receiver android:name="com.brickssoft.locationtracking.service.BootReceiver" android:exported="true">
|
|
33
35
|
<intent-filter>
|
|
34
36
|
<action android:name="android.intent.action.BOOT_COMPLETED"/>
|
|
@@ -117,8 +117,11 @@ internal object OptionParsers {
|
|
|
117
117
|
requireNumber(coords, "longitude", "location.coords.longitude").also {
|
|
118
118
|
checkRange(it, LONGITUDE, "location.coords.longitude")
|
|
119
119
|
}
|
|
120
|
+
requireNumber(coords, "accuracy", "location.coords.accuracy").also {
|
|
121
|
+
if (it <= 0) invalid("location.coords.accuracy must be > 0")
|
|
122
|
+
}
|
|
120
123
|
optNumber(coords, "altitude", "location.coords.altitude")
|
|
121
|
-
for (key in listOf("
|
|
124
|
+
for (key in listOf("altitude_accuracy", "speed", "speed_accuracy", "heading_accuracy")) {
|
|
122
125
|
val value = optNumber(coords, key, "location.coords.$key")
|
|
123
126
|
if (value != null && value < 0) invalid("location.coords.$key must be >= 0")
|
|
124
127
|
}
|
|
@@ -11,6 +11,8 @@ import com.brickssoft.locationtracking.core.Logger
|
|
|
11
11
|
import com.brickssoft.locationtracking.core.Subscription
|
|
12
12
|
import com.brickssoft.locationtracking.core.TrackingException
|
|
13
13
|
import com.brickssoft.locationtracking.model.BatteryOptimizationStatus
|
|
14
|
+
import com.brickssoft.locationtracking.http.ConfigChecks
|
|
15
|
+
import com.brickssoft.locationtracking.logging.LogRedaction
|
|
14
16
|
import com.brickssoft.locationtracking.model.BatteryOptimizationStatusJson
|
|
15
17
|
import com.brickssoft.locationtracking.model.DeviceInfoJson
|
|
16
18
|
import com.brickssoft.locationtracking.model.EventJson
|
|
@@ -71,21 +73,25 @@ internal class PluginHandlers(
|
|
|
71
73
|
val reset = OptionParsers.optBoolean(options, "reset") ?: true
|
|
72
74
|
val state = services.engine.ready(config, reset)
|
|
73
75
|
readyFlag.set(true)
|
|
74
|
-
return
|
|
76
|
+
return configResultJson(state)
|
|
75
77
|
}
|
|
76
78
|
|
|
77
79
|
/** `setConfig({config})` -> State. */
|
|
78
80
|
suspend fun setConfig(options: JSONObject): JSONObject {
|
|
79
81
|
gate("setConfig")
|
|
80
|
-
return
|
|
82
|
+
return configResultJson(services.engine.setConfig(OptionParsers.requireObject(options, "config")))
|
|
81
83
|
}
|
|
82
84
|
|
|
83
85
|
/** `reset({config?})` -> State. */
|
|
84
86
|
suspend fun reset(options: JSONObject): JSONObject {
|
|
85
87
|
gate("reset")
|
|
86
|
-
return
|
|
88
|
+
return configResultJson(services.engine.reset(OptionParsers.optObject(options, "config")))
|
|
87
89
|
}
|
|
88
90
|
|
|
91
|
+
/** The State of a call that applied a config, with `warnings`: what was accepted but probably wrong (rec. 14). */
|
|
92
|
+
private fun configResultJson(state: State): JSONObject =
|
|
93
|
+
stateJson(state).put("warnings", JSONArray(ConfigChecks.warnings(state.config)))
|
|
94
|
+
|
|
89
95
|
fun getState(): JSONObject {
|
|
90
96
|
gate("getState")
|
|
91
97
|
return stateJson(services.engine.state())
|
|
@@ -419,12 +425,15 @@ internal class PluginHandlers(
|
|
|
419
425
|
|
|
420
426
|
// ---- log
|
|
421
427
|
|
|
422
|
-
/**
|
|
428
|
+
/**
|
|
429
|
+
* `log({level, message})` -> void; written through [Logger] (logcat and the log file), with bearer tokens, JWTs,
|
|
430
|
+
* credential fields and URLs removed ([LogRedaction]).
|
|
431
|
+
*/
|
|
423
432
|
fun log(options: JSONObject): JSONObject? {
|
|
424
433
|
gate("log")
|
|
425
434
|
val level = OptionParsers.logLevel(options)
|
|
426
435
|
val message = OptionParsers.optString(options, "message") ?: OptionParsers.invalid("'message' is required")
|
|
427
|
-
Logger.log(level, JS_TAG, message)
|
|
436
|
+
Logger.log(level, JS_TAG, LogRedaction.redact(message))
|
|
428
437
|
return null
|
|
429
438
|
}
|
|
430
439
|
|
|
@@ -97,6 +97,12 @@ data class HeartbeatConfig(
|
|
|
97
97
|
val minInterval: Int = 180,
|
|
98
98
|
/** seconds, >= minInterval */
|
|
99
99
|
val maxInterval: Int = 300,
|
|
100
|
+
/**
|
|
101
|
+
* Seconds, 0..60. After a heartbeat record is queued, the phone is kept awake until its upload has finished, or
|
|
102
|
+
* at most this long, so the heartbeat reaches the server on time while tracking is on. 0 = the wake lock ends when
|
|
103
|
+
* the heartbeat is queued.
|
|
104
|
+
*/
|
|
105
|
+
val uploadKeepAwake: Int = 10,
|
|
100
106
|
)
|
|
101
107
|
|
|
102
108
|
data class AuthorizationConfig(
|
|
@@ -138,6 +144,10 @@ data class HttpConfig(
|
|
|
138
144
|
val geofenceTemplate: String? = null,
|
|
139
145
|
/** ms */
|
|
140
146
|
val timeout: Long = 60_000,
|
|
147
|
+
/**
|
|
148
|
+
* Seconds, >= 0. The longest wait a `Retry-After` header of a 429 or 503 answer can impose on automatic uploads.
|
|
149
|
+
*/
|
|
150
|
+
val maxRetryAfter: Int = 3600,
|
|
141
151
|
val authorization: AuthorizationConfig? = null,
|
|
142
152
|
)
|
|
143
153
|
|
|
@@ -69,12 +69,16 @@ object ConfigJson {
|
|
|
69
69
|
return result
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
-
/**
|
|
73
|
-
|
|
72
|
+
/**
|
|
73
|
+
* The full TS `Config`: every key present, defaults filled, unset optional values as JSON null. With [maskSecrets],
|
|
74
|
+
* the authorization secrets are masked ([maskSecret]): results returned to the app (`getState()`, `setConfig()`,
|
|
75
|
+
* `ready()`, …) never carry the tokens (recommendation 16); the `authorization` event still does.
|
|
76
|
+
*/
|
|
77
|
+
fun toJson(c: Config, maskSecrets: Boolean = false): JSONObject = JSONObject()
|
|
74
78
|
.put("geolocation", geolocationToJson(c.geolocation))
|
|
75
79
|
.put("activity", activityToJson(c.activity))
|
|
76
80
|
.put("heartbeat", heartbeatToJson(c.heartbeat))
|
|
77
|
-
.put("http", httpToJson(c.http))
|
|
81
|
+
.put("http", httpToJson(c.http, maskSecrets))
|
|
78
82
|
.put("persistence", persistenceToJson(c.persistence))
|
|
79
83
|
.put("app", JSONObject().put("stopOnTerminate", c.app.stopOnTerminate).put("startOnBoot", c.app.startOnBoot))
|
|
80
84
|
.put("notification", notificationToJson(c.notification))
|
|
@@ -91,7 +95,9 @@ object ConfigJson {
|
|
|
91
95
|
.put("odometer", finiteOr(s.runtime.odometer, 0.0))
|
|
92
96
|
.put("backend", s.backend.wire)
|
|
93
97
|
.put("lastRecordAt", JsonUtil.orNull(Iso8601.formatOrNull(s.runtime.lastRecordAt)))
|
|
94
|
-
.put("
|
|
98
|
+
.put("lastStopReason", JsonUtil.orNull(s.runtime.lastStopReason))
|
|
99
|
+
.put("lastStopAt", JsonUtil.orNull(Iso8601.formatOrNull(s.runtime.lastStopAt)))
|
|
100
|
+
.put("config", toJson(s.config, maskSecrets = true))
|
|
95
101
|
|
|
96
102
|
// ---- parse
|
|
97
103
|
|
|
@@ -184,6 +190,7 @@ object ConfigJson {
|
|
|
184
190
|
enabled = r.boolean("enabled", c.enabled, d.enabled),
|
|
185
191
|
minInterval = r.int("minInterval", c.minInterval, d.minInterval),
|
|
186
192
|
maxInterval = r.int("maxInterval", c.maxInterval, d.maxInterval),
|
|
193
|
+
uploadKeepAwake = r.int("uploadKeepAwake", c.uploadKeepAwake, d.uploadKeepAwake),
|
|
187
194
|
)
|
|
188
195
|
}
|
|
189
196
|
|
|
@@ -214,6 +221,7 @@ object ConfigJson {
|
|
|
214
221
|
locationTemplate = r.nullableString("locationTemplate", c.locationTemplate, d.locationTemplate),
|
|
215
222
|
geofenceTemplate = r.nullableString("geofenceTemplate", c.geofenceTemplate, d.geofenceTemplate),
|
|
216
223
|
timeout = r.long("timeout", c.timeout, d.timeout),
|
|
224
|
+
maxRetryAfter = r.int("maxRetryAfter", c.maxRetryAfter, d.maxRetryAfter),
|
|
217
225
|
authorization = r.group<AuthorizationConfig?>("authorization", c.authorization, d.authorization) { ar, a ->
|
|
218
226
|
parseAuthorization(ar, a ?: AuthorizationConfig())
|
|
219
227
|
},
|
|
@@ -226,11 +234,11 @@ object ConfigJson {
|
|
|
226
234
|
strategy = r.enumValue("strategy", c.strategy, d.strategy, listOf(d.strategy)) { value ->
|
|
227
235
|
d.strategy.takeIf { it.equals(value, ignoreCase = true) }
|
|
228
236
|
},
|
|
229
|
-
accessToken = r.nullableString("accessToken", c.accessToken, d.accessToken),
|
|
230
|
-
refreshToken = r.nullableString("refreshToken", c.refreshToken, d.refreshToken),
|
|
237
|
+
accessToken = unmasked(r.nullableString("accessToken", c.accessToken, d.accessToken), c.accessToken),
|
|
238
|
+
refreshToken = unmasked(r.nullableString("refreshToken", c.refreshToken, d.refreshToken), c.refreshToken),
|
|
231
239
|
refreshUrl = r.nullableString("refreshUrl", c.refreshUrl, d.refreshUrl),
|
|
232
|
-
refreshPayload = r.objectText("refreshPayload", c.refreshPayload, d.refreshPayload),
|
|
233
|
-
refreshHeaders = r.stringMap("refreshHeaders", c.refreshHeaders, d.refreshHeaders),
|
|
240
|
+
refreshPayload = unmaskedPayload(r.objectText("refreshPayload", c.refreshPayload, d.refreshPayload), c.refreshPayload),
|
|
241
|
+
refreshHeaders = unmaskedHeaders(r.stringMap("refreshHeaders", c.refreshHeaders, d.refreshHeaders), c.refreshHeaders),
|
|
234
242
|
refreshPayloadEncoding = r.enumValue(
|
|
235
243
|
"refreshPayloadEncoding",
|
|
236
244
|
c.refreshPayloadEncoding,
|
|
@@ -371,8 +379,9 @@ object ConfigJson {
|
|
|
371
379
|
.put("enabled", h.enabled)
|
|
372
380
|
.put("minInterval", h.minInterval)
|
|
373
381
|
.put("maxInterval", h.maxInterval)
|
|
382
|
+
.put("uploadKeepAwake", h.uploadKeepAwake)
|
|
374
383
|
|
|
375
|
-
private fun httpToJson(h: HttpConfig): JSONObject = JSONObject()
|
|
384
|
+
private fun httpToJson(h: HttpConfig, maskSecrets: Boolean): JSONObject = JSONObject()
|
|
376
385
|
.put("url", JsonUtil.orNull(h.url))
|
|
377
386
|
.put("method", h.method.wire)
|
|
378
387
|
.put("headers", JsonUtil.fromStringMap(h.headers))
|
|
@@ -387,15 +396,19 @@ object ConfigJson {
|
|
|
387
396
|
.put("locationTemplate", JsonUtil.orNull(h.locationTemplate))
|
|
388
397
|
.put("geofenceTemplate", JsonUtil.orNull(h.geofenceTemplate))
|
|
389
398
|
.put("timeout", h.timeout)
|
|
390
|
-
.put("
|
|
399
|
+
.put("maxRetryAfter", h.maxRetryAfter)
|
|
400
|
+
.put("authorization", h.authorization?.let { authorizationToJson(it, maskSecrets) } ?: JSONObject.NULL)
|
|
391
401
|
|
|
392
|
-
private fun authorizationToJson(a: AuthorizationConfig): JSONObject = JSONObject()
|
|
402
|
+
private fun authorizationToJson(a: AuthorizationConfig, mask: Boolean): JSONObject = JSONObject()
|
|
393
403
|
.put("strategy", a.strategy)
|
|
394
|
-
.put("accessToken", JsonUtil.orNull(a.accessToken))
|
|
395
|
-
.put("refreshToken", JsonUtil.orNull(a.refreshToken))
|
|
404
|
+
.put("accessToken", JsonUtil.orNull(if (mask) a.accessToken?.let(::maskSecret) else a.accessToken))
|
|
405
|
+
.put("refreshToken", JsonUtil.orNull(if (mask) a.refreshToken?.let(::maskSecret) else a.refreshToken))
|
|
396
406
|
.put("refreshUrl", JsonUtil.orNull(a.refreshUrl))
|
|
397
|
-
.put(
|
|
398
|
-
|
|
407
|
+
.put(
|
|
408
|
+
"refreshPayload",
|
|
409
|
+
objectOrEmpty(if (mask) maskedPayload(a.refreshPayload) else a.refreshPayload, "http.authorization.refreshPayload"),
|
|
410
|
+
)
|
|
411
|
+
.put("refreshHeaders", JsonUtil.fromStringMap(if (mask) a.refreshHeaders.mapValues { maskSecret(it.value) } else a.refreshHeaders))
|
|
399
412
|
.put("refreshPayloadEncoding", a.refreshPayloadEncoding.wire)
|
|
400
413
|
.put("expires", a.expires)
|
|
401
414
|
|
|
@@ -433,6 +446,48 @@ object ConfigJson {
|
|
|
433
446
|
.put("positiveAction", JsonUtil.orNull(b.positiveAction))
|
|
434
447
|
.put("negativeAction", JsonUtil.orNull(b.negativeAction))
|
|
435
448
|
|
|
449
|
+
/** Prefix of a masked secret in results: `***` and the last 4 characters (all hidden when 8 or fewer). */
|
|
450
|
+
const val MASK_PREFIX = "***"
|
|
451
|
+
|
|
452
|
+
/** [value] masked for results (see [toJson]). */
|
|
453
|
+
fun maskSecret(value: String): String =
|
|
454
|
+
if (value.length <= MASK_VISIBLE * 2) MASK_PREFIX else MASK_PREFIX + value.takeLast(MASK_VISIBLE)
|
|
455
|
+
|
|
456
|
+
private const val MASK_VISIBLE = 4
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* `refreshPayload` with every string value masked, except values that only use the `{refreshToken}` placeholder
|
|
460
|
+
* (they hold no secret themselves).
|
|
461
|
+
*/
|
|
462
|
+
private fun maskedPayload(text: String): String {
|
|
463
|
+
val payload = JsonUtil.parseObject(text) ?: return text
|
|
464
|
+
val masked = JSONObject()
|
|
465
|
+
val keys = payload.keys()
|
|
466
|
+
while (keys.hasNext()) {
|
|
467
|
+
val key = keys.next()
|
|
468
|
+
val value = payload.get(key)
|
|
469
|
+
masked.put(key, if (value is String && !value.contains("{refreshToken}")) maskSecret(value) else value)
|
|
470
|
+
}
|
|
471
|
+
return masked.toString()
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* A secret given back unchanged from a result (`setConfig({config: (await getState()).config})`) keeps the stored
|
|
476
|
+
* value instead of replacing it with the masked text.
|
|
477
|
+
*/
|
|
478
|
+
private fun unmasked(given: String?, current: String?): String? =
|
|
479
|
+
if (given != null && current != null && given.startsWith(MASK_PREFIX) && given == maskSecret(current)) current else given
|
|
480
|
+
|
|
481
|
+
private fun unmaskedPayload(given: String, current: String): String =
|
|
482
|
+
if (given != current && JsonUtil.parseObject(given)?.toString() == JsonUtil.parseObject(maskedPayload(current))?.toString()) {
|
|
483
|
+
current
|
|
484
|
+
} else {
|
|
485
|
+
given
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
private fun unmaskedHeaders(given: Map<String, String>, current: Map<String, String>): Map<String, String> =
|
|
489
|
+
if (given != current && given == current.mapValues { maskSecret(it.value) }) current else given
|
|
490
|
+
|
|
436
491
|
/** Parses stored JSON object text; invalid text (only possible through `update{}`) becomes `{}`. */
|
|
437
492
|
private fun objectOrEmpty(text: String, path: String): JSONObject {
|
|
438
493
|
JsonUtil.parseObject(text)?.let { return it }
|
|
@@ -28,4 +28,10 @@ interface ConfigStore {
|
|
|
28
28
|
fun update(transform: (Config) -> Config): Config
|
|
29
29
|
|
|
30
30
|
fun updateRuntime(transform: (RuntimeState) -> RuntimeState): RuntimeState
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* True when the stored tokens (`accessToken`, `refreshToken`, `refreshPayload`, `refreshHeaders`) could not be
|
|
34
|
+
* decrypted at load: they read as unset, and uploads that need them are not sent until the app sets new ones.
|
|
35
|
+
*/
|
|
36
|
+
val secretsUnavailable: Boolean get() = false
|
|
31
37
|
}
|
|
@@ -6,11 +6,11 @@ import com.brickssoft.locationtracking.core.Logger
|
|
|
6
6
|
/**
|
|
7
7
|
* Clamps config values to their allowed ranges and logs a warning for each value it changes. Never throws.
|
|
8
8
|
*
|
|
9
|
-
* - `heartbeat.minInterval` >= 60 s and `heartbeat.maxInterval` >= `minInterval`;
|
|
9
|
+
* - `heartbeat.minInterval` >= 60 s and `heartbeat.maxInterval` >= `minInterval`; `heartbeat.uploadKeepAwake` in 0..60;
|
|
10
10
|
* - `activity.minimumActivityRecognitionConfidence` in 0..100;
|
|
11
11
|
* - `geolocation.stationaryRadius` >= 1; distances, intervals, delays and filter thresholds >= 0;
|
|
12
12
|
* - `geolocation.locationTimeout` and `http.timeout` > 0 (otherwise their defaults);
|
|
13
|
-
* - `http.maxBatchSize` >= 1, `http.autoSyncThreshold` >= 0, `http.syncInterval` >= 0;
|
|
13
|
+
* - `http.maxBatchSize` >= 1, `http.autoSyncThreshold` >= 0, `http.syncInterval` >= 0, `http.maxRetryAfter` >= 0;
|
|
14
14
|
* - `persistence.maxDaysToPersist` >= 1, `persistence.maxRecordsToPersist` >= -1 (-1 = unlimited);
|
|
15
15
|
* - `logger.logMaxDays` >= 1;
|
|
16
16
|
* - at most [Constants.MAX_NOTIFICATION_ACTIONS] notification actions (the first ones are kept);
|
|
@@ -24,6 +24,9 @@ object ConfigValidator {
|
|
|
24
24
|
|
|
25
25
|
const val MIN_STATIONARY_RADIUS_M = 1.0
|
|
26
26
|
|
|
27
|
+
/** Largest allowed `heartbeat.uploadKeepAwake`, seconds. */
|
|
28
|
+
const val MAX_UPLOAD_KEEP_AWAKE_S = 60
|
|
29
|
+
|
|
27
30
|
/** Returns [config] with every out-of-range value clamped (equal to [config] when all values are valid). */
|
|
28
31
|
fun validate(config: Config): Config {
|
|
29
32
|
val validated = config.copy(
|
|
@@ -104,12 +107,14 @@ object ConfigValidator {
|
|
|
104
107
|
private fun heartbeat(h: HeartbeatConfig): HeartbeatConfig {
|
|
105
108
|
val min = atLeast("heartbeat.minInterval", h.minInterval, MIN_HEARTBEAT_INTERVAL_S)
|
|
106
109
|
val max = atLeast("heartbeat.maxInterval", h.maxInterval, min)
|
|
107
|
-
|
|
110
|
+
val keepAwake = inRange("heartbeat.uploadKeepAwake", h.uploadKeepAwake, 0, MAX_UPLOAD_KEEP_AWAKE_S)
|
|
111
|
+
return h.copy(minInterval = min, maxInterval = max, uploadKeepAwake = keepAwake)
|
|
108
112
|
}
|
|
109
113
|
|
|
110
114
|
private fun http(h: HttpConfig): HttpConfig = h.copy(
|
|
111
115
|
autoSyncThreshold = atLeast("http.autoSyncThreshold", h.autoSyncThreshold, 0),
|
|
112
116
|
syncInterval = atLeast("http.syncInterval", h.syncInterval, 0),
|
|
117
|
+
maxRetryAfter = atLeast("http.maxRetryAfter", h.maxRetryAfter, 0),
|
|
113
118
|
maxBatchSize = atLeast("http.maxBatchSize", h.maxBatchSize, 1),
|
|
114
119
|
timeout = positive("http.timeout", h.timeout, HttpConfig().timeout),
|
|
115
120
|
)
|
|
@@ -30,6 +30,11 @@ internal object RuntimeStateJson {
|
|
|
30
30
|
.put("trackingStartedAt", JsonUtil.orNull(r.trackingStartedAt))
|
|
31
31
|
.put("providerState", r.providerState?.let { ProviderStateJson.toJson(it) } ?: JSONObject.NULL)
|
|
32
32
|
.put("didReady", r.didReady)
|
|
33
|
+
.put("trackingStartedElapsed", JsonUtil.orNull(r.trackingStartedElapsed))
|
|
34
|
+
.put("trackingStartedBootCount", JsonUtil.orNull(r.trackingStartedBootCount))
|
|
35
|
+
.put("lastStopReason", JsonUtil.orNull(r.lastStopReason))
|
|
36
|
+
.put("lastStopAt", JsonUtil.orNull(r.lastStopAt))
|
|
37
|
+
.put("userStopRecordedFor", JsonUtil.orNull(r.userStopRecordedFor))
|
|
33
38
|
|
|
34
39
|
fun fromJson(json: JSONObject): RuntimeState {
|
|
35
40
|
val d = RuntimeState()
|
|
@@ -59,6 +64,11 @@ internal object RuntimeStateJson {
|
|
|
59
64
|
trackingStartedAt = JsonUtil.optLong(json, "trackingStartedAt"),
|
|
60
65
|
providerState = json.optJSONObject("providerState")?.let { ProviderStateJson.fromJson(it) },
|
|
61
66
|
didReady = JsonUtil.optBoolean(json, "didReady") ?: d.didReady,
|
|
67
|
+
trackingStartedElapsed = JsonUtil.optLong(json, "trackingStartedElapsed"),
|
|
68
|
+
trackingStartedBootCount = JsonUtil.optInt(json, "trackingStartedBootCount"),
|
|
69
|
+
lastStopReason = JsonUtil.optString(json, "lastStopReason"),
|
|
70
|
+
lastStopAt = JsonUtil.optLong(json, "lastStopAt"),
|
|
71
|
+
userStopRecordedFor = JsonUtil.optLong(json, "userStopRecordedFor"),
|
|
62
72
|
)
|
|
63
73
|
}
|
|
64
74
|
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
package com.brickssoft.locationtracking.config
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.security.keystore.KeyGenParameterSpec
|
|
5
|
+
import android.security.keystore.KeyProperties
|
|
6
|
+
import androidx.annotation.VisibleForTesting
|
|
7
|
+
import com.brickssoft.locationtracking.core.Logger
|
|
8
|
+
import org.json.JSONObject
|
|
9
|
+
import java.io.File
|
|
10
|
+
import java.io.IOException
|
|
11
|
+
import java.nio.ByteBuffer
|
|
12
|
+
import java.security.KeyStore
|
|
13
|
+
import javax.crypto.Cipher
|
|
14
|
+
import javax.crypto.KeyGenerator
|
|
15
|
+
import javax.crypto.SecretKey
|
|
16
|
+
import javax.crypto.spec.GCMParameterSpec
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The secret fields of `http.authorization` (recommendation 8): `accessToken`, `refreshToken`, `refreshPayload` (it can
|
|
20
|
+
* hold a client secret) and `refreshHeaders`. They are kept out of the SharedPreferences file and stored by a
|
|
21
|
+
* [SecretStore] instead.
|
|
22
|
+
*/
|
|
23
|
+
data class AuthorizationSecrets(
|
|
24
|
+
val accessToken: String?,
|
|
25
|
+
val refreshToken: String?,
|
|
26
|
+
val refreshPayload: String,
|
|
27
|
+
val refreshHeaders: Map<String, String>,
|
|
28
|
+
) {
|
|
29
|
+
override fun toString(): String = "AuthorizationSecrets(redacted)"
|
|
30
|
+
|
|
31
|
+
fun toJson(): JSONObject = JSONObject()
|
|
32
|
+
.put("accessToken", accessToken ?: JSONObject.NULL)
|
|
33
|
+
.put("refreshToken", refreshToken ?: JSONObject.NULL)
|
|
34
|
+
.put("refreshPayload", refreshPayload)
|
|
35
|
+
.put("refreshHeaders", JSONObject(refreshHeaders))
|
|
36
|
+
|
|
37
|
+
companion object {
|
|
38
|
+
val EMPTY = AuthorizationSecrets(null, null, AuthorizationConfig().refreshPayload, emptyMap())
|
|
39
|
+
|
|
40
|
+
fun of(auth: AuthorizationConfig): AuthorizationSecrets =
|
|
41
|
+
AuthorizationSecrets(auth.accessToken, auth.refreshToken, auth.refreshPayload, auth.refreshHeaders)
|
|
42
|
+
|
|
43
|
+
fun fromJson(json: JSONObject): AuthorizationSecrets {
|
|
44
|
+
val headers = LinkedHashMap<String, String>()
|
|
45
|
+
json.optJSONObject("refreshHeaders")?.let { h -> h.keys().forEach { k -> headers[k] = h.optString(k) } }
|
|
46
|
+
return AuthorizationSecrets(
|
|
47
|
+
accessToken = if (json.isNull("accessToken")) null else json.optString("accessToken"),
|
|
48
|
+
refreshToken = if (json.isNull("refreshToken")) null else json.optString("refreshToken"),
|
|
49
|
+
refreshPayload = json.optString("refreshPayload", AuthorizationConfig().refreshPayload),
|
|
50
|
+
refreshHeaders = headers,
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Stores [AuthorizationSecrets]. Every method may throw; the config store decides what a failure means. */
|
|
57
|
+
interface SecretStore {
|
|
58
|
+
/** The stored secrets, or null if none are stored. @throws Exception if they are stored but cannot be read. */
|
|
59
|
+
fun read(): AuthorizationSecrets?
|
|
60
|
+
|
|
61
|
+
fun write(secrets: AuthorizationSecrets)
|
|
62
|
+
|
|
63
|
+
fun clear()
|
|
64
|
+
|
|
65
|
+
companion object {
|
|
66
|
+
/** In memory only (tests). */
|
|
67
|
+
fun inMemory(): SecretStore = object : SecretStore {
|
|
68
|
+
@Volatile
|
|
69
|
+
private var value: AuthorizationSecrets? = null
|
|
70
|
+
|
|
71
|
+
override fun read(): AuthorizationSecrets? = value
|
|
72
|
+
|
|
73
|
+
override fun write(secrets: AuthorizationSecrets) {
|
|
74
|
+
value = secrets
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
override fun clear() {
|
|
78
|
+
value = null
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Encrypts and decrypts the secrets file. */
|
|
85
|
+
interface SecretCipher {
|
|
86
|
+
fun encrypt(plaintext: ByteArray): ByteArray
|
|
87
|
+
|
|
88
|
+
fun decrypt(ciphertext: ByteArray): ByteArray
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* AES-256-GCM with a key that never leaves the Android Keystore (alias [KEY_ALIAS], created on first use). The output
|
|
93
|
+
* is `[IV length (4 bytes)][IV][ciphertext and tag]`, with [AAD] as associated data.
|
|
94
|
+
*
|
|
95
|
+
* This protects copies of the file (a backup, a forensic image, `adb backup`); code running inside the app process on
|
|
96
|
+
* a rooted phone can still use the key.
|
|
97
|
+
*/
|
|
98
|
+
class KeystoreSecretCipher : SecretCipher {
|
|
99
|
+
override fun encrypt(plaintext: ByteArray): ByteArray {
|
|
100
|
+
val cipher = Cipher.getInstance(TRANSFORMATION)
|
|
101
|
+
cipher.init(Cipher.ENCRYPT_MODE, key())
|
|
102
|
+
cipher.updateAAD(AAD)
|
|
103
|
+
val encrypted = cipher.doFinal(plaintext)
|
|
104
|
+
val iv = cipher.iv
|
|
105
|
+
return ByteBuffer.allocate(Int.SIZE_BYTES + iv.size + encrypted.size).putInt(iv.size).put(iv).put(encrypted).array()
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
override fun decrypt(ciphertext: ByteArray): ByteArray {
|
|
109
|
+
val buffer = ByteBuffer.wrap(ciphertext)
|
|
110
|
+
val ivSize = buffer.int
|
|
111
|
+
require(ivSize in 12..32 && buffer.remaining() > ivSize) { "damaged secrets file" }
|
|
112
|
+
val iv = ByteArray(ivSize).also { buffer.get(it) }
|
|
113
|
+
val encrypted = ByteArray(buffer.remaining()).also { buffer.get(it) }
|
|
114
|
+
val cipher = Cipher.getInstance(TRANSFORMATION)
|
|
115
|
+
cipher.init(Cipher.DECRYPT_MODE, key(), GCMParameterSpec(TAG_BITS, iv))
|
|
116
|
+
cipher.updateAAD(AAD)
|
|
117
|
+
return cipher.doFinal(encrypted)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private fun key(): SecretKey = synchronized(lock) {
|
|
121
|
+
val store = KeyStore.getInstance(KEYSTORE).apply { load(null) }
|
|
122
|
+
(store.getKey(KEY_ALIAS, null) as? SecretKey) ?: KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE)
|
|
123
|
+
.apply {
|
|
124
|
+
init(
|
|
125
|
+
KeyGenParameterSpec.Builder(KEY_ALIAS, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
|
|
126
|
+
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
|
127
|
+
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
|
128
|
+
.setKeySize(KEY_BITS)
|
|
129
|
+
.build(),
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
.generateKey()
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
private companion object {
|
|
136
|
+
const val KEYSTORE = "AndroidKeyStore"
|
|
137
|
+
const val KEY_ALIAS = "com.brickssoft.locationtracking.secrets.v1"
|
|
138
|
+
const val TRANSFORMATION = "AES/GCM/NoPadding"
|
|
139
|
+
const val KEY_BITS = 256
|
|
140
|
+
const val TAG_BITS = 128
|
|
141
|
+
val AAD = "location_tracking_secrets_v1".toByteArray(Charsets.UTF_8)
|
|
142
|
+
val lock = Any()
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* [SecretStore] in the file `noBackupFilesDir/`[FILE_NAME] (never in Auto Backup or device-to-device transfer),
|
|
148
|
+
* encrypted by [cipher]. Writes go to a temporary file that is renamed over the old one.
|
|
149
|
+
*/
|
|
150
|
+
class FileSecretStore(private val file: File, private val cipher: SecretCipher) : SecretStore {
|
|
151
|
+
override fun read(): AuthorizationSecrets? {
|
|
152
|
+
if (!file.isFile) return null
|
|
153
|
+
val plaintext = cipher.decrypt(file.readBytes())
|
|
154
|
+
return AuthorizationSecrets.fromJson(JSONObject(String(plaintext, Charsets.UTF_8)))
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
override fun write(secrets: AuthorizationSecrets) {
|
|
158
|
+
val encrypted = cipher.encrypt(secrets.toJson().toString().toByteArray(Charsets.UTF_8))
|
|
159
|
+
file.parentFile?.mkdirs()
|
|
160
|
+
val temp = File(file.path + ".tmp")
|
|
161
|
+
temp.writeBytes(encrypted)
|
|
162
|
+
if (!temp.renameTo(file)) {
|
|
163
|
+
temp.delete()
|
|
164
|
+
throw IOException("could not replace $file")
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
override fun clear() {
|
|
169
|
+
if (file.exists() && !file.delete()) Logger.w(TAG, "could not delete $file")
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
companion object {
|
|
173
|
+
private const val TAG = "LT.Secrets"
|
|
174
|
+
const val FILE_NAME = "location_tracking_secrets.bin"
|
|
175
|
+
|
|
176
|
+
/** The store the plugin uses: [FILE_NAME] under `noBackupFilesDir`, Android Keystore encryption. */
|
|
177
|
+
fun forContext(context: Context): SecretStore {
|
|
178
|
+
testOverride?.let { return it }
|
|
179
|
+
val app = context.applicationContext ?: context
|
|
180
|
+
return FileSecretStore(File(app.noBackupFilesDir, FILE_NAME), KeystoreSecretCipher())
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Test hook: the store [forContext] returns (Robolectric has no Android Keystore). */
|
|
184
|
+
@VisibleForTesting
|
|
185
|
+
@Volatile
|
|
186
|
+
var testOverride: SecretStore? = null
|
|
187
|
+
}
|
|
188
|
+
}
|