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
diff --git a/api/current.txt b/api/current.txt
index 6ecb914..a769e7f 100644
--- a/api/current.txt
+++ b/api/current.txt
@@ -52563,19 +52563,19 @@
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 797b861..b41096c 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.Deque;
import java.util.List;
import java.util.Map;
+import java.util.function.Function;
import java.util.stream.Collectors;
/**
@@ -57,9 +57,9 @@
* </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 @@
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 04b94b0..04924c9 100644
--- a/core/java/android/view/textclassifier/ConversationActions.java
+++ b/core/java/android/view/textclassifier/ConversationActions.java
@@ -349,17 +349,31 @@
/**
* 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 @@
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 @@
@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 @@
};
/** 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 @@
@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 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 8e14dfd..4e9ab50 100644
--- a/core/java/android/view/textclassifier/TextClassifierImpl.java
+++ b/core/java/android/view/textclassifier/TextClassifierImpl.java
@@ -374,7 +374,8 @@
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 @@
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 f0faaf6..4a6c093 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 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 @@
@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);
- }
-
- @Test
- public void testToNativeMessages_missingPersonInMiddleOfConversation() {
- ConversationActions.Message firstMessage =
- new ConversationActions.Message.Builder()
- .setText("first")
- .setAuthor(new Person.Builder().setName("first").build())
- .build();
- ConversationActions.Message secondMessage =
- new ConversationActions.Message.Builder()
- .setText("second")
- .build();
- ConversationActions.Message thirdMessage =
- new ConversationActions.Message.Builder()
- .setText("third")
- .setAuthor(new Person.Builder().setName("third").build())
- .build();
- ConversationActions.Message fourthMessage =
- new ConversationActions.Message.Builder()
- .setText("fourth")
- .setAuthor(new Person.Builder().setName("fourth").build())
- .build();
-
- ActionsSuggestionsModel.ConversationMessage[] conversationMessages =
- ActionsSuggestionsHelper.toNativeMessages(
- Arrays.asList(firstMessage, secondMessage, thirdMessage, fourthMessage));
-
- assertThat(conversationMessages).hasLength(2);
- assertNativeMessage(conversationMessages[0], thirdMessage.getText(), 2);
- assertNativeMessage(conversationMessages[1], fourthMessage.getText(), 1);
- }
-
- @Test
public void testToNativeMessages_userIdEncoding() {
Person userA = new Person.Builder().setName("userA").build();
Person userB = new Person.Builder().setName("userB").build();
ConversationActions.Message firstMessage =
- new ConversationActions.Message.Builder()
+ new ConversationActions.Message.Builder(userB)
.setText("first")
- .setAuthor(userB)
.build();
ConversationActions.Message secondMessage =
- new ConversationActions.Message.Builder()
+ new ConversationActions.Message.Builder(userA)
.setText("second")
- .setAuthor(userA)
.build();
ConversationActions.Message thirdMessage =
- new ConversationActions.Message.Builder()
+ new ConversationActions.Message.Builder(PERSON_USER_LOCAL)
.setText("third")
- .setAuthor(ConversationActions.Message.PERSON_USER_LOCAL)
.build();
ConversationActions.Message fourthMessage =
- new ConversationActions.Message.Builder()
+ new ConversationActions.Message.Builder(userA)
.setText("fourth")
- .setAuthor(userA)
.build();
ActionsSuggestionsModel.ConversationMessage[] conversationMessages =
ActionsSuggestionsHelper.toNativeMessages(
- Arrays.asList(firstMessage, secondMessage, thirdMessage, fourthMessage));
+ Arrays.asList(firstMessage, secondMessage, thirdMessage, fourthMessage),
+ 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);
+ 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_referenceTime() {
+ ConversationActions.Message firstMessage =
+ new ConversationActions.Message.Builder(PERSON_USER_REMOTE)
+ .setText("first")
+ .setReferenceTime(createZonedDateTimeFromMsUtc(1000))
+ .build();
+ ConversationActions.Message secondMessage =
+ new ConversationActions.Message.Builder(PERSON_USER_REMOTE)
+ .setText("second")
+ .build();
+ ConversationActions.Message thirdMessage =
+ new ConversationActions.Message.Builder(PERSON_USER_REMOTE)
+ .setText("third")
+ .setReferenceTime(createZonedDateTimeFromMsUtc(2000))
+ .build();
+
+ ActionsSuggestionsModel.ConversationMessage[] conversationMessages =
+ ActionsSuggestionsHelper.toNativeMessages(
+ Arrays.asList(firstMessage, secondMessage, thirdMessage),
+ LANGUAGE_DETECTOR);
+
+ 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 aec4571..9b5c034 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 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 6f2b6c9..38df9b0 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 @@
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.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 @@
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 @@
/**
* 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 @@
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 @@
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 @@
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 @@
/** 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 0000000..60d31fc
--- /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);
+ }
+ }
+}