UDFPS Enrollment refactor (2/N)

This commit is focused on UI. The guided enrollment, lottie, text
and progress bar should all be working according to the previous
experience.

Test: atest
Bug: 297082837
Change-Id: I9b414053f5eaf7b2bc164dacdddc96ed44fec6cb
This commit is contained in:
Joshua McCloskey
2024-02-28 21:57:56 +00:00
parent e2099b7f94
commit a811dd67bd
18 changed files with 1336 additions and 139 deletions

View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright (C) 2023 The Android Open Source Project
~ Copyright (C) 2024 The Android Open Source Project
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
@@ -15,56 +15,77 @@
~ limitations under the License.
-->
<com.google.android.setupdesign.GlifLayout
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/setup_wizard_layout"
style="?attr/fingerprint_layout_theme"
android:layout_width="match_parent"
android:layout_height="match_parent"
>
android:orientation="vertical">
<LinearLayout
style="@style/SudContentFrame"
<!-- This is used to grab style attributes and apply them
to this layout -->
<com.google.android.setupdesign.GlifLayout
android:id="@+id/dummy_glif_layout"
style="?attr/fingerprint_layout_theme"
android:layout_width="0dp"
android:layout_height="0dp"
android:visibility="gone"
/>
<ImageView
android:id="@+id/sud_layout_icon"
style="@style/SudGlifIcon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:scaleType="fitStart"
android:src="@drawable/ic_lock" />
<TextView
android:id="@+id/title"
style="@style/SudGlifHeaderTitle"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_height="80dp"
android:ellipsize="end"
android:lines="2"
/>
<TextView
android:id="@+id/description"
style="@style/SudDescription.Glif"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:lines="3"
android:paddingLeft="10dp"
android:paddingRight="10dp"
/>
<com.airbnb.lottie.LottieAnimationView
android:id="@+id/illustration_lottie"
android:layout_width="match_parent"
android:layout_height="200dp"
android:clipChildren="false"
android:clipToPadding="false"
android:orientation="vertical">
android:paddingLeft="10dp"
android:paddingRight="10dp"
android:scaleType="centerInside"
app:lottie_autoPlay="true"
app:lottie_loop="true"
app:lottie_speed=".85" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:gravity="center|bottom"
android:orientation="vertical">
<FrameLayout
android:id="@+id/layout_container"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_gravity="center_horizontal|bottom"
android:clipToPadding="false"
>
<FrameLayout
android:id="@+id/layout_container"
android:layout_width="200dp"
android:layout_height="200dp"
android:layout_gravity="center_horizontal|bottom"
android:clipChildren="false"
android:clipToPadding="false"
tools:ignore="Suspicious0dp">
<!-- Animation res MUST be set in code -->
<com.airbnb.lottie.LottieAnimationView
android:id="@+id/illustration_lottie"
android:layout_width="200dp"
android:layout_height="200dp"
android:clipChildren="false"
android:clipToPadding="false"
android:scaleType="centerInside"
app:lottie_autoPlay="true"
app:lottie_loop="true"
app:lottie_speed=".85" />
</FrameLayout>
</LinearLayout>
</LinearLayout>
<include layout="@layout/fingerprint_v2_udfps_enroll_view" />
</FrameLayout>
</com.google.android.setupdesign.GlifLayout>
</LinearLayout>

View File

@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright (C) 2024 The Android Open Source Project
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<com.android.settings.biometrics.fingerprint2.ui.enrollment.modules.enrolling.udfps.ui.widget.UdfpsEnrollViewV2
xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/udfps_animation_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<ImageView
android:id="@+id/udfps_enroll_animation_fp_progress_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
/>
<ImageView
android:id="@+id/udfps_enroll_animation_fp_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
/>
</com.android.settings.biometrics.fingerprint2.ui.enrollment.modules.enrolling.udfps.ui.widget.UdfpsEnrollViewV2>

View File

@@ -32,6 +32,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.transform
import kotlinx.coroutines.withContext
/**
@@ -45,12 +46,12 @@ interface FingerprintSensorRepository {
}
class FingerprintSensorRepositoryImpl(
fingerprintManager: FingerprintManager,
backgroundDispatcher: CoroutineDispatcher,
activityScope: CoroutineScope,
fingerprintManager: FingerprintManager,
backgroundDispatcher: CoroutineDispatcher,
activityScope: CoroutineScope,
) : FingerprintSensorRepository {
override val fingerprintSensor: Flow<FingerprintSensor> =
private val fingerprintPropsInternal: Flow<FingerprintSensorPropertiesInternal> =
callbackFlow {
val callback =
object : IFingerprintAuthenticatorsRegisteredCallback.Stub() {
@@ -60,7 +61,7 @@ class FingerprintSensorRepositoryImpl(
if (sensors.isEmpty()) {
trySend(DEFAULT_PROPS)
} else {
trySend(sensors[0].toFingerprintSensor())
trySend(sensors[0])
}
}
}
@@ -71,20 +72,24 @@ class FingerprintSensorRepositoryImpl(
}
.stateIn(activityScope, started = SharingStarted.Eagerly, initialValue = DEFAULT_PROPS)
companion object {
private const val TAG = "FingerprintSensorRepoImpl"
private val DEFAULT_PROPS =
FingerprintSensorPropertiesInternal(
-1 /* sensorId */,
SensorProperties.STRENGTH_CONVENIENCE,
0 /* maxEnrollmentsPerUser */,
listOf<ComponentInfoInternal>(),
FingerprintSensorProperties.TYPE_UNKNOWN,
false /* halControlsIllumination */,
true /* resetLockoutRequiresHardwareAuthToken */,
listOf<SensorLocationInternal>(SensorLocationInternal.DEFAULT),
)
.toFingerprintSensor()
override val fingerprintSensor: Flow<FingerprintSensor> =
fingerprintPropsInternal.transform {
emit(it.toFingerprintSensor())
}
companion object {
private const val TAG = "FingerprintSensorRepoImpl"
private val DEFAULT_PROPS =
FingerprintSensorPropertiesInternal(
-1 /* sensorId */,
SensorProperties.STRENGTH_CONVENIENCE,
0 /* maxEnrollmentsPerUser */,
listOf<ComponentInfoInternal>(),
FingerprintSensorProperties.TYPE_UNKNOWN,
false /* halControlsIllumination */,
true /* resetLockoutRequiresHardwareAuthToken */,
listOf<SensorLocationInternal>(SensorLocationInternal.DEFAULT),
)
}
}

View File

@@ -20,10 +20,12 @@ import android.content.Context
import android.content.Intent
import android.hardware.biometrics.BiometricConstants;
import android.hardware.biometrics.BiometricFingerprintConstants
import android.hardware.biometrics.SensorLocationInternal
import android.hardware.fingerprint.FingerprintEnrollOptions;
import android.hardware.fingerprint.FingerprintManager
import android.hardware.fingerprint.FingerprintManager.GenerateChallengeCallback
import android.hardware.fingerprint.FingerprintManager.RemovalCallback
import android.hardware.fingerprint.FingerprintSensorPropertiesInternal
import android.os.CancellationSignal
import android.util.Log
import com.android.settings.biometrics.GatekeeperPasswordProvider

View File

@@ -16,6 +16,7 @@
package com.android.settings.biometrics.fingerprint2.lib.domain.interactor
import android.hardware.fingerprint.FingerprintSensorPropertiesInternal
import com.android.settings.biometrics.fingerprint2.lib.model.EnrollReason
import com.android.settings.biometrics.fingerprint2.lib.model.FingerEnrollState
import com.android.settings.biometrics.fingerprint2.lib.model.FingerprintAuthAttemptModel

View File

@@ -33,7 +33,6 @@ import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import com.android.settings.R
import com.android.settings.biometrics.fingerprint2.domain.interactor.OrientationInteractor
import com.android.settings.biometrics.fingerprint2.lib.model.FingerEnrollState
import com.android.settings.biometrics.fingerprint2.ui.enrollment.modules.enrolling.rfps.ui.viewmodel.RFPSIconTouchViewModel
import com.android.settings.biometrics.fingerprint2.ui.enrollment.modules.enrolling.rfps.ui.viewmodel.RFPSViewModel
@@ -42,7 +41,6 @@ import com.android.settings.biometrics.fingerprint2.ui.enrollment.modules.enroll
import com.android.settings.biometrics.fingerprint2.ui.enrollment.modules.enrolling.rfps.ui.widget.RFPSProgressBar
import com.android.settings.biometrics.fingerprint2.ui.enrollment.viewmodel.BackgroundViewModel
import com.android.settings.biometrics.fingerprint2.ui.enrollment.viewmodel.FingerprintNavigationStep
import com.android.settings.biometrics.fingerprint2.ui.enrollment.viewmodel.FingerprintNavigationViewModel
import com.android.settings.core.instrumentation.InstrumentedDialogFragment
import com.google.android.setupcompat.template.FooterBarMixin
import com.google.android.setupcompat.template.FooterButton
@@ -86,6 +84,7 @@ class RFPSEnrollFragment() : Fragment(R.layout.fingerprint_v2_rfps_enroll_enroll
private val backgroundViewModel: BackgroundViewModel by lazy {
viewModelProvider[BackgroundViewModel::class.java]
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,

View File

@@ -24,7 +24,6 @@ import android.graphics.drawable.AnimatedVectorDrawable
import android.graphics.drawable.Drawable
import android.graphics.drawable.LayerDrawable
import android.util.AttributeSet
import android.util.Log
import android.view.animation.AnimationUtils
import android.view.animation.Interpolator
import com.android.settings.R
@@ -82,7 +81,6 @@ class RFPSProgressBar : RingProgressBar {
}
shouldAnimateInternal = shouldAnimate
}
/** This function should only be called when actual progress has been made. */
fun updateProgress(percentComplete: Float) {

View File

@@ -17,8 +17,9 @@
package com.android.settings.biometrics.fingerprint2.ui.enrollment.modules.enrolling.udfps.ui.fragment
import android.os.Bundle
import android.util.Log
import android.view.View
import android.view.WindowManager
import android.widget.TextView
import androidx.annotation.VisibleForTesting
import androidx.fragment.app.Fragment
import androidx.lifecycle.Lifecycle
@@ -28,89 +29,23 @@ import androidx.lifecycle.repeatOnLifecycle
import com.airbnb.lottie.LottieAnimationView
import com.airbnb.lottie.LottieCompositionFactory
import com.android.settings.R
import com.android.settings.biometrics.fingerprint2.ui.enrollment.modules.enrolling.udfps.ui.viewmodel.DescriptionText
import com.android.settings.biometrics.fingerprint2.ui.enrollment.modules.enrolling.udfps.ui.viewmodel.HeaderText
import com.android.settings.biometrics.fingerprint2.ui.enrollment.modules.enrolling.udfps.ui.viewmodel.EducationAnimationModel
import com.android.settings.biometrics.fingerprint2.ui.enrollment.modules.enrolling.udfps.ui.viewmodel.StageViewModel
import com.android.settings.biometrics.fingerprint2.ui.enrollment.modules.enrolling.udfps.ui.viewmodel.UdfpsViewModel
import com.android.settings.biometrics.fingerprint2.ui.enrollment.modules.enrolling.udfps.ui.widget.UdfpsEnrollViewV2
import com.android.settings.biometrics.fingerprint2.ui.enrollment.viewmodel.FingerprintNavigationStep
import com.google.android.setupdesign.GlifLayout
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/** This fragment is responsible for showing the udfps Enrollment UI. */
class UdfpsEnrollFragment() : Fragment(R.layout.fingerprint_v2_udfps_enroll_enrolling) {
/** Used for testing purposes */
private var factory: ViewModelProvider.Factory? = null
private val viewModel: UdfpsViewModel by lazy { viewModelProvider[UdfpsViewModel::class.java] }
@VisibleForTesting
constructor(theFactory: ViewModelProvider.Factory) : this() {
factory = theFactory
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val layout = view as GlifLayout
val illustrationLottie: LottieAnimationView = layout.findViewById(R.id.illustration_lottie)!!
viewLifecycleOwner.lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.RESUMED) {
viewLifecycleOwner.lifecycleScope.launch {
viewModel.stageFlow.collect {
layout.setHeaderText(getHeaderText(it))
getDescriptionText(it)?.let { descriptionText ->
layout.setDescriptionText(descriptionText)
}
getLottie(it)?.let { lottie ->
layout.descriptionText = ""
LottieCompositionFactory.fromRawRes(requireContext().applicationContext, lottie)
.addListener { comp ->
comp?.let { composition ->
viewLifecycleOwner.lifecycleScope.launch {
illustrationLottie.setComposition(composition)
illustrationLottie.visibility = View.VISIBLE
illustrationLottie.playAnimation()
}
}
}
}
}
}
}
}
}
private fun getHeaderText(stageViewModel: StageViewModel): Int {
return when (stageViewModel) {
StageViewModel.Center,
StageViewModel.Guided,
StageViewModel.Fingertip,
StageViewModel.Unknown -> R.string.security_settings_udfps_enroll_fingertip_title
StageViewModel.LeftEdge -> R.string.security_settings_udfps_enroll_left_edge_title
StageViewModel.RightEdge -> R.string.security_settings_udfps_enroll_right_edge_title
}
}
private fun getDescriptionText(stageViewModel: StageViewModel): Int? {
return when (stageViewModel) {
StageViewModel.Center,
StageViewModel.Guided,
StageViewModel.Fingertip,
StageViewModel.LeftEdge,
StageViewModel.RightEdge -> null
StageViewModel.Unknown -> R.string.security_settings_udfps_enroll_start_message
}
}
private fun getLottie(stageViewModel: StageViewModel): Int? {
return when (stageViewModel) {
StageViewModel.Center,
StageViewModel.Guided -> R.raw.udfps_center_hint_lottie
StageViewModel.Fingertip -> R.raw.udfps_tip_hint_lottie
StageViewModel.LeftEdge -> R.raw.udfps_left_edge_hint_lottie
StageViewModel.RightEdge -> R.raw.udfps_right_edge_hint_lottie
StageViewModel.Unknown -> null
}
}
private lateinit var udfpsEnrollView: UdfpsEnrollViewV2
private val viewModelProvider: ViewModelProvider by lazy {
if (factory != null) {
@@ -120,6 +55,111 @@ class UdfpsEnrollFragment() : Fragment(R.layout.fingerprint_v2_udfps_enroll_enro
}
}
@VisibleForTesting
constructor(theFactory: ViewModelProvider.Factory) : this() {
factory = theFactory
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val illustrationLottie: LottieAnimationView = view.findViewById(R.id.illustration_lottie)!!
udfpsEnrollView = view.findViewById(R.id.udfps_animation_view)!!
val titleTextView = view.findViewById<TextView>(R.id.title)!!
val descriptionTextView = view.findViewById<TextView>(R.id.description)!!
val glifLayout = view.findViewById<GlifLayout>(R.id.dummy_glif_layout)!!
val backgroundColor = glifLayout.backgroundBaseColor
val window = requireActivity().window
window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS)
window.clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS)
val color = backgroundColor?.defaultColor ?: glifLayout.primaryColor.defaultColor
window.statusBarColor = color
view.setBackgroundColor(color)
viewLifecycleOwner.lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.RESUMED) {
viewLifecycleOwner.lifecycleScope.launch {
viewModel.headerText.collect { titleTextView.setText(it.toResource()) }
}
viewLifecycleOwner.lifecycleScope.launch {
viewModel.descriptionText.collect {
if (it != null) {
it.toResource()?.let { text -> descriptionTextView.setText(text) }
} else {
descriptionTextView.text = ""
}
}
}
viewLifecycleOwner.lifecycleScope.launch {
viewModel.sensorLocation.collect { rect -> udfpsEnrollView.setSensorRect(rect) }
}
viewLifecycleOwner.lifecycleScope.launch {
viewModel.accessibilityEnabled.collect { isEnabled -> udfpsEnrollView.setAccessibilityEnabled(isEnabled) }
}
viewLifecycleOwner.lifecycleScope.launch {
viewModel.lottie.collect { lottieModel ->
val resource = lottieModel.toResource()
if (resource != null) {
LottieCompositionFactory.fromRawRes(requireContext(), resource).addListener { comp ->
comp?.let { composition ->
illustrationLottie.setComposition(composition)
illustrationLottie.visibility = View.VISIBLE
illustrationLottie.playAnimation()
}
}
} else {
illustrationLottie.visibility = View.INVISIBLE
}
}
}
viewLifecycleOwner.lifecycleScope.launch {
viewModel.udfpsEvent.collect { udfpsEnrollView.onUdfpsEvent(it) }
}
viewLifecycleOwner.lifecycleScope.launch {
viewModel.enrollStage.collect { udfpsEnrollView.updateStage(it) }
}
}
}
}
private fun HeaderText.toResource(): Int {
return when (this.stageViewModel) {
StageViewModel.Center,
StageViewModel.Guided,
StageViewModel.Fingertip,
StageViewModel.Unknown -> R.string.security_settings_udfps_enroll_fingertip_title
StageViewModel.LeftEdge -> R.string.security_settings_udfps_enroll_left_edge_title
StageViewModel.RightEdge -> R.string.security_settings_udfps_enroll_right_edge_title
}
}
private fun DescriptionText.toResource(): Int? {
return when (this.stageViewModel) {
StageViewModel.Center,
StageViewModel.Guided,
StageViewModel.Fingertip,
StageViewModel.LeftEdge,
StageViewModel.RightEdge -> null
StageViewModel.Unknown -> R.string.security_settings_udfps_enroll_start_message
}
}
private fun EducationAnimationModel.toResource(): Int? {
return when (this.stageViewModel) {
StageViewModel.Center,
StageViewModel.Guided -> R.raw.udfps_center_hint_lottie
StageViewModel.Fingertip -> R.raw.udfps_tip_hint_lottie
StageViewModel.LeftEdge -> R.raw.udfps_left_edge_hint_lottie
StageViewModel.RightEdge -> R.raw.udfps_right_edge_hint_lottie
StageViewModel.Unknown -> null
}
}
companion object {
private const val TAG = "UDFPSEnrollFragment"
private val navStep = FingerprintNavigationStep.Enrollment::class

View File

@@ -0,0 +1,24 @@
/*
* Copyright (C) 2024 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.settings.biometrics.fingerprint2.ui.enrollment.modules.enrolling.udfps.ui.viewmodel
/** Represents the description text for UDFPS enrollment */
data class DescriptionText(
val isSuw: Boolean,
val isAccessibility: Boolean,
val stageViewModel: StageViewModel,
)

View File

@@ -0,0 +1,24 @@
/*
* Copyright (C) 2024 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.settings.biometrics.fingerprint2.ui.enrollment.modules.enrolling.udfps.ui.viewmodel
/** Represents the lottie for UDFPS enrollment */
data class EducationAnimationModel(
val isSuw: Boolean,
val isAccessibility: Boolean,
val stageViewModel: StageViewModel,
)

View File

@@ -0,0 +1,24 @@
/*
* Copyright (C) 2024 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.settings.biometrics.fingerprint2.ui.enrollment.modules.enrolling.udfps.ui.viewmodel
/** Represents the header text for UDFPS enrollment */
data class HeaderText(
val isSuw: Boolean,
val isAccessibility: Boolean,
val stageViewModel: StageViewModel,
)

View File

@@ -22,15 +22,30 @@ package com.android.settings.biometrics.fingerprint2.ui.enrollment.modules.enrol
* of the where the user should press their fingerprint
*/
sealed class StageViewModel {
/** Unknown stage */
data object Unknown : StageViewModel()
/** This is the stage that moves the fingerprint icon around during enrollment. */
data object Guided : StageViewModel()
/** The center stage is the initial stage of enrollment. */
data object Center : StageViewModel()
/**
* Fingerprint stage of enrollment. Typically there is some sort of indication that a user should
* be using their finger tip to enroll.
*/
data object Fingertip : StageViewModel()
/**
* Left edge stage of enrollment. Typically there is an indication that a user should be using the
* left edge of their fingerprint.
*/
data object LeftEdge : StageViewModel()
/**
* Right edge stage of enrollment. Typically there is an indication that a user should be using
* the right edge of their fingerprint.
*/
data object RightEdge : StageViewModel()
}

View File

@@ -0,0 +1,41 @@
/*
* Copyright (C) 2024 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.settings.biometrics.fingerprint2.ui.enrollment.modules.enrolling.udfps.ui.viewmodel
/** A class indicating a udfps enroll event occurred. */
sealed class UdfpsEnrollEvent
/** Describes how many [remainingSteps] and how many [totalSteps] are left in udfps enrollment. */
data class UdfpsProgress(val remainingSteps: Int, val totalSteps: Int) : UdfpsEnrollEvent()
/** Indicates a help event has been sent by enrollment */
data class UdfpsHelp(val helpMsgId: Int, val helpString: String) : UdfpsEnrollEvent()
/** Indicates a error event has been sent by enrollment */
data class UdfpsError(val errMsgId: Int, val errString: String) : UdfpsEnrollEvent()
/** Indicates an acquired event has occurred */
data class Acquired(val acquiredGood: Boolean) : UdfpsEnrollEvent()
/** Indicates a pointer down event has occurred */
data object PointerDown : UdfpsEnrollEvent()
/** Indicates a pointer up event has occurred */
data object PointerUp : UdfpsEnrollEvent()
/** Indicates the overlay has shown */
data object OverlayShown : UdfpsEnrollEvent()

View File

@@ -16,16 +16,93 @@
package com.android.settings.biometrics.fingerprint2.ui.enrollment.modules.enrolling.udfps.ui.viewmodel
import android.graphics.Rect
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import com.android.settings.biometrics.fingerprint2.ui.enrollment.viewmodel.FingerprintNavigationStep
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
/** ViewModel used to drive UDFPS Enrollment through [UdfpsEnrollFragment] */
class UdfpsViewModel() : ViewModel() {
/** Indicates what stage UDFPS enrollment is in. */
val stageFlow = flowOf(StageViewModel.Center)
private val isSetupWizard = flowOf(false)
/** Indicates which Enrollment stage we are currently in. */
private val sensorLocationInternal = Pair(540, 1713)
private val sensorRadius = 100
private val sensorRect =
Rect(
this.sensorLocationInternal.first - sensorRadius,
this.sensorLocationInternal.second - sensorRadius,
this.sensorLocationInternal.first + sensorRadius,
this.sensorLocationInternal.second + sensorRadius,
)
private val stageThresholds = flowOf(listOf(.25, .5, .75, .875))
/** Indicates if accessibility is enabled */
val accessibilityEnabled = flowOf(false)
/** Indicates the locates of the fingerprint sensor. */
val sensorLocation: Flow<Rect> = flowOf(sensorRect)
/** This is currently not hooked up to fingerprint manager, and is being fed mock events. */
val udfpsEvent: Flow<UdfpsEnrollEvent> =
flow {
enrollEvents.forEach { events ->
events.forEach { event -> emit(event) }
delay(1000)
}
}
.flowOn(Dispatchers.IO)
/** Determines the current [StageViewModel] enrollment is in */
val enrollStage: Flow<StageViewModel> =
combine(stageThresholds, udfpsEvent) { thresholds, event ->
if (event is UdfpsProgress) {
thresholdToStageMap(thresholds, event.totalSteps - event.remainingSteps, event.totalSteps)
} else {
null
}
}
.filterNotNull()
/** The header text for UDFPS enrollment */
val headerText: Flow<HeaderText> =
combine(isSetupWizard, accessibilityEnabled, enrollStage) { isSuw, isAccessibility, stage ->
return@combine HeaderText(isSuw, isAccessibility, stage)
}
private val shouldClearDescriptionText = enrollStage.map { it is StageViewModel.Unknown }
/** The description text for UDFPS enrollment */
val descriptionText: Flow<DescriptionText?> =
combine(isSetupWizard, accessibilityEnabled, enrollStage, shouldClearDescriptionText) {
isSuw,
isAccessibility,
stage,
shouldClearText ->
if (shouldClearText) {
return@combine null
} else {
return@combine DescriptionText(isSuw, isAccessibility, stage)
}
}
/** The lottie that should be shown for UDFPS Enrollment */
val lottie: Flow<EducationAnimationModel> =
combine(isSetupWizard, accessibilityEnabled, enrollStage) { isSuw, isAccessibility, stage ->
return@combine EducationAnimationModel(isSuw, isAccessibility, stage)
}.distinctUntilChanged()
class UdfpsEnrollmentFactory() : ViewModelProvider.Factory {
@@ -38,5 +115,58 @@ class UdfpsViewModel() : ViewModel() {
companion object {
private val navStep = FingerprintNavigationStep.Enrollment::class
private const val TAG = "UDFPSViewModel"
private val ENROLLMENT_STAGES_ORDERED =
listOf(
StageViewModel.Center,
StageViewModel.Guided,
StageViewModel.Fingertip,
StageViewModel.LeftEdge,
StageViewModel.RightEdge,
)
/**
* [thresholds] is a list of 4 numbers from [0,1] that separate enrollment into 5 stages. The
* stage is determined by mapping [thresholds] * [maxSteps] and finding where the [currentStep]
* is.
*
* Each number in the array should be strictly increasing such as [0.2, 0.5, 0.6, 0.8]
*/
private fun thresholdToStageMap(
thresholds: List<Double>,
currentStep: Int,
maxSteps: Int,
): StageViewModel {
val stageIterator = ENROLLMENT_STAGES_ORDERED.iterator()
thresholds.forEach {
val thresholdLimit = it * maxSteps
val curr = stageIterator.next()
if (currentStep < thresholdLimit) {
return curr
}
}
return stageIterator.next()
}
/** This will be removed */
private val enrollEvents: List<List<UdfpsEnrollEvent>> =
listOf(
listOf(OverlayShown),
CreateProgress(10, 10),
CreateProgress(9, 10),
CreateProgress(8, 10),
CreateProgress(7, 10),
CreateProgress(6, 10),
CreateProgress(5, 10),
CreateProgress(4, 10),
CreateProgress(3, 10),
CreateProgress(2, 10),
CreateProgress(1, 10),
CreateProgress(0, 10),
)
/** This will be removed */
private fun CreateProgress(remaining: Int, total: Int): List<UdfpsEnrollEvent> {
return listOf(PointerDown, Acquired(true), UdfpsProgress(remaining, total), PointerUp)
}
}
}

View File

@@ -0,0 +1,88 @@
/*
* Copyright (C) 2024 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.settings.biometrics.fingerprint2.ui.enrollment.modules.enrolling.udfps.ui.widget
import android.content.Context
import android.graphics.PointF
import android.util.TypedValue
import android.view.accessibility.AccessibilityManager
import com.android.settings.biometrics.fingerprint2.ui.enrollment.modules.enrolling.udfps.ui.viewmodel.StageViewModel
/** Keeps track of which guided enrollment point we should be using */
class UdfpsEnrollHelperV2(private val mContext: Context) {
private var isGuidedEnrollment: Boolean = false
private val accessibilityEnabled: Boolean
private val guidedEnrollmentPoints: MutableList<PointF>
private var index = 0
init {
val am = mContext.getSystemService(AccessibilityManager::class.java)
accessibilityEnabled = am!!.isEnabled
guidedEnrollmentPoints = ArrayList()
// Number of pixels per mm
val px =
TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_MM, 1f, mContext.resources.displayMetrics)
guidedEnrollmentPoints.add(PointF(2.00f * px, 0.00f * px))
guidedEnrollmentPoints.add(PointF(0.87f * px, -2.70f * px))
guidedEnrollmentPoints.add(PointF(-1.80f * px, -1.31f * px))
guidedEnrollmentPoints.add(PointF(-1.80f * px, 1.31f * px))
guidedEnrollmentPoints.add(PointF(0.88f * px, 2.70f * px))
guidedEnrollmentPoints.add(PointF(3.94f * px, -1.06f * px))
guidedEnrollmentPoints.add(PointF(2.90f * px, -4.14f * px))
guidedEnrollmentPoints.add(PointF(-0.52f * px, -5.95f * px))
guidedEnrollmentPoints.add(PointF(-3.33f * px, -3.33f * px))
guidedEnrollmentPoints.add(PointF(-3.99f * px, -0.35f * px))
guidedEnrollmentPoints.add(PointF(-3.62f * px, 2.54f * px))
guidedEnrollmentPoints.add(PointF(-1.49f * px, 5.57f * px))
guidedEnrollmentPoints.add(PointF(2.29f * px, 4.92f * px))
guidedEnrollmentPoints.add(PointF(3.82f * px, 1.78f * px))
}
/**
* This indicates whether we should be offsetting the enrollment icon based on
* [guidedEnrollmentPoints]
*/
fun onUpdateStage(stage: StageViewModel) {
this.isGuidedEnrollment = stage is StageViewModel.Guided
}
/** Updates [index] to be used by [guidedEnrollmentPoints] */
fun onEnrollmentProgress(remaining: Int, totalSteps: Int) {
index = totalSteps - remaining
}
/**
* Returns the current guided enrollment point, or (0,0) if we are not in guided enrollment or are
* in accessibility.
*/
val guidedEnrollmentLocation: PointF?
get() {
if (accessibilityEnabled || !isGuidedEnrollment) {
return null
}
var scale = SCALE
val originalPoint = guidedEnrollmentPoints[index % guidedEnrollmentPoints.size]
return PointF(originalPoint.x * scale, originalPoint.y * scale)
}
companion object {
private const val TAG = "UdfpsEnrollHelperV2"
private const val SCALE = 0.5f
}
}

View File

@@ -0,0 +1,243 @@
/*
* Copyright (C) 2024 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.settings.biometrics.fingerprint2.ui.enrollment.modules.enrolling.udfps.ui.widget
import android.animation.AnimatorSet
import android.animation.ValueAnimator
import android.annotation.ColorInt
import android.content.Context
import android.graphics.Canvas
import android.graphics.ColorFilter
import android.graphics.Paint
import android.graphics.PixelFormat
import android.graphics.Rect
import android.graphics.RectF
import android.graphics.drawable.Drawable
import android.graphics.drawable.ShapeDrawable
import android.graphics.drawable.shapes.PathShape
import android.util.AttributeSet
import android.util.Log
import android.util.PathParser
import android.view.animation.AccelerateDecelerateInterpolator
import androidx.core.animation.addListener
import androidx.core.graphics.toRect
import androidx.core.graphics.toRectF
import com.android.settings.R
import com.android.settings.biometrics.fingerprint2.ui.enrollment.modules.enrolling.udfps.ui.viewmodel.StageViewModel
import kotlin.math.sin
/**
* This class is responsible for drawing the udfps icon, and to update its movement based on the
* various stages of enrollment
*/
class UdfpsEnrollIconV2 internal constructor(context: Context, attrs: AttributeSet?) : Drawable() {
private var targetAnimatorSet: AnimatorSet? = null
private val movingTargetFpIcon: Drawable
private val fingerprintDrawable: ShapeDrawable
private val sensorOutlinePaint: Paint
private val blueFill: Paint
private val helper = UdfpsEnrollHelperV2(context)
@ColorInt private var enrollIconColor = 0
@ColorInt private var movingTargetFill = 0
private var currentScale = 1.0f
private var alpha = 0
/**
* This is the physical location of the sensor. This rect will be updated by [drawSensorRectAt]
*/
private val sensorRectBounds: Rect = Rect()
/**
* The following values are used to describe where the icon should be drawn. [currX] and [currY]
* are changed based on the current guided enrollment step which is given by the
* [UdfpsEnrollHelperV2]
*/
private var currX = 0f
private var currY = 0f
private var sensorWidth = 0f
private var sensorHeight = 0f
init {
fingerprintDrawable = createUdfpsIcon(context)
context
.obtainStyledAttributes(
attrs,
R.styleable.BiometricsEnrollView,
R.attr.biometricsEnrollStyle,
R.style.BiometricsEnrollStyle,
)
.let {
enrollIconColor = it.getColor(R.styleable.BiometricsEnrollView_biometricsEnrollIcon, 0)
movingTargetFill =
it.getColor(R.styleable.BiometricsEnrollView_biometricsMovingTargetFill, 0)
it.recycle()
}
sensorOutlinePaint = Paint(0 /* flags */).apply {
isAntiAlias = true
setColor(movingTargetFill)
style = Paint.Style.FILL
}
blueFill = Paint(0 /* flags */).apply {
isAntiAlias = true
setColor(movingTargetFill)
style = Paint.Style.FILL
}
movingTargetFpIcon = context.resources.getDrawable(R.drawable.ic_enrollment_fingerprint, null).apply {
setTint(enrollIconColor)
mutate()
}
fingerprintDrawable.setTint(enrollIconColor)
setAlpha(255)
}
override fun getAlpha(): Int {
return alpha
}
override fun setColorFilter(colorFilter: ColorFilter?) {}
override fun getOpacity(): Int {
return PixelFormat.UNKNOWN
}
override fun setAlpha(alpha: Int) {
this.alpha = alpha
}
/**
* The [sensorRect] coordinates for the sensor area. The [sensorRect] should be the coordinates
* with respect to its root frameview
*/
fun drawSensorRectAt(sensorRect: Rect) {
Log.e(TAG, "UdfpsEnrollIcon#drawSensorRect($sensorRect)")
sensorRectBounds.set(sensorRect)
fingerprintDrawable.bounds = sensorRect
movingTargetFpIcon.bounds = sensorRect
currX = sensorRect.left.toFloat()
currY = sensorRect.top.toFloat()
sensorWidth = (sensorRect.right - sensorRect.left).toFloat()
sensorHeight = (sensorRect.bottom - sensorRect.top).toFloat()
invalidateSelf()
}
/** Update the progress of the icon */
fun onEnrollmentProgress(remaining: Int, totalSteps: Int) {
helper.onEnrollmentProgress(remaining, totalSteps)
val offset = helper.guidedEnrollmentLocation
val currentBounds = getCurrLocation().toRect()
if (offset != null) {
// This is the desired location of the sensor rect, the [EnrollHelper]
// offsets the initial sensor rect by a bit to get the user to move their finger a bit more.
val targetRect = Rect(sensorRectBounds).toRectF()
targetRect.offset(offset.x, offset.y)
var shouldAnimateMovement =
!currentBounds.equals(targetRect) && offset.x != 0f && offset.y != 0f
if (shouldAnimateMovement) {
targetAnimatorSet?.let { it.cancel() }
animateMovement(currentBounds, targetRect, true)
}
} else {
// If we are not offsetting the sensor, move it back to its original place
animateMovement(currentBounds, sensorRectBounds.toRectF(), false)
}
invalidateSelf()
}
/** Update the stage of the icon */
fun updateStage(it: StageViewModel) {
helper.onUpdateStage(it)
invalidateSelf()
}
override fun draw(canvas: Canvas) {
val currLocation = getCurrLocation()
canvas.scale(currentScale, currentScale, currLocation.centerX(), currLocation.centerY())
sensorRectBounds?.let { canvas.drawOval(currLocation, sensorOutlinePaint) }
fingerprintDrawable.bounds = currLocation.toRect()
fingerprintDrawable.draw(canvas)
fingerprintDrawable.setAlpha(alpha)
sensorOutlinePaint.setAlpha(alpha)
}
private fun getCurrLocation(): RectF =
RectF(currX, currY, currX + sensorWidth, currY + sensorHeight)
private fun animateMovement(currentBounds: Rect, offsetRect: RectF, scaleMovement: Boolean) {
if (currentBounds.equals(offsetRect)) {
return
}
val xAnimator = ValueAnimator.ofFloat(currentBounds.left.toFloat(), offsetRect.left)
xAnimator.addUpdateListener {
currX = it.animatedValue as Float
invalidateSelf()
}
val yAnimator = ValueAnimator.ofFloat(currentBounds.top.toFloat(), offsetRect.top)
yAnimator.addUpdateListener {
currY = it.animatedValue as Float
invalidateSelf()
}
val animators = mutableListOf(xAnimator, yAnimator)
val duration = TARGET_ANIM_DURATION_LONG
if (scaleMovement) {
val scaleAnimator = ValueAnimator.ofFloat(0f, Math.PI.toFloat())
scaleAnimator.setDuration(duration)
scaleAnimator.addUpdateListener { animation: ValueAnimator ->
// Grow then shrink
currentScale =
(1 + SCALE_MAX * sin((animation.getAnimatedValue() as Float).toDouble()).toFloat())
invalidateSelf()
}
scaleAnimator.addListener(onEnd = { currentScale = 1f })
animators.add(scaleAnimator)
}
targetAnimatorSet = AnimatorSet()
targetAnimatorSet?.let {
it.interpolator = AccelerateDecelerateInterpolator()
it.setDuration(duration)
it.playTogether(animators.toList())
it.start()
}
}
companion object {
private const val TAG = "UdfpsEnrollDrawableV2"
private const val DEFAULT_STROKE_WIDTH = 3f
private const val TARGET_ANIM_DURATION_LONG = 800L
private const val SCALE_MAX = 0.25f
private fun createUdfpsIcon(context: Context): ShapeDrawable {
val fpPath = context.resources.getString(R.string.config_udfpsIcon)
val drawable = ShapeDrawable(PathShape(PathParser.createPathFromPathData(fpPath), 72f, 72f)).apply {
mutate()
paint.style = Paint.Style.STROKE
paint.strokeCap = Paint.Cap.ROUND
paint.strokeWidth = DEFAULT_STROKE_WIDTH
}
return drawable
}
}
}

View File

@@ -0,0 +1,362 @@
/*
* Copyright (C) 2024 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.settings.biometrics.fingerprint2.ui.enrollment.modules.enrolling.udfps.ui.widget
import android.animation.ValueAnimator
import android.animation.ValueAnimator.AnimatorUpdateListener
import android.content.Context
import android.graphics.Canvas
import android.graphics.ColorFilter
import android.graphics.Paint
import android.graphics.PixelFormat
import android.graphics.Rect
import android.graphics.drawable.Drawable
import android.os.Process
import android.os.VibrationAttributes
import android.os.VibrationEffect
import android.os.Vibrator
import android.util.AttributeSet
import android.util.DisplayMetrics
import android.view.animation.DecelerateInterpolator
import android.view.animation.Interpolator
import androidx.annotation.ColorInt
import androidx.core.graphics.toRectF
import com.android.internal.annotations.VisibleForTesting
import com.android.settings.R
import kotlin.math.max
import kotlin.math.min
/**
* UDFPS enrollment progress bar. This view is responsible for drawing the progress ring and its
* fill around the center of the UDFPS sensor.
*/
class UdfpsEnrollProgressBarDrawableV2(private val mContext: Context, attrs: AttributeSet?) :
Drawable() {
private val sensorRect: Rect = Rect()
private val strokeWidthPx: Float
@ColorInt private val progressColor: Int
@ColorInt private var helpColor: Int = 0
@ColorInt private val onFirstBucketFailedColor: Int
private val backgroundPaint: Paint
@VisibleForTesting val fillPaint: Paint
private val vibrator: Vibrator
private var isAccessibilityEnabled: Boolean = false
private var afterFirstTouch = false
private var remainingSteps = 0
private var totalSteps = 0
private var progress = 0f
private var progressAnimator: ValueAnimator? = null
private val progressUpdateListener: AnimatorUpdateListener
private var showingHelp = false
private var fillColorAnimator: ValueAnimator? = null
private val fillColorUpdateListener: AnimatorUpdateListener
private var backgroundColorAnimator: ValueAnimator? = null
private val backgroundColorUpdateListener: AnimatorUpdateListener
private var complete = false
private var movingTargetFill = 0
private var movingTargetFillError = 0
private var enrollProgressColor = 0
private var enrollProgressHelp = 0
private var enrollProgressHelpWithTalkback = 0
private val progressBarRadius: Int
init {
val ta =
mContext.obtainStyledAttributes(
attrs,
R.styleable.BiometricsEnrollView,
R.attr.biometricsEnrollStyle,
R.style.BiometricsEnrollStyle,
)
movingTargetFill = ta.getColor(R.styleable.BiometricsEnrollView_biometricsMovingTargetFill, 0)
movingTargetFillError =
ta.getColor(R.styleable.BiometricsEnrollView_biometricsMovingTargetFillError, 0)
enrollProgressColor = ta.getColor(R.styleable.BiometricsEnrollView_biometricsEnrollProgress, 0)
enrollProgressHelp =
ta.getColor(R.styleable.BiometricsEnrollView_biometricsEnrollProgressHelp, 0)
enrollProgressHelpWithTalkback =
ta.getColor(R.styleable.BiometricsEnrollView_biometricsEnrollProgressHelpWithTalkback, 0)
ta.recycle()
val density = mContext.resources.displayMetrics.densityDpi.toFloat()
strokeWidthPx = STROKE_WIDTH_DP * (density / DisplayMetrics.DENSITY_DEFAULT)
progressColor = enrollProgressColor
onFirstBucketFailedColor = movingTargetFillError
updateHelpColor()
backgroundPaint = Paint().apply {
strokeWidth = strokeWidthPx
setColor(movingTargetFill)
isAntiAlias = true
style = Paint.Style.STROKE
strokeCap = Paint.Cap.ROUND
}
// Progress fill should *not* use the extracted system color.
fillPaint = Paint().apply {
strokeWidth = strokeWidthPx
setColor(progressColor)
isAntiAlias = true
style = Paint.Style.STROKE
strokeCap = Paint.Cap.ROUND
}
vibrator = mContext.getSystemService(Vibrator::class.java)!!
progressBarRadius = mContext.resources.getInteger(R.integer.config_udfpsEnrollProgressBar)
progressUpdateListener = AnimatorUpdateListener { animation: ValueAnimator ->
progress = animation.getAnimatedValue() as Float
invalidateSelf()
}
fillColorUpdateListener = AnimatorUpdateListener { animation: ValueAnimator ->
fillPaint.setColor(animation.getAnimatedValue() as Int)
invalidateSelf()
}
backgroundColorUpdateListener = AnimatorUpdateListener { animation: ValueAnimator ->
backgroundPaint.setColor(animation.getAnimatedValue() as Int)
invalidateSelf()
}
}
/** Indicates enrollment progress has occurred. */
fun onEnrollmentProgress(remaining: Int, totalSteps: Int) {
afterFirstTouch = true
updateState(remaining, totalSteps, false /* showingHelp */)
}
/** Indicates enrollment help has occurred. */
fun onEnrollmentHelp(remaining: Int, totalSteps: Int) {
updateState(remaining, totalSteps, true /* showingHelp */)
}
/** Indicates the last step was acquired. */
fun onLastStepAcquired() {
updateState(0, totalSteps, false /* showingHelp */)
}
override fun draw(canvas: Canvas) {
canvas.save()
// This takes the sensors bounding box and expands it by [progressBarRadius] in all directions
val sensorProgressRect = Rect(sensorRect)
sensorProgressRect.inset(
-progressBarRadius,
-progressBarRadius,
-progressBarRadius,
-progressBarRadius,
)
// Rotate -90 degrees to make the progress start from the top right and not the bottom
// right
canvas.rotate(
-90f,
sensorProgressRect.centerX().toFloat(),
sensorProgressRect.centerY().toFloat(),
)
if (progress < 1f) {
// Draw the background color of the progress circle.
canvas.drawArc(
sensorProgressRect.toRectF(),
0f /* startAngle */,
360f /* sweepAngle */,
false /* useCenter */,
backgroundPaint,
)
}
if (progress > 0f) {
// Draw the filled portion of the progress circle.
canvas.drawArc(
sensorProgressRect.toRectF(),
0f /* startAngle */,
360f * progress /* sweepAngle */,
false /* useCenter */,
fillPaint,
)
}
canvas.restore()
}
/** Do nothing here, we will control the alpha internally. */
override fun setAlpha(alpha: Int) {}
/** Do not apply color filters here for enrollment. */
override fun setColorFilter(colorFilter: ColorFilter?) {}
/** Legacy code, returning PixelFormat.UNKNOWN */
override fun getOpacity(): Int {
return PixelFormat.UNKNOWN
}
/**
* Draws the progress with locations [sensorLocationX] [sensorLocationY], note these must be with
* respect to the parent framelayout.
*/
fun drawProgressAt(sensorRect: Rect) {
this.sensorRect.set(sensorRect)
}
/** Indicates if accessibility is enabled or not. */
fun setAccessibilityEnabled(enabled: Boolean) {
isAccessibilityEnabled = enabled
updateHelpColor()
}
private fun updateHelpColor() {
helpColor =
if (!isAccessibilityEnabled) {
enrollProgressHelp
} else {
enrollProgressHelpWithTalkback
}
}
private fun updateState(remainingSteps: Int, totalSteps: Int, showingHelp: Boolean) {
updateProgress(remainingSteps, totalSteps, showingHelp)
updateFillColor(showingHelp)
}
private fun updateProgress(remainingSteps: Int, totalSteps: Int, showingHelp: Boolean) {
if (this.remainingSteps == remainingSteps && this.totalSteps == totalSteps) {
return
}
if (this.showingHelp) {
if (vibrator != null && isAccessibilityEnabled) {
vibrator.vibrate(
Process.myUid(),
mContext.opPackageName,
VIBRATE_EFFECT_ERROR,
javaClass.getSimpleName() + "::onEnrollmentHelp",
FINGERPRINT_ENROLLING_SONFICATION_ATTRIBUTES,
)
}
} else {
// If the first touch is an error, remainingSteps will be -1 and the callback
// doesn't come from onEnrollmentHelp. If we are in the accessibility flow,
// we still would like to vibrate.
if (vibrator != null) {
if (remainingSteps == -1 && isAccessibilityEnabled) {
vibrator.vibrate(
Process.myUid(),
mContext.opPackageName,
VIBRATE_EFFECT_ERROR,
javaClass.getSimpleName() + "::onFirstTouchError",
FINGERPRINT_ENROLLING_SONFICATION_ATTRIBUTES,
)
} else if (remainingSteps != -1 && !isAccessibilityEnabled) {
vibrator.vibrate(
Process.myUid(),
mContext.opPackageName,
SUCCESS_VIBRATION_EFFECT,
javaClass.getSimpleName() + "::OnEnrollmentProgress",
HARDWARE_FEEDBACK_VIBRATION_ATTRIBUTES,
)
}
}
}
this.showingHelp = showingHelp
this.remainingSteps = remainingSteps
this.totalSteps = totalSteps
val progressSteps = max(0.0, (totalSteps - remainingSteps).toDouble()).toInt()
// If needed, add 1 to progress and total steps to account for initial touch.
val adjustedSteps = if (afterFirstTouch) progressSteps + 1 else progressSteps
val adjustedTotal = if (afterFirstTouch) this.totalSteps + 1 else this.totalSteps
val targetProgress =
min(1.0, (adjustedSteps.toFloat() / adjustedTotal.toFloat()).toDouble()).toFloat()
if (progressAnimator != null && progressAnimator!!.isRunning) {
progressAnimator!!.cancel()
}
progressAnimator =
ValueAnimator.ofFloat(progress, targetProgress).also {
it.setDuration(PROGRESS_ANIMATION_DURATION_MS)
it.addUpdateListener(progressUpdateListener)
it.start()
}
if (remainingSteps == 0) {
startCompletionAnimation()
} else if (remainingSteps > 0) {
rollBackCompletionAnimation()
}
}
private fun animateBackgroundColor() {
if (backgroundColorAnimator != null && backgroundColorAnimator!!.isRunning) {
backgroundColorAnimator!!.end()
}
backgroundColorAnimator =
ValueAnimator.ofArgb(backgroundPaint.color, onFirstBucketFailedColor).also {
it.setDuration(FILL_COLOR_ANIMATION_DURATION_MS)
it.repeatCount = 1
it.repeatMode = ValueAnimator.REVERSE
it.interpolator = DEACCEL
it.addUpdateListener(backgroundColorUpdateListener)
it.start()
}
}
private fun updateFillColor(showingHelp: Boolean) {
if (!afterFirstTouch && showingHelp) {
// If we are on the first touch, animate the background color
// instead of the progress color.
animateBackgroundColor()
return
}
if (fillColorAnimator != null && fillColorAnimator!!.isRunning) {
fillColorAnimator!!.end()
}
@ColorInt val targetColor = if (showingHelp) helpColor else progressColor
fillColorAnimator =
ValueAnimator.ofArgb(fillPaint.color, targetColor).also {
it.setDuration(FILL_COLOR_ANIMATION_DURATION_MS)
it.repeatCount = 1
it.repeatMode = ValueAnimator.REVERSE
it.interpolator = DEACCEL
it.addUpdateListener(fillColorUpdateListener)
it.start()
}
}
private fun startCompletionAnimation() {
if (complete) {
return
}
complete = true
}
private fun rollBackCompletionAnimation() {
if (!complete) {
return
}
complete = false
}
private fun loadResources(context: Context, attrs: AttributeSet?) {}
companion object {
private const val TAG = "UdfpsProgressBar"
private const val FILL_COLOR_ANIMATION_DURATION_MS = 350L
private const val PROGRESS_ANIMATION_DURATION_MS = 400L
private const val STROKE_WIDTH_DP = 12f
private val DEACCEL: Interpolator = DecelerateInterpolator()
private val VIBRATE_EFFECT_ERROR = VibrationEffect.createWaveform(longArrayOf(0, 5, 55, 60), -1)
private val FINGERPRINT_ENROLLING_SONFICATION_ATTRIBUTES =
VibrationAttributes.createForUsage(VibrationAttributes.USAGE_ACCESSIBILITY)
private val HARDWARE_FEEDBACK_VIBRATION_ATTRIBUTES =
VibrationAttributes.createForUsage(VibrationAttributes.USAGE_HARDWARE_FEEDBACK)
private val SUCCESS_VIBRATION_EFFECT = VibrationEffect.get(VibrationEffect.EFFECT_CLICK)
}
}

View File

@@ -0,0 +1,142 @@
/*
* Copyright (C) 2024 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.settings.biometrics.fingerprint2.ui.enrollment.modules.enrolling.udfps.ui.widget
import android.content.Context
import android.graphics.Rect
import android.util.AttributeSet
import android.util.Log
import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.ImageView
import com.android.settings.R
import com.android.settings.biometrics.fingerprint2.ui.enrollment.modules.enrolling.udfps.ui.viewmodel.Acquired
import com.android.settings.biometrics.fingerprint2.ui.enrollment.modules.enrolling.udfps.ui.viewmodel.OverlayShown
import com.android.settings.biometrics.fingerprint2.ui.enrollment.modules.enrolling.udfps.ui.viewmodel.PointerDown
import com.android.settings.biometrics.fingerprint2.ui.enrollment.modules.enrolling.udfps.ui.viewmodel.PointerUp
import com.android.settings.biometrics.fingerprint2.ui.enrollment.modules.enrolling.udfps.ui.viewmodel.StageViewModel
import com.android.settings.biometrics.fingerprint2.ui.enrollment.modules.enrolling.udfps.ui.viewmodel.UdfpsEnrollEvent
import com.android.settings.biometrics.fingerprint2.ui.enrollment.modules.enrolling.udfps.ui.viewmodel.UdfpsError
import com.android.settings.biometrics.fingerprint2.ui.enrollment.modules.enrolling.udfps.ui.viewmodel.UdfpsHelp
import com.android.settings.biometrics.fingerprint2.ui.enrollment.modules.enrolling.udfps.ui.viewmodel.UdfpsProgress
/**
* View corresponding with fingerprint_v2_udfps_enroll_view.xml. This view is responsible for
* drawing the [UdfpsEnrollIconV2] and the [UdfpsEnrollProgressBarDrawableV2].
*/
class UdfpsEnrollViewV2(context: Context, attrs: AttributeSet?) : FrameLayout(context, attrs) {
private var isAccessibilityEnabled: Boolean = false
private lateinit var sensorRect: Rect
private val fingerprintIcon: UdfpsEnrollIconV2 = UdfpsEnrollIconV2(mContext, attrs)
private val fingerprintProgressDrawable: UdfpsEnrollProgressBarDrawableV2 =
UdfpsEnrollProgressBarDrawableV2(mContext, attrs)
private var mTotalSteps = -1
private var mRemainingSteps = -1
/**
* This function computes the center (x,y) location with respect to the parent [FrameLayout] for
* the [UdfpsEnrollProgressBarDrawableV2]. It also computes the [Rect] with respect to the parent
* [FrameLayout] for the [UdfpsEnrollIconV2].
*/
fun setSensorRect(rect: Rect) {
this.sensorRect = rect
post {
findViewById<ImageView?>(R.id.udfps_enroll_animation_fp_progress_view)?.also {
it.setImageDrawable(fingerprintProgressDrawable)
}
findViewById<ImageView>(R.id.udfps_enroll_animation_fp_view)?.also {
it.setImageDrawable(fingerprintIcon)
}
val parentView = parent as ViewGroup
val coords = parentView.getLocationOnScreen()
val parentLeft = coords[0]
val parentTop = coords[1]
val sensorRectOffset = Rect(sensorRect)
sensorRectOffset.offset(-parentLeft, -parentTop)
fingerprintIcon.drawSensorRectAt(sensorRectOffset)
fingerprintProgressDrawable.drawProgressAt(sensorRectOffset)
}
}
/** Updates the current enrollment stage. */
fun updateStage(it: StageViewModel) {
fingerprintIcon.updateStage(it)
}
/** Receive enroll progress event */
fun onUdfpsEvent(event: UdfpsEnrollEvent) {
when (event) {
is UdfpsProgress -> onEnrollmentProgress(event.remainingSteps, event.totalSteps)
is Acquired -> onAcquired(event.acquiredGood)
is UdfpsHelp -> onEnrollmentHelp()
is PointerDown -> onPointerDown()
is PointerUp -> onPointerUp()
OverlayShown -> overlayShown()
is UdfpsError -> udfpsError(event.errMsgId, event.errString)
}
}
/** Indicates if accessibility is enabled. */
fun setAccessibilityEnabled(enabled: Boolean) {
this.isAccessibilityEnabled = enabled
fingerprintProgressDrawable.setAccessibilityEnabled(enabled)
}
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
invalidate()
super.onLayout(changed, left, top, right, bottom)
invalidate()
}
private fun udfpsError(errMsgId: Int, errString: String) {
Log.e(TAG, "Implement udfpsError")
}
private fun overlayShown() {
Log.e(TAG, "Implement overlayShown")
}
/** Receive enroll progress event */
private fun onEnrollmentProgress(remaining: Int, totalSteps: Int) {
post {
fingerprintIcon.onEnrollmentProgress(remaining, totalSteps)
fingerprintProgressDrawable.onEnrollmentProgress(remaining, totalSteps)
}
}
/** Receive enroll help event */
private fun onEnrollmentHelp() {
post { fingerprintProgressDrawable.onEnrollmentHelp(mRemainingSteps, mTotalSteps) }
}
/** Receive onAcquired event */
private fun onAcquired(isAcquiredGood: Boolean) {
val animateIfLastStepGood = isAcquiredGood && mRemainingSteps <= 2 && mRemainingSteps >= 0
post { if (animateIfLastStepGood) fingerprintProgressDrawable.onLastStepAcquired() }
}
/** Receive onPointerDown event */
private fun onPointerDown() {}
/** Receive onPointerUp event */
private fun onPointerUp() {}
companion object {
private const val TAG = "UdfpsEnrollView"
}
}