@offline-protocol/mesh-sdk 0.2.1 → 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.
@@ -698,7 +698,11 @@ class BleManager(
698
698
  }
699
699
  }
700
700
 
701
- 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)
702
706
 
703
707
  // Reduced logging
704
708
  } catch (e: SecurityException) {
@@ -762,6 +766,21 @@ class BleManager(
762
766
  .build()
763
767
  }
764
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
+
765
784
  private fun handleScanResult(result: ScanResult) {
766
785
  val device = result.device
767
786
  val rssi = result.rssi
@@ -161,6 +161,9 @@ public class BleManager: NSObject, TransportManager {
161
161
  private let MAX_CONSECUTIVE_SCAN_RESTARTS = 3
162
162
  private let CENTRAL_RESET_BACKOFF: TimeInterval = 45.0
163
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
164
167
 
165
168
  // MARK: - Thread helpers
166
169
  @inline(__always)
@@ -387,6 +390,7 @@ public class BleManager: NSObject, TransportManager {
387
390
  pendingFragments.removeAll()
388
391
  pendingOutboundFragments.removeAll()
389
392
  lastSeenMeshAdvertisements.removeAll()
393
+ unknownDeviceAttempts.removeAll()
390
394
  pendingAdvertiseRestart?.cancel()
391
395
  pendingAdvertiseRestart = nil
392
396
  lastAdvertiseRestartAt = nil
@@ -477,8 +481,12 @@ public class BleManager: NSObject, TransportManager {
477
481
  scanRestartCount = 0
478
482
  }
479
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.
480
488
  central.scanForPeripherals(
481
- withServices: [SERVICE_UUID],
489
+ withServices: nil,
482
490
  options: [CBCentralManagerScanOptionAllowDuplicatesKey: true]
483
491
  )
484
492
  isScanning = true
@@ -1216,6 +1224,9 @@ public class BleManager: NSObject, TransportManager {
1216
1224
 
1217
1225
  private func pruneMeshObservations(now: Date = Date()) {
1218
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 }
1219
1230
  }
1220
1231
 
1221
1232
  // MARK: - Adaptive Scan Methods
@@ -1319,6 +1330,88 @@ public class BleManager: NSObject, TransportManager {
1319
1330
 
1320
1331
  return normalizedHash < skipProbability
1321
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
+ }
1322
1415
 
1323
1416
  private func identifierForNodeHash(_ nodeHash: UInt64) -> UUID? {
1324
1417
  for (identifier, observation) in lastSeenMeshAdvertisements where observation.advertisement.nodeIdHash == nodeHash {
@@ -1499,6 +1592,20 @@ extension BleManager: CBCentralManagerDelegate {
1499
1592
  // Adaptive scanning: track discoveries for density estimation
1500
1593
  recordDiscoveryForDensity(now: now)
1501
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
+
1502
1609
  // Adaptive scanning: early RSSI filtering in dense networks
1503
1610
  if shouldFilterByRssi(rssiValue) {
1504
1611
  if logThrottler.shouldLog(key: "adaptive_rssi_filter", interval: 10) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@offline-protocol/mesh-sdk",
3
- "version": "0.2.1",
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",