Add dynamic launcher shortcuts.
Currently the shortcuts are created for the top 3 contacts returned from
Contacts.CONTENT_STREQUENT_URI
Test:
Added unit tests for DynamicShortcuts but currently suppressed because they
require AndroidJUnitRunner
Manual:
* Use N_MR1 device with recent dogfood Nexus launcher installed.
* launch app
* star some contacts if needed
* press home
* long press launcher icon
* verify that starred contacts show in list of shortcuts
* unstar some contacts
* verify that shortcuts change
* pin a shortcut
* remove contact for pinned shortcut
* verify that pinned shortcut is disabled
* pin a shortcut
* change name of contact for pinned shortcut
* verify that name on pinned shortcut changes
Also prevent disambiguation dialog for other home screen shortcuts
Bug 30189449
Bug 31628994
Change-Id: Iace4b1c88b51ba1f7973c6f4ef90002fb92d0784
diff --git a/AndroidManifest.xml b/AndroidManifest.xml
index 12f6866..52795cd 100644
--- a/AndroidManifest.xml
+++ b/AndroidManifest.xml
@@ -521,6 +521,10 @@
</intent-filter>
</service>
+ <!-- Service used to run JobScheduler jobs -->
+ <service android:name="com.android.contacts.ContactsJobService"
+ android:permission="android.permission.BIND_JOB_SERVICE" />
+
<provider
android:name="android.support.v4.content.FileProvider"
android:authorities="@string/contacts_file_provider_authority"
diff --git a/proguard.flags b/proguard.flags
index d6e3755..f9a072a 100644
--- a/proguard.flags
+++ b/proguard.flags
@@ -76,6 +76,7 @@
-keep class com.android.contacts.ContactsApplication { *; }
-keep class com.android.contacts.ContactSaveService { *; }
-keep class com.android.contacts.ContactSaveService$* { *; }
+-keep class com.android.contacts.DynamicShortcuts { *; }
-keep class com.android.contacts.editor.ContactEditorUtils { *; }
-keep class com.android.contacts.editor.EditorUiUtils { *; }
-keep class com.android.contacts.group.GroupUtil { *; }
diff --git a/res/values/strings.xml b/res/values/strings.xml
index 0f9f16e..70ca005 100644
--- a/res/values/strings.xml
+++ b/res/values/strings.xml
@@ -1779,4 +1779,11 @@
<!-- Formatted call duration displayed in recent card in QuickContact, for duration more than 1 hour -->
<string name="callDurationHourFormat"><xliff:g id="minutes">%s</xliff:g> hr <xliff:g id="minutes">%s</xliff:g> min <xliff:g id="seconds">%s</xliff:g> sec</string>
+
+ <!-- Toast shown when a dynamic shortcut is tapped after being disabled because the experiment was turned off on the device -->
+ <string name="dynamic_shortcut_disabled_message">This shortcut has been disabled</string>
+
+ <!-- Toast shown when a dynamic shortcut is tapped after being disabled because the contact was removed -->
+ <string name="dynamic_shortcut_contact_removed_message">Contact was removed</string>
+
</resources>
diff --git a/src/com/android/contacts/ContactsJobService.java b/src/com/android/contacts/ContactsJobService.java
new file mode 100644
index 0000000..c60a0a7
--- /dev/null
+++ b/src/com/android/contacts/ContactsJobService.java
@@ -0,0 +1,44 @@
+/*
+ * 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.app.job.JobParameters;
+import android.app.job.JobService;
+
+/**
+ * Service to run {@link android.app.job.JobScheduler} jobs.
+ */
+public class ContactsJobService extends JobService {
+
+ public static final int DYNAMIC_SHORTCUTS_JOB_ID = 1;
+
+ @Override
+ public boolean onStartJob(JobParameters jobParameters) {
+ switch (jobParameters.getJobId()) {
+ case DYNAMIC_SHORTCUTS_JOB_ID:
+ DynamicShortcuts.updateFromJob(this, jobParameters);
+ return true;
+ default:
+ break;
+ }
+ return false;
+ }
+
+ @Override
+ public boolean onStopJob(JobParameters jobParameters) {
+ return false;
+ }
+}
diff --git a/src/com/android/contacts/DynamicShortcuts.java b/src/com/android/contacts/DynamicShortcuts.java
new file mode 100644
index 0000000..2e873d8
--- /dev/null
+++ b/src/com/android/contacts/DynamicShortcuts.java
@@ -0,0 +1,432 @@
+/*
+ * 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.ComponentName;
+import android.content.ContentResolver;
+import android.content.ContentUris;
+import android.content.Context;
+import android.content.Intent;
+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.util.Log;
+
+import com.android.contacts.common.ContactPhotoManager;
+import com.android.contacts.common.Experiments;
+import com.android.contacts.common.util.BitmapUtil;
+import com.android.contacts.common.util.ImplicitIntentsUtil;
+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;
+
+import static com.android.contacts.common.list.ShortcutIntentBuilder.INTENT_EXTRA_IGNORE_LAUNCH_ANIMATION;
+
+/**
+ * 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";
+
+ // 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;
+
+ /**
+ * How long to wait after a change to the contacts content uri before updating the shortcuts
+ * This increases the likelihood that multiple updates will be coalesced in the case that
+ * the updates are happening rapidly
+ *
+ * TODO: this should probably be externally configurable to make it easier to manually test the
+ * behavior
+ */
+ private static final int CONTENT_CHANGE_MIN_UPDATE_DELAY_MILLIS = 10000; // 10 seconds
+ /**
+ * The maximum time to wait before updating the shortcuts that may have changed.
+ *
+ * TODO: this should probably be externally configurable to make it easier to manually test the
+ * behavior
+ */
+ private static final int CONTENT_CHANGE_MAX_UPDATE_DELAY_MILLIS = 24*60*60*1000; // 1 day
+
+ // 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;
+
+ public DynamicShortcuts(Context context) {
+ this(context, context.getContentResolver(), (ShortcutManager)
+ context.getSystemService(Context.SHORTCUT_SERVICE));
+ }
+
+ public DynamicShortcuts(Context context, ContentResolver contentResolver,
+ ShortcutManager shortcutManager) {
+ mContext = context;
+ mContentResolver = contentResolver;
+ mShortcutManager = shortcutManager;
+ }
+
+ @VisibleForTesting
+ void setShortLabelMaxLength(int length) {
+ this.mShortLabelMaxLength = length;
+ }
+
+ @VisibleForTesting
+ void setLongLabelMaxLength(int length) {
+ this.mLongLabelMaxLength = length;
+ }
+
+ @VisibleForTesting
+ void refresh() {
+ mShortcutManager.setDynamicShortcuts(getStrequentShortcuts());
+ 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();
+ // The contact ID may have changed but that's OK because it is just an optimization
+ final long contactId = extras == null ? 0 : 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());
+ }
+ }
+
+ 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 {
+ // For some reason the limit query parameter is ignored for the strequent content uri
+ for (int i = 0; i < MAX_SHORTCUTS && cursor.moveToNext(); i++) {
+ result.add(createShortcutFromRow(cursor));
+ }
+ } finally {
+ cursor.close();
+ }
+ return result;
+ }
+
+
+ @VisibleForTesting
+ ShortcutInfo createShortcutFromRow(Cursor cursor) {
+ final ShortcutInfo.Builder builder = builderForContactShortcut(cursor);
+ 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) {
+ final PersistableBundle extras = new PersistableBundle();
+ extras.putLong(Contacts._ID, id);
+
+ 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;
+ }
+
+ private void handleFlagDisabled() {
+ 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));
+ }
+
+ @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(CONTENT_CHANGE_MIN_UPDATE_DELAY_MILLIS)
+ .setTriggerContentMaxDelay(CONTENT_CHANGE_MAX_UPDATE_DELAY_MILLIS).build();
+ final JobScheduler scheduler = (JobScheduler)
+ mContext.getSystemService(Context.JOB_SCHEDULER_SERVICE);
+ scheduler.schedule(job);
+ }
+
+ public synchronized static void initialize(Context context) {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N_MR1) return;
+
+ final DynamicShortcuts shortcuts = new DynamicShortcuts(context);
+ if (!Flags.getInstance(context).getBoolean(Experiments.DYNAMIC_SHORTCUTS)) {
+ // Clear dynamic shortcuts if the flag is not enabled. This prevents shortcuts from
+ // staying around if it is enabled then later disabled (due to bugs for instance).
+ shortcuts.handleFlagDisabled();
+ } 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();
+ }
+ }
+
+ 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;
+ }
+
+ 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) {
+ // The shortcuts may have changed so update the job so that we are observing the
+ // correct Uris
+ mDynamicShortcuts.scheduleUpdateJob();
+ }
+ }
+}
diff --git a/src/com/android/contacts/common/list/ShortcutIntentBuilder.java b/src/com/android/contacts/common/list/ShortcutIntentBuilder.java
index f30a176..e230996 100644
--- a/src/com/android/contacts/common/list/ShortcutIntentBuilder.java
+++ b/src/com/android/contacts/common/list/ShortcutIntentBuilder.java
@@ -47,6 +47,7 @@
import com.android.contacts.common.ContactPhotoManager;
import com.android.contacts.common.ContactPhotoManager.DefaultImageRequest;
import com.android.contacts.common.R;
+import com.android.contacts.common.util.ImplicitIntentsUtil;
/**
* Constructs shortcut intents.
@@ -265,26 +266,8 @@
String lookupKey, byte[] bitmapData) {
Drawable drawable = getPhotoDrawable(bitmapData, displayName, lookupKey);
- // Use an implicit intent without a package name set. It is reasonable for a disambiguation
- // dialog to appear when opening QuickContacts from the launcher. Plus, this will be more
- // resistant to future package name changes done to Contacts.
- Intent shortcutIntent = new Intent(ContactsContract.QuickContact.ACTION_QUICK_CONTACT);
-
- // When starting from the launcher, start in a new, cleared task.
- // CLEAR_WHEN_TASK_RESET cannot reset the root of a task, so we
- // clear the whole thing preemptively here since QuickContactActivity will
- // finish itself when launching other detail activities. We need to use
- // Intent.FLAG_ACTIVITY_NO_ANIMATION since not all versions of launcher will respect
- // the INTENT_EXTRA_IGNORE_LAUNCH_ANIMATION intent extra.
- shortcutIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK
- | Intent.FLAG_ACTIVITY_NO_ANIMATION);
-
- // Tell the launcher to not do its animation, because we are doing our own
- shortcutIntent.putExtra(INTENT_EXTRA_IGNORE_LAUNCH_ANIMATION, true);
-
- shortcutIntent.setDataAndType(contactUri, contentType);
- shortcutIntent.putExtra(ContactsContract.QuickContact.EXTRA_EXCLUDE_MIMES,
- (String[]) null);
+ final Intent shortcutIntent = ImplicitIntentsUtil.getIntentForQuickContactLauncherShortcut(
+ mContext, contactUri);
final Bitmap icon = generateQuickContactIcon(drawable);
diff --git a/src/com/android/contacts/common/util/ImplicitIntentsUtil.java b/src/com/android/contacts/common/util/ImplicitIntentsUtil.java
index 47bf605..19d171c 100644
--- a/src/com/android/contacts/common/util/ImplicitIntentsUtil.java
+++ b/src/com/android/contacts/common/util/ImplicitIntentsUtil.java
@@ -32,6 +32,8 @@
import java.util.List;
+import static com.android.contacts.common.list.ShortcutIntentBuilder.INTENT_EXTRA_IGNORE_LAUNCH_ANIMATION;
+
/**
* Utility for forcing intents to be started inside the current app. This is useful for avoiding
* senseless disambiguation dialogs. Ie, if a user clicks a contact inside Contacts we assume
@@ -130,6 +132,28 @@
return intent;
}
+ public static Intent getIntentForQuickContactLauncherShortcut(Context context, Uri contactUri) {
+ final Intent intent = composeQuickContactIntent(context, contactUri,
+ QuickContact.MODE_LARGE);
+ intent.setPackage(context.getPackageName());
+
+ // When starting from the launcher, start in a new, cleared task.
+ // CLEAR_WHEN_TASK_RESET cannot reset the root of a task, so we
+ // clear the whole thing preemptively here since QuickContactActivity will
+ // finish itself when launching other detail activities. We need to use
+ // Intent.FLAG_ACTIVITY_NO_ANIMATION since not all versions of launcher will respect
+ // the INTENT_EXTRA_IGNORE_LAUNCH_ANIMATION intent extra.
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK
+ | Intent.FLAG_ACTIVITY_NO_ANIMATION);
+
+ // Tell the launcher to not do its animation, because we are doing our own
+ intent.putExtra(INTENT_EXTRA_IGNORE_LAUNCH_ANIMATION, true);
+
+ intent.putExtra(QuickContact.EXTRA_EXCLUDE_MIMES, (String[])null);
+
+ return intent;
+ }
+
/**
* Returns a copy of {@param intent} with a class name set, if a class inside this app
* has a corresponding intent filter.
diff --git a/tests/Android.mk b/tests/Android.mk
index 48a00f4..1176687 100644
--- a/tests/Android.mk
+++ b/tests/Android.mk
@@ -17,8 +17,9 @@
LOCAL_SDK_VERSION := current
LOCAL_MIN_SDK_VERSION := 21
-LOCAL_STATIC_JAVA_LIBRARIES := \
- mockito-target
+LOCAL_STATIC_JAVA_LIBRARIES += \
+ hamcrest-library \
+ mockito-target-minus-junit4
LOCAL_AAPT_FLAGS := \
--auto-add-overlay \
diff --git a/tests/src/com/android/contacts/DynamicShortcutsTests.java b/tests/src/com/android/contacts/DynamicShortcutsTests.java
new file mode 100644
index 0000000..168b646
--- /dev/null
+++ b/tests/src/com/android/contacts/DynamicShortcutsTests.java
@@ -0,0 +1,307 @@
+/*
+ * 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.JobScheduler;
+import android.content.ContentProvider;
+import android.content.ContentResolver;
+import android.content.Context;
+import android.content.pm.ShortcutInfo;
+import android.content.pm.ShortcutManager;
+import android.database.Cursor;
+import android.database.MatrixCursor;
+import android.net.Uri;
+import android.os.Build;
+import android.provider.ContactsContract;
+import android.provider.ContactsContract.Contacts;
+import android.support.test.filters.SdkSuppress;
+import android.test.AndroidTestCase;
+import android.test.mock.MockContentResolver;
+import android.test.suitebuilder.annotation.SmallTest;
+import android.test.suitebuilder.annotation.Suppress;
+
+import com.android.contacts.common.test.mocks.MockContentProvider;
+
+import org.hamcrest.BaseMatcher;
+import org.hamcrest.Description;
+import org.hamcrest.Matcher;
+import org.hamcrest.Matchers;
+import org.mockito.ArgumentCaptor;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.equalTo;
+import static org.mockito.Matchers.anyString;
+import static org.mockito.Matchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@TargetApi(Build.VERSION_CODES.N_MR1)
+@SdkSuppress(minSdkVersion = Build.VERSION_CODES.N_MR1)
+// TODO: need to switch to android.support.test.runner.AndroidJUnitRunner for the @SdkSuppress
+// annotation to be respected. So for now we suppress this test to keep it from failing when run
+// by the build system.
+@Suppress
+@SmallTest
+public class DynamicShortcutsTests extends AndroidTestCase {
+
+
+ @Override
+ protected void tearDown() throws Exception {
+ super.tearDown();
+
+ // Clean up the job if it was scheduled by these tests.
+ final JobScheduler scheduler = (JobScheduler) getContext()
+ .getSystemService(Context.JOB_SCHEDULER_SERVICE);
+ scheduler.cancel(ContactsJobService.DYNAMIC_SHORTCUTS_JOB_ID);
+ }
+
+ // Basic smoke test to make sure the queries executed by DynamicShortcuts are valid as well
+ // as the integration with ShortcutManager. Note that this may change the state of the shortcuts
+ // on the device it is executed on.
+ public void test_refresh_doesntCrash() {
+ final DynamicShortcuts sut = new DynamicShortcuts(getContext());
+ sut.refresh();
+ // Pass because it didn't throw an exception.
+ }
+
+ public void test_createShortcutFromRow_hasCorrectResult() {
+ final DynamicShortcuts sut = createDynamicShortcuts();
+
+ final Cursor row = queryResult(
+ // ID, LOOKUP_KEY, DISPLAY_NAME_PRIMARY
+ 1l, "lookup_key", "John Smith"
+ );
+
+ row.moveToFirst();
+ final ShortcutInfo shortcut = sut.builderForContactShortcut(row).build();
+
+ assertEquals("lookup_key", shortcut.getId());
+ assertEquals(Contacts.getLookupUri(1, "lookup_key"), shortcut.getIntent().getData());
+ assertEquals(ContactsContract.QuickContact.ACTION_QUICK_CONTACT,
+ shortcut.getIntent().getAction());
+ assertEquals("John Smith", shortcut.getShortLabel());
+ assertEquals("John Smith", shortcut.getLongLabel());
+ assertEquals(1l, shortcut.getExtras().getLong(Contacts._ID));
+ }
+
+ public void test_builderForContactShortcut_ellipsizesLongNamesForLabels() {
+ final DynamicShortcuts sut = createDynamicShortcuts();
+ sut.setShortLabelMaxLength(5);
+ sut.setLongLabelMaxLength(10);
+
+ final ShortcutInfo shortcut = sut.builderForContactShortcut(1l, "lookup_key",
+ "123456789 1011").build();
+
+ assertEquals("1234…", shortcut.getShortLabel());
+ assertEquals("123456789…", shortcut.getLongLabel());
+ }
+
+ public void test_updatePinned_disablesShortcutsForRemovedContacts() {
+ final ShortcutManager mockShortcutManager = mock(ShortcutManager.class);
+ when(mockShortcutManager.getPinnedShortcuts()).thenReturn(
+ Collections.singletonList(shortcutFor(1l, "key1", "name1")));
+
+ final DynamicShortcuts sut = new DynamicShortcuts(getContext(), emptyResolver(),
+ mockShortcutManager);
+
+ sut.updatePinned();
+
+ verify(mockShortcutManager).disableShortcuts(
+ eq(Collections.singletonList("key1")), anyString());
+ }
+
+ public void test_updatePinned_updatesExistingShortcutsWithMatchingKeys() {
+ final ShortcutManager mockShortcutManager = mock(ShortcutManager.class);
+ when(mockShortcutManager.getPinnedShortcuts()).thenReturn(
+ Arrays.asList(
+ shortcutFor(1l, "key1", "name1"),
+ shortcutFor(2l, "key2", "name2"),
+ shortcutFor(3l, "key3", "name3")
+ ));
+
+ final DynamicShortcuts sut = createDynamicShortcuts(resolverWithExpectedQueries(
+ queryForSingleRow(Contacts.getLookupUri(1l, "key1"), 11l, "key1", "New Name1"),
+ queryForSingleRow(Contacts.getLookupUri(2l, "key2"), 2l, "key2", "name2"),
+ queryForSingleRow(Contacts.getLookupUri(3l, "key3"), 33l, "key3", "name3")
+ ), mockShortcutManager);
+
+ sut.updatePinned();
+
+ final ArgumentCaptor<List<ShortcutInfo>> updateArgs =
+ ArgumentCaptor.forClass((Class) List.class);
+
+ verify(mockShortcutManager).disableShortcuts(
+ eq(Collections.<String>emptyList()), anyString());
+ verify(mockShortcutManager).updateShortcuts(updateArgs.capture());
+
+ final List<ShortcutInfo> arg = updateArgs.getValue();
+ assertThat(arg.size(), equalTo(3));
+ assertThat(arg.get(0),
+ isShortcutForContact(11l, "key1", "New Name1"));
+ assertThat(arg.get(1),
+ isShortcutForContact(2l, "key2", "name2"));
+ assertThat(arg.get(2),
+ isShortcutForContact(33l, "key3", "name3"));
+ }
+
+ public void test_refresh_setsDynamicShortcutsToStrequentContacts() {
+ final ShortcutManager mockShortcutManager = mock(ShortcutManager.class);
+ when(mockShortcutManager.getPinnedShortcuts()).thenReturn(
+ Collections.<ShortcutInfo>emptyList());
+ final DynamicShortcuts sut = createDynamicShortcuts(resolverWithExpectedQueries(
+ queryFor(Contacts.CONTENT_STREQUENT_URI,
+ 1l, "starred_key", "starred name",
+ 2l, "freq_key", "freq name",
+ 3l, "starred_2", "Starred Two")), mockShortcutManager);
+
+ sut.refresh();
+
+ final ArgumentCaptor<List<ShortcutInfo>> updateArgs =
+ ArgumentCaptor.forClass((Class) List.class);
+
+ verify(mockShortcutManager).setDynamicShortcuts(updateArgs.capture());
+
+ final List<ShortcutInfo> arg = updateArgs.getValue();
+ assertThat(arg.size(), equalTo(3));
+ assertThat(arg.get(0), isShortcutForContact(1l, "starred_key", "starred name"));
+ assertThat(arg.get(1), isShortcutForContact(2l, "freq_key", "freq name"));
+ assertThat(arg.get(2), isShortcutForContact(3l, "starred_2", "Starred Two"));
+ }
+
+ public void test_scheduleUpdateJob_schedulesJob() {
+ final DynamicShortcuts sut = createDynamicShortcuts();
+ sut.scheduleUpdateJob();
+ assertThat(DynamicShortcuts.isJobScheduled(getContext()), Matchers.is(true));
+ }
+
+ private Matcher<ShortcutInfo> isShortcutForContact(final long id,
+ final String lookupKey, final String name) {
+ return new BaseMatcher<ShortcutInfo>() {
+ @Override
+ public boolean matches(Object o) {
+ if (!(o instanceof ShortcutInfo)) return false;
+ final ShortcutInfo other = (ShortcutInfo)o;
+ return id == other.getExtras().getLong(Contacts._ID)
+ && lookupKey.equals(other.getId())
+ && name.equals(other.getLongLabel())
+ && name.equals(other.getShortLabel());
+ }
+
+ @Override
+ public void describeTo(Description description) {
+ description.appendText("Should be a shortcut for contact with _ID=" + id +
+ " lookup=" + lookupKey + " and display_name=" + name);
+ }
+ };
+ }
+
+ private ShortcutInfo shortcutFor(long contactId, String lookupKey, String name) {
+ return new DynamicShortcuts(getContext())
+ .builderForContactShortcut(contactId, lookupKey, name).build();
+ }
+
+ private ContentResolver emptyResolver() {
+ final MockContentProvider provider = new MockContentProvider();
+ provider.expect(MockContentProvider.Query.forAnyUri())
+ .withAnyProjection()
+ .withAnySelection()
+ .withAnySortOrder()
+ .returnEmptyCursor();
+ return resolverWithContactsProvider(provider);
+ }
+
+ private MockContentProvider.Query queryFor(Uri uri, Object... rows) {
+ final MockContentProvider.Query query = MockContentProvider.Query
+ .forUrisMatching(uri.getAuthority(), uri.getPath())
+ .withProjection(DynamicShortcuts.PROJECTION)
+ .withAnySelection()
+ .withAnySortOrder();
+
+ populateQueryRows(query, DynamicShortcuts.PROJECTION.length, rows);
+ return query;
+ }
+
+ private MockContentProvider.Query queryForSingleRow(Uri uri, Object... row) {
+ return new MockContentProvider.Query(uri)
+ .withProjection(DynamicShortcuts.PROJECTION)
+ .withAnySelection()
+ .withAnySortOrder()
+ .returnRow(row);
+ }
+
+ private ContentResolver resolverWithExpectedQueries(MockContentProvider.Query... queries) {
+ final MockContentProvider provider = new MockContentProvider();
+ for (MockContentProvider.Query query : queries) {
+ provider.expect(query);
+ }
+ return resolverWithContactsProvider(provider);
+ }
+
+ private ContentResolver resolverWithContactsProvider(ContentProvider provider) {
+ final MockContentResolver resolver = new MockContentResolver();
+ resolver.addProvider(ContactsContract.AUTHORITY, provider);
+ return resolver;
+ }
+
+ private DynamicShortcuts createDynamicShortcuts() {
+ return createDynamicShortcuts(emptyResolver(), mock(ShortcutManager.class));
+ }
+
+ private DynamicShortcuts createDynamicShortcuts(ContentResolver resolver,
+ ShortcutManager shortcutManager) {
+ final DynamicShortcuts result = new DynamicShortcuts(getContext(), resolver,
+ shortcutManager);
+ // Use very long label limits to make checking shortcuts easier to understand
+ result.setShortLabelMaxLength(100);
+ result.setLongLabelMaxLength(100);
+ return result;
+ }
+
+ private void populateQueryRows(MockContentProvider.Query query, int numColumns,
+ Object... rows) {
+ for (int i = 0; i < rows.length; i += numColumns) {
+ Object[] row = new Object[numColumns];
+ for (int j = 0; j < numColumns; j++) {
+ row[j] = rows[i + j];
+ }
+ query.returnRow(row);
+ }
+ }
+
+ private Cursor queryResult(Object... values) {
+ return queryResult(DynamicShortcuts.PROJECTION, values);
+ }
+
+ private Cursor queryResult(String[] columns, Object... values) {
+ MatrixCursor result = new MatrixCursor(new String[] {
+ Contacts._ID, Contacts.LOOKUP_KEY,
+ Contacts.DISPLAY_NAME_PRIMARY
+ });
+ for (int i = 0; i < values.length; i += columns.length) {
+ MatrixCursor.RowBuilder builder = result.newRow();
+ for (int j = 0; j < columns.length; j++) {
+ builder.add(values[i + j]);
+ }
+ }
+ return result;
+ }
+}
diff --git a/tests/src/com/android/contacts/common/test/mocks/MockContentProvider.java b/tests/src/com/android/contacts/common/test/mocks/MockContentProvider.java
index 335e8d2..336467d 100644
--- a/tests/src/com/android/contacts/common/test/mocks/MockContentProvider.java
+++ b/tests/src/com/android/contacts/common/test/mocks/MockContentProvider.java
@@ -20,6 +20,7 @@
import com.google.common.collect.Maps;
import android.content.ContentValues;
+import android.content.UriMatcher;
import android.database.Cursor;
import android.database.MatrixCursor;
import android.net.Uri;
@@ -43,6 +44,8 @@
public static class Query {
private final Uri mUri;
+ private UriMatcher mMatcher;
+
private String[] mProjection;
private String[] mDefaultProjection;
private String mSelection;
@@ -56,6 +59,15 @@
private boolean mExecuted;
+ private Query() {
+ mUri = null;
+ }
+
+ private Query(UriMatcher matcher) {
+ mUri = null;
+ mMatcher = matcher;
+ }
+
public Query(Uri uri) {
mUri = uri;
}
@@ -123,7 +135,11 @@
public boolean equals(Uri uri, String[] projection, String selection,
String[] selectionArgs, String sortOrder) {
- if (!uri.equals(mUri)) {
+ if (mUri == null) {
+ if (mMatcher != null && mMatcher.match(uri) == UriMatcher.NO_MATCH) {
+ return false;
+ }
+ } else if (!uri.equals(mUri)) {
return false;
}
@@ -169,6 +185,23 @@
}
return cursor;
}
+
+ public static Query forAnyUri() {
+ return new Query();
+ }
+
+ public static Query forUrisMatching(UriMatcher matcher) {
+ return new Query(matcher);
+ }
+
+ public static Query forUrisMatching(String authority, String... paths) {
+ final UriMatcher matcher = new UriMatcher(UriMatcher.NO_MATCH);
+ for (int i = 0; i < paths.length; i++) {
+ matcher.addURI(authority, paths[i], i);
+ }
+ return new Query(matcher);
+ }
+
}
public static class TypeQuery {
@@ -443,12 +476,15 @@
return true;
}
- public Query expectQuery(Uri contentUri) {
- Query query = new Query(contentUri);
+ public Query expect(Query query) {
mExpectedQueries.add(query);
return query;
}
+ public Query expectQuery(Uri contentUri) {
+ return expect(new Query(contentUri));
+ }
+
public void expectTypeQuery(Uri uri, String type) {
mExpectedTypeQueries.put(uri, type);
}
@@ -598,7 +634,7 @@
private static String queryToString(Uri uri, String[] projection, String selection,
String[] selectionArgs, String sortOrder) {
StringBuilder sb = new StringBuilder();
- sb.append(uri).append(" ");
+ sb.append(uri == null ? "<Any Uri>" : uri).append(" ");
if (projection != null) {
sb.append(Arrays.toString(projection));
} else {