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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/android/src/main/jniLibs/arm64-v8a/liblxmf_rn.so +0 -0
  2. package/android/src/main/jniLibs/armeabi-v7a/liblxmf_rn.so +0 -0
  3. package/android/src/main/jniLibs/x86_64/liblxmf_rn.so +0 -0
  4. package/android/src/main/kotlin/expo/modules/lxmf/BleManager.kt +123 -54
  5. package/android/src/main/kotlin/expo/modules/lxmf/LxmfModule.kt +1 -0
  6. package/example/README.md +50 -0
  7. package/example/app/(tabs)/_layout.tsx +28 -0
  8. package/example/app/(tabs)/index.tsx +874 -0
  9. package/example/app/_layout.tsx +14 -0
  10. package/example/app/modal.tsx +29 -0
  11. package/example/app.json +72 -0
  12. package/example/assets/images/android-icon-background.png +0 -0
  13. package/example/assets/images/android-icon-foreground.png +0 -0
  14. package/example/assets/images/android-icon-monochrome.png +0 -0
  15. package/example/assets/images/favicon.png +0 -0
  16. package/example/assets/images/icon.png +0 -0
  17. package/example/assets/images/partial-react-logo.png +0 -0
  18. package/example/assets/images/react-logo.png +0 -0
  19. package/example/assets/images/react-logo@2x.png +0 -0
  20. package/example/assets/images/react-logo@3x.png +0 -0
  21. package/example/assets/images/splash-icon.png +0 -0
  22. package/example/components/external-link.tsx +25 -0
  23. package/example/components/haptic-tab.tsx +18 -0
  24. package/example/components/hello-wave.tsx +19 -0
  25. package/example/components/parallax-scroll-view.tsx +79 -0
  26. package/example/components/themed-text.tsx +60 -0
  27. package/example/components/themed-view.tsx +14 -0
  28. package/example/components/ui/collapsible.tsx +45 -0
  29. package/example/components/ui/icon-symbol.ios.tsx +32 -0
  30. package/example/components/ui/icon-symbol.tsx +41 -0
  31. package/example/constants/theme.ts +53 -0
  32. package/example/eslint.config.js +10 -0
  33. package/example/expo-env.d.ts +3 -0
  34. package/example/hooks/use-color-scheme.ts +1 -0
  35. package/example/hooks/use-color-scheme.web.ts +21 -0
  36. package/example/hooks/use-theme-color.ts +21 -0
  37. package/example/metro.config.js +37 -0
  38. package/example/package.json +52 -0
  39. package/example/scripts/reset-project.js +112 -0
  40. package/example/tsconfig.json +17 -0
  41. package/ios/BLEManager.swift +41 -6
  42. package/ios/LxmfModule.swift +13 -2
  43. package/package.json +15 -2
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "example-app",
3
+ "main": "expo-router/entry",
4
+ "version": "1.0.0",
5
+ "scripts": {
6
+ "start": "expo start",
7
+ "reset-project": "node ./scripts/reset-project.js",
8
+ "android": "expo run:android",
9
+ "ios": "expo run:ios",
10
+ "web": "expo start --web",
11
+ "lint": "expo lint"
12
+ },
13
+ "dependencies": {
14
+ "@expo/vector-icons": "^15.0.3",
15
+ "@magicred-1/react-native-lxmf": "file:..",
16
+ "@react-native/virtualized-lists": "0.81.5",
17
+ "@react-navigation/bottom-tabs": "^7.4.0",
18
+ "@react-navigation/elements": "^2.6.3",
19
+ "@react-navigation/native": "^7.1.8",
20
+ "expo": "~54.0.33",
21
+ "expo-constants": "~18.0.13",
22
+ "expo-dev-client": "~6.0.20",
23
+ "expo-font": "~14.0.11",
24
+ "expo-haptics": "~15.0.8",
25
+ "expo-image": "~3.0.11",
26
+ "expo-linking": "~8.0.11",
27
+ "expo-router": "~6.0.23",
28
+ "expo-secure-store": "~15.0.7",
29
+ "expo-splash-screen": "~31.0.13",
30
+ "expo-status-bar": "~3.0.9",
31
+ "expo-symbols": "~1.0.8",
32
+ "expo-system-ui": "~6.0.9",
33
+ "expo-web-browser": "~15.0.10",
34
+ "react": "19.1.0",
35
+ "react-dom": "19.1.0",
36
+ "react-native": "0.81.5",
37
+ "react-native-gesture-handler": "~2.28.0",
38
+ "react-native-reanimated": "~4.1.1",
39
+ "react-native-safe-area-context": "~5.6.0",
40
+ "react-native-screens": "~4.16.0",
41
+ "react-native-web": "~0.21.0",
42
+ "react-native-worklets": "0.5.1",
43
+ "semver": "^7.7.4"
44
+ },
45
+ "devDependencies": {
46
+ "@types/react": "~19.1.0",
47
+ "eslint": "^9.25.0",
48
+ "eslint-config-expo": "~10.0.0",
49
+ "typescript": "~5.9.2"
50
+ },
51
+ "private": true
52
+ }
@@ -0,0 +1,112 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * This script is used to reset the project to a blank state.
5
+ * It deletes or moves the /app, /components, /hooks, /scripts, and /constants directories to /app-example based on user input and creates a new /app directory with an index.tsx and _layout.tsx file.
6
+ * You can remove the `reset-project` script from package.json and safely delete this file after running it.
7
+ */
8
+
9
+ const fs = require("fs");
10
+ const path = require("path");
11
+ const readline = require("readline");
12
+
13
+ const root = process.cwd();
14
+ const oldDirs = ["app", "components", "hooks", "constants", "scripts"];
15
+ const exampleDir = "app-example";
16
+ const newAppDir = "app";
17
+ const exampleDirPath = path.join(root, exampleDir);
18
+
19
+ const indexContent = `import { Text, View } from "react-native";
20
+
21
+ export default function Index() {
22
+ return (
23
+ <View
24
+ style={{
25
+ flex: 1,
26
+ justifyContent: "center",
27
+ alignItems: "center",
28
+ }}
29
+ >
30
+ <Text>Edit app/index.tsx to edit this screen.</Text>
31
+ </View>
32
+ );
33
+ }
34
+ `;
35
+
36
+ const layoutContent = `import { Stack } from "expo-router";
37
+
38
+ export default function RootLayout() {
39
+ return <Stack />;
40
+ }
41
+ `;
42
+
43
+ const rl = readline.createInterface({
44
+ input: process.stdin,
45
+ output: process.stdout,
46
+ });
47
+
48
+ const moveDirectories = async (userInput) => {
49
+ try {
50
+ if (userInput === "y") {
51
+ // Create the app-example directory
52
+ await fs.promises.mkdir(exampleDirPath, { recursive: true });
53
+ console.log(`📁 /${exampleDir} directory created.`);
54
+ }
55
+
56
+ // Move old directories to new app-example directory or delete them
57
+ for (const dir of oldDirs) {
58
+ const oldDirPath = path.join(root, dir);
59
+ if (fs.existsSync(oldDirPath)) {
60
+ if (userInput === "y") {
61
+ const newDirPath = path.join(root, exampleDir, dir);
62
+ await fs.promises.rename(oldDirPath, newDirPath);
63
+ console.log(`➡️ /${dir} moved to /${exampleDir}/${dir}.`);
64
+ } else {
65
+ await fs.promises.rm(oldDirPath, { recursive: true, force: true });
66
+ console.log(`❌ /${dir} deleted.`);
67
+ }
68
+ } else {
69
+ console.log(`➡️ /${dir} does not exist, skipping.`);
70
+ }
71
+ }
72
+
73
+ // Create new /app directory
74
+ const newAppDirPath = path.join(root, newAppDir);
75
+ await fs.promises.mkdir(newAppDirPath, { recursive: true });
76
+ console.log("\n📁 New /app directory created.");
77
+
78
+ // Create index.tsx
79
+ const indexPath = path.join(newAppDirPath, "index.tsx");
80
+ await fs.promises.writeFile(indexPath, indexContent);
81
+ console.log("📄 app/index.tsx created.");
82
+
83
+ // Create _layout.tsx
84
+ const layoutPath = path.join(newAppDirPath, "_layout.tsx");
85
+ await fs.promises.writeFile(layoutPath, layoutContent);
86
+ console.log("📄 app/_layout.tsx created.");
87
+
88
+ console.log("\n✅ Project reset complete. Next steps:");
89
+ console.log(
90
+ `1. Run \`npx expo start\` to start a development server.\n2. Edit app/index.tsx to edit the main screen.${
91
+ userInput === "y"
92
+ ? `\n3. Delete the /${exampleDir} directory when you're done referencing it.`
93
+ : ""
94
+ }`
95
+ );
96
+ } catch (error) {
97
+ console.error(`❌ Error during script execution: ${error.message}`);
98
+ }
99
+ };
100
+
101
+ rl.question(
102
+ "Do you want to move existing files to /app-example instead of deleting them? (Y/n): ",
103
+ (answer) => {
104
+ const userInput = answer.trim().toLowerCase() || "y";
105
+ if (userInput === "y" || userInput === "n") {
106
+ moveDirectories(userInput).finally(() => rl.close());
107
+ } else {
108
+ console.log("❌ Invalid input. Please enter 'Y' or 'N'.");
109
+ rl.close();
110
+ }
111
+ }
112
+ );
@@ -0,0 +1,17 @@
1
+ {
2
+ "extends": "expo/tsconfig.base",
3
+ "compilerOptions": {
4
+ "strict": true,
5
+ "paths": {
6
+ "@/*": [
7
+ "./*"
8
+ ]
9
+ }
10
+ },
11
+ "include": [
12
+ "**/*.ts",
13
+ "**/*.tsx",
14
+ ".expo/types/**/*.ts",
15
+ "expo-env.d.ts"
16
+ ]
17
+ }
@@ -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.
@@ -427,6 +441,11 @@ extension BLEManager: CBPeripheralDelegate {
427
441
  addr.withUnsafeBytes { ptr in
428
442
  _ = lxmf_ble_connected(ptr.baseAddress?.assumingMemoryBound(to: UInt8.self))
429
443
  }
444
+ // Report negotiated write limit so Rust segments correctly for this peer.
445
+ let writeLimit = peripheral.maximumWriteValueLength(for: .withoutResponse)
446
+ addr.withUnsafeBytes { ptr in
447
+ _ = lxmf_ble_mtu_negotiated(ptr.baseAddress?.assumingMemoryBound(to: UInt8.self), UInt32(writeLimit))
448
+ }
430
449
 
431
450
  for char in chars {
432
451
  if char.uuid == BLEManager.rxCharUUID {
@@ -466,6 +485,12 @@ extension BLEManager: CBPeripheralDelegate {
466
485
  }
467
486
  }
468
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
+ }
469
494
  }
470
495
 
471
496
  // MARK: - CBPeripheralManagerDelegate
@@ -513,6 +538,11 @@ extension BLEManager: CBPeripheralManagerDelegate {
513
538
  addr.withUnsafeBytes { ptr in
514
539
  _ = lxmf_ble_connected(ptr.baseAddress?.assumingMemoryBound(to: UInt8.self))
515
540
  }
541
+ // Report negotiated notification limit for this central.
542
+ let writeLimit = central.maximumUpdateValueLength
543
+ addr.withUnsafeBytes { ptr in
544
+ _ = lxmf_ble_mtu_negotiated(ptr.baseAddress?.assumingMemoryBound(to: UInt8.self), UInt32(writeLimit))
545
+ }
516
546
  }
517
547
  }
518
548
 
@@ -527,6 +557,11 @@ extension BLEManager: CBPeripheralManagerDelegate {
527
557
  }
528
558
  }
529
559
 
560
+ // Called when updateValue returned false and the subscriber buffer has drained.
561
+ func peripheralManagerIsReady(toUpdateSubscribers peripheral: CBPeripheralManager) {
562
+ onReadyToSend?()
563
+ }
564
+
530
565
  // Background restoration
531
566
  func peripheralManager(_ peripheral: CBPeripheralManager, willRestoreState dict: [String: Any]) {
532
567
  // Re-setup services on restoration
@@ -111,6 +111,12 @@ func lxmf_ble_disconnected(
111
111
  @_silgen_name("lxmf_ble_peer_count")
112
112
  func lxmf_ble_peer_count() -> Int32
113
113
 
114
+ @_silgen_name("lxmf_ble_mtu_negotiated")
115
+ func lxmf_ble_mtu_negotiated(
116
+ _ peerAddr: UnsafePointer<UInt8>?,
117
+ _ writeLimit: UInt32
118
+ ) -> Int32
119
+
114
120
  // --- NUS Interface FFI (RNode BLE via Nordic UART Service) ---
115
121
 
116
122
  @_silgen_name("lxmf_nus_receive")
@@ -135,7 +141,11 @@ public class LxmfModule: Module {
135
141
  private var txDrainTimer: Timer?
136
142
 
137
143
  // BLE manager for phone-to-phone mesh
138
- 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
+ }()
139
149
 
140
150
  public func definition() -> ModuleDefinition {
141
151
  Name("LxmfModule")
@@ -385,7 +395,8 @@ public class LxmfModule: Module {
385
395
 
386
396
  let frameData = Data(dataBuf[0..<Int(len)])
387
397
  let addr = Data(peerAddr)
388
- 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 }
389
400
  }
390
401
 
391
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.0",
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",
@@ -73,6 +73,19 @@
73
73
  "LxmfReactNative.podspec",
74
74
  "expo-module.config.json",
75
75
  "app.plugin.js",
76
- "README.md"
76
+ "README.md",
77
+ "example/app",
78
+ "example/assets",
79
+ "example/components",
80
+ "example/constants",
81
+ "example/hooks",
82
+ "example/scripts",
83
+ "example/app.json",
84
+ "example/package.json",
85
+ "example/metro.config.js",
86
+ "example/tsconfig.json",
87
+ "example/expo-env.d.ts",
88
+ "example/eslint.config.js",
89
+ "example/README.md"
77
90
  ]
78
91
  }