Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions differences.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -812,63 +815,82 @@ 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<AudioDeviceInfo> {
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<CharSequence>()
val entryValues = mutableListOf<CharSequence>()

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<OutputOption> {
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<String>()
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()
/**
* 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<OutputOption>, 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:") }
}

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.getOrNull(indexOfOption(options, preference.value))
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
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@
import com.google.android.accessibility.talkback.R;
import com.google.android.accessibility.talkback.UserInterface.UserInputEventListener;
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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -2070,6 +2084,31 @@ 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<AudioDeviceRouter.OutputOption> 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 = AudioDeviceRouter.indexOfOption(options, currentValue);
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.
Expand Down
3 changes: 3 additions & 0 deletions talkback/src/main/res/values/donottranslate.xml
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,7 @@
</string-array>
<string name="pref_selector_switch_tts_engine_key" translatable="false">pref_selector_switch_tts_engine_key</string>
<string name="pref_selector_voice_profile_key" translatable="false">pref_selector_voice_profile_key</string>
<string name="pref_selector_audio_output_device_key" translatable="false">pref_selector_audio_output_device_key</string>
<string name="pref_selector_voice_profile_moved_key" translatable="false">pref_selector_voice_profile_moved</string>
<string name="pref_selector_control_telling_time" translatable="false">pref_selector_control_telling_time</string>
<string name="pref_selector_actions_key" translatable="false">pref_selector_actions_key </string>
Expand Down Expand Up @@ -1561,6 +1562,7 @@
<bool name="pref_selector_links_controls_web_only_default">false</bool>
<bool name="pref_selector_tablet_held_up_faces_away_default">false</bool>
<bool name="pref_selector_switch_tts_engine_default">false</bool>
<bool name="pref_selector_audio_output_device_default">false</bool>
<bool name="pref_selector_voice_profile_default">false</bool>
<bool name="pref_selector_touch_focus_latency_default">false</bool>
<bool name="pref_selector_control_telling_time_default">false</bool>
Expand Down Expand Up @@ -1881,6 +1883,7 @@
<string name="selector_lift_to_activate_change" translatable="false">SELECTOR_LIFT_TO_ACTIVATE_CHANGE</string>
<string name="selector_wrap_around" translatable="false">SELECTOR_WRAP_AROUND</string>
<string name="selector_tablet_held_up_faces_away" translatable="false">SELECTOR_TABLET_HELD_UP_FACES_AWAY</string>
<string name="selector_audio_output_device" translatable="false">SELECTOR_AUDIO_OUTPUT_DEVICE</string>
<string name="selector_switch_tts_engine" translatable="false">SELECTOR_SWITCH_TTS_ENGINE</string>
<string name="selector_voice_profile" translatable="false">SELECTOR_VOICE_PROFILE</string>
<string name="selector_touch_focus_latency_change" translatable="false">SELECTOR_TOUCH_FOCUS_LATENCY_CHANGE</string>
Expand Down
5 changes: 5 additions & 0 deletions talkback/src/main/res/xml/selector_menu_preferences.xml
Original file line number Diff line number Diff line change
Expand Up @@ -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"/>
<com.google.android.accessibility.material.preference.AccessibilitySuiteCheckBoxPreference
android:layout="@layout/listitem_2texts"
android:defaultValue="@bool/pref_selector_audio_output_device_default"
android:key="@string/pref_selector_audio_output_device_key"
android:title="@string/title_pref_audio_output_device"/>
<com.google.android.accessibility.material.preference.AccessibilitySuiteCheckBoxPreference
android:layout="@layout/listitem_2texts"
android:defaultValue="@bool/pref_selector_brightness_default"
Expand Down
Original file line number Diff line number Diff line change
@@ -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"))
}
}
Loading