1 /* 2 * Copyright (C) 2021 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package com.android.settings.biometrics.fingerprint; 18 19 import android.animation.ValueAnimator; 20 import android.content.Context; 21 import android.content.res.TypedArray; 22 import android.graphics.Canvas; 23 import android.graphics.ColorFilter; 24 import android.graphics.Paint; 25 import android.graphics.drawable.Drawable; 26 import android.os.Process; 27 import android.os.VibrationAttributes; 28 import android.os.VibrationEffect; 29 import android.os.Vibrator; 30 import android.util.AttributeSet; 31 import android.util.DisplayMetrics; 32 import android.view.accessibility.AccessibilityManager; 33 import android.view.animation.DecelerateInterpolator; 34 import android.view.animation.Interpolator; 35 import android.view.animation.OvershootInterpolator; 36 37 import androidx.annotation.ColorInt; 38 import androidx.annotation.NonNull; 39 import androidx.annotation.Nullable; 40 41 import com.android.internal.annotations.VisibleForTesting; 42 import com.android.settings.R; 43 44 /** 45 * UDFPS enrollment progress bar. 46 */ 47 public class UdfpsEnrollProgressBarDrawable extends Drawable { 48 private static final String TAG = "UdfpsProgressBar"; 49 50 private static final long CHECKMARK_ANIMATION_DELAY_MS = 200L; 51 private static final long CHECKMARK_ANIMATION_DURATION_MS = 300L; 52 private static final long FILL_COLOR_ANIMATION_DURATION_MS = 350L; 53 private static final long PROGRESS_ANIMATION_DURATION_MS = 400L; 54 private static final float STROKE_WIDTH_DP = 12f; 55 private static final Interpolator DEACCEL = new DecelerateInterpolator(); 56 57 private static final VibrationEffect VIBRATE_EFFECT_ERROR = 58 VibrationEffect.createWaveform(new long[]{0, 5, 55, 60}, -1); 59 private static final VibrationAttributes FINGERPRINT_ENROLLING_SONFICATION_ATTRIBUTES = 60 VibrationAttributes.createForUsage(VibrationAttributes.USAGE_ACCESSIBILITY); 61 62 private static final VibrationAttributes HARDWARE_FEEDBACK_VIBRATION_ATTRIBUTES = 63 VibrationAttributes.createForUsage(VibrationAttributes.USAGE_HARDWARE_FEEDBACK); 64 65 private static final VibrationEffect SUCCESS_VIBRATION_EFFECT = 66 VibrationEffect.get(VibrationEffect.EFFECT_CLICK); 67 68 private final float mStrokeWidthPx; 69 @ColorInt 70 private final int mProgressColor; 71 @ColorInt 72 private final int mHelpColor; 73 @ColorInt 74 private final int mOnFirstBucketFailedColor; 75 @NonNull 76 private final Drawable mCheckmarkDrawable; 77 @NonNull 78 private final Interpolator mCheckmarkInterpolator; 79 @NonNull 80 private final Paint mBackgroundPaint; 81 @VisibleForTesting 82 @NonNull 83 final Paint mFillPaint; 84 @NonNull 85 private final Vibrator mVibrator; 86 @NonNull 87 private final boolean mIsAccessibilityEnabled; 88 @NonNull 89 private final Context mContext; 90 91 private boolean mAfterFirstTouch; 92 93 private int mRemainingSteps = 0; 94 private int mTotalSteps = 0; 95 private float mProgress = 0f; 96 @Nullable 97 private ValueAnimator mProgressAnimator; 98 @NonNull 99 private final ValueAnimator.AnimatorUpdateListener mProgressUpdateListener; 100 101 private boolean mShowingHelp = false; 102 @Nullable 103 private ValueAnimator mFillColorAnimator; 104 @NonNull 105 private final ValueAnimator.AnimatorUpdateListener mFillColorUpdateListener; 106 107 @Nullable 108 private ValueAnimator mBackgroundColorAnimator; 109 @NonNull 110 private final ValueAnimator.AnimatorUpdateListener mBackgroundColorUpdateListener; 111 112 private boolean mComplete = false; 113 private float mCheckmarkScale = 0f; 114 @Nullable 115 private ValueAnimator mCheckmarkAnimator; 116 @NonNull 117 private final ValueAnimator.AnimatorUpdateListener mCheckmarkUpdateListener; 118 119 private int mMovingTargetFill; 120 private int mMovingTargetFillError; 121 private int mEnrollProgress; 122 private int mEnrollProgressHelp; 123 private int mEnrollProgressHelpWithTalkback; 124 UdfpsEnrollProgressBarDrawable(@onNull Context context, @Nullable AttributeSet attrs)125 public UdfpsEnrollProgressBarDrawable(@NonNull Context context, @Nullable AttributeSet attrs) { 126 mContext = context; 127 128 loadResources(context, attrs); 129 float density = context.getResources().getDisplayMetrics().densityDpi; 130 mStrokeWidthPx = STROKE_WIDTH_DP * (density / DisplayMetrics.DENSITY_DEFAULT); 131 mProgressColor = mEnrollProgress; 132 final AccessibilityManager am = context.getSystemService(AccessibilityManager.class); 133 mIsAccessibilityEnabled = am.isTouchExplorationEnabled(); 134 mOnFirstBucketFailedColor = mMovingTargetFillError; 135 if (!mIsAccessibilityEnabled) { 136 mHelpColor = mEnrollProgressHelp; 137 } else { 138 mHelpColor = mEnrollProgressHelpWithTalkback; 139 } 140 mCheckmarkDrawable = context.getDrawable(R.drawable.udfps_enroll_checkmark); 141 mCheckmarkDrawable.mutate(); 142 mCheckmarkInterpolator = new OvershootInterpolator(); 143 144 mBackgroundPaint = new Paint(); 145 mBackgroundPaint.setStrokeWidth(mStrokeWidthPx); 146 mBackgroundPaint.setColor(mMovingTargetFill); 147 mBackgroundPaint.setAntiAlias(true); 148 mBackgroundPaint.setStyle(Paint.Style.STROKE); 149 mBackgroundPaint.setStrokeCap(Paint.Cap.ROUND); 150 151 // Progress fill should *not* use the extracted system color. 152 mFillPaint = new Paint(); 153 mFillPaint.setStrokeWidth(mStrokeWidthPx); 154 mFillPaint.setColor(mProgressColor); 155 mFillPaint.setAntiAlias(true); 156 mFillPaint.setStyle(Paint.Style.STROKE); 157 mFillPaint.setStrokeCap(Paint.Cap.ROUND); 158 159 mVibrator = mContext.getSystemService(Vibrator.class); 160 161 mProgressUpdateListener = animation -> { 162 mProgress = (float) animation.getAnimatedValue(); 163 invalidateSelf(); 164 }; 165 166 mFillColorUpdateListener = animation -> { 167 mFillPaint.setColor((int) animation.getAnimatedValue()); 168 invalidateSelf(); 169 }; 170 171 mCheckmarkUpdateListener = animation -> { 172 mCheckmarkScale = (float) animation.getAnimatedValue(); 173 invalidateSelf(); 174 }; 175 176 mBackgroundColorUpdateListener = animation -> { 177 mBackgroundPaint.setColor((int) animation.getAnimatedValue()); 178 invalidateSelf(); 179 }; 180 } 181 onEnrollmentProgress(int remaining, int totalSteps)182 void onEnrollmentProgress(int remaining, int totalSteps) { 183 mAfterFirstTouch = true; 184 updateState(remaining, totalSteps, false /* showingHelp */); 185 } 186 onEnrollmentHelp(int remaining, int totalSteps)187 void onEnrollmentHelp(int remaining, int totalSteps) { 188 updateState(remaining, totalSteps, true /* showingHelp */); 189 } 190 onLastStepAcquired()191 void onLastStepAcquired() { 192 updateState(0, mTotalSteps, false /* showingHelp */); 193 } 194 updateState(int remainingSteps, int totalSteps, boolean showingHelp)195 private void updateState(int remainingSteps, int totalSteps, boolean showingHelp) { 196 updateProgress(remainingSteps, totalSteps, showingHelp); 197 updateFillColor(showingHelp); 198 } 199 updateProgress(int remainingSteps, int totalSteps, boolean showingHelp)200 private void updateProgress(int remainingSteps, int totalSteps, boolean showingHelp) { 201 if (mRemainingSteps == remainingSteps && mTotalSteps == totalSteps) { 202 return; 203 } 204 205 mShowingHelp = showingHelp; 206 if (mShowingHelp) { 207 if (mVibrator != null && mIsAccessibilityEnabled) { 208 mVibrator.vibrate(Process.myUid(), mContext.getOpPackageName(), 209 VIBRATE_EFFECT_ERROR, getClass().getSimpleName() + "::onEnrollmentHelp", 210 FINGERPRINT_ENROLLING_SONFICATION_ATTRIBUTES); 211 } 212 } else { 213 // If the first touch is an error, remainingSteps will be -1 and the callback 214 // doesn't come from onEnrollmentHelp. If we are in the accessibility flow, 215 // we still would like to vibrate. 216 if (mVibrator != null) { 217 if (remainingSteps == -1 && mIsAccessibilityEnabled) { 218 mVibrator.vibrate(Process.myUid(), mContext.getOpPackageName(), 219 VIBRATE_EFFECT_ERROR, 220 getClass().getSimpleName() + "::onFirstTouchError", 221 FINGERPRINT_ENROLLING_SONFICATION_ATTRIBUTES); 222 } else if (remainingSteps != -1 && !mIsAccessibilityEnabled) { 223 mVibrator.vibrate(Process.myUid(), 224 mContext.getOpPackageName(), 225 SUCCESS_VIBRATION_EFFECT, 226 getClass().getSimpleName() + "::OnEnrollmentProgress", 227 HARDWARE_FEEDBACK_VIBRATION_ATTRIBUTES); 228 } 229 } 230 } 231 232 mRemainingSteps = remainingSteps; 233 mTotalSteps = totalSteps; 234 235 final int progressSteps = Math.max(0, totalSteps - remainingSteps); 236 237 // If needed, add 1 to progress and total steps to account for initial touch. 238 final int adjustedSteps = mAfterFirstTouch ? progressSteps + 1 : progressSteps; 239 final int adjustedTotal = mAfterFirstTouch ? mTotalSteps + 1 : mTotalSteps; 240 241 final float targetProgress = Math.min(1f, (float) adjustedSteps / (float) adjustedTotal); 242 243 if (mProgressAnimator != null && mProgressAnimator.isRunning()) { 244 mProgressAnimator.cancel(); 245 } 246 247 mProgressAnimator = ValueAnimator.ofFloat(mProgress, targetProgress); 248 mProgressAnimator.setDuration(PROGRESS_ANIMATION_DURATION_MS); 249 mProgressAnimator.addUpdateListener(mProgressUpdateListener); 250 mProgressAnimator.start(); 251 252 if (remainingSteps == 0) { 253 startCompletionAnimation(); 254 } else if (remainingSteps > 0) { 255 rollBackCompletionAnimation(); 256 } 257 } 258 animateBackgroundColor()259 private void animateBackgroundColor() { 260 if (mBackgroundColorAnimator != null && mBackgroundColorAnimator.isRunning()) { 261 mBackgroundColorAnimator.end(); 262 } 263 mBackgroundColorAnimator = ValueAnimator.ofArgb(mBackgroundPaint.getColor(), 264 mOnFirstBucketFailedColor); 265 mBackgroundColorAnimator.setDuration(FILL_COLOR_ANIMATION_DURATION_MS); 266 mBackgroundColorAnimator.setRepeatCount(1); 267 mBackgroundColorAnimator.setRepeatMode(ValueAnimator.REVERSE); 268 mBackgroundColorAnimator.setInterpolator(DEACCEL); 269 mBackgroundColorAnimator.addUpdateListener(mBackgroundColorUpdateListener); 270 mBackgroundColorAnimator.start(); 271 } 272 updateFillColor(boolean showingHelp)273 private void updateFillColor(boolean showingHelp) { 274 if (!mAfterFirstTouch && showingHelp) { 275 // If we are on the first touch, animate the background color 276 // instead of the progress color. 277 animateBackgroundColor(); 278 return; 279 } 280 281 if (mFillColorAnimator != null && mFillColorAnimator.isRunning()) { 282 mFillColorAnimator.end(); 283 } 284 285 @ColorInt final int targetColor = showingHelp ? mHelpColor : mProgressColor; 286 mFillColorAnimator = ValueAnimator.ofArgb(mFillPaint.getColor(), targetColor); 287 mFillColorAnimator.setDuration(FILL_COLOR_ANIMATION_DURATION_MS); 288 mFillColorAnimator.setRepeatCount(1); 289 mFillColorAnimator.setRepeatMode(ValueAnimator.REVERSE); 290 mFillColorAnimator.setInterpolator(DEACCEL); 291 mFillColorAnimator.addUpdateListener(mFillColorUpdateListener); 292 mFillColorAnimator.start(); 293 } 294 startCompletionAnimation()295 private void startCompletionAnimation() { 296 if (mComplete) { 297 return; 298 } 299 mComplete = true; 300 301 if (mCheckmarkAnimator != null && mCheckmarkAnimator.isRunning()) { 302 mCheckmarkAnimator.cancel(); 303 } 304 305 mCheckmarkAnimator = ValueAnimator.ofFloat(mCheckmarkScale, 1f); 306 mCheckmarkAnimator.setStartDelay(CHECKMARK_ANIMATION_DELAY_MS); 307 mCheckmarkAnimator.setDuration(CHECKMARK_ANIMATION_DURATION_MS); 308 mCheckmarkAnimator.setInterpolator(mCheckmarkInterpolator); 309 mCheckmarkAnimator.addUpdateListener(mCheckmarkUpdateListener); 310 mCheckmarkAnimator.start(); 311 } 312 rollBackCompletionAnimation()313 private void rollBackCompletionAnimation() { 314 if (!mComplete) { 315 return; 316 } 317 mComplete = false; 318 319 // Adjust duration based on how much of the completion animation has played. 320 final float animatedFraction = mCheckmarkAnimator != null 321 ? mCheckmarkAnimator.getAnimatedFraction() 322 : 0f; 323 final long durationMs = Math.round(CHECKMARK_ANIMATION_DELAY_MS * animatedFraction); 324 325 if (mCheckmarkAnimator != null && mCheckmarkAnimator.isRunning()) { 326 mCheckmarkAnimator.cancel(); 327 } 328 329 mCheckmarkAnimator = ValueAnimator.ofFloat(mCheckmarkScale, 0f); 330 mCheckmarkAnimator.setDuration(durationMs); 331 mCheckmarkAnimator.addUpdateListener(mCheckmarkUpdateListener); 332 mCheckmarkAnimator.start(); 333 } 334 loadResources(Context context, @Nullable AttributeSet attrs)335 private void loadResources(Context context, @Nullable AttributeSet attrs) { 336 final TypedArray ta = context.obtainStyledAttributes(attrs, 337 R.styleable.BiometricsEnrollView, R.attr.biometricsEnrollStyle, 338 R.style.BiometricsEnrollStyle); 339 mMovingTargetFill = ta.getColor( 340 R.styleable.BiometricsEnrollView_biometricsMovingTargetFill, 0); 341 mMovingTargetFillError = ta.getColor( 342 R.styleable.BiometricsEnrollView_biometricsMovingTargetFillError, 0); 343 mEnrollProgress = ta.getColor( 344 R.styleable.BiometricsEnrollView_biometricsEnrollProgress, 0); 345 mEnrollProgressHelp = ta.getColor( 346 R.styleable.BiometricsEnrollView_biometricsEnrollProgressHelp, 0); 347 mEnrollProgressHelpWithTalkback = ta.getColor( 348 R.styleable.BiometricsEnrollView_biometricsEnrollProgressHelpWithTalkback, 0); 349 ta.recycle(); 350 } 351 352 @Override draw(@onNull Canvas canvas)353 public void draw(@NonNull Canvas canvas) { 354 canvas.save(); 355 356 // Progress starts from the top, instead of the right 357 canvas.rotate(-90f, getBounds().centerX(), getBounds().centerY()); 358 359 final float halfPaddingPx = mStrokeWidthPx / 2f; 360 361 if (mProgress < 1f) { 362 // Draw the background color of the progress circle. 363 canvas.drawArc( 364 halfPaddingPx, 365 halfPaddingPx, 366 getBounds().right - halfPaddingPx, 367 getBounds().bottom - halfPaddingPx, 368 0f /* startAngle */, 369 360f /* sweepAngle */, 370 false /* useCenter */, 371 mBackgroundPaint); 372 } 373 374 if (mProgress > 0f) { 375 // Draw the filled portion of the progress circle. 376 canvas.drawArc( 377 halfPaddingPx, 378 halfPaddingPx, 379 getBounds().right - halfPaddingPx, 380 getBounds().bottom - halfPaddingPx, 381 0f /* startAngle */, 382 360f * mProgress /* sweepAngle */, 383 false /* useCenter */, 384 mFillPaint); 385 } 386 387 canvas.restore(); 388 389 if (mCheckmarkScale > 0f) { 390 final float offsetScale = (float) Math.sqrt(2) / 2f; 391 final float centerXOffset = (getBounds().width() - mStrokeWidthPx) / 2f * offsetScale; 392 final float centerYOffset = (getBounds().height() - mStrokeWidthPx) / 2f * offsetScale; 393 final float centerX = getBounds().centerX() + centerXOffset; 394 final float centerY = getBounds().centerY() + centerYOffset; 395 396 final float boundsXOffset = 397 mCheckmarkDrawable.getIntrinsicWidth() / 2f * mCheckmarkScale; 398 final float boundsYOffset = 399 mCheckmarkDrawable.getIntrinsicHeight() / 2f * mCheckmarkScale; 400 401 final int left = Math.round(centerX - boundsXOffset); 402 final int top = Math.round(centerY - boundsYOffset); 403 final int right = Math.round(centerX + boundsXOffset); 404 final int bottom = Math.round(centerY + boundsYOffset); 405 mCheckmarkDrawable.setBounds(left, top, right, bottom); 406 mCheckmarkDrawable.draw(canvas); 407 } 408 } 409 410 @Override setAlpha(int alpha)411 public void setAlpha(int alpha) {} 412 413 @Override setColorFilter(@ullable ColorFilter colorFilter)414 public void setColorFilter(@Nullable ColorFilter colorFilter) { 415 } 416 417 @Override getOpacity()418 public int getOpacity() { 419 return 0; 420 } 421 } 422