blob: c7d8456006d6c3d9f064a1a8778003e35b439e98 [file] [log] [blame]
/*
* Copyright (C) 2016 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.contacts;
import android.annotation.TargetApi;
import android.app.job.JobInfo;
import android.app.job.JobParameters;
import android.app.job.JobScheduler;
import android.app.job.JobService;
import android.content.BroadcastReceiver;
import android.content.ComponentName;
import android.content.ContentResolver;
import android.content.ContentUris;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.pm.ShortcutInfo;
import android.content.pm.ShortcutManager;
import android.database.Cursor;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.BitmapRegionDecoder;
import android.graphics.Canvas;
import android.graphics.Rect;
import android.graphics.drawable.Drawable;
import android.graphics.drawable.Icon;
import android.net.Uri;
import android.os.AsyncTask;
import android.os.Build;
import android.os.PersistableBundle;
import android.provider.ContactsContract;
import android.provider.ContactsContract.Contacts;
import android.support.annotation.VisibleForTesting;
import android.support.v4.content.LocalBroadcastManager;
import android.util.Log;
import com.android.contacts.activities.RequestPermissionsActivity;
import com.android.contacts.compat.CompatUtils;
import com.android.contacts.util.BitmapUtil;
import com.android.contacts.util.ImplicitIntentsUtil;
import com.android.contacts.util.PermissionsUtil;
import com.android.contactsbind.experiments.Flags;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
/**
* This class creates and updates the dynamic shortcuts displayed on the Nexus launcher for the
* Contacts app.
*
* Currently it adds shortcuts for the top 3 contacts in the {@link Contacts#CONTENT_STREQUENT_URI}
*
* Usage: DynamicShortcuts.initialize should be called during Application creation. This will
* schedule a Job to keep the shortcuts up-to-date so no further interations should be necessary.
*/
@TargetApi(Build.VERSION_CODES.N_MR1)
public class DynamicShortcuts {
private static final String TAG = "DynamicShortcuts";
// Must be the same as shortcutId in res/xml/shortcuts.xml
// Note: This doesn't fit very well because this is a "static" shortcut but it's still the most
// sensible place to put it right now.
public static final String SHORTCUT_ADD_CONTACT = "shortcut-add-contact";
// Note the Nexus launcher automatically truncates shortcut labels if they exceed these limits
// however, we implement our own truncation in case the shortcut is shown on a launcher that
// has different behavior
private static final int SHORT_LABEL_MAX_LENGTH = 12;
private static final int LONG_LABEL_MAX_LENGTH = 30;
private static final int MAX_SHORTCUTS = 3;
private static final String EXTRA_SHORTCUT_TYPE = "extraShortcutType";
// Because pinned shortcuts persist across app upgrades these values should not be changed
// though new ones may be added
private static final int SHORTCUT_TYPE_UNKNOWN = 0;
private static final int SHORTCUT_TYPE_CONTACT_URI = 1;
// The spec specifies that it should be 44dp @ xxxhdpi
// Note that ShortcutManager.getIconMaxWidth and ShortcutManager.getMaxHeight return different
// (larger) values.
private static final int RECOMMENDED_ICON_PIXEL_LENGTH = 176;
@VisibleForTesting
static final String[] PROJECTION = new String[] {
Contacts._ID, Contacts.LOOKUP_KEY, Contacts.DISPLAY_NAME_PRIMARY
};
private final Context mContext;
private final ContentResolver mContentResolver;
private final ShortcutManager mShortcutManager;
private int mShortLabelMaxLength = SHORT_LABEL_MAX_LENGTH;
private int mLongLabelMaxLength = LONG_LABEL_MAX_LENGTH;
private final int mContentChangeMinUpdateDelay;
private final int mContentChangeMaxUpdateDelay;
private final JobScheduler mJobScheduler;
public DynamicShortcuts(Context context) {
this(context, context.getContentResolver(), (ShortcutManager)
context.getSystemService(Context.SHORTCUT_SERVICE),
(JobScheduler) context.getSystemService(Context.JOB_SCHEDULER_SERVICE));
}
@VisibleForTesting
public DynamicShortcuts(Context context, ContentResolver contentResolver,
ShortcutManager shortcutManager, JobScheduler jobScheduler) {
mContext = context;
mContentResolver = contentResolver;
mShortcutManager = shortcutManager;
mJobScheduler = jobScheduler;
mContentChangeMinUpdateDelay = Flags.getInstance()
.getInteger(Experiments.DYNAMIC_MIN_CONTENT_CHANGE_UPDATE_DELAY_MILLIS);
mContentChangeMaxUpdateDelay = Flags.getInstance()
.getInteger(Experiments.DYNAMIC_MAX_CONTENT_CHANGE_UPDATE_DELAY_MILLIS);
}
@VisibleForTesting
void setShortLabelMaxLength(int length) {
this.mShortLabelMaxLength = length;
}
@VisibleForTesting
void setLongLabelMaxLength(int length) {
this.mLongLabelMaxLength = length;
}
@VisibleForTesting
void refresh() {
// Guard here in addition to initialize because this could be run by the JobScheduler
// after permissions are revoked (maybe)
if (!hasRequiredPermissions()) return;
final List<ShortcutInfo> shortcuts = getStrequentShortcuts();
mShortcutManager.setDynamicShortcuts(shortcuts);
if (Log.isLoggable(TAG, Log.DEBUG)) {
Log.d(TAG, "set dynamic shortcuts " + shortcuts);
}
updatePinned();
}
@VisibleForTesting
void updatePinned() {
final List<ShortcutInfo> updates = new ArrayList<>();
final List<String> removedIds = new ArrayList<>();
final List<String> enable = new ArrayList<>();
for (ShortcutInfo shortcut : mShortcutManager.getPinnedShortcuts()) {
final PersistableBundle extras = shortcut.getExtras();
if (!shortcut.isDynamic() || extras == null ||
extras.getInt(EXTRA_SHORTCUT_TYPE, SHORTCUT_TYPE_UNKNOWN) !=
SHORTCUT_TYPE_CONTACT_URI) {
continue;
}
// The contact ID may have changed but that's OK because it is just an optimization
final long contactId = extras.getLong(Contacts._ID);
final ShortcutInfo update = createShortcutForUri(
Contacts.getLookupUri(contactId, shortcut.getId()));
if (update != null) {
updates.add(update);
if (!shortcut.isEnabled()) {
// Handle the case that a contact is disabled because it doesn't exist but
// later is created (for instance by a sync)
enable.add(update.getId());
}
} else if (shortcut.isEnabled()) {
removedIds.add(shortcut.getId());
}
}
if (Log.isLoggable(TAG, Log.DEBUG)) {
Log.d(TAG, "updating " + updates);
Log.d(TAG, "enabling " + enable);
Log.d(TAG, "disabling " + removedIds);
}
mShortcutManager.updateShortcuts(updates);
mShortcutManager.enableShortcuts(enable);
mShortcutManager.disableShortcuts(removedIds,
mContext.getString(R.string.dynamic_shortcut_contact_removed_message));
}
private ShortcutInfo createShortcutForUri(Uri contactUri) {
final Cursor cursor = mContentResolver.query(contactUri, PROJECTION, null, null, null);
if (cursor == null) return null;
try {
if (cursor.moveToFirst()) {
return createShortcutFromRow(cursor);
}
} finally {
cursor.close();
}
return null;
}
public List<ShortcutInfo> getStrequentShortcuts() {
// The limit query parameter doesn't seem to work for this uri but we'll leave it because in
// case it does work on some phones or platform versions.
final Uri uri = Contacts.CONTENT_STREQUENT_URI.buildUpon()
.appendQueryParameter(ContactsContract.LIMIT_PARAM_KEY,
String.valueOf(MAX_SHORTCUTS))
.build();
final Cursor cursor = mContentResolver.query(uri, PROJECTION, null, null, null);
if (cursor == null) return Collections.emptyList();
final List<ShortcutInfo> result = new ArrayList<>();
try {
int i = 0;
while (i < MAX_SHORTCUTS && cursor.moveToNext()) {
final ShortcutInfo shortcut = createShortcutFromRow(cursor);
if (shortcut == null) {
continue;
}
result.add(shortcut);
i++;
}
} finally {
cursor.close();
}
return result;
}
@VisibleForTesting
ShortcutInfo createShortcutFromRow(Cursor cursor) {
final ShortcutInfo.Builder builder = builderForContactShortcut(cursor);
if (builder == null) {
return null;
}
addIconForContact(cursor, builder);
return builder.build();
}
@VisibleForTesting
ShortcutInfo.Builder builderForContactShortcut(Cursor cursor) {
final long id = cursor.getLong(0);
final String lookupKey = cursor.getString(1);
final String displayName = cursor.getString(2);
return builderForContactShortcut(id, lookupKey, displayName);
}
@VisibleForTesting
ShortcutInfo.Builder builderForContactShortcut(long id, String lookupKey, String displayName) {
if (lookupKey == null || displayName == null) {
return null;
}
final PersistableBundle extras = new PersistableBundle();
extras.putLong(Contacts._ID, id);
extras.putInt(EXTRA_SHORTCUT_TYPE, SHORTCUT_TYPE_CONTACT_URI);
final ShortcutInfo.Builder builder = new ShortcutInfo.Builder(mContext, lookupKey)
.setIntent(ImplicitIntentsUtil.getIntentForQuickContactLauncherShortcut(mContext,
Contacts.getLookupUri(id, lookupKey)))
.setDisabledMessage(mContext.getString(R.string.dynamic_shortcut_disabled_message))
.setExtras(extras);
if (displayName.length() < mLongLabelMaxLength) {
builder.setLongLabel(displayName);
} else {
builder.setLongLabel(displayName.substring(0, mLongLabelMaxLength - 1).trim() + "…");
}
if (displayName.length() < mShortLabelMaxLength) {
builder.setShortLabel(displayName);
} else {
builder.setShortLabel(displayName.substring(0, mShortLabelMaxLength - 1).trim() + "…");
}
return builder;
}
private void addIconForContact(Cursor cursor, ShortcutInfo.Builder builder) {
final long id = cursor.getLong(0);
final String lookupKey = cursor.getString(1);
final String displayName = cursor.getString(2);
final Bitmap bitmap = getContactPhoto(id);
if (bitmap != null) {
builder.setIcon(Icon.createWithBitmap(bitmap));
} else {
builder.setIcon(Icon.createWithBitmap(getFallbackAvatar(displayName, lookupKey)));
}
}
private Bitmap getContactPhoto(long id) {
final InputStream photoStream = Contacts.openContactPhotoInputStream(
mContext.getContentResolver(),
ContentUris.withAppendedId(Contacts.CONTENT_URI, id), true);
if (photoStream == null) return null;
try {
final Bitmap bitmap = decodeStreamForShortcut(photoStream);
photoStream.close();
return bitmap;
} catch (IOException e) {
Log.e(TAG, "Failed to decode contact photo for shortcut. ID=" + id, e);
return null;
} finally {
try {
photoStream.close();
} catch (IOException e) {
// swallow
}
}
}
private Bitmap decodeStreamForShortcut(InputStream stream) throws IOException {
final BitmapRegionDecoder bitmapDecoder = BitmapRegionDecoder.newInstance(stream, false);
final int sourceWidth = bitmapDecoder.getWidth();
final int sourceHeight = bitmapDecoder.getHeight();
final int iconMaxWidth = mShortcutManager.getIconMaxWidth();;
final int iconMaxHeight = mShortcutManager.getIconMaxHeight();
final int sampleSize = Math.min(
BitmapUtil.findOptimalSampleSize(sourceWidth,
RECOMMENDED_ICON_PIXEL_LENGTH),
BitmapUtil.findOptimalSampleSize(sourceHeight,
RECOMMENDED_ICON_PIXEL_LENGTH));
final BitmapFactory.Options opts = new BitmapFactory.Options();
opts.inSampleSize = sampleSize;
final int scaledWidth = sourceWidth / opts.inSampleSize;
final int scaledHeight = sourceHeight / opts.inSampleSize;
final int targetWidth = Math.min(scaledWidth, iconMaxWidth);
final int targetHeight = Math.min(scaledHeight, iconMaxHeight);
// Make it square.
final int targetSize = Math.min(targetWidth, targetHeight);
// The region is defined in the coordinates of the source image then the sampling is
// done on the extracted region.
final int prescaledXOffset = ((scaledWidth - targetSize) * opts.inSampleSize) / 2;
final int prescaledYOffset = ((scaledHeight - targetSize) * opts.inSampleSize) / 2;
final Bitmap bitmap = bitmapDecoder.decodeRegion(new Rect(
prescaledXOffset, prescaledYOffset,
sourceWidth - prescaledXOffset, sourceHeight - prescaledYOffset
), opts);
bitmapDecoder.recycle();
return BitmapUtil.getRoundedBitmap(bitmap, targetSize, targetSize);
}
private Bitmap getFallbackAvatar(String displayName, String lookupKey) {
final int w = RECOMMENDED_ICON_PIXEL_LENGTH;
final int h = RECOMMENDED_ICON_PIXEL_LENGTH;
final ContactPhotoManager.DefaultImageRequest request =
new ContactPhotoManager.DefaultImageRequest(displayName, lookupKey, true);
final Drawable avatar = ContactPhotoManager.getDefaultAvatarDrawableForContact(
mContext.getResources(), true, request);
final Bitmap result = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888);
// The avatar won't draw unless it thinks it is visible
avatar.setVisible(true, true);
final Canvas canvas = new Canvas(result);
avatar.setBounds(0, 0, w, h);
avatar.draw(canvas);
return result;
}
@VisibleForTesting
void handleFlagDisabled() {
removeAllShortcuts();
mJobScheduler.cancel(ContactsJobService.DYNAMIC_SHORTCUTS_JOB_ID);
}
private void removeAllShortcuts() {
mShortcutManager.removeAllDynamicShortcuts();
final List<ShortcutInfo> pinned = mShortcutManager.getPinnedShortcuts();
final List<String> ids = new ArrayList<>(pinned.size());
for (ShortcutInfo shortcut : pinned) {
ids.add(shortcut.getId());
}
mShortcutManager.disableShortcuts(ids, mContext
.getString(R.string.dynamic_shortcut_disabled_message));
if (Log.isLoggable(TAG, Log.DEBUG)) {
Log.d(TAG, "DynamicShortcuts have been removed.");
}
}
@VisibleForTesting
void scheduleUpdateJob() {
final JobInfo job = new JobInfo.Builder(
ContactsJobService.DYNAMIC_SHORTCUTS_JOB_ID,
new ComponentName(mContext, ContactsJobService.class))
// We just observe all changes to contacts. It would be better to be more granular
// but CP2 only notifies using this URI anyway so there isn't any point in adding
// that complexity.
.addTriggerContentUri(new JobInfo.TriggerContentUri(ContactsContract.AUTHORITY_URI,
JobInfo.TriggerContentUri.FLAG_NOTIFY_FOR_DESCENDANTS))
.setTriggerContentUpdateDelay(mContentChangeMinUpdateDelay)
.setTriggerContentMaxDelay(mContentChangeMaxUpdateDelay)
.build();
mJobScheduler.schedule(job);
}
void updateInBackground() {
new ShortcutUpdateTask(this).execute();
}
public synchronized static void initialize(Context context) {
if (Log.isLoggable(TAG, Log.DEBUG)) {
final Flags flags = Flags.getInstance();
Log.d(TAG, "DyanmicShortcuts.initialize\nVERSION >= N_MR1? " +
(Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) +
"\nisJobScheduled? " +
(CompatUtils.isLauncherShortcutCompatible() && isJobScheduled(context)) +
"\nminDelay=" +
flags.getInteger(Experiments.DYNAMIC_MIN_CONTENT_CHANGE_UPDATE_DELAY_MILLIS) +
"\nmaxDelay=" +
flags.getInteger(Experiments.DYNAMIC_MAX_CONTENT_CHANGE_UPDATE_DELAY_MILLIS));
}
if (!CompatUtils.isLauncherShortcutCompatible()) return;
final DynamicShortcuts shortcuts = new DynamicShortcuts(context);
if (!shortcuts.hasRequiredPermissions()) {
final IntentFilter filter = new IntentFilter();
filter.addAction(RequestPermissionsActivity.BROADCAST_PERMISSIONS_GRANTED);
LocalBroadcastManager.getInstance(shortcuts.mContext).registerReceiver(
new PermissionsGrantedReceiver(), filter);
} else if (!isJobScheduled(context)) {
// Update the shortcuts. If the job is already scheduled then either the app is being
// launched to run the job in which case the shortcuts will get updated when it runs or
// it has been launched for some other reason and the data we care about for shortcuts
// hasn't changed. Because the job reschedules itself after completion this check
// essentially means that this will run on each app launch that happens after a reboot.
// Note: the task schedules the job after completing.
new ShortcutUpdateTask(shortcuts).execute();
}
}
@VisibleForTesting
public static void reset(Context context) {
final JobScheduler jobScheduler =
(JobScheduler) context.getSystemService(Context.JOB_SCHEDULER_SERVICE);
jobScheduler.cancel(ContactsJobService.DYNAMIC_SHORTCUTS_JOB_ID);
if (!CompatUtils.isLauncherShortcutCompatible()) {
return;
}
new DynamicShortcuts(context).removeAllShortcuts();
}
@VisibleForTesting
boolean hasRequiredPermissions() {
return PermissionsUtil.hasContactsPermissions(mContext);
}
public static void updateFromJob(final JobService service, final JobParameters jobParams) {
new ShortcutUpdateTask(new DynamicShortcuts(service)) {
@Override
protected void onPostExecute(Void aVoid) {
// Must call super first which will reschedule the job before we call jobFinished
super.onPostExecute(aVoid);
service.jobFinished(jobParams, false);
}
}.execute();
}
@VisibleForTesting
public static boolean isJobScheduled(Context context) {
final JobScheduler scheduler = (JobScheduler) context
.getSystemService(Context.JOB_SCHEDULER_SERVICE);
return scheduler.getPendingJob(ContactsJobService.DYNAMIC_SHORTCUTS_JOB_ID) != null;
}
public static void reportShortcutUsed(Context context, String lookupKey) {
if (!CompatUtils.isLauncherShortcutCompatible() || lookupKey == null) return;
final ShortcutManager shortcutManager = (ShortcutManager) context
.getSystemService(Context.SHORTCUT_SERVICE);
shortcutManager.reportShortcutUsed(lookupKey);
}
private static class ShortcutUpdateTask extends AsyncTask<Void, Void, Void> {
private DynamicShortcuts mDynamicShortcuts;
public ShortcutUpdateTask(DynamicShortcuts shortcuts) {
mDynamicShortcuts = shortcuts;
}
@Override
protected Void doInBackground(Void... voids) {
mDynamicShortcuts.refresh();
return null;
}
@Override
protected void onPostExecute(Void aVoid) {
if (Log.isLoggable(TAG, Log.DEBUG)) {
Log.d(TAG, "ShorcutUpdateTask.onPostExecute");
}
// The shortcuts may have changed so update the job so that we are observing the
// correct Uris
mDynamicShortcuts.scheduleUpdateJob();
}
}
private static class PermissionsGrantedReceiver extends BroadcastReceiver {
@Override
public void onReceive(Context context, Intent intent) {
// Clear the receiver.
LocalBroadcastManager.getInstance(context).unregisterReceiver(this);
DynamicShortcuts.initialize(context);
}
}
}