• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
<lambda>null2  * Copyright (C) 2024 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 android.util.PluralsMessageFormatter
21 import com.android.systemui.authentication.domain.interactor.AuthenticationInteractor
22 import com.android.systemui.authentication.shared.model.AuthenticationMethodModel
23 import com.android.systemui.bouncer.domain.interactor.BouncerInteractor
24 import com.android.systemui.bouncer.domain.interactor.SimBouncerInteractor
25 import com.android.systemui.bouncer.shared.flag.ComposeBouncerFlags
26 import com.android.systemui.bouncer.shared.model.BouncerMessagePair
27 import com.android.systemui.bouncer.shared.model.BouncerMessageStrings
28 import com.android.systemui.bouncer.shared.model.primaryMessage
29 import com.android.systemui.bouncer.shared.model.secondaryMessage
30 import com.android.systemui.dagger.SysUISingleton
31 import com.android.systemui.dagger.qualifiers.Application
32 import com.android.systemui.deviceentry.domain.interactor.BiometricMessageInteractor
33 import com.android.systemui.deviceentry.domain.interactor.DeviceEntryFaceAuthInteractor
34 import com.android.systemui.deviceentry.domain.interactor.DeviceEntryFingerprintAuthInteractor
35 import com.android.systemui.deviceentry.domain.interactor.DeviceEntryInteractor
36 import com.android.systemui.deviceentry.shared.model.DeviceEntryRestrictionReason
37 import com.android.systemui.deviceentry.shared.model.FaceFailureMessage
38 import com.android.systemui.deviceentry.shared.model.FaceLockoutMessage
39 import com.android.systemui.deviceentry.shared.model.FaceTimeoutMessage
40 import com.android.systemui.deviceentry.shared.model.FingerprintFailureMessage
41 import com.android.systemui.deviceentry.shared.model.FingerprintLockoutMessage
42 import com.android.systemui.res.R.string.kg_too_many_failed_attempts_countdown
43 import com.android.systemui.user.ui.viewmodel.UserSwitcherViewModel
44 import com.android.systemui.user.ui.viewmodel.UserViewModel
45 import com.android.systemui.util.kotlin.Utils.Companion.sample
46 import com.android.systemui.util.time.SystemClock
47 import dagger.Module
48 import dagger.Provides
49 import kotlin.math.ceil
50 import kotlin.math.max
51 import kotlin.time.Duration.Companion.seconds
52 import kotlinx.coroutines.CoroutineScope
53 import kotlinx.coroutines.ExperimentalCoroutinesApi
54 import kotlinx.coroutines.Job
55 import kotlinx.coroutines.delay
56 import kotlinx.coroutines.flow.Flow
57 import kotlinx.coroutines.flow.MutableSharedFlow
58 import kotlinx.coroutines.flow.MutableStateFlow
59 import kotlinx.coroutines.flow.collectLatest
60 import kotlinx.coroutines.flow.combine
61 import kotlinx.coroutines.flow.emptyFlow
62 import kotlinx.coroutines.flow.flatMapLatest
63 import kotlinx.coroutines.flow.map
64 import kotlinx.coroutines.launch
65 
66 /** Holds UI state for the 2-line status message shown on the bouncer. */
67 @OptIn(ExperimentalCoroutinesApi::class)
68 class BouncerMessageViewModel(
69     @Application private val applicationContext: Context,
70     @Application private val applicationScope: CoroutineScope,
71     private val bouncerInteractor: BouncerInteractor,
72     private val simBouncerInteractor: SimBouncerInteractor,
73     private val authenticationInteractor: AuthenticationInteractor,
74     selectedUser: Flow<UserViewModel>,
75     private val clock: SystemClock,
76     private val biometricMessageInteractor: BiometricMessageInteractor,
77     private val faceAuthInteractor: DeviceEntryFaceAuthInteractor,
78     private val deviceEntryInteractor: DeviceEntryInteractor,
79     private val fingerprintInteractor: DeviceEntryFingerprintAuthInteractor,
80     flags: ComposeBouncerFlags,
81 ) {
82     /**
83      * A message shown when the user has attempted the wrong credential too many times and now must
84      * wait a while before attempting to authenticate again.
85      *
86      * This is updated every second (countdown) during the lockout. When lockout is not active, this
87      * is `null` and no lockout message should be shown.
88      */
89     private val lockoutMessage: MutableStateFlow<MessageViewModel?> = MutableStateFlow(null)
90 
91     /** Whether there is a lockout message that is available to be shown in the status message. */
92     val isLockoutMessagePresent: Flow<Boolean> = lockoutMessage.map { it != null }
93 
94     /** The user-facing message to show in the bouncer. */
95     val message: MutableStateFlow<MessageViewModel?> = MutableStateFlow(null)
96 
97     /** Initializes the bouncer message to default whenever it is shown. */
98     fun onShown() {
99         showDefaultMessage()
100     }
101 
102     /** Reset the message shown on the bouncer to the default message. */
103     fun showDefaultMessage() {
104         resetToDefault.tryEmit(Unit)
105     }
106 
107     private val resetToDefault = MutableSharedFlow<Unit>(replay = 1)
108 
109     private var lockoutCountdownJob: Job? = null
110 
111     private fun defaultBouncerMessageInitializer() {
112         applicationScope.launch {
113             resetToDefault.emit(Unit)
114             authenticationInteractor.authenticationMethod
115                 .flatMapLatest { authMethod ->
116                     if (authMethod == AuthenticationMethodModel.Sim) {
117                         resetToDefault.map {
118                             MessageViewModel(simBouncerInteractor.getDefaultMessage())
119                         }
120                     } else if (authMethod.isSecure) {
121                         combine(
122                             deviceEntryInteractor.deviceEntryRestrictionReason,
123                             lockoutMessage,
124                             fingerprintInteractor.isFingerprintCurrentlyAllowedOnBouncer,
125                             resetToDefault,
126                         ) { deviceEntryRestrictedReason, lockoutMsg, isFpAllowedInBouncer, _ ->
127                             lockoutMsg
128                                 ?: deviceEntryRestrictedReason.toMessage(
129                                     authMethod,
130                                     isFpAllowedInBouncer
131                                 )
132                         }
133                     } else {
134                         emptyFlow()
135                     }
136                 }
137                 .collectLatest { messageViewModel -> message.value = messageViewModel }
138         }
139     }
140 
141     private fun listenForSimBouncerEvents() {
142         // Listen for any events from the SIM bouncer and update the message shown on the bouncer.
143         applicationScope.launch {
144             authenticationInteractor.authenticationMethod
145                 .flatMapLatest { authMethod ->
146                     if (authMethod == AuthenticationMethodModel.Sim) {
147                         simBouncerInteractor.bouncerMessageChanged.map { simMsg ->
148                             simMsg?.let { MessageViewModel(it) }
149                         }
150                     } else {
151                         emptyFlow()
152                     }
153                 }
154                 .collectLatest {
155                     if (it != null) {
156                         message.value = it
157                     } else {
158                         resetToDefault.emit(Unit)
159                     }
160                 }
161         }
162     }
163 
164     private fun listenForFaceMessages() {
165         // Listen for any events from face authentication and update the message shown on the
166         // bouncer.
167         applicationScope.launch {
168             biometricMessageInteractor.faceMessage
169                 .sample(
170                     authenticationInteractor.authenticationMethod,
171                     fingerprintInteractor.isFingerprintCurrentlyAllowedOnBouncer,
172                 )
173                 .collectLatest { (faceMessage, authMethod, fingerprintAllowedOnBouncer) ->
174                     val isFaceAuthStrong = faceAuthInteractor.isFaceAuthStrong()
175                     val defaultPrimaryMessage =
176                         BouncerMessageStrings.defaultMessage(
177                                 authMethod,
178                                 fingerprintAllowedOnBouncer
179                             )
180                             .primaryMessage
181                             .toResString()
182                     message.value =
183                         when (faceMessage) {
184                             is FaceTimeoutMessage ->
185                                 MessageViewModel(
186                                     text = defaultPrimaryMessage,
187                                     secondaryText = faceMessage.message,
188                                     isUpdateAnimated = true
189                                 )
190                             is FaceLockoutMessage ->
191                                 if (isFaceAuthStrong)
192                                     BouncerMessageStrings.class3AuthLockedOut(authMethod)
193                                         .toMessage()
194                                 else
195                                     BouncerMessageStrings.faceLockedOut(
196                                             authMethod,
197                                             fingerprintAllowedOnBouncer
198                                         )
199                                         .toMessage()
200                             is FaceFailureMessage ->
201                                 BouncerMessageStrings.incorrectFaceInput(
202                                         authMethod,
203                                         fingerprintAllowedOnBouncer
204                                     )
205                                     .toMessage()
206                             else ->
207                                 MessageViewModel(
208                                     text = defaultPrimaryMessage,
209                                     secondaryText = faceMessage.message,
210                                     isUpdateAnimated = false
211                                 )
212                         }
213                     delay(MESSAGE_DURATION)
214                     resetToDefault.emit(Unit)
215                 }
216         }
217     }
218 
219     private fun listenForFingerprintMessages() {
220         applicationScope.launch {
221             // Listen for any events from fingerprint authentication and update the message shown
222             // on the bouncer.
223             biometricMessageInteractor.fingerprintMessage
224                 .sample(
225                     authenticationInteractor.authenticationMethod,
226                     fingerprintInteractor.isFingerprintCurrentlyAllowedOnBouncer
227                 )
228                 .collectLatest { (fingerprintMessage, authMethod, isFingerprintAllowed) ->
229                     val defaultPrimaryMessage =
230                         BouncerMessageStrings.defaultMessage(authMethod, isFingerprintAllowed)
231                             .primaryMessage
232                             .toResString()
233                     message.value =
234                         when (fingerprintMessage) {
235                             is FingerprintLockoutMessage ->
236                                 BouncerMessageStrings.class3AuthLockedOut(authMethod).toMessage()
237                             is FingerprintFailureMessage ->
238                                 BouncerMessageStrings.incorrectFingerprintInput(authMethod)
239                                     .toMessage()
240                             else ->
241                                 MessageViewModel(
242                                     text = defaultPrimaryMessage,
243                                     secondaryText = fingerprintMessage.message,
244                                     isUpdateAnimated = false
245                                 )
246                         }
247                     delay(MESSAGE_DURATION)
248                     resetToDefault.emit(Unit)
249                 }
250         }
251     }
252 
253     private fun listenForBouncerEvents() {
254         // Keeps the lockout message up-to-date.
255         applicationScope.launch {
256             bouncerInteractor.onLockoutStarted.collect { startLockoutCountdown() }
257         }
258 
259         // Listens to relevant bouncer events
260         applicationScope.launch {
261             bouncerInteractor.onIncorrectBouncerInput
262                 .sample(
263                     authenticationInteractor.authenticationMethod,
264                     fingerprintInteractor.isFingerprintCurrentlyAllowedOnBouncer
265                 )
266                 .collectLatest { (_, authMethod, isFingerprintAllowed) ->
267                     message.emit(
268                         BouncerMessageStrings.incorrectSecurityInput(
269                                 authMethod,
270                                 isFingerprintAllowed
271                             )
272                             .toMessage()
273                     )
274                     delay(MESSAGE_DURATION)
275                     resetToDefault.emit(Unit)
276                 }
277         }
278     }
279 
280     private fun DeviceEntryRestrictionReason?.toMessage(
281         authMethod: AuthenticationMethodModel,
282         isFingerprintAllowedOnBouncer: Boolean,
283     ): MessageViewModel {
284         return when (this) {
285             DeviceEntryRestrictionReason.UserLockdown ->
286                 BouncerMessageStrings.authRequiredAfterUserLockdown(authMethod)
287             DeviceEntryRestrictionReason.DeviceNotUnlockedSinceReboot ->
288                 BouncerMessageStrings.authRequiredAfterReboot(authMethod)
289             DeviceEntryRestrictionReason.PolicyLockdown ->
290                 BouncerMessageStrings.authRequiredAfterAdminLockdown(authMethod)
291             DeviceEntryRestrictionReason.UnattendedUpdate ->
292                 BouncerMessageStrings.authRequiredForUnattendedUpdate(authMethod)
293             DeviceEntryRestrictionReason.DeviceNotUnlockedSinceMainlineUpdate ->
294                 BouncerMessageStrings.authRequiredForMainlineUpdate(authMethod)
295             DeviceEntryRestrictionReason.SecurityTimeout ->
296                 BouncerMessageStrings.authRequiredAfterPrimaryAuthTimeout(authMethod)
297             DeviceEntryRestrictionReason.StrongBiometricsLockedOut ->
298                 BouncerMessageStrings.class3AuthLockedOut(authMethod)
299             DeviceEntryRestrictionReason.NonStrongFaceLockedOut ->
300                 BouncerMessageStrings.faceLockedOut(authMethod, isFingerprintAllowedOnBouncer)
301             DeviceEntryRestrictionReason.NonStrongBiometricsSecurityTimeout ->
302                 BouncerMessageStrings.nonStrongAuthTimeout(
303                     authMethod,
304                     isFingerprintAllowedOnBouncer
305                 )
306             DeviceEntryRestrictionReason.TrustAgentDisabled ->
307                 BouncerMessageStrings.trustAgentDisabled(authMethod, isFingerprintAllowedOnBouncer)
308             DeviceEntryRestrictionReason.AdaptiveAuthRequest ->
309                 BouncerMessageStrings.authRequiredAfterAdaptiveAuthRequest(
310                     authMethod,
311                     isFingerprintAllowedOnBouncer
312                 )
313             else -> BouncerMessageStrings.defaultMessage(authMethod, isFingerprintAllowedOnBouncer)
314         }.toMessage()
315     }
316 
317     private fun BouncerMessagePair.toMessage(): MessageViewModel {
318         val primaryMsg = this.primaryMessage.toResString()
319         val secondaryMsg =
320             if (this.secondaryMessage == 0) "" else this.secondaryMessage.toResString()
321         return MessageViewModel(primaryMsg, secondaryText = secondaryMsg, isUpdateAnimated = true)
322     }
323 
324     /** Shows the countdown message and refreshes it every second. */
325     private fun startLockoutCountdown() {
326         lockoutCountdownJob?.cancel()
327         lockoutCountdownJob =
328             applicationScope.launch {
329                 authenticationInteractor.authenticationMethod.collectLatest { authMethod ->
330                     do {
331                         val remainingSeconds = remainingLockoutSeconds()
332                         val authLockedOutMsg =
333                             BouncerMessageStrings.primaryAuthLockedOut(authMethod)
334                         lockoutMessage.value =
335                             if (remainingSeconds > 0) {
336                                 MessageViewModel(
337                                     text =
338                                         kg_too_many_failed_attempts_countdown.toPluralString(
339                                             mutableMapOf<String, Any>(
340                                                 Pair("count", remainingSeconds)
341                                             )
342                                         ),
343                                     secondaryText = authLockedOutMsg.secondaryMessage.toResString(),
344                                     isUpdateAnimated = false
345                                 )
346                             } else {
347                                 null
348                             }
349                         delay(1.seconds)
350                     } while (remainingSeconds > 0)
351                     lockoutCountdownJob = null
352                 }
353             }
354     }
355 
356     private fun remainingLockoutSeconds(): Int {
357         val endTimestampMs = authenticationInteractor.lockoutEndTimestamp ?: 0
358         val remainingMs = max(0, endTimestampMs - clock.elapsedRealtime())
359         return ceil(remainingMs / 1000f).toInt()
360     }
361 
362     private fun Int.toPluralString(formatterArgs: Map<String, Any>): String =
363         PluralsMessageFormatter.format(applicationContext.resources, formatterArgs, this)
364 
365     private fun Int.toResString(): String = applicationContext.getString(this)
366 
367     init {
368         if (flags.isComposeBouncerOrSceneContainerEnabled()) {
369             applicationScope.launch {
370                 // Update the lockout countdown whenever the selected user is switched.
371                 selectedUser.collect { startLockoutCountdown() }
372             }
373 
374             defaultBouncerMessageInitializer()
375 
376             listenForSimBouncerEvents()
377             listenForBouncerEvents()
378             listenForFaceMessages()
379             listenForFingerprintMessages()
380         }
381     }
382 
383     companion object {
384         private const val MESSAGE_DURATION = 2000L
385     }
386 }
387 
388 /** Data class that represents the status message show on the bouncer. */
389 data class MessageViewModel(
390     val text: String,
391     val secondaryText: String? = null,
392     /**
393      * Whether updates to the message should be cross-animated from one message to another.
394      *
395      * If `false`, no animation should be applied, the message text should just be replaced
396      * instantly.
397      */
398     val isUpdateAnimated: Boolean = true,
399 )
400 
401 @OptIn(ExperimentalCoroutinesApi::class)
402 @Module
403 object BouncerMessageViewModelModule {
404 
405     @Provides
406     @SysUISingleton
viewModelnull407     fun viewModel(
408         @Application applicationContext: Context,
409         @Application applicationScope: CoroutineScope,
410         bouncerInteractor: BouncerInteractor,
411         simBouncerInteractor: SimBouncerInteractor,
412         authenticationInteractor: AuthenticationInteractor,
413         clock: SystemClock,
414         biometricMessageInteractor: BiometricMessageInteractor,
415         faceAuthInteractor: DeviceEntryFaceAuthInteractor,
416         deviceEntryInteractor: DeviceEntryInteractor,
417         fingerprintInteractor: DeviceEntryFingerprintAuthInteractor,
418         flags: ComposeBouncerFlags,
419         userSwitcherViewModel: UserSwitcherViewModel,
420     ): BouncerMessageViewModel {
421         return BouncerMessageViewModel(
422             applicationContext = applicationContext,
423             applicationScope = applicationScope,
424             bouncerInteractor = bouncerInteractor,
425             simBouncerInteractor = simBouncerInteractor,
426             authenticationInteractor = authenticationInteractor,
427             clock = clock,
428             biometricMessageInteractor = biometricMessageInteractor,
429             faceAuthInteractor = faceAuthInteractor,
430             deviceEntryInteractor = deviceEntryInteractor,
431             fingerprintInteractor = fingerprintInteractor,
432             flags = flags,
433             selectedUser = userSwitcherViewModel.selectedUser,
434         )
435     }
436 }
437