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
3 changes: 3 additions & 0 deletions app/changelog/unreleased/20261015-pairing-blocked-help.json
Original file line number Diff line number Diff line change
@@ -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."
}
17 changes: 14 additions & 3 deletions app/lib/providers/onboarding_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -436,6 +436,7 @@ class OnboardingProvider extends BaseProvider with MessageNotifierMixin implemen
adapterOff: false,
),
);
_armInstructionsTimer(epoch, scanStarted: false);
onShowDialog();
return;
}
Expand All @@ -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;
}
Expand All @@ -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({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@
dynamic noSuchMethod(Invocation invocation) => null;
}

void main() {

Check warning on line 45 in app/test/providers/onboarding_provider_pairing_presentation_test.dart

View workflow job for this annotation

GitHub Actions / Hygiene

Long function

void main() is 180 lines; consider extracting focused helpers over 150 lines.
final binding = TestWidgetsFlutterBinding.ensureInitialized();
const bluetoothChannel = BasicMessageChannel<Object?>(
'dev.flutter.pigeon.omi_pigeon.BleHostApi.getBluetoothState',
Expand Down Expand Up @@ -139,6 +139,47 @@
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<void> 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<Object?>(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));
Expand Down
Loading