Skip to content

BLE: tell user to forget existing Omi when pairing is broken#7132

Open
mdmohsin7 wants to merge 6 commits intomainfrom
fix-pairing-lost-dialog
Open

BLE: tell user to forget existing Omi when pairing is broken#7132
mdmohsin7 wants to merge 6 commits intomainfrom
fix-pairing-lost-dialog

Conversation

@mdmohsin7
Copy link
Copy Markdown
Member

Problem

When iOS or Android still has a Bluetooth bond record for an Omi but the peripheral has wiped its bonding state (or vice versa), the BLE stack surfaces a specific error every time the app tries to connect:

  • iOS: CBError.peerRemovedPairingInformation (code 14) → [OmiBle] didFailToConnect: Omi, error=Peer removed pairing information
  • Android: GATT status 137 (GATT_AUTH_FAIL) or 15 (GATT_INSUF_ENCRYPTION)

Today the native layer just logs it, sends Apple's localized string up to Flutter, and immediately retries — which fails again with the same error in a tight loop. The Flutter side drops the error string in NativeBleTransport._handleConnectionState, the onboarding catch block silently removes the device from the scan list, and the user is left with no idea why their Omi keeps vanishing.

There's no recovery path because there's no way for the user to know that their phone needs to forget the device and re-pair from system Bluetooth settings.

Fix

Four-layer change keeping the platform-specific bits in the native side and the user-facing copy in shared Flutter code:

  1. iOS (app/ios/Runner/OmiBleManager.swift)

    • bleReasonString adds a case for .peerRemovedPairingInformation returning the stable token "pairing_lost".
    • Both didFailToConnect and didDisconnectPeripheral send "pairing_lost" to Flutter via onPeripheralDisconnected instead of Apple's localized description (so Flutter doesn't have to match English-only strings).
    • Auto-retry is short-circuited when the error is pairing-lost — the bond is broken and every retry will surface the same error until the user takes action.
  2. Android (app/android/app/src/main/kotlin/com/friend/ios/OmiBleForegroundService.kt)

    • handleDisconnection maps GATT status 137 and 15 to "pairing_lost" and forwards via the same onPeripheralDisconnected channel.
    • handleRetryLogic short-circuits on those statuses to stop the retry loop.
  3. Flutter routing (app/lib/services/bridges/ble_bridge.dart, app/lib/providers/device_provider.dart)

    • BleBridge gains a top-level pairingLostCallback, fired from onPeripheralDisconnected whenever the error string carries the pairing_lost token.
    • DeviceProvider registers the callback in its constructor and shows a non-dismissible ConfirmationDialog via the global navigator key. A guard flag prevents the dialog from stacking when multiple disconnect events fire during the failed-retry storm.
  4. l10n (app/lib/l10n/*)

    • Three new keys (pairingLostTitle, pairingLostBody, pairingLostButton) added to app_en.arb:
      • Title: Can't connect to your Omi
      • Body: Please open your phone's Bluetooth settings, remove the existing Omi from the list, then come back and try again.
      • Button: Open Bluetooth Settings
    • Machine-translated to all 48 other shipped locales via Google Translate (spot-checked es / fr / de / ja / ko / zh / ar / he — read naturally).
    • Generated AppLocalizations classes regenerated via flutter gen-l10n.
    • Plain-language, instruction-only copy: no jargon ("stale pairing info"), no platform-specific UI symbols (no iOS (i) chevron) — same dialog reads naturally on both iOS and Android.

The button is dismiss-only — the user manually navigates to system Bluetooth settings. No flaky deep-link via App-Prefs:Bluetooth / Android Settings.ACTION_BLUETOOTH_SETTINGS (those are inconsistent across OS versions and would add scope without solving the core issue).

Test plan

  • On an iOS device that has Omi paired, manually go to system Settings → Bluetooth → tap (i) → Forget This Device → reopen the app → confirm the dialog appears with the new copy and that there are no retry-storm log entries afterward.
  • On an Android device, equivalent: forget the device from system Bluetooth settings → reopen app → confirm dialog + no retry storm.
  • Tap "Open Bluetooth Settings" → confirm dialog dismisses cleanly and _pairingLostDialogShowing resets (re-trigger to verify).
  • Confirm the dialog does NOT stack when multiple onPeripheralDisconnected events fire in quick succession.
  • Verify the connect flow on a healthy bond still works (no regression in normal pairing).
  • Spot-check dialog rendering in 2-3 non-Latin locales (e.g. ko, ar, ja) for layout / RTL.

Note on diff size

The l10n commit shows a large insert/delete count because the pre-commit hook normalized all 49 touched .arb files from 2-space to 4-space JSON indent. The actual semantic delta is the 3 new keys per locale plus the 50 regenerated AppLocalizations getters; the rest is whitespace.

mdmohsin7 added 3 commits May 2, 2026 18:29
…ost, skip retry

When iOS surfaces CBError.peerRemovedPairingInformation (code 14), the OS-level
bond between phone and peripheral is broken. Auto-retrying the connect loops
forever with the same error until the user forgets the device in iOS Settings,
flooding logs and preventing the app from telling the user what to do.

Map this case to a stable 'pairing_lost' token in bleReasonString and pass
that token (instead of Apple's localized description) up to Flutter via
onPeripheralDisconnected, so the Dart layer can detect the condition without
matching English-only strings. Skip the auto-reconnect attempt for both
didFailToConnect and didDisconnectPeripheral when the error is pairing_lost.
…skip retry

Mirrors the iOS handling: GATT statuses 137 (GATT_AUTH_FAIL) and 15
(GATT_INSUF_ENCRYPTION) both indicate the OS-level bond is out of sync with
the peripheral's pairing record. Auto-retry loops with the same status until
the user forgets the device in system Bluetooth settings.

Map these statuses to a 'pairing_lost' token in handleDisconnection, surface
that token through onPeripheralDisconnected so the Dart layer can detect it
without parsing platform-specific strings, and short-circuit handleRetryLogic
to stop the retry loop until the user manually forgets the device.
BleBridge gains a top-level pairingLostCallback fired from onPeripheralDisconnected
whenever the error string carries the 'pairing_lost' token from native (iOS
peerRemovedPairingInformation / Android GATT_AUTH_FAIL / GATT_INSUF_ENCRYPTION).

DeviceProvider registers the callback in its constructor and shows a
non-dismissible ConfirmationDialog via the global navigator key, with text
explaining how to recover. A guard flag keeps the dialog from stacking when
multiple disconnect events fire during the failed-retry storm.

Adds the new l10n keys (pairingLostTitle / pairingLostBody / pairingLostButton)
to the call site; the .arb additions land in a follow-up commit.
@mdmohsin7
Copy link
Copy Markdown
Member Author

@morpheus review — Approved

Well-structured four-layer fix. Reviewed all semantic diffs (native iOS/Android, Flutter bridge/provider, l10n keys + translations). Spot-checked ja/ar/ko translations — clean.

What I checked:

  • iOSbleReasonString maps .peerRemovedPairingInformation"pairing_lost". Both didFailToConnect and didDisconnectPeripheral normalize the error string and skip retry identically (adjacent pattern parity ✅).
  • Android — GATT 137/15 mapped to "pairing_lost" in handleDisconnection, early return in handleRetryLogic stops retry storm.
  • Flutter bridgeisPairingLostError fires pairingLostCallback after the existing disconnect callback, so normal disconnect handling continues and the dialog layers on top. No interference with existing flow.
  • Flutter provider_pairingLostDialogShowing guard prevents dialog stacking. whenComplete resets the flag. ConfirmationDialog hides cancel button when cancelText is null (confirmed from widget source) — only the confirm button renders.

One non-blocking note:

The confirm button reads "Open Bluetooth Settings" (pairingLostButton) but onConfirm just pops the dialog — it doesn't actually open system settings. The user taps a button labeled "Open Bluetooth Settings" and lands back in the app. Consider relabeling to "OK" or "Got it" to match the actual behavior, or actually launching system settings via a platform channel. Not blocking — the core fix (stop retry storm, surface recovery instructions) is correct and complete.

Adds three keys (pairingLostTitle / pairingLostBody / pairingLostButton)
to app_en.arb and machine-translates them into all 48 other shipped locales
via Google Translate. Regenerates the AppLocalizations classes via
flutter gen-l10n.

Copy is plain-language and instruction-only — no jargon, no platform-specific
UI symbols (no iOS '(i)' chevron) — so the same dialog reads naturally on
both iOS and Android.
@mdmohsin7 mdmohsin7 force-pushed the fix-pairing-lost-dialog branch from 036bb5a to 2f15a91 Compare May 2, 2026 18:54
@mdmohsin7
Copy link
Copy Markdown
Member Author

@morpheus re-review after force-push — Approval stands

Verified the updated diff (783 insertions / 55 deletions, down from 261k/260k):

  • Commits 1–3 unchanged (same OIDs: 2578e10, 9a66cce, 173367c). All iOS/Android/Flutter semantic code identical to first review.
  • Commit 4 rewritten (036bb5a2f15a91): ARB files now show clean +3 appends per locale — no 2→4 space whitespace churn. Spot-checked ar, be, en — all correct.
  • --no-verify on the l10n commit noted (per ops approval).

Previous review findings all still apply. Non-blocking note about the "Open Bluetooth Settings" button text still stands.

Process note: Force-push rewrites branch history. Team non-negotiable is "fix forward, never rewrite." Noting for the record — ops approved, and the branch hadn't been merged, so no downstream impact.

The pairing-lost dialog's confirm button reads 'Open Bluetooth Settings' but
the original implementation just popped the dialog — leaving the user to
hunt for the right settings page on their own. This wires the button to
actually open the system Bluetooth settings page.

Adds a new openBluetoothSettings() method to the Pigeon BleHostApi:

- iOS: tries the App-Prefs:root=Bluetooth deep-link first, then falls back
  to UIApplication.openSettingsURLString. Apple has restricted the
  Bluetooth-specific URL on newer iOS versions; the fallback at least gets
  the user into Settings rather than nowhere.
- Android: fires Settings.ACTION_BLUETOOTH_SETTINGS, with a fallback to
  Settings.ACTION_SETTINGS if the Bluetooth-specific intent isn't
  resolvable on the target device.

The dialog's onConfirm now invokes BleHostApi().openBluetoothSettings()
fire-and-forget after popping the dialog, with a try/catch that logs but
does not surface failures (the user is already on their way to Settings;
better to let them recover than to flash an error).

Also re-formats the pre-existing battery-throttle ternary in device_provider
to the project's --line-length 120 style; the line was below the threshold
in main but now triggers the CI lint check because this PR modifies the
file.
@mdmohsin7
Copy link
Copy Markdown
Member Author

@greptile-apps review

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented May 2, 2026

Greptile Summary

This PR adds a recovery path for broken BLE bond state: native layers (iOS CBError.peerRemovedPairingInformation, Android GATT 137/15) now emit a stable "pairing_lost" token instead of raw localized strings, Flutter shows a non-dismissible dialog instructing the user to forget the device in system Bluetooth settings, and auto-retry is short-circuited so the failed-retry storm stops.

  • P1 — Android GATT 15 false-positive on initial pairing (OmiBleForegroundService.kt): GATT_INSUF_ENCRYPTION (15) can also be returned during normal first-time bond negotiation before the system pairing dialog appears. Treating it as pairing_lost will display the "forget the device" dialog to brand-new users and block the retry that would complete bonding. GATT 137 alone is the reliable broken-bond signal.
  • The dispose() callback clear and the await-with-try/catch on openBluetoothSettings() from the previous review round are both correctly addressed in this revision.

Confidence Score: 4/5

Safe to merge on iOS; the Android path carries a P1 risk of showing a misleading recovery dialog to first-time users if GATT status 15 fires during initial bond negotiation.

One P1 finding (GATT 15 false-positive on Android initial pairing) sets the ceiling at 4. iOS changes and the Flutter/Dart layer look correct. The P2 suggestion on contains() vs == does not affect the score.

app/android/app/src/main/kotlin/com/friend/ios/OmiBleForegroundService.kt — GATT status 15 handling needs re-evaluation.

Important Files Changed

Filename Overview
app/android/app/src/main/kotlin/com/friend/ios/OmiBleForegroundService.kt Maps GATT 137 and 15 to pairing_lost and skips retry; GATT 15 may also fire during normal initial bond negotiation, creating a false-positive for new users.
app/ios/Runner/OmiBleManager.swift Detects CBError.peerRemovedPairingInformation, sends pairing_lost token, and skips auto-retry correctly on both didFailToConnect and didDisconnectPeripheral.
app/ios/Runner/BleHostApiImpl.swift Adds openBluetoothSettings() using open() with completion-handler success flag (correctly avoids the canOpenURL limitation noted in the prior review).
app/lib/providers/device_provider.dart Registers pairingLostCallback in constructor and nulls it in dispose(); shows non-dismissible dialog with guard flag to prevent stacking; awaits openBluetoothSettings() inside try/catch.
app/lib/services/bridges/ble_bridge.dart Adds pairingLostCallback and fires it from onPeripheralDisconnected; uses .contains() for token matching where exact equality would be safer.
app/android/app/src/main/kotlin/com/friend/ios/BleHostApiImpl.kt Adds openBluetoothSettings() using Settings.ACTION_BLUETOOTH_SETTINGS with a fallback to ACTION_SETTINGS; clean implementation.

Sequence Diagram

sequenceDiagram
    participant Peripheral as Omi Device
    participant Native as Native BLE Layer(iOS / Android)
    participant Bridge as BleBridge (Flutter)
    participant Provider as DeviceProvider
    participant Dialog as ConfirmationDialog
    participant Settings as System BT Settings

    Peripheral->>Native: Disconnect (broken bond)
    Note over Native: iOS: CBError.peerRemovedPairingInformation<br/>Android: GATT 137 (or 15)
    Native->>Native: Map to pairing_lost token
    Native->>Native: Skip auto-retry
    Native->>Bridge: onPeripheralDisconnected(uuid, pairing_lost)
    Bridge->>Bridge: isPairingLostError() true
    Bridge->>Provider: pairingLostCallback(uuid)
    Provider->>Provider: _pairingLostDialogShowing = true
    Provider->>Dialog: showDialog (barrierDismissible: false)
    Dialog-->>Provider: User taps Open Bluetooth Settings
    Provider->>Provider: _pairingLostDialogShowing = false
    Provider->>Settings: BleHostApi.openBluetoothSettings()
    Note over Settings: User forgets Omi, re-pairs from scratch
Loading

Reviews (2): Last reviewed commit: "Address greptile P1+P2: await openBlueto..." | Re-trigger Greptile

Comment on lines +99 to +102
try {
BleHostApi().openBluetoothSettings();
} catch (e) {
Logger.debug('openBluetoothSettings failed: $e');
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Missing await on async openBluetoothSettings()

BleHostApi().openBluetoothSettings() returns Future<void>, but it is called without await. The surrounding try/catch only intercepts synchronous exceptions — any PlatformException thrown by the platform channel will escape as an unhandled future error, silently failing without the intended Logger.debug fallback.

Comment on lines +113 to +119
DispatchQueue.main.async {
if let url = bluetoothUrl, UIApplication.shared.canOpenURL(url) {
UIApplication.shared.open(url, options: [:], completionHandler: nil)
} else if let url = appSettingsUrl {
UIApplication.shared.open(url, options: [:], completionHandler: nil)
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 canOpenURL for App-Prefs:root=Bluetooth always returns false without LSApplicationQueriesSchemes entry

On iOS 9+, canOpenURL requires the queried URL scheme to be listed in LSApplicationQueriesSchemes inside Info.plist. App-Prefs: is not a registered first-party scheme, so canOpenURL always returns false, the branch is unreachable, and the fallback to UIApplication.openSettingsURLString (the app's own settings page, not Bluetooth settings) always fires. Use openURL directly and check success in the completion handler instead.

Suggested change
DispatchQueue.main.async {
if let url = bluetoothUrl, UIApplication.shared.canOpenURL(url) {
UIApplication.shared.open(url, options: [:], completionHandler: nil)
} else if let url = appSettingsUrl {
UIApplication.shared.open(url, options: [:], completionHandler: nil)
}
}
DispatchQueue.main.async {
if let url = bluetoothUrl {
UIApplication.shared.open(url, options: [:]) { success in
if !success, let settingsUrl = appSettingsUrl {
UIApplication.shared.open(settingsUrl, options: [:], completionHandler: nil)
}
}
} else if let url = appSettingsUrl {
UIApplication.shared.open(url, options: [:], completionHandler: nil)
}
}

…drop canOpenURL

Three issues from greptile review on PR #7132:

- (P1) device_provider: BleHostApi().openBluetoothSettings() returns
  Future<void>; calling it without await left PlatformExceptions from the
  Pigeon channel as unhandled future errors. Make onConfirm async and await
  inside the existing try/catch so the failure is caught and logged.

- (P1) device_provider: pairingLostCallback was set on the BleBridge
  singleton in the constructor but never cleared in dispose(), so a torn-down
  provider would keep receiving pairing_lost events on a dead instance.
  Null the callback in dispose().

- (P2) iOS BleHostApiImpl: canOpenURL on App-Prefs:root=Bluetooth always
  returns false on iOS 9+ without an LSApplicationQueriesSchemes entry,
  making the Bluetooth-specific branch dead code. Replace canOpenURL with
  the open() completion handler's success flag, falling back to the app
  settings URL only when the Bluetooth deep-link actually fails to launch.
@mdmohsin7
Copy link
Copy Markdown
Member Author

@greptile-apps re-review

Comment on lines +378 to 385
// Android GATT statuses that indicate the OS-level bond is broken / out of sync
// with the peripheral's pairing record. Auto-retry will loop forever until the
// user forgets the device in system Bluetooth settings.
// 137 = GATT_AUTH_FAIL (most common when the peer wiped its bonding info)
// 15 = GATT_INSUF_ENCRYPTION
val pairingLost = status == 137 || status == 15

val error = when {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 GATT status 15 can fire during initial bond negotiation, not only on broken bonds

GATT_INSUF_ENCRYPTION (15) is returned by Android when a peripheral requires an encrypted link but bonding hasn't been established yet — which is exactly what happens the first time a new user tries to connect to an Omi that mandates bonding. In that flow Android typically disconnects with status 15 before presenting the system pairing dialog; the re-bond occurs on the next automatic reconnect attempt. Mapping status 15 to pairing_lost here will: (1) display "Can't connect to your Omi — forget the device" to a user who has never paired before, and (2) skip the retry that would let Android complete the bond negotiation. GATT_AUTH_FAIL (137) is the reliable broken-bond signal; the status 15 guard should either be removed or restricted to cases where a prior bond record is already known to exist.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant