@bricks-soft/capacitor-location-tracking 8.0.1 → 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.
- package/README.md +375 -124
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/java/com/brickssoft/locationtracking/bridge/OptionParsers.kt +4 -1
- package/android/src/main/java/com/brickssoft/locationtracking/bridge/PluginHandlers.kt +14 -5
- package/android/src/main/java/com/brickssoft/locationtracking/config/Config.kt +10 -0
- package/android/src/main/java/com/brickssoft/locationtracking/config/ConfigJson.kt +70 -15
- package/android/src/main/java/com/brickssoft/locationtracking/config/ConfigStore.kt +6 -0
- package/android/src/main/java/com/brickssoft/locationtracking/config/ConfigValidator.kt +8 -3
- package/android/src/main/java/com/brickssoft/locationtracking/config/RuntimeStateJson.kt +10 -0
- package/android/src/main/java/com/brickssoft/locationtracking/config/SecretStore.kt +188 -0
- package/android/src/main/java/com/brickssoft/locationtracking/config/SharedPrefsConfigStore.kt +188 -17
- package/android/src/main/java/com/brickssoft/locationtracking/config/State.kt +11 -0
- package/android/src/main/java/com/brickssoft/locationtracking/core/Components.kt +21 -5
- package/android/src/main/java/com/brickssoft/locationtracking/core/ForceStopProbe.kt +20 -9
- package/android/src/main/java/com/brickssoft/locationtracking/core/TrackingEvent.kt +24 -2
- package/android/src/main/java/com/brickssoft/locationtracking/data/TrackingDatabase.kt +43 -1
- package/android/src/main/java/com/brickssoft/locationtracking/device/DefaultDeviceInfoProvider.kt +17 -0
- package/android/src/main/java/com/brickssoft/locationtracking/device/DeviceDetails.kt +167 -0
- package/android/src/main/java/com/brickssoft/locationtracking/engine/DefaultTrackingEngine.kt +191 -34
- package/android/src/main/java/com/brickssoft/locationtracking/engine/TrackingEngine.kt +6 -0
- package/android/src/main/java/com/brickssoft/locationtracking/engine/TrackingTimers.kt +3 -0
- package/android/src/main/java/com/brickssoft/locationtracking/heartbeat/DefaultHeartbeatScheduler.kt +52 -8
- package/android/src/main/java/com/brickssoft/locationtracking/heartbeat/HeartbeatAlarmReceiver.kt +22 -0
- package/android/src/main/java/com/brickssoft/locationtracking/http/AuthorizationManager.kt +78 -27
- package/android/src/main/java/com/brickssoft/locationtracking/http/ConfigChecks.kt +140 -0
- package/android/src/main/java/com/brickssoft/locationtracking/http/HttpSupport.kt +50 -12
- package/android/src/main/java/com/brickssoft/locationtracking/http/HttpSyncer.kt +17 -0
- package/android/src/main/java/com/brickssoft/locationtracking/http/OkHttpSyncer.kt +507 -92
- package/android/src/main/java/com/brickssoft/locationtracking/http/SyncPolicy.kt +6 -6
- package/android/src/main/java/com/brickssoft/locationtracking/http/TemplateRenderer.kt +23 -1
- package/android/src/main/java/com/brickssoft/locationtracking/http/UploadJobs.kt +217 -0
- package/android/src/main/java/com/brickssoft/locationtracking/http/UploadWait.kt +184 -0
- package/android/src/main/java/com/brickssoft/locationtracking/logging/FileLogger.kt +66 -7
- package/android/src/main/java/com/brickssoft/locationtracking/logging/LogRedaction.kt +31 -0
- package/android/src/main/java/com/brickssoft/locationtracking/model/DeviceModels.kt +44 -1
- package/android/src/main/java/com/brickssoft/locationtracking/model/ModelJson.kt +79 -8
- package/android/src/main/java/com/brickssoft/locationtracking/model/Record.kt +9 -0
- package/android/src/main/java/com/brickssoft/locationtracking/model/TrackedLocation.kt +12 -2
- package/android/src/main/java/com/brickssoft/locationtracking/position/DefaultPositionService.kt +10 -2
- package/android/src/main/java/com/brickssoft/locationtracking/position/PositionWatch.kt +24 -1
- package/android/src/main/java/com/brickssoft/locationtracking/processing/DefaultRecordFactory.kt +31 -10
- package/android/src/main/java/com/brickssoft/locationtracking/provider/Backends.kt +48 -3
- package/android/src/main/java/com/brickssoft/locationtracking/provider/DefaultProviderFactory.kt +60 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/LocationSubscriptions.kt +180 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/android/AndroidLocationBackend.kt +200 -126
- package/android/src/main/java/com/brickssoft/locationtracking/provider/android/LocationProviders.kt +20 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsLocationBackend.kt +39 -51
- package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsProviderBundle.kt +8 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsLocationBackend.kt +47 -53
- package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsProviderBundle.kt +34 -2
- package/android/src/main/java/com/brickssoft/locationtracking/record/RecordSink.kt +19 -1
- package/android/src/main/java/com/brickssoft/locationtracking/service/BootCountStore.kt +7 -0
- package/android/src/main/res/xml/lt_file_paths.xml +1 -1
- package/dist/esm/definitions.d.ts +107 -4
- package/dist/esm/definitions.js.map +1 -1
- package/dist/esm/events.d.ts +8 -2
- package/dist/esm/events.js +7 -1
- package/dist/esm/events.js.map +1 -1
- package/dist/esm/index.d.ts +1 -1
- package/dist/esm/index.js +1 -1
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/web/config.js +10 -0
- package/dist/esm/web/config.js.map +1 -1
- package/dist/esm/web/device.d.ts +1 -1
- package/dist/esm/web/device.js +8 -1
- package/dist/esm/web/device.js.map +1 -1
- package/dist/esm/web.d.ts +0 -8
- package/dist/esm/web.js +12 -3
- package/dist/esm/web.js.map +1 -1
- package/dist/plugin.cjs.js +39 -5
- package/dist/plugin.cjs.js.map +1 -1
- package/dist/plugin.js +39 -5
- package/dist/plugin.js.map +1 -1
- package/package.json +2 -2
|
@@ -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
|
|
53
|
-
* 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),
|
|
72
|
-
* `syncInterval`
|
|
73
|
-
*
|
|
74
|
-
*
|
|
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
|
|
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.
|
|
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
|
-
/**
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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)
|
|
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
|
|
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
|
-
|
|
242
|
-
|
|
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 (
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
294
|
-
armIntervalTimer(
|
|
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.
|
|
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)
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
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
|
|
421
|
-
* refresh at all unless [mayRefresh]. Emits the Http event of every request
|
|
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
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
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,
|
|
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
|
-
|
|
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(
|
|
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
|
|
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(
|
|
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
|
|
512
|
-
* due
|
|
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
|
|
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
|
-
|
|
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
|
}
|