@bricks-soft/capacitor-location-tracking 8.0.1 → 8.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/BricksSoftCapacitorLocationTracking.podspec +18 -0
- package/Package.swift +13 -0
- package/README.md +476 -147
- package/android/build.gradle +3 -1
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/java/com/brickssoft/locationtracking/bridge/OptionParsers.kt +4 -1
- package/android/src/main/java/com/brickssoft/locationtracking/bridge/PluginHandlers.kt +14 -5
- package/android/src/main/java/com/brickssoft/locationtracking/config/Config.kt +10 -0
- package/android/src/main/java/com/brickssoft/locationtracking/config/ConfigJson.kt +70 -15
- package/android/src/main/java/com/brickssoft/locationtracking/config/ConfigStore.kt +6 -0
- package/android/src/main/java/com/brickssoft/locationtracking/config/ConfigValidator.kt +8 -3
- package/android/src/main/java/com/brickssoft/locationtracking/config/RuntimeStateJson.kt +10 -0
- package/android/src/main/java/com/brickssoft/locationtracking/config/SecretStore.kt +188 -0
- package/android/src/main/java/com/brickssoft/locationtracking/config/SharedPrefsConfigStore.kt +188 -17
- package/android/src/main/java/com/brickssoft/locationtracking/config/State.kt +11 -0
- package/android/src/main/java/com/brickssoft/locationtracking/core/Components.kt +21 -5
- package/android/src/main/java/com/brickssoft/locationtracking/core/ForceStopProbe.kt +20 -9
- package/android/src/main/java/com/brickssoft/locationtracking/core/TrackingEvent.kt +24 -2
- package/android/src/main/java/com/brickssoft/locationtracking/data/TrackingDatabase.kt +43 -1
- package/android/src/main/java/com/brickssoft/locationtracking/device/DefaultDeviceInfoProvider.kt +17 -0
- package/android/src/main/java/com/brickssoft/locationtracking/device/DeviceDetails.kt +167 -0
- package/android/src/main/java/com/brickssoft/locationtracking/engine/DefaultTrackingEngine.kt +191 -34
- package/android/src/main/java/com/brickssoft/locationtracking/engine/TrackingEngine.kt +6 -0
- package/android/src/main/java/com/brickssoft/locationtracking/engine/TrackingTimers.kt +3 -0
- package/android/src/main/java/com/brickssoft/locationtracking/heartbeat/DefaultHeartbeatScheduler.kt +52 -8
- package/android/src/main/java/com/brickssoft/locationtracking/heartbeat/HeartbeatAlarmReceiver.kt +22 -0
- package/android/src/main/java/com/brickssoft/locationtracking/http/AuthorizationManager.kt +78 -27
- package/android/src/main/java/com/brickssoft/locationtracking/http/ConfigChecks.kt +140 -0
- package/android/src/main/java/com/brickssoft/locationtracking/http/HttpSupport.kt +50 -12
- package/android/src/main/java/com/brickssoft/locationtracking/http/HttpSyncer.kt +17 -0
- package/android/src/main/java/com/brickssoft/locationtracking/http/OkHttpSyncer.kt +507 -92
- package/android/src/main/java/com/brickssoft/locationtracking/http/SyncPolicy.kt +6 -6
- package/android/src/main/java/com/brickssoft/locationtracking/http/TemplateRenderer.kt +23 -1
- package/android/src/main/java/com/brickssoft/locationtracking/http/UploadJobs.kt +217 -0
- package/android/src/main/java/com/brickssoft/locationtracking/http/UploadWait.kt +184 -0
- package/android/src/main/java/com/brickssoft/locationtracking/logging/FileLogger.kt +66 -7
- package/android/src/main/java/com/brickssoft/locationtracking/logging/LogRedaction.kt +31 -0
- package/android/src/main/java/com/brickssoft/locationtracking/model/DeviceModels.kt +44 -1
- package/android/src/main/java/com/brickssoft/locationtracking/model/ModelJson.kt +79 -8
- package/android/src/main/java/com/brickssoft/locationtracking/model/Record.kt +9 -0
- package/android/src/main/java/com/brickssoft/locationtracking/model/TrackedLocation.kt +12 -2
- package/android/src/main/java/com/brickssoft/locationtracking/position/DefaultPositionService.kt +10 -2
- package/android/src/main/java/com/brickssoft/locationtracking/position/PositionWatch.kt +24 -1
- package/android/src/main/java/com/brickssoft/locationtracking/processing/DefaultRecordFactory.kt +31 -10
- package/android/src/main/java/com/brickssoft/locationtracking/provider/Backends.kt +48 -3
- package/android/src/main/java/com/brickssoft/locationtracking/provider/DefaultProviderFactory.kt +60 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/LocationSubscriptions.kt +180 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/android/AndroidLocationBackend.kt +200 -126
- package/android/src/main/java/com/brickssoft/locationtracking/provider/android/LocationProviders.kt +20 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsLocationBackend.kt +39 -51
- package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsProviderBundle.kt +8 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsLocationBackend.kt +47 -53
- package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsProviderBundle.kt +34 -2
- package/android/src/main/java/com/brickssoft/locationtracking/record/RecordSink.kt +19 -1
- package/android/src/main/java/com/brickssoft/locationtracking/service/BootCountStore.kt +7 -0
- package/android/src/main/res/xml/lt_file_paths.xml +1 -1
- package/dist/esm/definitions.d.ts +117 -8
- package/dist/esm/definitions.js.map +1 -1
- package/dist/esm/events.d.ts +8 -2
- package/dist/esm/events.js +7 -1
- package/dist/esm/events.js.map +1 -1
- package/dist/esm/index.d.ts +1 -1
- package/dist/esm/index.js +1 -1
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/web/config.js +10 -0
- package/dist/esm/web/config.js.map +1 -1
- package/dist/esm/web/device.d.ts +1 -1
- package/dist/esm/web/device.js +8 -1
- package/dist/esm/web/device.js.map +1 -1
- package/dist/esm/web.d.ts +0 -8
- package/dist/esm/web.js +12 -3
- package/dist/esm/web.js.map +1 -1
- package/dist/plugin.cjs.js +39 -5
- package/dist/plugin.cjs.js.map +1 -1
- package/dist/plugin.js +39 -5
- package/dist/plugin.js.map +1 -1
- package/ios/Sources/LocationTrackingPlugin/Api/BackgroundUpload.swift +24 -0
- package/ios/Sources/LocationTrackingPlugin/Api/LocationTrackingNative.swift +183 -0
- package/ios/Sources/LocationTrackingPlugin/Config/ConfigDefaults.swift +96 -0
- package/ios/Sources/LocationTrackingPlugin/Config/ConfigSchema.swift +74 -0
- package/ios/Sources/LocationTrackingPlugin/Config/ConfigValidator.swift +40 -0
- package/ios/Sources/LocationTrackingPlugin/Config/SecretStore.swift +35 -0
- package/ios/Sources/LocationTrackingPlugin/Config/TrackingConfig.swift +104 -0
- package/ios/Sources/LocationTrackingPlugin/Core/JSON.swift +50 -0
- package/ios/Sources/LocationTrackingPlugin/Core/Options.swift +26 -0
- package/ios/Sources/LocationTrackingPlugin/Core/PluginInfo.swift +6 -0
- package/ios/Sources/LocationTrackingPlugin/Core/TrackingClock.swift +11 -0
- package/ios/Sources/LocationTrackingPlugin/Core/TrackingCore.swift +298 -0
- package/ios/Sources/LocationTrackingPlugin/Data/TrackingStore.swift +155 -0
- package/ios/Sources/LocationTrackingPlugin/Device/DeviceReadings.swift +43 -0
- package/ios/Sources/LocationTrackingPlugin/Engine/CheckInNotifier.swift +92 -0
- package/ios/Sources/LocationTrackingPlugin/Engine/MotionStateMachine.swift +122 -0
- package/ios/Sources/LocationTrackingPlugin/Engine/SessionDeadline.swift +29 -0
- package/ios/Sources/LocationTrackingPlugin/Engine/TrackingEngine.swift +344 -0
- package/ios/Sources/LocationTrackingPlugin/Engine/TrackingTimers.swift +35 -0
- package/ios/Sources/LocationTrackingPlugin/Geofence/DwellTracker.swift +35 -0
- package/ios/Sources/LocationTrackingPlugin/Geofence/GeofenceEventAdapter.swift +88 -0
- package/ios/Sources/LocationTrackingPlugin/Geofence/GeofenceManager.swift +453 -0
- package/ios/Sources/LocationTrackingPlugin/Geofence/GeofenceMonitor.swift +207 -0
- package/ios/Sources/LocationTrackingPlugin/Geofence/GeofenceSpec.swift +103 -0
- package/ios/Sources/LocationTrackingPlugin/Geofence/GeofenceStore.swift +52 -0
- package/ios/Sources/LocationTrackingPlugin/Geofence/PolygonMath.swift +176 -0
- package/ios/Sources/LocationTrackingPlugin/Heartbeat/ExecutionAssertion.swift +33 -0
- package/ios/Sources/LocationTrackingPlugin/Heartbeat/HeartbeatWindow.swift +29 -0
- package/ios/Sources/LocationTrackingPlugin/Http/BodyBuilder.swift +52 -0
- package/ios/Sources/LocationTrackingPlugin/Http/HTTPTransport.swift +87 -0
- package/ios/Sources/LocationTrackingPlugin/Http/StrictJSON.swift +98 -0
- package/ios/Sources/LocationTrackingPlugin/Http/Syncer.swift +313 -0
- package/ios/Sources/LocationTrackingPlugin/Http/UploadBackoff.swift +26 -0
- package/ios/Sources/LocationTrackingPlugin/Location/RouteSource.swift +124 -0
- package/ios/Sources/LocationTrackingPlugin/LocationTrackingPlugin.swift +155 -0
- package/ios/Sources/LocationTrackingPlugin/Logging/LogExport.swift +73 -0
- package/ios/Sources/LocationTrackingPlugin/Logging/TrackingLog.swift +47 -0
- package/ios/Sources/LocationTrackingPlugin/Permission/LocationServices.swift +32 -0
- package/ios/Sources/LocationTrackingPlugin/Permission/Permissions.swift +102 -0
- package/ios/Sources/LocationTrackingPlugin/Position/PositionSource.swift +79 -0
- package/ios/Sources/LocationTrackingPlugin/Position/Positions.swift +33 -0
- package/ios/Sources/LocationTrackingPlugin/Position/SampleCollector.swift +31 -0
- package/ios/Sources/LocationTrackingPlugin/PrivacyInfo.xcprivacy +10 -0
- package/ios/Sources/LocationTrackingPlugin/Processing/LocationProcessor.swift +83 -0
- package/ios/Sources/LocationTrackingPlugin/Processing/TrackedLocation.swift +64 -0
- package/ios/Sources/LocationTrackingPlugin/Record/ExternalRecord.swift +54 -0
- package/ios/Sources/LocationTrackingPlugin/Record/RecordFactory.swift +27 -0
- package/package.json +10 -4
|
@@ -19,7 +19,9 @@ import com.brickssoft.locationtracking.model.RecordEvent
|
|
|
19
19
|
* - `syncInterval > 0` while tracking: the whole queue once the oldest pending normal record is at least
|
|
20
20
|
* `syncInterval` seconds old (age = now − `recorded_at`; a negative age, which a wall clock set back produces,
|
|
21
21
|
* counts as due), or once the queue holds `autoSyncThreshold` records when that is above 0 (a size cap that
|
|
22
|
-
* uploads earlier)
|
|
22
|
+
* uploads earlier).
|
|
23
|
+
* - While the stored wait after a failed automatic upload runs (`waiting`, see `NextTry`), normal records are not
|
|
24
|
+
* sent; priority records still are, and take the whole queue with them.
|
|
23
25
|
*/
|
|
24
26
|
internal object SyncPolicy {
|
|
25
27
|
/** The records an upload pass sends. */
|
|
@@ -39,9 +41,6 @@ internal object SyncPolicy {
|
|
|
39
41
|
|
|
40
42
|
/** The oldest pending normal record is due ([isIntervalDue]). */
|
|
41
43
|
DUE,
|
|
42
|
-
|
|
43
|
-
/** An automatic upload failed less than `syncInterval` ago: normal records wait for the retry time. */
|
|
44
|
-
RETRY_PENDING,
|
|
45
44
|
}
|
|
46
45
|
|
|
47
46
|
val PRIORITY_EVENTS: Set<RecordEvent> = RecordEvent.entries.filter { it.isPriority }.toSet()
|
|
@@ -83,6 +82,7 @@ internal object SyncPolicy {
|
|
|
83
82
|
* @param queued number of queued records.
|
|
84
83
|
* @param priorityQueued number of queued priority records.
|
|
85
84
|
* @param interval the state of the `syncInterval` rule; [IntervalCheck.OFF] applies the threshold rule.
|
|
85
|
+
* @param waiting true while the stored wait after a failed automatic upload runs: normal records are not sent.
|
|
86
86
|
*/
|
|
87
87
|
fun autoScope(
|
|
88
88
|
http: HttpConfig,
|
|
@@ -90,16 +90,16 @@ internal object SyncPolicy {
|
|
|
90
90
|
queued: Int,
|
|
91
91
|
priorityQueued: Int,
|
|
92
92
|
interval: IntervalCheck = IntervalCheck.OFF,
|
|
93
|
+
waiting: Boolean = false,
|
|
93
94
|
): Scope {
|
|
94
95
|
if (!hasUrl(http) || !connectivity.connected) return Scope.NONE
|
|
95
96
|
val restricted = isCellularRestricted(http, connectivity)
|
|
96
97
|
if (priorityQueued > 0) return if (restricted) Scope.PRIORITY_ONLY else Scope.ALL
|
|
97
|
-
if (restricted || !http.autoSync || queued <= 0) return Scope.NONE
|
|
98
|
+
if (restricted || !http.autoSync || queued <= 0 || waiting) return Scope.NONE
|
|
98
99
|
val upload = when (interval) {
|
|
99
100
|
IntervalCheck.OFF -> queued >= maxOf(1, http.autoSyncThreshold)
|
|
100
101
|
IntervalCheck.NOT_DUE -> http.autoSyncThreshold > 0 && queued >= http.autoSyncThreshold
|
|
101
102
|
IntervalCheck.DUE -> true
|
|
102
|
-
IntervalCheck.RETRY_PENDING -> false
|
|
103
103
|
}
|
|
104
104
|
return if (upload) Scope.ALL else Scope.NONE
|
|
105
105
|
}
|
|
@@ -18,7 +18,8 @@ import org.json.JSONTokener
|
|
|
18
18
|
* Each `<%= name %>` placeholder (surrounding whitespace allowed) is replaced by a raw JSON literal: numbers and
|
|
19
19
|
* booleans bare, null as `null`, strings JSON-escaped but without quotes (templates write `"<%= uuid %>"`), and
|
|
20
20
|
* `extras` as JSON object text (`{}` when the record has none), and `record` as the default record object (with
|
|
21
|
-
* `sent_at`), so `"raw": <%= record %>` keeps every field, and `provider` as the record's provider
|
|
21
|
+
* `sent_at`), so `"raw": <%= record %>` keeps every field, and `provider` and `device` as the record's provider and
|
|
22
|
+
* device objects (or null).
|
|
22
23
|
* A null value in a placeholder that is exactly wrapped
|
|
23
24
|
* in quotes (`"<%= timestamp %>"`) replaces the quotes too, so it becomes JSON `null` rather than the string "null".
|
|
24
25
|
* An unknown placeholder becomes "" and logs a warning.
|
|
@@ -53,10 +54,26 @@ internal object TemplateRenderer {
|
|
|
53
54
|
"activity.confidence" to { r, _ -> r.activity.confidence.toString() },
|
|
54
55
|
"battery.level" to { r, _ -> number(r.battery.level) },
|
|
55
56
|
"battery.is_charging" to { r, _ -> r.battery.isCharging.toString() },
|
|
57
|
+
"battery.temperature" to { r, _ -> number(r.batteryDetails?.temperature) },
|
|
58
|
+
"battery.health" to { r, _ -> r.batteryDetails?.health?.let { string(it) } },
|
|
59
|
+
"battery.plugged" to { r, _ -> r.batteryDetails?.plugged?.let { string(it) } },
|
|
60
|
+
"network" to { r, _ -> r.network?.let { string(it.wire) } },
|
|
61
|
+
"device.device_id" to { r, _ -> r.device?.deviceId?.let { string(it) } },
|
|
62
|
+
"device.background_restricted" to { r, _ -> r.device?.backgroundRestricted?.toString() },
|
|
63
|
+
"device.standby_bucket" to { r, _ -> r.device?.standbyBucket?.let { string(it) } },
|
|
64
|
+
"device.disk_free_bytes" to { r, _ -> r.device?.diskFreeBytes?.toString() },
|
|
65
|
+
"device.thermal_status" to { r, _ -> r.device?.thermalStatus?.let { string(it) } },
|
|
66
|
+
"device.manufacturer" to { r, _ -> r.device?.manufacturer?.let { string(it) } },
|
|
67
|
+
"device.model" to { r, _ -> r.device?.model?.let { string(it) } },
|
|
68
|
+
"device.os_version" to { r, _ -> r.device?.osVersion?.let { string(it) } },
|
|
69
|
+
"device.sdk_int" to { r, _ -> r.device?.sdkInt?.toString() },
|
|
70
|
+
"device.low_ram_device" to { r, _ -> r.device?.lowRamDevice?.toString() },
|
|
71
|
+
"device.ram_total_bytes" to { r, _ -> r.device?.ramTotalBytes?.toString() },
|
|
56
72
|
"elapsed_realtime_ms" to { r, _ -> r.elapsedRealtimeMs.toString() },
|
|
57
73
|
"boot_count" to { r, _ -> r.bootCount.toString() },
|
|
58
74
|
"backend" to { r, _ -> r.backend?.let { string(it.wire) } },
|
|
59
75
|
"reason" to { r, _ -> r.reason?.let { string(it) } },
|
|
76
|
+
"stopped_at" to { r, _ -> r.stoppedAt?.let { string(Iso8601.format(it)) } },
|
|
60
77
|
"geofence.identifier" to { r, _ -> r.geofence?.let { string(it.identifier) } },
|
|
61
78
|
"geofence.action" to { r, _ -> r.geofence?.let { string(it.action.name) } },
|
|
62
79
|
"provider.enabled" to { r, _ -> r.provider?.enabled?.toString() },
|
|
@@ -66,11 +83,16 @@ internal object TemplateRenderer {
|
|
|
66
83
|
"extras" to { r, _ -> JsonUtil.parseObject(r.extras)?.toString() ?: "{}" },
|
|
67
84
|
"record" to { r, sentAt -> RecordJson.toJson(r, sentAt).toString() },
|
|
68
85
|
"provider" to { r, _ -> r.provider?.let { ProviderStateJson.toJson(it).toString() } },
|
|
86
|
+
"device" to { r, _ -> r.device?.let { RecordJson.deviceToJson(it).toString() } },
|
|
69
87
|
)
|
|
70
88
|
|
|
71
89
|
/** Every supported placeholder name. */
|
|
72
90
|
val PLACEHOLDERS: Set<String> get() = RESOLVERS.keys
|
|
73
91
|
|
|
92
|
+
/** The placeholder names used in [template]. */
|
|
93
|
+
fun placeholderNames(template: String): Set<String> =
|
|
94
|
+
PLACEHOLDER.findAll(template).map { it.groupValues[2] }.toSet()
|
|
95
|
+
|
|
74
96
|
/**
|
|
75
97
|
* The template for [record]: `geofenceTemplate` for geofence records (falling back to `locationTemplate`),
|
|
76
98
|
* otherwise `locationTemplate`. Blank templates count as absent; null means the default shape.
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
package com.brickssoft.locationtracking.http
|
|
2
|
+
|
|
3
|
+
import android.app.job.JobInfo
|
|
4
|
+
import android.app.job.JobParameters
|
|
5
|
+
import android.app.job.JobScheduler
|
|
6
|
+
import android.app.job.JobService
|
|
7
|
+
import android.content.ComponentName
|
|
8
|
+
import android.content.Context
|
|
9
|
+
import android.os.Build
|
|
10
|
+
import com.brickssoft.locationtracking.core.Components
|
|
11
|
+
import com.brickssoft.locationtracking.core.Logger
|
|
12
|
+
import kotlinx.coroutines.Job
|
|
13
|
+
import kotlinx.coroutines.launch
|
|
14
|
+
import kotlin.coroutines.cancellation.CancellationException
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Schedules the upload job (recommendation 10): one upload pass in a process that may have no tracking session, so
|
|
18
|
+
* queued records reach the server whether tracking is on or off.
|
|
19
|
+
*/
|
|
20
|
+
interface UploadScheduler {
|
|
21
|
+
/**
|
|
22
|
+
* Replaces any waiting upload job with one that runs once a network is available and [delayMs] have passed.
|
|
23
|
+
* [unmetered]: only on an unmetered network (normal records with `disableAutoSyncOnCellular` on cellular).
|
|
24
|
+
*/
|
|
25
|
+
fun schedule(delayMs: Long, unmetered: Boolean = false)
|
|
26
|
+
|
|
27
|
+
/** Cancels the waiting upload job (the queue is empty). */
|
|
28
|
+
fun cancel()
|
|
29
|
+
|
|
30
|
+
companion object {
|
|
31
|
+
/** Schedules nothing (tests, and callers without a Context). */
|
|
32
|
+
val NONE: UploadScheduler = object : UploadScheduler {
|
|
33
|
+
override fun schedule(delayMs: Long, unmetered: Boolean) = Unit
|
|
34
|
+
|
|
35
|
+
override fun cancel() = Unit
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* [UploadScheduler] over the platform `JobScheduler` (no WorkManager dependency). One job with the fixed id
|
|
42
|
+
* [UPLOAD_JOB_ID]; each [schedule] replaces the waiting one.
|
|
43
|
+
*
|
|
44
|
+
* - The job requires a network (any network, or an unmetered one), is persisted across reboots
|
|
45
|
+
* (`RECEIVE_BOOT_COMPLETED` is declared by the plugin), and starts no earlier than the given delay.
|
|
46
|
+
* - Android 12+: a job without a delay is scheduled as expedited, so it runs as soon as possible even while the app is
|
|
47
|
+
* in the background; when the expedited quota is used up (Android refuses it), it is scheduled as a regular job.
|
|
48
|
+
* A job with a delay is always a regular job: Android does not allow a start delay on an expedited job.
|
|
49
|
+
* - A [schedule] or [cancel] made while the job runs (by the pass it runs) is applied when the job has finished:
|
|
50
|
+
* scheduling the same id would stop the running job.
|
|
51
|
+
* - The same request as the last one is not sent again within this process (the pass runs on every new record).
|
|
52
|
+
*/
|
|
53
|
+
class JobUploadScheduler(context: Context, private val sdkInt: Int = Build.VERSION.SDK_INT) : UploadScheduler {
|
|
54
|
+
private val appContext: Context = context.applicationContext ?: context
|
|
55
|
+
private val jobScheduler: JobScheduler? by lazy { appContext.getSystemService(JobScheduler::class.java) }
|
|
56
|
+
|
|
57
|
+
override fun schedule(delayMs: Long, unmetered: Boolean) {
|
|
58
|
+
val request = Request.Schedule(delayMs.coerceAtLeast(0L), unmetered)
|
|
59
|
+
if (deferWhileRunning(request)) return
|
|
60
|
+
apply(request)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
override fun cancel() {
|
|
64
|
+
if (deferWhileRunning(Request.Cancel)) return
|
|
65
|
+
apply(Request.Cancel)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Applies the request the job's own pass made, after the job finished. */
|
|
69
|
+
internal fun applyDeferred() {
|
|
70
|
+
val request = synchronized(lock) {
|
|
71
|
+
running = false
|
|
72
|
+
deferred.also { deferred = null }
|
|
73
|
+
} ?: return
|
|
74
|
+
apply(request)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
private fun deferWhileRunning(request: Request): Boolean = synchronized(lock) {
|
|
78
|
+
if (!running) return false
|
|
79
|
+
deferred = request
|
|
80
|
+
true
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private fun apply(request: Request) {
|
|
84
|
+
synchronized(lock) {
|
|
85
|
+
if (request == lastApplied) return
|
|
86
|
+
lastApplied = request
|
|
87
|
+
}
|
|
88
|
+
val scheduler = jobScheduler ?: return Logger.w(TAG, "JobScheduler is unavailable; no upload job")
|
|
89
|
+
try {
|
|
90
|
+
when (request) {
|
|
91
|
+
Request.Cancel -> {
|
|
92
|
+
scheduler.cancel(UPLOAD_JOB_ID)
|
|
93
|
+
Logger.d(TAG, "upload job cancelled: the queue is empty")
|
|
94
|
+
}
|
|
95
|
+
is Request.Schedule -> scheduleJob(scheduler, request)
|
|
96
|
+
}
|
|
97
|
+
} catch (e: Exception) {
|
|
98
|
+
synchronized(lock) { lastApplied = null }
|
|
99
|
+
Logger.w(TAG, "could not update the upload job", e)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
private fun scheduleJob(scheduler: JobScheduler, request: Request.Schedule) {
|
|
104
|
+
val network = if (request.unmetered) JobInfo.NETWORK_TYPE_UNMETERED else JobInfo.NETWORK_TYPE_ANY
|
|
105
|
+
if (request.delayMs == 0L && sdkInt >= Build.VERSION_CODES.S) {
|
|
106
|
+
val expedited = try {
|
|
107
|
+
scheduler.schedule(builder(network).setExpedited(true).build()) == JobScheduler.RESULT_SUCCESS
|
|
108
|
+
} catch (e: Exception) {
|
|
109
|
+
Logger.d(TAG, "expedited upload job refused (${e.javaClass.simpleName}: ${e.message})")
|
|
110
|
+
false
|
|
111
|
+
}
|
|
112
|
+
if (expedited) {
|
|
113
|
+
Logger.d(TAG, "upload job scheduled (expedited, network ${networkName(network)})")
|
|
114
|
+
return
|
|
115
|
+
}
|
|
116
|
+
Logger.d(TAG, "expedited upload job refused (quota used up?); scheduling a regular job")
|
|
117
|
+
}
|
|
118
|
+
val builder = builder(network)
|
|
119
|
+
if (request.delayMs > 0) builder.setMinimumLatency(request.delayMs)
|
|
120
|
+
val result = scheduler.schedule(builder.build())
|
|
121
|
+
if (result == JobScheduler.RESULT_SUCCESS) {
|
|
122
|
+
Logger.d(TAG, "upload job scheduled in ${request.delayMs / 1_000} s (network ${networkName(network)})")
|
|
123
|
+
} else {
|
|
124
|
+
synchronized(lock) { lastApplied = null }
|
|
125
|
+
Logger.w(TAG, "JobScheduler refused the upload job")
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private fun builder(network: Int): JobInfo.Builder =
|
|
130
|
+
JobInfo.Builder(UPLOAD_JOB_ID, ComponentName(appContext, UploadJobService::class.java))
|
|
131
|
+
.setRequiredNetworkType(network)
|
|
132
|
+
.setPersisted(true)
|
|
133
|
+
|
|
134
|
+
private fun networkName(network: Int): String = if (network == JobInfo.NETWORK_TYPE_UNMETERED) "unmetered" else "any"
|
|
135
|
+
|
|
136
|
+
private sealed interface Request {
|
|
137
|
+
data class Schedule(val delayMs: Long, val unmetered: Boolean) : Request
|
|
138
|
+
|
|
139
|
+
data object Cancel : Request
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
internal companion object {
|
|
143
|
+
private const val TAG = "LT.UploadJob"
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* JobScheduler ids are shared with the host app's own jobs (and WorkManager's, which counts up from 0), so the
|
|
147
|
+
* plugin uses one large fixed id ("LT" in its high bytes).
|
|
148
|
+
*/
|
|
149
|
+
const val UPLOAD_JOB_ID = 0x4C54_0001
|
|
150
|
+
|
|
151
|
+
private val lock = Any()
|
|
152
|
+
|
|
153
|
+
/** True while [UploadJobService] runs a pass; guarded by [lock]. */
|
|
154
|
+
private var running = false
|
|
155
|
+
private var deferred: Request? = null
|
|
156
|
+
private var lastApplied: Request? = null
|
|
157
|
+
|
|
158
|
+
/** Marks the job as running, so the scheduling its pass does waits for [applyDeferred]. */
|
|
159
|
+
internal fun markRunning() = synchronized(lock) {
|
|
160
|
+
running = true
|
|
161
|
+
// The system dropped the job it runs now: the next request must be sent even if it equals the last one.
|
|
162
|
+
lastApplied = null
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Test hook: forgets the in-process state. */
|
|
166
|
+
internal fun resetForTest() = synchronized(lock) {
|
|
167
|
+
running = false
|
|
168
|
+
deferred = null
|
|
169
|
+
lastApplied = null
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Runs one upload job: builds `Components` (the process may have been started for this job) and lets the uploader run
|
|
176
|
+
* its scheduled passes ([HttpSyncer.runScheduledPass]). What the passes want next (another job, or none) is applied
|
|
177
|
+
* after the job has finished.
|
|
178
|
+
*/
|
|
179
|
+
class UploadJobService : JobService() {
|
|
180
|
+
@Volatile
|
|
181
|
+
private var work: Job? = null
|
|
182
|
+
|
|
183
|
+
override fun onStartJob(params: JobParameters): Boolean {
|
|
184
|
+
val components = try {
|
|
185
|
+
Components.get(applicationContext)
|
|
186
|
+
} catch (e: Exception) {
|
|
187
|
+
Logger.e(TAG, "components unavailable; upload job not run", e)
|
|
188
|
+
return false
|
|
189
|
+
}
|
|
190
|
+
JobUploadScheduler.markRunning()
|
|
191
|
+
Logger.i(TAG, "upload job started")
|
|
192
|
+
work = components.scope.launch {
|
|
193
|
+
try {
|
|
194
|
+
components.syncer.runScheduledPass()
|
|
195
|
+
} catch (e: CancellationException) {
|
|
196
|
+
throw e
|
|
197
|
+
} catch (e: Exception) {
|
|
198
|
+
Logger.e(TAG, "upload job failed", e)
|
|
199
|
+
} finally {
|
|
200
|
+
jobFinished(params, false)
|
|
201
|
+
(components.uploadScheduler as? JobUploadScheduler)?.applyDeferred()
|
|
202
|
+
Logger.d(TAG, "upload job finished")
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return true
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
override fun onStopJob(params: JobParameters): Boolean {
|
|
209
|
+
Logger.i(TAG, "upload job stopped by the system; it will run again")
|
|
210
|
+
work?.cancel()
|
|
211
|
+
return true
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
private companion object {
|
|
215
|
+
const val TAG = "LT.UploadJob"
|
|
216
|
+
}
|
|
217
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
package com.brickssoft.locationtracking.http
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.content.SharedPreferences
|
|
5
|
+
import com.brickssoft.locationtracking.core.Clock
|
|
6
|
+
import com.brickssoft.locationtracking.core.Constants
|
|
7
|
+
import com.brickssoft.locationtracking.core.Logger
|
|
8
|
+
import okhttp3.Headers.Companion.headersOf
|
|
9
|
+
import org.json.JSONObject
|
|
10
|
+
import java.security.MessageDigest
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The stored "next try" time of automatic uploads (recommendations 4 and 11): after a failed automatic upload, normal
|
|
14
|
+
* records are not sent again before [atWall] / [atElapsed]. Priority records (heartbeat and audit records) and
|
|
15
|
+
* `sync()` do not wait.
|
|
16
|
+
*
|
|
17
|
+
* @property reason why uploads wait.
|
|
18
|
+
* @property failures consecutive failed automatic uploads with reason [Reason.SERVER]; the wait doubles with each.
|
|
19
|
+
* @property atElapsed elapsed-realtime ms of the next try, valid while the boot count is [bootCount].
|
|
20
|
+
* @property atWall epoch ms of the next try, used after a reboot (elapsed realtime restarts at 0).
|
|
21
|
+
* @property authKey for [Reason.AUTHORIZATION]: a hash of the access and refresh tokens that were rejected. The wait
|
|
22
|
+
* ends as soon as the configured tokens differ (the app set new ones).
|
|
23
|
+
*/
|
|
24
|
+
data class NextTry(
|
|
25
|
+
val reason: Reason,
|
|
26
|
+
val failures: Int,
|
|
27
|
+
val atElapsed: Long,
|
|
28
|
+
val bootCount: Int,
|
|
29
|
+
val atWall: Long,
|
|
30
|
+
val authKey: String? = null,
|
|
31
|
+
) {
|
|
32
|
+
enum class Reason(val wire: String) {
|
|
33
|
+
/** The server failed or rejected the upload, or did not answer. */
|
|
34
|
+
SERVER("server"),
|
|
35
|
+
|
|
36
|
+
/** HTTP 401 and the token could not be refreshed, or the stored tokens cannot be read. */
|
|
37
|
+
AUTHORIZATION("authorization"),
|
|
38
|
+
;
|
|
39
|
+
|
|
40
|
+
companion object {
|
|
41
|
+
fun fromWire(value: String?): Reason? = entries.firstOrNull { it.wire == value }
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Milliseconds until the next try at the times of [clock]; 0 or less when it is due. */
|
|
46
|
+
fun remainingMs(clock: Clock): Long {
|
|
47
|
+
val boot = clock.bootCount()
|
|
48
|
+
return if (bootCount >= 0 && boot == bootCount) atElapsed - clock.elapsedRealtime() else atWall - clock.now()
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
fun toJson(): JSONObject = JSONObject()
|
|
52
|
+
.put("reason", reason.wire)
|
|
53
|
+
.put("failures", failures)
|
|
54
|
+
.put("atElapsed", atElapsed)
|
|
55
|
+
.put("bootCount", bootCount)
|
|
56
|
+
.put("atWall", atWall)
|
|
57
|
+
.put("authKey", authKey ?: JSONObject.NULL)
|
|
58
|
+
|
|
59
|
+
companion object {
|
|
60
|
+
/** Null if [json] is not a stored [NextTry]. */
|
|
61
|
+
fun fromJson(json: JSONObject): NextTry? {
|
|
62
|
+
val reason = Reason.fromWire(json.optString("reason")) ?: return null
|
|
63
|
+
return NextTry(
|
|
64
|
+
reason = reason,
|
|
65
|
+
failures = json.optInt("failures", 1),
|
|
66
|
+
atElapsed = json.optLong("atElapsed"),
|
|
67
|
+
bootCount = json.optInt("bootCount", -1),
|
|
68
|
+
atWall = json.optLong("atWall"),
|
|
69
|
+
authKey = if (json.isNull("authKey")) null else json.optString("authKey"),
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Persists the [NextTry] across process restarts. */
|
|
76
|
+
interface NextTryStore {
|
|
77
|
+
fun load(): NextTry?
|
|
78
|
+
|
|
79
|
+
fun save(value: NextTry?)
|
|
80
|
+
|
|
81
|
+
companion object {
|
|
82
|
+
/** Keeps the value in memory only (tests, and callers without a Context). */
|
|
83
|
+
fun inMemory(): NextTryStore = object : NextTryStore {
|
|
84
|
+
@Volatile
|
|
85
|
+
private var value: NextTry? = null
|
|
86
|
+
|
|
87
|
+
override fun load(): NextTry? = value
|
|
88
|
+
|
|
89
|
+
override fun save(value: NextTry?) {
|
|
90
|
+
this.value = value
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** [NextTryStore] in the plugin's SharedPreferences file, key [KEY]. */
|
|
97
|
+
class PrefsNextTryStore(context: Context) : NextTryStore {
|
|
98
|
+
private val prefs: SharedPreferences by lazy {
|
|
99
|
+
(context.applicationContext ?: context).getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
override fun load(): NextTry? = try {
|
|
103
|
+
prefs.getString(KEY, null)?.let { NextTry.fromJson(JSONObject(it)) }
|
|
104
|
+
} catch (e: Exception) {
|
|
105
|
+
Logger.w(TAG, "stored upload wait is unreadable; ignoring it", e)
|
|
106
|
+
null
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
override fun save(value: NextTry?) {
|
|
110
|
+
try {
|
|
111
|
+
val editor = prefs.edit()
|
|
112
|
+
if (value == null) editor.remove(KEY) else editor.putString(KEY, value.toJson().toString())
|
|
113
|
+
editor.apply()
|
|
114
|
+
} catch (e: Exception) {
|
|
115
|
+
Logger.w(TAG, "cannot store the upload wait", e)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
private companion object {
|
|
120
|
+
const val TAG = "LT.Http"
|
|
121
|
+
const val KEY = "http_next_try"
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Wait rules for failed uploads.
|
|
127
|
+
*
|
|
128
|
+
* - Inside one upload (owner decision R2-Q18): the retries after 2, 4 and 8 s, each moved by a random amount of up to
|
|
129
|
+
* 20 % up or down ([retryDelayMs]), so many phones that failed at the same moment do not retry at the same moment.
|
|
130
|
+
* - Between automatic uploads ([serverWaitMs]): 30 s after the first failure, doubling with each further failure up to
|
|
131
|
+
* 15 minutes, each moved by up to 20 % up or down; never shorter than a `Retry-After` of a 429 or 503 answer (limited
|
|
132
|
+
* to `http.maxRetryAfter`) and, while `http.syncInterval` applies, never shorter than `syncInterval`.
|
|
133
|
+
* - After an authorization failure: [AUTH_WAIT_MS] (15 minutes), or until the app sets new tokens.
|
|
134
|
+
*/
|
|
135
|
+
object UploadBackoff {
|
|
136
|
+
/** Waits before the retries of one upload, before jitter. */
|
|
137
|
+
val RETRY_DELAYS_MS = longArrayOf(2_000L, 4_000L, 8_000L)
|
|
138
|
+
|
|
139
|
+
const val FIRST_WAIT_MS = 30_000L
|
|
140
|
+
const val MAX_WAIT_MS = 15 * 60_000L
|
|
141
|
+
const val AUTH_WAIT_MS = 15 * 60_000L
|
|
142
|
+
|
|
143
|
+
/** Largest relative change of a wait by jitter (20 %). */
|
|
144
|
+
const val JITTER = 0.2
|
|
145
|
+
|
|
146
|
+
/** [base] moved by up to [JITTER] up or down; [random] is in [0, 1). */
|
|
147
|
+
fun jittered(base: Long, random: Double): Long = (base * (1.0 - JITTER + 2 * JITTER * random)).toLong()
|
|
148
|
+
|
|
149
|
+
/** The wait before retry number [index] (0-based) of one upload. */
|
|
150
|
+
fun retryDelayMs(index: Int, random: Double): Long = jittered(RETRY_DELAYS_MS[index], random)
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* The wait after the [failures]-th consecutive failed automatic upload (1 = first failure), at least
|
|
154
|
+
* [retryAfterMs] (already limited to `http.maxRetryAfter`) and [minimumMs].
|
|
155
|
+
*/
|
|
156
|
+
fun serverWaitMs(failures: Int, random: Double, retryAfterMs: Long?, minimumMs: Long): Long {
|
|
157
|
+
val doublings = (failures - 1).coerceIn(0, 30)
|
|
158
|
+
val base = minOf(FIRST_WAIT_MS shl minOf(doublings, 20), MAX_WAIT_MS)
|
|
159
|
+
return maxOf(jittered(base, random), retryAfterMs ?: 0L, minimumMs)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The `Retry-After` value in ms (delta-seconds or an HTTP date relative to [now]), at most [maxSeconds] seconds;
|
|
164
|
+
* null if absent or unreadable. A date in the past gives 0.
|
|
165
|
+
*/
|
|
166
|
+
fun retryAfterMs(value: String?, now: Long, maxSeconds: Int): Long? {
|
|
167
|
+
val text = value?.trim()?.takeIf { it.isNotEmpty() } ?: return null
|
|
168
|
+
val ms = text.toLongOrNull()?.let { if (it < 0) null else it * 1_000L }
|
|
169
|
+
?: try {
|
|
170
|
+
headersOf(HttpSupport.RETRY_AFTER, text).getDate(HttpSupport.RETRY_AFTER)?.time?.let { maxOf(0L, it - now) }
|
|
171
|
+
} catch (e: IllegalArgumentException) {
|
|
172
|
+
null
|
|
173
|
+
}
|
|
174
|
+
?: return null
|
|
175
|
+
return minOf(ms, maxSeconds.coerceAtLeast(0) * 1_000L)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** A short, non-reversible key of a token pair, so the stored wait never holds a token. */
|
|
179
|
+
fun authKey(accessToken: String?, refreshToken: String?): String {
|
|
180
|
+
val digest = MessageDigest.getInstance("SHA-256")
|
|
181
|
+
.digest("${accessToken.orEmpty()}\u0000${refreshToken.orEmpty()}".toByteArray(Charsets.UTF_8))
|
|
182
|
+
return digest.take(12).joinToString("") { "%02x".format(it) }
|
|
183
|
+
}
|
|
184
|
+
}
|
|
@@ -43,8 +43,9 @@ import java.util.concurrent.atomic.AtomicBoolean
|
|
|
43
43
|
import java.util.zip.GZIPOutputStream
|
|
44
44
|
|
|
45
45
|
/**
|
|
46
|
-
* [LogStore] that keeps the log in daily UTC files `
|
|
47
|
-
* of [LogFormatter].
|
|
46
|
+
* [LogStore] that keeps the log in daily UTC files `noBackupFilesDir/location-tracking-logs/lt-YYYY-MM-DD.log`, in the
|
|
47
|
+
* format of [LogFormatter]. `noBackupFilesDir` keeps the logs out of Android Auto Backup and device-to-device transfer
|
|
48
|
+
* (recommendation 7); files written by an older version under `filesDir` are moved there on first use.
|
|
48
49
|
*
|
|
49
50
|
* [write] never blocks and never throws: it stamps the entry with [Clock.now] and appends it to an in-memory queue
|
|
50
51
|
* (beyond [maxPending] pending entries the oldest are dropped, and a warning records how many). One writer at a time
|
|
@@ -52,7 +53,10 @@ import java.util.zip.GZIPOutputStream
|
|
|
52
53
|
* they see every entry written before they were called.
|
|
53
54
|
*
|
|
54
55
|
* Files are purged by [configure] and on each UTC day rollover: a file is deleted when its whole day lies more than
|
|
55
|
-
* `maxDays` days back, i.e. the files of today and of the previous `maxDays` days are kept.
|
|
56
|
+
* `maxDays` days back, i.e. the files of today and of the previous `maxDays` days are kept. On top of that, the files
|
|
57
|
+
* together are kept under `maxTotalBytes` (10 MB; recommendation 16): after every [SIZE_CHECK_BYTES] written, and when
|
|
58
|
+
* purging, the oldest daily files are deleted until they fit. The file of the current day is never deleted, so a single
|
|
59
|
+
* day of verbose logging can still exceed the limit.
|
|
56
60
|
*
|
|
57
61
|
* [read] returns whole entries (a header line plus its indented continuation lines). `start`/`end` are inclusive
|
|
58
62
|
* bounds on the entry time; `level` keeps entries at that severity or above, as [LogLevel.allows] (so `OFF` matches
|
|
@@ -69,6 +73,7 @@ class FileLogger(
|
|
|
69
73
|
private val dispatchers: AppDispatchers,
|
|
70
74
|
private val http: Lazy<OkHttpClient>,
|
|
71
75
|
maxPending: Int = DEFAULT_MAX_PENDING,
|
|
76
|
+
private val maxTotalBytes: Long = DEFAULT_MAX_TOTAL_BYTES,
|
|
72
77
|
) : LogStore {
|
|
73
78
|
/** A queued entry; the stack trace is rendered at write time so the throwable is not retained. */
|
|
74
79
|
private class Entry(
|
|
@@ -82,8 +87,8 @@ class FileLogger(
|
|
|
82
87
|
private val appContext: Context = context.applicationContext ?: context
|
|
83
88
|
private val maxPending = maxPending.coerceAtLeast(1)
|
|
84
89
|
|
|
85
|
-
// Resolved on first use, on the writer's thread: Context.
|
|
86
|
-
private val logDir: File by lazy {
|
|
90
|
+
// Resolved on first use, on the writer's thread: Context.getNoBackupFilesDir() may touch the disk.
|
|
91
|
+
private val logDir: File by lazy { noBackupLogDir() }
|
|
87
92
|
private val cacheLogDir: File by lazy { File(appContext.cacheDir, Constants.LOG_DIR) }
|
|
88
93
|
|
|
89
94
|
private val queueLock = Any()
|
|
@@ -95,6 +100,7 @@ class FileLogger(
|
|
|
95
100
|
private val fileLock = Mutex()
|
|
96
101
|
private var lastWrittenDay = Long.MIN_VALUE // guarded by fileLock
|
|
97
102
|
private var newlineCheckedFile: String? = null // guarded by fileLock
|
|
103
|
+
private var bytesSinceSizeCheck = 0L // guarded by fileLock
|
|
98
104
|
|
|
99
105
|
@Volatile
|
|
100
106
|
private var level: LogLevel = LogLevel.INFO
|
|
@@ -318,8 +324,11 @@ class FileLogger(
|
|
|
318
324
|
val e = batch[i]
|
|
319
325
|
text.append(LogFormatter.formatEntry(e.timeMs, e.level, e.tag, e.message, e.stackTrace))
|
|
320
326
|
}
|
|
321
|
-
|
|
327
|
+
val bytes = text.toString().toByteArray(Charsets.UTF_8)
|
|
328
|
+
FileOutputStream(file, true).use { it.write(bytes) }
|
|
322
329
|
newlineCheckedFile = file.name
|
|
330
|
+
bytesSinceSizeCheck += bytes.size
|
|
331
|
+
if (bytesSinceSizeCheck >= SIZE_CHECK_BYTES) limitTotalSizeLocked(file)
|
|
323
332
|
} catch (e: IOException) {
|
|
324
333
|
newlineCheckedFile = null
|
|
325
334
|
fallbackLog("could not write $file", e)
|
|
@@ -334,7 +343,10 @@ class FileLogger(
|
|
|
334
343
|
}
|
|
335
344
|
}
|
|
336
345
|
|
|
337
|
-
/**
|
|
346
|
+
/**
|
|
347
|
+
* Deletes daily files whose day is more than [maxDays] days before [today], then the oldest ones beyond
|
|
348
|
+
* `maxTotalBytes`. Caller holds [fileLock].
|
|
349
|
+
*/
|
|
338
350
|
private fun purgeLocked(today: Long) {
|
|
339
351
|
val keepFrom = today - maxDays.coerceAtLeast(0)
|
|
340
352
|
try {
|
|
@@ -342,6 +354,47 @@ class FileLogger(
|
|
|
342
354
|
} catch (e: RuntimeException) {
|
|
343
355
|
fallbackLog("could not purge old log files", e)
|
|
344
356
|
}
|
|
357
|
+
limitTotalSizeLocked(File(logDir, LogFormatter.fileName(clock.now())))
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** Deletes the oldest daily files, never [current], until all of them fit in `maxTotalBytes`. Caller holds [fileLock]. */
|
|
361
|
+
private fun limitTotalSizeLocked(current: File) {
|
|
362
|
+
bytesSinceSizeCheck = 0
|
|
363
|
+
try {
|
|
364
|
+
val files = logFiles().map { it.second }
|
|
365
|
+
var total = files.sumOf { it.length() }
|
|
366
|
+
for (file in files) {
|
|
367
|
+
if (total <= maxTotalBytes) break
|
|
368
|
+
if (file.name == current.name) continue
|
|
369
|
+
val size = file.length()
|
|
370
|
+
if (file.delete()) total -= size
|
|
371
|
+
}
|
|
372
|
+
} catch (e: RuntimeException) {
|
|
373
|
+
fallbackLog("could not limit the log size", e)
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* `noBackupFilesDir/location-tracking-logs`, after moving the daily files an older version wrote to
|
|
379
|
+
* `filesDir/location-tracking-logs` there (a file that already exists in the new folder is left in the old one).
|
|
380
|
+
*/
|
|
381
|
+
private fun noBackupLogDir(): File {
|
|
382
|
+
val target = File(appContext.noBackupFilesDir, Constants.LOG_DIR)
|
|
383
|
+
try {
|
|
384
|
+
val legacy = File(appContext.filesDir, Constants.LOG_DIR)
|
|
385
|
+
if (legacy.isDirectory) {
|
|
386
|
+
if (!target.exists() && legacy.renameTo(target)) return target
|
|
387
|
+
target.mkdirs()
|
|
388
|
+
legacy.listFiles()?.forEach { file ->
|
|
389
|
+
val moved = File(target, file.name)
|
|
390
|
+
if (!moved.exists()) file.renameTo(moved)
|
|
391
|
+
}
|
|
392
|
+
legacy.delete() // only if empty
|
|
393
|
+
}
|
|
394
|
+
} catch (e: RuntimeException) {
|
|
395
|
+
fallbackLog("could not move the old log files", e)
|
|
396
|
+
}
|
|
397
|
+
return target
|
|
345
398
|
}
|
|
346
399
|
|
|
347
400
|
/** Daily log files with their day numbers, oldest first. */
|
|
@@ -447,6 +500,12 @@ class FileLogger(
|
|
|
447
500
|
const val DEFAULT_MAX_PENDING = 5_000
|
|
448
501
|
const val DEFAULT_MAX_DAYS = 3
|
|
449
502
|
|
|
503
|
+
/** All daily log files together are kept under this size (recommendation 16). */
|
|
504
|
+
const val DEFAULT_MAX_TOTAL_BYTES = 10L * 1024 * 1024
|
|
505
|
+
|
|
506
|
+
/** The size limit is checked after this many bytes were written. */
|
|
507
|
+
const val SIZE_CHECK_BYTES = 256L * 1024
|
|
508
|
+
|
|
450
509
|
/** Name of the gzipped log attached by [upload] and [prepareEmail]. */
|
|
451
510
|
const val UPLOAD_FILE_NAME = "location-tracking-log.txt.gz"
|
|
452
511
|
const val EMAIL_FILE_NAME = UPLOAD_FILE_NAME
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
package com.brickssoft.locationtracking.logging
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Removes credentials and addresses from text the app passes to the JS `log()` method (recommendation 16), before it
|
|
5
|
+
* reaches logcat and the log files, which can be emailed or uploaded:
|
|
6
|
+
* - values of credential fields (`accessToken`, `refresh_token`, `password`, `secret`, `apiKey`, `authorization`, …)
|
|
7
|
+
* written as `key: value`, `key=value` or JSON `"key": "value"`, in any letter case;
|
|
8
|
+
* - `Bearer <token>`;
|
|
9
|
+
* - JSON Web Tokens (`eyJ….….…`);
|
|
10
|
+
* - URLs (`http(s)://…`, `ws(s)://…`), which can carry tokens in their query or user info.
|
|
11
|
+
*/
|
|
12
|
+
internal object LogRedaction {
|
|
13
|
+
const val REDACTED = "[redacted]"
|
|
14
|
+
|
|
15
|
+
private val SECRET_FIELD = Regex(
|
|
16
|
+
"(?i)(\"?\\b(?:access[_-]?token|refresh[_-]?token|id[_-]?token|token|password|passwd|secret|client[_-]?secret|" +
|
|
17
|
+
"api[_-]?key|authorization)\\b\"?\\s*[:=]\\s*)(\"(?:[^\"\\\\]|\\\\.)*\"|[^\\s,;&}]+)",
|
|
18
|
+
)
|
|
19
|
+
private val BEARER = Regex("(?i)\\bBearer\\s+[A-Za-z0-9._~+/=-]+")
|
|
20
|
+
private val JWT = Regex("\\beyJ[A-Za-z0-9_-]*\\.[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]*")
|
|
21
|
+
private val URL = Regex("(?i)\\b(?:https?|wss?)://[^\\s\"'<>]+")
|
|
22
|
+
|
|
23
|
+
fun redact(text: String): String = text
|
|
24
|
+
.replace(SECRET_FIELD) { match ->
|
|
25
|
+
val value = match.groupValues[2]
|
|
26
|
+
match.groupValues[1] + if (value.startsWith("\"")) "\"$REDACTED\"" else REDACTED
|
|
27
|
+
}
|
|
28
|
+
.replace(BEARER, "Bearer $REDACTED")
|
|
29
|
+
.replace(JWT, "[jwt]")
|
|
30
|
+
.replace(URL, "[url]")
|
|
31
|
+
}
|