From 6383e738d440269d6ff70f7e1efa12b309c39eb8 Mon Sep 17 00:00:00 2001 From: Joshua McCloskey Date: Fri, 16 Aug 2024 21:34:49 +0000 Subject: [PATCH 01/11] Spit up FingerprintManagerInteractor 2/N Test: atest, screenshot tests passed Flag: com.android.settings.flags.fingerprint_v2_enrollment Change-Id: I1a2cf61290906e112a5a0129ef7ed3587d14de7e --- .../fingerprint2/BiometricsEnvironment.kt | 12 ++-- .../repository/FingerprintEnrollmentRepo.kt | 64 ++++++++++++------- .../fingerprint2/data/repository/UserRepo.kt | 17 ++++- .../CanEnrollFingerprintsInteractorImpl.kt | 9 ++- .../EnrolledFingerprintsInteractorImpl.kt | 16 ++--- .../domain/interactor/UserInteractorImpl.kt | 27 ++++++++ .../CanEnrollFingerprintsInteractor.kt | 14 +++- .../lib/domain/interactor/UserInteractor.kt | 31 +++++++++ .../viewmodel/FingerprintSettingsViewModel.kt | 11 ++-- .../FakeFingerprintManagerInteractor.kt | 19 ++++-- .../FingerprintManagerInteractorTest.kt | 47 ++++++++++---- ...gerprintEnrollConfirmationViewModelTest.kt | 2 +- 12 files changed, 199 insertions(+), 70 deletions(-) create mode 100644 src/com/android/settings/biometrics/fingerprint2/domain/interactor/UserInteractorImpl.kt create mode 100644 src/com/android/settings/biometrics/fingerprint2/lib/domain/interactor/UserInteractor.kt diff --git a/src/com/android/settings/biometrics/fingerprint2/BiometricsEnvironment.kt b/src/com/android/settings/biometrics/fingerprint2/BiometricsEnvironment.kt index e3233ed22b1..761a9c3a871 100644 --- a/src/com/android/settings/biometrics/fingerprint2/BiometricsEnvironment.kt +++ b/src/com/android/settings/biometrics/fingerprint2/BiometricsEnvironment.kt @@ -58,6 +58,7 @@ import com.android.settings.biometrics.fingerprint2.domain.interactor.SensorInte import com.android.settings.biometrics.fingerprint2.domain.interactor.TouchEventInteractor import com.android.settings.biometrics.fingerprint2.domain.interactor.UdfpsEnrollInteractor import com.android.settings.biometrics.fingerprint2.domain.interactor.UdfpsEnrollInteractorImpl +import com.android.settings.biometrics.fingerprint2.domain.interactor.UserInteractorImpl import com.android.settings.biometrics.fingerprint2.domain.interactor.VibrationInteractor import com.android.settings.biometrics.fingerprint2.domain.interactor.VibrationInteractorImpl import com.android.settings.biometrics.fingerprint2.lib.domain.interactor.AuthenitcateInteractor @@ -67,6 +68,7 @@ import com.android.settings.biometrics.fingerprint2.lib.domain.interactor.Genera import com.android.settings.biometrics.fingerprint2.lib.domain.interactor.RemoveFingerprintInteractor import com.android.settings.biometrics.fingerprint2.lib.domain.interactor.RenameFingerprintInteractor import com.android.settings.biometrics.fingerprint2.lib.domain.interactor.SensorInteractor +import com.android.settings.biometrics.fingerprint2.lib.domain.interactor.UserInteractor import com.android.settings.biometrics.fingerprint2.lib.model.Settings import java.util.concurrent.Executors import kotlinx.coroutines.MainScope @@ -97,11 +99,11 @@ class BiometricsEnvironment( com.android.internal.R.integer.config_fingerprintMaxTemplatesPerUser ) ) - private val fingerprintEnrollmentRepository = - FingerprintEnrollmentRepositoryImpl(fingerprintManager, userRepo, fingerprintSettingsRepository, - backgroundDispatcher, applicationScope) private val fingerprintSensorRepository: FingerprintSensorRepository = FingerprintSensorRepositoryImpl(fingerprintManager, backgroundDispatcher, applicationScope) + private val fingerprintEnrollmentRepository = + FingerprintEnrollmentRepositoryImpl(fingerprintManager, userRepo, fingerprintSettingsRepository, + backgroundDispatcher, applicationScope, fingerprintSensorRepository) private val debuggingRepository: DebuggingRepository = DebuggingRepositoryImpl() private val udfpsDebugRepo = UdfpsEnrollDebugRepositoryImpl() @@ -118,11 +120,13 @@ class BiometricsEnvironment( EnrollFingerprintInteractorImpl(context.userId, fingerprintManager, Settings) fun createFingerprintsEnrolledInteractor(): EnrolledFingerprintsInteractorImpl = - EnrolledFingerprintsInteractorImpl(fingerprintManager, context.userId) + EnrolledFingerprintsInteractorImpl(fingerprintEnrollmentRepository) fun createAuthenticateInteractor(): AuthenitcateInteractor = AuthenticateInteractorImpl(fingerprintManager, context.userId) + fun createUserInteractor(): UserInteractor = UserInteractorImpl(userRepo) + fun createRemoveFingerprintInteractor(): RemoveFingerprintInteractor = RemoveFingerprintsInteractorImpl(fingerprintManager, context.userId) diff --git a/src/com/android/settings/biometrics/fingerprint2/data/repository/FingerprintEnrollmentRepo.kt b/src/com/android/settings/biometrics/fingerprint2/data/repository/FingerprintEnrollmentRepo.kt index 22904e9d2ac..0bb4eead62d 100644 --- a/src/com/android/settings/biometrics/fingerprint2/data/repository/FingerprintEnrollmentRepo.kt +++ b/src/com/android/settings/biometrics/fingerprint2/data/repository/FingerprintEnrollmentRepo.kt @@ -23,14 +23,16 @@ import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update import kotlinx.coroutines.withContext /** Repository that contains information about fingerprint enrollments. */ @@ -38,20 +40,31 @@ interface FingerprintEnrollmentRepository { /** The current enrollments of the user */ val currentEnrollments: Flow?> + /** Indicates the maximum fingerprints that are enrollable * */ + val maxFingerprintsEnrollable: Flow + /** Indicates if a user can enroll another fingerprint */ val canEnrollUser: Flow - fun maxFingerprintsEnrollable(): Int + /** + * Indicates if we should use the default settings for maximum enrollments or the sensor props + * from the fingerprint sensor + */ + fun setShouldUseSettingsMaxFingerprints(useSettings: Boolean) } class FingerprintEnrollmentRepositoryImpl( - fingerprintManager: FingerprintManager, + private val fingerprintManager: FingerprintManager, userRepo: UserRepo, - private val settingsRepository: FingerprintSettingsRepository, + settingsRepository: FingerprintSettingsRepository, backgroundDispatcher: CoroutineDispatcher, applicationScope: CoroutineScope, + sensorRepo: FingerprintSensorRepository, ) : FingerprintEnrollmentRepository { + private val _shouldUseSettingsMaxFingerprints = MutableStateFlow(false) + val shouldUseSettingsMaxFingerprints = _shouldUseSettingsMaxFingerprints.asStateFlow() + private val enrollmentChangedFlow: Flow = callbackFlow { val callback = @@ -72,27 +85,34 @@ class FingerprintEnrollmentRepositoryImpl( override val currentEnrollments: Flow> = userRepo.currentUser .distinctUntilChanged() - .flatMapLatest { currentUser -> - enrollmentChangedFlow.map { enrollmentChanged -> - if (enrollmentChanged == null || enrollmentChanged == currentUser) { - fingerprintManager - .getEnrolledFingerprints(currentUser) - ?.map { (FingerprintData(it.name.toString(), it.biometricId, it.deviceId)) } - ?.toList() - } else { - null - } - } - } + .combine(enrollmentChangedFlow) { currentUser, _ -> getFingerprintsForUser(currentUser) } .filterNotNull() .flowOn(backgroundDispatcher) - override val canEnrollUser: Flow = - currentEnrollments.map { - it?.size?.let { it < settingsRepository.maxEnrollableFingerprints() } ?: false + override val maxFingerprintsEnrollable: Flow = + shouldUseSettingsMaxFingerprints.combine(sensorRepo.fingerprintSensor) { + shouldUseSettings, + sensor -> + if (shouldUseSettings) { + settingsRepository.maxEnrollableFingerprints() + } else { + sensor.maxEnrollmentsPerUser + } } - override fun maxFingerprintsEnrollable(): Int { - return settingsRepository.maxEnrollableFingerprints() + override val canEnrollUser: Flow = + currentEnrollments.combine(maxFingerprintsEnrollable) { enrollments, maxFingerprints -> + enrollments.size < maxFingerprints + } + + override fun setShouldUseSettingsMaxFingerprints(useSettings: Boolean) { + _shouldUseSettingsMaxFingerprints.update { useSettings } + } + + private fun getFingerprintsForUser(userId: Int): List? { + return fingerprintManager + .getEnrolledFingerprints(userId) + ?.map { (FingerprintData(it.name.toString(), it.biometricId, it.deviceId)) } + ?.toList() } } diff --git a/src/com/android/settings/biometrics/fingerprint2/data/repository/UserRepo.kt b/src/com/android/settings/biometrics/fingerprint2/data/repository/UserRepo.kt index 720e7787d12..91260431bc5 100644 --- a/src/com/android/settings/biometrics/fingerprint2/data/repository/UserRepo.kt +++ b/src/com/android/settings/biometrics/fingerprint2/data/repository/UserRepo.kt @@ -17,7 +17,10 @@ package com.android.settings.biometrics.fingerprint2.data.repository import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.update /** * A repository responsible for indicating the current user. @@ -27,8 +30,18 @@ interface UserRepo { * This flow indicates the current user. */ val currentUser: Flow + + /** + * Updates the current user. + */ + fun updateUser(user: Int) } -class UserRepoImpl(val currUser: Int): UserRepo { - override val currentUser: Flow = flowOf(currUser) +class UserRepoImpl(currUser: Int): UserRepo { + private val _currentUser = MutableStateFlow(currUser) + override val currentUser = _currentUser.asStateFlow() + + override fun updateUser(user: Int) { + _currentUser.update { user } + } } diff --git a/src/com/android/settings/biometrics/fingerprint2/domain/interactor/CanEnrollFingerprintsInteractorImpl.kt b/src/com/android/settings/biometrics/fingerprint2/domain/interactor/CanEnrollFingerprintsInteractorImpl.kt index caeea4e4586..cfdfbe23081 100644 --- a/src/com/android/settings/biometrics/fingerprint2/domain/interactor/CanEnrollFingerprintsInteractorImpl.kt +++ b/src/com/android/settings/biometrics/fingerprint2/domain/interactor/CanEnrollFingerprintsInteractorImpl.kt @@ -21,11 +21,14 @@ import com.android.settings.biometrics.fingerprint2.lib.domain.interactor.CanEnr import kotlinx.coroutines.flow.Flow class CanEnrollFingerprintsInteractorImpl( - val fingerprintEnrollmentRepository: FingerprintEnrollmentRepository + private val fingerprintEnrollmentRepository: FingerprintEnrollmentRepository ) : CanEnrollFingerprintsInteractor { override val canEnrollFingerprints: Flow = fingerprintEnrollmentRepository.canEnrollUser /** Indicates the maximum fingerprints enrollable for a given user */ - override fun maxFingerprintsEnrollable(): Int { - return fingerprintEnrollmentRepository.maxFingerprintsEnrollable() + override val maxFingerprintsEnrollable: Flow = + fingerprintEnrollmentRepository.maxFingerprintsEnrollable + + override fun setShouldUseSettingsMaxFingerprints(useSettings: Boolean) { + fingerprintEnrollmentRepository.setShouldUseSettingsMaxFingerprints(useSettings) } } diff --git a/src/com/android/settings/biometrics/fingerprint2/domain/interactor/EnrolledFingerprintsInteractorImpl.kt b/src/com/android/settings/biometrics/fingerprint2/domain/interactor/EnrolledFingerprintsInteractorImpl.kt index 83b532ecd98..f8bcaf7d634 100644 --- a/src/com/android/settings/biometrics/fingerprint2/domain/interactor/EnrolledFingerprintsInteractorImpl.kt +++ b/src/com/android/settings/biometrics/fingerprint2/domain/interactor/EnrolledFingerprintsInteractorImpl.kt @@ -16,22 +16,14 @@ package com.android.settings.biometrics.fingerprint2.domain.interactor -import android.hardware.fingerprint.FingerprintManager +import com.android.settings.biometrics.fingerprint2.data.repository.FingerprintEnrollmentRepository import com.android.settings.biometrics.fingerprint2.lib.domain.interactor.EnrolledFingerprintsInteractor import com.android.settings.biometrics.fingerprint2.lib.model.FingerprintData import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flow class EnrolledFingerprintsInteractorImpl( - private val fingerprintManager: FingerprintManager, - userId: Int, + private val fingerprintEnrollmentRepository: FingerprintEnrollmentRepository ) : EnrolledFingerprintsInteractor { - override val enrolledFingerprints: Flow?> = flow { - emit( - fingerprintManager - .getEnrolledFingerprints(userId) - ?.map { (FingerprintData(it.name.toString(), it.biometricId, it.deviceId)) } - ?.toList() - ) - } + override val enrolledFingerprints: Flow?> = + fingerprintEnrollmentRepository.currentEnrollments } diff --git a/src/com/android/settings/biometrics/fingerprint2/domain/interactor/UserInteractorImpl.kt b/src/com/android/settings/biometrics/fingerprint2/domain/interactor/UserInteractorImpl.kt new file mode 100644 index 00000000000..506006e8903 --- /dev/null +++ b/src/com/android/settings/biometrics/fingerprint2/domain/interactor/UserInteractorImpl.kt @@ -0,0 +1,27 @@ +/* + * 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.fingerprint2.domain.interactor + +import com.android.settings.biometrics.fingerprint2.data.repository.UserRepo +import com.android.settings.biometrics.fingerprint2.lib.domain.interactor.UserInteractor +import kotlinx.coroutines.flow.Flow + +class UserInteractorImpl(private val userRepo: UserRepo) : UserInteractor { + override val currentUser: Flow = userRepo.currentUser + + override fun updateUser(user: Int) = userRepo.updateUser(user) +} diff --git a/src/com/android/settings/biometrics/fingerprint2/lib/domain/interactor/CanEnrollFingerprintsInteractor.kt b/src/com/android/settings/biometrics/fingerprint2/lib/domain/interactor/CanEnrollFingerprintsInteractor.kt index 11a9258ed88..a5277a5e8a5 100644 --- a/src/com/android/settings/biometrics/fingerprint2/lib/domain/interactor/CanEnrollFingerprintsInteractor.kt +++ b/src/com/android/settings/biometrics/fingerprint2/lib/domain/interactor/CanEnrollFingerprintsInteractor.kt @@ -23,5 +23,17 @@ interface CanEnrollFingerprintsInteractor { /** Returns true if a user can enroll a fingerprint false otherwise. */ val canEnrollFingerprints: Flow /** Indicates the maximum fingerprints enrollable for a given user */ - fun maxFingerprintsEnrollable(): Int + val maxFingerprintsEnrollable: Flow + + /** + * Indicates if we should use the default settings for maximum enrollments or the sensor props + * from the fingerprint sensor. This can be useful if you are supporting HIDL & AIDL enrollment + * types from one code base. Prior to AIDL there was no way to determine how many + * fingerprints were enrollable, Settings relied on + * com.android.internal.R.integer.config_fingerprintMaxTemplatesPerUser. + * + * Typically Fingerprints with AIDL HAL's should not use this + * (setShouldUseSettingsMaxFingerprints(false)) + */ + fun setShouldUseSettingsMaxFingerprints(useSettings: Boolean) } diff --git a/src/com/android/settings/biometrics/fingerprint2/lib/domain/interactor/UserInteractor.kt b/src/com/android/settings/biometrics/fingerprint2/lib/domain/interactor/UserInteractor.kt new file mode 100644 index 00000000000..17b147a2f31 --- /dev/null +++ b/src/com/android/settings/biometrics/fingerprint2/lib/domain/interactor/UserInteractor.kt @@ -0,0 +1,31 @@ +/* + * 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.fingerprint2.lib.domain.interactor + +import kotlinx.coroutines.flow.Flow + +interface UserInteractor { + /** + * This flow indicates the current user. + */ + val currentUser: Flow + + /** + * Updates the current user. + */ + fun updateUser(user: Int) +} diff --git a/src/com/android/settings/biometrics/fingerprint2/ui/settings/viewmodel/FingerprintSettingsViewModel.kt b/src/com/android/settings/biometrics/fingerprint2/ui/settings/viewmodel/FingerprintSettingsViewModel.kt index c306c7870b9..7aad16dce75 100644 --- a/src/com/android/settings/biometrics/fingerprint2/ui/settings/viewmodel/FingerprintSettingsViewModel.kt +++ b/src/com/android/settings/biometrics/fingerprint2/ui/settings/viewmodel/FingerprintSettingsViewModel.kt @@ -43,7 +43,6 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.sample -import kotlinx.coroutines.flow.transform import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -72,10 +71,12 @@ class FingerprintSettingsViewModel( /** Represents the stream of the information of "Add Fingerprint" preference. */ val addFingerprintPrefInfo: Flow> = - _enrolledFingerprints.filterOnlyWhenSettingsIsShown().combine( - canEnrollFingerprintsInteractor.canEnrollFingerprints - ) { _, canEnrollFingerprints -> - Pair(canEnrollFingerprints, canEnrollFingerprintsInteractor.maxFingerprintsEnrollable()) + combine( + _enrolledFingerprints.filterOnlyWhenSettingsIsShown(), + canEnrollFingerprintsInteractor.canEnrollFingerprints, + canEnrollFingerprintsInteractor.maxFingerprintsEnrollable, + ) { _, canEnrollFingerprints, maxFingerprints -> + Pair(canEnrollFingerprints, maxFingerprints) } /** Represents the stream of visibility of sfps preference. */ diff --git a/tests/shared/src/com/android/settings/testutils2/FakeFingerprintManagerInteractor.kt b/tests/shared/src/com/android/settings/testutils2/FakeFingerprintManagerInteractor.kt index f61a3d3a02e..32ca2cdb59c 100644 --- a/tests/shared/src/com/android/settings/testutils2/FakeFingerprintManagerInteractor.kt +++ b/tests/shared/src/com/android/settings/testutils2/FakeFingerprintManagerInteractor.kt @@ -38,8 +38,12 @@ import com.android.systemui.biometrics.shared.model.FingerprintSensor import com.android.systemui.biometrics.shared.model.FingerprintSensorType import com.android.systemui.biometrics.shared.model.toFingerprintSensor import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.transform +import kotlinx.coroutines.flow.update /** Fake to be used by other classes to easily fake the FingerprintManager implementation. */ class FakeFingerprintManagerInteractor : @@ -52,7 +56,7 @@ class FakeFingerprintManagerInteractor : RenameFingerprintInteractor, SensorInteractor { - var enrollableFingerprints: Int = 5 + private val enrollableFingerprints = MutableStateFlow(5) var enrolledFingerprintsInternal: MutableList = mutableListOf() var challengeToGenerate: Pair = Pair(-1L, byteArrayOf()) var authenticateAttempt = FingerprintAuthAttemptModel.Success(1) @@ -82,13 +86,13 @@ class FakeFingerprintManagerInteractor : override val enrolledFingerprints: Flow> = flow { emit(enrolledFingerprintsInternal) } - override val canEnrollFingerprints: Flow = flow { - emit(enrolledFingerprintsInternal.size < enrollableFingerprints) + override val canEnrollFingerprints: Flow = enrollableFingerprints.transform { + emit(enrolledFingerprintsInternal.size < it) } - override fun maxFingerprintsEnrollable(): Int { - return enrollableFingerprints - } + override val maxFingerprintsEnrollable: Flow = enrollableFingerprints.asStateFlow() + + override fun setShouldUseSettingsMaxFingerprints(useSettings: Boolean) {} override val sensorPropertiesInternal: Flow = flow { emit(sensorProp) } override val hasSideFps: Flow = @@ -110,4 +114,7 @@ class FakeFingerprintManagerInteractor : } } + fun setMaxEnrollableFingerprints(fingerprints: Int) { + enrollableFingerprints.update { fingerprints } + } } diff --git a/tests/unit/src/com/android/settings/fingerprint2/domain/interactor/FingerprintManagerInteractorTest.kt b/tests/unit/src/com/android/settings/fingerprint2/domain/interactor/FingerprintManagerInteractorTest.kt index 691b6112bf7..2623206cddd 100644 --- a/tests/unit/src/com/android/settings/fingerprint2/domain/interactor/FingerprintManagerInteractorTest.kt +++ b/tests/unit/src/com/android/settings/fingerprint2/domain/interactor/FingerprintManagerInteractorTest.kt @@ -30,6 +30,7 @@ import android.hardware.fingerprint.FingerprintSensorPropertiesInternal import android.os.CancellationSignal import android.os.Handler import com.android.settings.biometrics.GatekeeperPasswordProvider +import com.android.settings.biometrics.fingerprint2.data.repository.FingerprintEnrollmentRepository import com.android.settings.biometrics.fingerprint2.data.repository.FingerprintEnrollmentRepositoryImpl import com.android.settings.biometrics.fingerprint2.data.repository.FingerprintSensorRepository import com.android.settings.biometrics.fingerprint2.data.repository.FingerprintSettingsRepositoryImpl @@ -61,7 +62,7 @@ import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.last +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestScope @@ -106,9 +107,14 @@ class FingerprintManagerInteractorTest { private val flow: FingerprintFlow = Default private val maxFingerprints = 5 private val currUser = MutableStateFlow(0) + private lateinit var fingerprintEnrollRepo: FingerprintEnrollmentRepository private val userRepo = object : UserRepo { override val currentUser: Flow = currUser + + override fun updateUser(user: Int) { + currUser.update { user } + } } @Before @@ -133,17 +139,18 @@ class FingerprintManagerInteractorTest { } val settingsRepository = FingerprintSettingsRepositoryImpl(maxFingerprints) - val fingerprintEnrollmentRepository = + fingerprintEnrollRepo = FingerprintEnrollmentRepositoryImpl( fingerprintManager, userRepo, settingsRepository, backgroundDispatcher, backgroundScope, + fingerprintSensorRepository, ) enrolledFingerprintsInteractorUnderTest = - EnrolledFingerprintsInteractorImpl(fingerprintManager, userId) + EnrolledFingerprintsInteractorImpl(fingerprintEnrollRepo) generateChallengeInteractorUnderTest = GenerateChallengeInteractorImpl(fingerprintManager, userId, gateKeeperPasswordProvider) removeFingerprintsInteractorUnderTest = @@ -153,7 +160,7 @@ class FingerprintManagerInteractorTest { authenticateInteractorImplUnderTest = AuthenticateInteractorImpl(fingerprintManager, userId) canEnrollFingerprintsInteractorUnderTest = - CanEnrollFingerprintsInteractorImpl(fingerprintEnrollmentRepository) + CanEnrollFingerprintsInteractorImpl(fingerprintEnrollRepo) enrollInteractorUnderTest = EnrollFingerprintInteractorImpl(userId, fingerprintManager, flow) } @@ -163,9 +170,16 @@ class FingerprintManagerInteractorTest { testScope.runTest { whenever(fingerprintManager.getEnrolledFingerprints(anyInt())).thenReturn(emptyList()) - val emptyFingerprintList: List = emptyList() - assertThat(enrolledFingerprintsInteractorUnderTest.enrolledFingerprints.last()) - .isEqualTo(emptyFingerprintList) + var list: List? = null + val job = + testScope.launch { + enrolledFingerprintsInteractorUnderTest.enrolledFingerprints.collect { list = it } + } + + runCurrent() + job.cancelAndJoin() + + assertThat(list!!.isEmpty()) } @Test @@ -174,10 +188,19 @@ class FingerprintManagerInteractorTest { val expected = Fingerprint("Finger 1,", 2, 3L) val fingerprintList: List = listOf(expected) whenever(fingerprintManager.getEnrolledFingerprints(anyInt())).thenReturn(fingerprintList) + // This causes the enrolled fingerprints to be updated + + var list: List? = null + val job = + testScope.launch { + enrolledFingerprintsInteractorUnderTest.enrolledFingerprints.collect { list = it } + } + + runCurrent() + job.cancelAndJoin() - val list = enrolledFingerprintsInteractorUnderTest.enrolledFingerprints.last() assertThat(list!!.size).isEqualTo(fingerprintList.size) - val actual = list[0] + val actual = list!![0] assertThat(actual.name).isEqualTo(expected.name) assertThat(actual.fingerId).isEqualTo(expected.biometricId) assertThat(actual.deviceId).isEqualTo(expected.deviceId) @@ -220,11 +243,7 @@ class FingerprintManagerInteractorTest { whenever(fingerprintManager.getEnrolledFingerprints(anyInt())).thenReturn(fingerprintList) var result: Boolean? = null - val job = - testScope.launch { - canEnrollFingerprintsInteractorUnderTest.canEnrollFingerprints.collect { result = it } - } - + val job = testScope.launch { fingerprintEnrollRepo.canEnrollUser.collect { result = it } } runCurrent() job.cancelAndJoin() diff --git a/tests/unit/src/com/android/settings/fingerprint2/ui/enrollment/viewmodel/FingerprintEnrollConfirmationViewModelTest.kt b/tests/unit/src/com/android/settings/fingerprint2/ui/enrollment/viewmodel/FingerprintEnrollConfirmationViewModelTest.kt index f59d1fcb820..a9ab5899e75 100644 --- a/tests/unit/src/com/android/settings/fingerprint2/ui/enrollment/viewmodel/FingerprintEnrollConfirmationViewModelTest.kt +++ b/tests/unit/src/com/android/settings/fingerprint2/ui/enrollment/viewmodel/FingerprintEnrollConfirmationViewModelTest.kt @@ -112,7 +112,7 @@ class FingerprintEnrollConfirmationViewModelTest { .toFingerprintSensor() fakeFingerprintManagerInteractor.enrolledFingerprintsInternal = mutableListOf() - fakeFingerprintManagerInteractor.enrollableFingerprints = 5 + fakeFingerprintManagerInteractor.setMaxEnrollableFingerprints(5) var canEnrollFingerprints: Boolean = false val job = launch { From 73ab82f52fc6ccc397f62a634d02b593a1f4506c Mon Sep 17 00:00:00 2001 From: Yi-an Chen Date: Wed, 18 Sep 2024 03:38:56 +0000 Subject: [PATCH 02/11] Correct behavior on Google Location History preference click Fixes: 366060896 Bug: 360240563 Flag: EXEMPT bugfix Test: Manually and RecentLocationAccessPreferenceControllerTest Change-Id: Ibe3c49c3060bcc0967f93a3a88aa45b04ab8a41d --- ...entLocationAccessPreferenceController.java | 36 +++++++++++++++---- ...ocationAccessPreferenceControllerTest.java | 25 ++++++++++++- 2 files changed, 53 insertions(+), 8 deletions(-) diff --git a/src/com/android/settings/location/RecentLocationAccessPreferenceController.java b/src/com/android/settings/location/RecentLocationAccessPreferenceController.java index 3cb30251c51..c93b450ba01 100644 --- a/src/com/android/settings/location/RecentLocationAccessPreferenceController.java +++ b/src/com/android/settings/location/RecentLocationAccessPreferenceController.java @@ -15,13 +15,16 @@ package com.android.settings.location; import static android.Manifest.permission_group.LOCATION; +import android.content.ActivityNotFoundException; import android.content.Context; import android.content.Intent; import android.icu.text.RelativeDateTimeFormatter; +import android.location.LocationManager; import android.os.UserHandle; import android.os.UserManager; import android.provider.DeviceConfig; import android.provider.Settings; +import android.util.Log; import androidx.annotation.VisibleForTesting; import androidx.preference.Preference; @@ -43,6 +46,8 @@ import java.util.List; * Preference controller that handles the display of apps that access locations. */ public class RecentLocationAccessPreferenceController extends LocationBasePreferenceController { + private static final String TAG = RecentLocationAccessPreferenceController.class + .getSimpleName(); public static final int MAX_APPS = 3; @VisibleForTesting RecentAppOpsAccess mRecentLocationApps; @@ -51,7 +56,8 @@ public class RecentLocationAccessPreferenceController extends LocationBasePrefer private boolean mShowSystem = false; private boolean mSystemSettingChanged = false; - private static class PackageEntryClickedListener implements + @VisibleForTesting + static class PackageEntryClickedListener implements Preference.OnPreferenceClickListener { private final Context mContext; private final String mPackage; @@ -66,12 +72,28 @@ public class RecentLocationAccessPreferenceController extends LocationBasePrefer @Override public boolean onPreferenceClick(Preference preference) { - final Intent intent = new Intent(Intent.ACTION_MANAGE_APP_PERMISSION); - intent.setPackage(mContext.getPackageManager().getPermissionControllerPackageName()); - intent.putExtra(Intent.EXTRA_PERMISSION_GROUP_NAME, LOCATION); - intent.putExtra(Intent.EXTRA_PACKAGE_NAME, mPackage); - intent.putExtra(Intent.EXTRA_USER, mUserHandle); - mContext.startActivity(intent); + if (mPackage.equals(mContext.getSystemService(LocationManager.class) + .getExtraLocationControllerPackage())) { + try { + mContext.startActivityAsUser( + new Intent(Settings.ACTION_LOCATION_CONTROLLER_EXTRA_PACKAGE_SETTINGS), + mUserHandle); + } catch (ActivityNotFoundException e) { + // In rare cases where location controller extra package is set, but + // no activity exists to handle the location controller extra package settings + // intent, log an error instead of crashing. + Log.e(TAG, "No activity to handle " + + "android.settings.LOCATION_CONTROLLER_EXTRA_PACKAGE_SETTINGS"); + } + } else { + final Intent intent = new Intent(Intent.ACTION_MANAGE_APP_PERMISSION); + intent.setPackage(mContext.getPackageManager() + .getPermissionControllerPackageName()); + intent.putExtra(Intent.EXTRA_PERMISSION_GROUP_NAME, LOCATION); + intent.putExtra(Intent.EXTRA_PACKAGE_NAME, mPackage); + intent.putExtra(Intent.EXTRA_USER, mUserHandle); + mContext.startActivity(intent); + } return true; } } diff --git a/tests/robotests/src/com/android/settings/location/RecentLocationAccessPreferenceControllerTest.java b/tests/robotests/src/com/android/settings/location/RecentLocationAccessPreferenceControllerTest.java index e9284ee5b57..7673f38bccf 100644 --- a/tests/robotests/src/com/android/settings/location/RecentLocationAccessPreferenceControllerTest.java +++ b/tests/robotests/src/com/android/settings/location/RecentLocationAccessPreferenceControllerTest.java @@ -17,12 +17,15 @@ package com.android.settings.location; import static com.google.common.truth.Truth.assertThat; +import static org.mockito.Mockito.doNothing; 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.content.Intent; +import android.location.LocationManager; import android.os.UserHandle; import android.provider.Settings; import android.view.LayoutInflater; @@ -65,7 +68,8 @@ public class RecentLocationAccessPreferenceControllerTest { private DashboardFragment mDashboardFragment; @Mock private RecentAppOpsAccess mRecentLocationApps; - + @Mock + private LocationManager mLocationManager; private Context mContext; private RecentLocationAccessPreferenceController mController; private View mAppEntitiesHeaderView; @@ -130,4 +134,23 @@ public class RecentLocationAccessPreferenceControllerTest { mContext.getContentResolver(), Settings.Secure.LOCATION_SHOW_SYSTEM_OPS, 1); verify(mLayoutPreference, Mockito.times(1)).addPreference(Mockito.any()); } + + @Test + public void testPreferenceClick_onExtraLocationPackage_startsExtraLocationActivity() { + String extraLocationPkgName = "extraLocationPkgName"; + when(mContext.getSystemService(LocationManager.class)).thenReturn(mLocationManager); + when(mLocationManager.getExtraLocationControllerPackage()).thenReturn(extraLocationPkgName); + RecentLocationAccessPreferenceController.PackageEntryClickedListener listener = + new RecentLocationAccessPreferenceController.PackageEntryClickedListener( + mContext, extraLocationPkgName, UserHandle.CURRENT); + doNothing().when(mContext).startActivityAsUser(Mockito.refEq(new Intent( + Settings.ACTION_LOCATION_CONTROLLER_EXTRA_PACKAGE_SETTINGS)), + Mockito.eq(UserHandle.CURRENT)); + + listener.onPreferenceClick(mLayoutPreference); + + verify(mContext).startActivityAsUser(Mockito.refEq(new Intent( + Settings.ACTION_LOCATION_CONTROLLER_EXTRA_PACKAGE_SETTINGS)), + Mockito.eq(UserHandle.CURRENT)); + } } From 57355a0743c9122cfea6b3f71c7187f8c54749bb Mon Sep 17 00:00:00 2001 From: Yiyi Shen Date: Wed, 25 Sep 2024 14:15:13 +0800 Subject: [PATCH 03/11] [Audiosharing] Show dialogs when lifecycle isAtLeast STARTED Test: atest Flag: com.android.settingslib.flags.enable_le_audio_sharing Bug: 305620450 Change-Id: I34f4f46b9377f1e3ec1a4cd27687c14d674f6da4 --- .../audiosharing/AudioSharingDialogFragment.java | 6 ++++++ .../audiosharing/AudioSharingDisconnectDialogFragment.java | 6 ++++++ .../audiosharing/AudioSharingErrorDialogFragment.java | 6 ++++++ .../AudioSharingIncompatibleDialogFragment.java | 6 ++++++ .../audiosharing/AudioSharingJoinDialogFragment.java | 6 ++++++ .../audiosharing/AudioSharingProgressDialogFragment.java | 6 ++++++ .../audiosharing/AudioSharingStopDialogFragment.java | 6 ++++++ .../audiosharing/AudioSharingSwitchBarController.java | 2 +- 8 files changed, 43 insertions(+), 1 deletion(-) diff --git a/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDialogFragment.java b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDialogFragment.java index 1b68eaccbfe..54a758c82d3 100644 --- a/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDialogFragment.java +++ b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDialogFragment.java @@ -31,6 +31,7 @@ import androidx.annotation.VisibleForTesting; import androidx.appcompat.app.AlertDialog; import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentManager; +import androidx.lifecycle.Lifecycle; import com.android.settings.R; import com.android.settings.bluetooth.BluetoothPairingDetail; @@ -95,6 +96,11 @@ public class AudioSharingDialogFragment extends InstrumentedDialogFragment { Log.d(TAG, "Fail to show dialog: " + e.getMessage()); return; } + Lifecycle.State currentState = host.getLifecycle().getCurrentState(); + if (!currentState.isAtLeast(Lifecycle.State.STARTED)) { + Log.d(TAG, "Fail to show dialog with state: " + currentState); + return; + } sHost = host; sListener = listener; sEventData = eventData; diff --git a/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDisconnectDialogFragment.java b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDisconnectDialogFragment.java index 7d9164449aa..fbd2e635f82 100644 --- a/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDisconnectDialogFragment.java +++ b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDisconnectDialogFragment.java @@ -28,6 +28,7 @@ import androidx.annotation.VisibleForTesting; import androidx.appcompat.app.AlertDialog; import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentManager; +import androidx.lifecycle.Lifecycle; import com.android.settings.R; import com.android.settings.core.instrumentation.InstrumentedDialogFragment; @@ -92,6 +93,11 @@ public class AudioSharingDisconnectDialogFragment extends InstrumentedDialogFrag Log.d(TAG, "Fail to show dialog: " + e.getMessage()); return; } + Lifecycle.State currentState = host.getLifecycle().getCurrentState(); + if (!currentState.isAtLeast(Lifecycle.State.STARTED)) { + Log.d(TAG, "Fail to show dialog with state: " + currentState); + return; + } AlertDialog dialog = AudioSharingDialogHelper.getDialogIfShowing(manager, TAG); if (dialog != null) { int newGroupId = BluetoothUtils.getGroupId(newDevice); diff --git a/src/com/android/settings/connecteddevice/audiosharing/AudioSharingErrorDialogFragment.java b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingErrorDialogFragment.java index 95b9bc36b92..b20ce6ad2fc 100644 --- a/src/com/android/settings/connecteddevice/audiosharing/AudioSharingErrorDialogFragment.java +++ b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingErrorDialogFragment.java @@ -25,6 +25,7 @@ import androidx.annotation.Nullable; import androidx.appcompat.app.AlertDialog; import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentManager; +import androidx.lifecycle.Lifecycle; import com.android.settings.core.instrumentation.InstrumentedDialogFragment; import com.android.settingslib.bluetooth.BluetoothUtils; @@ -52,6 +53,11 @@ public class AudioSharingErrorDialogFragment extends InstrumentedDialogFragment Log.d(TAG, "Fail to show dialog: " + e.getMessage()); return; } + Lifecycle.State currentState = host.getLifecycle().getCurrentState(); + if (!currentState.isAtLeast(Lifecycle.State.STARTED)) { + Log.d(TAG, "Fail to show dialog with state: " + currentState); + return; + } AlertDialog dialog = AudioSharingDialogHelper.getDialogIfShowing(manager, TAG); if (dialog != null) { Log.d(TAG, "Dialog is showing, return."); diff --git a/src/com/android/settings/connecteddevice/audiosharing/AudioSharingIncompatibleDialogFragment.java b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingIncompatibleDialogFragment.java index aceeb94420e..af4e3064bf8 100644 --- a/src/com/android/settings/connecteddevice/audiosharing/AudioSharingIncompatibleDialogFragment.java +++ b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingIncompatibleDialogFragment.java @@ -26,6 +26,7 @@ import androidx.annotation.Nullable; import androidx.appcompat.app.AlertDialog; import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentManager; +import androidx.lifecycle.Lifecycle; import com.android.settings.core.instrumentation.InstrumentedDialogFragment; import com.android.settingslib.bluetooth.BluetoothUtils; @@ -68,6 +69,11 @@ public class AudioSharingIncompatibleDialogFragment extends InstrumentedDialogFr Log.d(TAG, "Fail to show dialog: " + e.getMessage()); return; } + Lifecycle.State currentState = host.getLifecycle().getCurrentState(); + if (!currentState.isAtLeast(Lifecycle.State.STARTED)) { + Log.d(TAG, "Fail to show dialog with state: " + currentState); + return; + } sListener = listener; AlertDialog dialog = AudioSharingDialogHelper.getDialogIfShowing(manager, TAG); if (dialog != null) { diff --git a/src/com/android/settings/connecteddevice/audiosharing/AudioSharingJoinDialogFragment.java b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingJoinDialogFragment.java index ef461ebf1a0..a952c488156 100644 --- a/src/com/android/settings/connecteddevice/audiosharing/AudioSharingJoinDialogFragment.java +++ b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingJoinDialogFragment.java @@ -28,6 +28,7 @@ import androidx.annotation.VisibleForTesting; import androidx.appcompat.app.AlertDialog; import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentManager; +import androidx.lifecycle.Lifecycle; import com.android.settings.R; import com.android.settings.bluetooth.Utils; @@ -89,6 +90,11 @@ public class AudioSharingJoinDialogFragment extends InstrumentedDialogFragment { Log.d(TAG, "Fail to show dialog: " + e.getMessage()); return; } + Lifecycle.State currentState = host.getLifecycle().getCurrentState(); + if (!currentState.isAtLeast(Lifecycle.State.STARTED)) { + Log.d(TAG, "Fail to show dialog with state: " + currentState); + return; + } sListener = listener; sNewDevice = newDevice; sEventData = eventData; diff --git a/src/com/android/settings/connecteddevice/audiosharing/AudioSharingProgressDialogFragment.java b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingProgressDialogFragment.java index 53bfcf8f17c..9c8ddc7e21a 100644 --- a/src/com/android/settings/connecteddevice/audiosharing/AudioSharingProgressDialogFragment.java +++ b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingProgressDialogFragment.java @@ -31,6 +31,7 @@ import androidx.annotation.Nullable; import androidx.appcompat.app.AlertDialog; import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentManager; +import androidx.lifecycle.Lifecycle; import com.android.settings.R; import com.android.settings.core.instrumentation.InstrumentedDialogFragment; @@ -72,6 +73,11 @@ public class AudioSharingProgressDialogFragment extends InstrumentedDialogFragme Log.d(TAG, "Fail to show dialog: " + e.getMessage()); return; } + Lifecycle.State currentState = host.getLifecycle().getCurrentState(); + if (!currentState.isAtLeast(Lifecycle.State.STARTED)) { + Log.d(TAG, "Fail to show dialog with state: " + currentState); + return; + } AlertDialog dialog = AudioSharingDialogHelper.getDialogIfShowing(manager, TAG); if (dialog != null) { if (!sMessage.equals(message)) { diff --git a/src/com/android/settings/connecteddevice/audiosharing/AudioSharingStopDialogFragment.java b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingStopDialogFragment.java index 5b71f5163e9..2bd79c942bb 100644 --- a/src/com/android/settings/connecteddevice/audiosharing/AudioSharingStopDialogFragment.java +++ b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingStopDialogFragment.java @@ -28,6 +28,7 @@ import androidx.annotation.VisibleForTesting; import androidx.appcompat.app.AlertDialog; import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentManager; +import androidx.lifecycle.Lifecycle; import com.android.settings.R; import com.android.settings.core.instrumentation.InstrumentedDialogFragment; @@ -89,6 +90,11 @@ public class AudioSharingStopDialogFragment extends InstrumentedDialogFragment { Log.d(TAG, "Fail to show dialog: " + e.getMessage()); return; } + Lifecycle.State currentState = host.getLifecycle().getCurrentState(); + if (!currentState.isAtLeast(Lifecycle.State.STARTED)) { + Log.d(TAG, "Fail to show dialog with state: " + currentState); + return; + } AlertDialog dialog = AudioSharingDialogHelper.getDialogIfShowing(manager, TAG); if (dialog != null) { int newGroupId = BluetoothUtils.getGroupId(newDevice); diff --git a/src/com/android/settings/connecteddevice/audiosharing/AudioSharingSwitchBarController.java b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingSwitchBarController.java index ebc8cecadbf..49839492e43 100644 --- a/src/com/android/settings/connecteddevice/audiosharing/AudioSharingSwitchBarController.java +++ b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingSwitchBarController.java @@ -767,7 +767,7 @@ public class AudioSharingSwitchBarController extends BasePreferenceController && !(fragment instanceof AudioSharingErrorDialogFragment) && ((DialogFragment) fragment).getDialog() != null) { Log.d(TAG, "Remove stale dialog = " + fragment.getTag()); - ((DialogFragment) fragment).dismiss(); + ((DialogFragment) fragment).dismissAllowingStateLoss(); } } } From 54b0d18a047bb9cfc566eb7ba55cee173ccfebe3 Mon Sep 17 00:00:00 2001 From: Yiyi Shen Date: Tue, 24 Sep 2024 17:14:10 +0800 Subject: [PATCH 04/11] [Audiosharing] Use DialogFragment instead of raw AlertDialog FragmentManager can help manage the state of the dialog and automatically restore the dialog when a configuration change occurs Test: atest Flag: com.android.settingslib.flags.enable_le_audio_sharing Bug: 362858921 Change-Id: If63c7891cfb92e06c457e37eb5556f3eaf3f6121 --- .../BluetoothDevicePairingDetailBase.java | 35 +---- .../bluetooth/ProgressDialogFragment.java | 133 +++++++++++++++++ .../AudioSharingProgressDialogFragment.java | 1 + .../BluetoothDevicePairingDetailBaseTest.java | 78 ++++++++-- .../bluetooth/ProgressDialogFragmentTest.java | 140 ++++++++++++++++++ 5 files changed, 351 insertions(+), 36 deletions(-) create mode 100644 src/com/android/settings/bluetooth/ProgressDialogFragment.java create mode 100644 tests/robotests/src/com/android/settings/bluetooth/ProgressDialogFragmentTest.java diff --git a/src/com/android/settings/bluetooth/BluetoothDevicePairingDetailBase.java b/src/com/android/settings/bluetooth/BluetoothDevicePairingDetailBase.java index 2c65934dd72..387bf837ce4 100644 --- a/src/com/android/settings/bluetooth/BluetoothDevicePairingDetailBase.java +++ b/src/com/android/settings/bluetooth/BluetoothDevicePairingDetailBase.java @@ -31,15 +31,12 @@ import android.os.Handler; import android.os.Looper; import android.text.TextUtils; import android.util.Log; -import android.view.LayoutInflater; import android.view.View; -import android.widget.TextView; import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; -import androidx.appcompat.app.AlertDialog; import com.android.settings.R; import com.android.settings.SettingsActivity; @@ -71,8 +68,9 @@ public abstract class BluetoothDevicePairingDetailBase extends DeviceListPrefere private volatile BluetoothDevice mJustBonded = null; private final Handler mHandler = new Handler(Looper.getMainLooper()); private final ExecutorService mExecutor = Executors.newSingleThreadExecutor(); + @VisibleForTesting @Nullable - private AlertDialog mProgressDialog = null; + ProgressDialogFragment mProgressDialog = null; @VisibleForTesting boolean mShouldTriggerAudioSharingShareThenPairFlow = false; private CopyOnWriteArrayList mDevicesWithMetadataChangedListener = @@ -384,41 +382,24 @@ public abstract class BluetoothDevicePairingDetailBase extends DeviceListPrefere finish(); } - // TODO: use DialogFragment private void showConnectingDialog(@NonNull String deviceName) { postOnMainThread(() -> { String message = getContext().getString(R.string.progress_dialog_connect_device_content, deviceName); + if (mProgressDialog == null) { + mProgressDialog = ProgressDialogFragment.newInstance(this); + } if (mProgressDialog != null) { - Log.d(getLogTag(), "showConnectingDialog, is already showing"); - TextView textView = mProgressDialog.findViewById(R.id.message); - if (textView != null && !message.equals(textView.getText().toString())) { - Log.d(getLogTag(), "showConnectingDialog, update message"); - textView.setText(message); - } - return; + mProgressDialog.show(message); } - Log.d(getLogTag(), "showConnectingDialog, show dialog"); - AlertDialog.Builder builder = new AlertDialog.Builder(getActivity()); - LayoutInflater inflater = LayoutInflater.from(builder.getContext()); - View customView = inflater.inflate( - R.layout.dialog_audio_sharing_progress, /* root= */ - null); - TextView textView = customView.findViewById(R.id.message); - if (textView != null) { - textView.setText(message); - } - AlertDialog dialog = builder.setView(customView).setCancelable(false).create(); - dialog.setCanceledOnTouchOutside(false); - mProgressDialog = dialog; - dialog.show(); }); } private void dismissConnectingDialog() { postOnMainThread(() -> { if (mProgressDialog != null) { - mProgressDialog.dismiss(); + Log.d(getLogTag(), "Dismiss connecting dialog."); + mProgressDialog.dismissAllowingStateLoss(); } }); } diff --git a/src/com/android/settings/bluetooth/ProgressDialogFragment.java b/src/com/android/settings/bluetooth/ProgressDialogFragment.java new file mode 100644 index 00000000000..15d53299e42 --- /dev/null +++ b/src/com/android/settings/bluetooth/ProgressDialogFragment.java @@ -0,0 +1,133 @@ +/* + * 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.bluetooth; + +import android.app.Dialog; +import android.os.Bundle; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; +import androidx.appcompat.app.AlertDialog; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentManager; +import androidx.lifecycle.Lifecycle; + +import com.android.settings.R; +import com.android.settings.core.instrumentation.InstrumentedDialogFragment; + +import com.google.common.base.Strings; + +public class ProgressDialogFragment extends InstrumentedDialogFragment { + private static final String TAG = "BTProgressDialog"; + + private static final String BUNDLE_KEY_MESSAGE = "bundle_key_message"; + + @Nullable private static FragmentManager sManager; + @Nullable private static Lifecycle sLifecycle; + private String mMessage = ""; + @Nullable private AlertDialog mAlertDialog; + + @Override + public int getMetricsCategory() { + // TODO: add metrics + return 0; + } + + /** + * Returns a new instance of {@link ProgressDialogFragment} dialog. + * + * @param host The Fragment this dialog will be hosted. + */ + @Nullable + public static ProgressDialogFragment newInstance(@Nullable Fragment host) { + if (host == null) return null; + try { + sManager = host.getChildFragmentManager(); + sLifecycle = host.getLifecycle(); + } catch (IllegalStateException e) { + Log.d(TAG, "Fail to create new instance: " + e.getMessage()); + return null; + } + return new ProgressDialogFragment(); + } + + /** + * Display {@link ProgressDialogFragment} dialog. + * + * @param message The message to be shown on the dialog + */ + public void show(@NonNull String message) { + if (sManager == null) return; + Lifecycle.State currentState = sLifecycle == null ? null : sLifecycle.getCurrentState(); + if (currentState == null || !currentState.isAtLeast(Lifecycle.State.STARTED)) { + Log.d(TAG, "Fail to show dialog with state: " + currentState); + return; + } + if (mAlertDialog != null && mAlertDialog.isShowing()) { + if (!mMessage.equals(message)) { + Log.d(TAG, "Update dialog message."); + TextView messageView = mAlertDialog.findViewById(R.id.message); + if (messageView != null) { + messageView.setText(message); + } + mMessage = message; + } + Log.d(TAG, "Dialog is showing, return."); + return; + } + mMessage = message; + Log.d(TAG, "Show up the progress dialog."); + Bundle args = new Bundle(); + args.putString(BUNDLE_KEY_MESSAGE, message); + setArguments(args); + show(sManager, TAG); + } + + /** Returns the current message on the dialog. */ + @VisibleForTesting + @NonNull + public String getMessage() { + return mMessage; + } + + private ProgressDialogFragment() { + } + + @Override + @NonNull + public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) { + Bundle args = requireArguments(); + String message = args.getString(BUNDLE_KEY_MESSAGE, ""); + AlertDialog.Builder builder = new AlertDialog.Builder(getActivity()); + LayoutInflater inflater = LayoutInflater.from(builder.getContext()); + View customView = inflater.inflate( + R.layout.dialog_audio_sharing_progress, /* root= */ null); + TextView textView = customView.findViewById(R.id.message); + if (textView != null && !Strings.isNullOrEmpty(message)) { + textView.setText(message); + } + AlertDialog dialog = builder.setView(customView).setCancelable(false).create(); + dialog.setCanceledOnTouchOutside(false); + mAlertDialog = dialog; + return dialog; + } +} diff --git a/src/com/android/settings/connecteddevice/audiosharing/AudioSharingProgressDialogFragment.java b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingProgressDialogFragment.java index 53bfcf8f17c..a0cb6536570 100644 --- a/src/com/android/settings/connecteddevice/audiosharing/AudioSharingProgressDialogFragment.java +++ b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingProgressDialogFragment.java @@ -80,6 +80,7 @@ public class AudioSharingProgressDialogFragment extends InstrumentedDialogFragme if (messageView != null) { messageView.setText(message); } + sMessage = message; } Log.d(TAG, "Dialog is showing, return."); return; diff --git a/tests/robotests/src/com/android/settings/bluetooth/BluetoothDevicePairingDetailBaseTest.java b/tests/robotests/src/com/android/settings/bluetooth/BluetoothDevicePairingDetailBaseTest.java index 9f0cb6e35d9..949b3d83809 100644 --- a/tests/robotests/src/com/android/settings/bluetooth/BluetoothDevicePairingDetailBaseTest.java +++ b/tests/robotests/src/com/android/settings/bluetooth/BluetoothDevicePairingDetailBaseTest.java @@ -46,17 +46,21 @@ import android.os.Bundle; import android.os.Looper; import android.platform.test.flag.junit.SetFlagsRule; import android.util.Pair; -import android.widget.TextView; -import androidx.appcompat.app.AlertDialog; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.DialogFragment; import androidx.fragment.app.FragmentActivity; import androidx.fragment.app.FragmentManager; +import androidx.fragment.app.FragmentTransaction; +import androidx.lifecycle.Lifecycle; import androidx.test.core.app.ApplicationProvider; import com.android.settings.R; import com.android.settings.SettingsActivity; import com.android.settings.testutils.shadow.ShadowAlertDialogCompat; import com.android.settings.testutils.shadow.ShadowBluetoothAdapter; +import com.android.settings.testutils.shadow.ShadowFragment; import com.android.settingslib.bluetooth.CachedBluetoothDevice; import com.android.settingslib.bluetooth.LocalBluetoothManager; import com.android.settingslib.flags.Flags; @@ -73,8 +77,14 @@ import org.mockito.junit.MockitoRule; import org.robolectric.Robolectric; import org.robolectric.RobolectricTestRunner; import org.robolectric.annotation.Config; +import org.robolectric.annotation.Implementation; +import org.robolectric.annotation.Implements; +import org.robolectric.annotation.RealObject; +import org.robolectric.annotation.Resetter; import org.robolectric.shadow.api.Shadow; +import java.util.HashMap; +import java.util.Map; import java.util.concurrent.Executor; /** Tests for {@link BluetoothDevicePairingDetailBase}. */ @@ -82,7 +92,7 @@ import java.util.concurrent.Executor; @Config(shadows = { ShadowBluetoothAdapter.class, ShadowAlertDialogCompat.class, - com.android.settings.testutils.shadow.ShadowFragment.class, + ShadowFragment.class, }) public class BluetoothDevicePairingDetailBaseTest { @@ -133,7 +143,6 @@ public class BluetoothDevicePairingDetailBaseTest { mFragment.mLocalManager = mLocalManager; mFragment.mBluetoothAdapter = mBluetoothAdapter; mFragment.initPreferencesFromPreferenceScreen(); - } @Test @@ -199,22 +208,26 @@ public class BluetoothDevicePairingDetailBaseTest { } @Test + @Config(shadows = ShadowDialogFragment.class) public void onDeviceBondStateChanged_bonded_pairAndJoinSharingEnabled_handle() { mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING); + ShadowDialogFragment.reset(); when(mCachedBluetoothDevice.getDevice()).thenReturn(mBluetoothDevice); mFragment.mSelectedList.add(mBluetoothDevice); setUpFragmentWithPairAndJoinSharingIntent(true); mFragment.onDeviceBondStateChanged(mCachedBluetoothDevice, BluetoothDevice.BOND_BONDED); shadowOf(Looper.getMainLooper()).idle(); - AlertDialog dialog = ShadowAlertDialogCompat.getLatestAlertDialog(); - assertThat(dialog).isNotNull(); - TextView message = dialog.findViewById(R.id.message); - assertThat(message).isNotNull(); - assertThat(message.getText().toString()).isEqualTo( + ProgressDialogFragment progressDialog = mFragment.mProgressDialog; + assertThat(progressDialog).isNotNull(); + assertThat(progressDialog.getMessage()).isEqualTo( mContext.getString(R.string.progress_dialog_connect_device_content, TEST_DEVICE_ADDRESS)); + assertThat( + ShadowDialogFragment.isIsShowing(ProgressDialogFragment.class.getName())).isTrue(); verify(mFragment, never()).finish(); + + ShadowDialogFragment.reset(); } @Test @@ -283,9 +296,11 @@ public class BluetoothDevicePairingDetailBaseTest { } @Test + @Config(shadows = ShadowDialogFragment.class) public void onProfileConnectionStateChanged_deviceInSelectedListAndConnected_pairAndJoinSharing() { mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING); + ShadowDialogFragment.reset(); when(mCachedBluetoothDevice.getDevice()).thenReturn(mBluetoothDevice); mFragment.mSelectedList.add(mBluetoothDevice); setUpFragmentWithPairAndJoinSharingIntent(true); @@ -309,6 +324,8 @@ public class BluetoothDevicePairingDetailBaseTest { assertThat(btDevice).isNotNull(); assertThat(btDevice).isEqualTo(mBluetoothDevice); verify(mFragment).finish(); + + ShadowDialogFragment.reset(); } @Test @@ -393,7 +410,13 @@ public class BluetoothDevicePairingDetailBaseTest { doReturn(intent).when(activity).getIntent(); doReturn(activity).when(mFragment).getActivity(); FragmentManager fragmentManager = mock(FragmentManager.class); + FragmentTransaction fragmentTransaction = mock(FragmentTransaction.class); + doReturn(fragmentTransaction).when(fragmentManager).beginTransaction(); doReturn(fragmentManager).when(mFragment).getFragmentManager(); + doReturn(fragmentManager).when(mFragment).getChildFragmentManager(); + Lifecycle lifecycle = mock(Lifecycle.class); + when(lifecycle.getCurrentState()).thenReturn(Lifecycle.State.RESUMED); + doReturn(lifecycle).when(mFragment).getLifecycle(); mFragment.mShouldTriggerAudioSharingShareThenPairFlow = mFragment.shouldTriggerAudioSharingShareThenPairFlow(); } @@ -425,4 +448,41 @@ public class BluetoothDevicePairingDetailBaseTest { return "test_tag"; } } + + /** Shadow of DialogFragment. */ + @Implements(value = DialogFragment.class) + public static class ShadowDialogFragment { + @RealObject + private DialogFragment mDialogFragment; + private static Map sDialogStatus = new HashMap<>(); + + /** Resetter of the shadow. */ + @Resetter + public static void reset() { + sDialogStatus.clear(); + } + + /** Implementation for DialogFragment#show. */ + @Implementation + public void show(@NonNull FragmentManager manager, @Nullable String tag) { + sDialogStatus.put(mDialogFragment.getClass().getName(), true); + } + + /** Implementation for DialogFragment#dismissAllowingStateLoss. */ + @Implementation + public void dismissAllowingStateLoss() { + sDialogStatus.put(mDialogFragment.getClass().getName(), false); + } + + /** Implementation for DialogFragment#dismiss. */ + @Implementation + public void dismiss() { + sDialogStatus.put(mDialogFragment.getClass().getName(), false); + } + + /** Check if DialogFragment is showing. */ + public static boolean isIsShowing(String clazzName) { + return sDialogStatus.getOrDefault(clazzName, false); + } + } } diff --git a/tests/robotests/src/com/android/settings/bluetooth/ProgressDialogFragmentTest.java b/tests/robotests/src/com/android/settings/bluetooth/ProgressDialogFragmentTest.java new file mode 100644 index 00000000000..74687767b47 --- /dev/null +++ b/tests/robotests/src/com/android/settings/bluetooth/ProgressDialogFragmentTest.java @@ -0,0 +1,140 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.settings.bluetooth; + +import static com.google.common.truth.Truth.assertThat; + +import static org.robolectric.shadows.ShadowLooper.shadowMainLooper; + +import android.widget.TextView; + +import androidx.appcompat.app.AlertDialog; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentActivity; + +import com.android.settings.R; +import com.android.settings.testutils.shadow.ShadowAlertDialogCompat; + +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; +import org.robolectric.shadows.androidx.fragment.FragmentController; + +@RunWith(RobolectricTestRunner.class) +@Config(shadows = {ShadowAlertDialogCompat.class}) +public class ProgressDialogFragmentTest { + @Rule public final MockitoRule mocks = MockitoJUnit.rule(); + + private static final String TEST_MESSAGE1 = "message1"; + private static final String TEST_MESSAGE2 = "message2"; + + private Fragment mParent; + + @Before + public void setUp() { + ShadowAlertDialogCompat.reset(); + mParent = new Fragment(); + FragmentController.setupFragment( + mParent, FragmentActivity.class, /* containerViewId= */ 0, /* bundle= */ null); + } + + @After + public void tearDown() { + ShadowAlertDialogCompat.reset(); + } + + @Test + public void getMetricsCategory_correctValue() { + ProgressDialogFragment fragment = ProgressDialogFragment.newInstance(mParent); + // TODO: update real metric + assertThat(fragment.getMetricsCategory()).isEqualTo(0); + } + + @Test + public void onCreateDialog_unattachedFragment_nullDialogFragment() { + ProgressDialogFragment fragment = ProgressDialogFragment.newInstance(new Fragment()); + assertThat(fragment).isNull(); + } + + @Test + public void onCreateDialog_showDialog() { + ProgressDialogFragment fragment = ProgressDialogFragment.newInstance(mParent); + fragment.show(TEST_MESSAGE1); + shadowMainLooper().idle(); + AlertDialog dialog = ShadowAlertDialogCompat.getLatestAlertDialog(); + assertThat(dialog).isNotNull(); + assertThat(dialog.isShowing()).isTrue(); + TextView view = dialog.findViewById(R.id.message); + assertThat(view).isNotNull(); + assertThat(view.getText().toString()).isEqualTo(TEST_MESSAGE1); + } + + @Test + public void dismissDialog_succeed() { + ProgressDialogFragment fragment = ProgressDialogFragment.newInstance(mParent); + fragment.show(TEST_MESSAGE1); + shadowMainLooper().idle(); + AlertDialog dialog = ShadowAlertDialogCompat.getLatestAlertDialog(); + assertThat(dialog).isNotNull(); + assertThat(dialog.isShowing()).isTrue(); + + fragment.dismissAllowingStateLoss(); + shadowMainLooper().idle(); + assertThat(dialog.isShowing()).isFalse(); + } + + @Test + public void showDialog_sameMessage_keepExistingDialog() { + ProgressDialogFragment fragment = ProgressDialogFragment.newInstance(mParent); + fragment.show(TEST_MESSAGE1); + shadowMainLooper().idle(); + AlertDialog dialog = ShadowAlertDialogCompat.getLatestAlertDialog(); + assertThat(dialog).isNotNull(); + assertThat(dialog.isShowing()).isTrue(); + + fragment.show(TEST_MESSAGE1); + shadowMainLooper().idle(); + assertThat(dialog.isShowing()).isTrue(); + TextView view = dialog.findViewById(R.id.message); + assertThat(view).isNotNull(); + assertThat(view.getText().toString()).isEqualTo(TEST_MESSAGE1); + } + + @Test + public void showDialog_newMessage_keepAndUpdateDialog() { + ProgressDialogFragment fragment = ProgressDialogFragment.newInstance(mParent); + fragment.show(TEST_MESSAGE1); + shadowMainLooper().idle(); + AlertDialog dialog = ShadowAlertDialogCompat.getLatestAlertDialog(); + assertThat(dialog).isNotNull(); + assertThat(dialog.isShowing()).isTrue(); + TextView view = dialog.findViewById(R.id.message); + assertThat(view).isNotNull(); + assertThat(view.getText().toString()).isEqualTo(TEST_MESSAGE1); + + fragment.show(TEST_MESSAGE2); + shadowMainLooper().idle(); + assertThat(dialog.isShowing()).isTrue(); + assertThat(view.getText().toString()).isEqualTo(TEST_MESSAGE2); + } +} From 91dd9af87f723a84c5f93c45b8c82f9cbf7cb114 Mon Sep 17 00:00:00 2001 From: Jason Chang Date: Wed, 25 Sep 2024 15:01:24 +0000 Subject: [PATCH 05/11] Fix Fingerprint setup complete - illustration is just slightly cut off Due to the sufficient space between sub-description and illustration, modify the layout_marginTop to avoid the cut off problem. Flag: NONE bug-fixing Bug: 336981217 Test: build ABTD ROM and perform a visual inspection. Change-Id: Id69549968341ee59e3cb7ee8838a90fda7a78b3a --- res/layout/sfps_enroll_finish_base.xml | 2 +- res/values/dimens.xml | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/res/layout/sfps_enroll_finish_base.xml b/res/layout/sfps_enroll_finish_base.xml index 9e65c833f03..768fe346d9f 100644 --- a/res/layout/sfps_enroll_finish_base.xml +++ b/res/layout/sfps_enroll_finish_base.xml @@ -35,7 +35,7 @@ android:id="@+id/sfps_enrollment_finish_content_layout" android:layout_width="@dimen/sfps_enrollment_finished_icon_max_size" android:layout_height="@dimen/sfps_enrollment_finished_icon_max_size" - android:layout_marginTop="24dp" + android:layout_marginTop="@dimen/sfps_enroll_finish_icon_margin_top" android:paddingTop="0dp" android:paddingBottom="0dp" android:layout_gravity="center"> diff --git a/res/values/dimens.xml b/res/values/dimens.xml index c9a67e462bb..5961b95a606 100755 --- a/res/values/dimens.xml +++ b/res/values/dimens.xml @@ -156,6 +156,7 @@ 2dp 12dp 12dp + -24dp 0dp 20dp From 2f39a808fcdff8fddfe662be625c2ed3f1def5f9 Mon Sep 17 00:00:00 2001 From: David Liu Date: Wed, 18 Sep 2024 01:00:07 +0000 Subject: [PATCH 06/11] Fixed elapsed_time_millis in SettingsUIChanged event This change stores timestamp when received com.google.android.setupwizard.SETUP_WIZARD_FINISHED. This timestamp will be used to calculate elapsed_time_millis for SettingsUIChanged event after SUW complete. This enables to analyze how the user uses Settings in a specific time span after setup. Bug: 344466251 Test: metrics related change only Flag: EXEMPT metrics change only Change-Id: I85b15f1eb5e5a4502a27d8588bb01e59b7ad83b5 --- AndroidManifest.xml | 1 + src/com/android/settings/SettingsInitialize.java | 8 ++++++++ src/com/android/settings/SetupWizardUtils.java | 3 +++ .../src/com/android/settings/SettingsInitializeTest.java | 9 +++++++++ 4 files changed, 21 insertions(+) diff --git a/AndroidManifest.xml b/AndroidManifest.xml index fd40e905fda..771337ff6dc 100644 --- a/AndroidManifest.xml +++ b/AndroidManifest.xml @@ -237,6 +237,7 @@ + diff --git a/src/com/android/settings/SettingsInitialize.java b/src/com/android/settings/SettingsInitialize.java index 4887e26940c..254ef8c8b1a 100644 --- a/src/com/android/settings/SettingsInitialize.java +++ b/src/com/android/settings/SettingsInitialize.java @@ -39,6 +39,7 @@ import android.util.Log; import androidx.annotation.VisibleForTesting; import com.android.settings.activityembedding.ActivityEmbeddingUtils; +import com.android.settings.core.instrumentation.ElapsedTimeUtils; import com.android.settings.homepage.DeepLinkHomepageActivity; import com.android.settings.search.SearchStateReceiver; import com.android.settingslib.utils.ThreadUtils; @@ -69,6 +70,7 @@ public class SettingsInitialize extends BroadcastReceiver { webviewSettingSetup(context, pm, userInfo); ThreadUtils.postOnBackgroundThread(() -> refreshExistingShortcuts(context)); enableTwoPaneDeepLinkActivityIfNecessary(pm, context); + storeSuwCompleteTimestamp(context, broadcast); } private void managedProfileSetup(Context context, final PackageManager pm, Intent broadcast, @@ -161,4 +163,10 @@ public class SettingsInitialize extends BroadcastReceiver { pm.setComponentEnabledSetting(searchStateReceiver, enableState, PackageManager.DONT_KILL_APP); } + + private void storeSuwCompleteTimestamp(Context context, Intent broadcast) { + if (SetupWizardUtils.ACTION_SETUP_WIZARD_FINISHED.equals(broadcast.getAction())) { + ElapsedTimeUtils.storeSuwFinishedTimestamp(context, System.currentTimeMillis()); + } + } } diff --git a/src/com/android/settings/SetupWizardUtils.java b/src/com/android/settings/SetupWizardUtils.java index 25e91598f3b..57adeee895d 100644 --- a/src/com/android/settings/SetupWizardUtils.java +++ b/src/com/android/settings/SetupWizardUtils.java @@ -32,6 +32,9 @@ import java.util.Arrays; public class SetupWizardUtils { + public static final String ACTION_SETUP_WIZARD_FINISHED = + "com.google.android.setupwizard.SETUP_WIZARD_FINISHED"; + public static String getThemeString(Intent intent) { String theme = intent.getStringExtra(WizardManagerHelper.EXTRA_THEME); if (theme == null) { diff --git a/tests/robotests/src/com/android/settings/SettingsInitializeTest.java b/tests/robotests/src/com/android/settings/SettingsInitializeTest.java index a8f42c2b7c7..467436b2566 100644 --- a/tests/robotests/src/com/android/settings/SettingsInitializeTest.java +++ b/tests/robotests/src/com/android/settings/SettingsInitializeTest.java @@ -24,6 +24,7 @@ import android.content.pm.ShortcutInfo; import android.content.pm.ShortcutManager; +import com.android.settings.core.instrumentation.ElapsedTimeUtils; import java.util.Collections; import org.junit.Before; import org.junit.Test; @@ -96,4 +97,12 @@ public class SettingsInitializeTest { assertThat(updatedShortcuts).hasSize(1); assertThat(updatedShortcuts.get(0)).isSameInstanceAs(info); } + + @Test + public void onReceive_suwFinished_shouldHaveElapsedTime() { + mSettingsInitialize.onReceive(mContext, new Intent(SetupWizardUtils.ACTION_SETUP_WIZARD_FINISHED)); + + final long elapsedTime = ElapsedTimeUtils.getElapsedTime(System.currentTimeMillis()); + assertThat(elapsedTime).isNotEqualTo(-1L); + } } From 996afd17a1603a010c1a198c3aef9d9f71224bae Mon Sep 17 00:00:00 2001 From: Abdelrahman Daim Date: Wed, 25 Sep 2024 02:54:13 -0700 Subject: [PATCH 07/11] Protect the Settings application from potential null pointer exceptions. Summary: The app bar is not available, causing a null pointer exception. Test: Successful Build on master branch Change-Id: I36849606f6587d6e7f004ae21e1a6e6a5206735a Signed-off-by: Abdelrahman Daim --- .../applications/manageapplications/ManageApplications.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/com/android/settings/applications/manageapplications/ManageApplications.java b/src/com/android/settings/applications/manageapplications/ManageApplications.java index 6c16d94a51d..b837e1e9c5d 100644 --- a/src/com/android/settings/applications/manageapplications/ManageApplications.java +++ b/src/com/android/settings/applications/manageapplications/ManageApplications.java @@ -1034,6 +1034,9 @@ public class ManageApplications extends InstrumentedFragment } private void autoSetCollapsingToolbarLayoutScrolling() { + if (mAppBarLayout == null) { + return; + } final CoordinatorLayout.LayoutParams params = (CoordinatorLayout.LayoutParams) mAppBarLayout.getLayoutParams(); final AppBarLayout.Behavior behavior = new AppBarLayout.Behavior(); From 125bbc0cccffc35bc50490bbb5137a60eb4de4c8 Mon Sep 17 00:00:00 2001 From: Jacky Wang Date: Thu, 26 Sep 2024 11:25:43 +0800 Subject: [PATCH 08/11] Include settings flags into flags_packages Required for resource & manifest flags. Bug: 332202168 Flag: EXEMPT Modify Android.bp Test: Set android:featureFlag to manifest Service locally Change-Id: Iaf02e494b1a23b92616a5ff5e8fb84e93b4a340e --- Android.bp | 1 + 1 file changed, 1 insertion(+) diff --git a/Android.bp b/Android.bp index 0a58ee8ea7c..28c3148cf28 100644 --- a/Android.bp +++ b/Android.bp @@ -130,6 +130,7 @@ android_library { "ims-common", ], flags_packages: [ + "aconfig_settings_flags", "android.app.flags-aconfig", ], } From 9969334647b4b9439f549c0c0b2543fb3dec8813 Mon Sep 17 00:00:00 2001 From: Chaohui Wang Date: Thu, 26 Sep 2024 12:00:47 +0800 Subject: [PATCH 09/11] Fix Can't Able to Click Sims The root cause is SubscriptionManager.OnSubscriptionsChangedListener .onSubscriptionsChanged() not invoked in some cases. Even the SubscriptionManager.addOnSubscriptionsChangedListener's doc says the onSubscriptionsChanged() method will also be invoked once initially when calling it, there still case that the onSubscriptionsChanged() method is not invoked initially. For example, when the onSubscriptionsChanged event never happens before, on a device never ever has any subscriptions. Adding a .onStart { emit(Unit) } to fix. Also make the subscriptionsChangedFlow() a shared flow to mitigate the extra emit cost. Bug: 369276595 Flag: EXEMPT bug fix Test: manual - factory reset & no any sim Test: atest SubscriptionRepositoryTest Change-Id: Ic32a5666f14373926b5dfedb5dedadb4369acfc7 --- .../telephony/SubscriptionRepository.kt | 70 +++++++++++++++---- .../MobileNetworkSwitchControllerTest.kt | 5 ++ .../telephony/SubscriptionRepositoryTest.kt | 18 ++++- 3 files changed, 78 insertions(+), 15 deletions(-) diff --git a/src/com/android/settings/network/telephony/SubscriptionRepository.kt b/src/com/android/settings/network/telephony/SubscriptionRepository.kt index 6b5b4cb4d43..43bba0759a2 100644 --- a/src/com/android/settings/network/telephony/SubscriptionRepository.kt +++ b/src/com/android/settings/network/telephony/SubscriptionRepository.kt @@ -23,11 +23,13 @@ import android.util.Log import androidx.lifecycle.LifecycleOwner import com.android.settings.network.SubscriptionUtil import com.android.settingslib.spa.framework.util.collectLatestWithLifecycle +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.asExecutor import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.distinctUntilChanged @@ -36,6 +38,8 @@ import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.shareIn private const val TAG = "SubscriptionRepository" @@ -132,20 +136,7 @@ class SubscriptionRepository(private val context: Context) { fun canDisablePhysicalSubscription() = subscriptionManager.canDisablePhysicalSubscription() /** Flow for subscriptions changes. */ - fun subscriptionsChangedFlow() = callbackFlow { - val listener = object : SubscriptionManager.OnSubscriptionsChangedListener() { - override fun onSubscriptionsChanged() { - trySend(Unit) - } - } - - subscriptionManager.addOnSubscriptionsChangedListener( - Dispatchers.Default.asExecutor(), - listener, - ) - - awaitClose { subscriptionManager.removeOnSubscriptionsChangedListener(listener) } - }.conflate().onEach { Log.d(TAG, "subscriptions changed") }.flowOn(Dispatchers.Default) + fun subscriptionsChangedFlow() = getSharedSubscriptionsChangedFlow(context) /** Flow of active subscription ids. */ fun activeSubscriptionIdListFlow(): Flow> = @@ -172,6 +163,57 @@ class SubscriptionRepository(private val context: Context) { flowOf(null) } } + + companion object { + private lateinit var SharedSubscriptionsChangedFlow: Flow + + private fun getSharedSubscriptionsChangedFlow(context: Context): Flow { + if (!this::SharedSubscriptionsChangedFlow.isInitialized) { + SharedSubscriptionsChangedFlow = + context.applicationContext + .requireSubscriptionManager() + .subscriptionsChangedFlow() + .shareIn( + scope = CoroutineScope(Dispatchers.Default), + started = SharingStarted.WhileSubscribed(), + replay = 1, + ) + } + return SharedSubscriptionsChangedFlow + } + + /** + * Flow for subscriptions changes. + * + * Note: Even the SubscriptionManager.addOnSubscriptionsChangedListener's doc says the + * SubscriptionManager.OnSubscriptionsChangedListener.onSubscriptionsChanged() method will + * also be invoked once initially when calling it, there still case that the + * onSubscriptionsChanged() method is not invoked initially. For example, when the + * onSubscriptionsChanged event never happens before, on a device never ever has any + * subscriptions. + */ + private fun SubscriptionManager.subscriptionsChangedFlow() = + callbackFlow { + val listener = + object : SubscriptionManager.OnSubscriptionsChangedListener() { + override fun onSubscriptionsChanged() { + trySend(Unit) + } + + override fun onAddListenerFailed() { + close() + } + } + + addOnSubscriptionsChangedListener(Dispatchers.Default.asExecutor(), listener) + + awaitClose { removeOnSubscriptionsChangedListener(listener) } + } + .onStart { emit(Unit) } // Ensure this flow is never empty + .conflate() + .onEach { Log.d(TAG, "subscriptions changed") } + .flowOn(Dispatchers.Default) + } } val Context.subscriptionManager: SubscriptionManager? diff --git a/tests/spa_unit/src/com/android/settings/network/telephony/MobileNetworkSwitchControllerTest.kt b/tests/spa_unit/src/com/android/settings/network/telephony/MobileNetworkSwitchControllerTest.kt index ca370829b6f..f47c6354498 100644 --- a/tests/spa_unit/src/com/android/settings/network/telephony/MobileNetworkSwitchControllerTest.kt +++ b/tests/spa_unit/src/com/android/settings/network/telephony/MobileNetworkSwitchControllerTest.kt @@ -62,10 +62,15 @@ class MobileNetworkSwitchControllerTest { on { isSubscriptionEnabledFlow(SUB_ID) } doReturn flowOf(false) } + private val mockSubscriptionActivationRepository = mock { + on { isActivationChangeableFlow() } doReturn flowOf(true) + } + private val controller = MobileNetworkSwitchController( context = context, preferenceKey = TEST_KEY, subscriptionRepository = mockSubscriptionRepository, + subscriptionActivationRepository = mockSubscriptionActivationRepository, ).apply { init(SUB_ID) } @Test diff --git a/tests/spa_unit/src/com/android/settings/network/telephony/SubscriptionRepositoryTest.kt b/tests/spa_unit/src/com/android/settings/network/telephony/SubscriptionRepositoryTest.kt index 5052f57c588..ba5142e0eb0 100644 --- a/tests/spa_unit/src/com/android/settings/network/telephony/SubscriptionRepositoryTest.kt +++ b/tests/spa_unit/src/com/android/settings/network/telephony/SubscriptionRepositoryTest.kt @@ -91,7 +91,23 @@ class SubscriptionRepositoryTest { subInfoListener?.onSubscriptionsChanged() - assertThat(listDeferred.await()).hasSize(2) + assertThat(listDeferred.await().size).isAtLeast(2) + } + + @Test + fun subscriptionsChangedFlow_managerNotCallOnSubscriptionsChangedInitially() = runBlocking { + mockSubscriptionManager.stub { + on { addOnSubscriptionsChangedListener(any(), any()) } doAnswer + { + subInfoListener = + it.arguments[1] as SubscriptionManager.OnSubscriptionsChangedListener + // not call onSubscriptionsChanged here + } + } + + val initialValue = repository.subscriptionsChangedFlow().firstWithTimeoutOrNull() + + assertThat(initialValue).isSameInstanceAs(Unit) } @Test From bb1cadb916dea49ed83162526880661b552a91af Mon Sep 17 00:00:00 2001 From: Chaohui Wang Date: Thu, 26 Sep 2024 10:16:23 +0000 Subject: [PATCH 10/11] Use hasScrollAction in ApnEditPageProviderTest Instead of assuming a fixed tree structure in testing. Fix: 369416630 Flag: EXEMPT gradle only Test: atest ApnEditPageProviderTest (cherry picked from https://googleplex-android-review.googlesource.com/q/commit:0b530fd405eb95dfedf51bc55bc24bd7d446ead8) Merged-In: I0a50e7665d9049e089b5a0877f17d1f736ee3332 Change-Id: I0a50e7665d9049e089b5a0877f17d1f736ee3332 --- .../network/apn/ApnEditPageProviderTest.kt | 129 +++++------------- 1 file changed, 35 insertions(+), 94 deletions(-) diff --git a/tests/spa_unit/src/com/android/settings/network/apn/ApnEditPageProviderTest.kt b/tests/spa_unit/src/com/android/settings/network/apn/ApnEditPageProviderTest.kt index 3621948c9fb..2f7417d212f 100644 --- a/tests/spa_unit/src/com/android/settings/network/apn/ApnEditPageProviderTest.kt +++ b/tests/spa_unit/src/com/android/settings/network/apn/ApnEditPageProviderTest.kt @@ -19,26 +19,16 @@ package com.android.settings.network.apn import android.content.Context import android.net.Uri import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.assertIsOff -import androidx.compose.ui.test.assertIsOn +import androidx.compose.ui.test.hasScrollAction import androidx.compose.ui.test.hasText -import androidx.compose.ui.test.isFocused import androidx.compose.ui.test.junit4.createComposeRule -import androidx.compose.ui.test.onAllNodesWithText -import androidx.compose.ui.test.onChild -import androidx.compose.ui.test.onChildAt -import androidx.compose.ui.test.onLast import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.onRoot -import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollToNode import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import com.android.settings.R -import com.google.common.truth.Truth -import org.junit.Ignore +import com.google.common.truth.Truth.assertThat import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -46,8 +36,7 @@ import org.mockito.kotlin.mock @RunWith(AndroidJUnit4::class) class ApnEditPageProviderTest { - @get:Rule - val composeTestRule = createComposeRule() + @get:Rule val composeTestRule = createComposeRule() private val context: Context = ApplicationProvider.getApplicationContext() private val apnName = "apn_name" @@ -55,124 +44,76 @@ class ApnEditPageProviderTest { private val port = "port" private val apnType = context.resources.getString(R.string.apn_type) private val apnRoaming = "IPv4" - private val apnEnable = context.resources.getString(R.string.carrier_enabled) private val apnProtocolOptions = context.resources.getStringArray(R.array.apn_protocol_entries).toList() private val passwordTitle = context.resources.getString(R.string.apn_password) - private val apnInit = ApnData( - name = apnName, - proxy = proxy, - port = port, - apnType = apnType, - apnRoaming = apnProtocolOptions.indexOf(apnRoaming), - apnEnable = true - ) - private val apnData = mutableStateOf( - apnInit - ) + private val apnInit = + ApnData( + name = apnName, + proxy = proxy, + port = port, + apnType = apnType, + apnRoaming = apnProtocolOptions.indexOf(apnRoaming), + ) + private val apnData = mutableStateOf(apnInit) private val uri = mock {} @Test fun apnEditPageProvider_name() { - Truth.assertThat(ApnEditPageProvider.name).isEqualTo("ApnEdit") + assertThat(ApnEditPageProvider.name).isEqualTo("ApnEdit") } @Test fun title_displayed() { - composeTestRule.setContent { - ApnPage(apnInit, remember { apnData }, uri) - } + composeTestRule.setContent { ApnPage(apnInit, apnData, uri) } + composeTestRule.onNodeWithText(context.getString(R.string.apn_edit)).assertIsDisplayed() } @Test fun name_displayed() { - composeTestRule.setContent { - ApnPage(apnInit, remember { apnData }, uri) - } + composeTestRule.setContent { ApnPage(apnInit, apnData, uri) } + composeTestRule.onNodeWithText(apnName, true).assertIsDisplayed() } @Test fun proxy_displayed() { - composeTestRule.setContent { - ApnPage(apnInit, remember { apnData }, uri) - } - composeTestRule.onRoot().onChild().onChildAt(0) - .performScrollToNode(hasText(proxy, true)) + composeTestRule.setContent { ApnPage(apnInit, apnData, uri) } + + composeTestRule.onNode(hasScrollAction()).performScrollToNode(hasText(proxy, true)) composeTestRule.onNodeWithText(proxy, true).assertIsDisplayed() } @Test fun port_displayed() { - composeTestRule.setContent { - ApnPage(apnInit, remember { apnData }, uri) - } - composeTestRule.onRoot().onChild().onChildAt(0) - .performScrollToNode(hasText(port, true)) + composeTestRule.setContent { ApnPage(apnInit, apnData, uri) } + + composeTestRule.onNode(hasScrollAction()).performScrollToNode(hasText(port, true)) composeTestRule.onNodeWithText(port, true).assertIsDisplayed() } @Test - fun apn_type_displayed() { - composeTestRule.setContent { - ApnPage(apnInit, remember { apnData }, uri) - } - composeTestRule.onRoot().onChild().onChildAt(0) - .performScrollToNode(hasText(apnType, true)) + fun apnType_displayed() { + composeTestRule.setContent { ApnPage(apnInit, apnData, uri) } + + composeTestRule.onNode(hasScrollAction()).performScrollToNode(hasText(apnType, true)) composeTestRule.onNodeWithText(apnType, true).assertIsDisplayed() } @Test - fun apn_roaming_displayed() { - composeTestRule.setContent { - ApnPage(apnInit, remember { apnData }, uri) - } - composeTestRule.onRoot().onChild().onChildAt(0) - .performScrollToNode(hasText(apnRoaming, true)) + fun apnRoaming_displayed() { + composeTestRule.setContent { ApnPage(apnInit, apnData, uri) } + + composeTestRule.onNode(hasScrollAction()).performScrollToNode(hasText(apnRoaming, true)) composeTestRule.onNodeWithText(apnRoaming, true).assertIsDisplayed() } - @Ignore("b/342374681") - @Test - fun carrier_enabled_displayed() { - composeTestRule.setContent { - ApnPage(apnInit, remember { apnData }, uri) - } - composeTestRule.onRoot().onChild().onChildAt(0) - .performScrollToNode(hasText(apnEnable, true)) - composeTestRule.onNodeWithText(apnEnable, true).assertIsDisplayed() - } - - @Test - fun carrier_enabled_isChecked() { - composeTestRule.setContent { - ApnPage(apnInit, remember { apnData }, uri) - } - composeTestRule.onRoot().onChild().onChildAt(0) - .performScrollToNode(hasText(apnEnable, true)) - composeTestRule.onNodeWithText(apnEnable, true).assertIsOn() - } - - @Ignore("b/342374681") - @Test - fun carrier_enabled_checkChanged() { - composeTestRule.setContent { - ApnPage(apnInit, remember { apnData }, uri) - } - composeTestRule.onRoot().onChild().onChildAt(0) - .performScrollToNode(hasText(apnEnable, true)) - composeTestRule.onNodeWithText(apnEnable, true).performClick() - composeTestRule.onNodeWithText(apnEnable, true).assertIsOff() - } - @Test fun password_displayed() { - composeTestRule.setContent { - ApnPage(apnInit, remember { apnData }, uri) - } - composeTestRule.onRoot().onChild().onChildAt(0) - .performScrollToNode(hasText(passwordTitle, true)) + composeTestRule.setContent { ApnPage(apnInit, apnData, uri) } + + composeTestRule.onNode(hasScrollAction()).performScrollToNode(hasText(passwordTitle, true)) composeTestRule.onNodeWithText(passwordTitle, true).assertIsDisplayed() } -} \ No newline at end of file +} From f620f85484a99dd65424e65c5bcd521f7bce0d41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=ADas=20Hern=C3=A1ndez?= Date: Thu, 26 Sep 2024 15:25:01 +0200 Subject: [PATCH 11/11] Add a header before the icon list in the new/rename mode screen Also fixed paddings a bit (e.g. Done button was too close to the icon grid). Fixes: 369503296 Test: manual Flag: android.app.modes_ui Change-Id: Ic7b7dc9584db1f04b448fce828a8ec70cf17f06a --- res/layout/modes_edit_name.xml | 3 ++- res/layout/modes_icon_list.xml | 3 +-- res/values/strings.xml | 3 +++ res/xml/modes_edit_name_icon.xml | 14 ++++++++------ 4 files changed, 14 insertions(+), 9 deletions(-) diff --git a/res/layout/modes_edit_name.xml b/res/layout/modes_edit_name.xml index 0b086c746ed..7f1a1e606a3 100644 --- a/res/layout/modes_edit_name.xml +++ b/res/layout/modes_edit_name.xml @@ -20,7 +20,8 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:paddingStart="?android:attr/listPreferredItemPaddingStart" - android:paddingEnd="?android:attr/listPreferredItemPaddingEnd"> + android:paddingEnd="?android:attr/listPreferredItemPaddingEnd" + android:paddingBottom="8dp"> Mode name + + Choose an icon + Calendar events diff --git a/res/xml/modes_edit_name_icon.xml b/res/xml/modes_edit_name_icon.xml index 2109c776ac8..4bcf67deccc 100644 --- a/res/xml/modes_edit_name_icon.xml +++ b/res/xml/modes_edit_name_icon.xml @@ -33,13 +33,15 @@ android:key="name" android:layout="@layout/modes_edit_name" /> - + - + +