@bricks-soft/capacitor-location-tracking 0.0.0-stage → 8.0.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/LICENSE +21 -0
- package/README.md +2759 -2
- package/android/build.gradle +106 -0
- package/android/consumer-rules.pro +42 -0
- package/android/src/main/AndroidManifest.xml +52 -0
- package/android/src/main/java/com/brickssoft/locationtracking/LocationTrackingPlugin.kt +431 -0
- package/android/src/main/java/com/brickssoft/locationtracking/api/LocationTrackingListener.kt +52 -0
- package/android/src/main/java/com/brickssoft/locationtracking/api/LocationTrackingNative.kt +267 -0
- package/android/src/main/java/com/brickssoft/locationtracking/api/NativeListeners.kt +270 -0
- package/android/src/main/java/com/brickssoft/locationtracking/api/NativeThread.kt +57 -0
- package/android/src/main/java/com/brickssoft/locationtracking/bridge/BridgeServices.kt +64 -0
- package/android/src/main/java/com/brickssoft/locationtracking/bridge/CallResponder.kt +41 -0
- package/android/src/main/java/com/brickssoft/locationtracking/bridge/OptionParsers.kt +281 -0
- package/android/src/main/java/com/brickssoft/locationtracking/bridge/PluginHandlers.kt +541 -0
- package/android/src/main/java/com/brickssoft/locationtracking/bridge/PluginPermissionHost.kt +76 -0
- package/android/src/main/java/com/brickssoft/locationtracking/bridge/WatchRegistry.kt +84 -0
- package/android/src/main/java/com/brickssoft/locationtracking/config/Config.kt +231 -0
- package/android/src/main/java/com/brickssoft/locationtracking/config/ConfigJson.kt +574 -0
- package/android/src/main/java/com/brickssoft/locationtracking/config/ConfigStore.kt +31 -0
- package/android/src/main/java/com/brickssoft/locationtracking/config/ConfigValidator.kt +170 -0
- package/android/src/main/java/com/brickssoft/locationtracking/config/RuntimeStateJson.kt +73 -0
- package/android/src/main/java/com/brickssoft/locationtracking/config/SharedPrefsConfigStore.kt +135 -0
- package/android/src/main/java/com/brickssoft/locationtracking/config/State.kt +45 -0
- package/android/src/main/java/com/brickssoft/locationtracking/core/AppDispatchers.kt +21 -0
- package/android/src/main/java/com/brickssoft/locationtracking/core/Clock.kt +41 -0
- package/android/src/main/java/com/brickssoft/locationtracking/core/Components.kt +217 -0
- package/android/src/main/java/com/brickssoft/locationtracking/core/Constants.kt +78 -0
- package/android/src/main/java/com/brickssoft/locationtracking/core/Errors.kt +27 -0
- package/android/src/main/java/com/brickssoft/locationtracking/core/EventBus.kt +39 -0
- package/android/src/main/java/com/brickssoft/locationtracking/core/ForceStopProbe.kt +63 -0
- package/android/src/main/java/com/brickssoft/locationtracking/core/Iso8601.kt +63 -0
- package/android/src/main/java/com/brickssoft/locationtracking/core/Logger.kt +68 -0
- package/android/src/main/java/com/brickssoft/locationtracking/core/RecordHooks.kt +47 -0
- package/android/src/main/java/com/brickssoft/locationtracking/core/TrackingEvent.kt +64 -0
- package/android/src/main/java/com/brickssoft/locationtracking/data/SqlSupport.kt +79 -0
- package/android/src/main/java/com/brickssoft/locationtracking/data/SqliteGeofenceStore.kt +144 -0
- package/android/src/main/java/com/brickssoft/locationtracking/data/SqliteLocationStore.kt +215 -0
- package/android/src/main/java/com/brickssoft/locationtracking/data/Stores.kt +45 -0
- package/android/src/main/java/com/brickssoft/locationtracking/data/TrackingDatabase.kt +111 -0
- package/android/src/main/java/com/brickssoft/locationtracking/device/DefaultDeviceInfoProvider.kt +104 -0
- package/android/src/main/java/com/brickssoft/locationtracking/device/DefaultDeviceMonitor.kt +335 -0
- package/android/src/main/java/com/brickssoft/locationtracking/device/DeviceMonitor.kt +36 -0
- package/android/src/main/java/com/brickssoft/locationtracking/device/DeviceStateMapping.kt +69 -0
- package/android/src/main/java/com/brickssoft/locationtracking/engine/DefaultTrackingEngine.kt +1281 -0
- package/android/src/main/java/com/brickssoft/locationtracking/engine/ElasticDistance.kt +68 -0
- package/android/src/main/java/com/brickssoft/locationtracking/engine/LocationRequests.kt +74 -0
- package/android/src/main/java/com/brickssoft/locationtracking/engine/MotionStateMachine.kt +291 -0
- package/android/src/main/java/com/brickssoft/locationtracking/engine/StationaryRegion.kt +41 -0
- package/android/src/main/java/com/brickssoft/locationtracking/engine/TrackingEngine.kt +60 -0
- package/android/src/main/java/com/brickssoft/locationtracking/engine/TrackingTimers.kt +90 -0
- package/android/src/main/java/com/brickssoft/locationtracking/geofence/DefaultGeofenceManager.kt +899 -0
- package/android/src/main/java/com/brickssoft/locationtracking/geofence/DwellTracker.kt +80 -0
- package/android/src/main/java/com/brickssoft/locationtracking/geofence/GeofenceManager.kt +33 -0
- package/android/src/main/java/com/brickssoft/locationtracking/geofence/PolygonMath.kt +194 -0
- package/android/src/main/java/com/brickssoft/locationtracking/heartbeat/DefaultHeartbeatScheduler.kt +713 -0
- package/android/src/main/java/com/brickssoft/locationtracking/heartbeat/HeartbeatAlarmReceiver.kt +69 -0
- package/android/src/main/java/com/brickssoft/locationtracking/heartbeat/HeartbeatAlarms.kt +100 -0
- package/android/src/main/java/com/brickssoft/locationtracking/heartbeat/HeartbeatScheduler.kt +25 -0
- package/android/src/main/java/com/brickssoft/locationtracking/heartbeat/HeartbeatWindow.kt +179 -0
- package/android/src/main/java/com/brickssoft/locationtracking/http/AuthorizationManager.kt +213 -0
- package/android/src/main/java/com/brickssoft/locationtracking/http/BodyBuilder.kt +73 -0
- package/android/src/main/java/com/brickssoft/locationtracking/http/HttpSupport.kt +118 -0
- package/android/src/main/java/com/brickssoft/locationtracking/http/HttpSyncer.kt +18 -0
- package/android/src/main/java/com/brickssoft/locationtracking/http/OkHttpSyncer.kt +551 -0
- package/android/src/main/java/com/brickssoft/locationtracking/http/StrictJson.kt +135 -0
- package/android/src/main/java/com/brickssoft/locationtracking/http/SyncPolicy.kt +109 -0
- package/android/src/main/java/com/brickssoft/locationtracking/http/TemplateRenderer.kt +143 -0
- package/android/src/main/java/com/brickssoft/locationtracking/logging/FileLogger.kt +461 -0
- package/android/src/main/java/com/brickssoft/locationtracking/logging/LogFileProvider.kt +9 -0
- package/android/src/main/java/com/brickssoft/locationtracking/logging/LogFormatter.kt +148 -0
- package/android/src/main/java/com/brickssoft/locationtracking/logging/LogStore.kt +30 -0
- package/android/src/main/java/com/brickssoft/locationtracking/model/DeviceModels.kt +176 -0
- package/android/src/main/java/com/brickssoft/locationtracking/model/GeofenceSpec.kt +25 -0
- package/android/src/main/java/com/brickssoft/locationtracking/model/ModelJson.kt +550 -0
- package/android/src/main/java/com/brickssoft/locationtracking/model/Record.kt +106 -0
- package/android/src/main/java/com/brickssoft/locationtracking/model/TrackedLocation.kt +53 -0
- package/android/src/main/java/com/brickssoft/locationtracking/permission/BackgroundRationaleDialog.kt +85 -0
- package/android/src/main/java/com/brickssoft/locationtracking/permission/CurrentActivityTracker.kt +72 -0
- package/android/src/main/java/com/brickssoft/locationtracking/permission/DefaultPermissionManager.kt +333 -0
- package/android/src/main/java/com/brickssoft/locationtracking/permission/PermissionManager.kt +54 -0
- package/android/src/main/java/com/brickssoft/locationtracking/position/DefaultPositionService.kt +277 -0
- package/android/src/main/java/com/brickssoft/locationtracking/position/PositionService.kt +39 -0
- package/android/src/main/java/com/brickssoft/locationtracking/position/PositionWatch.kt +115 -0
- package/android/src/main/java/com/brickssoft/locationtracking/processing/DefaultLocationProcessor.kt +93 -0
- package/android/src/main/java/com/brickssoft/locationtracking/processing/DefaultOdometer.kt +99 -0
- package/android/src/main/java/com/brickssoft/locationtracking/processing/DefaultRecordFactory.kt +166 -0
- package/android/src/main/java/com/brickssoft/locationtracking/processing/Geo.kt +56 -0
- package/android/src/main/java/com/brickssoft/locationtracking/processing/KalmanFilter.kt +72 -0
- package/android/src/main/java/com/brickssoft/locationtracking/processing/Processing.kt +48 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/Backends.kt +187 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/DefaultProviderFactory.kt +169 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/ProviderPackaging.kt +135 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/android/AndroidActivityBackend.kt +25 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/android/AndroidGeofenceBackend.kt +181 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/android/AndroidGeofenceReceiver.kt +115 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/android/AndroidLocationBackend.kt +265 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/android/AndroidProviderBundle.kt +31 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/android/LocationProviders.kt +98 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsActivityBackend.kt +80 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsActivityReceiver.kt +62 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsErrors.kt +72 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsGeofenceBackend.kt +152 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsGeofenceReceiver.kt +78 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsLocationBackend.kt +176 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsProviderBundle.kt +62 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsReceiverSupport.kt +88 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsTasks.kt +55 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsActivityBackend.kt +71 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsActivityReceiver.kt +57 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsErrors.kt +76 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsGeofenceBackend.kt +116 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsGeofenceReceiver.kt +67 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsIntents.kt +83 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsLocationBackend.kt +197 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsProviderBundle.kt +55 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsTasks.kt +44 -0
- package/android/src/main/java/com/brickssoft/locationtracking/record/RecordSink.kt +95 -0
- package/android/src/main/java/com/brickssoft/locationtracking/service/BootCountStore.kt +44 -0
- package/android/src/main/java/com/brickssoft/locationtracking/service/BootReceiver.kt +175 -0
- package/android/src/main/java/com/brickssoft/locationtracking/service/DefaultServiceController.kt +196 -0
- package/android/src/main/java/com/brickssoft/locationtracking/service/LocationTrackingService.kt +319 -0
- package/android/src/main/java/com/brickssoft/locationtracking/service/NotificationActionReceiver.kt +42 -0
- package/android/src/main/java/com/brickssoft/locationtracking/service/NotificationFactory.kt +338 -0
- package/android/src/main/java/com/brickssoft/locationtracking/service/ServiceCommands.kt +204 -0
- package/android/src/main/java/com/brickssoft/locationtracking/service/ServiceController.kt +22 -0
- package/android/src/main/java/com/brickssoft/locationtracking/service/ServiceDeps.kt +61 -0
- package/android/src/main/java/com/brickssoft/locationtracking/service/WhileInUseStartCheck.kt +115 -0
- package/android/src/main/java/com/brickssoft/locationtracking/settings/DefaultDeviceSettings.kt +79 -0
- package/android/src/main/java/com/brickssoft/locationtracking/settings/DeviceSettings.kt +21 -0
- package/android/src/main/java/com/brickssoft/locationtracking/settings/OemPowerManagers.kt +198 -0
- package/android/src/main/res/drawable/lt_ic_notification.xml +11 -0
- package/android/src/main/res/values/lt_strings.xml +11 -0
- package/android/src/main/res/xml/lt_file_paths.xml +5 -0
- package/dist/esm/definitions.d.ts +719 -0
- package/dist/esm/definitions.js +2 -0
- package/dist/esm/definitions.js.map +1 -0
- package/dist/esm/events.d.ts +50 -0
- package/dist/esm/events.js +52 -0
- package/dist/esm/events.js.map +1 -0
- package/dist/esm/index.d.ts +3 -0
- package/dist/esm/index.js +5 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/plugin.d.ts +3 -0
- package/dist/esm/plugin.js +10 -0
- package/dist/esm/plugin.js.map +1 -0
- package/dist/esm/web/config.d.ts +19 -0
- package/dist/esm/web/config.js +243 -0
- package/dist/esm/web/config.js.map +1 -0
- package/dist/esm/web/device.d.ts +9 -0
- package/dist/esm/web/device.js +90 -0
- package/dist/esm/web/device.js.map +1 -0
- package/dist/esm/web/errors.d.ts +14 -0
- package/dist/esm/web/errors.js +19 -0
- package/dist/esm/web/errors.js.map +1 -0
- package/dist/esm/web/geolocation.d.ts +26 -0
- package/dist/esm/web/geolocation.js +117 -0
- package/dist/esm/web/geolocation.js.map +1 -0
- package/dist/esm/web/location.d.ts +7 -0
- package/dist/esm/web/location.js +70 -0
- package/dist/esm/web/location.js.map +1 -0
- package/dist/esm/web.d.ts +164 -0
- package/dist/esm/web.js +328 -0
- package/dist/esm/web.js.map +1 -0
- package/dist/plugin.cjs.js +945 -0
- package/dist/plugin.cjs.js.map +1 -0
- package/dist/plugin.js +948 -0
- package/dist/plugin.js.map +1 -0
- package/package.json +59 -4
|
@@ -0,0 +1,551 @@
|
|
|
1
|
+
package com.brickssoft.locationtracking.http
|
|
2
|
+
|
|
3
|
+
import com.brickssoft.locationtracking.config.Config
|
|
4
|
+
import com.brickssoft.locationtracking.config.ConfigStore
|
|
5
|
+
import com.brickssoft.locationtracking.config.HttpConfig
|
|
6
|
+
import com.brickssoft.locationtracking.config.RuntimeState
|
|
7
|
+
import com.brickssoft.locationtracking.core.AppDispatchers
|
|
8
|
+
import com.brickssoft.locationtracking.core.Clock
|
|
9
|
+
import com.brickssoft.locationtracking.core.ErrorCode
|
|
10
|
+
import com.brickssoft.locationtracking.core.EventBus
|
|
11
|
+
import com.brickssoft.locationtracking.core.Iso8601
|
|
12
|
+
import com.brickssoft.locationtracking.core.Logger
|
|
13
|
+
import com.brickssoft.locationtracking.core.TrackingEvent
|
|
14
|
+
import com.brickssoft.locationtracking.core.TrackingException
|
|
15
|
+
import com.brickssoft.locationtracking.data.LocationStore
|
|
16
|
+
import com.brickssoft.locationtracking.device.DeviceMonitor
|
|
17
|
+
import com.brickssoft.locationtracking.model.Connectivity
|
|
18
|
+
import com.brickssoft.locationtracking.model.ConnectivityType
|
|
19
|
+
import com.brickssoft.locationtracking.model.HttpResult
|
|
20
|
+
import com.brickssoft.locationtracking.model.Record
|
|
21
|
+
import kotlinx.coroutines.CoroutineScope
|
|
22
|
+
import kotlinx.coroutines.Job
|
|
23
|
+
import kotlinx.coroutines.channels.Channel
|
|
24
|
+
import kotlinx.coroutines.currentCoroutineContext
|
|
25
|
+
import kotlinx.coroutines.ensureActive
|
|
26
|
+
import kotlinx.coroutines.delay
|
|
27
|
+
import kotlinx.coroutines.flow.combine
|
|
28
|
+
import kotlinx.coroutines.launch
|
|
29
|
+
import kotlinx.coroutines.sync.Mutex
|
|
30
|
+
import kotlinx.coroutines.sync.withLock
|
|
31
|
+
import kotlinx.coroutines.withContext
|
|
32
|
+
import okhttp3.HttpUrl
|
|
33
|
+
import okhttp3.OkHttpClient
|
|
34
|
+
import java.io.IOException
|
|
35
|
+
import java.util.concurrent.atomic.AtomicBoolean
|
|
36
|
+
import java.util.concurrent.atomic.AtomicReference
|
|
37
|
+
import kotlin.coroutines.cancellation.CancellationException
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* [HttpSyncer] over OkHttp (architecture §2; round 2 §4 for `http.syncInterval`).
|
|
41
|
+
*
|
|
42
|
+
* - Automatic passes run on [scope] / `dispatchers.io`, triggered by [onRecordInserted], by
|
|
43
|
+
* `ConnectivityChange(connected = true)` once [start]ed, by [start] itself, and by the `syncInterval` timer
|
|
44
|
+
* (below). [SyncPolicy] decides what they send. Triggers are coalesced: any number of triggers during a pass
|
|
45
|
+
* schedule exactly one more pass.
|
|
46
|
+
* - Uploads are single-flight: automatic passes and [sync] share one mutex, so a record is never in two requests
|
|
47
|
+
* at once.
|
|
48
|
+
* - The queue drains oldest first, `maxBatchSize` records per request when `batchSync`, else one per request, and
|
|
49
|
+
* stops at the first failure. If the server rejected an upload, an automatic pass still sends the queued priority
|
|
50
|
+
* records (heartbeat and audit records), so one record the server keeps rejecting cannot hold them back.
|
|
51
|
+
* - One upload makes up to 4 tries (owner decision R2-Q18): a try without an answer (connection error or timeout),
|
|
52
|
+
* HTTP 5xx or HTTP 429 is tried again after 2, 4 and 8 s, while the upload lock is held. Any other answer ends the
|
|
53
|
+
* upload; a 401 keeps its token refresh and one more request, once per upload.
|
|
54
|
+
* - 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).
|
|
56
|
+
*
|
|
57
|
+
* **`http.syncInterval` (seconds, 0 = off).** While tracking is on (`runtime.enabled`) with `syncInterval > 0` and
|
|
58
|
+
* `autoSync`, normal records (every event that is not a priority record) are held until the oldest pending normal
|
|
59
|
+
* record is `syncInterval` seconds old, or until the queue reaches `autoSyncThreshold` when that is above 0; then
|
|
60
|
+
* one pass uploads the whole queue. Priority records still upload at once and take the whole queue with them.
|
|
61
|
+
* Every pass checks this rule (a pass runs on every insert, on connectivity regained, on [start], and on the timer).
|
|
62
|
+
* - **Timer.** After each pass, while the rule applies, `http.url` is valid and the oldest pending normal record is
|
|
63
|
+
* not yet due, one coroutine timer is armed for `oldest.recorded_at + syncInterval`; it requests a pass, so the
|
|
64
|
+
* upload happens without a further record. It is replaced when that due time changes. A pass that finds the queue
|
|
65
|
+
* empty, tracking off or the rule off cancels it. Deleting records through the store directly (for example
|
|
66
|
+
* `destroyLocations()`) is not seen: the timer then fires once and its pass finds nothing to send.
|
|
67
|
+
* - **Tracking off.** No timer, and normal records follow the `syncInterval = 0` rule (`autoSyncThreshold`), so a
|
|
68
|
+
* record created while tracking is off (for example by `getCurrentPosition`) is not held without a timer.
|
|
69
|
+
* - **Settings.** Once `syncInterval > 0` was seen in this process, a pass also runs when tracking is switched on or
|
|
70
|
+
* off and when `url`, `autoSync`, `autoSyncThreshold`, `syncInterval` or `disableAutoSyncOnCellular` change.
|
|
71
|
+
* - **Failures.** After a failed automatic upload (after its last try), the rule retries normal records once per
|
|
72
|
+
* `syncInterval`
|
|
73
|
+
* (measured on elapsed realtime) through the timer, not on every insert. Connectivity regained, a queued priority
|
|
74
|
+
* record and [sync] still upload at once. Records that are due but could not be tried (offline, cellular
|
|
75
|
+
* restricted) get no timer: connectivity regained triggers them.
|
|
76
|
+
*
|
|
77
|
+
* Worst-case staleness of the server's live location: about `syncInterval`. The timer is a coroutine `delay` and
|
|
78
|
+
* holds no wake lock. On Android a `delay` counts only the time the CPU is awake (the monotonic clock stops in deep
|
|
79
|
+
* sleep), so in deep sleep or Doze the timer fires later than the due time. Two other triggers bound that delay:
|
|
80
|
+
* while moving, every new location record runs the check against the wall clock; while stationary, the heartbeat
|
|
81
|
+
* alarm records a heartbeat, a priority record that uploads the whole queue. With the heartbeat disabled and no new
|
|
82
|
+
* record, held records wait until the CPU is awake long enough for the rest of the `delay` to run.
|
|
83
|
+
* A wall clock set back makes the records created before the change look newer than the ones created after it; the
|
|
84
|
+
* store orders by `recorded_at`, so such records wait for the oldest record created after the change (at most
|
|
85
|
+
* `syncInterval` after it) unless they are the only pending normal records, which are then due at once.
|
|
86
|
+
* Held records count against `persistence.maxRecordsToPersist` (default unlimited): a limit below the number of
|
|
87
|
+
* records created in `syncInterval` lets pruning delete held records before they are uploaded.
|
|
88
|
+
*/
|
|
89
|
+
class OkHttpSyncer(
|
|
90
|
+
private val configStore: ConfigStore,
|
|
91
|
+
private val locationStore: LocationStore,
|
|
92
|
+
private val device: DeviceMonitor,
|
|
93
|
+
private val events: EventBus,
|
|
94
|
+
private val clock: Clock,
|
|
95
|
+
private val http: OkHttpClient,
|
|
96
|
+
private val dispatchers: AppDispatchers,
|
|
97
|
+
private val scope: CoroutineScope,
|
|
98
|
+
) : HttpSyncer {
|
|
99
|
+
private val uploadLock = Mutex()
|
|
100
|
+
private val triggers = Channel<Unit>(Channel.CONFLATED)
|
|
101
|
+
private val workerLaunched = AtomicBoolean(false)
|
|
102
|
+
private val started = AtomicBoolean(false)
|
|
103
|
+
|
|
104
|
+
/** The connectivity of the latest `ConnectivityChange(connected = true)` not yet seen by a pass. */
|
|
105
|
+
private val connectivityHint = AtomicReference<Connectivity?>(null)
|
|
106
|
+
private val authorization = AuthorizationManager(configStore, events, clock, dispatchers)
|
|
107
|
+
|
|
108
|
+
/** Client derived from [http] for the last timeout used. */
|
|
109
|
+
@Volatile
|
|
110
|
+
private var timedClient: Pair<Long, OkHttpClient>? = null
|
|
111
|
+
|
|
112
|
+
/** Guards [intervalTimer] and [intervalTimerKey]. */
|
|
113
|
+
private val timerLock = Any()
|
|
114
|
+
|
|
115
|
+
/** The armed `syncInterval` check (see the class docs), or null. */
|
|
116
|
+
private var intervalTimer: Job? = null
|
|
117
|
+
|
|
118
|
+
/** What the armed [intervalTimer] waits for. */
|
|
119
|
+
private var intervalTimerKey: TimerKey? = null
|
|
120
|
+
|
|
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
|
+
*/
|
|
125
|
+
@Volatile
|
|
126
|
+
private var retryAtElapsed: Long? = null
|
|
127
|
+
|
|
128
|
+
private val stateWatcherLaunched = AtomicBoolean(false)
|
|
129
|
+
|
|
130
|
+
override fun start() {
|
|
131
|
+
if (!started.compareAndSet(false, true)) return
|
|
132
|
+
events.subscribe { event ->
|
|
133
|
+
if (event is TrackingEvent.ConnectivityChange) {
|
|
134
|
+
if (event.connectivity.connected) {
|
|
135
|
+
connectivityHint.set(event.connectivity)
|
|
136
|
+
requestPass()
|
|
137
|
+
} else {
|
|
138
|
+
connectivityHint.set(null)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
requestPass()
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
override fun onRecordInserted(record: Record) {
|
|
146
|
+
if (!SyncPolicy.hasUrl(configStore.config.value.http)) return
|
|
147
|
+
requestPass()
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
override suspend fun sync(): List<Record> {
|
|
151
|
+
val url = configStore.config.value.http.url
|
|
152
|
+
if (url.isNullOrBlank()) throw TrackingException(ErrorCode.NO_URL, "http.url is not set")
|
|
153
|
+
if (HttpSupport.parseUrl(url) == null) {
|
|
154
|
+
throw TrackingException(ErrorCode.NO_URL, "http.url is not a valid http(s) URL")
|
|
155
|
+
}
|
|
156
|
+
val result = uploadLock.withLock {
|
|
157
|
+
val drained = drain(SyncPolicy.Scope.ALL)
|
|
158
|
+
if (drained.failure == null) retryAtElapsed = null
|
|
159
|
+
updateIntervalTimer(null)
|
|
160
|
+
drained
|
|
161
|
+
}
|
|
162
|
+
result.failure?.let { failure ->
|
|
163
|
+
val r = failure.result
|
|
164
|
+
val message = "upload of ${r.uuids.size} record(s) failed"
|
|
165
|
+
throw when (failure.kind) {
|
|
166
|
+
FailureKind.HTTP -> TrackingException(ErrorCode.HTTP_ERROR, "$message: HTTP ${r.status}")
|
|
167
|
+
FailureKind.NETWORK -> TrackingException(ErrorCode.NETWORK_ERROR, "$message: ${r.responseText}")
|
|
168
|
+
FailureKind.INTERNAL -> TrackingException(ErrorCode.INTERNAL, "$message: ${r.responseText}")
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return result.uploaded
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Schedules an automatic pass; never blocks. The worker is launched on first use. */
|
|
175
|
+
private fun requestPass() {
|
|
176
|
+
if (workerLaunched.compareAndSet(false, true)) {
|
|
177
|
+
scope.launch(dispatchers.io) {
|
|
178
|
+
while (true) {
|
|
179
|
+
triggers.receive()
|
|
180
|
+
try {
|
|
181
|
+
uploadLock.withLock { autoPass() }
|
|
182
|
+
} catch (e: CancellationException) {
|
|
183
|
+
throw e
|
|
184
|
+
} catch (e: Exception) {
|
|
185
|
+
// autoPass handles its own failures; this keeps the worker alive if that ever changes.
|
|
186
|
+
Logger.e(TAG, "automatic pass failed unexpectedly", e)
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
triggers.trySend(Unit)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** One automatic pass: the upload [SyncPolicy] allows, then the `syncInterval` timer for what stays queued. */
|
|
195
|
+
private suspend fun autoPass() {
|
|
196
|
+
val lookup = try {
|
|
197
|
+
uploadPass()
|
|
198
|
+
} catch (e: CancellationException) {
|
|
199
|
+
throw e
|
|
200
|
+
} catch (e: Exception) {
|
|
201
|
+
Logger.e(TAG, "automatic upload failed", e)
|
|
202
|
+
null
|
|
203
|
+
}
|
|
204
|
+
updateIntervalTimer(lookup)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Uploads what [SyncPolicy.autoScope] allows. Returns the oldest-normal-record lookup it made if it left the
|
|
209
|
+
* queue unchanged, else null.
|
|
210
|
+
*/
|
|
211
|
+
private suspend fun uploadPass(): OldestNormal? {
|
|
212
|
+
val hint = connectivityHint.getAndSet(null)
|
|
213
|
+
// Connectivity regained since the last pass: retry at once. Cleared here, not in the event handler, so a pass
|
|
214
|
+
// that fails while the event arrives cannot set the wait again after the handler cleared it.
|
|
215
|
+
if (hint != null) retryAtElapsed = null
|
|
216
|
+
val config = configStore.config.value.http
|
|
217
|
+
if (!SyncPolicy.hasUrl(config)) return null
|
|
218
|
+
if (HttpSupport.parseUrl(config.url) == null) {
|
|
219
|
+
Logger.e(TAG, "http.url is not a valid http(s) URL; nothing uploaded")
|
|
220
|
+
return null
|
|
221
|
+
}
|
|
222
|
+
val connectivity = currentConnectivity(hint)
|
|
223
|
+
if (!connectivity.connected) {
|
|
224
|
+
Logger.d(TAG, "offline; upload deferred until connectivity returns")
|
|
225
|
+
return null
|
|
226
|
+
}
|
|
227
|
+
val priority = locationStore.count(SyncPolicy.PRIORITY_EVENTS)
|
|
228
|
+
val queued = locationStore.count()
|
|
229
|
+
val intervalOn = intervalApplies(config)
|
|
230
|
+
// The interval rule needs the oldest pending normal record only when no priority record forces an upload.
|
|
231
|
+
val lookup = if (intervalOn && priority == 0) lookUpOldestNormal() else null
|
|
232
|
+
val check = when {
|
|
233
|
+
!intervalOn -> SyncPolicy.IntervalCheck.OFF
|
|
234
|
+
isRetryPending() -> SyncPolicy.IntervalCheck.RETRY_PENDING
|
|
235
|
+
lookup?.record?.let { SyncPolicy.isIntervalDue(config, it.recordedAt, lookup.now) } == true ->
|
|
236
|
+
SyncPolicy.IntervalCheck.DUE
|
|
237
|
+
else -> SyncPolicy.IntervalCheck.NOT_DUE
|
|
238
|
+
}
|
|
239
|
+
val plan = SyncPolicy.autoScope(config, connectivity, queued, priority, check)
|
|
240
|
+
if (plan == SyncPolicy.Scope.NONE) return lookup
|
|
241
|
+
val failure = drain(plan).failure
|
|
242
|
+
if (plan == SyncPolicy.Scope.ALL && priority > 0 && failure != null && failure.kind != FailureKind.NETWORK) {
|
|
243
|
+
// The server is reachable but rejected an older upload: still deliver the audit records behind it.
|
|
244
|
+
// Priority records that just failed on their own are not re-sent in this pass.
|
|
245
|
+
val skip = if (failure.records.all { it.event.isPriority }) failure.result.uuids.toSet() else emptySet()
|
|
246
|
+
drain(SyncPolicy.Scope.PRIORITY_ONLY, skip)
|
|
247
|
+
}
|
|
248
|
+
if (failure != null && intervalOn) {
|
|
249
|
+
retryAtElapsed = clock.elapsedRealtime() + SyncPolicy.intervalMs(config)
|
|
250
|
+
} else if (failure == null && plan == SyncPolicy.Scope.ALL) {
|
|
251
|
+
retryAtElapsed = null
|
|
252
|
+
}
|
|
253
|
+
return null
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** True if normal records follow `syncInterval` now: configured ([SyncPolicy.usesInterval]) and tracking is on. */
|
|
257
|
+
private fun intervalApplies(config: HttpConfig): Boolean =
|
|
258
|
+
SyncPolicy.usesInterval(config) && configStore.runtime.value.enabled
|
|
259
|
+
|
|
260
|
+
private fun isRetryPending(): Boolean = retryAtElapsed?.let { clock.elapsedRealtime() < it } ?: false
|
|
261
|
+
|
|
262
|
+
private suspend fun lookUpOldestNormal(): OldestNormal =
|
|
263
|
+
OldestNormal(locationStore.list(1, SyncPolicy.NORMAL_EVENTS).firstOrNull(), clock.now())
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Arms, keeps or cancels the `syncInterval` timer for the current queue (see the class docs). [known] is a lookup
|
|
267
|
+
* made by a pass that left the queue unchanged; its `now` is reused so the pass and the timer agree on whether
|
|
268
|
+
* the record is due. Null means look it up now. Never throws (except cancellation).
|
|
269
|
+
*/
|
|
270
|
+
private suspend fun updateIntervalTimer(known: OldestNormal?) {
|
|
271
|
+
try {
|
|
272
|
+
val config = configStore.config.value.http
|
|
273
|
+
if (config.syncInterval > 0) launchStateWatcher()
|
|
274
|
+
if (!intervalApplies(config) || HttpSupport.parseUrl(config.url) == null) {
|
|
275
|
+
cancelIntervalTimer()
|
|
276
|
+
return
|
|
277
|
+
}
|
|
278
|
+
val lookup = known ?: lookUpOldestNormal()
|
|
279
|
+
val oldest = lookup.record
|
|
280
|
+
if (oldest == null) {
|
|
281
|
+
cancelIntervalTimer()
|
|
282
|
+
return
|
|
283
|
+
}
|
|
284
|
+
val dueAt = SyncPolicy.intervalDueAt(config, oldest.recordedAt, lookup.now)
|
|
285
|
+
val retryAt = retryAtElapsed?.takeIf { it > clock.elapsedRealtime() }
|
|
286
|
+
if (dueAt == null && retryAt == null) {
|
|
287
|
+
// Due and not waiting for a retry: this pass uploaded it, or could not try (offline, cellular
|
|
288
|
+
// restricted). The next record, connectivity or start() triggers the next attempt.
|
|
289
|
+
cancelIntervalTimer()
|
|
290
|
+
return
|
|
291
|
+
}
|
|
292
|
+
val untilDue = dueAt?.let { it - clock.now() } ?: 0L
|
|
293
|
+
val untilRetry = retryAt?.let { it - clock.elapsedRealtime() } ?: 0L
|
|
294
|
+
armIntervalTimer(TimerKey(dueAt, retryAt), maxOf(untilDue, untilRetry, 0L), config.syncInterval)
|
|
295
|
+
} catch (e: CancellationException) {
|
|
296
|
+
throw e
|
|
297
|
+
} catch (e: Exception) {
|
|
298
|
+
Logger.e(TAG, "could not schedule the syncInterval check", e)
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Arms the timer for [key] unless it is already armed for it; replaces a timer armed for anything else. */
|
|
303
|
+
private fun armIntervalTimer(key: TimerKey, delayMs: Long, syncInterval: Int) {
|
|
304
|
+
synchronized(timerLock) {
|
|
305
|
+
val current = intervalTimer
|
|
306
|
+
if (current != null && current.isActive && intervalTimerKey == key) return
|
|
307
|
+
current?.cancel()
|
|
308
|
+
intervalTimerKey = key
|
|
309
|
+
val due = key.dueAt?.let { Iso8601.format(it) } ?: "now"
|
|
310
|
+
val retry = if (key.retryAtElapsed != null) ", retry after a failed upload" else ""
|
|
311
|
+
Logger.d(TAG, "$TIMER_ARMED in ${delayMs / MS_PER_SECOND} s (syncInterval $syncInterval s, due $due$retry)")
|
|
312
|
+
intervalTimer = scope.launch(dispatchers.io) {
|
|
313
|
+
delay(delayMs)
|
|
314
|
+
val self = currentCoroutineContext()[Job]
|
|
315
|
+
synchronized(timerLock) {
|
|
316
|
+
if (intervalTimer === self) {
|
|
317
|
+
intervalTimer = null
|
|
318
|
+
intervalTimerKey = null
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
requestPass()
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
private fun cancelIntervalTimer() {
|
|
327
|
+
synchronized(timerLock) {
|
|
328
|
+
intervalTimer?.cancel()
|
|
329
|
+
intervalTimer = null
|
|
330
|
+
intervalTimerKey = null
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Once `syncInterval` has been set in this process: a pass whenever tracking is switched on or off or an
|
|
336
|
+
* interval-related `http` setting changes, so the timer and the held records follow at once (for example tracking
|
|
337
|
+
* stopped: the timer is cancelled and the held records follow the rules for `syncInterval = 0`).
|
|
338
|
+
*/
|
|
339
|
+
private fun launchStateWatcher() {
|
|
340
|
+
if (!stateWatcherLaunched.compareAndSet(false, true)) return
|
|
341
|
+
var last = WatchedState.of(configStore.config.value, configStore.runtime.value)
|
|
342
|
+
scope.launch(dispatchers.io) {
|
|
343
|
+
combine(configStore.config, configStore.runtime, WatchedState::of).collect { state ->
|
|
344
|
+
if (state != last) {
|
|
345
|
+
last = state
|
|
346
|
+
requestPass()
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* The monitor's connectivity, unless it says offline while [hint] (a `ConnectivityChange(connected = true)` since
|
|
354
|
+
* the last pass) says otherwise: the OS may report a new network before it becomes the default one.
|
|
355
|
+
*/
|
|
356
|
+
private fun currentConnectivity(hint: Connectivity?): Connectivity {
|
|
357
|
+
val reported = try {
|
|
358
|
+
device.connectivity()
|
|
359
|
+
} catch (e: Exception) {
|
|
360
|
+
Logger.w(TAG, "connectivity unknown; trying to upload anyway", e)
|
|
361
|
+
return Connectivity(connected = true, type = ConnectivityType.OTHER)
|
|
362
|
+
}
|
|
363
|
+
return if (!reported.connected && hint != null) hint else reported
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Uploads the queue ([plan] ALL or PRIORITY_ONLY) oldest first, skipping [skip], until it is empty or an upload
|
|
368
|
+
* fails.
|
|
369
|
+
*/
|
|
370
|
+
private suspend fun drain(plan: SyncPolicy.Scope, skip: Set<String> = emptySet()): DrainResult {
|
|
371
|
+
val filter = if (plan == SyncPolicy.Scope.PRIORITY_ONLY) SyncPolicy.PRIORITY_EVENTS else null
|
|
372
|
+
val uploaded = ArrayList<Record>()
|
|
373
|
+
val attempted = HashSet(skip)
|
|
374
|
+
while (true) {
|
|
375
|
+
val config = configStore.config.value.http
|
|
376
|
+
val url = HttpSupport.parseUrl(config.url) ?: break
|
|
377
|
+
val limit = SyncPolicy.chunkSize(config)
|
|
378
|
+
// Over-fetch by the skipped count so skipped records cannot hide the rest of the queue.
|
|
379
|
+
val chunk = locationStore.list(limit + skip.size, filter).filter { it.uuid !in attempted }.take(limit)
|
|
380
|
+
if (chunk.isEmpty()) break
|
|
381
|
+
chunk.mapTo(attempted) { it.uuid }
|
|
382
|
+
val failure = upload(url, config, chunk)
|
|
383
|
+
if (failure != null) return DrainResult(uploaded, failure)
|
|
384
|
+
uploaded += chunk
|
|
385
|
+
}
|
|
386
|
+
return DrainResult(uploaded, null)
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* One upload of [records]: up to `1 + RETRY_DELAYS_MS.size` tries (owner decision R2-Q18), then the store update.
|
|
391
|
+
* Only a try without an answer, HTTP 5xx or HTTP 429 is tried again, after the next delay. Returns null on
|
|
392
|
+
* success.
|
|
393
|
+
*/
|
|
394
|
+
private suspend fun upload(url: HttpUrl, config: HttpConfig, records: List<Record>): Failure? =
|
|
395
|
+
withContext(dispatchers.io) {
|
|
396
|
+
val uuids = records.map { it.uuid }
|
|
397
|
+
var outcome = tryUpload(url, config, records, uuids, mayRefresh = true)
|
|
398
|
+
var refreshed = outcome.refreshed
|
|
399
|
+
for (wait in RETRY_DELAYS_MS) {
|
|
400
|
+
if (!outcome.retryable) break
|
|
401
|
+
if (!outcome.reported) events.emit(TrackingEvent.Http(outcome.result))
|
|
402
|
+
Logger.w(TAG, "upload of ${uuids.size} record(s) failed (${outcome.reason}); next try in ${wait / MS_PER_SECOND} s")
|
|
403
|
+
delay(wait)
|
|
404
|
+
// At most one token refresh per upload, whether before a request or after a 401.
|
|
405
|
+
outcome = tryUpload(url, config, records, uuids, mayRefresh = !refreshed)
|
|
406
|
+
refreshed = refreshed || outcome.refreshed
|
|
407
|
+
}
|
|
408
|
+
if (outcome.kind == null) {
|
|
409
|
+
locationStore.delete(uuids)
|
|
410
|
+
Logger.i(TAG, "uploaded ${uuids.size} record(s): HTTP ${outcome.result.status}")
|
|
411
|
+
} else {
|
|
412
|
+
locationStore.markAttempt(uuids, clock.now())
|
|
413
|
+
Logger.w(TAG, "upload of ${uuids.size} record(s) failed (${outcome.reason}); kept in the queue")
|
|
414
|
+
}
|
|
415
|
+
if (!outcome.reported) events.emit(TrackingEvent.Http(outcome.result))
|
|
416
|
+
outcome.kind?.let { Failure(records, outcome.result, it) }
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* One try of an upload: pre-emptive token refresh, request, and on a 401 a token refresh and one more request; no
|
|
421
|
+
* refresh at all unless [mayRefresh]. Emits the Http event of every request but the last one (see [Try.reported]).
|
|
422
|
+
*/
|
|
423
|
+
private suspend fun tryUpload(
|
|
424
|
+
url: HttpUrl,
|
|
425
|
+
config: HttpConfig,
|
|
426
|
+
records: List<Record>,
|
|
427
|
+
uuids: List<String>,
|
|
428
|
+
mayRefresh: Boolean,
|
|
429
|
+
): Try {
|
|
430
|
+
var reported = false
|
|
431
|
+
var refreshed = false
|
|
432
|
+
return try {
|
|
433
|
+
val client = clientFor(config.timeout)
|
|
434
|
+
val bearer = authorization.appliesTo(config)
|
|
435
|
+
val token = if (bearer) authorization.tokenForRequest(client, mayRefresh) else NO_TOKEN
|
|
436
|
+
refreshed = token.refreshAttempted
|
|
437
|
+
var response = send(client, url, config, records, token.value)
|
|
438
|
+
if (response.status == HTTP_UNAUTHORIZED && bearer && mayRefresh && !token.refreshAttempted) {
|
|
439
|
+
events.emit(TrackingEvent.Http(HttpResult(false, response.status, response.body, uuids)))
|
|
440
|
+
reported = true
|
|
441
|
+
refreshed = true
|
|
442
|
+
val renewed = authorization.refresh(client, token.value)
|
|
443
|
+
if (renewed != null) {
|
|
444
|
+
reported = false // the retry is a new request with its own event, even if it throws
|
|
445
|
+
response = send(client, url, config, records, renewed)
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
val result = HttpResult(response.isSuccessful, response.status, response.body, uuids)
|
|
449
|
+
Try(result, if (result.success) null else FailureKind.HTTP, reported, refreshed)
|
|
450
|
+
} catch (e: IOException) {
|
|
451
|
+
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)
|
|
453
|
+
} catch (e: CancellationException) {
|
|
454
|
+
throw e
|
|
455
|
+
} catch (e: Exception) {
|
|
456
|
+
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)
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
private suspend fun send(
|
|
462
|
+
client: OkHttpClient,
|
|
463
|
+
url: HttpUrl,
|
|
464
|
+
config: HttpConfig,
|
|
465
|
+
records: List<Record>,
|
|
466
|
+
token: String?,
|
|
467
|
+
): HttpResponse {
|
|
468
|
+
val body = BodyBuilder.build(records, config, clock.now())
|
|
469
|
+
val headers = HttpSupport.headers(HttpSupport.JSON_CONTENT_TYPE, config.headers, token)
|
|
470
|
+
return HttpSupport.execute(client, HttpSupport.request(url, config.method.wire, headers, body), dispatchers.io)
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
private fun clientFor(timeoutMs: Long): OkHttpClient {
|
|
474
|
+
timedClient?.let { (timeout, client) -> if (timeout == timeoutMs) return client }
|
|
475
|
+
return HttpSupport.client(http, timeoutMs).also { timedClient = timeoutMs to it }
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
private enum class FailureKind {
|
|
479
|
+
/** The server answered with a non-2xx status. */
|
|
480
|
+
HTTP,
|
|
481
|
+
|
|
482
|
+
/** No HTTP status was received. */
|
|
483
|
+
NETWORK,
|
|
484
|
+
|
|
485
|
+
/** The upload could not be attempted (e.g. the body could not be built). */
|
|
486
|
+
INTERNAL,
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* 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.
|
|
492
|
+
*/
|
|
493
|
+
private class Try(val result: HttpResult, val kind: FailureKind?, val reported: Boolean, val refreshed: Boolean) {
|
|
494
|
+
/** True for a request without an answer, HTTP 5xx or HTTP 429 (R2-Q18). */
|
|
495
|
+
val retryable: Boolean
|
|
496
|
+
get() = kind == FailureKind.NETWORK ||
|
|
497
|
+
(kind == FailureKind.HTTP && (result.status in HTTP_SERVER_ERRORS || result.status == HTTP_TOO_MANY_REQUESTS))
|
|
498
|
+
|
|
499
|
+
val reason: String get() = if (kind == FailureKind.HTTP) "HTTP ${result.status}" else result.responseText
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/** A failed upload of [records]. */
|
|
503
|
+
private class Failure(val records: List<Record>, val result: HttpResult, val kind: FailureKind)
|
|
504
|
+
|
|
505
|
+
private class DrainResult(val uploaded: List<Record>, val failure: Failure?)
|
|
506
|
+
|
|
507
|
+
/** The oldest pending normal record ([record], null if none) as looked up at wall time [now]. */
|
|
508
|
+
private class OldestNormal(val record: Record?, val now: Long)
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* What an armed `syncInterval` timer waits for: [dueAt] (wall ms) when the oldest pending normal record becomes
|
|
512
|
+
* due, null if it is due already; [retryAtElapsed] (elapsed-realtime ms) for a retry after a failed upload.
|
|
513
|
+
*/
|
|
514
|
+
private data class TimerKey(val dueAt: Long?, val retryAtElapsed: Long?)
|
|
515
|
+
|
|
516
|
+
/** The settings and runtime state whose change re-evaluates the `syncInterval` rule. */
|
|
517
|
+
private data class WatchedState(
|
|
518
|
+
val url: String?,
|
|
519
|
+
val autoSync: Boolean,
|
|
520
|
+
val autoSyncThreshold: Int,
|
|
521
|
+
val syncInterval: Int,
|
|
522
|
+
val disableAutoSyncOnCellular: Boolean,
|
|
523
|
+
val trackingEnabled: Boolean,
|
|
524
|
+
) {
|
|
525
|
+
companion object {
|
|
526
|
+
fun of(config: Config, runtime: RuntimeState) = WatchedState(
|
|
527
|
+
config.http.url,
|
|
528
|
+
config.http.autoSync,
|
|
529
|
+
config.http.autoSyncThreshold,
|
|
530
|
+
config.http.syncInterval,
|
|
531
|
+
config.http.disableAutoSyncOnCellular,
|
|
532
|
+
runtime.enabled,
|
|
533
|
+
)
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
private companion object {
|
|
538
|
+
const val TAG = "LT.Http"
|
|
539
|
+
const val HTTP_UNAUTHORIZED = 401
|
|
540
|
+
const val HTTP_TOO_MANY_REQUESTS = 429
|
|
541
|
+
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
|
+
const val MS_PER_SECOND = 1_000L
|
|
546
|
+
|
|
547
|
+
/** Start of the debug log line written when the `syncInterval` timer is armed (tests look for it). */
|
|
548
|
+
const val TIMER_ARMED = "syncInterval check armed"
|
|
549
|
+
val NO_TOKEN = AuthorizationManager.Token(null, refreshAttempted = false)
|
|
550
|
+
}
|
|
551
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
package com.brickssoft.locationtracking.http
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Strict RFC 8259 validator. org.json's parser is lenient (it accepts unquoted strings, single quotes, `=`
|
|
5
|
+
* separators, ...), so rendered templates are checked here before they are parsed.
|
|
6
|
+
*/
|
|
7
|
+
internal object StrictJson {
|
|
8
|
+
private const val MAX_DEPTH = 256
|
|
9
|
+
|
|
10
|
+
/** True if [text] is exactly one JSON value, optionally surrounded by whitespace. */
|
|
11
|
+
fun isValid(text: String): Boolean {
|
|
12
|
+
val parser = Parser(text)
|
|
13
|
+
parser.skipWhitespace()
|
|
14
|
+
if (!parser.value(0)) return false
|
|
15
|
+
parser.skipWhitespace()
|
|
16
|
+
return parser.atEnd
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
private class Parser(private val s: String) {
|
|
20
|
+
private var i = 0
|
|
21
|
+
|
|
22
|
+
val atEnd: Boolean get() = i == s.length
|
|
23
|
+
|
|
24
|
+
fun skipWhitespace() {
|
|
25
|
+
while (i < s.length && (s[i] == ' ' || s[i] == '\t' || s[i] == '\n' || s[i] == '\r')) i++
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
fun value(depth: Int): Boolean {
|
|
29
|
+
if (depth > MAX_DEPTH || i >= s.length) return false
|
|
30
|
+
return when (s[i]) {
|
|
31
|
+
'{' -> obj(depth + 1)
|
|
32
|
+
'[' -> array(depth + 1)
|
|
33
|
+
'"' -> string()
|
|
34
|
+
't' -> literal("true")
|
|
35
|
+
'f' -> literal("false")
|
|
36
|
+
'n' -> literal("null")
|
|
37
|
+
else -> number()
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
private fun obj(depth: Int): Boolean {
|
|
42
|
+
i++ // '{'
|
|
43
|
+
skipWhitespace()
|
|
44
|
+
if (consume('}')) return true
|
|
45
|
+
while (true) {
|
|
46
|
+
skipWhitespace()
|
|
47
|
+
if (!peek('"') || !string()) return false
|
|
48
|
+
skipWhitespace()
|
|
49
|
+
if (!consume(':')) return false
|
|
50
|
+
skipWhitespace()
|
|
51
|
+
if (!value(depth)) return false
|
|
52
|
+
skipWhitespace()
|
|
53
|
+
if (consume('}')) return true
|
|
54
|
+
if (!consume(',')) return false
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
private fun array(depth: Int): Boolean {
|
|
59
|
+
i++ // '['
|
|
60
|
+
skipWhitespace()
|
|
61
|
+
if (consume(']')) return true
|
|
62
|
+
while (true) {
|
|
63
|
+
skipWhitespace()
|
|
64
|
+
if (!value(depth)) return false
|
|
65
|
+
skipWhitespace()
|
|
66
|
+
if (consume(']')) return true
|
|
67
|
+
if (!consume(',')) return false
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
private fun string(): Boolean {
|
|
72
|
+
i++ // opening quote
|
|
73
|
+
while (i < s.length) {
|
|
74
|
+
val c = s[i++]
|
|
75
|
+
when {
|
|
76
|
+
c == '"' -> return true
|
|
77
|
+
c == '\\' -> if (!escape()) return false
|
|
78
|
+
c < ' ' -> return false
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return false
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
private fun escape(): Boolean {
|
|
85
|
+
if (i >= s.length) return false
|
|
86
|
+
return when (s[i++]) {
|
|
87
|
+
'"', '\\', '/', 'b', 'f', 'n', 'r', 't' -> true
|
|
88
|
+
'u' -> {
|
|
89
|
+
if (i + 4 > s.length) return false
|
|
90
|
+
val hex = s.substring(i, i + 4)
|
|
91
|
+
i += 4
|
|
92
|
+
hex.all { it in '0'..'9' || it in 'a'..'f' || it in 'A'..'F' }
|
|
93
|
+
}
|
|
94
|
+
else -> false
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
private fun literal(word: String): Boolean {
|
|
99
|
+
if (!s.startsWith(word, i)) return false
|
|
100
|
+
i += word.length
|
|
101
|
+
return true
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private fun number(): Boolean {
|
|
105
|
+
consume('-')
|
|
106
|
+
if (i >= s.length) return false
|
|
107
|
+
when (s[i]) {
|
|
108
|
+
'0' -> i++
|
|
109
|
+
in '1'..'9' -> digits()
|
|
110
|
+
else -> return false
|
|
111
|
+
}
|
|
112
|
+
if (consume('.') && !digits()) return false
|
|
113
|
+
if (consume('e') || consume('E')) {
|
|
114
|
+
if (!consume('+')) consume('-')
|
|
115
|
+
if (!digits()) return false
|
|
116
|
+
}
|
|
117
|
+
return true
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Consumes one or more ASCII digits; false if there is none. */
|
|
121
|
+
private fun digits(): Boolean {
|
|
122
|
+
val start = i
|
|
123
|
+
while (i < s.length && s[i] in '0'..'9') i++
|
|
124
|
+
return i > start
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
private fun peek(c: Char): Boolean = i < s.length && s[i] == c
|
|
128
|
+
|
|
129
|
+
private fun consume(c: Char): Boolean {
|
|
130
|
+
if (!peek(c)) return false
|
|
131
|
+
i++
|
|
132
|
+
return true
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|