diff options
8 files changed, 412 insertions, 63 deletions
diff --git a/core/java/android/service/notification/NotificationAssistantService.java b/core/java/android/service/notification/NotificationAssistantService.java index c850a4e0f815..dd0242ae2dc8 100644 --- a/core/java/android/service/notification/NotificationAssistantService.java +++ b/core/java/android/service/notification/NotificationAssistantService.java @@ -19,7 +19,7 @@ package android.service.notification; import static java.lang.annotation.RetentionPolicy.SOURCE; import android.annotation.IntDef; -import android.annotation.Nullable; +import android.annotation.NonNull; import android.annotation.SdkConstant; import android.annotation.SystemApi; import android.annotation.TestApi; @@ -179,13 +179,13 @@ public abstract class NotificationAssistantService extends NotificationListenerS * @param isExpanded whether the notification is expanded. */ public void onNotificationExpansionChanged( - String key, boolean isUserAction, boolean isExpanded) {} + @NonNull String key, boolean isUserAction, boolean isExpanded) {} /** * Implement this to know when a direct reply is sent from a notification. * @param key the notification key */ - public void onNotificationDirectReply(String key) {} + public void onNotificationDirectReply(@NonNull String key) {} /** * Implement this to know when a suggested reply is sent. @@ -193,7 +193,9 @@ public abstract class NotificationAssistantService extends NotificationListenerS * @param reply the reply that is just sent * @param source the source that provided the reply, e.g. SOURCE_FROM_APP */ - public void onSuggestedReplySent(String key, CharSequence reply, @Source int source) {} + public void onSuggestedReplySent(@NonNull String key, @NonNull CharSequence reply, + @Source int source) { + } /** * Implement this to know when an action is clicked. @@ -201,7 +203,8 @@ public abstract class NotificationAssistantService extends NotificationListenerS * @param action the action that is just clicked * @param source the source that provided the action, e.g. SOURCE_FROM_APP */ - public void onActionClicked(String key, @Nullable Notification.Action action, int source) { + public void onActionClicked(@NonNull String key, @NonNull Notification.Action action, + @Source int source) { } /** diff --git a/core/java/android/view/textclassifier/ActionsSuggestionsHelper.java b/core/java/android/view/textclassifier/ActionsSuggestionsHelper.java index b41096c74bf7..77cb4cd28763 100644 --- a/core/java/android/view/textclassifier/ActionsSuggestionsHelper.java +++ b/core/java/android/view/textclassifier/ActionsSuggestionsHelper.java @@ -17,6 +17,7 @@ package android.view.textclassifier; import android.app.Person; +import android.content.Context; import android.text.TextUtils; import android.util.ArrayMap; @@ -28,7 +29,10 @@ import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Deque; import java.util.List; +import java.util.Locale; import java.util.Map; +import java.util.Objects; +import java.util.StringJoiner; import java.util.function.Function; import java.util.stream.Collectors; @@ -84,6 +88,29 @@ public final class ActionsSuggestionsHelper { new ActionsSuggestionsModel.ConversationMessage[nativeMessages.size()]); } + /** + * Returns the result id for logging. + */ + public static String createResultId( + Context context, + List<ConversationActions.Message> messages, + int modelVersion, + List<Locale> modelLocales) { + final StringJoiner localesJoiner = new StringJoiner(","); + for (Locale locale : modelLocales) { + localesJoiner.add(locale.toLanguageTag()); + } + final String modelName = String.format( + Locale.US, "%s_v%d", localesJoiner.toString(), modelVersion); + final int hash = Objects.hash( + messages.stream() + .map(ConversationActions.Message::getText) + .collect(Collectors.toList()), + context.getPackageName()); + return SelectionSessionLogger.SignatureParser.createSignature( + SelectionSessionLogger.CLASSIFIER_ID, modelName, hash); + } + private static final class PersonEncoder { private final Map<Person, Integer> mMapping = new ArrayMap<>(); private int mNextUserId = FIRST_NON_LOCAL_USER; diff --git a/core/java/android/view/textclassifier/TextClassifierEvent.java b/core/java/android/view/textclassifier/TextClassifierEvent.java index 3bb9ee88dae9..f2fea02171f9 100644 --- a/core/java/android/view/textclassifier/TextClassifierEvent.java +++ b/core/java/android/view/textclassifier/TextClassifierEvent.java @@ -27,6 +27,7 @@ import com.android.internal.util.Preconditions; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; +import java.util.Arrays; /** * A text classifier event. @@ -498,4 +499,25 @@ public final class TextClassifierEvent implements Parcelable { } // TODO: Add build(boolean validate). } + + @Override + public String toString() { + StringBuilder out = new StringBuilder(128); + out.append("TextClassifierEvent{"); + out.append("mEventCategory=").append(mEventCategory); + out.append(", mEventType=").append(mEventType); + out.append(", mEventContext=").append(mEventContext); + out.append(", mResultId=").append(mResultId); + out.append(", mEventIndex=").append(mEventIndex); + out.append(", mEventTime=").append(mEventTime); + out.append(", mExtras=").append(mExtras); + out.append(", mRelativeWordStartIndex=").append(mRelativeWordStartIndex); + out.append(", mRelativeWordEndIndex=").append(mRelativeWordEndIndex); + out.append(", mRelativeSuggestedWordStartIndex=").append(mRelativeSuggestedWordStartIndex); + out.append(", mRelativeSuggestedWordEndIndex=").append(mRelativeSuggestedWordEndIndex); + out.append(", mActionIndices=").append(Arrays.toString(mActionIndices)); + out.append(", mLanguage=").append(mLanguage); + out.append("}"); + return out.toString(); + } } diff --git a/core/java/android/view/textclassifier/TextClassifierImpl.java b/core/java/android/view/textclassifier/TextClassifierImpl.java index 9b0f9c6ee5e8..fcd06c384921 100644 --- a/core/java/android/view/textclassifier/TextClassifierImpl.java +++ b/core/java/android/view/textclassifier/TextClassifierImpl.java @@ -80,6 +80,8 @@ public final class TextClassifierImpl implements TextClassifier { private static final String LOG_TAG = DEFAULT_LOG_TAG; + private static final boolean DEBUG = false; + private static final File FACTORY_MODEL_DIR = new File("/etc/textclassifier/"); // Annotator private static final String ANNOTATOR_FACTORY_MODEL_FILENAME_REGEX = @@ -109,6 +111,8 @@ public final class TextClassifierImpl implements TextClassifier { @GuardedBy("mLock") // Do not access outside this lock. private LangIdModel mLangIdImpl; @GuardedBy("mLock") // Do not access outside this lock. + private ModelFileManager.ModelFile mActionModelInUse; + @GuardedBy("mLock") // Do not access outside this lock. private ActionsSuggestionsModel mActionsImpl; private final Object mLoggerLock = new Object(); @@ -342,8 +346,10 @@ public final class TextClassifierImpl implements TextClassifier { } @Override - public void onTextClassifierEvent(@NonNull TextClassifierEvent event) { - // TODO: Implement. + public void onTextClassifierEvent(TextClassifierEvent event) { + if (DEBUG) { + Log.d(DEFAULT_LOG_TAG, "onTextClassifierEvent() called with: event = [" + event + "]"); + } } /** @inheritDoc */ @@ -408,7 +414,12 @@ public final class TextClassifierImpl implements TextClassifier { .setConfidenceScore(nativeSuggestion.getScore()) .build()); } - return new ConversationActions(conversationActions, /*id*/ null); + String resultId = ActionsSuggestionsHelper.createResultId( + mContext, + request.getConversation(), + mActionModelInUse.getVersion(), + mActionModelInUse.getSupportedLocales()); + return new ConversationActions(conversationActions, resultId); } catch (Throwable t) { // Avoid throwing from this method. Log the error. Log.e(LOG_TAG, "Error suggesting conversation actions.", t); @@ -517,6 +528,7 @@ public final class TextClassifierImpl implements TextClassifier { try { if (pfd != null) { mActionsImpl = new ActionsSuggestionsModel(pfd.getFd()); + mActionModelInUse = bestModel; } } finally { maybeCloseAndLogError(pfd); diff --git a/packages/ExtServices/src/android/ext/services/notification/Assistant.java b/packages/ExtServices/src/android/ext/services/notification/Assistant.java index 54087d1c67ef..af52c00388f3 100644 --- a/packages/ExtServices/src/android/ext/services/notification/Assistant.java +++ b/packages/ExtServices/src/android/ext/services/notification/Assistant.java @@ -117,7 +117,7 @@ public class Assistant extends NotificationAssistantService { mPackageManager = ActivityThread.getPackageManager(); mSettings = mSettingsFactory.createAndRegister(mHandler, getApplicationContext().getContentResolver(), getUserId(), this::updateThresholds); - mSmartActionsHelper = new SmartActionsHelper(); + mSmartActionsHelper = new SmartActionsHelper(getContext(), mSettings); mNotificationCategorizer = new NotificationCategorizer(); mAgingHelper = new AgingHelper(getContext(), mNotificationCategorizer, @@ -215,10 +215,8 @@ public class Assistant extends NotificationAssistantService { return null; } NotificationEntry entry = new NotificationEntry(mPackageManager, sbn, channel); - ArrayList<Notification.Action> actions = - mSmartActionsHelper.suggestActions(this, entry, mSettings); - ArrayList<CharSequence> replies = - mSmartActionsHelper.suggestReplies(this, entry, mSettings); + ArrayList<Notification.Action> actions = mSmartActionsHelper.suggestActions(entry); + ArrayList<CharSequence> replies = mSmartActionsHelper.suggestReplies(entry); return createEnqueuedNotificationAdjustment(entry, actions, replies); } @@ -343,6 +341,7 @@ public class Assistant extends NotificationAssistantService { if (entry != null) { entry.setSeen(); mAgingHelper.onNotificationSeen(entry); + mSmartActionsHelper.onNotificationSeen(entry); } } } catch (Throwable e) { @@ -351,34 +350,46 @@ public class Assistant extends NotificationAssistantService { } @Override - public void onNotificationExpansionChanged(String key, boolean isUserAction, + public void onNotificationExpansionChanged(@NonNull String key, boolean isUserAction, boolean isExpanded) { if (DEBUG) { - Log.i(TAG, - "onNotificationExpansionChanged " + key + ", isUserAction =" + isUserAction - + ", isExpanded = isExpanded"); + Log.d(TAG, "onNotificationExpansionChanged() called with: key = [" + key + + "], isUserAction = [" + isUserAction + "], isExpanded = [" + isExpanded + + "]"); + } + NotificationEntry entry = mLiveNotifications.get(key); + + if (entry != null) { + entry.setExpanded(isExpanded); + mSmartActionsHelper.onNotificationExpansionChanged(entry, isUserAction, isExpanded); } } @Override - public void onNotificationDirectReply(String key) { + public void onNotificationDirectReply(@NonNull String key) { if (DEBUG) Log.i(TAG, "onNotificationDirectReply " + key); + mSmartActionsHelper.onNotificationDirectReply(key); } @Override - public void onSuggestedReplySent(String key, CharSequence reply, int source) { + public void onSuggestedReplySent(@NonNull String key, @NonNull CharSequence reply, + @Source int source) { if (DEBUG) { Log.d(TAG, "onSuggestedReplySent() called with: key = [" + key + "], reply = [" + reply + "], source = [" + source + "]"); } + mSmartActionsHelper.onSuggestedReplySent(key, reply, source); } @Override - public void onActionClicked(String key, Notification.Action action, int source) { + public void onActionClicked(@NonNull String key, @NonNull Notification.Action action, + @Source int source) { if (DEBUG) { - Log.d(TAG, "onActionClicked() called with: key = [" + key + "], action = [" + action.title - + "], source = [" + source + "]"); + Log.d(TAG, + "onActionClicked() called with: key = [" + key + "], action = [" + action.title + + "], source = [" + source + "]"); } + mSmartActionsHelper.onActionClicked(key, action, source); } @Override diff --git a/packages/ExtServices/src/android/ext/services/notification/NotificationEntry.java b/packages/ExtServices/src/android/ext/services/notification/NotificationEntry.java index 6f437bd5d96f..71fd9ce6e4af 100644 --- a/packages/ExtServices/src/android/ext/services/notification/NotificationEntry.java +++ b/packages/ExtServices/src/android/ext/services/notification/NotificationEntry.java @@ -52,6 +52,8 @@ public class NotificationEntry { private NotificationChannel mChannel; private int mImportance; private boolean mSeen; + private boolean mExpanded; + private boolean mIsShowActionEventLogged; public NotificationEntry(IPackageManager packageManager, StatusBarNotification sbn, NotificationChannel channel) { @@ -216,10 +218,26 @@ public class NotificationEntry { mSeen = true; } + public void setExpanded(boolean expanded) { + mExpanded = expanded; + } + + public void setShowActionEventLogged() { + mIsShowActionEventLogged = true; + } + public boolean hasSeen() { return mSeen; } + public boolean isExpanded() { + return mExpanded; + } + + public boolean isShowActionEventLogged() { + return mIsShowActionEventLogged; + } + public StatusBarNotification getSbn() { return mSbn; } diff --git a/packages/ExtServices/src/android/ext/services/notification/SmartActionsHelper.java b/packages/ExtServices/src/android/ext/services/notification/SmartActionsHelper.java index 38df9b0a6fdc..b041842c45b9 100644 --- a/packages/ExtServices/src/android/ext/services/notification/SmartActionsHelper.java +++ b/packages/ExtServices/src/android/ext/services/notification/SmartActionsHelper.java @@ -24,12 +24,16 @@ import android.content.Context; import android.os.Bundle; import android.os.Parcelable; import android.os.Process; +import android.service.notification.NotificationAssistantService; import android.text.TextUtils; import android.util.ArrayMap; +import android.util.LruCache; import android.view.textclassifier.ConversationActions; import android.view.textclassifier.TextClassification; +import android.view.textclassifier.TextClassificationContext; import android.view.textclassifier.TextClassificationManager; import android.view.textclassifier.TextClassifier; +import android.view.textclassifier.TextClassifierEvent; import android.view.textclassifier.TextLinks; import java.time.Instant; @@ -47,6 +51,7 @@ public class SmartActionsHelper { private static final ArrayList<Notification.Action> EMPTY_ACTION_LIST = new ArrayList<>(); private static final ArrayList<CharSequence> EMPTY_REPLY_LIST = new ArrayList<>(); + private static final String KEY_ACTION_TYPE = "action_type"; // If a notification has any of these flags set, it's inelgibile for actions being added. private static final int FLAG_MASK_INELGIBILE_FOR_ACTIONS = Notification.FLAG_ONGOING_EVENT @@ -59,6 +64,7 @@ public class SmartActionsHelper { private static final int MAX_SUGGESTED_REPLIES = 3; // TODO: Make this configurable. private static final int MAX_MESSAGES_TO_EXTRACT = 5; + private static final int MAX_RESULT_ID_TO_CACHE = 20; private static final ConversationActions.TypeConfig TYPE_CONFIG = new ConversationActions.TypeConfig.Builder().setIncludedTypes( @@ -68,26 +74,36 @@ public class SmartActionsHelper { private static final List<String> HINTS = Collections.singletonList(ConversationActions.HINT_FOR_NOTIFICATION); - SmartActionsHelper() { + private Context mContext; + @Nullable + private TextClassifier mTextClassifier; + @NonNull + private AssistantSettings mSettings; + private LruCache<String, String> mNotificationKeyToResultIdCache = + new LruCache<>(MAX_RESULT_ID_TO_CACHE); + + SmartActionsHelper(Context context, AssistantSettings settings) { + mContext = context; + TextClassificationManager textClassificationManager = + mContext.getSystemService(TextClassificationManager.class); + if (textClassificationManager != null) { + mTextClassifier = textClassificationManager.getTextClassifier(); + } + mSettings = settings; } /** * Adds action adjustments based on the notification contents. */ @NonNull - ArrayList<Notification.Action> suggestActions(@Nullable Context context, - @NonNull NotificationEntry entry, @NonNull AssistantSettings settings) { - if (!settings.mGenerateActions) { + ArrayList<Notification.Action> suggestActions(@NonNull NotificationEntry entry) { + if (!mSettings.mGenerateActions) { return EMPTY_ACTION_LIST; } if (!isEligibleForActionAdjustment(entry)) { return EMPTY_ACTION_LIST; } - if (context == null) { - return EMPTY_ACTION_LIST; - } - TextClassificationManager tcm = context.getSystemService(TextClassificationManager.class); - if (tcm == null) { + if (mTextClassifier == null) { return EMPTY_ACTION_LIST; } List<ConversationActions.Message> messages = extractMessages(entry.getNotification()); @@ -96,22 +112,17 @@ public class SmartActionsHelper { } // TODO: Move to TextClassifier.suggestConversationActions once it is ready. return suggestActionsFromText( - tcm, messages.get(messages.size() - 1).getText(), MAX_SMART_ACTIONS); + messages.get(messages.size() - 1).getText(), MAX_SMART_ACTIONS); } - ArrayList<CharSequence> suggestReplies(@Nullable Context context, - @NonNull NotificationEntry entry, @NonNull AssistantSettings settings) { - if (!settings.mGenerateReplies) { + ArrayList<CharSequence> suggestReplies(@NonNull NotificationEntry entry) { + if (!mSettings.mGenerateReplies) { return EMPTY_REPLY_LIST; } if (!isEligibleForReplyAdjustment(entry)) { return EMPTY_REPLY_LIST; } - if (context == null) { - return EMPTY_REPLY_LIST; - } - TextClassificationManager tcm = context.getSystemService(TextClassificationManager.class); - if (tcm == null) { + if (mTextClassifier == null) { return EMPTY_REPLY_LIST; } List<ConversationActions.Message> messages = extractMessages(entry.getNotification()); @@ -125,14 +136,122 @@ public class SmartActionsHelper { .setTypeConfig(TYPE_CONFIG) .build(); - TextClassifier textClassifier = tcm.getTextClassifier(); + ConversationActions conversationActionsResult = + mTextClassifier.suggestConversationActions(request); List<ConversationActions.ConversationAction> conversationActions = - textClassifier.suggestConversationActions(request).getConversationActions(); - - return conversationActions.stream() + conversationActionsResult.getConversationActions(); + ArrayList<CharSequence> replies = conversationActions.stream() .map(conversationAction -> conversationAction.getTextReply()) .filter(textReply -> !TextUtils.isEmpty(textReply)) .collect(Collectors.toCollection(ArrayList::new)); + + String resultId = conversationActionsResult.getId(); + if (resultId != null && !replies.isEmpty()) { + mNotificationKeyToResultIdCache.put(entry.getSbn().getKey(), resultId); + } + return replies; + } + + void onNotificationSeen(@NonNull NotificationEntry entry) { + if (entry.isExpanded()) { + maybeSendActionShownEvent(entry); + } + } + + void onNotificationExpansionChanged(@NonNull NotificationEntry entry, boolean isUserAction, + boolean isExpanded) { + // Notification can be expanded in the background, and thus the isUserAction check. + if (isUserAction && isExpanded) { + maybeSendActionShownEvent(entry); + } + } + + void onNotificationDirectReply(@NonNull String key) { + if (mTextClassifier == null) { + return; + } + String resultId = mNotificationKeyToResultIdCache.get(key); + if (resultId == null) { + return; + } + TextClassifierEvent textClassifierEvent = + createTextClassifierEventBuilder(TextClassifierEvent.TYPE_MANUAL_REPLY, resultId) + .build(); + mTextClassifier.onTextClassifierEvent(textClassifierEvent); + } + + void onSuggestedReplySent(@NonNull String key, @NonNull CharSequence reply, + @NotificationAssistantService.Source int source) { + if (mTextClassifier == null) { + return; + } + if (source != NotificationAssistantService.SOURCE_FROM_ASSISTANT) { + return; + } + String resultId = mNotificationKeyToResultIdCache.get(key); + if (resultId == null) { + return; + } + TextClassifierEvent textClassifierEvent = + createTextClassifierEventBuilder(TextClassifierEvent.TYPE_SMART_ACTION, resultId) + .setEntityType(ConversationActions.TYPE_TEXT_REPLY) + .build(); + mTextClassifier.onTextClassifierEvent(textClassifierEvent); + } + + void onActionClicked(@NonNull String key, @NonNull Notification.Action action, + @NotificationAssistantService.Source int source) { + if (mTextClassifier == null) { + return; + } + if (source != NotificationAssistantService.SOURCE_FROM_ASSISTANT) { + return; + } + String resultId = mNotificationKeyToResultIdCache.get(key); + if (resultId == null) { + return; + } + String actionType = action.getExtras().getString(KEY_ACTION_TYPE); + if (actionType == null) { + return; + } + TextClassifierEvent textClassifierEvent = + createTextClassifierEventBuilder(TextClassifierEvent.TYPE_SMART_ACTION, resultId) + .setEntityType(actionType) + .build(); + mTextClassifier.onTextClassifierEvent(textClassifierEvent); + } + + private TextClassifierEvent.Builder createTextClassifierEventBuilder( + int eventType, @NonNull String resultId) { + return new TextClassifierEvent.Builder( + TextClassifierEvent.CATEGORY_CONVERSATION_ACTIONS, eventType) + .setEventTime(System.currentTimeMillis()) + .setEventContext( + new TextClassificationContext.Builder( + mContext.getPackageName(), TextClassifier.WIDGET_TYPE_NOTIFICATION) + .build()) + .setResultId(resultId); + } + + private void maybeSendActionShownEvent(@NonNull NotificationEntry entry) { + if (mTextClassifier == null) { + return; + } + String resultId = mNotificationKeyToResultIdCache.get(entry.getSbn().getKey()); + if (resultId == null) { + return; + } + // Only report if this is the first time the user sees these suggestions. + if (entry.isShowActionEventLogged()) { + return; + } + entry.setShowActionEventLogged(); + TextClassifierEvent textClassifierEvent = + createTextClassifierEventBuilder(TextClassifierEvent.TYPE_ACTIONS_SHOWN, resultId) + .build(); + // TODO: If possible, report which replies / actions are actually seen by user. + mTextClassifier.onTextClassifierEvent(textClassifierEvent); } /** @@ -220,13 +339,10 @@ public class SmartActionsHelper { /** Returns a list of actions to act on entities in a given piece of text. */ @NonNull private ArrayList<Notification.Action> suggestActionsFromText( - @NonNull TextClassificationManager tcm, @Nullable CharSequence text, - int maxSmartActions) { + @Nullable CharSequence text, int maxSmartActions) { if (TextUtils.isEmpty(text)) { return EMPTY_ACTION_LIST; } - TextClassifier textClassifier = tcm.getTextClassifier(); - // We want to process only text visible to the user to avoid confusing suggestions, so we // truncate the text to a reasonable length. This is particularly important for e.g. // email apps that sometimes include the text for the entire thread. @@ -239,7 +355,7 @@ public class SmartActionsHelper { Collections.singletonList( TextClassifier.HINT_TEXT_IS_NOT_EDITABLE))) .build(); - TextLinks links = textClassifier.generateLinks(textLinksRequest); + TextLinks links = mTextClassifier.generateLinks(textLinksRequest); ArrayMap<String, Integer> entityTypeFrequency = getEntityTypeFrequency(links); ArrayList<Notification.Action> actions = new ArrayList<>(); @@ -254,19 +370,26 @@ public class SmartActionsHelper { // Generate the actions, and add the most prominent ones to the action bar. TextClassification classification = - textClassifier.classifyText( + mTextClassifier.classifyText( new TextClassification.Request.Builder( text, link.getStart(), link.getEnd()).build()); + if (classification.getEntityCount() == 0) { + continue; + } int numOfActions = Math.min( MAX_ACTIONS_PER_LINK, classification.getActions().size()); for (int i = 0; i < numOfActions; ++i) { - RemoteAction action = classification.getActions().get(i); - actions.add( - new Notification.Action.Builder( - action.getIcon(), - action.getTitle(), - action.getActionIntent()) - .build()); + RemoteAction remoteAction = classification.getActions().get(i); + Notification.Action action = new Notification.Action.Builder( + remoteAction.getIcon(), + remoteAction.getTitle(), + remoteAction.getActionIntent()) + .setSemanticAction( + Notification.Action.SEMANTIC_ACTION_CONTEXTUAL_SUGGESTION) + .addExtras(Bundle.forPair(KEY_ACTION_TYPE, classification.getEntity(0))) + .build(); + actions.add(action); + // We have enough smart actions. if (actions.size() >= maxSmartActions) { return actions; diff --git a/packages/ExtServices/tests/src/android/ext/services/notification/SmartActionHelperTest.java b/packages/ExtServices/tests/src/android/ext/services/notification/SmartActionHelperTest.java index 0352ebcec8b3..da382a003621 100644 --- a/packages/ExtServices/tests/src/android/ext/services/notification/SmartActionHelperTest.java +++ b/packages/ExtServices/tests/src/android/ext/services/notification/SmartActionHelperTest.java @@ -28,12 +28,14 @@ import android.app.Notification; import android.app.Person; import android.content.Context; import android.os.Process; +import android.service.notification.NotificationAssistantService; import android.service.notification.StatusBarNotification; import android.support.test.InstrumentationRegistry; import android.support.test.runner.AndroidJUnit4; import android.view.textclassifier.ConversationActions; import android.view.textclassifier.TextClassificationManager; import android.view.textclassifier.TextClassifier; +import android.view.textclassifier.TextClassifierEvent; import com.google.common.truth.FailureStrategy; import com.google.common.truth.Subject; @@ -44,12 +46,14 @@ import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.ArgumentCaptor; import org.mockito.Mock; +import org.mockito.Mockito; import org.mockito.MockitoAnnotations; import java.time.Instant; import java.time.ZoneOffset; import java.time.ZonedDateTime; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.Objects; @@ -58,8 +62,14 @@ import javax.annotation.Nullable; @RunWith(AndroidJUnit4.class) public class SmartActionHelperTest { + private static final String NOTIFICATION_KEY = "key"; + private static final String RESULT_ID = "id"; - private SmartActionsHelper mSmartActionsHelper = new SmartActionsHelper(); + private static final ConversationActions.ConversationAction REPLY_ACTION = + new ConversationActions.ConversationAction.Builder( + ConversationActions.TYPE_TEXT_REPLY).setTextReply("Home").build(); + + private SmartActionsHelper mSmartActionsHelper; private Context mContext; @Mock private TextClassifier mTextClassifier; @Mock private NotificationEntry mNotificationEntry; @@ -75,7 +85,7 @@ public class SmartActionHelperTest { mContext.getSystemService(TextClassificationManager.class) .setTextClassifier(mTextClassifier); when(mTextClassifier.suggestConversationActions(any(ConversationActions.Request.class))) - .thenReturn(new ConversationActions(Collections.emptyList(), null)); + .thenReturn(new ConversationActions(Arrays.asList(REPLY_ACTION), RESULT_ID)); when(mNotificationEntry.getSbn()).thenReturn(mStatusBarNotification); // The notification is eligible to have smart suggestions. @@ -83,18 +93,20 @@ public class SmartActionHelperTest { when(mNotificationEntry.isMessaging()).thenReturn(true); when(mStatusBarNotification.getPackageName()).thenReturn("random.app"); when(mStatusBarNotification.getUser()).thenReturn(Process.myUserHandle()); + when(mStatusBarNotification.getKey()).thenReturn(NOTIFICATION_KEY); mNotificationBuilder = new Notification.Builder(mContext, "channel"); mSettings = AssistantSettings.createForTesting( null, null, Process.myUserHandle().getIdentifier(), null); mSettings.mGenerateActions = true; mSettings.mGenerateReplies = true; + mSmartActionsHelper = new SmartActionsHelper(mContext, mSettings); } @Test public void testSuggestReplies_notMessagingApp() { when(mNotificationEntry.isMessaging()).thenReturn(false); ArrayList<CharSequence> textReplies = - mSmartActionsHelper.suggestReplies(mContext, mNotificationEntry, mSettings); + mSmartActionsHelper.suggestReplies(mNotificationEntry); assertThat(textReplies).isEmpty(); } @@ -102,7 +114,7 @@ public class SmartActionHelperTest { public void testSuggestReplies_noInlineReply() { when(mNotificationEntry.hasInlineReply()).thenReturn(false); ArrayList<CharSequence> textReplies = - mSmartActionsHelper.suggestReplies(mContext, mNotificationEntry, mSettings); + mSmartActionsHelper.suggestReplies(mNotificationEntry); assertThat(textReplies).isEmpty(); } @@ -169,18 +181,128 @@ public class SmartActionHelperTest { .build(); when(mNotificationEntry.getNotification()).thenReturn(notification); - mSmartActionsHelper.suggestReplies(mContext, mNotificationEntry, mSettings); + mSmartActionsHelper.suggestReplies(mNotificationEntry); verify(mTextClassifier, never()) .suggestConversationActions(any(ConversationActions.Request.class)); } + @Test + public void testOnSuggestedReplySent() { + final String message = "Where are you?"; + Notification notification = mNotificationBuilder.setContentText(message).build(); + when(mNotificationEntry.getNotification()).thenReturn(notification); + + mSmartActionsHelper.suggestReplies(mNotificationEntry); + mSmartActionsHelper.onSuggestedReplySent( + NOTIFICATION_KEY, message, NotificationAssistantService.SOURCE_FROM_ASSISTANT); + + ArgumentCaptor<TextClassifierEvent> argumentCaptor = + ArgumentCaptor.forClass(TextClassifierEvent.class); + verify(mTextClassifier).onTextClassifierEvent(argumentCaptor.capture()); + TextClassifierEvent textClassifierEvent = argumentCaptor.getValue(); + assertTextClassifierEvent(textClassifierEvent, TextClassifierEvent.TYPE_SMART_ACTION); + } + + @Test + public void testOnSuggestedReplySent_anotherNotification() { + final String message = "Where are you?"; + Notification notification = mNotificationBuilder.setContentText(message).build(); + when(mNotificationEntry.getNotification()).thenReturn(notification); + + mSmartActionsHelper.suggestReplies(mNotificationEntry); + mSmartActionsHelper.onSuggestedReplySent( + "something_else", message, NotificationAssistantService.SOURCE_FROM_ASSISTANT); + + verify(mTextClassifier, never()) + .onTextClassifierEvent(Mockito.any(TextClassifierEvent.class)); + } + + @Test + public void testOnSuggestedReplySent_missingResultId() { + when(mTextClassifier.suggestConversationActions(any(ConversationActions.Request.class))) + .thenReturn(new ConversationActions(Collections.emptyList(), null)); + + final String message = "Where are you?"; + Notification notification = mNotificationBuilder.setContentText(message).build(); + when(mNotificationEntry.getNotification()).thenReturn(notification); + + mSmartActionsHelper.suggestReplies(mNotificationEntry); + mSmartActionsHelper.onSuggestedReplySent( + "something_else", message, NotificationAssistantService.SOURCE_FROM_ASSISTANT); + + verify(mTextClassifier, never()) + .onTextClassifierEvent(Mockito.any(TextClassifierEvent.class)); + } + + @Test + public void testOnNotificationDirectReply() { + Notification notification = mNotificationBuilder.setContentText("Where are you?").build(); + when(mNotificationEntry.getNotification()).thenReturn(notification); + + mSmartActionsHelper.suggestReplies(mNotificationEntry); + mSmartActionsHelper.onNotificationDirectReply(NOTIFICATION_KEY); + + ArgumentCaptor<TextClassifierEvent> argumentCaptor = + ArgumentCaptor.forClass(TextClassifierEvent.class); + verify(mTextClassifier).onTextClassifierEvent(argumentCaptor.capture()); + TextClassifierEvent textClassifierEvent = argumentCaptor.getValue(); + assertTextClassifierEvent(textClassifierEvent, TextClassifierEvent.TYPE_MANUAL_REPLY); + } + + @Test + public void testOnNotificationExpansionChanged() { + final String message = "Where are you?"; + Notification notification = mNotificationBuilder.setContentText(message).build(); + when(mNotificationEntry.getNotification()).thenReturn(notification); + + mSmartActionsHelper.suggestReplies(mNotificationEntry); + mSmartActionsHelper.onNotificationExpansionChanged(mNotificationEntry, true, true); + + ArgumentCaptor<TextClassifierEvent> argumentCaptor = + ArgumentCaptor.forClass(TextClassifierEvent.class); + verify(mTextClassifier).onTextClassifierEvent(argumentCaptor.capture()); + TextClassifierEvent textClassifierEvent = argumentCaptor.getValue(); + assertTextClassifierEvent(textClassifierEvent, TextClassifierEvent.TYPE_ACTIONS_SHOWN); + } + + @Test + public void testOnNotificationsSeen_notExpanded() { + final String message = "Where are you?"; + Notification notification = mNotificationBuilder.setContentText(message).build(); + when(mNotificationEntry.getNotification()).thenReturn(notification); + when(mNotificationEntry.isExpanded()).thenReturn(false); + + mSmartActionsHelper.suggestReplies(mNotificationEntry); + mSmartActionsHelper.onNotificationSeen(mNotificationEntry); + + verify(mTextClassifier, never()).onTextClassifierEvent( + Mockito.any(TextClassifierEvent.class)); + } + + @Test + public void testOnNotificationsSeen_expanded() { + final String message = "Where are you?"; + Notification notification = mNotificationBuilder.setContentText(message).build(); + when(mNotificationEntry.getNotification()).thenReturn(notification); + when(mNotificationEntry.isExpanded()).thenReturn(true); + + mSmartActionsHelper.suggestReplies(mNotificationEntry); + mSmartActionsHelper.onNotificationSeen(mNotificationEntry); + + ArgumentCaptor<TextClassifierEvent> argumentCaptor = + ArgumentCaptor.forClass(TextClassifierEvent.class); + verify(mTextClassifier).onTextClassifierEvent(argumentCaptor.capture()); + TextClassifierEvent textClassifierEvent = argumentCaptor.getValue(); + assertTextClassifierEvent(textClassifierEvent, TextClassifierEvent.TYPE_ACTIONS_SHOWN); + } + private ZonedDateTime createZonedDateTimeFromMsUtc(long msUtc) { return ZonedDateTime.ofInstant(Instant.ofEpochMilli(msUtc), ZoneOffset.systemDefault()); } private List<ConversationActions.Message> getMessagesInRequest() { - mSmartActionsHelper.suggestReplies(mContext, mNotificationEntry, mSettings); + mSmartActionsHelper.suggestReplies(mNotificationEntry); ArgumentCaptor<ConversationActions.Request> argumentCaptor = ArgumentCaptor.forClass(ConversationActions.Request.class); @@ -189,6 +311,17 @@ public class SmartActionHelperTest { return request.getConversation(); } + private void assertTextClassifierEvent( + TextClassifierEvent textClassifierEvent, int expectedEventType) { + assertThat(textClassifierEvent.getEventCategory()) + .isEqualTo(TextClassifierEvent.CATEGORY_CONVERSATION_ACTIONS); + assertThat(textClassifierEvent.getEventContext().getPackageName()) + .isEqualTo(InstrumentationRegistry.getTargetContext().getPackageName()); + assertThat(textClassifierEvent.getEventContext().getWidgetType()) + .isEqualTo(TextClassifier.WIDGET_TYPE_NOTIFICATION); + assertThat(textClassifierEvent.getEventType()).isEqualTo(expectedEventType); + } + private static final class MessageSubject extends Subject<MessageSubject, ConversationActions.Message> { |