Skip to content
Open
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
26 changes: 26 additions & 0 deletions app/android/app/src/main/kotlin/com/friend/ios/BleHostApiImpl.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package com.friend.ios

import android.app.Activity
import android.content.Intent
import android.provider.Settings
import android.util.Log
import androidx.core.content.ContextCompat

Expand Down Expand Up @@ -147,6 +148,31 @@ class BleHostApiImpl(private val getActivity: () -> Activity?) : BleHostApi {
cm.associate(deviceAddress = deviceAddress)
}

// ── System settings deep-link ──

override fun openBluetoothSettings() {
val activity = getActivity() ?: run {
Log.w(TAG, "openBluetoothSettings: no activity available")
return
}
val intent = Intent(Settings.ACTION_BLUETOOTH_SETTINGS).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
try {
activity.startActivity(intent)
} catch (e: Exception) {
Log.e(TAG, "openBluetoothSettings failed: ${e.message}")
// Fallback to general settings if the Bluetooth-specific intent isn't resolvable.
try {
activity.startActivity(Intent(Settings.ACTION_SETTINGS).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
})
} catch (e2: Exception) {
Log.e(TAG, "openBluetoothSettings fallback also failed: ${e2.message}")
}
}
}

/**
* Called from MainActivity.onActivityResult to handle companion chooser result.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -375,7 +375,15 @@ class OmiBleForegroundService : Service() {

val addr = address.uppercase()

// 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 {
Comment on lines +378 to 385
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.

pairingLost -> "pairing_lost"
status == 22 -> "paired_to_another_phone"
status != 0 -> "gatt_status_$status"
else -> null
Expand Down Expand Up @@ -411,6 +419,13 @@ class OmiBleForegroundService : Service() {

if (isDestroying || status == -1 || !isBluetoothEnabled) return

// Skip retry when the bond is broken — every retry will fail with the same
// status until the user forgets the device in system Bluetooth settings.
if (status == 137 || status == 15) {
Log.i(TAG, "Skip retry for $addr: pairing lost (status=$status). Waiting for user to forget device in system settings.")
return
}

managed.retryCount++
Log.i(TAG, "Retry #${managed.retryCount} for $addr in ${RECONNECT_DELAY_MS}ms (status=$status)")

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -818,6 +818,13 @@ interface BleHostApi {
fun hasCompanionDeviceAssociation(): Boolean
/** (Android only) Initiate CompanionDeviceManager association for a device. */
fun requestCompanionDeviceAssociation(deviceAddress: String, callback: (Result<String>) -> Unit)
/**
* Open the system Bluetooth settings page so the user can forget a stale
* bond. iOS uses the App-Prefs:root=Bluetooth deep-link (falls back to the
* general Settings app on iOS versions where Apple has restricted the URL);
* Android fires the Settings.ACTION_BLUETOOTH_SETTINGS intent.
*/
fun openBluetoothSettings()

companion object {
/** The codec used by BleHostApi. */
Expand Down Expand Up @@ -1147,6 +1154,22 @@ interface BleHostApi {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.omi_pigeon.BleHostApi.openBluetoothSettings$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
val wrapped: List<Any?> = try {
api.openBluetoothSettings()
listOf(null)
} catch (exception: Throwable) {
PigeonCommunicatorPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
}
}
}
Expand Down
24 changes: 24 additions & 0 deletions app/ios/Runner/BleHostApiImpl.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Flutter
import UIKit

/// Bridges Pigeon BleHostApi calls to OmiBleManager.
final class BleHostApiImpl: BleHostApi {
Expand Down Expand Up @@ -101,4 +102,27 @@ final class BleHostApiImpl: BleHostApi {
// No-op on iOS — state restoration handles background reconnection
completion(.success(""))
}

func openBluetoothSettings() throws {
// Try the Bluetooth-specific deep-link first; Apple has restricted this URL
// on newer iOS versions. canOpenURL would always return false here without
// an LSApplicationQueriesSchemes entry for App-Prefs (a non-public scheme),
// so check the open() completion handler's success flag instead — that
// reflects what actually happened. Fall back to the app's settings page
// (UIApplication.openSettingsURLString) which is guaranteed to open and at
// least gets the user into Settings.
let bluetoothUrl = URL(string: "App-Prefs:root=Bluetooth")
let appSettingsUrl = URL(string: UIApplication.openSettingsURLString)
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)
}
}
Comment on lines +116 to +126
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)
}
}

}
}
23 changes: 18 additions & 5 deletions app/ios/Runner/OmiBleManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -364,10 +364,16 @@ final class OmiBleManager: NSObject {
case .connectionTimeout: return "connection_timeout"
case .peripheralDisconnected: return "remote_device_terminated"
case .connectionFailed: return "connection_failed_instant_passed"
case .peerRemovedPairingInformation: return "pairing_lost"
default: return "gatt_error_\(cbError.code.rawValue)"
}
}

private static func isPairingLostError(_ error: Error?) -> Bool {
guard let cbError = error as? CBError else { return false }
return cbError.code == .peerRemovedPairingInformation
}

/// Append a disconnect/fail event to the per-device history ring buffer.
/// `eventType` is "disconnect" for an established link lost, or "fail_to_connect"
/// for a connect attempt that never reached didConnect.
Expand Down Expand Up @@ -608,6 +614,7 @@ extension OmiBleManager: CBCentralManagerDelegate {
func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) {
let uuid = peripheralUuidString(peripheral)
let isManual = manuallyDisconnected.contains(uuid)
let pairingLost = Self.isPairingLostError(error)
NSLog("[OmiBle] didFailToConnect: \(peripheral.name ?? "<nil>"), uuid=\(uuid), error=\(error?.localizedDescription ?? "nil")")
cleanupPeripheral(uuid)

Expand All @@ -624,11 +631,14 @@ extension OmiBleManager: CBCentralManagerDelegate {
incrementFailToConnectCount(uuid: uuid)
}

flutterApi?.onPeripheralDisconnected(peripheralUuid: uuid, error: error?.localizedDescription) { _ in }
let flutterError = pairingLost ? "pairing_lost" : error?.localizedDescription
flutterApi?.onPeripheralDisconnected(peripheralUuid: uuid, error: flutterError) { _ in }

// Retry previously-connected peripherals — otherwise a failed connect silently
// drops the user. iOS queues this at the chipset level; it's free while waiting.
if !isManual, everConnected.contains(uuid) {
// Skip retry on pairing_lost: the OS-level bond is broken and every retry will
// fail with the same error until the user forgets the device in iOS Settings.
if !isManual, !pairingLost, everConnected.contains(uuid) {
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(200)) { [weak self] in
guard let self = self else { return }
self.centralManager.connect(peripheral, options: nil)
Expand All @@ -639,6 +649,7 @@ extension OmiBleManager: CBCentralManagerDelegate {
func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) {
let uuid = peripheralUuidString(peripheral)
let isManual = manuallyDisconnected.contains(uuid)
let pairingLost = Self.isPairingLostError(error)
NSLog("[OmiBle] didDisconnect: \(peripheral.name ?? "<nil>"), uuid=\(uuid), error=\(error?.localizedDescription ?? "nil")")
cleanupPeripheral(uuid)

Expand All @@ -657,10 +668,12 @@ extension OmiBleManager: CBCentralManagerDelegate {
}
connectionStartTimes.removeValue(forKey: uuid)

flutterApi?.onPeripheralDisconnected(peripheralUuid: uuid, error: error?.localizedDescription) { _ in }
let flutterError = pairingLost ? "pairing_lost" : error?.localizedDescription
flutterApi?.onPeripheralDisconnected(peripheralUuid: uuid, error: flutterError) { _ in }

// Auto-reconnect unless manually disconnected
if !isManual {
// Auto-reconnect unless manually disconnected. Skip on pairing_lost — the bond
// is broken and retries will loop until the user forgets the device.
if !isManual, !pairingLost {
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(200)) { [weak self] in
guard let self = self else { return }
// iOS handles this at the BLE chipset level — zero CPU/radio cost while waiting
Expand Down
22 changes: 22 additions & 0 deletions app/ios/Runner/PigeonCommunicator.g.swift
Original file line number Diff line number Diff line change
Expand Up @@ -827,6 +827,11 @@ protocol BleHostApi {
func hasCompanionDeviceAssociation() throws -> Bool
/// (Android only) Initiate CompanionDeviceManager association for a device.
func requestCompanionDeviceAssociation(deviceAddress: String, completion: @escaping (Result<String, Error>) -> Void)
/// Open the system Bluetooth settings page so the user can forget a stale
/// bond. iOS uses the App-Prefs:root=Bluetooth deep-link (falls back to the
/// general Settings app on iOS versions where Apple has restricted the URL);
/// Android fires the Settings.ACTION_BLUETOOTH_SETTINGS intent.
func openBluetoothSettings() throws
}

/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
Expand Down Expand Up @@ -1109,6 +1114,23 @@ class BleHostApiSetup {
} else {
requestCompanionDeviceAssociationChannel.setMessageHandler(nil)
}
/// Open the system Bluetooth settings page so the user can forget a stale
/// bond. iOS uses the App-Prefs:root=Bluetooth deep-link (falls back to the
/// general Settings app on iOS versions where Apple has restricted the URL);
/// Android fires the Settings.ACTION_BLUETOOTH_SETTINGS intent.
let openBluetoothSettingsChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.omi_pigeon.BleHostApi.openBluetoothSettings\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
openBluetoothSettingsChannel.setMessageHandler { _, reply in
do {
try api.openBluetoothSettings()
reply(wrapResult(nil))
} catch {
reply(wrapError(error))
}
}
} else {
openBluetoothSettingsChannel.setMessageHandler(nil)
}
}
}
/// Generated protocol from Pigeon that represents Flutter messages that can be called from Swift.
Expand Down
27 changes: 27 additions & 0 deletions app/lib/gen/pigeon_communicator.g.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1467,6 +1467,33 @@ class BleHostApi {
return (pigeonVar_replyList[0] as String?)!;
}
}

/// Open the system Bluetooth settings page so the user can forget a stale
/// bond. iOS uses the App-Prefs:root=Bluetooth deep-link (falls back to the
/// general Settings app on iOS versions where Apple has restricted the URL);
/// Android fires the Settings.ACTION_BLUETOOTH_SETTINGS intent.
Future<void> openBluetoothSettings() async {
final String pigeonVar_channelName = 'dev.flutter.pigeon.omi_pigeon.BleHostApi.openBluetoothSettings$pigeonVar_messageChannelSuffix';
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
final List<Object?>? pigeonVar_replyList =
await pigeonVar_sendFuture as List<Object?>?;
if (pigeonVar_replyList == null) {
throw _createConnectionError(pigeonVar_channelName);
} else if (pigeonVar_replyList.length > 1) {
throw PlatformException(
code: pigeonVar_replyList[0]! as String,
message: pigeonVar_replyList[1] as String?,
details: pigeonVar_replyList[2],
);
} else {
return;
}
}
}

abstract class BleFlutterApi {
Expand Down
5 changes: 4 additions & 1 deletion app/lib/l10n/app_ar.arb
Original file line number Diff line number Diff line change
Expand Up @@ -2980,5 +2980,8 @@
"selectAllTasksMenu": "تحديد الكل",
"showCompletedTasks": "إظهار المكتملة",
"startCallRecording": "بدء تسجيل المكالمة",
"startVoiceRecording": "بدء التسجيل الصوتي"
"startVoiceRecording": "بدء التسجيل الصوتي",
"pairingLostTitle": "لا يمكن الاتصال بجهاز أومي الخاص بك",
"pairingLostBody": "يرجى فتح إعدادات البلوتوث في هاتفك، وإزالة جهاز Omi الموجود من القائمة، ثم العودة والمحاولة مرة أخرى.",
"pairingLostButton": "افتح إعدادات البلوتوث"
}
5 changes: 4 additions & 1 deletion app/lib/l10n/app_be.arb
Original file line number Diff line number Diff line change
Expand Up @@ -10721,5 +10721,8 @@
"selectAllTasksMenu": "Выбраць усе",
"showCompletedTasks": "Паказаць завершаныя",
"startCallRecording": "Пачаць запіс званка",
"startVoiceRecording": "Пачаць галасавы запіс"
"startVoiceRecording": "Пачаць галасавы запіс",
"pairingLostTitle": "Не ўдаецца падключыцца да вашага Omi",
"pairingLostBody": "Калі ласка, адкрыйце налады Bluetooth вашага тэлефона, выдаліце існуючы Omi са спісу, а затым вярніцеся і паспрабуйце яшчэ раз.",
"pairingLostButton": "Адкрыйце налады Bluetooth"
}
5 changes: 4 additions & 1 deletion app/lib/l10n/app_bg.arb
Original file line number Diff line number Diff line change
Expand Up @@ -2982,5 +2982,8 @@
"selectAllTasksMenu": "Избиране на всички",
"showCompletedTasks": "Показване на завършените",
"startCallRecording": "Стартиране на запис на разговор",
"startVoiceRecording": "Стартиране на гласов запис"
"startVoiceRecording": "Стартиране на гласов запис",
"pairingLostTitle": "Не мога да се свържа с вашия Omi",
"pairingLostBody": "Моля, отворете настройките за Bluetooth на телефона си, премахнете съществуващия Omi от списъка, след което се върнете и опитайте отново.",
"pairingLostButton": "Отворете настройките на Bluetooth"
}
5 changes: 4 additions & 1 deletion app/lib/l10n/app_bn.arb
Original file line number Diff line number Diff line change
Expand Up @@ -10721,5 +10721,8 @@
"selectAllTasksMenu": "সমস্ত নির্বাচন",
"showCompletedTasks": "সম্পন্ন দেখান",
"startCallRecording": "কল রেকর্ডিং শুরু করুন",
"startVoiceRecording": "ভয়েস রেকর্ডিং শুরু করুন"
"startVoiceRecording": "ভয়েস রেকর্ডিং শুরু করুন",
"pairingLostTitle": "আপনার ওমির সাথে সংযোগ করা যাচ্ছে না।",
"pairingLostBody": "অনুগ্রহ করে আপনার ফোনের ব্লুটুথ সেটিংসে গিয়ে তালিকা থেকে বিদ্যমান Omi-কে সরিয়ে দিন, তারপর ফিরে এসে আবার চেষ্টা করুন।",
"pairingLostButton": "ব্লুটুথ সেটিংস খুলুন"
}
5 changes: 4 additions & 1 deletion app/lib/l10n/app_bs.arb
Original file line number Diff line number Diff line change
Expand Up @@ -10721,5 +10721,8 @@
"selectAllTasksMenu": "Odaberi sve",
"showCompletedTasks": "Prikaži završene",
"startCallRecording": "Pokreni snimanje poziva",
"startVoiceRecording": "Pokreni glasovno snimanje"
"startVoiceRecording": "Pokreni glasovno snimanje",
"pairingLostTitle": "Ne mogu se povezati s vašim Omi uređajem",
"pairingLostBody": "Molimo vas da otvorite Bluetooth postavke na telefonu, uklonite postojeći Omi sa liste, a zatim se vratite i pokušajte ponovo.",
"pairingLostButton": "Otvori postavke Bluetootha"
}
5 changes: 4 additions & 1 deletion app/lib/l10n/app_ca.arb
Original file line number Diff line number Diff line change
Expand Up @@ -2982,5 +2982,8 @@
"selectAllTasksMenu": "Selecciona tot",
"showCompletedTasks": "Mostra les completades",
"startCallRecording": "Inicia l'enregistrament de trucada",
"startVoiceRecording": "Inicia l'enregistrament de veu"
"startVoiceRecording": "Inicia l'enregistrament de veu",
"pairingLostTitle": "No em puc connectar al teu Omi",
"pairingLostBody": "Obriu la configuració de Bluetooth del telèfon, elimineu l'Omi existent de la llista i torneu-ho a provar.",
"pairingLostButton": "Obre la configuració de Bluetooth"
}
5 changes: 4 additions & 1 deletion app/lib/l10n/app_cs.arb
Original file line number Diff line number Diff line change
Expand Up @@ -2982,5 +2982,8 @@
"selectAllTasksMenu": "Vybrat vše",
"showCompletedTasks": "Zobrazit dokončené",
"startCallRecording": "Zahájit nahrávání hovoru",
"startVoiceRecording": "Zahájit hlasový záznam"
"startVoiceRecording": "Zahájit hlasový záznam",
"pairingLostTitle": "Nelze se připojit k vašemu Omi",
"pairingLostBody": "Otevřete prosím nastavení Bluetooth v telefonu, odeberte stávající zařízení Omi ze seznamu a zkuste to znovu.",
"pairingLostButton": "Otevřít nastavení Bluetooth"
}
5 changes: 4 additions & 1 deletion app/lib/l10n/app_da.arb
Original file line number Diff line number Diff line change
Expand Up @@ -3022,5 +3022,8 @@
"selectAllTasksMenu": "Vælg alle",
"showCompletedTasks": "Vis fuldførte",
"startCallRecording": "Start opkaldsoptagelse",
"startVoiceRecording": "Start stemmeoptagelse"
"startVoiceRecording": "Start stemmeoptagelse",
"pairingLostTitle": "Kan ikke oprette forbindelse til din Omi",
"pairingLostBody": "Åbn din telefons Bluetooth-indstillinger, fjern den eksisterende Omi fra listen, og prøv derefter igen.",
"pairingLostButton": "Åbn Bluetooth-indstillinger"
}
5 changes: 4 additions & 1 deletion app/lib/l10n/app_de.arb
Original file line number Diff line number Diff line change
Expand Up @@ -2981,5 +2981,8 @@
"selectAllTasksMenu": "Alle auswählen",
"showCompletedTasks": "Erledigte anzeigen",
"startCallRecording": "Anrufaufnahme starten",
"startVoiceRecording": "Sprachaufnahme starten"
"startVoiceRecording": "Sprachaufnahme starten",
"pairingLostTitle": "Verbindung zu Ihrem Omi nicht möglich",
"pairingLostBody": "Bitte öffnen Sie die Bluetooth-Einstellungen Ihres Telefons, entfernen Sie das vorhandene Omi aus der Liste und versuchen Sie es dann erneut.",
"pairingLostButton": "Öffnen Sie die Bluetooth-Einstellungen."
}
5 changes: 4 additions & 1 deletion app/lib/l10n/app_el.arb
Original file line number Diff line number Diff line change
Expand Up @@ -3013,5 +3013,8 @@
"selectAllTasksMenu": "Επιλογή όλων",
"showCompletedTasks": "Εμφάνιση ολοκληρωμένων",
"startCallRecording": "Έναρξη εγγραφής κλήσης",
"startVoiceRecording": "Έναρξη ηχογράφησης"
"startVoiceRecording": "Έναρξη ηχογράφησης",
"pairingLostTitle": "Δεν είναι δυνατή η σύνδεση με το Omi σας",
"pairingLostBody": "Ανοίξτε τις ρυθμίσεις Bluetooth του τηλεφώνου σας, καταργήστε το υπάρχον Omi από τη λίστα και, στη συνέχεια, επιστρέψτε και προσπαθήστε ξανά.",
"pairingLostButton": "Άνοιγμα ρυθμίσεων Bluetooth"
}
Loading
Loading