summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PasswordBouncer.kt30
-rw-r--r--packages/SystemUI/multivalentTests/src/com/android/systemui/bouncer/ui/viewmodel/PasswordBouncerViewModelTest.kt104
-rw-r--r--packages/SystemUI/multivalentTests/src/com/android/systemui/inputmethod/data/repository/InputMethodRepositoryTest.kt124
-rw-r--r--packages/SystemUI/multivalentTests/src/com/android/systemui/inputmethod/domain/interactor/InputMethodInteractorTest.kt157
-rw-r--r--packages/SystemUI/src/com/android/systemui/authentication/data/repository/AuthenticationRepository.kt8
-rw-r--r--packages/SystemUI/src/com/android/systemui/bouncer/data/repository/BouncerRepository.kt9
-rw-r--r--packages/SystemUI/src/com/android/systemui/bouncer/domain/interactor/BouncerInteractor.kt8
-rw-r--r--packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/BouncerViewModel.kt14
-rw-r--r--packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/PasswordBouncerViewModel.kt52
-rw-r--r--packages/SystemUI/src/com/android/systemui/dagger/SystemUIModule.java2
-rw-r--r--packages/SystemUI/src/com/android/systemui/inputmethod/InputMethodModule.kt29
-rw-r--r--packages/SystemUI/src/com/android/systemui/inputmethod/data/model/InputMethodModel.kt51
-rw-r--r--packages/SystemUI/src/com/android/systemui/inputmethod/data/repository/InputMethodRepository.kt139
-rw-r--r--packages/SystemUI/src/com/android/systemui/inputmethod/domain/interactor/InputMethodInteractor.kt53
-rw-r--r--packages/SystemUI/src/com/android/systemui/user/data/repository/UserRepository.kt18
-rw-r--r--packages/SystemUI/src/com/android/systemui/util/kotlin/Flow.kt127
-rw-r--r--packages/SystemUI/tests/src/com/android/systemui/user/data/repository/UserRepositoryImplTest.kt2
-rw-r--r--packages/SystemUI/tests/utils/src/com/android/systemui/bouncer/ui/viewmodel/BouncerViewModelKosmos.kt4
-rw-r--r--packages/SystemUI/tests/utils/src/com/android/systemui/inputmethod/data/repository/FakeInputMethodRepository.kt55
-rw-r--r--packages/SystemUI/tests/utils/src/com/android/systemui/inputmethod/data/repository/InputMethodRepositoryKosmos.kt23
-rw-r--r--packages/SystemUI/tests/utils/src/com/android/systemui/inputmethod/domain/interactor/InputMethodInteractorKosmos.kt27
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,
+ )
+ }