@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,310 @@
1
+ package dev.bglocation.core.geofence
2
+
3
+ import android.Manifest
4
+ import android.app.PendingIntent
5
+ import android.content.Context
6
+ import android.content.SharedPreferences
7
+ import android.content.pm.PackageManager
8
+ import android.os.Build
9
+ import android.util.Log
10
+ import androidx.core.app.ActivityCompat
11
+ import com.google.android.gms.location.Geofence
12
+ import com.google.android.gms.location.GeofencingClient
13
+ import com.google.android.gms.location.GeofencingRequest
14
+ import com.google.android.gms.location.LocationServices
15
+ import dev.bglocation.core.BGLLocationData
16
+ import dev.bglocation.core.location.BGLLocationHelpers
17
+ import org.json.JSONArray
18
+ import org.json.JSONObject
19
+
20
+ /**
21
+ * Manages Android geofencing via [GeofencingClient] with SharedPreferences persistence.
22
+ *
23
+ * - Persistence is the source of truth for getGeofences() (GeofencingClient does not
24
+ * expose registered geofences with full config).
25
+ * - PendingIntent uses FLAG_MUTABLE (required for Android 12+ BroadcastReceiver).
26
+ * - Limit of [MAX_GEOFENCES] enforced.
27
+ */
28
+ class BGLGeofenceManager(
29
+ private val context: Context,
30
+ private val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
31
+ ) {
32
+
33
+ companion object {
34
+ private const val TAG = "BGLocation"
35
+ const val MAX_GEOFENCES = 20
36
+ private const val PREFS_NAME = "bgl_geofences"
37
+ private const val KEY_GEOFENCES = "geofences_json"
38
+ const val ACTION_GEOFENCE_EVENT = "dev.bglocation.ACTION_GEOFENCE_EVENT"
39
+ }
40
+
41
+ private val geofencingClient: GeofencingClient = LocationServices.getGeofencingClient(context)
42
+
43
+ /**
44
+ * Callback fired on geofence transitions (called from [BGLGeofenceBroadcastReceiver]).
45
+ */
46
+ var onGeofenceEvent: ((BGLGeofenceEventData) -> Unit)? = null
47
+
48
+ /** In-memory cache — invalidated on every save. Avoids repeated JSON deserialization. */
49
+ private var cachedGeofences: List<BGLGeofenceConfig>? = null
50
+
51
+ // MARK: - Public API
52
+
53
+ /**
54
+ * Add a single geofence. Replaces existing geofence with the same identifier.
55
+ * @throws GeofenceLimitExceededException if adding would exceed [MAX_GEOFENCES].
56
+ */
57
+ fun addGeofence(geofence: BGLGeofenceConfig, onSuccess: () -> Unit, onError: (String) -> Unit) {
58
+ val current = getGeofences().toMutableList()
59
+
60
+ // Replace existing with same identifier
61
+ current.removeAll { it.identifier == geofence.identifier }
62
+
63
+ if (current.size >= MAX_GEOFENCES) {
64
+ onError("Cannot add geofence — limit of $MAX_GEOFENCES reached.")
65
+ return
66
+ }
67
+
68
+ current.add(geofence)
69
+ saveGeofences(current)
70
+
71
+ registerNativeGeofence(geofence, onSuccess, onError)
72
+ }
73
+
74
+ /**
75
+ * Add multiple geofences atomically. If the total would exceed the limit, none are added.
76
+ */
77
+ fun addGeofences(geofences: List<BGLGeofenceConfig>, onSuccess: () -> Unit, onError: (String) -> Unit) {
78
+ val current = getGeofences().toMutableList()
79
+
80
+ // Remove duplicates by identifier
81
+ val newIdentifiers = geofences.map { it.identifier }.toSet()
82
+ current.removeAll { it.identifier in newIdentifiers }
83
+
84
+ if (current.size + geofences.size > MAX_GEOFENCES) {
85
+ onError("Cannot add geofences — would exceed limit of $MAX_GEOFENCES.")
86
+ return
87
+ }
88
+
89
+ current.addAll(geofences)
90
+ saveGeofences(current)
91
+
92
+ registerNativeGeofences(geofences, onSuccess, onError)
93
+ }
94
+
95
+ /**
96
+ * Remove a geofence by identifier. No-op if not found.
97
+ */
98
+ fun removeGeofence(identifier: String) {
99
+ val current = getGeofences().toMutableList()
100
+ current.removeAll { it.identifier == identifier }
101
+ saveGeofences(current)
102
+
103
+ geofencingClient.removeGeofences(listOf(identifier))
104
+ .addOnSuccessListener { Log.d(TAG, "Geofence removed: $identifier") }
105
+ .addOnFailureListener { e -> Log.w(TAG, "Failed to remove geofence $identifier: ${e.message}") }
106
+ }
107
+
108
+ /**
109
+ * Remove all registered geofences.
110
+ */
111
+ fun removeAllGeofences() {
112
+ val current = getGeofences()
113
+ saveGeofences(emptyList())
114
+
115
+ if (current.isNotEmpty()) {
116
+ geofencingClient.removeGeofences(getPendingIntent())
117
+ .addOnSuccessListener { Log.d(TAG, "All geofences removed") }
118
+ .addOnFailureListener { e -> Log.w(TAG, "Failed to remove all geofences: ${e.message}") }
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Get all registered geofences from the cache (backed by persistence).
124
+ */
125
+ fun getGeofences(): List<BGLGeofenceConfig> {
126
+ cachedGeofences?.let { return it }
127
+ val loaded = loadGeofences()
128
+ cachedGeofences = loaded
129
+ return loaded
130
+ }
131
+
132
+ /**
133
+ * Whether any geofences are currently registered.
134
+ */
135
+ val hasGeofences: Boolean
136
+ get() = getGeofences().isNotEmpty()
137
+
138
+ /**
139
+ * Re-register all persisted geofences with the system.
140
+ * Called on boot and after configure() to restore geofences.
141
+ */
142
+ fun reRegisterAllGeofences() {
143
+ val geofences = loadGeofences()
144
+ if (geofences.isEmpty()) return
145
+
146
+ registerNativeGeofences(geofences,
147
+ onSuccess = { Log.d(TAG, "Re-registered ${geofences.size} geofences") },
148
+ onError = { e -> Log.w(TAG, "Failed to re-register geofences: $e") }
149
+ )
150
+ }
151
+
152
+ // MARK: - Native Geofence Registration
153
+
154
+ private fun registerNativeGeofence(geofence: BGLGeofenceConfig, onSuccess: () -> Unit, onError: (String) -> Unit) {
155
+ registerNativeGeofences(listOf(geofence), onSuccess, onError)
156
+ }
157
+
158
+ private fun registerNativeGeofences(geofences: List<BGLGeofenceConfig>, onSuccess: () -> Unit, onError: (String) -> Unit) {
159
+ if (ActivityCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION)
160
+ != PackageManager.PERMISSION_GRANTED
161
+ ) {
162
+ onError("Location permission not granted")
163
+ return
164
+ }
165
+
166
+ val nativeGeofences = geofences.map { config ->
167
+ var transitionTypes = 0
168
+ if (config.notifyOnEntry) transitionTypes = transitionTypes or Geofence.GEOFENCE_TRANSITION_ENTER
169
+ if (config.notifyOnExit) transitionTypes = transitionTypes or Geofence.GEOFENCE_TRANSITION_EXIT
170
+ if (config.notifyOnDwell) transitionTypes = transitionTypes or Geofence.GEOFENCE_TRANSITION_DWELL
171
+
172
+ Geofence.Builder()
173
+ .setRequestId(config.identifier)
174
+ .setCircularRegion(config.latitude, config.longitude, config.radius.toFloat())
175
+ .setExpirationDuration(Geofence.NEVER_EXPIRE)
176
+ .setTransitionTypes(transitionTypes)
177
+ .apply {
178
+ if (config.notifyOnDwell) {
179
+ setLoiteringDelay(config.dwellDelay * 1000) // seconds to millis
180
+ }
181
+ }
182
+ .build()
183
+ }
184
+
185
+ val request = GeofencingRequest.Builder()
186
+ .setInitialTrigger(GeofencingRequest.INITIAL_TRIGGER_ENTER)
187
+ .addGeofences(nativeGeofences)
188
+ .build()
189
+
190
+ geofencingClient.addGeofences(request, getPendingIntent())
191
+ .addOnSuccessListener {
192
+ Log.d(TAG, "Registered ${geofences.size} geofences")
193
+ onSuccess()
194
+ }
195
+ .addOnFailureListener { e ->
196
+ Log.e(TAG, "Failed to register geofences: ${e.message}")
197
+ onError(e.message ?: "Unknown error registering geofences")
198
+ }
199
+ }
200
+
201
+ /**
202
+ * Build the PendingIntent for [BGLGeofenceBroadcastReceiver].
203
+ * Uses FLAG_MUTABLE (required for Android 12+ BroadcastReceiver with geofencing).
204
+ */
205
+ private fun getPendingIntent(): PendingIntent {
206
+ val intent = android.content.Intent(context, BGLGeofenceBroadcastReceiver::class.java).apply {
207
+ action = ACTION_GEOFENCE_EVENT
208
+ }
209
+ val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
210
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
211
+ } else {
212
+ PendingIntent.FLAG_UPDATE_CURRENT
213
+ }
214
+ return PendingIntent.getBroadcast(context, 0, intent, flags)
215
+ }
216
+
217
+ // MARK: - Persistence (SharedPreferences JSON)
218
+
219
+ private fun loadGeofences(): List<BGLGeofenceConfig> {
220
+ val json = prefs.getString(KEY_GEOFENCES, null) ?: return emptyList()
221
+ return try {
222
+ val array = JSONArray(json)
223
+ (0 until array.length()).map { i ->
224
+ val obj = array.getJSONObject(i)
225
+ BGLGeofenceConfig(
226
+ identifier = obj.getString("identifier"),
227
+ latitude = obj.getDouble("latitude"),
228
+ longitude = obj.getDouble("longitude"),
229
+ radius = obj.getDouble("radius"),
230
+ notifyOnEntry = obj.optBoolean("notifyOnEntry", true),
231
+ notifyOnExit = obj.optBoolean("notifyOnExit", true),
232
+ notifyOnDwell = obj.optBoolean("notifyOnDwell", false),
233
+ dwellDelay = obj.optInt("dwellDelay", 300),
234
+ extras = obj.optJSONObject("extras")?.let { extrasObj ->
235
+ extrasObj.keys().asSequence().associateWith { key -> extrasObj.getString(key) }
236
+ }
237
+ )
238
+ }
239
+ } catch (e: Exception) {
240
+ Log.e(TAG, "Failed to parse geofences: ${e.message}")
241
+ emptyList()
242
+ }
243
+ }
244
+
245
+ private fun saveGeofences(geofences: List<BGLGeofenceConfig>) {
246
+ cachedGeofences = geofences
247
+ val array = JSONArray()
248
+ for (geofence in geofences) {
249
+ val obj = JSONObject().apply {
250
+ put("identifier", geofence.identifier)
251
+ put("latitude", geofence.latitude)
252
+ put("longitude", geofence.longitude)
253
+ put("radius", geofence.radius)
254
+ put("notifyOnEntry", geofence.notifyOnEntry)
255
+ put("notifyOnExit", geofence.notifyOnExit)
256
+ put("notifyOnDwell", geofence.notifyOnDwell)
257
+ put("dwellDelay", geofence.dwellDelay)
258
+ geofence.extras?.let { extras ->
259
+ put("extras", JSONObject(extras))
260
+ }
261
+ }
262
+ array.put(obj)
263
+ }
264
+ prefs.edit().putString(KEY_GEOFENCES, array.toString()).apply()
265
+ }
266
+
267
+ internal fun findGeofence(identifier: String): BGLGeofenceConfig? {
268
+ return getGeofences().firstOrNull { it.identifier == identifier }
269
+ }
270
+ }
271
+
272
+ /**
273
+ * Geofence configuration stored in persistence.
274
+ */
275
+ data class BGLGeofenceConfig(
276
+ val identifier: String,
277
+ val latitude: Double,
278
+ val longitude: Double,
279
+ val radius: Double,
280
+ val notifyOnEntry: Boolean = true,
281
+ val notifyOnExit: Boolean = true,
282
+ val notifyOnDwell: Boolean = false,
283
+ val dwellDelay: Int = 300,
284
+ val extras: Map<String, String>? = null
285
+ )
286
+
287
+ /**
288
+ * Geofence event emitted to JS layer.
289
+ */
290
+ data class BGLGeofenceEventData(
291
+ val identifier: String,
292
+ val action: String, // "enter", "exit", "dwell"
293
+ val location: BGLLocationData?,
294
+ val extras: Map<String, String>?,
295
+ val timestamp: Long
296
+ ) {
297
+ fun toJSONObject(): JSONObject {
298
+ return JSONObject().apply {
299
+ put("identifier", identifier)
300
+ put("action", action)
301
+ put("timestamp", timestamp)
302
+ if (location != null) {
303
+ put("location", BGLLocationHelpers.toJSONObject(location))
304
+ } else {
305
+ put("location", JSONObject.NULL)
306
+ }
307
+ extras?.let { put("extras", JSONObject(it)) }
308
+ }
309
+ }
310
+ }
@@ -0,0 +1,187 @@
1
+ package dev.bglocation.core.http
2
+
3
+ import dev.bglocation.core.BGLHttpConfig
4
+ import dev.bglocation.core.BGLLocationData
5
+ import dev.bglocation.core.location.BGLLocationHelpers
6
+ import android.os.Handler
7
+ import android.os.Looper
8
+ import android.util.Log
9
+ import org.json.JSONObject
10
+ import java.io.BufferedReader
11
+ import java.io.InputStreamReader
12
+ import java.io.OutputStreamWriter
13
+ import java.net.HttpURLConnection
14
+ import java.net.URL
15
+ import java.util.concurrent.ExecutorService
16
+ import java.util.concurrent.Executors
17
+
18
+ data class BGLHttpResult(
19
+ val statusCode: Int,
20
+ val success: Boolean,
21
+ val responseText: String,
22
+ val error: String? = null,
23
+ val bufferedCount: Int = 0
24
+ )
25
+
26
+ class BGLHttpSender {
27
+
28
+ companion object {
29
+ private const val TAG = "BGLocation"
30
+ private const val CONNECT_TIMEOUT = 10_000
31
+ private const val READ_TIMEOUT = 10_000
32
+ private const val FLUSH_BATCH_SIZE = 50
33
+ }
34
+
35
+ private val executor: ExecutorService = Executors.newSingleThreadExecutor()
36
+ private val mainHandler = Handler(Looper.getMainLooper())
37
+ private var config: BGLHttpConfig? = null
38
+ private var buffer: BGLLocationBuffer? = null
39
+ @Volatile
40
+ private var isFlushing = false
41
+
42
+ /** Called for each HTTP result during buffer flush. Set this to receive onHttp events for flushed items. */
43
+ @Volatile
44
+ var onFlushProgress: ((BGLHttpResult) -> Unit)? = null
45
+
46
+ fun configure(httpConfig: BGLHttpConfig?) {
47
+ config = httpConfig
48
+ }
49
+
50
+ fun setBuffer(locationBuffer: BGLLocationBuffer?) {
51
+ buffer = locationBuffer
52
+ }
53
+
54
+ fun sendLocation(location: BGLLocationData, callback: (BGLHttpResult) -> Unit) {
55
+ val httpConfig = config ?: return
56
+
57
+ executor.execute {
58
+ val result = executePost(httpConfig, location)
59
+ if (!result.success && buffer != null) {
60
+ buffer?.add(location)
61
+ val count = buffer?.count() ?: 0
62
+ Log.d(TAG, "HTTP failed, location buffered (buffer=$count)")
63
+ mainHandler.post { callback(result.copy(bufferedCount = count)) }
64
+ } else {
65
+ val count = buffer?.count() ?: 0
66
+ mainHandler.post { callback(result.copy(bufferedCount = count)) }
67
+ // After a successful send, try to flush buffered items
68
+ if (result.success && count > 0) {
69
+ flushBuffer(httpConfig, onFlushProgress)
70
+ }
71
+ }
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Flush buffered locations to the server.
77
+ * Called after a successful send or on network reconnect.
78
+ */
79
+ fun flushBuffer(onProgress: ((BGLHttpResult) -> Unit)?) {
80
+ val httpConfig = config ?: return
81
+ flushBuffer(httpConfig, onProgress)
82
+ }
83
+
84
+ private fun flushBuffer(httpConfig: BGLHttpConfig, onProgress: ((BGLHttpResult) -> Unit)?) {
85
+ val locationBuffer = buffer ?: return
86
+ if (isFlushing) return
87
+ isFlushing = true
88
+
89
+ executor.execute {
90
+ try {
91
+ while (true) {
92
+ val batch = locationBuffer.peek(FLUSH_BATCH_SIZE)
93
+ if (batch.isEmpty()) break
94
+
95
+ val successIds = mutableListOf<Long>()
96
+ for ((id, location) in batch) {
97
+ val result = executePost(httpConfig, location)
98
+ if (result.success) {
99
+ successIds.add(id)
100
+ } else {
101
+ // Stop flushing on first failure — server is likely unavailable
102
+ if (successIds.isNotEmpty()) {
103
+ locationBuffer.remove(successIds)
104
+ }
105
+ val count = locationBuffer.count()
106
+ mainHandler.post {
107
+ onProgress?.invoke(result.copy(bufferedCount = count))
108
+ }
109
+ return@execute
110
+ }
111
+ }
112
+ if (successIds.isNotEmpty()) {
113
+ locationBuffer.remove(successIds)
114
+ }
115
+ }
116
+ val count = locationBuffer.count()
117
+ if (count == 0) {
118
+ Log.d(TAG, "Buffer flush complete — all locations sent")
119
+ }
120
+ mainHandler.post {
121
+ onProgress?.invoke(BGLHttpResult(
122
+ statusCode = 200,
123
+ success = true,
124
+ responseText = "",
125
+ bufferedCount = count
126
+ ))
127
+ }
128
+ } finally {
129
+ isFlushing = false
130
+ }
131
+ }
132
+ }
133
+
134
+ fun shutdown() {
135
+ executor.shutdown()
136
+ }
137
+
138
+ private fun executePost(httpConfig: BGLHttpConfig, location: BGLLocationData): BGLHttpResult {
139
+ return try {
140
+ val url = URL(httpConfig.url)
141
+ val conn = url.openConnection() as HttpURLConnection
142
+ conn.requestMethod = "POST"
143
+ conn.setRequestProperty("Content-Type", "application/json")
144
+
145
+ for ((key, value) in httpConfig.headers) {
146
+ conn.setRequestProperty(key, value)
147
+ }
148
+
149
+ conn.doOutput = true
150
+ conn.connectTimeout = CONNECT_TIMEOUT
151
+ conn.readTimeout = READ_TIMEOUT
152
+
153
+ val body = BGLLocationHelpers.buildLocationBody(location)
154
+
155
+ OutputStreamWriter(conn.outputStream).use { writer ->
156
+ writer.write(body.toString())
157
+ writer.flush()
158
+ }
159
+
160
+ val statusCode = conn.responseCode
161
+ val responseText = try {
162
+ val stream = if (statusCode < 400) conn.inputStream else conn.errorStream
163
+ BufferedReader(InputStreamReader(stream)).use { it.readText() }
164
+ } catch (_: Exception) {
165
+ ""
166
+ }
167
+
168
+ conn.disconnect()
169
+
170
+ Log.d(TAG, "HTTP POST → ${httpConfig.url} → $statusCode")
171
+
172
+ BGLHttpResult(
173
+ statusCode = statusCode,
174
+ success = statusCode in 200..299,
175
+ responseText = responseText
176
+ )
177
+ } catch (e: Exception) {
178
+ Log.e(TAG, "HTTP POST failed: ${e.message}")
179
+ BGLHttpResult(
180
+ statusCode = 0,
181
+ success = false,
182
+ responseText = "",
183
+ error = e.message
184
+ )
185
+ }
186
+ }
187
+ }
@@ -0,0 +1,152 @@
1
+ package dev.bglocation.core.http
2
+
3
+ import dev.bglocation.core.BGLLocationBufferConfig
4
+ import dev.bglocation.core.BGLLocationData
5
+ import android.content.ContentValues
6
+ import android.content.Context
7
+ import android.database.sqlite.SQLiteDatabase
8
+ import android.database.sqlite.SQLiteOpenHelper
9
+ import android.util.Log
10
+
11
+ /**
12
+ * SQLite-backed offline buffer for location data.
13
+ *
14
+ * When HTTP upload fails, locations are stored locally and retried later.
15
+ * The buffer enforces a configurable maximum size — oldest entries are dropped
16
+ * when the limit is exceeded.
17
+ */
18
+ class BGLLocationBuffer(context: Context) : SQLiteOpenHelper(context, DB_NAME, null, DB_VERSION) {
19
+
20
+ companion object {
21
+ private const val TAG = "BGLocation"
22
+ internal const val DB_NAME = "bgl_location_buffer.db"
23
+ internal const val DB_VERSION = 2
24
+ internal const val TABLE = "buffered_locations"
25
+ }
26
+
27
+ private var maxSize: Int = 1000
28
+
29
+ /** Called when buffer overflow triggers auto-trim. Parameter is the number of dropped entries. */
30
+ var onOverflow: ((Int) -> Unit)? = null
31
+
32
+ fun configure(bufferConfig: BGLLocationBufferConfig?) {
33
+ maxSize = bufferConfig?.maxSize ?: 1000
34
+ }
35
+
36
+ override fun onCreate(db: SQLiteDatabase) {
37
+ db.execSQL("""
38
+ CREATE TABLE $TABLE (
39
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
40
+ latitude REAL NOT NULL,
41
+ longitude REAL NOT NULL,
42
+ accuracy REAL NOT NULL,
43
+ speed REAL NOT NULL,
44
+ heading REAL NOT NULL,
45
+ altitude REAL NOT NULL,
46
+ timestamp INTEGER NOT NULL,
47
+ is_moving INTEGER NOT NULL,
48
+ is_mock INTEGER NOT NULL DEFAULT 0,
49
+ created_at INTEGER NOT NULL DEFAULT (strftime('%s','now') * 1000)
50
+ )
51
+ """.trimIndent())
52
+ }
53
+
54
+ override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
55
+ // Additive migrations — preserve buffered locations across schema upgrades.
56
+ // Each version block adds only the delta.
57
+ if (oldVersion < 2) {
58
+ db.execSQL("ALTER TABLE $TABLE ADD COLUMN is_mock INTEGER NOT NULL DEFAULT 0")
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Store a failed location in the buffer.
64
+ * If the buffer exceeds [maxSize], the oldest entries are removed.
65
+ */
66
+ fun add(location: BGLLocationData) {
67
+ val db = writableDatabase
68
+ val values = ContentValues().apply {
69
+ put("latitude", location.latitude)
70
+ put("longitude", location.longitude)
71
+ put("accuracy", location.accuracy.toDouble())
72
+ put("speed", location.speed.toDouble())
73
+ put("heading", location.heading.toDouble())
74
+ put("altitude", location.altitude)
75
+ put("timestamp", location.timestamp)
76
+ put("is_moving", if (location.isMoving) 1 else 0)
77
+ put("is_mock", if (location.isMock) 1 else 0)
78
+ }
79
+ db.insert(TABLE, null, values)
80
+ trimBuffer(db)
81
+ }
82
+
83
+ /**
84
+ * Retrieve the oldest [limit] buffered locations (FIFO order).
85
+ * Returns pairs of (rowId, BGLLocationData) for deletion after successful send.
86
+ */
87
+ fun peek(limit: Int = 50): List<Pair<Long, BGLLocationData>> {
88
+ val db = readableDatabase
89
+ val cursor = db.query(
90
+ TABLE, null, null, null, null, null,
91
+ "id ASC", limit.toString()
92
+ )
93
+ val results = mutableListOf<Pair<Long, BGLLocationData>>()
94
+ cursor.use {
95
+ while (it.moveToNext()) {
96
+ val id = it.getLong(it.getColumnIndexOrThrow("id"))
97
+ val data = BGLLocationData(
98
+ latitude = it.getDouble(it.getColumnIndexOrThrow("latitude")),
99
+ longitude = it.getDouble(it.getColumnIndexOrThrow("longitude")),
100
+ accuracy = it.getFloat(it.getColumnIndexOrThrow("accuracy")),
101
+ speed = it.getFloat(it.getColumnIndexOrThrow("speed")),
102
+ heading = it.getFloat(it.getColumnIndexOrThrow("heading")),
103
+ altitude = it.getDouble(it.getColumnIndexOrThrow("altitude")),
104
+ timestamp = it.getLong(it.getColumnIndexOrThrow("timestamp")),
105
+ isMoving = it.getInt(it.getColumnIndexOrThrow("is_moving")) == 1,
106
+ isMock = it.getInt(it.getColumnIndexOrThrow("is_mock")) == 1
107
+ )
108
+ results.add(id to data)
109
+ }
110
+ }
111
+ return results
112
+ }
113
+
114
+ /**
115
+ * Remove specific entries by their row IDs after successful send.
116
+ */
117
+ fun remove(ids: List<Long>) {
118
+ if (ids.isEmpty()) return
119
+ val db = writableDatabase
120
+ val placeholders = ids.joinToString(",") { "?" }
121
+ val args = ids.map { it.toString() }.toTypedArray()
122
+ db.delete(TABLE, "id IN ($placeholders)", args)
123
+ }
124
+
125
+ /** Current number of buffered locations. */
126
+ fun count(): Int {
127
+ val db = readableDatabase
128
+ val cursor = db.rawQuery("SELECT COUNT(*) FROM $TABLE", null)
129
+ cursor.use {
130
+ return if (it.moveToFirst()) it.getInt(0) else 0
131
+ }
132
+ }
133
+
134
+ /** Remove all buffered locations. */
135
+ fun clear() {
136
+ writableDatabase.delete(TABLE, null, null)
137
+ }
138
+
139
+ /**
140
+ * Trim the buffer to [maxSize] by removing oldest entries.
141
+ */
142
+ private fun trimBuffer(db: SQLiteDatabase) {
143
+ val overflow = count() - maxSize
144
+ if (overflow <= 0) return
145
+ db.execSQL(
146
+ "DELETE FROM $TABLE WHERE id IN (SELECT id FROM $TABLE ORDER BY id ASC LIMIT ?)",
147
+ arrayOf(overflow)
148
+ )
149
+ Log.d(TAG, "BGLLocationBuffer trimmed $overflow oldest entries (maxSize=$maxSize)")
150
+ onOverflow?.invoke(overflow)
151
+ }
152
+ }
@@ -0,0 +1,16 @@
1
+ package dev.bglocation.core.license
2
+
3
+ /**
4
+ * Build-time configuration injected by the consuming plugin bridge.
5
+ * The bridge sets [buildEpoch] during plugin initialization.
6
+ * Used for update gating: licenses with `exp < buildEpoch` degrade to trial mode.
7
+ */
8
+ object BGLBuildConfig {
9
+ /**
10
+ * Unix epoch seconds when the consuming plugin was built.
11
+ * Set by the bridge during initialization (e.g. from Gradle BuildConfig).
12
+ * Default 0 means update gating is disabled.
13
+ */
14
+ @JvmStatic
15
+ var buildEpoch: Long = 0L
16
+ }