blob: 28cb80d0c74cb2d709f120e3a9e0d7cdc4438480 [file] [log] [blame]
/*
* Copyright (C) 2017 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package android.view.textclassifier.logging;
import android.annotation.IntDef;
import android.annotation.NonNull;
import android.annotation.Nullable;
import android.compat.annotation.UnsupportedAppUsage;
import android.content.Context;
import android.metrics.LogMaker;
import android.os.Build;
import android.util.Log;
import android.view.textclassifier.TextClassification;
import android.view.textclassifier.TextClassifier;
import android.view.textclassifier.TextSelection;
import com.android.internal.annotations.VisibleForTesting;
import com.android.internal.logging.MetricsLogger;
import com.android.internal.logging.nano.MetricsProto.MetricsEvent;
import com.android.internal.util.Preconditions;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.util.Objects;
import java.util.UUID;
/**
* A selection event tracker.
* @hide
*/
//TODO: Do not allow any crashes from this class.
public final class SmartSelectionEventTracker {
private static final String LOG_TAG = "SmartSelectEventTracker";
private static final boolean DEBUG_LOG_ENABLED = true;
private static final int START_EVENT_DELTA = MetricsEvent.FIELD_SELECTION_SINCE_START;
private static final int PREV_EVENT_DELTA = MetricsEvent.FIELD_SELECTION_SINCE_PREVIOUS;
private static final int INDEX = MetricsEvent.FIELD_SELECTION_SESSION_INDEX;
private static final int WIDGET_TYPE = MetricsEvent.FIELD_SELECTION_WIDGET_TYPE;
private static final int WIDGET_VERSION = MetricsEvent.FIELD_SELECTION_WIDGET_VERSION;
private static final int MODEL_NAME = MetricsEvent.FIELD_TEXTCLASSIFIER_MODEL;
private static final int ENTITY_TYPE = MetricsEvent.FIELD_SELECTION_ENTITY_TYPE;
private static final int SMART_START = MetricsEvent.FIELD_SELECTION_SMART_RANGE_START;
private static final int SMART_END = MetricsEvent.FIELD_SELECTION_SMART_RANGE_END;
private static final int EVENT_START = MetricsEvent.FIELD_SELECTION_RANGE_START;
private static final int EVENT_END = MetricsEvent.FIELD_SELECTION_RANGE_END;
private static final int SESSION_ID = MetricsEvent.FIELD_SELECTION_SESSION_ID;
private static final String ZERO = "0";
private static final String TEXTVIEW = "textview";
private static final String EDITTEXT = "edittext";
private static final String UNSELECTABLE_TEXTVIEW = "nosel-textview";
private static final String WEBVIEW = "webview";
private static final String EDIT_WEBVIEW = "edit-webview";
private static final String CUSTOM_TEXTVIEW = "customview";
private static final String CUSTOM_EDITTEXT = "customedit";
private static final String CUSTOM_UNSELECTABLE_TEXTVIEW = "nosel-customview";
private static final String UNKNOWN = "unknown";
@Retention(RetentionPolicy.SOURCE)
@IntDef({WidgetType.UNSPECIFIED, WidgetType.TEXTVIEW, WidgetType.WEBVIEW,
WidgetType.EDITTEXT, WidgetType.EDIT_WEBVIEW})
public @interface WidgetType {
int UNSPECIFIED = 0;
int TEXTVIEW = 1;
int WEBVIEW = 2;
int EDITTEXT = 3;
int EDIT_WEBVIEW = 4;
int UNSELECTABLE_TEXTVIEW = 5;
int CUSTOM_TEXTVIEW = 6;
int CUSTOM_EDITTEXT = 7;
int CUSTOM_UNSELECTABLE_TEXTVIEW = 8;
}
private final MetricsLogger mMetricsLogger = new MetricsLogger();
private final int mWidgetType;
@Nullable private final String mWidgetVersion;
private final Context mContext;
@Nullable private String mSessionId;
private final int[] mSmartIndices = new int[2];
private final int[] mPrevIndices = new int[2];
private int mOrigStart;
private int mIndex;
private long mSessionStartTime;
private long mLastEventTime;
private boolean mSmartSelectionTriggered;
private String mModelName;
@UnsupportedAppUsage(trackingBug = 136637107, maxTargetSdk = Build.VERSION_CODES.Q,
publicAlternatives = "See {@link android.view.textclassifier.TextClassifier}.")
public SmartSelectionEventTracker(@NonNull Context context, @WidgetType int widgetType) {
mWidgetType = widgetType;
mWidgetVersion = null;
mContext = Objects.requireNonNull(context);
}
public SmartSelectionEventTracker(
@NonNull Context context, @WidgetType int widgetType, @Nullable String widgetVersion) {
mWidgetType = widgetType;
mWidgetVersion = widgetVersion;
mContext = Objects.requireNonNull(context);
}
/**
* Logs a selection event.
*
* @param event the selection event
*/
@UnsupportedAppUsage(trackingBug = 136637107, maxTargetSdk = Build.VERSION_CODES.Q,
publicAlternatives = "See {@link android.view.textclassifier.TextClassifier}.")
public void logEvent(@NonNull SelectionEvent event) {
Objects.requireNonNull(event);
if (event.mEventType != SelectionEvent.EventType.SELECTION_STARTED && mSessionId == null
&& DEBUG_LOG_ENABLED) {
Log.d(LOG_TAG, "Selection session not yet started. Ignoring event");
return;
}
final long now = System.currentTimeMillis();
switch (event.mEventType) {
case SelectionEvent.EventType.SELECTION_STARTED:
mSessionId = startNewSession();
Preconditions.checkArgument(event.mEnd == event.mStart + 1);
mOrigStart = event.mStart;
mSessionStartTime = now;
break;
case SelectionEvent.EventType.SMART_SELECTION_SINGLE: // fall through
case SelectionEvent.EventType.SMART_SELECTION_MULTI:
mSmartSelectionTriggered = true;
mModelName = getModelName(event);
mSmartIndices[0] = event.mStart;
mSmartIndices[1] = event.mEnd;
break;
case SelectionEvent.EventType.SELECTION_MODIFIED: // fall through
case SelectionEvent.EventType.AUTO_SELECTION:
if (mPrevIndices[0] == event.mStart && mPrevIndices[1] == event.mEnd) {
// Selection did not change. Ignore event.
return;
}
}
writeEvent(event, now);
if (event.isTerminal()) {
endSession();
}
}
private void writeEvent(SelectionEvent event, long now) {
final long prevEventDelta = mLastEventTime == 0 ? 0 : now - mLastEventTime;
final LogMaker log = new LogMaker(MetricsEvent.TEXT_SELECTION_SESSION)
.setType(getLogType(event))
.setSubtype(MetricsEvent.TEXT_SELECTION_INVOCATION_MANUAL)
.setPackageName(mContext.getPackageName())
.addTaggedData(START_EVENT_DELTA, now - mSessionStartTime)
.addTaggedData(PREV_EVENT_DELTA, prevEventDelta)
.addTaggedData(INDEX, mIndex)
.addTaggedData(WIDGET_TYPE, getWidgetTypeName())
.addTaggedData(WIDGET_VERSION, mWidgetVersion)
.addTaggedData(MODEL_NAME, mModelName)
.addTaggedData(ENTITY_TYPE, event.mEntityType)
.addTaggedData(SMART_START, getSmartRangeDelta(mSmartIndices[0]))
.addTaggedData(SMART_END, getSmartRangeDelta(mSmartIndices[1]))
.addTaggedData(EVENT_START, getRangeDelta(event.mStart))
.addTaggedData(EVENT_END, getRangeDelta(event.mEnd))
.addTaggedData(SESSION_ID, mSessionId);
mMetricsLogger.write(log);
debugLog(log);
mLastEventTime = now;
mPrevIndices[0] = event.mStart;
mPrevIndices[1] = event.mEnd;
mIndex++;
}
private String startNewSession() {
endSession();
mSessionId = createSessionId();
return mSessionId;
}
private void endSession() {
// Reset fields.
mOrigStart = 0;
mSmartIndices[0] = mSmartIndices[1] = 0;
mPrevIndices[0] = mPrevIndices[1] = 0;
mIndex = 0;
mSessionStartTime = 0;
mLastEventTime = 0;
mSmartSelectionTriggered = false;
mModelName = getModelName(null);
mSessionId = null;
}
private static int getLogType(SelectionEvent event) {
switch (event.mEventType) {
case SelectionEvent.ActionType.OVERTYPE:
return MetricsEvent.ACTION_TEXT_SELECTION_OVERTYPE;
case SelectionEvent.ActionType.COPY:
return MetricsEvent.ACTION_TEXT_SELECTION_COPY;
case SelectionEvent.ActionType.PASTE:
return MetricsEvent.ACTION_TEXT_SELECTION_PASTE;
case SelectionEvent.ActionType.CUT:
return MetricsEvent.ACTION_TEXT_SELECTION_CUT;
case SelectionEvent.ActionType.SHARE:
return MetricsEvent.ACTION_TEXT_SELECTION_SHARE;
case SelectionEvent.ActionType.SMART_SHARE:
return MetricsEvent.ACTION_TEXT_SELECTION_SMART_SHARE;
case SelectionEvent.ActionType.DRAG:
return MetricsEvent.ACTION_TEXT_SELECTION_DRAG;
case SelectionEvent.ActionType.ABANDON:
return MetricsEvent.ACTION_TEXT_SELECTION_ABANDON;
case SelectionEvent.ActionType.OTHER:
return MetricsEvent.ACTION_TEXT_SELECTION_OTHER;
case SelectionEvent.ActionType.SELECT_ALL:
return MetricsEvent.ACTION_TEXT_SELECTION_SELECT_ALL;
case SelectionEvent.ActionType.RESET:
return MetricsEvent.ACTION_TEXT_SELECTION_RESET;
case SelectionEvent.EventType.SELECTION_STARTED:
return MetricsEvent.ACTION_TEXT_SELECTION_START;
case SelectionEvent.EventType.SELECTION_MODIFIED:
return MetricsEvent.ACTION_TEXT_SELECTION_MODIFY;
case SelectionEvent.EventType.SMART_SELECTION_SINGLE:
return MetricsEvent.ACTION_TEXT_SELECTION_SMART_SINGLE;
case SelectionEvent.EventType.SMART_SELECTION_MULTI:
return MetricsEvent.ACTION_TEXT_SELECTION_SMART_MULTI;
case SelectionEvent.EventType.AUTO_SELECTION:
return MetricsEvent.ACTION_TEXT_SELECTION_AUTO;
default:
return MetricsEvent.VIEW_UNKNOWN;
}
}
private static String getLogTypeString(int logType) {
switch (logType) {
case MetricsEvent.ACTION_TEXT_SELECTION_OVERTYPE:
return "OVERTYPE";
case MetricsEvent.ACTION_TEXT_SELECTION_COPY:
return "COPY";
case MetricsEvent.ACTION_TEXT_SELECTION_PASTE:
return "PASTE";
case MetricsEvent.ACTION_TEXT_SELECTION_CUT:
return "CUT";
case MetricsEvent.ACTION_TEXT_SELECTION_SHARE:
return "SHARE";
case MetricsEvent.ACTION_TEXT_SELECTION_SMART_SHARE:
return "SMART_SHARE";
case MetricsEvent.ACTION_TEXT_SELECTION_DRAG:
return "DRAG";
case MetricsEvent.ACTION_TEXT_SELECTION_ABANDON:
return "ABANDON";
case MetricsEvent.ACTION_TEXT_SELECTION_OTHER:
return "OTHER";
case MetricsEvent.ACTION_TEXT_SELECTION_SELECT_ALL:
return "SELECT_ALL";
case MetricsEvent.ACTION_TEXT_SELECTION_RESET:
return "RESET";
case MetricsEvent.ACTION_TEXT_SELECTION_START:
return "SELECTION_STARTED";
case MetricsEvent.ACTION_TEXT_SELECTION_MODIFY:
return "SELECTION_MODIFIED";
case MetricsEvent.ACTION_TEXT_SELECTION_SMART_SINGLE:
return "SMART_SELECTION_SINGLE";
case MetricsEvent.ACTION_TEXT_SELECTION_SMART_MULTI:
return "SMART_SELECTION_MULTI";
case MetricsEvent.ACTION_TEXT_SELECTION_AUTO:
return "AUTO_SELECTION";
default:
return UNKNOWN;
}
}
private int getRangeDelta(int offset) {
return offset - mOrigStart;
}
private int getSmartRangeDelta(int offset) {
return mSmartSelectionTriggered ? getRangeDelta(offset) : 0;
}
private String getWidgetTypeName() {
switch (mWidgetType) {
case WidgetType.TEXTVIEW:
return TEXTVIEW;
case WidgetType.WEBVIEW:
return WEBVIEW;
case WidgetType.EDITTEXT:
return EDITTEXT;
case WidgetType.EDIT_WEBVIEW:
return EDIT_WEBVIEW;
case WidgetType.UNSELECTABLE_TEXTVIEW:
return UNSELECTABLE_TEXTVIEW;
case WidgetType.CUSTOM_TEXTVIEW:
return CUSTOM_TEXTVIEW;
case WidgetType.CUSTOM_EDITTEXT:
return CUSTOM_EDITTEXT;
case WidgetType.CUSTOM_UNSELECTABLE_TEXTVIEW:
return CUSTOM_UNSELECTABLE_TEXTVIEW;
default:
return UNKNOWN;
}
}
private String getModelName(@Nullable SelectionEvent event) {
return event == null
? SelectionEvent.NO_VERSION_TAG
: Objects.toString(event.mVersionTag, SelectionEvent.NO_VERSION_TAG);
}
private static String createSessionId() {
return UUID.randomUUID().toString();
}
private static void debugLog(LogMaker log) {
if (!DEBUG_LOG_ENABLED) return;
final String widgetType = Objects.toString(log.getTaggedData(WIDGET_TYPE), UNKNOWN);
final String widgetVersion = Objects.toString(log.getTaggedData(WIDGET_VERSION), "");
final String widget = widgetVersion.isEmpty()
? widgetType : widgetType + "-" + widgetVersion;
final int index = Integer.parseInt(Objects.toString(log.getTaggedData(INDEX), ZERO));
if (log.getType() == MetricsEvent.ACTION_TEXT_SELECTION_START) {
String sessionId = Objects.toString(log.getTaggedData(SESSION_ID), "");
sessionId = sessionId.substring(sessionId.lastIndexOf("-") + 1);
Log.d(LOG_TAG, String.format("New selection session: %s (%s)", widget, sessionId));
}
final String model = Objects.toString(log.getTaggedData(MODEL_NAME), UNKNOWN);
final String entity = Objects.toString(log.getTaggedData(ENTITY_TYPE), UNKNOWN);
final String type = getLogTypeString(log.getType());
final int smartStart = Integer.parseInt(
Objects.toString(log.getTaggedData(SMART_START), ZERO));
final int smartEnd = Integer.parseInt(
Objects.toString(log.getTaggedData(SMART_END), ZERO));
final int eventStart = Integer.parseInt(
Objects.toString(log.getTaggedData(EVENT_START), ZERO));
final int eventEnd = Integer.parseInt(
Objects.toString(log.getTaggedData(EVENT_END), ZERO));
Log.d(LOG_TAG, String.format("%2d: %s/%s, range=%d,%d - smart_range=%d,%d (%s/%s)",
index, type, entity, eventStart, eventEnd, smartStart, smartEnd, widget, model));
}
/**
* A selection event.
* Specify index parameters as word token indices.
*/
public static final class SelectionEvent {
/**
* Use this to specify an indeterminate positive index.
*/
public static final int OUT_OF_BOUNDS = Integer.MAX_VALUE;
/**
* Use this to specify an indeterminate negative index.
*/
public static final int OUT_OF_BOUNDS_NEGATIVE = Integer.MIN_VALUE;
private static final String NO_VERSION_TAG = "";
@Retention(RetentionPolicy.SOURCE)
@IntDef({ActionType.OVERTYPE, ActionType.COPY, ActionType.PASTE, ActionType.CUT,
ActionType.SHARE, ActionType.SMART_SHARE, ActionType.DRAG, ActionType.ABANDON,
ActionType.OTHER, ActionType.SELECT_ALL, ActionType.RESET})
public @interface ActionType {
/** User typed over the selection. */
int OVERTYPE = 100;
/** User copied the selection. */
int COPY = 101;
/** User pasted over the selection. */
int PASTE = 102;
/** User cut the selection. */
int CUT = 103;
/** User shared the selection. */
int SHARE = 104;
/** User clicked the textAssist menu item. */
int SMART_SHARE = 105;
/** User dragged+dropped the selection. */
int DRAG = 106;
/** User abandoned the selection. */
int ABANDON = 107;
/** User performed an action on the selection. */
int OTHER = 108;
/* Non-terminal actions. */
/** User activated Select All */
int SELECT_ALL = 200;
/** User reset the smart selection. */
int RESET = 201;
}
@Retention(RetentionPolicy.SOURCE)
@IntDef({ActionType.OVERTYPE, ActionType.COPY, ActionType.PASTE, ActionType.CUT,
ActionType.SHARE, ActionType.SMART_SHARE, ActionType.DRAG, ActionType.ABANDON,
ActionType.OTHER, ActionType.SELECT_ALL, ActionType.RESET,
EventType.SELECTION_STARTED, EventType.SELECTION_MODIFIED,
EventType.SMART_SELECTION_SINGLE, EventType.SMART_SELECTION_MULTI,
EventType.AUTO_SELECTION})
private @interface EventType {
/** User started a new selection. */
int SELECTION_STARTED = 1;
/** User modified an existing selection. */
int SELECTION_MODIFIED = 2;
/** Smart selection triggered for a single token (word). */
int SMART_SELECTION_SINGLE = 3;
/** Smart selection triggered spanning multiple tokens (words). */
int SMART_SELECTION_MULTI = 4;
/** Something else other than User or the default TextClassifier triggered a selection. */
int AUTO_SELECTION = 5;
}
private final int mStart;
private final int mEnd;
private @EventType int mEventType;
private final @TextClassifier.EntityType String mEntityType;
private final String mVersionTag;
private SelectionEvent(
int start, int end, int eventType,
@TextClassifier.EntityType String entityType, String versionTag) {
Preconditions.checkArgument(end >= start, "end cannot be less than start");
mStart = start;
mEnd = end;
mEventType = eventType;
mEntityType = Objects.requireNonNull(entityType);
mVersionTag = Objects.requireNonNull(versionTag);
}
/**
* Creates a "selection started" event.
*
* @param start the word index of the selected word
*/
@UnsupportedAppUsage(trackingBug = 136637107, maxTargetSdk = Build.VERSION_CODES.Q,
publicAlternatives = "See {@link android.view.textclassifier.TextClassifier}.")
public static SelectionEvent selectionStarted(int start) {
return new SelectionEvent(
start, start + 1, EventType.SELECTION_STARTED,
TextClassifier.TYPE_UNKNOWN, NO_VERSION_TAG);
}
/**
* Creates a "selection modified" event.
* Use when the user modifies the selection.
*
* @param start the start word (inclusive) index of the selection
* @param end the end word (exclusive) index of the selection
*/
@UnsupportedAppUsage(trackingBug = 136637107, maxTargetSdk = Build.VERSION_CODES.Q,
publicAlternatives = "See {@link android.view.textclassifier.TextClassifier}.")
public static SelectionEvent selectionModified(int start, int end) {
return new SelectionEvent(
start, end, EventType.SELECTION_MODIFIED,
TextClassifier.TYPE_UNKNOWN, NO_VERSION_TAG);
}
/**
* Creates a "selection modified" event.
* Use when the user modifies the selection and the selection's entity type is known.
*
* @param start the start word (inclusive) index of the selection
* @param end the end word (exclusive) index of the selection
* @param classification the TextClassification object returned by the TextClassifier that
* classified the selected text
*/
@UnsupportedAppUsage(trackingBug = 136637107, maxTargetSdk = Build.VERSION_CODES.Q,
publicAlternatives = "See {@link android.view.textclassifier.TextClassifier}.")
public static SelectionEvent selectionModified(
int start, int end, @NonNull TextClassification classification) {
final String entityType = classification.getEntityCount() > 0
? classification.getEntity(0)
: TextClassifier.TYPE_UNKNOWN;
final String versionTag = getVersionInfo(classification.getId());
return new SelectionEvent(
start, end, EventType.SELECTION_MODIFIED, entityType, versionTag);
}
/**
* Creates a "selection modified" event.
* Use when a TextClassifier modifies the selection.
*
* @param start the start word (inclusive) index of the selection
* @param end the end word (exclusive) index of the selection
* @param selection the TextSelection object returned by the TextClassifier for the
* specified selection
*/
@UnsupportedAppUsage(trackingBug = 136637107, maxTargetSdk = Build.VERSION_CODES.Q,
publicAlternatives = "See {@link android.view.textclassifier.TextClassifier}.")
public static SelectionEvent selectionModified(
int start, int end, @NonNull TextSelection selection) {
final boolean smartSelection = getSourceClassifier(selection.getId())
.equals(TextClassifier.DEFAULT_LOG_TAG);
final int eventType;
if (smartSelection) {
eventType = end - start > 1
? EventType.SMART_SELECTION_MULTI
: EventType.SMART_SELECTION_SINGLE;
} else {
eventType = EventType.AUTO_SELECTION;
}
final String entityType = selection.getEntityCount() > 0
? selection.getEntity(0)
: TextClassifier.TYPE_UNKNOWN;
final String versionTag = getVersionInfo(selection.getId());
return new SelectionEvent(start, end, eventType, entityType, versionTag);
}
/**
* Creates an event specifying an action taken on a selection.
* Use when the user clicks on an action to act on the selected text.
*
* @param start the start word (inclusive) index of the selection
* @param end the end word (exclusive) index of the selection
* @param actionType the action that was performed on the selection
*/
@UnsupportedAppUsage(trackingBug = 136637107, maxTargetSdk = Build.VERSION_CODES.Q,
publicAlternatives = "See {@link android.view.textclassifier.TextClassifier}.")
public static SelectionEvent selectionAction(
int start, int end, @ActionType int actionType) {
return new SelectionEvent(
start, end, actionType, TextClassifier.TYPE_UNKNOWN, NO_VERSION_TAG);
}
/**
* Creates an event specifying an action taken on a selection.
* Use when the user clicks on an action to act on the selected text and the selection's
* entity type is known.
*
* @param start the start word (inclusive) index of the selection
* @param end the end word (exclusive) index of the selection
* @param actionType the action that was performed on the selection
* @param classification the TextClassification object returned by the TextClassifier that
* classified the selected text
*/
@UnsupportedAppUsage(trackingBug = 136637107, maxTargetSdk = Build.VERSION_CODES.Q,
publicAlternatives = "See {@link android.view.textclassifier.TextClassifier}.")
public static SelectionEvent selectionAction(
int start, int end, @ActionType int actionType,
@NonNull TextClassification classification) {
final String entityType = classification.getEntityCount() > 0
? classification.getEntity(0)
: TextClassifier.TYPE_UNKNOWN;
final String versionTag = getVersionInfo(classification.getId());
return new SelectionEvent(start, end, actionType, entityType, versionTag);
}
@VisibleForTesting
public static String getVersionInfo(String signature) {
final int start = signature.indexOf("|") + 1;
final int end = signature.indexOf("|", start);
if (start >= 1 && end >= start) {
return signature.substring(start, end);
}
return "";
}
private static String getSourceClassifier(String signature) {
final int end = signature.indexOf("|");
if (end >= 0) {
return signature.substring(0, end);
}
return "";
}
private boolean isTerminal() {
switch (mEventType) {
case ActionType.OVERTYPE: // fall through
case ActionType.COPY: // fall through
case ActionType.PASTE: // fall through
case ActionType.CUT: // fall through
case ActionType.SHARE: // fall through
case ActionType.SMART_SHARE: // fall through
case ActionType.DRAG: // fall through
case ActionType.ABANDON: // fall through
case ActionType.OTHER: // fall through
return true;
default:
return false;
}
}
}
}