@bricks-soft/capacitor-location-tracking 0.0.0-stage → 8.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (168) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +2759 -2
  3. package/android/build.gradle +107 -0
  4. package/android/consumer-rules.pro +42 -0
  5. package/android/src/main/AndroidManifest.xml +52 -0
  6. package/android/src/main/java/com/brickssoft/locationtracking/LocationTrackingPlugin.kt +431 -0
  7. package/android/src/main/java/com/brickssoft/locationtracking/api/LocationTrackingListener.kt +52 -0
  8. package/android/src/main/java/com/brickssoft/locationtracking/api/LocationTrackingNative.kt +267 -0
  9. package/android/src/main/java/com/brickssoft/locationtracking/api/NativeListeners.kt +270 -0
  10. package/android/src/main/java/com/brickssoft/locationtracking/api/NativeThread.kt +57 -0
  11. package/android/src/main/java/com/brickssoft/locationtracking/bridge/BridgeServices.kt +64 -0
  12. package/android/src/main/java/com/brickssoft/locationtracking/bridge/CallResponder.kt +41 -0
  13. package/android/src/main/java/com/brickssoft/locationtracking/bridge/OptionParsers.kt +281 -0
  14. package/android/src/main/java/com/brickssoft/locationtracking/bridge/PluginHandlers.kt +541 -0
  15. package/android/src/main/java/com/brickssoft/locationtracking/bridge/PluginPermissionHost.kt +76 -0
  16. package/android/src/main/java/com/brickssoft/locationtracking/bridge/WatchRegistry.kt +84 -0
  17. package/android/src/main/java/com/brickssoft/locationtracking/config/Config.kt +231 -0
  18. package/android/src/main/java/com/brickssoft/locationtracking/config/ConfigJson.kt +574 -0
  19. package/android/src/main/java/com/brickssoft/locationtracking/config/ConfigStore.kt +31 -0
  20. package/android/src/main/java/com/brickssoft/locationtracking/config/ConfigValidator.kt +170 -0
  21. package/android/src/main/java/com/brickssoft/locationtracking/config/RuntimeStateJson.kt +73 -0
  22. package/android/src/main/java/com/brickssoft/locationtracking/config/SharedPrefsConfigStore.kt +135 -0
  23. package/android/src/main/java/com/brickssoft/locationtracking/config/State.kt +45 -0
  24. package/android/src/main/java/com/brickssoft/locationtracking/core/AppDispatchers.kt +21 -0
  25. package/android/src/main/java/com/brickssoft/locationtracking/core/Clock.kt +41 -0
  26. package/android/src/main/java/com/brickssoft/locationtracking/core/Components.kt +217 -0
  27. package/android/src/main/java/com/brickssoft/locationtracking/core/Constants.kt +78 -0
  28. package/android/src/main/java/com/brickssoft/locationtracking/core/Errors.kt +27 -0
  29. package/android/src/main/java/com/brickssoft/locationtracking/core/EventBus.kt +39 -0
  30. package/android/src/main/java/com/brickssoft/locationtracking/core/ForceStopProbe.kt +63 -0
  31. package/android/src/main/java/com/brickssoft/locationtracking/core/Iso8601.kt +63 -0
  32. package/android/src/main/java/com/brickssoft/locationtracking/core/Logger.kt +68 -0
  33. package/android/src/main/java/com/brickssoft/locationtracking/core/RecordHooks.kt +47 -0
  34. package/android/src/main/java/com/brickssoft/locationtracking/core/TrackingEvent.kt +64 -0
  35. package/android/src/main/java/com/brickssoft/locationtracking/data/SqlSupport.kt +79 -0
  36. package/android/src/main/java/com/brickssoft/locationtracking/data/SqliteGeofenceStore.kt +144 -0
  37. package/android/src/main/java/com/brickssoft/locationtracking/data/SqliteLocationStore.kt +215 -0
  38. package/android/src/main/java/com/brickssoft/locationtracking/data/Stores.kt +45 -0
  39. package/android/src/main/java/com/brickssoft/locationtracking/data/TrackingDatabase.kt +111 -0
  40. package/android/src/main/java/com/brickssoft/locationtracking/device/DefaultDeviceInfoProvider.kt +104 -0
  41. package/android/src/main/java/com/brickssoft/locationtracking/device/DefaultDeviceMonitor.kt +335 -0
  42. package/android/src/main/java/com/brickssoft/locationtracking/device/DeviceMonitor.kt +36 -0
  43. package/android/src/main/java/com/brickssoft/locationtracking/device/DeviceStateMapping.kt +69 -0
  44. package/android/src/main/java/com/brickssoft/locationtracking/engine/DefaultTrackingEngine.kt +1281 -0
  45. package/android/src/main/java/com/brickssoft/locationtracking/engine/ElasticDistance.kt +68 -0
  46. package/android/src/main/java/com/brickssoft/locationtracking/engine/LocationRequests.kt +74 -0
  47. package/android/src/main/java/com/brickssoft/locationtracking/engine/MotionStateMachine.kt +291 -0
  48. package/android/src/main/java/com/brickssoft/locationtracking/engine/StationaryRegion.kt +41 -0
  49. package/android/src/main/java/com/brickssoft/locationtracking/engine/TrackingEngine.kt +60 -0
  50. package/android/src/main/java/com/brickssoft/locationtracking/engine/TrackingTimers.kt +90 -0
  51. package/android/src/main/java/com/brickssoft/locationtracking/geofence/DefaultGeofenceManager.kt +899 -0
  52. package/android/src/main/java/com/brickssoft/locationtracking/geofence/DwellTracker.kt +80 -0
  53. package/android/src/main/java/com/brickssoft/locationtracking/geofence/GeofenceManager.kt +33 -0
  54. package/android/src/main/java/com/brickssoft/locationtracking/geofence/PolygonMath.kt +194 -0
  55. package/android/src/main/java/com/brickssoft/locationtracking/heartbeat/DefaultHeartbeatScheduler.kt +713 -0
  56. package/android/src/main/java/com/brickssoft/locationtracking/heartbeat/HeartbeatAlarmReceiver.kt +69 -0
  57. package/android/src/main/java/com/brickssoft/locationtracking/heartbeat/HeartbeatAlarms.kt +100 -0
  58. package/android/src/main/java/com/brickssoft/locationtracking/heartbeat/HeartbeatScheduler.kt +25 -0
  59. package/android/src/main/java/com/brickssoft/locationtracking/heartbeat/HeartbeatWindow.kt +179 -0
  60. package/android/src/main/java/com/brickssoft/locationtracking/http/AuthorizationManager.kt +213 -0
  61. package/android/src/main/java/com/brickssoft/locationtracking/http/BodyBuilder.kt +73 -0
  62. package/android/src/main/java/com/brickssoft/locationtracking/http/HttpSupport.kt +118 -0
  63. package/android/src/main/java/com/brickssoft/locationtracking/http/HttpSyncer.kt +18 -0
  64. package/android/src/main/java/com/brickssoft/locationtracking/http/OkHttpSyncer.kt +551 -0
  65. package/android/src/main/java/com/brickssoft/locationtracking/http/StrictJson.kt +135 -0
  66. package/android/src/main/java/com/brickssoft/locationtracking/http/SyncPolicy.kt +109 -0
  67. package/android/src/main/java/com/brickssoft/locationtracking/http/TemplateRenderer.kt +143 -0
  68. package/android/src/main/java/com/brickssoft/locationtracking/logging/FileLogger.kt +461 -0
  69. package/android/src/main/java/com/brickssoft/locationtracking/logging/LogFileProvider.kt +9 -0
  70. package/android/src/main/java/com/brickssoft/locationtracking/logging/LogFormatter.kt +148 -0
  71. package/android/src/main/java/com/brickssoft/locationtracking/logging/LogStore.kt +30 -0
  72. package/android/src/main/java/com/brickssoft/locationtracking/model/DeviceModels.kt +176 -0
  73. package/android/src/main/java/com/brickssoft/locationtracking/model/GeofenceSpec.kt +25 -0
  74. package/android/src/main/java/com/brickssoft/locationtracking/model/ModelJson.kt +550 -0
  75. package/android/src/main/java/com/brickssoft/locationtracking/model/Record.kt +106 -0
  76. package/android/src/main/java/com/brickssoft/locationtracking/model/TrackedLocation.kt +53 -0
  77. package/android/src/main/java/com/brickssoft/locationtracking/permission/BackgroundRationaleDialog.kt +85 -0
  78. package/android/src/main/java/com/brickssoft/locationtracking/permission/CurrentActivityTracker.kt +72 -0
  79. package/android/src/main/java/com/brickssoft/locationtracking/permission/DefaultPermissionManager.kt +333 -0
  80. package/android/src/main/java/com/brickssoft/locationtracking/permission/PermissionManager.kt +54 -0
  81. package/android/src/main/java/com/brickssoft/locationtracking/position/DefaultPositionService.kt +277 -0
  82. package/android/src/main/java/com/brickssoft/locationtracking/position/PositionService.kt +39 -0
  83. package/android/src/main/java/com/brickssoft/locationtracking/position/PositionWatch.kt +115 -0
  84. package/android/src/main/java/com/brickssoft/locationtracking/processing/DefaultLocationProcessor.kt +93 -0
  85. package/android/src/main/java/com/brickssoft/locationtracking/processing/DefaultOdometer.kt +99 -0
  86. package/android/src/main/java/com/brickssoft/locationtracking/processing/DefaultRecordFactory.kt +166 -0
  87. package/android/src/main/java/com/brickssoft/locationtracking/processing/Geo.kt +56 -0
  88. package/android/src/main/java/com/brickssoft/locationtracking/processing/KalmanFilter.kt +72 -0
  89. package/android/src/main/java/com/brickssoft/locationtracking/processing/Processing.kt +48 -0
  90. package/android/src/main/java/com/brickssoft/locationtracking/provider/Backends.kt +187 -0
  91. package/android/src/main/java/com/brickssoft/locationtracking/provider/DefaultProviderFactory.kt +169 -0
  92. package/android/src/main/java/com/brickssoft/locationtracking/provider/ProviderPackaging.kt +135 -0
  93. package/android/src/main/java/com/brickssoft/locationtracking/provider/android/AndroidActivityBackend.kt +25 -0
  94. package/android/src/main/java/com/brickssoft/locationtracking/provider/android/AndroidGeofenceBackend.kt +181 -0
  95. package/android/src/main/java/com/brickssoft/locationtracking/provider/android/AndroidGeofenceReceiver.kt +115 -0
  96. package/android/src/main/java/com/brickssoft/locationtracking/provider/android/AndroidLocationBackend.kt +265 -0
  97. package/android/src/main/java/com/brickssoft/locationtracking/provider/android/AndroidProviderBundle.kt +31 -0
  98. package/android/src/main/java/com/brickssoft/locationtracking/provider/android/LocationProviders.kt +98 -0
  99. package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsActivityBackend.kt +80 -0
  100. package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsActivityReceiver.kt +62 -0
  101. package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsErrors.kt +72 -0
  102. package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsGeofenceBackend.kt +152 -0
  103. package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsGeofenceReceiver.kt +78 -0
  104. package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsLocationBackend.kt +176 -0
  105. package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsProviderBundle.kt +62 -0
  106. package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsReceiverSupport.kt +88 -0
  107. package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsTasks.kt +55 -0
  108. package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsActivityBackend.kt +71 -0
  109. package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsActivityReceiver.kt +57 -0
  110. package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsErrors.kt +76 -0
  111. package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsGeofenceBackend.kt +116 -0
  112. package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsGeofenceReceiver.kt +67 -0
  113. package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsIntents.kt +83 -0
  114. package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsLocationBackend.kt +197 -0
  115. package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsProviderBundle.kt +55 -0
  116. package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsTasks.kt +44 -0
  117. package/android/src/main/java/com/brickssoft/locationtracking/record/RecordSink.kt +95 -0
  118. package/android/src/main/java/com/brickssoft/locationtracking/service/BootCountStore.kt +44 -0
  119. package/android/src/main/java/com/brickssoft/locationtracking/service/BootReceiver.kt +175 -0
  120. package/android/src/main/java/com/brickssoft/locationtracking/service/DefaultServiceController.kt +196 -0
  121. package/android/src/main/java/com/brickssoft/locationtracking/service/LocationTrackingService.kt +319 -0
  122. package/android/src/main/java/com/brickssoft/locationtracking/service/NotificationActionReceiver.kt +42 -0
  123. package/android/src/main/java/com/brickssoft/locationtracking/service/NotificationFactory.kt +338 -0
  124. package/android/src/main/java/com/brickssoft/locationtracking/service/ServiceCommands.kt +204 -0
  125. package/android/src/main/java/com/brickssoft/locationtracking/service/ServiceController.kt +22 -0
  126. package/android/src/main/java/com/brickssoft/locationtracking/service/ServiceDeps.kt +61 -0
  127. package/android/src/main/java/com/brickssoft/locationtracking/service/WhileInUseStartCheck.kt +115 -0
  128. package/android/src/main/java/com/brickssoft/locationtracking/settings/DefaultDeviceSettings.kt +79 -0
  129. package/android/src/main/java/com/brickssoft/locationtracking/settings/DeviceSettings.kt +21 -0
  130. package/android/src/main/java/com/brickssoft/locationtracking/settings/OemPowerManagers.kt +198 -0
  131. package/android/src/main/res/drawable/lt_ic_notification.xml +11 -0
  132. package/android/src/main/res/values/lt_strings.xml +11 -0
  133. package/android/src/main/res/xml/lt_file_paths.xml +5 -0
  134. package/dist/esm/definitions.d.ts +719 -0
  135. package/dist/esm/definitions.js +2 -0
  136. package/dist/esm/definitions.js.map +1 -0
  137. package/dist/esm/events.d.ts +50 -0
  138. package/dist/esm/events.js +52 -0
  139. package/dist/esm/events.js.map +1 -0
  140. package/dist/esm/index.d.ts +3 -0
  141. package/dist/esm/index.js +5 -0
  142. package/dist/esm/index.js.map +1 -0
  143. package/dist/esm/plugin.d.ts +3 -0
  144. package/dist/esm/plugin.js +10 -0
  145. package/dist/esm/plugin.js.map +1 -0
  146. package/dist/esm/web/config.d.ts +19 -0
  147. package/dist/esm/web/config.js +243 -0
  148. package/dist/esm/web/config.js.map +1 -0
  149. package/dist/esm/web/device.d.ts +9 -0
  150. package/dist/esm/web/device.js +90 -0
  151. package/dist/esm/web/device.js.map +1 -0
  152. package/dist/esm/web/errors.d.ts +14 -0
  153. package/dist/esm/web/errors.js +19 -0
  154. package/dist/esm/web/errors.js.map +1 -0
  155. package/dist/esm/web/geolocation.d.ts +26 -0
  156. package/dist/esm/web/geolocation.js +117 -0
  157. package/dist/esm/web/geolocation.js.map +1 -0
  158. package/dist/esm/web/location.d.ts +7 -0
  159. package/dist/esm/web/location.js +70 -0
  160. package/dist/esm/web/location.js.map +1 -0
  161. package/dist/esm/web.d.ts +164 -0
  162. package/dist/esm/web.js +328 -0
  163. package/dist/esm/web.js.map +1 -0
  164. package/dist/plugin.cjs.js +945 -0
  165. package/dist/plugin.cjs.js.map +1 -0
  166. package/dist/plugin.js +948 -0
  167. package/dist/plugin.js.map +1 -0
  168. package/package.json +60 -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
+ }