diff --git a/src/com/android/settings/biometrics2/ui/view/FingerprintEnrollEnrollingSfpsFragment.java b/src/com/android/settings/biometrics2/ui/view/FingerprintEnrollEnrollingSfpsFragment.java deleted file mode 100644 index 3551d555dfc..00000000000 --- a/src/com/android/settings/biometrics2/ui/view/FingerprintEnrollEnrollingSfpsFragment.java +++ /dev/null @@ -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 mProgressObserver = progress -> { - if (DEBUG) { - Log.d(TAG, "mProgressObserver(" + progress + ")"); - } - if (progress != null && progress.getSteps() >= 0) { - onEnrollmentProgressChange(progress); - } - }; - - private final Observer mHelpMessageObserver = helpMessage -> { - if (DEBUG) { - Log.d(TAG, "mHelpMessageObserver(" + helpMessage + ")"); - } - if (helpMessage != null) { - onEnrollmentHelp(helpMessage); - } - }; - - private final Observer 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); - } - - } -} diff --git a/src/com/android/settings/biometrics2/ui/view/FingerprintEnrollEnrollingSfpsFragment.kt b/src/com/android/settings/biometrics2/ui/view/FingerprintEnrollEnrollingSfpsFragment.kt new file mode 100644 index 00000000000..980f800f909 --- /dev/null +++ b/src/com/android/settings/biometrics2/ui/view/FingerprintEnrollEnrollingSfpsFragment.kt @@ -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(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(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 = + Observer { progress: EnrollmentProgress? -> + if (DEBUG) { + Log.d(TAG, "progressObserver($progress)") + } + if (progress != null && progress.steps >= 0) { + onEnrollmentProgressChange(progress) + } + } + + private val helpMessageObserver: Observer = + Observer { helpMessage: EnrollmentStatusMessage? -> + if (DEBUG) { + Log.d(TAG, "helpMessageObserver($helpMessage)") + } + helpMessage?.let { onEnrollmentHelp(it) } + } + + private val errorMessageObserver: Observer = + Observer { 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(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(R.id.fingerprint_progress_bar)!!.progressBackgroundTintMode = + PorterDuff.Mode.SRC + + view.findViewById(R.id.fingerprint_progress_bar)!! + .applyProgressBarDynamicColor(this, false) + + view.findViewById(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( + 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) + } +}