diff --git a/api/current.txt b/api/current.txt index 8c1f5a07d84bf..7cc40487b279c 100644 --- a/api/current.txt +++ b/api/current.txt @@ -12457,6 +12457,7 @@ package android.content.pm { method @NonNull public java.util.List getShortcuts(int); method public boolean isRateLimitingActive(); method public boolean isRequestPinShortcutSupported(); + method public void pushDynamicShortcut(@NonNull android.content.pm.ShortcutInfo); method public void removeAllDynamicShortcuts(); method public void removeDynamicShortcuts(@NonNull java.util.List); method public void removeLongLivedShortcuts(@NonNull java.util.List); diff --git a/core/java/android/content/pm/IShortcutService.aidl b/core/java/android/content/pm/IShortcutService.aidl index 9e85fc301a0c3..29a55b7a74daf 100644 --- a/core/java/android/content/pm/IShortcutService.aidl +++ b/core/java/android/content/pm/IShortcutService.aidl @@ -76,4 +76,6 @@ interface IShortcutService { void removeLongLivedShortcuts(String packageName, in List shortcutIds, int userId); ParceledListSlice getShortcuts(String packageName, int matchFlags, int userId); + + void pushDynamicShortcut(String packageName, in ShortcutInfo shortcut, int userId); } \ No newline at end of file diff --git a/core/java/android/content/pm/ShortcutInfo.java b/core/java/android/content/pm/ShortcutInfo.java index bde4f614a39e3..49e8c052cbca7 100644 --- a/core/java/android/content/pm/ShortcutInfo.java +++ b/core/java/android/content/pm/ShortcutInfo.java @@ -1726,11 +1726,11 @@ public final class ShortcutInfo implements Parcelable { } /** - * @return true if pinned but neither static nor dynamic. + * @return true if pinned or cached, but neither static nor dynamic. * @hide */ public boolean isFloating() { - return isPinned() && !(isDynamic() || isManifestShortcut()); + return (isPinned() || isCached()) && !(isDynamic() || isManifestShortcut()); } /** @hide */ diff --git a/core/java/android/content/pm/ShortcutManager.java b/core/java/android/content/pm/ShortcutManager.java index 3eea3f62fd468..35c99a13a1524 100644 --- a/core/java/android/content/pm/ShortcutManager.java +++ b/core/java/android/content/pm/ShortcutManager.java @@ -24,6 +24,7 @@ import android.annotation.SystemApi; import android.annotation.SystemService; import android.annotation.TestApi; import android.annotation.UserIdInt; +import android.app.Notification; import android.app.usage.UsageStatsManager; import android.compat.annotation.UnsupportedAppUsage; import android.content.ComponentName; @@ -741,4 +742,33 @@ public class ShortcutManager { throw e.rethrowFromSystemServer(); } } + + /** + * Publish a single dynamic shortcut. If there are already dynamic or pinned shortcuts with the + * same ID, each mutable shortcut is updated. + * + *

This method is useful when posting notifications which are tagged with shortcut IDs; In + * order to make sure shortcuts exist and are up-to-date, without the need to explicitly handle + * the shortcut count limit. + * @see android.app.NotificationManager#notify(int, Notification) + * @see Notification.Builder#setShortcutId(String) + * + *

If {@link #getMaxShortcutCountPerActivity()} is already reached, an existing shortcut with + * the lowest rank will be removed to add space for the new shortcut. + * + *

If the rank of the shortcut is not explicitly set, it will be set to zero, and shortcut + * will be added to the top of the list. + * + * @throws IllegalArgumentException if trying to update an immutable shortcut. + * + * @throws IllegalStateException when the user is locked. + */ + public void pushDynamicShortcut(@NonNull ShortcutInfo shortcut) { + try { + mService.pushDynamicShortcut(mContext.getPackageName(), shortcut, injectMyUserId()); + } catch (RemoteException e) { + throw e.rethrowFromSystemServer(); + } + } + } diff --git a/services/core/java/com/android/server/pm/ShortcutPackage.java b/services/core/java/com/android/server/pm/ShortcutPackage.java index c37ceb3bc1b0a..2de9858d91f91 100644 --- a/services/core/java/com/android/server/pm/ShortcutPackage.java +++ b/services/core/java/com/android/server/pm/ShortcutPackage.java @@ -309,6 +309,60 @@ class ShortcutPackage extends ShortcutPackageItem { forceReplaceShortcutInner(newShortcut); } + /** + * Push a shortcut. If the max number of dynamic shortcuts is already reached, remove the + * shortcut with the lowest rank before adding the new shortcut. + */ + public boolean pushDynamicShortcut(@NonNull ShortcutInfo newShortcut) { + Preconditions.checkArgument(newShortcut.isEnabled(), + "pushDynamicShortcuts() cannot publish disabled shortcuts"); + + newShortcut.addFlags(ShortcutInfo.FLAG_DYNAMIC); + + final ShortcutInfo oldShortcut = mShortcuts.get(newShortcut.getId()); + boolean wasPinned = false; + + if (oldShortcut == null) { + final ShortcutService service = mShortcutUser.mService; + final int maxShortcuts = service.getMaxActivityShortcuts(); + + final ArrayMap> all = + sortShortcutsToActivities(); + final ArrayList activityShortcuts = all.get(newShortcut.getActivity()); + + if (activityShortcuts != null && activityShortcuts.size() == maxShortcuts) { + // Max has reached. Delete the shortcut with lowest rank. + + // Sort by isManifestShortcut() and getRank(). + Collections.sort(activityShortcuts, mShortcutTypeAndRankComparator); + + final ShortcutInfo shortcut = activityShortcuts.get(maxShortcuts - 1); + if (shortcut.isManifestShortcut()) { + // All shortcuts are manifest shortcuts and cannot be removed. + Slog.e(TAG, "Failed to remove manifest shortcut while pushing dynamic shortcut " + + newShortcut.getId()); + return false; + } + + deleteDynamicWithId(shortcut.getId(), /*ignoreInvisible=*/ true); + } + } else { + // It's an update case. + // Make sure the target is updatable. (i.e. should be mutable.) + oldShortcut.ensureUpdatableWith(newShortcut, /*isUpdating=*/ false); + + wasPinned = oldShortcut.isPinned(); + } + + // If it was originally pinned, the new one should be pinned too. + if (wasPinned) { + newShortcut.addFlags(ShortcutInfo.FLAG_PINNED); + } + + forceReplaceShortcutInner(newShortcut); + return true; + } + /** * Remove all shortcuts that aren't pinned, cached nor dynamic. */ diff --git a/services/core/java/com/android/server/pm/ShortcutService.java b/services/core/java/com/android/server/pm/ShortcutService.java index 377fd16d4e191..b2638735bce74 100644 --- a/services/core/java/com/android/server/pm/ShortcutService.java +++ b/services/core/java/com/android/server/pm/ShortcutService.java @@ -133,6 +133,7 @@ import java.lang.annotation.RetentionPolicy; import java.net.URISyntaxException; import java.nio.charset.StandardCharsets; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.Objects; @@ -1945,6 +1946,50 @@ public class ShortcutService extends IShortcutService.Stub { return true; } + @Override + public void pushDynamicShortcut(String packageName, ShortcutInfo shortcut, + @UserIdInt int userId) { + verifyCaller(packageName, userId); + verifyShortcutInfoPackage(packageName, shortcut); + + final boolean unlimited = injectHasUnlimitedShortcutsApiCallsPermission( + injectBinderCallingPid(), injectBinderCallingUid()); + + synchronized (mLock) { + throwIfUserLockedL(userId); + + final ShortcutPackage ps = getPackageShortcutsForPublisherLocked(packageName, userId); + + ps.ensureNotImmutable(shortcut.getId(), /*ignoreInvisible=*/ true); + fillInDefaultActivity(Arrays.asList(shortcut)); + + if (!shortcut.hasRank()) { + shortcut.setRank(0); + } + // Initialize the implicit ranks for ShortcutPackage.adjustRanks(). + ps.clearAllImplicitRanks(); + shortcut.setImplicitRank(0); + + // Validate the shortcut. + fixUpIncomingShortcutInfo(shortcut, /* forUpdate= */ false); + + // When ranks are changing, we need to insert between ranks, so set the + // "rank changed" flag. + shortcut.setRankChanged(); + + // Push it. + if (!ps.pushDynamicShortcut(shortcut)) { + return; + } + + // Lastly, adjust the ranks. + ps.adjustRanks(); + } + packageShortcutsChanged(packageName, userId); + + verifyStates(); + } + @Override public boolean requestPinShortcut(String packageName, ShortcutInfo shortcut, IntentSender resultIntent, int userId) { diff --git a/services/tests/servicestests/src/com/android/server/pm/ShortcutManagerTest1.java b/services/tests/servicestests/src/com/android/server/pm/ShortcutManagerTest1.java index 2936bdd8501ba..56460fb6f0a0a 100644 --- a/services/tests/servicestests/src/com/android/server/pm/ShortcutManagerTest1.java +++ b/services/tests/servicestests/src/com/android/server/pm/ShortcutManagerTest1.java @@ -379,6 +379,113 @@ public class ShortcutManagerTest1 extends BaseShortcutManagerTest { }); } + public void testPushDynamicShortcut() { + + setCaller(CALLING_PACKAGE_1, USER_0); + + final ShortcutInfo s1 = makeShortcut("s1"); + final ShortcutInfo s2 = makeShortcut("s2"); + final ShortcutInfo s3 = makeShortcut("s3"); + final ShortcutInfo s4 = makeShortcut("s4"); + + final ShortcutInfo s10 = makeShortcut("s10"); + final ShortcutInfo s11 = makeShortcut("s11"); + final ShortcutInfo s12 = makeShortcut("s12"); + final ShortcutInfo s13 = makeShortcut("s13"); + final ShortcutInfo s14 = makeShortcut("s14"); + + // Test push as first shortcut + mManager.pushDynamicShortcut(s1); + assertShortcutIds(assertAllNotKeyFieldsOnly(mManager.getDynamicShortcuts()), "s1"); + assertEquals(0, getCallerShortcut("s1").getRank()); + + // Test push when other shortcuts exist + assertTrue(mManager.setDynamicShortcuts(list(s1, s2))); + assertShortcutIds(assertAllNotKeyFieldsOnly(mManager.getDynamicShortcuts()), "s1", "s2"); + mManager.pushDynamicShortcut(s3); + assertShortcutIds(assertAllNotKeyFieldsOnly(mManager.getDynamicShortcuts()), + "s1", "s2", "s3"); + assertEquals(0, getCallerShortcut("s3").getRank()); + assertEquals(1, getCallerShortcut("s1").getRank()); + assertEquals(2, getCallerShortcut("s2").getRank()); + + mInjectedCurrentTimeMillis += INTERVAL; // reset + + // Push with set rank + s4.setRank(2); + mManager.pushDynamicShortcut(s4); + assertEquals(2, getCallerShortcut("s4").getRank()); + assertEquals(3, getCallerShortcut("s2").getRank()); + + // Push existing shortcut with set rank + final ShortcutInfo s4_2 = makeShortcut("s4"); + s4_2.setRank(4); + mManager.pushDynamicShortcut(s4_2); + assertEquals(2, getCallerShortcut("s2").getRank()); + assertEquals(3, getCallerShortcut("s4").getRank()); + + mInjectedCurrentTimeMillis += INTERVAL; // reset + + // Test push as last + assertTrue(mManager.addDynamicShortcuts(makeShortcuts("s5", "s6", "s7", "s8", "s9"))); + mManager.pushDynamicShortcut(s10); + assertShortcutIds(assertAllNotKeyFieldsOnly(mManager.getDynamicShortcuts()), + "s1", "s2", "s3", "s4", "s5", "s6", "s7", "s8", "s9", "s10"); + assertEquals(0, getCallerShortcut("s10").getRank()); + assertEquals(1, getCallerShortcut("s5").getRank()); + assertEquals(6, getCallerShortcut("s3").getRank()); + assertEquals(7, getCallerShortcut("s1").getRank()); + assertEquals(8, getCallerShortcut("s2").getRank()); + assertEquals(9, getCallerShortcut("s4").getRank()); + + // Push when max has already reached + mManager.pushDynamicShortcut(s11); + assertShortcutIds(assertAllNotKeyFieldsOnly(mManager.getDynamicShortcuts()), + "s1", "s2", "s3", "s5", "s6", "s7", "s8", "s9", "s10", "s11"); + assertEquals(0, getCallerShortcut("s11").getRank()); + assertEquals(1, getCallerShortcut("s10").getRank()); + assertEquals(9, getCallerShortcut("s2").getRank()); + + mInjectedCurrentTimeMillis += INTERVAL; // reset + + // Push with different activity + s12.setActivity(makeComponent(ShortcutActivity2.class)); + mManager.pushDynamicShortcut(s12); + assertEquals(makeComponent(ShortcutActivity2.class), + getCallerShortcut("s12").getActivity()); + assertEquals(0, getCallerShortcut("s12").getRank()); + + // Push to update shortcut with different activity + final ShortcutInfo s1_2 = makeShortcut("s1"); + s1_2.setActivity(makeComponent(ShortcutActivity2.class)); + s1_2.setRank(1); + mManager.pushDynamicShortcut(s1_2); + assertEquals(0, getCallerShortcut("s12").getRank()); + assertEquals(1, getCallerShortcut("s1").getRank()); + assertEquals(0, getCallerShortcut("s11").getRank()); + assertEquals(1, getCallerShortcut("s10").getRank()); + assertEquals(7, getCallerShortcut("s3").getRank()); + assertEquals(8, getCallerShortcut("s2").getRank()); + + mInjectedCurrentTimeMillis += INTERVAL; // reset + + // Test push when dropped shortcut is cached + s13.setLongLived(); + s13.setRank(100); + mManager.pushDynamicShortcut(s13); + assertEquals(9, getCallerShortcut("s13").getRank()); + runWithCaller(LAUNCHER_1, USER_0, () -> { + mLauncherApps.cacheShortcuts(CALLING_PACKAGE_1, list("s13"), HANDLE_USER_0); + }); + + mManager.pushDynamicShortcut(s14); + assertShortcutIds(assertAllNotKeyFieldsOnly(mManager.getDynamicShortcuts()), + "s1", "s2", "s3", "s5", "s6", "s7", "s8", "s9", "s10", "s11", "s12", "s14"); + // Verify s13 stayed as cached + assertShortcutIds(mManager.getShortcuts(ShortcutManager.FLAG_MATCH_CACHED), + "s13"); + } + public void testUnlimitedCalls() { setCaller(CALLING_PACKAGE_1, USER_0);