@bricks-soft/capacitor-location-tracking 8.0.0 → 8.1.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.
Files changed (75) hide show
  1. package/README.md +375 -124
  2. package/android/build.gradle +2 -1
  3. package/android/src/main/AndroidManifest.xml +2 -0
  4. package/android/src/main/java/com/brickssoft/locationtracking/bridge/OptionParsers.kt +4 -1
  5. package/android/src/main/java/com/brickssoft/locationtracking/bridge/PluginHandlers.kt +14 -5
  6. package/android/src/main/java/com/brickssoft/locationtracking/config/Config.kt +10 -0
  7. package/android/src/main/java/com/brickssoft/locationtracking/config/ConfigJson.kt +70 -15
  8. package/android/src/main/java/com/brickssoft/locationtracking/config/ConfigStore.kt +6 -0
  9. package/android/src/main/java/com/brickssoft/locationtracking/config/ConfigValidator.kt +8 -3
  10. package/android/src/main/java/com/brickssoft/locationtracking/config/RuntimeStateJson.kt +10 -0
  11. package/android/src/main/java/com/brickssoft/locationtracking/config/SecretStore.kt +188 -0
  12. package/android/src/main/java/com/brickssoft/locationtracking/config/SharedPrefsConfigStore.kt +188 -17
  13. package/android/src/main/java/com/brickssoft/locationtracking/config/State.kt +11 -0
  14. package/android/src/main/java/com/brickssoft/locationtracking/core/Components.kt +21 -5
  15. package/android/src/main/java/com/brickssoft/locationtracking/core/ForceStopProbe.kt +20 -9
  16. package/android/src/main/java/com/brickssoft/locationtracking/core/TrackingEvent.kt +24 -2
  17. package/android/src/main/java/com/brickssoft/locationtracking/data/TrackingDatabase.kt +43 -1
  18. package/android/src/main/java/com/brickssoft/locationtracking/device/DefaultDeviceInfoProvider.kt +17 -0
  19. package/android/src/main/java/com/brickssoft/locationtracking/device/DeviceDetails.kt +167 -0
  20. package/android/src/main/java/com/brickssoft/locationtracking/engine/DefaultTrackingEngine.kt +191 -34
  21. package/android/src/main/java/com/brickssoft/locationtracking/engine/TrackingEngine.kt +6 -0
  22. package/android/src/main/java/com/brickssoft/locationtracking/engine/TrackingTimers.kt +3 -0
  23. package/android/src/main/java/com/brickssoft/locationtracking/heartbeat/DefaultHeartbeatScheduler.kt +52 -8
  24. package/android/src/main/java/com/brickssoft/locationtracking/heartbeat/HeartbeatAlarmReceiver.kt +22 -0
  25. package/android/src/main/java/com/brickssoft/locationtracking/http/AuthorizationManager.kt +78 -27
  26. package/android/src/main/java/com/brickssoft/locationtracking/http/ConfigChecks.kt +140 -0
  27. package/android/src/main/java/com/brickssoft/locationtracking/http/HttpSupport.kt +50 -12
  28. package/android/src/main/java/com/brickssoft/locationtracking/http/HttpSyncer.kt +17 -0
  29. package/android/src/main/java/com/brickssoft/locationtracking/http/OkHttpSyncer.kt +507 -92
  30. package/android/src/main/java/com/brickssoft/locationtracking/http/SyncPolicy.kt +6 -6
  31. package/android/src/main/java/com/brickssoft/locationtracking/http/TemplateRenderer.kt +23 -1
  32. package/android/src/main/java/com/brickssoft/locationtracking/http/UploadJobs.kt +217 -0
  33. package/android/src/main/java/com/brickssoft/locationtracking/http/UploadWait.kt +184 -0
  34. package/android/src/main/java/com/brickssoft/locationtracking/logging/FileLogger.kt +66 -7
  35. package/android/src/main/java/com/brickssoft/locationtracking/logging/LogRedaction.kt +31 -0
  36. package/android/src/main/java/com/brickssoft/locationtracking/model/DeviceModels.kt +44 -1
  37. package/android/src/main/java/com/brickssoft/locationtracking/model/ModelJson.kt +79 -8
  38. package/android/src/main/java/com/brickssoft/locationtracking/model/Record.kt +9 -0
  39. package/android/src/main/java/com/brickssoft/locationtracking/model/TrackedLocation.kt +12 -2
  40. package/android/src/main/java/com/brickssoft/locationtracking/position/DefaultPositionService.kt +10 -2
  41. package/android/src/main/java/com/brickssoft/locationtracking/position/PositionWatch.kt +24 -1
  42. package/android/src/main/java/com/brickssoft/locationtracking/processing/DefaultRecordFactory.kt +31 -10
  43. package/android/src/main/java/com/brickssoft/locationtracking/provider/Backends.kt +48 -3
  44. package/android/src/main/java/com/brickssoft/locationtracking/provider/DefaultProviderFactory.kt +60 -0
  45. package/android/src/main/java/com/brickssoft/locationtracking/provider/LocationSubscriptions.kt +180 -0
  46. package/android/src/main/java/com/brickssoft/locationtracking/provider/android/AndroidLocationBackend.kt +200 -126
  47. package/android/src/main/java/com/brickssoft/locationtracking/provider/android/LocationProviders.kt +20 -0
  48. package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsLocationBackend.kt +39 -51
  49. package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsProviderBundle.kt +8 -0
  50. package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsLocationBackend.kt +47 -53
  51. package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsProviderBundle.kt +34 -2
  52. package/android/src/main/java/com/brickssoft/locationtracking/record/RecordSink.kt +19 -1
  53. package/android/src/main/java/com/brickssoft/locationtracking/service/BootCountStore.kt +7 -0
  54. package/android/src/main/res/xml/lt_file_paths.xml +1 -1
  55. package/dist/esm/definitions.d.ts +107 -4
  56. package/dist/esm/definitions.js.map +1 -1
  57. package/dist/esm/events.d.ts +8 -2
  58. package/dist/esm/events.js +7 -1
  59. package/dist/esm/events.js.map +1 -1
  60. package/dist/esm/index.d.ts +1 -1
  61. package/dist/esm/index.js +1 -1
  62. package/dist/esm/index.js.map +1 -1
  63. package/dist/esm/web/config.js +10 -0
  64. package/dist/esm/web/config.js.map +1 -1
  65. package/dist/esm/web/device.d.ts +2 -2
  66. package/dist/esm/web/device.js +9 -2
  67. package/dist/esm/web/device.js.map +1 -1
  68. package/dist/esm/web.d.ts +0 -8
  69. package/dist/esm/web.js +12 -3
  70. package/dist/esm/web.js.map +1 -1
  71. package/dist/plugin.cjs.js +40 -6
  72. package/dist/plugin.cjs.js.map +1 -1
  73. package/dist/plugin.js +40 -6
  74. package/dist/plugin.js.map +1 -1
  75. package/package.json +4 -3
@@ -39,7 +39,8 @@ android {
39
39
  minSdkVersion project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 24
40
40
  targetSdkVersion project.hasProperty('targetSdkVersion') ? rootProject.ext.targetSdkVersion : 36
41
41
  consumerProguardFiles 'consumer-rules.pro'
42
- buildConfigField 'String', 'PLUGIN_VERSION', '"8.0.0"'
42
+ // package.json ships next to android/ in the npm package, so it is the one place the version is set.
43
+ buildConfigField 'String', 'PLUGIN_VERSION', "\"${new groovy.json.JsonSlurper().parse(file('../package.json')).version}\""
43
44
  }
44
45
  buildFeatures {
45
46
  buildConfig = true
@@ -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("accuracy", "altitude_accuracy", "speed", "speed_accuracy", "heading_accuracy")) {
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 stateJson(state)
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 stateJson(services.engine.setConfig(OptionParsers.requireObject(options, "config")))
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 stateJson(services.engine.reset(OptionParsers.optObject(options, "config")))
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
- /** `log({level, message})` -> void; written through [Logger] (logcat and the log file). */
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
- /** The full TS `Config`: every key present, defaults filled, unset optional values as JSON null. */
73
- fun toJson(c: Config): JSONObject = JSONObject()
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("config", toJson(s.config))
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("authorization", h.authorization?.let { authorizationToJson(it) } ?: JSONObject.NULL)
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("refreshPayload", objectOrEmpty(a.refreshPayload, "http.authorization.refreshPayload"))
398
- .put("refreshHeaders", JsonUtil.fromStringMap(a.refreshHeaders))
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
- return h.copy(minInterval = min, maxInterval = max)
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
+ }