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

Commit 997e9751 authored by Jonathon Axford's avatar Jonathon Axford Committed by Android (Google) Code Review
Browse files

Merge "Letterbox scrolling: out-of-bounds gesture handling" into main

parents 0d79d91d d0816f0b
Loading
Loading
Loading
Loading
+76 −21
Original line number Diff line number Diff line
@@ -18,6 +18,9 @@ package android.view;

import android.content.Context;
import android.os.Build;
import android.os.Handler;

import com.android.window.flags.Flags;

import java.util.ArrayList;
import java.util.List;
@@ -32,50 +35,102 @@ public class InputEventCompatProcessor {

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

    /** List of events to be used to return the processed events */
    private List<InputEvent> mProcessedEvents;
    private final List<InputEvent> mProcessedEvents;

    public InputEventCompatProcessor(Context context) {
        this(context, null);
    }

    public InputEventCompatProcessor(Context context, Handler handler) {
        mContext = context;
        mTargetSdkVersion = context.getApplicationInfo().targetSdkVersion;
        if (Flags.scrollingFromLetterbox()) {
            mLetterboxScrollProcessor = new LetterboxScrollProcessor(mContext, handler);
        } else {
            mLetterboxScrollProcessor = null;
        }

        mProcessedEvents = new ArrayList<>();
    }


    /**
     * Processes the InputEvent for compatibility before it is sent to the app, allowing for the
     * generation of more than one event if necessary.
     *
     * @param e The InputEvent to process
     * @return The list of adjusted events, or null if no adjustments are needed. Do not keep a
     *         reference to the output as the list is reused.
     * @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.
     */
    public List<InputEvent> processInputEventForCompatibility(InputEvent e) {
        if (mTargetSdkVersion < Build.VERSION_CODES.M && e instanceof MotionEvent) {
    public List<InputEvent> processInputEventForCompatibility(InputEvent inputEvent) {
        mProcessedEvents.clear();
            MotionEvent motion = (MotionEvent) e;
            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);
            }
            mProcessedEvents.add(motion);

        // Process the event for StylusButtonCompatibility.
        final InputEvent stylusCompatEvent = processStylusButtonCompatibility(inputEvent);

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

        // If no adjustments are needed for LetterboxCompatibility.
        if (letterboxScrollCompatEvents == null) {
            // If stylus compatibility made adjustments, return that adjusted event.
            if (stylusCompatEvent != null) {
                mProcessedEvents.add(stylusCompatEvent);
                return mProcessedEvents;
            }
            // Otherwise, return null to indicate no adjustments.
            return null;
        }

        // Otherwise if LetterboxCompatibility made adjustments, return the list of adjusted events.
        mProcessedEvents.addAll(letterboxScrollCompatEvents);
        return mProcessedEvents;
    }

    /**
     * Processes the InputEvent for compatibility before it is finished by calling
     * InputEventReceiver#finishInputEvent().
     *
     * @param e The InputEvent to process
     * @return The InputEvent to finish, or null if it should not be finished
     * @param inputEvent The InputEvent to process.
     * @return The InputEvent to finish, or null if it should not be finished.
     */
    public InputEvent processInputEventBeforeFinish(InputEvent e) {
    public InputEvent processInputEventBeforeFinish(InputEvent inputEvent) {
        if (mLetterboxScrollProcessor != null && inputEvent instanceof MotionEvent motionEvent) {
            // LetterboxScrollProcessor may have generated events while processing motion events.
            return mLetterboxScrollProcessor.processMotionEventBeforeFinish(motionEvent);
        }

        // No changes needed
        return e;
        return inputEvent;
    }


    private List<MotionEvent> processLetterboxScrollCompatibility(InputEvent inputEvent) {
        if (mLetterboxScrollProcessor != null
                && inputEvent instanceof MotionEvent motionEvent
                && motionEvent.getAction() != MotionEvent.ACTION_OUTSIDE) {
            return mLetterboxScrollProcessor.processMotionEvent(motionEvent);
        }
        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;
        }
        return null;
    }
}
+194 −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 android.view;

import android.annotation.Nullable;
import android.content.Context;
import android.graphics.Rect;
import android.os.Handler;

import androidx.annotation.NonNull;

import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;

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

    private enum LetterboxScrollState {
        AWAITING_GESTURE_START,
        GESTURE_STARTED_IN_APP,
        GESTURE_STARTED_OUTSIDE_APP,
        SCROLLING_STARTED_OUTSIDE_APP
    }

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

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

    /** IDs of events generated from this class */
    private final Set<Integer> mGeneratedEventIds = new HashSet<>();

    public LetterboxScrollProcessor(@NonNull Context context, @Nullable Handler handler) {
        mContext = context;
        mScrollDetector = new GestureDetector(context, new ScrollListener(), handler);
    }

    /**
     * Processes the MotionEvent. 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.
     * @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.
     */
    public List<MotionEvent> processMotionEvent(MotionEvent motionEvent) {
        mProcessedEvents.clear();
        final Rect appBounds = getAppBounds();

        // Set state at the start of the gesture (when ACTION_DOWN is received)
        if (motionEvent.getAction() == MotionEvent.ACTION_DOWN) {
            if (isOutsideAppBounds(motionEvent, appBounds)) {
                mState = LetterboxScrollState.GESTURE_STARTED_OUTSIDE_APP;
            } else {
                mState = LetterboxScrollState.GESTURE_STARTED_IN_APP;
            }
        }

        boolean makeNoAdjustments = false;

        switch (mState) {
            case AWAITING_GESTURE_START:
            case GESTURE_STARTED_IN_APP:
                // Do not adjust events if gesture is started in or is over the app.
                makeNoAdjustments = true;
                break;

            case GESTURE_STARTED_OUTSIDE_APP:
                // Send offset events to the scroll-detector. These events are not added to
                // mProcessedEvents and are therefore ignored until detected as part of a scroll.
                applyOffset(motionEvent, appBounds);
                mScrollDetector.onTouchEvent(motionEvent);
                // If scroll-detector triggered, then the state is changed to
                // SCROLLING_STARTED_OUTSIDE_APP (scroll detector can only trigger after an
                // ACTION_MOVE event is received).
                if (mState == LetterboxScrollState.SCROLLING_STARTED_OUTSIDE_APP) {
                    // Also, include ACTION_MOVE motion event that triggered the scroll-detector.
                    mProcessedEvents.add(motionEvent);
                }
                break;

            // Once scroll-detector has detected scrolling, offset is applied to the gesture.
            case SCROLLING_STARTED_OUTSIDE_APP:
                if (isOutsideAppBounds(motionEvent, appBounds)) {
                    // Offset the event to be over the app if the event is out-of-bounds.
                    applyOffset(motionEvent, appBounds);
                } else {
                    // Otherwise, the gesture is already over the app so stop offsetting it.
                    mState = LetterboxScrollState.GESTURE_STARTED_IN_APP;
                }
                mProcessedEvents.add(motionEvent);
                break;
        }

        // Reset state at the end of the gesture
        if (motionEvent.getAction() == MotionEvent.ACTION_UP
                || motionEvent.getAction() == MotionEvent.ACTION_CANCEL) {
            mState = LetterboxScrollState.AWAITING_GESTURE_START;
        }

        if (makeNoAdjustments) return null;
        return mProcessedEvents;
    }


    /**
     * Processes the InputEvent for compatibility before it is finished by calling
     * InputEventReceiver#finishInputEvent().
     *
     * @param motionEvent The MotionEvent to process.
     * @return The motionEvent to finish, or null if it should not be finished.
     */
    public InputEvent processMotionEventBeforeFinish(MotionEvent motionEvent) {
        if (mGeneratedEventIds.remove(motionEvent.getId())) return null;
        return motionEvent;
    }

    private Rect getAppBounds() {
        return mContext.getResources().getConfiguration().windowConfiguration.getBounds();
    }

    private boolean isOutsideAppBounds(MotionEvent motionEvent, Rect appBounds) {
        return motionEvent.getX() < 0 || motionEvent.getX() >= appBounds.width()
                || motionEvent.getY() < 0 || motionEvent.getY() >= appBounds.height();
    }

    private void applyOffset(MotionEvent event, Rect appBounds) {
        float horizontalOffset = calculateOffset(event.getX(), appBounds.width());
        float verticalOffset = calculateOffset(event.getY(), appBounds.height());
        // Apply the offset to the motion event so it is over the app's view.
        event.offsetLocation(horizontalOffset, verticalOffset);
    }

    private float calculateOffset(float eventCoord, int appBoundary) {
        if (eventCoord < 0) {
            return -eventCoord;
        } else if (eventCoord >= appBoundary) {
            return -(eventCoord - appBoundary + 1);
        } else {
            return 0;
        }
    }

    private class ScrollListener extends GestureDetector.SimpleOnGestureListener {
        private ScrollListener() {}

        @Override
        public boolean onScroll(
                @Nullable MotionEvent actionDownEvent,
                @NonNull MotionEvent actionMoveEvent,
                float distanceX,
                float distanceY) {
            // Inject in-bounds ACTION_DOWN event before continuing gesture with offset.
            final MotionEvent newActionDownEvent = MotionEvent.obtain(
                    Objects.requireNonNull(actionDownEvent));
            Rect appBounds = getAppBounds();
            applyOffset(newActionDownEvent, appBounds);
            mGeneratedEventIds.add(newActionDownEvent.getId());
            mProcessedEvents.add(newActionDownEvent);

            // Change state when onScroll method is triggered - at this point, the passed event is
            // known to be 'part of' a scroll gesture.
            mState = LetterboxScrollState.SCROLLING_STARTED_OUTSIDE_APP;

            return super.onScroll(actionDownEvent, actionMoveEvent, distanceX, distanceY);
        }
    }
}
+1 −1
Original line number Diff line number Diff line
@@ -1283,7 +1283,7 @@ public final class ViewRootImpl implements ViewParent,
                                    R.string.config_inputEventCompatProcessorOverrideClassName);
        if (processorOverrideName.isEmpty()) {
            // No compatibility processor override, using default.
            mInputCompatProcessor = new InputEventCompatProcessor(context);
            mInputCompatProcessor = new InputEventCompatProcessor(context, mHandler);
        } else {
            InputEventCompatProcessor compatProcessor = null;
            try {
+121 −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 android.view;

import static android.view.MotionEvent.ACTION_DOWN;
import static android.view.MotionEvent.ACTION_UP;

import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
import static org.junit.Assume.assumeTrue;

import android.content.Context;
import android.graphics.Rect;
import android.os.Build;
import android.os.Handler;
import android.os.Looper;
import android.platform.test.annotations.DisableFlags;
import android.platform.test.annotations.EnableFlags;
import android.platform.test.annotations.Presubmit;
import android.platform.test.flag.junit.SetFlagsRule;

import androidx.test.filters.SmallTest;
import androidx.test.platform.app.InstrumentationRegistry;

import com.android.window.flags.Flags;

import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;

import java.util.ArrayList;
import java.util.List;

/**
 * Tests for {@link InputEventCompatProcessor}
 *
 * Build/Install/Run:
 *  atest FrameworksCoreTests:InputEventCompatProcessorTest
 */
@SmallTest
@Presubmit
public class InputEventCompatProcessorTest {

    private InputEventCompatProcessor mInputEventCompatProcessor;

    @Rule
    public final SetFlagsRule mSetFlagsRule = new SetFlagsRule();

    @Before
    public void setUp() {
        Context context = InstrumentationRegistry.getInstrumentation().getTargetContext();

        assumeTrue("Is at least targeting Android M",
                context.getApplicationInfo().targetSdkVersion >= Build.VERSION_CODES.M);

        // Set app bounds as if it was letterboxed.
        context.getResources().getConfiguration().windowConfiguration
                .setBounds(new Rect(200, 200, 600, 1000));

        Handler handler = new Handler(Looper.getMainLooper());

        mInputEventCompatProcessor = new InputEventCompatProcessor(context, handler);
    }

    @DisableFlags(Flags.FLAG_SCROLLING_FROM_LETTERBOX)
    @Test
    public void testTapGestureOutsideBoundsHasNoAdjustmentsWhenScrollingFromLetterboxDisabled() {
        // Tap-like gesture in bounds (non-scroll).
        List<MotionEvent> tapGestureEvents = createTapGestureEvents(-100f, -100f);

        for (MotionEvent motionEvent : tapGestureEvents) {
            List<InputEvent> compatProcessedEvents =
                    mInputEventCompatProcessor.processInputEventForCompatibility(motionEvent);
            // Expect null to be returned, because no adjustments should be made to these events
            // when Letterbox Scroll Processor is disabled.
            assertNull(compatProcessedEvents);
        }
    }

    @EnableFlags(Flags.FLAG_SCROLLING_FROM_LETTERBOX)
    @Test
    public void testTapGestureOutsideBoundsIsIgnoredWhenScrollingFromLetterboxEnabled() {
        // Tap-like gesture in bounds (non-scroll).
        List<MotionEvent> tapGestureEvents = createTapGestureEvents(-100f, -100f);

        for (MotionEvent motionEvent : tapGestureEvents) {
            List<InputEvent> compatProcessedEvents =
                    mInputEventCompatProcessor.processInputEventForCompatibility(motionEvent);
            // Expect no events returned because Letterbox Scroll Processor is enabled and therefore
            // should cause the out of bound events to be ignored.
            assertTrue(compatProcessedEvents.isEmpty());
        }
    }

    private List<MotionEvent> createTapGestureEvents(float startX, float startY) {
        // Events for tap-like gesture (non-scroll)
        List<MotionEvent> motionEvents = new ArrayList<>();
        motionEvents.add(createBasicMotionEvent(0, ACTION_DOWN, startX, startY));
        motionEvents.add(createBasicMotionEvent(10, ACTION_UP, startX , startY));
        return motionEvents;
    }

    private MotionEvent createBasicMotionEvent(int downTime, int action, float x, float y) {
        return MotionEvent.obtain(0, downTime, action, x, y, 0);
    }
}
+253 −0

File added.

Preview size limit exceeded, changes collapsed.