/* * Copyright (C) 2023 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. */ @file:OptIn(ExperimentalCoroutinesApi::class) package com.android.systemui.bouncer.ui.viewmodel import android.content.Context import android.view.KeyEvent.KEYCODE_0 import android.view.KeyEvent.KEYCODE_9 import android.view.KeyEvent.KEYCODE_DEL import android.view.KeyEvent.KEYCODE_NUMPAD_0 import android.view.KeyEvent.KEYCODE_NUMPAD_9 import android.view.KeyEvent.isConfirmKey import androidx.compose.ui.input.key.KeyEvent import androidx.compose.ui.input.key.KeyEventType import com.android.keyguard.PinShapeAdapter import com.android.systemui.authentication.shared.model.AuthenticationMethodModel import com.android.systemui.bouncer.domain.interactor.BouncerInteractor import com.android.systemui.bouncer.domain.interactor.SimBouncerInteractor import com.android.systemui.res.R import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch /** Holds UI state and handles user input for the PIN code bouncer UI. */ class PinBouncerViewModel( applicationContext: Context, viewModelScope: CoroutineScope, interactor: BouncerInteractor, isInputEnabled: StateFlow, private val onIntentionalUserInput: () -> Unit, private val simBouncerInteractor: SimBouncerInteractor, authenticationMethod: AuthenticationMethodModel, ) : AuthMethodBouncerViewModel( viewModelScope = viewModelScope, interactor = interactor, isInputEnabled = isInputEnabled, ) { /** * Whether the sim-related UI in the pin view is showing. * * This UI is used to unlock a locked sim. */ val isSimAreaVisible = authenticationMethod == AuthenticationMethodModel.Sim val isLockedEsim: StateFlow = simBouncerInteractor.isLockedEsim val errorDialogMessage: StateFlow = simBouncerInteractor.errorDialogMessage val isSimUnlockingDialogVisible: MutableStateFlow = MutableStateFlow(false) val pinShapes = PinShapeAdapter(applicationContext) private val mutablePinInput = MutableStateFlow(PinInputViewModel.empty()) /** Currently entered pin keys. */ val pinInput: StateFlow = mutablePinInput /** The length of the PIN for which we should show a hint. */ val hintedPinLength: StateFlow = if (isSimAreaVisible) { flowOf(null) } else { interactor.hintedPinLength } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null) /** Appearance of the backspace button. */ val backspaceButtonAppearance: StateFlow = combine( mutablePinInput, interactor.isAutoConfirmEnabled, ) { mutablePinEntries, isAutoConfirmEnabled -> computeBackspaceButtonAppearance( pinInput = mutablePinEntries, isAutoConfirmEnabled = isAutoConfirmEnabled, ) } .stateIn( scope = viewModelScope, // Make sure this is kept as WhileSubscribed or we can run into a bug where the // downstream continues to receive old/stale/cached values. started = SharingStarted.WhileSubscribed(), initialValue = ActionButtonAppearance.Hidden, ) /** Appearance of the confirm button. */ val confirmButtonAppearance: StateFlow = interactor.isAutoConfirmEnabled .map { if (it) ActionButtonAppearance.Hidden else ActionButtonAppearance.Shown } .stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(), initialValue = ActionButtonAppearance.Hidden, ) override val authenticationMethod: AuthenticationMethodModel = authenticationMethod override val lockoutMessageId = R.string.kg_too_many_failed_pin_attempts_dialog_message init { viewModelScope.launch { simBouncerInteractor.subId.collect { onResetSimFlow() } } } /** Notifies that the user dismissed the sim pin error dialog. */ fun onErrorDialogDismissed() { viewModelScope.launch { simBouncerInteractor.onErrorDialogDismissed() } } /** * Whether the digit buttons should be animated when touched. Note that this doesn't affect the * delete or enter buttons; those should always animate. */ val isDigitButtonAnimationEnabled: StateFlow = interactor.isPinEnhancedPrivacyEnabled .map { !it } .stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(), initialValue = !interactor.isPinEnhancedPrivacyEnabled.value, ) /** Notifies that the user clicked on a PIN button with the given digit value. */ fun onPinButtonClicked(input: Int) { val pinInput = mutablePinInput.value onIntentionalUserInput() val maxInputLength = hintedPinLength.value ?: Int.MAX_VALUE if (pinInput.getPin().size < maxInputLength) { mutablePinInput.value = pinInput.append(input) tryAuthenticate(useAutoConfirm = true) } } /** Notifies that the user clicked the backspace button. */ fun onBackspaceButtonClicked() { mutablePinInput.value = mutablePinInput.value.deleteLast() } /** Notifies that the user long-pressed the backspace button. */ fun onBackspaceButtonLongPressed() { clearInput() } /** Notifies that the user clicked the "enter" button. */ fun onAuthenticateButtonClicked() { if (authenticationMethod == AuthenticationMethodModel.Sim) { viewModelScope.launch { isSimUnlockingDialogVisible.value = true simBouncerInteractor.verifySim(getInput()) isSimUnlockingDialogVisible.value = false clearInput() } } else { tryAuthenticate(useAutoConfirm = false) } } fun onDisableEsimButtonClicked() { viewModelScope.launch { simBouncerInteractor.disableEsim() } } /** Resets the sim screen and shows a default message. */ private fun onResetSimFlow() { simBouncerInteractor.resetSimPukUserInput() clearInput() } override fun clearInput() { mutablePinInput.value = mutablePinInput.value.clearAll() } override fun getInput(): List { return mutablePinInput.value.getPin() } private fun computeBackspaceButtonAppearance( pinInput: PinInputViewModel, isAutoConfirmEnabled: Boolean, ): ActionButtonAppearance { val isEmpty = pinInput.isEmpty() return when { isAutoConfirmEnabled && isEmpty -> ActionButtonAppearance.Hidden isAutoConfirmEnabled -> ActionButtonAppearance.Subtle else -> ActionButtonAppearance.Shown } } /** * Notifies that a key event has occurred. * * @return `true` when the [KeyEvent] was consumed as user input on bouncer; `false` otherwise. */ fun onKeyEvent(type: KeyEventType, keyCode: Int): Boolean { return when (type) { KeyEventType.KeyUp -> { if (isConfirmKey(keyCode)) { onAuthenticateButtonClicked() true } else { false } } KeyEventType.KeyDown -> { when (keyCode) { KEYCODE_DEL -> { onBackspaceButtonClicked() true } in KEYCODE_0..KEYCODE_9 -> { onPinButtonClicked(keyCode - KEYCODE_0) true } in KEYCODE_NUMPAD_0..KEYCODE_NUMPAD_9 -> { onPinButtonClicked(keyCode - KEYCODE_NUMPAD_0) true } else -> { false } } } else -> false } } } /** Appearance of pin-pad action buttons. */ enum class ActionButtonAppearance { /** Button must not be shown. */ Hidden, /** Button is shown, but with no background to make it less prominent. */ Subtle, /** Button is shown. */ Shown, }