diff options
21 files changed, 910 insertions, 126 deletions
diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PasswordBouncer.kt b/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PasswordBouncer.kt index f9b91cf4c10b..bd539a740e81 100644 --- a/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PasswordBouncer.kt +++ b/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PasswordBouncer.kt @@ -20,6 +20,7 @@ package com.android.systemui.bouncer.ui.composable import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.TextField @@ -36,16 +37,21 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onInterceptKeyBeforeSoftKeyboard +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import com.android.compose.PlatformIconButton import com.android.systemui.bouncer.ui.viewmodel.PasswordBouncerViewModel +import com.android.systemui.res.R /** UI for the input part of a password-requiring version of the bouncer. */ @Composable @@ -64,6 +70,7 @@ internal fun PasswordBouncer( val password: String by viewModel.password.collectAsState() val isInputEnabled: Boolean by viewModel.isInputEnabled.collectAsState() val animateFailure: Boolean by viewModel.animateFailure.collectAsState() + val isImeSwitcherButtonVisible by viewModel.isImeSwitcherButtonVisible.collectAsState() DisposableEffect(Unit) { viewModel.onShown() @@ -116,5 +123,28 @@ internal fun PasswordBouncer( false } }, + trailingIcon = + if (isImeSwitcherButtonVisible) { + { ImeSwitcherButton(viewModel, color) } + } else null + ) +} + +/** Button for changing the password input method (IME). */ +@Composable +private fun ImeSwitcherButton( + viewModel: PasswordBouncerViewModel, + color: Color, +) { + val context = LocalContext.current + PlatformIconButton( + onClick = { viewModel.onImeSwitcherButtonClicked(context.displayId) }, + iconResource = R.drawable.ic_lockscreen_ime, + contentDescription = stringResource(R.string.accessibility_ime_switch_button), + colors = + IconButtonDefaults.filledIconButtonColors( + contentColor = color, + containerColor = Color.Transparent, + ) ) } diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/bouncer/ui/viewmodel/PasswordBouncerViewModelTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/bouncer/ui/viewmodel/PasswordBouncerViewModelTest.kt index c193d14220de..fbb5415402db 100644 --- a/packages/SystemUI/multivalentTests/src/com/android/systemui/bouncer/ui/viewmodel/PasswordBouncerViewModelTest.kt +++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/bouncer/ui/viewmodel/PasswordBouncerViewModelTest.kt @@ -16,6 +16,7 @@ package com.android.systemui.bouncer.ui.viewmodel +import android.content.pm.UserInfo import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.SmallTest import com.android.systemui.SysuiTestCase @@ -26,18 +27,27 @@ import com.android.systemui.bouncer.domain.interactor.bouncerInteractor import com.android.systemui.coroutines.collectLastValue import com.android.systemui.coroutines.collectValues import com.android.systemui.deviceentry.data.repository.fakeDeviceEntryRepository +import com.android.systemui.inputmethod.data.model.InputMethodModel +import com.android.systemui.inputmethod.data.repository.fakeInputMethodRepository +import com.android.systemui.inputmethod.domain.interactor.inputMethodInteractor import com.android.systemui.kosmos.testScope import com.android.systemui.res.R import com.android.systemui.scene.domain.interactor.sceneInteractor import com.android.systemui.scene.shared.model.SceneKey import com.android.systemui.scene.shared.model.SceneModel import com.android.systemui.testKosmos +import com.android.systemui.user.data.model.SelectedUserModel +import com.android.systemui.user.data.model.SelectionStatus +import com.android.systemui.user.data.repository.fakeUserRepository +import com.android.systemui.user.domain.interactor.selectedUserInteractor import com.google.common.truth.Truth.assertThat +import java.util.UUID import kotlin.time.Duration.Companion.seconds import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.Before @@ -51,19 +61,22 @@ class PasswordBouncerViewModelTest : SysuiTestCase() { private val kosmos = testKosmos() private val testScope = kosmos.testScope - private val authenticationInteractor = kosmos.authenticationInteractor + private val authenticationInteractor by lazy { kosmos.authenticationInteractor } private val sceneInteractor by lazy { kosmos.sceneInteractor } private val bouncerInteractor by lazy { kosmos.bouncerInteractor } + private val selectedUserInteractor by lazy { kosmos.selectedUserInteractor } + private val inputMethodInteractor by lazy { kosmos.inputMethodInteractor } private val bouncerViewModel by lazy { kosmos.bouncerViewModel } private val isInputEnabled = MutableStateFlow(true) - private val underTest by lazy { + private val underTest = PasswordBouncerViewModel( viewModelScope = testScope.backgroundScope, + isInputEnabled = isInputEnabled.asStateFlow(), interactor = bouncerInteractor, - isInputEnabled.asStateFlow(), + inputMethodInteractor = inputMethodInteractor, + selectedUserInteractor = selectedUserInteractor, ) - } @Before fun setUp() { @@ -270,6 +283,52 @@ class PasswordBouncerViewModelTest : SysuiTestCase() { assertThat(isTextFieldFocusRequested).isTrue() } + @Test + fun isImeSwitcherButtonVisible() = + testScope.runTest { + val selectedUserId by collectLastValue(selectedUserInteractor.selectedUser) + selectUser(USER_INFOS.first()) + + enableInputMethodsForUser(checkNotNull(selectedUserId)) + + // Assert initial value, before the UI subscribes. + assertThat(underTest.isImeSwitcherButtonVisible.value).isFalse() + + // Subscription starts; verify a fresh value is fetched. + val isImeSwitcherButtonVisible by collectLastValue(underTest.isImeSwitcherButtonVisible) + assertThat(isImeSwitcherButtonVisible).isTrue() + + // Change the user, verify a fresh value is fetched. + selectUser(USER_INFOS.last()) + + assertThat( + inputMethodInteractor.hasMultipleEnabledImesOrSubtypes( + checkNotNull(selectedUserId) + ) + ) + .isFalse() + assertThat(isImeSwitcherButtonVisible).isFalse() + + // Enable IMEs and add another subscriber; verify a fresh value is fetched. + enableInputMethodsForUser(checkNotNull(selectedUserId)) + val collector2 by collectLastValue(underTest.isImeSwitcherButtonVisible) + assertThat(collector2).isTrue() + } + + @Test + fun onImeSwitcherButtonClicked() = + testScope.runTest { + val displayId = 7 + assertThat(kosmos.fakeInputMethodRepository.inputMethodPickerShownDisplayId) + .isNotEqualTo(displayId) + + underTest.onImeSwitcherButtonClicked(displayId) + runCurrent() + + assertThat(kosmos.fakeInputMethodRepository.inputMethodPickerShownDisplayId) + .isEqualTo(displayId) + } + private fun TestScope.switchToScene(toScene: SceneKey) { val currentScene by collectLastValue(sceneInteractor.desiredScene) val bouncerShown = currentScene?.key != SceneKey.Bouncer && toScene == SceneKey.Bouncer @@ -310,8 +369,45 @@ class PasswordBouncerViewModelTest : SysuiTestCase() { runCurrent() } + private fun TestScope.selectUser(userInfo: UserInfo) { + kosmos.fakeUserRepository.selectedUser.value = + SelectedUserModel( + userInfo = userInfo, + selectionStatus = SelectionStatus.SELECTION_COMPLETE + ) + advanceTimeBy(PasswordBouncerViewModel.DELAY_TO_FETCH_IMES) + } + + private suspend fun enableInputMethodsForUser(userId: Int) { + kosmos.fakeInputMethodRepository.setEnabledInputMethods( + userId, + createInputMethodWithSubtypes(auxiliarySubtypes = 0, nonAuxiliarySubtypes = 0), + createInputMethodWithSubtypes(auxiliarySubtypes = 0, nonAuxiliarySubtypes = 1), + ) + assertThat(inputMethodInteractor.hasMultipleEnabledImesOrSubtypes(userId)).isTrue() + } + + private fun createInputMethodWithSubtypes( + auxiliarySubtypes: Int, + nonAuxiliarySubtypes: Int, + ): InputMethodModel { + return InputMethodModel( + imeId = UUID.randomUUID().toString(), + subtypes = + List(auxiliarySubtypes + nonAuxiliarySubtypes) { + InputMethodModel.Subtype(subtypeId = it, isAuxiliary = it < auxiliarySubtypes) + } + ) + } + companion object { private const val ENTER_YOUR_PASSWORD = "Enter your password" private const val WRONG_PASSWORD = "Wrong password" + + private val USER_INFOS = + listOf( + UserInfo(100, "First user", 0), + UserInfo(101, "Second user", 0), + ) } } diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/inputmethod/data/repository/InputMethodRepositoryTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/inputmethod/data/repository/InputMethodRepositoryTest.kt new file mode 100644 index 000000000000..857cdce448ed --- /dev/null +++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/inputmethod/data/repository/InputMethodRepositoryTest.kt @@ -0,0 +1,124 @@ +/* + * 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.systemui.inputmethod.data.repository + +import android.os.UserHandle +import android.view.inputmethod.InputMethodInfo +import android.view.inputmethod.InputMethodManager +import android.view.inputmethod.InputMethodSubtype +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SmallTest +import com.android.systemui.SysuiTestCase +import com.android.systemui.kosmos.Kosmos +import com.android.systemui.kosmos.testDispatcher +import com.android.systemui.kosmos.testScope +import com.android.systemui.util.mockito.any +import com.android.systemui.util.mockito.eq +import com.android.systemui.util.mockito.mock +import com.android.systemui.util.mockito.whenever +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.flow.count +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentMatchers.anyBoolean +import org.mockito.Mock +import org.mockito.Mockito.verify +import org.mockito.MockitoAnnotations + +@SmallTest +@RunWith(AndroidJUnit4::class) +class InputMethodRepositoryTest : SysuiTestCase() { + + @Mock private lateinit var inputMethodManager: InputMethodManager + + private val kosmos = Kosmos() + private val testScope = kosmos.testScope + + private lateinit var underTest: InputMethodRepository + + @Before + fun setUp() { + MockitoAnnotations.initMocks(this) + + whenever(inputMethodManager.getEnabledInputMethodSubtypeList(eq(null), anyBoolean())) + .thenReturn(listOf()) + + underTest = + InputMethodRepositoryImpl( + backgroundDispatcher = kosmos.testDispatcher, + inputMethodManager = inputMethodManager, + ) + } + + @Test + fun enabledInputMethods_noImes_emptyFlow() = + testScope.runTest { + whenever(inputMethodManager.getEnabledInputMethodListAsUser(eq(USER_HANDLE))) + .thenReturn(listOf()) + whenever(inputMethodManager.getEnabledInputMethodSubtypeList(any(), anyBoolean())) + .thenReturn(listOf()) + + assertThat(underTest.enabledInputMethods(USER_ID, fetchSubtypes = true).count()) + .isEqualTo(0) + } + + @Test + fun selectedInputMethodSubtypes_returnsSubtypeList() = + testScope.runTest { + val subtypeId = 123 + val isAuxiliary = true + whenever(inputMethodManager.getEnabledInputMethodListAsUser(eq(USER_HANDLE))) + .thenReturn(listOf(mock<InputMethodInfo>())) + whenever(inputMethodManager.getEnabledInputMethodSubtypeList(any(), anyBoolean())) + .thenReturn(listOf()) + whenever(inputMethodManager.getEnabledInputMethodSubtypeList(eq(null), anyBoolean())) + .thenReturn( + listOf( + InputMethodSubtype.InputMethodSubtypeBuilder() + .setSubtypeId(subtypeId) + .setIsAuxiliary(isAuxiliary) + .build() + ) + ) + + val result = underTest.selectedInputMethodSubtypes() + assertThat(result).hasSize(1) + assertThat(result.first().subtypeId).isEqualTo(subtypeId) + assertThat(result.first().isAuxiliary).isEqualTo(isAuxiliary) + } + + @Test + fun showImePicker_forwardsDisplayId() = + testScope.runTest { + val displayId = 7 + + underTest.showInputMethodPicker(displayId, /* showAuxiliarySubtypes = */ true) + + verify(inputMethodManager) + .showInputMethodPickerFromSystem( + /* showAuxiliarySubtypes = */ eq(true), + /* displayId = */ eq(displayId) + ) + } + + companion object { + private const val USER_ID = 100 + private val USER_HANDLE = UserHandle.of(USER_ID) + } +} diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/inputmethod/domain/interactor/InputMethodInteractorTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/inputmethod/domain/interactor/InputMethodInteractorTest.kt new file mode 100644 index 000000000000..d23ff2a817e9 --- /dev/null +++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/inputmethod/domain/interactor/InputMethodInteractorTest.kt @@ -0,0 +1,157 @@ +/* + * 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.systemui.inputmethod.domain.interactor + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SmallTest +import com.android.systemui.SysuiTestCase +import com.android.systemui.inputmethod.data.model.InputMethodModel +import com.android.systemui.inputmethod.data.repository.fakeInputMethodRepository +import com.android.systemui.inputmethod.data.repository.inputMethodRepository +import com.android.systemui.kosmos.Kosmos +import com.android.systemui.kosmos.testScope +import com.google.common.truth.Truth.assertThat +import java.util.UUID +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith + +@SmallTest +@RunWith(AndroidJUnit4::class) +class InputMethodInteractorTest : SysuiTestCase() { + + private val kosmos = Kosmos() + private val testScope = kosmos.testScope + private val fakeInputMethodRepository = kosmos.fakeInputMethodRepository + + private val underTest = InputMethodInteractor(repository = kosmos.inputMethodRepository) + + @Test + fun hasMultipleEnabledImesOrSubtypes_noImes_returnsFalse() = + testScope.runTest { + fakeInputMethodRepository.setEnabledInputMethods(USER_ID) + + assertThat(underTest.hasMultipleEnabledImesOrSubtypes(USER_ID)).isFalse() + } + + @Test + fun hasMultipleEnabledImesOrSubtypes_noMatches_returnsFalse() = + testScope.runTest { + fakeInputMethodRepository.setEnabledInputMethods( + USER_ID, + createInputMethodWithSubtypes(auxiliarySubtypes = 1, nonAuxiliarySubtypes = 0), + createInputMethodWithSubtypes(auxiliarySubtypes = 3, nonAuxiliarySubtypes = 0), + ) + + assertThat(underTest.hasMultipleEnabledImesOrSubtypes(USER_ID)).isFalse() + } + + @Test + fun hasMultipleEnabledImesOrSubtypes_oneMatch_returnsFalse() = + testScope.runTest { + fakeInputMethodRepository.setEnabledInputMethods( + USER_ID, + createInputMethodWithSubtypes(auxiliarySubtypes = 0, nonAuxiliarySubtypes = 0), + ) + + assertThat(underTest.hasMultipleEnabledImesOrSubtypes(USER_ID)).isFalse() + } + + @Test + fun hasMultipleEnabledImesOrSubtypes_twoMatches_returnsTrue() = + testScope.runTest { + fakeInputMethodRepository.setEnabledInputMethods( + USER_ID, + createInputMethodWithSubtypes(auxiliarySubtypes = 0, nonAuxiliarySubtypes = 1), + createInputMethodWithSubtypes(auxiliarySubtypes = 0, nonAuxiliarySubtypes = 0), + ) + + assertThat(underTest.hasMultipleEnabledImesOrSubtypes(USER_ID)).isTrue() + } + + @Test + fun hasMultipleEnabledImesOrSubtypes_oneWithNonAux_returnsFalse() = + testScope.runTest { + fakeInputMethodRepository.setEnabledInputMethods( + USER_ID, + createInputMethodWithSubtypes(auxiliarySubtypes = 0, nonAuxiliarySubtypes = 2), + ) + + assertThat(underTest.hasMultipleEnabledImesOrSubtypes(USER_ID)).isFalse() + } + + @Test + fun hasMultipleEnabledImesOrSubtypes_twoWithAux_returnsFalse() = + testScope.runTest { + fakeInputMethodRepository.setEnabledInputMethods( + USER_ID, + createInputMethodWithSubtypes(auxiliarySubtypes = 3, nonAuxiliarySubtypes = 0), + createInputMethodWithSubtypes(auxiliarySubtypes = 5, nonAuxiliarySubtypes = 0), + ) + + assertThat(underTest.hasMultipleEnabledImesOrSubtypes(USER_ID)).isFalse() + } + + @Test + fun hasMultipleEnabledImesOrSubtypes_selectedHasOneSubtype_returnsFalse() = + testScope.runTest { + fakeInputMethodRepository.selectedInputMethodSubtypes = + listOf(InputMethodModel.Subtype(1, isAuxiliary = false)) + + assertThat(underTest.hasMultipleEnabledImesOrSubtypes(USER_ID)).isFalse() + } + + @Test + fun hasMultipleEnabledImesOrSubtypes_selectedHasTwoSubtypes_returnsTrue() = + testScope.runTest { + fakeInputMethodRepository.selectedInputMethodSubtypes = + listOf( + InputMethodModel.Subtype(subtypeId = 1, isAuxiliary = false), + InputMethodModel.Subtype(subtypeId = 2, isAuxiliary = false), + ) + + assertThat(underTest.hasMultipleEnabledImesOrSubtypes(USER_ID)).isTrue() + } + + @Test + fun showImePicker_shownOnCorrectId() = + testScope.runTest { + val displayId = 7 + + underTest.showInputMethodPicker(displayId, showAuxiliarySubtypes = false) + + assertThat(fakeInputMethodRepository.inputMethodPickerShownDisplayId) + .isEqualTo(displayId) + } + + private fun createInputMethodWithSubtypes( + auxiliarySubtypes: Int, + nonAuxiliarySubtypes: Int, + ): InputMethodModel { + return InputMethodModel( + imeId = UUID.randomUUID().toString(), + subtypes = + List(auxiliarySubtypes + nonAuxiliarySubtypes) { + InputMethodModel.Subtype(subtypeId = it, isAuxiliary = it < auxiliarySubtypes) + } + ) + } + + companion object { + private const val USER_ID = 100 + } +} diff --git a/packages/SystemUI/src/com/android/systemui/authentication/data/repository/AuthenticationRepository.kt b/packages/SystemUI/src/com/android/systemui/authentication/data/repository/AuthenticationRepository.kt index 49f34f18b06e..454ed27161a2 100644 --- a/packages/SystemUI/src/com/android/systemui/authentication/data/repository/AuthenticationRepository.kt +++ b/packages/SystemUI/src/com/android/systemui/authentication/data/repository/AuthenticationRepository.kt @@ -39,7 +39,7 @@ import com.android.systemui.dagger.qualifiers.Background import com.android.systemui.scene.shared.flag.SceneContainerFlags import com.android.systemui.statusbar.pipeline.mobile.data.repository.MobileConnectionsRepository import com.android.systemui.user.data.repository.UserRepository -import com.android.systemui.util.kotlin.pairwise +import com.android.systemui.util.kotlin.onSubscriberAdded import com.android.systemui.util.time.SystemClock import dagger.Binds import dagger.Module @@ -54,7 +54,6 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart @@ -355,10 +354,7 @@ constructor( userRepository.selectedUserInfo.map { it.id }.distinctUntilChanged(), // Emits a value only when the number of downstream subscribers of this flow // increases. - flow.subscriptionCount.pairwise(initialValue = 0).filter { (previous, current) - -> - current > previous - }, + flow.onSubscriberAdded(), ) { selectedUserId, _ -> selectedUserId } diff --git a/packages/SystemUI/src/com/android/systemui/bouncer/data/repository/BouncerRepository.kt b/packages/SystemUI/src/com/android/systemui/bouncer/data/repository/BouncerRepository.kt index 7265c0c1cb94..d849b3a44519 100644 --- a/packages/SystemUI/src/com/android/systemui/bouncer/data/repository/BouncerRepository.kt +++ b/packages/SystemUI/src/com/android/systemui/bouncer/data/repository/BouncerRepository.kt @@ -21,8 +21,6 @@ import com.android.systemui.flags.FeatureFlagsClassic import com.android.systemui.flags.Flags import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow /** Provides access to bouncer-related application state. */ @SysUISingleton @@ -31,15 +29,10 @@ class BouncerRepository constructor( private val flags: FeatureFlagsClassic, ) { - private val _message = MutableStateFlow<String?>(null) /** The user-facing message to show in the bouncer. */ - val message: StateFlow<String?> = _message.asStateFlow() + val message = MutableStateFlow<String?>(null) /** Whether the user switcher should be displayed within the bouncer UI on large screens. */ val isUserSwitcherVisible: Boolean get() = flags.isEnabled(Flags.FULL_SCREEN_USER_SWITCHER) - - fun setMessage(message: String?) { - _message.value = message - } } diff --git a/packages/SystemUI/src/com/android/systemui/bouncer/domain/interactor/BouncerInteractor.kt b/packages/SystemUI/src/com/android/systemui/bouncer/domain/interactor/BouncerInteractor.kt index c8ce245e48bf..d8be1afc4dd6 100644 --- a/packages/SystemUI/src/com/android/systemui/bouncer/domain/interactor/BouncerInteractor.kt +++ b/packages/SystemUI/src/com/android/systemui/bouncer/domain/interactor/BouncerInteractor.kt @@ -120,7 +120,7 @@ constructor( } fun setMessage(message: String?) { - repository.setMessage(message) + repository.message.value = message } /** @@ -129,13 +129,13 @@ constructor( */ fun resetMessage() { applicationScope.launch { - repository.setMessage(promptMessage(authenticationInteractor.getAuthenticationMethod())) + setMessage(promptMessage(authenticationInteractor.getAuthenticationMethod())) } } /** Removes the user-facing message. */ fun clearMessage() { - repository.setMessage(null) + setMessage(null) } /** @@ -196,7 +196,7 @@ constructor( * message without having the attempt trigger lockout. */ private suspend fun showWrongInputMessage() { - repository.setMessage(wrongInputMessage(authenticationInteractor.getAuthenticationMethod())) + setMessage(wrongInputMessage(authenticationInteractor.getAuthenticationMethod())) } /** Notifies that the input method editor (software keyboard) has been hidden by the user. */ diff --git a/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/BouncerViewModel.kt b/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/BouncerViewModel.kt index 4d686a1ba0d4..4466cbbe05be 100644 --- a/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/BouncerViewModel.kt +++ b/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/BouncerViewModel.kt @@ -34,7 +34,9 @@ import com.android.systemui.common.shared.model.Text import com.android.systemui.dagger.SysUISingleton import com.android.systemui.dagger.qualifiers.Application import com.android.systemui.dagger.qualifiers.Main +import com.android.systemui.inputmethod.domain.interactor.InputMethodInteractor import com.android.systemui.scene.shared.flag.SceneContainerFlags +import com.android.systemui.user.domain.interactor.SelectedUserInteractor import com.android.systemui.user.ui.viewmodel.UserActionViewModel import com.android.systemui.user.ui.viewmodel.UserSwitcherViewModel import com.android.systemui.user.ui.viewmodel.UserViewModel @@ -66,8 +68,10 @@ class BouncerViewModel( @Application private val applicationScope: CoroutineScope, @Main private val mainDispatcher: CoroutineDispatcher, private val bouncerInteractor: BouncerInteractor, + private val inputMethodInteractor: InputMethodInteractor, private val simBouncerInteractor: SimBouncerInteractor, private val authenticationInteractor: AuthenticationInteractor, + private val selectedUserInteractor: SelectedUserInteractor, flags: SceneContainerFlags, selectedUser: Flow<UserViewModel>, users: Flow<List<UserViewModel>>, @@ -346,8 +350,10 @@ class BouncerViewModel( is AuthenticationMethodModel.Password -> PasswordBouncerViewModel( viewModelScope = newViewModelScope, - interactor = bouncerInteractor, isInputEnabled = isInputEnabled, + interactor = bouncerInteractor, + inputMethodInteractor = inputMethodInteractor, + selectedUserInteractor = selectedUserInteractor, ) is AuthenticationMethodModel.Pattern -> PatternBouncerViewModel( @@ -467,11 +473,13 @@ object BouncerViewModelModule { @Application applicationScope: CoroutineScope, @Main mainDispatcher: CoroutineDispatcher, bouncerInteractor: BouncerInteractor, + imeInteractor: InputMethodInteractor, simBouncerInteractor: SimBouncerInteractor, + actionButtonInteractor: BouncerActionButtonInteractor, authenticationInteractor: AuthenticationInteractor, + selectedUserInteractor: SelectedUserInteractor, flags: SceneContainerFlags, userSwitcherViewModel: UserSwitcherViewModel, - actionButtonInteractor: BouncerActionButtonInteractor, clock: SystemClock, devicePolicyManager: DevicePolicyManager, ): BouncerViewModel { @@ -480,8 +488,10 @@ object BouncerViewModelModule { applicationScope = applicationScope, mainDispatcher = mainDispatcher, bouncerInteractor = bouncerInteractor, + inputMethodInteractor = imeInteractor, simBouncerInteractor = simBouncerInteractor, authenticationInteractor = authenticationInteractor, + selectedUserInteractor = selectedUserInteractor, flags = flags, selectedUser = userSwitcherViewModel.selectedUser, users = userSwitcherViewModel.users, diff --git a/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/PasswordBouncerViewModel.kt b/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/PasswordBouncerViewModel.kt index 5c9c997db7e4..1c8b84d82a56 100644 --- a/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/PasswordBouncerViewModel.kt +++ b/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/PasswordBouncerViewModel.kt @@ -16,23 +16,32 @@ package com.android.systemui.bouncer.ui.viewmodel +import androidx.annotation.VisibleForTesting import com.android.systemui.authentication.shared.model.AuthenticationMethodModel import com.android.systemui.bouncer.domain.interactor.BouncerInteractor +import com.android.systemui.inputmethod.domain.interactor.InputMethodInteractor import com.android.systemui.res.R +import com.android.systemui.user.domain.interactor.SelectedUserInteractor +import com.android.systemui.util.kotlin.onSubscriberAdded +import kotlin.time.Duration.Companion.milliseconds import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch /** Holds UI state and handles user input for the password bouncer UI. */ class PasswordBouncerViewModel( viewModelScope: CoroutineScope, - interactor: BouncerInteractor, isInputEnabled: StateFlow<Boolean>, + interactor: BouncerInteractor, + private val inputMethodInteractor: InputMethodInteractor, + private val selectedUserInteractor: SelectedUserInteractor, ) : AuthMethodBouncerViewModel( viewModelScope = viewModelScope, @@ -49,6 +58,9 @@ class PasswordBouncerViewModel( override val lockoutMessageId = R.string.kg_too_many_failed_password_attempts_dialog_message + /** Informs the UI whether the input method switcher button should be visible. */ + val isImeSwitcherButtonVisible: StateFlow<Boolean> = imeSwitcherRefreshingFlow() + /** Whether the text field element currently has focus. */ private val isTextFieldFocused = MutableStateFlow(false) @@ -87,6 +99,13 @@ class PasswordBouncerViewModel( _password.value = newPassword } + /** Notifies that the user clicked the button to change the input method. */ + fun onImeSwitcherButtonClicked(displayId: Int) { + viewModelScope.launch { + inputMethodInteractor.showInputMethodPicker(displayId, showAuxiliarySubtypes = false) + } + } + /** Notifies that the user has pressed the key for attempting to authenticate the password. */ fun onAuthenticateKeyPressed() { if (_password.value.isNotEmpty()) { @@ -103,4 +122,35 @@ class PasswordBouncerViewModel( fun onTextFieldFocusChanged(isFocused: Boolean) { isTextFieldFocused.value = isFocused } + + /** + * Whether the input method switcher button should be displayed in the password bouncer UI. The + * value may be stale at the moment of subscription to this flow, but it is guaranteed to be + * shortly updated with a fresh value. + * + * Note: Each added subscription triggers an IPC call in the background, so this should only be + * subscribed to by the UI once in its lifecycle (i.e. when the bouncer is shown). + */ + private fun imeSwitcherRefreshingFlow(): StateFlow<Boolean> { + val isImeSwitcherButtonVisible = MutableStateFlow(value = false) + viewModelScope.launch { + // Re-fetch the currently-enabled IMEs whenever the selected user changes, and whenever + // the UI subscribes to the `isImeSwitcherButtonVisible` flow. + combine( + // InputMethodManagerService sometimes takes some time to update its internal + // state when the selected user changes. As a workaround, delay fetching the IME + // info. + selectedUserInteractor.selectedUser.onEach { delay(DELAY_TO_FETCH_IMES) }, + isImeSwitcherButtonVisible.onSubscriberAdded() + ) { selectedUserId, _ -> + inputMethodInteractor.hasMultipleEnabledImesOrSubtypes(selectedUserId) + } + .collect { isImeSwitcherButtonVisible.value = it } + } + return isImeSwitcherButtonVisible.asStateFlow() + } + + companion object { + @VisibleForTesting val DELAY_TO_FETCH_IMES = 300.milliseconds + } } diff --git a/packages/SystemUI/src/com/android/systemui/dagger/SystemUIModule.java b/packages/SystemUI/src/com/android/systemui/dagger/SystemUIModule.java index 2587e2da2172..efcbd47b67b4 100644 --- a/packages/SystemUI/src/com/android/systemui/dagger/SystemUIModule.java +++ b/packages/SystemUI/src/com/android/systemui/dagger/SystemUIModule.java @@ -57,6 +57,7 @@ import com.android.systemui.dump.DumpManager; import com.android.systemui.flags.FeatureFlags; import com.android.systemui.flags.FlagDependenciesModule; import com.android.systemui.flags.FlagsModule; +import com.android.systemui.inputmethod.InputMethodModule; import com.android.systemui.keyboard.KeyboardModule; import com.android.systemui.keyevent.data.repository.KeyEventRepositoryModule; import com.android.systemui.keyguard.ui.view.layout.blueprints.KeyguardBlueprintModule; @@ -193,6 +194,7 @@ import javax.inject.Named; FlagsModule.class, FlagDependenciesModule.class, FooterActionsModule.class, + InputMethodModule.class, KeyEventRepositoryModule.class, KeyboardModule.class, KeyguardBlueprintModule.class, diff --git a/packages/SystemUI/src/com/android/systemui/inputmethod/InputMethodModule.kt b/packages/SystemUI/src/com/android/systemui/inputmethod/InputMethodModule.kt new file mode 100644 index 000000000000..bac48f176712 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/inputmethod/InputMethodModule.kt @@ -0,0 +1,29 @@ +/* + * 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.systemui.inputmethod + +import com.android.systemui.inputmethod.data.repository.InputMethodRepositoryModule +import dagger.Module + +/** Module for providing objects exposed by the input method package. */ +@Module( + includes = + [ + InputMethodRepositoryModule::class, + ], +) +object InputMethodModule diff --git a/packages/SystemUI/src/com/android/systemui/inputmethod/data/model/InputMethodModel.kt b/packages/SystemUI/src/com/android/systemui/inputmethod/data/model/InputMethodModel.kt new file mode 100644 index 000000000000..bdc18b322ac0 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/inputmethod/data/model/InputMethodModel.kt @@ -0,0 +1,51 @@ +/* + * 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.systemui.inputmethod.data.model + +/** + * Models an input method editor (IME). + * + * @see android.view.inputmethod.InputMethodInfo + */ +data class InputMethodModel( + /** A unique ID for this input method. */ + val imeId: String, + /** The subtypes of this IME (may be empty). */ + val subtypes: List<Subtype>, +) { + /** + * A Subtype can describe locale (e.g. en_US, fr_FR...) and mode (e.g. voice, keyboard), and is + * used for IME switch and settings. + * + * @see android.view.inputmethod.InputMethodSubtype + */ + data class Subtype( + /** A unique ID for this IME subtype. */ + val subtypeId: Int, + /** + * Whether this subtype is auxiliary. An auxiliary subtype will not be shown in the list of + * enabled IMEs for choosing the current IME in Settings, but it will be shown in the list + * of IMEs in the IME switcher to allow the user to tentatively switch to this subtype while + * an IME is shown. + * + * The intent of this flag is to allow for IMEs that are invoked in a one-shot way as + * auxiliary input mode, and return to the previous IME once it is finished (e.g. voice + * input). + */ + val isAuxiliary: Boolean, + ) +} diff --git a/packages/SystemUI/src/com/android/systemui/inputmethod/data/repository/InputMethodRepository.kt b/packages/SystemUI/src/com/android/systemui/inputmethod/data/repository/InputMethodRepository.kt new file mode 100644 index 000000000000..5f316c4495ec --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/inputmethod/data/repository/InputMethodRepository.kt @@ -0,0 +1,139 @@ +/* + * 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.systemui.inputmethod.data.repository + +import android.annotation.SuppressLint +import android.os.UserHandle +import android.view.inputmethod.InputMethodInfo +import android.view.inputmethod.InputMethodManager +import com.android.systemui.dagger.SysUISingleton +import com.android.systemui.dagger.qualifiers.Background +import com.android.systemui.inputmethod.data.model.InputMethodModel +import dagger.Binds +import dagger.Module +import javax.inject.Inject +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.asFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext + +/** Provides access to input-method related application state in the bouncer. */ +interface InputMethodRepository { + /** + * Creates and returns a new `Flow` of installed input methods that are enabled for the + * specified user. + * + * @param fetchSubtypes Whether to fetch the IME Subtypes as well (requires an additional IPC + * call for each IME, avoid if not needed). + * @see InputMethodManager.getEnabledInputMethodListAsUser + */ + suspend fun enabledInputMethods(userId: Int, fetchSubtypes: Boolean): Flow<InputMethodModel> + + /** Returns enabled subtypes for the currently selected input method. */ + suspend fun selectedInputMethodSubtypes(): List<InputMethodModel.Subtype> + + /** + * Shows the system's input method picker dialog. + * + * @param displayId The display ID on which to show the dialog. + * @param showAuxiliarySubtypes Whether to show auxiliary input method subtypes in the list of + * enabled IMEs. + * @see InputMethodManager.showInputMethodPickerFromSystem + */ + suspend fun showInputMethodPicker(displayId: Int, showAuxiliarySubtypes: Boolean) +} + +@SysUISingleton +class InputMethodRepositoryImpl +@Inject +constructor( + @Background private val backgroundDispatcher: CoroutineDispatcher, + private val inputMethodManager: InputMethodManager, +) : InputMethodRepository { + + override suspend fun enabledInputMethods( + userId: Int, + fetchSubtypes: Boolean + ): Flow<InputMethodModel> { + return withContext(backgroundDispatcher) { + inputMethodManager.getEnabledInputMethodListAsUser(UserHandle.of(userId)) + } + .asFlow() + .map { inputMethodInfo -> + InputMethodModel( + imeId = inputMethodInfo.id, + subtypes = + if (fetchSubtypes) { + enabledInputMethodSubtypes( + inputMethodInfo, + allowsImplicitlyEnabledSubtypes = true + ) + } else { + listOf() + } + ) + } + } + + override suspend fun selectedInputMethodSubtypes(): List<InputMethodModel.Subtype> { + return enabledInputMethodSubtypes( + inputMethodInfo = null, // Fetch subtypes for the currently-selected IME. + allowsImplicitlyEnabledSubtypes = false + ) + } + + @SuppressLint("MissingPermission") + override suspend fun showInputMethodPicker(displayId: Int, showAuxiliarySubtypes: Boolean) { + withContext(backgroundDispatcher) { + inputMethodManager.showInputMethodPickerFromSystem(showAuxiliarySubtypes, displayId) + } + } + + /** + * Returns a list of enabled input method subtypes for the specified input method info. + * + * @param inputMethodInfo The [InputMethodInfo] whose subtypes list will be returned. If `null`, + * returns enabled subtypes for the currently selected [InputMethodInfo]. + * @param allowsImplicitlyEnabledSubtypes Whether to allow to return the implicitly enabled + * subtypes. If an input method info doesn't have enabled subtypes, the framework will + * implicitly enable subtypes according to the current system language. + * @see InputMethodManager.getEnabledInputMethodSubtypeList + */ + private suspend fun enabledInputMethodSubtypes( + inputMethodInfo: InputMethodInfo?, + allowsImplicitlyEnabledSubtypes: Boolean + ): List<InputMethodModel.Subtype> { + return withContext(backgroundDispatcher) { + inputMethodManager.getEnabledInputMethodSubtypeList( + inputMethodInfo, + allowsImplicitlyEnabledSubtypes + ) + } + .map { + InputMethodModel.Subtype( + subtypeId = it.subtypeId, + isAuxiliary = it.isAuxiliary, + ) + } + } +} + +@Module +interface InputMethodRepositoryModule { + @Binds fun repository(impl: InputMethodRepositoryImpl): InputMethodRepository +} diff --git a/packages/SystemUI/src/com/android/systemui/inputmethod/domain/interactor/InputMethodInteractor.kt b/packages/SystemUI/src/com/android/systemui/inputmethod/domain/interactor/InputMethodInteractor.kt new file mode 100644 index 000000000000..c54aa7f2c6a5 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/inputmethod/domain/interactor/InputMethodInteractor.kt @@ -0,0 +1,53 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.inputmethod.domain.interactor + +import com.android.systemui.dagger.SysUISingleton +import com.android.systemui.inputmethod.data.repository.InputMethodRepository +import javax.inject.Inject +import kotlinx.coroutines.flow.count +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.take + +/** Hosts application business logic related to input methods (e.g. software keyboard). */ +@SysUISingleton +class InputMethodInteractor +@Inject +constructor( + private val repository: InputMethodRepository, +) { + /** + * Returns whether there are multiple enabled input methods to choose from for password input. + * + * Method adapted from `com.android.inputmethod.latin.Utils`. + */ + suspend fun hasMultipleEnabledImesOrSubtypes(userId: Int): Boolean { + // Count IMEs that either have no subtypes, or have at least one non-auxiliary subtype. + val matchingInputMethods = + repository + .enabledInputMethods(userId, fetchSubtypes = true) + .filter { ime -> ime.subtypes.isEmpty() || ime.subtypes.any { !it.isAuxiliary } } + .take(2) // Short-circuit if we find at least 2 matching IMEs. + + return matchingInputMethods.count() > 1 || repository.selectedInputMethodSubtypes().size > 1 + } + + /** Shows the system's input method picker dialog. */ + suspend fun showInputMethodPicker(displayId: Int, showAuxiliarySubtypes: Boolean) { + repository.showInputMethodPicker(displayId, showAuxiliarySubtypes) + } +} diff --git a/packages/SystemUI/src/com/android/systemui/user/data/repository/UserRepository.kt b/packages/SystemUI/src/com/android/systemui/user/data/repository/UserRepository.kt index cf76c0d2e696..37be1c6aa73d 100644 --- a/packages/SystemUI/src/com/android/systemui/user/data/repository/UserRepository.kt +++ b/packages/SystemUI/src/com/android/systemui/user/data/repository/UserRepository.kt @@ -17,6 +17,7 @@ package com.android.systemui.user.data.repository +import android.annotation.SuppressLint import android.content.Context import android.content.pm.UserInfo import android.os.UserHandle @@ -209,18 +210,15 @@ constructor( override val selectedUserInfo: Flow<UserInfo> = selectedUser.map { it.userInfo } + @SuppressLint("MissingPermission") override fun refreshUsers() { applicationScope.launch { - val result = withContext(backgroundDispatcher) { manager.aliveUsers } - - if (result != null) { - _userInfos.value = - result - // Users should be sorted by ascending creation time. - .sortedBy { it.creationTime } - // The guest user is always last, regardless of creation time. - .sortedBy { it.isGuest } - } + _userInfos.value = + withContext(backgroundDispatcher) { manager.aliveUsers } + // Users should be sorted by ascending creation time. + .sortedBy { it.creationTime } + // The guest user is always last, regardless of creation time. + .sortedBy { it.isGuest } if (mainUserId == UserHandle.USER_NULL) { val mainUser = withContext(backgroundDispatcher) { manager.mainUser } diff --git a/packages/SystemUI/src/com/android/systemui/util/kotlin/Flow.kt b/packages/SystemUI/src/com/android/systemui/util/kotlin/Flow.kt index 8fe57e116405..d47413faeadf 100644 --- a/packages/SystemUI/src/com/android/systemui/util/kotlin/Flow.kt +++ b/packages/SystemUI/src/com/android/systemui/util/kotlin/Flow.kt @@ -20,21 +20,19 @@ import com.android.systemui.util.time.SystemClock import com.android.systemui.util.time.SystemClockImpl import java.util.concurrent.atomic.AtomicReference import kotlin.math.max -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.channelFlow import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart -import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch /** @@ -106,6 +104,14 @@ fun <S, T : S> Flow<T>.pairwise(initialValue: S): Flow<WithPrev<S, T>> = /** Holds a [newValue] emitted from a [Flow], along with the [previousValue] emitted value. */ data class WithPrev<out S, out T : S>(val previousValue: S, val newValue: T) +/** Emits a [Unit] only when the number of downstream subscribers of this flow increases. */ +fun <T> MutableSharedFlow<T>.onSubscriberAdded(): Flow<Unit> { + return subscriptionCount + .pairwise(initialValue = 0) + .filter { (previous, current) -> current > previous } + .map {} +} + /** * Returns a new [Flow] that combines the [Set] changes between each emission from [this] using * [transform]. @@ -183,34 +189,6 @@ fun <A> Flow<*>.sample(other: Flow<A>): Flow<A> = sample(other) { _, a -> a } /** * Returns a flow that mirrors the original flow, but delays values following emitted values for the - * given [periodMs]. If the original flow emits more than one value during this period, only the - * latest value is emitted. - * - * Example: - * ```kotlin - * flow { - * emit(1) // t=0ms - * delay(90) - * emit(2) // t=90ms - * delay(90) - * emit(3) // t=180ms - * delay(1010) - * emit(4) // t=1190ms - * delay(1010) - * emit(5) // t=2200ms - * }.throttle(1000) - * ``` - * - * produces the following emissions at the following times - * - * ```text - * 1 (t=0ms), 3 (t=1000ms), 4 (t=2000ms), 5 (t=3000ms) - * ``` - */ -fun <T> Flow<T>.throttle(periodMs: Long): Flow<T> = this.throttle(periodMs, SystemClockImpl()) - -/** - * Returns a flow that mirrors the original flow, but delays values following emitted values for the * given [periodMs] as reported by the given [clock]. If the original flow emits more than one value * during this period, only The latest value is emitted. * @@ -235,70 +213,37 @@ fun <T> Flow<T>.throttle(periodMs: Long): Flow<T> = this.throttle(periodMs, Syst * 1 (t=0ms), 3 (t=1000ms), 4 (t=2000ms), 5 (t=3000ms) * ``` */ -fun <T> Flow<T>.throttle(periodMs: Long, clock: SystemClock): Flow<T> = channelFlow { - coroutineScope { - var previousEmitTimeMs = 0L - var delayJob: Job? = null - var sendJob: Job? = null - val outerScope = this +fun <T> Flow<T>.throttle(periodMs: Long, clock: SystemClock = SystemClockImpl()): Flow<T> = + channelFlow { + coroutineScope { + var previousEmitTimeMs = 0L + var delayJob: Job? = null + var sendJob: Job? = null + val outerScope = this - collect { - delayJob?.cancel() - sendJob?.join() - val currentTimeMs = clock.elapsedRealtime() - val timeSinceLastEmit = currentTimeMs - previousEmitTimeMs - val timeUntilNextEmit = max(0L, periodMs - timeSinceLastEmit) - if (timeUntilNextEmit > 0L) { - // We create delayJob to allow cancellation during the delay period - delayJob = launch { - delay(timeUntilNextEmit) - sendJob = - outerScope.launch(start = CoroutineStart.UNDISPATCHED) { - send(it) - previousEmitTimeMs = clock.elapsedRealtime() - } + collect { + delayJob?.cancel() + sendJob?.join() + val currentTimeMs = clock.elapsedRealtime() + val timeSinceLastEmit = currentTimeMs - previousEmitTimeMs + val timeUntilNextEmit = max(0L, periodMs - timeSinceLastEmit) + if (timeUntilNextEmit > 0L) { + // We create delayJob to allow cancellation during the delay period + delayJob = launch { + delay(timeUntilNextEmit) + sendJob = + outerScope.launch(start = CoroutineStart.UNDISPATCHED) { + send(it) + previousEmitTimeMs = clock.elapsedRealtime() + } + } + } else { + send(it) + previousEmitTimeMs = currentTimeMs } - } else { - send(it) - previousEmitTimeMs = currentTimeMs } } } -} - -/** - * Returns a [StateFlow] launched in the surrounding [CoroutineScope]. This [StateFlow] gets its - * value by invoking [getValue] whenever an event is emitted from [changedSignals]. It will also - * immediately invoke [getValue] to establish its initial value. - */ -inline fun <T> CoroutineScope.stateFlow( - changedSignals: Flow<*>, - crossinline getValue: () -> T, -): StateFlow<T> = - changedSignals.map { getValue() }.stateIn(this, SharingStarted.Eagerly, getValue()) - -inline fun <T1, T2, T3, T4, T5, T6, R> combine( - flow: Flow<T1>, - flow2: Flow<T2>, - flow3: Flow<T3>, - flow4: Flow<T4>, - flow5: Flow<T5>, - flow6: Flow<T6>, - crossinline transform: suspend (T1, T2, T3, T4, T5, T6) -> R -): Flow<R> { - return kotlinx.coroutines.flow.combine(flow, flow2, flow3, flow4, flow5, flow6) { args: Array<*> - -> - @Suppress("UNCHECKED_CAST") - transform( - args[0] as T1, - args[1] as T2, - args[2] as T3, - args[3] as T4, - args[4] as T5, - args[5] as T6 - ) - } -} inline fun <T1, T2, T3, T4, T5, T6, T7, R> combine( flow: Flow<T1>, diff --git a/packages/SystemUI/tests/src/com/android/systemui/user/data/repository/UserRepositoryImplTest.kt b/packages/SystemUI/tests/src/com/android/systemui/user/data/repository/UserRepositoryImplTest.kt index abfff34f6dba..0669cb8b8ba5 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/user/data/repository/UserRepositoryImplTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/user/data/repository/UserRepositoryImplTest.kt @@ -31,6 +31,7 @@ import com.android.systemui.util.settings.FakeGlobalSettings import com.google.common.truth.Truth.assertThat import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.launchIn @@ -46,6 +47,7 @@ import org.mockito.Mockito.mock import org.mockito.Mockito.`when` as whenever import org.mockito.MockitoAnnotations +@OptIn(ExperimentalCoroutinesApi::class) @SmallTest @RunWith(JUnit4::class) class UserRepositoryImplTest : SysuiTestCase() { diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/bouncer/ui/viewmodel/BouncerViewModelKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/bouncer/ui/viewmodel/BouncerViewModelKosmos.kt index d91c5974815c..99dfe94af3df 100644 --- a/packages/SystemUI/tests/utils/src/com/android/systemui/bouncer/ui/viewmodel/BouncerViewModelKosmos.kt +++ b/packages/SystemUI/tests/utils/src/com/android/systemui/bouncer/ui/viewmodel/BouncerViewModelKosmos.kt @@ -21,11 +21,13 @@ import com.android.systemui.authentication.domain.interactor.authenticationInter import com.android.systemui.bouncer.domain.interactor.bouncerActionButtonInteractor import com.android.systemui.bouncer.domain.interactor.bouncerInteractor import com.android.systemui.bouncer.domain.interactor.simBouncerInteractor +import com.android.systemui.inputmethod.domain.interactor.inputMethodInteractor import com.android.systemui.kosmos.Kosmos import com.android.systemui.kosmos.Kosmos.Fixture import com.android.systemui.kosmos.testDispatcher import com.android.systemui.kosmos.testScope import com.android.systemui.scene.shared.flag.sceneContainerFlags +import com.android.systemui.user.domain.interactor.selectedUserInteractor import com.android.systemui.user.ui.viewmodel.userSwitcherViewModel import com.android.systemui.util.mockito.mock import com.android.systemui.util.time.systemClock @@ -36,8 +38,10 @@ val Kosmos.bouncerViewModel by Fixture { applicationScope = testScope.backgroundScope, mainDispatcher = testDispatcher, bouncerInteractor = bouncerInteractor, + inputMethodInteractor = inputMethodInteractor, simBouncerInteractor = simBouncerInteractor, authenticationInteractor = authenticationInteractor, + selectedUserInteractor = selectedUserInteractor, flags = sceneContainerFlags, selectedUser = userSwitcherViewModel.selectedUser, users = userSwitcherViewModel.users, diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/inputmethod/data/repository/FakeInputMethodRepository.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/inputmethod/data/repository/FakeInputMethodRepository.kt new file mode 100644 index 000000000000..8e4461dd5b1e --- /dev/null +++ b/packages/SystemUI/tests/utils/src/com/android/systemui/inputmethod/data/repository/FakeInputMethodRepository.kt @@ -0,0 +1,55 @@ +/* + * 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.systemui.inputmethod.data.repository + +import com.android.systemui.dagger.SysUISingleton +import com.android.systemui.inputmethod.data.model.InputMethodModel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.asFlow +import kotlinx.coroutines.flow.flowOf + +@SysUISingleton +class FakeInputMethodRepository : InputMethodRepository { + + private var usersToEnabledInputMethods: MutableMap<Int, Flow<InputMethodModel>> = mutableMapOf() + + var selectedInputMethodSubtypes = listOf<InputMethodModel.Subtype>() + + /** + * The display ID on which the input method picker dialog was shown, or `null` if the dialog was + * not shown. + */ + var inputMethodPickerShownDisplayId: Int? = null + + fun setEnabledInputMethods(userId: Int, vararg enabledInputMethods: InputMethodModel) { + usersToEnabledInputMethods[userId] = enabledInputMethods.asFlow() + } + + override suspend fun enabledInputMethods( + userId: Int, + fetchSubtypes: Boolean, + ): Flow<InputMethodModel> { + return usersToEnabledInputMethods[userId] ?: flowOf() + } + + override suspend fun selectedInputMethodSubtypes(): List<InputMethodModel.Subtype> = + selectedInputMethodSubtypes + + override suspend fun showInputMethodPicker(displayId: Int, showAuxiliarySubtypes: Boolean) { + inputMethodPickerShownDisplayId = displayId + } +} diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/inputmethod/data/repository/InputMethodRepositoryKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/inputmethod/data/repository/InputMethodRepositoryKosmos.kt new file mode 100644 index 000000000000..b71b9d878876 --- /dev/null +++ b/packages/SystemUI/tests/utils/src/com/android/systemui/inputmethod/data/repository/InputMethodRepositoryKosmos.kt @@ -0,0 +1,23 @@ +/* + * 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.systemui.inputmethod.data.repository + +import com.android.systemui.kosmos.Kosmos + +var Kosmos.inputMethodRepository: InputMethodRepository by + Kosmos.Fixture { fakeInputMethodRepository } +val Kosmos.fakeInputMethodRepository by Kosmos.Fixture { FakeInputMethodRepository() } diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/inputmethod/domain/interactor/InputMethodInteractorKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/inputmethod/domain/interactor/InputMethodInteractorKosmos.kt new file mode 100644 index 000000000000..da7757565888 --- /dev/null +++ b/packages/SystemUI/tests/utils/src/com/android/systemui/inputmethod/domain/interactor/InputMethodInteractorKosmos.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.systemui.inputmethod.domain.interactor + +import com.android.systemui.inputmethod.data.repository.inputMethodRepository +import com.android.systemui.kosmos.Kosmos + +val Kosmos.inputMethodInteractor by + Kosmos.Fixture { + InputMethodInteractor( + repository = inputMethodRepository, + ) + } |