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