@magicred-1/react-native-lxmf 0.2.1 → 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.
@@ -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.
@@ -82,20 +83,29 @@ class BleManager(
82
83
  // ── Lifecycle ────────────────────────────────────────────────────────────
83
84
 
84
85
  fun start() {
86
+ if (isRunning) {
87
+ Log.d(TAG, "BleManager already running — skipping duplicate start()")
88
+ return
89
+ }
85
90
  if (adapter == null || !adapter!!.isEnabled) {
86
91
  Log.w(TAG, "Bluetooth not available or not enabled")
87
92
  return
88
93
  }
94
+ isRunning = true
89
95
  // GATT server must be opened (and service added) before advertising,
90
96
  // otherwise centrals connect and find no service. startAdvertising()
91
97
  // is invoked from onServiceAdded once the service is registered.
92
98
  openGattServer()
93
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)
94
103
  mainHandler.postDelayed(txPollRunnable, TX_POLL_INTERVAL_MS)
95
104
  Log.i(TAG, "BleManager started")
96
105
  }
97
106
 
98
107
  fun stop() {
108
+ isRunning = false
99
109
  stopScanning()
100
110
  stopAdvertising()
101
111
  closeGattServer()
@@ -441,7 +451,8 @@ class BleManager(
441
451
  val effectiveMtu = if (status == BluetoothGatt.GATT_SUCCESS) mtu else 23
442
452
  Log.i(TAG, "BLE MTU negotiated: $mac -> ${effectiveMtu}B")
443
453
  module.nativeOnMtuNegotiated(macToBytes(mac), effectiveMtu)
444
- // Now safe to subscribe each segment will fit in one ATT PDU.
454
+ // Subscribe to peer's TX notifications; nativeBleConnected fires in onDescriptorWrite
455
+ // after the CCCD write completes — writing before then returns ok=false.
445
456
  val service = gatt.getService(RNS_SERVICE_UUID) ?: return
446
457
  val peerTxChar = service.getCharacteristic(RNS_TX_CHAR_UUID) ?: return
447
458
  gatt.setCharacteristicNotification(peerTxChar, true)
@@ -450,22 +461,51 @@ class BleManager(
450
461
  cccd.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
451
462
  @Suppress("DEPRECATION")
452
463
  gatt.writeDescriptor(cccd)
453
- module.nativeBleConnected(macToBytes(mac))
454
- Log.i(TAG, "BLE peer ready: $mac")
455
464
  }
456
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")
478
+ }
479
+ }
480
+ }
481
+
482
+ // API 33+: platform delivers value directly — characteristic.value is null here.
483
+ @Suppress("OVERRIDE_DEPRECATION")
457
484
  override fun onCharacteristicChanged(
458
485
  gatt: BluetoothGatt,
459
486
  characteristic: BluetoothGattCharacteristic,
460
487
  ) {
488
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) return
461
489
  if (characteristic.uuid == RNS_TX_CHAR_UUID) {
462
490
  @Suppress("DEPRECATION")
463
491
  val data = characteristic.value ?: return
464
- 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}")
465
493
  module.nativeBleReceive(macToBytes(gatt.device.address), data)
466
494
  }
467
495
  }
468
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
+
469
509
  override fun onCharacteristicWrite(
470
510
  gatt: BluetoothGatt,
471
511
  characteristic: BluetoothGattCharacteristic,
@@ -479,41 +519,42 @@ class BleManager(
479
519
 
480
520
  // ── TX drain — poll Rust and write to peer characteristics ───────────────
481
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.
482
525
  private fun drainTxQueue() {
483
- while (true) {
484
- val json = module.nativeBlePollTx() ?: break
485
- try {
486
- val obj = org.json.JSONObject(json)
487
- val peerHex = obj.getString("peer") // "aabbccddeeff"
488
- val dataB64 = obj.getString("data")
489
- val data = android.util.Base64.decode(dataB64, android.util.Base64.DEFAULT)
490
- val mac = hexToMacString(peerHex) // "AA:BB:CC:DD:EE:FF"
491
-
492
- // Peripheral (server) role: peer subscribed to our TX char →
493
- // push via NOTIFY. Mirrors iOS peripheralManager.updateValue.
494
- val subscriber = serverSubscribers[mac]
495
- val txChar = serverTxChar
496
- if (subscriber != null && txChar != null) {
497
- val ok = notifyServerSubscriber(subscriber, txChar, data)
498
- Log.d(TAG, "BLE NOTIFY ${data.size}B to $mac ok=$ok")
499
- continue
500
- }
501
-
502
- // Central (client) role: write to peer's RX characteristic.
503
- // RX has WRITE properties; TX is notify-only (writes there
504
- // would be rejected by the peer). Matches iOS convention.
505
- val gatt = connections[mac] ?: continue
506
- val service = gatt.getService(RNS_SERVICE_UUID) ?: continue
507
- val peerRxChar = service.getCharacteristic(RNS_RX_CHAR_UUID) ?: continue
508
- @Suppress("DEPRECATION")
509
- peerRxChar.value = data
510
- peerRxChar.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
511
- @Suppress("DEPRECATION")
512
- val ok = gatt.writeCharacteristic(peerRxChar)
513
- Log.d(TAG, "BLE TX ${data.size}B to $mac ok=$ok")
514
- } catch (e: Exception) {
515
- 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
516
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}")
517
558
  }
518
559
  }
519
560
 
@@ -11,7 +11,7 @@ import {
11
11
  View,
12
12
  } from 'react-native';
13
13
  import * as SecureStore from 'expo-secure-store';
14
- import { LxmfNodeMode, type LxmfEvent, useLxmf } from '@magicred-1/react-native-lxmf';
14
+ import { LxmfModule, LxmfNodeMode, type LxmfEvent, useLxmf } from '@magicred-1/react-native-lxmf';
15
15
 
16
16
  // Persisted identity blob schema (versioned). Stored in expo-secure-store under
17
17
  // IDENTITY_KEY — encrypted at rest on iOS (Keychain) and Android (Keystore-backed).
@@ -215,6 +215,7 @@ export default function HomeScreen() {
215
215
  const [sendResult, setSendResult] = useState('');
216
216
 
217
217
  const [unpairedRNodes, setUnpairedRNodes] = useState(0);
218
+ const [liveBleCount, setLiveBleCount] = useState(0);
218
219
 
219
220
  // Identity hydration: read once from secure store on mount. Until hydrated,
220
221
  // we pass 'new' so Rust generates a fresh identity (which we'll then persist
@@ -305,7 +306,21 @@ export default function HomeScreen() {
305
306
 
306
307
  const announceEvts = useMemo(() => events.filter(e => e.type === 'announceReceived').slice(0, 20), [events]);
307
308
  const msgEvts = useMemo(() => events.filter(e => e.type === 'messageReceived').slice(0, 20), [events]);
308
- const logEvts = useMemo(() => events.filter(e => e.type === 'log').slice(0, 30), [events]);
309
+ const logEvts = useMemo(() => events.filter(e => e.type === 'log').slice(0, 100), [events]);
310
+
311
+ // Deduped peer identity hashes from all announce events (any interface)
312
+ const knownPeerHashes = useMemo(() => {
313
+ const map = new Map<string, { hash: string; name: string; lastSeen: string }>();
314
+ for (const e of events) {
315
+ if (e.type !== 'announceReceived') continue;
316
+ const hash = String(e.destHash ?? e.address ?? '');
317
+ if (!hash) continue;
318
+ if (!map.has(hash)) {
319
+ map.set(hash, { hash, name: e.appData ? String(e.appData) : '', lastSeen: fmtTime(e) });
320
+ }
321
+ }
322
+ return Array.from(map.values());
323
+ }, [events]);
309
324
  const allEvts = useMemo(() => events.slice(0, 30), [events]);
310
325
 
311
326
  // ── Actions ───────────────────────────────────────────────────────────────
@@ -392,6 +407,15 @@ export default function HomeScreen() {
392
407
  return () => clearInterval(id);
393
408
  }, [bleActive, bleUnpairedRNodeCount]);
394
409
 
410
+ // Live BLE peer count — poll every second
411
+ useEffect(() => {
412
+ if (!bleActive) { setLiveBleCount(0); return; }
413
+ const tick = () => { try { setLiveBleCount(LxmfModule.blePeerCount()); } catch {} };
414
+ tick();
415
+ const id = setInterval(tick, 1000);
416
+ return () => clearInterval(id);
417
+ }, [bleActive]);
418
+
395
419
  const onSend = useCallback(async () => {
396
420
  const d = dest.trim().toLowerCase();
397
421
  if (!/^[0-9a-f]{32}$/.test(d)) { setSendResult('Dest = 32 hex chars.'); return; }
@@ -492,7 +516,7 @@ export default function HomeScreen() {
492
516
  <Accordion title="BLE Mesh" defaultOpen>
493
517
  <Text style={S.hint}>Pair RNodes in iOS Settings &gt; Bluetooth first, then start BLE.</Text>
494
518
  <Row label="BLE active" value={bleActive ? 'Yes' : 'No'} />
495
- <Row label="BLE peers" value={String(status?.blePeerCount ?? 0)} />
519
+ <Row label="Connected peers" value={String(liveBleCount)} />
496
520
  {unpairedRNodes > 0 && (
497
521
  <Text style={S.warn}>
498
522
  Found {unpairedRNodes} unpaired RNode{unpairedRNodes > 1 ? 's' : ''}. Open Settings &gt; Bluetooth, pair the device, then restart BLE.
@@ -504,15 +528,27 @@ export default function HomeScreen() {
504
528
  </View>
505
529
  </Accordion>
506
530
 
507
- {/* ── Peers / Beacons ──────────────────────────────────────────────── */}
508
- <Accordion title="Peers / Beacons" badge={beacons.length} defaultOpen={false}>
509
- {beacons.length === 0 ? (
510
- <Text style={S.muted}>No peers yet.</Text>
531
+ {/* ── BLE Peers ────────────────────────────────────────────────────── */}
532
+ <Accordion title="BLE Peers" badge={liveBleCount} defaultOpen>
533
+ <Text style={S.hint}>
534
+ Live BLE connections: {liveBleCount}. LXMF identity hashes appear after peer announces.
535
+ </Text>
536
+ {knownPeerHashes.length === 0 ? (
537
+ <Text style={S.muted}>No peer announces received yet.</Text>
511
538
  ) : (
512
- beacons.map((b, i) => (
513
- <View key={`${b.destHash}-${i}`} style={S.itemCard}>
514
- <Text selectable style={S.itemTitle}>{shortHex(b.destHash)}</Text>
515
- <Text style={S.itemMeta}>state: {b.state} · reconnects: {b.reconnectAttempts}</Text>
539
+ knownPeerHashes.map((p) => (
540
+ <View key={p.hash} style={S.itemCard}>
541
+ {p.name ? <Text style={S.itemTitle}>{p.name}</Text> : null}
542
+ <Text selectable style={S.itemBody}>{p.hash}</Text>
543
+ <Text style={S.itemMeta}>last seen: {p.lastSeen}</Text>
544
+ <View style={S.announceActions}>
545
+ <Pressable style={S.copyBtn} onPress={() => copyToClipboard(p.hash)}>
546
+ <Text style={S.copyBtnText}>⎘</Text>
547
+ </Pressable>
548
+ <Pressable style={S.sendToBtn} onPress={() => { setDest(p.hash); setSendResult(''); }}>
549
+ <Text style={S.sendToBtnText}>→ Send</Text>
550
+ </Pressable>
551
+ </View>
516
552
  </View>
517
553
  ))
518
554
  )}
@@ -637,14 +673,15 @@ export default function HomeScreen() {
637
673
  </Accordion>
638
674
 
639
675
  {/* ── Debug Logs ───────────────────────────────────────────────────── */}
640
- <Accordion title="Debug Logs" badge={counts.logs} defaultOpen={false}>
676
+ <Accordion title="Debug Logs" badge={counts.logs} defaultOpen>
641
677
  {logEvts.length === 0 ? (
642
678
  <Text style={S.muted}>No logs yet.</Text>
643
679
  ) : (
644
680
  logEvts.map((e, i) => (
645
- <Text key={`${evtKey(e, 'lg-')}-${i}`} selectable style={S.logLine} numberOfLines={3}>
646
- [{fmtTime(e)}] {evtSummary(e)}
647
- </Text>
681
+ <View key={`${evtKey(e, 'lg-')}-${i}`} style={S.logRow}>
682
+ <Text style={S.logTime}>{fmtTime(e)}</Text>
683
+ <Text selectable style={S.logLine}>{String(e.message ?? e.msg ?? evtSummary(e))}</Text>
684
+ </View>
648
685
  ))
649
686
  )}
650
687
  </Accordion>
@@ -53,6 +53,10 @@ class BLEManager: NSObject {
53
53
 
54
54
  private var isRunning = false
55
55
 
56
+ /// Called when CoreBluetooth signals it is ready to accept more writes.
57
+ /// LxmfModule sets this to re-trigger drainOutgoing() without a timer.
58
+ var onReadyToSend: (() -> Void)?
59
+
56
60
  /// Per-launch random token embedded in our advertisement's local name, so
57
61
  /// our central role can detect and skip our own peripheral advertisement
58
62
  /// (CoreBluetooth does not auto-filter self when running both roles).
@@ -176,20 +180,30 @@ class BLEManager: NSObject {
176
180
  }
177
181
 
178
182
  /// Send data to a specific peer by 6-byte pseudo-MAC address.
179
- /// Used by drainOutgoing() to route frames from lxmf_ble_poll_tx.
180
- func sendToPeerAddr(_ addr: Data, data: Data) {
181
- // Try peripheral role: send notification to a subscribed central
183
+ /// Returns false when CoreBluetooth's TX buffer is full — caller should stop
184
+ /// draining and wait for onReadyToSend before resuming.
185
+ @discardableResult
186
+ func sendToPeerAddr(_ addr: Data, data: Data) -> Bool {
187
+ // Peripheral role: push notification to subscribed central.
188
+ // updateValue returns false when the subscriber's buffer is full;
189
+ // peripheralManagerIsReady(toUpdateSubscribers:) fires when it drains.
182
190
  if let central = addrToCentral[addr], let txChar = txCharacteristic {
183
- peripheralManager?.updateValue(data, for: txChar, onSubscribedCentrals: [central])
184
- return
191
+ let ok = peripheralManager?.updateValue(data, for: txChar, onSubscribedCentrals: [central]) ?? false
192
+ return ok
185
193
  }
186
194
 
187
- // Try central role: write to connected peripheral's RX characteristic
195
+ // Central role: write to peer's RX characteristic.
196
+ // canSendWriteWithoutResponse goes false when the internal queue is full;
197
+ // peripheral(_:isReadyToSendWriteWithoutResponse:) fires when it drains.
188
198
  if let peripheralUUID = addrToPeripheralUUID[addr],
189
199
  let peripheral = connectedPeripherals[peripheralUUID],
190
200
  let char = txCharacteristics[peripheralUUID] {
201
+ guard peripheral.canSendWriteWithoutResponse else { return false }
191
202
  peripheral.writeValue(data, for: char, type: .withoutResponse)
203
+ return true
192
204
  }
205
+
206
+ return true // peer not found — frame consumed, no retry needed
193
207
  }
194
208
 
195
209
  /// Write KISS-framed data to all connected RNodes via NUS TX characteristic.
@@ -471,6 +485,12 @@ extension BLEManager: CBPeripheralDelegate {
471
485
  }
472
486
  }
473
487
  }
488
+
489
+ // Called when writeValue(.withoutResponse) exhausted the internal queue.
490
+ // Re-trigger TX drain so buffered frames get sent now that there's room.
491
+ func peripheralIsReady(toSendWriteWithoutResponse peripheral: CBPeripheral) {
492
+ onReadyToSend?()
493
+ }
474
494
  }
475
495
 
476
496
  // MARK: - CBPeripheralManagerDelegate
@@ -537,6 +557,11 @@ extension BLEManager: CBPeripheralManagerDelegate {
537
557
  }
538
558
  }
539
559
 
560
+ // Called when updateValue returned false and the subscriber buffer has drained.
561
+ func peripheralManagerIsReady(toUpdateSubscribers peripheral: CBPeripheralManager) {
562
+ onReadyToSend?()
563
+ }
564
+
540
565
  // Background restoration
541
566
  func peripheralManager(_ peripheral: CBPeripheralManager, willRestoreState dict: [String: Any]) {
542
567
  // Re-setup services on restoration
@@ -141,7 +141,11 @@ public class LxmfModule: Module {
141
141
  private var txDrainTimer: Timer?
142
142
 
143
143
  // BLE manager for phone-to-phone mesh
144
- private lazy var bleManager = BLEManager()
144
+ private lazy var bleManager: BLEManager = {
145
+ let mgr = BLEManager()
146
+ mgr.onReadyToSend = { [weak self] in DispatchQueue.main.async { self?.drainOutgoing() } }
147
+ return mgr
148
+ }()
145
149
 
146
150
  public func definition() -> ModuleDefinition {
147
151
  Name("LxmfModule")
@@ -391,7 +395,8 @@ public class LxmfModule: Module {
391
395
 
392
396
  let frameData = Data(dataBuf[0..<Int(len)])
393
397
  let addr = Data(peerAddr)
394
- bleManager.sendToPeerAddr(addr, data: frameData)
398
+ // Stop draining if CoreBluetooth buffer is full — onReadyToSend re-triggers us.
399
+ guard bleManager.sendToPeerAddr(addr, data: frameData) else { break }
395
400
  }
396
401
 
397
402
  // --- NUS: poll for KISS-framed RNode data ---
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magicred-1/react-native-lxmf",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "description": "LXMF Reticulum mesh networking for React Native + Expo",
5
5
  "main": "build/index.js",
6
6
  "types": "build/index.d.ts",