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
This commit is contained in:
Rasheed Lewis
2022-11-23 15:48:03 +00:00
parent 96f5e4321e
commit e00edd6df6
7 changed files with 199 additions and 7 deletions

View File

@@ -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 @@ interface IQSService {
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);
}

View File

@@ -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 @@ public final class Tile implements Parcelable {
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 @@ public final class Tile implements Parcelable {
}
}
/**
* 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 @@ public final class Tile implements Parcelable {
} 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 @@ public final class Tile implements Parcelable {
} 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);

View File

@@ -20,6 +20,7 @@ import android.annotation.SdkConstant.SdkConstantType;
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;
@@ -335,6 +336,20 @@ public class TileService extends Service {
}
}
/**
* 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/>

View File

@@ -18,6 +18,7 @@ package com.android.systemui.qs.external;
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 androidx.annotation.WorkerThread;
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 @@ public class CustomTile extends QSTileImpl<State> implements TileChangeListener
private android.graphics.drawable.Icon mDefaultIcon;
@Nullable
private CharSequence mDefaultLabel;
@Nullable
private View mViewClicked;
private final Context mUserContext;
@@ -202,7 +206,7 @@ public class CustomTile extends QSTileImpl<State> implements TileChangeListener
* 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 @@ public class CustomTile extends QSTileImpl<State> implements TileChangeListener
/**
* 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 @@ public class CustomTile extends QSTileImpl<State> implements TileChangeListener
/**
* 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 @@ public class CustomTile extends QSTileImpl<State> implements TileChangeListener
if (tile.getStateDescription() != null || overwriteNulls) {
mTile.setStateDescription(tile.getStateDescription());
}
mTile.setActivityLaunchForClick(tile.getActivityLaunchForClick());
mTile.setState(tile.getState());
}
@@ -324,6 +330,7 @@ public class CustomTile extends QSTileImpl<State> implements TileChangeListener
mService.onStartListening();
}
} else {
mViewClicked = null;
mService.onStopListening();
if (mIsTokenGranted && !mIsShowingDialog) {
try {
@@ -388,6 +395,7 @@ public class CustomTile extends QSTileImpl<State> implements TileChangeListener
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 @@ public class CustomTile extends QSTileImpl<State> implements TileChangeListener
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 @@ public class CustomTile extends QSTileImpl<State> implements TileChangeListener
});
}
/**
* 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 @@ public class CustomTile extends QSTileImpl<State> implements TileChangeListener
/**
* 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}
*/

View File

@@ -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 android.util.Log;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import com.android.internal.statusbar.StatusBarIcon;
import com.android.systemui.broadcast.BroadcastDispatcher;
@@ -275,6 +277,19 @@ public class TileServices extends IQSService.Stub {
}
}
@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);
@@ -336,7 +351,7 @@ public class TileServices extends IQSService.Stub {
}
@Nullable
private CustomTile getTileForToken(IBinder token) {
public CustomTile getTileForToken(IBinder token) {
synchronized (mServices) {
return mTokenMap.get(token);
}

View File

@@ -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.test.suitebuilder.annotation.SmallTest
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.plugins.statusbar.StatusBarStateController
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 @@ class CustomTileTest : SysuiTestCase() {
`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 @@ class CustomTileTest : SysuiTestCase() {
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>())
}
}

View File

@@ -26,6 +26,7 @@ import static org.mockito.Mockito.times;
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.tuner.TunerService;
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 @@ public class TileServicesTest extends SysuiTestCase {
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,