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