@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
@@ -9,6 +9,9 @@ import com.brickssoft.locationtracking.model.TrackedLocation
9
9
  import com.brickssoft.locationtracking.provider.LocationBackend
10
10
  import com.brickssoft.locationtracking.provider.LocationListener
11
11
  import com.brickssoft.locationtracking.provider.LocationRequestSpec
12
+ import com.brickssoft.locationtracking.provider.LocationSubscriptions
13
+ import com.brickssoft.locationtracking.provider.OsRegistration
14
+ import com.brickssoft.locationtracking.provider.RegistrationEvents
12
15
  import com.google.android.gms.location.CurrentLocationRequest
13
16
  import com.google.android.gms.location.FusedLocationProviderClient
14
17
  import com.google.android.gms.location.LocationAvailability
@@ -25,11 +28,12 @@ import kotlin.coroutines.cancellation.CancellationException
25
28
  /**
26
29
  * Fused location provider backend.
27
30
  *
28
- * Every [LocationListener] gets its own [LocationCallback] on the main looper. Requesting again with the same
29
- * listener reuses its callback, which GMS treats as a replacement of the previous request.
31
+ * Listener bookkeeping is [LocationSubscriptions]: every request gets its own [LocationCallback] on the main looper,
32
+ * a request for the same listener removes the previous callback first, and a failure of `requestLocationUpdates`
33
+ * (thrown, or reported later by its Task) reaches the listener's `onError` (recommendation 3).
34
+ * `onLocationAvailability` is kept as [availabilityHint].
30
35
  *
31
- * Only [getCurrentLocation] throws: the [LocationBackend] contract declares no exception for the other methods, so
32
- * their failures are logged (a missing permission also shows in the provider state).
36
+ * Only [getCurrentLocation] throws: the [LocationBackend] contract declares no exception for the other methods.
33
37
  */
34
38
  internal class GmsLocationBackend(
35
39
  private val client: FusedLocationProviderClient,
@@ -37,40 +41,48 @@ internal class GmsLocationBackend(
37
41
  ) : LocationBackend {
38
42
  override val kind: ProviderKind = ProviderKind.GMS
39
43
 
40
- /** Guarded by itself; GMS registration calls are made under the same lock so add/remove cannot interleave. */
41
- private val callbacks = HashMap<LocationListener, ListenerCallback>()
44
+ private val subscriptions = LocationSubscriptions(TAG) { GmsErrors.map(it, "requestLocationUpdates") }
45
+
46
+ override val availabilityHint: Boolean? get() = subscriptions.availabilityHint
42
47
 
43
- /**
44
- * Never throws. A synchronous rejection (e.g. `SecurityException` without location permission) is logged and
45
- * leaves a new listener unregistered, or a replaced listener on its previous request. Asynchronous failures of
46
- * the GMS Task are logged too.
47
- */
48
48
  override fun requestUpdates(spec: LocationRequestSpec, listener: LocationListener) {
49
49
  val request = try {
50
50
  buildLocationRequest(spec)
51
51
  } catch (e: Exception) {
52
52
  Logger.e(TAG, "invalid location request $spec", e)
53
+ listener.onError(GmsErrors.map(e, "requestLocationUpdates"))
53
54
  return
54
55
  }
55
- synchronized(callbacks) {
56
- val existing = callbacks[listener]
57
- val callback = existing ?: ListenerCallback(listener)
58
- try {
59
- client.requestLocationUpdates(request, callback, looper()).logFailure(TAG, "requestLocationUpdates")
60
- } catch (e: Exception) {
61
- val mapped = GmsErrors.map(e, "requestLocationUpdates")
62
- Logger.e(TAG, "${mapped.code}: ${mapped.message}", e)
63
- return
64
- }
65
- if (existing == null) callbacks[listener] = callback
66
- Logger.d(TAG, "location updates ${if (existing == null) "requested" else "replaced"}: $spec")
67
- }
56
+ subscriptions.request(listener, spec) { events -> Registration(request, events) }
57
+ Logger.d(TAG, "location updates requested: $spec")
68
58
  }
69
59
 
70
60
  override fun removeUpdates(listener: LocationListener) {
71
- synchronized(callbacks) {
72
- val callback = callbacks.remove(listener) ?: return
73
- callback.active = false
61
+ subscriptions.remove(listener)
62
+ }
63
+
64
+ /** One `requestLocationUpdates` with its own callback. */
65
+ private inner class Registration(
66
+ private val request: LocationRequest,
67
+ private val events: RegistrationEvents,
68
+ ) : OsRegistration {
69
+ private val callback = object : LocationCallback() {
70
+ override fun onLocationResult(result: LocationResult) {
71
+ events.locations(result.locations.map(TrackedLocation::from))
72
+ }
73
+
74
+ override fun onLocationAvailability(availability: LocationAvailability) {
75
+ events.availability(availability.isLocationAvailable)
76
+ }
77
+ }
78
+
79
+ override fun start(done: (Exception?) -> Unit) {
80
+ client.requestLocationUpdates(request, callback, looper()).addOnCompleteListener(DirectExecutor) { task ->
81
+ done(task.exception ?: if (task.isCanceled) IllegalStateException("request cancelled by GMS") else null)
82
+ }
83
+ }
84
+
85
+ override fun remove() {
74
86
  try {
75
87
  client.removeLocationUpdates(callback).logFailure(TAG, "removeLocationUpdates")
76
88
  } catch (e: Exception) {
@@ -126,30 +138,6 @@ internal class GmsLocationBackend(
126
138
  }
127
139
  }
128
140
 
129
- /**
130
- * Forwards fixes to one listener until [removeUpdates] deactivates it. Best effort: a batch whose delivery already
131
- * started on the looper thread may still reach the listener while [removeUpdates] runs on another thread.
132
- */
133
- private class ListenerCallback(private val listener: LocationListener) : LocationCallback() {
134
- @Volatile
135
- var active = true
136
-
137
- override fun onLocationResult(result: LocationResult) {
138
- if (!active) return
139
- val locations = result.locations.map(TrackedLocation::from)
140
- if (locations.isEmpty()) return
141
- try {
142
- listener.onLocations(locations)
143
- } catch (e: Exception) {
144
- Logger.e(TAG, "location listener failed", e)
145
- }
146
- }
147
-
148
- override fun onLocationAvailability(availability: LocationAvailability) {
149
- Logger.v(TAG, "location available: ${availability.isLocationAvailable}")
150
- }
151
- }
152
-
153
141
  companion object {
154
142
  private const val TAG = "LT.GmsLocation"
155
143
 
@@ -40,16 +40,24 @@ class GmsProviderBundle @JvmOverloads constructor(
40
40
  private val activityBackend by lazy { GmsActivityBackend(appContext, activityClient(appContext)) }
41
41
  private val geofenceBackend by lazy { GmsGeofenceBackend(appContext, geofencingClient(appContext)) }
42
42
 
43
+ @Volatile
44
+ private var lastStatus: Int? = null
45
+
43
46
  /** True if `GoogleApiAvailability` reports SUCCESS; false on any other result or failure. */
44
47
  override fun isAvailable(): Boolean = try {
45
48
  val status = availabilityCheck(appContext)
49
+ lastStatus = status
46
50
  if (status != ConnectionResult.SUCCESS) Logger.d(TAG, "Google Play services unavailable: status $status")
47
51
  status == ConnectionResult.SUCCESS
48
52
  } catch (t: Throwable) {
53
+ lastStatus = null
49
54
  Logger.w(TAG, "Google Play services availability check failed", t)
50
55
  false
51
56
  }
52
57
 
58
+ /** The `GoogleApiAvailability` result of the last [isAvailable] (`ConnectionResult` code), or null. */
59
+ override fun availabilityStatus(): Int? = lastStatus
60
+
53
61
  override fun location(): LocationBackend = locationBackend
54
62
 
55
63
  override fun activity(): ActivityBackend = activityBackend
@@ -10,8 +10,12 @@ import com.brickssoft.locationtracking.model.TrackedLocation
10
10
  import com.brickssoft.locationtracking.provider.LocationBackend
11
11
  import com.brickssoft.locationtracking.provider.LocationListener
12
12
  import com.brickssoft.locationtracking.provider.LocationRequestSpec
13
+ import com.brickssoft.locationtracking.provider.LocationSubscriptions
14
+ import com.brickssoft.locationtracking.provider.OsRegistration
15
+ import com.brickssoft.locationtracking.provider.RegistrationEvents
13
16
  import com.huawei.hmf.tasks.TaskExecutors
14
17
  import com.huawei.hms.location.FusedLocationProviderClient
18
+ import com.huawei.hms.location.LocationAvailability
15
19
  import com.huawei.hms.location.LocationCallback
16
20
  import com.huawei.hms.location.LocationRequest
17
21
  import com.huawei.hms.location.LocationResult
@@ -22,9 +26,14 @@ import kotlinx.coroutines.withTimeoutOrNull
22
26
  /**
23
27
  * [LocationBackend] over the HMS fused location client.
24
28
  *
25
- * - Each listener gets its own [LocationCallback] on the main looper; requesting again with the same listener
26
- * replaces its request, and late fixes from a replaced callback are dropped.
27
- * - `getCurrentLocation` is a one-shot request (`numUpdates = 1`) whose callback is always removed.
29
+ * - Listener bookkeeping is [LocationSubscriptions]: each request gets its own [LocationCallback] on the main looper; a
30
+ * request for the same listener removes the previous callback first, and late fixes of a replaced callback are
31
+ * dropped. A failure of `requestLocationUpdates` (thrown, or reported later by its task) reaches the listener's
32
+ * `onError` (recommendation 3). The first delivered fix counts as a successful registration: Huawei does not
33
+ * guarantee that the task succeeds before the first result.
34
+ * - `getCurrentLocation` is a one-shot request (`numUpdates = 1`) whose callback is always removed. It returns the
35
+ * first fix even while the request task has not completed, and a failed request ends it at once with the error
36
+ * instead of waiting for the timeout.
28
37
  * - Permission failures surface as `TrackingException(PERMISSION_DENIED)`; other HMS failures are mapped by
29
38
  * [toTrackingException].
30
39
  *
@@ -40,35 +49,17 @@ internal class HmsLocationBackend(
40
49
 
41
50
  private val client: FusedLocationProviderClient by lazy(clientProvider)
42
51
 
43
- /**
44
- * Active callback per listener. Guarded by [lock], which is also held across the HMS request/remove calls so a
45
- * concurrent request/remove for the same listener cannot leave an orphaned HMS registration. Those calls only
46
- * submit work and return a task; callbacks never take the lock (they check [ListenerCallback.active]).
47
- */
48
- private val callbacks = HashMap<LocationListener, ListenerCallback>()
49
- private val lock = Any()
52
+ private val subscriptions = LocationSubscriptions(TAG) { it.toTrackingException("requestLocationUpdates") }
53
+
54
+ override val availabilityHint: Boolean? get() = subscriptions.availabilityHint
50
55
 
51
56
  override fun requestUpdates(spec: LocationRequestSpec, listener: LocationListener) {
52
57
  val request = buildLocationRequest(spec)
53
- synchronized(lock) {
54
- callbacks.remove(listener)?.let(::deactivateAndRemove)
55
- val callback = ListenerCallback(listener)
56
- callbacks[listener] = callback
57
- try {
58
- client.requestLocationUpdates(request, callback, looperProvider())
59
- .addOnFailureListener(TaskExecutors.immediate()) { e ->
60
- Logger.e(TAG, "requestLocationUpdates failed: ${describeHmsError(e)}", e)
61
- forget(callback)
62
- }
63
- } catch (e: Exception) {
64
- Logger.e(TAG, "requestLocationUpdates failed: ${describeHmsError(e)}", e)
65
- forget(callback)
66
- }
67
- }
58
+ subscriptions.request(listener, spec) { events -> Registration(request, events) }
68
59
  }
69
60
 
70
61
  override fun removeUpdates(listener: LocationListener) {
71
- synchronized(lock) { callbacks.remove(listener)?.let(::deactivateAndRemove) }
62
+ subscriptions.remove(listener)
72
63
  }
73
64
 
74
65
  override suspend fun getLastLocation(): TrackedLocation? = hmsCall("getLastLocation") {
@@ -87,9 +78,15 @@ internal class HmsLocationBackend(
87
78
  var requested = false
88
79
  try {
89
80
  return withTimeoutOrNull(timeoutMs.coerceAtLeast(0)) {
90
- hmsCall("getCurrentLocation") {
81
+ val task = hmsCall("getCurrentLocation") {
91
82
  requested = true
92
- client.requestLocationUpdates(buildOneShotRequest(accuracy), callback, looperProvider()).await()
83
+ client.requestLocationUpdates(buildOneShotRequest(accuracy), callback, looperProvider())
84
+ }
85
+ task.addOnFailureListener(TaskExecutors.immediate()) { e ->
86
+ fix.completeExceptionally(e.toTrackingException("getCurrentLocation"))
87
+ }
88
+ task.addOnCanceledListener(TaskExecutors.immediate()) {
89
+ fix.completeExceptionally(HmsTaskCanceledException().toTrackingException("getCurrentLocation"))
93
90
  }
94
91
  fix.await()
95
92
  }
@@ -99,18 +96,7 @@ internal class HmsLocationBackend(
99
96
  }
100
97
 
101
98
  /** Number of listeners with an active request (for tests and diagnostics). */
102
- internal val activeListenerCount: Int get() = synchronized(lock) { callbacks.size }
103
-
104
- /** Drops [callback] after its request failed, unless it was already replaced or removed. */
105
- private fun forget(callback: ListenerCallback) {
106
- callback.active = false
107
- synchronized(lock) { if (callbacks[callback.listener] === callback) callbacks.remove(callback.listener) }
108
- }
109
-
110
- private fun deactivateAndRemove(callback: ListenerCallback) {
111
- callback.active = false
112
- removeQuietly(callback)
113
- }
99
+ internal val activeListenerCount: Int get() = subscriptions.count
114
100
 
115
101
  private fun removeQuietly(callback: LocationCallback) {
116
102
  try {
@@ -123,21 +109,29 @@ internal class HmsLocationBackend(
123
109
  }
124
110
  }
125
111
 
126
- /** Delivers to [listener] until replaced or removed; late fixes after that are dropped. */
127
- private class ListenerCallback(val listener: LocationListener) : LocationCallback() {
128
- @Volatile
129
- var active = true
130
-
131
- override fun onLocationResult(result: LocationResult?) {
132
- if (!active) return
133
- val fixes = result?.locations.orEmpty().mapNotNull { location -> location?.let(::toTrackedLocation) }
134
- if (fixes.isEmpty()) return
135
- try {
136
- listener.onLocations(fixes)
137
- } catch (e: Exception) {
138
- Logger.e(TAG, "location listener failed", e)
112
+ /** One `requestLocationUpdates` with its own callback. */
113
+ private inner class Registration(
114
+ private val request: LocationRequest,
115
+ private val events: RegistrationEvents,
116
+ ) : OsRegistration {
117
+ private val callback = object : LocationCallback() {
118
+ override fun onLocationResult(result: LocationResult?) {
119
+ events.locations(result?.locations.orEmpty().mapNotNull { location -> location?.let(::toTrackedLocation) })
120
+ }
121
+
122
+ override fun onLocationAvailability(availability: LocationAvailability?) {
123
+ availability?.let { events.availability(it.isLocationAvailable) }
139
124
  }
140
125
  }
126
+
127
+ override fun start(done: (Exception?) -> Unit) {
128
+ client.requestLocationUpdates(request, callback, looperProvider())
129
+ .addOnSuccessListener(TaskExecutors.immediate()) { done(null) }
130
+ .addOnFailureListener(TaskExecutors.immediate()) { e -> done(e) }
131
+ .addOnCanceledListener(TaskExecutors.immediate()) { done(HmsTaskCanceledException()) }
132
+ }
133
+
134
+ override fun remove() = removeQuietly(callback)
141
135
  }
142
136
 
143
137
  companion object {
@@ -23,6 +23,10 @@ class HmsProviderBundle @JvmOverloads constructor(
23
23
  private val availabilityCheck: (Context) -> Int = { ctx ->
24
24
  HuaweiApiAvailability.getInstance().isHuaweiMobileServicesAvailable(ctx)
25
25
  },
26
+ /** `isHuaweiMobileServicesAvailable(context, minimumVersion)`; injectable for tests. */
27
+ private val versionCheck: (Context, Int) -> Int = { ctx, minimum ->
28
+ HuaweiApiAvailability.getInstance().isHuaweiMobileServicesAvailable(ctx, minimum)
29
+ },
26
30
  ) : ProviderBundle {
27
31
  private val appContext: Context = context.applicationContext ?: context
28
32
 
@@ -32,24 +36,52 @@ class HmsProviderBundle @JvmOverloads constructor(
32
36
  private val activityBackend by lazy { HmsActivityBackend(appContext) }
33
37
  private val geofenceBackend by lazy { HmsGeofenceBackend(appContext) }
34
38
 
39
+ @Volatile
40
+ private var lastStatus: Int? = null
41
+
35
42
  /** True if HMS Core reports SUCCESS; any failure (including a missing SDK class) counts as unavailable. */
36
43
  override fun isAvailable(): Boolean =
37
44
  try {
38
45
  val result = availabilityCheck(appContext)
46
+ lastStatus = result
39
47
  if (result != ConnectionResult.SUCCESS) Logger.d(TAG, "HMS Core unavailable: result $result")
40
48
  result == ConnectionResult.SUCCESS
41
49
  } catch (t: Throwable) {
50
+ lastStatus = null
42
51
  Logger.w(TAG, "HMS availability check failed", t)
43
52
  false
44
53
  }
45
54
 
55
+ /** The `HuaweiApiAvailability` result of the last [isAvailable], or null. */
56
+ override fun availabilityStatus(): Int? = lastStatus
57
+
58
+ /**
59
+ * A diagnostic when HMS Core is older than 6.3.0.301 ([MIN_RECOMMENDED_HMS_CORE]), the version Huawei's Location
60
+ * Kit documentation asks for (recommendation 15). It does not change the provider choice. Null when HMS Core is
61
+ * that version or newer, or the check fails.
62
+ */
63
+ override fun versionDiagnostic(): String? = try {
64
+ val result = versionCheck(appContext, MIN_RECOMMENDED_HMS_CORE)
65
+ if (result == ConnectionResult.SUCCESS) {
66
+ null
67
+ } else {
68
+ "HMS Core is older than 6.3.0.301 or needs an update (isHuaweiMobileServicesAvailable result $result)"
69
+ }
70
+ } catch (t: Throwable) {
71
+ Logger.w(TAG, "HMS Core version check failed", t)
72
+ null
73
+ }
74
+
46
75
  override fun location(): LocationBackend = locationBackend
47
76
 
48
77
  override fun activity(): ActivityBackend = activityBackend
49
78
 
50
79
  override fun geofence(): GeofenceBackend = geofenceBackend
51
80
 
52
- private companion object {
53
- const val TAG = "LT.HmsBundle"
81
+ internal companion object {
82
+ private const val TAG = "LT.HmsBundle"
83
+
84
+ /** HMS Core 6.3.0.301, as `isHuaweiMobileServicesAvailable` expects a version (60300301). */
85
+ const val MIN_RECOMMENDED_HMS_CORE = 60300301
54
86
  }
55
87
  }
@@ -1,5 +1,6 @@
1
1
  package com.brickssoft.locationtracking.record
2
2
 
3
+ import android.database.sqlite.SQLiteFullException
3
4
  import com.brickssoft.locationtracking.config.ConfigStore
4
5
  import com.brickssoft.locationtracking.core.EventBus
5
6
  import com.brickssoft.locationtracking.core.Logger
@@ -29,7 +30,8 @@ interface RecordSink {
29
30
  * 5. `syncer.onRecordInserted(record)`.
30
31
  *
31
32
  * If the insert fails, the failure is logged, steps 2-4 still run (live listeners still get the record) and
32
- * step 5 is skipped because the record is not queued.
33
+ * step 5 is skipped because the record is not queued. A failure because the storage is full (`SQLiteFullException`)
34
+ * also emits a `StorageFull` event (recommendation 16), so the app can tell the user.
33
35
  *
34
36
  * Round 2: [hooks] receives every record right after step 1, also when the insert failed or the caller was cancelled
35
37
  * during the insert (a companion listener keeps its own audit trail, so a local database failure must not hide the
@@ -43,6 +45,8 @@ class DefaultRecordSink(
43
45
  private val syncer: HttpSyncer,
44
46
  private val events: EventBus,
45
47
  private val hooks: RecordHooks = RecordHooks(),
48
+ /** Free bytes of the app's storage, for the `StorageFull` event; null if unknown. */
49
+ private val diskFreeBytes: () -> Long? = { null },
46
50
  ) : RecordSink {
47
51
  override suspend fun submit(record: Record): Record {
48
52
  val stored = try {
@@ -56,6 +60,14 @@ class DefaultRecordSink(
56
60
  throw e
57
61
  } catch (e: Exception) {
58
62
  Logger.e(TAG, "failed to persist ${record.event.wire} record ${record.uuid}", e)
63
+ if (isStorageFull(e)) {
64
+ val free = try {
65
+ diskFreeBytes()
66
+ } catch (x: Exception) {
67
+ null
68
+ }
69
+ events.emit(TrackingEvent.StorageFull(record.uuid, record.event, free))
70
+ }
59
71
  false
60
72
  }
61
73
 
@@ -91,5 +103,11 @@ class DefaultRecordSink(
91
103
 
92
104
  private companion object {
93
105
  const val TAG = "LT.RecordSink"
106
+
107
+ /** True if [e] or one of its causes is `SQLiteFullException` (or an "out of disk space" I/O error). */
108
+ fun isStorageFull(e: Throwable): Boolean = generateSequence(e) { it.cause }.take(8).any {
109
+ it is SQLiteFullException || it.message?.contains("SQLITE_FULL") == true ||
110
+ it.message?.contains("ENOSPC") == true
111
+ }
94
112
  }
95
113
  }
@@ -37,6 +37,13 @@ internal class BootCountStore(context: Context) {
37
37
  private fun read(key: String): Int? = if (prefs.contains(key)) prefs.getInt(key, -1) else null
38
38
 
39
39
  companion object {
40
+ /** Forgets every stored boot count (the state was restored onto another installation). */
41
+ @SuppressLint("ApplySharedPref")
42
+ fun clear(context: Context) {
43
+ (context.applicationContext ?: context).getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
44
+ .edit().clear().commit()
45
+ }
46
+
40
47
  const val PREFS_NAME = "location_tracking_boot"
41
48
  private const val KEY_LAST_HANDLED = "last_handled_boot_count"
42
49
  private const val KEY_LAST_SERVICE_START = "last_service_start_boot_count"
@@ -1,5 +1,5 @@
1
1
  <?xml version="1.0" encoding="utf-8"?>
2
2
  <paths>
3
- <files-path name="lt_logs" path="location-tracking-logs/" />
3
+ <!-- Only the gzipped copy in the cache is shared (emailLog); the daily files are under noBackupFilesDir. -->
4
4
  <cache-path name="lt_logs_cache" path="location-tracking-logs/" />
5
5
  </paths>
@@ -121,6 +121,12 @@ export interface HeartbeatConfig {
121
121
  * @default 300
122
122
  */
123
123
  maxInterval?: number;
124
+ /**
125
+ * seconds, 0..60. After a heartbeat is queued, keep the phone awake until its upload has finished, or at most this
126
+ * long, so it reaches the server on time while tracking is on. 0 = do not keep the phone awake.
127
+ * @default 10
128
+ */
129
+ uploadKeepAwake?: number;
124
130
  }
125
131
  export interface AuthorizationConfig {
126
132
  /** @default 'JWT' */
@@ -186,6 +192,11 @@ export interface HttpConfig {
186
192
  * @default 60000
187
193
  */
188
194
  timeout?: number;
195
+ /**
196
+ * seconds, >= 0. The longest wait that a `Retry-After` header of a 429 or 503 answer can impose on automatic uploads.
197
+ * @default 3600
198
+ */
199
+ maxRetryAfter?: number;
189
200
  authorization?: AuthorizationConfig | null;
190
201
  }
191
202
  export interface PersistenceConfig {
@@ -301,8 +312,22 @@ export interface State {
301
312
  odometer: number;
302
313
  backend: LocationBackend;
303
314
  lastRecordAt: string | null;
304
- /** fully populated with defaults */
315
+ /** reason of the last `tracking_stop` record (the `tracking_stop` reasons of `Location.reason`); null if none */
316
+ lastStopReason: string | null;
317
+ /** when tracking stopped for `lastStopReason` (ISO-8601 UTC ms); null if none */
318
+ lastStopAt: string | null;
319
+ /**
320
+ * fully populated with defaults. On Android the `http.authorization` secrets (`accessToken`, `refreshToken`, the
321
+ * values of `refreshPayload` and `refreshHeaders`) are masked: `***`, or `***` plus the last 4 characters for values
322
+ * longer than 8 characters; a `refreshPayload` value that contains `{refreshToken}` is shown. Passing a masked value
323
+ * back to `setConfig` keeps the stored one. The web stub returns them as given.
324
+ */
305
325
  config: Config;
326
+ /**
327
+ * `ready()`, `setConfig()` and `reset()` only: what the given config contains that was accepted but is probably
328
+ * wrong (for example an `http://` upload URL).
329
+ */
330
+ warnings?: string[];
306
331
  }
307
332
  export interface Coords {
308
333
  latitude: number;
@@ -343,10 +368,16 @@ export interface Location {
343
368
  type: ActivityType;
344
369
  confidence: number;
345
370
  };
346
- /** level 0..1, -1 unknown */
371
+ /**
372
+ * level 0..1, -1 unknown. Heartbeat records also carry `temperature` (°C), `health` and `plugged` when the device
373
+ * reports them.
374
+ */
347
375
  battery: {
348
376
  level: number;
349
377
  is_charging: boolean;
378
+ temperature?: number;
379
+ health?: 'good' | 'overheat' | 'dead' | 'over_voltage' | 'cold' | 'failure' | 'unknown';
380
+ plugged?: 'ac' | 'usb' | 'wireless' | 'dock' | 'none' | 'other';
350
381
  };
351
382
  backend: LocationBackend | null;
352
383
  extras?: Record<string, unknown>;
@@ -360,11 +391,45 @@ export interface Location {
360
391
  provider?: ProviderState;
361
392
  /**
362
393
  * tracking_start: start|start_geofences|boot|restore|package_replaced|resume_notification;
363
- * tracking_stop: stop|stop_on_stationary|stop_after_elapsed|terminate|permission_denied|service_start_failed|reboot|package_replaced
394
+ * tracking_stop: stop|stop_on_stationary|stop_after_elapsed|terminate|permission_denied|service_start_failed|reboot|package_replaced|user_stopped
364
395
  */
365
396
  reason?: string;
397
+ /** event 'tracking_stop' with reason 'user_stopped' only: when the user stopped the app (ISO-8601 UTC ms) */
398
+ stopped_at?: string;
366
399
  /** event 'heartbeat' only, optional: how the heartbeat is scheduled */
367
400
  heartbeat?: HeartbeatMeta;
401
+ /** connection type when the record was created */
402
+ network?: ConnectivityType;
403
+ /** the device id on every record; more readings on 'heartbeat' and 'tracking_start' records (see `DeviceReadings`) */
404
+ device?: DeviceReadings;
405
+ }
406
+ /**
407
+ * The `device` object of records; a reading is present only when the device provides it.
408
+ * Every record: `device_id`.
409
+ * Heartbeat records: `background_restricted`, `standby_bucket`, `disk_free_bytes`, `thermal_status`.
410
+ * `tracking_start` records: `manufacturer`, `model`, `os_version`, `sdk_int`, `low_ram_device`, `ram_total_bytes`.
411
+ */
412
+ export interface DeviceReadings {
413
+ /**
414
+ * the device id: on Android, `Settings.Secure.ANDROID_ID`; other platforms will fill it with their own id.
415
+ * Absent in records queued by an older version. Declare "Device or other IDs" in Google Play's Data safety form.
416
+ */
417
+ device_id?: string;
418
+ /** the user set the app's battery use to "Restricted" (Android 9+) */
419
+ background_restricted?: boolean;
420
+ /** App Standby bucket (Android 9+) */
421
+ standby_bucket?: 'exempted' | 'active' | 'working_set' | 'frequent' | 'rare' | 'restricted';
422
+ /** free space on the storage that holds the app's files */
423
+ disk_free_bytes?: number;
424
+ /** Android 10+ */
425
+ thermal_status?: 'none' | 'light' | 'moderate' | 'severe' | 'critical' | 'emergency' | 'shutdown' | 'unknown';
426
+ manufacturer?: string;
427
+ model?: string;
428
+ /** Android version, e.g. "14" */
429
+ os_version?: string;
430
+ sdk_int?: number;
431
+ low_ram_device?: boolean;
432
+ ram_total_bytes?: number;
368
433
  }
369
434
  /** Scheduling metadata of a `heartbeat` record (snake_case = wire format). */
370
435
  export interface HeartbeatMeta {
@@ -382,7 +447,8 @@ export interface HeartbeatMeta {
382
447
  }
383
448
  export type LocationRecord = Location;
384
449
  export interface InsertLocationInput {
385
- coords: Pick<Coords, 'latitude' | 'longitude'> & Partial<Coords>;
450
+ /** `accuracy` (m) is required and must be greater than 0; otherwise the call rejects with `INVALID_ARGUMENT`. */
451
+ coords: Pick<Coords, 'latitude' | 'longitude' | 'accuracy'> & Partial<Coords>;
386
452
  timestamp?: string;
387
453
  event?: RecordEvent;
388
454
  is_moving?: boolean;
@@ -449,6 +515,25 @@ export interface PowerSaveChangeEvent {
449
515
  }
450
516
  export interface EnabledChangeEvent {
451
517
  enabled: boolean;
518
+ /**
519
+ * why: a `tracking_start` reason when tracking was switched on (start, start_geofences, resume_notification), a
520
+ * `tracking_stop` reason when it was switched off; the same values as `Location.reason`
521
+ */
522
+ reason: string | null;
523
+ }
524
+ /** The location request failed (no permission, Play services or HMS Core refused it, no usable provider). */
525
+ export interface LocationErrorEvent {
526
+ code: ErrorCode;
527
+ message: string;
528
+ backend: LocationBackend;
529
+ /** the plugin requests location updates again after this many ms; null if it does not */
530
+ retryInMs: number | null;
531
+ }
532
+ /** A record could not be stored because the device storage is full. */
533
+ export interface StorageFullEvent {
534
+ uuid: string;
535
+ event: RecordEvent;
536
+ diskFreeBytes: number | null;
452
537
  }
453
538
  export interface NotificationActionEvent {
454
539
  id: string;
@@ -473,6 +558,8 @@ export interface LocationTrackingEventMap {
473
558
  enabledchange: EnabledChangeEvent;
474
559
  notificationaction: NotificationActionEvent;
475
560
  authorization: AuthorizationEvent;
561
+ locationerror: LocationErrorEvent;
562
+ storagefull: StorageFullEvent;
476
563
  }
477
564
  export type LocationTrackingEventName = keyof LocationTrackingEventMap;
478
565
  export interface CurrentPositionOptions {
@@ -545,6 +632,20 @@ export interface DeviceInfo {
545
632
  hmsAvailable: boolean;
546
633
  backend: LocationBackend;
547
634
  packagedProviders: string[];
635
+ /** why a better location backend is not used (for example "gms: unavailable (status 2)"); null when none is better */
636
+ backendFallbackReason: string | null;
637
+ /** raw GoogleApiAvailability result (0 = success); null if GMS is not packaged or not checked yet */
638
+ gmsStatus: number | null;
639
+ /** raw HuaweiApiAvailability result (0 = success); null if HMS is not packaged or not checked yet */
640
+ hmsStatus: number | null;
641
+ /** true when HMS Core is older than 6.3.0.301; null if HMS is not packaged */
642
+ hmsCoreOutdated: boolean | null;
643
+ /** last LocationAvailability reported by GMS or HMS; only a hint (it can be wrong both ways); null if none */
644
+ locationAvailableHint: boolean | null;
645
+ /** number of CPU cores */
646
+ cpuCores: number;
647
+ /** CPU types (Build.SUPPORTED_ABIS), e.g. ["arm64-v8a"] */
648
+ supportedAbis: string[];
548
649
  }
549
650
  export interface Sensors {
550
651
  accelerometer: boolean;
@@ -715,5 +816,7 @@ export interface LocationTrackingPlugin {
715
816
  addListener(eventName: 'enabledchange', listenerFunc: (e: EnabledChangeEvent) => void): Promise<PluginListenerHandle>;
716
817
  addListener(eventName: 'notificationaction', listenerFunc: (e: NotificationActionEvent) => void): Promise<PluginListenerHandle>;
717
818
  addListener(eventName: 'authorization', listenerFunc: (e: AuthorizationEvent) => void): Promise<PluginListenerHandle>;
819
+ addListener(eventName: 'locationerror', listenerFunc: (e: LocationErrorEvent) => void): Promise<PluginListenerHandle>;
820
+ addListener(eventName: 'storagefull', listenerFunc: (e: StorageFullEvent) => void): Promise<PluginListenerHandle>;
718
821
  removeAllListeners(): Promise<void>;
719
822
  }