Conversation
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 SummaryThis PR addresses two distinct Android BLE issues: preventing auto-reconnect after manual disconnect by checking Key changes and findings:
Confidence Score: 4/5
Important Files Changed
Sequence DiagramsequenceDiagram
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
|
| 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() | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
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)
}| init { | ||
| Log.i(TAG, "OmiBleManager initialized") | ||
| application.registerReceiver(bondStateReceiver, IntentFilter(BluetoothDevice.ACTION_BOND_STATE_CHANGED)) | ||
| } |
There was a problem hiding this comment.
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
OmiBleManagerfrom 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.
| 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 | ||
| } |
There was a problem hiding this comment.
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 onbondStateReceiver: 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
}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.
|
@greptile-apps re-review |
| 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 | ||
| } |
There was a problem hiding this comment.
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:
| 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.
Summary
Prevent auto-reconnect after manual disconnect: When the user disconnects from the Device Info page,
CompanionDeviceService.onDeviceAppearedwas immediately reconnecting (device turns red then blue, notification says "Listening" but app shows disconnected). NowonDeviceAppearedchecksmanuallyDisconnectedbefore reconnecting, and onlyconnectPeripheral(explicit user action) clears that flag.Auto-bond after GATT connection: Checks
bondStateafterSTATE_CONNECTED— ifBOND_NONE, callscreateBond()and waits for bonding before discovering services. IfBOND_BONDED, discovers services immediately. ABondStateReceivertriggers 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
🤖 Generated with Claude Code