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

import android.content.Context;
import android.os.Build;
import android.view.textclassifier.SelectionEvent;
import android.view.textclassifier.TextClassificationContext;
import android.view.textclassifier.TextClassificationManager;
import android.view.textclassifier.TextClassifier;

import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;

import org.chromium.base.Log;
import org.chromium.content.browser.WindowEventObserver;
import org.chromium.content.browser.WindowEventObserverManager;
import org.chromium.content_public.browser.SelectionClient;
import org.chromium.content_public.browser.SelectionEventProcessor;
import org.chromium.content_public.browser.WebContents;
import org.chromium.ui.base.WindowAndroid;

/**
 * Logs various selection events and manages the lifecycle of a text classifier session.
 * We are logging word indices here. For one example:
 *     New York City , NY
 *    -1   0    1    2 3  4
 * We selected "York" at the first place, so York is [0, 1). Then we assume that Smart Selection
 * expanded the selection range to the whole "New York City , NY", we need to log [-1, 4). After
 * that, we single tap on "City", Smart Selection reset get triggered, we need to log [1, 2). Spaces
 * are ignored but we count each punctuation mark as a word.
 */
@RequiresApi(Build.VERSION_CODES.P)
public class SmartSelectionEventProcessor implements SelectionEventProcessor {
    private static final String TAG = "SmartSelectionLogger";
    private static final boolean DEBUG = false;

    // May be null if {@link onWindowAndroidChanged()} sets it to null.
    private WindowAndroid mWindowAndroid;

    private TextClassifier mSession;

    private SelectionIndicesConverter mConverter;

    public static SmartSelectionEventProcessor create(WebContents webContents) {
        if (webContents.getTopLevelNativeWindow().getContext().get() == null) {
            return null;
        }
        return new SmartSelectionEventProcessor(webContents);
    }

    private SmartSelectionEventProcessor(WebContents webContents) {
        mWindowAndroid = webContents.getTopLevelNativeWindow();
        WindowEventObserverManager manager = WindowEventObserverManager.from(webContents);
        if (manager != null) {
            manager.addObserver(
                    new WindowEventObserver() {
                        @Override
                        public void onWindowAndroidChanged(WindowAndroid newWindowAndroid) {
                            mWindowAndroid = newWindowAndroid;
                        }
                    });
        }
    }

    public void onSelectionStarted(String selectionText, int startOffset, boolean editable) {
        if (mWindowAndroid == null) return;

        Context context = mWindowAndroid.getContext().get();
        if (context == null) return;

        mSession = createSession(context, editable);
        mConverter = new SelectionIndicesConverter();
        mConverter.updateSelectionState(selectionText, startOffset);
        mConverter.setInitialStartOffset(startOffset);

        if (DEBUG) Log.d(TAG, "logSelectionStarted");
        logEvent(SelectionEvent.createSelectionStartedEvent(SelectionEvent.INVOCATION_MANUAL, 0));
    }

    public void onSelectionModified(
            String selectionText, int startOffset, SelectionClient.Result result) {
        if (mSession == null) return;
        if (!mConverter.updateSelectionState(selectionText, startOffset)) {
            // DOM change detected, end logging session.
            endTextClassificationSession();
            return;
        }

        int endOffset = startOffset + selectionText.length();
        int[] indices = new int[2];
        if (!mConverter.getWordDelta(startOffset, endOffset, indices)) {
            // Invalid indices, end logging session.
            endTextClassificationSession();
            return;
        }

        if (DEBUG) Log.d(TAG, "logSelectionModified [%d, %d)", indices[0], indices[1]);
        if (result != null && result.textSelection != null) {
            logEvent(
                    SelectionEvent.createSelectionModifiedEvent(
                            indices[0], indices[1], result.textSelection));
        } else if (result != null && result.textClassification != null) {
            logEvent(
                    SelectionEvent.createSelectionModifiedEvent(
                            indices[0], indices[1], result.textClassification));
        } else {
            logEvent(SelectionEvent.createSelectionModifiedEvent(indices[0], indices[1]));
        }
    }

    public void onSelectionAction(
            String selectionText, int startOffset, int action, SelectionClient.Result result) {
        if (mSession == null) {
            return;
        }
        if (!mConverter.updateSelectionState(selectionText, startOffset)) {
            // DOM change detected, end logging session.
            endTextClassificationSession();
            return;
        }

        int endOffset = startOffset + selectionText.length();
        int[] indices = new int[2];
        if (!mConverter.getWordDelta(startOffset, endOffset, indices)) {
            // Invalid indices, end logging session.
            endTextClassificationSession();
            return;
        }

        if (DEBUG) {
            Log.d(TAG, "logSelectionAction [%d, %d)", indices[0], indices[1]);
            Log.d(TAG, "logSelectionAction ActionType = %d", action);
        }

        if (result != null && result.textClassification != null) {
            logEvent(
                    SelectionEvent.createSelectionActionEvent(
                            indices[0], indices[1], action, result.textClassification));
        } else {
            logEvent(SelectionEvent.createSelectionActionEvent(indices[0], indices[1], action));
        }

        if (SelectionEvent.isTerminal(action)) {
            endTextClassificationSession();
        }
    }

    private TextClassifier createSession(Context context, boolean editable) {
        TextClassificationContext textClassificationContext =
                new TextClassificationContext.Builder(
                                context.getPackageName(),
                                editable
                                        ? TextClassifier.WIDGET_TYPE_EDIT_WEBVIEW
                                        : TextClassifier.WIDGET_TYPE_WEBVIEW)
                        .build();
        TextClassificationManager tcm =
                (TextClassificationManager)
                        context.getSystemService(Context.TEXT_CLASSIFICATION_SERVICE);
        return tcm.createTextClassificationSession(textClassificationContext);
    }

    private void endTextClassificationSession() {
        if (mSession == null || mSession.isDestroyed()) {
            return;
        }
        mSession.destroy();
        mSession = null;
    }

    public void logEvent(SelectionEvent selectionEvent) {
        mSession.onSelectionEvent(selectionEvent);
    }

    @Nullable
    public TextClassifier getTextClassifierSession() {
        return mSession;
    }
}