@magicred-1/react-native-lxmf 0.2.30 → 0.2.32

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.
@@ -48,8 +48,6 @@ class BleManager(
48
48
  private val connecting = mutableSetOf<String>()
49
49
  // Timestamp (ms) when each MAC last disconnected — enforces reconnect cooldown
50
50
  private val disconnectedAt = mutableMapOf<String, Long>()
51
- // MACs seen in scan results but not yet GATT-connected (mirrors iOS discoveredUnpairedRNodes)
52
- private val discoveredUnpaired = mutableSetOf<String>()
53
51
 
54
52
  private var scanner: BluetoothLeScanner? = null
55
53
  private var advertiser: BluetoothLeAdvertiser? = null
@@ -115,12 +113,10 @@ class BleManager(
115
113
  connections.values.forEach { it.disconnect(); it.close() }
116
114
  connections.clear()
117
115
  connecting.clear()
118
- discoveredUnpaired.clear()
119
116
  Log.i(TAG, "BleManager stopped")
120
117
  }
121
118
 
122
119
  fun connectedPeerCount(): Int = module.nativeBlePeerCount()
123
- fun unpairedRNodeCount(): Int = discoveredUnpaired.size
124
120
 
125
121
  // ── Advertising (so peers can find us) ───────────────────────────────────
126
122
 
@@ -396,7 +392,6 @@ class BleManager(
396
392
  val lastDisconnect = disconnectedAt[mac] ?: 0L
397
393
  if (System.currentTimeMillis() - lastDisconnect < RECONNECT_COOLDOWN_MS) return
398
394
  Log.i(TAG, "BLE: found peer $mac, connecting")
399
- discoveredUnpaired.add(mac)
400
395
  connecting.add(mac)
401
396
  device.connectGatt(context, false, gattCallback, BluetoothDevice.TRANSPORT_LE)
402
397
  }
@@ -418,7 +413,6 @@ class BleManager(
418
413
  Log.i(TAG, "BLE GATT connected: $mac")
419
414
  connections[mac] = gatt
420
415
  connecting.remove(mac)
421
- discoveredUnpaired.remove(mac)
422
416
  gatt.discoverServices()
423
417
  }
424
418
  BluetoothProfile.STATE_DISCONNECTED -> {
@@ -427,7 +421,6 @@ class BleManager(
427
421
  Log.i(TAG, "BLE GATT disconnected: $mac (status=$status)")
428
422
  connections.remove(mac)
429
423
  connecting.remove(mac)
430
- discoveredUnpaired.remove(mac)
431
424
  disconnectedAt[mac] = System.currentTimeMillis()
432
425
  gatt.close()
433
426
  // Notify Rust
@@ -15,12 +15,13 @@ class LxmfModule : Module() {
15
15
  private val pollRunnable = object : Runnable {
16
16
  override fun run() {
17
17
  drainAndEmitEvents()
18
+ nusManager?.pollTxAndWrite()
18
19
  pollHandler.postDelayed(this, POLL_INTERVAL_MS)
19
20
  }
20
21
  }
21
22
 
22
- // BleManager is created lazily when the app context is available
23
23
  private var bleManager: BleManager? = null
24
+ private var nusManager: NusManager? = null
24
25
 
25
26
  override fun definition() = ModuleDefinition {
26
27
  Name("LxmfModule")
@@ -48,6 +49,7 @@ class LxmfModule : Module() {
48
49
  ?: appContext.currentActivity?.applicationContext
49
50
  if (ctx != null) {
50
51
  bleManager = BleManager(ctx, this@LxmfModule)
52
+ nusManager = NusManager(ctx, this@LxmfModule)
51
53
  }
52
54
  }
53
55
 
@@ -55,6 +57,8 @@ class LxmfModule : Module() {
55
57
  pollHandler.removeCallbacks(pollRunnable)
56
58
  bleManager?.stop()
57
59
  bleManager = null
60
+ nusManager?.stop()
61
+ nusManager = null
58
62
  }
59
63
 
60
64
  // Lifecycle
@@ -79,11 +83,13 @@ class LxmfModule : Module() {
79
83
  bleMtuHint.toShort(), interfacesJson, displayName, isBeacon)
80
84
  if (rc != 0) throw RuntimeException("nativeStart returned $rc")
81
85
  bleManager?.start()
86
+ nusManager?.start()
82
87
  true
83
88
  }
84
89
 
85
90
  AsyncFunction("stop") {
86
91
  bleManager?.stop()
92
+ nusManager?.stop()
87
93
  val rc = nativeStop()
88
94
  if (rc != 0) throw RuntimeException("nativeStop returned $rc")
89
95
  true
@@ -134,12 +140,14 @@ class LxmfModule : Module() {
134
140
  Function("startBLE") {
135
141
  Log.d(TAG, "startBLE()")
136
142
  bleManager?.start()
143
+ nusManager?.start()
137
144
  true
138
145
  }
139
146
 
140
147
  Function("stopBLE") {
141
148
  Log.d(TAG, "stopBLE()")
142
149
  bleManager?.stop()
150
+ nusManager?.stop()
143
151
  true
144
152
  }
145
153
 
@@ -148,7 +156,7 @@ class LxmfModule : Module() {
148
156
  }
149
157
 
150
158
  Function("bleUnpairedRNodeCount") {
151
- bleManager?.unpairedRNodeCount() ?: 0
159
+ nusManager?.unpairedRNodeCount() ?: 0
152
160
  }
153
161
  }
154
162
 
@@ -217,6 +225,10 @@ class LxmfModule : Module() {
217
225
  private external fun nativeSetLogLevel(level: Int): Int
218
226
  private external fun nativeAbiVersion(): Int
219
227
 
228
+ // NUS JNI — called by NusManager (same package)
229
+ external fun nativeNusReceive(data: ByteArray)
230
+ external fun nativeNusPollTx(): ByteArray?
231
+
220
232
  // BLE JNI — called by BleManager (same package)
221
233
  // Must NOT be `internal` — Kotlin mangles internal function names in JVM bytecode,
222
234
  // which breaks JNI symbol resolution (produces e.g. nativeBlePollTx$lxmf_react_native_debug).
@@ -0,0 +1,283 @@
1
+ package expo.modules.lxmf
2
+
3
+ import android.bluetooth.*
4
+ import android.bluetooth.le.*
5
+ import android.content.Context
6
+ import android.os.Build
7
+ import android.os.Handler
8
+ import android.os.Looper
9
+ import android.os.ParcelUuid
10
+ import android.util.Log
11
+ import java.util.UUID
12
+
13
+ private const val NUS_TAG = "LxmfNus"
14
+
15
+ // NUS GATT UUIDs — must match nus_iface.rs constants exactly
16
+ val NUS_SERVICE_UUID: UUID = UUID.fromString("6e400001-b5a3-f393-e0a9-e50e24dcca9e")
17
+ val NUS_TX_CHAR_UUID: UUID = UUID.fromString("6e400002-b5a3-f393-e0a9-e50e24dcca9e") // phone writes TO RNode
18
+ val NUS_RX_CHAR_UUID: UUID = UUID.fromString("6e400003-b5a3-f393-e0a9-e50e24dcca9e") // phone receives FROM RNode
19
+
20
+ /**
21
+ * NusManager — Android BLE client for RNode hardware (Heltec V3 etc.) via Nordic UART Service.
22
+ *
23
+ * Mirrors iOS BLEManager NUS logic:
24
+ * - Scans for NUS service UUID
25
+ * - If device is OS-paired (BOND_BONDED): connects and sets up KISS pipe to Rust NusInterface
26
+ * - If not paired: tracks as discoveredUnpaired (bleUnpairedRNodeCount returns this count)
27
+ * - RX notifications → nativeNusReceive() → Rust NusInterface (KISS deframing + transport)
28
+ * - TX polling → nativeNusPollTx() → write chunked to RNode's NUS TX characteristic
29
+ *
30
+ * Separate from BleManager (mesh peers) — two different scan filters, two GATT roles.
31
+ */
32
+ class NusManager(
33
+ private val context: Context,
34
+ private val module: LxmfModule,
35
+ ) {
36
+ private val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager
37
+ private val adapter: BluetoothAdapter? get() = bluetoothManager?.adapter
38
+ private val mainHandler = Handler(Looper.getMainLooper())
39
+
40
+ // Active GATT connections to bonded RNodes, keyed by MAC
41
+ private val connections = mutableMapOf<String, BluetoothGatt>()
42
+ // NUS TX characteristics (phone writes TO RNode), keyed by MAC
43
+ private val txChars = mutableMapOf<String, BluetoothGattCharacteristic>()
44
+ // Negotiated write MTU per connection — used for TX chunking
45
+ private val writeMtu = mutableMapOf<String, Int>()
46
+ // MACs found in scan but not OS-paired — UI should prompt user to pair in Settings
47
+ private val discoveredUnpaired = mutableSetOf<String>()
48
+ // MACs currently attempting connection
49
+ private val connecting = mutableSetOf<String>()
50
+
51
+ private var scanner: BluetoothLeScanner? = null
52
+ private var isScanning = false
53
+ private var isRunning = false
54
+
55
+ companion object {
56
+ private const val SCAN_RESTART_DELAY_MS = 30_000L
57
+ private const val RECONNECT_DELAY_MS = 3_000L
58
+ private const val DEFAULT_WRITE_MTU = 20 // conservative BLE default
59
+ }
60
+
61
+ // ── Lifecycle ────────────────────────────────────────────────────────────
62
+
63
+ fun start() {
64
+ if (isRunning) return
65
+ isRunning = true
66
+ startScanning()
67
+ Log.i(NUS_TAG, "NusManager started")
68
+ }
69
+
70
+ fun stop() {
71
+ isRunning = false
72
+ stopScanning()
73
+ connections.values.forEach { it.disconnect(); it.close() }
74
+ connections.clear()
75
+ txChars.clear()
76
+ writeMtu.clear()
77
+ connecting.clear()
78
+ discoveredUnpaired.clear()
79
+ Log.i(NUS_TAG, "NusManager stopped")
80
+ }
81
+
82
+ /** Number of RNodes visible in scan but not yet OS-paired (mirrors iOS discoveredUnpairedRNodes.count). */
83
+ fun unpairedRNodeCount(): Int = discoveredUnpaired.size
84
+
85
+ /** Number of fully connected and ready RNodes. */
86
+ fun connectedRNodeCount(): Int = connections.size
87
+
88
+ /**
89
+ * Drain Rust NUS TX queue and write each frame to all connected RNodes.
90
+ * Called from LxmfModule poll runnable at the same 80ms cadence as event polling.
91
+ */
92
+ fun pollTxAndWrite() {
93
+ if (connections.isEmpty()) return
94
+ repeat(8) {
95
+ val data = module.nativeNusPollTx() ?: return
96
+ for ((mac, char) in txChars) {
97
+ val gatt = connections[mac] ?: return@repeat
98
+ writeChunked(gatt, char, data, writeMtu[mac] ?: DEFAULT_WRITE_MTU)
99
+ }
100
+ }
101
+ }
102
+
103
+ private fun writeChunked(
104
+ gatt: BluetoothGatt,
105
+ char: BluetoothGattCharacteristic,
106
+ data: ByteArray,
107
+ mtu: Int,
108
+ ) {
109
+ var offset = 0
110
+ while (offset < data.size) {
111
+ val end = minOf(offset + mtu, data.size)
112
+ val chunk = data.copyOfRange(offset, end)
113
+ @Suppress("DEPRECATION")
114
+ char.value = chunk
115
+ char.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
116
+ @Suppress("DEPRECATION")
117
+ gatt.writeCharacteristic(char)
118
+ offset = end
119
+ }
120
+ }
121
+
122
+ // ── Scanning ─────────────────────────────────────────────────────────────
123
+
124
+ private fun startScanning() {
125
+ if (isScanning) return
126
+ scanner = adapter?.bluetoothLeScanner ?: return
127
+ val filter = ScanFilter.Builder()
128
+ .setServiceUuid(ParcelUuid(NUS_SERVICE_UUID))
129
+ .build()
130
+ val settings = ScanSettings.Builder()
131
+ .setScanMode(ScanSettings.SCAN_MODE_BALANCED)
132
+ .build()
133
+ scanner?.startScan(listOf(filter), settings, scanCallback)
134
+ isScanning = true
135
+ Log.d(NUS_TAG, "NUS scan started")
136
+ }
137
+
138
+ private fun stopScanning() {
139
+ if (isScanning) {
140
+ scanner?.stopScan(scanCallback)
141
+ isScanning = false
142
+ }
143
+ }
144
+
145
+ private val scanCallback = object : ScanCallback() {
146
+ override fun onScanResult(callbackType: Int, result: ScanResult) {
147
+ val device = result.device ?: return
148
+ val mac = device.address ?: return
149
+ if (mac in connections || mac in connecting) return
150
+
151
+ when (device.bondState) {
152
+ BluetoothDevice.BOND_BONDED -> {
153
+ // OS-paired — safe to connect and use NUS pipe
154
+ discoveredUnpaired.remove(mac)
155
+ Log.i(NUS_TAG, "NUS: found bonded RNode $mac, connecting")
156
+ connecting.add(mac)
157
+ device.connectGatt(context, false, gattCallback, BluetoothDevice.TRANSPORT_LE)
158
+ }
159
+ else -> {
160
+ // Not paired — track for UI, don't auto-connect
161
+ if (discoveredUnpaired.add(mac)) {
162
+ Log.i(NUS_TAG, "NUS: found unpaired RNode $mac — pair in Bluetooth Settings first")
163
+ }
164
+ }
165
+ }
166
+ }
167
+
168
+ override fun onScanFailed(errorCode: Int) {
169
+ Log.e(NUS_TAG, "NUS scan failed: $errorCode")
170
+ isScanning = false
171
+ mainHandler.postDelayed({ startScanning() }, SCAN_RESTART_DELAY_MS)
172
+ }
173
+ }
174
+
175
+ // ── GATT ─────────────────────────────────────────────────────────────────
176
+
177
+ private val gattCallback = object : BluetoothGattCallback() {
178
+ override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
179
+ val mac = gatt.device.address
180
+ when (newState) {
181
+ BluetoothProfile.STATE_CONNECTED -> {
182
+ Log.i(NUS_TAG, "NUS GATT connected: $mac")
183
+ connections[mac] = gatt
184
+ connecting.remove(mac)
185
+ discoveredUnpaired.remove(mac)
186
+ // Negotiate large MTU before service discovery
187
+ gatt.requestMtu(517)
188
+ }
189
+ BluetoothProfile.STATE_DISCONNECTED -> {
190
+ if (mac !in connections && mac !in connecting) return
191
+ Log.i(NUS_TAG, "NUS GATT disconnected: $mac (status=$status)")
192
+ connections.remove(mac)
193
+ txChars.remove(mac)
194
+ writeMtu.remove(mac)
195
+ connecting.remove(mac)
196
+ gatt.close()
197
+ // Auto-reconnect bonded devices
198
+ if (isRunning && gatt.device.bondState == BluetoothDevice.BOND_BONDED) {
199
+ mainHandler.postDelayed({
200
+ if (isRunning && mac !in connections && mac !in connecting) {
201
+ Log.i(NUS_TAG, "NUS: reconnecting $mac")
202
+ connecting.add(mac)
203
+ gatt.device.connectGatt(
204
+ context, false, this, BluetoothDevice.TRANSPORT_LE
205
+ )
206
+ }
207
+ }, RECONNECT_DELAY_MS)
208
+ }
209
+ }
210
+ }
211
+ }
212
+
213
+ override fun onMtuChanged(gatt: BluetoothGatt, mtu: Int, status: Int) {
214
+ val mac = gatt.device.address
215
+ val effective = if (status == BluetoothGatt.GATT_SUCCESS) mtu - 3 else DEFAULT_WRITE_MTU
216
+ writeMtu[mac] = effective.coerceAtLeast(DEFAULT_WRITE_MTU)
217
+ Log.i(NUS_TAG, "NUS MTU negotiated: $mac → ${effective}B write limit")
218
+ gatt.discoverServices()
219
+ }
220
+
221
+ override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
222
+ val mac = gatt.device.address
223
+ if (status != BluetoothGatt.GATT_SUCCESS) {
224
+ Log.w(NUS_TAG, "NUS service discovery failed on $mac: $status")
225
+ gatt.disconnect()
226
+ return
227
+ }
228
+ val service = gatt.getService(NUS_SERVICE_UUID)
229
+ if (service == null) {
230
+ Log.w(NUS_TAG, "NUS service not found on $mac — not an RNode?")
231
+ gatt.disconnect()
232
+ return
233
+ }
234
+
235
+ // NUS TX char — phone writes KISS frames TO the RNode
236
+ val txChar = service.getCharacteristic(NUS_TX_CHAR_UUID)
237
+ if (txChar != null) txChars[mac] = txChar
238
+
239
+ // NUS RX char — subscribe for KISS frame notifications FROM the RNode
240
+ val rxChar = service.getCharacteristic(NUS_RX_CHAR_UUID)
241
+ if (rxChar != null) {
242
+ gatt.setCharacteristicNotification(rxChar, true)
243
+ val cccd = rxChar.getDescriptor(CCCD_UUID)
244
+ if (cccd != null) {
245
+ @Suppress("DEPRECATION")
246
+ cccd.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
247
+ @Suppress("DEPRECATION")
248
+ gatt.writeDescriptor(cccd)
249
+ }
250
+ }
251
+
252
+ Log.i(NUS_TAG, "NUS RNode ready: $mac (tx=${txChar != null}, rx=${rxChar != null})")
253
+ }
254
+
255
+ // API < 33 compat override
256
+ @Suppress("OVERRIDE_DEPRECATION")
257
+ override fun onCharacteristicChanged(
258
+ gatt: BluetoothGatt,
259
+ characteristic: BluetoothGattCharacteristic,
260
+ ) {
261
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) return
262
+ if (characteristic.uuid == NUS_RX_CHAR_UUID) {
263
+ @Suppress("DEPRECATION")
264
+ val data = characteristic.value ?: return
265
+ Log.d(NUS_TAG, "NUS RX(compat) ${data.size}B from ${gatt.device.address}")
266
+ module.nativeNusReceive(data)
267
+ }
268
+ }
269
+
270
+ // API 33+ — value delivered directly, no stale characteristic.value
271
+ @androidx.annotation.RequiresApi(Build.VERSION_CODES.TIRAMISU)
272
+ override fun onCharacteristicChanged(
273
+ gatt: BluetoothGatt,
274
+ characteristic: BluetoothGattCharacteristic,
275
+ value: ByteArray,
276
+ ) {
277
+ if (characteristic.uuid == NUS_RX_CHAR_UUID) {
278
+ Log.d(NUS_TAG, "NUS RX ${value.size}B from ${gatt.device.address}")
279
+ module.nativeNusReceive(value)
280
+ }
281
+ }
282
+ }
283
+ }
@@ -207,6 +207,7 @@ export default function HomeScreen() {
207
207
 
208
208
  const [unpairedRNodes, setUnpairedRNodes] = useState(0);
209
209
  const [liveBleCount, setLiveBleCount] = useState(0);
210
+ const [storedMsgs, setStoredMsgs] = useState<any[]>([]);
210
211
 
211
212
  // Identity hydration: read once from secure store on mount. Until hydrated,
212
213
  // we pass 'new' so Rust generates a fresh identity (which we'll then persist
@@ -218,20 +219,13 @@ export default function HomeScreen() {
218
219
  (async () => {
219
220
  try {
220
221
  const raw = await SecureStore.getItemAsync(IDENTITY_KEY);
221
- // TODO(sentinel): remove debug logs before merge — lengths/booleans only, no key material
222
- console.log('[persist] hydrate', { hasRaw: !!raw, rawLen: raw?.length ?? 0 });
223
222
  if (cancelled) return;
224
223
  if (raw) {
225
224
  const parsed = JSON.parse(raw);
226
- const valid = isValidIdentity(parsed);
227
- console.log('[persist] parsed', { valid, hasIdHex: typeof parsed?.identity_hex === 'string', hasAddrHex: typeof parsed?.address_hex === 'string' });
228
- if (valid) {
229
- setStoredIdentity(parsed);
230
- }
225
+ if (isValidIdentity(parsed)) setStoredIdentity(parsed);
231
226
  }
232
- } catch (e: any) {
233
- console.log('[persist] hydrate FAIL', e?.message ?? 'unknown');
234
- // Corrupt blob or storage error — fall through; we'll generate fresh.
227
+ } catch {
228
+ // Corrupt blob or storage error — fall through; generate fresh identity.
235
229
  } finally {
236
230
  if (!cancelled) setIdentityHydrated(true);
237
231
  }
@@ -240,31 +234,23 @@ export default function HomeScreen() {
240
234
  }, []);
241
235
 
242
236
  const {
243
- isNativeAvailable, isRunning, status, error, events, beacons,
244
- start, stop, send, getStatus, getIdentityHex,
245
- startBLE, stopBLE, bleUnpairedRNodeCount,
237
+ isNativeAvailable, isRunning, status, error, events,
238
+ start, stop, send, broadcast, getStatus, getIdentityHex, fetchMessages,
239
+ bleUnpairedRNodeCount,
246
240
  } = useLxmf({
247
241
  identityHex: storedIdentity?.identity_hex ?? 'new',
248
242
  lxmfAddressHex: storedIdentity?.address_hex ?? 'new',
249
243
  logLevel: 3,
250
244
  });
251
245
 
252
- // Persist identity after node starts successfully (only if not already stored,
253
- // or if the running identity differs — defensive against schema migrations).
246
+ // Persist identity after node starts (only when identity changes from stored copy).
254
247
  useEffect(() => {
255
248
  if (!isRunning) return;
256
249
  const idHex = getIdentityHex();
257
250
  const addrHex = status?.addressHex;
258
- // TODO(sentinel): remove debug logs before merge — lengths/booleans only, no key material
259
- console.log('[persist] save check', {
260
- idHexLen: idHex?.length ?? 0,
261
- addrHexLen: addrHex?.length ?? 0,
262
- alreadyStoredSame: storedIdentity?.identity_hex === idHex && storedIdentity?.address_hex === addrHex,
263
- });
264
251
  if (!idHex || idHex.length !== 128) return;
265
252
  if (!addrHex || !/^[0-9a-fA-F]{32}$/.test(addrHex)) return;
266
253
  if (storedIdentity?.identity_hex === idHex && storedIdentity?.address_hex === addrHex) return;
267
-
268
254
  const blob: StoredIdentity = {
269
255
  version: IDENTITY_SCHEMA_VERSION,
270
256
  identity_hex: idHex,
@@ -272,16 +258,15 @@ export default function HomeScreen() {
272
258
  created_at: new Date().toISOString(),
273
259
  };
274
260
  SecureStore.setItemAsync(IDENTITY_KEY, JSON.stringify(blob))
275
- .then(() => {
276
- console.log('[persist] save OK');
277
- setStoredIdentity(blob);
278
- })
279
- .catch((e: any) => {
280
- console.log('[persist] save FAIL', e?.message ?? 'unknown');
281
- /* persistence failure is non-fatal for the running session */
282
- });
261
+ .then(() => setStoredIdentity(blob))
262
+ .catch(() => { /* non-fatal */ });
283
263
  }, [isRunning, status?.addressHex, storedIdentity, getIdentityHex]);
284
264
 
265
+ // Load persisted messages from SQLite whenever node starts.
266
+ useEffect(() => {
267
+ if (isRunning) setStoredMsgs(fetchMessages(50));
268
+ }, [isRunning, fetchMessages]);
269
+
285
270
  // ── Derived ───────────────────────────────────────────────────────────────
286
271
 
287
272
  const counts = useMemo(() => {
@@ -326,21 +311,18 @@ export default function HomeScreen() {
326
311
  mode: LxmfNodeMode.ReticulumAndBle,
327
312
  tcpInterfaces: [{ host, port }],
328
313
  displayName: displayName.trim() || 'lxmf-mobile',
329
- isBeacon,
330
314
  });
331
315
  if (ok) {
332
316
  setTcpActive(true);
333
- startBLE();
334
317
  setBleActive(true);
335
318
  }
336
- }, [tcpHost, tcpPort, displayName, isBeacon, start, startBLE]);
319
+ }, [tcpHost, tcpPort, displayName, start]);
337
320
 
338
321
  const onStopTcp = useCallback(async () => {
339
- stopBLE();
340
322
  await stop();
341
323
  setTcpActive(false);
342
324
  setBleActive(false);
343
- }, [stop, stopBLE]);
325
+ }, [stop]);
344
326
 
345
327
  const onStartBle = useCallback(async () => {
346
328
  setTransportMsg('');
@@ -353,43 +335,31 @@ export default function HomeScreen() {
353
335
  ]
354
336
  : [PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION];
355
337
  const results = await PermissionsAndroid.requestMultiple(perms);
356
- const denied = Object.values(results).some(r => r !== PermissionsAndroid.RESULTS.GRANTED);
357
- if (denied) {
338
+ if (Object.values(results).some(r => r !== PermissionsAndroid.RESULTS.GRANTED)) {
358
339
  setTransportMsg('BLE permissions denied.');
359
340
  return;
360
341
  }
361
342
  }
362
- // If node already running (started via TCP), just enable BLE hardware.
363
- // Otherwise start mode 4 (TCP+BLE) using the configured TCP host/port.
364
- if (!isRunning) {
365
- const host = tcpHost.trim();
366
- const port = Number(tcpPort);
367
- if (!host) { setTransportMsg('Host required for TCP+BLE mode.'); return; }
368
- if (!Number.isInteger(port) || port < 1 || port > 65535) { setTransportMsg('Port 1–65535.'); return; }
369
- const ok = await start({
370
- mode: LxmfNodeMode.ReticulumAndBle,
371
- tcpInterfaces: [{ host, port }],
372
- displayName: displayName.trim() || 'lxmf-mobile',
373
- isBeacon,
374
- });
375
- if (!ok) {
376
- setTransportMsg('Failed to start node.');
377
- return;
378
- }
379
- setTcpActive(true);
380
- }
381
- startBLE();
343
+ const ok = await start({
344
+ mode: LxmfNodeMode.BleOnly,
345
+ displayName: displayName.trim() || 'lxmf-mobile',
346
+ });
347
+ if (!ok) { setTransportMsg('Failed to start BLE node.'); return; }
382
348
  setBleActive(true);
383
- }, [isRunning, tcpHost, tcpPort, isBeacon, start, startBLE, displayName]);
349
+ }, [start, displayName]);
384
350
 
385
351
  const onStopBle = useCallback(async () => {
386
- stopBLE();
352
+ await stop();
387
353
  setBleActive(false);
388
354
  setUnpairedRNodes(0);
389
- if (isRunning) {
390
- await stop();
391
- }
392
- }, [stopBLE, stop, isRunning]);
355
+ }, [stop]);
356
+
357
+ const onBroadcast = useCallback(async () => {
358
+ if (!knownPeerHashes.length) { setSendResult('No known peers.'); return; }
359
+ const dests = knownPeerHashes.map(p => p.hash);
360
+ const r = await broadcast(dests, utf8ToBase64(msgText));
361
+ setSendResult(r >= 0 ? `Broadcast #${r} → ${dests.length} peers` : 'Broadcast failed.');
362
+ }, [knownPeerHashes, msgText, broadcast]);
393
363
 
394
364
  // Poll for unpaired RNodes while BLE is active
395
365
  useEffect(() => {
@@ -617,13 +587,51 @@ export default function HomeScreen() {
617
587
  />
618
588
  <View style={S.btnRow}>
619
589
  <Btn label="Send" onPress={onSend} disabled={!isRunning} />
590
+ <Btn label="Broadcast" onPress={onBroadcast} disabled={!isRunning || !knownPeerHashes.length} />
620
591
  </View>
621
592
  {sendResult ? <Text style={S.feedback}>{sendResult}</Text> : null}
622
593
  </Accordion>
623
594
 
624
595
  {/* ── Messages ─────────────────────────────────────────────────────── */}
625
- <Accordion title="Messages" badge={counts.messages} defaultOpen>
626
- {msgEvts.length === 0 ? (
596
+ <Accordion title="Messages" badge={counts.messages + storedMsgs.length} defaultOpen>
597
+ {/* Persisted (SQLite) */}
598
+ {storedMsgs.length > 0 && (
599
+ <>
600
+ <Text style={S.sectionLabel}>Persisted ({storedMsgs.length})</Text>
601
+ {storedMsgs.map((m: any, i: number) => {
602
+ const bodyText = base64ToUtf8(m.body ?? '');
603
+ const titleText = m.title ? base64ToUtf8(m.title) : '';
604
+ const sender = m.source ?? m.source_hash ?? '';
605
+ const t = m.timestamp ? new Date(m.timestamp > 10_000_000_000 ? m.timestamp : m.timestamp * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '';
606
+ return (
607
+ <View key={`stored-${i}-${sender}`} style={[S.itemCard, S.storedCard]}>
608
+ <View style={S.announceHeader}>
609
+ <View style={S.announceInfo}>
610
+ <Text selectable style={S.itemTitle}>From: {shortHex(sender)}</Text>
611
+ {titleText ? <Text selectable style={S.msgTitle}>{titleText}</Text> : null}
612
+ {bodyText ? <Text selectable style={S.itemBody}>{bodyText}</Text> : null}
613
+ {t ? <Text style={S.itemMeta}>{t}</Text> : null}
614
+ </View>
615
+ {sender ? (
616
+ <View style={S.announceActions}>
617
+ <Pressable style={S.copyBtn} onPress={() => copyToClipboard(sender)}>
618
+ <Text style={S.copyBtnText}>⎘</Text>
619
+ </Pressable>
620
+ <Pressable style={S.sendToBtn} onPress={() => { setDest(sender); setSendResult(''); }}>
621
+ <Text style={S.sendToBtnText}>↩ Reply</Text>
622
+ </Pressable>
623
+ </View>
624
+ ) : null}
625
+ </View>
626
+ </View>
627
+ );
628
+ })}
629
+ </>
630
+ )}
631
+
632
+ {/* Live (in-session) */}
633
+ {msgEvts.length > 0 && <Text style={S.sectionLabel}>Live session</Text>}
634
+ {msgEvts.length === 0 && storedMsgs.length === 0 ? (
627
635
  <Text style={S.muted}>No messages yet.</Text>
628
636
  ) : (
629
637
  msgEvts.map((e, i) => {
@@ -885,4 +893,6 @@ const S = StyleSheet.create({
885
893
  // Message card extras
886
894
  msgTitle: { color: C.text, fontSize: 13, fontWeight: '600', fontStyle: 'italic' },
887
895
  mediaBadge: { color: C.accentBright, fontSize: 11, fontFamily: 'monospace', marginTop: 2 },
896
+ sectionLabel: { color: C.textDim, fontSize: 11, fontWeight: '600', textTransform: 'uppercase', letterSpacing: 0.8, marginTop: 4 },
897
+ storedCard: { borderColor: '#253d50', backgroundColor: '#0b1a25' },
888
898
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magicred-1/react-native-lxmf",
3
- "version": "0.2.30",
3
+ "version": "0.2.32",
4
4
  "description": "LXMF Reticulum mesh networking for React Native + Expo",
5
5
  "main": "build/index.js",
6
6
  "types": "build/index.d.ts",