@bricks-soft/capacitor-location-tracking 0.0.0-stage → 8.0.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 (168) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +2759 -2
  3. package/android/build.gradle +106 -0
  4. package/android/consumer-rules.pro +42 -0
  5. package/android/src/main/AndroidManifest.xml +52 -0
  6. package/android/src/main/java/com/brickssoft/locationtracking/LocationTrackingPlugin.kt +431 -0
  7. package/android/src/main/java/com/brickssoft/locationtracking/api/LocationTrackingListener.kt +52 -0
  8. package/android/src/main/java/com/brickssoft/locationtracking/api/LocationTrackingNative.kt +267 -0
  9. package/android/src/main/java/com/brickssoft/locationtracking/api/NativeListeners.kt +270 -0
  10. package/android/src/main/java/com/brickssoft/locationtracking/api/NativeThread.kt +57 -0
  11. package/android/src/main/java/com/brickssoft/locationtracking/bridge/BridgeServices.kt +64 -0
  12. package/android/src/main/java/com/brickssoft/locationtracking/bridge/CallResponder.kt +41 -0
  13. package/android/src/main/java/com/brickssoft/locationtracking/bridge/OptionParsers.kt +281 -0
  14. package/android/src/main/java/com/brickssoft/locationtracking/bridge/PluginHandlers.kt +541 -0
  15. package/android/src/main/java/com/brickssoft/locationtracking/bridge/PluginPermissionHost.kt +76 -0
  16. package/android/src/main/java/com/brickssoft/locationtracking/bridge/WatchRegistry.kt +84 -0
  17. package/android/src/main/java/com/brickssoft/locationtracking/config/Config.kt +231 -0
  18. package/android/src/main/java/com/brickssoft/locationtracking/config/ConfigJson.kt +574 -0
  19. package/android/src/main/java/com/brickssoft/locationtracking/config/ConfigStore.kt +31 -0
  20. package/android/src/main/java/com/brickssoft/locationtracking/config/ConfigValidator.kt +170 -0
  21. package/android/src/main/java/com/brickssoft/locationtracking/config/RuntimeStateJson.kt +73 -0
  22. package/android/src/main/java/com/brickssoft/locationtracking/config/SharedPrefsConfigStore.kt +135 -0
  23. package/android/src/main/java/com/brickssoft/locationtracking/config/State.kt +45 -0
  24. package/android/src/main/java/com/brickssoft/locationtracking/core/AppDispatchers.kt +21 -0
  25. package/android/src/main/java/com/brickssoft/locationtracking/core/Clock.kt +41 -0
  26. package/android/src/main/java/com/brickssoft/locationtracking/core/Components.kt +217 -0
  27. package/android/src/main/java/com/brickssoft/locationtracking/core/Constants.kt +78 -0
  28. package/android/src/main/java/com/brickssoft/locationtracking/core/Errors.kt +27 -0
  29. package/android/src/main/java/com/brickssoft/locationtracking/core/EventBus.kt +39 -0
  30. package/android/src/main/java/com/brickssoft/locationtracking/core/ForceStopProbe.kt +63 -0
  31. package/android/src/main/java/com/brickssoft/locationtracking/core/Iso8601.kt +63 -0
  32. package/android/src/main/java/com/brickssoft/locationtracking/core/Logger.kt +68 -0
  33. package/android/src/main/java/com/brickssoft/locationtracking/core/RecordHooks.kt +47 -0
  34. package/android/src/main/java/com/brickssoft/locationtracking/core/TrackingEvent.kt +64 -0
  35. package/android/src/main/java/com/brickssoft/locationtracking/data/SqlSupport.kt +79 -0
  36. package/android/src/main/java/com/brickssoft/locationtracking/data/SqliteGeofenceStore.kt +144 -0
  37. package/android/src/main/java/com/brickssoft/locationtracking/data/SqliteLocationStore.kt +215 -0
  38. package/android/src/main/java/com/brickssoft/locationtracking/data/Stores.kt +45 -0
  39. package/android/src/main/java/com/brickssoft/locationtracking/data/TrackingDatabase.kt +111 -0
  40. package/android/src/main/java/com/brickssoft/locationtracking/device/DefaultDeviceInfoProvider.kt +104 -0
  41. package/android/src/main/java/com/brickssoft/locationtracking/device/DefaultDeviceMonitor.kt +335 -0
  42. package/android/src/main/java/com/brickssoft/locationtracking/device/DeviceMonitor.kt +36 -0
  43. package/android/src/main/java/com/brickssoft/locationtracking/device/DeviceStateMapping.kt +69 -0
  44. package/android/src/main/java/com/brickssoft/locationtracking/engine/DefaultTrackingEngine.kt +1281 -0
  45. package/android/src/main/java/com/brickssoft/locationtracking/engine/ElasticDistance.kt +68 -0
  46. package/android/src/main/java/com/brickssoft/locationtracking/engine/LocationRequests.kt +74 -0
  47. package/android/src/main/java/com/brickssoft/locationtracking/engine/MotionStateMachine.kt +291 -0
  48. package/android/src/main/java/com/brickssoft/locationtracking/engine/StationaryRegion.kt +41 -0
  49. package/android/src/main/java/com/brickssoft/locationtracking/engine/TrackingEngine.kt +60 -0
  50. package/android/src/main/java/com/brickssoft/locationtracking/engine/TrackingTimers.kt +90 -0
  51. package/android/src/main/java/com/brickssoft/locationtracking/geofence/DefaultGeofenceManager.kt +899 -0
  52. package/android/src/main/java/com/brickssoft/locationtracking/geofence/DwellTracker.kt +80 -0
  53. package/android/src/main/java/com/brickssoft/locationtracking/geofence/GeofenceManager.kt +33 -0
  54. package/android/src/main/java/com/brickssoft/locationtracking/geofence/PolygonMath.kt +194 -0
  55. package/android/src/main/java/com/brickssoft/locationtracking/heartbeat/DefaultHeartbeatScheduler.kt +713 -0
  56. package/android/src/main/java/com/brickssoft/locationtracking/heartbeat/HeartbeatAlarmReceiver.kt +69 -0
  57. package/android/src/main/java/com/brickssoft/locationtracking/heartbeat/HeartbeatAlarms.kt +100 -0
  58. package/android/src/main/java/com/brickssoft/locationtracking/heartbeat/HeartbeatScheduler.kt +25 -0
  59. package/android/src/main/java/com/brickssoft/locationtracking/heartbeat/HeartbeatWindow.kt +179 -0
  60. package/android/src/main/java/com/brickssoft/locationtracking/http/AuthorizationManager.kt +213 -0
  61. package/android/src/main/java/com/brickssoft/locationtracking/http/BodyBuilder.kt +73 -0
  62. package/android/src/main/java/com/brickssoft/locationtracking/http/HttpSupport.kt +118 -0
  63. package/android/src/main/java/com/brickssoft/locationtracking/http/HttpSyncer.kt +18 -0
  64. package/android/src/main/java/com/brickssoft/locationtracking/http/OkHttpSyncer.kt +551 -0
  65. package/android/src/main/java/com/brickssoft/locationtracking/http/StrictJson.kt +135 -0
  66. package/android/src/main/java/com/brickssoft/locationtracking/http/SyncPolicy.kt +109 -0
  67. package/android/src/main/java/com/brickssoft/locationtracking/http/TemplateRenderer.kt +143 -0
  68. package/android/src/main/java/com/brickssoft/locationtracking/logging/FileLogger.kt +461 -0
  69. package/android/src/main/java/com/brickssoft/locationtracking/logging/LogFileProvider.kt +9 -0
  70. package/android/src/main/java/com/brickssoft/locationtracking/logging/LogFormatter.kt +148 -0
  71. package/android/src/main/java/com/brickssoft/locationtracking/logging/LogStore.kt +30 -0
  72. package/android/src/main/java/com/brickssoft/locationtracking/model/DeviceModels.kt +176 -0
  73. package/android/src/main/java/com/brickssoft/locationtracking/model/GeofenceSpec.kt +25 -0
  74. package/android/src/main/java/com/brickssoft/locationtracking/model/ModelJson.kt +550 -0
  75. package/android/src/main/java/com/brickssoft/locationtracking/model/Record.kt +106 -0
  76. package/android/src/main/java/com/brickssoft/locationtracking/model/TrackedLocation.kt +53 -0
  77. package/android/src/main/java/com/brickssoft/locationtracking/permission/BackgroundRationaleDialog.kt +85 -0
  78. package/android/src/main/java/com/brickssoft/locationtracking/permission/CurrentActivityTracker.kt +72 -0
  79. package/android/src/main/java/com/brickssoft/locationtracking/permission/DefaultPermissionManager.kt +333 -0
  80. package/android/src/main/java/com/brickssoft/locationtracking/permission/PermissionManager.kt +54 -0
  81. package/android/src/main/java/com/brickssoft/locationtracking/position/DefaultPositionService.kt +277 -0
  82. package/android/src/main/java/com/brickssoft/locationtracking/position/PositionService.kt +39 -0
  83. package/android/src/main/java/com/brickssoft/locationtracking/position/PositionWatch.kt +115 -0
  84. package/android/src/main/java/com/brickssoft/locationtracking/processing/DefaultLocationProcessor.kt +93 -0
  85. package/android/src/main/java/com/brickssoft/locationtracking/processing/DefaultOdometer.kt +99 -0
  86. package/android/src/main/java/com/brickssoft/locationtracking/processing/DefaultRecordFactory.kt +166 -0
  87. package/android/src/main/java/com/brickssoft/locationtracking/processing/Geo.kt +56 -0
  88. package/android/src/main/java/com/brickssoft/locationtracking/processing/KalmanFilter.kt +72 -0
  89. package/android/src/main/java/com/brickssoft/locationtracking/processing/Processing.kt +48 -0
  90. package/android/src/main/java/com/brickssoft/locationtracking/provider/Backends.kt +187 -0
  91. package/android/src/main/java/com/brickssoft/locationtracking/provider/DefaultProviderFactory.kt +169 -0
  92. package/android/src/main/java/com/brickssoft/locationtracking/provider/ProviderPackaging.kt +135 -0
  93. package/android/src/main/java/com/brickssoft/locationtracking/provider/android/AndroidActivityBackend.kt +25 -0
  94. package/android/src/main/java/com/brickssoft/locationtracking/provider/android/AndroidGeofenceBackend.kt +181 -0
  95. package/android/src/main/java/com/brickssoft/locationtracking/provider/android/AndroidGeofenceReceiver.kt +115 -0
  96. package/android/src/main/java/com/brickssoft/locationtracking/provider/android/AndroidLocationBackend.kt +265 -0
  97. package/android/src/main/java/com/brickssoft/locationtracking/provider/android/AndroidProviderBundle.kt +31 -0
  98. package/android/src/main/java/com/brickssoft/locationtracking/provider/android/LocationProviders.kt +98 -0
  99. package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsActivityBackend.kt +80 -0
  100. package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsActivityReceiver.kt +62 -0
  101. package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsErrors.kt +72 -0
  102. package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsGeofenceBackend.kt +152 -0
  103. package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsGeofenceReceiver.kt +78 -0
  104. package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsLocationBackend.kt +176 -0
  105. package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsProviderBundle.kt +62 -0
  106. package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsReceiverSupport.kt +88 -0
  107. package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsTasks.kt +55 -0
  108. package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsActivityBackend.kt +71 -0
  109. package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsActivityReceiver.kt +57 -0
  110. package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsErrors.kt +76 -0
  111. package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsGeofenceBackend.kt +116 -0
  112. package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsGeofenceReceiver.kt +67 -0
  113. package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsIntents.kt +83 -0
  114. package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsLocationBackend.kt +197 -0
  115. package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsProviderBundle.kt +55 -0
  116. package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsTasks.kt +44 -0
  117. package/android/src/main/java/com/brickssoft/locationtracking/record/RecordSink.kt +95 -0
  118. package/android/src/main/java/com/brickssoft/locationtracking/service/BootCountStore.kt +44 -0
  119. package/android/src/main/java/com/brickssoft/locationtracking/service/BootReceiver.kt +175 -0
  120. package/android/src/main/java/com/brickssoft/locationtracking/service/DefaultServiceController.kt +196 -0
  121. package/android/src/main/java/com/brickssoft/locationtracking/service/LocationTrackingService.kt +319 -0
  122. package/android/src/main/java/com/brickssoft/locationtracking/service/NotificationActionReceiver.kt +42 -0
  123. package/android/src/main/java/com/brickssoft/locationtracking/service/NotificationFactory.kt +338 -0
  124. package/android/src/main/java/com/brickssoft/locationtracking/service/ServiceCommands.kt +204 -0
  125. package/android/src/main/java/com/brickssoft/locationtracking/service/ServiceController.kt +22 -0
  126. package/android/src/main/java/com/brickssoft/locationtracking/service/ServiceDeps.kt +61 -0
  127. package/android/src/main/java/com/brickssoft/locationtracking/service/WhileInUseStartCheck.kt +115 -0
  128. package/android/src/main/java/com/brickssoft/locationtracking/settings/DefaultDeviceSettings.kt +79 -0
  129. package/android/src/main/java/com/brickssoft/locationtracking/settings/DeviceSettings.kt +21 -0
  130. package/android/src/main/java/com/brickssoft/locationtracking/settings/OemPowerManagers.kt +198 -0
  131. package/android/src/main/res/drawable/lt_ic_notification.xml +11 -0
  132. package/android/src/main/res/values/lt_strings.xml +11 -0
  133. package/android/src/main/res/xml/lt_file_paths.xml +5 -0
  134. package/dist/esm/definitions.d.ts +719 -0
  135. package/dist/esm/definitions.js +2 -0
  136. package/dist/esm/definitions.js.map +1 -0
  137. package/dist/esm/events.d.ts +50 -0
  138. package/dist/esm/events.js +52 -0
  139. package/dist/esm/events.js.map +1 -0
  140. package/dist/esm/index.d.ts +3 -0
  141. package/dist/esm/index.js +5 -0
  142. package/dist/esm/index.js.map +1 -0
  143. package/dist/esm/plugin.d.ts +3 -0
  144. package/dist/esm/plugin.js +10 -0
  145. package/dist/esm/plugin.js.map +1 -0
  146. package/dist/esm/web/config.d.ts +19 -0
  147. package/dist/esm/web/config.js +243 -0
  148. package/dist/esm/web/config.js.map +1 -0
  149. package/dist/esm/web/device.d.ts +9 -0
  150. package/dist/esm/web/device.js +90 -0
  151. package/dist/esm/web/device.js.map +1 -0
  152. package/dist/esm/web/errors.d.ts +14 -0
  153. package/dist/esm/web/errors.js +19 -0
  154. package/dist/esm/web/errors.js.map +1 -0
  155. package/dist/esm/web/geolocation.d.ts +26 -0
  156. package/dist/esm/web/geolocation.js +117 -0
  157. package/dist/esm/web/geolocation.js.map +1 -0
  158. package/dist/esm/web/location.d.ts +7 -0
  159. package/dist/esm/web/location.js +70 -0
  160. package/dist/esm/web/location.js.map +1 -0
  161. package/dist/esm/web.d.ts +164 -0
  162. package/dist/esm/web.js +328 -0
  163. package/dist/esm/web.js.map +1 -0
  164. package/dist/plugin.cjs.js +945 -0
  165. package/dist/plugin.cjs.js.map +1 -0
  166. package/dist/plugin.js +948 -0
  167. package/dist/plugin.js.map +1 -0
  168. package/package.json +59 -4
@@ -0,0 +1,152 @@
1
+ package com.brickssoft.locationtracking.provider.gms
2
+
3
+ import android.app.PendingIntent
4
+ import android.content.Context
5
+ import android.content.Intent
6
+ import com.brickssoft.locationtracking.core.Constants
7
+ import com.brickssoft.locationtracking.core.ErrorCode
8
+ import com.brickssoft.locationtracking.core.Logger
9
+ import com.brickssoft.locationtracking.core.TrackingException
10
+ import com.brickssoft.locationtracking.model.ProviderKind
11
+ import com.brickssoft.locationtracking.provider.GeofenceBackend
12
+ import com.brickssoft.locationtracking.provider.OsGeofence
13
+ import com.google.android.gms.location.Geofence
14
+ import com.google.android.gms.location.GeofencingClient
15
+ import com.google.android.gms.location.GeofencingRequest
16
+ import kotlinx.coroutines.currentCoroutineContext
17
+ import kotlinx.coroutines.ensureActive
18
+ import kotlin.coroutines.cancellation.CancellationException
19
+
20
+ /**
21
+ * OS geofencing through `GeofencingClient`. Transitions arrive at [GmsGeofenceReceiver] through a mutable,
22
+ * explicit broadcast PendingIntent. Adding a region with an existing id replaces it.
23
+ */
24
+ internal class GmsGeofenceBackend(
25
+ private val context: Context,
26
+ private val client: GeofencingClient,
27
+ ) : GeofenceBackend {
28
+ override val kind: ProviderKind = ProviderKind.GMS
29
+ override val supportsDwell: Boolean = true
30
+
31
+ /**
32
+ * Registers [regions], one GMS request per distinct `initialTriggerEntry` value. Regions that watch no
33
+ * transition are skipped (GMS rejects them). If a later request fails, the regions registered by the earlier
34
+ * requests of this call are removed again (best effort) before the error is thrown.
35
+ *
36
+ * @throws TrackingException see [GmsErrors.map].
37
+ */
38
+ override suspend fun add(regions: List<OsGeofence>) = call("addGeofences") {
39
+ val requests = buildGeofencingRequests(regions)
40
+ val registered = mutableListOf<String>()
41
+ try {
42
+ for (request in requests) {
43
+ client.addGeofences(request, pendingIntent(context)).await()
44
+ registered += request.geofences.map { it.requestId }
45
+ }
46
+ } catch (e: Exception) {
47
+ if (registered.isNotEmpty()) rollBack(registered)
48
+ throw e
49
+ }
50
+ if (registered.isNotEmpty()) Logger.d(TAG, "registered ${registered.size} geofences")
51
+ }
52
+
53
+ /** @throws TrackingException see [GmsErrors.map]. */
54
+ override suspend fun remove(ids: List<String>) {
55
+ if (ids.isEmpty()) return
56
+ call("removeGeofences") { client.removeGeofences(ids).await() }
57
+ }
58
+
59
+ /** Removes every geofence registered with this backend's PendingIntent; a no-op if it does not exist. */
60
+ override suspend fun removeAll() = call("removeAllGeofences") {
61
+ val pending = existingPendingIntent(context) ?: return@call
62
+ client.removeGeofences(pending).await()
63
+ }
64
+
65
+ /** Best effort; never replaces the original error (unless the caller itself is cancelled). */
66
+ private suspend fun rollBack(ids: List<String>) {
67
+ try {
68
+ client.removeGeofences(ids).await()
69
+ } catch (e: Exception) {
70
+ currentCoroutineContext().ensureActive()
71
+ Logger.w(TAG, "rolling back geofences $ids failed", e)
72
+ }
73
+ }
74
+
75
+ private suspend fun call(operation: String, block: suspend () -> Unit) {
76
+ try {
77
+ block()
78
+ } catch (e: CancellationException) {
79
+ currentCoroutineContext().ensureActive()
80
+ // GMS cancelled the Task itself; the caller is still active and expects a TrackingException.
81
+ throw TrackingException(ErrorCode.UNAVAILABLE, "$operation was cancelled by Google Play services", e)
82
+ } catch (e: Exception) {
83
+ val mapped = GmsErrors.map(e, operation)
84
+ Logger.w(TAG, "${mapped.code}: ${mapped.message}", e)
85
+ throw mapped
86
+ }
87
+ }
88
+
89
+ companion object {
90
+ private const val TAG = "LT.GmsGeofence"
91
+
92
+ /** Initial trigger for `initialTriggerEntry`: report ENTER, and DWELL after the delay, if already inside. */
93
+ private const val INITIAL_TRIGGER_INSIDE =
94
+ GeofencingRequest.INITIAL_TRIGGER_ENTER or GeofencingRequest.INITIAL_TRIGGER_DWELL
95
+
96
+ /** Explicit broadcast to [GmsGeofenceReceiver]; mutable because GMS fills in the transition. */
97
+ fun pendingIntent(context: Context): PendingIntent =
98
+ PendingIntent.getBroadcast(context, Constants.RC_GMS_GEOFENCE, intent(context), Constants.piMutable())
99
+
100
+ /** The PendingIntent of [pendingIntent] if it exists, without creating it. */
101
+ fun existingPendingIntent(context: Context): PendingIntent? = PendingIntent.getBroadcast(
102
+ context,
103
+ Constants.RC_GMS_GEOFENCE,
104
+ intent(context),
105
+ Constants.piMutable() or PendingIntent.FLAG_NO_CREATE,
106
+ )
107
+
108
+ private fun intent(context: Context): Intent =
109
+ Intent(context, GmsGeofenceReceiver::class.java).setAction(Constants.ACTION_GEOFENCE)
110
+
111
+ /**
112
+ * Groups [regions] by `initialTriggerEntry`. `true` sets the initial trigger `ENTER | DWELL` (a device that is
113
+ * already inside reports ENTER, and DWELL after the loitering delay, for the transitions it watches); `false`
114
+ * sets no initial trigger.
115
+ *
116
+ * @throws IllegalArgumentException if GMS rejects a region (id longer than 100, bad coordinates or radius).
117
+ */
118
+ fun buildGeofencingRequests(regions: List<OsGeofence>): List<GeofencingRequest> = regions
119
+ .mapNotNull { region -> toGeofence(region)?.let { region.initialTriggerEntry to it } }
120
+ .groupBy({ it.first }, { it.second })
121
+ .map { (initialTriggerEntry, geofences) ->
122
+ GeofencingRequest.Builder()
123
+ .setInitialTrigger(if (initialTriggerEntry) INITIAL_TRIGGER_INSIDE else 0)
124
+ .addGeofences(geofences)
125
+ .build()
126
+ }
127
+
128
+ /** The GMS geofence for [region], or null if it watches no transition. */
129
+ fun toGeofence(region: OsGeofence): Geofence? {
130
+ val transitions = transitionTypes(region)
131
+ if (transitions == 0) {
132
+ Logger.w(TAG, "geofence '${region.id}' watches no transition; not registered")
133
+ return null
134
+ }
135
+ return Geofence.Builder()
136
+ .setRequestId(region.id)
137
+ .setCircularRegion(region.latitude, region.longitude, region.radius)
138
+ .setTransitionTypes(transitions)
139
+ .setLoiteringDelay(region.loiteringDelayMs.coerceAtLeast(0))
140
+ .setExpirationDuration(Geofence.NEVER_EXPIRE)
141
+ .build()
142
+ }
143
+
144
+ fun transitionTypes(region: OsGeofence): Int {
145
+ var types = 0
146
+ if (region.onEntry) types = types or Geofence.GEOFENCE_TRANSITION_ENTER
147
+ if (region.onExit) types = types or Geofence.GEOFENCE_TRANSITION_EXIT
148
+ if (region.onDwell) types = types or Geofence.GEOFENCE_TRANSITION_DWELL
149
+ return types
150
+ }
151
+ }
152
+ }
@@ -0,0 +1,78 @@
1
+ package com.brickssoft.locationtracking.provider.gms
2
+
3
+ import android.content.BroadcastReceiver
4
+ import android.content.Context
5
+ import android.content.Intent
6
+ import com.brickssoft.locationtracking.core.Components
7
+ import com.brickssoft.locationtracking.core.Logger
8
+ import com.brickssoft.locationtracking.model.GeofenceAction
9
+ import com.brickssoft.locationtracking.model.TrackedLocation
10
+ import com.brickssoft.locationtracking.provider.GeofenceTransitionSink
11
+ import com.brickssoft.locationtracking.provider.OsGeofenceTransition
12
+ import com.google.android.gms.location.Geofence
13
+ import com.google.android.gms.location.GeofenceStatusCodes
14
+ import com.google.android.gms.location.GeofencingEvent
15
+
16
+ /**
17
+ * Receives `GeofencingEvent`s from the PendingIntent of [GmsGeofenceBackend] and delivers one
18
+ * [OsGeofenceTransition] per triggering geofence to the GeofenceManager with `goAsync()` on `Components.scope`.
19
+ */
20
+ class GmsGeofenceReceiver internal constructor(
21
+ private val target: (Context) -> ReceiverTarget<GeofenceTransitionSink>,
22
+ ) : BroadcastReceiver() {
23
+ constructor() : this({ ctx -> Components.get(ctx).let { ReceiverTarget(it.scope, it.geofences) } })
24
+
25
+ override fun onReceive(context: Context, intent: Intent) {
26
+ val transitions = parseGeofenceTransitions(intent)
27
+ if (transitions.isEmpty()) return
28
+ Logger.d(TAG, "geofence transitions: ${transitions.joinToString { "${it.id}:${it.action}" }}")
29
+ deliverAsync(context, target, TAG, "geofence delivery") { it.onGeofenceTransitions(transitions) }
30
+ }
31
+
32
+ internal companion object {
33
+ private const val TAG = "LT.GmsGeofence"
34
+
35
+ fun parseGeofenceTransitions(intent: Intent?): List<OsGeofenceTransition> {
36
+ val event = try {
37
+ intent?.let { GeofencingEvent.fromIntent(it) }
38
+ } catch (e: Exception) {
39
+ Logger.w(TAG, "unreadable geofencing event", e)
40
+ null
41
+ }
42
+ return parseGeofencingEvent(event)
43
+ }
44
+
45
+ /**
46
+ * One transition per triggering geofence, all with the event's action and triggering location.
47
+ * Empty (and logged) if [event] is null, carries an error or has an unknown transition.
48
+ */
49
+ fun parseGeofencingEvent(event: GeofencingEvent?): List<OsGeofenceTransition> {
50
+ if (event == null) {
51
+ Logger.w(TAG, "intent carries no geofencing event")
52
+ return emptyList()
53
+ }
54
+ if (event.hasError()) {
55
+ val code = event.errorCode
56
+ // GEOFENCE_NOT_AVAILABLE also means GMS removed every registered geofence; nothing re-adds them
57
+ // until tracking restarts (see the unit report's contract change request).
58
+ val notAvailable = code == GeofenceStatusCodes.GEOFENCE_NOT_AVAILABLE
59
+ val dropped = if (notAvailable) "; all geofences were removed" else ""
60
+ Logger.e(TAG, "geofencing error: ${GmsErrors.describe(code)}$dropped")
61
+ return emptyList()
62
+ }
63
+ val action = mapTransition(event.geofenceTransition) ?: run {
64
+ Logger.w(TAG, "unknown geofence transition ${event.geofenceTransition}")
65
+ return emptyList()
66
+ }
67
+ val location = event.triggeringLocation?.let(TrackedLocation::from)
68
+ return event.triggeringGeofences.orEmpty().map { OsGeofenceTransition(it.requestId, action, location) }
69
+ }
70
+
71
+ fun mapTransition(transition: Int): GeofenceAction? = when (transition) {
72
+ Geofence.GEOFENCE_TRANSITION_ENTER -> GeofenceAction.ENTER
73
+ Geofence.GEOFENCE_TRANSITION_EXIT -> GeofenceAction.EXIT
74
+ Geofence.GEOFENCE_TRANSITION_DWELL -> GeofenceAction.DWELL
75
+ else -> null
76
+ }
77
+ }
78
+ }
@@ -0,0 +1,176 @@
1
+ package com.brickssoft.locationtracking.provider.gms
2
+
3
+ import android.os.Looper
4
+ import com.brickssoft.locationtracking.core.Logger
5
+ import com.brickssoft.locationtracking.core.TrackingException
6
+ import com.brickssoft.locationtracking.model.DesiredAccuracy
7
+ import com.brickssoft.locationtracking.model.ProviderKind
8
+ import com.brickssoft.locationtracking.model.TrackedLocation
9
+ import com.brickssoft.locationtracking.provider.LocationBackend
10
+ import com.brickssoft.locationtracking.provider.LocationListener
11
+ import com.brickssoft.locationtracking.provider.LocationRequestSpec
12
+ import com.google.android.gms.location.CurrentLocationRequest
13
+ import com.google.android.gms.location.FusedLocationProviderClient
14
+ import com.google.android.gms.location.LocationAvailability
15
+ import com.google.android.gms.location.LocationCallback
16
+ import com.google.android.gms.location.LocationRequest
17
+ import com.google.android.gms.location.LocationResult
18
+ import com.google.android.gms.location.Priority
19
+ import com.google.android.gms.tasks.CancellationTokenSource
20
+ import kotlinx.coroutines.currentCoroutineContext
21
+ import kotlinx.coroutines.ensureActive
22
+ import kotlinx.coroutines.withTimeoutOrNull
23
+ import kotlin.coroutines.cancellation.CancellationException
24
+
25
+ /**
26
+ * Fused location provider backend.
27
+ *
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.
30
+ *
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).
33
+ */
34
+ internal class GmsLocationBackend(
35
+ private val client: FusedLocationProviderClient,
36
+ private val looper: () -> Looper = Looper::getMainLooper,
37
+ ) : LocationBackend {
38
+ override val kind: ProviderKind = ProviderKind.GMS
39
+
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>()
42
+
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
+ override fun requestUpdates(spec: LocationRequestSpec, listener: LocationListener) {
49
+ val request = try {
50
+ buildLocationRequest(spec)
51
+ } catch (e: Exception) {
52
+ Logger.e(TAG, "invalid location request $spec", e)
53
+ return
54
+ }
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
+ }
68
+ }
69
+
70
+ override fun removeUpdates(listener: LocationListener) {
71
+ synchronized(callbacks) {
72
+ val callback = callbacks.remove(listener) ?: return
73
+ callback.active = false
74
+ try {
75
+ client.removeLocationUpdates(callback).logFailure(TAG, "removeLocationUpdates")
76
+ } catch (e: Exception) {
77
+ Logger.w(TAG, "removeLocationUpdates failed", e)
78
+ }
79
+ }
80
+ }
81
+
82
+ /**
83
+ * The cached fused location, or null if there is none or GMS cannot provide it (including a missing permission;
84
+ * logged). Never throws, so callers such as the heartbeat can always fall back to a record without coords.
85
+ */
86
+ override suspend fun getLastLocation(): TrackedLocation? = try {
87
+ client.lastLocation.await()?.let(TrackedLocation::from)
88
+ } catch (e: CancellationException) {
89
+ currentCoroutineContext().ensureActive()
90
+ Logger.w(TAG, "getLastLocation cancelled by GMS")
91
+ null
92
+ } catch (e: Exception) {
93
+ val mapped = GmsErrors.map(e, "getLastLocation")
94
+ Logger.w(TAG, "${mapped.code}: ${mapped.message}", e)
95
+ null
96
+ }
97
+
98
+ /**
99
+ * Requests one fix computed now (a cached fix is accepted only for PASSIVE, which never computes one itself).
100
+ * Returns null if none arrives within [timeoutMs]: GMS gives up after the request duration, and the coroutine
101
+ * timeout is a safety net that also cancels the GMS request.
102
+ *
103
+ * @throws TrackingException PERMISSION_DENIED without location permission, otherwise see [GmsErrors.map]
104
+ * (UNAVAILABLE for GMS API failures).
105
+ */
106
+ override suspend fun getCurrentLocation(accuracy: DesiredAccuracy, timeoutMs: Long): TrackedLocation? {
107
+ if (timeoutMs <= 0) return null
108
+ val cancellation = CancellationTokenSource()
109
+ return try {
110
+ val request = CurrentLocationRequest.Builder()
111
+ .setPriority(accuracy.toGmsPriority())
112
+ .setDurationMillis(timeoutMs)
113
+ .apply { if (accuracy != DesiredAccuracy.PASSIVE) setMaxUpdateAgeMillis(0) }
114
+ .build()
115
+ withTimeoutOrNull(timeoutMs) {
116
+ client.getCurrentLocation(request, cancellation.token).await(cancellation)
117
+ }?.let(TrackedLocation::from)
118
+ } catch (e: CancellationException) {
119
+ // Either the caller was cancelled (rethrown here) or GMS cancelled the task itself (no fix).
120
+ currentCoroutineContext().ensureActive()
121
+ null
122
+ } catch (e: Exception) {
123
+ throw GmsErrors.map(e, "getCurrentLocation")
124
+ } finally {
125
+ cancellation.cancel()
126
+ }
127
+ }
128
+
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
+ companion object {
154
+ private const val TAG = "LT.GmsLocation"
155
+
156
+ /**
157
+ * `LocationRequest.Builder(priority, interval)` with the fastest interval (clamped to [0, interval]) and the
158
+ * distance filter (at least 0).
159
+ */
160
+ fun buildLocationRequest(spec: LocationRequestSpec): LocationRequest {
161
+ val interval = spec.intervalMs.coerceAtLeast(0)
162
+ val distance = spec.distanceFilterM.takeIf { it.isFinite() }?.coerceAtLeast(0f) ?: 0f
163
+ return LocationRequest.Builder(spec.accuracy.toGmsPriority(), interval)
164
+ .setMinUpdateIntervalMillis(spec.fastestIntervalMs.coerceIn(0, interval))
165
+ .setMinUpdateDistanceMeters(distance)
166
+ .build()
167
+ }
168
+
169
+ fun DesiredAccuracy.toGmsPriority(): Int = when (this) {
170
+ DesiredAccuracy.HIGH -> Priority.PRIORITY_HIGH_ACCURACY
171
+ DesiredAccuracy.BALANCED -> Priority.PRIORITY_BALANCED_POWER_ACCURACY
172
+ DesiredAccuracy.LOW -> Priority.PRIORITY_LOW_POWER
173
+ DesiredAccuracy.PASSIVE -> Priority.PRIORITY_PASSIVE
174
+ }
175
+ }
176
+ }
@@ -0,0 +1,62 @@
1
+ package com.brickssoft.locationtracking.provider.gms
2
+
3
+ import android.content.Context
4
+ import com.brickssoft.locationtracking.core.Logger
5
+ import com.brickssoft.locationtracking.model.ProviderKind
6
+ import com.brickssoft.locationtracking.provider.ActivityBackend
7
+ import com.brickssoft.locationtracking.provider.GeofenceBackend
8
+ import com.brickssoft.locationtracking.provider.LocationBackend
9
+ import com.brickssoft.locationtracking.provider.ProviderBundle
10
+ import com.google.android.gms.common.ConnectionResult
11
+ import com.google.android.gms.common.GoogleApiAvailability
12
+ import com.google.android.gms.location.ActivityRecognition
13
+ import com.google.android.gms.location.ActivityRecognitionClient
14
+ import com.google.android.gms.location.FusedLocationProviderClient
15
+ import com.google.android.gms.location.GeofencingClient
16
+ import com.google.android.gms.location.LocationServices
17
+
18
+ /**
19
+ * Google Play services backends: fused location, activity recognition and geofencing.
20
+ *
21
+ * Created reflectively through the public `(Context)` constructor. The other parameters exist so tests can inject
22
+ * the availability check and the GMS clients. Each backend (and its client) is created on first use.
23
+ */
24
+ class GmsProviderBundle @JvmOverloads constructor(
25
+ context: Context,
26
+ private val availabilityCheck: (Context) -> Int = {
27
+ GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(it)
28
+ },
29
+ private val fusedClient: (Context) -> FusedLocationProviderClient = {
30
+ LocationServices.getFusedLocationProviderClient(it)
31
+ },
32
+ private val activityClient: (Context) -> ActivityRecognitionClient = { ActivityRecognition.getClient(it) },
33
+ private val geofencingClient: (Context) -> GeofencingClient = { LocationServices.getGeofencingClient(it) },
34
+ ) : ProviderBundle {
35
+ private val appContext: Context = context.applicationContext ?: context
36
+
37
+ override val kind: ProviderKind = ProviderKind.GMS
38
+
39
+ private val locationBackend by lazy { GmsLocationBackend(fusedClient(appContext)) }
40
+ private val activityBackend by lazy { GmsActivityBackend(appContext, activityClient(appContext)) }
41
+ private val geofenceBackend by lazy { GmsGeofenceBackend(appContext, geofencingClient(appContext)) }
42
+
43
+ /** True if `GoogleApiAvailability` reports SUCCESS; false on any other result or failure. */
44
+ override fun isAvailable(): Boolean = try {
45
+ val status = availabilityCheck(appContext)
46
+ if (status != ConnectionResult.SUCCESS) Logger.d(TAG, "Google Play services unavailable: status $status")
47
+ status == ConnectionResult.SUCCESS
48
+ } catch (t: Throwable) {
49
+ Logger.w(TAG, "Google Play services availability check failed", t)
50
+ false
51
+ }
52
+
53
+ override fun location(): LocationBackend = locationBackend
54
+
55
+ override fun activity(): ActivityBackend = activityBackend
56
+
57
+ override fun geofence(): GeofenceBackend = geofenceBackend
58
+
59
+ private companion object {
60
+ const val TAG = "LT.Gms"
61
+ }
62
+ }
@@ -0,0 +1,88 @@
1
+ package com.brickssoft.locationtracking.provider.gms
2
+
3
+ import android.content.BroadcastReceiver
4
+ import android.content.Context
5
+ import com.brickssoft.locationtracking.core.Logger
6
+ import kotlinx.coroutines.CoroutineScope
7
+ import kotlinx.coroutines.Job
8
+ import kotlinx.coroutines.delay
9
+ import kotlinx.coroutines.launch
10
+ import java.util.concurrent.atomic.AtomicBoolean
11
+ import kotlin.coroutines.cancellation.CancellationException
12
+
13
+ /** Where a PendingIntent receiver delivers its parsed payload: normally `Components.get(ctx).scope` and a sink. */
14
+ internal class ReceiverTarget<T>(val scope: CoroutineScope, val sink: T)
15
+
16
+ /**
17
+ * The system reports a broadcast ANR if `goAsync()` is not finished in time (10 s for foreground broadcasts), so
18
+ * the broadcast is finished after this deadline even if the delivery is still running.
19
+ */
20
+ internal const val RECEIVER_FINISH_DEADLINE_MS = 9_000L
21
+
22
+ /**
23
+ * Called from `onReceive`: calls `goAsync()`, resolves the target and runs [deliver] with its sink in its scope.
24
+ * The broadcast is always finished, also if the target cannot be resolved.
25
+ */
26
+ internal fun <T> BroadcastReceiver.deliverAsync(
27
+ context: Context,
28
+ target: (Context) -> ReceiverTarget<T>,
29
+ tag: String,
30
+ what: String,
31
+ deliver: suspend (T) -> Unit,
32
+ ) {
33
+ val pending = goAsync()
34
+ try {
35
+ val resolved = target(context)
36
+ resolved.scope.launchReceiverWork(pending, tag, what) { deliver(resolved.sink) }
37
+ } catch (e: Exception) {
38
+ Logger.e(tag, "cannot start $what", e)
39
+ try {
40
+ pending?.finish()
41
+ } catch (finishError: Exception) {
42
+ Logger.w(tag, "$what: finishing the broadcast failed", finishError)
43
+ }
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Runs [block] in this scope for a receiver that called `goAsync()`. [pending] is finished exactly once: when the
49
+ * work completes, fails or is cancelled (even if the scope was already cancelled), or after [deadlineMs] while the
50
+ * work keeps running. Failures are logged.
51
+ */
52
+ internal fun CoroutineScope.launchReceiverWork(
53
+ pending: BroadcastReceiver.PendingResult?,
54
+ tag: String,
55
+ what: String,
56
+ deadlineMs: Long = RECEIVER_FINISH_DEADLINE_MS,
57
+ block: suspend () -> Unit,
58
+ ): Job {
59
+ val finished = AtomicBoolean(false)
60
+ val finish = {
61
+ if (finished.compareAndSet(false, true)) {
62
+ try {
63
+ pending?.finish()
64
+ } catch (e: Exception) {
65
+ Logger.w(tag, "$what: finishing the broadcast failed", e)
66
+ }
67
+ }
68
+ }
69
+ val job = launch {
70
+ try {
71
+ block()
72
+ } catch (e: CancellationException) {
73
+ throw e
74
+ } catch (e: Exception) {
75
+ Logger.e(tag, "$what failed", e)
76
+ }
77
+ }
78
+ val watchdog = launch {
79
+ delay(deadlineMs)
80
+ Logger.w(tag, "$what still running after $deadlineMs ms; finishing the broadcast early")
81
+ finish()
82
+ }
83
+ job.invokeOnCompletion {
84
+ watchdog.cancel()
85
+ finish()
86
+ }
87
+ return job
88
+ }
@@ -0,0 +1,55 @@
1
+ package com.brickssoft.locationtracking.provider.gms
2
+
3
+ import com.brickssoft.locationtracking.core.Logger
4
+ import com.google.android.gms.tasks.CancellationTokenSource
5
+ import com.google.android.gms.tasks.Task
6
+ import kotlinx.coroutines.suspendCancellableCoroutine
7
+ import java.util.concurrent.Executor
8
+ import kotlin.coroutines.cancellation.CancellationException
9
+ import kotlin.coroutines.resume
10
+ import kotlin.coroutines.resumeWithException
11
+
12
+ /** Runs Task listeners on the thread that completes the task; the coroutine then resumes on its own dispatcher. */
13
+ internal object DirectExecutor : Executor {
14
+ override fun execute(command: Runnable) = command.run()
15
+ }
16
+
17
+ /**
18
+ * Suspends until this Task completes, without the play-services coroutines dependency.
19
+ *
20
+ * - Success resumes with the result (which may be null, e.g. no last location or `Task<Void>`).
21
+ * - Failure rethrows the Task's exception unchanged.
22
+ * - A cancelled Task throws [CancellationException].
23
+ * - Cancelling the calling coroutine cancels [cancellationTokenSource], if given, so GMS stops the work.
24
+ */
25
+ internal suspend fun <T> Task<T>.await(cancellationTokenSource: CancellationTokenSource? = null): T {
26
+ if (isComplete) return completedResult()
27
+ return suspendCancellableCoroutine { cont ->
28
+ addOnCompleteListener(DirectExecutor) { task ->
29
+ val error = task.exception
30
+ when {
31
+ error != null -> cont.resumeWithException(error)
32
+ task.isCanceled -> cont.cancel()
33
+ else -> cont.resume(task.result)
34
+ }
35
+ }
36
+ if (cancellationTokenSource != null) {
37
+ cont.invokeOnCancellation { cancellationTokenSource.cancel() }
38
+ }
39
+ }
40
+ }
41
+
42
+ private fun <T> Task<T>.completedResult(): T {
43
+ exception?.let { throw it }
44
+ if (isCanceled) throw CancellationException("Task ${this::class.java.name} was cancelled")
45
+ return result
46
+ }
47
+
48
+ /** Adds a listener that only logs a failure of a fire-and-forget Task. Never throws. */
49
+ internal fun Task<*>?.logFailure(tag: String, what: String) {
50
+ try {
51
+ this?.addOnFailureListener(DirectExecutor) { e -> Logger.w(tag, "$what failed", e) }
52
+ } catch (e: Exception) {
53
+ Logger.w(tag, "$what: cannot observe result", e)
54
+ }
55
+ }