1 /*
<lambda>null2  * 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.settings.biometrics2.ui.view
17 
18 import android.animation.Animator
19 import android.animation.ObjectAnimator
20 import android.content.Context
21 import android.graphics.PorterDuff
22 import android.graphics.drawable.Animatable2
23 import android.graphics.drawable.AnimatedVectorDrawable
24 import android.graphics.drawable.Drawable
25 import android.graphics.drawable.LayerDrawable
26 import android.hardware.fingerprint.FingerprintManager.ENROLL_ENROLL
27 import android.os.Bundle
28 import android.text.TextUtils
29 import android.util.Log
30 import android.view.LayoutInflater
31 import android.view.MotionEvent
32 import android.view.View
33 import android.view.ViewGroup
34 import android.view.animation.AnimationUtils.loadInterpolator
35 import android.view.animation.Interpolator
36 import android.widget.ProgressBar
37 import android.widget.TextView
38 import androidx.activity.OnBackPressedCallback
39 import androidx.fragment.app.Fragment
40 import androidx.fragment.app.FragmentActivity
41 import androidx.lifecycle.Lifecycle
42 import androidx.lifecycle.Observer
43 import androidx.lifecycle.ViewModelProvider
44 import androidx.lifecycle.lifecycleScope
45 import androidx.lifecycle.repeatOnLifecycle
46 import com.android.settings.R
47 import com.android.settings.biometrics2.ui.model.EnrollmentProgress
48 import com.android.settings.biometrics2.ui.model.EnrollmentStatusMessage
49 import com.android.settings.biometrics2.ui.viewmodel.FingerprintEnrollEnrollingViewModel
50 import com.android.settings.biometrics2.ui.viewmodel.FingerprintEnrollErrorDialogViewModel
51 import com.android.settings.biometrics2.ui.viewmodel.FingerprintEnrollProgressViewModel
52 import com.google.android.setupcompat.template.FooterBarMixin
53 import com.google.android.setupcompat.template.FooterButton
54 import com.google.android.setupdesign.GlifLayout
55 import kotlinx.coroutines.launch
56 
57 /**
58  * Fragment is used to handle enrolling process for rfps
59  */
60 class FingerprintEnrollEnrollingRfpsFragment : Fragment() {
61 
62     private var _enrollingViewModel: FingerprintEnrollEnrollingViewModel? = null
63     private val enrollingViewModel: FingerprintEnrollEnrollingViewModel
64         get() = _enrollingViewModel!!
65 
66     private var _progressViewModel: FingerprintEnrollProgressViewModel? = null
67     private val progressViewModel: FingerprintEnrollProgressViewModel
68         get() = _progressViewModel!!
69 
70     private var _errorDialogViewModel: FingerprintEnrollErrorDialogViewModel? = null
71     private val errorDialogViewModel: FingerprintEnrollErrorDialogViewModel
72         get() = _errorDialogViewModel!!
73 
74     private var fastOutSlowInInterpolator: Interpolator? = null
75     private var linearOutSlowInInterpolator: Interpolator? = null
76     private var fastOutLinearInInterpolator: Interpolator? = null
77 
78     private var isAnimationCancelled = false
79 
80     private var enrollingView: GlifLayout? = null
81     private val progressBar: ProgressBar
82         get() = enrollingView!!.findViewById(R.id.fingerprint_progress_bar)!!
83 
84     private var progressAnim: ObjectAnimator? = null
85 
86     private val errorText: TextView
87         get() = enrollingView!!.findViewById(R.id.error_text)!!
88 
89     private val iconAnimationDrawable: AnimatedVectorDrawable?
90         get() = (progressBar.background as LayerDrawable)
91             .findDrawableByLayerId(R.id.fingerprint_animation) as AnimatedVectorDrawable?
92 
93     private val iconBackgroundBlinksDrawable: AnimatedVectorDrawable?
94         get() = (progressBar.background as LayerDrawable)
95             .findDrawableByLayerId(R.id.fingerprint_background) as AnimatedVectorDrawable?
96 
97     private var iconTouchCount = 0
98 
99     private val touchAgainRunnable = Runnable {
100         showError(
101             // Use enrollingView to getString to prevent activity is missing during rotation
102             enrollingView!!.context.getString(
103                 R.string.security_settings_fingerprint_enroll_lift_touch_again
104             )
105         )
106     }
107 
108     private val onSkipClickListener = View.OnClickListener { _: View? ->
109         enrollingViewModel.setOnSkipPressed()
110         cancelEnrollment(true)
111     }
112 
113     private var enrollingCancelSignal: Any? = null
114 
115     private val progressObserver = Observer { progress: EnrollmentProgress? ->
116         if (progress != null && progress.steps >= 0) {
117             onEnrollmentProgressChange(progress)
118         }
119     }
120 
121     private val helpMessageObserver = Observer { helpMessage: EnrollmentStatusMessage? ->
122         helpMessage?.let { onEnrollmentHelp(it) }
123     }
124 
125     private val errorMessageObserver = Observer { errorMessage: EnrollmentStatusMessage? ->
126         Log.d(TAG, "errorMessageObserver($errorMessage)")
127         errorMessage?.let { onEnrollmentError(it) }
128    }
129 
130     private val canceledSignalObserver = Observer { canceledSignal: Any? ->
131         canceledSignal?.let { onEnrollmentCanceled(it) }
132     }
133 
134     private val onBackPressedCallback: OnBackPressedCallback =
135         object : OnBackPressedCallback(true) {
136             override fun handleOnBackPressed() {
137                 isEnabled = false
138                 enrollingViewModel.setOnBackPressed()
139                 cancelEnrollment(true)
140             }
141         }
142 
143     override fun onAttach(context: Context) {
144         ViewModelProvider(requireActivity()).let { provider ->
145             _enrollingViewModel = provider[FingerprintEnrollEnrollingViewModel::class.java]
146             _progressViewModel = provider[FingerprintEnrollProgressViewModel::class.java]
147             _errorDialogViewModel = provider[FingerprintEnrollErrorDialogViewModel::class.java]
148         }
149         super.onAttach(context)
150         requireActivity().onBackPressedDispatcher.addCallback(onBackPressedCallback)
151     }
152 
153     override fun onDetach() {
154         onBackPressedCallback.isEnabled = false
155         super.onDetach()
156     }
157 
158     override fun onCreateView(
159         inflater: LayoutInflater, container: ViewGroup?,
160         savedInstanceState: Bundle?
161     ): View {
162         enrollingView = inflater.inflate(
163                 R.layout.fingerprint_enroll_enrolling, container, false
164         ) as GlifLayout
165         return enrollingView!!
166     }
167 
168     override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
169         super.onViewCreated(view, savedInstanceState)
170 
171         iconAnimationDrawable!!.registerAnimationCallback(iconAnimationCallback)
172 
173         progressBar.setOnTouchListener { _: View?, event: MotionEvent ->
174             if (event.actionMasked == MotionEvent.ACTION_DOWN) {
175                 iconTouchCount++
176                 if (iconTouchCount == ICON_TOUCH_COUNT_SHOW_UNTIL_DIALOG_SHOWN) {
177                     showIconTouchDialog()
178                 } else {
179                     progressBar.postDelayed(
180                         showDialogRunnable,
181                         ICON_TOUCH_DURATION_UNTIL_DIALOG_SHOWN
182                     )
183                 }
184             } else if (event.actionMasked == MotionEvent.ACTION_CANCEL
185                 || event.actionMasked == MotionEvent.ACTION_UP
186             ) {
187                 progressBar.removeCallbacks(showDialogRunnable)
188             }
189             true
190         }
191 
192         requireActivity().bindFingerprintEnrollEnrollingRfpsView(
193             view = enrollingView!!,
194             onSkipClickListener = onSkipClickListener
195         )
196 
197         fastOutSlowInInterpolator =
198             loadInterpolator(requireContext(), android.R.interpolator.fast_out_slow_in)
199         linearOutSlowInInterpolator =
200             loadInterpolator(requireContext(), android.R.interpolator.linear_out_slow_in)
201         fastOutLinearInInterpolator =
202             loadInterpolator(requireContext(), android.R.interpolator.fast_out_linear_in)
203 
204         lifecycleScope.launch {
205             repeatOnLifecycle(Lifecycle.State.STARTED) {
206                 errorDialogViewModel.triggerRetryFlow.collect { retryEnrollment() }
207             }
208         }
209     }
210 
211     private fun retryEnrollment() {
212         isAnimationCancelled = false
213         startIconAnimation()
214         startEnrollment()
215 
216         clearError()
217         updateProgress(false /* animate */, progressViewModel.progressLiveData.value!!)
218         updateTitleAndDescription()
219     }
220 
221     override fun onStart() {
222         super.onStart()
223 
224         val isEnrolling = progressViewModel.isEnrolling
225         val isErrorDialogShown = errorDialogViewModel.isDialogShown
226         Log.d(TAG, "onStart(), isEnrolling:$isEnrolling, isErrorDialog:$isErrorDialogShown")
227         if (!isErrorDialogShown) {
228             isAnimationCancelled = false
229             startIconAnimation()
230             startEnrollment()
231         }
232 
233         updateProgress(false /* animate */, progressViewModel.progressLiveData.value!!)
234         updateTitleAndDescription()
235     }
236 
237     private fun startIconAnimation() {
238         iconAnimationDrawable?.start()
239     }
240 
241     private fun stopIconAnimation() {
242         isAnimationCancelled = true
243         iconAnimationDrawable?.stop()
244     }
245 
246     override fun onStop() {
247         stopIconAnimation()
248         removeEnrollmentObservers()
249         val isEnrolling = progressViewModel.isEnrolling
250         val isConfigChange = requireActivity().isChangingConfigurations
251         Log.d(TAG, "onStop(), enrolling:$isEnrolling isConfigChange:$isConfigChange")
252         if (isEnrolling && !isConfigChange) {
253             cancelEnrollment(false)
254         }
255         super.onStop()
256     }
257 
258     private fun removeEnrollmentObservers() {
259         progressViewModel.errorMessageLiveData.removeObserver(errorMessageObserver)
260         progressViewModel.progressLiveData.removeObserver(progressObserver)
261         progressViewModel.helpMessageLiveData.removeObserver(helpMessageObserver)
262     }
263 
264     private fun cancelEnrollment(waitForLastCancelErrMsg: Boolean) {
265         if (!progressViewModel.isEnrolling) {
266             Log.d(TAG, "cancelEnrollment(), failed because isEnrolling is false")
267             return
268         }
269         removeEnrollmentObservers()
270         if (waitForLastCancelErrMsg) {
271             progressViewModel.canceledSignalLiveData.observe(this, canceledSignalObserver)
272         } else {
273             enrollingCancelSignal = null
274         }
275         val cancelResult: Boolean = progressViewModel.cancelEnrollment()
276         if (!cancelResult) {
277             Log.e(TAG, "cancelEnrollment(), failed to cancel enrollment")
278         }
279     }
280 
281     private fun startEnrollment() {
282         enrollingCancelSignal = progressViewModel.startEnrollment(ENROLL_ENROLL)
283         if (enrollingCancelSignal == null) {
284             Log.e(TAG, "startEnrollment(), failed")
285         } else {
286             Log.d(TAG, "startEnrollment(), success")
287         }
288         progressViewModel.progressLiveData.observe(this, progressObserver)
289         progressViewModel.helpMessageLiveData.observe(this, helpMessageObserver)
290         progressViewModel.errorMessageLiveData.observe(this, errorMessageObserver)
291     }
292 
293     private fun onEnrollmentHelp(helpMessage: EnrollmentStatusMessage) {
294         Log.d(TAG, "onEnrollmentHelp($helpMessage)")
295         val helpStr: CharSequence = helpMessage.str
296         if (!TextUtils.isEmpty(helpStr)) {
297             errorText.removeCallbacks(touchAgainRunnable)
298             showError(helpStr)
299         }
300     }
301 
302     private fun onEnrollmentError(errorMessage: EnrollmentStatusMessage) {
303         stopIconAnimation()
304 
305         cancelEnrollment(true)
306         lifecycleScope.launch {
307             Log.d(TAG, "newDialog $errorMessage")
308             errorDialogViewModel.newDialog(errorMessage.msgId)
309         }
310     }
311 
312     private fun onEnrollmentCanceled(canceledSignal: Any) {
313         Log.d(
314             TAG,
315             "onEnrollmentCanceled enrolling:$enrollingCancelSignal, canceled:$canceledSignal"
316         )
317         if (enrollingCancelSignal === canceledSignal) {
318             progressViewModel.canceledSignalLiveData.removeObserver(canceledSignalObserver)
319             progressViewModel.clearProgressLiveData()
320             if (enrollingViewModel.onBackPressed) {
321                 enrollingViewModel.onCancelledDueToOnBackPressed()
322             } else if (enrollingViewModel.onSkipPressed) {
323                 enrollingViewModel.onCancelledDueToOnSkipPressed()
324             }
325         }
326     }
327 
328     private fun onEnrollmentProgressChange(progress: EnrollmentProgress) {
329         updateProgress(true /* animate */, progress)
330         updateTitleAndDescription()
331         animateFlash()
332         errorText.removeCallbacks(touchAgainRunnable)
333         errorText.postDelayed(touchAgainRunnable, HINT_TIMEOUT_DURATION.toLong())
334     }
335 
336     private fun updateProgress(animate: Boolean, enrollmentProgress: EnrollmentProgress) {
337         val progress = getProgress(enrollmentProgress)
338         Log.d(TAG, "updateProgress($animate, $enrollmentProgress), old:${progressBar.progress}"
339                 + ", new:$progress")
340 
341         // Only clear the error when progress has been made.
342         // TODO (b/234772728) Add tests.
343         if (progressBar.progress < progress) {
344             clearError()
345         }
346         if (animate) {
347             animateProgress(progress)
348         } else {
349             progressBar.progress = progress
350             if (progress >= PROGRESS_BAR_MAX) {
351                 delayedFinishRunnable.run()
352             }
353         }
354     }
355 
356     private fun getProgress(progress: EnrollmentProgress): Int {
357         if (progress.steps == -1) {
358             return 0
359         }
360         val displayProgress = 0.coerceAtLeast(progress.steps + 1 - progress.remaining)
361         return PROGRESS_BAR_MAX * displayProgress / (progress.steps + 1)
362     }
363 
364     private fun showError(error: CharSequence) {
365         errorText.text = error
366         if (errorText.visibility == View.INVISIBLE) {
367             errorText.visibility = View.VISIBLE
368             errorText.translationY = enrollingView!!.context.resources.getDimensionPixelSize(
369                 R.dimen.fingerprint_error_text_appear_distance
370             ).toFloat()
371             errorText.alpha = 0f
372             errorText.animate()
373                 .alpha(1f)
374                 .translationY(0f)
375                 .setDuration(200)
376                 .setInterpolator(linearOutSlowInInterpolator)
377                 .start()
378         } else {
379             errorText.animate().cancel()
380             errorText.alpha = 1f
381             errorText.translationY = 0f
382         }
383         if (isResumed && enrollingViewModel.isAccessibilityEnabled) {
384             enrollingViewModel.vibrateError(javaClass.simpleName + "::showError")
385         }
386     }
387 
388     private fun clearError() {
389         if (errorText.visibility == View.VISIBLE) {
390             errorText.animate()
391                 .alpha(0f)
392                 .translationY(
393                     resources.getDimensionPixelSize(
394                         R.dimen.fingerprint_error_text_disappear_distance
395                     ).toFloat()
396                 )
397                 .setDuration(100)
398                 .setInterpolator(fastOutLinearInInterpolator)
399                 .withEndAction { errorText.visibility = View.INVISIBLE }
400                 .start()
401         }
402     }
403 
404     private fun animateProgress(progress: Int) {
405         progressAnim?.cancel()
406         val anim = ObjectAnimator.ofInt(
407             progressBar /* target */,
408             "progress" /* propertyName */,
409             progressBar.progress /* values[0] */,
410             progress /* values[1] */
411         )
412         anim.addListener(progressAnimationListener)
413         anim.interpolator = fastOutSlowInInterpolator
414         anim.setDuration(ANIMATION_DURATION)
415         anim.start()
416         progressAnim = anim
417     }
418 
419     private fun animateFlash() {
420         iconBackgroundBlinksDrawable?.start()
421     }
422 
423     private fun updateTitleAndDescription() {
424         val progressLiveData: EnrollmentProgress = progressViewModel.progressLiveData.value!!
425         GlifLayoutHelper(activity!!, enrollingView!!).setDescriptionText(
426             enrollingView!!.context.getString(
427                 if (progressLiveData.steps == -1)
428                     R.string.security_settings_fingerprint_enroll_start_message
429                 else
430                     R.string.security_settings_fingerprint_enroll_repeat_message
431             )
432         )
433     }
434 
435     private fun showIconTouchDialog() {
436         iconTouchCount = 0
437         enrollingViewModel.showIconTouchDialog()
438     }
439 
440     private val showDialogRunnable = Runnable { showIconTouchDialog() }
441 
442     private val progressAnimationListener: Animator.AnimatorListener =
443         object : Animator.AnimatorListener {
444             override fun onAnimationStart(animation: Animator) {
445                 startIconAnimation()
446             }
447 
448             override fun onAnimationRepeat(animation: Animator) {}
449             override fun onAnimationEnd(animation: Animator) {
450                 stopIconAnimation()
451                 if (progressBar.progress >= PROGRESS_BAR_MAX) {
452                     progressBar.postDelayed(delayedFinishRunnable, ANIMATION_DURATION)
453                 }
454             }
455 
456             override fun onAnimationCancel(animation: Animator) {}
457         }
458 
459     // Give the user a chance to see progress completed before jumping to the next stage.
460     private val delayedFinishRunnable = Runnable { enrollingViewModel.onEnrollingDone() }
461 
462     private val iconAnimationCallback: Animatable2.AnimationCallback =
463         object : Animatable2.AnimationCallback() {
464             override fun onAnimationEnd(d: Drawable) {
465                 if (isAnimationCancelled) {
466                     return
467                 }
468 
469                 // Start animation after it has ended.
470                 progressBar.post { startIconAnimation() }
471             }
472         }
473 
474     companion object {
475         private const val DEBUG = false
476         private const val TAG = "FingerprintEnrollEnrollingRfpsFragment"
477         private const val PROGRESS_BAR_MAX = 10000
478         private const val ANIMATION_DURATION = 250L
479         private const val ICON_TOUCH_DURATION_UNTIL_DIALOG_SHOWN: Long = 500
480         private const val ICON_TOUCH_COUNT_SHOW_UNTIL_DIALOG_SHOWN = 3
481 
482         /**
483          * If we don't see progress during this time, we show an error message to remind the users that
484          * they need to lift the finger and touch again.
485          */
486         private const val HINT_TIMEOUT_DURATION = 2500
487     }
488 }
489 
FragmentActivitynull490 fun FragmentActivity.bindFingerprintEnrollEnrollingRfpsView(
491     view: GlifLayout,
492     onSkipClickListener: View.OnClickListener
493 ) {
494     GlifLayoutHelper(this, view).let {
495         it.setDescriptionText(
496             getString(
497                 R.string.security_settings_fingerprint_enroll_start_message
498             )
499         )
500         it.setHeaderText(R.string.security_settings_fingerprint_enroll_repeat_title)
501     }
502 
503     view.findViewById<ProgressBar>(R.id.fingerprint_progress_bar)!!
504         .progressBackgroundTintMode = PorterDuff.Mode.SRC
505 
506     view.getMixin(FooterBarMixin::class.java).secondaryButton =
507         FooterButton.Builder(this)
508             .setText(R.string.security_settings_fingerprint_enroll_enrolling_skip)
509             .setListener(onSkipClickListener)
510             .setButtonType(FooterButton.ButtonType.SKIP)
511             .setTheme(com.google.android.setupdesign.R.style.SudGlifButton_Secondary)
512             .build()
513 }
514