@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
@@ -17,6 +17,7 @@ import com.brickssoft.locationtracking.core.LogLevel
17
17
  import com.brickssoft.locationtracking.core.Logger
18
18
  import com.brickssoft.locationtracking.data.LocationStore
19
19
  import com.brickssoft.locationtracking.device.DeviceMonitor
20
+ import com.brickssoft.locationtracking.http.HttpSyncer
20
21
  import com.brickssoft.locationtracking.model.HeartbeatMeta
21
22
  import com.brickssoft.locationtracking.model.HeartbeatStatus
22
23
  import com.brickssoft.locationtracking.model.HeartbeatStrategy
@@ -70,8 +71,14 @@ import kotlin.coroutines.cancellation.CancellationException
70
71
  * idle-paced backup. A record every 5 s therefore sets the alarms once per 30 s instead of once per record. An alarm
71
72
  * left in place fires up to 30 s early; [onAlarm] then finds the window not due, creates no heartbeat and arms the
72
73
  * real due time.
73
- * - One partial wake lock per alarm delivery. It is released when the heartbeat has been submitted (the upload that
74
- * the submit starts holds no wake lock) and times out after [WAKE_LOCK_TIMEOUT_MS] if something hangs.
74
+ * - One partial wake lock per alarm delivery. It is released when the heartbeat has been submitted and times out after
75
+ * [WAKE_LOCK_TIMEOUT_MS] if something hangs.
76
+ * - **Upload wake lock** (recommendation 11d, `heartbeat.uploadKeepAwake`, default 10 s). Right before the alarm's
77
+ * wake lock ends, a second wake lock is taken for the heartbeat's upload: it is released when the first upload pass
78
+ * that started after the heartbeat was queued has finished ([HttpSyncer.awaitUpload]: uploaded, failed or nothing to
79
+ * send), or after `uploadKeepAwake` seconds. So the heartbeat reaches the server on time instead of when the phone
80
+ * next wakes up. The wait runs outside the alarm broadcast, so a slow upload cannot hold the broadcast past its limit.
81
+ * With 0 there is no second wake lock (the behavior before this setting).
75
82
  * - The backend's last known location is requested only when `runtime.lastLocation` is null, and not again within
76
83
  * [LAST_LOCATION_RETRY_MS] after the backend answered that it has none (a timeout or an error is retried at the
77
84
  * next heartbeat).
@@ -91,6 +98,8 @@ class DefaultHeartbeatScheduler(
91
98
  private val clock: Clock,
92
99
  private val scope: CoroutineScope,
93
100
  private val alarms: HeartbeatAlarms = SystemHeartbeatAlarms(context),
101
+ /** The uploader whose pass the upload wake lock waits for; null = no upload wake lock. */
102
+ private val uploads: Lazy<HttpSyncer>? = null,
94
103
  ) : HeartbeatScheduler {
95
104
  private enum class Lifecycle {
96
105
  /** Fresh process: never started. An alarm may still arrive from a previous process. */
@@ -323,7 +332,9 @@ class DefaultHeartbeatScheduler(
323
332
  Logger.d(TAG, "$trigger: superseded by a newer record or stop() while the heartbeat was built")
324
333
  return
325
334
  }
335
+ val ticket = uploads?.let { tryOrNull("upload ticket") { it.value.uploadTicket() } }
326
336
  recordSink.value.submit(record)
337
+ keepAwakeForUpload(ticket)
327
338
  val meta = record.heartbeat
328
339
  Logger.i(
329
340
  TAG,
@@ -625,13 +636,45 @@ class DefaultHeartbeatScheduler(
625
636
 
626
637
  // ---- wake lock
627
638
 
628
- private fun acquireWakeLock(): PowerManager.WakeLock? = try {
629
- powerManager?.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, WAKE_LOCK_TAG)?.apply {
630
- setReferenceCounted(false)
631
- acquire(WAKE_LOCK_TIMEOUT_MS)
639
+ private fun acquireWakeLock(timeoutMs: Long = WAKE_LOCK_TIMEOUT_MS, tag: String = WAKE_LOCK_TAG): PowerManager.WakeLock? =
640
+ try {
641
+ powerManager?.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, tag)?.apply {
642
+ setReferenceCounted(false)
643
+ acquire(timeoutMs)
644
+ }
645
+ } catch (e: RuntimeException) {
646
+ Logger.w(TAG, "cannot acquire the wake lock", e)
647
+ null
648
+ }
649
+
650
+ /**
651
+ * Keeps the phone awake until the first upload pass started after [ticket] has finished, at most
652
+ * `heartbeat.uploadKeepAwake` seconds (see the class docs). Taken before the caller's wake lock ends.
653
+ */
654
+ private fun keepAwakeForUpload(ticket: Long?) {
655
+ val syncer = uploads ?: return
656
+ val seconds = configStore.config.value.heartbeat.uploadKeepAwake
657
+ if (ticket == null || seconds <= 0) return
658
+ val limitMs = seconds * 1_000L
659
+ val wakeLock = acquireWakeLock(limitMs, UPLOAD_WAKE_LOCK_TAG)
660
+ scope.launch {
661
+ try {
662
+ val done = withTimeoutOrNull(limitMs) { syncer.value.awaitUpload(ticket) }
663
+ if (done == null) Logger.d(TAG, "heartbeat upload not finished within $seconds s; the phone may sleep")
664
+ } catch (e: CancellationException) {
665
+ throw e
666
+ } catch (e: Exception) {
667
+ Logger.w(TAG, "waiting for the heartbeat upload failed", e)
668
+ } finally {
669
+ release(wakeLock)
670
+ }
632
671
  }
633
- } catch (e: RuntimeException) {
634
- Logger.w(TAG, "cannot acquire the wake lock", e)
672
+ }
673
+
674
+ private inline fun <T> tryOrNull(what: String, block: () -> T): T? = try {
675
+ block()
676
+ } catch (e: Exception) {
677
+ Logger.w(TAG, "$what failed", e)
635
678
  null
636
679
  }
637
680
 
@@ -689,6 +732,7 @@ class DefaultHeartbeatScheduler(
689
732
  const val TAG = "LT.Heartbeat"
690
733
  const val LISTENER_TAG = "lt-heartbeat"
691
734
  const val WAKE_LOCK_TAG = "LocationTracking:heartbeat"
735
+ const val UPLOAD_WAKE_LOCK_TAG = "LocationTracking:heartbeat-upload"
692
736
 
693
737
  /**
694
738
  * Upper bound of the heartbeat wake lock. The lock is released as soon as the heartbeat is submitted
@@ -40,6 +40,10 @@ class HeartbeatAlarmReceiver : BroadcastReceiver() {
40
40
  /**
41
41
  * The post-alarm decision: run the heartbeat, then restore tracking if it is enabled while the foreground
42
42
  * service is not running. Failures are logged; the restore check runs even if the heartbeat failed.
43
+ *
44
+ * In a process that started after the user stopped the app (the Android 13+ Task Manager's Stop leaves the
45
+ * alarms in place), no heartbeat is created: the engine's restore releases the alarms and records the user stop
46
+ * instead (recommendation 6).
43
47
  */
44
48
  suspend fun deliver(
45
49
  trigger: HeartbeatTrigger,
@@ -48,6 +52,24 @@ class HeartbeatAlarmReceiver : BroadcastReceiver() {
48
52
  serviceController: Lazy<ServiceController>,
49
53
  engine: Lazy<TrackingEngine>,
50
54
  ) {
55
+ val userStopped = try {
56
+ configStore.runtime.value.enabled && !serviceController.value.isRunning &&
57
+ engine.value.startedAfterUserStop()
58
+ } catch (e: Exception) {
59
+ Logger.w(TAG, "cannot tell whether the user stopped the app", e)
60
+ false
61
+ }
62
+ if (userStopped) {
63
+ Logger.i(TAG, "heartbeat alarm ($trigger) after a user stop: no heartbeat")
64
+ try {
65
+ engine.value.restore("restore")
66
+ } catch (e: CancellationException) {
67
+ throw e
68
+ } catch (e: Exception) {
69
+ Logger.e(TAG, "handling the user stop failed", e)
70
+ }
71
+ return
72
+ }
51
73
  try {
52
74
  heartbeat.onAlarm(trigger)
53
75
  } catch (e: CancellationException) {
@@ -27,9 +27,16 @@ import java.io.IOException
27
27
  * a 401 (at most once per upload; the caller enforces that).
28
28
  * - A refresh POSTs `refreshPayload` (string values with `{refreshToken}` substituted, JSON or form encoded) with
29
29
  * `refreshHeaders` to `refreshUrl`, reads `accessToken|access_token`, `refreshToken|refresh_token` and
30
- * `expires|expires_at` (epoch; seconds if < 1e12) or `expires_in` (relative seconds), persists the new tokens with
31
- * `configStore.update` and emits an `Authorization` event (also on failure).
32
- * - Refreshes are single-flight: callers that waited while another caller replaced their stale token reuse it.
30
+ * `expires|expires_at` (epoch; seconds if < 1e12) or `expires_in` (relative seconds), and emits an `Authorization`
31
+ * event (also on failure). The refresh answer is read in full: the new tokens are parsed from it.
32
+ * - **One identity per upload** (recommendation 2). An upload reads the [Credentials] once, at its start, and every
33
+ * try of that upload uses only them or credentials refreshed from them. When the configured tokens differ from them
34
+ * (the app called `setConfig` with another user's token, or removed the authorization), [renew] and [prepare]
35
+ * answer [Outcome.CHANGED] and the upload stops, keeping its records.
36
+ * - **Compare-and-set save.** A refresh saves its new tokens only if the configured access and refresh tokens are still
37
+ * the ones it started from; otherwise the new tokens are dropped and the answer is [Outcome.CHANGED]. A refresh that
38
+ * finishes after the app configured another user can therefore never overwrite that user's tokens.
39
+ * - Refreshes are single-flight (one at a time).
33
40
  *
34
41
  * Token values are never logged.
35
42
  */
@@ -48,8 +55,28 @@ internal class AuthorizationManager(
48
55
  @Volatile
49
56
  private var shortLivedToken: String? = null
50
57
 
51
- /** The access token to send ([value] null = no Authorization header) and whether a refresh was attempted. */
52
- data class Token(val value: String?, val refreshAttempted: Boolean)
58
+ /** The access and refresh tokens one upload is sent with (blank values are null). */
59
+ data class Credentials(val accessToken: String?, val refreshToken: String?) {
60
+ override fun toString(): String = "Credentials(redacted)"
61
+ }
62
+
63
+ /** What [prepare] or [renew] found. */
64
+ enum class Outcome {
65
+ /** The credentials are usable as they are (no refresh was needed, or [prepare] may not refresh). */
66
+ UNCHANGED,
67
+
68
+ /** The token was refreshed; use the returned credentials. */
69
+ RENEWED,
70
+
71
+ /** A refresh was needed but failed or is not possible (no `refreshUrl`). */
72
+ FAILED,
73
+
74
+ /** The configured tokens are no longer the upload's: stop the upload and keep its records. */
75
+ CHANGED,
76
+ }
77
+
78
+ /** [credentials] to send with and what happened; [refreshAttempted] is true if a refresh request was made. */
79
+ data class Result(val outcome: Outcome, val credentials: Credentials, val refreshAttempted: Boolean)
53
80
 
54
81
  /** Tokens read from a refresh response; [expires] is epoch ms or -1 if unknown. */
55
82
  data class Tokens(val accessToken: String, val refreshToken: String?, val expires: Long)
@@ -58,29 +85,38 @@ internal class AuthorizationManager(
58
85
  fun appliesTo(http: HttpConfig): Boolean =
59
86
  http.authorization != null && !HttpSupport.hasHeader(http.headers, HttpSupport.AUTHORIZATION)
60
87
 
88
+ /** The configured credentials, or null without `http.authorization`. */
89
+ fun current(): Credentials? = configStore.config.value.http.authorization?.let(::credentialsOf)
90
+
91
+ /** True while the configured credentials are still [credentials]. */
92
+ fun isCurrent(credentials: Credentials): Boolean = current() == credentials
93
+
61
94
  /**
62
- * The token for the next request, refreshing first if it is missing or about to expire (and refreshable), unless
63
- * [mayRefresh] is false.
95
+ * Before a request with [credentials]: refreshes first if the token is missing or about to expire and a refresh is
96
+ * possible, unless [mayRefresh] is false. Never answers [Outcome.FAILED] for a token that is still usable; a failed
97
+ * refresh of an expiring token keeps sending the old one ([Outcome.FAILED] with the old credentials).
64
98
  */
65
- suspend fun tokenForRequest(client: OkHttpClient, mayRefresh: Boolean = true): Token {
66
- val auth = configStore.config.value.http.authorization ?: return Token(null, false)
67
- val current = auth.accessToken.nonBlank()
68
- if (!mayRefresh || !needsRefresh(auth) || !canRefresh(auth)) return Token(current, false)
69
- return Token(refresh(client, current) ?: current, true)
99
+ suspend fun prepare(client: OkHttpClient, credentials: Credentials, mayRefresh: Boolean): Result {
100
+ val auth = configStore.config.value.http.authorization
101
+ if (auth == null || credentialsOf(auth) != credentials) return Result(Outcome.CHANGED, credentials, false)
102
+ if (!mayRefresh || !needsRefresh(auth) || !canRefresh(auth)) return Result(Outcome.UNCHANGED, credentials, false)
103
+ return renew(client, credentials)
70
104
  }
71
105
 
72
106
  /**
73
- * Refreshes the token (single-flight) unless another caller already replaced [staleToken] with a token that
74
- * does not need a refresh. Returns the token to use, or null if there is none (refresh failed or impossible).
107
+ * Refreshes [from] (single-flight). [Outcome.CHANGED] if the configured tokens are no longer [from] (before or after
108
+ * the refresh request); [Outcome.FAILED] if the refresh failed or is not possible.
75
109
  */
76
- suspend fun refresh(client: OkHttpClient, staleToken: String?): String? = mutex.withLock {
77
- val auth = configStore.config.value.http.authorization ?: return@withLock null
78
- val current = auth.accessToken.nonBlank()
79
- if (current != null && current != staleToken && !needsRefresh(auth)) return@withLock current
80
- if (!canRefresh(auth)) return@withLock null
81
- performRefresh(client, auth)
110
+ suspend fun renew(client: OkHttpClient, from: Credentials): Result = mutex.withLock {
111
+ val auth = configStore.config.value.http.authorization
112
+ if (auth == null || credentialsOf(auth) != from) return@withLock Result(Outcome.CHANGED, from, false)
113
+ if (!canRefresh(auth)) return@withLock Result(Outcome.FAILED, from, false)
114
+ performRefresh(client, auth, from)
82
115
  }
83
116
 
117
+ private fun credentialsOf(auth: AuthorizationConfig) =
118
+ Credentials(auth.accessToken.nonBlank(), auth.refreshToken.nonBlank())
119
+
84
120
  private fun canRefresh(auth: AuthorizationConfig): Boolean = !auth.refreshUrl.isNullOrBlank()
85
121
 
86
122
  private fun needsRefresh(auth: AuthorizationConfig): Boolean {
@@ -91,12 +127,13 @@ internal class AuthorizationManager(
91
127
  return now >= auth.expires || (now >= auth.expires - EXPIRY_MARGIN_MS && token != shortLivedToken)
92
128
  }
93
129
 
94
- private suspend fun performRefresh(client: OkHttpClient, auth: AuthorizationConfig): String? {
130
+ private suspend fun performRefresh(client: OkHttpClient, auth: AuthorizationConfig, from: Credentials): Result {
131
+ val failed = Result(Outcome.FAILED, from, refreshAttempted = true)
95
132
  val url = HttpSupport.parseUrl(auth.refreshUrl)
96
133
  if (url == null) {
97
134
  Logger.e(TAG, "authorization.refreshUrl is not a valid http(s) URL")
98
135
  emit(false, 0, "authorization.refreshUrl is not a valid http(s) URL", null)
99
- return null
136
+ return failed.copy(refreshAttempted = false)
100
137
  }
101
138
  val (contentType, body) = refreshBody(auth)
102
139
  val headers = HttpSupport.headers(contentType, auth.refreshHeaders, bearerToken = null)
@@ -107,36 +144,50 @@ internal class AuthorizationManager(
107
144
  currentCoroutineContext().ensureActive()
108
145
  Logger.w(TAG, "token refresh failed: ${e.message}")
109
146
  emit(false, 0, e.message ?: e.javaClass.simpleName, null)
110
- return null
147
+ return failed
111
148
  }
112
149
  val json = JsonUtil.parseObject(response.body)
113
150
  if (!response.isSuccessful) {
114
151
  Logger.w(TAG, "token refresh failed: HTTP ${response.status}")
115
152
  emit(false, response.status, "HTTP ${response.status}", json?.toString())
116
- return null
153
+ return failed
117
154
  }
118
155
  val tokens = parseTokens(json, clock.now())
119
156
  if (tokens == null) {
120
157
  Logger.w(TAG, "token refresh response (HTTP ${response.status}) has no accessToken")
121
158
  emit(false, response.status, "refresh response has no accessToken", json?.toString())
122
- return null
159
+ return failed
123
160
  }
161
+ var saved: Credentials? = null
124
162
  configStore.update { config ->
125
- // Do not resurrect an authorization config removed while the refresh was in flight.
163
+ // Only onto the tokens the refresh started from: the app may have removed the authorization or configured
164
+ // another user while the refresh was in flight.
126
165
  val current = config.http.authorization ?: return@update config
166
+ if (credentialsOf(current) != from) return@update config
127
167
  val updated = current.copy(
128
168
  accessToken = tokens.accessToken,
129
169
  refreshToken = tokens.refreshToken ?: current.refreshToken,
130
170
  expires = tokens.expires,
131
171
  )
172
+ saved = credentialsOf(updated)
132
173
  config.copy(http = config.http.copy(authorization = updated))
133
174
  }
175
+ val renewed = saved
176
+ if (renewed == null) {
177
+ Logger.w(TAG, "the authorization changed while its token was refreshed; the new tokens are not saved")
178
+ return Result(Outcome.CHANGED, from, refreshAttempted = true)
179
+ }
134
180
  val shortLived = tokens.expires > 0 && tokens.expires - clock.now() <= EXPIRY_MARGIN_MS
135
181
  shortLivedToken = if (shortLived) tokens.accessToken else null
136
182
  if (shortLived) Logger.w(TAG, "refreshed access token expires within ${EXPIRY_MARGIN_MS / 1000} s")
137
183
  Logger.i(TAG, "access token refreshed (HTTP ${response.status})")
138
184
  emit(true, response.status, null, json?.toString())
139
- return tokens.accessToken
185
+ return Result(Outcome.RENEWED, renewed, refreshAttempted = true)
186
+ }
187
+
188
+ /** Emits an `Authorization` event with [error] and no request (for example the stored tokens cannot be read). */
189
+ fun reportUnusable(error: String) {
190
+ emit(false, 0, error, null)
140
191
  }
141
192
 
142
193
  private fun emit(success: Boolean, status: Int, error: String?, responseJson: String?) {
@@ -0,0 +1,140 @@
1
+ package com.brickssoft.locationtracking.http
2
+
3
+ import com.brickssoft.locationtracking.config.Config
4
+ import com.brickssoft.locationtracking.config.HttpConfig
5
+ import com.brickssoft.locationtracking.core.ErrorCode
6
+ import com.brickssoft.locationtracking.core.TrackingException
7
+ import com.brickssoft.locationtracking.model.AccuracyLevel
8
+ import com.brickssoft.locationtracking.model.ActivitySample
9
+ import com.brickssoft.locationtracking.model.ActivityType
10
+ import com.brickssoft.locationtracking.model.BatteryDetails
11
+ import com.brickssoft.locationtracking.model.BatterySnapshot
12
+ import com.brickssoft.locationtracking.model.ConnectivityType
13
+ import com.brickssoft.locationtracking.model.DeviceSnapshot
14
+ import com.brickssoft.locationtracking.model.GeofenceAction
15
+ import com.brickssoft.locationtracking.model.GeofenceHit
16
+ import com.brickssoft.locationtracking.model.HeartbeatMeta
17
+ import com.brickssoft.locationtracking.model.HeartbeatStrategy
18
+ import com.brickssoft.locationtracking.model.PermissionLevel
19
+ import com.brickssoft.locationtracking.model.ProviderKind
20
+ import com.brickssoft.locationtracking.model.ProviderState
21
+ import com.brickssoft.locationtracking.model.Record
22
+ import com.brickssoft.locationtracking.model.RecordEvent
23
+ import com.brickssoft.locationtracking.model.TrackedLocation
24
+ import okhttp3.Headers
25
+
26
+ /**
27
+ * Checks of a config the app gives (`ready()`, `setConfig()`, `reset()`) that [com.brickssoft.locationtracking.config.
28
+ * ConfigValidator] cannot fix by clamping (recommendation 14). Mistakes that would otherwise show only at upload time
29
+ * reject the call with `INVALID_ARGUMENT`, and nothing is applied:
30
+ * - `http.url` (unless unset or blank): an http or https URL with a host, without user info (`user:password@`) and
31
+ * without a fragment (`#…`);
32
+ * - `http.headers` and `http.authorization.refreshHeaders`: names and values that OkHttp accepts (ASCII, no line
33
+ * breaks). A rejected header is named; its value is never repeated;
34
+ * - `http.locationTemplate` and `http.geofenceTemplate`: every `<%= name %>` placeholder is known, and the template
35
+ * renders to a valid JSON object or array ([StrictJson]) for a sample record with every field set and for one with
36
+ * none set.
37
+ *
38
+ * [warnings] lists what is accepted but probably wrong: an `http://` upload URL (Android 9+ blocks cleartext traffic
39
+ * unless the app's network security config allows that host).
40
+ */
41
+ internal object ConfigChecks {
42
+ /** Throws `INVALID_ARGUMENT` for the first mistake found; returns [warnings] otherwise. */
43
+ fun check(config: Config): List<String> {
44
+ val http = config.http
45
+ checkUrl(http)
46
+ checkHeaders("http.headers", http.headers)
47
+ http.authorization?.let { checkHeaders("http.authorization.refreshHeaders", it.refreshHeaders) }
48
+ checkTemplate("http.locationTemplate", http.locationTemplate)
49
+ checkTemplate("http.geofenceTemplate", http.geofenceTemplate)
50
+ return warnings(config)
51
+ }
52
+
53
+ fun warnings(config: Config): List<String> {
54
+ val url = HttpSupport.parseUrl(config.http.url) ?: return emptyList()
55
+ if (url.scheme != "http") return emptyList()
56
+ return listOf(
57
+ "http.url uses http://: Android 9+ blocks cleartext traffic unless the app's network security config " +
58
+ "allows ${url.host}; uploads would fail until then",
59
+ )
60
+ }
61
+
62
+ private fun checkUrl(http: HttpConfig) {
63
+ val text = http.url?.takeIf { it.isNotBlank() } ?: return
64
+ val url = HttpSupport.parseUrl(text) ?: invalid("http.url must be an http(s) URL with a host")
65
+ if (url.host.isEmpty()) invalid("http.url must have a host")
66
+ if (url.username.isNotEmpty() || url.password.isNotEmpty()) {
67
+ invalid("http.url must not contain user info (user:password@); use http.headers")
68
+ }
69
+ if (url.fragment != null) invalid("http.url must not contain a fragment (#...)")
70
+ }
71
+
72
+ private fun checkHeaders(path: String, headers: Map<String, String>) {
73
+ for ((name, value) in headers) {
74
+ try {
75
+ Headers.Builder().add(name, value)
76
+ } catch (e: IllegalArgumentException) {
77
+ // Never repeat the value: it may be a credential.
78
+ invalid("$path: header '${name.take(MAX_ECHO)}' has an invalid name or value (ASCII only, no line breaks)")
79
+ }
80
+ }
81
+ }
82
+
83
+ private fun checkTemplate(path: String, template: String?) {
84
+ if (template.isNullOrBlank()) return
85
+ val unknown = TemplateRenderer.placeholderNames(template) - TemplateRenderer.PLACEHOLDERS
86
+ if (unknown.isNotEmpty()) {
87
+ invalid("$path has unknown placeholder(s) ${unknown.joinToString { "'${it.take(MAX_ECHO)}'" }}")
88
+ }
89
+ for (sample in SAMPLES) {
90
+ val text = TemplateRenderer.renderText(template, sample, sample.recordedAt)
91
+ if (!StrictJson.isValid(text) || !(text.trimStart().startsWith("{") || text.trimStart().startsWith("["))) {
92
+ invalid("$path does not render to a JSON object or array (check the quotes around string placeholders)")
93
+ }
94
+ }
95
+ }
96
+
97
+ private fun invalid(message: String): Nothing = throw TrackingException(ErrorCode.INVALID_ARGUMENT, "config.$message")
98
+
99
+ private const val MAX_ECHO = 40
100
+ private const val SAMPLE_TIME = 1_790_000_000_000L
101
+
102
+ /** A record with every field set, and one with none (no location, no geofence, no provider). */
103
+ private val SAMPLES: List<Record> by lazy {
104
+ val full = Record(
105
+ uuid = "00000000-0000-4000-8000-000000000000",
106
+ event = RecordEvent.GEOFENCE,
107
+ location = TrackedLocation(
108
+ latitude = 52.5, longitude = 13.4, accuracy = 5f, altitude = 34.0, altitudeAccuracy = 3f, speed = 1.5f,
109
+ speedAccuracy = 0.5f, heading = 90f, headingAccuracy = 10f, time = SAMPLE_TIME, isMock = false,
110
+ ),
111
+ recordedAt = SAMPLE_TIME,
112
+ elapsedRealtimeMs = 1_000L,
113
+ bootCount = 1,
114
+ isMoving = true,
115
+ odometer = 10.0,
116
+ activity = ActivitySample(ActivityType.WALKING, 80),
117
+ battery = BatterySnapshot(0.5f, false),
118
+ backend = ProviderKind.GMS,
119
+ extras = """{"k":"v"}""",
120
+ geofence = GeofenceHit("id", GeofenceAction.ENTER, """{"k":"v"}"""),
121
+ provider = ProviderState(true, true, true, PermissionLevel.ALWAYS, AccuracyLevel.PRECISE, ProviderKind.GMS),
122
+ reason = "start",
123
+ heartbeat = HeartbeatMeta(HeartbeatStrategy.EXACT, 180, 300, SAMPLE_TIME, true, false),
124
+ stoppedAt = SAMPLE_TIME,
125
+ network = ConnectivityType.WIFI,
126
+ device = DeviceSnapshot(
127
+ backgroundRestricted = false, standbyBucket = "active", diskFreeBytes = 1L, thermalStatus = "none",
128
+ manufacturer = "m", model = "m", osVersion = "14", sdkInt = 34, deviceId = "a", lowRamDevice = false,
129
+ ramTotalBytes = 1L,
130
+ ),
131
+ batteryDetails = BatteryDetails(30f, "good", "ac"),
132
+ )
133
+ val empty = Record(
134
+ uuid = full.uuid, event = RecordEvent.HEARTBEAT, location = null, recordedAt = SAMPLE_TIME,
135
+ elapsedRealtimeMs = 1_000L, bootCount = -1, isMoving = false, odometer = 0.0,
136
+ activity = ActivitySample.UNKNOWN, battery = BatterySnapshot.UNKNOWN, backend = null,
137
+ )
138
+ listOf(full, empty)
139
+ }
140
+ }
@@ -17,8 +17,11 @@ import java.util.Locale
17
17
  import java.util.concurrent.TimeUnit
18
18
  import java.util.concurrent.atomic.AtomicBoolean
19
19
 
20
- /** Status and body text of a completed HTTP exchange. */
21
- internal data class HttpResponse(val status: Int, val body: String) {
20
+ /**
21
+ * Status and body text of a completed HTTP exchange. [retryAfter] is the raw `Retry-After` header value, if the server
22
+ * sent one.
23
+ */
24
+ internal data class HttpResponse(val status: Int, val body: String, val retryAfter: String? = null) {
22
25
  val isSuccessful: Boolean get() = status in 200..299
23
26
  }
24
27
 
@@ -28,20 +31,45 @@ internal object HttpSupport {
28
31
 
29
32
  const val CONTENT_TYPE = "Content-Type"
30
33
  const val AUTHORIZATION = "Authorization"
34
+ const val RETRY_AFTER = "Retry-After"
31
35
  const val JSON_CONTENT_TYPE = "application/json; charset=utf-8"
32
36
  const val FORM_CONTENT_TYPE = "application/x-www-form-urlencoded"
33
37
 
38
+ /** Most bytes of an upload answer that are read and passed on in the `http` event (16 KB). */
39
+ const val MAX_UPLOAD_RESPONSE_BYTES = 16_384L
40
+
41
+ /**
42
+ * The plugin's shared client (uploads, token refresh, `uploadLog`). It follows no redirects: OkHttp would send a
43
+ * redirected POST again as a GET without a body, and a 2xx answer to that GET would delete records the server never
44
+ * received. A 3xx is therefore an answer like any other non-2xx one. It also does not repeat a request on its own
45
+ * after a connection failure; the uploader decides about retries.
46
+ */
47
+ fun sharedClient(): OkHttpClient = OkHttpClient.Builder()
48
+ .followRedirects(false)
49
+ .followSslRedirects(false)
50
+ .retryOnConnectionFailure(false)
51
+ .build()
52
+
34
53
  /** Parses an http(s) URL (surrounding whitespace ignored); null if blank or invalid. */
35
54
  fun parseUrl(url: String?): HttpUrl? = url?.trim()?.takeIf { it.isNotEmpty() }?.toHttpUrlOrNull()
36
55
 
37
- /** A client derived from [base] (sharing its pool) whose call, read and write timeouts are [timeoutMs]. */
56
+ /**
57
+ * A client derived from [base] (sharing its pool) whose call, read and write timeouts are [timeoutMs] (unless
58
+ * [timeoutMs] <= 0). Whatever [base] is configured with, the derived client follows no redirects and does not
59
+ * repeat a request after a connection failure (see [sharedClient]).
60
+ */
38
61
  fun client(base: OkHttpClient, timeoutMs: Long): OkHttpClient {
39
- if (timeoutMs <= 0) return base
40
- return base.newBuilder()
41
- .callTimeout(timeoutMs, TimeUnit.MILLISECONDS)
42
- .readTimeout(timeoutMs, TimeUnit.MILLISECONDS)
43
- .writeTimeout(timeoutMs, TimeUnit.MILLISECONDS)
44
- .build()
62
+ val builder = base.newBuilder()
63
+ .followRedirects(false)
64
+ .followSslRedirects(false)
65
+ .retryOnConnectionFailure(false)
66
+ if (timeoutMs > 0) {
67
+ builder
68
+ .callTimeout(timeoutMs, TimeUnit.MILLISECONDS)
69
+ .readTimeout(timeoutMs, TimeUnit.MILLISECONDS)
70
+ .writeTimeout(timeoutMs, TimeUnit.MILLISECONDS)
71
+ }
72
+ return builder.build()
45
73
  }
46
74
 
47
75
  /** True if [headers] contains [name], ignoring case. */
@@ -86,10 +114,16 @@ internal object HttpSupport {
86
114
  /**
87
115
  * Executes [request] on [io]. Cancelling the coroutine cancels the call (OkHttp does not support thread
88
116
  * interrupts). A body that fails to read after the status arrived yields "", because the server already answered.
117
+ * With [maxBodyBytes], at most that many bytes of the body are read (`peekBody`); the rest is discarded.
89
118
  *
90
119
  * @throws IOException if the exchange fails before a status is received.
91
120
  */
92
- suspend fun execute(client: OkHttpClient, request: Request, io: CoroutineDispatcher): HttpResponse =
121
+ suspend fun execute(
122
+ client: OkHttpClient,
123
+ request: Request,
124
+ io: CoroutineDispatcher,
125
+ maxBodyBytes: Long? = null,
126
+ ): HttpResponse =
93
127
  withContext(io) {
94
128
  val call = client.newCall(request)
95
129
  val finished = AtomicBoolean(false)
@@ -103,12 +137,16 @@ internal object HttpSupport {
103
137
  try {
104
138
  call.execute().use { response ->
105
139
  val text = try {
106
- response.body?.string().orEmpty()
140
+ if (maxBodyBytes != null) {
141
+ response.peekBody(maxBodyBytes).string()
142
+ } else {
143
+ response.body?.string().orEmpty()
144
+ }
107
145
  } catch (e: IOException) {
108
146
  Logger.w(TAG, "could not read the response body of HTTP ${response.code}: ${e.message}")
109
147
  ""
110
148
  }
111
- HttpResponse(response.code, text)
149
+ HttpResponse(response.code, text, response.header(RETRY_AFTER))
112
150
  }
113
151
  } finally {
114
152
  finished.set(true)
@@ -15,4 +15,21 @@ interface HttpSyncer {
15
15
  * @throws com.brickssoft.locationtracking.core.TrackingException NO_URL, HTTP_ERROR or NETWORK_ERROR.
16
16
  */
17
17
  suspend fun sync(): List<Record>
18
+
19
+ /** Tracking stopped: make sure what is still queued is uploaded later, also if this process dies (upload job). */
20
+ fun onTrackingStopped() = Unit
21
+
22
+ /**
23
+ * The upload job runs: one automatic pass, and more while they succeed and leave records due. Returns when done.
24
+ */
25
+ suspend fun runScheduledPass() = Unit
26
+
27
+ /** A ticket for [awaitUpload]; take it before the record whose upload is awaited is queued. */
28
+ fun uploadTicket(): Long = 0L
29
+
30
+ /**
31
+ * Suspends until the first automatic pass started after [ticket] was taken has finished (uploaded, failed, or
32
+ * found nothing to send). Returns at once if no `http.url` is set. Callers bound the wait with a timeout.
33
+ */
34
+ suspend fun awaitUpload(ticket: Long) = Unit
18
35
  }