From 694fe0751a2ff7b55d40d42b6918f40c74241d8e Mon Sep 17 00:00:00 2001 From: MiltonWu Date: Wed, 4 Sep 2024 11:58:56 +0000 Subject: [PATCH 01/11] Customize Fingerprint enroll activities Provide an interface for ODM/OEM to override Fingerprint enrollment activities. Bug: 364794493 Flag: EXEMPT can't apply flag for manifest change Test: atest SettingsRoboTests:FingerprintEnrollTest Change-Id: Ic519970a3837614b3d4c8cb2f6d75967ae838208 --- AndroidManifest.xml | 18 +++- .../ActivityEmbeddingRulesController.java | 11 ++- .../settings/biometrics/BiometricUtils.java | 7 +- .../fingerprint/FingerprintEnroll.kt | 69 ++++++++++++++ .../FingerprintEnrollActivityClassProvider.kt | 34 +++++++ .../FingerprintFeatureProvider.java | 10 +- .../fingerprint/FingerprintSettings.java | 2 +- .../fragment/FingerprintSettingsV2Fragment.kt | 4 +- .../fingerprint/FingerprintEnrollTest.kt | 94 +++++++++++++++++++ 9 files changed, 233 insertions(+), 16 deletions(-) create mode 100644 src/com/android/settings/biometrics/fingerprint/FingerprintEnroll.kt create mode 100644 src/com/android/settings/biometrics/fingerprint/FingerprintEnrollActivityClassProvider.kt create mode 100644 tests/robotests/src/com/android/settings/biometrics/fingerprint/FingerprintEnrollTest.kt diff --git a/AndroidManifest.xml b/AndroidManifest.xml index d988feaada7..eebef14bc16 100644 --- a/AndroidManifest.xml +++ b/AndroidManifest.xml @@ -2816,6 +2816,9 @@ + @@ -2826,9 +2829,13 @@ + android:exported="false" + android:theme="@style/GlifTheme.Light" + android:taskAffinity="com.android.settings.root" /> + + @@ -2845,7 +2856,6 @@ - + get() = enrollActivityProvider.setup + } + + /** Inner class representing enrolling fingerprint enrollment from FingerprintSettings */ + class InternalActivity : FingerprintEnroll() { + override val nextActivityClass: Class<*> + get() = enrollActivityProvider.internal + } + + /** + * The class of the next activity to launch. This is open to allow subclasses to provide their + * own behavior. Defaults to the default activity class provided by the + * enrollActivityClassProvider. + */ + open val nextActivityClass: Class<*> + get() = enrollActivityProvider.default + + protected val enrollActivityProvider: FingerprintEnrollActivityClassProvider + get() = featureFactory.fingerprintFeatureProvider.enrollActivityClassProvider + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + /** + * Logs the next activity to be launched, creates an intent for that activity, + * adds flags to forward the result, includes any existing extras from the current intent, + * starts the new activity and then finishes the current one + */ + Log.d("FingerprintEnroll", "forward to $nextActivityClass") + val nextIntent = Intent(this, nextActivityClass) + nextIntent.addFlags(Intent.FLAG_ACTIVITY_FORWARD_RESULT) + nextIntent.putExtras(intent) + startActivity(nextIntent) + finish() + } +} \ No newline at end of file diff --git a/src/com/android/settings/biometrics/fingerprint/FingerprintEnrollActivityClassProvider.kt b/src/com/android/settings/biometrics/fingerprint/FingerprintEnrollActivityClassProvider.kt new file mode 100644 index 00000000000..853a3df01b8 --- /dev/null +++ b/src/com/android/settings/biometrics/fingerprint/FingerprintEnrollActivityClassProvider.kt @@ -0,0 +1,34 @@ +/* + * 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.biometrics.fingerprint + +import android.app.Activity + +open class FingerprintEnrollActivityClassProvider { + + open val default: Class + get() = FingerprintEnrollIntroduction::class.java + open val setup: Class + get() = SetupFingerprintEnrollIntroduction::class.java + open val internal: Class + get() = FingerprintEnrollIntroductionInternal::class.java + + companion object { + @JvmStatic + val instance = FingerprintEnrollActivityClassProvider() + } +} diff --git a/src/com/android/settings/biometrics/fingerprint/FingerprintFeatureProvider.java b/src/com/android/settings/biometrics/fingerprint/FingerprintFeatureProvider.java index c1e34a579a8..baa88b5655a 100644 --- a/src/com/android/settings/biometrics/fingerprint/FingerprintFeatureProvider.java +++ b/src/com/android/settings/biometrics/fingerprint/FingerprintFeatureProvider.java @@ -33,7 +33,6 @@ public interface FingerprintFeatureProvider { */ SfpsEnrollmentFeature getSfpsEnrollmentFeature(); - /** * Gets calibrator for udfps pre-enroll * @param appContext application context @@ -52,4 +51,13 @@ public interface FingerprintFeatureProvider { * @return the feature implementation */ SfpsRestToUnlockFeature getSfpsRestToUnlockFeature(@NonNull Context context); + + /** + * Gets the provider for current fingerprint enrollment activity classes + * @return the provider + */ + @NonNull + default FingerprintEnrollActivityClassProvider getEnrollActivityClassProvider() { + return FingerprintEnrollActivityClassProvider.getInstance(); + } } diff --git a/src/com/android/settings/biometrics/fingerprint/FingerprintSettings.java b/src/com/android/settings/biometrics/fingerprint/FingerprintSettings.java index 125691fbf1c..20d453f2ea8 100644 --- a/src/com/android/settings/biometrics/fingerprint/FingerprintSettings.java +++ b/src/com/android/settings/biometrics/fingerprint/FingerprintSettings.java @@ -1142,7 +1142,7 @@ public class FingerprintSettings extends SubSettings { private void addFirstFingerprint(@Nullable Long gkPwHandle) { Intent intent = new Intent(); intent.setClassName(SETTINGS_PACKAGE_NAME, - FingerprintEnrollIntroductionInternal.class.getName()); + FingerprintEnroll.InternalActivity.class.getName()); intent.putExtra(EXTRA_FROM_SETTINGS_SUMMARY, true); intent.putExtra(SettingsBaseActivity.EXTRA_PAGE_TRANSITION_TYPE, SettingsTransitionHelper.TransitionType.TRANSITION_SLIDE); diff --git a/src/com/android/settings/biometrics/fingerprint2/ui/settings/fragment/FingerprintSettingsV2Fragment.kt b/src/com/android/settings/biometrics/fingerprint2/ui/settings/fragment/FingerprintSettingsV2Fragment.kt index 241eaea0b28..d9289d6f107 100644 --- a/src/com/android/settings/biometrics/fingerprint2/ui/settings/fragment/FingerprintSettingsV2Fragment.kt +++ b/src/com/android/settings/biometrics/fingerprint2/ui/settings/fragment/FingerprintSettingsV2Fragment.kt @@ -43,7 +43,7 @@ import com.android.settings.biometrics.BiometricEnrollBase.CONFIRM_REQUEST import com.android.settings.biometrics.BiometricEnrollBase.EXTRA_FROM_SETTINGS_SUMMARY import com.android.settings.biometrics.BiometricEnrollBase.RESULT_FINISHED import com.android.settings.biometrics.fingerprint.FingerprintEnrollEnrolling -import com.android.settings.biometrics.fingerprint.FingerprintEnrollIntroductionInternal +import com.android.settings.biometrics.fingerprint.FingerprintEnroll.InternalActivity import com.android.settings.biometrics.fingerprint2.data.repository.FingerprintSensorRepositoryImpl import com.android.settings.biometrics.fingerprint2.domain.interactor.PressToAuthInteractorImpl import com.android.settings.biometrics.fingerprint2.lib.model.FingerprintAuthAttemptModel @@ -514,7 +514,7 @@ class FingerprintSettingsV2Fragment : val intent = Intent() intent.setClassName( SETTINGS_PACKAGE_NAME, - FingerprintEnrollIntroductionInternal::class.java.name, + InternalActivity::class.java.name, ) intent.putExtra(EXTRA_FROM_SETTINGS_SUMMARY, true) intent.putExtra( diff --git a/tests/robotests/src/com/android/settings/biometrics/fingerprint/FingerprintEnrollTest.kt b/tests/robotests/src/com/android/settings/biometrics/fingerprint/FingerprintEnrollTest.kt new file mode 100644 index 00000000000..07cdffb942c --- /dev/null +++ b/tests/robotests/src/com/android/settings/biometrics/fingerprint/FingerprintEnrollTest.kt @@ -0,0 +1,94 @@ +/* + * 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.biometrics.fingerprint + +import android.app.Activity +import android.content.Intent +import com.android.settings.overlay.FeatureFactory +import com.android.settings.testutils.FakeFeatureFactory +import com.google.common.truth.Truth.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.`when` +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows + +@RunWith(RobolectricTestRunner::class) +class FingerprintEnrollTest { + + private lateinit var featureFactory: FeatureFactory + + private companion object { + const val INTENT_KEY = "testKey" + const val INTENT_VALUE = "testValue" + val INTENT = Intent().apply { + putExtra(INTENT_KEY, INTENT_VALUE) + } + } + + private val activityProvider = FingerprintEnrollActivityClassProvider() + + @Before + fun setUp() { + featureFactory = FakeFeatureFactory.setupForTest() + `when`(featureFactory.fingerprintFeatureProvider.enrollActivityClassProvider) + .thenReturn(activityProvider) + } + + private fun setupActivity(activityClass: Class): FingerprintEnroll { + return Robolectric.buildActivity(activityClass, INTENT).create().get() + } + + @Test + fun testFinishAndLaunchDefaultActivity() { + // Run + val activity = setupActivity(FingerprintEnroll::class.java) + + // Verify + verifyLaunchNextActivity(activity, activityProvider.default) + } + + @Test + fun testFinishAndLaunchSetupActivity() { + // Run + val activity = setupActivity(FingerprintEnroll.SetupActivity::class.java) + + // Verify + verifyLaunchNextActivity(activity, activityProvider.setup) + } + + @Test + fun testFinishAndLaunchInternalActivity() { + // Run + val activity = setupActivity(FingerprintEnroll.InternalActivity::class.java) + + // Verify + verifyLaunchNextActivity(activity, activityProvider.internal) + } + + private fun verifyLaunchNextActivity( + currentActivityInstance : FingerprintEnroll, + nextActivityClass: Class + ) { + assertThat(currentActivityInstance.isFinishing).isTrue() + val nextActivityIntent = Shadows.shadowOf(currentActivityInstance).nextStartedActivity + assertThat(nextActivityIntent.component!!.className).isEqualTo(nextActivityClass.name) + assertThat(nextActivityIntent.extras!!.size()).isEqualTo(1) + assertThat(nextActivityIntent.getStringExtra(INTENT_KEY)).isEqualTo(INTENT_VALUE) + } +} From dd9b17b0830e5f4e1fee8f49ac12ca3da3d6f7a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=ADas=20Hern=C3=A1ndez?= Date: Tue, 10 Sep 2024 18:23:24 +0200 Subject: [PATCH 02/11] Remove "Do Not Disturb" from Sound summary in Settings top screen Bug: 361140177 Test: manual Flag: android.app.modes_ui Change-Id: I11f808319bbcc2645e593f70003611f70a3fc930 --- res/values/strings.xml | 4 +- res/xml/top_level_settings.xml | 5 ++- res/xml/top_level_settings_v2.xml | 5 ++- .../TopLevelSoundPreferenceController.java | 45 +++++++++++++++++++ 4 files changed, 54 insertions(+), 5 deletions(-) create mode 100644 src/com/android/settings/sound/TopLevelSoundPreferenceController.java diff --git a/res/values/strings.xml b/res/values/strings.xml index 3ec5904c749..3fb6688f38e 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -7829,7 +7829,9 @@ keyboard, haptics, vibrate, - Volume, vibration, Do Not Disturb + Volume and vibration + + Volume, vibration, Do Not Disturb Media volume diff --git a/res/xml/top_level_settings.xml b/res/xml/top_level_settings.xml index 1ec968a415c..44fe7fcc3d3 100644 --- a/res/xml/top_level_settings.xml +++ b/res/xml/top_level_settings.xml @@ -104,8 +104,9 @@ android:key="top_level_sound" android:order="-90" android:title="@string/sound_settings" - android:summary="@string/sound_dashboard_summary" - settings:highlightableMenuKey="@string/menu_key_sound"/> + android:summary="@string/sound_dashboard_summary_with_dnd" + settings:highlightableMenuKey="@string/menu_key_sound" + settings:controller="com.android.settings.sound.TopLevelSoundPreferenceController"/> + android:summary="@string/sound_dashboard_summary_with_dnd" + settings:highlightableMenuKey="@string/menu_key_sound" + settings:controller="com.android.settings.sound.TopLevelSoundPreferenceController"/> Date: Wed, 11 Sep 2024 11:24:07 +0800 Subject: [PATCH 03/11] Show highlight for device setting items BUG: 343317785 Test: atest DeviceDetailsFragmentFormatterTest Flag: com.android.settings.flags.enable_bluetooth_device_details_polish Change-Id: Ifac11881a9a305a39c1d2057ea354a8096f70647 --- .../ui/layout/DeviceSettingLayout.kt | 5 +- .../ui/view/DeviceDetailsFragmentFormatter.kt | 120 +++++++++++++++--- .../BluetoothDeviceDetailsViewModel.kt | 60 ++++++--- .../DeviceDetailsFragmentFormatterTest.kt | 19 +-- .../BluetoothDeviceDetailsViewModelTest.kt | 14 +- 5 files changed, 163 insertions(+), 55 deletions(-) diff --git a/src/com/android/settings/bluetooth/ui/layout/DeviceSettingLayout.kt b/src/com/android/settings/bluetooth/ui/layout/DeviceSettingLayout.kt index 87e2e8b4962..5987e5a2079 100644 --- a/src/com/android/settings/bluetooth/ui/layout/DeviceSettingLayout.kt +++ b/src/com/android/settings/bluetooth/ui/layout/DeviceSettingLayout.kt @@ -22,4 +22,7 @@ import kotlinx.coroutines.flow.Flow data class DeviceSettingLayout(val rows: List) /** Represent a row in the layout. */ -data class DeviceSettingLayoutRow(val settingIds: Flow>) +data class DeviceSettingLayoutRow(val columns: Flow>) + +/** Represent a column in a row. */ +data class DeviceSettingLayoutColumn(val settingId: Int, val highlighted: Boolean) diff --git a/src/com/android/settings/bluetooth/ui/view/DeviceDetailsFragmentFormatter.kt b/src/com/android/settings/bluetooth/ui/view/DeviceDetailsFragmentFormatter.kt index f2a569d2245..a5997e7bc83 100644 --- a/src/com/android/settings/bluetooth/ui/view/DeviceDetailsFragmentFormatter.kt +++ b/src/com/android/settings/bluetooth/ui/view/DeviceDetailsFragmentFormatter.kt @@ -20,12 +20,23 @@ import android.bluetooth.BluetoothAdapter import android.content.Context import android.media.AudioManager import android.os.Bundle +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.lifecycleScope @@ -43,7 +54,6 @@ import com.android.settings.core.SubSettingLauncher import com.android.settings.overlay.FeatureFactory.Companion.featureFactory import com.android.settings.spa.preference.ComposePreference import com.android.settingslib.bluetooth.CachedBluetoothDevice -import com.android.settingslib.bluetooth.devicesettings.DeviceSettingId import com.android.settingslib.bluetooth.devicesettings.shared.model.DeviceSettingConfigItemModel import com.android.settingslib.bluetooth.devicesettings.shared.model.DeviceSettingIcon import com.android.settingslib.spa.framework.theme.SettingsDimension @@ -91,10 +101,16 @@ class DeviceDetailsFragmentFormatterImpl( ) : DeviceDetailsFragmentFormatter { private val repository = featureFactory.bluetoothFeatureProvider.getDeviceSettingRepository( - context, bluetoothAdapter, fragment.lifecycleScope) + context, + bluetoothAdapter, + fragment.lifecycleScope, + ) private val spatialAudioInteractor = featureFactory.bluetoothFeatureProvider.getSpatialAudioInteractor( - context, context.getSystemService(AudioManager::class.java), fragment.lifecycleScope) + context, + context.getSystemService(AudioManager::class.java), + fragment.lifecycleScope, + ) private val viewModel: BluetoothDeviceDetailsViewModel = ViewModelProvider( fragment, @@ -104,7 +120,8 @@ class DeviceDetailsFragmentFormatterImpl( spatialAudioInteractor, cachedDevice, backgroundCoroutineContext, - )) + ), + ) .get(BluetoothDeviceDetailsViewModel::class.java) override fun getVisiblePreferenceKeys(fragmentType: FragmentTypeModel): List? = @@ -120,7 +137,8 @@ class DeviceDetailsFragmentFormatterImpl( viewModel .getItems(fragmentType) ?.filterIsInstance() - ?.first()?.invisibleProfiles + ?.first() + ?.invisibleProfiles } /** Updates bluetooth device details fragment layout. */ @@ -144,7 +162,8 @@ class DeviceDetailsFragmentFormatterImpl( val settingId = items[row].settingId if (settingIdToXmlPreferences.containsKey(settingId)) { fragment.preferenceScreen.addPreference( - settingIdToXmlPreferences[settingId]!!.apply { order = row }) + settingIdToXmlPreferences[settingId]!!.apply { order = row } + ) } else { val pref = ComposePreference(context) @@ -169,7 +188,8 @@ class DeviceDetailsFragmentFormatterImpl( emitAll( viewModel.getDeviceSetting(cachedDevice, item.settingId).map { it as? DeviceSettingPreferenceModel.HelpPreference - }) + } + ) } ?: emit(null) } @@ -177,22 +197,56 @@ class DeviceDetailsFragmentFormatterImpl( private fun buildPreference(layout: DeviceSettingLayout, row: Int) { val contents by remember(row) { - layout.rows[row].settingIds.flatMapLatest { settingIds -> - if (settingIds.isEmpty()) { + layout.rows[row].columns.flatMapLatest { columns -> + if (columns.isEmpty()) { flowOf(emptyList()) } else { combine( - settingIds.map { settingId -> - viewModel.getDeviceSetting(cachedDevice, settingId) - }) { - it.toList() + columns.map { column -> + viewModel.getDeviceSetting(cachedDevice, column.settingId) } + ) { + it.toList() + } } } } .collectAsStateWithLifecycle(initialValue = listOf()) + val highlighted by + remember(row) { + layout.rows[row].columns.map { columns -> columns.any { it.highlighted } } + } + .collectAsStateWithLifecycle(initialValue = false) + val settings = contents + AnimatedVisibility( + visible = settings.isNotEmpty(), + enter = expandVertically(expandFrom = Alignment.Top), + exit = shrinkVertically(shrinkTowards = Alignment.Top), + ) { + Box { + Box( + modifier = + Modifier.matchParentSize() + .padding(16.dp, 0.dp, 8.dp, 0.dp) + .background( + color = + if (highlighted) { + MaterialTheme.colorScheme.primaryContainer + } else { + Color.Transparent + }, + shape = RoundedCornerShape(28.dp), + ), + ) {} + buildPreferences(settings) + } + } + } + + @Composable + fun buildPreferences(settings: List) { when (settings.size) { 0 -> {} 1 -> { @@ -217,11 +271,18 @@ class DeviceDetailsFragmentFormatterImpl( } } else -> { - if (!settings.all { it is DeviceSettingPreferenceModel.MultiTogglePreference }) { + if ( + !settings.all { + it is DeviceSettingPreferenceModel.MultiTogglePreference + } + ) { return } buildMultiTogglePreference( - settings.filterIsInstance()) + settings.filterIsInstance< + DeviceSettingPreferenceModel.MultiTogglePreference + >() + ) } } } @@ -243,11 +304,19 @@ class DeviceDetailsFragmentFormatterImpl( override val onCheckedChange = { newChecked: Boolean -> model.onCheckedChange(newChecked) } - override val icon = @Composable { deviceSettingIcon(model.icon) } + override val icon: (@Composable () -> Unit)? + get() { + if (model.icon == null) { + return null + } + return { deviceSettingIcon(model.icon) } + } } if (model.onPrimaryClick != null) { TwoTargetSwitchPreference( - switchPrefModel, primaryOnClick = model.onPrimaryClick::invoke) + switchPrefModel, + primaryOnClick = model.onPrimaryClick::invoke, + ) } else { SwitchPreference(switchPrefModel) } @@ -263,8 +332,15 @@ class DeviceDetailsFragmentFormatterImpl( model.onClick?.invoke() Unit } - override val icon = @Composable { deviceSettingIcon(model.icon) } - }) + override val icon: (@Composable () -> Unit)? + get() { + if (model.icon == null) { + return null + } + return { deviceSettingIcon(model.icon) } + } + } + ) } @Composable @@ -281,11 +357,13 @@ class DeviceDetailsFragmentFormatterImpl( .setDestination(DeviceDetailsMoreSettingsFragment::class.java.name) .setSourceMetricsCategory(fragment.getMetricsCategory()) .setArguments( - Bundle().apply { putString(KEY_DEVICE_ADDRESS, cachedDevice.address) }) + Bundle().apply { putString(KEY_DEVICE_ADDRESS, cachedDevice.address) } + ) .launch() } override val icon = @Composable { deviceSettingIcon(null) } - }) + } + ) } @Composable diff --git a/src/com/android/settings/bluetooth/ui/viewmodel/BluetoothDeviceDetailsViewModel.kt b/src/com/android/settings/bluetooth/ui/viewmodel/BluetoothDeviceDetailsViewModel.kt index 1071adce37f..67a0ebc8398 100644 --- a/src/com/android/settings/bluetooth/ui/viewmodel/BluetoothDeviceDetailsViewModel.kt +++ b/src/com/android/settings/bluetooth/ui/viewmodel/BluetoothDeviceDetailsViewModel.kt @@ -24,6 +24,7 @@ import androidx.lifecycle.viewModelScope import com.android.settings.R import com.android.settings.bluetooth.domain.interactor.SpatialAudioInteractor import com.android.settings.bluetooth.ui.layout.DeviceSettingLayout +import com.android.settings.bluetooth.ui.layout.DeviceSettingLayoutColumn import com.android.settings.bluetooth.ui.layout.DeviceSettingLayoutRow import com.android.settings.bluetooth.ui.model.DeviceSettingPreferenceModel import com.android.settings.bluetooth.ui.model.FragmentTypeModel @@ -36,7 +37,6 @@ import com.android.settingslib.bluetooth.devicesettings.shared.model.DeviceSetti import com.android.settingslib.bluetooth.devicesettings.shared.model.DeviceSettingStateModel import kotlin.coroutines.CoroutineContext import kotlinx.coroutines.CoroutineStart -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted @@ -51,7 +51,7 @@ class BluetoothDeviceDetailsViewModel( private val spatialAudioInteractor: SpatialAudioInteractor, private val cachedDevice: CachedBluetoothDevice, backgroundCoroutineContext: CoroutineContext, -) : AndroidViewModel(application){ +) : AndroidViewModel(application) { private val items = viewModelScope.async(backgroundCoroutineContext, start = CoroutineStart.LAZY) { @@ -74,7 +74,7 @@ class BluetoothDeviceDetailsViewModel( fun getDeviceSetting( cachedDevice: CachedBluetoothDevice, - @DeviceSettingId settingId: Int + @DeviceSettingId settingId: Int, ): Flow { if (settingId == DeviceSettingId.DEVICE_SETTING_ID_MORE_SETTINGS) { return flowOf(DeviceSettingPreferenceModel.MoreSettingsPreference(settingId)) @@ -98,16 +98,19 @@ class BluetoothDeviceDetailsViewModel( checked = switchState?.checked ?: false, onCheckedChange = { newState -> updateState?.invoke( - DeviceSettingStateModel.ActionSwitchPreferenceState(newState)) + DeviceSettingStateModel.ActionSwitchPreferenceState(newState) + ) }, - onPrimaryClick = { intent?.let { application.startActivity(it) } }) + onPrimaryClick = { intent?.let { application.startActivity(it) } }, + ) } else { DeviceSettingPreferenceModel.PlainPreference( id = id, title = title, summary = summary, icon = icon, - onClick = { intent?.let { application.startActivity(it) } }) + onClick = { intent?.let { application.startActivity(it) } }, + ) } } is DeviceSettingModel.FooterPreference -> @@ -116,9 +119,8 @@ class BluetoothDeviceDetailsViewModel( DeviceSettingPreferenceModel.HelpPreference( id = id, icon = DeviceSettingIcon.ResourceIcon(R.drawable.ic_help), - onClick = { - application.startActivity(intent) - }) + onClick = { application.startActivity(intent) }, + ) is DeviceSettingModel.MultiTogglePreference -> DeviceSettingPreferenceModel.MultiTogglePreference( id = id, @@ -129,7 +131,8 @@ class BluetoothDeviceDetailsViewModel( isAllowedChangingState = isAllowedChangingState, onSelectedChange = { newState -> updateState(DeviceSettingStateModel.MultiTogglePreferenceState(newState)) - }) + }, + ) is DeviceSettingModel.Unknown -> null } } @@ -145,8 +148,8 @@ class BluetoothDeviceDetailsViewModel( configItems.map { idToDeviceSetting[it.settingId] ?: flowOf(null) } val positionToSettingIds = combine(configDeviceSetting) { settings -> - val positionMapping = mutableMapOf>() - var multiToggleSettingIds: MutableList? = null + val positionMapping = mutableMapOf>() + var multiToggleSettingIds: MutableList? = null for (i in settings.indices) { val configItem = configItems[i] val setting = settings[i] @@ -156,14 +159,31 @@ class BluetoothDeviceDetailsViewModel( } if (setting !is DeviceSettingPreferenceModel.MultiTogglePreference) { multiToggleSettingIds = null - positionMapping[i] = listOf(configItem.settingId) + positionMapping[i] = + listOf( + DeviceSettingLayoutColumn( + configItem.settingId, + configItem.highlighted, + ) + ) continue } if (multiToggleSettingIds != null) { - multiToggleSettingIds.add(setting.id) + multiToggleSettingIds.add( + DeviceSettingLayoutColumn( + configItem.settingId, + configItem.highlighted, + ) + ) } else { - multiToggleSettingIds = mutableListOf(setting.id) + multiToggleSettingIds = + mutableListOf( + DeviceSettingLayoutColumn( + configItem.settingId, + configItem.highlighted, + ) + ) positionMapping[i] = multiToggleSettingIds } } @@ -173,7 +193,8 @@ class BluetoothDeviceDetailsViewModel( return DeviceSettingLayout( configItems.indices.map { idx -> DeviceSettingLayoutRow(positionToSettingIds.map { it[idx] ?: emptyList() }) - }) + } + ) } class Factory( @@ -186,9 +207,12 @@ class BluetoothDeviceDetailsViewModel( override fun create(modelClass: Class): T { @Suppress("UNCHECKED_CAST") return BluetoothDeviceDetailsViewModel( - application, deviceSettingRepository, spatialAudioInteractor, + application, + deviceSettingRepository, + spatialAudioInteractor, cachedDevice, - backgroundCoroutineContext) + backgroundCoroutineContext, + ) as T } } diff --git a/tests/robotests/src/com/android/settings/bluetooth/ui/view/DeviceDetailsFragmentFormatterTest.kt b/tests/robotests/src/com/android/settings/bluetooth/ui/view/DeviceDetailsFragmentFormatterTest.kt index 8070b2e5362..51c0c3076ee 100644 --- a/tests/robotests/src/com/android/settings/bluetooth/ui/view/DeviceDetailsFragmentFormatterTest.kt +++ b/tests/robotests/src/com/android/settings/bluetooth/ui/view/DeviceDetailsFragmentFormatterTest.kt @@ -124,10 +124,11 @@ class DeviceDetailsFragmentFormatterTest { listOf( DeviceSettingConfigItemModel.BuiltinItem.CommonBuiltinItem( DeviceSettingId.DEVICE_SETTING_ID_HEADER, - "bluetooth_device_header" + highlighted = false, + preferenceKey = "bluetooth_device_header" ), DeviceSettingConfigItemModel.BuiltinItem.CommonBuiltinItem( - DeviceSettingId.DEVICE_SETTING_ID_ACTION_BUTTONS, "action_buttons"), + DeviceSettingId.DEVICE_SETTING_ID_ACTION_BUTTONS, highlighted = false, preferenceKey = "action_buttons"), ), listOf(), null)) @@ -157,7 +158,7 @@ class DeviceDetailsFragmentFormatterTest { `when`(repository.getDeviceSettingsConfig(cachedDevice)) .thenReturn( DeviceSettingConfigModel( - listOf(), listOf(), DeviceSettingConfigItemModel.AppProvidedItem(12345))) + listOf(), listOf(), DeviceSettingConfigItemModel.AppProvidedItem(12345, false))) val intent = Intent().apply { setAction(Intent.ACTION_VIEW) setFlags(Intent.FLAG_ACTIVITY_NEW_TASK) @@ -206,10 +207,10 @@ class DeviceDetailsFragmentFormatterTest { listOf( DeviceSettingConfigItemModel.BuiltinItem.CommonBuiltinItem( DeviceSettingId.DEVICE_SETTING_ID_HEADER, - "bluetooth_device_header"), + highlighted = false, preferenceKey = "bluetooth_device_header"), DeviceSettingConfigItemModel.BuiltinItem.CommonBuiltinItem( DeviceSettingId.DEVICE_SETTING_ID_KEYBOARD_SETTINGS, - "keyboard_settings"), + highlighted = false, preferenceKey = "keyboard_settings"), ), listOf(), null)) @@ -230,12 +231,14 @@ class DeviceDetailsFragmentFormatterTest { listOf( DeviceSettingConfigItemModel.BuiltinItem.CommonBuiltinItem( DeviceSettingId.DEVICE_SETTING_ID_HEADER, - "bluetooth_device_header"), + highlighted = false, + preferenceKey = "bluetooth_device_header"), DeviceSettingConfigItemModel.AppProvidedItem( - DeviceSettingId.DEVICE_SETTING_ID_ANC), + DeviceSettingId.DEVICE_SETTING_ID_ANC, highlighted = false), DeviceSettingConfigItemModel.BuiltinItem.CommonBuiltinItem( DeviceSettingId.DEVICE_SETTING_ID_KEYBOARD_SETTINGS, - "keyboard_settings"), + highlighted = false, + preferenceKey = "keyboard_settings"), ), listOf(), null)) diff --git a/tests/robotests/src/com/android/settings/bluetooth/ui/viewmodel/BluetoothDeviceDetailsViewModelTest.kt b/tests/robotests/src/com/android/settings/bluetooth/ui/viewmodel/BluetoothDeviceDetailsViewModelTest.kt index 6869c23fa95..c3f938c3c46 100644 --- a/tests/robotests/src/com/android/settings/bluetooth/ui/viewmodel/BluetoothDeviceDetailsViewModelTest.kt +++ b/tests/robotests/src/com/android/settings/bluetooth/ui/viewmodel/BluetoothDeviceDetailsViewModelTest.kt @@ -246,11 +246,11 @@ class BluetoothDeviceDetailsViewModelTest { } private fun getLatestLayout(layout: DeviceSettingLayout): List> { - var latestLayout = MutableList(layout.rows.size) { emptyList() } + val latestLayout = MutableList(layout.rows.size) { emptyList() } for (i in layout.rows.indices) { layout.rows[i] - .settingIds - .onEach { latestLayout[i] = it } + .columns + .onEach { latestLayout[i] = it.map { c -> c.settingId } } .launchIn(testScope.backgroundScope) } @@ -278,15 +278,15 @@ class BluetoothDeviceDetailsViewModelTest { DeviceSettingModel.ActionSwitchPreference(cachedDevice, settingId, "title") private fun buildRemoteSettingItem(settingId: Int) = - DeviceSettingConfigItemModel.AppProvidedItem(settingId) + DeviceSettingConfigItemModel.AppProvidedItem(settingId, false) private companion object { val BUILTIN_SETTING_ITEM_1 = DeviceSettingConfigItemModel.BuiltinItem.CommonBuiltinItem( - DeviceSettingId.DEVICE_SETTING_ID_HEADER, "bluetooth_device_header") + DeviceSettingId.DEVICE_SETTING_ID_HEADER, false, "bluetooth_device_header") val BUILDIN_SETTING_ITEM_2 = DeviceSettingConfigItemModel.BuiltinItem.CommonBuiltinItem( - DeviceSettingId.DEVICE_SETTING_ID_ACTION_BUTTONS, "action_buttons") - val SETTING_ITEM_HELP = DeviceSettingConfigItemModel.AppProvidedItem(12345) + DeviceSettingId.DEVICE_SETTING_ID_ACTION_BUTTONS, false, "action_buttons") + val SETTING_ITEM_HELP = DeviceSettingConfigItemModel.AppProvidedItem(12345, false) } } From 5b26a37a7e63a98da215ccf0284735bc4fa0ef24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=ADas=20Hern=C3=A1ndez?= Date: Tue, 10 Sep 2024 14:16:46 +0200 Subject: [PATCH 04/11] Rename usages of ZenRule.isAutomaticActive() to isActive() in Settings Bug: 363193376 Test: N/A, automatic refactor Flag: EXEMPT automatic refactor Change-Id: Ice1ceccbe09e6206555b2f2b75bd7ea39d24dfd7 --- .../zen/ZenModeBehaviorFooterPreferenceController.java | 2 +- .../zen/ZenModeSettingsFooterPreferenceController.java | 4 ++-- .../zen/ZenModeBehaviorFooterPreferenceControllerTest.java | 5 ++--- .../zen/ZenModeSettingsFooterPreferenceControllerTest.java | 3 +-- 4 files changed, 6 insertions(+), 8 deletions(-) diff --git a/src/com/android/settings/notification/zen/ZenModeBehaviorFooterPreferenceController.java b/src/com/android/settings/notification/zen/ZenModeBehaviorFooterPreferenceController.java index 9332c9b0c2b..82f0816fc86 100644 --- a/src/com/android/settings/notification/zen/ZenModeBehaviorFooterPreferenceController.java +++ b/src/com/android/settings/notification/zen/ZenModeBehaviorFooterPreferenceController.java @@ -76,7 +76,7 @@ public class ZenModeBehaviorFooterPreferenceController extends AbstractZenModePr // DND turned on by an automatic rule with deprecated zen mode for (ZenModeConfig.ZenRule automaticRule : config.automaticRules.values()) { - if (automaticRule.isAutomaticActive() && isDeprecatedZenMode( + if (automaticRule.isActive() && isDeprecatedZenMode( automaticRule.zenMode)) { ComponentName component = automaticRule.component; if (component != null) { diff --git a/src/com/android/settings/notification/zen/ZenModeSettingsFooterPreferenceController.java b/src/com/android/settings/notification/zen/ZenModeSettingsFooterPreferenceController.java index 6a574411a25..4781b360419 100644 --- a/src/com/android/settings/notification/zen/ZenModeSettingsFooterPreferenceController.java +++ b/src/com/android/settings/notification/zen/ZenModeSettingsFooterPreferenceController.java @@ -153,7 +153,7 @@ public class ZenModeSettingsFooterPreferenceController extends AbstractZenModePr // DND turned on by an automatic rule for (ZenModeConfig.ZenRule automaticRule : config.automaticRules.values()) { - if (automaticRule.isAutomaticActive()) { + if (automaticRule.isActive()) { // set footer if 3rd party rule if (!mZenModeConfigWrapper.isTimeRule(automaticRule.conditionId)) { return mContext.getString(R.string.zen_mode_settings_dnd_automatic_rule, @@ -180,7 +180,7 @@ public class ZenModeSettingsFooterPreferenceController extends AbstractZenModePr } for (ZenModeConfig.ZenRule automaticRule : config.automaticRules.values()) { - if (automaticRule.isAutomaticActive()) { + if (automaticRule.isActive()) { zenRules.add(automaticRule); } } diff --git a/tests/robotests/src/com/android/settings/notification/zen/ZenModeBehaviorFooterPreferenceControllerTest.java b/tests/robotests/src/com/android/settings/notification/zen/ZenModeBehaviorFooterPreferenceControllerTest.java index fd795155a4f..2b0d6e7687d 100644 --- a/tests/robotests/src/com/android/settings/notification/zen/ZenModeBehaviorFooterPreferenceControllerTest.java +++ b/tests/robotests/src/com/android/settings/notification/zen/ZenModeBehaviorFooterPreferenceControllerTest.java @@ -43,7 +43,6 @@ import androidx.preference.PreferenceScreen; import com.android.settings.R; import com.android.settings.notification.zen.AbstractZenModePreferenceController.ZenModeConfigWrapper; -import com.android.settings.notification.zen.ZenModeBehaviorFooterPreferenceController; import com.android.settingslib.core.lifecycle.Lifecycle; import org.junit.Before; @@ -206,7 +205,7 @@ public class ZenModeBehaviorFooterPreferenceControllerTest { ZenRule injectedRule = spy(new ZenRule()); injectedRule.zenMode = ZEN_MODE_ALARMS; injectedRule.component = mock(ComponentName.class); - when(injectedRule.isAutomaticActive()).thenReturn(true); + when(injectedRule.isActive()).thenReturn(true); when(injectedRule.component.getPackageName()).thenReturn(TEST_APP_NAME); injectedAutomaticRules.put("testid", injectedRule); @@ -226,7 +225,7 @@ public class ZenModeBehaviorFooterPreferenceControllerTest { ZenRule injectedRule = spy(new ZenRule()); injectedRule.zenMode = ZEN_MODE_NO_INTERRUPTIONS; injectedRule.component = mock(ComponentName.class); - when(injectedRule.isAutomaticActive()).thenReturn(true); + when(injectedRule.isActive()).thenReturn(true); when(injectedRule.component.getPackageName()).thenReturn(TEST_APP_NAME); injectedAutomaticRules.put("testid", injectedRule); diff --git a/tests/robotests/src/com/android/settings/notification/zen/ZenModeSettingsFooterPreferenceControllerTest.java b/tests/robotests/src/com/android/settings/notification/zen/ZenModeSettingsFooterPreferenceControllerTest.java index efa2f558b5f..e5c2d426cae 100644 --- a/tests/robotests/src/com/android/settings/notification/zen/ZenModeSettingsFooterPreferenceControllerTest.java +++ b/tests/robotests/src/com/android/settings/notification/zen/ZenModeSettingsFooterPreferenceControllerTest.java @@ -44,7 +44,6 @@ import androidx.preference.Preference; import androidx.preference.PreferenceScreen; import com.android.settings.notification.zen.AbstractZenModePreferenceController.ZenModeConfigWrapper; -import com.android.settings.notification.zen.ZenModeSettingsFooterPreferenceController; import com.android.settingslib.core.lifecycle.Lifecycle; import org.junit.Before; @@ -289,7 +288,7 @@ public class ZenModeSettingsFooterPreferenceControllerTest { injectedRule.component = mock(ComponentName.class); injectedRule.name = nameAndId; injectedRule.conditionId = new Uri.Builder().authority(nameAndId).build(); // unique uri - when(injectedRule.isAutomaticActive()).thenReturn(isActive); + when(injectedRule.isActive()).thenReturn(isActive); when(mConfigWrapper.isTimeRule(injectedRule.conditionId)).thenReturn(!isApp); if (isApp) { when(injectedRule.component.getPackageName()).thenReturn(TEST_APP_NAME); From 1e49ce24829e5109f3f7014d61e17d4e7925c59e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=ADas=20Hern=C3=A1ndez?= Date: Wed, 11 Sep 2024 15:55:16 +0000 Subject: [PATCH 05/11] Use the Modes icon in the "dummy" ModesActivity So that newly-created Modes Settings shortcuts have it (a different CL will update previous shortcuts). Bug: 365545604 Change-Id: I74760bde4c40646b0571e0b7dad6383729f470f7 Test: manual Flag: android.app.modes_ui --- AndroidManifest.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AndroidManifest.xml b/AndroidManifest.xml index 66c3beb5bd2..3527e831516 100644 --- a/AndroidManifest.xml +++ b/AndroidManifest.xml @@ -1293,7 +1293,7 @@ From a6db1aabb6f86d527875a2c5d754d8a4c94604e2 Mon Sep 17 00:00:00 2001 From: Chaohui Wang Date: Wed, 11 Sep 2024 18:20:23 +0800 Subject: [PATCH 06/11] Create SimRepository Which unifies whether we should sim settings on some related pages. Before this change, we check SubscriptionUtil.isSimHardwareVisible() and / or Utils.isWifiOnly(). After this change, we unified logic to, canChangeSimSettings() = packageManager.hasSystemFeature(PackageManager.FEATURE_TELEPHONY) && userManager.isAdminUser Fix: 365924140 Flag: EXEMPT bug fix Test: manual - check Network & internet Test: unit tests Change-Id: Ibf83237e3d0088f78c96a1b39ee8f1e3a9c756ea --- .../network/MobileNetworkListFragment.kt | 9 +- .../MobileNetworkPreferenceController.java | 162 --------------- .../MobileNetworkSummaryController.java | 8 +- ...LevelNetworkEntryPreferenceController.java | 55 ------ ...opLevelNetworkEntryPreferenceController.kt | 58 ++++++ .../network/telephony/SimRepository.kt | 30 +++ .../network/NetworkCellularGroupProvider.kt | 8 +- .../MobileNetworkSummaryControllerTest.java | 19 -- ...lNetworkEntryPreferenceControllerTest.java | 102 ---------- .../network/MobileNetworkListFragmentTest.kt | 40 +--- ...velNetworkEntryPreferenceControllerTest.kt | 101 ++++++++++ .../network/telephony/SimRepositoryTest.kt | 87 ++++++++ ...MobileNetworkPreferenceControllerTest.java | 187 ------------------ 13 files changed, 295 insertions(+), 571 deletions(-) delete mode 100644 src/com/android/settings/network/MobileNetworkPreferenceController.java delete mode 100644 src/com/android/settings/network/TopLevelNetworkEntryPreferenceController.java create mode 100644 src/com/android/settings/network/TopLevelNetworkEntryPreferenceController.kt create mode 100644 src/com/android/settings/network/telephony/SimRepository.kt delete mode 100644 tests/robotests/src/com/android/settings/network/TopLevelNetworkEntryPreferenceControllerTest.java create mode 100644 tests/spa_unit/src/com/android/settings/network/TopLevelNetworkEntryPreferenceControllerTest.kt create mode 100644 tests/spa_unit/src/com/android/settings/network/telephony/SimRepositoryTest.kt delete mode 100644 tests/unit/src/com/android/settings/network/MobileNetworkPreferenceControllerTest.java diff --git a/src/com/android/settings/network/MobileNetworkListFragment.kt b/src/com/android/settings/network/MobileNetworkListFragment.kt index bb88330dcfb..d110779b36d 100644 --- a/src/com/android/settings/network/MobileNetworkListFragment.kt +++ b/src/com/android/settings/network/MobileNetworkListFragment.kt @@ -27,13 +27,13 @@ import com.android.settings.R import com.android.settings.SettingsPreferenceFragment import com.android.settings.dashboard.DashboardFragment import com.android.settings.flags.Flags +import com.android.settings.network.telephony.SimRepository import com.android.settings.network.telephony.euicc.EuiccRepository import com.android.settings.search.BaseSearchIndexProvider import com.android.settings.spa.SpaActivity.Companion.startSpaActivity import com.android.settings.spa.network.NetworkCellularGroupProvider import com.android.settingslib.search.SearchIndexable import com.android.settingslib.spa.framework.util.collectLatestWithLifecycle -import com.android.settingslib.spaprivileged.framework.common.userManager import com.android.settingslib.spaprivileged.settingsprovider.settingsGlobalBooleanFlow @SearchIndexable(forTarget = SearchIndexable.ALL and SearchIndexable.ARC.inv()) @@ -85,10 +85,11 @@ class MobileNetworkListFragment : DashboardFragment() { val SEARCH_INDEX_DATA_PROVIDER = SearchIndexProvider() @VisibleForTesting - class SearchIndexProvider : BaseSearchIndexProvider(R.xml.network_provider_sims_list) { + class SearchIndexProvider( + private val simRepositoryFactory: (Context) -> SimRepository = ::SimRepository + ) : BaseSearchIndexProvider(R.xml.network_provider_sims_list) { public override fun isPageSearchEnabled(context: Context): Boolean = - SubscriptionUtil.isSimHardwareVisible(context) && - context.userManager.isAdminUser + simRepositoryFactory(context).showMobileNetworkPage() } } } diff --git a/src/com/android/settings/network/MobileNetworkPreferenceController.java b/src/com/android/settings/network/MobileNetworkPreferenceController.java deleted file mode 100644 index b49613a7a94..00000000000 --- a/src/com/android/settings/network/MobileNetworkPreferenceController.java +++ /dev/null @@ -1,162 +0,0 @@ -/* - * Copyright (C) 2016 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.network; - -import static android.os.UserHandle.myUserId; -import static android.os.UserManager.DISALLOW_CONFIG_MOBILE_NETWORKS; - -import static com.android.settings.Utils.SETTINGS_PACKAGE_NAME; - -import static androidx.lifecycle.Lifecycle.Event; - -import android.content.BroadcastReceiver; -import android.content.Context; -import android.content.Intent; -import android.content.IntentFilter; -import android.os.UserManager; -import android.provider.Settings; -import android.telephony.PhoneStateListener; -import android.telephony.ServiceState; -import android.telephony.TelephonyCallback; -import android.telephony.TelephonyManager; - -import androidx.annotation.VisibleForTesting; -import androidx.lifecycle.LifecycleObserver; -import androidx.lifecycle.OnLifecycleEvent; -import androidx.preference.Preference; -import androidx.preference.PreferenceScreen; - -import com.android.settings.core.PreferenceControllerMixin; -import com.android.settings.network.telephony.MobileNetworkUtils; -import com.android.settingslib.RestrictedLockUtilsInternal; -import com.android.settingslib.RestrictedPreference; -import com.android.settingslib.Utils; -import com.android.settingslib.core.AbstractPreferenceController; - -public class MobileNetworkPreferenceController extends AbstractPreferenceController - implements PreferenceControllerMixin, LifecycleObserver { - - @VisibleForTesting - static final String KEY_MOBILE_NETWORK_SETTINGS = "mobile_network_settings"; - - private final boolean mIsSecondaryUser; - private final TelephonyManager mTelephonyManager; - private final UserManager mUserManager; - private Preference mPreference; - @VisibleForTesting - MobileNetworkTelephonyCallback mTelephonyCallback; - - private BroadcastReceiver mAirplanModeChangedReceiver; - - public MobileNetworkPreferenceController(Context context) { - super(context); - mUserManager = (UserManager) context.getSystemService(Context.USER_SERVICE); - mTelephonyManager = (TelephonyManager) context.getSystemService(Context.TELEPHONY_SERVICE); - mIsSecondaryUser = !mUserManager.isAdminUser(); - - mAirplanModeChangedReceiver = new BroadcastReceiver() { - @Override - public void onReceive(Context context, Intent intent) { - updateState(mPreference); - } - }; - } - - @Override - public boolean isAvailable() { - return !isUserRestricted() && !Utils.isWifiOnly(mContext); - } - - public boolean isUserRestricted() { - return mIsSecondaryUser || - RestrictedLockUtilsInternal.hasBaseUserRestriction( - mContext, - DISALLOW_CONFIG_MOBILE_NETWORKS, - myUserId()); - } - - @Override - public void displayPreference(PreferenceScreen screen) { - super.displayPreference(screen); - mPreference = screen.findPreference(getPreferenceKey()); - } - - @Override - public String getPreferenceKey() { - return KEY_MOBILE_NETWORK_SETTINGS; - } - - class MobileNetworkTelephonyCallback extends TelephonyCallback implements - TelephonyCallback.ServiceStateListener { - @Override - public void onServiceStateChanged(ServiceState serviceState) { - updateState(mPreference); - } - } - - @OnLifecycleEvent(Event.ON_START) - public void onStart() { - if (isAvailable()) { - if (mTelephonyCallback == null) { - mTelephonyCallback = new MobileNetworkTelephonyCallback(); - } - mTelephonyManager.registerTelephonyCallback( - mContext.getMainExecutor(), mTelephonyCallback); - } - if (mAirplanModeChangedReceiver != null) { - mContext.registerReceiver(mAirplanModeChangedReceiver, - new IntentFilter(Intent.ACTION_AIRPLANE_MODE_CHANGED)); - } - } - - @OnLifecycleEvent(Event.ON_STOP) - public void onStop() { - if (mTelephonyCallback != null) { - mTelephonyManager.unregisterTelephonyCallback(mTelephonyCallback); - } - if (mAirplanModeChangedReceiver != null) { - mContext.unregisterReceiver(mAirplanModeChangedReceiver); - } - } - - @Override - public void updateState(Preference preference) { - super.updateState(preference); - - if (preference instanceof RestrictedPreference && - ((RestrictedPreference) preference).isDisabledByAdmin()) { - return; - } - preference.setEnabled(Settings.Global.getInt( - mContext.getContentResolver(), Settings.Global.AIRPLANE_MODE_ON, 0) == 0); - } - - @Override - public boolean handlePreferenceTreeClick(Preference preference) { - if (KEY_MOBILE_NETWORK_SETTINGS.equals(preference.getKey())) { - final Intent intent = new Intent(Settings.ACTION_NETWORK_OPERATOR_SETTINGS); - intent.setPackage(SETTINGS_PACKAGE_NAME); - mContext.startActivity(intent); - return true; - } - return false; - } - - @Override - public CharSequence getSummary() { - return MobileNetworkUtils.getCurrentCarrierNameForDisplay(mContext); - } -} diff --git a/src/com/android/settings/network/MobileNetworkSummaryController.java b/src/com/android/settings/network/MobileNetworkSummaryController.java index 9bf6915a527..45d475f8eb8 100644 --- a/src/com/android/settings/network/MobileNetworkSummaryController.java +++ b/src/com/android/settings/network/MobileNetworkSummaryController.java @@ -21,7 +21,6 @@ import static androidx.lifecycle.Lifecycle.Event.ON_RESUME; import android.content.Context; import android.content.Intent; -import android.os.UserManager; import android.telephony.SubscriptionManager; import android.telephony.euicc.EuiccManager; @@ -35,10 +34,10 @@ import androidx.preference.PreferenceScreen; import com.android.settings.R; import com.android.settings.core.PreferenceControllerMixin; import com.android.settings.dashboard.DashboardFragment; +import com.android.settings.network.telephony.SimRepository; import com.android.settings.network.telephony.euicc.EuiccRepository; import com.android.settings.overlay.FeatureFactory; import com.android.settingslib.RestrictedPreference; -import com.android.settingslib.Utils; import com.android.settingslib.core.AbstractPreferenceController; import com.android.settingslib.core.instrumentation.MetricsFeatureProvider; import com.android.settingslib.mobile.dataservice.MobileNetworkInfoEntity; @@ -56,7 +55,6 @@ public class MobileNetworkSummaryController extends AbstractPreferenceController private static final String KEY = "mobile_network_list"; private final MetricsFeatureProvider mMetricsFeatureProvider; - private UserManager mUserManager; private RestrictedPreference mPreference; private MobileNetworkRepository mMobileNetworkRepository; @@ -85,7 +83,6 @@ public class MobileNetworkSummaryController extends AbstractPreferenceController LifecycleOwner lifecycleOwner) { super(context); mMetricsFeatureProvider = FeatureFactory.getFeatureFactory().getMetricsFeatureProvider(); - mUserManager = context.getSystemService(UserManager.class); mLifecycleOwner = lifecycleOwner; mMobileNetworkRepository = MobileNetworkRepository.getInstance(context); mIsAirplaneModeOn = mMobileNetworkRepository.isAirplaneModeOn(); @@ -185,8 +182,7 @@ public class MobileNetworkSummaryController extends AbstractPreferenceController @Override public boolean isAvailable() { - return SubscriptionUtil.isSimHardwareVisible(mContext) && - !Utils.isWifiOnly(mContext) && mUserManager.isAdminUser(); + return new SimRepository(mContext).showMobileNetworkPage(); } @Override diff --git a/src/com/android/settings/network/TopLevelNetworkEntryPreferenceController.java b/src/com/android/settings/network/TopLevelNetworkEntryPreferenceController.java deleted file mode 100644 index a5c19adcd99..00000000000 --- a/src/com/android/settings/network/TopLevelNetworkEntryPreferenceController.java +++ /dev/null @@ -1,55 +0,0 @@ -/* - * Copyright (C) 2018 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.network; - -import android.content.Context; -import android.text.BidiFormatter; - -import com.android.settings.R; -import com.android.settings.Utils; -import com.android.settings.activityembedding.ActivityEmbeddingUtils; -import com.android.settings.core.BasePreferenceController; - -public class TopLevelNetworkEntryPreferenceController extends BasePreferenceController { - - private final MobileNetworkPreferenceController mMobileNetworkPreferenceController; - - public TopLevelNetworkEntryPreferenceController(Context context, String preferenceKey) { - super(context, preferenceKey); - mMobileNetworkPreferenceController = new MobileNetworkPreferenceController(mContext); - } - - @Override - public int getAvailabilityStatus() { - // TODO(b/281597506): Update the ActivityEmbeddingUtils.isEmbeddingActivityEnabled - // while getting the new API. - return (Utils.isDemoUser(mContext) - && !ActivityEmbeddingUtils.isEmbeddingActivityEnabled(mContext)) - ? UNSUPPORTED_ON_DEVICE : AVAILABLE; - } - - @Override - public CharSequence getSummary() { - if (mMobileNetworkPreferenceController.isAvailable()) { - return BidiFormatter.getInstance() - .unicodeWrap(mContext.getString(R.string.network_dashboard_summary_mobile)); - } else { - return BidiFormatter.getInstance() - .unicodeWrap(mContext.getString(R.string.network_dashboard_summary_no_mobile)); - } - } -} diff --git a/src/com/android/settings/network/TopLevelNetworkEntryPreferenceController.kt b/src/com/android/settings/network/TopLevelNetworkEntryPreferenceController.kt new file mode 100644 index 00000000000..1722f6ae6b9 --- /dev/null +++ b/src/com/android/settings/network/TopLevelNetworkEntryPreferenceController.kt @@ -0,0 +1,58 @@ +/* + * 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.network + +import android.content.Context +import android.text.BidiFormatter +import com.android.settings.R +import com.android.settings.Utils +import com.android.settings.activityembedding.ActivityEmbeddingUtils +import com.android.settings.core.BasePreferenceController +import com.android.settings.network.telephony.SimRepository + +class TopLevelNetworkEntryPreferenceController +@JvmOverloads +constructor( + context: Context, + preferenceKey: String, + private val simRepository: SimRepository = SimRepository(context), + private val isDemoUser: () -> Boolean = { Utils.isDemoUser(context) }, + private val isEmbeddingActivityEnabled: () -> Boolean = { + ActivityEmbeddingUtils.isEmbeddingActivityEnabled(context) + }, +) : BasePreferenceController(context, preferenceKey) { + + override fun getAvailabilityStatus(): Int { + // TODO(b/281597506): Update the ActivityEmbeddingUtils.isEmbeddingActivityEnabled + // while getting the new API. + return if (isDemoUser() && !isEmbeddingActivityEnabled()) { + UNSUPPORTED_ON_DEVICE + } else { + AVAILABLE + } + } + + override fun getSummary(): CharSequence { + val summaryResId = + if (simRepository.showMobileNetworkPage()) { + R.string.network_dashboard_summary_mobile + } else { + R.string.network_dashboard_summary_no_mobile + } + return BidiFormatter.getInstance().unicodeWrap(mContext.getString(summaryResId)) + } +} diff --git a/src/com/android/settings/network/telephony/SimRepository.kt b/src/com/android/settings/network/telephony/SimRepository.kt new file mode 100644 index 00000000000..ed3c8aa303c --- /dev/null +++ b/src/com/android/settings/network/telephony/SimRepository.kt @@ -0,0 +1,30 @@ +/* + * 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.network.telephony + +import android.content.Context +import android.content.pm.PackageManager +import com.android.settingslib.spaprivileged.framework.common.userManager + +class SimRepository(context: Context) { + private val packageManager = context.packageManager + private val userManager = context.userManager + + /** Gets whether we show mobile network settings page to the current user. */ + fun showMobileNetworkPage(): Boolean = + packageManager.hasSystemFeature(PackageManager.FEATURE_TELEPHONY) && userManager.isAdminUser +} diff --git a/src/com/android/settings/spa/network/NetworkCellularGroupProvider.kt b/src/com/android/settings/spa/network/NetworkCellularGroupProvider.kt index f76bba45388..d736fe5224a 100644 --- a/src/com/android/settings/spa/network/NetworkCellularGroupProvider.kt +++ b/src/com/android/settings/spa/network/NetworkCellularGroupProvider.kt @@ -48,9 +48,9 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.android.settings.R import com.android.settings.flags.Flags import com.android.settings.network.SubscriptionInfoListViewModel -import com.android.settings.network.SubscriptionUtil import com.android.settings.network.telephony.DataSubscriptionRepository import com.android.settings.network.telephony.MobileDataRepository +import com.android.settings.network.telephony.SimRepository import com.android.settings.network.telephony.requireSubscriptionManager import com.android.settings.spa.network.PrimarySimRepository.PrimarySimInfo import com.android.settings.spa.search.SearchablePage @@ -66,7 +66,6 @@ import com.android.settingslib.spa.widget.preference.PreferenceModel import com.android.settingslib.spa.widget.scaffold.RegularScaffold import com.android.settingslib.spa.widget.ui.Category import com.android.settingslib.spaprivileged.framework.common.broadcastReceiverFlow -import com.android.settingslib.spaprivileged.framework.common.userManager import com.android.settingslib.spaprivileged.settingsprovider.settingsGlobalBooleanFlow import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -213,10 +212,7 @@ open class NetworkCellularGroupProvider : SettingsPageProvider, SearchablePage { const val fileName = "NetworkCellularGroupProvider" private fun isPageSearchable(context: Context) = - Flags.isDualSimOnboardingEnabled() && - SubscriptionUtil.isSimHardwareVisible(context) && - !com.android.settingslib.Utils.isWifiOnly(context) && - context.userManager.isAdminUser + Flags.isDualSimOnboardingEnabled() && SimRepository(context).showMobileNetworkPage() } } diff --git a/tests/robotests/src/com/android/settings/network/MobileNetworkSummaryControllerTest.java b/tests/robotests/src/com/android/settings/network/MobileNetworkSummaryControllerTest.java index 8d6d2d9bc4e..1823d6d6bed 100644 --- a/tests/robotests/src/com/android/settings/network/MobileNetworkSummaryControllerTest.java +++ b/tests/robotests/src/com/android/settings/network/MobileNetworkSummaryControllerTest.java @@ -32,7 +32,6 @@ import static org.mockito.Mockito.when; import android.content.Context; import android.content.Intent; -import android.os.UserManager; import android.provider.Settings; import android.telephony.SubscriptionInfo; import android.telephony.SubscriptionManager; @@ -73,8 +72,6 @@ public class MobileNetworkSummaryControllerTest { @Mock private PreferenceScreen mPreferenceScreen; @Mock - private UserManager mUserManager; - @Mock private MobileNetworkRepository mMobileNetworkRepository; @Mock private MobileNetworkRepository.MobileNetworkCallback mMobileNetworkCallback; @@ -92,7 +89,6 @@ public class MobileNetworkSummaryControllerTest { doReturn(mTelephonyManager).when(mContext).getSystemService(TelephonyManager.class); doReturn(mSubscriptionManager).when(mContext).getSystemService(SubscriptionManager.class); doReturn(mEuiccManager).when(mContext).getSystemService(EuiccManager.class); - doReturn(mUserManager).when(mContext).getSystemService(UserManager.class); mMobileNetworkRepository = MobileNetworkRepository.getInstance(mContext); mLifecycleOwner = () -> mLifecycle; mLifecycle = new Lifecycle(mLifecycleOwner); @@ -118,21 +114,6 @@ public class MobileNetworkSummaryControllerTest { SubscriptionUtil.setAvailableSubscriptionsForTesting(null); } - @Test - public void isAvailable_wifiOnlyMode_notAvailable() { - when(mTelephonyManager.isDataCapable()).thenReturn(false); - when(mUserManager.isAdminUser()).thenReturn(true); - - assertThat(mController.isAvailable()).isFalse(); - } - - @Test - public void isAvailable_secondaryUser_notAvailable() { - when(mTelephonyManager.isDataCapable()).thenReturn(true); - when(mUserManager.isAdminUser()).thenReturn(false); - assertThat(mController.isAvailable()).isFalse(); - } - @Test public void getSummary_noSubscriptions_returnSummaryCorrectly() { mController.displayPreference(mPreferenceScreen); diff --git a/tests/robotests/src/com/android/settings/network/TopLevelNetworkEntryPreferenceControllerTest.java b/tests/robotests/src/com/android/settings/network/TopLevelNetworkEntryPreferenceControllerTest.java deleted file mode 100644 index 8e0c8631e4f..00000000000 --- a/tests/robotests/src/com/android/settings/network/TopLevelNetworkEntryPreferenceControllerTest.java +++ /dev/null @@ -1,102 +0,0 @@ -/* - * Copyright (C) 2018 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.network; - -import static com.android.settings.core.BasePreferenceController.UNSUPPORTED_ON_DEVICE; - -import static com.google.common.truth.Truth.assertThat; - -import static org.mockito.Mockito.when; - -import android.content.Context; -import android.os.UserManager; -import android.text.BidiFormatter; -import android.util.FeatureFlagUtils; - -import com.android.settings.R; -import com.android.settings.testutils.shadow.ShadowRestrictedLockUtilsInternal; -import com.android.settings.testutils.shadow.ShadowUserManager; -import com.android.settings.testutils.shadow.ShadowUtils; - -import org.junit.After; -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.mockito.Mock; -import org.mockito.MockitoAnnotations; -import org.robolectric.RobolectricTestRunner; -import org.robolectric.RuntimeEnvironment; -import org.robolectric.annotation.Config; -import org.robolectric.shadow.api.Shadow; -import org.robolectric.util.ReflectionHelpers; - -@RunWith(RobolectricTestRunner.class) -@Config(shadows = { - ShadowRestrictedLockUtilsInternal.class, - ShadowUtils.class, - ShadowUserManager.class, -}) -public class TopLevelNetworkEntryPreferenceControllerTest { - - @Mock - private MobileNetworkPreferenceController mMobileNetworkPreferenceController;; - - private Context mContext; - private TopLevelNetworkEntryPreferenceController mController; - - @Before - public void setUp() { - MockitoAnnotations.initMocks(this); - mContext = RuntimeEnvironment.application; - final ShadowUserManager um = Shadow.extract( - RuntimeEnvironment.application.getSystemService(UserManager.class)); - um.setIsAdminUser(true); - - mController = new TopLevelNetworkEntryPreferenceController(mContext, "test_key"); - - ReflectionHelpers.setField(mController, "mMobileNetworkPreferenceController", - mMobileNetworkPreferenceController); - } - - @After - public void tearDown() { - ShadowUtils.reset(); - } - - @Test - public void getAvailabilityStatus_demoUser_nonLargeScreen_unsupported() { - ShadowUtils.setIsDemoUser(true); - FeatureFlagUtils.setEnabled(mContext, "settings_support_large_screen", false); - assertThat(mController.getAvailabilityStatus()).isEqualTo(UNSUPPORTED_ON_DEVICE); - } - - @Test - public void getSummary_hasMobile_shouldReturnMobileSummary() { - when(mMobileNetworkPreferenceController.isAvailable()).thenReturn(true); - - assertThat(mController.getSummary()).isEqualTo(BidiFormatter.getInstance().unicodeWrap( - mContext.getString(R.string.network_dashboard_summary_mobile))); - } - - @Test - public void getSummary_noMobile_shouldReturnNoMobileSummary() { - when(mMobileNetworkPreferenceController.isAvailable()).thenReturn(false); - - assertThat(mController.getSummary()).isEqualTo(BidiFormatter.getInstance().unicodeWrap( - mContext.getString(R.string.network_dashboard_summary_no_mobile))); - } -} diff --git a/tests/spa_unit/src/com/android/settings/network/MobileNetworkListFragmentTest.kt b/tests/spa_unit/src/com/android/settings/network/MobileNetworkListFragmentTest.kt index 3ba4bac39ce..4bb5f2f803f 100644 --- a/tests/spa_unit/src/com/android/settings/network/MobileNetworkListFragmentTest.kt +++ b/tests/spa_unit/src/com/android/settings/network/MobileNetworkListFragmentTest.kt @@ -17,57 +17,37 @@ package com.android.settings.network import android.content.Context -import android.content.res.Resources -import android.os.UserManager import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.android.settings.R -import com.android.settingslib.spaprivileged.framework.common.userManager +import com.android.settings.network.MobileNetworkListFragment.Companion.SearchIndexProvider +import com.android.settings.network.telephony.SimRepository import com.google.common.truth.Truth.assertThat import org.junit.Test import org.junit.runner.RunWith import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock -import org.mockito.kotlin.spy import org.mockito.kotlin.stub @RunWith(AndroidJUnit4::class) class MobileNetworkListFragmentTest { - private val mockUserManager = mock() + private val mockSimRepository = mock() - private val mockResources = mock() - - private val context: Context = spy(ApplicationProvider.getApplicationContext()) { - on { userManager } doReturn mockUserManager - on { resources } doReturn mockResources - } + private val context: Context = ApplicationProvider.getApplicationContext() @Test - fun isPageSearchEnabled_adminUser_shouldReturnTrue() { - mockUserManager.stub { - on { isAdminUser } doReturn true - } - mockResources.stub { - on { getBoolean(R.bool.config_show_sim_info) } doReturn true - } + fun isPageSearchEnabled_showMobileNetworkPage_returnTrue() { + mockSimRepository.stub { on { showMobileNetworkPage() } doReturn true } - val isEnabled = - MobileNetworkListFragment.SEARCH_INDEX_DATA_PROVIDER.isPageSearchEnabled(context) + val isEnabled = SearchIndexProvider { mockSimRepository }.isPageSearchEnabled(context) assertThat(isEnabled).isTrue() } @Test - fun isPageSearchEnabled_nonAdminUser_shouldReturnFalse() { - mockUserManager.stub { - on { isAdminUser } doReturn false - } - mockResources.stub { - on { getBoolean(R.bool.config_show_sim_info) } doReturn true - } + fun isPageSearchEnabled_hideMobileNetworkPage_returnFalse() { + mockSimRepository.stub { on { showMobileNetworkPage() } doReturn false } - val isEnabled = - MobileNetworkListFragment.SEARCH_INDEX_DATA_PROVIDER.isPageSearchEnabled(context) + val isEnabled = SearchIndexProvider { mockSimRepository }.isPageSearchEnabled(context) assertThat(isEnabled).isFalse() } diff --git a/tests/spa_unit/src/com/android/settings/network/TopLevelNetworkEntryPreferenceControllerTest.kt b/tests/spa_unit/src/com/android/settings/network/TopLevelNetworkEntryPreferenceControllerTest.kt new file mode 100644 index 00000000000..27c960282dc --- /dev/null +++ b/tests/spa_unit/src/com/android/settings/network/TopLevelNetworkEntryPreferenceControllerTest.kt @@ -0,0 +1,101 @@ +/* + * 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.network + +import android.content.Context +import android.text.BidiFormatter +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.android.settings.R +import com.android.settings.core.BasePreferenceController +import com.android.settings.network.telephony.SimRepository +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.stub + +@RunWith(AndroidJUnit4::class) +class TopLevelNetworkEntryPreferenceControllerTest { + + private val mockSimRepository = mock() + + private val context: Context = ApplicationProvider.getApplicationContext() + + private var isDemoUser = false + private var isEmbeddingActivityEnabled = false + + private var controller = + TopLevelNetworkEntryPreferenceController( + context = context, + preferenceKey = TEST_KEY, + simRepository = mockSimRepository, + isDemoUser = { isDemoUser }, + isEmbeddingActivityEnabled = { isEmbeddingActivityEnabled }, + ) + + @Test + fun getAvailabilityStatus_demoUser_largeScreen_unsupported() { + isDemoUser = true + isEmbeddingActivityEnabled = true + + val availabilityStatus = controller.availabilityStatus + + assertThat(availabilityStatus).isEqualTo(BasePreferenceController.AVAILABLE) + } + + @Test + fun getAvailabilityStatus_demoUser_nonLargeScreen_unsupported() { + isDemoUser = true + isEmbeddingActivityEnabled = false + + val availabilityStatus = controller.availabilityStatus + + assertThat(availabilityStatus).isEqualTo(BasePreferenceController.UNSUPPORTED_ON_DEVICE) + } + + @Test + fun getSummary_hasMobile_shouldReturnMobileSummary() { + mockSimRepository.stub { on { showMobileNetworkPage() } doReturn true } + + val summary = controller.summary + + assertThat(summary) + .isEqualTo( + BidiFormatter.getInstance() + .unicodeWrap(context.getString(R.string.network_dashboard_summary_mobile)) + ) + } + + @Test + fun getSummary_noMobile_shouldReturnNoMobileSummary() { + mockSimRepository.stub { on { showMobileNetworkPage() } doReturn false } + + val summary = controller.summary + + assertThat(summary) + .isEqualTo( + BidiFormatter.getInstance() + .unicodeWrap(context.getString(R.string.network_dashboard_summary_no_mobile)) + ) + } + + private companion object { + const val TEST_KEY = "test_key" + } +} diff --git a/tests/spa_unit/src/com/android/settings/network/telephony/SimRepositoryTest.kt b/tests/spa_unit/src/com/android/settings/network/telephony/SimRepositoryTest.kt new file mode 100644 index 00000000000..bbcac086a10 --- /dev/null +++ b/tests/spa_unit/src/com/android/settings/network/telephony/SimRepositoryTest.kt @@ -0,0 +1,87 @@ +/* + * 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.network.telephony + +import android.content.Context +import android.content.pm.PackageManager +import android.os.UserManager +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.android.settingslib.spaprivileged.framework.common.userManager +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.spy +import org.mockito.kotlin.stub + +@RunWith(AndroidJUnit4::class) +class SimRepositoryTest { + + private val mockUserManager = mock() + + private val mockPackageManager = mock() + + private val context: Context = spy(ApplicationProvider.getApplicationContext()) { + on { userManager } doReturn mockUserManager + on { packageManager } doReturn mockPackageManager + } + + private val repository = SimRepository(context) + + @Test + fun showMobileNetworkPage_adminUserAndHasTelephony_returnTrue() { + mockUserManager.stub { + on { isAdminUser } doReturn true + } + mockPackageManager.stub { + on { hasSystemFeature(PackageManager.FEATURE_TELEPHONY) } doReturn true + } + + val showMobileNetworkPage = repository.showMobileNetworkPage() + + assertThat(showMobileNetworkPage).isTrue() + } + + @Test + fun showMobileNetworkPage_notAdminUser_returnFalse() { + mockUserManager.stub { + on { isAdminUser } doReturn false + } + mockPackageManager.stub { + on { hasSystemFeature(PackageManager.FEATURE_TELEPHONY) } doReturn true + } + + val showMobileNetworkPage = repository.showMobileNetworkPage() + + assertThat(showMobileNetworkPage).isFalse() + } + + @Test fun showMobileNetworkPage_noTelephony_returnFalse() { + mockUserManager.stub { + on { isAdminUser } doReturn true + } + mockPackageManager.stub { + on { hasSystemFeature(PackageManager.FEATURE_TELEPHONY) } doReturn false + } + + val showMobileNetworkPage = repository.showMobileNetworkPage() + + assertThat(showMobileNetworkPage).isFalse() + } +} diff --git a/tests/unit/src/com/android/settings/network/MobileNetworkPreferenceControllerTest.java b/tests/unit/src/com/android/settings/network/MobileNetworkPreferenceControllerTest.java deleted file mode 100644 index 1231c01b749..00000000000 --- a/tests/unit/src/com/android/settings/network/MobileNetworkPreferenceControllerTest.java +++ /dev/null @@ -1,187 +0,0 @@ -/* - * Copyright (C) 2020 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.network; - -import static androidx.lifecycle.Lifecycle.Event; - -import static com.google.common.truth.Truth.assertThat; - -import static org.mockito.Mockito.doReturn; -import static org.mockito.Mockito.spy; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import android.content.Context; -import android.os.Looper; -import android.os.UserManager; -import android.provider.Settings; -import android.provider.Settings.Global; -import android.telephony.PhoneStateListener; -import android.telephony.SubscriptionManager; -import android.telephony.TelephonyManager; - -import androidx.lifecycle.LifecycleOwner; -import androidx.lifecycle.LifecycleRegistry; -import androidx.preference.Preference; -import androidx.preference.PreferenceManager; -import androidx.preference.PreferenceScreen; -import androidx.test.annotation.UiThreadTest; -import androidx.test.core.app.ApplicationProvider; -import androidx.test.ext.junit.runners.AndroidJUnit4; - -import com.android.settingslib.RestrictedLockUtils.EnforcedAdmin; -import com.android.settingslib.RestrictedPreference; - -import org.junit.Assert; -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.mockito.Mock; -import org.mockito.MockitoAnnotations; - -@RunWith(AndroidJUnit4.class) -public class MobileNetworkPreferenceControllerTest { - private Context mContext; - @Mock - private TelephonyManager mTelephonyManager; - @Mock - private SubscriptionManager mSubscriptionManager; - - @Mock - private UserManager mUserManager; - - private PreferenceManager mPreferenceManager; - private PreferenceScreen mScreen; - - @Mock - private LifecycleOwner mLifecycleOwner; - private LifecycleRegistry mLifecycleRegistry; - private MobileNetworkPreferenceController mController; - private Preference mPreference; - - @Before - public void setUp() { - MockitoAnnotations.initMocks(this); - mContext = spy(ApplicationProvider.getApplicationContext()); - when(mContext.getSystemService(Context.TELEPHONY_SERVICE)).thenReturn(mTelephonyManager); - when(mContext.getSystemService(SubscriptionManager.class)).thenReturn(mSubscriptionManager); - when(mSubscriptionManager.createForAllUserProfiles()).thenReturn(mSubscriptionManager); - when(mContext.getSystemService(UserManager.class)).thenReturn(mUserManager); - if (Looper.myLooper() == null) { - Looper.prepare(); - } - mPreferenceManager = new PreferenceManager(mContext); - mScreen = mPreferenceManager.createPreferenceScreen(mContext); - mPreference = new Preference(mContext); - mPreference.setKey(MobileNetworkPreferenceController.KEY_MOBILE_NETWORK_SETTINGS); - - mLifecycleRegistry = new LifecycleRegistry(mLifecycleOwner); - when(mLifecycleOwner.getLifecycle()).thenReturn(mLifecycleRegistry); - } - - @Test - public void secondaryUser_prefIsNotAvailable() { - when(mUserManager.isAdminUser()).thenReturn(false); - when(mTelephonyManager.isDataCapable()).thenReturn(true); - - mController = new MobileNetworkPreferenceController(mContext); - assertThat(mController.isAvailable()).isFalse(); - } - - @Test - public void wifiOnly_prefIsNotAvailable() { - when(mUserManager.isAdminUser()).thenReturn(true); - when(mTelephonyManager.isDataCapable()).thenReturn(false); - - mController = new MobileNetworkPreferenceController(mContext); - assertThat(mController.isAvailable()).isFalse(); - } - - @Test - @UiThreadTest - public void goThroughLifecycle_isAvailable_shouldListenToServiceChange() { - mController = spy(new MobileNetworkPreferenceController(mContext)); - mLifecycleRegistry.addObserver(mController); - doReturn(true).when(mController).isAvailable(); - - mLifecycleRegistry.handleLifecycleEvent(Event.ON_START); - verify(mController).onStart(); - verify(mTelephonyManager).registerTelephonyCallback( - mContext.getMainExecutor(), mController.mTelephonyCallback); - - mLifecycleRegistry.handleLifecycleEvent(Event.ON_STOP); - verify(mController).onStop(); - verify(mTelephonyManager).unregisterTelephonyCallback(mController.mTelephonyCallback); - } - - @Test - @UiThreadTest - public void serviceStateChange_shouldUpdatePrefSummary() { - final String testCarrierName = "test"; - - mController = spy(new MobileNetworkPreferenceController(mContext)); - mLifecycleRegistry.addObserver(mController); - doReturn(true).when(mController).isAvailable(); - - mScreen.addPreference(mPreference); - - // Display pref and go through lifecycle to set up listener. - mController.displayPreference(mScreen); - mLifecycleRegistry.handleLifecycleEvent(Event.ON_START); - verify(mController).onStart(); - verify(mTelephonyManager).registerTelephonyCallback( - mContext.getMainExecutor(), mController.mTelephonyCallback); - - doReturn(testCarrierName).when(mController).getSummary(); - - mController.mTelephonyCallback.onServiceStateChanged(null); - - // Carrier name should be set. - Assert.assertEquals(mPreference.getSummary(), testCarrierName); - } - - @Test - public void airplaneModeTurnedOn_shouldDisablePreference() { - Settings.Global.putInt(mContext.getContentResolver(), - Global.AIRPLANE_MODE_ON, 1); - mController = spy(new MobileNetworkPreferenceController(mContext)); - final RestrictedPreference mPreference = new RestrictedPreference(mContext); - mController.updateState(mPreference); - assertThat(mPreference.isEnabled()).isFalse(); - } - - @Test - public void airplaneModeTurnedOffAndNoUserRestriction_shouldEnablePreference() { - Settings.Global.putInt(mContext.getContentResolver(), - Global.AIRPLANE_MODE_ON, 0); - mController = spy(new MobileNetworkPreferenceController(mContext)); - final RestrictedPreference mPreference = new RestrictedPreference(mContext); - mPreference.setDisabledByAdmin(null); - mController.updateState(mPreference); - assertThat(mPreference.isEnabled()).isTrue(); - } - - @Test - public void airplaneModeTurnedOffAndHasUserRestriction_shouldDisablePreference() { - Settings.Global.putInt(mContext.getContentResolver(), - Global.AIRPLANE_MODE_ON, 0); - mController = spy(new MobileNetworkPreferenceController(mContext)); - final RestrictedPreference mPreference = new RestrictedPreference(mContext); - mPreference.setDisabledByAdmin(EnforcedAdmin.MULTIPLE_ENFORCED_ADMIN); - mController.updateState(mPreference); - assertThat(mPreference.isEnabled()).isFalse(); - } -} From 3f1980520915533443c20ccdb61cf6538af39ca7 Mon Sep 17 00:00:00 2001 From: Chun-Ku Lin Date: Wed, 11 Sep 2024 19:05:37 +0000 Subject: [PATCH 07/11] Provide installed a11y services/activities from dynamicRawData for search Bug: 354076686 Flag: com.android.settings.accessibility.fix_a11y_settings_search Test: Search Project Relate and verify the item shows up in the search result Test: Search Talkback with keywords, verify the Talkback shows up in the search result Test: atest AccessibilitySettingsTest Change-Id: I258ecb0928308b7cde30c12104408e11cc25ecd5 --- .../AccessibilityActivityPreference.java | 6 ++ .../AccessibilitySearchFeatureProvider.java | 20 ++++++- ...ccessibilitySearchFeatureProviderImpl.java | 12 ++++ .../AccessibilityServicePreference.java | 6 ++ .../accessibility/AccessibilitySettings.java | 47 +++++++++++++++- .../AccessibilitySettingsTest.java | 56 +++++++++++++++++-- 6 files changed, 140 insertions(+), 7 deletions(-) diff --git a/src/com/android/settings/accessibility/AccessibilityActivityPreference.java b/src/com/android/settings/accessibility/AccessibilityActivityPreference.java index 914d9cf3f84..a8e456d3e35 100644 --- a/src/com/android/settings/accessibility/AccessibilityActivityPreference.java +++ b/src/com/android/settings/accessibility/AccessibilityActivityPreference.java @@ -26,6 +26,7 @@ import android.graphics.drawable.Drawable; import android.os.Bundle; import android.util.Log; +import androidx.annotation.NonNull; import androidx.core.content.ContextCompat; import com.android.settings.R; @@ -101,6 +102,11 @@ public class AccessibilityActivityPreference extends RestrictedPreference { return mLabel; } + @NonNull + public ComponentName getComponentName() { + return mComponentName; + } + private Drawable getA11yActivityIcon() { ActivityInfo activityInfo = mA11yShortcutInfo.getActivityInfo(); Drawable serviceIcon; diff --git a/src/com/android/settings/accessibility/AccessibilitySearchFeatureProvider.java b/src/com/android/settings/accessibility/AccessibilitySearchFeatureProvider.java index 6aa8c841ed1..6a0b5e216b9 100644 --- a/src/com/android/settings/accessibility/AccessibilitySearchFeatureProvider.java +++ b/src/com/android/settings/accessibility/AccessibilitySearchFeatureProvider.java @@ -16,8 +16,12 @@ package com.android.settings.accessibility; +import android.content.ComponentName; import android.content.Context; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + import com.android.settingslib.search.SearchIndexableRaw; import java.util.List; @@ -28,10 +32,22 @@ import java.util.List; public interface AccessibilitySearchFeatureProvider { /** - * Returns a list of raw data for indexing. See {@link SearchIndexableRaw} + * Returns accessibility features to be searched where the accessibility features are always on + * the device and their feature names won't change. * * @param context a valid context {@link Context} instance - * @return a list of {@link SearchIndexableRaw} references. Can be null. + * @return a list of {@link SearchIndexableRaw} references */ + @Nullable List getSearchIndexableRawData(Context context); + + /** + * Returns synonyms of the Accessibility component that is used for search. + * + * @param context the context that is used for grabbing resources + * @param componentName the ComponentName of the accessibility feature + * @return a comma separated synonyms e.g. "wifi, wi-fi, network connection" + */ + @NonNull + String getSynonymsForComponent(@NonNull Context context, @NonNull ComponentName componentName); } diff --git a/src/com/android/settings/accessibility/AccessibilitySearchFeatureProviderImpl.java b/src/com/android/settings/accessibility/AccessibilitySearchFeatureProviderImpl.java index c358af11d06..94594a1a292 100644 --- a/src/com/android/settings/accessibility/AccessibilitySearchFeatureProviderImpl.java +++ b/src/com/android/settings/accessibility/AccessibilitySearchFeatureProviderImpl.java @@ -16,8 +16,12 @@ package com.android.settings.accessibility; +import android.content.ComponentName; import android.content.Context; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + import com.android.settingslib.search.SearchIndexableRaw; import java.util.List; @@ -27,8 +31,16 @@ import java.util.List; */ public class AccessibilitySearchFeatureProviderImpl implements AccessibilitySearchFeatureProvider { + @Nullable @Override public List getSearchIndexableRawData(Context context) { return null; } + + @NonNull + @Override + public String getSynonymsForComponent(@NonNull Context context, + @NonNull ComponentName componentName) { + return ""; + } } diff --git a/src/com/android/settings/accessibility/AccessibilityServicePreference.java b/src/com/android/settings/accessibility/AccessibilityServicePreference.java index c1dfae80fb7..8a22d820af9 100644 --- a/src/com/android/settings/accessibility/AccessibilityServicePreference.java +++ b/src/com/android/settings/accessibility/AccessibilityServicePreference.java @@ -26,6 +26,7 @@ import android.graphics.drawable.Drawable; import android.os.Bundle; import android.util.Log; +import androidx.annotation.NonNull; import androidx.core.content.ContextCompat; import com.android.settings.R; @@ -95,6 +96,11 @@ public class AccessibilityServicePreference extends RestrictedPreference { super.performClick(); } + @NonNull + public ComponentName getComponentName() { + return mComponentName; + } + private Drawable getA11yServiceIcon() { ResolveInfo resolveInfo = mA11yServiceInfo.getResolveInfo(); Drawable serviceIcon; diff --git a/src/com/android/settings/accessibility/AccessibilitySettings.java b/src/com/android/settings/accessibility/AccessibilitySettings.java index 8de49365060..db8f9379afe 100644 --- a/src/com/android/settings/accessibility/AccessibilitySettings.java +++ b/src/com/android/settings/accessibility/AccessibilitySettings.java @@ -473,7 +473,7 @@ public class AccessibilitySettings extends DashboardFragment implements * @param installedShortcutList A list of installed {@link AccessibilityShortcutInfo}s. * @param installedServiceList A list of installed {@link AccessibilityServiceInfo}s. */ - private List getInstalledAccessibilityPreferences(Context context, + private static List getInstalledAccessibilityPreferences(Context context, List installedShortcutList, List installedServiceList) { final RestrictedPreferenceHelper preferenceHelper = new RestrictedPreferenceHelper(context); @@ -623,6 +623,51 @@ public class AccessibilitySettings extends DashboardFragment implements .getAccessibilitySearchFeatureProvider().getSearchIndexableRawData( context); } + + @Override + public List getDynamicRawDataToIndex(Context context, + boolean enabled) { + List dynamicRawData = super.getDynamicRawDataToIndex( + context, enabled); + if (dynamicRawData == null) { + dynamicRawData = new ArrayList<>(); + } + if (!Flags.fixA11ySettingsSearch()) { + return dynamicRawData; + } + + AccessibilityManager a11yManager = context.getSystemService( + AccessibilityManager.class); + AccessibilitySearchFeatureProvider a11ySearchFeatureProvider = + FeatureFactory.getFeatureFactory() + .getAccessibilitySearchFeatureProvider(); + List installedA11yFeaturesPref = + AccessibilitySettings.getInstalledAccessibilityPreferences( + context, + a11yManager.getInstalledAccessibilityShortcutListAsUser( + context, UserHandle.myUserId()), + a11yManager.getInstalledAccessibilityServiceList() + ); + for (RestrictedPreference pref : installedA11yFeaturesPref) { + SearchIndexableRaw indexableRaw = new SearchIndexableRaw(context); + indexableRaw.key = pref.getKey(); + indexableRaw.title = pref.getTitle().toString(); + @NonNull String synonyms = ""; + if (pref instanceof AccessibilityServicePreference) { + synonyms = a11ySearchFeatureProvider.getSynonymsForComponent( + context, + ((AccessibilityServicePreference) pref).getComponentName()); + } else if (pref instanceof AccessibilityActivityPreference) { + synonyms = a11ySearchFeatureProvider.getSynonymsForComponent( + context, + ((AccessibilityActivityPreference) pref).getComponentName()); + } + indexableRaw.keywords = synonyms; + dynamicRawData.add(indexableRaw); + } + + return dynamicRawData; + } }; @Override diff --git a/tests/robotests/src/com/android/settings/accessibility/AccessibilitySettingsTest.java b/tests/robotests/src/com/android/settings/accessibility/AccessibilitySettingsTest.java index 3982dc0c68c..36578a90e25 100644 --- a/tests/robotests/src/com/android/settings/accessibility/AccessibilitySettingsTest.java +++ b/tests/robotests/src/com/android/settings/accessibility/AccessibilitySettingsTest.java @@ -42,7 +42,6 @@ import android.platform.test.annotations.EnableFlags; import android.platform.test.flag.junit.SetFlagsRule; import android.provider.Settings; import android.view.accessibility.AccessibilityManager; -import android.view.accessibility.Flags; import androidx.fragment.app.Fragment; import androidx.test.core.app.ApplicationProvider; @@ -50,6 +49,7 @@ import androidx.test.core.app.ApplicationProvider; import com.android.internal.accessibility.util.AccessibilityUtils; import com.android.settings.R; import com.android.settings.SettingsActivity; +import com.android.settings.testutils.FakeFeatureFactory; import com.android.settings.testutils.XmlTestUtils; import com.android.settings.testutils.shadow.ShadowAccessibilityManager; import com.android.settings.testutils.shadow.ShadowApplicationPackageManager; @@ -78,6 +78,7 @@ import org.robolectric.android.controller.ActivityController; import org.robolectric.annotation.Config; import org.robolectric.shadow.api.Shadow; import org.robolectric.shadows.ShadowContentResolver; +import org.robolectric.shadows.ShadowLooper; import org.xmlpull.v1.XmlPullParserException; import java.io.IOException; @@ -155,6 +156,53 @@ public class AccessibilitySettingsTest { assertThat(indexableRawList).isNull(); } + @DisableFlags(Flags.FLAG_FIX_A11Y_SETTINGS_SEARCH) + @Test + public void getDynamicRawDataToIndex_hasInstalledA11yFeatures_flagOff_returnEmpty() { + mShadowAccessibilityManager.setInstalledAccessibilityServiceList( + List.of(mServiceInfo)); + mShadowAccessibilityManager.setInstalledAccessibilityShortcutListAsUser( + List.of(getMockAccessibilityShortcutInfo())); + + assertThat(AccessibilitySettings.SEARCH_INDEX_DATA_PROVIDER.getDynamicRawDataToIndex( + mContext, /* enabled= */ true)) + .isEmpty(); + } + + @EnableFlags(Flags.FLAG_FIX_A11Y_SETTINGS_SEARCH) + @Test + public void getDynamicRawDataToIndex_hasInstalledA11yFeatures_flagOn_returnRawDataForInstalledA11yFeatures() { + mShadowAccessibilityManager.setInstalledAccessibilityServiceList( + List.of(mServiceInfo)); + mShadowAccessibilityManager.setInstalledAccessibilityShortcutListAsUser( + List.of(getMockAccessibilityShortcutInfo())); + final AccessibilitySearchFeatureProvider featureProvider = + FakeFeatureFactory.setupForTest().getAccessibilitySearchFeatureProvider(); + final String synonyms = "fake keyword1, fake keyword2"; + when(featureProvider.getSynonymsForComponent(mContext, ACTIVITY_COMPONENT_NAME)) + .thenReturn(""); + when(featureProvider.getSynonymsForComponent(mContext, SERVICE_COMPONENT_NAME)) + .thenReturn(synonyms); + + final List indexableRawDataList = + AccessibilitySettings.SEARCH_INDEX_DATA_PROVIDER.getDynamicRawDataToIndex( + mContext, /* enabled= */ true); + ShadowLooper.runUiThreadTasksIncludingDelayedTasks(); + + assertThat(indexableRawDataList).hasSize(2); + SearchIndexableRaw a11yActivityIndexableData = indexableRawDataList.get(0); + assertThat(a11yActivityIndexableData.key).isEqualTo( + ACTIVITY_COMPONENT_NAME.flattenToString()); + assertThat(a11yActivityIndexableData.title).isEqualTo(DEFAULT_LABEL); + assertThat(a11yActivityIndexableData.keywords).isEmpty(); + + SearchIndexableRaw a11yServiceIndexableData = indexableRawDataList.get(1); + assertThat(a11yServiceIndexableData.key).isEqualTo( + SERVICE_COMPONENT_NAME.flattenToString()); + assertThat(a11yServiceIndexableData.title).isEqualTo(DEFAULT_LABEL); + assertThat(a11yServiceIndexableData.keywords).isEqualTo(synonyms); + } + @Test public void getServiceSummary_serviceCrash_showsStopped() { mServiceInfo.crashed = true; @@ -328,7 +376,7 @@ public class AccessibilitySettingsTest { } @Test - @DisableFlags(Flags.FLAG_A11Y_QS_SHORTCUT) + @DisableFlags(android.view.accessibility.Flags.FLAG_A11Y_QS_SHORTCUT) public void onCreate_flagDisabled_haveRegisterToSpecificUrisAndActions() { setupFragment(); @@ -341,7 +389,7 @@ public class AccessibilitySettingsTest { } @Test - @EnableFlags(Flags.FLAG_A11Y_QS_SHORTCUT) + @EnableFlags(android.view.accessibility.Flags.FLAG_A11Y_QS_SHORTCUT) public void onCreate_flagEnabled_haveRegisterToSpecificUrisAndActions() { setupFragment(); @@ -415,7 +463,7 @@ public class AccessibilitySettingsTest { } @Test - @EnableFlags(com.android.settings.accessibility.Flags.FLAG_CHECK_PREBUNDLED_IS_PREINSTALLED) + @EnableFlags(Flags.FLAG_CHECK_PREBUNDLED_IS_PREINSTALLED) public void testNonPreinstalledApp_IncludedInDownloadedCategory() { mShadowAccessibilityManager.setInstalledAccessibilityServiceList( List.of(getMockAccessibilityServiceInfo( From 1b98e508094ef3612f9757b60198de450797db55 Mon Sep 17 00:00:00 2001 From: Daniel Norman Date: Thu, 12 Sep 2024 20:48:09 +0000 Subject: [PATCH 08/11] Uses placeholder and percentage formatter for seek bar state strings. Placeholders and percentage formatter are best practice to help prevent accidental translation errors, especially when mixing formatted strings with literal percent signs. Fix: 366201919 Flag: EXEMPT minor string format fix with no functionality change Test: Use TalkBack to observe the state description of the seekbar; observe description is unchanged (e.g. "60% left, 40% right") Test: atest BalanceSeekBarTest Change-Id: Ie9dcc9219d253795be31b39279ed9d01d8794f66 --- res/values/strings.xml | 4 ++-- .../settings/accessibility/BalanceSeekBar.java | 7 +++++-- .../settings/accessibility/BalanceSeekBarTest.java | 13 +++++++++---- 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/res/values/strings.xml b/res/values/strings.xml index c63e5f4fcf2..cf203bbc760 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -13342,9 +13342,9 @@ On - Audio %1$d%% left, %2$d%% right + Audio %1$s left, %2$s right - Audio %1$d%% right, %2$d%% left + Audio %1$s right, %2$s left Your device name is visible to apps you installed. It may also be seen by other people when you connect to Bluetooth devices, connect to a Wi-Fi network or set up a Wi-Fi hotspot. diff --git a/src/com/android/settings/accessibility/BalanceSeekBar.java b/src/com/android/settings/accessibility/BalanceSeekBar.java index 7441d6fe9e2..8f8f767cebf 100644 --- a/src/com/android/settings/accessibility/BalanceSeekBar.java +++ b/src/com/android/settings/accessibility/BalanceSeekBar.java @@ -36,6 +36,7 @@ import android.widget.SeekBar; import androidx.annotation.VisibleForTesting; import com.android.settings.R; +import com.android.settings.Utils; /** * A custom seekbar for the balance setting. @@ -178,10 +179,12 @@ public class BalanceSeekBar extends SeekBar { == LAYOUT_DIRECTION_RTL; final int rightPercent = (int) (100 * (progress / max)); final int leftPercent = 100 - rightPercent; + final String rightPercentString = Utils.formatPercentage(rightPercent); + final String leftPercentString = Utils.formatPercentage(leftPercent); if (rightPercent > leftPercent || (rightPercent == leftPercent && isLayoutRtl)) { - return context.getString(resIdRightFirst, rightPercent, leftPercent); + return context.getString(resIdRightFirst, rightPercentString, leftPercentString); } else { - return context.getString(resIdLeftFirst, leftPercent, rightPercent); + return context.getString(resIdLeftFirst, leftPercentString, rightPercentString); } } } diff --git a/tests/robotests/src/com/android/settings/accessibility/BalanceSeekBarTest.java b/tests/robotests/src/com/android/settings/accessibility/BalanceSeekBarTest.java index d74794f0363..bbe511d1b98 100644 --- a/tests/robotests/src/com/android/settings/accessibility/BalanceSeekBarTest.java +++ b/tests/robotests/src/com/android/settings/accessibility/BalanceSeekBarTest.java @@ -34,6 +34,7 @@ import android.util.AttributeSet; import android.widget.SeekBar; import com.android.settings.R; +import com.android.settings.Utils; import com.android.settings.testutils.shadow.ShadowSystemSettings; import org.junit.Before; @@ -162,7 +163,8 @@ public class BalanceSeekBarTest { mProxySeekBarListener.onProgressChanged(mSeekBar, progress, true); assertThat(mSeekBar.getStateDescription()).isEqualTo( - mContext.getString(R.string.audio_seek_bar_state_left_first, 50, 50)); + mContext.getString(R.string.audio_seek_bar_state_left_first, + Utils.formatPercentage(50), Utils.formatPercentage(50))); } @Test @@ -177,7 +179,8 @@ public class BalanceSeekBarTest { mProxySeekBarListener.onProgressChanged(mSeekBar, progress, true); assertThat(mSeekBar.getStateDescription()).isEqualTo( - mContext.getString(R.string.audio_seek_bar_state_right_first, 50, 50)); + mContext.getString(R.string.audio_seek_bar_state_right_first, + Utils.formatPercentage(50), Utils.formatPercentage(50))); } @Test @@ -189,7 +192,8 @@ public class BalanceSeekBarTest { mProxySeekBarListener.onProgressChanged(mSeekBar, progress, true); assertThat(mSeekBar.getStateDescription()).isEqualTo( - mContext.getString(R.string.audio_seek_bar_state_left_first, 75, 25)); + mContext.getString(R.string.audio_seek_bar_state_left_first, + Utils.formatPercentage(75), Utils.formatPercentage(25))); } @Test @@ -201,7 +205,8 @@ public class BalanceSeekBarTest { mProxySeekBarListener.onProgressChanged(mSeekBar, progress, true); assertThat(mSeekBar.getStateDescription()).isEqualTo( - mContext.getString(R.string.audio_seek_bar_state_right_first, 75, 25)); + mContext.getString(R.string.audio_seek_bar_state_right_first, + Utils.formatPercentage(75), Utils.formatPercentage(25))); } // method to get the center from BalanceSeekBar for testing setMax(). From 8bbecf4612168ba2e9513c6e62987ec777eb226d Mon Sep 17 00:00:00 2001 From: Chun-Ku Lin Date: Thu, 12 Sep 2024 20:13:31 +0000 Subject: [PATCH 09/11] Load icon from correct package for Accessibility slices Bug: 326233533 Flag: EXEMPT low risk Test: Mannual. Search TalkBack in Settings app, the Icon shows up correctly. Search "Turn on TalkBack" in assistant app, the Icon is not affected. Change-Id: I6c15a13b4e7dd56f873124ae5722f15f2447f5b4 --- .../android/settings/slices/SliceBuilderUtils.java | 14 +++++++++++++- .../settings/slices/SliceDataConverter.java | 7 +++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/com/android/settings/slices/SliceBuilderUtils.java b/src/com/android/settings/slices/SliceBuilderUtils.java index c9d5f23f8d4..f99267ef241 100644 --- a/src/com/android/settings/slices/SliceBuilderUtils.java +++ b/src/com/android/settings/slices/SliceBuilderUtils.java @@ -24,6 +24,7 @@ import static com.android.settings.slices.SettingsSliceProvider.EXTRA_SLICE_KEY; import android.annotation.ColorInt; import android.app.PendingIntent; +import android.content.ComponentName; import android.content.Context; import android.content.Intent; import android.net.Uri; @@ -47,6 +48,7 @@ import com.android.settings.R; import com.android.settings.SettingsActivity; import com.android.settings.SubSettings; import com.android.settings.Utils; +import com.android.settings.accessibility.AccessibilitySlicePreferenceController; import com.android.settings.core.BasePreferenceController; import com.android.settings.core.SliderPreferenceController; import com.android.settings.core.SubSettingLauncher; @@ -448,7 +450,17 @@ public class SliceBuilderUtils { iconResource = R.drawable.ic_settings_accent; } try { - return IconCompat.createWithResource(context, iconResource); + // LINT.IfChange(createA11yIcon) + if (AccessibilitySlicePreferenceController.class.getName().equals( + data.getPreferenceController())) { + ComponentName serviceComponent = ComponentName.unflattenFromString(data.getKey()); + return IconCompat.createWithResource( + context.createPackageContext(serviceComponent.getPackageName(), 0), + iconResource); + // LINT.ThenChange() + } else { + return IconCompat.createWithResource(context, iconResource); + } } catch (Exception e) { Log.w(TAG, "Falling back to settings icon because there is an error getting slice icon " + data.getUri(), e); diff --git a/src/com/android/settings/slices/SliceDataConverter.java b/src/com/android/settings/slices/SliceDataConverter.java index f6828af3df0..983edc0a588 100644 --- a/src/com/android/settings/slices/SliceDataConverter.java +++ b/src/com/android/settings/slices/SliceDataConverter.java @@ -274,6 +274,12 @@ class SliceDataConverter { final ServiceInfo serviceInfo = resolveInfo.serviceInfo; final String packageName = serviceInfo.packageName; final ComponentName componentName = new ComponentName(packageName, serviceInfo.name); + + // If we change the flattenedName that is used to be set as a key of the Slice, we + // need to make corresponding change in SliceBuilderUtils, since we rely on the + // the A11y Service Slice's key to be a ComponentName to get the correct package name + // to grab the icon belongs to that package. + // LINT.IfChange final String flattenedName = componentName.flattenToString(); if (!a11yServiceNames.contains(flattenedName)) { @@ -287,6 +293,7 @@ class SliceDataConverter { } sliceDataBuilder.setKey(flattenedName) + // LINT.ThenChange(SliceBuilderUtils.java:createA11yIcon) .setTitle(title) .setUri(new Uri.Builder() .scheme(ContentResolver.SCHEME_CONTENT) From f6a573530eb3f5ea4325efed3b98d2bdeb6f23f2 Mon Sep 17 00:00:00 2001 From: Jacky Wang Date: Wed, 11 Sep 2024 22:50:36 +0800 Subject: [PATCH 10/11] Move ObservablePreferenceFragment class Bug: 365922551 Flag: EXEMPT Move class only Test: Presubmit Change-Id: I17851055b09d73b95e6adaafbe96f4375d5f637e --- Android.bp | 4 +- .../core/InstrumentedPreferenceFragment.java | 1 - .../core/ObservablePreferenceFragment.java | 137 ++++++++++++++++++ .../OwnerInfoPreferenceController.java | 2 +- .../OwnerInfoPreferenceControllerTest.java | 2 +- 5 files changed, 142 insertions(+), 4 deletions(-) create mode 100644 src/com/android/settings/core/ObservablePreferenceFragment.java diff --git a/Android.bp b/Android.bp index 8b903ba91be..0a58ee8ea7c 100644 --- a/Android.bp +++ b/Android.bp @@ -94,8 +94,10 @@ android_library { "MediaDrmSettingsFlagsLib", "Settings-change-ids", "SettingsLib", - "SettingsLibDataStore", "SettingsLibActivityEmbedding", + "SettingsLibDataStore", + "SettingsLibMetadata", + "SettingsLibPreference", "aconfig_settings_flags_lib", "accessibility_settings_flags_lib", "contextualcards", diff --git a/src/com/android/settings/core/InstrumentedPreferenceFragment.java b/src/com/android/settings/core/InstrumentedPreferenceFragment.java index 4d871d4c3ff..9b03e9b16e2 100644 --- a/src/com/android/settings/core/InstrumentedPreferenceFragment.java +++ b/src/com/android/settings/core/InstrumentedPreferenceFragment.java @@ -37,7 +37,6 @@ import com.android.settingslib.core.instrumentation.Instrumentable; import com.android.settingslib.core.instrumentation.MetricsFeatureProvider; import com.android.settingslib.core.instrumentation.SettingsJankMonitor; import com.android.settingslib.core.instrumentation.VisibilityLoggerMixin; -import com.android.settingslib.core.lifecycle.ObservablePreferenceFragment; /** * Instrumented fragment that logs visibility state. diff --git a/src/com/android/settings/core/ObservablePreferenceFragment.java b/src/com/android/settings/core/ObservablePreferenceFragment.java new file mode 100644 index 00000000000..997317dbc1a --- /dev/null +++ b/src/com/android/settings/core/ObservablePreferenceFragment.java @@ -0,0 +1,137 @@ +/* + * 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.core; + + +import static androidx.lifecycle.Lifecycle.Event.ON_CREATE; +import static androidx.lifecycle.Lifecycle.Event.ON_DESTROY; +import static androidx.lifecycle.Lifecycle.Event.ON_PAUSE; +import static androidx.lifecycle.Lifecycle.Event.ON_RESUME; +import static androidx.lifecycle.Lifecycle.Event.ON_START; +import static androidx.lifecycle.Lifecycle.Event.ON_STOP; + +import android.annotation.CallSuper; +import android.content.Context; +import android.os.Bundle; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; + +import androidx.lifecycle.LifecycleOwner; +import androidx.preference.PreferenceScreen; + +import com.android.settingslib.core.lifecycle.Lifecycle; +import com.android.settingslib.preference.PreferenceFragment; + +/** + * Preference fragment that has hooks to observe fragment lifecycle events. + */ +public abstract class ObservablePreferenceFragment extends PreferenceFragment + implements LifecycleOwner { + + private final Lifecycle mLifecycle = new Lifecycle(this); + + public Lifecycle getSettingsLifecycle() { + return mLifecycle; + } + + @CallSuper + @Override + public void onAttach(Context context) { + super.onAttach(context); + mLifecycle.onAttach(context); + } + + @CallSuper + @Override + public void onCreate(Bundle savedInstanceState) { + mLifecycle.onCreate(savedInstanceState); + mLifecycle.handleLifecycleEvent(ON_CREATE); + super.onCreate(savedInstanceState); + } + + @Override + public void setPreferenceScreen(PreferenceScreen preferenceScreen) { + mLifecycle.setPreferenceScreen(preferenceScreen); + super.setPreferenceScreen(preferenceScreen); + } + + @CallSuper + @Override + public void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); + mLifecycle.onSaveInstanceState(outState); + } + + @CallSuper + @Override + public void onStart() { + mLifecycle.handleLifecycleEvent(ON_START); + super.onStart(); + } + + @CallSuper + @Override + public void onResume() { + mLifecycle.handleLifecycleEvent(ON_RESUME); + super.onResume(); + } + + @CallSuper + @Override + public void onPause() { + mLifecycle.handleLifecycleEvent(ON_PAUSE); + super.onPause(); + } + + @CallSuper + @Override + public void onStop() { + mLifecycle.handleLifecycleEvent(ON_STOP); + super.onStop(); + } + + @CallSuper + @Override + public void onDestroy() { + mLifecycle.handleLifecycleEvent(ON_DESTROY); + super.onDestroy(); + } + + @CallSuper + @Override + public void onCreateOptionsMenu(final Menu menu, final MenuInflater inflater) { + mLifecycle.onCreateOptionsMenu(menu, inflater); + super.onCreateOptionsMenu(menu, inflater); + } + + @CallSuper + @Override + public void onPrepareOptionsMenu(final Menu menu) { + mLifecycle.onPrepareOptionsMenu(menu); + super.onPrepareOptionsMenu(menu); + } + + @CallSuper + @Override + public boolean onOptionsItemSelected(final MenuItem menuItem) { + boolean lifecycleHandled = mLifecycle.onOptionsItemSelected(menuItem); + if (!lifecycleHandled) { + return super.onOptionsItemSelected(menuItem); + } + return lifecycleHandled; + } +} diff --git a/src/com/android/settings/security/OwnerInfoPreferenceController.java b/src/com/android/settings/security/OwnerInfoPreferenceController.java index 248301671a0..67dbbc14738 100644 --- a/src/com/android/settings/security/OwnerInfoPreferenceController.java +++ b/src/com/android/settings/security/OwnerInfoPreferenceController.java @@ -24,6 +24,7 @@ import androidx.preference.Preference; import androidx.preference.PreferenceScreen; import com.android.internal.widget.LockPatternUtils; +import com.android.settings.core.ObservablePreferenceFragment; import com.android.settings.core.PreferenceControllerMixin; import com.android.settings.users.OwnerInfoSettings; import com.android.settingslib.RestrictedLockUtils.EnforcedAdmin; @@ -31,7 +32,6 @@ import com.android.settingslib.RestrictedLockUtilsInternal; import com.android.settingslib.RestrictedPreference; import com.android.settingslib.core.AbstractPreferenceController; import com.android.settingslib.core.lifecycle.LifecycleObserver; -import com.android.settingslib.core.lifecycle.ObservablePreferenceFragment; import com.android.settingslib.core.lifecycle.events.OnResume; public class OwnerInfoPreferenceController extends AbstractPreferenceController diff --git a/tests/robotests/src/com/android/settings/security/OwnerInfoPreferenceControllerTest.java b/tests/robotests/src/com/android/settings/security/OwnerInfoPreferenceControllerTest.java index 81f4fce1517..0db950be6de 100644 --- a/tests/robotests/src/com/android/settings/security/OwnerInfoPreferenceControllerTest.java +++ b/tests/robotests/src/com/android/settings/security/OwnerInfoPreferenceControllerTest.java @@ -36,11 +36,11 @@ import androidx.preference.PreferenceManager; import androidx.preference.PreferenceScreen; import com.android.internal.widget.LockPatternUtils; +import com.android.settings.core.ObservablePreferenceFragment; import com.android.settings.users.OwnerInfoSettings; import com.android.settingslib.RestrictedLockUtils.EnforcedAdmin; import com.android.settingslib.RestrictedPreference; import com.android.settingslib.core.lifecycle.Lifecycle; -import com.android.settingslib.core.lifecycle.ObservablePreferenceFragment; import org.junit.Before; import org.junit.Test; From 2c3f54c5e3e20b17eea577967400cf3574c06fe2 Mon Sep 17 00:00:00 2001 From: Rongxuan Liu Date: Thu, 29 Aug 2024 20:23:48 +0000 Subject: [PATCH 11/11] [AudioStream] Hysteresis mode support Flag: com.android.settingslib.flags.audio_sharing_hysteresis_mode_fix Test: atest com.android.settings.connecteddevice.audiosharing.audiostreams Test: manual test with broadcast hysteresis mode Bug: 355222285 Bug: 355221818 Change-Id: If3a1fbdc391eeda6979868829bc00c435a43c329 --- res/values/strings.xml | 2 + .../AudioStreamButtonController.java | 26 +++- .../AudioStreamHeaderController.java | 38 ++++- .../audiostreams/AudioStreamStateHandler.java | 10 +- .../audiostreams/AudioStreamsHelper.java | 40 ++++- .../AudioStreamsProgressCategoryCallback.java | 5 + ...udioStreamsProgressCategoryController.java | 107 ++++++++++++-- .../audiostreams/SourcePresentState.java | 87 +++++++++++ .../AudioStreamButtonControllerTest.java | 34 ++++- .../AudioStreamHeaderControllerTest.java | 68 ++++++++- .../AudioStreamStateHandlerTest.java | 28 ++++ .../audiostreams/AudioStreamsHelperTest.java | 56 +++++++ ...ioStreamsProgressCategoryCallbackTest.java | 21 +++ ...StreamsProgressCategoryControllerTest.java | 83 +++++++++++ .../audiostreams/SourcePresentStateTest.java | 137 ++++++++++++++++++ .../testshadows/ShadowAudioStreamsHelper.java | 5 + 16 files changed, 719 insertions(+), 28 deletions(-) create mode 100644 src/com/android/settings/connecteddevice/audiosharing/audiostreams/SourcePresentState.java create mode 100644 tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/SourcePresentStateTest.java diff --git a/res/values/strings.xml b/res/values/strings.xml index c63e5f4fcf2..207fd0bcb3a 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -13553,6 +13553,8 @@ Can\u0027t play this audio stream on %1$s. Listening now + + Paused by host Stop listening diff --git a/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamButtonController.java b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamButtonController.java index 939dd5c2f92..48acf3256d0 100644 --- a/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamButtonController.java +++ b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamButtonController.java @@ -16,6 +16,8 @@ package com.android.settings.connecteddevice.audiosharing.audiostreams; +import static com.android.settingslib.flags.Flags.audioSharingHysteresisModeFix; + import android.app.settings.SettingsEnums; import android.bluetooth.BluetoothDevice; import android.bluetooth.BluetoothLeBroadcastAssistant; @@ -41,6 +43,7 @@ import com.android.settingslib.core.instrumentation.MetricsFeatureProvider; import com.android.settingslib.utils.ThreadUtils; import com.android.settingslib.widget.ActionButtonsPreference; +import java.util.List; import java.util.concurrent.Executor; import java.util.concurrent.Executors; @@ -73,12 +76,18 @@ public class AudioStreamButtonController extends BasePreferenceController int sourceId, BluetoothLeBroadcastReceiveState state) { super.onReceiveStateChanged(sink, sourceId, state); - if (AudioStreamsHelper.isConnected(state)) { + boolean shouldUpdateButton = + audioSharingHysteresisModeFix() + ? AudioStreamsHelper.hasSourcePresent(state) + : AudioStreamsHelper.isConnected(state); + if (shouldUpdateButton) { updateButton(); - mMetricsFeatureProvider.action( - mContext, - SettingsEnums.ACTION_AUDIO_STREAM_JOIN_SUCCEED, - SOURCE_ORIGIN_REPOSITORY); + if (AudioStreamsHelper.isConnected(state)) { + mMetricsFeatureProvider.action( + mContext, + SettingsEnums.ACTION_AUDIO_STREAM_JOIN_SUCCEED, + SOURCE_ORIGIN_REPOSITORY); + } } } @@ -146,8 +155,13 @@ public class AudioStreamButtonController extends BasePreferenceController Log.w(TAG, "updateButton(): preference is null!"); return; } + + List sources = + audioSharingHysteresisModeFix() + ? mAudioStreamsHelper.getAllPresentSources() + : mAudioStreamsHelper.getAllConnectedSources(); boolean isConnected = - mAudioStreamsHelper.getAllConnectedSources().stream() + sources.stream() .map(BluetoothLeBroadcastReceiveState::getBroadcastId) .anyMatch(connectedBroadcastId -> connectedBroadcastId == mBroadcastId); diff --git a/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamHeaderController.java b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamHeaderController.java index e1a178d87e6..0ee93e7742e 100644 --- a/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamHeaderController.java +++ b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamHeaderController.java @@ -16,6 +16,10 @@ package com.android.settings.connecteddevice.audiosharing.audiostreams; +import static com.android.settingslib.flags.Flags.audioSharingHysteresisModeFix; + +import static java.util.stream.Collectors.toList; + import android.bluetooth.BluetoothDevice; import android.bluetooth.BluetoothLeBroadcastAssistant; import android.bluetooth.BluetoothLeBroadcastReceiveState; @@ -48,6 +52,8 @@ public class AudioStreamHeaderController extends BasePreferenceController static final int AUDIO_STREAM_HEADER_LISTENING_NOW_SUMMARY = R.string.audio_streams_listening_now; + static final int AUDIO_STREAM_HEADER_PRESENT_NOW_SUMMARY = R.string.audio_streams_present_now; + @VisibleForTesting static final String AUDIO_STREAM_HEADER_NOT_LISTENING_SUMMARY = ""; private static final String TAG = "AudioStreamHeaderController"; private static final String KEY = "audio_stream_header"; @@ -80,6 +86,10 @@ public class AudioStreamHeaderController extends BasePreferenceController updateSummary(); mAudioStreamsHelper.startMediaService( mContext, mBroadcastId, mBroadcastName); + } else if (audioSharingHysteresisModeFix() + && AudioStreamsHelper.hasSourcePresent(state)) { + // if source present but not connected, only update the summary + updateSummary(); } } }; @@ -140,8 +150,27 @@ public class AudioStreamHeaderController extends BasePreferenceController var unused = ThreadUtils.postOnBackgroundThread( () -> { + var connectedSourceList = + mAudioStreamsHelper.getAllPresentSources().stream() + .filter( + state -> + (state.getBroadcastId() + == mBroadcastId)) + .collect(toList()); + var latestSummary = - mAudioStreamsHelper.getAllConnectedSources().stream() + audioSharingHysteresisModeFix() + ? connectedSourceList.isEmpty() + ? AUDIO_STREAM_HEADER_NOT_LISTENING_SUMMARY + : (connectedSourceList.stream() + .anyMatch( + AudioStreamsHelper + ::isConnected) + ? mContext.getString( + AUDIO_STREAM_HEADER_LISTENING_NOW_SUMMARY) + : mContext.getString( + AUDIO_STREAM_HEADER_PRESENT_NOW_SUMMARY)) + : mAudioStreamsHelper.getAllConnectedSources().stream() .map( BluetoothLeBroadcastReceiveState ::getBroadcastId) @@ -149,9 +178,10 @@ public class AudioStreamHeaderController extends BasePreferenceController connectedBroadcastId -> connectedBroadcastId == mBroadcastId) - ? mContext.getString( - AUDIO_STREAM_HEADER_LISTENING_NOW_SUMMARY) - : AUDIO_STREAM_HEADER_NOT_LISTENING_SUMMARY; + ? mContext.getString( + AUDIO_STREAM_HEADER_LISTENING_NOW_SUMMARY) + : AUDIO_STREAM_HEADER_NOT_LISTENING_SUMMARY; + ThreadUtils.postOnMainThread( () -> { if (mHeaderController != null) { diff --git a/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamStateHandler.java b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamStateHandler.java index 758984fe432..458cfab55ff 100644 --- a/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamStateHandler.java +++ b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamStateHandler.java @@ -18,6 +18,8 @@ package com.android.settings.connecteddevice.audiosharing.audiostreams; import static android.text.Spanned.SPAN_EXCLUSIVE_INCLUSIVE; +import static com.android.settingslib.flags.Flags.audioSharingHysteresisModeFix; + import android.os.Handler; import android.os.Looper; import android.text.SpannableString; @@ -94,8 +96,12 @@ class AudioStreamStateHandler { } preference.setIsConnected( newState - == AudioStreamsProgressCategoryController.AudioStreamState - .SOURCE_ADDED); + == AudioStreamsProgressCategoryController + .AudioStreamState.SOURCE_ADDED + || (audioSharingHysteresisModeFix() + && newState + == AudioStreamsProgressCategoryController + .AudioStreamState.SOURCE_PRESENT)); preference.setOnPreferenceClickListener(getOnClickListener(controller)); }); } diff --git a/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsHelper.java b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsHelper.java index c219e0b6de3..c0d91626d78 100644 --- a/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsHelper.java +++ b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsHelper.java @@ -19,6 +19,7 @@ package com.android.settings.connecteddevice.audiosharing.audiostreams; import static com.android.settings.connecteddevice.audiosharing.audiostreams.AudioStreamMediaService.BROADCAST_ID; import static com.android.settings.connecteddevice.audiosharing.audiostreams.AudioStreamMediaService.BROADCAST_TITLE; import static com.android.settings.connecteddevice.audiosharing.audiostreams.AudioStreamMediaService.DEVICES; +import static com.android.settingslib.flags.Flags.audioSharingHysteresisModeFix; import static java.util.Collections.emptyList; @@ -63,6 +64,12 @@ public class AudioStreamsHelper { private final @Nullable LocalBluetoothManager mBluetoothManager; private final @Nullable LocalBluetoothLeBroadcastAssistant mLeBroadcastAssistant; + // Referring to Broadcast Audio Scan Service 1.0 + // Table 3.9: Broadcast Receive State characteristic format + // 0x00000000: 0b0 = Not synchronized to BIS_index[x] + // 0xFFFFFFFF: Failed to sync to BIG + private static final long BIS_SYNC_NOT_SYNC_TO_BIS = 0x00000000L; + private static final long BIS_SYNC_FAILED_SYNC_TO_BIG = 0xFFFFFFFFL; AudioStreamsHelper(@Nullable LocalBluetoothManager bluetoothManager) { mBluetoothManager = bluetoothManager; @@ -144,6 +151,19 @@ public class AudioStreamsHelper { .toList(); } + /** Retrieves a list of all LE broadcast receive states from sinks with source present. */ + @VisibleForTesting + public List getAllPresentSources() { + if (mLeBroadcastAssistant == null) { + Log.w(TAG, "getAllPresentSources(): LeBroadcastAssistant is null!"); + return emptyList(); + } + return getConnectedBluetoothDevices(mBluetoothManager, /* inSharingOnly= */ true).stream() + .flatMap(sink -> mLeBroadcastAssistant.getAllSources(sink).stream()) + .filter(AudioStreamsHelper::hasSourcePresent) + .toList(); + } + /** Retrieves LocalBluetoothLeBroadcastAssistant. */ @VisibleForTesting @Nullable @@ -153,7 +173,18 @@ public class AudioStreamsHelper { /** Checks the connectivity status based on the provided broadcast receive state. */ public static boolean isConnected(BluetoothLeBroadcastReceiveState state) { - return state.getBisSyncState().stream().anyMatch(bitmap -> bitmap != 0); + return state.getBisSyncState().stream() + .anyMatch( + bitmap -> + (bitmap != BIS_SYNC_NOT_SYNC_TO_BIS + && bitmap != BIS_SYNC_FAILED_SYNC_TO_BIG)); + } + + /** Checks the connectivity status based on the provided broadcast receive state. */ + public static boolean hasSourcePresent(BluetoothLeBroadcastReceiveState state) { + // Referring to Broadcast Audio Scan Service 1.0 + // All zero address means no source on the sink device + return !state.getSourceDevice().getAddress().equals("00:00:00:00:00:00"); } static boolean isBadCode(BluetoothLeBroadcastReceiveState state) { @@ -242,7 +273,8 @@ public class AudioStreamsHelper { List sourceList = assistant.getAllSources(cachedDevice.getDevice()); if (!sourceList.isEmpty() - && sourceList.stream().anyMatch(AudioStreamsHelper::isConnected)) { + && (audioSharingHysteresisModeFix() + || sourceList.stream().anyMatch(AudioStreamsHelper::isConnected))) { Log.d( TAG, "Lead device has connected broadcast source, device = " @@ -253,7 +285,9 @@ public class AudioStreamsHelper { for (CachedBluetoothDevice device : cachedDevice.getMemberDevice()) { List list = assistant.getAllSources(device.getDevice()); - if (!list.isEmpty() && list.stream().anyMatch(AudioStreamsHelper::isConnected)) { + if (!list.isEmpty() + && (audioSharingHysteresisModeFix() + || list.stream().anyMatch(AudioStreamsHelper::isConnected))) { Log.d( TAG, "Member device has connected broadcast source, device = " diff --git a/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsProgressCategoryCallback.java b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsProgressCategoryCallback.java index 3370d8dbfd5..b379d4e7314 100644 --- a/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsProgressCategoryCallback.java +++ b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsProgressCategoryCallback.java @@ -16,6 +16,8 @@ package com.android.settings.connecteddevice.audiosharing.audiostreams; +import static com.android.settingslib.flags.Flags.audioSharingHysteresisModeFix; + import android.bluetooth.BluetoothDevice; import android.bluetooth.BluetoothLeBroadcastMetadata; import android.bluetooth.BluetoothLeBroadcastReceiveState; @@ -39,6 +41,9 @@ public class AudioStreamsProgressCategoryCallback extends AudioStreamsBroadcastA mCategoryController.handleSourceConnected(state); } else if (AudioStreamsHelper.isBadCode(state)) { mCategoryController.handleSourceConnectBadCode(state); + } else if (audioSharingHysteresisModeFix() && AudioStreamsHelper.hasSourcePresent(state)) { + // Keep this check as the last, source might also present in above states + mCategoryController.handleSourcePresent(state); } } diff --git a/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsProgressCategoryController.java b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsProgressCategoryController.java index 9bbf135285c..7ab588260d0 100644 --- a/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsProgressCategoryController.java +++ b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsProgressCategoryController.java @@ -16,6 +16,8 @@ package com.android.settings.connecteddevice.audiosharing.audiostreams; +import static com.android.settingslib.flags.Flags.audioSharingHysteresisModeFix; + import static java.util.Collections.emptyList; import android.app.AlertDialog; @@ -48,6 +50,7 @@ import com.android.settingslib.bluetooth.LocalBluetoothManager; import com.android.settingslib.utils.ThreadUtils; import java.util.Comparator; +import java.util.List; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.Executor; import java.util.concurrent.Executors; @@ -95,9 +98,14 @@ public class AudioStreamsProgressCategoryController extends BasePreferenceContro private final Comparator mComparator = Comparator.comparing( p -> - p.getAudioStreamState() - == AudioStreamsProgressCategoryController - .AudioStreamState.SOURCE_ADDED) + (p.getAudioStreamState() + == AudioStreamsProgressCategoryController + .AudioStreamState.SOURCE_ADDED + || (audioSharingHysteresisModeFix() + && p.getAudioStreamState() + == AudioStreamsProgressCategoryController + .AudioStreamState + .SOURCE_PRESENT))) .thenComparingInt(AudioStreamPreference::getAudioStreamRssi) .reversed(); @@ -113,6 +121,8 @@ public class AudioStreamsProgressCategoryController extends BasePreferenceContro ADD_SOURCE_BAD_CODE, // When addSource result in other bad state. ADD_SOURCE_FAILED, + // Source is present on sink. + SOURCE_PRESENT, // Source is added to active sink. SOURCE_ADDED, } @@ -243,10 +253,13 @@ public class AudioStreamsProgressCategoryController extends BasePreferenceContro existingPreference, AudioStreamState.ADD_SOURCE_WAIT_FOR_RESPONSE); } else { // A preference with source founded existed either because it's already - // connected (SOURCE_ADDED). Any other reason is unexpected. We update the - // preference with this source and won't change it's state. + // connected (SOURCE_ADDED) or present (SOURCE_PRESENT). Any other reason + // is unexpected. We update the preference with this source and won't + // change it's state. existingPreference.setAudioStreamMetadata(source); - if (fromState != AudioStreamState.SOURCE_ADDED) { + if (fromState != AudioStreamState.SOURCE_ADDED + && (!audioSharingHysteresisModeFix() + || fromState != AudioStreamState.SOURCE_PRESENT)) { Log.w( TAG, "handleSourceFound(): unexpected state : " @@ -346,10 +359,14 @@ public class AudioStreamsProgressCategoryController extends BasePreferenceContro for (var entry : mBroadcastIdToPreferenceMap.entrySet()) { var preference = entry.getValue(); - // Look for preference has SOURCE_ADDED state, re-check if they are still connected. If + // Look for preference has SOURCE_ADDED or SOURCE_PRESENT state, re-check if they are + // still connected. If // not, means the source is removed from the sink, we move back the preference to SYNCED // state. - if (preference.getAudioStreamState() == AudioStreamState.SOURCE_ADDED + if ((preference.getAudioStreamState() == AudioStreamState.SOURCE_ADDED + || (audioSharingHysteresisModeFix() + && preference.getAudioStreamState() + == AudioStreamState.SOURCE_PRESENT)) && mAudioStreamsHelper.getAllConnectedSources().stream() .noneMatch( connected -> @@ -383,6 +400,7 @@ public class AudioStreamsProgressCategoryController extends BasePreferenceContro if (!AudioStreamsHelper.isConnected(receiveState)) { return; } + var broadcastIdConnected = receiveState.getBroadcastId(); if (mSourceFromQrCode != null && mSourceFromQrCode.getBroadcastId() == UNSET_BROADCAST_ID) { // mSourceFromQrCode could have no broadcast Id, we fill in the broadcast Id from the @@ -455,6 +473,58 @@ public class AudioStreamsProgressCategoryController extends BasePreferenceContro }); } + // Find preference by receiveState and decide next state. + // Expect one preference existed, move to SOURCE_PRESENT + void handleSourcePresent(BluetoothLeBroadcastReceiveState receiveState) { + if (DEBUG) { + Log.d(TAG, "handleSourcePresent()"); + } + if (!AudioStreamsHelper.hasSourcePresent(receiveState)) { + return; + } + + var broadcastIdConnected = receiveState.getBroadcastId(); + if (mSourceFromQrCode != null && mSourceFromQrCode.getBroadcastId() == UNSET_BROADCAST_ID) { + // mSourceFromQrCode could have no broadcast Id, we fill in the broadcast Id from the + // connected source receiveState. + if (DEBUG) { + Log.d( + TAG, + "handleSourcePresent() : processing mSourceFromQrCode with broadcastId" + + " unset"); + } + boolean updated = + maybeUpdateId( + AudioStreamsHelper.getBroadcastName(receiveState), + receiveState.getBroadcastId()); + if (updated && mBroadcastIdToPreferenceMap.containsKey(UNSET_BROADCAST_ID)) { + var preference = mBroadcastIdToPreferenceMap.remove(UNSET_BROADCAST_ID); + mBroadcastIdToPreferenceMap.put(receiveState.getBroadcastId(), preference); + } + } + + mBroadcastIdToPreferenceMap.compute( + broadcastIdConnected, + (k, existingPreference) -> { + if (existingPreference == null) { + // No existing preference for this source even if it's already connected, + // add one and set initial state to SOURCE_PRESENT. This could happen + // because + // we retrieves the connected source during onStart() from + // AudioStreamsHelper#getAllPresentSources() even before the source is + // founded by scanning. + return addNewPreference(receiveState, AudioStreamState.SOURCE_PRESENT); + } + if (existingPreference.getAudioStreamState() == AudioStreamState.WAIT_FOR_SYNC + && existingPreference.getAudioStreamBroadcastId() == UNSET_BROADCAST_ID + && mSourceFromQrCode != null) { + existingPreference.setAudioStreamMetadata(mSourceFromQrCode); + } + moveToState(existingPreference, AudioStreamState.SOURCE_PRESENT); + return existingPreference; + }); + } + // Find preference by metadata and decide next state. // Expect one preference existed, move to ADD_SOURCE_WAIT_FOR_RESPONSE void handleSourceAddRequest( @@ -530,9 +600,23 @@ public class AudioStreamsProgressCategoryController extends BasePreferenceContro // Handle QR code scan, display currently connected streams then start scanning // sequentially handleSourceFromQrCodeIfExists(); - mAudioStreamsHelper - .getAllConnectedSources() - .forEach(this::handleSourceConnected); + if (audioSharingHysteresisModeFix()) { + // With hysteresis mode, we prioritize showing connected sources first. + // If no connected sources are found, we then show present sources. + List sources = + mAudioStreamsHelper.getAllConnectedSources(); + if (!sources.isEmpty()) { + sources.forEach(this::handleSourceConnected); + } else { + mAudioStreamsHelper + .getAllPresentSources() + .forEach(this::handleSourcePresent); + } + } else { + mAudioStreamsHelper + .getAllConnectedSources() + .forEach(this::handleSourceConnected); + } mLeBroadcastAssistant.startSearchingForSources(emptyList()); mMediaControlHelper.start(); }); @@ -581,6 +665,7 @@ public class AudioStreamsProgressCategoryController extends BasePreferenceContro AddSourceWaitForResponseState.getInstance(); case ADD_SOURCE_BAD_CODE -> AddSourceBadCodeState.getInstance(); case ADD_SOURCE_FAILED -> AddSourceFailedState.getInstance(); + case SOURCE_PRESENT -> SourcePresentState.getInstance(); case SOURCE_ADDED -> SourceAddedState.getInstance(); default -> throw new IllegalArgumentException("Unsupported state: " + state); }; diff --git a/src/com/android/settings/connecteddevice/audiosharing/audiostreams/SourcePresentState.java b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/SourcePresentState.java new file mode 100644 index 00000000000..1e724f16f63 --- /dev/null +++ b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/SourcePresentState.java @@ -0,0 +1,87 @@ +/* + * 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 android.app.settings.SettingsEnums; +import android.os.Bundle; + +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; +import androidx.preference.Preference; + +import com.android.settings.R; +import com.android.settings.core.SubSettingLauncher; +import com.android.settings.dashboard.DashboardFragment; + +class SourcePresentState extends AudioStreamStateHandler { + @VisibleForTesting + static final int AUDIO_STREAM_SOURCE_PRESENT_STATE_SUMMARY = R.string.audio_streams_present_now; + + @Nullable private static SourcePresentState sInstance = null; + + SourcePresentState() {} + + static SourcePresentState getInstance() { + if (sInstance == null) { + sInstance = new SourcePresentState(); + } + return sInstance; + } + + @Override + void performAction( + AudioStreamPreference preference, + AudioStreamsProgressCategoryController controller, + AudioStreamsHelper helper) { + // nothing to do + } + + @Override + int getSummary() { + return AUDIO_STREAM_SOURCE_PRESENT_STATE_SUMMARY; + } + + @Override + Preference.OnPreferenceClickListener getOnClickListener( + AudioStreamsProgressCategoryController controller) { + return preference -> { + var p = (AudioStreamPreference) preference; + Bundle broadcast = new Bundle(); + broadcast.putString( + AudioStreamDetailsFragment.BROADCAST_NAME_ARG, (String) p.getTitle()); + broadcast.putInt( + AudioStreamDetailsFragment.BROADCAST_ID_ARG, p.getAudioStreamBroadcastId()); + + new SubSettingLauncher(p.getContext()) + .setTitleRes(R.string.audio_streams_detail_page_title) + .setDestination(AudioStreamDetailsFragment.class.getName()) + .setSourceMetricsCategory( + !(controller.getFragment() instanceof DashboardFragment) + ? SettingsEnums.PAGE_UNKNOWN + : ((DashboardFragment) controller.getFragment()) + .getMetricsCategory()) + .setArguments(broadcast) + .launch(); + return true; + }; + } + + @Override + AudioStreamsProgressCategoryController.AudioStreamState getStateEnum() { + return AudioStreamsProgressCategoryController.AudioStreamState.SOURCE_PRESENT; + } +} diff --git a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamButtonControllerTest.java b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamButtonControllerTest.java index c6fb361d656..1d39bc9f0db 100644 --- a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamButtonControllerTest.java +++ b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamButtonControllerTest.java @@ -16,6 +16,8 @@ package com.android.settings.connecteddevice.audiosharing.audiostreams; +import static com.android.settingslib.flags.Flags.FLAG_AUDIO_SHARING_HYSTERESIS_MODE_FIX; + import static com.google.common.truth.Truth.assertThat; import static org.mockito.ArgumentMatchers.any; @@ -34,6 +36,7 @@ import android.bluetooth.BluetoothLeBroadcastAssistant; import android.bluetooth.BluetoothLeBroadcastMetadata; import android.bluetooth.BluetoothLeBroadcastReceiveState; import android.content.Context; +import android.platform.test.flag.junit.SetFlagsRule; import android.view.View; import androidx.lifecycle.LifecycleOwner; @@ -72,8 +75,8 @@ import java.util.concurrent.Executor; ShadowAudioStreamsHelper.class, }) public class AudioStreamButtonControllerTest { - @Rule public final MockitoRule mMockitoRule = MockitoJUnit.rule(); + @Rule public final SetFlagsRule mSetFlagsRule = new SetFlagsRule(); private static final String KEY = "audio_stream_button"; private static final int BROADCAST_ID = 1; private final Context mContext = ApplicationProvider.getApplicationContext(); @@ -83,6 +86,7 @@ public class AudioStreamButtonControllerTest { @Mock private LocalBluetoothLeBroadcastAssistant mAssistant; @Mock private AudioStreamsRepository mRepository; @Mock private ActionButtonsPreference mPreference; + @Mock private BluetoothDevice mSourceDevice; private Lifecycle mLifecycle; private LifecycleOwner mLifecycleOwner; private FakeFeatureFactory mFeatureFactory; @@ -90,6 +94,7 @@ public class AudioStreamButtonControllerTest { @Before public void setUp() { + mSetFlagsRule.disableFlags(FLAG_AUDIO_SHARING_HYSTERESIS_MODE_FIX); ShadowAudioStreamsHelper.setUseMock(mAudioStreamsHelper); when(mAudioStreamsHelper.getLeBroadcastAssistant()).thenReturn(mAssistant); mFeatureFactory = FakeFeatureFactory.setupForTest(); @@ -254,6 +259,33 @@ public class AudioStreamButtonControllerTest { .setButton1Icon(com.android.settings.R.drawable.ic_settings_close); } + @Test + public void testCallback_onReceiveStateChangedWithSourcePresent_updateButton() { + mSetFlagsRule.enableFlags(FLAG_AUDIO_SHARING_HYSTERESIS_MODE_FIX); + String address = "11:22:33:44:55:66"; + + BluetoothLeBroadcastReceiveState state = mock(BluetoothLeBroadcastReceiveState.class); + when(state.getBroadcastId()).thenReturn(BROADCAST_ID); + when(state.getSourceDevice()).thenReturn(mSourceDevice); + when(mSourceDevice.getAddress()).thenReturn(address); + List bisSyncState = new ArrayList<>(); + when(state.getBisSyncState()).thenReturn(bisSyncState); + when(mAudioStreamsHelper.getAllPresentSources()).thenReturn(List.of(state)); + + mController.displayPreference(mScreen); + mController.mBroadcastAssistantCallback.onReceiveStateChanged( + mock(BluetoothDevice.class), /* sourceId= */ 0, state); + + verify(mFeatureFactory.metricsFeatureProvider, never()) + .action(any(), eq(SettingsEnums.ACTION_AUDIO_STREAM_JOIN_SUCCEED), anyInt()); + + // Called twice, once in displayPreference, the other one in callback + verify(mPreference, times(2)).setButton1Enabled(true); + verify(mPreference, times(2)).setButton1Text(R.string.audio_streams_disconnect); + verify(mPreference, times(2)) + .setButton1Icon(com.android.settings.R.drawable.ic_settings_close); + } + @Test public void testCallback_onSourceAddFailed_updateButton() { when(mAudioStreamsHelper.getAllConnectedSources()).thenReturn(Collections.emptyList()); diff --git a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamHeaderControllerTest.java b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamHeaderControllerTest.java index 327090da437..5cdc7974846 100644 --- a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamHeaderControllerTest.java +++ b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamHeaderControllerTest.java @@ -18,6 +18,8 @@ package com.android.settings.connecteddevice.audiosharing.audiostreams; import static com.android.settings.connecteddevice.audiosharing.audiostreams.AudioStreamHeaderController.AUDIO_STREAM_HEADER_LISTENING_NOW_SUMMARY; import static com.android.settings.connecteddevice.audiosharing.audiostreams.AudioStreamHeaderController.AUDIO_STREAM_HEADER_NOT_LISTENING_SUMMARY; +import static com.android.settings.connecteddevice.audiosharing.audiostreams.AudioStreamHeaderController.AUDIO_STREAM_HEADER_PRESENT_NOW_SUMMARY; +import static com.android.settingslib.flags.Flags.FLAG_AUDIO_SHARING_HYSTERESIS_MODE_FIX; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; @@ -31,6 +33,7 @@ import android.bluetooth.BluetoothLeBroadcastAssistant; import android.bluetooth.BluetoothLeBroadcastReceiveState; import android.content.Context; import android.graphics.drawable.Drawable; +import android.platform.test.flag.junit.SetFlagsRule; import androidx.lifecycle.LifecycleOwner; import androidx.preference.PreferenceScreen; @@ -68,8 +71,9 @@ import java.util.concurrent.Executor; ShadowAudioStreamsHelper.class, }) public class AudioStreamHeaderControllerTest { - @Rule public final MockitoRule mMockitoRule = MockitoJUnit.rule(); + @Rule public final SetFlagsRule mSetFlagsRule = new SetFlagsRule(); + private static final String KEY = "audio_stream_header"; private static final int BROADCAST_ID = 1; private static final String BROADCAST_NAME = "broadcast name"; @@ -81,12 +85,15 @@ public class AudioStreamHeaderControllerTest { @Mock private AudioStreamDetailsFragment mFragment; @Mock private LayoutPreference mPreference; @Mock private EntityHeaderController mHeaderController; + @Mock private BluetoothDevice mBluetoothDevice; private Lifecycle mLifecycle; private LifecycleOwner mLifecycleOwner; private AudioStreamHeaderController mController; @Before public void setUp() { + mSetFlagsRule.disableFlags(FLAG_AUDIO_SHARING_HYSTERESIS_MODE_FIX); + ShadowEntityHeaderController.setUseMock(mHeaderController); ShadowAudioStreamsHelper.setUseMock(mAudioStreamsHelper); when(mAudioStreamsHelper.getLeBroadcastAssistant()).thenReturn(mAssistant); @@ -168,6 +175,44 @@ public class AudioStreamHeaderControllerTest { verify(mScreen).addPreference(any()); } + @Test + public void testDisplayPreference_sourcePresent_setSummary() { + mSetFlagsRule.enableFlags(FLAG_AUDIO_SHARING_HYSTERESIS_MODE_FIX); + String address = "11:22:33:44:55:66"; + + when(mBroadcastReceiveState.getBroadcastId()).thenReturn(BROADCAST_ID); + when(mBroadcastReceiveState.getSourceDevice()).thenReturn(mBluetoothDevice); + when(mBluetoothDevice.getAddress()).thenReturn(address); + List bisSyncState = new ArrayList<>(); + when(mBroadcastReceiveState.getBisSyncState()).thenReturn(bisSyncState); + when(mAudioStreamsHelper.getAllPresentSources()) + .thenReturn(List.of(mBroadcastReceiveState)); + + mController.displayPreference(mScreen); + + verify(mHeaderController).setLabel(BROADCAST_NAME); + verify(mHeaderController).setIcon(any(Drawable.class)); + verify(mHeaderController) + .setSummary(mContext.getString(AUDIO_STREAM_HEADER_PRESENT_NOW_SUMMARY)); + verify(mHeaderController).done(true); + verify(mScreen).addPreference(any()); + } + + @Test + public void testDisplayPreference_sourceNotPresent_setSummary() { + mSetFlagsRule.enableFlags(FLAG_AUDIO_SHARING_HYSTERESIS_MODE_FIX); + + when(mAudioStreamsHelper.getAllPresentSources()).thenReturn(Collections.emptyList()); + + mController.displayPreference(mScreen); + + verify(mHeaderController).setLabel(BROADCAST_NAME); + verify(mHeaderController).setIcon(any(Drawable.class)); + verify(mHeaderController).setSummary(AUDIO_STREAM_HEADER_NOT_LISTENING_SUMMARY); + verify(mHeaderController).done(true); + verify(mScreen).addPreference(any()); + } + @Test public void testCallback_onSourceRemoved_updateButton() { when(mAudioStreamsHelper.getAllConnectedSources()).thenReturn(Collections.emptyList()); @@ -212,4 +257,25 @@ public class AudioStreamHeaderControllerTest { .setSummary(mContext.getString(AUDIO_STREAM_HEADER_LISTENING_NOW_SUMMARY)); verify(mHeaderController, times(2)).done(true); } + + @Test + public void testCallback_onReceiveStateChangedWithSourcePresent_updateButton() { + mSetFlagsRule.enableFlags(FLAG_AUDIO_SHARING_HYSTERESIS_MODE_FIX); + String address = "11:22:33:44:55:66"; + + when(mAudioStreamsHelper.getAllPresentSources()) + .thenReturn(List.of(mBroadcastReceiveState)); + when(mBroadcastReceiveState.getBroadcastId()).thenReturn(BROADCAST_ID); + when(mBroadcastReceiveState.getSourceDevice()).thenReturn(mBluetoothDevice); + when(mBluetoothDevice.getAddress()).thenReturn(address); + + mController.displayPreference(mScreen); + mController.mBroadcastAssistantCallback.onReceiveStateChanged( + mock(BluetoothDevice.class), /* sourceId= */ 0, mBroadcastReceiveState); + + // Called twice, once in displayPreference, the other one in callback + verify(mHeaderController, times(2)) + .setSummary(mContext.getString(AUDIO_STREAM_HEADER_PRESENT_NOW_SUMMARY)); + verify(mHeaderController, times(2)).done(true); + } } 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 index e44dee90e70..bb873d44575 100644 --- a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamStateHandlerTest.java +++ b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamStateHandlerTest.java @@ -16,6 +16,8 @@ package com.android.settings.connecteddevice.audiosharing.audiostreams; +import static com.android.settingslib.flags.Flags.FLAG_AUDIO_SHARING_HYSTERESIS_MODE_FIX; + import static com.google.common.truth.Truth.assertThat; import static org.mockito.ArgumentMatchers.any; @@ -30,6 +32,7 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import android.content.Context; +import android.platform.test.flag.junit.SetFlagsRule; import android.text.SpannableString; import androidx.preference.Preference; @@ -48,6 +51,8 @@ import org.robolectric.RobolectricTestRunner; @RunWith(RobolectricTestRunner.class) public class AudioStreamStateHandlerTest { @Rule public final MockitoRule mMockitoRule = MockitoJUnit.rule(); + @Rule public final SetFlagsRule mSetFlagsRule = new SetFlagsRule(); + private static final int SUMMARY_RES = 1; private static final String SUMMARY = "summary"; private final Context mContext = spy(ApplicationProvider.getApplicationContext()); @@ -58,6 +63,7 @@ public class AudioStreamStateHandlerTest { @Before public void setUp() { + mSetFlagsRule.disableFlags(FLAG_AUDIO_SHARING_HYSTERESIS_MODE_FIX); mHandler = spy(new AudioStreamStateHandler()); } @@ -101,6 +107,28 @@ public class AudioStreamStateHandlerTest { verify(mPreference).setOnPreferenceClickListener(eq(null)); } + @Test + public void testHandleStateChange_setNewState_sourcePresent() { + mSetFlagsRule.enableFlags(FLAG_AUDIO_SHARING_HYSTERESIS_MODE_FIX); + + when(mHandler.getStateEnum()) + .thenReturn(AudioStreamsProgressCategoryController.AudioStreamState.SOURCE_PRESENT); + when(mPreference.getAudioStreamState()) + .thenReturn( + AudioStreamsProgressCategoryController.AudioStreamState + .ADD_SOURCE_BAD_CODE); + + mHandler.handleStateChange(mPreference, mController, mHelper); + + verify(mPreference) + .setAudioStreamState( + AudioStreamsProgressCategoryController.AudioStreamState.SOURCE_PRESENT); + verify(mHandler).performAction(any(), any(), any()); + verify(mPreference).setIsConnected(eq(true)); + verify(mPreference).setSummary(eq("")); + verify(mPreference).setOnPreferenceClickListener(eq(null)); + } + @Test public void testHandleStateChange_setNewState_newSummary_newListener() { Preference.OnPreferenceClickListener listener = diff --git a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsHelperTest.java b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsHelperTest.java index 42667982eda..fca1137e5c7 100644 --- a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsHelperTest.java +++ b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsHelperTest.java @@ -19,6 +19,8 @@ package com.android.settings.connecteddevice.audiosharing.audiostreams; import static android.content.res.Configuration.ORIENTATION_LANDSCAPE; import static android.content.res.Configuration.ORIENTATION_PORTRAIT; +import static com.android.settingslib.flags.Flags.FLAG_AUDIO_SHARING_HYSTERESIS_MODE_FIX; + import static com.google.common.truth.Truth.assertThat; import static org.mockito.ArgumentMatchers.any; @@ -37,6 +39,7 @@ import android.bluetooth.BluetoothLeBroadcastReceiveState; import android.content.Context; import android.content.res.Configuration; import android.content.res.Resources; +import android.platform.test.flag.junit.SetFlagsRule; import androidx.fragment.app.FragmentActivity; import androidx.test.core.app.ApplicationProvider; @@ -74,6 +77,8 @@ import java.util.List; }) public class AudioStreamsHelperTest { @Rule public final MockitoRule mMockitoRule = MockitoJUnit.rule(); + @Rule public final SetFlagsRule mSetFlagsRule = new SetFlagsRule(); + private static final int GROUP_ID = 1; private static final int BROADCAST_ID_1 = 1; private static final int BROADCAST_ID_2 = 2; @@ -86,10 +91,12 @@ public class AudioStreamsHelperTest { @Mock private BluetoothLeBroadcastMetadata mMetadata; @Mock private CachedBluetoothDevice mCachedDevice; @Mock private BluetoothDevice mDevice; + @Mock private BluetoothDevice mSourceDevice; private AudioStreamsHelper mHelper; @Before public void setUp() { + mSetFlagsRule.disableFlags(FLAG_AUDIO_SHARING_HYSTERESIS_MODE_FIX); when(mLocalBluetoothManager.getProfileManager()).thenReturn(mLocalBluetoothProfileManager); when(mLocalBluetoothManager.getCachedDeviceManager()).thenReturn(mDeviceManager); when(mLocalBluetoothProfileManager.getLeAudioBroadcastAssistantProfile()) @@ -166,6 +173,7 @@ public class AudioStreamsHelperTest { @Test public void removeSource_memberHasConnectedSource() { + String address = "11:22:33:44:55:66"; List devices = new ArrayList<>(); var memberDevice = mock(BluetoothDevice.class); devices.add(mDevice); @@ -184,6 +192,8 @@ public class AudioStreamsHelperTest { List bisSyncState = new ArrayList<>(); bisSyncState.add(1L); when(source.getBisSyncState()).thenReturn(bisSyncState); + when(source.getSourceDevice()).thenReturn(mSourceDevice); + when(mSourceDevice.getAddress()).thenReturn(address); mHelper.removeSource(BROADCAST_ID_2); @@ -217,6 +227,52 @@ public class AudioStreamsHelperTest { assertThat(list.get(0)).isEqualTo(source); } + @Test + public void getAllPresentSources_noSource() { + mSetFlagsRule.enableFlags(FLAG_AUDIO_SHARING_HYSTERESIS_MODE_FIX); + + List devices = new ArrayList<>(); + devices.add(mDevice); + + String address = "00:00:00:00:00:00"; + + when(mAssistant.getAllConnectedDevices()).thenReturn(devices); + BluetoothLeBroadcastReceiveState source = mock(BluetoothLeBroadcastReceiveState.class); + when(mDeviceManager.findDevice(any())).thenReturn(mCachedDevice); + when(mCachedDevice.getDevice()).thenReturn(mDevice); + when(mCachedDevice.getGroupId()).thenReturn(GROUP_ID); + when(mAssistant.getAllSources(any())).thenReturn(ImmutableList.of(source)); + when(source.getSourceDevice()).thenReturn(mSourceDevice); + when(mSourceDevice.getAddress()).thenReturn(address); + + var list = mHelper.getAllPresentSources(); + assertThat(list).isEmpty(); + } + + @Test + public void getAllPresentSources_returnSource() { + mSetFlagsRule.enableFlags(FLAG_AUDIO_SHARING_HYSTERESIS_MODE_FIX); + String address = "11:22:33:44:55:66"; + + List devices = new ArrayList<>(); + devices.add(mDevice); + + when(mAssistant.getAllConnectedDevices()).thenReturn(devices); + BluetoothLeBroadcastReceiveState source = mock(BluetoothLeBroadcastReceiveState.class); + when(mDeviceManager.findDevice(any())).thenReturn(mCachedDevice); + when(mCachedDevice.getDevice()).thenReturn(mDevice); + when(mCachedDevice.getGroupId()).thenReturn(GROUP_ID); + when(mAssistant.getAllSources(any())).thenReturn(ImmutableList.of(source)); + when(source.getSourceDevice()).thenReturn(mSourceDevice); + when(mSourceDevice.getAddress()).thenReturn(address); + List bisSyncState = new ArrayList<>(); + when(source.getBisSyncState()).thenReturn(bisSyncState); + + var list = mHelper.getAllPresentSources(); + assertThat(list).isNotEmpty(); + assertThat(list.get(0)).isEqualTo(source); + } + @Test public void startMediaService_noDevice_doNothing() { mHelper.startMediaService(mContext, BROADCAST_ID_1, BROADCAST_NAME); 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 index 164c2f093e8..1e645282227 100644 --- a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsProgressCategoryCallbackTest.java +++ b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsProgressCategoryCallbackTest.java @@ -16,6 +16,8 @@ package com.android.settings.connecteddevice.audiosharing.audiostreams; +import static com.android.settingslib.flags.Flags.FLAG_AUDIO_SHARING_HYSTERESIS_MODE_FIX; + import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.ArgumentMatchers.anyString; @@ -25,6 +27,7 @@ import static org.mockito.Mockito.when; import android.bluetooth.BluetoothDevice; import android.bluetooth.BluetoothLeBroadcastMetadata; import android.bluetooth.BluetoothLeBroadcastReceiveState; +import android.platform.test.flag.junit.SetFlagsRule; import org.junit.Before; import org.junit.Rule; @@ -41,14 +44,18 @@ import java.util.List; @RunWith(RobolectricTestRunner.class) public class AudioStreamsProgressCategoryCallbackTest { @Rule public final MockitoRule mMockitoRule = MockitoJUnit.rule(); + @Rule public final SetFlagsRule mSetFlagsRule = new SetFlagsRule(); + @Mock private AudioStreamsProgressCategoryController mController; @Mock private BluetoothDevice mDevice; @Mock private BluetoothLeBroadcastReceiveState mState; @Mock private BluetoothLeBroadcastMetadata mMetadata; + @Mock private BluetoothDevice mSourceDevice; private AudioStreamsProgressCategoryCallback mCallback; @Before public void setUp() { + mSetFlagsRule.disableFlags(FLAG_AUDIO_SHARING_HYSTERESIS_MODE_FIX); mCallback = new AudioStreamsProgressCategoryCallback(mController); } @@ -62,6 +69,20 @@ public class AudioStreamsProgressCategoryCallbackTest { verify(mController).handleSourceConnected(any()); } + @Test + public void testOnReceiveStateChanged_sourcePresent() { + mSetFlagsRule.enableFlags(FLAG_AUDIO_SHARING_HYSTERESIS_MODE_FIX); + String address = "11:22:33:44:55:66"; + + List bisSyncState = new ArrayList<>(); + when(mState.getBisSyncState()).thenReturn(bisSyncState); + when(mState.getSourceDevice()).thenReturn(mSourceDevice); + when(mSourceDevice.getAddress()).thenReturn(address); + mCallback.onReceiveStateChanged(mDevice, /* sourceId= */ 0, mState); + + verify(mController).handleSourcePresent(any()); + } + @Test public void testOnReceiveStateChanged_badCode() { when(mState.getPaSyncState()) 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 index fd1b649fabf..227748ae232 100644 --- a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsProgressCategoryControllerTest.java +++ b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsProgressCategoryControllerTest.java @@ -20,10 +20,12 @@ import static com.android.settings.connecteddevice.audiosharing.audiostreams.Aud 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.SOURCE_PRESENT; 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.android.settingslib.flags.Flags.FLAG_AUDIO_SHARING_HYSTERESIS_MODE_FIX; import static com.google.common.truth.Truth.assertThat; @@ -41,12 +43,14 @@ import static org.robolectric.Shadows.shadowOf; import static java.util.Collections.emptyList; import android.bluetooth.BluetoothAdapter; +import android.bluetooth.BluetoothDevice; 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.platform.test.flag.junit.SetFlagsRule; import android.view.View; import android.widget.Button; import android.widget.TextView; @@ -96,6 +100,8 @@ import java.util.List; }) public class AudioStreamsProgressCategoryControllerTest { @Rule public final MockitoRule mMockitoRule = MockitoJUnit.rule(); + @Rule public final SetFlagsRule mSetFlagsRule = new SetFlagsRule(); + private static final String VALID_METADATA = "BLUETOOTH:UUID:184F;BN:VGVzdA==;AT:1;AD:00A1A1A1A1A1;BI:1E240;BC:VGVzdENvZGU=;" + "MD:BgNwVGVzdA==;AS:1;PI:A0;NS:1;BS:3;NB:2;SM:BQNUZXN0BARlbmc=;;"; @@ -115,6 +121,7 @@ public class AudioStreamsProgressCategoryControllerTest { @Mock private BluetoothLeBroadcastMetadata mMetadata; @Mock private CachedBluetoothDevice mDevice; @Mock private AudioStreamsProgressCategoryPreference mPreference; + @Mock private BluetoothDevice mSourceDevice; private Lifecycle mLifecycle; private LifecycleOwner mLifecycleOwner; private Fragment mFragment; @@ -125,6 +132,7 @@ public class AudioStreamsProgressCategoryControllerTest { ShadowAudioStreamsHelper.setUseMock(mAudioStreamsHelper); when(mAudioStreamsHelper.getLeBroadcastAssistant()).thenReturn(mLeBroadcastAssistant); when(mAudioStreamsHelper.getAllConnectedSources()).thenReturn(emptyList()); + mSetFlagsRule.disableFlags(FLAG_AUDIO_SHARING_HYSTERESIS_MODE_FIX); ShadowBluetoothUtils.sLocalBluetoothManager = mLocalBtManager; when(mLocalBtManager.getEventManager()).thenReturn(mBluetoothEventManager); @@ -282,6 +290,29 @@ public class AudioStreamsProgressCategoryControllerTest { verify(mController, never()).moveToState(any(), any()); } + @Test + public void testOnStart_initHasDevice_getPresentSources() { + mSetFlagsRule.enableFlags(FLAG_AUDIO_SHARING_HYSTERESIS_MODE_FIX); + + // Setup a device + ShadowAudioStreamsHelper.setCachedBluetoothDeviceInSharingOrLeConnected(mDevice); + + List connectedList = new ArrayList<>(); + // Empty connected device list + when(mAudioStreamsHelper.getAllConnectedSources()).thenReturn(connectedList); + + mController.onStart(mLifecycleOwner); + shadowOf(Looper.getMainLooper()).idle(); + + verify(mAudioStreamsHelper).getAllPresentSources(); + 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 @@ -764,6 +795,58 @@ public class AudioStreamsProgressCategoryControllerTest { assertThat(states.get(1)).isEqualTo(ADD_SOURCE_FAILED); } + @Test + public void testHandleSourcePresent_updateState() { + mSetFlagsRule.enableFlags(FLAG_AUDIO_SHARING_HYSTERESIS_MODE_FIX); + String address = "11:22:33:44:55:66"; + + // 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 connected source is identified as having a bad code + BluetoothLeBroadcastReceiveState receiveState = + mock(BluetoothLeBroadcastReceiveState.class); + when(receiveState.getBroadcastId()).thenReturn(NEWLY_FOUND_BROADCAST_ID); + when(receiveState.getSourceDevice()).thenReturn(mSourceDevice); + when(mSourceDevice.getAddress()).thenReturn(address); + List bisSyncState = new ArrayList<>(); + when(receiveState.getBisSyncState()).thenReturn(bisSyncState); + + // The new found source is identified as failed to connect + mController.handleSourcePresent(receiveState); + 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(SOURCE_PRESENT); + } + private static BluetoothLeBroadcastReceiveState createConnectedMock(int id) { var connected = mock(BluetoothLeBroadcastReceiveState.class); List bisSyncState = new ArrayList<>(); diff --git a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/SourcePresentStateTest.java b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/SourcePresentStateTest.java new file mode 100644 index 00000000000..fd84fefb3e5 --- /dev/null +++ b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/SourcePresentStateTest.java @@ -0,0 +1,137 @@ +/* + * 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 android.app.settings.SettingsEnums.AUDIO_STREAM_MAIN; + +import static com.android.settings.connecteddevice.audiosharing.audiostreams.SourcePresentState.AUDIO_STREAM_SOURCE_PRESENT_STATE_SUMMARY; + +import static com.google.common.truth.Truth.assertThat; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +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 SourcePresentStateTest { + @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 SourcePresentState mInstance; + + @Before + public void setUp() { + when(mFragment.getActivity()).thenReturn(mActivity); + mFeatureFactory = FakeFeatureFactory.setupForTest(); + mInstance = new SourcePresentState(); + when(mPreference.getAudioStreamBroadcastId()).thenReturn(BROADCAST_ID); + when(mPreference.getTitle()).thenReturn(BROADCAST_TITLE); + } + + @Test + public void testGetInstance() { + mInstance = SourcePresentState.getInstance(); + assertThat(mInstance).isNotNull(); + assertThat(mInstance).isInstanceOf(SourcePresentState.class); + } + + @Test + public void testGetSummary() { + int summary = mInstance.getSummary(); + assertThat(summary).isEqualTo(AUDIO_STREAM_SOURCE_PRESENT_STATE_SUMMARY); + } + + @Test + public void testGetStateEnum() { + AudioStreamsProgressCategoryController.AudioStreamState stateEnum = + mInstance.getStateEnum(); + assertThat(stateEnum) + .isEqualTo(AudioStreamsProgressCategoryController.AudioStreamState.SOURCE_PRESENT); + } + + @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/testshadows/ShadowAudioStreamsHelper.java b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/testshadows/ShadowAudioStreamsHelper.java index 051eda7c442..c7d0c60efa8 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 @@ -59,6 +59,11 @@ public class ShadowAudioStreamsHelper { return sMockHelper.getAllConnectedSources(); } + @Implementation + public List getAllPresentSources() { + return sMockHelper.getAllPresentSources(); + } + /** Gets {@link CachedBluetoothDevice} in sharing or le connected */ @Implementation public static Optional getCachedBluetoothDeviceInSharingOrLeConnected(