From 2b3f15ef947af6730ee95b694fdc38bd0c562dba Mon Sep 17 00:00:00 2001 From: LuoXin <480445898@qq.com> Date: Fri, 15 May 2026 23:35:36 +0800 Subject: [PATCH 1/3] docs(audio): clarify selectDevice() overrides preferredDeviceList MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit selectDevice()'s KDoc was a single line and did not mention that the call overrides the automatic selection driven by preferredDeviceList. In practice this has caused integrations to ship apps that silently route audio to the speaker even when the user has a wired or Bluetooth headset connected — the typical anti-pattern is implementing a "speakerphone on/off" toggle by unconditionally calling selectDevice(Speakerphone::class.java) at call start. This change is documentation-only: - Document that selectDevice() is sticky and overrides preferredDeviceList, including across hot-plug events. - Call out the unconditional-selectDevice(Speakerphone) anti-pattern. - Add a recommended pattern that combines selectDevice() with audioDeviceChangeListener and a preferSpeakerWhenNoHeadset flag, so a user-facing speaker toggle still respects wired/BT headsets. - Note that BLUETOOTH_CONNECT (Android 12+) must be granted before availableAudioDevices reports any Bluetooth headset. Co-authored-by: Cursor --- .../docs-select-device-headset-clarify.md | 5 ++ .../android/audio/AudioSwitchHandler.kt | 47 ++++++++++++++++++- 2 files changed, 51 insertions(+), 1 deletion(-) create mode 100644 .changeset/docs-select-device-headset-clarify.md diff --git a/.changeset/docs-select-device-headset-clarify.md b/.changeset/docs-select-device-headset-clarify.md new file mode 100644 index 000000000..1a1e0f4ca --- /dev/null +++ b/.changeset/docs-select-device-headset-clarify.md @@ -0,0 +1,5 @@ +--- +"client-sdk-android": patch +--- + +docs(audio): clarify that `AudioSwitchHandler.selectDevice()` overrides the automatic selection driven by `preferredDeviceList` and add a recommended sample for implementing a speakerphone toggle that still respects wired/Bluetooth headsets. Also note the `BLUETOOTH_CONNECT` (Android 12+) runtime permission requirement for Bluetooth devices to appear in `availableAudioDevices`. diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/audio/AudioSwitchHandler.kt b/livekit-android-sdk/src/main/java/io/livekit/android/audio/AudioSwitchHandler.kt index 9f8d50fc9..aa3d9417c 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/audio/AudioSwitchHandler.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/audio/AudioSwitchHandler.kt @@ -285,7 +285,52 @@ constructor(private val context: Context) : AudioHandler { get() = audioSwitch?.availableAudioDevices ?: listOf() /** - * Select a specific audio device. + * Select a specific audio device, overriding the automatic selection driven by + * [preferredDeviceList]. + * + * The selection is sticky: once you call this, the chosen device stays active even + * when the set of [availableAudioDevices] changes (for example, plugging or unplugging + * a wired headset, or connecting a Bluetooth headset). If you want to keep following + * the priority defined by [preferredDeviceList] across such hot-plug events, do not + * call [selectDevice] — leave the automatic selection in place. + * + * A common pitfall is to unconditionally call `selectDevice(Speakerphone)` (or + * `Earpiece`) at the start of a call to implement a "speakerphone on/off" toggle. + * On devices where the user has a wired or Bluetooth headset connected, this will + * silently override the headset and route audio to the speaker/earpiece instead. + * + * Recommended pattern for a "speakerphone toggle" while still respecting headsets: + * + * ```kotlin + * // Treat the user toggle only as a fallback when no headset is present. + * private var preferSpeakerWhenNoHeadset: Boolean = true + * + * fun onUserToggleSpeakerphone(on: Boolean) { + * preferSpeakerWhenNoHeadset = on + * selectBestAudioDevice() + * } + * + * private fun installAudioRouting(handler: AudioSwitchHandler) { + * handler.audioDeviceChangeListener = { _, _ -> selectBestAudioDevice() } + * selectBestAudioDevice() + * } + * + * private fun selectBestAudioDevice() { + * val handler = audioHandler ?: return + * val devices = handler.availableAudioDevices + * val target = devices.firstOrNull { it is AudioDevice.BluetoothHeadset } + * ?: devices.firstOrNull { it is AudioDevice.WiredHeadset } + * ?: if (preferSpeakerWhenNoHeadset) + * devices.firstOrNull { it is AudioDevice.Speakerphone } + * else + * devices.firstOrNull { it is AudioDevice.Earpiece } + * target?.let { handler.selectDevice(it) } + * } + * ``` + * + * Note: on Android 12 (API 31) and above, the `BLUETOOTH_CONNECT` runtime + * permission must be granted before [availableAudioDevices] will report any + * Bluetooth headset, even if the OS shows the headset as connected. */ @Synchronized fun selectDevice(audioDevice: AudioDevice?) { From c4dedb7a1629a135112b124d4a13aad5d554ee9c Mon Sep 17 00:00:00 2001 From: LuoXin <480445898@qq.com> Date: Sat, 16 May 2026 11:08:50 +0800 Subject: [PATCH 2/3] docs(audio): simplify selectDevice() KDoc; drop sample and BLUETOOTH_CONNECT note MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review from @davidliu and @adrian-niculescu on PR #941: - @davidliu (line 318): the recommended sample reimplements logic that AudioSwitchHandler already does. Drop the listener + selectBestAudioDevice sample; instead point readers at preferredDeviceList for the common case of "I just want a different priority order". - @davidliu (total): keep the KDoc short, no longer reads as "don't use this function". - @adrian-niculescu (sticky semantics): describe the real behavior — the selection is sticky and AudioSwitch resumes it automatically if the selected device temporarily disappears and later reconnects, rather than implying the selection is dropped on disconnect. - @adrian-niculescu (undo path): document selectDevice(null) as the way to return to fully automatic selection. - @davidliu (line 331): drop the BLUETOOTH_CONNECT note. Verified on a Pixel 7a (Android 14) that availableAudioDevices reports BluetoothHeadset with the wrapper code fully no-op, regardless of permission state. The remaining KDoc only documents the three things that are not obvious from the function signature: stickiness, the preferredDeviceList alternative for the common pitfall, and selectDevice(null) as the undo path. Co-authored-by: Cursor --- .../docs-select-device-headset-clarify.md | 2 +- .../android/audio/AudioSwitchHandler.kt | 55 ++++--------------- 2 files changed, 13 insertions(+), 44 deletions(-) diff --git a/.changeset/docs-select-device-headset-clarify.md b/.changeset/docs-select-device-headset-clarify.md index 1a1e0f4ca..504f093fd 100644 --- a/.changeset/docs-select-device-headset-clarify.md +++ b/.changeset/docs-select-device-headset-clarify.md @@ -2,4 +2,4 @@ "client-sdk-android": patch --- -docs(audio): clarify that `AudioSwitchHandler.selectDevice()` overrides the automatic selection driven by `preferredDeviceList` and add a recommended sample for implementing a speakerphone toggle that still respects wired/Bluetooth headsets. Also note the `BLUETOOTH_CONNECT` (Android 12+) runtime permission requirement for Bluetooth devices to appear in `availableAudioDevices`. +docs(audio): clarify that `AudioSwitchHandler.selectDevice()` is sticky and overrides the automatic selection driven by `preferredDeviceList`. Document that callers who only need a different priority order (e.g. prefer Speakerphone over Earpiece as the fallback when no headset is present) should set `preferredDeviceList` instead, and that `selectDevice(null)` returns to fully automatic selection. diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/audio/AudioSwitchHandler.kt b/livekit-android-sdk/src/main/java/io/livekit/android/audio/AudioSwitchHandler.kt index aa3d9417c..0034a4b7e 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/audio/AudioSwitchHandler.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/audio/AudioSwitchHandler.kt @@ -286,51 +286,20 @@ constructor(private val context: Context) : AudioHandler { /** * Select a specific audio device, overriding the automatic selection driven by - * [preferredDeviceList]. + * [preferredDeviceList]. The selection is sticky: the chosen device stays selected + * whenever it is available, even as other devices are connected or disconnected, + * and AudioSwitch will resume it automatically if it temporarily disappears and + * later reconnects. * - * The selection is sticky: once you call this, the chosen device stays active even - * when the set of [availableAudioDevices] changes (for example, plugging or unplugging - * a wired headset, or connecting a Bluetooth headset). If you want to keep following - * the priority defined by [preferredDeviceList] across such hot-plug events, do not - * call [selectDevice] — leave the automatic selection in place. + * If your goal is only to change the priority order — for example, prefer + * Speakerphone over Earpiece as the fallback when no headset is present — + * set [preferredDeviceList] instead. Calling [selectDevice] is not required + * for that. In particular, calling `selectDevice(Speakerphone)` at the start + * of every call will silently override any wired or Bluetooth headset the + * user has connected. * - * A common pitfall is to unconditionally call `selectDevice(Speakerphone)` (or - * `Earpiece`) at the start of a call to implement a "speakerphone on/off" toggle. - * On devices where the user has a wired or Bluetooth headset connected, this will - * silently override the headset and route audio to the speaker/earpiece instead. - * - * Recommended pattern for a "speakerphone toggle" while still respecting headsets: - * - * ```kotlin - * // Treat the user toggle only as a fallback when no headset is present. - * private var preferSpeakerWhenNoHeadset: Boolean = true - * - * fun onUserToggleSpeakerphone(on: Boolean) { - * preferSpeakerWhenNoHeadset = on - * selectBestAudioDevice() - * } - * - * private fun installAudioRouting(handler: AudioSwitchHandler) { - * handler.audioDeviceChangeListener = { _, _ -> selectBestAudioDevice() } - * selectBestAudioDevice() - * } - * - * private fun selectBestAudioDevice() { - * val handler = audioHandler ?: return - * val devices = handler.availableAudioDevices - * val target = devices.firstOrNull { it is AudioDevice.BluetoothHeadset } - * ?: devices.firstOrNull { it is AudioDevice.WiredHeadset } - * ?: if (preferSpeakerWhenNoHeadset) - * devices.firstOrNull { it is AudioDevice.Speakerphone } - * else - * devices.firstOrNull { it is AudioDevice.Earpiece } - * target?.let { handler.selectDevice(it) } - * } - * ``` - * - * Note: on Android 12 (API 31) and above, the `BLUETOOTH_CONNECT` runtime - * permission must be granted before [availableAudioDevices] will report any - * Bluetooth headset, even if the OS shows the headset as connected. + * To return to fully automatic selection after a previous sticky call, pass + * `null`: `selectDevice(null)`. */ @Synchronized fun selectDevice(audioDevice: AudioDevice?) { From 565f58782f4192474ffe552b29a1cd03d422dc65 Mon Sep 17 00:00:00 2001 From: LuoXin <480445898@qq.com> Date: Sat, 16 May 2026 11:59:31 +0800 Subject: [PATCH 3/3] docs(audio): slim selectDevice() KDoc to the essentials MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Further address @davidliu's "reads like 'don't use this function'" feedback on PR #941. v2 still carried an anti-pattern callout ("calling selectDevice(Speakerphone) at the start of every call will silently override any wired or Bluetooth headset...") which kept the overall tone negative. v3 rewrites the KDoc as a neutral how-to-use: - Sentence 1: sticky semantics — overrides preferredDeviceList, persists across hot-plug, resumes on reconnect, cleared by passing null. - Sentence 2: pointer to preferredDeviceList for the "I only want a different priority order" case. Two sentences, five lines, no warnings. All the substantive technical content from @adrian-niculescu's review (sticky/resume semantics, selectDevice(null) as the undo path) is preserved; the "set preferredDeviceList instead" recommendation requested by @davidliu is preserved. The anti-pattern warning is dropped — the existence of preferredDeviceList in the recommendation already implies it. Co-authored-by: Cursor --- .../docs-select-device-headset-clarify.md | 2 +- .../android/audio/AudioSwitchHandler.kt | 20 ++++++------------- 2 files changed, 7 insertions(+), 15 deletions(-) diff --git a/.changeset/docs-select-device-headset-clarify.md b/.changeset/docs-select-device-headset-clarify.md index 504f093fd..306a125b3 100644 --- a/.changeset/docs-select-device-headset-clarify.md +++ b/.changeset/docs-select-device-headset-clarify.md @@ -2,4 +2,4 @@ "client-sdk-android": patch --- -docs(audio): clarify that `AudioSwitchHandler.selectDevice()` is sticky and overrides the automatic selection driven by `preferredDeviceList`. Document that callers who only need a different priority order (e.g. prefer Speakerphone over Earpiece as the fallback when no headset is present) should set `preferredDeviceList` instead, and that `selectDevice(null)` returns to fully automatic selection. +docs(audio): clarify that `AudioSwitchHandler.selectDevice()` is sticky and overrides `preferredDeviceList`. Document that callers who only need a different priority order should set `preferredDeviceList` instead, and that `selectDevice(null)` clears a sticky selection. diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/audio/AudioSwitchHandler.kt b/livekit-android-sdk/src/main/java/io/livekit/android/audio/AudioSwitchHandler.kt index 0034a4b7e..d80f8bf1a 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/audio/AudioSwitchHandler.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/audio/AudioSwitchHandler.kt @@ -285,21 +285,13 @@ constructor(private val context: Context) : AudioHandler { get() = audioSwitch?.availableAudioDevices ?: listOf() /** - * Select a specific audio device, overriding the automatic selection driven by - * [preferredDeviceList]. The selection is sticky: the chosen device stays selected - * whenever it is available, even as other devices are connected or disconnected, - * and AudioSwitch will resume it automatically if it temporarily disappears and - * later reconnects. + * Select a specific audio device. The selection is sticky: it overrides + * [preferredDeviceList], persists across device hot-plug, and is restored + * automatically if the device temporarily disappears and later reconnects. + * Pass `null` to clear a sticky selection. * - * If your goal is only to change the priority order — for example, prefer - * Speakerphone over Earpiece as the fallback when no headset is present — - * set [preferredDeviceList] instead. Calling [selectDevice] is not required - * for that. In particular, calling `selectDevice(Speakerphone)` at the start - * of every call will silently override any wired or Bluetooth headset the - * user has connected. - * - * To return to fully automatic selection after a previous sticky call, pass - * `null`: `selectDevice(null)`. + * If you only need to change which device is preferred when several are + * available, set [preferredDeviceList] instead. */ @Synchronized fun selectDevice(audioDevice: AudioDevice?) {