@bglocation/capacitor 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/CapacitorBackgroundLocation.podspec +19 -0
  2. package/LICENSE.md +97 -0
  3. package/Package.swift +44 -0
  4. package/README.md +264 -0
  5. package/android/build.gradle +74 -0
  6. package/android/src/main/AndroidManifest.xml +37 -0
  7. package/android/src/main/kotlin/dev/bglocation/BackgroundLocationPlugin.kt +684 -0
  8. package/android/src/main/kotlin/dev/bglocation/core/Models.kt +76 -0
  9. package/android/src/main/kotlin/dev/bglocation/core/battery/BGLBatteryHelper.kt +127 -0
  10. package/android/src/main/kotlin/dev/bglocation/core/boot/BGLBootCompletedReceiver.kt +32 -0
  11. package/android/src/main/kotlin/dev/bglocation/core/config/BGLConfigParser.kt +114 -0
  12. package/android/src/main/kotlin/dev/bglocation/core/config/BGLVersion.kt +6 -0
  13. package/android/src/main/kotlin/dev/bglocation/core/debug/BGLDebugLogger.kt +174 -0
  14. package/android/src/main/kotlin/dev/bglocation/core/geofence/BGLGeofenceBroadcastReceiver.kt +93 -0
  15. package/android/src/main/kotlin/dev/bglocation/core/geofence/BGLGeofenceManager.kt +310 -0
  16. package/android/src/main/kotlin/dev/bglocation/core/http/BGLHttpSender.kt +187 -0
  17. package/android/src/main/kotlin/dev/bglocation/core/http/BGLLocationBuffer.kt +152 -0
  18. package/android/src/main/kotlin/dev/bglocation/core/license/BGLBuildConfig.kt +16 -0
  19. package/android/src/main/kotlin/dev/bglocation/core/license/BGLLicenseEnforcer.kt +137 -0
  20. package/android/src/main/kotlin/dev/bglocation/core/license/BGLLicenseValidator.kt +134 -0
  21. package/android/src/main/kotlin/dev/bglocation/core/license/BGLTrialTimer.kt +176 -0
  22. package/android/src/main/kotlin/dev/bglocation/core/location/BGLAdaptiveFilter.kt +94 -0
  23. package/android/src/main/kotlin/dev/bglocation/core/location/BGLHeartbeatTimer.kt +38 -0
  24. package/android/src/main/kotlin/dev/bglocation/core/location/BGLLocationForegroundService.kt +289 -0
  25. package/android/src/main/kotlin/dev/bglocation/core/location/BGLLocationHelpers.kt +72 -0
  26. package/android/src/main/kotlin/dev/bglocation/core/location/BGLPermissionManager.kt +99 -0
  27. package/android/src/main/kotlin/dev/bglocation/core/notification/BGLNotificationHelper.kt +77 -0
  28. package/dist/esm/definitions.d.ts +390 -0
  29. package/dist/esm/definitions.js +3 -0
  30. package/dist/esm/definitions.js.map +1 -0
  31. package/dist/esm/index.d.ts +4 -0
  32. package/dist/esm/index.js +26 -0
  33. package/dist/esm/index.js.map +1 -0
  34. package/dist/esm/web.d.ts +47 -0
  35. package/dist/esm/web.js +231 -0
  36. package/dist/esm/web.js.map +1 -0
  37. package/dist/esm/web.test.d.ts +1 -0
  38. package/dist/esm/web.test.js +940 -0
  39. package/dist/esm/web.test.js.map +1 -0
  40. package/dist/plugin.cjs.js +267 -0
  41. package/dist/plugin.cjs.js.map +1 -0
  42. package/dist/plugin.js +270 -0
  43. package/dist/plugin.js.map +1 -0
  44. package/ios/Sources/BGLocationCore/Config/BGLConfigParser.swift +88 -0
  45. package/ios/Sources/BGLocationCore/Config/BGLVersion.swift +6 -0
  46. package/ios/Sources/BGLocationCore/Debug/BGLDebugLogger.swift +201 -0
  47. package/ios/Sources/BGLocationCore/Geofence/BGLGeofenceManager.swift +538 -0
  48. package/ios/Sources/BGLocationCore/Http/BGLHttpSender.swift +227 -0
  49. package/ios/Sources/BGLocationCore/Http/BGLLocationBuffer.swift +198 -0
  50. package/ios/Sources/BGLocationCore/License/BGLBuildConfig.swift +11 -0
  51. package/ios/Sources/BGLocationCore/License/BGLLicenseEnforcer.swift +134 -0
  52. package/ios/Sources/BGLocationCore/License/BGLLicenseValidator.swift +163 -0
  53. package/ios/Sources/BGLocationCore/License/BGLTrialTimer.swift +168 -0
  54. package/ios/Sources/BGLocationCore/Location/BGLAdaptiveFilter.swift +91 -0
  55. package/ios/Sources/BGLocationCore/Location/BGLHeartbeatTimer.swift +50 -0
  56. package/ios/Sources/BGLocationCore/Location/BGLLocationData.swift +48 -0
  57. package/ios/Sources/BGLocationCore/Location/BGLLocationHelpers.swift +42 -0
  58. package/ios/Sources/BGLocationCore/Location/BGLLocationManager.swift +268 -0
  59. package/ios/Sources/BGLocationCore/Location/BGLPermissionManager.swift +33 -0
  60. package/ios/Sources/BackgroundLocationPlugin/BackgroundLocationPlugin.swift +657 -0
  61. package/package.json +75 -0
@@ -0,0 +1,289 @@
1
+ package dev.bglocation.core.location
2
+
3
+ import dev.bglocation.core.BGLLocationConfig
4
+ import dev.bglocation.core.BGLLocationData
5
+ import dev.bglocation.core.debug.BGLDebugLogger
6
+ import dev.bglocation.core.http.BGLHttpResult
7
+ import dev.bglocation.core.http.BGLHttpSender
8
+ import dev.bglocation.core.http.BGLLocationBuffer
9
+ import dev.bglocation.core.notification.BGLNotificationHelper
10
+ import android.annotation.SuppressLint
11
+ import android.app.Service
12
+ import android.content.Intent
13
+ import android.content.pm.ServiceInfo
14
+ import android.os.Binder
15
+ import android.os.Build
16
+ import android.os.IBinder
17
+ import android.util.Log
18
+ import com.google.android.gms.location.FusedLocationProviderClient
19
+ import com.google.android.gms.location.LocationCallback
20
+ import com.google.android.gms.location.LocationRequest
21
+ import com.google.android.gms.location.LocationResult
22
+ import com.google.android.gms.location.LocationServices
23
+ import com.google.android.gms.location.Priority
24
+
25
+ /**
26
+ * Foreground Service that keeps GPS running in the background.
27
+ *
28
+ * Android 8+ (API 26) requires a Foreground Service with a persistent notification
29
+ * to maintain background execution. Android 14+ also requires `foregroundServiceType="location"`.
30
+ *
31
+ * Dependencies are exposed as `internal var` for testability (Android Service
32
+ * does not support constructor injection).
33
+ */
34
+ class BGLLocationForegroundService : Service() {
35
+
36
+ companion object {
37
+ private const val TAG = "BGLocation"
38
+ }
39
+
40
+ inner class LocalBinder : Binder() {
41
+ fun getService(): BGLLocationForegroundService = this@BGLLocationForegroundService
42
+ }
43
+
44
+ private val binder = LocalBinder()
45
+
46
+ // --- Injected dependencies (var for bridge/testing access) ---
47
+ internal var fusedClient: FusedLocationProviderClient? = null
48
+ internal var httpSender: BGLHttpSender? = null
49
+ internal var notificationHelper: BGLNotificationHelper? = null
50
+ internal var heartbeatTimer: BGLHeartbeatTimer? = null
51
+ internal var locationBuffer: BGLLocationBuffer? = null
52
+ var debug: BGLDebugLogger = BGLDebugLogger()
53
+
54
+ private var config = BGLLocationConfig()
55
+ @Volatile
56
+ private var lastLocation: BGLLocationData? = null
57
+ var adaptiveFilter: BGLAdaptiveFilter? = null
58
+
59
+ var isTracking = false
60
+ private set
61
+
62
+ // Callbacks — set by BackgroundLocationPlugin
63
+ var onLocation: ((BGLLocationData) -> Unit)? = null
64
+ var onProviderChange: ((Boolean) -> Unit)? = null
65
+ var onHeartbeat: ((BGLLocationData?) -> Unit)? = null
66
+ var onHttp: ((BGLHttpResult) -> Unit)? = null
67
+ var onMockLocation: ((BGLLocationData) -> Unit)? = null
68
+ @Volatile
69
+ private var mockLocationEmitted = false
70
+
71
+ private val locationCallback = object : LocationCallback() {
72
+ override fun onLocationResult(result: LocationResult) {
73
+ val location = result.lastLocation ?: return
74
+ val data = BGLLocationHelpers.mapLocation(location)
75
+ lastLocation = data
76
+ onLocation?.invoke(data)
77
+
78
+ // C.3: Emit mock location warning once per tracking session
79
+ if (data.isMock && !mockLocationEmitted) {
80
+ mockLocationEmitted = true
81
+ onMockLocation?.invoke(data)
82
+ }
83
+
84
+ // Adaptive distance filter: update EMA and reconfigure if needed
85
+ val filter = adaptiveFilter
86
+ if (filter != null) {
87
+ val shouldReconfigure = filter.update(data.speed)
88
+ if (shouldReconfigure) {
89
+ reconfigureLocationRequest()
90
+ debug.logMessage("Auto distance filter: ${filter.effectiveDistanceFilter.toInt()}m (speed: ${"%.1f".format(data.speed)}m/s)")
91
+ }
92
+ }
93
+
94
+ debug.logLocation(
95
+ lat = data.latitude,
96
+ lng = data.longitude,
97
+ accuracy = data.accuracy,
98
+ speed = data.speed,
99
+ isMoving = data.isMoving
100
+ )
101
+ updateDebugNotification()
102
+
103
+ httpSender?.sendLocation(data) { httpResult ->
104
+ debug.logHttp(
105
+ statusCode = httpResult.statusCode,
106
+ success = httpResult.success,
107
+ error = httpResult.error
108
+ )
109
+ updateDebugNotification()
110
+ onHttp?.invoke(httpResult)
111
+ }
112
+ }
113
+ }
114
+
115
+ override fun onCreate() {
116
+ super.onCreate()
117
+ if (fusedClient == null) {
118
+ fusedClient = LocationServices.getFusedLocationProviderClient(this)
119
+ }
120
+ if (httpSender == null) {
121
+ httpSender = BGLHttpSender()
122
+ }
123
+ httpSender?.onFlushProgress = { result ->
124
+ onHttp?.invoke(result)
125
+ }
126
+ if (heartbeatTimer == null) {
127
+ heartbeatTimer = BGLHeartbeatTimer()
128
+ }
129
+ if (notificationHelper == null) {
130
+ notificationHelper = BGLNotificationHelper(this)
131
+ }
132
+ val helper = notificationHelper!!
133
+ helper.createChannel()
134
+ val notification = helper.buildNotification(config.notification)
135
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
136
+ startForeground(BGLNotificationHelper.NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION)
137
+ } else {
138
+ startForeground(BGLNotificationHelper.NOTIFICATION_ID, notification)
139
+ }
140
+ Log.d(TAG, "BGLLocationForegroundService created")
141
+ }
142
+
143
+ override fun onBind(intent: Intent?): IBinder = binder
144
+
145
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
146
+ return START_STICKY
147
+ }
148
+
149
+ fun configure(newConfig: BGLLocationConfig) {
150
+ config = newConfig
151
+ httpSender?.configure(newConfig.http)
152
+
153
+ // Initialize adaptive filter if in auto mode
154
+ if (newConfig.distanceFilterMode == "auto") {
155
+ val autoConfig = newConfig.autoDistanceFilter ?: BGLAutoDistanceFilterConfig()
156
+ adaptiveFilter = BGLAdaptiveFilter(autoConfig)
157
+ } else {
158
+ adaptiveFilter = null
159
+ }
160
+
161
+ // Set up offline buffer if HTTP + buffer config is provided
162
+ if (newConfig.http != null && newConfig.http.buffer != null) {
163
+ if (locationBuffer == null) {
164
+ locationBuffer = BGLLocationBuffer(this)
165
+ }
166
+ locationBuffer?.configure(newConfig.http.buffer)
167
+ locationBuffer?.onOverflow = { dropped ->
168
+ debug.logMessage("BUFFER_OVERFLOW dropped=$dropped maxSize=${newConfig.http.buffer?.maxSize ?: 1000}")
169
+ }
170
+ httpSender?.setBuffer(locationBuffer)
171
+ } else {
172
+ httpSender?.setBuffer(null)
173
+ locationBuffer?.close()
174
+ locationBuffer = null
175
+ }
176
+
177
+ debug.configure(debug = newConfig.debug, debugSounds = newConfig.debugSounds)
178
+ debug.logConfigure(
179
+ distanceFilter = newConfig.distanceFilter,
180
+ heartbeat = newConfig.heartbeatInterval,
181
+ httpUrl = newConfig.http?.url
182
+ )
183
+ notificationHelper?.updateNotification(config.notification)
184
+ }
185
+
186
+ @SuppressLint("MissingPermission")
187
+ fun startTracking() {
188
+ if (isTracking) return
189
+ mockLocationEmitted = false
190
+
191
+ val initialDistanceFilter = adaptiveFilter?.effectiveDistanceFilter ?: config.distanceFilter
192
+
193
+ val request = buildLocationRequest(initialDistanceFilter)
194
+
195
+ fusedClient?.requestLocationUpdates(request, locationCallback, mainLooper)
196
+ heartbeatTimer?.start(config.heartbeatInterval) {
197
+ onHeartbeat?.invoke(lastLocation)
198
+ debug.logHeartbeat(hasLocation = lastLocation != null)
199
+ updateDebugNotification()
200
+ }
201
+ isTracking = true
202
+ debug.logStart()
203
+ val modeInfo = if (adaptiveFilter != null) "auto (initial=${initialDistanceFilter.toInt()}m)" else "${config.distanceFilter}m"
204
+ Log.d(TAG, "Location tracking started — interval=${config.locationUpdateInterval}ms, distance=$modeInfo")
205
+ }
206
+
207
+ fun stopTracking() {
208
+ if (!isTracking) return
209
+
210
+ fusedClient?.removeLocationUpdates(locationCallback)
211
+ heartbeatTimer?.stop()
212
+ adaptiveFilter?.reset()
213
+ isTracking = false
214
+ debug.logStop()
215
+ Log.d(TAG, "Location tracking stopped")
216
+ }
217
+
218
+ /**
219
+ * Reconfigure the FusedLocationProviderClient with a new distance filter.
220
+ * Called by the adaptive filter when the effective distance changes significantly.
221
+ */
222
+ @SuppressLint("MissingPermission")
223
+ private fun reconfigureLocationRequest() {
224
+ if (!isTracking) return
225
+ val filter = adaptiveFilter ?: return
226
+
227
+ fusedClient?.removeLocationUpdates(locationCallback)
228
+
229
+ val request = buildLocationRequest(filter.effectiveDistanceFilter)
230
+
231
+ fusedClient?.requestLocationUpdates(request, locationCallback, mainLooper)
232
+ Log.d(TAG, "Adaptive filter reconfigured — new distance=${filter.effectiveDistanceFilter.toInt()}m")
233
+ }
234
+
235
+ private fun buildLocationRequest(distanceFilter: Float): LocationRequest {
236
+ val priority = when (config.desiredAccuracy) {
237
+ "high" -> Priority.PRIORITY_HIGH_ACCURACY
238
+ "balanced" -> Priority.PRIORITY_BALANCED_POWER_ACCURACY
239
+ "low" -> Priority.PRIORITY_LOW_POWER
240
+ else -> {
241
+ Log.w(TAG, "Unknown desiredAccuracy '${config.desiredAccuracy}', falling back to 'high'")
242
+ Priority.PRIORITY_HIGH_ACCURACY
243
+ }
244
+ }
245
+ return LocationRequest.Builder(priority, config.locationUpdateInterval)
246
+ .setMinUpdateDistanceMeters(distanceFilter)
247
+ .setMinUpdateIntervalMillis(config.fastestLocationUpdateInterval)
248
+ .build()
249
+ }
250
+
251
+ @SuppressLint("MissingPermission")
252
+ fun getCurrentPosition(callback: (BGLLocationData?) -> Unit) {
253
+ val client = fusedClient
254
+ if (client == null) {
255
+ callback(lastLocation)
256
+ return
257
+ }
258
+ client.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, null)
259
+ .addOnSuccessListener { location ->
260
+ if (location != null) {
261
+ callback(BGLLocationHelpers.mapLocation(location))
262
+ } else {
263
+ callback(lastLocation)
264
+ }
265
+ }
266
+ .addOnFailureListener {
267
+ callback(lastLocation)
268
+ }
269
+ }
270
+
271
+ override fun onDestroy() {
272
+ stopTracking()
273
+ httpSender?.shutdown()
274
+ debug.release()
275
+ Log.d(TAG, "BGLLocationForegroundService destroyed")
276
+ super.onDestroy()
277
+ }
278
+
279
+ private fun updateDebugNotification() {
280
+ if (!debug.enabled) return
281
+
282
+ val text = debug.notificationText(
283
+ lat = lastLocation?.latitude,
284
+ lng = lastLocation?.longitude,
285
+ effectiveDistanceFilter = adaptiveFilter?.effectiveDistanceFilter
286
+ )
287
+ notificationHelper?.updateDebugNotification(config.notification, text)
288
+ }
289
+ }
@@ -0,0 +1,72 @@
1
+ package dev.bglocation.core.location
2
+
3
+ import android.location.Location
4
+ import android.os.Build
5
+ import dev.bglocation.core.BGLLocationData
6
+ import org.json.JSONObject
7
+
8
+ /**
9
+ * Pure helper functions for location data transformation.
10
+ * Centralizes field mapping to avoid duplication across plugin, service, and HTTP layer.
11
+ */
12
+ object BGLLocationHelpers {
13
+
14
+ /** Speed threshold (m/s) above which the device is considered moving. */
15
+ const val MOTION_SPEED_THRESHOLD = 0.5f
16
+
17
+ /**
18
+ * Map an Android [Location] to a normalized [BGLLocationData].
19
+ */
20
+ internal fun mapLocation(location: Location): BGLLocationData {
21
+ return BGLLocationData(
22
+ latitude = location.latitude,
23
+ longitude = location.longitude,
24
+ accuracy = location.accuracy,
25
+ speed = maxOf(location.speed, 0f),
26
+ heading = if (location.hasBearing()) location.bearing else -1f,
27
+ altitude = location.altitude,
28
+ timestamp = location.time,
29
+ isMoving = location.speed > MOTION_SPEED_THRESHOLD,
30
+ isMock = isMockLocation(location)
31
+ )
32
+ }
33
+
34
+ /**
35
+ * Detect whether a [Location] was generated by a mock/test provider.
36
+ */
37
+ @Suppress("DEPRECATION")
38
+ internal fun isMockLocation(location: Location): Boolean {
39
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
40
+ location.isMock
41
+ } else {
42
+ location.isFromMockProvider
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Convert [BGLLocationData] to a [JSONObject] for event emission.
48
+ * Single source of truth for the 9 location fields exposed to JS.
49
+ */
50
+ fun toJSONObject(location: BGLLocationData): JSONObject {
51
+ return JSONObject().apply {
52
+ put("latitude", location.latitude)
53
+ put("longitude", location.longitude)
54
+ put("accuracy", location.accuracy.toDouble())
55
+ put("speed", location.speed.toDouble())
56
+ put("heading", location.heading.toDouble())
57
+ put("altitude", location.altitude)
58
+ put("timestamp", location.timestamp)
59
+ put("isMoving", location.isMoving)
60
+ put("isMock", location.isMock)
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Build the HTTP POST body wrapping location in a `{ "location": { ... } }` envelope.
66
+ */
67
+ internal fun buildLocationBody(location: BGLLocationData): JSONObject {
68
+ return JSONObject().apply {
69
+ put("location", toJSONObject(location))
70
+ }
71
+ }
72
+ }
@@ -0,0 +1,99 @@
1
+ package dev.bglocation.core.location
2
+
3
+ import android.Manifest
4
+ import android.app.Activity
5
+ import android.content.Context
6
+ import android.content.pm.PackageManager
7
+ import android.os.Build
8
+ import android.util.Log
9
+ import androidx.core.app.ActivityCompat
10
+ import org.json.JSONObject
11
+
12
+ /**
13
+ * Handles permission checking and rationale emission logic.
14
+ */
15
+ class BGLPermissionManager {
16
+
17
+ companion object {
18
+ private const val TAG = "BGLocation"
19
+ }
20
+
21
+ /**
22
+ * Build permission status from current permission state.
23
+ */
24
+ fun buildPermissionStatus(
25
+ context: Context,
26
+ activity: Activity?,
27
+ capPermissionState: String?
28
+ ): JSONObject {
29
+ val fineGranted = ActivityCompat.checkSelfPermission(
30
+ context, Manifest.permission.ACCESS_FINE_LOCATION
31
+ ) == PackageManager.PERMISSION_GRANTED
32
+
33
+ val bgGranted = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
34
+ ActivityCompat.checkSelfPermission(
35
+ context, Manifest.permission.ACCESS_BACKGROUND_LOCATION
36
+ ) == PackageManager.PERMISSION_GRANTED
37
+ } else {
38
+ fineGranted
39
+ }
40
+
41
+ val locationState = when {
42
+ fineGranted -> "granted"
43
+ activity != null && ActivityCompat.shouldShowRequestPermissionRationale(
44
+ activity, Manifest.permission.ACCESS_FINE_LOCATION
45
+ ) -> "prompt"
46
+ else -> {
47
+ if (capPermissionState == "denied") "denied" else "prompt"
48
+ }
49
+ }
50
+
51
+ val bgState = when {
52
+ bgGranted -> "granted"
53
+ !fineGranted -> locationState
54
+ else -> "prompt"
55
+ }
56
+
57
+ return JSONObject().apply {
58
+ put("location", locationState)
59
+ put("backgroundLocation", bgState)
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Build rationale for background location permission on Android 11+.
65
+ * Returns the rationale data if it should be emitted, or null if not applicable.
66
+ */
67
+ fun buildBackgroundRationale(
68
+ context: Context,
69
+ activity: Activity?,
70
+ permissions: List<String>
71
+ ): JSONObject? {
72
+ val requestingBackground = permissions.contains("backgroundLocation")
73
+ if (!requestingBackground || Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return null
74
+
75
+ val fineGranted = ActivityCompat.checkSelfPermission(
76
+ context, Manifest.permission.ACCESS_FINE_LOCATION
77
+ ) == PackageManager.PERMISSION_GRANTED
78
+
79
+ val bgGranted = ActivityCompat.checkSelfPermission(
80
+ context, Manifest.permission.ACCESS_BACKGROUND_LOCATION
81
+ ) == PackageManager.PERMISSION_GRANTED
82
+
83
+ if (!fineGranted || bgGranted) return null
84
+
85
+ val shouldShow = activity?.let {
86
+ ActivityCompat.shouldShowRequestPermissionRationale(
87
+ it, Manifest.permission.ACCESS_BACKGROUND_LOCATION
88
+ )
89
+ } ?: false
90
+
91
+ Log.d(TAG, "Emitted onPermissionRationale before background location request (shouldShowRationale=$shouldShow)")
92
+
93
+ return JSONObject().apply {
94
+ put("permission", "backgroundLocation")
95
+ put("shouldShowRationale", shouldShow)
96
+ put("message", "Background location permission is required for continuous tracking. On Android 11+, please select \"Allow all the time\" in the app's location settings.")
97
+ }
98
+ }
99
+ }
@@ -0,0 +1,77 @@
1
+ package dev.bglocation.core.notification
2
+
3
+ import dev.bglocation.core.BGLNotificationConfig
4
+ import android.app.Notification
5
+ import android.app.NotificationChannel
6
+ import android.app.NotificationManager
7
+ import android.content.Context
8
+ import androidx.core.app.NotificationCompat
9
+
10
+ /**
11
+ * Manages the foreground service notification for background location tracking.
12
+ *
13
+ * Extracted from BGLLocationForegroundService (SRP) — handles channel creation,
14
+ * notification building, and debug info display.
15
+ */
16
+ class BGLNotificationHelper(private val context: Context) {
17
+
18
+ companion object {
19
+ const val CHANNEL_ID = "bgl_location_channel"
20
+ const val NOTIFICATION_ID = 1001
21
+ }
22
+
23
+ fun createChannel() {
24
+ val channel = NotificationChannel(
25
+ CHANNEL_ID,
26
+ "Location Tracking",
27
+ NotificationManager.IMPORTANCE_LOW
28
+ ).apply {
29
+ description = "Background location tracking for tour"
30
+ }
31
+ val manager = context.getSystemService(NotificationManager::class.java)
32
+ manager.createNotificationChannel(channel)
33
+ }
34
+
35
+ fun buildNotification(config: BGLNotificationConfig): Notification {
36
+ return baseBuilder()
37
+ .setContentTitle(config.title)
38
+ .setContentText(config.text)
39
+ .build()
40
+ }
41
+
42
+ fun updateNotification(config: BGLNotificationConfig) {
43
+ val manager = context.getSystemService(NotificationManager::class.java) ?: return
44
+ manager.notify(NOTIFICATION_ID, buildNotification(config))
45
+ }
46
+
47
+ fun updateDebugNotification(config: BGLNotificationConfig, debugText: String) {
48
+ val notification = baseBuilder()
49
+ .setContentTitle("${config.title} [DEBUG]")
50
+ .setContentText(debugText)
51
+ .build()
52
+ val manager = context.getSystemService(NotificationManager::class.java)
53
+ manager.notify(NOTIFICATION_ID, notification)
54
+ }
55
+
56
+ /**
57
+ * Resolve a valid notification small icon.
58
+ *
59
+ * Adaptive (mipmap) launcher icons cannot be used as notification small icons —
60
+ * they render as blank squares on most Android versions.
61
+ * Try a dedicated drawable first, then fall back to a system icon.
62
+ */
63
+ internal fun getNotificationIcon(): Int {
64
+ val custom = context.resources.getIdentifier(
65
+ "ic_notification", "drawable", context.packageName
66
+ )
67
+ if (custom != 0) return custom
68
+ return android.R.drawable.ic_menu_mylocation
69
+ }
70
+
71
+ private fun baseBuilder(): NotificationCompat.Builder {
72
+ return NotificationCompat.Builder(context, CHANNEL_ID)
73
+ .setSmallIcon(getNotificationIcon())
74
+ .setOngoing(true)
75
+ .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
76
+ }
77
+ }