@expofp/react-native-efp-crowdconnected 0.1.2 → 0.1.4

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.
@@ -78,6 +78,7 @@ dependencies {
78
78
  implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
79
79
 
80
80
  implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2"
81
+ implementation "androidx.fragment:fragment-ktx:1.6.2"
81
82
 
82
83
  implementation "net.crowdconnected.android.core:android-core:2.1.3"
83
84
  implementation "net.crowdconnected.android.ips:android-ips:2.1.3"
@@ -78,12 +78,20 @@ data class ExpoFpPosition(
78
78
  lat = null,
79
79
  lng = null
80
80
  )
81
+ "GEO" -> ExpoFpPosition(
82
+ x = null,
83
+ y = null,
84
+ z = z,
85
+ angle = angle,
86
+ lat = latitude,
87
+ lng = longitude
88
+ )
81
89
  else -> {
82
- // GEO and unknown types: only lat/lng, no x/y
83
- // GEO location type uses lat/lng only
90
+ // Unknown or mixed types: include all available coordinates
91
+ // This ensures blue dot works even if locationType is unexpected
84
92
  ExpoFpPosition(
85
- x = null,
86
- y = null,
93
+ x = xPixels,
94
+ y = yPixels,
87
95
  z = z,
88
96
  angle = angle,
89
97
  lat = latitude,
@@ -0,0 +1,233 @@
1
+ package com.expofpcrowdconnected
2
+
3
+ import android.Manifest
4
+ import android.app.Activity
5
+ import android.bluetooth.BluetoothAdapter
6
+ import android.content.Intent
7
+ import android.content.pm.PackageManager
8
+ import android.net.Uri
9
+ import android.os.Build
10
+ import android.os.Bundle
11
+ import android.provider.Settings
12
+ import androidx.activity.result.ActivityResultLauncher
13
+ import androidx.activity.result.contract.ActivityResultContracts
14
+ import androidx.core.content.ContextCompat
15
+ import androidx.fragment.app.Fragment
16
+ import androidx.fragment.app.FragmentActivity
17
+ import kotlin.coroutines.resume
18
+ import kotlinx.coroutines.suspendCancellableCoroutine
19
+
20
+ /**
21
+ * Result of a permission request containing grant status and lists of denied permissions.
22
+ *
23
+ * @param granted true if all requested permissions were granted
24
+ * @param denied list of permissions the user denied
25
+ * @param permanentlyDenied list of permissions the user denied with "Don't ask again"
26
+ */
27
+ data class PermissionResult(
28
+ val granted: Boolean,
29
+ val denied: List<String>,
30
+ val permanentlyDenied: List<String>
31
+ )
32
+
33
+ /**
34
+ * Headless Fragment that handles runtime permission requests.
35
+ *
36
+ * Uses ActivityResultContracts to request permissions without requiring
37
+ * the hosting Activity to implement onRequestPermissionsResult.
38
+ * Mirrors the native CrowdConnected SDK's PermissionHostFragment pattern.
39
+ */
40
+ class PermissionRequester : Fragment() {
41
+
42
+ companion object {
43
+ private const val FRAGMENT_TAG = "com.expofpcrowdconnected.PermissionRequester"
44
+
45
+ /**
46
+ * Obtain an existing or create a new PermissionRequester fragment
47
+ * attached to the given activity. Must be called on the main thread.
48
+ */
49
+ fun obtain(activity: FragmentActivity): PermissionRequester {
50
+ val fm = activity.supportFragmentManager
51
+ val existing = fm.findFragmentByTag(FRAGMENT_TAG) as? PermissionRequester
52
+ if (existing != null) {
53
+ return existing
54
+ }
55
+ val fragment = PermissionRequester()
56
+ fm.beginTransaction()
57
+ .add(fragment, FRAGMENT_TAG)
58
+ .setReorderingAllowed(true)
59
+ .commitNowAllowingStateLoss()
60
+ return fragment
61
+ }
62
+ }
63
+
64
+ // --- Launchers (registered in onCreate) ---
65
+
66
+ private lateinit var multiPermLauncher: ActivityResultLauncher<Array<String>>
67
+ private lateinit var singlePermLauncher: ActivityResultLauncher<String>
68
+ private lateinit var settingsLauncher: ActivityResultLauncher<Intent>
69
+ private lateinit var bluetoothLauncher: ActivityResultLauncher<Intent>
70
+
71
+ // --- Callbacks ---
72
+
73
+ private var multiPermCallback: ((PermissionResult) -> Unit)? = null
74
+ private var singlePermCallback: ((Boolean) -> Unit)? = null
75
+ private var settingsCallback: ((Boolean) -> Unit)? = null
76
+ private var settingsCheck: (() -> Boolean)? = null
77
+ private var bluetoothCallback: ((Boolean) -> Unit)? = null
78
+
79
+ override fun onCreate(savedInstanceState: Bundle?) {
80
+ super.onCreate(savedInstanceState)
81
+
82
+ multiPermLauncher = registerForActivityResult(
83
+ ActivityResultContracts.RequestMultiplePermissions()
84
+ ) { results ->
85
+ val denied = results.filterValues { !it }.keys.toList()
86
+ val permanentlyDenied = denied.filter { perm ->
87
+ !shouldShowRequestPermissionRationale(perm)
88
+ }
89
+ multiPermCallback?.invoke(
90
+ PermissionResult(
91
+ granted = denied.isEmpty(),
92
+ denied = denied,
93
+ permanentlyDenied = permanentlyDenied
94
+ )
95
+ )
96
+ multiPermCallback = null
97
+ }
98
+
99
+ singlePermLauncher = registerForActivityResult(
100
+ ActivityResultContracts.RequestPermission()
101
+ ) { granted ->
102
+ singlePermCallback?.invoke(granted)
103
+ singlePermCallback = null
104
+ }
105
+
106
+ settingsLauncher = registerForActivityResult(
107
+ ActivityResultContracts.StartActivityForResult()
108
+ ) {
109
+ val ok = settingsCheck?.invoke() ?: false
110
+ settingsCallback?.invoke(ok)
111
+ settingsCallback = null
112
+ settingsCheck = null
113
+ }
114
+
115
+ bluetoothLauncher = registerForActivityResult(
116
+ ActivityResultContracts.StartActivityForResult()
117
+ ) { result ->
118
+ bluetoothCallback?.invoke(result.resultCode == Activity.RESULT_OK)
119
+ bluetoothCallback = null
120
+ }
121
+ }
122
+
123
+ override fun onDestroy() {
124
+ multiPermCallback?.let { cb ->
125
+ multiPermCallback = null
126
+ cb(PermissionResult(granted = false, denied = emptyList(), permanentlyDenied = emptyList()))
127
+ }
128
+ singlePermCallback?.let { cb ->
129
+ singlePermCallback = null
130
+ cb(false)
131
+ }
132
+ settingsCallback?.let { cb ->
133
+ settingsCallback = null
134
+ settingsCheck = null
135
+ cb(false)
136
+ }
137
+ bluetoothCallback?.let { cb ->
138
+ bluetoothCallback = null
139
+ cb(false)
140
+ }
141
+ super.onDestroy()
142
+ }
143
+
144
+ // --- Public suspend API ---
145
+
146
+ /**
147
+ * Request multiple permissions and suspend until the user responds.
148
+ */
149
+ suspend fun requestPermissions(permissions: Array<String>): PermissionResult =
150
+ suspendCancellableCoroutine { cont ->
151
+ if (permissions.isEmpty()) {
152
+ cont.resume(PermissionResult(true, emptyList(), emptyList()))
153
+ return@suspendCancellableCoroutine
154
+ }
155
+ multiPermCallback = { result ->
156
+ if (cont.isActive) cont.resume(result)
157
+ }
158
+ cont.invokeOnCancellation { multiPermCallback = null }
159
+ multiPermLauncher.launch(permissions)
160
+ }
161
+
162
+ /**
163
+ * Request the background location permission (Android 10+).
164
+ *
165
+ * On Android 11+ (API 30+), the system does not show a runtime dialog for
166
+ * ACCESS_BACKGROUND_LOCATION. Instead, the user is redirected to App Settings
167
+ * where they can manually toggle "Allow all the time".
168
+ * This matches the native CrowdConnected SDK's BackgroundPermissions behavior.
169
+ *
170
+ * @return true if permission was granted, false otherwise
171
+ */
172
+ suspend fun requestBackgroundPermission(): Boolean {
173
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
174
+ return true // background location not needed before Android 10
175
+ }
176
+
177
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
178
+ // API 30+: redirect to App Settings (system won't show a runtime dialog)
179
+ openAppSettingsAndCheck {
180
+ ContextCompat.checkSelfPermission(
181
+ requireContext(),
182
+ Manifest.permission.ACCESS_BACKGROUND_LOCATION
183
+ ) == PackageManager.PERMISSION_GRANTED
184
+ }
185
+ } else {
186
+ // API 29: standard permission dialog works
187
+ requestSinglePermission(Manifest.permission.ACCESS_BACKGROUND_LOCATION)
188
+ }
189
+ }
190
+
191
+ /**
192
+ * Request the user to enable Bluetooth via system dialog.
193
+ *
194
+ * @return true if Bluetooth was enabled, false if user declined
195
+ */
196
+ suspend fun requestEnableBluetooth(): Boolean =
197
+ suspendCancellableCoroutine { cont ->
198
+ bluetoothCallback = { enabled ->
199
+ if (cont.isActive) cont.resume(enabled)
200
+ }
201
+ cont.invokeOnCancellation { bluetoothCallback = null }
202
+ @Suppress("MissingPermission")
203
+ bluetoothLauncher.launch(Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE))
204
+ }
205
+
206
+ // --- Private helpers ---
207
+
208
+ private suspend fun requestSinglePermission(permission: String): Boolean =
209
+ suspendCancellableCoroutine { cont ->
210
+ singlePermCallback = { granted ->
211
+ if (cont.isActive) cont.resume(granted)
212
+ }
213
+ cont.invokeOnCancellation { singlePermCallback = null }
214
+ singlePermLauncher.launch(permission)
215
+ }
216
+
217
+ private suspend fun openAppSettingsAndCheck(isGranted: () -> Boolean): Boolean =
218
+ suspendCancellableCoroutine { cont ->
219
+ settingsCheck = isGranted
220
+ settingsCallback = { ok ->
221
+ if (cont.isActive) cont.resume(ok)
222
+ }
223
+ cont.invokeOnCancellation {
224
+ settingsCallback = null
225
+ settingsCheck = null
226
+ }
227
+ val intent = Intent(
228
+ Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
229
+ Uri.fromParts("package", requireContext().packageName, null)
230
+ )
231
+ settingsLauncher.launch(intent)
232
+ }
233
+ }
@@ -2,11 +2,14 @@ package com.expofpcrowdconnected
2
2
 
3
3
  import android.Manifest
4
4
  import android.app.Application
5
+ import android.bluetooth.BluetoothManager
5
6
  import android.content.Context
6
7
  import android.content.pm.PackageManager
7
8
  import android.os.Build
8
9
  import android.util.Log
9
10
  import androidx.core.content.ContextCompat
11
+ import androidx.fragment.app.FragmentActivity
12
+ import com.facebook.react.bridge.Arguments
10
13
  import com.facebook.react.bridge.Promise
11
14
  import com.facebook.react.bridge.ReactApplicationContext
12
15
  import com.facebook.react.bridge.ReactContextBaseJavaModule
@@ -23,6 +26,7 @@ import kotlinx.coroutines.delay
23
26
  import kotlinx.coroutines.launch
24
27
  import kotlinx.coroutines.sync.Mutex
25
28
  import kotlinx.coroutines.sync.withLock
29
+ import kotlinx.coroutines.withContext
26
30
  import net.crowdconnected.android.core.CrowdConnected
27
31
  import net.crowdconnected.android.core.StatusCallback
28
32
  import net.crowdconnected.android.core.Configuration
@@ -30,6 +34,15 @@ import net.crowdconnected.android.core.ConfigurationBuilder
30
34
  import net.crowdconnected.android.geo.GeoModule
31
35
  import net.crowdconnected.android.ips.IPSModule
32
36
 
37
+ /**
38
+ * Exception for permission denial with detailed information about denied permissions.
39
+ */
40
+ class PermissionDeniedException(
41
+ message: String,
42
+ val denied: List<String> = emptyList(),
43
+ val permanentlyDenied: List<String> = emptyList()
44
+ ) : Exception(message)
45
+
33
46
  /**
34
47
  * React Native module for CrowdConnected location provider
35
48
  * Provides cross-platform API: setup, startUpdatingLocation, stopUpdatingLocation, isLocationUpdating
@@ -83,6 +96,12 @@ class RCTCrowdConnectedLocationProviderModule(
83
96
  Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
84
97
  add(Manifest.permission.ACCESS_BACKGROUND_LOCATION)
85
98
  }
99
+
100
+ // POST_NOTIFICATIONS (Android 13+, API 33+) for foreground service notification
101
+ if (settings.isBackgroundUpdateEnabled &&
102
+ Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
103
+ add(Manifest.permission.POST_NOTIFICATIONS)
104
+ }
86
105
  }
87
106
 
88
107
  return required.filter {
@@ -129,6 +148,8 @@ class RCTCrowdConnectedLocationProviderModule(
129
148
  fun setup(settingsMap: ReadableMap, promise: Promise) {
130
149
  moduleScope.launch {
131
150
  try {
151
+ val wasUpdating: Boolean
152
+
132
153
  mutex.withLock {
133
154
  Log.d(TAG, "setup: Configuring CrowdConnected SDK")
134
155
 
@@ -139,21 +160,34 @@ class RCTCrowdConnectedLocationProviderModule(
139
160
  Log.d(TAG, "setup: Navigation type: ${newSettings.navigationType}, Background: ${newSettings.isBackgroundUpdateEnabled}, Heading: ${newSettings.isHeadingEnabled}")
140
161
 
141
162
  // If already updating, stop first
142
- val wasUpdating = providerInfo.isLocationUpdating
163
+ wasUpdating = providerInfo.isLocationUpdating
143
164
  if (wasUpdating) {
144
165
  Log.d(TAG, "setup: Already updating, stopping before restart")
145
166
  stopUpdatingLocationInternal()
146
167
  }
147
168
 
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 {
169
+ if (!wasUpdating) {
153
170
  Log.d(TAG, "setup: Configuration complete")
154
171
  promise.resolve(providerInfo.toWritableMap())
155
172
  }
156
173
  }
174
+
175
+ // If was updating, restart with new settings
176
+ // Permission request runs OUTSIDE the mutex to avoid blocking
177
+ if (wasUpdating) {
178
+ Log.d(TAG, "setup: Restarting with new settings")
179
+ ensurePermissions()
180
+ ensureBluetooth()
181
+ mutex.withLock {
182
+ startUpdatingLocationInternal(promise)
183
+ }
184
+ }
185
+ } catch (e: PermissionDeniedException) {
186
+ val userInfo = Arguments.createMap().apply {
187
+ putArray("denied", Arguments.fromList(e.denied))
188
+ putArray("permanentlyDenied", Arguments.fromList(e.permanentlyDenied))
189
+ }
190
+ promise.reject("PERMISSIONS_DENIED", e.message, null as Throwable?, userInfo)
157
191
  } catch (e: Exception) {
158
192
  Log.e(TAG, "setup: Failed to configure SDK", e)
159
193
  promise.reject("SETUP_ERROR", e.message, e)
@@ -168,9 +202,19 @@ class RCTCrowdConnectedLocationProviderModule(
168
202
  fun startUpdatingLocation(promise: Promise) {
169
203
  moduleScope.launch {
170
204
  try {
205
+ // Request permissions OUTSIDE the mutex to avoid blocking
206
+ // other module operations while waiting for user interaction
207
+ ensurePermissions()
208
+ ensureBluetooth()
171
209
  mutex.withLock {
172
210
  startUpdatingLocationInternal(promise)
173
211
  }
212
+ } catch (e: PermissionDeniedException) {
213
+ val userInfo = Arguments.createMap().apply {
214
+ putArray("denied", Arguments.fromList(e.denied))
215
+ putArray("permanentlyDenied", Arguments.fromList(e.permanentlyDenied))
216
+ }
217
+ promise.reject("PERMISSIONS_DENIED", e.message, null as Throwable?, userInfo)
174
218
  } catch (e: Exception) {
175
219
  promise.reject("START_ERROR", e.message, e)
176
220
  }
@@ -206,6 +250,110 @@ class RCTCrowdConnectedLocationProviderModule(
206
250
  }
207
251
  }
208
252
 
253
+ /**
254
+ * Ensures all required permissions are granted, requesting them from the user if necessary.
255
+ * Must be called OUTSIDE the mutex to avoid blocking other module operations
256
+ * while waiting for user interaction.
257
+ *
258
+ * @throws PermissionDeniedException if permissions are denied
259
+ * @throws IllegalStateException if settings are missing or activity is unavailable
260
+ */
261
+ private suspend fun ensurePermissions() {
262
+ val currentSettings = settings
263
+ ?: throw IllegalStateException("Settings are missing. Use 'setup' method first.")
264
+
265
+ val missing = missingPermissions(reactApplicationContext, currentSettings)
266
+ if (missing.isEmpty()) return
267
+
268
+ Log.d(TAG, "ensurePermissions: Missing permissions: ${missing.joinToString(", ")}. Requesting...")
269
+
270
+ val activity = reactApplicationContext.currentActivity as? FragmentActivity
271
+ ?: throw IllegalStateException(
272
+ "Missing required permissions: ${missing.joinToString(", ")}. Cannot auto-request: no activity available."
273
+ )
274
+
275
+ val bgPermission = Manifest.permission.ACCESS_BACKGROUND_LOCATION
276
+ val needBg = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && missing.contains(bgPermission)
277
+ val foregroundPerms = missing.filterNot { it == bgPermission }
278
+
279
+ val requester = withContext(Dispatchers.Main) {
280
+ PermissionRequester.obtain(activity)
281
+ }
282
+
283
+ // 1. Request foreground permissions first
284
+ if (foregroundPerms.isNotEmpty()) {
285
+ val result = withContext(Dispatchers.Main) {
286
+ requester.requestPermissions(foregroundPerms.toTypedArray())
287
+ }
288
+ if (!result.granted) {
289
+ throw PermissionDeniedException(
290
+ "Permissions denied: ${result.denied.joinToString(", ")}",
291
+ denied = result.denied,
292
+ permanentlyDenied = result.permanentlyDenied
293
+ )
294
+ }
295
+ }
296
+
297
+ // 2. Request background permission separately (Android requires this)
298
+ // On API 30+, this redirects the user to App Settings
299
+ if (needBg) {
300
+ val bgGranted = withContext(Dispatchers.Main) {
301
+ requester.requestBackgroundPermission()
302
+ }
303
+ if (!bgGranted) {
304
+ Log.w(TAG, "ensurePermissions: Background location permission denied")
305
+ throw PermissionDeniedException(
306
+ "Background location not granted",
307
+ denied = listOf(bgPermission),
308
+ permanentlyDenied = emptyList()
309
+ )
310
+ }
311
+ }
312
+
313
+ // Final safety check — verify all permissions are actually granted
314
+ val stillMissing = missingPermissions(reactApplicationContext, currentSettings)
315
+ if (stillMissing.isNotEmpty()) {
316
+ throw PermissionDeniedException(
317
+ "Required permissions still not granted: ${stillMissing.joinToString(", ")}",
318
+ denied = stillMissing,
319
+ permanentlyDenied = emptyList()
320
+ )
321
+ }
322
+ }
323
+
324
+ /**
325
+ * Checks if Bluetooth is supported but disabled, and prompts the user to enable it.
326
+ * This is non-blocking — if the user declines, SDK start proceeds anyway.
327
+ * Matches native CrowdConnected SDK behavior (BluetoothEnableGateway).
328
+ */
329
+ private suspend fun ensureBluetooth() {
330
+ val currentSettings = settings ?: return
331
+ if (!currentSettings.isBluetoothEnabled) return
332
+
333
+ val context = reactApplicationContext
334
+ if (!context.packageManager.hasSystemFeature(PackageManager.FEATURE_BLUETOOTH_LE)) return
335
+
336
+ val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager
337
+ if (bluetoothManager?.adapter?.isEnabled == true) return
338
+
339
+ val activity = reactApplicationContext.currentActivity as? FragmentActivity ?: return
340
+
341
+ Log.d(TAG, "ensureBluetooth: Bluetooth is disabled, requesting user to enable")
342
+ try {
343
+ val requester = withContext(Dispatchers.Main) {
344
+ PermissionRequester.obtain(activity)
345
+ }
346
+ val enabled = withContext(Dispatchers.Main) {
347
+ requester.requestEnableBluetooth()
348
+ }
349
+ if (!enabled) {
350
+ Log.w(TAG, "ensureBluetooth: User declined to enable Bluetooth")
351
+ }
352
+ } catch (e: Exception) {
353
+ Log.w(TAG, "ensureBluetooth: Failed to request Bluetooth enable", e)
354
+ }
355
+ }
356
+
209
357
  /**
210
358
  * Internal method to start location updates (must be called within mutex lock)
211
359
  */
@@ -223,7 +371,7 @@ class RCTCrowdConnectedLocationProviderModule(
223
371
  return
224
372
  }
225
373
 
226
- // Check permissions
374
+ // Check permissions (should already be granted by ensurePermissions called before mutex)
227
375
  val missing = missingPermissions(reactApplicationContext, currentSettings)
228
376
  if (missing.isNotEmpty()) {
229
377
  Log.e(TAG, "startUpdatingLocationInternal: Missing permissions: ${missing.joinToString(", ")}")
@@ -259,8 +407,11 @@ class RCTCrowdConnectedLocationProviderModule(
259
407
  val statusCallback = object : StatusCallback {
260
408
  override fun onStartUpSuccess() {
261
409
  Log.d(TAG, "StatusCallback: SDK startup successful")
262
- // Cancel timeout on SDK startup success
263
410
  cancelTimeoutJob()
411
+ registerPositionCallback()
412
+ CrowdConnected.getInstance()?.getDeviceId()?.let {
413
+ providerInfo = providerInfo.copy(deviceId = it)
414
+ }
264
415
  }
265
416
 
266
417
  override fun onStartUpFailure(reason: String) {
@@ -318,20 +469,8 @@ class RCTCrowdConnectedLocationProviderModule(
318
469
  CrowdConnected.start(app, config)
319
470
 
320
471
  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
472
  isStarted = true
331
473
 
332
- // Register position callback
333
- registerPositionCallback()
334
-
335
474
  // Apply aliases
336
475
  if (currentSettings.aliases.isNotEmpty()) {
337
476
  Log.d(TAG, "Applying ${currentSettings.aliases.size} aliases")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@expofp/react-native-efp-crowdconnected",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "React Native wrapper for ExpoFP around CrowdConnected SDK",
5
5
  "keywords": [
6
6
  "react-native",