Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions lib/ble/ble_state.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> serviceUuids,
{String? wantRemoteId, String? wantGeneration}) {
if (wantRemoteId != null && remoteId != wantRemoteId) return false;
final adv = accepts(serviceUuids);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium · Conflicting advertisements are accepted

When an advertisement contains service UUIDs for both supported generations and the selected generation's UUID is encountered first, ScanAcceptPolicy.accepts returns only the first recognized generation, so fitsChoice misses the conflicting UUID and accepts an ambiguous result; the scan can select a band advertising both generations.

Inspect all recognized generation UUIDs and reject advertisements that identify more than one generation.

Also at lib/ble/ble_state.dart:1936.

Prompt for AI agents
In `lib/ble/ble_state.dart` at line 1935:

**Conflicting advertisements are accepted**

When an advertisement contains service UUIDs for both supported generations and the selected generation's UUID is encountered first, `ScanAcceptPolicy.accepts` returns only the first recognized generation, so `fitsChoice` misses the conflicting UUID and accepts an ambiguous result; the scan can select a band advertising both generations.

Inspect all recognized generation UUIDs and reject advertisements that identify more than one generation.

Also at `lib/ble/ble_state.dart:1936`.

return wantGeneration == null || adv == null || adv == wantGeneration;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium · Wrong-generation bands pass selection

When a caller selects a generation but the advertisement has no recognized generation and GATT discovery identifies a different one, ScanAcceptPolicy.fitsChoice treats a missing advertised generation as a match, and the pairing flow does not compare the generation found during discovery with the selected generation before saving; the user is paired with the wrong-generation band.

Reject advertisements with no recognized generation when one was selected, and verify the discovered generation matches before saving the pairing.

Prompt for AI agents
In `lib/ble/ble_state.dart` at line 1936:

**Wrong-generation bands pass selection**

When a caller selects a generation but the advertisement has no recognized generation and GATT discovery identifies a different one, `ScanAcceptPolicy.fitsChoice` treats a missing advertised generation as a match, and the pairing flow does not compare the generation found during discovery with the selected generation before saving; the user is paired with the wrong-generation band.

Reject advertisements with no recognized generation when one was selected, and verify the discovered generation matches before saving the pairing.

}
}

/// Which connect order a link gets, decided BEFORE discovery has run.
Expand Down
14 changes: 12 additions & 2 deletions lib/ble/transport.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<BluetoothDevice?> scan({
Duration timeout = const Duration(seconds: 12),
String? remoteId,
String? generation,
}) =>
withScanLock(() => _scanLocked(timeout));
withScanLock(() => _scanLocked(timeout, remoteId, generation));

Future<BluetoothDevice?> _scanLocked(Duration timeout) async {
Future<BluetoothDevice?> _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
Expand Down Expand Up @@ -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 ||
Expand Down
4 changes: 3 additions & 1 deletion lib/state/app_state.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4674,7 +4674,9 @@ class AppState extends ChangeNotifier {
}

// ── pairing (LOCAL only) ────────────────────────────────────────────────────
Future<BluetoothDevice?> scanForBand() => engine.scan();
/// [remoteId] / [generation]: only the band the user chose ([BleEngine.scan]).
Future<BluetoothDevice?> 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
Expand Down
11 changes: 9 additions & 2 deletions lib/ui2/onboarding/pairing.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High · Pairing ignores selected band

When a user opens pairing from a device-picker entry on a platform that does not use AccessorySetupKit, DevicePickerScreen._openEntry pushes PairingScreen without the selected entry or remote ID, so PairingScreen passes null filters to AppState.scanForBand and accepts any nearby WHOOP; pairWith can persist a different band than the user selected.

Pass the selected BandEntry and remote ID from DevicePickerScreen._openEntry into PairingScreen so the scan filters by the user's choice.

Also at lib/ui2/onboarding/pairing.dart:119, lib/ui2/onboarding/pairing.dart:142-143.

Prompt for AI agents
In `lib/ui2/onboarding/pairing.dart` at line 21:

**Pairing ignores selected band**

When a user opens pairing from a device-picker entry on a platform that does not use AccessorySetupKit, `DevicePickerScreen._openEntry` pushes `PairingScreen` without the selected entry or remote ID, so `PairingScreen` passes null filters to `AppState.scanForBand` and accepts any nearby WHOOP; `pairWith` can persist a different band than the user selected.

Pass the selected `BandEntry` and remote ID from `DevicePickerScreen._openEntry` into `PairingScreen` so the scan filters by the user's choice.

Also at `lib/ui2/onboarding/pairing.dart:119`, `lib/ui2/onboarding/pairing.dart:142-143`.

From AGENTS.md L210, AGENTS.md L217-218:

### 4.7 Capability wired into one call path but not all N

Expand Down Expand Up @@ -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<PairingScreen> createState() => _PairingScreenState();
Expand All @@ -132,7 +138,8 @@ class _PairingScreenState extends State<PairingScreen> {
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;
Expand Down
16 changes: 16 additions & 0 deletions test/pairing_choice_test.dart
Original file line number Diff line number Diff line change
@@ -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);
});
}