Deduplicate actions by their look

1. Deduplicate actions by their look. Ideally, we should compare
   their labels and icons, but comparing icon is expensive and thus
    we are comparing title + component name instead.

2. Put intent to extras in ConversationAction object

3. Updated LabeledIntent.resolve, so we only support activity intent
   handler.

4. Fixed a minor issue in the browser title chooser.
   If it is resolves to sharesheet / chooser, we will still show the URL.

BUG: 121200744
Test: atest frameworks/base/core/tests/coretests/src/android/view/textclassifier/

Change-Id: Ic7ea31eb0ac5e9386e8e4b428686a0b66726c96b
diff --git a/core/java/android/view/textclassifier/ActionsSuggestionsHelper.java b/core/java/android/view/textclassifier/ActionsSuggestionsHelper.java
index efdc968..ddbff7b 100644
--- a/core/java/android/view/textclassifier/ActionsSuggestionsHelper.java
+++ b/core/java/android/view/textclassifier/ActionsSuggestionsHelper.java
@@ -18,9 +18,11 @@
 
 import android.annotation.Nullable;
 import android.app.Person;
+import android.app.RemoteAction;
 import android.content.Context;
 import android.text.TextUtils;
 import android.util.ArrayMap;
+import android.util.Pair;
 
 import com.android.internal.annotations.VisibleForTesting;
 
@@ -118,12 +120,60 @@
     @Nullable
     public static LabeledIntent.TitleChooser createTitleChooser(String actionType) {
         if (ConversationAction.TYPE_OPEN_URL.equals(actionType)) {
-            return (labeledIntent, resolveInfo) -> resolveInfo.handleAllWebDataURI
-                    ? labeledIntent.titleWithEntity : labeledIntent.titleWithoutEntity;
+            return (labeledIntent, resolveInfo) -> {
+                if (resolveInfo.handleAllWebDataURI) {
+                    return labeledIntent.titleWithEntity;
+                }
+                if ("android".equals(resolveInfo.activityInfo.packageName)) {
+                    return labeledIntent.titleWithEntity;
+                }
+                return labeledIntent.titleWithoutEntity;
+            };
         }
         return null;
     }
 
+    /**
+     * Returns a list of {@link ConversationAction}s that have 0 duplicates. Two actions are
+     * duplicates if they may look the same to users. This function assumes every
+     * ConversationActions with a non-null RemoteAction also have a non-null intent in the extras.
+     */
+    public static List<ConversationAction> removeActionsWithDuplicates(
+            List<ConversationAction> conversationActions) {
+        // Ideally, we should compare title and icon here, but comparing icon is expensive and thus
+        // we use the component name of the target handler as the heuristic.
+        Map<Pair<String, String>, Integer> counter = new ArrayMap<>();
+        for (ConversationAction conversationAction : conversationActions) {
+            Pair<String, String> representation = getRepresentation(conversationAction);
+            if (representation == null) {
+                continue;
+            }
+            Integer existingCount = counter.getOrDefault(representation, 0);
+            counter.put(representation, existingCount + 1);
+        }
+        List<ConversationAction> result = new ArrayList<>();
+        for (ConversationAction conversationAction : conversationActions) {
+            Pair<String, String> representation = getRepresentation(conversationAction);
+            if (representation == null || counter.getOrDefault(representation, 0) == 1) {
+                result.add(conversationAction);
+            }
+        }
+        return result;
+    }
+
+    @Nullable
+    private static Pair<String, String> getRepresentation(
+            ConversationAction conversationAction) {
+        RemoteAction remoteAction = conversationAction.getAction();
+        if (remoteAction == null) {
+            return null;
+        }
+        return new Pair<>(
+                conversationAction.getAction().getTitle().toString(),
+                ExtrasUtils.getActionIntent(
+                        conversationAction.getExtras()).getComponent().getPackageName());
+    }
+
     private static final class PersonEncoder {
         private final Map<Person, Integer> mMapping = new ArrayMap<>();
         private int mNextUserId = FIRST_NON_LOCAL_USER;
diff --git a/core/java/android/view/textclassifier/ExtrasUtils.java b/core/java/android/view/textclassifier/ExtrasUtils.java
index 2ad17a8..df548ae 100644
--- a/core/java/android/view/textclassifier/ExtrasUtils.java
+++ b/core/java/android/view/textclassifier/ExtrasUtils.java
@@ -29,6 +29,7 @@
  */
 public final class ExtrasUtils {
 
+    private static final String ACTION_INTENT = "action-intent";
     private static final String ACTIONS_INTENTS = "actions-intents";
     private static final String FOREIGN_LANGUAGE = "foreign-language";
     private static final String ENTITY_TYPE = "entity-type";
@@ -77,6 +78,22 @@
     }
 
     /**
+     * Stores {@code actionIntents} information in TextClassifier response object's extras
+     * {@code container}.
+     */
+    public static void putActionIntent(Bundle container, @Nullable Intent actionIntent) {
+        container.putParcelable(ACTION_INTENT, actionIntent);
+    }
+
+    /**
+     * Returns {@code actionIntent} information contained in a TextClassifier response object.
+     */
+    @Nullable
+    public static Intent getActionIntent(Bundle container) {
+        return container.getParcelable(ACTION_INTENT);
+    }
+
+    /**
      * Returns {@code actionIntents} information contained in the TextClassification object.
      */
     @Nullable
diff --git a/core/java/android/view/textclassifier/LabeledIntent.java b/core/java/android/view/textclassifier/LabeledIntent.java
index 7544dc1..d2897b2 100644
--- a/core/java/android/view/textclassifier/LabeledIntent.java
+++ b/core/java/android/view/textclassifier/LabeledIntent.java
@@ -91,15 +91,22 @@
             Context context, @Nullable TitleChooser titleChooser) {
         final PackageManager pm = context.getPackageManager();
         final ResolveInfo resolveInfo = pm.resolveActivity(intent, 0);
-        final String packageName = resolveInfo != null && resolveInfo.activityInfo != null
-                ? resolveInfo.activityInfo.packageName : null;
-        Icon icon = null;
+
+        if (resolveInfo == null || resolveInfo.activityInfo == null) {
+            Log.w(TAG, "resolveInfo or activityInfo is null");
+            return null;
+        }
+        final String packageName = resolveInfo.activityInfo.packageName;
+        final String className = resolveInfo.activityInfo.name;
+        if (packageName == null || className == null) {
+            Log.w(TAG, "packageName or className is null");
+            return null;
+        }
         Intent resolvedIntent = new Intent(intent);
+        resolvedIntent.setComponent(new ComponentName(packageName, className));
         boolean shouldShowIcon = false;
-        if (packageName != null && !"android".equals(packageName)) {
-            // There is a default activity handling the intent.
-            resolvedIntent.setComponent(
-                    new ComponentName(packageName, resolveInfo.activityInfo.name));
+        Icon icon = null;
+        if (!"android".equals(packageName)) {
             if (resolveInfo.activityInfo.getIconResource() != 0) {
                 icon = Icon.createWithResource(
                         packageName, resolveInfo.activityInfo.getIconResource());
@@ -113,9 +120,6 @@
         }
         final PendingIntent pendingIntent =
                 TextClassification.createPendingIntent(context, resolvedIntent, requestCode);
-        if (pendingIntent == null) {
-            return null;
-        }
         if (titleChooser == null) {
             titleChooser = DEFAULT_TITLE_CHOOSER;
         }
@@ -150,6 +154,7 @@
     public interface TitleChooser {
         /**
          * Picks a title from a {@link LabeledIntent} by looking into resolved info.
+         * {@code resolveInfo} is guaranteed to have a non-null {@code activityInfo}.
          */
         @Nullable
         CharSequence chooseTitle(LabeledIntent labeledIntent, ResolveInfo resolveInfo);
diff --git a/core/java/android/view/textclassifier/TextClassification.java b/core/java/android/view/textclassifier/TextClassification.java
index 034da01..a275f0f 100644
--- a/core/java/android/view/textclassifier/TextClassification.java
+++ b/core/java/android/view/textclassifier/TextClassification.java
@@ -25,8 +25,6 @@
 import android.app.RemoteAction;
 import android.content.Context;
 import android.content.Intent;
-import android.content.pm.PackageManager;
-import android.content.pm.ResolveInfo;
 import android.content.res.Resources;
 import android.graphics.BitmapFactory;
 import android.graphics.drawable.AdaptiveIconDrawable;
@@ -304,53 +302,10 @@
      * @throws IllegalArgumentException if context or intent is null
      * @hide
      */
-    @Nullable
     public static PendingIntent createPendingIntent(
             @NonNull final Context context, @NonNull final Intent intent, int requestCode) {
-        final int flags = PendingIntent.FLAG_UPDATE_CURRENT;
-        switch (getIntentType(intent, context)) {
-            case IntentType.ACTIVITY:
-                return PendingIntent.getActivity(context, requestCode, intent, flags);
-            case IntentType.SERVICE:
-                return PendingIntent.getService(context, requestCode, intent, flags);
-            default:
-                return null;
-        }
-    }
-
-    @IntentType
-    private static int getIntentType(@NonNull Intent intent, @NonNull Context context) {
-        Preconditions.checkArgument(context != null);
-        Preconditions.checkArgument(intent != null);
-
-        final ResolveInfo activityRI = context.getPackageManager().resolveActivity(intent, 0);
-        if (activityRI != null) {
-            if (context.getPackageName().equals(activityRI.activityInfo.packageName)) {
-                return IntentType.ACTIVITY;
-            }
-            final boolean exported = activityRI.activityInfo.exported;
-            if (exported && hasPermission(context, activityRI.activityInfo.permission)) {
-                return IntentType.ACTIVITY;
-            }
-        }
-
-        final ResolveInfo serviceRI = context.getPackageManager().resolveService(intent, 0);
-        if (serviceRI != null) {
-            if (context.getPackageName().equals(serviceRI.serviceInfo.packageName)) {
-                return IntentType.SERVICE;
-            }
-            final boolean exported = serviceRI.serviceInfo.exported;
-            if (exported && hasPermission(context, serviceRI.serviceInfo.permission)) {
-                return IntentType.SERVICE;
-            }
-        }
-
-        return IntentType.UNSUPPORTED;
-    }
-
-    private static boolean hasPermission(@NonNull Context context, @NonNull String permission) {
-        return permission == null
-                || context.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED;
+        return PendingIntent.getActivity(
+                context, requestCode, intent, PendingIntent.FLAG_UPDATE_CURRENT);
     }
 
     /**
diff --git a/core/java/android/view/textclassifier/TextClassifierImpl.java b/core/java/android/view/textclassifier/TextClassifierImpl.java
index 632328b..293a230 100644
--- a/core/java/android/view/textclassifier/TextClassifierImpl.java
+++ b/core/java/android/view/textclassifier/TextClassifierImpl.java
@@ -403,6 +403,12 @@
         return mFallback.suggestConversationActions(request);
     }
 
+    /**
+     * Returns the {@link ConversationAction} result, with a non-null extras.
+     * <p>
+     * Whenever the RemoteAction is non-null, you can expect its corresponding intent
+     * with a non-null component name is in the extras.
+     */
     private ConversationActions createConversationActionResult(
             ConversationActions.Request request,
             ActionsSuggestionsModel.ActionSuggestion[] nativeSuggestions) {
@@ -419,6 +425,7 @@
             }
             List<LabeledIntent> labeledIntents =
                     mTemplateIntentFactory.create(nativeSuggestion.getRemoteActionTemplates());
+            Bundle extras = new Bundle();
             RemoteAction remoteAction = null;
             // Given that we only support implicit intent here, we should expect there is just one
             // intent for each action type.
@@ -428,6 +435,7 @@
                 LabeledIntent.Result result = labeledIntents.get(0).resolve(mContext, titleChooser);
                 if (result != null) {
                     remoteAction = result.remoteAction;
+                    ExtrasUtils.putActionIntent(extras, result.resolvedIntent);
                 }
             }
             conversationActions.add(
@@ -435,8 +443,11 @@
                             .setConfidenceScore(nativeSuggestion.getScore())
                             .setTextReply(nativeSuggestion.getResponseText())
                             .setAction(remoteAction)
+                            .setExtras(extras)
                             .build());
         }
+        conversationActions =
+                ActionsSuggestionsHelper.removeActionsWithDuplicates(conversationActions);
         String resultId = ActionsSuggestionsHelper.createResultId(
                 mContext,
                 request.getConversation(),
diff --git a/core/tests/coretests/src/android/view/textclassifier/ActionsSuggestionsHelperTest.java b/core/tests/coretests/src/android/view/textclassifier/ActionsSuggestionsHelperTest.java
index 5022e30..f440953 100644
--- a/core/tests/coretests/src/android/view/textclassifier/ActionsSuggestionsHelperTest.java
+++ b/core/tests/coretests/src/android/view/textclassifier/ActionsSuggestionsHelperTest.java
@@ -21,8 +21,15 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
+import android.app.PendingIntent;
 import android.app.Person;
+import android.app.RemoteAction;
+import android.content.ComponentName;
+import android.content.Intent;
+import android.graphics.drawable.Icon;
+import android.os.Bundle;
 
+import androidx.test.InstrumentationRegistry;
 import androidx.test.filters.SmallTest;
 import androidx.test.runner.AndroidJUnit4;
 
@@ -36,6 +43,7 @@
 import java.time.ZonedDateTime;
 import java.util.Arrays;
 import java.util.Collections;
+import java.util.List;
 import java.util.Locale;
 import java.util.function.Function;
 
@@ -129,6 +137,73 @@
         assertNativeMessage(conversationMessages[2], thirdMessage.getText(), 1, 2000);
     }
 
+    @Test
+    public void testDeduplicateActions() {
+        Bundle phoneExtras = new Bundle();
+        Intent phoneIntent = new Intent();
+        phoneIntent.setComponent(new ComponentName("phone", "intent"));
+        ExtrasUtils.putActionIntent(phoneExtras, phoneIntent);
+
+        Bundle anotherPhoneExtras = new Bundle();
+        Intent anotherPhoneIntent = new Intent();
+        anotherPhoneIntent.setComponent(new ComponentName("phone", "another.intent"));
+        ExtrasUtils.putActionIntent(anotherPhoneExtras, anotherPhoneIntent);
+
+        Bundle urlExtras = new Bundle();
+        Intent urlIntent = new Intent();
+        urlIntent.setComponent(new ComponentName("url", "intent"));
+        ExtrasUtils.putActionIntent(urlExtras, urlIntent);
+
+        PendingIntent pendingIntent = PendingIntent.getActivity(
+                InstrumentationRegistry.getTargetContext(),
+                0,
+                phoneIntent,
+                0);
+        Icon icon = Icon.createWithData(new byte[0], 0, 0);
+        ConversationAction action =
+                new ConversationAction.Builder(ConversationAction.TYPE_CALL_PHONE)
+                        .setAction(new RemoteAction(icon, "label", "1", pendingIntent))
+                        .setExtras(phoneExtras)
+                        .build();
+        ConversationAction actionWithSameLabel =
+                new ConversationAction.Builder(ConversationAction.TYPE_CALL_PHONE)
+                        .setAction(new RemoteAction(
+                                icon, "label", "2", pendingIntent))
+                        .setExtras(phoneExtras)
+                        .build();
+        ConversationAction actionWithSamePackageButDifferentClass =
+                new ConversationAction.Builder(ConversationAction.TYPE_CALL_PHONE)
+                        .setAction(new RemoteAction(
+                                icon, "label", "3", pendingIntent))
+                        .setExtras(anotherPhoneExtras)
+                        .build();
+        ConversationAction actionWithDifferentLabel =
+                new ConversationAction.Builder(ConversationAction.TYPE_CALL_PHONE)
+                        .setAction(new RemoteAction(
+                                icon, "another_label", "4", pendingIntent))
+                        .setExtras(phoneExtras)
+                        .build();
+        ConversationAction actionWithDifferentPackage =
+                new ConversationAction.Builder(ConversationAction.TYPE_OPEN_URL)
+                        .setAction(new RemoteAction(icon, "label", "5", pendingIntent))
+                        .setExtras(urlExtras)
+                        .build();
+        ConversationAction actionWithoutRemoteAction =
+                new ConversationAction.Builder(ConversationAction.TYPE_CREATE_REMINDER)
+                        .build();
+
+        List<ConversationAction> conversationActions =
+                ActionsSuggestionsHelper.removeActionsWithDuplicates(
+                        Arrays.asList(action, actionWithSameLabel,
+                                actionWithSamePackageButDifferentClass, actionWithDifferentLabel,
+                                actionWithDifferentPackage, actionWithoutRemoteAction));
+
+        assertThat(conversationActions).hasSize(3);
+        assertThat(conversationActions.get(0).getAction().getContentDescription()).isEqualTo("4");
+        assertThat(conversationActions.get(1).getAction().getContentDescription()).isEqualTo("5");
+        assertThat(conversationActions.get(2).getAction()).isNull();
+    }
+
     private ZonedDateTime createZonedDateTimeFromMsUtc(long msUtc) {
         return ZonedDateTime.ofInstant(Instant.ofEpochMilli(msUtc), ZoneId.of("UTC"));
     }
diff --git a/core/tests/coretests/src/android/view/textclassifier/LabeledIntentTest.java b/core/tests/coretests/src/android/view/textclassifier/LabeledIntentTest.java
index e4e9cde..6520c8f 100644
--- a/core/tests/coretests/src/android/view/textclassifier/LabeledIntentTest.java
+++ b/core/tests/coretests/src/android/view/textclassifier/LabeledIntentTest.java
@@ -132,7 +132,6 @@
         assertThat(intent.getComponent()).isNotNull();
     }
 
-
     @Test
     public void resolve_missingTitle() {
         assertThrows(
@@ -146,4 +145,19 @@
                                 REQUEST_CODE
                         ));
     }
+
+    @Test
+    public void resolve_noIntentHandler() {
+        Intent intent = new Intent("some.thing.does.not.exist");
+        LabeledIntent labeledIntent = new LabeledIntent(
+                TITLE_WITHOUT_ENTITY,
+                null,
+                DESCRIPTION,
+                intent,
+                REQUEST_CODE);
+
+        LabeledIntent.Result result = labeledIntent.resolve(mContext, null);
+
+        assertThat(result).isNull();
+    }
 }
diff --git a/core/tests/coretests/src/android/view/textclassifier/TextClassifierTest.java b/core/tests/coretests/src/android/view/textclassifier/TextClassifierTest.java
index d2d03e5..bcaf663 100644
--- a/core/tests/coretests/src/android/view/textclassifier/TextClassifierTest.java
+++ b/core/tests/coretests/src/android/view/textclassifier/TextClassifierTest.java
@@ -24,6 +24,7 @@
 import android.app.RemoteAction;
 import android.content.Context;
 import android.content.Intent;
+import android.net.Uri;
 import android.os.Bundle;
 import android.os.LocaleList;
 import android.text.Spannable;
@@ -32,6 +33,8 @@
 import androidx.test.InstrumentationRegistry;
 import androidx.test.filters.SmallTest;
 
+import com.google.common.truth.Truth;
+
 import org.hamcrest.BaseMatcher;
 import org.hamcrest.Description;
 import org.hamcrest.Matcher;
@@ -403,7 +406,6 @@
 
         ConversationActions conversationActions = mClassifier.suggestConversationActions(request);
         assertTrue(conversationActions.getConversationActions().size() > 0);
-        assertTrue(conversationActions.getConversationActions().size() == 1);
         for (ConversationAction conversationAction :
                 conversationActions.getConversationActions()) {
             assertThat(conversationAction,
@@ -438,6 +440,34 @@
         }
     }
 
+    @Test
+    public void testSuggestConversationActions_openUrl() {
+        if (isTextClassifierDisabled()) return;
+        ConversationActions.Message message =
+                new ConversationActions.Message.Builder(
+                        ConversationActions.Message.PERSON_USER_OTHERS)
+                        .setText("Check this out: https://www.android.com")
+                        .build();
+        TextClassifier.EntityConfig typeConfig =
+                new TextClassifier.EntityConfig.Builder().includeTypesFromTextClassifier(false)
+                        .setIncludedTypes(
+                                Collections.singletonList(ConversationAction.TYPE_OPEN_URL))
+                        .build();
+        ConversationActions.Request request =
+                new ConversationActions.Request.Builder(Collections.singletonList(message))
+                        .setMaxSuggestions(1)
+                        .setTypeConfig(typeConfig)
+                        .build();
+
+        ConversationActions conversationActions = mClassifier.suggestConversationActions(request);
+        Truth.assertThat(conversationActions.getConversationActions()).hasSize(1);
+        ConversationAction conversationAction = conversationActions.getConversationActions().get(0);
+        Truth.assertThat(conversationAction.getType()).isEqualTo(ConversationAction.TYPE_OPEN_URL);
+        Intent actionIntent = ExtrasUtils.getActionIntent(conversationAction.getExtras());
+        Truth.assertThat(actionIntent.getAction()).isEqualTo(Intent.ACTION_VIEW);
+        Truth.assertThat(actionIntent.getData()).isEqualTo(Uri.parse("https://www.android.com"));
+    }
+
 
     private boolean isTextClassifierDisabled() {
         return mClassifier == null || mClassifier == TextClassifier.NO_OP;