@magicred-1/react-native-lxmf 0.1.0
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/LxmfReactNative.podspec +25 -0
- package/README.md +57 -0
- package/android/build.gradle.kts +33 -0
- 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 +282 -0
- package/android/src/main/kotlin/expo/modules/lxmf/LxmfModule.kt +232 -0
- package/app.plugin.js +40 -0
- package/build/LxmfModule.d.ts +29 -0
- package/build/LxmfModule.js +31 -0
- package/build/index.d.ts +2 -0
- package/build/index.js +2 -0
- package/build/useLxmf.d.ts +81 -0
- package/build/useLxmf.js +252 -0
- package/expo-module.config.json +10 -0
- package/ios/BLEManager.swift +494 -0
- package/ios/LxmfModule.swift +422 -0
- package/package.json +76 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
Pod::Spec.new do |s|
|
|
2
|
+
s.name = 'LxmfReactNative'
|
|
3
|
+
s.version = '0.1.0'
|
|
4
|
+
s.summary = 'LXMF Reticulum mesh networking for React Native'
|
|
5
|
+
s.homepage = 'https://github.com/anon0mesh/lxmf_react_native_rust'
|
|
6
|
+
s.license = { type: 'MIT' }
|
|
7
|
+
s.author = { 'anon0mesh' => 'anon0mesh@example.com' }
|
|
8
|
+
s.source = { git: 'https://github.com/anon0mesh/lxmf_react_native_rust.git' }
|
|
9
|
+
|
|
10
|
+
s.platform = :ios, '13.0'
|
|
11
|
+
s.requires_arc = true
|
|
12
|
+
s.swift_version = '5.5'
|
|
13
|
+
|
|
14
|
+
s.source_files = 'ios/**/*.swift'
|
|
15
|
+
s.public_header_files = 'ios/**/*.h'
|
|
16
|
+
|
|
17
|
+
# Link the Rust static library (XCFramework built by scripts/build-rust-ios.sh)
|
|
18
|
+
s.vendored_frameworks = 'ios/RustCore/liblxmf_rn.xcframework'
|
|
19
|
+
s.libraries = 'c++'
|
|
20
|
+
|
|
21
|
+
s.dependency 'ExpoModulesCore'
|
|
22
|
+
|
|
23
|
+
# BLE framework
|
|
24
|
+
s.frameworks = 'CoreBluetooth', 'Foundation'
|
|
25
|
+
end
|
package/README.md
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# @magicred-1/react-native-lxmf
|
|
2
|
+
|
|
3
|
+
LXMF Reticulum mesh networking module for React Native + Expo.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @magicred-1/react-native-lxmf
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import { useLxmf, LxmfNodeMode } from '@magicred-1/react-native-lxmf';
|
|
15
|
+
|
|
16
|
+
const lxmf = useLxmf({
|
|
17
|
+
identityHex: 'new',
|
|
18
|
+
lxmfAddressHex: 'new',
|
|
19
|
+
mode: LxmfNodeMode.BleOnly,
|
|
20
|
+
});
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Package Contents
|
|
24
|
+
|
|
25
|
+
Published package includes:
|
|
26
|
+
|
|
27
|
+
- `build/` JavaScript + TypeScript declarations
|
|
28
|
+
- `android/` native Android module sources and JNI library artifacts
|
|
29
|
+
- `ios/` native iOS Swift sources
|
|
30
|
+
- `LxmfReactNative.podspec`
|
|
31
|
+
- Expo plugin files (`app.plugin.js`, `expo-module.config.json`)
|
|
32
|
+
|
|
33
|
+
## Build
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
npm run build
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Verify Package
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
npm run pack:check
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Publish
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
npm publish
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Important Native Note
|
|
52
|
+
|
|
53
|
+
The iOS podspec currently references a Rust static library outside the package directory:
|
|
54
|
+
|
|
55
|
+
- `../../rust-core/target/release/liblxmf_rn.a`
|
|
56
|
+
|
|
57
|
+
For public npm distribution, this static library must be bundled into the package (or built during pod install), otherwise iOS consumers will fail to link.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
plugins {
|
|
2
|
+
id("com.android.library")
|
|
3
|
+
id("expo-module-gradle-plugin")
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
android {
|
|
7
|
+
namespace = "expo.modules.lxmf"
|
|
8
|
+
compileSdk = 36
|
|
9
|
+
|
|
10
|
+
defaultConfig {
|
|
11
|
+
minSdk = 24
|
|
12
|
+
versionCode = 1
|
|
13
|
+
versionName = "1.0.0"
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
compileOptions {
|
|
17
|
+
sourceCompatibility = JavaVersion.VERSION_11
|
|
18
|
+
targetCompatibility = JavaVersion.VERSION_11
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
kotlinOptions {
|
|
22
|
+
jvmTarget = "11"
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
sourceSets {
|
|
26
|
+
getByName("main").java.srcDirs("src/main/kotlin")
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
dependencies {
|
|
31
|
+
compileOnly(project(":expo-modules-core"))
|
|
32
|
+
}
|
|
33
|
+
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
package expo.modules.lxmf
|
|
2
|
+
|
|
3
|
+
import android.bluetooth.*
|
|
4
|
+
import android.bluetooth.le.*
|
|
5
|
+
import android.content.Context
|
|
6
|
+
import android.os.Handler
|
|
7
|
+
import android.os.Looper
|
|
8
|
+
import android.os.ParcelUuid
|
|
9
|
+
import android.util.Log
|
|
10
|
+
import java.util.UUID
|
|
11
|
+
|
|
12
|
+
private const val TAG = "LxmfBle"
|
|
13
|
+
|
|
14
|
+
// UUIDs must match ble_iface.rs constants exactly
|
|
15
|
+
val RNS_SERVICE_UUID: UUID = UUID.fromString("5f3bafcd-2bb7-4de0-9c6f-2c5f88b6b8f2")
|
|
16
|
+
val RNS_RX_CHAR_UUID: UUID = UUID.fromString("3b28e4f6-5a30-4a5f-b700-68bb74d1b036")
|
|
17
|
+
val RNS_TX_CHAR_UUID: UUID = UUID.fromString("8b6ded1a-ea65-4a1e-a1f0-5cf69d5dc2ad")
|
|
18
|
+
|
|
19
|
+
// GATT descriptor for enabling notifications
|
|
20
|
+
val CCCD_UUID: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* BleManager — owns all Android BLE hardware access for the LXMF BLE interface.
|
|
24
|
+
*
|
|
25
|
+
* Responsibilities:
|
|
26
|
+
* - Scan for Reticulum BLE peers (service UUID filter)
|
|
27
|
+
* - Advertise our service UUID so peers can find us
|
|
28
|
+
* - Connect to discovered peers via GATT
|
|
29
|
+
* - Enable notifications on the RX characteristic
|
|
30
|
+
* - Pass received bytes to Rust via nativeBleReceive()
|
|
31
|
+
* - Poll nativeBlePollTx() and write results to TX characteristic
|
|
32
|
+
* - Notify Rust of connect/disconnect events
|
|
33
|
+
*
|
|
34
|
+
* Rust handles: HDLC framing, segmentation, Reticulum packet routing.
|
|
35
|
+
*/
|
|
36
|
+
class BleManager(
|
|
37
|
+
private val context: Context,
|
|
38
|
+
private val module: LxmfModule,
|
|
39
|
+
) {
|
|
40
|
+
private val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager
|
|
41
|
+
private val adapter: BluetoothAdapter? get() = bluetoothManager?.adapter
|
|
42
|
+
private val mainHandler = Handler(Looper.getMainLooper())
|
|
43
|
+
|
|
44
|
+
// Active GATT connections keyed by MAC address string
|
|
45
|
+
private val connections = mutableMapOf<String, BluetoothGatt>()
|
|
46
|
+
// MACs we are currently trying to connect (avoid duplicate attempts)
|
|
47
|
+
private val connecting = mutableSetOf<String>()
|
|
48
|
+
// Timestamp (ms) when each MAC last disconnected — enforces reconnect cooldown
|
|
49
|
+
private val disconnectedAt = mutableMapOf<String, Long>()
|
|
50
|
+
|
|
51
|
+
private var scanner: BluetoothLeScanner? = null
|
|
52
|
+
private var advertiser: BluetoothLeAdvertiser? = null
|
|
53
|
+
private var isScanning = false
|
|
54
|
+
private var isAdvertising = false
|
|
55
|
+
|
|
56
|
+
// TX polling — every 50 ms drain the Rust TX queue and write to peers
|
|
57
|
+
private val txPollRunnable = object : Runnable {
|
|
58
|
+
override fun run() {
|
|
59
|
+
drainTxQueue()
|
|
60
|
+
mainHandler.postDelayed(this, TX_POLL_INTERVAL_MS)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
companion object {
|
|
65
|
+
private const val TX_POLL_INTERVAL_MS = 50L
|
|
66
|
+
private const val SCAN_RESTART_DELAY_MS = 30_000L
|
|
67
|
+
/** How long to wait before reconnecting to a peer that just disconnected. */
|
|
68
|
+
private const val RECONNECT_COOLDOWN_MS = 15_000L
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ── Lifecycle ────────────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
fun start() {
|
|
74
|
+
if (adapter == null || !adapter!!.isEnabled) {
|
|
75
|
+
Log.w(TAG, "Bluetooth not available or not enabled")
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
startAdvertising()
|
|
79
|
+
startScanning()
|
|
80
|
+
mainHandler.postDelayed(txPollRunnable, TX_POLL_INTERVAL_MS)
|
|
81
|
+
Log.i(TAG, "BleManager started")
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
fun stop() {
|
|
85
|
+
stopScanning()
|
|
86
|
+
stopAdvertising()
|
|
87
|
+
mainHandler.removeCallbacks(txPollRunnable)
|
|
88
|
+
connections.values.forEach { it.disconnect(); it.close() }
|
|
89
|
+
connections.clear()
|
|
90
|
+
connecting.clear()
|
|
91
|
+
Log.i(TAG, "BleManager stopped")
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
fun connectedPeerCount(): Int = module.nativeBlePeerCount()
|
|
95
|
+
|
|
96
|
+
// ── Advertising (so peers can find us) ───────────────────────────────────
|
|
97
|
+
|
|
98
|
+
private fun startAdvertising() {
|
|
99
|
+
advertiser = adapter?.bluetoothLeAdvertiser ?: return
|
|
100
|
+
val settings = AdvertiseSettings.Builder()
|
|
101
|
+
.setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_BALANCED)
|
|
102
|
+
.setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_MEDIUM)
|
|
103
|
+
.setConnectable(true)
|
|
104
|
+
.build()
|
|
105
|
+
val data = AdvertiseData.Builder()
|
|
106
|
+
.addServiceUuid(ParcelUuid(RNS_SERVICE_UUID))
|
|
107
|
+
.setIncludeDeviceName(false)
|
|
108
|
+
.build()
|
|
109
|
+
advertiser?.startAdvertising(settings, data, advertiseCallback)
|
|
110
|
+
isAdvertising = true
|
|
111
|
+
Log.d(TAG, "BLE advertising started")
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
private fun stopAdvertising() {
|
|
115
|
+
if (isAdvertising) {
|
|
116
|
+
advertiser?.stopAdvertising(advertiseCallback)
|
|
117
|
+
isAdvertising = false
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
private val advertiseCallback = object : AdvertiseCallback() {
|
|
122
|
+
override fun onStartSuccess(settingsInEffect: AdvertiseSettings?) {
|
|
123
|
+
Log.i(TAG, "BLE advertise started")
|
|
124
|
+
}
|
|
125
|
+
override fun onStartFailure(errorCode: Int) {
|
|
126
|
+
Log.e(TAG, "BLE advertise failed: $errorCode")
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ── Scanning (find peers) ─────────────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
private fun startScanning() {
|
|
133
|
+
if (isScanning) return
|
|
134
|
+
scanner = adapter?.bluetoothLeScanner ?: return
|
|
135
|
+
val filter = ScanFilter.Builder()
|
|
136
|
+
.setServiceUuid(ParcelUuid(RNS_SERVICE_UUID))
|
|
137
|
+
.build()
|
|
138
|
+
val settings = ScanSettings.Builder()
|
|
139
|
+
.setScanMode(ScanSettings.SCAN_MODE_BALANCED)
|
|
140
|
+
.build()
|
|
141
|
+
scanner?.startScan(listOf(filter), settings, scanCallback)
|
|
142
|
+
isScanning = true
|
|
143
|
+
Log.d(TAG, "BLE scan started")
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
private fun stopScanning() {
|
|
147
|
+
if (isScanning) {
|
|
148
|
+
scanner?.stopScan(scanCallback)
|
|
149
|
+
isScanning = false
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
private val scanCallback = object : ScanCallback() {
|
|
154
|
+
override fun onScanResult(callbackType: Int, result: ScanResult) {
|
|
155
|
+
val device = result.device ?: return
|
|
156
|
+
val mac = device.address ?: return
|
|
157
|
+
if (mac in connections || mac in connecting) return
|
|
158
|
+
val lastDisconnect = disconnectedAt[mac] ?: 0L
|
|
159
|
+
if (System.currentTimeMillis() - lastDisconnect < RECONNECT_COOLDOWN_MS) return
|
|
160
|
+
Log.i(TAG, "BLE: found peer $mac, connecting")
|
|
161
|
+
connecting.add(mac)
|
|
162
|
+
device.connectGatt(context, false, gattCallback, BluetoothDevice.TRANSPORT_LE)
|
|
163
|
+
}
|
|
164
|
+
override fun onScanFailed(errorCode: Int) {
|
|
165
|
+
Log.e(TAG, "BLE scan failed: $errorCode")
|
|
166
|
+
isScanning = false
|
|
167
|
+
// Restart scan after delay
|
|
168
|
+
mainHandler.postDelayed({ startScanning() }, SCAN_RESTART_DELAY_MS)
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ── GATT callbacks ────────────────────────────────────────────────────────
|
|
173
|
+
|
|
174
|
+
private val gattCallback = object : BluetoothGattCallback() {
|
|
175
|
+
override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
|
|
176
|
+
val mac = gatt.device.address
|
|
177
|
+
when (newState) {
|
|
178
|
+
BluetoothProfile.STATE_CONNECTED -> {
|
|
179
|
+
Log.i(TAG, "BLE GATT connected: $mac")
|
|
180
|
+
connections[mac] = gatt
|
|
181
|
+
connecting.remove(mac)
|
|
182
|
+
gatt.discoverServices()
|
|
183
|
+
}
|
|
184
|
+
BluetoothProfile.STATE_DISCONNECTED -> {
|
|
185
|
+
// Guard against double-fire (Android BLE can call this twice)
|
|
186
|
+
if (mac !in connections && mac !in connecting) return
|
|
187
|
+
Log.i(TAG, "BLE GATT disconnected: $mac (status=$status)")
|
|
188
|
+
connections.remove(mac)
|
|
189
|
+
connecting.remove(mac)
|
|
190
|
+
disconnectedAt[mac] = System.currentTimeMillis()
|
|
191
|
+
gatt.close()
|
|
192
|
+
// Notify Rust
|
|
193
|
+
module.nativeBleDisconnected(macToBytes(mac))
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
|
|
199
|
+
if (status != BluetoothGatt.GATT_SUCCESS) {
|
|
200
|
+
Log.w(TAG, "Service discovery failed on ${gatt.device.address}: $status")
|
|
201
|
+
gatt.disconnect()
|
|
202
|
+
return
|
|
203
|
+
}
|
|
204
|
+
val service = gatt.getService(RNS_SERVICE_UUID)
|
|
205
|
+
if (service == null) {
|
|
206
|
+
Log.w(TAG, "RNS service not found on ${gatt.device.address}")
|
|
207
|
+
gatt.disconnect()
|
|
208
|
+
return
|
|
209
|
+
}
|
|
210
|
+
// Enable notifications on RX characteristic
|
|
211
|
+
val rxChar = service.getCharacteristic(RNS_RX_CHAR_UUID)
|
|
212
|
+
if (rxChar != null) {
|
|
213
|
+
gatt.setCharacteristicNotification(rxChar, true)
|
|
214
|
+
val cccd = rxChar.getDescriptor(CCCD_UUID)
|
|
215
|
+
cccd?.let {
|
|
216
|
+
it.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
|
|
217
|
+
gatt.writeDescriptor(it)
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
// Notify Rust of new peer
|
|
221
|
+
module.nativeBleConnected(macToBytes(gatt.device.address))
|
|
222
|
+
Log.i(TAG, "BLE peer ready: ${gatt.device.address}")
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
override fun onCharacteristicChanged(
|
|
226
|
+
gatt: BluetoothGatt,
|
|
227
|
+
characteristic: BluetoothGattCharacteristic,
|
|
228
|
+
) {
|
|
229
|
+
if (characteristic.uuid == RNS_RX_CHAR_UUID) {
|
|
230
|
+
val data = characteristic.value ?: return
|
|
231
|
+
Log.d(TAG, "BLE RX ${data.size}B from ${gatt.device.address}")
|
|
232
|
+
module.nativeBleReceive(macToBytes(gatt.device.address), data)
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
override fun onCharacteristicWrite(
|
|
237
|
+
gatt: BluetoothGatt,
|
|
238
|
+
characteristic: BluetoothGattCharacteristic,
|
|
239
|
+
status: Int,
|
|
240
|
+
) {
|
|
241
|
+
if (status != BluetoothGatt.GATT_SUCCESS) {
|
|
242
|
+
Log.w(TAG, "BLE TX write failed: $status on ${gatt.device.address}")
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ── TX drain — poll Rust and write to peer characteristics ───────────────
|
|
248
|
+
|
|
249
|
+
private fun drainTxQueue() {
|
|
250
|
+
while (true) {
|
|
251
|
+
val json = module.nativeBlePollTx() ?: break
|
|
252
|
+
try {
|
|
253
|
+
val obj = org.json.JSONObject(json)
|
|
254
|
+
val peerHex = obj.getString("peer") // "aabbccddeeff"
|
|
255
|
+
val dataB64 = obj.getString("data")
|
|
256
|
+
val data = android.util.Base64.decode(dataB64, android.util.Base64.DEFAULT)
|
|
257
|
+
val mac = hexToMacString(peerHex) // "AA:BB:CC:DD:EE:FF"
|
|
258
|
+
val gatt = connections[mac] ?: continue
|
|
259
|
+
val service = gatt.getService(RNS_SERVICE_UUID) ?: continue
|
|
260
|
+
val txChar = service.getCharacteristic(RNS_TX_CHAR_UUID) ?: continue
|
|
261
|
+
txChar.value = data
|
|
262
|
+
txChar.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
|
|
263
|
+
val ok = gatt.writeCharacteristic(txChar)
|
|
264
|
+
Log.d(TAG, "BLE TX ${data.size}B to $mac ok=$ok")
|
|
265
|
+
} catch (e: Exception) {
|
|
266
|
+
Log.e(TAG, "drainTxQueue parse error: ${e.message}")
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// ── Helpers ───────────────────────────────────────────────────────────────
|
|
272
|
+
|
|
273
|
+
/** Convert "AA:BB:CC:DD:EE:FF" → ByteArray(6) */
|
|
274
|
+
private fun macToBytes(mac: String): ByteArray {
|
|
275
|
+
return mac.split(":").map { it.toInt(16).toByte() }.toByteArray()
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Convert "aabbccddeeff" → "AA:BB:CC:DD:EE:FF" */
|
|
279
|
+
private fun hexToMacString(hex: String): String {
|
|
280
|
+
return hex.chunked(2).joinToString(":") { it.uppercase() }
|
|
281
|
+
}
|
|
282
|
+
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
package expo.modules.lxmf
|
|
2
|
+
|
|
3
|
+
import android.os.Handler
|
|
4
|
+
import android.os.Looper
|
|
5
|
+
import android.util.Log
|
|
6
|
+
import expo.modules.kotlin.modules.Module
|
|
7
|
+
import expo.modules.kotlin.modules.ModuleDefinition
|
|
8
|
+
import org.json.JSONArray
|
|
9
|
+
|
|
10
|
+
private const val TAG = "LxmfModule"
|
|
11
|
+
private const val POLL_INTERVAL_MS = 500L
|
|
12
|
+
|
|
13
|
+
class LxmfModule : Module() {
|
|
14
|
+
private val pollHandler = Handler(Looper.getMainLooper())
|
|
15
|
+
private val pollRunnable = object : Runnable {
|
|
16
|
+
override fun run() {
|
|
17
|
+
drainAndEmitEvents()
|
|
18
|
+
pollHandler.postDelayed(this, POLL_INTERVAL_MS)
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// BleManager is created lazily when the app context is available
|
|
23
|
+
private var bleManager: BleManager? = null
|
|
24
|
+
|
|
25
|
+
override fun definition() = ModuleDefinition {
|
|
26
|
+
Name("LxmfModule")
|
|
27
|
+
|
|
28
|
+
Events(
|
|
29
|
+
"onPacketReceived",
|
|
30
|
+
"onTxReceived",
|
|
31
|
+
"onBeaconDiscovered",
|
|
32
|
+
"onMessageReceived",
|
|
33
|
+
"onAnnounceReceived",
|
|
34
|
+
"onStatusChanged",
|
|
35
|
+
"onLog",
|
|
36
|
+
"onError",
|
|
37
|
+
"onOutgoingPacket"
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
OnCreate {
|
|
41
|
+
if (isNativeLibraryLoaded()) {
|
|
42
|
+
pollHandler.postDelayed(pollRunnable, POLL_INTERVAL_MS)
|
|
43
|
+
} else {
|
|
44
|
+
Log.w(TAG, "Skipping event polling because liblxmf_rn is not loaded")
|
|
45
|
+
}
|
|
46
|
+
// Create BleManager with whatever Android context is available in this lifecycle phase
|
|
47
|
+
val ctx = appContext.reactContext?.applicationContext
|
|
48
|
+
?: appContext.currentActivity?.applicationContext
|
|
49
|
+
if (ctx != null) {
|
|
50
|
+
bleManager = BleManager(ctx, this@LxmfModule)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
OnDestroy {
|
|
55
|
+
pollHandler.removeCallbacks(pollRunnable)
|
|
56
|
+
bleManager?.stop()
|
|
57
|
+
bleManager = null
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Lifecycle
|
|
61
|
+
Function("init") { dbPath: String? ->
|
|
62
|
+
val rc = nativeInit(dbPath)
|
|
63
|
+
if (rc != 0) throw RuntimeException("nativeInit returned $rc")
|
|
64
|
+
true
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
AsyncFunction("start") { identityHex: String, lxmfAddressHex: String, mode: Int,
|
|
68
|
+
announceIntervalMs: Double, bleMtuHint: Int,
|
|
69
|
+
tcpInterfaces: List<Map<String, Any>>, displayName: String ->
|
|
70
|
+
Log.d(TAG, "start() mode=$mode interfaces=$tcpInterfaces name=$displayName")
|
|
71
|
+
val interfacesJson = org.json.JSONArray(tcpInterfaces.map { iface ->
|
|
72
|
+
org.json.JSONObject().apply {
|
|
73
|
+
put("host", iface["host"] ?: "")
|
|
74
|
+
put("port", iface["port"] ?: 0)
|
|
75
|
+
}
|
|
76
|
+
}).toString()
|
|
77
|
+
val rc = nativeStart(identityHex, lxmfAddressHex, mode, announceIntervalMs.toLong(),
|
|
78
|
+
bleMtuHint.toShort(), interfacesJson, displayName)
|
|
79
|
+
if (rc != 0) throw RuntimeException("nativeStart returned $rc")
|
|
80
|
+
true
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
AsyncFunction("stop") {
|
|
84
|
+
val rc = nativeStop()
|
|
85
|
+
if (rc != 0) throw RuntimeException("nativeStop returned $rc")
|
|
86
|
+
true
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
Function("isRunning") {
|
|
90
|
+
nativeIsRunning()
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Messaging
|
|
94
|
+
AsyncFunction("send") { destHex: String, bodyBase64: String ->
|
|
95
|
+
nativeSend(destHex, bodyBase64).toDouble()
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
AsyncFunction("broadcast") { destsHex: List<String>, bodyBase64: String ->
|
|
99
|
+
val destsJson = org.json.JSONArray(destsHex).toString()
|
|
100
|
+
nativeBroadcast(destsJson, bodyBase64).toDouble()
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Status & State
|
|
104
|
+
Function("getStatus") {
|
|
105
|
+
nativeGetStatus()
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
Function("getBeacons") {
|
|
109
|
+
nativeGetBeacons()
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
Function("fetchMessages") { limit: Int ->
|
|
113
|
+
nativeFetchMessages(limit)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Configuration
|
|
117
|
+
Function("setLogLevel") { level: Int ->
|
|
118
|
+
nativeSetLogLevel(level) == 0
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
Function("abiVersion") {
|
|
122
|
+
nativeAbiVersion()
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// BLE Control — starts/stops BLE scan+advertise+GATT bridge to Rust
|
|
126
|
+
Function("startBLE") {
|
|
127
|
+
Log.d(TAG, "startBLE()")
|
|
128
|
+
bleManager?.start()
|
|
129
|
+
true
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
Function("stopBLE") {
|
|
133
|
+
Log.d(TAG, "stopBLE()")
|
|
134
|
+
bleManager?.stop()
|
|
135
|
+
true
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
Function("blePeerCount") {
|
|
139
|
+
nativeBlePeerCount()
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
private fun drainAndEmitEvents() {
|
|
144
|
+
if (!isNativeLibraryLoaded()) return
|
|
145
|
+
|
|
146
|
+
val json = try {
|
|
147
|
+
nativePollEvents()
|
|
148
|
+
} catch (e: UnsatisfiedLinkError) {
|
|
149
|
+
Log.e(TAG, "nativePollEvents unavailable: ${e.message}")
|
|
150
|
+
pollHandler.removeCallbacks(pollRunnable)
|
|
151
|
+
return
|
|
152
|
+
} ?: return
|
|
153
|
+
|
|
154
|
+
try {
|
|
155
|
+
val arr = JSONArray(json)
|
|
156
|
+
for (i in 0 until arr.length()) {
|
|
157
|
+
val obj = arr.getJSONObject(i)
|
|
158
|
+
val type = obj.optString("type")
|
|
159
|
+
val eventName = when (type) {
|
|
160
|
+
"statusChanged" -> "onStatusChanged"
|
|
161
|
+
"announceReceived" -> "onAnnounceReceived"
|
|
162
|
+
"messageReceived" -> "onMessageReceived"
|
|
163
|
+
"packetReceived" -> "onPacketReceived"
|
|
164
|
+
"txReceived" -> "onTxReceived"
|
|
165
|
+
"beaconDiscovered" -> "onBeaconDiscovered"
|
|
166
|
+
"log" -> "onLog"
|
|
167
|
+
"error" -> "onError"
|
|
168
|
+
else -> null
|
|
169
|
+
} ?: continue
|
|
170
|
+
|
|
171
|
+
val params = mutableMapOf<String, Any?>()
|
|
172
|
+
val keys = obj.keys()
|
|
173
|
+
while (keys.hasNext()) {
|
|
174
|
+
val key = keys.next()
|
|
175
|
+
if (key != "type") params[key] = obj.get(key)
|
|
176
|
+
}
|
|
177
|
+
sendEvent(eventName, params)
|
|
178
|
+
}
|
|
179
|
+
} catch (e: Exception) {
|
|
180
|
+
Log.e(TAG, "drainAndEmitEvents parse error: ${e.message}")
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Native JNI method declarations — types must match Rust JNI signatures exactly
|
|
185
|
+
private external fun nativeInit(dbPath: String?): Int
|
|
186
|
+
private external fun nativeStart(
|
|
187
|
+
identityHex: String,
|
|
188
|
+
lxmfAddressHex: String,
|
|
189
|
+
mode: Int,
|
|
190
|
+
announceIntervalMs: Long,
|
|
191
|
+
bleMtuHint: Short,
|
|
192
|
+
tcpInterfacesJson: String,
|
|
193
|
+
displayName: String
|
|
194
|
+
): Int
|
|
195
|
+
private external fun nativeStop(): Int
|
|
196
|
+
private external fun nativeIsRunning(): Boolean
|
|
197
|
+
private external fun nativePollEvents(): String?
|
|
198
|
+
private external fun nativeSend(destHex: String, bodyBase64: String): Long
|
|
199
|
+
private external fun nativeBroadcast(destsJson: String, bodyBase64: String): Long
|
|
200
|
+
private external fun nativeGetStatus(): String?
|
|
201
|
+
private external fun nativeGetBeacons(): String?
|
|
202
|
+
private external fun nativeFetchMessages(limit: Int): String?
|
|
203
|
+
private external fun nativeSetLogLevel(level: Int): Int
|
|
204
|
+
private external fun nativeAbiVersion(): Int
|
|
205
|
+
|
|
206
|
+
// BLE JNI — called by BleManager (same package)
|
|
207
|
+
// Must NOT be `internal` — Kotlin mangles internal function names in JVM bytecode,
|
|
208
|
+
// which breaks JNI symbol resolution (produces e.g. nativeBlePollTx$lxmf_react_native_debug).
|
|
209
|
+
external fun nativeBleReceive(peerAddr: ByteArray, data: ByteArray)
|
|
210
|
+
external fun nativeBlePollTx(): String?
|
|
211
|
+
external fun nativeBleConnected(peerAddr: ByteArray)
|
|
212
|
+
external fun nativeBleDisconnected(peerAddr: ByteArray)
|
|
213
|
+
external fun nativeBlePeerCount(): Int
|
|
214
|
+
|
|
215
|
+
companion object {
|
|
216
|
+
@Volatile
|
|
217
|
+
private var nativeLibraryLoaded = false
|
|
218
|
+
|
|
219
|
+
fun isNativeLibraryLoaded(): Boolean = nativeLibraryLoaded
|
|
220
|
+
|
|
221
|
+
init {
|
|
222
|
+
try {
|
|
223
|
+
System.loadLibrary("lxmf_rn")
|
|
224
|
+
nativeLibraryLoaded = true
|
|
225
|
+
Log.i(TAG, "liblxmf_rn loaded successfully")
|
|
226
|
+
} catch (e: UnsatisfiedLinkError) {
|
|
227
|
+
nativeLibraryLoaded = false
|
|
228
|
+
Log.e(TAG, "Failed to load liblxmf_rn: ${e.message}")
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
package/app.plugin.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
const { withInfoPlist, withAndroidManifest } = require('@expo/config-plugins');
|
|
2
|
+
|
|
3
|
+
function withLxmfPermissions(config) {
|
|
4
|
+
// Android BLE permissions
|
|
5
|
+
config = withAndroidManifest(config, (c) => {
|
|
6
|
+
const manifest = c.modResults.manifest;
|
|
7
|
+
const permissions = manifest['uses-permission'] || [];
|
|
8
|
+
const blePermissions = [
|
|
9
|
+
'android.permission.BLUETOOTH',
|
|
10
|
+
'android.permission.BLUETOOTH_ADMIN',
|
|
11
|
+
'android.permission.BLUETOOTH_SCAN',
|
|
12
|
+
'android.permission.BLUETOOTH_CONNECT',
|
|
13
|
+
'android.permission.BLUETOOTH_ADVERTISE',
|
|
14
|
+
'android.permission.ACCESS_FINE_LOCATION',
|
|
15
|
+
'android.permission.ACCESS_COARSE_LOCATION',
|
|
16
|
+
];
|
|
17
|
+
for (const perm of blePermissions) {
|
|
18
|
+
if (!permissions.some((p) => p.$['android:name'] === perm)) {
|
|
19
|
+
permissions.push({ $: { 'android:name': perm } });
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
manifest['uses-permission'] = permissions;
|
|
23
|
+
return c;
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
// iOS BLE usage descriptions
|
|
27
|
+
config = withInfoPlist(config, (c) => {
|
|
28
|
+
c.modResults.NSBluetoothAlwaysUsageDescription =
|
|
29
|
+
c.modResults.NSBluetoothAlwaysUsageDescription ||
|
|
30
|
+
'Used for LXMF mesh networking via BLE';
|
|
31
|
+
c.modResults.NSBluetoothPeripheralUsageDescription =
|
|
32
|
+
c.modResults.NSBluetoothPeripheralUsageDescription ||
|
|
33
|
+
'Used for LXMF mesh networking via BLE';
|
|
34
|
+
return c;
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
return config;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
module.exports = withLxmfPermissions;
|