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

Commit 2e36dd54 authored by Wenyu Zhang's avatar Wenyu Zhang Committed by Android (Google) Code Review
Browse files

Merge "autoclick: Hide panel when mouse disconnects" into main

parents 71bd3e71 3421070b
Loading
Loading
Loading
Loading
+140 −0
Original line number Diff line number Diff line
@@ -44,6 +44,7 @@ import android.content.ContentResolver;
import android.content.Context;
import android.content.res.Configuration;
import android.database.ContentObserver;
import android.hardware.input.InputManager;
import android.net.Uri;
import android.os.Handler;
import android.os.SystemClock;
@@ -248,6 +249,77 @@ public class AutoclickController extends BaseEventStreamTransformation implement
                }
            };

    @VisibleForTesting InputManagerWrapper mInputManagerWrapper;

    private final InputManager.InputDeviceListener mInputDeviceListener =
            new InputManager.InputDeviceListener() {
                // True when the pointing device is connected, including mouse, touchpad, etc.
                private boolean mIsPointingDeviceConnected = false;

                // True when the autoclick type panel is temporarily hidden due to the pointing
                // device being disconnected.
                private boolean mTemporaryHideAutoclickTypePanel = false;

                @Override
                public void onInputDeviceAdded(int deviceId) {
                    onInputDeviceChanged(deviceId);
                }

                @Override
                public void onInputDeviceRemoved(int deviceId) {
                    onInputDeviceChanged(deviceId);
                }

                @Override
                public void onInputDeviceChanged(int deviceId) {
                    boolean wasConnected = mIsPointingDeviceConnected;
                    mIsPointingDeviceConnected = false;
                    for (final int id : mInputManagerWrapper.getInputDeviceIds()) {
                        final InputDeviceWrapper device = mInputManagerWrapper.getInputDevice(id);
                        if (device == null || !device.isEnabled() || device.isVirtual()) {
                            continue;
                        }
                        if (device.supportsSource(InputDevice.SOURCE_MOUSE)
                                || device.supportsSource(InputDevice.SOURCE_TOUCHPAD)
                                || device.supportsSource(InputDevice.SOURCE_STYLUS)
                                || device.supportsSource(InputDevice.SOURCE_BLUETOOTH_STYLUS)) {
                            mIsPointingDeviceConnected = true;
                            break;
                        }
                    }

                    // If the device state did not change, do nothing.
                    if (wasConnected == mIsPointingDeviceConnected) {
                        return;
                    }

                    // Pointing device state changes from connected to disconnected.
                    if (!mIsPointingDeviceConnected) {
                        if (mAutoclickTypePanel != null) {
                            mTemporaryHideAutoclickTypePanel = true;
                            mAutoclickTypePanel.hide();

                            if (mAutoclickScrollPanel != null) {
                                mAutoclickScrollPanel.hide();
                            }
                        }

                    // Pointing device state changes from disconnected to connected and the panel
                    // was temporarily hidden due to the pointing device being disconnected.
                    } else if (mTemporaryHideAutoclickTypePanel && mIsPointingDeviceConnected) {
                        if (mAutoclickTypePanel != null) {
                            mTemporaryHideAutoclickTypePanel = false;
                            mAutoclickTypePanel.show();

                            // No need to explicitly show the scroll panel here since we don't know
                            // the cursor position when the pointing device is connected. If the
                            // user disconnects the pointing device in scroll mode, another auto
                            // click will trigger the scroll panel to be shown.
                        }
                    }
                }
            };

    public AutoclickController(Context context, int userId, AccessibilityTraceManager trace) {
        mTrace = trace;
        mContext = context;
@@ -340,6 +412,12 @@ public class AutoclickController extends BaseEventStreamTransformation implement
        mAutoclickTypePanel.show();
        mContext.registerComponentCallbacks(this);
        mWindowManager.addView(mAutoclickIndicatorView, mAutoclickIndicatorView.getLayoutParams());

        if (mInputManagerWrapper == null) {
            mInputManagerWrapper =
                    new InputManagerWrapper(mContext.getSystemService(InputManager.class));
        }
        mInputManagerWrapper.registerInputDeviceListener(mInputDeviceListener, handler);
    }

    @Override
@@ -375,6 +453,10 @@ public class AutoclickController extends BaseEventStreamTransformation implement
    @Override
    public void onDestroy() {
        mContext.unregisterComponentCallbacks(this);
        if (mInputManagerWrapper != null) {
            mInputManagerWrapper.unregisterInputDeviceListener(mInputDeviceListener);
        }

        if (mAutoclickSettingsObserver != null) {
            mAutoclickSettingsObserver.stop();
            mAutoclickSettingsObserver = null;
@@ -593,6 +675,64 @@ public class AutoclickController extends BaseEventStreamTransformation implement

    }

    /** A wrapper for the final InputManager class, to allow mocking in tests. */
    @VisibleForTesting
    public static class InputManagerWrapper {
        private final InputManager mInputManager;

        InputManagerWrapper(InputManager inputManager) {
            mInputManager = inputManager;
        }

        public void registerInputDeviceListener(
                InputManager.InputDeviceListener listener, Handler handler) {
            if (mInputManager == null) return;
            mInputManager.registerInputDeviceListener(listener, handler);
        }

        public void unregisterInputDeviceListener(InputManager.InputDeviceListener listener) {
            if (mInputManager == null) return;
            mInputManager.unregisterInputDeviceListener(listener);
        }

        public int[] getInputDeviceIds() {
            if (mInputManager == null) return new int[0];
            return mInputManager.getInputDeviceIds();
        }

        public InputDeviceWrapper getInputDevice(int id) {
            if (mInputManager == null) return null;
            InputDevice device = mInputManager.getInputDevice(id);
            return device == null ? null : new InputDeviceWrapper(device);
        }
    }

    /** A wrapper for the final InputDevice class, to allow mocking in tests. */
    @VisibleForTesting
    public static class InputDeviceWrapper {
        private final InputDevice mInputDevice;

        InputDeviceWrapper(InputDevice inputDevice) {
            mInputDevice = inputDevice;
        }

        public boolean isEnabled() {
            return mInputDevice.isEnabled();
        }

        public boolean isVirtual() {
            return mInputDevice.isVirtual();
        }

        public boolean supportsSource(int source) {
            return mInputDevice.supportsSource(source);
        }

        public int getSources() {
            return mInputDevice.getSources();
        }
    }

    /**
     * Observes and updates various autoclick setting values.
     */
+23 −0
Original line number Diff line number Diff line
@@ -153,6 +153,8 @@ public class AutoclickTypePanel {
    // Whether autoclick is paused.
    private boolean mPaused = false;

    private boolean mIsPanelShown = false;

    private int mStatusBarHeight = 0;

    // The current corner position of the panel, default to bottom right.
@@ -390,19 +392,32 @@ public class AutoclickTypePanel {
    }

    public void show() {
        if (mIsPanelShown) {
            return;
        }

        // Restores the panel position from saved settings. If no valid position is saved,
        // defaults to bottom-right corner.
        restorePanelPosition();
        mWindowManager.addView(mContentView, mParams);
        mIsPanelShown = true;

        // Update icon after view is laid out on screen to ensure accurate position detection
        // (getLocationOnScreen only works properly after layout is complete).
        mContentView.post(() -> {
            updatePositionButtonIcon(getVisualCorner());
        });

        // Make sure the selected button is highlighted if not already. This is to handle the
        // case that the panel is shown when a pointing device is reconnected.
        toggleSelectedButtonStyle(mSelectedButton, /* isSelected= */ true);
    }

    public void hide() {
        if (!mIsPanelShown) {
            return;
        }

        // Sets the button background to unselected styling, this is necessary to make sure the
        // button background styling is correct when the panel shows up next time.
        toggleSelectedButtonStyle(mSelectedButton, /* isSelected= */ false);
@@ -411,6 +426,7 @@ public class AutoclickTypePanel {
        savePanelPosition();

        mWindowManager.removeView(mContentView);
        mIsPanelShown = false;
    }

    /**
@@ -513,6 +529,13 @@ public class AutoclickTypePanel {
        updatePositionButtonIcon(mCurrentCorner);
    }

    /** Resets the panel position to bottom-right corner. */
    @VisibleForTesting
    void resetPanelPositionForTesting() {
        setPanelPositionForCorner(mParams, CORNER_BOTTOM_RIGHT);
        mCurrentCorner = CORNER_BOTTOM_RIGHT;
    }

    /**
     * Sets the panel's gravity and initial x/y offsets based on the specified corner.
     * @param params The WindowManager.LayoutParams to modify.
+118 −1
Original line number Diff line number Diff line
@@ -25,8 +25,8 @@ import static com.android.server.testutils.MockitoUtilsKt.eq;
import static com.google.common.truth.Truth.assertThat;

import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyFloat;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.times;
@@ -35,6 +35,7 @@ import static org.mockito.Mockito.when;

import android.content.Context;
import android.content.res.Configuration;
import android.hardware.input.InputManager;
import android.platform.test.annotations.DisableFlags;
import android.platform.test.annotations.EnableFlags;
import android.platform.test.flag.junit.SetFlagsRule;
@@ -59,6 +60,7 @@ import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.Mockito;
import org.mockito.junit.MockitoJUnit;
@@ -86,6 +88,7 @@ public class AutoclickControllerTest {
    private AccessibilityTraceManager mMockTrace;
    @Mock
    private WindowManager mMockWindowManager;
    @Mock private AutoclickController.InputManagerWrapper mMockInputManagerWrapper;
    private AutoclickController mController;
    private MotionEventCaptor mMotionEventCaptor;

@@ -1543,6 +1546,120 @@ public class AutoclickControllerTest {
        // Verify updateConfiguration was called.
        verify(spyIndicatorView).onConfigurationChanged(newConfig);
    }

    @Test
    @EnableFlags(com.android.server.accessibility.Flags.FLAG_ENABLE_AUTOCLICK_INDICATOR)
    public void onInputDeviceChanged_disconnectAndReconnect_hidesAndShowsTypePanel() {
        // Setup: one mouse connected initially.
        mController.mInputManagerWrapper = mMockInputManagerWrapper;
        when(mMockInputManagerWrapper.getInputDeviceIds()).thenReturn(new int[] {1});
        AutoclickController.InputDeviceWrapper mockMouse =
                mock(AutoclickController.InputDeviceWrapper.class);
        when(mockMouse.supportsSource(InputDevice.SOURCE_MOUSE)).thenReturn(true);
        when(mockMouse.isEnabled()).thenReturn(true);
        when(mockMouse.isVirtual()).thenReturn(false);
        when(mMockInputManagerWrapper.getInputDevice(1)).thenReturn(mockMouse);

        // Initialize controller and panels.
        injectFakeMouseActionHoverMoveEvent();

        // Capture the listener.
        ArgumentCaptor<InputManager.InputDeviceListener> listenerCaptor =
                ArgumentCaptor.forClass(InputManager.InputDeviceListener.class);
        verify(mMockInputManagerWrapper)
                .registerInputDeviceListener(listenerCaptor.capture(), any());
        InputManager.InputDeviceListener listener = listenerCaptor.getValue();

        // Manually trigger once to establish initial connected state.
        listener.onInputDeviceChanged(1);
        mTestableLooper.processAllMessages();

        // Mock panels to verify interactions.
        AutoclickTypePanel mockTypePanel = mock(AutoclickTypePanel.class);
        AutoclickScrollPanel mockScrollPanel = mock(AutoclickScrollPanel.class);
        mController.mAutoclickTypePanel = mockTypePanel;
        mController.mAutoclickScrollPanel = mockScrollPanel;

        // Action: disconnect mouse.
        when(mMockInputManagerWrapper.getInputDeviceIds()).thenReturn(new int[0]);
        listener.onInputDeviceChanged(1);
        mTestableLooper.processAllMessages();

        // Verify panels are hidden.
        verify(mockTypePanel).hide();
        verify(mockScrollPanel).hide();

        // Action: reconnect mouse.
        when(mMockInputManagerWrapper.getInputDeviceIds()).thenReturn(new int[] {1});
        listener.onInputDeviceChanged(1);
        mTestableLooper.processAllMessages();

        // Verify type panel is shown, but scroll panel is not.
        verify(mockTypePanel).show();
        verify(mockScrollPanel, Mockito.never()).show(anyFloat(), anyFloat());
    }

    @Test
    @EnableFlags(com.android.server.accessibility.Flags.FLAG_ENABLE_AUTOCLICK_INDICATOR)
    public void onInputDeviceChanged_noConnectionChange_panelsStateUnchanged() {
        // Setup: one mouse connected initially.
        mController.mInputManagerWrapper = mMockInputManagerWrapper;
        when(mMockInputManagerWrapper.getInputDeviceIds()).thenReturn(new int[] {1});
        AutoclickController.InputDeviceWrapper mockMouse =
                mock(AutoclickController.InputDeviceWrapper.class);
        when(mockMouse.supportsSource(InputDevice.SOURCE_MOUSE)).thenReturn(true);
        when(mockMouse.isEnabled()).thenReturn(true);
        when(mockMouse.isVirtual()).thenReturn(false);
        when(mMockInputManagerWrapper.getInputDevice(1)).thenReturn(mockMouse);

        // Initialize controller and panels.
        injectFakeMouseActionHoverMoveEvent();

        // Capture the listener.
        ArgumentCaptor<InputManager.InputDeviceListener> listenerCaptor =
                ArgumentCaptor.forClass(InputManager.InputDeviceListener.class);
        verify(mMockInputManagerWrapper)
                .registerInputDeviceListener(listenerCaptor.capture(), any());
        InputManager.InputDeviceListener listener = listenerCaptor.getValue();

        // Manually trigger once to establish initial connected state.
        listener.onInputDeviceChanged(1);
        mTestableLooper.processAllMessages();

        // Mock panels to verify interactions.
        AutoclickTypePanel mockTypePanel = mock(AutoclickTypePanel.class);
        AutoclickScrollPanel mockScrollPanel = mock(AutoclickScrollPanel.class);
        mController.mAutoclickTypePanel = mockTypePanel;
        mController.mAutoclickScrollPanel = mockScrollPanel;

        // Action: trigger change, but connection state is the same (connected).
        listener.onInputDeviceChanged(1);
        mTestableLooper.processAllMessages();

        // Verify panels state is unchanged.
        verify(mockTypePanel, Mockito.never()).hide();
        verify(mockScrollPanel, Mockito.never()).hide();
        verify(mockTypePanel, Mockito.never()).show();

        // Action: disconnect mouse.
        when(mMockInputManagerWrapper.getInputDeviceIds()).thenReturn(new int[0]);
        listener.onInputDeviceChanged(1);
        mTestableLooper.processAllMessages();

        // Verify hide was called once.
        verify(mockTypePanel, times(1)).hide();
        verify(mockScrollPanel, times(1)).hide();

        // Action: trigger change, but connection state is the same (disconnected).
        listener.onInputDeviceChanged(1);
        mTestableLooper.processAllMessages();

        // Verify panels state is unchanged (hide not called again).
        verify(mockTypePanel, times(1)).hide();
        verify(mockScrollPanel, times(1)).hide();
        verify(mockTypePanel, Mockito.never()).show();
    }

    /**
     * =========================================================================
     * Helper Functions
+4 −0
Original line number Diff line number Diff line
@@ -120,6 +120,10 @@ public class AutoclickTypePanelTest {
        mPauseButton = contentView.findViewById(R.id.accessibility_autoclick_pause_layout);
        mPositionButton = contentView.findViewById(R.id.accessibility_autoclick_position_layout);
        mLongPressButton = contentView.findViewById(R.id.accessibility_autoclick_long_press_layout);

        // Set panel to default bottom-right corner.
        mAutoclickTypePanel.show();
        mAutoclickTypePanel.resetPanelPositionForTesting();
    }

    @Test