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 package com.android.systemui.biometrics.ui.viewmodel
17 
18 import android.content.Context
19 import android.graphics.Rect
20 import android.hardware.biometrics.BiometricPrompt
21 import android.util.Log
22 import android.view.HapticFeedbackConstants
23 import android.view.MotionEvent
24 import com.android.systemui.biometrics.AuthBiometricView
25 import com.android.systemui.biometrics.Utils
26 import com.android.systemui.biometrics.domain.interactor.DisplayStateInteractor
27 import com.android.systemui.biometrics.domain.interactor.PromptSelectorInteractor
28 import com.android.systemui.biometrics.domain.model.BiometricModalities
29 import com.android.systemui.biometrics.shared.model.BiometricModality
30 import com.android.systemui.biometrics.shared.model.DisplayRotation
31 import com.android.systemui.biometrics.shared.model.PromptKind
32 import com.android.systemui.dagger.qualifiers.Application
33 import com.android.systemui.flags.FeatureFlags
34 import com.android.systemui.flags.Flags.ONE_WAY_HAPTICS_API_MIGRATION
35 import com.android.systemui.statusbar.VibratorHelper
36 import javax.inject.Inject
37 import kotlinx.coroutines.Job
38 import kotlinx.coroutines.coroutineScope
39 import kotlinx.coroutines.delay
40 import kotlinx.coroutines.flow.Flow
41 import kotlinx.coroutines.flow.MutableStateFlow
42 import kotlinx.coroutines.flow.StateFlow
43 import kotlinx.coroutines.flow.asStateFlow
44 import kotlinx.coroutines.flow.combine
45 import kotlinx.coroutines.flow.distinctUntilChanged
46 import kotlinx.coroutines.flow.first
47 import kotlinx.coroutines.flow.map
48 import kotlinx.coroutines.launch
49 
50 /** ViewModel for BiometricPrompt. */
51 class PromptViewModel
52 @Inject
53 constructor(
54     private val displayStateInteractor: DisplayStateInteractor,
55     private val promptSelectorInteractor: PromptSelectorInteractor,
56     private val vibrator: VibratorHelper,
57     @Application context: Context,
58     private val featureFlags: FeatureFlags,
59 ) {
60     /** Models UI of [BiometricPromptLayout.iconView] */
61     val fingerprintIconViewModel: PromptFingerprintIconViewModel =
62         PromptFingerprintIconViewModel(displayStateInteractor, promptSelectorInteractor)
63 
64     /** The set of modalities available for this prompt */
65     val modalities: Flow<BiometricModalities> =
66         promptSelectorInteractor.prompt
67             .map { it?.modalities ?: BiometricModalities() }
68             .distinctUntilChanged()
69 
70     // TODO(b/251476085): remove after icon controllers are migrated - do not keep this state
71     private var _legacyState = MutableStateFlow(AuthBiometricView.STATE_IDLE)
72     val legacyState: StateFlow<Int> = _legacyState.asStateFlow()
73 
74     private val _isAuthenticating: MutableStateFlow<Boolean> = MutableStateFlow(false)
75 
76     /** If the user is currently authenticating (i.e. at least one biometric is scanning). */
77     val isAuthenticating: Flow<Boolean> = _isAuthenticating.asStateFlow()
78 
79     private val _isAuthenticated: MutableStateFlow<PromptAuthState> =
80         MutableStateFlow(PromptAuthState(false))
81 
82     /** If the user has successfully authenticated and confirmed (when explicitly required). */
83     val isAuthenticated: Flow<PromptAuthState> = _isAuthenticated.asStateFlow()
84 
85     private val _isOverlayTouched: MutableStateFlow<Boolean> = MutableStateFlow(false)
86 
87     /** The kind of credential the user has. */
88     val credentialKind: Flow<PromptKind> = promptSelectorInteractor.credentialKind
89 
90     /** The label to use for the cancel button. */
91     val negativeButtonText: Flow<String> =
92         promptSelectorInteractor.prompt.map { it?.negativeButtonText ?: "" }
93 
94     private val _message: MutableStateFlow<PromptMessage> = MutableStateFlow(PromptMessage.Empty)
95 
96     /** A message to show the user, if there is an error, hint, or help to show. */
97     val message: Flow<PromptMessage> = _message.asStateFlow()
98 
99     private val isRetrySupported: Flow<Boolean> = modalities.map { it.hasFace }
100 
101     private val _fingerprintStartMode = MutableStateFlow(FingerprintStartMode.Pending)
102 
103     /** Fingerprint sensor state. */
104     val fingerprintStartMode: Flow<FingerprintStartMode> = _fingerprintStartMode.asStateFlow()
105 
106     private val _forceLargeSize = MutableStateFlow(false)
107     private val _forceMediumSize = MutableStateFlow(false)
108 
109     private val _hapticsToPlay = MutableStateFlow(HapticFeedbackConstants.NO_HAPTICS)
110 
111     /** Event fired to the view indicating a [HapticFeedbackConstants] to be played */
112     val hapticsToPlay = _hapticsToPlay.asStateFlow()
113 
114     /** The size of the prompt. */
115     val size: Flow<PromptSize> =
116         combine(
117                 _forceLargeSize,
118                 _forceMediumSize,
119                 modalities,
120                 promptSelectorInteractor.isConfirmationRequired,
121                 fingerprintStartMode,
122             ) { forceLarge, forceMedium, modalities, confirmationRequired, fpStartMode ->
123                 when {
124                     forceLarge -> PromptSize.LARGE
125                     forceMedium -> PromptSize.MEDIUM
126                     modalities.hasFaceOnly && !confirmationRequired -> PromptSize.SMALL
127                     modalities.hasFaceAndFingerprint &&
128                         !confirmationRequired &&
129                         fpStartMode == FingerprintStartMode.Pending -> PromptSize.SMALL
130                     else -> PromptSize.MEDIUM
131                 }
132             }
133             .distinctUntilChanged()
134 
135     /**
136      * If the API caller or the user's personal preferences require explicit confirmation after
137      * successful authentication. Confirmation always required when in explicit flow.
138      */
139     val isConfirmationRequired: Flow<Boolean> =
140         combine(_isOverlayTouched, size) { isOverlayTouched, size ->
141             !isOverlayTouched && size.isNotSmall
142         }
143 
144     /** Padding for prompt UI elements */
145     val promptPadding: Flow<Rect> =
146         combine(size, displayStateInteractor.currentRotation) { size, rotation ->
147             if (size != PromptSize.LARGE) {
148                 val navBarInsets = Utils.getNavbarInsets(context)
149                 if (rotation == DisplayRotation.ROTATION_90) {
150                     Rect(0, 0, navBarInsets.right, 0)
151                 } else if (rotation == DisplayRotation.ROTATION_270) {
152                     Rect(navBarInsets.left, 0, 0, 0)
153                 } else {
154                     Rect(0, 0, 0, navBarInsets.bottom)
155                 }
156             } else {
157                 Rect(0, 0, 0, 0)
158             }
159         }
160 
161     /** Title for the prompt. */
162     val title: Flow<String> =
163         promptSelectorInteractor.prompt.map { it?.title ?: "" }.distinctUntilChanged()
164 
165     /** Subtitle for the prompt. */
166     val subtitle: Flow<String> =
167         promptSelectorInteractor.prompt.map { it?.subtitle ?: "" }.distinctUntilChanged()
168 
169     /** Description for the prompt. */
170     val description: Flow<String> =
171         promptSelectorInteractor.prompt.map { it?.description ?: "" }.distinctUntilChanged()
172 
173     /** If the indicator (help, error) message should be shown. */
174     val isIndicatorMessageVisible: Flow<Boolean> =
175         combine(
176                 size,
177                 message,
178             ) { size, message ->
179                 size.isNotSmall && message.message.isNotBlank()
180             }
181             .distinctUntilChanged()
182 
183     /** If the auth is pending confirmation and the confirm button should be shown. */
184     val isConfirmButtonVisible: Flow<Boolean> =
185         combine(
186                 size,
187                 isAuthenticated,
188             ) { size, authState ->
189                 size.isNotSmall && authState.isAuthenticated && authState.needsUserConfirmation
190             }
191             .distinctUntilChanged()
192 
193     /** If the icon can be used as a confirmation button. */
194     val isIconConfirmButton: Flow<Boolean> = size.map { it.isNotSmall }.distinctUntilChanged()
195 
196     /** If the negative button should be shown. */
197     val isNegativeButtonVisible: Flow<Boolean> =
198         combine(
199                 size,
200                 isAuthenticated,
201                 promptSelectorInteractor.isCredentialAllowed,
202             ) { size, authState, credentialAllowed ->
203                 size.isNotSmall && authState.isNotAuthenticated && !credentialAllowed
204             }
205             .distinctUntilChanged()
206 
207     /** If the cancel button should be shown (. */
208     val isCancelButtonVisible: Flow<Boolean> =
209         combine(
210                 size,
211                 isAuthenticated,
212                 isNegativeButtonVisible,
213                 isConfirmButtonVisible,
214             ) { size, authState, showNegativeButton, showConfirmButton ->
215                 size.isNotSmall &&
216                     authState.isAuthenticated &&
217                     !showNegativeButton &&
218                     showConfirmButton
219             }
220             .distinctUntilChanged()
221 
222     private val _canTryAgainNow = MutableStateFlow(false)
223     /**
224      * If authentication can be manually restarted via the try again button or touching a
225      * fingerprint sensor.
226      */
227     val canTryAgainNow: Flow<Boolean> =
228         combine(
229                 _canTryAgainNow,
230                 size,
231                 isAuthenticated,
232                 isRetrySupported,
233             ) { readyToTryAgain, size, authState, supportsRetry ->
234                 readyToTryAgain && size.isNotSmall && supportsRetry && authState.isNotAuthenticated
235             }
236             .distinctUntilChanged()
237 
238     /** If the try again button show be shown (only the button, see [canTryAgainNow]). */
239     val isTryAgainButtonVisible: Flow<Boolean> =
240         combine(
241                 canTryAgainNow,
242                 modalities,
243             ) { tryAgainIsPossible, modalities ->
244                 tryAgainIsPossible && modalities.hasFaceOnly
245             }
246             .distinctUntilChanged()
247 
248     /** If the credential fallback button show be shown. */
249     val isCredentialButtonVisible: Flow<Boolean> =
250         combine(
251                 size,
252                 isAuthenticated,
253                 promptSelectorInteractor.isCredentialAllowed,
254             ) { size, authState, credentialAllowed ->
255                 size.isNotSmall && authState.isNotAuthenticated && credentialAllowed
256             }
257             .distinctUntilChanged()
258 
259     private val history = PromptHistoryImpl()
260     private var messageJob: Job? = null
261 
262     /**
263      * Show a temporary error [message] associated with an optional [failedModality] and play
264      * [hapticFeedback].
265      *
266      * The [messageAfterError] will be shown via [showAuthenticating] when [authenticateAfterError]
267      * is set (or via [showHelp] when not set) after the error is dismissed.
268      *
269      * The error is ignored if the user has already authenticated or if [suppressIf] is true given
270      * the currently showing [PromptMessage] and [PromptHistory].
271      */
272     suspend fun showTemporaryError(
273         message: String,
274         messageAfterError: String,
275         authenticateAfterError: Boolean,
276         suppressIf: (PromptMessage, PromptHistory) -> Boolean = { _, _ -> false },
277         hapticFeedback: Boolean = true,
278         failedModality: BiometricModality = BiometricModality.None,
279     ) = coroutineScope {
280         if (_isAuthenticated.value.isAuthenticated) {
281             return@coroutineScope
282         }
283 
284         _canTryAgainNow.value = supportsRetry(failedModality)
285 
286         val suppress = suppressIf(_message.value, history)
287         history.failure(failedModality)
288         if (suppress) {
289             return@coroutineScope
290         }
291 
292         _isAuthenticating.value = false
293         _isAuthenticated.value = PromptAuthState(false)
294         _forceMediumSize.value = true
295         _message.value = PromptMessage.Error(message)
296         _legacyState.value = AuthBiometricView.STATE_ERROR
297 
298         if (hapticFeedback) {
299             vibrator.error(failedModality)
300         }
301 
302         messageJob?.cancel()
303         messageJob = launch {
304             delay(BiometricPrompt.HIDE_DIALOG_DELAY.toLong())
305             if (authenticateAfterError) {
306                 showAuthenticating(messageAfterError)
307             } else {
308                 showInfo(messageAfterError)
309             }
310         }
311     }
312 
313     /**
314      * Call to ensure the fingerprint sensor has started. Either when the dialog is first shown
315      * (most cases) or when it should be enabled after a first error (coex implicit flow).
316      */
317     fun ensureFingerprintHasStarted(isDelayed: Boolean) {
318         if (_fingerprintStartMode.value == FingerprintStartMode.Pending) {
319             _fingerprintStartMode.value =
320                 if (isDelayed) FingerprintStartMode.Delayed else FingerprintStartMode.Normal
321         }
322     }
323 
324     // enable retry only when face fails (fingerprint runs constantly)
325     private fun supportsRetry(failedModality: BiometricModality) =
326         failedModality == BiometricModality.Face
327 
328     suspend fun showHelp(message: String) = showHelp(message, clearIconError = false)
329     suspend fun showInfo(message: String) = showHelp(message, clearIconError = true)
330 
331     /**
332      * Show a persistent help message.
333      *
334      * Will be show even if the user has already authenticated.
335      */
336     private suspend fun showHelp(message: String, clearIconError: Boolean) {
337         val alreadyAuthenticated = _isAuthenticated.value.isAuthenticated
338         if (!alreadyAuthenticated) {
339             _isAuthenticating.value = false
340             _isAuthenticated.value = PromptAuthState(false)
341         }
342 
343         _message.value =
344             if (message.isNotBlank()) PromptMessage.Help(message) else PromptMessage.Empty
345         _forceMediumSize.value = true
346         _legacyState.value =
347             if (alreadyAuthenticated && isConfirmationRequired.first()) {
348                 AuthBiometricView.STATE_PENDING_CONFIRMATION
349             } else if (alreadyAuthenticated && !isConfirmationRequired.first()) {
350                 AuthBiometricView.STATE_AUTHENTICATED
351             } else if (clearIconError) {
352                 AuthBiometricView.STATE_IDLE
353             } else {
354                 AuthBiometricView.STATE_HELP
355             }
356 
357         messageJob?.cancel()
358         messageJob = null
359     }
360 
361     /**
362      * Show a temporary help message and transition back to a fixed message.
363      *
364      * Ignored if the user has already authenticated.
365      */
366     suspend fun showTemporaryHelp(
367         message: String,
368         messageAfterHelp: String = "",
369     ) = coroutineScope {
370         if (_isAuthenticated.value.isAuthenticated) {
371             return@coroutineScope
372         }
373 
374         _isAuthenticating.value = false
375         _isAuthenticated.value = PromptAuthState(false)
376         _message.value =
377             if (message.isNotBlank()) PromptMessage.Help(message) else PromptMessage.Empty
378         _forceMediumSize.value = true
379         _legacyState.value = AuthBiometricView.STATE_HELP
380 
381         messageJob?.cancel()
382         messageJob = launch {
383             delay(BiometricPrompt.HIDE_DIALOG_DELAY.toLong())
384             showAuthenticating(messageAfterHelp)
385         }
386     }
387 
388     /** Show the user that biometrics are actively running and set [isAuthenticating]. */
389     fun showAuthenticating(message: String = "", isRetry: Boolean = false) {
390         if (_isAuthenticated.value.isAuthenticated) {
391             // TODO(jbolinger): convert to go/tex-apc?
392             Log.w(TAG, "Cannot show authenticating after authenticated")
393             return
394         }
395 
396         _isAuthenticating.value = true
397         _isAuthenticated.value = PromptAuthState(false)
398         _message.value = if (message.isBlank()) PromptMessage.Empty else PromptMessage.Help(message)
399         _legacyState.value = AuthBiometricView.STATE_AUTHENTICATING
400 
401         // reset the try again button(s) after the user attempts a retry
402         if (isRetry) {
403             _canTryAgainNow.value = false
404         }
405 
406         messageJob?.cancel()
407         messageJob = null
408     }
409 
410     /**
411      * Show successfully authentication, set [isAuthenticated], and dismiss the prompt after a
412      * [dismissAfterDelay] or prompt for explicit confirmation (if required).
413      */
414     suspend fun showAuthenticated(
415         modality: BiometricModality,
416         dismissAfterDelay: Long,
417         helpMessage: String = "",
418     ) {
419         if (_isAuthenticated.value.isAuthenticated) {
420             // TODO(jbolinger): convert to go/tex-apc?
421             Log.w(TAG, "Cannot show authenticated after authenticated")
422             return
423         }
424 
425         _isAuthenticating.value = false
426         val needsUserConfirmation = needsExplicitConfirmation(modality)
427         _isAuthenticated.value =
428             PromptAuthState(true, modality, needsUserConfirmation, dismissAfterDelay)
429         _message.value = PromptMessage.Empty
430         _legacyState.value =
431             if (needsUserConfirmation) {
432                 AuthBiometricView.STATE_PENDING_CONFIRMATION
433             } else {
434                 AuthBiometricView.STATE_AUTHENTICATED
435             }
436 
437         if (!needsUserConfirmation) {
438             vibrator.success(modality)
439         }
440 
441         messageJob?.cancel()
442         messageJob = null
443 
444         if (helpMessage.isNotBlank()) {
445             showHelp(helpMessage)
446         }
447     }
448 
449     private suspend fun needsExplicitConfirmation(modality: BiometricModality): Boolean {
450         val confirmationRequired = isConfirmationRequired.first()
451 
452         // Only worry about confirmationRequired if face was used to unlock
453         if (modality == BiometricModality.Face) {
454             return confirmationRequired
455         }
456         // fingerprint only never requires confirmation
457         return false
458     }
459 
460     /**
461      * Set the prompt's auth state to authenticated and confirmed.
462      *
463      * This should only be used after [showAuthenticated] when the operation requires explicit user
464      * confirmation.
465      */
466     fun confirmAuthenticated() {
467         val authState = _isAuthenticated.value
468         if (authState.isNotAuthenticated) {
469             Log.w(TAG, "Cannot confirm authenticated when not authenticated")
470             return
471         }
472 
473         _isAuthenticated.value = authState.asExplicitlyConfirmed()
474         _message.value = PromptMessage.Empty
475         _legacyState.value = AuthBiometricView.STATE_AUTHENTICATED
476 
477         vibrator.success(authState.authenticatedModality)
478 
479         messageJob?.cancel()
480         messageJob = null
481     }
482 
483     /**
484      * Touch event occurred on the overlay
485      *
486      * Tracks whether a finger is currently down to set [_isOverlayTouched] to be used as user
487      * confirmation
488      */
489     fun onOverlayTouch(event: MotionEvent): Boolean {
490         if (event.actionMasked == MotionEvent.ACTION_DOWN) {
491             _isOverlayTouched.value = true
492 
493             if (_isAuthenticated.value.needsUserConfirmation) {
494                 confirmAuthenticated()
495             }
496             return true
497         } else if (event.actionMasked == MotionEvent.ACTION_UP) {
498             _isOverlayTouched.value = false
499         }
500         return false
501     }
502 
503     /**
504      * Switch to the credential view.
505      *
506      * TODO(b/251476085): this should be decoupled from the shared panel controller
507      */
508     fun onSwitchToCredential() {
509         _forceLargeSize.value = true
510     }
511 
512     private fun VibratorHelper.success(modality: BiometricModality) {
513         if (featureFlags.isEnabled(ONE_WAY_HAPTICS_API_MIGRATION)) {
514             _hapticsToPlay.value = HapticFeedbackConstants.CONFIRM
515         } else {
516             vibrateAuthSuccess("$TAG, modality = $modality BP::success")
517         }
518     }
519 
520     private fun VibratorHelper.error(modality: BiometricModality = BiometricModality.None) {
521         if (featureFlags.isEnabled(ONE_WAY_HAPTICS_API_MIGRATION)) {
522             _hapticsToPlay.value = HapticFeedbackConstants.REJECT
523         } else {
524             vibrateAuthError("$TAG, modality = $modality BP::error")
525         }
526     }
527 
528     /** Clears the [hapticsToPlay] variable by setting it to the NO_HAPTICS default. */
529     fun clearHaptics() {
530         _hapticsToPlay.value = HapticFeedbackConstants.NO_HAPTICS
531     }
532 
533     companion object {
534         private const val TAG = "PromptViewModel"
535     }
536 }
537 
538 /** How the fingerprint sensor was started for the prompt. */
539 enum class FingerprintStartMode {
540     /** Fingerprint sensor has not started. */
541     Pending,
542 
543     /** Fingerprint sensor started immediately when prompt was displayed. */
544     Normal,
545 
546     /** Fingerprint sensor started after the first failure of another passive modality. */
547     Delayed;
548 
549     /** If this is [Normal] or [Delayed]. */
550     val isStarted: Boolean
551         get() = this == Normal || this == Delayed
552 }
553