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