@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,69 @@
1
+ package com.brickssoft.locationtracking.heartbeat
2
+
3
+ import android.content.BroadcastReceiver
4
+ import android.content.Context
5
+ import android.content.Intent
6
+ import com.brickssoft.locationtracking.config.ConfigStore
7
+ import com.brickssoft.locationtracking.core.Components
8
+ import com.brickssoft.locationtracking.core.Constants
9
+ import com.brickssoft.locationtracking.core.Logger
10
+ import com.brickssoft.locationtracking.engine.TrackingEngine
11
+ import com.brickssoft.locationtracking.service.ServiceController
12
+ import kotlinx.coroutines.launch
13
+ import kotlin.coroutines.cancellation.CancellationException
14
+
15
+ /**
16
+ * Receives the heartbeat PendingIntent alarms (exact and backup). Runs [HeartbeatScheduler.onAlarm] and, if
17
+ * tracking is enabled but the foreground service is gone (the process was killed), restores tracking with
18
+ * `engine.restore("restore")`: together with `START_STICKY` this is how tracking comes back after task killers.
19
+ */
20
+ class HeartbeatAlarmReceiver : BroadcastReceiver() {
21
+ override fun onReceive(context: Context, intent: Intent) {
22
+ if (intent.action != Constants.ACTION_HEARTBEAT) return
23
+ val trigger = HeartbeatIntents.triggerOf(intent)
24
+ val pending = goAsync()
25
+ try {
26
+ val c = Components.get(context)
27
+ // invokeOnCompletion also finishes the broadcast if the coroutine is cancelled before it starts.
28
+ c.scope.launch {
29
+ deliver(trigger, c.heartbeat, c.configStore, lazy { c.serviceController }, lazy { c.engine })
30
+ }.invokeOnCompletion { pending.finish() }
31
+ } catch (e: Exception) {
32
+ Logger.e(TAG, "heartbeat alarm delivery failed", e)
33
+ pending.finish()
34
+ }
35
+ }
36
+
37
+ internal companion object {
38
+ private const val TAG = DefaultHeartbeatScheduler.TAG
39
+
40
+ /**
41
+ * The post-alarm decision: run the heartbeat, then restore tracking if it is enabled while the foreground
42
+ * service is not running. Failures are logged; the restore check runs even if the heartbeat failed.
43
+ */
44
+ suspend fun deliver(
45
+ trigger: HeartbeatTrigger,
46
+ heartbeat: HeartbeatScheduler,
47
+ configStore: ConfigStore,
48
+ serviceController: Lazy<ServiceController>,
49
+ engine: Lazy<TrackingEngine>,
50
+ ) {
51
+ try {
52
+ heartbeat.onAlarm(trigger)
53
+ } catch (e: CancellationException) {
54
+ throw e
55
+ } catch (e: Exception) {
56
+ Logger.e(TAG, "heartbeat alarm ($trigger) failed", e)
57
+ }
58
+ if (!configStore.runtime.value.enabled || serviceController.value.isRunning) return
59
+ Logger.i(TAG, "tracking is enabled but the service is not running; restoring")
60
+ try {
61
+ engine.value.restore("restore")
62
+ } catch (e: CancellationException) {
63
+ throw e
64
+ } catch (e: Exception) {
65
+ Logger.e(TAG, "restore after heartbeat alarm failed", e)
66
+ }
67
+ }
68
+ }
69
+ }
@@ -0,0 +1,100 @@
1
+ package com.brickssoft.locationtracking.heartbeat
2
+
3
+ import android.annotation.SuppressLint
4
+ import android.app.AlarmManager
5
+ import android.app.PendingIntent
6
+ import android.content.Context
7
+ import android.content.Intent
8
+ import android.os.Handler
9
+ import com.brickssoft.locationtracking.core.Constants
10
+ import com.brickssoft.locationtracking.core.Logger
11
+
12
+ /** The subset of [AlarmManager] the heartbeat uses. Injectable so tests can simulate a refusing AlarmManager. */
13
+ interface HeartbeatAlarms {
14
+ /** @throws SecurityException if exact alarms are not allowed. */
15
+ fun setExactAndAllowWhileIdle(type: Int, triggerAtMillis: Long, operation: PendingIntent)
16
+
17
+ fun setExact(type: Int, triggerAtMillis: Long, tag: String, listener: AlarmManager.OnAlarmListener, handler: Handler)
18
+
19
+ fun setAndAllowWhileIdle(type: Int, triggerAtMillis: Long, operation: PendingIntent)
20
+
21
+ fun cancel(operation: PendingIntent)
22
+
23
+ fun cancel(listener: AlarmManager.OnAlarmListener)
24
+ }
25
+
26
+ /**
27
+ * [HeartbeatAlarms] backed by the system [AlarmManager]. The plugin declares no exact-alarm permission: the
28
+ * scheduler calls [setExactAndAllowWhileIdle] only when `canScheduleExactAlarms()` (battery-exempt apps) and
29
+ * catches the [SecurityException] otherwise; listener-based [setExact] needs no permission.
30
+ */
31
+ class SystemHeartbeatAlarms(context: Context) : HeartbeatAlarms {
32
+ private val appContext: Context = context.applicationContext ?: context
33
+ private val alarmManager: AlarmManager? by lazy {
34
+ (appContext.getSystemService(Context.ALARM_SERVICE) as? AlarmManager).also {
35
+ if (it == null) Logger.e(TAG, "AlarmManager unavailable; heartbeat alarms are disabled")
36
+ }
37
+ }
38
+
39
+ @SuppressLint("ScheduleExactAlarm", "MissingPermission")
40
+ override fun setExactAndAllowWhileIdle(type: Int, triggerAtMillis: Long, operation: PendingIntent) {
41
+ alarmManager?.setExactAndAllowWhileIdle(type, triggerAtMillis, operation)
42
+ }
43
+
44
+ @SuppressLint("ScheduleExactAlarm", "MissingPermission")
45
+ override fun setExact(
46
+ type: Int,
47
+ triggerAtMillis: Long,
48
+ tag: String,
49
+ listener: AlarmManager.OnAlarmListener,
50
+ handler: Handler,
51
+ ) {
52
+ alarmManager?.setExact(type, triggerAtMillis, tag, listener, handler)
53
+ }
54
+
55
+ override fun setAndAllowWhileIdle(type: Int, triggerAtMillis: Long, operation: PendingIntent) {
56
+ alarmManager?.setAndAllowWhileIdle(type, triggerAtMillis, operation)
57
+ }
58
+
59
+ override fun cancel(operation: PendingIntent) {
60
+ alarmManager?.cancel(operation)
61
+ }
62
+
63
+ override fun cancel(listener: AlarmManager.OnAlarmListener) {
64
+ alarmManager?.cancel(listener)
65
+ }
66
+
67
+ private companion object {
68
+ const val TAG = DefaultHeartbeatScheduler.TAG
69
+ }
70
+ }
71
+
72
+ /** The explicit, immutable heartbeat broadcast (request code [Constants.RC_HEARTBEAT], [Constants.ACTION_HEARTBEAT]). */
73
+ internal object HeartbeatIntents {
74
+ /** [HeartbeatTrigger] name of the alarm that fired. */
75
+ const val EXTRA_TRIGGER = "com.brickssoft.locationtracking.EXTRA_HEARTBEAT_TRIGGER"
76
+
77
+ fun intent(context: Context, trigger: HeartbeatTrigger): Intent =
78
+ Intent(context, HeartbeatAlarmReceiver::class.java)
79
+ .setAction(Constants.ACTION_HEARTBEAT)
80
+ .putExtra(EXTRA_TRIGGER, trigger.name)
81
+
82
+ /** The heartbeat PendingIntent; [trigger] is updated in place (one PendingIntent, so one alarm, at a time). */
83
+ fun pendingIntent(context: Context, trigger: HeartbeatTrigger): PendingIntent =
84
+ PendingIntent.getBroadcast(context, Constants.RC_HEARTBEAT, intent(context, trigger), Constants.piImmutable())
85
+
86
+ /** The heartbeat PendingIntent if it exists (for example armed by a previous process), without creating it. */
87
+ fun existing(context: Context): PendingIntent? =
88
+ PendingIntent.getBroadcast(
89
+ context,
90
+ Constants.RC_HEARTBEAT,
91
+ Intent(context, HeartbeatAlarmReceiver::class.java).setAction(Constants.ACTION_HEARTBEAT),
92
+ PendingIntent.FLAG_NO_CREATE or PendingIntent.FLAG_IMMUTABLE,
93
+ )
94
+
95
+ /** The trigger carried by [intent]; [HeartbeatTrigger.BACKUP_ALARM] if missing or unknown. */
96
+ fun triggerOf(intent: Intent?): HeartbeatTrigger {
97
+ val name = intent?.getStringExtra(EXTRA_TRIGGER) ?: return HeartbeatTrigger.BACKUP_ALARM
98
+ return HeartbeatTrigger.entries.firstOrNull { it.name == name } ?: HeartbeatTrigger.BACKUP_ALARM
99
+ }
100
+ }
@@ -0,0 +1,25 @@
1
+ package com.brickssoft.locationtracking.heartbeat
2
+
3
+ import com.brickssoft.locationtracking.model.HeartbeatStatus
4
+ import com.brickssoft.locationtracking.model.Record
5
+
6
+ enum class HeartbeatTrigger {
7
+ EXACT_ALARM,
8
+ LISTENER_ALARM,
9
+ BACKUP_ALARM,
10
+ }
11
+
12
+ /** Creates a `heartbeat` record when no record was created for `heartbeat.minInterval` while tracking. */
13
+ interface HeartbeatScheduler {
14
+ /** Starts the window from runtime.lastRecord* (or now); observes config itself. */
15
+ fun start()
16
+
17
+ fun stop()
18
+
19
+ /** Every persisted record restarts the window. */
20
+ fun onRecordRecorded(record: Record)
21
+
22
+ suspend fun onAlarm(trigger: HeartbeatTrigger)
23
+
24
+ suspend fun status(): HeartbeatStatus
25
+ }
@@ -0,0 +1,179 @@
1
+ package com.brickssoft.locationtracking.heartbeat
2
+
3
+ import com.brickssoft.locationtracking.model.HeartbeatStrategy
4
+
5
+ /**
6
+ * One heartbeat window: when the next heartbeat is due and how it is scheduled (architecture §3).
7
+ *
8
+ * Pure Kotlin, no Android types. Every `*Elapsed` value is in the current boot's elapsed-realtime timebase
9
+ * (ms), which is what `AlarmManager.ELAPSED_REALTIME_WAKEUP` uses.
10
+ *
11
+ * @property baseElapsed start of the window: the last record (or the no-record anchor), never before the session start.
12
+ * @property dueElapsed `base + minInterval` (never before the last attempt + minInterval): when the heartbeat is due.
13
+ * @property deadlineElapsed `base + maxInterval`: the heartbeat should exist by then.
14
+ * @property backupAtElapsed trigger of the allow-while-idle backup alarm (`due`, or paced 9 min apart while idle).
15
+ * @property strategy how the window is armed; [HeartbeatStrategy.DISABLED] if heartbeat or tracking is off.
16
+ * @property nextHeartbeatAt wall-clock estimate of the next heartbeat (epoch ms), or null when disabled.
17
+ * @property minIntervalMs the effective min interval (floored at [MIN_INTERVAL_FLOOR_S]).
18
+ */
19
+ internal data class HeartbeatWindow(
20
+ val baseElapsed: Long,
21
+ val dueElapsed: Long,
22
+ val deadlineElapsed: Long,
23
+ val backupAtElapsed: Long,
24
+ val strategy: HeartbeatStrategy,
25
+ val nextHeartbeatAt: Long?,
26
+ val minIntervalMs: Long,
27
+ ) {
28
+ /**
29
+ * Elapsed time at which the heartbeat is expected: the backup alarm while idle-paced (the listener alarm is
30
+ * deferred in deep idle), otherwise [dueElapsed]. This is the heartbeat metadata's `next_at` and
31
+ * `getHeartbeatStatus().nextHeartbeatAt`, converted to wall-clock time.
32
+ */
33
+ val expectedFireElapsed: Long
34
+ get() = if (strategy == HeartbeatStrategy.IDLE_PACED) backupAtElapsed else dueElapsed
35
+
36
+ /**
37
+ * True if a heartbeat must be created now: `now - base >= minInterval - 1 s` (and the previous attempt is at
38
+ * least `minInterval - 1 s` ago).
39
+ */
40
+ fun shouldFire(nowElapsed: Long): Boolean = nowElapsed >= dueElapsed - FIRE_TOLERANCE_MS
41
+
42
+ /**
43
+ * True if the alarms armed for [armed] must be set again for this window (null [armed]: nothing is armed).
44
+ *
45
+ * Every record moves the window later, and a moving phone records every few seconds. Setting the alarms for each
46
+ * record costs AlarmManager calls, so a due time that moved later by less than [REARM_THRESHOLD_MS] keeps the
47
+ * armed alarms. Such an alarm fires up to [REARM_THRESHOLD_MS] before the real due time; the alarm handler then
48
+ * finds the window not due, creates no heartbeat and arms the real due time.
49
+ *
50
+ * The alarms are always set again when:
51
+ * - the strategy differs;
52
+ * - a trigger time moved earlier (the armed alarm would fire after the heartbeat is due);
53
+ * - the strategy is [HeartbeatStrategy.IDLE_PACED] and the backup time moved at all. An early backup alarm uses
54
+ * one of the few allow-while-idle alarms Android grants per hour and would move the next backup about
55
+ * 9 minutes later ([IDLE_BACKUP_SPACING_MS]). Records are rare in deep idle, so this costs little.
56
+ */
57
+ fun needsRearm(armed: HeartbeatWindow?): Boolean {
58
+ if (armed == null || armed.strategy != strategy) return true
59
+ val dueMove = dueElapsed - armed.dueElapsed
60
+ val backupMove = backupAtElapsed - armed.backupAtElapsed
61
+ if (dueMove < 0L || backupMove < 0L) return true
62
+ if (strategy == HeartbeatStrategy.IDLE_PACED && backupMove != 0L) return true
63
+ // Outside idle pacing the backup time equals the due time, so the due time decides.
64
+ return dueMove >= REARM_THRESHOLD_MS
65
+ }
66
+
67
+ companion object {
68
+ /** Tolerance of [shouldFire], so an alarm delivered a little early still counts. */
69
+ const val FIRE_TOLERANCE_MS = 1_000L
70
+
71
+ /**
72
+ * A due time that moved later by less than this keeps the armed alarms (see [needsRearm]). With a record every
73
+ * 5 s, the alarms are set once per 30 s of records instead of once per record.
74
+ */
75
+ const val REARM_THRESHOLD_MS = 30_000L
76
+
77
+ /** Minimum spacing of allow-while-idle backup alarms while idle and not battery-exempt. */
78
+ const val IDLE_BACKUP_SPACING_MS = 9 * 60_000L
79
+
80
+ /** Floor of `heartbeat.minInterval` (the documented minimum). */
81
+ const val MIN_INTERVAL_FLOOR_S = 60
82
+
83
+ /**
84
+ * Computes the window.
85
+ *
86
+ * Base: the last record's elapsed time if it was recorded in this boot; otherwise it is derived from the
87
+ * wall clock (`nowElapsed - (now - lastRecordAt)`); if there is no record, [noRecordBaseElapsed] (or
88
+ * [nowElapsed]). A last record older than [trackingStartedAt] belongs to an earlier tracking session, so
89
+ * the base is then the session start instead (otherwise every `start()` after a pause would find the window
90
+ * overdue). The base never lies in the future. When the boot count is unknown (-1) on either side, the
91
+ * record counts as "this boot" as long as its elapsed time is not ahead of [nowElapsed].
92
+ *
93
+ * @param lastRecordAt wall time of the last record (epoch ms), or null if there is none.
94
+ * @param lastRecordElapsed elapsed time of the last record.
95
+ * @param lastRecordBootCount boot count of the last record.
96
+ * @param minIntervalSec `heartbeat.minInterval` (floored at [MIN_INTERVAL_FLOOR_S]).
97
+ * @param maxIntervalSec `heartbeat.maxInterval` (at least the min interval).
98
+ * @param isIdle device is in deep idle (Doze).
99
+ * @param canExact `canScheduleExactAlarms()`: battery-exempt, alarms are not rate-limited.
100
+ * @param lastBackupFireElapsed when a backup alarm last fired in this boot, or null.
101
+ * @param enabled tracking and heartbeat are both enabled; false yields [HeartbeatStrategy.DISABLED].
102
+ * @param noRecordBaseElapsed base to use when there is no record (a stable anchor), default [nowElapsed].
103
+ * @param lastAttemptElapsed elapsed time of the last heartbeat attempt: the next one is due no earlier than
104
+ * one min interval later, even if that attempt failed to produce a record.
105
+ * @param trackingStartedAt wall time the current tracking session started (`runtime.trackingStartedAt`).
106
+ */
107
+ fun compute(
108
+ lastRecordAt: Long?,
109
+ lastRecordElapsed: Long?,
110
+ lastRecordBootCount: Int?,
111
+ now: Long,
112
+ nowElapsed: Long,
113
+ bootCount: Int,
114
+ minIntervalSec: Int,
115
+ maxIntervalSec: Int,
116
+ isIdle: Boolean,
117
+ canExact: Boolean,
118
+ lastBackupFireElapsed: Long?,
119
+ enabled: Boolean = true,
120
+ noRecordBaseElapsed: Long? = null,
121
+ lastAttemptElapsed: Long? = null,
122
+ trackingStartedAt: Long? = null,
123
+ ): HeartbeatWindow {
124
+ val minMs = minIntervalSec.coerceAtLeast(MIN_INTERVAL_FLOOR_S) * 1_000L
125
+ val maxMs = (maxIntervalSec * 1_000L).coerceAtLeast(minMs)
126
+
127
+ val base = when {
128
+ lastRecordAt == null -> noRecordBaseElapsed ?: nowElapsed
129
+ lastRecordElapsed != null && sameBoot(lastRecordElapsed, lastRecordBootCount, nowElapsed, bootCount) ->
130
+ lastRecordElapsed
131
+ else -> nowElapsed - (now - lastRecordAt)
132
+ }.coerceAtLeast(sessionStartElapsed(lastRecordAt, trackingStartedAt, now, nowElapsed) ?: Long.MIN_VALUE)
133
+ .coerceAtMost(nowElapsed)
134
+ val due = maxOf(base + minMs, lastAttemptElapsed?.plus(minMs) ?: Long.MIN_VALUE)
135
+
136
+ // A backup fire from a previous boot (ahead of now) says nothing about the current idle quota.
137
+ val lastBackup = lastBackupFireElapsed?.takeIf { it <= nowElapsed }
138
+ val backupAt = if (isIdle && !canExact && lastBackup != null) {
139
+ maxOf(due, lastBackup + IDLE_BACKUP_SPACING_MS)
140
+ } else {
141
+ due
142
+ }
143
+
144
+ val strategy = when {
145
+ !enabled -> HeartbeatStrategy.DISABLED
146
+ canExact -> HeartbeatStrategy.EXACT
147
+ isIdle -> HeartbeatStrategy.IDLE_PACED
148
+ else -> HeartbeatStrategy.LISTENER_WITH_BACKUP
149
+ }
150
+ val window = HeartbeatWindow(
151
+ baseElapsed = base,
152
+ dueElapsed = due,
153
+ deadlineElapsed = base + maxMs,
154
+ backupAtElapsed = backupAt,
155
+ strategy = strategy,
156
+ nextHeartbeatAt = null,
157
+ minIntervalMs = minMs,
158
+ )
159
+ if (strategy == HeartbeatStrategy.DISABLED) return window
160
+ // An overdue window fires as soon as it is armed.
161
+ val next = now + (maxOf(window.expectedFireElapsed, nowElapsed) - nowElapsed)
162
+ return window.copy(nextHeartbeatAt = next)
163
+ }
164
+
165
+ /** Elapsed time of the session start, if the last record predates it (and the start is not in the future). */
166
+ private fun sessionStartElapsed(lastRecordAt: Long?, trackingStartedAt: Long?, now: Long, nowElapsed: Long): Long? {
167
+ if (lastRecordAt == null || trackingStartedAt == null) return null
168
+ if (lastRecordAt >= trackingStartedAt || trackingStartedAt > now) return null
169
+ return nowElapsed - (now - trackingStartedAt)
170
+ }
171
+
172
+ private fun sameBoot(recordElapsed: Long, recordBootCount: Int?, nowElapsed: Long, bootCount: Int): Boolean =
173
+ if (recordBootCount != null && recordBootCount >= 0 && bootCount >= 0) {
174
+ recordBootCount == bootCount
175
+ } else {
176
+ recordElapsed <= nowElapsed
177
+ }
178
+ }
179
+ }
@@ -0,0 +1,213 @@
1
+ package com.brickssoft.locationtracking.http
2
+
3
+ import com.brickssoft.locationtracking.config.AuthorizationConfig
4
+ import com.brickssoft.locationtracking.config.ConfigStore
5
+ import com.brickssoft.locationtracking.config.HttpConfig
6
+ import com.brickssoft.locationtracking.config.RefreshPayloadEncoding
7
+ import com.brickssoft.locationtracking.core.AppDispatchers
8
+ import com.brickssoft.locationtracking.core.Clock
9
+ import com.brickssoft.locationtracking.core.EventBus
10
+ import com.brickssoft.locationtracking.core.Iso8601
11
+ import com.brickssoft.locationtracking.core.Logger
12
+ import com.brickssoft.locationtracking.core.TrackingEvent
13
+ import com.brickssoft.locationtracking.model.JsonUtil
14
+ import kotlinx.coroutines.currentCoroutineContext
15
+ import kotlinx.coroutines.ensureActive
16
+ import kotlinx.coroutines.sync.Mutex
17
+ import kotlinx.coroutines.sync.withLock
18
+ import okhttp3.FormBody
19
+ import okhttp3.OkHttpClient
20
+ import org.json.JSONObject
21
+ import java.io.IOException
22
+
23
+ /**
24
+ * JWT access-token handling for `http.authorization` (architecture §2).
25
+ *
26
+ * - The token is refreshed before a request when it is missing or `expires > 0 && now >= expires - 60 s`, and after
27
+ * a 401 (at most once per upload; the caller enforces that).
28
+ * - A refresh POSTs `refreshPayload` (string values with `{refreshToken}` substituted, JSON or form encoded) with
29
+ * `refreshHeaders` to `refreshUrl`, reads `accessToken|access_token`, `refreshToken|refresh_token` and
30
+ * `expires|expires_at` (epoch; seconds if < 1e12) or `expires_in` (relative seconds), persists the new tokens with
31
+ * `configStore.update` and emits an `Authorization` event (also on failure).
32
+ * - Refreshes are single-flight: callers that waited while another caller replaced their stale token reuse it.
33
+ *
34
+ * Token values are never logged.
35
+ */
36
+ internal class AuthorizationManager(
37
+ private val configStore: ConfigStore,
38
+ private val events: EventBus,
39
+ private val clock: Clock,
40
+ private val dispatchers: AppDispatchers,
41
+ ) {
42
+ private val mutex = Mutex()
43
+
44
+ /**
45
+ * A refreshed token whose whole lifetime already fell inside the refresh margin. It is not refreshed again before
46
+ * it really expires; otherwise every upload would first refresh it.
47
+ */
48
+ @Volatile
49
+ private var shortLivedToken: String? = null
50
+
51
+ /** The access token to send ([value] null = no Authorization header) and whether a refresh was attempted. */
52
+ data class Token(val value: String?, val refreshAttempted: Boolean)
53
+
54
+ /** Tokens read from a refresh response; [expires] is epoch ms or -1 if unknown. */
55
+ data class Tokens(val accessToken: String, val refreshToken: String?, val expires: Long)
56
+
57
+ /** True if requests configured by [http] carry the bearer token (authorization set, no explicit header). */
58
+ fun appliesTo(http: HttpConfig): Boolean =
59
+ http.authorization != null && !HttpSupport.hasHeader(http.headers, HttpSupport.AUTHORIZATION)
60
+
61
+ /**
62
+ * The token for the next request, refreshing first if it is missing or about to expire (and refreshable), unless
63
+ * [mayRefresh] is false.
64
+ */
65
+ suspend fun tokenForRequest(client: OkHttpClient, mayRefresh: Boolean = true): Token {
66
+ val auth = configStore.config.value.http.authorization ?: return Token(null, false)
67
+ val current = auth.accessToken.nonBlank()
68
+ if (!mayRefresh || !needsRefresh(auth) || !canRefresh(auth)) return Token(current, false)
69
+ return Token(refresh(client, current) ?: current, true)
70
+ }
71
+
72
+ /**
73
+ * Refreshes the token (single-flight) unless another caller already replaced [staleToken] with a token that
74
+ * does not need a refresh. Returns the token to use, or null if there is none (refresh failed or impossible).
75
+ */
76
+ suspend fun refresh(client: OkHttpClient, staleToken: String?): String? = mutex.withLock {
77
+ val auth = configStore.config.value.http.authorization ?: return@withLock null
78
+ val current = auth.accessToken.nonBlank()
79
+ if (current != null && current != staleToken && !needsRefresh(auth)) return@withLock current
80
+ if (!canRefresh(auth)) return@withLock null
81
+ performRefresh(client, auth)
82
+ }
83
+
84
+ private fun canRefresh(auth: AuthorizationConfig): Boolean = !auth.refreshUrl.isNullOrBlank()
85
+
86
+ private fun needsRefresh(auth: AuthorizationConfig): Boolean {
87
+ val token = auth.accessToken
88
+ if (token.isNullOrBlank()) return true
89
+ if (auth.expires <= 0) return false
90
+ val now = clock.now()
91
+ return now >= auth.expires || (now >= auth.expires - EXPIRY_MARGIN_MS && token != shortLivedToken)
92
+ }
93
+
94
+ private suspend fun performRefresh(client: OkHttpClient, auth: AuthorizationConfig): String? {
95
+ val url = HttpSupport.parseUrl(auth.refreshUrl)
96
+ if (url == null) {
97
+ Logger.e(TAG, "authorization.refreshUrl is not a valid http(s) URL")
98
+ emit(false, 0, "authorization.refreshUrl is not a valid http(s) URL", null)
99
+ return null
100
+ }
101
+ val (contentType, body) = refreshBody(auth)
102
+ val headers = HttpSupport.headers(contentType, auth.refreshHeaders, bearerToken = null)
103
+ val request = HttpSupport.request(url, "POST", headers, body)
104
+ val response = try {
105
+ HttpSupport.execute(client, request, dispatchers.io)
106
+ } catch (e: IOException) {
107
+ currentCoroutineContext().ensureActive()
108
+ Logger.w(TAG, "token refresh failed: ${e.message}")
109
+ emit(false, 0, e.message ?: e.javaClass.simpleName, null)
110
+ return null
111
+ }
112
+ val json = JsonUtil.parseObject(response.body)
113
+ if (!response.isSuccessful) {
114
+ Logger.w(TAG, "token refresh failed: HTTP ${response.status}")
115
+ emit(false, response.status, "HTTP ${response.status}", json?.toString())
116
+ return null
117
+ }
118
+ val tokens = parseTokens(json, clock.now())
119
+ if (tokens == null) {
120
+ Logger.w(TAG, "token refresh response (HTTP ${response.status}) has no accessToken")
121
+ emit(false, response.status, "refresh response has no accessToken", json?.toString())
122
+ return null
123
+ }
124
+ configStore.update { config ->
125
+ // Do not resurrect an authorization config removed while the refresh was in flight.
126
+ val current = config.http.authorization ?: return@update config
127
+ val updated = current.copy(
128
+ accessToken = tokens.accessToken,
129
+ refreshToken = tokens.refreshToken ?: current.refreshToken,
130
+ expires = tokens.expires,
131
+ )
132
+ config.copy(http = config.http.copy(authorization = updated))
133
+ }
134
+ val shortLived = tokens.expires > 0 && tokens.expires - clock.now() <= EXPIRY_MARGIN_MS
135
+ shortLivedToken = if (shortLived) tokens.accessToken else null
136
+ if (shortLived) Logger.w(TAG, "refreshed access token expires within ${EXPIRY_MARGIN_MS / 1000} s")
137
+ Logger.i(TAG, "access token refreshed (HTTP ${response.status})")
138
+ emit(true, response.status, null, json?.toString())
139
+ return tokens.accessToken
140
+ }
141
+
142
+ private fun emit(success: Boolean, status: Int, error: String?, responseJson: String?) {
143
+ events.emit(TrackingEvent.Authorization(success, status, error, responseJson))
144
+ }
145
+
146
+ companion object {
147
+ private const val TAG = "LT.Auth"
148
+
149
+ /** Refresh this long before `expires`. */
150
+ const val EXPIRY_MARGIN_MS = 60_000L
151
+
152
+ private const val REFRESH_TOKEN_PLACEHOLDER = "{refreshToken}"
153
+
154
+ /** Epoch values below this are seconds, not milliseconds. */
155
+ private const val EPOCH_SECONDS_LIMIT = 1e12
156
+
157
+ /** Content type and body text of the refresh request. */
158
+ fun refreshBody(auth: AuthorizationConfig): Pair<String, String> {
159
+ val payload = JsonUtil.parseObject(auth.refreshPayload) ?: JSONObject().also {
160
+ if (auth.refreshPayload.isNotBlank()) Logger.w(TAG, "authorization.refreshPayload is not a JSON object")
161
+ }
162
+ val refreshToken = auth.refreshToken.orEmpty()
163
+ val substituted = JSONObject()
164
+ val keys = payload.keys()
165
+ while (keys.hasNext()) {
166
+ val key = keys.next()
167
+ val value = payload.get(key)
168
+ val resolved = if (value is String) value.replace(REFRESH_TOKEN_PLACEHOLDER, refreshToken) else value
169
+ substituted.put(key, resolved)
170
+ }
171
+ return when (auth.refreshPayloadEncoding) {
172
+ RefreshPayloadEncoding.JSON -> HttpSupport.JSON_CONTENT_TYPE to substituted.toString()
173
+ RefreshPayloadEncoding.FORM -> HttpSupport.FORM_CONTENT_TYPE to formEncode(substituted)
174
+ }
175
+ }
176
+
177
+ /** Reads the tokens of a refresh response; null if it has no access token. */
178
+ fun parseTokens(json: JSONObject?, now: Long): Tokens? {
179
+ if (json == null) return null
180
+ val access = firstString(json, "accessToken", "access_token") ?: return null
181
+ val expires = epochMs(json, "expires")
182
+ ?: epochMs(json, "expires_at")
183
+ ?: JsonUtil.optDouble(json, "expires_in")?.takeIf { it > 0 }?.let { now + (it * 1000).toLong() }
184
+ ?: -1L
185
+ return Tokens(access, firstString(json, "refreshToken", "refresh_token"), expires)
186
+ }
187
+
188
+ private fun firstString(json: JSONObject, vararg keys: String): String? =
189
+ keys.firstNotNullOfOrNull { JsonUtil.optString(json, it).nonBlank() }
190
+
191
+ /** An absolute expiry: epoch seconds (< 1e12), epoch ms, or an ISO-8601 string; null if absent/invalid. */
192
+ private fun epochMs(json: JSONObject, key: String): Long? {
193
+ val number = JsonUtil.optDouble(json, key)
194
+ ?: return JsonUtil.optString(json, key)?.let { Iso8601.parse(it) }
195
+ if (number <= 0) return null
196
+ return if (number < EPOCH_SECONDS_LIMIT) (number * 1000).toLong() else number.toLong()
197
+ }
198
+
199
+ /** `application/x-www-form-urlencoded` text of [json]'s non-null values. */
200
+ private fun formEncode(json: JSONObject): String {
201
+ val form = FormBody.Builder()
202
+ val keys = json.keys()
203
+ while (keys.hasNext()) {
204
+ val key = keys.next()
205
+ if (!json.isNull(key)) form.add(key, json.get(key).toString())
206
+ }
207
+ val body = form.build()
208
+ return (0 until body.size).joinToString("&") { "${body.encodedName(it)}=${body.encodedValue(it)}" }
209
+ }
210
+
211
+ private fun String?.nonBlank(): String? = this?.takeIf { it.isNotBlank() }
212
+ }
213
+ }
@@ -0,0 +1,73 @@
1
+ package com.brickssoft.locationtracking.http
2
+
3
+ import com.brickssoft.locationtracking.config.HttpConfig
4
+ import com.brickssoft.locationtracking.core.Logger
5
+ import com.brickssoft.locationtracking.model.JsonUtil
6
+ import com.brickssoft.locationtracking.model.Record
7
+ import com.brickssoft.locationtracking.model.RecordJson
8
+ import org.json.JSONArray
9
+ import org.json.JSONObject
10
+
11
+ /**
12
+ * Builds request bodies (architecture §2). Every record carries `sent_at`; a template, when configured, replaces
13
+ * the default record shape.
14
+ *
15
+ * - Single record (`batchSync:false`): `{"<rootProperty>": {...}, ...params}`.
16
+ * - Batch (`batchSync:true`): `{"<rootProperty>": [{...}, ...], ...params}`, records in the given order.
17
+ * - `rootProperty` "." (or blank): a single record's fields are merged into the root together with params; a batch
18
+ * is a bare JSON array and params are ignored.
19
+ *
20
+ * Params never overwrite a key the body already has (the record data wins).
21
+ */
22
+ internal object BodyBuilder {
23
+ private const val TAG = "LT.Http"
24
+
25
+ /** The `rootProperty` value meaning "no wrapping". */
26
+ const val MERGE_ROOT = "."
27
+
28
+ /** Body for [records]: a batch if `http.batchSync`, otherwise [records] must hold exactly one record. */
29
+ fun build(records: List<Record>, http: HttpConfig, sentAt: Long): String =
30
+ if (http.batchSync) {
31
+ batch(records, http, sentAt)
32
+ } else {
33
+ require(records.size == 1) { "a non-batch body holds exactly one record, got ${records.size}" }
34
+ single(records[0], http, sentAt)
35
+ }
36
+
37
+ fun single(record: Record, http: HttpConfig, sentAt: Long): String {
38
+ val value = recordValue(record, http, sentAt)
39
+ if (!isMerge(http)) return withParams(JSONObject().put(http.rootProperty, value), http).toString()
40
+ // A template may render an array, which cannot be merged: send it bare.
41
+ return if (value is JSONObject) withParams(value, http).toString() else value.toString()
42
+ }
43
+
44
+ fun batch(records: List<Record>, http: HttpConfig, sentAt: Long): String {
45
+ val array = JSONArray()
46
+ for (record in records) array.put(recordValue(record, http, sentAt))
47
+ if (isMerge(http)) return array.toString()
48
+ return withParams(JSONObject().put(http.rootProperty, array), http).toString()
49
+ }
50
+
51
+ /** The rendered template for [record] if one applies and renders valid JSON, else `RecordJson.toJson`. */
52
+ fun recordValue(record: Record, http: HttpConfig, sentAt: Long): Any {
53
+ val template = TemplateRenderer.templateFor(record, http)
54
+ if (template != null) TemplateRenderer.render(template, record, sentAt)?.let { return it }
55
+ return RecordJson.toJson(record, sentAt)
56
+ }
57
+
58
+ private fun isMerge(http: HttpConfig): Boolean = http.rootProperty.isBlank() || http.rootProperty == MERGE_ROOT
59
+
60
+ private fun withParams(body: JSONObject, http: HttpConfig): JSONObject {
61
+ val params = JsonUtil.parseObject(http.params)
62
+ if (params == null) {
63
+ if (http.params.isNotBlank()) Logger.w(TAG, "http.params is not a JSON object; ignored")
64
+ return body
65
+ }
66
+ val keys = params.keys()
67
+ while (keys.hasNext()) {
68
+ val key = keys.next()
69
+ if (!body.has(key)) body.put(key, params.get(key))
70
+ }
71
+ return body
72
+ }
73
+ }