From 4da85473815db04fa6aaa486269b031d4d7d6c4f Mon Sep 17 00:00:00 2001 From: Yares Date: Mon, 26 Oct 2026 14:34:27 -0300 Subject: [PATCH 1/3] Factor audio output option enumeration out of updatePreference --- .../talkback/audio/AudioDeviceRouter.kt | 99 ++++++++++--------- 1 file changed, 55 insertions(+), 44 deletions(-) diff --git a/talkback/src/main/java/com/google/android/accessibility/talkback/audio/AudioDeviceRouter.kt b/talkback/src/main/java/com/google/android/accessibility/talkback/audio/AudioDeviceRouter.kt index 4094b1f28..547b50413 100644 --- a/talkback/src/main/java/com/google/android/accessibility/talkback/audio/AudioDeviceRouter.kt +++ b/talkback/src/main/java/com/google/android/accessibility/talkback/audio/AudioDeviceRouter.kt @@ -743,6 +743,9 @@ class AudioDeviceRouter @JvmOverloads constructor( } } + /** One selectable audio output: what the user sees and what is stored in the preference. */ + class OutputOption(val displayName: String, val prefValue: String) + companion object { private const val TAG = "AudioDeviceRouter" // Gaps between re-assertion passes, covering slow Bluetooth profile switches. @@ -812,63 +815,71 @@ class AudioDeviceRouter @JvmOverloads constructor( } /** - * Dynamically populates entries and entry values for the audio output device [ListPreference]. + * The connected external outputs, in the order Android reports them. */ @JvmStatic - fun updatePreference(preference: ListPreference, context: Context) { + fun getExternalDevices(context: Context): List { val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager - val connectedDevices = - audioManager?.getDevices(AudioManager.GET_DEVICES_OUTPUTS) ?: emptyArray() - - val externalDevices = connectedDevices.filter { isExternalDevice(it) } - - val entries = mutableListOf() - val entryValues = mutableListOf() - - entries.add(context.getString(R.string.value_audio_output_device_default)) - entryValues.add("default") - - entries.add(context.getString(R.string.value_audio_output_device_speaker)) - entryValues.add("speaker") + return audioManager?.getDevices(AudioManager.GET_DEVICES_OUTPUTS) + ?.filter { isExternalDevice(it) } + ?: emptyList() + } + /** + * The selectable audio outputs in cycling order: system default, phone speaker, then each + * connected external device by name. + */ + @JvmStatic + fun getOutputOptions(context: Context): List { + val options = + mutableListOf( + OutputOption(context.getString(R.string.value_audio_output_device_default), "default"), + OutputOption(context.getString(R.string.value_audio_output_device_speaker), "speaker"), + ) val seenNames = mutableSetOf() - for (dev in externalDevices) { + for (dev in getExternalDevices(context)) { val displayName = getDeviceDisplayName(dev, context) if (seenNames.add(displayName)) { - entries.add(displayName) - entryValues.add("device:${dev.type}:$displayName") + options.add(OutputOption(displayName, "device:${dev.type}:$displayName")) } } + return options + } - preference.entries = entries.toTypedArray() - preference.entryValues = entryValues.toTypedArray() - - val currentValue = preference.value - val currentTarget = AudioTarget.parse(currentValue) - val summaryText = when (currentTarget) { - is AudioTarget.SystemDefault -> - context.getString(R.string.value_audio_output_device_default) - is AudioTarget.PhoneSpeaker -> - context.getString(R.string.value_audio_output_device_speaker) - is AudioTarget.SpecificDevice -> { - val matchIndex = entryValues.indexOfFirst { - it.toString() == currentValue || it.toString().endsWith(":${currentTarget.name}") - } - if (matchIndex >= 0) { - entries[matchIndex].toString() - } else { - "${currentTarget.name} (${context.getString(R.string.value_audio_output_device_disconnected)})" - } - } - is AudioTarget.AnyExternal -> { - if (externalDevices.isNotEmpty()) { - getDeviceDisplayName(externalDevices.first(), context) - } else { + /** + * Dynamically populates entries and entry values for the audio output device [ListPreference]. + */ + @JvmStatic + fun updatePreference(preference: ListPreference, context: Context) { + val options = getOutputOptions(context) + preference.entries = options.map { it.displayName }.toTypedArray() + preference.entryValues = options.map { it.prefValue }.toTypedArray() + + val currentTarget = AudioTarget.parse(preference.value) + preference.summary = + when (currentTarget) { + is AudioTarget.SystemDefault -> context.getString(R.string.value_audio_output_device_default) + is AudioTarget.PhoneSpeaker -> + context.getString(R.string.value_audio_output_device_speaker) + is AudioTarget.SpecificDevice -> { + val match = + options.firstOrNull { + it.prefValue == preference.value || + it.prefValue.endsWith(":${currentTarget.name}") + } + if (match != null) { + match.displayName + } else { + "${currentTarget.name} (${context.getString(R.string.value_audio_output_device_disconnected)})" + } + } + is AudioTarget.AnyExternal -> { + val external = getExternalDevices(context) + if (external.isNotEmpty()) getDeviceDisplayName(external.first(), context) + else context.getString(R.string.value_audio_output_device_default) } } - } - preference.summary = summaryText } } } From 6f7565eaefbb52a1581a5b4e04a8fb658c6de5c8 Mon Sep 17 00:00:00 2001 From: Yares Date: Mon, 26 Oct 2026 14:38:18 -0300 Subject: [PATCH 2/3] Add audio output device reading control --- .../talkback/selector/SelectorController.java | 47 ++++++++++++++++++- .../src/main/res/values/donottranslate.xml | 3 ++ .../res/xml/selector_menu_preferences.xml | 5 ++ 3 files changed, 54 insertions(+), 1 deletion(-) diff --git a/talkback/src/main/java/com/google/android/accessibility/talkback/selector/SelectorController.java b/talkback/src/main/java/com/google/android/accessibility/talkback/selector/SelectorController.java index 225c1ced9..913a72ae0 100644 --- a/talkback/src/main/java/com/google/android/accessibility/talkback/selector/SelectorController.java +++ b/talkback/src/main/java/com/google/android/accessibility/talkback/selector/SelectorController.java @@ -85,6 +85,7 @@ import com.google.android.accessibility.talkback.Pipeline; import com.google.android.accessibility.talkback.R; import com.google.android.accessibility.talkback.UserInterface.UserInputEventListener; +import com.google.android.accessibility.talkback.audio.AudioDeviceRouter; import com.google.android.accessibility.talkback.analytics.TalkBackAnalytics; import com.google.android.accessibility.talkback.compositor.Compositor; import com.google.android.accessibility.talkback.compositor.CompositorUtils; @@ -386,7 +387,11 @@ public enum Setting { SWITCH_VOICE_PROFILE( R.string.pref_selector_voice_profile_key, R.string.selector_voice_profile, - R.bool.pref_selector_voice_profile_default); + R.bool.pref_selector_voice_profile_default), + AUDIO_OUTPUT_DEVICE( + R.string.pref_selector_audio_output_device_key, + R.string.selector_audio_output_device, + R.bool.pref_selector_audio_output_device_default); /** The preference key of the filter in the selector settings page. */ final int prefKeyResId; @@ -639,6 +644,7 @@ static boolean isValid(Granularity granularity, ActorState actorState) { Setting.AUDIO_FOCUS, Setting.SCROLLING_SEQUENTIAL, Setting.CHANGE_ACCESSIBILITY_VOLUME, + Setting.AUDIO_OUTPUT_DEVICE, Setting.CHANGE_BRIGHTNESS, Setting.CHANGE_TOUCH_FOCUS_LATENCY, Setting.CHANGE_TYPING_FOCUS_LATENCY, @@ -986,6 +992,10 @@ private DescriptionAndHint getSettingActionDescriptionAndHint(Setting setting, E actionDescription = context.getString(R.string.title_selector_voice_profile); hint = getAdjustSelectedSettingGestures(); } + case AUDIO_OUTPUT_DEVICE -> { + actionDescription = context.getString(R.string.title_pref_audio_output_device); + hint = getAdjustSelectedSettingGestures(); + } case ACTIONS -> { // Reset currentActionId to the default action. resetActionMenuToDefault(); @@ -1819,6 +1829,10 @@ public void adjustSelectedSetting(EventId eventId, boolean isNext) { changeVoiceProfile(eventId, isNext, getAdjustSelectedSettingGestures()); return; } + case AUDIO_OUTPUT_DEVICE -> { + changeAudioOutputDevice(eventId, isNext); + return; + } case GRANULARITY -> { // Granularity is phased out. Assigns to the character setting. updateSettingPref(context, GRANULARITY_CHARACTERS); @@ -2070,6 +2084,37 @@ private void changeTtsEngine(EventId eventId, boolean isNext) { showQuickMenuActionOverlay(eventId, displayText); } + /** Changes the audio output device to the previous or next option. */ + private void changeAudioOutputDevice(EventId eventId, boolean isNext) { + List options = AudioDeviceRouter.getOutputOptions(context); + String currentValue = + SharedPreferencesUtils.getStringPref( + prefs, + context.getResources(), + R.string.pref_audio_output_device_key, + R.string.pref_audio_output_device_default); + int currentIndex = -1; + for (int i = 0; i < options.size(); i++) { + if (options.get(i).getPrefValue().equals(currentValue)) { + currentIndex = i; + break; + } + } + int nextIndex = + (currentIndex < 0) + ? (isNext ? 0 : options.size() - 1) + : Math.floorMod(currentIndex + (isNext ? 1 : -1), options.size()); + String nextValue = options.get(nextIndex).getPrefValue(); + prefs + .edit() + .putString(context.getString(R.string.pref_audio_output_device_key), nextValue) + .apply(); + + String displayText = options.get(nextIndex).getDisplayName(); + announceSetting(eventId, displayText, getAdjustSelectedSettingGestures()); + showQuickMenuActionOverlay(eventId, displayText); + } + /** * Changes to the previous or next voice profile from a gesture or keyboard shortcut, with * Backtalk's default first. diff --git a/talkback/src/main/res/values/donottranslate.xml b/talkback/src/main/res/values/donottranslate.xml index 0fadcf226..f81342e14 100644 --- a/talkback/src/main/res/values/donottranslate.xml +++ b/talkback/src/main/res/values/donottranslate.xml @@ -254,6 +254,7 @@ pref_selector_switch_tts_engine_key pref_selector_voice_profile_key + pref_selector_audio_output_device_key pref_selector_voice_profile_moved pref_selector_control_telling_time pref_selector_actions_key @@ -1561,6 +1562,7 @@ false false false + false false false false @@ -1881,6 +1883,7 @@ SELECTOR_LIFT_TO_ACTIVATE_CHANGE SELECTOR_WRAP_AROUND SELECTOR_TABLET_HELD_UP_FACES_AWAY + SELECTOR_AUDIO_OUTPUT_DEVICE SELECTOR_SWITCH_TTS_ENGINE SELECTOR_VOICE_PROFILE SELECTOR_TOUCH_FOCUS_LATENCY_CHANGE diff --git a/talkback/src/main/res/xml/selector_menu_preferences.xml b/talkback/src/main/res/xml/selector_menu_preferences.xml index 372464cb8..5e552a60d 100644 --- a/talkback/src/main/res/xml/selector_menu_preferences.xml +++ b/talkback/src/main/res/xml/selector_menu_preferences.xml @@ -297,6 +297,11 @@ android:key="@string/pref_selector_change_a11y_volume_key" android:title="@string/title_pref_a11y_volume" android:summary="@string/summary_support_change_a11y_volume"/> + Date: Mon, 26 Oct 2026 13:30:47 -0600 Subject: [PATCH 3/3] Find the chosen audio output the same way in the reading control and the setting The reading control only knew the chosen output when the stored value matched exactly, so a device whose type had changed, or Any external device, sent the first swipe back to the start of the list. Both now use indexOfOption, which matches a device by name and any external device to the first one. Also documents the reading control and tests the matching. --- differences.md | 2 + .../talkback/audio/AudioDeviceRouter.kt | 21 ++++-- .../talkback/selector/SelectorController.java | 10 +-- .../talkback/audio/AudioOutputOptionsTest.kt | 71 +++++++++++++++++++ 4 files changed, 91 insertions(+), 13 deletions(-) create mode 100644 talkback/src/test/java/com/google/android/accessibility/talkback/audio/AudioOutputOptionsTest.kt diff --git a/differences.md b/differences.md index 5ffe72026..1fce82f99 100644 --- a/differences.md +++ b/differences.md @@ -225,6 +225,8 @@ TalkBack marked its notices that it was updated, or that gestures changed, as on Backtalk keeps your choice when devices connect or disconnect, or when other apps start playing. If the device that you chose isn't connected, Backtalk uses another external device, and the setting shows "(disconnected)" next to the device name. During phone calls, Android decides where audio plays. +To switch quickly, add **Audio output device** in **Reading controls**. Swiping up or down with it goes through **System default**, **Phone speaker** and each connected device, and changes the same setting. + ### Accessibility volume on Bluetooth headphones On some phones, such as Samsung's, TalkBack's accessibility volume gestures, its **Accessibility volume** reading control, and volume keys pressed while touching the screen change the media volume instead of the accessibility volume while Bluetooth headphones are connected. Backtalk changes the accessibility volume the way the system volume panel's slider does, so these work the same with or without headphones. diff --git a/talkback/src/main/java/com/google/android/accessibility/talkback/audio/AudioDeviceRouter.kt b/talkback/src/main/java/com/google/android/accessibility/talkback/audio/AudioDeviceRouter.kt index 547b50413..0251dc234 100644 --- a/talkback/src/main/java/com/google/android/accessibility/talkback/audio/AudioDeviceRouter.kt +++ b/talkback/src/main/java/com/google/android/accessibility/talkback/audio/AudioDeviceRouter.kt @@ -846,6 +846,21 @@ class AudioDeviceRouter @JvmOverloads constructor( return options } + /** + * Returns the index in [options] of the output that the preference [value] chooses, or -1. A + * device matches by name when its type has changed, and "any external device" matches the first + * external device, which is where it routes. + */ + @JvmStatic + fun indexOfOption(options: List, value: String?): Int = + when (val target = AudioTarget.parse(value)) { + is AudioTarget.SystemDefault -> options.indexOfFirst { it.prefValue == "default" } + is AudioTarget.PhoneSpeaker -> options.indexOfFirst { it.prefValue == "speaker" } + is AudioTarget.SpecificDevice -> + options.indexOfFirst { it.prefValue == value || it.prefValue.endsWith(":${target.name}") } + is AudioTarget.AnyExternal -> options.indexOfFirst { it.prefValue.startsWith("device:") } + } + /** * Dynamically populates entries and entry values for the audio output device [ListPreference]. */ @@ -863,11 +878,7 @@ class AudioDeviceRouter @JvmOverloads constructor( is AudioTarget.PhoneSpeaker -> context.getString(R.string.value_audio_output_device_speaker) is AudioTarget.SpecificDevice -> { - val match = - options.firstOrNull { - it.prefValue == preference.value || - it.prefValue.endsWith(":${currentTarget.name}") - } + val match = options.getOrNull(indexOfOption(options, preference.value)) if (match != null) { match.displayName } else { diff --git a/talkback/src/main/java/com/google/android/accessibility/talkback/selector/SelectorController.java b/talkback/src/main/java/com/google/android/accessibility/talkback/selector/SelectorController.java index 913a72ae0..52c8cae5f 100644 --- a/talkback/src/main/java/com/google/android/accessibility/talkback/selector/SelectorController.java +++ b/talkback/src/main/java/com/google/android/accessibility/talkback/selector/SelectorController.java @@ -85,8 +85,8 @@ import com.google.android.accessibility.talkback.Pipeline; import com.google.android.accessibility.talkback.R; import com.google.android.accessibility.talkback.UserInterface.UserInputEventListener; -import com.google.android.accessibility.talkback.audio.AudioDeviceRouter; import com.google.android.accessibility.talkback.analytics.TalkBackAnalytics; +import com.google.android.accessibility.talkback.audio.AudioDeviceRouter; import com.google.android.accessibility.talkback.compositor.Compositor; import com.google.android.accessibility.talkback.compositor.CompositorUtils; import com.google.android.accessibility.talkback.contextmenu.ContextMenu; @@ -2093,13 +2093,7 @@ private void changeAudioOutputDevice(EventId eventId, boolean isNext) { context.getResources(), R.string.pref_audio_output_device_key, R.string.pref_audio_output_device_default); - int currentIndex = -1; - for (int i = 0; i < options.size(); i++) { - if (options.get(i).getPrefValue().equals(currentValue)) { - currentIndex = i; - break; - } - } + int currentIndex = AudioDeviceRouter.indexOfOption(options, currentValue); int nextIndex = (currentIndex < 0) ? (isNext ? 0 : options.size() - 1) diff --git a/talkback/src/test/java/com/google/android/accessibility/talkback/audio/AudioOutputOptionsTest.kt b/talkback/src/test/java/com/google/android/accessibility/talkback/audio/AudioOutputOptionsTest.kt new file mode 100644 index 000000000..face8631d --- /dev/null +++ b/talkback/src/test/java/com/google/android/accessibility/talkback/audio/AudioOutputOptionsTest.kt @@ -0,0 +1,71 @@ +/* + * Copyright 2026 Backtalk contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy of + * the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ + +package com.google.android.accessibility.talkback.audio + +import android.content.Context +import android.media.AudioDeviceInfo +import com.google.android.accessibility.talkback.audio.AudioDeviceRouter.Companion.getOutputOptions +import com.google.android.accessibility.talkback.audio.AudioDeviceRouter.Companion.indexOfOption +import com.google.android.accessibility.talkback.audio.AudioDeviceRouter.OutputOption +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class AudioOutputOptionsTest { + private val context: Context = RuntimeEnvironment.getApplication() + + private val options = + listOf( + OutputOption("System default", "default"), + OutputOption("Phone speaker", "speaker"), + OutputOption("Buds", "device:${AudioDeviceInfo.TYPE_BLUETOOTH_A2DP}:Buds"), + OutputOption("Headphones", "device:${AudioDeviceInfo.TYPE_WIRED_HEADPHONES}:Headphones"), + ) + + @Test + fun options_withNoDevices_areDefaultThenSpeaker() { + assertEquals(listOf("default", "speaker"), getOutputOptions(context).map { it.prefValue }) + } + + @Test + fun index_ofEachKindOfValue() { + assertEquals(0, indexOfOption(options, "default")) + assertEquals(0, indexOfOption(options, null)) + assertEquals(1, indexOfOption(options, "speaker")) + assertEquals(3, indexOfOption(options, options[3].prefValue)) + } + + @Test + fun index_ofADeviceWhoseTypeChanged_matchesByName() { + assertEquals(2, indexOfOption(options, "device:${AudioDeviceInfo.TYPE_BLE_HEADSET}:Buds")) + } + + @Test + fun index_ofAnyExternalDevice_isTheFirstDevice() { + assertEquals(2, indexOfOption(options, "bluetooth_or_wired")) + } + + @Test + fun index_ofADisconnectedDevice_isMissing() { + assertEquals(-1, indexOfOption(options, "device:${AudioDeviceInfo.TYPE_USB_HEADSET}:Dock")) + } +}