chromium/content/public/android/java/src/org/chromium/content/browser/input/TextSuggestionHost.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.content.browser.input;

import android.content.Context;

import androidx.annotation.VisibleForTesting;

import org.jni_zero.CalledByNative;
import org.jni_zero.JNINamespace;
import org.jni_zero.NativeMethods;

import org.chromium.base.UserData;
import org.chromium.content.browser.PopupController;
import org.chromium.content.browser.PopupController.HideablePopup;
import org.chromium.content.browser.WindowEventObserver;
import org.chromium.content.browser.WindowEventObserverManager;
import org.chromium.content.browser.webcontents.WebContentsImpl;
import org.chromium.content.browser.webcontents.WebContentsImpl.UserDataFactory;
import org.chromium.content_public.browser.WebContents;
import org.chromium.ui.base.ViewAndroidDelegate;
import org.chromium.ui.base.WindowAndroid;

/**
 * Handles displaying the Android spellcheck/text suggestion menu (provided by
 * SuggestionsPopupWindow) when requested by the C++ class TextSuggestionHostAndroid and applying
 * the commands in that menu (by calling back to the C++ class).
 */
@JNINamespace("content")
public class TextSuggestionHost implements WindowEventObserver, HideablePopup, UserData {
    private long mNativeTextSuggestionHost;
    private final WebContentsImpl mWebContents;
    private final Context mContext;
    private final ViewAndroidDelegate mViewDelegate;

    private boolean mIsAttachedToWindow;
    private WindowAndroid mWindowAndroid;

    private SpellCheckPopupWindow mSpellCheckPopupWindow;
    private TextSuggestionsPopupWindow mTextSuggestionsPopupWindow;

    private static final class UserDataFactoryLazyHolder {
        private static final UserDataFactory<TextSuggestionHost> INSTANCE = TextSuggestionHost::new;
    }

    /**
     * Get {@link TextSuggestionHost} object used for the give WebContents.
     * {@link #create()} should precede any calls to this.
     * @param webContents {@link WebContents} object.
     * @return {@link TextSuggestionHost} object.
     */
    @VisibleForTesting
    static TextSuggestionHost fromWebContents(WebContents webContents) {
        return ((WebContentsImpl) webContents)
                .getOrSetUserData(TextSuggestionHost.class, UserDataFactoryLazyHolder.INSTANCE);
    }

    @CalledByNative
    private static TextSuggestionHost create(WebContents webContents, long nativePtr) {
        TextSuggestionHost host = fromWebContents(webContents);
        host.setNativePtr(nativePtr);
        return host;
    }

    /**
     * Create {@link TextSuggestionHost} instance.
     * @param webContents WebContents instance.
     */
    public TextSuggestionHost(WebContents webContents) {
        mWebContents = (WebContentsImpl) webContents;
        mContext = mWebContents.getContext();
        mWindowAndroid = mWebContents.getTopLevelNativeWindow();
        mViewDelegate = mWebContents.getViewAndroidDelegate();
        assert mViewDelegate != null;
        PopupController.register(mWebContents, this);
        WindowEventObserverManager.from(mWebContents).addObserver(this);
    }

    private void setNativePtr(long nativePtr) {
        mNativeTextSuggestionHost = nativePtr;
    }

    private float getContentOffsetYPix() {
        return mWebContents.getRenderCoordinates().getContentOffsetYPix();
    }

    // WindowEventObserver

    @Override
    public void onWindowAndroidChanged(WindowAndroid newWindowAndroid) {
        mWindowAndroid = newWindowAndroid;
        if (mSpellCheckPopupWindow != null) {
            mSpellCheckPopupWindow.updateWindowAndroid(mWindowAndroid);
        }
        if (mTextSuggestionsPopupWindow != null) {
            mTextSuggestionsPopupWindow.updateWindowAndroid(mWindowAndroid);
        }
    }

    @Override
    public void onAttachedToWindow() {
        mIsAttachedToWindow = true;
    }

    @Override
    public void onDetachedFromWindow() {
        mIsAttachedToWindow = false;
    }

    @Override
    public void onRotationChanged(int rotation) {
        hidePopups();
    }

    // HieablePopup
    @Override
    public void hide() {
        hidePopups();
    }

    @CalledByNative
    private void showSpellCheckSuggestionMenu(
            double caretXPx, double caretYPx, String markedText, String[] suggestions) {
        if (!mIsAttachedToWindow) {
            // This can happen if a new browser window is opened immediately after tapping a spell
            // check underline, before the timer to open the menu fires.
            onSuggestionMenuClosed(false);
            return;
        }

        hidePopups();
        mSpellCheckPopupWindow =
                new SpellCheckPopupWindow(
                        mContext, this, mWindowAndroid, mViewDelegate.getContainerView());

        mSpellCheckPopupWindow.show(
                caretXPx, caretYPx + getContentOffsetYPix(), markedText, suggestions);
    }

    @CalledByNative
    private void showTextSuggestionMenu(
            double caretXPx, double caretYPx, String markedText, SuggestionInfo[] suggestions) {
        if (!mIsAttachedToWindow) {
            // This can happen if a new browser window is opened immediately after tapping a spell
            // check underline, before the timer to open the menu fires.
            onSuggestionMenuClosed(false);
            return;
        }

        hidePopups();
        mTextSuggestionsPopupWindow =
                new TextSuggestionsPopupWindow(
                        mContext, this, mWindowAndroid, mViewDelegate.getContainerView());

        mTextSuggestionsPopupWindow.show(
                caretXPx, caretYPx + getContentOffsetYPix(), markedText, suggestions);
    }

    /** Hides the text suggestion menu (and informs Blink that it was closed). */
    @CalledByNative
    public void hidePopups() {
        if (mTextSuggestionsPopupWindow != null && mTextSuggestionsPopupWindow.isShowing()) {
            mTextSuggestionsPopupWindow.dismiss();
            mTextSuggestionsPopupWindow = null;
        }

        if (mSpellCheckPopupWindow != null && mSpellCheckPopupWindow.isShowing()) {
            mSpellCheckPopupWindow.dismiss();
            mSpellCheckPopupWindow = null;
        }
    }

    /** Tells Blink to replace the active suggestion range with the specified replacement. */
    public void applySpellCheckSuggestion(String suggestion) {
        TextSuggestionHostJni.get()
                .applySpellCheckSuggestion(
                        mNativeTextSuggestionHost, TextSuggestionHost.this, suggestion);
    }

    /**
     * Tells Blink to replace the active suggestion range with the specified suggestion on the
     * specified marker.
     */
    public void applyTextSuggestion(int markerTag, int suggestionIndex) {
        TextSuggestionHostJni.get()
                .applyTextSuggestion(
                        mNativeTextSuggestionHost,
                        TextSuggestionHost.this,
                        markerTag,
                        suggestionIndex);
    }

    /** Tells Blink to delete the active suggestion range. */
    public void deleteActiveSuggestionRange() {
        TextSuggestionHostJni.get()
                .deleteActiveSuggestionRange(mNativeTextSuggestionHost, TextSuggestionHost.this);
    }

    /** Tells Blink to remove spelling markers under all instances of the specified word. */
    public void onNewWordAddedToDictionary(String word) {
        TextSuggestionHostJni.get()
                .onNewWordAddedToDictionary(
                        mNativeTextSuggestionHost, TextSuggestionHost.this, word);
    }

    /**
     * Tells Blink the suggestion menu was closed (and also clears the reference to the
     * SuggestionsPopupWindow instance so it can be garbage collected).
     */
    public void onSuggestionMenuClosed(boolean dismissedByItemTap) {
        if (!dismissedByItemTap) {
            TextSuggestionHostJni.get()
                    .onSuggestionMenuClosed(mNativeTextSuggestionHost, TextSuggestionHost.this);
        }
        mSpellCheckPopupWindow = null;
        mTextSuggestionsPopupWindow = null;
    }

    @CalledByNative
    private void onNativeDestroyed() {
        hidePopups();
        mNativeTextSuggestionHost = 0;
    }

    /**
     * @return The TextSuggestionsPopupWindow, if one exists.
     */
    public SuggestionsPopupWindow getTextSuggestionsPopupWindowForTesting() {
        return mTextSuggestionsPopupWindow;
    }

    /**
     * @return The SpellCheckPopupWindow, if one exists.
     */
    public SuggestionsPopupWindow getSpellCheckPopupWindowForTesting() {
        return mSpellCheckPopupWindow;
    }

    @NativeMethods
    interface Natives {
        void applySpellCheckSuggestion(
                long nativeTextSuggestionHostAndroid, TextSuggestionHost caller, String suggestion);

        void applyTextSuggestion(
                long nativeTextSuggestionHostAndroid,
                TextSuggestionHost caller,
                int markerTag,
                int suggestionIndex);

        void deleteActiveSuggestionRange(
                long nativeTextSuggestionHostAndroid, TextSuggestionHost caller);

        void onNewWordAddedToDictionary(
                long nativeTextSuggestionHostAndroid, TextSuggestionHost caller, String word);

        void onSuggestionMenuClosed(
                long nativeTextSuggestionHostAndroid, TextSuggestionHost caller);
    }
}