From 4b85389124a528fa4ecf122ae9b4a44fc15c26d1 Mon Sep 17 00:00:00 2001 From: chelseahao Date: Wed, 19 Jun 2024 11:57:39 +0800 Subject: [PATCH 1/4] [Audiosharing] Increase test coverage for audio stream states. 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: I91d9a45abd4c9659c9d0ddeca5f5aaefed36f820 --- .../AddSourceWaitForResponseState.java | 3 +- .../audiostreams/AudioStreamStateHandler.java | 7 +- .../audiostreams/AudioStreamsHelper.java | 6 +- .../audiostreams/SourceAddedState.java | 6 +- .../audiostreams/WaitForSyncState.java | 6 +- .../AddSourceBadCodeStateTest.java | 39 ++++++- .../AddSourceFailedStateTest.java | 39 ++++++- .../AddSourceWaitForResponseStateTest.java | 54 ++++++++- .../audiostreams/SourceAddedStateTest.java | 103 +++++++++++++++++- .../audiostreams/SyncedStateTest.java | 51 ++++++++- .../audiostreams/WaitForSyncStateTest.java | 61 ++++++++++- 11 files changed, 351 insertions(+), 24 deletions(-) diff --git a/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AddSourceWaitForResponseState.java b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AddSourceWaitForResponseState.java index 24a28dd0602..7be01a20235 100644 --- a/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AddSourceWaitForResponseState.java +++ b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AddSourceWaitForResponseState.java @@ -36,7 +36,8 @@ class AddSourceWaitForResponseState extends AudioStreamStateHandler { @Nullable private static AddSourceWaitForResponseState sInstance = null; - private AddSourceWaitForResponseState() {} + @VisibleForTesting + AddSourceWaitForResponseState() {} static AddSourceWaitForResponseState getInstance() { if (sInstance == null) { diff --git a/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamStateHandler.java b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamStateHandler.java index b0c5b6baebf..4bb84751b36 100644 --- a/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamStateHandler.java +++ b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamStateHandler.java @@ -35,10 +35,10 @@ class AudioStreamStateHandler { private static final boolean DEBUG = BluetoothUtils.D; @VisibleForTesting static final int EMPTY_STRING_RES = 0; - final AudioStreamsRepository mAudioStreamsRepository = AudioStreamsRepository.getInstance(); final Handler mHandler = new Handler(Looper.getMainLooper()); final MetricsFeatureProvider mMetricsFeatureProvider = FeatureFactory.getFeatureFactory().getMetricsFeatureProvider(); + AudioStreamsRepository mAudioStreamsRepository = AudioStreamsRepository.getInstance(); AudioStreamStateHandler() {} @@ -112,4 +112,9 @@ class AudioStreamStateHandler { AudioStreamsProgressCategoryController.AudioStreamState getStateEnum() { return AudioStreamsProgressCategoryController.AudioStreamState.UNKNOWN; } + + @VisibleForTesting + void setAudioStreamsRepositoryForTesting(AudioStreamsRepository repository) { + mAudioStreamsRepository = repository; + } } diff --git a/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsHelper.java b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsHelper.java index 775186a859e..6e335a0971c 100644 --- a/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsHelper.java +++ b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsHelper.java @@ -71,7 +71,8 @@ public class AudioStreamsHelper { * * @param source The LE broadcast metadata representing the audio source. */ - void addSource(BluetoothLeBroadcastMetadata source) { + @VisibleForTesting + public void addSource(BluetoothLeBroadcastMetadata source) { if (mLeBroadcastAssistant == null) { Log.w(TAG, "addSource(): LeBroadcastAssistant is null!"); return; @@ -97,7 +98,8 @@ public class AudioStreamsHelper { } /** Removes sources from LE broadcasts associated for all active sinks based on broadcast Id. */ - void removeSource(int broadcastId) { + @VisibleForTesting + public void removeSource(int broadcastId) { if (mLeBroadcastAssistant == null) { Log.w(TAG, "removeSource(): LeBroadcastAssistant is null!"); return; diff --git a/src/com/android/settings/connecteddevice/audiosharing/audiostreams/SourceAddedState.java b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/SourceAddedState.java index ee84429663a..4f36db9363c 100644 --- a/src/com/android/settings/connecteddevice/audiosharing/audiostreams/SourceAddedState.java +++ b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/SourceAddedState.java @@ -32,7 +32,8 @@ class SourceAddedState extends AudioStreamStateHandler { @Nullable private static SourceAddedState sInstance = null; - private SourceAddedState() {} + @VisibleForTesting + SourceAddedState() {} static SourceAddedState getInstance() { if (sInstance == null) { @@ -80,8 +81,7 @@ class SourceAddedState extends AudioStreamStateHandler { AudioStreamDetailsFragment.BROADCAST_ID_ARG, p.getAudioStreamBroadcastId()); new SubSettingLauncher(p.getContext()) - .setTitleText( - p.getContext().getString(R.string.audio_streams_detail_page_title)) + .setTitleRes(R.string.audio_streams_detail_page_title) .setDestination(AudioStreamDetailsFragment.class.getName()) .setSourceMetricsCategory( controller.getFragment() == null diff --git a/src/com/android/settings/connecteddevice/audiosharing/audiostreams/WaitForSyncState.java b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/WaitForSyncState.java index 55f61fdd0e2..9689b263a47 100644 --- a/src/com/android/settings/connecteddevice/audiosharing/audiostreams/WaitForSyncState.java +++ b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/WaitForSyncState.java @@ -39,7 +39,8 @@ class WaitForSyncState extends AudioStreamStateHandler { @Nullable private static WaitForSyncState sInstance = null; - private WaitForSyncState() {} + @VisibleForTesting + WaitForSyncState() {} static WaitForSyncState getInstance() { if (sInstance == null) { @@ -114,7 +115,8 @@ class WaitForSyncState extends AudioStreamStateHandler { SettingsEnums.DIALOG_AUDIO_STREAM_MAIN_WAIT_FOR_SYNC_TIMEOUT); } - private void launchQrCodeScanFragment(Context context, Fragment fragment) { + @VisibleForTesting + void launchQrCodeScanFragment(Context context, Fragment fragment) { new SubSettingLauncher(context) .setTitleRes(R.string.audio_streams_main_page_scan_qr_code_title) .setDestination(AudioStreamsQrCodeScanFragment.class.getName()) diff --git a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AddSourceBadCodeStateTest.java b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AddSourceBadCodeStateTest.java index 2fddff52358..aba300e08bc 100644 --- a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AddSourceBadCodeStateTest.java +++ b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AddSourceBadCodeStateTest.java @@ -20,18 +20,40 @@ import static com.android.settings.connecteddevice.audiosharing.audiostreams.Add import static com.google.common.truth.Truth.assertThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.app.settings.SettingsEnums; +import android.content.Context; + +import androidx.test.core.app.ApplicationProvider; + +import com.android.settings.testutils.FakeFeatureFactory; + import org.junit.Before; +import org.junit.Rule; import org.junit.Test; import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; import org.robolectric.RobolectricTestRunner; @RunWith(RobolectricTestRunner.class) public class AddSourceBadCodeStateTest { + @Rule public final MockitoRule mMockitoRule = MockitoJUnit.rule(); + private final Context mContext = ApplicationProvider.getApplicationContext(); + @Mock private AudioStreamPreference mPreference; + @Mock private AudioStreamsProgressCategoryController mController; + @Mock private AudioStreamsHelper mHelper; + private FakeFeatureFactory mFeatureFactory; private AddSourceBadCodeState mInstance; @Before public void setUp() { - mInstance = AddSourceBadCodeState.getInstance(); + mFeatureFactory = FakeFeatureFactory.setupForTest(); + mInstance = new AddSourceBadCodeState(); } @Test @@ -55,4 +77,19 @@ public class AddSourceBadCodeStateTest { AudioStreamsProgressCategoryController.AudioStreamState .ADD_SOURCE_BAD_CODE); } + + @Test + public void testPerformAction() { + when(mPreference.getContext()).thenReturn(mContext); + when(mPreference.getSourceOriginForLogging()) + .thenReturn(SourceOriginForLogging.QR_CODE_SCAN_SETTINGS); + + mInstance.performAction(mPreference, mController, mHelper); + + verify(mFeatureFactory.metricsFeatureProvider) + .action( + eq(mContext), + eq(SettingsEnums.ACTION_AUDIO_STREAM_JOIN_FAILED_BAD_CODE), + eq(SourceOriginForLogging.QR_CODE_SCAN_SETTINGS.ordinal())); + } } diff --git a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AddSourceFailedStateTest.java b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AddSourceFailedStateTest.java index d8b1fcf9401..1bc9f9148f5 100644 --- a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AddSourceFailedStateTest.java +++ b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AddSourceFailedStateTest.java @@ -20,18 +20,40 @@ import static com.android.settings.connecteddevice.audiosharing.audiostreams.Add import static com.google.common.truth.Truth.assertThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.app.settings.SettingsEnums; +import android.content.Context; + +import androidx.test.core.app.ApplicationProvider; + +import com.android.settings.testutils.FakeFeatureFactory; + import org.junit.Before; +import org.junit.Rule; import org.junit.Test; import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; import org.robolectric.RobolectricTestRunner; @RunWith(RobolectricTestRunner.class) public class AddSourceFailedStateTest { + @Rule public final MockitoRule mMockitoRule = MockitoJUnit.rule(); + private final Context mContext = ApplicationProvider.getApplicationContext(); + @Mock private AudioStreamPreference mPreference; + @Mock private AudioStreamsProgressCategoryController mController; + @Mock private AudioStreamsHelper mHelper; + private FakeFeatureFactory mFeatureFactory; private AddSourceFailedState mInstance; @Before public void setUp() { - mInstance = AddSourceFailedState.getInstance(); + mFeatureFactory = FakeFeatureFactory.setupForTest(); + mInstance = new AddSourceFailedState(); } @Test @@ -54,4 +76,19 @@ public class AddSourceFailedStateTest { .isEqualTo( AudioStreamsProgressCategoryController.AudioStreamState.ADD_SOURCE_FAILED); } + + @Test + public void testPerformAction() { + when(mPreference.getContext()).thenReturn(mContext); + when(mPreference.getSourceOriginForLogging()) + .thenReturn(SourceOriginForLogging.QR_CODE_SCAN_SETTINGS); + + mInstance.performAction(mPreference, mController, mHelper); + + verify(mFeatureFactory.metricsFeatureProvider) + .action( + eq(mContext), + eq(SettingsEnums.ACTION_AUDIO_STREAM_JOIN_FAILED_OTHER), + eq(SourceOriginForLogging.QR_CODE_SCAN_SETTINGS.ordinal())); + } } diff --git a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AddSourceWaitForResponseStateTest.java b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AddSourceWaitForResponseStateTest.java index 6e5342bb3fe..950ad38a64a 100644 --- a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AddSourceWaitForResponseStateTest.java +++ b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AddSourceWaitForResponseStateTest.java @@ -22,11 +22,21 @@ import static com.google.common.truth.Truth.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import android.app.settings.SettingsEnums; import android.bluetooth.BluetoothLeBroadcastMetadata; +import android.content.Context; + +import androidx.test.core.app.ApplicationProvider; + +import com.android.settings.R; +import com.android.settings.testutils.FakeFeatureFactory; import org.junit.Before; import org.junit.Rule; @@ -36,23 +46,36 @@ 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.ShadowLooper; import java.util.concurrent.TimeUnit; @RunWith(RobolectricTestRunner.class) +@Config( + shadows = { + ShadowAlertDialog.class, + }) public class AddSourceWaitForResponseStateTest { - private static final int BROADCAST_ID = 1; @Rule public final MockitoRule mMockitoRule = MockitoJUnit.rule(); + private static final int BROADCAST_ID = 1; + private final Context mContext = spy(ApplicationProvider.getApplicationContext()); @Mock private AudioStreamPreference mMockPreference; @Mock private AudioStreamsProgressCategoryController mMockController; @Mock private AudioStreamsHelper mMockHelper; @Mock private BluetoothLeBroadcastMetadata mMockMetadata; + @Mock private AudioStreamsRepository mMockRepository; + private FakeFeatureFactory mFeatureFactory; private AddSourceWaitForResponseState mInstance; @Before public void setUp() { - mInstance = AddSourceWaitForResponseState.getInstance(); + mFeatureFactory = FakeFeatureFactory.setupForTest(); + mInstance = new AddSourceWaitForResponseState(); + when(mMockPreference.getContext()).thenReturn(mContext); + when(mMockPreference.getSourceOriginForLogging()) + .thenReturn(SourceOriginForLogging.QR_CODE_SCAN_SETTINGS); } @Test @@ -93,11 +116,18 @@ public class AddSourceWaitForResponseStateTest { public void testPerformAction_metadataIsNotNull_addSource() { when(mMockPreference.getAudioStreamMetadata()).thenReturn(mMockMetadata); when(mMockPreference.getSourceOriginForLogging()) - .thenReturn(SourceOriginForLogging.UNKNOWN); + .thenReturn(SourceOriginForLogging.QR_CODE_SCAN_SETTINGS); + mInstance.setAudioStreamsRepositoryForTesting(mMockRepository); mInstance.performAction(mMockPreference, mMockController, mMockHelper); verify(mMockHelper).addSource(mMockMetadata); + verify(mFeatureFactory.metricsFeatureProvider) + .action( + eq(mContext), + eq(SettingsEnums.ACTION_AUDIO_STREAM_JOIN), + eq(SourceOriginForLogging.QR_CODE_SCAN_SETTINGS.ordinal())); + verify(mMockRepository).cacheMetadata(mMockMetadata); verify(mMockController, never()).handleSourceFailedToConnect(anyInt()); } @@ -108,12 +138,28 @@ public class AddSourceWaitForResponseStateTest { when(mMockPreference.getAudioStreamState()).thenReturn(mInstance.getStateEnum()); when(mMockPreference.getAudioStreamBroadcastId()).thenReturn(BROADCAST_ID); when(mMockPreference.getSourceOriginForLogging()) - .thenReturn(SourceOriginForLogging.UNKNOWN); + .thenReturn(SourceOriginForLogging.QR_CODE_SCAN_SETTINGS); + when(mMockController.getFragment()).thenReturn(mock(AudioStreamsDashboardFragment.class)); + mInstance.setAudioStreamsRepositoryForTesting(mMockRepository); mInstance.performAction(mMockPreference, mMockController, mMockHelper); ShadowLooper.idleMainLooper(ADD_SOURCE_WAIT_FOR_RESPONSE_TIMEOUT_MILLIS, TimeUnit.SECONDS); verify(mMockHelper).addSource(mMockMetadata); verify(mMockController).handleSourceFailedToConnect(BROADCAST_ID); + verify(mFeatureFactory.metricsFeatureProvider) + .action( + eq(mContext), + eq(SettingsEnums.ACTION_AUDIO_STREAM_JOIN), + eq(SourceOriginForLogging.QR_CODE_SCAN_SETTINGS.ordinal())); + verify(mMockRepository).cacheMetadata(mMockMetadata); + verify(mFeatureFactory.metricsFeatureProvider) + .action( + eq(mContext), + eq(SettingsEnums.ACTION_AUDIO_STREAM_JOIN_FAILED_TIMEOUT), + eq(SourceOriginForLogging.QR_CODE_SCAN_SETTINGS.ordinal())); + verify(mContext).getString(R.string.audio_streams_dialog_stream_is_not_available); + verify(mContext).getString(R.string.audio_streams_is_not_playing); + verify(mContext).getString(R.string.audio_streams_dialog_close); } } diff --git a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/SourceAddedStateTest.java b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/SourceAddedStateTest.java index 0f0bafe217a..082735a31fc 100644 --- a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/SourceAddedStateTest.java +++ b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/SourceAddedStateTest.java @@ -16,27 +16,71 @@ package com.android.settings.connecteddevice.audiosharing.audiostreams; +import static android.app.settings.SettingsEnums.AUDIO_STREAM_MAIN; + import static com.android.settings.connecteddevice.audiosharing.audiostreams.SourceAddedState.AUDIO_STREAM_SOURCE_ADDED_STATE_SUMMARY; import static com.google.common.truth.Truth.assertThat; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.app.settings.SettingsEnums; +import android.bluetooth.BluetoothLeBroadcastMetadata; +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; + +import androidx.fragment.app.FragmentActivity; +import androidx.preference.Preference; +import androidx.test.core.app.ApplicationProvider; + +import com.android.settings.R; +import com.android.settings.SettingsActivity; +import com.android.settings.testutils.FakeFeatureFactory; +import com.android.settings.testutils.shadow.ShadowFragment; +import com.android.settingslib.core.instrumentation.MetricsFeatureProvider; + 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; @RunWith(RobolectricTestRunner.class) +@Config( + shadows = { + ShadowFragment.class, + }) public class SourceAddedStateTest { - @Rule public final MockitoRule mMockitoRule = MockitoJUnit.rule(); + private static final int BROADCAST_ID = 1; + private static final String BROADCAST_TITLE = "title"; + private final Context mContext = ApplicationProvider.getApplicationContext(); + @Mock private AudioStreamPreference mPreference; + @Mock private AudioStreamsProgressCategoryController mController; + @Mock private AudioStreamsHelper mHelper; + @Mock private AudioStreamsRepository mRepository; + @Mock private AudioStreamsDashboardFragment mFragment; + @Mock private FragmentActivity mActivity; + private FakeFeatureFactory mFeatureFactory; private SourceAddedState mInstance; @Before public void setUp() { - mInstance = SourceAddedState.getInstance(); + when(mFragment.getActivity()).thenReturn(mActivity); + mFeatureFactory = FakeFeatureFactory.setupForTest(); + mInstance = new SourceAddedState(); + when(mPreference.getAudioStreamBroadcastId()).thenReturn(BROADCAST_ID); + when(mPreference.getTitle()).thenReturn(BROADCAST_TITLE); } @Test @@ -58,4 +102,59 @@ public class SourceAddedStateTest { assertThat(stateEnum) .isEqualTo(AudioStreamsProgressCategoryController.AudioStreamState.SOURCE_ADDED); } + + @Test + public void testPerformAction() { + mInstance.setAudioStreamsRepositoryForTesting(mRepository); + BluetoothLeBroadcastMetadata mockMetadata = mock(BluetoothLeBroadcastMetadata.class); + when(mRepository.getCachedMetadata(anyInt())).thenReturn(mockMetadata); + when(mPreference.getContext()).thenReturn(mContext); + when(mPreference.getSourceOriginForLogging()) + .thenReturn(SourceOriginForLogging.QR_CODE_SCAN_SETTINGS); + + mInstance.performAction(mPreference, mController, mHelper); + + verify(mRepository).saveMetadata(eq(mContext), eq(mockMetadata)); + verify(mFeatureFactory.metricsFeatureProvider) + .action( + eq(mContext), + eq(SettingsEnums.ACTION_AUDIO_STREAM_JOIN_SUCCEED), + eq(SourceOriginForLogging.QR_CODE_SCAN_SETTINGS.ordinal())); + verify(mHelper).startMediaService(eq(mContext), eq(BROADCAST_ID), eq(BROADCAST_TITLE)); + } + + @Test + public void testGetOnClickListener_startSubSettings() { + when(mController.getFragment()).thenReturn(mFragment); + when(mFragment.getMetricsCategory()).thenReturn(AUDIO_STREAM_MAIN); + + Preference.OnPreferenceClickListener listener = mInstance.getOnClickListener(mController); + assertThat(listener).isNotNull(); + + // mContext is not an Activity context, calling startActivity() from outside of an Activity + // context requires the FLAG_ACTIVITY_NEW_TASK flag, create a mock to avoid this + // AndroidRuntimeException. + Context activityContext = mock(Context.class); + when(mPreference.getContext()).thenReturn(activityContext); + + listener.onPreferenceClick(mPreference); + + ArgumentCaptor argumentCaptor = ArgumentCaptor.forClass(Intent.class); + verify(activityContext).startActivity(argumentCaptor.capture()); + + Intent intent = argumentCaptor.getValue(); + assertThat(intent.getStringExtra(SettingsActivity.EXTRA_SHOW_FRAGMENT)) + .isEqualTo(AudioStreamDetailsFragment.class.getName()); + assertThat(intent.getIntExtra(SettingsActivity.EXTRA_SHOW_FRAGMENT_TITLE_RESID, 0)) + .isEqualTo(R.string.audio_streams_detail_page_title); + assertThat(intent.getIntExtra(MetricsFeatureProvider.EXTRA_SOURCE_METRICS_CATEGORY, 0)) + .isEqualTo(AUDIO_STREAM_MAIN); + + Bundle bundle = intent.getBundleExtra(SettingsActivity.EXTRA_SHOW_FRAGMENT_ARGUMENTS); + assertThat(bundle).isNotNull(); + assertThat(bundle.getString(AudioStreamDetailsFragment.BROADCAST_NAME_ARG)) + .isEqualTo(BROADCAST_TITLE); + assertThat(bundle.getInt(AudioStreamDetailsFragment.BROADCAST_ID_ARG)) + .isEqualTo(BROADCAST_ID); + } } diff --git a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/SyncedStateTest.java b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/SyncedStateTest.java index e9eab5066ac..2b19e2058b3 100644 --- a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/SyncedStateTest.java +++ b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/SyncedStateTest.java @@ -20,7 +20,7 @@ import static com.android.settings.connecteddevice.audiosharing.audiostreams.Aud import static com.google.common.truth.Truth.assertThat; -import static org.mockito.Mockito.never; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.robolectric.shadows.ShadowLooper.shadowMainLooper; @@ -28,10 +28,16 @@ import static org.robolectric.shadows.ShadowLooper.shadowMainLooper; import android.app.AlertDialog; import android.bluetooth.BluetoothLeBroadcastMetadata; import android.content.Context; +import android.content.DialogInterface; +import android.widget.Button; +import android.widget.TextView; import androidx.preference.Preference; import androidx.test.core.app.ApplicationProvider; +import com.android.settings.R; +import com.android.settingslib.bluetooth.BluetoothLeBroadcastMetadataExt; + import org.junit.After; import org.junit.Before; import org.junit.Rule; @@ -42,7 +48,9 @@ import org.mockito.junit.MockitoJUnit; import org.mockito.junit.MockitoRule; import org.robolectric.RobolectricTestRunner; import org.robolectric.annotation.Config; +import org.robolectric.shadow.api.Shadow; import org.robolectric.shadows.ShadowAlertDialog; +import org.robolectric.shadows.ShadowLooper; @RunWith(RobolectricTestRunner.class) @Config( @@ -51,6 +59,10 @@ import org.robolectric.shadows.ShadowAlertDialog; }) public class SyncedStateTest { @Rule public final MockitoRule mMockitoRule = MockitoJUnit.rule(); + private static final String ENCRYPTED_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 BROADCAST_TITLE = "title"; @Mock private AudioStreamsProgressCategoryController mMockController; @Mock private AudioStreamPreference mMockPreference; @Mock private BluetoothLeBroadcastMetadata mMockMetadata; @@ -105,18 +117,47 @@ public class SyncedStateTest { @Test public void testGetOnClickListener_isEncrypted_passwordDialogShowing() { + when(mMockPreference.getAudioStreamMetadata()) + .thenReturn( + BluetoothLeBroadcastMetadataExt.INSTANCE.convertToBroadcastMetadata( + ENCRYPTED_METADATA)); + when(mMockPreference.getContext()).thenReturn(mMockContext); + when(mMockPreference.getTitle()).thenReturn(BROADCAST_TITLE); + Preference.OnPreferenceClickListener listener = mInstance.getOnClickListener(mMockController); - when(mMockPreference.getAudioStreamMetadata()).thenReturn(mMockMetadata); - when(mMockPreference.getContext()).thenReturn(mMockContext); - when(mMockMetadata.isEncrypted()).thenReturn(true); + assertThat(listener).isNotNull(); listener.onPreferenceClick(mMockPreference); shadowMainLooper().idle(); AlertDialog dialog = ShadowAlertDialog.getLatestAlertDialog(); + assertThat(dialog).isNotNull(); assertThat(dialog.isShowing()).isTrue(); - verify(mMockController, never()).handleSourceAddRequest(mMockPreference, mMockMetadata); + + Button neutralButton = dialog.getButton(DialogInterface.BUTTON_NEUTRAL); + assertThat(neutralButton).isNotNull(); + assertThat(neutralButton.getText().toString()) + .isEqualTo(mMockContext.getString(android.R.string.cancel)); + + Button positiveButton = dialog.getButton(DialogInterface.BUTTON_POSITIVE); + assertThat(positiveButton).isNotNull(); + assertThat(positiveButton.getText().toString()) + .isEqualTo( + mMockContext.getString(R.string.bluetooth_connect_access_dialog_positive)); + + positiveButton.callOnClick(); + ShadowLooper.idleMainLooper(); + verify(mMockController).handleSourceAddRequest(any(), any()); + + ShadowAlertDialog shadowDialog = Shadow.extract(dialog); + TextView title = shadowDialog.getView().findViewById(R.id.broadcast_name_text); + assertThat(title).isNotNull(); + assertThat(title.getText().toString()).isEqualTo(BROADCAST_TITLE); + assertThat(shadowDialog.getTitle().toString()) + .isEqualTo(mMockContext.getString(R.string.find_broadcast_password_dialog_title)); + + dialog.cancel(); } } diff --git a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/WaitForSyncStateTest.java b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/WaitForSyncStateTest.java index 3eb07a46f74..d97bf8fe58e 100644 --- a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/WaitForSyncStateTest.java +++ b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/WaitForSyncStateTest.java @@ -16,22 +16,39 @@ package com.android.settings.connecteddevice.audiosharing.audiostreams; +import static android.app.settings.SettingsEnums.DIALOG_AUDIO_STREAM_MAIN_WAIT_FOR_SYNC_TIMEOUT; + +import static com.android.settings.connecteddevice.audiosharing.audiostreams.AudioStreamsScanQrCodeController.REQUEST_SCAN_BT_BROADCAST_QR_CODE; import static com.android.settings.connecteddevice.audiosharing.audiostreams.WaitForSyncState.AUDIO_STREAM_WAIT_FOR_SYNC_STATE_SUMMARY; import static com.android.settings.connecteddevice.audiosharing.audiostreams.WaitForSyncState.WAIT_FOR_SYNC_TIMEOUT_MILLIS; import static com.google.common.truth.Truth.assertThat; import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import android.app.settings.SettingsEnums; import android.bluetooth.BluetoothLeBroadcastMetadata; +import android.content.Context; +import android.content.Intent; + +import androidx.test.core.app.ApplicationProvider; + +import com.android.settings.R; +import com.android.settings.SettingsActivity; +import com.android.settings.testutils.FakeFeatureFactory; +import com.android.settingslib.core.instrumentation.MetricsFeatureProvider; 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; @@ -43,15 +60,18 @@ import java.util.concurrent.TimeUnit; @RunWith(RobolectricTestRunner.class) public class WaitForSyncStateTest { @Rule public final MockitoRule mMockitoRule = MockitoJUnit.rule(); + private final Context mContext = spy(ApplicationProvider.getApplicationContext()); @Mock private AudioStreamPreference mMockPreference; @Mock private AudioStreamsProgressCategoryController mMockController; @Mock private AudioStreamsHelper mMockHelper; @Mock private BluetoothLeBroadcastMetadata mMockMetadata; + private FakeFeatureFactory mFeatureFactory; private WaitForSyncState mInstance; @Before public void setUp() { - mInstance = WaitForSyncState.getInstance(); + mFeatureFactory = FakeFeatureFactory.setupForTest(); + mInstance = new WaitForSyncState(); } @Test @@ -93,12 +113,49 @@ public class WaitForSyncStateTest { .thenReturn(AudioStreamsProgressCategoryController.AudioStreamState.WAIT_FOR_SYNC); when(mMockPreference.getAudioStreamBroadcastId()).thenReturn(1); when(mMockPreference.getAudioStreamMetadata()).thenReturn(mMockMetadata); + when(mMockPreference.getContext()).thenReturn(mContext); when(mMockPreference.getSourceOriginForLogging()) - .thenReturn(SourceOriginForLogging.UNKNOWN); + .thenReturn(SourceOriginForLogging.BROADCAST_SEARCH); + when(mMockController.getFragment()).thenReturn(mock(AudioStreamsDashboardFragment.class)); mInstance.performAction(mMockPreference, mMockController, mMockHelper); ShadowLooper.idleMainLooper(WAIT_FOR_SYNC_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS); verify(mMockController).handleSourceLost(1); + verify(mFeatureFactory.metricsFeatureProvider) + .action( + eq(mContext), + eq(SettingsEnums.ACTION_AUDIO_STREAM_JOIN_FAILED_WAIT_FOR_SYNC_TIMEOUT), + eq(SourceOriginForLogging.BROADCAST_SEARCH.ordinal())); + verify(mContext).getString(R.string.audio_streams_dialog_stream_is_not_available); + verify(mContext).getString(R.string.audio_streams_is_not_playing); + verify(mContext).getString(R.string.audio_streams_dialog_close); + verify(mContext).getString(R.string.audio_streams_dialog_retry); + } + + @Test + public void testLaunchQrCodeScanFragment() { + // mContext is not an Activity context, calling startActivity() from outside of an Activity + // context requires the FLAG_ACTIVITY_NEW_TASK flag, create a mock to avoid this + // AndroidRuntimeException. + Context activityContext = mock(Context.class); + AudioStreamsDashboardFragment fragment = mock(AudioStreamsDashboardFragment.class); + mInstance.launchQrCodeScanFragment(activityContext, fragment); + + ArgumentCaptor intentCaptor = ArgumentCaptor.forClass(Intent.class); + ArgumentCaptor requestCodeCaptor = ArgumentCaptor.forClass(Integer.class); + verify(fragment) + .startActivityForResult(intentCaptor.capture(), requestCodeCaptor.capture()); + + Intent intent = intentCaptor.getValue(); + assertThat(intent.getStringExtra(SettingsActivity.EXTRA_SHOW_FRAGMENT)) + .isEqualTo(AudioStreamsQrCodeScanFragment.class.getName()); + assertThat(intent.getIntExtra(SettingsActivity.EXTRA_SHOW_FRAGMENT_TITLE_RESID, 0)) + .isEqualTo(R.string.audio_streams_main_page_scan_qr_code_title); + assertThat(intent.getIntExtra(MetricsFeatureProvider.EXTRA_SOURCE_METRICS_CATEGORY, 0)) + .isEqualTo(DIALOG_AUDIO_STREAM_MAIN_WAIT_FOR_SYNC_TIMEOUT); + + int requestCode = requestCodeCaptor.getValue(); + assertThat(requestCode).isEqualTo(REQUEST_SCAN_BT_BROADCAST_QR_CODE); } } From 44a0b59ad2742ac1bdc470db92b983abffaa6987 Mon Sep 17 00:00:00 2001 From: chelseahao Date: Fri, 21 Jun 2024 10:43:28 +0800 Subject: [PATCH 2/4] [Audiosharing] Listen to `onProfileConnectionStateChanged` of LE_AUDIO_BROADCAST_ASSISTANT to be more precise on device connection status upon bluetooth on/off. Also increase test coverage. 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: Ia78b1fe19bff3cb179794db1dc09374db13818d8 --- ...udioStreamsActiveDeviceSummaryUpdater.java | 29 ++-- .../AudioStreamsCategoryController.java | 12 +- .../AudioStreamsProgressCategoryCallback.java | 13 -- .../AudioStreamsScanQrCodeController.java | 11 +- .../AudioStreamStateHandlerTest.java | 143 ++++++++++++++++++ ...StreamsActiveDeviceSummaryUpdaterTest.java | 34 ++++- .../AudioStreamsCategoryControllerTest.java | 23 ++- .../AudioStreamsDialogFragmentTest.java | 85 +++++++++++ ...ioStreamsProgressCategoryCallbackTest.java | 140 +++++++++++++++++ .../AudioStreamsScanQrCodeControllerTest.java | 46 +++++- 10 files changed, 493 insertions(+), 43 deletions(-) create mode 100644 tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamStateHandlerTest.java create mode 100644 tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsDialogFragmentTest.java create mode 100644 tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsProgressCategoryCallbackTest.java diff --git a/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsActiveDeviceSummaryUpdater.java b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsActiveDeviceSummaryUpdater.java index ab22b0702bc..47ee440694f 100644 --- a/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsActiveDeviceSummaryUpdater.java +++ b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsActiveDeviceSummaryUpdater.java @@ -16,24 +16,22 @@ package com.android.settings.connecteddevice.audiosharing.audiostreams; +import android.bluetooth.BluetoothAdapter; import android.bluetooth.BluetoothProfile; import android.content.Context; import android.text.TextUtils; -import android.util.Log; +import androidx.annotation.NonNull; import androidx.annotation.Nullable; import com.android.settings.R; import com.android.settings.bluetooth.Utils; import com.android.settingslib.bluetooth.BluetoothCallback; -import com.android.settingslib.bluetooth.BluetoothUtils; import com.android.settingslib.bluetooth.CachedBluetoothDevice; import com.android.settingslib.bluetooth.LocalBluetoothManager; import com.android.settingslib.utils.ThreadUtils; public class AudioStreamsActiveDeviceSummaryUpdater implements BluetoothCallback { - private static final String TAG = "AudioStreamsActiveDeviceSummaryUpdater"; - private static final boolean DEBUG = BluetoothUtils.D; private final LocalBluetoothManager mBluetoothManager; private Context mContext; @Nullable private String mSummary; @@ -47,17 +45,20 @@ public class AudioStreamsActiveDeviceSummaryUpdater implements BluetoothCallback } @Override - public void onActiveDeviceChanged( - @Nullable CachedBluetoothDevice activeDevice, int bluetoothProfile) { - if (DEBUG) { - Log.d( - TAG, - "onActiveDeviceChanged() with activeDevice : " - + (activeDevice == null ? "null" : activeDevice.getAddress()) - + " on profile : " - + bluetoothProfile); + public void onBluetoothStateChanged(@AdapterState int bluetoothState) { + if (bluetoothState == BluetoothAdapter.STATE_OFF) { + notifyChangeIfNeeded(); } - if (bluetoothProfile == BluetoothProfile.LE_AUDIO) { + } + + @Override + public void onProfileConnectionStateChanged( + @NonNull CachedBluetoothDevice cachedDevice, + @ConnectionState int state, + int bluetoothProfile) { + if (bluetoothProfile == BluetoothProfile.LE_AUDIO_BROADCAST_ASSISTANT + && (state == BluetoothAdapter.STATE_CONNECTED + || state == BluetoothAdapter.STATE_DISCONNECTED)) { notifyChangeIfNeeded(); } } diff --git a/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsCategoryController.java b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsCategoryController.java index 3174ace8520..0107c6ee49b 100644 --- a/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsCategoryController.java +++ b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsCategoryController.java @@ -16,12 +16,12 @@ package com.android.settings.connecteddevice.audiosharing.audiostreams; +import android.bluetooth.BluetoothAdapter; import android.bluetooth.BluetoothProfile; import android.content.Context; import android.util.Log; import androidx.annotation.NonNull; -import androidx.annotation.Nullable; import androidx.lifecycle.LifecycleOwner; import com.android.settings.bluetooth.Utils; @@ -44,9 +44,13 @@ public class AudioStreamsCategoryController extends AudioSharingBasePreferenceCo private final BluetoothCallback mBluetoothCallback = new BluetoothCallback() { @Override - public void onActiveDeviceChanged( - @Nullable CachedBluetoothDevice activeDevice, int bluetoothProfile) { - if (bluetoothProfile == BluetoothProfile.LE_AUDIO) { + public void onProfileConnectionStateChanged( + @NonNull CachedBluetoothDevice cachedDevice, + @ConnectionState int state, + int bluetoothProfile) { + if (bluetoothProfile == BluetoothProfile.LE_AUDIO_BROADCAST_ASSISTANT + && (state == BluetoothAdapter.STATE_CONNECTED + || state == BluetoothAdapter.STATE_DISCONNECTED)) { updateVisibility(); } } diff --git a/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsProgressCategoryCallback.java b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsProgressCategoryCallback.java index cb3a0daac2f..3370d8dbfd5 100644 --- a/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsProgressCategoryCallback.java +++ b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsProgressCategoryCallback.java @@ -19,7 +19,6 @@ package com.android.settings.connecteddevice.audiosharing.audiostreams; import android.bluetooth.BluetoothDevice; import android.bluetooth.BluetoothLeBroadcastMetadata; import android.bluetooth.BluetoothLeBroadcastReceiveState; -import android.util.Log; public class AudioStreamsProgressCategoryCallback extends AudioStreamsBroadcastAssistantCallback { private static final String TAG = "AudioStreamsProgressCategoryCallback"; @@ -53,10 +52,6 @@ public class AudioStreamsProgressCategoryCallback extends AudioStreamsBroadcastA @Override public void onSearchStarted(int reason) { super.onSearchStarted(reason); - if (mCategoryController == null) { - Log.w(TAG, "onSearchStarted() : mCategoryController is null!"); - return; - } mCategoryController.setScanning(true); } @@ -69,10 +64,6 @@ public class AudioStreamsProgressCategoryCallback extends AudioStreamsBroadcastA @Override public void onSearchStopped(int reason) { super.onSearchStopped(reason); - if (mCategoryController == null) { - Log.w(TAG, "onSearchStopped() : mCategoryController is null!"); - return; - } mCategoryController.setScanning(false); } @@ -86,10 +77,6 @@ public class AudioStreamsProgressCategoryCallback extends AudioStreamsBroadcastA @Override public void onSourceFound(BluetoothLeBroadcastMetadata source) { super.onSourceFound(source); - if (mCategoryController == null) { - Log.w(TAG, "onSourceFound() : mCategoryController is null!"); - return; - } mCategoryController.handleSourceFound(source); } diff --git a/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsScanQrCodeController.java b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsScanQrCodeController.java index 5f50be72790..d0d82fbc678 100644 --- a/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsScanQrCodeController.java +++ b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsScanQrCodeController.java @@ -16,6 +16,7 @@ package com.android.settings.connecteddevice.audiosharing.audiostreams; +import android.bluetooth.BluetoothAdapter; import android.bluetooth.BluetoothProfile; import android.content.Context; import android.util.Log; @@ -47,9 +48,13 @@ public class AudioStreamsScanQrCodeController extends BasePreferenceController final BluetoothCallback mBluetoothCallback = new BluetoothCallback() { @Override - public void onActiveDeviceChanged( - @Nullable CachedBluetoothDevice activeDevice, int bluetoothProfile) { - if (bluetoothProfile == BluetoothProfile.LE_AUDIO) { + public void onProfileConnectionStateChanged( + @NonNull CachedBluetoothDevice cachedDevice, + @ConnectionState int state, + int bluetoothProfile) { + if (bluetoothProfile == BluetoothProfile.LE_AUDIO_BROADCAST_ASSISTANT + && (state == BluetoothAdapter.STATE_CONNECTED + || state == BluetoothAdapter.STATE_DISCONNECTED)) { updateVisibility(); } } diff --git a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamStateHandlerTest.java b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamStateHandlerTest.java new file mode 100644 index 00000000000..adc77a183f6 --- /dev/null +++ b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamStateHandlerTest.java @@ -0,0 +1,143 @@ +/* + * 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.google.common.truth.Truth.assertThat; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.content.Context; + +import androidx.preference.Preference; +import androidx.test.core.app.ApplicationProvider; + +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; +import org.robolectric.RobolectricTestRunner; + +@RunWith(RobolectricTestRunner.class) +public class AudioStreamStateHandlerTest { + @Rule public final MockitoRule mMockitoRule = MockitoJUnit.rule(); + private static final int SUMMARY_RES = 1; + private static final String SUMMARY = "summary"; + private final Context mContext = spy(ApplicationProvider.getApplicationContext()); + @Mock private AudioStreamsProgressCategoryController mController; + @Mock private AudioStreamsHelper mHelper; + @Mock private AudioStreamPreference mPreference; + private AudioStreamStateHandler mHandler; + + @Before + public void setUp() { + mHandler = spy(new AudioStreamStateHandler()); + } + + @Test + public void testHandleStateChange_noChange_doNothing() { + when(mHandler.getStateEnum()) + .thenReturn( + AudioStreamsProgressCategoryController.AudioStreamState + .ADD_SOURCE_BAD_CODE); + when(mPreference.getAudioStreamState()) + .thenReturn( + AudioStreamsProgressCategoryController.AudioStreamState + .ADD_SOURCE_BAD_CODE); + + mHandler.handleStateChange(mPreference, mController, mHelper); + + verify(mPreference, never()).setAudioStreamState(any()); + verify(mHandler, never()).performAction(any(), any(), any()); + verify(mPreference, never()).setIsConnected(anyBoolean(), anyString(), any()); + } + + @Test + public void testHandleStateChange_setNewState() { + when(mHandler.getStateEnum()) + .thenReturn(AudioStreamsProgressCategoryController.AudioStreamState.SOURCE_ADDED); + when(mPreference.getAudioStreamState()) + .thenReturn( + AudioStreamsProgressCategoryController.AudioStreamState + .ADD_SOURCE_BAD_CODE); + + mHandler.handleStateChange(mPreference, mController, mHelper); + + verify(mPreference) + .setAudioStreamState( + AudioStreamsProgressCategoryController.AudioStreamState.SOURCE_ADDED); + verify(mHandler).performAction(any(), any(), any()); + verify(mPreference).setIsConnected(eq(true), eq(""), eq(null)); + } + + @Test + public void testHandleStateChange_setNewState_newSummary_newListener() { + Preference.OnPreferenceClickListener listener = + mock(Preference.OnPreferenceClickListener.class); + when(mHandler.getStateEnum()) + .thenReturn( + AudioStreamsProgressCategoryController.AudioStreamState + .ADD_SOURCE_BAD_CODE); + when(mHandler.getSummary()).thenReturn(SUMMARY_RES); + when(mHandler.getOnClickListener(any())).thenReturn(listener); + when(mPreference.getAudioStreamState()) + .thenReturn( + AudioStreamsProgressCategoryController.AudioStreamState.ADD_SOURCE_FAILED); + when(mPreference.getContext()).thenReturn(mContext); + doReturn(SUMMARY).when(mContext).getString(anyInt()); + + mHandler.handleStateChange(mPreference, mController, mHelper); + + verify(mPreference) + .setAudioStreamState( + AudioStreamsProgressCategoryController.AudioStreamState + .ADD_SOURCE_BAD_CODE); + verify(mHandler).performAction(any(), any(), any()); + verify(mPreference).setIsConnected(eq(false), eq(SUMMARY), eq(listener)); + } + + @Test + public void testGetSummary() { + int res = mHandler.getSummary(); + assertThat(res).isEqualTo(AudioStreamStateHandler.EMPTY_STRING_RES); + } + + @Test + public void testGetOnClickListener() { + Preference.OnPreferenceClickListener listener = mHandler.getOnClickListener(mController); + assertThat(listener).isNull(); + } + + @Test + public void testGetStateEnum() { + var state = mHandler.getStateEnum(); + assertThat(state) + .isEqualTo(AudioStreamsProgressCategoryController.AudioStreamState.UNKNOWN); + } +} diff --git a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsActiveDeviceSummaryUpdaterTest.java b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsActiveDeviceSummaryUpdaterTest.java index 4403528e2fb..d6b99a1d3e3 100644 --- a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsActiveDeviceSummaryUpdaterTest.java +++ b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsActiveDeviceSummaryUpdaterTest.java @@ -20,6 +20,7 @@ import static com.google.common.truth.Truth.assertThat; import static org.mockito.Mockito.when; +import android.bluetooth.BluetoothAdapter; import android.bluetooth.BluetoothProfile; import android.content.Context; @@ -76,25 +77,46 @@ public class AudioStreamsActiveDeviceSummaryUpdaterTest { } @Test - public void onActiveDeviceChanged_notLeProfile_doNothing() { - mUpdater.onActiveDeviceChanged(mCachedBluetoothDevice, 0); + public void unregister_doNothing() { + mUpdater.register(false); assertThat(mUpdatedSummary).isNull(); } @Test - public void onActiveDeviceChanged_leProfile_summaryUpdated() { + public void onProfileConnectionStateChanged_notLeAssistProfile_doNothing() { + mUpdater.onProfileConnectionStateChanged(mCachedBluetoothDevice, 0, 0); + + assertThat(mUpdatedSummary).isNull(); + } + + @Test + public void onProfileConnectionStateChanged_leAssistantProfile_summaryUpdated() { ShadowAudioStreamsHelper.setCachedBluetoothDeviceInSharingOrLeConnected( mCachedBluetoothDevice); when(mCachedBluetoothDevice.getName()).thenReturn(DEVICE_NAME); - mUpdater.onActiveDeviceChanged(mCachedBluetoothDevice, BluetoothProfile.LE_AUDIO); + mUpdater.onProfileConnectionStateChanged( + mCachedBluetoothDevice, + BluetoothAdapter.STATE_CONNECTED, + BluetoothProfile.LE_AUDIO_BROADCAST_ASSISTANT); assertThat(mUpdatedSummary).isEqualTo(DEVICE_NAME); } @Test - public void onActiveDeviceChanged_leProfile_noDevice_summaryUpdated() { - mUpdater.onActiveDeviceChanged(mCachedBluetoothDevice, BluetoothProfile.LE_AUDIO); + public void onActiveDeviceChanged_leAssistantProfile_noDevice_summaryUpdated() { + mUpdater.onProfileConnectionStateChanged( + mCachedBluetoothDevice, + BluetoothAdapter.STATE_CONNECTED, + BluetoothProfile.LE_AUDIO_BROADCAST_ASSISTANT); + + assertThat(mUpdatedSummary) + .isEqualTo(mContext.getString(R.string.audio_streams_dialog_no_le_device_title)); + } + + @Test + public void onBluetoothStateOff_summaryUpdated() { + mUpdater.onBluetoothStateChanged(BluetoothAdapter.STATE_OFF); assertThat(mUpdatedSummary) .isEqualTo(mContext.getString(R.string.audio_streams_dialog_no_le_device_title)); diff --git a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsCategoryControllerTest.java b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsCategoryControllerTest.java index e4b6903e800..0e003097a3f 100644 --- a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsCategoryControllerTest.java +++ b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsCategoryControllerTest.java @@ -23,11 +23,13 @@ import static com.google.common.truth.Truth.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.robolectric.Shadows.shadowOf; import android.bluetooth.BluetoothAdapter; +import android.bluetooth.BluetoothProfile; import android.bluetooth.BluetoothStatusCodes; import android.content.Context; import android.os.Looper; @@ -42,6 +44,7 @@ import com.android.settings.bluetooth.Utils; 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.BluetoothCallback; import com.android.settingslib.bluetooth.BluetoothEventManager; import com.android.settingslib.bluetooth.CachedBluetoothDevice; import com.android.settingslib.bluetooth.LocalBluetoothLeBroadcast; @@ -57,6 +60,7 @@ 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; @@ -116,7 +120,7 @@ public class AudioStreamsCategoryControllerTest { when(mBroadcast.isProfileReady()).thenReturn(true); when(mAssistant.isProfileReady()).thenReturn(true); when(mVolumeControl.isProfileReady()).thenReturn(true); - mController = new AudioStreamsCategoryController(mContext, KEY); + mController = spy(new AudioStreamsCategoryController(mContext, KEY)); mPreference = new Preference(mContext); when(mScreen.findPreference(KEY)).thenReturn(mPreference); mController.displayPreference(mScreen); @@ -228,4 +232,21 @@ public class AudioStreamsCategoryControllerTest { shadowOf(Looper.getMainLooper()).idle(); assertThat(mPreference.isVisible()).isTrue(); } + + @Test + public void onProfileConnectionStateChanged_updateVisibility() { + mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_QR_CODE_PRIVATE_BROADCAST_SHARING); + ArgumentCaptor argumentCaptor = + ArgumentCaptor.forClass(BluetoothCallback.class); + mController.onStart(mLifecycleOwner); + verify(mBluetoothEventManager).registerCallback(argumentCaptor.capture()); + + BluetoothCallback callback = argumentCaptor.getValue(); + callback.onProfileConnectionStateChanged( + mCachedBluetoothDevice, + BluetoothProfile.LE_AUDIO_BROADCAST_ASSISTANT, + BluetoothAdapter.STATE_DISCONNECTED); + + verify(mController).updateVisibility(); + } } diff --git a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsDialogFragmentTest.java b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsDialogFragmentTest.java new file mode 100644 index 00000000000..e83dade16e9 --- /dev/null +++ b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsDialogFragmentTest.java @@ -0,0 +1,85 @@ +/* + * 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.google.common.truth.Truth.assertThat; + +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; + +import android.app.settings.SettingsEnums; +import android.content.Context; +import android.os.Bundle; + +import androidx.test.core.app.ApplicationProvider; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; +import org.robolectric.shadows.ShadowAlertDialog; +import org.robolectric.shadows.ShadowLooper; +import org.robolectric.shadows.androidx.fragment.FragmentController; + +@RunWith(RobolectricTestRunner.class) +@Config( + shadows = { + ShadowAlertDialog.class, + }) +public class AudioStreamsDialogFragmentTest { + private final Context mContext = ApplicationProvider.getApplicationContext(); + private AudioStreamsDialogFragment.DialogBuilder mDialogBuilder; + private AudioStreamsDialogFragment mFragment; + + @Before + public void setUp() { + mDialogBuilder = spy(new AudioStreamsDialogFragment.DialogBuilder(mContext)); + mFragment = new AudioStreamsDialogFragment(mDialogBuilder, SettingsEnums.PAGE_UNKNOWN); + } + + @After + public void tearDown() { + ShadowAlertDialog.reset(); + } + + @Test + public void testGetMetricsCategory() { + int dialogId = mFragment.getMetricsCategory(); + + assertThat(dialogId).isEqualTo(SettingsEnums.PAGE_UNKNOWN); + } + + @Test + public void testOnCreateDialog() { + mFragment.onCreateDialog(Bundle.EMPTY); + + verify(mDialogBuilder).build(); + } + + @Test + public void testShowDialog() { + FragmentController.setupFragment(mFragment); + AudioStreamsDialogFragment.show(mFragment, mDialogBuilder, SettingsEnums.PAGE_UNKNOWN); + ShadowLooper.idleMainLooper(); + + var dialog = ShadowAlertDialog.getLatestAlertDialog(); + assertThat(dialog).isNotNull(); + assertThat(dialog.isShowing()).isTrue(); + } +} diff --git a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsProgressCategoryCallbackTest.java b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsProgressCategoryCallbackTest.java new file mode 100644 index 00000000000..164c2f093e8 --- /dev/null +++ b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsProgressCategoryCallbackTest.java @@ -0,0 +1,140 @@ +/* + * 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 org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.bluetooth.BluetoothDevice; +import android.bluetooth.BluetoothLeBroadcastMetadata; +import android.bluetooth.BluetoothLeBroadcastReceiveState; + +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; +import org.robolectric.RobolectricTestRunner; + +import java.util.ArrayList; +import java.util.List; + +@RunWith(RobolectricTestRunner.class) +public class AudioStreamsProgressCategoryCallbackTest { + @Rule public final MockitoRule mMockitoRule = MockitoJUnit.rule(); + @Mock private AudioStreamsProgressCategoryController mController; + @Mock private BluetoothDevice mDevice; + @Mock private BluetoothLeBroadcastReceiveState mState; + @Mock private BluetoothLeBroadcastMetadata mMetadata; + private AudioStreamsProgressCategoryCallback mCallback; + + @Before + public void setUp() { + mCallback = new AudioStreamsProgressCategoryCallback(mController); + } + + @Test + public void testOnReceiveStateChanged_connected() { + List bisSyncState = new ArrayList<>(); + bisSyncState.add(1L); + when(mState.getBisSyncState()).thenReturn(bisSyncState); + mCallback.onReceiveStateChanged(mDevice, /* sourceId= */ 0, mState); + + verify(mController).handleSourceConnected(any()); + } + + @Test + public void testOnReceiveStateChanged_badCode() { + when(mState.getPaSyncState()) + .thenReturn(BluetoothLeBroadcastReceiveState.PA_SYNC_STATE_SYNCHRONIZED); + when(mState.getBigEncryptionState()) + .thenReturn(BluetoothLeBroadcastReceiveState.BIG_ENCRYPTION_STATE_BAD_CODE); + mCallback.onReceiveStateChanged(mDevice, /* sourceId= */ 0, mState); + + verify(mController).handleSourceConnectBadCode(any()); + } + + @Test + public void testOnSearchStartFailed() { + mCallback.onSearchStartFailed(/* reason= */ 0); + + verify(mController).showToast(anyString()); + verify(mController).setScanning(anyBoolean()); + } + + @Test + public void testOnSearchStarted() { + mCallback.onSearchStarted(/* reason= */ 0); + + verify(mController).setScanning(anyBoolean()); + } + + @Test + public void testOnSearchStopFailed() { + mCallback.onSearchStopFailed(/* reason= */ 0); + + verify(mController).showToast(anyString()); + } + + @Test + public void testOnSearchStopped() { + mCallback.onSearchStopped(/* reason= */ 0); + + verify(mController).setScanning(anyBoolean()); + } + + @Test + public void testOnSourceAddFailed() { + when(mMetadata.getBroadcastId()).thenReturn(1); + mCallback.onSourceAddFailed(mDevice, mMetadata, /* reason= */ 0); + + verify(mController).handleSourceFailedToConnect(1); + } + + @Test + public void testOnSourceFound() { + mCallback.onSourceFound(mMetadata); + + verify(mController).handleSourceFound(mMetadata); + } + + @Test + public void testOnSourceLost() { + mCallback.onSourceLost(/* broadcastId= */ 1); + + verify(mController).handleSourceLost(1); + } + + @Test + public void testOnSourceRemoveFailed() { + mCallback.onSourceRemoveFailed(mDevice, /* sourceId= */ 0, /* reason= */ 0); + + verify(mController).showToast(anyString()); + } + + @Test + public void testOnSourceRemoved() { + mCallback.onSourceRemoved(mDevice, /* sourceId= */ 0, /* reason= */ 0); + + verify(mController).handleSourceRemoved(); + } +} diff --git a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsScanQrCodeControllerTest.java b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsScanQrCodeControllerTest.java index 4990f26ac22..a83cbf03379 100644 --- a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsScanQrCodeControllerTest.java +++ b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsScanQrCodeControllerTest.java @@ -16,29 +16,38 @@ package com.android.settings.connecteddevice.audiosharing.audiostreams; +import static android.app.settings.SettingsEnums.AUDIO_STREAM_MAIN; + +import static com.android.settings.connecteddevice.audiosharing.audiostreams.AudioStreamsScanQrCodeController.REQUEST_SCAN_BT_BROADCAST_QR_CODE; 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.anyString; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.spy; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import android.bluetooth.BluetoothAdapter; import android.bluetooth.BluetoothProfile; import android.content.Context; +import android.content.Intent; import androidx.lifecycle.LifecycleOwner; import androidx.preference.Preference; import androidx.preference.PreferenceScreen; import androidx.test.core.app.ApplicationProvider; +import com.android.settings.R; +import com.android.settings.SettingsActivity; import com.android.settings.connecteddevice.audiosharing.audiostreams.testshadows.ShadowAudioStreamsHelper; import com.android.settings.testutils.shadow.ShadowBluetoothUtils; import com.android.settingslib.bluetooth.BluetoothEventManager; import com.android.settingslib.bluetooth.CachedBluetoothDevice; import com.android.settingslib.bluetooth.LocalBluetoothManager; +import com.android.settingslib.core.instrumentation.MetricsFeatureProvider; import com.android.settingslib.core.lifecycle.Lifecycle; import org.junit.After; @@ -46,6 +55,7 @@ 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; @@ -139,17 +149,46 @@ public class AudioStreamsScanQrCodeControllerTest { public void onPreferenceClick_hasFragment_launchSubSetting() { mController.displayPreference(mScreen); mController.setFragment(mFragment); + when(mFragment.getMetricsCategory()).thenReturn(AUDIO_STREAM_MAIN); var listener = mPreference.getOnPreferenceClickListener(); assertThat(listener).isNotNull(); + + // mContext is not an Activity context, calling startActivity() from outside of an Activity + // context requires the FLAG_ACTIVITY_NEW_TASK flag, create a mock to avoid this + // AndroidRuntimeException. + Context activityContext = mock(Context.class); + when(mPreference.getContext()).thenReturn(activityContext); + when(mPreference.getKey()).thenReturn(AudioStreamsScanQrCodeController.KEY); + var clicked = listener.onPreferenceClick(mPreference); + + ArgumentCaptor intentCaptor = ArgumentCaptor.forClass(Intent.class); + ArgumentCaptor requestCodeCaptor = ArgumentCaptor.forClass(Integer.class); + verify(mFragment) + .startActivityForResult(intentCaptor.capture(), requestCodeCaptor.capture()); + + Intent intent = intentCaptor.getValue(); + assertThat(intent.getStringExtra(SettingsActivity.EXTRA_SHOW_FRAGMENT)) + .isEqualTo(AudioStreamsQrCodeScanFragment.class.getName()); + assertThat(intent.getIntExtra(SettingsActivity.EXTRA_SHOW_FRAGMENT_TITLE_RESID, 0)) + .isEqualTo(R.string.audio_streams_main_page_scan_qr_code_title); + assertThat(intent.getIntExtra(MetricsFeatureProvider.EXTRA_SOURCE_METRICS_CATEGORY, 0)) + .isEqualTo(AUDIO_STREAM_MAIN); + + int requestCode = requestCodeCaptor.getValue(); + assertThat(requestCode).isEqualTo(REQUEST_SCAN_BT_BROADCAST_QR_CODE); + assertThat(clicked).isTrue(); } @Test public void updateVisibility_noConnected_invisible() { mController.displayPreference(mScreen); - mController.mBluetoothCallback.onActiveDeviceChanged(mDevice, BluetoothProfile.LE_AUDIO); + mController.mBluetoothCallback.onProfileConnectionStateChanged( + mDevice, + BluetoothAdapter.STATE_DISCONNECTED, + BluetoothProfile.LE_AUDIO_BROADCAST_ASSISTANT); assertThat(mPreference.isVisible()).isFalse(); } @@ -158,7 +197,10 @@ public class AudioStreamsScanQrCodeControllerTest { public void updateVisibility_hasConnected_visible() { mController.displayPreference(mScreen); ShadowAudioStreamsHelper.setCachedBluetoothDeviceInSharingOrLeConnected(mDevice); - mController.mBluetoothCallback.onActiveDeviceChanged(mDevice, BluetoothProfile.LE_AUDIO); + mController.mBluetoothCallback.onProfileConnectionStateChanged( + mDevice, + BluetoothAdapter.STATE_CONNECTED, + BluetoothProfile.LE_AUDIO_BROADCAST_ASSISTANT); assertThat(mPreference.isVisible()).isTrue(); } From e0abdc5c7aa5bc1eee13b1e56f51e538282a3224 Mon Sep 17 00:00:00 2001 From: chelseahao Date: Fri, 21 Jun 2024 14:14:12 +0800 Subject: [PATCH 3/4] [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; From 4afef7eed1910f5459f2c511e45ff8b2c54bfa4e Mon Sep 17 00:00:00 2001 From: chelseahao Date: Mon, 24 Jun 2024 14:33:32 +0800 Subject: [PATCH 4/4] [Audiosharing] Created test for the name and password preferences. Test: atest -c com.android.settings.connecteddevice.audiosharing Flag: com.android.settingslib.flags.enable_le_audio_qr_code_private_broadcast_sharing Bug: 345686602 Change-Id: I9036e560e29aba9555c207dce1b2018be010bca8 --- .../AudioSharingNamePreference.java | 6 +- .../AudioSharingNamePreferenceController.java | 4 +- ...ioSharingPasswordPreferenceController.java | 93 ++++- ...ioSharingNamePreferenceControllerTest.java | 319 +++++++++++++++++ .../AudioSharingNamePreferenceTest.java | 141 ++++++++ .../AudioSharingNameTextValidatorTest.java | 52 +++ ...aringPasswordPreferenceControllerTest.java | 335 ++++++++++++++++++ .../AudioSharingPasswordPreferenceTest.java | 215 +++++++++++ .../AudioSharingPasswordValidatorTest.java | 53 +++ 9 files changed, 1200 insertions(+), 18 deletions(-) create mode 100644 tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingNamePreferenceControllerTest.java create mode 100644 tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingNamePreferenceTest.java create mode 100644 tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingNameTextValidatorTest.java create mode 100644 tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingPasswordPreferenceControllerTest.java create mode 100644 tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingPasswordPreferenceTest.java create mode 100644 tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingPasswordValidatorTest.java diff --git a/src/com/android/settings/connecteddevice/audiosharing/AudioSharingNamePreference.java b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingNamePreference.java index 0bb6b607041..bfccdc4c672 100644 --- a/src/com/android/settings/connecteddevice/audiosharing/AudioSharingNamePreference.java +++ b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingNamePreference.java @@ -95,14 +95,14 @@ public class AudioSharingNamePreference extends ValidatedEditTextPreference { } private void configureInvisibleStateForQrCodeIcon(ImageButton shareButton, View divider) { - divider.setVisibility(View.INVISIBLE); - shareButton.setVisibility(View.INVISIBLE); + divider.setVisibility(View.GONE); + shareButton.setVisibility(View.GONE); shareButton.setOnClickListener(null); } private void launchAudioSharingQrCodeFragment() { new SubSettingLauncher(getContext()) - .setTitleText(getContext().getString(R.string.audio_streams_qr_code_page_title)) + .setTitleRes(R.string.audio_streams_qr_code_page_title) .setDestination(AudioStreamsQrCodeFragment.class.getName()) .setSourceMetricsCategory(SettingsEnums.AUDIO_SHARING_SETTINGS) .launch(); diff --git a/src/com/android/settings/connecteddevice/audiosharing/AudioSharingNamePreferenceController.java b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingNamePreferenceController.java index 24b8f20cf51..894ba487014 100644 --- a/src/com/android/settings/connecteddevice/audiosharing/AudioSharingNamePreferenceController.java +++ b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingNamePreferenceController.java @@ -26,6 +26,7 @@ import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; import androidx.lifecycle.DefaultLifecycleObserver; import androidx.lifecycle.LifecycleOwner; import androidx.preference.Preference; @@ -56,7 +57,8 @@ public class AudioSharingNamePreferenceController extends BasePreferenceControll private static final boolean DEBUG = BluetoothUtils.D; private static final String PREF_KEY = "audio_sharing_stream_name"; - private final BluetoothLeBroadcast.Callback mBroadcastCallback = + @VisibleForTesting + final BluetoothLeBroadcast.Callback mBroadcastCallback = new BluetoothLeBroadcast.Callback() { @Override public void onBroadcastMetadataChanged( diff --git a/src/com/android/settings/connecteddevice/audiosharing/AudioSharingPasswordPreferenceController.java b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingPasswordPreferenceController.java index 258cf3be843..14930e11a6a 100644 --- a/src/com/android/settings/connecteddevice/audiosharing/AudioSharingPasswordPreferenceController.java +++ b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingPasswordPreferenceController.java @@ -19,12 +19,19 @@ package com.android.settings.connecteddevice.audiosharing; import static com.android.settings.connecteddevice.audiosharing.AudioSharingUtils.isBroadcasting; import android.app.settings.SettingsEnums; +import android.content.ContentResolver; import android.content.Context; import android.content.SharedPreferences; +import android.database.ContentObserver; +import android.os.Handler; +import android.os.Looper; +import android.provider.Settings; import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.lifecycle.DefaultLifecycleObserver; +import androidx.lifecycle.LifecycleOwner; import androidx.preference.PreferenceScreen; import com.android.settings.R; @@ -41,15 +48,19 @@ import java.nio.charset.StandardCharsets; public class AudioSharingPasswordPreferenceController extends BasePreferenceController implements ValidatedEditTextPreference.Validator, - AudioSharingPasswordPreference.OnDialogEventListener { - + AudioSharingPasswordPreference.OnDialogEventListener, + DefaultLifecycleObserver { private static final String TAG = "AudioSharingPasswordPreferenceController"; private static final String PREF_KEY = "audio_sharing_stream_password"; private static final String SHARED_PREF_NAME = "audio_sharing_settings"; private static final String SHARED_PREF_KEY = "default_password"; + @Nullable private final ContentResolver mContentResolver; + @Nullable private final SharedPreferences mSharedPref; @Nullable private final LocalBluetoothManager mBtManager; @Nullable private final LocalBluetoothLeBroadcast mBroadcast; @Nullable private AudioSharingPasswordPreference mPreference; + private final ContentObserver mSettingsObserver; + private final SharedPreferences.OnSharedPreferenceChangeListener mSharedPrefChangeListener; private final AudioSharingPasswordValidator mAudioSharingPasswordValidator; private final MetricsFeatureProvider mMetricsFeatureProvider; @@ -61,9 +72,44 @@ public class AudioSharingPasswordPreferenceController extends BasePreferenceCont ? mBtManager.getProfileManager().getLeAudioBroadcastProfile() : null; mAudioSharingPasswordValidator = new AudioSharingPasswordValidator(); + mContentResolver = context.getContentResolver(); + mSettingsObserver = new PasswordSettingsObserver(); + mSharedPref = context.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE); + mSharedPrefChangeListener = new PasswordSharedPrefChangeListener(); mMetricsFeatureProvider = FeatureFactory.getFeatureFactory().getMetricsFeatureProvider(); } + @Override + public void onStart(@NonNull LifecycleOwner owner) { + if (!isAvailable()) { + Log.d(TAG, "Feature is not available."); + return; + } + if (mContentResolver != null) { + mContentResolver.registerContentObserver( + Settings.Secure.getUriFor(Settings.Secure.BLUETOOTH_LE_BROADCAST_CODE), + false, + mSettingsObserver); + } + if (mSharedPref != null) { + mSharedPref.registerOnSharedPreferenceChangeListener(mSharedPrefChangeListener); + } + } + + @Override + public void onStop(@NonNull LifecycleOwner owner) { + if (!isAvailable()) { + Log.d(TAG, "Feature is not available."); + return; + } + if (mContentResolver != null) { + mContentResolver.unregisterContentObserver(mSettingsObserver); + } + if (mSharedPref != null) { + mSharedPref.unregisterOnSharedPreferenceChangeListener(mSharedPrefChangeListener); + } + } + @Override public int getAvailabilityStatus() { return AudioSharingUtils.isFeatureEnabled() ? AVAILABLE : UNSUPPORTED_ON_DEVICE; @@ -125,7 +171,6 @@ public class AudioSharingPasswordPreferenceController extends BasePreferenceCont persistDefaultPassword(mContext, password); mBroadcast.setBroadcastCode( isPublicBroadcast ? new byte[0] : password.getBytes()); - updatePreference(); mMetricsFeatureProvider.action( mContext, SettingsEnums.ACTION_AUDIO_STREAM_PASSWORD_UPDATED, @@ -164,32 +209,52 @@ public class AudioSharingPasswordPreferenceController extends BasePreferenceCont }); } - private static void persistDefaultPassword(Context context, String defaultPassword) { + private class PasswordSettingsObserver extends ContentObserver { + PasswordSettingsObserver() { + super(new Handler(Looper.getMainLooper())); + } + + @Override + public void onChange(boolean selfChange) { + Log.d(TAG, "onChange, broadcast password has been changed"); + updatePreference(); + } + } + + private class PasswordSharedPrefChangeListener + implements SharedPreferences.OnSharedPreferenceChangeListener { + @Override + public void onSharedPreferenceChanged( + SharedPreferences sharedPreferences, @Nullable String key) { + if (!SHARED_PREF_KEY.equals(key)) { + return; + } + Log.d(TAG, "onSharedPreferenceChanged, default password has been changed"); + updatePreference(); + } + } + + private void persistDefaultPassword(Context context, String defaultPassword) { if (getDefaultPassword(context).equals(defaultPassword)) { return; } - - SharedPreferences sharedPref = - context.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE); - if (sharedPref == null) { + if (mSharedPref == null) { Log.w(TAG, "persistDefaultPassword(): sharedPref is empty!"); return; } - SharedPreferences.Editor editor = sharedPref.edit(); + SharedPreferences.Editor editor = mSharedPref.edit(); editor.putString(SHARED_PREF_KEY, defaultPassword); editor.apply(); } - private static String getDefaultPassword(Context context) { - SharedPreferences sharedPref = - context.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE); - if (sharedPref == null) { + private String getDefaultPassword(Context context) { + if (mSharedPref == null) { Log.w(TAG, "getDefaultPassword(): sharedPref is empty!"); return ""; } - String value = sharedPref.getString(SHARED_PREF_KEY, ""); + String value = mSharedPref.getString(SHARED_PREF_KEY, ""); if (value != null && value.isEmpty()) { Log.w(TAG, "getDefaultPassword(): default password is empty!"); } diff --git a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingNamePreferenceControllerTest.java b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingNamePreferenceControllerTest.java new file mode 100644 index 00000000000..618e02129e6 --- /dev/null +++ b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingNamePreferenceControllerTest.java @@ -0,0 +1,319 @@ +/* + * 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; + +import static com.android.settings.core.BasePreferenceController.AVAILABLE; +import static com.android.settings.core.BasePreferenceController.UNSUPPORTED_ON_DEVICE; + +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.anyInt; +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 android.app.settings.SettingsEnums; +import android.bluetooth.BluetoothAdapter; +import android.bluetooth.BluetoothLeBroadcast; +import android.bluetooth.BluetoothLeBroadcastMetadata; +import android.bluetooth.BluetoothStatusCodes; +import android.content.Context; +import android.platform.test.flag.junit.SetFlagsRule; + +import androidx.lifecycle.LifecycleOwner; +import androidx.preference.PreferenceScreen; +import androidx.test.core.app.ApplicationProvider; + +import com.android.settings.bluetooth.Utils; +import com.android.settings.testutils.FakeFeatureFactory; +import com.android.settings.testutils.shadow.ShadowBluetoothAdapter; +import com.android.settings.testutils.shadow.ShadowBluetoothUtils; +import com.android.settingslib.bluetooth.BluetoothEventManager; +import com.android.settingslib.bluetooth.LocalBluetoothLeBroadcast; +import com.android.settingslib.bluetooth.LocalBluetoothLeBroadcastAssistant; +import com.android.settingslib.bluetooth.LocalBluetoothManager; +import com.android.settingslib.bluetooth.LocalBluetoothProfileManager; +import com.android.settingslib.bluetooth.VolumeControlProfile; +import com.android.settingslib.core.lifecycle.Lifecycle; +import com.android.settingslib.flags.Flags; + +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.Spy; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; +import org.robolectric.shadow.api.Shadow; +import org.robolectric.shadows.ShadowLooper; + +import java.util.concurrent.Executor; + +@RunWith(RobolectricTestRunner.class) +@Config( + shadows = { + ShadowBluetoothAdapter.class, + ShadowBluetoothUtils.class, + }) +public class AudioSharingNamePreferenceControllerTest { + private static final String PREF_KEY = "audio_sharing_stream_name"; + private static final String BROADCAST_NAME = "broadcast_name"; + private static final CharSequence UPDATED_NAME = "updated_name"; + + @Rule public final MockitoRule mMockitoRule = MockitoJUnit.rule(); + @Rule public final SetFlagsRule mSetFlagsRule = new SetFlagsRule(); + @Spy Context mContext = ApplicationProvider.getApplicationContext(); + @Mock private LocalBluetoothLeBroadcast mBroadcast; + @Mock private LocalBluetoothLeBroadcastAssistant mAssistant; + @Mock private VolumeControlProfile mVolumeControl; + @Mock private LocalBluetoothManager mLocalBtManager; + @Mock private BluetoothEventManager mEventManager; + @Mock private LocalBluetoothProfileManager mProfileManager; + @Mock private PreferenceScreen mScreen; + private AudioSharingNamePreferenceController mController; + private ShadowBluetoothAdapter mShadowBluetoothAdapter; + private Lifecycle mLifecycle; + private LifecycleOwner mLifecycleOwner; + private AudioSharingNamePreference mPreference; + private FakeFeatureFactory mFeatureFactory; + + @Before + public void setUp() { + mShadowBluetoothAdapter = Shadow.extract(BluetoothAdapter.getDefaultAdapter()); + mShadowBluetoothAdapter.setEnabled(true); + mShadowBluetoothAdapter.setIsLeAudioBroadcastSourceSupported( + BluetoothStatusCodes.FEATURE_SUPPORTED); + mShadowBluetoothAdapter.setIsLeAudioBroadcastAssistantSupported( + BluetoothStatusCodes.FEATURE_SUPPORTED); + ShadowBluetoothUtils.sLocalBluetoothManager = mLocalBtManager; + mLocalBtManager = Utils.getLocalBtManager(mContext); + when(mLocalBtManager.getEventManager()).thenReturn(mEventManager); + when(mLocalBtManager.getProfileManager()).thenReturn(mProfileManager); + when(mProfileManager.getLeAudioBroadcastProfile()).thenReturn(mBroadcast); + when(mProfileManager.getLeAudioBroadcastAssistantProfile()).thenReturn(mAssistant); + when(mProfileManager.getVolumeControlProfile()).thenReturn(mVolumeControl); + when(mBroadcast.isProfileReady()).thenReturn(true); + when(mAssistant.isProfileReady()).thenReturn(true); + when(mVolumeControl.isProfileReady()).thenReturn(true); + when(mBroadcast.isProfileReady()).thenReturn(true); + mFeatureFactory = FakeFeatureFactory.setupForTest(); + mLifecycleOwner = () -> mLifecycle; + mLifecycle = new Lifecycle(mLifecycleOwner); + mController = new AudioSharingNamePreferenceController(mContext, PREF_KEY); + mPreference = spy(new AudioSharingNamePreference(mContext)); + when(mScreen.findPreference(PREF_KEY)).thenReturn(mPreference); + } + + @Test + public void getAvailabilityStatus_flagOn_available() { + mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING); + assertThat(mController.getAvailabilityStatus()).isEqualTo(AVAILABLE); + } + + @Test + public void getAvailabilityStatus_flagOff_unsupported() { + mSetFlagsRule.disableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING); + assertThat(mController.getAvailabilityStatus()).isEqualTo(UNSUPPORTED_ON_DEVICE); + } + + @Test + public void onStart_flagOff_doNothing() { + mSetFlagsRule.disableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING); + mController.onStart(mLifecycleOwner); + verify(mBroadcast, never()) + .registerServiceCallBack( + any(Executor.class), any(BluetoothLeBroadcast.Callback.class)); + } + + @Test + public void onStart_flagOn_registerCallbacks() { + mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING); + mController.onStart(mLifecycleOwner); + verify(mBroadcast) + .registerServiceCallBack( + any(Executor.class), any(BluetoothLeBroadcast.Callback.class)); + } + + @Test + public void onStart_flagOn_serviceNotReady_registerCallbacks() { + mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING); + when(mBroadcast.isProfileReady()).thenReturn(false); + mController.onStart(mLifecycleOwner); + verify(mProfileManager) + .addServiceListener(any(LocalBluetoothProfileManager.ServiceListener.class)); + } + + @Test + public void onServiceConnected_removeCallbacks() { + mController.onServiceConnected(); + verify(mProfileManager) + .removeServiceListener(any(LocalBluetoothProfileManager.ServiceListener.class)); + } + + @Test + public void onStop_flagOff_doNothing() { + mSetFlagsRule.disableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING); + mController.onStart(mLifecycleOwner); + mController.onStop(mLifecycleOwner); + verify(mBroadcast, never()) + .unregisterServiceCallBack(any(BluetoothLeBroadcast.Callback.class)); + } + + @Test + public void onStop_flagOn_unregisterCallbacks() { + mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING); + mController.onStart(mLifecycleOwner); + mController.onStop(mLifecycleOwner); + verify(mBroadcast).unregisterServiceCallBack(any(BluetoothLeBroadcast.Callback.class)); + } + + @Test + public void displayPreference_updateName_showIcon() { + when(mBroadcast.getBroadcastName()).thenReturn(BROADCAST_NAME); + when(mBroadcast.isEnabled(any())).thenReturn(true); + mController.displayPreference(mScreen); + ShadowLooper.idleMainLooper(); + + assertThat(mPreference.getText()).isEqualTo(BROADCAST_NAME); + assertThat(mPreference.getSummary()).isEqualTo(BROADCAST_NAME); + verify(mPreference).setValidator(any()); + verify(mPreference).setShowQrCodeIcon(true); + } + + @Test + public void displayPreference_updateName_hideIcon() { + when(mBroadcast.getBroadcastName()).thenReturn(BROADCAST_NAME); + when(mBroadcast.isEnabled(any())).thenReturn(false); + mController.displayPreference(mScreen); + ShadowLooper.idleMainLooper(); + + assertThat(mPreference.getText()).isEqualTo(BROADCAST_NAME); + assertThat(mPreference.getSummary()).isEqualTo(BROADCAST_NAME); + verify(mPreference).setValidator(any()); + verify(mPreference).setShowQrCodeIcon(false); + } + + @Test + public void onPreferenceChange_noChange_doNothing() { + when(mPreference.getSummary()).thenReturn(BROADCAST_NAME); + mController.displayPreference(mScreen); + boolean changed = mController.onPreferenceChange(mPreference, BROADCAST_NAME); + ShadowLooper.idleMainLooper(); + + verify(mBroadcast, never()).setBroadcastName(anyString()); + verify(mBroadcast, never()).setProgramInfo(anyString()); + verify(mBroadcast, never()).updateBroadcast(); + verify(mFeatureFactory.metricsFeatureProvider, never()).action(any(), anyInt(), anyInt()); + + assertThat(changed).isFalse(); + } + + @Test + public void onPreferenceChange_changed_updateName_broadcasting() { + when(mPreference.getSummary()).thenReturn(BROADCAST_NAME); + when(mBroadcast.isEnabled(any())).thenReturn(true); + mController.displayPreference(mScreen); + boolean changed = mController.onPreferenceChange(mPreference, UPDATED_NAME); + ShadowLooper.idleMainLooper(); + + verify(mBroadcast).setBroadcastName(UPDATED_NAME.toString()); + verify(mBroadcast).setProgramInfo(UPDATED_NAME.toString()); + verify(mBroadcast).updateBroadcast(); + verify(mFeatureFactory.metricsFeatureProvider) + .action(mContext, SettingsEnums.ACTION_AUDIO_STREAM_NAME_UPDATED, 1); + assertThat(changed).isTrue(); + } + + @Test + public void onPreferenceChange_changed_updateName_notBroadcasting() { + when(mPreference.getSummary()).thenReturn(BROADCAST_NAME); + when(mBroadcast.isEnabled(any())).thenReturn(false); + mController.displayPreference(mScreen); + boolean changed = mController.onPreferenceChange(mPreference, UPDATED_NAME); + ShadowLooper.idleMainLooper(); + + verify(mBroadcast).setBroadcastName(UPDATED_NAME.toString()); + verify(mBroadcast).setProgramInfo(UPDATED_NAME.toString()); + verify(mBroadcast, never()).updateBroadcast(); + verify(mFeatureFactory.metricsFeatureProvider) + .action(mContext, SettingsEnums.ACTION_AUDIO_STREAM_NAME_UPDATED, 0); + assertThat(changed).isTrue(); + } + + @Test + public void unrelatedCallbacks_doNotUpdateIcon() { + mController.displayPreference(mScreen); + mController.mBroadcastCallback.onBroadcastStartFailed(/* reason= */ 0); + mController.mBroadcastCallback.onBroadcastStarted(/* reason= */ 0, /* broadcastId= */ 0); + mController.mBroadcastCallback.onBroadcastStopFailed(/* reason= */ 0); + mController.mBroadcastCallback.onBroadcastUpdateFailed( + /* reason= */ 0, /* broadcastId= */ 0); + mController.mBroadcastCallback.onBroadcastUpdated(/* reason= */ 0, /* broadcastId= */ 0); + mController.mBroadcastCallback.onPlaybackStarted(/* reason= */ 0, /* broadcastId= */ 0); + mController.mBroadcastCallback.onPlaybackStopped(/* reason= */ 0, /* broadcastId= */ 0); + + ShadowLooper.idleMainLooper(); + // Should be called once in displayPreference, but not called after callbacks + verify(mPreference).setShowQrCodeIcon(anyBoolean()); + } + + @Test + public void broadcastOnCallback_updateIcon() { + mController.displayPreference(mScreen); + mController.mBroadcastCallback.onBroadcastMetadataChanged( + /* broadcastId= */ 0, mock(BluetoothLeBroadcastMetadata.class)); + + ShadowLooper.idleMainLooper(); + + // Should be called twice, in displayPreference and also after callback + verify(mPreference, times(2)).setShowQrCodeIcon(anyBoolean()); + } + + @Test + public void broadcastStopCallback_updateIcon() { + mController.displayPreference(mScreen); + mController.mBroadcastCallback.onBroadcastStopped(/* reason= */ 0, /* broadcastId= */ 0); + + ShadowLooper.idleMainLooper(); + + // Should be called twice, in displayPreference and also after callback + verify(mPreference, times(2)).setShowQrCodeIcon(anyBoolean()); + } + + @Test + public void idTextValid_emptyString() { + boolean valid = mController.isTextValid(""); + + assertThat(valid).isFalse(); + } + + @Test + public void idTextValid_validName() { + boolean valid = mController.isTextValid("valid name"); + + assertThat(valid).isTrue(); + } +} diff --git a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingNamePreferenceTest.java b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingNamePreferenceTest.java new file mode 100644 index 00000000000..13e2a9d4636 --- /dev/null +++ b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingNamePreferenceTest.java @@ -0,0 +1,141 @@ +/* + * 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; + +import static com.google.common.truth.Truth.assertThat; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.app.settings.SettingsEnums; +import android.content.Context; +import android.content.Intent; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.ImageButton; +import android.widget.LinearLayout; + +import androidx.preference.PreferenceViewHolder; +import androidx.test.core.app.ApplicationProvider; + +import com.android.settings.R; +import com.android.settings.SettingsActivity; +import com.android.settings.connecteddevice.audiosharing.audiostreams.AudioStreamsQrCodeFragment; +import com.android.settingslib.core.instrumentation.MetricsFeatureProvider; + +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; +import org.robolectric.RobolectricTestRunner; + +@RunWith(RobolectricTestRunner.class) +public class AudioSharingNamePreferenceTest { + @Rule public final MockitoRule mMockitoRule = MockitoJUnit.rule(); + private Context mContext; + private AudioSharingNamePreference mPreference; + + @Before + public void setup() { + mContext = ApplicationProvider.getApplicationContext(); + mPreference = spy(new AudioSharingNamePreference(mContext, null)); + } + + @Test + public void initialize_correctLayout() { + assertThat(mPreference.getLayoutResource()) + .isEqualTo( + com.android.settingslib.widget.preference.twotarget.R.layout + .preference_two_target); + assertThat(mPreference.getWidgetLayoutResource()) + .isEqualTo(R.layout.preference_widget_qrcode); + } + + @Test + public void onBindViewHolder_correctLayout_noQrCodeButton() { + LayoutInflater inflater = LayoutInflater.from(mContext); + View view = inflater.inflate(mPreference.getLayoutResource(), null); + LinearLayout widgetView = view.findViewById(android.R.id.widget_frame); + assertThat(widgetView).isNotNull(); + inflater.inflate(mPreference.getWidgetLayoutResource(), widgetView, true); + + var holder = PreferenceViewHolder.createInstanceForTests(view); + mPreference.setShowQrCodeIcon(false); + mPreference.onBindViewHolder(holder); + + ImageButton shareButton = (ImageButton) holder.findViewById(R.id.button_icon); + View divider = + holder.findViewById( + com.android.settingslib.widget.preference.twotarget.R.id + .two_target_divider); + + assertThat(shareButton).isNotNull(); + assertThat(shareButton.getVisibility()).isEqualTo(View.GONE); + assertThat(shareButton.hasOnClickListeners()).isFalse(); + assertThat(divider).isNotNull(); + assertThat(divider.getVisibility()).isEqualTo(View.GONE); + } + + @Test + public void onBindViewHolder_correctLayout_showQrCodeButton() { + LayoutInflater inflater = LayoutInflater.from(mContext); + View view = inflater.inflate(mPreference.getLayoutResource(), null); + LinearLayout widgetView = view.findViewById(android.R.id.widget_frame); + assertThat(widgetView).isNotNull(); + inflater.inflate(mPreference.getWidgetLayoutResource(), widgetView, true); + + var holder = PreferenceViewHolder.createInstanceForTests(view); + mPreference.setShowQrCodeIcon(true); + mPreference.onBindViewHolder(holder); + + ImageButton shareButton = (ImageButton) holder.findViewById(R.id.button_icon); + View divider = + holder.findViewById( + com.android.settingslib.widget.preference.twotarget.R.id + .two_target_divider); + + assertThat(shareButton).isNotNull(); + assertThat(shareButton.getVisibility()).isEqualTo(View.VISIBLE); + assertThat(shareButton.getDrawable()).isNotNull(); + assertThat(shareButton.hasOnClickListeners()).isTrue(); + assertThat(divider).isNotNull(); + assertThat(divider.getVisibility()).isEqualTo(View.VISIBLE); + + // mContext is not an Activity context, calling startActivity() from outside of an Activity + // context requires the FLAG_ACTIVITY_NEW_TASK flag, create a mock to avoid this + // AndroidRuntimeException. + Context activityContext = mock(Context.class); + when(mPreference.getContext()).thenReturn(activityContext); + shareButton.callOnClick(); + + ArgumentCaptor argumentCaptor = ArgumentCaptor.forClass(Intent.class); + verify(activityContext).startActivity(argumentCaptor.capture()); + + Intent intent = argumentCaptor.getValue(); + assertThat(intent.getStringExtra(SettingsActivity.EXTRA_SHOW_FRAGMENT)) + .isEqualTo(AudioStreamsQrCodeFragment.class.getName()); + assertThat(intent.getIntExtra(SettingsActivity.EXTRA_SHOW_FRAGMENT_TITLE_RESID, 0)) + .isEqualTo(R.string.audio_streams_qr_code_page_title); + assertThat(intent.getIntExtra(MetricsFeatureProvider.EXTRA_SOURCE_METRICS_CATEGORY, 0)) + .isEqualTo(SettingsEnums.AUDIO_SHARING_SETTINGS); + } +} diff --git a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingNameTextValidatorTest.java b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingNameTextValidatorTest.java new file mode 100644 index 00000000000..ada6117b34a --- /dev/null +++ b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingNameTextValidatorTest.java @@ -0,0 +1,52 @@ +/* + * 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; + +import static com.google.common.truth.Truth.assertThat; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; + +@RunWith(RobolectricTestRunner.class) +public class AudioSharingNameTextValidatorTest { + private AudioSharingNameTextValidator mValidator; + + @Before + public void setUp() { + mValidator = new AudioSharingNameTextValidator(); + } + + @Test + public void testValidNames() { + assertThat(mValidator.isTextValid("ValidName")).isTrue(); + assertThat(mValidator.isTextValid("12345678")).isTrue(); + assertThat(mValidator.isTextValid("Name_With_Underscores")).isTrue(); + assertThat(mValidator.isTextValid("ÄÖÜß")).isTrue(); + assertThat(mValidator.isTextValid("ThisNameIsExactly32Characters!")).isTrue(); + } + + @Test + public void testInvalidNames() { + assertThat(mValidator.isTextValid(null)).isFalse(); + assertThat(mValidator.isTextValid("")).isFalse(); + assertThat(mValidator.isTextValid("abc")).isFalse(); + assertThat(mValidator.isTextValid("ThisNameIsWayTooLongForAnAudioSharingName")).isFalse(); + assertThat(mValidator.isTextValid("Invalid\uDC00")).isFalse(); + } +} diff --git a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingPasswordPreferenceControllerTest.java b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingPasswordPreferenceControllerTest.java new file mode 100644 index 00000000000..5bfb9663e11 --- /dev/null +++ b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingPasswordPreferenceControllerTest.java @@ -0,0 +1,335 @@ +/* + * 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; + +import static com.android.settings.core.BasePreferenceController.AVAILABLE; +import static com.android.settings.core.BasePreferenceController.UNSUPPORTED_ON_DEVICE; + +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.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.app.settings.SettingsEnums; +import android.bluetooth.BluetoothAdapter; +import android.bluetooth.BluetoothStatusCodes; +import android.content.ContentResolver; +import android.content.Context; +import android.content.SharedPreferences; +import android.database.ContentObserver; +import android.platform.test.flag.junit.SetFlagsRule; + +import androidx.lifecycle.LifecycleOwner; +import androidx.preference.PreferenceScreen; +import androidx.test.core.app.ApplicationProvider; + +import com.android.settings.R; +import com.android.settings.bluetooth.Utils; +import com.android.settings.testutils.FakeFeatureFactory; +import com.android.settings.testutils.shadow.ShadowBluetoothAdapter; +import com.android.settings.testutils.shadow.ShadowBluetoothUtils; +import com.android.settingslib.bluetooth.LocalBluetoothLeBroadcast; +import com.android.settingslib.bluetooth.LocalBluetoothManager; +import com.android.settingslib.bluetooth.LocalBluetoothProfileManager; +import com.android.settingslib.core.lifecycle.Lifecycle; +import com.android.settingslib.flags.Flags; + +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.Spy; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; +import org.robolectric.shadow.api.Shadow; +import org.robolectric.shadows.ShadowLooper; + +import java.nio.charset.StandardCharsets; + +@RunWith(RobolectricTestRunner.class) +@Config( + shadows = { + ShadowBluetoothAdapter.class, + ShadowBluetoothUtils.class, + }) +public class AudioSharingPasswordPreferenceControllerTest { + private static final String PREF_KEY = "audio_sharing_stream_password"; + private static final String SHARED_PREF_KEY = "default_password"; + private static final String BROADCAST_PASSWORD = "password"; + private static final String EDITTEXT_PASSWORD = "edittext_password"; + private static final String HIDDEN_PASSWORD = "********"; + + @Rule public final MockitoRule mMockitoRule = MockitoJUnit.rule(); + @Rule public final SetFlagsRule mSetFlagsRule = new SetFlagsRule(); + @Spy Context mContext = ApplicationProvider.getApplicationContext(); + @Mock private LocalBluetoothLeBroadcast mBroadcast; + @Mock private LocalBluetoothManager mLocalBtManager; + @Mock private LocalBluetoothProfileManager mProfileManager; + @Mock private SharedPreferences mSharedPreferences; + @Mock private SharedPreferences.Editor mEditor; + @Mock private ContentResolver mContentResolver; + @Mock private PreferenceScreen mScreen; + private AudioSharingPasswordPreferenceController mController; + private ShadowBluetoothAdapter mShadowBluetoothAdapter; + private Lifecycle mLifecycle; + private LifecycleOwner mLifecycleOwner; + private AudioSharingPasswordPreference mPreference; + private FakeFeatureFactory mFeatureFactory; + + @Before + public void setUp() { + mShadowBluetoothAdapter = Shadow.extract(BluetoothAdapter.getDefaultAdapter()); + mShadowBluetoothAdapter.setEnabled(true); + mShadowBluetoothAdapter.setIsLeAudioBroadcastSourceSupported( + BluetoothStatusCodes.FEATURE_SUPPORTED); + mShadowBluetoothAdapter.setIsLeAudioBroadcastAssistantSupported( + BluetoothStatusCodes.FEATURE_SUPPORTED); + mLocalBtManager = Utils.getLocalBtManager(mContext); + when(mLocalBtManager.getProfileManager()).thenReturn(mProfileManager); + when(mProfileManager.getLeAudioBroadcastProfile()).thenReturn(mBroadcast); + mFeatureFactory = FakeFeatureFactory.setupForTest(); + when(mContext.getContentResolver()).thenReturn(mContentResolver); + when(mContext.getSharedPreferences(anyString(), anyInt())).thenReturn(mSharedPreferences); + when(mSharedPreferences.edit()).thenReturn(mEditor); + when(mEditor.putString(anyString(), anyString())).thenReturn(mEditor); + mLifecycleOwner = () -> mLifecycle; + mLifecycle = new Lifecycle(mLifecycleOwner); + mController = new AudioSharingPasswordPreferenceController(mContext, PREF_KEY); + mPreference = spy(new AudioSharingPasswordPreference(mContext)); + when(mScreen.findPreference(PREF_KEY)).thenReturn(mPreference); + } + + @Test + public void getAvailabilityStatus_flagOn_available() { + mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING); + assertThat(mController.getAvailabilityStatus()).isEqualTo(AVAILABLE); + } + + @Test + public void getAvailabilityStatus_flagOff_unsupported() { + mSetFlagsRule.disableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING); + assertThat(mController.getAvailabilityStatus()).isEqualTo(UNSUPPORTED_ON_DEVICE); + } + + @Test + public void onStart_flagOff_doNothing() { + mSetFlagsRule.disableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING); + mController.onStart(mLifecycleOwner); + verify(mContentResolver, never()).registerContentObserver(any(), anyBoolean(), any()); + verify(mSharedPreferences, never()).registerOnSharedPreferenceChangeListener(any()); + } + + @Test + public void onStart_flagOn_registerCallbacks() { + mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING); + mController.onStart(mLifecycleOwner); + verify(mContentResolver).registerContentObserver(any(), anyBoolean(), any()); + verify(mSharedPreferences).registerOnSharedPreferenceChangeListener(any()); + } + + @Test + public void onStop_flagOff_doNothing() { + mSetFlagsRule.disableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING); + mController.onStop(mLifecycleOwner); + verify(mContentResolver, never()).unregisterContentObserver(any()); + verify(mSharedPreferences, never()).unregisterOnSharedPreferenceChangeListener(any()); + } + + @Test + public void onStop_flagOn_registerCallbacks() { + mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING); + mController.onStop(mLifecycleOwner); + verify(mContentResolver).unregisterContentObserver(any()); + verify(mSharedPreferences).unregisterOnSharedPreferenceChangeListener(any()); + } + + @Test + public void displayPreference_setupPreference_noPassword() { + when(mSharedPreferences.getString(anyString(), anyString())).thenReturn(EDITTEXT_PASSWORD); + when(mBroadcast.getBroadcastCode()).thenReturn(new byte[] {}); + + mController.displayPreference(mScreen); + ShadowLooper.idleMainLooper(); + + assertThat(mPreference.isPassword()).isTrue(); + assertThat(mPreference.getDialogLayoutResource()) + .isEqualTo(R.layout.audio_sharing_password_dialog); + assertThat(mPreference.getText()).isEqualTo(EDITTEXT_PASSWORD); + assertThat(mPreference.getSummary()) + .isEqualTo(mContext.getString(R.string.audio_streams_no_password_summary)); + verify(mPreference).setValidator(any()); + verify(mPreference).setOnDialogEventListener(any()); + } + + @Test + public void contentObserver_updatePreferenceOnChange() { + mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING); + when(mBroadcast.getBroadcastCode()) + .thenReturn(BROADCAST_PASSWORD.getBytes(StandardCharsets.UTF_8)); + mController.onStart(mLifecycleOwner); + mController.displayPreference(mScreen); + ShadowLooper.idleMainLooper(); + + ArgumentCaptor observerCaptor = + ArgumentCaptor.forClass(ContentObserver.class); + verify(mContentResolver) + .registerContentObserver(any(), anyBoolean(), observerCaptor.capture()); + + var observer = observerCaptor.getValue(); + assertThat(observer).isNotNull(); + observer.onChange(true); + verify(mPreference).setText(anyString()); + verify(mPreference).setSummary(anyString()); + } + + @Test + public void sharedPrefChangeListener_updatePreferenceOnChange() { + mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING); + when(mBroadcast.getBroadcastCode()) + .thenReturn(BROADCAST_PASSWORD.getBytes(StandardCharsets.UTF_8)); + mController.onStart(mLifecycleOwner); + mController.displayPreference(mScreen); + ShadowLooper.idleMainLooper(); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(SharedPreferences.OnSharedPreferenceChangeListener.class); + verify(mSharedPreferences).registerOnSharedPreferenceChangeListener(captor.capture()); + + var observer = captor.getValue(); + assertThat(captor).isNotNull(); + observer.onSharedPreferenceChanged(mSharedPreferences, SHARED_PREF_KEY); + verify(mPreference).setText(anyString()); + verify(mPreference).setSummary(anyString()); + } + + @Test + public void displayPreference_setupPreference_hasPassword() { + when(mBroadcast.getBroadcastCode()) + .thenReturn(BROADCAST_PASSWORD.getBytes(StandardCharsets.UTF_8)); + mController.displayPreference(mScreen); + ShadowLooper.idleMainLooper(); + + assertThat(mPreference.isPassword()).isTrue(); + assertThat(mPreference.getDialogLayoutResource()) + .isEqualTo(R.layout.audio_sharing_password_dialog); + assertThat(mPreference.getText()).isEqualTo(BROADCAST_PASSWORD); + assertThat(mPreference.getSummary()).isEqualTo(HIDDEN_PASSWORD); + verify(mPreference).setValidator(any()); + verify(mPreference).setOnDialogEventListener(any()); + } + + @Test + public void onBindDialogView_updatePreference_isBroadcasting_noPassword() { + when(mBroadcast.getBroadcastCode()).thenReturn(new byte[] {}); + when(mBroadcast.isEnabled(any())).thenReturn(true); + mController.displayPreference(mScreen); + mController.onBindDialogView(); + ShadowLooper.idleMainLooper(); + + verify(mPreference).setEditable(false); + verify(mPreference).setChecked(true); + } + + @Test + public void onBindDialogView_updatePreference_isNotBroadcasting_hasPassword() { + when(mBroadcast.getBroadcastCode()) + .thenReturn(BROADCAST_PASSWORD.getBytes(StandardCharsets.UTF_8)); + mController.displayPreference(mScreen); + mController.onBindDialogView(); + ShadowLooper.idleMainLooper(); + + verify(mPreference).setEditable(true); + verify(mPreference).setChecked(false); + } + + @Test + public void onPreferenceDataChanged_isBroadcasting_doNothing() { + when(mBroadcast.isEnabled(any())).thenReturn(true); + mController.displayPreference(mScreen); + mController.onPreferenceDataChanged(BROADCAST_PASSWORD, /* isPublicBroadcast= */ false); + ShadowLooper.idleMainLooper(); + + verify(mBroadcast, never()).setBroadcastCode(any()); + verify(mFeatureFactory.metricsFeatureProvider, never()).action(any(), anyInt(), anyInt()); + } + + @Test + public void onPreferenceDataChanged_noChange_doNothing() { + when(mSharedPreferences.getString(anyString(), anyString())).thenReturn(EDITTEXT_PASSWORD); + when(mBroadcast.getBroadcastCode()).thenReturn(new byte[] {}); + mController.displayPreference(mScreen); + mController.onPreferenceDataChanged(EDITTEXT_PASSWORD, /* isPublicBroadcast= */ true); + ShadowLooper.idleMainLooper(); + + verify(mBroadcast, never()).setBroadcastCode(any()); + verify(mFeatureFactory.metricsFeatureProvider, never()).action(any(), anyInt(), anyInt()); + } + + @Test + public void onPreferenceDataChanged_updateToNonPublicBroadcast() { + when(mSharedPreferences.getString(anyString(), anyString())).thenReturn(EDITTEXT_PASSWORD); + when(mBroadcast.getBroadcastCode()).thenReturn(new byte[] {}); + mController.displayPreference(mScreen); + mController.onPreferenceDataChanged(BROADCAST_PASSWORD, /* isPublicBroadcast= */ false); + ShadowLooper.idleMainLooper(); + + verify(mBroadcast).setBroadcastCode(BROADCAST_PASSWORD.getBytes(StandardCharsets.UTF_8)); + verify(mEditor).putString(anyString(), eq(BROADCAST_PASSWORD)); + verify(mFeatureFactory.metricsFeatureProvider) + .action(mContext, SettingsEnums.ACTION_AUDIO_STREAM_PASSWORD_UPDATED, 0); + } + + @Test + public void onPreferenceDataChanged_updateToPublicBroadcast() { + when(mSharedPreferences.getString(anyString(), anyString())).thenReturn(EDITTEXT_PASSWORD); + when(mBroadcast.getBroadcastCode()) + .thenReturn(BROADCAST_PASSWORD.getBytes(StandardCharsets.UTF_8)); + mController.displayPreference(mScreen); + mController.onPreferenceDataChanged(EDITTEXT_PASSWORD, /* isPublicBroadcast= */ true); + ShadowLooper.idleMainLooper(); + + verify(mBroadcast).setBroadcastCode("".getBytes(StandardCharsets.UTF_8)); + verify(mEditor, never()).putString(anyString(), eq(EDITTEXT_PASSWORD)); + verify(mFeatureFactory.metricsFeatureProvider) + .action(mContext, SettingsEnums.ACTION_AUDIO_STREAM_PASSWORD_UPDATED, 1); + } + + @Test + public void idTextValid_emptyString() { + boolean valid = mController.isTextValid(""); + + assertThat(valid).isFalse(); + } + + @Test + public void idTextValid_validPassword() { + boolean valid = mController.isTextValid(BROADCAST_PASSWORD); + + assertThat(valid).isTrue(); + } +} diff --git a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingPasswordPreferenceTest.java b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingPasswordPreferenceTest.java new file mode 100644 index 00000000000..0b87e8ca25d --- /dev/null +++ b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingPasswordPreferenceTest.java @@ -0,0 +1,215 @@ +/* + * 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; + +import static android.view.View.GONE; +import static android.view.View.VISIBLE; + +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.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import android.content.Context; +import android.content.DialogInterface; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.CheckBox; +import android.widget.EditText; + +import androidx.appcompat.app.AlertDialog; +import androidx.test.core.app.ApplicationProvider; + +import com.android.settings.R; + +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; +import org.robolectric.RobolectricTestRunner; + +@RunWith(RobolectricTestRunner.class) +public class AudioSharingPasswordPreferenceTest { + @Rule public final MockitoRule mMockitoRule = MockitoJUnit.rule(); + private static final String EDIT_TEXT_CONTENT = "text"; + private Context mContext; + private AudioSharingPasswordPreference mPreference; + + @Before + public void setup() { + mContext = ApplicationProvider.getApplicationContext(); + mPreference = new AudioSharingPasswordPreference(mContext, null); + } + + @Test + public void onBindDialogView_correctLayout() { + View view = + LayoutInflater.from(mContext).inflate(R.layout.audio_sharing_password_dialog, null); + mPreference.onBindDialogView(view); + + var editText = view.findViewById(android.R.id.edit); + var checkBox = view.findViewById(R.id.audio_sharing_stream_password_checkbox); + var dialogMessage = view.findViewById(android.R.id.message); + + assertThat(editText).isNotNull(); + assertThat(checkBox).isNotNull(); + assertThat(dialogMessage).isNotNull(); + } + + @Test + public void setEditable_true() { + View view = + LayoutInflater.from(mContext).inflate(R.layout.audio_sharing_password_dialog, null); + mPreference.onBindDialogView(view); + + var editText = view.findViewById(android.R.id.edit); + var checkBox = view.findViewById(R.id.audio_sharing_stream_password_checkbox); + var dialogMessage = view.findViewById(android.R.id.message); + + mPreference.setEditable(true); + + assertThat(editText).isNotNull(); + assertThat(editText.isEnabled()).isTrue(); + assertThat(editText.getAlpha()).isEqualTo(1.0f); + assertThat(checkBox).isNotNull(); + assertThat(checkBox.isEnabled()).isTrue(); + assertThat(dialogMessage).isNotNull(); + assertThat(dialogMessage.getVisibility()).isEqualTo(GONE); + } + + @Test + public void setEditable_false() { + View view = + LayoutInflater.from(mContext).inflate(R.layout.audio_sharing_password_dialog, null); + mPreference.onBindDialogView(view); + + var editText = view.findViewById(android.R.id.edit); + var checkBox = view.findViewById(R.id.audio_sharing_stream_password_checkbox); + var dialogMessage = view.findViewById(android.R.id.message); + + mPreference.setEditable(false); + + assertThat(editText).isNotNull(); + assertThat(editText.isEnabled()).isFalse(); + assertThat(editText.getAlpha()).isLessThan(1.0f); + assertThat(checkBox).isNotNull(); + assertThat(checkBox.isEnabled()).isFalse(); + assertThat(dialogMessage).isNotNull(); + assertThat(dialogMessage.getVisibility()).isEqualTo(VISIBLE); + } + + @Test + public void setChecked_true() { + View view = + LayoutInflater.from(mContext).inflate(R.layout.audio_sharing_password_dialog, null); + mPreference.onBindDialogView(view); + + CheckBox checkBox = view.findViewById(R.id.audio_sharing_stream_password_checkbox); + + mPreference.setChecked(true); + + assertThat(checkBox).isNotNull(); + assertThat(checkBox.isChecked()).isTrue(); + } + + @Test + public void setChecked_false() { + View view = + LayoutInflater.from(mContext).inflate(R.layout.audio_sharing_password_dialog, null); + mPreference.onBindDialogView(view); + + CheckBox checkBox = view.findViewById(R.id.audio_sharing_stream_password_checkbox); + + mPreference.setChecked(false); + + assertThat(checkBox).isNotNull(); + assertThat(checkBox.isChecked()).isFalse(); + } + + @Test + public void onDialogEventListener_onClick_positiveButton() { + AudioSharingPasswordPreference.OnDialogEventListener listener = + mock(AudioSharingPasswordPreference.OnDialogEventListener.class); + mPreference.setOnDialogEventListener(listener); + View view = + LayoutInflater.from(mContext).inflate(R.layout.audio_sharing_password_dialog, null); + mPreference.onBindDialogView(view); + + EditText editText = view.findViewById(android.R.id.edit); + assertThat(editText).isNotNull(); + editText.setText(EDIT_TEXT_CONTENT); + + mPreference.onClick(mock(DialogInterface.class), DialogInterface.BUTTON_POSITIVE); + + verify(listener).onBindDialogView(); + verify(listener).onPreferenceDataChanged(eq(EDIT_TEXT_CONTENT), anyBoolean()); + } + + @Test + public void onDialogEventListener_onClick_negativeButton_doNothing() { + AudioSharingPasswordPreference.OnDialogEventListener listener = + mock(AudioSharingPasswordPreference.OnDialogEventListener.class); + mPreference.setOnDialogEventListener(listener); + View view = + LayoutInflater.from(mContext).inflate(R.layout.audio_sharing_password_dialog, null); + mPreference.onBindDialogView(view); + + EditText editText = view.findViewById(android.R.id.edit); + assertThat(editText).isNotNull(); + editText.setText(EDIT_TEXT_CONTENT); + + mPreference.onClick(mock(DialogInterface.class), DialogInterface.BUTTON_NEGATIVE); + + verify(listener).onBindDialogView(); + verify(listener, never()).onPreferenceDataChanged(anyString(), anyBoolean()); + } + + @Test + public void onPrepareDialogBuilder_editable_doNothing() { + View view = + LayoutInflater.from(mContext).inflate(R.layout.audio_sharing_password_dialog, null); + mPreference.onBindDialogView(view); + mPreference.setEditable(true); + + var dialogBuilder = mock(AlertDialog.Builder.class); + mPreference.onPrepareDialogBuilder( + dialogBuilder, mock(DialogInterface.OnClickListener.class)); + + verify(dialogBuilder, never()).setPositiveButton(any(), any()); + } + + @Test + public void onPrepareDialogBuilder_notEditable_disableButton() { + View view = + LayoutInflater.from(mContext).inflate(R.layout.audio_sharing_password_dialog, null); + mPreference.onBindDialogView(view); + mPreference.setEditable(false); + + var dialogBuilder = mock(AlertDialog.Builder.class); + mPreference.onPrepareDialogBuilder( + dialogBuilder, mock(DialogInterface.OnClickListener.class)); + + verify(dialogBuilder).setPositiveButton(any(), any()); + } +} diff --git a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingPasswordValidatorTest.java b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingPasswordValidatorTest.java new file mode 100644 index 00000000000..5c96fe1b5bf --- /dev/null +++ b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingPasswordValidatorTest.java @@ -0,0 +1,53 @@ +/* + * 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; + +import static com.google.common.truth.Truth.assertThat; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; + +@RunWith(RobolectricTestRunner.class) +public class AudioSharingPasswordValidatorTest { + private AudioSharingPasswordValidator mValidator; + + @Before + public void setUp() { + mValidator = new AudioSharingPasswordValidator(); + } + + @Test + public void testValidPasswords() { + assertThat(mValidator.isTextValid("1234")).isTrue(); + assertThat(mValidator.isTextValid("Password")).isTrue(); + assertThat(mValidator.isTextValid("SecurePass123!")).isTrue(); + assertThat(mValidator.isTextValid("ÄÖÜß")).isTrue(); + assertThat(mValidator.isTextValid("1234567890abcdef")).isTrue(); + } + + @Test + public void testInvalidPasswords() { + assertThat(mValidator.isTextValid(null)).isFalse(); + assertThat(mValidator.isTextValid("")).isFalse(); + assertThat(mValidator.isTextValid("abc")).isFalse(); + assertThat(mValidator.isTextValid("ThisIsAVeryLongPasswordThatExceedsSixteenOctets")) + .isFalse(); + assertThat(mValidator.isTextValid("Invalid\uDC00")).isFalse(); + } +}