@bricks-soft/capacitor-location-tracking 0.0.0-stage → 8.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +2759 -2
- package/android/build.gradle +106 -0
- package/android/consumer-rules.pro +42 -0
- package/android/src/main/AndroidManifest.xml +52 -0
- package/android/src/main/java/com/brickssoft/locationtracking/LocationTrackingPlugin.kt +431 -0
- package/android/src/main/java/com/brickssoft/locationtracking/api/LocationTrackingListener.kt +52 -0
- package/android/src/main/java/com/brickssoft/locationtracking/api/LocationTrackingNative.kt +267 -0
- package/android/src/main/java/com/brickssoft/locationtracking/api/NativeListeners.kt +270 -0
- package/android/src/main/java/com/brickssoft/locationtracking/api/NativeThread.kt +57 -0
- package/android/src/main/java/com/brickssoft/locationtracking/bridge/BridgeServices.kt +64 -0
- package/android/src/main/java/com/brickssoft/locationtracking/bridge/CallResponder.kt +41 -0
- package/android/src/main/java/com/brickssoft/locationtracking/bridge/OptionParsers.kt +281 -0
- package/android/src/main/java/com/brickssoft/locationtracking/bridge/PluginHandlers.kt +541 -0
- package/android/src/main/java/com/brickssoft/locationtracking/bridge/PluginPermissionHost.kt +76 -0
- package/android/src/main/java/com/brickssoft/locationtracking/bridge/WatchRegistry.kt +84 -0
- package/android/src/main/java/com/brickssoft/locationtracking/config/Config.kt +231 -0
- package/android/src/main/java/com/brickssoft/locationtracking/config/ConfigJson.kt +574 -0
- package/android/src/main/java/com/brickssoft/locationtracking/config/ConfigStore.kt +31 -0
- package/android/src/main/java/com/brickssoft/locationtracking/config/ConfigValidator.kt +170 -0
- package/android/src/main/java/com/brickssoft/locationtracking/config/RuntimeStateJson.kt +73 -0
- package/android/src/main/java/com/brickssoft/locationtracking/config/SharedPrefsConfigStore.kt +135 -0
- package/android/src/main/java/com/brickssoft/locationtracking/config/State.kt +45 -0
- package/android/src/main/java/com/brickssoft/locationtracking/core/AppDispatchers.kt +21 -0
- package/android/src/main/java/com/brickssoft/locationtracking/core/Clock.kt +41 -0
- package/android/src/main/java/com/brickssoft/locationtracking/core/Components.kt +217 -0
- package/android/src/main/java/com/brickssoft/locationtracking/core/Constants.kt +78 -0
- package/android/src/main/java/com/brickssoft/locationtracking/core/Errors.kt +27 -0
- package/android/src/main/java/com/brickssoft/locationtracking/core/EventBus.kt +39 -0
- package/android/src/main/java/com/brickssoft/locationtracking/core/ForceStopProbe.kt +63 -0
- package/android/src/main/java/com/brickssoft/locationtracking/core/Iso8601.kt +63 -0
- package/android/src/main/java/com/brickssoft/locationtracking/core/Logger.kt +68 -0
- package/android/src/main/java/com/brickssoft/locationtracking/core/RecordHooks.kt +47 -0
- package/android/src/main/java/com/brickssoft/locationtracking/core/TrackingEvent.kt +64 -0
- package/android/src/main/java/com/brickssoft/locationtracking/data/SqlSupport.kt +79 -0
- package/android/src/main/java/com/brickssoft/locationtracking/data/SqliteGeofenceStore.kt +144 -0
- package/android/src/main/java/com/brickssoft/locationtracking/data/SqliteLocationStore.kt +215 -0
- package/android/src/main/java/com/brickssoft/locationtracking/data/Stores.kt +45 -0
- package/android/src/main/java/com/brickssoft/locationtracking/data/TrackingDatabase.kt +111 -0
- package/android/src/main/java/com/brickssoft/locationtracking/device/DefaultDeviceInfoProvider.kt +104 -0
- package/android/src/main/java/com/brickssoft/locationtracking/device/DefaultDeviceMonitor.kt +335 -0
- package/android/src/main/java/com/brickssoft/locationtracking/device/DeviceMonitor.kt +36 -0
- package/android/src/main/java/com/brickssoft/locationtracking/device/DeviceStateMapping.kt +69 -0
- package/android/src/main/java/com/brickssoft/locationtracking/engine/DefaultTrackingEngine.kt +1281 -0
- package/android/src/main/java/com/brickssoft/locationtracking/engine/ElasticDistance.kt +68 -0
- package/android/src/main/java/com/brickssoft/locationtracking/engine/LocationRequests.kt +74 -0
- package/android/src/main/java/com/brickssoft/locationtracking/engine/MotionStateMachine.kt +291 -0
- package/android/src/main/java/com/brickssoft/locationtracking/engine/StationaryRegion.kt +41 -0
- package/android/src/main/java/com/brickssoft/locationtracking/engine/TrackingEngine.kt +60 -0
- package/android/src/main/java/com/brickssoft/locationtracking/engine/TrackingTimers.kt +90 -0
- package/android/src/main/java/com/brickssoft/locationtracking/geofence/DefaultGeofenceManager.kt +899 -0
- package/android/src/main/java/com/brickssoft/locationtracking/geofence/DwellTracker.kt +80 -0
- package/android/src/main/java/com/brickssoft/locationtracking/geofence/GeofenceManager.kt +33 -0
- package/android/src/main/java/com/brickssoft/locationtracking/geofence/PolygonMath.kt +194 -0
- package/android/src/main/java/com/brickssoft/locationtracking/heartbeat/DefaultHeartbeatScheduler.kt +713 -0
- package/android/src/main/java/com/brickssoft/locationtracking/heartbeat/HeartbeatAlarmReceiver.kt +69 -0
- package/android/src/main/java/com/brickssoft/locationtracking/heartbeat/HeartbeatAlarms.kt +100 -0
- package/android/src/main/java/com/brickssoft/locationtracking/heartbeat/HeartbeatScheduler.kt +25 -0
- package/android/src/main/java/com/brickssoft/locationtracking/heartbeat/HeartbeatWindow.kt +179 -0
- package/android/src/main/java/com/brickssoft/locationtracking/http/AuthorizationManager.kt +213 -0
- package/android/src/main/java/com/brickssoft/locationtracking/http/BodyBuilder.kt +73 -0
- package/android/src/main/java/com/brickssoft/locationtracking/http/HttpSupport.kt +118 -0
- package/android/src/main/java/com/brickssoft/locationtracking/http/HttpSyncer.kt +18 -0
- package/android/src/main/java/com/brickssoft/locationtracking/http/OkHttpSyncer.kt +551 -0
- package/android/src/main/java/com/brickssoft/locationtracking/http/StrictJson.kt +135 -0
- package/android/src/main/java/com/brickssoft/locationtracking/http/SyncPolicy.kt +109 -0
- package/android/src/main/java/com/brickssoft/locationtracking/http/TemplateRenderer.kt +143 -0
- package/android/src/main/java/com/brickssoft/locationtracking/logging/FileLogger.kt +461 -0
- package/android/src/main/java/com/brickssoft/locationtracking/logging/LogFileProvider.kt +9 -0
- package/android/src/main/java/com/brickssoft/locationtracking/logging/LogFormatter.kt +148 -0
- package/android/src/main/java/com/brickssoft/locationtracking/logging/LogStore.kt +30 -0
- package/android/src/main/java/com/brickssoft/locationtracking/model/DeviceModels.kt +176 -0
- package/android/src/main/java/com/brickssoft/locationtracking/model/GeofenceSpec.kt +25 -0
- package/android/src/main/java/com/brickssoft/locationtracking/model/ModelJson.kt +550 -0
- package/android/src/main/java/com/brickssoft/locationtracking/model/Record.kt +106 -0
- package/android/src/main/java/com/brickssoft/locationtracking/model/TrackedLocation.kt +53 -0
- package/android/src/main/java/com/brickssoft/locationtracking/permission/BackgroundRationaleDialog.kt +85 -0
- package/android/src/main/java/com/brickssoft/locationtracking/permission/CurrentActivityTracker.kt +72 -0
- package/android/src/main/java/com/brickssoft/locationtracking/permission/DefaultPermissionManager.kt +333 -0
- package/android/src/main/java/com/brickssoft/locationtracking/permission/PermissionManager.kt +54 -0
- package/android/src/main/java/com/brickssoft/locationtracking/position/DefaultPositionService.kt +277 -0
- package/android/src/main/java/com/brickssoft/locationtracking/position/PositionService.kt +39 -0
- package/android/src/main/java/com/brickssoft/locationtracking/position/PositionWatch.kt +115 -0
- package/android/src/main/java/com/brickssoft/locationtracking/processing/DefaultLocationProcessor.kt +93 -0
- package/android/src/main/java/com/brickssoft/locationtracking/processing/DefaultOdometer.kt +99 -0
- package/android/src/main/java/com/brickssoft/locationtracking/processing/DefaultRecordFactory.kt +166 -0
- package/android/src/main/java/com/brickssoft/locationtracking/processing/Geo.kt +56 -0
- package/android/src/main/java/com/brickssoft/locationtracking/processing/KalmanFilter.kt +72 -0
- package/android/src/main/java/com/brickssoft/locationtracking/processing/Processing.kt +48 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/Backends.kt +187 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/DefaultProviderFactory.kt +169 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/ProviderPackaging.kt +135 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/android/AndroidActivityBackend.kt +25 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/android/AndroidGeofenceBackend.kt +181 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/android/AndroidGeofenceReceiver.kt +115 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/android/AndroidLocationBackend.kt +265 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/android/AndroidProviderBundle.kt +31 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/android/LocationProviders.kt +98 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsActivityBackend.kt +80 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsActivityReceiver.kt +62 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsErrors.kt +72 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsGeofenceBackend.kt +152 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsGeofenceReceiver.kt +78 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsLocationBackend.kt +176 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsProviderBundle.kt +62 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsReceiverSupport.kt +88 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsTasks.kt +55 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsActivityBackend.kt +71 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsActivityReceiver.kt +57 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsErrors.kt +76 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsGeofenceBackend.kt +116 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsGeofenceReceiver.kt +67 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsIntents.kt +83 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsLocationBackend.kt +197 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsProviderBundle.kt +55 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsTasks.kt +44 -0
- package/android/src/main/java/com/brickssoft/locationtracking/record/RecordSink.kt +95 -0
- package/android/src/main/java/com/brickssoft/locationtracking/service/BootCountStore.kt +44 -0
- package/android/src/main/java/com/brickssoft/locationtracking/service/BootReceiver.kt +175 -0
- package/android/src/main/java/com/brickssoft/locationtracking/service/DefaultServiceController.kt +196 -0
- package/android/src/main/java/com/brickssoft/locationtracking/service/LocationTrackingService.kt +319 -0
- package/android/src/main/java/com/brickssoft/locationtracking/service/NotificationActionReceiver.kt +42 -0
- package/android/src/main/java/com/brickssoft/locationtracking/service/NotificationFactory.kt +338 -0
- package/android/src/main/java/com/brickssoft/locationtracking/service/ServiceCommands.kt +204 -0
- package/android/src/main/java/com/brickssoft/locationtracking/service/ServiceController.kt +22 -0
- package/android/src/main/java/com/brickssoft/locationtracking/service/ServiceDeps.kt +61 -0
- package/android/src/main/java/com/brickssoft/locationtracking/service/WhileInUseStartCheck.kt +115 -0
- package/android/src/main/java/com/brickssoft/locationtracking/settings/DefaultDeviceSettings.kt +79 -0
- package/android/src/main/java/com/brickssoft/locationtracking/settings/DeviceSettings.kt +21 -0
- package/android/src/main/java/com/brickssoft/locationtracking/settings/OemPowerManagers.kt +198 -0
- package/android/src/main/res/drawable/lt_ic_notification.xml +11 -0
- package/android/src/main/res/values/lt_strings.xml +11 -0
- package/android/src/main/res/xml/lt_file_paths.xml +5 -0
- package/dist/esm/definitions.d.ts +719 -0
- package/dist/esm/definitions.js +2 -0
- package/dist/esm/definitions.js.map +1 -0
- package/dist/esm/events.d.ts +50 -0
- package/dist/esm/events.js +52 -0
- package/dist/esm/events.js.map +1 -0
- package/dist/esm/index.d.ts +3 -0
- package/dist/esm/index.js +5 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/plugin.d.ts +3 -0
- package/dist/esm/plugin.js +10 -0
- package/dist/esm/plugin.js.map +1 -0
- package/dist/esm/web/config.d.ts +19 -0
- package/dist/esm/web/config.js +243 -0
- package/dist/esm/web/config.js.map +1 -0
- package/dist/esm/web/device.d.ts +9 -0
- package/dist/esm/web/device.js +90 -0
- package/dist/esm/web/device.js.map +1 -0
- package/dist/esm/web/errors.d.ts +14 -0
- package/dist/esm/web/errors.js +19 -0
- package/dist/esm/web/errors.js.map +1 -0
- package/dist/esm/web/geolocation.d.ts +26 -0
- package/dist/esm/web/geolocation.js +117 -0
- package/dist/esm/web/geolocation.js.map +1 -0
- package/dist/esm/web/location.d.ts +7 -0
- package/dist/esm/web/location.js +70 -0
- package/dist/esm/web/location.js.map +1 -0
- package/dist/esm/web.d.ts +164 -0
- package/dist/esm/web.js +328 -0
- package/dist/esm/web.js.map +1 -0
- package/dist/plugin.cjs.js +945 -0
- package/dist/plugin.cjs.js.map +1 -0
- package/dist/plugin.js +948 -0
- package/dist/plugin.js.map +1 -0
- package/package.json +59 -4
package/android/src/main/java/com/brickssoft/locationtracking/heartbeat/HeartbeatAlarmReceiver.kt
ADDED
|
@@ -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
|
+
}
|