diff options
Diffstat (limited to 'java/com/android/dialer/shortcuts')
20 files changed, 1822 insertions, 0 deletions
diff --git a/java/com/android/dialer/shortcuts/AndroidManifest.xml b/java/com/android/dialer/shortcuts/AndroidManifest.xml new file mode 100644 index 000000000..e731a3e68 --- /dev/null +++ b/java/com/android/dialer/shortcuts/AndroidManifest.xml @@ -0,0 +1,50 @@ +<!-- + ~ 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 + --> +<manifest xmlns:android="http://schemas.android.com/apk/res/android" + package="com.android.dialer.shortcuts"> + + <uses-sdk + android:minSdkVersion="23" + android:targetSdkVersion="25"/> + + <application> + + <service + android:exported="false" + android:name=".PeriodicJobService" + android:permission="android.permission.BIND_JOB_SERVICE"/> + + <!-- + Comments for attributes in CallContactActivity: + taskAffinity="" -> Open the dialog without opening the dialer app behind it + noHistory="true" -> Navigating away finishes activity + excludeFromRecents="true" -> Don't show in "recent apps" screen + + We do not export this activity and do not declare an intent filter as a security precaution + so that apps other than the dialer cannot attempt to make phone calls using it. + --> + <activity + android:name=".CallContactActivity" + android:taskAffinity="" + android:noHistory="true" + android:excludeFromRecents="true" + android:label="" + android:exported="false" + android:theme="@style/CallContactsTheme"/> + + </application> + +</manifest> diff --git a/java/com/android/dialer/shortcuts/AutoValue_DialerShortcut.java b/java/com/android/dialer/shortcuts/AutoValue_DialerShortcut.java new file mode 100644 index 000000000..ef995c816 --- /dev/null +++ b/java/com/android/dialer/shortcuts/AutoValue_DialerShortcut.java @@ -0,0 +1,161 @@ +/* + * Copyright (C) 2017 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.dialer.shortcuts; + +import android.support.annotation.NonNull; +import javax.annotation.Generated; + +@Generated("com.google.auto.value.processor.AutoValueProcessor") + final class AutoValue_DialerShortcut extends DialerShortcut { + + private final long contactId; + private final String lookupKey; + private final String displayName; + private final int rank; + + private AutoValue_DialerShortcut( + long contactId, + String lookupKey, + String displayName, + int rank) { + this.contactId = contactId; + this.lookupKey = lookupKey; + this.displayName = displayName; + this.rank = rank; + } + + @Override + long getContactId() { + return contactId; + } + + @NonNull + @Override + String getLookupKey() { + return lookupKey; + } + + @NonNull + @Override + String getDisplayName() { + return displayName; + } + + @Override + int getRank() { + return rank; + } + + @Override + public String toString() { + return "DialerShortcut{" + + "contactId=" + contactId + ", " + + "lookupKey=" + lookupKey + ", " + + "displayName=" + displayName + ", " + + "rank=" + rank + + "}"; + } + + @Override + public boolean equals(Object o) { + if (o == this) { + return true; + } + if (o instanceof DialerShortcut) { + DialerShortcut that = (DialerShortcut) o; + return (this.contactId == that.getContactId()) + && (this.lookupKey.equals(that.getLookupKey())) + && (this.displayName.equals(that.getDisplayName())) + && (this.rank == that.getRank()); + } + return false; + } + + @Override + public int hashCode() { + int h = 1; + h *= 1000003; + h ^= (this.contactId >>> 32) ^ this.contactId; + h *= 1000003; + h ^= this.lookupKey.hashCode(); + h *= 1000003; + h ^= this.displayName.hashCode(); + h *= 1000003; + h ^= this.rank; + return h; + } + + static final class Builder extends DialerShortcut.Builder { + private Long contactId; + private String lookupKey; + private String displayName; + private Integer rank; + Builder() { + } + private Builder(DialerShortcut source) { + this.contactId = source.getContactId(); + this.lookupKey = source.getLookupKey(); + this.displayName = source.getDisplayName(); + this.rank = source.getRank(); + } + @Override + DialerShortcut.Builder setContactId(long contactId) { + this.contactId = contactId; + return this; + } + @Override + DialerShortcut.Builder setLookupKey(String lookupKey) { + this.lookupKey = lookupKey; + return this; + } + @Override + DialerShortcut.Builder setDisplayName(String displayName) { + this.displayName = displayName; + return this; + } + @Override + DialerShortcut.Builder setRank(int rank) { + this.rank = rank; + return this; + } + @Override + DialerShortcut build() { + String missing = ""; + if (this.contactId == null) { + missing += " contactId"; + } + if (this.lookupKey == null) { + missing += " lookupKey"; + } + if (this.displayName == null) { + missing += " displayName"; + } + if (this.rank == null) { + missing += " rank"; + } + if (!missing.isEmpty()) { + throw new IllegalStateException("Missing required properties:" + missing); + } + return new AutoValue_DialerShortcut( + this.contactId, + this.lookupKey, + this.displayName, + this.rank); + } + } + +}
\ No newline at end of file diff --git a/java/com/android/dialer/shortcuts/CallContactActivity.java b/java/com/android/dialer/shortcuts/CallContactActivity.java new file mode 100644 index 000000000..1e9a01b39 --- /dev/null +++ b/java/com/android/dialer/shortcuts/CallContactActivity.java @@ -0,0 +1,133 @@ +/* + * 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.dialer.shortcuts; + +import android.content.pm.PackageManager; +import android.net.Uri; +import android.os.Bundle; +import android.support.v4.app.ActivityCompat; +import android.widget.Toast; +import com.android.dialer.callintent.nano.CallInitiationType; +import com.android.dialer.callintent.nano.CallSpecificAppData; +import com.android.dialer.common.LogUtil; +import com.android.dialer.interactions.PhoneNumberInteraction; +import com.android.dialer.interactions.PhoneNumberInteraction.InteractionErrorCode; +import com.android.dialer.util.TransactionSafeActivity; + +/** + * Invisible activity launched when a shortcut is selected by user. Calls a contact based on URI. + */ +public class CallContactActivity extends TransactionSafeActivity + implements PhoneNumberInteraction.DisambigDialogDismissedListener, + PhoneNumberInteraction.InteractionErrorListener, + ActivityCompat.OnRequestPermissionsResultCallback { + + private static final String CONTACT_URI_KEY = "uri_key"; + + private Uri contactUri; + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + if ("com.android.dialer.shortcuts.CALL_CONTACT".equals(getIntent().getAction())) { + if (Shortcuts.areDynamicShortcutsEnabled(this)) { + LogUtil.i("CallContactActivity.onCreate", "shortcut clicked"); + contactUri = getIntent().getData(); + makeCall(); + } else { + LogUtil.i("CallContactActivity.onCreate", "dynamic shortcuts disabled"); + finish(); + } + } + } + + private void makeCall() { + CallSpecificAppData callSpecificAppData = new CallSpecificAppData(); + callSpecificAppData.callInitiationType = CallInitiationType.Type.LAUNCHER_SHORTCUT; + PhoneNumberInteraction.startInteractionForPhoneCall( + this, contactUri, false /* isVideoCall */, callSpecificAppData); + } + + @Override + public void onDisambigDialogDismissed() { + finish(); + } + + @Override + public void interactionError(@InteractionErrorCode int interactionErrorCode) { + // Note: There is some subtlety to how contact lookup keys work that make it difficult to + // distinguish the case of the contact missing from the case of the a contact not having a + // number. For example, if a contact's phone number is deleted, subsequent lookups based on + // lookup key will actually return no results because the phone number was part of the + // lookup key. In this case, it would be inaccurate to say the contact can't be found though, so + // in all cases we just say the contact can't be found or the contact doesn't have a number. + switch (interactionErrorCode) { + case InteractionErrorCode.CONTACT_NOT_FOUND: + case InteractionErrorCode.CONTACT_HAS_NO_NUMBER: + Toast.makeText( + this, + R.string.dialer_shortcut_contact_not_found_or_has_no_number, + Toast.LENGTH_SHORT) + .show(); + break; + case InteractionErrorCode.USER_LEAVING_ACTIVITY: + case InteractionErrorCode.OTHER_ERROR: + default: + // If the user is leaving the activity or the error code was "other" there's no useful + // information to display but we still need to finish this invisible activity. + break; + } + finish(); + } + + @Override + public void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); + outState.putParcelable(CONTACT_URI_KEY, contactUri); + } + + @Override + public void onRestoreInstanceState(Bundle savedInstanceState) { + super.onRestoreInstanceState(savedInstanceState); + if (savedInstanceState == null) { + return; + } + contactUri = savedInstanceState.getParcelable(CONTACT_URI_KEY); + } + + @Override + public void onRequestPermissionsResult( + int requestCode, String[] permissions, int[] grantResults) { + switch (requestCode) { + case PhoneNumberInteraction.REQUEST_READ_CONTACTS: + { + // If request is cancelled, the result arrays are empty. + if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + makeCall(); + } else { + Toast.makeText(this, R.string.dialer_shortcut_no_permissions, Toast.LENGTH_SHORT) + .show(); + } + finish(); + break; + } + default: + throw new IllegalStateException("Unsupported request code: " + requestCode); + } + } +} diff --git a/java/com/android/dialer/shortcuts/DialerShortcut.java b/java/com/android/dialer/shortcuts/DialerShortcut.java new file mode 100644 index 000000000..f2fb3301a --- /dev/null +++ b/java/com/android/dialer/shortcuts/DialerShortcut.java @@ -0,0 +1,190 @@ +/* + * 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.dialer.shortcuts; + +import android.annotation.TargetApi; +import android.content.pm.ShortcutInfo; +import android.net.Uri; +import android.os.Build.VERSION_CODES; +import android.provider.ContactsContract.Contacts; +import android.support.annotation.NonNull; + + +/** + * Convenience data structure. + * + * <p>This differs from {@link ShortcutInfo} in that it doesn't hold an icon or intent, and provides + * convenience methods for doing things like constructing labels. + */ +@TargetApi(VERSION_CODES.N_MR1) // Shortcuts introduced in N MR1 + +abstract class DialerShortcut { + + /** Marker value indicates that shortcut has no setRank. Used by pinned shortcuts. */ + static final int NO_RANK = -1; + + /** + * Contact ID from contacts provider. Note that this a numeric row ID from the + * ContactsContract.Contacts._ID column. + */ + abstract long getContactId(); + + /** + * Lookup key from contacts provider. An example lookup key is: "0r8-47392D". This is the value + * from ContactsContract.Contacts.LOOKUP_KEY. + */ + @NonNull + abstract String getLookupKey(); + + /** Display name from contacts provider. */ + @NonNull + abstract String getDisplayName(); + + /** + * Rank for dynamic shortcuts. This value should be positive or {@link #NO_RANK}. + * + * <p>For floating shortcuts (pinned shortcuts with no corresponding dynamic shortcut), setRank + * has no meaning and the setRank may be set to {@link #NO_RANK}. + */ + abstract int getRank(); + + /** The short label for the shortcut. Used when pinning shortcuts, for example. */ + @NonNull + String getShortLabel() { + // Be sure to update getDisplayNameFromShortcutInfo when updating this. + return getDisplayName(); + } + + /** + * The long label for the shortcut. Used for shortcuts displayed when pressing and holding the app + * launcher icon, for example. + */ + @NonNull + String getLongLabel() { + return getDisplayName(); + } + + /** The display name for the provided shortcut. */ + static String getDisplayNameFromShortcutInfo(ShortcutInfo shortcutInfo) { + return shortcutInfo.getShortLabel().toString(); + } + + /** + * The id used to identify launcher shortcuts. Used for updating/deleting shortcuts. + * + * <p>Lookup keys are used for shortcut IDs. See {@link #getLookupKey()}. + * + * <p>If you change this, you probably also need to change {@link #getLookupKeyFromShortcutInfo}. + */ + @NonNull + String getShortcutId() { + return getLookupKey(); + } + + /** + * Returns the contact lookup key from the provided {@link ShortcutInfo}. + * + * <p>Lookup keys are used for shortcut IDs. See {@link #getLookupKey()}. + */ + @NonNull + static String getLookupKeyFromShortcutInfo(@NonNull ShortcutInfo shortcutInfo) { + return shortcutInfo.getId(); // Lookup keys are used for shortcut IDs. + } + + /** + * Returns the lookup URI from the provided {@link ShortcutInfo}. + * + * <p>Lookup URIs are constructed from lookup key and contact ID. Here is an example lookup URI + * where lookup key is "0r8-47392D" and contact ID is 8: + * + * <p>"content://com.android.contacts/contacts/lookup/0r8-47392D/8" + */ + @NonNull + static Uri getLookupUriFromShortcutInfo(@NonNull ShortcutInfo shortcutInfo) { + long contactId = + shortcutInfo.getIntent().getLongExtra(ShortcutInfoFactory.EXTRA_CONTACT_ID, -1); + if (contactId == -1) { + throw new IllegalStateException("No contact ID found for shortcut: " + shortcutInfo.getId()); + } + String lookupKey = getLookupKeyFromShortcutInfo(shortcutInfo); + return Contacts.getLookupUri(contactId, lookupKey); + } + + /** + * Contacts provider URI which uses the contact lookup key. + * + * <p>Lookup URIs are constructed from lookup key and contact ID. Here is an example lookup URI + * where lookup key is "0r8-47392D" and contact ID is 8: + * + * <p>"content://com.android.contacts/contacts/lookup/0r8-47392D/8" + */ + @NonNull + Uri getLookupUri() { + return Contacts.getLookupUri(getContactId(), getLookupKey()); + } + + /** + * Given an existing shortcut with the same shortcut ID, returns true if the existing shortcut + * needs to be updated, e.g. if the contact's name or rank has changed. + * + * <p>Does not detect photo updates. + */ + boolean needsUpdate(@NonNull ShortcutInfo oldInfo) { + if (this.getRank() != NO_RANK && oldInfo.getRank() != this.getRank()) { + return true; + } + if (!oldInfo.getShortLabel().equals(this.getShortLabel())) { + return true; + } + if (!oldInfo.getLongLabel().equals(this.getLongLabel())) { + return true; + } + return false; + } + + static Builder builder() { + return new AutoValue_DialerShortcut.Builder().setRank(NO_RANK); + } + + + abstract static class Builder { + + /** + * Sets the contact ID. This should be a value from the contact provider's Contact._ID column. + */ + abstract Builder setContactId(long value); + + /** + * Sets the lookup key. This should be a contact lookup key as provided by the contact provider. + */ + abstract Builder setLookupKey(@NonNull String value); + + /** Sets the display name. This should be a value provided by the contact provider. */ + abstract Builder setDisplayName(@NonNull String value); + + /** + * Sets the rank for the shortcut, used for ordering dynamic shortcuts. This is required for + * dynamic shortcuts but unused for floating shortcuts because rank has no meaning for floating + * shortcuts. (Floating shortcuts are shortcuts which are pinned but have no corresponding + * dynamic shortcut.) + */ + abstract Builder setRank(int value); + + /** Builds the immutable {@link DialerShortcut} object from this builder. */ + abstract DialerShortcut build(); + } +} diff --git a/java/com/android/dialer/shortcuts/DynamicShortcuts.java b/java/com/android/dialer/shortcuts/DynamicShortcuts.java new file mode 100644 index 000000000..be9e088e1 --- /dev/null +++ b/java/com/android/dialer/shortcuts/DynamicShortcuts.java @@ -0,0 +1,243 @@ +/* + * 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.dialer.shortcuts; + +import android.Manifest; +import android.annotation.TargetApi; +import android.content.Context; +import android.content.pm.PackageManager; +import android.content.pm.ShortcutInfo; +import android.content.pm.ShortcutManager; +import android.os.Build.VERSION_CODES; +import android.support.annotation.NonNull; +import android.support.annotation.WorkerThread; +import android.support.v4.content.ContextCompat; +import android.util.ArrayMap; +import com.android.contacts.common.list.ContactEntry; +import com.android.dialer.common.Assert; +import com.android.dialer.common.LogUtil; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; + +/** + * Handles refreshing of dialer dynamic shortcuts. + * + * <p>Dynamic shortcuts are the list of shortcuts which is accessible by tapping and holding the + * dialer launcher icon from the app drawer or a home screen. + * + * <p>Dynamic shortcuts are refreshed whenever the dialtacts activity detects changes to favorites + * tiles. This class compares the newly updated favorites tiles to the existing list of (previously + * published) dynamic shortcuts to compute a delta, which consists of lists of shortcuts which need + * to be updated, added, or deleted. + * + * <p>Dynamic shortcuts should mirror (in order) the contacts displayed in the "tiled favorites" tab + * of the dialer application. When selecting a dynamic shortcut, the behavior should be the same as + * if the user had tapped on the contact from the tiled favorites tab. Specifically, if the user has + * more than one phone number, a number picker should be displayed, and otherwise the contact should + * be called directly. + * + * <p>Note that an icon change by itself does not trigger a shortcut update, because it is not + * possible to detect an icon update and we don't want to constantly force update icons, because + * that is an expensive operation which requires storage I/O. + * + * <p>However, the job scheduler uses {@link #updateIcons()} to makes sure icons are forcefully + * updated periodically (about once a day). + * + */ +@TargetApi(VERSION_CODES.N_MR1) // Shortcuts introduced in N MR1 +final class DynamicShortcuts { + + private static final int MAX_DYNAMIC_SHORTCUTS = 3; + + private static class Delta { + + final Map<String, DialerShortcut> shortcutsToUpdateById = new ArrayMap<>(); + final List<String> shortcutIdsToRemove = new ArrayList<>(); + final Map<String, DialerShortcut> shortcutsToAddById = new ArrayMap<>(); + } + + private final Context context; + private final ShortcutInfoFactory shortcutInfoFactory; + + DynamicShortcuts(@NonNull Context context, IconFactory iconFactory) { + this.context = context; + this.shortcutInfoFactory = new ShortcutInfoFactory(context, iconFactory); + } + + /** + * Performs a "complete refresh" of dynamic shortcuts. This is done by comparing the provided + * contact information with the existing dynamic shortcuts in order to compute a delta which + * contains shortcuts which should be added, updated, or removed. + * + * <p>If the delta is non-empty, it is applied by making appropriate calls to the {@link + * ShortcutManager} system service. + * + * <p>This is a slow blocking call which performs file I/O and should not be performed on the main + * thread. + */ + @WorkerThread + public void refresh(List<ContactEntry> contacts) { + Assert.isWorkerThread(); + LogUtil.enterBlock("DynamicShortcuts.refresh"); + + ShortcutManager shortcutManager = getShortcutManager(context); + + if (ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CONTACTS) + != PackageManager.PERMISSION_GRANTED) { + LogUtil.i("DynamicShortcuts.refresh", "no contact permissions"); + shortcutManager.removeAllDynamicShortcuts(); + return; + } + + // Fill the available shortcuts with dynamic shortcuts up to a maximum of 3 dynamic shortcuts. + int numDynamicShortcutsToCreate = + Math.min( + MAX_DYNAMIC_SHORTCUTS, + shortcutManager.getMaxShortcutCountPerActivity() + - shortcutManager.getManifestShortcuts().size()); + + Map<String, DialerShortcut> newDynamicShortcutsById = + new ArrayMap<>(numDynamicShortcutsToCreate); + int rank = 0; + for (ContactEntry entry : contacts) { + if (newDynamicShortcutsById.size() >= numDynamicShortcutsToCreate) { + break; + } + + DialerShortcut shortcut = + DialerShortcut.builder() + .setContactId(entry.id) + .setLookupKey(entry.lookupKey) + .setDisplayName(entry.getPreferredDisplayName()) + .setRank(rank++) + .build(); + newDynamicShortcutsById.put(shortcut.getShortcutId(), shortcut); + } + + List<ShortcutInfo> oldDynamicShortcuts = new ArrayList<>(shortcutManager.getDynamicShortcuts()); + Delta delta = computeDelta(oldDynamicShortcuts, newDynamicShortcutsById); + applyDelta(delta); + } + + /** + * Forces an update of all dynamic shortcut icons. This should only be done from job scheduler as + * updating icons requires storage I/O. + */ + @WorkerThread + void updateIcons() { + Assert.isWorkerThread(); + LogUtil.enterBlock("DynamicShortcuts.updateIcons"); + + if (ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CONTACTS) + != PackageManager.PERMISSION_GRANTED) { + LogUtil.i("DynamicShortcuts.updateIcons", "no contact permissions"); + return; + } + + ShortcutManager shortcutManager = getShortcutManager(context); + + int maxDynamicShortcutsToCreate = + shortcutManager.getMaxShortcutCountPerActivity() + - shortcutManager.getManifestShortcuts().size(); + int count = 0; + + List<ShortcutInfo> newShortcuts = new ArrayList<>(); + for (ShortcutInfo oldInfo : shortcutManager.getDynamicShortcuts()) { + newShortcuts.add(shortcutInfoFactory.withUpdatedIcon(oldInfo)); + if (++count >= maxDynamicShortcutsToCreate) { + break; + } + } + LogUtil.i("DynamicShortcuts.updateIcons", "updating %d shortcut icons", newShortcuts.size()); + shortcutManager.setDynamicShortcuts(newShortcuts); + } + + @NonNull + private Delta computeDelta( + @NonNull List<ShortcutInfo> oldDynamicShortcuts, + @NonNull Map<String, DialerShortcut> newDynamicShortcutsById) { + Delta delta = new Delta(); + if (oldDynamicShortcuts.isEmpty()) { + delta.shortcutsToAddById.putAll(newDynamicShortcutsById); + return delta; + } + + for (ShortcutInfo oldInfo : oldDynamicShortcuts) { + // Check to see if the new shortcut list contains the existing shortcut. + DialerShortcut newShortcut = newDynamicShortcutsById.get(oldInfo.getId()); + if (newShortcut != null) { + if (newShortcut.needsUpdate(oldInfo)) { + LogUtil.i("DynamicShortcuts.computeDelta", "contact updated"); + delta.shortcutsToUpdateById.put(oldInfo.getId(), newShortcut); + } // else the shortcut hasn't changed, nothing to do to it + } else { + // The old shortcut is not in the new shortcut list, remove it. + LogUtil.i("DynamicShortcuts.computeDelta", "contact removed"); + delta.shortcutIdsToRemove.add(oldInfo.getId()); + } + } + + // Add any new shortcuts that were not in the old shortcuts. + for (Entry<String, DialerShortcut> entry : newDynamicShortcutsById.entrySet()) { + String newId = entry.getKey(); + DialerShortcut newShortcut = entry.getValue(); + if (!containsShortcut(oldDynamicShortcuts, newId)) { + // The new shortcut was not found in the old shortcut list, so add it. + LogUtil.i("DynamicShortcuts.computeDelta", "contact added"); + delta.shortcutsToAddById.put(newId, newShortcut); + } + } + return delta; + } + + private void applyDelta(@NonNull Delta delta) { + ShortcutManager shortcutManager = getShortcutManager(context); + // Must perform remove before performing add to avoid adding more than supported by system. + if (!delta.shortcutIdsToRemove.isEmpty()) { + shortcutManager.removeDynamicShortcuts(delta.shortcutIdsToRemove); + } + if (!delta.shortcutsToUpdateById.isEmpty()) { + // Note: This may update pinned shortcuts as well. Pinned shortcuts which are also dynamic + // are not updated by the pinned shortcut logic. The reason that they are updated here + // instead of in the pinned shortcut logic is because setRank is required and only available + // here. + shortcutManager.updateShortcuts( + shortcutInfoFactory.buildShortcutInfos(delta.shortcutsToUpdateById)); + } + if (!delta.shortcutsToAddById.isEmpty()) { + shortcutManager.addDynamicShortcuts( + shortcutInfoFactory.buildShortcutInfos(delta.shortcutsToAddById)); + } + } + + private boolean containsShortcut( + @NonNull List<ShortcutInfo> shortcutInfos, @NonNull String shortcutId) { + for (ShortcutInfo oldInfo : shortcutInfos) { + if (oldInfo.getId().equals(shortcutId)) { + return true; + } + } + return false; + } + + private static ShortcutManager getShortcutManager(Context context) { + //noinspection WrongConstant + return (ShortcutManager) context.getSystemService(Context.SHORTCUT_SERVICE); + } +} diff --git a/java/com/android/dialer/shortcuts/IconFactory.java b/java/com/android/dialer/shortcuts/IconFactory.java new file mode 100644 index 000000000..a8c4ada4e --- /dev/null +++ b/java/com/android/dialer/shortcuts/IconFactory.java @@ -0,0 +1,112 @@ +/* + * 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.dialer.shortcuts; + +import android.content.Context; +import android.content.pm.ShortcutInfo; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.Icon; +import android.net.Uri; +import android.provider.ContactsContract; +import android.support.annotation.NonNull; +import android.support.annotation.WorkerThread; +import android.support.v4.graphics.drawable.RoundedBitmapDrawable; +import android.support.v4.graphics.drawable.RoundedBitmapDrawableFactory; +import com.android.contacts.common.lettertiles.LetterTileDrawable; +import com.android.dialer.common.Assert; +import com.android.dialer.util.DrawableConverter; +import java.io.InputStream; + +/** Constructs the icons for dialer shortcuts. */ +class IconFactory { + + private final Context context; + + IconFactory(@NonNull Context context) { + this.context = context; + } + + /** + * Creates an icon for the provided {@link DialerShortcut}. + * + * <p>The icon is a circle which contains a photo of the contact associated with the shortcut, if + * available. If a photo is not available, a circular colored icon with a single letter is instead + * created, where the letter is the first letter of the contact's name. If the contact has no + * name, a default colored "anonymous" avatar is used. + * + * <p>These icons should match exactly the favorites tiles in the starred tab of the dialer + * application, except that they are circular instead of rectangular. + */ + @WorkerThread + @NonNull + public Icon create(@NonNull DialerShortcut shortcut) { + Assert.isWorkerThread(); + + return create(shortcut.getLookupUri(), shortcut.getDisplayName(), shortcut.getLookupKey()); + } + + /** Same as {@link #create(DialerShortcut)}, but accepts a {@link ShortcutInfo}. */ + @WorkerThread + @NonNull + public Icon create(@NonNull ShortcutInfo shortcutInfo) { + Assert.isWorkerThread(); + return create( + DialerShortcut.getLookupUriFromShortcutInfo(shortcutInfo), + DialerShortcut.getDisplayNameFromShortcutInfo(shortcutInfo), + DialerShortcut.getLookupKeyFromShortcutInfo(shortcutInfo)); + } + + @WorkerThread + @NonNull + private Icon create( + @NonNull Uri lookupUri, @NonNull String displayName, @NonNull String lookupKey) { + Assert.isWorkerThread(); + + // In testing, there was no difference between high-res and thumbnail. + InputStream inputStream = + ContactsContract.Contacts.openContactPhotoInputStream( + context.getContentResolver(), lookupUri, false /* preferHighres */); + + Drawable drawable; + if (inputStream == null) { + // No photo for contact; use a letter tile. + LetterTileDrawable letterTileDrawable = new LetterTileDrawable(context.getResources()); + letterTileDrawable.setCanonicalDialerLetterTileDetails( + displayName, lookupKey, LetterTileDrawable.SHAPE_CIRCLE, LetterTileDrawable.TYPE_DEFAULT); + drawable = letterTileDrawable; + } else { + // There's a photo, create a circular drawable from it. + Bitmap bitmap = BitmapFactory.decodeStream(inputStream); + drawable = createCircularDrawable(bitmap); + } + int iconSize = + context.getResources().getDimensionPixelSize(R.dimen.launcher_shortcut_icon_size); + return Icon.createWithBitmap( + DrawableConverter.drawableToBitmap(drawable, iconSize /* width */, iconSize /* height */)); + } + + @NonNull + private Drawable createCircularDrawable(@NonNull Bitmap bitmap) { + RoundedBitmapDrawable roundedBitmapDrawable = + RoundedBitmapDrawableFactory.create(context.getResources(), bitmap); + roundedBitmapDrawable.setCircular(true); + roundedBitmapDrawable.setAntiAlias(true); + return roundedBitmapDrawable; + } +} diff --git a/java/com/android/dialer/shortcuts/PeriodicJobService.java b/java/com/android/dialer/shortcuts/PeriodicJobService.java new file mode 100644 index 000000000..62c9e37a0 --- /dev/null +++ b/java/com/android/dialer/shortcuts/PeriodicJobService.java @@ -0,0 +1,118 @@ +/* + * 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.dialer.shortcuts; + +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.Context; +import android.os.Build.VERSION; +import android.os.Build.VERSION_CODES; +import android.support.annotation.MainThread; +import android.support.annotation.NonNull; +import android.support.v4.os.UserManagerCompat; +import com.android.dialer.common.Assert; +import com.android.dialer.common.LogUtil; +import com.android.dialer.constants.ScheduledJobIds; +import java.util.concurrent.TimeUnit; + +/** + * {@link JobService} which starts the periodic job to refresh dynamic and pinned shortcuts. + * + * <p>Only {@link #schedulePeriodicJob(Context)} should be used by callers. + */ +@TargetApi(VERSION_CODES.N_MR1) // Shortcuts introduced in N MR1 +public final class PeriodicJobService extends JobService { + + private static final long REFRESH_PERIOD_MILLIS = TimeUnit.HOURS.toMillis(24); + + private RefreshShortcutsTask refreshShortcutsTask; + + /** + * Schedules the periodic job to refresh shortcuts. If called repeatedly, the job will just be + * rescheduled. + * + * <p>The job will not be scheduled if the build version is not at least N MR1 or if the user is + * locked. + */ + @MainThread + public static void schedulePeriodicJob(@NonNull Context context) { + Assert.isMainThread(); + LogUtil.enterBlock("PeriodicJobService.schedulePeriodicJob"); + + if (VERSION.SDK_INT >= VERSION_CODES.N_MR1 && UserManagerCompat.isUserUnlocked(context)) { + JobScheduler jobScheduler = context.getSystemService(JobScheduler.class); + if (jobScheduler.getPendingJob(ScheduledJobIds.SHORTCUT_PERIODIC_JOB) != null) { + LogUtil.i("PeriodicJobService.schedulePeriodicJob", "job already scheduled."); + return; + } + JobInfo jobInfo = + new JobInfo.Builder( + ScheduledJobIds.SHORTCUT_PERIODIC_JOB, + new ComponentName(context, PeriodicJobService.class)) + .setPeriodic(REFRESH_PERIOD_MILLIS) + .setPersisted(true) + .setRequiresCharging(true) + .setRequiresDeviceIdle(true) + .build(); + jobScheduler.schedule(jobInfo); + } + } + + /** Cancels the periodic job. */ + @MainThread + public static void cancelJob(@NonNull Context context) { + Assert.isMainThread(); + LogUtil.enterBlock("PeriodicJobService.cancelJob"); + + context.getSystemService(JobScheduler.class).cancel(ScheduledJobIds.SHORTCUT_PERIODIC_JOB); + } + + @Override + @MainThread + public boolean onStartJob(@NonNull JobParameters params) { + Assert.isMainThread(); + LogUtil.enterBlock("PeriodicJobService.onStartJob"); + + if (VERSION.SDK_INT >= VERSION_CODES.N_MR1) { + (refreshShortcutsTask = new RefreshShortcutsTask(this)).execute(params); + } else { + // It is possible for the job to have been scheduled on NMR1+ and then the system was + // downgraded to < NMR1. In this case, shortcuts are no longer supported so we cancel the job + // which creates them. + LogUtil.i("PeriodicJobService.onStartJob", "not running on NMR1, cancelling job"); + cancelJob(this); + return false; + } + return true; + } + + @Override + @MainThread + public boolean onStopJob(@NonNull JobParameters params) { + Assert.isMainThread(); + LogUtil.enterBlock("PeriodicJobService.onStopJob"); + + if (refreshShortcutsTask != null) { + refreshShortcutsTask.cancel(false /* mayInterruptIfRunning */); + } + return false; + } +} diff --git a/java/com/android/dialer/shortcuts/PinnedShortcuts.java b/java/com/android/dialer/shortcuts/PinnedShortcuts.java new file mode 100644 index 000000000..bfcc3df81 --- /dev/null +++ b/java/com/android/dialer/shortcuts/PinnedShortcuts.java @@ -0,0 +1,159 @@ +/* + * 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.dialer.shortcuts; + +import android.Manifest; +import android.annotation.TargetApi; +import android.content.Context; +import android.content.pm.PackageManager; +import android.content.pm.ShortcutInfo; +import android.content.pm.ShortcutManager; +import android.database.Cursor; +import android.net.Uri; +import android.os.Build.VERSION_CODES; +import android.provider.ContactsContract.Contacts; +import android.support.annotation.NonNull; +import android.support.annotation.WorkerThread; +import android.support.v4.content.ContextCompat; +import android.util.ArrayMap; +import com.android.dialer.common.Assert; +import com.android.dialer.common.LogUtil; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * Handles refreshing of dialer pinned shortcuts. + * + * <p>Pinned shortcuts are icons that the user has dragged to their home screen from the dialer + * application launcher shortcut menu, which is accessible by tapping and holding the dialer + * launcher icon from the app drawer or a home screen. + * + * <p>When refreshing pinned shortcuts, we check to make sure that pinned contact information is + * still up to date (e.g. photo and name). We also check to see if the contact has been deleted from + * the user's contacts, and if so, we disable the pinned shortcut. + * + */ +@TargetApi(VERSION_CODES.N_MR1) // Shortcuts introduced in N MR1 +final class PinnedShortcuts { + + private static final String[] PROJECTION = + new String[] { + Contacts._ID, Contacts.DISPLAY_NAME_PRIMARY, Contacts.CONTACT_LAST_UPDATED_TIMESTAMP, + }; + + private static class Delta { + + final List<String> shortcutIdsToDisable = new ArrayList<>(); + final Map<String, DialerShortcut> shortcutsToUpdateById = new ArrayMap<>(); + } + + private final Context context; + private final ShortcutInfoFactory shortcutInfoFactory; + + PinnedShortcuts(@NonNull Context context) { + this.context = context; + this.shortcutInfoFactory = new ShortcutInfoFactory(context, new IconFactory(context)); + } + + /** + * Performs a "complete refresh" of pinned shortcuts. This is done by (synchronously) querying for + * all contacts which currently have pinned shortcuts. The query results are used to compute a + * delta which contains a list of shortcuts which need to be updated (e.g. because of name/photo + * changes) or disabled (if contacts were deleted). Note that pinned shortcuts cannot be deleted + * programmatically and must be deleted by the user. + * + * <p>If the delta is non-empty, it is applied by making appropriate calls to the {@link + * ShortcutManager} system service. + * + * <p>This is a slow blocking call which performs file I/O and should not be performed on the main + * thread. + */ + @WorkerThread + public void refresh() { + Assert.isWorkerThread(); + LogUtil.enterBlock("PinnedShortcuts.refresh"); + + if (ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CONTACTS) + != PackageManager.PERMISSION_GRANTED) { + LogUtil.i("PinnedShortcuts.refresh", "no contact permissions"); + return; + } + + Delta delta = new Delta(); + ShortcutManager shortcutManager = context.getSystemService(ShortcutManager.class); + for (ShortcutInfo shortcutInfo : shortcutManager.getPinnedShortcuts()) { + if (shortcutInfo.isDeclaredInManifest()) { + // We never update/disable the manifest shortcut (the "create new contact" shortcut). + continue; + } + if (shortcutInfo.isDynamic()) { + // If the shortcut is both pinned and dynamic, let the logic which updates dynamic shortcuts + // handle the update. It would be problematic to try and apply the update here, because the + // setRank is nonsensical for pinned shortcuts and therefore could not be calculated. + continue; + } + + String lookupKey = DialerShortcut.getLookupKeyFromShortcutInfo(shortcutInfo); + Uri lookupUri = DialerShortcut.getLookupUriFromShortcutInfo(shortcutInfo); + + try (Cursor cursor = + context.getContentResolver().query(lookupUri, PROJECTION, null, null, null)) { + + if (cursor == null || !cursor.moveToNext()) { + LogUtil.i("PinnedShortcuts.refresh", "contact disabled"); + delta.shortcutIdsToDisable.add(shortcutInfo.getId()); + continue; + } + + // Note: The lookup key may have changed but we cannot refresh it because that would require + // changing the shortcut ID, which can only be accomplished with a remove and add; but + // pinned shortcuts cannot be added or removed. + DialerShortcut shortcut = + DialerShortcut.builder() + .setContactId(cursor.getLong(cursor.getColumnIndexOrThrow(Contacts._ID))) + .setLookupKey(lookupKey) + .setDisplayName( + cursor.getString(cursor.getColumnIndexOrThrow(Contacts.DISPLAY_NAME_PRIMARY))) + .build(); + + if (shortcut.needsUpdate(shortcutInfo)) { + LogUtil.i("PinnedShortcuts.refresh", "contact updated"); + delta.shortcutsToUpdateById.put(shortcutInfo.getId(), shortcut); + } + } + } + applyDelta(delta); + } + + private void applyDelta(@NonNull Delta delta) { + ShortcutManager shortcutManager = context.getSystemService(ShortcutManager.class); + String shortcutDisabledMessage = + context.getResources().getString(R.string.dialer_shortcut_disabled_message); + if (!delta.shortcutIdsToDisable.isEmpty()) { + shortcutManager.disableShortcuts(delta.shortcutIdsToDisable, shortcutDisabledMessage); + } + if (!delta.shortcutsToUpdateById.isEmpty()) { + // Note: This call updates both pinned and dynamic shortcuts, but the delta should contain + // no dynamic shortcuts. + if (!shortcutManager.updateShortcuts( + shortcutInfoFactory.buildShortcutInfos(delta.shortcutsToUpdateById))) { + LogUtil.i("PinnedShortcuts.applyDelta", "shortcutManager rate limited."); + } + } + } +} diff --git a/java/com/android/dialer/shortcuts/RefreshShortcutsTask.java b/java/com/android/dialer/shortcuts/RefreshShortcutsTask.java new file mode 100644 index 000000000..086d1dc7a --- /dev/null +++ b/java/com/android/dialer/shortcuts/RefreshShortcutsTask.java @@ -0,0 +1,71 @@ +/* + * 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.dialer.shortcuts; + +import android.annotation.TargetApi; +import android.app.job.JobParameters; +import android.app.job.JobService; +import android.os.AsyncTask; +import android.os.Build.VERSION_CODES; +import android.support.annotation.MainThread; +import android.support.annotation.NonNull; +import android.support.annotation.WorkerThread; +import com.android.dialer.common.Assert; +import com.android.dialer.common.LogUtil; + +/** {@link AsyncTask} used by the periodic job service to refresh dynamic and pinned shortcuts. */ +@TargetApi(VERSION_CODES.N_MR1) // Shortcuts introduced in N MR1 +final class RefreshShortcutsTask extends AsyncTask<JobParameters, Void, JobParameters> { + + private final JobService jobService; + + RefreshShortcutsTask(@NonNull JobService jobService) { + this.jobService = jobService; + } + + /** @param params array with length 1, provided from PeriodicJobService */ + @Override + @NonNull + @WorkerThread + protected JobParameters doInBackground(JobParameters... params) { + Assert.isWorkerThread(); + LogUtil.enterBlock("RefreshShortcutsTask.doInBackground"); + + // Dynamic shortcuts are refreshed from the UI but icons can become stale, so update them + // periodically using the job service. + // + // The reason that icons can become is stale is that there is no last updated timestamp for + // pictures; there is only a last updated timestamp for the entire contact row, which changes + // frequently (for example, when they are called their "times_contacted" is incremented). + // Relying on such a spuriously updated timestamp would result in too frequent shortcut updates, + // so instead we just allow the icon to become stale in the case that the contact's photo is + // updated, and then rely on the job service to periodically force update it. + new DynamicShortcuts(jobService, new IconFactory(jobService)).updateIcons(); // Blocking + new PinnedShortcuts(jobService).refresh(); // Blocking + + return params[0]; + } + + @Override + @MainThread + protected void onPostExecute(JobParameters params) { + Assert.isMainThread(); + LogUtil.enterBlock("RefreshShortcutsTask.onPostExecute"); + + jobService.jobFinished(params, false /* needsReschedule */); + } +} diff --git a/java/com/android/dialer/shortcuts/ShortcutInfoFactory.java b/java/com/android/dialer/shortcuts/ShortcutInfoFactory.java new file mode 100644 index 000000000..cf780bbd7 --- /dev/null +++ b/java/com/android/dialer/shortcuts/ShortcutInfoFactory.java @@ -0,0 +1,100 @@ +/* + * 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.dialer.shortcuts; + +import android.annotation.TargetApi; +import android.content.Context; +import android.content.Intent; +import android.content.pm.ShortcutInfo; +import android.os.Build.VERSION_CODES; +import android.support.annotation.NonNull; +import android.support.annotation.WorkerThread; +import com.android.dialer.common.Assert; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * Creates {@link ShortcutInfo} objects (which are required by shortcut manager system service) from + * {@link DialerShortcut} objects (which are package-private convenience data structures). + * + * <p>The main work this factory does is create shortcut intents. It also delegates to the {@link + * IconFactory} to create icons. + */ +@TargetApi(VERSION_CODES.N_MR1) // Shortcuts introduced in N MR1 +final class ShortcutInfoFactory { + + /** Key for the contact ID extra (a long) stored as part of the shortcut intent. */ + static final String EXTRA_CONTACT_ID = "contactId"; + + private final Context context; + private final IconFactory iconFactory; + + ShortcutInfoFactory(@NonNull Context context, IconFactory iconFactory) { + this.context = context; + this.iconFactory = iconFactory; + } + + /** + * Builds a list {@link ShortcutInfo} objects from the provided collection of {@link + * DialerShortcut} objects. This primarily means setting the intent and adding the icon, which + * {@link DialerShortcut} objects do not hold. + */ + @WorkerThread + @NonNull + List<ShortcutInfo> buildShortcutInfos(@NonNull Map<String, DialerShortcut> shortcutsById) { + Assert.isWorkerThread(); + List<ShortcutInfo> shortcuts = new ArrayList<>(shortcutsById.size()); + for (DialerShortcut shortcut : shortcutsById.values()) { + Intent intent = new Intent(); + intent.setClassName(context, "com.android.dialer.shortcuts.CallContactActivity"); + intent.setData(shortcut.getLookupUri()); + intent.setAction("com.android.dialer.shortcuts.CALL_CONTACT"); + intent.putExtra(EXTRA_CONTACT_ID, shortcut.getContactId()); + + ShortcutInfo.Builder shortcutInfo = + new ShortcutInfo.Builder(context, shortcut.getShortcutId()) + .setIntent(intent) + .setShortLabel(shortcut.getShortLabel()) + .setLongLabel(shortcut.getLongLabel()) + .setIcon(iconFactory.create(shortcut)); + + if (shortcut.getRank() != DialerShortcut.NO_RANK) { + shortcutInfo.setRank(shortcut.getRank()); + } + shortcuts.add(shortcutInfo.build()); + } + return shortcuts; + } + + /** + * Creates a copy of the provided {@link ShortcutInfo} but with an updated icon fetched from + * contacts provider. + */ + @WorkerThread + @NonNull + ShortcutInfo withUpdatedIcon(ShortcutInfo info) { + Assert.isWorkerThread(); + return new ShortcutInfo.Builder(context, info.getId()) + .setIntent(info.getIntent()) + .setShortLabel(info.getShortLabel()) + .setLongLabel(info.getLongLabel()) + .setRank(info.getRank()) + .setIcon(iconFactory.create(info)) + .build(); + } +} diff --git a/java/com/android/dialer/shortcuts/ShortcutRefresher.java b/java/com/android/dialer/shortcuts/ShortcutRefresher.java new file mode 100644 index 000000000..f5ff64874 --- /dev/null +++ b/java/com/android/dialer/shortcuts/ShortcutRefresher.java @@ -0,0 +1,86 @@ +/* + * 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.dialer.shortcuts; + +import android.content.Context; +import android.os.Build; +import android.support.annotation.MainThread; +import android.support.annotation.NonNull; +import android.support.annotation.WorkerThread; +import com.android.contacts.common.list.ContactEntry; +import com.android.dialer.common.Assert; +import com.android.dialer.common.AsyncTaskExecutor; +import com.android.dialer.common.AsyncTaskExecutors; +import com.android.dialer.common.FallibleAsyncTask; +import com.android.dialer.common.LogUtil; +import java.util.ArrayList; +import java.util.List; + +/** Refreshes launcher shortcuts from UI components using provided list of contacts. */ +public final class ShortcutRefresher { + + private static final AsyncTaskExecutor EXECUTOR = AsyncTaskExecutors.createThreadPoolExecutor(); + + /** Asynchronously updates launcher shortcuts using the provided list of contacts. */ + @MainThread + public static void refresh(@NonNull Context context, List<ContactEntry> contacts) { + Assert.isMainThread(); + Assert.isNotNull(context); + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N_MR1) { + return; + } + + if (!Shortcuts.areDynamicShortcutsEnabled(context)) { + return; + } + + //noinspection unchecked + EXECUTOR.submit(Task.ID, new Task(context), new ArrayList<>(contacts)); + } + + private static final class Task extends FallibleAsyncTask<List<ContactEntry>, Void, Void> { + private static final String ID = "ShortcutRefresher.Task"; + + private final Context context; + + Task(Context context) { + this.context = context; + } + + /** + * @param params array containing exactly one element, the list of contacts from favorites + * tiles, ordered in tile order. + */ + @SafeVarargs + @Override + @NonNull + @WorkerThread + protected final Void doInBackgroundFallible(List<ContactEntry>... params) { + Assert.isWorkerThread(); + LogUtil.enterBlock("ShortcutRefresher.Task.doInBackground"); + + // Only dynamic shortcuts are maintained from UI components. Pinned shortcuts are maintained + // by the job scheduler. This is because a pinned contact may not necessarily still be in the + // favorites tiles, so refreshing it would require an additional database query. We don't want + // to incur the cost of that extra database query every time the favorites tiles change. + new DynamicShortcuts(context, new IconFactory(context)).refresh(params[0]); // Blocking + + return null; + } + } +} diff --git a/java/com/android/dialer/shortcuts/ShortcutUsageReporter.java b/java/com/android/dialer/shortcuts/ShortcutUsageReporter.java new file mode 100644 index 000000000..50130fc49 --- /dev/null +++ b/java/com/android/dialer/shortcuts/ShortcutUsageReporter.java @@ -0,0 +1,132 @@ +/* + * 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.dialer.shortcuts; + +import android.Manifest; +import android.annotation.TargetApi; +import android.content.Context; +import android.content.pm.PackageManager; +import android.content.pm.ShortcutManager; +import android.database.Cursor; +import android.net.Uri; +import android.os.AsyncTask; +import android.os.Build; +import android.os.Build.VERSION_CODES; +import android.provider.ContactsContract.Contacts; +import android.provider.ContactsContract.PhoneLookup; +import android.support.annotation.MainThread; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.annotation.WorkerThread; +import android.support.v4.content.ContextCompat; +import android.text.TextUtils; +import com.android.dialer.common.Assert; +import com.android.dialer.common.AsyncTaskExecutor; +import com.android.dialer.common.AsyncTaskExecutors; +import com.android.dialer.common.LogUtil; + +/** + * Reports outgoing calls as shortcut usage. + * + * <p>Note that all outgoing calls are considered shortcut usage, no matter where they are initiated + * from (i.e. from anywhere in the dialer app, or even from other apps). + * + * <p>This allows launcher applications to provide users with shortcut suggestions, even if the user + * isn't already using shortcuts. + */ +@TargetApi(VERSION_CODES.N_MR1) // Shortcuts introduced in N_MR1 +public class ShortcutUsageReporter { + + private static final AsyncTaskExecutor EXECUTOR = AsyncTaskExecutors.createThreadPoolExecutor(); + + /** + * Called when an outgoing call is added to the call list in order to report outgoing calls as + * shortcut usage. This should be called exactly once for each outgoing call. + * + * <p>Asynchronously queries the contacts database for the contact's lookup key which corresponds + * to the provided phone number, and uses that to report shortcut usage. + * + * @param context used to access ShortcutManager system service + * @param phoneNumber the phone number being called + */ + @MainThread + public static void onOutgoingCallAdded(@NonNull Context context, @Nullable String phoneNumber) { + Assert.isMainThread(); + Assert.isNotNull(context); + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N_MR1 || TextUtils.isEmpty(phoneNumber)) { + return; + } + + EXECUTOR.submit(Task.ID, new Task(context), phoneNumber); + } + + private static final class Task extends AsyncTask<String, Void, Void> { + private static final String ID = "ShortcutUsageReporter.Task"; + + private final Context context; + + public Task(Context context) { + this.context = context; + } + + /** @param phoneNumbers array with exactly one non-empty phone number */ + @Override + @WorkerThread + protected Void doInBackground(@NonNull String... phoneNumbers) { + Assert.isWorkerThread(); + + String lookupKey = queryForLookupKey(phoneNumbers[0]); + if (!TextUtils.isEmpty(lookupKey)) { + LogUtil.i("ShortcutUsageReporter.backgroundLogUsage", "%s", lookupKey); + ShortcutManager shortcutManager = + (ShortcutManager) context.getSystemService(Context.SHORTCUT_SERVICE); + + // Note: There may not currently exist a shortcut with the provided key, but it is logged + // anyway, so that launcher applications at least have the information should the shortcut + // be created in the future. + shortcutManager.reportShortcutUsed(lookupKey); + } + return null; + } + + @Nullable + @WorkerThread + private String queryForLookupKey(String phoneNumber) { + Assert.isWorkerThread(); + + if (ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CONTACTS) + != PackageManager.PERMISSION_GRANTED) { + LogUtil.i("ShortcutUsageReporter.queryForLookupKey", "No contact permissions"); + return null; + } + + Uri uri = Uri.withAppendedPath(PhoneLookup.CONTENT_FILTER_URI, Uri.encode(phoneNumber)); + try (Cursor cursor = + context + .getContentResolver() + .query(uri, new String[] {Contacts.LOOKUP_KEY}, null, null, null)) { + + if (cursor == null || !cursor.moveToNext()) { + return null; // No contact for dialed number + } + // Arbitrarily use first result. + return cursor.getString(cursor.getColumnIndex(Contacts.LOOKUP_KEY)); + } + } + } +} diff --git a/java/com/android/dialer/shortcuts/Shortcuts.java b/java/com/android/dialer/shortcuts/Shortcuts.java new file mode 100644 index 000000000..b6a7fa82a --- /dev/null +++ b/java/com/android/dialer/shortcuts/Shortcuts.java @@ -0,0 +1,34 @@ +/* + * 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.dialer.shortcuts; + +import android.content.Context; +import android.support.annotation.NonNull; +import com.android.dialer.common.ConfigProviderBindings; + +/** Checks if dynamic shortcuts should be enabled. */ +public class Shortcuts { + + /** Key for boolean config value which determines whether or not to enable dynamic shortcuts. */ + private static final String DYNAMIC_SHORTCUTS_ENABLED = "dynamic_shortcuts_enabled"; + + static boolean areDynamicShortcutsEnabled(@NonNull Context context) { + return ConfigProviderBindings.get(context).getBoolean(DYNAMIC_SHORTCUTS_ENABLED, true); + } + + private Shortcuts() {} +} diff --git a/java/com/android/dialer/shortcuts/ShortcutsJobScheduler.java b/java/com/android/dialer/shortcuts/ShortcutsJobScheduler.java new file mode 100644 index 000000000..4cfc4361c --- /dev/null +++ b/java/com/android/dialer/shortcuts/ShortcutsJobScheduler.java @@ -0,0 +1,48 @@ +/* + * 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.dialer.shortcuts; + +import android.content.Context; +import android.support.annotation.MainThread; +import android.support.annotation.NonNull; +import com.android.dialer.common.Assert; +import com.android.dialer.common.LogUtil; + +/** + * Schedules dialer shortcut jobs. + * + * <p>A {@link ConfigProvider} value controls whether the jobs which creates shortcuts should be + * scheduled or cancelled. + */ +public class ShortcutsJobScheduler { + + @MainThread + public static void scheduleAllJobs(@NonNull Context context) { + LogUtil.enterBlock("ShortcutsJobScheduler.scheduleAllJobs"); + Assert.isMainThread(); + + if (Shortcuts.areDynamicShortcutsEnabled(context)) { + LogUtil.i("ShortcutsJobScheduler.scheduleAllJobs", "enabling shortcuts"); + + PeriodicJobService.schedulePeriodicJob(context); + } else { + LogUtil.i("ShortcutsJobScheduler.scheduleAllJobs", "disabling shortcuts"); + + PeriodicJobService.cancelJob(context); + } + } +} diff --git a/java/com/android/dialer/shortcuts/res/drawable/ic_shortcut_add_contact.xml b/java/com/android/dialer/shortcuts/res/drawable/ic_shortcut_add_contact.xml new file mode 100644 index 000000000..c06aec82f --- /dev/null +++ b/java/com/android/dialer/shortcuts/res/drawable/ic_shortcut_add_contact.xml @@ -0,0 +1,39 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ 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 + --> +<layer-list xmlns:android="http://schemas.android.com/apk/res/android"> + <item + android:bottom="2dp" + android:left="2dp" + android:right="2dp" + android:top="2dp"> + <shape android:shape="oval"> + <size + android:height="44dp" + android:width="44dp"/> + <solid android:color="@color/shortcut_add_contact_background_color"/> + </shape> + </item> + + <item + android:bottom="12dp" + android:left="10dp" + android:right="14dp" + android:top="12dp"> + <bitmap android:src="@drawable/quantum_ic_person_add_white_24" + android:tint="@color/shortcut_add_contact_foreground_color"/> + </item> +</layer-list> diff --git a/java/com/android/dialer/shortcuts/res/values/colors.xml b/java/com/android/dialer/shortcuts/res/values/colors.xml new file mode 100644 index 000000000..e20309b56 --- /dev/null +++ b/java/com/android/dialer/shortcuts/res/values/colors.xml @@ -0,0 +1,20 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ 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 + --> +<resources> + <color name="shortcut_add_contact_foreground_color">#2A56C6</color> + <color name="shortcut_add_contact_background_color">#f5f5f5</color> +</resources> diff --git a/java/com/android/dialer/shortcuts/res/values/dimens.xml b/java/com/android/dialer/shortcuts/res/values/dimens.xml new file mode 100644 index 000000000..232125653 --- /dev/null +++ b/java/com/android/dialer/shortcuts/res/values/dimens.xml @@ -0,0 +1,19 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ 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 + --> +<resources> + <dimen name="launcher_shortcut_icon_size">48dp</dimen> +</resources> diff --git a/java/com/android/dialer/shortcuts/res/values/strings.xml b/java/com/android/dialer/shortcuts/res/values/strings.xml new file mode 100644 index 000000000..1e2c87f12 --- /dev/null +++ b/java/com/android/dialer/shortcuts/res/values/strings.xml @@ -0,0 +1,37 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ 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 + --> +<resources> + <!-- Text to display in launcher shortcut for adding a new contact. Short version. [CHAR LIMIT=10] --> + <string name="dialer_shortcut_add_contact_short">New contact</string> + + <!-- Text to display in launcher shortcut for adding a new contact. Long version. [CHAR LIMIT=25] --> + <string name="dialer_shortcut_add_contact_long">New contact</string> + + <!-- Message to display when the user taps a pinned launcher shortcut (on a + homescreen) which has been disabled. A shortcut may be disabled if the + contact has been deleted or if it is invalid for some other reason. [CHAR LIMIT=70] --> + <string name="dialer_shortcut_disabled_message">Shortcut not working. Drag to remove.</string> + + <!-- Error message to display when a tapping a shortcut fails because the specified contact can't + be found or doesn't have any phone numbers. [CHAR LIMIT=70] --> + <string name="dialer_shortcut_contact_not_found_or_has_no_number">Contact no longer available.</string> + + <!-- Error message to display when a tapping a shortcut fails because contact permissions are + missing. [CHAR LIMIT=70] --> + <string name="dialer_shortcut_no_permissions">Cannot call without contact permissions.</string> + +</resources> diff --git a/java/com/android/dialer/shortcuts/res/values/themes.xml b/java/com/android/dialer/shortcuts/res/values/themes.xml new file mode 100644 index 000000000..085854d89 --- /dev/null +++ b/java/com/android/dialer/shortcuts/res/values/themes.xml @@ -0,0 +1,39 @@ +<resources> + <!-- + ~ 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 + --> + + <!-- CallContactsActivity is an invisible trampoline activity for launcher shortcuts to jump into + the calling activity. When the user taps a shortcut they will be taken to either the phone + number disambiguation dialog or directly into the incall UI via this activity, but this + activity itself should be completely transparent to the user. + + Note that this must inherit from Theme.AppCompat. We inherit from Theme.AppCompat.Light so + that the colors of the disambiguation dialog match the colors when it is shown via the + favorites tiles tab. --> + <style name="CallContactsTheme" parent="Theme.AppCompat.Light"> + <item name="android:windowNoTitle">true</item> + <item name="android:backgroundDimEnabled">false</item> + <item name="android:windowBackground">@null</item> + <item name="android:windowFrame">@null</item> + <item name="android:windowContentOverlay">@null</item> + <item name="android:windowAnimationStyle">@null</item> + <item name="android:windowIsTranslucent">true</item> + <item name="android:windowIsFloating">true</item> + <item name="android:windowActionBar">false</item> + <item name="android:windowDisablePreview">true</item> + </style> + +</resources>
\ No newline at end of file diff --git a/java/com/android/dialer/shortcuts/res/xml/shortcuts.xml b/java/com/android/dialer/shortcuts/res/xml/shortcuts.xml new file mode 100644 index 000000000..5e8f58d1f --- /dev/null +++ b/java/com/android/dialer/shortcuts/res/xml/shortcuts.xml @@ -0,0 +1,31 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ 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 + --> +<shortcuts xmlns:android="http://schemas.android.com/apk/res/android"> + <shortcut + android:enabled="true" + android:icon="@drawable/ic_shortcut_add_contact" + android:shortcutId="dialer-shortcut-add-contact" + android:shortcutLongLabel="@string/dialer_shortcut_add_contact_long" + android:shortcutShortLabel="@string/dialer_shortcut_add_contact_short"> + + <intent + android:action="android.intent.action.INSERT" + android:data="content://com.android.contacts/contacts" + android:targetPackage="com.google.android.contacts" + android:targetClass="com.android.contacts.activities.CompactContactEditorActivity"/> + </shortcut> +</shortcuts> |