diff --git a/res/values/strings.xml b/res/values/strings.xml index eb316e0a8eb..ae587496150 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -14081,6 +14081,12 @@ Stop listening Connect compatible headphones + + Turn off Talkback temporarily + + Talkback cannot be used when listening to audio streams. Turn off talkback to start listening. + + Turn off Connect a device diff --git a/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamConfirmDialog.java b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamConfirmDialog.java index d2e288f0d0b..1a9ef619581 100644 --- a/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamConfirmDialog.java +++ b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamConfirmDialog.java @@ -17,6 +17,8 @@ package com.android.settings.connecteddevice.audiosharing.audiostreams; import static com.android.settings.connecteddevice.audiosharing.audiostreams.AudioStreamsDashboardFragment.KEY_BROADCAST_METADATA; +import static com.android.settings.connecteddevice.audiosharing.audiostreams.AudioStreamsHelper.getEnabledScreenReaderServices; +import static com.android.settings.connecteddevice.audiosharing.audiostreams.AudioStreamsHelper.setAccessibilityServiceOff; import static com.android.settingslib.bluetooth.BluetoothBroadcastUtils.SCHEME_BT_BROADCAST_METADATA; import android.app.Activity; @@ -41,6 +43,7 @@ import com.android.settings.core.instrumentation.InstrumentedDialogFragment; import com.android.settingslib.bluetooth.BluetoothLeBroadcastMetadataExt; import com.android.settingslib.bluetooth.BluetoothUtils; import com.android.settingslib.bluetooth.LocalBluetoothLeBroadcastAssistant; +import com.android.settingslib.utils.ThreadUtils; public class AudioStreamConfirmDialog extends InstrumentedDialogFragment { private static final String TAG = "AudioStreamConfirmDialog"; @@ -86,6 +89,8 @@ public class AudioStreamConfirmDialog extends InstrumentedDialogFragment { case SettingsEnums.DIALOG_AUDIO_STREAM_CONFIRM_FEATURE_UNSUPPORTED -> getUnsupportedDialog(); case SettingsEnums.DIALOG_AUDIO_STREAM_CONFIRM_NO_LE_DEVICE -> getNoLeDeviceDialog(); + case SettingsEnums.DIALOG_AUDIO_STREAM_CONFIRM_TURN_OFF_TALKBACK -> + getTurnOffTalkbackDialog(); case SettingsEnums.DIALOG_AUDIO_STREAM_CONFIRM_LISTEN -> getConfirmDialog(); default -> getErrorDialog(); }; @@ -168,6 +173,36 @@ public class AudioStreamConfirmDialog extends InstrumentedDialogFragment { .build(); } + private Dialog getTurnOffTalkbackDialog() { + return new AudioStreamsDialogFragment.DialogBuilder(getActivity()) + .setTitle(getString(R.string.audio_streams_dialog_turn_off_talkback_title)) + .setSubTitle2(getString(R.string.audio_streams_dialog_turn_off_talkback_subtitle)) + .setLeftButtonText(getString(R.string.cancel)) + .setLeftButtonOnClickListener( + unused -> { + dismiss(); + if (mActivity != null) { + mActivity.finish(); + } + }) + .setRightButtonText( + getString(R.string.audio_streams_dialog_turn_off_talkback_button)) + .setRightButtonOnClickListener( + dialog -> { + var unused = ThreadUtils.postOnBackgroundThread(() -> { + var enabledScreenReader = getEnabledScreenReaderServices(mContext); + if (!enabledScreenReader.isEmpty()) { + setAccessibilityServiceOff(mContext, enabledScreenReader); + } + }); + dismiss(); + if (mActivity != null) { + mActivity.finish(); + } + }) + .build(); + } + private Dialog getNoLeDeviceDialog() { return new AudioStreamsDialogFragment.DialogBuilder(getActivity()) .setTitle(getString(R.string.audio_streams_dialog_no_le_device_title)) @@ -234,6 +269,9 @@ public class AudioStreamConfirmDialog extends InstrumentedDialogFragment { if (!hasConnectedDevice) { return SettingsEnums.DIALOG_AUDIO_STREAM_CONFIRM_NO_LE_DEVICE; } + if (!getEnabledScreenReaderServices(mContext).isEmpty()) { + return SettingsEnums.DIALOG_AUDIO_STREAM_CONFIRM_TURN_OFF_TALKBACK; + } return hasMetadata ? SettingsEnums.DIALOG_AUDIO_STREAM_CONFIRM_LISTEN : SettingsEnums.DIALOG_AUDIO_STREAM_CONFIRM_DATA_ERROR; diff --git a/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsHelper.java b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsHelper.java index 0890870442f..12962bf2d6f 100644 --- a/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsHelper.java +++ b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsHelper.java @@ -19,6 +19,7 @@ package com.android.settings.connecteddevice.audiosharing.audiostreams; import static com.android.settings.connecteddevice.audiosharing.audiostreams.AudioStreamMediaService.BROADCAST_ID; import static com.android.settings.connecteddevice.audiosharing.audiostreams.AudioStreamMediaService.BROADCAST_TITLE; import static com.android.settings.connecteddevice.audiosharing.audiostreams.AudioStreamMediaService.DEVICES; +import static com.android.settingslib.accessibility.AccessibilityUtils.setAccessibilityServiceState; import static com.android.settingslib.bluetooth.BluetoothUtils.isAudioSharingHysteresisModeFixAvailable; import static com.android.settingslib.bluetooth.LocalBluetoothLeBroadcastAssistant.LocalBluetoothLeBroadcastSourceState; import static com.android.settingslib.bluetooth.LocalBluetoothLeBroadcastAssistant.LocalBluetoothLeBroadcastSourceState.DECRYPTION_FAILED; @@ -30,15 +31,18 @@ import static java.util.Collections.emptyList; import static java.util.Collections.emptyMap; import static java.util.stream.Collectors.toMap; +import android.accessibilityservice.AccessibilityServiceInfo; import android.bluetooth.BluetoothDevice; import android.bluetooth.BluetoothLeAudioContentMetadata; import android.bluetooth.BluetoothLeBroadcastMetadata; import android.bluetooth.BluetoothLeBroadcastReceiveState; +import android.content.ComponentName; import android.content.Context; import android.content.Intent; import android.content.res.Configuration; import android.util.Log; import android.util.Pair; +import android.view.accessibility.AccessibilityManager; import androidx.annotation.VisibleForTesting; import androidx.fragment.app.FragmentActivity; @@ -56,9 +60,12 @@ import com.google.android.material.appbar.AppBarLayout; import com.google.common.base.Strings; import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Set; import java.util.function.Function; import java.util.stream.Stream; @@ -399,4 +406,55 @@ public class AudioStreamsHelper { } } } + + /** + * Retrieves a set of enabled screen reader services that are pre-installed. + * + *

This method checks the accessibility manager for enabled accessibility services + * and filters them based on a list of pre-installed screen reader service component names + * defined in the {@code config_preinstalled_screen_reader_services} resource array.

+ * + * @param context The context. + * @return A set of {@link ComponentName} objects representing the enabled pre-installed + * screen reader services, or an empty set if no services are found, or if an error occurs. + */ + public static Set getEnabledScreenReaderServices(Context context) { + AccessibilityManager manager = context.getSystemService(AccessibilityManager.class); + if (manager == null) { + return Collections.emptySet(); + } + Set screenReaderServices = new HashSet<>(); + Collections.addAll(screenReaderServices, context.getResources() + .getStringArray(R.array.config_preinstalled_screen_reader_services)); + if (screenReaderServices.isEmpty()) { + return Collections.emptySet(); + } + Set enabledScreenReaderServices = new HashSet<>(); + List enabledServices = manager.getEnabledAccessibilityServiceList( + AccessibilityServiceInfo.FEEDBACK_ALL_MASK); + for (AccessibilityServiceInfo service : enabledServices) { + ComponentName componentName = service.getComponentName(); + if (screenReaderServices.contains(componentName.flattenToString())) { + enabledScreenReaderServices.add(componentName); + } + } + Log.d(TAG, "getEnabledScreenReaderServices(): " + enabledScreenReaderServices); + return enabledScreenReaderServices; + } + + /** + * Turns off the specified accessibility services. + * + * This method iterates through a set of ComponentName objects, each representing an + * accessibility service, and disables them. + * + * @param context The application context. + * @param services A set of ComponentName objects representing the services to disable. + */ + public static void setAccessibilityServiceOff(Context context, Set services) { + for (ComponentName service : services) { + Log.d(TAG, "setScreenReaderOff(): " + service); + setAccessibilityServiceState(context, service, false); + } + } } diff --git a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamConfirmDialogTest.java b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamConfirmDialogTest.java index d22130a7bbe..ac548059853 100644 --- a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamConfirmDialogTest.java +++ b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamConfirmDialogTest.java @@ -34,6 +34,7 @@ import android.app.settings.SettingsEnums; import android.bluetooth.BluetoothAdapter; import android.bluetooth.BluetoothDevice; import android.bluetooth.BluetoothStatusCodes; +import android.content.ComponentName; import android.content.Context; import android.content.Intent; import android.net.Uri; @@ -46,6 +47,7 @@ import androidx.fragment.app.FragmentActivity; import androidx.test.core.app.ApplicationProvider; import com.android.settings.R; +import com.android.settings.connecteddevice.audiosharing.audiostreams.testshadows.ShadowAudioStreamsHelper; import com.android.settings.testutils.shadow.ShadowBluetoothAdapter; import com.android.settings.testutils.shadow.ShadowBluetoothUtils; import com.android.settingslib.bluetooth.LocalBluetoothLeBroadcast; @@ -74,12 +76,15 @@ import java.util.List; @RunWith(RobolectricTestRunner.class) @Config( shadows = { - ShadowBluetoothAdapter.class, - ShadowBluetoothUtils.class, + ShadowBluetoothAdapter.class, + ShadowBluetoothUtils.class, + ShadowAudioStreamsHelper.class, }) public class AudioStreamConfirmDialogTest { - @Rule public final MockitoRule mMockitoRule = MockitoJUnit.rule(); - @Rule public final SetFlagsRule mSetFlagsRule = new SetFlagsRule(); + @Rule + public final MockitoRule mMockitoRule = MockitoJUnit.rule(); + @Rule + public final SetFlagsRule mSetFlagsRule = new SetFlagsRule(); private static final String VALID_METADATA = "BLUETOOTH:UUID:184F;BN:VGVzdA==;AT:1;AD:00A1A1A1A1A1;BI:1E240;BC:VGVzdENvZGU=;" + "MD:BgNwVGVzdA==;AS:1;PI:A0;NS:1;BS:3;NB:2;SM:BQNUZXN0BARlbmc=;;"; @@ -88,12 +93,18 @@ public class AudioStreamConfirmDialogTest { + "MD:BgNwVGVzdA==;AS:1;PI:A0;NS:1;BS:3;NB:2;SM:BQNUZXN0BARlbmc=;;"; private static final String DEVICE_NAME = "device_name"; private final Context mContext = ApplicationProvider.getApplicationContext(); - @Mock private LocalBluetoothManager mLocalBluetoothManager; - @Mock private LocalBluetoothProfileManager mLocalBluetoothProfileManager; - @Mock private LocalBluetoothLeBroadcast mBroadcast; - @Mock private LocalBluetoothLeBroadcastAssistant mAssistant; - @Mock private VolumeControlProfile mVolumeControl; - @Mock private BluetoothDevice mBluetoothDevice; + @Mock + private LocalBluetoothManager mLocalBluetoothManager; + @Mock + private LocalBluetoothProfileManager mLocalBluetoothProfileManager; + @Mock + private LocalBluetoothLeBroadcast mBroadcast; + @Mock + private LocalBluetoothLeBroadcastAssistant mAssistant; + @Mock + private VolumeControlProfile mVolumeControl; + @Mock + private BluetoothDevice mBluetoothDevice; private AudioStreamConfirmDialog mDialogFragment; @Before @@ -376,6 +387,66 @@ public class AudioStreamConfirmDialogTest { verify(mDialogFragment.mActivity, times(2)).finish(); } + @Test + public void showDialog_turnOffTalkback() { + List devices = new ArrayList<>(); + devices.add(mBluetoothDevice); + when(mAssistant.getAllConnectedDevices()).thenReturn(devices); + when(mBluetoothDevice.getAlias()).thenReturn(""); + ShadowAudioStreamsHelper.setEnabledScreenReaderService(new ComponentName("pkg", "class")); + + Intent intent = new Intent(); + intent.putExtra(KEY_BROADCAST_METADATA, VALID_METADATA); + FragmentController.of(mDialogFragment, intent) + .create(/* containerViewId= */ 0, /* bundle= */ null) + .start() + .resume() + .visible() + .get(); + shadowMainLooper().idle(); + + assertThat(mDialogFragment.getMetricsCategory()) + .isEqualTo(SettingsEnums.DIALOG_AUDIO_STREAM_CONFIRM_TURN_OFF_TALKBACK); + assertThat(mDialogFragment.mActivity).isNotNull(); + mDialogFragment.mActivity = spy(mDialogFragment.mActivity); + + var dialog = mDialogFragment.getDialog(); + assertThat(dialog).isNotNull(); + assertThat(dialog.isShowing()).isTrue(); + + TextView title = dialog.findViewById(R.id.dialog_title); + assertThat(title).isNotNull(); + assertThat(title.getText()) + .isEqualTo( + mContext.getString(R.string.audio_streams_dialog_turn_off_talkback_title)); + TextView subtitle1 = dialog.findViewById(R.id.dialog_subtitle); + assertThat(subtitle1).isNotNull(); + assertThat(subtitle1.getVisibility()).isEqualTo(View.GONE); + TextView subtitle2 = dialog.findViewById(R.id.dialog_subtitle_2); + assertThat(subtitle2).isNotNull(); + assertThat(subtitle2.getText()) + .isEqualTo(mContext.getString( + R.string.audio_streams_dialog_turn_off_talkback_subtitle)); + View leftButton = dialog.findViewById(R.id.left_button); + assertThat(leftButton).isNotNull(); + assertThat(leftButton.getVisibility()).isEqualTo(View.VISIBLE); + assertThat(leftButton.hasOnClickListeners()).isTrue(); + + leftButton.callOnClick(); + assertThat(dialog.isShowing()).isFalse(); + + Button rightButton = dialog.findViewById(R.id.right_button); + assertThat(rightButton).isNotNull(); + assertThat(rightButton.getText()) + .isEqualTo( + mContext.getString(R.string.audio_streams_dialog_turn_off_talkback_button)); + assertThat(rightButton.hasOnClickListeners()).isTrue(); + + rightButton.callOnClick(); + assertThat(dialog.isShowing()).isFalse(); + verify(mDialogFragment.mActivity, times(2)).finish(); + } + @Test public void showDialog_getDataStringFromIntent_confirmListen() { List devices = new ArrayList<>(); diff --git a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsHelperTest.java b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsHelperTest.java index ba37c83055a..36276036c6e 100644 --- a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsHelperTest.java +++ b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsHelperTest.java @@ -36,22 +36,28 @@ import static org.mockito.Mockito.spy; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import android.accessibilityservice.AccessibilityServiceInfo; import android.bluetooth.BluetoothAdapter; import android.bluetooth.BluetoothDevice; import android.bluetooth.BluetoothLeBroadcastMetadata; import android.bluetooth.BluetoothLeBroadcastReceiveState; import android.bluetooth.BluetoothStatusCodes; +import android.content.ComponentName; import android.content.Context; import android.content.res.Configuration; import android.content.res.Resources; +import android.os.UserHandle; import android.platform.test.flag.junit.SetFlagsRule; +import android.view.accessibility.AccessibilityManager; import androidx.fragment.app.FragmentActivity; import androidx.test.core.app.ApplicationProvider; import com.android.settings.R; +import com.android.settings.testutils.shadow.ShadowAccessibilityManager; import com.android.settings.testutils.shadow.ShadowBluetoothAdapter; import com.android.settings.testutils.shadow.ShadowThreadUtils; +import com.android.settingslib.accessibility.AccessibilityUtils; import com.android.settingslib.bluetooth.CachedBluetoothDevice; import com.android.settingslib.bluetooth.CachedBluetoothDeviceManager; import com.android.settingslib.bluetooth.LocalBluetoothLeBroadcastAssistant; @@ -75,11 +81,14 @@ import org.robolectric.shadow.api.Shadow; import java.util.ArrayList; import java.util.Collections; +import java.util.HashSet; import java.util.List; +import java.util.Set; @RunWith(RobolectricTestRunner.class) @Config( shadows = { + ShadowAccessibilityManager.class, ShadowThreadUtils.class, ShadowBluetoothAdapter.class, }) @@ -100,11 +109,17 @@ public class AudioStreamsHelperTest { @Mock private CachedBluetoothDevice mCachedDevice; @Mock private BluetoothDevice mDevice; @Mock private BluetoothDevice mSourceDevice; + @Mock + private AccessibilityServiceInfo mTalkbackServiceInfo; + private ShadowAccessibilityManager mShadowAccessibilityManager; private AudioStreamsHelper mHelper; @Before public void setUp() { mSetFlagsRule.disableFlags(FLAG_AUDIO_SHARING_HYSTERESIS_MODE_FIX); + mShadowAccessibilityManager = Shadow.extract( + mContext.getSystemService(AccessibilityManager.class)); + mShadowAccessibilityManager.setEnabledAccessibilityServiceList(new ArrayList<>()); ShadowBluetoothAdapter shadowBluetoothAdapter = Shadow.extract( BluetoothAdapter.getDefaultAdapter()); shadowBluetoothAdapter.setEnabled(true); @@ -348,6 +363,54 @@ public class AudioStreamsHelperTest { verify(appBarLayout).setExpanded(eq(true)); } + @Test + public void getEnabledScreenReaderServices_noAccessibilityManager_returnEmpty() { + mShadowAccessibilityManager = null; + Set result = AudioStreamsHelper.getEnabledScreenReaderServices(mContext); + + assertThat(result).isEmpty(); + } + + @Test + public void getEnabledScreenReaderServices_notEnabled_returnEmpty() { + Resources resources = spy(mContext.getResources()); + when(mContext.getResources()).thenReturn(resources); + when(resources.getStringArray(R.array.config_preinstalled_screen_reader_services)) + .thenReturn(new String[]{"pkg/serviceClassName"}); + mShadowAccessibilityManager.setEnabledAccessibilityServiceList( + new ArrayList<>()); + Set result = AudioStreamsHelper.getEnabledScreenReaderServices(mContext); + + assertThat(result).isEmpty(); + } + + @Test + public void getEnabledScreenReaderServices_enabled_returnService() { + Resources resources = spy(mContext.getResources()); + when(mContext.getResources()).thenReturn(resources); + when(resources.getStringArray(R.array.config_preinstalled_screen_reader_services)) + .thenReturn(new String[]{"pkg/serviceClassName"}); + ComponentName expected = new ComponentName("pkg", "serviceClassName"); + when(mTalkbackServiceInfo.getComponentName()).thenReturn(expected); + mShadowAccessibilityManager.setEnabledAccessibilityServiceList( + new ArrayList<>(List.of(mTalkbackServiceInfo))); + Set result = AudioStreamsHelper.getEnabledScreenReaderServices(mContext); + + assertThat(result).isNotEmpty(); + assertThat(result.iterator().next()).isEqualTo(expected); + } + + @Test + public void setAccessibilityServiceOff_valueOff() { + ComponentName componentName = new ComponentName("pkg", "serviceClassName"); + var target = new HashSet(); + target.add(componentName); + AudioStreamsHelper.setAccessibilityServiceOff(mContext, target); + + assertThat(AccessibilityUtils.getEnabledServicesFromSettings(mContext, + UserHandle.myUserId())).isEmpty(); + } + private void setUpFragment( FragmentActivity fragmentActivity, AppBarLayout appBarLayout, int orientationPortrait) { Resources resources = mock(Resources.class); diff --git a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/testshadows/ShadowAudioStreamsHelper.java b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/testshadows/ShadowAudioStreamsHelper.java index 1d3c7a0ddd6..1b79b96668b 100644 --- a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/testshadows/ShadowAudioStreamsHelper.java +++ b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/testshadows/ShadowAudioStreamsHelper.java @@ -21,6 +21,8 @@ import static com.android.settingslib.bluetooth.LocalBluetoothLeBroadcastAssista import android.bluetooth.BluetoothDevice; import android.bluetooth.BluetoothLeBroadcastMetadata; import android.bluetooth.BluetoothLeBroadcastReceiveState; +import android.content.ComponentName; +import android.content.Context; import androidx.annotation.Nullable; @@ -33,14 +35,17 @@ import org.robolectric.annotation.Implementation; import org.robolectric.annotation.Implements; import org.robolectric.annotation.Resetter; +import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Set; @Implements(value = AudioStreamsHelper.class, callThroughByDefault = true) public class ShadowAudioStreamsHelper { private static AudioStreamsHelper sMockHelper; @Nullable private static CachedBluetoothDevice sCachedBluetoothDevice; + @Nullable private static ComponentName sEnabledScreenReaderService; public static void setUseMock(AudioStreamsHelper mockAudioStreamsHelper) { sMockHelper = mockAudioStreamsHelper; @@ -51,6 +56,7 @@ public class ShadowAudioStreamsHelper { public static void reset() { sMockHelper = null; sCachedBluetoothDevice = null; + sEnabledScreenReaderService = null; } public static void setCachedBluetoothDeviceInSharingOrLeConnected( @@ -58,6 +64,10 @@ public class ShadowAudioStreamsHelper { sCachedBluetoothDevice = cachedBluetoothDevice; } + public static void setEnabledScreenReaderService(ComponentName componentName) { + sEnabledScreenReaderService = componentName; + } + @Implementation public Map getConnectedBroadcastIdAndState( boolean hysteresisModeFixAvailable) { @@ -76,6 +86,15 @@ public class ShadowAudioStreamsHelper { return Optional.ofNullable(sCachedBluetoothDevice); } + /** Retrieves a set of enabled screen reader services that are pre-installed. */ + @Implementation + public static Set getEnabledScreenReaderServices(Context context) { + if (sEnabledScreenReaderService != null) { + return Set.of(sEnabledScreenReaderService); + } + return Collections.emptySet(); + } + @Implementation public LocalBluetoothLeBroadcastAssistant getLeBroadcastAssistant() { return sMockHelper.getLeBroadcastAssistant();