@magicred-1/react-native-lxmf 0.2.0 → 0.2.2

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 (43) hide show
  1. package/android/src/main/jniLibs/arm64-v8a/liblxmf_rn.so +0 -0
  2. package/android/src/main/jniLibs/armeabi-v7a/liblxmf_rn.so +0 -0
  3. package/android/src/main/jniLibs/x86_64/liblxmf_rn.so +0 -0
  4. package/android/src/main/kotlin/expo/modules/lxmf/BleManager.kt +123 -54
  5. package/android/src/main/kotlin/expo/modules/lxmf/LxmfModule.kt +1 -0
  6. package/example/README.md +50 -0
  7. package/example/app/(tabs)/_layout.tsx +28 -0
  8. package/example/app/(tabs)/index.tsx +874 -0
  9. package/example/app/_layout.tsx +14 -0
  10. package/example/app/modal.tsx +29 -0
  11. package/example/app.json +72 -0
  12. package/example/assets/images/android-icon-background.png +0 -0
  13. package/example/assets/images/android-icon-foreground.png +0 -0
  14. package/example/assets/images/android-icon-monochrome.png +0 -0
  15. package/example/assets/images/favicon.png +0 -0
  16. package/example/assets/images/icon.png +0 -0
  17. package/example/assets/images/partial-react-logo.png +0 -0
  18. package/example/assets/images/react-logo.png +0 -0
  19. package/example/assets/images/react-logo@2x.png +0 -0
  20. package/example/assets/images/react-logo@3x.png +0 -0
  21. package/example/assets/images/splash-icon.png +0 -0
  22. package/example/components/external-link.tsx +25 -0
  23. package/example/components/haptic-tab.tsx +18 -0
  24. package/example/components/hello-wave.tsx +19 -0
  25. package/example/components/parallax-scroll-view.tsx +79 -0
  26. package/example/components/themed-text.tsx +60 -0
  27. package/example/components/themed-view.tsx +14 -0
  28. package/example/components/ui/collapsible.tsx +45 -0
  29. package/example/components/ui/icon-symbol.ios.tsx +32 -0
  30. package/example/components/ui/icon-symbol.tsx +41 -0
  31. package/example/constants/theme.ts +53 -0
  32. package/example/eslint.config.js +10 -0
  33. package/example/expo-env.d.ts +3 -0
  34. package/example/hooks/use-color-scheme.ts +1 -0
  35. package/example/hooks/use-color-scheme.web.ts +21 -0
  36. package/example/hooks/use-theme-color.ts +21 -0
  37. package/example/metro.config.js +37 -0
  38. package/example/package.json +52 -0
  39. package/example/scripts/reset-project.js +112 -0
  40. package/example/tsconfig.json +17 -0
  41. package/ios/BLEManager.swift +41 -6
  42. package/ios/LxmfModule.swift +13 -2
  43. package/package.json +15 -2
@@ -53,6 +53,7 @@ class BleManager(
53
53
  private var advertiser: BluetoothLeAdvertiser? = null
54
54
  private var isScanning = false
55
55
  private var isAdvertising = false
56
+ private var isRunning = false
56
57
 
57
58
  // GATT server (peripheral role) — accepts inbound writes from remote centrals
58
59
  // and pushes outbound notifications to subscribed centrals.
@@ -61,6 +62,8 @@ class BleManager(
61
62
  // Centrals that have enabled CCC notifications on our TX char, keyed by MAC.
62
63
  // Only these are "registered as peers" with Rust (mirrors iOS subscribedCentrals).
63
64
  private val serverSubscribers = mutableMapOf<String, BluetoothDevice>()
65
+ // Buffer for ATT Long Write (preparedWrite=true) fragments from remote centrals.
66
+ private val preparedWriteBuffer = mutableMapOf<String, java.io.ByteArrayOutputStream>()
64
67
 
65
68
  // TX polling — every 50 ms drain the Rust TX queue and write to peers
66
69
  private val txPollRunnable = object : Runnable {
@@ -80,20 +83,29 @@ class BleManager(
80
83
  // ── Lifecycle ────────────────────────────────────────────────────────────
81
84
 
82
85
  fun start() {
86
+ if (isRunning) {
87
+ Log.d(TAG, "BleManager already running — skipping duplicate start()")
88
+ return
89
+ }
83
90
  if (adapter == null || !adapter!!.isEnabled) {
84
91
  Log.w(TAG, "Bluetooth not available or not enabled")
85
92
  return
86
93
  }
94
+ isRunning = true
87
95
  // GATT server must be opened (and service added) before advertising,
88
96
  // otherwise centrals connect and find no service. startAdvertising()
89
97
  // is invoked from onServiceAdded once the service is registered.
90
98
  openGattServer()
91
99
  startScanning()
100
+ // Remove any stale callbacks before scheduling — guards against duplicate
101
+ // poll loops if start() is somehow called more than once.
102
+ mainHandler.removeCallbacks(txPollRunnable)
92
103
  mainHandler.postDelayed(txPollRunnable, TX_POLL_INTERVAL_MS)
93
104
  Log.i(TAG, "BleManager started")
94
105
  }
95
106
 
96
107
  fun stop() {
108
+ isRunning = false
97
109
  stopScanning()
98
110
  stopAdvertising()
99
111
  closeGattServer()
@@ -258,14 +270,36 @@ class BleManager(
258
270
  value: ByteArray?,
259
271
  ) {
260
272
  if (characteristic.uuid == RNS_RX_CHAR_UUID && value != null && value.isNotEmpty()) {
261
- // Inbound LXMF frame from a remote central. Push raw bytes to
262
- // Rust HDLC deframing happens there. 4KB cap enforced in FFI.
263
- Log.d(TAG, "GATT server RX ${value.size}B from ${device.address}")
264
- module.nativeBleReceive(macToBytes(device.address), value)
273
+ if (preparedWrite) {
274
+ // ATT Long Write fragment buffer until onExecuteWrite.
275
+ preparedWriteBuffer.getOrPut(device.address) { java.io.ByteArrayOutputStream() }
276
+ .write(value)
277
+ } else {
278
+ Log.d(TAG, "GATT server RX ${value.size}B from ${device.address}")
279
+ module.nativeBleReceive(macToBytes(device.address), value)
280
+ }
265
281
  }
266
282
  if (responseNeeded) {
267
- gattServer?.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, offset, null)
283
+ gattServer?.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, offset, value)
284
+ }
285
+ }
286
+
287
+ override fun onExecuteWrite(device: BluetoothDevice, requestId: Int, execute: Boolean) {
288
+ if (execute) {
289
+ preparedWriteBuffer.remove(device.address)?.let { buf ->
290
+ val data = buf.toByteArray()
291
+ Log.d(TAG, "GATT server RX (assembled) ${data.size}B from ${device.address}")
292
+ module.nativeBleReceive(macToBytes(device.address), data)
293
+ }
294
+ } else {
295
+ preparedWriteBuffer.remove(device.address)
268
296
  }
297
+ gattServer?.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, 0, null)
298
+ }
299
+
300
+ override fun onMtuChanged(device: BluetoothDevice, mtu: Int) {
301
+ Log.i(TAG, "GATT server MTU changed: ${device.address} -> ${mtu}B")
302
+ module.nativeOnMtuNegotiated(macToBytes(device.address), mtu)
269
303
  }
270
304
 
271
305
  override fun onDescriptorWriteRequest(
@@ -407,37 +441,71 @@ class BleManager(
407
441
  gatt.disconnect()
408
442
  return
409
443
  }
410
- // Subscribe to peer's TX char (their notify path our inbound).
411
- // Convention matches iOS BLEManager.swift: RX is write-only on the
412
- // peripheral, TX is notify-only. Subscribing to RX is a no-op.
413
- val peerTxChar = service.getCharacteristic(RNS_TX_CHAR_UUID)
414
- if (peerTxChar != null) {
415
- gatt.setCharacteristicNotification(peerTxChar, true)
416
- val cccd = peerTxChar.getDescriptor(CCCD_UUID)
417
- cccd?.let {
418
- @Suppress("DEPRECATION")
419
- it.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
420
- @Suppress("DEPRECATION")
421
- gatt.writeDescriptor(it)
444
+ // Request large MTU before enabling notifications so writes fit in one ATT PDU.
445
+ Log.i(TAG, "BLE services discovered: ${gatt.device.address}, requesting MTU")
446
+ gatt.requestMtu(517)
447
+ }
448
+
449
+ override fun onMtuChanged(gatt: BluetoothGatt, mtu: Int, status: Int) {
450
+ val mac = gatt.device.address
451
+ val effectiveMtu = if (status == BluetoothGatt.GATT_SUCCESS) mtu else 23
452
+ Log.i(TAG, "BLE MTU negotiated: $mac -> ${effectiveMtu}B")
453
+ module.nativeOnMtuNegotiated(macToBytes(mac), effectiveMtu)
454
+ // Subscribe to peer's TX notifications; nativeBleConnected fires in onDescriptorWrite
455
+ // after the CCCD write completes — writing before then returns ok=false.
456
+ val service = gatt.getService(RNS_SERVICE_UUID) ?: return
457
+ val peerTxChar = service.getCharacteristic(RNS_TX_CHAR_UUID) ?: return
458
+ gatt.setCharacteristicNotification(peerTxChar, true)
459
+ val cccd = peerTxChar.getDescriptor(CCCD_UUID) ?: return
460
+ @Suppress("DEPRECATION")
461
+ cccd.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
462
+ @Suppress("DEPRECATION")
463
+ gatt.writeDescriptor(cccd)
464
+ }
465
+
466
+ override fun onDescriptorWrite(gatt: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) {
467
+ val mac = gatt.device.address
468
+ if (descriptor.uuid == CCCD_UUID && descriptor.characteristic?.uuid == RNS_TX_CHAR_UUID) {
469
+ if (status == BluetoothGatt.GATT_SUCCESS) {
470
+ // GATT setup complete — subscribed to peer TX notifications, safe to write now.
471
+ Log.i(TAG, "BLE peer ready (client): $mac")
472
+ module.nativeBleConnected(macToBytes(mac))
473
+ } else {
474
+ // CCCD write failed — can't subscribe to their notifications from client role,
475
+ // but if they already connected to our server and subscribed, the server-side
476
+ // path handles full duplex. Don't disconnect — keep the connection open.
477
+ Log.w(TAG, "BLE CCCD write failed ($status) on $mac — continuing via server role if available")
422
478
  }
423
479
  }
424
- // Notify Rust of new peer
425
- module.nativeBleConnected(macToBytes(gatt.device.address))
426
- Log.i(TAG, "BLE peer ready: ${gatt.device.address}")
427
480
  }
428
481
 
482
+ // API 33+: platform delivers value directly — characteristic.value is null here.
483
+ @Suppress("OVERRIDE_DEPRECATION")
429
484
  override fun onCharacteristicChanged(
430
485
  gatt: BluetoothGatt,
431
486
  characteristic: BluetoothGattCharacteristic,
432
487
  ) {
488
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) return
433
489
  if (characteristic.uuid == RNS_TX_CHAR_UUID) {
434
490
  @Suppress("DEPRECATION")
435
491
  val data = characteristic.value ?: return
436
- Log.d(TAG, "BLE RX ${data.size}B from ${gatt.device.address}")
492
+ Log.d(TAG, "BLE RX(compat) ${data.size}B from ${gatt.device.address}")
437
493
  module.nativeBleReceive(macToBytes(gatt.device.address), data)
438
494
  }
439
495
  }
440
496
 
497
+ @androidx.annotation.RequiresApi(Build.VERSION_CODES.TIRAMISU)
498
+ override fun onCharacteristicChanged(
499
+ gatt: BluetoothGatt,
500
+ characteristic: BluetoothGattCharacteristic,
501
+ value: ByteArray,
502
+ ) {
503
+ if (characteristic.uuid == RNS_TX_CHAR_UUID) {
504
+ Log.d(TAG, "BLE RX ${value.size}B from ${gatt.device.address}")
505
+ module.nativeBleReceive(macToBytes(gatt.device.address), value)
506
+ }
507
+ }
508
+
441
509
  override fun onCharacteristicWrite(
442
510
  gatt: BluetoothGatt,
443
511
  characteristic: BluetoothGattCharacteristic,
@@ -451,41 +519,42 @@ class BleManager(
451
519
 
452
520
  // ── TX drain — poll Rust and write to peer characteristics ───────────────
453
521
 
522
+ // Android GATT is single-op: concurrent writeCharacteristic() calls return
523
+ // false and drop the packet. Send one frame per 50ms poll tick instead of
524
+ // draining all pending frames at once.
454
525
  private fun drainTxQueue() {
455
- while (true) {
456
- val json = module.nativeBlePollTx() ?: break
457
- try {
458
- val obj = org.json.JSONObject(json)
459
- val peerHex = obj.getString("peer") // "aabbccddeeff"
460
- val dataB64 = obj.getString("data")
461
- val data = android.util.Base64.decode(dataB64, android.util.Base64.DEFAULT)
462
- val mac = hexToMacString(peerHex) // "AA:BB:CC:DD:EE:FF"
463
-
464
- // Peripheral (server) role: peer subscribed to our TX char →
465
- // push via NOTIFY. Mirrors iOS peripheralManager.updateValue.
466
- val subscriber = serverSubscribers[mac]
467
- val txChar = serverTxChar
468
- if (subscriber != null && txChar != null) {
469
- val ok = notifyServerSubscriber(subscriber, txChar, data)
470
- Log.d(TAG, "BLE NOTIFY ${data.size}B to $mac ok=$ok")
471
- continue
472
- }
473
-
474
- // Central (client) role: write to peer's RX characteristic.
475
- // RX has WRITE properties; TX is notify-only (writes there
476
- // would be rejected by the peer). Matches iOS convention.
477
- val gatt = connections[mac] ?: continue
478
- val service = gatt.getService(RNS_SERVICE_UUID) ?: continue
479
- val peerRxChar = service.getCharacteristic(RNS_RX_CHAR_UUID) ?: continue
480
- @Suppress("DEPRECATION")
481
- peerRxChar.value = data
482
- peerRxChar.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
483
- @Suppress("DEPRECATION")
484
- val ok = gatt.writeCharacteristic(peerRxChar)
485
- Log.d(TAG, "BLE TX ${data.size}B to $mac ok=$ok")
486
- } catch (e: Exception) {
487
- Log.e(TAG, "drainTxQueue parse error: ${e.message}")
526
+ val json = module.nativeBlePollTx() ?: return
527
+ try {
528
+ val obj = org.json.JSONObject(json)
529
+ val peerHex = obj.getString("peer") // "aabbccddeeff"
530
+ val dataB64 = obj.getString("data")
531
+ val data = android.util.Base64.decode(dataB64, android.util.Base64.DEFAULT)
532
+ val mac = hexToMacString(peerHex) // "AA:BB:CC:DD:EE:FF"
533
+
534
+ // Peripheral (server) role: peer subscribed to our TX char →
535
+ // push via NOTIFY. Mirrors iOS peripheralManager.updateValue.
536
+ val subscriber = serverSubscribers[mac]
537
+ val txChar = serverTxChar
538
+ if (subscriber != null && txChar != null) {
539
+ val ok = notifyServerSubscriber(subscriber, txChar, data)
540
+ Log.d(TAG, "BLE NOTIFY ${data.size}B to $mac ok=$ok")
541
+ return
488
542
  }
543
+
544
+ // Central (client) role: write to peer's RX characteristic.
545
+ // RX has WRITE properties; TX is notify-only (writes there
546
+ // would be rejected by the peer). Matches iOS convention.
547
+ val gatt = connections[mac] ?: return
548
+ val service = gatt.getService(RNS_SERVICE_UUID) ?: return
549
+ val peerRxChar = service.getCharacteristic(RNS_RX_CHAR_UUID) ?: return
550
+ @Suppress("DEPRECATION")
551
+ peerRxChar.value = data
552
+ peerRxChar.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
553
+ @Suppress("DEPRECATION")
554
+ val ok = gatt.writeCharacteristic(peerRxChar)
555
+ Log.d(TAG, "BLE TX ${data.size}B to $mac ok=$ok")
556
+ } catch (e: Exception) {
557
+ Log.e(TAG, "drainTxQueue parse error: ${e.message}")
489
558
  }
490
559
  }
491
560
 
@@ -217,6 +217,7 @@ class LxmfModule : Module() {
217
217
  external fun nativeBleConnected(peerAddr: ByteArray)
218
218
  external fun nativeBleDisconnected(peerAddr: ByteArray)
219
219
  external fun nativeBlePeerCount(): Int
220
+ external fun nativeOnMtuNegotiated(peerAddr: ByteArray, attMtu: Int)
220
221
 
221
222
  companion object {
222
223
  @Volatile
@@ -0,0 +1,50 @@
1
+ # Welcome to your Expo app 👋
2
+
3
+ This is an [Expo](https://expo.dev) project created with [`create-expo-app`](https://www.npmjs.com/package/create-expo-app).
4
+
5
+ ## Get started
6
+
7
+ 1. Install dependencies
8
+
9
+ ```bash
10
+ npm install
11
+ ```
12
+
13
+ 2. Start the app
14
+
15
+ ```bash
16
+ npx expo start
17
+ ```
18
+
19
+ In the output, you'll find options to open the app in a
20
+
21
+ - [development build](https://docs.expo.dev/develop/development-builds/introduction/)
22
+ - [Android emulator](https://docs.expo.dev/workflow/android-studio-emulator/)
23
+ - [iOS simulator](https://docs.expo.dev/workflow/ios-simulator/)
24
+ - [Expo Go](https://expo.dev/go), a limited sandbox for trying out app development with Expo
25
+
26
+ You can start developing by editing the files inside the **app** directory. This project uses [file-based routing](https://docs.expo.dev/router/introduction).
27
+
28
+ ## Get a fresh project
29
+
30
+ When you're ready, run:
31
+
32
+ ```bash
33
+ npm run reset-project
34
+ ```
35
+
36
+ This command will move the starter code to the **app-example** directory and create a blank **app** directory where you can start developing.
37
+
38
+ ## Learn more
39
+
40
+ To learn more about developing your project with Expo, look at the following resources:
41
+
42
+ - [Expo documentation](https://docs.expo.dev/): Learn fundamentals, or go into advanced topics with our [guides](https://docs.expo.dev/guides).
43
+ - [Learn Expo tutorial](https://docs.expo.dev/tutorial/introduction/): Follow a step-by-step tutorial where you'll create a project that runs on Android, iOS, and the web.
44
+
45
+ ## Join the community
46
+
47
+ Join our community of developers creating universal apps.
48
+
49
+ - [Expo on GitHub](https://github.com/expo/expo): View our open source platform and contribute.
50
+ - [Discord community](https://chat.expo.dev): Chat with Expo users and ask questions.
@@ -0,0 +1,28 @@
1
+ import { Tabs } from 'expo-router';
2
+ import React from 'react';
3
+
4
+ import { HapticTab } from '@/components/haptic-tab';
5
+ import { IconSymbol } from '@/components/ui/icon-symbol';
6
+ import { Colors } from '@/constants/theme';
7
+ import { useColorScheme } from '@/hooks/use-color-scheme';
8
+
9
+ export default function TabLayout() {
10
+ const colorScheme = useColorScheme();
11
+
12
+ return (
13
+ <Tabs
14
+ screenOptions={{
15
+ tabBarActiveTintColor: Colors[colorScheme ?? 'light'].tint,
16
+ headerShown: false,
17
+ tabBarButton: HapticTab,
18
+ }}>
19
+ <Tabs.Screen
20
+ name="index"
21
+ options={{
22
+ title: 'Home',
23
+ tabBarIcon: ({ color }) => <IconSymbol size={28} name="house.fill" color={color} />,
24
+ }}
25
+ />
26
+ </Tabs>
27
+ );
28
+ }