@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.
- package/LICENSE +21 -0
- package/README.md +2759 -2
- package/android/build.gradle +107 -0
- package/android/consumer-rules.pro +42 -0
- package/android/src/main/AndroidManifest.xml +52 -0
- package/android/src/main/java/com/brickssoft/locationtracking/LocationTrackingPlugin.kt +431 -0
- package/android/src/main/java/com/brickssoft/locationtracking/api/LocationTrackingListener.kt +52 -0
- package/android/src/main/java/com/brickssoft/locationtracking/api/LocationTrackingNative.kt +267 -0
- package/android/src/main/java/com/brickssoft/locationtracking/api/NativeListeners.kt +270 -0
- package/android/src/main/java/com/brickssoft/locationtracking/api/NativeThread.kt +57 -0
- package/android/src/main/java/com/brickssoft/locationtracking/bridge/BridgeServices.kt +64 -0
- package/android/src/main/java/com/brickssoft/locationtracking/bridge/CallResponder.kt +41 -0
- package/android/src/main/java/com/brickssoft/locationtracking/bridge/OptionParsers.kt +281 -0
- package/android/src/main/java/com/brickssoft/locationtracking/bridge/PluginHandlers.kt +541 -0
- package/android/src/main/java/com/brickssoft/locationtracking/bridge/PluginPermissionHost.kt +76 -0
- package/android/src/main/java/com/brickssoft/locationtracking/bridge/WatchRegistry.kt +84 -0
- package/android/src/main/java/com/brickssoft/locationtracking/config/Config.kt +231 -0
- package/android/src/main/java/com/brickssoft/locationtracking/config/ConfigJson.kt +574 -0
- package/android/src/main/java/com/brickssoft/locationtracking/config/ConfigStore.kt +31 -0
- package/android/src/main/java/com/brickssoft/locationtracking/config/ConfigValidator.kt +170 -0
- package/android/src/main/java/com/brickssoft/locationtracking/config/RuntimeStateJson.kt +73 -0
- package/android/src/main/java/com/brickssoft/locationtracking/config/SharedPrefsConfigStore.kt +135 -0
- package/android/src/main/java/com/brickssoft/locationtracking/config/State.kt +45 -0
- package/android/src/main/java/com/brickssoft/locationtracking/core/AppDispatchers.kt +21 -0
- package/android/src/main/java/com/brickssoft/locationtracking/core/Clock.kt +41 -0
- package/android/src/main/java/com/brickssoft/locationtracking/core/Components.kt +217 -0
- package/android/src/main/java/com/brickssoft/locationtracking/core/Constants.kt +78 -0
- package/android/src/main/java/com/brickssoft/locationtracking/core/Errors.kt +27 -0
- package/android/src/main/java/com/brickssoft/locationtracking/core/EventBus.kt +39 -0
- package/android/src/main/java/com/brickssoft/locationtracking/core/ForceStopProbe.kt +63 -0
- package/android/src/main/java/com/brickssoft/locationtracking/core/Iso8601.kt +63 -0
- package/android/src/main/java/com/brickssoft/locationtracking/core/Logger.kt +68 -0
- package/android/src/main/java/com/brickssoft/locationtracking/core/RecordHooks.kt +47 -0
- package/android/src/main/java/com/brickssoft/locationtracking/core/TrackingEvent.kt +64 -0
- package/android/src/main/java/com/brickssoft/locationtracking/data/SqlSupport.kt +79 -0
- package/android/src/main/java/com/brickssoft/locationtracking/data/SqliteGeofenceStore.kt +144 -0
- package/android/src/main/java/com/brickssoft/locationtracking/data/SqliteLocationStore.kt +215 -0
- package/android/src/main/java/com/brickssoft/locationtracking/data/Stores.kt +45 -0
- package/android/src/main/java/com/brickssoft/locationtracking/data/TrackingDatabase.kt +111 -0
- package/android/src/main/java/com/brickssoft/locationtracking/device/DefaultDeviceInfoProvider.kt +104 -0
- package/android/src/main/java/com/brickssoft/locationtracking/device/DefaultDeviceMonitor.kt +335 -0
- package/android/src/main/java/com/brickssoft/locationtracking/device/DeviceMonitor.kt +36 -0
- package/android/src/main/java/com/brickssoft/locationtracking/device/DeviceStateMapping.kt +69 -0
- package/android/src/main/java/com/brickssoft/locationtracking/engine/DefaultTrackingEngine.kt +1281 -0
- package/android/src/main/java/com/brickssoft/locationtracking/engine/ElasticDistance.kt +68 -0
- package/android/src/main/java/com/brickssoft/locationtracking/engine/LocationRequests.kt +74 -0
- package/android/src/main/java/com/brickssoft/locationtracking/engine/MotionStateMachine.kt +291 -0
- package/android/src/main/java/com/brickssoft/locationtracking/engine/StationaryRegion.kt +41 -0
- package/android/src/main/java/com/brickssoft/locationtracking/engine/TrackingEngine.kt +60 -0
- package/android/src/main/java/com/brickssoft/locationtracking/engine/TrackingTimers.kt +90 -0
- package/android/src/main/java/com/brickssoft/locationtracking/geofence/DefaultGeofenceManager.kt +899 -0
- package/android/src/main/java/com/brickssoft/locationtracking/geofence/DwellTracker.kt +80 -0
- package/android/src/main/java/com/brickssoft/locationtracking/geofence/GeofenceManager.kt +33 -0
- package/android/src/main/java/com/brickssoft/locationtracking/geofence/PolygonMath.kt +194 -0
- package/android/src/main/java/com/brickssoft/locationtracking/heartbeat/DefaultHeartbeatScheduler.kt +713 -0
- package/android/src/main/java/com/brickssoft/locationtracking/heartbeat/HeartbeatAlarmReceiver.kt +69 -0
- package/android/src/main/java/com/brickssoft/locationtracking/heartbeat/HeartbeatAlarms.kt +100 -0
- package/android/src/main/java/com/brickssoft/locationtracking/heartbeat/HeartbeatScheduler.kt +25 -0
- package/android/src/main/java/com/brickssoft/locationtracking/heartbeat/HeartbeatWindow.kt +179 -0
- package/android/src/main/java/com/brickssoft/locationtracking/http/AuthorizationManager.kt +213 -0
- package/android/src/main/java/com/brickssoft/locationtracking/http/BodyBuilder.kt +73 -0
- package/android/src/main/java/com/brickssoft/locationtracking/http/HttpSupport.kt +118 -0
- package/android/src/main/java/com/brickssoft/locationtracking/http/HttpSyncer.kt +18 -0
- package/android/src/main/java/com/brickssoft/locationtracking/http/OkHttpSyncer.kt +551 -0
- package/android/src/main/java/com/brickssoft/locationtracking/http/StrictJson.kt +135 -0
- package/android/src/main/java/com/brickssoft/locationtracking/http/SyncPolicy.kt +109 -0
- package/android/src/main/java/com/brickssoft/locationtracking/http/TemplateRenderer.kt +143 -0
- package/android/src/main/java/com/brickssoft/locationtracking/logging/FileLogger.kt +461 -0
- package/android/src/main/java/com/brickssoft/locationtracking/logging/LogFileProvider.kt +9 -0
- package/android/src/main/java/com/brickssoft/locationtracking/logging/LogFormatter.kt +148 -0
- package/android/src/main/java/com/brickssoft/locationtracking/logging/LogStore.kt +30 -0
- package/android/src/main/java/com/brickssoft/locationtracking/model/DeviceModels.kt +176 -0
- package/android/src/main/java/com/brickssoft/locationtracking/model/GeofenceSpec.kt +25 -0
- package/android/src/main/java/com/brickssoft/locationtracking/model/ModelJson.kt +550 -0
- package/android/src/main/java/com/brickssoft/locationtracking/model/Record.kt +106 -0
- package/android/src/main/java/com/brickssoft/locationtracking/model/TrackedLocation.kt +53 -0
- package/android/src/main/java/com/brickssoft/locationtracking/permission/BackgroundRationaleDialog.kt +85 -0
- package/android/src/main/java/com/brickssoft/locationtracking/permission/CurrentActivityTracker.kt +72 -0
- package/android/src/main/java/com/brickssoft/locationtracking/permission/DefaultPermissionManager.kt +333 -0
- package/android/src/main/java/com/brickssoft/locationtracking/permission/PermissionManager.kt +54 -0
- package/android/src/main/java/com/brickssoft/locationtracking/position/DefaultPositionService.kt +277 -0
- package/android/src/main/java/com/brickssoft/locationtracking/position/PositionService.kt +39 -0
- package/android/src/main/java/com/brickssoft/locationtracking/position/PositionWatch.kt +115 -0
- package/android/src/main/java/com/brickssoft/locationtracking/processing/DefaultLocationProcessor.kt +93 -0
- package/android/src/main/java/com/brickssoft/locationtracking/processing/DefaultOdometer.kt +99 -0
- package/android/src/main/java/com/brickssoft/locationtracking/processing/DefaultRecordFactory.kt +166 -0
- package/android/src/main/java/com/brickssoft/locationtracking/processing/Geo.kt +56 -0
- package/android/src/main/java/com/brickssoft/locationtracking/processing/KalmanFilter.kt +72 -0
- package/android/src/main/java/com/brickssoft/locationtracking/processing/Processing.kt +48 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/Backends.kt +187 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/DefaultProviderFactory.kt +169 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/ProviderPackaging.kt +135 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/android/AndroidActivityBackend.kt +25 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/android/AndroidGeofenceBackend.kt +181 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/android/AndroidGeofenceReceiver.kt +115 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/android/AndroidLocationBackend.kt +265 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/android/AndroidProviderBundle.kt +31 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/android/LocationProviders.kt +98 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsActivityBackend.kt +80 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsActivityReceiver.kt +62 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsErrors.kt +72 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsGeofenceBackend.kt +152 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsGeofenceReceiver.kt +78 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsLocationBackend.kt +176 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsProviderBundle.kt +62 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsReceiverSupport.kt +88 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsTasks.kt +55 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsActivityBackend.kt +71 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsActivityReceiver.kt +57 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsErrors.kt +76 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsGeofenceBackend.kt +116 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsGeofenceReceiver.kt +67 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsIntents.kt +83 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsLocationBackend.kt +197 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsProviderBundle.kt +55 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsTasks.kt +44 -0
- package/android/src/main/java/com/brickssoft/locationtracking/record/RecordSink.kt +95 -0
- package/android/src/main/java/com/brickssoft/locationtracking/service/BootCountStore.kt +44 -0
- package/android/src/main/java/com/brickssoft/locationtracking/service/BootReceiver.kt +175 -0
- package/android/src/main/java/com/brickssoft/locationtracking/service/DefaultServiceController.kt +196 -0
- package/android/src/main/java/com/brickssoft/locationtracking/service/LocationTrackingService.kt +319 -0
- package/android/src/main/java/com/brickssoft/locationtracking/service/NotificationActionReceiver.kt +42 -0
- package/android/src/main/java/com/brickssoft/locationtracking/service/NotificationFactory.kt +338 -0
- package/android/src/main/java/com/brickssoft/locationtracking/service/ServiceCommands.kt +204 -0
- package/android/src/main/java/com/brickssoft/locationtracking/service/ServiceController.kt +22 -0
- package/android/src/main/java/com/brickssoft/locationtracking/service/ServiceDeps.kt +61 -0
- package/android/src/main/java/com/brickssoft/locationtracking/service/WhileInUseStartCheck.kt +115 -0
- package/android/src/main/java/com/brickssoft/locationtracking/settings/DefaultDeviceSettings.kt +79 -0
- package/android/src/main/java/com/brickssoft/locationtracking/settings/DeviceSettings.kt +21 -0
- package/android/src/main/java/com/brickssoft/locationtracking/settings/OemPowerManagers.kt +198 -0
- package/android/src/main/res/drawable/lt_ic_notification.xml +11 -0
- package/android/src/main/res/values/lt_strings.xml +11 -0
- package/android/src/main/res/xml/lt_file_paths.xml +5 -0
- package/dist/esm/definitions.d.ts +719 -0
- package/dist/esm/definitions.js +2 -0
- package/dist/esm/definitions.js.map +1 -0
- package/dist/esm/events.d.ts +50 -0
- package/dist/esm/events.js +52 -0
- package/dist/esm/events.js.map +1 -0
- package/dist/esm/index.d.ts +3 -0
- package/dist/esm/index.js +5 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/plugin.d.ts +3 -0
- package/dist/esm/plugin.js +10 -0
- package/dist/esm/plugin.js.map +1 -0
- package/dist/esm/web/config.d.ts +19 -0
- package/dist/esm/web/config.js +243 -0
- package/dist/esm/web/config.js.map +1 -0
- package/dist/esm/web/device.d.ts +9 -0
- package/dist/esm/web/device.js +90 -0
- package/dist/esm/web/device.js.map +1 -0
- package/dist/esm/web/errors.d.ts +14 -0
- package/dist/esm/web/errors.js +19 -0
- package/dist/esm/web/errors.js.map +1 -0
- package/dist/esm/web/geolocation.d.ts +26 -0
- package/dist/esm/web/geolocation.js +117 -0
- package/dist/esm/web/geolocation.js.map +1 -0
- package/dist/esm/web/location.d.ts +7 -0
- package/dist/esm/web/location.js +70 -0
- package/dist/esm/web/location.js.map +1 -0
- package/dist/esm/web.d.ts +164 -0
- package/dist/esm/web.js +328 -0
- package/dist/esm/web.js.map +1 -0
- package/dist/plugin.cjs.js +945 -0
- package/dist/plugin.cjs.js.map +1 -0
- package/dist/plugin.js +948 -0
- package/dist/plugin.js.map +1 -0
- 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
|
+
}
|