@expofp/react-native-efp-crowdconnected 0.0.2-alpha.1
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/ExpofpCrowdconnected.podspec +26 -0
- package/LICENSE +20 -0
- package/README.md +169 -0
- package/android/build.gradle +86 -0
- package/android/gradle.properties +5 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/java/com/expofpcrowdconnected/ExpoFpAzimuthProvider.kt +94 -0
- package/android/src/main/java/com/expofpcrowdconnected/ExpoFpCrowdConnectedLocationProviderSettings.kt +136 -0
- package/android/src/main/java/com/expofpcrowdconnected/ExpoFpPosition.kt +96 -0
- package/android/src/main/java/com/expofpcrowdconnected/ExpoFpProviderInfo.kt +27 -0
- package/android/src/main/java/com/expofpcrowdconnected/ExpofpCrowdconnectedPackage.kt +33 -0
- package/android/src/main/java/com/expofpcrowdconnected/RCTCrowdConnectedLocationProviderModule.kt +504 -0
- package/ios/ExpoFpLocationProvider/ExpoFpCrowdConnectedLocationProviderSettings.swift +116 -0
- package/ios/ExpoFpLocationProvider/ExpoFpCrowdConnectedNavigationType.swift +14 -0
- package/ios/ExpoFpLocationProvider/ExpoFpError.swift +28 -0
- package/ios/ExpoFpLocationProvider/ExpoFpFloorType.swift +39 -0
- package/ios/ExpoFpLocationProvider/ExpoFpLocationSettings.swift +18 -0
- package/ios/ExpoFpLocationProvider/ExpoFpPosition.swift +42 -0
- package/ios/ExpoFpLocationProvider/ExpoFpProviderInfo.swift +12 -0
- package/ios/Extensions/Encodable+Extensions.swift +28 -0
- package/ios/Extensions/LocationTrackingMode+Extensions.swift +16 -0
- package/ios/Extensions/NSDictionary+Extensions.swift +21 -0
- package/ios/RCTCrowdConnectedLocationProvider.m +24 -0
- package/ios/RCTCrowdConnectedLocationProvider.swift +252 -0
- package/lib/module/crowdconnected-location-provider-native-module.js +38 -0
- package/lib/module/crowdconnected-location-provider-native-module.js.map +1 -0
- package/lib/module/index.js +5 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/module/types.js +2 -0
- package/lib/module/types.js.map +1 -0
- package/lib/typescript/package.json +1 -0
- package/lib/typescript/src/crowdconnected-location-provider-native-module.d.ts +4 -0
- package/lib/typescript/src/crowdconnected-location-provider-native-module.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +3 -0
- package/lib/typescript/src/index.d.ts.map +1 -0
- package/lib/typescript/src/types.d.ts +40 -0
- package/lib/typescript/src/types.d.ts.map +1 -0
- package/package.json +154 -0
- package/src/crowdconnected-location-provider-native-module.tsx +57 -0
- package/src/index.tsx +2 -0
- package/src/types.ts +49 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
package com.expofpcrowdconnected
|
|
2
|
+
|
|
3
|
+
import com.facebook.react.BaseReactPackage
|
|
4
|
+
import com.facebook.react.bridge.NativeModule
|
|
5
|
+
import com.facebook.react.bridge.ReactApplicationContext
|
|
6
|
+
import com.facebook.react.module.model.ReactModuleInfo
|
|
7
|
+
import com.facebook.react.module.model.ReactModuleInfoProvider
|
|
8
|
+
import java.util.HashMap
|
|
9
|
+
|
|
10
|
+
class ExpofpCrowdconnectedPackage : BaseReactPackage() {
|
|
11
|
+
override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? {
|
|
12
|
+
return if (name == RCTCrowdConnectedLocationProviderModule.MODULE_NAME) {
|
|
13
|
+
RCTCrowdConnectedLocationProviderModule(reactContext)
|
|
14
|
+
} else {
|
|
15
|
+
null
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
override fun getReactModuleInfoProvider(): ReactModuleInfoProvider {
|
|
20
|
+
return ReactModuleInfoProvider {
|
|
21
|
+
val moduleInfos: MutableMap<String, ReactModuleInfo> = HashMap()
|
|
22
|
+
moduleInfos[RCTCrowdConnectedLocationProviderModule.MODULE_NAME] = ReactModuleInfo(
|
|
23
|
+
RCTCrowdConnectedLocationProviderModule.MODULE_NAME,
|
|
24
|
+
RCTCrowdConnectedLocationProviderModule.MODULE_NAME,
|
|
25
|
+
false, // canOverrideExistingModule
|
|
26
|
+
false, // needsEagerInit
|
|
27
|
+
false, // isCxxModule
|
|
28
|
+
false // isTurboModule
|
|
29
|
+
)
|
|
30
|
+
moduleInfos
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
package/android/src/main/java/com/expofpcrowdconnected/RCTCrowdConnectedLocationProviderModule.kt
ADDED
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
package com.expofpcrowdconnected
|
|
2
|
+
|
|
3
|
+
import android.Manifest
|
|
4
|
+
import android.app.Application
|
|
5
|
+
import android.content.Context
|
|
6
|
+
import android.content.pm.PackageManager
|
|
7
|
+
import android.os.Build
|
|
8
|
+
import android.util.Log
|
|
9
|
+
import androidx.core.content.ContextCompat
|
|
10
|
+
import com.facebook.react.bridge.Promise
|
|
11
|
+
import com.facebook.react.bridge.ReactApplicationContext
|
|
12
|
+
import com.facebook.react.bridge.ReactContextBaseJavaModule
|
|
13
|
+
import com.facebook.react.bridge.ReactMethod
|
|
14
|
+
import com.facebook.react.bridge.ReadableMap
|
|
15
|
+
import com.facebook.react.bridge.WritableMap
|
|
16
|
+
import com.facebook.react.modules.core.DeviceEventManagerModule
|
|
17
|
+
import kotlinx.coroutines.CoroutineScope
|
|
18
|
+
import kotlinx.coroutines.Dispatchers
|
|
19
|
+
import kotlinx.coroutines.Job
|
|
20
|
+
import kotlinx.coroutines.SupervisorJob
|
|
21
|
+
import kotlinx.coroutines.cancel
|
|
22
|
+
import kotlinx.coroutines.delay
|
|
23
|
+
import kotlinx.coroutines.launch
|
|
24
|
+
import kotlinx.coroutines.sync.Mutex
|
|
25
|
+
import kotlinx.coroutines.sync.withLock
|
|
26
|
+
import net.crowdconnected.android.core.CrowdConnected
|
|
27
|
+
import net.crowdconnected.android.core.StatusCallback
|
|
28
|
+
import net.crowdconnected.android.core.Configuration
|
|
29
|
+
import net.crowdconnected.android.core.ConfigurationBuilder
|
|
30
|
+
import net.crowdconnected.android.geo.GeoModule
|
|
31
|
+
import net.crowdconnected.android.ips.IPSModule
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* React Native module for CrowdConnected location provider
|
|
35
|
+
* Provides cross-platform API: setup, startUpdatingLocation, stopUpdatingLocation, isLocationUpdating
|
|
36
|
+
* Emits events: onLocationChange, onLocationError
|
|
37
|
+
*/
|
|
38
|
+
class RCTCrowdConnectedLocationProviderModule(
|
|
39
|
+
reactContext: ReactApplicationContext
|
|
40
|
+
) : ReactContextBaseJavaModule(reactContext) {
|
|
41
|
+
|
|
42
|
+
companion object {
|
|
43
|
+
const val MODULE_NAME = "RCTCrowdConnectedLocationProvider"
|
|
44
|
+
private const val TAG = "RCTCrowdConnected"
|
|
45
|
+
private const val EVENT_LOCATION_CHANGE = "onLocationChange"
|
|
46
|
+
private const val EVENT_LOCATION_ERROR = "onLocationError"
|
|
47
|
+
private const val TIMEOUT_DURATION_MS = 65_000L // 65 seconds timeout for SDK startup
|
|
48
|
+
|
|
49
|
+
// Foreground service notification
|
|
50
|
+
private const val NOTIFICATION_TITLE = "Location tracking is active"
|
|
51
|
+
private const val NOTIFICATION_ICON = android.R.drawable.ic_menu_mylocation
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Check for missing runtime permissions
|
|
55
|
+
* Returns list of permissions that are not granted
|
|
56
|
+
*/
|
|
57
|
+
fun missingPermissions(
|
|
58
|
+
context: Context,
|
|
59
|
+
settings: ExpoFpCrowdConnectedLocationProviderSettings
|
|
60
|
+
): List<String> {
|
|
61
|
+
val required = buildList {
|
|
62
|
+
// Location permission (always required)
|
|
63
|
+
add(Manifest.permission.ACCESS_FINE_LOCATION)
|
|
64
|
+
|
|
65
|
+
// Bluetooth permissions vary by API level
|
|
66
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
|
67
|
+
// Android 12+ (API 31+)
|
|
68
|
+
if (settings.isBluetoothEnabled) {
|
|
69
|
+
add(Manifest.permission.BLUETOOTH_SCAN)
|
|
70
|
+
add(Manifest.permission.BLUETOOTH_CONNECT)
|
|
71
|
+
}
|
|
72
|
+
} else {
|
|
73
|
+
// Android 11 and below (API 30 and below)
|
|
74
|
+
if (settings.isBluetoothEnabled) {
|
|
75
|
+
add(Manifest.permission.BLUETOOTH)
|
|
76
|
+
add(Manifest.permission.BLUETOOTH_ADMIN)
|
|
77
|
+
}
|
|
78
|
+
add(Manifest.permission.ACCESS_COARSE_LOCATION)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Background location (Android 10+, API 29+)
|
|
82
|
+
if (settings.isBackgroundUpdateEnabled &&
|
|
83
|
+
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
|
84
|
+
add(Manifest.permission.ACCESS_BACKGROUND_LOCATION)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return required.filter {
|
|
89
|
+
ContextCompat.checkSelfPermission(context, it) != PackageManager.PERMISSION_GRANTED
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Coroutine scope for async operations
|
|
96
|
+
* - SupervisorJob ensures that failure in one coroutine doesn't cancel others
|
|
97
|
+
* - Dispatchers.Main ensures we're on the main thread for React Native operations
|
|
98
|
+
*/
|
|
99
|
+
private val moduleScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Mutex for thread-safe state management
|
|
103
|
+
* Protects access to: settings, providerInfo, isStarted, currentPosition, azimuthProvider
|
|
104
|
+
* All ReactMethod calls and internal state modifications must acquire this lock
|
|
105
|
+
*/
|
|
106
|
+
private val mutex = Mutex()
|
|
107
|
+
|
|
108
|
+
// Timeout job for SDK start (65 seconds)
|
|
109
|
+
private var timeoutJob: Job? = null
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Module state - protected by mutex
|
|
113
|
+
* These fields should only be accessed within mutex.withLock { } blocks or on main thread
|
|
114
|
+
* @Volatile ensures visibility across threads for reads outside mutex (e.g., onCatalystInstanceDestroy)
|
|
115
|
+
*/
|
|
116
|
+
@Volatile private var settings: ExpoFpCrowdConnectedLocationProviderSettings? = null
|
|
117
|
+
@Volatile private var providerInfo = ExpoFpProviderInfo(deviceId = null, isLocationUpdating = false)
|
|
118
|
+
@Volatile private var currentPosition: ExpoFpPosition? = null
|
|
119
|
+
@Volatile private var azimuthProvider: ExpoFpAzimuthProvider? = null
|
|
120
|
+
@Volatile private var isStarted = false
|
|
121
|
+
|
|
122
|
+
override fun getName(): String = MODULE_NAME
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Setup and configure the CrowdConnected SDK
|
|
126
|
+
* If location updates are already running, they will be stopped and restarted with new settings
|
|
127
|
+
*/
|
|
128
|
+
@ReactMethod
|
|
129
|
+
fun setup(settingsMap: ReadableMap, promise: Promise) {
|
|
130
|
+
moduleScope.launch {
|
|
131
|
+
try {
|
|
132
|
+
mutex.withLock {
|
|
133
|
+
Log.d(TAG, "setup: Configuring CrowdConnected SDK")
|
|
134
|
+
|
|
135
|
+
// Parse settings from JavaScript
|
|
136
|
+
val newSettings = ExpoFpCrowdConnectedLocationProviderSettings.fromReadableMap(settingsMap)
|
|
137
|
+
settings = newSettings
|
|
138
|
+
|
|
139
|
+
Log.d(TAG, "setup: Navigation type: ${newSettings.navigationType}, Background: ${newSettings.isBackgroundUpdateEnabled}, Heading: ${newSettings.isHeadingEnabled}")
|
|
140
|
+
|
|
141
|
+
// If already updating, stop first
|
|
142
|
+
val wasUpdating = providerInfo.isLocationUpdating
|
|
143
|
+
if (wasUpdating) {
|
|
144
|
+
Log.d(TAG, "setup: Already updating, stopping before restart")
|
|
145
|
+
stopUpdatingLocationInternal()
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// If was updating, restart with new settings
|
|
149
|
+
if (wasUpdating) {
|
|
150
|
+
Log.d(TAG, "setup: Restarting with new settings")
|
|
151
|
+
startUpdatingLocationInternal(promise)
|
|
152
|
+
} else {
|
|
153
|
+
Log.d(TAG, "setup: Configuration complete")
|
|
154
|
+
promise.resolve(providerInfo.toWritableMap())
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
} catch (e: Exception) {
|
|
158
|
+
Log.e(TAG, "setup: Failed to configure SDK", e)
|
|
159
|
+
promise.reject("SETUP_ERROR", e.message, e)
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Start location updates
|
|
166
|
+
*/
|
|
167
|
+
@ReactMethod
|
|
168
|
+
fun startUpdatingLocation(promise: Promise) {
|
|
169
|
+
moduleScope.launch {
|
|
170
|
+
try {
|
|
171
|
+
mutex.withLock {
|
|
172
|
+
startUpdatingLocationInternal(promise)
|
|
173
|
+
}
|
|
174
|
+
} catch (e: Exception) {
|
|
175
|
+
promise.reject("START_ERROR", e.message, e)
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Stop location updates
|
|
182
|
+
*/
|
|
183
|
+
@ReactMethod
|
|
184
|
+
fun stopUpdatingLocation(promise: Promise) {
|
|
185
|
+
moduleScope.launch {
|
|
186
|
+
try {
|
|
187
|
+
mutex.withLock {
|
|
188
|
+
stopUpdatingLocationInternal()
|
|
189
|
+
promise.resolve(providerInfo.toWritableMap())
|
|
190
|
+
}
|
|
191
|
+
} catch (e: Exception) {
|
|
192
|
+
promise.reject("STOP_ERROR", e.message, e)
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Check if location updates are currently active
|
|
199
|
+
*/
|
|
200
|
+
@ReactMethod
|
|
201
|
+
fun isLocationUpdating(promise: Promise) {
|
|
202
|
+
try {
|
|
203
|
+
promise.resolve(providerInfo.toWritableMap())
|
|
204
|
+
} catch (e: Exception) {
|
|
205
|
+
promise.reject("STATUS_ERROR", e.message, e)
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Internal method to start location updates (must be called within mutex lock)
|
|
211
|
+
*/
|
|
212
|
+
private suspend fun startUpdatingLocationInternal(promise: Promise) {
|
|
213
|
+
Log.d(TAG, "startUpdatingLocationInternal: Starting location updates")
|
|
214
|
+
|
|
215
|
+
// Check settings
|
|
216
|
+
val currentSettings = settings
|
|
217
|
+
?: throw IllegalStateException("Settings are missing. Use 'setup' method first.")
|
|
218
|
+
|
|
219
|
+
// Already updating
|
|
220
|
+
if (providerInfo.isLocationUpdating) {
|
|
221
|
+
Log.d(TAG, "startUpdatingLocationInternal: Already updating")
|
|
222
|
+
promise.resolve(providerInfo.toWritableMap())
|
|
223
|
+
return
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Check permissions
|
|
227
|
+
val missing = missingPermissions(reactApplicationContext, currentSettings)
|
|
228
|
+
if (missing.isNotEmpty()) {
|
|
229
|
+
Log.e(TAG, "startUpdatingLocationInternal: Missing permissions: ${missing.joinToString(", ")}")
|
|
230
|
+
throw IllegalStateException(
|
|
231
|
+
"Missing required permissions: ${missing.joinToString(", ")}"
|
|
232
|
+
)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
Log.d(TAG, "startUpdatingLocationInternal: Permissions granted, starting SDK")
|
|
236
|
+
|
|
237
|
+
// Mark as updating (but not started yet - will set after SDK starts successfully)
|
|
238
|
+
providerInfo = providerInfo.copy(isLocationUpdating = true)
|
|
239
|
+
|
|
240
|
+
// Start azimuth provider if heading is enabled
|
|
241
|
+
if (currentSettings.isHeadingEnabled) {
|
|
242
|
+
Log.d(TAG, "startUpdatingLocationInternal: Starting azimuth provider")
|
|
243
|
+
azimuthProvider = ExpoFpAzimuthProvider(reactApplicationContext) { newAngle ->
|
|
244
|
+
// Update current position with new heading
|
|
245
|
+
currentPosition?.let { pos ->
|
|
246
|
+
val updatedPosition = pos.updateHeading(newAngle)
|
|
247
|
+
currentPosition = updatedPosition
|
|
248
|
+
sendLocationEvent(updatedPosition)
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
val started = azimuthProvider?.start() ?: false
|
|
252
|
+
if (!started) {
|
|
253
|
+
Log.w(TAG, "Warning: Heading updates requested but sensor unavailable or failed to start")
|
|
254
|
+
// Continue with location tracking even if heading is not available
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Build CrowdConnected configuration
|
|
259
|
+
val statusCallback = object : StatusCallback {
|
|
260
|
+
override fun onStartUpSuccess() {
|
|
261
|
+
Log.d(TAG, "StatusCallback: SDK startup successful")
|
|
262
|
+
// Cancel timeout on SDK startup success
|
|
263
|
+
cancelTimeoutJob()
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
override fun onStartUpFailure(reason: String) {
|
|
267
|
+
Log.e(TAG, "StatusCallback: SDK startup failed: $reason")
|
|
268
|
+
sendErrorEvent("StartUp Failure: $reason")
|
|
269
|
+
moduleScope.launch {
|
|
270
|
+
mutex.withLock {
|
|
271
|
+
stopUpdatingLocationInternal()
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
override fun onRuntimeError(error: String) {
|
|
277
|
+
Log.e(TAG, "StatusCallback: Runtime error: $error")
|
|
278
|
+
sendErrorEvent("Runtime Error: $error")
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
val builder = ConfigurationBuilder()
|
|
283
|
+
.withAppKey(currentSettings.appKey)
|
|
284
|
+
.withToken(currentSettings.token)
|
|
285
|
+
.withSecret(currentSettings.secret)
|
|
286
|
+
.withStatusCallback(statusCallback)
|
|
287
|
+
|
|
288
|
+
// Add modules based on navigation type
|
|
289
|
+
when (currentSettings.navigationType) {
|
|
290
|
+
ExpoFpCrowdConnectedNavigationType.ALL -> {
|
|
291
|
+
builder.addModule(IPSModule())
|
|
292
|
+
builder.addModule(GeoModule())
|
|
293
|
+
}
|
|
294
|
+
ExpoFpCrowdConnectedNavigationType.IPS -> {
|
|
295
|
+
builder.addModule(IPSModule())
|
|
296
|
+
}
|
|
297
|
+
ExpoFpCrowdConnectedNavigationType.GEO -> {
|
|
298
|
+
builder.addModule(GeoModule())
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Set foreground service notification if background updates are enabled
|
|
303
|
+
if (currentSettings.isBackgroundUpdateEnabled) {
|
|
304
|
+
builder.withServiceNotificationInfo(
|
|
305
|
+
NOTIFICATION_TITLE,
|
|
306
|
+
NOTIFICATION_ICON
|
|
307
|
+
)
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
val config: Configuration = builder.build()
|
|
311
|
+
|
|
312
|
+
// Start timeout job before starting SDK
|
|
313
|
+
startTimeoutJob(promise)
|
|
314
|
+
|
|
315
|
+
// Start CrowdConnected SDK
|
|
316
|
+
try {
|
|
317
|
+
val app = reactApplicationContext.applicationContext as Application
|
|
318
|
+
CrowdConnected.start(app, config)
|
|
319
|
+
|
|
320
|
+
Log.i(TAG, "CrowdConnected SDK start initiated")
|
|
321
|
+
|
|
322
|
+
// Get device ID from CrowdConnected instance
|
|
323
|
+
val deviceId = CrowdConnected.getInstance()?.getDeviceId()
|
|
324
|
+
if (deviceId != null) {
|
|
325
|
+
providerInfo = providerInfo.copy(deviceId = deviceId)
|
|
326
|
+
Log.d(TAG, "Device ID set: $deviceId")
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Mark as started (SDK successfully started)
|
|
330
|
+
isStarted = true
|
|
331
|
+
|
|
332
|
+
// Register position callback
|
|
333
|
+
registerPositionCallback()
|
|
334
|
+
|
|
335
|
+
// Apply aliases
|
|
336
|
+
if (currentSettings.aliases.isNotEmpty()) {
|
|
337
|
+
Log.d(TAG, "Applying ${currentSettings.aliases.size} aliases")
|
|
338
|
+
currentSettings.aliases.forEach { (key, value) ->
|
|
339
|
+
CrowdConnected.getInstance()?.setAlias(key, value)
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
promise.resolve(providerInfo.toWritableMap())
|
|
344
|
+
} catch (e: Exception) {
|
|
345
|
+
Log.e(TAG, "CrowdConnected SDK start failed with exception", e)
|
|
346
|
+
cancelTimeoutJob()
|
|
347
|
+
stopUpdatingLocationInternal()
|
|
348
|
+
throw e
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Internal method to stop location updates (must be called within mutex lock)
|
|
354
|
+
*/
|
|
355
|
+
private fun stopUpdatingLocationInternal() {
|
|
356
|
+
if (!isStarted) {
|
|
357
|
+
Log.d(TAG, "stopUpdatingLocationInternal: Not started, nothing to stop")
|
|
358
|
+
return
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
Log.d(TAG, "stopUpdatingLocationInternal: Stopping location updates")
|
|
362
|
+
|
|
363
|
+
// Cancel timeout job
|
|
364
|
+
cancelTimeoutJob()
|
|
365
|
+
|
|
366
|
+
isStarted = false
|
|
367
|
+
providerInfo = providerInfo.copy(isLocationUpdating = false)
|
|
368
|
+
|
|
369
|
+
// Deregister position callback
|
|
370
|
+
Log.d(TAG, "stopUpdatingLocationInternal: Deregistering position callback")
|
|
371
|
+
CrowdConnected.getInstance()?.deregisterPositionCallback()
|
|
372
|
+
|
|
373
|
+
// Stop CrowdConnected SDK
|
|
374
|
+
try {
|
|
375
|
+
Log.d(TAG, "stopUpdatingLocationInternal: Stopping CrowdConnected SDK")
|
|
376
|
+
CrowdConnected.getInstance()?.stop()
|
|
377
|
+
} catch (e: Exception) {
|
|
378
|
+
Log.e(TAG, "stopUpdatingLocationInternal: Error stopping CrowdConnected SDK", e)
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Stop azimuth provider
|
|
382
|
+
if (azimuthProvider != null) {
|
|
383
|
+
Log.d(TAG, "stopUpdatingLocationInternal: Stopping azimuth provider")
|
|
384
|
+
azimuthProvider?.stop()
|
|
385
|
+
azimuthProvider = null
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
currentPosition = null
|
|
389
|
+
Log.i(TAG, "stopUpdatingLocationInternal: Location updates stopped")
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Register callback for position updates from CrowdConnected SDK
|
|
394
|
+
*/
|
|
395
|
+
private fun registerPositionCallback() {
|
|
396
|
+
CrowdConnected.getInstance()?.registerPositionCallback { position ->
|
|
397
|
+
// Ensure we're on the main thread for React Native event emission
|
|
398
|
+
moduleScope.launch {
|
|
399
|
+
val angle = azimuthProvider?.azimuth
|
|
400
|
+
|
|
401
|
+
val expoFpPosition = ExpoFpPosition.fromCrowdConnected(
|
|
402
|
+
locationType = position.locationType,
|
|
403
|
+
xPixels = position.xPixels,
|
|
404
|
+
yPixels = position.yPixels,
|
|
405
|
+
floor = position.floor,
|
|
406
|
+
latitude = position.latitude,
|
|
407
|
+
longitude = position.longitude,
|
|
408
|
+
angle = angle
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
currentPosition = expoFpPosition
|
|
412
|
+
sendLocationEvent(expoFpPosition)
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Send location change event to JavaScript
|
|
419
|
+
*/
|
|
420
|
+
private fun sendLocationEvent(position: ExpoFpPosition) {
|
|
421
|
+
if (!providerInfo.isLocationUpdating) {
|
|
422
|
+
Log.w(TAG, "Attempted to send location event but location updates are not active")
|
|
423
|
+
return
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
try {
|
|
427
|
+
reactApplicationContext
|
|
428
|
+
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
|
|
429
|
+
?.emit(EVENT_LOCATION_CHANGE, position.toWritableMap())
|
|
430
|
+
} catch (e: Exception) {
|
|
431
|
+
Log.e(TAG, "Failed to send location event to JavaScript", e)
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Send error event to JavaScript
|
|
437
|
+
* Ensures thread-safety by dispatching on main thread
|
|
438
|
+
*/
|
|
439
|
+
private fun sendErrorEvent(message: String) {
|
|
440
|
+
moduleScope.launch {
|
|
441
|
+
try {
|
|
442
|
+
val deviceIdSuffix = providerInfo.deviceId?.let { " Device ID: $it" } ?: ""
|
|
443
|
+
reactApplicationContext
|
|
444
|
+
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
|
|
445
|
+
?.emit(EVENT_LOCATION_ERROR, message + deviceIdSuffix)
|
|
446
|
+
} catch (e: Exception) {
|
|
447
|
+
Log.e(TAG, "Failed to send error event to JavaScript", e)
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Start timeout job that will stop SDK if it doesn't respond in time
|
|
454
|
+
* Uses 65 second timeout for SDK startup
|
|
455
|
+
* Note: Does not automatically restart to avoid potential infinite loops
|
|
456
|
+
*/
|
|
457
|
+
private fun startTimeoutJob(promise: Promise) {
|
|
458
|
+
cancelTimeoutJob()
|
|
459
|
+
|
|
460
|
+
timeoutJob = moduleScope.launch {
|
|
461
|
+
delay(TIMEOUT_DURATION_MS)
|
|
462
|
+
|
|
463
|
+
mutex.withLock {
|
|
464
|
+
if (isStarted && providerInfo.isLocationUpdating) {
|
|
465
|
+
Log.w(TAG, "Timeout: SDK did not respond within 65 seconds")
|
|
466
|
+
sendErrorEvent("Start Timeout! Location updates stopped. Please restart manually.")
|
|
467
|
+
stopUpdatingLocationInternal()
|
|
468
|
+
// Note: Not automatically restarting to avoid potential infinite loops
|
|
469
|
+
// User will receive error event and can manually restart if needed
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Cancel the timeout job
|
|
477
|
+
*/
|
|
478
|
+
private fun cancelTimeoutJob() {
|
|
479
|
+
timeoutJob?.cancel()
|
|
480
|
+
timeoutJob = null
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Cleanup when React Native context is destroyed
|
|
485
|
+
* Stops location updates synchronously to prevent resource leaks
|
|
486
|
+
*/
|
|
487
|
+
override fun onCatalystInstanceDestroy() {
|
|
488
|
+
super.onCatalystInstanceDestroy()
|
|
489
|
+
|
|
490
|
+
// Stop location updates synchronously before cancelling the scope
|
|
491
|
+
// No mutex lock needed here as we're shutting down and there's no concurrent access risk
|
|
492
|
+
if (isStarted) {
|
|
493
|
+
try {
|
|
494
|
+
Log.d(TAG, "onCatalystInstanceDestroy: Stopping location updates")
|
|
495
|
+
stopUpdatingLocationInternal()
|
|
496
|
+
} catch (e: Exception) {
|
|
497
|
+
Log.e(TAG, "onCatalystInstanceDestroy: Error during cleanup", e)
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// Cancel coroutine scope
|
|
502
|
+
moduleScope.cancel()
|
|
503
|
+
}
|
|
504
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
//
|
|
2
|
+
// ExpoFpCrowdConnectedNavigationType.swift
|
|
3
|
+
// ExpoFpCrowdConnected
|
|
4
|
+
//
|
|
5
|
+
// Created by Nikita Kolesnikov on 21.05.2025.
|
|
6
|
+
// Copyright © 2025 ExpoFP. All rights reserved.
|
|
7
|
+
//
|
|
8
|
+
|
|
9
|
+
import CrowdConnectedCore
|
|
10
|
+
|
|
11
|
+
/// Settings for `ExpoFpCrowdConnectedLocationProvider` initialization.
|
|
12
|
+
struct ExpoFpCrowdConnectedLocationProviderSettings {
|
|
13
|
+
|
|
14
|
+
/// Authentication credentials: `AppKey`, `Token`, `Secret`.
|
|
15
|
+
///
|
|
16
|
+
/// For `AppKey` [register here](https://app.crowdconnected.com/register).
|
|
17
|
+
///
|
|
18
|
+
/// For more information use [Tokens gude](https://customer.support.crowdconnected.com/servicedesk/customer/article/2888139205).
|
|
19
|
+
let credentials: SDKCredentials
|
|
20
|
+
|
|
21
|
+
/// Navigation type to enable.
|
|
22
|
+
let navigationType: ExpoFpCrowdConnectedNavigationType
|
|
23
|
+
|
|
24
|
+
/// Tracking mode: `foregroundOnly` or `foregroundAndBackground`.
|
|
25
|
+
let trackingMode: LocationTrackingMode
|
|
26
|
+
|
|
27
|
+
/// Enables CrowdConnected CoreBluetooth positioning.
|
|
28
|
+
let isBluetoothEnabled: Bool
|
|
29
|
+
|
|
30
|
+
/// Enables 'heading' on the map using build-in native CoreLocation tools.
|
|
31
|
+
let isHeadingEnabled: Bool
|
|
32
|
+
|
|
33
|
+
/// CrowdConnected aliases for deviceID.
|
|
34
|
+
///
|
|
35
|
+
/// For more information use [Alias guide](https://customer.support.crowdconnected.com/servicedesk/customer/kb/view/2888204977).
|
|
36
|
+
let aliases: [String: String]
|
|
37
|
+
|
|
38
|
+
init(settings: ExpoFpLocationSettings) throws(ExpoFpError) {
|
|
39
|
+
let appKey = settings.appKey.replacingOccurrences(of: " ", with: "")
|
|
40
|
+
if appKey.isEmpty { throw ExpoFpError.locationProviderError(message: Self.message(for: .missingAppKey)) }
|
|
41
|
+
|
|
42
|
+
let token = settings.token.replacingOccurrences(of: " ", with: "")
|
|
43
|
+
if token.isEmpty { throw ExpoFpError.locationProviderError(message: Self.message(for: .missingToken)) }
|
|
44
|
+
|
|
45
|
+
let secret = settings.secret.replacingOccurrences(of: " ", with: "")
|
|
46
|
+
if secret.isEmpty { throw ExpoFpError.locationProviderError(message: Self.message(for: .missingSecret)) }
|
|
47
|
+
|
|
48
|
+
self.credentials = SDKCredentials(appKey: appKey, token: token, secret: secret)
|
|
49
|
+
|
|
50
|
+
let trackingMode = settings.isBackgroundUpdateEnabled ? LocationTrackingMode.foregroundAndBackground : .foregroundOnly
|
|
51
|
+
try Self.checkLocationPermissions(for: trackingMode)
|
|
52
|
+
self.trackingMode = trackingMode
|
|
53
|
+
self.navigationType = settings.navigationType
|
|
54
|
+
|
|
55
|
+
if settings.isBluetoothEnabled { try Self.checkBluetoothPermissions() }
|
|
56
|
+
self.isBluetoothEnabled = settings.isBluetoothEnabled
|
|
57
|
+
|
|
58
|
+
self.isHeadingEnabled = settings.isHeadingEnabled
|
|
59
|
+
self.aliases = settings.aliases ?? [:]
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// MARK: - Check Permission Methods
|
|
63
|
+
|
|
64
|
+
private static func checkLocationPermissions(for mode: LocationTrackingMode) throws(ExpoFpError) {
|
|
65
|
+
guard checkDescription(for: .locationWhenInUse) else {
|
|
66
|
+
throw ExpoFpError.locationProviderError(message: Self.message(for: .missingWhileInUseLocationPermissionItem))
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
guard mode.isAllowedInBackground else { return }
|
|
70
|
+
|
|
71
|
+
guard checkDescription(for: .locationAlwaysAndWhenInUse) else {
|
|
72
|
+
throw ExpoFpError.locationProviderError(message: Self.message(for: .missingAlwaysLocationPermissionItem))
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
guard checkBackgroundMode(.location) else {
|
|
76
|
+
throw ExpoFpError.locationProviderError(message: Self.message(for: .missingLocationBackgroundModeItem))
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
private static func checkBluetoothPermissions() throws(ExpoFpError) {
|
|
81
|
+
guard checkDescription(for: .bluetoothAlwaysUsage) else {
|
|
82
|
+
throw ExpoFpError.locationProviderError(message: Self.message(for: .missingBluetoothPermissionItem))
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
guard checkBackgroundMode(.bluetooth) else {
|
|
86
|
+
throw ExpoFpError.locationProviderError(message: Self.message(for: .missingBluetoothBackgroundModeItem))
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
private static func message(for result: CrowdConnectedValidationResult) -> String {
|
|
91
|
+
result.description
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
private static func checkDescription(for permission: Permission) -> Bool {
|
|
95
|
+
let description = Bundle.main.object(forInfoDictionaryKey: permission.rawValue) as? String
|
|
96
|
+
return description?.isEmpty == false
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private static func checkBackgroundMode(_ mode: BackgroundMode) -> Bool {
|
|
100
|
+
let modes = (Bundle.main.object(forInfoDictionaryKey: BackgroundMode.name) as? [String]) ?? []
|
|
101
|
+
return modes.contains(mode.rawValue)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private enum BackgroundMode: String {
|
|
105
|
+
case bluetooth = "bluetooth-central"
|
|
106
|
+
case location
|
|
107
|
+
|
|
108
|
+
static let name = "UIBackgroundModes"
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
private enum Permission: String {
|
|
112
|
+
case bluetoothAlwaysUsage = "NSBluetoothAlwaysUsageDescription"
|
|
113
|
+
case locationWhenInUse = "NSLocationWhenInUseUsageDescription"
|
|
114
|
+
case locationAlwaysAndWhenInUse = "NSLocationAlwaysAndWhenInUseUsageDescription"
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
//
|
|
2
|
+
// ExpoFpCrowdConnectedNavigationType.swift
|
|
3
|
+
// ExpoFpCrowdConnected
|
|
4
|
+
//
|
|
5
|
+
// Created by Nikita Kolesnikov on 21.05.2025.
|
|
6
|
+
// Copyright © 2025 ExpoFP. All rights reserved.
|
|
7
|
+
//
|
|
8
|
+
|
|
9
|
+
/// A type of navigation that can be enabled in the CrowdConnected framework.
|
|
10
|
+
public enum ExpoFpCrowdConnectedNavigationType: String, Codable {
|
|
11
|
+
case all
|
|
12
|
+
case geo = "GEO"
|
|
13
|
+
case ips = "IPS"
|
|
14
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
//
|
|
2
|
+
// ExpoFpError.swift
|
|
3
|
+
// ExpoFP
|
|
4
|
+
//
|
|
5
|
+
// Created by Nikita Kolesnikov on 14.04.2025.
|
|
6
|
+
// Copyright © 2025 ExpoFP. All rights reserved.
|
|
7
|
+
//
|
|
8
|
+
|
|
9
|
+
/// An error that representing a cause of SDK failure.
|
|
10
|
+
enum ExpoFpError: Error, CustomStringConvertible {
|
|
11
|
+
/// Error inside SDK.
|
|
12
|
+
case internalError(error: Error? = nil, message: String? = nil)
|
|
13
|
+
/// Decoding error inside SDK or after network response.
|
|
14
|
+
case decodingError(error: Error? = nil, message: String? = nil)
|
|
15
|
+
/// Error while using location provider.
|
|
16
|
+
case locationProviderError(error: Error? = nil, message: String? = nil)
|
|
17
|
+
|
|
18
|
+
var description: String {
|
|
19
|
+
switch self {
|
|
20
|
+
case .internalError(let error, let message):
|
|
21
|
+
"internalError(\(String(describing: error)));\n message: \(message ?? "")"
|
|
22
|
+
case .decodingError(let error, let message):
|
|
23
|
+
"decodingError(\(String(describing: error)));\n message: \(message ?? "")"
|
|
24
|
+
case .locationProviderError(let error, let message):
|
|
25
|
+
"locationProviderError(\(String(describing: error)));\n message: \(message ?? "")"
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|