Skip to content

Fix/async bt connect#1

Open
Pablomx2 wants to merge 2 commits into
Felip6499:mainfrom
Pablomx2:fix/async-bt-connect
Open

Fix/async bt connect#1
Pablomx2 wants to merge 2 commits into
Felip6499:mainfrom
Pablomx2:fix/async-bt-connect

Conversation

@Pablomx2

Copy link
Copy Markdown

No description provided.

Pablo Mena and others added 2 commits June 23, 2026 13:22
IOBluetoothDevice.openConnection() is synchronous and blocks the calling
thread until the baseband link is established or times out (up to several
seconds). connect() ran on the main actor and rescheduled its verification
poll on DispatchQueue.main, so a slow or failing AirPods connect could stall
the menu-bar app and delay processing of other audio events.

Move the whole connect/poll cycle onto a dedicated serial background queue.
connect() now returns immediately; no behavior change beyond not blocking
the main thread.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds an opt-in yieldToOtherSource setting. When enabled, PodSwitch stops
fighting for the target device after another source has taken it: once the
target's A2DP link to this phone drops (a CONNECTED->DISCONNECTED transition
seen by the new TargetConnectionMonitor) it is treated as yielded, and media
starting on the phone will not auto-steal it back. Normal switching resumes
when the target reconnects to the phone on its own, when playback fully stops,
or when the user explicitly accepts a switch.

Limitation: Android exposes no way to observe what a remote host streams
through a shared sink, so 'busy elsewhere' is inferred from the target's own
A2DP link to this phone, not from the other source's playback. This works for
single-link setups (another device grabbing the headphones shows up as a
disconnect); with multipoint the link may persist and the existing
already-active guard applies instead.

Engine + Coordinator changes covered by new unit tests. NOT compiled here:
no Android SDK/JDK in this environment — needs an on-device/CI build.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@Pablomx2 Pablomx2 closed this Jun 23, 2026
@Pablomx2 Pablomx2 reopened this Jun 23, 2026
@Pablomx2

Copy link
Copy Markdown
Author

Fixed

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review

This pull request introduces a 'yield to other source' feature on Android to prevent reclaiming the Bluetooth connection when another device takes over, supported by a new TargetConnectionMonitor and corresponding UI and settings updates. On macOS, it moves Bluetooth connection work to a background queue to prevent blocking the main thread. Key feedback includes addressing thread-safety issues with IOBluetooth on macOS by avoiding background queue execution, ensuring the Android broadcast receiver is registered as RECEIVER_EXPORTED to properly receive Bluetooth events, using IntentCompat for backward compatibility, and avoiding main-thread blocking disk reads in SwitchService by caching the target device ID.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines 28 to 39
public func connect(deviceId: String) {
guard let device = pairedDevice(matching: deviceId) else {
Self.log.error("connect: device not found among paired devices")
return
}
if device.isConnected() && isActiveOutput(deviceId: deviceId) {
return
Self.queue.async {
guard let device = pairedDevice(matching: deviceId) else {
Self.log.error("connect: device not found among paired devices")
return
}
if device.isConnected() && isActiveOutput(deviceId: deviceId) {
return
}
attemptConnect(deviceId: deviceId, attempt: 1)
}
attemptConnect(deviceId: deviceId, attempt: 1)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

IOBluetooth is not thread-safe. Calling its APIs (such as IOBluetoothDevice.pairedDevices(), device.isConnected(), and device.openConnection()) on a background queue can lead to race conditions, memory corruption, or crashes (e.g., EXC_BAD_ACCESS).

To safely connect asynchronously without blocking the main thread, consider using the asynchronous C API IOBluetoothDeviceAsyncConnectionCreate on the main thread, or ensure all IOBluetooth interactions are dispatched back to the main thread.

Comment on lines +48 to +53
ContextCompat.registerReceiver(
context,
callback,
IntentFilter(BluetoothA2dp.ACTION_CONNECTION_STATE_CHANGED),
ContextCompat.RECEIVER_NOT_EXPORTED,
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

On Android 13+ (API 33+), registering a receiver with RECEIVER_NOT_EXPORTED prevents it from receiving broadcasts from other UIDs/processes. Since the Bluetooth stack (com.android.bluetooth) runs under a separate system UID (not UID 1000), registering this receiver as RECEIVER_NOT_EXPORTED will prevent it from receiving ACTION_CONNECTION_STATE_CHANGED broadcasts on many devices.

Since BluetoothA2dp.ACTION_CONNECTION_STATE_CHANGED is a protected system broadcast that cannot be spoofed by third-party apps, it is safe and necessary to register this receiver as RECEIVER_EXPORTED.

Suggested change
ContextCompat.registerReceiver(
context,
callback,
IntentFilter(BluetoothA2dp.ACTION_CONNECTION_STATE_CHANGED),
ContextCompat.RECEIVER_NOT_EXPORTED,
)
ContextCompat.registerReceiver(
context,
callback,
IntentFilter(BluetoothA2dp.ACTION_CONNECTION_STATE_CHANGED),
ContextCompat.RECEIVER_EXPORTED,
)

Comment on lines +64 to +70
@Suppress("DEPRECATION")
private fun deviceFrom(intent: Intent): BluetoothDevice? =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE, BluetoothDevice::class.java)
} else {
intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Instead of manually checking the SDK version and suppressing deprecation warnings, you can use IntentCompat.getParcelableExtra from androidx.core.content.IntentCompat. This provides a safe, backward-compatible way to retrieve parcelable extras.

    private fun deviceFrom(intent: Intent): BluetoothDevice? =
        androidx.core.content.IntentCompat.getParcelableExtra(
            intent,
            BluetoothDevice.EXTRA_DEVICE,
            BluetoothDevice::class.java
        )

Comment on lines +41 to +47
connectionMonitor = TargetConnectionMonitor(
context = applicationContext,
targetAddress = { settings.currentConfig().targetDeviceId },
onChanged = { connected ->
coordinator.handle(SwitchEvent.TargetConnectionChanged(connected))
},
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Calling settings.currentConfig() inside the targetAddress lambda triggers runBlocking on the main thread every time a Bluetooth connection state broadcast is received. Since DataStore reads from disk, this can cause UI jank or ANRs (Application Not Responding).

Since SwitchService is a LifecycleService, we can collect settings.configFlow within the lifecycleScope to maintain a cached targetDeviceId in memory, completely avoiding blocking the main thread.

        var targetDeviceId: String? = null
        androidx.lifecycle.lifecycleScope.launch {
            settings.configFlow.collect { config ->
                targetDeviceId = config.targetDeviceId
            }
        }

        connectionMonitor = TargetConnectionMonitor(
            context = applicationContext,
            targetAddress = { targetDeviceId },
            onChanged = { connected ->
                coordinator.handle(SwitchEvent.TargetConnectionChanged(connected))
            },
        )

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