@kafitra/react-native-live-tracking 0.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/LICENSE +21 -0
- package/README.md +396 -0
- package/android/build.gradle +71 -0
- package/android/gradle.properties +7 -0
- package/android/src/main/AndroidManifest.xml +40 -0
- package/android/src/main/java/com/livetracking/LiveTrackingModuleImpl.kt +728 -0
- package/android/src/main/java/com/livetracking/LiveTrackingPackage.kt +16 -0
- package/android/src/main/java/com/livetracking/location/LocationEngine.kt +93 -0
- package/android/src/main/java/com/livetracking/network/NetworkListener.kt +127 -0
- package/android/src/main/java/com/livetracking/optimizer/ActivityRecognitionHandler.kt +248 -0
- package/android/src/main/java/com/livetracking/optimizer/MotionSleepManager.kt +130 -0
- package/android/src/main/java/com/livetracking/permissions/PermissionHandler.kt +145 -0
- package/android/src/main/java/com/livetracking/queue/QueueEngine.kt +167 -0
- package/android/src/main/java/com/livetracking/queue/QueuedLocation.kt +16 -0
- package/android/src/main/java/com/livetracking/queue/TrackingDatabase.kt +239 -0
- package/android/src/main/java/com/livetracking/receiver/BootReceiver.kt +53 -0
- package/android/src/main/java/com/livetracking/service/TrackingForegroundService.kt +145 -0
- package/android/src/main/java/com/livetracking/sync/FirebaseSyncEngine.kt +277 -0
- package/android/src/main/java/com/livetracking/sync/LocationDataPoint.kt +31 -0
- package/android/src/main/java/com/livetracking/sync/SyncEngineController.kt +220 -0
- package/android/src/main/java/com/livetracking/sync/SyncTargetConfig.kt +20 -0
- package/android/src/main/java/com/livetracking/sync/TargetHandler.kt +601 -0
- package/android/src/newarch/java/com/livetracking/LiveTrackingModule.kt +64 -0
- package/android/src/oldarch/java/com/livetracking/LiveTrackingModule.kt +70 -0
- package/android/src/test/java/com/livetracking/BackoffCalculationTest.kt +216 -0
- package/android/src/test/java/com/livetracking/BatchAccumulatorTest.kt +391 -0
- package/android/src/test/java/com/livetracking/BootReceiverTest.kt +247 -0
- package/android/src/test/java/com/livetracking/FirebaseSyncEngineTest.kt +337 -0
- package/android/src/test/java/com/livetracking/LocationEngineTest.kt +202 -0
- package/android/src/test/java/com/livetracking/MotionSleepManagerTest.kt +420 -0
- package/android/src/test/java/com/livetracking/OfflineQueueTest.kt +462 -0
- package/android/src/test/java/com/livetracking/PermissionHandlerTest.kt +200 -0
- package/android/src/test/java/com/livetracking/QueueEngineTest.kt +335 -0
- package/android/src/test/java/com/livetracking/SyncEngineControllerTest.kt +855 -0
- package/ios/ActivityRecognitionHandler.swift +196 -0
- package/ios/BackgroundModeHelper.swift +132 -0
- package/ios/FirebaseSyncEngine.swift +276 -0
- package/ios/LiveTracking-Bridging-Header.h +2 -0
- package/ios/LiveTracking.m +37 -0
- package/ios/LiveTracking.swift +773 -0
- package/ios/LocationDataPoint.swift +56 -0
- package/ios/LocationEngine.swift +160 -0
- package/ios/MotionSleepManager.swift +151 -0
- package/ios/NetworkListener.swift +105 -0
- package/ios/OfflineQueueManager.swift +503 -0
- package/ios/PermissionHandler.swift +148 -0
- package/ios/QueueEngine.swift +249 -0
- package/ios/SyncEngineController.swift +396 -0
- package/ios/SyncTargetConfig.swift +36 -0
- package/ios/TargetHandler.swift +715 -0
- package/ios/Tests/ActivityRecognitionHandlerTests.swift +259 -0
- package/ios/Tests/FirebaseSyncEngineTests.swift +303 -0
- package/ios/Tests/LocationEngineTests.swift +244 -0
- package/ios/Tests/MotionSleepManagerTests.swift +355 -0
- package/ios/Tests/NetworkListenerTests.swift +188 -0
- package/ios/Tests/OfflineQueueFlushTests.swift +375 -0
- package/ios/Tests/PermissionHandlerTests.swift +238 -0
- package/ios/Tests/QueueEngineTests.swift +346 -0
- package/ios/TrackingCleanup.swift +93 -0
- package/ios/TrackingNotificationManager.swift +187 -0
- package/lib/commonjs/EventEmitter.js +113 -0
- package/lib/commonjs/EventEmitter.js.map +1 -0
- package/lib/commonjs/LiveTracking.js +134 -0
- package/lib/commonjs/LiveTracking.js.map +1 -0
- package/lib/commonjs/NativeLiveTracking.js +21 -0
- package/lib/commonjs/NativeLiveTracking.js.map +1 -0
- package/lib/commonjs/filters/distanceTimeFilter.js +63 -0
- package/lib/commonjs/filters/distanceTimeFilter.js.map +1 -0
- package/lib/commonjs/index.js +103 -0
- package/lib/commonjs/index.js.map +1 -0
- package/lib/commonjs/serialization/locationSerializer.js +51 -0
- package/lib/commonjs/serialization/locationSerializer.js.map +1 -0
- package/lib/commonjs/types.js +77 -0
- package/lib/commonjs/types.js.map +1 -0
- package/lib/commonjs/utils/distance.js +63 -0
- package/lib/commonjs/utils/distance.js.map +1 -0
- package/lib/commonjs/utils/retry.js +80 -0
- package/lib/commonjs/utils/retry.js.map +1 -0
- package/lib/commonjs/validation.js +463 -0
- package/lib/commonjs/validation.js.map +1 -0
- package/lib/module/EventEmitter.js +105 -0
- package/lib/module/EventEmitter.js.map +1 -0
- package/lib/module/LiveTracking.js +127 -0
- package/lib/module/LiveTracking.js.map +1 -0
- package/lib/module/NativeLiveTracking.js +16 -0
- package/lib/module/NativeLiveTracking.js.map +1 -0
- package/lib/module/filters/distanceTimeFilter.js +58 -0
- package/lib/module/filters/distanceTimeFilter.js.map +1 -0
- package/lib/module/index.js +32 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/serialization/locationSerializer.js +45 -0
- package/lib/module/serialization/locationSerializer.js.map +1 -0
- package/lib/module/types.js +94 -0
- package/lib/module/types.js.map +1 -0
- package/lib/module/utils/distance.js +56 -0
- package/lib/module/utils/distance.js.map +1 -0
- package/lib/module/utils/retry.js +72 -0
- package/lib/module/utils/retry.js.map +1 -0
- package/lib/module/validation.js +456 -0
- package/lib/module/validation.js.map +1 -0
- package/lib/typescript/EventEmitter.d.ts +65 -0
- package/lib/typescript/EventEmitter.d.ts.map +1 -0
- package/lib/typescript/LiveTracking.d.ts +23 -0
- package/lib/typescript/LiveTracking.d.ts.map +1 -0
- package/lib/typescript/NativeLiveTracking.d.ts +25 -0
- package/lib/typescript/NativeLiveTracking.d.ts.map +1 -0
- package/lib/typescript/filters/distanceTimeFilter.d.ts +44 -0
- package/lib/typescript/filters/distanceTimeFilter.d.ts.map +1 -0
- package/lib/typescript/index.d.ts +21 -0
- package/lib/typescript/index.d.ts.map +1 -0
- package/lib/typescript/serialization/locationSerializer.d.ts +39 -0
- package/lib/typescript/serialization/locationSerializer.d.ts.map +1 -0
- package/lib/typescript/types.d.ts +217 -0
- package/lib/typescript/types.d.ts.map +1 -0
- package/lib/typescript/utils/distance.d.ts +38 -0
- package/lib/typescript/utils/distance.d.ts.map +1 -0
- package/lib/typescript/utils/retry.d.ts +60 -0
- package/lib/typescript/utils/retry.d.ts.map +1 -0
- package/lib/typescript/validation.d.ts +26 -0
- package/lib/typescript/validation.d.ts.map +1 -0
- package/package.json +126 -0
- package/react-native-live-tracking.podspec +47 -0
- package/src/EventEmitter.ts +118 -0
- package/src/LiveTracking.ts +159 -0
- package/src/NativeLiveTracking.ts +29 -0
- package/src/filters/distanceTimeFilter.ts +75 -0
- package/src/index.ts +51 -0
- package/src/serialization/locationSerializer.ts +57 -0
- package/src/types.ts +252 -0
- package/src/utils/distance.ts +68 -0
- package/src/utils/retry.ts +75 -0
- package/src/validation.ts +552 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
package com.livetracking.service
|
|
2
|
+
|
|
3
|
+
import android.app.Notification
|
|
4
|
+
import android.app.NotificationChannel
|
|
5
|
+
import android.app.NotificationManager
|
|
6
|
+
import android.app.Service
|
|
7
|
+
import android.content.Context
|
|
8
|
+
import android.content.Intent
|
|
9
|
+
import android.os.Build
|
|
10
|
+
import android.os.IBinder
|
|
11
|
+
import androidx.core.app.NotificationCompat
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Android Foreground Service for persistent location tracking.
|
|
15
|
+
* Keeps the app alive in the background with a persistent notification,
|
|
16
|
+
* preventing the OS from killing the process.
|
|
17
|
+
*
|
|
18
|
+
* Requirements: 2.1, 2.4, 2.5
|
|
19
|
+
*/
|
|
20
|
+
class TrackingForegroundService : Service() {
|
|
21
|
+
|
|
22
|
+
companion object {
|
|
23
|
+
const val DEFAULT_CHANNEL_ID = "live_tracking_channel"
|
|
24
|
+
const val DEFAULT_CHANNEL_NAME = "Live Tracking"
|
|
25
|
+
const val NOTIFICATION_ID = 1001
|
|
26
|
+
|
|
27
|
+
private const val EXTRA_NOTIFICATION_TITLE = "extra_notification_title"
|
|
28
|
+
private const val EXTRA_NOTIFICATION_TEXT = "extra_notification_text"
|
|
29
|
+
private const val EXTRA_NOTIFICATION_ICON = "extra_notification_icon"
|
|
30
|
+
private const val EXTRA_CHANNEL_ID = "extra_channel_id"
|
|
31
|
+
private const val EXTRA_CHANNEL_NAME = "extra_channel_name"
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Start the foreground service with notification configuration.
|
|
35
|
+
*/
|
|
36
|
+
fun start(
|
|
37
|
+
context: Context,
|
|
38
|
+
title: String = "Live Tracking",
|
|
39
|
+
text: String = "Tracking your location",
|
|
40
|
+
icon: String? = null,
|
|
41
|
+
channelId: String = DEFAULT_CHANNEL_ID,
|
|
42
|
+
channelName: String = DEFAULT_CHANNEL_NAME
|
|
43
|
+
) {
|
|
44
|
+
val intent = Intent(context, TrackingForegroundService::class.java).apply {
|
|
45
|
+
putExtra(EXTRA_NOTIFICATION_TITLE, title)
|
|
46
|
+
putExtra(EXTRA_NOTIFICATION_TEXT, text)
|
|
47
|
+
putExtra(EXTRA_CHANNEL_ID, channelId)
|
|
48
|
+
putExtra(EXTRA_CHANNEL_NAME, channelName)
|
|
49
|
+
if (icon != null) {
|
|
50
|
+
putExtra(EXTRA_NOTIFICATION_ICON, icon)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
55
|
+
context.startForegroundService(intent)
|
|
56
|
+
} else {
|
|
57
|
+
context.startService(intent)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Stop the foreground service.
|
|
63
|
+
*/
|
|
64
|
+
fun stop(context: Context) {
|
|
65
|
+
val intent = Intent(context, TrackingForegroundService::class.java)
|
|
66
|
+
context.stopService(intent)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
override fun onBind(intent: Intent?): IBinder? {
|
|
71
|
+
// Not a bound service
|
|
72
|
+
return null
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
|
76
|
+
val title = intent?.getStringExtra(EXTRA_NOTIFICATION_TITLE) ?: "Live Tracking"
|
|
77
|
+
val text = intent?.getStringExtra(EXTRA_NOTIFICATION_TEXT) ?: "Tracking your location"
|
|
78
|
+
val iconName = intent?.getStringExtra(EXTRA_NOTIFICATION_ICON)
|
|
79
|
+
val channelId = intent?.getStringExtra(EXTRA_CHANNEL_ID) ?: DEFAULT_CHANNEL_ID
|
|
80
|
+
val channelName = intent?.getStringExtra(EXTRA_CHANNEL_NAME) ?: DEFAULT_CHANNEL_NAME
|
|
81
|
+
|
|
82
|
+
createNotificationChannel(channelId, channelName)
|
|
83
|
+
|
|
84
|
+
val notification = buildNotification(title, text, iconName, channelId)
|
|
85
|
+
startForeground(NOTIFICATION_ID, notification)
|
|
86
|
+
|
|
87
|
+
return START_STICKY
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
override fun onDestroy() {
|
|
91
|
+
super.onDestroy()
|
|
92
|
+
stopForeground(STOP_FOREGROUND_REMOVE)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Creates the notification channel for Android O and above.
|
|
97
|
+
*/
|
|
98
|
+
private fun createNotificationChannel(channelId: String, channelName: String) {
|
|
99
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
100
|
+
val channel = NotificationChannel(
|
|
101
|
+
channelId,
|
|
102
|
+
channelName,
|
|
103
|
+
NotificationManager.IMPORTANCE_LOW
|
|
104
|
+
).apply {
|
|
105
|
+
description = "Notification channel for live location tracking"
|
|
106
|
+
setShowBadge(false)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
|
110
|
+
notificationManager.createNotificationChannel(channel)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Builds the persistent notification displayed while tracking is active.
|
|
116
|
+
*/
|
|
117
|
+
private fun buildNotification(
|
|
118
|
+
title: String,
|
|
119
|
+
text: String,
|
|
120
|
+
iconName: String?,
|
|
121
|
+
channelId: String
|
|
122
|
+
): Notification {
|
|
123
|
+
val iconResId = if (iconName != null) {
|
|
124
|
+
resources.getIdentifier(iconName, "drawable", packageName)
|
|
125
|
+
} else {
|
|
126
|
+
0
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Fallback to app icon if custom icon not found
|
|
130
|
+
val finalIconResId = if (iconResId != 0) {
|
|
131
|
+
iconResId
|
|
132
|
+
} else {
|
|
133
|
+
applicationInfo.icon
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return NotificationCompat.Builder(this, channelId)
|
|
137
|
+
.setContentTitle(title)
|
|
138
|
+
.setContentText(text)
|
|
139
|
+
.setSmallIcon(finalIconResId)
|
|
140
|
+
.setOngoing(true)
|
|
141
|
+
.setPriority(NotificationCompat.PRIORITY_LOW)
|
|
142
|
+
.setCategory(NotificationCompat.CATEGORY_SERVICE)
|
|
143
|
+
.build()
|
|
144
|
+
}
|
|
145
|
+
}
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
package com.livetracking.sync
|
|
2
|
+
|
|
3
|
+
import com.google.firebase.database.FirebaseDatabase
|
|
4
|
+
import com.google.firebase.firestore.FirebaseFirestore
|
|
5
|
+
import com.livetracking.queue.QueuedLocation
|
|
6
|
+
import java.util.concurrent.ExecutorService
|
|
7
|
+
import java.util.concurrent.Executors
|
|
8
|
+
import kotlin.math.pow
|
|
9
|
+
import kotlin.random.Random
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Callback interface for Firebase sync operations.
|
|
13
|
+
*/
|
|
14
|
+
interface SyncCallback {
|
|
15
|
+
fun onSuccess()
|
|
16
|
+
fun onError(errorCode: String, message: String)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* FirebaseSyncEngine handles writing location data to Firebase.
|
|
21
|
+
*
|
|
22
|
+
* Supports both Firebase Realtime Database (RTDB) and Cloud Firestore.
|
|
23
|
+
* Implements retry with exponential backoff for resilient writes.
|
|
24
|
+
*
|
|
25
|
+
* - updateCurrentLocation: overwrites the current location at currentLocationPath
|
|
26
|
+
* - pushHistoryBatch: appends a batch of locations to historyPath
|
|
27
|
+
*
|
|
28
|
+
* @param service Either "RTDB" or "Firestore"
|
|
29
|
+
* @param currentLocationPath Firebase path for current location (overwrite)
|
|
30
|
+
* @param historyPath Firebase path for history locations (append)
|
|
31
|
+
*/
|
|
32
|
+
class FirebaseSyncEngine(
|
|
33
|
+
private val service: String,
|
|
34
|
+
private val currentLocationPath: String?,
|
|
35
|
+
private val historyPath: String?
|
|
36
|
+
) {
|
|
37
|
+
|
|
38
|
+
companion object {
|
|
39
|
+
private const val BASE_DELAY_MS = 1000L
|
|
40
|
+
private const val MULTIPLIER = 2.0
|
|
41
|
+
private const val JITTER_MS = 200L
|
|
42
|
+
private const val MAX_RETRIES_CURRENT = 3
|
|
43
|
+
private const val MAX_RETRIES_HISTORY = 5
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
private val executor: ExecutorService = Executors.newSingleThreadExecutor()
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Update the current location at the configured currentLocationPath.
|
|
50
|
+
* Uses set/update (overwrite) semantics.
|
|
51
|
+
* Retries up to 3 times with exponential backoff on failure.
|
|
52
|
+
*
|
|
53
|
+
* @param latitude Location latitude
|
|
54
|
+
* @param longitude Location longitude
|
|
55
|
+
* @param timestamp Unix timestamp in milliseconds
|
|
56
|
+
* @param accuracy Location accuracy in meters
|
|
57
|
+
* @param speed Speed in m/s, nullable
|
|
58
|
+
* @param callback SyncCallback for success/error notification
|
|
59
|
+
*/
|
|
60
|
+
fun updateCurrentLocation(
|
|
61
|
+
latitude: Double,
|
|
62
|
+
longitude: Double,
|
|
63
|
+
timestamp: Long,
|
|
64
|
+
accuracy: Float,
|
|
65
|
+
speed: Float?,
|
|
66
|
+
callback: SyncCallback
|
|
67
|
+
) {
|
|
68
|
+
if (currentLocationPath == null) {
|
|
69
|
+
callback.onError("NO_PATH", "currentLocationPath is not configured")
|
|
70
|
+
return
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
val data = hashMapOf<String, Any>(
|
|
74
|
+
"latitude" to latitude,
|
|
75
|
+
"longitude" to longitude,
|
|
76
|
+
"timestamp" to timestamp,
|
|
77
|
+
"accuracy" to accuracy.toDouble(),
|
|
78
|
+
"updatedAt" to timestamp
|
|
79
|
+
)
|
|
80
|
+
speed?.let { data["speed"] = it.toDouble() }
|
|
81
|
+
|
|
82
|
+
executor.execute {
|
|
83
|
+
executeWithRetry(
|
|
84
|
+
maxRetries = MAX_RETRIES_CURRENT,
|
|
85
|
+
operation = { attemptCallback ->
|
|
86
|
+
writeCurrentLocation(data, attemptCallback)
|
|
87
|
+
},
|
|
88
|
+
callback = callback
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Push a batch of locations to the configured historyPath.
|
|
95
|
+
* Uses push/append semantics (each location gets a unique key).
|
|
96
|
+
* Retries up to 5 times with exponential backoff on failure.
|
|
97
|
+
*
|
|
98
|
+
* @param locations List of QueuedLocation to send
|
|
99
|
+
* @param callback SyncCallback for success/error notification
|
|
100
|
+
*/
|
|
101
|
+
fun pushHistoryBatch(locations: List<QueuedLocation>, callback: SyncCallback) {
|
|
102
|
+
if (historyPath == null) {
|
|
103
|
+
callback.onError("NO_PATH", "historyPath is not configured")
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (locations.isEmpty()) {
|
|
108
|
+
callback.onSuccess()
|
|
109
|
+
return
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
executor.execute {
|
|
113
|
+
executeWithRetry(
|
|
114
|
+
maxRetries = MAX_RETRIES_HISTORY,
|
|
115
|
+
operation = { attemptCallback ->
|
|
116
|
+
writeHistoryBatch(locations, attemptCallback)
|
|
117
|
+
},
|
|
118
|
+
callback = callback
|
|
119
|
+
)
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Shutdown the executor service. Call when the engine is no longer needed.
|
|
125
|
+
*/
|
|
126
|
+
fun shutdown() {
|
|
127
|
+
executor.shutdown()
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// --- Private implementation ---
|
|
131
|
+
|
|
132
|
+
private fun writeCurrentLocation(data: Map<String, Any>, callback: SyncCallback) {
|
|
133
|
+
when (service) {
|
|
134
|
+
"RTDB" -> writeCurrentLocationRTDB(data, callback)
|
|
135
|
+
"Firestore" -> writeCurrentLocationFirestore(data, callback)
|
|
136
|
+
else -> callback.onError("INVALID_SERVICE", "Unknown service: $service. Use 'RTDB' or 'Firestore'.")
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private fun writeCurrentLocationRTDB(data: Map<String, Any>, callback: SyncCallback) {
|
|
141
|
+
val reference = FirebaseDatabase.getInstance().getReference(currentLocationPath!!)
|
|
142
|
+
reference.setValue(data)
|
|
143
|
+
.addOnSuccessListener { callback.onSuccess() }
|
|
144
|
+
.addOnFailureListener { e ->
|
|
145
|
+
callback.onError("FIREBASE_WRITE_FAILED", e.message ?: "RTDB write failed")
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private fun writeCurrentLocationFirestore(data: Map<String, Any>, callback: SyncCallback) {
|
|
150
|
+
val document = FirebaseFirestore.getInstance().document(currentLocationPath!!)
|
|
151
|
+
document.set(data)
|
|
152
|
+
.addOnSuccessListener { callback.onSuccess() }
|
|
153
|
+
.addOnFailureListener { e ->
|
|
154
|
+
callback.onError("FIREBASE_WRITE_FAILED", e.message ?: "Firestore write failed")
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
private fun writeHistoryBatch(locations: List<QueuedLocation>, callback: SyncCallback) {
|
|
159
|
+
when (service) {
|
|
160
|
+
"RTDB" -> writeHistoryBatchRTDB(locations, callback)
|
|
161
|
+
"Firestore" -> writeHistoryBatchFirestore(locations, callback)
|
|
162
|
+
else -> callback.onError("INVALID_SERVICE", "Unknown service: $service. Use 'RTDB' or 'Firestore'.")
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
private fun writeHistoryBatchRTDB(locations: List<QueuedLocation>, callback: SyncCallback) {
|
|
167
|
+
val reference = FirebaseDatabase.getInstance().getReference(historyPath!!)
|
|
168
|
+
val updates = hashMapOf<String, Any>()
|
|
169
|
+
|
|
170
|
+
for (location in locations) {
|
|
171
|
+
val key = reference.push().key ?: continue
|
|
172
|
+
updates[key] = locationToMap(location)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (updates.isEmpty()) {
|
|
176
|
+
callback.onSuccess()
|
|
177
|
+
return
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
reference.updateChildren(updates)
|
|
181
|
+
.addOnSuccessListener { callback.onSuccess() }
|
|
182
|
+
.addOnFailureListener { e ->
|
|
183
|
+
callback.onError("FIREBASE_WRITE_FAILED", e.message ?: "RTDB batch write failed")
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
private fun writeHistoryBatchFirestore(locations: List<QueuedLocation>, callback: SyncCallback) {
|
|
188
|
+
val firestore = FirebaseFirestore.getInstance()
|
|
189
|
+
val collection = firestore.collection(historyPath!!)
|
|
190
|
+
val batch = firestore.batch()
|
|
191
|
+
|
|
192
|
+
for (location in locations) {
|
|
193
|
+
val docRef = collection.document()
|
|
194
|
+
batch.set(docRef, locationToMap(location))
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
batch.commit()
|
|
198
|
+
.addOnSuccessListener { callback.onSuccess() }
|
|
199
|
+
.addOnFailureListener { e ->
|
|
200
|
+
callback.onError("FIREBASE_WRITE_FAILED", e.message ?: "Firestore batch write failed")
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
private fun locationToMap(location: QueuedLocation): Map<String, Any?> {
|
|
205
|
+
val map = hashMapOf<String, Any?>(
|
|
206
|
+
"latitude" to location.latitude,
|
|
207
|
+
"longitude" to location.longitude,
|
|
208
|
+
"timestamp" to location.timestamp,
|
|
209
|
+
"accuracy" to location.accuracy.toDouble(),
|
|
210
|
+
"speed" to location.speed?.toDouble()
|
|
211
|
+
)
|
|
212
|
+
location.altitude?.let { map["altitude"] = it }
|
|
213
|
+
location.bearing?.let { map["bearing"] = it.toDouble() }
|
|
214
|
+
return map
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Execute an operation with exponential backoff retry.
|
|
219
|
+
*
|
|
220
|
+
* Retry delay formula: baseDelay * 2^(attempt-1) ± jitter
|
|
221
|
+
* - Base delay: 1000ms
|
|
222
|
+
* - Multiplier: 2x
|
|
223
|
+
* - Jitter: ±200ms (random)
|
|
224
|
+
*
|
|
225
|
+
* @param maxRetries Maximum number of retry attempts
|
|
226
|
+
* @param operation The Firebase write operation to execute
|
|
227
|
+
* @param callback Final callback after all retries exhausted or success
|
|
228
|
+
*/
|
|
229
|
+
private fun executeWithRetry(
|
|
230
|
+
maxRetries: Int,
|
|
231
|
+
operation: (SyncCallback) -> Unit,
|
|
232
|
+
callback: SyncCallback
|
|
233
|
+
) {
|
|
234
|
+
var attempt = 0
|
|
235
|
+
|
|
236
|
+
fun tryOperation() {
|
|
237
|
+
attempt++
|
|
238
|
+
operation(object : SyncCallback {
|
|
239
|
+
override fun onSuccess() {
|
|
240
|
+
callback.onSuccess()
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
override fun onError(errorCode: String, message: String) {
|
|
244
|
+
if (attempt >= maxRetries) {
|
|
245
|
+
callback.onError(errorCode, "Failed after $attempt attempts: $message")
|
|
246
|
+
} else {
|
|
247
|
+
val delay = calculateBackoffDelay(attempt)
|
|
248
|
+
try {
|
|
249
|
+
Thread.sleep(delay)
|
|
250
|
+
} catch (e: InterruptedException) {
|
|
251
|
+
Thread.currentThread().interrupt()
|
|
252
|
+
callback.onError("INTERRUPTED", "Retry interrupted")
|
|
253
|
+
return
|
|
254
|
+
}
|
|
255
|
+
tryOperation()
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
})
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
tryOperation()
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Calculate exponential backoff delay with jitter.
|
|
266
|
+
*
|
|
267
|
+
* Formula: baseDelay * 2^(attempt-1) ± random jitter
|
|
268
|
+
*
|
|
269
|
+
* @param attempt Current attempt number (1-indexed)
|
|
270
|
+
* @return Delay in milliseconds
|
|
271
|
+
*/
|
|
272
|
+
internal fun calculateBackoffDelay(attempt: Int): Long {
|
|
273
|
+
val exponentialDelay = (BASE_DELAY_MS * MULTIPLIER.pow((attempt - 1).toDouble())).toLong()
|
|
274
|
+
val jitter = Random.nextLong(-JITTER_MS, JITTER_MS + 1)
|
|
275
|
+
return maxOf(0L, exponentialDelay + jitter)
|
|
276
|
+
}
|
|
277
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
package com.livetracking.sync
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Represents a single location data point dispatched to sync targets.
|
|
5
|
+
*
|
|
6
|
+
* Required fields (always available from device sensors):
|
|
7
|
+
* - latitude, longitude, timestamp, accuracy
|
|
8
|
+
*
|
|
9
|
+
* Optional fields (may be unavailable depending on device/sensor state):
|
|
10
|
+
* - speed, altitude, bearing
|
|
11
|
+
*
|
|
12
|
+
* When optional fields are null, they should be omitted or written as null
|
|
13
|
+
* in the Firebase payload — never as placeholder values (0, -1, etc.).
|
|
14
|
+
*
|
|
15
|
+
* @param latitude Location latitude in degrees
|
|
16
|
+
* @param longitude Location longitude in degrees
|
|
17
|
+
* @param timestamp Unix timestamp in milliseconds
|
|
18
|
+
* @param accuracy Location accuracy in meters
|
|
19
|
+
* @param speed Speed in m/s, or null if unavailable
|
|
20
|
+
* @param altitude Altitude in meters, or null if unavailable
|
|
21
|
+
* @param bearing Bearing in degrees, or null if unavailable
|
|
22
|
+
*/
|
|
23
|
+
data class LocationDataPoint(
|
|
24
|
+
val latitude: Double,
|
|
25
|
+
val longitude: Double,
|
|
26
|
+
val timestamp: Long,
|
|
27
|
+
val accuracy: Float,
|
|
28
|
+
val speed: Float? = null,
|
|
29
|
+
val altitude: Double? = null,
|
|
30
|
+
val bearing: Float? = null
|
|
31
|
+
)
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
package com.livetracking.sync
|
|
2
|
+
|
|
3
|
+
import org.json.JSONArray
|
|
4
|
+
import org.json.JSONException
|
|
5
|
+
import org.json.JSONObject
|
|
6
|
+
import java.util.concurrent.ExecutorService
|
|
7
|
+
import java.util.concurrent.Executors
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* SyncEngineController replaces the monolithic FirebaseSyncEngine.
|
|
11
|
+
*
|
|
12
|
+
* It manages a list of TargetHandler instances — one per configured sync target.
|
|
13
|
+
* When a location arrives, it fans out to all handlers in parallel.
|
|
14
|
+
* Each handler independently manages batching, retry, and offline queue logic.
|
|
15
|
+
*
|
|
16
|
+
* Responsibilities:
|
|
17
|
+
* - Parse targets JSON and instantiate one TargetHandler per target
|
|
18
|
+
* - Dispatch location data to all handlers concurrently
|
|
19
|
+
* - Flush all partial batches on stop
|
|
20
|
+
* - Report per-target queued location counts
|
|
21
|
+
* - Clean up all handlers on shutdown
|
|
22
|
+
*
|
|
23
|
+
* @param targetsJson JSON array string of sync target configurations
|
|
24
|
+
* @param firebaseService The Firebase service type ("RTDB" or "Firestore")
|
|
25
|
+
* @param networkChecker Function that returns current online status
|
|
26
|
+
* @param offlineQueueProvider Optional provider for offline persistence
|
|
27
|
+
* @param eventListener Listener for error/warning events
|
|
28
|
+
* @throws IllegalArgumentException if targetsJson fails to parse or contains invalid targets
|
|
29
|
+
*/
|
|
30
|
+
class SyncEngineController(
|
|
31
|
+
targetsJson: String,
|
|
32
|
+
private val firebaseService: String,
|
|
33
|
+
private val networkChecker: () -> Boolean,
|
|
34
|
+
private val offlineQueueProvider: OfflineQueueProvider? = null,
|
|
35
|
+
private val eventListener: TargetEventListener? = null
|
|
36
|
+
) {
|
|
37
|
+
|
|
38
|
+
private val handlers: List<TargetHandler>
|
|
39
|
+
private val dispatchExecutor: ExecutorService = Executors.newCachedThreadPool()
|
|
40
|
+
|
|
41
|
+
init {
|
|
42
|
+
handlers = parseAndCreateHandlers(targetsJson)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Dispatch a location data point to all configured sync targets in parallel.
|
|
47
|
+
*
|
|
48
|
+
* Each target handler receives the location independently and decides
|
|
49
|
+
* whether to accumulate (batch), write immediately, or queue offline.
|
|
50
|
+
* A failure in one target does not block others.
|
|
51
|
+
*
|
|
52
|
+
* @param location The location data point to dispatch
|
|
53
|
+
*/
|
|
54
|
+
fun dispatchLocation(location: LocationDataPoint) {
|
|
55
|
+
for (handler in handlers) {
|
|
56
|
+
dispatchExecutor.execute {
|
|
57
|
+
handler.dispatch(location)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Flush all partially-filled batches for all targets.
|
|
64
|
+
*
|
|
65
|
+
* Called during stop() to ensure no accumulated data is lost.
|
|
66
|
+
* Blocks until all handlers have completed their flush.
|
|
67
|
+
*/
|
|
68
|
+
fun flushAll() {
|
|
69
|
+
for (handler in handlers) {
|
|
70
|
+
handler.flush()
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Get the number of queued locations for each configured target.
|
|
76
|
+
*
|
|
77
|
+
* Returns a map of target path → queued count.
|
|
78
|
+
* Targets with offlineQueue disabled report 0.
|
|
79
|
+
*
|
|
80
|
+
* @return Map of target path to queued location count
|
|
81
|
+
*/
|
|
82
|
+
fun getQueuedCounts(): Map<String, Int> {
|
|
83
|
+
val counts = mutableMapOf<String, Int>()
|
|
84
|
+
for (handler in handlers) {
|
|
85
|
+
counts[handler.targetPath] = handler.getQueuedCount()
|
|
86
|
+
}
|
|
87
|
+
return counts
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Shutdown all handlers and release resources.
|
|
92
|
+
*
|
|
93
|
+
* Cancels any in-progress retries and batch timers.
|
|
94
|
+
* After shutdown, no further dispatches will be processed.
|
|
95
|
+
*/
|
|
96
|
+
fun shutdown() {
|
|
97
|
+
for (handler in handlers) {
|
|
98
|
+
handler.shutdown()
|
|
99
|
+
}
|
|
100
|
+
dispatchExecutor.shutdown()
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Flush offline queues for all targets that have offlineQueue enabled.
|
|
105
|
+
* Called when network connectivity is restored.
|
|
106
|
+
*/
|
|
107
|
+
fun flushOfflineQueues() {
|
|
108
|
+
for (handler in handlers) {
|
|
109
|
+
handler.flushOfflineQueue()
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Get the list of configured target paths.
|
|
115
|
+
*
|
|
116
|
+
* @return List of target path strings
|
|
117
|
+
*/
|
|
118
|
+
fun getTargetPaths(): List<String> {
|
|
119
|
+
return handlers.map { it.targetPath }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// --- Private implementation ---
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Parse the targets JSON array and create one TargetHandler per target.
|
|
126
|
+
*
|
|
127
|
+
* @param targetsJson JSON array string of sync target configurations
|
|
128
|
+
* @return List of TargetHandler instances
|
|
129
|
+
* @throws IllegalArgumentException if JSON is invalid or targets are missing required fields
|
|
130
|
+
*/
|
|
131
|
+
private fun parseAndCreateHandlers(targetsJson: String): List<TargetHandler> {
|
|
132
|
+
val configs = parseTargetsJson(targetsJson)
|
|
133
|
+
return configs.map { config ->
|
|
134
|
+
val queueProvider = if (config.offlineQueue) offlineQueueProvider else null
|
|
135
|
+
TargetHandler(
|
|
136
|
+
config = config,
|
|
137
|
+
firebaseService = firebaseService,
|
|
138
|
+
networkChecker = networkChecker,
|
|
139
|
+
offlineQueueProvider = queueProvider,
|
|
140
|
+
eventListener = eventListener
|
|
141
|
+
)
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Parse a JSON array string into a list of SyncTargetConfig objects.
|
|
147
|
+
*
|
|
148
|
+
* Each target must have:
|
|
149
|
+
* - "path" (string, required)
|
|
150
|
+
* - "method" (string, required: "set", "push", or "update")
|
|
151
|
+
* - "batchSize" (int, optional, defaults to 1)
|
|
152
|
+
* - "offlineQueue" (boolean, optional, defaults to false)
|
|
153
|
+
*
|
|
154
|
+
* @param json JSON array string
|
|
155
|
+
* @return List of SyncTargetConfig
|
|
156
|
+
* @throws IllegalArgumentException if parsing fails or required fields are missing
|
|
157
|
+
*/
|
|
158
|
+
private fun parseTargetsJson(json: String): List<SyncTargetConfig> {
|
|
159
|
+
val jsonArray: JSONArray
|
|
160
|
+
try {
|
|
161
|
+
jsonArray = JSONArray(json)
|
|
162
|
+
} catch (e: JSONException) {
|
|
163
|
+
throw IllegalArgumentException(
|
|
164
|
+
"Failed to parse targets JSON: ${e.message}"
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (jsonArray.length() == 0) {
|
|
169
|
+
throw IllegalArgumentException(
|
|
170
|
+
"Targets array is empty — at least one sync target is required"
|
|
171
|
+
)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
val configs = mutableListOf<SyncTargetConfig>()
|
|
175
|
+
|
|
176
|
+
for (i in 0 until jsonArray.length()) {
|
|
177
|
+
val obj: JSONObject
|
|
178
|
+
try {
|
|
179
|
+
obj = jsonArray.getJSONObject(i)
|
|
180
|
+
} catch (e: JSONException) {
|
|
181
|
+
throw IllegalArgumentException(
|
|
182
|
+
"Target at index $i is not a valid JSON object: ${e.message}"
|
|
183
|
+
)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
val path = obj.optString("path", "")
|
|
187
|
+
if (path.isEmpty()) {
|
|
188
|
+
throw IllegalArgumentException(
|
|
189
|
+
"Target at index $i is missing required field 'path'"
|
|
190
|
+
)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
val method = obj.optString("method", "")
|
|
194
|
+
if (method.isEmpty()) {
|
|
195
|
+
throw IllegalArgumentException(
|
|
196
|
+
"Target at index $i is missing required field 'method'"
|
|
197
|
+
)
|
|
198
|
+
}
|
|
199
|
+
if (method !in listOf("set", "push", "update")) {
|
|
200
|
+
throw IllegalArgumentException(
|
|
201
|
+
"Target at index $i has invalid method '$method'. Must be 'set', 'push', or 'update'"
|
|
202
|
+
)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
val batchSize = obj.optInt("batchSize", 1)
|
|
206
|
+
val offlineQueue = obj.optBoolean("offlineQueue", false)
|
|
207
|
+
|
|
208
|
+
configs.add(
|
|
209
|
+
SyncTargetConfig(
|
|
210
|
+
path = path,
|
|
211
|
+
method = method,
|
|
212
|
+
batchSize = batchSize,
|
|
213
|
+
offlineQueue = offlineQueue
|
|
214
|
+
)
|
|
215
|
+
)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return configs
|
|
219
|
+
}
|
|
220
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
package com.livetracking.sync
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Configuration for a single sync target.
|
|
5
|
+
*
|
|
6
|
+
* Each sync target defines a Firebase path, write method, optional batch size,
|
|
7
|
+
* and optional offline queue preference. The native SyncEngineController
|
|
8
|
+
* instantiates one TargetHandler per SyncTargetConfig.
|
|
9
|
+
*
|
|
10
|
+
* @param path Firebase path to write location data to
|
|
11
|
+
* @param method Write method: "set" (overwrite), "push" (append), or "update" (merge)
|
|
12
|
+
* @param batchSize Number of location points to accumulate before writing. Default: 1 (immediate write)
|
|
13
|
+
* @param offlineQueue Whether to persist data locally when offline. Default: false
|
|
14
|
+
*/
|
|
15
|
+
data class SyncTargetConfig(
|
|
16
|
+
val path: String,
|
|
17
|
+
val method: String,
|
|
18
|
+
val batchSize: Int = 1,
|
|
19
|
+
val offlineQueue: Boolean = false
|
|
20
|
+
)
|