blob: 38df9b0a6fdcc2703b9e1100c1edb692e37bb769 [file] [log] [blame]
/**
* 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 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;
import android.os.Parcelable;
import android.os.Process;
import android.text.TextUtils;
import android.util.ArrayMap;
import android.view.textclassifier.ConversationActions;
import android.view.textclassifier.TextClassification;
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;
public class SmartActionsHelper {
private static final ArrayList<Notification.Action> EMPTY_ACTION_LIST = new ArrayList<>();
private static final ArrayList<CharSequence> EMPTY_REPLY_LIST = new ArrayList<>();
// 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
| Notification.FLAG_FOREGROUND_SERVICE
| Notification.FLAG_GROUP_SUMMARY
| Notification.FLAG_NO_CLEAR;
private static final int MAX_ACTION_EXTRACTION_TEXT_LENGTH = 400;
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(
Collections.singletonList(ConversationActions.TYPE_TEXT_REPLY))
.includeTypesFromTextClassifier(false)
.build();
private static final List<String> HINTS =
Collections.singletonList(ConversationActions.HINT_FOR_NOTIFICATION);
SmartActionsHelper() {
}
/**
* 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) {
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) {
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, messages.get(messages.size() - 1).getText(), MAX_SMART_ACTIONS);
}
ArrayList<CharSequence> suggestReplies(@Nullable Context context,
@NonNull NotificationEntry entry, @NonNull AssistantSettings settings) {
if (!settings.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) {
return EMPTY_REPLY_LIST;
}
List<ConversationActions.Message> messages = extractMessages(entry.getNotification());
if (messages.isEmpty()) {
return EMPTY_REPLY_LIST;
}
ConversationActions.Request request =
new ConversationActions.Request.Builder(messages)
.setMaxSuggestions(MAX_SUGGESTED_REPLIES)
.setHints(HINTS)
.setTypeConfig(TYPE_CONFIG)
.build();
TextClassifier textClassifier = tcm.getTextClassifier();
List<ConversationActions.ConversationAction> conversationActions =
textClassifier.suggestConversationActions(request).getConversationActions();
return conversationActions.stream()
.map(conversationAction -> conversationAction.getTextReply())
.filter(textReply -> !TextUtils.isEmpty(textReply))
.collect(Collectors.toCollection(ArrayList::new));
}
/**
* Returns whether a notification is eligible for action adjustments.
*
* <p>We exclude system notifications, those that get refreshed frequently, or ones that relate
* to fundamental phone functionality where any error would result in a very negative user
* experience.
*/
private boolean isEligibleForActionAdjustment(@NonNull NotificationEntry entry) {
Notification notification = entry.getNotification();
String pkg = entry.getSbn().getPackageName();
if (!Process.myUserHandle().equals(entry.getSbn().getUser())) {
return false;
}
if ((notification.flags & FLAG_MASK_INELGIBILE_FOR_ACTIONS) != 0) {
return false;
}
if (TextUtils.isEmpty(pkg) || pkg.equals("android")) {
return false;
}
// For now, we are only interested in messages.
return entry.isMessaging();
}
private boolean isEligibleForReplyAdjustment(@NonNull NotificationEntry entry) {
if (!Process.myUserHandle().equals(entry.getSbn().getUser())) {
return false;
}
String pkg = entry.getSbn().getPackageName();
if (TextUtils.isEmpty(pkg) || pkg.equals("android")) {
return false;
}
// For now, we are only interested in messages.
if (!entry.isMessaging()) {
return false;
}
// Does not make sense to provide suggested replies if it is not something that can be
// replied.
if (!entry.hasInlineReply()) {
return false;
}
return true;
}
/** Returns the text most salient for action extraction in a notification. */
@Nullable
private List<ConversationActions.Message> extractMessages(@NonNull Notification notification) {
Parcelable[] messages = notification.extras.getParcelableArray(Notification.EXTRA_MESSAGES);
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;
}
}
return new ArrayList<>(extractMessages);
}
/** 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) {
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.
text = text.subSequence(0, Math.min(text.length(), MAX_ACTION_EXTRACTION_TEXT_LENGTH));
// Extract all entities.
TextLinks.Request textLinksRequest = new TextLinks.Request.Builder(text)
.setEntityConfig(
TextClassifier.EntityConfig.createWithHints(
Collections.singletonList(
TextClassifier.HINT_TEXT_IS_NOT_EDITABLE)))
.build();
TextLinks links = textClassifier.generateLinks(textLinksRequest);
ArrayMap<String, Integer> entityTypeFrequency = getEntityTypeFrequency(links);
ArrayList<Notification.Action> actions = new ArrayList<>();
for (TextLinks.TextLink link : links.getLinks()) {
// Ignore any entity type for which we have too many entities. This is to handle the
// case where a notification contains e.g. a list of phone numbers. In such cases, the
// user likely wants to act on the whole list rather than an individual entity.
if (link.getEntityCount() == 0
|| entityTypeFrequency.get(link.getEntity(0)) != 1) {
continue;
}
// Generate the actions, and add the most prominent ones to the action bar.
TextClassification classification =
textClassifier.classifyText(
new TextClassification.Request.Builder(
text, link.getStart(), link.getEnd()).build());
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());
// We have enough smart actions.
if (actions.size() >= maxSmartActions) {
return actions;
}
}
}
return actions;
}
/**
* Given the links extracted from a piece of text, returns the frequency of each entity
* type.
*/
@NonNull
private ArrayMap<String, Integer> getEntityTypeFrequency(@NonNull TextLinks links) {
ArrayMap<String, Integer> entityTypeCount = new ArrayMap<>();
for (TextLinks.TextLink link : links.getLinks()) {
if (link.getEntityCount() == 0) {
continue;
}
String entityType = link.getEntity(0);
if (entityTypeCount.containsKey(entityType)) {
entityTypeCount.put(entityType, entityTypeCount.get(entityType) + 1);
} else {
entityTypeCount.put(entityType, 1);
}
}
return entityTypeCount;
}
}