Add log when clipboard smart actions shown

Refactors to pull the action classification into a separate class for
testing. Logic flow is unchanged.

Test: make statsd_testdrive && $ANDROID_HOST_OUT/bin/statsd_testdrive 90
Test: atest ClipboardOverlayControllerTest
Bug: 261201283
Change-Id: Ie931b2c11ef1a01c16147907d730d1dd6e5febe8
diff --git a/packages/SystemUI/src/com/android/systemui/clipboardoverlay/ClipboardOverlayController.java b/packages/SystemUI/src/com/android/systemui/clipboardoverlay/ClipboardOverlayController.java
index 63c2065..f97d6af 100644
--- a/packages/SystemUI/src/com/android/systemui/clipboardoverlay/ClipboardOverlayController.java
+++ b/packages/SystemUI/src/com/android/systemui/clipboardoverlay/ClipboardOverlayController.java
@@ -22,6 +22,7 @@
 
 import static com.android.internal.config.sysui.SystemUiDeviceConfigFlags.CLIPBOARD_OVERLAY_SHOW_ACTIONS;
 import static com.android.internal.config.sysui.SystemUiDeviceConfigFlags.CLIPBOARD_OVERLAY_SHOW_EDIT_BUTTON;
+import static com.android.systemui.clipboardoverlay.ClipboardOverlayEvent.CLIPBOARD_OVERLAY_ACTION_SHOWN;
 import static com.android.systemui.clipboardoverlay.ClipboardOverlayEvent.CLIPBOARD_OVERLAY_ACTION_TAPPED;
 import static com.android.systemui.clipboardoverlay.ClipboardOverlayEvent.CLIPBOARD_OVERLAY_DISMISSED_OTHER;
 import static com.android.systemui.clipboardoverlay.ClipboardOverlayEvent.CLIPBOARD_OVERLAY_DISMISS_TAPPED;
@@ -41,7 +42,6 @@
 import android.content.BroadcastReceiver;
 import android.content.ClipData;
 import android.content.ClipDescription;
-import android.content.ComponentName;
 import android.content.ContentResolver;
 import android.content.Context;
 import android.content.Intent;
@@ -51,7 +51,6 @@
 import android.hardware.display.DisplayManager;
 import android.hardware.input.InputManager;
 import android.net.Uri;
-import android.os.AsyncTask;
 import android.os.Looper;
 import android.provider.DeviceConfig;
 import android.text.TextUtils;
@@ -62,10 +61,6 @@
 import android.view.InputEventReceiver;
 import android.view.InputMonitor;
 import android.view.MotionEvent;
-import android.view.textclassifier.TextClassification;
-import android.view.textclassifier.TextClassificationManager;
-import android.view.textclassifier.TextClassifier;
-import android.view.textclassifier.TextLinks;
 
 import androidx.annotation.NonNull;
 
@@ -74,12 +69,13 @@
 import com.android.systemui.broadcast.BroadcastDispatcher;
 import com.android.systemui.broadcast.BroadcastSender;
 import com.android.systemui.clipboardoverlay.dagger.ClipboardOverlayModule.OverlayWindowContext;
+import com.android.systemui.dagger.qualifiers.Background;
 import com.android.systemui.flags.FeatureFlags;
 import com.android.systemui.screenshot.TimeoutHandler;
 
 import java.io.IOException;
-import java.util.ArrayList;
 import java.util.Optional;
+import java.util.concurrent.Executor;
 
 import javax.inject.Inject;
 
@@ -102,9 +98,9 @@
     private final DisplayManager mDisplayManager;
     private final ClipboardOverlayWindow mWindow;
     private final TimeoutHandler mTimeoutHandler;
-    private final TextClassifier mTextClassifier;
     private final ClipboardOverlayUtils mClipboardUtils;
     private final FeatureFlags mFeatureFlags;
+    private final Executor mBgExecutor;
 
     private final ClipboardOverlayView mView;
 
@@ -189,6 +185,7 @@
             TimeoutHandler timeoutHandler,
             FeatureFlags featureFlags,
             ClipboardOverlayUtils clipboardUtils,
+            @Background Executor bgExecutor,
             UiEventLogger uiEventLogger) {
         mBroadcastDispatcher = broadcastDispatcher;
         mDisplayManager = requireNonNull(context.getSystemService(DisplayManager.class));
@@ -204,14 +201,12 @@
             hideImmediate();
         });
 
-        mTextClassifier = requireNonNull(context.getSystemService(TextClassificationManager.class))
-                .getTextClassifier();
-
         mTimeoutHandler = timeoutHandler;
         mTimeoutHandler.setDefaultTimeoutMillis(CLIPBOARD_DEFAULT_TIMEOUT_MILLIS);
 
         mFeatureFlags = featureFlags;
         mClipboardUtils = clipboardUtils;
+        mBgExecutor = bgExecutor;
 
         mView.setCallbacks(mClipboardCallbacks);
 
@@ -281,7 +276,7 @@
             if (isRemote || DeviceConfig.getBoolean(DeviceConfig.NAMESPACE_SYSTEMUI,
                     CLIPBOARD_OVERLAY_SHOW_ACTIONS, false)) {
                 if (item.getTextLinks() != null) {
-                    AsyncTask.execute(() -> classifyText(clipData.getItemAt(0), clipSource));
+                    classifyText(clipData.getItemAt(0), clipSource);
                 }
             }
             if (isSensitive) {
@@ -338,22 +333,18 @@
     }
 
     private void classifyText(ClipData.Item item, String source) {
-        ArrayList<RemoteAction> actions = new ArrayList<>();
-        for (TextLinks.TextLink link : item.getTextLinks().getLinks()) {
-            TextClassification classification = mTextClassifier.classifyText(
-                    item.getText(), link.getStart(), link.getEnd(), null);
-            actions.addAll(classification.getActions());
-        }
-        mView.post(() -> {
-            Optional<RemoteAction> action = actions.stream().filter(remoteAction -> {
-                ComponentName component = remoteAction.getActionIntent().getIntent().getComponent();
-                return component != null && !TextUtils.equals(source, component.getPackageName());
-            }).findFirst();
-            mView.resetActionChips();
-            action.ifPresent(remoteAction -> mView.setActionChip(remoteAction, () -> {
-                mClipboardLogger.logSessionComplete(CLIPBOARD_OVERLAY_ACTION_TAPPED);
-                animateOut();
-            }));
+        mBgExecutor.execute(() -> {
+            Optional<RemoteAction> action = mClipboardUtils.getAction(item, source);
+            mView.post(() -> {
+                mView.resetActionChips();
+                action.ifPresent(remoteAction -> {
+                    mView.setActionChip(remoteAction, () -> {
+                        mClipboardLogger.logSessionComplete(CLIPBOARD_OVERLAY_ACTION_TAPPED);
+                        animateOut();
+                    });
+                    mClipboardLogger.logUnguarded(CLIPBOARD_OVERLAY_ACTION_SHOWN);
+                });
+            });
         });
     }
 
@@ -539,6 +530,10 @@
             mClipSource = clipSource;
         }
 
+        void logUnguarded(@NonNull UiEventLogger.UiEventEnum event) {
+            mUiEventLogger.log(event, 0, mClipSource);
+        }
+
         void logSessionComplete(@NonNull UiEventLogger.UiEventEnum event) {
             if (!mGuarded) {
                 mGuarded = true;
diff --git a/packages/SystemUI/src/com/android/systemui/clipboardoverlay/ClipboardOverlayEvent.java b/packages/SystemUI/src/com/android/systemui/clipboardoverlay/ClipboardOverlayEvent.java
index a0b2ab9..9917507 100644
--- a/packages/SystemUI/src/com/android/systemui/clipboardoverlay/ClipboardOverlayEvent.java
+++ b/packages/SystemUI/src/com/android/systemui/clipboardoverlay/ClipboardOverlayEvent.java
@@ -28,6 +28,8 @@
     CLIPBOARD_OVERLAY_EDIT_TAPPED(951),
     @UiEvent(doc = "clipboard share tapped")
     CLIPBOARD_OVERLAY_SHARE_TAPPED(1067),
+    @UiEvent(doc = "clipboard smart action shown")
+    CLIPBOARD_OVERLAY_ACTION_SHOWN(1260),
     @UiEvent(doc = "clipboard action tapped")
     CLIPBOARD_OVERLAY_ACTION_TAPPED(952),
     @UiEvent(doc = "clipboard remote copy tapped")
diff --git a/packages/SystemUI/src/com/android/systemui/clipboardoverlay/ClipboardOverlayUtils.java b/packages/SystemUI/src/com/android/systemui/clipboardoverlay/ClipboardOverlayUtils.java
index c194e66..785e4a0 100644
--- a/packages/SystemUI/src/com/android/systemui/clipboardoverlay/ClipboardOverlayUtils.java
+++ b/packages/SystemUI/src/com/android/systemui/clipboardoverlay/ClipboardOverlayUtils.java
@@ -16,22 +16,34 @@
 
 package com.android.systemui.clipboardoverlay;
 
+import android.app.RemoteAction;
 import android.content.ClipData;
 import android.content.ClipDescription;
 import android.content.ComponentName;
 import android.content.Context;
 import android.os.Build;
 import android.provider.DeviceConfig;
+import android.text.TextUtils;
+import android.view.textclassifier.TextClassification;
+import android.view.textclassifier.TextClassificationManager;
+import android.view.textclassifier.TextClassifier;
+import android.view.textclassifier.TextLinks;
 
 import com.android.internal.config.sysui.SystemUiDeviceConfigFlags;
 import com.android.systemui.R;
 
+import java.util.ArrayList;
+import java.util.Optional;
+
 import javax.inject.Inject;
 
 class ClipboardOverlayUtils {
 
+    private final TextClassifier mTextClassifier;
+
     @Inject
-    ClipboardOverlayUtils() {
+    ClipboardOverlayUtils(TextClassificationManager textClassificationManager) {
+        mTextClassifier = textClassificationManager.getTextClassifier();
     }
 
     boolean isRemoteCopy(Context context, ClipData clipData, String clipSource) {
@@ -52,4 +64,21 @@
         }
         return false;
     }
+
+    public Optional<RemoteAction> getAction(ClipData.Item item, String source) {
+        return getActions(item).stream().filter(remoteAction -> {
+            ComponentName component = remoteAction.getActionIntent().getIntent().getComponent();
+            return component != null && !TextUtils.equals(source, component.getPackageName());
+        }).findFirst();
+    }
+
+    private ArrayList<RemoteAction> getActions(ClipData.Item item) {
+        ArrayList<RemoteAction> actions = new ArrayList<>();
+        for (TextLinks.TextLink link : item.getTextLinks().getLinks()) {
+            TextClassification classification = mTextClassifier.classifyText(
+                    item.getText(), link.getStart(), link.getEnd(), null);
+            actions.addAll(classification.getActions());
+        }
+        return actions;
+    }
 }
diff --git a/packages/SystemUI/src/com/android/systemui/dagger/FrameworkServicesModule.java b/packages/SystemUI/src/com/android/systemui/dagger/FrameworkServicesModule.java
index 7864f19..71708d3 100644
--- a/packages/SystemUI/src/com/android/systemui/dagger/FrameworkServicesModule.java
+++ b/packages/SystemUI/src/com/android/systemui/dagger/FrameworkServicesModule.java
@@ -92,6 +92,7 @@
 import android.view.accessibility.AccessibilityManager;
 import android.view.accessibility.CaptioningManager;
 import android.view.inputmethod.InputMethodManager;
+import android.view.textclassifier.TextClassificationManager;
 
 import com.android.internal.app.IBatteryStats;
 import com.android.internal.appwidget.IAppWidgetService;
@@ -623,4 +624,10 @@
     static BluetoothAdapter provideBluetoothAdapter(BluetoothManager bluetoothManager) {
         return bluetoothManager.getAdapter();
     }
+
+    @Provides
+    @Singleton
+    static TextClassificationManager provideTextClassificationManager(Context context) {
+        return context.getSystemService(TextClassificationManager.class);
+    }
 }
diff --git a/packages/SystemUI/tests/src/com/android/systemui/clipboardoverlay/ClipboardOverlayControllerTest.java b/packages/SystemUI/tests/src/com/android/systemui/clipboardoverlay/ClipboardOverlayControllerTest.java
index d6e621f..b4e85c0 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/clipboardoverlay/ClipboardOverlayControllerTest.java
+++ b/packages/SystemUI/tests/src/com/android/systemui/clipboardoverlay/ClipboardOverlayControllerTest.java
@@ -16,12 +16,14 @@
 
 package com.android.systemui.clipboardoverlay;
 
+import static com.android.systemui.clipboardoverlay.ClipboardOverlayEvent.CLIPBOARD_OVERLAY_ACTION_SHOWN;
 import static com.android.systemui.clipboardoverlay.ClipboardOverlayEvent.CLIPBOARD_OVERLAY_DISMISS_TAPPED;
 import static com.android.systemui.clipboardoverlay.ClipboardOverlayEvent.CLIPBOARD_OVERLAY_SHARE_TAPPED;
 import static com.android.systemui.clipboardoverlay.ClipboardOverlayEvent.CLIPBOARD_OVERLAY_SWIPE_DISMISSED;
 import static com.android.systemui.flags.Flags.CLIPBOARD_REMOTE_BEHAVIOR;
 
 import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
 import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
@@ -29,10 +31,13 @@
 import static org.mockito.Mockito.when;
 
 import android.animation.Animator;
+import android.app.RemoteAction;
 import android.content.ClipData;
 import android.content.ClipDescription;
+import android.content.Context;
 import android.net.Uri;
 import android.os.PersistableBundle;
+import android.view.textclassifier.TextLinks;
 
 import androidx.test.filters.SmallTest;
 import androidx.test.runner.AndroidJUnit4;
@@ -42,6 +47,8 @@
 import com.android.systemui.broadcast.BroadcastSender;
 import com.android.systemui.flags.FakeFeatureFlags;
 import com.android.systemui.screenshot.TimeoutHandler;
+import com.android.systemui.util.concurrency.FakeExecutor;
+import com.android.systemui.util.time.FakeSystemClock;
 
 import org.junit.After;
 import org.junit.Before;
@@ -50,7 +57,12 @@
 import org.mockito.ArgumentCaptor;
 import org.mockito.Captor;
 import org.mockito.Mock;
+import org.mockito.Mockito;
 import org.mockito.MockitoAnnotations;
+import org.mockito.invocation.InvocationOnMock;
+import org.mockito.stubbing.Answer;
+
+import java.util.Optional;
 
 @SmallTest
 @RunWith(AndroidJUnit4.class)
@@ -80,6 +92,8 @@
     private ArgumentCaptor<ClipboardOverlayView.ClipboardOverlayCallbacks> mOverlayCallbacksCaptor;
     private ClipboardOverlayView.ClipboardOverlayCallbacks mCallbacks;
 
+    private FakeExecutor mExecutor = new FakeExecutor(new FakeSystemClock());
+
     @Before
     public void setup() {
         MockitoAnnotations.initMocks(this);
@@ -101,6 +115,7 @@
                 mTimeoutHandler,
                 mFeatureFlags,
                 mClipboardUtils,
+                mExecutor,
                 mUiEventLogger);
         verify(mClipboardOverlayView).setCallbacks(mOverlayCallbacksCaptor.capture());
         mCallbacks = mOverlayCallbacksCaptor.getValue();
@@ -237,4 +252,29 @@
         verify(mUiEventLogger).log(CLIPBOARD_OVERLAY_DISMISS_TAPPED, 0, "second.package");
         verifyNoMoreInteractions(mUiEventLogger);
     }
+
+    @Test
+    public void test_logOnClipboardActionsShown() {
+        ClipData.Item item = mSampleClipData.getItemAt(0);
+        item.setTextLinks(Mockito.mock(TextLinks.class));
+        mFeatureFlags.set(CLIPBOARD_REMOTE_BEHAVIOR, true);
+        when(mClipboardUtils.isRemoteCopy(any(Context.class), any(ClipData.class), anyString()))
+                .thenReturn(true);
+        when(mClipboardUtils.getAction(any(ClipData.Item.class), anyString()))
+                .thenReturn(Optional.of(Mockito.mock(RemoteAction.class)));
+        when(mClipboardOverlayView.post(any(Runnable.class))).thenAnswer(new Answer<Object>() {
+            @Override
+            public Object answer(InvocationOnMock invocation) throws Throwable {
+                ((Runnable) invocation.getArgument(0)).run();
+                return null;
+            }
+        });
+
+        mOverlayController.setClipData(
+                new ClipData(mSampleClipData.getDescription(), item), "actionShownSource");
+        mExecutor.runAllReady();
+
+        verify(mUiEventLogger).log(CLIPBOARD_OVERLAY_ACTION_SHOWN, 0, "actionShownSource");
+        verifyNoMoreInteractions(mUiEventLogger);
+    }
 }
diff --git a/packages/SystemUI/tests/src/com/android/systemui/clipboardoverlay/ClipboardOverlayUtilsTest.java b/packages/SystemUI/tests/src/com/android/systemui/clipboardoverlay/ClipboardOverlayUtilsTest.java
index 09b1699..aea6be3 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/clipboardoverlay/ClipboardOverlayUtilsTest.java
+++ b/packages/SystemUI/tests/src/com/android/systemui/clipboardoverlay/ClipboardOverlayUtilsTest.java
@@ -16,13 +16,24 @@
 
 package com.android.systemui.clipboardoverlay;
 
+import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertTrue;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.isNull;
+import static org.mockito.Mockito.when;
 
+import android.app.RemoteAction;
 import android.content.ClipData;
 import android.content.ClipDescription;
 import android.os.PersistableBundle;
 import android.testing.TestableResources;
+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 androidx.test.filters.SmallTest;
 import androidx.test.runner.AndroidJUnit4;
@@ -30,19 +41,84 @@
 import com.android.systemui.R;
 import com.android.systemui.SysuiTestCase;
 
+import com.google.android.collect.Lists;
+
 import org.junit.Before;
 import org.junit.Test;
 import org.junit.runner.RunWith;
+import org.mockito.Answers;
+import org.mockito.Mock;
+import org.mockito.Mockito;
+import org.mockito.MockitoAnnotations;
+
+import java.util.Map;
+import java.util.Optional;
 
 @SmallTest
 @RunWith(AndroidJUnit4.class)
 public class ClipboardOverlayUtilsTest extends SysuiTestCase {
 
     private ClipboardOverlayUtils mClipboardUtils;
+    @Mock
+    private TextClassificationManager mTextClassificationManager;
+    @Mock
+    private TextClassifier mTextClassifier;
+
+    @Mock
+    private ClipData.Item mClipDataItem;
 
     @Before
     public void setUp() {
-        mClipboardUtils = new ClipboardOverlayUtils();
+        MockitoAnnotations.initMocks(this);
+
+        when(mTextClassificationManager.getTextClassifier()).thenReturn(mTextClassifier);
+        mClipboardUtils = new ClipboardOverlayUtils(mTextClassificationManager);
+    }
+
+    @Test
+    public void test_getAction_noLinks_returnsEmptyOptional() {
+        ClipData.Item item = new ClipData.Item("no text links");
+        item.setTextLinks(Mockito.mock(TextLinks.class));
+
+        Optional<RemoteAction> action = mClipboardUtils.getAction(item, "");
+
+        assertTrue(action.isEmpty());
+    }
+
+    @Test
+    public void test_getAction_returnsFirstLink() {
+        when(mClipDataItem.getTextLinks()).thenReturn(getFakeTextLinks());
+        when(mClipDataItem.getText()).thenReturn("");
+        RemoteAction actionA = constructRemoteAction("abc");
+        RemoteAction actionB = constructRemoteAction("def");
+        TextClassification classificationA = Mockito.mock(TextClassification.class);
+        when(classificationA.getActions()).thenReturn(Lists.newArrayList(actionA));
+        TextClassification classificationB = Mockito.mock(TextClassification.class);
+        when(classificationB.getActions()).thenReturn(Lists.newArrayList(actionB));
+        when(mTextClassifier.classifyText(anyString(), anyInt(), anyInt(), isNull())).thenReturn(
+                classificationA, classificationB);
+
+        RemoteAction result = mClipboardUtils.getAction(mClipDataItem, "def").orElse(null);
+
+        assertEquals(actionA, result);
+    }
+
+    @Test
+    public void test_getAction_skipsMatchingComponent() {
+        when(mClipDataItem.getTextLinks()).thenReturn(getFakeTextLinks());
+        when(mClipDataItem.getText()).thenReturn("");
+        RemoteAction actionA = constructRemoteAction("abc");
+        RemoteAction actionB = constructRemoteAction("def");
+        TextClassification classificationA = Mockito.mock(TextClassification.class);
+        when(classificationA.getActions()).thenReturn(Lists.newArrayList(actionA));
+        TextClassification classificationB = Mockito.mock(TextClassification.class);
+        when(classificationB.getActions()).thenReturn(Lists.newArrayList(actionB));
+        when(mTextClassifier.classifyText(anyString(), anyInt(), anyInt(), isNull())).thenReturn(
+                classificationA, classificationB);
+
+        RemoteAction result = mClipboardUtils.getAction(mClipDataItem, "abc").orElse(null);
+
+        assertEquals(actionB, result);
     }
 
     @Test
@@ -92,7 +168,7 @@
         assertFalse(mClipboardUtils.isRemoteCopy(mContext, data, ""));
     }
 
-    static ClipData constructClipData(String[] mimeTypes, ClipData.Item item,
+    private static ClipData constructClipData(String[] mimeTypes, ClipData.Item item,
             PersistableBundle extras) {
         ClipDescription description = new ClipDescription("Test", mimeTypes);
         if (extras != null) {
@@ -100,4 +176,20 @@
         }
         return new ClipData(description, item);
     }
+
+    private static RemoteAction constructRemoteAction(String packageName) {
+        RemoteAction action = Mockito.mock(RemoteAction.class, Answers.RETURNS_DEEP_STUBS);
+        when(action.getActionIntent().getIntent().getComponent().getPackageName())
+                .thenReturn(packageName);
+        return action;
+    }
+
+    private static TextLinks getFakeTextLinks() {
+        TextLinks.Builder textLinks = new TextLinks.Builder("test");
+        final Map<String, Float> scores = new ArrayMap<>();
+        scores.put(TextClassifier.TYPE_EMAIL, 1f);
+        textLinks.addLink(0, 0, scores);
+        textLinks.addLink(0, 0, scores);
+        return textLinks.build();
+    }
 }