@bricks-soft/capacitor-location-tracking 8.0.0 → 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.
- package/README.md +375 -124
- package/android/build.gradle +2 -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 +107 -4
- 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 +2 -2
- package/dist/esm/web/device.js +9 -2
- 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 +40 -6
- package/dist/plugin.cjs.js.map +1 -1
- package/dist/plugin.js +40 -6
- package/dist/plugin.js.map +1 -1
- package/package.json +4 -3
package/android/src/main/java/com/brickssoft/locationtracking/config/SharedPrefsConfigStore.kt
CHANGED
|
@@ -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
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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
|
|
28
|
-
|
|
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
|
-
/**
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
/**
|
|
87
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
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(
|
|
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
|
|
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) {
|
|
39
|
-
return ForceStopProbe {
|
|
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
|
-
|
|
43
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
/**
|
|
52
|
-
|
|
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
|
-
*
|
|
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
|
|
package/android/src/main/java/com/brickssoft/locationtracking/device/DefaultDeviceInfoProvider.kt
CHANGED
|
@@ -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
|
|