@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.
- package/CapacitorBackgroundLocation.podspec +19 -0
- package/LICENSE.md +97 -0
- package/Package.swift +44 -0
- package/README.md +264 -0
- package/android/build.gradle +74 -0
- package/android/src/main/AndroidManifest.xml +37 -0
- package/android/src/main/kotlin/dev/bglocation/BackgroundLocationPlugin.kt +684 -0
- package/android/src/main/kotlin/dev/bglocation/core/Models.kt +76 -0
- package/android/src/main/kotlin/dev/bglocation/core/battery/BGLBatteryHelper.kt +127 -0
- package/android/src/main/kotlin/dev/bglocation/core/boot/BGLBootCompletedReceiver.kt +32 -0
- package/android/src/main/kotlin/dev/bglocation/core/config/BGLConfigParser.kt +114 -0
- package/android/src/main/kotlin/dev/bglocation/core/config/BGLVersion.kt +6 -0
- package/android/src/main/kotlin/dev/bglocation/core/debug/BGLDebugLogger.kt +174 -0
- package/android/src/main/kotlin/dev/bglocation/core/geofence/BGLGeofenceBroadcastReceiver.kt +93 -0
- package/android/src/main/kotlin/dev/bglocation/core/geofence/BGLGeofenceManager.kt +310 -0
- package/android/src/main/kotlin/dev/bglocation/core/http/BGLHttpSender.kt +187 -0
- package/android/src/main/kotlin/dev/bglocation/core/http/BGLLocationBuffer.kt +152 -0
- package/android/src/main/kotlin/dev/bglocation/core/license/BGLBuildConfig.kt +16 -0
- package/android/src/main/kotlin/dev/bglocation/core/license/BGLLicenseEnforcer.kt +137 -0
- package/android/src/main/kotlin/dev/bglocation/core/license/BGLLicenseValidator.kt +134 -0
- package/android/src/main/kotlin/dev/bglocation/core/license/BGLTrialTimer.kt +176 -0
- package/android/src/main/kotlin/dev/bglocation/core/location/BGLAdaptiveFilter.kt +94 -0
- package/android/src/main/kotlin/dev/bglocation/core/location/BGLHeartbeatTimer.kt +38 -0
- package/android/src/main/kotlin/dev/bglocation/core/location/BGLLocationForegroundService.kt +289 -0
- package/android/src/main/kotlin/dev/bglocation/core/location/BGLLocationHelpers.kt +72 -0
- package/android/src/main/kotlin/dev/bglocation/core/location/BGLPermissionManager.kt +99 -0
- package/android/src/main/kotlin/dev/bglocation/core/notification/BGLNotificationHelper.kt +77 -0
- package/dist/esm/definitions.d.ts +390 -0
- package/dist/esm/definitions.js +3 -0
- package/dist/esm/definitions.js.map +1 -0
- package/dist/esm/index.d.ts +4 -0
- package/dist/esm/index.js +26 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/web.d.ts +47 -0
- package/dist/esm/web.js +231 -0
- package/dist/esm/web.js.map +1 -0
- package/dist/esm/web.test.d.ts +1 -0
- package/dist/esm/web.test.js +940 -0
- package/dist/esm/web.test.js.map +1 -0
- package/dist/plugin.cjs.js +267 -0
- package/dist/plugin.cjs.js.map +1 -0
- package/dist/plugin.js +270 -0
- package/dist/plugin.js.map +1 -0
- package/ios/Sources/BGLocationCore/Config/BGLConfigParser.swift +88 -0
- package/ios/Sources/BGLocationCore/Config/BGLVersion.swift +6 -0
- package/ios/Sources/BGLocationCore/Debug/BGLDebugLogger.swift +201 -0
- package/ios/Sources/BGLocationCore/Geofence/BGLGeofenceManager.swift +538 -0
- package/ios/Sources/BGLocationCore/Http/BGLHttpSender.swift +227 -0
- package/ios/Sources/BGLocationCore/Http/BGLLocationBuffer.swift +198 -0
- package/ios/Sources/BGLocationCore/License/BGLBuildConfig.swift +11 -0
- package/ios/Sources/BGLocationCore/License/BGLLicenseEnforcer.swift +134 -0
- package/ios/Sources/BGLocationCore/License/BGLLicenseValidator.swift +163 -0
- package/ios/Sources/BGLocationCore/License/BGLTrialTimer.swift +168 -0
- package/ios/Sources/BGLocationCore/Location/BGLAdaptiveFilter.swift +91 -0
- package/ios/Sources/BGLocationCore/Location/BGLHeartbeatTimer.swift +50 -0
- package/ios/Sources/BGLocationCore/Location/BGLLocationData.swift +48 -0
- package/ios/Sources/BGLocationCore/Location/BGLLocationHelpers.swift +42 -0
- package/ios/Sources/BGLocationCore/Location/BGLLocationManager.swift +268 -0
- package/ios/Sources/BGLocationCore/Location/BGLPermissionManager.swift +33 -0
- package/ios/Sources/BackgroundLocationPlugin/BackgroundLocationPlugin.swift +657 -0
- 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
|
+
}
|