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

Commit 6adcb969 authored by “Longbo's avatar “Longbo
Browse files

a11y: Add scroll cursor point indicator view

Video: http://shortn/_dyMJkMDMfy

Bug: b/409100606
Test: AutoclickScrollPointIndicatorTest
Flag: com.android.server.accessibility.enable_autoclick_indicator

Change-Id: I9d3380524fd1bcd49cc866612e56c52a70c2ceed
parent 9523b841
Loading
Loading
Loading
Loading
+4 −0
Original line number Diff line number Diff line
@@ -69,6 +69,7 @@ public class AutoclickScrollPanel {
    private final WindowManager mWindowManager;
    private final WindowManager.LayoutParams mParams;
    private ScrollPanelControllerInterface mScrollPanelController;
    private final AutoclickScrollPointIndicator mAutoclickScrollPointIndicator;

    // Scroll panel buttons.
    private final ImageButton mUpButton;
@@ -101,6 +102,7 @@ public class AutoclickScrollPanel {
        mContext = context;
        mWindowManager = windowManager;
        mScrollPanelController = controller;
        mAutoclickScrollPointIndicator = new AutoclickScrollPointIndicator(context);
        mContentView = (AutoclickLinearLayout) LayoutInflater.from(context).inflate(
                R.layout.accessibility_autoclick_scroll_panel, null);
        mParams = getDefaultLayoutParams();
@@ -157,6 +159,7 @@ public class AutoclickScrollPanel {
        }
        // Position the panel at the cursor location
        positionPanelAtCursor(cursorX, cursorY);
        mAutoclickScrollPointIndicator.show(cursorX, cursorY);
        mWindowManager.addView(mContentView, mParams);
        mInScrollMode = true;
    }
@@ -202,6 +205,7 @@ public class AutoclickScrollPanel {
        if (!mInScrollMode) {
            return;
        }
        mAutoclickScrollPointIndicator.hide();
        mWindowManager.removeView(mContentView);
        mInScrollMode = false;
    }
+135 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2025 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.server.accessibility.autoclick;

import static android.view.WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS;

import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.PixelFormat;
import android.util.DisplayMetrics;
import android.view.View;
import android.view.WindowManager;

import androidx.annotation.VisibleForTesting;

import com.android.internal.R;

/**
 * A visual indicator that displays a point at the scroll cursor location.
 */
public class AutoclickScrollPointIndicator extends View {
    // 16dp diameter (8dp radius).
    private static final float POINT_RADIUS_DP = 8f;

    private final WindowManager mWindowManager;
    private final Paint mPaint;
    private final float mPointSizePx;

    // x and y coordinates of the cursor point indicator.
    private float mX;
    private float mY;

    private boolean mIsVisible = false;

    public AutoclickScrollPointIndicator(Context context) {
        super(context);

        mWindowManager = context.getSystemService(WindowManager.class);

        // Convert dp to pixels based on screen density.
        float density = getResources().getDisplayMetrics().density;
        mPointSizePx = POINT_RADIUS_DP * density;

        // Setup paint for drawing.
        mPaint = new Paint();
        mPaint.setAntiAlias(true);
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        // Get the screen dimensions.
        DisplayMetrics displayMetrics = getResources().getDisplayMetrics();
        int screenWidth = displayMetrics.widthPixels;
        int screenHeight = displayMetrics.heightPixels;

        setMeasuredDimension(screenWidth, screenHeight);
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);

        // Draw a solid circle with materialColorPrimary.
        mPaint.setStyle(Paint.Style.FILL);
        mPaint.setColor(getContext().getColor(R.color.materialColorPrimary));
        canvas.drawCircle(mX, mY, mPointSizePx, mPaint);
    }

    /**
     * Shows the cursor point indicator at the specified coordinates.
     *
     * @param x The x-coordinate of the cursor.
     * @param y The y-coordinate of the cursor.
     */
    public void show(float x, float y) {
        mX = x;
        mY = y;

        if (!mIsVisible) {
            mWindowManager.addView(this, getLayoutParams());
            mIsVisible = true;
        }

        invalidate();
    }

    /**
     * Hides the cursor point indicator.
     */
    public void hide() {
        if (mIsVisible) {
            mWindowManager.removeView(this);
            mIsVisible = false;
        }
    }

    /**
     * Retrieves the layout params for AutoclickScrollPointIndicator, used when it's added to the
     * Window Manager.
     */
    public WindowManager.LayoutParams getLayoutParams() {
        final WindowManager.LayoutParams layoutParams = new WindowManager.LayoutParams();
        layoutParams.type = WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY;
        layoutParams.flags = WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
                | WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE
                | WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN;
        layoutParams.privateFlags |= WindowManager.LayoutParams.SYSTEM_FLAG_SHOW_FOR_ALL_USERS;
        layoutParams.layoutInDisplayCutoutMode = LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS;
        layoutParams.format = PixelFormat.TRANSLUCENT;
        layoutParams.setTitle(AutoclickScrollPointIndicator.class.getSimpleName());
        layoutParams.inputFeatures |= WindowManager.LayoutParams.INPUT_FEATURE_NO_INPUT_CHANNEL;

        return layoutParams;
    }

    @VisibleForTesting
    public boolean isVisible() {
        return mIsVisible;
    }
}
+4 −3
Original line number Diff line number Diff line
@@ -241,8 +241,8 @@ public class AutoclickScrollPanelTest {
        mScrollPanel.show(cursorX, cursorY);

        // Verify view is added to window manager.
        verify(mMockWindowManager).addView(any(), any(WindowManager.LayoutParams.class));

        verify(mMockWindowManager).addView(eq(mScrollPanel.getContentViewForTesting()),
                any(WindowManager.LayoutParams.class));
        // Verify panel is visible.
        assertThat(mScrollPanel.isVisible()).isTrue();
    }
@@ -268,7 +268,8 @@ public class AutoclickScrollPanelTest {
        assertThat(mScrollPanel.isVisible()).isTrue();

        // Verify view was added twice to window manager.
        verify(mMockWindowManager, times(2)).addView(any(), any(WindowManager.LayoutParams.class));
        verify(mMockWindowManager, times(2)).addView(eq(mScrollPanel.getContentViewForTesting()),
                any(WindowManager.LayoutParams.class));
    }

    @Test
+114 −0
Original line number Diff line number Diff line
/*
 * Copyright 2025 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.server.accessibility.autoclick;

import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation;

import static com.google.common.truth.Truth.assertThat;

import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;

import android.content.Context;
import android.testing.AndroidTestingRunner;
import android.testing.TestableContext;
import android.testing.TestableLooper;
import android.view.WindowManager;

import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mock;
import org.mockito.junit.MockitoJUnit;
import org.mockito.junit.MockitoRule;

/** Test cases for {@link AutoclickScrollPointIndicator}. */
@RunWith(AndroidTestingRunner.class)
@TestableLooper.RunWithLooper(setAsMainLooper = true)
public class AutoclickScrollPointIndicatorTest {
    @Rule
    public final MockitoRule mMockitoRule = MockitoJUnit.rule();

    @Rule
    public TestableContext mTestableContext =
            new TestableContext(getInstrumentation().getContext());

    @Mock
    private WindowManager mMockWindowManager;

    private AutoclickScrollPointIndicator mPointIndicator;

    @Before
    public void setUp() {
        mTestableContext.addMockSystemService(Context.WINDOW_SERVICE, mMockWindowManager);
        mPointIndicator = new AutoclickScrollPointIndicator(mTestableContext);
    }

    @Test
    public void initialState_isNotVisible() {
        assertThat(mPointIndicator.isVisible()).isFalse();
    }

    @Test
    public void show_addsViewToWindowManager() {
        float testX = 100.0f;
        float testY = 200.0f;

        mPointIndicator.show(testX, testY);

        // Verify view is added to window manager.
        verify(mMockWindowManager).addView(eq(mPointIndicator),
                any(WindowManager.LayoutParams.class));

        // Verify isVisible reflects correct state.
        assertThat(mPointIndicator.isVisible()).isTrue();
    }

    @Test
    public void show_alreadyVisible_doesNotAddAgain() {
        float testX = 100.0f;
        float testY = 200.0f;

        // Show twice.
        mPointIndicator.show(testX, testY);
        mPointIndicator.show(testX, testY);

        // Verify addView was only called once.
        verify(mMockWindowManager, times(1)).addView(any(), any());
    }

    @Test
    public void hide_removesViewFromWindowManager() {
        float testX = 100.0f;
        float testY = 200.0f;

        // First show the indicator.
        mPointIndicator.show(testX, testY);

        // Then hide it.
        mPointIndicator.hide();

        // Verify view is removed from window manager.
        verify(mMockWindowManager).removeView(eq(mPointIndicator));

        // Verify indicator is hidden.
        assertThat(mPointIndicator.isVisible()).isFalse();
    }
}