Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit fab84a2a authored by Treehugger Robot's avatar Treehugger Robot Committed by Android (Google) Code Review
Browse files

Merge "Enable a11y for FaceEnrollEducation lottie" into main

parents a4d17b81 fbfcd824
Loading
Loading
Loading
Loading
+3 −2
Original line number Diff line number Diff line
@@ -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
+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": []
}
+66 −0
Original line number Diff line number Diff line
@@ -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;
@@ -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;
@@ -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);
@@ -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) {
@@ -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();
@@ -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
@@ -433,6 +497,7 @@ public class FaceEnrollEducation extends BiometricEnrollBase {

    private void hideDefaultIllustration() {
        if (mIsUsingLottie) {
            forceConfigureA11yDelegate(false);
            mIllustrationLottie.cancelAnimation();
            mIllustrationLottie.setVisibility(View.INVISIBLE);
        } else {
@@ -449,6 +514,7 @@ public class FaceEnrollEducation extends BiometricEnrollBase {
                setupllIllustrationAnim(mIllustrationLottie);
            }
            mIllustrationLottie.setVisibility(View.VISIBLE);
            forceConfigureA11yDelegate(true);
            mIllustrationLottie.playAnimation();
            mIllustrationLottie.setProgress(0f);
        } else {
+1 −0
Original line number Diff line number Diff line
@@ -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>
+138 −85
Original line number Diff line number Diff line
@@ -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;
@@ -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;
@@ -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 {
@@ -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
@@ -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
@@ -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));
        });
    }
}