1 /*
2  * Copyright (C) 2016 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 package com.android.contacts;
17 
18 import android.annotation.TargetApi;
19 import android.app.ActivityManager;
20 import android.app.job.JobInfo;
21 import android.app.job.JobParameters;
22 import android.app.job.JobScheduler;
23 import android.app.job.JobService;
24 import android.content.BroadcastReceiver;
25 import android.content.ComponentName;
26 import android.content.ContentResolver;
27 import android.content.ContentUris;
28 import android.content.Context;
29 import android.content.Intent;
30 import android.content.IntentFilter;
31 import android.content.pm.ShortcutInfo;
32 import android.content.pm.ShortcutManager;
33 import android.database.Cursor;
34 import android.graphics.Bitmap;
35 import android.graphics.BitmapFactory;
36 import android.graphics.BitmapRegionDecoder;
37 import android.graphics.Canvas;
38 import android.graphics.Rect;
39 import android.graphics.drawable.AdaptiveIconDrawable;
40 import android.graphics.drawable.Drawable;
41 import android.graphics.drawable.Icon;
42 import android.net.Uri;
43 import android.os.AsyncTask;
44 import android.os.Build;
45 import android.os.PersistableBundle;
46 import android.provider.ContactsContract;
47 import android.provider.ContactsContract.Contacts;
48 import androidx.annotation.VisibleForTesting;
49 import androidx.localbroadcastmanager.content.LocalBroadcastManager;
50 import androidx.core.os.BuildCompat;
51 import android.util.Log;
52 
53 import com.android.contacts.activities.RequestPermissionsActivity;
54 import com.android.contacts.compat.CompatUtils;
55 import com.android.contacts.lettertiles.LetterTileDrawable;
56 import com.android.contacts.util.BitmapUtil;
57 import com.android.contacts.util.ImplicitIntentsUtil;
58 import com.android.contacts.util.PermissionsUtil;
59 import com.android.contactsbind.experiments.Flags;
60 
61 import java.io.IOException;
62 import java.io.InputStream;
63 import java.util.ArrayList;
64 import java.util.Collections;
65 import java.util.List;
66 
67 /**
68  * This class creates and updates the dynamic shortcuts displayed on the Nexus launcher for the
69  * Contacts app.
70  *
71  * Currently it adds shortcuts for the top 3 contacts in the {@link Contacts#CONTENT_STREQUENT_URI}
72  *
73  * Usage: DynamicShortcuts.initialize should be called during Application creation. This will
74  * schedule a Job to keep the shortcuts up-to-date so no further interactions should be necessary.
75  */
76 @TargetApi(Build.VERSION_CODES.N_MR1)
77 public class DynamicShortcuts {
78     private static final String TAG = "DynamicShortcuts";
79 
80     // Must be the same as shortcutId in res/xml/shortcuts.xml
81     // Note: This doesn't fit very well because this is a "static" shortcut but it's still the most
82     // sensible place to put it right now.
83     public static final String SHORTCUT_ADD_CONTACT = "shortcut-add-contact";
84 
85     // Note the Nexus launcher automatically truncates shortcut labels if they exceed these limits
86     // however, we implement our own truncation in case the shortcut is shown on a launcher that
87     // has different behavior
88     private static final int SHORT_LABEL_MAX_LENGTH = 12;
89     private static final int LONG_LABEL_MAX_LENGTH = 30;
90     private static final int MAX_SHORTCUTS = 3;
91 
92     private static final String EXTRA_SHORTCUT_TYPE = "extraShortcutType";
93 
94     // Because pinned shortcuts persist across app upgrades these values should not be changed
95     // though new ones may be added
96     private static final int SHORTCUT_TYPE_UNKNOWN = 0;
97     private static final int SHORTCUT_TYPE_CONTACT_URI = 1;
98     private static final int SHORTCUT_TYPE_ACTION_URI = 2;
99 
100     @VisibleForTesting
101     static final String[] PROJECTION = new String[] {
102             Contacts._ID, Contacts.LOOKUP_KEY, Contacts.DISPLAY_NAME_PRIMARY
103     };
104 
105     private final Context mContext;
106 
107     private final ContentResolver mContentResolver;
108     private final ShortcutManager mShortcutManager;
109     private int mShortLabelMaxLength = SHORT_LABEL_MAX_LENGTH;
110     private int mLongLabelMaxLength = LONG_LABEL_MAX_LENGTH;
111     private int mIconSize;
112     private final int mContentChangeMinUpdateDelay;
113     private final int mContentChangeMaxUpdateDelay;
114     private final JobScheduler mJobScheduler;
115 
DynamicShortcuts(Context context)116     public DynamicShortcuts(Context context) {
117         this(context, context.getContentResolver(), (ShortcutManager)
118                 context.getSystemService(Context.SHORTCUT_SERVICE),
119                 (JobScheduler) context.getSystemService(Context.JOB_SCHEDULER_SERVICE));
120     }
121 
122     @VisibleForTesting
DynamicShortcuts(Context context, ContentResolver contentResolver, ShortcutManager shortcutManager, JobScheduler jobScheduler)123     public DynamicShortcuts(Context context, ContentResolver contentResolver,
124             ShortcutManager shortcutManager, JobScheduler jobScheduler) {
125         mContext = context;
126         mContentResolver = contentResolver;
127         mShortcutManager = shortcutManager;
128         mJobScheduler = jobScheduler;
129         mContentChangeMinUpdateDelay = Flags.getInstance()
130                 .getInteger(Experiments.DYNAMIC_MIN_CONTENT_CHANGE_UPDATE_DELAY_MILLIS);
131         mContentChangeMaxUpdateDelay = Flags.getInstance()
132                 .getInteger(Experiments.DYNAMIC_MAX_CONTENT_CHANGE_UPDATE_DELAY_MILLIS);
133         final ActivityManager am = (ActivityManager) context
134                 .getSystemService(Context.ACTIVITY_SERVICE);
135         mIconSize = context.getResources().getDimensionPixelSize(R.dimen.shortcut_icon_size);
136         if (mIconSize == 0) {
137             mIconSize = am.getLauncherLargeIconSize();
138         }
139     }
140 
141     @VisibleForTesting
setShortLabelMaxLength(int length)142     void setShortLabelMaxLength(int length) {
143         this.mShortLabelMaxLength = length;
144     }
145 
146     @VisibleForTesting
setLongLabelMaxLength(int length)147     void setLongLabelMaxLength(int length) {
148         this.mLongLabelMaxLength = length;
149     }
150 
151     @VisibleForTesting
refresh()152     void refresh() {
153         // Guard here in addition to initialize because this could be run by the JobScheduler
154         // after permissions are revoked (maybe)
155         if (!hasRequiredPermissions()) return;
156 
157         final List<ShortcutInfo> shortcuts = getStrequentShortcuts();
158         mShortcutManager.setDynamicShortcuts(shortcuts);
159         if (Log.isLoggable(TAG, Log.DEBUG)) {
160             Log.d(TAG, "set dynamic shortcuts " + shortcuts);
161         }
162         updatePinned();
163     }
164 
165     @VisibleForTesting
updatePinned()166     void updatePinned() {
167         final List<ShortcutInfo> updates = new ArrayList<>();
168         final List<String> removedIds = new ArrayList<>();
169         final List<String> enable = new ArrayList<>();
170 
171         for (ShortcutInfo shortcut : mShortcutManager.getPinnedShortcuts()) {
172             final PersistableBundle extras = shortcut.getExtras();
173 
174             if (extras == null || extras.getInt(EXTRA_SHORTCUT_TYPE, SHORTCUT_TYPE_UNKNOWN) !=
175                     SHORTCUT_TYPE_CONTACT_URI) {
176                 continue;
177             }
178 
179             // The contact ID may have changed but that's OK because it is just an optimization
180             final long contactId = extras.getLong(Contacts._ID);
181 
182             final ShortcutInfo update = createShortcutForUri(
183                     Contacts.getLookupUri(contactId, shortcut.getId()));
184             if (update != null) {
185                 updates.add(update);
186                 if (!shortcut.isEnabled()) {
187                     // Handle the case that a contact is disabled because it doesn't exist but
188                     // later is created (for instance by a sync)
189                     enable.add(update.getId());
190                 }
191             } else if (shortcut.isEnabled()) {
192                 removedIds.add(shortcut.getId());
193             }
194         }
195 
196         if (Log.isLoggable(TAG, Log.DEBUG)) {
197             Log.d(TAG, "updating " + updates);
198             Log.d(TAG, "enabling " + enable);
199             Log.d(TAG, "disabling " + removedIds);
200         }
201 
202         mShortcutManager.updateShortcuts(updates);
203         mShortcutManager.enableShortcuts(enable);
204         mShortcutManager.disableShortcuts(removedIds,
205                 mContext.getString(R.string.dynamic_shortcut_contact_removed_message));
206     }
207 
createShortcutForUri(Uri contactUri)208     private ShortcutInfo createShortcutForUri(Uri contactUri) {
209         final Cursor cursor = mContentResolver.query(contactUri, PROJECTION, null, null, null);
210         if (cursor == null) return null;
211 
212         try {
213             if (cursor.moveToFirst()) {
214                 return createShortcutFromRow(cursor);
215             }
216         } finally {
217             cursor.close();
218         }
219         return null;
220     }
221 
getStrequentShortcuts()222     public List<ShortcutInfo> getStrequentShortcuts() {
223         // The limit query parameter doesn't seem to work for this uri but we'll leave it because in
224         // case it does work on some phones or platform versions.
225         final Uri uri = Contacts.CONTENT_STREQUENT_URI.buildUpon()
226                 .appendQueryParameter(ContactsContract.LIMIT_PARAM_KEY,
227                         String.valueOf(MAX_SHORTCUTS))
228                 .build();
229         final Cursor cursor = mContentResolver.query(uri, PROJECTION, null, null, null);
230 
231         if (cursor == null) return Collections.emptyList();
232 
233         final List<ShortcutInfo> result = new ArrayList<>();
234 
235         try {
236             int i = 0;
237             while (i < MAX_SHORTCUTS && cursor.moveToNext()) {
238                 final ShortcutInfo shortcut = createShortcutFromRow(cursor);
239                 if (shortcut == null) {
240                     continue;
241                 }
242                 result.add(shortcut);
243                 i++;
244             }
245         } finally {
246             cursor.close();
247         }
248         return result;
249     }
250 
251 
252     @VisibleForTesting
createShortcutFromRow(Cursor cursor)253     ShortcutInfo createShortcutFromRow(Cursor cursor) {
254         final ShortcutInfo.Builder builder = builderForContactShortcut(cursor);
255         if (builder == null) {
256             return null;
257         }
258         addIconForContact(cursor, builder);
259         return builder.build();
260     }
261 
262     @VisibleForTesting
builderForContactShortcut(Cursor cursor)263     ShortcutInfo.Builder builderForContactShortcut(Cursor cursor) {
264         final long id = cursor.getLong(0);
265         final String lookupKey = cursor.getString(1);
266         final String displayName = cursor.getString(2);
267         return builderForContactShortcut(id, lookupKey, displayName);
268     }
269 
270     @VisibleForTesting
builderForContactShortcut(long id, String lookupKey, String displayName)271     ShortcutInfo.Builder builderForContactShortcut(long id, String lookupKey, String displayName) {
272         if (lookupKey == null || displayName == null) {
273             return null;
274         }
275         final PersistableBundle extras = new PersistableBundle();
276         extras.putLong(Contacts._ID, id);
277         extras.putInt(EXTRA_SHORTCUT_TYPE, SHORTCUT_TYPE_CONTACT_URI);
278 
279         final ShortcutInfo.Builder builder = new ShortcutInfo.Builder(mContext, lookupKey)
280                 .setIntent(ImplicitIntentsUtil.getIntentForQuickContactLauncherShortcut(mContext,
281                         Contacts.getLookupUri(id, lookupKey)))
282                 .setDisabledMessage(mContext.getString(R.string.dynamic_shortcut_disabled_message))
283                 .setExtras(extras);
284 
285         setLabel(builder, displayName);
286         return builder;
287     }
288 
289     @VisibleForTesting
getActionShortcutInfo(String id, String label, Intent action, Icon icon)290     ShortcutInfo getActionShortcutInfo(String id, String label, Intent action, Icon icon) {
291         if (id == null || label == null) {
292             return null;
293         }
294         final PersistableBundle extras = new PersistableBundle();
295         extras.putInt(EXTRA_SHORTCUT_TYPE, SHORTCUT_TYPE_ACTION_URI);
296 
297         final ShortcutInfo.Builder builder = new ShortcutInfo.Builder(mContext, id)
298                 .setIntent(action)
299                 .setIcon(icon)
300                 .setExtras(extras)
301                 .setDisabledMessage(mContext.getString(R.string.dynamic_shortcut_disabled_message));
302 
303         setLabel(builder, label);
304         return builder.build();
305     }
306 
getQuickContactShortcutInfo(long id, String lookupKey, String displayName)307     public ShortcutInfo getQuickContactShortcutInfo(long id, String lookupKey, String displayName) {
308         final ShortcutInfo.Builder builder = builderForContactShortcut(id, lookupKey, displayName);
309         if (builder == null) {
310             return null;
311         }
312         addIconForContact(id, lookupKey, displayName, builder);
313         return builder.build();
314     }
315 
setLabel(ShortcutInfo.Builder builder, String label)316     private void setLabel(ShortcutInfo.Builder builder, String label) {
317         if (label.length() < mLongLabelMaxLength) {
318             builder.setLongLabel(label);
319         } else {
320             builder.setLongLabel(label.substring(0, mLongLabelMaxLength - 1).trim() + "…");
321         }
322 
323         if (label.length() < mShortLabelMaxLength) {
324             builder.setShortLabel(label);
325         } else {
326             builder.setShortLabel(label.substring(0, mShortLabelMaxLength - 1).trim() + "…");
327         }
328     }
329 
addIconForContact(Cursor cursor, ShortcutInfo.Builder builder)330     private void addIconForContact(Cursor cursor, ShortcutInfo.Builder builder) {
331         final long id = cursor.getLong(0);
332         final String lookupKey = cursor.getString(1);
333         final String displayName = cursor.getString(2);
334         addIconForContact(id, lookupKey, displayName, builder);
335     }
336 
addIconForContact(long id, String lookupKey, String displayName, ShortcutInfo.Builder builder)337     private void addIconForContact(long id, String lookupKey, String displayName,
338             ShortcutInfo.Builder builder) {
339         Bitmap bitmap = getContactPhoto(id);
340         if (bitmap == null) {
341             bitmap = getFallbackAvatar(displayName, lookupKey);
342         }
343         final Icon icon;
344         if (BuildCompat.isAtLeastO()) {
345             icon = Icon.createWithAdaptiveBitmap(bitmap);
346         } else {
347             icon = Icon.createWithBitmap(bitmap);
348         }
349 
350         builder.setIcon(icon);
351     }
352 
getContactPhoto(long id)353     private Bitmap getContactPhoto(long id) {
354         final InputStream photoStream = Contacts.openContactPhotoInputStream(
355                 mContext.getContentResolver(),
356                 ContentUris.withAppendedId(Contacts.CONTENT_URI, id), true);
357 
358         if (photoStream == null) return null;
359         try {
360             final Bitmap bitmap = decodeStreamForShortcut(photoStream);
361             photoStream.close();
362             return bitmap;
363         } catch (IOException e) {
364             Log.e(TAG, "Failed to decode contact photo for shortcut. ID=" + id, e);
365             return null;
366         } finally {
367             try {
368                 photoStream.close();
369             } catch (IOException e) {
370                 // swallow
371             }
372         }
373     }
374 
decodeStreamForShortcut(InputStream stream)375     private Bitmap decodeStreamForShortcut(InputStream stream) throws IOException {
376         final BitmapRegionDecoder bitmapDecoder = BitmapRegionDecoder.newInstance(stream, false);
377 
378         final int sourceWidth = bitmapDecoder.getWidth();
379         final int sourceHeight = bitmapDecoder.getHeight();
380 
381         final int iconMaxWidth = mShortcutManager.getIconMaxWidth();
382         final int iconMaxHeight = mShortcutManager.getIconMaxHeight();
383 
384         final int sampleSize = Math.min(
385                 BitmapUtil.findOptimalSampleSize(sourceWidth, mIconSize),
386                 BitmapUtil.findOptimalSampleSize(sourceHeight, mIconSize));
387         final BitmapFactory.Options opts = new BitmapFactory.Options();
388         opts.inSampleSize = sampleSize;
389 
390         final int scaledWidth = sourceWidth / opts.inSampleSize;
391         final int scaledHeight = sourceHeight / opts.inSampleSize;
392 
393         final int targetWidth = Math.min(scaledWidth, iconMaxWidth);
394         final int targetHeight = Math.min(scaledHeight, iconMaxHeight);
395 
396         // Make it square.
397         final int targetSize = Math.min(targetWidth, targetHeight);
398 
399         // The region is defined in the coordinates of the source image then the sampling is
400         // done on the extracted region.
401         final int prescaledXOffset = ((scaledWidth - targetSize) * opts.inSampleSize) / 2;
402         final int prescaledYOffset = ((scaledHeight - targetSize) * opts.inSampleSize) / 2;
403 
404         final Bitmap bitmap = bitmapDecoder.decodeRegion(new Rect(
405                 prescaledXOffset, prescaledYOffset,
406                 sourceWidth - prescaledXOffset, sourceHeight - prescaledYOffset
407         ), opts);
408         bitmapDecoder.recycle();
409 
410         if (!BuildCompat.isAtLeastO()) {
411             return BitmapUtil.getRoundedBitmap(bitmap, targetSize, targetSize);
412         }
413 
414         return bitmap;
415     }
416 
getFallbackAvatar(String displayName, String lookupKey)417     private Bitmap getFallbackAvatar(String displayName, String lookupKey) {
418         // Use a circular icon if we're not on O or higher.
419         final boolean circularIcon = !BuildCompat.isAtLeastO();
420 
421         final ContactPhotoManager.DefaultImageRequest request =
422                 new ContactPhotoManager.DefaultImageRequest(displayName, lookupKey, circularIcon);
423         if (BuildCompat.isAtLeastO()) {
424             // On O, scale the image down to add the padding needed by AdaptiveIcons.
425             request.scale = LetterTileDrawable.getAdaptiveIconScale();
426         }
427         final Drawable avatar = ContactPhotoManager.getDefaultAvatarDrawableForContact(
428                 mContext.getResources(), true, request);
429         final Bitmap result = Bitmap.createBitmap(mIconSize, mIconSize, Bitmap.Config.ARGB_8888);
430         // The avatar won't draw unless it thinks it is visible
431         avatar.setVisible(true, true);
432         final Canvas canvas = new Canvas(result);
433         avatar.setBounds(0, 0, mIconSize, mIconSize);
434         avatar.draw(canvas);
435         return result;
436     }
437 
438     @VisibleForTesting
handleFlagDisabled()439     void handleFlagDisabled() {
440         removeAllShortcuts();
441         mJobScheduler.cancel(ContactsJobService.DYNAMIC_SHORTCUTS_JOB_ID);
442     }
443 
removeAllShortcuts()444     private void removeAllShortcuts() {
445         mShortcutManager.removeAllDynamicShortcuts();
446 
447         final List<ShortcutInfo> pinned = mShortcutManager.getPinnedShortcuts();
448         final List<String> ids = new ArrayList<>(pinned.size());
449         for (ShortcutInfo shortcut : pinned) {
450             ids.add(shortcut.getId());
451         }
452         mShortcutManager.disableShortcuts(ids, mContext
453                 .getString(R.string.dynamic_shortcut_disabled_message));
454         if (Log.isLoggable(TAG, Log.DEBUG)) {
455             Log.d(TAG, "DynamicShortcuts have been removed.");
456         }
457     }
458 
459     @VisibleForTesting
scheduleUpdateJob()460     void scheduleUpdateJob() {
461         final JobInfo job = new JobInfo.Builder(
462                 ContactsJobService.DYNAMIC_SHORTCUTS_JOB_ID,
463                 new ComponentName(mContext, ContactsJobService.class))
464                 // We just observe all changes to contacts. It would be better to be more granular
465                 // but CP2 only notifies using this URI anyway so there isn't any point in adding
466                 // that complexity.
467                 .addTriggerContentUri(new JobInfo.TriggerContentUri(ContactsContract.AUTHORITY_URI,
468                         JobInfo.TriggerContentUri.FLAG_NOTIFY_FOR_DESCENDANTS))
469                 .setTriggerContentUpdateDelay(mContentChangeMinUpdateDelay)
470                 .setTriggerContentMaxDelay(mContentChangeMaxUpdateDelay)
471                 .build();
472         mJobScheduler.schedule(job);
473     }
474 
updateInBackground()475     void updateInBackground() {
476         new ShortcutUpdateTask(this).execute();
477     }
478 
initialize(Context context)479     public synchronized static void initialize(Context context) {
480         if (Log.isLoggable(TAG, Log.DEBUG)) {
481             final Flags flags = Flags.getInstance();
482             Log.d(TAG, "DyanmicShortcuts.initialize\nVERSION >= N_MR1? " +
483                     (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) +
484                     "\nisJobScheduled? " +
485                     (CompatUtils.isLauncherShortcutCompatible() && isJobScheduled(context)) +
486                     "\nminDelay=" +
487                     flags.getInteger(Experiments.DYNAMIC_MIN_CONTENT_CHANGE_UPDATE_DELAY_MILLIS) +
488                     "\nmaxDelay=" +
489                     flags.getInteger(Experiments.DYNAMIC_MAX_CONTENT_CHANGE_UPDATE_DELAY_MILLIS));
490         }
491 
492         if (!CompatUtils.isLauncherShortcutCompatible()) return;
493 
494         final DynamicShortcuts shortcuts = new DynamicShortcuts(context);
495 
496         if (!shortcuts.hasRequiredPermissions()) {
497             final IntentFilter filter = new IntentFilter();
498             filter.addAction(RequestPermissionsActivity.BROADCAST_PERMISSIONS_GRANTED);
499             LocalBroadcastManager.getInstance(shortcuts.mContext).registerReceiver(
500                     new PermissionsGrantedReceiver(), filter);
501         } else if (!isJobScheduled(context)) {
502             // Update the shortcuts. If the job is already scheduled then either the app is being
503             // launched to run the job in which case the shortcuts will get updated when it runs or
504             // it has been launched for some other reason and the data we care about for shortcuts
505             // hasn't changed. Because the job reschedules itself after completion this check
506             // essentially means that this will run on each app launch that happens after a reboot.
507             // Note: the task schedules the job after completing.
508             new ShortcutUpdateTask(shortcuts).execute();
509         }
510     }
511 
512     @VisibleForTesting
reset(Context context)513     public static void reset(Context context) {
514         final JobScheduler jobScheduler =
515                 (JobScheduler) context.getSystemService(Context.JOB_SCHEDULER_SERVICE);
516         jobScheduler.cancel(ContactsJobService.DYNAMIC_SHORTCUTS_JOB_ID);
517 
518         if (!CompatUtils.isLauncherShortcutCompatible()) {
519             return;
520         }
521         new DynamicShortcuts(context).removeAllShortcuts();
522     }
523 
524     @VisibleForTesting
hasRequiredPermissions()525     boolean hasRequiredPermissions() {
526         return PermissionsUtil.hasContactsPermissions(mContext);
527     }
528 
updateFromJob(final JobService service, final JobParameters jobParams)529     public static void updateFromJob(final JobService service, final JobParameters jobParams) {
530         new ShortcutUpdateTask(new DynamicShortcuts(service)) {
531             @Override
532             protected void onPostExecute(Void aVoid) {
533                 // Must call super first which will reschedule the job before we call jobFinished
534                 super.onPostExecute(aVoid);
535                 service.jobFinished(jobParams, false);
536             }
537         }.execute();
538     }
539 
540     @VisibleForTesting
isJobScheduled(Context context)541     public static boolean isJobScheduled(Context context) {
542         final JobScheduler scheduler = (JobScheduler) context
543                 .getSystemService(Context.JOB_SCHEDULER_SERVICE);
544         return scheduler.getPendingJob(ContactsJobService.DYNAMIC_SHORTCUTS_JOB_ID) != null;
545     }
546 
reportShortcutUsed(Context context, String lookupKey)547     public static void reportShortcutUsed(Context context, String lookupKey) {
548         if (!CompatUtils.isLauncherShortcutCompatible() || lookupKey == null) return;
549         final ShortcutManager shortcutManager = (ShortcutManager) context
550                 .getSystemService(Context.SHORTCUT_SERVICE);
551         shortcutManager.reportShortcutUsed(lookupKey);
552     }
553 
554     private static class ShortcutUpdateTask extends AsyncTask<Void, Void, Void> {
555         private DynamicShortcuts mDynamicShortcuts;
556 
ShortcutUpdateTask(DynamicShortcuts shortcuts)557         public ShortcutUpdateTask(DynamicShortcuts shortcuts) {
558             mDynamicShortcuts = shortcuts;
559         }
560 
561         @Override
doInBackground(Void... voids)562         protected Void doInBackground(Void... voids) {
563             mDynamicShortcuts.refresh();
564             return null;
565         }
566 
567         @Override
onPostExecute(Void aVoid)568         protected void onPostExecute(Void aVoid) {
569             if (Log.isLoggable(TAG, Log.DEBUG)) {
570                 Log.d(TAG, "ShorcutUpdateTask.onPostExecute");
571             }
572             // The shortcuts may have changed so update the job so that we are observing the
573             // correct Uris
574             mDynamicShortcuts.scheduleUpdateJob();
575         }
576     }
577 
578     private static class PermissionsGrantedReceiver extends BroadcastReceiver {
579         @Override
onReceive(Context context, Intent intent)580         public void onReceive(Context context, Intent intent) {
581             // Clear the receiver.
582             LocalBroadcastManager.getInstance(context).unregisterReceiver(this);
583             DynamicShortcuts.initialize(context);
584         }
585     }
586 }
587