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 4094b1f28..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 @@ -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,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 { 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() + /** + * 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:") } + } - 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 } } } 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..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 @@ -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; @@ -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,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 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. 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"/> +