@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.
- package/android/src/main/jniLibs/arm64-v8a/liblxmf_rn.so +0 -0
- package/android/src/main/jniLibs/armeabi-v7a/liblxmf_rn.so +0 -0
- package/android/src/main/jniLibs/x86_64/liblxmf_rn.so +0 -0
- package/android/src/main/kotlin/expo/modules/lxmf/BleManager.kt +0 -7
- package/android/src/main/kotlin/expo/modules/lxmf/LxmfModule.kt +14 -2
- package/android/src/main/kotlin/expo/modules/lxmf/NusManager.kt +283 -0
- package/example/app/(tabs)/index.tsx +75 -65
- package/package.json +1 -1
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
233
|
-
|
|
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,
|
|
244
|
-
start, stop, send, getStatus, getIdentityHex,
|
|
245
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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
|
-
}, [
|
|
349
|
+
}, [start, displayName]);
|
|
384
350
|
|
|
385
351
|
const onStopBle = useCallback(async () => {
|
|
386
|
-
|
|
352
|
+
await stop();
|
|
387
353
|
setBleActive(false);
|
|
388
354
|
setUnpairedRNodes(0);
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
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
|
-
{
|
|
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
|
});
|