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

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

Merge changes I7424c277,I52767c2d into main

* changes:
  Refactor LetterboxScrollProcessor
  Extract stylus compatibility logic into a separate class
parents 95332e0f 471293da
Loading
Loading
Loading
Loading
+16 −23
Original line number Diff line number Diff line
@@ -17,11 +17,9 @@
package android.view;

import android.content.Context;
import android.os.Build;
import android.os.Handler;
import android.view.input.LetterboxScrollProcessor;

import com.android.window.flags.Flags;
import android.view.input.StylusButtonCompatibility;

import java.util.ArrayList;
import java.util.List;
@@ -36,6 +34,7 @@ public class InputEventCompatProcessor {

    protected Context mContext;
    protected int mTargetSdkVersion;
    private final StylusButtonCompatibility mStylusButtonCompatibility;
    private final LetterboxScrollProcessor mLetterboxScrollProcessor;

    /** List of events to be used to return the processed events */
@@ -48,7 +47,12 @@ public class InputEventCompatProcessor {
    public InputEventCompatProcessor(Context context, Handler handler) {
        mContext = context;
        mTargetSdkVersion = context.getApplicationInfo().targetSdkVersion;
        if (Flags.scrollingFromLetterbox()) {
        if (StylusButtonCompatibility.isCompatibilityNeeded(context)) {
            mStylusButtonCompatibility = new StylusButtonCompatibility();
        } else {
            mStylusButtonCompatibility = null;
        }
        if (LetterboxScrollProcessor.isCompatibilityNeeded()) {
            mLetterboxScrollProcessor = new LetterboxScrollProcessor(mContext, handler);
        } else {
            mLetterboxScrollProcessor = null;
@@ -73,7 +77,7 @@ public class InputEventCompatProcessor {
        final InputEvent stylusCompatEvent = processStylusButtonCompatibility(inputEvent);

        // Process the event for LetterboxScrollCompatibility.
        List<MotionEvent> letterboxScrollCompatEvents = processLetterboxScrollCompatibility(
        List<InputEvent> letterboxScrollCompatEvents = processLetterboxScrollCompatibility(
                stylusCompatEvent != null ? stylusCompatEvent : inputEvent);

        // If no adjustments are needed for LetterboxCompatibility.
@@ -100,9 +104,9 @@ public class InputEventCompatProcessor {
     * @return The InputEvent to finish, or null if it should not be finished.
     */
    public InputEvent processInputEventBeforeFinish(InputEvent inputEvent) {
        if (mLetterboxScrollProcessor != null && inputEvent instanceof MotionEvent motionEvent) {
        if (mLetterboxScrollProcessor != null) {
            // LetterboxScrollProcessor may have generated events while processing motion events.
            return mLetterboxScrollProcessor.processMotionEventBeforeFinish(motionEvent);
            return mLetterboxScrollProcessor.processInputEventBeforeFinish(inputEvent);
        }

        // No changes needed
@@ -110,27 +114,16 @@ public class InputEventCompatProcessor {
    }


    private List<MotionEvent> processLetterboxScrollCompatibility(InputEvent inputEvent) {
        if (mLetterboxScrollProcessor != null
                && inputEvent instanceof MotionEvent motionEvent
                && motionEvent.getAction() != MotionEvent.ACTION_OUTSIDE) {
            return mLetterboxScrollProcessor.processMotionEvent(motionEvent);
    private List<InputEvent> processLetterboxScrollCompatibility(InputEvent inputEvent) {
        if (mLetterboxScrollProcessor != null) {
            return mLetterboxScrollProcessor.processInputEventForCompatibility(inputEvent);
        }
        return null;
    }


    private InputEvent processStylusButtonCompatibility(InputEvent inputEvent) {
        if (mTargetSdkVersion < Build.VERSION_CODES.M && inputEvent instanceof MotionEvent) {
            MotionEvent motion = (MotionEvent) inputEvent;
            final int mask =
                    MotionEvent.BUTTON_STYLUS_PRIMARY | MotionEvent.BUTTON_STYLUS_SECONDARY;
            final int buttonState = motion.getButtonState();
            final int compatButtonState = (buttonState & mask) >> 4;
            if (compatButtonState != 0) {
                motion.setButtonState(buttonState | compatButtonState);
            }
            return motion;
        if (mStylusButtonCompatibility != null) {
            return mStylusButtonCompatibility.processInputEventForCompatibility(inputEvent);
        }
        return null;
    }
+28 −21
Original line number Diff line number Diff line
@@ -16,8 +16,7 @@

package android.view.input;

import static com.android.internal.annotations.VisibleForTesting.Visibility.PACKAGE;

import android.annotation.NonNull;
import android.annotation.Nullable;
import android.content.Context;
import android.graphics.Rect;
@@ -27,9 +26,7 @@ import android.view.InputDevice;
import android.view.InputEvent;
import android.view.MotionEvent;

import androidx.annotation.NonNull;

import com.android.internal.annotations.VisibleForTesting;
import com.android.window.flags.Flags;

import java.util.ArrayList;
import java.util.HashSet;
@@ -38,12 +35,11 @@ import java.util.Objects;
import java.util.Set;

/**
 * {@link MotionEvent} processor that forwards scrolls on the letterbox area to the app's view
 * {@link InputEvent} processor that forwards scrolls on the letterbox area to the app's view
 * hierarchy by translating the coordinates to app's inbound area.
 *
 * @hide
 */
@VisibleForTesting(visibility = PACKAGE)
public class LetterboxScrollProcessor {

    private enum LetterboxScrollState {
@@ -53,11 +49,15 @@ public class LetterboxScrollProcessor {
        SCROLLING_STARTED_OUTSIDE_APP
    }

    @NonNull private LetterboxScrollState mState = LetterboxScrollState.AWAITING_GESTURE_START;
    @NonNull private final List<MotionEvent> mProcessedEvents = new ArrayList<>();
    @NonNull
    private LetterboxScrollState mState = LetterboxScrollState.AWAITING_GESTURE_START;
    @NonNull
    private final List<InputEvent> mProcessedEvents = new ArrayList<>();

    @NonNull private final GestureDetector mScrollDetector;
    @NonNull private final Context mContext;
    @NonNull
    private final GestureDetector mScrollDetector;
    @NonNull
    private final Context mContext;

    /** IDs of events generated from this class */
    private final Set<Integer> mGeneratedEventIds = new HashSet<>();
@@ -67,24 +67,32 @@ public class LetterboxScrollProcessor {
        mScrollDetector = new GestureDetector(context, new ScrollListener(), handler);
    }

    public static boolean isCompatibilityNeeded() {
        return Flags.scrollingFromLetterbox();
    }

    /**
     * Processes the MotionEvent. If the gesture is started in the app's bounds, or moves over the
     * Processes the InputEvent. If the gesture is started in the app's bounds, or moves over the
     * app then the motion events are not adjusted. Motion events from outside the app's
     * bounds that are detected as a scroll gesture are adjusted to be over the app's bounds.
     * Otherwise (if the events are outside the app's bounds and not part of a scroll gesture), the
     * motion events are ignored.
     *
     * @param motionEvent The MotionEvent to process.
     * @param inputEvent The InputEvent to process.
     * @return The list of adjusted events, or null if no adjustments are needed. The list is empty
     * if the event should be ignored. Do not keep a reference to the output as the list is reused.
     */
    @Nullable
    public List<MotionEvent> processMotionEvent(@NonNull MotionEvent motionEvent) {
        if (!motionEvent.isFromSource(InputDevice.SOURCE_CLASS_POINTER)) {
            // This is a non-pointer event that doesn't correspond to any location on the screen.
            // Ignore it.
    public List<InputEvent> processInputEventForCompatibility(@NonNull InputEvent inputEvent) {
        if (!(inputEvent instanceof MotionEvent motionEvent)
                || motionEvent.getAction() == MotionEvent.ACTION_OUTSIDE
                || !motionEvent.isFromSource(InputDevice.SOURCE_CLASS_POINTER)) {
            return null;
        }
        return processMotionEvent(motionEvent);
    }

    private List<InputEvent> processMotionEvent(@NonNull MotionEvent motionEvent) {
        mProcessedEvents.clear();
        final Rect appBounds = getAppBounds();

@@ -146,13 +154,12 @@ public class LetterboxScrollProcessor {
     * Processes the InputEvent for compatibility before it is finished by calling
     * InputEventReceiver#finishInputEvent().
     *
     * @param motionEvent The MotionEvent to process.
     * @param inputEvent The InputEvent to process.
     * @return The motionEvent to finish, or null if it should not be finished.
     */
    @Nullable
    @VisibleForTesting(visibility = PACKAGE)
    public InputEvent processMotionEventBeforeFinish(@NonNull MotionEvent motionEvent) {
        return mGeneratedEventIds.remove(motionEvent.getId()) ? null : motionEvent;
    public InputEvent processInputEventBeforeFinish(@NonNull InputEvent inputEvent) {
        return mGeneratedEventIds.remove(inputEvent.getId()) ? null : inputEvent;
    }

    @NonNull
+62 −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 android.view.input;

import android.annotation.NonNull;
import android.annotation.Nullable;
import android.content.Context;
import android.os.Build;
import android.view.InputEvent;
import android.view.MotionEvent;

/**
 * This rewrites stylus button events for an application targeting older SDK.
 *
 * @hide
 */
public class StylusButtonCompatibility {
    private static final int STYLUS_BUTTONS_MASK =
            MotionEvent.BUTTON_STYLUS_PRIMARY | MotionEvent.BUTTON_STYLUS_SECONDARY;

    /**
     * Returns {@code true} if this compatibility is required based on the given context.
     */
    public static boolean isCompatibilityNeeded(Context context) {
        return context.getApplicationInfo().targetSdkVersion < Build.VERSION_CODES.M;
    }

    /**
     * Returns a rewritten event if compatibility is applied.
     * Returns a null if the event is not modified.
     */
    @Nullable
    public InputEvent processInputEventForCompatibility(@NonNull InputEvent inputEvent) {
        if (!(inputEvent instanceof MotionEvent motion)) {
            return null;
        }
        final int buttonState = motion.getButtonState();
        // BUTTON_STYLUS_PRIMARY and BUTTON_STYLUS_SECONDARY are mapped to
        // BUTTON_SECONDARY and BUTTON_TERTIARY respectively.
        final int compatButtonState = (buttonState & STYLUS_BUTTONS_MASK) >> 4;
        if (compatButtonState == 0) {
            // No need to rewrite.
            return null;
        }
        motion.setButtonState(buttonState | compatButtonState);
        return motion;
    }
}
+26 −22
Original line number Diff line number Diff line
@@ -30,6 +30,7 @@ import android.graphics.Rect;
import android.os.Handler;
import android.os.Looper;
import android.platform.test.annotations.Presubmit;
import android.view.InputEvent;
import android.view.MotionEvent;
import android.view.ViewConfiguration;

@@ -81,7 +82,7 @@ public class LetterboxScrollProcessorTest {
                /* startX= */ 0f, /* startY= */ 0f);

        // Get processed events from Letterbox Scroll Processor.
        final List<MotionEvent> processedEvents = processMotionEvents(tapGestureEvents);
        final List<InputEvent> processedEvents = processMotionEvents(tapGestureEvents);

        // Ensure no changes are made to events after processing - event locations should not be
        // adjusted because the gesture started in the app's bounds (for all gestures).
@@ -99,7 +100,7 @@ public class LetterboxScrollProcessorTest {
                dialogBounds);

        // Get processed events from Letterbox Scroll Processor.
        List<MotionEvent> processedEvents = processMotionEvents(tapGestureEvents);
        List<InputEvent> processedEvents = processMotionEvents(tapGestureEvents);

        // Ensure no changes are made to events after processing - the event should be forwarded as
        // normal.
@@ -115,7 +116,7 @@ public class LetterboxScrollProcessorTest {
                /* startX= */ -100f, /* startY= */ -100f);

        // Get processed events from Letterbox Scroll Processor.
        final List<MotionEvent> processedEvents = processMotionEvents(tapGestureEvents);
        final List<InputEvent> processedEvents = processMotionEvents(tapGestureEvents);

        // All events should be ignored since it was a non-scroll gesture and out of bounds.
        assertEquals(0, processedEvents.size());
@@ -128,7 +129,7 @@ public class LetterboxScrollProcessorTest {
                /* startX= */ 0f, /* startY= */ 0f);

        // Get processed events from Letterbox Scroll Processor.
        final List<MotionEvent> processedEvents = processMotionEvents(scrollGestureEvents);
        final List<InputEvent> processedEvents = processMotionEvents(scrollGestureEvents);

        // Ensure no changes are made to events after processing - event locations should not be
        // adjusted because the gesture started in the app's bounds (for all gestures).
@@ -144,7 +145,7 @@ public class LetterboxScrollProcessorTest {
                /* startX= */ 390f, /* startY= */ 790f);

        // Get processed events from Letterbox Scroll Processor.
        final List<MotionEvent> processedEvents = processMotionEvents(scrollGestureEvents);
        final List<InputEvent> processedEvents = processMotionEvents(scrollGestureEvents);

        // Ensure no changes are made to events after processing - event locations should not be
        // adjusted because the gesture started in the app's bounds (for all gestures), even if it
@@ -161,18 +162,18 @@ public class LetterboxScrollProcessorTest {
                /* startX= */ -100f, /* startY= */ 0f);

        // Get processed events from Letterbox Scroll Processor.
        List<MotionEvent> processedEvents = processMotionEvents(scrollGestureEvents);
        List<InputEvent> processedEvents = processMotionEvents(scrollGestureEvents);

        // When a scroll occurs outside bounds: once detected as a scroll, the ACTION_DOWN is
        // expected to be received again but with an offset so it is over the app's bounds.

        // Ensure offset ACTION_DOWN is first event received.
        MotionEvent firstProcessedEvent = processedEvents.getFirst();
        MotionEvent firstProcessedEvent = (MotionEvent) processedEvents.getFirst();
        assertEquals(ACTION_DOWN, firstProcessedEvent.getAction());
        assertEquals(0, firstProcessedEvent.getX(), EPSILON);
        assertEquals(0, firstProcessedEvent.getY(), EPSILON);
        // Ensure this event is not finished (because it was generated by LetterboxScrollProcessor).
        assertNull(mLetterboxScrollProcessor.processMotionEventBeforeFinish(firstProcessedEvent));
        assertNull(mLetterboxScrollProcessor.processInputEventBeforeFinish(firstProcessedEvent));
    }

    @Test
@@ -182,7 +183,7 @@ public class LetterboxScrollProcessorTest {
                /* startX= */ -100f, /* startY= */ 0f);

        // Get processed events from Letterbox Scroll Processor.
        final List<MotionEvent> processedEvents = processMotionEvents(scrollGestureEvents);
        final List<InputEvent> processedEvents = processMotionEvents(scrollGestureEvents);

        // When a scroll occurs outside bounds: once detected as a scroll, an offset ACTION_DOWN is
        // placed and then the rest of the gesture is offset also. Some ACTION_MOVE events may be
@@ -198,12 +199,12 @@ public class LetterboxScrollProcessorTest {
    }

    @NonNull
    private List<MotionEvent> processMotionEvents(@NonNull List<MotionEvent> motionEvents) {
        final List<MotionEvent> processedEvents = new ArrayList<>();
    private List<InputEvent> processMotionEvents(@NonNull List<MotionEvent> motionEvents) {
        final List<InputEvent> processedEvents = new ArrayList<>();
        for (MotionEvent motionEvent : motionEvents) {
            MotionEvent clonedEvent = MotionEvent.obtain(motionEvent);
            List<MotionEvent> letterboxScrollCompatEvents =
                    mLetterboxScrollProcessor.processMotionEvent(clonedEvent);
            List<InputEvent> letterboxScrollCompatEvents =
                    mLetterboxScrollProcessor.processInputEventForCompatibility(clonedEvent);
            if (letterboxScrollCompatEvents == null) {
                // Use original event if null returned (no adjustments made).
                processedEvents.add(clonedEvent);
@@ -284,35 +285,38 @@ public class LetterboxScrollProcessorTest {

    private void assertEventLocationsAreNotAdjusted(
            @NonNull List<MotionEvent> originalEvents,
            @NonNull List<MotionEvent> processedEvents) {
            @NonNull List<InputEvent> processedEvents) {
        assertEquals("MotionEvent arrays are not the same size",
                originalEvents.size(), processedEvents.size());

        for (int i = 0; i < originalEvents.size(); i++) {
            assertEquals("X coordinates was unexpectedly adjusted at index " + i,
                    originalEvents.get(i).getX(), processedEvents.get(i).getX(), EPSILON);
                    originalEvents.get(i).getX(), ((MotionEvent) processedEvents.get(i)).getX(),
                    EPSILON);
            assertEquals("Y coordinates was unexpectedly adjusted at index " + i,
                    originalEvents.get(i).getY(), processedEvents.get(i).getY(), EPSILON);
                    originalEvents.get(i).getY(), ((MotionEvent) processedEvents.get(i)).getY(),
                    EPSILON);
        }
    }

    private void assertXCoordinatesAdjustedToZero(
            @NonNull List<MotionEvent> originalEvents,
            @NonNull List<MotionEvent> processedEvents) {
            @NonNull List<InputEvent> processedEvents) {
        assertEquals("MotionEvent arrays are not the same size",
                originalEvents.size(), processedEvents.size());

        for (int i = 0; i < originalEvents.size(); i++) {
            assertEquals("X coordinate was not adjusted to 0 at index " + i,
                    0, processedEvents.get(i).getX(), EPSILON);
                    0, ((MotionEvent) processedEvents.get(i)).getX(), EPSILON);
            assertEquals("Y coordinate was unexpectedly adjusted at index " + i,
                    originalEvents.get(i).getY(), processedEvents.get(i).getY(), EPSILON);
                    originalEvents.get(i).getY(), ((MotionEvent) processedEvents.get(i)).getY(),
                    EPSILON);
        }
    }

    private void assertMotionEventsShouldBeFinished(@NonNull List<MotionEvent> processedEvents) {
        for (MotionEvent processedEvent : processedEvents) {
            assertNotNull(mLetterboxScrollProcessor.processMotionEventBeforeFinish(processedEvent));
    private void assertMotionEventsShouldBeFinished(@NonNull List<InputEvent> processedEvents) {
        for (InputEvent processedEvent : processedEvents) {
            assertNotNull(mLetterboxScrollProcessor.processInputEventBeforeFinish(processedEvent));
        }
    }
}
+98 −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 android.view.input

import android.content.Context
import android.os.Build
import android.os.SystemClock
import android.platform.test.annotations.Presubmit
import android.view.MotionEvent
import androidx.test.filters.SmallTest
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test

/**
 * Tests for [StylusButtonCompatibility].
 *
 * Build/Install/Run:
 * atest FrameworksCoreTests:StylusButtonCompatibilityTest
 */
@SmallTest
@Presubmit
class StylusButtonCompatibilityTest {
    private lateinit var stylusButtonCompatibility: StylusButtonCompatibility
    private lateinit var context: Context

    @Before
    fun setUp() {
        context = InstrumentationRegistry.getInstrumentation().targetContext
        stylusButtonCompatibility = StylusButtonCompatibility()
    }

    @Test
    fun targetSdkMCompatibilityNotNeeded() {
        context.getApplicationInfo().targetSdkVersion = Build.VERSION_CODES.M

        assertFalse(StylusButtonCompatibility.isCompatibilityNeeded(context))
    }

    @Test
    fun targetSdkLCompatibilityNotNeeded() {
        context.getApplicationInfo().targetSdkVersion = Build.VERSION_CODES.LOLLIPOP_MR1

        assertTrue(StylusButtonCompatibility.isCompatibilityNeeded(context))
    }

    @Test
    fun primaryStylusButtonAddsSecondaryButton() {
        val event = MotionEvent.obtain(
            SystemClock.uptimeMillis(), SystemClock.uptimeMillis(),
            MotionEvent.ACTION_BUTTON_PRESS, /* x= */ 100f, /* y= */ 200f, /* metaState= */ 0
        )
        event.buttonState = MotionEvent.BUTTON_STYLUS_PRIMARY

        val result = stylusButtonCompatibility.processInputEventForCompatibility(event)

        assertNotNull(result)
        assertEquals(
            MotionEvent.BUTTON_SECONDARY or MotionEvent.BUTTON_STYLUS_PRIMARY,
            (result as MotionEvent).buttonState
        )
    }

    @Test
    fun secondaryStylusButtonAddsTertiaryButton() {
        val event = MotionEvent.obtain(
            SystemClock.uptimeMillis(), SystemClock.uptimeMillis(),
            MotionEvent.ACTION_BUTTON_PRESS, /* x= */ 100f, /* y= */ 200f, /* metaState= */ 0
        )
        event.buttonState = MotionEvent.BUTTON_STYLUS_SECONDARY

        val result = stylusButtonCompatibility.processInputEventForCompatibility(event)

        assertNotNull(result)
        assertEquals(
            MotionEvent.BUTTON_TERTIARY or MotionEvent.BUTTON_STYLUS_SECONDARY,
            (result as MotionEvent).buttonState
        )
    }
}