1 /* 2 * Copyright (C) 2023 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package com.android.systemui.bouncer.ui.viewmodel 18 19 import android.content.Context 20 import com.android.systemui.R 21 import com.android.systemui.authentication.domain.interactor.AuthenticationInteractor 22 import com.android.systemui.authentication.domain.model.AuthenticationMethodModel 23 import com.android.systemui.bouncer.domain.interactor.BouncerInteractor 24 import com.android.systemui.dagger.SysUISingleton 25 import com.android.systemui.dagger.qualifiers.Application 26 import com.android.systemui.flags.FeatureFlags 27 import com.android.systemui.flags.Flags 28 import javax.inject.Inject 29 import kotlin.math.ceil 30 import kotlinx.coroutines.CoroutineScope 31 import kotlinx.coroutines.flow.MutableStateFlow 32 import kotlinx.coroutines.flow.SharingStarted 33 import kotlinx.coroutines.flow.StateFlow 34 import kotlinx.coroutines.flow.asStateFlow 35 import kotlinx.coroutines.flow.combine 36 import kotlinx.coroutines.flow.distinctUntilChanged 37 import kotlinx.coroutines.flow.map 38 import kotlinx.coroutines.flow.stateIn 39 import kotlinx.coroutines.launch 40 41 /** Holds UI state and handles user input on bouncer UIs. */ 42 @SysUISingleton 43 class BouncerViewModel 44 @Inject 45 constructor( 46 @Application private val applicationContext: Context, 47 @Application private val applicationScope: CoroutineScope, 48 private val bouncerInteractor: BouncerInteractor, 49 private val authenticationInteractor: AuthenticationInteractor, 50 featureFlags: FeatureFlags, 51 ) { 52 private val isInputEnabled: StateFlow<Boolean> = 53 bouncerInteractor.isThrottled 54 .map { !it } 55 .stateIn( 56 scope = applicationScope, 57 started = SharingStarted.WhileSubscribed(), 58 initialValue = !bouncerInteractor.isThrottled.value, 59 ) 60 61 private val pin: PinBouncerViewModel by lazy { 62 PinBouncerViewModel( 63 applicationContext = applicationContext, 64 applicationScope = applicationScope, 65 interactor = bouncerInteractor, 66 isInputEnabled = isInputEnabled, 67 ) 68 } 69 70 private val password: PasswordBouncerViewModel by lazy { 71 PasswordBouncerViewModel( 72 applicationScope = applicationScope, 73 interactor = bouncerInteractor, 74 isInputEnabled = isInputEnabled, 75 ) 76 } 77 78 private val pattern: PatternBouncerViewModel by lazy { 79 PatternBouncerViewModel( 80 applicationContext = applicationContext, 81 applicationScope = applicationScope, 82 interactor = bouncerInteractor, 83 isInputEnabled = isInputEnabled, 84 ) 85 } 86 87 /** View-model for the current UI, based on the current authentication method. */ 88 val authMethod: StateFlow<AuthMethodBouncerViewModel?> = 89 authenticationInteractor.authenticationMethod 90 .map { authenticationMethod -> 91 when (authenticationMethod) { 92 is AuthenticationMethodModel.Pin -> pin 93 is AuthenticationMethodModel.Password -> password 94 is AuthenticationMethodModel.Pattern -> pattern 95 else -> null 96 } 97 } 98 .stateIn( 99 scope = applicationScope, 100 started = SharingStarted.WhileSubscribed(), 101 initialValue = null, 102 ) 103 104 init { 105 if (featureFlags.isEnabled(Flags.SCENE_CONTAINER)) { 106 applicationScope.launch { 107 bouncerInteractor.isThrottled 108 .map { isThrottled -> 109 if (isThrottled) { 110 when (authenticationInteractor.getAuthenticationMethod()) { 111 is AuthenticationMethodModel.Pin -> 112 R.string.kg_too_many_failed_pin_attempts_dialog_message 113 is AuthenticationMethodModel.Password -> 114 R.string.kg_too_many_failed_password_attempts_dialog_message 115 is AuthenticationMethodModel.Pattern -> 116 R.string.kg_too_many_failed_pattern_attempts_dialog_message 117 else -> null 118 }?.let { stringResourceId -> 119 applicationContext.getString( 120 stringResourceId, 121 bouncerInteractor.throttling.value.failedAttemptCount, 122 ceil(bouncerInteractor.throttling.value.remainingMs / 1000f) 123 .toInt(), 124 ) 125 } 126 } else { 127 null 128 } 129 } 130 .distinctUntilChanged() 131 .collect { dialogMessageOrNull -> 132 if (dialogMessageOrNull != null) { 133 _throttlingDialogMessage.value = dialogMessageOrNull 134 } 135 } 136 } 137 } 138 } 139 140 /** The user-facing message to show in the bouncer. */ 141 val message: StateFlow<MessageViewModel> = 142 combine( 143 bouncerInteractor.message, 144 bouncerInteractor.isThrottled, 145 ) { message, isThrottled -> 146 toMessageViewModel(message, isThrottled) 147 } 148 .stateIn( 149 scope = applicationScope, 150 started = SharingStarted.WhileSubscribed(), 151 initialValue = 152 toMessageViewModel( 153 message = bouncerInteractor.message.value, 154 isThrottled = bouncerInteractor.isThrottled.value, 155 ), 156 ) 157 158 private val _throttlingDialogMessage = MutableStateFlow<String?>(null) 159 /** 160 * A message for a throttling dialog to show when the user has attempted the wrong credential 161 * too many times and now must wait a while before attempting again. 162 * 163 * If `null`, no dialog should be shown. 164 * 165 * Once the dialog is shown, the UI should call [onThrottlingDialogDismissed] when the user 166 * dismisses this dialog. 167 */ 168 val throttlingDialogMessage: StateFlow<String?> = _throttlingDialogMessage.asStateFlow() 169 170 /** Notifies that the emergency services button was clicked. */ 171 fun onEmergencyServicesButtonClicked() { 172 // TODO(b/280877228): implement this 173 } 174 175 /** Notifies that a throttling dialog has been dismissed by the user. */ 176 fun onThrottlingDialogDismissed() { 177 _throttlingDialogMessage.value = null 178 } 179 180 private fun toMessageViewModel( 181 message: String?, 182 isThrottled: Boolean, 183 ): MessageViewModel { 184 return MessageViewModel( 185 text = message ?: "", 186 isUpdateAnimated = !isThrottled, 187 ) 188 } 189 190 data class MessageViewModel( 191 val text: String, 192 193 /** 194 * Whether updates to the message should be cross-animated from one message to another. 195 * 196 * If `false`, no animation should be applied, the message text should just be replaced 197 * instantly. 198 */ 199 val isUpdateAnimated: Boolean, 200 ) 201 } 202