@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,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
|
+
}
|