@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,684 @@
1
+ package dev.bglocation
2
+
3
+ import android.Manifest
4
+ import android.content.ComponentName
5
+ import android.content.Context
6
+ import android.content.Intent
7
+ import android.content.ServiceConnection
8
+ import android.content.pm.PackageManager
9
+ import android.os.Handler
10
+ import android.os.IBinder
11
+ import android.os.Looper
12
+ import android.util.Log
13
+ import androidx.core.app.ActivityCompat
14
+ import com.getcapacitor.JSObject
15
+ import com.getcapacitor.Plugin
16
+ import com.getcapacitor.PluginCall
17
+ import com.getcapacitor.PluginMethod
18
+ import com.getcapacitor.annotation.CapacitorPlugin
19
+ import com.getcapacitor.annotation.Permission
20
+ import dev.bglocation.core.*
21
+ import dev.bglocation.core.battery.BGLBatteryHelper
22
+ import dev.bglocation.core.battery.BGLBatteryState
23
+ import dev.bglocation.core.config.BGLConfigParser
24
+ import dev.bglocation.core.config.BGLVersion
25
+ import dev.bglocation.core.debug.BGLDebugLogger
26
+ import dev.bglocation.core.geofence.BGLGeofenceBroadcastReceiver
27
+ import dev.bglocation.core.geofence.BGLGeofenceConfig
28
+ import dev.bglocation.core.geofence.BGLGeofenceManager
29
+ import dev.bglocation.core.http.BGLHttpResult
30
+ import dev.bglocation.core.license.BGLBuildConfig
31
+ import dev.bglocation.core.license.BGLLicenseEnforcer
32
+ import dev.bglocation.core.license.BGLLicenseValidator
33
+ import dev.bglocation.core.license.BGLTrialTimer
34
+ import dev.bglocation.core.location.BGLLocationForegroundService
35
+ import dev.bglocation.core.location.BGLLocationHelpers
36
+ import dev.bglocation.core.location.BGLPermissionManager
37
+ import org.json.JSONObject
38
+
39
+ @CapacitorPlugin(
40
+ name = "BackgroundLocation",
41
+ permissions = [
42
+ Permission(
43
+ strings = [Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION],
44
+ alias = "location"
45
+ ),
46
+ Permission(
47
+ strings = ["android.permission.ACCESS_BACKGROUND_LOCATION"],
48
+ alias = "backgroundLocation"
49
+ )
50
+ ]
51
+ )
52
+ class BackgroundLocationPlugin : Plugin() {
53
+
54
+ companion object {
55
+ private const val TAG = "BGLocation"
56
+ private const val LICENSE_PREFS_NAME = "bglocation_license"
57
+ private const val SERVICE_BIND_TIMEOUT_MS = 10_000L
58
+ }
59
+
60
+ private val permissionManager = BGLPermissionManager()
61
+
62
+ @PluginMethod
63
+ fun getVersion(call: PluginCall) {
64
+ val result = JSObject()
65
+ result.put("pluginVersion", "1.1.0")
66
+ result.put("coreVersion", BGLVersion.VERSION)
67
+ call.resolve(result)
68
+ }
69
+
70
+ @PluginMethod
71
+ override fun checkPermissions(call: PluginCall) {
72
+ val capState = getPermissionState("location")
73
+ val capStateStr = if (capState != null && capState.toString() == "denied") "denied" else null
74
+ val result = permissionManager.buildPermissionStatus(context, activity, capStateStr)
75
+ call.resolve(result.toJSObject())
76
+ }
77
+
78
+ // C.2: Override requestPermissions to emit onPermissionRationale before
79
+ // requesting background location on Android 11+ (API 30+).
80
+ // On Android 11+, the system dialog does NOT show "Allow all the time" —
81
+ // the user must navigate to Settings manually. We emit an event so the
82
+ // JS layer can display a custom rationale UI before triggering the request.
83
+ @PluginMethod
84
+ override fun requestPermissions(call: PluginCall) {
85
+ val permissions = call.getArray("permissions")?.toList<String>() ?: listOf("location")
86
+
87
+ val rationale = permissionManager.buildBackgroundRationale(context, activity, permissions)
88
+ if (rationale != null) {
89
+ notifyListeners("onPermissionRationale", rationale.toJSObject())
90
+ }
91
+
92
+ // Delegate to Capacitor's built-in permission handling
93
+ super.requestPermissions(call)
94
+ }
95
+
96
+ private var locationService: BGLLocationForegroundService? = null
97
+ private var isBound = false
98
+ private var pendingStartCall: PluginCall? = null
99
+ private val startTimeoutHandler = Handler(Looper.getMainLooper())
100
+
101
+ // Configuration stored until service starts
102
+ internal var config = BGLLocationConfig()
103
+ internal lateinit var batteryHelper: BGLBatteryHelper
104
+
105
+ // Licensing
106
+ internal lateinit var licenseEnforcer: BGLLicenseEnforcer
107
+ // Keep direct accessors for backward-compatible test access
108
+ internal val licenseMode: BGLLicenseMode get() = licenseEnforcer.licenseMode
109
+ internal val trialTimer: BGLTrialTimer? get() = licenseEnforcer.trialTimer
110
+
111
+ // Geofencing
112
+ private var geofenceManager: BGLGeofenceManager? = null
113
+ private var isConfigured = false
114
+
115
+ // Debug logger for geofence operations (lives in bridge, not service)
116
+ private val geofenceDebug = BGLDebugLogger()
117
+
118
+ override fun load() {
119
+ BGLBuildConfig.buildEpoch = BuildConfig.PLUGIN_BUILD_EPOCH
120
+ batteryHelper = BGLBatteryHelper(context)
121
+
122
+ val licensePrefs = context.getSharedPreferences(LICENSE_PREFS_NAME, Context.MODE_PRIVATE)
123
+ val licenseValidator = BGLLicenseValidator(licensePrefs)
124
+ licenseEnforcer = BGLLicenseEnforcer(licensePrefs, licenseValidator)
125
+ }
126
+
127
+ private val serviceConnection = object : ServiceConnection {
128
+ override fun onServiceConnected(name: ComponentName?, binder: IBinder?) {
129
+ startTimeoutHandler.removeCallbacksAndMessages(null)
130
+
131
+ val localBinder = binder as BGLLocationForegroundService.LocalBinder
132
+ locationService = localBinder.getService()
133
+ isBound = true
134
+
135
+ locationService?.configure(config)
136
+
137
+ // IMPORTANT: Set callbacks BEFORE startTracking() to prevent
138
+ // race conditions where events fire before handlers are registered.
139
+ locationService?.onLocation = { location ->
140
+ emitLocation(location)
141
+ }
142
+ locationService?.onProviderChange = { enabled ->
143
+ emitProviderChange(enabled)
144
+ }
145
+ locationService?.onHeartbeat = { location ->
146
+ emitHeartbeat(location)
147
+ }
148
+ locationService?.onHttp = { result ->
149
+ emitHttp(result)
150
+ }
151
+ locationService?.debug?.onDebug = { message ->
152
+ emitDebug(message)
153
+ }
154
+ locationService?.onMockLocation = { location ->
155
+ emitMockLocation(location)
156
+ }
157
+
158
+ locationService?.startTracking()
159
+
160
+ // Start trial timer if in trial mode
161
+ licenseEnforcer.startTrialIfNeeded()
162
+
163
+ // Emit battery warning if optimization is active (B.1)
164
+ checkAndEmitBatteryWarning()
165
+
166
+ pendingStartCall?.let { call ->
167
+ val result = JSObject().apply {
168
+ put("enabled", true)
169
+ put("tracking", true)
170
+ }
171
+ call.resolve(result)
172
+ pendingStartCall = null
173
+ }
174
+
175
+ Log.d(TAG, "BackgroundLocation service connected")
176
+ }
177
+
178
+ override fun onServiceDisconnected(name: ComponentName?) {
179
+ locationService = null
180
+ isBound = false
181
+ Log.d(TAG, "BackgroundLocation service disconnected")
182
+ }
183
+ }
184
+
185
+ @PluginMethod
186
+ fun configure(call: PluginCall) {
187
+ config = BGLConfigParser.parseLocationConfig(call.data)
188
+
189
+ // --- License validation ---
190
+ val licenseKey = getConfig().getString("licenseKey")
191
+ val bundleId = context.packageName
192
+ val configureResult = JSObject()
193
+
194
+ config = licenseEnforcer.validateLicense(licenseKey, bundleId, config, configureResult) {
195
+ onTrialExpired()
196
+ }
197
+
198
+ // If service is already running, update config
199
+ locationService?.configure(config)
200
+
201
+ // --- Geofencing setup ---
202
+ val manager = BGLGeofenceManager(context)
203
+ manager.onGeofenceEvent = { event ->
204
+ notifyListeners("onGeofence", event.toJSONObject().toJSObject())
205
+ Log.d(TAG, "Geofence ${event.action}: ${event.identifier}")
206
+ }
207
+ geofenceManager = manager
208
+ BGLGeofenceBroadcastReceiver.geofenceManager = manager
209
+
210
+ // Configure debug logger for geofence operations
211
+ geofenceDebug.configure(config.debug, config.debugSounds)
212
+ geofenceDebug.onDebug = { message -> emitDebug(message) }
213
+ BGLGeofenceBroadcastReceiver.debugLogger = geofenceDebug
214
+
215
+ // Re-register persisted geofences (they survive app restarts but not reboots)
216
+ manager.reRegisterAllGeofences()
217
+
218
+ isConfigured = true
219
+
220
+ Log.d(TAG, "BackgroundLocation configured — distanceFilter=${config.distanceFilter} (mode=${config.distanceFilterMode}), heartbeat=${config.heartbeatInterval}s, http=${config.http?.url ?: "disabled"}")
221
+ configureResult.put("distanceFilterMode", config.distanceFilterMode)
222
+ call.resolve(configureResult)
223
+ }
224
+
225
+ @PluginMethod
226
+ fun start(call: PluginCall) {
227
+ if (ActivityCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION)
228
+ != PackageManager.PERMISSION_GRANTED
229
+ ) {
230
+ call.reject("Location permission not granted")
231
+ return
232
+ }
233
+
234
+ // Trial mode: check cooldown before allowing start
235
+ val cooldown = licenseEnforcer.checkCooldown()
236
+ if (cooldown != null) {
237
+ call.reject(
238
+ cooldown.message,
239
+ "TRIAL_COOLDOWN",
240
+ Exception("Trial cooldown active"),
241
+ JSObject().apply {
242
+ put("remainingSeconds", cooldown.remainingSeconds)
243
+ put("message", cooldown.message)
244
+ }
245
+ )
246
+ return
247
+ }
248
+
249
+ // Reject any previous unresolved start() call
250
+ pendingStartCall?.reject("Superseded by a new start() call")
251
+ pendingStartCall = call
252
+
253
+ // Add timeout for service binding — reject if onServiceConnected not called within 10s
254
+ startTimeoutHandler.removeCallbacksAndMessages(null)
255
+ startTimeoutHandler.postDelayed({
256
+ pendingStartCall?.reject("Service connection timed out")
257
+ pendingStartCall = null
258
+ }, SERVICE_BIND_TIMEOUT_MS)
259
+
260
+ val intent = Intent(context, BGLLocationForegroundService::class.java)
261
+ context.startForegroundService(intent)
262
+ context.bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE)
263
+
264
+ Log.d(TAG, "BackgroundLocation starting service")
265
+ }
266
+
267
+ /**
268
+ * Internal stop — tears down service, unbinds, and clears references.
269
+ * Shared by stop() and onTrialExpired() to avoid duplicating cleanup logic.
270
+ */
271
+ private fun performStopTracking() {
272
+ locationService?.stopTracking()
273
+ if (isBound) {
274
+ context.unbindService(serviceConnection)
275
+ isBound = false
276
+ }
277
+ val intent = Intent(context, BGLLocationForegroundService::class.java)
278
+ context.stopService(intent)
279
+ locationService = null
280
+ }
281
+
282
+ @PluginMethod
283
+ fun stop(call: PluginCall) {
284
+ // Only stop the trial timer if no geofences are active
285
+ licenseEnforcer.stopTrialIfNoGeofences(geofenceManager?.hasGeofences == true)
286
+ performStopTracking()
287
+
288
+ val hasPermission = ActivityCompat.checkSelfPermission(
289
+ context, Manifest.permission.ACCESS_FINE_LOCATION
290
+ ) == PackageManager.PERMISSION_GRANTED
291
+
292
+ Log.d(TAG, "BackgroundLocation stopped")
293
+ val result = JSObject().apply {
294
+ put("enabled", hasPermission)
295
+ put("tracking", false)
296
+ }
297
+ call.resolve(result)
298
+ }
299
+
300
+ @PluginMethod
301
+ fun getState(call: PluginCall) {
302
+ val isTracking = locationService?.isTracking ?: false
303
+ val hasPermission = activity?.let {
304
+ ActivityCompat.checkSelfPermission(it, Manifest.permission.ACCESS_FINE_LOCATION) ==
305
+ PackageManager.PERMISSION_GRANTED
306
+ } ?: false
307
+ val result = JSObject().apply {
308
+ put("enabled", hasPermission)
309
+ put("tracking", isTracking)
310
+ }
311
+ call.resolve(result)
312
+ }
313
+
314
+ @PluginMethod
315
+ fun getCurrentPosition(call: PluginCall) {
316
+ val service = locationService
317
+ if (service == null) {
318
+ call.reject("Service not running")
319
+ return
320
+ }
321
+
322
+ service.getCurrentPosition { location ->
323
+ if (location != null) {
324
+ call.resolve(BGLLocationHelpers.toJSONObject(location).toJSObject())
325
+ } else {
326
+ call.reject("Unable to get current position — no location available")
327
+ }
328
+ }
329
+ }
330
+
331
+ // MARK: - Geofencing Methods
332
+
333
+ @PluginMethod
334
+ fun addGeofence(call: PluginCall) {
335
+ val manager = geofenceManager
336
+ if (!isConfigured || manager == null) {
337
+ call.reject("Plugin not configured. Call configure() first.", "NOT_CONFIGURED")
338
+ return
339
+ }
340
+
341
+ val cooldown = licenseEnforcer.checkCooldown()
342
+ if (cooldown != null) {
343
+ call.reject(
344
+ cooldown.message,
345
+ "TRIAL_COOLDOWN",
346
+ Exception("Trial cooldown active"),
347
+ JSObject().apply { put("remainingSeconds", cooldown.remainingSeconds) }
348
+ )
349
+ return
350
+ }
351
+
352
+ val identifier = call.getString("identifier")
353
+ if (identifier == null) {
354
+ call.reject("Missing required parameter: identifier")
355
+ return
356
+ }
357
+ val latitude = call.getDouble("latitude")
358
+ val longitude = call.getDouble("longitude")
359
+ val radius = call.getDouble("radius")
360
+
361
+ if (latitude == null || longitude == null || radius == null) {
362
+ call.reject("Missing required parameters: latitude, longitude, radius")
363
+ return
364
+ }
365
+
366
+ val extras = call.getObject("extras", null)?.let { extrasObj ->
367
+ extrasObj.keys().asSequence().associateWith { key -> extrasObj.optString(key, "") }
368
+ }
369
+
370
+ val geofence = BGLGeofenceConfig(
371
+ identifier = identifier,
372
+ latitude = latitude,
373
+ longitude = longitude,
374
+ radius = radius,
375
+ notifyOnEntry = call.getBoolean("notifyOnEntry", true) ?: true,
376
+ notifyOnExit = call.getBoolean("notifyOnExit", true) ?: true,
377
+ notifyOnDwell = call.getBoolean("notifyOnDwell", false) ?: false,
378
+ dwellDelay = call.getInt("dwellDelay", 300) ?: 300,
379
+ extras = extras
380
+ )
381
+
382
+ manager.addGeofence(geofence,
383
+ onSuccess = {
384
+ licenseEnforcer.startTrialIfNeeded()
385
+ geofenceDebug.logGeofenceAdd(identifier)
386
+ Log.d(TAG, "Geofence added: $identifier")
387
+ call.resolve()
388
+ },
389
+ onError = { errorMsg ->
390
+ val code = if (errorMsg.contains("limit")) "GEOFENCE_LIMIT_EXCEEDED" else "GEOFENCE_ERROR"
391
+ call.reject(errorMsg, code)
392
+ }
393
+ )
394
+ }
395
+
396
+ @PluginMethod
397
+ fun addGeofences(call: PluginCall) {
398
+ val manager = geofenceManager
399
+ if (!isConfigured || manager == null) {
400
+ call.reject("Plugin not configured. Call configure() first.", "NOT_CONFIGURED")
401
+ return
402
+ }
403
+
404
+ val cooldown = licenseEnforcer.checkCooldown()
405
+ if (cooldown != null) {
406
+ call.reject(
407
+ cooldown.message,
408
+ "TRIAL_COOLDOWN",
409
+ Exception("Trial cooldown active"),
410
+ JSObject().apply { put("remainingSeconds", cooldown.remainingSeconds) }
411
+ )
412
+ return
413
+ }
414
+
415
+ val geofencesArray = call.getArray("geofences")
416
+ if (geofencesArray == null) {
417
+ call.reject("Missing required parameter: geofences")
418
+ return
419
+ }
420
+
421
+ val geofences = mutableListOf<BGLGeofenceConfig>()
422
+ for (i in 0 until geofencesArray.length()) {
423
+ val obj = geofencesArray.optJSONObject(i)
424
+ if (obj == null) {
425
+ call.reject("Geofence at index $i is not a valid object")
426
+ return
427
+ }
428
+ val identifier = if (obj.has("identifier")) obj.getString("identifier") else null
429
+ val lat = if (obj.has("latitude")) obj.getDouble("latitude") else null
430
+ val lng = if (obj.has("longitude")) obj.getDouble("longitude") else null
431
+ val rad = if (obj.has("radius")) obj.getDouble("radius") else null
432
+
433
+ if (identifier == null || lat == null || lng == null || rad == null) {
434
+ call.reject("Geofence at index $i missing required fields (identifier, latitude, longitude, radius)")
435
+ return
436
+ }
437
+
438
+ val extras = obj.optJSONObject("extras")?.let { extrasObj ->
439
+ extrasObj.keys().asSequence().associateWith { key -> extrasObj.optString(key, "") }
440
+ }
441
+
442
+ geofences.add(BGLGeofenceConfig(
443
+ identifier = identifier,
444
+ latitude = lat,
445
+ longitude = lng,
446
+ radius = rad,
447
+ notifyOnEntry = obj.optBoolean("notifyOnEntry", true),
448
+ notifyOnExit = obj.optBoolean("notifyOnExit", true),
449
+ notifyOnDwell = obj.optBoolean("notifyOnDwell", false),
450
+ dwellDelay = obj.optInt("dwellDelay", 300),
451
+ extras = extras
452
+ ))
453
+ }
454
+
455
+ manager.addGeofences(geofences,
456
+ onSuccess = {
457
+ licenseEnforcer.startTrialIfNeeded()
458
+ geofenceDebug.logGeofenceAddBatch(geofences.size)
459
+ Log.d(TAG, "Batch added ${geofences.size} geofences")
460
+ call.resolve()
461
+ },
462
+ onError = { errorMsg ->
463
+ val code = if (errorMsg.contains("limit")) "GEOFENCE_LIMIT_EXCEEDED" else "GEOFENCE_ERROR"
464
+ call.reject(errorMsg, code)
465
+ }
466
+ )
467
+ }
468
+
469
+ @PluginMethod
470
+ fun removeGeofence(call: PluginCall) {
471
+ val manager = geofenceManager
472
+ if (!isConfigured || manager == null) {
473
+ call.reject("Plugin not configured. Call configure() first.", "NOT_CONFIGURED")
474
+ return
475
+ }
476
+
477
+ val identifier = call.getString("identifier")
478
+ if (identifier == null) {
479
+ call.reject("Missing required parameter: identifier")
480
+ return
481
+ }
482
+
483
+ manager.removeGeofence(identifier)
484
+ geofenceDebug.logGeofenceRemove(identifier)
485
+
486
+ // If no geofences and no tracking, stop trial timer
487
+ val isTracking = locationService?.isTracking ?: false
488
+ licenseEnforcer.stopTrialIfIdle(!manager.hasGeofences, isTracking)
489
+
490
+ Log.d(TAG, "Geofence removed: $identifier")
491
+ call.resolve()
492
+ }
493
+
494
+ @PluginMethod
495
+ fun removeAllGeofences(call: PluginCall) {
496
+ val manager = geofenceManager
497
+ if (!isConfigured || manager == null) {
498
+ call.reject("Plugin not configured. Call configure() first.", "NOT_CONFIGURED")
499
+ return
500
+ }
501
+
502
+ val count = manager.getGeofences().size
503
+ manager.removeAllGeofences()
504
+ geofenceDebug.logGeofenceRemoveAll(count)
505
+
506
+ // If not tracking, stop trial timer
507
+ val isTracking = locationService?.isTracking ?: false
508
+ licenseEnforcer.stopTrialIfIdle(hasGeofences = false, isTracking = isTracking)
509
+
510
+ Log.d(TAG, "All geofences removed")
511
+ call.resolve()
512
+ }
513
+
514
+ @PluginMethod
515
+ fun getGeofences(call: PluginCall) {
516
+ val manager = geofenceManager
517
+ if (!isConfigured || manager == null) {
518
+ call.reject("Plugin not configured. Call configure() first.", "NOT_CONFIGURED")
519
+ return
520
+ }
521
+
522
+ val geofences = manager.getGeofences()
523
+ val result = com.getcapacitor.JSArray()
524
+ for (geofence in geofences) {
525
+ val obj = JSObject().apply {
526
+ put("identifier", geofence.identifier)
527
+ put("latitude", geofence.latitude)
528
+ put("longitude", geofence.longitude)
529
+ put("radius", geofence.radius)
530
+ put("notifyOnEntry", geofence.notifyOnEntry)
531
+ put("notifyOnExit", geofence.notifyOnExit)
532
+ put("notifyOnDwell", geofence.notifyOnDwell)
533
+ put("dwellDelay", geofence.dwellDelay)
534
+ geofence.extras?.let { extras ->
535
+ put("extras", org.json.JSONObject(extras))
536
+ }
537
+ }
538
+ result.put(obj)
539
+ }
540
+
541
+ val response = JSObject().apply {
542
+ put("geofences", result)
543
+ }
544
+ call.resolve(response)
545
+ }
546
+
547
+ // MARK: - Battery Optimization (B.1)
548
+
549
+ @PluginMethod
550
+ fun checkBatteryOptimization(call: PluginCall) {
551
+ call.resolve(batteryStateToJSObject(batteryHelper.checkState()))
552
+ }
553
+
554
+ @PluginMethod
555
+ fun requestBatteryOptimization(call: PluginCall) {
556
+ val intent = batteryHelper.createBatteryOptimizationIntent()
557
+ if (intent != null) {
558
+ try {
559
+ context.startActivity(intent)
560
+ } catch (e: Exception) {
561
+ Log.w(TAG, "Failed to open battery optimization settings: ${e.message}")
562
+ }
563
+ }
564
+ // Return state before user interaction — caller should re-check
565
+ // with checkBatteryOptimization() after app resumes.
566
+ call.resolve(batteryStateToJSObject(batteryHelper.checkState()))
567
+ }
568
+
569
+ /**
570
+ * Emit onBatteryWarning if battery optimization is active.
571
+ * Called automatically on start() to warn about potential tracking issues.
572
+ */
573
+ private fun checkAndEmitBatteryWarning() {
574
+ val state = batteryHelper.checkState()
575
+ if (!state.isIgnoringOptimizations) {
576
+ notifyListeners("onBatteryWarning", batteryStateToJSObject(state))
577
+ Log.w(TAG, "Battery optimization active — ${state.message}")
578
+ }
579
+ }
580
+
581
+ internal fun batteryStateToJSObject(state: BGLBatteryState): JSObject {
582
+ return JSObject().apply {
583
+ put("isIgnoringOptimizations", state.isIgnoringOptimizations)
584
+ put("manufacturer", state.manufacturer)
585
+ put("helpUrl", state.helpUrl)
586
+ put("message", state.message)
587
+ }
588
+ }
589
+
590
+ // MARK: - Event emission
591
+
592
+ private fun emitLocation(location: BGLLocationData) {
593
+ val jsObj = BGLLocationHelpers.toJSONObject(location).toJSObject()
594
+ // Add effectiveDistanceFilter in auto mode
595
+ if (config.distanceFilterMode == "auto") {
596
+ val effective = locationService?.adaptiveFilter?.effectiveDistanceFilter
597
+ if (effective != null) {
598
+ jsObj.put("effectiveDistanceFilter", effective.toDouble())
599
+ }
600
+ }
601
+ notifyListeners("onLocation", jsObj)
602
+ }
603
+
604
+ private fun emitHeartbeat(location: BGLLocationData?) {
605
+ val data = JSObject().apply {
606
+ if (location != null) {
607
+ put("location", BGLLocationHelpers.toJSONObject(location))
608
+ } else {
609
+ put("location", org.json.JSONObject.NULL)
610
+ }
611
+ put("timestamp", System.currentTimeMillis())
612
+ }
613
+ notifyListeners("onHeartbeat", data)
614
+ }
615
+
616
+ private fun emitProviderChange(enabled: Boolean) {
617
+ val data = JSObject().apply {
618
+ put("gps", enabled)
619
+ put("network", enabled)
620
+ put("enabled", enabled)
621
+ put("status", if (enabled) 1 else 0)
622
+ }
623
+ notifyListeners("onProviderChange", data)
624
+ }
625
+
626
+ private fun emitHttp(result: BGLHttpResult) {
627
+ val data = JSObject().apply {
628
+ put("statusCode", result.statusCode)
629
+ put("success", result.success)
630
+ put("responseText", result.responseText)
631
+ if (result.error != null) {
632
+ put("error", result.error)
633
+ }
634
+ put("bufferedCount", result.bufferedCount)
635
+ }
636
+ notifyListeners("onHttp", data)
637
+ }
638
+
639
+ private fun emitMockLocation(location: BGLLocationData) {
640
+ val data = JSObject().apply {
641
+ put("location", BGLLocationHelpers.toJSONObject(location))
642
+ put("message", "Mock location detected — the device is using a mock/test location provider.")
643
+ }
644
+ notifyListeners("onMockLocation", data)
645
+ Log.w(TAG, "Mock location detected — (${location.latitude}, ${location.longitude})")
646
+ }
647
+
648
+ private fun emitDebug(message: String) {
649
+ val data = JSObject().apply {
650
+ put("message", message)
651
+ put("timestamp", System.currentTimeMillis())
652
+ }
653
+ notifyListeners("onDebug", data)
654
+ }
655
+
656
+ /**
657
+ * Called by BGLTrialTimer when the 30-minute trial period expires.
658
+ * Auto-stops tracking and emits onTrialExpired event.
659
+ */
660
+ private fun onTrialExpired() {
661
+ performStopTracking()
662
+ geofenceManager?.removeAllGeofences()
663
+
664
+ val cooldownMinutes = BGLTrialTimer.COOLDOWN_DURATION_SECONDS / 60
665
+ val data = JSObject().apply {
666
+ put("elapsed", BGLTrialTimer.TRIAL_DURATION_SECONDS)
667
+ put("cooldownSeconds", BGLTrialTimer.COOLDOWN_DURATION_SECONDS)
668
+ put("message", "Trial expired. Next start available in $cooldownMinutes minutes. Purchase a license for unlimited use.")
669
+ }
670
+ notifyListeners("onTrialExpired", data)
671
+ Log.w(TAG, "Trial expired — tracking auto-stopped, cooldown started (${BGLTrialTimer.COOLDOWN_DURATION_SECONDS}s)")
672
+ }
673
+
674
+ override fun handleOnDestroy() {
675
+ if (isBound) {
676
+ context.unbindService(serviceConnection)
677
+ isBound = false
678
+ }
679
+ super.handleOnDestroy()
680
+ }
681
+ }
682
+
683
+ /** Convert core JSONObject to Capacitor JSObject */
684
+ private fun JSONObject.toJSObject(): JSObject = JSObject(this.toString())