Merge "[BiometricsV2] Refactor EnrollingSfpsFragment"

This commit is contained in:
TreeHugger Robot
2023-06-21 08:03:37 +00:00
committed by Android (Google) Code Review
2 changed files with 620 additions and 599 deletions

View File

@@ -1,599 +0,0 @@
/*
* Copyright (C) 2023 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.biometrics2.ui.view;
import static android.hardware.fingerprint.FingerprintManager.ENROLL_ENROLL;
import android.animation.Animator;
import android.animation.ObjectAnimator;
import android.annotation.RawRes;
import android.app.Activity;
import android.content.Context;
import android.content.res.ColorStateList;
import android.content.res.Configuration;
import android.graphics.PorterDuff;
import android.graphics.PorterDuffColorFilter;
import android.hardware.fingerprint.FingerprintManager;
import android.os.Bundle;
import android.text.TextUtils;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.view.animation.AccelerateDecelerateInterpolator;
import android.view.animation.AnimationUtils;
import android.view.animation.Interpolator;
import android.widget.ProgressBar;
import android.widget.RelativeLayout;
import androidx.activity.OnBackPressedCallback;
import androidx.annotation.NonNull;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentActivity;
import androidx.lifecycle.Observer;
import androidx.lifecycle.ViewModelProvider;
import com.android.settings.R;
import com.android.settings.biometrics.fingerprint.FingerprintErrorDialog;
import com.android.settings.biometrics2.ui.model.EnrollmentProgress;
import com.android.settings.biometrics2.ui.model.EnrollmentStatusMessage;
import com.android.settings.biometrics2.ui.viewmodel.FingerprintEnrollEnrollingViewModel;
import com.android.settings.biometrics2.ui.viewmodel.FingerprintEnrollProgressViewModel;
import com.airbnb.lottie.LottieAnimationView;
import com.airbnb.lottie.LottieCompositionFactory;
import com.airbnb.lottie.LottieProperty;
import com.airbnb.lottie.model.KeyPath;
import com.google.android.setupcompat.template.FooterBarMixin;
import com.google.android.setupcompat.template.FooterButton;
import com.google.android.setupdesign.GlifLayout;
import com.google.android.setupdesign.template.DescriptionMixin;
import com.google.android.setupdesign.template.HeaderMixin;
/**
* Fragment is used to handle enrolling process for sfps
*/
public class FingerprintEnrollEnrollingSfpsFragment extends Fragment {
private static final String TAG = FingerprintEnrollEnrollingSfpsFragment.class.getSimpleName();
private static final boolean DEBUG = false;
private static final int PROGRESS_BAR_MAX = 10000;
private static final long ANIMATION_DURATION = 250L;
private static final long ICON_TOUCH_DURATION_UNTIL_DIALOG_SHOWN = 500;
private static final int ICON_TOUCH_COUNT_SHOW_UNTIL_DIALOG_SHOWN = 3;
private static final int STAGE_UNKNOWN = -1;
private static final int SFPS_STAGE_NO_ANIMATION = 0;
private static final int SFPS_STAGE_CENTER = 1;
private static final int SFPS_STAGE_FINGERTIP = 2;
private static final int SFPS_STAGE_LEFT_EDGE = 3;
private static final int SFPS_STAGE_RIGHT_EDGE = 4;
private FingerprintEnrollEnrollingViewModel mEnrollingViewModel;
private FingerprintEnrollProgressViewModel mProgressViewModel;
private Interpolator mFastOutSlowInInterpolator;
private GlifLayout mView;
private ProgressBar mProgressBar;
private ObjectAnimator mProgressAnim;
private LottieAnimationView mIllustrationLottie;
private boolean mHaveShownSfpsNoAnimationLottie;
private boolean mHaveShownSfpsCenterLottie;
private boolean mHaveShownSfpsTipLottie;
private boolean mHaveShownSfpsLeftEdgeLottie;
private boolean mHaveShownSfpsRightEdgeLottie;
private ObjectAnimator mHelpAnimation;
private int mIconTouchCount;
private final View.OnClickListener mOnSkipClickListener = v -> {
mEnrollingViewModel.setOnSkipPressed();
cancelEnrollment();
};
private final Observer<EnrollmentProgress> mProgressObserver = progress -> {
if (DEBUG) {
Log.d(TAG, "mProgressObserver(" + progress + ")");
}
if (progress != null && progress.getSteps() >= 0) {
onEnrollmentProgressChange(progress);
}
};
private final Observer<EnrollmentStatusMessage> mHelpMessageObserver = helpMessage -> {
if (DEBUG) {
Log.d(TAG, "mHelpMessageObserver(" + helpMessage + ")");
}
if (helpMessage != null) {
onEnrollmentHelp(helpMessage);
}
};
private final Observer<EnrollmentStatusMessage> mErrorMessageObserver = errorMessage -> {
if (DEBUG) {
Log.d(TAG, "mErrorMessageObserver(" + errorMessage + ")");
}
if (errorMessage != null) {
onEnrollmentError(errorMessage);
}
};
@Override
public void onAttach(@NonNull Context context) {
final FragmentActivity activity = getActivity();
final ViewModelProvider provider = new ViewModelProvider(activity);
mEnrollingViewModel = provider.get(FingerprintEnrollEnrollingViewModel.class);
mProgressViewModel = provider.get(FingerprintEnrollProgressViewModel.class);
super.onAttach(context);
requireActivity().getOnBackPressedDispatcher().addCallback(new OnBackPressedCallback(true) {
@Override
public void handleOnBackPressed() {
setEnabled(false);
mEnrollingViewModel.setOnBackPressed();
cancelEnrollment();
}
});
}
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container,
Bundle savedInstanceState) {
mView = initSfpsLayout(inflater, container);
maybeHideSfpsText(getActivity().getResources().getConfiguration());
return mView;
}
private GlifLayout initSfpsLayout(LayoutInflater inflater, ViewGroup container) {
final GlifLayout containView = (GlifLayout) inflater.inflate(R.layout.sfps_enroll_enrolling,
container, false);
final Activity activity = getActivity();
new GlifLayoutHelper(activity, containView).setDescriptionText(
getString(R.string.security_settings_fingerprint_enroll_start_message));
// setHelpAnimation()
final float translationX = 40;
final int duration = 550;
final RelativeLayout progressLottieLayout = containView.findViewById(R.id.progress_lottie);
mHelpAnimation = ObjectAnimator.ofFloat(progressLottieLayout,
"translationX" /* propertyName */,
0, translationX, -1 * translationX, translationX, 0f);
mHelpAnimation.setInterpolator(new AccelerateDecelerateInterpolator());
mHelpAnimation.setDuration(duration);
mHelpAnimation.setAutoCancel(false);
mIllustrationLottie = containView.findViewById(R.id.illustration_lottie);
mProgressBar = containView.findViewById(R.id.fingerprint_progress_bar);
final FooterBarMixin footerBarMixin = containView.getMixin(FooterBarMixin.class);
footerBarMixin.setSecondaryButton(
new FooterButton.Builder(activity)
.setText(R.string.security_settings_fingerprint_enroll_enrolling_skip)
.setListener(mOnSkipClickListener)
.setButtonType(FooterButton.ButtonType.SKIP)
.setTheme(R.style.SudGlifButton_Secondary)
.build()
);
mFastOutSlowInInterpolator = AnimationUtils.loadInterpolator(
activity, android.R.interpolator.fast_out_slow_in);
mProgressBar.setProgressBackgroundTintMode(PorterDuff.Mode.SRC);
mProgressBar.setOnTouchListener((v, event) -> {
if (event.getActionMasked() == MotionEvent.ACTION_DOWN) {
mIconTouchCount++;
if (mIconTouchCount == ICON_TOUCH_COUNT_SHOW_UNTIL_DIALOG_SHOWN) {
showIconTouchDialog();
} else {
mProgressBar.postDelayed(mShowDialogRunnable,
ICON_TOUCH_DURATION_UNTIL_DIALOG_SHOWN);
}
} else if (event.getActionMasked() == MotionEvent.ACTION_CANCEL
|| event.getActionMasked() == MotionEvent.ACTION_UP) {
mProgressBar.removeCallbacks(mShowDialogRunnable);
}
return true;
});
return containView;
}
@Override
public void onStart() {
super.onStart();
startEnrollment();
updateProgress(false /* animate */, mProgressViewModel.getProgressLiveData().getValue());
final EnrollmentStatusMessage msg = mProgressViewModel.getHelpMessageLiveData().getValue();
if (msg != null) {
onEnrollmentHelp(msg);
} else {
clearError();
updateTitleAndDescription();
}
}
@Override
public void onStop() {
removeEnrollmentObservers();
if (!getActivity().isChangingConfigurations() && mProgressViewModel.isEnrolling()) {
mProgressViewModel.cancelEnrollment();
}
super.onStop();
}
private void removeEnrollmentObservers() {
preRemoveEnrollmentObservers();
mProgressViewModel.getErrorMessageLiveData().removeObserver(mErrorMessageObserver);
}
private void preRemoveEnrollmentObservers() {
mProgressViewModel.getProgressLiveData().removeObserver(mProgressObserver);
mProgressViewModel.getHelpMessageLiveData().removeObserver(mHelpMessageObserver);
}
private void cancelEnrollment() {
preRemoveEnrollmentObservers();
mProgressViewModel.cancelEnrollment();
}
private void startEnrollment() {
final boolean startResult = mProgressViewModel.startEnrollment(ENROLL_ENROLL);
if (!startResult) {
Log.e(TAG, "startEnrollment(), failed");
}
mProgressViewModel.getProgressLiveData().observe(this, mProgressObserver);
mProgressViewModel.getHelpMessageLiveData().observe(this, mHelpMessageObserver);
mProgressViewModel.getErrorMessageLiveData().observe(this, mErrorMessageObserver);
}
private void configureEnrollmentStage(CharSequence description, @RawRes int lottie) {
new GlifLayoutHelper(getActivity(), mView).setDescriptionText(description);
LottieCompositionFactory.fromRawRes(getActivity(), lottie)
.addListener((c) -> {
mIllustrationLottie.setComposition(c);
mIllustrationLottie.setVisibility(View.VISIBLE);
mIllustrationLottie.playAnimation();
});
}
private int getCurrentSfpsStage() {
EnrollmentProgress progressLiveData = mProgressViewModel.getProgressLiveData().getValue();
if (progressLiveData == null) {
return STAGE_UNKNOWN;
}
final int progressSteps = progressLiveData.getSteps() - progressLiveData.getRemaining();
if (progressSteps < getStageThresholdSteps(0)) {
return SFPS_STAGE_NO_ANIMATION;
} else if (progressSteps < getStageThresholdSteps(1)) {
return SFPS_STAGE_CENTER;
} else if (progressSteps < getStageThresholdSteps(2)) {
return SFPS_STAGE_FINGERTIP;
} else if (progressSteps < getStageThresholdSteps(3)) {
return SFPS_STAGE_LEFT_EDGE;
} else {
return SFPS_STAGE_RIGHT_EDGE;
}
}
private void onEnrollmentHelp(@NonNull EnrollmentStatusMessage helpMessage) {
final CharSequence helpStr = helpMessage.getStr();
if (!TextUtils.isEmpty(helpStr)) {
showError(helpStr);
}
}
private void onEnrollmentError(@NonNull EnrollmentStatusMessage errorMessage) {
removeEnrollmentObservers();
if (mEnrollingViewModel.getOnBackPressed()
&& errorMessage.getMsgId() == FingerprintManager.FINGERPRINT_ERROR_CANCELED) {
mEnrollingViewModel.onCancelledDueToOnBackPressed();
} else if (mEnrollingViewModel.getOnSkipPressed()
&& errorMessage.getMsgId() == FingerprintManager.FINGERPRINT_ERROR_CANCELED) {
mEnrollingViewModel.onCancelledDueToOnSkipPressed();
} else {
final int errMsgId = errorMessage.getMsgId();
mEnrollingViewModel.showErrorDialog(
new FingerprintEnrollEnrollingViewModel.ErrorDialogData(
getString(FingerprintErrorDialog.getErrorMessage(errMsgId)),
getString(FingerprintErrorDialog.getErrorTitle(errMsgId)),
errMsgId
));
mProgressViewModel.cancelEnrollment();
}
}
private void announceEnrollmentProgress(CharSequence announcement) {
mEnrollingViewModel.sendAccessibilityEvent(announcement);
}
private void onEnrollmentProgressChange(@NonNull EnrollmentProgress progress) {
updateProgress(true /* animate */, progress);
if (mEnrollingViewModel.isAccessibilityEnabled()) {
final int percent = (int) (((float) (progress.getSteps() - progress.getRemaining())
/ (float) progress.getSteps()) * 100);
CharSequence announcement = getString(
R.string.security_settings_sfps_enroll_progress_a11y_message, percent);
announceEnrollmentProgress(announcement);
mIllustrationLottie.setContentDescription(
getString(R.string.security_settings_sfps_animation_a11y_label, percent)
);
}
updateTitleAndDescription();
}
private void updateProgress(boolean animate, @NonNull EnrollmentProgress enrollmentProgress) {
if (!mProgressViewModel.isEnrolling()) {
Log.d(TAG, "Enrollment not started yet");
return;
}
final int progress = getProgress(enrollmentProgress);
// Only clear the error when progress has been made.
// TODO (b/234772728) Add tests.
if (mProgressBar != null && mProgressBar.getProgress() < progress) {
clearError();
}
if (animate) {
animateProgress(progress);
} else {
if (mProgressBar != null) {
mProgressBar.setProgress(progress);
}
if (progress >= PROGRESS_BAR_MAX) {
mDelayedFinishRunnable.run();
}
}
}
private int getProgress(@NonNull EnrollmentProgress progress) {
if (progress.getSteps() == -1) {
return 0;
}
int displayProgress = Math.max(0, progress.getSteps() + 1 - progress.getRemaining());
return PROGRESS_BAR_MAX * displayProgress / (progress.getSteps() + 1);
}
private void showError(CharSequence error) {
mView.setHeaderText(error);
mView.getHeaderTextView().setContentDescription(error);
new GlifLayoutHelper(getActivity(), mView).setDescriptionText("");
if (isResumed() && !mHelpAnimation.isRunning()) {
mHelpAnimation.start();
}
applySfpsErrorDynamicColors(true);
if (isResumed() && mEnrollingViewModel.isAccessibilityEnabled()) {
mEnrollingViewModel.vibrateError(getClass().getSimpleName() + "::showError");
}
}
private void clearError() {
applySfpsErrorDynamicColors(false);
}
private void animateProgress(int progress) {
if (mProgressAnim != null) {
mProgressAnim.cancel();
}
ObjectAnimator anim = ObjectAnimator.ofInt(mProgressBar, "progress",
mProgressBar.getProgress(), progress);
anim.addListener(mProgressAnimationListener);
anim.setInterpolator(mFastOutSlowInInterpolator);
anim.setDuration(ANIMATION_DURATION);
anim.start();
mProgressAnim = anim;
}
/**
* Applies dynamic colors corresponding to showing or clearing errors on the progress bar
* and finger lottie for SFPS
*/
private void applySfpsErrorDynamicColors(boolean isError) {
applyProgressBarDynamicColor(isError);
applyLottieDynamicColor(isError);
}
private void applyProgressBarDynamicColor(boolean isError) {
final Context context = getActivity().getApplicationContext();
int error_color = context.getColor(R.color.sfps_enrollment_progress_bar_error_color);
int progress_bar_fill_color = context.getColor(
R.color.sfps_enrollment_progress_bar_fill_color);
ColorStateList fillColor = ColorStateList.valueOf(
isError ? error_color : progress_bar_fill_color);
mProgressBar.setProgressTintList(fillColor);
mProgressBar.setProgressTintMode(PorterDuff.Mode.SRC);
mProgressBar.invalidate();
}
private void applyLottieDynamicColor(boolean isError) {
final Context context = getActivity().getApplicationContext();
int error_color = context.getColor(R.color.sfps_enrollment_fp_error_color);
int fp_captured_color = context.getColor(R.color.sfps_enrollment_fp_captured_color);
int color = isError ? error_color : fp_captured_color;
mIllustrationLottie.addValueCallback(
new KeyPath(".blue100", "**"),
LottieProperty.COLOR_FILTER,
frameInfo -> new PorterDuffColorFilter(color, PorterDuff.Mode.SRC_ATOP)
);
mIllustrationLottie.invalidate();
}
private int getStageThresholdSteps(int index) {
final EnrollmentProgress progressLiveData =
mProgressViewModel.getProgressLiveData().getValue();
if (progressLiveData == null || progressLiveData.getSteps() == -1) {
Log.w(TAG, "getStageThresholdSteps: Enrollment not started yet");
return 1;
}
return Math.round(progressLiveData.getSteps()
* mEnrollingViewModel.getEnrollStageThreshold(index));
}
private void updateTitleAndDescription() {
final GlifLayoutHelper glifLayoutHelper = new GlifLayoutHelper(getActivity(), mView);
if (mEnrollingViewModel.isAccessibilityEnabled()) {
mEnrollingViewModel.clearTalkback();
glifLayoutHelper.getGlifLayout().getDescriptionTextView().setAccessibilityLiveRegion(
View.ACCESSIBILITY_LIVE_REGION_POLITE);
}
final int stage = getCurrentSfpsStage();
if (DEBUG) {
Log.d(TAG, "updateTitleAndDescription, stage:" + stage
+ ", noAnimation:" + mHaveShownSfpsNoAnimationLottie
+ ", center:" + mHaveShownSfpsCenterLottie
+ ", tip:" + mHaveShownSfpsTipLottie
+ ", leftEdge:" + mHaveShownSfpsLeftEdgeLottie
+ ", rightEdge:" + mHaveShownSfpsRightEdgeLottie);
}
switch (stage) {
case SFPS_STAGE_NO_ANIMATION:
glifLayoutHelper.setHeaderText(
R.string.security_settings_fingerprint_enroll_repeat_title);
if (!mHaveShownSfpsNoAnimationLottie) {
mHaveShownSfpsNoAnimationLottie = true;
mIllustrationLottie.setContentDescription(
getString(
R.string.security_settings_sfps_animation_a11y_label,
0
)
);
configureEnrollmentStage(
getString(R.string.security_settings_sfps_enroll_start_message),
R.raw.sfps_lottie_no_animation
);
}
break;
case SFPS_STAGE_CENTER:
glifLayoutHelper.setHeaderText(
R.string.security_settings_sfps_enroll_finger_center_title);
if (!mHaveShownSfpsCenterLottie) {
mHaveShownSfpsCenterLottie = true;
configureEnrollmentStage(
getString(R.string.security_settings_sfps_enroll_start_message),
R.raw.sfps_lottie_pad_center
);
}
break;
case SFPS_STAGE_FINGERTIP:
glifLayoutHelper.setHeaderText(
R.string.security_settings_sfps_enroll_fingertip_title);
if (!mHaveShownSfpsTipLottie) {
mHaveShownSfpsTipLottie = true;
configureEnrollmentStage("", R.raw.sfps_lottie_tip);
}
break;
case SFPS_STAGE_LEFT_EDGE:
glifLayoutHelper.setHeaderText(
R.string.security_settings_sfps_enroll_left_edge_title);
if (!mHaveShownSfpsLeftEdgeLottie) {
mHaveShownSfpsLeftEdgeLottie = true;
configureEnrollmentStage("", R.raw.sfps_lottie_left_edge);
}
break;
case SFPS_STAGE_RIGHT_EDGE:
glifLayoutHelper.setHeaderText(
R.string.security_settings_sfps_enroll_right_edge_title);
if (!mHaveShownSfpsRightEdgeLottie) {
mHaveShownSfpsRightEdgeLottie = true;
configureEnrollmentStage("", R.raw.sfps_lottie_right_edge);
}
break;
case STAGE_UNKNOWN:
default:
// Don't use BiometricEnrollBase#setHeaderText, since that invokes setTitle,
// which gets announced for a11y upon entering the page. For SFPS, we want to
// announce a different string for a11y upon entering the page.
glifLayoutHelper.setHeaderText(
R.string.security_settings_sfps_enroll_find_sensor_title);
glifLayoutHelper.setDescriptionText(getString(
R.string.security_settings_sfps_enroll_start_message));
final CharSequence description = getString(
R.string.security_settings_sfps_enroll_find_sensor_message);
glifLayoutHelper.getGlifLayout().getHeaderTextView().setContentDescription(
description);
glifLayoutHelper.getActivity().setTitle(description);
break;
}
}
private void showIconTouchDialog() {
mIconTouchCount = 0;
mEnrollingViewModel.showIconTouchDialog();
}
private final Runnable mShowDialogRunnable = () -> showIconTouchDialog();
private final Animator.AnimatorListener mProgressAnimationListener =
new Animator.AnimatorListener() {
@Override
public void onAnimationStart(Animator animation) { }
@Override
public void onAnimationRepeat(Animator animation) { }
@Override
public void onAnimationEnd(Animator animation) {
if (mProgressBar.getProgress() >= PROGRESS_BAR_MAX) {
mProgressBar.postDelayed(mDelayedFinishRunnable, ANIMATION_DURATION);
}
}
@Override
public void onAnimationCancel(Animator animation) { }
};
// Give the user a chance to see progress completed before jumping to the next stage.
private final Runnable mDelayedFinishRunnable = () -> mEnrollingViewModel.onEnrollingDone();
private void maybeHideSfpsText(@NonNull Configuration newConfig) {
final HeaderMixin headerMixin = ((GlifLayout) mView).getMixin(HeaderMixin.class);
final DescriptionMixin descriptionMixin = ((GlifLayout) mView).getMixin(
DescriptionMixin.class);
final boolean isLandscape = newConfig.orientation == Configuration.ORIENTATION_LANDSCAPE;
if (isLandscape) {
headerMixin.setAutoTextSizeEnabled(true);
headerMixin.getTextView().setMinLines(0);
headerMixin.getTextView().setMaxLines(10);
descriptionMixin.getTextView().setMinLines(0);
descriptionMixin.getTextView().setMaxLines(10);
} else {
headerMixin.setAutoTextSizeEnabled(false);
headerMixin.getTextView().setLines(4);
// hide the description
descriptionMixin.getTextView().setLines(0);
}
}
}

View File

@@ -0,0 +1,620 @@
/*
* Copyright (C) 2023 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.biometrics2.ui.view
import android.animation.Animator
import android.animation.ObjectAnimator
import android.annotation.RawRes
import android.content.Context
import android.content.res.ColorStateList
import android.content.res.Configuration
import android.graphics.ColorFilter
import android.graphics.PorterDuff
import android.graphics.PorterDuffColorFilter
import android.hardware.fingerprint.FingerprintManager
import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.view.animation.AccelerateDecelerateInterpolator
import android.view.animation.AnimationUtils
import android.view.animation.Interpolator
import android.widget.ProgressBar
import android.widget.RelativeLayout
import androidx.activity.OnBackPressedCallback
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProvider
import com.airbnb.lottie.LottieAnimationView
import com.airbnb.lottie.LottieComposition
import com.airbnb.lottie.LottieCompositionFactory
import com.airbnb.lottie.LottieProperty
import com.airbnb.lottie.model.KeyPath
import com.android.settings.R
import com.android.settings.biometrics.fingerprint.FingerprintErrorDialog
import com.android.settings.biometrics2.ui.model.EnrollmentProgress
import com.android.settings.biometrics2.ui.model.EnrollmentStatusMessage
import com.android.settings.biometrics2.ui.viewmodel.FingerprintEnrollEnrollingViewModel
import com.android.settings.biometrics2.ui.viewmodel.FingerprintEnrollProgressViewModel
import com.google.android.setupcompat.template.FooterBarMixin
import com.google.android.setupcompat.template.FooterButton
import com.google.android.setupdesign.GlifLayout
import com.google.android.setupdesign.template.DescriptionMixin
import com.google.android.setupdesign.template.HeaderMixin
import kotlin.math.roundToInt
/**
* Fragment is used to handle enrolling process for sfps
*/
class FingerprintEnrollEnrollingSfpsFragment : Fragment() {
private var _enrollingViewModel: FingerprintEnrollEnrollingViewModel? = null
private val enrollingViewModel: FingerprintEnrollEnrollingViewModel
get() = _enrollingViewModel!!
private var _progressViewModel: FingerprintEnrollProgressViewModel? = null
private val progressViewModel: FingerprintEnrollProgressViewModel
get() = _progressViewModel!!
private val fastOutSlowInInterpolator: Interpolator
get() = AnimationUtils.loadInterpolator(activity, R.interpolator.fast_out_slow_in)
private var enrollingSfpsView: GlifLayout? = null
private val progressBar: ProgressBar
get() = enrollingSfpsView!!.findViewById<ProgressBar>(R.id.fingerprint_progress_bar)!!
private var progressAnim: ObjectAnimator? = null
private val progressAnimationListener: Animator.AnimatorListener =
object : Animator.AnimatorListener {
override fun onAnimationStart(animation: Animator) {}
override fun onAnimationRepeat(animation: Animator) {}
override fun onAnimationEnd(animation: Animator) {
if (progressBar.progress >= PROGRESS_BAR_MAX) {
progressBar.postDelayed(delayedFinishRunnable, PROGRESS_ANIMATION_DURATION)
}
}
override fun onAnimationCancel(animation: Animator) {}
}
private val illustrationLottie: LottieAnimationView
get() = enrollingSfpsView!!.findViewById<LottieAnimationView>(R.id.illustration_lottie)!!
private var haveShownSfpsNoAnimationLottie = false
private var haveShownSfpsCenterLottie = false
private var haveShownSfpsTipLottie = false
private var haveShownSfpsLeftEdgeLottie = false
private var haveShownSfpsRightEdgeLottie = false
private var helpAnimation: ObjectAnimator? = null
private var iconTouchCount = 0
private val showIconTouchDialogRunnable = Runnable { showIconTouchDialog() }
// Give the user a chance to see progress completed before jumping to the next stage.
private val delayedFinishRunnable = Runnable { enrollingViewModel.onEnrollingDone() }
private val onSkipClickListener = View.OnClickListener { _: View? ->
enrollingViewModel.setOnSkipPressed()
cancelEnrollment()
}
private val progressObserver: Observer<EnrollmentProgress> =
Observer<EnrollmentProgress> { progress: EnrollmentProgress? ->
if (DEBUG) {
Log.d(TAG, "progressObserver($progress)")
}
if (progress != null && progress.steps >= 0) {
onEnrollmentProgressChange(progress)
}
}
private val helpMessageObserver: Observer<EnrollmentStatusMessage> =
Observer<EnrollmentStatusMessage> { helpMessage: EnrollmentStatusMessage? ->
if (DEBUG) {
Log.d(TAG, "helpMessageObserver($helpMessage)")
}
helpMessage?.let { onEnrollmentHelp(it) }
}
private val errorMessageObserver: Observer<EnrollmentStatusMessage> =
Observer<EnrollmentStatusMessage> { errorMessage: EnrollmentStatusMessage? ->
if (DEBUG) {
Log.d(TAG, "errorMessageObserver($errorMessage)")
}
errorMessage?.let { onEnrollmentError(it) }
}
override fun onAttach(context: Context) {
ViewModelProvider(requireActivity()).let { provider ->
_enrollingViewModel = provider[FingerprintEnrollEnrollingViewModel::class.java]
_progressViewModel = provider[FingerprintEnrollProgressViewModel::class.java]
}
super.onAttach(context)
requireActivity().onBackPressedDispatcher.addCallback(
object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
isEnabled = false
enrollingViewModel.setOnBackPressed()
cancelEnrollment()
}
})
}
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
enrollingSfpsView = inflater.inflate(
R.layout.sfps_enroll_enrolling,
container, false
) as GlifLayout
return enrollingSfpsView
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
requireActivity().bindFingerprintEnrollEnrollingSfpsView(
view = enrollingSfpsView!!,
onSkipClickListener = onSkipClickListener
)
// setHelpAnimation()
helpAnimation = ObjectAnimator.ofFloat(
enrollingSfpsView!!.findViewById<RelativeLayout>(R.id.progress_lottie)!!,
"translationX" /* propertyName */,
0f,
HELP_ANIMATION_TRANSLATION_X,
-1 * HELP_ANIMATION_TRANSLATION_X,
HELP_ANIMATION_TRANSLATION_X,
0f
).also {
it.interpolator = AccelerateDecelerateInterpolator()
it.setDuration(HELP_ANIMATION_DURATION)
it.setAutoCancel(false)
}
progressBar.setOnTouchListener { _: View?, event: MotionEvent ->
if (event.actionMasked == MotionEvent.ACTION_DOWN) {
iconTouchCount++
if (iconTouchCount == ICON_TOUCH_COUNT_SHOW_UNTIL_DIALOG_SHOWN) {
showIconTouchDialog()
} else {
progressBar.postDelayed(
showIconTouchDialogRunnable,
ICON_TOUCH_DURATION_UNTIL_DIALOG_SHOWN
)
}
} else if (event.actionMasked == MotionEvent.ACTION_CANCEL
|| event.actionMasked == MotionEvent.ACTION_UP
) {
progressBar.removeCallbacks(showIconTouchDialogRunnable)
}
true
}
}
override fun onStart() {
super.onStart()
startEnrollment()
updateProgress(false /* animate */, progressViewModel.progressLiveData.value!!)
progressViewModel.helpMessageLiveData.value?.let {
onEnrollmentHelp(it)
} ?: run {
clearError()
updateTitleAndDescription()
}
}
override fun onStop() {
removeEnrollmentObservers()
if (!activity!!.isChangingConfigurations && progressViewModel.isEnrolling) {
progressViewModel.cancelEnrollment()
}
super.onStop()
}
private fun removeEnrollmentObservers() {
preRemoveEnrollmentObservers()
progressViewModel.errorMessageLiveData.removeObserver(errorMessageObserver)
}
private fun preRemoveEnrollmentObservers() {
progressViewModel.progressLiveData.removeObserver(progressObserver)
progressViewModel.helpMessageLiveData.removeObserver(helpMessageObserver)
}
private fun cancelEnrollment() {
preRemoveEnrollmentObservers()
progressViewModel.cancelEnrollment()
}
private fun startEnrollment() {
val startResult: Boolean =
progressViewModel.startEnrollment(FingerprintManager.ENROLL_ENROLL)
if (!startResult) {
Log.e(TAG, "startEnrollment(), failed")
}
progressViewModel.progressLiveData.observe(this, progressObserver)
progressViewModel.helpMessageLiveData.observe(this, helpMessageObserver)
progressViewModel.errorMessageLiveData.observe(this, errorMessageObserver)
}
private fun configureEnrollmentStage(description: CharSequence, @RawRes lottie: Int) {
GlifLayoutHelper(requireActivity(), enrollingSfpsView!!).setDescriptionText(description)
LottieCompositionFactory.fromRawRes(activity, lottie)
.addListener { c: LottieComposition ->
illustrationLottie.setComposition(c)
illustrationLottie.visibility = View.VISIBLE
illustrationLottie.playAnimation()
}
}
private val currentSfpsStage: Int
get() {
val progressLiveData: EnrollmentProgress =
progressViewModel.progressLiveData.value
?: return STAGE_UNKNOWN
val progressSteps: Int = progressLiveData.steps - progressLiveData.remaining
return if (progressSteps < getStageThresholdSteps(0)) {
SFPS_STAGE_NO_ANIMATION
} else if (progressSteps < getStageThresholdSteps(1)) {
SFPS_STAGE_CENTER
} else if (progressSteps < getStageThresholdSteps(2)) {
SFPS_STAGE_FINGERTIP
} else if (progressSteps < getStageThresholdSteps(3)) {
SFPS_STAGE_LEFT_EDGE
} else {
SFPS_STAGE_RIGHT_EDGE
}
}
private fun onEnrollmentHelp(helpMessage: EnrollmentStatusMessage) {
val helpStr: CharSequence = helpMessage.str
if (helpStr.isNotEmpty()) {
showError(helpStr)
}
}
private fun onEnrollmentError(errorMessage: EnrollmentStatusMessage) {
removeEnrollmentObservers()
if (enrollingViewModel.onBackPressed
&& errorMessage.msgId == FingerprintManager.FINGERPRINT_ERROR_CANCELED
) {
enrollingViewModel.onCancelledDueToOnBackPressed()
} else if (enrollingViewModel.onSkipPressed
&& errorMessage.msgId == FingerprintManager.FINGERPRINT_ERROR_CANCELED
) {
enrollingViewModel.onCancelledDueToOnSkipPressed()
} else {
val errMsgId: Int = errorMessage.msgId
enrollingViewModel.showErrorDialog(
FingerprintEnrollEnrollingViewModel.ErrorDialogData(
getString(FingerprintErrorDialog.getErrorMessage(errMsgId)),
getString(FingerprintErrorDialog.getErrorTitle(errMsgId)),
errMsgId
)
)
progressViewModel.cancelEnrollment()
}
}
private fun announceEnrollmentProgress(announcement: CharSequence) {
enrollingViewModel.sendAccessibilityEvent(announcement)
}
private fun onEnrollmentProgressChange(progress: EnrollmentProgress) {
updateProgress(true /* animate */, progress)
if (enrollingViewModel.isAccessibilityEnabled) {
val percent: Int =
((progress.steps - progress.remaining).toFloat() / progress.steps.toFloat() * 100).toInt()
val announcement: CharSequence = getString(
R.string.security_settings_sfps_enroll_progress_a11y_message, percent
)
announceEnrollmentProgress(announcement)
illustrationLottie.contentDescription =
getString(R.string.security_settings_sfps_animation_a11y_label, percent)
}
updateTitleAndDescription()
}
private fun updateProgress(animate: Boolean, enrollmentProgress: EnrollmentProgress) {
if (!progressViewModel.isEnrolling) {
Log.d(TAG, "Enrollment not started yet")
return
}
val progress = getProgress(enrollmentProgress)
// Only clear the error when progress has been made.
// TODO (b/234772728) Add tests.
if (progressBar.progress < progress) {
clearError()
}
if (animate) {
animateProgress(progress)
} else {
progressBar.progress = progress
if (progress >= PROGRESS_BAR_MAX) {
delayedFinishRunnable.run()
}
}
}
private fun getProgress(progress: EnrollmentProgress): Int {
if (progress.steps == -1) {
return 0
}
val displayProgress = Math.max(0, progress.steps + 1 - progress.remaining)
return PROGRESS_BAR_MAX * displayProgress / (progress.steps + 1)
}
private fun showError(error: CharSequence) {
enrollingSfpsView!!.let {
it.headerText = error
it.headerTextView.contentDescription = error
GlifLayoutHelper(requireActivity(), it).setDescriptionText("")
}
if (isResumed && !helpAnimation!!.isRunning) {
helpAnimation!!.start()
}
applySfpsErrorDynamicColors(true)
if (isResumed && enrollingViewModel.isAccessibilityEnabled) {
enrollingViewModel.vibrateError(javaClass.simpleName + "::showError")
}
}
private fun clearError() {
applySfpsErrorDynamicColors(false)
}
private fun animateProgress(progress: Int) {
progressAnim?.cancel()
progressAnim = ObjectAnimator.ofInt(
progressBar,
"progress",
progressBar.progress,
progress
).also {
it.addListener(progressAnimationListener)
it.interpolator = fastOutSlowInInterpolator
it.setDuration(PROGRESS_ANIMATION_DURATION)
it.start()
}
}
/**
* Applies dynamic colors corresponding to showing or clearing errors on the progress bar
* and finger lottie for SFPS
*/
private fun applySfpsErrorDynamicColors(isError: Boolean) {
progressBar.applyProgressBarDynamicColor(requireContext(), isError)
illustrationLottie.applyLottieDynamicColor(requireContext(), isError)
}
private fun getStageThresholdSteps(index: Int): Int {
val progressLiveData: EnrollmentProgress? =
progressViewModel.progressLiveData.value
if (progressLiveData == null || progressLiveData.steps == -1) {
Log.w(TAG, "getStageThresholdSteps: Enrollment not started yet")
return 1
}
return (progressLiveData.steps
* enrollingViewModel.getEnrollStageThreshold(index)).roundToInt()
}
private fun updateTitleAndDescription() {
val helper = GlifLayoutHelper(requireActivity(), enrollingSfpsView!!)
if (enrollingViewModel.isAccessibilityEnabled) {
enrollingViewModel.clearTalkback()
helper.glifLayout.descriptionTextView.accessibilityLiveRegion =
View.ACCESSIBILITY_LIVE_REGION_POLITE
}
val stage = currentSfpsStage
if (DEBUG) {
Log.d(
TAG, "updateTitleAndDescription, stage:" + stage
+ ", noAnimation:" + haveShownSfpsNoAnimationLottie
+ ", center:" + haveShownSfpsCenterLottie
+ ", tip:" + haveShownSfpsTipLottie
+ ", leftEdge:" + haveShownSfpsLeftEdgeLottie
+ ", rightEdge:" + haveShownSfpsRightEdgeLottie
)
}
when (stage) {
SFPS_STAGE_NO_ANIMATION -> {
helper.setHeaderText(R.string.security_settings_fingerprint_enroll_repeat_title)
if (!haveShownSfpsNoAnimationLottie) {
haveShownSfpsNoAnimationLottie = true
illustrationLottie.contentDescription =
getString(R.string.security_settings_sfps_animation_a11y_label, 0)
configureEnrollmentStage(
getString(R.string.security_settings_sfps_enroll_start_message),
R.raw.sfps_lottie_no_animation
)
}
}
SFPS_STAGE_CENTER -> {
helper.setHeaderText(R.string.security_settings_sfps_enroll_finger_center_title)
if (!haveShownSfpsCenterLottie) {
haveShownSfpsCenterLottie = true
configureEnrollmentStage(
getString(R.string.security_settings_sfps_enroll_start_message),
R.raw.sfps_lottie_pad_center
)
}
}
SFPS_STAGE_FINGERTIP -> {
helper.setHeaderText(R.string.security_settings_sfps_enroll_fingertip_title)
if (!haveShownSfpsTipLottie) {
haveShownSfpsTipLottie = true
configureEnrollmentStage("", R.raw.sfps_lottie_tip)
}
}
SFPS_STAGE_LEFT_EDGE -> {
helper.setHeaderText(R.string.security_settings_sfps_enroll_left_edge_title)
if (!haveShownSfpsLeftEdgeLottie) {
haveShownSfpsLeftEdgeLottie = true
configureEnrollmentStage("", R.raw.sfps_lottie_left_edge)
}
}
SFPS_STAGE_RIGHT_EDGE -> {
helper.setHeaderText(R.string.security_settings_sfps_enroll_right_edge_title)
if (!haveShownSfpsRightEdgeLottie) {
haveShownSfpsRightEdgeLottie = true
configureEnrollmentStage("", R.raw.sfps_lottie_right_edge)
}
}
STAGE_UNKNOWN -> {
// Don't use BiometricEnrollBase#setHeaderText, since that invokes setTitle,
// which gets announced for a11y upon entering the page. For SFPS, we want to
// announce a different string for a11y upon entering the page.
helper.setHeaderText(R.string.security_settings_sfps_enroll_find_sensor_title)
helper.setDescriptionText(
getString(R.string.security_settings_sfps_enroll_start_message)
)
val description: CharSequence = getString(
R.string.security_settings_sfps_enroll_find_sensor_message
)
helper.glifLayout.headerTextView.contentDescription = description
helper.activity.title = description
}
else -> {
helper.setHeaderText(R.string.security_settings_sfps_enroll_find_sensor_title)
helper.setDescriptionText(
getString(R.string.security_settings_sfps_enroll_start_message)
)
val description: CharSequence = getString(
R.string.security_settings_sfps_enroll_find_sensor_message
)
helper.glifLayout.headerTextView.contentDescription = description
helper.activity.title = description
}
}
}
private fun showIconTouchDialog() {
iconTouchCount = 0
enrollingViewModel.showIconTouchDialog()
}
companion object {
private val TAG = FingerprintEnrollEnrollingSfpsFragment::class.java.simpleName
private const val DEBUG = false
private const val PROGRESS_BAR_MAX = 10000
private const val HELP_ANIMATION_DURATION = 550L
private const val HELP_ANIMATION_TRANSLATION_X = 40f
private const val PROGRESS_ANIMATION_DURATION = 250L
private const val ICON_TOUCH_DURATION_UNTIL_DIALOG_SHOWN: Long = 500
private const val ICON_TOUCH_COUNT_SHOW_UNTIL_DIALOG_SHOWN = 3
private const val STAGE_UNKNOWN = -1
private const val SFPS_STAGE_NO_ANIMATION = 0
private const val SFPS_STAGE_CENTER = 1
private const val SFPS_STAGE_FINGERTIP = 2
private const val SFPS_STAGE_LEFT_EDGE = 3
private const val SFPS_STAGE_RIGHT_EDGE = 4
}
}
fun FragmentActivity.bindFingerprintEnrollEnrollingSfpsView(
view: GlifLayout,
onSkipClickListener: View.OnClickListener
) {
GlifLayoutHelper(this, view).setDescriptionText(
getString(R.string.security_settings_fingerprint_enroll_start_message)
)
view.getMixin(FooterBarMixin::class.java).secondaryButton = FooterButton.Builder(this)
.setText(R.string.security_settings_fingerprint_enroll_enrolling_skip)
.setListener(onSkipClickListener)
.setButtonType(FooterButton.ButtonType.SKIP)
.setTheme(R.style.SudGlifButton_Secondary)
.build()
view.findViewById<ProgressBar>(R.id.fingerprint_progress_bar)!!.progressBackgroundTintMode =
PorterDuff.Mode.SRC
view.findViewById<ProgressBar>(R.id.fingerprint_progress_bar)!!
.applyProgressBarDynamicColor(this, false)
view.findViewById<LottieAnimationView>(R.id.illustration_lottie)!!
.applyLottieDynamicColor(this, false)
view.maybeHideSfpsText(resources.configuration.orientation)
}
private fun ProgressBar.applyProgressBarDynamicColor(context: Context, isError: Boolean) {
progressTintList = ColorStateList.valueOf(
context.getColor(
if (isError)
R.color.sfps_enrollment_progress_bar_error_color
else
R.color.sfps_enrollment_progress_bar_fill_color
)
)
progressTintMode = PorterDuff.Mode.SRC
invalidate()
}
fun LottieAnimationView.applyLottieDynamicColor(context: Context, isError: Boolean) {
addValueCallback<ColorFilter>(
KeyPath(".blue100", "**"),
LottieProperty.COLOR_FILTER
) {
PorterDuffColorFilter(
context.getColor(
if (isError)
R.color.sfps_enrollment_fp_error_color
else
R.color.sfps_enrollment_fp_captured_color
),
PorterDuff.Mode.SRC_ATOP
)
}
invalidate()
}
fun GlifLayout.maybeHideSfpsText(@Configuration.Orientation orientation: Int) {
val headerMixin: HeaderMixin = getMixin(HeaderMixin::class.java)
val descriptionMixin: DescriptionMixin = getMixin(DescriptionMixin::class.java)
val isLandscape = (orientation == Configuration.ORIENTATION_LANDSCAPE)
headerMixin.setAutoTextSizeEnabled(isLandscape)
if (isLandscape) {
headerMixin.textView.minLines = 0
headerMixin.textView.maxLines = 10
descriptionMixin.textView.minLines = 0
descriptionMixin.textView.maxLines = 10
} else {
headerMixin.textView.setLines(4)
// hide the description
descriptionMixin.textView.setLines(0)
}
}