@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,76 @@
1
+ package dev.bglocation.core
2
+
3
+ import dev.bglocation.core.location.BGLAutoDistanceFilterConfig
4
+
5
+ /**
6
+ * License mode for the plugin.
7
+ */
8
+ enum class BGLLicenseMode {
9
+ FULL,
10
+ TRIAL
11
+ }
12
+
13
+ /**
14
+ * Result of license key validation.
15
+ */
16
+ sealed class BGLLicenseResult {
17
+ data class Valid(val updatesUntil: Long?) : BGLLicenseResult()
18
+ data class Invalid(val reason: String) : BGLLicenseResult()
19
+ data class UpdatesExpired(val updatesUntil: Long) : BGLLicenseResult()
20
+ }
21
+
22
+ /**
23
+ * HTTP endpoint configuration for native location uploads.
24
+ */
25
+ data class BGLHttpConfig(
26
+ val url: String,
27
+ val headers: Map<String, String> = emptyMap(),
28
+ val buffer: BGLLocationBufferConfig? = null
29
+ )
30
+
31
+ /**
32
+ * Offline buffer configuration for failed HTTP requests.
33
+ */
34
+ data class BGLLocationBufferConfig(
35
+ val maxSize: Int = 1000
36
+ )
37
+
38
+ /**
39
+ * Foreground service notification configuration.
40
+ */
41
+ data class BGLNotificationConfig(
42
+ val title: String = "Background Location",
43
+ val text: String = "Tracking your location"
44
+ )
45
+
46
+ /**
47
+ * Configuration for the background location plugin.
48
+ */
49
+ data class BGLLocationConfig(
50
+ val distanceFilter: Float = 15f,
51
+ val distanceFilterMode: String = "fixed",
52
+ val autoDistanceFilter: BGLAutoDistanceFilterConfig? = null,
53
+ val desiredAccuracy: String = "high",
54
+ val locationUpdateInterval: Long = 5000L,
55
+ val fastestLocationUpdateInterval: Long = 2000L,
56
+ val heartbeatInterval: Int = 15,
57
+ val http: BGLHttpConfig? = null,
58
+ val notification: BGLNotificationConfig = BGLNotificationConfig(),
59
+ val debug: Boolean = false,
60
+ val debugSounds: Boolean = false
61
+ )
62
+
63
+ /**
64
+ * Normalized location data emitted to JS.
65
+ */
66
+ data class BGLLocationData(
67
+ val latitude: Double,
68
+ val longitude: Double,
69
+ val accuracy: Float,
70
+ val speed: Float,
71
+ val heading: Float,
72
+ val altitude: Double,
73
+ val timestamp: Long,
74
+ val isMoving: Boolean,
75
+ val isMock: Boolean = false
76
+ )
@@ -0,0 +1,127 @@
1
+ package dev.bglocation.core.battery
2
+
3
+ import android.content.Context
4
+ import android.content.Intent
5
+ import android.net.Uri
6
+ import android.os.Build
7
+ import android.os.PowerManager
8
+ import android.provider.Settings
9
+ import android.util.Log
10
+
11
+ /**
12
+ * Helper for detecting and handling OEM battery optimization that may kill background tracking.
13
+ *
14
+ * Many Android OEMs (Xiaomi, Huawei, Samsung, OnePlus, Oppo, Vivo) implement aggressive
15
+ * battery optimization beyond stock Android Doze mode. This helper:
16
+ * 1. Checks if the app is exempt from battery optimization
17
+ * 2. Detects the device manufacturer to provide OEM-specific guidance
18
+ * 3. Provides dontkillmyapp.com URLs with per-OEM instructions
19
+ */
20
+ class BGLBatteryHelper(
21
+ private val context: Context,
22
+ private val manufacturer: String = Build.MANUFACTURER.lowercase()
23
+ ) {
24
+
25
+ companion object {
26
+ private const val TAG = "BGLocation"
27
+ private const val BASE_URL = "https://dontkillmyapp.com"
28
+
29
+ /**
30
+ * Mapping of manufacturer names (lowercase) to dontkillmyapp.com slugs.
31
+ * Only OEMs with known aggressive battery management are listed.
32
+ */
33
+ private val OEM_SLUGS = mapOf(
34
+ "xiaomi" to "xiaomi",
35
+ "huawei" to "huawei",
36
+ "samsung" to "samsung",
37
+ "oneplus" to "oneplus",
38
+ "oppo" to "oppo",
39
+ "vivo" to "vivo",
40
+ "realme" to "realme",
41
+ "meizu" to "meizu",
42
+ "asus" to "asus",
43
+ "lenovo" to "lenovo",
44
+ "nokia" to "nokia",
45
+ "sony" to "sony",
46
+ "wiko" to "wiko"
47
+ )
48
+ }
49
+
50
+ /**
51
+ * Returns the device manufacturer in lowercase.
52
+ */
53
+ fun getManufacturer(): String = manufacturer
54
+
55
+ /**
56
+ * Check if the app is currently exempt from battery optimization.
57
+ * When true, Android Doze and App Standby will not restrict the app's background execution.
58
+ */
59
+ fun isIgnoringBatteryOptimizations(): Boolean {
60
+ val pm = context.getSystemService(Context.POWER_SERVICE) as? PowerManager
61
+ ?: return false
62
+ return pm.isIgnoringBatteryOptimizations(context.packageName)
63
+ }
64
+
65
+ /**
66
+ * Get the dontkillmyapp.com URL for the current device manufacturer.
67
+ * Returns the generic page if the manufacturer is not in the known OEM list.
68
+ */
69
+ fun getHelpUrl(): String {
70
+ val manufacturer = getManufacturer()
71
+ val slug = OEM_SLUGS[manufacturer]
72
+ return if (slug != null) "$BASE_URL/$slug" else BASE_URL
73
+ }
74
+
75
+ /**
76
+ * Whether this device's manufacturer is known to have aggressive battery management.
77
+ */
78
+ fun isAggressiveOem(): Boolean {
79
+ return OEM_SLUGS.containsKey(getManufacturer())
80
+ }
81
+
82
+ /**
83
+ * Build a BatteryWarningEvent-compatible map with current state.
84
+ */
85
+ fun checkState(): BGLBatteryState {
86
+ val isIgnoring = isIgnoringBatteryOptimizations()
87
+ val manufacturer = getManufacturer()
88
+ val helpUrl = getHelpUrl()
89
+ val isAggressive = isAggressiveOem()
90
+
91
+ val message = when {
92
+ isIgnoring -> "Battery optimization is disabled for this app. Background tracking is safe."
93
+ isAggressive -> "Battery optimization is active on $manufacturer. Background tracking may be killed. Please follow the instructions at $helpUrl to whitelist this app."
94
+ else -> "Battery optimization is active. Background tracking may be restricted. Consider disabling battery optimization for this app."
95
+ }
96
+
97
+ return BGLBatteryState(
98
+ isIgnoringOptimizations = isIgnoring,
99
+ manufacturer = manufacturer,
100
+ helpUrl = helpUrl,
101
+ message = message
102
+ )
103
+ }
104
+
105
+ /**
106
+ * Create an intent to request battery optimization exemption via system dialog.
107
+ * Returns null if the permission is already granted.
108
+ */
109
+ fun createBatteryOptimizationIntent(): Intent? {
110
+ if (isIgnoringBatteryOptimizations()) return null
111
+
112
+ return Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
113
+ data = Uri.parse("package:${context.packageName}")
114
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
115
+ }
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Represents the current battery optimization state.
121
+ */
122
+ data class BGLBatteryState(
123
+ val isIgnoringOptimizations: Boolean,
124
+ val manufacturer: String,
125
+ val helpUrl: String,
126
+ val message: String
127
+ )
@@ -0,0 +1,32 @@
1
+ package dev.bglocation.core.boot
2
+
3
+ import dev.bglocation.core.geofence.BGLGeofenceManager
4
+ import android.content.BroadcastReceiver
5
+ import android.content.Context
6
+ import android.content.Intent
7
+ import android.util.Log
8
+
9
+ /**
10
+ * Re-registers persisted geofences after device boot.
11
+ *
12
+ * Android clears all registered geofences on reboot. This receiver fires on
13
+ * BOOT_COMPLETED and re-registers them from SharedPreferences persistence.
14
+ *
15
+ * Requires `<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />`
16
+ * in AndroidManifest.xml.
17
+ */
18
+ class BGLBootCompletedReceiver : BroadcastReceiver() {
19
+
20
+ companion object {
21
+ private const val TAG = "BGLocation"
22
+ }
23
+
24
+ override fun onReceive(context: Context, intent: Intent) {
25
+ if (intent.action != Intent.ACTION_BOOT_COMPLETED) return
26
+
27
+ Log.d(TAG, "BGLBootCompletedReceiver: device booted — re-registering geofences")
28
+
29
+ val manager = BGLGeofenceManager(context)
30
+ manager.reRegisterAllGeofences()
31
+ }
32
+ }
@@ -0,0 +1,114 @@
1
+ package dev.bglocation.core.config
2
+
3
+ import dev.bglocation.core.location.BGLAutoDistanceFilterConfig
4
+ import dev.bglocation.core.BGLHttpConfig
5
+ import dev.bglocation.core.BGLLocationBufferConfig
6
+ import dev.bglocation.core.BGLLocationConfig
7
+ import dev.bglocation.core.BGLNotificationConfig
8
+ import org.json.JSONObject
9
+
10
+ /**
11
+ * Framework-agnostic parser: accepts [JSONObject] instead of Capacitor PluginCall.
12
+ */
13
+ object BGLConfigParser {
14
+
15
+ /**
16
+ * Parse HTTP config from the "http" object.
17
+ * Returns null if no valid URL is provided.
18
+ */
19
+ fun parseHttpConfig(options: JSONObject): BGLHttpConfig? {
20
+ val httpObj = options.optJSONObject("http") ?: return null
21
+ if (!httpObj.has("url")) return null
22
+
23
+ val headers = mutableMapOf<String, String>()
24
+ httpObj.optJSONObject("headers")?.let { headersObj ->
25
+ headersObj.keys().forEach { key ->
26
+ headers[key] = headersObj.getString(key)
27
+ }
28
+ }
29
+ val bufferObj = httpObj.optJSONObject("buffer")
30
+ val bufferConfig = if (bufferObj != null) {
31
+ BGLLocationBufferConfig(
32
+ maxSize = bufferObj.optInt("maxSize", 1000)
33
+ )
34
+ } else {
35
+ null
36
+ }
37
+ return BGLHttpConfig(
38
+ url = httpObj.getString("url") ?: "",
39
+ headers = headers,
40
+ buffer = bufferConfig
41
+ )
42
+ }
43
+
44
+ /**
45
+ * Parse notification config from the "notification" object.
46
+ * Returns defaults if not provided.
47
+ */
48
+ fun parseNotificationConfig(options: JSONObject): BGLNotificationConfig {
49
+ val notifObj = options.optJSONObject("notification") ?: return BGLNotificationConfig()
50
+ return BGLNotificationConfig(
51
+ title = notifObj.optString("title", "Background Location"),
52
+ text = notifObj.optString("text", "Tracking your location")
53
+ )
54
+ }
55
+
56
+ /**
57
+ * Parse distance filter mode and value.
58
+ * Returns a pair of (distanceFilterMode, distanceFilter).
59
+ */
60
+ fun parseDistanceFilter(options: JSONObject): Pair<String, Float> {
61
+ val distanceFilterRaw = options.optString("distanceFilter", null)
62
+ val isAutoMode = distanceFilterRaw == "auto"
63
+ val mode = if (isAutoMode) "auto" else "fixed"
64
+ val value = if (isAutoMode) {
65
+ BGLAutoDistanceFilterConfig.DEFAULT_MIN_DISTANCE
66
+ } else {
67
+ options.optDouble("distanceFilter", 15.0).toFloat()
68
+ }
69
+ return Pair(mode, value)
70
+ }
71
+
72
+ /**
73
+ * Parse auto distance filter config.
74
+ * Returns null if not in auto mode.
75
+ */
76
+ fun parseAutoDistanceFilterConfig(options: JSONObject, isAutoMode: Boolean): BGLAutoDistanceFilterConfig? {
77
+ if (!isAutoMode) return null
78
+ val autoObj = options.optJSONObject("autoDistanceFilter")
79
+ return if (autoObj != null) {
80
+ BGLAutoDistanceFilterConfig(
81
+ targetInterval = autoObj.optDouble("targetInterval", BGLAutoDistanceFilterConfig.DEFAULT_TARGET_INTERVAL.toDouble()),
82
+ minDistance = autoObj.optDouble("minDistance", BGLAutoDistanceFilterConfig.DEFAULT_MIN_DISTANCE.toDouble()).toFloat(),
83
+ maxDistance = autoObj.optDouble("maxDistance", BGLAutoDistanceFilterConfig.DEFAULT_MAX_DISTANCE.toDouble()).toFloat()
84
+ )
85
+ } else {
86
+ BGLAutoDistanceFilterConfig()
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Build the full BGLLocationConfig from a JSONObject options dictionary.
92
+ */
93
+ fun parseLocationConfig(options: JSONObject): BGLLocationConfig {
94
+ val httpConfig = parseHttpConfig(options)
95
+ val notificationConfig = parseNotificationConfig(options)
96
+ val (distanceFilterMode, distanceFilter) = parseDistanceFilter(options)
97
+ val isAutoMode = distanceFilterMode == "auto"
98
+ val autoDistanceFilterConfig = parseAutoDistanceFilterConfig(options, isAutoMode)
99
+
100
+ return BGLLocationConfig(
101
+ distanceFilter = distanceFilter,
102
+ distanceFilterMode = distanceFilterMode,
103
+ autoDistanceFilter = autoDistanceFilterConfig,
104
+ desiredAccuracy = options.optString("desiredAccuracy", "high"),
105
+ locationUpdateInterval = options.optLong("locationUpdateInterval", 5000L),
106
+ fastestLocationUpdateInterval = options.optLong("fastestLocationUpdateInterval", 2000L),
107
+ heartbeatInterval = options.optInt("heartbeatInterval", 15),
108
+ http = httpConfig,
109
+ notification = notificationConfig,
110
+ debug = options.optBoolean("debug", false),
111
+ debugSounds = options.optBoolean("debugSounds", false)
112
+ )
113
+ }
114
+ }
@@ -0,0 +1,6 @@
1
+ package dev.bglocation.core.config
2
+
3
+ /** Version constant for the BGLocationCore native library. */
4
+ object BGLVersion {
5
+ const val VERSION = "1.0.2"
6
+ }
@@ -0,0 +1,174 @@
1
+ package dev.bglocation.core.debug
2
+
3
+ import android.media.ToneGenerator
4
+ import android.media.AudioManager
5
+ import android.util.Log
6
+
7
+ /**
8
+ * Debug logger for the background location plugin.
9
+ *
10
+ * When [enabled], emits detailed logs via [Log.d] and fires [onDebug] callbacks
11
+ * with human-readable messages. Optionally plays short system sounds when
12
+ * [soundsEnabled] is true — useful for confirming background location events
13
+ * without looking at the screen.
14
+ */
15
+ class BGLDebugLogger {
16
+
17
+ companion object {
18
+ private const val TAG = "BGL_DEBUG"
19
+ }
20
+
21
+ var enabled = false
22
+ private set
23
+ var soundsEnabled = false
24
+ private set
25
+
26
+ var onDebug: ((String) -> Unit)? = null
27
+
28
+ var locationCount = 0
29
+ private set
30
+ var heartbeatCount = 0
31
+ private set
32
+ var httpSuccessCount = 0
33
+ private set
34
+ var httpErrorCount = 0
35
+ private set
36
+
37
+ private var toneGenerator: ToneGenerator? = null
38
+
39
+ fun configure(debug: Boolean, debugSounds: Boolean) {
40
+ enabled = debug
41
+ soundsEnabled = debugSounds
42
+ if (debug && debugSounds) {
43
+ toneGenerator = try {
44
+ ToneGenerator(AudioManager.STREAM_MUSIC, 100)
45
+ } catch (_: Exception) {
46
+ null
47
+ }
48
+ } else {
49
+ toneGenerator?.release()
50
+ toneGenerator = null
51
+ }
52
+ reset()
53
+ }
54
+
55
+ fun reset() {
56
+ locationCount = 0
57
+ heartbeatCount = 0
58
+ httpSuccessCount = 0
59
+ httpErrorCount = 0
60
+ }
61
+
62
+ fun logConfigure(distanceFilter: Float, heartbeat: Int, httpUrl: String?) {
63
+ if (!enabled) return
64
+ val httpInfo = httpUrl ?: "disabled"
65
+ emit("CONFIGURE distance=${distanceFilter}m heartbeat=${heartbeat}s http=$httpInfo")
66
+ }
67
+
68
+ fun logStart() {
69
+ if (!enabled) return
70
+ reset()
71
+ emit("START tracking")
72
+ playTone(ToneGenerator.TONE_PROP_BEEP)
73
+ }
74
+
75
+ fun logStop() {
76
+ if (!enabled) return
77
+ emit("STOP tracking — locations=$locationCount heartbeats=$heartbeatCount http_ok=$httpSuccessCount http_err=$httpErrorCount")
78
+ playTone(ToneGenerator.TONE_PROP_BEEP2)
79
+ }
80
+
81
+ fun logLocation(lat: Double, lng: Double, accuracy: Float, speed: Float, isMoving: Boolean) {
82
+ if (!enabled) return
83
+ locationCount++
84
+ val moving = if (isMoving) "moving" else "stationary"
85
+ emit("LOCATION #$locationCount (${"%.5f".format(lat)}, ${"%.5f".format(lng)}) acc=${"%.1f".format(accuracy)}m spd=${"%.1f".format(speed)}m/s $moving")
86
+ playTone(ToneGenerator.TONE_PROP_ACK)
87
+ }
88
+
89
+ fun logHeartbeat(hasLocation: Boolean) {
90
+ if (!enabled) return
91
+ heartbeatCount++
92
+ val status = if (hasLocation) "with location" else "no location"
93
+ emit("HEARTBEAT #$heartbeatCount $status")
94
+ playTone(ToneGenerator.TONE_SUP_CONFIRM)
95
+ }
96
+
97
+ fun logHttp(statusCode: Int, success: Boolean, error: String?) {
98
+ if (!enabled) return
99
+ if (success) {
100
+ httpSuccessCount++
101
+ emit("HTTP OK #$httpSuccessCount status=$statusCode")
102
+ } else {
103
+ httpErrorCount++
104
+ val errMsg = error ?: "unknown"
105
+ emit("HTTP ERROR #$httpErrorCount status=$statusCode error=$errMsg")
106
+ playTone(ToneGenerator.TONE_SUP_ERROR)
107
+ }
108
+ }
109
+
110
+ fun logMessage(message: String) {
111
+ if (!enabled) return
112
+ emit(message)
113
+ }
114
+
115
+ // MARK: - Geofence Logging
116
+
117
+ fun logGeofenceAdd(identifier: String) {
118
+ if (!enabled) return
119
+ emit("GEOFENCE ADD $identifier")
120
+ playTone(ToneGenerator.TONE_PROP_ACK)
121
+ }
122
+
123
+ fun logGeofenceAddBatch(count: Int) {
124
+ if (!enabled) return
125
+ emit("GEOFENCE ADD_BATCH count=$count")
126
+ playTone(ToneGenerator.TONE_PROP_ACK)
127
+ }
128
+
129
+ fun logGeofenceRemove(identifier: String) {
130
+ if (!enabled) return
131
+ emit("GEOFENCE REMOVE $identifier")
132
+ }
133
+
134
+ fun logGeofenceRemoveAll(count: Int) {
135
+ if (!enabled) return
136
+ emit("GEOFENCE REMOVE_ALL count=$count")
137
+ }
138
+
139
+ fun logGeofenceEvent(identifier: String, action: String) {
140
+ if (!enabled) return
141
+ emit("GEOFENCE ${action.uppercase()} $identifier")
142
+ playTone(ToneGenerator.TONE_SUP_CONFIRM)
143
+ }
144
+
145
+ /** Build a summary line for the dynamic notification. */
146
+ fun notificationText(lat: Double?, lng: Double?, effectiveDistanceFilter: Float? = null): String {
147
+ val coordsPart = if (lat != null && lng != null) {
148
+ "${"%.4f".format(lat)}, ${"%.4f".format(lng)}"
149
+ } else {
150
+ "waiting for GPS"
151
+ }
152
+ val distPart = if (effectiveDistanceFilter != null) " df:${effectiveDistanceFilter.toInt()}m" else ""
153
+ return "$coordsPart | L:$locationCount H:$heartbeatCount OK:$httpSuccessCount ERR:$httpErrorCount$distPart"
154
+ }
155
+
156
+ private fun emit(message: String) {
157
+ Log.d(TAG, message)
158
+ onDebug?.invoke(message)
159
+ }
160
+
161
+ private fun playTone(tone: Int) {
162
+ if (!soundsEnabled) return
163
+ try {
164
+ toneGenerator?.startTone(tone, 150)
165
+ } catch (_: Exception) {
166
+ // Ignore — sound is best-effort
167
+ }
168
+ }
169
+
170
+ fun release() {
171
+ toneGenerator?.release()
172
+ toneGenerator = null
173
+ }
174
+ }
@@ -0,0 +1,93 @@
1
+ package dev.bglocation.core.geofence
2
+
3
+ import dev.bglocation.core.debug.BGLDebugLogger
4
+ import dev.bglocation.core.location.BGLLocationHelpers
5
+ import android.content.BroadcastReceiver
6
+ import android.content.Context
7
+ import android.content.Intent
8
+ import android.util.Log
9
+ import com.google.android.gms.location.Geofence
10
+ import com.google.android.gms.location.GeofencingEvent
11
+
12
+ /**
13
+ * BroadcastReceiver for geofence transition events from [GeofencingClient].
14
+ *
15
+ * Registered in AndroidManifest.xml as a static receiver so it fires even when the app
16
+ * is not in the foreground. The receiver resolves the [BGLGeofenceManager] singleton
17
+ * from the companion object (set by [BackgroundLocationPlugin] on configure).
18
+ */
19
+ class BGLGeofenceBroadcastReceiver : BroadcastReceiver() {
20
+
21
+ companion object {
22
+ private const val TAG = "BGLocation"
23
+
24
+ /**
25
+ * Singleton reference set by [BackgroundLocationPlugin.configure].
26
+ * The receiver uses this to look up extras and emit events.
27
+ */
28
+ @Volatile
29
+ var geofenceManager: BGLGeofenceManager? = null
30
+
31
+ /**
32
+ * Debug logger reference set by [BackgroundLocationPlugin.configure].
33
+ * Used to log geofence transition events with sounds.
34
+ */
35
+ @Volatile
36
+ var debugLogger: BGLDebugLogger? = null
37
+ }
38
+
39
+ override fun onReceive(context: Context, intent: Intent) {
40
+ if (intent.action != BGLGeofenceManager.ACTION_GEOFENCE_EVENT) return
41
+
42
+ val event = GeofencingEvent.fromIntent(intent)
43
+ if (event == null) {
44
+ Log.w(TAG, "BGLGeofenceBroadcastReceiver: null GeofencingEvent")
45
+ return
46
+ }
47
+ if (event.hasError()) {
48
+ Log.e(TAG, "BGLGeofenceBroadcastReceiver error: ${event.errorCode}")
49
+ return
50
+ }
51
+
52
+ val triggeringGeofences = event.triggeringGeofences ?: return
53
+ val transitionType = event.geofenceTransition
54
+
55
+ val action = when (transitionType) {
56
+ Geofence.GEOFENCE_TRANSITION_ENTER -> "enter"
57
+ Geofence.GEOFENCE_TRANSITION_EXIT -> "exit"
58
+ Geofence.GEOFENCE_TRANSITION_DWELL -> "dwell"
59
+ else -> {
60
+ Log.w(TAG, "BGLGeofenceBroadcastReceiver: unknown transition type $transitionType")
61
+ return
62
+ }
63
+ }
64
+
65
+ val triggeringLocation = event.triggeringLocation
66
+ val locationData = if (triggeringLocation != null) {
67
+ BGLLocationHelpers.mapLocation(triggeringLocation)
68
+ } else {
69
+ null
70
+ }
71
+
72
+ val manager = geofenceManager
73
+ if (manager == null) {
74
+ Log.w(TAG, "BGLGeofenceBroadcastReceiver: geofenceManager is null — plugin not configured. Dropping ${triggeringGeofences.size} event(s).")
75
+ return
76
+ }
77
+ val now = System.currentTimeMillis()
78
+
79
+ for (geofence in triggeringGeofences) {
80
+ val config = manager.findGeofence(geofence.requestId)
81
+ val eventData = BGLGeofenceEventData(
82
+ identifier = geofence.requestId,
83
+ action = action,
84
+ location = locationData,
85
+ extras = config?.extras,
86
+ timestamp = now
87
+ )
88
+ Log.d(TAG, "Geofence $action: ${geofence.requestId}")
89
+ debugLogger?.logGeofenceEvent(geofence.requestId, action)
90
+ manager.onGeofenceEvent?.invoke(eventData)
91
+ }
92
+ }
93
+ }