diff --git a/ios/Runner/AccessorySetup.swift b/ios/Runner/AccessorySetup.swift index ee7c0a86..75fa2d76 100644 --- a/ios/Runner/AccessorySetup.swift +++ b/ios/Runner/AccessorySetup.swift @@ -26,13 +26,19 @@ import AccessorySetupKit /// - `removeAll` -> nil (deprovision all — used on unpair) enum AccessorySetup { private static let channelName = "openstrap/accessory_setup" - // WHOOP GATT service UUIDs, one per generation (match GattProfile in Dart). - // `fileprivate` so the iOS-18 Impl below can read them. BOTH must also be - // listed in Info.plist under NSAccessorySetupBluetoothServices. - // • gen4 ("Harvard", WHOOP 4) — 6108… - // • gen5 ("fd4b", WHOOP 5) — fd4b… (EXPERIMENTAL) + // WHOOP GATT service UUIDs (match GattProfile / kWhoopMemberUuid16 in Dart). + // `fileprivate` so the iOS-18 Impl below can read them. Every criterion used + // in an ASDiscoveryDescriptor must also be listed in Info.plist or iOS + // silently ignores it. + // • gen4 ("Harvard", WHOOP 4) — 6108… 128-bit vendor service + // • gen5 ("fd4b", WHOOP 5.0 / MG) — fd4b0001-cce1-… 128-bit vendor + // • 16-bit SIG member UUID 0xFD4B — what still fits a 31-byte AD + // The 16-bit form is NOT 0000FD4B-0000-1000-8000-00805F9B34FB; no band + // advertises that Bluetooth-base expansion. fileprivate static let whoopServiceUUIDGen4 = "61080001-8d6d-82b8-614a-1c8cb0f8dcc6" fileprivate static let whoopServiceUUIDGen5 = "fd4b0001-cce1-4033-93ce-002d5875f58a" + fileprivate static let whoopMemberUUID16 = "FD4B" + fileprivate static let nameSubstring = "WHOOP" static func register(messenger: FlutterBinaryMessenger) { let channel = FlutterMethodChannel(name: channelName, binaryMessenger: messenger) @@ -140,44 +146,68 @@ private final class Impl { return } - // Match on the WHOOP custom service UUID alone. The foreground scan finds the - // band via startScan(withServices:[…]) and succeeds, which proves the band - // advertises this service — so it's a reliable, sufficient filter. Every - // descriptor criterion must be declared in Info.plist; the UUIDs are listed - // under NSAccessorySetupBluetoothServices. (No bluetoothNameSubstring: a - // single descriptor AND-combines its criteria, and a name filter would also - // require an NSAccessorySetupBluetoothNames entry and risk excluding the band - // on a name mismatch.) + // ONE ITEM PER MATCH STRATEGY. A single ASDiscoveryDescriptor AND-combines + // its criteria, so folding gen5's 128-bit UUID, 16-bit 0xFD4B, and a name + // substring onto one descriptor would match nothing. showPicker(for:) takes + // an array so each strategy is its own accessory; the sheet de-duplicates + // by peripheral. // - // ASK matches ANY item in the picker list, so we offer one item per WHOOP - // generation: gen4 (WHOOP 4) and gen5 (WHOOP 5, experimental). A band that - // advertises either service can be provisioned; the provisioned identifier is - // the same CoreBluetooth UUID regardless of generation. + // Why three gen5-relevant items: we do not yet know (no nRF Connect capture) + // whether fd4b0001-… is in the primary advertisement or only the scan + // response. The 16-bit member UUID is what still fits a 31-byte AD; the + // name (`WHOOP MGB…` / `WHOOP 5A…`) survives even if iOS hashes the 128-bit + // UUID in the overflow area. let productImage = UIImage(named: "StrapProduct") ?? UIImage(systemName: "sensor.tag.radiowave.forward") ?? UIImage() - func item(_ serviceUUID: String, _ name: String) -> ASPickerDisplayItem { + func makeItem(_ label: String, + _ configure: (ASDiscoveryDescriptor) -> Void) -> ASPickerDisplayItem { let descriptor = ASDiscoveryDescriptor() - descriptor.bluetoothServiceUUID = CBUUID(string: serviceUUID) - return ASPickerDisplayItem( - name: name, productImage: productImage, descriptor: descriptor) + configure(descriptor) + return ASPickerDisplayItem(name: label, productImage: productImage, + descriptor: descriptor) } - let items = [ - item(AccessorySetup.whoopServiceUUIDGen4, "WHOOP band"), - item(AccessorySetup.whoopServiceUUIDGen5, "WHOOP 5 band"), + let items: [ASPickerDisplayItem] = [ + makeItem("WHOOP band") { + $0.bluetoothServiceUUID = CBUUID(string: AccessorySetup.whoopServiceUUIDGen4) + }, + makeItem("WHOOP 5.0 / MG") { + $0.bluetoothServiceUUID = CBUUID(string: AccessorySetup.whoopServiceUUIDGen5) + }, + makeItem("WHOOP 5.0 / MG") { + $0.bluetoothServiceUUID = CBUUID(string: AccessorySetup.whoopMemberUUID16) + }, + makeItem("WHOOP band") { + $0.bluetoothNameSubstring = AccessorySetup.nameSubstring + }, ] pickerResult = completion + present(items, allowGen4Retry: true) + } + + /// Presents the picker and resolves `pickerResult`. + /// + /// If iOS rejects the widened descriptor list (a name-only item is the + /// experimental one), retry once with the WHOOP 4.0 item that already ships, + /// so the experiment can never take down 4.0 pairing. + private func present(_ items: [ASPickerDisplayItem], allowGen4Retry: Bool) { session.showPicker(for: items) { [weak self] error in guard let self = self else { return } if let error = error { - if let cb = self.pickerResult { - self.pickerResult = nil - cb(.failure(PickerError(message: error.localizedDescription))) + guard let cb = self.pickerResult else { return } + let message = error.localizedDescription + let looksCancelled = message.lowercased().contains("cancel") + if allowGen4Retry, !looksCancelled, items.count > 1 { + NSLog("[ASK] picker rejected the %d-item descriptor list (%@) — " + + "retrying with the WHOOP 4.0 item only.", items.count, message) + self.present([items[0]], allowGen4Retry: false) + return } + self.pickerResult = nil + cb(.failure(PickerError(message: message))) return } - // Picker succeeded — read the newly provisioned accessory's identifier. let id = self.session.accessories .compactMap { $0.bluetoothIdentifier } .first?.uuidString.uppercased() diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 45d903a2..5d2fc325 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -55,6 +55,18 @@ 61080001-8D6D-82B8-614A-1C8CB0F8DCC6 FD4B0001-CCE1-4033-93CE-002D5875F58A + + FD4B + + + NSAccessorySetupBluetoothNames + + WHOOP NSAccessorySetupKitSupports diff --git a/lib/ble/ble_engine.dart b/lib/ble/ble_engine.dart index 2bb7caad..91b9746f 100644 --- a/lib/ble/ble_engine.dart +++ b/lib/ble/ble_engine.dart @@ -84,6 +84,54 @@ typedef ArchiveSink = Future Function(ArchiveRecord archive); /// trigger now that listening is continuous and there's no discrete sync end. typedef DataStoredSink = void Function(); +// ── WHOOP 5.0 / MG discovery (not wire-format) ────────────────────────────── +// Transport/framing lives in package:openstrap_protocol (BandProfile / GattProfile). +// Edge only decides what to *look for*. The 128-bit vendor service is already +// on main; what is still unsettled on hardware is whether a real band puts it +// in the primary advertisement or only the scan response (#238 close note). +// +// A 128-bit UUID often does not fit the 31-byte AD. iOS hashes anything that +// spills into the scan-response overflow area, so AccessorySetupKit never sees +// it. The SIG member UUID 0xFD4B (2 bytes) and the advertised name +// (`WHOOP MGB…` / `WHOOP 5A…`) still fit. That 16-bit form is NOT the +// Bluetooth-base expansion `0000FD4B-0000-1000-8000-00805F9B34FB` — no band +// advertises that 128-bit value. + +/// 16-bit Bluetooth SIG member UUID assigned to WHOOP. Distinct from +/// [GattProfile.gen5.service]. Platforms disagree on spelling: iOS reports +/// `"fd4b"`; Android reports the Base-UUID expansion. +const String kWhoopMemberUuid16 = 'fd4b'; + +/// Service UUIDs used as the BLE `withServices` scan filter. +/// +/// `withServices` is OR-combined on both platforms. The 16-bit member UUID +/// must be its own entry: filtering only on the 128-bit vendor UUID misses a +/// band that advertised the 2-byte form. +List whoopScanServiceUuids() => [ + Guid(GattProfile.gen4.service), + Guid(GattProfile.gen5.service), + Guid(kWhoopMemberUuid16), + ]; + +/// True when a scan result is a WHOOP strap of either generation. +/// +/// Matching is broad on purpose: a band whose 128-bit service UUID is hidden +/// in the scan-response overflow must still be caught by the 16-bit member +/// UUID or by its advertised name, or pairing never starts. +bool advertisementLooksLikeWhoop({ + required String platformName, + required Iterable serviceUuids, +}) { + if (platformName.toLowerCase().contains('whoop')) return true; + for (final raw in serviceUuids) { + final u = raw.toLowerCase(); + if (u.startsWith(GattProfile.gen4.servicePrefix.toLowerCase())) return true; + if (u.startsWith(GattProfile.gen5.servicePrefix.toLowerCase())) return true; + if (u == kWhoopMemberUuid16 || u.startsWith('0000fd4b')) return true; + } + return false; +} + /// Map a decoded gen5 historical record onto the band-agnostic `Sample` type, /// or null when this record kind has no `Sample` equivalent (yet). @@ -1288,21 +1336,18 @@ class BleEngine { await FlutterBluePlus.stopScan(); } _setPhase(BleConnState.scanning); - // Advertise-filter on BOTH generations' service UUIDs (gen4 6108xxxx + - // gen5 fd4bxxxx); the actual generation is pinned later at discovery. - final gen4Svc = Guid(GattProfile.gen4.service); - final gen5Svc = Guid(GattProfile.gen5.service); + // Advertise-filter on both 128-bit vendor UUIDs plus the 16-bit member + // UUID. Generation is pinned later at GATT discovery. See + // [whoopScanServiceUuids] / [advertisementLooksLikeWhoop]. BluetoothDevice? found; final sub = FlutterBluePlus.onScanResults.listen((results) { for (final r in results) { - final name = r.device.platformName.toLowerCase(); - final advNames = r.advertisementData.serviceUuids.map( - (g) => g.str.toLowerCase(), - ); if (found == null && - (name.contains('whoop') || - advNames.any((s) => - s.startsWith('61080001') || s.startsWith('fd4b0001')))) { + advertisementLooksLikeWhoop( + platformName: r.device.platformName, + serviceUuids: + r.advertisementData.serviceUuids.map((g) => g.str), + )) { found = r.device; FlutterBluePlus.stopScan(); } @@ -1310,7 +1355,7 @@ class BleEngine { }); try { await FlutterBluePlus.startScan( - withServices: [gen4Svc, gen5Svc], timeout: timeout); + withServices: whoopScanServiceUuids(), timeout: timeout); await FlutterBluePlus.isScanning.where((on) => on == false).first; } catch (e) { _log('scan error: $e'); diff --git a/test/gen5_pairing_filter_test.dart b/test/gen5_pairing_filter_test.dart new file mode 100644 index 00000000..affaea77 --- /dev/null +++ b/test/gen5_pairing_filter_test.dart @@ -0,0 +1,164 @@ +// Pairing filter for WHOOP 5.0 / MG — the leftover from #238 after the +// transport landed in protocol#27 + edge#97. +// +// The 128-bit vendor service `fd4b0001-cce1-…` is already on main. What was +// never settled is whether a real band puts that UUID in the *primary* +// advertisement or only the scan response. A 128-bit UUID often does not fit +// the 31-byte AD; iOS then hashes it in the overflow area and AccessorySetupKit +// never sees it. The SIG member UUID `0xFD4B` (2 bytes) and the advertised +// local name (`WHOOP MGB…` / `WHOOP 5A…`) are what still fit. +// +// This is not a second codec. Edge still holds no wire-format. These tests pin +// the discovery surface: Dart scan filter, Info.plist, and ASK descriptors. + +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/ble/ble_engine.dart'; +import 'package:openstrap_protocol/openstrap_protocol.dart'; + +String _posix(String path) => path.replaceAll(Platform.pathSeparator, '/'); + +String _readRepoFile(String posixPath) { + final file = File(posixPath.split('/').join(Platform.pathSeparator)); + expect(file.existsSync(), isTrue, + reason: 'run from the package root; missing $posixPath'); + return file.readAsStringSync(); +} + +void main() { + group('16-bit member UUID is not the Bluetooth-base expansion', () { + test('kWhoopMemberUuid16 is the short SIG assignment', () { + expect(kWhoopMemberUuid16, 'fd4b'); + expect(kWhoopMemberUuid16, isNot('0000fd4b-0000-1000-8000-00805f9b34fb')); + expect(GattProfile.gen5.service, isNot(startsWith('0000fd4b'))); + expect(GattProfile.gen5.service, + 'fd4b0001-cce1-4033-93ce-002d5875f58a'); + }); + }); + + group('advertisementLooksLikeWhoop', () { + test('matches gen4 and gen5 128-bit vendor prefixes', () { + expect( + advertisementLooksLikeWhoop( + platformName: '', + serviceUuids: [GattProfile.gen4.service], + ), + isTrue, + ); + expect( + advertisementLooksLikeWhoop( + platformName: '', + serviceUuids: [GattProfile.gen5.service], + ), + isTrue, + ); + }); + + test('matches the 16-bit member UUID in both platform spellings', () { + // iOS reports 16-bit UUIDs short; Android expands them against the base. + expect( + advertisementLooksLikeWhoop( + platformName: '', + serviceUuids: const ['fd4b'], + ), + isTrue, + ); + expect( + advertisementLooksLikeWhoop( + platformName: '', + serviceUuids: const ['0000fd4b-0000-1000-8000-00805f9b34fb'], + ), + isTrue, + ); + }); + + test('matches a WHOOP MG advertised name with no service UUID yet', () { + // Issue #237: the band shows up as WHOOP MGB… in system Bluetooth. + expect( + advertisementLooksLikeWhoop( + platformName: 'WHOOP MGB1234', + serviceUuids: const [], + ), + isTrue, + ); + }); + + test('rejects a Polar H10 advertising the standard HR service', () { + expect( + advertisementLooksLikeWhoop( + platformName: 'Polar H10', + serviceUuids: const ['0000180d-0000-1000-8000-00805f9b34fb'], + ), + isFalse, + ); + }); + }); + + group('whoopScanServiceUuids', () { + test('filters on both 128-bit vendors and the 16-bit member UUID', () { + final uuids = whoopScanServiceUuids().map((g) => g.str.toLowerCase()); + expect(uuids, contains(GattProfile.gen4.service)); + expect(uuids, contains(GattProfile.gen5.service)); + expect( + uuids.any((u) => u == 'fd4b' || u.startsWith('0000fd4b')), + isTrue, + reason: '16-bit 0xFD4B must be its own scan filter — the 128-bit ' + 'vendor UUID is a different value and will not match a band that ' + 'only advertised the 2-byte form', + ); + }); + }); + + group('iOS ASK / Info.plist stay in lockstep with the Dart filter', () { + late String plist; + late String swift; + late String engine; + + setUpAll(() { + plist = _readRepoFile('ios/Runner/Info.plist'); + swift = _readRepoFile('ios/Runner/AccessorySetup.swift'); + engine = _readRepoFile('lib/ble/ble_engine.dart'); + }); + + test('Info.plist declares the 128-bit vendor service and 16-bit FD4B', () { + expect(plist, contains('FD4B0001-CCE1-4033-93CE-002D5875F58A')); + expect(plist, contains('FD4B')); + // The Bluetooth-base expansion may be named in a comment as the thing + // we must NOT declare. It must never be an actual ASK service string. + expect( + plist, + isNot(contains('0000FD4B-0000-1000-8000-00805F9B34FB')), + ); + }); + + test('Info.plist declares the WHOOP name net for ASK', () { + expect(plist, contains('NSAccessorySetupBluetoothNames')); + expect(plist, contains('WHOOP')); + }); + + test('ASK has a separate 16-bit FD4B descriptor, not AND-combined', () { + expect(swift, contains('whoopServiceUUIDGen5')); + expect(swift.toUpperCase(), contains('FD4B0001-CCE1-4033-93CE-002D5875F58A')); + // A 16-bit CBUUID("FD4B") is its own picker item. Criteria inside one + // ASDiscoveryDescriptor AND-combine, so folding this onto the 128-bit + // item would match nothing if the band advertised only one form. + expect(swift, contains('whoopMemberUUID16')); + expect( + swift, + contains('CBUUID(string: AccessorySetup.whoopMemberUUID16)'), + ); + }); + + test('ASK has a name-substring item as the last net', () { + expect(swift, contains('bluetoothNameSubstring')); + expect(swift, contains('"WHOOP"')); + }); + + test('engine scan uses the shared filter helper, not a second UUID list', () { + expect(engine, contains('whoopScanServiceUuids()')); + expect(engine, contains('advertisementLooksLikeWhoop(')); + expect(_posix('lib/ble/ble_engine.dart'), 'lib/ble/ble_engine.dart'); + }); + }); +}