Loading core/java/android/view/InputEventCompatProcessor.java +16 −23 Original line number Diff line number Diff line Loading @@ -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; Loading @@ -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 */ Loading @@ -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; Loading @@ -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. Loading @@ -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 Loading @@ -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; } Loading core/java/android/view/input/LetterboxScrollProcessor.java +28 −21 Original line number Diff line number Diff line Loading @@ -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; Loading @@ -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; Loading @@ -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 { Loading @@ -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<>(); Loading @@ -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(); Loading Loading @@ -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 Loading core/java/android/view/input/StylusButtonCompatibility.java 0 → 100644 +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; } } core/tests/coretests/src/android/view/input/LetterboxScrollProcessorTest.java +26 −22 Original line number Diff line number Diff line Loading @@ -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; Loading Loading @@ -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). Loading @@ -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. Loading @@ -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()); Loading @@ -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). Loading @@ -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 Loading @@ -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 Loading @@ -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 Loading @@ -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); Loading Loading @@ -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)); } } } core/tests/coretests/src/android/view/input/StylusButtonCompatibilityTest.kt 0 → 100644 +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 ) } } Loading
core/java/android/view/InputEventCompatProcessor.java +16 −23 Original line number Diff line number Diff line Loading @@ -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; Loading @@ -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 */ Loading @@ -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; Loading @@ -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. Loading @@ -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 Loading @@ -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; } Loading
core/java/android/view/input/LetterboxScrollProcessor.java +28 −21 Original line number Diff line number Diff line Loading @@ -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; Loading @@ -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; Loading @@ -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 { Loading @@ -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<>(); Loading @@ -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(); Loading Loading @@ -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 Loading
core/java/android/view/input/StylusButtonCompatibility.java 0 → 100644 +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; } }
core/tests/coretests/src/android/view/input/LetterboxScrollProcessorTest.java +26 −22 Original line number Diff line number Diff line Loading @@ -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; Loading Loading @@ -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). Loading @@ -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. Loading @@ -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()); Loading @@ -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). Loading @@ -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 Loading @@ -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 Loading @@ -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 Loading @@ -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); Loading Loading @@ -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)); } } }
core/tests/coretests/src/android/view/input/StylusButtonCompatibilityTest.kt 0 → 100644 +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 ) } }