| /** |
| * 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.RemoteAction; |
| import android.app.RemoteInput; |
| import android.content.Context; |
| import android.os.Bundle; |
| import android.os.Parcelable; |
| import android.os.Process; |
| import android.os.SystemProperties; |
| import android.service.notification.StatusBarNotification; |
| import android.text.TextUtils; |
| import android.util.ArrayMap; |
| import android.view.textclassifier.TextClassification; |
| import android.view.textclassifier.TextClassificationManager; |
| import android.view.textclassifier.TextClassifier; |
| import android.view.textclassifier.TextLinks; |
| |
| import java.util.ArrayList; |
| import java.util.Arrays; |
| import java.util.Collections; |
| |
| 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 = Notification.MAX_ACTION_BUTTONS; |
| // Allow us to test out smart reply with dumb suggestions, it is disabled by default. |
| // TODO: Removed this once we have the model. |
| private static final String SYS_PROP_SMART_REPLIES_EXPERIMENT = |
| "persist.sys.smart_replies_experiment"; |
| |
| 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, @NonNull NotificationEntry entry) { |
| 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; |
| } |
| Notification.Action[] actions = entry.getNotification().actions; |
| int numOfExistingActions = actions == null ? 0: actions.length; |
| int maxSmartActions = MAX_SMART_ACTIONS - numOfExistingActions; |
| return suggestActionsFromText( |
| tcm, |
| getMostSalientActionText(entry.getNotification()), maxSmartActions); |
| } |
| |
| ArrayList<CharSequence> suggestReplies( |
| @Nullable Context context, @NonNull NotificationEntry entry) { |
| if (!isEligibleForReplyAdjustment(entry)) { |
| return EMPTY_REPLY_LIST; |
| } |
| if (context == null) { |
| return EMPTY_REPLY_LIST; |
| } |
| // TODO: replaced this with our model when it is ready. |
| return new ArrayList<>(Arrays.asList("Yes, please", "No, thanks")); |
| } |
| |
| /** |
| * 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.actions != null |
| && notification.actions.length >= Notification.MAX_ACTION_BUTTONS) { |
| 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 (!SystemProperties.getBoolean(SYS_PROP_SMART_REPLIES_EXPERIMENT, false)) { |
| return false; |
| } |
| Notification notification = entry.getNotification(); |
| if (notification.actions == null) { |
| return false; |
| } |
| return entry.hasInlineReply(); |
| } |
| |
| /** 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. */ |
| 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; |
| } |
| } |
| |
| // Fall back to using the normal text. |
| return notification.extras.getCharSequence(Notification.EXTRA_TEXT); |
| } |
| |
| /** 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; |
| } |
| } |