@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.
Files changed (42) hide show
  1. package/ExpofpCrowdconnected.podspec +26 -0
  2. package/LICENSE +20 -0
  3. package/README.md +169 -0
  4. package/android/build.gradle +86 -0
  5. package/android/gradle.properties +5 -0
  6. package/android/src/main/AndroidManifest.xml +2 -0
  7. package/android/src/main/java/com/expofpcrowdconnected/ExpoFpAzimuthProvider.kt +94 -0
  8. package/android/src/main/java/com/expofpcrowdconnected/ExpoFpCrowdConnectedLocationProviderSettings.kt +136 -0
  9. package/android/src/main/java/com/expofpcrowdconnected/ExpoFpPosition.kt +96 -0
  10. package/android/src/main/java/com/expofpcrowdconnected/ExpoFpProviderInfo.kt +27 -0
  11. package/android/src/main/java/com/expofpcrowdconnected/ExpofpCrowdconnectedPackage.kt +33 -0
  12. package/android/src/main/java/com/expofpcrowdconnected/RCTCrowdConnectedLocationProviderModule.kt +504 -0
  13. package/ios/ExpoFpLocationProvider/ExpoFpCrowdConnectedLocationProviderSettings.swift +116 -0
  14. package/ios/ExpoFpLocationProvider/ExpoFpCrowdConnectedNavigationType.swift +14 -0
  15. package/ios/ExpoFpLocationProvider/ExpoFpError.swift +28 -0
  16. package/ios/ExpoFpLocationProvider/ExpoFpFloorType.swift +39 -0
  17. package/ios/ExpoFpLocationProvider/ExpoFpLocationSettings.swift +18 -0
  18. package/ios/ExpoFpLocationProvider/ExpoFpPosition.swift +42 -0
  19. package/ios/ExpoFpLocationProvider/ExpoFpProviderInfo.swift +12 -0
  20. package/ios/Extensions/Encodable+Extensions.swift +28 -0
  21. package/ios/Extensions/LocationTrackingMode+Extensions.swift +16 -0
  22. package/ios/Extensions/NSDictionary+Extensions.swift +21 -0
  23. package/ios/RCTCrowdConnectedLocationProvider.m +24 -0
  24. package/ios/RCTCrowdConnectedLocationProvider.swift +252 -0
  25. package/lib/module/crowdconnected-location-provider-native-module.js +38 -0
  26. package/lib/module/crowdconnected-location-provider-native-module.js.map +1 -0
  27. package/lib/module/index.js +5 -0
  28. package/lib/module/index.js.map +1 -0
  29. package/lib/module/package.json +1 -0
  30. package/lib/module/types.js +2 -0
  31. package/lib/module/types.js.map +1 -0
  32. package/lib/typescript/package.json +1 -0
  33. package/lib/typescript/src/crowdconnected-location-provider-native-module.d.ts +4 -0
  34. package/lib/typescript/src/crowdconnected-location-provider-native-module.d.ts.map +1 -0
  35. package/lib/typescript/src/index.d.ts +3 -0
  36. package/lib/typescript/src/index.d.ts.map +1 -0
  37. package/lib/typescript/src/types.d.ts +40 -0
  38. package/lib/typescript/src/types.d.ts.map +1 -0
  39. package/package.json +154 -0
  40. package/src/crowdconnected-location-provider-native-module.tsx +57 -0
  41. package/src/index.tsx +2 -0
  42. 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
+ }
@@ -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
+ }