-
-
Notifications
You must be signed in to change notification settings - Fork 78
feat(ble): close the gen5/MG pairing gap (no wire-format in edge) #255
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -84,6 +84,54 @@ typedef ArchiveSink = Future<void> 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<Guid> 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<String> 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,29 +1336,26 @@ 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(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
1343
to
1355
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win Handle the error from the unawaited
🛡️ Proposed fix found = r.device;
- FlutterBluePlus.stopScan();
+ unawaited(
+ FlutterBluePlus.stopScan().catchError(
+ (Object e) => _log('stopScan after match failed: $e'),
+ ),
+ );📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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'); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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('<string>FD4B</string>')); | ||
| // 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('<string>0000FD4B-0000-1000-8000-00805F9B34FB</string>')), | ||
| ); | ||
| }); | ||
|
|
||
| test('Info.plist declares the WHOOP name net for ASK', () { | ||
| expect(plist, contains('<key>NSAccessorySetupBluetoothNames</key>')); | ||
| expect(plist, contains('<string>WHOOP</string>')); | ||
| }); | ||
|
|
||
| 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"')); | ||
| }); | ||
|
Comment on lines
+140
to
+156
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win Add coverage for the Gen 4 picker retry. This group pins the descriptor list but not the new fallback behavior in The coding guidelines require regression tests for behavior changes, including lifecycle safety. "Behavior changes, especially regressions involving readiness, abstention, idempotence, synchronization, migrations, and lifecycle safety, must include regression tests." 🤖 Prompt for AI AgentsSources: Coding guidelines, Learnings |
||
|
|
||
| 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'); | ||
| }); | ||
|
Comment on lines
+158
to
+162
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value Remove the tautological path assertion. Line 161 compares a string literal with itself after ♻️ Proposed cleanup expect(engine, contains('whoopScanServiceUuids()'));
expect(engine, contains('advertisementLooksLikeWhoop('));
- expect(_posix('lib/ble/ble_engine.dart'), 'lib/ble/ble_engine.dart');
});Also remove the now-unused helper: -String _posix(String path) => path.replaceAll(Platform.pathSeparator, '/');
-🤖 Prompt for AI Agents |
||
| }); | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Make the Gen 4 retry safe across picker dismissal and cancellation.
When the first picker dismisses while
presentis retrying,.pickerDidDismisscan resolvepickerResultbefore the retry succeeds, causing a provisioned accessory to be reported to Dart as cancelled. Suppress dismissal resolution while the retry is in flight and complete the pending result when the retry finishes.Also determine user cancellation from the typed
ASError(error.code == .userCancelled) rather thanlocalizedDescription; otherwise a localized message that does not containcancelcan incorrectly start a second picker.📍 Affects 1 file
ios/Runner/AccessorySetup.swift#L197-L206(this comment)ios/Runner/AccessorySetup.swift#L185-L186🤖 Prompt for AI Agents