diff options
| author | 2018-12-12 23:30:02 +0000 | |
|---|---|---|
| committer | 2018-12-12 23:30:02 +0000 | |
| commit | a44e66a504c828b4ae580753d865b1dc7bf82780 (patch) | |
| tree | 4ca14a18e983c1de8ac587396c745515164c9659 | |
| parent | f59189146c7830cbe2d0fd704779183a47bec313 (diff) | |
| parent | 43a899fc2ba43817d5dca87764f4ee625e2577ea (diff) | |
Merge changes from topic "reference_time_tc"
* changes:
Populate person and reference time, uses more than the last message in NAS
Pass reference time / locales of messages to the model
8 files changed, 423 insertions, 143 deletions
diff --git a/api/current.txt b/api/current.txt index 6ecb914a5723..a769e7f63806 100644 --- a/api/current.txt +++ b/api/current.txt @@ -52563,19 +52563,19 @@ package android.view.textclassifier { method public int describeContents(); method public android.app.Person getAuthor(); method public android.os.Bundle getExtras(); + method public java.time.ZonedDateTime getReferenceTime(); method public java.lang.CharSequence getText(); - method public java.time.ZonedDateTime getTime(); method public void writeToParcel(android.os.Parcel, int); field public static final android.os.Parcelable.Creator<android.view.textclassifier.ConversationActions.Message> CREATOR; field public static final android.app.Person PERSON_USER_LOCAL; + field public static final android.app.Person PERSON_USER_REMOTE; } public static final class ConversationActions.Message.Builder { - ctor public ConversationActions.Message.Builder(); + ctor public ConversationActions.Message.Builder(android.app.Person); method public android.view.textclassifier.ConversationActions.Message build(); - method public android.view.textclassifier.ConversationActions.Message.Builder setAuthor(android.app.Person); - method public android.view.textclassifier.ConversationActions.Message.Builder setComposeTime(java.time.ZonedDateTime); method public android.view.textclassifier.ConversationActions.Message.Builder setExtras(android.os.Bundle); + method public android.view.textclassifier.ConversationActions.Message.Builder setReferenceTime(java.time.ZonedDateTime); method public android.view.textclassifier.ConversationActions.Message.Builder setText(java.lang.CharSequence); } diff --git a/core/java/android/view/textclassifier/ActionsSuggestionsHelper.java b/core/java/android/view/textclassifier/ActionsSuggestionsHelper.java index 797b861e9e70..b41096c74bf7 100644 --- a/core/java/android/view/textclassifier/ActionsSuggestionsHelper.java +++ b/core/java/android/view/textclassifier/ActionsSuggestionsHelper.java @@ -16,7 +16,6 @@ package android.view.textclassifier; -import android.annotation.NonNull; import android.app.Person; import android.text.TextUtils; import android.util.ArrayMap; @@ -30,6 +29,7 @@ import java.util.ArrayList; import java.util.Deque; import java.util.List; import java.util.Map; +import java.util.function.Function; import java.util.stream.Collectors; /** @@ -57,9 +57,9 @@ public final class ActionsSuggestionsHelper { * </ul> * User A will be encoded as 2, user B will be encoded as 1 and local user will be encoded as 0. */ - @NonNull public static ActionsSuggestionsModel.ConversationMessage[] toNativeMessages( - @NonNull List<ConversationActions.Message> messages) { + List<ConversationActions.Message> messages, + Function<CharSequence, String> languageDetector) { List<ConversationActions.Message> messagesWithText = messages.stream() .filter(message -> !TextUtils.isEmpty(message.getText())) @@ -67,31 +67,18 @@ public final class ActionsSuggestionsHelper { if (messagesWithText.isEmpty()) { return new ActionsSuggestionsModel.ConversationMessage[0]; } - int size = messagesWithText.size(); - // If the last message (the most important one) does not have the Person object, we will - // just use the last message and consider this message is sent from a remote user. - ConversationActions.Message lastMessage = messages.get(size - 1); - boolean useLastMessageOnly = lastMessage.getAuthor() == null; - if (useLastMessageOnly) { - return new ActionsSuggestionsModel.ConversationMessage[]{ - new ActionsSuggestionsModel.ConversationMessage( - FIRST_NON_LOCAL_USER, - lastMessage.getText().toString(), - 0, - null)}; - } - - // Encode the messages in the reverse order, stop whenever the Person object is missing. Deque<ActionsSuggestionsModel.ConversationMessage> nativeMessages = new ArrayDeque<>(); PersonEncoder personEncoder = new PersonEncoder(); + int size = messagesWithText.size(); for (int i = size - 1; i >= 0; i--) { ConversationActions.Message message = messagesWithText.get(i); - if (message.getAuthor() == null) { - break; - } + long referenceTime = message.getReferenceTime() == null + ? 0 + : message.getReferenceTime().toInstant().toEpochMilli(); nativeMessages.push(new ActionsSuggestionsModel.ConversationMessage( personEncoder.encode(message.getAuthor()), - message.getText().toString(), 0, null)); + message.getText().toString(), referenceTime, + languageDetector.apply(message.getText()))); } return nativeMessages.toArray( new ActionsSuggestionsModel.ConversationMessage[nativeMessages.size()]); diff --git a/core/java/android/view/textclassifier/ConversationActions.java b/core/java/android/view/textclassifier/ConversationActions.java index 04b94b0e03ad..04924c9a59c4 100644 --- a/core/java/android/view/textclassifier/ConversationActions.java +++ b/core/java/android/view/textclassifier/ConversationActions.java @@ -349,17 +349,31 @@ public final class ConversationActions implements Parcelable { /** * Represents the local user. * - * @see Builder#setAuthor(Person) + * @see Builder#Builder(Person) */ public static final Person PERSON_USER_LOCAL = new Person.Builder() .setKey("text-classifier-conversation-actions-local-user") .build(); + /** + * Represents the remote user. + * <p> + * If possible, you are suggested to create a {@link Person} object that can identify + * the remote user better, so that the underlying model could differentiate between + * different remote users. + * + * @see Builder#Builder(Person) + */ + public static final Person PERSON_USER_REMOTE = + new Person.Builder() + .setKey("text-classifier-conversation-actions-remote-user") + .build(); + @Nullable private final Person mAuthor; @Nullable - private final ZonedDateTime mComposeTime; + private final ZonedDateTime mReferenceTime; @Nullable private final CharSequence mText; @NonNull @@ -367,18 +381,18 @@ public final class ConversationActions implements Parcelable { private Message( @Nullable Person author, - @Nullable ZonedDateTime composeTime, + @Nullable ZonedDateTime referenceTime, @Nullable CharSequence text, @NonNull Bundle bundle) { mAuthor = author; - mComposeTime = composeTime; + mReferenceTime = referenceTime; mText = text; mExtras = Preconditions.checkNotNull(bundle); } private Message(Parcel in) { mAuthor = in.readParcelable(null); - mComposeTime = + mReferenceTime = in.readInt() == 0 ? null : ZonedDateTime.parse( @@ -390,9 +404,9 @@ public final class ConversationActions implements Parcelable { @Override public void writeToParcel(Parcel parcel, int flags) { parcel.writeParcelable(mAuthor, flags); - parcel.writeInt(mComposeTime != null ? 1 : 0); - if (mComposeTime != null) { - parcel.writeString(mComposeTime.format(DateTimeFormatter.ISO_ZONED_DATE_TIME)); + parcel.writeInt(mReferenceTime != null ? 1 : 0); + if (mReferenceTime != null) { + parcel.writeString(mReferenceTime.format(DateTimeFormatter.ISO_ZONED_DATE_TIME)); } parcel.writeCharSequence(mText); parcel.writeBundle(mExtras); @@ -417,15 +431,18 @@ public final class ConversationActions implements Parcelable { }; /** Returns the person that composed the message. */ - @Nullable + @NonNull public Person getAuthor() { return mAuthor; } - /** Returns the compose time of the message. */ + /** + * Returns the reference time of the message, for example it could be the compose or send + * time of this message. + */ @Nullable - public ZonedDateTime getTime() { - return mComposeTime; + public ZonedDateTime getReferenceTime() { + return mReferenceTime; } /** Returns the text of the message. */ @@ -451,34 +468,38 @@ public final class ConversationActions implements Parcelable { @Nullable private Person mAuthor; @Nullable - private ZonedDateTime mComposeTime; + private ZonedDateTime mReferenceTime; @Nullable private CharSequence mText; @Nullable private Bundle mExtras; /** - * Sets the person who composed this message. - * <p> - * Use {@link #PERSON_USER_LOCAL} to represent the local user. + * Constructs a builder. + * + * @param author the person that composed the message, use {@link #PERSON_USER_LOCAL} + * to represent the local user. If it is not possible to identify the + * remote user that the local user is conversing with, use + * {@link #PERSON_USER_REMOTE} to represent a remote user. */ - @NonNull - public Builder setAuthor(@Nullable Person author) { - mAuthor = author; - return this; + public Builder(@NonNull Person author) { + mAuthor = Preconditions.checkNotNull(author); } - /** Sets the text of this message */ + /** Sets the text of this message. */ @NonNull public Builder setText(@Nullable CharSequence text) { mText = text; return this; } - /** Sets the compose time of this message */ + /** + * Sets the reference time of this message, for example it could be the compose or send + * time of this message. + */ @NonNull - public Builder setComposeTime(@Nullable ZonedDateTime composeTime) { - mComposeTime = composeTime; + public Builder setReferenceTime(@Nullable ZonedDateTime referenceTime) { + mReferenceTime = referenceTime; return this; } @@ -494,7 +515,7 @@ public final class ConversationActions implements Parcelable { public Message build() { return new Message( mAuthor, - mComposeTime, + mReferenceTime, mText == null ? null : new SpannedString(mText), mExtras == null ? new Bundle() : mExtras.deepCopy()); } diff --git a/core/java/android/view/textclassifier/TextClassifierImpl.java b/core/java/android/view/textclassifier/TextClassifierImpl.java index 8e14dfdb7ee3..4e9ab5092777 100644 --- a/core/java/android/view/textclassifier/TextClassifierImpl.java +++ b/core/java/android/view/textclassifier/TextClassifierImpl.java @@ -374,7 +374,8 @@ public final class TextClassifierImpl implements TextClassifier { return mFallback.suggestConversationActions(request); } ActionsSuggestionsModel.ConversationMessage[] nativeMessages = - ActionsSuggestionsHelper.toNativeMessages(request.getConversation()); + ActionsSuggestionsHelper.toNativeMessages(request.getConversation(), + this::detectLanguageTagsFromText); if (nativeMessages.length == 0) { return mFallback.suggestConversationActions(request); } @@ -407,6 +408,26 @@ public final class TextClassifierImpl implements TextClassifier { return mFallback.suggestConversationActions(request); } + @Nullable + private String detectLanguageTagsFromText(CharSequence text) { + TextLanguage.Request request = new TextLanguage.Request.Builder(text).build(); + TextLanguage textLanguage = detectLanguage(request); + int localeHypothesisCount = textLanguage.getLocaleHypothesisCount(); + List<String> languageTags = new ArrayList<>(); + // TODO: Reconsider this and probably make the score threshold configurable. + for (int i = 0; i < localeHypothesisCount; i++) { + ULocale locale = textLanguage.getLocale(i); + if (textLanguage.getConfidenceScore(locale) < 0.5) { + break; + } + languageTags.add(locale.toLanguageTag()); + } + if (languageTags.isEmpty()) { + return LocaleList.getDefault().toLanguageTags(); + } + return String.join(",", languageTags); + } + private Collection<String> resolveActionTypesFromRequest(ConversationActions.Request request) { List<String> defaultActionTypes = request.getHints().contains(ConversationActions.HINT_FOR_NOTIFICATION) diff --git a/core/tests/coretests/src/android/view/textclassifier/ActionsSuggestionsHelperTest.java b/core/tests/coretests/src/android/view/textclassifier/ActionsSuggestionsHelperTest.java index f0faaf6153b1..4a6c093e3bd1 100644 --- a/core/tests/coretests/src/android/view/textclassifier/ActionsSuggestionsHelperTest.java +++ b/core/tests/coretests/src/android/view/textclassifier/ActionsSuggestionsHelperTest.java @@ -16,6 +16,9 @@ package android.view.textclassifier; +import static android.view.textclassifier.ConversationActions.Message.PERSON_USER_LOCAL; +import static android.view.textclassifier.ConversationActions.Message.PERSON_USER_REMOTE; + import static com.google.common.truth.Truth.assertThat; import android.app.Person; @@ -27,16 +30,26 @@ import com.google.android.textclassifier.ActionsSuggestionsModel; import org.junit.Test; import org.junit.runner.RunWith; +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZonedDateTime; import java.util.Arrays; import java.util.Collections; +import java.util.Locale; +import java.util.function.Function; @SmallTest @RunWith(AndroidJUnit4.class) public class ActionsSuggestionsHelperTest { + private static final String LOCALE_TAG = Locale.US.toLanguageTag(); + private static final Function<CharSequence, String> LANGUAGE_DETECTOR = + charSequence -> LOCALE_TAG; + @Test public void testToNativeMessages_emptyInput() { ActionsSuggestionsModel.ConversationMessage[] conversationMessages = - ActionsSuggestionsHelper.toNativeMessages(Collections.emptyList()); + ActionsSuggestionsHelper.toNativeMessages( + Collections.emptyList(), LANGUAGE_DETECTOR); assertThat(conversationMessages).isEmpty(); } @@ -44,114 +57,89 @@ public class ActionsSuggestionsHelperTest { @Test public void testToNativeMessages_noTextMessages() { ConversationActions.Message messageWithoutText = - new ConversationActions.Message.Builder().build(); + new ConversationActions.Message.Builder(PERSON_USER_REMOTE).build(); ActionsSuggestionsModel.ConversationMessage[] conversationMessages = ActionsSuggestionsHelper.toNativeMessages( - Collections.singletonList(messageWithoutText)); + Collections.singletonList(messageWithoutText), LANGUAGE_DETECTOR); assertThat(conversationMessages).isEmpty(); } @Test - public void testToNativeMessages_missingPersonInFirstMessage() { - ConversationActions.Message firstMessage = - new ConversationActions.Message.Builder() - .setText("first") - .build(); - ConversationActions.Message secondMessage = - new ConversationActions.Message.Builder() - .setText("second") - .setAuthor(new Person.Builder().build()) - .build(); - ConversationActions.Message thirdMessage = - new ConversationActions.Message.Builder() - .setText("third") - .setAuthor(ConversationActions.Message.PERSON_USER_LOCAL) - .build(); - - ActionsSuggestionsModel.ConversationMessage[] conversationMessages = - ActionsSuggestionsHelper.toNativeMessages( - Arrays.asList(firstMessage, secondMessage, thirdMessage)); - - assertThat(conversationMessages).hasLength(2); - assertNativeMessage(conversationMessages[0], secondMessage.getText(), 1); - assertNativeMessage(conversationMessages[1], thirdMessage.getText(), 0); - } + public void testToNativeMessages_userIdEncoding() { + Person userA = new Person.Builder().setName("userA").build(); + Person userB = new Person.Builder().setName("userB").build(); - @Test - public void testToNativeMessages_missingPersonInMiddleOfConversation() { ConversationActions.Message firstMessage = - new ConversationActions.Message.Builder() + new ConversationActions.Message.Builder(userB) .setText("first") - .setAuthor(new Person.Builder().setName("first").build()) .build(); ConversationActions.Message secondMessage = - new ConversationActions.Message.Builder() + new ConversationActions.Message.Builder(userA) .setText("second") .build(); ConversationActions.Message thirdMessage = - new ConversationActions.Message.Builder() + new ConversationActions.Message.Builder(PERSON_USER_LOCAL) .setText("third") - .setAuthor(new Person.Builder().setName("third").build()) .build(); ConversationActions.Message fourthMessage = - new ConversationActions.Message.Builder() + new ConversationActions.Message.Builder(userA) .setText("fourth") - .setAuthor(new Person.Builder().setName("fourth").build()) .build(); ActionsSuggestionsModel.ConversationMessage[] conversationMessages = ActionsSuggestionsHelper.toNativeMessages( - Arrays.asList(firstMessage, secondMessage, thirdMessage, fourthMessage)); + Arrays.asList(firstMessage, secondMessage, thirdMessage, fourthMessage), + LANGUAGE_DETECTOR); - assertThat(conversationMessages).hasLength(2); - assertNativeMessage(conversationMessages[0], thirdMessage.getText(), 2); - assertNativeMessage(conversationMessages[1], fourthMessage.getText(), 1); + assertThat(conversationMessages).hasLength(4); + assertNativeMessage(conversationMessages[0], firstMessage.getText(), 2, 0); + assertNativeMessage(conversationMessages[1], secondMessage.getText(), 1, 0); + assertNativeMessage(conversationMessages[2], thirdMessage.getText(), 0, 0); + assertNativeMessage(conversationMessages[3], fourthMessage.getText(), 1, 0); } @Test - public void testToNativeMessages_userIdEncoding() { - Person userA = new Person.Builder().setName("userA").build(); - Person userB = new Person.Builder().setName("userB").build(); - + public void testToNativeMessages_referenceTime() { ConversationActions.Message firstMessage = - new ConversationActions.Message.Builder() + new ConversationActions.Message.Builder(PERSON_USER_REMOTE) .setText("first") - .setAuthor(userB) + .setReferenceTime(createZonedDateTimeFromMsUtc(1000)) .build(); ConversationActions.Message secondMessage = - new ConversationActions.Message.Builder() + new ConversationActions.Message.Builder(PERSON_USER_REMOTE) .setText("second") - .setAuthor(userA) .build(); ConversationActions.Message thirdMessage = - new ConversationActions.Message.Builder() + new ConversationActions.Message.Builder(PERSON_USER_REMOTE) .setText("third") - .setAuthor(ConversationActions.Message.PERSON_USER_LOCAL) - .build(); - ConversationActions.Message fourthMessage = - new ConversationActions.Message.Builder() - .setText("fourth") - .setAuthor(userA) + .setReferenceTime(createZonedDateTimeFromMsUtc(2000)) .build(); ActionsSuggestionsModel.ConversationMessage[] conversationMessages = ActionsSuggestionsHelper.toNativeMessages( - Arrays.asList(firstMessage, secondMessage, thirdMessage, fourthMessage)); + Arrays.asList(firstMessage, secondMessage, thirdMessage), + LANGUAGE_DETECTOR); - assertThat(conversationMessages).hasLength(4); - assertNativeMessage(conversationMessages[0], firstMessage.getText(), 2); - assertNativeMessage(conversationMessages[1], secondMessage.getText(), 1); - assertNativeMessage(conversationMessages[2], thirdMessage.getText(), 0); - assertNativeMessage(conversationMessages[3], fourthMessage.getText(), 1); + assertThat(conversationMessages).hasLength(3); + assertNativeMessage(conversationMessages[0], firstMessage.getText(), 1, 1000); + assertNativeMessage(conversationMessages[1], secondMessage.getText(), 1, 0); + assertNativeMessage(conversationMessages[2], thirdMessage.getText(), 1, 2000); + } + + private ZonedDateTime createZonedDateTimeFromMsUtc(long msUtc) { + return ZonedDateTime.ofInstant(Instant.ofEpochMilli(msUtc), ZoneId.of("UTC")); } private static void assertNativeMessage( ActionsSuggestionsModel.ConversationMessage nativeMessage, CharSequence text, - int userId) { + int userId, + long referenceTimeInMsUtc) { assertThat(nativeMessage.getText()).isEqualTo(text.toString()); assertThat(nativeMessage.getUserId()).isEqualTo(userId); + assertThat(nativeMessage.getLocales()).isEqualTo(LOCALE_TAG); + assertThat(nativeMessage.getReferenceTimeMsUtc()).isEqualTo(referenceTimeInMsUtc); } } diff --git a/core/tests/coretests/src/android/view/textclassifier/TextClassifierTest.java b/core/tests/coretests/src/android/view/textclassifier/TextClassifierTest.java index aec4571252e7..9b5c0347bdb6 100644 --- a/core/tests/coretests/src/android/view/textclassifier/TextClassifierTest.java +++ b/core/tests/coretests/src/android/view/textclassifier/TextClassifierTest.java @@ -373,7 +373,10 @@ public class TextClassifierTest { public void testSuggestConversationActions_textReplyOnly_maxThree() { if (isTextClassifierDisabled()) return; ConversationActions.Message message = - new ConversationActions.Message.Builder().setText("Where are you?").build(); + new ConversationActions.Message.Builder( + ConversationActions.Message.PERSON_USER_REMOTE) + .setText("Hello") + .build(); ConversationActions.TypeConfig typeConfig = new ConversationActions.TypeConfig.Builder().includeTypesFromTextClassifier(false) .setIncludedTypes( diff --git a/packages/ExtServices/src/android/ext/services/notification/SmartActionsHelper.java b/packages/ExtServices/src/android/ext/services/notification/SmartActionsHelper.java index 6f2b6c9dafd4..38df9b0a6fdc 100644 --- a/packages/ExtServices/src/android/ext/services/notification/SmartActionsHelper.java +++ b/packages/ExtServices/src/android/ext/services/notification/SmartActionsHelper.java @@ -18,6 +18,7 @@ package android.ext.services.notification; import android.annotation.NonNull; import android.annotation.Nullable; import android.app.Notification; +import android.app.Person; import android.app.RemoteAction; import android.content.Context; import android.os.Bundle; @@ -31,8 +32,14 @@ import android.view.textclassifier.TextClassificationManager; import android.view.textclassifier.TextClassifier; import android.view.textclassifier.TextLinks; +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.util.ArrayDeque; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collections; +import java.util.Deque; import java.util.List; import java.util.stream.Collectors; @@ -50,6 +57,8 @@ public class SmartActionsHelper { private static final int MAX_ACTIONS_PER_LINK = 1; private static final int MAX_SMART_ACTIONS = 3; private static final int MAX_SUGGESTED_REPLIES = 3; + // TODO: Make this configurable. + private static final int MAX_MESSAGES_TO_EXTRACT = 5; private static final ConversationActions.TypeConfig TYPE_CONFIG = new ConversationActions.TypeConfig.Builder().setIncludedTypes( @@ -64,9 +73,6 @@ public class SmartActionsHelper { /** * Adds action adjustments based on the notification contents. - * - * TODO: Once we have a API in {@link TextClassificationManager} to predict smart actions - * from notification text / message, we can replace most of the code here by consuming that API. */ @NonNull ArrayList<Notification.Action> suggestActions(@Nullable Context context, @@ -84,9 +90,13 @@ public class SmartActionsHelper { if (tcm == null) { return EMPTY_ACTION_LIST; } + List<ConversationActions.Message> messages = extractMessages(entry.getNotification()); + if (messages.isEmpty()) { + return EMPTY_ACTION_LIST; + } + // TODO: Move to TextClassifier.suggestConversationActions once it is ready. return suggestActionsFromText( - tcm, - getMostSalientActionText(entry.getNotification()), MAX_SMART_ACTIONS); + tcm, messages.get(messages.size() - 1).getText(), MAX_SMART_ACTIONS); } ArrayList<CharSequence> suggestReplies(@Nullable Context context, @@ -104,14 +114,12 @@ public class SmartActionsHelper { if (tcm == null) { return EMPTY_REPLY_LIST; } - CharSequence text = getMostSalientActionText(entry.getNotification()); - ConversationActions.Message message = - new ConversationActions.Message.Builder() - .setText(text) - .build(); - + List<ConversationActions.Message> messages = extractMessages(entry.getNotification()); + if (messages.isEmpty()) { + return EMPTY_REPLY_LIST; + } ConversationActions.Request request = - new ConversationActions.Request.Builder(Collections.singletonList(message)) + new ConversationActions.Request.Builder(messages) .setMaxSuggestions(MAX_SUGGESTED_REPLIES) .setHints(HINTS) .setTypeConfig(TYPE_CONFIG) @@ -140,10 +148,6 @@ public class SmartActionsHelper { if (!Process.myUserHandle().equals(entry.getSbn().getUser())) { return false; } - if (notification.actions != null - && notification.actions.length >= Notification.MAX_ACTION_BUTTONS) { - return false; - } if ((notification.flags & FLAG_MASK_INELGIBILE_FOR_ACTIONS) != 0) { return false; } @@ -176,21 +180,41 @@ public class SmartActionsHelper { /** Returns the text most salient for action extraction in a notification. */ @Nullable - private CharSequence getMostSalientActionText(@NonNull Notification notification) { - /* If it's messaging style, use the most recent message. */ - // TODO: Use the last few X messages instead and take the Person object into consideration. + private List<ConversationActions.Message> extractMessages(@NonNull Notification notification) { Parcelable[] messages = notification.extras.getParcelableArray(Notification.EXTRA_MESSAGES); - if (messages != null && messages.length != 0) { - Bundle lastMessage = (Bundle) messages[messages.length - 1]; - CharSequence lastMessageText = - lastMessage.getCharSequence(Notification.MessagingStyle.Message.KEY_TEXT); - if (!TextUtils.isEmpty(lastMessageText)) { - return lastMessageText; + if (messages == null || messages.length == 0) { + return Arrays.asList(new ConversationActions.Message.Builder( + ConversationActions.Message.PERSON_USER_REMOTE) + .setText(notification.extras.getCharSequence(Notification.EXTRA_TEXT)) + .build()); + } + Person localUser = notification.extras.getParcelable(Notification.EXTRA_MESSAGING_PERSON); + Deque<ConversationActions.Message> extractMessages = new ArrayDeque<>(); + for (int i = messages.length - 1; i >= 0; i--) { + Notification.MessagingStyle.Message message = + Notification.MessagingStyle.Message.getMessageFromBundle((Bundle) messages[i]); + if (message == null) { + continue; + } + Person senderPerson = message.getSenderPerson(); + // Skip encoding once the sender is missing as it is important to distinguish + // local user and remote user when generating replies. + if (senderPerson == null) { + break; + } + Person author = localUser != null && localUser.equals(senderPerson) + ? ConversationActions.Message.PERSON_USER_LOCAL : senderPerson; + extractMessages.push(new ConversationActions.Message.Builder(author) + .setText(message.getText()) + .setReferenceTime( + ZonedDateTime.ofInstant(Instant.ofEpochMilli(message.getTimestamp()), + ZoneOffset.systemDefault())) + .build()); + if (extractMessages.size() >= MAX_MESSAGES_TO_EXTRACT) { + break; } } - - // Fall back to using the normal text. - return notification.extras.getCharSequence(Notification.EXTRA_TEXT); + return new ArrayList<>(extractMessages); } /** Returns a list of actions to act on entities in a given piece of text. */ diff --git a/packages/ExtServices/tests/src/android/ext/services/notification/SmartActionHelperTest.java b/packages/ExtServices/tests/src/android/ext/services/notification/SmartActionHelperTest.java new file mode 100644 index 000000000000..60d31fca8ddb --- /dev/null +++ b/packages/ExtServices/tests/src/android/ext/services/notification/SmartActionHelperTest.java @@ -0,0 +1,236 @@ +/** + * Copyright (C) 2018 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.ext.services.notification; + +import static com.google.common.truth.Truth.assertAbout; +import static com.google.common.truth.Truth.assertThat; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.annotation.NonNull; +import android.app.Notification; +import android.app.Person; +import android.content.Context; +import android.os.Process; +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 com.google.common.truth.FailureStrategy; +import com.google.common.truth.Subject; +import com.google.common.truth.SubjectFactory; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +import javax.annotation.Nullable; + +@RunWith(AndroidJUnit4.class) +public class SmartActionHelperTest { + + private SmartActionsHelper mSmartActionsHelper = new SmartActionsHelper(); + private Context mContext; + @Mock private TextClassifier mTextClassifier; + @Mock private NotificationEntry mNotificationEntry; + @Mock private StatusBarNotification mStatusBarNotification; + private Notification.Builder mNotificationBuilder; + private AssistantSettings mSettings; + + @Before + public void setup() { + MockitoAnnotations.initMocks(this); + mContext = InstrumentationRegistry.getTargetContext(); + + mContext.getSystemService(TextClassificationManager.class) + .setTextClassifier(mTextClassifier); + when(mTextClassifier.suggestConversationActions(any(ConversationActions.Request.class))) + .thenReturn(new ConversationActions(Collections.emptyList())); + + when(mNotificationEntry.getSbn()).thenReturn(mStatusBarNotification); + // The notification is eligible to have smart suggestions. + when(mNotificationEntry.hasInlineReply()).thenReturn(true); + when(mNotificationEntry.isMessaging()).thenReturn(true); + when(mStatusBarNotification.getPackageName()).thenReturn("random.app"); + when(mStatusBarNotification.getUser()).thenReturn(Process.myUserHandle()); + mNotificationBuilder = new Notification.Builder(mContext, "channel"); + mSettings = AssistantSettings.createForTesting( + null, null, Process.myUserHandle().getIdentifier(), null); + mSettings.mGenerateActions = true; + mSettings.mGenerateReplies = true; + } + + @Test + public void testSuggestReplies_notMessagingApp() { + when(mNotificationEntry.isMessaging()).thenReturn(false); + ArrayList<CharSequence> textReplies = + mSmartActionsHelper.suggestReplies(mContext, mNotificationEntry, mSettings); + assertThat(textReplies).isEmpty(); + } + + @Test + public void testSuggestReplies_noInlineReply() { + when(mNotificationEntry.hasInlineReply()).thenReturn(false); + ArrayList<CharSequence> textReplies = + mSmartActionsHelper.suggestReplies(mContext, mNotificationEntry, mSettings); + assertThat(textReplies).isEmpty(); + } + + @Test + public void testSuggestReplies_nonMessageStyle() { + Notification notification = mNotificationBuilder.setContentText("Where are you?").build(); + when(mNotificationEntry.getNotification()).thenReturn(notification); + + List<ConversationActions.Message> messages = getMessagesInRequest(); + assertThat(messages).hasSize(1); + MessageSubject.assertThat(messages.get(0)).hasText("Where are you?"); + } + + @Test + public void testSuggestReplies_messageStyle() { + Person me = new Person.Builder().setName("Me").build(); + Person userA = new Person.Builder().setName("A").build(); + Person userB = new Person.Builder().setName("B").build(); + Notification.MessagingStyle style = + new Notification.MessagingStyle(me) + .addMessage("firstMessage", 1000, (Person) null) + .addMessage("secondMessage", 2000, me) + .addMessage("thirdMessage", 3000, userA) + .addMessage("fourthMessage", 4000, userB); + Notification notification = + mNotificationBuilder + .setContentText("You have three new messages") + .setStyle(style) + .build(); + when(mNotificationEntry.getNotification()).thenReturn(notification); + + List<ConversationActions.Message> messages = getMessagesInRequest(); + assertThat(messages).hasSize(3); + + ConversationActions.Message secondMessage = messages.get(0); + MessageSubject.assertThat(secondMessage).hasText("secondMessage"); + MessageSubject.assertThat(secondMessage) + .hasPerson(ConversationActions.Message.PERSON_USER_LOCAL); + MessageSubject.assertThat(secondMessage) + .hasReferenceTime(createZonedDateTimeFromMsUtc(2000)); + + ConversationActions.Message thirdMessage = messages.get(1); + MessageSubject.assertThat(thirdMessage).hasText("thirdMessage"); + MessageSubject.assertThat(thirdMessage).hasPerson(userA); + MessageSubject.assertThat(thirdMessage) + .hasReferenceTime(createZonedDateTimeFromMsUtc(3000)); + + ConversationActions.Message fourthMessage = messages.get(2); + MessageSubject.assertThat(fourthMessage).hasText("fourthMessage"); + MessageSubject.assertThat(fourthMessage).hasPerson(userB); + MessageSubject.assertThat(fourthMessage) + .hasReferenceTime(createZonedDateTimeFromMsUtc(4000)); + } + + @Test + public void testSuggestReplies_messageStyle_noPerson() { + Person me = new Person.Builder().setName("Me").build(); + Notification.MessagingStyle style = + new Notification.MessagingStyle(me).addMessage("message", 1000, (Person) null); + Notification notification = + mNotificationBuilder + .setContentText("You have one new message") + .setStyle(style) + .build(); + when(mNotificationEntry.getNotification()).thenReturn(notification); + + mSmartActionsHelper.suggestReplies(mContext, mNotificationEntry, mSettings); + + verify(mTextClassifier, never()) + .suggestConversationActions(any(ConversationActions.Request.class)); + } + + private ZonedDateTime createZonedDateTimeFromMsUtc(long msUtc) { + return ZonedDateTime.ofInstant(Instant.ofEpochMilli(msUtc), ZoneOffset.systemDefault()); + } + + private List<ConversationActions.Message> getMessagesInRequest() { + mSmartActionsHelper.suggestReplies(mContext, mNotificationEntry, mSettings); + + ArgumentCaptor<ConversationActions.Request> argumentCaptor = + ArgumentCaptor.forClass(ConversationActions.Request.class); + verify(mTextClassifier).suggestConversationActions(argumentCaptor.capture()); + ConversationActions.Request request = argumentCaptor.getValue(); + return request.getConversation(); + } + + private static final class MessageSubject + extends Subject<MessageSubject, ConversationActions.Message> { + + private static final SubjectFactory<MessageSubject, ConversationActions.Message> FACTORY = + new SubjectFactory<MessageSubject, ConversationActions.Message>() { + @Override + public MessageSubject getSubject( + @NonNull FailureStrategy failureStrategy, + @NonNull ConversationActions.Message subject) { + return new MessageSubject(failureStrategy, subject); + } + }; + + private MessageSubject( + FailureStrategy failureStrategy, @Nullable ConversationActions.Message subject) { + super(failureStrategy, subject); + } + + private void hasText(String text) { + if (!Objects.equals(text, getSubject().getText().toString())) { + failWithBadResults("has text", text, "has", getSubject().getText()); + } + } + + private void hasPerson(Person person) { + if (!Objects.equals(person, getSubject().getAuthor())) { + failWithBadResults("has author", person, "has", getSubject().getAuthor()); + } + } + + private void hasReferenceTime(ZonedDateTime referenceTime) { + if (!Objects.equals(referenceTime, getSubject().getReferenceTime())) { + failWithBadResults( + "has reference time", + referenceTime, + "has", + getSubject().getReferenceTime()); + } + } + + private static MessageSubject assertThat(ConversationActions.Message message) { + return assertAbout(FACTORY).that(message); + } + } +} |