chromium/chrome/android/java/src/org/chromium/chrome/browser/contextualsearch/ContextualSearchInternalStateController.java

// Copyright 2017 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.chrome.browser.contextualsearch;

import androidx.annotation.IntDef;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;

import org.chromium.base.Log;
import org.chromium.chrome.browser.compositor.bottombar.OverlayPanel.StateChangeReason;
import org.chromium.chrome.browser.contextualsearch.ContextualSearchSelectionController.SelectionType;

import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;

/**
 * Controls the internal state of the Contextual Search Manager.
 * <p>
 * This class keeps track of the current internal state of the {@code ContextualSearchManager} and
 * helps it to transition between states and return to the idle state when work has been
 * interrupted or complete.
 * <p>
 * Usage: Call {@link #reset(StateChangeReason)} to reset to the {@code IDLE} state, which hides
 * the UI.<br>
 * Call {@link #enter(InternalState)} to enter a start-state (when a user gesture is recognized).
 * When doing some work on a state, which may be done in an asynchronous manner:<ol>
 * <li>call {@link #notifyStartingWorkOn(InternalState)} to note that work is starting on that state
 * <li>call {@link #notifyFinishedWorkOn(InternalState)} when work is completed.
 * <li>If a handler of an async response needs to do additional work, such as updating the UI, it
 * should first call {@link #isStillWorkingOn(InternalState)} to check that work has not been
 * interrupted since the async operation was started.
 * </ol><p>
 * The {@link #notifyFinishedWorkOn(InternalState)} method will automatically start a transition to
 * the appropriate next state.
 * <p>
 * Policy decisions about state transitions should only be done in the private
 * {@link #transitionTo(InternalState)} method of this class (not within the
 * {@code ContextualSearchManager} itself).
 */
class ContextualSearchInternalStateController {
    private static final String TAG = "ContextualSearch";

    private final ContextualSearchPolicy mPolicy;
    private final ContextualSearchInternalStateHandler mStateHandler;

    // The type of selection that triggered this state-change sequence;
    private @SelectionType int mSelectionType;

    /**
     * The current internal state of the {@code ContextualSearchManager}.
     * States can be "start states" which can be passed to #enter(), or "transitional states" which
     * automatically transition to the appropriate next state when work is done on them, or
     * "resting states" which do not transition into any next state, or a combination of the
     * above.
     */
    @IntDef({
        InternalState.UNDEFINED,
        InternalState.IDLE,
        InternalState.LONG_PRESS_RECOGNIZED,
        InternalState.SHOWING_LITERAL_SEARCH,
        InternalState.SELECTION_CLEARED_RECOGNIZED,
        InternalState.WAITING_FOR_POSSIBLE_TAP_NEAR_PREVIOUS,
        InternalState.TAP_RECOGNIZED,
        InternalState.WAITING_FOR_POSSIBLE_TAP_ON_TAP_SELECTION,
        InternalState.TAP_GESTURE_COMMIT,
        InternalState.GATHERING_SURROUNDINGS,
        InternalState.DECIDING_SUPPRESSION,
        InternalState.START_SHOWING_TAP_UI,
        InternalState.SHOW_RESOLVING_UI,
        InternalState.RESOLVING,
        InternalState.SHOWING_TAP_SEARCH,
        InternalState.RESOLVING_LONG_PRESS_RECOGNIZED,
        InternalState.SHOWING_RESOLVED_LONG_PRESS_SEARCH,
        InternalState.SEARCH_COMPLETED,
    })
    @Retention(RetentionPolicy.SOURCE)
    public @interface InternalState {
        /**
         * This start state should only be used when the manager is not yet initialized or already
         * destroyed.
         */
        int UNDEFINED = 0;

        /** This start/resting state shows no UI (panel is closed). */
        int IDLE = 1;

        /** This starts a transition that leads to the SHOWING_LITERAL_SEARCH state. */
        int LONG_PRESS_RECOGNIZED = 2;

        /** State showing the panel in response to a literal non-resolving search. */
        int SHOWING_LITERAL_SEARCH = 3;

        /**
         * This is a start state when the selection is cleared typically due to a tap on the base
         * page. If the previous state wasn't IDLE then it could be a tap near a previous Tap.
         * Transitions to WAITING_FOR_POSSIBLE_TAP_NEAR_PREVIOUS to wait for a Tap and hide the Bar
         * if no tap ever happens.
         */
        int SELECTION_CLEARED_RECOGNIZED = 4;

        /**
         * Waits to see if the tap gesture was valid so we can just update the Bar instead of
         * hiding/showing it.
         */
        int WAITING_FOR_POSSIBLE_TAP_NEAR_PREVIOUS = 5;

        /** This starts a sequence of states needed to get to the SHOWING_TAP_SEARCH state. */
        int TAP_RECOGNIZED = 6;

        /**
         * Waits to see if the Tap was on a previous tap-selection, which will show the selection
         * manipulation pins and be subsumed by a LONG_PRESS_RECOGNIZED.  If that doesn't happen
         * within the waiting period we'll advance.
         */
        int WAITING_FOR_POSSIBLE_TAP_ON_TAP_SELECTION = 7;

        /**
         * The first state in the Tap-gesture processing pipeline where we know we're processing
         * a Tap-gesture that won't be converted into a long-press (from tap on tap-selection).  It
         * may later be suppressed or ignored due to being on an invalid character.
         */
        int TAP_GESTURE_COMMIT = 8;

        /** Gathers text surrounding the selection. */
        int GATHERING_SURROUNDINGS = 9;

        /** Decides if the gesture should trigger the UX or be suppressed. */
        int DECIDING_SUPPRESSION = 10;

        /** Start showing the Tap UI. Currently this means select the word that was tapped. */
        int START_SHOWING_TAP_UI = 11;

        /** Show the full Tap UI. Currently this means showing the Overlay Panel. */
        int SHOW_RESOLVING_UI = 12;

        /**
         * Resolving the Search Term using the surrounding text and additional context.
         * Currently this makes a server request, which could take a long time.
         */
        int RESOLVING = 13;

        /** State when showing the panel in response to a Tap gesture. */
        int SHOWING_TAP_SEARCH = 14;

        /**
         * This starts the resolving transition that leads to the
         * SHOWING_RESOLVED_LONG_PRESS_SEARCH.
         */
        int RESOLVING_LONG_PRESS_RECOGNIZED = 15;

        /** State when showing the panel in response to a longpress gesture that resolved. */
        int SHOWING_RESOLVED_LONG_PRESS_SEARCH = 16;

        /** The final resting state for all searches once they have completed. */
        int SEARCH_COMPLETED = 17;
    }

    // The current state of this instance.
    private @InternalState int mState = InternalState.UNDEFINED;

    // Whether work has started on the current state.
    private boolean mDidStartWork;

    // The previous state of this instance.
    private @InternalState int mPreviousState = InternalState.UNDEFINED;

    /**
     * Constructs an instance of this class, which has the same lifetime as the
     * {@code ContextualSearchManager} and the given parameters.
     */
    ContextualSearchInternalStateController(
            ContextualSearchPolicy policy, ContextualSearchInternalStateHandler stateHandler) {
        mPolicy = policy;
        mStateHandler = stateHandler;
    }

    // ============================================================================================
    // State-transition management.
    // This code is designed to solve several problems:
    // 1) Document the sequencing of handling a gesture in code.  Now there's a single method that
    //    determines the sequence that should be followed for Tap handling (our most complicated
    //    case.
    // 2) Document the initiation and subsequent notification/handling of operations.  Now the
    //    method that starts an operation and the notification handler are tied together by their
    //    references to the same state.  This allows a simple search to find the
    //    initiation and handler together (which is not always easy, e.g. SelectWordAroundCaret
    //    does not yet have an ACK so we infer that it's complete when the selection change -- or
    //    does not change after some short waiting period).
    // 3) Gracefully handle sequence interruptions.  When an asynchronous operation is in progress
    //    the user may start a new sequence or abort the current sequence.  Now the handler for an
    //    asynchronous operation can easily detect that it's no longer working on that operation
    //    and skip the normal completion of the operation.
    // ============================================================================================

    /**
     * Reset the current state to the IDLE state.
     * @param reason The reason for the reset.
     */
    void reset(@Nullable @StateChangeReason Integer reason) {
        mSelectionType = SelectionType.UNDETERMINED;
        transitionTo(InternalState.IDLE, reason);
    }

    /**
     * Enters the given starting state immediately.
     * Note: This will synchronously complete the given state and process all subsequent
     * non-asynchronous states before returning.  See https://crbug.com/1099383.
     * @param state The new starting {@link InternalState} we're now in.
     */
    void enter(@InternalState int state) {
        assert state == InternalState.UNDEFINED
                || state == InternalState.IDLE
                || state == InternalState.LONG_PRESS_RECOGNIZED
                || state == InternalState.RESOLVING_LONG_PRESS_RECOGNIZED
                || state == InternalState.TAP_RECOGNIZED
                || state == InternalState.SELECTION_CLEARED_RECOGNIZED;
        mPreviousState = mState;
        mState = state;
        switch (state) {
            case InternalState.LONG_PRESS_RECOGNIZED:
                mSelectionType = SelectionType.LONG_PRESS;
                break;
            case InternalState.RESOLVING_LONG_PRESS_RECOGNIZED:
                mSelectionType = SelectionType.RESOLVING_LONG_PRESS;
                break;
            case InternalState.TAP_RECOGNIZED:
                mSelectionType = SelectionType.TAP;
                break;
            default:
                mSelectionType = SelectionType.UNDETERMINED;
        }
        notifyStartingWorkOn(mState);
        notifyFinishedWorkOn(mState);
    }

    /**
     * Confirms that work is starting on the given state.
     * @param state The {@link InternalState} that we're now working on.
     */
    void notifyStartingWorkOn(@InternalState int state) {
        assert mState == state;
        mDidStartWork = true;
    }

    /**
     * @return Whether we're still working on the given state.
     */
    boolean isStillWorkingOn(@InternalState int state) {
        return mState == state;
    }

    /**
     * Confirms that work has been finished on the given state, and will process all subsequent
     * non-asynchronous states before returning.  See https://crbug.com/1099383.
     * This should be called by every operation that waits for some kind of completion when it
     * completes.  The operation's start must be flagged using {@link #notifyStartingWorkOn}.
     * @param state The {@link InternalState} that we've finished working on.
     */
    void notifyFinishedWorkOn(@InternalState int state) {
        finishWorkingOn(state);
    }

    /**
     * Notifies that the given state has been started and completed. Useful when no work is needed.
     */
    void notifyStartedAndFinished(@InternalState int state) {
        notifyStartingWorkOn(state);
        notifyFinishedWorkOn(state);
    }

    /**
     * @return The current internal state for testing purposes.
     */
    @VisibleForTesting
    protected @InternalState int getState() {
        return mState;
    }

    /**
     * Establishes the given state by calling code that starts work on that state.
     * @param state The new {@link InternalState} to establish.
     */
    private void transitionTo(@InternalState int state) {
        transitionTo(state, null);
    }

    /**
     * Establishes the given state by calling code that starts work on that state or simply
     * displays the appropriate UX for that state.
     * @param state The new {@link InternalState} to establish.
     * @param reason The reason we're starting this state, or {@code null} if not significant
     *        or known.  Only needed when we enter the IDLE state.
     */
    private void transitionTo(
            final @InternalState int state, final @Nullable @StateChangeReason Integer reason) {
        if (state == mState && !mPolicy.shouldRetryCurrentState(state)) return;
        Log.v(TAG, "State transition " + String.valueOf(mState) + " => " + String.valueOf(state));

        // This should be the only part of the code that changes the state (other than #enter)!
        mPreviousState = mState;
        mState = state;

        mDidStartWork = false;
        startWorkingOn(state, reason);
    }

    /**
     * Starts working on the given state by calling code that starts work on that state or simply
     * displays the appropriate UX for that state.
     * @param state The new {@link InternalState} to establish.
     * @param reason The reason we're starting this state, or {@code null} if not significant
     *        or known.  Only needed when we enter the IDLE state.
     */
    private void startWorkingOn(
            @InternalState int state, @Nullable @StateChangeReason Integer reason) {
        // All transitional states should be listed here, but not start states.
        switch (state) {
            case InternalState.IDLE:
                assert reason != null;
                mStateHandler.hideContextualSearchUi(reason);
                break;
            case InternalState.SHOWING_LITERAL_SEARCH:
                mStateHandler.showContextualSearchLiteralSearchUi();
                break;
            case InternalState.WAITING_FOR_POSSIBLE_TAP_NEAR_PREVIOUS:
                mStateHandler.waitForPossibleTapNearPrevious();
                break;
            case InternalState.WAITING_FOR_POSSIBLE_TAP_ON_TAP_SELECTION:
                mStateHandler.waitForPossibleTapOnTapSelection();
                break;
            case InternalState.TAP_GESTURE_COMMIT:
                mStateHandler.tapGestureCommit();
                break;
            case InternalState.GATHERING_SURROUNDINGS:
                mStateHandler.gatherSurroundingText();
                break;
            case InternalState.DECIDING_SUPPRESSION:
                mStateHandler.decideSuppression();
                break;
            case InternalState.START_SHOWING_TAP_UI:
                mStateHandler.startShowingTapUi();
                break;
            case InternalState.SHOW_RESOLVING_UI:
                mStateHandler.showContextualSearchResolvingUi();
                break;
            case InternalState.RESOLVING:
                mStateHandler.resolveSearchTerm();
                break;
            case InternalState.SHOWING_TAP_SEARCH:
                mStateHandler.showingTapSearch();
                break;
            case InternalState.SHOWING_RESOLVED_LONG_PRESS_SEARCH:
                mStateHandler.showingIntelligentLongpress();
                break;
            case InternalState.SEARCH_COMPLETED:
                mStateHandler.completeSearch();
                break;
            default:
                Log.w(TAG, "Warning: unexpected startWorkingOn " + String.valueOf(state));
                break;
        }
    }

    /**
     * Finishes working on the given state by making a transition to the next state if needed.
     * @param state The {@link InternalState} that we've finished working on.
     */
    private void finishWorkingOn(@InternalState int state) {
        // When an async task finishes work some action may have caused a reset and now we're
        // in a new sequence, so no need to finish work on the abandoned state.
        if (state != mState) return;

        // Should have called #nofifyStartingWorkOn this state already.
        assert mDidStartWork;

        if (mState == InternalState.IDLE || mState == InternalState.UNDEFINED) {
            Log.w(TAG, "Warning, the " + String.valueOf(state) + " state was aborted.");
            return;
        }

        switch (state) {
            case InternalState.LONG_PRESS_RECOGNIZED:
                transitionTo(InternalState.GATHERING_SURROUNDINGS);
                break;
            case InternalState.SHOWING_LITERAL_SEARCH:
                transitionTo(InternalState.SEARCH_COMPLETED);
                break;
            case InternalState.SELECTION_CLEARED_RECOGNIZED:
                if (mPreviousState != InternalState.UNDEFINED
                        && mPreviousState != InternalState.IDLE) {
                    transitionTo(InternalState.WAITING_FOR_POSSIBLE_TAP_NEAR_PREVIOUS);
                } else {
                    reset(StateChangeReason.BASE_PAGE_TAP);
                }
                break;
            case InternalState.WAITING_FOR_POSSIBLE_TAP_NEAR_PREVIOUS:
                // If a tap near the previous was detected we've started another sequence and won't
                // get here. So we know the wait completed without any other action so we need to
                // reset the UX.
                reset(StateChangeReason.BASE_PAGE_TAP);
                break;
            case InternalState.TAP_RECOGNIZED:
                transitionTo(
                        mPreviousState != InternalState.UNDEFINED
                                        && mPreviousState != InternalState.IDLE
                                ? InternalState.WAITING_FOR_POSSIBLE_TAP_ON_TAP_SELECTION
                                : InternalState.TAP_GESTURE_COMMIT);
                break;
            case InternalState.WAITING_FOR_POSSIBLE_TAP_ON_TAP_SELECTION:
                transitionTo(InternalState.TAP_GESTURE_COMMIT);
                break;
            case InternalState.TAP_GESTURE_COMMIT:
                transitionTo(InternalState.GATHERING_SURROUNDINGS);
                break;
            case InternalState.GATHERING_SURROUNDINGS:
                // We gather surroundings for both Tap and Long-press in order to notify icing.
                if (mSelectionType == SelectionType.LONG_PRESS) {
                    transitionTo(InternalState.SHOWING_LITERAL_SEARCH);
                } else if (mSelectionType == SelectionType.RESOLVING_LONG_PRESS) {
                    transitionTo(InternalState.SHOW_RESOLVING_UI);
                } else {
                    transitionTo(InternalState.DECIDING_SUPPRESSION);
                }
                break;
            case InternalState.DECIDING_SUPPRESSION:
                transitionTo(InternalState.START_SHOWING_TAP_UI);
                break;
            case InternalState.START_SHOWING_TAP_UI:
                transitionTo(InternalState.SHOW_RESOLVING_UI);
                break;
            case InternalState.SHOW_RESOLVING_UI:
                transitionTo(
                        mPolicy.shouldPreviousGestureResolve()
                                ? InternalState.RESOLVING
                                : InternalState.SHOWING_TAP_SEARCH);
                break;
            case InternalState.RESOLVING:
                transitionTo(
                        mSelectionType == SelectionType.TAP
                                ? InternalState.SHOWING_TAP_SEARCH
                                : InternalState.SHOWING_RESOLVED_LONG_PRESS_SEARCH);
                break;
            case InternalState.RESOLVING_LONG_PRESS_RECOGNIZED:
                transitionTo(InternalState.GATHERING_SURROUNDINGS);
                break;
            case InternalState.SHOWING_RESOLVED_LONG_PRESS_SEARCH:
                transitionTo(InternalState.SEARCH_COMPLETED);
                break;
            case InternalState.SHOWING_TAP_SEARCH:
                transitionTo(InternalState.SEARCH_COMPLETED);
                break;
            case InternalState.SEARCH_COMPLETED:
                // Resting state
                break;
            default:
                Log.e(TAG, "The state " + String.valueOf(state) + " is not transitional!");
                assert false;
        }
    }
}