@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,137 @@
1
+ package dev.bglocation.core.license
2
+
3
+ import android.content.SharedPreferences
4
+ import android.util.Log
5
+ import dev.bglocation.core.BGLLicenseMode
6
+ import dev.bglocation.core.BGLLicenseResult
7
+ import dev.bglocation.core.BGLLocationConfig
8
+ import org.json.JSONObject
9
+
10
+ /**
11
+ * Manages license validation, trial timer lifecycle, and cooldown enforcement.
12
+ */
13
+ class BGLLicenseEnforcer(
14
+ private val licensePrefs: SharedPreferences,
15
+ private val licenseValidator: BGLLicenseValidator
16
+ ) {
17
+
18
+ companion object {
19
+ private const val TAG = "BGLocation"
20
+ }
21
+
22
+ var licenseMode = BGLLicenseMode.TRIAL
23
+ private set
24
+ var trialTimer: BGLTrialTimer? = null
25
+ private set
26
+
27
+ /**
28
+ * Validate license key and populate configure result.
29
+ * Returns the updated config (may force debug in trial mode).
30
+ */
31
+ fun validateLicense(
32
+ licenseKey: String?,
33
+ bundleId: String,
34
+ config: BGLLocationConfig,
35
+ configureResult: JSONObject,
36
+ onTrialExpired: () -> Unit
37
+ ): BGLLocationConfig {
38
+ val result = licenseValidator.validate(licenseKey, bundleId, BGLBuildConfig.buildEpoch)
39
+ var updatedConfig = config
40
+
41
+ when (result) {
42
+ is BGLLicenseResult.Valid -> {
43
+ licenseMode = BGLLicenseMode.FULL
44
+ trialTimer?.stop()
45
+ trialTimer = null
46
+ configureResult.put("licenseMode", "full")
47
+ if (result.updatesUntil != null) {
48
+ val updatesUntil = java.time.Instant.ofEpochSecond(result.updatesUntil).toString()
49
+ configureResult.put("licenseUpdatesUntil", updatesUntil)
50
+ Log.d(TAG, "License valid — full mode, updates until $updatesUntil")
51
+ } else {
52
+ Log.d(TAG, "License valid — full mode, no update expiry")
53
+ }
54
+ }
55
+ is BGLLicenseResult.UpdatesExpired -> {
56
+ licenseMode = BGLLicenseMode.TRIAL
57
+ updatedConfig = config.copy(debug = true)
58
+ val updatesUntil = java.time.Instant.ofEpochSecond(result.updatesUntil).toString()
59
+ configureResult.put("licenseMode", "trial")
60
+ configureResult.put("licenseUpdateExpired", true)
61
+ configureResult.put("licenseUpdatesUntil", updatesUntil)
62
+ configureResult.put("licenseError", "Updates expired. This plugin version requires a license with updates valid until ${java.time.Instant.ofEpochSecond(BGLBuildConfig.buildEpoch)}. Please renew at bglocation.dev/portal")
63
+
64
+ if (trialTimer == null) {
65
+ trialTimer = BGLTrialTimer(licensePrefs, onTrialExpired)
66
+ }
67
+
68
+ Log.w(TAG, "License updates expired (until $updatesUntil). Running in TRIAL mode.")
69
+ }
70
+ is BGLLicenseResult.Invalid -> {
71
+ licenseMode = BGLLicenseMode.TRIAL
72
+ updatedConfig = config.copy(debug = true)
73
+ configureResult.put("licenseMode", "trial")
74
+ configureResult.put("licenseError", result.reason)
75
+
76
+ if (trialTimer == null) {
77
+ trialTimer = BGLTrialTimer(licensePrefs, onTrialExpired)
78
+ }
79
+
80
+ Log.w(TAG, "License: ${result.reason}. Running in TRIAL mode (30 min limit, 1h cooldown).")
81
+ }
82
+ }
83
+
84
+ return updatedConfig
85
+ }
86
+
87
+ /**
88
+ * Check if trial cooldown is active. Returns a rejection JSObject if cooldown
89
+ * is active, or null if the operation can proceed.
90
+ */
91
+ fun checkCooldown(): BGLCooldownResult? {
92
+ if (licenseMode != BGLLicenseMode.TRIAL) return null
93
+ val timer = trialTimer ?: return null
94
+ if (!timer.isInCooldown()) return null
95
+
96
+ val remaining = timer.remainingCooldownSeconds()
97
+ return BGLCooldownResult(
98
+ remainingSeconds = remaining,
99
+ message = "Trial cooldown active. Please wait $remaining seconds or provide a license key."
100
+ )
101
+ }
102
+
103
+ /**
104
+ * Start trial timer if in trial mode.
105
+ */
106
+ fun startTrialIfNeeded() {
107
+ if (licenseMode == BGLLicenseMode.TRIAL) {
108
+ trialTimer?.start()
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Stop trial timer if no geofences are active and tracking is stopped.
114
+ */
115
+ fun stopTrialIfIdle(hasGeofences: Boolean, isTracking: Boolean) {
116
+ if (licenseMode == BGLLicenseMode.TRIAL && !hasGeofences && !isTracking) {
117
+ trialTimer?.stop()
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Stop trial timer if no geofences are active (used by stop()).
123
+ */
124
+ fun stopTrialIfNoGeofences(hasGeofences: Boolean) {
125
+ if (!hasGeofences) {
126
+ trialTimer?.stop()
127
+ }
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Result of a cooldown check.
133
+ */
134
+ data class BGLCooldownResult(
135
+ val remainingSeconds: Int,
136
+ val message: String
137
+ )
@@ -0,0 +1,134 @@
1
+ package dev.bglocation.core.license
2
+
3
+ import dev.bglocation.core.BGLLicenseResult
4
+ import android.content.SharedPreferences
5
+ import java.security.KeyFactory
6
+ import java.security.Signature
7
+ import java.security.spec.X509EncodedKeySpec
8
+ import java.util.Base64
9
+ import org.json.JSONObject
10
+
11
+ /**
12
+ * Offline RSA-SHA256 license key validator.
13
+ *
14
+ * Key format: BGL1-{base64url(payload)}.{base64url(signature)}
15
+ * Payload: { bid: String, iat: Long, exp?: Long }
16
+ *
17
+ * Public key is embedded at compile time — private key never leaves the publisher.
18
+ */
19
+ class BGLLicenseValidator(private val prefs: SharedPreferences) {
20
+
21
+ companion object {
22
+ private const val KEY_MAX_OBSERVED_TIME = "bgl_max_observed_time"
23
+ private const val KEY_PREFIX = "BGL1-"
24
+
25
+ // RSA-2048 public key (SPKI / X.509 DER, base64-encoded)
26
+ private const val PUBLIC_KEY_BASE64 =
27
+ "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAygeAaJHcA3s1DOsu4QWP" +
28
+ "NBNrK7gu6w+FhHMh5cVmy3YRkUrXblHnRCM1EZdfZb8/jX24rtyrrblF6sUDFDa/" +
29
+ "G/r1DeQUapmcVlbbJbQQbbRziVYQ3wA3N+/uZSJ08ac5CPw09yuIU2ytFkbnx27T" +
30
+ "nrRQ5Z8u9hSbW9e5YE9c5pG0yngD0IbW2Kz1ZVEH4TkLdNuhjaPr9wyA5Y3paOW3" +
31
+ "P0Fz9cpHj1eDVEYRZNlzvFuRMvxo2lVSX/FqNuRi4orlyGfHugLjqHihIymz6rVJ" +
32
+ "k+5lLyjgX6KLbKusn5OzblyedBwyJ6TcPWf7BBxZik6hWuqcv24reeCUMXO5t8eC" +
33
+ "+wIDAQAB"
34
+ }
35
+
36
+ /**
37
+ * Validate a license key against the given bundle ID.
38
+ *
39
+ * @param licenseKey The GT1 license key string, or null/empty for trial.
40
+ * @param bundleId The app's package name / bundle identifier.
41
+ * @param buildEpoch Unix epoch seconds when the plugin was built. Used for update gating.
42
+ * @return [BGLLicenseResult.Valid] with optional updatesUntil timestamp, or [BGLLicenseResult.Invalid] with reason.
43
+ */
44
+ fun validate(licenseKey: String?, bundleId: String, buildEpoch: Long = 0): BGLLicenseResult {
45
+ if (licenseKey.isNullOrBlank()) {
46
+ return BGLLicenseResult.Invalid("No license key provided")
47
+ }
48
+
49
+ if (!licenseKey.startsWith(KEY_PREFIX)) {
50
+ return BGLLicenseResult.Invalid("Unsupported key format")
51
+ }
52
+
53
+ val body = licenseKey.removePrefix(KEY_PREFIX)
54
+ val parts = body.split(".")
55
+ if (parts.size != 2) {
56
+ return BGLLicenseResult.Invalid("Malformed key structure")
57
+ }
58
+
59
+ val payloadB64 = parts[0]
60
+ val signatureB64 = parts[1]
61
+
62
+ // Decode payload
63
+ val payloadBytes: ByteArray
64
+ try {
65
+ payloadBytes = Base64.getUrlDecoder().decode(payloadB64)
66
+ } catch (_: IllegalArgumentException) {
67
+ return BGLLicenseResult.Invalid("Invalid payload encoding")
68
+ }
69
+
70
+ // Decode signature
71
+ val signatureBytes: ByteArray
72
+ try {
73
+ signatureBytes = Base64.getUrlDecoder().decode(signatureB64)
74
+ } catch (_: IllegalArgumentException) {
75
+ return BGLLicenseResult.Invalid("Invalid signature encoding")
76
+ }
77
+
78
+ // Verify RSA-SHA256 signature
79
+ if (!verifySignature(payloadBytes, signatureBytes)) {
80
+ return BGLLicenseResult.Invalid("Invalid signature")
81
+ }
82
+
83
+ // Parse payload JSON
84
+ val json: JSONObject
85
+ try {
86
+ json = JSONObject(String(payloadBytes, Charsets.UTF_8))
87
+ } catch (_: Exception) {
88
+ return BGLLicenseResult.Invalid("Invalid payload JSON")
89
+ }
90
+
91
+ val bid = json.optString("bid", "")
92
+ // exp is optional — informational only ("updates available until")
93
+ val exp = if (json.has("exp")) json.optLong("exp", 0).takeIf { it != 0L } else null
94
+
95
+ // Check bundle ID
96
+ if (bid != bundleId) {
97
+ return BGLLicenseResult.Invalid("Bundle ID mismatch")
98
+ }
99
+
100
+ // Update gating: if exp is set and build epoch is after exp, updates have expired
101
+ if (exp != null && buildEpoch > 0 && exp < buildEpoch) {
102
+ return BGLLicenseResult.UpdatesExpired(updatesUntil = exp)
103
+ }
104
+
105
+ return BGLLicenseResult.Valid(updatesUntil = exp)
106
+ }
107
+
108
+ /**
109
+ * High-water-mark: track the maximum observed time to prevent clock rollback attacks.
110
+ * Once a time is observed, it never "goes back".
111
+ */
112
+ internal fun maxObservedTime(nowSeconds: Long): Long {
113
+ val stored = prefs.getLong(KEY_MAX_OBSERVED_TIME, 0)
114
+ val maxTime = maxOf(nowSeconds, stored)
115
+ if (maxTime != stored) {
116
+ prefs.edit().putLong(KEY_MAX_OBSERVED_TIME, maxTime).apply()
117
+ }
118
+ return maxTime
119
+ }
120
+
121
+ private fun verifySignature(data: ByteArray, signature: ByteArray): Boolean {
122
+ return try {
123
+ val keyBytes = Base64.getDecoder().decode(PUBLIC_KEY_BASE64)
124
+ val keySpec = X509EncodedKeySpec(keyBytes)
125
+ val publicKey = KeyFactory.getInstance("RSA").generatePublic(keySpec)
126
+ val sig = Signature.getInstance("SHA256withRSA")
127
+ sig.initVerify(publicKey)
128
+ sig.update(data)
129
+ sig.verify(signature)
130
+ } catch (_: Exception) {
131
+ false
132
+ }
133
+ }
134
+ }
@@ -0,0 +1,176 @@
1
+ package dev.bglocation.core.license
2
+
3
+ import android.content.SharedPreferences
4
+ import android.os.Handler
5
+ import android.os.Looper
6
+ import android.os.SystemClock
7
+
8
+ /**
9
+ * 30-minute trial timer with 1-hour cooldown.
10
+ *
11
+ * After the trial expires, a cooldown is persisted so that restarting the app
12
+ * does not bypass the restriction. Clock manipulation is mitigated using
13
+ * [SystemClock.elapsedRealtime] (monotonic) as the primary source and an
14
+ * anti-rollback guard on wall clock as fallback.
15
+ */
16
+ class BGLTrialTimer(
17
+ private val prefs: SharedPreferences,
18
+ private val onExpired: () -> Unit
19
+ ) {
20
+
21
+ companion object {
22
+ const val TRIAL_DURATION_SECONDS = 30 * 60 // 30 min
23
+ const val COOLDOWN_DURATION_SECONDS = 60 * 60 // 1 hour
24
+
25
+ internal const val KEY_COOLDOWN_ENDS = "bgl_trial_cooldown_ends_at"
26
+ internal const val KEY_COOLDOWN_SET = "bgl_trial_cooldown_set_at"
27
+ internal const val KEY_COOLDOWN_MONO = "bgl_trial_cooldown_mono_at"
28
+ internal const val KEY_TRIAL_STARTED_AT = "bgl_trial_started_at"
29
+ }
30
+
31
+ private val handler = Handler(Looper.getMainLooper())
32
+ private var timerRunnable: Runnable? = null
33
+
34
+ var isRunning: Boolean = false
35
+ private set
36
+
37
+ /**
38
+ * Start the 30-minute trial timer.
39
+ *
40
+ * @throws IllegalStateException if cooldown is active.
41
+ */
42
+ fun start() {
43
+ check(!isInCooldown()) {
44
+ "Trial cooldown active. ${remainingCooldownSeconds()} seconds remaining."
45
+ }
46
+
47
+ isRunning = true
48
+
49
+ // Persist trial start time so app restarts can detect an expired trial.
50
+ prefs.edit().putLong(KEY_TRIAL_STARTED_AT, System.currentTimeMillis() / 1000).apply()
51
+
52
+ val runnable = Runnable { expire() }
53
+ timerRunnable = runnable
54
+ handler.postDelayed(runnable, TRIAL_DURATION_SECONDS * 1000L)
55
+ }
56
+
57
+ /**
58
+ * Stop the timer without triggering cooldown (normal stop by user).
59
+ */
60
+ fun stop() {
61
+ timerRunnable?.let { handler.removeCallbacks(it) }
62
+ timerRunnable = null
63
+ isRunning = false
64
+ prefs.edit().remove(KEY_TRIAL_STARTED_AT).apply()
65
+ }
66
+
67
+ /**
68
+ * Check whether the cooldown period is active.
69
+ *
70
+ * Uses monotonic clock (primary) and anti-rollback guard (fallback).
71
+ *
72
+ * **Side effect (state repair):** If a previously started trial should have
73
+ * expired while the app was inactive (killed/restarted), this method detects
74
+ * the stale [KEY_TRIAL_STARTED_AT] value and transitions the timer into
75
+ * cooldown. This is intentional — the timer's in-memory state is lost on
76
+ * process death, so [isInCooldown] reconstructs it from persisted data.
77
+ */
78
+ fun isInCooldown(): Boolean {
79
+ // Check if a previous trial was started but expired while the app was inactive.
80
+ val trialStartedAt = prefs.getLong(KEY_TRIAL_STARTED_AT, 0)
81
+ if (trialStartedAt > 0) {
82
+ val nowSeconds = System.currentTimeMillis() / 1000
83
+ val elapsed = nowSeconds - trialStartedAt
84
+ if (elapsed >= TRIAL_DURATION_SECONDS || nowSeconds < trialStartedAt) {
85
+ // Trial should have expired — persist cooldown and clear trial start
86
+ prefs.edit().remove(KEY_TRIAL_STARTED_AT).apply()
87
+ persistCooldown()
88
+ isRunning = false
89
+ timerRunnable?.let { handler.removeCallbacks(it) }
90
+ timerRunnable = null
91
+ return true
92
+ }
93
+ }
94
+
95
+ val monoAt = prefs.getLong(KEY_COOLDOWN_MONO, 0)
96
+ if (monoAt > 0) {
97
+ val elapsedSinceCooldown = (SystemClock.elapsedRealtime() / 1000) - monoAt
98
+ if (elapsedSinceCooldown < COOLDOWN_DURATION_SECONDS) {
99
+ return true
100
+ }
101
+ }
102
+
103
+ // Anti-rollback guard: if current wall clock is before the time we set cooldown,
104
+ // someone rolled back the clock → cooldown still active.
105
+ val setAt = prefs.getLong(KEY_COOLDOWN_SET, 0)
106
+ val nowSeconds = System.currentTimeMillis() / 1000
107
+ if (setAt > 0 && nowSeconds < setAt) {
108
+ return true
109
+ }
110
+
111
+ // Check wall clock expiry as last resort
112
+ val endsAt = prefs.getLong(KEY_COOLDOWN_ENDS, 0)
113
+ if (endsAt > 0 && nowSeconds < endsAt) {
114
+ return true
115
+ }
116
+
117
+ // Cooldown is over — clear persisted values
118
+ if (monoAt > 0 || setAt > 0 || endsAt > 0) {
119
+ clearCooldown()
120
+ }
121
+
122
+ return false
123
+ }
124
+
125
+ /**
126
+ * Remaining cooldown time in seconds (0 if not in cooldown).
127
+ */
128
+ fun remainingCooldownSeconds(): Int {
129
+ // Primary: monotonic clock
130
+ val monoAt = prefs.getLong(KEY_COOLDOWN_MONO, 0)
131
+ if (monoAt > 0) {
132
+ val elapsedSinceCooldown = (SystemClock.elapsedRealtime() / 1000) - monoAt
133
+ val remaining = COOLDOWN_DURATION_SECONDS - elapsedSinceCooldown
134
+ if (remaining > 0) {
135
+ return remaining.toInt()
136
+ }
137
+ }
138
+
139
+ // Fallback: wall clock
140
+ val endsAt = prefs.getLong(KEY_COOLDOWN_ENDS, 0)
141
+ val nowSeconds = System.currentTimeMillis() / 1000
142
+ val remaining = endsAt - nowSeconds
143
+ return if (remaining > 0) remaining.toInt() else 0
144
+ }
145
+
146
+ /**
147
+ * Exposed as `internal` so that same-package tests can trigger expiry
148
+ * without waiting for the real 30-minute timer.
149
+ */
150
+ internal fun expire() {
151
+ isRunning = false
152
+ timerRunnable = null
153
+ prefs.edit().remove(KEY_TRIAL_STARTED_AT).apply()
154
+ persistCooldown()
155
+ onExpired()
156
+ }
157
+
158
+ private fun persistCooldown() {
159
+ val nowSeconds = System.currentTimeMillis() / 1000
160
+ val monoSeconds = SystemClock.elapsedRealtime() / 1000
161
+ prefs.edit()
162
+ .putLong(KEY_COOLDOWN_ENDS, nowSeconds + COOLDOWN_DURATION_SECONDS)
163
+ .putLong(KEY_COOLDOWN_SET, nowSeconds)
164
+ .putLong(KEY_COOLDOWN_MONO, monoSeconds)
165
+ .apply()
166
+ }
167
+
168
+ private fun clearCooldown() {
169
+ prefs.edit()
170
+ .remove(KEY_COOLDOWN_ENDS)
171
+ .remove(KEY_COOLDOWN_SET)
172
+ .remove(KEY_COOLDOWN_MONO)
173
+ .remove(KEY_TRIAL_STARTED_AT)
174
+ .apply()
175
+ }
176
+ }
@@ -0,0 +1,94 @@
1
+ package dev.bglocation.core.location
2
+
3
+ /**
4
+ * Configuration for the adaptive distance filter.
5
+ */
6
+ data class BGLAutoDistanceFilterConfig(
7
+ val targetInterval: Double = DEFAULT_TARGET_INTERVAL,
8
+ val minDistance: Float = DEFAULT_MIN_DISTANCE,
9
+ val maxDistance: Float = DEFAULT_MAX_DISTANCE
10
+ ) {
11
+ companion object {
12
+ const val DEFAULT_TARGET_INTERVAL: Double = 10.0
13
+ const val DEFAULT_MIN_DISTANCE: Float = 10f
14
+ const val DEFAULT_MAX_DISTANCE: Float = 500f
15
+ }
16
+ }
17
+
18
+ /**
19
+ * Adaptive distance filter that dynamically adjusts based on speed.
20
+ *
21
+ * Uses Exponential Moving Average (EMA) to smooth GPS speed readings
22
+ * and calculates an effective distance filter so that location updates
23
+ * arrive at approximately [config.targetInterval] seconds regardless of speed.
24
+ *
25
+ * Formula: effectiveDistance = clamp(smoothedSpeed * targetInterval, minDistance, maxDistance)
26
+ *
27
+ * The native distance filter is only reconfigured when the change exceeds [RECALC_THRESHOLD]
28
+ * to avoid expensive LocationRequest restarts on Android.
29
+ */
30
+ class BGLAdaptiveFilter(
31
+ private val config: BGLAutoDistanceFilterConfig = BGLAutoDistanceFilterConfig()
32
+ ) {
33
+
34
+ companion object {
35
+ /** EMA smoothing factor for acceleration. Lower = smoother but slower to react. */
36
+ const val EMA_ALPHA_UP: Float = 0.3f
37
+
38
+ /** EMA smoothing factor for deceleration. Higher = faster reaction to speed drops. */
39
+ const val EMA_ALPHA_DOWN: Float = 0.6f
40
+
41
+ /** Minimum change in meters to trigger a native distance filter reconfiguration. */
42
+ const val RECALC_THRESHOLD: Float = 5f
43
+ }
44
+
45
+ private var smoothedSpeed: Float = 0f
46
+ private var currentDistanceFilter: Float = config.minDistance
47
+ private var initialized = false
48
+
49
+ /**
50
+ * The current effective distance filter in meters.
51
+ */
52
+ val effectiveDistanceFilter: Float
53
+ get() = currentDistanceFilter
54
+
55
+ /**
56
+ * Update the filter with a new speed reading and determine if the native
57
+ * distance filter should be reconfigured.
58
+ *
59
+ * @param speedMps Current speed in meters per second. Negative values (no GPS speed) are treated as 0.
60
+ * @return `true` if the native distance filter should be updated (change exceeds threshold),
61
+ * `false` if no reconfiguration is needed.
62
+ */
63
+ fun update(speedMps: Float): Boolean {
64
+ val safeSpeed = if (speedMps < 0f || speedMps.isNaN()) 0f else speedMps
65
+
66
+ smoothedSpeed = if (!initialized) {
67
+ initialized = true
68
+ safeSpeed
69
+ } else {
70
+ val alpha = if (safeSpeed < smoothedSpeed) EMA_ALPHA_DOWN else EMA_ALPHA_UP
71
+ alpha * safeSpeed + (1f - alpha) * smoothedSpeed
72
+ }
73
+
74
+ val rawDistance = (smoothedSpeed * config.targetInterval).toFloat()
75
+ val clamped = rawDistance.coerceIn(config.minDistance, config.maxDistance)
76
+
77
+ val delta = kotlin.math.abs(clamped - currentDistanceFilter)
78
+ return if (delta >= RECALC_THRESHOLD) {
79
+ currentDistanceFilter = clamped
80
+ true
81
+ } else {
82
+ false
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Reset the filter state. Should be called when tracking stops.
88
+ */
89
+ fun reset() {
90
+ smoothedSpeed = 0f
91
+ currentDistanceFilter = config.minDistance
92
+ initialized = false
93
+ }
94
+ }
@@ -0,0 +1,38 @@
1
+ package dev.bglocation.core.location
2
+
3
+ import android.os.Handler
4
+ import android.os.Looper
5
+
6
+ /**
7
+ * Repeating timer for heartbeat events using Handler/Looper.
8
+ * Matches the iOS BGLHeartbeatTimer API for cross-platform symmetry.
9
+ */
10
+ class BGLHeartbeatTimer(private val handler: Handler = Handler(Looper.getMainLooper())) {
11
+
12
+ private var runnable: Runnable? = null
13
+ var isRunning = false
14
+ private set
15
+
16
+ fun start(intervalSeconds: Int, onTick: () -> Unit) {
17
+ stop()
18
+
19
+ val intervalMs = intervalSeconds * 1000L
20
+ val tickRunnable = object : Runnable {
21
+ override fun run() {
22
+ if (isRunning) {
23
+ onTick()
24
+ handler.postDelayed(this, intervalMs)
25
+ }
26
+ }
27
+ }
28
+ runnable = tickRunnable
29
+ isRunning = true
30
+ handler.postDelayed(tickRunnable, intervalMs)
31
+ }
32
+
33
+ fun stop() {
34
+ runnable?.let { handler.removeCallbacks(it) }
35
+ runnable = null
36
+ isRunning = false
37
+ }
38
+ }