@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
@@ -24,7 +24,13 @@ import kotlinx.coroutines.channels.Channel
24
24
  import kotlinx.coroutines.currentCoroutineContext
25
25
  import kotlinx.coroutines.ensureActive
26
26
  import kotlinx.coroutines.delay
27
+ import kotlinx.coroutines.flow.MutableStateFlow
27
28
  import kotlinx.coroutines.flow.combine
29
+ import kotlinx.coroutines.flow.distinctUntilChanged
30
+ import kotlinx.coroutines.flow.drop
31
+ import kotlinx.coroutines.flow.first
32
+ import kotlinx.coroutines.flow.map
33
+ import kotlinx.coroutines.flow.update
28
34
  import kotlinx.coroutines.launch
29
35
  import kotlinx.coroutines.sync.Mutex
30
36
  import kotlinx.coroutines.sync.withLock
@@ -33,7 +39,9 @@ import okhttp3.HttpUrl
33
39
  import okhttp3.OkHttpClient
34
40
  import java.io.IOException
35
41
  import java.util.concurrent.atomic.AtomicBoolean
42
+ import java.util.concurrent.atomic.AtomicLong
36
43
  import java.util.concurrent.atomic.AtomicReference
44
+ import kotlin.random.Random
37
45
  import kotlin.coroutines.cancellation.CancellationException
38
46
 
39
47
  /**
@@ -49,10 +57,42 @@ import kotlin.coroutines.cancellation.CancellationException
49
57
  * stops at the first failure. If the server rejected an upload, an automatic pass still sends the queued priority
50
58
  * records (heartbeat and audit records), so one record the server keeps rejecting cannot hold them back.
51
59
  * - One upload makes up to 4 tries (owner decision R2-Q18): a try without an answer (connection error or timeout),
52
- * HTTP 5xx or HTTP 429 is tried again after 2, 4 and 8 s, while the upload lock is held. Any other answer ends the
53
- * upload; a 401 keeps its token refresh and one more request, once per upload.
60
+ * HTTP 5xx or HTTP 429 is tried again after about 2, 4 and 8 s (each wait moved by up to 20 % up or down), while the
61
+ * upload lock is held. A 429 or 503 whose `Retry-After` asks for a longer wait than the next retry ends the upload.
62
+ * Any other answer ends the upload; a 401 keeps its token refresh and one more request, once per upload. A 3xx is a
63
+ * failed upload like any other non-2xx answer: the client follows no redirects ([HttpSupport.sharedClient]).
64
+ * - Every try of one upload is sent with the credentials read at the start of the upload, or credentials refreshed
65
+ * from them ([AuthorizationManager]); if the app configured other tokens in the meantime, the upload stops and keeps
66
+ * its records (recommendation 2). No wait is stored and no attempt is counted. If `http.authorization` is still set,
67
+ * a new automatic pass runs [CHANGED_RETRY_MS] later with the new tokens (a coroutine timer, and the upload job for
68
+ * the case that this process dies); automatic passes before then upload nothing, so other triggers join that pass.
69
+ * If it was removed, nothing is started (README, "Logging out and switching users").
54
70
  * - 2xx deletes the records; a failure that ends the upload keeps them and marks one attempt. One `Http` event
55
- * is emitted per HTTP request (every try; a 401 that triggers a token refresh and a retry yields two).
71
+ * is emitted per HTTP request (every try; a 401 that triggers a token refresh and a retry yields two). At most
72
+ * [HttpSupport.MAX_UPLOAD_RESPONSE_BYTES] of an answer are read into the event.
73
+ * - HTTP 413 for a batch of more than one record halves the batch size, and the same records are sent again at once in
74
+ * smaller batches. The smaller size applies for the rest of the pass, and the next pass after a pass with a success
75
+ * uses `maxBatchSize` again. A 413 for a single record is a failure like other 4xx answers.
76
+ *
77
+ * **Stored wait between failed automatic uploads** (recommendations 4 and 11; [NextTry], [UploadBackoff]). After a
78
+ * failed automatic upload the next allowed time is stored (it survives a process restart):
79
+ * - reason `server` (the server answered with a non-2xx status other than an authorization failure, or did not
80
+ * answer): 30 s, doubling with each further failure up to 15 minutes, each moved by up to 20 % up or down; at least
81
+ * the `Retry-After` of a 429 or 503 (limited to `http.maxRetryAfter`); while the `syncInterval` rule applies, at least
82
+ * `syncInterval`. Cleared when the network returns.
83
+ * - reason `authorization` (HTTP 401 and the token could not be refreshed, or the stored tokens cannot be read):
84
+ * 15 minutes, ended at once when the app configures other tokens. Not cleared when the network returns.
85
+ * - Both are cleared by a successful upload. While the wait runs, normal records are not sent automatically; priority
86
+ * records and [sync] are not held back, as before. When the wait ends, the upload job (scheduled for that time)
87
+ * runs a pass; while the `syncInterval` rule applies, its timer does too.
88
+ *
89
+ * **Upload job** (recommendation 10, [UploadScheduler]). Queued records are sent whether tracking is on or off, also
90
+ * after this process died: after an automatic pass that could not send due records because the device was offline
91
+ * (or on cellular with `disableAutoSyncOnCellular`), the upload job is scheduled to run as soon as a (unmetered)
92
+ * network is available; after a failed automatic upload it is scheduled for the stored next-try time; when the queue
93
+ * becomes empty it is cancelled. [onTrackingStopped] schedules it at once as a safety net, and so does every automatic
94
+ * upload outside the job while tracking is off: such a process has no foreground service, so Android can freeze it
95
+ * (cached app freezer) or kill it during the upload and its retry waits.
56
96
  *
57
97
  * **`http.syncInterval` (seconds, 0 = off).** While tracking is on (`runtime.enabled`) with `syncInterval > 0` and
58
98
  * `autoSync`, normal records (every event that is not a priority record) are held until the oldest pending normal
@@ -68,11 +108,10 @@ import kotlin.coroutines.cancellation.CancellationException
68
108
  * record created while tracking is off (for example by `getCurrentPosition`) is not held without a timer.
69
109
  * - **Settings.** Once `syncInterval > 0` was seen in this process, a pass also runs when tracking is switched on or
70
110
  * off and when `url`, `autoSync`, `autoSyncThreshold`, `syncInterval` or `disableAutoSyncOnCellular` change.
71
- * - **Failures.** After a failed automatic upload (after its last try), the rule retries normal records once per
72
- * `syncInterval`
73
- * (measured on elapsed realtime) through the timer, not on every insert. Connectivity regained, a queued priority
74
- * record and [sync] still upload at once. Records that are due but could not be tried (offline, cellular
75
- * restricted) get no timer: connectivity regained triggers them.
111
+ * - **Failures.** After a failed automatic upload (after its last try), normal records wait for the stored next-try
112
+ * time (above), which is at least `syncInterval` while the rule applies; the timer then requests a pass. Connectivity
113
+ * regained, a queued priority record and [sync] still upload at once. Records that are due but could not be tried
114
+ * (offline, cellular restricted) get no timer: connectivity regained (or the upload job) triggers them.
76
115
  *
77
116
  * Worst-case staleness of the server's live location: about `syncInterval`. The timer is a coroutine `delay` and
78
117
  * holds no wake lock. On Android a `delay` counts only the time the CPU is awake (the monotonic clock stops in deep
@@ -95,6 +134,10 @@ class OkHttpSyncer(
95
134
  private val http: OkHttpClient,
96
135
  private val dispatchers: AppDispatchers,
97
136
  private val scope: CoroutineScope,
137
+ private val nextTryStore: NextTryStore = NextTryStore.inMemory(),
138
+ private val scheduler: UploadScheduler = UploadScheduler.NONE,
139
+ /** Uniform in [0, 1): the jitter of the retry waits. */
140
+ private val random: () -> Double = { Random.nextDouble() },
98
141
  ) : HttpSyncer {
99
142
  private val uploadLock = Mutex()
100
143
  private val triggers = Channel<Unit>(Channel.CONFLATED)
@@ -118,15 +161,38 @@ class OkHttpSyncer(
118
161
  /** What the armed [intervalTimer] waits for. */
119
162
  private var intervalTimerKey: TimerKey? = null
120
163
 
121
- /**
122
- * Elapsed-realtime ms before which the interval rule does not retry normal records after a failed automatic
123
- * upload; null when no retry is pending. Elapsed realtime, so a wall-clock change cannot stretch the wait.
124
- */
164
+ /** The stored wait after a failed automatic upload (see the class docs); loaded on first use. */
125
165
  @Volatile
126
- private var retryAtElapsed: Long? = null
166
+ private var nextTryValue: NextTry? = null
167
+
168
+ @Volatile
169
+ private var nextTryLoaded = false
170
+
171
+ /** The batch size after an HTTP 413 (see the class docs), or null for `maxBatchSize`. Guarded by [uploadLock]. */
172
+ private var reducedBatch: Int? = null
173
+
174
+ /** What the last automatic pass did; read by [runScheduledPass]. Guarded by [uploadLock]. */
175
+ private var lastPass = PassResult.NOTHING
176
+
177
+ /** Automatic passes started so far (see [uploadTicket]). */
178
+ private val passesStarted = AtomicLong(0)
179
+
180
+ /** The number of the newest finished automatic pass. */
181
+ private val passesFinished = MutableStateFlow(0L)
127
182
 
128
183
  private val stateWatcherLaunched = AtomicBoolean(false)
129
184
 
185
+ /** `elapsedRealtime` (deep sleep included) when the last upload request ended, or null. */
186
+ @Volatile
187
+ private var lastExchangeEnd: Long? = null
188
+
189
+ /** The timer of the pass after an upload stopped for new tokens; guarded by [uploadLock]. */
190
+ private var changedRetry: Job? = null
191
+
192
+ /** True until [changedRetry] runs: automatic passes upload nothing meanwhile. */
193
+ @Volatile
194
+ private var changedPause = false
195
+
130
196
  override fun start() {
131
197
  if (!started.compareAndSet(false, true)) return
132
198
  events.subscribe { event ->
@@ -139,9 +205,39 @@ class OkHttpSyncer(
139
205
  }
140
206
  }
141
207
  }
208
+ // New tokens from the app end an authorization wait at once.
209
+ scope.launch(dispatchers.io) {
210
+ configStore.config.map { authorization.current() }.distinctUntilChanged().drop(1).collect {
211
+ if (nextTry()?.reason == NextTry.Reason.AUTHORIZATION) requestPass()
212
+ }
213
+ }
142
214
  requestPass()
143
215
  }
144
216
 
217
+ override fun onTrackingStopped() {
218
+ if (!SyncPolicy.hasUrl(configStore.config.value.http)) return
219
+ // A safety net: the pass that the tracking_stop record started cancels the job again if it empties the queue.
220
+ scheduler.schedule(0L)
221
+ }
222
+
223
+ override suspend fun runScheduledPass() {
224
+ repeat(MAX_JOB_PASSES) {
225
+ val result = uploadLock.withLock {
226
+ autoPass(fromJob = true)
227
+ lastPass
228
+ }
229
+ // "After a success with records left, it runs again at once": records that arrived during the pass.
230
+ if (result != PassResult.SUCCESS || locationStore.count() == 0) return
231
+ }
232
+ }
233
+
234
+ override fun uploadTicket(): Long = passesStarted.get() + 1
235
+
236
+ override suspend fun awaitUpload(ticket: Long) {
237
+ if (HttpSupport.parseUrl(configStore.config.value.http.url) == null) return
238
+ passesFinished.first { it >= ticket }
239
+ }
240
+
145
241
  override fun onRecordInserted(record: Record) {
146
242
  if (!SyncPolicy.hasUrl(configStore.config.value.http)) return
147
243
  requestPass()
@@ -155,7 +251,12 @@ class OkHttpSyncer(
155
251
  }
156
252
  val result = uploadLock.withLock {
157
253
  val drained = drain(SyncPolicy.Scope.ALL)
158
- if (drained.failure == null) retryAtElapsed = null
254
+ if (drained.anySuccess) reducedBatch = null
255
+ if (drained.failure == null) {
256
+ setNextTry(null)
257
+ cancelJobIfEmpty()
258
+ }
259
+ if (drained.failure?.kind == FailureKind.CHANGED) retryAfterAuthorizationChange(configStore.config.value.http)
159
260
  updateIntervalTimer(null)
160
261
  drained
161
262
  }
@@ -166,6 +267,15 @@ class OkHttpSyncer(
166
267
  FailureKind.HTTP -> TrackingException(ErrorCode.HTTP_ERROR, "$message: HTTP ${r.status}")
167
268
  FailureKind.NETWORK -> TrackingException(ErrorCode.NETWORK_ERROR, "$message: ${r.responseText}")
168
269
  FailureKind.INTERNAL -> TrackingException(ErrorCode.INTERNAL, "$message: ${r.responseText}")
270
+ FailureKind.AUTH -> TrackingException(
271
+ ErrorCode.HTTP_ERROR,
272
+ "$message: authorization failed (${if (r.status > 0) "HTTP ${r.status}" else r.responseText})",
273
+ )
274
+ FailureKind.CHANGED -> TrackingException(
275
+ ErrorCode.HTTP_ERROR,
276
+ "upload of ${r.uuids.size} record(s) stopped: http.authorization changed during the upload; " +
277
+ "the records are kept",
278
+ )
169
279
  }
170
280
  }
171
281
  return result.uploaded
@@ -191,73 +301,255 @@ class OkHttpSyncer(
191
301
  triggers.trySend(Unit)
192
302
  }
193
303
 
194
- /** One automatic pass: the upload [SyncPolicy] allows, then the `syncInterval` timer for what stays queued. */
195
- private suspend fun autoPass() {
196
- val lookup = try {
197
- uploadPass()
198
- } catch (e: CancellationException) {
199
- throw e
200
- } catch (e: Exception) {
201
- Logger.e(TAG, "automatic upload failed", e)
202
- null
304
+ /**
305
+ * One automatic pass: the upload [SyncPolicy] allows, then the timer for what stays queued. Caller holds
306
+ * [uploadLock].
307
+ */
308
+ private suspend fun autoPass(fromJob: Boolean = false) {
309
+ val number = passesStarted.incrementAndGet()
310
+ try {
311
+ lastPass = PassResult.NOTHING
312
+ val lookup = try {
313
+ uploadPass(fromJob)
314
+ } catch (e: CancellationException) {
315
+ throw e
316
+ } catch (e: Exception) {
317
+ Logger.e(TAG, "automatic upload failed", e)
318
+ null
319
+ }
320
+ updateIntervalTimer(lookup)
321
+ } finally {
322
+ passesFinished.update { maxOf(it, number) }
203
323
  }
204
- updateIntervalTimer(lookup)
205
324
  }
206
325
 
207
326
  /**
208
327
  * Uploads what [SyncPolicy.autoScope] allows. Returns the oldest-normal-record lookup it made if it left the
209
328
  * queue unchanged, else null.
210
329
  */
211
- private suspend fun uploadPass(): OldestNormal? {
330
+ private suspend fun uploadPass(fromJob: Boolean): OldestNormal? {
331
+ if (changedPause) {
332
+ // Triggers during the pause after new tokens (the upload job's included) wait for the pass at its end.
333
+ Logger.d(TAG, "http.authorization changed: automatic uploads wait for the pass after the pause")
334
+ return null
335
+ }
212
336
  val hint = connectivityHint.getAndSet(null)
213
337
  // Connectivity regained since the last pass: retry at once. Cleared here, not in the event handler, so a pass
214
338
  // that fails while the event arrives cannot set the wait again after the handler cleared it.
215
- if (hint != null) retryAtElapsed = null
339
+ if (hint != null) clearServerWait("connectivity returned")
216
340
  val config = configStore.config.value.http
217
341
  if (!SyncPolicy.hasUrl(config)) return null
218
342
  if (HttpSupport.parseUrl(config.url) == null) {
219
343
  Logger.e(TAG, "http.url is not a valid http(s) URL; nothing uploaded")
220
344
  return null
221
345
  }
222
- val connectivity = currentConnectivity(hint)
223
- if (!connectivity.connected) {
224
- Logger.d(TAG, "offline; upload deferred until connectivity returns")
225
- return null
226
- }
227
346
  val priority = locationStore.count(SyncPolicy.PRIORITY_EVENTS)
228
347
  val queued = locationStore.count()
348
+ if (queued == 0) {
349
+ cancelJobIfEmpty(0)
350
+ return null
351
+ }
229
352
  val intervalOn = intervalApplies(config)
353
+ val waiting = isWaiting(config)
230
354
  // The interval rule needs the oldest pending normal record only when no priority record forces an upload.
231
355
  val lookup = if (intervalOn && priority == 0) lookUpOldestNormal() else null
232
356
  val check = when {
233
357
  !intervalOn -> SyncPolicy.IntervalCheck.OFF
234
- isRetryPending() -> SyncPolicy.IntervalCheck.RETRY_PENDING
235
358
  lookup?.record?.let { SyncPolicy.isIntervalDue(config, it.recordedAt, lookup.now) } == true ->
236
359
  SyncPolicy.IntervalCheck.DUE
237
360
  else -> SyncPolicy.IntervalCheck.NOT_DUE
238
361
  }
239
- val plan = SyncPolicy.autoScope(config, connectivity, queued, priority, check)
362
+ val connectivity = currentConnectivity(hint)
363
+ // What would be sent on an unrestricted network: records that are due although this pass cannot send them.
364
+ val dueOnline = SyncPolicy.autoScope(config, ONLINE, queued, priority, check, waiting)
365
+ if (!connectivity.connected) {
366
+ Logger.d(TAG, "offline; upload deferred until connectivity returns")
367
+ if (dueOnline != SyncPolicy.Scope.NONE) scheduler.schedule(0L)
368
+ return null
369
+ }
370
+ val plan = SyncPolicy.autoScope(config, connectivity, queued, priority, check, waiting)
371
+ if (SyncPolicy.isCellularRestricted(config, connectivity) && dueOnline == SyncPolicy.Scope.ALL &&
372
+ queued > priority
373
+ ) {
374
+ // Normal records wait for an unmetered network, in this process or the next one.
375
+ scheduler.schedule(0L, unmetered = true)
376
+ }
240
377
  if (plan == SyncPolicy.Scope.NONE) return lookup
241
- val failure = drain(plan).failure
242
- if (plan == SyncPolicy.Scope.ALL && priority > 0 && failure != null && failure.kind != FailureKind.NETWORK) {
378
+ if (!fromJob && !configStore.runtime.value.enabled) {
379
+ // Without a tracking session nothing keeps this process running: Android 11+ freezes a cached process
380
+ // (seen on API 34 during the retry waits) and may kill it. The job runs as soon as possible, waits for the
381
+ // upload lock and keeps the process active; if this process dies, it finishes the upload.
382
+ scheduler.schedule(0L)
383
+ }
384
+ val drained = drain(plan)
385
+ var anySuccess = drained.anySuccess
386
+ val failure = drained.failure
387
+ if (plan == SyncPolicy.Scope.ALL && priority > 0 && failure != null &&
388
+ (failure.kind == FailureKind.HTTP || failure.kind == FailureKind.INTERNAL)
389
+ ) {
243
390
  // The server is reachable but rejected an older upload: still deliver the audit records behind it.
244
391
  // Priority records that just failed on their own are not re-sent in this pass.
245
392
  val skip = if (failure.records.all { it.event.isPriority }) failure.result.uuids.toSet() else emptySet()
246
- drain(SyncPolicy.Scope.PRIORITY_ONLY, skip)
393
+ anySuccess = drain(SyncPolicy.Scope.PRIORITY_ONLY, skip).anySuccess || anySuccess
247
394
  }
248
- if (failure != null && intervalOn) {
249
- retryAtElapsed = clock.elapsedRealtime() + SyncPolicy.intervalMs(config)
250
- } else if (failure == null && plan == SyncPolicy.Scope.ALL) {
251
- retryAtElapsed = null
395
+ if (anySuccess) reducedBatch = null
396
+ when {
397
+ failure == null -> {
398
+ lastPass = PassResult.SUCCESS
399
+ if (plan == SyncPolicy.Scope.ALL) setNextTry(null)
400
+ cancelJobIfEmpty()
401
+ }
402
+ failure.kind == FailureKind.CHANGED -> {
403
+ // Not a failure of the server, so no wait and no attempt.
404
+ lastPass = PassResult.FAILED
405
+ retryAfterAuthorizationChange(config)
406
+ }
407
+ else -> {
408
+ lastPass = PassResult.FAILED
409
+ storeWait(failure, config, intervalOn)
410
+ }
252
411
  }
253
412
  return null
254
413
  }
255
414
 
415
+ /**
416
+ * After an upload stopped because the app changed `http.authorization`: a new automatic pass [CHANGED_RETRY_MS]
417
+ * later, if the authorization is still set. Caller holds [uploadLock].
418
+ */
419
+ private fun retryAfterAuthorizationChange(config: HttpConfig) {
420
+ if (authorization.current() == null) {
421
+ Logger.i(TAG, "http.authorization was removed during the upload; no new upload is started")
422
+ return
423
+ }
424
+ Logger.i(TAG, "http.authorization changed during the upload; next upload in ${CHANGED_RETRY_MS / MS_PER_SECOND} s")
425
+ changedPause = true
426
+ changedRetry?.cancel()
427
+ changedRetry = scope.launch(dispatchers.io) {
428
+ delay(CHANGED_RETRY_MS)
429
+ changedPause = false
430
+ requestPass()
431
+ }
432
+ // The job replaces the waiting one; a longer stored wait keeps its time, as normal records wait for it anyway.
433
+ scheduler.schedule(maxOf(CHANGED_RETRY_MS, waitRemainingMs(config)))
434
+ }
435
+
256
436
  /** True if normal records follow `syncInterval` now: configured ([SyncPolicy.usesInterval]) and tracking is on. */
257
437
  private fun intervalApplies(config: HttpConfig): Boolean =
258
438
  SyncPolicy.usesInterval(config) && configStore.runtime.value.enabled
259
439
 
260
- private fun isRetryPending(): Boolean = retryAtElapsed?.let { clock.elapsedRealtime() < it } ?: false
440
+ // ---- stored wait (NextTry)
441
+
442
+ private fun nextTry(): NextTry? {
443
+ if (!nextTryLoaded) {
444
+ nextTryValue = nextTryStore.load()
445
+ nextTryLoaded = true
446
+ }
447
+ return nextTryValue
448
+ }
449
+
450
+ private fun setNextTry(value: NextTry?) {
451
+ if (value == null && nextTry() == null) return
452
+ nextTryValue = value
453
+ nextTryLoaded = true
454
+ nextTryStore.save(value)
455
+ }
456
+
457
+ /**
458
+ * True while the stored wait runs. An authorization wait ends when the configured tokens are not the rejected ones
459
+ * any more. A wait longer than any wait the rules can produce (the wall clock was set back across a reboot) is
460
+ * shortened to that longest wait.
461
+ */
462
+ private fun isWaiting(config: HttpConfig): Boolean {
463
+ val wait = nextTry() ?: return false
464
+ if (wait.reason == NextTry.Reason.AUTHORIZATION && currentAuthKey() != wait.authKey) {
465
+ Logger.i(TAG, "the authorization changed: automatic uploads resume")
466
+ setNextTry(null)
467
+ return false
468
+ }
469
+ val remaining = wait.remainingMs(clock)
470
+ if (remaining <= 0) return false
471
+ val longest = longestWaitMs(config)
472
+ if (remaining > longest) {
473
+ setNextTry(
474
+ wait.copy(
475
+ atElapsed = clock.elapsedRealtime() + longest,
476
+ bootCount = clock.bootCount(),
477
+ atWall = clock.now() + longest,
478
+ ),
479
+ )
480
+ }
481
+ return true
482
+ }
483
+
484
+ /** Remaining ms of the stored wait (0 if none or due). */
485
+ private fun waitRemainingMs(config: HttpConfig): Long =
486
+ if (isWaiting(config)) nextTry()?.remainingMs(clock)?.coerceAtLeast(0L) ?: 0L else 0L
487
+
488
+ private fun longestWaitMs(config: HttpConfig): Long = maxOf(
489
+ UploadBackoff.MAX_WAIT_MS + UploadBackoff.MAX_WAIT_MS / 4,
490
+ UploadBackoff.AUTH_WAIT_MS,
491
+ config.maxRetryAfter * MS_PER_SECOND,
492
+ SyncPolicy.intervalMs(config) * 2,
493
+ )
494
+
495
+ private fun clearServerWait(why: String) {
496
+ val wait = nextTry() ?: return
497
+ if (wait.reason != NextTry.Reason.SERVER) return
498
+ Logger.d(TAG, "$why: the wait before the next automatic upload ends")
499
+ setNextTry(null)
500
+ }
501
+
502
+ /** Stores the wait after the failed automatic upload [failure] and schedules the upload job for its end. */
503
+ private fun storeWait(failure: Failure, config: HttpConfig, intervalOn: Boolean) {
504
+ val previous = nextTry()
505
+ val wait = if (failure.kind == FailureKind.AUTH) {
506
+ NextTry(
507
+ reason = NextTry.Reason.AUTHORIZATION,
508
+ failures = 1,
509
+ atElapsed = clock.elapsedRealtime() + UploadBackoff.AUTH_WAIT_MS,
510
+ bootCount = clock.bootCount(),
511
+ atWall = clock.now() + UploadBackoff.AUTH_WAIT_MS,
512
+ authKey = failure.credentials?.let { UploadBackoff.authKey(it.accessToken, it.refreshToken) }
513
+ ?: currentAuthKey(),
514
+ )
515
+ } else {
516
+ val failures = (previous?.takeIf { it.reason == NextTry.Reason.SERVER }?.failures ?: 0) + 1
517
+ val status = failure.result.status
518
+ val retryAfter = if (status == HTTP_TOO_MANY_REQUESTS || status == HTTP_SERVICE_UNAVAILABLE) {
519
+ UploadBackoff.retryAfterMs(failure.retryAfter, clock.now(), config.maxRetryAfter)
520
+ } else {
521
+ null
522
+ }
523
+ val minimum = if (intervalOn) SyncPolicy.intervalMs(config) else 0L
524
+ val ms = UploadBackoff.serverWaitMs(failures, random(), retryAfter, minimum)
525
+ NextTry(
526
+ reason = NextTry.Reason.SERVER,
527
+ failures = failures,
528
+ atElapsed = clock.elapsedRealtime() + ms,
529
+ bootCount = clock.bootCount(),
530
+ atWall = clock.now() + ms,
531
+ )
532
+ }
533
+ setNextTry(wait)
534
+ val ms = wait.remainingMs(clock).coerceAtLeast(0L)
535
+ Logger.i(TAG, "next automatic upload of normal records in ${ms / MS_PER_SECOND} s (${wait.reason.wire} wait)")
536
+ scheduler.schedule(ms)
537
+ }
538
+
539
+ private fun currentAuthKey(): String? =
540
+ authorization.current()?.let { UploadBackoff.authKey(it.accessToken, it.refreshToken) }
541
+
542
+ /** Cancels the upload job when the queue is empty ([queued]: the count, if already known). */
543
+ private suspend fun cancelJobIfEmpty(queued: Int? = null) {
544
+ val count = queued ?: try {
545
+ locationStore.count()
546
+ } catch (e: CancellationException) {
547
+ throw e
548
+ } catch (e: Exception) {
549
+ return
550
+ }
551
+ if (count == 0) scheduler.cancel()
552
+ }
261
553
 
262
554
  private suspend fun lookUpOldestNormal(): OldestNormal =
263
555
  OldestNormal(locationStore.list(1, SyncPolicy.NORMAL_EVENTS).firstOrNull(), clock.now())
@@ -271,10 +563,18 @@ class OkHttpSyncer(
271
563
  try {
272
564
  val config = configStore.config.value.http
273
565
  if (config.syncInterval > 0) launchStateWatcher()
274
- if (!intervalApplies(config) || HttpSupport.parseUrl(config.url) == null) {
566
+ if (HttpSupport.parseUrl(config.url) == null) {
567
+ cancelIntervalTimer()
568
+ return
569
+ }
570
+ if (!intervalApplies(config)) {
571
+ // Without the interval rule, the end of a stored wait is handled by the upload job (scheduled for it
572
+ // when the upload failed), the next record, connectivity regained or a priority record.
275
573
  cancelIntervalTimer()
276
574
  return
277
575
  }
576
+ val untilRetry = waitRemainingMs(config)
577
+ val retryAt = if (untilRetry > 0) clock.elapsedRealtime() + untilRetry else null
278
578
  val lookup = known ?: lookUpOldestNormal()
279
579
  val oldest = lookup.record
280
580
  if (oldest == null) {
@@ -282,7 +582,6 @@ class OkHttpSyncer(
282
582
  return
283
583
  }
284
584
  val dueAt = SyncPolicy.intervalDueAt(config, oldest.recordedAt, lookup.now)
285
- val retryAt = retryAtElapsed?.takeIf { it > clock.elapsedRealtime() }
286
585
  if (dueAt == null && retryAt == null) {
287
586
  // Due and not waiting for a retry: this pass uploaded it, or could not try (offline, cellular
288
587
  // restricted). The next record, connectivity or start() triggers the next attempt.
@@ -290,8 +589,8 @@ class OkHttpSyncer(
290
589
  return
291
590
  }
292
591
  val untilDue = dueAt?.let { it - clock.now() } ?: 0L
293
- val untilRetry = retryAt?.let { it - clock.elapsedRealtime() } ?: 0L
294
- armIntervalTimer(TimerKey(dueAt, retryAt), maxOf(untilDue, untilRetry, 0L), config.syncInterval)
592
+ val key = TimerKey(dueAt, if (retryAt != null) nextTryKey() else null)
593
+ armIntervalTimer(key, maxOf(untilDue, untilRetry, 0L), config.syncInterval)
295
594
  } catch (e: CancellationException) {
296
595
  throw e
297
596
  } catch (e: Exception) {
@@ -307,7 +606,7 @@ class OkHttpSyncer(
307
606
  current?.cancel()
308
607
  intervalTimerKey = key
309
608
  val due = key.dueAt?.let { Iso8601.format(it) } ?: "now"
310
- val retry = if (key.retryAtElapsed != null) ", retry after a failed upload" else ""
609
+ val retry = if (key.retry != null) ", retry after a failed upload" else ""
311
610
  Logger.d(TAG, "$TIMER_ARMED in ${delayMs / MS_PER_SECOND} s (syncInterval $syncInterval s, due $due$retry)")
312
611
  intervalTimer = scope.launch(dispatchers.io) {
313
612
  delay(delayMs)
@@ -323,6 +622,9 @@ class OkHttpSyncer(
323
622
  }
324
623
  }
325
624
 
625
+ /** Identifies the stored wait for [TimerKey] (stable while it does not change). */
626
+ private fun nextTryKey(): Long? = nextTry()?.let { it.atWall xor (it.atElapsed shl 1) }
627
+
326
628
  private fun cancelIntervalTimer() {
327
629
  synchronized(timerLock) {
328
630
  intervalTimer?.cancel()
@@ -374,13 +676,22 @@ class OkHttpSyncer(
374
676
  while (true) {
375
677
  val config = configStore.config.value.http
376
678
  val url = HttpSupport.parseUrl(config.url) ?: break
377
- val limit = SyncPolicy.chunkSize(config)
679
+ val limit = minOf(SyncPolicy.chunkSize(config), reducedBatch ?: Int.MAX_VALUE)
378
680
  // Over-fetch by the skipped count so skipped records cannot hide the rest of the queue.
379
681
  val chunk = locationStore.list(limit + skip.size, filter).filter { it.uuid !in attempted }.take(limit)
380
682
  if (chunk.isEmpty()) break
381
683
  chunk.mapTo(attempted) { it.uuid }
382
684
  val failure = upload(url, config, chunk)
383
- if (failure != null) return DrainResult(uploaded, failure)
685
+ if (failure != null) {
686
+ if (failure.isPayloadTooLarge && chunk.size > 1) {
687
+ // The same records again at once, in smaller batches.
688
+ reducedBatch = chunk.size / 2
689
+ chunk.forEach { attempted.remove(it.uuid) }
690
+ Logger.w(TAG, "HTTP 413 for ${chunk.size} records: sending batches of $reducedBatch")
691
+ continue
692
+ }
693
+ return DrainResult(uploaded, failure)
694
+ }
384
695
  uploaded += chunk
385
696
  }
386
697
  return DrainResult(uploaded, null)
@@ -394,67 +705,114 @@ class OkHttpSyncer(
394
705
  private suspend fun upload(url: HttpUrl, config: HttpConfig, records: List<Record>): Failure? =
395
706
  withContext(dispatchers.io) {
396
707
  val uuids = records.map { it.uuid }
397
- var outcome = tryUpload(url, config, records, uuids, mayRefresh = true)
708
+ // Read once: every try of this upload uses these credentials or ones refreshed from them.
709
+ var credentials = if (authorization.appliesTo(config)) authorization.current() else null
710
+ var outcome = tryUpload(url, config, records, uuids, credentials, mayRefresh = true)
711
+ credentials = outcome.credentials ?: credentials
398
712
  var refreshed = outcome.refreshed
399
- for (wait in RETRY_DELAYS_MS) {
713
+ for (index in UploadBackoff.RETRY_DELAYS_MS.indices) {
400
714
  if (!outcome.retryable) break
715
+ val wait = UploadBackoff.retryDelayMs(index, random())
716
+ val retryAfter = UploadBackoff.retryAfterMs(outcome.retryAfter, clock.now(), config.maxRetryAfter)
717
+ if (retryAfter != null && retryAfter > wait) {
718
+ Logger.w(TAG, "upload of ${uuids.size} record(s) failed (${outcome.reason}); Retry-After asks for ${retryAfter / MS_PER_SECOND} s")
719
+ break
720
+ }
401
721
  if (!outcome.reported) events.emit(TrackingEvent.Http(outcome.result))
402
722
  Logger.w(TAG, "upload of ${uuids.size} record(s) failed (${outcome.reason}); next try in ${wait / MS_PER_SECOND} s")
403
723
  delay(wait)
404
724
  // At most one token refresh per upload, whether before a request or after a 401.
405
- outcome = tryUpload(url, config, records, uuids, mayRefresh = !refreshed)
725
+ outcome = tryUpload(url, config, records, uuids, credentials, mayRefresh = !refreshed)
726
+ credentials = outcome.credentials ?: credentials
406
727
  refreshed = refreshed || outcome.refreshed
407
728
  }
408
- if (outcome.kind == null) {
409
- locationStore.delete(uuids)
410
- Logger.i(TAG, "uploaded ${uuids.size} record(s): HTTP ${outcome.result.status}")
411
- } else {
412
- locationStore.markAttempt(uuids, clock.now())
413
- Logger.w(TAG, "upload of ${uuids.size} record(s) failed (${outcome.reason}); kept in the queue")
729
+ val tooLarge = outcome.result.status == HTTP_PAYLOAD_TOO_LARGE && records.size > 1
730
+ when {
731
+ outcome.kind == null -> {
732
+ locationStore.delete(uuids)
733
+ Logger.i(TAG, "uploaded ${uuids.size} record(s): HTTP ${outcome.result.status}")
734
+ }
735
+ outcome.kind == FailureKind.CHANGED ->
736
+ Logger.w(TAG, "upload of ${uuids.size} record(s) stopped: http.authorization changed; kept in the queue")
737
+ tooLarge -> Unit // sent again at once in smaller batches
738
+ else -> {
739
+ locationStore.markAttempt(uuids, clock.now())
740
+ Logger.w(TAG, "upload of ${uuids.size} record(s) failed (${outcome.reason}); kept in the queue")
741
+ }
414
742
  }
415
- if (!outcome.reported) events.emit(TrackingEvent.Http(outcome.result))
416
- outcome.kind?.let { Failure(records, outcome.result, it) }
743
+ if (!outcome.reported && outcome.sent) events.emit(TrackingEvent.Http(outcome.result))
744
+ outcome.kind?.let { Failure(records, outcome.result, it, outcome.retryAfter, credentials) }
417
745
  }
418
746
 
419
747
  /**
420
- * One try of an upload: pre-emptive token refresh, request, and on a 401 a token refresh and one more request; no
421
- * refresh at all unless [mayRefresh]. Emits the Http event of every request but the last one (see [Try.reported]).
748
+ * One try of an upload with [credentials] (null: no bearer token): pre-emptive token refresh, request, and on a 401
749
+ * a token refresh and one more request; no refresh at all unless [mayRefresh]. Emits the Http event of every request
750
+ * but the last one (see [Try.reported]). Stops before sending when the configured tokens are no longer
751
+ * [credentials] ([FailureKind.CHANGED]), and does not send when the stored tokens cannot be read
752
+ * ([FailureKind.AUTH]).
422
753
  */
423
754
  private suspend fun tryUpload(
424
755
  url: HttpUrl,
425
756
  config: HttpConfig,
426
757
  records: List<Record>,
427
758
  uuids: List<String>,
759
+ credentials: AuthorizationManager.Credentials?,
428
760
  mayRefresh: Boolean,
429
761
  ): Try {
430
762
  var reported = false
431
763
  var refreshed = false
764
+ var used = credentials
432
765
  return try {
433
766
  val client = clientFor(config.timeout)
434
- val bearer = authorization.appliesTo(config)
435
- val token = if (bearer) authorization.tokenForRequest(client, mayRefresh) else NO_TOKEN
436
- refreshed = token.refreshAttempted
437
- var response = send(client, url, config, records, token.value)
438
- if (response.status == HTTP_UNAUTHORIZED && bearer && mayRefresh && !token.refreshAttempted) {
439
- events.emit(TrackingEvent.Http(HttpResult(false, response.status, response.body, uuids)))
440
- reported = true
441
- refreshed = true
442
- val renewed = authorization.refresh(client, token.value)
443
- if (renewed != null) {
444
- reported = false // the retry is a new request with its own event, even if it throws
445
- response = send(client, url, config, records, renewed)
767
+ if (used != null) {
768
+ if (configStore.secretsUnavailable) {
769
+ val error = "the stored tokens could not be decrypted; set http.authorization again"
770
+ Logger.e(TAG, "$error; nothing uploaded")
771
+ authorization.reportUnusable(error)
772
+ return Try(HttpResult(false, 0, error, uuids), FailureKind.AUTH, false, false, used, sent = false)
773
+ }
774
+ val prepared = authorization.prepare(client, used, mayRefresh)
775
+ refreshed = prepared.refreshAttempted
776
+ used = prepared.credentials
777
+ if (prepared.outcome == AuthorizationManager.Outcome.CHANGED) {
778
+ return Try(HttpResult(false, 0, AUTH_CHANGED, uuids), FailureKind.CHANGED, false, refreshed, used, sent = false)
779
+ }
780
+ }
781
+ var response = send(client, url, config, records, used?.accessToken)
782
+ var kind: FailureKind? = if (response.isSuccessful) null else FailureKind.HTTP
783
+ if (response.status == HTTP_UNAUTHORIZED && used != null) {
784
+ kind = FailureKind.AUTH
785
+ if (mayRefresh && !refreshed) {
786
+ events.emit(TrackingEvent.Http(HttpResult(false, response.status, response.body, uuids)))
787
+ reported = true
788
+ refreshed = true
789
+ val renewed = authorization.renew(client, used)
790
+ when (renewed.outcome) {
791
+ AuthorizationManager.Outcome.RENEWED -> {
792
+ used = renewed.credentials
793
+ reported = false // the retry is a new request with its own event, even if it throws
794
+ response = send(client, url, config, records, used.accessToken)
795
+ kind = when {
796
+ response.isSuccessful -> null
797
+ response.status == HTTP_UNAUTHORIZED -> FailureKind.AUTH
798
+ else -> FailureKind.HTTP
799
+ }
800
+ }
801
+ AuthorizationManager.Outcome.CHANGED -> kind = FailureKind.CHANGED
802
+ else -> Unit // FAILED: the 401 stands, an authorization failure
803
+ }
446
804
  }
447
805
  }
448
806
  val result = HttpResult(response.isSuccessful, response.status, response.body, uuids)
449
- Try(result, if (result.success) null else FailureKind.HTTP, reported, refreshed)
807
+ Try(result, kind, reported, refreshed, used, retryAfter = response.retryAfter)
450
808
  } catch (e: IOException) {
451
809
  currentCoroutineContext().ensureActive() // a call cancelled with the coroutine is not an upload failure
452
- Try(HttpResult(false, 0, e.message ?: e.javaClass.simpleName, uuids), FailureKind.NETWORK, reported, refreshed)
810
+ Try(HttpResult(false, 0, e.message ?: e.javaClass.simpleName, uuids), FailureKind.NETWORK, reported, refreshed, used)
453
811
  } catch (e: CancellationException) {
454
812
  throw e
455
813
  } catch (e: Exception) {
456
814
  Logger.e(TAG, "upload of ${uuids.size} record(s) failed unexpectedly", e)
457
- Try(HttpResult(false, 0, e.message ?: e.javaClass.simpleName, uuids), FailureKind.INTERNAL, reported, refreshed)
815
+ Try(HttpResult(false, 0, e.message ?: e.javaClass.simpleName, uuids), FailureKind.INTERNAL, reported, refreshed, used)
458
816
  }
459
817
  }
460
818
 
@@ -467,7 +825,19 @@ class OkHttpSyncer(
467
825
  ): HttpResponse {
468
826
  val body = BodyBuilder.build(records, config, clock.now())
469
827
  val headers = HttpSupport.headers(HttpSupport.JSON_CONTENT_TYPE, config.headers, token)
470
- return HttpSupport.execute(client, HttpSupport.request(url, config.method.wire, headers, body), dispatchers.io)
828
+ val request = HttpSupport.request(url, config.method.wire, headers, body)
829
+ // The client does not repeat a request after a connection failure (HttpSupport.sharedClient), so a pooled
830
+ // connection the server has closed would fail the try. OkHttp checks a pooled connection only after 10 s idle,
831
+ // on a clock that stops in deep sleep; many servers close idle connections after 5 s (Node.js, Apache).
832
+ val idleSince = lastExchangeEnd
833
+ if (idleSince != null && clock.elapsedRealtime() - idleSince > POOLED_CONNECTION_MAX_IDLE_MS) {
834
+ client.connectionPool.evictAll()
835
+ }
836
+ try {
837
+ return HttpSupport.execute(client, request, dispatchers.io, HttpSupport.MAX_UPLOAD_RESPONSE_BYTES)
838
+ } finally {
839
+ lastExchangeEnd = clock.elapsedRealtime()
840
+ }
471
841
  }
472
842
 
473
843
  private fun clientFor(timeoutMs: Long): OkHttpClient {
@@ -484,34 +854,67 @@ class OkHttpSyncer(
484
854
 
485
855
  /** The upload could not be attempted (e.g. the body could not be built). */
486
856
  INTERNAL,
857
+
858
+ /** HTTP 401 and no refreshed token was accepted, or the stored tokens cannot be read. */
859
+ AUTH,
860
+
861
+ /** The configured tokens changed during the upload: stopped, records kept, no attempt counted. */
862
+ CHANGED,
487
863
  }
488
864
 
865
+ /** What the last automatic pass did. */
866
+ private enum class PassResult { NOTHING, SUCCESS, FAILED }
867
+
489
868
  /**
490
869
  * The outcome of one [tryUpload]: [kind] null on success. [reported] means the Http event for [result] was
491
- * already emitted (a 401 whose token refresh failed); [refreshed] means the try attempted a token refresh.
870
+ * already emitted (a 401 whose token refresh failed); [refreshed] means the try attempted a token refresh;
871
+ * [credentials] are the ones the try ended with; [sent] is false when no request was made.
492
872
  */
493
- private class Try(val result: HttpResult, val kind: FailureKind?, val reported: Boolean, val refreshed: Boolean) {
873
+ private class Try(
874
+ val result: HttpResult,
875
+ val kind: FailureKind?,
876
+ val reported: Boolean,
877
+ val refreshed: Boolean,
878
+ val credentials: AuthorizationManager.Credentials?,
879
+ val retryAfter: String? = null,
880
+ val sent: Boolean = true,
881
+ ) {
494
882
  /** True for a request without an answer, HTTP 5xx or HTTP 429 (R2-Q18). */
495
883
  val retryable: Boolean
496
884
  get() = kind == FailureKind.NETWORK ||
497
885
  (kind == FailureKind.HTTP && (result.status in HTTP_SERVER_ERRORS || result.status == HTTP_TOO_MANY_REQUESTS))
498
886
 
499
- val reason: String get() = if (kind == FailureKind.HTTP) "HTTP ${result.status}" else result.responseText
887
+ val reason: String
888
+ get() = if (kind == FailureKind.HTTP || kind == FailureKind.AUTH && result.status > 0) {
889
+ "HTTP ${result.status}"
890
+ } else {
891
+ result.responseText
892
+ }
500
893
  }
501
894
 
502
- /** A failed upload of [records]. */
503
- private class Failure(val records: List<Record>, val result: HttpResult, val kind: FailureKind)
895
+ /** A failed upload of [records]; [credentials] are the ones it was sent with (for the authorization wait). */
896
+ private class Failure(
897
+ val records: List<Record>,
898
+ val result: HttpResult,
899
+ val kind: FailureKind,
900
+ val retryAfter: String?,
901
+ val credentials: AuthorizationManager.Credentials?,
902
+ ) {
903
+ val isPayloadTooLarge: Boolean get() = kind == FailureKind.HTTP && result.status == HTTP_PAYLOAD_TOO_LARGE
904
+ }
504
905
 
505
- private class DrainResult(val uploaded: List<Record>, val failure: Failure?)
906
+ private class DrainResult(val uploaded: List<Record>, val failure: Failure?) {
907
+ val anySuccess: Boolean get() = uploaded.isNotEmpty()
908
+ }
506
909
 
507
910
  /** The oldest pending normal record ([record], null if none) as looked up at wall time [now]. */
508
911
  private class OldestNormal(val record: Record?, val now: Long)
509
912
 
510
913
  /**
511
- * What an armed `syncInterval` timer waits for: [dueAt] (wall ms) when the oldest pending normal record becomes
512
- * due, null if it is due already; [retryAtElapsed] (elapsed-realtime ms) for a retry after a failed upload.
914
+ * What an armed timer waits for: [dueAt] (wall ms) when the oldest pending normal record becomes due, null if it is
915
+ * due already or the `syncInterval` rule does not apply; [retry] identifies the stored wait after a failed upload.
513
916
  */
514
- private data class TimerKey(val dueAt: Long?, val retryAtElapsed: Long?)
917
+ private data class TimerKey(val dueAt: Long?, val retry: Long?)
515
918
 
516
919
  /** The settings and runtime state whose change re-evaluates the `syncInterval` rule. */
517
920
  private data class WatchedState(
@@ -534,18 +937,30 @@ class OkHttpSyncer(
534
937
  }
535
938
  }
536
939
 
537
- private companion object {
538
- const val TAG = "LT.Http"
940
+ internal companion object {
941
+ private const val TAG = "LT.Http"
539
942
  const val HTTP_UNAUTHORIZED = 401
943
+ const val HTTP_PAYLOAD_TOO_LARGE = 413
540
944
  const val HTTP_TOO_MANY_REQUESTS = 429
945
+ const val HTTP_SERVICE_UNAVAILABLE = 503
541
946
  val HTTP_SERVER_ERRORS = 500..599
542
-
543
- /** Waits before the retries of one upload (R2-Q18): 3 retries after 2, 4 and 8 s. */
544
- val RETRY_DELAYS_MS = longArrayOf(2_000L, 4_000L, 8_000L)
545
947
  const val MS_PER_SECOND = 1_000L
546
948
 
949
+ /** Passes one upload job runs at most while they succeed and leave records due. */
950
+ const val MAX_JOB_PASSES = 5
951
+
952
+ const val AUTH_CHANGED = "http.authorization changed during the upload"
953
+
954
+ /** Pooled connections idle longer than this are closed before an upload request (see [send]). */
955
+ const val POOLED_CONNECTION_MAX_IDLE_MS = 4_000L
956
+
957
+ /** The wait before the pass that follows an upload stopped for new tokens (rule 2a). */
958
+ const val CHANGED_RETRY_MS = 10_000L
959
+
960
+ /** An unrestricted network, to ask the policy what is due whatever the network is. */
961
+ val ONLINE = Connectivity(connected = true, type = ConnectivityType.WIFI)
962
+
547
963
  /** Start of the debug log line written when the `syncInterval` timer is armed (tests look for it). */
548
964
  const val TIMER_ARMED = "syncInterval check armed"
549
- val NO_TOKEN = AuthorizationManager.Token(null, refreshAttempted = false)
550
965
  }
551
966
  }