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