From 49896858674936f7e314b868f6f94c5e2c0c7e6e Mon Sep 17 00:00:00 2001 From: Apoorv Darshan Date: Thu, 15 Oct 2026 00:34:42 +0530 Subject: [PATCH 1/2] fix(onboarding): arm pairing help when bluetooth blocks discovery scanDevices returned early when Bluetooth permission, location (Android 11 and older) or adapter readiness blocked discovery, before the ten-second "No Omi Found" timer was armed. Once the blocked card was dismissed (or when no card applied), the page sat on "Searching for devices" with no scan running and no Scan Again, How to Pair or Contact Support. Arm the same delayed help on every blocked path; only a scan that actually started still reports Device Scan Empty. Co-Authored-By: Claude Opus 5.5 --- .../20261015-pairing-blocked-help.json | 3 ++ app/lib/providers/onboarding_provider.dart | 17 ++++++-- ...ng_provider_pairing_presentation_test.dart | 41 +++++++++++++++++++ 3 files changed, 58 insertions(+), 3 deletions(-) create mode 100644 app/changelog/unreleased/20261015-pairing-blocked-help.json diff --git a/app/changelog/unreleased/20261015-pairing-blocked-help.json b/app/changelog/unreleased/20261015-pairing-blocked-help.json new file mode 100644 index 00000000000..ce6ab42e565 --- /dev/null +++ b/app/changelog/unreleased/20261015-pairing-blocked-help.json @@ -0,0 +1,3 @@ +{ + "change": "If Bluetooth is off or not allowed while connecting your Omi, Scan Again, How to Pair and Contact Support now still appear after a few seconds." +} diff --git a/app/lib/providers/onboarding_provider.dart b/app/lib/providers/onboarding_provider.dart index eaa3bbf6887..2d255cf6b61 100644 --- a/app/lib/providers/onboarding_provider.dart +++ b/app/lib/providers/onboarding_provider.dart @@ -436,6 +436,7 @@ class OnboardingProvider extends BaseProvider with MessageNotifierMixin implemen adapterOff: false, ), ); + _armInstructionsTimer(epoch, scanStarted: false); onShowDialog(); return; } @@ -452,6 +453,7 @@ class OnboardingProvider extends BaseProvider with MessageNotifierMixin implemen updateLocationPermission(locationGranted); if (!locationGranted) { _emitPairingBlocked(DevicePairingBlockedReason.locationPermissionMissing); + _armInstructionsTimer(epoch, scanStarted: false); onShowLocationDialog?.call(); return; } @@ -462,20 +464,29 @@ class OnboardingProvider extends BaseProvider with MessageNotifierMixin implemen if (_isDisposed || epoch != _scanEpoch) return; _emitPairingBlocked(_pairingBlockForAdapter(BluetoothReadiness.instance.state), adapterState: BluetoothReadiness.instance.state); + _armInstructionsTimer(epoch, scanStarted: false); return; } if (_isDisposed || epoch != _scanEpoch) return; + _armInstructionsTimer(epoch, scanStarted: true); + + await deviceProvider?.initiateConnection("Onboarding"); + } + + /// Arms the delayed "No Omi Found" help (Scan Again, pairing guide, Contact Support). A blocked + /// search arms it too, so the help never depends on discovery having started; only a scan that + /// actually ran reports Device Scan Empty (a blocked one already reported Device Pairing Blocked). + void _armInstructionsTimer(int epoch, {required bool scanStarted}) { + _didNotMakeItTimer?.cancel(); _didNotMakeItTimer = Timer(const Duration(seconds: deviceScanEmptyAfterSeconds), () { if (_isDisposed || epoch != _scanEpoch) return; - if (shouldReportEmptyDeviceScan(deviceCount: deviceList.length)) { + if (scanStarted && shouldReportEmptyDeviceScan(deviceCount: deviceList.length)) { PlatformManager.instance.analytics.deviceScanEmpty(afterSeconds: deviceScanEmptyAfterSeconds); } enableInstructions = true; notifyListeners(); }); - - await deviceProvider?.initiateConnection("Onboarding"); } void _recordOnboardingPermission({ diff --git a/app/test/providers/onboarding_provider_pairing_presentation_test.dart b/app/test/providers/onboarding_provider_pairing_presentation_test.dart index 7bb503bef81..ec5e93b5a9a 100644 --- a/app/test/providers/onboarding_provider_pairing_presentation_test.dart +++ b/app/test/providers/onboarding_provider_pairing_presentation_test.dart @@ -139,6 +139,47 @@ void main() { await tester.pumpWidget(const SizedBox()); }); + // #17851: a blocked search returned before the delayed help was armed, so once the blocked card was + // dismissed the page sat on "Searching for devices" with no scan running and no route to support. + Future expectDelayedHelpAfterBlockedScan(WidgetTester tester, Reason reason) async { + await pumpConnect(tester); + expect(provider.pairingFailureReason, reason); + expect(find.byType(PairingFailureCard), findsOneWidget); + await tester.pump(const Duration(seconds: deviceScanEmptyAfterSeconds)); + await tester.pump(); + expect(provider.enableInstructions, isTrue); + await tester.ensureVisible(find.byKey(const Key('pairing_failure_dismiss'))); + await tester.tap(find.byKey(const Key('pairing_failure_dismiss'))); + await tester.pump(); + final l10n = AppLocalizationsEn(); + expect(find.text(l10n.searchingForDevices), findsNothing); + expect(find.text(l10n.findDeviceNoneTitle), findsOneWidget); + expect(find.byKey(const Key('find_devices_scan_again')), findsOneWidget); + expect(find.byKey(const Key('find_devices_contact_support')), findsOneWidget); + await AnalyticsManager.flushPending(force: true); + expect(adapter.names, contains('Device Pairing Blocked')); + // Discovery never ran, so the funnel must not also count it as an empty scan. + expect(adapter.names, isNot(contains('Device Scan Empty'))); + await tester.pumpWidget(const SizedBox()); + } + + testWidgets('Bluetooth off still arms the delayed help', (tester) async { + binding.defaultBinaryMessenger.setMockDecodedMessageHandler(bluetoothChannel, (_) async => ['off']); + await expectDelayedHelpAfterBlockedScan(tester, Reason.bluetoothOff); + }); + + testWidgets('denied Bluetooth permission still arms the delayed help', (tester) async { + const permissions = MethodChannel('flutter.baseflow.com/permissions/methods'); + binding.defaultBinaryMessenger.setMockMethodCallHandler(permissions, (call) async { + // 0 is PermissionStatus.denied. + if (call.method == 'requestPermissions') return {for (final p in call.arguments as List) p as int: 0}; + return 0; + }); + addTearDown(() => binding.defaultBinaryMessenger.setMockMethodCallHandler(permissions, null)); + provider.hasBluetoothPermission = false; + await expectDelayedHelpAfterBlockedScan(tester, Reason.bluetoothPermissionDenied); + }); + test('saved visible devices clear reachability, but stale-bond recovery is retained', () async { await SharedPreferencesUtil() .btDeviceSet(BtDevice(id: 'saved-omi', name: 'Saved Omi', type: DeviceType.omi, rssi: -50)); From d9231bd2c5ce86b64ca1cee5bf792192ecc0a4ba Mon Sep 17 00:00:00 2001 From: Apoorv Darshan Date: Thu, 15 Oct 2026 00:52:05 +0530 Subject: [PATCH 2/2] ci: re-run with ci:full Co-Authored-By: Claude Opus 5.5