Add Start Activity API for TileServices

This API allows developers to add a pendingIntent to a CustomTile, which
will allow the tile to start a new activity.

There are two ways of using this functionality:
1. Call TileService#startActivityAndCollapse(pendingIntent) to
   immediately start a new activity

2. Set a pendingIntent in advance with
   Tile#setActivityLaunchForClick(pendingIntent), and the activity will
   start when the user clicks on the tile

Bug: 241766793
Test: atest CustomTileTest
Test: atest TileServicesTest
Change-Id: I07ad73ae468eed7a03202babfaeab271d4f7f3ae
diff --git a/core/java/android/service/quicksettings/IQSService.aidl b/core/java/android/service/quicksettings/IQSService.aidl
index d03ff93..7b690ea 100644
--- a/core/java/android/service/quicksettings/IQSService.aidl
+++ b/core/java/android/service/quicksettings/IQSService.aidl
@@ -15,6 +15,7 @@
  */
 package android.service.quicksettings;
 
+import android.app.PendingIntent;
 import android.content.ComponentName;
 import android.graphics.drawable.Icon;
 import android.service.quicksettings.Tile;
@@ -29,10 +30,10 @@
             String contentDescription);
     void onShowDialog(in IBinder tile);
     void onStartActivity(in IBinder tile);
+    void startActivity(in IBinder tile, in PendingIntent pendingIntent);
     boolean isLocked();
     boolean isSecure();
     void startUnlockAndRun(in IBinder tile);
-
     void onDialogHidden(in IBinder tile);
     void onStartSuccessful(in IBinder tile);
 }
diff --git a/core/java/android/service/quicksettings/Tile.java b/core/java/android/service/quicksettings/Tile.java
index 40c0ac0..d60b225 100644
--- a/core/java/android/service/quicksettings/Tile.java
+++ b/core/java/android/service/quicksettings/Tile.java
@@ -16,6 +16,7 @@
 package android.service.quicksettings;
 
 import android.annotation.Nullable;
+import android.app.PendingIntent;
 import android.graphics.drawable.Icon;
 import android.os.IBinder;
 import android.os.Parcel;
@@ -66,6 +67,7 @@
     private CharSequence mSubtitle;
     private CharSequence mContentDescription;
     private CharSequence mStateDescription;
+    private PendingIntent mPendingIntent;
     // Default to inactive until clients of the new API can update.
     private int mState = STATE_INACTIVE;
 
@@ -223,6 +225,34 @@
         }
     }
 
+    /**
+     * Gets the Activity {@link PendingIntent} to be launched when the tile is clicked.
+     * @hide
+     */
+    @Nullable
+    public PendingIntent getActivityLaunchForClick() {
+        return mPendingIntent;
+    }
+
+    /**
+     * Sets an Activity {@link PendingIntent} to be launched when the tile is clicked.
+     *
+     * The last value set here will be launched when the user clicks in the tile, instead of
+     * forwarding the `onClick` message to the {@link TileService}. Set to {@code null} to handle
+     * the `onClick` in the `TileService`
+     * (This is the default behavior if this method is never called.)
+     * @param pendingIntent a PendingIntent for an activity to be launched onclick, or {@code null}
+     *                      to handle the clicks in the `TileService`.
+     * @hide
+     */
+    public void setActivityLaunchForClick(@Nullable PendingIntent pendingIntent) {
+        if (pendingIntent != null && !pendingIntent.isActivity()) {
+            throw new IllegalArgumentException();
+        } else {
+            mPendingIntent = pendingIntent;
+        }
+    }
+
     @Override
     public void writeToParcel(Parcel dest, int flags) {
         if (mIcon != null) {
@@ -231,6 +261,12 @@
         } else {
             dest.writeByte((byte) 0);
         }
+        if (mPendingIntent != null) {
+            dest.writeByte((byte) 1);
+            mPendingIntent.writeToParcel(dest, flags);
+        } else {
+            dest.writeByte((byte) 0);
+        }
         dest.writeInt(mState);
         TextUtils.writeToParcel(mLabel, dest, flags);
         TextUtils.writeToParcel(mSubtitle, dest, flags);
@@ -244,6 +280,11 @@
         } else {
             mIcon = null;
         }
+        if (source.readByte() != 0) {
+            mPendingIntent = PendingIntent.CREATOR.createFromParcel(source);
+        } else {
+            mPendingIntent = null;
+        }
         mState = source.readInt();
         mLabel = TextUtils.CHAR_SEQUENCE_CREATOR.createFromParcel(source);
         mSubtitle = TextUtils.CHAR_SEQUENCE_CREATOR.createFromParcel(source);
diff --git a/core/java/android/service/quicksettings/TileService.java b/core/java/android/service/quicksettings/TileService.java
index 8550219..506b3b8 100644
--- a/core/java/android/service/quicksettings/TileService.java
+++ b/core/java/android/service/quicksettings/TileService.java
@@ -20,6 +20,7 @@
 import android.annotation.SystemApi;
 import android.annotation.TestApi;
 import android.app.Dialog;
+import android.app.PendingIntent;
 import android.app.Service;
 import android.app.StatusBarManager;
 import android.content.ComponentName;
@@ -336,6 +337,20 @@
     }
 
     /**
+     * Starts an {@link android.app.Activity}.
+     * Will collapse Quick Settings after launching.
+     *
+     * @param pendingIntent A PendingIntent for an Activity to be launched immediately.
+     * @hide
+     */
+    public void startActivityAndCollapse(PendingIntent pendingIntent) {
+        try {
+            mService.startActivity(mTileToken, pendingIntent);
+        } catch (RemoteException e) {
+        }
+    }
+
+    /**
      * Gets the {@link Tile} for this service.
      * <p/>
      * This tile may be used to get or set the current state for this
diff --git a/packages/SystemUI/src/com/android/systemui/qs/external/CustomTile.java b/packages/SystemUI/src/com/android/systemui/qs/external/CustomTile.java
index c4386ab..cfda9fd 100644
--- a/packages/SystemUI/src/com/android/systemui/qs/external/CustomTile.java
+++ b/packages/SystemUI/src/com/android/systemui/qs/external/CustomTile.java
@@ -18,6 +18,7 @@
 import static android.view.Display.DEFAULT_DISPLAY;
 import static android.view.WindowManager.LayoutParams.TYPE_QS_DIALOG;
 
+import android.app.PendingIntent;
 import android.content.ComponentName;
 import android.content.Context;
 import android.content.Intent;
@@ -51,6 +52,7 @@
 import com.android.internal.annotations.VisibleForTesting;
 import com.android.internal.logging.MetricsLogger;
 import com.android.internal.logging.nano.MetricsProto.MetricsEvent;
+import com.android.systemui.animation.ActivityLaunchAnimator;
 import com.android.systemui.dagger.qualifiers.Background;
 import com.android.systemui.dagger.qualifiers.Main;
 import com.android.systemui.plugins.ActivityStarter;
@@ -92,6 +94,8 @@
     private android.graphics.drawable.Icon mDefaultIcon;
     @Nullable
     private CharSequence mDefaultLabel;
+    @Nullable
+    private View mViewClicked;
 
     private final Context mUserContext;
 
@@ -202,7 +206,7 @@
      * Compare two icons, only works for resources.
      */
     private boolean iconEquals(@Nullable android.graphics.drawable.Icon icon1,
-            @Nullable android.graphics.drawable.Icon icon2) {
+                               @Nullable android.graphics.drawable.Icon icon2) {
         if (icon1 == icon2) {
             return true;
         }
@@ -229,7 +233,7 @@
 
     /**
      * Custom tile is considered available if there is a default icon (obtained from PM).
-     *
+     * <p>
      * It will return {@code true} before initialization, so tiles are not destroyed prematurely.
      */
     @Override
@@ -262,6 +266,7 @@
 
     /**
      * Update state of {@link this#mTile} from a remote {@link TileService}.
+     *
      * @param tile tile populated with state to apply
      */
     public void updateTileState(Tile tile) {
@@ -293,6 +298,7 @@
         if (tile.getStateDescription() != null || overwriteNulls) {
             mTile.setStateDescription(tile.getStateDescription());
         }
+        mTile.setActivityLaunchForClick(tile.getActivityLaunchForClick());
         mTile.setState(tile.getState());
     }
 
@@ -324,6 +330,7 @@
                     mService.onStartListening();
                 }
             } else {
+                mViewClicked = null;
                 mService.onStopListening();
                 if (mIsTokenGranted && !mIsShowingDialog) {
                     try {
@@ -388,6 +395,7 @@
         if (mTile.getState() == Tile.STATE_UNAVAILABLE) {
             return;
         }
+        mViewClicked = view;
         try {
             if (DEBUG) Log.d(TAG, "Adding token");
             mWindowManager.addWindowToken(mToken, TYPE_QS_DIALOG, DEFAULT_DISPLAY,
@@ -400,7 +408,12 @@
                 mServiceManager.setBindRequested(true);
                 mService.onStartListening();
             }
-            mService.onClick(mToken);
+
+            if (mTile.getActivityLaunchForClick() != null) {
+                startActivityAndCollapse(mTile.getActivityLaunchForClick());
+            } else {
+                mService.onClick(mToken);
+            }
         } catch (RemoteException e) {
             // Called through wrapper, won't happen here.
         }
@@ -483,6 +496,27 @@
         });
     }
 
+    /**
+     * Starts an {@link android.app.Activity}
+     * @param pendingIntent A PendingIntent for an Activity to be launched immediately.
+     */
+    public void startActivityAndCollapse(PendingIntent pendingIntent) {
+        if (!pendingIntent.isActivity()) {
+            Log.i(TAG, "Intent not for activity.");
+        } else if (!mIsTokenGranted) {
+            Log.i(TAG, "Launching activity before click");
+        } else {
+            Log.i(TAG, "The activity is starting");
+            ActivityLaunchAnimator.Controller controller = mViewClicked == null
+                    ? null
+                    : ActivityLaunchAnimator.Controller.fromView(mViewClicked, 0);
+            mUiHandler.post(() ->
+                    mActivityStarter.startPendingIntentDismissingKeyguard(
+                            pendingIntent, null, controller)
+            );
+        }
+    }
+
     public static String toSpec(ComponentName name) {
         return PREFIX + name.flattenToShortString() + ")";
     }
@@ -509,8 +543,8 @@
     /**
      * Create a {@link CustomTile} for a given spec and user.
      *
-     * @param builder including injected common dependencies.
-     * @param spec as provided by {@link CustomTile#toSpec}
+     * @param builder     including injected common dependencies.
+     * @param spec        as provided by {@link CustomTile#toSpec}
      * @param userContext context for the user that is creating this tile.
      * @return a new {@link CustomTile}
      */
diff --git a/packages/SystemUI/src/com/android/systemui/qs/external/TileServices.java b/packages/SystemUI/src/com/android/systemui/qs/external/TileServices.java
index 5d03da3..3d48fd1 100644
--- a/packages/SystemUI/src/com/android/systemui/qs/external/TileServices.java
+++ b/packages/SystemUI/src/com/android/systemui/qs/external/TileServices.java
@@ -15,6 +15,7 @@
  */
 package com.android.systemui.qs.external;
 
+import android.app.PendingIntent;
 import android.content.ComponentName;
 import android.content.Context;
 import android.content.pm.PackageInfo;
@@ -32,6 +33,7 @@
 
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
+import androidx.annotation.VisibleForTesting;
 
 import com.android.internal.statusbar.StatusBarIcon;
 import com.android.systemui.broadcast.BroadcastDispatcher;
@@ -276,6 +278,19 @@
     }
 
     @Override
+    public void startActivity(IBinder token, PendingIntent pendingIntent) {
+        startActivity(getTileForToken(token), pendingIntent);
+    }
+
+    @VisibleForTesting
+    protected void startActivity(CustomTile customTile, PendingIntent pendingIntent) {
+        if (customTile != null) {
+            verifyCaller(customTile);
+            customTile.startActivityAndCollapse(pendingIntent);
+        }
+    }
+
+    @Override
     public void updateStatusIcon(IBinder token, Icon icon, String contentDescription) {
         CustomTile customTile = getTileForToken(token);
         if (customTile != null) {
@@ -336,7 +351,7 @@
     }
 
     @Nullable
-    private CustomTile getTileForToken(IBinder token) {
+    public CustomTile getTileForToken(IBinder token) {
         synchronized (mServices) {
             return mTokenMap.get(token);
         }
diff --git a/packages/SystemUI/tests/src/com/android/systemui/qs/external/CustomTileTest.kt b/packages/SystemUI/tests/src/com/android/systemui/qs/external/CustomTileTest.kt
index f3fcdbf..2bd068a 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/qs/external/CustomTileTest.kt
+++ b/packages/SystemUI/tests/src/com/android/systemui/qs/external/CustomTileTest.kt
@@ -16,6 +16,7 @@
 
 package com.android.systemui.qs.external
 
+import android.app.PendingIntent
 import android.content.ComponentName
 import android.content.Context
 import android.content.pm.ApplicationInfo
@@ -30,8 +31,10 @@
 import android.testing.AndroidTestingRunner
 import android.testing.TestableLooper
 import android.view.IWindowManager
+import android.view.View
 import com.android.internal.logging.MetricsLogger
 import com.android.systemui.SysuiTestCase
+import com.android.systemui.animation.ActivityLaunchAnimator
 import com.android.systemui.classifier.FalsingManagerFake
 import com.android.systemui.plugins.ActivityStarter
 import com.android.systemui.plugins.qs.QSTile
@@ -39,8 +42,11 @@
 import com.android.systemui.qs.QSHost
 import com.android.systemui.qs.logging.QSLogger
 import com.android.systemui.util.mockito.any
+import com.android.systemui.util.mockito.eq
+import com.android.systemui.util.mockito.nullable
 import org.junit.Assert.assertEquals
 import org.junit.Assert.assertFalse
+import org.junit.Assert.assertThrows
 import org.junit.Assert.assertTrue
 import org.junit.Before
 import org.junit.Test
@@ -236,6 +242,10 @@
         `when`(tile.qsTile.icon.loadDrawable(any(Context::class.java)))
                 .thenReturn(mock(Drawable::class.java))
 
+        val pi = mock(PendingIntent::class.java)
+        `when`(pi.isActivity).thenReturn(true)
+        tile.qsTile.activityLaunchForClick = pi
+
         tile.refreshState()
 
         testableLooper.processAllMessages()
@@ -289,4 +299,52 @@
         assertFalse(tile.isAvailable)
         verify(tileHost).removeTile(tile.tileSpec)
     }
+
+    @Test
+    fun testInvalidPendingIntentDoesNotStartActivity() {
+        val pi = mock(PendingIntent::class.java)
+        `when`(pi.isActivity).thenReturn(false)
+        val tile = CustomTile.create(customTileBuilder, TILE_SPEC, mContext)
+
+        assertThrows(IllegalArgumentException::class.java) {
+            tile.qsTile.activityLaunchForClick = pi
+        }
+
+        tile.handleClick(mock(View::class.java))
+        testableLooper.processAllMessages()
+
+        verify(activityStarter, never())
+            .startPendingIntentDismissingKeyguard(
+                any(), any(), any(ActivityLaunchAnimator.Controller::class.java))
+    }
+
+    @Test
+    fun testValidPendingIntentWithNoClickDoesNotStartActivity() {
+        val pi = mock(PendingIntent::class.java)
+        `when`(pi.isActivity).thenReturn(true)
+        val tile = CustomTile.create(customTileBuilder, TILE_SPEC, mContext)
+        tile.qsTile.activityLaunchForClick = pi
+
+        testableLooper.processAllMessages()
+
+        verify(activityStarter, never())
+            .startPendingIntentDismissingKeyguard(
+                any(), any(), any(ActivityLaunchAnimator.Controller::class.java))
+    }
+
+    @Test
+    fun testValidPendingIntentStartsActivity() {
+        val pi = mock(PendingIntent::class.java)
+        `when`(pi.isActivity).thenReturn(true)
+        val tile = CustomTile.create(customTileBuilder, TILE_SPEC, mContext)
+        tile.qsTile.activityLaunchForClick = pi
+
+        tile.handleClick(mock(View::class.java))
+
+        testableLooper.processAllMessages()
+
+        verify(activityStarter)
+            .startPendingIntentDismissingKeyguard(
+                eq(pi), nullable(), nullable<ActivityLaunchAnimator.Controller>())
+    }
 }
\ No newline at end of file
diff --git a/packages/SystemUI/tests/src/com/android/systemui/qs/external/TileServicesTest.java b/packages/SystemUI/tests/src/com/android/systemui/qs/external/TileServicesTest.java
index 25c95ef..172c87f 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/qs/external/TileServicesTest.java
+++ b/packages/SystemUI/tests/src/com/android/systemui/qs/external/TileServicesTest.java
@@ -26,6 +26,7 @@
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
+import android.app.PendingIntent;
 import android.content.ComponentName;
 import android.content.Intent;
 import android.os.Handler;
@@ -58,6 +59,7 @@
 import com.android.systemui.util.settings.SecureSettings;
 
 import org.junit.After;
+import org.junit.Assert;
 import org.junit.Before;
 import org.junit.Test;
 import org.junit.runner.RunWith;
@@ -245,6 +247,32 @@
         verify(manager.getTileService()).onStartListening();
     }
 
+    @Test
+    public void testValidCustomTileStartsActivity() {
+        CustomTile tile = mock(CustomTile.class);
+        PendingIntent pi = mock(PendingIntent.class);
+        ComponentName componentName = mock(ComponentName.class);
+        when(tile.getComponent()).thenReturn(componentName);
+        when(componentName.getPackageName()).thenReturn(this.getContext().getPackageName());
+
+        mTileService.startActivity(tile, pi);
+
+        verify(tile).startActivityAndCollapse(pi);
+    }
+
+    @Test
+    public void testInvalidCustomTileDoesNotStartActivity() {
+        CustomTile tile = mock(CustomTile.class);
+        PendingIntent pi = mock(PendingIntent.class);
+        ComponentName componentName = mock(ComponentName.class);
+        when(tile.getComponent()).thenReturn(componentName);
+        when(componentName.getPackageName()).thenReturn("invalid.package.name");
+
+        Assert.assertThrows(SecurityException.class, () -> mTileService.startActivity(tile, pi));
+
+        verify(tile, never()).startActivityAndCollapse(pi);
+    }
+
     private class TestTileServices extends TileServices {
         TestTileServices(QSTileHost host, Provider<Handler> handlerProvider,
                 BroadcastDispatcher broadcastDispatcher, UserTracker userTracker,