Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit e1a27ac0 authored by Tony Mak's avatar Tony Mak
Browse files

Update ExtService to use suggestConversationActions

1. Use suggestConversationActions for both replies and actions
2. Make existing flags configurable via DeviceConfig

Test: atest SmartActionHelperTest.java
Test: atest AssistantSettingsTest.java

BUG: 123745079

Change-Id: I9b84edf9818d5839202c337ac3c3d48378adbf55
parent ae33c3bd
Loading
Loading
Loading
Loading
+2 −0
Original line number Diff line number Diff line
@@ -5753,6 +5753,8 @@ package android.provider {
  public static interface DeviceConfig.NotificationAssistant {
    field public static final String GENERATE_ACTIONS = "generate_actions";
    field public static final String GENERATE_REPLIES = "generate_replies";
    field public static final String MAX_MESSAGES_TO_EXTRACT = "max_messages_to_extract";
    field public static final String MAX_SUGGESTIONS = "max_suggestions";
    field public static final String NAMESPACE = "notification_assistant";
  }
+4 −0
Original line number Diff line number Diff line
@@ -147,6 +147,10 @@ public final class DeviceConfig {
         * Whether the Notification Assistant should generate contextual actions for notifications.
         */
        String GENERATE_ACTIONS = "generate_actions";

        String MAX_MESSAGES_TO_EXTRACT = "max_messages_to_extract";

        String MAX_SUGGESTIONS = "max_suggestions";
    }

    /**
+47 −24
Original line number Diff line number Diff line
@@ -23,7 +23,6 @@ import android.os.Handler;
import android.provider.DeviceConfig;
import android.provider.Settings;
import android.text.TextUtils;
import android.util.KeyValueListParser;
import android.util.Log;

import com.android.internal.annotations.VisibleForTesting;
@@ -37,6 +36,9 @@ final class AssistantSettings extends ContentObserver {
    private static final boolean DEFAULT_GENERATE_REPLIES = true;
    private static final boolean DEFAULT_GENERATE_ACTIONS = true;
    private static final int DEFAULT_NEW_INTERRUPTION_MODEL_INT = 1;
    private static final int DEFAULT_MAX_MESSAGES_TO_EXTRACT = 5;
    @VisibleForTesting
    static final int DEFAULT_MAX_SUGGESTIONS = 3;

    private static final Uri STREAK_LIMIT_URI =
            Settings.Global.getUriFor(Settings.Global.BLOCKING_HELPER_STREAK_LIMIT);
@@ -46,7 +48,6 @@ final class AssistantSettings extends ContentObserver {
    private static final Uri NOTIFICATION_NEW_INTERRUPTION_MODEL_URI =
            Settings.Secure.getUriFor(Settings.Secure.NOTIFICATION_NEW_INTERRUPTION_MODEL);

    private final KeyValueListParser mParser = new KeyValueListParser(',');
    private final ContentResolver mResolver;
    private final int mUserId;

@@ -55,12 +56,14 @@ final class AssistantSettings extends ContentObserver {
    @VisibleForTesting
    protected final Runnable mOnUpdateRunnable;

    // Actuall configuration settings.
    // Actual configuration settings.
    float mDismissToViewRatioLimit;
    int mStreakLimit;
    boolean mGenerateReplies = DEFAULT_GENERATE_REPLIES;
    boolean mGenerateActions = DEFAULT_GENERATE_ACTIONS;
    boolean mNewInterruptionModel;
    int mMaxMessagesToExtract = DEFAULT_MAX_MESSAGES_TO_EXTRACT;
    int mMaxSuggestions = DEFAULT_MAX_SUGGESTIONS;

    private AssistantSettings(Handler handler, ContentResolver resolver, int userId,
            Runnable onUpdateRunnable) {
@@ -124,27 +127,18 @@ final class AssistantSettings extends ContentObserver {
    }

    private void updateFromDeviceConfigFlags() {
        String generateRepliesFlag = DeviceConfig.getProperty(
                DeviceConfig.NotificationAssistant.NAMESPACE,
                DeviceConfig.NotificationAssistant.GENERATE_REPLIES);
        if (TextUtils.isEmpty(generateRepliesFlag)) {
            mGenerateReplies = DEFAULT_GENERATE_REPLIES;
        } else {
            // parseBoolean returns false for everything that isn't 'true' so there's no need to
            // sanitise the flag string here.
            mGenerateReplies = Boolean.parseBoolean(generateRepliesFlag);
        }
        mGenerateReplies = DeviceConfigHelper.getBoolean(
                DeviceConfig.NotificationAssistant.GENERATE_REPLIES, DEFAULT_GENERATE_REPLIES);

        String generateActionsFlag = DeviceConfig.getProperty(
                DeviceConfig.NotificationAssistant.NAMESPACE,
                DeviceConfig.NotificationAssistant.GENERATE_ACTIONS);
        if (TextUtils.isEmpty(generateActionsFlag)) {
            mGenerateActions = DEFAULT_GENERATE_ACTIONS;
        } else {
            // parseBoolean returns false for everything that isn't 'true' so there's no need to
            // sanitise the flag string here.
            mGenerateActions = Boolean.parseBoolean(generateActionsFlag);
        }
        mGenerateActions = DeviceConfigHelper.getBoolean(
                DeviceConfig.NotificationAssistant.GENERATE_ACTIONS, DEFAULT_GENERATE_ACTIONS);

        mMaxMessagesToExtract = DeviceConfigHelper.getInteger(
                DeviceConfig.NotificationAssistant.MAX_MESSAGES_TO_EXTRACT,
                DEFAULT_MAX_MESSAGES_TO_EXTRACT);

        mMaxSuggestions = DeviceConfigHelper.getInteger(
                DeviceConfig.NotificationAssistant.MAX_SUGGESTIONS, DEFAULT_MAX_SUGGESTIONS);

        mOnUpdateRunnable.run();
    }
@@ -175,6 +169,35 @@ final class AssistantSettings extends ContentObserver {
        mOnUpdateRunnable.run();
    }

    static class DeviceConfigHelper {

        static int getInteger(String key, int defaultValue) {
            String value = getValue(key);
            if (TextUtils.isEmpty(value)) {
                return defaultValue;
            }
            try {
                return Integer.parseInt(value);
            } catch (NumberFormatException ex) {
                return defaultValue;
            }
        }

        static boolean getBoolean(String key, boolean defaultValue) {
            String value = getValue(key);
            if (TextUtils.isEmpty(value)) {
                return defaultValue;
            }
            return Boolean.parseBoolean(value);
        }

        private static String getValue(String key) {
            return DeviceConfig.getProperty(
                    DeviceConfig.NotificationAssistant.NAMESPACE,
                    key);
        }
    }

    public interface Factory {
        AssistantSettings createAndRegister(Handler handler, ContentResolver resolver, int userId,
                Runnable onUpdateRunnable);
+54 −125
Original line number Diff line number Diff line
@@ -29,28 +29,22 @@ import android.text.TextUtils;
import android.util.LruCache;
import android.view.textclassifier.ConversationAction;
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;
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<>();

    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 =
@@ -58,19 +52,8 @@ public class SmartActionsHelper {
                    | 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 int MAX_RESULT_ID_TO_CACHE = 20;

    private static final TextClassifier.EntityConfig TYPE_CONFIG =
            new TextClassifier.EntityConfig.Builder().setIncludedTypes(
                    Collections.singletonList(ConversationAction.TYPE_TEXT_REPLY))
                    .includeTypesFromTextClassifier(false)
                    .build();
    private static final List<String> HINTS =
            Collections.singletonList(ConversationActions.Request.HINT_FOR_NOTIFICATION);

@@ -92,20 +75,30 @@ public class SmartActionsHelper {
        mSettings = settings;
    }

    @NonNull
    SmartSuggestions suggest(@NonNull NotificationEntry entry) {
        // Whenever suggest() is called on a notification, its previous session is ended.
        mNotificationKeyToResultIdCache.remove(entry.getSbn().getKey());

        ArrayList<Notification.Action> actions = suggestActions(entry);
        ArrayList<CharSequence> replies = suggestReplies(entry);
        boolean eligibleForReplyAdjustment =
                mSettings.mGenerateReplies && isEligibleForReplyAdjustment(entry);
        boolean eligibleForActionAdjustment =
                mSettings.mGenerateActions && isEligibleForActionAdjustment(entry);

        // Not logging subsequent events of this notification if we didn't generate any suggestion
        // for it.
        if (replies.isEmpty() && actions.isEmpty()) {
            mNotificationKeyToResultIdCache.remove(entry.getSbn().getKey());
        }
        List<ConversationAction> conversationActions =
                suggestConversationActions(
                        entry,
                        eligibleForReplyAdjustment,
                        eligibleForActionAdjustment);

        ArrayList<CharSequence> replies = conversationActions.stream()
                .map(ConversationAction::getTextReply)
                .filter(textReply -> !TextUtils.isEmpty(textReply))
                .collect(Collectors.toCollection(ArrayList::new));

        ArrayList<Notification.Action> actions = conversationActions.stream()
                .filter(conversationAction -> conversationAction.getAction() != null)
                .map(action -> createNotificationAction(action.getAction(), action.getType()))
                .collect(Collectors.toCollection(ArrayList::new));
        return new SmartSuggestions(replies, actions);
    }

@@ -113,61 +106,48 @@ public class SmartActionsHelper {
     * Adds action adjustments based on the notification contents.
     */
    @NonNull
    ArrayList<Notification.Action> suggestActions(@NonNull NotificationEntry entry) {
        if (!mSettings.mGenerateActions) {
            return EMPTY_ACTION_LIST;
        }
        if (!isEligibleForActionAdjustment(entry)) {
            return EMPTY_ACTION_LIST;
    private List<ConversationAction> suggestConversationActions(
            @NonNull NotificationEntry entry,
            boolean includeReplies,
            boolean includeActions) {
        if (!includeReplies && !includeActions) {
            return Collections.emptyList();
        }
        if (mTextClassifier == null) {
            return EMPTY_ACTION_LIST;
            return Collections.emptyList();
        }
        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(
                messages.get(messages.size() - 1).getText(), MAX_SMART_ACTIONS);
            return Collections.emptyList();
        }

    @NonNull
    ArrayList<CharSequence> suggestReplies(@NonNull NotificationEntry entry) {
        if (!mSettings.mGenerateReplies) {
            return EMPTY_REPLY_LIST;
        }
        if (!isEligibleForReplyAdjustment(entry)) {
            return EMPTY_REPLY_LIST;
        }
        if (mTextClassifier == null) {
            return EMPTY_REPLY_LIST;
        }
        List<ConversationActions.Message> messages = extractMessages(entry.getNotification());
        if (messages.isEmpty()) {
            return EMPTY_REPLY_LIST;
        TextClassifier.EntityConfig.Builder typeConfigBuilder =
                new TextClassifier.EntityConfig.Builder();
        if (!includeReplies) {
            typeConfigBuilder.setExcludedTypes(
                    Collections.singletonList(ConversationAction.TYPE_TEXT_REPLY));
        } else if (!includeActions) {
            typeConfigBuilder
                    .setIncludedTypes(
                            Collections.singletonList(ConversationAction.TYPE_TEXT_REPLY))
                    .includeTypesFromTextClassifier(false);
        }
        ConversationActions.Request request =
                new ConversationActions.Request.Builder(messages)
                        .setMaxSuggestions(MAX_SUGGESTED_REPLIES)
                        .setMaxSuggestions(mSettings.mMaxSuggestions)
                        .setHints(HINTS)
                        .setTypeConfig(TYPE_CONFIG)
                        .setTypeConfig(typeConfigBuilder.build())
                        .build();

        ConversationActions conversationActionsResult =
                mTextClassifier.suggestConversationActions(request);
        List<ConversationAction> conversationActions =
                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) {
        if (!TextUtils.isEmpty(resultId)
                && !conversationActionsResult.getConversationActions().isEmpty()) {
            mNotificationKeyToResultIdCache.put(entry.getSbn().getKey(), resultId);
        }
        return replies;
        return conversationActionsResult.getConversationActions();
    }

    void onNotificationExpansionChanged(@NonNull NotificationEntry entry, boolean isUserAction,
@@ -248,6 +228,17 @@ public class SmartActionsHelper {
        mTextClassifier.onTextClassifierEvent(textClassifierEvent);
    }

    private Notification.Action createNotificationAction(
            RemoteAction remoteAction, String actionType) {
        return new Notification.Action.Builder(
                remoteAction.getIcon(),
                remoteAction.getTitle(),
                remoteAction.getActionIntent())
                .setContextual(true)
                .addExtras(Bundle.forPair(KEY_ACTION_TYPE, actionType))
                .build();
    }

    private TextClassifierEvent.Builder createTextClassifierEventBuilder(
            int eventType, @NonNull String resultId) {
        return new TextClassifierEvent.Builder(
@@ -308,7 +299,7 @@ public class SmartActionsHelper {
    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(
            return Collections.singletonList(new ConversationActions.Message.Builder(
                    ConversationActions.Message.PERSON_USER_OTHERS)
                    .setText(notification.extras.getCharSequence(Notification.EXTRA_TEXT))
                    .build());
@@ -335,75 +326,13 @@ public class SmartActionsHelper {
                            ZonedDateTime.ofInstant(Instant.ofEpochMilli(message.getTimestamp()),
                                    ZoneOffset.systemDefault()))
                    .build());
            if (extractMessages.size() >= MAX_MESSAGES_TO_EXTRACT) {
            if (extractMessages.size() >= mSettings.mMaxMessagesToExtract) {
                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(
            @Nullable CharSequence text, int maxSmartActions) {
        if (TextUtils.isEmpty(text)) {
            return EMPTY_ACTION_LIST;
        }
        // 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 = mTextClassifier.generateLinks(textLinksRequest);
        EntityTypeCounter entityTypeCounter = EntityTypeCounter.fromTextLinks(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
                    || entityTypeCounter.getCount(link.getEntity(0)) != 1) {
                continue;
            }

            // Generate the actions, and add the most prominent ones to the action bar.
            TextClassification classification =
                    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 remoteAction = classification.getActions().get(i);
                Notification.Action action = new Notification.Action.Builder(
                        remoteAction.getIcon(),
                        remoteAction.getTitle(),
                        remoteAction.getActionIntent())
                        .setContextual(true)
                        .addExtras(Bundle.forPair(KEY_ACTION_TYPE, classification.getEntity(0)))
                        .build();
                actions.add(action);

                // We have enough smart actions.
                if (actions.size() >= maxSmartActions) {
                    return actions;
                }
            }
        }
        return actions;
    }

    static class SmartSuggestions {
        public final ArrayList<CharSequence> replies;
        public final ArrayList<Notification.Action> actions;
+1 −0
Original line number Diff line number Diff line
@@ -15,6 +15,7 @@ android_test {
    static_libs: [
        "ExtServices-core",
        "android-support-test",
        "compatibility-device-util",
        "mockito-target-minus-junit4",
        "espresso-core",
        "truth-prebuilt",
Loading