From e0abdc5c7aa5bc1eee13b1e56f51e538282a3224 Mon Sep 17 00:00:00 2001 From: chelseahao Date: Fri, 21 Jun 2024 14:14:12 +0800 Subject: [PATCH] [Audiosharing] Created test for the main controller. Test: atest -c com.android.settings.connecteddevice.audiosharing.audiostreams Flag: com.android.settingslib.flags.enable_le_audio_qr_code_private_broadcast_sharing Bug: 345686602 Change-Id: Ieb735d392607c131c213be90cd72c4b7a9ed958d --- ...udioStreamsProgressCategoryController.java | 46 +- .../audiostreams/SourceAddedState.java | 6 +- ...StreamsProgressCategoryControllerTest.java | 671 ++++++++++++++++++ .../testshadows/ShadowAudioStreamsHelper.java | 2 +- 4 files changed, 712 insertions(+), 13 deletions(-) create mode 100644 tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsProgressCategoryControllerTest.java diff --git a/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsProgressCategoryController.java b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsProgressCategoryController.java index 890879e817c..9bbf135285c 100644 --- a/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsProgressCategoryController.java +++ b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsProgressCategoryController.java @@ -20,6 +20,7 @@ import static java.util.Collections.emptyList; import android.app.AlertDialog; import android.app.settings.SettingsEnums; +import android.bluetooth.BluetoothAdapter; import android.bluetooth.BluetoothLeBroadcastMetadata; import android.bluetooth.BluetoothLeBroadcastReceiveState; import android.bluetooth.BluetoothProfile; @@ -27,10 +28,12 @@ import android.content.Context; import android.util.Log; import androidx.annotation.NonNull; +import androidx.fragment.app.Fragment; import androidx.lifecycle.DefaultLifecycleObserver; import androidx.lifecycle.LifecycleOwner; import androidx.preference.PreferenceScreen; +import com.android.internal.annotations.VisibleForTesting; import com.android.settings.R; import com.android.settings.bluetooth.Utils; import com.android.settings.connecteddevice.ConnectedDeviceDashboardFragment; @@ -55,13 +58,35 @@ public class AudioStreamsProgressCategoryController extends BasePreferenceContro implements DefaultLifecycleObserver { private static final String TAG = "AudioStreamsProgressCategoryController"; private static final boolean DEBUG = BluetoothUtils.D; - private static final int UNSET_BROADCAST_ID = -1; - private final BluetoothCallback mBluetoothCallback = + @VisibleForTesting static final int UNSET_BROADCAST_ID = -1; + + @VisibleForTesting + final BluetoothCallback mBluetoothCallback = new BluetoothCallback() { @Override - public void onActiveDeviceChanged( - @Nullable CachedBluetoothDevice activeDevice, int bluetoothProfile) { - if (bluetoothProfile == BluetoothProfile.LE_AUDIO) { + public void onBluetoothStateChanged(@AdapterState int bluetoothState) { + Log.d(TAG, "onBluetoothStateChanged() with bluetoothState : " + bluetoothState); + if (bluetoothState == BluetoothAdapter.STATE_OFF) { + mExecutor.execute(() -> init()); + } + } + + @Override + public void onProfileConnectionStateChanged( + @NonNull CachedBluetoothDevice cachedDevice, + @ConnectionState int state, + int bluetoothProfile) { + Log.d( + TAG, + "onProfileConnectionStateChanged() with cachedDevice : " + + cachedDevice.getAddress() + + " with state : " + + state + + " on profile : " + + bluetoothProfile); + if (bluetoothProfile == BluetoothProfile.LE_AUDIO_BROADCAST_ASSISTANT + && (state == BluetoothAdapter.STATE_CONNECTED + || state == BluetoothAdapter.STATE_DISCONNECTED)) { mExecutor.execute(() -> init()); } } @@ -92,7 +117,7 @@ public class AudioStreamsProgressCategoryController extends BasePreferenceContro SOURCE_ADDED, } - private final Executor mExecutor; + @VisibleForTesting Executor mExecutor; private final AudioStreamsProgressCategoryCallback mBroadcastAssistantCallback; private final AudioStreamsHelper mAudioStreamsHelper; private final MediaControlHelper mMediaControlHelper; @@ -103,7 +128,7 @@ public class AudioStreamsProgressCategoryController extends BasePreferenceContro private @Nullable BluetoothLeBroadcastMetadata mSourceFromQrCode; private SourceOriginForLogging mSourceFromQrCodeOriginForLogging; @Nullable private AudioStreamsProgressCategoryPreference mCategoryPreference; - @Nullable private AudioStreamsDashboardFragment mFragment; + @Nullable private Fragment mFragment; public AudioStreamsProgressCategoryController(Context context, String preferenceKey) { super(context, preferenceKey); @@ -142,12 +167,12 @@ public class AudioStreamsProgressCategoryController extends BasePreferenceContro mExecutor.execute(this::stopScanning); } - void setFragment(AudioStreamsDashboardFragment fragment) { + void setFragment(Fragment fragment) { mFragment = fragment; } @Nullable - AudioStreamsDashboardFragment getFragment() { + Fragment getFragment() { return mFragment; } @@ -546,7 +571,8 @@ public class AudioStreamsProgressCategoryController extends BasePreferenceContro return preference; } - private void moveToState(AudioStreamPreference preference, AudioStreamState state) { + @VisibleForTesting + void moveToState(AudioStreamPreference preference, AudioStreamState state) { AudioStreamStateHandler stateHandler = switch (state) { case SYNCED -> SyncedState.getInstance(); diff --git a/src/com/android/settings/connecteddevice/audiosharing/audiostreams/SourceAddedState.java b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/SourceAddedState.java index 4f36db9363c..88393ab1b67 100644 --- a/src/com/android/settings/connecteddevice/audiosharing/audiostreams/SourceAddedState.java +++ b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/SourceAddedState.java @@ -25,6 +25,7 @@ import androidx.preference.Preference; import com.android.settings.R; import com.android.settings.core.SubSettingLauncher; +import com.android.settings.dashboard.DashboardFragment; class SourceAddedState extends AudioStreamStateHandler { @VisibleForTesting @@ -84,9 +85,10 @@ class SourceAddedState extends AudioStreamStateHandler { .setTitleRes(R.string.audio_streams_detail_page_title) .setDestination(AudioStreamDetailsFragment.class.getName()) .setSourceMetricsCategory( - controller.getFragment() == null + !(controller.getFragment() instanceof DashboardFragment) ? SettingsEnums.PAGE_UNKNOWN - : controller.getFragment().getMetricsCategory()) + : ((DashboardFragment) controller.getFragment()) + .getMetricsCategory()) .setArguments(broadcast) .launch(); return true; diff --git a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsProgressCategoryControllerTest.java b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsProgressCategoryControllerTest.java new file mode 100644 index 00000000000..d43ec81ddaf --- /dev/null +++ b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsProgressCategoryControllerTest.java @@ -0,0 +1,671 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * 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.android.settings.connecteddevice.audiosharing.audiostreams; + +import static com.android.settings.connecteddevice.audiosharing.audiostreams.AudioStreamsProgressCategoryController.AudioStreamState.ADD_SOURCE_BAD_CODE; +import static com.android.settings.connecteddevice.audiosharing.audiostreams.AudioStreamsProgressCategoryController.AudioStreamState.ADD_SOURCE_FAILED; +import static com.android.settings.connecteddevice.audiosharing.audiostreams.AudioStreamsProgressCategoryController.AudioStreamState.ADD_SOURCE_WAIT_FOR_RESPONSE; +import static com.android.settings.connecteddevice.audiosharing.audiostreams.AudioStreamsProgressCategoryController.AudioStreamState.SOURCE_ADDED; +import static com.android.settings.connecteddevice.audiosharing.audiostreams.AudioStreamsProgressCategoryController.AudioStreamState.SYNCED; +import static com.android.settings.connecteddevice.audiosharing.audiostreams.AudioStreamsProgressCategoryController.AudioStreamState.WAIT_FOR_SYNC; +import static com.android.settings.connecteddevice.audiosharing.audiostreams.AudioStreamsProgressCategoryController.UNSET_BROADCAST_ID; +import static com.android.settings.core.BasePreferenceController.AVAILABLE; + +import static com.google.common.truth.Truth.assertThat; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.robolectric.Shadows.shadowOf; + +import static java.util.Collections.emptyList; + +import android.bluetooth.BluetoothAdapter; +import android.bluetooth.BluetoothLeAudioContentMetadata; +import android.bluetooth.BluetoothLeBroadcastMetadata; +import android.bluetooth.BluetoothLeBroadcastReceiveState; +import android.bluetooth.BluetoothProfile; +import android.content.Context; +import android.os.Looper; +import android.view.View; +import android.widget.Button; +import android.widget.TextView; + +import androidx.fragment.app.Fragment; +import androidx.lifecycle.LifecycleOwner; +import androidx.preference.PreferenceScreen; +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.ShadowBluetoothUtils; +import com.android.settings.testutils.shadow.ShadowThreadUtils; +import com.android.settingslib.bluetooth.BluetoothEventManager; +import com.android.settingslib.bluetooth.BluetoothLeBroadcastMetadataExt; +import com.android.settingslib.bluetooth.CachedBluetoothDevice; +import com.android.settingslib.bluetooth.LocalBluetoothLeBroadcastAssistant; +import com.android.settingslib.bluetooth.LocalBluetoothManager; +import com.android.settingslib.core.lifecycle.Lifecycle; + +import com.google.common.collect.ImmutableList; + +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; +import org.robolectric.shadows.ShadowAlertDialog; +import org.robolectric.shadows.androidx.fragment.FragmentController; + +import java.util.ArrayList; +import java.util.List; + +@RunWith(RobolectricTestRunner.class) +@Config( + shadows = { + ShadowBluetoothUtils.class, + ShadowAudioStreamsHelper.class, + ShadowThreadUtils.class, + ShadowAlertDialog.class, + }) +public class AudioStreamsProgressCategoryControllerTest { + @Rule public final MockitoRule mMockitoRule = MockitoJUnit.rule(); + 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=;;"; + private static final String KEY = "audio_streams_nearby_category"; + private static final int QR_CODE_BROADCAST_ID = 1; + private static final int ALREADY_CONNECTED_BROADCAST_ID = 2; + private static final int NEWLY_FOUND_BROADCAST_ID = 3; + private static final String BROADCAST_NAME_1 = "name_1"; + private static final String BROADCAST_NAME_2 = "name_2"; + private static final byte[] BROADCAST_CODE = new byte[] {1}; + private final Context mContext = ApplicationProvider.getApplicationContext(); + @Mock private LocalBluetoothManager mLocalBtManager; + @Mock private BluetoothEventManager mBluetoothEventManager; + @Mock private PreferenceScreen mScreen; + @Mock private AudioStreamsHelper mAudioStreamsHelper; + @Mock private LocalBluetoothLeBroadcastAssistant mLeBroadcastAssistant; + @Mock private BluetoothLeBroadcastMetadata mMetadata; + @Mock private CachedBluetoothDevice mDevice; + @Mock private AudioStreamsProgressCategoryPreference mPreference; + private Lifecycle mLifecycle; + private LifecycleOwner mLifecycleOwner; + private Fragment mFragment; + private TestController mController; + + @Before + public void setUp() { + ShadowAudioStreamsHelper.setUseMock(mAudioStreamsHelper); + when(mAudioStreamsHelper.getLeBroadcastAssistant()).thenReturn(mLeBroadcastAssistant); + when(mAudioStreamsHelper.getAllConnectedSources()).thenReturn(emptyList()); + + ShadowBluetoothUtils.sLocalBluetoothManager = mLocalBtManager; + when(mLocalBtManager.getEventManager()).thenReturn(mBluetoothEventManager); + when(mLeBroadcastAssistant.isSearchInProgress()).thenReturn(false); + + when(mScreen.findPreference(anyString())).thenReturn(mPreference); + + mLifecycleOwner = () -> mLifecycle; + mLifecycle = new Lifecycle(mLifecycleOwner); + + mFragment = new Fragment(); + mController = spy(new TestController(mContext, KEY)); + } + + @After + public void tearDown() { + ShadowBluetoothUtils.reset(); + ShadowAudioStreamsHelper.reset(); + } + + @Test + public void testGetAvailabilityStatus() { + int status = mController.getAvailabilityStatus(); + + assertThat(status).isEqualTo(AVAILABLE); + } + + @Test + public void testDisplayPreference() { + mController.displayPreference(mScreen); + + verify(mPreference).setVisible(true); + } + + @Test + public void testSetScanning() { + mController.displayPreference(mScreen); + mController.setScanning(true); + + verify(mPreference).setProgress(true); + } + + @Test + public void testOnStart_initNoDevice_showDialog() { + when(mLeBroadcastAssistant.isSearchInProgress()).thenReturn(true); + + FragmentController.setupFragment(mFragment); + mController.setFragment(mFragment); + mController.displayPreference(mScreen); + mController.onStart(mLifecycleOwner); + shadowOf(Looper.getMainLooper()).idle(); + + // Called twice, once in displayPreference, the other in init() + verify(mPreference, times(2)).setVisible(anyBoolean()); + verify(mPreference).removeAudioStreamPreferences(); + verify(mLeBroadcastAssistant).stopSearchingForSources(); + verify(mLeBroadcastAssistant).unregisterServiceCallBack(any()); + + var dialog = ShadowAlertDialog.getLatestAlertDialog(); + 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_no_le_device_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_no_le_device_subtitle)); + View leftButton = dialog.findViewById(R.id.left_button); + assertThat(leftButton).isNotNull(); + assertThat(leftButton.getVisibility()).isEqualTo(View.VISIBLE); + Button rightButton = dialog.findViewById(R.id.right_button); + assertThat(rightButton).isNotNull(); + assertThat(rightButton.getText()) + .isEqualTo(mContext.getString(R.string.audio_streams_dialog_no_le_device_button)); + assertThat(rightButton.hasOnClickListeners()).isTrue(); + + dialog.cancel(); + } + + @Test + public void testBluetoothOff_triggerRunnable() { + mController.mBluetoothCallback.onBluetoothStateChanged(BluetoothAdapter.STATE_OFF); + + verify(mController.mExecutor).execute(any()); + } + + @Test + public void testDeviceConnectionStateChanged_triggerRunnable() { + mController.mBluetoothCallback.onProfileConnectionStateChanged( + mDevice, + BluetoothAdapter.STATE_DISCONNECTED, + BluetoothProfile.LE_AUDIO_BROADCAST_ASSISTANT); + + verify(mController.mExecutor).execute(any()); + } + + @Test + public void testOnStart_initHasDevice_noPreference() { + // Setup a device + ShadowAudioStreamsHelper.setCachedBluetoothDeviceInSharingOrLeConnected(mDevice); + + mController.onStart(mLifecycleOwner); + shadowOf(Looper.getMainLooper()).idle(); + + verify(mLeBroadcastAssistant).registerServiceCallBack(any(), any()); + verify(mLeBroadcastAssistant).startSearchingForSources(any()); + + var dialog = ShadowAlertDialog.getLatestAlertDialog(); + assertThat(dialog).isNull(); + + verify(mController, never()).moveToState(any(), any()); + } + + @Test + public void testOnStart_handleSourceFromQrCode() { + // Setup a device + ShadowAudioStreamsHelper.setCachedBluetoothDeviceInSharingOrLeConnected(mDevice); + + // Setup a source from qr code + mController.setSourceFromQrCode(mMetadata, SourceOriginForLogging.UNKNOWN); + when(mMetadata.getBroadcastId()).thenReturn(QR_CODE_BROADCAST_ID); + + // Handle the source from qr code in onStart + mController.displayPreference(mScreen); + mController.onStart(mLifecycleOwner); + shadowOf(Looper.getMainLooper()).idle(); + + // Verify the connected source is created and moved to WAIT_FOR_SYNC + ArgumentCaptor preference = + ArgumentCaptor.forClass(AudioStreamPreference.class); + ArgumentCaptor state = + ArgumentCaptor.forClass( + AudioStreamsProgressCategoryController.AudioStreamState.class); + + verify(mController).moveToState(preference.capture(), state.capture()); + assertThat(preference.getValue()).isNotNull(); + assertThat(preference.getValue().getAudioStreamBroadcastId()) + .isEqualTo(QR_CODE_BROADCAST_ID); + assertThat(state.getValue()).isEqualTo(WAIT_FOR_SYNC); + } + + @Test + public void testOnStart_handleSourceAlreadyConnected() { + // Setup a device + ShadowAudioStreamsHelper.setCachedBluetoothDeviceInSharingOrLeConnected(mDevice); + + // Setup a connected source + BluetoothLeBroadcastReceiveState connected = + createConnectedMock(ALREADY_CONNECTED_BROADCAST_ID); + List list = new ArrayList<>(); + list.add(connected); + when(mAudioStreamsHelper.getAllConnectedSources()).thenReturn(list); + + // Handle already connected source in onStart + mController.displayPreference(mScreen); + mController.onStart(mLifecycleOwner); + shadowOf(Looper.getMainLooper()).idle(); + + ArgumentCaptor preference = + ArgumentCaptor.forClass(AudioStreamPreference.class); + ArgumentCaptor state = + ArgumentCaptor.forClass( + AudioStreamsProgressCategoryController.AudioStreamState.class); + + // Verify the connected source is created and moved to SOURCE_ADDED + verify(mController).moveToState(preference.capture(), state.capture()); + assertThat(preference.getValue()).isNotNull(); + assertThat(preference.getValue().getAudioStreamBroadcastId()) + .isEqualTo(ALREADY_CONNECTED_BROADCAST_ID); + assertThat(state.getValue()).isEqualTo(SOURCE_ADDED); + } + + @Test + public void testOnStart_sourceFromQrCodeNoId_sourceAlreadyConnected_sameName_updateId() { + // Setup a device + ShadowAudioStreamsHelper.setCachedBluetoothDeviceInSharingOrLeConnected(mDevice); + + // Setup source from qr code with unset id and BROADCAST_NAME_1. Creating a real metadata + // for properly update its id. + var metadata = + BluetoothLeBroadcastMetadataExt.INSTANCE.convertToBroadcastMetadata(VALID_METADATA); + assertThat(metadata).isNotNull(); + var metadataWithNoIdAndSameName = + new BluetoothLeBroadcastMetadata.Builder(metadata) + .setBroadcastId(UNSET_BROADCAST_ID) + .setBroadcastName(BROADCAST_NAME_1) + .build(); + mController.setSourceFromQrCode( + metadataWithNoIdAndSameName, SourceOriginForLogging.UNKNOWN); + + // Setup a connected source with name BROADCAST_NAME_1 and id + BluetoothLeBroadcastReceiveState connected = + createConnectedMock(ALREADY_CONNECTED_BROADCAST_ID); + var data = mock(BluetoothLeAudioContentMetadata.class); + when(connected.getSubgroupMetadata()).thenReturn(ImmutableList.of(data)); + when(data.getProgramInfo()).thenReturn(BROADCAST_NAME_1); + when(mAudioStreamsHelper.getAllConnectedSources()).thenReturn(ImmutableList.of(connected)); + + // Handle both source from qr code and already connected source in onStart + mController.displayPreference(mScreen); + mController.onStart(mLifecycleOwner); + shadowOf(Looper.getMainLooper()).idle(); + + // Verify two preferences created, one moved to state WAIT_FOR_SYNC, one to SOURCE_ADDED. + // Both has ALREADY_CONNECTED_BROADCAST_ID as the UNSET_ID is updated to match. + ArgumentCaptor preference = + ArgumentCaptor.forClass(AudioStreamPreference.class); + ArgumentCaptor state = + ArgumentCaptor.forClass( + AudioStreamsProgressCategoryController.AudioStreamState.class); + verify(mController, times(2)).moveToState(preference.capture(), state.capture()); + + List preferences = preference.getAllValues(); + assertThat(preferences.size()).isEqualTo(2); + List states = state.getAllValues(); + assertThat(states.size()).isEqualTo(2); + + // The preference contains source from qr code + assertThat(preferences.get(0).getAudioStreamBroadcastId()) + .isEqualTo(ALREADY_CONNECTED_BROADCAST_ID); + assertThat(states.get(0)).isEqualTo(WAIT_FOR_SYNC); + + // The preference contains already connected source + assertThat(preferences.get(1).getAudioStreamBroadcastId()) + .isEqualTo(ALREADY_CONNECTED_BROADCAST_ID); + assertThat(states.get(1)).isEqualTo(SOURCE_ADDED); + } + + @Test + public void testHandleSourceFound_addNew() { + // Setup a device + ShadowAudioStreamsHelper.setCachedBluetoothDeviceInSharingOrLeConnected(mDevice); + + when(mMetadata.getBroadcastId()).thenReturn(NEWLY_FOUND_BROADCAST_ID); + // A new source is found + mController.handleSourceFound(mMetadata); + + // Verify a preference is created with state SYNCED. + ArgumentCaptor preference = + ArgumentCaptor.forClass(AudioStreamPreference.class); + ArgumentCaptor state = + ArgumentCaptor.forClass( + AudioStreamsProgressCategoryController.AudioStreamState.class); + + verify(mController).moveToState(preference.capture(), state.capture()); + assertThat(preference.getValue()).isNotNull(); + assertThat(preference.getValue().getAudioStreamBroadcastId()) + .isEqualTo(NEWLY_FOUND_BROADCAST_ID); + assertThat(state.getValue()).isEqualTo(SYNCED); + } + + @Test + public void testHandleSourceFound_sameIdWithSourceFromQrCode_updateMetadataAndState() { + // Setup a device + ShadowAudioStreamsHelper.setCachedBluetoothDeviceInSharingOrLeConnected(mDevice); + + // Setup source from qr code with QR_CODE_BROADCAST_ID, BROADCAST_NAME_1 and BROADCAST_CODE. + var metadata = + BluetoothLeBroadcastMetadataExt.INSTANCE.convertToBroadcastMetadata(VALID_METADATA); + assertThat(metadata).isNotNull(); + var metadataFromQrCode = + new BluetoothLeBroadcastMetadata.Builder(metadata) + .setBroadcastId(QR_CODE_BROADCAST_ID) + .setBroadcastName(BROADCAST_NAME_1) + .setBroadcastCode(BROADCAST_CODE) + .build(); + mController.setSourceFromQrCode(metadataFromQrCode, SourceOriginForLogging.UNKNOWN); + + // Handle the source from qr code in onStart + mController.displayPreference(mScreen); + mController.onStart(mLifecycleOwner); + shadowOf(Looper.getMainLooper()).idle(); + + // A new source is found + mController.handleSourceFound( + new BluetoothLeBroadcastMetadata.Builder(metadata) + .setBroadcastId(QR_CODE_BROADCAST_ID) + .setBroadcastName(BROADCAST_NAME_2) + .build()); + shadowOf(Looper.getMainLooper()).idle(); + + ArgumentCaptor preference = + ArgumentCaptor.forClass(AudioStreamPreference.class); + ArgumentCaptor state = + ArgumentCaptor.forClass( + AudioStreamsProgressCategoryController.AudioStreamState.class); + + verify(mController, times(2)).moveToState(preference.capture(), state.capture()); + List preferences = preference.getAllValues(); + List states = state.getAllValues(); + + // Verify the qr code source is created with WAIT_FOR_SYNC, broadcast name got updated to + // BROADCAST_NAME_2 + var sourceFromQrCode = preferences.get(0); + assertThat(sourceFromQrCode.getAudioStreamBroadcastId()).isEqualTo(QR_CODE_BROADCAST_ID); + assertThat(sourceFromQrCode.getAudioStreamMetadata()).isNotNull(); + assertThat(sourceFromQrCode.getAudioStreamMetadata().getBroadcastName()) + .isEqualTo(BROADCAST_NAME_2); + assertThat(sourceFromQrCode.getAudioStreamMetadata().getBroadcastCode()) + .isEqualTo(BROADCAST_CODE); + assertThat(states.get(0)).isEqualTo(WAIT_FOR_SYNC); + + // Verify the newly found source is created, broadcast code is retrieved from the source + // from qr code, and state updated to ADD_SOURCE_WAIT_FOR_RESPONSE + var newlyFoundSource = preferences.get(1); + assertThat(newlyFoundSource.getAudioStreamBroadcastId()).isEqualTo(QR_CODE_BROADCAST_ID); + assertThat(newlyFoundSource.getAudioStreamMetadata()).isNotNull(); + assertThat(newlyFoundSource.getAudioStreamMetadata().getBroadcastName()) + .isEqualTo(BROADCAST_NAME_2); + assertThat(newlyFoundSource.getAudioStreamMetadata().getBroadcastCode()) + .isEqualTo(BROADCAST_CODE); + assertThat(states.get(1)).isEqualTo(ADD_SOURCE_WAIT_FOR_RESPONSE); + } + + @Test + public void testHandleSourceFound_sameIdWithOtherState_doNothing() { + // Setup a device + ShadowAudioStreamsHelper.setCachedBluetoothDeviceInSharingOrLeConnected(mDevice); + + // Setup source already connected + BluetoothLeBroadcastReceiveState connected = + createConnectedMock(ALREADY_CONNECTED_BROADCAST_ID); + when(mAudioStreamsHelper.getAllConnectedSources()).thenReturn(ImmutableList.of(connected)); + + // Handle source already connected in onStart + mController.displayPreference(mScreen); + mController.onStart(mLifecycleOwner); + shadowOf(Looper.getMainLooper()).idle(); + + // A new source found + when(mMetadata.getBroadcastId()).thenReturn(ALREADY_CONNECTED_BROADCAST_ID); + mController.handleSourceFound(mMetadata); + shadowOf(Looper.getMainLooper()).idle(); + + // Verify only the connected source has created a preference, and its state remains as + // SOURCE_ADDED + ArgumentCaptor preference = + ArgumentCaptor.forClass(AudioStreamPreference.class); + ArgumentCaptor state = + ArgumentCaptor.forClass( + AudioStreamsProgressCategoryController.AudioStreamState.class); + + verify(mController).moveToState(preference.capture(), state.capture()); + assertThat(preference.getValue()).isNotNull(); + assertThat(preference.getValue().getAudioStreamBroadcastId()) + .isEqualTo(ALREADY_CONNECTED_BROADCAST_ID); + assertThat(preference.getValue().getAudioStreamState()).isEqualTo(SOURCE_ADDED); + } + + @Test + public void testHandleSourceLost_removed() { + // Setup a device + ShadowAudioStreamsHelper.setCachedBluetoothDeviceInSharingOrLeConnected(mDevice); + + // Setup mPreference so it's not null + mController.displayPreference(mScreen); + + // A new source found + when(mMetadata.getBroadcastId()).thenReturn(NEWLY_FOUND_BROADCAST_ID); + mController.handleSourceFound(mMetadata); + shadowOf(Looper.getMainLooper()).idle(); + + // A new source found is lost + mController.handleSourceLost(NEWLY_FOUND_BROADCAST_ID); + shadowOf(Looper.getMainLooper()).idle(); + + ArgumentCaptor preferenceToAdd = + ArgumentCaptor.forClass(AudioStreamPreference.class); + ArgumentCaptor preferenceToRemove = + ArgumentCaptor.forClass(AudioStreamPreference.class); + ArgumentCaptor state = + ArgumentCaptor.forClass( + AudioStreamsProgressCategoryController.AudioStreamState.class); + + // Verify a new preference is created with state SYNCED. + verify(mController).moveToState(preferenceToAdd.capture(), state.capture()); + assertThat(preferenceToAdd.getValue()).isNotNull(); + assertThat(preferenceToAdd.getValue().getAudioStreamBroadcastId()) + .isEqualTo(NEWLY_FOUND_BROADCAST_ID); + assertThat(state.getValue()).isEqualTo(SYNCED); + + // Verify the preference with NEWLY_FOUND_BROADCAST_ID is removed. + verify(mPreference).removePreference(preferenceToRemove.capture()); + assertThat(preferenceToRemove.getValue().getAudioStreamBroadcastId()) + .isEqualTo(NEWLY_FOUND_BROADCAST_ID); + } + + @Test + public void testHandleSourceRemoved_removed() { + // Setup a device + ShadowAudioStreamsHelper.setCachedBluetoothDeviceInSharingOrLeConnected(mDevice); + + // Setup already connected source + BluetoothLeBroadcastReceiveState connected = + createConnectedMock(ALREADY_CONNECTED_BROADCAST_ID); + when(mAudioStreamsHelper.getAllConnectedSources()).thenReturn(ImmutableList.of(connected)); + + // Handle connected source in onStart + mController.displayPreference(mScreen); + mController.onStart(mLifecycleOwner); + shadowOf(Looper.getMainLooper()).idle(); + + // The connect source is no longer connected + when(mAudioStreamsHelper.getAllConnectedSources()).thenReturn(emptyList()); + mController.handleSourceRemoved(); + shadowOf(Looper.getMainLooper()).idle(); + + ArgumentCaptor preferenceToAdd = + ArgumentCaptor.forClass(AudioStreamPreference.class); + ArgumentCaptor preferenceToRemove = + ArgumentCaptor.forClass(AudioStreamPreference.class); + ArgumentCaptor state = + ArgumentCaptor.forClass( + AudioStreamsProgressCategoryController.AudioStreamState.class); + + // Verify a new preference is created with state SOURCE_ADDED. + verify(mController).moveToState(preferenceToAdd.capture(), state.capture()); + assertThat(preferenceToAdd.getValue()).isNotNull(); + assertThat(preferenceToAdd.getValue().getAudioStreamBroadcastId()) + .isEqualTo(ALREADY_CONNECTED_BROADCAST_ID); + assertThat(state.getValue()).isEqualTo(SOURCE_ADDED); + + // Verify the preference with ALREADY_CONNECTED_BROADCAST_ID is removed. + verify(mPreference).removePreference(preferenceToRemove.capture()); + assertThat(preferenceToRemove.getValue().getAudioStreamBroadcastId()) + .isEqualTo(ALREADY_CONNECTED_BROADCAST_ID); + } + + @Test + public void testHandleSourceRemoved_updateState() { + // Setup a device + ShadowAudioStreamsHelper.setCachedBluetoothDeviceInSharingOrLeConnected(mDevice); + + // Setup a connected source + BluetoothLeBroadcastReceiveState connected = + createConnectedMock(ALREADY_CONNECTED_BROADCAST_ID); + when(mAudioStreamsHelper.getAllConnectedSources()).thenReturn(ImmutableList.of(connected)); + + // Handle connected source in onStart + mController.displayPreference(mScreen); + mController.onStart(mLifecycleOwner); + shadowOf(Looper.getMainLooper()).idle(); + + // The connected source is identified as having a bad code + BluetoothLeBroadcastReceiveState badCode = mock(BluetoothLeBroadcastReceiveState.class); + when(badCode.getBroadcastId()).thenReturn(ALREADY_CONNECTED_BROADCAST_ID); + when(badCode.getPaSyncState()) + .thenReturn(BluetoothLeBroadcastReceiveState.PA_SYNC_STATE_SYNCHRONIZED); + when(badCode.getBigEncryptionState()) + .thenReturn(BluetoothLeBroadcastReceiveState.BIG_ENCRYPTION_STATE_BAD_CODE); + mController.handleSourceConnectBadCode(badCode); + shadowOf(Looper.getMainLooper()).idle(); + + ArgumentCaptor preference = + ArgumentCaptor.forClass(AudioStreamPreference.class); + ArgumentCaptor state = + ArgumentCaptor.forClass( + AudioStreamsProgressCategoryController.AudioStreamState.class); + + verify(mController, times(2)).moveToState(preference.capture(), state.capture()); + List preferences = preference.getAllValues(); + assertThat(preferences.size()).isEqualTo(2); + List states = state.getAllValues(); + assertThat(states.size()).isEqualTo(2); + + // Verify the connected source is created state SOURCE_ADDED + assertThat(preferences.get(0).getAudioStreamBroadcastId()) + .isEqualTo(ALREADY_CONNECTED_BROADCAST_ID); + assertThat(states.get(0)).isEqualTo(SOURCE_ADDED); + + // Verify the connected source is updated to state ADD_SOURCE_BAD_CODE + assertThat(preferences.get(1).getAudioStreamBroadcastId()) + .isEqualTo(ALREADY_CONNECTED_BROADCAST_ID); + assertThat(states.get(1)).isEqualTo(ADD_SOURCE_BAD_CODE); + } + + @Test + public void testHandleSourceFailedToConnect_updateState() { + // Setup a device + ShadowAudioStreamsHelper.setCachedBluetoothDeviceInSharingOrLeConnected(mDevice); + + // Setup mPreference so it's not null + mController.displayPreference(mScreen); + + // A new source found + when(mMetadata.getBroadcastId()).thenReturn(NEWLY_FOUND_BROADCAST_ID); + mController.handleSourceFound(mMetadata); + shadowOf(Looper.getMainLooper()).idle(); + + // The new found source is identified as failed to connect + mController.handleSourceFailedToConnect(NEWLY_FOUND_BROADCAST_ID); + shadowOf(Looper.getMainLooper()).idle(); + + ArgumentCaptor preference = + ArgumentCaptor.forClass(AudioStreamPreference.class); + ArgumentCaptor state = + ArgumentCaptor.forClass( + AudioStreamsProgressCategoryController.AudioStreamState.class); + + verify(mController, times(2)).moveToState(preference.capture(), state.capture()); + List preferences = preference.getAllValues(); + assertThat(preferences.size()).isEqualTo(2); + List states = state.getAllValues(); + assertThat(states.size()).isEqualTo(2); + + // Verify one preference is created with SYNCED + assertThat(preferences.get(0).getAudioStreamBroadcastId()) + .isEqualTo(NEWLY_FOUND_BROADCAST_ID); + assertThat(states.get(0)).isEqualTo(SYNCED); + + // Verify the preference is updated to state ADD_SOURCE_FAILED + assertThat(preferences.get(1).getAudioStreamBroadcastId()) + .isEqualTo(NEWLY_FOUND_BROADCAST_ID); + assertThat(states.get(1)).isEqualTo(ADD_SOURCE_FAILED); + } + + private static BluetoothLeBroadcastReceiveState createConnectedMock(int id) { + var connected = mock(BluetoothLeBroadcastReceiveState.class); + List bisSyncState = new ArrayList<>(); + bisSyncState.add(1L); + when(connected.getBroadcastId()).thenReturn(id); + when(connected.getBisSyncState()).thenReturn(bisSyncState); + return connected; + } + + static class TestController extends AudioStreamsProgressCategoryController { + TestController(Context context, String preferenceKey) { + super(context, preferenceKey); + mExecutor = spy(mContext.getMainExecutor()); + } + + @Override + void moveToState(AudioStreamPreference preference, AudioStreamState state) { + preference.setAudioStreamState(state); + // Do nothing else to avoid side effect from AudioStreamStateHandler#performAction + } + } +} 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 3a0a6c4b757..13c19cae872 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 @@ -32,7 +32,7 @@ import org.robolectric.annotation.Resetter; import java.util.List; import java.util.Optional; -@Implements(value = AudioStreamsHelper.class, callThroughByDefault = false) +@Implements(value = AudioStreamsHelper.class, callThroughByDefault = true) public class ShadowAudioStreamsHelper { private static AudioStreamsHelper sMockHelper; @Nullable private static CachedBluetoothDevice sCachedBluetoothDevice;