diff --git a/res/raw/face_posture_guidance_lottie.json b/res/raw/face_posture_guidance_lottie.json new file mode 100644 index 00000000000..e69de29bb2d diff --git a/res/values/config.xml b/res/values/config.xml index 4aa41425ce6..f49eff736a5 100755 --- a/res/values/config.xml +++ b/res/values/config.xml @@ -283,6 +283,19 @@ + + + + + 0 + false @@ -292,6 +305,9 @@ false + + true + diff --git a/src/com/android/settings/activityembedding/ActivityEmbeddingRulesController.java b/src/com/android/settings/activityembedding/ActivityEmbeddingRulesController.java index 03bff7bfa30..369f453fc1b 100644 --- a/src/com/android/settings/activityembedding/ActivityEmbeddingRulesController.java +++ b/src/com/android/settings/activityembedding/ActivityEmbeddingRulesController.java @@ -36,6 +36,7 @@ import androidx.window.embedding.SplitRule; import com.android.settings.Settings; import com.android.settings.SettingsActivity; import com.android.settings.SubSettings; +import com.android.settings.biometrics.face.FaceEnrollIntroductionInternal; import com.android.settings.biometrics.fingerprint.FingerprintEnrollEnrolling; import com.android.settings.biometrics.fingerprint.FingerprintEnrollIntroduction; import com.android.settings.biometrics.fingerprint.FingerprintEnrollIntroductionInternal; @@ -228,6 +229,8 @@ public class ActivityEmbeddingRulesController { addActivityFilter(activityFilters, FingerprintEnrollIntroduction.class); addActivityFilter(activityFilters, FingerprintEnrollIntroductionInternal.class); addActivityFilter(activityFilters, FingerprintEnrollEnrolling.class); + addActivityFilter(activityFilters, FaceEnrollIntroductionInternal.class); + addActivityFilter(activityFilters, Settings.FaceSettingsInternalActivity.class); addActivityFilter(activityFilters, AvatarPickerActivity.class); mSplitController.registerRule(new ActivityRule(activityFilters, true /* alwaysExpand */)); } diff --git a/src/com/android/settings/biometrics/BiometricEnrollActivity.java b/src/com/android/settings/biometrics/BiometricEnrollActivity.java index 932c41073e4..bb16f0bb9b6 100644 --- a/src/com/android/settings/biometrics/BiometricEnrollActivity.java +++ b/src/com/android/settings/biometrics/BiometricEnrollActivity.java @@ -48,6 +48,7 @@ import com.android.internal.widget.LockPatternUtils; import com.android.settings.R; import com.android.settings.SetupWizardUtils; import com.android.settings.core.InstrumentedActivity; +import com.android.settings.overlay.FeatureFactory; import com.android.settings.password.ChooseLockGeneric; import com.android.settings.password.ChooseLockPattern; import com.android.settings.password.ChooseLockSettingsHelper; @@ -215,11 +216,16 @@ public class BiometricEnrollActivity extends InstrumentedActivity { mIsFaceEnrollable = faceManager.getEnrolledFaces(mUserId).size() < maxEnrolls; - // exclude face enrollment from setup wizard if configured as a convenience - // isSetupWizard is always false for unicorn enrollment, so if consent is - // required check if setup has completed instead. - final boolean isSettingUp = isSetupWizard || (mParentalOptionsRequired + final boolean parentalConsent = isSetupWizard || (mParentalOptionsRequired && !WizardManagerHelper.isUserSetupComplete(this)); + if (parentalConsent && isMultiSensor && mIsFaceEnrollable) { + // Exclude face enrollment from setup wizard if feature config not supported + // in setup wizard flow, we still allow user enroll faces through settings. + mIsFaceEnrollable = FeatureFactory.getFactory(getApplicationContext()) + .getFaceFeatureProvider() + .isSetupWizardSupported(getApplicationContext()); + Log.d(TAG, "config_suw_support_face_enroll: " + mIsFaceEnrollable); + } } } if (mHasFeatureFingerprint) { diff --git a/src/com/android/settings/biometrics/BiometricEnrollBase.java b/src/com/android/settings/biometrics/BiometricEnrollBase.java index eea1bad91a9..2f852f08b9f 100644 --- a/src/com/android/settings/biometrics/BiometricEnrollBase.java +++ b/src/com/android/settings/biometrics/BiometricEnrollBase.java @@ -38,7 +38,10 @@ import com.android.settings.SetupWizardUtils; import com.android.settings.Utils; import com.android.settings.biometrics.fingerprint.FingerprintEnrollEnrolling; import com.android.settings.core.InstrumentedActivity; +import com.android.settings.overlay.FeatureFactory; import com.android.settings.password.ChooseLockSettingsHelper; +import com.android.systemui.unfold.compat.ScreenSizeFoldProvider; +import com.android.systemui.unfold.updates.FoldProvider; import com.google.android.setupcompat.template.FooterBarMixin; import com.google.android.setupcompat.template.FooterButton; @@ -60,8 +63,10 @@ public abstract class BiometricEnrollBase extends InstrumentedActivity { public static final String EXTRA_KEY_SENSOR_ID = "sensor_id"; public static final String EXTRA_KEY_CHALLENGE = "challenge"; public static final String EXTRA_KEY_MODALITY = "sensor_modality"; + public static final String EXTRA_KEY_NEXT_LAUNCHED = "next_launched"; public static final String EXTRA_FINISHED_ENROLL_FACE = "finished_enrolling_face"; public static final String EXTRA_FINISHED_ENROLL_FINGERPRINT = "finished_enrolling_fingerprint"; + public static final String EXTRA_LAUNCHED_POSTURE_GUIDANCE = "launched_posture_guidance"; /** * Used by the choose fingerprint wizard to indicate the wizard is @@ -115,14 +120,25 @@ public abstract class BiometricEnrollBase extends InstrumentedActivity { * example, when starting fingerprint enroll after face enroll. */ public static final int ENROLL_NEXT_BIOMETRIC_REQUEST = 6; + public static final int REQUEST_POSTURE_GUIDANCE = 7; protected boolean mLaunchedConfirmLock; + protected boolean mLaunchedPostureGuidance; + protected boolean mNextLaunched; protected byte[] mToken; protected int mUserId; protected int mSensorId; + @BiometricUtils.DevicePostureInt + protected int mDevicePostureState; protected long mChallenge; protected boolean mFromSettingsSummary; protected FooterBarMixin mFooterBarMixin; + @Nullable + protected ScreenSizeFoldProvider mScreenSizeFoldProvider; + @Nullable + protected Intent mPostureGuidanceIntent = null; + @Nullable + protected FoldProvider.FoldCallback mFoldCallback = null; @Override protected void onCreate(Bundle savedInstanceState) { @@ -139,16 +155,23 @@ public abstract class BiometricEnrollBase extends InstrumentedActivity { ChooseLockSettingsHelper.EXTRA_KEY_CHALLENGE_TOKEN); } mFromSettingsSummary = getIntent().getBooleanExtra(EXTRA_FROM_SETTINGS_SUMMARY, false); - if (savedInstanceState != null && mToken == null) { - mLaunchedConfirmLock = savedInstanceState.getBoolean(EXTRA_KEY_LAUNCHED_CONFIRM); - mToken = savedInstanceState.getByteArray( - ChooseLockSettingsHelper.EXTRA_KEY_CHALLENGE_TOKEN); - mFromSettingsSummary = - savedInstanceState.getBoolean(EXTRA_FROM_SETTINGS_SUMMARY, false); - mChallenge = savedInstanceState.getLong(EXTRA_KEY_CHALLENGE); - mSensorId = savedInstanceState.getInt(EXTRA_KEY_SENSOR_ID); + if (savedInstanceState != null) { + if (mToken == null) { + mLaunchedConfirmLock = savedInstanceState.getBoolean(EXTRA_KEY_LAUNCHED_CONFIRM); + mToken = savedInstanceState.getByteArray( + ChooseLockSettingsHelper.EXTRA_KEY_CHALLENGE_TOKEN); + mFromSettingsSummary = + savedInstanceState.getBoolean(EXTRA_FROM_SETTINGS_SUMMARY, false); + mChallenge = savedInstanceState.getLong(EXTRA_KEY_CHALLENGE); + mSensorId = savedInstanceState.getInt(EXTRA_KEY_SENSOR_ID); + } + mLaunchedPostureGuidance = savedInstanceState.getBoolean( + EXTRA_LAUNCHED_POSTURE_GUIDANCE); + mNextLaunched = savedInstanceState.getBoolean(EXTRA_KEY_NEXT_LAUNCHED); } mUserId = getIntent().getIntExtra(Intent.EXTRA_USER_ID, UserHandle.myUserId()); + mPostureGuidanceIntent = FeatureFactory.getFactory(getApplicationContext()) + .getFaceFeatureProvider().getPostureGuidanceIntent(getApplicationContext()); } @Override @@ -159,6 +182,8 @@ public abstract class BiometricEnrollBase extends InstrumentedActivity { outState.putBoolean(EXTRA_FROM_SETTINGS_SUMMARY, mFromSettingsSummary); outState.putLong(EXTRA_KEY_CHALLENGE, mChallenge); outState.putInt(EXTRA_KEY_SENSOR_ID, mSensorId); + outState.putBoolean(EXTRA_LAUNCHED_POSTURE_GUIDANCE, mLaunchedPostureGuidance); + outState.putBoolean(EXTRA_KEY_NEXT_LAUNCHED, mNextLaunched); } @Override @@ -184,6 +209,12 @@ public abstract class BiometricEnrollBase extends InstrumentedActivity { @Override protected void onStop() { super.onStop(); + if (mScreenSizeFoldProvider != null && mFoldCallback != null) { + mScreenSizeFoldProvider.unregisterCallback(mFoldCallback); + } + mScreenSizeFoldProvider = null; + mFoldCallback = null; + if (!isChangingConfigurations() && shouldFinishWhenBackgrounded() && !BiometricUtils.isAnyMultiBiometricFlow(this)) { setResult(RESULT_TIMEOUT); @@ -191,6 +222,17 @@ public abstract class BiometricEnrollBase extends InstrumentedActivity { } } + protected boolean launchPostureGuidance() { + if (mPostureGuidanceIntent == null || mLaunchedPostureGuidance) { + return false; + } + BiometricUtils.copyMultiBiometricExtras(getIntent(), mPostureGuidanceIntent); + startActivityForResult(mPostureGuidanceIntent, REQUEST_POSTURE_GUIDANCE); + mLaunchedPostureGuidance = true; + overridePendingTransition(0 /* no enter anim */, 0 /* no exit anim */); + return mLaunchedPostureGuidance; + } + protected boolean shouldFinishWhenBackgrounded() { return !WizardManagerHelper.isAnySetupWizard(getIntent()); } diff --git a/src/com/android/settings/biometrics/BiometricEnrollIntroduction.java b/src/com/android/settings/biometrics/BiometricEnrollIntroduction.java index acfe5a10255..730e0496774 100644 --- a/src/com/android/settings/biometrics/BiometricEnrollIntroduction.java +++ b/src/com/android/settings/biometrics/BiometricEnrollIntroduction.java @@ -155,6 +155,8 @@ public abstract class BiometricEnrollIntroduction extends BiometricEnrollBase if (savedInstanceState != null) { mConfirmingCredentials = savedInstanceState.getBoolean(KEY_CONFIRMING_CREDENTIALS); mHasScrolledToBottom = savedInstanceState.getBoolean(KEY_SCROLLED_TO_BOTTOM); + mLaunchedPostureGuidance = savedInstanceState.getBoolean( + EXTRA_LAUNCHED_POSTURE_GUIDANCE); } Intent intent = getIntent(); @@ -273,6 +275,7 @@ public abstract class BiometricEnrollIntroduction extends BiometricEnrollBase finish(); } } + mNextLaunched = true; } private void launchChooseLock() { diff --git a/src/com/android/settings/biometrics/BiometricUtils.java b/src/com/android/settings/biometrics/BiometricUtils.java index 9cc656cda23..733308325f6 100644 --- a/src/com/android/settings/biometrics/BiometricUtils.java +++ b/src/com/android/settings/biometrics/BiometricUtils.java @@ -16,6 +16,7 @@ package com.android.settings.biometrics; +import android.annotation.IntDef; import android.app.Activity; import android.app.PendingIntent; import android.app.admin.DevicePolicyManager; @@ -47,17 +48,53 @@ import com.android.settings.password.SetupChooseLockGeneric; import com.google.android.setupcompat.util.WizardManagerHelper; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + /** * Common biometric utilities. */ public class BiometricUtils { private static final String TAG = "BiometricUtils"; + // Note: Theis IntDef must align SystemUI DevicePostureInt + @IntDef(prefix = {"DEVICE_POSTURE_"}, value = { + DEVICE_POSTURE_UNKNOWN, + DEVICE_POSTURE_CLOSED, + DEVICE_POSTURE_HALF_OPENED, + DEVICE_POSTURE_OPENED, + DEVICE_POSTURE_FLIPPED + }) + @Retention(RetentionPolicy.SOURCE) + public @interface DevicePostureInt {} + + // NOTE: These constants **must** match those defined for Jetpack Sidecar. This is because we + // use the Device State -> Jetpack Posture map in DevicePostureControllerImpl to translate + // between the two. + public static final int DEVICE_POSTURE_UNKNOWN = 0; + public static final int DEVICE_POSTURE_CLOSED = 1; + public static final int DEVICE_POSTURE_HALF_OPENED = 2; + public static final int DEVICE_POSTURE_OPENED = 3; + public static final int DEVICE_POSTURE_FLIPPED = 4; + + public static int sAllowEnrollPosture = DEVICE_POSTURE_UNKNOWN; + /** * Request was sent for starting another enrollment of a previously * enrolled biometric of the same type. */ public static int REQUEST_ADD_ANOTHER = 7; + + /** + * Gatekeeper credential not match exception, it throws if VerifyCredentialResponse is not + * matched in requestGatekeeperHat(). + */ + public static class GatekeeperCredentialNotMatchException extends IllegalStateException { + public GatekeeperCredentialNotMatchException(String s) { + super(s); + } + }; + /** * Given the result from confirming or choosing a credential, request Gatekeeper to generate * a HardwareAuthToken with the Gatekeeper Password together with a biometric challenge. @@ -67,6 +104,8 @@ public class BiometricUtils { * @param userId User ID that the credential/biometric operation applies to * @param challenge Unique biometric challenge from FingerprintManager/FaceManager * @return + * @throws GatekeeperCredentialNotMatchException if Gatekeeper response is not match + * @throws IllegalStateException if Gatekeeper Password is missing */ public static byte[] requestGatekeeperHat(@NonNull Context context, @NonNull Intent result, int userId, long challenge) { @@ -84,7 +123,7 @@ public class BiometricUtils { final VerifyCredentialResponse response = utils.verifyGatekeeperPasswordHandle(gkPwHandle, challenge, userId); if (!response.isMatched()) { - throw new IllegalStateException("Unable to request Gatekeeper HAT"); + throw new GatekeeperCredentialNotMatchException("Unable to request Gatekeeper HAT"); } return response.getGatekeeperHAT(); } @@ -270,6 +309,51 @@ public class BiometricUtils { || isMultiBiometricFingerprintEnrollmentFlow(activity); } + /** + * Used to check if the activity is showing a posture guidance to user. + * + * @param devicePosture the device posture state + * @param isLaunchedPostureGuidance True launching a posture guidance to user + * @return True if the activity is showing posture guidance to user + */ + public static boolean isPostureGuidanceShowing(@DevicePostureInt int devicePosture, + boolean isLaunchedPostureGuidance) { + return !isPostureAllowEnrollment(devicePosture) && isLaunchedPostureGuidance; + } + + /** + * Used to check if current device posture state is allow to enroll biometrics. + * For compatibility, we don't restrict enrollment if device do not config. + * + * @param devicePosture True if current device posture allow enrollment + * @return True if current device posture state allow enrollment + */ + public static boolean isPostureAllowEnrollment(@DevicePostureInt int devicePosture) { + return (sAllowEnrollPosture == DEVICE_POSTURE_UNKNOWN) + || (devicePosture == sAllowEnrollPosture); + } + + /** + * Used to check if the activity should show a posture guidance to user. + * + * @param devicePosture the device posture state + * @param isLaunchedPostureGuidance True launching a posture guidance to user + * @return True if posture disallow enroll and posture guidance not showing, false otherwise. + */ + public static boolean shouldShowPostureGuidance(@DevicePostureInt int devicePosture, + boolean isLaunchedPostureGuidance) { + return !isPostureAllowEnrollment(devicePosture) && !isLaunchedPostureGuidance; + } + + /** + * Sets allowed device posture for face enrollment. + * + * @param devicePosture the allowed posture state {@link DevicePostureInt} for enrollment + */ + public static void setDevicePosturesAllowEnroll(@DevicePostureInt int devicePosture) { + sAllowEnrollPosture = devicePosture; + } + public static void copyMultiBiometricExtras(@NonNull Intent fromIntent, @NonNull Intent toIntent) { PendingIntent pendingIntent = (PendingIntent) fromIntent.getExtra( diff --git a/src/com/android/settings/biometrics/face/FaceEnrollEducation.java b/src/com/android/settings/biometrics/face/FaceEnrollEducation.java index d2d356b1104..4ef47522fa8 100644 --- a/src/com/android/settings/biometrics/face/FaceEnrollEducation.java +++ b/src/com/android/settings/biometrics/face/FaceEnrollEducation.java @@ -16,24 +16,35 @@ package com.android.settings.biometrics.face; +import static com.android.settings.biometrics.BiometricUtils.isPostureAllowEnrollment; +import static com.android.settings.biometrics.BiometricUtils.isPostureGuidanceShowing; + import android.app.settings.SettingsEnums; import android.content.ComponentName; import android.content.Intent; +import android.content.res.Configuration; import android.hardware.face.FaceManager; import android.os.Bundle; import android.os.UserHandle; import android.text.TextUtils; +import android.util.Log; import android.view.View; import android.view.accessibility.AccessibilityManager; import android.widget.Button; import android.widget.CompoundButton; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; + import com.android.settings.R; import com.android.settings.Utils; import com.android.settings.biometrics.BiometricEnrollBase; import com.android.settings.biometrics.BiometricUtils; import com.android.settings.password.ChooseLockSettingsHelper; import com.android.settings.password.SetupSkipDialog; +import com.android.systemui.unfold.compat.ScreenSizeFoldProvider; +import com.android.systemui.unfold.updates.FoldProvider; import com.airbnb.lottie.LottieAnimationView; import com.google.android.setupcompat.template.FooterBarMixin; @@ -41,18 +52,19 @@ import com.google.android.setupcompat.template.FooterButton; import com.google.android.setupcompat.util.WizardManagerHelper; import com.google.android.setupdesign.view.IllustrationVideoView; +/** + * Provides animated education for users to know how to enroll a face with appropriate posture. + */ public class FaceEnrollEducation extends BiometricEnrollBase { private static final String TAG = "FaceEducation"; private FaceManager mFaceManager; private FaceEnrollAccessibilityToggle mSwitchDiversity; - private boolean mIsUsingLottie; private IllustrationVideoView mIllustrationDefault; private LottieAnimationView mIllustrationLottie; private View mIllustrationAccessibility; private Intent mResultIntent; - private boolean mNextClicked; private boolean mAccessibilityEnabled; private final CompoundButton.OnCheckedChangeListener mSwitchDiversityListener = @@ -154,6 +166,34 @@ public class FaceEnrollEducation extends BiometricEnrollBase { } } + @Override + protected void onStart() { + super.onStart(); + if (getPostureGuidanceIntent() == null) { + Log.d(TAG, "Device do not support posture guidance"); + return; + } + + BiometricUtils.setDevicePosturesAllowEnroll( + getResources().getInteger(R.integer.config_face_enroll_supported_posture)); + + if (getPostureCallback() == null) { + mFoldCallback = isFolded -> { + mDevicePostureState = isFolded ? BiometricUtils.DEVICE_POSTURE_CLOSED + : BiometricUtils.DEVICE_POSTURE_OPENED; + if (BiometricUtils.shouldShowPostureGuidance(mDevicePostureState, + mLaunchedPostureGuidance) && !mNextLaunched) { + launchPostureGuidance(); + } + }; + } + + if (mScreenSizeFoldProvider == null) { + mScreenSizeFoldProvider = new ScreenSizeFoldProvider(getApplicationContext()); + mScreenSizeFoldProvider.registerCallback(mFoldCallback, getMainExecutor()); + } + } + @Override protected void onResume() { super.onResume(); @@ -172,7 +212,8 @@ public class FaceEnrollEducation extends BiometricEnrollBase { @Override protected boolean shouldFinishWhenBackgrounded() { - return super.shouldFinishWhenBackgrounded() && !mNextClicked; + return super.shouldFinishWhenBackgrounded() && !mNextLaunched + && !isPostureGuidanceShowing(mDevicePostureState, mLaunchedPostureGuidance); } @Override @@ -206,13 +247,14 @@ public class FaceEnrollEducation extends BiometricEnrollBase { FaceEnrollAccessibilityDialog dialog = FaceEnrollAccessibilityDialog.newInstance(); dialog.setPositiveButtonListener((dialog1, which) -> { startActivityForResult(intent, BIOMETRIC_FIND_SENSOR_REQUEST); - mNextClicked = true; + mNextLaunched = true; }); dialog.show(getSupportFragmentManager(), FaceEnrollAccessibilityDialog.class.getName()); } else { startActivityForResult(intent, BIOMETRIC_FIND_SENSOR_REQUEST); - mNextClicked = true; + mNextLaunched = true; } + } protected void onSkipButtonClick(View view) { @@ -223,15 +265,29 @@ public class FaceEnrollEducation extends BiometricEnrollBase { } } + @Override + public void onConfigurationChanged(@NonNull Configuration newConfig) { + super.onConfigurationChanged(newConfig); + if (mScreenSizeFoldProvider != null && getPostureCallback() != null) { + mScreenSizeFoldProvider.onConfigurationChange(newConfig); + } + } + @Override protected void onActivityResult(int requestCode, int resultCode, Intent data) { - super.onActivityResult(requestCode, resultCode, data); + if (requestCode == REQUEST_POSTURE_GUIDANCE) { + mLaunchedPostureGuidance = false; + if (resultCode == RESULT_CANCELED || resultCode == RESULT_SKIP) { + onSkipButtonClick(getCurrentFocus()); + } + return; + } mResultIntent = data; boolean hasEnrolledFace = false; if (data != null) { hasEnrolledFace = data.getBooleanExtra(EXTRA_FINISHED_ENROLL_FACE, false); } - if (resultCode == RESULT_TIMEOUT) { + if (resultCode == RESULT_TIMEOUT || !isPostureAllowEnrollment(mDevicePostureState)) { setResult(resultCode, data); finish(); } else if (requestCode == BIOMETRIC_FIND_SENSOR_REQUEST @@ -243,6 +299,26 @@ public class FaceEnrollEducation extends BiometricEnrollBase { finish(); } } + mNextLaunched = false; + super.onActivityResult(requestCode, resultCode, data); + } + + @VisibleForTesting + @Nullable + protected Intent getPostureGuidanceIntent() { + return mPostureGuidanceIntent; + } + + @VisibleForTesting + @Nullable + protected FoldProvider.FoldCallback getPostureCallback() { + return mFoldCallback; + } + + @VisibleForTesting + @BiometricUtils.DevicePostureInt + protected int getDevicePostureState() { + return mDevicePostureState; } @Override @@ -262,8 +338,10 @@ public class FaceEnrollEducation extends BiometricEnrollBase { private void showDefaultIllustration() { if (mIsUsingLottie) { + mIllustrationLottie.setAnimation(R.raw.face_education_lottie); mIllustrationLottie.setVisibility(View.VISIBLE); mIllustrationLottie.playAnimation(); + mIllustrationLottie.setProgress(0f); } else { mIllustrationDefault.setVisibility(View.VISIBLE); mIllustrationDefault.start(); diff --git a/src/com/android/settings/biometrics/face/FaceEnrollIntroduction.java b/src/com/android/settings/biometrics/face/FaceEnrollIntroduction.java index ed74d2a550d..a1233086926 100644 --- a/src/com/android/settings/biometrics/face/FaceEnrollIntroduction.java +++ b/src/com/android/settings/biometrics/face/FaceEnrollIntroduction.java @@ -18,9 +18,12 @@ package com.android.settings.biometrics.face; import static android.app.admin.DevicePolicyResources.Strings.Settings.FACE_UNLOCK_DISABLED; +import static com.android.settings.biometrics.BiometricUtils.GatekeeperCredentialNotMatchException; + import android.app.admin.DevicePolicyManager; import android.app.settings.SettingsEnums; import android.content.Intent; +import android.content.res.Configuration; import android.hardware.SensorPrivacyManager; import android.hardware.biometrics.BiometricAuthenticator; import android.hardware.face.FaceManager; @@ -36,6 +39,7 @@ import android.widget.TextView; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.StringRes; +import androidx.annotation.VisibleForTesting; import com.android.settings.R; import com.android.settings.Utils; @@ -43,11 +47,12 @@ import com.android.settings.biometrics.BiometricEnrollActivity; import com.android.settings.biometrics.BiometricEnrollIntroduction; import com.android.settings.biometrics.BiometricUtils; import com.android.settings.biometrics.MultiBiometricEnrollHelper; -import com.android.settings.overlay.FeatureFactory; import com.android.settings.password.ChooseLockSettingsHelper; import com.android.settings.password.SetupSkipDialog; import com.android.settings.utils.SensorPrivacyManagerHelper; import com.android.settingslib.RestrictedLockUtilsInternal; +import com.android.systemui.unfold.compat.ScreenSizeFoldProvider; +import com.android.systemui.unfold.updates.FoldProvider; import com.google.android.setupcompat.template.FooterButton; import com.google.android.setupcompat.util.WizardManagerHelper; @@ -61,7 +66,6 @@ public class FaceEnrollIntroduction extends BiometricEnrollIntroduction { private static final String TAG = "FaceEnrollIntroduction"; private FaceManager mFaceManager; - private FaceFeatureProvider mFaceFeatureProvider; @Nullable private FooterButton mPrimaryFooterButton; @Nullable private FooterButton mSecondaryFooterButton; @Nullable private SensorPrivacyManager mSensorPrivacyManager; @@ -98,6 +102,12 @@ public class FaceEnrollIntroduction extends BiometricEnrollIntroduction { } } + @Override + protected boolean shouldFinishWhenBackgrounded() { + return super.shouldFinishWhenBackgrounded() && !BiometricUtils.isPostureGuidanceShowing( + mDevicePostureState, mLaunchedPostureGuidance); + } + @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); @@ -142,9 +152,7 @@ public class FaceEnrollIntroduction extends BiometricEnrollIntroduction { infoMessageRequireEyes.setText(getInfoMessageRequireEyes()); } - mFaceManager = Utils.getFaceManagerOrNull(this); - mFaceFeatureProvider = FeatureFactory.getFactory(getApplicationContext()) - .getFaceFeatureProvider(); + mFaceManager = getFaceManager(); // This path is an entry point for SetNewPasswordController, e.g. // adb shell am start -a android.app.action.SET_NEW_PASSWORD @@ -154,11 +162,22 @@ public class FaceEnrollIntroduction extends BiometricEnrollIntroduction { // We either block on generateChallenge, or need to gray out the "next" button until // the challenge is ready. Let's just do this for now. mFaceManager.generateChallenge(mUserId, (sensorId, userId, challenge) -> { - mToken = BiometricUtils.requestGatekeeperHat(this, getIntent(), mUserId, - challenge); - mSensorId = sensorId; - mChallenge = challenge; - mFooterBarMixin.getPrimaryButton().setEnabled(true); + if (isFinishing()) { + // Do nothing if activity is finishing + Log.w(TAG, "activity finished before challenge callback launched."); + return; + } + + try { + mToken = requestGatekeeperHat(challenge); + mSensorId = sensorId; + mChallenge = challenge; + mFooterBarMixin.getPrimaryButton().setEnabled(true); + } catch (GatekeeperCredentialNotMatchException e) { + // Let BiometricEnrollBase#onCreate() to trigger confirmLock() + getIntent().removeExtra(ChooseLockSettingsHelper.EXTRA_KEY_GK_PW_HANDLE); + recreate(); + } }); } } @@ -172,8 +191,82 @@ public class FaceEnrollIntroduction extends BiometricEnrollIntroduction { Log.v(TAG, "cameraPrivacyEnabled : " + cameraPrivacyEnabled); } + @VisibleForTesting + @Nullable + protected FaceManager getFaceManager() { + return Utils.getFaceManagerOrNull(this); + } + + @VisibleForTesting + @Nullable + protected Intent getPostureGuidanceIntent() { + return mPostureGuidanceIntent; + } + + @VisibleForTesting + @Nullable + protected FoldProvider.FoldCallback getPostureCallback() { + return mFoldCallback; + } + + @VisibleForTesting + @BiometricUtils.DevicePostureInt + protected int getDevicePostureState() { + return mDevicePostureState; + } + + @VisibleForTesting + @Nullable + protected byte[] requestGatekeeperHat(long challenge) { + return BiometricUtils.requestGatekeeperHat(this, getIntent(), mUserId, challenge); + } + + @Override + public void onConfigurationChanged(@NonNull Configuration newConfig) { + super.onConfigurationChanged(newConfig); + if (mScreenSizeFoldProvider != null && getPostureCallback() != null) { + mScreenSizeFoldProvider.onConfigurationChange(newConfig); + } + } + + @Override + protected void onStart() { + super.onStart(); + if (getPostureGuidanceIntent() == null) { + Log.d(TAG, "Device do not support posture guidance"); + return; + } + + BiometricUtils.setDevicePosturesAllowEnroll( + getResources().getInteger(R.integer.config_face_enroll_supported_posture)); + + if (getPostureCallback() == null) { + mFoldCallback = isFolded -> { + mDevicePostureState = isFolded ? BiometricUtils.DEVICE_POSTURE_CLOSED + : BiometricUtils.DEVICE_POSTURE_OPENED; + if (BiometricUtils.shouldShowPostureGuidance(mDevicePostureState, + mLaunchedPostureGuidance) && !mNextLaunched) { + launchPostureGuidance(); + } + }; + } + + if (mScreenSizeFoldProvider == null) { + mScreenSizeFoldProvider = new ScreenSizeFoldProvider(getApplicationContext()); + mScreenSizeFoldProvider.registerCallback(mFoldCallback, getMainExecutor()); + } + } + @Override protected void onActivityResult(int requestCode, int resultCode, Intent data) { + if (requestCode == REQUEST_POSTURE_GUIDANCE) { + mLaunchedPostureGuidance = false; + if (resultCode == RESULT_CANCELED || resultCode == RESULT_SKIP) { + onSkipButtonClick(getCurrentFocus()); + } + return; + } + // If user has skipped or finished enrolling, don't restart enrollment. final boolean isEnrollRequest = requestCode == BIOMETRIC_FIND_SENSOR_REQUEST || requestCode == ENROLL_NEXT_BIOMETRIC_REQUEST; @@ -184,10 +277,12 @@ public class FaceEnrollIntroduction extends BiometricEnrollIntroduction { hasEnrolledFace = data.getBooleanExtra(EXTRA_FINISHED_ENROLL_FACE, false); } - if (resultCode == RESULT_CANCELED && hasEnrolledFace) { - setResult(resultCode, data); - finish(); - return; + if (resultCode == RESULT_CANCELED) { + if (hasEnrolledFace || !BiometricUtils.isPostureAllowEnrollment(mDevicePostureState)) { + setResult(resultCode, data); + finish(); + return; + } } if (isEnrollRequest && isResultSkipOrFinished || hasEnrolledFace) { diff --git a/src/com/android/settings/biometrics/face/FaceFeatureProvider.java b/src/com/android/settings/biometrics/face/FaceFeatureProvider.java index 26ea2615f1c..1a4fd90668e 100644 --- a/src/com/android/settings/biometrics/face/FaceFeatureProvider.java +++ b/src/com/android/settings/biometrics/face/FaceFeatureProvider.java @@ -17,9 +17,19 @@ package com.android.settings.biometrics.face; import android.content.Context; +import android.content.Intent; + +import androidx.annotation.Nullable; /** Feature provider for face unlock */ public interface FaceFeatureProvider { + /** Returns specified intent config by resource R.string.config_face_enroll_guidance_page. */ + @Nullable + Intent getPostureGuidanceIntent(Context context); + /** Returns true if attention checking is supported. */ boolean isAttentionSupported(Context context); + + /** Returns true if setup wizard supported face enrollment. */ + boolean isSetupWizardSupported(Context context); } diff --git a/src/com/android/settings/biometrics/face/FaceFeatureProviderImpl.java b/src/com/android/settings/biometrics/face/FaceFeatureProviderImpl.java index e5086007cde..8b7edce40fb 100644 --- a/src/com/android/settings/biometrics/face/FaceFeatureProviderImpl.java +++ b/src/com/android/settings/biometrics/face/FaceFeatureProviderImpl.java @@ -16,13 +16,48 @@ package com.android.settings.biometrics.face; +import android.content.ComponentName; import android.content.Context; -import android.provider.Settings; +import android.content.Intent; +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.android.settings.R; public class FaceFeatureProviderImpl implements FaceFeatureProvider { + /** + * Returns the guidance page intent if device support {@link FoldingFeature}, and we want to + * guide user enrolling faces with specific device posture. + * + * @param context the application context + * @return the posture guidance intent, otherwise null if device not support + */ + @Nullable + @Override + public Intent getPostureGuidanceIntent(Context context) { + final String flattenedString = context.getString(R.string.config_face_enroll_guidance_page); + final Intent intent; + if (!TextUtils.isEmpty(flattenedString)) { + ComponentName componentName = ComponentName.unflattenFromString(flattenedString); + if (componentName != null) { + intent = new Intent(); + intent.setComponent(componentName); + return intent; + } + } + return null; + } + @Override public boolean isAttentionSupported(Context context) { return true; } + + @Override + public boolean isSetupWizardSupported(@NonNull Context context) { + return true; + } } diff --git a/tests/robotests/src/com/android/settings/biometrics/face/FaceEnrollEducationTest.java b/tests/robotests/src/com/android/settings/biometrics/face/FaceEnrollEducationTest.java new file mode 100644 index 00000000000..b4ddddedacc --- /dev/null +++ b/tests/robotests/src/com/android/settings/biometrics/face/FaceEnrollEducationTest.java @@ -0,0 +1,225 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.settings.biometrics.face; + +import static android.util.DisplayMetrics.DENSITY_DEFAULT; +import static android.util.DisplayMetrics.DENSITY_XXXHIGH; + +import static com.android.settings.biometrics.BiometricEnrollBase.EXTRA_KEY_NEXT_LAUNCHED; +import static com.android.settings.biometrics.BiometricEnrollBase.EXTRA_LAUNCHED_POSTURE_GUIDANCE; +import static com.android.settings.biometrics.BiometricUtils.DEVICE_POSTURE_CLOSED; +import static com.android.settings.biometrics.BiometricUtils.DEVICE_POSTURE_OPENED; +import static com.android.settings.biometrics.BiometricUtils.DEVICE_POSTURE_UNKNOWN; + +import static com.google.common.truth.Truth.assertThat; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.when; + +import android.content.Context; +import android.content.Intent; +import android.content.res.Configuration; +import android.hardware.face.FaceManager; +import android.view.View; + +import androidx.test.core.app.ApplicationProvider; + +import com.android.settings.R; +import com.android.settings.password.ChooseLockSettingsHelper; +import com.android.settings.testutils.FakeFeatureFactory; +import com.android.settings.testutils.shadow.ShadowUtils; + +import com.google.android.setupcompat.template.FooterBarMixin; +import com.google.android.setupcompat.template.FooterButton; +import com.google.android.setupdesign.GlifLayout; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.robolectric.Robolectric; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.android.controller.ActivityController; +import org.robolectric.annotation.Config; + +@RunWith(RobolectricTestRunner.class) +@Config(shadows = {ShadowUtils.class}) +public class FaceEnrollEducationTest { + @Mock + private FaceManager mFaceManager; + + private Context mContext; + private ActivityController mActivityController; + private TestFaceEnrollEducation mActivity; + private FakeFeatureFactory mFakeFeatureFactory; + + public static class TestFaceEnrollEducation extends FaceEnrollEducation { + + @Override + protected boolean launchPostureGuidance() { + return super.launchPostureGuidance(); + } + } + + @Before + public void setUp() { + MockitoAnnotations.initMocks(this); + ShadowUtils.setFaceManager(mFaceManager); + mFakeFeatureFactory = FakeFeatureFactory.setupForTest(); + } + + @After + public void tearDown() { + ShadowUtils.reset(); + } + + private void setupActivityForPosture() { + final Intent testIntent = new Intent(); + // Set the challenge token so the confirm screen will not be shown + testIntent.putExtra(ChooseLockSettingsHelper.EXTRA_KEY_CHALLENGE_TOKEN, new byte[0]); + testIntent.putExtra(EXTRA_KEY_NEXT_LAUNCHED, false); + testIntent.putExtra(EXTRA_LAUNCHED_POSTURE_GUIDANCE, false); + + when(mFakeFeatureFactory.mFaceFeatureProvider.getPostureGuidanceIntent(any())).thenReturn( + testIntent); + mContext = spy(ApplicationProvider.getApplicationContext()); + mActivityController = Robolectric.buildActivity( + TestFaceEnrollEducation.class, testIntent); + mActivity = spy(mActivityController.create().get()); + + when(mContext.getSystemService(Context.FACE_SERVICE)).thenReturn(mFaceManager); + } + + private void setupActivity() { + final Intent testIntent = new Intent(); + // Set the challenge token so the confirm screen will not be shown + testIntent.putExtra(ChooseLockSettingsHelper.EXTRA_KEY_CHALLENGE_TOKEN, new byte[0]); + + when(mFakeFeatureFactory.mFaceFeatureProvider.getPostureGuidanceIntent(any())).thenReturn( + null /* Simulate no posture intent */); + mContext = spy(ApplicationProvider.getApplicationContext()); + mActivityController = Robolectric.buildActivity( + TestFaceEnrollEducation.class, testIntent); + mActivity = spy(mActivityController.create().get()); + + when(mContext.getSystemService(Context.FACE_SERVICE)).thenReturn(mFaceManager); + } + + private GlifLayout getGlifLayout() { + return mActivity.findViewById(R.id.setup_wizard_layout); + } + + @Test + public void testFaceEnrollEducation_hasHeader() { + setupActivity(); + CharSequence headerText = getGlifLayout().getHeaderText(); + + assertThat(headerText.toString()).isEqualTo( + mContext.getString(R.string.security_settings_face_enroll_education_title)); + } + + @Test + public void testFaceEnrollEducation_hasDescription() { + setupActivity(); + CharSequence desc = getGlifLayout().getDescriptionText(); + + assertThat(desc.toString()).isEqualTo( + mContext.getString(R.string.security_settings_face_enroll_education_message)); + } + + @Test + public void testFaceEnrollEducation_showFooterPrimaryButton() { + setupActivity(); + FooterBarMixin footer = getGlifLayout().getMixin(FooterBarMixin.class); + FooterButton footerButton = footer.getPrimaryButton(); + + assertThat(footerButton.getVisibility()).isEqualTo(View.VISIBLE); + assertThat(footerButton.getText().toString()).isEqualTo( + mContext.getString(R.string.security_settings_face_enroll_education_start)); + } + + @Test + public void testFaceEnrollEducation_showFooterSecondaryButton() { + setupActivity(); + FooterBarMixin footer = getGlifLayout().getMixin(FooterBarMixin.class); + FooterButton footerButton = footer.getSecondaryButton(); + + assertThat(footerButton.getVisibility()).isEqualTo(View.VISIBLE); + assertThat(footerButton.getText().toString()).isEqualTo(mContext.getString( + R.string.security_settings_face_enroll_introduction_cancel)); + } + + @Test + public void testFaceEnrollEducation_defaultNeverLaunchPostureGuidance() { + setupActivity(); + + assertThat(mActivity.launchPostureGuidance()).isFalse(); + assertThat(mActivity.getDevicePostureState()).isEqualTo(DEVICE_POSTURE_UNKNOWN); + } + + @Test + public void testFaceEnrollEducation_onStartNeverRegisterPostureChangeCallback() { + setupActivity(); + mActivity.onStart(); + + assertThat(mActivity.getPostureGuidanceIntent()).isNull(); + assertThat(mActivity.getPostureCallback()).isNull(); + assertThat(mActivity.getDevicePostureState()).isEqualTo(DEVICE_POSTURE_UNKNOWN); + } + + @Test + public void testFaceEnrollEducationWithPosture_onStartRegisteredPostureChangeCallback() { + setupActivityForPosture(); + mActivity.onStart(); + + assertThat(mActivity.getPostureGuidanceIntent()).isNotNull(); + assertThat(mActivity.getPostureCallback()).isNotNull(); + } + + @Test + public void testFaceEnrollEducationWithPosture_onFoldedUpdated_unFolded() { + final Configuration newConfig = new Configuration(); + newConfig.smallestScreenWidthDp = DENSITY_XXXHIGH; + setupActivityForPosture(); + mActivity.onStart(); + + assertThat(mActivity.getPostureGuidanceIntent()).isNotNull(); + assertThat(mActivity.getPostureCallback()).isNotNull(); + + mActivity.onConfigurationChanged(newConfig); + + assertThat(mActivity.getDevicePostureState()).isEqualTo(DEVICE_POSTURE_OPENED); + } + + @Test + public void testFaceEnrollEducationWithPosture_onFoldedUpdated_folded() { + final Configuration newConfig = new Configuration(); + newConfig.smallestScreenWidthDp = DENSITY_DEFAULT; + setupActivityForPosture(); + mActivity.onStart(); + + assertThat(mActivity.getPostureGuidanceIntent()).isNotNull(); + assertThat(mActivity.getPostureCallback()).isNotNull(); + + mActivity.onConfigurationChanged(newConfig); + + assertThat(mActivity.getDevicePostureState()).isEqualTo(DEVICE_POSTURE_CLOSED); + } +} diff --git a/tests/robotests/src/com/android/settings/biometrics/face/FaceEnrollIntroductionTest.java b/tests/robotests/src/com/android/settings/biometrics/face/FaceEnrollIntroductionTest.java new file mode 100644 index 00000000000..6c04add9261 --- /dev/null +++ b/tests/robotests/src/com/android/settings/biometrics/face/FaceEnrollIntroductionTest.java @@ -0,0 +1,403 @@ +/* + * Copyright (C) 2022 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.face; + +import static android.app.admin.DevicePolicyManager.PASSWORD_QUALITY_NUMERIC; +import static android.util.DisplayMetrics.DENSITY_DEFAULT; +import static android.util.DisplayMetrics.DENSITY_XXXHIGH; + +import static com.android.settings.biometrics.BiometricEnrollBase.EXTRA_KEY_NEXT_LAUNCHED; +import static com.android.settings.biometrics.BiometricEnrollBase.EXTRA_LAUNCHED_POSTURE_GUIDANCE; +import static com.android.settings.biometrics.BiometricUtils.DEVICE_POSTURE_CLOSED; +import static com.android.settings.biometrics.BiometricUtils.DEVICE_POSTURE_OPENED; +import static com.android.settings.biometrics.BiometricUtils.DEVICE_POSTURE_UNKNOWN; + +import static com.google.common.truth.Truth.assertThat; +import static com.google.common.truth.Truth.assertWithMessage; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.when; + +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.content.res.Configuration; +import android.hardware.face.Face; +import android.hardware.face.FaceManager; +import android.os.UserHandle; +import android.view.View; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.test.core.app.ApplicationProvider; + +import com.android.internal.widget.LockPatternUtils; +import com.android.settings.R; +import com.android.settings.biometrics.BiometricUtils; +import com.android.settings.password.ChooseLockSettingsHelper; +import com.android.settings.testutils.FakeFeatureFactory; +import com.android.settings.testutils.shadow.ShadowDevicePolicyManager; +import com.android.settings.testutils.shadow.ShadowLockPatternUtils; +import com.android.settings.testutils.shadow.ShadowSensorPrivacyManager; +import com.android.settings.testutils.shadow.ShadowUserManager; +import com.android.settings.testutils.shadow.ShadowUtils; + +import com.google.android.setupcompat.template.FooterBarMixin; +import com.google.android.setupcompat.template.FooterButton; +import com.google.android.setupdesign.GlifLayout; +import com.google.android.setupdesign.view.BottomScrollView; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; +import org.robolectric.Robolectric; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.Shadows; +import org.robolectric.android.controller.ActivityController; +import org.robolectric.annotation.Config; +import org.robolectric.shadows.ShadowActivity; + +import java.util.ArrayList; +import java.util.List; + +@RunWith(RobolectricTestRunner.class) +@Config(shadows = { + ShadowLockPatternUtils.class, + ShadowUserManager.class, + ShadowUtils.class, + ShadowDevicePolicyManager.class, + ShadowSensorPrivacyManager.class +}) +public class FaceEnrollIntroductionTest { + + @Mock + private FaceManager mFaceManager; + @Mock + private LockPatternUtils mLockPatternUtils; + + private Context mContext; + private ActivityController mController; + private TestFaceEnrollIntroduction mActivity; + private FaceEnrollIntroduction mSpyActivity; + private FakeFeatureFactory mFakeFeatureFactory; + private ShadowUserManager mUserManager; + + enum GateKeeperAction { CALL_SUPER, RETURN_BYTE_ARRAY, THROW_CREDENTIAL_NOT_MATCH } + + public static class TestFaceEnrollIntroduction extends FaceEnrollIntroduction { + + private int mRecreateCount = 0; + + public int getRecreateCount() { + return mRecreateCount; + } + + @Override + public void recreate() { + mRecreateCount++; + // Do nothing + } + + public boolean getConfirmingCredentials() { + return mConfirmingCredentials; + } + + public FaceManager mOverrideFaceManager = null; + @NonNull + public GateKeeperAction mGateKeeperAction = GateKeeperAction.CALL_SUPER; + + @Nullable + @Override + public byte[] requestGatekeeperHat(long challenge) { + switch (mGateKeeperAction) { + case RETURN_BYTE_ARRAY: + return new byte[]{1}; + case THROW_CREDENTIAL_NOT_MATCH: + throw new BiometricUtils.GatekeeperCredentialNotMatchException("test"); + case CALL_SUPER: + default: + return super.requestGatekeeperHat(challenge); + } + } + + @Nullable + @Override + protected FaceManager getFaceManager() { + return mOverrideFaceManager; + } + + @Override + protected boolean launchPostureGuidance() { + return super.launchPostureGuidance(); + } + } + + @Before + public void setUp() { + MockitoAnnotations.initMocks(this); + ShadowUtils.setFaceManager(mFaceManager); + mUserManager = ShadowUserManager.getShadow(); + mFakeFeatureFactory = FakeFeatureFactory.setupForTest(); + + when(mFakeFeatureFactory.securityFeatureProvider.getLockPatternUtils(any(Context.class))) + .thenReturn(mLockPatternUtils); + } + + @After + public void tearDown() { + ShadowUtils.reset(); + } + + private void setupActivity() { + final Intent testIntent = new Intent(); + // Set the challenge token so the confirm screen will not be shown + testIntent.putExtra(ChooseLockSettingsHelper.EXTRA_KEY_CHALLENGE_TOKEN, new byte[0]); + + when(mFakeFeatureFactory.mFaceFeatureProvider.getPostureGuidanceIntent(any())).thenReturn( + null /* Simulate no posture intent */); + mContext = spy(ApplicationProvider.getApplicationContext()); + mUserManager.addUserProfile(new UserHandle(0)); + mController = Robolectric.buildActivity( + TestFaceEnrollIntroduction.class, testIntent); + mActivity = (TestFaceEnrollIntroduction) spy(mController.get()); + mActivity.mOverrideFaceManager = mFaceManager; + when(mActivity.getPostureGuidanceIntent()).thenReturn(null); + when(mContext.getApplicationContext()).thenReturn(mContext); + when(mContext.getSystemService(Context.FACE_SERVICE)).thenReturn(mFaceManager); + when(mContext.getSystemService(Context.USER_SERVICE)).thenReturn(mUserManager); + when(mLockPatternUtils.getActivePasswordQuality(Mockito.anyInt())).thenReturn( + PASSWORD_QUALITY_NUMERIC); + + mController.create(); + } + + private void setupActivityForPosture() { + final Intent testIntent = new Intent(); + // Set the challenge token so the confirm screen will not be shown + testIntent.putExtra(ChooseLockSettingsHelper.EXTRA_KEY_CHALLENGE_TOKEN, new byte[0]); + testIntent.putExtra(EXTRA_KEY_NEXT_LAUNCHED, false); + testIntent.putExtra(EXTRA_LAUNCHED_POSTURE_GUIDANCE, false); + + when(mFakeFeatureFactory.mFaceFeatureProvider.getPostureGuidanceIntent(any())).thenReturn( + testIntent); + + mContext = spy(ApplicationProvider.getApplicationContext()); + mUserManager.addUserProfile(new UserHandle(0)); + mController = Robolectric.buildActivity(TestFaceEnrollIntroduction.class, testIntent); + mSpyActivity = (FaceEnrollIntroduction) spy(mController.get()); + when(mSpyActivity.getPostureGuidanceIntent()).thenReturn(testIntent); + when(mContext.getSystemService(Context.FACE_SERVICE)).thenReturn(mFaceManager); + when(mContext.getSystemService(Context.USER_SERVICE)).thenReturn(mUserManager); + when(mLockPatternUtils.getActivePasswordQuality(Mockito.anyInt())).thenReturn( + PASSWORD_QUALITY_NUMERIC); + + mController.create(); + } + + private void setupActivityWithGenerateChallenge(@NonNull Intent intent) { + doAnswer(invocation -> { + final FaceManager.GenerateChallengeCallback callback = + invocation.getArgument(1); + callback.onGenerateChallengeResult(0, 0, 1L); + return null; + }).when(mFaceManager).generateChallenge(anyInt(), any()); + mController = Robolectric.buildActivity(TestFaceEnrollIntroduction.class, intent); + mActivity = (TestFaceEnrollIntroduction) mController.get(); + mActivity.mOverrideFaceManager = mFaceManager; + } + + private GlifLayout getGlifLayout(Activity activity) { + return activity.findViewById(R.id.setup_wizard_layout); + } + + private void setFaceManagerToHave(int numEnrollments) { + List faces = new ArrayList<>(); + for (int i = 0; i < numEnrollments; i++) { + faces.add(new Face("Face " + i /* name */, 1 /*faceId */, 1 /* deviceId */)); + } + when(mFaceManager.getEnrolledFaces(anyInt())).thenReturn(faces); + } + + @Test + public void intro_CheckCanEnroll() { + setFaceManagerToHave(0 /* numEnrollments */); + setupActivityWithGenerateChallenge(new Intent()); + mController.create(); + int result = mActivity.checkMaxEnrolled(); + + assertThat(result).isEqualTo(0); + } + + @Test + public void intro_CheckMaxEnrolled() { + setFaceManagerToHave(1 /* numEnrollments */); + setupActivityWithGenerateChallenge(new Intent()); + mController.create(); + int result = mActivity.checkMaxEnrolled(); + + assertThat(result).isEqualTo(R.string.face_intro_error_max); + } + + @Test + public void testOnCreate() { + setupActivityWithGenerateChallenge(new Intent()); + mController.create(); + } + + @Test + public void testOnCreateToGenerateChallenge() { + setupActivityWithGenerateChallenge( + new Intent().putExtra(ChooseLockSettingsHelper.EXTRA_KEY_GK_PW_HANDLE, 1L)); + mActivity.mGateKeeperAction = GateKeeperAction.RETURN_BYTE_ARRAY; + mController.create(); + } + + @Test + public void testGenerateChallengeFailThenRecreate() { + setupActivityWithGenerateChallenge( + new Intent().putExtra(ChooseLockSettingsHelper.EXTRA_KEY_GK_PW_HANDLE, 1L)); + mActivity.mGateKeeperAction = GateKeeperAction.THROW_CREDENTIAL_NOT_MATCH; + mController.create(); + + // Make sure recreate() is called on original activity + assertThat(mActivity.getRecreateCount()).isEqualTo(1); + + // Simulate recreate() action + setupActivityWithGenerateChallenge(mActivity.getIntent()); + mController.create(); + + // Verify confirmLock() + assertThat(mActivity.getConfirmingCredentials()).isTrue(); + ShadowActivity shadowActivity = Shadows.shadowOf(mActivity); + ShadowActivity.IntentForResult startedActivity = + shadowActivity.getNextStartedActivityForResult(); + assertWithMessage("Next activity 1").that(startedActivity).isNotNull(); + } + + @Test + public void testFaceEnrollIntroduction_hasHeader() { + setupActivity(); + TextView headerTextView = getGlifLayout(mActivity).findViewById(R.id.suc_layout_title); + + assertThat(headerTextView).isNotNull(); + assertThat(headerTextView.getText().toString()).isNotEmpty(); + } + + @Test + public void testFaceEnrollIntroduction_hasDescription() { + setupActivity(); + CharSequence desc = getGlifLayout(mActivity).getDescriptionText(); + + assertThat(desc.toString()).isEqualTo( + mContext.getString(R.string.security_settings_face_enroll_introduction_message)); + } + + @Test + public void testFaceEnrollEducation_hasBottomScrollView() { + setupActivity(); + BottomScrollView scrollView = getGlifLayout(mActivity).findViewById(R.id.sud_scroll_view); + + assertThat(scrollView).isNotNull(); + assertThat(scrollView.getVisibility()).isEqualTo(View.VISIBLE); + } + + @Test + public void testFaceEnrollIntroduction_showFooterPrimaryButton() { + setupActivity(); + FooterBarMixin footer = getGlifLayout(mActivity).getMixin(FooterBarMixin.class); + FooterButton footerButton = footer.getPrimaryButton(); + + assertThat(footerButton).isNotNull(); + assertThat(footerButton.getVisibility()).isEqualTo(View.VISIBLE); + assertThat(footerButton.getText().toString()).isEqualTo( + mContext.getString(R.string.security_settings_face_enroll_introduction_agree)); + } + + @Test + public void testFaceEnrollIntroduction_notShowFooterSecondaryButton() { + setupActivity(); + FooterBarMixin footer = getGlifLayout(mActivity).getMixin(FooterBarMixin.class); + FooterButton footerButton = footer.getSecondaryButton(); + + assertThat(footerButton.getVisibility()).isEqualTo(View.INVISIBLE); + } + + @Test + public void testFaceEnrollIntroduction_defaultNeverLaunchPostureGuidance() { + setupActivity(); + + assertThat(mActivity.launchPostureGuidance()).isFalse(); + assertThat(mActivity.getDevicePostureState()).isEqualTo(DEVICE_POSTURE_UNKNOWN); + } + + @Test + public void testFaceEnrollIntroduction_onStartNeverRegisterPostureChangeCallback() { + setupActivity(); + mActivity.onStart(); + + assertThat(mActivity.getPostureGuidanceIntent()).isNull(); + assertThat(mActivity.getPostureCallback()).isNull(); + assertThat(mActivity.getDevicePostureState()).isEqualTo(DEVICE_POSTURE_UNKNOWN); + } + + @Test + public void testFaceEnrollIntroduction_onStartRegisteredPostureChangeCallback() { + setupActivityForPosture(); + mSpyActivity.onStart(); + + assertThat(mSpyActivity.getPostureGuidanceIntent()).isNotNull(); + assertThat(mSpyActivity.getPostureCallback()).isNotNull(); + } + + @Test + public void testFaceEnrollIntroduction_onFoldedUpdated_unFolded() { + final Configuration newConfig = new Configuration(); + newConfig.smallestScreenWidthDp = DENSITY_XXXHIGH; + setupActivityForPosture(); + mSpyActivity.onStart(); + + assertThat(mSpyActivity.getPostureGuidanceIntent()).isNotNull(); + assertThat(mSpyActivity.getPostureCallback()).isNotNull(); + + mSpyActivity.onConfigurationChanged(newConfig); + + assertThat(mSpyActivity.getDevicePostureState()).isEqualTo(DEVICE_POSTURE_OPENED); + } + + @Test + public void testFaceEnrollEducation_onFoldedUpdated_folded() { + final Configuration newConfig = new Configuration(); + newConfig.smallestScreenWidthDp = DENSITY_DEFAULT; + setupActivityForPosture(); + mSpyActivity.onStart(); + + assertThat(mSpyActivity.getPostureGuidanceIntent()).isNotNull(); + assertThat(mSpyActivity.getPostureCallback()).isNotNull(); + + mSpyActivity.onConfigurationChanged(newConfig); + + assertThat(mSpyActivity.getDevicePostureState()).isEqualTo(DEVICE_POSTURE_CLOSED); + } +}