Loading res/layout/face_enroll_education.xml +3 −2 Original line number Diff line number Diff line Loading @@ -57,8 +57,9 @@ android:layout_marginTop="@dimen/lottie_animation_view_margin_top" android:scaleType="centerInside" android:visibility="gone" app:lottie_autoPlay="true" app:lottie_loop="true" app:lottie_autoPlay="false" app:lottie_loop="false" android:contentDescription="@string/settingslib_illustration_content_description" app:lottie_speed="1.5" /> <ImageView Loading res/raw/face_education_lottie.json +13 −0 Original line number Diff line number Diff line { "v": "5.9.0", "fr": 60, "ip": 0, "op": 214, "w": 0, "h": 0, "nm": "empty", "ddd": 0, "assets": [], "layers": [], "markers": [] } src/com/android/settings/biometrics/face/FaceEnrollEducation.java +66 −0 Original line number Diff line number Diff line Loading @@ -16,9 +16,12 @@ package com.android.settings.biometrics.face; import static com.android.settingslib.widget.preference.illustration.R.string.settingslib_action_label_pause; import static com.android.settingslib.widget.preference.illustration.R.string.settingslib_action_label_resume; import static com.android.settings.biometrics.BiometricUtils.isPostureAllowEnrollment; import static com.android.settings.biometrics.BiometricUtils.isPostureGuidanceShowing; import android.animation.Animator; import android.app.settings.SettingsEnums; import android.content.ComponentName; import android.content.Intent; Loading @@ -31,7 +34,9 @@ import android.os.UserHandle; import android.text.TextUtils; import android.util.Log; import android.view.View; import android.view.accessibility.AccessibilityEvent; import android.view.accessibility.AccessibilityManager; import android.view.accessibility.AccessibilityNodeInfo; import android.widget.Button; import android.widget.CompoundButton; import android.widget.ImageView; Loading Loading @@ -117,6 +122,22 @@ public class FaceEnrollEducation extends BiometricEnrollBase { } }; private final Animator.AnimatorListener mA11yUpdater = new Animator.AnimatorListener() { @Override public void onAnimationStart(@NonNull Animator animation) {} @Override public void onAnimationEnd(@NonNull Animator animation) { forceConfigureA11yDelegate(false); } @Override public void onAnimationCancel(@NonNull Animator animation) {} @Override public void onAnimationRepeat(@NonNull Animator animation) {} }; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); Loading Loading @@ -148,7 +169,11 @@ public class FaceEnrollEducation extends BiometricEnrollBase { setupllIllustrationAnim(mIllustrationLottie); } mIllustrationLottie.setVisibility(View.VISIBLE); mIllustrationLottie.addAnimatorListener(mA11yUpdater); configureA11yDelegate(true); mIllustrationLottie.playAnimation(); mIllustrationLottie.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { Loading Loading @@ -227,6 +252,35 @@ public class FaceEnrollEducation extends BiometricEnrollBase { } } private void configureA11yDelegate(boolean isAnimating) { mIllustrationLottie.setAccessibilityDelegate(new View.AccessibilityDelegate() { @Override public void onInitializeAccessibilityNodeInfo(@NonNull View host, @NonNull AccessibilityNodeInfo info) { super.onInitializeAccessibilityNodeInfo(host, info); // Not speak "Image" for [LottieAnimationView] in a11y mode info.setClassName(null); AccessibilityNodeInfo.AccessibilityAction clickAction = new AccessibilityNodeInfo.AccessibilityAction( AccessibilityNodeInfo.ACTION_CLICK, getString(isAnimating ? settingslib_action_label_pause : settingslib_action_label_resume) ); info.addAction(clickAction); } }); } private void forceConfigureA11yDelegate(boolean isAnimating) { // Update delegate to read correct text based on latest animating state configureA11yDelegate(isAnimating); // Trigger the accessibility service to re-create AccessibilityNode mIllustrationLottie.sendAccessibilityEvent(AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED); } @Override protected void onStart() { super.onStart(); Loading Loading @@ -271,6 +325,16 @@ public class FaceEnrollEducation extends BiometricEnrollBase { } } @Override protected void onDestroy() { if (mIllustrationLottie != null && mIsUsingLottie) { mIllustrationLottie.removeAnimatorListener(mA11yUpdater); mIllustrationLottie.setAccessibilityDelegate(null); mIllustrationLottie.setOnClickListener(null); } super.onDestroy(); } @Override protected boolean shouldFinishWhenBackgrounded() { return super.shouldFinishWhenBackgrounded() && !mNextLaunched Loading Loading @@ -433,6 +497,7 @@ public class FaceEnrollEducation extends BiometricEnrollBase { private void hideDefaultIllustration() { if (mIsUsingLottie) { forceConfigureA11yDelegate(false); mIllustrationLottie.cancelAnimation(); mIllustrationLottie.setVisibility(View.INVISIBLE); } else { Loading @@ -449,6 +514,7 @@ public class FaceEnrollEducation extends BiometricEnrollBase { setupllIllustrationAnim(mIllustrationLottie); } mIllustrationLottie.setVisibility(View.VISIBLE); forceConfigureA11yDelegate(true); mIllustrationLottie.playAnimation(); mIllustrationLottie.setProgress(0f); } else { Loading tests/robotests/AndroidManifest.xml +1 −0 Original line number Diff line number Diff line Loading @@ -23,6 +23,7 @@ <application> <activity android:name="com.android.settings.security.TestActivity" android:exported="true" /> <activity android:name="com.android.settings.biometrics.face.FaceEnrollEducationTest$TestFaceEnrollEducation" android:exported="true" /> </application> </manifest> tests/robotests/src/com/android/settings/biometrics/face/FaceEnrollEducationTest.java +138 −85 Original line number Diff line number Diff line Loading @@ -19,6 +19,8 @@ package com.android.settings.biometrics.face; import static android.util.DisplayMetrics.DENSITY_DEFAULT; import static android.util.DisplayMetrics.DENSITY_XXXHIGH; import static com.android.settingslib.widget.preference.illustration.R.string.settingslib_action_label_pause; import static com.android.settingslib.widget.preference.illustration.R.string.settingslib_illustration_content_description; 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; Loading @@ -28,7 +30,6 @@ import static com.android.settings.biometrics.BiometricUtils.DEVICE_POSTURE_UNKN 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; Loading @@ -36,43 +37,41 @@ import android.content.Intent; import android.content.res.Configuration; import android.hardware.face.FaceManager; import android.view.View; import android.view.accessibility.AccessibilityNodeInfo; import android.widget.Button; import androidx.lifecycle.Lifecycle.State; import androidx.test.core.app.ActivityScenario; 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.SettingsShadowResources; import com.android.settings.testutils.shadow.ShadowUtils; import com.airbnb.lottie.LottieAnimationView; 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.Assert; import org.junit.Before; import org.junit.Ignore; 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}) @Config(shadows = {ShadowUtils.class, SettingsShadowResources.class}) public class FaceEnrollEducationTest { @Mock private FaceManager mFaceManager; private Context mContext; private ActivityController<TestFaceEnrollEducation> mActivityController; private TestFaceEnrollEducation mActivity; private ActivityScenario<TestFaceEnrollEducation> mScenario; private FakeFeatureFactory mFakeFeatureFactory; public static class TestFaceEnrollEducation extends FaceEnrollEducation { Loading @@ -87,116 +86,131 @@ public class FaceEnrollEducationTest { public void setUp() { MockitoAnnotations.initMocks(this); ShadowUtils.setFaceManager(mFaceManager); mContext = ApplicationProvider.getApplicationContext(); mFakeFeatureFactory = FakeFeatureFactory.setupForTest(); } @After public void tearDown() { ShadowUtils.reset(); if (mScenario != null) { mScenario.close(); } } private void setupActivityForPosture() { final Intent testIntent = new Intent(); Context appContext = ApplicationProvider.getApplicationContext(); final Intent testIntent = new Intent(appContext, TestFaceEnrollEducation.class); // 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); final Intent postureGuidanceProviderIntent = new Intent(); // Intent for the mock provider when(mFakeFeatureFactory.mFaceFeatureProvider.getPostureGuidanceIntent(any())).thenReturn( testIntent); mContext = spy(ApplicationProvider.getApplicationContext()); mActivityController = Robolectric.buildActivity( TestFaceEnrollEducation.class, testIntent); mActivity = spy(mActivityController.create().get()); postureGuidanceProviderIntent); when(mContext.getSystemService(Context.FACE_SERVICE)).thenReturn(mFaceManager); mScenario = ActivityScenario.launch(testIntent); } private void setupActivity() { final Intent testIntent = new Intent(); Context appContext = ApplicationProvider.getApplicationContext(); final Intent testIntent = new Intent(appContext, TestFaceEnrollEducation.class); // 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); mScenario = ActivityScenario.launch(testIntent); } @Test @Ignore("b/295325503") public void testFaceEnrollEducation_hasHeader() { setupActivity(); CharSequence headerText = getGlifLayout().getHeaderText(); mScenario.onActivity(activity -> { GlifLayout glifLayout = activity.findViewById(R.id.setup_wizard_layout); CharSequence headerText = glifLayout.getHeaderText(); assertThat(headerText.toString()).isEqualTo( mContext.getString(R.string.security_settings_face_enroll_education_title)); activity.getString(R.string.security_settings_face_enroll_education_title)); }); } @Test public void testFaceEnrollEducation_hasDescription() { setupActivity(); CharSequence desc = getGlifLayout().getDescriptionText(); mScenario.onActivity(activity -> { GlifLayout glifLayout = activity.findViewById(R.id.setup_wizard_layout); CharSequence desc = glifLayout.getDescriptionText(); assertThat(desc.toString()).isEqualTo( mContext.getString(R.string.security_settings_face_enroll_education_message)); activity.getString(R.string.security_settings_face_enroll_education_message)); }); } @Test public void testFaceEnrollEducation_showFooterPrimaryButton() { setupActivity(); FooterBarMixin footer = getGlifLayout().getMixin(FooterBarMixin.class); mScenario.onActivity(activity -> { GlifLayout glifLayout = activity.findViewById(R.id.setup_wizard_layout); FooterBarMixin footer = glifLayout.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)); activity.getString(R.string.security_settings_face_enroll_education_start)); }); } @Test public void testFaceEnrollEducation_showFooterSecondaryButton() { setupActivity(); FooterBarMixin footer = getGlifLayout().getMixin(FooterBarMixin.class); mScenario.onActivity(activity -> { GlifLayout glifLayout = activity.findViewById(R.id.setup_wizard_layout); FooterBarMixin footer = glifLayout.getMixin(FooterBarMixin.class); FooterButton footerButton = footer.getSecondaryButton(); assertThat(footerButton.getVisibility()).isEqualTo(View.VISIBLE); assertThat(footerButton.getText().toString()).isEqualTo(mContext.getString( assertThat(footerButton.getText().toString()).isEqualTo(activity.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); mScenario.onActivity(activity -> { assertThat(activity.launchPostureGuidance()).isFalse(); assertThat(activity.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); mScenario.onActivity(activity -> { assertThat(activity.getPostureGuidanceIntent()).isNull(); assertThat(activity.getPostureCallback()).isNull(); assertThat(activity.getDevicePostureState()).isEqualTo(DEVICE_POSTURE_UNKNOWN); }); } @Test public void testFaceEnrollEducationWithPosture_onStartRegisteredPostureChangeCallback() { setupActivityForPosture(); mActivity.onStart(); assertThat(mActivity.getPostureGuidanceIntent()).isNotNull(); assertThat(mActivity.getPostureCallback()).isNotNull(); mScenario.onActivity(activity -> { assertThat(activity.getPostureGuidanceIntent()).isNotNull(); assertThat(activity.getPostureCallback()).isNotNull(); }); } @Test Loading @@ -204,14 +218,15 @@ public class FaceEnrollEducationTest { final Configuration newConfig = new Configuration(); newConfig.smallestScreenWidthDp = DENSITY_XXXHIGH; setupActivityForPosture(); mActivity.onStart(); assertThat(mActivity.getPostureGuidanceIntent()).isNotNull(); assertThat(mActivity.getPostureCallback()).isNotNull(); mScenario.onActivity(activity -> { assertThat(activity.getPostureGuidanceIntent()).isNotNull(); assertThat(activity.getPostureCallback()).isNotNull(); mActivity.onConfigurationChanged(newConfig); activity.onConfigurationChanged(newConfig); assertThat(mActivity.getDevicePostureState()).isEqualTo(DEVICE_POSTURE_OPENED); assertThat(activity.getDevicePostureState()).isEqualTo(DEVICE_POSTURE_OPENED); }); } @Test Loading @@ -219,42 +234,80 @@ public class FaceEnrollEducationTest { final Configuration newConfig = new Configuration(); newConfig.smallestScreenWidthDp = DENSITY_DEFAULT; setupActivityForPosture(); mActivity.onStart(); assertThat(mActivity.getPostureGuidanceIntent()).isNotNull(); assertThat(mActivity.getPostureCallback()).isNotNull(); mScenario.onActivity(activity -> { assertThat(activity.getPostureGuidanceIntent()).isNotNull(); assertThat(activity.getPostureCallback()).isNotNull(); mActivity.onConfigurationChanged(newConfig); activity.onConfigurationChanged(newConfig); assertThat(mActivity.getDevicePostureState()).isEqualTo(DEVICE_POSTURE_CLOSED); assertThat(activity.getDevicePostureState()).isEqualTo(DEVICE_POSTURE_CLOSED); }); } @Test public void testFaceEnrollEducation_LaunchActivityNormal() { try (ActivityScenario<FaceEnrollEducation> faceEnrollEducationScenario = ActivityScenario.launch(FaceEnrollEducation.class)) { faceEnrollEducationScenario.onActivity(originalActivity -> { assertThat(faceEnrollEducationScenario.getState()).isEqualTo(State.RESUMED); setupActivity(); final View faceEnrollEducationView = originalActivity.findViewById( mScenario.onActivity(activity -> { final View faceEnrollEducationView = activity.findViewById( R.id.setup_wizard_layout); assertThat(faceEnrollEducationView).isNotNull(); final FaceEnrollEducation activity = spy(originalActivity); int a11yButtonId = activity.isUsingExpressiveStyle() ? R.id.accessibility_button_expressive : R.id.accessibility_button; final Button a11yButton = activity.findViewById(a11yButtonId); assertThat(a11yButton).isNotNull(); }); } catch (Exception e) { System.err.println("FaceEnrollEducationTest failed due to an unexpected exception:"); e.printStackTrace(); } Assert.fail("FaceEnrollEducationTest failed due to the inability to " + "launch Activity normally."); @Test public void testFaceEnrollEducation_LottieA11y() { SettingsShadowResources.overrideResource(R.bool.config_face_education_use_lottie, true); setupActivity(); mScenario.onActivity(activity -> { LottieAnimationView lottie = activity.findViewById(R.id.illustration_lottie); assertThat(lottie).isNotNull(); assertThat(lottie.getVisibility()).isEqualTo(View.VISIBLE); // Correct contentDescription for a11y CharSequence contentDescription = lottie.getContentDescription(); assertThat(contentDescription).isNotNull(); assertThat(contentDescription.toString()).isEqualTo( mContext.getString(settingslib_illustration_content_description)); // Verify an OnClickListener is present. Direct testing of Lottie's // click-to-pause/resume behavior has limitations in Robolectric, so this checks for // interactive setup. assertThat(lottie.hasOnClickListeners()).isTrue(); // Verify the AccessibilityDelegate provides correct information. // Lottie animation playback and full interaction are not deeply simulated in // Robolectric, so we inspect the AccessibilityNodeInfo as populated by the delegate. // Expected AccessibilityNodeInfo properties: // 1. className is null: Prevents TalkBack from announcing a generic type like "Image", // allowing for more specific announcements via contentDescription or action labels. // 2. ACTION_CLICK label is "pause" because animation is playing before onResume(). View.AccessibilityDelegate delegate = lottie.getAccessibilityDelegate(); assertThat(delegate).isNotNull(); AccessibilityNodeInfo nodeInfo = new AccessibilityNodeInfo(lottie); delegate.onInitializeAccessibilityNodeInfo(lottie, nodeInfo); assertThat(nodeInfo.getClassName()).isNull(); CharSequence clickActionLabel = null; if (nodeInfo.getActionList() != null) { for (AccessibilityNodeInfo.AccessibilityAction action : nodeInfo.getActionList()) { if (action.getId() == AccessibilityNodeInfo.ACTION_CLICK) { clickActionLabel = action.getLabel(); break; } } } assertThat(clickActionLabel).isNotNull(); assertThat(clickActionLabel.toString()).isEqualTo( mContext.getString(settingslib_action_label_pause)); }); } } Loading
res/layout/face_enroll_education.xml +3 −2 Original line number Diff line number Diff line Loading @@ -57,8 +57,9 @@ android:layout_marginTop="@dimen/lottie_animation_view_margin_top" android:scaleType="centerInside" android:visibility="gone" app:lottie_autoPlay="true" app:lottie_loop="true" app:lottie_autoPlay="false" app:lottie_loop="false" android:contentDescription="@string/settingslib_illustration_content_description" app:lottie_speed="1.5" /> <ImageView Loading
res/raw/face_education_lottie.json +13 −0 Original line number Diff line number Diff line { "v": "5.9.0", "fr": 60, "ip": 0, "op": 214, "w": 0, "h": 0, "nm": "empty", "ddd": 0, "assets": [], "layers": [], "markers": [] }
src/com/android/settings/biometrics/face/FaceEnrollEducation.java +66 −0 Original line number Diff line number Diff line Loading @@ -16,9 +16,12 @@ package com.android.settings.biometrics.face; import static com.android.settingslib.widget.preference.illustration.R.string.settingslib_action_label_pause; import static com.android.settingslib.widget.preference.illustration.R.string.settingslib_action_label_resume; import static com.android.settings.biometrics.BiometricUtils.isPostureAllowEnrollment; import static com.android.settings.biometrics.BiometricUtils.isPostureGuidanceShowing; import android.animation.Animator; import android.app.settings.SettingsEnums; import android.content.ComponentName; import android.content.Intent; Loading @@ -31,7 +34,9 @@ import android.os.UserHandle; import android.text.TextUtils; import android.util.Log; import android.view.View; import android.view.accessibility.AccessibilityEvent; import android.view.accessibility.AccessibilityManager; import android.view.accessibility.AccessibilityNodeInfo; import android.widget.Button; import android.widget.CompoundButton; import android.widget.ImageView; Loading Loading @@ -117,6 +122,22 @@ public class FaceEnrollEducation extends BiometricEnrollBase { } }; private final Animator.AnimatorListener mA11yUpdater = new Animator.AnimatorListener() { @Override public void onAnimationStart(@NonNull Animator animation) {} @Override public void onAnimationEnd(@NonNull Animator animation) { forceConfigureA11yDelegate(false); } @Override public void onAnimationCancel(@NonNull Animator animation) {} @Override public void onAnimationRepeat(@NonNull Animator animation) {} }; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); Loading Loading @@ -148,7 +169,11 @@ public class FaceEnrollEducation extends BiometricEnrollBase { setupllIllustrationAnim(mIllustrationLottie); } mIllustrationLottie.setVisibility(View.VISIBLE); mIllustrationLottie.addAnimatorListener(mA11yUpdater); configureA11yDelegate(true); mIllustrationLottie.playAnimation(); mIllustrationLottie.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { Loading Loading @@ -227,6 +252,35 @@ public class FaceEnrollEducation extends BiometricEnrollBase { } } private void configureA11yDelegate(boolean isAnimating) { mIllustrationLottie.setAccessibilityDelegate(new View.AccessibilityDelegate() { @Override public void onInitializeAccessibilityNodeInfo(@NonNull View host, @NonNull AccessibilityNodeInfo info) { super.onInitializeAccessibilityNodeInfo(host, info); // Not speak "Image" for [LottieAnimationView] in a11y mode info.setClassName(null); AccessibilityNodeInfo.AccessibilityAction clickAction = new AccessibilityNodeInfo.AccessibilityAction( AccessibilityNodeInfo.ACTION_CLICK, getString(isAnimating ? settingslib_action_label_pause : settingslib_action_label_resume) ); info.addAction(clickAction); } }); } private void forceConfigureA11yDelegate(boolean isAnimating) { // Update delegate to read correct text based on latest animating state configureA11yDelegate(isAnimating); // Trigger the accessibility service to re-create AccessibilityNode mIllustrationLottie.sendAccessibilityEvent(AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED); } @Override protected void onStart() { super.onStart(); Loading Loading @@ -271,6 +325,16 @@ public class FaceEnrollEducation extends BiometricEnrollBase { } } @Override protected void onDestroy() { if (mIllustrationLottie != null && mIsUsingLottie) { mIllustrationLottie.removeAnimatorListener(mA11yUpdater); mIllustrationLottie.setAccessibilityDelegate(null); mIllustrationLottie.setOnClickListener(null); } super.onDestroy(); } @Override protected boolean shouldFinishWhenBackgrounded() { return super.shouldFinishWhenBackgrounded() && !mNextLaunched Loading Loading @@ -433,6 +497,7 @@ public class FaceEnrollEducation extends BiometricEnrollBase { private void hideDefaultIllustration() { if (mIsUsingLottie) { forceConfigureA11yDelegate(false); mIllustrationLottie.cancelAnimation(); mIllustrationLottie.setVisibility(View.INVISIBLE); } else { Loading @@ -449,6 +514,7 @@ public class FaceEnrollEducation extends BiometricEnrollBase { setupllIllustrationAnim(mIllustrationLottie); } mIllustrationLottie.setVisibility(View.VISIBLE); forceConfigureA11yDelegate(true); mIllustrationLottie.playAnimation(); mIllustrationLottie.setProgress(0f); } else { Loading
tests/robotests/AndroidManifest.xml +1 −0 Original line number Diff line number Diff line Loading @@ -23,6 +23,7 @@ <application> <activity android:name="com.android.settings.security.TestActivity" android:exported="true" /> <activity android:name="com.android.settings.biometrics.face.FaceEnrollEducationTest$TestFaceEnrollEducation" android:exported="true" /> </application> </manifest>
tests/robotests/src/com/android/settings/biometrics/face/FaceEnrollEducationTest.java +138 −85 Original line number Diff line number Diff line Loading @@ -19,6 +19,8 @@ package com.android.settings.biometrics.face; import static android.util.DisplayMetrics.DENSITY_DEFAULT; import static android.util.DisplayMetrics.DENSITY_XXXHIGH; import static com.android.settingslib.widget.preference.illustration.R.string.settingslib_action_label_pause; import static com.android.settingslib.widget.preference.illustration.R.string.settingslib_illustration_content_description; 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; Loading @@ -28,7 +30,6 @@ import static com.android.settings.biometrics.BiometricUtils.DEVICE_POSTURE_UNKN 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; Loading @@ -36,43 +37,41 @@ import android.content.Intent; import android.content.res.Configuration; import android.hardware.face.FaceManager; import android.view.View; import android.view.accessibility.AccessibilityNodeInfo; import android.widget.Button; import androidx.lifecycle.Lifecycle.State; import androidx.test.core.app.ActivityScenario; 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.SettingsShadowResources; import com.android.settings.testutils.shadow.ShadowUtils; import com.airbnb.lottie.LottieAnimationView; 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.Assert; import org.junit.Before; import org.junit.Ignore; 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}) @Config(shadows = {ShadowUtils.class, SettingsShadowResources.class}) public class FaceEnrollEducationTest { @Mock private FaceManager mFaceManager; private Context mContext; private ActivityController<TestFaceEnrollEducation> mActivityController; private TestFaceEnrollEducation mActivity; private ActivityScenario<TestFaceEnrollEducation> mScenario; private FakeFeatureFactory mFakeFeatureFactory; public static class TestFaceEnrollEducation extends FaceEnrollEducation { Loading @@ -87,116 +86,131 @@ public class FaceEnrollEducationTest { public void setUp() { MockitoAnnotations.initMocks(this); ShadowUtils.setFaceManager(mFaceManager); mContext = ApplicationProvider.getApplicationContext(); mFakeFeatureFactory = FakeFeatureFactory.setupForTest(); } @After public void tearDown() { ShadowUtils.reset(); if (mScenario != null) { mScenario.close(); } } private void setupActivityForPosture() { final Intent testIntent = new Intent(); Context appContext = ApplicationProvider.getApplicationContext(); final Intent testIntent = new Intent(appContext, TestFaceEnrollEducation.class); // 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); final Intent postureGuidanceProviderIntent = new Intent(); // Intent for the mock provider when(mFakeFeatureFactory.mFaceFeatureProvider.getPostureGuidanceIntent(any())).thenReturn( testIntent); mContext = spy(ApplicationProvider.getApplicationContext()); mActivityController = Robolectric.buildActivity( TestFaceEnrollEducation.class, testIntent); mActivity = spy(mActivityController.create().get()); postureGuidanceProviderIntent); when(mContext.getSystemService(Context.FACE_SERVICE)).thenReturn(mFaceManager); mScenario = ActivityScenario.launch(testIntent); } private void setupActivity() { final Intent testIntent = new Intent(); Context appContext = ApplicationProvider.getApplicationContext(); final Intent testIntent = new Intent(appContext, TestFaceEnrollEducation.class); // 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); mScenario = ActivityScenario.launch(testIntent); } @Test @Ignore("b/295325503") public void testFaceEnrollEducation_hasHeader() { setupActivity(); CharSequence headerText = getGlifLayout().getHeaderText(); mScenario.onActivity(activity -> { GlifLayout glifLayout = activity.findViewById(R.id.setup_wizard_layout); CharSequence headerText = glifLayout.getHeaderText(); assertThat(headerText.toString()).isEqualTo( mContext.getString(R.string.security_settings_face_enroll_education_title)); activity.getString(R.string.security_settings_face_enroll_education_title)); }); } @Test public void testFaceEnrollEducation_hasDescription() { setupActivity(); CharSequence desc = getGlifLayout().getDescriptionText(); mScenario.onActivity(activity -> { GlifLayout glifLayout = activity.findViewById(R.id.setup_wizard_layout); CharSequence desc = glifLayout.getDescriptionText(); assertThat(desc.toString()).isEqualTo( mContext.getString(R.string.security_settings_face_enroll_education_message)); activity.getString(R.string.security_settings_face_enroll_education_message)); }); } @Test public void testFaceEnrollEducation_showFooterPrimaryButton() { setupActivity(); FooterBarMixin footer = getGlifLayout().getMixin(FooterBarMixin.class); mScenario.onActivity(activity -> { GlifLayout glifLayout = activity.findViewById(R.id.setup_wizard_layout); FooterBarMixin footer = glifLayout.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)); activity.getString(R.string.security_settings_face_enroll_education_start)); }); } @Test public void testFaceEnrollEducation_showFooterSecondaryButton() { setupActivity(); FooterBarMixin footer = getGlifLayout().getMixin(FooterBarMixin.class); mScenario.onActivity(activity -> { GlifLayout glifLayout = activity.findViewById(R.id.setup_wizard_layout); FooterBarMixin footer = glifLayout.getMixin(FooterBarMixin.class); FooterButton footerButton = footer.getSecondaryButton(); assertThat(footerButton.getVisibility()).isEqualTo(View.VISIBLE); assertThat(footerButton.getText().toString()).isEqualTo(mContext.getString( assertThat(footerButton.getText().toString()).isEqualTo(activity.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); mScenario.onActivity(activity -> { assertThat(activity.launchPostureGuidance()).isFalse(); assertThat(activity.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); mScenario.onActivity(activity -> { assertThat(activity.getPostureGuidanceIntent()).isNull(); assertThat(activity.getPostureCallback()).isNull(); assertThat(activity.getDevicePostureState()).isEqualTo(DEVICE_POSTURE_UNKNOWN); }); } @Test public void testFaceEnrollEducationWithPosture_onStartRegisteredPostureChangeCallback() { setupActivityForPosture(); mActivity.onStart(); assertThat(mActivity.getPostureGuidanceIntent()).isNotNull(); assertThat(mActivity.getPostureCallback()).isNotNull(); mScenario.onActivity(activity -> { assertThat(activity.getPostureGuidanceIntent()).isNotNull(); assertThat(activity.getPostureCallback()).isNotNull(); }); } @Test Loading @@ -204,14 +218,15 @@ public class FaceEnrollEducationTest { final Configuration newConfig = new Configuration(); newConfig.smallestScreenWidthDp = DENSITY_XXXHIGH; setupActivityForPosture(); mActivity.onStart(); assertThat(mActivity.getPostureGuidanceIntent()).isNotNull(); assertThat(mActivity.getPostureCallback()).isNotNull(); mScenario.onActivity(activity -> { assertThat(activity.getPostureGuidanceIntent()).isNotNull(); assertThat(activity.getPostureCallback()).isNotNull(); mActivity.onConfigurationChanged(newConfig); activity.onConfigurationChanged(newConfig); assertThat(mActivity.getDevicePostureState()).isEqualTo(DEVICE_POSTURE_OPENED); assertThat(activity.getDevicePostureState()).isEqualTo(DEVICE_POSTURE_OPENED); }); } @Test Loading @@ -219,42 +234,80 @@ public class FaceEnrollEducationTest { final Configuration newConfig = new Configuration(); newConfig.smallestScreenWidthDp = DENSITY_DEFAULT; setupActivityForPosture(); mActivity.onStart(); assertThat(mActivity.getPostureGuidanceIntent()).isNotNull(); assertThat(mActivity.getPostureCallback()).isNotNull(); mScenario.onActivity(activity -> { assertThat(activity.getPostureGuidanceIntent()).isNotNull(); assertThat(activity.getPostureCallback()).isNotNull(); mActivity.onConfigurationChanged(newConfig); activity.onConfigurationChanged(newConfig); assertThat(mActivity.getDevicePostureState()).isEqualTo(DEVICE_POSTURE_CLOSED); assertThat(activity.getDevicePostureState()).isEqualTo(DEVICE_POSTURE_CLOSED); }); } @Test public void testFaceEnrollEducation_LaunchActivityNormal() { try (ActivityScenario<FaceEnrollEducation> faceEnrollEducationScenario = ActivityScenario.launch(FaceEnrollEducation.class)) { faceEnrollEducationScenario.onActivity(originalActivity -> { assertThat(faceEnrollEducationScenario.getState()).isEqualTo(State.RESUMED); setupActivity(); final View faceEnrollEducationView = originalActivity.findViewById( mScenario.onActivity(activity -> { final View faceEnrollEducationView = activity.findViewById( R.id.setup_wizard_layout); assertThat(faceEnrollEducationView).isNotNull(); final FaceEnrollEducation activity = spy(originalActivity); int a11yButtonId = activity.isUsingExpressiveStyle() ? R.id.accessibility_button_expressive : R.id.accessibility_button; final Button a11yButton = activity.findViewById(a11yButtonId); assertThat(a11yButton).isNotNull(); }); } catch (Exception e) { System.err.println("FaceEnrollEducationTest failed due to an unexpected exception:"); e.printStackTrace(); } Assert.fail("FaceEnrollEducationTest failed due to the inability to " + "launch Activity normally."); @Test public void testFaceEnrollEducation_LottieA11y() { SettingsShadowResources.overrideResource(R.bool.config_face_education_use_lottie, true); setupActivity(); mScenario.onActivity(activity -> { LottieAnimationView lottie = activity.findViewById(R.id.illustration_lottie); assertThat(lottie).isNotNull(); assertThat(lottie.getVisibility()).isEqualTo(View.VISIBLE); // Correct contentDescription for a11y CharSequence contentDescription = lottie.getContentDescription(); assertThat(contentDescription).isNotNull(); assertThat(contentDescription.toString()).isEqualTo( mContext.getString(settingslib_illustration_content_description)); // Verify an OnClickListener is present. Direct testing of Lottie's // click-to-pause/resume behavior has limitations in Robolectric, so this checks for // interactive setup. assertThat(lottie.hasOnClickListeners()).isTrue(); // Verify the AccessibilityDelegate provides correct information. // Lottie animation playback and full interaction are not deeply simulated in // Robolectric, so we inspect the AccessibilityNodeInfo as populated by the delegate. // Expected AccessibilityNodeInfo properties: // 1. className is null: Prevents TalkBack from announcing a generic type like "Image", // allowing for more specific announcements via contentDescription or action labels. // 2. ACTION_CLICK label is "pause" because animation is playing before onResume(). View.AccessibilityDelegate delegate = lottie.getAccessibilityDelegate(); assertThat(delegate).isNotNull(); AccessibilityNodeInfo nodeInfo = new AccessibilityNodeInfo(lottie); delegate.onInitializeAccessibilityNodeInfo(lottie, nodeInfo); assertThat(nodeInfo.getClassName()).isNull(); CharSequence clickActionLabel = null; if (nodeInfo.getActionList() != null) { for (AccessibilityNodeInfo.AccessibilityAction action : nodeInfo.getActionList()) { if (action.getId() == AccessibilityNodeInfo.ACTION_CLICK) { clickActionLabel = action.getLabel(); break; } } } assertThat(clickActionLabel).isNotNull(); assertThat(clickActionLabel.toString()).isEqualTo( mContext.getString(settingslib_action_label_pause)); }); } }