/* * Copyright (C) 2015 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.fingerprint; import static android.hardware.biometrics.BiometricFingerprintConstants.FINGERPRINT_ERROR_USER_CANCELED; import android.animation.Animator; import android.animation.ObjectAnimator; import android.annotation.IntDef; import android.annotation.NonNull; import android.annotation.Nullable; import android.annotation.RawRes; import android.app.Dialog; import android.app.settings.SettingsEnums; import android.content.Context; import android.content.DialogInterface; import android.content.Intent; import android.content.res.ColorStateList; import android.content.res.Configuration; import android.content.res.Resources; import android.graphics.Point; import android.graphics.PorterDuff; import android.graphics.PorterDuffColorFilter; import android.graphics.Rect; import android.graphics.drawable.Animatable2; import android.graphics.drawable.AnimatedVectorDrawable; import android.graphics.drawable.Drawable; import android.graphics.drawable.LayerDrawable; import android.hardware.fingerprint.FingerprintManager; import android.hardware.fingerprint.FingerprintSensorPropertiesInternal; import android.os.Bundle; import android.os.Process; import android.os.VibrationAttributes; import android.os.VibrationEffect; import android.os.Vibrator; import android.text.TextUtils; import android.util.FeatureFlagUtils; import android.util.Log; import android.view.DisplayInfo; import android.view.MotionEvent; import android.view.OrientationEventListener; import android.view.Surface; import android.view.View; import android.view.ViewGroup; import android.view.accessibility.AccessibilityEvent; import android.view.accessibility.AccessibilityManager; import android.view.animation.AccelerateDecelerateInterpolator; import android.view.animation.AnimationUtils; import android.view.animation.Interpolator; import android.widget.FrameLayout; import android.widget.LinearLayout; import android.widget.ProgressBar; import android.widget.RelativeLayout; import android.widget.TextView; import android.widget.Toast; import androidx.annotation.IdRes; import androidx.appcompat.app.AlertDialog; import com.android.internal.annotations.VisibleForTesting; import com.android.settings.R; import com.android.settings.biometrics.BiometricEnrollSidecar; import com.android.settings.biometrics.BiometricUtils; import com.android.settings.biometrics.BiometricsEnrollEnrolling; import com.android.settings.core.instrumentation.InstrumentedDialogFragment; import com.android.settingslib.display.DisplayDensityUtils; import com.android.settingslib.udfps.UdfpsOverlayParams; import com.android.settingslib.udfps.UdfpsUtils; 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.FooterActionButton; import com.google.android.setupcompat.template.FooterBarMixin; import com.google.android.setupcompat.template.FooterButton; import com.google.android.setupcompat.util.WizardManagerHelper; import com.google.android.setupdesign.GlifLayout; import com.google.android.setupdesign.template.DescriptionMixin; import com.google.android.setupdesign.template.HeaderMixin; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.util.List; import java.util.Locale; /** * Activity which handles the actual enrolling for fingerprint. */ public class FingerprintEnrollEnrolling extends BiometricsEnrollEnrolling { private static final String TAG = "FingerprintEnrollEnrolling"; static final String TAG_SIDECAR = "sidecar"; static final String TAG_UDFPS_HELPER = "udfps_helper"; static final String ICON_TOUCH_DIALOG = "fps_icon_touch_dialog"; static final String KEY_STATE_CANCELED = "is_canceled"; static final String KEY_STATE_PREVIOUS_ROTATION = "previous_rotation"; private static final int PROGRESS_BAR_MAX = 10000; private static final int STAGE_UNKNOWN = -1; private static final int STAGE_CENTER = 0; private static final int STAGE_GUIDED = 1; private static final int STAGE_FINGERTIP = 2; private static final int STAGE_LEFT_EDGE = 3; private static final int STAGE_RIGHT_EDGE = 4; @VisibleForTesting protected static final int SFPS_STAGE_NO_ANIMATION = 0; @VisibleForTesting protected static final int SFPS_STAGE_CENTER = 1; @VisibleForTesting protected static final int SFPS_STAGE_FINGERTIP = 2; @VisibleForTesting protected static final int SFPS_STAGE_LEFT_EDGE = 3; @VisibleForTesting protected static final int SFPS_STAGE_RIGHT_EDGE = 4; @IntDef({STAGE_UNKNOWN, STAGE_CENTER, STAGE_GUIDED, STAGE_FINGERTIP, STAGE_LEFT_EDGE, STAGE_RIGHT_EDGE}) @Retention(RetentionPolicy.SOURCE) private @interface EnrollStage {} @VisibleForTesting @IntDef({STAGE_UNKNOWN, SFPS_STAGE_NO_ANIMATION, SFPS_STAGE_CENTER, SFPS_STAGE_FINGERTIP, SFPS_STAGE_LEFT_EDGE, SFPS_STAGE_RIGHT_EDGE}) @Retention(RetentionPolicy.SOURCE) protected @interface SfpsEnrollStage {} /** * If we don't see progress during this time, we show an error message to remind the users that * they need to lift the finger and touch again. */ private static final int HINT_TIMEOUT_DURATION = 2500; /** * How long the user needs to touch the icon until we show the dialog. */ private static final long ICON_TOUCH_DURATION_UNTIL_DIALOG_SHOWN = 500; /** * How many times the user needs to touch the icon until we show the dialog that this is not the * fingerprint sensor. */ private static final int ICON_TOUCH_COUNT_SHOW_UNTIL_DIALOG_SHOWN = 3; private static final VibrationEffect VIBRATE_EFFECT_ERROR = VibrationEffect.createWaveform(new long[] {0, 5, 55, 60}, -1); private static final VibrationAttributes FINGERPRINT_ENROLLING_SONFICATION_ATTRIBUTES = VibrationAttributes.createForUsage(VibrationAttributes.USAGE_ACCESSIBILITY); private FingerprintManager mFingerprintManager; private boolean mCanAssumeUdfps; private boolean mCanAssumeSfps; @Nullable private ProgressBar mProgressBar; @VisibleForTesting @Nullable UdfpsEnrollHelper mUdfpsEnrollHelper; // TODO(b/260617060): Do not hard-code mScaleFactor, referring to AuthController. private float mScaleFactor = 1.0f; private ObjectAnimator mProgressAnim; private TextView mErrorText; private Interpolator mFastOutSlowInInterpolator; private Interpolator mLinearOutSlowInInterpolator; private Interpolator mFastOutLinearInInterpolator; private int mIconTouchCount; private boolean mAnimationCancelled; @Nullable private AnimatedVectorDrawable mIconAnimationDrawable; @Nullable private AnimatedVectorDrawable mIconBackgroundBlinksDrawable; private boolean mRestoring; private Vibrator mVibrator; private boolean mIsSetupWizard; private boolean mIsOrientationChanged; @VisibleForTesting boolean mIsCanceled; private AccessibilityManager mAccessibilityManager; private boolean mIsAccessibilityEnabled; private LottieAnimationView mIllustrationLottie; private boolean mHaveShownUdfpsTipLottie; private boolean mHaveShownUdfpsLeftEdgeLottie; private boolean mHaveShownUdfpsRightEdgeLottie; private boolean mHaveShownUdfpsCenterLottie; private boolean mHaveShownUdfpsGuideLottie; private boolean mHaveShownSfpsNoAnimationLottie; private boolean mHaveShownSfpsCenterLottie; private boolean mHaveShownSfpsTipLottie; private boolean mHaveShownSfpsLeftEdgeLottie; private boolean mHaveShownSfpsRightEdgeLottie; private boolean mShouldShowLottie; private UdfpsUtils mUdfpsUtils; private ObjectAnimator mHelpAnimation; private OrientationEventListener mOrientationEventListener; private int mPreviousRotation = 0; @VisibleForTesting protected boolean shouldShowLottie() { DisplayDensityUtils displayDensity = new DisplayDensityUtils(getApplicationContext()); int currentDensityIndex = displayDensity.getCurrentIndexForDefaultDisplay(); final int currentDensity = displayDensity.getDefaultDisplayDensityValues() [currentDensityIndex]; final int defaultDensity = displayDensity.getDefaultDensityForDefaultDisplay(); return defaultDensity == currentDensity; } @Override public void onWindowFocusChanged(boolean hasFocus) { if (hasFocus || mIsCanceled) { return; } // By UX design, we should ensure seamless enrollment CUJ even though user rotate device. // Do NOT cancel enrollment progress after rotating, adding mIsOrientationChanged // to judge if the focus changed was triggered by rotation, current WMS has triple callbacks // (true > false > true), we need to reset mIsOrientationChanged when !hasFocus callback. // Side fps do not have to synchronize udfpsController overlay state, we should bypass sfps // from onWindowFocusChanged() as long press sfps power key will prompt dialog to users. if (!mIsOrientationChanged && !mCanAssumeSfps) { onCancelEnrollment(FINGERPRINT_ERROR_USER_CANCELED); } else { mIsOrientationChanged = false; } } @Override protected void onApplyThemeResource(Resources.Theme theme, int resid, boolean first) { theme.applyStyle(R.style.SetupWizardPartnerResource, true); super.onApplyThemeResource(theme, resid, first); } @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); if (isInMultiWindowMode()) { final Toast splitUnsupportedToast = Toast.makeText(this, R.string.dock_multi_instances_not_supported_text, Toast.LENGTH_SHORT); splitUnsupportedToast.show(); finish(); return; } if (savedInstanceState != null) { restoreSavedState(savedInstanceState); } mFingerprintManager = getSystemService(FingerprintManager.class); final List props = mFingerprintManager.getSensorPropertiesInternal(); mCanAssumeUdfps = props != null && props.size() == 1 && props.get(0).isAnyUdfpsType(); mCanAssumeSfps = props != null && props.size() == 1 && props.get(0).isAnySidefpsType(); mAccessibilityManager = getSystemService(AccessibilityManager.class); mIsAccessibilityEnabled = mAccessibilityManager.isEnabled(); mUdfpsUtils = new UdfpsUtils(); final boolean isLayoutRtl = (TextUtils.getLayoutDirectionFromLocale( Locale.getDefault()) == View.LAYOUT_DIRECTION_RTL); listenOrientationEvent(); if (mCanAssumeUdfps) { int rotation = getApplicationContext().getDisplay().getRotation(); switch (rotation) { case Surface.ROTATION_90: final GlifLayout layout = (GlifLayout) getLayoutInflater().inflate( R.layout.udfps_enroll_enrolling, null, false); final LinearLayout layoutContainer = layout.findViewById( R.id.layout_container); final LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams( LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.MATCH_PARENT); lp.setMarginEnd((int) getResources().getDimension( R.dimen.rotation_90_enroll_margin_end)); layoutContainer.setPaddingRelative((int) getResources().getDimension( R.dimen.rotation_90_enroll_padding_start), 0, isLayoutRtl ? 0 : (int) getResources().getDimension( R.dimen.rotation_90_enroll_padding_end), 0); layoutContainer.setLayoutParams(lp); if (FeatureFlagUtils.isEnabled(getApplicationContext(), FeatureFlagUtils.SETTINGS_SHOW_UDFPS_ENROLL_IN_SETTINGS)) { final UdfpsEnrollView udfpsEnrollView = addUdfpsEnrollView(props.get(0)); layout.addView(udfpsEnrollView); setOnHoverListener(true, layout, udfpsEnrollView); } setContentView(layout, lp); break; case Surface.ROTATION_0: case Surface.ROTATION_180: case Surface.ROTATION_270: default: final GlifLayout defaultLayout = (GlifLayout) getLayoutInflater().inflate( R.layout.udfps_enroll_enrolling, null, false); if (FeatureFlagUtils.isEnabled(getApplicationContext(), FeatureFlagUtils.SETTINGS_SHOW_UDFPS_ENROLL_IN_SETTINGS)) { final UdfpsEnrollView udfpsEnrollView = addUdfpsEnrollView(props.get(0)); if (rotation == Surface.ROTATION_0 || rotation == Surface.ROTATION_180) { // In the portrait mode, set layout_container's height 0, so it's // always shown at the bottom of the screen. // Add udfps enroll view into layout_container instead of // udfps_enroll_enrolling, so that when the content is too long to // make udfps_enroll_enrolling larger than the screen, udfps enroll // view could still be set to right position by setting bottom margin to // its parent view (layout_container) because it's always at the // bottom of the screen. final FrameLayout portraitLayoutContainer = defaultLayout.findViewById( R.id.layout_container); final ViewGroup.LayoutParams containerLp = portraitLayoutContainer.getLayoutParams(); containerLp.height = 0; portraitLayoutContainer.addView(udfpsEnrollView); setOnHoverListener(false, defaultLayout, udfpsEnrollView); } else if (rotation == Surface.ROTATION_270) { defaultLayout.addView(udfpsEnrollView); setOnHoverListener(true, defaultLayout, udfpsEnrollView); } } setContentView(defaultLayout); break; } setDescriptionText(R.string.security_settings_udfps_enroll_start_message); } else if (mCanAssumeSfps) { setContentView(R.layout.sfps_enroll_enrolling); setDescriptionText(R.string.security_settings_fingerprint_enroll_start_message); setHelpAnimation(); } else { setContentView(R.layout.fingerprint_enroll_enrolling); setDescriptionText(R.string.security_settings_fingerprint_enroll_start_message); } mIsSetupWizard = WizardManagerHelper.isAnySetupWizard(getIntent()); if (mCanAssumeUdfps || mCanAssumeSfps) { updateTitleAndDescription(); } else { setHeaderText(R.string.security_settings_fingerprint_enroll_repeat_title); } mShouldShowLottie = shouldShowLottie(); // On non-SFPS devices, only show the lottie if the current display density is the default // density. Otherwise, the lottie will overlap with the settings header text. boolean isLandscape = BiometricUtils.isReverseLandscape(getApplicationContext()) || BiometricUtils.isLandscape(getApplicationContext()); updateOrientation((isLandscape ? Configuration.ORIENTATION_LANDSCAPE : Configuration.ORIENTATION_PORTRAIT)); mErrorText = findViewById(R.id.error_text); mProgressBar = findViewById(R.id.fingerprint_progress_bar); mVibrator = getSystemService(Vibrator.class); mFooterBarMixin = getLayout().getMixin(FooterBarMixin.class); mFooterBarMixin.setSecondaryButton( new FooterButton.Builder(this) .setText(R.string.security_settings_fingerprint_enroll_enrolling_skip) .setListener(this::onSkipButtonClick) .setButtonType(FooterButton.ButtonType.SKIP) .setTheme(R.style.SudGlifButton_Secondary) .build() ); if (FeatureFlagUtils.isEnabled(getApplicationContext(), FeatureFlagUtils.SETTINGS_SHOW_UDFPS_ENROLL_IN_SETTINGS)) { // Remove the space view and make the width of footer button container WRAP_CONTENT // to avoid hiding the udfps view progress bar bottom. final LinearLayout buttonContainer = mFooterBarMixin.getButtonContainer(); View spaceView = null; for (int i = 0; i < buttonContainer.getChildCount(); i++) { if (!(buttonContainer.getChildAt(i) instanceof FooterActionButton)) { spaceView = buttonContainer.getChildAt(i); break; } } if (spaceView != null) { spaceView.setVisibility(View.GONE); buttonContainer.getLayoutParams().width = ViewGroup.LayoutParams.WRAP_CONTENT; } } final LayerDrawable fingerprintDrawable = mProgressBar != null ? (LayerDrawable) mProgressBar.getBackground() : null; if (fingerprintDrawable != null) { mIconAnimationDrawable = (AnimatedVectorDrawable) fingerprintDrawable.findDrawableByLayerId(R.id.fingerprint_animation); mIconBackgroundBlinksDrawable = (AnimatedVectorDrawable) fingerprintDrawable.findDrawableByLayerId(R.id.fingerprint_background); mIconAnimationDrawable.registerAnimationCallback(mIconAnimationCallback); } mFastOutSlowInInterpolator = AnimationUtils.loadInterpolator( this, android.R.interpolator.fast_out_slow_in); mLinearOutSlowInInterpolator = AnimationUtils.loadInterpolator( this, android.R.interpolator.linear_out_slow_in); mFastOutLinearInInterpolator = AnimationUtils.loadInterpolator( this, android.R.interpolator.fast_out_linear_in); if (mProgressBar != null) { 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; }); } final Configuration config = getApplicationContext().getResources().getConfiguration(); maybeHideSfpsText(config); } private void setHelpAnimation() { final float translationX = 40; final int duration = 550; final RelativeLayout progressLottieLayout = 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); } @Override protected BiometricEnrollSidecar getSidecar() { final FingerprintEnrollSidecar sidecar = new FingerprintEnrollSidecar(this, FingerprintManager.ENROLL_ENROLL); return sidecar; } @Override protected boolean shouldStartAutomatically() { if (mCanAssumeUdfps) { // Continue enrollment if restoring (e.g. configuration changed). Otherwise, wait // for the entry animation to complete before starting. return mRestoring && !mIsCanceled; } return true; } @Override protected void onSaveInstanceState(Bundle outState) { super.onSaveInstanceState(outState); outState.putBoolean(KEY_STATE_CANCELED, mIsCanceled); outState.putInt(KEY_STATE_PREVIOUS_ROTATION, mPreviousRotation); } private void restoreSavedState(Bundle savedInstanceState) { mRestoring = true; mIsCanceled = savedInstanceState.getBoolean(KEY_STATE_CANCELED, false); mPreviousRotation = savedInstanceState.getInt(KEY_STATE_PREVIOUS_ROTATION, getDisplay().getRotation()); mIsOrientationChanged = mPreviousRotation != getDisplay().getRotation(); } @Override protected void onStart() { super.onStart(); updateProgress(false /* animate */); updateTitleAndDescription(); if (mRestoring) { startIconAnimation(); } } @Override public void onEnterAnimationComplete() { super.onEnterAnimationComplete(); if (mCanAssumeUdfps) { startEnrollment(); } mAnimationCancelled = false; startIconAnimation(); } private void startIconAnimation() { if (mIconAnimationDrawable != null) { mIconAnimationDrawable.start(); } } private void stopIconAnimation() { mAnimationCancelled = true; if (mIconAnimationDrawable != null) { mIconAnimationDrawable.stop(); } } @VisibleForTesting void onCancelEnrollment(@IdRes int errorMsgId) { // showErrorDialog() will cause onWindowFocusChanged(false), set mIsCanceled to false // before showErrorDialog() to prevent that another error dialog is triggered again. mIsCanceled = true; FingerprintErrorDialog.showErrorDialog(this, errorMsgId, mCanAssumeUdfps); mIsOrientationChanged = false; cancelEnrollment(); stopIconAnimation(); stopListenOrientationEvent(); if (!mCanAssumeUdfps) { mErrorText.removeCallbacks(mTouchAgainRunnable); } } @Override protected void onStop() { if (!isChangingConfigurations()) { if (!WizardManagerHelper.isAnySetupWizard(getIntent()) && !BiometricUtils.isAnyMultiBiometricFlow(this) && !mFromSettingsSummary) { setResult(RESULT_TIMEOUT); } finish(); } stopIconAnimation(); super.onStop(); } @Override protected boolean shouldFinishWhenBackgrounded() { // Prevent super.onStop() from finishing, since we handle this in our onStop(). return false; } @Override protected void onDestroy() { stopListenOrientationEvent(); super.onDestroy(); } private void animateProgress(int progress) { if (mCanAssumeUdfps) { // UDFPS animations are owned by SystemUI if (progress >= PROGRESS_BAR_MAX) { // Wait for any animations in SysUI to finish, then proceed to next page getMainThreadHandler().postDelayed(mDelayedFinishRunnable, getFinishDelay()); } return; } if (mProgressAnim != null) { mProgressAnim.cancel(); } ObjectAnimator anim = ObjectAnimator.ofInt(mProgressBar, "progress", mProgressBar.getProgress(), progress); anim.addListener(mProgressAnimationListener); anim.setInterpolator(mFastOutSlowInInterpolator); anim.setDuration(250); anim.start(); mProgressAnim = anim; } private void animateFlash() { if (mIconBackgroundBlinksDrawable != null) { mIconBackgroundBlinksDrawable.start(); } } protected Intent getFinishIntent() { return new Intent(this, FingerprintEnrollFinish.class); } private void updateTitleAndDescription() { if (mCanAssumeUdfps) { updateTitleAndDescriptionForUdfps(); return; } else if (mCanAssumeSfps) { updateTitleAndDescriptionForSfps(); return; } if (mSidecar == null || mSidecar.getEnrollmentSteps() == -1) { setDescriptionText(R.string.security_settings_fingerprint_enroll_start_message); } else { setDescriptionText(R.string.security_settings_fingerprint_enroll_repeat_message); } } private void updateTitleAndDescriptionForUdfps() { switch (getCurrentStage()) { case STAGE_CENTER: setHeaderText(R.string.security_settings_fingerprint_enroll_repeat_title); if (mIsAccessibilityEnabled || mIllustrationLottie == null) { setDescriptionText(R.string.security_settings_udfps_enroll_start_message); } else if (!mHaveShownUdfpsCenterLottie && mIllustrationLottie != null) { mHaveShownUdfpsCenterLottie = true; // Note: Update string reference when differentiate in between udfps & sfps mIllustrationLottie.setContentDescription( getString(R.string.security_settings_sfps_enroll_finger_center_title) ); configureEnrollmentStage("", R.raw.udfps_center_hint_lottie); } break; case STAGE_GUIDED: setHeaderText(R.string.security_settings_fingerprint_enroll_repeat_title); if (mIsAccessibilityEnabled || mIllustrationLottie == null) { setDescriptionText(R.string.security_settings_udfps_enroll_repeat_a11y_message); } else if (!mHaveShownUdfpsGuideLottie && mIllustrationLottie != null) { mHaveShownUdfpsGuideLottie = true; mIllustrationLottie.setContentDescription( getString(R.string.security_settings_fingerprint_enroll_repeat_message) ); // TODO(b/228100413) Could customize guided lottie animation configureEnrollmentStage("", R.raw.udfps_center_hint_lottie); } break; case STAGE_FINGERTIP: setHeaderText(R.string.security_settings_udfps_enroll_fingertip_title); if (!mHaveShownUdfpsTipLottie && mIllustrationLottie != null) { mHaveShownUdfpsTipLottie = true; mIllustrationLottie.setContentDescription( getString(R.string.security_settings_udfps_tip_fingerprint_help) ); configureEnrollmentStage("", R.raw.udfps_tip_hint_lottie); } break; case STAGE_LEFT_EDGE: setHeaderText(R.string.security_settings_udfps_enroll_left_edge_title); if (!mHaveShownUdfpsLeftEdgeLottie && mIllustrationLottie != null) { mHaveShownUdfpsLeftEdgeLottie = true; mIllustrationLottie.setContentDescription( getString(R.string.security_settings_udfps_side_fingerprint_help) ); configureEnrollmentStage("", R.raw.udfps_left_edge_hint_lottie); } else if (mIllustrationLottie == null) { if (isStageHalfCompleted()) { setDescriptionText( R.string.security_settings_fingerprint_enroll_repeat_message); } else { setDescriptionText(R.string.security_settings_udfps_enroll_edge_message); } } break; case STAGE_RIGHT_EDGE: setHeaderText(R.string.security_settings_udfps_enroll_right_edge_title); if (!mHaveShownUdfpsRightEdgeLottie && mIllustrationLottie != null) { mHaveShownUdfpsRightEdgeLottie = true; mIllustrationLottie.setContentDescription( getString(R.string.security_settings_udfps_side_fingerprint_help) ); configureEnrollmentStage("", R.raw.udfps_right_edge_hint_lottie); } else if (mIllustrationLottie == null) { if (isStageHalfCompleted()) { setDescriptionText( R.string.security_settings_fingerprint_enroll_repeat_message); } else { setDescriptionText(R.string.security_settings_udfps_enroll_edge_message); } } break; case STAGE_UNKNOWN: default: // setHeaderText(R.string.security_settings_fingerprint_enroll_udfps_title); // Don't use BiometricEnrollBase#setHeaderText, since that invokes setTitle, // which gets announced for a11y upon entering the page. For UDFPS, we want to // announce a different string for a11y upon entering the page. getLayout().setHeaderText( R.string.security_settings_fingerprint_enroll_udfps_title); setDescriptionText(R.string.security_settings_udfps_enroll_start_message); final CharSequence description = getString( R.string.security_settings_udfps_enroll_a11y); getLayout().getHeaderTextView().setContentDescription(description); setTitle(description); break; } } // Interrupt any existing talkback speech to prevent stacking talkback messages private void clearTalkback() { AccessibilityManager.getInstance(getApplicationContext()).interrupt(); } private void updateTitleAndDescriptionForSfps() { if (mIsAccessibilityEnabled) { clearTalkback(); getLayout().getDescriptionTextView().setAccessibilityLiveRegion( View.ACCESSIBILITY_LIVE_REGION_POLITE); } switch (getCurrentSfpsStage()) { case SFPS_STAGE_NO_ANIMATION: setHeaderText(R.string.security_settings_fingerprint_enroll_repeat_title); if (!mHaveShownSfpsNoAnimationLottie && mIllustrationLottie != null) { 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: setHeaderText(R.string.security_settings_sfps_enroll_finger_center_title); if (!mHaveShownSfpsCenterLottie && mIllustrationLottie != null) { mHaveShownSfpsCenterLottie = true; configureEnrollmentStage( getString(R.string.security_settings_sfps_enroll_start_message), R.raw.sfps_lottie_pad_center ); } break; case SFPS_STAGE_FINGERTIP: setHeaderText(R.string.security_settings_sfps_enroll_fingertip_title); if (!mHaveShownSfpsTipLottie && mIllustrationLottie != null) { mHaveShownSfpsTipLottie = true; configureEnrollmentStage("", R.raw.sfps_lottie_tip); } break; case SFPS_STAGE_LEFT_EDGE: setHeaderText(R.string.security_settings_sfps_enroll_left_edge_title); if (!mHaveShownSfpsLeftEdgeLottie && mIllustrationLottie != null) { mHaveShownSfpsLeftEdgeLottie = true; configureEnrollmentStage("", R.raw.sfps_lottie_left_edge); } break; case SFPS_STAGE_RIGHT_EDGE: setHeaderText(R.string.security_settings_sfps_enroll_right_edge_title); if (!mHaveShownSfpsRightEdgeLottie && mIllustrationLottie != null) { 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. getLayout().setHeaderText( R.string.security_settings_sfps_enroll_find_sensor_title); setDescriptionText(R.string.security_settings_sfps_enroll_start_message); final CharSequence description = getString( R.string.security_settings_sfps_enroll_find_sensor_message); getLayout().getHeaderTextView().setContentDescription(description); setTitle(description); break; } } private void configureEnrollmentStage(CharSequence description, @RawRes int lottie) { setDescriptionText(description); LottieCompositionFactory.fromRawRes(this, lottie) .addListener((c) -> { mIllustrationLottie.setComposition(c); mIllustrationLottie.setVisibility(View.VISIBLE); mIllustrationLottie.playAnimation(); }); } @EnrollStage private int getCurrentStage() { if (mSidecar == null || mSidecar.getEnrollmentSteps() == -1) { return STAGE_UNKNOWN; } final int progressSteps = mSidecar.getEnrollmentSteps() - mSidecar.getEnrollmentRemaining(); if (progressSteps < getStageThresholdSteps(0)) { return STAGE_CENTER; } else if (progressSteps < getStageThresholdSteps(1)) { return STAGE_GUIDED; } else if (progressSteps < getStageThresholdSteps(2)) { return STAGE_FINGERTIP; } else if (progressSteps < getStageThresholdSteps(3)) { return STAGE_LEFT_EDGE; } else { return STAGE_RIGHT_EDGE; } } @SfpsEnrollStage private int getCurrentSfpsStage() { if (mSidecar == null) { return STAGE_UNKNOWN; } final int progressSteps = mSidecar.getEnrollmentSteps() - mSidecar.getEnrollmentRemaining(); 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 boolean isStageHalfCompleted() { // Prior to first enrollment step. if (mSidecar == null || mSidecar.getEnrollmentSteps() == -1) { return false; } final int progressSteps = mSidecar.getEnrollmentSteps() - mSidecar.getEnrollmentRemaining(); int prevThresholdSteps = 0; for (int i = 0; i < mFingerprintManager.getEnrollStageCount(); i++) { final int thresholdSteps = getStageThresholdSteps(i); if (progressSteps >= prevThresholdSteps && progressSteps < thresholdSteps) { final int adjustedProgress = progressSteps - prevThresholdSteps; final int adjustedThreshold = thresholdSteps - prevThresholdSteps; return adjustedProgress >= adjustedThreshold / 2; } prevThresholdSteps = thresholdSteps; } // After last enrollment step. return true; } @VisibleForTesting protected int getStageThresholdSteps(int index) { if (mSidecar == null || mSidecar.getEnrollmentSteps() == -1) { Log.w(TAG, "getStageThresholdSteps: Enrollment not started yet"); return 1; } return Math.round(mSidecar.getEnrollmentSteps() * mFingerprintManager.getEnrollStageThreshold(index)); } @Override public void onEnrollmentHelp(int helpMsgId, CharSequence helpString) { if (!TextUtils.isEmpty(helpString)) { if (!(mCanAssumeUdfps || mCanAssumeSfps)) { mErrorText.removeCallbacks(mTouchAgainRunnable); } showError(helpString); if (mUdfpsEnrollHelper != null) mUdfpsEnrollHelper.onEnrollmentHelp(); } dismissTouchDialogIfSfps(); } @Override public void onEnrollmentError(int errMsgId, CharSequence errString) { onCancelEnrollment(errMsgId); dismissTouchDialogIfSfps(); } private void announceEnrollmentProgress(CharSequence announcement) { AccessibilityEvent e = AccessibilityEvent.obtain(); e.setEventType(AccessibilityEvent.TYPE_ANNOUNCEMENT); e.setClassName(getClass().getName()); e.setPackageName(getPackageName()); e.getText().add(announcement); mAccessibilityManager.sendAccessibilityEvent(e); } @Override public void onEnrollmentProgressChange(int steps, int remaining) { updateProgress(true /* animate */); final int percent = (int) (((float) (steps - remaining) / (float) steps) * 100); if (mCanAssumeSfps && mIsAccessibilityEnabled) { CharSequence announcement = getString( R.string.security_settings_sfps_enroll_progress_a11y_message, percent); announceEnrollmentProgress(announcement); if (mIllustrationLottie != null) { mIllustrationLottie.setContentDescription( getString( R.string.security_settings_sfps_animation_a11y_label, percent) ); } } updateTitleAndDescription(); animateFlash(); if (mCanAssumeUdfps) { if (mIsAccessibilityEnabled) { CharSequence announcement = getString( R.string.security_settings_udfps_enroll_progress_a11y_message, percent); announceEnrollmentProgress(announcement); } } else if (!mCanAssumeSfps) { mErrorText.removeCallbacks(mTouchAgainRunnable); mErrorText.postDelayed(mTouchAgainRunnable, HINT_TIMEOUT_DURATION); } dismissTouchDialogIfSfps(); } private void dismissTouchDialogIfSfps() { if (!mCanAssumeSfps) { return; } final IconTouchDialog dialog = (IconTouchDialog) getSupportFragmentManager().findFragmentByTag(ICON_TOUCH_DIALOG); if (dialog != null && dialog.isResumed()) { dialog.dismiss(); } } @Override public void onAcquired(boolean isAcquiredGood) { if (mUdfpsEnrollHelper != null) { mUdfpsEnrollHelper.onAcquired(isAcquiredGood); } } @Override public void onPointerDown(int sensorId) { if (mUdfpsEnrollHelper != null) { mUdfpsEnrollHelper.onPointerDown(sensorId); } } @Override public void onPointerUp(int sensorId) { if (mUdfpsEnrollHelper != null) { mUdfpsEnrollHelper.onPointerUp(sensorId); } } private void updateProgress(boolean animate) { if (mSidecar == null || !mSidecar.isEnrolling()) { Log.d(TAG, "Enrollment not started yet"); return; } int progress = getProgress( mSidecar.getEnrollmentSteps(), mSidecar.getEnrollmentRemaining()); // Only clear the error when progress has been made. // TODO (b/234772728) Add tests. if (mProgressBar != null && mProgressBar.getProgress() < progress) { clearError(); } if (mUdfpsEnrollHelper != null) { mUdfpsEnrollHelper.onEnrollmentProgress(mSidecar.getEnrollmentSteps(), mSidecar.getEnrollmentRemaining()); } if (animate) { animateProgress(progress); } else { if (mProgressBar != null) { mProgressBar.setProgress(progress); } if (progress >= PROGRESS_BAR_MAX) { mDelayedFinishRunnable.run(); } } } private int getProgress(int steps, int remaining) { if (steps == -1) { return 0; } int progress = Math.max(0, steps + 1 - remaining); return PROGRESS_BAR_MAX * progress / (steps + 1); } private void showIconTouchDialog() { mIconTouchCount = 0; new IconTouchDialog().show(getSupportFragmentManager(), ICON_TOUCH_DIALOG); } private void showError(CharSequence error) { if (mCanAssumeUdfps || mCanAssumeSfps) { setHeaderText(error); // Show nothing for subtitle when getting an error message. setDescriptionText(""); if (mCanAssumeSfps) { if (!mHelpAnimation.isRunning()) { mHelpAnimation.start(); } applySfpsErrorDynamicColors(getApplicationContext(), true); } } else { mErrorText.setText(error); if (mErrorText.getVisibility() == View.INVISIBLE) { mErrorText.setVisibility(View.VISIBLE); mErrorText.setTranslationY(getResources().getDimensionPixelSize( R.dimen.fingerprint_error_text_appear_distance)); mErrorText.setAlpha(0f); mErrorText.animate() .alpha(1f) .translationY(0f) .setDuration(200) .setInterpolator(mLinearOutSlowInInterpolator) .start(); } else { mErrorText.animate().cancel(); mErrorText.setAlpha(1f); mErrorText.setTranslationY(0f); } } if (isResumed() && mIsAccessibilityEnabled && !mCanAssumeUdfps) { mVibrator.vibrate(Process.myUid(), getApplicationContext().getOpPackageName(), VIBRATE_EFFECT_ERROR, getClass().getSimpleName() + "::showError", FINGERPRINT_ENROLLING_SONFICATION_ATTRIBUTES); } } private void clearError() { if (mCanAssumeSfps) { applySfpsErrorDynamicColors(getApplicationContext(), false); } if ((!(mCanAssumeUdfps || mCanAssumeSfps)) && mErrorText.getVisibility() == View.VISIBLE) { mErrorText.animate() .alpha(0f) .translationY(getResources().getDimensionPixelSize( R.dimen.fingerprint_error_text_disappear_distance)) .setDuration(100) .setInterpolator(mFastOutLinearInInterpolator) .withEndAction(() -> mErrorText.setVisibility(View.INVISIBLE)) .start(); } } /** * Applies dynamic colors corresponding to showing or clearing errors on the progress bar * and finger lottie for SFPS */ private void applySfpsErrorDynamicColors(Context context, boolean isError) { applyProgressBarDynamicColor(context, isError); if (mIllustrationLottie != null) { applyLottieDynamicColor(context, isError); } } private void applyProgressBarDynamicColor(Context context, boolean isError) { if (mProgressBar != null) { 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(Context context, boolean isError) { 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 void listenOrientationEvent() { mOrientationEventListener = new OrientationEventListener(this) { @Override public void onOrientationChanged(int orientation) { final int currentRotation = getDisplay().getRotation(); if ((mPreviousRotation == Surface.ROTATION_90 && currentRotation == Surface.ROTATION_270) || ( mPreviousRotation == Surface.ROTATION_270 && currentRotation == Surface.ROTATION_90)) { mPreviousRotation = currentRotation; recreate(); } } }; mOrientationEventListener.enable(); mPreviousRotation = getDisplay().getRotation(); } private void stopListenOrientationEvent() { if (mOrientationEventListener != null) { mOrientationEventListener.disable(); } mOrientationEventListener = null; } private final Animator.AnimatorListener mProgressAnimationListener = new Animator.AnimatorListener() { @Override public void onAnimationStart(Animator animation) { startIconAnimation(); } @Override public void onAnimationRepeat(Animator animation) { } @Override public void onAnimationEnd(Animator animation) { stopIconAnimation(); if (mProgressBar.getProgress() >= PROGRESS_BAR_MAX) { mProgressBar.postDelayed(mDelayedFinishRunnable, getFinishDelay()); } } @Override public void onAnimationCancel(Animator animation) { } }; private long getFinishDelay() { return mCanAssumeUdfps ? 400L : 250L; } // Give the user a chance to see progress completed before jumping to the next stage. private final Runnable mDelayedFinishRunnable = new Runnable() { @Override public void run() { launchFinish(mToken); } }; private final Animatable2.AnimationCallback mIconAnimationCallback = new Animatable2.AnimationCallback() { @Override public void onAnimationEnd(Drawable d) { if (mAnimationCancelled) { return; } // Start animation after it has ended. mProgressBar.post(new Runnable() { @Override public void run() { startIconAnimation(); } }); } }; private final Runnable mShowDialogRunnable = new Runnable() { @Override public void run() { showIconTouchDialog(); } }; private final Runnable mTouchAgainRunnable = new Runnable() { @Override public void run() { showError(getString(R.string.security_settings_fingerprint_enroll_lift_touch_again)); } }; @Override public int getMetricsCategory() { return SettingsEnums.FINGERPRINT_ENROLLING; } private void updateOrientation(int orientation) { if (mCanAssumeSfps) { mIllustrationLottie = findViewById(R.id.illustration_lottie); } else { switch(orientation) { case Configuration.ORIENTATION_LANDSCAPE: { mIllustrationLottie = null; break; } case Configuration.ORIENTATION_PORTRAIT: { if (mShouldShowLottie) { mIllustrationLottie = findViewById(R.id.illustration_lottie); } break; } default: Log.e(TAG, "Error unhandled configuration change"); break; } } } @SuppressWarnings("MissingSuperCall") // TODO: Fix me @Override public void onConfigurationChanged(@NonNull Configuration newConfig) { maybeHideSfpsText(newConfig); switch(newConfig.orientation) { case Configuration.ORIENTATION_LANDSCAPE: { updateOrientation(Configuration.ORIENTATION_LANDSCAPE); break; } case Configuration.ORIENTATION_PORTRAIT: { updateOrientation(Configuration.ORIENTATION_PORTRAIT); break; } default: Log.e(TAG, "Error unhandled configuration change"); break; } } private void maybeHideSfpsText(@NonNull Configuration newConfig) { final HeaderMixin headerMixin = getLayout().getMixin(HeaderMixin.class); final DescriptionMixin descriptionMixin = getLayout().getMixin(DescriptionMixin.class); final boolean isLandscape = newConfig.orientation == Configuration.ORIENTATION_LANDSCAPE; if (mCanAssumeSfps) { 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); } } } private UdfpsEnrollView addUdfpsEnrollView(FingerprintSensorPropertiesInternal udfpsProps) { UdfpsEnrollView udfpsEnrollView = (UdfpsEnrollView) getLayoutInflater().inflate( R.layout.udfps_enroll_view, null, false); DisplayInfo displayInfo = new DisplayInfo(); getDisplay().getDisplayInfo(displayInfo); mScaleFactor = mUdfpsUtils.getScaleFactor(displayInfo); Rect udfpsBounds = udfpsProps.getLocation().getRect(); udfpsBounds.scale(mScaleFactor); final Rect overlayBounds = new Rect( 0, /* left */ displayInfo.getNaturalHeight() / 2, /* top */ displayInfo.getNaturalWidth(), /* right */ displayInfo.getNaturalHeight() /* botom */); UdfpsOverlayParams params = new UdfpsOverlayParams( udfpsBounds, overlayBounds, displayInfo.getNaturalWidth(), displayInfo.getNaturalHeight(), mScaleFactor, displayInfo.rotation); udfpsEnrollView.setOverlayParams(params); mUdfpsEnrollHelper = (UdfpsEnrollHelper) getSupportFragmentManager().findFragmentByTag( FingerprintEnrollEnrolling.TAG_UDFPS_HELPER); if (mUdfpsEnrollHelper == null) { mUdfpsEnrollHelper = new UdfpsEnrollHelper(getApplicationContext(), mFingerprintManager); getSupportFragmentManager().beginTransaction() .add(mUdfpsEnrollHelper, FingerprintEnrollEnrolling.TAG_UDFPS_HELPER) .commitAllowingStateLoss(); } udfpsEnrollView.setEnrollHelper(mUdfpsEnrollHelper); return udfpsEnrollView; } private void setOnHoverListener(boolean isLandscape, GlifLayout enrollLayout, UdfpsEnrollView udfpsEnrollView) { if (!mIsAccessibilityEnabled) return; final Context context = getApplicationContext(); final View.OnHoverListener onHoverListener = (v, event) -> { // Map the touch to portrait mode if the device is in // landscape mode. final Point scaledTouch = mUdfpsUtils.getTouchInNativeCoordinates(event.getPointerId(0), event, udfpsEnrollView.getOverlayParams()); final String theStr = mUdfpsUtils.onTouchOutsideOfSensorArea( mAccessibilityManager.isTouchExplorationEnabled(), context, scaledTouch.x, scaledTouch.y, udfpsEnrollView.getOverlayParams()); if (theStr != null) { v.announceForAccessibility(theStr); } return false; }; enrollLayout.findManagedViewById(isLandscape ? R.id.sud_landscape_content_area : R.id.sud_layout_content).setOnHoverListener(onHoverListener); } public static class IconTouchDialog extends InstrumentedDialogFragment { @Override public Dialog onCreateDialog(Bundle savedInstanceState) { AlertDialog.Builder builder = new AlertDialog.Builder(getActivity(), R.style.Theme_AlertDialog); builder.setTitle(R.string.security_settings_fingerprint_enroll_touch_dialog_title) .setMessage(R.string.security_settings_fingerprint_enroll_touch_dialog_message) .setPositiveButton(R.string.security_settings_fingerprint_enroll_dialog_ok, new DialogInterface.OnClickListener() { @Override public void onClick(DialogInterface dialog, int which) { dialog.dismiss(); } }); return builder.create(); } @Override public int getMetricsCategory() { return SettingsEnums.DIALOG_FINGERPRINT_ICON_TOUCH; } } }