1 /** 2 * Copyright (C) 2017 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package androidx.core.content.pm; 18 19 import static androidx.core.graphics.drawable.IconCompat.TYPE_URI; 20 import static androidx.core.graphics.drawable.IconCompat.TYPE_URI_ADAPTIVE_BITMAP; 21 22 import android.app.Activity; 23 import android.app.ActivityManager; 24 import android.content.BroadcastReceiver; 25 import android.content.Context; 26 import android.content.Intent; 27 import android.content.IntentSender; 28 import android.content.pm.ActivityInfo; 29 import android.content.pm.PackageManager; 30 import android.content.pm.ResolveInfo; 31 import android.content.pm.ShortcutInfo; 32 import android.content.pm.ShortcutManager; 33 import android.graphics.Bitmap; 34 import android.graphics.BitmapFactory; 35 import android.os.Build; 36 import android.os.Bundle; 37 import android.text.TextUtils; 38 import android.util.DisplayMetrics; 39 40 import androidx.annotation.IntDef; 41 import androidx.annotation.RequiresApi; 42 import androidx.annotation.RestrictTo; 43 import androidx.annotation.RestrictTo.Scope; 44 import androidx.annotation.VisibleForTesting; 45 import androidx.core.content.ContextCompat; 46 import androidx.core.graphics.drawable.IconCompat; 47 import androidx.core.util.Preconditions; 48 49 import org.jspecify.annotations.NonNull; 50 import org.jspecify.annotations.Nullable; 51 52 import java.io.InputStream; 53 import java.lang.annotation.Retention; 54 import java.lang.annotation.RetentionPolicy; 55 import java.lang.reflect.Method; 56 import java.util.ArrayList; 57 import java.util.Arrays; 58 import java.util.Collections; 59 import java.util.List; 60 import java.util.Objects; 61 62 /** 63 * Helper for accessing features in {@link android.content.pm.ShortcutManager}. 64 */ 65 public class ShortcutManagerCompat { 66 67 /** 68 * Include manifest shortcuts in the result. 69 * 70 * @see #getShortcuts 71 */ 72 public static final int FLAG_MATCH_MANIFEST = 1 << 0; 73 74 /** 75 * Include dynamic shortcuts in the result. 76 * 77 * @see #getShortcuts 78 */ 79 public static final int FLAG_MATCH_DYNAMIC = 1 << 1; 80 81 /** 82 * Include pinned shortcuts in the result. 83 * 84 * @see #getShortcuts 85 */ 86 public static final int FLAG_MATCH_PINNED = 1 << 2; 87 88 /** 89 * Include cached shortcuts in the result. 90 * 91 * @see #getShortcuts 92 */ 93 public static final int FLAG_MATCH_CACHED = 1 << 3; 94 95 @RestrictTo(Scope.LIBRARY_GROUP_PREFIX) 96 @IntDef(flag = true, value = { 97 FLAG_MATCH_MANIFEST, 98 FLAG_MATCH_DYNAMIC, 99 FLAG_MATCH_PINNED, 100 FLAG_MATCH_CACHED, 101 }) 102 @Retention(RetentionPolicy.SOURCE) 103 public @interface ShortcutMatchFlags {} 104 105 @VisibleForTesting static final String ACTION_INSTALL_SHORTCUT = 106 "com.android.launcher.action.INSTALL_SHORTCUT"; 107 @VisibleForTesting static final String INSTALL_SHORTCUT_PERMISSION = 108 "com.android.launcher.permission.INSTALL_SHORTCUT"; 109 110 private static final int DEFAULT_MAX_ICON_DIMENSION_DP = 96; 111 private static final int DEFAULT_MAX_ICON_DIMENSION_LOWRAM_DP = 48; 112 113 /** 114 * Key to get the shortcut ID from extras of a share intent. 115 * 116 * When user selects a direct share item from ShareSheet, the app will receive a share intent 117 * which includes the ID of the corresponding shortcut in the extras field. 118 */ 119 public static final String EXTRA_SHORTCUT_ID = "android.intent.extra.shortcut.ID"; 120 121 /** 122 * ShortcutInfoCompatSaver instance that provides APIs to persist shortcuts locally. 123 * 124 * Will be instantiated by reflection to load an implementation from another module if possible. 125 * If fails to load an implementation via reflection, will use the default implementation which 126 * is no-op to avoid unnecessary disk I/O. 127 */ 128 private static volatile ShortcutInfoCompatSaver<?> sShortcutInfoCompatSaver = null; 129 130 /** 131 * Will be instantiated by reflection to load an implementation from another module if 132 * possible. Modules can declare the class to be instantiated using the meta-data in their 133 * Manifest. 134 * 135 * If fails to load an implementation via reflection, will use the default implementation which 136 * is no-op. 137 */ 138 private static volatile List<ShortcutInfoChangeListener> sShortcutInfoChangeListeners = null; 139 140 private static final String SHORTCUT_LISTENER_INTENT_FILTER_ACTION = "androidx.core.content.pm" 141 + ".SHORTCUT_LISTENER"; 142 private static final String SHORTCUT_LISTENER_META_DATA_KEY = "androidx.core.content.pm" 143 + ".shortcut_listener_impl"; 144 ShortcutManagerCompat()145 private ShortcutManagerCompat() { 146 /* Hide constructor */ 147 } 148 149 /** 150 * @return {@code true} if the launcher supports {@link #requestPinShortcut}, 151 * {@code false} otherwise 152 */ isRequestPinShortcutSupported(@onNull Context context)153 public static boolean isRequestPinShortcutSupported(@NonNull Context context) { 154 if (Build.VERSION.SDK_INT >= 26) { 155 return context.getSystemService(ShortcutManager.class).isRequestPinShortcutSupported(); 156 } 157 158 if (ContextCompat.checkSelfPermission(context, INSTALL_SHORTCUT_PERMISSION) 159 != PackageManager.PERMISSION_GRANTED) { 160 return false; 161 } 162 for (ResolveInfo info : context.getPackageManager().queryBroadcastReceivers( 163 new Intent(ACTION_INSTALL_SHORTCUT), 0)) { 164 String permission = info.activityInfo.permission; 165 if (TextUtils.isEmpty(permission) || INSTALL_SHORTCUT_PERMISSION.equals(permission)) { 166 return true; 167 } 168 } 169 return false; 170 } 171 172 /** 173 * Request to create a pinned shortcut. 174 * <p>On API <= 25 it creates a legacy shortcut with the provided icon, label and intent. For 175 * newer APIs it will create a {@link android.content.pm.ShortcutInfo} object which can be 176 * updated by the app. 177 * 178 * <p>Use {@link android.app.PendingIntent#getIntentSender()} to create a {@link IntentSender}. 179 * 180 * @param context context to use for the request. 181 * @param shortcut new shortcut to pin 182 * @param callback if not null, this intent will be sent when the shortcut is pinned 183 * 184 * @return {@code true} if the launcher supports this feature 185 * 186 * @see #isRequestPinShortcutSupported 187 * @see IntentSender 188 * @see android.app.PendingIntent#getIntentSender() 189 */ requestPinShortcut(final @NonNull Context context, @NonNull ShortcutInfoCompat shortcut, final @Nullable IntentSender callback)190 public static boolean requestPinShortcut(final @NonNull Context context, 191 @NonNull ShortcutInfoCompat shortcut, final @Nullable IntentSender callback) { 192 if (Build.VERSION.SDK_INT <= 32 193 && shortcut.isExcludedFromSurfaces(ShortcutInfoCompat.SURFACE_LAUNCHER)) { 194 // A shortcut that is not frequently used cannot be pinned to WorkSpace. 195 return false; 196 } 197 if (Build.VERSION.SDK_INT >= 26) { 198 return context.getSystemService(ShortcutManager.class).requestPinShortcut( 199 shortcut.toShortcutInfo(), callback); 200 } 201 202 if (!isRequestPinShortcutSupported(context)) { 203 return false; 204 } 205 Intent intent = shortcut.addToIntent(new Intent(ACTION_INSTALL_SHORTCUT)); 206 207 // If the callback is null, just send the broadcast 208 if (callback == null) { 209 context.sendBroadcast(intent); 210 return true; 211 } 212 213 // Otherwise send the callback when the intent has successfully been dispatched. 214 context.sendOrderedBroadcast(intent, null, new BroadcastReceiver() { 215 @Override 216 public void onReceive(Context context, Intent intent) { 217 try { 218 callback.sendIntent(context, 0, null, null, null); 219 } catch (IntentSender.SendIntentException e) { 220 // Ignore 221 } 222 } 223 }, null, Activity.RESULT_OK, null, null); 224 return true; 225 } 226 227 /** 228 * Returns an Intent which can be used by the launcher to pin shortcut. 229 * <p>This should be used by an Activity to set result in response to 230 * {@link Intent#ACTION_CREATE_SHORTCUT}. 231 * 232 * @param context context to use for the intent. 233 * @param shortcut new shortcut to pin 234 * @return the intent that should be set as the result for the calling activity 235 * 236 * @see Intent#ACTION_CREATE_SHORTCUT 237 */ createShortcutResultIntent(@onNull Context context, @NonNull ShortcutInfoCompat shortcut)238 public static @NonNull Intent createShortcutResultIntent(@NonNull Context context, 239 @NonNull ShortcutInfoCompat shortcut) { 240 Intent result = null; 241 if (Build.VERSION.SDK_INT >= 26) { 242 result = context.getSystemService(ShortcutManager.class) 243 .createShortcutResultIntent(shortcut.toShortcutInfo()); 244 } 245 if (result == null) { 246 result = new Intent(); 247 } 248 return shortcut.addToIntent(result); 249 } 250 251 /** 252 * Returns {@link ShortcutInfoCompat}s that match {@code matchFlags}. 253 * 254 * @param matchFlags result includes shortcuts matching this flags. Any combination of: 255 * <ul> 256 * <li>{@link #FLAG_MATCH_MANIFEST} 257 * <li>{@link #FLAG_MATCH_DYNAMIC} 258 * <li>{@link #FLAG_MATCH_PINNED} 259 * <li>{@link #FLAG_MATCH_CACHED} 260 * </ul> 261 * 262 * Compatibility behavior: 263 * <ul> 264 * <li>API 30 and above, this method matches platform behavior. 265 * <li>API 25 through 29, this method aggregates the result from corresponding platform 266 * api. 267 * <li>API 24 and earlier, this method can only returns dynamic shortcut. Calling this 268 * method with other flag will be ignored. 269 * </ul> 270 * 271 * @param context context to use for the shortcuts. 272 * @return list of {@link ShortcutInfoCompat}s that match the flag. 273 * 274 * <p>At least one of the {@code MATCH} flags should be set. Otherwise no shortcuts will be 275 * returned. 276 * 277 * @throws IllegalStateException when the user is locked. 278 */ getShortcuts(final @NonNull Context context, @ShortcutMatchFlags int matchFlags)279 public static @NonNull List<ShortcutInfoCompat> getShortcuts(final @NonNull Context context, 280 @ShortcutMatchFlags int matchFlags) { 281 if (Build.VERSION.SDK_INT >= 30) { 282 final List<ShortcutInfo> shortcuts = 283 context.getSystemService(ShortcutManager.class).getShortcuts(matchFlags); 284 return ShortcutInfoCompat.fromShortcuts(context, shortcuts); 285 } else if (Build.VERSION.SDK_INT >= 25) { 286 final ShortcutManager manager = context.getSystemService(ShortcutManager.class); 287 final List<ShortcutInfo> shortcuts = new ArrayList<>(); 288 if ((matchFlags & FLAG_MATCH_MANIFEST) != 0) { 289 shortcuts.addAll(manager.getManifestShortcuts()); 290 } 291 if ((matchFlags & FLAG_MATCH_DYNAMIC) != 0) { 292 shortcuts.addAll(manager.getDynamicShortcuts()); 293 } 294 if ((matchFlags & FLAG_MATCH_PINNED) != 0) { 295 shortcuts.addAll(manager.getPinnedShortcuts()); 296 } 297 return ShortcutInfoCompat.fromShortcuts(context, shortcuts); 298 } 299 if ((matchFlags & FLAG_MATCH_DYNAMIC) != 0) { 300 try { 301 return getShortcutInfoSaverInstance(context).getShortcuts(); 302 } catch (Exception e) { 303 // Ignore 304 } 305 } 306 return Collections.emptyList(); 307 } 308 309 /** 310 * Publish the list of dynamic shortcuts. If there are already dynamic or pinned shortcuts with 311 * the same IDs, each mutable shortcut is updated. 312 * <p>On API <= 31 Any shortcuts that are marked as excluded from launcher will not be passed 313 * to the {@link ShortcutManager}, but they might still be available to assistant and other 314 * surfaces through alternative means. 315 * 316 * <p>This API will be rate-limited. 317 * 318 * @return {@code true} if the call has succeeded. {@code false} if the call fails or is 319 * rate-limited. 320 * 321 * @throws IllegalArgumentException if {@link #getMaxShortcutCountPerActivity(Context)} is 322 * exceeded, or when trying to update immutable shortcuts. 323 */ addDynamicShortcuts(@onNull Context context, @NonNull List<ShortcutInfoCompat> shortcutInfoList)324 public static boolean addDynamicShortcuts(@NonNull Context context, 325 @NonNull List<ShortcutInfoCompat> shortcutInfoList) { 326 final List<ShortcutInfoCompat> clone = removeShortcutsExcludedFromSurface( 327 shortcutInfoList, ShortcutInfoCompat.SURFACE_LAUNCHER); 328 if (Build.VERSION.SDK_INT <= 29) { 329 convertUriIconsToBitmapIcons(context, clone); 330 } 331 if (Build.VERSION.SDK_INT >= 25) { 332 ArrayList<ShortcutInfo> shortcuts = new ArrayList<>(); 333 for (ShortcutInfoCompat item : clone) { 334 shortcuts.add(item.toShortcutInfo()); 335 } 336 if (!context.getSystemService(ShortcutManager.class).addDynamicShortcuts(shortcuts)) { 337 return false; 338 } 339 } 340 341 getShortcutInfoSaverInstance(context).addShortcuts(clone); 342 for (ShortcutInfoChangeListener listener : getShortcutInfoListeners(context)) { 343 listener.onShortcutAdded(shortcutInfoList); 344 } 345 return true; 346 } 347 348 /** 349 * @return The maximum number of static and dynamic shortcuts that each launcher icon 350 * can have at a time. 351 */ getMaxShortcutCountPerActivity(@onNull Context context)352 public static int getMaxShortcutCountPerActivity(@NonNull Context context) { 353 Preconditions.checkNotNull(context); 354 if (Build.VERSION.SDK_INT >= 25) { 355 return context.getSystemService(ShortcutManager.class).getMaxShortcutCountPerActivity(); 356 } 357 358 return 5; 359 } 360 361 /** 362 * Return {@code true} when rate-limiting is active for the caller app. 363 * 364 * <p>For details, see <a href="/guide/topics/ui/shortcuts/managing-shortcuts#rate-limiting"> 365 * Rate limiting</a>. 366 * 367 * @throws IllegalStateException when the user is locked. 368 */ isRateLimitingActive(final @NonNull Context context)369 public static boolean isRateLimitingActive(final @NonNull Context context) { 370 Preconditions.checkNotNull(context); 371 if (Build.VERSION.SDK_INT >= 25) { 372 return context.getSystemService(ShortcutManager.class).isRateLimitingActive(); 373 } 374 375 return getShortcuts(context, FLAG_MATCH_MANIFEST | FLAG_MATCH_DYNAMIC).size() 376 == getMaxShortcutCountPerActivity(context); 377 } 378 379 /** 380 * Return the max width for icons, in pixels. 381 * 382 * <p> Note that this method returns max width of icon's visible part. Hence, it does not take 383 * into account the inset introduced by {@link android.graphics.drawable.AdaptiveIconDrawable}. 384 * To calculate bitmap image to function as 385 * {@link android.graphics.drawable.AdaptiveIconDrawable}, multiply 386 * 1 + 2 * {@link android.graphics.drawable.AdaptiveIconDrawable#getExtraInsetFraction()} to 387 * the returned size. 388 */ getIconMaxWidth(final @NonNull Context context)389 public static int getIconMaxWidth(final @NonNull Context context) { 390 Preconditions.checkNotNull(context); 391 if (Build.VERSION.SDK_INT >= 25) { 392 return context.getSystemService(ShortcutManager.class).getIconMaxWidth(); 393 } 394 return getIconDimensionInternal(context, true); 395 } 396 397 /** 398 * Return the max height for icons, in pixels. 399 */ getIconMaxHeight(final @NonNull Context context)400 public static int getIconMaxHeight(final @NonNull Context context) { 401 Preconditions.checkNotNull(context); 402 if (Build.VERSION.SDK_INT >= 25) { 403 return context.getSystemService(ShortcutManager.class).getIconMaxHeight(); 404 } 405 return getIconDimensionInternal(context, false); 406 } 407 408 /** 409 * Apps that publish shortcuts should call this method whenever the user 410 * selects the shortcut containing the given ID or when the user completes 411 * an action in the app that is equivalent to selecting the shortcut. 412 * For more details, read about 413 * <a href="/guide/topics/ui/shortcuts/managing-shortcuts.html#track-usage"> 414 * tracking shortcut usage</a>. 415 * 416 * <p>The information is accessible via {@link android.app.usage.UsageStatsManager#queryEvents} 417 * Typically, launcher apps use this information to build a prediction model 418 * so that they can promote the shortcuts that are likely to be used at the moment. 419 * 420 * @throws IllegalStateException when the user is locked. 421 * 422 * <p>This method is not supported on devices running SDK < 25 since the platform class will 423 * not be available. 424 */ reportShortcutUsed(final @NonNull Context context, final @NonNull String shortcutId)425 public static void reportShortcutUsed(final @NonNull Context context, 426 final @NonNull String shortcutId) { 427 Preconditions.checkNotNull(context); 428 Preconditions.checkNotNull(shortcutId); 429 if (Build.VERSION.SDK_INT >= 25) { 430 context.getSystemService(ShortcutManager.class).reportShortcutUsed(shortcutId); 431 } 432 433 for (ShortcutInfoChangeListener listener : getShortcutInfoListeners(context)) { 434 listener.onShortcutUsageReported(Collections.singletonList(shortcutId)); 435 } 436 } 437 438 /** 439 * Publish the list of shortcuts. All existing dynamic shortcuts from the caller app 440 * will be replaced. If there are already pinned shortcuts with the same IDs, 441 * the mutable pinned shortcuts are updated. 442 * <p>On API <= 31 Any shortcuts that are marked as excluded from launcher will not be passed 443 * to the {@link ShortcutManager}, but they might still be available to assistant and other 444 * surfaces through alternative means. 445 * 446 * <p>This API will be rate-limited. 447 * 448 * Compatibility behavior: 449 * <ul> 450 * <li>API 25 and above, this method matches platform behavior. 451 * <li>API 24 and earlier, this method is equivalent of calling 452 * {@link #removeAllDynamicShortcuts} and {@link #addDynamicShortcuts} consecutively. 453 * </ul> 454 * 455 * @return {@code true} if the call has succeeded. {@code false} if the call is rate-limited. 456 * 457 * @throws IllegalArgumentException if {@link #getMaxShortcutCountPerActivity} is exceeded, 458 * or when trying to update immutable shortcuts. 459 * 460 * @throws IllegalStateException when the user is locked. 461 */ setDynamicShortcuts(final @NonNull Context context, final @NonNull List<ShortcutInfoCompat> shortcutInfoList)462 public static boolean setDynamicShortcuts(final @NonNull Context context, 463 final @NonNull List<ShortcutInfoCompat> shortcutInfoList) { 464 Preconditions.checkNotNull(context); 465 Preconditions.checkNotNull(shortcutInfoList); 466 final List<ShortcutInfoCompat> clone = removeShortcutsExcludedFromSurface( 467 shortcutInfoList, ShortcutInfoCompat.SURFACE_LAUNCHER); 468 if (Build.VERSION.SDK_INT >= 25) { 469 List<ShortcutInfo> shortcuts = new ArrayList<>(clone.size()); 470 for (ShortcutInfoCompat compat : clone) { 471 shortcuts.add(compat.toShortcutInfo()); 472 } 473 if (!context.getSystemService(ShortcutManager.class).setDynamicShortcuts(shortcuts)) { 474 return false; 475 } 476 } 477 getShortcutInfoSaverInstance(context).removeAllShortcuts(); 478 getShortcutInfoSaverInstance(context).addShortcuts(clone); 479 480 for (ShortcutInfoChangeListener listener : getShortcutInfoListeners(context)) { 481 listener.onAllShortcutsRemoved(); 482 listener.onShortcutAdded(shortcutInfoList); 483 } 484 return true; 485 } 486 487 /** 488 * Return all dynamic shortcuts from the caller app. 489 * 490 * <p>This API is intended to be used for examining what shortcuts are currently published. 491 * Re-publishing returned {@link ShortcutInfo}s via APIs such as 492 * {@link #addDynamicShortcuts(Context, List)} may cause loss of information such as icons. 493 */ getDynamicShortcuts(@onNull Context context)494 public static @NonNull List<ShortcutInfoCompat> getDynamicShortcuts(@NonNull Context context) { 495 if (Build.VERSION.SDK_INT >= 25) { 496 List<ShortcutInfo> shortcuts = context.getSystemService( 497 ShortcutManager.class).getDynamicShortcuts(); 498 List<ShortcutInfoCompat> compats = new ArrayList<>(shortcuts.size()); 499 for (ShortcutInfo item : shortcuts) { 500 compats.add(new ShortcutInfoCompat.Builder(context, item).build()); 501 } 502 return compats; 503 } 504 505 try { 506 return getShortcutInfoSaverInstance(context).getShortcuts(); 507 } catch (Exception e) { 508 /* Do nothing */ 509 } 510 511 return new ArrayList<>(); 512 } 513 514 /** 515 * Update all existing shortcuts with the same IDs. Target shortcuts may be pinned and/or 516 * dynamic, but they must not be immutable. 517 * <p>On API <= 31 Any shortcuts that are marked as excluded from launcher will not be passed 518 * to the {@link ShortcutManager}, but they might still be available to assistant and other 519 * surfaces through alternative means. 520 * 521 * <p>This API will be rate-limited. 522 * 523 * @return {@code true} if the call has succeeded. {@code false} if the call fails or is 524 * rate-limited. 525 * 526 * @throws IllegalArgumentException If trying to update immutable shortcuts. 527 */ updateShortcuts(@onNull Context context, @NonNull List<ShortcutInfoCompat> shortcutInfoList)528 public static boolean updateShortcuts(@NonNull Context context, 529 @NonNull List<ShortcutInfoCompat> shortcutInfoList) { 530 final List<ShortcutInfoCompat> clone = removeShortcutsExcludedFromSurface( 531 shortcutInfoList, ShortcutInfoCompat.SURFACE_LAUNCHER); 532 if (Build.VERSION.SDK_INT <= 29) { 533 convertUriIconsToBitmapIcons(context, clone); 534 } 535 if (Build.VERSION.SDK_INT >= 25) { 536 ArrayList<ShortcutInfo> shortcuts = new ArrayList<>(); 537 for (ShortcutInfoCompat item : clone) { 538 shortcuts.add(item.toShortcutInfo()); 539 } 540 if (!context.getSystemService(ShortcutManager.class).updateShortcuts(shortcuts)) { 541 return false; 542 } 543 } 544 545 getShortcutInfoSaverInstance(context).addShortcuts(clone); 546 for (ShortcutInfoChangeListener listener : getShortcutInfoListeners(context)) { 547 listener.onShortcutUpdated(shortcutInfoList); 548 } 549 return true; 550 } 551 552 @VisibleForTesting convertUriIconToBitmapIcon(final @NonNull Context context, final @NonNull ShortcutInfoCompat info)553 static boolean convertUriIconToBitmapIcon(final @NonNull Context context, 554 final @NonNull ShortcutInfoCompat info) { 555 if (info.mIcon == null) { 556 return false; 557 } 558 final int type = info.mIcon.mType; 559 if (type != TYPE_URI_ADAPTIVE_BITMAP && type != TYPE_URI) { 560 return true; 561 } 562 InputStream is = info.mIcon.getUriInputStream(context); 563 if (is == null) { 564 return false; 565 } 566 final Bitmap bitmap = BitmapFactory.decodeStream(is); 567 if (bitmap == null) { 568 return false; 569 } 570 info.mIcon = (type == TYPE_URI_ADAPTIVE_BITMAP) 571 ? IconCompat.createWithAdaptiveBitmap(bitmap) 572 : IconCompat.createWithBitmap(bitmap); 573 return true; 574 } 575 576 @VisibleForTesting convertUriIconsToBitmapIcons(final @NonNull Context context, final @NonNull List<ShortcutInfoCompat> shortcutInfoList)577 static void convertUriIconsToBitmapIcons(final @NonNull Context context, 578 final @NonNull List<ShortcutInfoCompat> shortcutInfoList) { 579 final List<ShortcutInfoCompat> shortcuts = new ArrayList<>(shortcutInfoList); 580 for (ShortcutInfoCompat info : shortcuts) { 581 if (!convertUriIconToBitmapIcon(context, info)) { 582 shortcutInfoList.remove(info); 583 } 584 } 585 } 586 587 /** 588 * Disable pinned shortcuts, showing the user a custom error message when they try to select 589 * the disabled shortcuts. 590 * For more details, read 591 * <a href="/guide/topics/ui/shortcuts/managing-shortcuts.html#disable-shortcuts"> 592 * Disable shortcuts</a>. 593 * 594 * Compatibility behavior: 595 * <ul> 596 * <li>API 25 and above, this method matches platform behavior. 597 * <li>API 24 and earlier, this method behaves the same as {@link #removeDynamicShortcuts} 598 * </ul> 599 * 600 * @throws IllegalArgumentException If trying to disable immutable shortcuts. 601 * 602 * @throws IllegalStateException when the user is locked. 603 */ disableShortcuts(final @NonNull Context context, final @NonNull List<String> shortcutIds, final @Nullable CharSequence disabledMessage)604 public static void disableShortcuts(final @NonNull Context context, 605 final @NonNull List<String> shortcutIds, final @Nullable CharSequence disabledMessage) { 606 if (Build.VERSION.SDK_INT >= 25) { 607 context.getSystemService(ShortcutManager.class) 608 .disableShortcuts(shortcutIds, disabledMessage); 609 } 610 611 getShortcutInfoSaverInstance(context).removeShortcuts(shortcutIds); 612 for (ShortcutInfoChangeListener listener : getShortcutInfoListeners(context)) { 613 listener.onShortcutRemoved(shortcutIds); 614 } 615 } 616 617 /** 618 * Re-enable pinned shortcuts that were previously disabled. If the target shortcuts 619 * are already enabled, this method does nothing. 620 * <p>In API 31 and below any shortcuts that are marked as excluded from launcher will be 621 * ignored. 622 * 623 * Compatibility behavior: 624 * <ul> 625 * <li>API 25 and above, this method matches platform behavior. 626 * <li>API 24 and earlier, this method behaves the same as {@link #addDynamicShortcuts} 627 * </ul> 628 * 629 * @throws IllegalArgumentException If trying to enable immutable shortcuts. 630 * 631 * @throws IllegalStateException when the user is locked. 632 */ enableShortcuts(final @NonNull Context context, final @NonNull List<ShortcutInfoCompat> shortcutInfoList)633 public static void enableShortcuts(final @NonNull Context context, 634 final @NonNull List<ShortcutInfoCompat> shortcutInfoList) { 635 final List<ShortcutInfoCompat> clone = removeShortcutsExcludedFromSurface( 636 shortcutInfoList, ShortcutInfoCompat.SURFACE_LAUNCHER); 637 if (Build.VERSION.SDK_INT >= 25) { 638 final ArrayList<String> shortcutIds = new ArrayList<>(shortcutInfoList.size()); 639 for (ShortcutInfoCompat shortcut : clone) { 640 shortcutIds.add(shortcut.mId); 641 } 642 context.getSystemService(ShortcutManager.class).enableShortcuts(shortcutIds); 643 } 644 645 getShortcutInfoSaverInstance(context).addShortcuts(clone); 646 for (ShortcutInfoChangeListener listener : getShortcutInfoListeners(context)) { 647 listener.onShortcutAdded(shortcutInfoList); 648 } 649 } 650 651 /** 652 * Delete dynamic shortcuts by ID. Note that if a shortcut is set as long-lived, it may still 653 * be available in the system as a cached shortcut even after being removed from the list of 654 * dynamic shortcuts. 655 * 656 * @see #removeLongLivedShortcuts 657 */ removeDynamicShortcuts(@onNull Context context, @NonNull List<String> shortcutIds)658 public static void removeDynamicShortcuts(@NonNull Context context, 659 @NonNull List<String> shortcutIds) { 660 if (Build.VERSION.SDK_INT >= 25) { 661 context.getSystemService(ShortcutManager.class).removeDynamicShortcuts(shortcutIds); 662 } 663 664 getShortcutInfoSaverInstance(context).removeShortcuts(shortcutIds); 665 for (ShortcutInfoChangeListener listener : getShortcutInfoListeners(context)) { 666 listener.onShortcutRemoved(shortcutIds); 667 } 668 } 669 670 /** 671 * Delete all dynamic shortcuts from the caller app. Note that if a shortcut is set as 672 * long-lived, it may still be available in the system as a cached shortcut even after being 673 * removed from the list of dynamic shortcuts. 674 * 675 * @see #removeLongLivedShortcuts 676 */ removeAllDynamicShortcuts(@onNull Context context)677 public static void removeAllDynamicShortcuts(@NonNull Context context) { 678 if (Build.VERSION.SDK_INT >= 25) { 679 context.getSystemService(ShortcutManager.class).removeAllDynamicShortcuts(); 680 } 681 682 getShortcutInfoSaverInstance(context).removeAllShortcuts(); 683 for (ShortcutInfoChangeListener listener : getShortcutInfoListeners(context)) { 684 listener.onAllShortcutsRemoved(); 685 } 686 } 687 688 /** 689 * Delete long lived shortcuts by ID. 690 * 691 * Compatibility behavior: 692 * <ul> 693 * <li>API 30 and above, this method matches platform behavior. 694 * <li>API 29 and earlier, this method behaves the same as {@link #removeDynamicShortcuts} 695 * </ul> 696 * 697 * @throws IllegalStateException when the user is locked. 698 */ removeLongLivedShortcuts(final @NonNull Context context, final @NonNull List<String> shortcutIds)699 public static void removeLongLivedShortcuts(final @NonNull Context context, 700 final @NonNull List<String> shortcutIds) { 701 if (Build.VERSION.SDK_INT < 30) { 702 removeDynamicShortcuts(context, shortcutIds); 703 return; 704 } 705 706 context.getSystemService(ShortcutManager.class).removeLongLivedShortcuts(shortcutIds); 707 getShortcutInfoSaverInstance(context).removeShortcuts(shortcutIds); 708 for (ShortcutInfoChangeListener listener : getShortcutInfoListeners(context)) { 709 listener.onShortcutRemoved(shortcutIds); 710 } 711 } 712 713 /** 714 * Publish a single dynamic shortcut. If there are already dynamic or pinned shortcuts with the 715 * same ID, each mutable shortcut is updated. 716 * 717 * <p>This method is useful when posting notifications which are tagged with shortcut IDs; In 718 * order to make sure shortcuts exist and are up-to-date, without the need to explicitly handle 719 * the shortcut count limit. 720 * @see androidx.core.app.NotificationManagerCompat#notify(int, android.app.Notification) 721 * @see androidx.core.app.NotificationCompat.Builder#setShortcutId(String) 722 * 723 * <p>If {@link #getMaxShortcutCountPerActivity} is already reached, an existing shortcut with 724 * the lowest rank will be removed to add space for the new shortcut. 725 * 726 * <p>If the rank of the shortcut is not explicitly set, it will be set to zero, and shortcut 727 * will be added to the top of the list. 728 * 729 * Compatibility behavior: 730 * <ul> 731 * <li>API 30 and above, this method matches platform behavior. 732 * <li>API 25 to 29, this api is simulated by 733 * {@link ShortcutManager#addDynamicShortcuts(List)} and 734 * {@link ShortcutManager#removeDynamicShortcuts(List)} and thus will be rate-limited. 735 * <li>API 24 and earlier, this method uses internal implementation and matches platform 736 * behavior. 737 * </ul> 738 * 739 * @return {@code true} if the call has succeeded. {@code false} if the call fails or is 740 * rate-limited. 741 * 742 * @throws IllegalArgumentException if trying to update an immutable shortcut. 743 * 744 * @throws IllegalStateException when the user is locked. 745 */ pushDynamicShortcut(final @NonNull Context context, final @NonNull ShortcutInfoCompat shortcut)746 public static boolean pushDynamicShortcut(final @NonNull Context context, 747 final @NonNull ShortcutInfoCompat shortcut) { 748 Preconditions.checkNotNull(context); 749 Preconditions.checkNotNull(shortcut); 750 751 if (Build.VERSION.SDK_INT <= 32 752 && shortcut.isExcludedFromSurfaces(ShortcutInfoCompat.SURFACE_LAUNCHER)) { 753 for (ShortcutInfoChangeListener listener : getShortcutInfoListeners(context)) { 754 listener.onShortcutAdded(Collections.singletonList(shortcut)); 755 } 756 return true; 757 } 758 int maxShortcutCount = getMaxShortcutCountPerActivity(context); 759 if (maxShortcutCount == 0) { 760 return false; 761 } 762 if (Build.VERSION.SDK_INT <= 29) { 763 convertUriIconToBitmapIcon(context, shortcut); 764 } 765 if (Build.VERSION.SDK_INT >= 30) { 766 context.getSystemService(ShortcutManager.class).pushDynamicShortcut( 767 shortcut.toShortcutInfo()); 768 } else if (Build.VERSION.SDK_INT >= 25) { 769 final ShortcutManager sm = context.getSystemService(ShortcutManager.class); 770 if (sm.isRateLimitingActive()) { 771 return false; 772 } 773 final List<ShortcutInfo> shortcuts = sm.getDynamicShortcuts(); 774 if (shortcuts.size() >= maxShortcutCount) { 775 sm.removeDynamicShortcuts(Arrays.asList( 776 Api25Impl.getShortcutInfoWithLowestRank(shortcuts))); 777 } 778 sm.addDynamicShortcuts(Arrays.asList(shortcut.toShortcutInfo())); 779 } 780 final ShortcutInfoCompatSaver<?> saver = getShortcutInfoSaverInstance(context); 781 try { 782 final List<ShortcutInfoCompat> oldShortcuts = saver.getShortcuts(); 783 if (oldShortcuts.size() >= maxShortcutCount) { 784 saver.removeShortcuts(Arrays.asList( 785 getShortcutInfoCompatWithLowestRank(oldShortcuts))); 786 } 787 saver.addShortcuts(Arrays.asList(shortcut)); 788 return true; 789 } catch (Exception e) { 790 // Ignore 791 } finally { 792 for (ShortcutInfoChangeListener listener : getShortcutInfoListeners(context)) { 793 listener.onShortcutAdded(Collections.singletonList(shortcut)); 794 } 795 reportShortcutUsed(context, shortcut.getId()); 796 } 797 return false; 798 } 799 getShortcutInfoCompatWithLowestRank( final @NonNull List<ShortcutInfoCompat> shortcuts)800 private static String getShortcutInfoCompatWithLowestRank( 801 final @NonNull List<ShortcutInfoCompat> shortcuts) { 802 int rank = -1; 803 String target = null; 804 for (ShortcutInfoCompat s : shortcuts) { 805 if (s.getRank() > rank) { 806 target = s.getId(); 807 rank = s.getRank(); 808 } 809 } 810 return target; 811 } 812 813 @VisibleForTesting setShortcutInfoCompatSaver(final ShortcutInfoCompatSaver<Void> saver)814 static void setShortcutInfoCompatSaver(final ShortcutInfoCompatSaver<Void> saver) { 815 sShortcutInfoCompatSaver = saver; 816 } 817 818 @VisibleForTesting setShortcutInfoChangeListeners(final List<ShortcutInfoChangeListener> listeners)819 static void setShortcutInfoChangeListeners(final List<ShortcutInfoChangeListener> listeners) { 820 sShortcutInfoChangeListeners = listeners; 821 } 822 823 @VisibleForTesting getShortcutInfoChangeListeners()824 static List<ShortcutInfoChangeListener> getShortcutInfoChangeListeners() { 825 return sShortcutInfoChangeListeners; 826 } 827 getIconDimensionInternal(final @NonNull Context context, final boolean isHorizontal)828 private static int getIconDimensionInternal(final @NonNull Context context, 829 final boolean isHorizontal) { 830 final ActivityManager am = (ActivityManager) 831 context.getSystemService(Context.ACTIVITY_SERVICE); 832 final boolean isLowRamDevice = am == null || am.isLowRamDevice(); 833 final int iconDimensionDp = Math.max(1, isLowRamDevice 834 ? DEFAULT_MAX_ICON_DIMENSION_LOWRAM_DP : DEFAULT_MAX_ICON_DIMENSION_DP); 835 final DisplayMetrics displayMetrics = context.getResources().getDisplayMetrics(); 836 float density = (isHorizontal ? displayMetrics.xdpi : displayMetrics.ydpi) 837 / DisplayMetrics.DENSITY_MEDIUM; 838 return (int) (iconDimensionDp * density); 839 } 840 getShortcutInfoSaverInstance(Context context)841 private static ShortcutInfoCompatSaver<?> getShortcutInfoSaverInstance(Context context) { 842 if (sShortcutInfoCompatSaver == null) { 843 if (Build.VERSION.SDK_INT >= 23) { 844 try { 845 ClassLoader loader = ShortcutManagerCompat.class.getClassLoader(); 846 Class<?> saver = Class.forName( 847 "androidx.sharetarget.ShortcutInfoCompatSaverImpl", false, loader); 848 Method getInstanceMethod = saver.getMethod("getInstance", Context.class); 849 sShortcutInfoCompatSaver = (ShortcutInfoCompatSaver) getInstanceMethod.invoke( 850 null, context); 851 } catch (Exception e) { /* Do nothing */ } 852 } 853 854 if (sShortcutInfoCompatSaver == null) { 855 // Implementation not available. Instantiate to the default no-op impl. 856 sShortcutInfoCompatSaver = new ShortcutInfoCompatSaver.NoopImpl(); 857 } 858 } 859 return sShortcutInfoCompatSaver; 860 } 861 862 @SuppressWarnings("deprecation") getShortcutInfoListeners(Context context)863 private static List<ShortcutInfoChangeListener> getShortcutInfoListeners(Context context) { 864 if (sShortcutInfoChangeListeners == null) { 865 List<ShortcutInfoChangeListener> result = new ArrayList<>(); 866 if (Build.VERSION.SDK_INT >= 21) { 867 PackageManager packageManager = context.getPackageManager(); 868 Intent activityIntent = new Intent(SHORTCUT_LISTENER_INTENT_FILTER_ACTION); 869 activityIntent.setPackage(context.getPackageName()); 870 871 List<ResolveInfo> resolveInfos = packageManager.queryIntentActivities( 872 activityIntent, PackageManager.GET_META_DATA); 873 874 for (ResolveInfo resolveInfo : resolveInfos) { 875 ActivityInfo activityInfo = resolveInfo.activityInfo; 876 if (activityInfo == null) { 877 continue; 878 } 879 Bundle metaData = activityInfo.metaData; 880 if (metaData == null) { 881 continue; 882 } 883 String shortcutListenerImplName = 884 metaData.getString(SHORTCUT_LISTENER_META_DATA_KEY); 885 if (shortcutListenerImplName == null) { 886 continue; 887 } 888 try { 889 ClassLoader loader = ShortcutManagerCompat.class.getClassLoader(); 890 Class<?> listener = Class.forName(shortcutListenerImplName, false, loader); 891 Method getInstanceMethod = listener.getMethod("getInstance", Context.class); 892 result.add((ShortcutInfoChangeListener) 893 getInstanceMethod.invoke(null, context)); 894 } catch (Exception e) { /* Do nothing */ } 895 } 896 } 897 898 // Make sure the listeners are not already added while the loop is running. 899 if (sShortcutInfoChangeListeners == null) { 900 sShortcutInfoChangeListeners = result; 901 } 902 } 903 return sShortcutInfoChangeListeners; 904 } 905 removeShortcutsExcludedFromSurface( final @NonNull List<ShortcutInfoCompat> shortcuts, final int surfaces)906 private static @NonNull List<ShortcutInfoCompat> removeShortcutsExcludedFromSurface( 907 final @NonNull List<ShortcutInfoCompat> shortcuts, final int surfaces) { 908 Objects.requireNonNull(shortcuts); 909 if (Build.VERSION.SDK_INT > 32) return shortcuts; 910 final List<ShortcutInfoCompat> clone = new ArrayList<>(shortcuts); 911 for (ShortcutInfoCompat si: shortcuts) { 912 if (si.isExcludedFromSurfaces(surfaces)) { 913 clone.remove(si); 914 } 915 } 916 return clone; 917 } 918 919 @RequiresApi(25) 920 private static class Api25Impl { getShortcutInfoWithLowestRank(final @NonNull List<ShortcutInfo> shortcuts)921 static String getShortcutInfoWithLowestRank(final @NonNull List<ShortcutInfo> shortcuts) { 922 int rank = -1; 923 String target = null; 924 for (ShortcutInfo s : shortcuts) { 925 if (s.getRank() > rank) { 926 target = s.getId(); 927 rank = s.getRank(); 928 } 929 } 930 return target; 931 } 932 } 933 } 934