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,