@bricks-soft/capacitor-location-tracking 8.0.1 → 8.1.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.
Files changed (74) hide show
  1. package/README.md +375 -124
  2. package/android/src/main/AndroidManifest.xml +2 -0
  3. package/android/src/main/java/com/brickssoft/locationtracking/bridge/OptionParsers.kt +4 -1
  4. package/android/src/main/java/com/brickssoft/locationtracking/bridge/PluginHandlers.kt +14 -5
  5. package/android/src/main/java/com/brickssoft/locationtracking/config/Config.kt +10 -0
  6. package/android/src/main/java/com/brickssoft/locationtracking/config/ConfigJson.kt +70 -15
  7. package/android/src/main/java/com/brickssoft/locationtracking/config/ConfigStore.kt +6 -0
  8. package/android/src/main/java/com/brickssoft/locationtracking/config/ConfigValidator.kt +8 -3
  9. package/android/src/main/java/com/brickssoft/locationtracking/config/RuntimeStateJson.kt +10 -0
  10. package/android/src/main/java/com/brickssoft/locationtracking/config/SecretStore.kt +188 -0
  11. package/android/src/main/java/com/brickssoft/locationtracking/config/SharedPrefsConfigStore.kt +188 -17
  12. package/android/src/main/java/com/brickssoft/locationtracking/config/State.kt +11 -0
  13. package/android/src/main/java/com/brickssoft/locationtracking/core/Components.kt +21 -5
  14. package/android/src/main/java/com/brickssoft/locationtracking/core/ForceStopProbe.kt +20 -9
  15. package/android/src/main/java/com/brickssoft/locationtracking/core/TrackingEvent.kt +24 -2
  16. package/android/src/main/java/com/brickssoft/locationtracking/data/TrackingDatabase.kt +43 -1
  17. package/android/src/main/java/com/brickssoft/locationtracking/device/DefaultDeviceInfoProvider.kt +17 -0
  18. package/android/src/main/java/com/brickssoft/locationtracking/device/DeviceDetails.kt +167 -0
  19. package/android/src/main/java/com/brickssoft/locationtracking/engine/DefaultTrackingEngine.kt +191 -34
  20. package/android/src/main/java/com/brickssoft/locationtracking/engine/TrackingEngine.kt +6 -0
  21. package/android/src/main/java/com/brickssoft/locationtracking/engine/TrackingTimers.kt +3 -0
  22. package/android/src/main/java/com/brickssoft/locationtracking/heartbeat/DefaultHeartbeatScheduler.kt +52 -8
  23. package/android/src/main/java/com/brickssoft/locationtracking/heartbeat/HeartbeatAlarmReceiver.kt +22 -0
  24. package/android/src/main/java/com/brickssoft/locationtracking/http/AuthorizationManager.kt +78 -27
  25. package/android/src/main/java/com/brickssoft/locationtracking/http/ConfigChecks.kt +140 -0
  26. package/android/src/main/java/com/brickssoft/locationtracking/http/HttpSupport.kt +50 -12
  27. package/android/src/main/java/com/brickssoft/locationtracking/http/HttpSyncer.kt +17 -0
  28. package/android/src/main/java/com/brickssoft/locationtracking/http/OkHttpSyncer.kt +507 -92
  29. package/android/src/main/java/com/brickssoft/locationtracking/http/SyncPolicy.kt +6 -6
  30. package/android/src/main/java/com/brickssoft/locationtracking/http/TemplateRenderer.kt +23 -1
  31. package/android/src/main/java/com/brickssoft/locationtracking/http/UploadJobs.kt +217 -0
  32. package/android/src/main/java/com/brickssoft/locationtracking/http/UploadWait.kt +184 -0
  33. package/android/src/main/java/com/brickssoft/locationtracking/logging/FileLogger.kt +66 -7
  34. package/android/src/main/java/com/brickssoft/locationtracking/logging/LogRedaction.kt +31 -0
  35. package/android/src/main/java/com/brickssoft/locationtracking/model/DeviceModels.kt +44 -1
  36. package/android/src/main/java/com/brickssoft/locationtracking/model/ModelJson.kt +79 -8
  37. package/android/src/main/java/com/brickssoft/locationtracking/model/Record.kt +9 -0
  38. package/android/src/main/java/com/brickssoft/locationtracking/model/TrackedLocation.kt +12 -2
  39. package/android/src/main/java/com/brickssoft/locationtracking/position/DefaultPositionService.kt +10 -2
  40. package/android/src/main/java/com/brickssoft/locationtracking/position/PositionWatch.kt +24 -1
  41. package/android/src/main/java/com/brickssoft/locationtracking/processing/DefaultRecordFactory.kt +31 -10
  42. package/android/src/main/java/com/brickssoft/locationtracking/provider/Backends.kt +48 -3
  43. package/android/src/main/java/com/brickssoft/locationtracking/provider/DefaultProviderFactory.kt +60 -0
  44. package/android/src/main/java/com/brickssoft/locationtracking/provider/LocationSubscriptions.kt +180 -0
  45. package/android/src/main/java/com/brickssoft/locationtracking/provider/android/AndroidLocationBackend.kt +200 -126
  46. package/android/src/main/java/com/brickssoft/locationtracking/provider/android/LocationProviders.kt +20 -0
  47. package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsLocationBackend.kt +39 -51
  48. package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsProviderBundle.kt +8 -0
  49. package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsLocationBackend.kt +47 -53
  50. package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsProviderBundle.kt +34 -2
  51. package/android/src/main/java/com/brickssoft/locationtracking/record/RecordSink.kt +19 -1
  52. package/android/src/main/java/com/brickssoft/locationtracking/service/BootCountStore.kt +7 -0
  53. package/android/src/main/res/xml/lt_file_paths.xml +1 -1
  54. package/dist/esm/definitions.d.ts +107 -4
  55. package/dist/esm/definitions.js.map +1 -1
  56. package/dist/esm/events.d.ts +8 -2
  57. package/dist/esm/events.js +7 -1
  58. package/dist/esm/events.js.map +1 -1
  59. package/dist/esm/index.d.ts +1 -1
  60. package/dist/esm/index.js +1 -1
  61. package/dist/esm/index.js.map +1 -1
  62. package/dist/esm/web/config.js +10 -0
  63. package/dist/esm/web/config.js.map +1 -1
  64. package/dist/esm/web/device.d.ts +1 -1
  65. package/dist/esm/web/device.js +8 -1
  66. package/dist/esm/web/device.js.map +1 -1
  67. package/dist/esm/web.d.ts +0 -8
  68. package/dist/esm/web.js +12 -3
  69. package/dist/esm/web.js.map +1 -1
  70. package/dist/plugin.cjs.js +39 -5
  71. package/dist/plugin.cjs.js.map +1 -1
  72. package/dist/plugin.js +39 -5
  73. package/dist/plugin.js.map +1 -1
  74. package/package.json +2 -2
@@ -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); nothing while a retry after a failed upload is pending.
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 object (or null).
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 `filesDir/location-tracking-logs/lt-YYYY-MM-DD.log`, in the format
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.getFilesDir() may touch the disk.
86
- private val logDir: File by lazy { File(appContext.filesDir, Constants.LOG_DIR) }
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
- FileOutputStream(file, true).use { it.write(text.toString().toByteArray(Charsets.UTF_8)) }
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
- /** Deletes daily files whose day is more than [maxDays] days before [today]. Caller holds [fileLock]. */
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
+ }