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