@offline-protocol/mesh-sdk 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.
@@ -113,6 +113,8 @@ class BleManager(
113
113
  private var gattServer: BluetoothGattServer? = null
114
114
  private var messageCharacteristic: BluetoothGattCharacteristic? = null
115
115
  private var deviceIdCharacteristic: BluetoothGattCharacteristic? = null
116
+ @Volatile private var isGattServiceReady = false
117
+ @Volatile private var pendingAdvertiseReason: String? = null
116
118
 
117
119
  // Connection registry keeps track of client/server links and desired roles.
118
120
  private val connections = MeshConnectionRegistry()
@@ -412,6 +414,8 @@ class BleManager(
412
414
  // Close GATT server
413
415
  gattServer?.close()
414
416
  gattServer = null
417
+ isGattServiceReady = false
418
+ pendingAdvertiseReason = null
415
419
 
416
420
  updateState(TransportState.STOPPED)
417
421
  protocol.bleStatusChanged(false)
@@ -508,6 +512,9 @@ class BleManager(
508
512
 
509
513
  private fun setupGattServer() {
510
514
  try {
515
+ // Reset flag - service registration is asynchronous
516
+ isGattServiceReady = false
517
+
511
518
  gattServer = bluetoothManager.openGattServer(context, gattServerCallback)
512
519
 
513
520
  // Create message characteristic (write without response + notify)
@@ -530,10 +537,11 @@ class BleManager(
530
537
  service.addCharacteristic(messageCharacteristic)
531
538
  service.addCharacteristic(deviceIdCharacteristic)
532
539
 
533
- // Add service to GATT server
540
+ // Add service to GATT server (asynchronous - callback in onServiceAdded)
534
541
  gattServer?.addService(service)
535
542
 
536
- Log.i(TAG, "GATT server configured")
543
+ Log.i(TAG, "GATT server setup initiated, waiting for service registration callback...")
544
+ emitDiagnostic("info", "GATT server setup initiated")
537
545
  } catch (e: SecurityException) {
538
546
  Log.e(TAG, "Permission denied while setting up GATT server", e)
539
547
  throw e
@@ -644,6 +652,16 @@ class BleManager(
644
652
  private fun startAdvertising(reason: String = "manual") {
645
653
  if (isAdvertising) return
646
654
 
655
+ // Wait for GATT service to be ready before advertising
656
+ if (!isGattServiceReady) {
657
+ pendingAdvertiseReason = reason
658
+ if (logThrottler.shouldLog("advert_waiting_gatt", intervalMs = 5000)) {
659
+ Log.i(TAG, "Waiting for GATT service to be ready before advertising (reason: $reason)")
660
+ emitDiagnostic("info", "Waiting for GATT service registration", mapOf("reason" to reason))
661
+ }
662
+ return
663
+ }
664
+
647
665
  try {
648
666
  val settings = AdvertiseSettings.Builder()
649
667
  .setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_LOW_LATENCY)
@@ -680,7 +698,11 @@ class BleManager(
680
698
  }
681
699
  }
682
700
 
683
- bluetoothLeAdvertiser?.startAdvertising(settings, advertiseData, advertiseCallback)
701
+ // Include scan response with service UUID for iOS compatibility
702
+ // iOS's CoreBluetooth actively queries for scan responses and has known issues
703
+ // recognizing 128-bit service UUIDs from Android's main advertisement packet
704
+ val scanResponse = buildScanResponse()
705
+ bluetoothLeAdvertiser?.startAdvertising(settings, advertiseData, scanResponse, advertiseCallback)
684
706
 
685
707
  // Reduced logging
686
708
  } catch (e: SecurityException) {
@@ -744,6 +766,21 @@ class BleManager(
744
766
  .build()
745
767
  }
746
768
 
769
+ /**
770
+ * Builds the scan response data for BLE advertising.
771
+ *
772
+ * iOS's CoreBluetooth actively queries for scan responses during BLE scanning.
773
+ * Including the service UUID in the scan response makes Android devices more
774
+ * reliably visible to iOS devices, which have known issues recognizing 128-bit
775
+ * service UUIDs from Android's main advertisement packet format.
776
+ */
777
+ private fun buildScanResponse(): AdvertiseData {
778
+ return AdvertiseData.Builder()
779
+ .setIncludeDeviceName(false)
780
+ .addServiceUuid(ParcelUuid(SERVICE_UUID))
781
+ .build()
782
+ }
783
+
747
784
  private fun handleScanResult(result: ScanResult) {
748
785
  val device = result.device
749
786
  val rssi = result.rssi
@@ -1456,6 +1493,33 @@ class BleManager(
1456
1493
  // MARK: - GATT Server Callback
1457
1494
 
1458
1495
  private val gattServerCallback = object : BluetoothGattServerCallback() {
1496
+ override fun onServiceAdded(status: Int, service: BluetoothGattService?) {
1497
+ if (status == BluetoothGatt.GATT_SUCCESS) {
1498
+ Log.i(TAG, "✅ GATT service added successfully: ${service?.uuid}")
1499
+ emitDiagnostic("info", "GATT service registered successfully", mapOf(
1500
+ "serviceUUID" to (service?.uuid?.toString() ?: "unknown")
1501
+ ))
1502
+ isGattServiceReady = true
1503
+
1504
+ // Start advertising now that the service is ready
1505
+ val reason = pendingAdvertiseReason
1506
+ if (reason != null) {
1507
+ pendingAdvertiseReason = null
1508
+ Log.i(TAG, "📡 Starting deferred advertising after GATT service ready")
1509
+ mainHandler.post {
1510
+ startAdvertising("gatt_service_ready")
1511
+ }
1512
+ }
1513
+ } else {
1514
+ Log.e(TAG, "❌ Error adding GATT service: status=$status")
1515
+ emitDiagnostic("error", "Error adding GATT service", mapOf(
1516
+ "status" to status,
1517
+ "serviceUUID" to (service?.uuid?.toString() ?: "unknown")
1518
+ ))
1519
+ isGattServiceReady = false
1520
+ }
1521
+ }
1522
+
1459
1523
  override fun onConnectionStateChange(device: BluetoothDevice, status: Int, newState: Int) {
1460
1524
  when (newState) {
1461
1525
  BluetoothProfile.STATE_CONNECTED -> {
@@ -121,6 +121,8 @@ public class BleManager: NSObject, TransportManager {
121
121
  private var isAdvertising = false
122
122
  private var centralReady = false
123
123
  private var peripheralReady = false
124
+ private var isGattServiceReady = false
125
+ private var pendingAdvertiseAfterServiceReady = false
124
126
  private var subscribedCentrals: Set<UUID> = []
125
127
  private var lastMeshAdvertisement: MeshAdvertisementData?
126
128
 
@@ -159,6 +161,9 @@ public class BleManager: NSObject, TransportManager {
159
161
  private let MAX_CONSECUTIVE_SCAN_RESTARTS = 3
160
162
  private let CENTRAL_RESET_BACKOFF: TimeInterval = 45.0
161
163
  private let MINIMUM_RSSI_TO_CONNECT: Int16 = -90
164
+ /// Rate limiting for unknown connectable devices that need GATT verification
165
+ private var unknownDeviceAttempts: [UUID: Date] = [:]
166
+ private let UNKNOWN_DEVICE_RATE_LIMIT: TimeInterval = 10.0
162
167
 
163
168
  // MARK: - Thread helpers
164
169
  @inline(__always)
@@ -385,6 +390,7 @@ public class BleManager: NSObject, TransportManager {
385
390
  pendingFragments.removeAll()
386
391
  pendingOutboundFragments.removeAll()
387
392
  lastSeenMeshAdvertisements.removeAll()
393
+ unknownDeviceAttempts.removeAll()
388
394
  pendingAdvertiseRestart?.cancel()
389
395
  pendingAdvertiseRestart = nil
390
396
  lastAdvertiseRestartAt = nil
@@ -397,6 +403,8 @@ public class BleManager: NSObject, TransportManager {
397
403
 
398
404
  centralReady = false
399
405
  peripheralReady = false
406
+ isGattServiceReady = false
407
+ pendingAdvertiseAfterServiceReady = false
400
408
 
401
409
  updateState(.stopped)
402
410
  emitDiagnostic("info", "BLE transport stopped")
@@ -473,8 +481,12 @@ public class BleManager: NSObject, TransportManager {
473
481
  scanRestartCount = 0
474
482
  }
475
483
 
484
+ // Scan without service UUID filter for iOS ↔ Android interoperability
485
+ // iOS's scanForPeripherals(withServices:) has known issues recognizing 128-bit
486
+ // service UUIDs from Android advertisements. Scanning with nil allows us to see
487
+ // all peripherals and filter in the discovery callback instead.
476
488
  central.scanForPeripherals(
477
- withServices: [SERVICE_UUID],
489
+ withServices: nil,
478
490
  options: [CBCentralManagerScanOptionAllowDuplicatesKey: true]
479
491
  )
480
492
  isScanning = true
@@ -705,6 +717,16 @@ public class BleManager: NSObject, TransportManager {
705
717
 
706
718
  setupGattServer()
707
719
 
720
+ // Wait for GATT service to be ready before advertising
721
+ guard isGattServiceReady else {
722
+ pendingAdvertiseAfterServiceReady = true
723
+ if logThrottler.shouldLog(key: "advert_waiting_gatt", interval: 5) {
724
+ print("[BleManager] Waiting for GATT service to be ready before advertising (reason: \(reason))")
725
+ emitDiagnostic("info", "Waiting for GATT service registration", context: ["reason": reason])
726
+ }
727
+ return
728
+ }
729
+
708
730
  let meshData = meshController.advertisement()
709
731
  lastMeshAdvertisement = meshData
710
732
  var advertisementData: [String: Any] = [
@@ -763,10 +785,13 @@ public class BleManager: NSObject, TransportManager {
763
785
 
764
786
  private func setupGattServer() {
765
787
  guard let peripheral = peripheralManager else { return }
766
- if messageCharacteristic != nil && deviceIdCharacteristic != nil {
788
+ if messageCharacteristic != nil && deviceIdCharacteristic != nil && isGattServiceReady {
767
789
  return
768
790
  }
769
791
 
792
+ // Reset flag - service registration is asynchronous
793
+ isGattServiceReady = false
794
+
770
795
  // Create message characteristic (write without response + notify)
771
796
  messageCharacteristic = CBMutableCharacteristic(
772
797
  type: MESSAGE_CHAR_UUID,
@@ -788,10 +813,10 @@ public class BleManager: NSObject, TransportManager {
788
813
  let service = CBMutableService(type: SERVICE_UUID, primary: true)
789
814
  service.characteristics = [messageCharacteristic!, deviceIdCharacteristic!]
790
815
 
791
- // Add service to peripheral manager
816
+ // Add service to peripheral manager (asynchronous - callback in peripheralManager(_:didAdd:error:))
792
817
  peripheral.add(service)
793
- print("[BleManager] GATT server configured")
794
- emitDiagnostic("info", "GATT server configured")
818
+ print("[BleManager] GATT server setup initiated, waiting for service registration callback...")
819
+ emitDiagnostic("info", "GATT server setup initiated")
795
820
  }
796
821
 
797
822
  private func startFragmentPolling() {
@@ -1199,6 +1224,9 @@ public class BleManager: NSObject, TransportManager {
1199
1224
 
1200
1225
  private func pruneMeshObservations(now: Date = Date()) {
1201
1226
  lastSeenMeshAdvertisements = lastSeenMeshAdvertisements.filter { now.timeIntervalSince($0.value.timestamp) <= MESH_OBSERVATION_TTL }
1227
+
1228
+ // Also clean up stale unknown device rate limiting entries
1229
+ unknownDeviceAttempts = unknownDeviceAttempts.filter { now.timeIntervalSince($0.value) <= UNKNOWN_DEVICE_RATE_LIMIT * 3 }
1202
1230
  }
1203
1231
 
1204
1232
  // MARK: - Adaptive Scan Methods
@@ -1302,6 +1330,88 @@ public class BleManager: NSObject, TransportManager {
1302
1330
 
1303
1331
  return normalizedHash < skipProbability
1304
1332
  }
1333
+
1334
+ // MARK: - Smart Filtering for iOS ↔ Android Interoperability
1335
+
1336
+ /// Determines if a discovered peripheral should be processed.
1337
+ /// This implements smart filtering since we scan without a service UUID filter
1338
+ /// (required for iOS ↔ Android interoperability).
1339
+ ///
1340
+ /// Accepts:
1341
+ /// - Devices advertising our service UUID (iOS devices)
1342
+ /// - Devices with our service data
1343
+ /// - Previously discovered mesh devices
1344
+ /// - Unknown connectable devices (rate-limited, verified via GATT)
1345
+ private func shouldProcessDiscoveredPeripheral(
1346
+ peripheral: CBPeripheral,
1347
+ advertisementData: [String: Any],
1348
+ rssi: Int16,
1349
+ now: Date
1350
+ ) -> Bool {
1351
+ // 1. Check if device is advertising our service UUID
1352
+ if let serviceUUIDs = advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID] {
1353
+ if serviceUUIDs.contains(SERVICE_UUID) {
1354
+ return true
1355
+ }
1356
+ }
1357
+
1358
+ // 2. Check for our service data (may come from scan response)
1359
+ if let serviceData = advertisementData[CBAdvertisementDataServiceDataKey] as? [CBUUID: Data] {
1360
+ if serviceData[SERVICE_UUID] != nil {
1361
+ return true
1362
+ }
1363
+ }
1364
+
1365
+ // 3. Check if this is a previously discovered mesh device
1366
+ if lastSeenMeshAdvertisements[peripheral.identifier] != nil {
1367
+ return true
1368
+ }
1369
+
1370
+ // 4. Check if we already have this peripheral in our discovered list
1371
+ // (previously connected or successfully verified via GATT)
1372
+ if discoveredPeripherals[peripheral.identifier] != nil {
1373
+ return true
1374
+ }
1375
+
1376
+ // 5. Check if we already have a device ID mapping for this peripheral
1377
+ if connections.peripheralDeviceId(for: peripheral.identifier) != nil ||
1378
+ connections.centralDeviceId(for: peripheral.identifier) != nil {
1379
+ return true
1380
+ }
1381
+
1382
+ // 6. For unknown connectable devices, allow with rate limiting
1383
+ // These will be verified via GATT service discovery after connection
1384
+ let isConnectable: Bool
1385
+ if #available(iOS 13.0, *) {
1386
+ isConnectable = (advertisementData[CBAdvertisementDataIsConnectable] as? NSNumber)?.boolValue ?? false
1387
+ } else {
1388
+ isConnectable = true
1389
+ }
1390
+
1391
+ if isConnectable {
1392
+ // Rate limit unknown device connection attempts
1393
+ if let lastAttempt = unknownDeviceAttempts[peripheral.identifier],
1394
+ now.timeIntervalSince(lastAttempt) < UNKNOWN_DEVICE_RATE_LIMIT {
1395
+ return false
1396
+ }
1397
+
1398
+ // Only process strong signals for unknown devices
1399
+ if rssi >= -75 {
1400
+ unknownDeviceAttempts[peripheral.identifier] = now
1401
+ if logThrottler.shouldLog(key: "unknown_connectable_\(peripheral.identifier.uuidString)", interval: 30) {
1402
+ print("[BleManager] Allowing unknown connectable device for GATT verification: \(peripheral.identifier) RSSI=\(rssi)")
1403
+ emitDiagnostic("debug", "Allowing unknown device for GATT verification", context: [
1404
+ "identifier": peripheral.identifier.uuidString,
1405
+ "rssi": rssi
1406
+ ])
1407
+ }
1408
+ return true
1409
+ }
1410
+ }
1411
+
1412
+ // Filter out all other devices (not our mesh network)
1413
+ return false
1414
+ }
1305
1415
 
1306
1416
  private func identifierForNodeHash(_ nodeHash: UInt64) -> UUID? {
1307
1417
  for (identifier, observation) in lastSeenMeshAdvertisements where observation.advertisement.nodeIdHash == nodeHash {
@@ -1482,6 +1592,20 @@ extension BleManager: CBCentralManagerDelegate {
1482
1592
  // Adaptive scanning: track discoveries for density estimation
1483
1593
  recordDiscoveryForDensity(now: now)
1484
1594
 
1595
+ // Smart filtering for iOS ↔ Android interoperability
1596
+ // Since we scan without a service UUID filter (for Android compatibility),
1597
+ // we need to filter discovered peripherals here instead.
1598
+ let shouldProcess = shouldProcessDiscoveredPeripheral(
1599
+ peripheral: peripheral,
1600
+ advertisementData: advertisementData,
1601
+ rssi: rssiValue,
1602
+ now: now
1603
+ )
1604
+
1605
+ if !shouldProcess {
1606
+ return
1607
+ }
1608
+
1485
1609
  // Adaptive scanning: early RSSI filtering in dense networks
1486
1610
  if shouldFilterByRssi(rssiValue) {
1487
1611
  if logThrottler.shouldLog(key: "adaptive_rssi_filter", interval: 10) {
@@ -1523,7 +1647,20 @@ extension BleManager: CBCentralManagerDelegate {
1523
1647
  pruneMeshObservations(now: now)
1524
1648
  meshController.observeAdvertisement(meshMetadata, rssi: Int(rssiValue))
1525
1649
 
1526
- let decision = meshController.shouldInitiateOutbound(metadata: meshMetadata, rssi: Int(rssiValue))
1650
+ // When there's no metadata (iOS/Android advertising without service data),
1651
+ // still try to connect - metadata will be exchanged via GATT after connection
1652
+ let decision: MeshController.MeshDecision
1653
+ if meshMetadata == nil {
1654
+ // No metadata in advertisement - allow basic connection to exchange info via GATT
1655
+ decision = MeshController.MeshDecision(
1656
+ intent: .intraCluster,
1657
+ reason: "no_metadata_in_advert",
1658
+ evictPeerId: nil
1659
+ )
1660
+ } else {
1661
+ decision = meshController.shouldInitiateOutbound(metadata: meshMetadata, rssi: Int(rssiValue))
1662
+ }
1663
+
1527
1664
  guard decision.intent != .rejected else {
1528
1665
  if logThrottler.shouldLog(key: "mesh_skip_\(peripheral.identifier.uuidString)", interval: 15) {
1529
1666
  print("[BleManager] Skipping \(peripheral.identifier) due to \(decision.reason)")
@@ -1892,6 +2029,32 @@ extension BleManager: CBPeripheralManagerDelegate {
1892
2029
  }
1893
2030
  }
1894
2031
 
2032
+ public func peripheralManager(_ peripheral: CBPeripheralManager, didAdd service: CBService, error: Error?) {
2033
+ if let error = error {
2034
+ print("[BleManager] ❌ Error adding GATT service: \(error)")
2035
+ emitDiagnostic("error", "Error adding GATT service", context: [
2036
+ "error": error.localizedDescription,
2037
+ "serviceUUID": service.uuid.uuidString
2038
+ ])
2039
+ isGattServiceReady = false
2040
+ return
2041
+ }
2042
+
2043
+ print("[BleManager] ✅ GATT service added successfully: \(service.uuid)")
2044
+ emitDiagnostic("info", "GATT service registered successfully", context: [
2045
+ "serviceUUID": service.uuid.uuidString
2046
+ ])
2047
+
2048
+ isGattServiceReady = true
2049
+
2050
+ // Start advertising now that the service is ready
2051
+ if pendingAdvertiseAfterServiceReady {
2052
+ pendingAdvertiseAfterServiceReady = false
2053
+ print("[BleManager] 📡 Starting deferred advertising after GATT service ready")
2054
+ startAdvertising(reason: "gatt_service_ready")
2055
+ }
2056
+ }
2057
+
1895
2058
  public func peripheralManager(_ peripheral: CBPeripheralManager, didReceiveWrite requests: [CBATTRequest]) {
1896
2059
  for request in requests {
1897
2060
  print("[BleManager] 📨 GATT WRITE REQUEST from \(request.central.identifier), char: \(request.characteristic.uuid), size: \(request.value?.count ?? 0)")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@offline-protocol/mesh-sdk",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Offline-first mesh networking SDK with intelligent transport switching for React Native",
5
5
  "main": "lib/index.js",
6
6
  "types": "lib/index.d.ts",