Home | History | Annotate | Download | only in contacts
      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 
    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
    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
    137     void setShortLabelMaxLength(int length) {
    138         this.mShortLabelMaxLength = length;
    139     }
    140 
    141     @VisibleForTesting
    142     void setLongLabelMaxLength(int length) {
    143         this.mLongLabelMaxLength = length;
    144     }
    145 
    146     @VisibleForTesting
    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
    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 
    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 
    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
    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
    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
    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
    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 
    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 
    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 
    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 
    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 
    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 
    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 
    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
    452     void handleFlagDisabled() {
    453         removeAllShortcuts();
    454         mJobScheduler.cancel(ContactsJobService.DYNAMIC_SHORTCUTS_JOB_ID);
    455     }
    456 
    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
    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 
    488     void updateInBackground() {
    489         new ShortcutUpdateTask(this).execute();
    490     }
    491 
    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
    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
    538     boolean hasRequiredPermissions() {
    539         return PermissionsUtil.hasContactsPermissions(mContext);
    540     }
    541 
    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
    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 
    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 
    570         public ShortcutUpdateTask(DynamicShortcuts shortcuts) {
    571             mDynamicShortcuts = shortcuts;
    572         }
    573 
    574         @Override
    575         protected Void doInBackground(Void... voids) {
    576             mDynamicShortcuts.refresh();
    577             return null;
    578         }
    579 
    580         @Override
    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
    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