From 3a83fcccb5b6a116e4a5668b9577a7d22383c3ab Mon Sep 17 00:00:00 2001 From: MikenRafter Date: Tue, 10 Nov 2026 19:51:39 -0700 Subject: [PATCH] fix(pairing): bind scan results to the selected band --- lib/ble/ble_state.dart | 11 +++++++++++ lib/ble/transport.dart | 14 ++++++++++++-- lib/state/app_state.dart | 4 +++- lib/ui2/onboarding/pairing.dart | 11 +++++++++-- test/pairing_choice_test.dart | 16 ++++++++++++++++ 5 files changed, 51 insertions(+), 5 deletions(-) create mode 100644 test/pairing_choice_test.dart diff --git a/lib/ble/ble_state.dart b/lib/ble/ble_state.dart index 6641f3284..8886f0196 100644 --- a/lib/ble/ble_state.dart +++ b/lib/ble/ble_state.dart @@ -1924,6 +1924,17 @@ class ScanAcceptPolicy { } return null; } + + /// Whether a scan result may be the band the user chose: the remote id + /// [wantRemoteId] when one was named, and no advertised generation other + /// than [wantGeneration]. A name-only or 16-bit-only match names no + /// generation, so it may still be either and passes. + static bool fitsChoice(String remoteId, Iterable serviceUuids, + {String? wantRemoteId, String? wantGeneration}) { + if (wantRemoteId != null && remoteId != wantRemoteId) return false; + final adv = accepts(serviceUuids); + return wantGeneration == null || adv == null || adv == wantGeneration; + } } /// Which connect order a link gets, decided BEFORE discovery has run. diff --git a/lib/ble/transport.dart b/lib/ble/transport.dart index 9f4432220..bf35138df 100644 --- a/lib/ble/transport.dart +++ b/lib/ble/transport.dart @@ -21,12 +21,19 @@ extension BleEngineTransport on BleEngine { /// this one radio scanner, and the `isScanning == false` await below is /// satisfied by ITS `stopScan` too — an unserialised scan silently ends /// having seen nothing and reports "No band found". + /// + /// [remoteId] and [generation] narrow it to the band the user chose + /// ([ScanAcceptPolicy.fitsChoice]): any other WHOOP in range is passed over, + /// never paired in its place. Future scan({ Duration timeout = const Duration(seconds: 12), + String? remoteId, + String? generation, }) => - withScanLock(() => _scanLocked(timeout)); + withScanLock(() => _scanLocked(timeout, remoteId, generation)); - Future _scanLocked(Duration timeout) async { + Future _scanLocked( + Duration timeout, String? remoteId, String? generation) async { // A phone-level blocker is NOT "nothing answered". Returning null for a // revoked Bluetooth permission classified it as `notFound` upstream, which // told the user to walk closer to a band that was never the problem — the @@ -77,6 +84,9 @@ extension BleEngineTransport on BleEngine { // not a matchable service UUID; WHOOP 5's `fd4b` member UUID needs no // such fallback, so it supplies none. if (found == null && + ScanAcceptPolicy.fitsChoice(r.device.remoteId.str, + r.advertisementData.serviceUuids.map((g) => g.str), + wantRemoteId: remoteId, wantGeneration: generation) && (kFramedBands.any((e) => e.nameMatcher?.call(name) ?? false) || advNames.any((s) => s == kWhoopMemberUuid16 || diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index 0d6267716..6f2049fb7 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -4629,7 +4629,9 @@ class AppState extends ChangeNotifier { } // ── pairing (LOCAL only) ──────────────────────────────────────────────────── - Future scanForBand() => engine.scan(); + /// [remoteId] / [generation]: only the band the user chose ([BleEngine.scan]). + Future scanForBand({String? remoteId, String? generation}) => + engine.scan(remoteId: remoteId, generation: generation); /// True on iOS 18+, where pairing must go through the AccessorySetupKit picker so /// the band is provisioned for iOS-26 background relaunch (TN3115). False on Android diff --git a/lib/ui2/onboarding/pairing.dart b/lib/ui2/onboarding/pairing.dart index 56000e93a..acc9aed3e 100644 --- a/lib/ui2/onboarding/pairing.dart +++ b/lib/ui2/onboarding/pairing.dart @@ -15,6 +15,7 @@ import 'package:flutter/material.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:provider/provider.dart'; +import '../../ble/adapters/_registry.dart' show BandEntry; import '../../ble/band_status_l10n.dart' show localizedBandStatus; import '../../ble/ble_state.dart'; import '../../l10n/app_localizations.dart'; @@ -110,7 +111,12 @@ class PairingScreen extends StatefulWidget { /// Walk past pairing and open the app anyway. Supplied by the router. final VoidCallback? onSkip; - const PairingScreen({super.key, this.onSkip}); + /// Android and older iOS bind scans to this choice. On iOS 18+ the + /// AccessorySetupKit sheet chooses; no post-connect choice check runs. + final BandEntry? entry; + final String? remoteId; + + const PairingScreen({super.key, this.onSkip, this.entry, this.remoteId}); @override State createState() => _PairingScreenState(); @@ -132,7 +138,8 @@ class _PairingScreenState extends State { if (await app.accessorySetupSupported()) { await app.pairViaAccessorySetup(); } else { - final found = await app.scanForBand(); + final found = await app.scanForBand( + remoteId: widget.remoteId, generation: widget.entry?.id); if (found == null) { if (mounted) setState(() => _phase = PairPhase.notFound); return; diff --git a/test/pairing_choice_test.dart b/test/pairing_choice_test.dart new file mode 100644 index 000000000..b1f42e2f3 --- /dev/null +++ b/test/pairing_choice_test.dart @@ -0,0 +1,16 @@ +// WHY: the scan must never substitute a nearby band for the tapped one. +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/ble/ble_state.dart'; + +void main() { + test('choice binds both id and generation, with independent wildcards', () { + expect(ScanAcceptPolicy.fitsChoice('picked', const ['fd4b0001'], wantRemoteId: 'picked', wantGeneration: 'gen5'), isTrue); + expect(ScanAcceptPolicy.fitsChoice('other', const ['fd4b0001'], wantRemoteId: 'picked', wantGeneration: 'gen5'), isFalse); + expect(ScanAcceptPolicy.fitsChoice('picked', const ['61080001'], wantRemoteId: 'picked', wantGeneration: 'gen5'), isFalse); + expect(ScanAcceptPolicy.fitsChoice('picked', const ['61080001'], wantRemoteId: 'picked'), isTrue); + expect(ScanAcceptPolicy.fitsChoice('other', const ['fd4b0001'], wantGeneration: 'gen5'), isTrue); + expect(ScanAcceptPolicy.fitsChoice('other', const ['61080001']), isTrue); + // A name-only match has no model evidence; discovery still has to identify it. + expect(ScanAcceptPolicy.fitsChoice('picked', const [], wantRemoteId: 'picked', wantGeneration: 'gen5'), isTrue); + }); +}