@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
@@ -1,10 +1,13 @@
1
1
  package com.brickssoft.locationtracking.config
2
2
 
3
+ import android.annotation.SuppressLint
3
4
  import android.content.Context
4
5
  import android.content.SharedPreferences
5
6
  import com.brickssoft.locationtracking.core.Clock
6
7
  import com.brickssoft.locationtracking.core.Constants
7
8
  import com.brickssoft.locationtracking.core.Logger
9
+ import com.brickssoft.locationtracking.http.ConfigChecks
10
+ import com.brickssoft.locationtracking.service.BootCountStore
8
11
  import kotlinx.coroutines.flow.MutableStateFlow
9
12
  import kotlinx.coroutines.flow.StateFlow
10
13
  import kotlinx.coroutines.flow.asStateFlow
@@ -16,24 +19,58 @@ import org.json.JSONObject
16
19
  * Config and runtime state are loaded synchronously in the constructor. An unreadable stored value falls back to
17
20
  * its default; unreadable JSON text falls back to all defaults.
18
21
  *
19
- * Every write runs under one lock: it computes the new value, persists it with `apply()` and publishes it on the
20
- * matching [StateFlow] before returning. Config writes go through [ConfigJson.parse] and [ConfigValidator].
21
- * Only the `cfg_` keys of the shared prefs file are touched.
22
+ * Every write runs under one lock: it computes the new value, persists it and publishes it on the matching
23
+ * [StateFlow] before returning. Writes use `apply()`, except a change of `runtime.enabled`, which uses `commit()`, so a
24
+ * process killed a few milliseconds after `stop()` cannot bring tracking back (recommendation 16). Config writes go
25
+ * through [ConfigJson.parse] and [ConfigValidator]. Only the `cfg_` keys of the shared prefs file are touched.
26
+ *
27
+ * **Tokens** (recommendation 8). `accessToken`, `refreshToken`, `refreshPayload` and `refreshHeaders` of
28
+ * `http.authorization` are never written to the prefs file: they go to [secrets] (AES-GCM with an Android Keystore key,
29
+ * under `noBackupFilesDir`), and the prefs file keeps the rest of the config. Plain-text tokens stored by an older
30
+ * version are moved there on the first load. If the stored tokens cannot be decrypted (the key is gone, the file is
31
+ * damaged, or the Keystore throws), they read as unset and [secretsUnavailable] is true: the uploader then sends
32
+ * nothing that needs them and emits an `authorization` event with an error, until the app sets new tokens. If they
33
+ * cannot be encrypted, they are kept in memory only (logged as an error).
34
+ *
35
+ * **Restored onto another installation** (recommendation 7). The app's `firstInstallTime` is stored; when it differs
36
+ * from the stored one, the prefs file came from another installation (Auto Backup restore or device-to-device
37
+ * transfer). Tracking is then never turned on by itself: `enabled` is cleared, the tokens are removed and the boot
38
+ * counts are cleared.
22
39
  */
23
40
  class SharedPrefsConfigStore(
24
41
  context: Context,
25
42
  @Suppress("unused") private val clock: Clock,
43
+ private val secrets: SecretStore = FileSecretStore.forContext(context),
26
44
  ) : ConfigStore {
27
- private val prefs: SharedPreferences =
28
- (context.applicationContext ?: context).getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
45
+ private val appContext: Context = context.applicationContext ?: context
46
+ private val prefs: SharedPreferences = appContext.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
29
47
  private val lock = Any()
30
48
 
49
+ /** The secrets [secrets] holds now (null: none). Guarded by [lock] after construction. */
50
+ private var storedSecrets: AuthorizationSecrets? = null
51
+
52
+ @Volatile
53
+ private var secretsLost = false
54
+
55
+ /** The plain-text tokens of an older version were moved to [secrets]: the prefs file must be rewritten. */
56
+ private var migratedSecrets = false
57
+
58
+ private val restored: Boolean = detectRestore()
59
+
31
60
  private val configFlow = MutableStateFlow(loadConfig())
32
61
  private val runtimeFlow = MutableStateFlow(loadRuntime())
33
62
 
34
63
  override val config: StateFlow<Config> = configFlow.asStateFlow()
35
64
  override val runtime: StateFlow<RuntimeState> = runtimeFlow.asStateFlow()
36
65
 
66
+ override val secretsUnavailable: Boolean get() = secretsLost
67
+
68
+ init {
69
+ if (restored || migratedSecrets) {
70
+ persist(configFlow.value, runtimeFlow.value.takeIf { restored }, sync = true)
71
+ }
72
+ }
73
+
37
74
  override fun ready(json: JSONObject?, reset: Boolean): Config = synchronized(lock) {
38
75
  val current = configFlow.value
39
76
  val next = when {
@@ -63,7 +100,7 @@ class SharedPrefsConfigStore(
63
100
  val current = runtimeFlow.value
64
101
  val next = transform(current)
65
102
  if (next != current) {
66
- persist(null, next)
103
+ persist(null, next, sync = next.enabled != current.enabled)
67
104
  runtimeFlow.value = next
68
105
  }
69
106
  next
@@ -79,21 +116,105 @@ class SharedPrefsConfigStore(
79
116
  next
80
117
  }
81
118
 
82
- /** Defaults-or-[base] plus [json], clamped. Throws INVALID_ARGUMENT (before anything changes) on bad input. */
83
- private fun build(json: JSONObject?, base: Config): Config =
84
- ConfigValidator.validate(if (json == null) base else ConfigJson.parse(json, base))
119
+ /**
120
+ * Defaults-or-[base] plus [json], clamped, then checked ([ConfigChecks]). Throws INVALID_ARGUMENT (before anything
121
+ * changes) on bad input; logs the warnings.
122
+ */
123
+ private fun build(json: JSONObject?, base: Config): Config {
124
+ val config = ConfigValidator.validate(if (json == null) base else ConfigJson.parse(json, base))
125
+ ConfigChecks.check(config).forEach { Logger.w(TAG, it) }
126
+ return config
127
+ }
85
128
 
86
- /** Writes the non-null values in one `apply()`. A value that cannot be serialized is logged and skipped. */
87
- private fun persist(config: Config?, runtime: RuntimeState?) {
129
+ /**
130
+ * Writes the non-null values in one `apply()` (`commit()` when [sync]). A value that cannot be serialized is logged
131
+ * and skipped. The config's tokens go to [secrets] (see the class docs), never to the prefs file.
132
+ */
133
+ @SuppressLint("ApplySharedPref") // commit() only for `enabled` changes, which must be on disk at once
134
+ private fun persist(config: Config?, runtime: RuntimeState?, sync: Boolean = false) {
88
135
  if (config == null && runtime == null) return
89
136
  val editor = prefs.edit()
90
137
  if (config != null) {
91
- serialize("config") { ConfigJson.toJson(config) }?.let { editor.putString(KEY_CONFIG, it) }
138
+ storeSecrets(config, editor)
139
+ serialize("config") { ConfigJson.toJson(withoutSecrets(config)) }?.let { editor.putString(KEY_CONFIG, it) }
92
140
  }
93
141
  if (runtime != null) {
94
142
  serialize("runtime state") { RuntimeStateJson.toJson(runtime) }?.let { editor.putString(KEY_RUNTIME, it) }
95
143
  }
96
- editor.apply()
144
+ if (sync) editor.commit() else editor.apply()
145
+ }
146
+
147
+ /** Writes [config]'s tokens to [secrets] if they changed. */
148
+ private fun storeSecrets(config: Config, editor: SharedPreferences.Editor) {
149
+ val auth = config.http.authorization
150
+ val wanted = auth?.let { AuthorizationSecrets.of(it) }?.takeUnless { it == AuthorizationSecrets.EMPTY }
151
+ if (wanted == storedSecrets) return
152
+ try {
153
+ if (wanted == null) secrets.clear() else secrets.write(wanted)
154
+ storedSecrets = wanted
155
+ editor.putBoolean(KEY_SECRETS, wanted != null)
156
+ // New tokens from the app (or no authorization at all) end the "cannot be decrypted" state.
157
+ if (auth == null || wanted?.accessToken != null || wanted?.refreshToken != null) secretsLost = false
158
+ } catch (e: Exception) {
159
+ // GeneralSecurityException, and the RuntimeException (ProviderException) some Keystores throw. The old
160
+ // tokens are removed so they cannot come back after a restart (for example after a user switch).
161
+ Logger.e(TAG, "cannot store the authorization tokens encrypted; they are kept in memory only", e)
162
+ secrets.runCatching { clear() }
163
+ storedSecrets = null
164
+ editor.putBoolean(KEY_SECRETS, false)
165
+ }
166
+ }
167
+
168
+ private fun withoutSecrets(config: Config): Config {
169
+ val auth = config.http.authorization ?: return config
170
+ val stripped = auth.copy(
171
+ accessToken = null,
172
+ refreshToken = null,
173
+ refreshPayload = AuthorizationSecrets.EMPTY.refreshPayload,
174
+ refreshHeaders = emptyMap(),
175
+ )
176
+ return config.copy(http = config.http.copy(authorization = stripped))
177
+ }
178
+
179
+ private fun withSecrets(config: Config, values: AuthorizationSecrets): Config {
180
+ val auth = config.http.authorization ?: return config
181
+ val merged = auth.copy(
182
+ accessToken = values.accessToken,
183
+ refreshToken = values.refreshToken,
184
+ refreshPayload = values.refreshPayload,
185
+ refreshHeaders = values.refreshHeaders,
186
+ )
187
+ return config.copy(http = config.http.copy(authorization = merged))
188
+ }
189
+
190
+ /**
191
+ * True when the prefs file came from another installation (see the class docs); stores the current
192
+ * `firstInstallTime` either way. A missing stored value (first run, or an update from a version that did not store
193
+ * it) is not a restore.
194
+ */
195
+ @SuppressLint("ApplySharedPref")
196
+ private fun detectRestore(): Boolean {
197
+ val installed = try {
198
+ appContext.packageManager.getPackageInfo(appContext.packageName, 0).firstInstallTime
199
+ } catch (e: Exception) {
200
+ Logger.w(TAG, "firstInstallTime unavailable; restores from another installation are not detected", e)
201
+ return false
202
+ }
203
+ val stored = prefs.getLong(KEY_FIRST_INSTALL, NO_INSTALL_TIME)
204
+ if (stored == installed) return false
205
+ prefs.edit().putLong(KEY_FIRST_INSTALL, installed).commit()
206
+ if (stored == NO_INSTALL_TIME) return false
207
+ Logger.w(
208
+ TAG,
209
+ "the stored state comes from another installation (backup restore or device transfer): tracking is not " +
210
+ "turned on by itself; tokens and boot counts are cleared",
211
+ )
212
+ try {
213
+ BootCountStore.clear(appContext)
214
+ } catch (e: Exception) {
215
+ Logger.w(TAG, "cannot clear the boot counts", e)
216
+ }
217
+ return true
97
218
  }
98
219
 
99
220
  private inline fun serialize(what: String, toJson: () -> JSONObject): String? = try {
@@ -105,22 +226,65 @@ class SharedPrefsConfigStore(
105
226
 
106
227
  private fun loadConfig(): Config {
107
228
  val text = prefs.getString(KEY_CONFIG, null) ?: return Config()
108
- return try {
229
+ val stored = try {
109
230
  ConfigValidator.validate(ConfigJson.parseStored(JSONObject(text)))
110
231
  } catch (e: Exception) {
111
232
  Logger.e(TAG, "persisted config is unreadable; using defaults", e)
112
- Config()
233
+ return Config()
234
+ }
235
+ if (restored) {
236
+ secrets.runCatching { clear() }
237
+ prefs.edit().remove(KEY_SECRETS).apply()
238
+ return withoutSecrets(stored)
239
+ }
240
+ return withStoredSecrets(stored)
241
+ }
242
+
243
+ /** [stored] with the tokens from [secrets], or with the plain-text tokens of an older version moved there. */
244
+ private fun withStoredSecrets(stored: Config): Config {
245
+ val auth = stored.http.authorization ?: return stored
246
+ val plain = AuthorizationSecrets.of(auth).takeUnless { it == AuthorizationSecrets.EMPTY }
247
+ val values = try {
248
+ secrets.read()
249
+ } catch (e: Exception) {
250
+ // GeneralSecurityException, a damaged file, or a Keystore ProviderException (a RuntimeException).
251
+ Logger.e(TAG, "the stored tokens cannot be decrypted; uploads that need them wait for new tokens", e)
252
+ secretsLost = true
253
+ return withoutSecrets(stored)
113
254
  }
255
+ if (values != null) {
256
+ storedSecrets = values
257
+ return withSecrets(stored, values)
258
+ }
259
+ if (plain != null) {
260
+ // Written in plain text by an older version: move them now.
261
+ try {
262
+ secrets.write(plain)
263
+ storedSecrets = plain
264
+ migratedSecrets = true
265
+ prefs.edit().putBoolean(KEY_SECRETS, true).apply()
266
+ Logger.i(TAG, "authorization tokens moved to encrypted storage")
267
+ } catch (e: Exception) {
268
+ Logger.w(TAG, "cannot encrypt the stored tokens yet; they stay in the old place until the next start", e)
269
+ }
270
+ return stored
271
+ }
272
+ if (prefs.getBoolean(KEY_SECRETS, false)) {
273
+ Logger.e(TAG, "the encrypted tokens are missing; uploads that need them wait for new tokens")
274
+ secretsLost = true
275
+ }
276
+ return stored
114
277
  }
115
278
 
116
279
  private fun loadRuntime(): RuntimeState {
117
280
  val text = prefs.getString(KEY_RUNTIME, null) ?: return RuntimeState()
118
- return try {
281
+ val stored = try {
119
282
  RuntimeStateJson.fromJson(JSONObject(text))
120
283
  } catch (e: Exception) {
121
284
  Logger.e(TAG, "persisted runtime state is unreadable; using defaults", e)
122
- RuntimeState()
285
+ return RuntimeState()
123
286
  }
287
+ return if (restored) stored.copy(enabled = false, isMoving = false) else stored
124
288
  }
125
289
 
126
290
  internal companion object {
@@ -131,5 +295,12 @@ class SharedPrefsConfigStore(
131
295
 
132
296
  /** Runtime state JSON (`RuntimeStateJson`). */
133
297
  const val KEY_RUNTIME = "cfg_runtime"
298
+
299
+ /** True while the encrypted tokens file holds tokens. */
300
+ const val KEY_SECRETS = "cfg_secrets"
301
+
302
+ /** `PackageInfo.firstInstallTime` of the installation that wrote this file. */
303
+ const val KEY_FIRST_INSTALL = "cfg_first_install_time"
304
+ private const val NO_INSTALL_TIME = -1L
134
305
  }
135
306
  }
@@ -24,6 +24,12 @@ enum class TrackingMode(val wire: String) {
24
24
  * @property lastRecordBootCount boot count when the last record was persisted.
25
25
  * @property providerState last provider state seen, used to detect `providerchange` across process restarts.
26
26
  * @property didReady true once `ready()` has ever completed (drives `ReadyOptions.reset=false`).
27
+ * @property trackingStartedElapsed elapsed-realtime ms when the session started (`stopAfterElapsedMinutes`), valid
28
+ * while the boot count is [trackingStartedBootCount].
29
+ * @property lastStopReason the reason of the last `tracking_stop` record, and [lastStopAt] when tracking stopped
30
+ * (epoch ms).
31
+ * @property userStopRecordedFor the time of the user stop (Task Manager Stop or force stop) whose `tracking_stop`
32
+ * record was written, so a later process does not write it again.
27
33
  */
28
34
  data class RuntimeState(
29
35
  val enabled: Boolean = false,
@@ -39,6 +45,11 @@ data class RuntimeState(
39
45
  val trackingStartedAt: Long? = null,
40
46
  val providerState: ProviderState? = null,
41
47
  val didReady: Boolean = false,
48
+ val trackingStartedElapsed: Long? = null,
49
+ val trackingStartedBootCount: Int? = null,
50
+ val lastStopReason: String? = null,
51
+ val lastStopAt: Long? = null,
52
+ val userStopRecordedFor: Long? = null,
42
53
  )
43
54
 
44
55
  /** Snapshot returned to JS as `State` (see `ConfigJson.stateToJson`). */
@@ -2,6 +2,7 @@ package com.brickssoft.locationtracking.core
2
2
 
3
3
  import android.annotation.SuppressLint
4
4
  import android.content.Context
5
+ import android.os.StatFs
5
6
  import androidx.annotation.VisibleForTesting
6
7
  import com.brickssoft.locationtracking.api.NativeListeners
7
8
  import com.brickssoft.locationtracking.config.ConfigStore
@@ -11,6 +12,7 @@ import com.brickssoft.locationtracking.data.LocationStore
11
12
  import com.brickssoft.locationtracking.data.SqliteGeofenceStore
12
13
  import com.brickssoft.locationtracking.data.SqliteLocationStore
13
14
  import com.brickssoft.locationtracking.data.TrackingDatabase
15
+ import com.brickssoft.locationtracking.device.DefaultDeviceDetails
14
16
  import com.brickssoft.locationtracking.device.DefaultDeviceInfoProvider
15
17
  import com.brickssoft.locationtracking.device.DefaultDeviceMonitor
16
18
  import com.brickssoft.locationtracking.device.DeviceInfoProvider
@@ -21,8 +23,12 @@ import com.brickssoft.locationtracking.geofence.DefaultGeofenceManager
21
23
  import com.brickssoft.locationtracking.geofence.GeofenceManager
22
24
  import com.brickssoft.locationtracking.heartbeat.DefaultHeartbeatScheduler
23
25
  import com.brickssoft.locationtracking.heartbeat.HeartbeatScheduler
26
+ import com.brickssoft.locationtracking.http.HttpSupport
24
27
  import com.brickssoft.locationtracking.http.HttpSyncer
28
+ import com.brickssoft.locationtracking.http.JobUploadScheduler
25
29
  import com.brickssoft.locationtracking.http.OkHttpSyncer
30
+ import com.brickssoft.locationtracking.http.PrefsNextTryStore
31
+ import com.brickssoft.locationtracking.http.UploadScheduler
26
32
  import com.brickssoft.locationtracking.logging.FileLogger
27
33
  import com.brickssoft.locationtracking.logging.LogStore
28
34
  import com.brickssoft.locationtracking.permission.DefaultPermissionManager
@@ -72,7 +78,7 @@ class Components private constructor(context: Context) {
72
78
 
73
79
  /** Round 2: every queued record (sink and insertLocation) for the companion native API (unit 5). */
74
80
  val recordHooks: RecordHooks = RecordHooks()
75
- val http: OkHttpClient by lazy { OkHttpClient() }
81
+ val http: OkHttpClient by lazy { HttpSupport.sharedClient() }
76
82
 
77
83
  // U17
78
84
  val logStore: LogStore by lazy { FileLogger(this.context, clock, dispatchers, lazy { http }) }
@@ -97,30 +103,40 @@ class Components private constructor(context: Context) {
97
103
  val deviceInfo: DeviceInfoProvider by lazy { DefaultDeviceInfoProvider(this.context, lazy { providers }) }
98
104
 
99
105
  // U10
100
- private val database by lazy { TrackingDatabase(this.context) }
106
+ private val database by lazy { TrackingDatabase(this.context, TrackingDatabase.location(this.context)) }
101
107
  val locationStore: LocationStore by lazy { SqliteLocationStore(database, configStore, clock, dispatchers) }
102
108
  val geofenceStore: GeofenceStore by lazy { SqliteGeofenceStore(database, dispatchers) }
103
109
 
104
110
  // U9
105
111
  val processor: LocationProcessor by lazy { DefaultLocationProcessor(configStore) }
106
112
  val odometer: Odometer by lazy { DefaultOdometer(configStore) }
107
- val recordFactory: RecordFactory by lazy { DefaultRecordFactory(configStore, device, providers, clock) }
113
+ val recordFactory: RecordFactory by lazy {
114
+ DefaultRecordFactory(configStore, device, providers, clock, DefaultDeviceDetails(this.context))
115
+ }
108
116
 
109
117
  // U11
118
+ /** Schedules the upload job that sends queued records when no tracking session runs (recommendation 10). */
119
+ val uploadScheduler: UploadScheduler by lazy { JobUploadScheduler(this.context) }
110
120
  val syncer: HttpSyncer by lazy {
111
- OkHttpSyncer(configStore, locationStore, device, events, clock, http, dispatchers, scope)
121
+ OkHttpSyncer(
122
+ configStore, locationStore, device, events, clock, http, dispatchers, scope,
123
+ PrefsNextTryStore(this.context), uploadScheduler,
124
+ )
112
125
  }
113
126
 
114
127
  // U12
115
128
  val heartbeat: HeartbeatScheduler by lazy {
116
129
  DefaultHeartbeatScheduler(
117
130
  this.context, configStore, device, providers, locationStore, recordFactory, lazy { recordSink }, clock, scope,
131
+ uploads = lazy { syncer },
118
132
  )
119
133
  }
120
134
 
121
135
  // SCAFFOLD
122
136
  val recordSink: RecordSink by lazy {
123
- DefaultRecordSink(locationStore, configStore, heartbeat, syncer, events, recordHooks)
137
+ DefaultRecordSink(locationStore, configStore, heartbeat, syncer, events, recordHooks) {
138
+ StatFs(this.context.noBackupFilesDir.path).availableBytes
139
+ }
124
140
  }
125
141
 
126
142
  // U13 (round 2: STATIONARY_REGION_ID transitions go to the engine)
@@ -11,12 +11,18 @@ import android.os.Build
11
11
  * Android cancels the app's alarms and does not restart its services after a force stop, so tracking stays off until
12
12
  * the user opens the app again. A broadcast that was already on its way can still start the process (on the CI
13
13
  * emulator a Play services activity update arrived 0.5 s after `am force-stop`, scenario P-L04). The engine asks this
14
- * probe before a background trigger (activity update, stationary region EXIT) restores tracking.
14
+ * probe before a background trigger (activity update, stationary region EXIT, heartbeat alarm, the service's sticky or
15
+ * earlier-process restart) restores tracking. A user stop means both the Settings force stop and the Stop button of
16
+ * the Android 13+ Task Manager; unlike a force stop, the Task Manager leaves the app's alarms in place, so the heartbeat
17
+ * alarm can start the process again (recommendation 6).
15
18
  */
16
19
  fun interface ForceStopProbe {
17
20
  /** True when the previous process of this app ended with a user force stop. */
18
21
  fun startedAfterForceStop(): Boolean
19
22
 
23
+ /** When the previous process was stopped by the user (the exit record's time, epoch ms), or null. */
24
+ fun forceStopTime(): Long? = null
25
+
20
26
  companion object {
21
27
  private const val TAG = "LT.ForceStop"
22
28
 
@@ -35,28 +41,33 @@ fun interface ForceStopProbe {
35
41
  */
36
42
  fun system(context: Context): ForceStopProbe {
37
43
  val app = context.applicationContext ?: context
38
- val result by lazy(LazyThreadSafetyMode.SYNCHRONIZED) { lastExitWasForceStop(app) }
39
- return ForceStopProbe { result }
44
+ val result by lazy(LazyThreadSafetyMode.SYNCHRONIZED) { lastExitForceStopTime(app) }
45
+ return object : ForceStopProbe {
46
+ override fun startedAfterForceStop(): Boolean = result != null
47
+
48
+ override fun forceStopTime(): Long? = result
49
+ }
40
50
  }
41
51
 
42
- private fun lastExitWasForceStop(context: Context): Boolean {
43
- if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return false
52
+ /** The time of the previous main process's exit if it was a user stop, else null. */
53
+ private fun lastExitForceStopTime(context: Context): Long? {
54
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return null
44
55
  return try {
45
- val manager = context.getSystemService(ActivityManager::class.java) ?: return false
56
+ val manager = context.getSystemService(ActivityManager::class.java) ?: return null
46
57
  val mainProcess = context.applicationInfo?.processName ?: context.packageName
47
58
  val previous = manager.getHistoricalProcessExitReasons(context.packageName, 0, EXIT_RECORDS)
48
59
  .firstOrNull { it.processName == mainProcess }
49
- ?: return false
60
+ ?: return null
50
61
  val forceStopped = previous.reason == ApplicationExitInfo.REASON_USER_REQUESTED
51
62
  Logger.i(
52
63
  TAG,
53
64
  "previous process ${previous.pid} exited with reason ${previous.reason} " +
54
65
  "(${previous.description ?: "no description"}); force stop: $forceStopped",
55
66
  )
56
- forceStopped
67
+ if (forceStopped) previous.timestamp else null
57
68
  } catch (e: Exception) {
58
69
  Logger.w(TAG, "the previous process's exit reason is unknown", e)
59
- false
70
+ null
60
71
  }
61
72
  }
62
73
  }
@@ -5,8 +5,10 @@ import com.brickssoft.locationtracking.model.Connectivity
5
5
  import com.brickssoft.locationtracking.model.GeofenceAction
6
6
  import com.brickssoft.locationtracking.model.GeofenceSpec
7
7
  import com.brickssoft.locationtracking.model.HttpResult
8
+ import com.brickssoft.locationtracking.model.ProviderKind
8
9
  import com.brickssoft.locationtracking.model.ProviderState
9
10
  import com.brickssoft.locationtracking.model.Record
11
+ import com.brickssoft.locationtracking.model.RecordEvent
10
12
 
11
13
  /**
12
14
  * Events forwarded to JS. The JS event name and payload are built by
@@ -48,12 +50,32 @@ sealed interface TrackingEvent {
48
50
  /** "powersavechange" */
49
51
  data class PowerSaveChange(val isPowerSaveMode: Boolean) : TrackingEvent
50
52
 
51
- /** "enabledchange" */
52
- data class EnabledChange(val enabled: Boolean) : TrackingEvent
53
+ /**
54
+ * "enabledchange". [reason] is the `tracking_start` reason when tracking was switched on, the `tracking_stop` reason
55
+ * when it was switched off (the same values as the audit records), or null.
56
+ */
57
+ data class EnabledChange(val enabled: Boolean, val reason: String? = null) : TrackingEvent
53
58
 
54
59
  /** "notificationaction" */
55
60
  data class NotificationAction(val id: String) : TrackingEvent
56
61
 
62
+ /**
63
+ * "locationerror": the location request on [backend] failed (recommendation 3). The engine retries it after
64
+ * [retryInMs] ms (null: no retry is scheduled, for example tracking stopped).
65
+ */
66
+ data class LocationError(
67
+ val code: ErrorCode,
68
+ val message: String,
69
+ val backend: ProviderKind,
70
+ val retryInMs: Long?,
71
+ ) : TrackingEvent
72
+
73
+ /**
74
+ * "storagefull": the record [uuid] ([event]) could not be stored because the device storage is full
75
+ * (recommendation 16); [diskFreeBytes] is the free space of the app's storage, or null if unknown.
76
+ */
77
+ data class StorageFull(val uuid: String, val event: RecordEvent, val diskFreeBytes: Long?) : TrackingEvent
78
+
57
79
  /** "authorization"; [responseJson] is JSON object text. */
58
80
  data class Authorization(
59
81
  val success: Boolean,
@@ -5,6 +5,7 @@ import android.database.sqlite.SQLiteDatabase
5
5
  import android.database.sqlite.SQLiteOpenHelper
6
6
  import com.brickssoft.locationtracking.core.Constants
7
7
  import com.brickssoft.locationtracking.core.Logger
8
+ import java.io.File
8
9
 
9
10
  /**
10
11
  * SQLiteOpenHelper for [Constants.DATABASE_NAME] (schema [VERSION]), shared by [SqliteLocationStore] and
@@ -17,7 +18,11 @@ import com.brickssoft.locationtracking.core.Logger
17
18
  * - `geofences`: `json` is `GeofenceJson.toJson(spec)`; `runtime_json` is `{insideCircle, insidePolygon, enteredAt}`
18
19
  * or null.
19
20
  *
20
- * @param name database file name; `null` creates an in-memory database.
21
+ * The plugin opens it at [location]: `noBackupFilesDir/location_tracking.db`, so the queue is not copied by Android
22
+ * Auto Backup or device-to-device transfer (recommendation 7). A database left in the default databases folder by an
23
+ * older version is moved there once, without losing records.
24
+ *
25
+ * @param name database file name or absolute path; `null` creates an in-memory database.
21
26
  */
22
27
  class TrackingDatabase(
23
28
  context: Context,
@@ -86,6 +91,43 @@ class TrackingDatabase(
86
91
  const val VERSION = 1
87
92
 
88
93
  private const val TAG = "LT.Database"
94
+
95
+ /** Files SQLite may keep next to a database. */
96
+ private val SIDE_FILES = listOf("-wal", "-shm", "-journal")
97
+
98
+ /**
99
+ * The absolute path of the plugin's database under `noBackupFilesDir`. A database still in the default
100
+ * databases folder (an older version) is moved there first: its write-ahead log is folded into the main file
101
+ * (`PRAGMA wal_checkpoint(TRUNCATE)` and close), then the file is renamed (same file system), then the old side
102
+ * files are deleted. If anything fails, the old path is returned and nothing is lost. If both exist (a
103
+ * downgrade and upgrade), the one under `noBackupFilesDir` is used and the old one is left in place.
104
+ */
105
+ fun location(context: Context): String {
106
+ val app = context.applicationContext ?: context
107
+ val target = File(app.noBackupFilesDir, Constants.DATABASE_NAME)
108
+ val legacy = app.getDatabasePath(Constants.DATABASE_NAME)
109
+ if (!legacy.exists()) return target.path
110
+ if (target.exists()) {
111
+ Logger.w(TAG, "databases/${Constants.DATABASE_NAME} and its no_backup copy both exist; using the no_backup one")
112
+ return target.path
113
+ }
114
+ return try {
115
+ SQLiteDatabase.openDatabase(legacy.path, null, SQLiteDatabase.OPEN_READWRITE).use { db ->
116
+ db.rawQuery("PRAGMA wal_checkpoint(TRUNCATE)", null).use { it.moveToFirst() }
117
+ }
118
+ target.parentFile?.mkdirs()
119
+ if (!legacy.renameTo(target)) {
120
+ Logger.w(TAG, "could not move the database to no_backup; keeping it in databases/")
121
+ return legacy.path
122
+ }
123
+ for (suffix in SIDE_FILES) File(legacy.path + suffix).delete()
124
+ Logger.i(TAG, "database moved to no_backup (kept out of Auto Backup and device transfer)")
125
+ target.path
126
+ } catch (e: Exception) {
127
+ Logger.e(TAG, "could not move the database to no_backup; keeping it in databases/", e)
128
+ legacy.path
129
+ }
130
+ }
89
131
  }
90
132
  }
91
133
 
@@ -44,6 +44,12 @@ class DefaultDeviceInfoProvider(
44
44
  Logger.e(TAG, "provider kind unavailable", e)
45
45
  null
46
46
  }
47
+ val selection = try {
48
+ factory?.selectionInfo()
49
+ } catch (e: Exception) {
50
+ Logger.w(TAG, "provider selection info unavailable", e)
51
+ null
52
+ }
47
53
  return DeviceInfo(
48
54
  platform = "android",
49
55
  manufacturer = Build.MANUFACTURER.orEmpty(),
@@ -56,6 +62,17 @@ class DefaultDeviceInfoProvider(
56
62
  hmsAvailable = factory != null && isAvailable(factory, ProviderKind.HMS),
57
63
  backend = backend ?: ProviderKind.ANDROID,
58
64
  packagedProviders = packagedProviders(),
65
+ backendFallbackReason = selection?.fallbackReason,
66
+ gmsStatus = selection?.gmsStatus,
67
+ hmsStatus = selection?.hmsStatus,
68
+ hmsCoreOutdated = selection?.hmsCoreOutdated,
69
+ locationAvailableHint = try {
70
+ factory?.location()?.availabilityHint
71
+ } catch (e: Exception) {
72
+ null
73
+ },
74
+ cpuCores = Runtime.getRuntime().availableProcessors(),
75
+ supportedAbis = Build.SUPPORTED_ABIS?.toList().orEmpty(),
59
76
  )
60
77
  }
61
78