diff --git a/api/current.txt b/api/current.txt index 0f8503653e472..fc19ed206dc72 100644 --- a/api/current.txt +++ b/api/current.txt @@ -9994,6 +9994,7 @@ package android.content.pm { method public int getWeight(); method public boolean hasIconFile(); method public boolean hasIconResource(); + method public boolean hasKeyFieldsOnly(); method public boolean isDynamic(); method public boolean isPinned(); method public void writeToParcel(android.os.Parcel, int); @@ -10004,6 +10005,7 @@ package android.content.pm { field public static final int FLAG_DYNAMIC = 1; // 0x1 field public static final int FLAG_HAS_ICON_FILE = 8; // 0x8 field public static final int FLAG_HAS_ICON_RES = 4; // 0x4 + field public static final int FLAG_KEY_FIELDS_ONLY = 16; // 0x10 field public static final int FLAG_PINNED = 2; // 0x2 } @@ -10024,6 +10026,7 @@ package android.content.pm { method public void deleteAllDynamicShortcuts(); method public void deleteDynamicShortcut(java.lang.String); method public java.util.List getDynamicShortcuts(); + method public int getIconMaxDimensions(); method public int getMaxDynamicShortcutCount(); method public java.util.List getPinnedShortcuts(); method public long getRateLimitResetTime(); @@ -29030,7 +29033,6 @@ package android.os { ctor public PersistableBundle(); ctor public PersistableBundle(int); ctor public PersistableBundle(android.os.PersistableBundle); - ctor public PersistableBundle(android.os.Bundle); method public java.lang.Object clone(); method public int describeContents(); method public android.os.PersistableBundle getPersistableBundle(java.lang.String); diff --git a/api/system-current.txt b/api/system-current.txt index d93bd5fb11d79..5b13c947f18ee 100644 --- a/api/system-current.txt +++ b/api/system-current.txt @@ -10388,6 +10388,7 @@ package android.content.pm { method public int getWeight(); method public boolean hasIconFile(); method public boolean hasIconResource(); + method public boolean hasKeyFieldsOnly(); method public boolean isDynamic(); method public boolean isPinned(); method public void writeToParcel(android.os.Parcel, int); @@ -10398,6 +10399,7 @@ package android.content.pm { field public static final int FLAG_DYNAMIC = 1; // 0x1 field public static final int FLAG_HAS_ICON_FILE = 8; // 0x8 field public static final int FLAG_HAS_ICON_RES = 4; // 0x4 + field public static final int FLAG_KEY_FIELDS_ONLY = 16; // 0x10 field public static final int FLAG_PINNED = 2; // 0x2 } @@ -10418,6 +10420,7 @@ package android.content.pm { method public void deleteAllDynamicShortcuts(); method public void deleteDynamicShortcut(java.lang.String); method public java.util.List getDynamicShortcuts(); + method public int getIconMaxDimensions(); method public int getMaxDynamicShortcutCount(); method public java.util.List getPinnedShortcuts(); method public long getRateLimitResetTime(); @@ -31310,7 +31313,6 @@ package android.os { ctor public PersistableBundle(); ctor public PersistableBundle(int); ctor public PersistableBundle(android.os.PersistableBundle); - ctor public PersistableBundle(android.os.Bundle); method public java.lang.Object clone(); method public int describeContents(); method public android.os.PersistableBundle getPersistableBundle(java.lang.String); diff --git a/api/test-current.txt b/api/test-current.txt index 3251377f1a482..03dc35367d4db 100644 --- a/api/test-current.txt +++ b/api/test-current.txt @@ -10004,6 +10004,7 @@ package android.content.pm { method public int getWeight(); method public boolean hasIconFile(); method public boolean hasIconResource(); + method public boolean hasKeyFieldsOnly(); method public boolean isDynamic(); method public boolean isPinned(); method public void writeToParcel(android.os.Parcel, int); @@ -10014,6 +10015,7 @@ package android.content.pm { field public static final int FLAG_DYNAMIC = 1; // 0x1 field public static final int FLAG_HAS_ICON_FILE = 8; // 0x8 field public static final int FLAG_HAS_ICON_RES = 4; // 0x4 + field public static final int FLAG_KEY_FIELDS_ONLY = 16; // 0x10 field public static final int FLAG_PINNED = 2; // 0x2 } @@ -10034,6 +10036,7 @@ package android.content.pm { method public void deleteAllDynamicShortcuts(); method public void deleteDynamicShortcut(java.lang.String); method public java.util.List getDynamicShortcuts(); + method public int getIconMaxDimensions(); method public int getMaxDynamicShortcutCount(); method public java.util.List getPinnedShortcuts(); method public long getRateLimitResetTime(); @@ -29096,7 +29099,6 @@ package android.os { ctor public PersistableBundle(); ctor public PersistableBundle(int); ctor public PersistableBundle(android.os.PersistableBundle); - ctor public PersistableBundle(android.os.Bundle); method public java.lang.Object clone(); method public int describeContents(); method public android.os.PersistableBundle getPersistableBundle(java.lang.String); diff --git a/core/java/android/app/SystemServiceRegistry.java b/core/java/android/app/SystemServiceRegistry.java index 3a5dd30bfda71..bd321ac4fe80c 100644 --- a/core/java/android/app/SystemServiceRegistry.java +++ b/core/java/android/app/SystemServiceRegistry.java @@ -753,12 +753,11 @@ final class SystemServiceRegistry { registerService(Context.SHORTCUT_SERVICE, ShortcutManager.class, new CachedServiceFetcher() { - @Override - public ShortcutManager createService(ContextImpl ctx) { - IBinder b = ServiceManager.getService(Context.SHORTCUT_SERVICE); - return new ShortcutManager(ctx, - IShortcutService.Stub.asInterface(b)); - }}); + @Override + public ShortcutManager createService(ContextImpl ctx) { + IBinder b = ServiceManager.getService(Context.SHORTCUT_SERVICE); + return new ShortcutManager(ctx, IShortcutService.Stub.asInterface(b)); + }}); } /** diff --git a/core/java/android/content/pm/ILauncherApps.aidl b/core/java/android/content/pm/ILauncherApps.aidl index cf3e298fd6d70..b1d3f207f82f6 100644 --- a/core/java/android/content/pm/ILauncherApps.aidl +++ b/core/java/android/content/pm/ILauncherApps.aidl @@ -26,6 +26,8 @@ import android.content.pm.ShortcutInfo; import android.graphics.Rect; import android.os.Bundle; import android.os.UserHandle; +import android.os.ParcelFileDescriptor; + import java.util.List; /** @@ -52,4 +54,8 @@ interface ILauncherApps { in UserHandle user); boolean startShortcut(String callingPackage, String packageName, String id, in Rect sourceBounds, in Bundle startActivityOptions, in UserHandle user); + + int getShortcutIconResId(String callingPackage, in ShortcutInfo shortcut, in UserHandle user); + ParcelFileDescriptor getShortcutIconFd(String callingPackage, in ShortcutInfo shortcut, + in UserHandle user); } diff --git a/core/java/android/content/pm/IShortcutService.aidl b/core/java/android/content/pm/IShortcutService.aidl index 23e671d82f123..8f9dcfc15d632 100644 --- a/core/java/android/content/pm/IShortcutService.aidl +++ b/core/java/android/content/pm/IShortcutService.aidl @@ -44,5 +44,7 @@ interface IShortcutService { long getRateLimitResetTime(String packageName, int userId); + int getIconMaxDimensions(String packageName, int userId); + void resetThrottling(); // system only API for developer opsions } \ No newline at end of file diff --git a/core/java/android/content/pm/LauncherApps.java b/core/java/android/content/pm/LauncherApps.java index 8e4a0e20097ee..a6a732ea95135 100644 --- a/core/java/android/content/pm/LauncherApps.java +++ b/core/java/android/content/pm/LauncherApps.java @@ -183,7 +183,8 @@ public class LauncherApps { public static final int FLAG_GET_PINNED = 1 << 1; /** - * Requests "key" fields only. + * Requests "key" fields only. See {@link ShortcutInfo#hasKeyFieldsOnly()} for which + * fields are available. */ public static final int FLAG_GET_KEY_FIELDS_ONLY = 1 << 2; @@ -473,7 +474,11 @@ public class LauncherApps { */ @RequiresPermission(permission.BIND_APPWIDGET) public int getShortcutIconResId(@NonNull ShortcutInfo shortcut, @NonNull UserHandle user) { - throw new RuntimeException("not implemented yet"); + try { + return mService.getShortcutIconResId(mContext.getPackageName(), shortcut, user); + } catch (RemoteException e) { + throw e.rethrowFromSystemServer(); + } } /** @@ -488,7 +493,11 @@ public class LauncherApps { @RequiresPermission(permission.BIND_APPWIDGET) public ParcelFileDescriptor getShortcutIconFd( @NonNull ShortcutInfo shortcut, @NonNull UserHandle user) { - throw new RuntimeException("not implemented yet"); + try { + return mService.getShortcutIconFd(mContext.getPackageName(), shortcut, user); + } catch (RemoteException e) { + throw e.rethrowFromSystemServer(); + } } /** diff --git a/core/java/android/content/pm/ShortcutInfo.java b/core/java/android/content/pm/ShortcutInfo.java index 65205636c98e8..83a70cd1a7695 100644 --- a/core/java/android/content/pm/ShortcutInfo.java +++ b/core/java/android/content/pm/ShortcutInfo.java @@ -19,9 +19,11 @@ import android.annotation.IntDef; import android.annotation.NonNull; import android.annotation.Nullable; import android.content.ComponentName; +import android.content.ContentResolver; import android.content.Context; import android.content.Intent; import android.graphics.drawable.Icon; +import android.os.Bundle; import android.os.Parcel; import android.os.Parcelable; import android.os.PersistableBundle; @@ -60,6 +62,9 @@ public class ShortcutInfo implements Parcelable { /* @hide */ public static final int FLAG_HAS_ICON_FILE = 1 << 3; + /* @hide */ + public static final int FLAG_KEY_FIELDS_ONLY = 1 << 4; + /** @hide */ @IntDef(flag = true, value = { @@ -67,6 +72,7 @@ public class ShortcutInfo implements Parcelable { FLAG_PINNED, FLAG_HAS_ICON_RES, FLAG_HAS_ICON_FILE, + FLAG_KEY_FIELDS_ONLY, }) @Retention(RetentionPolicy.SOURCE) public @interface ShortcutFlags {} @@ -114,10 +120,15 @@ public class ShortcutInfo implements Parcelable { @NonNull private String mTitle; + /** + * Intent *with extras removed*. + */ @NonNull private Intent mIntent; - // Internal use only. + /** + * Extras for the intent. + */ @NonNull private PersistableBundle mIntentPersistableExtras; @@ -149,6 +160,11 @@ public class ShortcutInfo implements Parcelable { mIcon = b.mIcon; mTitle = b.mTitle; mIntent = b.mIntent; + final Bundle intentExtras = mIntent.getExtras(); + if (intentExtras != null) { + mIntent.replaceExtras((Bundle) null); + mIntentPersistableExtras = new PersistableBundle(intentExtras); + } mWeight = b.mWeight; mExtras = b.mExtras; updateTimestamp(); @@ -170,11 +186,12 @@ public class ShortcutInfo implements Parcelable { private ShortcutInfo(ShortcutInfo source, @CloneFlags int cloneFlags) { mId = source.mId; mPackageName = source.mPackageName; - mActivityComponent = source.mActivityComponent; mFlags = source.mFlags; mLastChangedTimestamp = source.mLastChangedTimestamp; if ((cloneFlags & CLONE_REMOVE_NON_KEY_INFO) == 0) { + mActivityComponent = source.mActivityComponent; + if ((cloneFlags & CLONE_REMOVE_ICON) == 0) { mIcon = source.mIcon; } @@ -188,6 +205,10 @@ public class ShortcutInfo implements Parcelable { mExtras = source.mExtras; mIconResourceId = source.mIconResourceId; mBitmapPath = source.mBitmapPath; + + } else { + // Set this bit. + mFlags |= FLAG_KEY_FIELDS_ONLY; } } @@ -238,6 +259,39 @@ public class ShortcutInfo implements Parcelable { updateTimestamp(); } + /** + * @hide + */ + public static Icon validateIcon(Icon icon) { + switch (icon.getType()) { + case Icon.TYPE_RESOURCE: + case Icon.TYPE_BITMAP: + break; // OK + case Icon.TYPE_URI: + if (ContentResolver.SCHEME_CONTENT.equals(icon.getUri().getScheme())) { + break; + } + // Note "file:" is not supported, because depending on the path, system server + // cannot access it. // TODO Revisit "file:" icon support + + // fall through + default: + throw getInvalidIconException(); + } + if (icon.hasTint()) { + // TODO support it + throw new IllegalArgumentException("Icons with tints are not supported"); + } + + return icon; + } + + /** @hide */ + public static IllegalArgumentException getInvalidIconException() { + return new IllegalArgumentException("Unsupported icon type:" + +" only bitmap, resource and content URI are supported"); + } + /** * Builder class for {@link ShortcutInfo} objects. */ @@ -273,7 +327,9 @@ public class ShortcutInfo implements Parcelable { } /** - * Optionally sets the target activity. + * Optionally sets the target activity. If it's not set, and if the caller application + * has multiple launcher icons, this shortcut will be shown on all those icons. + * If it's set, this shortcut will be only shown on this activity. */ @NonNull public Builder setActivityComponent(@NonNull ComponentName activityComponent) { @@ -284,15 +340,22 @@ public class ShortcutInfo implements Parcelable { /** * Optionally sets an icon. * - * - Tint is not supported TODO Either check and throw, or support it. - * - URI icons will be converted into Bitmap icons at the registration time. + *
    + *
  • Tints are not supported. + *
  • Bitmaps, resources and "content:" URIs are supported. + *
  • "content:" URI will be fetched when a shortcut is registered to + * {@link ShortcutManager}. Changing the content from the same URI later will + * not be reflected to launcher icons. + *
* - * TODO Only allow Bitmap, Resource and URI types. byte[] type can easily go over - * binder size limit. + *

For performance reasons, icons will NOT be available on instances + * returned by {@link ShortcutManager} or {@link LauncherApps}. Launcher applications + * need to use {@link LauncherApps#getShortcutIconFd(ShortcutInfo, UserHandle)} + * and {@link LauncherApps#getShortcutIconResId(ShortcutInfo, UserHandle)}. */ @NonNull public Builder setIcon(Icon icon) { - mIcon = icon; + mIcon = validateIcon(icon); return this; } @@ -316,7 +379,7 @@ public class ShortcutInfo implements Parcelable { } /** - * Optionally sets the weight of a shortcut, which will be used by Launcher for sorting. + * Optionally sets the weight of a shortcut, which will be used by the launcher for sorting. * The larger the weight, the more "important" a shortcut is. */ @NonNull @@ -326,8 +389,8 @@ public class ShortcutInfo implements Parcelable { } /** - * Optional values that application can set. - * TODO: reserve keys starting with "android." + * Optional values that applications can set. Applications can store any meta-data of + * shortcuts in this, and retrieve later from {@link ShortcutInfo#getExtras()}. */ @NonNull public Builder setExtras(@NonNull PersistableBundle extras) { @@ -353,7 +416,7 @@ public class ShortcutInfo implements Parcelable { } /** - * Return the ID of the shortcut. + * Return the package name of the creator application. */ @NonNull public String getPackageName() { @@ -374,7 +437,7 @@ public class ShortcutInfo implements Parcelable { * * For performance reasons, this will NOT be available when an instance is returned * by {@link ShortcutManager} or {@link LauncherApps}. A launcher application needs to use - * other APIs in LauncherApps to fetch the bitmap. TODO Add a precondition for it. + * other APIs in LauncherApps to fetch the bitmap. * * @hide */ @@ -385,22 +448,46 @@ public class ShortcutInfo implements Parcelable { /** * Return the shortcut title. + * + *

All shortcuts must have a non-empty title, but this method will return null when + * {@link #hasKeyFieldsOnly()} is true. */ - @NonNull + @Nullable public String getTitle() { return mTitle; } /** * Return the intent. - * TODO Set mIntentPersistableExtras and before returning. + * + *

All shortcuts must have an intent, but this method will return null when + * {@link #hasKeyFieldsOnly()} is true. */ - @NonNull + @Nullable public Intent getIntent() { + if (mIntent == null) { + return null; + } + final Intent intent = new Intent(mIntent); + intent.replaceExtras( + mIntentPersistableExtras != null ? new Bundle(mIntentPersistableExtras) : null); + return intent; + } + + /** + * Return "raw" intent, which is the original intent without the extras. + * @hide + */ + @Nullable + public Intent getIntentNoExtras() { return mIntent; } - /** @hide */ + /** + * The extras in the intent. We convert extras into {@link PersistableBundle} so we can + * persist them. + * @hide + */ @Nullable public PersistableBundle getIntentPersistableExtras() { return mIntentPersistableExtras; @@ -483,6 +570,23 @@ public class ShortcutInfo implements Parcelable { return hasFlags(FLAG_HAS_ICON_FILE); } + /** + * Return whether a shortcut only contains "key" information only or not. If true, only the + * following fields are available. + *

    + *
  • {@link #getId()} + *
  • {@link #getPackageName()} + *
  • {@link #getLastChangedTimestamp()} + *
  • {@link #isDynamic()} + *
  • {@link #isPinned()} + *
  • {@link #hasIconResource()} + *
  • {@link #hasIconFile()} + *
+ */ + public boolean hasKeyFieldsOnly() { + return hasFlags(FLAG_KEY_FIELDS_ONLY); + } + /** @hide */ public void updateTimestamp() { mLastChangedTimestamp = System.currentTimeMillis(); @@ -495,33 +599,13 @@ public class ShortcutInfo implements Parcelable { } /** @hide */ - public void setIcon(Icon icon) { - mIcon = icon; + public void clearIcon() { + mIcon = null; } /** @hide */ - public void setTitle(String title) { - mTitle = title; - } - - /** @hide */ - public void setIntent(Intent intent) { - mIntent = intent; - } - - /** @hide */ - public void setIntentPersistableExtras(PersistableBundle intentPersistableExtras) { - mIntentPersistableExtras = intentPersistableExtras; - } - - /** @hide */ - public void setWeight(int weight) { - mWeight = weight; - } - - /** @hide */ - public void setExtras(PersistableBundle extras) { - mExtras = extras; + public void setIconResourceId(int iconResourceId) { + mIconResourceId = iconResourceId; } /** @hide */ @@ -643,9 +727,10 @@ public class ShortcutInfo implements Parcelable { sb.append(", extras="); sb.append(mExtras); + sb.append(", flags="); + sb.append(mFlags); + if (includeInternalData) { - sb.append(", flags="); - sb.append(mFlags); sb.append(", iconRes="); sb.append(mIconResourceId); diff --git a/core/java/android/content/pm/ShortcutManager.java b/core/java/android/content/pm/ShortcutManager.java index 4c51d499ab4a2..b247f65c94c3f 100644 --- a/core/java/android/content/pm/ShortcutManager.java +++ b/core/java/android/content/pm/ShortcutManager.java @@ -239,6 +239,17 @@ public class ShortcutManager { } } + /** + * Return the max width and height for icons, in pixels. + */ + public int getIconMaxDimensions() { + try { + return mService.getIconMaxDimensions(mContext.getPackageName(), injectMyUserId()); + } catch (RemoteException e) { + throw e.rethrowFromSystemServer(); + } + } + /** @hide injection point */ @VisibleForTesting protected int injectMyUserId() { diff --git a/core/java/android/content/pm/ShortcutServiceInternal.java b/core/java/android/content/pm/ShortcutServiceInternal.java index 3d6028a6ffe59..918c763545fab 100644 --- a/core/java/android/content/pm/ShortcutServiceInternal.java +++ b/core/java/android/content/pm/ShortcutServiceInternal.java @@ -22,6 +22,8 @@ import android.annotation.UserIdInt; import android.content.ComponentName; import android.content.Intent; import android.content.pm.LauncherApps.ShortcutQuery; +import android.os.ParcelFileDescriptor; +import android.os.UserHandle; import java.util.List; @@ -55,4 +57,10 @@ public abstract class ShortcutServiceInternal { @NonNull String packageName, @NonNull String shortcutId, int userId); public abstract void addListener(@NonNull ShortcutChangeListener listener); + + public abstract int getShortcutIconResId(@NonNull String callingPackage, + @NonNull ShortcutInfo shortcut, int userId); + + public abstract ParcelFileDescriptor getShortcutIconFd(@NonNull String callingPackage, + @NonNull ShortcutInfo shortcut, int userId); } diff --git a/core/java/android/os/PersistableBundle.java b/core/java/android/os/PersistableBundle.java index f36bb2989c72c..5872f7438904e 100644 --- a/core/java/android/os/PersistableBundle.java +++ b/core/java/android/os/PersistableBundle.java @@ -86,6 +86,8 @@ public final class PersistableBundle extends BaseBundle implements Cloneable, Pa * @param b a Bundle to be copied. * * @throws IllegalArgumentException if any element of {@code b} cannot be persisted. + * + * @hide */ public PersistableBundle(Bundle b) { this(b.getMap()); diff --git a/graphics/java/android/graphics/drawable/Icon.java b/graphics/java/android/graphics/drawable/Icon.java index 0de4c2cffa17e..51221b4a303e9 100644 --- a/graphics/java/android/graphics/drawable/Icon.java +++ b/graphics/java/android/graphics/drawable/Icon.java @@ -627,6 +627,11 @@ public final class Icon implements Parcelable { return this; } + /** @hide */ + public boolean hasTint() { + return (mTintList != null) || (mTintMode != DEFAULT_TINT_MODE); + } + /** * Create an Icon pointing to an image file specified by path. * diff --git a/services/core/java/com/android/server/pm/LauncherAppsService.java b/services/core/java/com/android/server/pm/LauncherAppsService.java index 56128708599b3..e90fb3208d858 100644 --- a/services/core/java/com/android/server/pm/LauncherAppsService.java +++ b/services/core/java/com/android/server/pm/LauncherAppsService.java @@ -42,6 +42,7 @@ import android.net.Uri; import android.os.Binder; import android.os.Bundle; import android.os.IInterface; +import android.os.ParcelFileDescriptor; import android.os.RemoteCallbackList; import android.os.RemoteException; import android.os.UserHandle; @@ -328,6 +329,26 @@ public class LauncherAppsService extends SystemService { ids, user.getIdentifier()); } + @Override + public int getShortcutIconResId(String callingPackage, ShortcutInfo shortcut, + UserHandle user) { + enforceShortcutPermission(user); + verifyCallingPackage(callingPackage); + + return mShortcutServiceInternal.getShortcutIconResId(callingPackage, shortcut, + user.getIdentifier()); + } + + @Override + public ParcelFileDescriptor getShortcutIconFd(String callingPackage, ShortcutInfo shortcut, + UserHandle user) { + enforceShortcutPermission(user); + verifyCallingPackage(callingPackage); + + return mShortcutServiceInternal.getShortcutIconFd(callingPackage, shortcut, + user.getIdentifier()); + } + @Override public boolean startShortcut(String callingPackage, String packageName, String shortcutId, Rect sourceBounds, Bundle startActivityOptions, UserHandle user) diff --git a/services/core/java/com/android/server/pm/ShortcutService.java b/services/core/java/com/android/server/pm/ShortcutService.java index 423767ad87b44..8982632666b7f 100644 --- a/services/core/java/com/android/server/pm/ShortcutService.java +++ b/services/core/java/com/android/server/pm/ShortcutService.java @@ -18,7 +18,9 @@ package com.android.server.pm; import android.annotation.NonNull; import android.annotation.Nullable; import android.annotation.UserIdInt; +import android.app.ActivityManager; import android.content.ComponentName; +import android.content.ContentProvider; import android.content.Context; import android.content.Intent; import android.content.pm.IShortcutService; @@ -30,24 +32,33 @@ import android.content.pm.ParceledListSlice; import android.content.pm.ShortcutInfo; import android.content.pm.ShortcutServiceInternal; import android.content.pm.ShortcutServiceInternal.ShortcutChangeListener; +import android.graphics.Bitmap; +import android.graphics.Bitmap.CompressFormat; +import android.graphics.BitmapFactory; +import android.graphics.Canvas; +import android.graphics.RectF; import android.graphics.drawable.Icon; +import android.net.Uri; import android.os.Binder; -import android.os.Bundle; import android.os.Environment; import android.os.Handler; +import android.os.ParcelFileDescriptor; import android.os.PersistableBundle; import android.os.Process; import android.os.RemoteException; import android.os.ResultReceiver; +import android.os.SELinux; import android.os.ShellCommand; import android.os.UserHandle; import android.text.TextUtils; +import android.text.format.Formatter; import android.text.format.Time; import android.util.ArrayMap; import android.util.ArraySet; import android.util.AtomicFile; import android.util.Slog; import android.util.SparseArray; +import android.util.TypedValue; import android.util.Xml; import com.android.internal.annotations.GuardedBy; @@ -70,6 +81,7 @@ import java.io.FileInputStream; import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.IOException; +import java.io.InputStream; import java.io.PrintWriter; import java.net.URISyntaxException; import java.nio.charset.StandardCharsets; @@ -79,22 +91,28 @@ import java.util.function.Predicate; /** * TODO: - * - Make save async * - * - Add Bitmap support + * - Implement launchShortcut * - * - Implement updateShortcuts + * - Detect when already registered instances are passed to APIs again, which might break + * internal bitmap handling. * * - Listen to PACKAGE_*, remove orphan info, update timestamp for icon res + * -> Need to scan all packages when a user starts too. + * -> Clear data -> remove all dynamic? but not the pinned? * * - Pinned per each launcher package (multiple launchers) * - * - Dev option to reset all counts for QA (for now use "adb shell cmd shortcut reset-throttling") - * * - Load config from settings + * + * - Make save async (should we?) + * + * - Scan and remove orphan bitmaps (just in case). + * + * - Backup & restore */ public class ShortcutService extends IShortcutService.Stub { - private static final String TAG = "ShortcutService"; + static final String TAG = "ShortcutService"; private static final boolean DEBUG = true; // STOPSHIP if true private static final boolean DEBUG_LOAD = true; // STOPSHIP if true @@ -102,6 +120,8 @@ public class ShortcutService extends IShortcutService.Stub { private static final int DEFAULT_RESET_INTERVAL_SEC = 24 * 60 * 60; // 1 day private static final int DEFAULT_MAX_DAILY_UPDATES = 10; private static final int DEFAULT_MAX_SHORTCUTS_PER_APP = 5; + private static final int DEFAULT_MAX_ICON_DIMENSION_DP = 96; + private static final int DEFAULT_MAX_ICON_DIMENSION_LOWRAM_DP = 48; private static final int SAVE_DELAY_MS = 5000; // in milliseconds. @@ -114,11 +134,29 @@ public class ShortcutService extends IShortcutService.Stub { @VisibleForTesting static final String FILENAME_USER_PACKAGES = "shortcuts.xml"; - private static final String DIRECTORY_BITMAPS = "bitmaps"; + static final String DIRECTORY_BITMAPS = "bitmaps"; private static final String TAG_ROOT = "root"; + private static final String TAG_PACKAGE = "package"; private static final String TAG_LAST_RESET_TIME = "last_reset_time"; + private static final String TAG_INTENT_EXTRAS = "intent-extras"; + private static final String TAG_EXTRAS = "extras"; + private static final String TAG_SHORTCUT = "shortcut"; + private static final String ATTR_VALUE = "value"; + private static final String ATTR_NAME = "name"; + private static final String ATTR_DYNAMIC_COUNT = "dynamic-count"; + private static final String ATTR_CALL_COUNT = "call-count"; + private static final String ATTR_LAST_RESET = "last-reset"; + private static final String ATTR_ID = "id"; + private static final String ATTR_ACTIVITY = "activity"; + private static final String ATTR_TITLE = "title"; + private static final String ATTR_INTENT = "intent"; + private static final String ATTR_WEIGHT = "weight"; + private static final String ATTR_TIMESTAMP = "timestamp"; + private static final String ATTR_FLAGS = "flags"; + private static final String ATTR_ICON_RES = "icon-res"; + private static final String ATTR_BITMAP_PATH = "bitmap-path"; private final Context mContext; @@ -136,8 +174,16 @@ public class ShortcutService extends IShortcutService.Stub { * All the information relevant to shortcuts from a single package (per-user). * * TODO Move the persisting code to this class. + * + * Only save/load/dump should look/touch inside this class. */ private static class PackageShortcuts { + @UserIdInt + private final int mUserId; + + @NonNull + private final String mPackageName; + /** * All the shortcuts from the package, keyed on IDs. */ @@ -151,20 +197,38 @@ public class ShortcutService extends IShortcutService.Stub { /** * # of times the package has called rate-limited APIs. */ - private int mApiCallCountInner; + private int mApiCallCount; /** - * When {@link #mApiCallCountInner} was reset last time. + * When {@link #mApiCallCount} was reset last time. */ private long mLastResetTime; - /** - * @return the all shortcuts. Note DO NOT add/remove or touch the flags of the result - * directly, which would cause {@link #mDynamicShortcutCount} to be out of sync. - */ + private PackageShortcuts(int userId, String packageName) { + mUserId = userId; + mPackageName = packageName; + } + @GuardedBy("mLock") - public ArrayMap getShortcuts() { - return mShortcuts; + @Nullable + public ShortcutInfo findShortcutById(String id) { + return mShortcuts.get(id); + } + + private ShortcutInfo deleteShortcut(@NonNull ShortcutService s, + @NonNull String id) { + final ShortcutInfo shortcut = mShortcuts.remove(id); + if (shortcut != null) { + s.removeIcon(mUserId, shortcut); + shortcut.clearFlags(ShortcutInfo.FLAG_DYNAMIC | ShortcutInfo.FLAG_PINNED); + } + return shortcut; + } + + void addShortcut(@NonNull ShortcutService s, @NonNull ShortcutInfo newShortcut) { + deleteShortcut(s, newShortcut.getId()); + s.saveIconAndFixUpShortcut(mUserId, newShortcut); + mShortcuts.put(newShortcut.getId(), newShortcut); } /** @@ -195,40 +259,44 @@ public class ShortcutService extends IShortcutService.Stub { // Okay, make it dynamic and add. newShortcut.addFlags(oldFlags); - mShortcuts.put(newShortcut.getId(), newShortcut); + addShortcut(s, newShortcut); mDynamicShortcutCount = newDynamicCount; } - @GuardedBy("mLock") - public void deleteAllDynamicShortcuts() { + /** + * Remove all shortcuts that aren't pinned nor dynamic. + */ + private void removeOrphans(@NonNull ShortcutService s) { ArrayList removeList = null; // Lazily initialize. for (int i = mShortcuts.size() - 1; i >= 0; i--) { final ShortcutInfo si = mShortcuts.valueAt(i); - if (!si.isDynamic()) { - continue; - } - if (si.isPinned()) { - // Still pinned, so don't remove; just make it non-dynamic. - si.clearFlags(ShortcutInfo.FLAG_DYNAMIC); - } else { - if (removeList == null) { - removeList = new ArrayList<>(); - } - removeList.add(si.getId()); + if (si.isPinned() || si.isDynamic()) continue; + + if (removeList == null) { + removeList = new ArrayList<>(); } + removeList.add(si.getId()); } if (removeList != null) { for (int i = removeList.size() - 1 ; i >= 0; i--) { - mShortcuts.remove(removeList.get(i)); + deleteShortcut(s, removeList.get(i)); } } + } + + @GuardedBy("mLock") + public void deleteAllDynamicShortcuts(@NonNull ShortcutService s) { + for (int i = mShortcuts.size() - 1; i >= 0; i--) { + mShortcuts.valueAt(i).clearFlags(ShortcutInfo.FLAG_DYNAMIC); + } + removeOrphans(s); mDynamicShortcutCount = 0; } @GuardedBy("mLock") - public void deleteDynamicWithId(@NonNull String shortcutId) { + public void deleteDynamicWithId(@NonNull ShortcutService s, @NonNull String shortcutId) { final ShortcutInfo oldShortcut = mShortcuts.get(shortcutId); if (oldShortcut == null) { @@ -240,18 +308,30 @@ public class ShortcutService extends IShortcutService.Stub { if (oldShortcut.isPinned()) { oldShortcut.clearFlags(ShortcutInfo.FLAG_DYNAMIC); } else { - mShortcuts.remove(shortcutId); + deleteShortcut(s, shortcutId); } } @GuardedBy("mLock") - public void pinAll(List shortcutIds) { + public void replacePinned(@NonNull ShortcutService s, String launcherPackage, + List shortcutIds) { + + // TODO Should be per launcherPackage. + + // First, un-pin all shortcuts + for (int i = mShortcuts.size() - 1; i >= 0; i--) { + mShortcuts.valueAt(i).clearFlags(ShortcutInfo.FLAG_PINNED); + } + + // Then pin ALL for (int i = shortcutIds.size() - 1; i >= 0; i--) { final ShortcutInfo shortcut = mShortcuts.get(shortcutIds.get(i)); if (shortcut != null) { shortcut.addFlags(ShortcutInfo.FLAG_PINNED); } } + + removeOrphans(s); } /** @@ -261,16 +341,22 @@ public class ShortcutService extends IShortcutService.Stub { public int getApiCallCount(@NonNull ShortcutService s) { final long last = s.getLastResetTimeLocked(); + final long now = s.injectCurrentTimeMillis(); + if (mLastResetTime > now) { + // Clock rewound. // TODO Test it + mLastResetTime = now; + } + // If not reset yet, then reset. if (mLastResetTime < last) { - mApiCallCountInner = 0; + mApiCallCount = 0; mLastResetTime = last; } - return mApiCallCountInner; + return mApiCallCount; } /** - * If the caller app hasn't been throttled yet, increment {@link #mApiCallCountInner} + * If the caller app hasn't been throttled yet, increment {@link #mApiCallCount} * and return true. Otherwise just return false. */ @GuardedBy("mLock") @@ -278,13 +364,13 @@ public class ShortcutService extends IShortcutService.Stub { if (getApiCallCount(s) >= s.mMaxDailyUpdates) { return false; } - mApiCallCountInner++; + mApiCallCount++; return true; } @GuardedBy("mLock") public void resetRateLimitingForCommandLine() { - mApiCallCountInner = 0; + mApiCallCount = 0; mLastResetTime = 0; } @@ -313,21 +399,27 @@ public class ShortcutService extends IShortcutService.Stub { /** * Max number of dynamic shortcuts that each application can have at a time. */ - @GuardedBy("mLock") private int mMaxDynamicShortcuts; /** * Max number of updating API calls that each application can make a day. */ - @GuardedBy("mLock") private int mMaxDailyUpdates; /** * Actual throttling-reset interval. By default it's a day. */ - @GuardedBy("mLock") private long mResetInterval; + /** + * Icon max width/height in pixels. + */ + private int mMaxIconDimension; + + private CompressFormat mIconPersistFormat = CompressFormat.PNG; + + private int mIconPersistQuality = 100; + public ShortcutService(Context context) { mContext = Preconditions.checkNotNull(context); LocalServices.addService(ShortcutServiceInternal.class, new LocalService()); @@ -417,9 +509,15 @@ public class ShortcutService extends IShortcutService.Stub { mResetInterval = DEFAULT_RESET_INTERVAL_SEC * 1000L; mMaxDailyUpdates = DEFAULT_MAX_DAILY_UPDATES; mMaxDynamicShortcuts = DEFAULT_MAX_SHORTCUTS_PER_APP; + + final int iconDimensionDp = (injectIsLowRamDevice() + ? DEFAULT_MAX_ICON_DIMENSION_LOWRAM_DP : DEFAULT_MAX_ICON_DIMENSION_DP); + mMaxIconDimension = + (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, iconDimensionDp, + mContext.getResources().getDisplayMetrics()); } - // === Persistings === + // === Persisting === @Nullable private String parseStringAttribute(XmlPullParser parser, String attribute) { @@ -605,23 +703,24 @@ public class ShortcutService extends IShortcutService.Stub { // Body. for (int i = 0; i < packages.size(); i++) { final String packageName = packages.keyAt(i); - final PackageShortcuts shortcuts = packages.valueAt(i); + final PackageShortcuts packageShortcuts = packages.valueAt(i); // TODO Move this to PackageShortcuts. - out.startTag(null, "package"); + out.startTag(null, TAG_PACKAGE); - writeAttr(out, "name", packageName); - writeAttr(out, "dynamic-count", shortcuts.mDynamicShortcutCount); - writeAttr(out, "call-count", shortcuts.mApiCallCountInner); - writeAttr(out, "last-reset", shortcuts.mLastResetTime); + writeAttr(out, ATTR_NAME, packageName); + writeAttr(out, ATTR_DYNAMIC_COUNT, packageShortcuts.mDynamicShortcutCount); + writeAttr(out, ATTR_CALL_COUNT, packageShortcuts.mApiCallCount); + writeAttr(out, ATTR_LAST_RESET, packageShortcuts.mLastResetTime); - final int size = shortcuts.getShortcuts().size(); + final ArrayMap shortcuts = packageShortcuts.mShortcuts; + final int size = shortcuts.size(); for (int j = 0; j < size; j++) { - saveShortcut(out, shortcuts.getShortcuts().valueAt(j)); + saveShortcut(out, shortcuts.valueAt(j)); } - out.endTag(null, "package"); + out.endTag(null, TAG_PACKAGE); } // Epilogue. @@ -638,23 +737,23 @@ public class ShortcutService extends IShortcutService.Stub { private void saveShortcut(XmlSerializer out, ShortcutInfo si) throws IOException, XmlPullParserException { - out.startTag(null, "shortcut"); - writeAttr(out, "id", si.getId()); + out.startTag(null, TAG_SHORTCUT); + writeAttr(out, ATTR_ID, si.getId()); // writeAttr(out, "package", si.getPackageName()); // not needed - writeAttr(out, "activity", si.getActivityComponent()); + writeAttr(out, ATTR_ACTIVITY, si.getActivityComponent()); // writeAttr(out, "icon", si.getIcon()); // We don't save it. - writeAttr(out, "title", si.getTitle()); - writeAttr(out, "intent", si.getIntent()); - writeAttr(out, "weight", si.getWeight()); - writeAttr(out, "timestamp", si.getLastChangedTimestamp()); - writeAttr(out, "flags", si.getFlags()); - writeAttr(out, "icon-res", si.getIconResourceId()); - writeAttr(out, "bitmap-path", si.getBitmapPath()); + writeAttr(out, ATTR_TITLE, si.getTitle()); + writeAttr(out, ATTR_INTENT, si.getIntentNoExtras()); + writeAttr(out, ATTR_WEIGHT, si.getWeight()); + writeAttr(out, ATTR_TIMESTAMP, si.getLastChangedTimestamp()); + writeAttr(out, ATTR_FLAGS, si.getFlags()); + writeAttr(out, ATTR_ICON_RES, si.getIconResourceId()); + writeAttr(out, ATTR_BITMAP_PATH, si.getBitmapPath()); - writeTagExtra(out, "intent-extras", si.getIntentPersistableExtras()); - writeTagExtra(out, "extras", si.getExtras()); + writeTagExtra(out, TAG_INTENT_EXTRAS, si.getIntentPersistableExtras()); + writeTagExtra(out, TAG_EXTRAS, si.getExtras()); - out.endTag(null, "shortcut"); + out.endTag(null, TAG_SHORTCUT); } private static IOException throwForInvalidTag(int depth, String tag) throws IOException { @@ -710,24 +809,27 @@ public class ShortcutService extends IShortcutService.Stub { } case 2: { switch (tag) { - case "package": - packageName = parseStringAttribute(parser, "name"); - shortcuts = new PackageShortcuts(); + case TAG_PACKAGE: + packageName = parseStringAttribute(parser, ATTR_NAME); + shortcuts = new PackageShortcuts(userId, packageName); ret.put(packageName, shortcuts); shortcuts.mDynamicShortcutCount = - (int) parseLongAttribute(parser, "dynamic-count"); - shortcuts.mApiCallCountInner = - (int) parseLongAttribute(parser, "call-count"); - shortcuts.mLastResetTime = parseLongAttribute(parser, "last-reset"); + (int) parseLongAttribute(parser, ATTR_DYNAMIC_COUNT); + shortcuts.mApiCallCount = + (int) parseLongAttribute(parser, ATTR_CALL_COUNT); + shortcuts.mLastResetTime = parseLongAttribute(parser, + ATTR_LAST_RESET); continue; } break; } case 3: { switch (tag) { - case "shortcut": + case TAG_SHORTCUT: final ShortcutInfo si = parseShortcut(parser, packageName); + + // Don't use addShortcut(), we don't need to save the icon. shortcuts.mShortcuts.put(si.getId(), si); continue; } @@ -760,15 +862,15 @@ public class ShortcutService extends IShortcutService.Stub { int iconRes; String bitmapPath; - id = parseStringAttribute(parser, "id"); - activityComponent = parseComponentNameAttribute(parser, "activity"); - title = parseStringAttribute(parser, "title"); - intent = parseIntentAttribute(parser, "intent"); - weight = (int) parseLongAttribute(parser, "weight"); - lastChangedTimestamp = (int) parseLongAttribute(parser, "timestamp"); - flags = (int) parseLongAttribute(parser, "flags"); - iconRes = (int) parseLongAttribute(parser, "icon-res"); - bitmapPath = parseStringAttribute(parser, "bitmap-path"); + id = parseStringAttribute(parser, ATTR_ID); + activityComponent = parseComponentNameAttribute(parser, ATTR_ACTIVITY); + title = parseStringAttribute(parser, ATTR_TITLE); + intent = parseIntentAttribute(parser, ATTR_INTENT); + weight = (int) parseLongAttribute(parser, ATTR_WEIGHT); + lastChangedTimestamp = (int) parseLongAttribute(parser, ATTR_TIMESTAMP); + flags = (int) parseLongAttribute(parser, ATTR_FLAGS); + iconRes = (int) parseLongAttribute(parser, ATTR_ICON_RES); + bitmapPath = parseStringAttribute(parser, ATTR_BITMAP_PATH); final int outerDepth = parser.getDepth(); int type; @@ -784,10 +886,10 @@ public class ShortcutService extends IShortcutService.Stub { depth, type, tag)); } switch (tag) { - case "intent-extras": + case TAG_INTENT_EXTRAS: intentPersistableExtras = PersistableBundle.restoreFromXml(parser); continue; - case "extras": + case TAG_EXTRAS: extras = PersistableBundle.restoreFromXml(parser); continue; } @@ -875,7 +977,7 @@ public class ShortcutService extends IShortcutService.Stub { final ArrayMap userPackages = getUserShortcutsLocked(userId); PackageShortcuts shortcuts = userPackages.get(packageName); if (shortcuts == null) { - shortcuts = new PackageShortcuts(); + shortcuts = new PackageShortcuts(userId, packageName); userPackages.put(packageName, shortcuts); } return shortcuts; @@ -883,6 +985,195 @@ public class ShortcutService extends IShortcutService.Stub { // === Caller validation === + void removeIcon(@UserIdInt int userId, ShortcutInfo shortcut) { + if (shortcut.getBitmapPath() != null) { + if (DEBUG) { + Slog.d(TAG, "Removing " + shortcut.getBitmapPath()); + } + new File(shortcut.getBitmapPath()).delete(); + + shortcut.setBitmapPath(null); + shortcut.setIconResourceId(0); + shortcut.clearFlags(ShortcutInfo.FLAG_HAS_ICON_FILE | ShortcutInfo.FLAG_HAS_ICON_RES); + } + } + + @VisibleForTesting + static class FileOutputStreamWithPath extends FileOutputStream { + private final File mFile; + + public FileOutputStreamWithPath(File file) throws FileNotFoundException { + super(file); + mFile = file; + } + + public File getFile() { + return mFile; + } + } + + /** + * Build the cached bitmap filename for a shortcut icon. + * + * The filename will be based on the ID, except certain characters will be escaped. + */ + @VisibleForTesting + FileOutputStreamWithPath openIconFileForWrite(@UserIdInt int userId, ShortcutInfo shortcut) + throws IOException { + final File packagePath = new File(getUserBitmapFilePath(userId), + shortcut.getPackageName()); + if (!packagePath.isDirectory()) { + packagePath.mkdirs(); + if (!packagePath.isDirectory()) { + throw new IOException("Unable to create directory " + packagePath); + } + SELinux.restorecon(packagePath); + } + + final String baseName = String.valueOf(injectCurrentTimeMillis()); + for (int suffix = 0;; suffix++) { + final String filename = (suffix == 0 ? baseName : baseName + "_" + suffix) + ".png"; + final File file = new File(packagePath, filename); + if (!file.exists()) { + if (DEBUG) { + Slog.d(TAG, "Saving icon to " + file.getAbsolutePath()); + } + return new FileOutputStreamWithPath(file); + } + } + } + + void saveIconAndFixUpShortcut(@UserIdInt int userId, ShortcutInfo shortcut) { + if (shortcut.hasIconFile() || shortcut.hasIconResource()) { + return; + } + + final long token = Binder.clearCallingIdentity(); + try { + // Clear icon info on the shortcut. + shortcut.setIconResourceId(0); + shortcut.setBitmapPath(null); + + final Icon icon = shortcut.getIcon(); + if (icon == null) { + return; // has no icon + } + + Bitmap bitmap = null; + try { + switch (icon.getType()) { + case Icon.TYPE_RESOURCE: { + injectValidateIconResPackage(shortcut, icon); + + shortcut.setIconResourceId(icon.getResId()); + shortcut.addFlags(ShortcutInfo.FLAG_HAS_ICON_RES); + return; + } + case Icon.TYPE_BITMAP: { + bitmap = icon.getBitmap(); + break; + } + case Icon.TYPE_URI: { + final Uri uri = ContentProvider.maybeAddUserId(icon.getUri(), userId); + + try (InputStream is = mContext.getContentResolver().openInputStream(uri)) { + + bitmap = BitmapFactory.decodeStream(is); + + } catch (IOException e) { + Slog.e(TAG, "Unable to load icon from " + uri); + return; + } + break; + } + default: + // This shouldn't happen because we've already validated the icon, but + // just in case. + throw ShortcutInfo.getInvalidIconException(); + } + if (bitmap == null) { + Slog.e(TAG, "Null bitmap detected"); + return; + } + // Shrink and write to the file. + File path = null; + try { + final FileOutputStreamWithPath out = openIconFileForWrite(userId, shortcut); + try { + path = out.getFile(); + + shrinkBitmap(bitmap, mMaxIconDimension) + .compress(mIconPersistFormat, mIconPersistQuality, out); + + shortcut.setBitmapPath(out.getFile().getAbsolutePath()); + shortcut.addFlags(ShortcutInfo.FLAG_HAS_ICON_FILE); + } finally { + IoUtils.closeQuietly(out); + } + } catch (IOException|RuntimeException e) { + // STOPSHIP Change wtf to e + Slog.wtf(ShortcutService.TAG, "Unable to write bitmap to file", e); + if (path != null && path.exists()) { + path.delete(); + } + } + } finally { + if (bitmap != null) { + bitmap.recycle(); + } + // Once saved, we won't use the original icon information, so null it out. + shortcut.clearIcon(); + } + } finally { + Binder.restoreCallingIdentity(token); + } + } + + // Unfortunately we can't do this check in unit tests because we fake creator package names, + // so override in unit tests. + // TODO CTS this case. + void injectValidateIconResPackage(ShortcutInfo shortcut, Icon icon) { + if (!shortcut.getPackageName().equals(icon.getResPackage())) { + throw new IllegalArgumentException( + "Icon resource must reside in shortcut owner package"); + } + } + + @VisibleForTesting + static Bitmap shrinkBitmap(Bitmap in, int maxSize) { + // Original width/height. + final int ow = in.getWidth(); + final int oh = in.getHeight(); + if ((ow <= maxSize) && (oh <= maxSize)) { + if (DEBUG) { + Slog.d(TAG, String.format("Icon size %dx%d, no need to shrink", ow, oh)); + } + return in; + } + final int longerDimension = Math.max(ow, oh); + + // New width and height. + final int nw = ow * maxSize / longerDimension; + final int nh = oh * maxSize / longerDimension; + if (DEBUG) { + Slog.d(TAG, String.format("Icon size %dx%d, shrinking to %dx%d", + ow, oh, nw, nh)); + } + + final Bitmap scaledBitmap = Bitmap.createBitmap(nw, nh, Bitmap.Config.ARGB_8888); + final Canvas c = new Canvas(scaledBitmap); + + final RectF dst = new RectF(0, 0, nw, nh); + + c.drawBitmap(in, /*src=*/ null, dst, /* paint =*/ null); + + in.recycle(); + + return scaledBitmap; + } + + // === Caller validation === + private boolean isCallerSystem() { final int callingUid = injectBinderCallingUid(); return UserHandle.isSameApp(callingUid, Process.SYSTEM_UID); @@ -915,31 +1206,21 @@ public class ShortcutService extends IShortcutService.Stub { if (UserHandle.getUserId(callingUid) != userId) { throw new SecurityException("Invalid user-ID"); } - verifyCallingPackage(packageName); - } - - private void verifyCallingPackage(@NonNull String packageName) { - Preconditions.checkStringNotEmpty(packageName, "packageName"); - - if (isCallerSystem()) { - return; // no check - } - - if (injectGetPackageUid(packageName) == injectBinderCallingUid()) { + if (injectGetPackageUid(packageName, userId) == injectBinderCallingUid()) { return; // Caller is valid. } throw new SecurityException("Caller UID= doesn't own " + packageName); } // Test overrides it. - int injectGetPackageUid(String packageName) { + int injectGetPackageUid(@NonNull String packageName, @UserIdInt int userId) { try { // TODO Is MATCH_UNINSTALLED_PACKAGES correct to get SD card app info? - return mContext.getPackageManager().getPackageUid(packageName, + return mContext.getPackageManager().getPackageUidAsUser(packageName, PackageManager.MATCH_ENCRYPTION_AWARE_AND_UNAWARE - | PackageManager.MATCH_UNINSTALLED_PACKAGES); + | PackageManager.MATCH_UNINSTALLED_PACKAGES, userId); } catch (NameNotFoundException e) { return -1; } @@ -983,8 +1264,10 @@ public class ShortcutService extends IShortcutService.Stub { * - Make sure the intent's extras are persistable, and them to set * {@link ShortcutInfo#mIntentPersistableExtras}. Also clear its extras. * - Clear flags. + * + * TODO Detailed unit tests */ - private void fixUpIncomingShortcutInfo(@NonNull ShortcutInfo shortcut) { + private void fixUpIncomingShortcutInfo(@NonNull ShortcutInfo shortcut, boolean forUpdate) { Preconditions.checkNotNull(shortcut, "Null shortcut detected"); if (shortcut.getActivityComponent() != null) { Preconditions.checkState( @@ -993,24 +1276,60 @@ public class ShortcutService extends IShortcutService.Stub { "Activity package name mismatch"); } - shortcut.enforceMandatoryFields(); - - final Intent intent = shortcut.getIntent(); - final Bundle intentExtras = intent.getExtras(); - if (intentExtras != null && intentExtras.size() > 0) { - intent.replaceExtras((Bundle) null); - - // PersistableBundle's constructor will throw IllegalArgumentException if original - // extras contain something not persistable. - shortcut.setIntentPersistableExtras(new PersistableBundle(intentExtras)); + if (!forUpdate) { + shortcut.enforceMandatoryFields(); + } + if (shortcut.getIcon() != null) { + ShortcutInfo.validateIcon(shortcut.getIcon()); } - // TODO Save the icon - shortcut.setIcon(null); + validateForXml(shortcut.getId()); + validateForXml(shortcut.getTitle()); + validatePersistableBundleForXml(shortcut.getIntentPersistableExtras()); + validatePersistableBundleForXml(shortcut.getExtras()); shortcut.setFlags(0); } + // KXmlSerializer is strict and doesn't allow certain characters, so we disallow those + // characters. + + private static void validatePersistableBundleForXml(PersistableBundle b) { + if (b == null || b.size() == 0) { + return; + } + for (String key : b.keySet()) { + validateForXml(key); + final Object value = b.get(key); + if (value == null) { + continue; + } else if (value instanceof String) { + validateForXml((String) value); + } else if (value instanceof String[]) { + for (String v : (String[]) value) { + validateForXml(v); + } + } else if (value instanceof PersistableBundle) { + validatePersistableBundleForXml((PersistableBundle) value); + } + } + } + + private static void validateForXml(String s) { + if (TextUtils.isEmpty(s)) { + return; + } + for (int i = s.length() - 1; i >= 0; i--) { + if (!isAllowedInXml(s.charAt(i))) { + throw new IllegalArgumentException("Unsupported character detected in: " + s); + } + } + } + + private static boolean isAllowedInXml(char c) { + return (c >= 0x20 && c <= 0xd7ff) || (c >= 0xe000 && c <= 0xfffd); + } + // === APIs === @Override @@ -1032,11 +1351,11 @@ public class ShortcutService extends IShortcutService.Stub { // Validate the shortcuts. for (int i = 0; i < size; i++) { - fixUpIncomingShortcutInfo(newShortcuts.get(i)); + fixUpIncomingShortcutInfo(newShortcuts.get(i), /* forUpdate= */ false); } // First, remove all un-pinned; dynamic shortcuts - ps.deleteAllDynamicShortcuts(); + ps.deleteAllDynamicShortcuts(this); // Then, add/update all. We need to make sure to take over "pinned" flag. for (int i = 0; i < size; i++) { @@ -1046,7 +1365,6 @@ public class ShortcutService extends IShortcutService.Stub { } } userPackageChanged(packageName, userId); - return true; } @@ -1056,15 +1374,34 @@ public class ShortcutService extends IShortcutService.Stub { verifyCaller(packageName, userId); final List newShortcuts = (List) shortcutInfoList.getList(); + final int size = newShortcuts.size(); synchronized (mLock) { + final PackageShortcuts ps = getPackageShortcutsLocked(packageName, userId); - if (true) { - throw new RuntimeException("not implemented yet"); + // Throttling. + if (!ps.tryApiCall(this)) { + return false; } - // TODO Similar to setDynamicShortcuts, but don't add new ones, and don't change flags. - // Update non-null fields only. + for (int i = 0; i < size; i++) { + final ShortcutInfo source = newShortcuts.get(i); + fixUpIncomingShortcutInfo(source, /* forUpdate= */ true); + + final ShortcutInfo target = ps.findShortcutById(source.getId()); + if (target != null) { + final boolean replacingIcon = (source.getIcon() != null); + if (replacingIcon) { + removeIcon(userId, target); + } + + target.copyNonNullFieldsFrom(source); + + if (replacingIcon) { + saveIconAndFixUpShortcut(userId, target); + } + } + } } userPackageChanged(packageName, userId); @@ -1085,7 +1422,7 @@ public class ShortcutService extends IShortcutService.Stub { } // Validate the shortcut. - fixUpIncomingShortcutInfo(newShortcut); + fixUpIncomingShortcutInfo(newShortcut, /* forUpdate= */ false); // Add it. newShortcut.addFlags(ShortcutInfo.FLAG_DYNAMIC); @@ -1103,7 +1440,7 @@ public class ShortcutService extends IShortcutService.Stub { Preconditions.checkStringNotEmpty(shortcutId, "shortcutId must be provided"); synchronized (mLock) { - getPackageShortcutsLocked(packageName, userId).deleteDynamicWithId(shortcutId); + getPackageShortcutsLocked(packageName, userId).deleteDynamicWithId(this, shortcutId); } userPackageChanged(packageName, userId); } @@ -1113,7 +1450,7 @@ public class ShortcutService extends IShortcutService.Stub { verifyCaller(packageName, userId); synchronized (mLock) { - getPackageShortcutsLocked(packageName, userId).deleteAllDynamicShortcuts(); + getPackageShortcutsLocked(packageName, userId).deleteAllDynamicShortcuts(this); } userPackageChanged(packageName, userId); } @@ -1177,6 +1514,13 @@ public class ShortcutService extends IShortcutService.Stub { } } + @Override + public int getIconMaxDimensions(String packageName, int userId) throws RemoteException { + synchronized (mLock) { + return mMaxIconDimension; + } + } + /** * Reset all throttling, for developer options and command line. Only system/shell can call it. */ @@ -1193,6 +1537,7 @@ public class ShortcutService extends IShortcutService.Stub { mRawLastResetTime = injectCurrentTimeMillis(); } scheduleSaveBaseState(); + Slog.i(TAG, "ShortcutManager: throttling counter reset"); } /** @@ -1217,7 +1562,7 @@ public class ShortcutService extends IShortcutService.Stub { } else { final ArrayMap packages = getUserShortcutsLocked(userId); - for (int i = 0; i < packages.size(); i++) { + for (int i = packages.size() - 1; i >= 0; i--) { getShortcutsInnerLocked( packages.keyAt(i), changedSince, componentName, queryFlags, userId, ret, cloneFlag); @@ -1274,7 +1619,8 @@ public class ShortcutService extends IShortcutService.Stub { Preconditions.checkNotNull(shortcutIds, "shortcutIds"); synchronized (mLock) { - getPackageShortcutsLocked(packageName, userId).pinAll(shortcutIds); + getPackageShortcutsLocked(packageName, userId).replacePinned( + ShortcutService.this, callingPackage, shortcutIds); } userPackageChanged(packageName, userId); } @@ -1289,18 +1635,8 @@ public class ShortcutService extends IShortcutService.Stub { synchronized (mLock) { final ShortcutInfo fullShortcut = getPackageShortcutsLocked(packageName, userId) - .getShortcuts().get(shortcutId); - if (fullShortcut == null) { - return null; - } else { - final Intent intent = fullShortcut.getIntent(); - final PersistableBundle extras = fullShortcut.getIntentPersistableExtras(); - if (extras != null) { - intent.replaceExtras(new Bundle(extras)); - } - - return intent; - } + .findShortcutById(shortcutId); + return fullShortcut == null ? null : fullShortcut.getIntent(); } } @@ -1310,6 +1646,41 @@ public class ShortcutService extends IShortcutService.Stub { mListeners.add(Preconditions.checkNotNull(listener)); } } + + @Override + public int getShortcutIconResId(@NonNull String callingPackage, + @NonNull ShortcutInfo shortcut, int userId) { + Preconditions.checkNotNull(shortcut, "shortcut"); + + synchronized (mLock) { + final ShortcutInfo shortcutInfo = getPackageShortcutsLocked( + shortcut.getPackageName(), userId).findShortcutById(shortcut.getId()); + return (shortcutInfo != null && shortcutInfo.hasIconResource()) + ? shortcutInfo.getIconResourceId() : 0; + } + } + + @Override + public ParcelFileDescriptor getShortcutIconFd(@NonNull String callingPackage, + @NonNull ShortcutInfo shortcut, int userId) { + Preconditions.checkNotNull(shortcut, "shortcut"); + + synchronized (mLock) { + final ShortcutInfo shortcutInfo = getPackageShortcutsLocked( + shortcut.getPackageName(), userId).findShortcutById(shortcut.getId()); + if (shortcutInfo == null || !shortcutInfo.hasIconFile()) { + return null; + } + try { + return ParcelFileDescriptor.open( + new File(shortcutInfo.getBitmapPath()), + ParcelFileDescriptor.MODE_READ_ONLY); + } catch (FileNotFoundException e) { + Slog.e(TAG, "Icon file not found: " + shortcutInfo.getBitmapPath()); + return null; + } + } + } } // === Dump === @@ -1336,30 +1707,38 @@ public class ShortcutService extends IShortcutService.Stub { pw.print(now); pw.print("] "); pw.print(formatTime(now)); + pw.print(" Raw last reset: ["); pw.print(mRawLastResetTime); pw.print("] "); pw.print(formatTime(mRawLastResetTime)); final long last = getLastResetTimeLocked(); - final long next = getNextResetTimeLocked(); pw.print(" Last reset: ["); pw.print(last); pw.print("] "); pw.print(formatTime(last)); + final long next = getNextResetTimeLocked(); pw.print(" Next reset: ["); pw.print(next); pw.print("] "); pw.print(formatTime(next)); pw.println(); + pw.print(" Max icon dim: "); + pw.print(mMaxIconDimension); + pw.print(" Icon format: "); + pw.print(mIconPersistFormat); + pw.print(" Icon quality: "); + pw.print(mIconPersistQuality); + pw.println(); + pw.println(); for (int i = 0; i < mShortcuts.size(); i++) { dumpUserLocked(pw, mShortcuts.keyAt(i)); } - } } @@ -1379,8 +1758,8 @@ public class ShortcutService extends IShortcutService.Stub { } private void dumpPackageLocked(PrintWriter pw, int userId, String packageName) { - final PackageShortcuts shortcuts = mShortcuts.get(userId).get(packageName); - if (shortcuts == null) { + final PackageShortcuts packageShortcuts = mShortcuts.get(userId).get(packageName); + if (packageShortcuts == null) { return; } @@ -1389,22 +1768,38 @@ public class ShortcutService extends IShortcutService.Stub { pw.println(); pw.print(" Calls: "); - pw.print(shortcuts.getApiCallCount(this)); + pw.print(packageShortcuts.getApiCallCount(this)); pw.println(); // This should be after getApiCallCount(), which may update it. pw.print(" Last reset: ["); - pw.print(shortcuts.mLastResetTime); + pw.print(packageShortcuts.mLastResetTime); pw.print("] "); - pw.print(formatTime(shortcuts.mLastResetTime)); + pw.print(formatTime(packageShortcuts.mLastResetTime)); pw.println(); pw.println(" Shortcuts:"); - final int size = shortcuts.getShortcuts().size(); + long totalBitmapSize = 0; + final ArrayMap shortcuts = packageShortcuts.mShortcuts; + final int size = shortcuts.size(); for (int i = 0; i < size; i++) { + final ShortcutInfo si = shortcuts.valueAt(i); pw.print(" "); - pw.println(shortcuts.getShortcuts().valueAt(i).toInsecureString()); + pw.println(si.toInsecureString()); + if (si.hasIconFile()) { + final long len = new File(si.getBitmapPath()).length(); + pw.print(" "); + pw.print("bitmap size="); + pw.println(len); + + totalBitmapSize += len; + } } + pw.print(" Total bitmap size: "); + pw.print(totalBitmapSize); + pw.print(" ("); + pw.print(Formatter.formatFileSize(mContext, totalBitmapSize)); + pw.println(")"); } private static String formatTime(long time) { @@ -1505,7 +1900,15 @@ public class ShortcutService extends IShortcutService.Stub { } File injectUserDataPath(@UserIdInt int userId) { - return new File(Environment.getDataSystemDeDirectory(userId), DIRECTORY_PER_USER); + return new File(Environment.getDataSystemCeDirectory(userId), DIRECTORY_PER_USER); + } + + boolean injectIsLowRamDevice() { + return ActivityManager.isLowRamDeviceStatic(); + } + + File getUserBitmapFilePath(@UserIdInt int userId) { + return new File(injectUserDataPath(userId), DIRECTORY_BITMAPS); } @VisibleForTesting @@ -1523,6 +1926,11 @@ public class ShortcutService extends IShortcutService.Stub { mMaxDailyUpdates = max; } + @VisibleForTesting + void setMaxIconDimensionForTest(int dimension) { + mMaxIconDimension = dimension; + } + @VisibleForTesting public void setResetIntervalForTest(long interval) { mResetInterval = interval; diff --git a/services/java/com/android/server/SystemServer.java b/services/java/com/android/server/SystemServer.java index 59f8284a52002..e7daaa15d2473 100644 --- a/services/java/com/android/server/SystemServer.java +++ b/services/java/com/android/server/SystemServer.java @@ -726,9 +726,6 @@ public final class SystemServer { // Always start the Device Policy Manager, so that the API is compatible with // API8. mSystemServiceManager.startService(DevicePolicyManagerService.Lifecycle.class); - -// TODO is this a good place? - mSystemServiceManager.startService(ShortcutService.Lifecycle.class); } if (!disableSystemUI) { @@ -1139,6 +1136,8 @@ public final class SystemServer { } Trace.traceEnd(Trace.TRACE_TAG_SYSTEM_SERVER); } + // LauncherAppsService uses ShortcutService. + mSystemServiceManager.startService(ShortcutService.Lifecycle.class); mSystemServiceManager.startService(LauncherAppsService.class); } diff --git a/services/tests/servicestests/res/drawable-nodpi/black_1024x4096.png b/services/tests/servicestests/res/drawable-nodpi/black_1024x4096.png new file mode 100644 index 0000000000000..f70032698896e Binary files /dev/null and b/services/tests/servicestests/res/drawable-nodpi/black_1024x4096.png differ diff --git a/services/tests/servicestests/res/drawable-nodpi/black_16x64.png b/services/tests/servicestests/res/drawable-nodpi/black_16x64.png new file mode 100644 index 0000000000000..315763e45602e Binary files /dev/null and b/services/tests/servicestests/res/drawable-nodpi/black_16x64.png differ diff --git a/services/tests/servicestests/res/drawable-nodpi/black_32x32.png b/services/tests/servicestests/res/drawable-nodpi/black_32x32.png new file mode 100644 index 0000000000000..8958f6b1eabda Binary files /dev/null and b/services/tests/servicestests/res/drawable-nodpi/black_32x32.png differ diff --git a/services/tests/servicestests/res/drawable-nodpi/black_4096x1024.png b/services/tests/servicestests/res/drawable-nodpi/black_4096x1024.png new file mode 100644 index 0000000000000..f675030177343 Binary files /dev/null and b/services/tests/servicestests/res/drawable-nodpi/black_4096x1024.png differ diff --git a/services/tests/servicestests/res/drawable-nodpi/black_4096x4096.png b/services/tests/servicestests/res/drawable-nodpi/black_4096x4096.png new file mode 100644 index 0000000000000..999d8585fc6fe Binary files /dev/null and b/services/tests/servicestests/res/drawable-nodpi/black_4096x4096.png differ diff --git a/services/tests/servicestests/res/drawable-nodpi/black_512x512.png b/services/tests/servicestests/res/drawable-nodpi/black_512x512.png new file mode 100644 index 0000000000000..40d1c2c41f140 Binary files /dev/null and b/services/tests/servicestests/res/drawable-nodpi/black_512x512.png differ diff --git a/services/tests/servicestests/res/drawable-nodpi/black_64x16.png b/services/tests/servicestests/res/drawable-nodpi/black_64x16.png new file mode 100644 index 0000000000000..5883015199753 Binary files /dev/null and b/services/tests/servicestests/res/drawable-nodpi/black_64x16.png differ diff --git a/services/tests/servicestests/res/drawable-nodpi/black_64x64.png b/services/tests/servicestests/res/drawable-nodpi/black_64x64.png new file mode 100644 index 0000000000000..71cfafc2ad304 Binary files /dev/null and b/services/tests/servicestests/res/drawable-nodpi/black_64x64.png differ diff --git a/services/tests/servicestests/res/drawable-nodpi/icon1.png b/services/tests/servicestests/res/drawable-nodpi/icon1.png new file mode 100644 index 0000000000000..64eb294363d27 Binary files /dev/null and b/services/tests/servicestests/res/drawable-nodpi/icon1.png differ diff --git a/services/tests/servicestests/res/drawable-nodpi/icon2.png b/services/tests/servicestests/res/drawable-nodpi/icon2.png new file mode 100644 index 0000000000000..75024841d327c Binary files /dev/null and b/services/tests/servicestests/res/drawable-nodpi/icon2.png differ diff --git a/services/tests/servicestests/src/com/android/server/pm/ShortcutManagerTest.java b/services/tests/servicestests/src/com/android/server/pm/ShortcutManagerTest.java index 1f805e9116baa..21daa1b47d8c6 100644 --- a/services/tests/servicestests/src/com/android/server/pm/ShortcutManagerTest.java +++ b/services/tests/servicestests/src/com/android/server/pm/ShortcutManagerTest.java @@ -25,19 +25,26 @@ import android.content.pm.LauncherApps.ShortcutQuery; import android.content.pm.ShortcutInfo; import android.content.pm.ShortcutManager; import android.content.pm.ShortcutServiceInternal; +import android.content.res.Resources; +import android.graphics.Bitmap; import android.graphics.BitmapFactory; import android.graphics.drawable.Icon; import android.os.Bundle; import android.os.FileUtils; +import android.os.ParcelFileDescriptor; import android.os.UserHandle; import android.test.AndroidTestCase; import android.test.mock.MockContext; +import android.test.suitebuilder.annotation.SmallTest; import android.util.Log; import com.android.frameworks.servicestests.R; import com.android.internal.util.Preconditions; import com.android.server.LocalServices; import com.android.server.SystemService; +import com.android.server.pm.ShortcutService.FileOutputStreamWithPath; + +import libcore.io.IoUtils; import org.junit.Assert; @@ -45,13 +52,16 @@ import java.io.BufferedReader; import java.io.ByteArrayOutputStream; import java.io.File; import java.io.FileReader; +import java.io.IOException; import java.io.PrintWriter; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collection; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Set; /** * Tests for ShortcutService and ShortcutManager. @@ -61,7 +71,11 @@ import java.util.Map; -r -g ${ANDROID_PRODUCT_OUT}/data/app/FrameworksServicesTests/FrameworksServicesTests.apk && adb shell am instrument -e class com.android.server.pm.ShortcutManagerTest \ -w com.android.frameworks.servicestests/android.support.test.runner.AndroidJUnitRunner + + * TODO: Add checks with assertAllNotHaveIcon() + * TODO: Cross-user test (do in CTS?) */ +@SmallTest public class ShortcutManagerTest extends AndroidTestCase { private static final String TAG = "ShortcutManagerTest"; @@ -77,10 +91,19 @@ public class ShortcutManagerTest extends AndroidTestCase { public String getPackageName() { return mInjectedClientPackage; } + + @Override + public Resources getResources() { + return ShortcutManagerTest.this.getContext().getResources(); + } } /** Context used in the service side */ private final class ServiceContext extends MockContext { + @Override + public Resources getResources() { + return ShortcutManagerTest.this.getContext().getResources(); + } } /** ShortcutService with injection override methods. */ @@ -95,6 +118,7 @@ public class ShortcutManagerTest extends AndroidTestCase { setResetIntervalForTest(INTERVAL); setMaxDynamicShortcutsForTest(MAX_SHORTCUTS); setMaxDailyUpdatesForTest(MAX_DAILY_UPDATES); + setMaxIconDimensionForTest(MAX_ICON_DIMENSION); } @Override @@ -108,7 +132,7 @@ public class ShortcutManagerTest extends AndroidTestCase { } @Override - int injectGetPackageUid(String packageName) { + int injectGetPackageUid(String packageName, int userId) { Integer uid = mInjectedPackageUidMap.get(packageName); return uid != null ? uid : -1; } @@ -122,6 +146,11 @@ public class ShortcutManagerTest extends AndroidTestCase { File injectUserDataPath(@UserIdInt int userId) { return new File(mInjectedFilePathRoot, "user-" + userId); } + + @Override + void injectValidateIconResPackage(ShortcutInfo shortcut, Icon icon) { + // Can't check + } } /** ShortcutManager with injection override methods. */ @@ -181,10 +210,12 @@ public class ShortcutManagerTest extends AndroidTestCase { private static final long INTERVAL = 10000; - private static final int MAX_SHORTCUTS = 5; + private static final int MAX_SHORTCUTS = 10; private static final int MAX_DAILY_UPDATES = 3; + private static final int MAX_ICON_DIMENSION = 128; + @Override protected void setUp() throws Exception { super.setUp(); @@ -233,7 +264,8 @@ public class ShortcutManagerTest extends AndroidTestCase { /** Replace the current calling package */ private void setCaller(String packageName) { mInjectedClientPackage = packageName; - mInjectedCallingUid = Preconditions.checkNotNull(mInjectedPackageUidMap.get(packageName)); + mInjectedCallingUid = Preconditions.checkNotNull(mInjectedPackageUidMap.get(packageName), + "Unknown package"); } private String getCallingPackage() { @@ -342,6 +374,28 @@ public class ShortcutManagerTest extends AndroidTestCase { return s; } + /** + * Make a shortcut with an ID and icon. + */ + private ShortcutInfo makeShortcutWithIcon(String id, Icon icon) { + return makeShortcut( + id, "Title-" + id, /* activity =*/ null, icon, + makeIntent(Intent.ACTION_VIEW, ShortcutActivity.class), /* weight =*/ 0); + } + + private ShortcutInfo makePackageShortcut(String packageName, String id) { + String origCaller = getCallingPackage(); + + setCaller(packageName); + ShortcutInfo s = makeShortcut( + id, "Title-" + id, /* activity =*/ null, /* icon =*/ null, + makeIntent(Intent.ACTION_VIEW, ShortcutActivity.class), /* weight =*/ 0); + setCaller(origCaller); // restore the caller + + return s; + } + + /** * Make multiple shortcuts with IDs. */ @@ -413,6 +467,7 @@ public class ShortcutManagerTest extends AndroidTestCase { @NonNull private List assertShortcutIds(@NonNull List actualShortcuts, String... expectedIds) { + assertEquals(expectedIds.length, actualShortcuts.size()); final HashSet expected = new HashSet<>(Arrays.asList(expectedIds)); final HashSet actual = new HashSet<>(); for (ShortcutInfo s : actualShortcuts) { @@ -460,6 +515,35 @@ public class ShortcutManagerTest extends AndroidTestCase { return actualShortcuts; } + @NonNull + private List assertAllNotHaveIcon( + @NonNull List actualShortcuts) { + for (ShortcutInfo s : actualShortcuts) { + assertNull("ID " + s.getId(), s.getIcon()); + } + return actualShortcuts; + } + + @NonNull + private List assertAllHaveIconResId( + @NonNull List actualShortcuts) { + for (ShortcutInfo s : actualShortcuts) { + assertTrue("ID " + s.getId() + " not have icon res ID", s.hasIconResource()); + assertFalse("ID " + s.getId() + " shouldn't have icon FD", s.hasIconFile()); + } + return actualShortcuts; + } + + @NonNull + private List assertAllHaveIconFile( + @NonNull List actualShortcuts) { + for (ShortcutInfo s : actualShortcuts) { + assertFalse("ID " + s.getId() + " shouldn't have icon res ID", s.hasIconResource()); + assertTrue("ID " + s.getId() + " not have icon FD", s.hasIconFile()); + } + return actualShortcuts; + } + @NonNull private List assertAllHaveFlags(@NonNull List actualShortcuts, int shortcutFlags) { @@ -469,6 +553,24 @@ public class ShortcutManagerTest extends AndroidTestCase { return actualShortcuts; } + @NonNull + private List assertAllKeyFieldsOnly( + @NonNull List actualShortcuts) { + for (ShortcutInfo s : actualShortcuts) { + assertTrue("ID " + s.getId(), s.hasKeyFieldsOnly()); + } + return actualShortcuts; + } + + @NonNull + private List assertAllNotKeyFieldsOnly( + @NonNull List actualShortcuts) { + for (ShortcutInfo s : actualShortcuts) { + assertFalse("ID " + s.getId(), s.hasKeyFieldsOnly()); + } + return actualShortcuts; + } + @NonNull private List assertAllDynamic(@NonNull List actualShortcuts) { return assertAllHaveFlags(actualShortcuts, ShortcutInfo.FLAG_DYNAMIC); @@ -488,6 +590,31 @@ public class ShortcutManagerTest extends AndroidTestCase { return actualShortcuts; } + private void assertBitmapSize(int expectedWidth, int expectedHeight, @NonNull Bitmap bitmap) { + assertEquals("width", expectedWidth, bitmap.getWidth()); + assertEquals("height", expectedHeight, bitmap.getHeight()); + } + + private void assertAllUnique(Collection list) { + final Set set = new HashSet<>(); + for (T item : list) { + if (set.contains(item)) { + fail("Duplicate item found: " + item + " (in the list: " + list + ")"); + } + set.add(item); + } + } + + @NonNull + private Bitmap pfdToBitmap(@NonNull ParcelFileDescriptor pfd) { + Preconditions.checkNotNull(pfd); + try { + return BitmapFactory.decodeFileDescriptor(pfd.getFileDescriptor()); + } finally { + IoUtils.closeQuietly(pfd); + } + } + /** * Test for the first launch path, no settings file available. */ @@ -594,13 +721,17 @@ public class ShortcutManagerTest extends AndroidTestCase { final ShortcutInfo si3 = makeShortcut("shortcut3"); assertTrue(mManager.setDynamicShortcuts(Arrays.asList(si1, si2))); - assertEquals(2, mManager.getDynamicShortcuts().size()); + assertShortcutIds(assertAllNotKeyFieldsOnly( + mManager.getDynamicShortcuts()), + "shortcut1", "shortcut2"); assertEquals(2, mManager.getRemainingCallCount()); // TODO: Check fields assertTrue(mManager.setDynamicShortcuts(Arrays.asList(si1))); - assertEquals(1, mManager.getDynamicShortcuts().size()); + assertShortcutIds(assertAllNotKeyFieldsOnly( + mManager.getDynamicShortcuts()), + "shortcut1"); assertEquals(1, mManager.getRemainingCallCount()); assertTrue(mManager.setDynamicShortcuts(Arrays.asList())); @@ -629,16 +760,22 @@ public class ShortcutManagerTest extends AndroidTestCase { assertTrue(mManager.setDynamicShortcuts(Arrays.asList(si1))); assertEquals(2, mManager.getRemainingCallCount()); - assertEquals(1, mManager.getDynamicShortcuts().size()); + assertShortcutIds(assertAllNotKeyFieldsOnly( + mManager.getDynamicShortcuts()), + "shortcut1"); assertTrue(mManager.addDynamicShortcut(si2)); assertEquals(1, mManager.getRemainingCallCount()); - assertEquals(2, mManager.getDynamicShortcuts().size()); + assertShortcutIds(assertAllNotKeyFieldsOnly( + mManager.getDynamicShortcuts()), + "shortcut1", "shortcut2"); // Add with the same ID assertTrue(mManager.addDynamicShortcut(makeShortcut("shortcut1"))); assertEquals(0, mManager.getRemainingCallCount()); - assertEquals(2, mManager.getDynamicShortcuts().size()); // Still 2 + assertShortcutIds(assertAllNotKeyFieldsOnly( + mManager.getDynamicShortcuts()), + "shortcut1", "shortcut2"); // TODO Check max number @@ -651,24 +788,35 @@ public class ShortcutManagerTest extends AndroidTestCase { final ShortcutInfo si3 = makeShortcut("shortcut3"); assertTrue(mManager.setDynamicShortcuts(Arrays.asList(si1, si2, si3))); - assertEquals(3, mManager.getDynamicShortcuts().size()); + assertShortcutIds(assertAllNotKeyFieldsOnly( + mManager.getDynamicShortcuts()), + "shortcut1", "shortcut2", "shortcut3"); assertEquals(2, mManager.getRemainingCallCount()); mManager.deleteDynamicShortcut("shortcut1"); - assertEquals(2, mManager.getDynamicShortcuts().size()); + assertShortcutIds(assertAllNotKeyFieldsOnly( + mManager.getDynamicShortcuts()), + "shortcut2", "shortcut3"); mManager.deleteDynamicShortcut("shortcut1"); - assertEquals(2, mManager.getDynamicShortcuts().size()); + assertShortcutIds(assertAllNotKeyFieldsOnly( + mManager.getDynamicShortcuts()), + "shortcut2", "shortcut3"); mManager.deleteDynamicShortcut("shortcutXXX"); - assertEquals(2, mManager.getDynamicShortcuts().size()); + assertShortcutIds(assertAllNotKeyFieldsOnly( + mManager.getDynamicShortcuts()), + "shortcut2", "shortcut3"); mManager.deleteDynamicShortcut("shortcut2"); - assertEquals(1, mManager.getDynamicShortcuts().size()); + assertShortcutIds(assertAllNotKeyFieldsOnly( + mManager.getDynamicShortcuts()), + "shortcut3"); mManager.deleteDynamicShortcut("shortcut3"); - assertEquals(0, mManager.getDynamicShortcuts().size()); + assertShortcutIds(assertAllNotKeyFieldsOnly( + mManager.getDynamicShortcuts())); // Still 2 calls left. assertEquals(2, mManager.getRemainingCallCount()); @@ -682,7 +830,9 @@ public class ShortcutManagerTest extends AndroidTestCase { final ShortcutInfo si3 = makeShortcut("shortcut3"); assertTrue(mManager.setDynamicShortcuts(Arrays.asList(si1, si2, si3))); - assertEquals(3, mManager.getDynamicShortcuts().size()); + assertShortcutIds(assertAllNotKeyFieldsOnly( + mManager.getDynamicShortcuts()), + "shortcut1", "shortcut2", "shortcut3"); assertEquals(2, mManager.getRemainingCallCount()); @@ -732,7 +882,7 @@ public class ShortcutManagerTest extends AndroidTestCase { // Now it should work. mInjectedCurrentTimeLillis++; - assertTrue(mManager.setDynamicShortcuts(Arrays.asList(si1))); + assertTrue(mManager.setDynamicShortcuts(Arrays.asList(si1))); // fail assertEquals(2, mManager.getRemainingCallCount()); mInjectedCurrentTimeLillis++; @@ -831,6 +981,226 @@ public class ShortcutManagerTest extends AndroidTestCase { assertFalse(mManager.setDynamicShortcuts(Arrays.asList(si2))); } + public void testIcons() { + final Icon res32x32 = Icon.createWithResource(mContext, R.drawable.black_32x32); + final Icon res64x64 = Icon.createWithResource(mContext, R.drawable.black_64x64); + final Icon res512x512 = Icon.createWithResource(mContext, R.drawable.black_512x512); + + final Icon bmp32x32 = Icon.createWithBitmap(BitmapFactory.decodeResource( + mContext.getResources(), R.drawable.black_32x32)); + final Icon bmp64x64 = Icon.createWithBitmap(BitmapFactory.decodeResource( + mContext.getResources(), R.drawable.black_64x64)); + final Icon bmp512x512 = Icon.createWithBitmap(BitmapFactory.decodeResource( + mContext.getResources(), R.drawable.black_512x512)); + + // Set from package 1 + setCaller(CALLING_PACKAGE_1); + assertTrue(mManager.setDynamicShortcuts(Arrays.asList( + makeShortcutWithIcon("res32x32", res32x32), + makeShortcutWithIcon("res64x64", res64x64), + makeShortcutWithIcon("bmp32x32", bmp32x32), + makeShortcutWithIcon("bmp64x64", bmp64x64), + makeShortcutWithIcon("bmp512x512", bmp512x512), + makeShortcut("none") + ))); + + // getDynamicShortcuts() shouldn't return icons, thus assertAllNotHaveIcon(). + assertShortcutIds(assertAllNotHaveIcon(mManager.getDynamicShortcuts()), + "res32x32", + "res64x64", + "bmp32x32", + "bmp64x64", + "bmp512x512", + "none"); + + // Call from another caller with the same ID, just to make sure storage is per-package. + setCaller(CALLING_PACKAGE_2); + assertTrue(mManager.setDynamicShortcuts(Arrays.asList( + makeShortcutWithIcon("res32x32", res512x512), + makeShortcutWithIcon("res64x64", res512x512), + makeShortcutWithIcon("none", res512x512) + ))); + assertShortcutIds(assertAllNotHaveIcon(mManager.getDynamicShortcuts()), + "res32x32", + "res64x64", + "none"); + + dumpsysOnLogcat(); + + // Load from launcher. + Bitmap bmp; + + setCaller(LAUNCHER_1); + + // Check hasIconResource()/hasIconFile(). + assertShortcutIds(assertAllHaveIconResId(mInternal.getShortcutInfo( + getCallingPackage(), CALLING_PACKAGE_1, Arrays.asList("res32x32"), + getCallingUserId())), "res32x32"); + + assertShortcutIds(assertAllHaveIconResId(mInternal.getShortcutInfo( + getCallingPackage(), CALLING_PACKAGE_1, Arrays.asList("res64x64"), + getCallingUserId())), "res64x64"); + + assertShortcutIds(assertAllHaveIconFile(mInternal.getShortcutInfo( + getCallingPackage(), CALLING_PACKAGE_1, Arrays.asList("bmp32x32"), + getCallingUserId())), "bmp32x32"); + assertShortcutIds(assertAllHaveIconFile(mInternal.getShortcutInfo( + getCallingPackage(), CALLING_PACKAGE_1, Arrays.asList("bmp64x64"), + getCallingUserId())), "bmp64x64"); + assertShortcutIds(assertAllHaveIconFile(mInternal.getShortcutInfo( + getCallingPackage(), CALLING_PACKAGE_1, Arrays.asList("bmp512x512"), + getCallingUserId())), "bmp512x512"); + + // Check + assertEquals( + R.drawable.black_32x32, + mInternal.getShortcutIconResId(getCallingPackage(), + makePackageShortcut(CALLING_PACKAGE_1, "res32x32"), getCallingUserId())); + + assertEquals( + R.drawable.black_64x64, + mInternal.getShortcutIconResId( + getCallingPackage(), + makePackageShortcut(CALLING_PACKAGE_1, "res64x64"), getCallingUserId())); + + assertEquals( + 0, // because it's not a resource + mInternal.getShortcutIconResId( + getCallingPackage(), + makePackageShortcut(CALLING_PACKAGE_1, "bmp32x32"), getCallingUserId())); + assertEquals( + 0, // because it's not a resource + mInternal.getShortcutIconResId( + getCallingPackage(), + makePackageShortcut(CALLING_PACKAGE_1, "bmp64x64"), getCallingUserId())); + assertEquals( + 0, // because it's not a resource + mInternal.getShortcutIconResId( + getCallingPackage(), + makePackageShortcut(CALLING_PACKAGE_1, "bmp512x512"), getCallingUserId())); + + bmp = pfdToBitmap(mInternal.getShortcutIconFd( + getCallingPackage(), + makePackageShortcut(CALLING_PACKAGE_1, "bmp32x32"), getCallingUserId())); + assertBitmapSize(32, 32, bmp); + + bmp = pfdToBitmap(mInternal.getShortcutIconFd( + getCallingPackage(), + makePackageShortcut(CALLING_PACKAGE_1, "bmp64x64"), getCallingUserId())); + assertBitmapSize(64, 64, bmp); + + bmp = pfdToBitmap(mInternal.getShortcutIconFd( + getCallingPackage(), + makePackageShortcut(CALLING_PACKAGE_1, "bmp512x512"), getCallingUserId())); + assertBitmapSize(128, 128, bmp); + + // TODO Test the content URI case too. + } + + private void checkShrinkBitmap(int expectedWidth, int expectedHeight, int resId, int maxSize) { + assertBitmapSize(expectedWidth, expectedHeight, + ShortcutService.shrinkBitmap(BitmapFactory.decodeResource( + mContext.getResources(), resId), + maxSize)); + } + + public void testShrinkBitmap() { + checkShrinkBitmap(32, 32, R.drawable.black_512x512, 32); + checkShrinkBitmap(511, 511, R.drawable.black_512x512, 511); + checkShrinkBitmap(512, 512, R.drawable.black_512x512, 512); + + checkShrinkBitmap(1024, 4096, R.drawable.black_1024x4096, 4096); + checkShrinkBitmap(1024, 4096, R.drawable.black_1024x4096, 4100); + checkShrinkBitmap(512, 2048, R.drawable.black_1024x4096, 2048); + + checkShrinkBitmap(4096, 1024, R.drawable.black_4096x1024, 4096); + checkShrinkBitmap(4096, 1024, R.drawable.black_4096x1024, 4100); + checkShrinkBitmap(2048, 512, R.drawable.black_4096x1024, 2048); + } + + private File openIconFileForWriteAndGetPath(int userId, String packageName) + throws IOException { + // Shortcut IDs aren't used in the path, so just pass the same ID. + final FileOutputStreamWithPath out = + mService.openIconFileForWrite(userId, makePackageShortcut(packageName, "id")); + out.close(); + return out.getFile(); + } + + public void testOpenIconFileForWrite() throws IOException { + mInjectedCurrentTimeLillis = 1000; + + final File p10_1_1 = openIconFileForWriteAndGetPath(10, CALLING_PACKAGE_1); + final File p10_1_2 = openIconFileForWriteAndGetPath(10, CALLING_PACKAGE_1); + + final File p10_2_1 = openIconFileForWriteAndGetPath(10, CALLING_PACKAGE_2); + final File p10_2_2 = openIconFileForWriteAndGetPath(10, CALLING_PACKAGE_2); + + final File p11_1_1 = openIconFileForWriteAndGetPath(11, CALLING_PACKAGE_1); + final File p11_1_2 = openIconFileForWriteAndGetPath(11, CALLING_PACKAGE_1); + + mInjectedCurrentTimeLillis++; + + final File p10_1_3 = openIconFileForWriteAndGetPath(10, CALLING_PACKAGE_1); + final File p10_1_4 = openIconFileForWriteAndGetPath(10, CALLING_PACKAGE_1); + final File p10_1_5 = openIconFileForWriteAndGetPath(10, CALLING_PACKAGE_1); + + final File p10_2_3 = openIconFileForWriteAndGetPath(10, CALLING_PACKAGE_2); + final File p11_1_3 = openIconFileForWriteAndGetPath(11, CALLING_PACKAGE_1); + + // Make sure their paths are all unique + assertAllUnique(Arrays.asList( + p10_1_1, + p10_1_2, + p10_1_3, + p10_1_4, + p10_1_5, + + p10_2_1, + p10_2_2, + p10_2_3, + + p11_1_1, + p11_1_2, + p11_1_3 + )); + + // Check each set has the same parent. + assertEquals(p10_1_1.getParent(), p10_1_2.getParent()); + assertEquals(p10_1_1.getParent(), p10_1_3.getParent()); + assertEquals(p10_1_1.getParent(), p10_1_4.getParent()); + assertEquals(p10_1_1.getParent(), p10_1_5.getParent()); + + assertEquals(p10_2_1.getParent(), p10_2_2.getParent()); + assertEquals(p10_2_1.getParent(), p10_2_3.getParent()); + + assertEquals(p11_1_1.getParent(), p11_1_2.getParent()); + assertEquals(p11_1_1.getParent(), p11_1_3.getParent()); + + // Check the parents are still unique. + assertAllUnique(Arrays.asList( + p10_1_1.getParent(), + p10_2_1.getParent(), + p11_1_1.getParent() + )); + + // All files created at the same time for the same package/user, expcet for the first ones, + // will have "_" in the path. + assertFalse(p10_1_1.getName().contains("_")); + assertTrue(p10_1_2.getName().contains("_")); + assertFalse(p10_1_3.getName().contains("_")); + assertTrue(p10_1_4.getName().contains("_")); + assertTrue(p10_1_5.getName().contains("_")); + + assertFalse(p10_2_1.getName().contains("_")); + assertTrue(p10_2_2.getName().contains("_")); + assertFalse(p10_2_3.getName().contains("_")); + + assertFalse(p11_1_1.getName().contains("_")); + assertTrue(p11_1_2.getName().contains("_")); + assertFalse(p11_1_3.getName().contains("_")); + } + // TODO: updateShortcuts() // TODO: getPinnedShortcuts() @@ -860,9 +1230,10 @@ public class ShortcutManagerTest extends AndroidTestCase { // Get dynamic assertAllDynamic(assertAllHaveTitle(assertAllNotHaveIntents(assertShortcutIds( + assertAllNotKeyFieldsOnly( mInternal.getShortcuts(getCallingPackage(), /* time =*/ 0, CALLING_PACKAGE_1, /* activity =*/ null, - ShortcutQuery.FLAG_GET_DYNAMIC, getCallingUserId()), + ShortcutQuery.FLAG_GET_DYNAMIC, getCallingUserId())), "s1", "s2")))); // Get pinned @@ -874,18 +1245,20 @@ public class ShortcutManagerTest extends AndroidTestCase { // Get both, with timestamp assertAllDynamic(assertAllHaveTitle(assertAllNotHaveIntents(assertShortcutIds( - mInternal.getShortcuts(getCallingPackage(), /* time =*/ 1000, CALLING_PACKAGE_2, + assertAllNotKeyFieldsOnly(mInternal.getShortcuts(getCallingPackage(), + /* time =*/ 1000, CALLING_PACKAGE_2, /* activity =*/ null, ShortcutQuery.FLAG_GET_PINNED | ShortcutQuery.FLAG_GET_DYNAMIC, - getCallingUserId()), + getCallingUserId())), "s2", "s3")))); // FLAG_GET_KEY_FIELDS_ONLY assertAllDynamic(assertAllNotHaveTitle(assertAllNotHaveIntents(assertShortcutIds( - mInternal.getShortcuts(getCallingPackage(), /* time =*/ 1000, CALLING_PACKAGE_2, + assertAllKeyFieldsOnly(mInternal.getShortcuts(getCallingPackage(), + /* time =*/ 1000, CALLING_PACKAGE_2, /* activity =*/ null, ShortcutQuery.FLAG_GET_DYNAMIC | ShortcutQuery.FLAG_GET_KEY_FIELDS_ONLY, - getCallingUserId()), + getCallingUserId())), "s2", "s3")))); // Pin some shortcuts. @@ -894,19 +1267,20 @@ public class ShortcutManagerTest extends AndroidTestCase { // Pinned ones only assertAllPinned(assertAllHaveTitle(assertAllNotHaveIntents(assertShortcutIds( - mInternal.getShortcuts(getCallingPackage(), /* time =*/ 1000, CALLING_PACKAGE_2, + assertAllNotKeyFieldsOnly(mInternal.getShortcuts(getCallingPackage(), + /* time =*/ 1000, CALLING_PACKAGE_2, /* activity =*/ null, ShortcutQuery.FLAG_GET_PINNED, - getCallingUserId()), + getCallingUserId())), "s3")))); // All packages. - assertShortcutIds( + assertShortcutIds(assertAllNotKeyFieldsOnly( mInternal.getShortcuts(getCallingPackage(), /* time =*/ 5000, /* package= */ null, /* activity =*/ null, ShortcutQuery.FLAG_GET_DYNAMIC | ShortcutQuery.FLAG_GET_PINNED, - getCallingUserId()), + getCallingUserId())), "s1", "s3"); // TODO More tests: pinned but dynamic, filter by activity @@ -968,8 +1342,9 @@ public class ShortcutManagerTest extends AndroidTestCase { // Note we don't guarantee the orders. list = assertShortcutIds(assertAllHaveTitle(assertAllNotHaveIntents( + assertAllNotKeyFieldsOnly( mInternal.getShortcutInfo(getCallingPackage(), CALLING_PACKAGE_1, - Arrays.asList("s2", "s1", "s3", null), getCallingUserId()))), + Arrays.asList("s2", "s1", "s3", null), getCallingUserId())))), "s1", "s2"); assertEquals("Title 1", findById(list, "s1").getTitle()); assertEquals("Title 2", findById(list, "s2").getTitle()); @@ -1036,19 +1411,19 @@ public class ShortcutManagerTest extends AndroidTestCase { setCaller(LAUNCHER_1); // CALLING_PACKAGE_1 deleted s2, but it's pinned, so it still exists. - assertShortcutIds(assertAllPinned( + assertShortcutIds(assertAllPinned(assertAllNotKeyFieldsOnly( mInternal.getShortcuts(getCallingPackage(), /* time =*/ 0, CALLING_PACKAGE_1, - /* activity =*/ null, ShortcutQuery.FLAG_GET_PINNED, getCallingUserId())), + /* activity =*/ null, ShortcutQuery.FLAG_GET_PINNED, getCallingUserId()))), "s2"); - assertShortcutIds(assertAllPinned( + assertShortcutIds(assertAllPinned(assertAllNotKeyFieldsOnly( mInternal.getShortcuts(getCallingPackage(), /* time =*/ 0, CALLING_PACKAGE_2, - /* activity =*/ null, ShortcutQuery.FLAG_GET_PINNED, getCallingUserId())), + /* activity =*/ null, ShortcutQuery.FLAG_GET_PINNED, getCallingUserId()))), "s3", "s4"); - assertShortcutIds(assertAllPinned( + assertShortcutIds(assertAllPinned(assertAllNotKeyFieldsOnly( mInternal.getShortcuts(getCallingPackage(), /* time =*/ 0, CALLING_PACKAGE_3, - /* activity =*/ null, ShortcutQuery.FLAG_GET_PINNED, getCallingUserId())) + /* activity =*/ null, ShortcutQuery.FLAG_GET_PINNED, getCallingUserId()))) /* none */); }