chromium/components/webxr/android/java/src/org/chromium/components/webxr/XrImmersiveOverlay.java

// Copyright 2023 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

package org.chromium.components.webxr;

import android.annotation.SuppressLint;
import android.app.Activity;
import android.content.pm.ActivityInfo;
import android.content.res.Configuration;
import android.os.Build;
import android.view.MotionEvent;
import android.view.SurfaceHolder;
import android.view.SurfaceView;
import android.view.View;

import androidx.annotation.NonNull;

import org.chromium.base.Log;
import org.chromium.content_public.browser.ScreenOrientationDelegate;
import org.chromium.content_public.browser.ScreenOrientationProvider;
import org.chromium.content_public.browser.WebContents;
import org.chromium.content_public.browser.WebContentsObserver;
import org.chromium.ui.display.DisplayAndroid;

import java.util.HashMap;
import java.util.Map;

/**
 * Provides a fullscreen overlay for immersive sessions, allows tailoring setup/etc. due to the
 * particular needs of AR/VR sessions via the XrImmersiveOverlay.Delegate interface.
 */
public class XrImmersiveOverlay
        implements SurfaceHolder.Callback2, View.OnTouchListener, ScreenOrientationDelegate {
    /**
     * Abstraction layer for runtime-specific configuration that needs to happen when setting up a
     * SurfaceView.
     */
    public interface Delegate {
        /**
         * Called to allow for any configuration that may need to happen (e.g. in the Chrome
         * compositor), before a SurfaceView is created. This is required so that AR can ensure that
         * it ends up on top of the DOM surface if we don't need to show it.
         */
        void prepareToCreateSurfaceView();

        /**
         * Configure any Z-order, transparency, or other configuration details that may need to be
         * attached to the surfaceView. The SurfaceView should not yet be parented or made visible.
         * The SurfaceView will already be configured to keep the screen on.
         */
        void configureSurfaceView(SurfaceView surfaceView);

        /**
         * Add the SurfaceView to the Scene Hierarchy in the appropriate place and ensure that it
         * (and any relevant parents) are visible.
         */
        void parentAndShowSurfaceView(SurfaceView surfaceView);

        /**
         * Called when the surfaceView is no longer being used. It may or may not have been
         * destroyed or unparented just yet (See DEFER_SURFACE_VIEW_DESTRUCTION comments),; but
         * there may be some other system configuration that can/should be undone.
         */
        void onStopUsingSurfaceView();

        /**
         * Remove the SurfaceView from the Scene Hierarchy and ensure that it is hidden. Also
         * provides an opportunity to restore any state that may have been setup by
         * @{link parentAndShowSurfaceView}.
         */
        void removeAndHideSurfaceView(SurfaceView surfaceView);

        /**
         * This allows the delegate the opportunity to forward any touch events that would have
         * otherwise been consumed by the XrImmersiveOverlay; e.g. to the compositor.
         */
        void maybeForwardTouchEvent(MotionEvent ev);

        /**
         * Returns the desired @{link android.content.res.Configuration} int representing the
         * orientation that the OverlayDelegate desires the device to be in. Should be one of:
         * Configuration.ORIENTATION_LANDSCAPE
         * Configuration.ORIENTATION_PORTRAIT
         * Configuration.ORIENTATION_UNDEFINED
         */
        int getDesiredOrientation();

        /**
         * Returns whether the size of the rendering surface should be the size of the entire
         * display or should have the size of cutout areas into account.
         */
        boolean useDisplaySizes();
    }

    private static final String TAG = "XrImmersiveOverlay";
    private static final boolean DEBUG_LOGS = false;

    // See class comment for XrSurfaceViewImpl below.
    private static final boolean DEFER_SURFACE_VIEW_DESTRUCTION =
            (Build.VERSION.SDK_INT < Build.VERSION_CODES.O);

    private XrSessionCoordinator mXrSessionCoordinator;
    private Delegate mOverlayDelegate;
    private Activity mActivity;
    private boolean mSurfaceReportedReady;
    private Integer mRestoreOrientation;
    private boolean mCleanupInProgress;
    private XrSurfaceView mXrSurfaceView;
    private WebContents mWebContents;
    private boolean mUseOverlay;

    // Set containing all currently touching pointers.
    private HashMap<Integer, PointerData> mPointerIdToData;
    // ID of primary pointer (if present).
    private Integer mPrimaryPointerId;

    public void show(
            @NonNull Delegate overlayDelegate,
            @NonNull WebContents webContents,
            @NonNull XrSessionCoordinator caller) {
        if (DEBUG_LOGS) Log.i(TAG, "constructor");
        mXrSessionCoordinator = caller;

        mWebContents = webContents;
        mOverlayDelegate = overlayDelegate;

        mActivity = XrSessionCoordinator.getActivity(webContents);

        mPointerIdToData = new HashMap<Integer, PointerData>();
        mPrimaryPointerId = null;

        // Choose a concrete implementation to create a drawable Surface and make it fullscreen.
        // It forwards SurfaceHolder callbacks and touch events to this XrImmersiveOverlay object.
        mXrSurfaceView = new XrSurfaceView();
    }

    private class PointerData {
        public float x;
        public float y;
        public boolean touching;

        public PointerData(float x, float y, boolean touching) {
            this.x = x;
            this.y = y;
            this.touching = touching;
        }
    }

    private class XrSurfaceView {
        private SurfaceView mSurfaceView;
        private WebContentsObserver mWebContentsObserver;
        private boolean mDomSurfaceNeedsConfiguring;
        private boolean mSurfaceViewNeedsDestruction;
        private boolean mDestructionFromVisibilityChanged;

        // On some versions of Android (primarily N), the onWindowVisibilityChanged event is
        // responsible for firing the surfaceDestroyed event which ultimately removes the
        // SurfaceView from the window hierarchy. However in these cases, the synchronous
        // call to surfaceDestroyed appears to not expect or account for the View in question
        // potentially being detached from the window and causes a null reference exception.
        // This class works around that by setting and checking flags to ensure that on the
        // OS's where this is necessary, the SurfaceView is only *actually* detached from the
        // window after the parent onWindowVisibilityChanged event has finished processing
        // (assuming that the onWindowVisibilityChanged event would have caused the destruction).
        private class XrSurfaceViewImpl extends SurfaceView {
            public XrSurfaceViewImpl(Activity activity) {
                super(activity);
            }

            @Override
            protected void onWindowVisibilityChanged(int visibility) {
                if (mCleanupInProgress) return;
                mDestructionFromVisibilityChanged = true;
                super.onWindowVisibilityChanged(visibility);
                mDestructionFromVisibilityChanged = false;

                if (DEFER_SURFACE_VIEW_DESTRUCTION && mSurfaceViewNeedsDestruction) {
                    removeAndDestroySurfaceView();
                    mSurfaceViewNeedsDestruction = false;
                }
            }
        }

        @SuppressLint("ClickableViewAccessibility")
        public XrSurfaceView() {
            mOverlayDelegate.prepareToCreateSurfaceView();

            mSurfaceView = new XrSurfaceViewImpl(mActivity);
            mSurfaceView.setKeepScreenOn(true);
            mSurfaceView.getHolder().addCallback(XrImmersiveOverlay.this);
            mOverlayDelegate.configureSurfaceView(mSurfaceView);

            // Process touch input events for XR input. This consumes them, they'll be resent to
            // the compositor view via forwardMotionEvent.
            mSurfaceView.setOnTouchListener(XrImmersiveOverlay.this);

            mOverlayDelegate.parentAndShowSurfaceView(mSurfaceView);

            mWebContentsObserver =
                    new WebContentsObserver() {
                        @Override
                        public void didToggleFullscreenModeForTab(
                                boolean enteredFullscreen, boolean willCauseResize) {
                            if (DEBUG_LOGS) {
                                Log.i(
                                        TAG,
                                        "didToggleFullscreenModeForTab(), enteredFullscreen="
                                                + enteredFullscreen);
                            }

                            if (!enteredFullscreen) {
                                cleanupAndExit();
                            }
                        }
                    };

            // Watch for fullscreen exit triggered from JS, this needs to end the session.
            mWebContents.addObserver(mWebContentsObserver);
        }

        public void destroy() {
            mWebContents.removeObserver(mWebContentsObserver);

            if (!(DEFER_SURFACE_VIEW_DESTRUCTION && mDestructionFromVisibilityChanged)) {
                removeAndDestroySurfaceView();
            } else {
                mSurfaceViewNeedsDestruction = true;
            }

            mOverlayDelegate.onStopUsingSurfaceView();
        }

        private void removeAndDestroySurfaceView() {
            if (mSurfaceView == null) return;
            mOverlayDelegate.removeAndHideSurfaceView(mSurfaceView);
            mSurfaceView = null;
        }
    }

    @Override // View.OnTouchListener
    @SuppressLint("ClickableViewAccessibility")
    public boolean onTouch(View v, MotionEvent ev) {
        // Only forward primary actions, ignore more complex events such as secondary pointer
        // touches. Ignore batching since we're only sending one ray pose per frame.

        if (DEBUG_LOGS) {
            Log.i(
                    TAG,
                    "Received motion event, action: "
                            + MotionEvent.actionToString(ev.getAction())
                            + ", pointer count: "
                            + ev.getPointerCount()
                            + ", action index: "
                            + ev.getActionIndex());
            for (int i = 0; i < ev.getPointerCount(); i++) {
                Log.i(
                        TAG,
                        "Pointer index: "
                                + i
                                + ", id: "
                                + ev.getPointerId(i)
                                + ", coordinates: ("
                                + ev.getX(i)
                                + ", "
                                + ev.getY(i)
                                + ")");
            }
        }

        final int action = ev.getActionMasked();
        if (action == MotionEvent.ACTION_DOWN
                || action == MotionEvent.ACTION_UP
                || action == MotionEvent.ACTION_POINTER_DOWN
                || action == MotionEvent.ACTION_POINTER_UP
                || action == MotionEvent.ACTION_CANCEL
                || action == MotionEvent.ACTION_MOVE) {
            // ACTION_DOWN - gesture starts. Pointer with index 0 will be considered as a primary
            // pointer until it's raised. Then, there will be no primary pointer until the
            // gesture ends (ACTION_UP / ACTION_CANCEL).
            if (action == MotionEvent.ACTION_DOWN) {
                int pointerId = ev.getPointerId(0);

                // Remember primary pointer's ID. The start of the gesture is the only time when the
                // primary pointer is set.
                mPrimaryPointerId = pointerId;
                PointerData previousData =
                        mPointerIdToData.put(
                                mPrimaryPointerId, new PointerData(ev.getX(0), ev.getY(0), true));

                if (previousData != null) {
                    // Not much we can do here, just log and continue.
                    Log.w(
                            TAG,
                            "New pointer with ID "
                                    + pointerId
                                    + " introduced by ACTION_DOWN when old pointer with the same ID"
                                    + " already exists.");
                }

                // Send the events to the device.
                // This needs to happen after we have updated the state.
                sendMotionEvents(false);
            }

            // ACTION_UP - gesture ends.
            // ACTION_CANCEL - gesture was canceled - there will be no more points in it.
            if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL) {
                // Send the events to the device - all pointers are no longer `touching`:
                sendMotionEvents(true);

                // Clear the state - the gesture has ended.
                mPrimaryPointerId = null;
                mPointerIdToData.clear();
            }

            // ACTION_POINTER_DOWN - new pointer joined the gesture. Its index is passed
            // through MotionEvent.getActionIndex().
            if (action == MotionEvent.ACTION_POINTER_DOWN) {
                int pointerIndex = ev.getActionIndex();
                int pointerId = ev.getPointerId(pointerIndex);

                if (DEBUG_LOGS) Log.i(TAG, "New pointer, ID=" + pointerId);

                PointerData previousData =
                        mPointerIdToData.put(
                                pointerId,
                                new PointerData(
                                        ev.getX(pointerIndex), ev.getY(pointerIndex), true));

                if (previousData != null) {
                    // Not much we can do here, just log and continue.
                    Log.w(
                            TAG,
                            "New pointer with ID "
                                    + pointerId
                                    + " introduced by ACTION_POINTER_DOWN when old pointer with the"
                                    + " same ID already exists.");
                }

                if (DEBUG_LOGS) {
                    Log.i(TAG, "Known pointer IDs after ACTION_POINTER_DOWN:");
                    for (Map.Entry<Integer, PointerData> entry : mPointerIdToData.entrySet()) {
                        Log.i(TAG, "ID=" + entry.getKey());
                    }
                }

                // Send the events to the device.
                sendMotionEvents(false);
            }

            // ACTION_POINTER_UP - pointer left the gesture. Its index is passed though
            // MotionEvent.getActionIndex().
            if (action == MotionEvent.ACTION_POINTER_UP) {
                int pointerIndex = ev.getActionIndex();
                int pointerId = ev.getPointerId(pointerIndex);

                if (!mPointerIdToData.containsKey(pointerId)) {
                    // The pointer with ID that was not previously known has been somehow introduced
                    // outside of ACTION_DOWN / ACTION_POINTER_DOWN - this should never happen!
                    // Nevertheless, it happens in the wild, so ignore the pointer to prevent crash.
                    Log.w(
                            TAG,
                            "Pointer with ID "
                                    + pointerId
                                    + " not found in mPointerIdToData, ignoring ACTION_POINTER_UP"
                                    + " for it.");
                } else {
                    // Send the events to the device.
                    // The pointer that was raised needs to no longer be `touching`.
                    mPointerIdToData.get(pointerId).touching = false;
                    sendMotionEvents(false);

                    // If it so happened that it was a primary pointer, we need to remember that
                    // there is no primary pointer anymore.
                    if (mPrimaryPointerId != null && mPrimaryPointerId == pointerId) {
                        mPrimaryPointerId = null;
                    }
                    mPointerIdToData.remove(pointerId);
                }
            }

            if (action == MotionEvent.ACTION_MOVE) {
                for (int i = 0; i < ev.getPointerCount(); i++) {
                    int pointerId = ev.getPointerId(i);
                    PointerData pd = mPointerIdToData.get(pointerId);

                    // If pointer data is null for the given pointer id, then something is wrong
                    // with the code's assumption - new pointers can only appear due to ACTION_DOWN
                    // and ACTION_POINTER_DOWN, but it did not seem to happen in this case. In case
                    // logs are enabled, log this information.
                    if (DEBUG_LOGS && pd == null) {
                        Log.i(
                                TAG,
                                "Pointer with ID "
                                        + pointerId
                                        + " (index "
                                        + i
                                        + ") not found in mPointerIdToData. Known pointer IDs:");
                        for (Map.Entry<Integer, PointerData> entry : mPointerIdToData.entrySet()) {
                            Log.i(TAG, "ID=" + entry.getKey());
                        }
                    }

                    if (pd == null) {
                        // The pointer with ID that was not previously known has been somehow
                        // introduced outside of ACTION_DOWN / ACTION_POINTER_DOWN - this should
                        // never happen! Nevertheless, it happens in the wild, so ignore the pointer
                        // to prevent crash.
                        Log.w(
                                TAG,
                                "Pointer with ID "
                                        + pointerId
                                        + "(index "
                                        + i
                                        + ") not found in mPointerIdToData, ignoring ACTION_MOVE"
                                        + " for it.");
                        continue;
                    }

                    pd.x = ev.getX(i);
                    pd.y = ev.getY(i);
                }

                sendMotionEvents(false);
            }
        }

        // We need to consume the touch (returning true) to ensure that we get
        // followup events such as MOVE and UP. DOM Overlay mode needs to forward
        // the touch to the content view so that its UI elements keep working, so
        // we need to afford the particular session the chance to forward the event.
        mOverlayDelegate.maybeForwardTouchEvent(ev);
        return true;
    }

    // If the gestureEnded is set to true, the touching state present on the
    // PointerData entries will be ignored - none of them will be touching and
    // the entire collection will be cleared anyway.
    private void sendMotionEvents(boolean gestureEnded) {
        for (Map.Entry<Integer, PointerData> entry : mPointerIdToData.entrySet()) {
            mXrSessionCoordinator.onDrawingSurfaceTouch(
                    mPrimaryPointerId != null && mPrimaryPointerId.equals(entry.getKey()),
                    gestureEnded ? false : entry.getValue().touching,
                    entry.getKey().intValue(),
                    entry.getValue().x,
                    entry.getValue().y);
        }
    }

    @Override // ScreenOrientationDelegate
    public boolean canUnlockOrientation(Activity activity, int defaultOrientation) {
        if (mActivity == activity && mRestoreOrientation != null) {
            mRestoreOrientation = defaultOrientation;
            return false;
        }
        return true;
    }

    @Override // ScreenOrientationDelegate
    public boolean canLockOrientation() {
        return false;
    }

    @Override // SurfaceHolder.Callback2
    public void surfaceCreated(SurfaceHolder holder) {
        if (DEBUG_LOGS) Log.i(TAG, "surfaceCreated");
        // Do nothing here, we'll handle setup on the following surfaceChanged.
    }

    @Override // SurfaceHolder.Callback2
    public void surfaceRedrawNeeded(SurfaceHolder holder) {
        if (DEBUG_LOGS) Log.i(TAG, "surfaceRedrawNeeded");
    }

    @Override // SurfaceHolder.Callback2
    public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {
        // The surface may not immediately start out at the expected fullscreen size due to
        // animations or not-yet-hidden navigation bars. WebXR immersive sessions use a fixed-size
        // frame transport that can't be resized, so we need to pick a single size and stick with it
        // for the duration of the session. Use the expected fullscreen size for WebXR frame
        // transport even if the currently-visible part in the surface view is smaller than this. We
        // shouldn't get resize events since we're using FLAG_LAYOUT_STABLE and are locking screen
        // orientation.
        DisplayAndroid display = mWebContents.getTopLevelNativeWindow().getDisplay();
        if (mSurfaceReportedReady) {
            int rotation = display.getRotation();
            if (DEBUG_LOGS) {
                Log.i(
                        TAG,
                        "surfaceChanged ignoring change to width="
                                + width
                                + " height="
                                + height
                                + " rotation="
                                + rotation);
            }
            return;
        }

        // Need to ensure orientation is locked at this point to avoid race conditions. Save current
        // orientation mode, and then lock current orientation. It's unclear if there's still a risk
        // of races, for example if an orientation change was already in progress at this point but
        // wasn't fully processed yet. In that case the user may need to exit and re-enter the
        // session to get the intended layout.
        ScreenOrientationProvider.getInstance().setOrientationDelegate(this);
        if (mRestoreOrientation == null) {
            mRestoreOrientation = mActivity.getRequestedOrientation();
        }

        int desiredOrientation = mOverlayDelegate.getDesiredOrientation();
        int currentOrientation = mActivity.getResources().getConfiguration().orientation;

        int requestOrientation = configurationToActivityInfoOrientation(desiredOrientation);

        // If we have a desired orientation and it does not equal the current orientation, then we
        // will need to swap dimensions.
        boolean swapScreenDimensions =
                desiredOrientation != Configuration.ORIENTATION_UNDEFINED
                        && desiredOrientation != currentOrientation;

        mActivity.setRequestedOrientation(requestOrientation);

        // While it would be preferable to wait until the surface is at the desired fullscreen
        // resolution, i.e. via mActivity.getFullscreenManager().getPersistentFullscreenMode(), that
        // causes a chicken-and-egg problem for XrSurfaceView mode as used for DOM overlay.
        // Chrome's fullscreen mode is triggered by the Blink side setting an element fullscreen
        // after the session starts, but the session doesn't start until we report the drawing
        // surface being ready (including a configured size), so we use the reported size of the
        // display assuming that's what the fullscreen mode will use.
        if (mOverlayDelegate.useDisplaySizes()) {
            int screenWidth = display.getDisplayWidth();
            int screenHeight = display.getDisplayHeight();

            if (width < screenWidth || height < screenHeight) {
                if (DEBUG_LOGS) {
                    Log.i(
                            TAG,
                            "surfaceChanged adjusting size from "
                                    + width
                                    + "x"
                                    + height
                                    + " to"
                                    + screenWidth
                                    + "x"
                                    + screenHeight);
                }
                width = screenWidth;
                height = screenHeight;
            }
        }

        if (swapScreenDimensions) {
            // Swap width and height.
            int auxWidth = width;
            width = height;
            height = auxWidth;
        }

        int rotation = display.getRotation();
        if (DEBUG_LOGS) {
            Log.i(TAG, "surfaceChanged size=" + width + "x" + height + " rotation=" + rotation);
        }
        mXrSessionCoordinator.onDrawingSurfaceReady(
                holder.getSurface(),
                mWebContents.getTopLevelNativeWindow(),
                rotation,
                width,
                height);
        mSurfaceReportedReady = true;
    }

    @Override // SurfaceHolder.Callback2
    public void surfaceDestroyed(SurfaceHolder holder) {
        if (DEBUG_LOGS) Log.i(TAG, "surfaceDestroyed");
        cleanupAndExit();
    }

    public void cleanupAndExit() {
        if (DEBUG_LOGS) Log.i(TAG, "cleanupAndExit");

        // Avoid duplicate cleanup if we're exiting via XrSessionCoordinator's endSession.
        // That triggers cleanupAndExit -> remove SurfaceView -> surfaceDestroyed -> cleanupAndExit.
        if (mCleanupInProgress) return;
        mCleanupInProgress = true;

        // The surface is destroyed when exiting via "back" button, but also in other lifecycle
        // situations such as switching apps or toggling the phone's power button. Treat each of
        // these as exiting the immersive session. At this point, the surface isn't destroyed yet.
        // but will be soon. We need to give the native code a chance to cleanup any state before
        // we start any other logic to ensure that the surface is destroyed. We also need to run
        // the destroy callbacks to ensure consistent state after non-exiting lifecycle events.
        mXrSessionCoordinator.onDrawingSurfaceDestroyed();

        mXrSurfaceView.destroy();

        // The JS app may have put an element into fullscreen mode during the immersive session,
        // even if this wasn't visible to the user. Ensure that we fully exit out of any active
        // fullscreen state on session end to avoid being left in a confusing state.
        if (!mWebContents.isDestroyed()) {
            mWebContents.exitFullscreen();
        }

        // Restore orientation.
        ScreenOrientationProvider.getInstance().setOrientationDelegate(null);
        if (mRestoreOrientation != null) mActivity.setRequestedOrientation(mRestoreOrientation);
        mRestoreOrientation = null;
    }

    /**
     * Translates the provided int, which is expected to be of the result of
     * @{link Delegate.getDesiredOrientation} and thus one of:
     * Configuration.ORIENTATION_UNDEFINED,
     * Configuration.ORIENTATION_LANDSCAPE,
     * Configuration.ORIENTATION_PORTRAIT
     * and translates it to a corresponding "ActivityInfo" value that can then be passed on to
     * setRequestedOrientation.
     */
    private int configurationToActivityInfoOrientation(int configurationOrientation) {
        switch (configurationOrientation) {
            case Configuration.ORIENTATION_UNDEFINED:
                return ActivityInfo.SCREEN_ORIENTATION_LOCKED;
            case Configuration.ORIENTATION_LANDSCAPE:
                return ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE;
            case Configuration.ORIENTATION_PORTRAIT:
                return ActivityInfo.SCREEN_ORIENTATION_PORTRAIT;
            default:
                Log.e(
                        TAG,
                        "Unexpected configurationOrientation: "
                                + configurationOrientation
                                + " using default of 'Locked'.");
                return ActivityInfo.SCREEN_ORIENTATION_LOCKED;
        }
    }
}