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

Commit 5b20222b authored by Omar Abdelmonem's avatar Omar Abdelmonem
Browse files

Implement draggability feature for TouchpadDebugView

Added a draggability feature to TouchpadDebugView, allowing users to
reposition the view by touch. Constrained the view's movement to stay
within the screen boundaries, preventing it from being dragged
off-screen. Handled screen rotation events by adjusting the view's
position to remain within the new screen dimensions after rotation.

Bug: 359897456
Bug: 360137357

Test: atest TouchpadDebugViewTest
Test: Added unit tests to cover scenarios such as dragging the view,
ensuring the view stays within screen bounds, verifying no movement
for minimal touch input, and confirming the view returns to its
original position when the action is canceled. Manual testing was
also performed to validate these behaviors.
flag: com.android.hardware.input.touchpad_visualizer

Change-Id: I636a01e0741e32154ccee575e178f2aa38e44a0a
parent eeb12637
Loading
Loading
Loading
Loading
+136 −3
Original line number Diff line number Diff line
@@ -16,23 +16,69 @@

package com.android.server.input.debug;

import android.annotation.NonNull;
import android.content.Context;
import android.content.res.Configuration;
import android.graphics.Color;
import android.graphics.PixelFormat;
import android.graphics.Rect;
import android.util.Slog;
import android.view.Gravity;
import android.view.MotionEvent;
import android.view.ViewConfiguration;
import android.view.WindowManager;
import android.widget.LinearLayout;
import android.widget.TextView;

public class TouchpadDebugView extends LinearLayout {
import java.util.Objects;

public class TouchpadDebugView extends LinearLayout {
    /**
     * Input device ID for the touchpad that this debug view is displaying.
     */
    private final int mTouchpadId;

    @NonNull
    private final WindowManager mWindowManager;

    @NonNull
    private final WindowManager.LayoutParams mWindowLayoutParams;

    private final int mTouchSlop;

    private float mTouchDownX;
    private float mTouchDownY;
    private int mScreenWidth;
    private int mScreenHeight;
    private int mWindowLocationBeforeDragX;
    private int mWindowLocationBeforeDragY;

    public TouchpadDebugView(Context context, int touchpadId) {
        super(context);
        mTouchpadId = touchpadId;
        mWindowManager =
                Objects.requireNonNull(getContext().getSystemService(WindowManager.class));
        init(context);
        mTouchSlop = ViewConfiguration.get(context).getScaledTouchSlop();

        // TODO(b/360137366): Use the hardware properties to initialise layout parameters.
        mWindowLayoutParams = new WindowManager.LayoutParams();
        mWindowLayoutParams.type = WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY;
        mWindowLayoutParams.flags = WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
                | WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN;
        mWindowLayoutParams.privateFlags |=
                WindowManager.LayoutParams.SYSTEM_FLAG_SHOW_FOR_ALL_USERS;
        mWindowLayoutParams.setFitInsetsTypes(0);
        mWindowLayoutParams.layoutInDisplayCutoutMode =
                WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS;
        mWindowLayoutParams.format = PixelFormat.TRANSLUCENT;
        mWindowLayoutParams.setTitle("TouchpadDebugView - display " + mContext.getDisplayId());

        mWindowLayoutParams.x = 40;
        mWindowLayoutParams.y = 100;
        mWindowLayoutParams.width = WindowManager.LayoutParams.WRAP_CONTENT;
        mWindowLayoutParams.height = WindowManager.LayoutParams.WRAP_CONTENT;
        mWindowLayoutParams.gravity = Gravity.TOP | Gravity.LEFT;
    }

    private void init(Context context) {
@@ -43,14 +89,12 @@ public class TouchpadDebugView extends LinearLayout {
        setBackgroundColor(Color.TRANSPARENT);

        // TODO(b/286551975): Replace this content with the touchpad debug view.

        TextView textView1 = new TextView(context);
        textView1.setBackgroundColor(Color.parseColor("#FFFF0000"));
        textView1.setTextSize(20);
        textView1.setText("Touchpad Debug View 1");
        textView1.setGravity(Gravity.CENTER);
        textView1.setTextColor(Color.WHITE);

        textView1.setLayoutParams(new LayoutParams(1000, 200));

        TextView textView2 = new TextView(context);
@@ -63,9 +107,98 @@ public class TouchpadDebugView extends LinearLayout {

        addView(textView1);
        addView(textView2);

        updateScreenDimensions();
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        float deltaX;
        float deltaY;
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                mWindowLocationBeforeDragX = mWindowLayoutParams.x;
                mWindowLocationBeforeDragY = mWindowLayoutParams.y;
                mTouchDownX = event.getRawX() - mWindowLocationBeforeDragX;
                mTouchDownY = event.getRawY() - mWindowLocationBeforeDragY;
                return true;

            case MotionEvent.ACTION_MOVE:
                deltaX = event.getRawX() - mWindowLayoutParams.x - mTouchDownX;
                deltaY = event.getRawY() - mWindowLayoutParams.y - mTouchDownY;
                Slog.d("TouchpadDebugView", "Slop = " + mTouchSlop);
                if (isSlopExceeded(deltaX, deltaY)) {
                    Slog.d("TouchpadDebugView", "Slop exceeded");
                    mWindowLayoutParams.x =
                            Math.max(0, Math.min((int) (event.getRawX() - mTouchDownX),
                                    mScreenWidth - this.getWidth()));
                    mWindowLayoutParams.y =
                            Math.max(0, Math.min((int) (event.getRawY() - mTouchDownY),
                                    mScreenHeight - this.getHeight()));

                    Slog.d("TouchpadDebugView", "New position X: "
                            + mWindowLayoutParams.x + ", Y: " + mWindowLayoutParams.y);

                    mWindowManager.updateViewLayout(this, mWindowLayoutParams);
                }
                return true;

            case MotionEvent.ACTION_UP:
                deltaX = event.getRawX() - mWindowLayoutParams.x - mTouchDownX;
                deltaY = event.getRawY() - mWindowLayoutParams.y - mTouchDownY;
                if (!isSlopExceeded(deltaX, deltaY)) {
                    performClick();
                }
                return true;

            case MotionEvent.ACTION_CANCEL:
                // Move the window back to the original position
                mWindowLayoutParams.x = mWindowLocationBeforeDragX;
                mWindowLayoutParams.y = mWindowLocationBeforeDragY;
                mWindowManager.updateViewLayout(this, mWindowLayoutParams);
                return true;

            default:
                return super.onTouchEvent(event);
        }
    }

    @Override
    public boolean performClick() {
        super.performClick();
        Slog.d("TouchpadDebugView", "You clicked me!");
        return true;
    }

    @Override
    protected void onConfigurationChanged(Configuration newConfig) {
        super.onConfigurationChanged(newConfig);
        updateScreenDimensions();

        // Adjust view position to stay within screen bounds after rotation
        mWindowLayoutParams.x =
                Math.max(0, Math.min(mWindowLayoutParams.x, mScreenWidth - getWidth()));
        mWindowLayoutParams.y =
                Math.max(0, Math.min(mWindowLayoutParams.y, mScreenHeight - getHeight()));
        mWindowManager.updateViewLayout(this, mWindowLayoutParams);
    }

    private boolean isSlopExceeded(float deltaX, float deltaY) {
        return deltaX * deltaX + deltaY * deltaY >= mTouchSlop * mTouchSlop;
    }

    private void updateScreenDimensions() {
        Rect windowBounds =
                mWindowManager.getCurrentWindowMetrics().getBounds();
        mScreenWidth = windowBounds.width();
        mScreenHeight = windowBounds.height();
    }

    public int getTouchpadId() {
        return mTouchpadId;
    }

    public WindowManager.LayoutParams getWindowLayoutParams() {
        return mWindowLayoutParams;
    }
}
+6 −23
Original line number Diff line number Diff line
@@ -18,14 +18,12 @@ package com.android.server.input.debug;

import android.annotation.Nullable;
import android.content.Context;
import android.graphics.PixelFormat;
import android.hardware.display.DisplayManager;
import android.hardware.input.InputManager;
import android.os.Handler;
import android.os.Looper;
import android.util.Slog;
import android.view.Display;
import android.view.Gravity;
import android.view.InputDevice;
import android.view.WindowManager;

@@ -36,12 +34,14 @@ import java.util.Objects;

public class TouchpadDebugViewController {

    private static final String TAG = "TouchpadDebugViewController";
    private static final String TAG = "TouchpadDebugView";

    private final Context mContext;
    private final Handler mHandler;

    @Nullable
    private TouchpadDebugView mTouchpadDebugView;

    private final InputManagerService mInputManagerService;

    public TouchpadDebugViewController(Context context, Looper looper,
@@ -95,32 +95,15 @@ public class TouchpadDebugViewController {
                mContext.getSystemService(WindowManager.class));

        mTouchpadDebugView = new TouchpadDebugView(mContext, touchpadId);
        final WindowManager.LayoutParams mWindowLayoutParams =
                mTouchpadDebugView.getWindowLayoutParams();

        final WindowManager.LayoutParams lp = new WindowManager.LayoutParams();
        lp.type = WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY;
        lp.flags = WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
                | WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN;
        lp.privateFlags |= WindowManager.LayoutParams.SYSTEM_FLAG_SHOW_FOR_ALL_USERS;
        lp.setFitInsetsTypes(0);
        lp.layoutInDisplayCutoutMode =
                WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS;
        lp.format = PixelFormat.TRANSLUCENT;
        lp.setTitle("TouchpadDebugView - display " + mContext.getDisplayId());
        lp.inputFeatures |= WindowManager.LayoutParams.INPUT_FEATURE_NO_INPUT_CHANNEL;

        lp.x = 40;
        lp.y = 100;
        lp.width = WindowManager.LayoutParams.WRAP_CONTENT;
        lp.height = WindowManager.LayoutParams.WRAP_CONTENT;
        lp.gravity = Gravity.TOP | Gravity.LEFT;

        wm.addView(mTouchpadDebugView, lp);
        wm.addView(mTouchpadDebugView, mWindowLayoutParams);
        Slog.d(TAG, "Touchpad debug view created.");

        TouchpadHardwareProperties mTouchpadHardwareProperties =
                mInputManagerService.getTouchpadHardwareProperties(
                        touchpadId);
        // TODO(b/360137366): Use the hardware properties to initialise layout parameters.
        if (mTouchpadHardwareProperties != null) {
            Slog.d(TAG, mTouchpadHardwareProperties.toString());
        } else {
+292 −0
Original line number Diff line number Diff line
/*
 * Copyright 2024 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.input.debug;

import static android.view.InputDevice.SOURCE_TOUCHSCREEN;

import static org.junit.Assert.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

import android.content.Context;
import android.graphics.Rect;
import android.testing.TestableContext;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewConfiguration;
import android.view.WindowInsets;
import android.view.WindowManager;
import android.view.WindowMetrics;

import androidx.test.platform.app.InstrumentationRegistry;
import androidx.test.runner.AndroidJUnit4;

import com.android.cts.input.MotionEventBuilder;
import com.android.cts.input.PointerBuilder;

import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;

/**
 * Build/Install/Run:
 * atest TouchpadDebugViewTest
 */
@RunWith(AndroidJUnit4.class)
public class TouchpadDebugViewTest {
    private static final int TOUCHPAD_DEVICE_ID = 6;

    private TouchpadDebugView mTouchpadDebugView;
    private WindowManager.LayoutParams mWindowLayoutParams;

    @Mock
    WindowManager mWindowManager;

    Rect mWindowBounds;
    WindowMetrics mWindowMetrics;
    TestableContext mTestableContext;

    @Before
    public void setUp() {
        MockitoAnnotations.initMocks(this);
        Context context = InstrumentationRegistry.getInstrumentation().getContext();
        mTestableContext = new TestableContext(context);

        mTestableContext.addMockSystemService(WindowManager.class, mWindowManager);

        mWindowBounds = new Rect(0, 0, 2560, 1600);
        mWindowMetrics = new WindowMetrics(mWindowBounds, new WindowInsets(mWindowBounds), 1.0f);

        when(mWindowManager.getCurrentWindowMetrics()).thenReturn(mWindowMetrics);

        mTouchpadDebugView = new TouchpadDebugView(mTestableContext, TOUCHPAD_DEVICE_ID);

        mTouchpadDebugView.measure(
                View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED),
                View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)
        );

        doAnswer(invocation -> {
            mTouchpadDebugView.layout(0, 0, mTouchpadDebugView.getMeasuredWidth(),
                    mTouchpadDebugView.getMeasuredHeight());
            return null;
        }).when(mWindowManager).addView(any(), any());

        doAnswer(invocation -> {
            mTouchpadDebugView.layout(0, 0, mTouchpadDebugView.getMeasuredWidth(),
                    mTouchpadDebugView.getMeasuredHeight());
            return null;
        }).when(mWindowManager).updateViewLayout(any(), any());

        mWindowLayoutParams = mTouchpadDebugView.getWindowLayoutParams();
        mWindowLayoutParams.x = 20;
        mWindowLayoutParams.y = 20;

        mTouchpadDebugView.layout(0, 0, mTouchpadDebugView.getMeasuredWidth(),
                mTouchpadDebugView.getMeasuredHeight());
    }

    @Test
    public void testDragView() {
        // Initial view position relative to screen.
        int initialX = mWindowLayoutParams.x;
        int initialY = mWindowLayoutParams.y;

        float offsetX = ViewConfiguration.get(mTestableContext).getScaledTouchSlop() + 10;
        float offsetY = ViewConfiguration.get(mTestableContext).getScaledTouchSlop() + 10;

        // Simulate ACTION_DOWN event (initial touch).
        MotionEvent actionDown = new MotionEventBuilder(MotionEvent.ACTION_DOWN, SOURCE_TOUCHSCREEN)
                .pointer(new PointerBuilder(0, MotionEvent.TOOL_TYPE_FINGER)
                        .x(40f)
                        .y(40f)
                )
                .build();
        mTouchpadDebugView.dispatchTouchEvent(actionDown);

        verify(mWindowManager, times(0)).updateViewLayout(any(), any());

        // Simulate ACTION_MOVE event (dragging to the right).
        MotionEvent actionMove = new MotionEventBuilder(MotionEvent.ACTION_MOVE, SOURCE_TOUCHSCREEN)
                .pointer(new PointerBuilder(0, MotionEvent.TOOL_TYPE_FINGER)
                        .x(40f + offsetX)
                        .y(40f + offsetY)
                )
                .build();
        mTouchpadDebugView.dispatchTouchEvent(actionMove);

        ArgumentCaptor<WindowManager.LayoutParams> mWindowLayoutParamsCaptor =
                ArgumentCaptor.forClass(WindowManager.LayoutParams.class);
        verify(mWindowManager).updateViewLayout(any(), mWindowLayoutParamsCaptor.capture());

        // Verify position after ACTION_MOVE
        assertEquals(initialX + (long) offsetX, mWindowLayoutParamsCaptor.getValue().x);
        assertEquals(initialY + (long) offsetY, mWindowLayoutParamsCaptor.getValue().y);

        // Simulate ACTION_UP event (release touch).
        MotionEvent actionUp = new MotionEventBuilder(MotionEvent.ACTION_UP, SOURCE_TOUCHSCREEN)
                .pointer(new PointerBuilder(0, MotionEvent.TOOL_TYPE_FINGER)
                        .x(40f + offsetX)
                        .y(40f + offsetY)
                )
                .build();
        mTouchpadDebugView.dispatchTouchEvent(actionUp);

        assertEquals(initialX + (long) offsetX, mWindowLayoutParamsCaptor.getValue().x);
        assertEquals(initialY + (long) offsetY, mWindowLayoutParamsCaptor.getValue().y);
    }

    @Test
    public void testDragViewOutOfBounds() {
        int initialX = mWindowLayoutParams.x;
        int initialY = mWindowLayoutParams.y;

        MotionEvent actionDown = new MotionEventBuilder(MotionEvent.ACTION_DOWN, SOURCE_TOUCHSCREEN)
                .pointer(new PointerBuilder(0, MotionEvent.TOOL_TYPE_FINGER)
                        .x(initialX + 10f)
                        .y(initialY + 10f)
                )
                .build();
        mTouchpadDebugView.dispatchTouchEvent(actionDown);

        verify(mWindowManager, times(0)).updateViewLayout(any(), any());

        // Simulate ACTION_MOVE event (dragging far to the right and bottom, beyond screen bounds)
        MotionEvent actionMove = new MotionEventBuilder(MotionEvent.ACTION_MOVE, SOURCE_TOUCHSCREEN)
                .pointer(new PointerBuilder(0, MotionEvent.TOOL_TYPE_FINGER)
                        .x(mWindowBounds.width() + mTouchpadDebugView.getWidth())
                        .y(mWindowBounds.height() + mTouchpadDebugView.getHeight())
                )
                .build();
        mTouchpadDebugView.dispatchTouchEvent(actionMove);

        ArgumentCaptor<WindowManager.LayoutParams> mWindowLayoutParamsCaptor =
                ArgumentCaptor.forClass(WindowManager.LayoutParams.class);
        verify(mWindowManager).updateViewLayout(any(), mWindowLayoutParamsCaptor.capture());

        // Verify the view has been clamped to the right and bottom edges of the screen
        assertEquals(mWindowBounds.width() - mTouchpadDebugView.getWidth(),
                mWindowLayoutParamsCaptor.getValue().x);
        assertEquals(mWindowBounds.height() - mTouchpadDebugView.getHeight(),
                mWindowLayoutParamsCaptor.getValue().y);

        MotionEvent actionUp = new MotionEventBuilder(MotionEvent.ACTION_UP, SOURCE_TOUCHSCREEN)
                .pointer(new PointerBuilder(0, MotionEvent.TOOL_TYPE_FINGER)
                        .x(mWindowBounds.width() + mTouchpadDebugView.getWidth())
                        .y(mWindowBounds.height() + mTouchpadDebugView.getHeight())
                )
                .build();
        mTouchpadDebugView.dispatchTouchEvent(actionUp);

        // Verify the view has been clamped to the right and bottom edges of the screen
        assertEquals(mWindowBounds.width() - mTouchpadDebugView.getWidth(),
                mWindowLayoutParamsCaptor.getValue().x);
        assertEquals(mWindowBounds.height() - mTouchpadDebugView.getHeight(),
                mWindowLayoutParamsCaptor.getValue().y);
    }

    @Test
    public void testSlopOffset() {
        int initialX = mWindowLayoutParams.x;
        int initialY = mWindowLayoutParams.y;

        float offsetX = ViewConfiguration.get(mTestableContext).getScaledTouchSlop() / 2.0f;
        float offsetY = -(ViewConfiguration.get(mTestableContext).getScaledTouchSlop() / 2.0f);

        MotionEvent actionDown = new MotionEventBuilder(MotionEvent.ACTION_DOWN, SOURCE_TOUCHSCREEN)
                .pointer(new PointerBuilder(0, MotionEvent.TOOL_TYPE_FINGER)
                        .x(initialX)
                        .y(initialY)
                )
                .build();
        mTouchpadDebugView.dispatchTouchEvent(actionDown);

        MotionEvent actionMove = new MotionEventBuilder(MotionEvent.ACTION_MOVE, SOURCE_TOUCHSCREEN)
                .pointer(new PointerBuilder(0, MotionEvent.TOOL_TYPE_FINGER)
                        .x(initialX + offsetX)
                        .y(initialY + offsetY)
                )
                .build();
        mTouchpadDebugView.dispatchTouchEvent(actionMove);

        MotionEvent actionUp = new MotionEventBuilder(MotionEvent.ACTION_UP, SOURCE_TOUCHSCREEN)
                .pointer(new PointerBuilder(0, MotionEvent.TOOL_TYPE_FINGER)
                        .x(initialX)
                        .y(initialY)
                )
                .build();
        mTouchpadDebugView.dispatchTouchEvent(actionUp);

        // In this case the updateViewLayout() method wouldn't be called because the drag
        // distance hasn't exceeded the slop
        verify(mWindowManager, times(0)).updateViewLayout(any(), any());
    }

    @Test
    public void testViewReturnsToInitialPositionOnCancel() {
        int initialX = mWindowLayoutParams.x;
        int initialY = mWindowLayoutParams.y;

        float offsetX = 50;
        float offsetY = 50;

        MotionEvent actionDown = new MotionEventBuilder(MotionEvent.ACTION_DOWN, SOURCE_TOUCHSCREEN)
                .pointer(new PointerBuilder(0, MotionEvent.TOOL_TYPE_FINGER)
                        .x(initialX)
                        .y(initialY)
                )
                .build();
        mTouchpadDebugView.dispatchTouchEvent(actionDown);

        MotionEvent actionMove = new MotionEventBuilder(MotionEvent.ACTION_MOVE, SOURCE_TOUCHSCREEN)
                .pointer(new PointerBuilder(0, MotionEvent.TOOL_TYPE_FINGER)
                        .x(initialX + offsetX)
                        .y(initialY + offsetY)
                )
                .build();
        mTouchpadDebugView.dispatchTouchEvent(actionMove);

        ArgumentCaptor<WindowManager.LayoutParams> mWindowLayoutParamsCaptor =
                ArgumentCaptor.forClass(WindowManager.LayoutParams.class);
        verify(mWindowManager).updateViewLayout(any(), mWindowLayoutParamsCaptor.capture());

        assertEquals(initialX + (long) offsetX, mWindowLayoutParamsCaptor.getValue().x);
        assertEquals(initialY + (long) offsetY, mWindowLayoutParamsCaptor.getValue().y);

        // Simulate ACTION_CANCEL event (canceling the touch event stream)
        MotionEvent actionCancel = new MotionEventBuilder(MotionEvent.ACTION_CANCEL,
                SOURCE_TOUCHSCREEN)
                .pointer(new PointerBuilder(0, MotionEvent.TOOL_TYPE_FINGER)
                        .x(initialX + offsetX)
                        .y(initialY + offsetY)
                )
                .build();
        mTouchpadDebugView.dispatchTouchEvent(actionCancel);

        // Verify the view returns to its initial position
        verify(mWindowManager, times(2)).updateViewLayout(any(),
                mWindowLayoutParamsCaptor.capture());
        assertEquals(initialX, mWindowLayoutParamsCaptor.getValue().x);
        assertEquals(initialY, mWindowLayoutParamsCaptor.getValue().y);
    }
}