Skip to content

fix: prevent reconnect after manual disconnect + auto-bond for encrypted devices#5889

Merged
mdmohsin7 merged 4 commits intomainfrom
fix/prevent-reconnect-after-manual-disconnect
Mar 22, 2026
Merged

fix: prevent reconnect after manual disconnect + auto-bond for encrypted devices#5889
mdmohsin7 merged 4 commits intomainfrom
fix/prevent-reconnect-after-manual-disconnect

Conversation

@mdmohsin7
Copy link
Copy Markdown
Member

@mdmohsin7 mdmohsin7 commented Mar 21, 2026

Summary

  • Prevent auto-reconnect after manual disconnect: When the user disconnects from the Device Info page, CompanionDeviceService.onDeviceAppeared was immediately reconnecting (device turns red then blue, notification says "Listening" but app shows disconnected). Now onDeviceAppeared checks manuallyDisconnected before reconnecting, and only connectPeripheral (explicit user action) clears that flag.

  • Auto-bond after GATT connection: Checks bondState after STATE_CONNECTED — if BOND_NONE, calls createBond() and waits for bonding before discovering services. If BOND_BONDED, discovers services immediately. A BondStateReceiver triggers service re-discovery after bonding completes. This fixes Limitless device connectivity which requires an encrypted BLE link. CompanionDeviceManager association auto-approves Just Works pairing, so no user dialog is shown.

Test plan

  • Connect Omi device — should connect normally (Just Works bond completes silently)
  • Disconnect from Device Info page — device should stay disconnected, no "Reconnecting" notification
  • Re-pair device — should connect successfully
  • Connect Limitless device — should auto-bond and discover services without manual pairing dialog
  • Close app (swipe away) — device should disconnect, no reconnect notification

🤖 Generated with Claude Code

CompanionDeviceService was reconnecting immediately after the user
disconnected from the Device Info page. Now onDeviceAppeared checks
manuallyDisconnected, and only connectPeripheral (explicit user
action) clears that flag.
Check bondState after STATE_CONNECTED: if BOND_NONE, call createBond()
and wait for bonding to complete before discovering services. If
BOND_BONDED, discover services immediately. BondStateReceiver triggers
service discovery after bonding completes.

This fixes Limitless device connectivity which requires an encrypted
BLE link. CompanionDeviceManager association auto-approves the Just
Works pairing, so no user dialog is needed.
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Mar 21, 2026

Greptile Summary

This PR addresses two distinct Android BLE issues: preventing auto-reconnect after manual disconnect by checking manuallyDisconnected in CompanionDeviceService.onDeviceAppeared, and enabling auto-bond for devices requiring an encrypted BLE link (e.g. Limitless) by calling createBond() on STATE_CONNECTED when not already bonded and deferring service discovery to a BroadcastReceiver.

Key changes and findings:

  • handleDeviceAppeared in BleCompanionService now correctly gates on isManuallyDisconnected, fixing the primary reconnect loop
  • Gap: BleCompanionService.onCreatestartForegroundServiceFallback does not apply the same isManuallyDisconnected check, leaving a secondary reconnect path open when the service is restarted within the same process
  • reconnectKnownPeripheral no longer clears manuallyDisconnected (intentional: only an explicit connectPeripheral call resets the flag)
  • bondStateReceiver now handles both BOND_BONDED (discover services) and BOND_NONE/bond-failure (fall back to service discovery anyway), addressing the previously raised concern
  • createBond() return value is not checked in STATE_CONNECTED; if the OS rejects the bond request, service discovery silently stalls. Additionally, BOND_BONDING at connection time falls through to a redundant createBond() call

Confidence Score: 4/5

  • PR is close to merge-ready; the primary fixes are sound but one logic gap in the fallback onCreate path can still trigger an unwanted reconnect after manual disconnect.
  • Both core fixes (manual-disconnect guard, auto-bond) work correctly along the primary code paths. The BOND_NONE fallback concern from prior review is addressed. The remaining P1 issue — startForegroundServiceFallback bypassing isManuallyDisconnected — is a real but narrow scenario (service restart within same process while manually disconnected) and doesn't affect the common case. The P2 BOND_BONDING comment is a robustness improvement, not a blocking bug.
  • app/android/app/src/main/kotlin/com/friend/ios/BleCompanionService.kt — startForegroundServiceFallback needs the isManuallyDisconnected guard added to match handleDeviceAppeared.

Important Files Changed

Filename Overview
app/android/app/src/main/kotlin/com/friend/ios/BleCompanionService.kt Adds isManuallyDisconnected guard to handleDeviceAppeared (fixes the primary reconnect bug), but the startForegroundServiceFallback path called from onCreate has the same gap — no manual-disconnect check — leaving a secondary reconnect vector open.
app/android/app/src/main/kotlin/com/friend/ios/OmiBleManager.kt Introduces bondStateReceiver (registered in init) for auto-bond on GATT connect; adds isManuallyDisconnected helper; removes erroneous manuallyDisconnected.remove from reconnectKnownPeripheral. Bond failure fallback (BOND_NONE path) is now handled. BOND_BONDING state at connection time triggers a redundant createBond() call with unchecked return value.

Sequence Diagram

sequenceDiagram
    participant User
    participant BleCompanionService
    participant OmiBleManager
    participant BondStateReceiver
    participant BluetoothGatt

    Note over User,BluetoothGatt: Manual Disconnect Flow
    User->>OmiBleManager: disconnectPeripheral(addr)
    OmiBleManager->>OmiBleManager: manuallyDisconnected.add(addr)
    OmiBleManager->>BluetoothGatt: disconnect() + close()

    Note over BleCompanionService,OmiBleManager: Device re-appears in range
    BleCompanionService->>BleCompanionService: onDeviceAppeared(addr)
    BleCompanionService->>OmiBleManager: isManuallyDisconnected(addr)
    OmiBleManager-->>BleCompanionService: true
    BleCompanionService->>BleCompanionService: skip reconnect ✓

    Note over User,BluetoothGatt: Explicit Re-connect Flow
    User->>OmiBleManager: connectPeripheral(addr)
    OmiBleManager->>OmiBleManager: manuallyDisconnected.remove(addr)
    OmiBleManager->>BluetoothGatt: connectGatt()

    Note over OmiBleManager,BondStateReceiver: Auto-bond for encrypted devices
    BluetoothGatt-->>OmiBleManager: STATE_CONNECTED
    OmiBleManager->>BluetoothGatt: check bondState
    alt BOND_BONDED
        OmiBleManager->>BluetoothGatt: discoverServices()
    else BOND_NONE / BOND_BONDING
        OmiBleManager->>BluetoothGatt: createBond()
        BluetoothGatt-->>BondStateReceiver: ACTION_BOND_STATE_CHANGED (BOND_BONDED)
        BondStateReceiver->>BluetoothGatt: discoverServices()
    end
    BluetoothGatt-->>OmiBleManager: onServicesDiscovered
Loading

Comments Outside Diff (1)

  1. app/android/app/src/main/kotlin/com/friend/ios/BleCompanionService.kt, line 64-73 (link)

    P2 onCreate fallback bypasses manuallyDisconnected check

    handleDeviceAppeared now correctly guards against re-connecting after a manual disconnect, but the onCreatestartForegroundServiceFallback path has no such guard:

    override fun onCreate() {
        super.onCreate()
        if (hasBluetoothPermission() && !OmiBleManager.instance.appClosed) {
            startForegroundServiceFallback()   // ← no isManuallyDisconnected check
        }
    }

    If BleCompanionService is stopped and restarted by the OS within the same process (e.g. after a service crash/rebind), onCreate fires with appClosed = false and manuallyDisconnected still containing the address. startForegroundServiceFallback will then trigger a reconnect, undoing the manual-disconnect fix for that scenario.

    The fix is to mirror the same guard that was added to handleDeviceAppeared:

Last reviewed commit: "fix: handle bonding ..."

Comment on lines +87 to +107
private val bondStateReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != BluetoothDevice.ACTION_BOND_STATE_CHANGED) return
val device = intent.getParcelableExtra<BluetoothDevice>(BluetoothDevice.EXTRA_DEVICE) ?: return
val bondState = intent.getIntExtra(BluetoothDevice.EXTRA_BOND_STATE, BluetoothDevice.BOND_NONE)
val address = device.address.uppercase()

Log.i(TAG, "Bond state changed: $address → $bondState")
if (bondState == BluetoothDevice.BOND_BONDED) {
val gatt = connectedGatts[address] ?: return
Log.i(TAG, "Bonding complete for $address, re-discovering services")
servicesDiscoveredFor.remove(address)
enqueueCommand {
if (!gatt.discoverServices()) {
Log.e(TAG, "discoverServices returned false after bonding")
completeCommand()
}
}
}
}
}
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 No bonding-failure recovery in bondStateReceiver

The receiver only handles BOND_BONDED. If bonding fails the state transitions to BOND_NONE (e.g. timeout, user decline on a non-Just-Works device, or the remote side aborts), which falls through without any action.

At that point the GATT connection is still open, Flutter has already received onPeripheralConnected, but discoverServices is never called. The app shows the device as "connected" and "Listening", yet audio never flows — with no error surfaced and no automatic recovery.

A minimal fix is to disconnect and surface an error when bonding fails:

if (bondState == BluetoothDevice.BOND_BONDED) {
    val gatt = connectedGatts[address] ?: return
    Log.i(TAG, "Bonding complete for $address, re-discovering services")
    servicesDiscoveredFor.remove(address)
    enqueueCommand {
        if (!gatt.discoverServices()) {
            Log.e(TAG, "discoverServices returned false after bonding")
            completeCommand()
        }
    }
} else if (bondState == BluetoothDevice.BOND_NONE) {
    val gatt = connectedGatts[address] ?: return
    Log.e(TAG, "Bonding failed for $address, disconnecting")
    mainHandler.post {
        flutterApi?.onPeripheralDisconnected(address, "Bonding failed") {}
    }
    gatt.disconnect()
    gatt.close()
    connectedGatts.remove(address)
}

Comment on lines 109 to 112
init {
Log.i(TAG, "OmiBleManager initialized")
application.registerReceiver(bondStateReceiver, IntentFilter(BluetoothDevice.ACTION_BOND_STATE_CHANGED))
}
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 bondStateReceiver registered but never unregistered

bondStateReceiver is registered once in init via application.registerReceiver(...) but there is no corresponding unregisterReceiver call anywhere in the class.

OmiBleManager is an application-scoped singleton so in practice the process lifecycle limits the damage, but:

  • It prevents OmiBleManager from being garbage-collected if something ever holds a reference via the registered receiver.
  • Any future cleanup path (e.g. teardown for testing) would leak the receiver.

Consider adding an unregisterReceiver call in a destroy() / cleanup() method, or at minimum document that this is intentionally application-lifetime.

Comment on lines +522 to 534
if (gatt.device.bondState == BluetoothDevice.BOND_BONDED) {
Log.i(TAG, "Device $address already bonded, discovering services")
enqueueCommand {
if (!gatt.discoverServices()) {
Log.e(TAG, "discoverServices returned false")
completeCommand()
}
}
} else {
Log.i(TAG, "Device $address not bonded (state=${gatt.device.bondState}), calling createBond()")
gatt.device.createBond()
// Service discovery happens in bondStateReceiver after bonding completes
}
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 Omi (non-Limitless) devices silently blocked until bonding resolves

Before this PR, any device reaching STATE_CONNECTED would have services discovered immediately. Now, only BOND_BONDED devices proceed directly; everything else calls createBond().

For the standard Omi device (which presumably never required bonding), bondState may be BOND_NONE at connection time even though it doesn't need encryption. Calling createBond() on it then means:

  • If Just Works bonding silently succeeds → fine, but adds latency
  • If the stack returns BOND_NONE (already bonded check fails, or device policy does not support bonding) → see the companion comment on bondStateReceiver: services are never discovered

It would be safer to also discover services for BOND_BONDING state (bonding already in progress) and fall back to immediate discovery for devices where createBond() returns false:

if (gatt.device.bondState == BluetoothDevice.BOND_BONDED) {
    Log.i(TAG, "Device $address already bonded, discovering services")
    enqueueCommand {
        if (!gatt.discoverServices()) {
            Log.e(TAG, "discoverServices returned false")
            completeCommand()
        }
    }
} else if (gatt.device.bondState == BluetoothDevice.BOND_BONDING) {
    Log.i(TAG, "Device $address already bonding, waiting for bondStateReceiver")
    // bondStateReceiver will trigger discovery
} else {
    val bonded = gatt.device.createBond()
    Log.i(TAG, "Device $address not bonded, createBond() returned $bonded")
    if (!bonded) {
        // Device does not support bonding — discover services directly
        enqueueCommand {
            if (!gatt.discoverServices()) {
                Log.e(TAG, "discoverServices returned false")
                completeCommand()
            }
        }
    }
    // Otherwise bondStateReceiver handles it
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Nope, it works

If bonding fails (BOND_NONE), attempt service discovery anyway —
the device may work without encryption, and if not, GATT operations
will fail with proper error callbacks.
@mdmohsin7
Copy link
Copy Markdown
Member Author

@greptile-apps re-review

Comment on lines +534 to 546
if (gatt.device.bondState == BluetoothDevice.BOND_BONDED) {
Log.i(TAG, "Device $address already bonded, discovering services")
enqueueCommand {
if (!gatt.discoverServices()) {
Log.e(TAG, "discoverServices returned false")
completeCommand()
}
}
} else {
Log.i(TAG, "Device $address not bonded (state=${gatt.device.bondState}), calling createBond()")
gatt.device.createBond()
// Service discovery happens in bondStateReceiver after bonding completes
}
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 BOND_BONDING transitional state causes redundant createBond() call

When a device's bond state is BOND_BONDING at STATE_CONNECTED time (e.g. bonding is already in progress from a prior association flow), the code falls into the else branch and calls createBond() again. The return value is not checked, so the duplicate call is silently ignored — the bondStateReceiver will eventually fire for the already-in-progress bond and discover services.

While functionally harmless for the happy path, checking the return value or explicitly handling BOND_BONDING would make the intent clearer and avoid a confusing log line ("not bonded, calling createBond()") when bonding is actually already underway:

Suggested change
if (gatt.device.bondState == BluetoothDevice.BOND_BONDED) {
Log.i(TAG, "Device $address already bonded, discovering services")
enqueueCommand {
if (!gatt.discoverServices()) {
Log.e(TAG, "discoverServices returned false")
completeCommand()
}
}
} else {
Log.i(TAG, "Device $address not bonded (state=${gatt.device.bondState}), calling createBond()")
gatt.device.createBond()
// Service discovery happens in bondStateReceiver after bonding completes
}
if (gatt.device.bondState == BluetoothDevice.BOND_BONDED) {
Log.i(TAG, "Device $address already bonded, discovering services")
enqueueCommand {
if (!gatt.discoverServices()) {
Log.e(TAG, "discoverServices returned false")
completeCommand()
}
}
} else if (gatt.device.bondState == BluetoothDevice.BOND_BONDING) {
Log.i(TAG, "Device $address bonding already in progress, waiting for bondStateReceiver")
// Service discovery happens in bondStateReceiver after bonding completes
} else {
Log.i(TAG, "Device $address not bonded (state=${gatt.device.bondState}), calling createBond()")
if (!gatt.device.createBond()) {
Log.w(TAG, "createBond() returned false for $address, discovering services immediately")
enqueueCommand {
if (!gatt.discoverServices()) {
Log.e(TAG, "discoverServices returned false after createBond failure")
completeCommand()
}
}
}
// Otherwise: service discovery happens in bondStateReceiver after bonding completes
}

… devices

writeWithoutResponse skips the iOS pairing handshake. Prefer
writeWithResponse when the characteristic supports it, matching
how the Limitless app triggers Just Works pairing via type:0.
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