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));