1 /* 2 * Copyright (C) 2018 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; 18 19 import android.app.admin.DevicePolicyManager; 20 import android.content.Intent; 21 import android.graphics.PorterDuff; 22 import android.graphics.PorterDuffColorFilter; 23 import android.hardware.biometrics.BiometricAuthenticator; 24 import android.os.Bundle; 25 import android.os.UserHandle; 26 import android.os.UserManager; 27 import android.util.Log; 28 import android.view.View; 29 import android.widget.TextView; 30 31 import androidx.annotation.NonNull; 32 import androidx.annotation.Nullable; 33 import androidx.annotation.StringRes; 34 import androidx.annotation.VisibleForTesting; 35 36 import com.android.internal.widget.LockPatternUtils; 37 import com.android.settings.R; 38 import com.android.settings.SetupWizardUtils; 39 import com.android.settings.password.ChooseLockGeneric; 40 import com.android.settings.password.ChooseLockSettingsHelper; 41 import com.android.settings.password.SetupSkipDialog; 42 43 import com.google.android.setupcompat.template.FooterBarMixin; 44 import com.google.android.setupcompat.template.FooterButton; 45 import com.google.android.setupcompat.util.WizardManagerHelper; 46 import com.google.android.setupdesign.GlifLayout; 47 import com.google.android.setupdesign.span.LinkSpan; 48 import com.google.android.setupdesign.template.RequireScrollMixin; 49 import com.google.android.setupdesign.util.DynamicColorPalette; 50 51 /** 52 * Abstract base class for the intro onboarding activity for biometric enrollment. 53 */ 54 public abstract class BiometricEnrollIntroduction extends BiometricEnrollBase 55 implements LinkSpan.OnClickListener { 56 57 private static final String TAG = "BiometricEnrollIntroduction"; 58 59 private static final String KEY_CONFIRMING_CREDENTIALS = "confirming_credentials"; 60 private static final String KEY_SCROLLED_TO_BOTTOM = "scrolled"; 61 62 private GatekeeperPasswordProvider mGatekeeperPasswordProvider; 63 private UserManager mUserManager; 64 private boolean mHasPassword; 65 private boolean mBiometricUnlockDisabledByAdmin; 66 private TextView mErrorText; 67 protected boolean mConfirmingCredentials; 68 protected boolean mNextClicked; 69 private boolean mParentalConsentRequired; 70 private boolean mHasScrolledToBottom = false; 71 72 @Nullable private PorterDuffColorFilter mIconColorFilter; 73 74 /** 75 * @return true if the biometric is disabled by a device administrator 76 */ isDisabledByAdmin()77 protected abstract boolean isDisabledByAdmin(); 78 79 /** 80 * @return the layout resource 81 */ getLayoutResource()82 protected abstract int getLayoutResource(); 83 84 /** 85 * @return the header resource for if the biometric has been disabled by a device administrator 86 */ getHeaderResDisabledByAdmin()87 protected abstract int getHeaderResDisabledByAdmin(); 88 89 /** 90 * @return the default header resource 91 */ getHeaderResDefault()92 protected abstract int getHeaderResDefault(); 93 94 /** 95 * @return the description for if the biometric has been disabled by a device admin 96 */ getDescriptionDisabledByAdmin()97 protected abstract String getDescriptionDisabledByAdmin(); 98 99 /** 100 * @return the cancel button 101 */ getCancelButton()102 protected abstract FooterButton getCancelButton(); 103 104 /** 105 * @return the next button 106 */ getNextButton()107 protected abstract FooterButton getNextButton(); 108 109 /** 110 * @return the error TextView 111 */ getErrorTextView()112 protected abstract TextView getErrorTextView(); 113 114 /** 115 * @return 0 if there are no errors, otherwise returns the resource ID for the error string 116 * to be displayed. 117 */ checkMaxEnrolled()118 protected abstract int checkMaxEnrolled(); 119 120 /** 121 * @return the challenge generated by the biometric hardware 122 */ getChallenge(GenerateChallengeCallback callback)123 protected abstract void getChallenge(GenerateChallengeCallback callback); 124 125 /** 126 * @return one of the ChooseLockSettingsHelper#EXTRA_KEY_FOR_* constants 127 */ getExtraKeyForBiometric()128 protected abstract String getExtraKeyForBiometric(); 129 130 /** 131 * @return the intent for proceeding to the next step of enrollment. For Fingerprint, this 132 * should lead to the "Find Sensor" activity. For Face, this should lead to the "Enrolling" 133 * activity. 134 */ getEnrollingIntent()135 protected abstract Intent getEnrollingIntent(); 136 137 /** 138 * @return the title to be shown on the ConfirmLock screen. 139 */ getConfirmLockTitleResId()140 protected abstract int getConfirmLockTitleResId(); 141 142 /** 143 * @param span 144 */ onClick(LinkSpan span)145 public abstract void onClick(LinkSpan span); 146 getModality()147 public abstract @BiometricAuthenticator.Modality int getModality(); 148 149 protected interface GenerateChallengeCallback { onChallengeGenerated(int sensorId, int userId, long challenge)150 void onChallengeGenerated(int sensorId, int userId, long challenge); 151 } 152 153 @Override onCreate(Bundle savedInstanceState)154 protected void onCreate(Bundle savedInstanceState) { 155 super.onCreate(savedInstanceState); 156 157 if (shouldShowSplitScreenDialog()) { 158 BiometricsSplitScreenDialog 159 .newInstance(getModality(), !WizardManagerHelper.isAnySetupWizard(getIntent())) 160 .show(getSupportFragmentManager(), BiometricsSplitScreenDialog.class.getName()); 161 } 162 163 if (savedInstanceState != null) { 164 mConfirmingCredentials = savedInstanceState.getBoolean(KEY_CONFIRMING_CREDENTIALS); 165 mHasScrolledToBottom = savedInstanceState.getBoolean(KEY_SCROLLED_TO_BOTTOM); 166 mLaunchedPostureGuidance = savedInstanceState.getBoolean( 167 EXTRA_LAUNCHED_POSTURE_GUIDANCE); 168 } 169 170 Intent intent = getIntent(); 171 if (intent.getStringExtra(WizardManagerHelper.EXTRA_THEME) == null) { 172 // Put the theme in the intent so it gets propagated to other activities in the flow 173 intent.putExtra( 174 WizardManagerHelper.EXTRA_THEME, 175 SetupWizardUtils.getThemeString(intent)); 176 } 177 178 mBiometricUnlockDisabledByAdmin = isDisabledByAdmin(); 179 180 setContentView(getLayoutResource()); 181 mParentalConsentRequired = ParentalControlsUtils.parentConsentRequired(this, getModality()) 182 != null; 183 if (mBiometricUnlockDisabledByAdmin && !mParentalConsentRequired) { 184 setHeaderText(getHeaderResDisabledByAdmin()); 185 } else { 186 setHeaderText(getHeaderResDefault()); 187 } 188 189 mErrorText = getErrorTextView(); 190 191 mUserManager = getUserManager(); 192 updatePasswordQuality(); 193 194 // Check isFinishing() because FaceEnrollIntroduction may finish self to launch 195 // FaceSettings during onCreate() 196 if (!mConfirmingCredentials && !isFinishing()) { 197 if (!mHasPassword) { 198 // No password registered, launch into enrollment wizard. 199 mConfirmingCredentials = true; 200 launchChooseLock(); 201 } else if (!BiometricUtils.containsGatekeeperPasswordHandle(getIntent()) 202 && mToken == null) { 203 // It's possible to have a token but mLaunchedConfirmLock == false, since 204 // ChooseLockGeneric can pass us a token. 205 mConfirmingCredentials = true; 206 launchConfirmLock(getConfirmLockTitleResId()); 207 } 208 } 209 210 final GlifLayout layout = getLayout(); 211 mFooterBarMixin = layout.getMixin(FooterBarMixin.class); 212 mFooterBarMixin.setPrimaryButton(getPrimaryFooterButton()); 213 mFooterBarMixin.setSecondaryButton(getSecondaryFooterButton(), true /* usePrimaryStyle */); 214 mFooterBarMixin.getSecondaryButton().setVisibility( 215 mHasScrolledToBottom ? View.VISIBLE : View.INVISIBLE); 216 217 final RequireScrollMixin requireScrollMixin = layout.getMixin(RequireScrollMixin.class); 218 requireScrollMixin.requireScrollWithButton(this, getPrimaryFooterButton(), 219 getMoreButtonTextRes(), this::onNextButtonClick); 220 requireScrollMixin.setOnRequireScrollStateChangedListener( 221 scrollNeeded -> { 222 boolean enrollmentCompleted = checkMaxEnrolled() != 0; 223 if (!enrollmentCompleted) { 224 // Update text of primary button from "More" to "Agree". 225 final int primaryButtonTextRes = scrollNeeded 226 ? getMoreButtonTextRes() 227 : getAgreeButtonTextRes(); 228 getPrimaryFooterButton().setText(this, primaryButtonTextRes); 229 } 230 231 // Show secondary button once scroll is completed. 232 getSecondaryFooterButton().setVisibility( 233 !scrollNeeded && !enrollmentCompleted ? View.VISIBLE : View.INVISIBLE); 234 mHasScrolledToBottom = !scrollNeeded; 235 }); 236 237 final boolean isScrollNeeded = requireScrollMixin.isScrollingRequired(); 238 final boolean enrollmentCompleted = checkMaxEnrolled() != 0; 239 getSecondaryFooterButton().setVisibility( 240 !isScrollNeeded && !enrollmentCompleted ? View.VISIBLE : View.INVISIBLE); 241 } 242 243 @Override onResume()244 protected void onResume() { 245 super.onResume(); 246 247 //reset mNextClick to make sure introduction page would be closed correctly 248 mNextClicked = false; 249 250 final int errorMsg = checkMaxEnrolled(); 251 if (errorMsg == 0) { 252 mErrorText.setText(null); 253 mErrorText.setVisibility(View.GONE); 254 getNextButton().setVisibility(View.VISIBLE); 255 } else { 256 mErrorText.setText(errorMsg); 257 mErrorText.setVisibility(View.VISIBLE); 258 getNextButton().setText(getResources().getString(R.string.done)); 259 getNextButton().setVisibility(View.VISIBLE); 260 getSecondaryFooterButton().setVisibility(View.INVISIBLE); 261 } 262 } 263 264 @Override onSaveInstanceState(Bundle outState)265 protected void onSaveInstanceState(Bundle outState) { 266 super.onSaveInstanceState(outState); 267 outState.putBoolean(KEY_CONFIRMING_CREDENTIALS, mConfirmingCredentials); 268 outState.putBoolean(KEY_SCROLLED_TO_BOTTOM, mHasScrolledToBottom); 269 } 270 271 @Override shouldFinishWhenBackgrounded()272 protected boolean shouldFinishWhenBackgrounded() { 273 return super.shouldFinishWhenBackgrounded() && !mConfirmingCredentials && !mNextClicked; 274 } 275 276 @VisibleForTesting 277 @NonNull getGatekeeperPasswordProvider()278 protected GatekeeperPasswordProvider getGatekeeperPasswordProvider() { 279 if (mGatekeeperPasswordProvider == null) { 280 mGatekeeperPasswordProvider = new GatekeeperPasswordProvider(getLockPatternUtils()); 281 } 282 return mGatekeeperPasswordProvider; 283 } 284 285 @VisibleForTesting getUserManager()286 protected UserManager getUserManager() { 287 return UserManager.get(this); 288 } 289 290 @VisibleForTesting 291 @NonNull getLockPatternUtils()292 protected LockPatternUtils getLockPatternUtils() { 293 return new LockPatternUtils(this); 294 } 295 updatePasswordQuality()296 private void updatePasswordQuality() { 297 final int passwordQuality = getLockPatternUtils() 298 .getActivePasswordQuality(mUserManager.getCredentialOwnerProfile(mUserId)); 299 mHasPassword = passwordQuality != DevicePolicyManager.PASSWORD_QUALITY_UNSPECIFIED; 300 } 301 302 @Override onNextButtonClick(View view)303 protected void onNextButtonClick(View view) { 304 // If it's not on suw, this method shouldn't be accessed. 305 if (shouldShowSplitScreenDialog() && WizardManagerHelper.isAnySetupWizard(getIntent())) { 306 BiometricsSplitScreenDialog.newInstance(getModality(), false /*destroyActivity*/) 307 .show(getSupportFragmentManager(), BiometricsSplitScreenDialog.class.getName()); 308 return; 309 } 310 311 mNextClicked = true; 312 if (checkMaxEnrolled() == 0) { 313 // Lock thingy is already set up, launch directly to the next page 314 launchNextEnrollingActivity(mToken); 315 } else { 316 boolean couldStartNextBiometric = BiometricUtils.tryStartingNextBiometricEnroll(this, 317 ENROLL_NEXT_BIOMETRIC_REQUEST, "enrollIntroduction#onNextButtonClicked"); 318 if (!couldStartNextBiometric) { 319 setResult(RESULT_FINISHED); 320 finish(); 321 } 322 } 323 mNextLaunched = true; 324 } 325 launchChooseLock()326 private void launchChooseLock() { 327 Intent intent = BiometricUtils.getChooseLockIntent(this, getIntent()); 328 intent.putExtra(ChooseLockGeneric.ChooseLockGenericFragment.HIDE_INSECURE_OPTIONS, true); 329 intent.putExtra(ChooseLockSettingsHelper.EXTRA_KEY_REQUEST_GK_PW_HANDLE, true); 330 intent.putExtra(getExtraKeyForBiometric(), true); 331 if (mUserId != UserHandle.USER_NULL) { 332 intent.putExtra(Intent.EXTRA_USER_ID, mUserId); 333 } 334 startActivityForResult(intent, CHOOSE_LOCK_GENERIC_REQUEST); 335 } 336 launchNextEnrollingActivity(byte[] token)337 private void launchNextEnrollingActivity(byte[] token) { 338 Intent intent = getEnrollingIntent(); 339 if (token != null) { 340 intent.putExtra(ChooseLockSettingsHelper.EXTRA_KEY_CHALLENGE_TOKEN, token); 341 } 342 if (mUserId != UserHandle.USER_NULL) { 343 intent.putExtra(Intent.EXTRA_USER_ID, mUserId); 344 } 345 BiometricUtils.copyMultiBiometricExtras(getIntent(), intent); 346 intent.putExtra(EXTRA_FROM_SETTINGS_SUMMARY, mFromSettingsSummary); 347 intent.putExtra(EXTRA_KEY_CHALLENGE, mChallenge); 348 intent.putExtra(EXTRA_KEY_SENSOR_ID, mSensorId); 349 startActivityForResult(intent, BIOMETRIC_FIND_SENSOR_REQUEST); 350 } 351 352 /** 353 * Returns the intent extra data for setResult(), null means nothing need to been sent back 354 */ 355 @Nullable getSetResultIntentExtra(@ullable Intent activityResultIntent)356 protected Intent getSetResultIntentExtra(@Nullable Intent activityResultIntent) { 357 return activityResultIntent; 358 } 359 360 @Override onActivityResult(int requestCode, int resultCode, Intent data)361 protected void onActivityResult(int requestCode, int resultCode, Intent data) { 362 Log.d(TAG, 363 "onActivityResult(requestCode=" + requestCode + ", resultCode=" + resultCode + ")"); 364 final boolean cameFromMultiBioFpAuthAddAnother = 365 requestCode == BiometricUtils.REQUEST_ADD_ANOTHER 366 && BiometricUtils.isMultiBiometricFingerprintEnrollmentFlow(this); 367 if (requestCode == BIOMETRIC_FIND_SENSOR_REQUEST) { 368 if (isResultFinished(resultCode)) { 369 handleBiometricResultSkipOrFinished(resultCode, getSetResultIntentExtra(data)); 370 } else if (isResultSkipped(resultCode)) { 371 if (!BiometricUtils.tryStartingNextBiometricEnroll(this, 372 ENROLL_NEXT_BIOMETRIC_REQUEST, "BIOMETRIC_FIND_SENSOR_SKIPPED")) { 373 handleBiometricResultSkipOrFinished(resultCode, data); 374 } 375 } else if (resultCode == RESULT_TIMEOUT) { 376 setResult(resultCode, data); 377 finish(); 378 } 379 } else if (requestCode == CHOOSE_LOCK_GENERIC_REQUEST) { 380 mConfirmingCredentials = false; 381 if (resultCode == RESULT_FINISHED) { 382 updatePasswordQuality(); 383 final boolean handled = onSetOrConfirmCredentials(data); 384 if (!handled) { 385 overridePendingTransition( 386 com.google.android.setupdesign.R.anim.sud_slide_next_in, 387 com.google.android.setupdesign.R.anim.sud_slide_next_out); 388 getNextButton().setEnabled(false); 389 getChallenge(((sensorId, userId, challenge) -> { 390 mSensorId = sensorId; 391 mChallenge = challenge; 392 mToken = BiometricUtils.requestGatekeeperHat(this, data, mUserId, 393 challenge); 394 BiometricUtils.removeGatekeeperPasswordHandle(this, data); 395 getNextButton().setEnabled(true); 396 })); 397 } 398 } else { 399 setResult(resultCode, data); 400 finish(); 401 } 402 } else if (requestCode == CONFIRM_REQUEST) { 403 mConfirmingCredentials = false; 404 if (resultCode == RESULT_OK && data != null) { 405 final boolean handled = onSetOrConfirmCredentials(data); 406 if (!handled) { 407 overridePendingTransition( 408 com.google.android.setupdesign.R.anim.sud_slide_next_in, 409 com.google.android.setupdesign.R.anim.sud_slide_next_out); 410 getNextButton().setEnabled(false); 411 getChallenge(((sensorId, userId, challenge) -> { 412 mSensorId = sensorId; 413 mChallenge = challenge; 414 mToken = BiometricUtils.requestGatekeeperHat(this, data, mUserId, 415 challenge); 416 BiometricUtils.removeGatekeeperPasswordHandle(this, data); 417 getNextButton().setEnabled(true); 418 })); 419 } 420 } else { 421 setResult(resultCode, data); 422 finish(); 423 } 424 } else if (requestCode == LEARN_MORE_REQUEST) { 425 overridePendingTransition( 426 com.google.android.setupdesign.R.anim.sud_slide_back_in, 427 com.google.android.setupdesign.R.anim.sud_slide_back_out); 428 } else if (requestCode == ENROLL_NEXT_BIOMETRIC_REQUEST 429 || cameFromMultiBioFpAuthAddAnother) { 430 if (isResultFinished(resultCode)) { 431 handleBiometricResultSkipOrFinished(resultCode, data); 432 } else if (isResultSkipped(resultCode)) { 433 if (requestCode == BiometricUtils.REQUEST_ADD_ANOTHER) { 434 // If we came from an add another request, it still might 435 // be possible to add another biometric. Check if we can. 436 if (checkMaxEnrolled() != 0) { 437 // If we can't enroll any more biometrics, than skip 438 // this one. 439 handleBiometricResultSkipOrFinished(resultCode, data); 440 } 441 } else { 442 handleBiometricResultSkipOrFinished(resultCode, data); 443 } 444 } else if (resultCode != RESULT_CANCELED) { 445 setResult(resultCode, data); 446 finish(); 447 } 448 } 449 super.onActivityResult(requestCode, resultCode, data); 450 } 451 isResultSkipped(int resultCode)452 private static boolean isResultSkipped(int resultCode) { 453 return resultCode == RESULT_SKIP 454 || resultCode == SetupSkipDialog.RESULT_SKIP; 455 } 456 isResultFinished(int resultCode)457 private static boolean isResultFinished(int resultCode) { 458 return resultCode == RESULT_FINISHED; 459 } 460 isResultSkipOrFinished(int resultCode)461 private static boolean isResultSkipOrFinished(int resultCode) { 462 return isResultSkipped(resultCode) || isResultFinished(resultCode); 463 } 464 removeEnrollNextBiometric()465 protected void removeEnrollNextBiometric() { 466 getIntent().removeExtra(MultiBiometricEnrollHelper.EXTRA_ENROLL_AFTER_FACE); 467 getIntent().removeExtra(MultiBiometricEnrollHelper.EXTRA_ENROLL_AFTER_FINGERPRINT); 468 } 469 removeEnrollNextBiometricIfSkipEnroll(@ullable Intent data)470 protected void removeEnrollNextBiometricIfSkipEnroll(@Nullable Intent data) { 471 if (data != null 472 && data.getBooleanExtra( 473 MultiBiometricEnrollHelper.EXTRA_SKIP_PENDING_ENROLL, false)) { 474 removeEnrollNextBiometric(); 475 } 476 } handleBiometricResultSkipOrFinished(int resultCode, @Nullable Intent data)477 protected void handleBiometricResultSkipOrFinished(int resultCode, @Nullable Intent data) { 478 removeEnrollNextBiometricIfSkipEnroll(data); 479 if (resultCode == RESULT_SKIP) { 480 onEnrollmentSkipped(data); 481 } else if (resultCode == RESULT_FINISHED) { 482 onFinishedEnrolling(data); 483 } 484 } 485 486 /** 487 * Called after confirming credentials. Can be used to prevent the default 488 * behavior of immediately calling #getChallenge (useful to things like intro 489 * consent screens that don't actually do enrollment and will later start an 490 * activity that does). 491 * 492 * @return True if the default behavior should be skipped and handled by this method instead. 493 */ onSetOrConfirmCredentials(@ullable Intent data)494 protected boolean onSetOrConfirmCredentials(@Nullable Intent data) { 495 return false; 496 } 497 onCancelButtonClick(View view)498 protected void onCancelButtonClick(View view) { 499 finish(); 500 } 501 onSkipButtonClick(View view)502 protected void onSkipButtonClick(View view) { 503 onEnrollmentSkipped(null /* data */); 504 } 505 onEnrollmentSkipped(@ullable Intent data)506 protected void onEnrollmentSkipped(@Nullable Intent data) { 507 setResult(RESULT_SKIP, data); 508 finish(); 509 } 510 onFinishedEnrolling(@ullable Intent data)511 protected void onFinishedEnrolling(@Nullable Intent data) { 512 setResult(RESULT_FINISHED, data); 513 finish(); 514 } 515 updateDescriptionText()516 protected void updateDescriptionText() { 517 if (mBiometricUnlockDisabledByAdmin && !mParentalConsentRequired) { 518 setDescriptionText(getDescriptionDisabledByAdmin()); 519 } 520 } 521 522 @Override initViews()523 protected void initViews() { 524 super.initViews(); 525 updateDescriptionText(); 526 } 527 528 @NonNull getIconColorFilter()529 protected PorterDuffColorFilter getIconColorFilter() { 530 if (mIconColorFilter == null) { 531 mIconColorFilter = new PorterDuffColorFilter( 532 DynamicColorPalette.getColor(this, DynamicColorPalette.ColorType.ACCENT), 533 PorterDuff.Mode.SRC_IN); 534 } 535 return mIconColorFilter; 536 } 537 538 @NonNull getPrimaryFooterButton()539 protected abstract FooterButton getPrimaryFooterButton(); 540 541 @NonNull getSecondaryFooterButton()542 protected abstract FooterButton getSecondaryFooterButton(); 543 544 @StringRes getAgreeButtonTextRes()545 protected abstract int getAgreeButtonTextRes(); 546 547 @StringRes getMoreButtonTextRes()548 protected abstract int getMoreButtonTextRes(); 549 } 550