Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit e75340b3 authored by burakov's avatar burakov
Browse files

[flexiglass] Add an InputMethod module + IME switcher button to bouncer.

* Bonus: Add a new utility extension function `subscribersIncreased` to
  `Flow`, and remove the unused `stateFlow` function.

Fix: 300459199
Test: Manually tested by adding another input language and successfully
 switching it in the password bouncer using the button.
Test: Added unit tests, existing ones still pass.
Flag: ACONFIG com.android.systemui.scene_container DEVELOPMENT
Change-Id: Id223990bfac777d5dcad071d6428824e94801ca0
parent 1f5ce1eb
Loading
Loading
Loading
Loading
+30 −0
Original line number Diff line number Diff line
@@ -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,
            )
    )
}
+100 −4
Original line number Diff line number Diff line
@@ -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),
            )
    }
}
+124 −0
Original line number Diff line number Diff line
/*
 * 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)
    }
}
+157 −0
Original line number Diff line number Diff line
/*
 * 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
    }
}
+2 −6
Original line number Diff line number Diff line
@@ -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
                }
Loading