@bricks-soft/capacitor-location-tracking 0.0.0-stage → 8.0.1

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 +107 -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 +60 -4
@@ -0,0 +1,338 @@
1
+ package com.brickssoft.locationtracking.service
2
+
3
+ import android.annotation.SuppressLint
4
+ import android.app.Notification
5
+ import android.app.PendingIntent
6
+ import android.content.Context
7
+ import android.content.Intent
8
+ import android.graphics.Bitmap
9
+ import android.graphics.BitmapFactory
10
+ import android.graphics.Color
11
+ import android.graphics.drawable.AdaptiveIconDrawable
12
+ import android.graphics.drawable.Drawable
13
+ import android.os.Build
14
+ import androidx.annotation.ColorInt
15
+ import androidx.annotation.DrawableRes
16
+ import androidx.annotation.RequiresApi
17
+ import androidx.core.app.NotificationChannelCompat
18
+ import androidx.core.app.NotificationCompat
19
+ import androidx.core.app.NotificationManagerCompat
20
+ import androidx.core.content.res.ResourcesCompat
21
+ import androidx.core.graphics.drawable.toBitmap
22
+ import com.brickssoft.locationtracking.R
23
+ import com.brickssoft.locationtracking.config.NotificationConfig
24
+ import com.brickssoft.locationtracking.config.NotificationPriority
25
+ import com.brickssoft.locationtracking.config.ResumeNotificationConfig
26
+ import com.brickssoft.locationtracking.core.Constants
27
+ import com.brickssoft.locationtracking.core.Logger
28
+
29
+ /**
30
+ * Builds the foreground-service notification and its channel from [NotificationConfig].
31
+ *
32
+ * - Channel id and name come from the config. The default name and text use the plugin string resources
33
+ * (`lt_notification_channel_name`, `lt_notification_text`), so an app can translate them.
34
+ * - Channel importance follows `priority`: min → MIN, low → LOW, default → DEFAULT, high/max → HIGH.
35
+ * **Android fixes a channel's importance when the channel is first created**; the app cannot change it
36
+ * afterwards (only the user can). A later `priority` change therefore affects only the notification's own
37
+ * priority (used below API 26). To apply a new importance, also change `channelId`.
38
+ * - The small icon is resolved from `drawable/name`, `mipmap/name` or `name`; an unknown or adaptive (launcher)
39
+ * icon falls back to `lt_ic_notification`, because such a small icon shows as a white shape or breaks
40
+ * `startForeground`.
41
+ * - The notification is ongoing, silent, alerts only once and is shown immediately
42
+ * (`FOREGROUND_SERVICE_IMMEDIATE`). Tapping it opens the app; up to [Constants.MAX_NOTIFICATION_ACTIONS]
43
+ * buttons broadcast to [NotificationActionReceiver].
44
+ */
45
+ internal class NotificationFactory(context: Context) {
46
+ private val context: Context = context.applicationContext ?: context
47
+
48
+ /** Creates the channel, or updates its name if it exists. No-op below API 26. */
49
+ fun ensureChannel(config: NotificationConfig) {
50
+ val channel = NotificationChannelCompat.Builder(channelId(config), importanceFor(config.priority))
51
+ .setName(channelName(config))
52
+ .setShowBadge(false)
53
+ .setSound(null, null)
54
+ .setVibrationEnabled(false)
55
+ .build()
56
+ NotificationManagerCompat.from(context).createNotificationChannel(channel)
57
+ }
58
+
59
+ /** Ensures the channel, then builds the notification. */
60
+ fun build(config: NotificationConfig): Notification {
61
+ ensureChannel(config)
62
+ return compose(config)
63
+ }
64
+
65
+ /**
66
+ * The notification for the first `startForeground` of a service command. It is built without loading the config
67
+ * (no config store, no SharedPreferences, no JSON), so it is ready within milliseconds on a cold start:
68
+ * - [spec] is the notification fields that the sender copied into the start command (channel id and name,
69
+ * priority, title, text, small icon, color; no action buttons, no large icon). Its channel is created with the
70
+ * configured importance only if it does not exist yet (Android fixes a channel's importance at creation, so this
71
+ * first creation must use the configured priority); an existing channel is left unchanged.
72
+ * - Without [spec] (a restart with a null intent), the default channel ([NotificationConfig.DEFAULT_CHANNEL_ID],
73
+ * created only if it does not exist), the app label as title, `lt_notification_text` as text and the plugin icon:
74
+ * the same content as [build] of `NotificationConfig()`.
75
+ *
76
+ * If [spec] cannot be built, the defaults are used. The service replaces this notification with the configured
77
+ * one (same id) once the config is loaded.
78
+ */
79
+ fun buildInitial(spec: NotificationConfig? = null): Initial {
80
+ if (spec != null) {
81
+ try {
82
+ ensureChannelExists(spec)
83
+ return Initial(spec, compose(spec))
84
+ } catch (e: Exception) {
85
+ Logger.e(TAG, "invalid notification fields in the start command; using the defaults", e)
86
+ }
87
+ }
88
+ val defaults = NotificationConfig()
89
+ ensureChannelExists(defaults)
90
+ return Initial(defaults, compose(defaults, R.drawable.lt_ic_notification))
91
+ }
92
+
93
+ /** A notification from [buildInitial] and the config whose content it shows. */
94
+ data class Initial(val config: NotificationConfig, val notification: Notification)
95
+
96
+ /** Creates the channel of [config] if it does not exist; an existing channel is not changed. No-op below API 26. */
97
+ private fun ensureChannelExists(config: NotificationConfig) {
98
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
99
+ if (NotificationManagerCompat.from(context).getNotificationChannelCompat(channelId(config)) == null) {
100
+ ensureChannel(config)
101
+ }
102
+ }
103
+
104
+ /** The notification for [config]; its channel must exist. [smallIcon] skips the icon lookup when given. */
105
+ private fun compose(config: NotificationConfig, @DrawableRes smallIcon: Int? = null): Notification {
106
+ val builder = NotificationCompat.Builder(context, channelId(config))
107
+ .setContentTitle(title(config))
108
+ .setContentText(text(config))
109
+ .setSmallIcon(smallIcon ?: resolveSmallIcon(config.smallIcon))
110
+ .setPriority(compatPriorityFor(config.priority))
111
+ .setCategory(NotificationCompat.CATEGORY_SERVICE)
112
+ .setOngoing(true)
113
+ .setSilent(true)
114
+ .setOnlyAlertOnce(true)
115
+ .setShowWhen(false)
116
+ .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
117
+ loadLargeIcon(config.largeIcon)?.let { builder.setLargeIcon(it) }
118
+ parseColor(config.color)?.let { builder.setColor(it) }
119
+ contentIntent()?.let { builder.setContentIntent(it) }
120
+ config.actions.take(Constants.MAX_NOTIFICATION_ACTIONS).forEachIndexed { index, action ->
121
+ builder.addAction(0, action.label, actionIntent(index, action.id))
122
+ }
123
+ return builder.build()
124
+ }
125
+
126
+ /**
127
+ * The resume notification (`notification.resume`): its own channel at default importance (the tracking channel is
128
+ * often `min`, which would hide it), the tracking notification's small icon and color, dismissed by a tap or after
129
+ * [timeoutMs]. The tap starts [LocationTrackingService] directly with [LocationTrackingService.ACTION_RESUME]:
130
+ * a service started from a notification gets while-in-use location access on Android 14+.
131
+ */
132
+ fun buildResume(config: NotificationConfig, timeoutMs: Long?): Notification {
133
+ val resume = config.resume
134
+ val channel = NotificationChannelCompat.Builder(Constants.RESUME_CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_DEFAULT)
135
+ .setName(resumeChannelName(resume))
136
+ .setShowBadge(false)
137
+ .build()
138
+ NotificationManagerCompat.from(context).createNotificationChannel(channel)
139
+ val builder = NotificationCompat.Builder(context, Constants.RESUME_CHANNEL_ID)
140
+ .setContentTitle(resume.title?.takeIf { it.isNotBlank() } ?: appLabel())
141
+ .setContentText(resumeText(resume))
142
+ .setSmallIcon(resolveSmallIcon(config.smallIcon))
143
+ .setPriority(NotificationCompat.PRIORITY_DEFAULT)
144
+ .setCategory(NotificationCompat.CATEGORY_REMINDER)
145
+ .setAutoCancel(true)
146
+ .setContentIntent(resumeIntent(config))
147
+ parseColor(config.color)?.let { builder.setColor(it) }
148
+ timeoutMs?.let { builder.setTimeoutAfter(it) }
149
+ return builder.build()
150
+ }
151
+
152
+ /** [build], falling back to the default config if the configured notification cannot be built. */
153
+ fun buildSafely(config: NotificationConfig): Notification = try {
154
+ build(config)
155
+ } catch (e: Exception) {
156
+ Logger.e(TAG, "invalid notification config; using the defaults", e)
157
+ build(NotificationConfig())
158
+ }
159
+
160
+ /**
161
+ * `name` / `drawable/name` / `mipmap/name` → resource id, or [R.drawable.lt_ic_notification] if the name is
162
+ * unknown or names an adaptive launcher icon (it renders as a blank shape and crashes some Android 8.0 builds).
163
+ */
164
+ @DrawableRes
165
+ fun resolveSmallIcon(spec: String?): Int {
166
+ val id = resolveImage(spec)
167
+ return when {
168
+ id == 0 -> {
169
+ if (!spec.isNullOrBlank()) Logger.w(TAG, "small icon '$spec' not found; using the plugin icon")
170
+ R.drawable.lt_ic_notification
171
+ }
172
+ id != R.drawable.lt_ic_notification && isAdaptiveIcon(id) -> {
173
+ Logger.w(TAG, "small icon '$spec' is an adaptive launcher icon; using the plugin icon")
174
+ R.drawable.lt_ic_notification
175
+ }
176
+ else -> id
177
+ }
178
+ }
179
+
180
+ private fun isAdaptiveIcon(@DrawableRes id: Int): Boolean {
181
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return false
182
+ return try {
183
+ Api26.isAdaptive(ResourcesCompat.getDrawable(context.resources, id, context.theme))
184
+ } catch (e: Exception) {
185
+ false
186
+ }
187
+ }
188
+
189
+ /** `name` / `drawable/name` / `mipmap/name` (an optional leading `@` is ignored) → resource id, or 0. */
190
+ @SuppressLint("DiscouragedApi") // names come from JS config, so they can only be resolved at runtime
191
+ fun resolveImage(spec: String?): Int {
192
+ val value = spec?.trim()?.removePrefix("@").orEmpty()
193
+ if (value.isEmpty()) return 0
194
+ val slash = value.indexOf('/')
195
+ val types = if (slash >= 0) listOf(value.substring(0, slash)) else IMAGE_TYPES
196
+ val name = if (slash >= 0) value.substring(slash + 1) else value
197
+ if (name.isEmpty() || types.any { it !in IMAGE_TYPES }) return 0
198
+ val resources = context.resources
199
+ for (type in types) {
200
+ val id = try {
201
+ resources.getIdentifier(name, type, context.packageName)
202
+ } catch (e: Exception) {
203
+ 0
204
+ }
205
+ if (id != 0) return id
206
+ }
207
+ return 0
208
+ }
209
+
210
+ private fun loadLargeIcon(spec: String?): Bitmap? {
211
+ if (spec.isNullOrBlank()) return null
212
+ val id = resolveImage(spec)
213
+ if (id == 0) {
214
+ Logger.w(TAG, "large icon '$spec' not found")
215
+ return null
216
+ }
217
+ return try {
218
+ // decodeResource returns null for vector and adaptive drawables; render those instead.
219
+ BitmapFactory.decodeResource(context.resources, id)
220
+ ?: ResourcesCompat.getDrawable(context.resources, id, context.theme)?.toBitmap()
221
+ } catch (e: Exception) {
222
+ Logger.w(TAG, "could not load large icon '$spec'", e)
223
+ null
224
+ }
225
+ }
226
+
227
+ private fun title(config: NotificationConfig): CharSequence =
228
+ config.title?.takeIf { it.isNotBlank() } ?: appLabel()
229
+
230
+ private fun text(config: NotificationConfig): CharSequence =
231
+ if (config.text.isBlank() || config.text == NotificationConfig.DEFAULT_TEXT) {
232
+ context.getString(R.string.lt_notification_text)
233
+ } else {
234
+ config.text
235
+ }
236
+
237
+ private fun resumeText(resume: ResumeNotificationConfig): CharSequence =
238
+ if (resume.text.isBlank() || resume.text == ResumeNotificationConfig.DEFAULT_TEXT) {
239
+ context.getString(R.string.lt_resume_notification_text)
240
+ } else {
241
+ resume.text
242
+ }
243
+
244
+ private fun resumeChannelName(resume: ResumeNotificationConfig): CharSequence =
245
+ if (resume.channelName.isBlank() || resume.channelName == ResumeNotificationConfig.DEFAULT_CHANNEL_NAME) {
246
+ context.getString(R.string.lt_resume_channel_name)
247
+ } else {
248
+ resume.channelName
249
+ }
250
+
251
+ private fun channelId(config: NotificationConfig): String =
252
+ config.channelId.ifBlank { NotificationConfig.DEFAULT_CHANNEL_ID }
253
+
254
+ private fun channelName(config: NotificationConfig): CharSequence =
255
+ if (config.channelName.isBlank() || config.channelName == NotificationConfig.DEFAULT_CHANNEL_NAME) {
256
+ context.getString(R.string.lt_notification_channel_name)
257
+ } else {
258
+ config.channelName
259
+ }
260
+
261
+ private fun appLabel(): CharSequence = try {
262
+ context.applicationInfo.loadLabel(context.packageManager)
263
+ } catch (e: Exception) {
264
+ context.packageName
265
+ }
266
+
267
+ /** Opens the app like the launcher does (brings an existing task to the front). */
268
+ private fun contentIntent(): PendingIntent? {
269
+ val launch = context.packageManager.getLaunchIntentForPackage(context.packageName) ?: return null
270
+ // Without the package the intent matches the launcher's, so an existing task is resumed, not duplicated.
271
+ launch.setPackage(null)
272
+ launch.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED)
273
+ return PendingIntent.getActivity(context, Constants.RC_NOTIFICATION_CONTENT, launch, Constants.piImmutable())
274
+ }
275
+
276
+ /** Starts the service with [LocationTrackingService.ACTION_RESUME]; carries [config] for its first notification. */
277
+ private fun resumeIntent(config: NotificationConfig): PendingIntent {
278
+ val intent = Intent(context, LocationTrackingService::class.java).setAction(LocationTrackingService.ACTION_RESUME)
279
+ ServiceCommands.putNotification(intent, config)
280
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
281
+ PendingIntent.getForegroundService(context, Constants.RC_RESUME_NOTIFICATION, intent, Constants.piImmutable())
282
+ } else {
283
+ PendingIntent.getService(context, Constants.RC_RESUME_NOTIFICATION, intent, Constants.piImmutable())
284
+ }
285
+ }
286
+
287
+ private fun actionIntent(index: Int, id: String): PendingIntent {
288
+ val intent = Intent(context, NotificationActionReceiver::class.java)
289
+ .setAction(Constants.ACTION_NOTIFICATION_ACTION)
290
+ .putExtra(Constants.EXTRA_ACTION_ID, id)
291
+ return PendingIntent.getBroadcast(
292
+ context,
293
+ Constants.RC_NOTIFICATION_ACTION_BASE + index,
294
+ intent,
295
+ Constants.piImmutable(),
296
+ )
297
+ }
298
+
299
+ /** Keeps the API 26 class reference out of code that is verified on older devices. */
300
+ @RequiresApi(Build.VERSION_CODES.O)
301
+ internal object Api26 {
302
+ fun isAdaptive(drawable: Drawable?): Boolean = drawable is AdaptiveIconDrawable
303
+ }
304
+
305
+ companion object {
306
+ private const val TAG = "LT.Notification"
307
+ private val IMAGE_TYPES = listOf("drawable", "mipmap")
308
+
309
+ /** Channel importance for [priority]; applied only when the channel is created. */
310
+ fun importanceFor(priority: NotificationPriority): Int = when (priority) {
311
+ NotificationPriority.MIN -> NotificationManagerCompat.IMPORTANCE_MIN
312
+ NotificationPriority.LOW -> NotificationManagerCompat.IMPORTANCE_LOW
313
+ NotificationPriority.DEFAULT -> NotificationManagerCompat.IMPORTANCE_DEFAULT
314
+ NotificationPriority.HIGH, NotificationPriority.MAX -> NotificationManagerCompat.IMPORTANCE_HIGH
315
+ }
316
+
317
+ /** Notification priority for [priority] (effective below API 26). */
318
+ fun compatPriorityFor(priority: NotificationPriority): Int = when (priority) {
319
+ NotificationPriority.MIN -> NotificationCompat.PRIORITY_MIN
320
+ NotificationPriority.LOW -> NotificationCompat.PRIORITY_LOW
321
+ NotificationPriority.DEFAULT -> NotificationCompat.PRIORITY_DEFAULT
322
+ NotificationPriority.HIGH -> NotificationCompat.PRIORITY_HIGH
323
+ NotificationPriority.MAX -> NotificationCompat.PRIORITY_MAX
324
+ }
325
+
326
+ /** `#RRGGBB` / `#AARRGGBB` / color name → color int; null (logged) if blank or invalid. */
327
+ @ColorInt
328
+ fun parseColor(value: String?): Int? {
329
+ if (value.isNullOrBlank()) return null
330
+ return try {
331
+ Color.parseColor(value.trim())
332
+ } catch (e: IllegalArgumentException) {
333
+ Logger.w(TAG, "invalid notification color '$value'; ignored")
334
+ null
335
+ }
336
+ }
337
+ }
338
+ }
@@ -0,0 +1,204 @@
1
+ package com.brickssoft.locationtracking.service
2
+
3
+ import android.content.Intent
4
+ import android.os.Process
5
+ import androidx.annotation.VisibleForTesting
6
+ import com.brickssoft.locationtracking.config.NotificationConfig
7
+ import com.brickssoft.locationtracking.config.NotificationPriority
8
+ import kotlin.random.Random
9
+
10
+ /**
11
+ * The start/stop state machine that [DefaultServiceController] (the sender) and [LocationTrackingService] (the
12
+ * receiver) share inside one process. It exists because of one Android rule: after `startForegroundService()`, the
13
+ * service must call `startForeground()` before it stops. A `stopService()` that arrives before that makes the system
14
+ * crash the app with `ForegroundServiceDidNotStartInTimeException` ("Context.startForegroundService() did not then call
15
+ * Service.startForeground()").
16
+ *
17
+ * **Commands.** Every command the controller sends carries a sequence number ([EXTRA_SEQ], increasing within the
18
+ * process) and the process token ([EXTRA_ORIGIN] = [processToken]).
19
+ * - A **start** is sent with `startForegroundService()` and is marked [EXTRA_FOREGROUND_REQUIRED]. It also carries the
20
+ * configured notification's channel, priority, title, text, small icon and color as plain string extras
21
+ * ([putNotification]), so the service's first `startForeground()` uses the configured channel and content without
22
+ * loading the config (see [ServiceCommand.notification]).
23
+ * - A **stop** is sent with `startService()` (action [LocationTrackingService.ACTION_STOP]). Android delivers commands
24
+ * to `onStartCommand` in the order they were sent, so a stop is handled after every start sent before it, and each of
25
+ * those starts has entered the foreground by then.
26
+ *
27
+ * **States.** A start is *pending* from the moment it was sent until `onStartCommand` has called `startForeground()`
28
+ * for it ([startHandled], called on success and on failure). The controller records:
29
+ * - `lastStart`: the sequence number of the last start that Android accepted;
30
+ * - `lastStop`: the sequence number of the last stop request (recorded before the stop command is sent, and also when
31
+ * Android refuses the stop command);
32
+ * - `handledStart`: the sequence number of the last start that `onStartCommand` has handled.
33
+ *
34
+ * A start is pending while `lastStart > handledStart`. The latest request is a stop while `lastStop > lastStart`.
35
+ *
36
+ * **Transitions.**
37
+ * - `start()` while a start is pending and no stop followed it: nothing is sent, `start()` returns true (the pending
38
+ * start will bring the service into the foreground). Otherwise a new start is sent.
39
+ * - `stop()`: records `lastStop`, then sends the stop command. `Context.stopService()` is used only when Android
40
+ * refuses the stop command **and** no start is pending; while a start is pending the service stops itself after it
41
+ * has entered the foreground (see below).
42
+ * - `onStartCommand(start s)`: calls `startForeground()` first, then [startHandled]. If [isStoppedAfter] (a stop was
43
+ * requested after `s` and no start followed that stop), the service calls `stopSelf(startId)`. If the stop command is
44
+ * still queued, Android ignores this `stopSelf` (a newer start id exists) and the stop command stops the service.
45
+ * - `onStartCommand(stop t)`: if [isStartedAfter] (a start was sent after `t`), the stop is ignored. Otherwise the service
46
+ * calls `stopSelf(startId)`, which Android ignores when a newer command was already sent.
47
+ *
48
+ * Result: start → stop → start in one main-thread turn ends with the service running; start → stop ends with the
49
+ * service stopped after it entered the foreground once; no path calls `stopService()` while a start is pending.
50
+ *
51
+ * **Other processes.** A command whose [EXTRA_ORIGIN] differs from [processToken] was sent by an earlier process that
52
+ * died before `onStartCommand` ran (Android keeps undelivered commands and delivers them to the restarted service).
53
+ * Its sequence number means nothing here; the service decides from the persisted `enabled` flag instead.
54
+ *
55
+ * All state is guarded by one lock; no method blocks or performs I/O.
56
+ */
57
+ internal object ServiceCommands {
58
+ /** Long: the command's sequence number in the sending process. */
59
+ const val EXTRA_SEQ = "com.brickssoft.locationtracking.service.EXTRA_SEQ"
60
+
61
+ /** String: the [processToken] of the process that sent the command. */
62
+ const val EXTRA_ORIGIN = "com.brickssoft.locationtracking.service.EXTRA_ORIGIN"
63
+
64
+ /** Boolean: the command was sent with `startForegroundService()`, so `onStartCommand` must call `startForeground()`. */
65
+ const val EXTRA_FOREGROUND_REQUIRED = "com.brickssoft.locationtracking.service.EXTRA_FOREGROUND_REQUIRED"
66
+
67
+ // The notification fields of a start command (see putNotification).
68
+ const val EXTRA_CHANNEL_ID = "com.brickssoft.locationtracking.service.EXTRA_CHANNEL_ID"
69
+ const val EXTRA_CHANNEL_NAME = "com.brickssoft.locationtracking.service.EXTRA_CHANNEL_NAME"
70
+ const val EXTRA_PRIORITY = "com.brickssoft.locationtracking.service.EXTRA_PRIORITY"
71
+ const val EXTRA_TITLE = "com.brickssoft.locationtracking.service.EXTRA_TITLE"
72
+ const val EXTRA_TEXT = "com.brickssoft.locationtracking.service.EXTRA_TEXT"
73
+ const val EXTRA_SMALL_ICON = "com.brickssoft.locationtracking.service.EXTRA_SMALL_ICON"
74
+ const val EXTRA_COLOR = "com.brickssoft.locationtracking.service.EXTRA_COLOR"
75
+
76
+ /** Identifies this process. It differs between processes (pid plus a random number drawn at process start). */
77
+ val processToken: String = "${Process.myPid()}-${Random.nextLong().toULong().toString(16)}"
78
+
79
+ private val lock = Any()
80
+ private var lastSeq = 0L
81
+ private var lastStart = 0L
82
+ private var lastStop = 0L
83
+ private var handledStart = 0L
84
+
85
+ /** The next sequence number. */
86
+ fun nextSeq(): Long = synchronized(lock) { ++lastSeq }
87
+
88
+ /** Android accepted start [seq]. */
89
+ fun startSent(seq: Long) = synchronized(lock) { if (seq > lastStart) lastStart = seq }
90
+
91
+ /** Stop [seq] was requested (whether or not Android accepts the stop command). */
92
+ fun stopRequested(seq: Long) = synchronized(lock) { if (seq > lastStop) lastStop = seq }
93
+
94
+ /** `onStartCommand` called `startForeground()` for start [seq] (successfully or not). */
95
+ fun startHandled(seq: Long) = synchronized(lock) { if (seq > handledStart) handledStart = seq }
96
+
97
+ /** True while a start from this process has not reached `startForeground()` yet. */
98
+ val isStartPending: Boolean get() = synchronized(lock) { lastStart > handledStart }
99
+
100
+ /** True while a start is pending and it is the latest request (no stop was requested after it). */
101
+ val isStartPendingAndLatest: Boolean
102
+ get() = synchronized(lock) { lastStart > handledStart && lastStart > lastStop }
103
+
104
+ /** True if a stop was requested after start [seq] and no start was sent after that stop. */
105
+ fun isStoppedAfter(seq: Long): Boolean = synchronized(lock) { lastStop > seq && lastStop > lastStart }
106
+
107
+ /** True if a start was sent after stop [seq]. */
108
+ fun isStartedAfter(seq: Long): Boolean = synchronized(lock) { lastStart > seq }
109
+
110
+ /** Adds the sequence number, origin and foreground marker to a command intent. */
111
+ fun stamp(intent: Intent, seq: Long, foregroundRequired: Boolean): Intent = intent
112
+ .putExtra(EXTRA_SEQ, seq)
113
+ .putExtra(EXTRA_ORIGIN, processToken)
114
+ .putExtra(EXTRA_FOREGROUND_REQUIRED, foregroundRequired)
115
+
116
+ /**
117
+ * Copies the fields of [config] that the first notification needs (channel id, channel name, priority, title, text,
118
+ * small icon, color) into [intent]. Action buttons and the large icon are not copied; the configured notification
119
+ * that replaces the first one has them.
120
+ */
121
+ fun putNotification(intent: Intent, config: NotificationConfig): Intent = intent
122
+ .putExtra(EXTRA_CHANNEL_ID, config.channelId)
123
+ .putExtra(EXTRA_CHANNEL_NAME, config.channelName)
124
+ .putExtra(EXTRA_PRIORITY, config.priority.wire)
125
+ .putExtra(EXTRA_TITLE, config.title)
126
+ .putExtra(EXTRA_TEXT, config.text)
127
+ .putExtra(EXTRA_SMALL_ICON, config.smallIcon)
128
+ .putExtra(EXTRA_COLOR, config.color)
129
+
130
+ /** The notification fields written by [putNotification], or null if [intent] has none. Never throws. */
131
+ fun notificationOf(intent: Intent): NotificationConfig? = try {
132
+ intent.getStringExtra(EXTRA_CHANNEL_ID)?.let { channelId ->
133
+ val defaults = NotificationConfig()
134
+ NotificationConfig(
135
+ title = intent.getStringExtra(EXTRA_TITLE),
136
+ text = intent.getStringExtra(EXTRA_TEXT) ?: defaults.text,
137
+ smallIcon = intent.getStringExtra(EXTRA_SMALL_ICON) ?: defaults.smallIcon,
138
+ color = intent.getStringExtra(EXTRA_COLOR),
139
+ priority = NotificationPriority.fromWire(intent.getStringExtra(EXTRA_PRIORITY)) ?: defaults.priority,
140
+ channelId = channelId,
141
+ channelName = intent.getStringExtra(EXTRA_CHANNEL_NAME) ?: defaults.channelName,
142
+ )
143
+ }
144
+ } catch (e: Exception) {
145
+ null
146
+ }
147
+
148
+ /** Test hook: forgets every command. */
149
+ @VisibleForTesting
150
+ fun reset() = synchronized(lock) {
151
+ lastSeq = 0L
152
+ lastStart = 0L
153
+ lastStop = 0L
154
+ handledStart = 0L
155
+ }
156
+ }
157
+
158
+ /**
159
+ * One `onStartCommand` intent, parsed without touching any component.
160
+ *
161
+ * @property kind what the command asks for.
162
+ * @property seq the sender's sequence number, or 0 if the intent has none.
163
+ * @property fromThisProcess true if [ServiceCommands.processToken] sent it; false for a command of an earlier process.
164
+ * @property foregroundRequired true if the sender used `startForegroundService()`.
165
+ * @property notification the notification fields the sender copied from its config ([ServiceCommands.putNotification]),
166
+ * or null (null intent, stop command, or an intent without them).
167
+ */
168
+ internal data class ServiceCommand(
169
+ val kind: Kind,
170
+ val seq: Long,
171
+ val fromThisProcess: Boolean,
172
+ val foregroundRequired: Boolean,
173
+ val notification: NotificationConfig? = null,
174
+ ) {
175
+ enum class Kind {
176
+ /** Null intent: the system restarted the service after its process died (`START_STICKY`). */
177
+ RESTART,
178
+ START,
179
+ STOP,
180
+
181
+ /** The resume notification was tapped ([LocationTrackingService.ACTION_RESUME]). */
182
+ RESUME,
183
+ }
184
+
185
+ companion object {
186
+ fun parse(intent: Intent?): ServiceCommand {
187
+ if (intent == null) return ServiceCommand(Kind.RESTART, 0L, fromThisProcess = false, foregroundRequired = false)
188
+ val kind = when (intent.action) {
189
+ LocationTrackingService.ACTION_STOP -> Kind.STOP
190
+ LocationTrackingService.ACTION_RESUME -> Kind.RESUME
191
+ else -> Kind.START
192
+ }
193
+ val origin = intent.getStringExtra(ServiceCommands.EXTRA_ORIGIN)
194
+ return ServiceCommand(
195
+ kind = kind,
196
+ seq = intent.getLongExtra(ServiceCommands.EXTRA_SEQ, 0L),
197
+ fromThisProcess = origin == ServiceCommands.processToken,
198
+ // A start without the marker still came through startForegroundService(): the controller sends no other.
199
+ foregroundRequired = intent.getBooleanExtra(ServiceCommands.EXTRA_FOREGROUND_REQUIRED, kind != Kind.STOP),
200
+ notification = if (kind != Kind.STOP) ServiceCommands.notificationOf(intent) else null,
201
+ )
202
+ }
203
+ }
204
+ }
@@ -0,0 +1,22 @@
1
+ package com.brickssoft.locationtracking.service
2
+
3
+ /** Starts and stops the location foreground service. */
4
+ interface ServiceController {
5
+ val isRunning: Boolean
6
+
7
+ /** Returns false if the OS refused to start the service (logged). */
8
+ fun start(): Boolean
9
+
10
+ fun stop()
11
+
12
+ /** Rebuilds the notification from the current config. */
13
+ fun refreshNotification()
14
+
15
+ /**
16
+ * Posts the resume notification (`notification.resume`), dismissed at [deadline] (epoch ms) if not null. Only logs
17
+ * when notifications are not allowed (`POST_NOTIFICATIONS`).
18
+ */
19
+ fun showResumeNotification(deadline: Long?) = Unit
20
+
21
+ fun cancelResumeNotification() = Unit
22
+ }
@@ -0,0 +1,61 @@
1
+ package com.brickssoft.locationtracking.service
2
+
3
+ import android.content.Context
4
+ import androidx.annotation.VisibleForTesting
5
+ import com.brickssoft.locationtracking.config.ConfigStore
6
+ import com.brickssoft.locationtracking.core.Clock
7
+ import com.brickssoft.locationtracking.core.Components
8
+ import com.brickssoft.locationtracking.core.EventBus
9
+ import com.brickssoft.locationtracking.core.Logger
10
+ import com.brickssoft.locationtracking.engine.TrackingEngine
11
+ import kotlinx.coroutines.CoroutineScope
12
+ import kotlinx.coroutines.Job
13
+ import kotlinx.coroutines.launch
14
+ import kotlin.coroutines.cancellation.CancellationException
15
+
16
+ /**
17
+ * The collaborators that [LocationTrackingService], [BootReceiver] and [NotificationActionReceiver] take from
18
+ * [Components]. [engine] is lazy so that components the caller does not need are never constructed.
19
+ *
20
+ * Tests set [override] to inject fakes instead of the real components.
21
+ *
22
+ * [from] loads [Components] (SharedPreferences, logger, native listeners) on its first call in a process, so the service
23
+ * calls it only off the main thread and only after `startForeground`.
24
+ */
25
+ internal class ServiceDeps(
26
+ val configStore: ConfigStore,
27
+ val events: EventBus,
28
+ val scope: CoroutineScope,
29
+ val engine: Lazy<TrackingEngine>,
30
+ val clock: Clock,
31
+ ) {
32
+ /** Launches [block] on the engine in [scope]; failures are logged, never thrown. */
33
+ fun launchEngine(tag: String, what: String, block: suspend TrackingEngine.() -> Unit): Job = scope.launch {
34
+ try {
35
+ engine.value.block()
36
+ } catch (e: CancellationException) {
37
+ throw e
38
+ } catch (e: Exception) {
39
+ Logger.e(tag, "$what failed", e)
40
+ }
41
+ }
42
+
43
+ companion object {
44
+ /** Test hook: when set, [from] returns its result instead of reading [Components]. */
45
+ @VisibleForTesting
46
+ @Volatile
47
+ var override: ((Context) -> ServiceDeps)? = null
48
+
49
+ fun from(context: Context): ServiceDeps {
50
+ override?.let { return it(context) }
51
+ val components = Components.get(context)
52
+ return ServiceDeps(
53
+ configStore = components.configStore,
54
+ events = components.events,
55
+ scope = components.scope,
56
+ engine = lazy { components.engine },
57
+ clock = components.clock,
58
+ )
59
+ }
60
+ }
61
+ }