chromium/components/stylus_handwriting/android/java/src/org/chromium/components/stylus_handwriting/DirectWritingTrigger.java

// Copyright 2022 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.stylus_handwriting;

import android.content.Context;
import android.graphics.Point;
import android.graphics.Rect;
import android.graphics.RectF;
import android.os.Build;
import android.os.Bundle;
import android.os.Handler;
import android.view.MotionEvent;
import android.view.View;
import android.view.inputmethod.EditorBoundsInfo;
import android.view.inputmethod.EditorInfo;

import androidx.annotation.RequiresApi;
import androidx.annotation.VisibleForTesting;

import org.chromium.base.Log;
import org.chromium.content_public.browser.StylusWritingHandler;
import org.chromium.content_public.browser.StylusWritingImeCallback;
import org.chromium.content_public.browser.WebContents;

/**
 * Direct writing class that manages Input events, starting and stopping of recognition. Forwards
 * calls to DW service connection handler class {@link DirectWritingServiceBinder}. Also, sets the
 * {@link StylusWritingHandler} to receive messages about stylus writing events.
 */
class DirectWritingTrigger implements StylusWritingHandler, StylusApiOption {
    private static final String TAG = "DWTrigger";

    private DirectWritingServiceBinder mBinder = new DirectWritingServiceBinder();
    private DirectWritingServiceConfiguration mConfig = new DirectWritingServiceConfiguration();

    // Track whether DW recognition has been started.
    private boolean mRecognitionStarted;

    private final Handler mHandler = new Handler();

    // Token to determine if stylus writing can be continued without re-detection.
    private Object mStopWritingCallbackToken;

    // Token to hide the DW toolbar as stylus wasn't used for a while.
    private Object mHideDwToolbarCallbackToken;

    // Track whether DW service is enabled or not.
    private boolean mDwServiceEnabled;

    // Tracks whether handwriting hover icon is being shown or not.
    private boolean mIsHandwritingIconShowing;

    private StylusWritingImeCallback mStylusWritingImeCallback;
    private DirectWritingServiceCallback mCallback;

    private MotionEvent mCurrentStylusDownEvent;
    private MotionEvent mStylusUpEvent;
    private Rect mEditableNodeBounds;
    private boolean mStylusWritingDetected;
    private boolean mNeedsFocusedNodeChangedAfterTouchUp;
    private boolean mWasButtonPressed;

    /**
     * Sets the stylus writing handler to current web contents when initialized to receive messages
     * via {@link StylusWritingHandler}
     *
     * @param context current {@link Context}
     * @param webContents current web contents
     */
    @Override
    public void onWebContentsChanged(Context context, WebContents webContents) {
        updateDWSettings(context);
        webContents.setStylusWritingHandler(this);
        mStylusWritingImeCallback = webContents.getStylusWritingImeCallback();
        mCallback.setImeCallback(mStylusWritingImeCallback);
    }

    @Override
    public EditorBoundsInfo onFocusedNodeChanged(
            Rect editableBoundsOnScreenDip,
            boolean isEditable,
            View currentView,
            float scaleFactor,
            int contentOffsetY) {
        if (!mDwServiceEnabled || !mBinder.isServiceConnected()) return null;

        RectF bounds =
                new RectF(
                        editableBoundsOnScreenDip.left * scaleFactor,
                        editableBoundsOnScreenDip.top * scaleFactor,
                        editableBoundsOnScreenDip.right * scaleFactor,
                        editableBoundsOnScreenDip.bottom * scaleFactor);
        bounds.offset(0, contentOffsetY);
        EditorBoundsInfo editorBoundsInfo = null;
        if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) {
            editorBoundsInfo =
                    new EditorBoundsInfo.Builder()
                            .setEditorBounds(bounds)
                            .setHandwritingBounds(bounds)
                            .build();
        }
        Rect roundedBounds = new Rect();
        bounds.round(roundedBounds);

        if (isEditable) {
            if (!mStylusWritingDetected
                    && mNeedsFocusedNodeChangedAfterTouchUp
                    && mStylusUpEvent != null) {
                mBinder.updateEditableBounds(roundedBounds, currentView, true);
                // Call onStopRecognition with editable bounds to show DW toolbar on Pen TAP in
                // input field.
                onStopRecognition(mStylusUpEvent, roundedBounds, currentView);
                mNeedsFocusedNodeChangedAfterTouchUp = false;
            }
        } else {
            // Stop recognition and hide DW toolbar as focused node is not editable.
            hideDWToolbar();
            onStopRecognition(/* motionEvent= */ null, /* editableBounds= */ null, currentView);
        }

        mEditableNodeBounds = roundedBounds;
        mCallback.updateEditableBounds(roundedBounds, /* cursorPosition= */ new Point());
        return editorBoundsInfo;
    }

    @Override
    public boolean shouldInitiateStylusWriting() {
        if (!mDwServiceEnabled || !mBinder.isServiceConnected()) return false;
        mStylusWritingDetected = true;
        return true;
    }

    private void startRecognition(Rect editableBound) {
        if (mCurrentStylusDownEvent == null || mStylusWritingImeCallback == null) return;

        View rootView = mStylusWritingImeCallback.getContainerView();
        if (!mBinder.startRecognition(editableBound, mCurrentStylusDownEvent, rootView)) return;
        mRecognitionStarted = true;
        // Dispatch stored action down before action move, when writing is not yet started.
        onDispatchEvent(mCurrentStylusDownEvent, rootView);
        mStylusWritingImeCallback.resetGestureDetection();
    }

    @Override
    public boolean canShowSoftKeyboard() {
        // We avoid showing soft keyboard during direct writing as there is widget toolbar provided
        // by the service that allows options like add space, backspace, show/hide keyboard, and to
        // perform editor actions like next, prev, search, go, etc. It can be noted that Platform
        // Edit Text also does not show keyboard during direct writing.
        return false;
    }

    private void updateDWServiceStatus(Context context) {
        mDwServiceEnabled = isDirectWritingServiceEnabled(context);
        Log.i(TAG, "updateDWServiceStatus() : isEnabled = " + mDwServiceEnabled);
    }

    /**
     * Updates whether the Direct writing service is enabled or not.
     *
     * @param context current context
     */
    @VisibleForTesting
    void updateDWSettings(Context context) {
        boolean wasDWEnabled = mDwServiceEnabled;
        updateDWServiceStatus(context);
        if (!wasDWEnabled && mDwServiceEnabled) {
            onDWServiceEnabled();
        }
    }

    private void onDWServiceEnabled() {
        // Create IDirectWritingServiceCallbackImpl instance when DW setting is changed to
        // enabled. Platform Crash occurs if it is created when DW setting is not enabled.
        if (mCallback != null) return;
        mCallback = new DirectWritingServiceCallback();
        mCallback.setTriggerCallback(
                new DirectWritingServiceCallback.TriggerCallback() {
                    @Override
                    public void updateEditableBoundsToService() {
                        if (mStylusWritingImeCallback == null) return;
                        mBinder.updateEditableBounds(
                                mEditableNodeBounds,
                                mStylusWritingImeCallback.getContainerView(),
                                true);
                    }

                    @Override
                    public boolean isHandwritingIconShowing() {
                        return mIsHandwritingIconShowing;
                    }
                });
    }

    @Override
    public void onFocusChanged(boolean hasFocus) {
        if (!hasFocus) {
            // Hide DW toolbar and Stop Recognition when View focus is lost.
            hideDWToolbar();
            onStopRecognition(/* motionEvent= */ null, /* editableBounds= */ null);
        }
    }

    @Override
    public void onWindowFocusChanged(Context context, boolean hasWindowFocus) {
        if (hasWindowFocus) {
            updateDWSettings(context);
        } else {
            hideDWToolbar();
        }
        if (!mDwServiceEnabled) return;
        mBinder.onWindowFocusChanged(context, hasWindowFocus);
    }

    /**
     * Notify the view is detached from window.
     *
     * @param context the current context
     */
    @Override
    public void onDetachedFromWindow(Context context) {
        // Unbind service when view is detached.
        if (!mDwServiceEnabled || !mBinder.isServiceConnected()) return;
        mBinder.unbindService(context);
    }

    @Override
    public void onImeAdapterDestroyed() {
        mStylusWritingImeCallback = null;
        mCallback.setImeCallback(null);
    }

    /*
     * This API needs to be called before starting recognition to bind direct writing service.
     */
    private void bindDirectWritingService(View rootView) {
        mBinder.bindService(
                rootView.getContext(),
                new DirectWritingServiceBinder.DirectWritingTriggerCallback() {
                    @Override
                    public void updateConfiguration(Bundle bundle) {
                        mConfig.update(bundle);
                    }

                    @Override
                    public DirectWritingServiceCallback getServiceCallback() {
                        return mCallback;
                    }
                });
    }

    @VisibleForTesting
    DirectWritingServiceCallback getServiceCallback() {
        return mCallback;
    }

    void setServiceCallbackForTest(DirectWritingServiceCallback serviceCallback) {
        mCallback = serviceCallback;
    }

    void setServiceBinderForTest(DirectWritingServiceBinder serviceBinder) {
        mBinder = serviceBinder;
    }

    @VisibleForTesting
    StylusWritingImeCallback getStylusWritingImeCallbackForTest() {
        return mStylusWritingImeCallback;
    }

    @VisibleForTesting
    boolean stylusWritingDetected() {
        return mStylusWritingDetected;
    }

    /**
     * Handle hover events for Direct writing.
     *
     * @param event MotionEvent to be handled.
     * @param currentView the View which is receiving the events.
     */
    @RequiresApi(api = Build.VERSION_CODES.P)
    @Override
    public void handleHoverEvent(MotionEvent event, View currentView) {
        if (!mDwServiceEnabled) return;
        if (event.getToolType(0) != MotionEvent.TOOL_TYPE_STYLUS
                && event.getToolType(0) != MotionEvent.TOOL_TYPE_ERASER) {
            return;
        }
        // Try to connect and bind DW service if not connected already.
        if (!mBinder.isServiceConnected() && event.getAction() == MotionEvent.ACTION_HOVER_ENTER) {
            bindDirectWritingService(currentView);
        }
        handlePenEvent(event, currentView);
    }

    /**
     * Handle touch events if needed for Direct writing.
     *
     * @param event MotionEvent to be handled.
     * @param currentView the View which is receiving the events.
     * @return true if event is consumed by Direct writing system.
     */
    @RequiresApi(api = Build.VERSION_CODES.P)
    @Override
    public boolean handleTouchEvent(MotionEvent event, View currentView) {
        if (!mDwServiceEnabled) return false;
        if (handleButtonEvent(event)) {
            return false;
        }
        if (event.getToolType(0) == MotionEvent.TOOL_TYPE_STYLUS
                || event.getToolType(0) == MotionEvent.TOOL_TYPE_ERASER) {
            return handlePenEvent(event, currentView);
        } else {
            // Hide the DW toolbar when stylus is not being used.
            if (event.getActionMasked() == MotionEvent.ACTION_DOWN) {
                hideDWToolbar();
            }
        }
        return false;
    }

    private boolean handleButtonEvent(MotionEvent me) {
        if (me.isButtonPressed(MotionEvent.BUTTON_STYLUS_PRIMARY)) {
            if (me.getAction() == MotionEvent.ACTION_DOWN) {
                mWasButtonPressed = true;
            }
            return true;
        } else if (mWasButtonPressed) {
            if (me.getAction() == MotionEvent.ACTION_UP) {
                mWasButtonPressed = false;
            }
            return true;
        }
        return false;
    }

    @RequiresApi(api = Build.VERSION_CODES.P)
    private boolean handlePenEvent(MotionEvent me, View rootView) {
        int action = me.getAction();
        switch (action) {
            case MotionEvent.ACTION_DOWN:
                {
                    if (mHideDwToolbarCallbackToken != null) {
                        mHandler.removeCallbacksAndMessages(mHideDwToolbarCallbackToken);
                        mHideDwToolbarCallbackToken = null;
                    }

                    mCurrentStylusDownEvent = MotionEvent.obtain(me);
                    mNeedsFocusedNodeChangedAfterTouchUp = false;

                    if (mStopWritingCallbackToken != null) {
                        // We're still writing from last time.
                        mHandler.removeCallbacksAndMessages(mStopWritingCallbackToken);
                        mStopWritingCallbackToken = null;
                        onDispatchEvent(me, rootView);
                        return true;
                    }

                    // Reset cached stylus writing status when keep writing timer has expired to
                    // re-detect if writing is still over an input element.
                    mStylusWritingDetected = false;
                    mRecognitionStarted = false;
                    return false;
                }
            case MotionEvent.ACTION_MOVE:
                {
                    if (mRecognitionStarted) {
                        // Consume touch events once writing has started.
                        onDispatchEvent(me, rootView);
                        return true;
                    } else {
                        return false;
                    }
                }
            case MotionEvent.ACTION_UP:
                {
                    if (mRecognitionStarted) {
                        onDispatchEvent(me, rootView);
                        mStopWritingCallbackToken = new Object();
                        mHandler.postDelayed(
                                () -> {
                                    resetRecognition();
                                    mStopWritingCallbackToken = null;
                                },
                                mStopWritingCallbackToken,
                                mConfig.getKeepWritingDelayMs());
                        return true;
                    } else {
                        // Handle ACTION_UP in editable field, to show DW Toolbar.
                        if (mEditableNodeBounds != null
                                && !mEditableNodeBounds.isEmpty()
                                && mCurrentStylusDownEvent != null
                                && mEditableNodeBounds.contains(
                                        (int) mCurrentStylusDownEvent.getX(),
                                        (int) mCurrentStylusDownEvent.getY())) {
                            onStopRecognition(me, mEditableNodeBounds, rootView);
                        } else {
                            // It is possible that Pen TAP is done in an Input element without
                            // writing, so wait until element is focused to show DW toolbar.
                            mStylusUpEvent = MotionEvent.obtain(me);
                            mNeedsFocusedNodeChangedAfterTouchUp = true;
                        }
                        return false;
                    }
                }
            case MotionEvent.ACTION_HOVER_EXIT:
                {
                    // Hover exit is not forwarded to blink, so reset hover icon showing state.
                    mIsHandwritingIconShowing = false;

                    if (!mRecognitionStarted) break;
                    // Post task to stop recognition and hide DW toolbar as stylus is moved away.
                    mHideDwToolbarCallbackToken = new Object();
                    mHandler.postDelayed(
                            () -> {
                                onStopRecognition(
                                        /* motionEvent= */ null,
                                        /* editableBounds= */ null,
                                        rootView);
                                mHideDwToolbarCallbackToken = null;
                            },
                            mHideDwToolbarCallbackToken,
                            mConfig.getHideDwToolbarDelayMs());
                    break;
                }
            case MotionEvent.ACTION_HOVER_ENTER:
                {
                    if (mHideDwToolbarCallbackToken != null) {
                        mHandler.removeCallbacksAndMessages(mHideDwToolbarCallbackToken);
                        mHideDwToolbarCallbackToken = null;
                    }
                    break;
                }
            default:
                break;
        }
        return false;
    }

    /**
     * Dispatch event to Recognition View of Service after stylus writing is detected in edit rect.
     * Action Down is dispatched in startRecognition().
     */
    private void onDispatchEvent(MotionEvent me, View rootView) {
        mBinder.onDispatchEvent(me, rootView);
    }

    private boolean isDirectWritingServiceEnabled(Context context) {
        return DirectWritingSettingsHelper.isEnabled(context);
    }

    @Override
    public void updateInputState(String text, int selectionStart, int selectionEnd) {
        if (!mDwServiceEnabled || !mBinder.isServiceConnected()) return;
        mCallback.updateInputState(text, selectionStart, selectionEnd);
    }

    @Override
    public EditorBoundsInfo onEditElementFocusedForStylusWriting(
            Rect focusedEditBounds,
            Point cursorPosition,
            float scaleFactor,
            int contentOffsetY,
            View view) {
        // Don't start recognition if focused edit bounds are empty as it means stylus writable
        // element was not focused or bounds could not be obtained.
        if (focusedEditBounds.isEmpty()) return null;

        if (!mStylusWritingDetected || mStylusWritingImeCallback == null) return null;

        focusedEditBounds.offset(0, contentOffsetY);
        RectF bounds = new RectF(focusedEditBounds);
        EditorBoundsInfo editorBoundsInfo = null;
        if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) {
            editorBoundsInfo =
                    new EditorBoundsInfo.Builder()
                            .setEditorBounds(bounds)
                            .setHandwritingBounds(bounds)
                            .build();
        }
        StylusApiOption.recordStylusHandwritingTriggered(Api.DIRECT_WRITING);
        // Start recognition as stylus writable element is focused.
        startRecognition(focusedEditBounds);
        mCallback.updateEditableBounds(focusedEditBounds, cursorPosition);
        mBinder.updateEditableBounds(focusedEditBounds, view, false);
        return editorBoundsInfo;
    }

    @Override
    public void updateEditorInfo(EditorInfo editorInfo) {
        if (!mDwServiceEnabled || !mBinder.isServiceConnected()) return;
        mCallback.updateEditorInfo(editorInfo);
        mBinder.updateEditorInfo(editorInfo);
    }

    @Override
    public int getStylusPointerIcon() {
        return DirectWritingConstants.STYLUS_WRITING_ICON_VALUE;
    }

    private void onStopRecognition(MotionEvent motionEvent, Rect editableBounds) {
        if (mStylusWritingImeCallback == null) return;
        onStopRecognition(
                motionEvent, editableBounds, mStylusWritingImeCallback.getContainerView());
    }

    private void onStopRecognition(MotionEvent motionEvent, Rect editableBounds, View currentView) {
        if (!mDwServiceEnabled) return;
        mBinder.onStopRecognition(motionEvent, editableBounds, currentView);
        resetRecognition();
    }

    private void resetRecognition() {
        mRecognitionStarted = false;
        mCurrentStylusDownEvent = null;
        mStylusUpEvent = null;
    }

    private void hideDWToolbar() {
        if (!mDwServiceEnabled) return;
        mBinder.hideDWToolbar();
    }
}