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