diff options
Diffstat (limited to 'java/com/android/contacts')
358 files changed, 27457 insertions, 0 deletions
diff --git a/java/com/android/contacts/common/AndroidManifest.xml b/java/com/android/contacts/common/AndroidManifest.xml new file mode 100644 index 000000000..eae70cd30 --- /dev/null +++ b/java/com/android/contacts/common/AndroidManifest.xml @@ -0,0 +1,39 @@ +<!-- 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.contacts.common"> + + <application> + + <activity + android:name="com.android.contacts.common.dialog.CallSubjectDialog" + android:theme="@style/Theme.CallSubjectDialogTheme" + android:windowSoftInputMode="stateVisible|adjustResize"> + <intent-filter> + <action android:name="android.intent.action.VIEW"/> + </intent-filter> + </activity> + + <!-- Broadcast receiver that passively listens to location updates --> + <receiver android:name="com.android.contacts.common.location.CountryDetector$LocationChangedReceiver"/> + + <!-- IntentService to update the user's current country --> + <service + android:exported="false" + android:name="com.android.contacts.common.location.UpdateCountryService"/> + </application> +</manifest> + diff --git a/java/com/android/contacts/common/Bindings.java b/java/com/android/contacts/common/Bindings.java new file mode 100644 index 000000000..29cf7950a --- /dev/null +++ b/java/com/android/contacts/common/Bindings.java @@ -0,0 +1,52 @@ +/* + * 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.common; + +import android.content.Context; +import com.android.contacts.common.bindings.ContactsCommonBindings; +import com.android.contacts.common.bindings.ContactsCommonBindingsFactory; +import com.android.contacts.common.bindings.ContactsCommonBindingsStub; +import java.util.Objects; + +/** Accessor for the contacts common bindings. */ +public class Bindings { + + private static ContactsCommonBindings instance; + + private Bindings() {} + + public static ContactsCommonBindings get(Context context) { + Objects.requireNonNull(context); + if (instance != null) { + return instance; + } + + Context application = context.getApplicationContext(); + if (application instanceof ContactsCommonBindingsFactory) { + instance = ((ContactsCommonBindingsFactory) application).newContactsCommonBindings(); + } + + if (instance == null) { + instance = new ContactsCommonBindingsStub(); + } + return instance; + } + + public static void setForTesting(ContactsCommonBindings testInstance) { + instance = testInstance; + } +} diff --git a/java/com/android/contacts/common/ClipboardUtils.java b/java/com/android/contacts/common/ClipboardUtils.java new file mode 100644 index 000000000..9345b0f9c --- /dev/null +++ b/java/com/android/contacts/common/ClipboardUtils.java @@ -0,0 +1,55 @@ +/* + * Copyright (C) 2012 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.common; + +import android.content.ClipData; +import android.content.ClipboardManager; +import android.content.Context; +import android.text.TextUtils; +import android.widget.Toast; + +public class ClipboardUtils { + + private static final String TAG = "ClipboardUtils"; + + private ClipboardUtils() {} + + /** + * Copy a text to clipboard. + * + * @param context Context + * @param label Label to show to the user describing this clip. + * @param text Text to copy. + * @param showToast If {@code true}, a toast is shown to the user. + */ + public static void copyText( + Context context, CharSequence label, CharSequence text, boolean showToast) { + if (TextUtils.isEmpty(text)) { + return; + } + + ClipboardManager clipboardManager = + (ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE); + ClipData clipData = ClipData.newPlainText(label == null ? "" : label, text); + clipboardManager.setPrimaryClip(clipData); + + if (showToast) { + String toastText = context.getString(R.string.toast_text_copied); + Toast.makeText(context, toastText, Toast.LENGTH_SHORT).show(); + } + } +} diff --git a/java/com/android/contacts/common/Collapser.java b/java/com/android/contacts/common/Collapser.java new file mode 100644 index 000000000..0b5c48bf2 --- /dev/null +++ b/java/com/android/contacts/common/Collapser.java @@ -0,0 +1,95 @@ +/* + * Copyright (C) 2009 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.common; + +import android.content.Context; +import java.util.Iterator; +import java.util.List; + +/** + * Class used for collapsing data items into groups of similar items. The data items that should be + * collapsible should implement the Collapsible interface. The class also contains a utility + * function that takes an ArrayList of items and returns a list of the same items collapsed into + * groups. + */ +public final class Collapser { + + /* + * The Collapser uses an n^2 algorithm so we don't want it to run on + * lists beyond a certain size. This specifies the maximum size to collapse. + */ + private static final int MAX_LISTSIZE_TO_COLLAPSE = 20; + + /* + * This utility class cannot be instantiated. + */ + private Collapser() {} + + /** + * Collapses a list of Collapsible items into a list of collapsed items. Items are collapsed if + * {@link Collapsible#shouldCollapseWith(Object)} returns true, and are collapsed through the + * {@Link Collapsible#collapseWith(Object)} function implemented by the data item. + * + * @param list List of Objects of type <T extends Collapsible<T>> to be collapsed. + */ + public static <T extends Collapsible<T>> void collapseList(List<T> list, Context context) { + + int listSize = list.size(); + // The algorithm below is n^2 so don't run on long lists + if (listSize > MAX_LISTSIZE_TO_COLLAPSE) { + return; + } + + for (int i = 0; i < listSize; i++) { + T iItem = list.get(i); + if (iItem != null) { + for (int j = i + 1; j < listSize; j++) { + T jItem = list.get(j); + if (jItem != null) { + if (iItem.shouldCollapseWith(jItem, context)) { + iItem.collapseWith(jItem); + list.set(j, null); + } else if (jItem.shouldCollapseWith(iItem, context)) { + jItem.collapseWith(iItem); + list.set(i, null); + break; + } + } + } + } + } + + // Remove the null items + Iterator<T> itr = list.iterator(); + while (itr.hasNext()) { + if (itr.next() == null) { + itr.remove(); + } + } + } + + /* + * Interface implemented by data types that can be collapsed into groups of similar data. This + * can be used for example to collapse similar contact data items into a single item. + */ + public interface Collapsible<T> { + + void collapseWith(T t); + + boolean shouldCollapseWith(T t, Context context); + } +} diff --git a/java/com/android/contacts/common/ContactPhotoManager.java b/java/com/android/contacts/common/ContactPhotoManager.java new file mode 100644 index 000000000..834471047 --- /dev/null +++ b/java/com/android/contacts/common/ContactPhotoManager.java @@ -0,0 +1,487 @@ +/* + * Copyright (C) 2010 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.common; + +import android.content.ComponentCallbacks2; +import android.content.Context; +import android.content.res.Configuration; +import android.content.res.Resources; +import android.graphics.drawable.Drawable; +import android.net.Uri; +import android.net.Uri.Builder; +import android.support.annotation.VisibleForTesting; +import android.text.TextUtils; +import android.view.View; +import android.widget.ImageView; +import com.android.contacts.common.lettertiles.LetterTileDrawable; +import com.android.dialer.common.LogUtil; +import com.android.dialer.util.PermissionsUtil; + +/** Asynchronously loads contact photos and maintains a cache of photos. */ +public abstract class ContactPhotoManager implements ComponentCallbacks2 { + + /** Contact type constants used for default letter images */ + public static final int TYPE_PERSON = LetterTileDrawable.TYPE_PERSON; + + public static final int TYPE_BUSINESS = LetterTileDrawable.TYPE_BUSINESS; + public static final int TYPE_VOICEMAIL = LetterTileDrawable.TYPE_VOICEMAIL; + public static final int TYPE_DEFAULT = LetterTileDrawable.TYPE_DEFAULT; + /** Scale and offset default constants used for default letter images */ + public static final float SCALE_DEFAULT = 1.0f; + + public static final float OFFSET_DEFAULT = 0.0f; + public static final boolean IS_CIRCULAR_DEFAULT = false; + // TODO: Use LogUtil.isVerboseEnabled for DEBUG branches instead of a lint check. + // LINT.DoNotSubmitIf(true) + static final boolean DEBUG = false; + // LINT.DoNotSubmitIf(true) + static final boolean DEBUG_SIZES = false; + /** Uri-related constants used for default letter images */ + private static final String DISPLAY_NAME_PARAM_KEY = "display_name"; + + private static final String IDENTIFIER_PARAM_KEY = "identifier"; + private static final String CONTACT_TYPE_PARAM_KEY = "contact_type"; + private static final String SCALE_PARAM_KEY = "scale"; + private static final String OFFSET_PARAM_KEY = "offset"; + private static final String IS_CIRCULAR_PARAM_KEY = "is_circular"; + private static final String DEFAULT_IMAGE_URI_SCHEME = "defaultimage"; + private static final Uri DEFAULT_IMAGE_URI = Uri.parse(DEFAULT_IMAGE_URI_SCHEME + "://"); + public static final DefaultImageProvider DEFAULT_AVATAR = new LetterTileDefaultImageProvider(); + private static ContactPhotoManager sInstance; + + /** + * Given a {@link DefaultImageRequest}, returns an Uri that can be used to request a letter tile + * avatar when passed to the {@link ContactPhotoManager}. The internal implementation of this uri + * is not guaranteed to remain the same across application versions, so the actual uri should + * never be persisted in long-term storage and reused. + * + * @param request A {@link DefaultImageRequest} object with the fields configured to return a + * @return A Uri that when later passed to the {@link ContactPhotoManager} via {@link + * #loadPhoto(ImageView, Uri, int, boolean, boolean, DefaultImageRequest)}, can be used to + * request a default contact image, drawn as a letter tile using the parameters as configured + * in the provided {@link DefaultImageRequest} + */ + public static Uri getDefaultAvatarUriForContact(DefaultImageRequest request) { + final Builder builder = DEFAULT_IMAGE_URI.buildUpon(); + if (request != null) { + if (!TextUtils.isEmpty(request.displayName)) { + builder.appendQueryParameter(DISPLAY_NAME_PARAM_KEY, request.displayName); + } + if (!TextUtils.isEmpty(request.identifier)) { + builder.appendQueryParameter(IDENTIFIER_PARAM_KEY, request.identifier); + } + if (request.contactType != TYPE_DEFAULT) { + builder.appendQueryParameter(CONTACT_TYPE_PARAM_KEY, String.valueOf(request.contactType)); + } + if (request.scale != SCALE_DEFAULT) { + builder.appendQueryParameter(SCALE_PARAM_KEY, String.valueOf(request.scale)); + } + if (request.offset != OFFSET_DEFAULT) { + builder.appendQueryParameter(OFFSET_PARAM_KEY, String.valueOf(request.offset)); + } + if (request.isCircular != IS_CIRCULAR_DEFAULT) { + builder.appendQueryParameter(IS_CIRCULAR_PARAM_KEY, String.valueOf(request.isCircular)); + } + } + return builder.build(); + } + + /** + * Adds a business contact type encoded fragment to the URL. Used to ensure photo URLS from Nearby + * Places can be identified as business photo URLs rather than URLs for personal contact photos. + * + * @param photoUrl The photo URL to modify. + * @return URL with the contact type parameter added and set to TYPE_BUSINESS. + */ + public static String appendBusinessContactType(String photoUrl) { + Uri uri = Uri.parse(photoUrl); + Builder builder = uri.buildUpon(); + builder.encodedFragment(String.valueOf(TYPE_BUSINESS)); + return builder.build().toString(); + } + + /** + * Removes the contact type information stored in the photo URI encoded fragment. + * + * @param photoUri The photo URI to remove the contact type from. + * @return The photo URI with contact type removed. + */ + public static Uri removeContactType(Uri photoUri) { + String encodedFragment = photoUri.getEncodedFragment(); + if (!TextUtils.isEmpty(encodedFragment)) { + Builder builder = photoUri.buildUpon(); + builder.encodedFragment(null); + return builder.build(); + } + return photoUri; + } + + /** + * Inspects a photo URI to determine if the photo URI represents a business. + * + * @param photoUri The URI to inspect. + * @return Whether the URI represents a business photo or not. + */ + public static boolean isBusinessContactUri(Uri photoUri) { + if (photoUri == null) { + return false; + } + + String encodedFragment = photoUri.getEncodedFragment(); + return !TextUtils.isEmpty(encodedFragment) + && encodedFragment.equals(String.valueOf(TYPE_BUSINESS)); + } + + protected static DefaultImageRequest getDefaultImageRequestFromUri(Uri uri) { + final DefaultImageRequest request = + new DefaultImageRequest( + uri.getQueryParameter(DISPLAY_NAME_PARAM_KEY), + uri.getQueryParameter(IDENTIFIER_PARAM_KEY), + false); + try { + String contactType = uri.getQueryParameter(CONTACT_TYPE_PARAM_KEY); + if (!TextUtils.isEmpty(contactType)) { + request.contactType = Integer.valueOf(contactType); + } + + String scale = uri.getQueryParameter(SCALE_PARAM_KEY); + if (!TextUtils.isEmpty(scale)) { + request.scale = Float.valueOf(scale); + } + + String offset = uri.getQueryParameter(OFFSET_PARAM_KEY); + if (!TextUtils.isEmpty(offset)) { + request.offset = Float.valueOf(offset); + } + + String isCircular = uri.getQueryParameter(IS_CIRCULAR_PARAM_KEY); + if (!TextUtils.isEmpty(isCircular)) { + request.isCircular = Boolean.valueOf(isCircular); + } + } catch (NumberFormatException e) { + LogUtil.w( + "ContactPhotoManager.getDefaultImageRequestFromUri", + "Invalid DefaultImageRequest image parameters provided, ignoring and using " + + "defaults."); + } + + return request; + } + + public static ContactPhotoManager getInstance(Context context) { + if (sInstance == null) { + Context applicationContext = context.getApplicationContext(); + sInstance = createContactPhotoManager(applicationContext); + applicationContext.registerComponentCallbacks(sInstance); + if (PermissionsUtil.hasContactsPermissions(context)) { + sInstance.preloadPhotosInBackground(); + } + } + return sInstance; + } + + public static synchronized ContactPhotoManager createContactPhotoManager(Context context) { + return new ContactPhotoManagerImpl(context); + } + + @VisibleForTesting + public static void injectContactPhotoManagerForTesting(ContactPhotoManager photoManager) { + sInstance = photoManager; + } + + protected boolean isDefaultImageUri(Uri uri) { + return DEFAULT_IMAGE_URI_SCHEME.equals(uri.getScheme()); + } + + /** + * Load thumbnail image into the supplied image view. If the photo is already cached, it is + * displayed immediately. Otherwise a request is sent to load the photo from the database. + */ + public abstract void loadThumbnail( + ImageView view, + long photoId, + boolean darkTheme, + boolean isCircular, + DefaultImageRequest defaultImageRequest, + DefaultImageProvider defaultProvider); + + /** + * Calls {@link #loadThumbnail(ImageView, long, boolean, boolean, DefaultImageRequest, + * DefaultImageProvider)} using the {@link DefaultImageProvider} {@link #DEFAULT_AVATAR}. + */ + public final void loadThumbnail( + ImageView view, + long photoId, + boolean darkTheme, + boolean isCircular, + DefaultImageRequest defaultImageRequest) { + loadThumbnail(view, photoId, darkTheme, isCircular, defaultImageRequest, DEFAULT_AVATAR); + } + + /** + * Load photo into the supplied image view. If the photo is already cached, it is displayed + * immediately. Otherwise a request is sent to load the photo from the location specified by the + * URI. + * + * @param view The target view + * @param photoUri The uri of the photo to load + * @param requestedExtent Specifies an approximate Max(width, height) of the targetView. This is + * useful if the source image can be a lot bigger that the target, so that the decoding is + * done using efficient sampling. If requestedExtent is specified, no sampling of the image is + * performed + * @param darkTheme Whether the background is dark. This is used for default avatars + * @param defaultImageRequest {@link DefaultImageRequest} object that specifies how a default + * letter tile avatar should be drawn. + * @param defaultProvider The provider of default avatars (this is used if photoUri doesn't refer + * to an existing image) + */ + public abstract void loadPhoto( + ImageView view, + Uri photoUri, + int requestedExtent, + boolean darkTheme, + boolean isCircular, + DefaultImageRequest defaultImageRequest, + DefaultImageProvider defaultProvider); + + /** + * Calls {@link #loadPhoto(ImageView, Uri, int, boolean, boolean, DefaultImageRequest, + * DefaultImageProvider)} with {@link #DEFAULT_AVATAR} and {@code null} display names and lookup + * keys. + * + * @param defaultImageRequest {@link DefaultImageRequest} object that specifies how a default + * letter tile avatar should be drawn. + */ + public final void loadPhoto( + ImageView view, + Uri photoUri, + int requestedExtent, + boolean darkTheme, + boolean isCircular, + DefaultImageRequest defaultImageRequest) { + loadPhoto( + view, + photoUri, + requestedExtent, + darkTheme, + isCircular, + defaultImageRequest, + DEFAULT_AVATAR); + } + + /** + * Calls {@link #loadPhoto(ImageView, Uri, int, boolean, boolean, DefaultImageRequest, + * DefaultImageProvider)} with {@link #DEFAULT_AVATAR} and with the assumption, that the image is + * a thumbnail. + * + * @param defaultImageRequest {@link DefaultImageRequest} object that specifies how a default + * letter tile avatar should be drawn. + */ + public final void loadDirectoryPhoto( + ImageView view, + Uri photoUri, + boolean darkTheme, + boolean isCircular, + DefaultImageRequest defaultImageRequest) { + loadPhoto(view, photoUri, -1, darkTheme, isCircular, defaultImageRequest, DEFAULT_AVATAR); + } + + /** + * Remove photo from the supplied image view. This also cancels current pending load request + * inside this photo manager. + */ + public abstract void removePhoto(ImageView view); + + /** Cancels all pending requests to load photos asynchronously. */ + public abstract void cancelPendingRequests(View fragmentRootView); + + /** Temporarily stops loading photos from the database. */ + public abstract void pause(); + + /** Resumes loading photos from the database. */ + public abstract void resume(); + + /** + * Marks all cached photos for reloading. We can continue using cache but should also make sure + * the photos haven't changed in the background and notify the views if so. + */ + public abstract void refreshCache(); + + /** Initiates a background process that over time will fill up cache with preload photos. */ + public abstract void preloadPhotosInBackground(); + + // ComponentCallbacks2 + @Override + public void onConfigurationChanged(Configuration newConfig) {} + + // ComponentCallbacks2 + @Override + public void onLowMemory() {} + + // ComponentCallbacks2 + @Override + public void onTrimMemory(int level) {} + + /** + * Contains fields used to contain contact details and other user-defined settings that might be + * used by the ContactPhotoManager to generate a default contact image. This contact image takes + * the form of a letter or bitmap drawn on top of a colored tile. + */ + public static class DefaultImageRequest { + + /** + * Used to indicate that a drawable that represents a contact without any contact details should + * be returned. + */ + public static final DefaultImageRequest EMPTY_DEFAULT_IMAGE_REQUEST = new DefaultImageRequest(); + /** + * Used to indicate that a drawable that represents a business without a business photo should + * be returned. + */ + public static final DefaultImageRequest EMPTY_DEFAULT_BUSINESS_IMAGE_REQUEST = + new DefaultImageRequest(null, null, TYPE_BUSINESS, false); + /** + * Used to indicate that a circular drawable that represents a contact without any contact + * details should be returned. + */ + public static final DefaultImageRequest EMPTY_CIRCULAR_DEFAULT_IMAGE_REQUEST = + new DefaultImageRequest(null, null, true); + /** + * Used to indicate that a circular drawable that represents a business without a business photo + * should be returned. + */ + public static final DefaultImageRequest EMPTY_CIRCULAR_BUSINESS_IMAGE_REQUEST = + new DefaultImageRequest(null, null, TYPE_BUSINESS, true); + /** The contact's display name. The display name is used to */ + public String displayName; + /** + * A unique and deterministic string that can be used to identify this contact. This is usually + * the contact's lookup key, but other contact details can be used as well, especially for + * non-local or temporary contacts that might not have a lookup key. This is used to determine + * the color of the tile. + */ + public String identifier; + /** + * The type of this contact. This contact type may be used to decide the kind of image to use in + * the case where a unique letter cannot be generated from the contact's display name and + * identifier. See: {@link #TYPE_PERSON} {@link #TYPE_BUSINESS} {@link #TYPE_PERSON} {@link + * #TYPE_DEFAULT} + */ + public int contactType = TYPE_DEFAULT; + /** + * The amount to scale the letter or bitmap to, as a ratio of its default size (from a range of + * 0.0f to 2.0f). The default value is 1.0f. + */ + public float scale = SCALE_DEFAULT; + /** + * The amount to vertically offset the letter or image to within the tile. The provided offset + * must be within the range of -0.5f to 0.5f. If set to -0.5f, the letter will be shifted + * upwards by 0.5 times the height of the canvas it is being drawn on, which means it will be + * drawn with the center of the letter starting at the top edge of the canvas. If set to 0.5f, + * the letter will be shifted downwards by 0.5 times the height of the canvas it is being drawn + * on, which means it will be drawn with the center of the letter starting at the bottom edge of + * the canvas. The default is 0.0f, which means the letter is drawn in the exact vertical center + * of the tile. + */ + public float offset = OFFSET_DEFAULT; + /** Whether or not to draw the default image as a circle, instead of as a square/rectangle. */ + public boolean isCircular = false; + + public DefaultImageRequest() {} + + public DefaultImageRequest(String displayName, String identifier, boolean isCircular) { + this(displayName, identifier, TYPE_DEFAULT, SCALE_DEFAULT, OFFSET_DEFAULT, isCircular); + } + + public DefaultImageRequest( + String displayName, String identifier, int contactType, boolean isCircular) { + this(displayName, identifier, contactType, SCALE_DEFAULT, OFFSET_DEFAULT, isCircular); + } + + public DefaultImageRequest( + String displayName, + String identifier, + int contactType, + float scale, + float offset, + boolean isCircular) { + this.displayName = displayName; + this.identifier = identifier; + this.contactType = contactType; + this.scale = scale; + this.offset = offset; + this.isCircular = isCircular; + } + } + + public abstract static class DefaultImageProvider { + + /** + * Applies the default avatar to the ImageView. Extent is an indicator for the size (width or + * height). If darkTheme is set, the avatar is one that looks better on dark background + * + * @param defaultImageRequest {@link DefaultImageRequest} object that specifies how a default + * letter tile avatar should be drawn. + */ + public abstract void applyDefaultImage( + ImageView view, int extent, boolean darkTheme, DefaultImageRequest defaultImageRequest); + } + + /** + * A default image provider that applies a letter tile consisting of a colored background and a + * letter in the foreground as the default image for a contact. The color of the background and + * the type of letter is decided based on the contact's details. + */ + private static class LetterTileDefaultImageProvider extends DefaultImageProvider { + + public static Drawable getDefaultImageForContact( + Resources resources, DefaultImageRequest defaultImageRequest) { + final LetterTileDrawable drawable = new LetterTileDrawable(resources); + final int tileShape = + defaultImageRequest.isCircular + ? LetterTileDrawable.SHAPE_CIRCLE + : LetterTileDrawable.SHAPE_RECTANGLE; + if (defaultImageRequest != null) { + // If the contact identifier is null or empty, fallback to the + // displayName. In that case, use {@code null} for the contact's + // display name so that a default bitmap will be used instead of a + // letter + if (TextUtils.isEmpty(defaultImageRequest.identifier)) { + drawable.setCanonicalDialerLetterTileDetails( + null, defaultImageRequest.displayName, tileShape, defaultImageRequest.contactType); + } else { + drawable.setCanonicalDialerLetterTileDetails( + defaultImageRequest.displayName, + defaultImageRequest.identifier, + tileShape, + defaultImageRequest.contactType); + } + drawable.setScale(defaultImageRequest.scale); + drawable.setOffset(defaultImageRequest.offset); + } + return drawable; + } + + @Override + public void applyDefaultImage( + ImageView view, int extent, boolean darkTheme, DefaultImageRequest defaultImageRequest) { + final Drawable drawable = getDefaultImageForContact(view.getResources(), defaultImageRequest); + view.setImageDrawable(drawable); + } + } +} + diff --git a/java/com/android/contacts/common/ContactPhotoManagerImpl.java b/java/com/android/contacts/common/ContactPhotoManagerImpl.java new file mode 100644 index 000000000..2e6ff9fdc --- /dev/null +++ b/java/com/android/contacts/common/ContactPhotoManagerImpl.java @@ -0,0 +1,1262 @@ +/* + * 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.common; + +import android.app.ActivityManager; +import android.content.ComponentCallbacks2; +import android.content.ContentResolver; +import android.content.ContentUris; +import android.content.Context; +import android.content.res.Resources; +import android.database.Cursor; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.Paint.Style; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.TransitionDrawable; +import android.media.ThumbnailUtils; +import android.net.TrafficStats; +import android.net.Uri; +import android.os.Handler; +import android.os.Handler.Callback; +import android.os.HandlerThread; +import android.os.Message; +import android.provider.ContactsContract; +import android.provider.ContactsContract.Contacts; +import android.provider.ContactsContract.Contacts.Photo; +import android.provider.ContactsContract.Data; +import android.provider.ContactsContract.Directory; +import android.support.annotation.UiThread; +import android.support.annotation.WorkerThread; +import android.support.v4.graphics.drawable.RoundedBitmapDrawable; +import android.support.v4.graphics.drawable.RoundedBitmapDrawableFactory; +import android.text.TextUtils; +import android.util.LruCache; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import com.android.contacts.common.util.BitmapUtil; +import com.android.contacts.common.util.TrafficStatsTags; +import com.android.contacts.common.util.UriUtils; +import com.android.dialer.common.LogUtil; +import com.android.dialer.util.PermissionsUtil; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.lang.ref.Reference; +import java.lang.ref.SoftReference; +import java.net.HttpURLConnection; +import java.net.URL; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.Map.Entry; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; + +class ContactPhotoManagerImpl extends ContactPhotoManager implements Callback { + + private static final String LOADER_THREAD_NAME = "ContactPhotoLoader"; + + private static final int FADE_TRANSITION_DURATION = 200; + + /** + * Type of message sent by the UI thread to itself to indicate that some photos need to be loaded. + */ + private static final int MESSAGE_REQUEST_LOADING = 1; + + /** Type of message sent by the loader thread to indicate that some photos have been loaded. */ + private static final int MESSAGE_PHOTOS_LOADED = 2; + + private static final String[] EMPTY_STRING_ARRAY = new String[0]; + + private static final String[] COLUMNS = new String[] {Photo._ID, Photo.PHOTO}; + + /** + * Dummy object used to indicate that a bitmap for a given key could not be stored in the cache. + */ + private static final BitmapHolder BITMAP_UNAVAILABLE; + /** Cache size for {@link #mBitmapHolderCache} for devices with "large" RAM. */ + private static final int HOLDER_CACHE_SIZE = 2000000; + /** Cache size for {@link #mBitmapCache} for devices with "large" RAM. */ + private static final int BITMAP_CACHE_SIZE = 36864 * 48; // 1728K + /** Height/width of a thumbnail image */ + private static int mThumbnailSize; + + static { + BITMAP_UNAVAILABLE = new BitmapHolder(new byte[0], 0); + BITMAP_UNAVAILABLE.bitmapRef = new SoftReference<Bitmap>(null); + } + + private final Context mContext; + /** + * An LRU cache for bitmap holders. The cache contains bytes for photos just as they come from the + * database. Each holder has a soft reference to the actual bitmap. + */ + private final LruCache<Object, BitmapHolder> mBitmapHolderCache; + /** Cache size threshold at which bitmaps will not be preloaded. */ + private final int mBitmapHolderCacheRedZoneBytes; + /** + * Level 2 LRU cache for bitmaps. This is a smaller cache that holds the most recently used + * bitmaps to save time on decoding them from bytes (the bytes are stored in {@link + * #mBitmapHolderCache}. + */ + private final LruCache<Object, Bitmap> mBitmapCache; + /** + * A map from ImageView to the corresponding photo ID or uri, encapsulated in a request. The + * request may swapped out before the photo loading request is started. + */ + private final ConcurrentHashMap<ImageView, Request> mPendingRequests = + new ConcurrentHashMap<ImageView, Request>(); + /** Handler for messages sent to the UI thread. */ + private final Handler mMainThreadHandler = new Handler(this); + /** For debug: How many times we had to reload cached photo for a stale entry */ + private final AtomicInteger mStaleCacheOverwrite = new AtomicInteger(); + /** For debug: How many times we had to reload cached photo for a fresh entry. Should be 0. */ + private final AtomicInteger mFreshCacheOverwrite = new AtomicInteger(); + /** {@code true} if ALL entries in {@link #mBitmapHolderCache} are NOT fresh. */ + private volatile boolean mBitmapHolderCacheAllUnfresh = true; + /** Thread responsible for loading photos from the database. Created upon the first request. */ + private LoaderThread mLoaderThread; + /** A gate to make sure we only send one instance of MESSAGE_PHOTOS_NEEDED at a time. */ + private boolean mLoadingRequested; + /** Flag indicating if the image loading is paused. */ + private boolean mPaused; + /** The user agent string to use when loading URI based photos. */ + private String mUserAgent; + + public ContactPhotoManagerImpl(Context context) { + mContext = context; + + final ActivityManager am = + ((ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE)); + + final float cacheSizeAdjustment = (am.isLowRamDevice()) ? 0.5f : 1.0f; + + final int bitmapCacheSize = (int) (cacheSizeAdjustment * BITMAP_CACHE_SIZE); + mBitmapCache = + new LruCache<Object, Bitmap>(bitmapCacheSize) { + @Override + protected int sizeOf(Object key, Bitmap value) { + return value.getByteCount(); + } + + @Override + protected void entryRemoved( + boolean evicted, Object key, Bitmap oldValue, Bitmap newValue) { + if (DEBUG) { + dumpStats(); + } + } + }; + final int holderCacheSize = (int) (cacheSizeAdjustment * HOLDER_CACHE_SIZE); + mBitmapHolderCache = + new LruCache<Object, BitmapHolder>(holderCacheSize) { + @Override + protected int sizeOf(Object key, BitmapHolder value) { + return value.bytes != null ? value.bytes.length : 0; + } + + @Override + protected void entryRemoved( + boolean evicted, Object key, BitmapHolder oldValue, BitmapHolder newValue) { + if (DEBUG) { + dumpStats(); + } + } + }; + mBitmapHolderCacheRedZoneBytes = (int) (holderCacheSize * 0.75); + LogUtil.i( + "ContactPhotoManagerImpl.ContactPhotoManagerImpl", "cache adj: " + cacheSizeAdjustment); + if (DEBUG) { + LogUtil.d( + "ContactPhotoManagerImpl.ContactPhotoManagerImpl", + "Cache size: " + btk(mBitmapHolderCache.maxSize()) + " + " + btk(mBitmapCache.maxSize())); + } + + mThumbnailSize = + context.getResources().getDimensionPixelSize(R.dimen.contact_browser_list_item_photo_size); + + // Get a user agent string to use for URI photo requests. + mUserAgent = Bindings.get(context).getUserAgent(); + if (mUserAgent == null) { + mUserAgent = ""; + } + } + + /** Converts bytes to K bytes, rounding up. Used only for debug log. */ + private static String btk(int bytes) { + return ((bytes + 1023) / 1024) + "K"; + } + + private static final int safeDiv(int dividend, int divisor) { + return (divisor == 0) ? 0 : (dividend / divisor); + } + + private static boolean isChildView(View parent, View potentialChild) { + return potentialChild.getParent() != null + && (potentialChild.getParent() == parent + || (potentialChild.getParent() instanceof ViewGroup + && isChildView(parent, (ViewGroup) potentialChild.getParent()))); + } + + /** + * If necessary, decodes bytes stored in the holder to Bitmap. As long as the bitmap is held + * either by {@link #mBitmapCache} or by a soft reference in the holder, it will not be necessary + * to decode the bitmap. + */ + private static void inflateBitmap(BitmapHolder holder, int requestedExtent) { + final int sampleSize = + BitmapUtil.findOptimalSampleSize(holder.originalSmallerExtent, requestedExtent); + byte[] bytes = holder.bytes; + if (bytes == null || bytes.length == 0) { + return; + } + + if (sampleSize == holder.decodedSampleSize) { + // Check the soft reference. If will be retained if the bitmap is also + // in the LRU cache, so we don't need to check the LRU cache explicitly. + if (holder.bitmapRef != null) { + holder.bitmap = holder.bitmapRef.get(); + if (holder.bitmap != null) { + return; + } + } + } + + try { + Bitmap bitmap = BitmapUtil.decodeBitmapFromBytes(bytes, sampleSize); + + // TODO: As a temporary workaround while framework support is being added to + // clip non-square bitmaps into a perfect circle, manually crop the bitmap into + // into a square if it will be displayed as a thumbnail so that it can be cropped + // into a circle. + final int height = bitmap.getHeight(); + final int width = bitmap.getWidth(); + + // The smaller dimension of a scaled bitmap can range from anywhere from 0 to just + // below twice the length of a thumbnail image due to the way we calculate the optimal + // sample size. + if (height != width && Math.min(height, width) <= mThumbnailSize * 2) { + final int dimension = Math.min(height, width); + bitmap = ThumbnailUtils.extractThumbnail(bitmap, dimension, dimension); + } + // make bitmap mutable and draw size onto it + if (DEBUG_SIZES) { + Bitmap original = bitmap; + bitmap = bitmap.copy(bitmap.getConfig(), true); + original.recycle(); + Canvas canvas = new Canvas(bitmap); + Paint paint = new Paint(); + paint.setTextSize(16); + paint.setColor(Color.BLUE); + paint.setStyle(Style.FILL); + canvas.drawRect(0.0f, 0.0f, 50.0f, 20.0f, paint); + paint.setColor(Color.WHITE); + paint.setAntiAlias(true); + canvas.drawText(bitmap.getWidth() + "/" + sampleSize, 0, 15, paint); + } + + holder.decodedSampleSize = sampleSize; + holder.bitmap = bitmap; + holder.bitmapRef = new SoftReference<Bitmap>(bitmap); + if (DEBUG) { + LogUtil.d( + "ContactPhotoManagerImpl.inflateBitmap", + "inflateBitmap " + + btk(bytes.length) + + " -> " + + bitmap.getWidth() + + "x" + + bitmap.getHeight() + + ", " + + btk(bitmap.getByteCount())); + } + } catch (OutOfMemoryError e) { + // Do nothing - the photo will appear to be missing + } + } + + /** Dump cache stats on logcat. */ + private void dumpStats() { + if (!DEBUG) { + return; + } + { + int numHolders = 0; + int rawBytes = 0; + int bitmapBytes = 0; + int numBitmaps = 0; + for (BitmapHolder h : mBitmapHolderCache.snapshot().values()) { + numHolders++; + if (h.bytes != null) { + rawBytes += h.bytes.length; + } + Bitmap b = h.bitmapRef != null ? h.bitmapRef.get() : null; + if (b != null) { + numBitmaps++; + bitmapBytes += b.getByteCount(); + } + } + LogUtil.d( + "ContactPhotoManagerImpl.dumpStats", + "L1: " + + btk(rawBytes) + + " + " + + btk(bitmapBytes) + + " = " + + btk(rawBytes + bitmapBytes) + + ", " + + numHolders + + " holders, " + + numBitmaps + + " bitmaps, avg: " + + btk(safeDiv(rawBytes, numHolders)) + + "," + + btk(safeDiv(bitmapBytes, numBitmaps))); + LogUtil.d( + "ContactPhotoManagerImpl.dumpStats", + "L1 Stats: " + + mBitmapHolderCache.toString() + + ", overwrite: fresh=" + + mFreshCacheOverwrite.get() + + " stale=" + + mStaleCacheOverwrite.get()); + } + + { + int numBitmaps = 0; + int bitmapBytes = 0; + for (Bitmap b : mBitmapCache.snapshot().values()) { + numBitmaps++; + bitmapBytes += b.getByteCount(); + } + LogUtil.d( + "ContactPhotoManagerImpl.dumpStats", + "L2: " + + btk(bitmapBytes) + + ", " + + numBitmaps + + " bitmaps" + + ", avg: " + + btk(safeDiv(bitmapBytes, numBitmaps))); + // We don't get from L2 cache, so L2 stats is meaningless. + } + } + + @Override + public void onTrimMemory(int level) { + if (DEBUG) { + LogUtil.d("ContactPhotoManagerImpl.onTrimMemory", "onTrimMemory: " + level); + } + if (level >= ComponentCallbacks2.TRIM_MEMORY_MODERATE) { + // Clear the caches. Note all pending requests will be removed too. + clear(); + } + } + + @Override + public void preloadPhotosInBackground() { + ensureLoaderThread(); + mLoaderThread.requestPreloading(); + } + + @Override + public void loadThumbnail( + ImageView view, + long photoId, + boolean darkTheme, + boolean isCircular, + DefaultImageRequest defaultImageRequest, + DefaultImageProvider defaultProvider) { + if (photoId == 0) { + // No photo is needed + defaultProvider.applyDefaultImage(view, -1, darkTheme, defaultImageRequest); + mPendingRequests.remove(view); + } else { + if (DEBUG) { + LogUtil.d("ContactPhotoManagerImpl.loadThumbnail", "loadPhoto request: " + photoId); + } + loadPhotoByIdOrUri( + view, Request.createFromThumbnailId(photoId, darkTheme, isCircular, defaultProvider)); + } + } + + @Override + public void loadPhoto( + ImageView view, + Uri photoUri, + int requestedExtent, + boolean darkTheme, + boolean isCircular, + DefaultImageRequest defaultImageRequest, + DefaultImageProvider defaultProvider) { + if (photoUri == null) { + // No photo is needed + defaultProvider.applyDefaultImage(view, requestedExtent, darkTheme, defaultImageRequest); + mPendingRequests.remove(view); + } else { + if (DEBUG) { + LogUtil.d("ContactPhotoManagerImpl.loadPhoto", "loadPhoto request: " + photoUri); + } + if (isDefaultImageUri(photoUri)) { + createAndApplyDefaultImageForUri( + view, photoUri, requestedExtent, darkTheme, isCircular, defaultProvider); + } else { + loadPhotoByIdOrUri( + view, + Request.createFromUri( + photoUri, requestedExtent, darkTheme, isCircular, defaultProvider)); + } + } + } + + private void createAndApplyDefaultImageForUri( + ImageView view, + Uri uri, + int requestedExtent, + boolean darkTheme, + boolean isCircular, + DefaultImageProvider defaultProvider) { + DefaultImageRequest request = getDefaultImageRequestFromUri(uri); + request.isCircular = isCircular; + defaultProvider.applyDefaultImage(view, requestedExtent, darkTheme, request); + } + + private void loadPhotoByIdOrUri(ImageView view, Request request) { + boolean loaded = loadCachedPhoto(view, request, false); + if (loaded) { + mPendingRequests.remove(view); + } else { + mPendingRequests.put(view, request); + if (!mPaused) { + // Send a request to start loading photos + requestLoading(); + } + } + } + + @Override + public void removePhoto(ImageView view) { + view.setImageDrawable(null); + mPendingRequests.remove(view); + } + + /** + * Cancels pending requests to load photos asynchronously for views inside {@param + * fragmentRootView}. If {@param fragmentRootView} is null, cancels all requests. + */ + @Override + public void cancelPendingRequests(View fragmentRootView) { + if (fragmentRootView == null) { + mPendingRequests.clear(); + return; + } + final Iterator<Entry<ImageView, Request>> iterator = mPendingRequests.entrySet().iterator(); + while (iterator.hasNext()) { + final ImageView imageView = iterator.next().getKey(); + // If an ImageView is orphaned (currently scrap) or a child of fragmentRootView, then + // we can safely remove its request. + if (imageView.getParent() == null || isChildView(fragmentRootView, imageView)) { + iterator.remove(); + } + } + } + + @Override + public void refreshCache() { + if (mBitmapHolderCacheAllUnfresh) { + if (DEBUG) { + LogUtil.d("ContactPhotoManagerImpl.refreshCache", "refreshCache -- no fresh entries."); + } + return; + } + if (DEBUG) { + LogUtil.d("ContactPhotoManagerImpl.refreshCache", "refreshCache"); + } + mBitmapHolderCacheAllUnfresh = true; + for (BitmapHolder holder : mBitmapHolderCache.snapshot().values()) { + if (holder != BITMAP_UNAVAILABLE) { + holder.fresh = false; + } + } + } + + /** + * Checks if the photo is present in cache. If so, sets the photo on the view. + * + * @return false if the photo needs to be (re)loaded from the provider. + */ + @UiThread + private boolean loadCachedPhoto(ImageView view, Request request, boolean fadeIn) { + BitmapHolder holder = mBitmapHolderCache.get(request.getKey()); + if (holder == null) { + // The bitmap has not been loaded ==> show default avatar + request.applyDefaultImage(view, request.mIsCircular); + return false; + } + + if (holder.bytes == null) { + request.applyDefaultImage(view, request.mIsCircular); + return holder.fresh; + } + + Bitmap cachedBitmap = holder.bitmapRef == null ? null : holder.bitmapRef.get(); + if (cachedBitmap == null) { + request.applyDefaultImage(view, request.mIsCircular); + return false; + } + + final Drawable previousDrawable = view.getDrawable(); + if (fadeIn && previousDrawable != null) { + final Drawable[] layers = new Drawable[2]; + // Prevent cascade of TransitionDrawables. + if (previousDrawable instanceof TransitionDrawable) { + final TransitionDrawable previousTransitionDrawable = (TransitionDrawable) previousDrawable; + layers[0] = + previousTransitionDrawable.getDrawable( + previousTransitionDrawable.getNumberOfLayers() - 1); + } else { + layers[0] = previousDrawable; + } + layers[1] = getDrawableForBitmap(mContext.getResources(), cachedBitmap, request); + TransitionDrawable drawable = new TransitionDrawable(layers); + view.setImageDrawable(drawable); + drawable.startTransition(FADE_TRANSITION_DURATION); + } else { + view.setImageDrawable(getDrawableForBitmap(mContext.getResources(), cachedBitmap, request)); + } + + // Put the bitmap in the LRU cache. But only do this for images that are small enough + // (we require that at least six of those can be cached at the same time) + if (cachedBitmap.getByteCount() < mBitmapCache.maxSize() / 6) { + mBitmapCache.put(request.getKey(), cachedBitmap); + } + + // Soften the reference + holder.bitmap = null; + + return holder.fresh; + } + + /** + * Given a bitmap, returns a drawable that is configured to display the bitmap based on the + * specified request. + */ + private Drawable getDrawableForBitmap(Resources resources, Bitmap bitmap, Request request) { + if (request.mIsCircular) { + final RoundedBitmapDrawable drawable = RoundedBitmapDrawableFactory.create(resources, bitmap); + drawable.setAntiAlias(true); + drawable.setCornerRadius(bitmap.getHeight() / 2); + return drawable; + } else { + return new BitmapDrawable(resources, bitmap); + } + } + + public void clear() { + if (DEBUG) { + LogUtil.d("ContactPhotoManagerImpl.clear", "clear"); + } + mPendingRequests.clear(); + mBitmapHolderCache.evictAll(); + mBitmapCache.evictAll(); + } + + @Override + public void pause() { + mPaused = true; + } + + @Override + public void resume() { + mPaused = false; + if (DEBUG) { + dumpStats(); + } + if (!mPendingRequests.isEmpty()) { + requestLoading(); + } + } + + /** + * Sends a message to this thread itself to start loading images. If the current view contains + * multiple image views, all of those image views will get a chance to request their respective + * photos before any of those requests are executed. This allows us to load images in bulk. + */ + private void requestLoading() { + if (!mLoadingRequested) { + mLoadingRequested = true; + mMainThreadHandler.sendEmptyMessage(MESSAGE_REQUEST_LOADING); + } + } + + /** Processes requests on the main thread. */ + @Override + public boolean handleMessage(Message msg) { + switch (msg.what) { + case MESSAGE_REQUEST_LOADING: + { + mLoadingRequested = false; + if (!mPaused) { + ensureLoaderThread(); + mLoaderThread.requestLoading(); + } + return true; + } + + case MESSAGE_PHOTOS_LOADED: + { + if (!mPaused) { + processLoadedImages(); + } + if (DEBUG) { + dumpStats(); + } + return true; + } + } + return false; + } + + public void ensureLoaderThread() { + if (mLoaderThread == null) { + mLoaderThread = new LoaderThread(mContext.getContentResolver()); + mLoaderThread.start(); + } + } + + /** + * Goes over pending loading requests and displays loaded photos. If some of the photos still + * haven't been loaded, sends another request for image loading. + */ + private void processLoadedImages() { + final Iterator<Entry<ImageView, Request>> iterator = mPendingRequests.entrySet().iterator(); + while (iterator.hasNext()) { + final Entry<ImageView, Request> entry = iterator.next(); + // TODO: Temporarily disable contact photo fading in, until issues with + // RoundedBitmapDrawables overlapping the default image drawables are resolved. + final boolean loaded = loadCachedPhoto(entry.getKey(), entry.getValue(), false); + if (loaded) { + iterator.remove(); + } + } + + softenCache(); + + if (!mPendingRequests.isEmpty()) { + requestLoading(); + } + } + + /** + * Removes strong references to loaded bitmaps to allow them to be garbage collected if needed. + * Some of the bitmaps will still be retained by {@link #mBitmapCache}. + */ + private void softenCache() { + for (BitmapHolder holder : mBitmapHolderCache.snapshot().values()) { + holder.bitmap = null; + } + } + + /** Stores the supplied bitmap in cache. */ + private void cacheBitmap(Object key, byte[] bytes, boolean preloading, int requestedExtent) { + if (DEBUG) { + BitmapHolder prev = mBitmapHolderCache.get(key); + if (prev != null && prev.bytes != null) { + LogUtil.d( + "ContactPhotoManagerImpl.cacheBitmap", + "overwriting cache: key=" + key + (prev.fresh ? " FRESH" : " stale")); + if (prev.fresh) { + mFreshCacheOverwrite.incrementAndGet(); + } else { + mStaleCacheOverwrite.incrementAndGet(); + } + } + LogUtil.d( + "ContactPhotoManagerImpl.cacheBitmap", + "caching data: key=" + key + ", " + (bytes == null ? "<null>" : btk(bytes.length))); + } + BitmapHolder holder = + new BitmapHolder(bytes, bytes == null ? -1 : BitmapUtil.getSmallerExtentFromBytes(bytes)); + + // Unless this image is being preloaded, decode it right away while + // we are still on the background thread. + if (!preloading) { + inflateBitmap(holder, requestedExtent); + } + + if (bytes != null) { + mBitmapHolderCache.put(key, holder); + if (mBitmapHolderCache.get(key) != holder) { + LogUtil.w("ContactPhotoManagerImpl.cacheBitmap", "bitmap too big to fit in cache."); + mBitmapHolderCache.put(key, BITMAP_UNAVAILABLE); + } + } else { + mBitmapHolderCache.put(key, BITMAP_UNAVAILABLE); + } + + mBitmapHolderCacheAllUnfresh = false; + } + + /** + * Populates an array of photo IDs that need to be loaded. Also decodes bitmaps that we have + * already loaded + */ + private void obtainPhotoIdsAndUrisToLoad( + Set<Long> photoIds, Set<String> photoIdsAsStrings, Set<Request> uris) { + photoIds.clear(); + photoIdsAsStrings.clear(); + uris.clear(); + + boolean jpegsDecoded = false; + + /* + * Since the call is made from the loader thread, the map could be + * changing during the iteration. That's not really a problem: + * ConcurrentHashMap will allow those changes to happen without throwing + * exceptions. Since we may miss some requests in the situation of + * concurrent change, we will need to check the map again once loading + * is complete. + */ + Iterator<Request> iterator = mPendingRequests.values().iterator(); + while (iterator.hasNext()) { + Request request = iterator.next(); + final BitmapHolder holder = mBitmapHolderCache.get(request.getKey()); + if (holder == BITMAP_UNAVAILABLE) { + continue; + } + if (holder != null + && holder.bytes != null + && holder.fresh + && (holder.bitmapRef == null || holder.bitmapRef.get() == null)) { + // This was previously loaded but we don't currently have the inflated Bitmap + inflateBitmap(holder, request.getRequestedExtent()); + jpegsDecoded = true; + } else { + if (holder == null || !holder.fresh) { + if (request.isUriRequest()) { + uris.add(request); + } else { + photoIds.add(request.getId()); + photoIdsAsStrings.add(String.valueOf(request.mId)); + } + } + } + } + + if (jpegsDecoded) { + mMainThreadHandler.sendEmptyMessage(MESSAGE_PHOTOS_LOADED); + } + } + + /** Maintains the state of a particular photo. */ + private static class BitmapHolder { + + final byte[] bytes; + final int originalSmallerExtent; + + volatile boolean fresh; + Bitmap bitmap; + Reference<Bitmap> bitmapRef; + int decodedSampleSize; + + public BitmapHolder(byte[] bytes, int originalSmallerExtent) { + this.bytes = bytes; + this.fresh = true; + this.originalSmallerExtent = originalSmallerExtent; + } + } + + /** + * A holder for either a Uri or an id and a flag whether this was requested for the dark or light + * theme + */ + private static final class Request { + + private final long mId; + private final Uri mUri; + private final boolean mDarkTheme; + private final int mRequestedExtent; + private final DefaultImageProvider mDefaultProvider; + /** Whether or not the contact photo is to be displayed as a circle */ + private final boolean mIsCircular; + + private Request( + long id, + Uri uri, + int requestedExtent, + boolean darkTheme, + boolean isCircular, + DefaultImageProvider defaultProvider) { + mId = id; + mUri = uri; + mDarkTheme = darkTheme; + mIsCircular = isCircular; + mRequestedExtent = requestedExtent; + mDefaultProvider = defaultProvider; + } + + public static Request createFromThumbnailId( + long id, boolean darkTheme, boolean isCircular, DefaultImageProvider defaultProvider) { + return new Request(id, null /* no URI */, -1, darkTheme, isCircular, defaultProvider); + } + + public static Request createFromUri( + Uri uri, + int requestedExtent, + boolean darkTheme, + boolean isCircular, + DefaultImageProvider defaultProvider) { + return new Request( + 0 /* no ID */, uri, requestedExtent, darkTheme, isCircular, defaultProvider); + } + + public boolean isUriRequest() { + return mUri != null; + } + + public Uri getUri() { + return mUri; + } + + public long getId() { + return mId; + } + + public int getRequestedExtent() { + return mRequestedExtent; + } + + @Override + public int hashCode() { + final int prime = 31; + int result = 1; + result = prime * result + (int) (mId ^ (mId >>> 32)); + result = prime * result + mRequestedExtent; + result = prime * result + ((mUri == null) ? 0 : mUri.hashCode()); + return result; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (getClass() != obj.getClass()) { + return false; + } + final Request that = (Request) obj; + if (mId != that.mId) { + return false; + } + if (mRequestedExtent != that.mRequestedExtent) { + return false; + } + if (!UriUtils.areEqual(mUri, that.mUri)) { + return false; + } + // Don't compare equality of mDarkTheme because it is only used in the default contact + // photo case. When the contact does have a photo, the contact photo is the same + // regardless of mDarkTheme, so we shouldn't need to put the photo request on the queue + // twice. + return true; + } + + public Object getKey() { + return mUri == null ? mId : mUri; + } + + /** + * Applies the default image to the current view. If the request is URI-based, looks for the + * contact type encoded fragment to determine if this is a request for a business photo, in + * which case we will load the default business photo. + * + * @param view The current image view to apply the image to. + * @param isCircular Whether the image is circular or not. + */ + public void applyDefaultImage(ImageView view, boolean isCircular) { + final DefaultImageRequest request; + + if (isCircular) { + request = + ContactPhotoManager.isBusinessContactUri(mUri) + ? DefaultImageRequest.EMPTY_CIRCULAR_BUSINESS_IMAGE_REQUEST + : DefaultImageRequest.EMPTY_CIRCULAR_DEFAULT_IMAGE_REQUEST; + } else { + request = + ContactPhotoManager.isBusinessContactUri(mUri) + ? DefaultImageRequest.EMPTY_DEFAULT_BUSINESS_IMAGE_REQUEST + : DefaultImageRequest.EMPTY_DEFAULT_IMAGE_REQUEST; + } + mDefaultProvider.applyDefaultImage(view, mRequestedExtent, mDarkTheme, request); + } + } + + /** The thread that performs loading of photos from the database. */ + private class LoaderThread extends HandlerThread implements Callback { + + private static final int BUFFER_SIZE = 1024 * 16; + private static final int MESSAGE_PRELOAD_PHOTOS = 0; + private static final int MESSAGE_LOAD_PHOTOS = 1; + + /** A pause between preload batches that yields to the UI thread. */ + private static final int PHOTO_PRELOAD_DELAY = 1000; + + /** Number of photos to preload per batch. */ + private static final int PRELOAD_BATCH = 25; + + /** + * Maximum number of photos to preload. If the cache size is 2Mb and the expected average size + * of a photo is 4kb, then this number should be 2Mb/4kb = 500. + */ + private static final int MAX_PHOTOS_TO_PRELOAD = 100; + + private static final int PRELOAD_STATUS_NOT_STARTED = 0; + private static final int PRELOAD_STATUS_IN_PROGRESS = 1; + private static final int PRELOAD_STATUS_DONE = 2; + private final ContentResolver mResolver; + private final StringBuilder mStringBuilder = new StringBuilder(); + private final Set<Long> mPhotoIds = new HashSet<>(); + private final Set<String> mPhotoIdsAsStrings = new HashSet<>(); + private final Set<Request> mPhotoUris = new HashSet<>(); + private final List<Long> mPreloadPhotoIds = new ArrayList<>(); + private Handler mLoaderThreadHandler; + private byte[] mBuffer; + private int mPreloadStatus = PRELOAD_STATUS_NOT_STARTED; + + public LoaderThread(ContentResolver resolver) { + super(LOADER_THREAD_NAME); + mResolver = resolver; + } + + public void ensureHandler() { + if (mLoaderThreadHandler == null) { + mLoaderThreadHandler = new Handler(getLooper(), this); + } + } + + /** + * Kicks off preloading of the next batch of photos on the background thread. Preloading will + * happen after a delay: we want to yield to the UI thread as much as possible. + * + * <p>If preloading is already complete, does nothing. + */ + public void requestPreloading() { + if (mPreloadStatus == PRELOAD_STATUS_DONE) { + return; + } + + ensureHandler(); + if (mLoaderThreadHandler.hasMessages(MESSAGE_LOAD_PHOTOS)) { + return; + } + + mLoaderThreadHandler.sendEmptyMessageDelayed(MESSAGE_PRELOAD_PHOTOS, PHOTO_PRELOAD_DELAY); + } + + /** + * Sends a message to this thread to load requested photos. Cancels a preloading request, if + * any: we don't want preloading to impede loading of the photos we need to display now. + */ + public void requestLoading() { + ensureHandler(); + mLoaderThreadHandler.removeMessages(MESSAGE_PRELOAD_PHOTOS); + mLoaderThreadHandler.sendEmptyMessage(MESSAGE_LOAD_PHOTOS); + } + + /** + * Receives the above message, loads photos and then sends a message to the main thread to + * process them. + */ + @Override + public boolean handleMessage(Message msg) { + switch (msg.what) { + case MESSAGE_PRELOAD_PHOTOS: + preloadPhotosInBackground(); + break; + case MESSAGE_LOAD_PHOTOS: + loadPhotosInBackground(); + break; + } + return true; + } + + /** + * The first time it is called, figures out which photos need to be preloaded. Each subsequent + * call preloads the next batch of photos and requests another cycle of preloading after a + * delay. The whole process ends when we either run out of photos to preload or fill up cache. + */ + @WorkerThread + private void preloadPhotosInBackground() { + if (!PermissionsUtil.hasPermission(mContext, android.Manifest.permission.READ_CONTACTS)) { + return; + } + + if (mPreloadStatus == PRELOAD_STATUS_DONE) { + return; + } + + if (mPreloadStatus == PRELOAD_STATUS_NOT_STARTED) { + queryPhotosForPreload(); + if (mPreloadPhotoIds.isEmpty()) { + mPreloadStatus = PRELOAD_STATUS_DONE; + } else { + mPreloadStatus = PRELOAD_STATUS_IN_PROGRESS; + } + requestPreloading(); + return; + } + + if (mBitmapHolderCache.size() > mBitmapHolderCacheRedZoneBytes) { + mPreloadStatus = PRELOAD_STATUS_DONE; + return; + } + + mPhotoIds.clear(); + mPhotoIdsAsStrings.clear(); + + int count = 0; + int preloadSize = mPreloadPhotoIds.size(); + while (preloadSize > 0 && mPhotoIds.size() < PRELOAD_BATCH) { + preloadSize--; + count++; + Long photoId = mPreloadPhotoIds.get(preloadSize); + mPhotoIds.add(photoId); + mPhotoIdsAsStrings.add(photoId.toString()); + mPreloadPhotoIds.remove(preloadSize); + } + + loadThumbnails(true); + + if (preloadSize == 0) { + mPreloadStatus = PRELOAD_STATUS_DONE; + } + + LogUtil.v( + "ContactPhotoManagerImpl.preloadPhotosInBackground", + "preloaded " + count + " photos. cached bytes: " + mBitmapHolderCache.size()); + + requestPreloading(); + } + + @WorkerThread + private void queryPhotosForPreload() { + Cursor cursor = null; + try { + Uri uri = + Contacts.CONTENT_URI + .buildUpon() + .appendQueryParameter( + ContactsContract.DIRECTORY_PARAM_KEY, String.valueOf(Directory.DEFAULT)) + .appendQueryParameter( + ContactsContract.LIMIT_PARAM_KEY, String.valueOf(MAX_PHOTOS_TO_PRELOAD)) + .build(); + cursor = + mResolver.query( + uri, + new String[] {Contacts.PHOTO_ID}, + Contacts.PHOTO_ID + " NOT NULL AND " + Contacts.PHOTO_ID + "!=0", + null, + Contacts.STARRED + " DESC, " + Contacts.LAST_TIME_CONTACTED + " DESC"); + + if (cursor != null) { + while (cursor.moveToNext()) { + // Insert them in reverse order, because we will be taking + // them from the end of the list for loading. + mPreloadPhotoIds.add(0, cursor.getLong(0)); + } + } + } finally { + if (cursor != null) { + cursor.close(); + } + } + } + + @WorkerThread + private void loadPhotosInBackground() { + if (!PermissionsUtil.hasPermission(mContext, android.Manifest.permission.READ_CONTACTS)) { + return; + } + obtainPhotoIdsAndUrisToLoad(mPhotoIds, mPhotoIdsAsStrings, mPhotoUris); + loadThumbnails(false); + loadUriBasedPhotos(); + requestPreloading(); + } + + /** Loads thumbnail photos with ids */ + @WorkerThread + private void loadThumbnails(boolean preloading) { + if (mPhotoIds.isEmpty()) { + return; + } + + // Remove loaded photos from the preload queue: we don't want + // the preloading process to load them again. + if (!preloading && mPreloadStatus == PRELOAD_STATUS_IN_PROGRESS) { + for (Long id : mPhotoIds) { + mPreloadPhotoIds.remove(id); + } + if (mPreloadPhotoIds.isEmpty()) { + mPreloadStatus = PRELOAD_STATUS_DONE; + } + } + + mStringBuilder.setLength(0); + mStringBuilder.append(Photo._ID + " IN("); + for (int i = 0; i < mPhotoIds.size(); i++) { + if (i != 0) { + mStringBuilder.append(','); + } + mStringBuilder.append('?'); + } + mStringBuilder.append(')'); + + Cursor cursor = null; + try { + if (DEBUG) { + LogUtil.d( + "ContactPhotoManagerImpl.loadThumbnails", + "loading " + TextUtils.join(",", mPhotoIdsAsStrings)); + } + cursor = + mResolver.query( + Data.CONTENT_URI, + COLUMNS, + mStringBuilder.toString(), + mPhotoIdsAsStrings.toArray(EMPTY_STRING_ARRAY), + null); + + if (cursor != null) { + while (cursor.moveToNext()) { + Long id = cursor.getLong(0); + byte[] bytes = cursor.getBlob(1); + cacheBitmap(id, bytes, preloading, -1); + mPhotoIds.remove(id); + } + } + } finally { + if (cursor != null) { + cursor.close(); + } + } + + // Remaining photos were not found in the contacts database (but might be in profile). + for (Long id : mPhotoIds) { + if (ContactsContract.isProfileId(id)) { + Cursor profileCursor = null; + try { + profileCursor = + mResolver.query( + ContentUris.withAppendedId(Data.CONTENT_URI, id), COLUMNS, null, null, null); + if (profileCursor != null && profileCursor.moveToFirst()) { + cacheBitmap(profileCursor.getLong(0), profileCursor.getBlob(1), preloading, -1); + } else { + // Couldn't load a photo this way either. + cacheBitmap(id, null, preloading, -1); + } + } finally { + if (profileCursor != null) { + profileCursor.close(); + } + } + } else { + // Not a profile photo and not found - mark the cache accordingly + cacheBitmap(id, null, preloading, -1); + } + } + + mMainThreadHandler.sendEmptyMessage(MESSAGE_PHOTOS_LOADED); + } + + /** + * Loads photos referenced with Uris. Those can be remote thumbnails (from directory searches), + * display photos etc + */ + @WorkerThread + private void loadUriBasedPhotos() { + for (Request uriRequest : mPhotoUris) { + // Keep the original URI and use this to key into the cache. Failure to do so will + // result in an image being continually reloaded into cache if the original URI + // has a contact type encodedFragment (eg nearby places business photo URLs). + Uri originalUri = uriRequest.getUri(); + + // Strip off the "contact type" we added to the URI to ensure it was identifiable as + // a business photo -- there is no need to pass this on to the server. + Uri uri = ContactPhotoManager.removeContactType(originalUri); + + if (mBuffer == null) { + mBuffer = new byte[BUFFER_SIZE]; + } + try { + if (DEBUG) { + LogUtil.d("ContactPhotoManagerImpl.loadUriBasedPhotos", "loading " + uri); + } + final String scheme = uri.getScheme(); + InputStream is = null; + if (scheme.equals("http") || scheme.equals("https")) { + TrafficStats.setThreadStatsTag(TrafficStatsTags.CONTACT_PHOTO_DOWNLOAD_TAG); + final HttpURLConnection connection = + (HttpURLConnection) new URL(uri.toString()).openConnection(); + + // Include the user agent if it is specified. + if (!TextUtils.isEmpty(mUserAgent)) { + connection.setRequestProperty("User-Agent", mUserAgent); + } + try { + is = connection.getInputStream(); + } catch (IOException e) { + connection.disconnect(); + is = null; + } + TrafficStats.clearThreadStatsTag(); + } else { + is = mResolver.openInputStream(uri); + } + if (is != null) { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try { + int size; + while ((size = is.read(mBuffer)) != -1) { + baos.write(mBuffer, 0, size); + } + } finally { + is.close(); + } + cacheBitmap(originalUri, baos.toByteArray(), false, uriRequest.getRequestedExtent()); + mMainThreadHandler.sendEmptyMessage(MESSAGE_PHOTOS_LOADED); + } else { + LogUtil.v("ContactPhotoManagerImpl.loadUriBasedPhotos", "cannot load photo " + uri); + cacheBitmap(originalUri, null, false, uriRequest.getRequestedExtent()); + } + } catch (final Exception | OutOfMemoryError ex) { + LogUtil.v("ContactPhotoManagerImpl.loadUriBasedPhotos", "cannot load photo " + uri, ex); + cacheBitmap(originalUri, null, false, uriRequest.getRequestedExtent()); + } + } + } + } +} diff --git a/java/com/android/contacts/common/ContactPresenceIconUtil.java b/java/com/android/contacts/common/ContactPresenceIconUtil.java new file mode 100644 index 000000000..eeaf652a8 --- /dev/null +++ b/java/com/android/contacts/common/ContactPresenceIconUtil.java @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2010 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.common; + +import android.content.Context; +import android.graphics.drawable.Drawable; +import android.provider.ContactsContract.StatusUpdates; + +/** Define the contact present show policy in Contacts */ +public class ContactPresenceIconUtil { + + /** + * Get the presence icon resource according the status. + * + * @return null means don't show the status icon. + */ + public static Drawable getPresenceIcon(Context context, int status) { + // We don't show the offline status in Contacts + switch (status) { + case StatusUpdates.AVAILABLE: + case StatusUpdates.IDLE: + case StatusUpdates.AWAY: + case StatusUpdates.DO_NOT_DISTURB: + case StatusUpdates.INVISIBLE: + return context.getResources().getDrawable(StatusUpdates.getPresenceIconResourceId(status)); + case StatusUpdates.OFFLINE: + // The undefined status is treated as OFFLINE in getPresenceIconResourceId(); + default: + return null; + } + } +} diff --git a/java/com/android/contacts/common/ContactStatusUtil.java b/java/com/android/contacts/common/ContactStatusUtil.java new file mode 100644 index 000000000..97d84c876 --- /dev/null +++ b/java/com/android/contacts/common/ContactStatusUtil.java @@ -0,0 +1,44 @@ +/* + * Copyright (C) 2011 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.common; + +import android.content.Context; +import android.content.res.Resources; +import android.provider.ContactsContract.StatusUpdates; + +/** Provides static function to get default contact status message. */ +public class ContactStatusUtil { + + private static final String TAG = "ContactStatusUtil"; + + public static String getStatusString(Context context, int presence) { + Resources resources = context.getResources(); + switch (presence) { + case StatusUpdates.AVAILABLE: + return resources.getString(R.string.status_available); + case StatusUpdates.IDLE: + case StatusUpdates.AWAY: + return resources.getString(R.string.status_away); + case StatusUpdates.DO_NOT_DISTURB: + return resources.getString(R.string.status_busy); + case StatusUpdates.OFFLINE: + case StatusUpdates.INVISIBLE: + default: + return null; + } + } +} diff --git a/java/com/android/contacts/common/ContactTileLoaderFactory.java b/java/com/android/contacts/common/ContactTileLoaderFactory.java new file mode 100644 index 000000000..d71472ef8 --- /dev/null +++ b/java/com/android/contacts/common/ContactTileLoaderFactory.java @@ -0,0 +1,64 @@ +/* + * Copyright (C) 2011 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.common; + +import android.content.Context; +import android.content.CursorLoader; +import android.net.Uri; +import android.provider.ContactsContract; +import android.provider.ContactsContract.CommonDataKinds.Phone; +import android.provider.ContactsContract.Contacts; +import android.support.annotation.VisibleForTesting; + +/** + * Used to create {@link CursorLoader} which finds contacts information from the strequents table. + * + * <p>Only returns contacts with phone numbers. + */ +public final class ContactTileLoaderFactory { + + /** + * The _ID field returned for strequent items actually contains data._id instead of contacts._id + * because the query is performed on the data table. In order to obtain the contact id for + * strequent items, use Phone.contact_id instead. + */ + @VisibleForTesting + public static final String[] COLUMNS_PHONE_ONLY = + new String[] { + Contacts._ID, + Contacts.DISPLAY_NAME_PRIMARY, + Contacts.STARRED, + Contacts.PHOTO_URI, + Contacts.LOOKUP_KEY, + Phone.NUMBER, + Phone.TYPE, + Phone.LABEL, + Phone.IS_SUPER_PRIMARY, + Contacts.PINNED, + Phone.CONTACT_ID, + Contacts.DISPLAY_NAME_ALTERNATIVE, + }; + + public static CursorLoader createStrequentPhoneOnlyLoader(Context context) { + Uri uri = + Contacts.CONTENT_STREQUENT_URI + .buildUpon() + .appendQueryParameter(ContactsContract.STREQUENT_PHONE_ONLY, "true") + .build(); + + return new CursorLoader(context, uri, COLUMNS_PHONE_ONLY, null, null, null); + } +} diff --git a/java/com/android/contacts/common/ContactsUtils.java b/java/com/android/contacts/common/ContactsUtils.java new file mode 100644 index 000000000..60af44b9a --- /dev/null +++ b/java/com/android/contacts/common/ContactsUtils.java @@ -0,0 +1,265 @@ +/* + * Copyright (C) 2009 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.common; + +import android.content.Context; +import android.content.Intent; +import android.database.Cursor; +import android.net.Uri; +import android.provider.ContactsContract.CommonDataKinds.Im; +import android.provider.ContactsContract.DisplayPhoto; +import android.support.annotation.IntDef; +import android.text.TextUtils; +import android.util.Pair; +import com.android.contacts.common.compat.ContactsCompat; +import com.android.contacts.common.compat.DirectoryCompat; +import com.android.contacts.common.model.AccountTypeManager; +import com.android.contacts.common.model.account.AccountWithDataSet; +import com.android.contacts.common.model.dataitem.ImDataItem; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.List; + +public class ContactsUtils { + + // Telecomm related schemes are in CallUtil + public static final String SCHEME_IMTO = "imto"; + public static final String SCHEME_MAILTO = "mailto"; + public static final String SCHEME_SMSTO = "smsto"; + public static final long USER_TYPE_CURRENT = 0; + public static final long USER_TYPE_WORK = 1; + private static final String TAG = "ContactsUtils"; + private static final int DEFAULT_THUMBNAIL_SIZE = 96; + private static int sThumbnailSize = -1; + + /** + * This looks up the provider name defined in ProviderNames from the predefined IM protocol id. + * This is used for interacting with the IM application. + * + * @param protocol the protocol ID + * @return the provider name the IM app uses for the given protocol, or null if no provider is + * defined for the given protocol + * @hide + */ + public static String lookupProviderNameFromId(int protocol) { + switch (protocol) { + case Im.PROTOCOL_GOOGLE_TALK: + return ProviderNames.GTALK; + case Im.PROTOCOL_AIM: + return ProviderNames.AIM; + case Im.PROTOCOL_MSN: + return ProviderNames.MSN; + case Im.PROTOCOL_YAHOO: + return ProviderNames.YAHOO; + case Im.PROTOCOL_ICQ: + return ProviderNames.ICQ; + case Im.PROTOCOL_JABBER: + return ProviderNames.JABBER; + case Im.PROTOCOL_SKYPE: + return ProviderNames.SKYPE; + case Im.PROTOCOL_QQ: + return ProviderNames.QQ; + } + return null; + } + + /** + * Test if the given {@link CharSequence} contains any graphic characters, first checking {@link + * TextUtils#isEmpty(CharSequence)} to handle null. + */ + public static boolean isGraphic(CharSequence str) { + return !TextUtils.isEmpty(str) && TextUtils.isGraphic(str); + } + + /** Returns true if two objects are considered equal. Two null references are equal here. */ + public static boolean areObjectsEqual(Object a, Object b) { + return a == b || (a != null && a.equals(b)); + } + + /** Returns true if two {@link Intent}s are both null, or have the same action. */ + public static final boolean areIntentActionEqual(Intent a, Intent b) { + if (a == b) { + return true; + } + if (a == null || b == null) { + return false; + } + return TextUtils.equals(a.getAction(), b.getAction()); + } + + public static boolean areGroupWritableAccountsAvailable(Context context) { + final List<AccountWithDataSet> accounts = + AccountTypeManager.getInstance(context).getGroupWritableAccounts(); + return !accounts.isEmpty(); + } + + /** + * Returns the size (width and height) of thumbnail pictures as configured in the provider. This + * can safely be called from the UI thread, as the provider can serve this without performing a + * database access + */ + public static int getThumbnailSize(Context context) { + if (sThumbnailSize == -1) { + final Cursor c = + context + .getContentResolver() + .query( + DisplayPhoto.CONTENT_MAX_DIMENSIONS_URI, + new String[] {DisplayPhoto.THUMBNAIL_MAX_DIM}, + null, + null, + null); + if (c != null) { + try { + if (c.moveToFirst()) { + sThumbnailSize = c.getInt(0); + } + } finally { + c.close(); + } + } + } + return sThumbnailSize != -1 ? sThumbnailSize : DEFAULT_THUMBNAIL_SIZE; + } + + private static Intent getCustomImIntent(ImDataItem im, int protocol) { + String host = im.getCustomProtocol(); + final String data = im.getData(); + if (TextUtils.isEmpty(data)) { + return null; + } + if (protocol != Im.PROTOCOL_CUSTOM) { + // Try bringing in a well-known host for specific protocols + host = ContactsUtils.lookupProviderNameFromId(protocol); + } + if (TextUtils.isEmpty(host)) { + return null; + } + final String authority = host.toLowerCase(); + final Uri imUri = + new Uri.Builder().scheme(SCHEME_IMTO).authority(authority).appendPath(data).build(); + final Intent intent = new Intent(Intent.ACTION_SENDTO, imUri); + return intent; + } + + /** + * Returns the proper Intent for an ImDatItem. If available, a secondary intent is stored in the + * second Pair slot + */ + public static Pair<Intent, Intent> buildImIntent(Context context, ImDataItem im) { + Intent intent = null; + Intent secondaryIntent = null; + final boolean isEmail = im.isCreatedFromEmail(); + + if (!isEmail && !im.isProtocolValid()) { + return new Pair<>(null, null); + } + + final String data = im.getData(); + if (TextUtils.isEmpty(data)) { + return new Pair<>(null, null); + } + + final int protocol = isEmail ? Im.PROTOCOL_GOOGLE_TALK : im.getProtocol(); + + if (protocol == Im.PROTOCOL_GOOGLE_TALK) { + final int chatCapability = im.getChatCapability(); + if ((chatCapability & Im.CAPABILITY_HAS_CAMERA) != 0) { + intent = new Intent(Intent.ACTION_SENDTO, Uri.parse("xmpp:" + data + "?message")); + secondaryIntent = new Intent(Intent.ACTION_SENDTO, Uri.parse("xmpp:" + data + "?call")); + } else if ((chatCapability & Im.CAPABILITY_HAS_VOICE) != 0) { + // Allow Talking and Texting + intent = new Intent(Intent.ACTION_SENDTO, Uri.parse("xmpp:" + data + "?message")); + secondaryIntent = new Intent(Intent.ACTION_SENDTO, Uri.parse("xmpp:" + data + "?call")); + } else { + intent = new Intent(Intent.ACTION_SENDTO, Uri.parse("xmpp:" + data + "?message")); + } + } else { + // Build an IM Intent + intent = getCustomImIntent(im, protocol); + } + return new Pair<>(intent, secondaryIntent); + } + + /** + * Determine UserType from directory id and contact id. + * + * <p>3 types of query + * + * <p>1. 2 profile query: content://com.android.contacts/phone_lookup_enterprise/1234567890 + * personal and work contact are mixed into one cursor. no directory id. contact_id indicates if + * it's work contact + * + * <p>2. work local query: + * content://com.android.contacts/phone_lookup_enterprise/1234567890?directory=1000000000 either + * directory_id or contact_id is enough to identify work contact + * + * <p>3. work remote query: + * content://com.android.contacts/phone_lookup_enterprise/1234567890?directory=1000000003 + * contact_id is random. only directory_id is available + * + * <p>Summary: If directory_id is not null, always use directory_id to identify work contact. + * (which is the case here) Otherwise, use contact_id. + * + * @param directoryId directory id of ContactsProvider query + * @param contactId contact id + * @return UserType indicates the user type of the contact. A directory id or contact id larger + * than a thredshold indicates that the contact is stored in Work Profile, but not in current + * user. It's a contract by ContactsProvider and check by Contacts.isEnterpriseDirectoryId and + * Contacts.isEnterpriseContactId. Currently, only 2 kinds of users can be detected from the + * directoryId and contactId as ContactsProvider can only access current and work user's + * contacts + */ + public static @UserType long determineUserType(Long directoryId, Long contactId) { + // First check directory id + if (directoryId != null) { + return DirectoryCompat.isEnterpriseDirectoryId(directoryId) + ? USER_TYPE_WORK + : USER_TYPE_CURRENT; + } + // Only check contact id if directory id is null + if (contactId != null && contactId != 0L && ContactsCompat.isEnterpriseContactId(contactId)) { + return USER_TYPE_WORK; + } else { + return USER_TYPE_CURRENT; + } + } + + // TODO find a proper place for the canonical version of these + public interface ProviderNames { + + String YAHOO = "Yahoo"; + String GTALK = "GTalk"; + String MSN = "MSN"; + String ICQ = "ICQ"; + String AIM = "AIM"; + String XMPP = "XMPP"; + String JABBER = "JABBER"; + String SKYPE = "SKYPE"; + String QQ = "QQ"; + } + + /** + * UserType indicates the user type of the contact. If the contact is from Work User (Work Profile + * in Android Multi-User System), it's {@link #USER_TYPE_WORK}, otherwise, {@link + * #USER_TYPE_CURRENT}. Please note that current user can be in work profile, where the dialer is + * running inside Work Profile. + */ + @Retention(RetentionPolicy.SOURCE) + @IntDef({USER_TYPE_CURRENT, USER_TYPE_WORK}) + public @interface UserType {} +} diff --git a/java/com/android/contacts/common/GeoUtil.java b/java/com/android/contacts/common/GeoUtil.java new file mode 100644 index 000000000..50b0cd9e3 --- /dev/null +++ b/java/com/android/contacts/common/GeoUtil.java @@ -0,0 +1,55 @@ +/* + * Copyright (C) 2012 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.common; + +import android.app.Application; +import android.content.Context; +import com.android.contacts.common.location.CountryDetector; +import com.google.i18n.phonenumbers.NumberParseException; +import com.google.i18n.phonenumbers.PhoneNumberUtil; +import com.google.i18n.phonenumbers.Phonenumber; +import com.google.i18n.phonenumbers.geocoding.PhoneNumberOfflineGeocoder; +import java.util.Locale; + +/** Static methods related to Geo. */ +public class GeoUtil { + + /** + * Returns the country code of the country the user is currently in. Before calling this method, + * make sure that {@link CountryDetector#initialize(Context)} has already been called in {@link + * Application#onCreate()}. + * + * @return The ISO 3166-1 two letters country code of the country the user is in. + */ + public static String getCurrentCountryIso(Context context) { + // The {@link CountryDetector} should never return null so this is safe to return as-is. + return CountryDetector.getInstance(context).getCurrentCountryIso(); + } + + public static String getGeocodedLocationFor(Context context, String phoneNumber) { + final PhoneNumberOfflineGeocoder geocoder = PhoneNumberOfflineGeocoder.getInstance(); + final PhoneNumberUtil phoneNumberUtil = PhoneNumberUtil.getInstance(); + try { + final Phonenumber.PhoneNumber structuredPhoneNumber = + phoneNumberUtil.parse(phoneNumber, getCurrentCountryIso(context)); + final Locale locale = context.getResources().getConfiguration().locale; + return geocoder.getDescriptionForNumber(structuredPhoneNumber, locale); + } catch (NumberParseException e) { + return null; + } + } +} diff --git a/java/com/android/contacts/common/GroupMetaData.java b/java/com/android/contacts/common/GroupMetaData.java new file mode 100644 index 000000000..b34f1d629 --- /dev/null +++ b/java/com/android/contacts/common/GroupMetaData.java @@ -0,0 +1,76 @@ +/* + * Copyright (C) 2010 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.common; + +/** + * Meta-data for a contact group. We load all groups associated with the contact's constituent + * accounts. + */ +public final class GroupMetaData { + + private String mAccountName; + private String mAccountType; + private String mDataSet; + private long mGroupId; + private String mTitle; + private boolean mDefaultGroup; + private boolean mFavorites; + + public GroupMetaData( + String accountName, + String accountType, + String dataSet, + long groupId, + String title, + boolean defaultGroup, + boolean favorites) { + this.mAccountName = accountName; + this.mAccountType = accountType; + this.mDataSet = dataSet; + this.mGroupId = groupId; + this.mTitle = title; + this.mDefaultGroup = defaultGroup; + this.mFavorites = favorites; + } + + public String getAccountName() { + return mAccountName; + } + + public String getAccountType() { + return mAccountType; + } + + public String getDataSet() { + return mDataSet; + } + + public long getGroupId() { + return mGroupId; + } + + public String getTitle() { + return mTitle; + } + + public boolean isDefaultGroup() { + return mDefaultGroup; + } + + public boolean isFavorites() { + return mFavorites; + } +} diff --git a/java/com/android/contacts/common/MoreContactUtils.java b/java/com/android/contacts/common/MoreContactUtils.java new file mode 100644 index 000000000..028f89971 --- /dev/null +++ b/java/com/android/contacts/common/MoreContactUtils.java @@ -0,0 +1,251 @@ +/* + * Copyright (C) 2012 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.common; + +import android.content.Context; +import android.content.Intent; +import android.graphics.Rect; +import android.net.Uri; +import android.provider.ContactsContract; +import android.telephony.PhoneNumberUtils; +import android.text.TextUtils; +import android.view.View; +import android.widget.TextView; +import com.android.contacts.common.model.account.AccountType; +import com.google.i18n.phonenumbers.NumberParseException; +import com.google.i18n.phonenumbers.PhoneNumberUtil; + +/** Shared static contact utility methods. */ +public class MoreContactUtils { + + private static final String WAIT_SYMBOL_AS_STRING = String.valueOf(PhoneNumberUtils.WAIT); + + /** + * Returns true if two data with mimetypes which represent values in contact entries are + * considered equal for collapsing in the GUI. For caller-id, use {@link + * android.telephony.PhoneNumberUtils#compare(android.content.Context, String, String)} instead + */ + public static boolean shouldCollapse( + CharSequence mimetype1, CharSequence data1, CharSequence mimetype2, CharSequence data2) { + // different mimetypes? don't collapse + if (!TextUtils.equals(mimetype1, mimetype2)) { + return false; + } + + // exact same string? good, bail out early + if (TextUtils.equals(data1, data2)) { + return true; + } + + // so if either is null, these two must be different + if (data1 == null || data2 == null) { + return false; + } + + // if this is not about phone numbers, we know this is not a match (of course, some + // mimetypes could have more sophisticated matching is the future, e.g. addresses) + if (!TextUtils.equals(ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE, mimetype1)) { + return false; + } + + return shouldCollapsePhoneNumbers(data1.toString(), data2.toString()); + } + + // TODO: Move this to PhoneDataItem.shouldCollapse override + private static boolean shouldCollapsePhoneNumbers(String number1, String number2) { + // Work around to address b/20724444. We want to distinguish between #555, *555 and 555. + // This makes no attempt to distinguish between 555 and 55*5, since 55*5 is an improbable + // number. PhoneNumberUtil already distinguishes between 555 and 55#5. + if (number1.contains("#") != number2.contains("#") + || number1.contains("*") != number2.contains("*")) { + return false; + } + + // Now do the full phone number thing. split into parts, separated by waiting symbol + // and compare them individually + final String[] dataParts1 = number1.split(WAIT_SYMBOL_AS_STRING); + final String[] dataParts2 = number2.split(WAIT_SYMBOL_AS_STRING); + if (dataParts1.length != dataParts2.length) { + return false; + } + final PhoneNumberUtil util = PhoneNumberUtil.getInstance(); + for (int i = 0; i < dataParts1.length; i++) { + // Match phone numbers represented by keypad letters, in which case prefer the + // phone number with letters. + final String dataPart1 = PhoneNumberUtils.convertKeypadLettersToDigits(dataParts1[i]); + final String dataPart2 = dataParts2[i]; + + // substrings equal? shortcut, don't parse + if (TextUtils.equals(dataPart1, dataPart2)) { + continue; + } + + // do a full parse of the numbers + final PhoneNumberUtil.MatchType result = util.isNumberMatch(dataPart1, dataPart2); + switch (result) { + case NOT_A_NUMBER: + // don't understand the numbers? let's play it safe + return false; + case NO_MATCH: + return false; + case EXACT_MATCH: + break; + case NSN_MATCH: + try { + // For NANP phone numbers, match when one has +1 and the other does not. + // In this case, prefer the +1 version. + if (util.parse(dataPart1, null).getCountryCode() == 1) { + // At this point, the numbers can be either case 1 or 2 below.... + // + // case 1) + // +14155551212 <--- country code 1 + // 14155551212 <--- 1 is trunk prefix, not country code + // + // and + // + // case 2) + // +14155551212 + // 4155551212 + // + // From b/7519057, case 2 needs to be equal. But also that bug, case 3 + // below should not be equal. + // + // case 3) + // 14155551212 + // 4155551212 + // + // So in order to make sure transitive equality is valid, case 1 cannot + // be equal. Otherwise, transitive equality breaks and the following + // would all be collapsed: + // 4155551212 | + // 14155551212 |----> +14155551212 + // +14155551212 | + // + // With transitive equality, the collapsed values should be: + // 4155551212 | 14155551212 + // 14155551212 |----> +14155551212 + // +14155551212 | + + // Distinguish between case 1 and 2 by checking for trunk prefix '1' + // at the start of number 2. + if (dataPart2.trim().charAt(0) == '1') { + // case 1 + return false; + } + break; + } + } catch (NumberParseException e) { + // This is the case where the first number does not have a country code. + // examples: + // (123) 456-7890 & 123-456-7890 (collapse) + // 0049 (8092) 1234 & +49/80921234 (unit test says do not collapse) + + // Check the second number. If it also does not have a country code, then + // we should collapse. If it has a country code, then it's a different + // number and we should not collapse (this conclusion is based on an + // existing unit test). + try { + util.parse(dataPart2, null); + } catch (NumberParseException e2) { + // Number 2 also does not have a country. Collapse. + break; + } + } + return false; + case SHORT_NSN_MATCH: + return false; + default: + throw new IllegalStateException("Unknown result value from phone number " + "library"); + } + } + return true; + } + + /** + * Returns the {@link android.graphics.Rect} with left, top, right, and bottom coordinates that + * are equivalent to the given {@link android.view.View}'s bounds. This is equivalent to how the + * target {@link android.graphics.Rect} is calculated in {@link + * android.provider.ContactsContract.QuickContact#showQuickContact}. + */ + public static Rect getTargetRectFromView(View view) { + final int[] pos = new int[2]; + view.getLocationOnScreen(pos); + + final Rect rect = new Rect(); + rect.left = pos[0]; + rect.top = pos[1]; + rect.right = pos[0] + view.getWidth(); + rect.bottom = pos[1] + view.getHeight(); + return rect; + } + + /** + * Returns a header view based on the R.layout.list_separator, where the containing {@link + * android.widget.TextView} is set using the given textResourceId. + */ + public static TextView createHeaderView(Context context, int textResourceId) { + final TextView textView = (TextView) View.inflate(context, R.layout.list_separator, null); + textView.setText(context.getString(textResourceId)); + return textView; + } + + /** + * Set the top padding on the header view dynamically, based on whether the header is in the first + * row or not. + */ + public static void setHeaderViewBottomPadding( + Context context, TextView textView, boolean isFirstRow) { + final int topPadding; + if (isFirstRow) { + topPadding = + (int) + context + .getResources() + .getDimension(R.dimen.frequently_contacted_title_top_margin_when_first_row); + } else { + topPadding = + (int) context.getResources().getDimension(R.dimen.frequently_contacted_title_top_margin); + } + textView.setPaddingRelative( + textView.getPaddingStart(), + topPadding, + textView.getPaddingEnd(), + textView.getPaddingBottom()); + } + + /** + * Returns the intent to launch for the given invitable account type and contact lookup URI. This + * will return null if the account type is not invitable (i.e. there is no {@link + * AccountType#getInviteContactActivityClassName()} or {@link + * AccountType#syncAdapterPackageName}). + */ + public static Intent getInvitableIntent(AccountType accountType, Uri lookupUri) { + String syncAdapterPackageName = accountType.syncAdapterPackageName; + String className = accountType.getInviteContactActivityClassName(); + if (TextUtils.isEmpty(syncAdapterPackageName) || TextUtils.isEmpty(className)) { + return null; + } + Intent intent = new Intent(); + intent.setClassName(syncAdapterPackageName, className); + + intent.setAction(ContactsContract.Intents.INVITE_CONTACT); + + // Data is the lookup URI. + intent.setData(lookupUri); + return intent; + } +} diff --git a/java/com/android/contacts/common/bindings/ContactsCommonBindings.java b/java/com/android/contacts/common/bindings/ContactsCommonBindings.java new file mode 100644 index 000000000..44be53b3f --- /dev/null +++ b/java/com/android/contacts/common/bindings/ContactsCommonBindings.java @@ -0,0 +1,25 @@ +/* + * 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.common.bindings; + +import android.support.annotation.Nullable; + +/** Allows the container application to customize the contacts common library. */ +public interface ContactsCommonBindings { + + /** Builds a user agent string for the current application. */ + @Nullable + String getUserAgent(); +} diff --git a/java/com/android/contacts/common/bindings/ContactsCommonBindingsFactory.java b/java/com/android/contacts/common/bindings/ContactsCommonBindingsFactory.java new file mode 100644 index 000000000..8958ad997 --- /dev/null +++ b/java/com/android/contacts/common/bindings/ContactsCommonBindingsFactory.java @@ -0,0 +1,24 @@ +/* + * 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.common.bindings; + +/** + * This interface should be implementated by the Application subclass. It allows the contacts common + * module to get references to the ContactsCommonBindings. + */ +public interface ContactsCommonBindingsFactory { + + ContactsCommonBindings newContactsCommonBindings(); +} diff --git a/java/com/android/contacts/common/bindings/ContactsCommonBindingsStub.java b/java/com/android/contacts/common/bindings/ContactsCommonBindingsStub.java new file mode 100644 index 000000000..f2e21b18e --- /dev/null +++ b/java/com/android/contacts/common/bindings/ContactsCommonBindingsStub.java @@ -0,0 +1,27 @@ +/* + * 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.common.bindings; + +import android.support.annotation.Nullable; + +/** Default implementation for contacts common bindings. */ +public class ContactsCommonBindingsStub implements ContactsCommonBindings { + + @Override + @Nullable + public String getUserAgent() { + return null; + } +} diff --git a/java/com/android/contacts/common/compat/CallCompat.java b/java/com/android/contacts/common/compat/CallCompat.java new file mode 100644 index 000000000..641f7b1bd --- /dev/null +++ b/java/com/android/contacts/common/compat/CallCompat.java @@ -0,0 +1,45 @@ +/* + * 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.common.compat; + +import android.os.Build.VERSION; +import android.os.Build.VERSION_CODES; +import android.support.annotation.NonNull; +import android.telecom.Call; + +/** Compatibility utilities for android.telecom.Call */ +public class CallCompat { + + public static boolean canPullExternalCall(@NonNull android.telecom.Call call) { + return VERSION.SDK_INT >= VERSION_CODES.N_MR1 + && ((call.getDetails().getCallCapabilities() & Details.CAPABILITY_CAN_PULL_CALL) + == Details.CAPABILITY_CAN_PULL_CALL); + } + + /** android.telecom.Call.Details */ + public static class Details { + + public static final int PROPERTY_IS_EXTERNAL_CALL = Call.Details.PROPERTY_IS_EXTERNAL_CALL; + public static final int PROPERTY_ENTERPRISE_CALL = Call.Details.PROPERTY_ENTERPRISE_CALL; + public static final int CAPABILITY_CAN_PULL_CALL = Call.Details.CAPABILITY_CAN_PULL_CALL; + public static final int CAPABILITY_CANNOT_DOWNGRADE_VIDEO_TO_AUDIO = + Call.Details.CAPABILITY_CANNOT_DOWNGRADE_VIDEO_TO_AUDIO; + + public static final String EXTRA_ANSWERING_DROPS_FOREGROUND_CALL = + "android.telecom.extra.ANSWERING_DROPS_FG_CALL"; + } +} diff --git a/java/com/android/contacts/common/compat/CallableCompat.java b/java/com/android/contacts/common/compat/CallableCompat.java new file mode 100644 index 000000000..5e86f518e --- /dev/null +++ b/java/com/android/contacts/common/compat/CallableCompat.java @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2015 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.common.compat; + +import android.net.Uri; +import android.os.Build.VERSION; +import android.os.Build.VERSION_CODES; +import android.provider.ContactsContract.CommonDataKinds.Callable; + +public class CallableCompat { + + // TODO: Use N APIs + private static final Uri ENTERPRISE_CONTENT_FILTER_URI = + Uri.withAppendedPath(Callable.CONTENT_URI, "filter_enterprise"); + + public static Uri getContentFilterUri() { + if (VERSION.SDK_INT >= VERSION_CODES.N) { + return ENTERPRISE_CONTENT_FILTER_URI; + } + return Callable.CONTENT_FILTER_URI; + } +} diff --git a/java/com/android/contacts/common/compat/ContactsCompat.java b/java/com/android/contacts/common/compat/ContactsCompat.java new file mode 100644 index 000000000..39d0b55d3 --- /dev/null +++ b/java/com/android/contacts/common/compat/ContactsCompat.java @@ -0,0 +1,57 @@ +/* + * Copyright (C) 2015 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.common.compat; + +import android.net.Uri; +import android.os.Build.VERSION; +import android.os.Build.VERSION_CODES; +import android.provider.ContactsContract; +import android.provider.ContactsContract.Contacts; +import com.android.dialer.compat.CompatUtils; + +/** Compatibility class for {@link ContactsContract.Contacts} */ +public class ContactsCompat { + + // TODO: Use N APIs + private static final Uri ENTERPRISE_CONTENT_FILTER_URI = + Uri.withAppendedPath(Contacts.CONTENT_URI, "filter_enterprise"); + // Copied from ContactsContract.Contacts#ENTERPRISE_CONTACT_ID_BASE, which is hidden. + private static final long ENTERPRISE_CONTACT_ID_BASE = 1000000000; + + /** Not instantiable. */ + private ContactsCompat() {} + + public static Uri getContentUri() { + if (VERSION.SDK_INT >= VERSION_CODES.N) { + return ENTERPRISE_CONTENT_FILTER_URI; + } + return Contacts.CONTENT_FILTER_URI; + } + + /** + * Return {@code true} if a contact ID is from the contacts provider on the enterprise profile. + */ + public static boolean isEnterpriseContactId(long contactId) { + if (CompatUtils.isLollipopCompatible()) { + return Contacts.isEnterpriseContactId(contactId); + } else { + // copied from ContactsContract.Contacts.isEnterpriseContactId + return (contactId >= ENTERPRISE_CONTACT_ID_BASE) + && (contactId < ContactsContract.Profile.MIN_ID); + } + } +} diff --git a/java/com/android/contacts/common/compat/DirectoryCompat.java b/java/com/android/contacts/common/compat/DirectoryCompat.java new file mode 100644 index 000000000..85f4a4202 --- /dev/null +++ b/java/com/android/contacts/common/compat/DirectoryCompat.java @@ -0,0 +1,51 @@ +/* + * Copyright (C) 2015 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.common.compat; + +import android.net.Uri; +import android.os.Build.VERSION; +import android.os.Build.VERSION_CODES; +import android.provider.ContactsContract.Directory; + +public class DirectoryCompat { + + public static Uri getContentUri() { + if (VERSION.SDK_INT >= VERSION_CODES.N) { + return Directory.ENTERPRISE_CONTENT_URI; + } + return Directory.CONTENT_URI; + } + + public static boolean isInvisibleDirectory(long directoryId) { + if (VERSION.SDK_INT >= VERSION_CODES.N) { + return (directoryId == Directory.LOCAL_INVISIBLE + || directoryId == Directory.ENTERPRISE_LOCAL_INVISIBLE); + } + return directoryId == Directory.LOCAL_INVISIBLE; + } + + public static boolean isRemoteDirectoryId(long directoryId) { + if (VERSION.SDK_INT >= VERSION_CODES.N) { + return Directory.isRemoteDirectoryId(directoryId); + } + return !(directoryId == Directory.DEFAULT || directoryId == Directory.LOCAL_INVISIBLE); + } + + public static boolean isEnterpriseDirectoryId(long directoryId) { + return VERSION.SDK_INT >= VERSION_CODES.N && Directory.isEnterpriseDirectoryId(directoryId); + } +} diff --git a/java/com/android/contacts/common/compat/PhoneAccountCompat.java b/java/com/android/contacts/common/compat/PhoneAccountCompat.java new file mode 100644 index 000000000..6a24ec033 --- /dev/null +++ b/java/com/android/contacts/common/compat/PhoneAccountCompat.java @@ -0,0 +1,104 @@ +/* + * Copyright (C) 2015 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.common.compat; + +import android.content.Context; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.Icon; +import android.support.annotation.Nullable; +import android.telecom.PhoneAccount; +import android.util.Log; +import com.android.dialer.compat.CompatUtils; + +/** Compatiblity class for {@link android.telecom.PhoneAccount} */ +public class PhoneAccountCompat { + + private static final String TAG = PhoneAccountCompat.class.getSimpleName(); + + /** + * Gets the {@link Icon} associated with the given {@link PhoneAccount} + * + * @param phoneAccount the PhoneAccount from which to retrieve the Icon + * @return the Icon, or null + */ + @Nullable + public static Icon getIcon(@Nullable PhoneAccount phoneAccount) { + if (phoneAccount == null) { + return null; + } + + if (CompatUtils.isMarshmallowCompatible()) { + return phoneAccount.getIcon(); + } + + return null; + } + + /** + * Builds and returns an icon {@code Drawable} to represent this {@code PhoneAccount} in a user + * interface. + * + * @param phoneAccount the PhoneAccount from which to build the icon. + * @param context A {@code Context} to use for loading Drawables. + * @return An icon for this PhoneAccount, or null + */ + @Nullable + public static Drawable createIconDrawable( + @Nullable PhoneAccount phoneAccount, @Nullable Context context) { + if (phoneAccount == null || context == null) { + return null; + } + + if (CompatUtils.isMarshmallowCompatible()) { + return createIconDrawableMarshmallow(phoneAccount, context); + } + + if (CompatUtils.isLollipopMr1Compatible()) { + return createIconDrawableLollipopMr1(phoneAccount, context); + } + return null; + } + + @Nullable + private static Drawable createIconDrawableMarshmallow( + PhoneAccount phoneAccount, Context context) { + Icon accountIcon = getIcon(phoneAccount); + if (accountIcon == null) { + return null; + } + return accountIcon.loadDrawable(context); + } + + @Nullable + private static Drawable createIconDrawableLollipopMr1( + PhoneAccount phoneAccount, Context context) { + try { + return (Drawable) + PhoneAccount.class + .getMethod("createIconDrawable", Context.class) + .invoke(phoneAccount, context); + } catch (ReflectiveOperationException e) { + return null; + } catch (Throwable t) { + Log.e( + TAG, + "Unexpected exception when attempting to call " + + "android.telecom.PhoneAccount#createIconDrawable", + t); + return null; + } + } +} diff --git a/java/com/android/contacts/common/compat/PhoneCompat.java b/java/com/android/contacts/common/compat/PhoneCompat.java new file mode 100644 index 000000000..31db7b537 --- /dev/null +++ b/java/com/android/contacts/common/compat/PhoneCompat.java @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2015 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.common.compat; + +import android.net.Uri; +import android.os.Build.VERSION; +import android.os.Build.VERSION_CODES; +import android.provider.ContactsContract.CommonDataKinds.Phone; + +public class PhoneCompat { + + // TODO: Use N APIs + private static final Uri ENTERPRISE_CONTENT_FILTER_URI = + Uri.withAppendedPath(Phone.CONTENT_URI, "filter_enterprise"); + + public static Uri getContentFilterUri() { + if (VERSION.SDK_INT >= VERSION_CODES.N) { + return ENTERPRISE_CONTENT_FILTER_URI; + } + return Phone.CONTENT_FILTER_URI; + } +} diff --git a/java/com/android/contacts/common/compat/PhoneNumberUtilsCompat.java b/java/com/android/contacts/common/compat/PhoneNumberUtilsCompat.java new file mode 100644 index 000000000..960b340d8 --- /dev/null +++ b/java/com/android/contacts/common/compat/PhoneNumberUtilsCompat.java @@ -0,0 +1,174 @@ +/* + * Copyright (C) 2015 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.common.compat; + +import android.telephony.PhoneNumberUtils; +import android.text.Spannable; +import android.text.TextUtils; +import android.text.style.TtsSpan; +import com.android.dialer.compat.CompatUtils; +import com.google.i18n.phonenumbers.NumberParseException; +import com.google.i18n.phonenumbers.PhoneNumberUtil; +import com.google.i18n.phonenumbers.Phonenumber.PhoneNumber; + +/** + * This class contains static utility methods extracted from PhoneNumberUtils, and the methods were + * added in API level 23. In this way, we could enable the corresponding functionality for pre-M + * devices. We need maintain this class and keep it synced with PhoneNumberUtils. Another thing to + * keep in mind is that we use com.google.i18n rather than com.android.i18n in here, so we need make + * sure the application behavior is preserved. + */ +public class PhoneNumberUtilsCompat { + + /** Not instantiable. */ + private PhoneNumberUtilsCompat() {} + + public static String normalizeNumber(String phoneNumber) { + if (CompatUtils.isLollipopCompatible()) { + return PhoneNumberUtils.normalizeNumber(phoneNumber); + } else { + return normalizeNumberInternal(phoneNumber); + } + } + + /** Implementation copied from {@link PhoneNumberUtils#normalizeNumber} */ + private static String normalizeNumberInternal(String phoneNumber) { + if (TextUtils.isEmpty(phoneNumber)) { + return ""; + } + StringBuilder sb = new StringBuilder(); + int len = phoneNumber.length(); + for (int i = 0; i < len; i++) { + char c = phoneNumber.charAt(i); + // Character.digit() supports ASCII and Unicode digits (fullwidth, Arabic-Indic, etc.) + int digit = Character.digit(c, 10); + if (digit != -1) { + sb.append(digit); + } else if (sb.length() == 0 && c == '+') { + sb.append(c); + } else if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')) { + return normalizeNumber(PhoneNumberUtils.convertKeypadLettersToDigits(phoneNumber)); + } + } + return sb.toString(); + } + + public static String formatNumber( + String phoneNumber, String phoneNumberE164, String defaultCountryIso) { + if (CompatUtils.isLollipopCompatible()) { + return PhoneNumberUtils.formatNumber(phoneNumber, phoneNumberE164, defaultCountryIso); + } else { + // This method was deprecated in API level 21, so it's only used on pre-L SDKs. + return PhoneNumberUtils.formatNumber(phoneNumber); + } + } + + public static CharSequence createTtsSpannable(CharSequence phoneNumber) { + if (CompatUtils.isMarshmallowCompatible()) { + return PhoneNumberUtils.createTtsSpannable(phoneNumber); + } else { + return createTtsSpannableInternal(phoneNumber); + } + } + + public static TtsSpan createTtsSpan(String phoneNumber) { + if (CompatUtils.isMarshmallowCompatible()) { + return PhoneNumberUtils.createTtsSpan(phoneNumber); + } else if (CompatUtils.isLollipopCompatible()) { + return createTtsSpanLollipop(phoneNumber); + } else { + return null; + } + } + + /** Copied from {@link PhoneNumberUtils#createTtsSpannable} */ + private static CharSequence createTtsSpannableInternal(CharSequence phoneNumber) { + if (phoneNumber == null) { + return null; + } + Spannable spannable = Spannable.Factory.getInstance().newSpannable(phoneNumber); + addTtsSpanInternal(spannable, 0, spannable.length()); + return spannable; + } + + /** Compat method for addTtsSpan, see {@link PhoneNumberUtils#addTtsSpan} */ + public static void addTtsSpan(Spannable s, int start, int endExclusive) { + if (CompatUtils.isMarshmallowCompatible()) { + PhoneNumberUtils.addTtsSpan(s, start, endExclusive); + } else { + addTtsSpanInternal(s, start, endExclusive); + } + } + + /** Copied from {@link PhoneNumberUtils#addTtsSpan} */ + private static void addTtsSpanInternal(Spannable s, int start, int endExclusive) { + s.setSpan( + createTtsSpan(s.subSequence(start, endExclusive).toString()), + start, + endExclusive, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + } + + /** Copied from {@link PhoneNumberUtils#createTtsSpan} */ + private static TtsSpan createTtsSpanLollipop(String phoneNumberString) { + if (phoneNumberString == null) { + return null; + } + + // Parse the phone number + final PhoneNumberUtil phoneNumberUtil = PhoneNumberUtil.getInstance(); + PhoneNumber phoneNumber = null; + try { + // Don't supply a defaultRegion so this fails for non-international numbers because + // we don't want to TalkBalk to read a country code (e.g. +1) if it is not already + // present + phoneNumber = phoneNumberUtil.parse(phoneNumberString, /* defaultRegion */ null); + } catch (NumberParseException ignored) { + } + + // Build a telephone tts span + final TtsSpan.TelephoneBuilder builder = new TtsSpan.TelephoneBuilder(); + if (phoneNumber == null) { + // Strip separators otherwise TalkBack will be silent + // (this behavior was observed with TalkBalk 4.0.2 from their alpha channel) + builder.setNumberParts(splitAtNonNumerics(phoneNumberString)); + } else { + if (phoneNumber.hasCountryCode()) { + builder.setCountryCode(Integer.toString(phoneNumber.getCountryCode())); + } + builder.setNumberParts(Long.toString(phoneNumber.getNationalNumber())); + } + return builder.build(); + } + + /** + * Split a phone number using spaces, ignoring anything that is not a digit + * + * @param number A {@code CharSequence} before splitting, e.g., "+20(123)-456#" + * @return A {@code String} after splitting, e.g., "20 123 456". + */ + private static String splitAtNonNumerics(CharSequence number) { + StringBuilder sb = new StringBuilder(number.length()); + for (int i = 0; i < number.length(); i++) { + sb.append(PhoneNumberUtils.isISODigit(number.charAt(i)) ? number.charAt(i) : " "); + } + // It is very important to remove extra spaces. At time of writing, any leading or trailing + // spaces, or any sequence of more than one space, will confuse TalkBack and cause the TTS + // span to be non-functional! + return sb.toString().replaceAll(" +", " ").trim(); + } +} diff --git a/java/com/android/contacts/common/compat/TelephonyManagerCompat.java b/java/com/android/contacts/common/compat/TelephonyManagerCompat.java new file mode 100644 index 000000000..c8665af51 --- /dev/null +++ b/java/com/android/contacts/common/compat/TelephonyManagerCompat.java @@ -0,0 +1,213 @@ +/* + * Copyright (C) 2015 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.common.compat; + +import android.net.Uri; +import android.os.Build.VERSION; +import android.os.Build.VERSION_CODES; +import android.support.annotation.Nullable; +import android.telecom.PhoneAccountHandle; +import android.telephony.TelephonyManager; +import com.android.dialer.common.Assert; +import com.android.dialer.common.LogUtil; +import com.android.dialer.compat.CompatUtils; +import java.lang.reflect.InvocationTargetException; + +public class TelephonyManagerCompat { + + // TODO: Use public API for these constants when available + public static final String EVENT_HANDOVER_VIDEO_FROM_WIFI_TO_LTE = + "android.telephony.event.EVENT_HANDOVER_VIDEO_FROM_WIFI_TO_LTE"; + public static final String EVENT_HANDOVER_TO_WIFI_FAILED = + "android.telephony.event.EVENT_HANDOVER_TO_WIFI_FAILED"; + public static final String EVENT_CALL_REMOTELY_HELD = "android.telecom.event.CALL_REMOTELY_HELD"; + public static final String EVENT_CALL_REMOTELY_UNHELD = + "android.telecom.event.CALL_REMOTELY_UNHELD"; + + public static final String TELEPHONY_MANAGER_CLASS = "android.telephony.TelephonyManager"; + + /** + * @param telephonyManager The telephony manager instance to use for method calls. + * @return true if the current device is "voice capable". + * <p>"Voice capable" means that this device supports circuit-switched (i.e. voice) phone + * calls over the telephony network, and is allowed to display the in-call UI while a cellular + * voice call is active. This will be false on "data only" devices which can't make voice + * calls and don't support any in-call UI. + * <p>Note: the meaning of this flag is subtly different from the + * PackageManager.FEATURE_TELEPHONY system feature, which is available on any device with a + * telephony radio, even if the device is data-only. + */ + public static boolean isVoiceCapable(@Nullable TelephonyManager telephonyManager) { + if (telephonyManager == null) { + return false; + } + if (CompatUtils.isLollipopMr1Compatible() + || CompatUtils.isMethodAvailable(TELEPHONY_MANAGER_CLASS, "isVoiceCapable")) { + // isVoiceCapable was unhidden in L-MR1 + return telephonyManager.isVoiceCapable(); + } + final int phoneType = telephonyManager.getPhoneType(); + return phoneType == TelephonyManager.PHONE_TYPE_CDMA + || phoneType == TelephonyManager.PHONE_TYPE_GSM; + } + + /** + * Returns the number of phones available. Returns 1 for Single standby mode (Single SIM + * functionality) Returns 2 for Dual standby mode.(Dual SIM functionality) + * + * <p>Returns 1 if the method or telephonyManager is not available. + * + * @param telephonyManager The telephony manager instance to use for method calls. + */ + public static int getPhoneCount(@Nullable TelephonyManager telephonyManager) { + if (telephonyManager == null) { + return 1; + } + if (CompatUtils.isMarshmallowCompatible() + || CompatUtils.isMethodAvailable(TELEPHONY_MANAGER_CLASS, "getPhoneCount")) { + return telephonyManager.getPhoneCount(); + } + return 1; + } + + /** + * Returns the unique device ID of a subscription, for example, the IMEI for GSM and the MEID for + * CDMA phones. Return null if device ID is not available. + * + * <p>Requires Permission: {@link android.Manifest.permission#READ_PHONE_STATE READ_PHONE_STATE} + * + * @param telephonyManager The telephony manager instance to use for method calls. + * @param slotId of which deviceID is returned + */ + public static String getDeviceId(@Nullable TelephonyManager telephonyManager, int slotId) { + if (telephonyManager == null) { + return null; + } + if (CompatUtils.isMarshmallowCompatible() + || CompatUtils.isMethodAvailable(TELEPHONY_MANAGER_CLASS, "getDeviceId", Integer.class)) { + return telephonyManager.getDeviceId(slotId); + } + return null; + } + + /** + * Whether the phone supports TTY mode. + * + * @param telephonyManager The telephony manager instance to use for method calls. + * @return {@code true} if the device supports TTY mode, and {@code false} otherwise. + */ + public static boolean isTtyModeSupported(@Nullable TelephonyManager telephonyManager) { + if (telephonyManager == null) { + return false; + } + if (CompatUtils.isMarshmallowCompatible() + || CompatUtils.isMethodAvailable(TELEPHONY_MANAGER_CLASS, "isTtyModeSupported")) { + return telephonyManager.isTtyModeSupported(); + } + return false; + } + + /** + * Whether the phone supports hearing aid compatibility. + * + * @param telephonyManager The telephony manager instance to use for method calls. + * @return {@code true} if the device supports hearing aid compatibility, and {@code false} + * otherwise. + */ + public static boolean isHearingAidCompatibilitySupported( + @Nullable TelephonyManager telephonyManager) { + if (telephonyManager == null) { + return false; + } + if (CompatUtils.isMarshmallowCompatible() + || CompatUtils.isMethodAvailable( + TELEPHONY_MANAGER_CLASS, "isHearingAidCompatibilitySupported")) { + return telephonyManager.isHearingAidCompatibilitySupported(); + } + return false; + } + + /** + * Returns the URI for the per-account voicemail ringtone set in Phone settings. + * + * @param telephonyManager The telephony manager instance to use for method calls. + * @param accountHandle The handle for the {@link android.telecom.PhoneAccount} for which to + * retrieve the voicemail ringtone. + * @return The URI for the ringtone to play when receiving a voicemail from a specific + * PhoneAccount. + */ + @Nullable + public static Uri getVoicemailRingtoneUri( + TelephonyManager telephonyManager, PhoneAccountHandle accountHandle) { + if (VERSION.SDK_INT < VERSION_CODES.N) { + return null; + } + return telephonyManager.getVoicemailRingtoneUri(accountHandle); + } + + /** + * Returns whether vibration is set for voicemail notification in Phone settings. + * + * @param telephonyManager The telephony manager instance to use for method calls. + * @param accountHandle The handle for the {@link android.telecom.PhoneAccount} for which to + * retrieve the voicemail vibration setting. + * @return {@code true} if the vibration is set for this PhoneAccount, {@code false} otherwise. + */ + public static boolean isVoicemailVibrationEnabled( + TelephonyManager telephonyManager, PhoneAccountHandle accountHandle) { + return VERSION.SDK_INT < VERSION_CODES.N + || telephonyManager.isVoicemailVibrationEnabled(accountHandle); + } + + /** + * This method uses a new system API to enable or disable visual voicemail. TODO: restrict + * to N MR1, not needed in future SDK. + */ + public static void setVisualVoicemailEnabled( + TelephonyManager telephonyManager, PhoneAccountHandle handle, boolean enabled) { + if (VERSION.SDK_INT < VERSION_CODES.N_MR1) { + Assert.fail("setVisualVoicemailEnabled called on pre-NMR1"); + } + try { + TelephonyManager.class + .getMethod("setVisualVoicemailEnabled", PhoneAccountHandle.class, boolean.class) + .invoke(telephonyManager, handle, enabled); + } catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException e) { + LogUtil.e("TelephonyManagerCompat.setVisualVoicemailEnabled", "failed", e); + } + } + + /** + * This method uses a new system API to check if visual voicemail is enabled TODO: restrict + * to N MR1, not needed in future SDK. + */ + public static boolean isVisualVoicemailEnabled( + TelephonyManager telephonyManager, PhoneAccountHandle handle) { + if (VERSION.SDK_INT < VERSION_CODES.N_MR1) { + Assert.fail("isVisualVoicemailEnabled called on pre-NMR1"); + } + try { + return (boolean) + TelephonyManager.class + .getMethod("isVisualVoicemailEnabled", PhoneAccountHandle.class) + .invoke(telephonyManager, handle); + } catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException e) { + LogUtil.e("TelephonyManagerCompat.setVisualVoicemailEnabled", "failed", e); + } + return false; + } +} diff --git a/java/com/android/contacts/common/compat/telecom/TelecomManagerCompat.java b/java/com/android/contacts/common/compat/telecom/TelecomManagerCompat.java new file mode 100644 index 000000000..5687f6fbf --- /dev/null +++ b/java/com/android/contacts/common/compat/telecom/TelecomManagerCompat.java @@ -0,0 +1,302 @@ +/* + * Copyright (C) 2015 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.common.compat.telecom; + +import android.app.Activity; +import android.content.Intent; +import android.net.Uri; +import android.support.annotation.Nullable; +import android.telecom.PhoneAccount; +import android.telecom.PhoneAccountHandle; +import android.telecom.TelecomManager; +import android.telephony.PhoneNumberUtils; +import android.telephony.TelephonyManager; +import android.text.TextUtils; +import com.android.dialer.compat.CompatUtils; +import java.util.ArrayList; +import java.util.List; + +/** Compatibility class for {@link android.telecom.TelecomManager}. */ +public class TelecomManagerCompat { + + public static final String TELECOM_MANAGER_CLASS = "android.telecom.TelecomManager"; + + // TODO: remove once this is available in android.telecom.Call + // b/33779976 + public static final String EXTRA_LAST_EMERGENCY_CALLBACK_TIME_MILLIS = + "android.telecom.extra.LAST_EMERGENCY_CALLBACK_TIME_MILLIS"; + + /** + * Places a new outgoing call to the provided address using the system telecom service with the + * specified intent. + * + * @param activity {@link Activity} used to start another activity for the given intent + * @param telecomManager the {@link TelecomManager} used to place a call, if possible + * @param intent the intent for the call + */ + public static void placeCall( + @Nullable Activity activity, + @Nullable TelecomManager telecomManager, + @Nullable Intent intent) { + if (activity == null || telecomManager == null || intent == null) { + return; + } + if (CompatUtils.isMarshmallowCompatible()) { + telecomManager.placeCall(intent.getData(), intent.getExtras()); + return; + } + activity.startActivityForResult(intent, 0); + } + + /** + * Get the URI for running an adn query. + * + * @param telecomManager the {@link TelecomManager} used for method calls, if possible. + * @param accountHandle The handle for the account to derive an adn query URI for or {@code null} + * to return a URI which will use the default account. + * @return The URI (with the content:// scheme) specific to the specified {@link PhoneAccount} for + * the the content retrieve. + */ + public static Uri getAdnUriForPhoneAccount( + @Nullable TelecomManager telecomManager, PhoneAccountHandle accountHandle) { + if (telecomManager != null + && (CompatUtils.isMarshmallowCompatible() + || CompatUtils.isMethodAvailable( + TELECOM_MANAGER_CLASS, "getAdnUriForPhoneAccount", PhoneAccountHandle.class))) { + return telecomManager.getAdnUriForPhoneAccount(accountHandle); + } + return Uri.parse("content://icc/adn"); + } + + /** + * Returns a list of {@link PhoneAccountHandle}s which can be used to make and receive phone + * calls. The returned list includes only those accounts which have been explicitly enabled by the + * user. + * + * @param telecomManager the {@link TelecomManager} used for method calls, if possible. + * @return A list of PhoneAccountHandle objects. + */ + public static List<PhoneAccountHandle> getCallCapablePhoneAccounts( + @Nullable TelecomManager telecomManager) { + if (telecomManager != null + && (CompatUtils.isMarshmallowCompatible() + || CompatUtils.isMethodAvailable( + TELECOM_MANAGER_CLASS, "getCallCapablePhoneAccounts"))) { + return telecomManager.getCallCapablePhoneAccounts(); + } + return new ArrayList<>(); + } + + /** + * Used to determine the currently selected default dialer package. + * + * @param telecomManager the {@link TelecomManager} used for method calls, if possible. + * @return package name for the default dialer package or null if no package has been selected as + * the default dialer. + */ + @Nullable + public static String getDefaultDialerPackage(@Nullable TelecomManager telecomManager) { + if (telecomManager != null && CompatUtils.isDefaultDialerCompatible()) { + return telecomManager.getDefaultDialerPackage(); + } + return null; + } + + /** + * Return the {@link PhoneAccount} which will be used to place outgoing calls to addresses with + * the specified {@code uriScheme}. This PhoneAccount will always be a member of the list which is + * returned from invoking {@link TelecomManager#getCallCapablePhoneAccounts()}. The specific + * account returned depends on the following priorities: + * + * <p>1. If the user-selected default PhoneAccount supports the specified scheme, it will be + * returned. 2. If there exists only one PhoneAccount that supports the specified scheme, it will + * be returned. + * + * <p>If no PhoneAccount fits the criteria above, this method will return {@code null}. + * + * @param telecomManager the {@link TelecomManager} used for method calls, if possible. + * @param uriScheme The URI scheme. + * @return The {@link PhoneAccountHandle} corresponding to the account to be used. + */ + @Nullable + public static PhoneAccountHandle getDefaultOutgoingPhoneAccount( + @Nullable TelecomManager telecomManager, @Nullable String uriScheme) { + if (telecomManager != null + && (CompatUtils.isMarshmallowCompatible() + || CompatUtils.isMethodAvailable( + TELECOM_MANAGER_CLASS, "getDefaultOutgoingPhoneAccount", String.class))) { + return telecomManager.getDefaultOutgoingPhoneAccount(uriScheme); + } + return null; + } + + /** + * Return the line 1 phone number for given phone account. + * + * @param telecomManager the {@link TelecomManager} to use in the event that {@link + * TelecomManager#getLine1Number(PhoneAccountHandle)} is available + * @param telephonyManager the {@link TelephonyManager} to use if TelecomManager#getLine1Number is + * unavailable + * @param phoneAccountHandle the phoneAccountHandle upon which to check the line one number + * @return the line one number + */ + @Nullable + public static String getLine1Number( + @Nullable TelecomManager telecomManager, + @Nullable TelephonyManager telephonyManager, + @Nullable PhoneAccountHandle phoneAccountHandle) { + if (telecomManager != null && CompatUtils.isMarshmallowCompatible()) { + return telecomManager.getLine1Number(phoneAccountHandle); + } + if (telephonyManager != null) { + return telephonyManager.getLine1Number(); + } + return null; + } + + /** + * Return whether a given phone number is the configured voicemail number for a particular phone + * account. + * + * @param telecomManager the {@link TelecomManager} to use for checking the number. + * @param accountHandle The handle for the account to check the voicemail number against + * @param number The number to look up. + */ + public static boolean isVoiceMailNumber( + @Nullable TelecomManager telecomManager, + @Nullable PhoneAccountHandle accountHandle, + @Nullable String number) { + if (telecomManager != null + && (CompatUtils.isMarshmallowCompatible() + || CompatUtils.isMethodAvailable( + TELECOM_MANAGER_CLASS, + "isVoiceMailNumber", + PhoneAccountHandle.class, + String.class))) { + return telecomManager.isVoiceMailNumber(accountHandle, number); + } + return PhoneNumberUtils.isVoiceMailNumber(number); + } + + /** + * Return the {@link PhoneAccount} for a specified {@link PhoneAccountHandle}. Object includes + * resources which can be used in a user interface. + * + * @param telecomManager the {@link TelecomManager} used for method calls, if possible. + * @param account The {@link PhoneAccountHandle}. + * @return The {@link PhoneAccount} object or null if it doesn't exist. + */ + @Nullable + public static PhoneAccount getPhoneAccount( + @Nullable TelecomManager telecomManager, @Nullable PhoneAccountHandle accountHandle) { + if (telecomManager != null + && (CompatUtils.isMethodAvailable( + TELECOM_MANAGER_CLASS, "getPhoneAccount", PhoneAccountHandle.class))) { + return telecomManager.getPhoneAccount(accountHandle); + } + return null; + } + + /** + * Return the voicemail number for a given phone account. + * + * @param telecomManager The {@link TelecomManager} object to use for retrieving the voicemail + * number if accountHandle is specified. + * @param telephonyManager The {@link TelephonyManager} object to use for retrieving the voicemail + * number if accountHandle is null. + * @param accountHandle The handle for the phone account. + * @return The voicemail number for the phone account, and {@code null} if one has not been + * configured. + */ + @Nullable + public static String getVoiceMailNumber( + @Nullable TelecomManager telecomManager, + @Nullable TelephonyManager telephonyManager, + @Nullable PhoneAccountHandle accountHandle) { + if (telecomManager != null + && (CompatUtils.isMethodAvailable( + TELECOM_MANAGER_CLASS, "getVoiceMailNumber", PhoneAccountHandle.class))) { + return telecomManager.getVoiceMailNumber(accountHandle); + } else if (telephonyManager != null) { + return telephonyManager.getVoiceMailNumber(); + } + return null; + } + + /** + * Processes the specified dial string as an MMI code. MMI codes are any sequence of characters + * entered into the dialpad that contain a "*" or "#". Some of these sequences launch special + * behavior through handled by Telephony. + * + * @param telecomManager The {@link TelecomManager} object to use for handling MMI. + * @param dialString The digits to dial. + * @return {@code true} if the digits were processed as an MMI code, {@code false} otherwise. + */ + public static boolean handleMmi( + @Nullable TelecomManager telecomManager, + @Nullable String dialString, + @Nullable PhoneAccountHandle accountHandle) { + if (telecomManager == null || TextUtils.isEmpty(dialString)) { + return false; + } + if (CompatUtils.isMarshmallowCompatible()) { + return telecomManager.handleMmi(dialString, accountHandle); + } + + Object handleMmiResult = + CompatUtils.invokeMethod( + telecomManager, + "handleMmi", + new Class<?>[] {PhoneAccountHandle.class, String.class}, + new Object[] {accountHandle, dialString}); + if (handleMmiResult != null) { + return (boolean) handleMmiResult; + } + + return telecomManager.handleMmi(dialString); + } + + /** + * Silences the ringer if a ringing call exists. Noop if {@link TelecomManager#silenceRinger()} is + * unavailable. + * + * @param telecomManager the TelecomManager to use to silence the ringer. + */ + public static void silenceRinger(@Nullable TelecomManager telecomManager) { + if (telecomManager != null + && (CompatUtils.isMarshmallowCompatible() + || CompatUtils.isMethodAvailable(TELECOM_MANAGER_CLASS, "silenceRinger"))) { + telecomManager.silenceRinger(); + } + } + + /** + * Returns the current SIM call manager. Apps must be prepared for this method to return null, + * indicating that there currently exists no registered SIM call manager. + * + * @param telecomManager the {@link TelecomManager} to use to fetch the SIM call manager. + * @return The phone account handle of the current sim call manager. + */ + @Nullable + public static PhoneAccountHandle getSimCallManager(TelecomManager telecomManager) { + if (telecomManager != null + && (CompatUtils.isMarshmallowCompatible() + || CompatUtils.isMethodAvailable(TELECOM_MANAGER_CLASS, "getSimCallManager"))) { + return telecomManager.getSimCallManager(); + } + return null; + } +} diff --git a/java/com/android/contacts/common/database/ContactUpdateUtils.java b/java/com/android/contacts/common/database/ContactUpdateUtils.java new file mode 100644 index 000000000..1a9febc07 --- /dev/null +++ b/java/com/android/contacts/common/database/ContactUpdateUtils.java @@ -0,0 +1,49 @@ +/* + * Copyright (C) 2012 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.common.database; + +import android.content.ContentUris; +import android.content.ContentValues; +import android.content.Context; +import android.provider.ContactsContract; +import android.util.Log; + +/** Static methods to update contact information. */ +public class ContactUpdateUtils { + + private static final String TAG = ContactUpdateUtils.class.getSimpleName(); + + public static void setSuperPrimary(Context context, long dataId) { + if (dataId == -1) { + Log.e(TAG, "Invalid arguments for setSuperPrimary request"); + return; + } + + // Update the primary values in the data record. + ContentValues values = new ContentValues(2); + values.put(ContactsContract.Data.IS_SUPER_PRIMARY, 1); + values.put(ContactsContract.Data.IS_PRIMARY, 1); + + context + .getContentResolver() + .update( + ContentUris.withAppendedId(ContactsContract.Data.CONTENT_URI, dataId), + values, + null, + null); + } +} diff --git a/java/com/android/contacts/common/database/EmptyCursor.java b/java/com/android/contacts/common/database/EmptyCursor.java new file mode 100644 index 000000000..c2b24cdf7 --- /dev/null +++ b/java/com/android/contacts/common/database/EmptyCursor.java @@ -0,0 +1,84 @@ +/* + * Copyright (C) 2012 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.common.database; + +import android.database.AbstractCursor; +import android.database.CursorIndexOutOfBoundsException; + +/** + * A cursor that is empty. + * + * <p>If you want an empty cursor, this class is better than a MatrixCursor because it has less + * overhead. + */ +public final class EmptyCursor extends AbstractCursor { + + private String[] mColumns; + + public EmptyCursor(String[] columns) { + this.mColumns = columns; + } + + @Override + public int getCount() { + return 0; + } + + @Override + public String[] getColumnNames() { + return mColumns; + } + + @Override + public String getString(int column) { + throw cursorException(); + } + + @Override + public short getShort(int column) { + throw cursorException(); + } + + @Override + public int getInt(int column) { + throw cursorException(); + } + + @Override + public long getLong(int column) { + throw cursorException(); + } + + @Override + public float getFloat(int column) { + throw cursorException(); + } + + @Override + public double getDouble(int column) { + throw cursorException(); + } + + @Override + public boolean isNull(int column) { + throw cursorException(); + } + + private CursorIndexOutOfBoundsException cursorException() { + return new CursorIndexOutOfBoundsException("Operation not permitted on an empty cursor."); + } +} diff --git a/java/com/android/contacts/common/database/NoNullCursorAsyncQueryHandler.java b/java/com/android/contacts/common/database/NoNullCursorAsyncQueryHandler.java new file mode 100644 index 000000000..d5e61354a --- /dev/null +++ b/java/com/android/contacts/common/database/NoNullCursorAsyncQueryHandler.java @@ -0,0 +1,73 @@ +/* + * Copyright (C) 2012 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.common.database; + +import android.content.AsyncQueryHandler; +import android.content.ContentResolver; +import android.database.Cursor; +import android.net.Uri; + +/** + * An {@AsyncQueryHandler} that will never return a null cursor. + * + * <p>Instead, will return a {@link Cursor} with 0 records. + */ +public abstract class NoNullCursorAsyncQueryHandler extends AsyncQueryHandler { + + public NoNullCursorAsyncQueryHandler(ContentResolver cr) { + super(cr); + } + + @Override + public void startQuery( + int token, + Object cookie, + Uri uri, + String[] projection, + String selection, + String[] selectionArgs, + String orderBy) { + final CookieWithProjection projectionCookie = new CookieWithProjection(cookie, projection); + super.startQuery(token, projectionCookie, uri, projection, selection, selectionArgs, orderBy); + } + + @Override + protected final void onQueryComplete(int token, Object cookie, Cursor cursor) { + CookieWithProjection projectionCookie = (CookieWithProjection) cookie; + + super.onQueryComplete(token, projectionCookie.originalCookie, cursor); + + if (cursor == null) { + cursor = new EmptyCursor(projectionCookie.projection); + } + onNotNullableQueryComplete(token, projectionCookie.originalCookie, cursor); + } + + protected abstract void onNotNullableQueryComplete(int token, Object cookie, Cursor cursor); + + /** Class to add projection to an existing cookie. */ + private static class CookieWithProjection { + + public final Object originalCookie; + public final String[] projection; + + public CookieWithProjection(Object cookie, String[] projection) { + this.originalCookie = cookie; + this.projection = projection; + } + } +} diff --git a/java/com/android/contacts/common/dialog/CallSubjectDialog.java b/java/com/android/contacts/common/dialog/CallSubjectDialog.java new file mode 100644 index 000000000..d2e3a2357 --- /dev/null +++ b/java/com/android/contacts/common/dialog/CallSubjectDialog.java @@ -0,0 +1,607 @@ +/* + * Copyright (C) 2015 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.common.dialog; + +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.net.Uri; +import android.os.Build.VERSION; +import android.os.Build.VERSION_CODES; +import android.os.Bundle; +import android.preference.PreferenceManager; +import android.telecom.PhoneAccount; +import android.telecom.PhoneAccountHandle; +import android.telecom.TelecomManager; +import android.text.Editable; +import android.text.InputFilter; +import android.text.TextUtils; +import android.text.TextWatcher; +import android.view.View; +import android.view.ViewGroup; +import android.view.inputmethod.InputMethodManager; +import android.widget.AdapterView; +import android.widget.ArrayAdapter; +import android.widget.EditText; +import android.widget.ListView; +import android.widget.QuickContactBadge; +import android.widget.TextView; +import com.android.contacts.common.ContactPhotoManager; +import com.android.contacts.common.R; +import com.android.contacts.common.compat.telecom.TelecomManagerCompat; +import com.android.contacts.common.util.UriUtils; +import com.android.dialer.animation.AnimUtils; +import com.android.dialer.callintent.CallIntentBuilder; +import com.android.dialer.callintent.nano.CallInitiationType; +import com.android.dialer.common.LogUtil; +import com.android.dialer.compat.CompatUtils; +import com.android.dialer.util.ViewUtil; +import java.nio.charset.Charset; +import java.util.ArrayList; +import java.util.List; + +/** + * Implements a dialog which prompts for a call subject for an outgoing call. The dialog includes a + * pop up list of historical call subjects. + */ +public class CallSubjectDialog extends Activity { + + public static final String PREF_KEY_SUBJECT_HISTORY_COUNT = "subject_history_count"; + public static final String PREF_KEY_SUBJECT_HISTORY_ITEM = "subject_history_item"; + /** Activity intent argument bundle keys: */ + public static final String ARG_PHOTO_ID = "PHOTO_ID"; + + public static final String ARG_PHOTO_URI = "PHOTO_URI"; + public static final String ARG_CONTACT_URI = "CONTACT_URI"; + public static final String ARG_NAME_OR_NUMBER = "NAME_OR_NUMBER"; + public static final String ARG_IS_BUSINESS = "IS_BUSINESS"; + public static final String ARG_NUMBER = "NUMBER"; + public static final String ARG_DISPLAY_NUMBER = "DISPLAY_NUMBER"; + public static final String ARG_NUMBER_LABEL = "NUMBER_LABEL"; + public static final String ARG_PHONE_ACCOUNT_HANDLE = "PHONE_ACCOUNT_HANDLE"; + private static final int CALL_SUBJECT_LIMIT = 16; + private static final int CALL_SUBJECT_HISTORY_SIZE = 5; + private int mAnimationDuration; + private Charset mMessageEncoding; + private View mBackgroundView; + private View mDialogView; + private QuickContactBadge mContactPhoto; + private TextView mNameView; + private TextView mNumberView; + private EditText mCallSubjectView; + private TextView mCharacterLimitView; + private View mHistoryButton; + private View mSendAndCallButton; + private ListView mSubjectList; + + private int mLimit = CALL_SUBJECT_LIMIT; + /** Handles changes to the text in the subject box. Ensures the character limit is updated. */ + private final TextWatcher mTextWatcher = + new TextWatcher() { + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) { + // no-op + } + + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) { + updateCharacterLimit(); + } + + @Override + public void afterTextChanged(Editable s) { + // no-op + } + }; + + private int mPhotoSize; + private SharedPreferences mPrefs; + private List<String> mSubjectHistory; + /** Handles displaying the list of past call subjects. */ + private final View.OnClickListener mHistoryOnClickListener = + new View.OnClickListener() { + @Override + public void onClick(View v) { + hideSoftKeyboard(CallSubjectDialog.this, mCallSubjectView); + showCallHistory(mSubjectList.getVisibility() == View.GONE); + } + }; + /** + * Handles auto-hiding the call history when user clicks in the call subject field to give it + * focus. + */ + private final View.OnClickListener mCallSubjectClickListener = + new View.OnClickListener() { + @Override + public void onClick(View v) { + if (mSubjectList.getVisibility() == View.VISIBLE) { + showCallHistory(false); + } + } + }; + + private long mPhotoID; + private Uri mPhotoUri; + private Uri mContactUri; + private String mNameOrNumber; + private boolean mIsBusiness; + private String mNumber; + private String mDisplayNumber; + private String mNumberLabel; + private PhoneAccountHandle mPhoneAccountHandle; + /** Handles starting a call with a call subject specified. */ + private final View.OnClickListener mSendAndCallOnClickListener = + new View.OnClickListener() { + @Override + public void onClick(View v) { + String subject = mCallSubjectView.getText().toString(); + Intent intent = + new CallIntentBuilder(mNumber, CallInitiationType.Type.CALL_SUBJECT_DIALOG) + .setPhoneAccountHandle(mPhoneAccountHandle) + .setCallSubject(subject) + .build(); + + TelecomManagerCompat.placeCall( + CallSubjectDialog.this, + (TelecomManager) getSystemService(Context.TELECOM_SERVICE), + intent); + + mSubjectHistory.add(subject); + saveSubjectHistory(mSubjectHistory); + finish(); + } + }; + /** Click listener which handles user clicks outside of the dialog. */ + private View.OnClickListener mBackgroundListener = + new View.OnClickListener() { + @Override + public void onClick(View v) { + finish(); + } + }; + /** + * Item click listener which handles user clicks on the items in the list view. Dismisses the + * activity, returning the subject to the caller and closing the activity with the {@link + * Activity#RESULT_OK} result code. + */ + private AdapterView.OnItemClickListener mItemClickListener = + new AdapterView.OnItemClickListener() { + @Override + public void onItemClick(AdapterView<?> arg0, View view, int position, long arg3) { + mCallSubjectView.setText(mSubjectHistory.get(position)); + showCallHistory(false); + } + }; + + /** + * Show the call subject dialog given a phone number to dial (e.g. from the dialpad). + * + * @param activity The activity. + * @param number The number to dial. + */ + public static void start(Activity activity, String number) { + start( + activity, + -1 /* photoId */, + null /* photoUri */, + null /* contactUri */, + number /* nameOrNumber */, + false /* isBusiness */, + number /* number */, + null /* displayNumber */, + null /* numberLabel */, + null /* phoneAccountHandle */); + } + + /** + * Creates a call subject dialog. + * + * @param activity The current activity. + * @param photoId The photo ID (used to populate contact photo). + * @param photoUri The photo Uri (used to populate contact photo). + * @param contactUri The Contact URI (used so quick contact can be invoked from contact photo). + * @param nameOrNumber The name or number of the callee. + * @param isBusiness {@code true} if a business is being called (used for contact photo). + * @param number The raw number to dial. + * @param displayNumber The number to dial, formatted for display. + * @param numberLabel The label for the number (if from a contact). + * @param phoneAccountHandle The phone account handle. + */ + public static void start( + Activity activity, + long photoId, + Uri photoUri, + Uri contactUri, + String nameOrNumber, + boolean isBusiness, + String number, + String displayNumber, + String numberLabel, + PhoneAccountHandle phoneAccountHandle) { + Bundle arguments = new Bundle(); + arguments.putLong(ARG_PHOTO_ID, photoId); + arguments.putParcelable(ARG_PHOTO_URI, photoUri); + arguments.putParcelable(ARG_CONTACT_URI, contactUri); + arguments.putString(ARG_NAME_OR_NUMBER, nameOrNumber); + arguments.putBoolean(ARG_IS_BUSINESS, isBusiness); + arguments.putString(ARG_NUMBER, number); + arguments.putString(ARG_DISPLAY_NUMBER, displayNumber); + arguments.putString(ARG_NUMBER_LABEL, numberLabel); + arguments.putParcelable(ARG_PHONE_ACCOUNT_HANDLE, phoneAccountHandle); + start(activity, arguments); + } + + /** + * Shows the call subject dialog given a Bundle containing all the arguments required to display + * the dialog (e.g. from Quick Contacts). + * + * @param activity The activity. + * @param arguments The arguments bundle. + */ + public static void start(Activity activity, Bundle arguments) { + Intent intent = new Intent(activity, CallSubjectDialog.class); + intent.putExtras(arguments); + activity.startActivity(intent); + } + + /** + * Loads the subject history from shared preferences. + * + * @param prefs Shared preferences. + * @return List of subject history strings. + */ + public static List<String> loadSubjectHistory(SharedPreferences prefs) { + int historySize = prefs.getInt(PREF_KEY_SUBJECT_HISTORY_COUNT, 0); + List<String> subjects = new ArrayList(historySize); + + for (int ix = 0; ix < historySize; ix++) { + String historyItem = prefs.getString(PREF_KEY_SUBJECT_HISTORY_ITEM + ix, null); + if (!TextUtils.isEmpty(historyItem)) { + subjects.add(historyItem); + } + } + + return subjects; + } + + /** + * Creates the dialog, inflating the layout and populating it with the name and phone number. + * + * @param savedInstanceState The last saved instance state of the Fragment, or null if this is a + * freshly created Fragment. + * @return Dialog instance. + */ + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + mAnimationDuration = getResources().getInteger(R.integer.call_subject_animation_duration); + mPrefs = PreferenceManager.getDefaultSharedPreferences(this); + mPhotoSize = + getResources().getDimensionPixelSize(R.dimen.call_subject_dialog_contact_photo_size); + readArguments(); + loadConfiguration(); + mSubjectHistory = loadSubjectHistory(mPrefs); + + setContentView(R.layout.dialog_call_subject); + getWindow().setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT); + mBackgroundView = findViewById(R.id.call_subject_dialog); + mBackgroundView.setOnClickListener(mBackgroundListener); + mDialogView = findViewById(R.id.dialog_view); + mContactPhoto = (QuickContactBadge) findViewById(R.id.contact_photo); + mNameView = (TextView) findViewById(R.id.name); + mNumberView = (TextView) findViewById(R.id.number); + mCallSubjectView = (EditText) findViewById(R.id.call_subject); + mCallSubjectView.addTextChangedListener(mTextWatcher); + mCallSubjectView.setOnClickListener(mCallSubjectClickListener); + InputFilter[] filters = new InputFilter[1]; + filters[0] = new InputFilter.LengthFilter(mLimit); + mCallSubjectView.setFilters(filters); + mCharacterLimitView = (TextView) findViewById(R.id.character_limit); + mHistoryButton = findViewById(R.id.history_button); + mHistoryButton.setOnClickListener(mHistoryOnClickListener); + mHistoryButton.setVisibility(mSubjectHistory.isEmpty() ? View.GONE : View.VISIBLE); + mSendAndCallButton = findViewById(R.id.send_and_call_button); + mSendAndCallButton.setOnClickListener(mSendAndCallOnClickListener); + mSubjectList = (ListView) findViewById(R.id.subject_list); + mSubjectList.setOnItemClickListener(mItemClickListener); + mSubjectList.setVisibility(View.GONE); + + updateContactInfo(); + updateCharacterLimit(); + } + + /** Populates the contact info fields based on the current contact information. */ + private void updateContactInfo() { + if (mContactUri != null) { + setPhoto(mPhotoID, mPhotoUri, mContactUri, mNameOrNumber, mIsBusiness); + } else { + mContactPhoto.setVisibility(View.GONE); + } + mNameView.setText(mNameOrNumber); + if (!TextUtils.isEmpty(mNumberLabel) && !TextUtils.isEmpty(mDisplayNumber)) { + mNumberView.setVisibility(View.VISIBLE); + mNumberView.setText( + getString(R.string.call_subject_type_and_number, mNumberLabel, mDisplayNumber)); + } else { + mNumberView.setVisibility(View.GONE); + mNumberView.setText(null); + } + } + + /** Reads arguments from the fragment arguments and populates the necessary instance variables. */ + private void readArguments() { + Bundle arguments = getIntent().getExtras(); + if (arguments == null) { + LogUtil.e("CallSubjectDialog.readArguments", "arguments cannot be null"); + return; + } + mPhotoID = arguments.getLong(ARG_PHOTO_ID); + mPhotoUri = arguments.getParcelable(ARG_PHOTO_URI); + mContactUri = arguments.getParcelable(ARG_CONTACT_URI); + mNameOrNumber = arguments.getString(ARG_NAME_OR_NUMBER); + mIsBusiness = arguments.getBoolean(ARG_IS_BUSINESS); + mNumber = arguments.getString(ARG_NUMBER); + mDisplayNumber = arguments.getString(ARG_DISPLAY_NUMBER); + mNumberLabel = arguments.getString(ARG_NUMBER_LABEL); + mPhoneAccountHandle = arguments.getParcelable(ARG_PHONE_ACCOUNT_HANDLE); + } + + /** + * Updates the character limit display, coloring the text RED when the limit is reached or + * exceeded. + */ + private void updateCharacterLimit() { + String subjectText = mCallSubjectView.getText().toString(); + final int length; + + // If a message encoding is specified, use that to count bytes in the message. + if (mMessageEncoding != null) { + length = subjectText.getBytes(mMessageEncoding).length; + } else { + // No message encoding specified, so just count characters entered. + length = subjectText.length(); + } + + mCharacterLimitView.setText(getString(R.string.call_subject_limit, length, mLimit)); + if (length >= mLimit) { + mCharacterLimitView.setTextColor( + getResources().getColor(R.color.call_subject_limit_exceeded)); + } else { + mCharacterLimitView.setTextColor( + getResources().getColor(R.color.dialer_secondary_text_color)); + } + } + + /** Sets the photo on the quick contact photo. */ + private void setPhoto( + long photoId, Uri photoUri, Uri contactUri, String displayName, boolean isBusiness) { + mContactPhoto.assignContactUri(contactUri); + if (CompatUtils.isLollipopCompatible()) { + mContactPhoto.setOverlay(null); + } + + int contactType; + if (isBusiness) { + contactType = ContactPhotoManager.TYPE_BUSINESS; + } else { + contactType = ContactPhotoManager.TYPE_DEFAULT; + } + + String lookupKey = null; + if (contactUri != null) { + lookupKey = UriUtils.getLookupKeyFromUri(contactUri); + } + + ContactPhotoManager.DefaultImageRequest request = + new ContactPhotoManager.DefaultImageRequest( + displayName, lookupKey, contactType, true /* isCircular */); + + if (photoId == 0 && photoUri != null) { + ContactPhotoManager.getInstance(this) + .loadPhoto( + mContactPhoto, + photoUri, + mPhotoSize, + false /* darkTheme */, + true /* isCircular */, + request); + } else { + ContactPhotoManager.getInstance(this) + .loadThumbnail( + mContactPhoto, photoId, false /* darkTheme */, true /* isCircular */, request); + } + } + + /** + * Saves the subject history list to shared prefs, removing older items so that there are only + * {@link #CALL_SUBJECT_HISTORY_SIZE} items at most. + * + * @param history The history. + */ + private void saveSubjectHistory(List<String> history) { + // Remove oldest subject(s). + while (history.size() > CALL_SUBJECT_HISTORY_SIZE) { + history.remove(0); + } + + SharedPreferences.Editor editor = mPrefs.edit(); + int historyCount = 0; + for (String subject : history) { + if (!TextUtils.isEmpty(subject)) { + editor.putString(PREF_KEY_SUBJECT_HISTORY_ITEM + historyCount, subject); + historyCount++; + } + } + editor.putInt(PREF_KEY_SUBJECT_HISTORY_COUNT, historyCount); + editor.apply(); + } + + /** Hide software keyboard for the given {@link View}. */ + public void hideSoftKeyboard(Context context, View view) { + InputMethodManager imm = + (InputMethodManager) context.getSystemService(Context.INPUT_METHOD_SERVICE); + if (imm != null) { + imm.hideSoftInputFromWindow(view.getWindowToken(), InputMethodManager.HIDE_NOT_ALWAYS); + } + } + + /** + * Hides or shows the call history list. + * + * @param show {@code true} if the call history should be shown, {@code false} otherwise. + */ + private void showCallHistory(final boolean show) { + // Bail early if the visibility has not changed. + if ((show && mSubjectList.getVisibility() == View.VISIBLE) + || (!show && mSubjectList.getVisibility() == View.GONE)) { + return; + } + + final int dialogStartingBottom = mDialogView.getBottom(); + if (show) { + // Showing the subject list; bind the list of history items to the list and show it. + ArrayAdapter<String> adapter = + new ArrayAdapter<String>( + CallSubjectDialog.this, R.layout.call_subject_history_list_item, mSubjectHistory); + mSubjectList.setAdapter(adapter); + mSubjectList.setVisibility(View.VISIBLE); + } else { + // Hiding the subject list. + mSubjectList.setVisibility(View.GONE); + } + + // Use a ViewTreeObserver so that we can animate between the pre-layout and post-layout + // states. + ViewUtil.doOnPreDraw( + mBackgroundView, + true, + new Runnable() { + @Override + public void run() { + // Determine the amount the dialog has shifted due to the relayout. + int shiftAmount = dialogStartingBottom - mDialogView.getBottom(); + + // If the dialog needs to be shifted, do that now. + if (shiftAmount != 0) { + // Start animation in translated state and animate to translationY 0. + mDialogView.setTranslationY(shiftAmount); + mDialogView + .animate() + .translationY(0) + .setInterpolator(AnimUtils.EASE_OUT_EASE_IN) + .setDuration(mAnimationDuration) + .start(); + } + + if (show) { + // Show the subject list. + mSubjectList.setTranslationY(mSubjectList.getHeight()); + + mSubjectList + .animate() + .translationY(0) + .setInterpolator(AnimUtils.EASE_OUT_EASE_IN) + .setDuration(mAnimationDuration) + .setListener( + new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(Animator animation) { + super.onAnimationEnd(animation); + } + + @Override + public void onAnimationStart(Animator animation) { + super.onAnimationStart(animation); + mSubjectList.setVisibility(View.VISIBLE); + } + }) + .start(); + } else { + // Hide the subject list. + mSubjectList.setTranslationY(0); + + mSubjectList + .animate() + .translationY(mSubjectList.getHeight()) + .setInterpolator(AnimUtils.EASE_OUT_EASE_IN) + .setDuration(mAnimationDuration) + .setListener( + new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(Animator animation) { + super.onAnimationEnd(animation); + mSubjectList.setVisibility(View.GONE); + } + + @Override + public void onAnimationStart(Animator animation) { + super.onAnimationStart(animation); + } + }) + .start(); + } + } + }); + } + + /** + * Loads the message encoding and maximum message length from the phone account extras for the + * current phone account. + */ + private void loadConfiguration() { + // Only attempt to load configuration from the phone account extras if the SDK is N or + // later. If we've got a prior SDK the default encoding and message length will suffice. + if (VERSION.SDK_INT < VERSION_CODES.N) { + return; + } + + if (mPhoneAccountHandle == null) { + return; + } + + TelecomManager telecomManager = (TelecomManager) getSystemService(Context.TELECOM_SERVICE); + final PhoneAccount account = telecomManager.getPhoneAccount(mPhoneAccountHandle); + + Bundle phoneAccountExtras = account.getExtras(); + if (phoneAccountExtras == null) { + return; + } + + // Get limit, if provided; otherwise default to existing value. + mLimit = phoneAccountExtras.getInt(PhoneAccount.EXTRA_CALL_SUBJECT_MAX_LENGTH, mLimit); + + // Get charset; default to none (e.g. count characters 1:1). + String charsetName = + phoneAccountExtras.getString(PhoneAccount.EXTRA_CALL_SUBJECT_CHARACTER_ENCODING); + + if (!TextUtils.isEmpty(charsetName)) { + try { + mMessageEncoding = Charset.forName(charsetName); + } catch (java.nio.charset.UnsupportedCharsetException uce) { + // Character set was invalid; log warning and fallback to none. + LogUtil.e("CallSubjectDialog.loadConfiguration", "invalid charset: " + charsetName); + mMessageEncoding = null; + } + } else { + // No character set specified, so count characters 1:1. + mMessageEncoding = null; + } + } +} diff --git a/java/com/android/contacts/common/dialog/ClearFrequentsDialog.java b/java/com/android/contacts/common/dialog/ClearFrequentsDialog.java new file mode 100644 index 000000000..e96496cda --- /dev/null +++ b/java/com/android/contacts/common/dialog/ClearFrequentsDialog.java @@ -0,0 +1,88 @@ +/* + * Copyright (C) 2012 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.common.dialog; + +import android.app.AlertDialog; +import android.app.Dialog; +import android.app.DialogFragment; +import android.app.FragmentManager; +import android.app.ProgressDialog; +import android.content.ContentResolver; +import android.content.Context; +import android.content.DialogInterface; +import android.content.DialogInterface.OnClickListener; +import android.os.AsyncTask; +import android.os.Bundle; +import android.provider.ContactsContract; +import com.android.contacts.common.R; +import com.android.dialer.util.PermissionsUtil; + +/** Dialog that clears the frequently contacted list after confirming with the user. */ +public class ClearFrequentsDialog extends DialogFragment { + + /** Preferred way to show this dialog */ + public static void show(FragmentManager fragmentManager) { + ClearFrequentsDialog dialog = new ClearFrequentsDialog(); + dialog.show(fragmentManager, "clearFrequents"); + } + + @Override + public Dialog onCreateDialog(Bundle savedInstanceState) { + final Context context = getActivity().getApplicationContext(); + final ContentResolver resolver = getActivity().getContentResolver(); + final OnClickListener okListener = + new OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + if (!PermissionsUtil.hasContactsPermissions(context)) { + return; + } + + final ProgressDialog progressDialog = + ProgressDialog.show( + getContext(), + getString(R.string.clearFrequentsProgress_title), + null, + true, + true); + + final AsyncTask<Void, Void, Void> task = + new AsyncTask<Void, Void, Void>() { + @Override + protected Void doInBackground(Void... params) { + resolver.delete( + ContactsContract.DataUsageFeedback.DELETE_USAGE_URI, null, null); + return null; + } + + @Override + protected void onPostExecute(Void result) { + progressDialog.dismiss(); + } + }; + task.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + } + }; + return new AlertDialog.Builder(getActivity()) + .setTitle(R.string.clearFrequentsConfirmation_title) + .setMessage(R.string.clearFrequentsConfirmation) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(android.R.string.ok, okListener) + .setCancelable(true) + .create(); + } +} diff --git a/java/com/android/contacts/common/extensions/PhoneDirectoryExtender.java b/java/com/android/contacts/common/extensions/PhoneDirectoryExtender.java new file mode 100644 index 000000000..2607ad19a --- /dev/null +++ b/java/com/android/contacts/common/extensions/PhoneDirectoryExtender.java @@ -0,0 +1,28 @@ +/* + * 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.common.extensions; + +import android.content.Context; +import com.android.contacts.common.list.DirectoryPartition; +import java.util.List; + +/** An interface for adding extended phone directories. */ +public interface PhoneDirectoryExtender { + /** + * Return a list of extended directories to add. May return null if no directories are to be + * added. + */ + List<DirectoryPartition> getExtendedDirectories(Context context); +} diff --git a/java/com/android/contacts/common/extensions/PhoneDirectoryExtenderAccessor.java b/java/com/android/contacts/common/extensions/PhoneDirectoryExtenderAccessor.java new file mode 100644 index 000000000..84649f1ed --- /dev/null +++ b/java/com/android/contacts/common/extensions/PhoneDirectoryExtenderAccessor.java @@ -0,0 +1,45 @@ +/* + * 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.common.extensions; + +import android.content.Context; +import android.support.annotation.NonNull; +import com.android.dialer.common.Assert; + +/** Accessor for the phone directory extender singleton. */ +public final class PhoneDirectoryExtenderAccessor { + + private static PhoneDirectoryExtender instance; + + private PhoneDirectoryExtenderAccessor() {} + + @NonNull + public static PhoneDirectoryExtender get(@NonNull Context context) { + Assert.isNotNull(context); + if (instance != null) { + return instance; + } + + Context application = context.getApplicationContext(); + if (application instanceof PhoneDirectoryExtenderFactory) { + instance = ((PhoneDirectoryExtenderFactory) application).newPhoneDirectoryExtender(); + } + + if (instance == null) { + instance = new PhoneDirectoryExtenderStub(); + } + return instance; + } +} diff --git a/java/com/android/contacts/common/extensions/PhoneDirectoryExtenderFactory.java b/java/com/android/contacts/common/extensions/PhoneDirectoryExtenderFactory.java new file mode 100644 index 000000000..9750ee300 --- /dev/null +++ b/java/com/android/contacts/common/extensions/PhoneDirectoryExtenderFactory.java @@ -0,0 +1,27 @@ +/* + * 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.common.extensions; + +import android.support.annotation.NonNull; + +/** + * This interface should be implemented by the Application subclass. It allows the contacts module + * to get references to the PhoneDirectoryExtender. + */ +public interface PhoneDirectoryExtenderFactory { + + @NonNull + PhoneDirectoryExtender newPhoneDirectoryExtender(); +} diff --git a/java/com/android/contacts/common/extensions/PhoneDirectoryExtenderStub.java b/java/com/android/contacts/common/extensions/PhoneDirectoryExtenderStub.java new file mode 100644 index 000000000..95f971533 --- /dev/null +++ b/java/com/android/contacts/common/extensions/PhoneDirectoryExtenderStub.java @@ -0,0 +1,29 @@ +/* + * 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.common.extensions; + +import android.content.Context; +import com.android.contacts.common.list.DirectoryPartition; +import java.util.Collections; +import java.util.List; + +/** No-op implementation for phone directory extender. */ +class PhoneDirectoryExtenderStub implements PhoneDirectoryExtender { + + @Override + public List<DirectoryPartition> getExtendedDirectories(Context context) { + return Collections.emptyList(); + } +} diff --git a/java/com/android/contacts/common/format/FormatUtils.java b/java/com/android/contacts/common/format/FormatUtils.java new file mode 100644 index 000000000..727c15b83 --- /dev/null +++ b/java/com/android/contacts/common/format/FormatUtils.java @@ -0,0 +1,181 @@ +/* + * Copyright (C) 2011 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.common.format; + +import android.database.CharArrayBuffer; +import android.graphics.Typeface; +import android.support.annotation.VisibleForTesting; +import android.text.SpannableString; +import android.text.style.StyleSpan; +import java.util.Arrays; + +/** Assorted utility methods related to text formatting in Contacts. */ +public class FormatUtils { + + /** + * Finds the earliest point in buffer1 at which the first part of buffer2 matches. For example, + * overlapPoint("abcd", "cdef") == 2. + */ + public static int overlapPoint(CharArrayBuffer buffer1, CharArrayBuffer buffer2) { + if (buffer1 == null || buffer2 == null) { + return -1; + } + return overlapPoint( + Arrays.copyOfRange(buffer1.data, 0, buffer1.sizeCopied), + Arrays.copyOfRange(buffer2.data, 0, buffer2.sizeCopied)); + } + + /** + * Finds the earliest point in string1 at which the first part of string2 matches. For example, + * overlapPoint("abcd", "cdef") == 2. + */ + @VisibleForTesting + public static int overlapPoint(String string1, String string2) { + if (string1 == null || string2 == null) { + return -1; + } + return overlapPoint(string1.toCharArray(), string2.toCharArray()); + } + + /** + * Finds the earliest point in array1 at which the first part of array2 matches. For example, + * overlapPoint("abcd", "cdef") == 2. + */ + public static int overlapPoint(char[] array1, char[] array2) { + if (array1 == null || array2 == null) { + return -1; + } + int count1 = array1.length; + int count2 = array2.length; + + // Ignore matching tails of the two arrays. + while (count1 > 0 && count2 > 0 && array1[count1 - 1] == array2[count2 - 1]) { + count1--; + count2--; + } + + int size = count2; + for (int i = 0; i < count1; i++) { + if (i + size > count1) { + size = count1 - i; + } + int j; + for (j = 0; j < size; j++) { + if (array1[i + j] != array2[j]) { + break; + } + } + if (j == size) { + return i; + } + } + + return -1; + } + + /** + * Applies the given style to a range of the input CharSequence. + * + * @param style The style to apply (see the style constants in {@link Typeface}). + * @param input The CharSequence to style. + * @param start Starting index of the range to style (will be clamped to be a minimum of 0). + * @param end Ending index of the range to style (will be clamped to a maximum of the input + * length). + * @param flags Bitmask for configuring behavior of the span. See {@link android.text.Spanned}. + * @return The styled CharSequence. + */ + public static CharSequence applyStyleToSpan( + int style, CharSequence input, int start, int end, int flags) { + // Enforce bounds of the char sequence. + start = Math.max(0, start); + end = Math.min(input.length(), end); + SpannableString text = new SpannableString(input); + text.setSpan(new StyleSpan(style), start, end, flags); + return text; + } + + @VisibleForTesting + public static void copyToCharArrayBuffer(String text, CharArrayBuffer buffer) { + if (text != null) { + char[] data = buffer.data; + if (data == null || data.length < text.length()) { + buffer.data = text.toCharArray(); + } else { + text.getChars(0, text.length(), data, 0); + } + buffer.sizeCopied = text.length(); + } else { + buffer.sizeCopied = 0; + } + } + + /** Returns a String that represents the content of the given {@link CharArrayBuffer}. */ + @VisibleForTesting + public static String charArrayBufferToString(CharArrayBuffer buffer) { + return new String(buffer.data, 0, buffer.sizeCopied); + } + + /** + * Finds the index of the first word that starts with the given prefix. + * + * <p>If not found, returns -1. + * + * @param text the text in which to search for the prefix + * @param prefix the text to find, in upper case letters + */ + public static int indexOfWordPrefix(CharSequence text, String prefix) { + if (prefix == null || text == null) { + return -1; + } + + int textLength = text.length(); + int prefixLength = prefix.length(); + + if (prefixLength == 0 || textLength < prefixLength) { + return -1; + } + + int i = 0; + while (i < textLength) { + // Skip non-word characters + while (i < textLength && !Character.isLetterOrDigit(text.charAt(i))) { + i++; + } + + if (i + prefixLength > textLength) { + return -1; + } + + // Compare the prefixes + int j; + for (j = 0; j < prefixLength; j++) { + if (Character.toUpperCase(text.charAt(i + j)) != prefix.charAt(j)) { + break; + } + } + if (j == prefixLength) { + return i; + } + + // Skip this word + while (i < textLength && Character.isLetterOrDigit(text.charAt(i))) { + i++; + } + } + + return -1; + } +} diff --git a/java/com/android/contacts/common/format/TextHighlighter.java b/java/com/android/contacts/common/format/TextHighlighter.java new file mode 100644 index 000000000..30c03fdf3 --- /dev/null +++ b/java/com/android/contacts/common/format/TextHighlighter.java @@ -0,0 +1,93 @@ +/* + * Copyright (C) 2011 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.common.format; + +import android.text.SpannableString; +import android.text.style.CharacterStyle; +import android.text.style.StyleSpan; +import android.widget.TextView; + +/** Highlights the text in a text field. */ +public class TextHighlighter { + + private static final boolean DEBUG = false; + private final String TAG = TextHighlighter.class.getSimpleName(); + private int mTextStyle; + + private CharacterStyle mTextStyleSpan; + + public TextHighlighter(int textStyle) { + mTextStyle = textStyle; + mTextStyleSpan = getStyleSpan(); + } + + /** + * Sets the text on the given text view, highlighting the word that matches the given prefix. + * + * @param view the view on which to set the text + * @param text the string to use as the text + * @param prefix the prefix to look for + */ + public void setPrefixText(TextView view, String text, String prefix) { + view.setText(applyPrefixHighlight(text, prefix)); + } + + private CharacterStyle getStyleSpan() { + return new StyleSpan(mTextStyle); + } + + /** + * Applies highlight span to the text. + * + * @param text Text sequence to be highlighted. + * @param start Start position of the highlight sequence. + * @param end End position of the highlight sequence. + */ + public void applyMaskingHighlight(SpannableString text, int start, int end) { + /** Sets text color of the masked locations to be highlighted. */ + text.setSpan(getStyleSpan(), start, end, 0); + } + + /** + * Returns a CharSequence which highlights the given prefix if found in the given text. + * + * @param text the text to which to apply the highlight + * @param prefix the prefix to look for + */ + public CharSequence applyPrefixHighlight(CharSequence text, String prefix) { + if (prefix == null) { + return text; + } + + // Skip non-word characters at the beginning of prefix. + int prefixStart = 0; + while (prefixStart < prefix.length() + && !Character.isLetterOrDigit(prefix.charAt(prefixStart))) { + prefixStart++; + } + final String trimmedPrefix = prefix.substring(prefixStart); + + int index = FormatUtils.indexOfWordPrefix(text, trimmedPrefix); + if (index != -1) { + final SpannableString result = new SpannableString(text); + result.setSpan(mTextStyleSpan, index, index + trimmedPrefix.length(), 0 /* flags */); + return result; + } else { + return text; + } + } +} diff --git a/java/com/android/contacts/common/format/testing/SpannedTestUtils.java b/java/com/android/contacts/common/format/testing/SpannedTestUtils.java new file mode 100644 index 000000000..293d9d5ad --- /dev/null +++ b/java/com/android/contacts/common/format/testing/SpannedTestUtils.java @@ -0,0 +1,85 @@ +/* + * Copyright (C) 2011 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.common.format.testing; + +import android.test.suitebuilder.annotation.SmallTest; +import android.text.Html; +import android.text.SpannableString; +import android.text.Spanned; +import android.text.TextUtils; +import android.text.style.StyleSpan; +import android.widget.TextView; +import junit.framework.Assert; + +/** Utility class to check the value of spanned text in text views. */ +@SmallTest +public class SpannedTestUtils { + + /** + * Checks that the text contained in the text view matches the given HTML text. + * + * @param expectedHtmlText the expected text to be in the text view + * @param textView the text view from which to get the text + */ + public static void checkHtmlText(String expectedHtmlText, TextView textView) { + String actualHtmlText = Html.toHtml((Spanned) textView.getText()); + if (TextUtils.isEmpty(expectedHtmlText)) { + // If the text is empty, it does not add the <p></p> bits to it. + Assert.assertEquals("", actualHtmlText); + } else { + Assert.assertEquals("<p dir=ltr>" + expectedHtmlText + "</p>\n", actualHtmlText); + } + } + + /** + * Assert span exists in the correct location. + * + * @param seq The spannable string to check. + * @param start The starting index. + * @param end The ending index. + */ + public static void assertPrefixSpan(CharSequence seq, int start, int end) { + Assert.assertTrue(seq instanceof Spanned); + Spanned spannable = (Spanned) seq; + + if (start > 0) { + Assert.assertEquals(0, getNumForegroundColorSpansBetween(spannable, 0, start - 1)); + } + Assert.assertEquals(1, getNumForegroundColorSpansBetween(spannable, start, end)); + Assert.assertEquals( + 0, getNumForegroundColorSpansBetween(spannable, end + 1, spannable.length() - 1)); + } + + private static int getNumForegroundColorSpansBetween(Spanned value, int start, int end) { + return value.getSpans(start, end, StyleSpan.class).length; + } + + /** + * Asserts that the given character sequence is not a Spanned object and text is correct. + * + * @param seq The sequence to check. + * @param expected The expected text. + */ + public static void assertNotSpanned(CharSequence seq, String expected) { + Assert.assertFalse(seq instanceof Spanned); + Assert.assertEquals(expected, seq); + } + + public static int getNextTransition(SpannableString seq, int start) { + return seq.nextSpanTransition(start, seq.length(), StyleSpan.class); + } +} diff --git a/java/com/android/contacts/common/lettertiles/LetterTileDrawable.java b/java/com/android/contacts/common/lettertiles/LetterTileDrawable.java new file mode 100644 index 000000000..7e1839c1e --- /dev/null +++ b/java/com/android/contacts/common/lettertiles/LetterTileDrawable.java @@ -0,0 +1,382 @@ +/* + * Copyright (C) 2013 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.common.lettertiles; + +import android.content.res.Resources; +import android.content.res.TypedArray; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.Canvas; +import android.graphics.ColorFilter; +import android.graphics.Outline; +import android.graphics.Paint; +import android.graphics.Paint.Align; +import android.graphics.Rect; +import android.graphics.Typeface; +import android.graphics.drawable.Drawable; +import android.support.annotation.IntDef; +import android.support.annotation.Nullable; +import android.text.TextUtils; +import com.android.contacts.common.R; +import com.android.dialer.common.Assert; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +/** + * A drawable that encapsulates all the functionality needed to display a letter tile to represent a + * contact image. + */ +public class LetterTileDrawable extends Drawable { + + /** + * ContactType indicates the avatar type of the contact. For a person or for the default when no + * name is provided, it is {@link #TYPE_DEFAULT}, otherwise, for a business it is {@link + * #TYPE_BUSINESS}, and voicemail contacts should use {@link #TYPE_VOICEMAIL}. + */ + @Retention(RetentionPolicy.SOURCE) + @IntDef({TYPE_PERSON, TYPE_BUSINESS, TYPE_VOICEMAIL}) + public @interface ContactType {} + + /** Contact type constants */ + public static final int TYPE_PERSON = 1; + public static final int TYPE_BUSINESS = 2; + public static final int TYPE_VOICEMAIL = 3; + @ContactType public static final int TYPE_DEFAULT = TYPE_PERSON; + + /** + * Shape indicates the letter tile shape. It can be either a {@link #SHAPE_CIRCLE}, otherwise, it + * is a {@link #SHAPE_RECTANGLE}. + */ + @Retention(RetentionPolicy.SOURCE) + @IntDef({SHAPE_CIRCLE, SHAPE_RECTANGLE}) + public @interface Shape {} + + /** Shape constants */ + public static final int SHAPE_CIRCLE = 1; + + public static final int SHAPE_RECTANGLE = 2; + + /** 54% opacity */ + private static final int ALPHA = 138; + + /** Reusable components to avoid new allocations */ + private static final Paint sPaint = new Paint(); + + private static final Rect sRect = new Rect(); + private static final char[] sFirstChar = new char[1]; + /** Letter tile */ + private static TypedArray sColors; + + private static int sDefaultColor; + private static int sTileFontColor; + private static float sLetterToTileRatio; + private static Bitmap sDefaultPersonAvatar; + private static Bitmap sDefaultBusinessAvatar; + private static Bitmap sDefaultVoicemailAvatar; + private static final String TAG = LetterTileDrawable.class.getSimpleName(); + private final Paint mPaint; + private int mContactType = TYPE_DEFAULT; + private float mScale = 1.0f; + private float mOffset = 0.0f; + private boolean mIsCircle = false; + + private int mColor; + private Character mLetter = null; + + private boolean mAvatarWasVoicemailOrBusiness = false; + private String mDisplayName; + + public LetterTileDrawable(final Resources res) { + if (sColors == null) { + sColors = res.obtainTypedArray(R.array.letter_tile_colors); + sDefaultColor = res.getColor(R.color.letter_tile_default_color); + sTileFontColor = res.getColor(R.color.letter_tile_font_color); + sLetterToTileRatio = res.getFraction(R.dimen.letter_to_tile_ratio, 1, 1); + sDefaultPersonAvatar = + BitmapFactory.decodeResource( + res, R.drawable.product_logo_avatar_anonymous_white_color_120); + sDefaultBusinessAvatar = + BitmapFactory.decodeResource(res, R.drawable.ic_business_white_120dp); + sDefaultVoicemailAvatar = BitmapFactory.decodeResource(res, R.drawable.ic_voicemail_avatar); + sPaint.setTypeface( + Typeface.create(res.getString(R.string.letter_tile_letter_font_family), Typeface.NORMAL)); + sPaint.setTextAlign(Align.CENTER); + sPaint.setAntiAlias(true); + } + mPaint = new Paint(); + mPaint.setFilterBitmap(true); + mPaint.setDither(true); + mColor = sDefaultColor; + } + + private static Bitmap getBitmapForContactType(int contactType) { + switch (contactType) { + case TYPE_BUSINESS: + return sDefaultBusinessAvatar; + case TYPE_VOICEMAIL: + return sDefaultVoicemailAvatar; + case TYPE_PERSON: + default: + return sDefaultPersonAvatar; + } + } + + private static boolean isEnglishLetter(final char c) { + return ('A' <= c && c <= 'Z') || ('a' <= c && c <= 'z'); + } + + @Override + public void draw(final Canvas canvas) { + final Rect bounds = getBounds(); + if (!isVisible() || bounds.isEmpty()) { + return; + } + // Draw letter tile. + drawLetterTile(canvas); + } + + /** + * Draw the bitmap onto the canvas at the current bounds taking into account the current scale. + */ + private void drawBitmap( + final Bitmap bitmap, final int width, final int height, final Canvas canvas) { + // The bitmap should be drawn in the middle of the canvas without changing its width to + // height ratio. + final Rect destRect = copyBounds(); + + // Crop the destination bounds into a square, scaled and offset as appropriate + final int halfLength = (int) (mScale * Math.min(destRect.width(), destRect.height()) / 2); + + destRect.set( + destRect.centerX() - halfLength, + (int) (destRect.centerY() - halfLength + mOffset * destRect.height()), + destRect.centerX() + halfLength, + (int) (destRect.centerY() + halfLength + mOffset * destRect.height())); + + // Source rectangle remains the entire bounds of the source bitmap. + sRect.set(0, 0, width, height); + + sPaint.setTextAlign(Align.CENTER); + sPaint.setAntiAlias(true); + sPaint.setAlpha(ALPHA); + + canvas.drawBitmap(bitmap, sRect, destRect, sPaint); + } + + private void drawLetterTile(final Canvas canvas) { + // Draw background color. + sPaint.setColor(mColor); + + sPaint.setAlpha(mPaint.getAlpha()); + final Rect bounds = getBounds(); + final int minDimension = Math.min(bounds.width(), bounds.height()); + + if (mIsCircle) { + canvas.drawCircle(bounds.centerX(), bounds.centerY(), minDimension / 2, sPaint); + } else { + canvas.drawRect(bounds, sPaint); + } + + // Draw letter/digit only if the first character is an english letter or there's a override + + if (mLetter != null) { + // Draw letter or digit. + sFirstChar[0] = mLetter; + + // Scale text by canvas bounds and user selected scaling factor + sPaint.setTextSize(mScale * sLetterToTileRatio * minDimension); + sPaint.getTextBounds(sFirstChar, 0, 1, sRect); + sPaint.setTypeface(Typeface.create("sans-serif", Typeface.NORMAL)); + sPaint.setColor(sTileFontColor); + sPaint.setAlpha(ALPHA); + + // Draw the letter in the canvas, vertically shifted up or down by the user-defined + // offset + canvas.drawText( + sFirstChar, + 0, + 1, + bounds.centerX(), + bounds.centerY() + mOffset * bounds.height() - sRect.exactCenterY(), + sPaint); + } else { + // Draw the default image if there is no letter/digit to be drawn + final Bitmap bitmap = getBitmapForContactType(mContactType); + drawBitmap(bitmap, bitmap.getWidth(), bitmap.getHeight(), canvas); + } + } + + public int getColor() { + return mColor; + } + + public LetterTileDrawable setColor(int color) { + mColor = color; + return this; + } + + /** Returns a deterministic color based on the provided contact identifier string. */ + private int pickColor(final String identifier) { + if (TextUtils.isEmpty(identifier) || mContactType == TYPE_VOICEMAIL) { + return sDefaultColor; + } + // String.hashCode() implementation is not supposed to change across java versions, so + // this should guarantee the same email address always maps to the same color. + // The email should already have been normalized by the ContactRequest. + final int color = Math.abs(identifier.hashCode()) % sColors.length(); + return sColors.getColor(color, sDefaultColor); + } + + @Override + public void setAlpha(final int alpha) { + mPaint.setAlpha(alpha); + } + + @Override + public void setColorFilter(final ColorFilter cf) { + mPaint.setColorFilter(cf); + } + + @Override + public int getOpacity() { + return android.graphics.PixelFormat.OPAQUE; + } + + @Override + public void getOutline(Outline outline) { + if (mIsCircle) { + outline.setOval(getBounds()); + } else { + outline.setRect(getBounds()); + } + + outline.setAlpha(1); + } + + /** + * Scale the drawn letter tile to a ratio of its default size + * + * @param scale The ratio the letter tile should be scaled to as a percentage of its default size, + * from a scale of 0 to 2.0f. The default is 1.0f. + */ + public LetterTileDrawable setScale(float scale) { + mScale = scale; + return this; + } + + /** + * Assigns the vertical offset of the position of the letter tile to the ContactDrawable + * + * @param offset The provided offset must be within the range of -0.5f to 0.5f. If set to -0.5f, + * the letter will be shifted upwards by 0.5 times the height of the canvas it is being drawn + * on, which means it will be drawn with the center of the letter starting at the top edge of + * the canvas. If set to 0.5f, the letter will be shifted downwards by 0.5 times the height of + * the canvas it is being drawn on, which means it will be drawn with the center of the letter + * starting at the bottom edge of the canvas. The default is 0.0f. + */ + public LetterTileDrawable setOffset(float offset) { + Assert.checkArgument(offset >= -0.5f && offset <= 0.5f); + mOffset = offset; + return this; + } + + public LetterTileDrawable setLetter(Character letter) { + mLetter = letter; + return this; + } + + public Character getLetter() { + return this.mLetter; + } + + private LetterTileDrawable setLetterAndColorFromContactDetails( + final String displayName, final String identifier) { + if (displayName != null && displayName.length() > 0 && isEnglishLetter(displayName.charAt(0))) { + mLetter = Character.toUpperCase(displayName.charAt(0)); + } else { + mLetter = null; + } + mColor = pickColor(identifier); + return this; + } + + public LetterTileDrawable setContactType(@ContactType int contactType) { + mContactType = contactType; + return this; + } + + @ContactType + public int getContactType() { + return this.mContactType; + } + + public LetterTileDrawable setIsCircular(boolean isCircle) { + mIsCircle = isCircle; + return this; + } + + /** + * Creates a canonical letter tile for use across dialer fragments. + * + * @param displayName The display name to produce the letter in the tile. Null values or numbers + * yield no letter. + * @param identifierForTileColor The string used to produce the tile color. + * @param shape The shape of the tile. + * @param contactType The type of contact, e.g. TYPE_VOICEMAIL. + * @return this + */ + public LetterTileDrawable setCanonicalDialerLetterTileDetails( + @Nullable final String displayName, + @Nullable final String identifierForTileColor, + @Shape final int shape, + final int contactType) { + setContactType(contactType); + /** + * During hangup, we lose the call state for special types of contacts, like voicemail. To help + * callers avoid extraneous LetterTileDrawable allocations, we keep track of the special case + * until we encounter a new display name. + */ + if (contactType == TYPE_VOICEMAIL || contactType == TYPE_BUSINESS) { + this.mAvatarWasVoicemailOrBusiness = true; + } else if (displayName != null && !displayName.equals(mDisplayName)) { + this.mAvatarWasVoicemailOrBusiness = false; + } + this.mDisplayName = displayName; + if (shape == SHAPE_CIRCLE) { + this.setIsCircular(true); + } else { + this.setIsCircular(false); + } + + /** + * To preserve style, we don't use contactType to set the tile icon. In the future, when all + * callers surface this detail, we can use this to better style the tile icon. + */ + if (mAvatarWasVoicemailOrBusiness) { + this.setLetterAndColorFromContactDetails(null, displayName); + return this; + } else { + if (identifierForTileColor != null) { + this.setLetterAndColorFromContactDetails(displayName, identifierForTileColor); + return this; + } else { + this.setLetterAndColorFromContactDetails(displayName, displayName); + return this; + } + } + } +} diff --git a/java/com/android/contacts/common/list/AutoScrollListView.java b/java/com/android/contacts/common/list/AutoScrollListView.java new file mode 100644 index 000000000..601abf528 --- /dev/null +++ b/java/com/android/contacts/common/list/AutoScrollListView.java @@ -0,0 +1,125 @@ +/* + * Copyright (C) 2010 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.common.list; + +import android.content.Context; +import android.os.Build; +import android.util.AttributeSet; +import android.widget.ListView; + +/** + * A ListView that can be asked to scroll (smoothly or otherwise) to a specific position. This class + * takes advantage of similar functionality that exists in {@link ListView} and enhances it. + */ +public class AutoScrollListView extends ListView { + + /** Position the element at about 1/3 of the list height */ + private static final float PREFERRED_SELECTION_OFFSET_FROM_TOP = 0.33f; + + private int mRequestedScrollPosition = -1; + private boolean mSmoothScrollRequested; + + public AutoScrollListView(Context context) { + super(context); + } + + public AutoScrollListView(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public AutoScrollListView(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + } + + /** + * Brings the specified position to view by optionally performing a jump-scroll maneuver: first it + * jumps to some position near the one requested and then does a smooth scroll to the requested + * position. This creates an impression of full smooth scrolling without actually traversing the + * entire list. If smooth scrolling is not requested, instantly positions the requested item at a + * preferred offset. + */ + public void requestPositionToScreen(int position, boolean smoothScroll) { + mRequestedScrollPosition = position; + mSmoothScrollRequested = smoothScroll; + requestLayout(); + } + + @Override + protected void layoutChildren() { + super.layoutChildren(); + if (mRequestedScrollPosition == -1) { + return; + } + + final int position = mRequestedScrollPosition; + mRequestedScrollPosition = -1; + + int firstPosition = getFirstVisiblePosition() + 1; + int lastPosition = getLastVisiblePosition(); + if (position >= firstPosition && position <= lastPosition) { + return; // Already on screen + } + + final int offset = (int) (getHeight() * PREFERRED_SELECTION_OFFSET_FROM_TOP); + if (!mSmoothScrollRequested) { + setSelectionFromTop(position, offset); + + // Since we have changed the scrolling position, we need to redo child layout + // Calling "requestLayout" in the middle of a layout pass has no effect, + // so we call layoutChildren explicitly + super.layoutChildren(); + + } else { + // We will first position the list a couple of screens before or after + // the new selection and then scroll smoothly to it. + int twoScreens = (lastPosition - firstPosition) * 2; + int preliminaryPosition; + if (position < firstPosition) { + preliminaryPosition = position + twoScreens; + if (preliminaryPosition >= getCount()) { + preliminaryPosition = getCount() - 1; + } + if (preliminaryPosition < firstPosition) { + setSelection(preliminaryPosition); + super.layoutChildren(); + } + } else { + preliminaryPosition = position - twoScreens; + if (preliminaryPosition < 0) { + preliminaryPosition = 0; + } + if (preliminaryPosition > lastPosition) { + setSelection(preliminaryPosition); + super.layoutChildren(); + } + } + + smoothScrollToPositionFromTop(position, offset); + } + } + + @Override + protected void onLayout(boolean changed, int l, int t, int r, int b) { + super.onLayout(changed, l, t, r, b); + + // Workaround for b/31160338 and b/32778636. + if (android.os.Build.VERSION.SDK_INT == Build.VERSION_CODES.N + || android.os.Build.VERSION.SDK_INT == Build.VERSION_CODES.N_MR1) { + layoutChildren(); + } + } +} diff --git a/java/com/android/contacts/common/list/ContactEntry.java b/java/com/android/contacts/common/list/ContactEntry.java new file mode 100644 index 000000000..e33165e45 --- /dev/null +++ b/java/com/android/contacts/common/list/ContactEntry.java @@ -0,0 +1,57 @@ +/* + * Copyright (C) 2013 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.common.list; + +import android.net.Uri; +import android.provider.ContactsContract.PinnedPositions; +import android.text.TextUtils; +import com.android.contacts.common.preference.ContactsPreferences; + +/** Class to hold contact information */ +public class ContactEntry { + + public static final ContactEntry BLANK_ENTRY = new ContactEntry(); + private static final int UNSET_DISPLAY_ORDER_PREFERENCE = -1; + /** Primary name for a Contact */ + public String namePrimary; + /** Alternative name for a Contact, e.g. last name first */ + public String nameAlternative; + /** + * The user's preference on name display order, last name first or first time first. {@see + * ContactsPreferences} + */ + public int nameDisplayOrder = UNSET_DISPLAY_ORDER_PREFERENCE; + + public String phoneLabel; + public String phoneNumber; + public Uri photoUri; + public Uri lookupUri; + public String lookupKey; + public long id; + public int pinned = PinnedPositions.UNPINNED; + public boolean isFavorite = false; + public boolean isDefaultNumber = false; + + public String getPreferredDisplayName() { + if (nameDisplayOrder == UNSET_DISPLAY_ORDER_PREFERENCE + || nameDisplayOrder == ContactsPreferences.DISPLAY_ORDER_PRIMARY + || TextUtils.isEmpty(nameAlternative)) { + return namePrimary; + } + return nameAlternative; + } +} diff --git a/java/com/android/contacts/common/list/ContactEntryListAdapter.java b/java/com/android/contacts/common/list/ContactEntryListAdapter.java new file mode 100644 index 000000000..18bbae382 --- /dev/null +++ b/java/com/android/contacts/common/list/ContactEntryListAdapter.java @@ -0,0 +1,742 @@ +/* + * Copyright (C) 2010 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.common.list; + +import android.content.Context; +import android.content.CursorLoader; +import android.content.res.Resources; +import android.database.Cursor; +import android.net.Uri; +import android.os.Bundle; +import android.provider.ContactsContract; +import android.provider.ContactsContract.CommonDataKinds.Phone; +import android.provider.ContactsContract.Contacts; +import android.provider.ContactsContract.Directory; +import android.text.TextUtils; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.QuickContactBadge; +import android.widget.SectionIndexer; +import android.widget.TextView; +import com.android.contacts.common.ContactPhotoManager; +import com.android.contacts.common.ContactPhotoManager.DefaultImageRequest; +import com.android.contacts.common.ContactsUtils; +import com.android.contacts.common.R; +import com.android.contacts.common.compat.DirectoryCompat; +import com.android.contacts.common.util.SearchUtil; +import com.android.dialer.compat.CompatUtils; +import java.util.HashSet; + +/** + * Common base class for various contact-related lists, e.g. contact list, phone number list etc. + */ +public abstract class ContactEntryListAdapter extends IndexerListAdapter { + + /** + * Indicates whether the {@link Directory#LOCAL_INVISIBLE} directory should be included in the + * search. + */ + public static final boolean LOCAL_INVISIBLE_DIRECTORY_ENABLED = false; + + private static final String TAG = "ContactEntryListAdapter"; + private int mDisplayOrder; + private int mSortOrder; + + private boolean mDisplayPhotos; + private boolean mCircularPhotos = true; + private boolean mQuickContactEnabled; + private boolean mAdjustSelectionBoundsEnabled; + + /** The root view of the fragment that this adapter is associated with. */ + private View mFragmentRootView; + + private ContactPhotoManager mPhotoLoader; + + private String mQueryString; + private String mUpperCaseQueryString; + private boolean mSearchMode; + private int mDirectorySearchMode; + private int mDirectoryResultLimit = Integer.MAX_VALUE; + + private boolean mEmptyListEnabled = true; + + private boolean mSelectionVisible; + + private ContactListFilter mFilter; + private boolean mDarkTheme = false; + + /** Resource used to provide header-text for default filter. */ + private CharSequence mDefaultFilterHeaderText; + + public ContactEntryListAdapter(Context context) { + super(context); + setDefaultFilterHeaderText(R.string.local_search_label); + addPartitions(); + } + + /** + * @param fragmentRootView Root view of the fragment. This is used to restrict the scope of image + * loading requests that get cancelled on cursor changes. + */ + protected void setFragmentRootView(View fragmentRootView) { + mFragmentRootView = fragmentRootView; + } + + protected void setDefaultFilterHeaderText(int resourceId) { + mDefaultFilterHeaderText = getContext().getResources().getText(resourceId); + } + + @Override + protected ContactListItemView newView( + Context context, int partition, Cursor cursor, int position, ViewGroup parent) { + final ContactListItemView view = new ContactListItemView(context, null); + view.setIsSectionHeaderEnabled(isSectionHeaderDisplayEnabled()); + view.setAdjustSelectionBoundsEnabled(isAdjustSelectionBoundsEnabled()); + return view; + } + + @Override + protected void bindView(View itemView, int partition, Cursor cursor, int position) { + final ContactListItemView view = (ContactListItemView) itemView; + view.setIsSectionHeaderEnabled(isSectionHeaderDisplayEnabled()); + bindWorkProfileIcon(view, partition); + } + + @Override + protected View createPinnedSectionHeaderView(Context context, ViewGroup parent) { + return new ContactListPinnedHeaderView(context, null, parent); + } + + @Override + protected void setPinnedSectionTitle(View pinnedHeaderView, String title) { + ((ContactListPinnedHeaderView) pinnedHeaderView).setSectionHeaderTitle(title); + } + + protected void addPartitions() { + addPartition(createDefaultDirectoryPartition()); + } + + protected DirectoryPartition createDefaultDirectoryPartition() { + DirectoryPartition partition = new DirectoryPartition(true, true); + partition.setDirectoryId(Directory.DEFAULT); + partition.setDirectoryType(getContext().getString(R.string.contactsList)); + partition.setPriorityDirectory(true); + partition.setPhotoSupported(true); + partition.setLabel(mDefaultFilterHeaderText.toString()); + return partition; + } + + /** + * Remove all directories after the default directory. This is typically used when contacts list + * screens are asked to exit the search mode and thus need to remove all remote directory results + * for the search. + * + * <p>This code assumes that the default directory and directories before that should not be + * deleted (e.g. Join screen has "suggested contacts" directory before the default director, and + * we should not remove the directory). + */ + public void removeDirectoriesAfterDefault() { + final int partitionCount = getPartitionCount(); + for (int i = partitionCount - 1; i >= 0; i--) { + final Partition partition = getPartition(i); + if ((partition instanceof DirectoryPartition) + && ((DirectoryPartition) partition).getDirectoryId() == Directory.DEFAULT) { + break; + } else { + removePartition(i); + } + } + } + + protected int getPartitionByDirectoryId(long id) { + int count = getPartitionCount(); + for (int i = 0; i < count; i++) { + Partition partition = getPartition(i); + if (partition instanceof DirectoryPartition) { + if (((DirectoryPartition) partition).getDirectoryId() == id) { + return i; + } + } + } + return -1; + } + + protected DirectoryPartition getDirectoryById(long id) { + int count = getPartitionCount(); + for (int i = 0; i < count; i++) { + Partition partition = getPartition(i); + if (partition instanceof DirectoryPartition) { + final DirectoryPartition directoryPartition = (DirectoryPartition) partition; + if (directoryPartition.getDirectoryId() == id) { + return directoryPartition; + } + } + } + return null; + } + + public abstract void configureLoader(CursorLoader loader, long directoryId); + + /** Marks all partitions as "loading" */ + public void onDataReload() { + boolean notify = false; + int count = getPartitionCount(); + for (int i = 0; i < count; i++) { + Partition partition = getPartition(i); + if (partition instanceof DirectoryPartition) { + DirectoryPartition directoryPartition = (DirectoryPartition) partition; + if (!directoryPartition.isLoading()) { + notify = true; + } + directoryPartition.setStatus(DirectoryPartition.STATUS_NOT_LOADED); + } + } + if (notify) { + notifyDataSetChanged(); + } + } + + @Override + public void clearPartitions() { + int count = getPartitionCount(); + for (int i = 0; i < count; i++) { + Partition partition = getPartition(i); + if (partition instanceof DirectoryPartition) { + DirectoryPartition directoryPartition = (DirectoryPartition) partition; + directoryPartition.setStatus(DirectoryPartition.STATUS_NOT_LOADED); + } + } + super.clearPartitions(); + } + + public boolean isSearchMode() { + return mSearchMode; + } + + public void setSearchMode(boolean flag) { + mSearchMode = flag; + } + + public String getQueryString() { + return mQueryString; + } + + public void setQueryString(String queryString) { + mQueryString = queryString; + if (TextUtils.isEmpty(queryString)) { + mUpperCaseQueryString = null; + } else { + mUpperCaseQueryString = SearchUtil.cleanStartAndEndOfSearchQuery(queryString.toUpperCase()); + } + } + + public String getUpperCaseQueryString() { + return mUpperCaseQueryString; + } + + public int getDirectorySearchMode() { + return mDirectorySearchMode; + } + + public void setDirectorySearchMode(int mode) { + mDirectorySearchMode = mode; + } + + public int getDirectoryResultLimit() { + return mDirectoryResultLimit; + } + + public void setDirectoryResultLimit(int limit) { + this.mDirectoryResultLimit = limit; + } + + public int getDirectoryResultLimit(DirectoryPartition directoryPartition) { + final int limit = directoryPartition.getResultLimit(); + return limit == DirectoryPartition.RESULT_LIMIT_DEFAULT ? mDirectoryResultLimit : limit; + } + + public int getContactNameDisplayOrder() { + return mDisplayOrder; + } + + public void setContactNameDisplayOrder(int displayOrder) { + mDisplayOrder = displayOrder; + } + + public int getSortOrder() { + return mSortOrder; + } + + public void setSortOrder(int sortOrder) { + mSortOrder = sortOrder; + } + + protected ContactPhotoManager getPhotoLoader() { + return mPhotoLoader; + } + + public void setPhotoLoader(ContactPhotoManager photoLoader) { + mPhotoLoader = photoLoader; + } + + public boolean getDisplayPhotos() { + return mDisplayPhotos; + } + + public void setDisplayPhotos(boolean displayPhotos) { + mDisplayPhotos = displayPhotos; + } + + public boolean getCircularPhotos() { + return mCircularPhotos; + } + + public boolean isSelectionVisible() { + return mSelectionVisible; + } + + public void setSelectionVisible(boolean flag) { + this.mSelectionVisible = flag; + } + + public boolean isQuickContactEnabled() { + return mQuickContactEnabled; + } + + public void setQuickContactEnabled(boolean quickContactEnabled) { + mQuickContactEnabled = quickContactEnabled; + } + + public boolean isAdjustSelectionBoundsEnabled() { + return mAdjustSelectionBoundsEnabled; + } + + public void setAdjustSelectionBoundsEnabled(boolean enabled) { + mAdjustSelectionBoundsEnabled = enabled; + } + + public void setProfileExists(boolean exists) { + // Stick the "ME" header for the profile + if (exists) { + setSectionHeader(R.string.user_profile_contacts_list_header, /* # of ME */ 1); + } + } + + private void setSectionHeader(int resId, int numberOfItems) { + SectionIndexer indexer = getIndexer(); + if (indexer != null) { + ((ContactsSectionIndexer) indexer) + .setProfileAndFavoritesHeader(getContext().getString(resId), numberOfItems); + } + } + + public void setDarkTheme(boolean value) { + mDarkTheme = value; + } + + /** Updates partitions according to the directory meta-data contained in the supplied cursor. */ + public void changeDirectories(Cursor cursor) { + if (cursor.getCount() == 0) { + // Directory table must have at least local directory, without which this adapter will + // enter very weird state. + Log.e( + TAG, + "Directory search loader returned an empty cursor, which implies we have " + + "no directory entries.", + new RuntimeException()); + return; + } + HashSet<Long> directoryIds = new HashSet<Long>(); + + int idColumnIndex = cursor.getColumnIndex(Directory._ID); + int directoryTypeColumnIndex = cursor.getColumnIndex(DirectoryListLoader.DIRECTORY_TYPE); + int displayNameColumnIndex = cursor.getColumnIndex(Directory.DISPLAY_NAME); + int photoSupportColumnIndex = cursor.getColumnIndex(Directory.PHOTO_SUPPORT); + + // TODO preserve the order of partition to match those of the cursor + // Phase I: add new directories + cursor.moveToPosition(-1); + while (cursor.moveToNext()) { + long id = cursor.getLong(idColumnIndex); + directoryIds.add(id); + if (getPartitionByDirectoryId(id) == -1) { + DirectoryPartition partition = new DirectoryPartition(false, true); + partition.setDirectoryId(id); + if (DirectoryCompat.isRemoteDirectoryId(id)) { + if (DirectoryCompat.isEnterpriseDirectoryId(id)) { + partition.setLabel(mContext.getString(R.string.directory_search_label_work)); + } else { + partition.setLabel(mContext.getString(R.string.directory_search_label)); + } + } else { + if (DirectoryCompat.isEnterpriseDirectoryId(id)) { + partition.setLabel(mContext.getString(R.string.list_filter_phones_work)); + } else { + partition.setLabel(mDefaultFilterHeaderText.toString()); + } + } + partition.setDirectoryType(cursor.getString(directoryTypeColumnIndex)); + partition.setDisplayName(cursor.getString(displayNameColumnIndex)); + int photoSupport = cursor.getInt(photoSupportColumnIndex); + partition.setPhotoSupported( + photoSupport == Directory.PHOTO_SUPPORT_THUMBNAIL_ONLY + || photoSupport == Directory.PHOTO_SUPPORT_FULL); + addPartition(partition); + } + } + + // Phase II: remove deleted directories + int count = getPartitionCount(); + for (int i = count; --i >= 0; ) { + Partition partition = getPartition(i); + if (partition instanceof DirectoryPartition) { + long id = ((DirectoryPartition) partition).getDirectoryId(); + if (!directoryIds.contains(id)) { + removePartition(i); + } + } + } + + invalidate(); + notifyDataSetChanged(); + } + + @Override + public void changeCursor(int partitionIndex, Cursor cursor) { + if (partitionIndex >= getPartitionCount()) { + // There is no partition for this data + return; + } + + Partition partition = getPartition(partitionIndex); + if (partition instanceof DirectoryPartition) { + ((DirectoryPartition) partition).setStatus(DirectoryPartition.STATUS_LOADED); + } + + if (mDisplayPhotos && mPhotoLoader != null && isPhotoSupported(partitionIndex)) { + mPhotoLoader.refreshCache(); + } + + super.changeCursor(partitionIndex, cursor); + + if (isSectionHeaderDisplayEnabled() && partitionIndex == getIndexedPartition()) { + updateIndexer(cursor); + } + + // When the cursor changes, cancel any pending asynchronous photo loads. + mPhotoLoader.cancelPendingRequests(mFragmentRootView); + } + + public void changeCursor(Cursor cursor) { + changeCursor(0, cursor); + } + + /** Updates the indexer, which is used to produce section headers. */ + private void updateIndexer(Cursor cursor) { + if (cursor == null || cursor.isClosed()) { + setIndexer(null); + return; + } + + Bundle bundle = cursor.getExtras(); + if (bundle.containsKey(Contacts.EXTRA_ADDRESS_BOOK_INDEX_TITLES) + && bundle.containsKey(Contacts.EXTRA_ADDRESS_BOOK_INDEX_COUNTS)) { + String[] sections = bundle.getStringArray(Contacts.EXTRA_ADDRESS_BOOK_INDEX_TITLES); + int[] counts = bundle.getIntArray(Contacts.EXTRA_ADDRESS_BOOK_INDEX_COUNTS); + + if (getExtraStartingSection()) { + // Insert an additional unnamed section at the top of the list. + String[] allSections = new String[sections.length + 1]; + int[] allCounts = new int[counts.length + 1]; + for (int i = 0; i < sections.length; i++) { + allSections[i + 1] = sections[i]; + allCounts[i + 1] = counts[i]; + } + allCounts[0] = 1; + allSections[0] = ""; + setIndexer(new ContactsSectionIndexer(allSections, allCounts)); + } else { + setIndexer(new ContactsSectionIndexer(sections, counts)); + } + } else { + setIndexer(null); + } + } + + protected boolean getExtraStartingSection() { + return false; + } + + @Override + public int getViewTypeCount() { + // We need a separate view type for each item type, plus another one for + // each type with header, plus one for "other". + return getItemViewTypeCount() * 2 + 1; + } + + @Override + public int getItemViewType(int partitionIndex, int position) { + int type = super.getItemViewType(partitionIndex, position); + if (!isUserProfile(position) + && isSectionHeaderDisplayEnabled() + && partitionIndex == getIndexedPartition()) { + Placement placement = getItemPlacementInSection(position); + return placement.firstInSection ? type : getItemViewTypeCount() + type; + } else { + return type; + } + } + + @Override + public boolean isEmpty() { + // TODO + // if (contactsListActivity.mProviderStatus != ProviderStatus.STATUS_NORMAL) { + // return true; + // } + + if (!mEmptyListEnabled) { + return false; + } else if (isSearchMode()) { + return TextUtils.isEmpty(getQueryString()); + } else { + return super.isEmpty(); + } + } + + public boolean isLoading() { + int count = getPartitionCount(); + for (int i = 0; i < count; i++) { + Partition partition = getPartition(i); + if (partition instanceof DirectoryPartition && ((DirectoryPartition) partition).isLoading()) { + return true; + } + } + return false; + } + + /** Changes visibility parameters for the default directory partition. */ + public void configureDefaultPartition(boolean showIfEmpty, boolean hasHeader) { + int defaultPartitionIndex = -1; + int count = getPartitionCount(); + for (int i = 0; i < count; i++) { + Partition partition = getPartition(i); + if (partition instanceof DirectoryPartition + && ((DirectoryPartition) partition).getDirectoryId() == Directory.DEFAULT) { + defaultPartitionIndex = i; + break; + } + } + if (defaultPartitionIndex != -1) { + setShowIfEmpty(defaultPartitionIndex, showIfEmpty); + setHasHeader(defaultPartitionIndex, hasHeader); + } + } + + @Override + protected View newHeaderView(Context context, int partition, Cursor cursor, ViewGroup parent) { + LayoutInflater inflater = LayoutInflater.from(context); + View view = inflater.inflate(R.layout.directory_header, parent, false); + if (!getPinnedPartitionHeadersEnabled()) { + // If the headers are unpinned, there is no need for their background + // color to be non-transparent. Setting this transparent reduces maintenance for + // non-pinned headers. We don't need to bother synchronizing the activity's + // background color with the header background color. + view.setBackground(null); + } + return view; + } + + protected void bindWorkProfileIcon(final ContactListItemView view, int partitionId) { + final Partition partition = getPartition(partitionId); + if (partition instanceof DirectoryPartition) { + final DirectoryPartition directoryPartition = (DirectoryPartition) partition; + final long directoryId = directoryPartition.getDirectoryId(); + final long userType = ContactsUtils.determineUserType(directoryId, null); + view.setWorkProfileIconEnabled(userType == ContactsUtils.USER_TYPE_WORK); + } + } + + @Override + protected void bindHeaderView(View view, int partitionIndex, Cursor cursor) { + Partition partition = getPartition(partitionIndex); + if (!(partition instanceof DirectoryPartition)) { + return; + } + + DirectoryPartition directoryPartition = (DirectoryPartition) partition; + long directoryId = directoryPartition.getDirectoryId(); + TextView labelTextView = (TextView) view.findViewById(R.id.label); + TextView displayNameTextView = (TextView) view.findViewById(R.id.display_name); + labelTextView.setText(directoryPartition.getLabel()); + if (!DirectoryCompat.isRemoteDirectoryId(directoryId)) { + displayNameTextView.setText(null); + } else { + String directoryName = directoryPartition.getDisplayName(); + String displayName = + !TextUtils.isEmpty(directoryName) ? directoryName : directoryPartition.getDirectoryType(); + displayNameTextView.setText(displayName); + } + + final Resources res = getContext().getResources(); + final int headerPaddingTop = + partitionIndex == 1 && getPartition(0).isEmpty() + ? 0 + : res.getDimensionPixelOffset(R.dimen.directory_header_extra_top_padding); + // There should be no extra padding at the top of the first directory header + view.setPaddingRelative( + view.getPaddingStart(), headerPaddingTop, view.getPaddingEnd(), view.getPaddingBottom()); + } + + /** Checks whether the contact entry at the given position represents the user's profile. */ + protected boolean isUserProfile(int position) { + // The profile only ever appears in the first position if it is present. So if the position + // is anything beyond 0, it can't be the profile. + boolean isUserProfile = false; + if (position == 0) { + int partition = getPartitionForPosition(position); + if (partition >= 0) { + // Save the old cursor position - the call to getItem() may modify the cursor + // position. + int offset = getCursor(partition).getPosition(); + Cursor cursor = (Cursor) getItem(position); + if (cursor != null) { + int profileColumnIndex = cursor.getColumnIndex(Contacts.IS_USER_PROFILE); + if (profileColumnIndex != -1) { + isUserProfile = cursor.getInt(profileColumnIndex) == 1; + } + // Restore the old cursor position. + cursor.moveToPosition(offset); + } + } + } + return isUserProfile; + } + + public boolean isPhotoSupported(int partitionIndex) { + Partition partition = getPartition(partitionIndex); + if (partition instanceof DirectoryPartition) { + return ((DirectoryPartition) partition).isPhotoSupported(); + } + return true; + } + + /** Returns the currently selected filter. */ + public ContactListFilter getFilter() { + return mFilter; + } + + public void setFilter(ContactListFilter filter) { + mFilter = filter; + } + + // TODO: move sharable logic (bindXX() methods) to here with extra arguments + + /** + * Loads the photo for the quick contact view and assigns the contact uri. + * + * @param photoIdColumn Index of the photo id column + * @param photoUriColumn Index of the photo uri column. Optional: Can be -1 + * @param contactIdColumn Index of the contact id column + * @param lookUpKeyColumn Index of the lookup key column + * @param displayNameColumn Index of the display name column + */ + protected void bindQuickContact( + final ContactListItemView view, + int partitionIndex, + Cursor cursor, + int photoIdColumn, + int photoUriColumn, + int contactIdColumn, + int lookUpKeyColumn, + int displayNameColumn) { + long photoId = 0; + if (!cursor.isNull(photoIdColumn)) { + photoId = cursor.getLong(photoIdColumn); + } + + QuickContactBadge quickContact = view.getQuickContact(); + quickContact.assignContactUri( + getContactUri(partitionIndex, cursor, contactIdColumn, lookUpKeyColumn)); + if (CompatUtils.hasPrioritizedMimeType()) { + // The Contacts app never uses the QuickContactBadge. Therefore, it is safe to assume + // that only Dialer will use this QuickContact badge. This means prioritizing the phone + // mimetype here is reasonable. + quickContact.setPrioritizedMimeType(Phone.CONTENT_ITEM_TYPE); + } + + if (photoId != 0 || photoUriColumn == -1) { + getPhotoLoader().loadThumbnail(quickContact, photoId, mDarkTheme, mCircularPhotos, null); + } else { + final String photoUriString = cursor.getString(photoUriColumn); + final Uri photoUri = photoUriString == null ? null : Uri.parse(photoUriString); + DefaultImageRequest request = null; + if (photoUri == null) { + request = getDefaultImageRequestFromCursor(cursor, displayNameColumn, lookUpKeyColumn); + } + getPhotoLoader().loadPhoto(quickContact, photoUri, -1, mDarkTheme, mCircularPhotos, request); + } + } + + @Override + public boolean hasStableIds() { + // Whenever bindViewId() is called, the values passed into setId() are stable or + // stable-ish. For example, when one contact is modified we don't expect a second + // contact's Contact._ID values to change. + return true; + } + + protected void bindViewId(final ContactListItemView view, Cursor cursor, int idColumn) { + // Set a semi-stable id, so that talkback won't get confused when the list gets + // refreshed. There is little harm in inserting the same ID twice. + long contactId = cursor.getLong(idColumn); + view.setId((int) (contactId % Integer.MAX_VALUE)); + } + + protected Uri getContactUri( + int partitionIndex, Cursor cursor, int contactIdColumn, int lookUpKeyColumn) { + long contactId = cursor.getLong(contactIdColumn); + String lookupKey = cursor.getString(lookUpKeyColumn); + long directoryId = ((DirectoryPartition) getPartition(partitionIndex)).getDirectoryId(); + Uri uri = Contacts.getLookupUri(contactId, lookupKey); + if (uri != null && directoryId != Directory.DEFAULT) { + uri = + uri.buildUpon() + .appendQueryParameter( + ContactsContract.DIRECTORY_PARAM_KEY, String.valueOf(directoryId)) + .build(); + } + return uri; + } + + /** + * Retrieves the lookup key and display name from a cursor, and returns a {@link + * DefaultImageRequest} containing these contact details + * + * @param cursor Contacts cursor positioned at the current row to retrieve contact details for + * @param displayNameColumn Column index of the display name + * @param lookupKeyColumn Column index of the lookup key + * @return {@link DefaultImageRequest} with the displayName and identifier fields set to the + * display name and lookup key of the contact. + */ + public DefaultImageRequest getDefaultImageRequestFromCursor( + Cursor cursor, int displayNameColumn, int lookupKeyColumn) { + final String displayName = cursor.getString(displayNameColumn); + final String lookupKey = cursor.getString(lookupKeyColumn); + return new DefaultImageRequest(displayName, lookupKey, mCircularPhotos); + } +} diff --git a/java/com/android/contacts/common/list/ContactEntryListFragment.java b/java/com/android/contacts/common/list/ContactEntryListFragment.java new file mode 100644 index 000000000..a8d9b55ba --- /dev/null +++ b/java/com/android/contacts/common/list/ContactEntryListFragment.java @@ -0,0 +1,862 @@ +/* + * Copyright (C) 2010 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.common.list; + +import android.app.Activity; +import android.app.Fragment; +import android.app.LoaderManager; +import android.app.LoaderManager.LoaderCallbacks; +import android.content.Context; +import android.content.CursorLoader; +import android.content.Loader; +import android.database.Cursor; +import android.os.Bundle; +import android.os.Handler; +import android.os.Message; +import android.os.Parcelable; +import android.provider.ContactsContract.Directory; +import android.text.TextUtils; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.MotionEvent; +import android.view.View; +import android.view.View.OnFocusChangeListener; +import android.view.View.OnTouchListener; +import android.view.ViewGroup; +import android.view.inputmethod.InputMethodManager; +import android.widget.AbsListView; +import android.widget.AbsListView.OnScrollListener; +import android.widget.AdapterView; +import android.widget.AdapterView.OnItemClickListener; +import android.widget.AdapterView.OnItemLongClickListener; +import android.widget.ListView; +import com.android.common.widget.CompositeCursorAdapter.Partition; +import com.android.contacts.common.ContactPhotoManager; +import com.android.contacts.common.preference.ContactsPreferences; +import com.android.contacts.common.util.ContactListViewUtils; +import java.util.Locale; + +/** Common base class for various contact-related list fragments. */ +public abstract class ContactEntryListFragment<T extends ContactEntryListAdapter> extends Fragment + implements OnItemClickListener, + OnScrollListener, + OnFocusChangeListener, + OnTouchListener, + OnItemLongClickListener, + LoaderCallbacks<Cursor> { + private static final String TAG = "ContactEntryListFragment"; + private static final String KEY_LIST_STATE = "liststate"; + private static final String KEY_SECTION_HEADER_DISPLAY_ENABLED = "sectionHeaderDisplayEnabled"; + private static final String KEY_PHOTO_LOADER_ENABLED = "photoLoaderEnabled"; + private static final String KEY_QUICK_CONTACT_ENABLED = "quickContactEnabled"; + private static final String KEY_ADJUST_SELECTION_BOUNDS_ENABLED = "adjustSelectionBoundsEnabled"; + private static final String KEY_INCLUDE_PROFILE = "includeProfile"; + private static final String KEY_SEARCH_MODE = "searchMode"; + private static final String KEY_VISIBLE_SCROLLBAR_ENABLED = "visibleScrollbarEnabled"; + private static final String KEY_SCROLLBAR_POSITION = "scrollbarPosition"; + private static final String KEY_QUERY_STRING = "queryString"; + private static final String KEY_DIRECTORY_SEARCH_MODE = "directorySearchMode"; + private static final String KEY_SELECTION_VISIBLE = "selectionVisible"; + private static final String KEY_DARK_THEME = "darkTheme"; + private static final String KEY_LEGACY_COMPATIBILITY = "legacyCompatibility"; + private static final String KEY_DIRECTORY_RESULT_LIMIT = "directoryResultLimit"; + + private static final String DIRECTORY_ID_ARG_KEY = "directoryId"; + + private static final int DIRECTORY_LOADER_ID = -1; + + private static final int DIRECTORY_SEARCH_DELAY_MILLIS = 300; + private static final int DIRECTORY_SEARCH_MESSAGE = 1; + + private static final int DEFAULT_DIRECTORY_RESULT_LIMIT = 20; + private static final int STATUS_NOT_LOADED = 0; + private static final int STATUS_LOADING = 1; + private static final int STATUS_LOADED = 2; + protected boolean mUserProfileExists; + private boolean mSectionHeaderDisplayEnabled; + private boolean mPhotoLoaderEnabled; + private boolean mQuickContactEnabled = true; + private boolean mAdjustSelectionBoundsEnabled = true; + private boolean mIncludeProfile; + private boolean mSearchMode; + private boolean mVisibleScrollbarEnabled; + private boolean mShowEmptyListForEmptyQuery; + private int mVerticalScrollbarPosition = getDefaultVerticalScrollbarPosition(); + private String mQueryString; + private int mDirectorySearchMode = DirectoryListLoader.SEARCH_MODE_NONE; + private boolean mSelectionVisible; + private boolean mLegacyCompatibility; + private boolean mEnabled = true; + private T mAdapter; + private View mView; + private ListView mListView; + /** Used to save the scrolling state of the list when the fragment is not recreated. */ + private int mListViewTopIndex; + + private int mListViewTopOffset; + /** Used for keeping track of the scroll state of the list. */ + private Parcelable mListState; + + private int mDisplayOrder; + private int mSortOrder; + private int mDirectoryResultLimit = DEFAULT_DIRECTORY_RESULT_LIMIT; + private ContactPhotoManager mPhotoManager; + private ContactsPreferences mContactsPrefs; + private boolean mForceLoad; + private boolean mDarkTheme; + private int mDirectoryListStatus = STATUS_NOT_LOADED; + + /** + * Indicates whether we are doing the initial complete load of data (false) or a refresh caused by + * a change notification (true) + */ + private boolean mLoadPriorityDirectoriesOnly; + + private Context mContext; + + private LoaderManager mLoaderManager; + + private Handler mDelayedDirectorySearchHandler = + new Handler() { + @Override + public void handleMessage(Message msg) { + if (msg.what == DIRECTORY_SEARCH_MESSAGE) { + loadDirectoryPartition(msg.arg1, (DirectoryPartition) msg.obj); + } + } + }; + private ContactsPreferences.ChangeListener mPreferencesChangeListener = + new ContactsPreferences.ChangeListener() { + @Override + public void onChange() { + loadPreferences(); + reloadData(); + } + }; + + protected abstract View inflateView(LayoutInflater inflater, ViewGroup container); + + protected abstract T createListAdapter(); + + /** + * @param position Please note that the position is already adjusted for header views, so "0" + * means the first list item below header views. + */ + protected abstract void onItemClick(int position, long id); + + /** + * @param position Please note that the position is already adjusted for header views, so "0" + * means the first list item below header views. + */ + protected boolean onItemLongClick(int position, long id) { + return false; + } + + @Override + public void onAttach(Activity activity) { + super.onAttach(activity); + setContext(activity); + setLoaderManager(super.getLoaderManager()); + } + + @Override + public Context getContext() { + return mContext; + } + + /** Sets a context for the fragment in the unit test environment. */ + public void setContext(Context context) { + mContext = context; + configurePhotoLoader(); + } + + public void setEnabled(boolean enabled) { + if (mEnabled != enabled) { + mEnabled = enabled; + if (mAdapter != null) { + if (mEnabled) { + reloadData(); + } else { + mAdapter.clearPartitions(); + } + } + } + } + + @Override + public LoaderManager getLoaderManager() { + return mLoaderManager; + } + + /** Overrides a loader manager for use in unit tests. */ + public void setLoaderManager(LoaderManager loaderManager) { + mLoaderManager = loaderManager; + } + + public T getAdapter() { + return mAdapter; + } + + @Override + public View getView() { + return mView; + } + + public ListView getListView() { + return mListView; + } + + @Override + public void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); + outState.putBoolean(KEY_SECTION_HEADER_DISPLAY_ENABLED, mSectionHeaderDisplayEnabled); + outState.putBoolean(KEY_PHOTO_LOADER_ENABLED, mPhotoLoaderEnabled); + outState.putBoolean(KEY_QUICK_CONTACT_ENABLED, mQuickContactEnabled); + outState.putBoolean(KEY_ADJUST_SELECTION_BOUNDS_ENABLED, mAdjustSelectionBoundsEnabled); + outState.putBoolean(KEY_INCLUDE_PROFILE, mIncludeProfile); + outState.putBoolean(KEY_SEARCH_MODE, mSearchMode); + outState.putBoolean(KEY_VISIBLE_SCROLLBAR_ENABLED, mVisibleScrollbarEnabled); + outState.putInt(KEY_SCROLLBAR_POSITION, mVerticalScrollbarPosition); + outState.putInt(KEY_DIRECTORY_SEARCH_MODE, mDirectorySearchMode); + outState.putBoolean(KEY_SELECTION_VISIBLE, mSelectionVisible); + outState.putBoolean(KEY_LEGACY_COMPATIBILITY, mLegacyCompatibility); + outState.putString(KEY_QUERY_STRING, mQueryString); + outState.putInt(KEY_DIRECTORY_RESULT_LIMIT, mDirectoryResultLimit); + outState.putBoolean(KEY_DARK_THEME, mDarkTheme); + + if (mListView != null) { + outState.putParcelable(KEY_LIST_STATE, mListView.onSaveInstanceState()); + } + } + + @Override + public void onCreate(Bundle savedState) { + super.onCreate(savedState); + restoreSavedState(savedState); + mAdapter = createListAdapter(); + mContactsPrefs = new ContactsPreferences(mContext); + } + + public void restoreSavedState(Bundle savedState) { + if (savedState == null) { + return; + } + + mSectionHeaderDisplayEnabled = savedState.getBoolean(KEY_SECTION_HEADER_DISPLAY_ENABLED); + mPhotoLoaderEnabled = savedState.getBoolean(KEY_PHOTO_LOADER_ENABLED); + mQuickContactEnabled = savedState.getBoolean(KEY_QUICK_CONTACT_ENABLED); + mAdjustSelectionBoundsEnabled = savedState.getBoolean(KEY_ADJUST_SELECTION_BOUNDS_ENABLED); + mIncludeProfile = savedState.getBoolean(KEY_INCLUDE_PROFILE); + mSearchMode = savedState.getBoolean(KEY_SEARCH_MODE); + mVisibleScrollbarEnabled = savedState.getBoolean(KEY_VISIBLE_SCROLLBAR_ENABLED); + mVerticalScrollbarPosition = savedState.getInt(KEY_SCROLLBAR_POSITION); + mDirectorySearchMode = savedState.getInt(KEY_DIRECTORY_SEARCH_MODE); + mSelectionVisible = savedState.getBoolean(KEY_SELECTION_VISIBLE); + mLegacyCompatibility = savedState.getBoolean(KEY_LEGACY_COMPATIBILITY); + mQueryString = savedState.getString(KEY_QUERY_STRING); + mDirectoryResultLimit = savedState.getInt(KEY_DIRECTORY_RESULT_LIMIT); + mDarkTheme = savedState.getBoolean(KEY_DARK_THEME); + + // Retrieve list state. This will be applied in onLoadFinished + mListState = savedState.getParcelable(KEY_LIST_STATE); + } + + @Override + public void onStart() { + super.onStart(); + + mContactsPrefs.registerChangeListener(mPreferencesChangeListener); + + mForceLoad = loadPreferences(); + + mDirectoryListStatus = STATUS_NOT_LOADED; + mLoadPriorityDirectoriesOnly = true; + + startLoading(); + } + + protected void startLoading() { + if (mAdapter == null) { + // The method was called before the fragment was started + return; + } + + configureAdapter(); + int partitionCount = mAdapter.getPartitionCount(); + for (int i = 0; i < partitionCount; i++) { + Partition partition = mAdapter.getPartition(i); + if (partition instanceof DirectoryPartition) { + DirectoryPartition directoryPartition = (DirectoryPartition) partition; + if (directoryPartition.getStatus() == DirectoryPartition.STATUS_NOT_LOADED) { + if (directoryPartition.isPriorityDirectory() || !mLoadPriorityDirectoriesOnly) { + startLoadingDirectoryPartition(i); + } + } + } else { + getLoaderManager().initLoader(i, null, this); + } + } + + // Next time this method is called, we should start loading non-priority directories + mLoadPriorityDirectoriesOnly = false; + } + + @Override + public Loader<Cursor> onCreateLoader(int id, Bundle args) { + if (id == DIRECTORY_LOADER_ID) { + DirectoryListLoader loader = new DirectoryListLoader(mContext); + loader.setDirectorySearchMode(mAdapter.getDirectorySearchMode()); + loader.setLocalInvisibleDirectoryEnabled( + ContactEntryListAdapter.LOCAL_INVISIBLE_DIRECTORY_ENABLED); + return loader; + } else { + CursorLoader loader = createCursorLoader(mContext); + long directoryId = + args != null && args.containsKey(DIRECTORY_ID_ARG_KEY) + ? args.getLong(DIRECTORY_ID_ARG_KEY) + : Directory.DEFAULT; + mAdapter.configureLoader(loader, directoryId); + return loader; + } + } + + public CursorLoader createCursorLoader(Context context) { + return new CursorLoader(context, null, null, null, null, null) { + @Override + protected Cursor onLoadInBackground() { + try { + return super.onLoadInBackground(); + } catch (RuntimeException e) { + // We don't even know what the projection should be, so no point trying to + // return an empty MatrixCursor with the correct projection here. + Log.w(TAG, "RuntimeException while trying to query ContactsProvider."); + return null; + } + } + }; + } + + private void startLoadingDirectoryPartition(int partitionIndex) { + DirectoryPartition partition = (DirectoryPartition) mAdapter.getPartition(partitionIndex); + partition.setStatus(DirectoryPartition.STATUS_LOADING); + long directoryId = partition.getDirectoryId(); + if (mForceLoad) { + if (directoryId == Directory.DEFAULT) { + loadDirectoryPartition(partitionIndex, partition); + } else { + loadDirectoryPartitionDelayed(partitionIndex, partition); + } + } else { + Bundle args = new Bundle(); + args.putLong(DIRECTORY_ID_ARG_KEY, directoryId); + getLoaderManager().initLoader(partitionIndex, args, this); + } + } + + /** + * Queues up a delayed request to search the specified directory. Since directory search will + * likely introduce a lot of network traffic, we want to wait for a pause in the user's typing + * before sending a directory request. + */ + private void loadDirectoryPartitionDelayed(int partitionIndex, DirectoryPartition partition) { + mDelayedDirectorySearchHandler.removeMessages(DIRECTORY_SEARCH_MESSAGE, partition); + Message msg = + mDelayedDirectorySearchHandler.obtainMessage( + DIRECTORY_SEARCH_MESSAGE, partitionIndex, 0, partition); + mDelayedDirectorySearchHandler.sendMessageDelayed(msg, DIRECTORY_SEARCH_DELAY_MILLIS); + } + + /** Loads the directory partition. */ + protected void loadDirectoryPartition(int partitionIndex, DirectoryPartition partition) { + Bundle args = new Bundle(); + args.putLong(DIRECTORY_ID_ARG_KEY, partition.getDirectoryId()); + getLoaderManager().restartLoader(partitionIndex, args, this); + } + + /** Cancels all queued directory loading requests. */ + private void removePendingDirectorySearchRequests() { + mDelayedDirectorySearchHandler.removeMessages(DIRECTORY_SEARCH_MESSAGE); + } + + @Override + public void onLoadFinished(Loader<Cursor> loader, Cursor data) { + if (!mEnabled) { + return; + } + + int loaderId = loader.getId(); + if (loaderId == DIRECTORY_LOADER_ID) { + mDirectoryListStatus = STATUS_LOADED; + mAdapter.changeDirectories(data); + startLoading(); + } else { + onPartitionLoaded(loaderId, data); + if (isSearchMode()) { + int directorySearchMode = getDirectorySearchMode(); + if (directorySearchMode != DirectoryListLoader.SEARCH_MODE_NONE) { + if (mDirectoryListStatus == STATUS_NOT_LOADED) { + mDirectoryListStatus = STATUS_LOADING; + getLoaderManager().initLoader(DIRECTORY_LOADER_ID, null, this); + } else { + startLoading(); + } + } + } else { + mDirectoryListStatus = STATUS_NOT_LOADED; + getLoaderManager().destroyLoader(DIRECTORY_LOADER_ID); + } + } + } + + @Override + public void onLoaderReset(Loader<Cursor> loader) {} + + protected void onPartitionLoaded(int partitionIndex, Cursor data) { + if (partitionIndex >= mAdapter.getPartitionCount()) { + // When we get unsolicited data, ignore it. This could happen + // when we are switching from search mode to the default mode. + return; + } + + mAdapter.changeCursor(partitionIndex, data); + setProfileHeader(); + + if (!isLoading()) { + completeRestoreInstanceState(); + } + } + + public boolean isLoading() { + if (mAdapter != null && mAdapter.isLoading()) { + return true; + } + + return isLoadingDirectoryList(); + + } + + public boolean isLoadingDirectoryList() { + return isSearchMode() + && getDirectorySearchMode() != DirectoryListLoader.SEARCH_MODE_NONE + && (mDirectoryListStatus == STATUS_NOT_LOADED || mDirectoryListStatus == STATUS_LOADING); + } + + @Override + public void onStop() { + super.onStop(); + mContactsPrefs.unregisterChangeListener(); + mAdapter.clearPartitions(); + } + + protected void reloadData() { + removePendingDirectorySearchRequests(); + mAdapter.onDataReload(); + mLoadPriorityDirectoriesOnly = true; + mForceLoad = true; + startLoading(); + } + + /** + * Shows a view at the top of the list with a pseudo local profile prompting the user to add a + * local profile. Default implementation does nothing. + */ + protected void setProfileHeader() { + mUserProfileExists = false; + } + + /** Provides logic that dismisses this fragment. The default implementation does nothing. */ + protected void finish() {} + + public boolean isSectionHeaderDisplayEnabled() { + return mSectionHeaderDisplayEnabled; + } + + public void setSectionHeaderDisplayEnabled(boolean flag) { + if (mSectionHeaderDisplayEnabled != flag) { + mSectionHeaderDisplayEnabled = flag; + if (mAdapter != null) { + mAdapter.setSectionHeaderDisplayEnabled(flag); + } + configureVerticalScrollbar(); + } + } + + public boolean isVisibleScrollbarEnabled() { + return mVisibleScrollbarEnabled; + } + + public void setVisibleScrollbarEnabled(boolean flag) { + if (mVisibleScrollbarEnabled != flag) { + mVisibleScrollbarEnabled = flag; + configureVerticalScrollbar(); + } + } + + private void configureVerticalScrollbar() { + boolean hasScrollbar = isVisibleScrollbarEnabled() && isSectionHeaderDisplayEnabled(); + + if (mListView != null) { + mListView.setFastScrollEnabled(hasScrollbar); + mListView.setFastScrollAlwaysVisible(hasScrollbar); + mListView.setVerticalScrollbarPosition(mVerticalScrollbarPosition); + mListView.setScrollBarStyle(ListView.SCROLLBARS_OUTSIDE_OVERLAY); + } + } + + public boolean isPhotoLoaderEnabled() { + return mPhotoLoaderEnabled; + } + + public void setPhotoLoaderEnabled(boolean flag) { + mPhotoLoaderEnabled = flag; + configurePhotoLoader(); + } + + public void setQuickContactEnabled(boolean flag) { + this.mQuickContactEnabled = flag; + } + + public void setAdjustSelectionBoundsEnabled(boolean flag) { + mAdjustSelectionBoundsEnabled = flag; + } + + public final boolean isSearchMode() { + return mSearchMode; + } + + /** + * Enter/exit search mode. This is method is tightly related to the current query, and should only + * be called by {@link #setQueryString}. + * + * <p>Also note this method doesn't call {@link #reloadData()}; {@link #setQueryString} does it. + */ + protected void setSearchMode(boolean flag) { + if (mSearchMode != flag) { + mSearchMode = flag; + setSectionHeaderDisplayEnabled(!mSearchMode); + + if (!flag) { + mDirectoryListStatus = STATUS_NOT_LOADED; + getLoaderManager().destroyLoader(DIRECTORY_LOADER_ID); + } + + if (mAdapter != null) { + mAdapter.setSearchMode(flag); + + mAdapter.clearPartitions(); + if (!flag) { + // If we are switching from search to regular display, remove all directory + // partitions after default one, assuming they are remote directories which + // should be cleaned up on exiting the search mode. + mAdapter.removeDirectoriesAfterDefault(); + } + mAdapter.configureDefaultPartition(false, flag); + } + + if (mListView != null) { + mListView.setFastScrollEnabled(!flag); + } + } + } + + public final String getQueryString() { + return mQueryString; + } + + public void setQueryString(String queryString) { + if (!TextUtils.equals(mQueryString, queryString)) { + if (mShowEmptyListForEmptyQuery && mAdapter != null && mListView != null) { + if (TextUtils.isEmpty(mQueryString)) { + // Restore the adapter if the query used to be empty. + mListView.setAdapter(mAdapter); + } else if (TextUtils.isEmpty(queryString)) { + // Instantly clear the list view if the new query is empty. + mListView.setAdapter(null); + } + } + + mQueryString = queryString; + setSearchMode(!TextUtils.isEmpty(mQueryString) || mShowEmptyListForEmptyQuery); + + if (mAdapter != null) { + mAdapter.setQueryString(queryString); + reloadData(); + } + } + } + + public void setShowEmptyListForNullQuery(boolean show) { + mShowEmptyListForEmptyQuery = show; + } + + public boolean getShowEmptyListForNullQuery() { + return mShowEmptyListForEmptyQuery; + } + + public int getDirectoryLoaderId() { + return DIRECTORY_LOADER_ID; + } + + public int getDirectorySearchMode() { + return mDirectorySearchMode; + } + + public void setDirectorySearchMode(int mode) { + mDirectorySearchMode = mode; + } + + protected int getContactNameDisplayOrder() { + return mDisplayOrder; + } + + protected void setContactNameDisplayOrder(int displayOrder) { + mDisplayOrder = displayOrder; + if (mAdapter != null) { + mAdapter.setContactNameDisplayOrder(displayOrder); + } + } + + public int getSortOrder() { + return mSortOrder; + } + + public void setSortOrder(int sortOrder) { + mSortOrder = sortOrder; + if (mAdapter != null) { + mAdapter.setSortOrder(sortOrder); + } + } + + public void setDirectoryResultLimit(int limit) { + mDirectoryResultLimit = limit; + } + + protected boolean loadPreferences() { + boolean changed = false; + if (getContactNameDisplayOrder() != mContactsPrefs.getDisplayOrder()) { + setContactNameDisplayOrder(mContactsPrefs.getDisplayOrder()); + changed = true; + } + + if (getSortOrder() != mContactsPrefs.getSortOrder()) { + setSortOrder(mContactsPrefs.getSortOrder()); + changed = true; + } + + return changed; + } + + @Override + public View onCreateView( + LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + onCreateView(inflater, container); + + boolean searchMode = isSearchMode(); + mAdapter.setSearchMode(searchMode); + mAdapter.configureDefaultPartition(false, searchMode); + mAdapter.setPhotoLoader(mPhotoManager); + mListView.setAdapter(mAdapter); + + if (!isSearchMode()) { + mListView.setFocusableInTouchMode(true); + mListView.requestFocus(); + } + + return mView; + } + + protected void onCreateView(LayoutInflater inflater, ViewGroup container) { + mView = inflateView(inflater, container); + + mListView = (ListView) mView.findViewById(android.R.id.list); + if (mListView == null) { + throw new RuntimeException( + "Your content must have a ListView whose id attribute is " + "'android.R.id.list'"); + } + + View emptyView = mView.findViewById(android.R.id.empty); + if (emptyView != null) { + mListView.setEmptyView(emptyView); + } + + mListView.setOnItemClickListener(this); + mListView.setOnItemLongClickListener(this); + mListView.setOnFocusChangeListener(this); + mListView.setOnTouchListener(this); + mListView.setFastScrollEnabled(!isSearchMode()); + + // Tell list view to not show dividers. We'll do it ourself so that we can *not* show + // them when an A-Z headers is visible. + mListView.setDividerHeight(0); + + // We manually save/restore the listview state + mListView.setSaveEnabled(false); + + configureVerticalScrollbar(); + configurePhotoLoader(); + + getAdapter().setFragmentRootView(getView()); + + ContactListViewUtils.applyCardPaddingToView(getResources(), mListView, mView); + } + + @Override + public void onHiddenChanged(boolean hidden) { + super.onHiddenChanged(hidden); + if (getActivity() != null && getView() != null && !hidden) { + // If the padding was last applied when in a hidden state, it may have been applied + // incorrectly. Therefore we need to reapply it. + ContactListViewUtils.applyCardPaddingToView(getResources(), mListView, getView()); + } + } + + protected void configurePhotoLoader() { + if (isPhotoLoaderEnabled() && mContext != null) { + if (mPhotoManager == null) { + mPhotoManager = ContactPhotoManager.getInstance(mContext); + } + if (mListView != null) { + mListView.setOnScrollListener(this); + } + if (mAdapter != null) { + mAdapter.setPhotoLoader(mPhotoManager); + } + } + } + + protected void configureAdapter() { + if (mAdapter == null) { + return; + } + + mAdapter.setQuickContactEnabled(mQuickContactEnabled); + mAdapter.setAdjustSelectionBoundsEnabled(mAdjustSelectionBoundsEnabled); + mAdapter.setQueryString(mQueryString); + mAdapter.setDirectorySearchMode(mDirectorySearchMode); + mAdapter.setPinnedPartitionHeadersEnabled(false); + mAdapter.setContactNameDisplayOrder(mDisplayOrder); + mAdapter.setSortOrder(mSortOrder); + mAdapter.setSectionHeaderDisplayEnabled(mSectionHeaderDisplayEnabled); + mAdapter.setSelectionVisible(mSelectionVisible); + mAdapter.setDirectoryResultLimit(mDirectoryResultLimit); + mAdapter.setDarkTheme(mDarkTheme); + } + + @Override + public void onScroll( + AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount) {} + + @Override + public void onScrollStateChanged(AbsListView view, int scrollState) { + if (scrollState == OnScrollListener.SCROLL_STATE_FLING) { + mPhotoManager.pause(); + } else if (isPhotoLoaderEnabled()) { + mPhotoManager.resume(); + } + } + + @Override + public void onItemClick(AdapterView<?> parent, View view, int position, long id) { + hideSoftKeyboard(); + + int adjPosition = position - mListView.getHeaderViewsCount(); + if (adjPosition >= 0) { + onItemClick(adjPosition, id); + } + } + + @Override + public boolean onItemLongClick(AdapterView<?> parent, View view, int position, long id) { + int adjPosition = position - mListView.getHeaderViewsCount(); + + if (adjPosition >= 0) { + return onItemLongClick(adjPosition, id); + } + return false; + } + + private void hideSoftKeyboard() { + // Hide soft keyboard, if visible + InputMethodManager inputMethodManager = + (InputMethodManager) mContext.getSystemService(Context.INPUT_METHOD_SERVICE); + inputMethodManager.hideSoftInputFromWindow(mListView.getWindowToken(), 0); + } + + /** Dismisses the soft keyboard when the list takes focus. */ + @Override + public void onFocusChange(View view, boolean hasFocus) { + if (view == mListView && hasFocus) { + hideSoftKeyboard(); + } + } + + /** Dismisses the soft keyboard when the list is touched. */ + @Override + public boolean onTouch(View view, MotionEvent event) { + if (view == mListView) { + hideSoftKeyboard(); + } + return false; + } + + @Override + public void onPause() { + // Save the scrolling state of the list view + mListViewTopIndex = mListView.getFirstVisiblePosition(); + View v = mListView.getChildAt(0); + mListViewTopOffset = (v == null) ? 0 : (v.getTop() - mListView.getPaddingTop()); + + super.onPause(); + removePendingDirectorySearchRequests(); + } + + @Override + public void onResume() { + super.onResume(); + // Restore the selection of the list view. See b/19982820. + // This has to be done manually because if the list view has its emptyView set, + // the scrolling state will be reset when clearPartitions() is called on the adapter. + mListView.setSelectionFromTop(mListViewTopIndex, mListViewTopOffset); + } + + /** Restore the list state after the adapter is populated. */ + protected void completeRestoreInstanceState() { + if (mListState != null) { + mListView.onRestoreInstanceState(mListState); + mListState = null; + } + } + + public void setDarkTheme(boolean value) { + mDarkTheme = value; + if (mAdapter != null) { + mAdapter.setDarkTheme(value); + } + } + + private int getDefaultVerticalScrollbarPosition() { + final Locale locale = Locale.getDefault(); + final int layoutDirection = TextUtils.getLayoutDirectionFromLocale(locale); + switch (layoutDirection) { + case View.LAYOUT_DIRECTION_RTL: + return View.SCROLLBAR_POSITION_LEFT; + case View.LAYOUT_DIRECTION_LTR: + default: + return View.SCROLLBAR_POSITION_RIGHT; + } + } +} diff --git a/java/com/android/contacts/common/list/ContactListAdapter.java b/java/com/android/contacts/common/list/ContactListAdapter.java new file mode 100644 index 000000000..6cd311811 --- /dev/null +++ b/java/com/android/contacts/common/list/ContactListAdapter.java @@ -0,0 +1,232 @@ +/* + * Copyright (C) 2010 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.common.list; + +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; +import android.provider.ContactsContract; +import android.provider.ContactsContract.Contacts; +import android.provider.ContactsContract.Directory; +import android.provider.ContactsContract.SearchSnippets; +import android.view.ViewGroup; +import com.android.contacts.common.ContactPhotoManager.DefaultImageRequest; +import com.android.contacts.common.R; +import com.android.contacts.common.preference.ContactsPreferences; + +/** + * A cursor adapter for the {@link ContactsContract.Contacts#CONTENT_TYPE} content type. Also + * includes support for including the {@link ContactsContract.Profile} record in the list. + */ +public abstract class ContactListAdapter extends ContactEntryListAdapter { + + private CharSequence mUnknownNameText; + + public ContactListAdapter(Context context) { + super(context); + + mUnknownNameText = context.getText(R.string.missing_name); + } + + protected static Uri buildSectionIndexerUri(Uri uri) { + return uri.buildUpon().appendQueryParameter(Contacts.EXTRA_ADDRESS_BOOK_INDEX, "true").build(); + } + + public Uri getContactUri(int partitionIndex, Cursor cursor) { + long contactId = cursor.getLong(ContactQuery.CONTACT_ID); + String lookupKey = cursor.getString(ContactQuery.CONTACT_LOOKUP_KEY); + Uri uri = Contacts.getLookupUri(contactId, lookupKey); + long directoryId = ((DirectoryPartition) getPartition(partitionIndex)).getDirectoryId(); + if (uri != null && directoryId != Directory.DEFAULT) { + uri = + uri.buildUpon() + .appendQueryParameter( + ContactsContract.DIRECTORY_PARAM_KEY, String.valueOf(directoryId)) + .build(); + } + return uri; + } + + @Override + protected ContactListItemView newView( + Context context, int partition, Cursor cursor, int position, ViewGroup parent) { + ContactListItemView view = super.newView(context, partition, cursor, position, parent); + view.setUnknownNameText(mUnknownNameText); + view.setQuickContactEnabled(isQuickContactEnabled()); + view.setAdjustSelectionBoundsEnabled(isAdjustSelectionBoundsEnabled()); + view.setActivatedStateSupported(isSelectionVisible()); + return view; + } + + protected void bindSectionHeaderAndDivider( + ContactListItemView view, int position, Cursor cursor) { + view.setIsSectionHeaderEnabled(isSectionHeaderDisplayEnabled()); + if (isSectionHeaderDisplayEnabled()) { + Placement placement = getItemPlacementInSection(position); + view.setSectionHeader(placement.sectionHeader); + } else { + view.setSectionHeader(null); + } + } + + protected void bindPhoto(final ContactListItemView view, int partitionIndex, Cursor cursor) { + if (!isPhotoSupported(partitionIndex)) { + view.removePhotoView(); + return; + } + + // Set the photo, if available + long photoId = 0; + if (!cursor.isNull(ContactQuery.CONTACT_PHOTO_ID)) { + photoId = cursor.getLong(ContactQuery.CONTACT_PHOTO_ID); + } + + if (photoId != 0) { + getPhotoLoader() + .loadThumbnail(view.getPhotoView(), photoId, false, getCircularPhotos(), null); + } else { + final String photoUriString = cursor.getString(ContactQuery.CONTACT_PHOTO_URI); + final Uri photoUri = photoUriString == null ? null : Uri.parse(photoUriString); + DefaultImageRequest request = null; + if (photoUri == null) { + request = + getDefaultImageRequestFromCursor( + cursor, ContactQuery.CONTACT_DISPLAY_NAME, ContactQuery.CONTACT_LOOKUP_KEY); + } + getPhotoLoader() + .loadDirectoryPhoto(view.getPhotoView(), photoUri, false, getCircularPhotos(), request); + } + } + + protected void bindNameAndViewId(final ContactListItemView view, Cursor cursor) { + view.showDisplayName(cursor, ContactQuery.CONTACT_DISPLAY_NAME); + // Note: we don't show phonetic any more (See issue 5265330) + + bindViewId(view, cursor, ContactQuery.CONTACT_ID); + } + + protected void bindPresenceAndStatusMessage(final ContactListItemView view, Cursor cursor) { + view.showPresenceAndStatusMessage( + cursor, ContactQuery.CONTACT_PRESENCE_STATUS, ContactQuery.CONTACT_CONTACT_STATUS); + } + + protected void bindSearchSnippet(final ContactListItemView view, Cursor cursor) { + view.showSnippet(cursor, ContactQuery.CONTACT_SNIPPET); + } + + @Override + public void changeCursor(int partitionIndex, Cursor cursor) { + super.changeCursor(partitionIndex, cursor); + + if (cursor == null || !cursor.moveToFirst()) { + return; + } + + // hasProfile tells whether the first row is a profile + final boolean hasProfile = cursor.getInt(ContactQuery.CONTACT_IS_USER_PROFILE) == 1; + + // Add ME profile on top of favorites + cursor.moveToFirst(); + setProfileExists(hasProfile); + } + + /** @return Projection useful for children. */ + protected final String[] getProjection(boolean forSearch) { + final int sortOrder = getContactNameDisplayOrder(); + if (forSearch) { + if (sortOrder == ContactsPreferences.DISPLAY_ORDER_PRIMARY) { + return ContactQuery.FILTER_PROJECTION_PRIMARY; + } else { + return ContactQuery.FILTER_PROJECTION_ALTERNATIVE; + } + } else { + if (sortOrder == ContactsPreferences.DISPLAY_ORDER_PRIMARY) { + return ContactQuery.CONTACT_PROJECTION_PRIMARY; + } else { + return ContactQuery.CONTACT_PROJECTION_ALTERNATIVE; + } + } + } + + protected static class ContactQuery { + + public static final int CONTACT_ID = 0; + public static final int CONTACT_DISPLAY_NAME = 1; + public static final int CONTACT_PRESENCE_STATUS = 2; + public static final int CONTACT_CONTACT_STATUS = 3; + public static final int CONTACT_PHOTO_ID = 4; + public static final int CONTACT_PHOTO_URI = 5; + public static final int CONTACT_LOOKUP_KEY = 6; + public static final int CONTACT_IS_USER_PROFILE = 7; + public static final int CONTACT_PHONETIC_NAME = 8; + public static final int CONTACT_STARRED = 9; + public static final int CONTACT_SNIPPET = 10; + private static final String[] CONTACT_PROJECTION_PRIMARY = + new String[] { + Contacts._ID, // 0 + Contacts.DISPLAY_NAME_PRIMARY, // 1 + Contacts.CONTACT_PRESENCE, // 2 + Contacts.CONTACT_STATUS, // 3 + Contacts.PHOTO_ID, // 4 + Contacts.PHOTO_THUMBNAIL_URI, // 5 + Contacts.LOOKUP_KEY, // 6 + Contacts.IS_USER_PROFILE, // 7 + Contacts.PHONETIC_NAME, // 8 + Contacts.STARRED, // 9 + }; + private static final String[] CONTACT_PROJECTION_ALTERNATIVE = + new String[] { + Contacts._ID, // 0 + Contacts.DISPLAY_NAME_ALTERNATIVE, // 1 + Contacts.CONTACT_PRESENCE, // 2 + Contacts.CONTACT_STATUS, // 3 + Contacts.PHOTO_ID, // 4 + Contacts.PHOTO_THUMBNAIL_URI, // 5 + Contacts.LOOKUP_KEY, // 6 + Contacts.IS_USER_PROFILE, // 7 + Contacts.PHONETIC_NAME, // 8 + Contacts.STARRED, // 9 + }; + private static final String[] FILTER_PROJECTION_PRIMARY = + new String[] { + Contacts._ID, // 0 + Contacts.DISPLAY_NAME_PRIMARY, // 1 + Contacts.CONTACT_PRESENCE, // 2 + Contacts.CONTACT_STATUS, // 3 + Contacts.PHOTO_ID, // 4 + Contacts.PHOTO_THUMBNAIL_URI, // 5 + Contacts.LOOKUP_KEY, // 6 + Contacts.IS_USER_PROFILE, // 7 + Contacts.PHONETIC_NAME, // 8 + Contacts.STARRED, // 9 + SearchSnippets.SNIPPET, // 10 + }; + private static final String[] FILTER_PROJECTION_ALTERNATIVE = + new String[] { + Contacts._ID, // 0 + Contacts.DISPLAY_NAME_ALTERNATIVE, // 1 + Contacts.CONTACT_PRESENCE, // 2 + Contacts.CONTACT_STATUS, // 3 + Contacts.PHOTO_ID, // 4 + Contacts.PHOTO_THUMBNAIL_URI, // 5 + Contacts.LOOKUP_KEY, // 6 + Contacts.IS_USER_PROFILE, // 7 + Contacts.PHONETIC_NAME, // 8 + Contacts.STARRED, // 9 + SearchSnippets.SNIPPET, // 10 + }; + } +} diff --git a/java/com/android/contacts/common/list/ContactListFilter.java b/java/com/android/contacts/common/list/ContactListFilter.java new file mode 100644 index 000000000..1a03bb64c --- /dev/null +++ b/java/com/android/contacts/common/list/ContactListFilter.java @@ -0,0 +1,297 @@ +/* + * Copyright (C) 2010 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.common.list; + +import android.content.SharedPreferences; +import android.graphics.drawable.Drawable; +import android.net.Uri; +import android.os.Parcel; +import android.os.Parcelable; +import android.provider.ContactsContract.RawContacts; +import android.text.TextUtils; + +/** Contact list filter parameters. */ +public final class ContactListFilter implements Comparable<ContactListFilter>, Parcelable { + + public static final int FILTER_TYPE_DEFAULT = -1; + public static final int FILTER_TYPE_ALL_ACCOUNTS = -2; + public static final int FILTER_TYPE_CUSTOM = -3; + public static final int FILTER_TYPE_STARRED = -4; + public static final int FILTER_TYPE_WITH_PHONE_NUMBERS_ONLY = -5; + public static final int FILTER_TYPE_SINGLE_CONTACT = -6; + + public static final int FILTER_TYPE_ACCOUNT = 0; + public static final Parcelable.Creator<ContactListFilter> CREATOR = + new Parcelable.Creator<ContactListFilter>() { + @Override + public ContactListFilter createFromParcel(Parcel source) { + int filterType = source.readInt(); + String accountName = source.readString(); + String accountType = source.readString(); + String dataSet = source.readString(); + return new ContactListFilter(filterType, accountType, accountName, dataSet, null); + } + + @Override + public ContactListFilter[] newArray(int size) { + return new ContactListFilter[size]; + } + }; + /** + * Obsolete filter which had been used in Honeycomb. This may be stored in {@link + * SharedPreferences}, but should be replaced with ALL filter when it is found. + * + * <p>TODO: "group" filter and relevant variables are all obsolete. Remove them. + */ + private static final int FILTER_TYPE_GROUP = 1; + + private static final String KEY_FILTER_TYPE = "filter.type"; + private static final String KEY_ACCOUNT_NAME = "filter.accountName"; + private static final String KEY_ACCOUNT_TYPE = "filter.accountType"; + private static final String KEY_DATA_SET = "filter.dataSet"; + public final int filterType; + public final String accountType; + public final String accountName; + public final String dataSet; + public final Drawable icon; + private String mId; + + public ContactListFilter( + int filterType, String accountType, String accountName, String dataSet, Drawable icon) { + this.filterType = filterType; + this.accountType = accountType; + this.accountName = accountName; + this.dataSet = dataSet; + this.icon = icon; + } + + public static ContactListFilter createFilterWithType(int filterType) { + return new ContactListFilter(filterType, null, null, null, null); + } + + public static ContactListFilter createAccountFilter( + String accountType, String accountName, String dataSet, Drawable icon) { + return new ContactListFilter( + ContactListFilter.FILTER_TYPE_ACCOUNT, accountType, accountName, dataSet, icon); + } + + /** + * Store the given {@link ContactListFilter} to preferences. If the requested filter is of type + * {@link #FILTER_TYPE_SINGLE_CONTACT} then do not save it to preferences because it is a + * temporary state. + */ + public static void storeToPreferences(SharedPreferences prefs, ContactListFilter filter) { + if (filter != null && filter.filterType == FILTER_TYPE_SINGLE_CONTACT) { + return; + } + prefs + .edit() + .putInt(KEY_FILTER_TYPE, filter == null ? FILTER_TYPE_DEFAULT : filter.filterType) + .putString(KEY_ACCOUNT_NAME, filter == null ? null : filter.accountName) + .putString(KEY_ACCOUNT_TYPE, filter == null ? null : filter.accountType) + .putString(KEY_DATA_SET, filter == null ? null : filter.dataSet) + .apply(); + } + + /** + * Try to obtain ContactListFilter object saved in SharedPreference. If there's no info there, + * return ALL filter instead. + */ + public static ContactListFilter restoreDefaultPreferences(SharedPreferences prefs) { + ContactListFilter filter = restoreFromPreferences(prefs); + if (filter == null) { + filter = ContactListFilter.createFilterWithType(FILTER_TYPE_ALL_ACCOUNTS); + } + // "Group" filter is obsolete and thus is not exposed anymore. The "single contact mode" + // should also not be stored in preferences anymore since it is a temporary state. + if (filter.filterType == FILTER_TYPE_GROUP || filter.filterType == FILTER_TYPE_SINGLE_CONTACT) { + filter = ContactListFilter.createFilterWithType(FILTER_TYPE_ALL_ACCOUNTS); + } + return filter; + } + + private static ContactListFilter restoreFromPreferences(SharedPreferences prefs) { + int filterType = prefs.getInt(KEY_FILTER_TYPE, FILTER_TYPE_DEFAULT); + if (filterType == FILTER_TYPE_DEFAULT) { + return null; + } + + String accountName = prefs.getString(KEY_ACCOUNT_NAME, null); + String accountType = prefs.getString(KEY_ACCOUNT_TYPE, null); + String dataSet = prefs.getString(KEY_DATA_SET, null); + return new ContactListFilter(filterType, accountType, accountName, dataSet, null); + } + + public static final String filterTypeToString(int filterType) { + switch (filterType) { + case FILTER_TYPE_DEFAULT: + return "FILTER_TYPE_DEFAULT"; + case FILTER_TYPE_ALL_ACCOUNTS: + return "FILTER_TYPE_ALL_ACCOUNTS"; + case FILTER_TYPE_CUSTOM: + return "FILTER_TYPE_CUSTOM"; + case FILTER_TYPE_STARRED: + return "FILTER_TYPE_STARRED"; + case FILTER_TYPE_WITH_PHONE_NUMBERS_ONLY: + return "FILTER_TYPE_WITH_PHONE_NUMBERS_ONLY"; + case FILTER_TYPE_SINGLE_CONTACT: + return "FILTER_TYPE_SINGLE_CONTACT"; + case FILTER_TYPE_ACCOUNT: + return "FILTER_TYPE_ACCOUNT"; + default: + return "(unknown)"; + } + } + + /** Returns true if this filter is based on data and may become invalid over time. */ + public boolean isValidationRequired() { + return filterType == FILTER_TYPE_ACCOUNT; + } + + @Override + public String toString() { + switch (filterType) { + case FILTER_TYPE_DEFAULT: + return "default"; + case FILTER_TYPE_ALL_ACCOUNTS: + return "all_accounts"; + case FILTER_TYPE_CUSTOM: + return "custom"; + case FILTER_TYPE_STARRED: + return "starred"; + case FILTER_TYPE_WITH_PHONE_NUMBERS_ONLY: + return "with_phones"; + case FILTER_TYPE_SINGLE_CONTACT: + return "single"; + case FILTER_TYPE_ACCOUNT: + return "account: " + + accountType + + (dataSet != null ? "/" + dataSet : "") + + " " + + accountName; + } + return super.toString(); + } + + @Override + public int compareTo(ContactListFilter another) { + int res = accountName.compareTo(another.accountName); + if (res != 0) { + return res; + } + + res = accountType.compareTo(another.accountType); + if (res != 0) { + return res; + } + + return filterType - another.filterType; + } + + @Override + public int hashCode() { + int code = filterType; + if (accountType != null) { + code = code * 31 + accountType.hashCode(); + code = code * 31 + accountName.hashCode(); + } + if (dataSet != null) { + code = code * 31 + dataSet.hashCode(); + } + return code; + } + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + + if (!(other instanceof ContactListFilter)) { + return false; + } + + ContactListFilter otherFilter = (ContactListFilter) other; + return filterType == otherFilter.filterType + && TextUtils.equals(accountName, otherFilter.accountName) + && TextUtils.equals(accountType, otherFilter.accountType) + && TextUtils.equals(dataSet, otherFilter.dataSet); + + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeInt(filterType); + dest.writeString(accountName); + dest.writeString(accountType); + dest.writeString(dataSet); + } + + @Override + public int describeContents() { + return 0; + } + + /** Returns a string that can be used as a stable persistent identifier for this filter. */ + public String getId() { + if (mId == null) { + StringBuilder sb = new StringBuilder(); + sb.append(filterType); + if (accountType != null) { + sb.append('-').append(accountType); + } + if (dataSet != null) { + sb.append('/').append(dataSet); + } + if (accountName != null) { + sb.append('-').append(accountName.replace('-', '_')); + } + mId = sb.toString(); + } + return mId; + } + + /** + * Adds the account query parameters to the given {@code uriBuilder}. + * + * @throws IllegalStateException if the filter type is not {@link #FILTER_TYPE_ACCOUNT}. + */ + public Uri.Builder addAccountQueryParameterToUrl(Uri.Builder uriBuilder) { + if (filterType != FILTER_TYPE_ACCOUNT) { + throw new IllegalStateException("filterType must be FILTER_TYPE_ACCOUNT"); + } + uriBuilder.appendQueryParameter(RawContacts.ACCOUNT_NAME, accountName); + uriBuilder.appendQueryParameter(RawContacts.ACCOUNT_TYPE, accountType); + if (!TextUtils.isEmpty(dataSet)) { + uriBuilder.appendQueryParameter(RawContacts.DATA_SET, dataSet); + } + return uriBuilder; + } + + public String toDebugString() { + final StringBuilder builder = new StringBuilder(); + builder.append("[filter type: " + filterType + " (" + filterTypeToString(filterType) + ")"); + if (filterType == FILTER_TYPE_ACCOUNT) { + builder + .append(", accountType: " + accountType) + .append(", accountName: " + accountName) + .append(", dataSet: " + dataSet); + } + builder.append(", icon: " + icon + "]"); + return builder.toString(); + } +} diff --git a/java/com/android/contacts/common/list/ContactListFilterController.java b/java/com/android/contacts/common/list/ContactListFilterController.java new file mode 100644 index 000000000..d2168f3f2 --- /dev/null +++ b/java/com/android/contacts/common/list/ContactListFilterController.java @@ -0,0 +1,170 @@ +/* + * Copyright (C) 2010 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.common.list; + +import android.content.Context; +import android.content.SharedPreferences; +import android.preference.PreferenceManager; +import com.android.contacts.common.model.AccountTypeManager; +import com.android.contacts.common.model.account.AccountWithDataSet; +import java.util.ArrayList; +import java.util.List; + +/** Manages {@link ContactListFilter}. All methods must be called from UI thread. */ +public abstract class ContactListFilterController { + + // singleton to cache the filter controller + private static ContactListFilterControllerImpl sFilterController = null; + + public static ContactListFilterController getInstance(Context context) { + // We may need to synchronize this in the future if background task will call this. + if (sFilterController == null) { + sFilterController = new ContactListFilterControllerImpl(context); + } + return sFilterController; + } + + public abstract void addListener(ContactListFilterListener listener); + + public abstract void removeListener(ContactListFilterListener listener); + + /** Return the currently-active filter. */ + public abstract ContactListFilter getFilter(); + + /** + * @param filter the filter + * @param persistent True when the given filter should be saved soon. False when the filter should + * not be saved. The latter case may happen when some Intent requires a certain type of UI + * (e.g. single contact) temporarily. + */ + public abstract void setContactListFilter(ContactListFilter filter, boolean persistent); + + public abstract void selectCustomFilter(); + + /** + * Checks if the current filter is valid and reset the filter if not. It may happen when an + * account is removed while the filter points to the account with {@link + * ContactListFilter#FILTER_TYPE_ACCOUNT} type, for example. It may also happen if the current + * filter is {@link ContactListFilter#FILTER_TYPE_SINGLE_CONTACT}, in which case, we should switch + * to the last saved filter in {@link SharedPreferences}. + */ + public abstract void checkFilterValidity(boolean notifyListeners); + + public interface ContactListFilterListener { + + void onContactListFilterChanged(); + } +} + +/** + * Stores the {@link ContactListFilter} selected by the user and saves it to {@link + * SharedPreferences} if necessary. + */ +class ContactListFilterControllerImpl extends ContactListFilterController { + + private final Context mContext; + private final List<ContactListFilterListener> mListeners = + new ArrayList<ContactListFilterListener>(); + private ContactListFilter mFilter; + + public ContactListFilterControllerImpl(Context context) { + mContext = context; + mFilter = ContactListFilter.restoreDefaultPreferences(getSharedPreferences()); + checkFilterValidity(true /* notify listeners */); + } + + @Override + public void addListener(ContactListFilterListener listener) { + mListeners.add(listener); + } + + @Override + public void removeListener(ContactListFilterListener listener) { + mListeners.remove(listener); + } + + @Override + public ContactListFilter getFilter() { + return mFilter; + } + + private SharedPreferences getSharedPreferences() { + return PreferenceManager.getDefaultSharedPreferences(mContext); + } + + @Override + public void setContactListFilter(ContactListFilter filter, boolean persistent) { + setContactListFilter(filter, persistent, true); + } + + private void setContactListFilter( + ContactListFilter filter, boolean persistent, boolean notifyListeners) { + if (!filter.equals(mFilter)) { + mFilter = filter; + if (persistent) { + ContactListFilter.storeToPreferences(getSharedPreferences(), mFilter); + } + if (notifyListeners && !mListeners.isEmpty()) { + notifyContactListFilterChanged(); + } + } + } + + @Override + public void selectCustomFilter() { + setContactListFilter( + ContactListFilter.createFilterWithType(ContactListFilter.FILTER_TYPE_CUSTOM), true); + } + + private void notifyContactListFilterChanged() { + for (ContactListFilterListener listener : mListeners) { + listener.onContactListFilterChanged(); + } + } + + @Override + public void checkFilterValidity(boolean notifyListeners) { + if (mFilter == null) { + return; + } + + switch (mFilter.filterType) { + case ContactListFilter.FILTER_TYPE_SINGLE_CONTACT: + setContactListFilter( + ContactListFilter.restoreDefaultPreferences(getSharedPreferences()), + false, + notifyListeners); + break; + case ContactListFilter.FILTER_TYPE_ACCOUNT: + if (!filterAccountExists()) { + // The current account filter points to invalid account. Use "all" filter + // instead. + setContactListFilter( + ContactListFilter.createFilterWithType(ContactListFilter.FILTER_TYPE_ALL_ACCOUNTS), + true, + notifyListeners); + } + } + } + + /** @return true if the Account for the current filter exists. */ + private boolean filterAccountExists() { + final AccountTypeManager accountTypeManager = AccountTypeManager.getInstance(mContext); + final AccountWithDataSet filterAccount = + new AccountWithDataSet(mFilter.accountName, mFilter.accountType, mFilter.dataSet); + return accountTypeManager.contains(filterAccount, false); + } +} diff --git a/java/com/android/contacts/common/list/ContactListItemView.java b/java/com/android/contacts/common/list/ContactListItemView.java new file mode 100644 index 000000000..76842483a --- /dev/null +++ b/java/com/android/contacts/common/list/ContactListItemView.java @@ -0,0 +1,1513 @@ +/* + * Copyright (C) 2010 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.common.list; + +import android.content.Context; +import android.content.res.ColorStateList; +import android.content.res.TypedArray; +import android.database.Cursor; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Rect; +import android.graphics.Typeface; +import android.graphics.drawable.Drawable; +import android.os.Bundle; +import android.provider.ContactsContract; +import android.provider.ContactsContract.Contacts; +import android.provider.ContactsContract.SearchSnippets; +import android.support.v4.content.ContextCompat; +import android.support.v4.graphics.drawable.DrawableCompat; +import android.text.Spannable; +import android.text.SpannableString; +import android.text.TextUtils; +import android.text.TextUtils.TruncateAt; +import android.util.AttributeSet; +import android.util.TypedValue; +import android.view.Gravity; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewGroup; +import android.widget.AbsListView.SelectionBoundsAdjuster; +import android.widget.ImageView; +import android.widget.ImageView.ScaleType; +import android.widget.QuickContactBadge; +import android.widget.TextView; +import com.android.contacts.common.ContactPresenceIconUtil; +import com.android.contacts.common.ContactStatusUtil; +import com.android.contacts.common.R; +import com.android.contacts.common.compat.PhoneNumberUtilsCompat; +import com.android.contacts.common.format.TextHighlighter; +import com.android.contacts.common.util.ContactDisplayUtils; +import com.android.contacts.common.util.SearchUtil; +import com.android.dialer.compat.CompatUtils; +import com.android.dialer.util.ViewUtil; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * A custom view for an item in the contact list. The view contains the contact's photo, a set of + * text views (for name, status, etc...) and icons for presence and call. The view uses no XML file + * for layout and all the measurements and layouts are done in the onMeasure and onLayout methods. + * + * <p>The layout puts the contact's photo on the right side of the view, the call icon (if present) + * to the left of the photo, the text lines are aligned to the left and the presence icon (if + * present) is set to the left of the status line. + * + * <p>The layout also supports a header (used as a header of a group of contacts) that is above the + * contact's data and a divider between contact view. + */ +public class ContactListItemView extends ViewGroup implements SelectionBoundsAdjuster { + private static final Pattern SPLIT_PATTERN = + Pattern.compile("([\\w-\\.]+)@((?:[\\w]+\\.)+)([a-zA-Z]{2,4})|[\\w]+"); + static final char SNIPPET_START_MATCH = '['; + static final char SNIPPET_END_MATCH = ']'; + /** A helper used to highlight a prefix in a text field. */ + private final TextHighlighter mTextHighlighter; + // Style values for layout and appearance + // The initialized values are defaults if none is provided through xml. + private int mPreferredHeight = 0; + private int mGapBetweenImageAndText = 0; + private int mGapBetweenLabelAndData = 0; + private int mPresenceIconMargin = 4; + private int mPresenceIconSize = 16; + private int mTextIndent = 0; + private int mTextOffsetTop; + private int mNameTextViewTextSize; + private int mHeaderWidth; + private Drawable mActivatedBackgroundDrawable; + private int mVideoCallIconSize = 32; + private int mVideoCallIconMargin = 16; + // Set in onLayout. Represent left and right position of the View on the screen. + private int mLeftOffset; + private int mRightOffset; + /** Used with {@link #mLabelView}, specifying the width ratio between label and data. */ + private int mLabelViewWidthWeight = 3; + /** Used with {@link #mDataView}, specifying the width ratio between label and data. */ + private int mDataViewWidthWeight = 5; + + private ArrayList<HighlightSequence> mNameHighlightSequence; + private ArrayList<HighlightSequence> mNumberHighlightSequence; + // Highlighting prefix for names. + private String mHighlightedPrefix; + /** Used to notify listeners when a video call icon is clicked. */ + private PhoneNumberListAdapter.Listener mPhoneNumberListAdapterListener; + /** Indicates whether to show the "video call" icon, used to initiate a video call. */ + private boolean mShowVideoCallIcon = false; + /** Indicates whether the view should leave room for the "video call" icon. */ + private boolean mSupportVideoCallIcon = false; + + private PhotoPosition mPhotoPosition = getDefaultPhotoPosition(false /* normal/non opposite */); + // Header layout data + private TextView mHeaderTextView; + private boolean mIsSectionHeaderEnabled; + // The views inside the contact view + private boolean mQuickContactEnabled = true; + private QuickContactBadge mQuickContact; + private ImageView mPhotoView; + private TextView mNameTextView; + private TextView mLabelView; + private TextView mDataView; + private TextView mSnippetView; + private TextView mStatusView; + private ImageView mPresenceIcon; + private ImageView mVideoCallIcon; + private ImageView mWorkProfileIcon; + private ColorStateList mSecondaryTextColor; + private int mDefaultPhotoViewSize = 0; + /** + * Can be effective even when {@link #mPhotoView} is null, as we want to have horizontal padding + * to align other data in this View. + */ + private int mPhotoViewWidth; + /** + * Can be effective even when {@link #mPhotoView} is null, as we want to have vertical padding. + */ + private int mPhotoViewHeight; + /** + * Only effective when {@link #mPhotoView} is null. When true all the Views on the right side of + * the photo should have horizontal padding on those left assuming there is a photo. + */ + private boolean mKeepHorizontalPaddingForPhotoView; + /** Only effective when {@link #mPhotoView} is null. */ + private boolean mKeepVerticalPaddingForPhotoView; + /** + * True when {@link #mPhotoViewWidth} and {@link #mPhotoViewHeight} are ready for being used. + * False indicates those values should be updated before being used in position calculation. + */ + private boolean mPhotoViewWidthAndHeightAreReady = false; + + private int mNameTextViewHeight; + private int mNameTextViewTextColor = Color.BLACK; + private int mPhoneticNameTextViewHeight; + private int mLabelViewHeight; + private int mDataViewHeight; + private int mSnippetTextViewHeight; + private int mStatusTextViewHeight; + private int mCheckBoxWidth; + // Holds Math.max(mLabelTextViewHeight, mDataViewHeight), assuming Label and Data share the + // same row. + private int mLabelAndDataViewMaxHeight; + private boolean mActivatedStateSupported; + private boolean mAdjustSelectionBoundsEnabled = true; + private Rect mBoundsWithoutHeader = new Rect(); + private CharSequence mUnknownNameText; + private int mPosition; + + public ContactListItemView(Context context) { + super(context); + + mTextHighlighter = new TextHighlighter(Typeface.BOLD); + mNameHighlightSequence = new ArrayList<HighlightSequence>(); + mNumberHighlightSequence = new ArrayList<HighlightSequence>(); + } + + public ContactListItemView(Context context, AttributeSet attrs, boolean supportVideoCallIcon) { + this(context, attrs); + + mSupportVideoCallIcon = supportVideoCallIcon; + } + + public ContactListItemView(Context context, AttributeSet attrs) { + super(context, attrs); + + TypedArray a; + + if (R.styleable.ContactListItemView != null) { + // Read all style values + a = getContext().obtainStyledAttributes(attrs, R.styleable.ContactListItemView); + mPreferredHeight = + a.getDimensionPixelSize( + R.styleable.ContactListItemView_list_item_height, mPreferredHeight); + mActivatedBackgroundDrawable = + a.getDrawable(R.styleable.ContactListItemView_activated_background); + + mGapBetweenImageAndText = + a.getDimensionPixelOffset( + R.styleable.ContactListItemView_list_item_gap_between_image_and_text, + mGapBetweenImageAndText); + mGapBetweenLabelAndData = + a.getDimensionPixelOffset( + R.styleable.ContactListItemView_list_item_gap_between_label_and_data, + mGapBetweenLabelAndData); + mPresenceIconMargin = + a.getDimensionPixelOffset( + R.styleable.ContactListItemView_list_item_presence_icon_margin, mPresenceIconMargin); + mPresenceIconSize = + a.getDimensionPixelOffset( + R.styleable.ContactListItemView_list_item_presence_icon_size, mPresenceIconSize); + mDefaultPhotoViewSize = + a.getDimensionPixelOffset( + R.styleable.ContactListItemView_list_item_photo_size, mDefaultPhotoViewSize); + mTextIndent = + a.getDimensionPixelOffset( + R.styleable.ContactListItemView_list_item_text_indent, mTextIndent); + mTextOffsetTop = + a.getDimensionPixelOffset( + R.styleable.ContactListItemView_list_item_text_offset_top, mTextOffsetTop); + mDataViewWidthWeight = + a.getInteger( + R.styleable.ContactListItemView_list_item_data_width_weight, mDataViewWidthWeight); + mLabelViewWidthWeight = + a.getInteger( + R.styleable.ContactListItemView_list_item_label_width_weight, mLabelViewWidthWeight); + mNameTextViewTextColor = + a.getColor( + R.styleable.ContactListItemView_list_item_name_text_color, mNameTextViewTextColor); + mNameTextViewTextSize = + (int) + a.getDimension( + R.styleable.ContactListItemView_list_item_name_text_size, + (int) getResources().getDimension(R.dimen.contact_browser_list_item_text_size)); + mVideoCallIconSize = + a.getDimensionPixelOffset( + R.styleable.ContactListItemView_list_item_video_call_icon_size, mVideoCallIconSize); + mVideoCallIconMargin = + a.getDimensionPixelOffset( + R.styleable.ContactListItemView_list_item_video_call_icon_margin, + mVideoCallIconMargin); + + setPaddingRelative( + a.getDimensionPixelOffset(R.styleable.ContactListItemView_list_item_padding_left, 0), + a.getDimensionPixelOffset(R.styleable.ContactListItemView_list_item_padding_top, 0), + a.getDimensionPixelOffset(R.styleable.ContactListItemView_list_item_padding_right, 0), + a.getDimensionPixelOffset(R.styleable.ContactListItemView_list_item_padding_bottom, 0)); + + a.recycle(); + } + + mTextHighlighter = new TextHighlighter(Typeface.BOLD); + + if (R.styleable.Theme != null) { + a = getContext().obtainStyledAttributes(R.styleable.Theme); + mSecondaryTextColor = a.getColorStateList(R.styleable.Theme_android_textColorSecondary); + a.recycle(); + } + + mHeaderWidth = getResources().getDimensionPixelSize(R.dimen.contact_list_section_header_width); + + if (mActivatedBackgroundDrawable != null) { + mActivatedBackgroundDrawable.setCallback(this); + } + + mNameHighlightSequence = new ArrayList<HighlightSequence>(); + mNumberHighlightSequence = new ArrayList<HighlightSequence>(); + + setLayoutDirection(View.LAYOUT_DIRECTION_LOCALE); + } + + public static final PhotoPosition getDefaultPhotoPosition(boolean opposite) { + final Locale locale = Locale.getDefault(); + final int layoutDirection = TextUtils.getLayoutDirectionFromLocale(locale); + switch (layoutDirection) { + case View.LAYOUT_DIRECTION_RTL: + return (opposite ? PhotoPosition.LEFT : PhotoPosition.RIGHT); + case View.LAYOUT_DIRECTION_LTR: + default: + return (opposite ? PhotoPosition.RIGHT : PhotoPosition.LEFT); + } + } + + /** + * Helper method for splitting a string into tokens. The lists passed in are populated with the + * tokens and offsets into the content of each token. The tokenization function parses e-mail + * addresses as a single token; otherwise it splits on any non-alphanumeric character. + * + * @param content Content to split. + * @return List of token strings. + */ + private static List<String> split(String content) { + final Matcher matcher = SPLIT_PATTERN.matcher(content); + final ArrayList<String> tokens = new ArrayList<>(); + while (matcher.find()) { + tokens.add(matcher.group()); + } + return tokens; + } + + public void setUnknownNameText(CharSequence unknownNameText) { + mUnknownNameText = unknownNameText; + } + + public void setQuickContactEnabled(boolean flag) { + mQuickContactEnabled = flag; + } + + /** + * Sets whether the video calling icon is shown. For the video calling icon to be shown, {@link + * #mSupportVideoCallIcon} must be {@code true}. + * + * @param showVideoCallIcon {@code true} if the video calling icon is shown, {@code false} + * otherwise. + * @param listener Listener to notify when the video calling icon is clicked. + * @param position The position in the adapater of the video calling icon. + */ + public void setShowVideoCallIcon( + boolean showVideoCallIcon, PhoneNumberListAdapter.Listener listener, int position) { + mShowVideoCallIcon = showVideoCallIcon; + mPhoneNumberListAdapterListener = listener; + mPosition = position; + + if (mShowVideoCallIcon) { + if (mVideoCallIcon == null) { + mVideoCallIcon = new ImageView(getContext()); + addView(mVideoCallIcon); + } + mVideoCallIcon.setContentDescription( + getContext().getString(R.string.description_search_video_call)); + mVideoCallIcon.setImageResource(R.drawable.ic_search_video_call); + mVideoCallIcon.setScaleType(ScaleType.CENTER); + mVideoCallIcon.setVisibility(View.VISIBLE); + mVideoCallIcon.setOnClickListener( + new OnClickListener() { + @Override + public void onClick(View v) { + // Inform the adapter that the video calling icon was clicked. + if (mPhoneNumberListAdapterListener != null) { + mPhoneNumberListAdapterListener.onVideoCallIconClicked(mPosition); + } + } + }); + } else { + if (mVideoCallIcon != null) { + mVideoCallIcon.setVisibility(View.GONE); + } + } + } + + /** + * Sets whether the view supports a video calling icon. This is independent of whether the view is + * actually showing an icon. Support for the video calling icon ensures that the layout leaves + * space for the video icon, should it be shown. + * + * @param supportVideoCallIcon {@code true} if the video call icon is supported, {@code false} + * otherwise. + */ + public void setSupportVideoCallIcon(boolean supportVideoCallIcon) { + mSupportVideoCallIcon = supportVideoCallIcon; + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + // We will match parent's width and wrap content vertically, but make sure + // height is no less than listPreferredItemHeight. + final int specWidth = resolveSize(0, widthMeasureSpec); + final int preferredHeight = mPreferredHeight; + + mNameTextViewHeight = 0; + mPhoneticNameTextViewHeight = 0; + mLabelViewHeight = 0; + mDataViewHeight = 0; + mLabelAndDataViewMaxHeight = 0; + mSnippetTextViewHeight = 0; + mStatusTextViewHeight = 0; + mCheckBoxWidth = 0; + + ensurePhotoViewSize(); + + // Width each TextView is able to use. + int effectiveWidth; + // All the other Views will honor the photo, so available width for them may be shrunk. + if (mPhotoViewWidth > 0 || mKeepHorizontalPaddingForPhotoView) { + effectiveWidth = + specWidth + - getPaddingLeft() + - getPaddingRight() + - (mPhotoViewWidth + mGapBetweenImageAndText); + } else { + effectiveWidth = specWidth - getPaddingLeft() - getPaddingRight(); + } + + if (mIsSectionHeaderEnabled) { + effectiveWidth -= mHeaderWidth + mGapBetweenImageAndText; + } + + if (mSupportVideoCallIcon) { + effectiveWidth -= (mVideoCallIconSize + mVideoCallIconMargin); + } + + // Go over all visible text views and measure actual width of each of them. + // Also calculate their heights to get the total height for this entire view. + + if (isVisible(mNameTextView)) { + // Calculate width for name text - this parallels similar measurement in onLayout. + int nameTextWidth = effectiveWidth; + if (mPhotoPosition != PhotoPosition.LEFT) { + nameTextWidth -= mTextIndent; + } + mNameTextView.measure( + MeasureSpec.makeMeasureSpec(nameTextWidth, MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)); + mNameTextViewHeight = mNameTextView.getMeasuredHeight(); + } + + // If both data (phone number/email address) and label (type like "MOBILE") are quite long, + // we should ellipsize both using appropriate ratio. + final int dataWidth; + final int labelWidth; + if (isVisible(mDataView)) { + if (isVisible(mLabelView)) { + final int totalWidth = effectiveWidth - mGapBetweenLabelAndData; + dataWidth = + ((totalWidth * mDataViewWidthWeight) / (mDataViewWidthWeight + mLabelViewWidthWeight)); + labelWidth = + ((totalWidth * mLabelViewWidthWeight) / (mDataViewWidthWeight + mLabelViewWidthWeight)); + } else { + dataWidth = effectiveWidth; + labelWidth = 0; + } + } else { + dataWidth = 0; + if (isVisible(mLabelView)) { + labelWidth = effectiveWidth; + } else { + labelWidth = 0; + } + } + + if (isVisible(mDataView)) { + mDataView.measure( + MeasureSpec.makeMeasureSpec(dataWidth, MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)); + mDataViewHeight = mDataView.getMeasuredHeight(); + } + + if (isVisible(mLabelView)) { + mLabelView.measure( + MeasureSpec.makeMeasureSpec(labelWidth, MeasureSpec.AT_MOST), + MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)); + mLabelViewHeight = mLabelView.getMeasuredHeight(); + } + mLabelAndDataViewMaxHeight = Math.max(mLabelViewHeight, mDataViewHeight); + + if (isVisible(mSnippetView)) { + mSnippetView.measure( + MeasureSpec.makeMeasureSpec(effectiveWidth, MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)); + mSnippetTextViewHeight = mSnippetView.getMeasuredHeight(); + } + + // Status view height is the biggest of the text view and the presence icon + if (isVisible(mPresenceIcon)) { + mPresenceIcon.measure( + MeasureSpec.makeMeasureSpec(mPresenceIconSize, MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(mPresenceIconSize, MeasureSpec.EXACTLY)); + mStatusTextViewHeight = mPresenceIcon.getMeasuredHeight(); + } + + if (mSupportVideoCallIcon && isVisible(mVideoCallIcon)) { + mVideoCallIcon.measure( + MeasureSpec.makeMeasureSpec(mVideoCallIconSize, MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(mVideoCallIconSize, MeasureSpec.EXACTLY)); + } + + if (isVisible(mWorkProfileIcon)) { + mWorkProfileIcon.measure( + MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED), + MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)); + mNameTextViewHeight = Math.max(mNameTextViewHeight, mWorkProfileIcon.getMeasuredHeight()); + } + + if (isVisible(mStatusView)) { + // Presence and status are in a same row, so status will be affected by icon size. + final int statusWidth; + if (isVisible(mPresenceIcon)) { + statusWidth = (effectiveWidth - mPresenceIcon.getMeasuredWidth() - mPresenceIconMargin); + } else { + statusWidth = effectiveWidth; + } + mStatusView.measure( + MeasureSpec.makeMeasureSpec(statusWidth, MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)); + mStatusTextViewHeight = Math.max(mStatusTextViewHeight, mStatusView.getMeasuredHeight()); + } + + // Calculate height including padding. + int height = + (mNameTextViewHeight + + mPhoneticNameTextViewHeight + + mLabelAndDataViewMaxHeight + + mSnippetTextViewHeight + + mStatusTextViewHeight); + + // Make sure the height is at least as high as the photo + height = Math.max(height, mPhotoViewHeight + getPaddingBottom() + getPaddingTop()); + + // Make sure height is at least the preferred height + height = Math.max(height, preferredHeight); + + // Measure the header if it is visible. + if (mHeaderTextView != null && mHeaderTextView.getVisibility() == VISIBLE) { + mHeaderTextView.measure( + MeasureSpec.makeMeasureSpec(mHeaderWidth, MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)); + } + + setMeasuredDimension(specWidth, height); + } + + @Override + protected void onLayout(boolean changed, int left, int top, int right, int bottom) { + final int height = bottom - top; + final int width = right - left; + + // Determine the vertical bounds by laying out the header first. + int topBound = 0; + int bottomBound = height; + int leftBound = getPaddingLeft(); + int rightBound = width - getPaddingRight(); + + final boolean isLayoutRtl = ViewUtil.isViewLayoutRtl(this); + + // Put the section header on the left side of the contact view. + if (mIsSectionHeaderEnabled) { + // Align the text view all the way left, to be consistent with Contacts. + if (isLayoutRtl) { + rightBound = width; + } else { + leftBound = 0; + } + if (mHeaderTextView != null) { + int headerHeight = mHeaderTextView.getMeasuredHeight(); + int headerTopBound = (bottomBound + topBound - headerHeight) / 2 + mTextOffsetTop; + + mHeaderTextView.layout( + isLayoutRtl ? rightBound - mHeaderWidth : leftBound, + headerTopBound, + isLayoutRtl ? rightBound : leftBound + mHeaderWidth, + headerTopBound + headerHeight); + } + if (isLayoutRtl) { + rightBound -= mHeaderWidth; + } else { + leftBound += mHeaderWidth; + } + } + + mBoundsWithoutHeader.set(left + leftBound, topBound, left + rightBound, bottomBound); + mLeftOffset = left + leftBound; + mRightOffset = left + rightBound; + if (mIsSectionHeaderEnabled) { + if (isLayoutRtl) { + rightBound -= mGapBetweenImageAndText; + } else { + leftBound += mGapBetweenImageAndText; + } + } + + if (mActivatedStateSupported && isActivated()) { + mActivatedBackgroundDrawable.setBounds(mBoundsWithoutHeader); + } + + final View photoView = mQuickContact != null ? mQuickContact : mPhotoView; + if (mPhotoPosition == PhotoPosition.LEFT) { + // Photo is the left most view. All the other Views should on the right of the photo. + if (photoView != null) { + // Center the photo vertically + final int photoTop = topBound + (bottomBound - topBound - mPhotoViewHeight) / 2; + photoView.layout( + leftBound, photoTop, leftBound + mPhotoViewWidth, photoTop + mPhotoViewHeight); + leftBound += mPhotoViewWidth + mGapBetweenImageAndText; + } else if (mKeepHorizontalPaddingForPhotoView) { + // Draw nothing but keep the padding. + leftBound += mPhotoViewWidth + mGapBetweenImageAndText; + } + } else { + // Photo is the right most view. Right bound should be adjusted that way. + if (photoView != null) { + // Center the photo vertically + final int photoTop = topBound + (bottomBound - topBound - mPhotoViewHeight) / 2; + photoView.layout( + rightBound - mPhotoViewWidth, photoTop, rightBound, photoTop + mPhotoViewHeight); + rightBound -= (mPhotoViewWidth + mGapBetweenImageAndText); + } else if (mKeepHorizontalPaddingForPhotoView) { + // Draw nothing but keep the padding. + rightBound -= (mPhotoViewWidth + mGapBetweenImageAndText); + } + + // Add indent between left-most padding and texts. + leftBound += mTextIndent; + } + + if (mSupportVideoCallIcon) { + // Place the video call button at the end of the list (e.g. take into account RTL mode). + if (isVisible(mVideoCallIcon)) { + // Center the video icon vertically + final int videoIconTop = topBound + (bottomBound - topBound - mVideoCallIconSize) / 2; + + if (!isLayoutRtl) { + // When photo is on left, video icon is placed on the right edge. + mVideoCallIcon.layout( + rightBound - mVideoCallIconSize, + videoIconTop, + rightBound, + videoIconTop + mVideoCallIconSize); + } else { + // When photo is on right, video icon is placed on the left edge. + mVideoCallIcon.layout( + leftBound, + videoIconTop, + leftBound + mVideoCallIconSize, + videoIconTop + mVideoCallIconSize); + } + } + + if (mPhotoPosition == PhotoPosition.LEFT) { + rightBound -= (mVideoCallIconSize + mVideoCallIconMargin); + } else { + leftBound += mVideoCallIconSize + mVideoCallIconMargin; + } + } + + // Center text vertically, then apply the top offset. + final int totalTextHeight = + mNameTextViewHeight + + mPhoneticNameTextViewHeight + + mLabelAndDataViewMaxHeight + + mSnippetTextViewHeight + + mStatusTextViewHeight; + int textTopBound = (bottomBound + topBound - totalTextHeight) / 2 + mTextOffsetTop; + + // Work Profile icon align top + int workProfileIconWidth = 0; + if (isVisible(mWorkProfileIcon)) { + workProfileIconWidth = mWorkProfileIcon.getMeasuredWidth(); + final int distanceFromEnd = mCheckBoxWidth > 0 ? mCheckBoxWidth + mGapBetweenImageAndText : 0; + if (mPhotoPosition == PhotoPosition.LEFT) { + // When photo is on left, label is placed on the right edge of the list item. + mWorkProfileIcon.layout( + rightBound - workProfileIconWidth - distanceFromEnd, + textTopBound, + rightBound - distanceFromEnd, + textTopBound + mNameTextViewHeight); + } else { + // When photo is on right, label is placed on the left of data view. + mWorkProfileIcon.layout( + leftBound + distanceFromEnd, + textTopBound, + leftBound + workProfileIconWidth + distanceFromEnd, + textTopBound + mNameTextViewHeight); + } + } + + // Layout all text view and presence icon + // Put name TextView first + if (isVisible(mNameTextView)) { + final int distanceFromEnd = + workProfileIconWidth + + (mCheckBoxWidth > 0 ? mCheckBoxWidth + mGapBetweenImageAndText : 0); + if (mPhotoPosition == PhotoPosition.LEFT) { + mNameTextView.layout( + leftBound, + textTopBound, + rightBound - distanceFromEnd, + textTopBound + mNameTextViewHeight); + } else { + mNameTextView.layout( + leftBound + distanceFromEnd, + textTopBound, + rightBound, + textTopBound + mNameTextViewHeight); + } + } + + if (isVisible(mNameTextView) || isVisible(mWorkProfileIcon)) { + textTopBound += mNameTextViewHeight; + } + + // Presence and status + if (isLayoutRtl) { + int statusRightBound = rightBound; + if (isVisible(mPresenceIcon)) { + int iconWidth = mPresenceIcon.getMeasuredWidth(); + mPresenceIcon.layout( + rightBound - iconWidth, textTopBound, rightBound, textTopBound + mStatusTextViewHeight); + statusRightBound -= (iconWidth + mPresenceIconMargin); + } + + if (isVisible(mStatusView)) { + mStatusView.layout( + leftBound, textTopBound, statusRightBound, textTopBound + mStatusTextViewHeight); + } + } else { + int statusLeftBound = leftBound; + if (isVisible(mPresenceIcon)) { + int iconWidth = mPresenceIcon.getMeasuredWidth(); + mPresenceIcon.layout( + leftBound, textTopBound, leftBound + iconWidth, textTopBound + mStatusTextViewHeight); + statusLeftBound += (iconWidth + mPresenceIconMargin); + } + + if (isVisible(mStatusView)) { + mStatusView.layout( + statusLeftBound, textTopBound, rightBound, textTopBound + mStatusTextViewHeight); + } + } + + if (isVisible(mStatusView) || isVisible(mPresenceIcon)) { + textTopBound += mStatusTextViewHeight; + } + + // Rest of text views + int dataLeftBound = leftBound; + + // Label and Data align bottom. + if (isVisible(mLabelView)) { + if (!isLayoutRtl) { + mLabelView.layout( + dataLeftBound, + textTopBound + mLabelAndDataViewMaxHeight - mLabelViewHeight, + rightBound, + textTopBound + mLabelAndDataViewMaxHeight); + dataLeftBound += mLabelView.getMeasuredWidth() + mGapBetweenLabelAndData; + } else { + dataLeftBound = leftBound + mLabelView.getMeasuredWidth(); + mLabelView.layout( + rightBound - mLabelView.getMeasuredWidth(), + textTopBound + mLabelAndDataViewMaxHeight - mLabelViewHeight, + rightBound, + textTopBound + mLabelAndDataViewMaxHeight); + rightBound -= (mLabelView.getMeasuredWidth() + mGapBetweenLabelAndData); + } + } + + if (isVisible(mDataView)) { + if (!isLayoutRtl) { + mDataView.layout( + dataLeftBound, + textTopBound + mLabelAndDataViewMaxHeight - mDataViewHeight, + rightBound, + textTopBound + mLabelAndDataViewMaxHeight); + } else { + mDataView.layout( + rightBound - mDataView.getMeasuredWidth(), + textTopBound + mLabelAndDataViewMaxHeight - mDataViewHeight, + rightBound, + textTopBound + mLabelAndDataViewMaxHeight); + } + } + if (isVisible(mLabelView) || isVisible(mDataView)) { + textTopBound += mLabelAndDataViewMaxHeight; + } + + if (isVisible(mSnippetView)) { + mSnippetView.layout( + leftBound, textTopBound, rightBound, textTopBound + mSnippetTextViewHeight); + } + } + + @Override + public void adjustListItemSelectionBounds(Rect bounds) { + if (mAdjustSelectionBoundsEnabled) { + bounds.top += mBoundsWithoutHeader.top; + bounds.bottom = bounds.top + mBoundsWithoutHeader.height(); + bounds.left = mBoundsWithoutHeader.left; + bounds.right = mBoundsWithoutHeader.right; + } + } + + protected boolean isVisible(View view) { + return view != null && view.getVisibility() == View.VISIBLE; + } + + /** Extracts width and height from the style */ + private void ensurePhotoViewSize() { + if (!mPhotoViewWidthAndHeightAreReady) { + mPhotoViewWidth = mPhotoViewHeight = getDefaultPhotoViewSize(); + if (!mQuickContactEnabled && mPhotoView == null) { + if (!mKeepHorizontalPaddingForPhotoView) { + mPhotoViewWidth = 0; + } + if (!mKeepVerticalPaddingForPhotoView) { + mPhotoViewHeight = 0; + } + } + + mPhotoViewWidthAndHeightAreReady = true; + } + } + + protected int getDefaultPhotoViewSize() { + return mDefaultPhotoViewSize; + } + + /** + * Gets a LayoutParam that corresponds to the default photo size. + * + * @return A new LayoutParam. + */ + private LayoutParams getDefaultPhotoLayoutParams() { + LayoutParams params = generateDefaultLayoutParams(); + params.width = getDefaultPhotoViewSize(); + params.height = params.width; + return params; + } + + @Override + protected void drawableStateChanged() { + super.drawableStateChanged(); + if (mActivatedStateSupported) { + mActivatedBackgroundDrawable.setState(getDrawableState()); + } + } + + @Override + protected boolean verifyDrawable(Drawable who) { + return who == mActivatedBackgroundDrawable || super.verifyDrawable(who); + } + + @Override + public void jumpDrawablesToCurrentState() { + super.jumpDrawablesToCurrentState(); + if (mActivatedStateSupported) { + mActivatedBackgroundDrawable.jumpToCurrentState(); + } + } + + @Override + public void dispatchDraw(Canvas canvas) { + if (mActivatedStateSupported && isActivated()) { + mActivatedBackgroundDrawable.draw(canvas); + } + + super.dispatchDraw(canvas); + } + + /** Sets section header or makes it invisible if the title is null. */ + public void setSectionHeader(String title) { + if (!TextUtils.isEmpty(title)) { + if (mHeaderTextView == null) { + mHeaderTextView = new TextView(getContext()); + mHeaderTextView.setTextAppearance(getContext(), R.style.SectionHeaderStyle); + mHeaderTextView.setGravity(Gravity.CENTER_VERTICAL | Gravity.CENTER_HORIZONTAL); + addView(mHeaderTextView); + } + setMarqueeText(mHeaderTextView, title); + mHeaderTextView.setVisibility(View.VISIBLE); + mHeaderTextView.setAllCaps(true); + } else if (mHeaderTextView != null) { + mHeaderTextView.setVisibility(View.GONE); + } + } + + public void setIsSectionHeaderEnabled(boolean isSectionHeaderEnabled) { + mIsSectionHeaderEnabled = isSectionHeaderEnabled; + } + + /** Returns the quick contact badge, creating it if necessary. */ + public QuickContactBadge getQuickContact() { + if (!mQuickContactEnabled) { + throw new IllegalStateException("QuickContact is disabled for this view"); + } + if (mQuickContact == null) { + mQuickContact = new QuickContactBadge(getContext()); + if (CompatUtils.isLollipopCompatible()) { + mQuickContact.setOverlay(null); + } + mQuickContact.setLayoutParams(getDefaultPhotoLayoutParams()); + if (mNameTextView != null) { + mQuickContact.setContentDescription( + getContext() + .getString(R.string.description_quick_contact_for, mNameTextView.getText())); + } + + addView(mQuickContact); + mPhotoViewWidthAndHeightAreReady = false; + } + return mQuickContact; + } + + /** Returns the photo view, creating it if necessary. */ + public ImageView getPhotoView() { + if (mPhotoView == null) { + mPhotoView = new ImageView(getContext()); + mPhotoView.setLayoutParams(getDefaultPhotoLayoutParams()); + // Quick contact style used above will set a background - remove it + mPhotoView.setBackground(null); + addView(mPhotoView); + mPhotoViewWidthAndHeightAreReady = false; + } + return mPhotoView; + } + + /** Removes the photo view. */ + public void removePhotoView() { + removePhotoView(false, true); + } + + /** + * Removes the photo view. + * + * @param keepHorizontalPadding True means data on the right side will have padding on left, + * pretending there is still a photo view. + * @param keepVerticalPadding True means the View will have some height enough for accommodating a + * photo view. + */ + public void removePhotoView(boolean keepHorizontalPadding, boolean keepVerticalPadding) { + mPhotoViewWidthAndHeightAreReady = false; + mKeepHorizontalPaddingForPhotoView = keepHorizontalPadding; + mKeepVerticalPaddingForPhotoView = keepVerticalPadding; + if (mPhotoView != null) { + removeView(mPhotoView); + mPhotoView = null; + } + if (mQuickContact != null) { + removeView(mQuickContact); + mQuickContact = null; + } + } + + /** + * Sets a word prefix that will be highlighted if encountered in fields like name and search + * snippet. This will disable the mask highlighting for names. + * + * <p>NOTE: must be all upper-case + */ + public void setHighlightedPrefix(String upperCasePrefix) { + mHighlightedPrefix = upperCasePrefix; + } + + /** Clears previously set highlight sequences for the view. */ + public void clearHighlightSequences() { + mNameHighlightSequence.clear(); + mNumberHighlightSequence.clear(); + mHighlightedPrefix = null; + } + + /** + * Adds a highlight sequence to the name highlighter. + * + * @param start The start position of the highlight sequence. + * @param end The end position of the highlight sequence. + */ + public void addNameHighlightSequence(int start, int end) { + mNameHighlightSequence.add(new HighlightSequence(start, end)); + } + + /** + * Adds a highlight sequence to the number highlighter. + * + * @param start The start position of the highlight sequence. + * @param end The end position of the highlight sequence. + */ + public void addNumberHighlightSequence(int start, int end) { + mNumberHighlightSequence.add(new HighlightSequence(start, end)); + } + + /** Returns the text view for the contact name, creating it if necessary. */ + public TextView getNameTextView() { + if (mNameTextView == null) { + mNameTextView = new TextView(getContext()); + mNameTextView.setSingleLine(true); + mNameTextView.setEllipsize(getTextEllipsis()); + mNameTextView.setTextColor(mNameTextViewTextColor); + mNameTextView.setTextSize(TypedValue.COMPLEX_UNIT_PX, mNameTextViewTextSize); + // Manually call setActivated() since this view may be added after the first + // setActivated() call toward this whole item view. + mNameTextView.setActivated(isActivated()); + mNameTextView.setGravity(Gravity.CENTER_VERTICAL); + mNameTextView.setTextAlignment(View.TEXT_ALIGNMENT_VIEW_START); + mNameTextView.setId(R.id.cliv_name_textview); + if (CompatUtils.isLollipopCompatible()) { + mNameTextView.setElegantTextHeight(false); + } + addView(mNameTextView); + } + return mNameTextView; + } + + /** Adds or updates a text view for the data label. */ + public void setLabel(CharSequence text) { + if (TextUtils.isEmpty(text)) { + if (mLabelView != null) { + mLabelView.setVisibility(View.GONE); + } + } else { + getLabelView(); + setMarqueeText(mLabelView, text); + mLabelView.setVisibility(VISIBLE); + } + } + + /** Returns the text view for the data label, creating it if necessary. */ + public TextView getLabelView() { + if (mLabelView == null) { + mLabelView = new TextView(getContext()); + mLabelView.setLayoutParams( + new LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)); + + mLabelView.setSingleLine(true); + mLabelView.setEllipsize(getTextEllipsis()); + mLabelView.setTextAppearance(getContext(), R.style.TextAppearanceSmall); + if (mPhotoPosition == PhotoPosition.LEFT) { + mLabelView.setAllCaps(true); + } else { + mLabelView.setTypeface(mLabelView.getTypeface(), Typeface.BOLD); + } + mLabelView.setActivated(isActivated()); + mLabelView.setId(R.id.cliv_label_textview); + addView(mLabelView); + } + return mLabelView; + } + + /** + * Sets phone number for a list item. This takes care of number highlighting if the highlight mask + * exists. + */ + public void setPhoneNumber(String text) { + if (text == null) { + if (mDataView != null) { + mDataView.setVisibility(View.GONE); + } + } else { + getDataView(); + + // TODO: Format number using PhoneNumberUtils.formatNumber before assigning it to + // mDataView. Make sure that determination of the highlight sequences are done only + // after number formatting. + + // Sets phone number texts for display after highlighting it, if applicable. + // CharSequence textToSet = text; + final SpannableString textToSet = new SpannableString(text); + + if (mNumberHighlightSequence.size() != 0) { + final HighlightSequence highlightSequence = mNumberHighlightSequence.get(0); + mTextHighlighter.applyMaskingHighlight( + textToSet, highlightSequence.start, highlightSequence.end); + } + + setMarqueeText(mDataView, textToSet); + mDataView.setVisibility(VISIBLE); + + // We have a phone number as "mDataView" so make it always LTR and VIEW_START + mDataView.setTextDirection(View.TEXT_DIRECTION_LTR); + mDataView.setTextAlignment(View.TEXT_ALIGNMENT_VIEW_START); + } + } + + private void setMarqueeText(TextView textView, CharSequence text) { + if (getTextEllipsis() == TruncateAt.MARQUEE) { + // To show MARQUEE correctly (with END effect during non-active state), we need + // to build Spanned with MARQUEE in addition to TextView's ellipsize setting. + final SpannableString spannable = new SpannableString(text); + spannable.setSpan( + TruncateAt.MARQUEE, 0, spannable.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + textView.setText(spannable); + } else { + textView.setText(text); + } + } + + /** Returns the text view for the data text, creating it if necessary. */ + public TextView getDataView() { + if (mDataView == null) { + mDataView = new TextView(getContext()); + mDataView.setSingleLine(true); + mDataView.setEllipsize(getTextEllipsis()); + mDataView.setTextAppearance(getContext(), R.style.TextAppearanceSmall); + mDataView.setTextAlignment(View.TEXT_ALIGNMENT_VIEW_START); + mDataView.setActivated(isActivated()); + mDataView.setId(R.id.cliv_data_view); + if (CompatUtils.isLollipopCompatible()) { + mDataView.setElegantTextHeight(false); + } + addView(mDataView); + } + return mDataView; + } + + /** Adds or updates a text view for the search snippet. */ + public void setSnippet(String text) { + if (TextUtils.isEmpty(text)) { + if (mSnippetView != null) { + mSnippetView.setVisibility(View.GONE); + } + } else { + mTextHighlighter.setPrefixText(getSnippetView(), text, mHighlightedPrefix); + mSnippetView.setVisibility(VISIBLE); + if (ContactDisplayUtils.isPossiblePhoneNumber(text)) { + // Give the text-to-speech engine a hint that it's a phone number + mSnippetView.setContentDescription(PhoneNumberUtilsCompat.createTtsSpannable(text)); + } else { + mSnippetView.setContentDescription(null); + } + } + } + + /** Returns the text view for the search snippet, creating it if necessary. */ + public TextView getSnippetView() { + if (mSnippetView == null) { + mSnippetView = new TextView(getContext()); + mSnippetView.setSingleLine(true); + mSnippetView.setEllipsize(getTextEllipsis()); + mSnippetView.setTextAppearance(getContext(), android.R.style.TextAppearance_Small); + mSnippetView.setTextAlignment(View.TEXT_ALIGNMENT_VIEW_START); + mSnippetView.setActivated(isActivated()); + addView(mSnippetView); + } + return mSnippetView; + } + + /** Returns the text view for the status, creating it if necessary. */ + public TextView getStatusView() { + if (mStatusView == null) { + mStatusView = new TextView(getContext()); + mStatusView.setSingleLine(true); + mStatusView.setEllipsize(getTextEllipsis()); + mStatusView.setTextAppearance(getContext(), android.R.style.TextAppearance_Small); + mStatusView.setTextColor(mSecondaryTextColor); + mStatusView.setActivated(isActivated()); + mStatusView.setTextAlignment(View.TEXT_ALIGNMENT_VIEW_START); + addView(mStatusView); + } + return mStatusView; + } + + /** Adds or updates a text view for the status. */ + public void setStatus(CharSequence text) { + if (TextUtils.isEmpty(text)) { + if (mStatusView != null) { + mStatusView.setVisibility(View.GONE); + } + } else { + getStatusView(); + setMarqueeText(mStatusView, text); + mStatusView.setVisibility(VISIBLE); + } + } + + /** Adds or updates the presence icon view. */ + public void setPresence(Drawable icon) { + if (icon != null) { + if (mPresenceIcon == null) { + mPresenceIcon = new ImageView(getContext()); + addView(mPresenceIcon); + } + mPresenceIcon.setImageDrawable(icon); + mPresenceIcon.setScaleType(ScaleType.CENTER); + mPresenceIcon.setVisibility(View.VISIBLE); + } else { + if (mPresenceIcon != null) { + mPresenceIcon.setVisibility(View.GONE); + } + } + } + + /** + * Set to display work profile icon or not + * + * @param enabled set to display work profile icon or not + */ + public void setWorkProfileIconEnabled(boolean enabled) { + if (mWorkProfileIcon != null) { + mWorkProfileIcon.setVisibility(enabled ? View.VISIBLE : View.GONE); + } else if (enabled) { + mWorkProfileIcon = new ImageView(getContext()); + addView(mWorkProfileIcon); + mWorkProfileIcon.setImageResource(R.drawable.ic_work_profile); + mWorkProfileIcon.setScaleType(ScaleType.CENTER_INSIDE); + mWorkProfileIcon.setVisibility(View.VISIBLE); + } + } + + private TruncateAt getTextEllipsis() { + return TruncateAt.MARQUEE; + } + + public void showDisplayName(Cursor cursor, int nameColumnIndex) { + CharSequence name = cursor.getString(nameColumnIndex); + setDisplayName(name); + + // Since the quick contact content description is derived from the display name and there is + // no guarantee that when the quick contact is initialized the display name is already set, + // do it here too. + if (mQuickContact != null) { + mQuickContact.setContentDescription( + getContext().getString(R.string.description_quick_contact_for, mNameTextView.getText())); + } + } + + public void setDisplayName(CharSequence name) { + if (!TextUtils.isEmpty(name)) { + // Chooses the available highlighting method for highlighting. + if (mHighlightedPrefix != null) { + name = mTextHighlighter.applyPrefixHighlight(name, mHighlightedPrefix); + } else if (mNameHighlightSequence.size() != 0) { + final SpannableString spannableName = new SpannableString(name); + for (HighlightSequence highlightSequence : mNameHighlightSequence) { + mTextHighlighter.applyMaskingHighlight( + spannableName, highlightSequence.start, highlightSequence.end); + } + name = spannableName; + } + } else { + name = mUnknownNameText; + } + setMarqueeText(getNameTextView(), name); + + if (ContactDisplayUtils.isPossiblePhoneNumber(name)) { + // Give the text-to-speech engine a hint that it's a phone number + mNameTextView.setTextDirection(View.TEXT_DIRECTION_LTR); + mNameTextView.setContentDescription( + PhoneNumberUtilsCompat.createTtsSpannable(name.toString())); + } else { + // Remove span tags of highlighting for talkback to avoid reading highlighting and rest + // of the name into two separate parts. + mNameTextView.setContentDescription(name.toString()); + } + } + + public void hideDisplayName() { + if (mNameTextView != null) { + removeView(mNameTextView); + mNameTextView = null; + } + } + + /** Sets the proper icon (star or presence or nothing) and/or status message. */ + public void showPresenceAndStatusMessage( + Cursor cursor, int presenceColumnIndex, int contactStatusColumnIndex) { + Drawable icon = null; + int presence = 0; + if (!cursor.isNull(presenceColumnIndex)) { + presence = cursor.getInt(presenceColumnIndex); + icon = ContactPresenceIconUtil.getPresenceIcon(getContext(), presence); + } + setPresence(icon); + + String statusMessage = null; + if (contactStatusColumnIndex != 0 && !cursor.isNull(contactStatusColumnIndex)) { + statusMessage = cursor.getString(contactStatusColumnIndex); + } + // If there is no status message from the contact, but there was a presence value, then use + // the default status message string + if (statusMessage == null && presence != 0) { + statusMessage = ContactStatusUtil.getStatusString(getContext(), presence); + } + setStatus(statusMessage); + } + + /** Shows search snippet. */ + public void showSnippet(Cursor cursor, int summarySnippetColumnIndex) { + if (cursor.getColumnCount() <= summarySnippetColumnIndex + || !SearchSnippets.SNIPPET.equals(cursor.getColumnName(summarySnippetColumnIndex))) { + setSnippet(null); + return; + } + + String snippet = cursor.getString(summarySnippetColumnIndex); + + // Do client side snippeting if provider didn't do it + final Bundle extras = cursor.getExtras(); + if (extras.getBoolean(ContactsContract.DEFERRED_SNIPPETING)) { + + final String query = extras.getString(ContactsContract.DEFERRED_SNIPPETING_QUERY); + + String displayName = null; + int displayNameIndex = cursor.getColumnIndex(Contacts.DISPLAY_NAME); + if (displayNameIndex >= 0) { + displayName = cursor.getString(displayNameIndex); + } + + snippet = updateSnippet(snippet, query, displayName); + + } else { + if (snippet != null) { + int from = 0; + int to = snippet.length(); + int start = snippet.indexOf(SNIPPET_START_MATCH); + if (start == -1) { + snippet = null; + } else { + int firstNl = snippet.lastIndexOf('\n', start); + if (firstNl != -1) { + from = firstNl + 1; + } + int end = snippet.lastIndexOf(SNIPPET_END_MATCH); + if (end != -1) { + int lastNl = snippet.indexOf('\n', end); + if (lastNl != -1) { + to = lastNl; + } + } + + StringBuilder sb = new StringBuilder(); + for (int i = from; i < to; i++) { + char c = snippet.charAt(i); + if (c != SNIPPET_START_MATCH && c != SNIPPET_END_MATCH) { + sb.append(c); + } + } + snippet = sb.toString(); + } + } + } + + setSnippet(snippet); + } + + /** + * Used for deferred snippets from the database. The contents come back as large strings which + * need to be extracted for display. + * + * @param snippet The snippet from the database. + * @param query The search query substring. + * @param displayName The contact display name. + * @return The proper snippet to display. + */ + private String updateSnippet(String snippet, String query, String displayName) { + + if (TextUtils.isEmpty(snippet) || TextUtils.isEmpty(query)) { + return null; + } + query = SearchUtil.cleanStartAndEndOfSearchQuery(query.toLowerCase()); + + // If the display name already contains the query term, return empty - snippets should + // not be needed in that case. + if (!TextUtils.isEmpty(displayName)) { + final String lowerDisplayName = displayName.toLowerCase(); + final List<String> nameTokens = split(lowerDisplayName); + for (String nameToken : nameTokens) { + if (nameToken.startsWith(query)) { + return null; + } + } + } + + // The snippet may contain multiple data lines. + // Show the first line that matches the query. + final SearchUtil.MatchedLine matched = SearchUtil.findMatchingLine(snippet, query); + + if (matched != null && matched.line != null) { + // Tokenize for long strings since the match may be at the end of it. + // Skip this part for short strings since the whole string will be displayed. + // Most contact strings are short so the snippetize method will be called infrequently. + final int lengthThreshold = + getResources().getInteger(R.integer.snippet_length_before_tokenize); + if (matched.line.length() > lengthThreshold) { + return snippetize(matched.line, matched.startIndex, lengthThreshold); + } else { + return matched.line; + } + } + + // No match found. + return null; + } + + private String snippetize(String line, int matchIndex, int maxLength) { + // Show up to maxLength characters. But we only show full tokens so show the last full token + // up to maxLength characters. So as many starting tokens as possible before trying ending + // tokens. + int remainingLength = maxLength; + int tempRemainingLength = remainingLength; + + // Start the end token after the matched query. + int index = matchIndex; + int endTokenIndex = index; + + // Find the match token first. + while (index < line.length()) { + if (!Character.isLetterOrDigit(line.charAt(index))) { + endTokenIndex = index; + remainingLength = tempRemainingLength; + break; + } + tempRemainingLength--; + index++; + } + + // Find as much content before the match. + index = matchIndex - 1; + tempRemainingLength = remainingLength; + int startTokenIndex = matchIndex; + while (index > -1 && tempRemainingLength > 0) { + if (!Character.isLetterOrDigit(line.charAt(index))) { + startTokenIndex = index; + remainingLength = tempRemainingLength; + } + tempRemainingLength--; + index--; + } + + index = endTokenIndex; + tempRemainingLength = remainingLength; + // Find remaining content at after match. + while (index < line.length() && tempRemainingLength > 0) { + if (!Character.isLetterOrDigit(line.charAt(index))) { + endTokenIndex = index; + } + tempRemainingLength--; + index++; + } + // Append ellipse if there is content before or after. + final StringBuilder sb = new StringBuilder(); + if (startTokenIndex > 0) { + sb.append("..."); + } + sb.append(line.substring(startTokenIndex, endTokenIndex)); + if (endTokenIndex < line.length()) { + sb.append("..."); + } + return sb.toString(); + } + + public void setActivatedStateSupported(boolean flag) { + this.mActivatedStateSupported = flag; + } + + public void setAdjustSelectionBoundsEnabled(boolean enabled) { + mAdjustSelectionBoundsEnabled = enabled; + } + + @Override + public void requestLayout() { + // We will assume that once measured this will not need to resize + // itself, so there is no need to pass the layout request to the parent + // view (ListView). + forceLayout(); + } + + public void setPhotoPosition(PhotoPosition photoPosition) { + mPhotoPosition = photoPosition; + } + + /** + * Set drawable resources directly for the drawable resource of the photo view. + * + * @param drawableId Id of drawable resource. + */ + public void setDrawableResource(int drawableId) { + ImageView photo = getPhotoView(); + photo.setScaleType(ImageView.ScaleType.CENTER); + final Drawable drawable = ContextCompat.getDrawable(getContext(), drawableId); + final int iconColor = ContextCompat.getColor(getContext(), R.color.search_shortcut_icon_color); + if (CompatUtils.isLollipopCompatible()) { + photo.setImageDrawable(drawable); + photo.setImageTintList(ColorStateList.valueOf(iconColor)); + } else { + final Drawable drawableWrapper = DrawableCompat.wrap(drawable).mutate(); + DrawableCompat.setTint(drawableWrapper, iconColor); + photo.setImageDrawable(drawableWrapper); + } + } + + @Override + public boolean onTouchEvent(MotionEvent event) { + final float x = event.getX(); + final float y = event.getY(); + // If the touch event's coordinates are not within the view's header, then delegate + // to super.onTouchEvent so that regular view behavior is preserved. Otherwise, consume + // and ignore the touch event. + if (mBoundsWithoutHeader.contains((int) x, (int) y) || !pointIsInView(x, y)) { + return super.onTouchEvent(event); + } else { + return true; + } + } + + private final boolean pointIsInView(float localX, float localY) { + return localX >= mLeftOffset + && localX < mRightOffset + && localY >= 0 + && localY < (getBottom() - getTop()); + } + + /** + * Where to put contact photo. This affects the other Views' layout or look-and-feel. + * + * <p>TODO: replace enum with int constants + */ + public enum PhotoPosition { + LEFT, + RIGHT + } + + protected static class HighlightSequence { + + private final int start; + private final int end; + + HighlightSequence(int start, int end) { + this.start = start; + this.end = end; + } + } +} diff --git a/java/com/android/contacts/common/list/ContactListPinnedHeaderView.java b/java/com/android/contacts/common/list/ContactListPinnedHeaderView.java new file mode 100644 index 000000000..1f3e2bfe3 --- /dev/null +++ b/java/com/android/contacts/common/list/ContactListPinnedHeaderView.java @@ -0,0 +1,70 @@ +/* + * Copyright (C) 2010 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.common.list; + +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Color; +import android.text.TextUtils; +import android.util.AttributeSet; +import android.view.Gravity; +import android.view.View; +import android.widget.LinearLayout.LayoutParams; +import android.widget.TextView; +import com.android.contacts.common.R; + +/** A custom view for the pinned section header shown at the top of the contact list. */ +public class ContactListPinnedHeaderView extends TextView { + + public ContactListPinnedHeaderView(Context context, AttributeSet attrs, View parent) { + super(context, attrs); + + if (R.styleable.ContactListItemView == null) { + return; + } + TypedArray a = getContext().obtainStyledAttributes(attrs, R.styleable.ContactListItemView); + int backgroundColor = + a.getColor(R.styleable.ContactListItemView_list_item_background_color, Color.WHITE); + int textOffsetTop = + a.getDimensionPixelSize(R.styleable.ContactListItemView_list_item_text_offset_top, 0); + int paddingStartOffset = + a.getDimensionPixelSize(R.styleable.ContactListItemView_list_item_padding_left, 0); + int textWidth = getResources().getDimensionPixelSize(R.dimen.contact_list_section_header_width); + int widthIncludingPadding = paddingStartOffset + textWidth; + a.recycle(); + + setBackgroundColor(backgroundColor); + setTextAppearance(getContext(), R.style.SectionHeaderStyle); + setLayoutParams(new LayoutParams(textWidth, LayoutParams.WRAP_CONTENT)); + setLayoutDirection(parent.getLayoutDirection()); + setGravity(Gravity.CENTER_VERTICAL | Gravity.CENTER_HORIZONTAL); + + // Apply text top offset. Multiply by two, because we are implementing this by padding for a + // vertically centered view, rather than adjusting the position directly via a layout. + setPaddingRelative( + 0, getPaddingTop() + (textOffsetTop * 2), getPaddingEnd(), getPaddingBottom()); + } + + /** Sets section header or makes it invisible if the title is null. */ + public void setSectionHeaderTitle(String title) { + if (!TextUtils.isEmpty(title)) { + setText(title); + } else { + setVisibility(View.GONE); + } + } +} diff --git a/java/com/android/contacts/common/list/ContactTileView.java b/java/com/android/contacts/common/list/ContactTileView.java new file mode 100644 index 000000000..9273b0583 --- /dev/null +++ b/java/com/android/contacts/common/list/ContactTileView.java @@ -0,0 +1,171 @@ +/* + * Copyright (C) 2011 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.common.list; + +import android.content.Context; +import android.graphics.Rect; +import android.net.Uri; +import android.util.AttributeSet; +import android.util.Log; +import android.view.View; +import android.widget.FrameLayout; +import android.widget.ImageView; +import android.widget.TextView; +import com.android.contacts.common.ContactPhotoManager; +import com.android.contacts.common.ContactPhotoManager.DefaultImageRequest; +import com.android.contacts.common.MoreContactUtils; +import com.android.contacts.common.R; + +/** A ContactTile displays a contact's picture and name */ +public abstract class ContactTileView extends FrameLayout { + + private static final String TAG = ContactTileView.class.getSimpleName(); + protected Listener mListener; + private Uri mLookupUri; + private ImageView mPhoto; + private TextView mName; + private ContactPhotoManager mPhotoManager = null; + + public ContactTileView(Context context, AttributeSet attrs) { + super(context, attrs); + } + + @Override + protected void onFinishInflate() { + super.onFinishInflate(); + mName = (TextView) findViewById(R.id.contact_tile_name); + mPhoto = (ImageView) findViewById(R.id.contact_tile_image); + + OnClickListener listener = createClickListener(); + setOnClickListener(listener); + } + + protected OnClickListener createClickListener() { + return new OnClickListener() { + @Override + public void onClick(View v) { + if (mListener == null) { + return; + } + mListener.onContactSelected( + getLookupUri(), MoreContactUtils.getTargetRectFromView(ContactTileView.this)); + } + }; + } + + public void setPhotoManager(ContactPhotoManager photoManager) { + mPhotoManager = photoManager; + } + + /** + * Populates the data members to be displayed from the fields in {@link + * com.android.contacts.common.list.ContactEntry} + */ + public void loadFromContact(ContactEntry entry) { + + if (entry != null) { + mName.setText(getNameForView(entry)); + mLookupUri = entry.lookupUri; + + setVisibility(View.VISIBLE); + + if (mPhotoManager != null) { + DefaultImageRequest request = getDefaultImageRequest(entry.namePrimary, entry.lookupKey); + configureViewForImage(entry.photoUri == null); + if (mPhoto != null) { + mPhotoManager.loadPhoto( + mPhoto, + entry.photoUri, + getApproximateImageSize(), + isDarkTheme(), + isContactPhotoCircular(), + request); + + + } + } else { + Log.w(TAG, "contactPhotoManager not set"); + } + } else { + setVisibility(View.INVISIBLE); + } + } + + public void setListener(Listener listener) { + mListener = listener; + } + + public Uri getLookupUri() { + return mLookupUri; + } + + /** + * Returns the string that should actually be displayed as the contact's name. Subclasses can + * override this to return formatted versions of the name - i.e. first name only. + */ + protected String getNameForView(ContactEntry contactEntry) { + return contactEntry.namePrimary; + } + + /** + * Implemented by subclasses to estimate the size of the picture. This can return -1 if only a + * thumbnail is shown anyway + */ + protected abstract int getApproximateImageSize(); + + protected abstract boolean isDarkTheme(); + + /** + * Implemented by subclasses to reconfigure the view's layout and subviews, based on whether or + * not the contact has a user-defined photo. + * + * @param isDefaultImage True if the contact does not have a user-defined contact photo (which + * means a default contact image will be applied by the {@link ContactPhotoManager} + */ + protected void configureViewForImage(boolean isDefaultImage) { + // No-op by default. + } + + /** + * Implemented by subclasses to allow them to return a {@link DefaultImageRequest} with the + * various image parameters defined to match their own layouts. + * + * @param displayName The display name of the contact + * @param lookupKey The lookup key of the contact + * @return A {@link DefaultImageRequest} object with each field configured by the subclass as + * desired, or {@code null}. + */ + protected DefaultImageRequest getDefaultImageRequest(String displayName, String lookupKey) { + return new DefaultImageRequest(displayName, lookupKey, isContactPhotoCircular()); + } + + /** + * Whether contact photo should be displayed as a circular image. Implemented by subclasses so + * they can change which drawables to fetch. + */ + protected boolean isContactPhotoCircular() { + return true; + } + + public interface Listener { + + /** Notification that the contact was selected; no specific action is dictated. */ + void onContactSelected(Uri contactLookupUri, Rect viewRect); + + /** Notification that the specified number is to be called. */ + void onCallNumberDirectly(String phoneNumber); + } +} diff --git a/java/com/android/contacts/common/list/ContactsSectionIndexer.java b/java/com/android/contacts/common/list/ContactsSectionIndexer.java new file mode 100644 index 000000000..3f0f2b7ee --- /dev/null +++ b/java/com/android/contacts/common/list/ContactsSectionIndexer.java @@ -0,0 +1,119 @@ +/* + * Copyright (C) 2010 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.common.list; + +import android.text.TextUtils; +import android.widget.SectionIndexer; +import java.util.Arrays; + +/** + * A section indexer that is configured with precomputed section titles and their respective counts. + */ +public class ContactsSectionIndexer implements SectionIndexer { + + private static final String BLANK_HEADER_STRING = " "; + private String[] mSections; + private int[] mPositions; + private int mCount; + + /** + * Constructor. + * + * @param sections a non-null array + * @param counts a non-null array of the same size as <code>sections</code> + */ + public ContactsSectionIndexer(String[] sections, int[] counts) { + if (sections == null || counts == null) { + throw new NullPointerException(); + } + + if (sections.length != counts.length) { + throw new IllegalArgumentException( + "The sections and counts arrays must have the same length"); + } + + // TODO process sections/counts based on current locale and/or specific section titles + + this.mSections = sections; + mPositions = new int[counts.length]; + int position = 0; + for (int i = 0; i < counts.length; i++) { + if (TextUtils.isEmpty(mSections[i])) { + mSections[i] = BLANK_HEADER_STRING; + } else if (!mSections[i].equals(BLANK_HEADER_STRING)) { + mSections[i] = mSections[i].trim(); + } + + mPositions[i] = position; + position += counts[i]; + } + mCount = position; + } + + public Object[] getSections() { + return mSections; + } + + public int getPositionForSection(int section) { + if (section < 0 || section >= mSections.length) { + return -1; + } + + return mPositions[section]; + } + + public int getSectionForPosition(int position) { + if (position < 0 || position >= mCount) { + return -1; + } + + int index = Arrays.binarySearch(mPositions, position); + + /* + * Consider this example: section positions are 0, 3, 5; the supplied + * position is 4. The section corresponding to position 4 starts at + * position 3, so the expected return value is 1. Binary search will not + * find 4 in the array and thus will return -insertPosition-1, i.e. -3. + * To get from that number to the expected value of 1 we need to negate + * and subtract 2. + */ + return index >= 0 ? index : -index - 2; + } + + public void setProfileAndFavoritesHeader(String header, int numberOfItemsToAdd) { + if (mSections != null) { + // Don't do anything if the header is already set properly. + if (mSections.length > 0 && header.equals(mSections[0])) { + return; + } + + // Since the section indexer isn't aware of the profile at the top, we need to add a + // special section at the top for it and shift everything else down. + String[] tempSections = new String[mSections.length + 1]; + int[] tempPositions = new int[mPositions.length + 1]; + tempSections[0] = header; + tempPositions[0] = 0; + for (int i = 1; i <= mPositions.length; i++) { + tempSections[i] = mSections[i - 1]; + tempPositions[i] = mPositions[i - 1] + numberOfItemsToAdd; + } + mSections = tempSections; + mPositions = tempPositions; + mCount = mCount + numberOfItemsToAdd; + } + } +} diff --git a/java/com/android/contacts/common/list/DefaultContactListAdapter.java b/java/com/android/contacts/common/list/DefaultContactListAdapter.java new file mode 100644 index 000000000..7bcae0e0e --- /dev/null +++ b/java/com/android/contacts/common/list/DefaultContactListAdapter.java @@ -0,0 +1,216 @@ +/* + * Copyright (C) 2010 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.common.list; + +import android.content.Context; +import android.content.CursorLoader; +import android.content.SharedPreferences; +import android.database.Cursor; +import android.net.Uri; +import android.net.Uri.Builder; +import android.preference.PreferenceManager; +import android.provider.ContactsContract; +import android.provider.ContactsContract.Contacts; +import android.provider.ContactsContract.Directory; +import android.provider.ContactsContract.SearchSnippets; +import android.text.TextUtils; +import android.view.View; +import com.android.contacts.common.compat.ContactsCompat; +import com.android.contacts.common.preference.ContactsPreferences; +import java.util.ArrayList; +import java.util.List; + +/** A cursor adapter for the {@link ContactsContract.Contacts#CONTENT_TYPE} content type. */ +public class DefaultContactListAdapter extends ContactListAdapter { + + public DefaultContactListAdapter(Context context) { + super(context); + } + + @Override + public void configureLoader(CursorLoader loader, long directoryId) { + String sortOrder = null; + if (isSearchMode()) { + String query = getQueryString(); + if (query == null) { + query = ""; + } + query = query.trim(); + if (TextUtils.isEmpty(query)) { + // Regardless of the directory, we don't want anything returned, + // so let's just send a "nothing" query to the local directory. + loader.setUri(Contacts.CONTENT_URI); + loader.setProjection(getProjection(false)); + loader.setSelection("0"); + } else { + final Builder builder = ContactsCompat.getContentUri().buildUpon(); + appendSearchParameters(builder, query, directoryId); + loader.setUri(builder.build()); + loader.setProjection(getProjection(true)); + } + } else { + final ContactListFilter filter = getFilter(); + configureUri(loader, directoryId, filter); + loader.setProjection(getProjection(false)); + configureSelection(loader, directoryId, filter); + } + + if (getSortOrder() == ContactsPreferences.SORT_ORDER_PRIMARY) { + if (sortOrder == null) { + sortOrder = Contacts.SORT_KEY_PRIMARY; + } else { + sortOrder += ", " + Contacts.SORT_KEY_PRIMARY; + } + } else { + if (sortOrder == null) { + sortOrder = Contacts.SORT_KEY_ALTERNATIVE; + } else { + sortOrder += ", " + Contacts.SORT_KEY_ALTERNATIVE; + } + } + loader.setSortOrder(sortOrder); + } + + private void appendSearchParameters(Builder builder, String query, long directoryId) { + builder.appendPath(query); // Builder will encode the query + builder.appendQueryParameter(ContactsContract.DIRECTORY_PARAM_KEY, String.valueOf(directoryId)); + if (directoryId != Directory.DEFAULT && directoryId != Directory.LOCAL_INVISIBLE) { + builder.appendQueryParameter( + ContactsContract.LIMIT_PARAM_KEY, + String.valueOf(getDirectoryResultLimit(getDirectoryById(directoryId)))); + } + builder.appendQueryParameter(SearchSnippets.DEFERRED_SNIPPETING_KEY, "1"); + } + + protected void configureUri(CursorLoader loader, long directoryId, ContactListFilter filter) { + Uri uri = Contacts.CONTENT_URI; + + if (directoryId == Directory.DEFAULT && isSectionHeaderDisplayEnabled()) { + uri = ContactListAdapter.buildSectionIndexerUri(uri); + } + + // The "All accounts" filter is the same as the entire contents of Directory.DEFAULT + if (filter != null + && filter.filterType != ContactListFilter.FILTER_TYPE_CUSTOM + && filter.filterType != ContactListFilter.FILTER_TYPE_SINGLE_CONTACT) { + final Uri.Builder builder = uri.buildUpon(); + builder.appendQueryParameter( + ContactsContract.DIRECTORY_PARAM_KEY, String.valueOf(Directory.DEFAULT)); + if (filter.filterType == ContactListFilter.FILTER_TYPE_ACCOUNT) { + filter.addAccountQueryParameterToUrl(builder); + } + uri = builder.build(); + } + + loader.setUri(uri); + } + + private void configureSelection(CursorLoader loader, long directoryId, ContactListFilter filter) { + if (filter == null) { + return; + } + + if (directoryId != Directory.DEFAULT) { + return; + } + + StringBuilder selection = new StringBuilder(); + List<String> selectionArgs = new ArrayList<String>(); + + switch (filter.filterType) { + case ContactListFilter.FILTER_TYPE_ALL_ACCOUNTS: + { + // We have already added directory=0 to the URI, which takes care of this + // filter + break; + } + case ContactListFilter.FILTER_TYPE_SINGLE_CONTACT: + { + // We have already added the lookup key to the URI, which takes care of this + // filter + break; + } + case ContactListFilter.FILTER_TYPE_STARRED: + { + selection.append(Contacts.STARRED + "!=0"); + break; + } + case ContactListFilter.FILTER_TYPE_WITH_PHONE_NUMBERS_ONLY: + { + selection.append(Contacts.HAS_PHONE_NUMBER + "=1"); + break; + } + case ContactListFilter.FILTER_TYPE_CUSTOM: + { + selection.append(Contacts.IN_VISIBLE_GROUP + "=1"); + if (isCustomFilterForPhoneNumbersOnly()) { + selection.append(" AND " + Contacts.HAS_PHONE_NUMBER + "=1"); + } + break; + } + case ContactListFilter.FILTER_TYPE_ACCOUNT: + { + // We use query parameters for account filter, so no selection to add here. + break; + } + } + loader.setSelection(selection.toString()); + loader.setSelectionArgs(selectionArgs.toArray(new String[0])); + } + + @Override + protected void bindView(View itemView, int partition, Cursor cursor, int position) { + super.bindView(itemView, partition, cursor, position); + final ContactListItemView view = (ContactListItemView) itemView; + + view.setHighlightedPrefix(isSearchMode() ? getUpperCaseQueryString() : null); + + bindSectionHeaderAndDivider(view, position, cursor); + + if (isQuickContactEnabled()) { + bindQuickContact( + view, + partition, + cursor, + ContactQuery.CONTACT_PHOTO_ID, + ContactQuery.CONTACT_PHOTO_URI, + ContactQuery.CONTACT_ID, + ContactQuery.CONTACT_LOOKUP_KEY, + ContactQuery.CONTACT_DISPLAY_NAME); + } else { + if (getDisplayPhotos()) { + bindPhoto(view, partition, cursor); + } + } + + bindNameAndViewId(view, cursor); + bindPresenceAndStatusMessage(view, cursor); + + if (isSearchMode()) { + bindSearchSnippet(view, cursor); + } else { + view.setSnippet(null); + } + } + + private boolean isCustomFilterForPhoneNumbersOnly() { + // TODO: this flag should not be stored in shared prefs. It needs to be in the db. + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(getContext()); + return prefs.getBoolean( + ContactsPreferences.PREF_DISPLAY_ONLY_PHONES, + ContactsPreferences.PREF_DISPLAY_ONLY_PHONES_DEFAULT); + } +} diff --git a/java/com/android/contacts/common/list/DirectoryListLoader.java b/java/com/android/contacts/common/list/DirectoryListLoader.java new file mode 100644 index 000000000..48b098c07 --- /dev/null +++ b/java/com/android/contacts/common/list/DirectoryListLoader.java @@ -0,0 +1,201 @@ +/* + * Copyright (C) 2010 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.common.list; + +import android.content.AsyncTaskLoader; +import android.content.Context; +import android.content.pm.PackageManager; +import android.database.ContentObserver; +import android.database.Cursor; +import android.database.MatrixCursor; +import android.net.Uri; +import android.os.Handler; +import android.provider.ContactsContract.Directory; +import android.text.TextUtils; +import android.util.Log; +import com.android.contacts.common.R; +import com.android.contacts.common.compat.DirectoryCompat; + +/** A specialized loader for the list of directories, see {@link Directory}. */ +public class DirectoryListLoader extends AsyncTaskLoader<Cursor> { + + public static final int SEARCH_MODE_NONE = 0; + public static final int SEARCH_MODE_DEFAULT = 1; + public static final int SEARCH_MODE_CONTACT_SHORTCUT = 2; + public static final int SEARCH_MODE_DATA_SHORTCUT = 3; + // This is a virtual column created for a MatrixCursor. + public static final String DIRECTORY_TYPE = "directoryType"; + private static final String TAG = "ContactEntryListAdapter"; + private static final String[] RESULT_PROJECTION = { + Directory._ID, DIRECTORY_TYPE, Directory.DISPLAY_NAME, Directory.PHOTO_SUPPORT, + }; + private final ContentObserver mObserver = + new ContentObserver(new Handler()) { + @Override + public void onChange(boolean selfChange) { + forceLoad(); + } + }; + private int mDirectorySearchMode; + private boolean mLocalInvisibleDirectoryEnabled; + private MatrixCursor mDefaultDirectoryList; + + public DirectoryListLoader(Context context) { + super(context); + } + + public void setDirectorySearchMode(int mode) { + mDirectorySearchMode = mode; + } + + /** + * A flag that indicates whether the {@link Directory#LOCAL_INVISIBLE} directory should be + * included in the results. + */ + public void setLocalInvisibleDirectoryEnabled(boolean flag) { + this.mLocalInvisibleDirectoryEnabled = flag; + } + + @Override + protected void onStartLoading() { + getContext().getContentResolver().registerContentObserver(DirectoryQuery.URI, false, mObserver); + forceLoad(); + } + + @Override + protected void onStopLoading() { + getContext().getContentResolver().unregisterContentObserver(mObserver); + } + + @Override + public Cursor loadInBackground() { + if (mDirectorySearchMode == SEARCH_MODE_NONE) { + return getDefaultDirectories(); + } + + MatrixCursor result = new MatrixCursor(RESULT_PROJECTION); + Context context = getContext(); + PackageManager pm = context.getPackageManager(); + String selection; + switch (mDirectorySearchMode) { + case SEARCH_MODE_DEFAULT: + selection = null; + break; + + case SEARCH_MODE_CONTACT_SHORTCUT: + selection = Directory.SHORTCUT_SUPPORT + "=" + Directory.SHORTCUT_SUPPORT_FULL; + break; + + case SEARCH_MODE_DATA_SHORTCUT: + selection = + Directory.SHORTCUT_SUPPORT + + " IN (" + + Directory.SHORTCUT_SUPPORT_FULL + + ", " + + Directory.SHORTCUT_SUPPORT_DATA_ITEMS_ONLY + + ")"; + break; + + default: + throw new RuntimeException("Unsupported directory search mode: " + mDirectorySearchMode); + } + Cursor cursor = null; + try { + cursor = + context + .getContentResolver() + .query( + DirectoryQuery.URI, + DirectoryQuery.PROJECTION, + selection, + null, + DirectoryQuery.ORDER_BY); + + if (cursor == null) { + return result; + } + + while (cursor.moveToNext()) { + long directoryId = cursor.getLong(DirectoryQuery.ID); + if (!mLocalInvisibleDirectoryEnabled && DirectoryCompat.isInvisibleDirectory(directoryId)) { + continue; + } + String directoryType = null; + + String packageName = cursor.getString(DirectoryQuery.PACKAGE_NAME); + int typeResourceId = cursor.getInt(DirectoryQuery.TYPE_RESOURCE_ID); + if (!TextUtils.isEmpty(packageName) && typeResourceId != 0) { + try { + directoryType = pm.getResourcesForApplication(packageName).getString(typeResourceId); + } catch (Exception e) { + Log.e(TAG, "Cannot obtain directory type from package: " + packageName); + } + } + String displayName = cursor.getString(DirectoryQuery.DISPLAY_NAME); + int photoSupport = cursor.getInt(DirectoryQuery.PHOTO_SUPPORT); + result.addRow(new Object[] {directoryId, directoryType, displayName, photoSupport}); + } + } catch (RuntimeException e) { + Log.w(TAG, "Runtime Exception when querying directory"); + } finally { + if (cursor != null) { + cursor.close(); + } + } + + return result; + } + + private Cursor getDefaultDirectories() { + if (mDefaultDirectoryList == null) { + mDefaultDirectoryList = new MatrixCursor(RESULT_PROJECTION); + mDefaultDirectoryList.addRow( + new Object[] {Directory.DEFAULT, getContext().getString(R.string.contactsList), null}); + mDefaultDirectoryList.addRow( + new Object[] { + Directory.LOCAL_INVISIBLE, + getContext().getString(R.string.local_invisible_directory), + null + }); + } + return mDefaultDirectoryList; + } + + @Override + protected void onReset() { + stopLoading(); + } + + private static final class DirectoryQuery { + + public static final Uri URI = DirectoryCompat.getContentUri(); + public static final String ORDER_BY = Directory._ID; + + public static final String[] PROJECTION = { + Directory._ID, + Directory.PACKAGE_NAME, + Directory.TYPE_RESOURCE_ID, + Directory.DISPLAY_NAME, + Directory.PHOTO_SUPPORT, + }; + + public static final int ID = 0; + public static final int PACKAGE_NAME = 1; + public static final int TYPE_RESOURCE_ID = 2; + public static final int DISPLAY_NAME = 3; + public static final int PHOTO_SUPPORT = 4; + } +} diff --git a/java/com/android/contacts/common/list/DirectoryPartition.java b/java/com/android/contacts/common/list/DirectoryPartition.java new file mode 100644 index 000000000..26b851041 --- /dev/null +++ b/java/com/android/contacts/common/list/DirectoryPartition.java @@ -0,0 +1,179 @@ +/* + * Copyright (C) 2010 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.common.list; + +import android.provider.ContactsContract.Directory; +import com.android.common.widget.CompositeCursorAdapter; + +/** Model object for a {@link Directory} row. */ +public final class DirectoryPartition extends CompositeCursorAdapter.Partition { + + public static final int STATUS_NOT_LOADED = 0; + public static final int STATUS_LOADING = 1; + public static final int STATUS_LOADED = 2; + + public static final int RESULT_LIMIT_DEFAULT = -1; + + private long mDirectoryId; + private String mContentUri; + private String mDirectoryType; + private String mDisplayName; + private int mStatus; + private boolean mPriorityDirectory; + private boolean mPhotoSupported; + private int mResultLimit = RESULT_LIMIT_DEFAULT; + private boolean mDisplayNumber = true; + + private String mLabel; + + public DirectoryPartition(boolean showIfEmpty, boolean hasHeader) { + super(showIfEmpty, hasHeader); + } + + /** Directory ID, see {@link Directory}. */ + public long getDirectoryId() { + return mDirectoryId; + } + + public void setDirectoryId(long directoryId) { + this.mDirectoryId = directoryId; + } + + /** + * Directory type resolved from {@link Directory#PACKAGE_NAME} and {@link + * Directory#TYPE_RESOURCE_ID}; + */ + public String getDirectoryType() { + return mDirectoryType; + } + + public void setDirectoryType(String directoryType) { + this.mDirectoryType = directoryType; + } + + /** See {@link Directory#DISPLAY_NAME}. */ + public String getDisplayName() { + return mDisplayName; + } + + public void setDisplayName(String displayName) { + this.mDisplayName = displayName; + } + + public int getStatus() { + return mStatus; + } + + public void setStatus(int status) { + mStatus = status; + } + + public boolean isLoading() { + return mStatus == STATUS_NOT_LOADED || mStatus == STATUS_LOADING; + } + + /** Returns true if this directory should be loaded before non-priority directories. */ + public boolean isPriorityDirectory() { + return mPriorityDirectory; + } + + public void setPriorityDirectory(boolean priorityDirectory) { + mPriorityDirectory = priorityDirectory; + } + + /** Returns true if this directory supports photos. */ + public boolean isPhotoSupported() { + return mPhotoSupported; + } + + public void setPhotoSupported(boolean flag) { + this.mPhotoSupported = flag; + } + + /** + * Max number of results for this directory. Defaults to {@link #RESULT_LIMIT_DEFAULT} which + * implies using the adapter's {@link + * com.android.contacts.common.list.ContactListAdapter#getDirectoryResultLimit()} + */ + public int getResultLimit() { + return mResultLimit; + } + + public void setResultLimit(int resultLimit) { + mResultLimit = resultLimit; + } + + /** + * Used by extended directories to specify a custom content URI. Extended directories MUST have a + * content URI + */ + public String getContentUri() { + return mContentUri; + } + + public void setContentUri(String contentUri) { + mContentUri = contentUri; + } + + /** A label to display in the header next to the display name. */ + public String getLabel() { + return mLabel; + } + + public void setLabel(String label) { + mLabel = label; + } + + @Override + public String toString() { + return "DirectoryPartition{" + + "mDirectoryId=" + + mDirectoryId + + ", mContentUri='" + + mContentUri + + '\'' + + ", mDirectoryType='" + + mDirectoryType + + '\'' + + ", mDisplayName='" + + mDisplayName + + '\'' + + ", mStatus=" + + mStatus + + ", mPriorityDirectory=" + + mPriorityDirectory + + ", mPhotoSupported=" + + mPhotoSupported + + ", mResultLimit=" + + mResultLimit + + ", mLabel='" + + mLabel + + '\'' + + '}'; + } + + /** + * Whether or not to display the phone number in app that have that option - Dialer. If false, + * Phone Label should be used instead of Phone Number. + */ + public boolean isDisplayNumber() { + return mDisplayNumber; + } + + public void setDisplayNumber(boolean displayNumber) { + mDisplayNumber = displayNumber; + } +} diff --git a/java/com/android/contacts/common/list/IndexerListAdapter.java b/java/com/android/contacts/common/list/IndexerListAdapter.java new file mode 100644 index 000000000..2289f6e59 --- /dev/null +++ b/java/com/android/contacts/common/list/IndexerListAdapter.java @@ -0,0 +1,214 @@ +/* + * Copyright (C) 2010 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.common.list; + +import android.content.Context; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ListView; +import android.widget.SectionIndexer; + +/** A list adapter that supports section indexer and a pinned header. */ +public abstract class IndexerListAdapter extends PinnedHeaderListAdapter implements SectionIndexer { + + protected Context mContext; + private SectionIndexer mIndexer; + private int mIndexedPartition = 0; + private boolean mSectionHeaderDisplayEnabled; + private View mHeader; + private Placement mPlacementCache = new Placement(); + + /** Constructor. */ + public IndexerListAdapter(Context context) { + super(context); + mContext = context; + } + + /** + * Creates a section header view that will be pinned at the top of the list as the user scrolls. + */ + protected abstract View createPinnedSectionHeaderView(Context context, ViewGroup parent); + + /** Sets the title in the pinned header as the user scrolls. */ + protected abstract void setPinnedSectionTitle(View pinnedHeaderView, String title); + + public boolean isSectionHeaderDisplayEnabled() { + return mSectionHeaderDisplayEnabled; + } + + public void setSectionHeaderDisplayEnabled(boolean flag) { + this.mSectionHeaderDisplayEnabled = flag; + } + + public int getIndexedPartition() { + return mIndexedPartition; + } + + public void setIndexedPartition(int partition) { + this.mIndexedPartition = partition; + } + + public SectionIndexer getIndexer() { + return mIndexer; + } + + public void setIndexer(SectionIndexer indexer) { + mIndexer = indexer; + mPlacementCache.invalidate(); + } + + public Object[] getSections() { + if (mIndexer == null) { + return new String[] {" "}; + } else { + return mIndexer.getSections(); + } + } + + /** @return relative position of the section in the indexed partition */ + public int getPositionForSection(int sectionIndex) { + if (mIndexer == null) { + return -1; + } + + return mIndexer.getPositionForSection(sectionIndex); + } + + /** @param position relative position in the indexed partition */ + public int getSectionForPosition(int position) { + if (mIndexer == null) { + return -1; + } + + return mIndexer.getSectionForPosition(position); + } + + @Override + public int getPinnedHeaderCount() { + if (isSectionHeaderDisplayEnabled()) { + return super.getPinnedHeaderCount() + 1; + } else { + return super.getPinnedHeaderCount(); + } + } + + @Override + public View getPinnedHeaderView(int viewIndex, View convertView, ViewGroup parent) { + if (isSectionHeaderDisplayEnabled() && viewIndex == getPinnedHeaderCount() - 1) { + if (mHeader == null) { + mHeader = createPinnedSectionHeaderView(mContext, parent); + } + return mHeader; + } else { + return super.getPinnedHeaderView(viewIndex, convertView, parent); + } + } + + @Override + public void configurePinnedHeaders(PinnedHeaderListView listView) { + super.configurePinnedHeaders(listView); + + if (!isSectionHeaderDisplayEnabled()) { + return; + } + + int index = getPinnedHeaderCount() - 1; + if (mIndexer == null || getCount() == 0) { + listView.setHeaderInvisible(index, false); + } else { + int listPosition = listView.getPositionAt(listView.getTotalTopPinnedHeaderHeight()); + int position = listPosition - listView.getHeaderViewsCount(); + + int section = -1; + int partition = getPartitionForPosition(position); + if (partition == mIndexedPartition) { + int offset = getOffsetInPartition(position); + if (offset != -1) { + section = getSectionForPosition(offset); + } + } + + if (section == -1) { + listView.setHeaderInvisible(index, false); + } else { + View topChild = listView.getChildAt(listPosition); + if (topChild != null) { + // Match the pinned header's height to the height of the list item. + mHeader.setMinimumHeight(topChild.getMeasuredHeight()); + } + setPinnedSectionTitle(mHeader, (String) mIndexer.getSections()[section]); + + // Compute the item position where the current partition begins + int partitionStart = getPositionForPartition(mIndexedPartition); + if (hasHeader(mIndexedPartition)) { + partitionStart++; + } + + // Compute the item position where the next section begins + int nextSectionPosition = partitionStart + getPositionForSection(section + 1); + boolean isLastInSection = position == nextSectionPosition - 1; + listView.setFadingHeader(index, listPosition, isLastInSection); + } + } + } + + /** + * Computes the item's placement within its section and populates the {@code placement} object + * accordingly. Please note that the returned object is volatile and should be copied if the + * result needs to be used later. + */ + public Placement getItemPlacementInSection(int position) { + if (mPlacementCache.position == position) { + return mPlacementCache; + } + + mPlacementCache.position = position; + if (isSectionHeaderDisplayEnabled()) { + int section = getSectionForPosition(position); + if (section != -1 && getPositionForSection(section) == position) { + mPlacementCache.firstInSection = true; + mPlacementCache.sectionHeader = (String) getSections()[section]; + } else { + mPlacementCache.firstInSection = false; + mPlacementCache.sectionHeader = null; + } + + mPlacementCache.lastInSection = (getPositionForSection(section + 1) - 1 == position); + } else { + mPlacementCache.firstInSection = false; + mPlacementCache.lastInSection = false; + mPlacementCache.sectionHeader = null; + } + return mPlacementCache; + } + + /** + * An item view is displayed differently depending on whether it is placed at the beginning, + * middle or end of a section. It also needs to know the section header when it is at the + * beginning of a section. This object captures all this configuration. + */ + public static final class Placement { + + public boolean firstInSection; + public boolean lastInSection; + public String sectionHeader; + private int position = ListView.INVALID_POSITION; + + public void invalidate() { + position = ListView.INVALID_POSITION; + } + } +} diff --git a/java/com/android/contacts/common/list/OnPhoneNumberPickerActionListener.java b/java/com/android/contacts/common/list/OnPhoneNumberPickerActionListener.java new file mode 100644 index 000000000..89bd889e6 --- /dev/null +++ b/java/com/android/contacts/common/list/OnPhoneNumberPickerActionListener.java @@ -0,0 +1,39 @@ +/* + * Copyright (C) 2010 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.common.list; + +import android.app.ActionBar; +import android.net.Uri; +import com.android.dialer.callintent.nano.CallSpecificAppData; + +/** Action callbacks that can be sent by a phone number picker. */ +public interface OnPhoneNumberPickerActionListener { + + int CALL_INITIATION_UNKNOWN = 0; + + /** Returns the selected phone number uri to the requester. */ + void onPickDataUri(Uri dataUri, boolean isVideoCall, CallSpecificAppData callSpecificAppData); + + /** + * Returns the specified phone number to the requester. May call the specified phone number, + * either as an audio or video call. + */ + void onPickPhoneNumber( + String phoneNumber, boolean isVideoCall, CallSpecificAppData callSpecificAppData); + + /** Called when home menu in {@link ActionBar} is clicked by the user. */ + void onHomeInActionBarSelected(); +} diff --git a/java/com/android/contacts/common/list/PhoneNumberListAdapter.java b/java/com/android/contacts/common/list/PhoneNumberListAdapter.java new file mode 100644 index 000000000..c7b24229f --- /dev/null +++ b/java/com/android/contacts/common/list/PhoneNumberListAdapter.java @@ -0,0 +1,583 @@ +/* + * Copyright (C) 2010 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.common.list; + +import android.content.Context; +import android.content.CursorLoader; +import android.database.Cursor; +import android.net.Uri; +import android.net.Uri.Builder; +import android.provider.ContactsContract; +import android.provider.ContactsContract.CommonDataKinds.Callable; +import android.provider.ContactsContract.CommonDataKinds.Phone; +import android.provider.ContactsContract.CommonDataKinds.SipAddress; +import android.provider.ContactsContract.Contacts; +import android.provider.ContactsContract.Directory; +import android.text.TextUtils; +import android.util.Log; +import android.view.View; +import android.view.ViewGroup; +import com.android.contacts.common.ContactPhotoManager.DefaultImageRequest; +import com.android.contacts.common.ContactsUtils; +import com.android.contacts.common.GeoUtil; +import com.android.contacts.common.R; +import com.android.contacts.common.compat.CallableCompat; +import com.android.contacts.common.compat.DirectoryCompat; +import com.android.contacts.common.compat.PhoneCompat; +import com.android.contacts.common.extensions.PhoneDirectoryExtenderAccessor; +import com.android.contacts.common.preference.ContactsPreferences; +import com.android.contacts.common.util.Constants; +import com.android.dialer.compat.CompatUtils; +import com.android.dialer.util.CallUtil; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/** + * A cursor adapter for the {@link Phone#CONTENT_ITEM_TYPE} and {@link + * SipAddress#CONTENT_ITEM_TYPE}. + * + * <p>By default this adapter just handles phone numbers. When {@link #setUseCallableUri(boolean)} + * is called with "true", this adapter starts handling SIP addresses too, by using {@link Callable} + * API instead of {@link Phone}. + */ +public class PhoneNumberListAdapter extends ContactEntryListAdapter { + + private static final String TAG = PhoneNumberListAdapter.class.getSimpleName(); + private static final String IGNORE_NUMBER_TOO_LONG_CLAUSE = "length(" + Phone.NUMBER + ") < 1000"; + // A list of extended directories to add to the directories from the database + private final List<DirectoryPartition> mExtendedDirectories; + private final CharSequence mUnknownNameText; + // Extended directories will have ID's that are higher than any of the id's from the database, + // so that we can identify them and set them up properly. If no extended directories + // exist, this will be Long.MAX_VALUE + private long mFirstExtendedDirectoryId = Long.MAX_VALUE; + private ContactListItemView.PhotoPosition mPhotoPosition; + private boolean mUseCallableUri; + private Listener mListener; + private boolean mIsVideoEnabled; + private boolean mIsPresenceEnabled; + + public PhoneNumberListAdapter(Context context) { + super(context); + setDefaultFilterHeaderText(R.string.list_filter_phones); + mUnknownNameText = context.getText(android.R.string.unknownName); + + mExtendedDirectories = + PhoneDirectoryExtenderAccessor.get(mContext).getExtendedDirectories(mContext); + + int videoCapabilities = CallUtil.getVideoCallingAvailability(context); + mIsVideoEnabled = (videoCapabilities & CallUtil.VIDEO_CALLING_ENABLED) != 0; + mIsPresenceEnabled = (videoCapabilities & CallUtil.VIDEO_CALLING_PRESENCE) != 0; + } + + @Override + public void configureLoader(CursorLoader loader, long directoryId) { + String query = getQueryString(); + if (query == null) { + query = ""; + } + if (isExtendedDirectory(directoryId)) { + final DirectoryPartition directory = getExtendedDirectoryFromId(directoryId); + final String contentUri = directory.getContentUri(); + if (contentUri == null) { + throw new IllegalStateException("Extended directory must have a content URL: " + directory); + } + final Builder builder = Uri.parse(contentUri).buildUpon(); + builder.appendPath(query); + builder.appendQueryParameter( + ContactsContract.LIMIT_PARAM_KEY, String.valueOf(getDirectoryResultLimit(directory))); + loader.setUri(builder.build()); + loader.setProjection(PhoneQuery.PROJECTION_PRIMARY); + } else { + final boolean isRemoteDirectoryQuery = DirectoryCompat.isRemoteDirectoryId(directoryId); + final Builder builder; + if (isSearchMode()) { + final Uri baseUri; + if (isRemoteDirectoryQuery) { + baseUri = PhoneCompat.getContentFilterUri(); + } else if (mUseCallableUri) { + baseUri = CallableCompat.getContentFilterUri(); + } else { + baseUri = PhoneCompat.getContentFilterUri(); + } + builder = baseUri.buildUpon(); + builder.appendPath(query); // Builder will encode the query + builder.appendQueryParameter( + ContactsContract.DIRECTORY_PARAM_KEY, String.valueOf(directoryId)); + if (isRemoteDirectoryQuery) { + builder.appendQueryParameter( + ContactsContract.LIMIT_PARAM_KEY, + String.valueOf(getDirectoryResultLimit(getDirectoryById(directoryId)))); + } + } else { + Uri baseUri = mUseCallableUri ? Callable.CONTENT_URI : Phone.CONTENT_URI; + builder = + baseUri + .buildUpon() + .appendQueryParameter( + ContactsContract.DIRECTORY_PARAM_KEY, String.valueOf(Directory.DEFAULT)); + if (isSectionHeaderDisplayEnabled()) { + builder.appendQueryParameter(Phone.EXTRA_ADDRESS_BOOK_INDEX, "true"); + } + applyFilter(loader, builder, directoryId, getFilter()); + } + + // Ignore invalid phone numbers that are too long. These can potentially cause freezes + // in the UI and there is no reason to display them. + final String prevSelection = loader.getSelection(); + final String newSelection; + if (!TextUtils.isEmpty(prevSelection)) { + newSelection = prevSelection + " AND " + IGNORE_NUMBER_TOO_LONG_CLAUSE; + } else { + newSelection = IGNORE_NUMBER_TOO_LONG_CLAUSE; + } + loader.setSelection(newSelection); + + // Remove duplicates when it is possible. + builder.appendQueryParameter(ContactsContract.REMOVE_DUPLICATE_ENTRIES, "true"); + loader.setUri(builder.build()); + + // TODO a projection that includes the search snippet + if (getContactNameDisplayOrder() == ContactsPreferences.DISPLAY_ORDER_PRIMARY) { + loader.setProjection(PhoneQuery.PROJECTION_PRIMARY); + } else { + loader.setProjection(PhoneQuery.PROJECTION_ALTERNATIVE); + } + + if (getSortOrder() == ContactsPreferences.SORT_ORDER_PRIMARY) { + loader.setSortOrder(Phone.SORT_KEY_PRIMARY); + } else { + loader.setSortOrder(Phone.SORT_KEY_ALTERNATIVE); + } + } + } + + protected boolean isExtendedDirectory(long directoryId) { + return directoryId >= mFirstExtendedDirectoryId; + } + + private DirectoryPartition getExtendedDirectoryFromId(long directoryId) { + final int directoryIndex = (int) (directoryId - mFirstExtendedDirectoryId); + return mExtendedDirectories.get(directoryIndex); + } + + /** + * Configure {@code loader} and {@code uriBuilder} according to {@code directoryId} and {@code + * filter}. + */ + private void applyFilter( + CursorLoader loader, Uri.Builder uriBuilder, long directoryId, ContactListFilter filter) { + if (filter == null || directoryId != Directory.DEFAULT) { + return; + } + + final StringBuilder selection = new StringBuilder(); + final List<String> selectionArgs = new ArrayList<String>(); + + switch (filter.filterType) { + case ContactListFilter.FILTER_TYPE_CUSTOM: + { + selection.append(Contacts.IN_VISIBLE_GROUP + "=1"); + selection.append(" AND " + Contacts.HAS_PHONE_NUMBER + "=1"); + break; + } + case ContactListFilter.FILTER_TYPE_ACCOUNT: + { + filter.addAccountQueryParameterToUrl(uriBuilder); + break; + } + case ContactListFilter.FILTER_TYPE_ALL_ACCOUNTS: + case ContactListFilter.FILTER_TYPE_DEFAULT: + break; // No selection needed. + case ContactListFilter.FILTER_TYPE_WITH_PHONE_NUMBERS_ONLY: + break; // This adapter is always "phone only", so no selection needed either. + default: + Log.w( + TAG, + "Unsupported filter type came " + + "(type: " + + filter.filterType + + ", toString: " + + filter + + ")" + + " showing all contacts."); + // No selection. + break; + } + loader.setSelection(selection.toString()); + loader.setSelectionArgs(selectionArgs.toArray(new String[0])); + } + + public String getPhoneNumber(int position) { + final Cursor item = (Cursor) getItem(position); + return item != null ? item.getString(PhoneQuery.PHONE_NUMBER) : null; + } + + /** + * Retrieves the lookup key for the given cursor position. + * + * @param position The cursor position. + * @return The lookup key. + */ + public String getLookupKey(int position) { + final Cursor item = (Cursor) getItem(position); + return item != null ? item.getString(PhoneQuery.LOOKUP_KEY) : null; + } + + @Override + protected ContactListItemView newView( + Context context, int partition, Cursor cursor, int position, ViewGroup parent) { + ContactListItemView view = super.newView(context, partition, cursor, position, parent); + view.setUnknownNameText(mUnknownNameText); + view.setQuickContactEnabled(isQuickContactEnabled()); + view.setPhotoPosition(mPhotoPosition); + return view; + } + + protected void setHighlight(ContactListItemView view, Cursor cursor) { + view.setHighlightedPrefix(isSearchMode() ? getUpperCaseQueryString() : null); + } + + @Override + protected void bindView(View itemView, int partition, Cursor cursor, int position) { + super.bindView(itemView, partition, cursor, position); + ContactListItemView view = (ContactListItemView) itemView; + + setHighlight(view, cursor); + + // Look at elements before and after this position, checking if contact IDs are same. + // If they have one same contact ID, it means they can be grouped. + // + // In one group, only the first entry will show its photo and its name, and the other + // entries in the group show just their data (e.g. phone number, email address). + cursor.moveToPosition(position); + boolean isFirstEntry = true; + final long currentContactId = cursor.getLong(PhoneQuery.CONTACT_ID); + if (cursor.moveToPrevious() && !cursor.isBeforeFirst()) { + final long previousContactId = cursor.getLong(PhoneQuery.CONTACT_ID); + if (currentContactId == previousContactId) { + isFirstEntry = false; + } + } + cursor.moveToPosition(position); + + bindViewId(view, cursor, PhoneQuery.PHONE_ID); + + bindSectionHeaderAndDivider(view, position); + if (isFirstEntry) { + bindName(view, cursor); + if (isQuickContactEnabled()) { + bindQuickContact( + view, + partition, + cursor, + PhoneQuery.PHOTO_ID, + PhoneQuery.PHOTO_URI, + PhoneQuery.CONTACT_ID, + PhoneQuery.LOOKUP_KEY, + PhoneQuery.DISPLAY_NAME); + } else { + if (getDisplayPhotos()) { + bindPhoto(view, partition, cursor); + } + } + } else { + unbindName(view); + + view.removePhotoView(true, false); + } + + final DirectoryPartition directory = (DirectoryPartition) getPartition(partition); + + // If the first partition does not have a header, then all subsequent partitions' + // getPositionForPartition returns an index off by 1. + int partitionOffset = 0; + if (partition > 0 && !getPartition(0).getHasHeader()) { + partitionOffset = 1; + } + position += getPositionForPartition(partition) + partitionOffset; + + bindPhoneNumber(view, cursor, directory.isDisplayNumber(), position); + } + + protected void bindPhoneNumber( + ContactListItemView view, Cursor cursor, boolean displayNumber, int position) { + CharSequence label = null; + if (displayNumber && !cursor.isNull(PhoneQuery.PHONE_TYPE)) { + final int type = cursor.getInt(PhoneQuery.PHONE_TYPE); + final String customLabel = cursor.getString(PhoneQuery.PHONE_LABEL); + + // TODO cache + label = Phone.getTypeLabel(getContext().getResources(), type, customLabel); + } + view.setLabel(label); + final String text; + if (displayNumber) { + text = cursor.getString(PhoneQuery.PHONE_NUMBER); + } else { + // Display phone label. If that's null, display geocoded location for the number + final String phoneLabel = cursor.getString(PhoneQuery.PHONE_LABEL); + if (phoneLabel != null) { + text = phoneLabel; + } else { + final String phoneNumber = cursor.getString(PhoneQuery.PHONE_NUMBER); + text = GeoUtil.getGeocodedLocationFor(mContext, phoneNumber); + } + } + view.setPhoneNumber(text); + + if (CompatUtils.isVideoCompatible()) { + // Determine if carrier presence indicates the number supports video calling. + int carrierPresence = cursor.getInt(PhoneQuery.CARRIER_PRESENCE); + boolean isPresent = (carrierPresence & Phone.CARRIER_PRESENCE_VT_CAPABLE) != 0; + + boolean isVideoIconShown = mIsVideoEnabled && (!mIsPresenceEnabled || isPresent); + view.setShowVideoCallIcon(isVideoIconShown, mListener, position); + } + } + + protected void bindSectionHeaderAndDivider(final ContactListItemView view, int position) { + if (isSectionHeaderDisplayEnabled()) { + Placement placement = getItemPlacementInSection(position); + view.setSectionHeader(placement.firstInSection ? placement.sectionHeader : null); + } else { + view.setSectionHeader(null); + } + } + + protected void bindName(final ContactListItemView view, Cursor cursor) { + view.showDisplayName(cursor, PhoneQuery.DISPLAY_NAME); + // Note: we don't show phonetic names any more (see issue 5265330) + } + + protected void unbindName(final ContactListItemView view) { + view.hideDisplayName(); + } + + @Override + protected void bindWorkProfileIcon(final ContactListItemView view, int partition) { + final DirectoryPartition directory = (DirectoryPartition) getPartition(partition); + final long directoryId = directory.getDirectoryId(); + final long userType = ContactsUtils.determineUserType(directoryId, null); + // Work directory must not be a extended directory. An extended directory is custom + // directory in the app, but not a directory provided by framework. So it can't be + // USER_TYPE_WORK. + view.setWorkProfileIconEnabled( + !isExtendedDirectory(directoryId) && userType == ContactsUtils.USER_TYPE_WORK); + } + + protected void bindPhoto(final ContactListItemView view, int partitionIndex, Cursor cursor) { + if (!isPhotoSupported(partitionIndex)) { + view.removePhotoView(); + return; + } + + long photoId = 0; + if (!cursor.isNull(PhoneQuery.PHOTO_ID)) { + photoId = cursor.getLong(PhoneQuery.PHOTO_ID); + } + + if (photoId != 0) { + getPhotoLoader() + .loadThumbnail(view.getPhotoView(), photoId, false, getCircularPhotos(), null); + } else { + final String photoUriString = cursor.getString(PhoneQuery.PHOTO_URI); + final Uri photoUri = photoUriString == null ? null : Uri.parse(photoUriString); + + DefaultImageRequest request = null; + if (photoUri == null) { + final String displayName = cursor.getString(PhoneQuery.DISPLAY_NAME); + final String lookupKey = cursor.getString(PhoneQuery.LOOKUP_KEY); + request = new DefaultImageRequest(displayName, lookupKey, getCircularPhotos()); + } + getPhotoLoader() + .loadDirectoryPhoto(view.getPhotoView(), photoUri, false, getCircularPhotos(), request); + } + } + + public ContactListItemView.PhotoPosition getPhotoPosition() { + return mPhotoPosition; + } + + public void setPhotoPosition(ContactListItemView.PhotoPosition photoPosition) { + mPhotoPosition = photoPosition; + } + + public void setUseCallableUri(boolean useCallableUri) { + mUseCallableUri = useCallableUri; + } + + /** + * Override base implementation to inject extended directories between local & remote directories. + * This is done in the following steps: 1. Call base implementation to add directories from the + * cursor. 2. Iterate all base directories and establish the following information: a. The highest + * directory id so that we can assign unused id's to the extended directories. b. The index of the + * last non-remote directory. This is where we will insert extended directories. 3. Iterate the + * extended directories and for each one, assign an ID and insert it in the proper location. + */ + @Override + public void changeDirectories(Cursor cursor) { + super.changeDirectories(cursor); + if (getDirectorySearchMode() == DirectoryListLoader.SEARCH_MODE_NONE) { + return; + } + final int numExtendedDirectories = mExtendedDirectories.size(); + if (getPartitionCount() == cursor.getCount() + numExtendedDirectories) { + // already added all directories; + return; + } + // + mFirstExtendedDirectoryId = Long.MAX_VALUE; + if (numExtendedDirectories > 0) { + // The Directory.LOCAL_INVISIBLE is not in the cursor but we can't reuse it's + // "special" ID. + long maxId = Directory.LOCAL_INVISIBLE; + int insertIndex = 0; + for (int i = 0, n = getPartitionCount(); i < n; i++) { + final DirectoryPartition partition = (DirectoryPartition) getPartition(i); + final long id = partition.getDirectoryId(); + if (id > maxId) { + maxId = id; + } + if (!DirectoryCompat.isRemoteDirectoryId(id)) { + // assuming remote directories come after local, we will end up with the index + // where we should insert extended directories. This also works if there are no + // remote directories at all. + insertIndex = i + 1; + } + } + // Extended directories ID's cannot collide with base directories + mFirstExtendedDirectoryId = maxId + 1; + for (int i = 0; i < numExtendedDirectories; i++) { + final long id = mFirstExtendedDirectoryId + i; + final DirectoryPartition directory = mExtendedDirectories.get(i); + if (getPartitionByDirectoryId(id) == -1) { + addPartition(insertIndex, directory); + directory.setDirectoryId(id); + } + } + } + } + + @Override + protected Uri getContactUri( + int partitionIndex, Cursor cursor, int contactIdColumn, int lookUpKeyColumn) { + final DirectoryPartition directory = (DirectoryPartition) getPartition(partitionIndex); + final long directoryId = directory.getDirectoryId(); + if (!isExtendedDirectory(directoryId)) { + return super.getContactUri(partitionIndex, cursor, contactIdColumn, lookUpKeyColumn); + } + return Contacts.CONTENT_LOOKUP_URI + .buildUpon() + .appendPath(Constants.LOOKUP_URI_ENCODED) + .appendQueryParameter(Directory.DISPLAY_NAME, directory.getLabel()) + .appendQueryParameter(ContactsContract.DIRECTORY_PARAM_KEY, String.valueOf(directoryId)) + .encodedFragment(cursor.getString(lookUpKeyColumn)) + .build(); + } + + public Listener getListener() { + return mListener; + } + + public void setListener(Listener listener) { + mListener = listener; + } + + public interface Listener { + + void onVideoCallIconClicked(int position); + } + + public static class PhoneQuery { + + /** + * Optional key used as part of a JSON lookup key to specify an analytics category associated + * with the row. + */ + public static final String ANALYTICS_CATEGORY = "analytics_category"; + + /** + * Optional key used as part of a JSON lookup key to specify an analytics action associated with + * the row. + */ + public static final String ANALYTICS_ACTION = "analytics_action"; + + /** + * Optional key used as part of a JSON lookup key to specify an analytics value associated with + * the row. + */ + public static final String ANALYTICS_VALUE = "analytics_value"; + + public static final String[] PROJECTION_PRIMARY_INTERNAL = + new String[] { + Phone._ID, // 0 + Phone.TYPE, // 1 + Phone.LABEL, // 2 + Phone.NUMBER, // 3 + Phone.CONTACT_ID, // 4 + Phone.LOOKUP_KEY, // 5 + Phone.PHOTO_ID, // 6 + Phone.DISPLAY_NAME_PRIMARY, // 7 + Phone.PHOTO_THUMBNAIL_URI, // 8 + }; + + public static final String[] PROJECTION_PRIMARY; + public static final String[] PROJECTION_ALTERNATIVE_INTERNAL = + new String[] { + Phone._ID, // 0 + Phone.TYPE, // 1 + Phone.LABEL, // 2 + Phone.NUMBER, // 3 + Phone.CONTACT_ID, // 4 + Phone.LOOKUP_KEY, // 5 + Phone.PHOTO_ID, // 6 + Phone.DISPLAY_NAME_ALTERNATIVE, // 7 + Phone.PHOTO_THUMBNAIL_URI, // 8 + }; + public static final String[] PROJECTION_ALTERNATIVE; + public static final int PHONE_ID = 0; + public static final int PHONE_TYPE = 1; + public static final int PHONE_LABEL = 2; + public static final int PHONE_NUMBER = 3; + public static final int CONTACT_ID = 4; + public static final int LOOKUP_KEY = 5; + public static final int PHOTO_ID = 6; + public static final int DISPLAY_NAME = 7; + public static final int PHOTO_URI = 8; + public static final int CARRIER_PRESENCE = 9; + + static { + final List<String> projectionList = + new ArrayList<>(Arrays.asList(PROJECTION_PRIMARY_INTERNAL)); + if (CompatUtils.isMarshmallowCompatible()) { + projectionList.add(Phone.CARRIER_PRESENCE); // 9 + } + PROJECTION_PRIMARY = projectionList.toArray(new String[projectionList.size()]); + } + + static { + final List<String> projectionList = + new ArrayList<>(Arrays.asList(PROJECTION_ALTERNATIVE_INTERNAL)); + if (CompatUtils.isMarshmallowCompatible()) { + projectionList.add(Phone.CARRIER_PRESENCE); // 9 + } + PROJECTION_ALTERNATIVE = projectionList.toArray(new String[projectionList.size()]); + } + } +} diff --git a/java/com/android/contacts/common/list/PhoneNumberPickerFragment.java b/java/com/android/contacts/common/list/PhoneNumberPickerFragment.java new file mode 100644 index 000000000..4ae81529b --- /dev/null +++ b/java/com/android/contacts/common/list/PhoneNumberPickerFragment.java @@ -0,0 +1,402 @@ +/* + * Copyright (C) 2010 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.common.list; + +import android.content.Loader; +import android.database.Cursor; +import android.os.Bundle; +import android.support.annotation.MainThread; +import android.support.annotation.Nullable; +import android.text.TextUtils; +import android.util.ArraySet; +import android.view.LayoutInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import com.android.contacts.common.R; +import com.android.contacts.common.util.AccountFilterUtil; +import com.android.dialer.callintent.nano.CallSpecificAppData; +import com.android.dialer.common.Assert; +import com.android.dialer.common.LogUtil; +import com.android.dialer.logging.Logger; +import java.util.Set; +import org.json.JSONException; +import org.json.JSONObject; + +/** Fragment containing a phone number list for picking. */ +public class PhoneNumberPickerFragment extends ContactEntryListFragment<ContactEntryListAdapter> + implements PhoneNumberListAdapter.Listener { + + private static final String KEY_FILTER = "filter"; + private OnPhoneNumberPickerActionListener mListener; + private ContactListFilter mFilter; + private View mAccountFilterHeader; + /** + * Lives as ListView's header and is shown when {@link #mAccountFilterHeader} is set to View.GONE. + */ + private View mPaddingView; + /** true if the loader has started at least once. */ + private boolean mLoaderStarted; + + private boolean mUseCallableUri; + + private ContactListItemView.PhotoPosition mPhotoPosition = + ContactListItemView.getDefaultPhotoPosition(false /* normal/non opposite */); + + private final Set<OnLoadFinishedListener> mLoadFinishedListeners = + new ArraySet<OnLoadFinishedListener>(); + + private CursorReranker mCursorReranker; + + public PhoneNumberPickerFragment() { + setQuickContactEnabled(false); + setPhotoLoaderEnabled(true); + setSectionHeaderDisplayEnabled(true); + setDirectorySearchMode(DirectoryListLoader.SEARCH_MODE_NONE); + + // Show nothing instead of letting caller Activity show something. + setHasOptionsMenu(true); + } + + /** + * Handles a click on the video call icon for a row in the list. + * + * @param position The position in the list where the click ocurred. + */ + @Override + public void onVideoCallIconClicked(int position) { + callNumber(position, true /* isVideoCall */); + } + + public void setDirectorySearchEnabled(boolean flag) { + setDirectorySearchMode( + flag ? DirectoryListLoader.SEARCH_MODE_DEFAULT : DirectoryListLoader.SEARCH_MODE_NONE); + } + + public void setOnPhoneNumberPickerActionListener(OnPhoneNumberPickerActionListener listener) { + this.mListener = listener; + } + + public OnPhoneNumberPickerActionListener getOnPhoneNumberPickerListener() { + return mListener; + } + + @Override + protected void onCreateView(LayoutInflater inflater, ViewGroup container) { + super.onCreateView(inflater, container); + + View paddingView = inflater.inflate(R.layout.contact_detail_list_padding, null, false); + mPaddingView = paddingView.findViewById(R.id.contact_detail_list_padding); + getListView().addHeaderView(paddingView); + + mAccountFilterHeader = getView().findViewById(R.id.account_filter_header_container); + updateFilterHeaderView(); + + setVisibleScrollbarEnabled(getVisibleScrollbarEnabled()); + } + + protected boolean getVisibleScrollbarEnabled() { + return true; + } + + @Override + protected void setSearchMode(boolean flag) { + super.setSearchMode(flag); + updateFilterHeaderView(); + } + + private void updateFilterHeaderView() { + final ContactListFilter filter = getFilter(); + if (mAccountFilterHeader == null || filter == null) { + return; + } + final boolean shouldShowHeader = + !isSearchMode() + && AccountFilterUtil.updateAccountFilterTitleForPhone( + mAccountFilterHeader, filter, false); + if (shouldShowHeader) { + mPaddingView.setVisibility(View.GONE); + mAccountFilterHeader.setVisibility(View.VISIBLE); + } else { + mPaddingView.setVisibility(View.VISIBLE); + mAccountFilterHeader.setVisibility(View.GONE); + } + } + + @Override + public void restoreSavedState(Bundle savedState) { + super.restoreSavedState(savedState); + + if (savedState == null) { + return; + } + + mFilter = savedState.getParcelable(KEY_FILTER); + } + + @Override + public void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); + outState.putParcelable(KEY_FILTER, mFilter); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + final int itemId = item.getItemId(); + if (itemId == android.R.id.home) { // See ActionBar#setDisplayHomeAsUpEnabled() + if (mListener != null) { + mListener.onHomeInActionBarSelected(); + } + return true; + } + return super.onOptionsItemSelected(item); + } + + @Override + protected void onItemClick(int position, long id) { + callNumber(position, false /* isVideoCall */); + } + + /** + * Initiates a call to the number at the specified position. + * + * @param position The position. + * @param isVideoCall {@code true} if the call should be initiated as a video call, {@code false} + * otherwise. + */ + private void callNumber(int position, boolean isVideoCall) { + final String number = getPhoneNumber(position); + if (!TextUtils.isEmpty(number)) { + cacheContactInfo(position); + CallSpecificAppData callSpecificAppData = new CallSpecificAppData(); + callSpecificAppData.callInitiationType = getCallInitiationType(true /* isRemoteDirectory */); + callSpecificAppData.positionOfSelectedSearchResult = position; + callSpecificAppData.charactersInSearchString = + getQueryString() == null ? 0 : getQueryString().length(); + mListener.onPickPhoneNumber(number, isVideoCall, callSpecificAppData); + } else { + LogUtil.i( + "PhoneNumberPickerFragment.callNumber", + "item at %d was clicked before adapter is ready, ignoring", + position); + } + + // Get the lookup key and track any analytics + final String lookupKey = getLookupKey(position); + if (!TextUtils.isEmpty(lookupKey)) { + maybeTrackAnalytics(lookupKey); + } + } + + protected void cacheContactInfo(int position) { + // Not implemented. Hook for child classes + } + + protected String getPhoneNumber(int position) { + final PhoneNumberListAdapter adapter = (PhoneNumberListAdapter) getAdapter(); + return adapter.getPhoneNumber(position); + } + + protected String getLookupKey(int position) { + final PhoneNumberListAdapter adapter = (PhoneNumberListAdapter) getAdapter(); + return adapter.getLookupKey(position); + } + + @Override + protected void startLoading() { + mLoaderStarted = true; + super.startLoading(); + } + + @Override + @MainThread + public void onLoadFinished(Loader<Cursor> loader, Cursor data) { + Assert.isMainThread(); + // TODO: define and verify behavior for "Nearby places", corp directories, + // and dividers listed in UI between these categories + if (mCursorReranker != null + && data != null + && !data.isClosed() + && data.getCount() > 0 + && loader.getId() != -1) { // skip invalid directory ID of -1 + data = mCursorReranker.rerankCursor(data); + } + super.onLoadFinished(loader, data); + + // disable scroll bar if there is no data + setVisibleScrollbarEnabled(data != null && !data.isClosed() && data.getCount() > 0); + + if (data != null) { + notifyListeners(); + } + } + + /** Ranks cursor data rows and returns reference to new cursor object with reordered data. */ + public interface CursorReranker { + @MainThread + Cursor rerankCursor(Cursor data); + } + + @MainThread + public void setReranker(@Nullable CursorReranker reranker) { + Assert.isMainThread(); + mCursorReranker = reranker; + } + + /** Listener that is notified when cursor has finished loading data. */ + public interface OnLoadFinishedListener { + void onLoadFinished(); + } + + @MainThread + public void addOnLoadFinishedListener(OnLoadFinishedListener listener) { + Assert.isMainThread(); + mLoadFinishedListeners.add(listener); + } + + @MainThread + public void removeOnLoadFinishedListener(OnLoadFinishedListener listener) { + Assert.isMainThread(); + mLoadFinishedListeners.remove(listener); + } + + @MainThread + protected void notifyListeners() { + Assert.isMainThread(); + for (OnLoadFinishedListener listener : mLoadFinishedListeners) { + listener.onLoadFinished(); + } + } + + @MainThread + @Override + public void onDetach() { + Assert.isMainThread(); + mLoadFinishedListeners.clear(); + super.onDetach(); + } + + public void setUseCallableUri(boolean useCallableUri) { + mUseCallableUri = useCallableUri; + } + + public boolean usesCallableUri() { + return mUseCallableUri; + } + + @Override + protected ContactEntryListAdapter createListAdapter() { + PhoneNumberListAdapter adapter = new PhoneNumberListAdapter(getActivity()); + adapter.setDisplayPhotos(true); + adapter.setUseCallableUri(mUseCallableUri); + return adapter; + } + + @Override + protected void configureAdapter() { + super.configureAdapter(); + + final ContactEntryListAdapter adapter = getAdapter(); + if (adapter == null) { + return; + } + + if (!isSearchMode() && mFilter != null) { + adapter.setFilter(mFilter); + } + + setPhotoPosition(adapter); + } + + protected void setPhotoPosition(ContactEntryListAdapter adapter) { + ((PhoneNumberListAdapter) adapter).setPhotoPosition(mPhotoPosition); + } + + @Override + protected View inflateView(LayoutInflater inflater, ViewGroup container) { + return inflater.inflate(R.layout.contact_list_content, null); + } + + public ContactListFilter getFilter() { + return mFilter; + } + + public void setFilter(ContactListFilter filter) { + if ((mFilter == null && filter == null) || (mFilter != null && mFilter.equals(filter))) { + return; + } + + mFilter = filter; + if (mLoaderStarted) { + reloadData(); + } + updateFilterHeaderView(); + } + + public void setPhotoPosition(ContactListItemView.PhotoPosition photoPosition) { + mPhotoPosition = photoPosition; + + final PhoneNumberListAdapter adapter = (PhoneNumberListAdapter) getAdapter(); + if (adapter != null) { + adapter.setPhotoPosition(photoPosition); + } + } + + /** + * @param isRemoteDirectory {@code true} if the call was initiated using a contact/phone number + * not in the local contacts database + */ + protected int getCallInitiationType(boolean isRemoteDirectory) { + return OnPhoneNumberPickerActionListener.CALL_INITIATION_UNKNOWN; + } + + /** + * Where a lookup key contains analytic event information, logs the associated analytics event. + * + * @param lookupKey The lookup key JSON object. + */ + private void maybeTrackAnalytics(String lookupKey) { + try { + JSONObject json = new JSONObject(lookupKey); + + String analyticsCategory = + json.getString(PhoneNumberListAdapter.PhoneQuery.ANALYTICS_CATEGORY); + String analyticsAction = json.getString(PhoneNumberListAdapter.PhoneQuery.ANALYTICS_ACTION); + String analyticsValue = json.getString(PhoneNumberListAdapter.PhoneQuery.ANALYTICS_VALUE); + + if (TextUtils.isEmpty(analyticsCategory) + || TextUtils.isEmpty(analyticsAction) + || TextUtils.isEmpty(analyticsValue)) { + return; + } + + // Assume that the analytic value being tracked could be a float value, but just cast + // to a long so that the analytic server can handle it. + long value; + try { + float floatValue = Float.parseFloat(analyticsValue); + value = (long) floatValue; + } catch (NumberFormatException nfe) { + return; + } + + Logger.get(getActivity()) + .sendHitEventAnalytics(analyticsCategory, analyticsAction, "" /* label */, value); + } catch (JSONException e) { + // Not an error; just a lookup key that doesn't have the right information. + } + } +} diff --git a/java/com/android/contacts/common/list/PinnedHeaderListAdapter.java b/java/com/android/contacts/common/list/PinnedHeaderListAdapter.java new file mode 100644 index 000000000..0bdcef084 --- /dev/null +++ b/java/com/android/contacts/common/list/PinnedHeaderListAdapter.java @@ -0,0 +1,159 @@ +/* + * Copyright (C) 2010 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.common.list; + +import android.content.Context; +import android.view.View; +import android.view.ViewGroup; +import com.android.common.widget.CompositeCursorAdapter; + +/** A subclass of {@link CompositeCursorAdapter} that manages pinned partition headers. */ +public abstract class PinnedHeaderListAdapter extends CompositeCursorAdapter + implements PinnedHeaderListView.PinnedHeaderAdapter { + + public static final int PARTITION_HEADER_TYPE = 0; + + private boolean mPinnedPartitionHeadersEnabled; + private boolean[] mHeaderVisibility; + + public PinnedHeaderListAdapter(Context context) { + super(context); + } + + public boolean getPinnedPartitionHeadersEnabled() { + return mPinnedPartitionHeadersEnabled; + } + + public void setPinnedPartitionHeadersEnabled(boolean flag) { + this.mPinnedPartitionHeadersEnabled = flag; + } + + @Override + public int getPinnedHeaderCount() { + if (mPinnedPartitionHeadersEnabled) { + return getPartitionCount(); + } else { + return 0; + } + } + + protected boolean isPinnedPartitionHeaderVisible(int partition) { + return getPinnedPartitionHeadersEnabled() + && hasHeader(partition) + && !isPartitionEmpty(partition); + } + + /** The default implementation creates the same type of view as a normal partition header. */ + @Override + public View getPinnedHeaderView(int partition, View convertView, ViewGroup parent) { + if (hasHeader(partition)) { + View view = null; + if (convertView != null) { + Integer headerType = (Integer) convertView.getTag(); + if (headerType != null && headerType == PARTITION_HEADER_TYPE) { + view = convertView; + } + } + if (view == null) { + view = newHeaderView(getContext(), partition, null, parent); + view.setTag(PARTITION_HEADER_TYPE); + view.setFocusable(false); + view.setEnabled(false); + } + bindHeaderView(view, partition, getCursor(partition)); + view.setLayoutDirection(parent.getLayoutDirection()); + return view; + } else { + return null; + } + } + + @Override + public void configurePinnedHeaders(PinnedHeaderListView listView) { + if (!getPinnedPartitionHeadersEnabled()) { + return; + } + + int size = getPartitionCount(); + + // Cache visibility bits, because we will need them several times later on + if (mHeaderVisibility == null || mHeaderVisibility.length != size) { + mHeaderVisibility = new boolean[size]; + } + for (int i = 0; i < size; i++) { + boolean visible = isPinnedPartitionHeaderVisible(i); + mHeaderVisibility[i] = visible; + if (!visible) { + listView.setHeaderInvisible(i, true); + } + } + + int headerViewsCount = listView.getHeaderViewsCount(); + + // Starting at the top, find and pin headers for partitions preceding the visible one(s) + int maxTopHeader = -1; + int topHeaderHeight = 0; + for (int i = 0; i < size; i++) { + if (mHeaderVisibility[i]) { + int position = listView.getPositionAt(topHeaderHeight) - headerViewsCount; + int partition = getPartitionForPosition(position); + if (i > partition) { + break; + } + + listView.setHeaderPinnedAtTop(i, topHeaderHeight, false); + topHeaderHeight += listView.getPinnedHeaderHeight(i); + maxTopHeader = i; + } + } + + // Starting at the bottom, find and pin headers for partitions following the visible one(s) + int maxBottomHeader = size; + int bottomHeaderHeight = 0; + int listHeight = listView.getHeight(); + for (int i = size; --i > maxTopHeader; ) { + if (mHeaderVisibility[i]) { + int position = listView.getPositionAt(listHeight - bottomHeaderHeight) - headerViewsCount; + if (position < 0) { + break; + } + + int partition = getPartitionForPosition(position - 1); + if (partition == -1 || i <= partition) { + break; + } + + int height = listView.getPinnedHeaderHeight(i); + bottomHeaderHeight += height; + + listView.setHeaderPinnedAtBottom(i, listHeight - bottomHeaderHeight, false); + maxBottomHeader = i; + } + } + + // Headers in between the top-pinned and bottom-pinned should be hidden + for (int i = maxTopHeader + 1; i < maxBottomHeader; i++) { + if (mHeaderVisibility[i]) { + listView.setHeaderInvisible(i, isPartitionEmpty(i)); + } + } + } + + @Override + public int getScrollPositionForHeader(int viewIndex) { + return getPositionForPartition(viewIndex); + } +} diff --git a/java/com/android/contacts/common/list/PinnedHeaderListView.java b/java/com/android/contacts/common/list/PinnedHeaderListView.java new file mode 100644 index 000000000..33c68b68c --- /dev/null +++ b/java/com/android/contacts/common/list/PinnedHeaderListView.java @@ -0,0 +1,563 @@ +/* + * Copyright (C) 2010 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.common.list; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.RectF; +import android.util.AttributeSet; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewGroup; +import android.widget.AbsListView; +import android.widget.AbsListView.OnScrollListener; +import android.widget.AdapterView; +import android.widget.AdapterView.OnItemSelectedListener; +import android.widget.ListAdapter; +import com.android.dialer.util.ViewUtil; + +/** + * A ListView that maintains a header pinned at the top of the list. The pinned header can be pushed + * up and dissolved as needed. + */ +public class PinnedHeaderListView extends AutoScrollListView + implements OnScrollListener, OnItemSelectedListener { + + private static final int MAX_ALPHA = 255; + private static final int TOP = 0; + private static final int BOTTOM = 1; + private static final int FADING = 2; + private static final int DEFAULT_ANIMATION_DURATION = 20; + private static final int DEFAULT_SMOOTH_SCROLL_DURATION = 100; + private PinnedHeaderAdapter mAdapter; + private int mSize; + private PinnedHeader[] mHeaders; + private RectF mBounds = new RectF(); + private OnScrollListener mOnScrollListener; + private OnItemSelectedListener mOnItemSelectedListener; + private int mScrollState; + private boolean mScrollToSectionOnHeaderTouch = false; + private boolean mHeaderTouched = false; + private int mAnimationDuration = DEFAULT_ANIMATION_DURATION; + private boolean mAnimating; + private long mAnimationTargetTime; + private int mHeaderPaddingStart; + private int mHeaderWidth; + + public PinnedHeaderListView(Context context) { + this(context, null, android.R.attr.listViewStyle); + } + + public PinnedHeaderListView(Context context, AttributeSet attrs) { + this(context, attrs, android.R.attr.listViewStyle); + } + + public PinnedHeaderListView(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + super.setOnScrollListener(this); + super.setOnItemSelectedListener(this); + } + + @Override + protected void onLayout(boolean changed, int l, int t, int r, int b) { + super.onLayout(changed, l, t, r, b); + mHeaderPaddingStart = getPaddingStart(); + mHeaderWidth = r - l - mHeaderPaddingStart - getPaddingEnd(); + } + + @Override + public void setAdapter(ListAdapter adapter) { + mAdapter = (PinnedHeaderAdapter) adapter; + super.setAdapter(adapter); + } + + @Override + public void setOnScrollListener(OnScrollListener onScrollListener) { + mOnScrollListener = onScrollListener; + super.setOnScrollListener(this); + } + + @Override + public void setOnItemSelectedListener(OnItemSelectedListener listener) { + mOnItemSelectedListener = listener; + super.setOnItemSelectedListener(this); + } + + public void setScrollToSectionOnHeaderTouch(boolean value) { + mScrollToSectionOnHeaderTouch = value; + } + + @Override + public void onScroll( + AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount) { + if (mAdapter != null) { + int count = mAdapter.getPinnedHeaderCount(); + if (count != mSize) { + mSize = count; + if (mHeaders == null) { + mHeaders = new PinnedHeader[mSize]; + } else if (mHeaders.length < mSize) { + PinnedHeader[] headers = mHeaders; + mHeaders = new PinnedHeader[mSize]; + System.arraycopy(headers, 0, mHeaders, 0, headers.length); + } + } + + for (int i = 0; i < mSize; i++) { + if (mHeaders[i] == null) { + mHeaders[i] = new PinnedHeader(); + } + mHeaders[i].view = mAdapter.getPinnedHeaderView(i, mHeaders[i].view, this); + } + + mAnimationTargetTime = System.currentTimeMillis() + mAnimationDuration; + mAdapter.configurePinnedHeaders(this); + invalidateIfAnimating(); + } + if (mOnScrollListener != null) { + mOnScrollListener.onScroll(this, firstVisibleItem, visibleItemCount, totalItemCount); + } + } + + @Override + protected float getTopFadingEdgeStrength() { + // Disable vertical fading at the top when the pinned header is present + return mSize > 0 ? 0 : super.getTopFadingEdgeStrength(); + } + + @Override + public void onScrollStateChanged(AbsListView view, int scrollState) { + mScrollState = scrollState; + if (mOnScrollListener != null) { + mOnScrollListener.onScrollStateChanged(this, scrollState); + } + } + + /** + * Ensures that the selected item is positioned below the top-pinned headers and above the + * bottom-pinned ones. + */ + @Override + public void onItemSelected(AdapterView<?> parent, View view, int position, long id) { + int height = getHeight(); + + int windowTop = 0; + int windowBottom = height; + + for (int i = 0; i < mSize; i++) { + PinnedHeader header = mHeaders[i]; + if (header.visible) { + if (header.state == TOP) { + windowTop = header.y + header.height; + } else if (header.state == BOTTOM) { + windowBottom = header.y; + break; + } + } + } + + View selectedView = getSelectedView(); + if (selectedView != null) { + if (selectedView.getTop() < windowTop) { + setSelectionFromTop(position, windowTop); + } else if (selectedView.getBottom() > windowBottom) { + setSelectionFromTop(position, windowBottom - selectedView.getHeight()); + } + } + + if (mOnItemSelectedListener != null) { + mOnItemSelectedListener.onItemSelected(parent, view, position, id); + } + } + + @Override + public void onNothingSelected(AdapterView<?> parent) { + if (mOnItemSelectedListener != null) { + mOnItemSelectedListener.onNothingSelected(parent); + } + } + + public int getPinnedHeaderHeight(int viewIndex) { + ensurePinnedHeaderLayout(viewIndex); + return mHeaders[viewIndex].view.getHeight(); + } + + /** + * Set header to be pinned at the top. + * + * @param viewIndex index of the header view + * @param y is position of the header in pixels. + * @param animate true if the transition to the new coordinate should be animated + */ + public void setHeaderPinnedAtTop(int viewIndex, int y, boolean animate) { + ensurePinnedHeaderLayout(viewIndex); + PinnedHeader header = mHeaders[viewIndex]; + header.visible = true; + header.y = y; + header.state = TOP; + + // TODO perhaps we should animate at the top as well + header.animating = false; + } + + /** + * Set header to be pinned at the bottom. + * + * @param viewIndex index of the header view + * @param y is position of the header in pixels. + * @param animate true if the transition to the new coordinate should be animated + */ + public void setHeaderPinnedAtBottom(int viewIndex, int y, boolean animate) { + ensurePinnedHeaderLayout(viewIndex); + PinnedHeader header = mHeaders[viewIndex]; + header.state = BOTTOM; + if (header.animating) { + header.targetTime = mAnimationTargetTime; + header.sourceY = header.y; + header.targetY = y; + } else if (animate && (header.y != y || !header.visible)) { + if (header.visible) { + header.sourceY = header.y; + } else { + header.visible = true; + header.sourceY = y + header.height; + } + header.animating = true; + header.targetVisible = true; + header.targetTime = mAnimationTargetTime; + header.targetY = y; + } else { + header.visible = true; + header.y = y; + } + } + + /** + * Set header to be pinned at the top of the first visible item. + * + * @param viewIndex index of the header view + * @param position is position of the header in pixels. + */ + public void setFadingHeader(int viewIndex, int position, boolean fade) { + ensurePinnedHeaderLayout(viewIndex); + + View child = getChildAt(position - getFirstVisiblePosition()); + if (child == null) { + return; + } + + PinnedHeader header = mHeaders[viewIndex]; + header.visible = true; + header.state = FADING; + header.alpha = MAX_ALPHA; + header.animating = false; + + int top = getTotalTopPinnedHeaderHeight(); + header.y = top; + if (fade) { + int bottom = child.getBottom() - top; + int headerHeight = header.height; + if (bottom < headerHeight) { + int portion = bottom - headerHeight; + header.alpha = MAX_ALPHA * (headerHeight + portion) / headerHeight; + header.y = top + portion; + } + } + } + + /** + * Makes header invisible. + * + * @param viewIndex index of the header view + * @param animate true if the transition to the new coordinate should be animated + */ + public void setHeaderInvisible(int viewIndex, boolean animate) { + PinnedHeader header = mHeaders[viewIndex]; + if (header.visible && (animate || header.animating) && header.state == BOTTOM) { + header.sourceY = header.y; + if (!header.animating) { + header.visible = true; + header.targetY = getBottom() + header.height; + } + header.animating = true; + header.targetTime = mAnimationTargetTime; + header.targetVisible = false; + } else { + header.visible = false; + } + } + + private void ensurePinnedHeaderLayout(int viewIndex) { + View view = mHeaders[viewIndex].view; + if (view.isLayoutRequested()) { + ViewGroup.LayoutParams layoutParams = view.getLayoutParams(); + int widthSpec; + int heightSpec; + + if (layoutParams != null && layoutParams.width > 0) { + widthSpec = View.MeasureSpec.makeMeasureSpec(layoutParams.width, View.MeasureSpec.EXACTLY); + } else { + widthSpec = View.MeasureSpec.makeMeasureSpec(mHeaderWidth, View.MeasureSpec.EXACTLY); + } + + if (layoutParams != null && layoutParams.height > 0) { + heightSpec = + View.MeasureSpec.makeMeasureSpec(layoutParams.height, View.MeasureSpec.EXACTLY); + } else { + heightSpec = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED); + } + view.measure(widthSpec, heightSpec); + int height = view.getMeasuredHeight(); + mHeaders[viewIndex].height = height; + view.layout(0, 0, view.getMeasuredWidth(), height); + } + } + + /** Returns the sum of heights of headers pinned to the top. */ + public int getTotalTopPinnedHeaderHeight() { + for (int i = mSize; --i >= 0; ) { + PinnedHeader header = mHeaders[i]; + if (header.visible && header.state == TOP) { + return header.y + header.height; + } + } + return 0; + } + + /** Returns the list item position at the specified y coordinate. */ + public int getPositionAt(int y) { + do { + int position = pointToPosition(getPaddingLeft() + 1, y); + if (position != -1) { + return position; + } + // If position == -1, we must have hit a separator. Let's examine + // a nearby pixel + y--; + } while (y > 0); + return 0; + } + + @Override + public boolean onInterceptTouchEvent(MotionEvent ev) { + mHeaderTouched = false; + if (super.onInterceptTouchEvent(ev)) { + return true; + } + + if (mScrollState == SCROLL_STATE_IDLE) { + final int y = (int) ev.getY(); + final int x = (int) ev.getX(); + for (int i = mSize; --i >= 0; ) { + PinnedHeader header = mHeaders[i]; + // For RTL layouts, this also takes into account that the scrollbar is on the left + // side. + final int padding = getPaddingLeft(); + if (header.visible + && header.y <= y + && header.y + header.height > y + && x >= padding + && padding + header.view.getWidth() >= x) { + mHeaderTouched = true; + if (mScrollToSectionOnHeaderTouch && ev.getAction() == MotionEvent.ACTION_DOWN) { + return smoothScrollToPartition(i); + } else { + return true; + } + } + } + } + + return false; + } + + @Override + public boolean onTouchEvent(MotionEvent ev) { + if (mHeaderTouched) { + if (ev.getAction() == MotionEvent.ACTION_UP) { + mHeaderTouched = false; + } + return true; + } + return super.onTouchEvent(ev); + } + + private boolean smoothScrollToPartition(int partition) { + if (mAdapter == null) { + return false; + } + final int position = mAdapter.getScrollPositionForHeader(partition); + if (position == -1) { + return false; + } + + int offset = 0; + for (int i = 0; i < partition; i++) { + PinnedHeader header = mHeaders[i]; + if (header.visible) { + offset += header.height; + } + } + smoothScrollToPositionFromTop( + position + getHeaderViewsCount(), offset, DEFAULT_SMOOTH_SCROLL_DURATION); + return true; + } + + private void invalidateIfAnimating() { + mAnimating = false; + for (int i = 0; i < mSize; i++) { + if (mHeaders[i].animating) { + mAnimating = true; + invalidate(); + return; + } + } + } + + @Override + protected void dispatchDraw(Canvas canvas) { + long currentTime = mAnimating ? System.currentTimeMillis() : 0; + + int top = 0; + int bottom = getBottom(); + boolean hasVisibleHeaders = false; + for (int i = 0; i < mSize; i++) { + PinnedHeader header = mHeaders[i]; + if (header.visible) { + hasVisibleHeaders = true; + if (header.state == BOTTOM && header.y < bottom) { + bottom = header.y; + } else if (header.state == TOP || header.state == FADING) { + int newTop = header.y + header.height; + if (newTop > top) { + top = newTop; + } + } + } + } + + if (hasVisibleHeaders) { + canvas.save(); + } + + super.dispatchDraw(canvas); + + if (hasVisibleHeaders) { + canvas.restore(); + + // If the first item is visible and if it has a positive top that is greater than the + // first header's assigned y-value, use that for the first header's y value. This way, + // the header inherits any padding applied to the list view. + if (mSize > 0 && getFirstVisiblePosition() == 0) { + View firstChild = getChildAt(0); + PinnedHeader firstHeader = mHeaders[0]; + + if (firstHeader != null) { + int firstHeaderTop = firstChild != null ? firstChild.getTop() : 0; + firstHeader.y = Math.max(firstHeader.y, firstHeaderTop); + } + } + + // First draw top headers, then the bottom ones to handle the Z axis correctly + for (int i = mSize; --i >= 0; ) { + PinnedHeader header = mHeaders[i]; + if (header.visible && (header.state == TOP || header.state == FADING)) { + drawHeader(canvas, header, currentTime); + } + } + + for (int i = 0; i < mSize; i++) { + PinnedHeader header = mHeaders[i]; + if (header.visible && header.state == BOTTOM) { + drawHeader(canvas, header, currentTime); + } + } + } + + invalidateIfAnimating(); + } + + private void drawHeader(Canvas canvas, PinnedHeader header, long currentTime) { + if (header.animating) { + int timeLeft = (int) (header.targetTime - currentTime); + if (timeLeft <= 0) { + header.y = header.targetY; + header.visible = header.targetVisible; + header.animating = false; + } else { + header.y = + header.targetY + (header.sourceY - header.targetY) * timeLeft / mAnimationDuration; + } + } + if (header.visible) { + View view = header.view; + int saveCount = canvas.save(); + int translateX = + ViewUtil.isViewLayoutRtl(this) + ? getWidth() - mHeaderPaddingStart - view.getWidth() + : mHeaderPaddingStart; + canvas.translate(translateX, header.y); + if (header.state == FADING) { + mBounds.set(0, 0, view.getWidth(), view.getHeight()); + canvas.saveLayerAlpha(mBounds, header.alpha, Canvas.ALL_SAVE_FLAG); + } + view.draw(canvas); + canvas.restoreToCount(saveCount); + } + } + + /** Adapter interface. The list adapter must implement this interface. */ + public interface PinnedHeaderAdapter { + + /** Returns the overall number of pinned headers, visible or not. */ + int getPinnedHeaderCount(); + + /** Creates or updates the pinned header view. */ + View getPinnedHeaderView(int viewIndex, View convertView, ViewGroup parent); + + /** + * Configures the pinned headers to match the visible list items. The adapter should call {@link + * PinnedHeaderListView#setHeaderPinnedAtTop}, {@link + * PinnedHeaderListView#setHeaderPinnedAtBottom}, {@link PinnedHeaderListView#setFadingHeader} + * or {@link PinnedHeaderListView#setHeaderInvisible}, for each header that needs to change its + * position or visibility. + */ + void configurePinnedHeaders(PinnedHeaderListView listView); + + /** + * Returns the list position to scroll to if the pinned header is touched. Return -1 if the list + * does not need to be scrolled. + */ + int getScrollPositionForHeader(int viewIndex); + } + + private static final class PinnedHeader { + + View view; + boolean visible; + int y; + int height; + int alpha; + int state; + + boolean animating; + boolean targetVisible; + int sourceY; + int targetY; + long targetTime; + } +} diff --git a/java/com/android/contacts/common/list/ViewPagerTabStrip.java b/java/com/android/contacts/common/list/ViewPagerTabStrip.java new file mode 100644 index 000000000..969a6d342 --- /dev/null +++ b/java/com/android/contacts/common/list/ViewPagerTabStrip.java @@ -0,0 +1,109 @@ +/* + * Copyright (C) 2014 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.common.list; + +import android.content.Context; +import android.content.res.Resources; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.util.AttributeSet; +import android.view.View; +import android.widget.LinearLayout; +import com.android.contacts.common.R; + +public class ViewPagerTabStrip extends LinearLayout { + + private final Paint mSelectedUnderlinePaint; + private int mSelectedUnderlineThickness; + private int mIndexForSelection; + private float mSelectionOffset; + + public ViewPagerTabStrip(Context context) { + this(context, null); + } + + public ViewPagerTabStrip(Context context, AttributeSet attrs) { + super(context, attrs); + + final Resources res = context.getResources(); + + mSelectedUnderlineThickness = res.getDimensionPixelSize(R.dimen.tab_selected_underline_height); + int underlineColor = res.getColor(R.color.tab_selected_underline_color); + int backgroundColor = res.getColor(R.color.contactscommon_actionbar_background_color); + + mSelectedUnderlinePaint = new Paint(); + mSelectedUnderlinePaint.setColor(underlineColor); + + setBackgroundColor(backgroundColor); + setWillNotDraw(false); + } + + /** + * Notifies this view that view pager has been scrolled. We save the tab index and selection + * offset for interpolating the position and width of selection underline. + */ + void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) { + mIndexForSelection = position; + mSelectionOffset = positionOffset; + invalidate(); + } + + @Override + protected void onDraw(Canvas canvas) { + int childCount = getChildCount(); + + // Thick colored underline below the current selection + if (childCount > 0) { + View selectedTitle = getChildAt(mIndexForSelection); + + if (selectedTitle == null) { + // The view pager's tab count changed but we weren't notified yet. Ignore this draw + // pass, when we get a new selection we will update and draw the selection strip in + // the correct place. + return; + } + int selectedLeft = selectedTitle.getLeft(); + int selectedRight = selectedTitle.getRight(); + final boolean isRtl = isRtl(); + final boolean hasNextTab = + isRtl ? mIndexForSelection > 0 : (mIndexForSelection < (getChildCount() - 1)); + if ((mSelectionOffset > 0.0f) && hasNextTab) { + // Draw the selection partway between the tabs + View nextTitle = getChildAt(mIndexForSelection + (isRtl ? -1 : 1)); + int nextLeft = nextTitle.getLeft(); + int nextRight = nextTitle.getRight(); + + selectedLeft = + (int) (mSelectionOffset * nextLeft + (1.0f - mSelectionOffset) * selectedLeft); + selectedRight = + (int) (mSelectionOffset * nextRight + (1.0f - mSelectionOffset) * selectedRight); + } + + int height = getHeight(); + canvas.drawRect( + selectedLeft, + height - mSelectedUnderlineThickness, + selectedRight, + height, + mSelectedUnderlinePaint); + } + } + + private boolean isRtl() { + return getLayoutDirection() == View.LAYOUT_DIRECTION_RTL; + } +} diff --git a/java/com/android/contacts/common/list/ViewPagerTabs.java b/java/com/android/contacts/common/list/ViewPagerTabs.java new file mode 100644 index 000000000..34f623ef4 --- /dev/null +++ b/java/com/android/contacts/common/list/ViewPagerTabs.java @@ -0,0 +1,317 @@ +/* + * Copyright (C) 2014 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.common.list; + +import android.content.Context; +import android.content.res.ColorStateList; +import android.content.res.TypedArray; +import android.graphics.Outline; +import android.support.v4.view.PagerAdapter; +import android.support.v4.view.ViewPager; +import android.util.AttributeSet; +import android.util.TypedValue; +import android.view.Gravity; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewOutlineProvider; +import android.widget.FrameLayout; +import android.widget.HorizontalScrollView; +import android.widget.LinearLayout; +import android.widget.TextView; +import android.widget.Toast; +import com.android.contacts.common.R; +import com.android.dialer.compat.CompatUtils; + +/** + * Lightweight implementation of ViewPager tabs. This looks similar to traditional actionBar tabs, + * but allows for the view containing the tabs to be placed anywhere on screen. Text-related + * attributes can also be assigned in XML - these will get propogated to the child TextViews + * automatically. + */ +public class ViewPagerTabs extends HorizontalScrollView implements ViewPager.OnPageChangeListener { + + private static final ViewOutlineProvider VIEW_BOUNDS_OUTLINE_PROVIDER; + private static final int TAB_SIDE_PADDING_IN_DPS = 10; + // TODO: This should use <declare-styleable> in the future + private static final int[] ATTRS = + new int[] { + android.R.attr.textSize, + android.R.attr.textStyle, + android.R.attr.textColor, + android.R.attr.textAllCaps + }; + + static { + if (CompatUtils.isLollipopCompatible()) { + VIEW_BOUNDS_OUTLINE_PROVIDER = + new ViewOutlineProvider() { + @Override + public void getOutline(View view, Outline outline) { + outline.setRect(0, 0, view.getWidth(), view.getHeight()); + } + }; + } else { + VIEW_BOUNDS_OUTLINE_PROVIDER = null; + } + } + + /** + * Linearlayout that will contain the TextViews serving as tabs. This is the only child of the + * parent HorizontalScrollView. + */ + final int mTextStyle; + + final ColorStateList mTextColor; + final int mTextSize; + final boolean mTextAllCaps; + ViewPager mPager; + int mPrevSelected = -1; + int mSidePadding; + private ViewPagerTabStrip mTabStrip; + private int[] mTabIcons; + // For displaying the unread count next to the tab icon. + private int[] mUnreadCounts; + + public ViewPagerTabs(Context context) { + this(context, null); + } + + public ViewPagerTabs(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public ViewPagerTabs(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + setFillViewport(true); + + mSidePadding = (int) (getResources().getDisplayMetrics().density * TAB_SIDE_PADDING_IN_DPS); + + final TypedArray a = context.obtainStyledAttributes(attrs, ATTRS); + mTextSize = a.getDimensionPixelSize(0, 0); + mTextStyle = a.getInt(1, 0); + mTextColor = a.getColorStateList(2); + mTextAllCaps = a.getBoolean(3, false); + + mTabStrip = new ViewPagerTabStrip(context); + addView( + mTabStrip, + new FrameLayout.LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.MATCH_PARENT)); + a.recycle(); + + if (CompatUtils.isLollipopCompatible()) { + // enable shadow casting from view bounds + setOutlineProvider(VIEW_BOUNDS_OUTLINE_PROVIDER); + } + } + + public void setViewPager(ViewPager viewPager) { + mPager = viewPager; + addTabs(mPager.getAdapter()); + } + + /** + * Set the tab icons and initialize an array for unread counts the same length as the icon array. + * + * @param tabIcons An array representing the tab icons in order. + */ + public void configureTabIcons(int[] tabIcons) { + mTabIcons = tabIcons; + mUnreadCounts = new int[tabIcons.length]; + } + + public void setUnreadCount(int count, int position) { + if (mUnreadCounts == null || position >= mUnreadCounts.length) { + return; + } + mUnreadCounts[position] = count; + } + + private void addTabs(PagerAdapter adapter) { + mTabStrip.removeAllViews(); + + final int count = adapter.getCount(); + for (int i = 0; i < count; i++) { + addTab(adapter.getPageTitle(i), i); + } + } + + private void addTab(CharSequence tabTitle, final int position) { + View tabView; + if (mTabIcons != null && position < mTabIcons.length) { + View layout = LayoutInflater.from(getContext()).inflate(R.layout.unread_count_tab, null); + View iconView = layout.findViewById(R.id.icon); + iconView.setBackgroundResource(mTabIcons[position]); + iconView.setContentDescription(tabTitle); + TextView textView = (TextView) layout.findViewById(R.id.count); + if (mUnreadCounts != null && mUnreadCounts[position] > 0) { + textView.setText(Integer.toString(mUnreadCounts[position])); + textView.setVisibility(View.VISIBLE); + iconView.setContentDescription( + getResources() + .getQuantityString( + R.plurals.tab_title_with_unread_items, + mUnreadCounts[position], + tabTitle.toString(), + mUnreadCounts[position])); + } else { + textView.setVisibility(View.INVISIBLE); + iconView.setContentDescription(getResources().getString(R.string.tab_title, tabTitle)); + } + tabView = layout; + } else { + final TextView textView = new TextView(getContext()); + textView.setText(tabTitle); + textView.setBackgroundResource(R.drawable.view_pager_tab_background); + + // Assign various text appearance related attributes to child views. + if (mTextStyle > 0) { + textView.setTypeface(textView.getTypeface(), mTextStyle); + } + if (mTextSize > 0) { + textView.setTextSize(TypedValue.COMPLEX_UNIT_PX, mTextSize); + } + if (mTextColor != null) { + textView.setTextColor(mTextColor); + } + textView.setAllCaps(mTextAllCaps); + textView.setGravity(Gravity.CENTER); + + tabView = textView; + } + + tabView.setOnClickListener( + new OnClickListener() { + @Override + public void onClick(View v) { + mPager.setCurrentItem(getRtlPosition(position)); + } + }); + + tabView.setOnLongClickListener(new OnTabLongClickListener(position)); + + tabView.setPadding(mSidePadding, 0, mSidePadding, 0); + + mTabStrip.addView( + tabView, + position, + new LinearLayout.LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.MATCH_PARENT, 1)); + + // Default to the first child being selected + if (position == 0) { + mPrevSelected = 0; + tabView.setSelected(true); + } + } + + /** + * Remove a tab at a certain index. + * + * @param index The index of the tab view we wish to remove. + */ + public void removeTab(int index) { + View view = mTabStrip.getChildAt(index); + if (view != null) { + mTabStrip.removeView(view); + } + } + + /** + * Refresh a tab at a certain index by removing it and reconstructing it. + * + * @param index The index of the tab view we wish to update. + */ + public void updateTab(int index) { + removeTab(index); + + if (index < mPager.getAdapter().getCount()) { + addTab(mPager.getAdapter().getPageTitle(index), index); + } + } + + @Override + public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) { + position = getRtlPosition(position); + int tabStripChildCount = mTabStrip.getChildCount(); + if ((tabStripChildCount == 0) || (position < 0) || (position >= tabStripChildCount)) { + return; + } + + mTabStrip.onPageScrolled(position, positionOffset, positionOffsetPixels); + } + + @Override + public void onPageSelected(int position) { + position = getRtlPosition(position); + int tabStripChildCount = mTabStrip.getChildCount(); + if ((tabStripChildCount == 0) || (position < 0) || (position >= tabStripChildCount)) { + return; + } + + if (mPrevSelected >= 0 && mPrevSelected < tabStripChildCount) { + mTabStrip.getChildAt(mPrevSelected).setSelected(false); + } + final View selectedChild = mTabStrip.getChildAt(position); + selectedChild.setSelected(true); + + // Update scroll position + final int scrollPos = selectedChild.getLeft() - (getWidth() - selectedChild.getWidth()) / 2; + smoothScrollTo(scrollPos, 0); + mPrevSelected = position; + } + + @Override + public void onPageScrollStateChanged(int state) {} + + private int getRtlPosition(int position) { + if (getLayoutDirection() == View.LAYOUT_DIRECTION_RTL) { + return mTabStrip.getChildCount() - 1 - position; + } + return position; + } + + /** Simulates actionbar tab behavior by showing a toast with the tab title when long clicked. */ + private class OnTabLongClickListener implements OnLongClickListener { + + final int mPosition; + + public OnTabLongClickListener(int position) { + mPosition = position; + } + + @Override + public boolean onLongClick(View v) { + final int[] screenPos = new int[2]; + getLocationOnScreen(screenPos); + + final Context context = getContext(); + final int width = getWidth(); + final int height = getHeight(); + final int screenWidth = context.getResources().getDisplayMetrics().widthPixels; + + Toast toast = + Toast.makeText(context, mPager.getAdapter().getPageTitle(mPosition), Toast.LENGTH_SHORT); + + // Show the toast under the tab + toast.setGravity( + Gravity.TOP | Gravity.CENTER_HORIZONTAL, + (screenPos[0] + width / 2) - screenWidth / 2, + screenPos[1] + height); + + toast.show(); + return true; + } + } +} diff --git a/java/com/android/contacts/common/location/CountryDetector.java b/java/com/android/contacts/common/location/CountryDetector.java new file mode 100644 index 000000000..7d9e42b38 --- /dev/null +++ b/java/com/android/contacts/common/location/CountryDetector.java @@ -0,0 +1,221 @@ +/* + * 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.common.location; + +import android.app.PendingIntent; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.location.Geocoder; +import android.location.Location; +import android.location.LocationManager; +import android.preference.PreferenceManager; +import android.telephony.TelephonyManager; +import android.text.TextUtils; +import android.util.Log; +import com.android.dialer.util.PermissionsUtil; +import java.util.Locale; + +/** + * This class is used to detect the country where the user is. It is a simplified version of the + * country detector service in the framework. The sources of country location are queried in the + * following order of reliability: + * + * <ul> + * <li>Mobile network + * <li>Location manager + * <li>SIM's country + * <li>User's default locale + * </ul> + * + * As far as possible this class tries to replicate the behavior of the system's country detector + * service: 1) Order in priority of sources of country location 2) Mobile network information + * provided by CDMA phones is ignored 3) Location information is updated every 12 hours (instead of + * 24 hours in the system) 4) Location updates only uses the {@link + * LocationManager#PASSIVE_PROVIDER} to avoid active use of the GPS 5) If a location is successfully + * obtained and geocoded, we never fall back to use of the SIM's country (for the system, the + * fallback never happens without a reboot) 6) Location is not used if the device does not implement + * a {@link android.location.Geocoder} + */ +public class CountryDetector { + + public static final String KEY_PREFERENCE_TIME_UPDATED = "preference_time_updated"; + public static final String KEY_PREFERENCE_CURRENT_COUNTRY = "preference_current_country"; + private static final String TAG = "CountryDetector"; + // Wait 12 hours between updates + private static final long TIME_BETWEEN_UPDATES_MS = 1000L * 60 * 60 * 12; + // Minimum distance before an update is triggered, in meters. We don't need this to be too + // exact because all we care about is what country the user is in. + private static final long DISTANCE_BETWEEN_UPDATES_METERS = 5000; + private static CountryDetector sInstance; + private final TelephonyManager mTelephonyManager; + private final LocationManager mLocationManager; + private final LocaleProvider mLocaleProvider; + // Used as a default country code when all the sources of country data have failed in the + // exceedingly rare event that the device does not have a default locale set for some reason. + private static final String DEFAULT_COUNTRY_ISO = "US"; + private final Context mContext; + + private CountryDetector(Context context) { + this( + context, + (TelephonyManager) context.getSystemService(Context.TELEPHONY_SERVICE), + (LocationManager) context.getSystemService(Context.LOCATION_SERVICE), + new LocaleProvider()); + } + + private CountryDetector( + Context context, + TelephonyManager telephonyManager, + LocationManager locationManager, + LocaleProvider localeProvider) { + mTelephonyManager = telephonyManager; + mLocationManager = locationManager; + mLocaleProvider = localeProvider; + mContext = context; + + registerForLocationUpdates(context, mLocationManager); + } + + public static void registerForLocationUpdates(Context context, LocationManager locationManager) { + if (!PermissionsUtil.hasLocationPermissions(context)) { + Log.w(TAG, "No location permissions, not registering for location updates."); + return; + } + + if (!Geocoder.isPresent()) { + // Certain devices do not have an implementation of a geocoder - in that case there is + // no point trying to get location updates because we cannot retrieve the country based + // on the location anyway. + return; + } + final Intent activeIntent = new Intent(context, LocationChangedReceiver.class); + final PendingIntent pendingIntent = + PendingIntent.getBroadcast(context, 0, activeIntent, PendingIntent.FLAG_UPDATE_CURRENT); + + locationManager.requestLocationUpdates( + LocationManager.PASSIVE_PROVIDER, + TIME_BETWEEN_UPDATES_MS, + DISTANCE_BETWEEN_UPDATES_METERS, + pendingIntent); + } + + /** + * Returns the instance of the country detector. {@link #initialize(Context)} must have been + * called previously. + * + * @return the initialized country detector. + */ + public static synchronized CountryDetector getInstance(Context context) { + if (sInstance == null) { + sInstance = new CountryDetector(context.getApplicationContext()); + } + return sInstance; + } + + /** Factory method for {@link CountryDetector} that allows the caller to provide mock objects. */ + public CountryDetector getInstanceForTest( + Context context, + TelephonyManager telephonyManager, + LocationManager locationManager, + LocaleProvider localeProvider, + Geocoder geocoder) { + return new CountryDetector(context, telephonyManager, locationManager, localeProvider); + } + + public String getCurrentCountryIso() { + String result = null; + if (isNetworkCountryCodeAvailable()) { + result = getNetworkBasedCountryIso(); + } + if (TextUtils.isEmpty(result)) { + result = getLocationBasedCountryIso(); + } + if (TextUtils.isEmpty(result)) { + result = getSimBasedCountryIso(); + } + if (TextUtils.isEmpty(result)) { + result = getLocaleBasedCountryIso(); + } + if (TextUtils.isEmpty(result)) { + result = DEFAULT_COUNTRY_ISO; + } + return result.toUpperCase(Locale.US); + } + + /** @return the country code of the current telephony network the user is connected to. */ + private String getNetworkBasedCountryIso() { + return mTelephonyManager.getNetworkCountryIso(); + } + + /** @return the geocoded country code detected by the {@link LocationManager}. */ + private String getLocationBasedCountryIso() { + if (!Geocoder.isPresent() || !PermissionsUtil.hasLocationPermissions(mContext)) { + return null; + } + final SharedPreferences sharedPreferences = + PreferenceManager.getDefaultSharedPreferences(mContext); + return sharedPreferences.getString(KEY_PREFERENCE_CURRENT_COUNTRY, null); + } + + /** @return the country code of the SIM card currently inserted in the device. */ + private String getSimBasedCountryIso() { + return mTelephonyManager.getSimCountryIso(); + } + + /** @return the country code of the user's currently selected locale. */ + private String getLocaleBasedCountryIso() { + Locale defaultLocale = mLocaleProvider.getDefaultLocale(); + if (defaultLocale != null) { + return defaultLocale.getCountry(); + } + return null; + } + + private boolean isNetworkCountryCodeAvailable() { + // On CDMA TelephonyManager.getNetworkCountryIso() just returns the SIM's country code. + // In this case, we want to ignore the value returned and fallback to location instead. + return mTelephonyManager.getPhoneType() == TelephonyManager.PHONE_TYPE_GSM; + } + + /** + * Class that can be used to return the user's default locale. This is in its own class so that it + * can be mocked out. + */ + public static class LocaleProvider { + + public Locale getDefaultLocale() { + return Locale.getDefault(); + } + } + + public static class LocationChangedReceiver extends BroadcastReceiver { + + @Override + public void onReceive(final Context context, Intent intent) { + if (!intent.hasExtra(LocationManager.KEY_LOCATION_CHANGED)) { + return; + } + + final Location location = + (Location) intent.getExtras().get(LocationManager.KEY_LOCATION_CHANGED); + + UpdateCountryService.updateCountry(context, location); + } + } +} diff --git a/java/com/android/contacts/common/location/UpdateCountryService.java b/java/com/android/contacts/common/location/UpdateCountryService.java new file mode 100644 index 000000000..f23e09e20 --- /dev/null +++ b/java/com/android/contacts/common/location/UpdateCountryService.java @@ -0,0 +1,104 @@ +/* + * 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.common.location; + +import android.app.IntentService; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.content.SharedPreferences.Editor; +import android.location.Address; +import android.location.Geocoder; +import android.location.Location; +import android.preference.PreferenceManager; +import android.util.Log; +import java.io.IOException; +import java.util.List; + +/** + * Service used to perform asynchronous geocoding from within a broadcast receiver. Given a {@link + * Location}, convert it into a country code, and save it in shared preferences. + */ +public class UpdateCountryService extends IntentService { + + private static final String TAG = UpdateCountryService.class.getSimpleName(); + + private static final String ACTION_UPDATE_COUNTRY = "saveCountry"; + + private static final String KEY_INTENT_LOCATION = "location"; + + public UpdateCountryService() { + super(TAG); + } + + public static void updateCountry(Context context, Location location) { + final Intent serviceIntent = new Intent(context, UpdateCountryService.class); + serviceIntent.setAction(ACTION_UPDATE_COUNTRY); + serviceIntent.putExtra(UpdateCountryService.KEY_INTENT_LOCATION, location); + context.startService(serviceIntent); + } + + @Override + protected void onHandleIntent(Intent intent) { + if (intent == null) { + Log.d(TAG, "onHandleIntent: could not handle null intent"); + return; + } + if (ACTION_UPDATE_COUNTRY.equals(intent.getAction())) { + final Location location = intent.getParcelableExtra(KEY_INTENT_LOCATION); + final String country = getCountryFromLocation(getApplicationContext(), location); + + if (country == null) { + return; + } + + final SharedPreferences prefs = + PreferenceManager.getDefaultSharedPreferences(getApplicationContext()); + + final Editor editor = prefs.edit(); + editor.putLong(CountryDetector.KEY_PREFERENCE_TIME_UPDATED, System.currentTimeMillis()); + editor.putString(CountryDetector.KEY_PREFERENCE_CURRENT_COUNTRY, country); + editor.commit(); + } + } + + /** + * Given a {@link Location}, return a country code. + * + * @return the ISO 3166-1 two letter country code + */ + private String getCountryFromLocation(Context context, Location location) { + final Geocoder geocoder = new Geocoder(context); + String country = null; + try { + double latitude = location.getLatitude(); + // Latitude has to be between 90 and -90 (latitude of north and south poles wrt equator) + if (latitude <= 90 && latitude >= -90) { + final List<Address> addresses = + geocoder.getFromLocation(location.getLatitude(), location.getLongitude(), 1); + if (addresses != null && addresses.size() > 0) { + country = addresses.get(0).getCountryCode(); + } + } else { + Log.w(TAG, "Invalid latitude"); + } + } catch (IOException e) { + Log.w(TAG, "Exception occurred when getting geocoded country from location"); + } + return country; + } +} diff --git a/java/com/android/contacts/common/model/AccountTypeManager.java b/java/com/android/contacts/common/model/AccountTypeManager.java new file mode 100644 index 000000000..f225ff6ac --- /dev/null +++ b/java/com/android/contacts/common/model/AccountTypeManager.java @@ -0,0 +1,813 @@ +/* + * Copyright (C) 2009 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.common.model; + +import android.accounts.Account; +import android.accounts.AccountManager; +import android.accounts.AuthenticatorDescription; +import android.accounts.OnAccountsUpdateListener; +import android.content.BroadcastReceiver; +import android.content.ContentResolver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.SyncAdapterType; +import android.content.SyncStatusObserver; +import android.content.pm.PackageManager; +import android.content.pm.ResolveInfo; +import android.net.Uri; +import android.os.AsyncTask; +import android.os.Handler; +import android.os.HandlerThread; +import android.os.Looper; +import android.os.Message; +import android.os.SystemClock; +import android.provider.ContactsContract; +import android.support.annotation.VisibleForTesting; +import android.text.TextUtils; +import android.util.ArrayMap; +import android.util.Log; +import android.util.TimingLogger; +import com.android.contacts.common.MoreContactUtils; +import com.android.contacts.common.list.ContactListFilterController; +import com.android.contacts.common.model.account.AccountType; +import com.android.contacts.common.model.account.AccountTypeWithDataSet; +import com.android.contacts.common.model.account.AccountWithDataSet; +import com.android.contacts.common.model.account.ExchangeAccountType; +import com.android.contacts.common.model.account.ExternalAccountType; +import com.android.contacts.common.model.account.FallbackAccountType; +import com.android.contacts.common.model.account.GoogleAccountType; +import com.android.contacts.common.model.account.SamsungAccountType; +import com.android.contacts.common.model.dataitem.DataKind; +import com.android.contacts.common.util.Constants; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * Singleton holder for all parsed {@link AccountType} available on the system, typically filled + * through {@link PackageManager} queries. + */ +public abstract class AccountTypeManager { + + static final String TAG = "AccountTypeManager"; + + private static final Object mInitializationLock = new Object(); + private static AccountTypeManager mAccountTypeManager; + + /** + * Requests the singleton instance of {@link AccountTypeManager} with data bound from the + * available authenticators. This method can safely be called from the UI thread. + */ + public static AccountTypeManager getInstance(Context context) { + synchronized (mInitializationLock) { + if (mAccountTypeManager == null) { + context = context.getApplicationContext(); + mAccountTypeManager = new AccountTypeManagerImpl(context); + } + } + return mAccountTypeManager; + } + + /** + * Set the instance of account type manager. This is only for and should only be used by unit + * tests. While having this method is not ideal, it's simpler than the alternative of holding this + * as a service in the ContactsApplication context class. + * + * @param mockManager The mock AccountTypeManager. + */ + public static void setInstanceForTest(AccountTypeManager mockManager) { + synchronized (mInitializationLock) { + mAccountTypeManager = mockManager; + } + } + + /** + * Returns the list of all accounts (if contactWritableOnly is false) or just the list of contact + * writable accounts (if contactWritableOnly is true). + */ + // TODO: Consider splitting this into getContactWritableAccounts() and getAllAccounts() + public abstract List<AccountWithDataSet> getAccounts(boolean contactWritableOnly); + + /** Returns the list of accounts that are group writable. */ + public abstract List<AccountWithDataSet> getGroupWritableAccounts(); + + public abstract AccountType getAccountType(AccountTypeWithDataSet accountTypeWithDataSet); + + public final AccountType getAccountType(String accountType, String dataSet) { + return getAccountType(AccountTypeWithDataSet.get(accountType, dataSet)); + } + + public final AccountType getAccountTypeForAccount(AccountWithDataSet account) { + if (account != null) { + return getAccountType(account.getAccountTypeWithDataSet()); + } + return getAccountType(null, null); + } + + /** + * @return Unmodifiable map from {@link AccountTypeWithDataSet}s to {@link AccountType}s which + * support the "invite" feature and have one or more account. + * <p>This is a filtered down and more "usable" list compared to {@link + * #getAllInvitableAccountTypes}, where usable is defined as: (1) making sure that the app + * that contributed the account type is not disabled (in order to avoid presenting the user + * with an option that does nothing), and (2) that there is at least one raw contact with that + * account type in the database (assuming that the user probably doesn't use that account + * type). + * <p>Warning: Don't use on the UI thread because this can scan the database. + */ + public abstract Map<AccountTypeWithDataSet, AccountType> getUsableInvitableAccountTypes(); + + /** + * Find the best {@link DataKind} matching the requested {@link AccountType#accountType}, {@link + * AccountType#dataSet}, and {@link DataKind#mimeType}. If no direct match found, we try searching + * {@link FallbackAccountType}. + */ + public DataKind getKindOrFallback(AccountType type, String mimeType) { + return type == null ? null : type.getKindForMimetype(mimeType); + } + + /** + * Returns all registered {@link AccountType}s, including extension ones. + * + * @param contactWritableOnly if true, it only returns ones that support writing contacts. + */ + public abstract List<AccountType> getAccountTypes(boolean contactWritableOnly); + + /** + * @param contactWritableOnly if true, it only returns ones that support writing contacts. + * @return true when this instance contains the given account. + */ + public boolean contains(AccountWithDataSet account, boolean contactWritableOnly) { + for (AccountWithDataSet account_2 : getAccounts(false)) { + if (account.equals(account_2)) { + return true; + } + } + return false; + } +} + +class AccountTypeManagerImpl extends AccountTypeManager + implements OnAccountsUpdateListener, SyncStatusObserver { + + private static final Map<AccountTypeWithDataSet, AccountType> + EMPTY_UNMODIFIABLE_ACCOUNT_TYPE_MAP = + Collections.unmodifiableMap(new HashMap<AccountTypeWithDataSet, AccountType>()); + + /** + * A sample contact URI used to test whether any activities will respond to an invitable intent + * with the given URI as the intent data. This doesn't need to be specific to a real contact + * because an app that intercepts the intent should probably do so for all types of contact URIs. + */ + private static final Uri SAMPLE_CONTACT_URI = ContactsContract.Contacts.getLookupUri(1, "xxx"); + + private static final int MESSAGE_LOAD_DATA = 0; + private static final int MESSAGE_PROCESS_BROADCAST_INTENT = 1; + private static final Comparator<AccountWithDataSet> ACCOUNT_COMPARATOR = + new Comparator<AccountWithDataSet>() { + @Override + public int compare(AccountWithDataSet a, AccountWithDataSet b) { + if (Objects.equals(a.name, b.name) + && Objects.equals(a.type, b.type) + && Objects.equals(a.dataSet, b.dataSet)) { + return 0; + } else if (b.name == null || b.type == null) { + return -1; + } else if (a.name == null || a.type == null) { + return 1; + } else { + int diff = a.name.compareTo(b.name); + if (diff != 0) { + return diff; + } + diff = a.type.compareTo(b.type); + if (diff != 0) { + return diff; + } + + // Accounts without data sets get sorted before those that have them. + if (a.dataSet != null) { + return b.dataSet == null ? 1 : a.dataSet.compareTo(b.dataSet); + } else { + return -1; + } + } + } + }; + private final InvitableAccountTypeCache mInvitableAccountTypeCache; + /** + * The boolean value is equal to true if the {@link InvitableAccountTypeCache} has been + * initialized. False otherwise. + */ + private final AtomicBoolean mInvitablesCacheIsInitialized = new AtomicBoolean(false); + /** + * The boolean value is equal to true if the {@link FindInvitablesTask} is still executing. False + * otherwise. + */ + private final AtomicBoolean mInvitablesTaskIsRunning = new AtomicBoolean(false); + + private final Handler mMainThreadHandler = new Handler(Looper.getMainLooper()); + private Context mContext; + private final Runnable mCheckFilterValidityRunnable = + new Runnable() { + @Override + public void run() { + ContactListFilterController.getInstance(mContext).checkFilterValidity(true); + } + }; + private AccountManager mAccountManager; + private AccountType mFallbackAccountType; + private List<AccountWithDataSet> mAccounts = new ArrayList<>(); + private List<AccountWithDataSet> mContactWritableAccounts = new ArrayList<>(); + private List<AccountWithDataSet> mGroupWritableAccounts = new ArrayList<>(); + private Map<AccountTypeWithDataSet, AccountType> mAccountTypesWithDataSets = new ArrayMap<>(); + private Map<AccountTypeWithDataSet, AccountType> mInvitableAccountTypes = + EMPTY_UNMODIFIABLE_ACCOUNT_TYPE_MAP; + private HandlerThread mListenerThread; + private Handler mListenerHandler; + private BroadcastReceiver mBroadcastReceiver = + new BroadcastReceiver() { + + @Override + public void onReceive(Context context, Intent intent) { + Message msg = mListenerHandler.obtainMessage(MESSAGE_PROCESS_BROADCAST_INTENT, intent); + mListenerHandler.sendMessage(msg); + } + }; + /* A latch that ensures that asynchronous initialization completes before data is used */ + private volatile CountDownLatch mInitializationLatch = new CountDownLatch(1); + + /** Internal constructor that only performs initial parsing. */ + public AccountTypeManagerImpl(Context context) { + mContext = context; + mFallbackAccountType = new FallbackAccountType(context); + + mAccountManager = AccountManager.get(mContext); + + mListenerThread = new HandlerThread("AccountChangeListener"); + mListenerThread.start(); + mListenerHandler = + new Handler(mListenerThread.getLooper()) { + @Override + public void handleMessage(Message msg) { + switch (msg.what) { + case MESSAGE_LOAD_DATA: + loadAccountsInBackground(); + break; + case MESSAGE_PROCESS_BROADCAST_INTENT: + processBroadcastIntent((Intent) msg.obj); + break; + } + } + }; + + mInvitableAccountTypeCache = new InvitableAccountTypeCache(); + + // Request updates when packages or accounts change + IntentFilter filter = new IntentFilter(Intent.ACTION_PACKAGE_ADDED); + filter.addAction(Intent.ACTION_PACKAGE_REMOVED); + filter.addAction(Intent.ACTION_PACKAGE_CHANGED); + filter.addDataScheme("package"); + mContext.registerReceiver(mBroadcastReceiver, filter); + IntentFilter sdFilter = new IntentFilter(); + sdFilter.addAction(Intent.ACTION_EXTERNAL_APPLICATIONS_AVAILABLE); + sdFilter.addAction(Intent.ACTION_EXTERNAL_APPLICATIONS_UNAVAILABLE); + mContext.registerReceiver(mBroadcastReceiver, sdFilter); + + // Request updates when locale is changed so that the order of each field will + // be able to be changed on the locale change. + filter = new IntentFilter(Intent.ACTION_LOCALE_CHANGED); + mContext.registerReceiver(mBroadcastReceiver, filter); + + mAccountManager.addOnAccountsUpdatedListener(this, mListenerHandler, false); + + ContentResolver.addStatusChangeListener(ContentResolver.SYNC_OBSERVER_TYPE_SETTINGS, this); + + mListenerHandler.sendEmptyMessage(MESSAGE_LOAD_DATA); + } + + /** + * Find a specific {@link AuthenticatorDescription} in the provided list that matches the given + * account type. + */ + protected static AuthenticatorDescription findAuthenticator( + AuthenticatorDescription[] auths, String accountType) { + for (AuthenticatorDescription auth : auths) { + if (accountType.equals(auth.type)) { + return auth; + } + } + return null; + } + + /** + * Return all {@link AccountType}s with at least one account which supports "invite", i.e. its + * {@link AccountType#getInviteContactActivityClassName()} is not empty. + */ + @VisibleForTesting + static Map<AccountTypeWithDataSet, AccountType> findAllInvitableAccountTypes( + Context context, + Collection<AccountWithDataSet> accounts, + Map<AccountTypeWithDataSet, AccountType> accountTypesByTypeAndDataSet) { + Map<AccountTypeWithDataSet, AccountType> result = new ArrayMap<>(); + for (AccountWithDataSet account : accounts) { + AccountTypeWithDataSet accountTypeWithDataSet = account.getAccountTypeWithDataSet(); + AccountType type = accountTypesByTypeAndDataSet.get(accountTypeWithDataSet); + if (type == null) { + continue; // just in case + } + if (result.containsKey(accountTypeWithDataSet)) { + continue; + } + + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d( + TAG, + "Type " + + accountTypeWithDataSet + + " inviteClass=" + + type.getInviteContactActivityClassName()); + } + if (!TextUtils.isEmpty(type.getInviteContactActivityClassName())) { + result.put(accountTypeWithDataSet, type); + } + } + return Collections.unmodifiableMap(result); + } + + @Override + public void onStatusChanged(int which) { + mListenerHandler.sendEmptyMessage(MESSAGE_LOAD_DATA); + } + + public void processBroadcastIntent(Intent intent) { + mListenerHandler.sendEmptyMessage(MESSAGE_LOAD_DATA); + } + + /* This notification will arrive on the background thread */ + public void onAccountsUpdated(Account[] accounts) { + // Refresh to catch any changed accounts + loadAccountsInBackground(); + } + + /** + * Returns instantly if accounts and account types have already been loaded. Otherwise waits for + * the background thread to complete the loading. + */ + void ensureAccountsLoaded() { + CountDownLatch latch = mInitializationLatch; + if (latch == null) { + return; + } + while (true) { + try { + latch.await(); + return; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + } + + /** + * Loads account list and corresponding account types (potentially with data sets). Always called + * on a background thread. + */ + protected void loadAccountsInBackground() { + if (Log.isLoggable(Constants.PERFORMANCE_TAG, Log.DEBUG)) { + Log.d(Constants.PERFORMANCE_TAG, "AccountTypeManager.loadAccountsInBackground start"); + } + TimingLogger timings = new TimingLogger(TAG, "loadAccountsInBackground"); + final long startTime = SystemClock.currentThreadTimeMillis(); + final long startTimeWall = SystemClock.elapsedRealtime(); + + // Account types, keyed off the account type and data set concatenation. + final Map<AccountTypeWithDataSet, AccountType> accountTypesByTypeAndDataSet = new ArrayMap<>(); + + // The same AccountTypes, but keyed off {@link RawContacts#ACCOUNT_TYPE}. Since there can + // be multiple account types (with different data sets) for the same type of account, each + // type string may have multiple AccountType entries. + final Map<String, List<AccountType>> accountTypesByType = new ArrayMap<>(); + + final List<AccountWithDataSet> allAccounts = new ArrayList<>(); + final List<AccountWithDataSet> contactWritableAccounts = new ArrayList<>(); + final List<AccountWithDataSet> groupWritableAccounts = new ArrayList<>(); + final Set<String> extensionPackages = new HashSet<>(); + + final AccountManager am = mAccountManager; + + final SyncAdapterType[] syncs = ContentResolver.getSyncAdapterTypes(); + final AuthenticatorDescription[] auths = am.getAuthenticatorTypes(); + + // First process sync adapters to find any that provide contact data. + for (SyncAdapterType sync : syncs) { + if (!ContactsContract.AUTHORITY.equals(sync.authority)) { + // Skip sync adapters that don't provide contact data. + continue; + } + + // Look for the formatting details provided by each sync + // adapter, using the authenticator to find general resources. + final String type = sync.accountType; + final AuthenticatorDescription auth = findAuthenticator(auths, type); + if (auth == null) { + Log.w(TAG, "No authenticator found for type=" + type + ", ignoring it."); + continue; + } + + AccountType accountType; + if (GoogleAccountType.ACCOUNT_TYPE.equals(type)) { + accountType = new GoogleAccountType(mContext, auth.packageName); + } else if (ExchangeAccountType.isExchangeType(type)) { + accountType = new ExchangeAccountType(mContext, auth.packageName, type); + } else if (SamsungAccountType.isSamsungAccountType(mContext, type, auth.packageName)) { + accountType = new SamsungAccountType(mContext, auth.packageName, type); + } else { + Log.d( + TAG, "Registering external account type=" + type + ", packageName=" + auth.packageName); + accountType = new ExternalAccountType(mContext, auth.packageName, false); + } + if (!accountType.isInitialized()) { + if (accountType.isEmbedded()) { + throw new IllegalStateException( + "Problem initializing embedded type " + accountType.getClass().getCanonicalName()); + } else { + // Skip external account types that couldn't be initialized. + continue; + } + } + + accountType.accountType = auth.type; + accountType.titleRes = auth.labelId; + accountType.iconRes = auth.iconId; + + addAccountType(accountType, accountTypesByTypeAndDataSet, accountTypesByType); + + // Check to see if the account type knows of any other non-sync-adapter packages + // that may provide other data sets of contact data. + extensionPackages.addAll(accountType.getExtensionPackageNames()); + } + + // If any extension packages were specified, process them as well. + if (!extensionPackages.isEmpty()) { + Log.d(TAG, "Registering " + extensionPackages.size() + " extension packages"); + for (String extensionPackage : extensionPackages) { + ExternalAccountType accountType = new ExternalAccountType(mContext, extensionPackage, true); + if (!accountType.isInitialized()) { + // Skip external account types that couldn't be initialized. + continue; + } + if (!accountType.hasContactsMetadata()) { + Log.w( + TAG, + "Skipping extension package " + + extensionPackage + + " because" + + " it doesn't have the CONTACTS_STRUCTURE metadata"); + continue; + } + if (TextUtils.isEmpty(accountType.accountType)) { + Log.w( + TAG, + "Skipping extension package " + + extensionPackage + + " because" + + " the CONTACTS_STRUCTURE metadata doesn't have the accountType" + + " attribute"); + continue; + } + Log.d( + TAG, + "Registering extension package account type=" + + accountType.accountType + + ", dataSet=" + + accountType.dataSet + + ", packageName=" + + extensionPackage); + + addAccountType(accountType, accountTypesByTypeAndDataSet, accountTypesByType); + } + } + timings.addSplit("Loaded account types"); + + // Map in accounts to associate the account names with each account type entry. + Account[] accounts = mAccountManager.getAccounts(); + for (Account account : accounts) { + boolean syncable = ContentResolver.getIsSyncable(account, ContactsContract.AUTHORITY) > 0; + + if (syncable) { + List<AccountType> accountTypes = accountTypesByType.get(account.type); + if (accountTypes != null) { + // Add an account-with-data-set entry for each account type that is + // authenticated by this account. + for (AccountType accountType : accountTypes) { + AccountWithDataSet accountWithDataSet = + new AccountWithDataSet(account.name, account.type, accountType.dataSet); + allAccounts.add(accountWithDataSet); + if (accountType.areContactsWritable()) { + contactWritableAccounts.add(accountWithDataSet); + } + if (accountType.isGroupMembershipEditable()) { + groupWritableAccounts.add(accountWithDataSet); + } + } + } + } + } + + Collections.sort(allAccounts, ACCOUNT_COMPARATOR); + Collections.sort(contactWritableAccounts, ACCOUNT_COMPARATOR); + Collections.sort(groupWritableAccounts, ACCOUNT_COMPARATOR); + + timings.addSplit("Loaded accounts"); + + synchronized (this) { + mAccountTypesWithDataSets = accountTypesByTypeAndDataSet; + mAccounts = allAccounts; + mContactWritableAccounts = contactWritableAccounts; + mGroupWritableAccounts = groupWritableAccounts; + mInvitableAccountTypes = + findAllInvitableAccountTypes(mContext, allAccounts, accountTypesByTypeAndDataSet); + } + + timings.dumpToLog(); + final long endTimeWall = SystemClock.elapsedRealtime(); + final long endTime = SystemClock.currentThreadTimeMillis(); + + Log.i( + TAG, + "Loaded meta-data for " + + mAccountTypesWithDataSets.size() + + " account types, " + + mAccounts.size() + + " accounts in " + + (endTimeWall - startTimeWall) + + "ms(wall) " + + (endTime - startTime) + + "ms(cpu)"); + + if (mInitializationLatch != null) { + mInitializationLatch.countDown(); + mInitializationLatch = null; + } + if (Log.isLoggable(Constants.PERFORMANCE_TAG, Log.DEBUG)) { + Log.d(Constants.PERFORMANCE_TAG, "AccountTypeManager.loadAccountsInBackground finish"); + } + + // Check filter validity since filter may become obsolete after account update. It must be + // done from UI thread. + mMainThreadHandler.post(mCheckFilterValidityRunnable); + } + + // Bookkeeping method for tracking the known account types in the given maps. + private void addAccountType( + AccountType accountType, + Map<AccountTypeWithDataSet, AccountType> accountTypesByTypeAndDataSet, + Map<String, List<AccountType>> accountTypesByType) { + accountTypesByTypeAndDataSet.put(accountType.getAccountTypeAndDataSet(), accountType); + List<AccountType> accountsForType = accountTypesByType.get(accountType.accountType); + if (accountsForType == null) { + accountsForType = new ArrayList<>(); + } + accountsForType.add(accountType); + accountTypesByType.put(accountType.accountType, accountsForType); + } + + /** Return list of all known, contact writable {@link AccountWithDataSet}'s. */ + @Override + public List<AccountWithDataSet> getAccounts(boolean contactWritableOnly) { + ensureAccountsLoaded(); + return contactWritableOnly ? mContactWritableAccounts : mAccounts; + } + + /** Return the list of all known, group writable {@link AccountWithDataSet}'s. */ + public List<AccountWithDataSet> getGroupWritableAccounts() { + ensureAccountsLoaded(); + return mGroupWritableAccounts; + } + + /** + * Find the best {@link DataKind} matching the requested {@link AccountType#accountType}, {@link + * AccountType#dataSet}, and {@link DataKind#mimeType}. If no direct match found, we try searching + * {@link FallbackAccountType}. + */ + @Override + public DataKind getKindOrFallback(AccountType type, String mimeType) { + ensureAccountsLoaded(); + DataKind kind = null; + + // Try finding account type and kind matching request + if (type != null) { + kind = type.getKindForMimetype(mimeType); + } + + if (kind == null) { + // Nothing found, so try fallback as last resort + kind = mFallbackAccountType.getKindForMimetype(mimeType); + } + + if (kind == null) { + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "Unknown type=" + type + ", mime=" + mimeType); + } + } + + return kind; + } + + /** Return {@link AccountType} for the given account type and data set. */ + @Override + public AccountType getAccountType(AccountTypeWithDataSet accountTypeWithDataSet) { + ensureAccountsLoaded(); + synchronized (this) { + AccountType type = mAccountTypesWithDataSets.get(accountTypeWithDataSet); + return type != null ? type : mFallbackAccountType; + } + } + + /** + * @return Unmodifiable map from {@link AccountTypeWithDataSet}s to {@link AccountType}s which + * support the "invite" feature and have one or more account. This is an unfiltered list. See + * {@link #getUsableInvitableAccountTypes()}. + */ + private Map<AccountTypeWithDataSet, AccountType> getAllInvitableAccountTypes() { + ensureAccountsLoaded(); + return mInvitableAccountTypes; + } + + @Override + public Map<AccountTypeWithDataSet, AccountType> getUsableInvitableAccountTypes() { + ensureAccountsLoaded(); + // Since this method is not thread-safe, it's possible for multiple threads to encounter + // the situation where (1) the cache has not been initialized yet or + // (2) an async task to refresh the account type list in the cache has already been + // started. Hence we use {@link AtomicBoolean}s and return cached values immediately + // while we compute the actual result in the background. We use this approach instead of + // using "synchronized" because computing the account type list involves a DB read, and + // can potentially cause a deadlock situation if this method is called from code which + // holds the DB lock. The trade-off of potentially having an incorrect list of invitable + // account types for a short period of time seems more manageable than enforcing the + // context in which this method is called. + + // Computing the list of usable invitable account types is done on the fly as requested. + // If this method has never been called before, then block until the list has been computed. + if (!mInvitablesCacheIsInitialized.get()) { + mInvitableAccountTypeCache.setCachedValue(findUsableInvitableAccountTypes(mContext)); + mInvitablesCacheIsInitialized.set(true); + } else { + // Otherwise, there is a value in the cache. If the value has expired and + // an async task has not already been started by another thread, then kick off a new + // async task to compute the list. + if (mInvitableAccountTypeCache.isExpired() + && mInvitablesTaskIsRunning.compareAndSet(false, true)) { + new FindInvitablesTask().execute(); + } + } + + return mInvitableAccountTypeCache.getCachedValue(); + } + + /** + * Return all usable {@link AccountType}s that support the "invite" feature from the list of all + * potential invitable account types (retrieved from {@link #getAllInvitableAccountTypes}). A + * usable invitable account type means: (1) there is at least 1 raw contact in the database with + * that account type, and (2) the app contributing the account type is not disabled. + * + * <p>Warning: Don't use on the UI thread because this can scan the database. + */ + private Map<AccountTypeWithDataSet, AccountType> findUsableInvitableAccountTypes( + Context context) { + Map<AccountTypeWithDataSet, AccountType> allInvitables = getAllInvitableAccountTypes(); + if (allInvitables.isEmpty()) { + return EMPTY_UNMODIFIABLE_ACCOUNT_TYPE_MAP; + } + + final Map<AccountTypeWithDataSet, AccountType> result = new ArrayMap<>(); + result.putAll(allInvitables); + + final PackageManager packageManager = context.getPackageManager(); + for (AccountTypeWithDataSet accountTypeWithDataSet : allInvitables.keySet()) { + AccountType accountType = allInvitables.get(accountTypeWithDataSet); + + // Make sure that account types don't come from apps that are disabled. + Intent invitableIntent = MoreContactUtils.getInvitableIntent(accountType, SAMPLE_CONTACT_URI); + if (invitableIntent == null) { + result.remove(accountTypeWithDataSet); + continue; + } + ResolveInfo resolveInfo = + packageManager.resolveActivity(invitableIntent, PackageManager.MATCH_DEFAULT_ONLY); + if (resolveInfo == null) { + // If we can't find an activity to start for this intent, then there's no point in + // showing this option to the user. + result.remove(accountTypeWithDataSet); + continue; + } + + // Make sure that there is at least 1 raw contact with this account type. This check + // is non-trivial and should not be done on the UI thread. + if (!accountTypeWithDataSet.hasData(context)) { + result.remove(accountTypeWithDataSet); + } + } + + return Collections.unmodifiableMap(result); + } + + @Override + public List<AccountType> getAccountTypes(boolean contactWritableOnly) { + ensureAccountsLoaded(); + final List<AccountType> accountTypes = new ArrayList<>(); + synchronized (this) { + for (AccountType type : mAccountTypesWithDataSets.values()) { + if (!contactWritableOnly || type.areContactsWritable()) { + accountTypes.add(type); + } + } + } + return accountTypes; + } + + /** + * This cache holds a list of invitable {@link AccountTypeWithDataSet}s, in the form of a {@link + * Map<AccountTypeWithDataSet, AccountType>}. Note that the cached value is valid only for {@link + * #TIME_TO_LIVE} milliseconds. + */ + private static final class InvitableAccountTypeCache { + + /** + * The cached {@link #mInvitableAccountTypes} list expires after this number of milliseconds has + * elapsed. + */ + private static final long TIME_TO_LIVE = 60000; + + private Map<AccountTypeWithDataSet, AccountType> mInvitableAccountTypes; + + private long mTimeLastSet; + + /** + * Returns true if the data in this cache is stale and needs to be refreshed. Returns false + * otherwise. + */ + public boolean isExpired() { + return SystemClock.elapsedRealtime() - mTimeLastSet > TIME_TO_LIVE; + } + + /** + * Returns the cached value. Note that the caller is responsible for checking {@link + * #isExpired()} to ensure that the value is not stale. + */ + public Map<AccountTypeWithDataSet, AccountType> getCachedValue() { + return mInvitableAccountTypes; + } + + public void setCachedValue(Map<AccountTypeWithDataSet, AccountType> map) { + mInvitableAccountTypes = map; + mTimeLastSet = SystemClock.elapsedRealtime(); + } + } + + /** + * Background task to find all usable {@link AccountType}s that support the "invite" feature from + * the list of all potential invitable account types. Once the work is completed, the list of + * account types is stored in the {@link AccountTypeManager}'s {@link InvitableAccountTypeCache}. + */ + private class FindInvitablesTask + extends AsyncTask<Void, Void, Map<AccountTypeWithDataSet, AccountType>> { + + @Override + protected Map<AccountTypeWithDataSet, AccountType> doInBackground(Void... params) { + return findUsableInvitableAccountTypes(mContext); + } + + @Override + protected void onPostExecute(Map<AccountTypeWithDataSet, AccountType> accountTypes) { + mInvitableAccountTypeCache.setCachedValue(accountTypes); + mInvitablesTaskIsRunning.set(false); + } + } +} diff --git a/java/com/android/contacts/common/model/BuilderWrapper.java b/java/com/android/contacts/common/model/BuilderWrapper.java new file mode 100644 index 000000000..9c666e59c --- /dev/null +++ b/java/com/android/contacts/common/model/BuilderWrapper.java @@ -0,0 +1,53 @@ +/* + * Copyright (C) 2015 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.common.model; + +import android.content.ContentProviderOperation.Builder; + +/** + * This class is created for the purpose of compatibility and make the type of + * ContentProviderOperation available on pre-M SDKs. Since ContentProviderOperation is usually + * created by Builder and we don’t have access to the type via Builder, so we need to create a + * wrapper class for Builder first and include type. Then we could use the builder and the type in + * this class to create a wrapper of ContentProviderOperation. + */ +public class BuilderWrapper { + + private Builder mBuilder; + private int mType; + + public BuilderWrapper(Builder builder, int type) { + mBuilder = builder; + mType = type; + } + + public int getType() { + return mType; + } + + public void setType(int mType) { + this.mType = mType; + } + + public Builder getBuilder() { + return mBuilder; + } + + public void setBuilder(Builder mBuilder) { + this.mBuilder = mBuilder; + } +} diff --git a/java/com/android/contacts/common/model/CPOWrapper.java b/java/com/android/contacts/common/model/CPOWrapper.java new file mode 100644 index 000000000..4a67e6700 --- /dev/null +++ b/java/com/android/contacts/common/model/CPOWrapper.java @@ -0,0 +1,50 @@ +/* + * Copyright (C) 2015 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.common.model; + +import android.content.ContentProviderOperation; + +/** + * This class is created for the purpose of compatibility and make the type of + * ContentProviderOperation available on pre-M SDKs. + */ +public class CPOWrapper { + + private ContentProviderOperation mOperation; + private int mType; + + public CPOWrapper(ContentProviderOperation builder, int type) { + mOperation = builder; + mType = type; + } + + public int getType() { + return mType; + } + + public void setType(int type) { + this.mType = type; + } + + public ContentProviderOperation getOperation() { + return mOperation; + } + + public void setOperation(ContentProviderOperation operation) { + this.mOperation = operation; + } +} diff --git a/java/com/android/contacts/common/model/Contact.java b/java/com/android/contacts/common/model/Contact.java new file mode 100644 index 000000000..ad0b66efe --- /dev/null +++ b/java/com/android/contacts/common/model/Contact.java @@ -0,0 +1,384 @@ +/* + * Copyright (C) 2012 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.common.model; + +import android.content.ContentValues; +import android.net.Uri; +import android.provider.ContactsContract.CommonDataKinds.Photo; +import android.provider.ContactsContract.Data; +import android.provider.ContactsContract.Directory; +import android.provider.ContactsContract.DisplayNameSources; +import android.support.annotation.VisibleForTesting; +import com.android.contacts.common.GroupMetaData; +import com.android.contacts.common.model.account.AccountType; +import com.google.common.collect.ImmutableList; +import java.util.ArrayList; + +/** + * A Contact represents a single person or logical entity as perceived by the user. The information + * about a contact can come from multiple data sources, which are each represented by a RawContact + * object. Thus, a Contact is associated with a collection of RawContact objects. + * + * <p>The aggregation of raw contacts into a single contact is performed automatically, and it is + * also possible for users to manually split and join raw contacts into various contacts. + * + * <p>Only the {@link ContactLoader} class can create a Contact object with various flags to allow + * partial loading of contact data. Thus, an instance of this class should be treated as a read-only + * object. + */ +public class Contact { + + private final Uri mRequestedUri; + private final Uri mLookupUri; + private final Uri mUri; + private final long mDirectoryId; + private final String mLookupKey; + private final long mId; + private final long mNameRawContactId; + private final int mDisplayNameSource; + private final long mPhotoId; + private final String mPhotoUri; + private final String mDisplayName; + private final String mAltDisplayName; + private final String mPhoneticName; + private final boolean mStarred; + private final Integer mPresence; + private final boolean mSendToVoicemail; + private final String mCustomRingtone; + private final boolean mIsUserProfile; + private final Contact.Status mStatus; + private final Exception mException; + private ImmutableList<RawContact> mRawContacts; + private ImmutableList<AccountType> mInvitableAccountTypes; + private String mDirectoryDisplayName; + private String mDirectoryType; + private String mDirectoryAccountType; + private String mDirectoryAccountName; + private int mDirectoryExportSupport; + private ImmutableList<GroupMetaData> mGroups; + private byte[] mPhotoBinaryData; + /** + * Small version of the contact photo loaded from a blob instead of from a file. If a large + * contact photo is not available yet, then this has the same value as mPhotoBinaryData. + */ + private byte[] mThumbnailPhotoBinaryData; + + /** Constructor for special results, namely "no contact found" and "error". */ + private Contact(Uri requestedUri, Contact.Status status, Exception exception) { + if (status == Status.ERROR && exception == null) { + throw new IllegalArgumentException("ERROR result must have exception"); + } + mStatus = status; + mException = exception; + mRequestedUri = requestedUri; + mLookupUri = null; + mUri = null; + mDirectoryId = -1; + mLookupKey = null; + mId = -1; + mRawContacts = null; + mNameRawContactId = -1; + mDisplayNameSource = DisplayNameSources.UNDEFINED; + mPhotoId = -1; + mPhotoUri = null; + mDisplayName = null; + mAltDisplayName = null; + mPhoneticName = null; + mStarred = false; + mPresence = null; + mInvitableAccountTypes = null; + mSendToVoicemail = false; + mCustomRingtone = null; + mIsUserProfile = false; + } + + /** Constructor to call when contact was found */ + public Contact( + Uri requestedUri, + Uri uri, + Uri lookupUri, + long directoryId, + String lookupKey, + long id, + long nameRawContactId, + int displayNameSource, + long photoId, + String photoUri, + String displayName, + String altDisplayName, + String phoneticName, + boolean starred, + Integer presence, + boolean sendToVoicemail, + String customRingtone, + boolean isUserProfile) { + mStatus = Status.LOADED; + mException = null; + mRequestedUri = requestedUri; + mLookupUri = lookupUri; + mUri = uri; + mDirectoryId = directoryId; + mLookupKey = lookupKey; + mId = id; + mRawContacts = null; + mNameRawContactId = nameRawContactId; + mDisplayNameSource = displayNameSource; + mPhotoId = photoId; + mPhotoUri = photoUri; + mDisplayName = displayName; + mAltDisplayName = altDisplayName; + mPhoneticName = phoneticName; + mStarred = starred; + mPresence = presence; + mInvitableAccountTypes = null; + mSendToVoicemail = sendToVoicemail; + mCustomRingtone = customRingtone; + mIsUserProfile = isUserProfile; + } + + public Contact(Uri requestedUri, Contact from) { + mRequestedUri = requestedUri; + + mStatus = from.mStatus; + mException = from.mException; + mLookupUri = from.mLookupUri; + mUri = from.mUri; + mDirectoryId = from.mDirectoryId; + mLookupKey = from.mLookupKey; + mId = from.mId; + mNameRawContactId = from.mNameRawContactId; + mDisplayNameSource = from.mDisplayNameSource; + mPhotoId = from.mPhotoId; + mPhotoUri = from.mPhotoUri; + mDisplayName = from.mDisplayName; + mAltDisplayName = from.mAltDisplayName; + mPhoneticName = from.mPhoneticName; + mStarred = from.mStarred; + mPresence = from.mPresence; + mRawContacts = from.mRawContacts; + mInvitableAccountTypes = from.mInvitableAccountTypes; + + mDirectoryDisplayName = from.mDirectoryDisplayName; + mDirectoryType = from.mDirectoryType; + mDirectoryAccountType = from.mDirectoryAccountType; + mDirectoryAccountName = from.mDirectoryAccountName; + mDirectoryExportSupport = from.mDirectoryExportSupport; + + mGroups = from.mGroups; + + mPhotoBinaryData = from.mPhotoBinaryData; + mSendToVoicemail = from.mSendToVoicemail; + mCustomRingtone = from.mCustomRingtone; + mIsUserProfile = from.mIsUserProfile; + } + + public static Contact forError(Uri requestedUri, Exception exception) { + return new Contact(requestedUri, Status.ERROR, exception); + } + + public static Contact forNotFound(Uri requestedUri) { + return new Contact(requestedUri, Status.NOT_FOUND, null); + } + + /** @param exportSupport See {@link Directory#EXPORT_SUPPORT}. */ + public void setDirectoryMetaData( + String displayName, + String directoryType, + String accountType, + String accountName, + int exportSupport) { + mDirectoryDisplayName = displayName; + mDirectoryType = directoryType; + mDirectoryAccountType = accountType; + mDirectoryAccountName = accountName; + mDirectoryExportSupport = exportSupport; + } + + /** + * Returns the URI for the contact that contains both the lookup key and the ID. This is the best + * URI to reference a contact. For directory contacts, this is the same a the URI as returned by + * {@link #getUri()} + */ + public Uri getLookupUri() { + return mLookupUri; + } + + public String getLookupKey() { + return mLookupKey; + } + + /** + * Returns the contact Uri that was passed to the provider to make the query. This is the same as + * the requested Uri, unless the requested Uri doesn't specify a Contact: If it either references + * a Raw-Contact or a Person (a pre-Eclair style Uri), this Uri will always reference the full + * aggregate contact. + */ + public Uri getUri() { + return mUri; + } + + /** Returns the contact ID. */ + @VisibleForTesting + public long getId() { + return mId; + } + + /** + * @return true when an exception happened during loading, in which case {@link #getException} + * returns the actual exception object. + */ + public boolean isError() { + return mStatus == Status.ERROR; + } + + public Exception getException() { + return mException; + } + + /** @return true if the specified contact is successfully loaded. */ + public boolean isLoaded() { + return mStatus == Status.LOADED; + } + + public long getNameRawContactId() { + return mNameRawContactId; + } + + public int getDisplayNameSource() { + return mDisplayNameSource; + } + + public long getPhotoId() { + return mPhotoId; + } + + public String getPhotoUri() { + return mPhotoUri; + } + + public String getDisplayName() { + return mDisplayName; + } + + public boolean getStarred() { + return mStarred; + } + + public Integer getPresence() { + return mPresence; + } + + /** + * This can return non-null invitable account types only if the {@link ContactLoader} was + * configured to load invitable account types in its constructor. + */ + public ImmutableList<AccountType> getInvitableAccountTypes() { + return mInvitableAccountTypes; + } + + /* package */ void setInvitableAccountTypes(ImmutableList<AccountType> accountTypes) { + mInvitableAccountTypes = accountTypes; + } + + public ImmutableList<RawContact> getRawContacts() { + return mRawContacts; + } + + /* package */ void setRawContacts(ImmutableList<RawContact> rawContacts) { + mRawContacts = rawContacts; + } + + public long getDirectoryId() { + return mDirectoryId; + } + + public boolean isDirectoryEntry() { + return mDirectoryId != -1 + && mDirectoryId != Directory.DEFAULT + && mDirectoryId != Directory.LOCAL_INVISIBLE; + } + + /* package */ void setPhotoBinaryData(byte[] photoBinaryData) { + mPhotoBinaryData = photoBinaryData; + } + + public byte[] getThumbnailPhotoBinaryData() { + return mThumbnailPhotoBinaryData; + } + + /* package */ void setThumbnailPhotoBinaryData(byte[] photoBinaryData) { + mThumbnailPhotoBinaryData = photoBinaryData; + } + + public ArrayList<ContentValues> getContentValues() { + if (mRawContacts.size() != 1) { + throw new IllegalStateException("Cannot extract content values from an aggregated contact"); + } + + RawContact rawContact = mRawContacts.get(0); + ArrayList<ContentValues> result = rawContact.getContentValues(); + + // If the photo was loaded using the URI, create an entry for the photo + // binary data. + if (mPhotoId == 0 && mPhotoBinaryData != null) { + ContentValues photo = new ContentValues(); + photo.put(Data.MIMETYPE, Photo.CONTENT_ITEM_TYPE); + photo.put(Photo.PHOTO, mPhotoBinaryData); + result.add(photo); + } + + return result; + } + + /** + * This can return non-null group meta-data only if the {@link ContactLoader} was configured to + * load group metadata in its constructor. + */ + public ImmutableList<GroupMetaData> getGroupMetaData() { + return mGroups; + } + + /* package */ void setGroupMetaData(ImmutableList<GroupMetaData> groups) { + mGroups = groups; + } + + public boolean isUserProfile() { + return mIsUserProfile; + } + + @Override + public String toString() { + return "{requested=" + + mRequestedUri + + ",lookupkey=" + + mLookupKey + + ",uri=" + + mUri + + ",status=" + + mStatus + + "}"; + } + + private enum Status { + /** Contact is successfully loaded */ + LOADED, + /** There was an error loading the contact */ + ERROR, + /** Contact is not found */ + NOT_FOUND, + } +} diff --git a/java/com/android/contacts/common/model/ContactLoader.java b/java/com/android/contacts/common/model/ContactLoader.java new file mode 100644 index 000000000..eb16bffcd --- /dev/null +++ b/java/com/android/contacts/common/model/ContactLoader.java @@ -0,0 +1,998 @@ +/* + * Copyright (C) 2010 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.common.model; + +import android.content.AsyncTaskLoader; +import android.content.ContentResolver; +import android.content.ContentUris; +import android.content.ContentValues; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.content.pm.PackageManager.NameNotFoundException; +import android.content.res.AssetFileDescriptor; +import android.content.res.Resources; +import android.database.Cursor; +import android.net.Uri; +import android.provider.ContactsContract; +import android.provider.ContactsContract.CommonDataKinds.GroupMembership; +import android.provider.ContactsContract.Contacts; +import android.provider.ContactsContract.Data; +import android.provider.ContactsContract.Directory; +import android.provider.ContactsContract.Groups; +import android.provider.ContactsContract.RawContacts; +import android.text.TextUtils; +import android.util.Log; +import com.android.contacts.common.GeoUtil; +import com.android.contacts.common.GroupMetaData; +import com.android.contacts.common.model.account.AccountType; +import com.android.contacts.common.model.account.AccountTypeWithDataSet; +import com.android.contacts.common.model.dataitem.DataItem; +import com.android.contacts.common.model.dataitem.PhoneDataItem; +import com.android.contacts.common.model.dataitem.PhotoDataItem; +import com.android.contacts.common.util.Constants; +import com.android.contacts.common.util.ContactLoaderUtils; +import com.android.contacts.common.util.UriUtils; +import com.android.dialer.compat.CompatUtils; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.Lists; +import com.google.common.collect.Maps; +import com.google.common.collect.Sets; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +/** Loads a single Contact and all it constituent RawContacts. */ +public class ContactLoader extends AsyncTaskLoader<Contact> { + + private static final String TAG = ContactLoader.class.getSimpleName(); + + /** A short-lived cache that can be set by {@link #cacheResult()} */ + private static Contact sCachedResult = null; + + private final Uri mRequestedUri; + private final Set<Long> mNotifiedRawContactIds = Sets.newHashSet(); + private Uri mLookupUri; + private boolean mLoadGroupMetaData; + private boolean mLoadInvitableAccountTypes; + private boolean mPostViewNotification; + private boolean mComputeFormattedPhoneNumber; + private Contact mContact; + private ForceLoadContentObserver mObserver; + + public ContactLoader(Context context, Uri lookupUri, boolean postViewNotification) { + this(context, lookupUri, false, false, postViewNotification, false); + } + + public ContactLoader( + Context context, + Uri lookupUri, + boolean loadGroupMetaData, + boolean loadInvitableAccountTypes, + boolean postViewNotification, + boolean computeFormattedPhoneNumber) { + super(context); + mLookupUri = lookupUri; + mRequestedUri = lookupUri; + mLoadGroupMetaData = loadGroupMetaData; + mLoadInvitableAccountTypes = loadInvitableAccountTypes; + mPostViewNotification = postViewNotification; + mComputeFormattedPhoneNumber = computeFormattedPhoneNumber; + } + + /** + * Parses a {@link Contact} stored as a JSON string in a lookup URI. + * + * @param lookupUri The contact information to parse . + * @return The parsed {@code Contact} information. + */ + public static Contact parseEncodedContactEntity(Uri lookupUri) { + try { + return loadEncodedContactEntity(lookupUri, lookupUri); + } catch (JSONException je) { + return null; + } + } + + private static Contact loadEncodedContactEntity(Uri uri, Uri lookupUri) throws JSONException { + final String jsonString = uri.getEncodedFragment(); + final JSONObject json = new JSONObject(jsonString); + + final long directoryId = + Long.valueOf(uri.getQueryParameter(ContactsContract.DIRECTORY_PARAM_KEY)); + + final String displayName = json.optString(Contacts.DISPLAY_NAME); + final String altDisplayName = json.optString(Contacts.DISPLAY_NAME_ALTERNATIVE, displayName); + final int displayNameSource = json.getInt(Contacts.DISPLAY_NAME_SOURCE); + final String photoUri = json.optString(Contacts.PHOTO_URI, null); + final Contact contact = + new Contact( + uri, + uri, + lookupUri, + directoryId, + null /* lookupKey */, + -1 /* id */, + -1 /* nameRawContactId */, + displayNameSource, + 0 /* photoId */, + photoUri, + displayName, + altDisplayName, + null /* phoneticName */, + false /* starred */, + null /* presence */, + false /* sendToVoicemail */, + null /* customRingtone */, + false /* isUserProfile */); + + final String accountName = json.optString(RawContacts.ACCOUNT_NAME, null); + final String directoryName = uri.getQueryParameter(Directory.DISPLAY_NAME); + if (accountName != null) { + final String accountType = json.getString(RawContacts.ACCOUNT_TYPE); + contact.setDirectoryMetaData( + directoryName, + null, + accountName, + accountType, + json.optInt(Directory.EXPORT_SUPPORT, Directory.EXPORT_SUPPORT_SAME_ACCOUNT_ONLY)); + } else { + contact.setDirectoryMetaData( + directoryName, + null, + null, + null, + json.optInt(Directory.EXPORT_SUPPORT, Directory.EXPORT_SUPPORT_ANY_ACCOUNT)); + } + + final ContentValues values = new ContentValues(); + values.put(Data._ID, -1); + values.put(Data.CONTACT_ID, -1); + final RawContact rawContact = new RawContact(values); + + final JSONObject items = json.getJSONObject(Contacts.CONTENT_ITEM_TYPE); + final Iterator keys = items.keys(); + while (keys.hasNext()) { + final String mimetype = (String) keys.next(); + + // Could be single object or array. + final JSONObject obj = items.optJSONObject(mimetype); + if (obj == null) { + final JSONArray array = items.getJSONArray(mimetype); + for (int i = 0; i < array.length(); i++) { + final JSONObject item = array.getJSONObject(i); + processOneRecord(rawContact, item, mimetype); + } + } else { + processOneRecord(rawContact, obj, mimetype); + } + } + + contact.setRawContacts(new ImmutableList.Builder<RawContact>().add(rawContact).build()); + return contact; + } + + private static void processOneRecord(RawContact rawContact, JSONObject item, String mimetype) + throws JSONException { + final ContentValues itemValues = new ContentValues(); + itemValues.put(Data.MIMETYPE, mimetype); + itemValues.put(Data._ID, -1); + + final Iterator iterator = item.keys(); + while (iterator.hasNext()) { + String name = (String) iterator.next(); + final Object o = item.get(name); + if (o instanceof String) { + itemValues.put(name, (String) o); + } else if (o instanceof Integer) { + itemValues.put(name, (Integer) o); + } + } + rawContact.addDataItemValues(itemValues); + } + + @Override + public Contact loadInBackground() { + Log.e(TAG, "loadInBackground=" + mLookupUri); + try { + final ContentResolver resolver = getContext().getContentResolver(); + final Uri uriCurrentFormat = ContactLoaderUtils.ensureIsContactUri(resolver, mLookupUri); + final Contact cachedResult = sCachedResult; + sCachedResult = null; + // Is this the same Uri as what we had before already? In that case, reuse that result + final Contact result; + final boolean resultIsCached; + if (cachedResult != null && UriUtils.areEqual(cachedResult.getLookupUri(), mLookupUri)) { + // We are using a cached result from earlier. Below, we should make sure + // we are not doing any more network or disc accesses + result = new Contact(mRequestedUri, cachedResult); + resultIsCached = true; + } else { + if (uriCurrentFormat.getLastPathSegment().equals(Constants.LOOKUP_URI_ENCODED)) { + result = loadEncodedContactEntity(uriCurrentFormat, mLookupUri); + } else { + result = loadContactEntity(resolver, uriCurrentFormat); + } + resultIsCached = false; + } + if (result.isLoaded()) { + if (result.isDirectoryEntry()) { + if (!resultIsCached) { + loadDirectoryMetaData(result); + } + } else if (mLoadGroupMetaData) { + if (result.getGroupMetaData() == null) { + loadGroupMetaData(result); + } + } + if (mComputeFormattedPhoneNumber) { + computeFormattedPhoneNumbers(result); + } + if (!resultIsCached) { + loadPhotoBinaryData(result); + } + + // Note ME profile should never have "Add connection" + if (mLoadInvitableAccountTypes && result.getInvitableAccountTypes() == null) { + loadInvitableAccountTypes(result); + } + } + return result; + } catch (Exception e) { + Log.e(TAG, "Error loading the contact: " + mLookupUri, e); + return Contact.forError(mRequestedUri, e); + } + } + + private Contact loadContactEntity(ContentResolver resolver, Uri contactUri) { + Uri entityUri = Uri.withAppendedPath(contactUri, Contacts.Entity.CONTENT_DIRECTORY); + Cursor cursor = + resolver.query(entityUri, ContactQuery.COLUMNS, null, null, Contacts.Entity.RAW_CONTACT_ID); + if (cursor == null) { + Log.e(TAG, "No cursor returned in loadContactEntity"); + return Contact.forNotFound(mRequestedUri); + } + + try { + if (!cursor.moveToFirst()) { + cursor.close(); + return Contact.forNotFound(mRequestedUri); + } + + // Create the loaded contact starting with the header data. + Contact contact = loadContactHeaderData(cursor, contactUri); + + // Fill in the raw contacts, which is wrapped in an Entity and any + // status data. Initially, result has empty entities and statuses. + long currentRawContactId = -1; + RawContact rawContact = null; + ImmutableList.Builder<RawContact> rawContactsBuilder = + new ImmutableList.Builder<RawContact>(); + do { + long rawContactId = cursor.getLong(ContactQuery.RAW_CONTACT_ID); + if (rawContactId != currentRawContactId) { + // First time to see this raw contact id, so create a new entity, and + // add it to the result's entities. + currentRawContactId = rawContactId; + rawContact = new RawContact(loadRawContactValues(cursor)); + rawContactsBuilder.add(rawContact); + } + if (!cursor.isNull(ContactQuery.DATA_ID)) { + ContentValues data = loadDataValues(cursor); + rawContact.addDataItemValues(data); + } + } while (cursor.moveToNext()); + + contact.setRawContacts(rawContactsBuilder.build()); + + return contact; + } finally { + cursor.close(); + } + } + + /** + * Looks for the photo data item in entities. If found, a thumbnail will be stored. A larger photo + * will also be stored if available. + */ + private void loadPhotoBinaryData(Contact contactData) { + loadThumbnailBinaryData(contactData); + + // Try to load the large photo from a file using the photo URI. + String photoUri = contactData.getPhotoUri(); + if (photoUri != null) { + try { + final InputStream inputStream; + final AssetFileDescriptor fd; + final Uri uri = Uri.parse(photoUri); + final String scheme = uri.getScheme(); + if ("http".equals(scheme) || "https".equals(scheme)) { + // Support HTTP urls that might come from extended directories + inputStream = new URL(photoUri).openStream(); + fd = null; + } else { + fd = getContext().getContentResolver().openAssetFileDescriptor(uri, "r"); + inputStream = fd.createInputStream(); + } + byte[] buffer = new byte[16 * 1024]; + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try { + int size; + while ((size = inputStream.read(buffer)) != -1) { + baos.write(buffer, 0, size); + } + contactData.setPhotoBinaryData(baos.toByteArray()); + } finally { + inputStream.close(); + if (fd != null) { + fd.close(); + } + } + return; + } catch (IOException ioe) { + // Just fall back to the case below. + } + } + + // If we couldn't load from a file, fall back to the data blob. + contactData.setPhotoBinaryData(contactData.getThumbnailPhotoBinaryData()); + } + + private void loadThumbnailBinaryData(Contact contactData) { + final long photoId = contactData.getPhotoId(); + if (photoId <= 0) { + // No photo ID + return; + } + + for (RawContact rawContact : contactData.getRawContacts()) { + for (DataItem dataItem : rawContact.getDataItems()) { + if (dataItem.getId() == photoId) { + if (!(dataItem instanceof PhotoDataItem)) { + break; + } + + final PhotoDataItem photo = (PhotoDataItem) dataItem; + contactData.setThumbnailPhotoBinaryData(photo.getPhoto()); + break; + } + } + } + } + + /** Sets the "invitable" account types to {@link Contact#mInvitableAccountTypes}. */ + private void loadInvitableAccountTypes(Contact contactData) { + final ImmutableList.Builder<AccountType> resultListBuilder = + new ImmutableList.Builder<AccountType>(); + if (!contactData.isUserProfile()) { + Map<AccountTypeWithDataSet, AccountType> invitables = + AccountTypeManager.getInstance(getContext()).getUsableInvitableAccountTypes(); + if (!invitables.isEmpty()) { + final Map<AccountTypeWithDataSet, AccountType> resultMap = Maps.newHashMap(invitables); + + // Remove the ones that already have a raw contact in the current contact + for (RawContact rawContact : contactData.getRawContacts()) { + final AccountTypeWithDataSet type = + AccountTypeWithDataSet.get( + rawContact.getAccountTypeString(), rawContact.getDataSet()); + resultMap.remove(type); + } + + resultListBuilder.addAll(resultMap.values()); + } + } + + // Set to mInvitableAccountTypes + contactData.setInvitableAccountTypes(resultListBuilder.build()); + } + + /** Extracts Contact level columns from the cursor. */ + private Contact loadContactHeaderData(final Cursor cursor, Uri contactUri) { + final String directoryParameter = + contactUri.getQueryParameter(ContactsContract.DIRECTORY_PARAM_KEY); + final long directoryId = + directoryParameter == null ? Directory.DEFAULT : Long.parseLong(directoryParameter); + final long contactId = cursor.getLong(ContactQuery.CONTACT_ID); + final String lookupKey = cursor.getString(ContactQuery.LOOKUP_KEY); + final long nameRawContactId = cursor.getLong(ContactQuery.NAME_RAW_CONTACT_ID); + final int displayNameSource = cursor.getInt(ContactQuery.DISPLAY_NAME_SOURCE); + final String displayName = cursor.getString(ContactQuery.DISPLAY_NAME); + final String altDisplayName = cursor.getString(ContactQuery.ALT_DISPLAY_NAME); + final String phoneticName = cursor.getString(ContactQuery.PHONETIC_NAME); + final long photoId = cursor.getLong(ContactQuery.PHOTO_ID); + final String photoUri = cursor.getString(ContactQuery.PHOTO_URI); + final boolean starred = cursor.getInt(ContactQuery.STARRED) != 0; + final Integer presence = + cursor.isNull(ContactQuery.CONTACT_PRESENCE) + ? null + : cursor.getInt(ContactQuery.CONTACT_PRESENCE); + final boolean sendToVoicemail = cursor.getInt(ContactQuery.SEND_TO_VOICEMAIL) == 1; + final String customRingtone = cursor.getString(ContactQuery.CUSTOM_RINGTONE); + final boolean isUserProfile = cursor.getInt(ContactQuery.IS_USER_PROFILE) == 1; + + Uri lookupUri; + if (directoryId == Directory.DEFAULT || directoryId == Directory.LOCAL_INVISIBLE) { + lookupUri = + ContentUris.withAppendedId( + Uri.withAppendedPath(Contacts.CONTENT_LOOKUP_URI, lookupKey), contactId); + } else { + lookupUri = contactUri; + } + + return new Contact( + mRequestedUri, + contactUri, + lookupUri, + directoryId, + lookupKey, + contactId, + nameRawContactId, + displayNameSource, + photoId, + photoUri, + displayName, + altDisplayName, + phoneticName, + starred, + presence, + sendToVoicemail, + customRingtone, + isUserProfile); + } + + /** Extracts RawContact level columns from the cursor. */ + private ContentValues loadRawContactValues(Cursor cursor) { + ContentValues cv = new ContentValues(); + + cv.put(RawContacts._ID, cursor.getLong(ContactQuery.RAW_CONTACT_ID)); + + cursorColumnToContentValues(cursor, cv, ContactQuery.ACCOUNT_NAME); + cursorColumnToContentValues(cursor, cv, ContactQuery.ACCOUNT_TYPE); + cursorColumnToContentValues(cursor, cv, ContactQuery.DATA_SET); + cursorColumnToContentValues(cursor, cv, ContactQuery.DIRTY); + cursorColumnToContentValues(cursor, cv, ContactQuery.VERSION); + cursorColumnToContentValues(cursor, cv, ContactQuery.SOURCE_ID); + cursorColumnToContentValues(cursor, cv, ContactQuery.SYNC1); + cursorColumnToContentValues(cursor, cv, ContactQuery.SYNC2); + cursorColumnToContentValues(cursor, cv, ContactQuery.SYNC3); + cursorColumnToContentValues(cursor, cv, ContactQuery.SYNC4); + cursorColumnToContentValues(cursor, cv, ContactQuery.DELETED); + cursorColumnToContentValues(cursor, cv, ContactQuery.CONTACT_ID); + cursorColumnToContentValues(cursor, cv, ContactQuery.STARRED); + + return cv; + } + + /** Extracts Data level columns from the cursor. */ + private ContentValues loadDataValues(Cursor cursor) { + ContentValues cv = new ContentValues(); + + cv.put(Data._ID, cursor.getLong(ContactQuery.DATA_ID)); + + cursorColumnToContentValues(cursor, cv, ContactQuery.DATA1); + cursorColumnToContentValues(cursor, cv, ContactQuery.DATA2); + cursorColumnToContentValues(cursor, cv, ContactQuery.DATA3); + cursorColumnToContentValues(cursor, cv, ContactQuery.DATA4); + cursorColumnToContentValues(cursor, cv, ContactQuery.DATA5); + cursorColumnToContentValues(cursor, cv, ContactQuery.DATA6); + cursorColumnToContentValues(cursor, cv, ContactQuery.DATA7); + cursorColumnToContentValues(cursor, cv, ContactQuery.DATA8); + cursorColumnToContentValues(cursor, cv, ContactQuery.DATA9); + cursorColumnToContentValues(cursor, cv, ContactQuery.DATA10); + cursorColumnToContentValues(cursor, cv, ContactQuery.DATA11); + cursorColumnToContentValues(cursor, cv, ContactQuery.DATA12); + cursorColumnToContentValues(cursor, cv, ContactQuery.DATA13); + cursorColumnToContentValues(cursor, cv, ContactQuery.DATA14); + cursorColumnToContentValues(cursor, cv, ContactQuery.DATA15); + cursorColumnToContentValues(cursor, cv, ContactQuery.DATA_SYNC1); + cursorColumnToContentValues(cursor, cv, ContactQuery.DATA_SYNC2); + cursorColumnToContentValues(cursor, cv, ContactQuery.DATA_SYNC3); + cursorColumnToContentValues(cursor, cv, ContactQuery.DATA_SYNC4); + cursorColumnToContentValues(cursor, cv, ContactQuery.DATA_VERSION); + cursorColumnToContentValues(cursor, cv, ContactQuery.IS_PRIMARY); + cursorColumnToContentValues(cursor, cv, ContactQuery.IS_SUPERPRIMARY); + cursorColumnToContentValues(cursor, cv, ContactQuery.MIMETYPE); + cursorColumnToContentValues(cursor, cv, ContactQuery.GROUP_SOURCE_ID); + cursorColumnToContentValues(cursor, cv, ContactQuery.CHAT_CAPABILITY); + cursorColumnToContentValues(cursor, cv, ContactQuery.TIMES_USED); + cursorColumnToContentValues(cursor, cv, ContactQuery.LAST_TIME_USED); + if (CompatUtils.isMarshmallowCompatible()) { + cursorColumnToContentValues(cursor, cv, ContactQuery.CARRIER_PRESENCE); + } + + return cv; + } + + private void cursorColumnToContentValues(Cursor cursor, ContentValues values, int index) { + switch (cursor.getType(index)) { + case Cursor.FIELD_TYPE_NULL: + // don't put anything in the content values + break; + case Cursor.FIELD_TYPE_INTEGER: + values.put(ContactQuery.COLUMNS[index], cursor.getLong(index)); + break; + case Cursor.FIELD_TYPE_STRING: + values.put(ContactQuery.COLUMNS[index], cursor.getString(index)); + break; + case Cursor.FIELD_TYPE_BLOB: + values.put(ContactQuery.COLUMNS[index], cursor.getBlob(index)); + break; + default: + throw new IllegalStateException("Invalid or unhandled data type"); + } + } + + private void loadDirectoryMetaData(Contact result) { + long directoryId = result.getDirectoryId(); + + Cursor cursor = + getContext() + .getContentResolver() + .query( + ContentUris.withAppendedId(Directory.CONTENT_URI, directoryId), + DirectoryQuery.COLUMNS, + null, + null, + null); + if (cursor == null) { + return; + } + try { + if (cursor.moveToFirst()) { + final String displayName = cursor.getString(DirectoryQuery.DISPLAY_NAME); + final String packageName = cursor.getString(DirectoryQuery.PACKAGE_NAME); + final int typeResourceId = cursor.getInt(DirectoryQuery.TYPE_RESOURCE_ID); + final String accountType = cursor.getString(DirectoryQuery.ACCOUNT_TYPE); + final String accountName = cursor.getString(DirectoryQuery.ACCOUNT_NAME); + final int exportSupport = cursor.getInt(DirectoryQuery.EXPORT_SUPPORT); + String directoryType = null; + if (!TextUtils.isEmpty(packageName)) { + PackageManager pm = getContext().getPackageManager(); + try { + Resources resources = pm.getResourcesForApplication(packageName); + directoryType = resources.getString(typeResourceId); + } catch (NameNotFoundException e) { + Log.w( + TAG, "Contact directory resource not found: " + packageName + "." + typeResourceId); + } + } + + result.setDirectoryMetaData( + displayName, directoryType, accountType, accountName, exportSupport); + } + } finally { + cursor.close(); + } + } + + /** + * Loads groups meta-data for all groups associated with all constituent raw contacts' accounts. + */ + private void loadGroupMetaData(Contact result) { + StringBuilder selection = new StringBuilder(); + ArrayList<String> selectionArgs = new ArrayList<String>(); + final HashSet<AccountKey> accountsSeen = new HashSet<>(); + for (RawContact rawContact : result.getRawContacts()) { + final String accountName = rawContact.getAccountName(); + final String accountType = rawContact.getAccountTypeString(); + final String dataSet = rawContact.getDataSet(); + final AccountKey accountKey = new AccountKey(accountName, accountType, dataSet); + if (accountName != null && accountType != null && !accountsSeen.contains(accountKey)) { + accountsSeen.add(accountKey); + if (selection.length() != 0) { + selection.append(" OR "); + } + selection.append("(" + Groups.ACCOUNT_NAME + "=? AND " + Groups.ACCOUNT_TYPE + "=?"); + selectionArgs.add(accountName); + selectionArgs.add(accountType); + + if (dataSet != null) { + selection.append(" AND " + Groups.DATA_SET + "=?"); + selectionArgs.add(dataSet); + } else { + selection.append(" AND " + Groups.DATA_SET + " IS NULL"); + } + selection.append(")"); + } + } + final ImmutableList.Builder<GroupMetaData> groupListBuilder = + new ImmutableList.Builder<GroupMetaData>(); + final Cursor cursor = + getContext() + .getContentResolver() + .query( + Groups.CONTENT_URI, + GroupQuery.COLUMNS, + selection.toString(), + selectionArgs.toArray(new String[0]), + null); + if (cursor != null) { + try { + while (cursor.moveToNext()) { + final String accountName = cursor.getString(GroupQuery.ACCOUNT_NAME); + final String accountType = cursor.getString(GroupQuery.ACCOUNT_TYPE); + final String dataSet = cursor.getString(GroupQuery.DATA_SET); + final long groupId = cursor.getLong(GroupQuery.ID); + final String title = cursor.getString(GroupQuery.TITLE); + final boolean defaultGroup = + !cursor.isNull(GroupQuery.AUTO_ADD) && cursor.getInt(GroupQuery.AUTO_ADD) != 0; + final boolean favorites = + !cursor.isNull(GroupQuery.FAVORITES) && cursor.getInt(GroupQuery.FAVORITES) != 0; + + groupListBuilder.add( + new GroupMetaData( + accountName, accountType, dataSet, groupId, title, defaultGroup, favorites)); + } + } finally { + cursor.close(); + } + } + result.setGroupMetaData(groupListBuilder.build()); + } + + /** + * Iterates over all data items that represent phone numbers are tries to calculate a formatted + * number. This function can safely be called several times as no unformatted data is overwritten + */ + private void computeFormattedPhoneNumbers(Contact contactData) { + final String countryIso = GeoUtil.getCurrentCountryIso(getContext()); + final ImmutableList<RawContact> rawContacts = contactData.getRawContacts(); + final int rawContactCount = rawContacts.size(); + for (int rawContactIndex = 0; rawContactIndex < rawContactCount; rawContactIndex++) { + final RawContact rawContact = rawContacts.get(rawContactIndex); + final List<DataItem> dataItems = rawContact.getDataItems(); + final int dataCount = dataItems.size(); + for (int dataIndex = 0; dataIndex < dataCount; dataIndex++) { + final DataItem dataItem = dataItems.get(dataIndex); + if (dataItem instanceof PhoneDataItem) { + final PhoneDataItem phoneDataItem = (PhoneDataItem) dataItem; + phoneDataItem.computeFormattedPhoneNumber(countryIso); + } + } + } + } + + @Override + public void deliverResult(Contact result) { + unregisterObserver(); + + // The creator isn't interested in any further updates + if (isReset() || result == null) { + return; + } + + mContact = result; + + if (result.isLoaded()) { + mLookupUri = result.getLookupUri(); + + if (!result.isDirectoryEntry()) { + Log.i(TAG, "Registering content observer for " + mLookupUri); + if (mObserver == null) { + mObserver = new ForceLoadContentObserver(); + } + getContext().getContentResolver().registerContentObserver(mLookupUri, true, mObserver); + } + + if (mPostViewNotification) { + // inform the source of the data that this contact is being looked at + postViewNotificationToSyncAdapter(); + } + } + + super.deliverResult(mContact); + } + + /** + * Posts a message to the contributing sync adapters that have opted-in, notifying them that the + * contact has just been loaded + */ + private void postViewNotificationToSyncAdapter() { + Context context = getContext(); + for (RawContact rawContact : mContact.getRawContacts()) { + final long rawContactId = rawContact.getId(); + if (mNotifiedRawContactIds.contains(rawContactId)) { + continue; // Already notified for this raw contact. + } + mNotifiedRawContactIds.add(rawContactId); + final AccountType accountType = rawContact.getAccountType(context); + final String serviceName = accountType.getViewContactNotifyServiceClassName(); + final String servicePackageName = accountType.getViewContactNotifyServicePackageName(); + if (!TextUtils.isEmpty(serviceName) && !TextUtils.isEmpty(servicePackageName)) { + final Uri uri = ContentUris.withAppendedId(RawContacts.CONTENT_URI, rawContactId); + final Intent intent = new Intent(); + intent.setClassName(servicePackageName, serviceName); + intent.setAction(Intent.ACTION_VIEW); + intent.setDataAndType(uri, RawContacts.CONTENT_ITEM_TYPE); + try { + context.startService(intent); + } catch (Exception e) { + Log.e(TAG, "Error sending message to source-app", e); + } + } + } + } + + private void unregisterObserver() { + if (mObserver != null) { + getContext().getContentResolver().unregisterContentObserver(mObserver); + mObserver = null; + } + } + + public Uri getLookupUri() { + return mLookupUri; + } + + public void setLookupUri(Uri lookupUri) { + mLookupUri = lookupUri; + } + + @Override + protected void onStartLoading() { + if (mContact != null) { + deliverResult(mContact); + } + + if (takeContentChanged() || mContact == null) { + forceLoad(); + } + } + + @Override + protected void onStopLoading() { + cancelLoad(); + } + + @Override + protected void onReset() { + super.onReset(); + cancelLoad(); + unregisterObserver(); + mContact = null; + } + + /** + * Projection used for the query that loads all data for the entire contact (except for social + * stream items). + */ + private static class ContactQuery { + + public static final int NAME_RAW_CONTACT_ID = 0; + public static final int DISPLAY_NAME_SOURCE = 1; + public static final int LOOKUP_KEY = 2; + public static final int DISPLAY_NAME = 3; + public static final int ALT_DISPLAY_NAME = 4; + public static final int PHONETIC_NAME = 5; + public static final int PHOTO_ID = 6; + public static final int STARRED = 7; + public static final int CONTACT_PRESENCE = 8; + public static final int CONTACT_STATUS = 9; + public static final int CONTACT_STATUS_TIMESTAMP = 10; + public static final int CONTACT_STATUS_RES_PACKAGE = 11; + public static final int CONTACT_STATUS_LABEL = 12; + public static final int CONTACT_ID = 13; + public static final int RAW_CONTACT_ID = 14; + public static final int ACCOUNT_NAME = 15; + public static final int ACCOUNT_TYPE = 16; + public static final int DATA_SET = 17; + public static final int DIRTY = 18; + public static final int VERSION = 19; + public static final int SOURCE_ID = 20; + public static final int SYNC1 = 21; + public static final int SYNC2 = 22; + public static final int SYNC3 = 23; + public static final int SYNC4 = 24; + public static final int DELETED = 25; + public static final int DATA_ID = 26; + public static final int DATA1 = 27; + public static final int DATA2 = 28; + public static final int DATA3 = 29; + public static final int DATA4 = 30; + public static final int DATA5 = 31; + public static final int DATA6 = 32; + public static final int DATA7 = 33; + public static final int DATA8 = 34; + public static final int DATA9 = 35; + public static final int DATA10 = 36; + public static final int DATA11 = 37; + public static final int DATA12 = 38; + public static final int DATA13 = 39; + public static final int DATA14 = 40; + public static final int DATA15 = 41; + public static final int DATA_SYNC1 = 42; + public static final int DATA_SYNC2 = 43; + public static final int DATA_SYNC3 = 44; + public static final int DATA_SYNC4 = 45; + public static final int DATA_VERSION = 46; + public static final int IS_PRIMARY = 47; + public static final int IS_SUPERPRIMARY = 48; + public static final int MIMETYPE = 49; + public static final int GROUP_SOURCE_ID = 50; + public static final int PRESENCE = 51; + public static final int CHAT_CAPABILITY = 52; + public static final int STATUS = 53; + public static final int STATUS_RES_PACKAGE = 54; + public static final int STATUS_ICON = 55; + public static final int STATUS_LABEL = 56; + public static final int STATUS_TIMESTAMP = 57; + public static final int PHOTO_URI = 58; + public static final int SEND_TO_VOICEMAIL = 59; + public static final int CUSTOM_RINGTONE = 60; + public static final int IS_USER_PROFILE = 61; + public static final int TIMES_USED = 62; + public static final int LAST_TIME_USED = 63; + public static final int CARRIER_PRESENCE = 64; + static final String[] COLUMNS_INTERNAL = + new String[] { + Contacts.NAME_RAW_CONTACT_ID, + Contacts.DISPLAY_NAME_SOURCE, + Contacts.LOOKUP_KEY, + Contacts.DISPLAY_NAME, + Contacts.DISPLAY_NAME_ALTERNATIVE, + Contacts.PHONETIC_NAME, + Contacts.PHOTO_ID, + Contacts.STARRED, + Contacts.CONTACT_PRESENCE, + Contacts.CONTACT_STATUS, + Contacts.CONTACT_STATUS_TIMESTAMP, + Contacts.CONTACT_STATUS_RES_PACKAGE, + Contacts.CONTACT_STATUS_LABEL, + Contacts.Entity.CONTACT_ID, + Contacts.Entity.RAW_CONTACT_ID, + RawContacts.ACCOUNT_NAME, + RawContacts.ACCOUNT_TYPE, + RawContacts.DATA_SET, + RawContacts.DIRTY, + RawContacts.VERSION, + RawContacts.SOURCE_ID, + RawContacts.SYNC1, + RawContacts.SYNC2, + RawContacts.SYNC3, + RawContacts.SYNC4, + RawContacts.DELETED, + Contacts.Entity.DATA_ID, + Data.DATA1, + Data.DATA2, + Data.DATA3, + Data.DATA4, + Data.DATA5, + Data.DATA6, + Data.DATA7, + Data.DATA8, + Data.DATA9, + Data.DATA10, + Data.DATA11, + Data.DATA12, + Data.DATA13, + Data.DATA14, + Data.DATA15, + Data.SYNC1, + Data.SYNC2, + Data.SYNC3, + Data.SYNC4, + Data.DATA_VERSION, + Data.IS_PRIMARY, + Data.IS_SUPER_PRIMARY, + Data.MIMETYPE, + GroupMembership.GROUP_SOURCE_ID, + Data.PRESENCE, + Data.CHAT_CAPABILITY, + Data.STATUS, + Data.STATUS_RES_PACKAGE, + Data.STATUS_ICON, + Data.STATUS_LABEL, + Data.STATUS_TIMESTAMP, + Contacts.PHOTO_URI, + Contacts.SEND_TO_VOICEMAIL, + Contacts.CUSTOM_RINGTONE, + Contacts.IS_USER_PROFILE, + Data.TIMES_USED, + Data.LAST_TIME_USED + }; + static final String[] COLUMNS; + + static { + List<String> projectionList = Lists.newArrayList(COLUMNS_INTERNAL); + if (CompatUtils.isMarshmallowCompatible()) { + projectionList.add(Data.CARRIER_PRESENCE); + } + COLUMNS = projectionList.toArray(new String[projectionList.size()]); + } + } + + /** Projection used for the query that loads all data for the entire contact. */ + private static class DirectoryQuery { + + public static final int DISPLAY_NAME = 0; + public static final int PACKAGE_NAME = 1; + public static final int TYPE_RESOURCE_ID = 2; + public static final int ACCOUNT_TYPE = 3; + public static final int ACCOUNT_NAME = 4; + public static final int EXPORT_SUPPORT = 5; + static final String[] COLUMNS = + new String[] { + Directory.DISPLAY_NAME, + Directory.PACKAGE_NAME, + Directory.TYPE_RESOURCE_ID, + Directory.ACCOUNT_TYPE, + Directory.ACCOUNT_NAME, + Directory.EXPORT_SUPPORT, + }; + } + + private static class GroupQuery { + + public static final int ACCOUNT_NAME = 0; + public static final int ACCOUNT_TYPE = 1; + public static final int DATA_SET = 2; + public static final int ID = 3; + public static final int TITLE = 4; + public static final int AUTO_ADD = 5; + public static final int FAVORITES = 6; + static final String[] COLUMNS = + new String[] { + Groups.ACCOUNT_NAME, + Groups.ACCOUNT_TYPE, + Groups.DATA_SET, + Groups._ID, + Groups.TITLE, + Groups.AUTO_ADD, + Groups.FAVORITES, + }; + } + + private static class AccountKey { + + private final String mAccountName; + private final String mAccountType; + private final String mDataSet; + + public AccountKey(String accountName, String accountType, String dataSet) { + mAccountName = accountName; + mAccountType = accountType; + mDataSet = dataSet; + } + + @Override + public int hashCode() { + return Objects.hash(mAccountName, mAccountType, mDataSet); + } + + @Override + public boolean equals(Object obj) { + if (!(obj instanceof AccountKey)) { + return false; + } + final AccountKey other = (AccountKey) obj; + return Objects.equals(mAccountName, other.mAccountName) + && Objects.equals(mAccountType, other.mAccountType) + && Objects.equals(mDataSet, other.mDataSet); + } + } +} diff --git a/java/com/android/contacts/common/model/RawContact.java b/java/com/android/contacts/common/model/RawContact.java new file mode 100644 index 000000000..9efc8a878 --- /dev/null +++ b/java/com/android/contacts/common/model/RawContact.java @@ -0,0 +1,351 @@ +/* + * Copyright (C) 2012 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.common.model; + +import android.content.ContentValues; +import android.content.Context; +import android.content.Entity; +import android.net.Uri; +import android.os.Parcel; +import android.os.Parcelable; +import android.provider.ContactsContract.Contacts; +import android.provider.ContactsContract.Data; +import android.provider.ContactsContract.RawContacts; +import com.android.contacts.common.model.account.AccountType; +import com.android.contacts.common.model.account.AccountWithDataSet; +import com.android.contacts.common.model.dataitem.DataItem; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +/** + * RawContact represents a single raw contact in the raw contacts database. It has specialized + * getters/setters for raw contact items, and also contains a collection of DataItem objects. A + * RawContact contains the information from a single account. + * + * <p>This allows RawContact objects to be thought of as a class with raw contact fields (like + * account type, name, data set, sync state, etc.) and a list of DataItem objects that represent + * contact information elements (like phone numbers, email, address, etc.). + */ +public final class RawContact implements Parcelable { + + /** Create for building the parcelable. */ + public static final Parcelable.Creator<RawContact> CREATOR = + new Parcelable.Creator<RawContact>() { + + @Override + public RawContact createFromParcel(Parcel parcel) { + return new RawContact(parcel); + } + + @Override + public RawContact[] newArray(int i) { + return new RawContact[i]; + } + }; + + private final ContentValues mValues; + private final ArrayList<NamedDataItem> mDataItems; + private AccountTypeManager mAccountTypeManager; + + /** A RawContact object can be created with or without a context. */ + public RawContact() { + this(new ContentValues()); + } + + public RawContact(ContentValues values) { + mValues = values; + mDataItems = new ArrayList<NamedDataItem>(); + } + + /** + * Constructor for the parcelable. + * + * @param parcel The parcel to de-serialize from. + */ + private RawContact(Parcel parcel) { + mValues = parcel.readParcelable(ContentValues.class.getClassLoader()); + mDataItems = new ArrayList<>(); + parcel.readTypedList(mDataItems, NamedDataItem.CREATOR); + } + + public static RawContact createFrom(Entity entity) { + final ContentValues values = entity.getEntityValues(); + final ArrayList<Entity.NamedContentValues> subValues = entity.getSubValues(); + + RawContact rawContact = new RawContact(values); + for (Entity.NamedContentValues subValue : subValues) { + rawContact.addNamedDataItemValues(subValue.uri, subValue.values); + } + return rawContact; + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel parcel, int i) { + parcel.writeParcelable(mValues, i); + parcel.writeTypedList(mDataItems); + } + + public AccountTypeManager getAccountTypeManager(Context context) { + if (mAccountTypeManager == null) { + mAccountTypeManager = AccountTypeManager.getInstance(context); + } + return mAccountTypeManager; + } + + public ContentValues getValues() { + return mValues; + } + + /** Returns the id of the raw contact. */ + public Long getId() { + return getValues().getAsLong(RawContacts._ID); + } + + /** Returns the account name of the raw contact. */ + public String getAccountName() { + return getValues().getAsString(RawContacts.ACCOUNT_NAME); + } + + /** Returns the account type of the raw contact. */ + public String getAccountTypeString() { + return getValues().getAsString(RawContacts.ACCOUNT_TYPE); + } + + /** Returns the data set of the raw contact. */ + public String getDataSet() { + return getValues().getAsString(RawContacts.DATA_SET); + } + + public boolean isDirty() { + return getValues().getAsBoolean(RawContacts.DIRTY); + } + + public String getSourceId() { + return getValues().getAsString(RawContacts.SOURCE_ID); + } + + public String getSync1() { + return getValues().getAsString(RawContacts.SYNC1); + } + + public String getSync2() { + return getValues().getAsString(RawContacts.SYNC2); + } + + public String getSync3() { + return getValues().getAsString(RawContacts.SYNC3); + } + + public String getSync4() { + return getValues().getAsString(RawContacts.SYNC4); + } + + public boolean isDeleted() { + return getValues().getAsBoolean(RawContacts.DELETED); + } + + public long getContactId() { + return getValues().getAsLong(Contacts.Entity.CONTACT_ID); + } + + public boolean isStarred() { + return getValues().getAsBoolean(Contacts.STARRED); + } + + public AccountType getAccountType(Context context) { + return getAccountTypeManager(context).getAccountType(getAccountTypeString(), getDataSet()); + } + + /** + * Sets the account name, account type, and data set strings. Valid combinations for account-name, + * account-type, data-set 1) null, null, null (local account) 2) non-null, non-null, null (valid + * account without data-set) 3) non-null, non-null, non-null (valid account with data-set) + */ + private void setAccount(String accountName, String accountType, String dataSet) { + final ContentValues values = getValues(); + if (accountName == null) { + if (accountType == null && dataSet == null) { + // This is a local account + values.putNull(RawContacts.ACCOUNT_NAME); + values.putNull(RawContacts.ACCOUNT_TYPE); + values.putNull(RawContacts.DATA_SET); + return; + } + } else { + if (accountType != null) { + // This is a valid account, either with or without a dataSet. + values.put(RawContacts.ACCOUNT_NAME, accountName); + values.put(RawContacts.ACCOUNT_TYPE, accountType); + if (dataSet == null) { + values.putNull(RawContacts.DATA_SET); + } else { + values.put(RawContacts.DATA_SET, dataSet); + } + return; + } + } + throw new IllegalArgumentException( + "Not a valid combination of account name, type, and data set."); + } + + public void setAccount(AccountWithDataSet accountWithDataSet) { + if (accountWithDataSet != null) { + setAccount(accountWithDataSet.name, accountWithDataSet.type, accountWithDataSet.dataSet); + } else { + setAccount(null, null, null); + } + } + + public void setAccountToLocal() { + setAccount(null, null, null); + } + + /** Creates and inserts a DataItem object that wraps the content values, and returns it. */ + public void addDataItemValues(ContentValues values) { + addNamedDataItemValues(Data.CONTENT_URI, values); + } + + public NamedDataItem addNamedDataItemValues(Uri uri, ContentValues values) { + final NamedDataItem namedItem = new NamedDataItem(uri, values); + mDataItems.add(namedItem); + return namedItem; + } + + public ArrayList<ContentValues> getContentValues() { + final ArrayList<ContentValues> list = new ArrayList<>(mDataItems.size()); + for (NamedDataItem dataItem : mDataItems) { + if (Data.CONTENT_URI.equals(dataItem.mUri)) { + list.add(dataItem.mContentValues); + } + } + return list; + } + + public List<DataItem> getDataItems() { + final ArrayList<DataItem> list = new ArrayList<>(mDataItems.size()); + for (NamedDataItem dataItem : mDataItems) { + if (Data.CONTENT_URI.equals(dataItem.mUri)) { + list.add(DataItem.createFrom(dataItem.mContentValues)); + } + } + return list; + } + + public String toString() { + final StringBuilder sb = new StringBuilder(); + sb.append("RawContact: ").append(mValues); + for (RawContact.NamedDataItem namedDataItem : mDataItems) { + sb.append("\n ").append(namedDataItem.mUri); + sb.append("\n -> ").append(namedDataItem.mContentValues); + } + return sb.toString(); + } + + @Override + public int hashCode() { + return Objects.hash(mValues, mDataItems); + } + + @Override + public boolean equals(Object obj) { + if (obj == null) { + return false; + } + if (getClass() != obj.getClass()) { + return false; + } + + RawContact other = (RawContact) obj; + return Objects.equals(mValues, other.mValues) && Objects.equals(mDataItems, other.mDataItems); + } + + public static final class NamedDataItem implements Parcelable { + + public static final Parcelable.Creator<NamedDataItem> CREATOR = + new Parcelable.Creator<NamedDataItem>() { + + @Override + public NamedDataItem createFromParcel(Parcel parcel) { + return new NamedDataItem(parcel); + } + + @Override + public NamedDataItem[] newArray(int i) { + return new NamedDataItem[i]; + } + }; + public final Uri mUri; + // This use to be a DataItem. DataItem creation is now delayed until the point of request + // since there is no benefit to storing them here due to the multiple inheritance. + // Eventually instanceof still has to be used anyways to determine which sub-class of + // DataItem it is. And having parent DataItem's here makes it very difficult to serialize or + // parcelable. + // + // Instead of having a common DataItem super class, we should refactor this to be a generic + // Object where the object is a concrete class that no longer relies on ContentValues. + // (this will also make the classes easier to use). + // Since instanceof is used later anyways, having a list of Objects won't hurt and is no + // worse than having a DataItem. + public final ContentValues mContentValues; + + public NamedDataItem(Uri uri, ContentValues values) { + this.mUri = uri; + this.mContentValues = values; + } + + public NamedDataItem(Parcel parcel) { + this.mUri = parcel.readParcelable(Uri.class.getClassLoader()); + this.mContentValues = parcel.readParcelable(ContentValues.class.getClassLoader()); + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel parcel, int i) { + parcel.writeParcelable(mUri, i); + parcel.writeParcelable(mContentValues, i); + } + + @Override + public int hashCode() { + return Objects.hash(mUri, mContentValues); + } + + @Override + public boolean equals(Object obj) { + if (obj == null) { + return false; + } + if (getClass() != obj.getClass()) { + return false; + } + + final NamedDataItem other = (NamedDataItem) obj; + return Objects.equals(mUri, other.mUri) + && Objects.equals(mContentValues, other.mContentValues); + } + } +} diff --git a/java/com/android/contacts/common/model/account/AccountType.java b/java/com/android/contacts/common/model/account/AccountType.java new file mode 100644 index 000000000..1ae485a5f --- /dev/null +++ b/java/com/android/contacts/common/model/account/AccountType.java @@ -0,0 +1,501 @@ +/* + * Copyright (C) 2009 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.common.model.account; + +import android.content.ContentValues; +import android.content.Context; +import android.content.pm.PackageManager; +import android.graphics.drawable.Drawable; +import android.provider.ContactsContract.CommonDataKinds.Phone; +import android.provider.ContactsContract.CommonDataKinds.StructuredPostal; +import android.provider.ContactsContract.Contacts; +import android.provider.ContactsContract.RawContacts; +import android.support.annotation.VisibleForTesting; +import android.util.ArrayMap; +import android.view.inputmethod.EditorInfo; +import android.widget.EditText; +import com.android.contacts.common.R; +import com.android.contacts.common.model.dataitem.DataKind; +import java.text.Collator; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Map; + +/** + * Internal structure that represents constraints and styles for a specific data source, such as the + * various data types they support, including details on how those types should be rendered and + * edited. + * + * <p>In the future this may be inflated from XML defined by a data source. + */ +public abstract class AccountType { + + private static final String TAG = "AccountType"; + /** {@link Comparator} to sort by {@link DataKind#weight}. */ + private static Comparator<DataKind> sWeightComparator = + new Comparator<DataKind>() { + @Override + public int compare(DataKind object1, DataKind object2) { + return object1.weight - object2.weight; + } + }; + /** The {@link RawContacts#ACCOUNT_TYPE} these constraints apply to. */ + public String accountType = null; + /** The {@link RawContacts#DATA_SET} these constraints apply to. */ + public String dataSet = null; + /** + * Package that resources should be loaded from. Will be null for embedded types, in which case + * resources are stored in this package itself. + * + * <p>TODO Clean up {@link #resourcePackageName}, {@link #syncAdapterPackageName} and {@link + * #getViewContactNotifyServicePackageName()}. + * + * <p>There's the following invariants: - {@link #syncAdapterPackageName} is always set to the + * actual sync adapter package name. - {@link #resourcePackageName} too is set to the same value, + * unless {@link #isEmbedded()}, in which case it'll be null. There's an unfortunate exception of + * {@link FallbackAccountType}. Even though it {@link #isEmbedded()}, but we set non-null to + * {@link #resourcePackageName} for unit tests. + */ + public String resourcePackageName; + /** + * The package name for the authenticator (for the embedded types, i.e. Google and Exchange) or + * the sync adapter (for external type, including extensions). + */ + public String syncAdapterPackageName; + + public int titleRes; + public int iconRes; + protected boolean mIsInitialized; + /** Set of {@link DataKind} supported by this source. */ + private ArrayList<DataKind> mKinds = new ArrayList<>(); + /** Lookup map of {@link #mKinds} on {@link DataKind#mimeType}. */ + private Map<String, DataKind> mMimeKinds = new ArrayMap<>(); + + /** + * Return a string resource loaded from the given package (or the current package if {@code + * packageName} is null), unless {@code resId} is -1, in which case it returns {@code + * defaultValue}. + * + * <p>(The behavior is undefined if the resource or package doesn't exist.) + */ + @VisibleForTesting + static CharSequence getResourceText( + Context context, String packageName, int resId, String defaultValue) { + if (resId != -1 && packageName != null) { + final PackageManager pm = context.getPackageManager(); + return pm.getText(packageName, resId, null); + } else if (resId != -1) { + return context.getText(resId); + } else { + return defaultValue; + } + } + + public static Drawable getDisplayIcon( + Context context, int titleRes, int iconRes, String syncAdapterPackageName) { + if (titleRes != -1 && syncAdapterPackageName != null) { + final PackageManager pm = context.getPackageManager(); + return pm.getDrawable(syncAdapterPackageName, iconRes, null); + } else if (titleRes != -1) { + return context.getResources().getDrawable(iconRes); + } else { + return null; + } + } + + /** + * Whether this account type was able to be fully initialized. This may be false if (for example) + * the package name associated with the account type could not be found. + */ + public final boolean isInitialized() { + return mIsInitialized; + } + + /** + * @return Whether this type is an "embedded" type. i.e. any of {@link FallbackAccountType}, + * {@link GoogleAccountType} or {@link ExternalAccountType}. + * <p>If an embedded type cannot be initialized (i.e. if {@link #isInitialized()} returns + * {@code false}) it's considered critical, and the application will crash. On the other hand + * if it's not an embedded type, we just skip loading the type. + */ + public boolean isEmbedded() { + return true; + } + + public boolean isExtension() { + return false; + } + + /** + * @return True if contacts can be created and edited using this app. If false, there could still + * be an external editor as provided by {@link #getEditContactActivityClassName()} or {@link + * #getCreateContactActivityClassName()} + */ + public abstract boolean areContactsWritable(); + + /** + * Returns an optional custom edit activity. + * + * <p>Only makes sense for non-embedded account types. The activity class should reside in the + * sync adapter package as determined by {@link #syncAdapterPackageName}. + */ + public String getEditContactActivityClassName() { + return null; + } + + /** + * Returns an optional custom new contact activity. + * + * <p>Only makes sense for non-embedded account types. The activity class should reside in the + * sync adapter package as determined by {@link #syncAdapterPackageName}. + */ + public String getCreateContactActivityClassName() { + return null; + } + + /** + * Returns an optional custom invite contact activity. + * + * <p>Only makes sense for non-embedded account types. The activity class should reside in the + * sync adapter package as determined by {@link #syncAdapterPackageName}. + */ + public String getInviteContactActivityClassName() { + return null; + } + + /** + * Returns an optional service that can be launched whenever a contact is being looked at. This + * allows the sync adapter to provide more up-to-date information. + * + * <p>The service class should reside in the sync adapter package as determined by {@link + * #getViewContactNotifyServicePackageName()}. + */ + public String getViewContactNotifyServiceClassName() { + return null; + } + + /** + * TODO This is way too hacky should be removed. + * + * <p>This is introduced for {@link GoogleAccountType} where {@link #syncAdapterPackageName} is + * the authenticator package name but the notification service is in the sync adapter package. See + * {@link #resourcePackageName} -- we should clean up those. + */ + public String getViewContactNotifyServicePackageName() { + return syncAdapterPackageName; + } + + /** Returns an optional Activity string that can be used to view the group. */ + public String getViewGroupActivity() { + return null; + } + + public CharSequence getDisplayLabel(Context context) { + // Note this resource is defined in the sync adapter package, not resourcePackageName. + return getResourceText(context, syncAdapterPackageName, titleRes, accountType); + } + + /** @return resource ID for the "invite contact" action label, or -1 if not defined. */ + protected int getInviteContactActionResId() { + return -1; + } + + /** @return resource ID for the "view group" label, or -1 if not defined. */ + protected int getViewGroupLabelResId() { + return -1; + } + + /** Returns {@link AccountTypeWithDataSet} for this type. */ + public AccountTypeWithDataSet getAccountTypeAndDataSet() { + return AccountTypeWithDataSet.get(accountType, dataSet); + } + + /** + * Returns a list of additional package names that should be inspected as additional external + * account types. This allows for a primary account type to indicate other packages that may not + * be sync adapters but which still provide contact data, perhaps under a separate data set within + * the account. + */ + public List<String> getExtensionPackageNames() { + return new ArrayList<String>(); + } + + /** + * Returns an optional custom label for the "invite contact" action, which will be shown on the + * contact card. (If not defined, returns null.) + */ + public CharSequence getInviteContactActionLabel(Context context) { + // Note this resource is defined in the sync adapter package, not resourcePackageName. + return getResourceText(context, syncAdapterPackageName, getInviteContactActionResId(), ""); + } + + /** + * Returns a label for the "view group" action. If not defined, this falls back to our own "View + * Updates" string + */ + public CharSequence getViewGroupLabel(Context context) { + // Note this resource is defined in the sync adapter package, not resourcePackageName. + final CharSequence customTitle = + getResourceText(context, syncAdapterPackageName, getViewGroupLabelResId(), null); + + return customTitle == null ? context.getText(R.string.view_updates_from_group) : customTitle; + } + + public Drawable getDisplayIcon(Context context) { + return getDisplayIcon(context, titleRes, iconRes, syncAdapterPackageName); + } + + /** Whether or not groups created under this account type have editable membership lists. */ + public abstract boolean isGroupMembershipEditable(); + + /** Return list of {@link DataKind} supported, sorted by {@link DataKind#weight}. */ + public ArrayList<DataKind> getSortedDataKinds() { + // TODO: optimize by marking if already sorted + Collections.sort(mKinds, sWeightComparator); + return mKinds; + } + + /** Find the {@link DataKind} for a specific MIME-type, if it's handled by this data source. */ + public DataKind getKindForMimetype(String mimeType) { + return this.mMimeKinds.get(mimeType); + } + + /** Add given {@link DataKind} to list of those provided by this source. */ + public DataKind addKind(DataKind kind) throws DefinitionException { + if (kind.mimeType == null) { + throw new DefinitionException("null is not a valid mime type"); + } + if (mMimeKinds.get(kind.mimeType) != null) { + throw new DefinitionException("mime type '" + kind.mimeType + "' is already registered"); + } + + kind.resourcePackageName = this.resourcePackageName; + this.mKinds.add(kind); + this.mMimeKinds.put(kind.mimeType, kind); + return kind; + } + + /** + * Generic method of inflating a given {@link ContentValues} into a user-readable {@link + * CharSequence}. For example, an inflater could combine the multiple columns of {@link + * StructuredPostal} together using a string resource before presenting to the user. + */ + public interface StringInflater { + + CharSequence inflateUsing(Context context, ContentValues values); + } + + protected static class DefinitionException extends Exception { + + public DefinitionException(String message) { + super(message); + } + + public DefinitionException(String message, Exception inner) { + super(message, inner); + } + } + + /** + * Description of a specific "type" or "label" of a {@link DataKind} row, such as {@link + * Phone#TYPE_WORK}. Includes constraints on total number of rows a {@link Contacts} may have of + * this type, and details on how user-defined labels are stored. + */ + public static class EditType { + + public int rawValue; + public int labelRes; + public boolean secondary; + /** + * The number of entries allowed for the type. -1 if not specified. + * + * @see DataKind#typeOverallMax + */ + public int specificMax; + + public String customColumn; + + public EditType(int rawValue, int labelRes) { + this.rawValue = rawValue; + this.labelRes = labelRes; + this.specificMax = -1; + } + + public EditType setSecondary(boolean secondary) { + this.secondary = secondary; + return this; + } + + public EditType setSpecificMax(int specificMax) { + this.specificMax = specificMax; + return this; + } + + public EditType setCustomColumn(String customColumn) { + this.customColumn = customColumn; + return this; + } + + @Override + public boolean equals(Object object) { + if (object instanceof EditType) { + final EditType other = (EditType) object; + return other.rawValue == rawValue; + } + return false; + } + + @Override + public int hashCode() { + return rawValue; + } + + @Override + public String toString() { + return this.getClass().getSimpleName() + + " rawValue=" + + rawValue + + " labelRes=" + + labelRes + + " secondary=" + + secondary + + " specificMax=" + + specificMax + + " customColumn=" + + customColumn; + } + } + + public static class EventEditType extends EditType { + + private boolean mYearOptional; + + public EventEditType(int rawValue, int labelRes) { + super(rawValue, labelRes); + } + + public boolean isYearOptional() { + return mYearOptional; + } + + public EventEditType setYearOptional(boolean yearOptional) { + mYearOptional = yearOptional; + return this; + } + + @Override + public String toString() { + return super.toString() + " mYearOptional=" + mYearOptional; + } + } + + /** + * Description of a user-editable field on a {@link DataKind} row, such as {@link Phone#NUMBER}. + * Includes flags to apply to an {@link EditText}, and the column where this field is stored. + */ + public static final class EditField { + + public String column; + public int titleRes; + public int inputType; + public int minLines; + public boolean optional; + public boolean shortForm; + public boolean longForm; + + public EditField(String column, int titleRes) { + this.column = column; + this.titleRes = titleRes; + } + + public EditField(String column, int titleRes, int inputType) { + this(column, titleRes); + this.inputType = inputType; + } + + public EditField setOptional(boolean optional) { + this.optional = optional; + return this; + } + + public EditField setShortForm(boolean shortForm) { + this.shortForm = shortForm; + return this; + } + + public EditField setLongForm(boolean longForm) { + this.longForm = longForm; + return this; + } + + public EditField setMinLines(int minLines) { + this.minLines = minLines; + return this; + } + + public boolean isMultiLine() { + return (inputType & EditorInfo.TYPE_TEXT_FLAG_MULTI_LINE) != 0; + } + + @Override + public String toString() { + return this.getClass().getSimpleName() + + ":" + + " column=" + + column + + " titleRes=" + + titleRes + + " inputType=" + + inputType + + " minLines=" + + minLines + + " optional=" + + optional + + " shortForm=" + + shortForm + + " longForm=" + + longForm; + } + } + + /** + * Compare two {@link AccountType} by their {@link AccountType#getDisplayLabel} with the current + * locale. + */ + public static class DisplayLabelComparator implements Comparator<AccountType> { + + private final Context mContext; + /** {@link Comparator} for the current locale. */ + private final Collator mCollator = Collator.getInstance(); + + public DisplayLabelComparator(Context context) { + mContext = context; + } + + private String getDisplayLabel(AccountType type) { + CharSequence label = type.getDisplayLabel(mContext); + return (label == null) ? "" : label.toString(); + } + + @Override + public int compare(AccountType lhs, AccountType rhs) { + return mCollator.compare(getDisplayLabel(lhs), getDisplayLabel(rhs)); + } + } +} diff --git a/java/com/android/contacts/common/model/account/AccountTypeWithDataSet.java b/java/com/android/contacts/common/model/account/AccountTypeWithDataSet.java new file mode 100644 index 000000000..a32ebe139 --- /dev/null +++ b/java/com/android/contacts/common/model/account/AccountTypeWithDataSet.java @@ -0,0 +1,103 @@ +/* + * Copyright (C) 2011 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.common.model.account; + +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; +import android.provider.BaseColumns; +import android.provider.ContactsContract; +import android.provider.ContactsContract.RawContacts; +import android.text.TextUtils; +import java.util.Objects; + +/** Encapsulates an "account type" string and a "data set" string. */ +public class AccountTypeWithDataSet { + + private static final String[] ID_PROJECTION = new String[] {BaseColumns._ID}; + private static final Uri RAW_CONTACTS_URI_LIMIT_1 = + RawContacts.CONTENT_URI + .buildUpon() + .appendQueryParameter(ContactsContract.LIMIT_PARAM_KEY, "1") + .build(); + + /** account type. Can be null for fallback type. */ + public final String accountType; + + /** dataSet may be null, but never be "". */ + public final String dataSet; + + private AccountTypeWithDataSet(String accountType, String dataSet) { + this.accountType = TextUtils.isEmpty(accountType) ? null : accountType; + this.dataSet = TextUtils.isEmpty(dataSet) ? null : dataSet; + } + + public static AccountTypeWithDataSet get(String accountType, String dataSet) { + return new AccountTypeWithDataSet(accountType, dataSet); + } + + /** + * Return true if there are any contacts in the database with this account type and data set. + * Touches DB. Don't use in the UI thread. + */ + public boolean hasData(Context context) { + final String BASE_SELECTION = RawContacts.ACCOUNT_TYPE + " = ?"; + final String selection; + final String[] args; + if (TextUtils.isEmpty(dataSet)) { + selection = BASE_SELECTION + " AND " + RawContacts.DATA_SET + " IS NULL"; + args = new String[] {accountType}; + } else { + selection = BASE_SELECTION + " AND " + RawContacts.DATA_SET + " = ?"; + args = new String[] {accountType, dataSet}; + } + + final Cursor c = + context + .getContentResolver() + .query(RAW_CONTACTS_URI_LIMIT_1, ID_PROJECTION, selection, args, null); + if (c == null) { + return false; + } + try { + return c.moveToFirst(); + } finally { + c.close(); + } + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof AccountTypeWithDataSet)) { + return false; + } + + AccountTypeWithDataSet other = (AccountTypeWithDataSet) o; + return Objects.equals(accountType, other.accountType) && Objects.equals(dataSet, other.dataSet); + } + + @Override + public int hashCode() { + return (accountType == null ? 0 : accountType.hashCode()) + ^ (dataSet == null ? 0 : dataSet.hashCode()); + } + + @Override + public String toString() { + return "[" + accountType + "/" + dataSet + "]"; + } +} diff --git a/java/com/android/contacts/common/model/account/AccountWithDataSet.java b/java/com/android/contacts/common/model/account/AccountWithDataSet.java new file mode 100644 index 000000000..71faf509c --- /dev/null +++ b/java/com/android/contacts/common/model/account/AccountWithDataSet.java @@ -0,0 +1,229 @@ +/* + * Copyright (C) 2011 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.common.model.account; + +import android.accounts.Account; +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; +import android.os.Parcel; +import android.os.Parcelable; +import android.provider.BaseColumns; +import android.provider.ContactsContract; +import android.provider.ContactsContract.RawContacts; +import android.text.TextUtils; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.regex.Pattern; + +/** Wrapper for an account that includes a data set (which may be null). */ +public class AccountWithDataSet implements Parcelable { + + // For Parcelable + public static final Creator<AccountWithDataSet> CREATOR = + new Creator<AccountWithDataSet>() { + public AccountWithDataSet createFromParcel(Parcel source) { + return new AccountWithDataSet(source); + } + + public AccountWithDataSet[] newArray(int size) { + return new AccountWithDataSet[size]; + } + }; + private static final String STRINGIFY_SEPARATOR = "\u0001"; + private static final String ARRAY_STRINGIFY_SEPARATOR = "\u0002"; + private static final Pattern STRINGIFY_SEPARATOR_PAT = + Pattern.compile(Pattern.quote(STRINGIFY_SEPARATOR)); + private static final Pattern ARRAY_STRINGIFY_SEPARATOR_PAT = + Pattern.compile(Pattern.quote(ARRAY_STRINGIFY_SEPARATOR)); + private static final String[] ID_PROJECTION = new String[] {BaseColumns._ID}; + private static final Uri RAW_CONTACTS_URI_LIMIT_1 = + RawContacts.CONTENT_URI + .buildUpon() + .appendQueryParameter(ContactsContract.LIMIT_PARAM_KEY, "1") + .build(); + public final String name; + public final String type; + public final String dataSet; + private final AccountTypeWithDataSet mAccountTypeWithDataSet; + + public AccountWithDataSet(String name, String type, String dataSet) { + this.name = emptyToNull(name); + this.type = emptyToNull(type); + this.dataSet = emptyToNull(dataSet); + mAccountTypeWithDataSet = AccountTypeWithDataSet.get(type, dataSet); + } + + public AccountWithDataSet(Parcel in) { + this.name = in.readString(); + this.type = in.readString(); + this.dataSet = in.readString(); + mAccountTypeWithDataSet = AccountTypeWithDataSet.get(type, dataSet); + } + + private static String emptyToNull(String text) { + return TextUtils.isEmpty(text) ? null : text; + } + + private static StringBuilder addStringified(StringBuilder sb, AccountWithDataSet account) { + if (!TextUtils.isEmpty(account.name)) { + sb.append(account.name); + } + sb.append(STRINGIFY_SEPARATOR); + if (!TextUtils.isEmpty(account.type)) { + sb.append(account.type); + } + sb.append(STRINGIFY_SEPARATOR); + if (!TextUtils.isEmpty(account.dataSet)) { + sb.append(account.dataSet); + } + + return sb; + } + + /** + * Unpack a string created by {@link #stringify}. + * + * @throws IllegalArgumentException if it's an invalid string. + */ + public static AccountWithDataSet unstringify(String s) { + final String[] array = STRINGIFY_SEPARATOR_PAT.split(s, 3); + if (array.length < 3) { + throw new IllegalArgumentException("Invalid string " + s); + } + return new AccountWithDataSet( + array[0], array[1], TextUtils.isEmpty(array[2]) ? null : array[2]); + } + + /** Pack a list of {@link AccountWithDataSet} into a string. */ + public static String stringifyList(List<AccountWithDataSet> accounts) { + final StringBuilder sb = new StringBuilder(); + + for (AccountWithDataSet account : accounts) { + if (sb.length() > 0) { + sb.append(ARRAY_STRINGIFY_SEPARATOR); + } + addStringified(sb, account); + } + + return sb.toString(); + } + + /** + * Unpack a list of {@link AccountWithDataSet} into a string. + * + * @throws IllegalArgumentException if it's an invalid string. + */ + public static List<AccountWithDataSet> unstringifyList(String s) { + final ArrayList<AccountWithDataSet> ret = new ArrayList<>(); + if (TextUtils.isEmpty(s)) { + return ret; + } + + final String[] array = ARRAY_STRINGIFY_SEPARATOR_PAT.split(s); + + for (int i = 0; i < array.length; i++) { + ret.add(unstringify(array[i])); + } + + return ret; + } + + public boolean isLocalAccount() { + return name == null && type == null; + } + + public Account getAccountOrNull() { + if (name != null && type != null) { + return new Account(name, type); + } + return null; + } + + public int describeContents() { + return 0; + } + + public void writeToParcel(Parcel dest, int flags) { + dest.writeString(name); + dest.writeString(type); + dest.writeString(dataSet); + } + + public AccountTypeWithDataSet getAccountTypeWithDataSet() { + return mAccountTypeWithDataSet; + } + + /** + * Return {@code true} if this account has any contacts in the database. Touches DB. Don't use in + * the UI thread. + */ + public boolean hasData(Context context) { + final String BASE_SELECTION = + RawContacts.ACCOUNT_TYPE + " = ?" + " AND " + RawContacts.ACCOUNT_NAME + " = ?"; + final String selection; + final String[] args; + if (TextUtils.isEmpty(dataSet)) { + selection = BASE_SELECTION + " AND " + RawContacts.DATA_SET + " IS NULL"; + args = new String[] {type, name}; + } else { + selection = BASE_SELECTION + " AND " + RawContacts.DATA_SET + " = ?"; + args = new String[] {type, name, dataSet}; + } + + final Cursor c = + context + .getContentResolver() + .query(RAW_CONTACTS_URI_LIMIT_1, ID_PROJECTION, selection, args, null); + if (c == null) { + return false; + } + try { + return c.moveToFirst(); + } finally { + c.close(); + } + } + + public boolean equals(Object obj) { + if (obj instanceof AccountWithDataSet) { + AccountWithDataSet other = (AccountWithDataSet) obj; + return Objects.equals(name, other.name) + && Objects.equals(type, other.type) + && Objects.equals(dataSet, other.dataSet); + } + return false; + } + + public int hashCode() { + int result = 17; + result = 31 * result + (name != null ? name.hashCode() : 0); + result = 31 * result + (type != null ? type.hashCode() : 0); + result = 31 * result + (dataSet != null ? dataSet.hashCode() : 0); + return result; + } + + public String toString() { + return "AccountWithDataSet {name=" + name + ", type=" + type + ", dataSet=" + dataSet + "}"; + } + + /** Pack the instance into a string. */ + public String stringify() { + return addStringified(new StringBuilder(), this).toString(); + } +} diff --git a/java/com/android/contacts/common/model/account/BaseAccountType.java b/java/com/android/contacts/common/model/account/BaseAccountType.java new file mode 100644 index 000000000..21b555917 --- /dev/null +++ b/java/com/android/contacts/common/model/account/BaseAccountType.java @@ -0,0 +1,1890 @@ +/* + * Copyright (C) 2009 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.common.model.account; + +import android.content.ContentValues; +import android.content.Context; +import android.content.res.Resources; +import android.provider.ContactsContract.CommonDataKinds.BaseTypes; +import android.provider.ContactsContract.CommonDataKinds.Email; +import android.provider.ContactsContract.CommonDataKinds.Event; +import android.provider.ContactsContract.CommonDataKinds.GroupMembership; +import android.provider.ContactsContract.CommonDataKinds.Im; +import android.provider.ContactsContract.CommonDataKinds.Nickname; +import android.provider.ContactsContract.CommonDataKinds.Note; +import android.provider.ContactsContract.CommonDataKinds.Organization; +import android.provider.ContactsContract.CommonDataKinds.Phone; +import android.provider.ContactsContract.CommonDataKinds.Photo; +import android.provider.ContactsContract.CommonDataKinds.Relation; +import android.provider.ContactsContract.CommonDataKinds.SipAddress; +import android.provider.ContactsContract.CommonDataKinds.StructuredName; +import android.provider.ContactsContract.CommonDataKinds.StructuredPostal; +import android.provider.ContactsContract.CommonDataKinds.Website; +import android.util.ArrayMap; +import android.util.AttributeSet; +import android.util.Log; +import android.view.inputmethod.EditorInfo; +import com.android.contacts.common.R; +import com.android.contacts.common.model.dataitem.DataKind; +import com.android.contacts.common.util.CommonDateUtils; +import com.android.contacts.common.util.ContactDisplayUtils; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import org.xmlpull.v1.XmlPullParser; +import org.xmlpull.v1.XmlPullParserException; + +public abstract class BaseAccountType extends AccountType { + + public static final StringInflater ORGANIZATION_BODY_INFLATER = + new StringInflater() { + @Override + public CharSequence inflateUsing(Context context, ContentValues values) { + final CharSequence companyValue = + values.containsKey(Organization.COMPANY) + ? values.getAsString(Organization.COMPANY) + : null; + final CharSequence titleValue = + values.containsKey(Organization.TITLE) + ? values.getAsString(Organization.TITLE) + : null; + + if (companyValue != null && titleValue != null) { + return companyValue + ": " + titleValue; + } else if (companyValue == null) { + return titleValue; + } else { + return companyValue; + } + } + }; + protected static final int FLAGS_PHONE = EditorInfo.TYPE_CLASS_PHONE; + protected static final int FLAGS_EMAIL = + EditorInfo.TYPE_CLASS_TEXT | EditorInfo.TYPE_TEXT_VARIATION_EMAIL_ADDRESS; + protected static final int FLAGS_PERSON_NAME = + EditorInfo.TYPE_CLASS_TEXT + | EditorInfo.TYPE_TEXT_FLAG_CAP_WORDS + | EditorInfo.TYPE_TEXT_VARIATION_PERSON_NAME; + protected static final int FLAGS_PHONETIC = + EditorInfo.TYPE_CLASS_TEXT | EditorInfo.TYPE_TEXT_VARIATION_PHONETIC; + protected static final int FLAGS_GENERIC_NAME = + EditorInfo.TYPE_CLASS_TEXT | EditorInfo.TYPE_TEXT_FLAG_CAP_WORDS; + protected static final int FLAGS_NOTE = + EditorInfo.TYPE_CLASS_TEXT + | EditorInfo.TYPE_TEXT_FLAG_CAP_SENTENCES + | EditorInfo.TYPE_TEXT_FLAG_MULTI_LINE; + protected static final int FLAGS_EVENT = EditorInfo.TYPE_CLASS_TEXT; + protected static final int FLAGS_WEBSITE = + EditorInfo.TYPE_CLASS_TEXT | EditorInfo.TYPE_TEXT_VARIATION_URI; + protected static final int FLAGS_POSTAL = + EditorInfo.TYPE_CLASS_TEXT + | EditorInfo.TYPE_TEXT_VARIATION_POSTAL_ADDRESS + | EditorInfo.TYPE_TEXT_FLAG_CAP_WORDS + | EditorInfo.TYPE_TEXT_FLAG_MULTI_LINE; + protected static final int FLAGS_SIP_ADDRESS = + EditorInfo.TYPE_CLASS_TEXT + | EditorInfo.TYPE_TEXT_VARIATION_EMAIL_ADDRESS; // since SIP addresses have the same + // basic format as email addresses + protected static final int FLAGS_RELATION = + EditorInfo.TYPE_CLASS_TEXT + | EditorInfo.TYPE_TEXT_FLAG_CAP_WORDS + | EditorInfo.TYPE_TEXT_VARIATION_PERSON_NAME; + + // Specify the maximum number of lines that can be used to display various field types. If no + // value is specified for a particular type, we use the default value from {@link DataKind}. + protected static final int MAX_LINES_FOR_POSTAL_ADDRESS = 10; + protected static final int MAX_LINES_FOR_GROUP = 10; + protected static final int MAX_LINES_FOR_NOTE = 100; + private static final String TAG = "BaseAccountType"; + + public BaseAccountType() { + this.accountType = null; + this.dataSet = null; + this.titleRes = R.string.account_phone; + this.iconRes = R.mipmap.ic_contacts_launcher; + } + + protected static EditType buildPhoneType(int type) { + return new EditType(type, Phone.getTypeLabelResource(type)); + } + + protected static EditType buildEmailType(int type) { + return new EditType(type, Email.getTypeLabelResource(type)); + } + + protected static EditType buildPostalType(int type) { + return new EditType(type, StructuredPostal.getTypeLabelResource(type)); + } + + protected static EditType buildImType(int type) { + return new EditType(type, Im.getProtocolLabelResource(type)); + } + + protected static EditType buildEventType(int type, boolean yearOptional) { + return new EventEditType(type, Event.getTypeResource(type)).setYearOptional(yearOptional); + } + + protected static EditType buildRelationType(int type) { + return new EditType(type, Relation.getTypeLabelResource(type)); + } + + // Utility methods to keep code shorter. + private static boolean getAttr(AttributeSet attrs, String attribute, boolean defaultValue) { + return attrs.getAttributeBooleanValue(null, attribute, defaultValue); + } + + private static int getAttr(AttributeSet attrs, String attribute, int defaultValue) { + return attrs.getAttributeIntValue(null, attribute, defaultValue); + } + + private static String getAttr(AttributeSet attrs, String attribute) { + return attrs.getAttributeValue(null, attribute); + } + + protected DataKind addDataKindStructuredName(Context context) throws DefinitionException { + DataKind kind = + addKind( + new DataKind( + StructuredName.CONTENT_ITEM_TYPE, R.string.nameLabelsGroup, Weight.NONE, true)); + kind.actionHeader = new SimpleInflater(R.string.nameLabelsGroup); + kind.actionBody = new SimpleInflater(Nickname.NAME); + kind.typeOverallMax = 1; + + kind.fieldList = new ArrayList<>(); + kind.fieldList.add( + new EditField(StructuredName.DISPLAY_NAME, R.string.full_name, FLAGS_PERSON_NAME)); + kind.fieldList.add( + new EditField(StructuredName.PREFIX, R.string.name_prefix, FLAGS_PERSON_NAME) + .setLongForm(true)); + kind.fieldList.add( + new EditField(StructuredName.FAMILY_NAME, R.string.name_family, FLAGS_PERSON_NAME) + .setLongForm(true)); + kind.fieldList.add( + new EditField(StructuredName.MIDDLE_NAME, R.string.name_middle, FLAGS_PERSON_NAME) + .setLongForm(true)); + kind.fieldList.add( + new EditField(StructuredName.GIVEN_NAME, R.string.name_given, FLAGS_PERSON_NAME) + .setLongForm(true)); + kind.fieldList.add( + new EditField(StructuredName.SUFFIX, R.string.name_suffix, FLAGS_PERSON_NAME) + .setLongForm(true)); + kind.fieldList.add( + new EditField( + StructuredName.PHONETIC_FAMILY_NAME, R.string.name_phonetic_family, FLAGS_PHONETIC)); + kind.fieldList.add( + new EditField( + StructuredName.PHONETIC_MIDDLE_NAME, R.string.name_phonetic_middle, FLAGS_PHONETIC)); + kind.fieldList.add( + new EditField( + StructuredName.PHONETIC_GIVEN_NAME, R.string.name_phonetic_given, FLAGS_PHONETIC)); + + return kind; + } + + protected DataKind addDataKindDisplayName(Context context) throws DefinitionException { + DataKind kind = + addKind( + new DataKind( + DataKind.PSEUDO_MIME_TYPE_DISPLAY_NAME, + R.string.nameLabelsGroup, + Weight.NONE, + true)); + kind.actionHeader = new SimpleInflater(R.string.nameLabelsGroup); + kind.actionBody = new SimpleInflater(Nickname.NAME); + kind.typeOverallMax = 1; + + kind.fieldList = new ArrayList<>(); + kind.fieldList.add( + new EditField(StructuredName.DISPLAY_NAME, R.string.full_name, FLAGS_PERSON_NAME) + .setShortForm(true)); + + boolean displayOrderPrimary = + context.getResources().getBoolean(R.bool.config_editor_field_order_primary); + + if (!displayOrderPrimary) { + kind.fieldList.add( + new EditField(StructuredName.PREFIX, R.string.name_prefix, FLAGS_PERSON_NAME) + .setLongForm(true)); + kind.fieldList.add( + new EditField(StructuredName.FAMILY_NAME, R.string.name_family, FLAGS_PERSON_NAME) + .setLongForm(true)); + kind.fieldList.add( + new EditField(StructuredName.MIDDLE_NAME, R.string.name_middle, FLAGS_PERSON_NAME) + .setLongForm(true)); + kind.fieldList.add( + new EditField(StructuredName.GIVEN_NAME, R.string.name_given, FLAGS_PERSON_NAME) + .setLongForm(true)); + kind.fieldList.add( + new EditField(StructuredName.SUFFIX, R.string.name_suffix, FLAGS_PERSON_NAME) + .setLongForm(true)); + } else { + kind.fieldList.add( + new EditField(StructuredName.PREFIX, R.string.name_prefix, FLAGS_PERSON_NAME) + .setLongForm(true)); + kind.fieldList.add( + new EditField(StructuredName.GIVEN_NAME, R.string.name_given, FLAGS_PERSON_NAME) + .setLongForm(true)); + kind.fieldList.add( + new EditField(StructuredName.MIDDLE_NAME, R.string.name_middle, FLAGS_PERSON_NAME) + .setLongForm(true)); + kind.fieldList.add( + new EditField(StructuredName.FAMILY_NAME, R.string.name_family, FLAGS_PERSON_NAME) + .setLongForm(true)); + kind.fieldList.add( + new EditField(StructuredName.SUFFIX, R.string.name_suffix, FLAGS_PERSON_NAME) + .setLongForm(true)); + } + + return kind; + } + + protected DataKind addDataKindPhoneticName(Context context) throws DefinitionException { + DataKind kind = + addKind( + new DataKind( + DataKind.PSEUDO_MIME_TYPE_PHONETIC_NAME, + R.string.name_phonetic, + Weight.NONE, + true)); + kind.actionHeader = new SimpleInflater(R.string.nameLabelsGroup); + kind.actionBody = new SimpleInflater(Nickname.NAME); + kind.typeOverallMax = 1; + + kind.fieldList = new ArrayList<>(); + kind.fieldList.add( + new EditField(DataKind.PSEUDO_COLUMN_PHONETIC_NAME, R.string.name_phonetic, FLAGS_PHONETIC) + .setShortForm(true)); + kind.fieldList.add( + new EditField( + StructuredName.PHONETIC_FAMILY_NAME, R.string.name_phonetic_family, FLAGS_PHONETIC) + .setLongForm(true)); + kind.fieldList.add( + new EditField( + StructuredName.PHONETIC_MIDDLE_NAME, R.string.name_phonetic_middle, FLAGS_PHONETIC) + .setLongForm(true)); + kind.fieldList.add( + new EditField( + StructuredName.PHONETIC_GIVEN_NAME, R.string.name_phonetic_given, FLAGS_PHONETIC) + .setLongForm(true)); + + return kind; + } + + protected DataKind addDataKindNickname(Context context) throws DefinitionException { + DataKind kind = + addKind( + new DataKind( + Nickname.CONTENT_ITEM_TYPE, R.string.nicknameLabelsGroup, Weight.NICKNAME, true)); + kind.typeOverallMax = 1; + kind.actionHeader = new SimpleInflater(R.string.nicknameLabelsGroup); + kind.actionBody = new SimpleInflater(Nickname.NAME); + kind.defaultValues = new ContentValues(); + kind.defaultValues.put(Nickname.TYPE, Nickname.TYPE_DEFAULT); + + kind.fieldList = new ArrayList<>(); + kind.fieldList.add( + new EditField(Nickname.NAME, R.string.nicknameLabelsGroup, FLAGS_PERSON_NAME)); + + return kind; + } + + protected DataKind addDataKindPhone(Context context) throws DefinitionException { + DataKind kind = + addKind( + new DataKind(Phone.CONTENT_ITEM_TYPE, R.string.phoneLabelsGroup, Weight.PHONE, true)); + kind.iconAltRes = R.drawable.ic_message_24dp; + kind.iconAltDescriptionRes = R.string.sms; + kind.actionHeader = new PhoneActionInflater(); + kind.actionAltHeader = new PhoneActionAltInflater(); + kind.actionBody = new SimpleInflater(Phone.NUMBER); + kind.typeColumn = Phone.TYPE; + kind.typeList = new ArrayList<>(); + kind.typeList.add(buildPhoneType(Phone.TYPE_MOBILE)); + kind.typeList.add(buildPhoneType(Phone.TYPE_HOME)); + kind.typeList.add(buildPhoneType(Phone.TYPE_WORK)); + kind.typeList.add(buildPhoneType(Phone.TYPE_FAX_WORK).setSecondary(true)); + kind.typeList.add(buildPhoneType(Phone.TYPE_FAX_HOME).setSecondary(true)); + kind.typeList.add(buildPhoneType(Phone.TYPE_PAGER).setSecondary(true)); + kind.typeList.add(buildPhoneType(Phone.TYPE_OTHER)); + kind.typeList.add( + buildPhoneType(Phone.TYPE_CUSTOM).setSecondary(true).setCustomColumn(Phone.LABEL)); + kind.typeList.add(buildPhoneType(Phone.TYPE_CALLBACK).setSecondary(true)); + kind.typeList.add(buildPhoneType(Phone.TYPE_CAR).setSecondary(true)); + kind.typeList.add(buildPhoneType(Phone.TYPE_COMPANY_MAIN).setSecondary(true)); + kind.typeList.add(buildPhoneType(Phone.TYPE_ISDN).setSecondary(true)); + kind.typeList.add(buildPhoneType(Phone.TYPE_MAIN).setSecondary(true)); + kind.typeList.add(buildPhoneType(Phone.TYPE_OTHER_FAX).setSecondary(true)); + kind.typeList.add(buildPhoneType(Phone.TYPE_RADIO).setSecondary(true)); + kind.typeList.add(buildPhoneType(Phone.TYPE_TELEX).setSecondary(true)); + kind.typeList.add(buildPhoneType(Phone.TYPE_TTY_TDD).setSecondary(true)); + kind.typeList.add(buildPhoneType(Phone.TYPE_WORK_MOBILE).setSecondary(true)); + kind.typeList.add(buildPhoneType(Phone.TYPE_WORK_PAGER).setSecondary(true)); + kind.typeList.add(buildPhoneType(Phone.TYPE_ASSISTANT).setSecondary(true)); + kind.typeList.add(buildPhoneType(Phone.TYPE_MMS).setSecondary(true)); + + kind.fieldList = new ArrayList<>(); + kind.fieldList.add(new EditField(Phone.NUMBER, R.string.phoneLabelsGroup, FLAGS_PHONE)); + + return kind; + } + + protected DataKind addDataKindEmail(Context context) throws DefinitionException { + DataKind kind = + addKind( + new DataKind(Email.CONTENT_ITEM_TYPE, R.string.emailLabelsGroup, Weight.EMAIL, true)); + kind.actionHeader = new EmailActionInflater(); + kind.actionBody = new SimpleInflater(Email.DATA); + kind.typeColumn = Email.TYPE; + kind.typeList = new ArrayList<>(); + kind.typeList.add(buildEmailType(Email.TYPE_HOME)); + kind.typeList.add(buildEmailType(Email.TYPE_WORK)); + kind.typeList.add(buildEmailType(Email.TYPE_OTHER)); + kind.typeList.add(buildEmailType(Email.TYPE_MOBILE)); + kind.typeList.add( + buildEmailType(Email.TYPE_CUSTOM).setSecondary(true).setCustomColumn(Email.LABEL)); + + kind.fieldList = new ArrayList<>(); + kind.fieldList.add(new EditField(Email.DATA, R.string.emailLabelsGroup, FLAGS_EMAIL)); + + return kind; + } + + protected DataKind addDataKindStructuredPostal(Context context) throws DefinitionException { + DataKind kind = + addKind( + new DataKind( + StructuredPostal.CONTENT_ITEM_TYPE, + R.string.postalLabelsGroup, + Weight.STRUCTURED_POSTAL, + true)); + kind.actionHeader = new PostalActionInflater(); + kind.actionBody = new SimpleInflater(StructuredPostal.FORMATTED_ADDRESS); + kind.typeColumn = StructuredPostal.TYPE; + kind.typeList = new ArrayList<>(); + kind.typeList.add(buildPostalType(StructuredPostal.TYPE_HOME)); + kind.typeList.add(buildPostalType(StructuredPostal.TYPE_WORK)); + kind.typeList.add(buildPostalType(StructuredPostal.TYPE_OTHER)); + kind.typeList.add( + buildPostalType(StructuredPostal.TYPE_CUSTOM) + .setSecondary(true) + .setCustomColumn(StructuredPostal.LABEL)); + + kind.fieldList = new ArrayList<>(); + kind.fieldList.add( + new EditField(StructuredPostal.FORMATTED_ADDRESS, R.string.postal_address, FLAGS_POSTAL)); + + kind.maxLinesForDisplay = MAX_LINES_FOR_POSTAL_ADDRESS; + + return kind; + } + + protected DataKind addDataKindIm(Context context) throws DefinitionException { + DataKind kind = + addKind(new DataKind(Im.CONTENT_ITEM_TYPE, R.string.imLabelsGroup, Weight.IM, true)); + kind.actionHeader = new ImActionInflater(); + kind.actionBody = new SimpleInflater(Im.DATA); + + // NOTE: even though a traditional "type" exists, for editing + // purposes we're using the protocol to pick labels + + kind.defaultValues = new ContentValues(); + kind.defaultValues.put(Im.TYPE, Im.TYPE_OTHER); + + kind.typeColumn = Im.PROTOCOL; + kind.typeList = new ArrayList<>(); + kind.typeList.add(buildImType(Im.PROTOCOL_AIM)); + kind.typeList.add(buildImType(Im.PROTOCOL_MSN)); + kind.typeList.add(buildImType(Im.PROTOCOL_YAHOO)); + kind.typeList.add(buildImType(Im.PROTOCOL_SKYPE)); + kind.typeList.add(buildImType(Im.PROTOCOL_QQ)); + kind.typeList.add(buildImType(Im.PROTOCOL_GOOGLE_TALK)); + kind.typeList.add(buildImType(Im.PROTOCOL_ICQ)); + kind.typeList.add(buildImType(Im.PROTOCOL_JABBER)); + kind.typeList.add( + buildImType(Im.PROTOCOL_CUSTOM).setSecondary(true).setCustomColumn(Im.CUSTOM_PROTOCOL)); + + kind.fieldList = new ArrayList<>(); + kind.fieldList.add(new EditField(Im.DATA, R.string.imLabelsGroup, FLAGS_EMAIL)); + + return kind; + } + + protected DataKind addDataKindOrganization(Context context) throws DefinitionException { + DataKind kind = + addKind( + new DataKind( + Organization.CONTENT_ITEM_TYPE, + R.string.organizationLabelsGroup, + Weight.ORGANIZATION, + true)); + kind.actionHeader = new SimpleInflater(R.string.organizationLabelsGroup); + kind.actionBody = ORGANIZATION_BODY_INFLATER; + kind.typeOverallMax = 1; + + kind.fieldList = new ArrayList<>(); + kind.fieldList.add( + new EditField(Organization.COMPANY, R.string.ghostData_company, FLAGS_GENERIC_NAME)); + kind.fieldList.add( + new EditField(Organization.TITLE, R.string.ghostData_title, FLAGS_GENERIC_NAME)); + + return kind; + } + + protected DataKind addDataKindPhoto(Context context) throws DefinitionException { + DataKind kind = addKind(new DataKind(Photo.CONTENT_ITEM_TYPE, -1, Weight.NONE, true)); + kind.typeOverallMax = 1; + kind.fieldList = new ArrayList<>(); + kind.fieldList.add(new EditField(Photo.PHOTO, -1, -1)); + return kind; + } + + protected DataKind addDataKindNote(Context context) throws DefinitionException { + DataKind kind = + addKind(new DataKind(Note.CONTENT_ITEM_TYPE, R.string.label_notes, Weight.NOTE, true)); + kind.typeOverallMax = 1; + kind.actionHeader = new SimpleInflater(R.string.label_notes); + kind.actionBody = new SimpleInflater(Note.NOTE); + kind.fieldList = new ArrayList<>(); + kind.fieldList.add(new EditField(Note.NOTE, R.string.label_notes, FLAGS_NOTE)); + + kind.maxLinesForDisplay = MAX_LINES_FOR_NOTE; + + return kind; + } + + protected DataKind addDataKindWebsite(Context context) throws DefinitionException { + DataKind kind = + addKind( + new DataKind( + Website.CONTENT_ITEM_TYPE, R.string.websiteLabelsGroup, Weight.WEBSITE, true)); + kind.actionHeader = new SimpleInflater(R.string.websiteLabelsGroup); + kind.actionBody = new SimpleInflater(Website.URL); + kind.defaultValues = new ContentValues(); + kind.defaultValues.put(Website.TYPE, Website.TYPE_OTHER); + + kind.fieldList = new ArrayList<>(); + kind.fieldList.add(new EditField(Website.URL, R.string.websiteLabelsGroup, FLAGS_WEBSITE)); + + return kind; + } + + protected DataKind addDataKindSipAddress(Context context) throws DefinitionException { + DataKind kind = + addKind( + new DataKind( + SipAddress.CONTENT_ITEM_TYPE, + R.string.label_sip_address, + Weight.SIP_ADDRESS, + true)); + + kind.actionHeader = new SimpleInflater(R.string.label_sip_address); + kind.actionBody = new SimpleInflater(SipAddress.SIP_ADDRESS); + kind.fieldList = new ArrayList<>(); + kind.fieldList.add( + new EditField(SipAddress.SIP_ADDRESS, R.string.label_sip_address, FLAGS_SIP_ADDRESS)); + kind.typeOverallMax = 1; + + return kind; + } + + protected DataKind addDataKindGroupMembership(Context context) throws DefinitionException { + DataKind kind = + addKind( + new DataKind( + GroupMembership.CONTENT_ITEM_TYPE, + R.string.groupsLabel, + Weight.GROUP_MEMBERSHIP, + true)); + + kind.typeOverallMax = 1; + kind.fieldList = new ArrayList<>(); + kind.fieldList.add(new EditField(GroupMembership.GROUP_ROW_ID, -1, -1)); + + kind.maxLinesForDisplay = MAX_LINES_FOR_GROUP; + + return kind; + } + + @Override + public boolean isGroupMembershipEditable() { + return false; + } + + /** Parses the content of the EditSchema tag in contacts.xml. */ + protected final void parseEditSchema(Context context, XmlPullParser parser, AttributeSet attrs) + throws XmlPullParserException, IOException, DefinitionException { + + final int outerDepth = parser.getDepth(); + int type; + while ((type = parser.next()) != XmlPullParser.END_DOCUMENT + && (type != XmlPullParser.END_TAG || parser.getDepth() > outerDepth)) { + final int depth = parser.getDepth(); + if (type != XmlPullParser.START_TAG || depth != outerDepth + 1) { + continue; // Not direct child tag + } + + final String tag = parser.getName(); + + if (Tag.DATA_KIND.equals(tag)) { + for (DataKind kind : KindParser.INSTANCE.parseDataKindTag(context, parser, attrs)) { + addKind(kind); + } + } else { + Log.w(TAG, "Skipping unknown tag " + tag); + } + } + } + + private interface Tag { + + String DATA_KIND = "DataKind"; + String TYPE = "Type"; + } + + private interface Attr { + + String MAX_OCCURRENCE = "maxOccurs"; + String DATE_WITH_TIME = "dateWithTime"; + String YEAR_OPTIONAL = "yearOptional"; + String KIND = "kind"; + String TYPE = "type"; + } + + protected interface Weight { + + int NONE = -1; + int PHONE = 10; + int EMAIL = 15; + int STRUCTURED_POSTAL = 25; + int NICKNAME = 111; + int EVENT = 120; + int ORGANIZATION = 125; + int NOTE = 130; + int IM = 140; + int SIP_ADDRESS = 145; + int GROUP_MEMBERSHIP = 150; + int WEBSITE = 160; + int RELATIONSHIP = 999; + } + + /** + * Simple inflater that assumes a string resource has a "%s" that will be filled from the given + * column. + */ + public static class SimpleInflater implements StringInflater { + + private final int mStringRes; + private final String mColumnName; + + public SimpleInflater(int stringRes) { + this(stringRes, null); + } + + public SimpleInflater(String columnName) { + this(-1, columnName); + } + + public SimpleInflater(int stringRes, String columnName) { + mStringRes = stringRes; + mColumnName = columnName; + } + + @Override + public CharSequence inflateUsing(Context context, ContentValues values) { + final boolean validColumn = values.containsKey(mColumnName); + final boolean validString = mStringRes > 0; + + final CharSequence stringValue = validString ? context.getText(mStringRes) : null; + final CharSequence columnValue = validColumn ? values.getAsString(mColumnName) : null; + + if (validString && validColumn) { + return String.format(stringValue.toString(), columnValue); + } else if (validString) { + return stringValue; + } else if (validColumn) { + return columnValue; + } else { + return null; + } + } + + @Override + public String toString() { + return this.getClass().getSimpleName() + + " mStringRes=" + + mStringRes + + " mColumnName" + + mColumnName; + } + + public String getColumnNameForTest() { + return mColumnName; + } + } + + public abstract static class CommonInflater implements StringInflater { + + protected abstract int getTypeLabelResource(Integer type); + + protected boolean isCustom(Integer type) { + return type == BaseTypes.TYPE_CUSTOM; + } + + protected String getTypeColumn() { + return Phone.TYPE; + } + + protected String getLabelColumn() { + return Phone.LABEL; + } + + protected CharSequence getTypeLabel(Resources res, Integer type, CharSequence label) { + final int labelRes = getTypeLabelResource(type); + if (type == null) { + return res.getText(labelRes); + } else if (isCustom(type)) { + return res.getString(labelRes, label == null ? "" : label); + } else { + return res.getText(labelRes); + } + } + + @Override + public CharSequence inflateUsing(Context context, ContentValues values) { + final Integer type = values.getAsInteger(getTypeColumn()); + final String label = values.getAsString(getLabelColumn()); + return getTypeLabel(context.getResources(), type, label); + } + + @Override + public String toString() { + return this.getClass().getSimpleName(); + } + } + + public static class PhoneActionInflater extends CommonInflater { + + @Override + protected boolean isCustom(Integer type) { + return ContactDisplayUtils.isCustomPhoneType(type); + } + + @Override + protected int getTypeLabelResource(Integer type) { + return ContactDisplayUtils.getPhoneLabelResourceId(type); + } + } + + public static class PhoneActionAltInflater extends CommonInflater { + + @Override + protected boolean isCustom(Integer type) { + return ContactDisplayUtils.isCustomPhoneType(type); + } + + @Override + protected int getTypeLabelResource(Integer type) { + return ContactDisplayUtils.getSmsLabelResourceId(type); + } + } + + public static class EmailActionInflater extends CommonInflater { + + @Override + protected int getTypeLabelResource(Integer type) { + if (type == null) { + return R.string.email; + } + switch (type) { + case Email.TYPE_HOME: + return R.string.email_home; + case Email.TYPE_WORK: + return R.string.email_work; + case Email.TYPE_OTHER: + return R.string.email_other; + case Email.TYPE_MOBILE: + return R.string.email_mobile; + default: + return R.string.email_custom; + } + } + } + + public static class EventActionInflater extends CommonInflater { + + @Override + protected int getTypeLabelResource(Integer type) { + return Event.getTypeResource(type); + } + } + + public static class RelationActionInflater extends CommonInflater { + + @Override + protected int getTypeLabelResource(Integer type) { + return Relation.getTypeLabelResource(type == null ? Relation.TYPE_CUSTOM : type); + } + } + + public static class PostalActionInflater extends CommonInflater { + + @Override + protected int getTypeLabelResource(Integer type) { + if (type == null) { + return R.string.map_other; + } + switch (type) { + case StructuredPostal.TYPE_HOME: + return R.string.map_home; + case StructuredPostal.TYPE_WORK: + return R.string.map_work; + case StructuredPostal.TYPE_OTHER: + return R.string.map_other; + default: + return R.string.map_custom; + } + } + } + + public static class ImActionInflater extends CommonInflater { + + @Override + protected String getTypeColumn() { + return Im.PROTOCOL; + } + + @Override + protected String getLabelColumn() { + return Im.CUSTOM_PROTOCOL; + } + + @Override + protected int getTypeLabelResource(Integer type) { + if (type == null) { + return R.string.chat; + } + switch (type) { + case Im.PROTOCOL_AIM: + return R.string.chat_aim; + case Im.PROTOCOL_MSN: + return R.string.chat_msn; + case Im.PROTOCOL_YAHOO: + return R.string.chat_yahoo; + case Im.PROTOCOL_SKYPE: + return R.string.chat_skype; + case Im.PROTOCOL_QQ: + return R.string.chat_qq; + case Im.PROTOCOL_GOOGLE_TALK: + return R.string.chat_gtalk; + case Im.PROTOCOL_ICQ: + return R.string.chat_icq; + case Im.PROTOCOL_JABBER: + return R.string.chat_jabber; + case Im.PROTOCOL_NETMEETING: + return R.string.chat; + default: + return R.string.chat; + } + } + } + + // TODO Extract it to its own class, and move all KindBuilders to it as well. + private static class KindParser { + + public static final KindParser INSTANCE = new KindParser(); + + private final Map<String, KindBuilder> mBuilders = new ArrayMap<>(); + + private KindParser() { + addBuilder(new NameKindBuilder()); + addBuilder(new NicknameKindBuilder()); + addBuilder(new PhoneKindBuilder()); + addBuilder(new EmailKindBuilder()); + addBuilder(new StructuredPostalKindBuilder()); + addBuilder(new ImKindBuilder()); + addBuilder(new OrganizationKindBuilder()); + addBuilder(new PhotoKindBuilder()); + addBuilder(new NoteKindBuilder()); + addBuilder(new WebsiteKindBuilder()); + addBuilder(new SipAddressKindBuilder()); + addBuilder(new GroupMembershipKindBuilder()); + addBuilder(new EventKindBuilder()); + addBuilder(new RelationshipKindBuilder()); + } + + private void addBuilder(KindBuilder builder) { + mBuilders.put(builder.getTagName(), builder); + } + + /** + * Takes a {@link XmlPullParser} at the start of a DataKind tag, parses it and returns {@link + * DataKind}s. (Usually just one, but there are three for the "name" kind.) + * + * <p>This method returns a list, because we need to add 3 kinds for the name data kind. + * (structured, display and phonetic) + */ + public List<DataKind> parseDataKindTag( + Context context, XmlPullParser parser, AttributeSet attrs) + throws DefinitionException, XmlPullParserException, IOException { + final String kind = getAttr(attrs, Attr.KIND); + final KindBuilder builder = mBuilders.get(kind); + if (builder != null) { + return builder.parseDataKind(context, parser, attrs); + } else { + throw new DefinitionException("Undefined data kind '" + kind + "'"); + } + } + } + + private abstract static class KindBuilder { + + public abstract String getTagName(); + + /** DataKind tag parser specific to each kind. Subclasses must implement it. */ + public abstract List<DataKind> parseDataKind( + Context context, XmlPullParser parser, AttributeSet attrs) + throws DefinitionException, XmlPullParserException, IOException; + + /** Creates a new {@link DataKind}, and also parses the child Type tags in the DataKind tag. */ + protected final DataKind newDataKind( + Context context, + XmlPullParser parser, + AttributeSet attrs, + boolean isPseudo, + String mimeType, + String typeColumn, + int titleRes, + int weight, + StringInflater actionHeader, + StringInflater actionBody) + throws DefinitionException, XmlPullParserException, IOException { + + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "Adding DataKind: " + mimeType); + } + + final DataKind kind = new DataKind(mimeType, titleRes, weight, true); + kind.typeColumn = typeColumn; + kind.actionHeader = actionHeader; + kind.actionBody = actionBody; + kind.fieldList = new ArrayList<>(); + + // Get more information from the tag... + // A pseudo data kind doesn't have corresponding tag the XML, so we skip this. + if (!isPseudo) { + kind.typeOverallMax = getAttr(attrs, Attr.MAX_OCCURRENCE, -1); + + // Process "Type" tags. + // If a kind has the type column, contacts.xml must have at least one type + // definition. Otherwise, it mustn't have a type definition. + if (kind.typeColumn != null) { + // Parse and add types. + kind.typeList = new ArrayList<>(); + parseTypes(context, parser, attrs, kind, true); + if (kind.typeList.size() == 0) { + throw new DefinitionException("Kind " + kind.mimeType + " must have at least one type"); + } + } else { + // Make sure it has no types. + parseTypes(context, parser, attrs, kind, false /* can't have types */); + } + } + + return kind; + } + + /** + * Parses Type elements in a DataKind element, and if {@code canHaveTypes} is true adds them to + * the given {@link DataKind}. Otherwise the {@link DataKind} can't have a type, so throws + * {@link DefinitionException}. + */ + private void parseTypes( + Context context, + XmlPullParser parser, + AttributeSet attrs, + DataKind kind, + boolean canHaveTypes) + throws DefinitionException, XmlPullParserException, IOException { + final int outerDepth = parser.getDepth(); + int type; + while ((type = parser.next()) != XmlPullParser.END_DOCUMENT + && (type != XmlPullParser.END_TAG || parser.getDepth() > outerDepth)) { + final int depth = parser.getDepth(); + if (type != XmlPullParser.START_TAG || depth != outerDepth + 1) { + continue; // Not direct child tag + } + + final String tag = parser.getName(); + if (Tag.TYPE.equals(tag)) { + if (canHaveTypes) { + kind.typeList.add(parseTypeTag(parser, attrs, kind)); + } else { + throw new DefinitionException("Kind " + kind.mimeType + " can't have types"); + } + } else { + throw new DefinitionException("Unknown tag: " + tag); + } + } + } + + /** + * Parses a single Type element and returns an {@link EditType} built from it. Uses {@link + * #buildEditTypeForTypeTag} defined in subclasses to actually build an {@link EditType}. + */ + private EditType parseTypeTag(XmlPullParser parser, AttributeSet attrs, DataKind kind) + throws DefinitionException { + + final String typeName = getAttr(attrs, Attr.TYPE); + + final EditType et = buildEditTypeForTypeTag(attrs, typeName); + if (et == null) { + throw new DefinitionException( + "Undefined type '" + typeName + "' for data kind '" + kind.mimeType + "'"); + } + et.specificMax = getAttr(attrs, Attr.MAX_OCCURRENCE, -1); + + return et; + } + + /** + * Returns an {@link EditType} for the given "type". Subclasses may optionally use the + * attributes in the tag to set optional values. (e.g. "yearOptional" for the event kind) + */ + protected EditType buildEditTypeForTypeTag(AttributeSet attrs, String type) { + return null; + } + + protected final void throwIfList(DataKind kind) throws DefinitionException { + if (kind.typeOverallMax != 1) { + throw new DefinitionException("Kind " + kind.mimeType + " must have 'overallMax=\"1\"'"); + } + } + } + + /** DataKind parser for Name. (structured, display, phonetic) */ + private static class NameKindBuilder extends KindBuilder { + + private static void checkAttributeTrue(boolean value, String attrName) + throws DefinitionException { + if (!value) { + throw new DefinitionException(attrName + " must be true"); + } + } + + @Override + public String getTagName() { + return "name"; + } + + @Override + public List<DataKind> parseDataKind(Context context, XmlPullParser parser, AttributeSet attrs) + throws DefinitionException, XmlPullParserException, IOException { + + // Build 3 data kinds: + // - StructuredName.CONTENT_ITEM_TYPE + // - DataKind.PSEUDO_MIME_TYPE_DISPLAY_NAME + // - DataKind.PSEUDO_MIME_TYPE_PHONETIC_NAME + + final boolean displayOrderPrimary = + context.getResources().getBoolean(R.bool.config_editor_field_order_primary); + + final boolean supportsDisplayName = getAttr(attrs, "supportsDisplayName", false); + final boolean supportsPrefix = getAttr(attrs, "supportsPrefix", false); + final boolean supportsMiddleName = getAttr(attrs, "supportsMiddleName", false); + final boolean supportsSuffix = getAttr(attrs, "supportsSuffix", false); + final boolean supportsPhoneticFamilyName = + getAttr(attrs, "supportsPhoneticFamilyName", false); + final boolean supportsPhoneticMiddleName = + getAttr(attrs, "supportsPhoneticMiddleName", false); + final boolean supportsPhoneticGivenName = getAttr(attrs, "supportsPhoneticGivenName", false); + + // For now, every things must be supported. + checkAttributeTrue(supportsDisplayName, "supportsDisplayName"); + checkAttributeTrue(supportsPrefix, "supportsPrefix"); + checkAttributeTrue(supportsMiddleName, "supportsMiddleName"); + checkAttributeTrue(supportsSuffix, "supportsSuffix"); + checkAttributeTrue(supportsPhoneticFamilyName, "supportsPhoneticFamilyName"); + checkAttributeTrue(supportsPhoneticMiddleName, "supportsPhoneticMiddleName"); + checkAttributeTrue(supportsPhoneticGivenName, "supportsPhoneticGivenName"); + + final List<DataKind> kinds = new ArrayList<>(); + + // Structured name + final DataKind ks = + newDataKind( + context, + parser, + attrs, + false, + StructuredName.CONTENT_ITEM_TYPE, + null, + R.string.nameLabelsGroup, + Weight.NONE, + new SimpleInflater(R.string.nameLabelsGroup), + new SimpleInflater(Nickname.NAME)); + + throwIfList(ks); + kinds.add(ks); + + // Note about setLongForm/setShortForm below. + // We need to set this only when the type supports display name. (=supportsDisplayName) + // Otherwise (i.e. Exchange) we don't set these flags, but instead make some fields + // "optional". + + ks.fieldList.add( + new EditField(StructuredName.DISPLAY_NAME, R.string.full_name, FLAGS_PERSON_NAME)); + ks.fieldList.add( + new EditField(StructuredName.PREFIX, R.string.name_prefix, FLAGS_PERSON_NAME) + .setLongForm(true)); + ks.fieldList.add( + new EditField(StructuredName.FAMILY_NAME, R.string.name_family, FLAGS_PERSON_NAME) + .setLongForm(true)); + ks.fieldList.add( + new EditField(StructuredName.MIDDLE_NAME, R.string.name_middle, FLAGS_PERSON_NAME) + .setLongForm(true)); + ks.fieldList.add( + new EditField(StructuredName.GIVEN_NAME, R.string.name_given, FLAGS_PERSON_NAME) + .setLongForm(true)); + ks.fieldList.add( + new EditField(StructuredName.SUFFIX, R.string.name_suffix, FLAGS_PERSON_NAME) + .setLongForm(true)); + ks.fieldList.add( + new EditField( + StructuredName.PHONETIC_FAMILY_NAME, R.string.name_phonetic_family, FLAGS_PHONETIC)); + ks.fieldList.add( + new EditField( + StructuredName.PHONETIC_MIDDLE_NAME, R.string.name_phonetic_middle, FLAGS_PHONETIC)); + ks.fieldList.add( + new EditField( + StructuredName.PHONETIC_GIVEN_NAME, R.string.name_phonetic_given, FLAGS_PHONETIC)); + + // Display name + final DataKind kd = + newDataKind( + context, + parser, + attrs, + true, + DataKind.PSEUDO_MIME_TYPE_DISPLAY_NAME, + null, + R.string.nameLabelsGroup, + Weight.NONE, + new SimpleInflater(R.string.nameLabelsGroup), + new SimpleInflater(Nickname.NAME)); + kd.typeOverallMax = 1; + kinds.add(kd); + + kd.fieldList.add( + new EditField(StructuredName.DISPLAY_NAME, R.string.full_name, FLAGS_PERSON_NAME) + .setShortForm(true)); + + if (!displayOrderPrimary) { + kd.fieldList.add( + new EditField(StructuredName.PREFIX, R.string.name_prefix, FLAGS_PERSON_NAME) + .setLongForm(true)); + kd.fieldList.add( + new EditField(StructuredName.FAMILY_NAME, R.string.name_family, FLAGS_PERSON_NAME) + .setLongForm(true)); + kd.fieldList.add( + new EditField(StructuredName.MIDDLE_NAME, R.string.name_middle, FLAGS_PERSON_NAME) + .setLongForm(true)); + kd.fieldList.add( + new EditField(StructuredName.GIVEN_NAME, R.string.name_given, FLAGS_PERSON_NAME) + .setLongForm(true)); + kd.fieldList.add( + new EditField(StructuredName.SUFFIX, R.string.name_suffix, FLAGS_PERSON_NAME) + .setLongForm(true)); + } else { + kd.fieldList.add( + new EditField(StructuredName.PREFIX, R.string.name_prefix, FLAGS_PERSON_NAME) + .setLongForm(true)); + kd.fieldList.add( + new EditField(StructuredName.GIVEN_NAME, R.string.name_given, FLAGS_PERSON_NAME) + .setLongForm(true)); + kd.fieldList.add( + new EditField(StructuredName.MIDDLE_NAME, R.string.name_middle, FLAGS_PERSON_NAME) + .setLongForm(true)); + kd.fieldList.add( + new EditField(StructuredName.FAMILY_NAME, R.string.name_family, FLAGS_PERSON_NAME) + .setLongForm(true)); + kd.fieldList.add( + new EditField(StructuredName.SUFFIX, R.string.name_suffix, FLAGS_PERSON_NAME) + .setLongForm(true)); + } + + // Phonetic name + final DataKind kp = + newDataKind( + context, + parser, + attrs, + true, + DataKind.PSEUDO_MIME_TYPE_PHONETIC_NAME, + null, + R.string.name_phonetic, + Weight.NONE, + new SimpleInflater(R.string.nameLabelsGroup), + new SimpleInflater(Nickname.NAME)); + kp.typeOverallMax = 1; + kinds.add(kp); + + // We may want to change the order depending on displayOrderPrimary too. + kp.fieldList.add( + new EditField( + DataKind.PSEUDO_COLUMN_PHONETIC_NAME, R.string.name_phonetic, FLAGS_PHONETIC) + .setShortForm(true)); + kp.fieldList.add( + new EditField( + StructuredName.PHONETIC_FAMILY_NAME, + R.string.name_phonetic_family, + FLAGS_PHONETIC) + .setLongForm(true)); + kp.fieldList.add( + new EditField( + StructuredName.PHONETIC_MIDDLE_NAME, + R.string.name_phonetic_middle, + FLAGS_PHONETIC) + .setLongForm(true)); + kp.fieldList.add( + new EditField( + StructuredName.PHONETIC_GIVEN_NAME, R.string.name_phonetic_given, FLAGS_PHONETIC) + .setLongForm(true)); + return kinds; + } + } + + private static class NicknameKindBuilder extends KindBuilder { + + @Override + public String getTagName() { + return "nickname"; + } + + @Override + public List<DataKind> parseDataKind(Context context, XmlPullParser parser, AttributeSet attrs) + throws DefinitionException, XmlPullParserException, IOException { + final DataKind kind = + newDataKind( + context, + parser, + attrs, + false, + Nickname.CONTENT_ITEM_TYPE, + null, + R.string.nicknameLabelsGroup, + Weight.NICKNAME, + new SimpleInflater(R.string.nicknameLabelsGroup), + new SimpleInflater(Nickname.NAME)); + + kind.fieldList.add( + new EditField(Nickname.NAME, R.string.nicknameLabelsGroup, FLAGS_PERSON_NAME)); + + kind.defaultValues = new ContentValues(); + kind.defaultValues.put(Nickname.TYPE, Nickname.TYPE_DEFAULT); + + throwIfList(kind); + List<DataKind> result = new ArrayList<>(); + result.add(kind); + return result; + } + } + + private static class PhoneKindBuilder extends KindBuilder { + + /** Just to avoid line-wrapping... */ + protected static EditType build(int type, boolean secondary) { + return new EditType(type, Phone.getTypeLabelResource(type)).setSecondary(secondary); + } + + @Override + public String getTagName() { + return "phone"; + } + + @Override + public List<DataKind> parseDataKind(Context context, XmlPullParser parser, AttributeSet attrs) + throws DefinitionException, XmlPullParserException, IOException { + final DataKind kind = + newDataKind( + context, + parser, + attrs, + false, + Phone.CONTENT_ITEM_TYPE, + Phone.TYPE, + R.string.phoneLabelsGroup, + Weight.PHONE, + new PhoneActionInflater(), + new SimpleInflater(Phone.NUMBER)); + + kind.iconAltRes = R.drawable.ic_message_24dp; + kind.iconAltDescriptionRes = R.string.sms; + kind.actionAltHeader = new PhoneActionAltInflater(); + + kind.fieldList.add(new EditField(Phone.NUMBER, R.string.phoneLabelsGroup, FLAGS_PHONE)); + + List<DataKind> result = new ArrayList<>(); + result.add(kind); + return result; + } + + @Override + protected EditType buildEditTypeForTypeTag(AttributeSet attrs, String type) { + if ("home".equals(type)) { + return build(Phone.TYPE_HOME, false); + } + if ("mobile".equals(type)) { + return build(Phone.TYPE_MOBILE, false); + } + if ("work".equals(type)) { + return build(Phone.TYPE_WORK, false); + } + if ("fax_work".equals(type)) { + return build(Phone.TYPE_FAX_WORK, true); + } + if ("fax_home".equals(type)) { + return build(Phone.TYPE_FAX_HOME, true); + } + if ("pager".equals(type)) { + return build(Phone.TYPE_PAGER, true); + } + if ("other".equals(type)) { + return build(Phone.TYPE_OTHER, false); + } + if ("callback".equals(type)) { + return build(Phone.TYPE_CALLBACK, true); + } + if ("car".equals(type)) { + return build(Phone.TYPE_CAR, true); + } + if ("company_main".equals(type)) { + return build(Phone.TYPE_COMPANY_MAIN, true); + } + if ("isdn".equals(type)) { + return build(Phone.TYPE_ISDN, true); + } + if ("main".equals(type)) { + return build(Phone.TYPE_MAIN, true); + } + if ("other_fax".equals(type)) { + return build(Phone.TYPE_OTHER_FAX, true); + } + if ("radio".equals(type)) { + return build(Phone.TYPE_RADIO, true); + } + if ("telex".equals(type)) { + return build(Phone.TYPE_TELEX, true); + } + if ("tty_tdd".equals(type)) { + return build(Phone.TYPE_TTY_TDD, true); + } + if ("work_mobile".equals(type)) { + return build(Phone.TYPE_WORK_MOBILE, true); + } + if ("work_pager".equals(type)) { + return build(Phone.TYPE_WORK_PAGER, true); + } + + // Note "assistant" used to be a custom column for the fallback type, but not anymore. + if ("assistant".equals(type)) { + return build(Phone.TYPE_ASSISTANT, true); + } + if ("mms".equals(type)) { + return build(Phone.TYPE_MMS, true); + } + if ("custom".equals(type)) { + return build(Phone.TYPE_CUSTOM, true).setCustomColumn(Phone.LABEL); + } + return null; + } + } + + private static class EmailKindBuilder extends KindBuilder { + + @Override + public String getTagName() { + return "email"; + } + + @Override + public List<DataKind> parseDataKind(Context context, XmlPullParser parser, AttributeSet attrs) + throws DefinitionException, XmlPullParserException, IOException { + final DataKind kind = + newDataKind( + context, + parser, + attrs, + false, + Email.CONTENT_ITEM_TYPE, + Email.TYPE, + R.string.emailLabelsGroup, + Weight.EMAIL, + new EmailActionInflater(), + new SimpleInflater(Email.DATA)); + kind.fieldList.add(new EditField(Email.DATA, R.string.emailLabelsGroup, FLAGS_EMAIL)); + + List<DataKind> result = new ArrayList<>(); + result.add(kind); + return result; + } + + @Override + protected EditType buildEditTypeForTypeTag(AttributeSet attrs, String type) { + // EditType is mutable, so we need to create a new instance every time. + if ("home".equals(type)) { + return buildEmailType(Email.TYPE_HOME); + } + if ("work".equals(type)) { + return buildEmailType(Email.TYPE_WORK); + } + if ("other".equals(type)) { + return buildEmailType(Email.TYPE_OTHER); + } + if ("mobile".equals(type)) { + return buildEmailType(Email.TYPE_MOBILE); + } + if ("custom".equals(type)) { + return buildEmailType(Email.TYPE_CUSTOM).setSecondary(true).setCustomColumn(Email.LABEL); + } + return null; + } + } + + private static class StructuredPostalKindBuilder extends KindBuilder { + + @Override + public String getTagName() { + return "postal"; + } + + @Override + public List<DataKind> parseDataKind(Context context, XmlPullParser parser, AttributeSet attrs) + throws DefinitionException, XmlPullParserException, IOException { + final DataKind kind = + newDataKind( + context, + parser, + attrs, + false, + StructuredPostal.CONTENT_ITEM_TYPE, + StructuredPostal.TYPE, + R.string.postalLabelsGroup, + Weight.STRUCTURED_POSTAL, + new PostalActionInflater(), + new SimpleInflater(StructuredPostal.FORMATTED_ADDRESS)); + + if (getAttr(attrs, "needsStructured", false)) { + if (Locale.JAPANESE.getLanguage().equals(Locale.getDefault().getLanguage())) { + // Japanese order + kind.fieldList.add( + new EditField(StructuredPostal.COUNTRY, R.string.postal_country, FLAGS_POSTAL) + .setOptional(true)); + kind.fieldList.add( + new EditField(StructuredPostal.POSTCODE, R.string.postal_postcode, FLAGS_POSTAL)); + kind.fieldList.add( + new EditField(StructuredPostal.REGION, R.string.postal_region, FLAGS_POSTAL)); + kind.fieldList.add( + new EditField(StructuredPostal.CITY, R.string.postal_city, FLAGS_POSTAL)); + kind.fieldList.add( + new EditField(StructuredPostal.STREET, R.string.postal_street, FLAGS_POSTAL)); + } else { + // Generic order + kind.fieldList.add( + new EditField(StructuredPostal.STREET, R.string.postal_street, FLAGS_POSTAL)); + kind.fieldList.add( + new EditField(StructuredPostal.CITY, R.string.postal_city, FLAGS_POSTAL)); + kind.fieldList.add( + new EditField(StructuredPostal.REGION, R.string.postal_region, FLAGS_POSTAL)); + kind.fieldList.add( + new EditField(StructuredPostal.POSTCODE, R.string.postal_postcode, FLAGS_POSTAL)); + kind.fieldList.add( + new EditField(StructuredPostal.COUNTRY, R.string.postal_country, FLAGS_POSTAL) + .setOptional(true)); + } + } else { + kind.maxLinesForDisplay = MAX_LINES_FOR_POSTAL_ADDRESS; + kind.fieldList.add( + new EditField( + StructuredPostal.FORMATTED_ADDRESS, R.string.postal_address, FLAGS_POSTAL)); + } + + List<DataKind> result = new ArrayList<>(); + result.add(kind); + return result; + } + + @Override + protected EditType buildEditTypeForTypeTag(AttributeSet attrs, String type) { + // EditType is mutable, so we need to create a new instance every time. + if ("home".equals(type)) { + return buildPostalType(StructuredPostal.TYPE_HOME); + } + if ("work".equals(type)) { + return buildPostalType(StructuredPostal.TYPE_WORK); + } + if ("other".equals(type)) { + return buildPostalType(StructuredPostal.TYPE_OTHER); + } + if ("custom".equals(type)) { + return buildPostalType(StructuredPostal.TYPE_CUSTOM) + .setSecondary(true) + .setCustomColumn(Email.LABEL); + } + return null; + } + } + + private static class ImKindBuilder extends KindBuilder { + + @Override + public String getTagName() { + return "im"; + } + + @Override + public List<DataKind> parseDataKind(Context context, XmlPullParser parser, AttributeSet attrs) + throws DefinitionException, XmlPullParserException, IOException { + + // IM is special: + // - It uses "protocol" as the custom label field + // - Its TYPE is fixed to TYPE_OTHER + + final DataKind kind = + newDataKind( + context, + parser, + attrs, + false, + Im.CONTENT_ITEM_TYPE, + Im.PROTOCOL, + R.string.imLabelsGroup, + Weight.IM, + new ImActionInflater(), + new SimpleInflater(Im.DATA) // header / action + ); + kind.fieldList.add(new EditField(Im.DATA, R.string.imLabelsGroup, FLAGS_EMAIL)); + + kind.defaultValues = new ContentValues(); + kind.defaultValues.put(Im.TYPE, Im.TYPE_OTHER); + + List<DataKind> result = new ArrayList<>(); + result.add(kind); + return result; + } + + @Override + protected EditType buildEditTypeForTypeTag(AttributeSet attrs, String type) { + if ("aim".equals(type)) { + return buildImType(Im.PROTOCOL_AIM); + } + if ("msn".equals(type)) { + return buildImType(Im.PROTOCOL_MSN); + } + if ("yahoo".equals(type)) { + return buildImType(Im.PROTOCOL_YAHOO); + } + if ("skype".equals(type)) { + return buildImType(Im.PROTOCOL_SKYPE); + } + if ("qq".equals(type)) { + return buildImType(Im.PROTOCOL_QQ); + } + if ("google_talk".equals(type)) { + return buildImType(Im.PROTOCOL_GOOGLE_TALK); + } + if ("icq".equals(type)) { + return buildImType(Im.PROTOCOL_ICQ); + } + if ("jabber".equals(type)) { + return buildImType(Im.PROTOCOL_JABBER); + } + if ("custom".equals(type)) { + return buildImType(Im.PROTOCOL_CUSTOM) + .setSecondary(true) + .setCustomColumn(Im.CUSTOM_PROTOCOL); + } + return null; + } + } + + private static class OrganizationKindBuilder extends KindBuilder { + + @Override + public String getTagName() { + return "organization"; + } + + @Override + public List<DataKind> parseDataKind(Context context, XmlPullParser parser, AttributeSet attrs) + throws DefinitionException, XmlPullParserException, IOException { + final DataKind kind = + newDataKind( + context, + parser, + attrs, + false, + Organization.CONTENT_ITEM_TYPE, + null, + R.string.organizationLabelsGroup, + Weight.ORGANIZATION, + new SimpleInflater(R.string.organizationLabelsGroup), + ORGANIZATION_BODY_INFLATER); + + kind.fieldList.add( + new EditField(Organization.COMPANY, R.string.ghostData_company, FLAGS_GENERIC_NAME)); + kind.fieldList.add( + new EditField(Organization.TITLE, R.string.ghostData_title, FLAGS_GENERIC_NAME)); + + throwIfList(kind); + + List<DataKind> result = new ArrayList<>(); + result.add(kind); + return result; + } + } + + private static class PhotoKindBuilder extends KindBuilder { + + @Override + public String getTagName() { + return "photo"; + } + + @Override + public List<DataKind> parseDataKind(Context context, XmlPullParser parser, AttributeSet attrs) + throws DefinitionException, XmlPullParserException, IOException { + final DataKind kind = + newDataKind( + context, + parser, + attrs, + false, + Photo.CONTENT_ITEM_TYPE, + null /* no type */, + Weight.NONE, + -1, + null, + null // no header, no body + ); + + kind.fieldList.add(new EditField(Photo.PHOTO, -1, -1)); + + throwIfList(kind); + + List<DataKind> result = new ArrayList<>(); + result.add(kind); + return result; + } + } + + private static class NoteKindBuilder extends KindBuilder { + + @Override + public String getTagName() { + return "note"; + } + + @Override + public List<DataKind> parseDataKind(Context context, XmlPullParser parser, AttributeSet attrs) + throws DefinitionException, XmlPullParserException, IOException { + final DataKind kind = + newDataKind( + context, + parser, + attrs, + false, + Note.CONTENT_ITEM_TYPE, + null, + R.string.label_notes, + Weight.NOTE, + new SimpleInflater(R.string.label_notes), + new SimpleInflater(Note.NOTE)); + + kind.fieldList.add(new EditField(Note.NOTE, R.string.label_notes, FLAGS_NOTE)); + kind.maxLinesForDisplay = MAX_LINES_FOR_NOTE; + + throwIfList(kind); + + List<DataKind> result = new ArrayList<>(); + result.add(kind); + return result; + } + } + + private static class WebsiteKindBuilder extends KindBuilder { + + @Override + public String getTagName() { + return "website"; + } + + @Override + public List<DataKind> parseDataKind(Context context, XmlPullParser parser, AttributeSet attrs) + throws DefinitionException, XmlPullParserException, IOException { + final DataKind kind = + newDataKind( + context, + parser, + attrs, + false, + Website.CONTENT_ITEM_TYPE, + null, + R.string.websiteLabelsGroup, + Weight.WEBSITE, + new SimpleInflater(R.string.websiteLabelsGroup), + new SimpleInflater(Website.URL)); + + kind.fieldList.add(new EditField(Website.URL, R.string.websiteLabelsGroup, FLAGS_WEBSITE)); + + kind.defaultValues = new ContentValues(); + kind.defaultValues.put(Website.TYPE, Website.TYPE_OTHER); + + List<DataKind> result = new ArrayList<>(); + result.add(kind); + return result; + } + } + + private static class SipAddressKindBuilder extends KindBuilder { + + @Override + public String getTagName() { + return "sip_address"; + } + + @Override + public List<DataKind> parseDataKind(Context context, XmlPullParser parser, AttributeSet attrs) + throws DefinitionException, XmlPullParserException, IOException { + final DataKind kind = + newDataKind( + context, + parser, + attrs, + false, + SipAddress.CONTENT_ITEM_TYPE, + null, + R.string.label_sip_address, + Weight.SIP_ADDRESS, + new SimpleInflater(R.string.label_sip_address), + new SimpleInflater(SipAddress.SIP_ADDRESS)); + + kind.fieldList.add( + new EditField(SipAddress.SIP_ADDRESS, R.string.label_sip_address, FLAGS_SIP_ADDRESS)); + + throwIfList(kind); + + List<DataKind> result = new ArrayList<>(); + result.add(kind); + return result; + } + } + + private static class GroupMembershipKindBuilder extends KindBuilder { + + @Override + public String getTagName() { + return "group_membership"; + } + + @Override + public List<DataKind> parseDataKind(Context context, XmlPullParser parser, AttributeSet attrs) + throws DefinitionException, XmlPullParserException, IOException { + final DataKind kind = + newDataKind( + context, + parser, + attrs, + false, + GroupMembership.CONTENT_ITEM_TYPE, + null, + R.string.groupsLabel, + Weight.GROUP_MEMBERSHIP, + null, + null); + + kind.fieldList.add(new EditField(GroupMembership.GROUP_ROW_ID, -1, -1)); + kind.maxLinesForDisplay = MAX_LINES_FOR_GROUP; + + throwIfList(kind); + + List<DataKind> result = new ArrayList<>(); + result.add(kind); + return result; + } + } + + /** + * Event DataKind parser. + * + * <p>Event DataKind is used only for Google/Exchange types, so this parser is not used for now. + */ + private static class EventKindBuilder extends KindBuilder { + + @Override + public String getTagName() { + return "event"; + } + + @Override + public List<DataKind> parseDataKind(Context context, XmlPullParser parser, AttributeSet attrs) + throws DefinitionException, XmlPullParserException, IOException { + final DataKind kind = + newDataKind( + context, + parser, + attrs, + false, + Event.CONTENT_ITEM_TYPE, + Event.TYPE, + R.string.eventLabelsGroup, + Weight.EVENT, + new EventActionInflater(), + new SimpleInflater(Event.START_DATE)); + + kind.fieldList.add(new EditField(Event.DATA, R.string.eventLabelsGroup, FLAGS_EVENT)); + + if (getAttr(attrs, Attr.DATE_WITH_TIME, false)) { + kind.dateFormatWithoutYear = CommonDateUtils.NO_YEAR_DATE_AND_TIME_FORMAT; + kind.dateFormatWithYear = CommonDateUtils.DATE_AND_TIME_FORMAT; + } else { + kind.dateFormatWithoutYear = CommonDateUtils.NO_YEAR_DATE_FORMAT; + kind.dateFormatWithYear = CommonDateUtils.FULL_DATE_FORMAT; + } + + List<DataKind> result = new ArrayList<>(); + result.add(kind); + return result; + } + + @Override + protected EditType buildEditTypeForTypeTag(AttributeSet attrs, String type) { + final boolean yo = getAttr(attrs, Attr.YEAR_OPTIONAL, false); + + if ("birthday".equals(type)) { + return buildEventType(Event.TYPE_BIRTHDAY, yo).setSpecificMax(1); + } + if ("anniversary".equals(type)) { + return buildEventType(Event.TYPE_ANNIVERSARY, yo); + } + if ("other".equals(type)) { + return buildEventType(Event.TYPE_OTHER, yo); + } + if ("custom".equals(type)) { + return buildEventType(Event.TYPE_CUSTOM, yo) + .setSecondary(true) + .setCustomColumn(Event.LABEL); + } + return null; + } + } + + /** + * Relationship DataKind parser. + * + * <p>Relationship DataKind is used only for Google/Exchange types, so this parser is not used for + * now. + */ + private static class RelationshipKindBuilder extends KindBuilder { + + @Override + public String getTagName() { + return "relationship"; + } + + @Override + public List<DataKind> parseDataKind(Context context, XmlPullParser parser, AttributeSet attrs) + throws DefinitionException, XmlPullParserException, IOException { + final DataKind kind = + newDataKind( + context, + parser, + attrs, + false, + Relation.CONTENT_ITEM_TYPE, + Relation.TYPE, + R.string.relationLabelsGroup, + Weight.RELATIONSHIP, + new RelationActionInflater(), + new SimpleInflater(Relation.NAME)); + + kind.fieldList.add( + new EditField(Relation.DATA, R.string.relationLabelsGroup, FLAGS_RELATION)); + + kind.defaultValues = new ContentValues(); + kind.defaultValues.put(Relation.TYPE, Relation.TYPE_SPOUSE); + + List<DataKind> result = new ArrayList<>(); + result.add(kind); + return result; + } + + @Override + protected EditType buildEditTypeForTypeTag(AttributeSet attrs, String type) { + // EditType is mutable, so we need to create a new instance every time. + if ("assistant".equals(type)) { + return buildRelationType(Relation.TYPE_ASSISTANT); + } + if ("brother".equals(type)) { + return buildRelationType(Relation.TYPE_BROTHER); + } + if ("child".equals(type)) { + return buildRelationType(Relation.TYPE_CHILD); + } + if ("domestic_partner".equals(type)) { + return buildRelationType(Relation.TYPE_DOMESTIC_PARTNER); + } + if ("father".equals(type)) { + return buildRelationType(Relation.TYPE_FATHER); + } + if ("friend".equals(type)) { + return buildRelationType(Relation.TYPE_FRIEND); + } + if ("manager".equals(type)) { + return buildRelationType(Relation.TYPE_MANAGER); + } + if ("mother".equals(type)) { + return buildRelationType(Relation.TYPE_MOTHER); + } + if ("parent".equals(type)) { + return buildRelationType(Relation.TYPE_PARENT); + } + if ("partner".equals(type)) { + return buildRelationType(Relation.TYPE_PARTNER); + } + if ("referred_by".equals(type)) { + return buildRelationType(Relation.TYPE_REFERRED_BY); + } + if ("relative".equals(type)) { + return buildRelationType(Relation.TYPE_RELATIVE); + } + if ("sister".equals(type)) { + return buildRelationType(Relation.TYPE_SISTER); + } + if ("spouse".equals(type)) { + return buildRelationType(Relation.TYPE_SPOUSE); + } + if ("custom".equals(type)) { + return buildRelationType(Relation.TYPE_CUSTOM) + .setSecondary(true) + .setCustomColumn(Relation.LABEL); + } + return null; + } + } +} diff --git a/java/com/android/contacts/common/model/account/ExchangeAccountType.java b/java/com/android/contacts/common/model/account/ExchangeAccountType.java new file mode 100644 index 000000000..a27028e80 --- /dev/null +++ b/java/com/android/contacts/common/model/account/ExchangeAccountType.java @@ -0,0 +1,365 @@ +/* + * Copyright (C) 2009 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.common.model.account; + +import android.content.ContentValues; +import android.content.Context; +import android.provider.ContactsContract.CommonDataKinds.Email; +import android.provider.ContactsContract.CommonDataKinds.Event; +import android.provider.ContactsContract.CommonDataKinds.Im; +import android.provider.ContactsContract.CommonDataKinds.Nickname; +import android.provider.ContactsContract.CommonDataKinds.Note; +import android.provider.ContactsContract.CommonDataKinds.Organization; +import android.provider.ContactsContract.CommonDataKinds.Phone; +import android.provider.ContactsContract.CommonDataKinds.Photo; +import android.provider.ContactsContract.CommonDataKinds.StructuredName; +import android.provider.ContactsContract.CommonDataKinds.StructuredPostal; +import android.provider.ContactsContract.CommonDataKinds.Website; +import android.util.Log; +import com.android.contacts.common.R; +import com.android.contacts.common.model.dataitem.DataKind; +import com.android.contacts.common.util.CommonDateUtils; +import java.util.ArrayList; +import java.util.Locale; + +public class ExchangeAccountType extends BaseAccountType { + + private static final String TAG = "ExchangeAccountType"; + + private static final String ACCOUNT_TYPE_AOSP = "com.android.exchange"; + private static final String ACCOUNT_TYPE_GOOGLE_1 = "com.google.android.exchange"; + private static final String ACCOUNT_TYPE_GOOGLE_2 = "com.google.android.gm.exchange"; + + public ExchangeAccountType(Context context, String authenticatorPackageName, String type) { + this.accountType = type; + this.resourcePackageName = null; + this.syncAdapterPackageName = authenticatorPackageName; + + try { + addDataKindStructuredName(context); + addDataKindDisplayName(context); + addDataKindPhoneticName(context); + addDataKindNickname(context); + addDataKindPhone(context); + addDataKindEmail(context); + addDataKindStructuredPostal(context); + addDataKindIm(context); + addDataKindOrganization(context); + addDataKindPhoto(context); + addDataKindNote(context); + addDataKindEvent(context); + addDataKindWebsite(context); + addDataKindGroupMembership(context); + + mIsInitialized = true; + } catch (DefinitionException e) { + Log.e(TAG, "Problem building account type", e); + } + } + + public static boolean isExchangeType(String type) { + return ACCOUNT_TYPE_AOSP.equals(type) + || ACCOUNT_TYPE_GOOGLE_1.equals(type) + || ACCOUNT_TYPE_GOOGLE_2.equals(type); + } + + @Override + protected DataKind addDataKindStructuredName(Context context) throws DefinitionException { + DataKind kind = + addKind( + new DataKind( + StructuredName.CONTENT_ITEM_TYPE, R.string.nameLabelsGroup, Weight.NONE, true)); + kind.actionHeader = new SimpleInflater(R.string.nameLabelsGroup); + kind.actionBody = new SimpleInflater(Nickname.NAME); + + kind.typeOverallMax = 1; + + kind.fieldList = new ArrayList<>(); + kind.fieldList.add( + new EditField(StructuredName.PREFIX, R.string.name_prefix, FLAGS_PERSON_NAME) + .setOptional(true)); + kind.fieldList.add( + new EditField(StructuredName.FAMILY_NAME, R.string.name_family, FLAGS_PERSON_NAME)); + kind.fieldList.add( + new EditField(StructuredName.MIDDLE_NAME, R.string.name_middle, FLAGS_PERSON_NAME)); + kind.fieldList.add( + new EditField(StructuredName.GIVEN_NAME, R.string.name_given, FLAGS_PERSON_NAME)); + kind.fieldList.add( + new EditField(StructuredName.SUFFIX, R.string.name_suffix, FLAGS_PERSON_NAME)); + + kind.fieldList.add( + new EditField( + StructuredName.PHONETIC_FAMILY_NAME, R.string.name_phonetic_family, FLAGS_PHONETIC)); + kind.fieldList.add( + new EditField( + StructuredName.PHONETIC_GIVEN_NAME, R.string.name_phonetic_given, FLAGS_PHONETIC)); + + return kind; + } + + @Override + protected DataKind addDataKindDisplayName(Context context) throws DefinitionException { + DataKind kind = + addKind( + new DataKind( + DataKind.PSEUDO_MIME_TYPE_DISPLAY_NAME, + R.string.nameLabelsGroup, + Weight.NONE, + true)); + + boolean displayOrderPrimary = + context.getResources().getBoolean(R.bool.config_editor_field_order_primary); + kind.typeOverallMax = 1; + + kind.fieldList = new ArrayList<>(); + kind.fieldList.add( + new EditField(StructuredName.PREFIX, R.string.name_prefix, FLAGS_PERSON_NAME) + .setOptional(true)); + if (!displayOrderPrimary) { + kind.fieldList.add( + new EditField(StructuredName.FAMILY_NAME, R.string.name_family, FLAGS_PERSON_NAME)); + kind.fieldList.add( + new EditField(StructuredName.MIDDLE_NAME, R.string.name_middle, FLAGS_PERSON_NAME) + .setOptional(true)); + kind.fieldList.add( + new EditField(StructuredName.GIVEN_NAME, R.string.name_given, FLAGS_PERSON_NAME)); + } else { + kind.fieldList.add( + new EditField(StructuredName.GIVEN_NAME, R.string.name_given, FLAGS_PERSON_NAME)); + kind.fieldList.add( + new EditField(StructuredName.MIDDLE_NAME, R.string.name_middle, FLAGS_PERSON_NAME) + .setOptional(true)); + kind.fieldList.add( + new EditField(StructuredName.FAMILY_NAME, R.string.name_family, FLAGS_PERSON_NAME)); + } + kind.fieldList.add( + new EditField(StructuredName.SUFFIX, R.string.name_suffix, FLAGS_PERSON_NAME) + .setOptional(true)); + + return kind; + } + + @Override + protected DataKind addDataKindPhoneticName(Context context) throws DefinitionException { + DataKind kind = + addKind( + new DataKind( + DataKind.PSEUDO_MIME_TYPE_PHONETIC_NAME, + R.string.name_phonetic, + Weight.NONE, + true)); + kind.actionHeader = new SimpleInflater(R.string.nameLabelsGroup); + kind.actionBody = new SimpleInflater(Nickname.NAME); + + kind.typeOverallMax = 1; + + kind.fieldList = new ArrayList<>(); + kind.fieldList.add( + new EditField( + StructuredName.PHONETIC_FAMILY_NAME, R.string.name_phonetic_family, FLAGS_PHONETIC)); + kind.fieldList.add( + new EditField( + StructuredName.PHONETIC_GIVEN_NAME, R.string.name_phonetic_given, FLAGS_PHONETIC)); + + return kind; + } + + @Override + protected DataKind addDataKindNickname(Context context) throws DefinitionException { + final DataKind kind = super.addDataKindNickname(context); + + kind.typeOverallMax = 1; + + kind.fieldList = new ArrayList<>(); + kind.fieldList.add( + new EditField(Nickname.NAME, R.string.nicknameLabelsGroup, FLAGS_PERSON_NAME)); + + return kind; + } + + @Override + protected DataKind addDataKindPhone(Context context) throws DefinitionException { + final DataKind kind = super.addDataKindPhone(context); + + kind.typeColumn = Phone.TYPE; + kind.typeList = new ArrayList<>(); + kind.typeList.add(buildPhoneType(Phone.TYPE_MOBILE).setSpecificMax(1)); + kind.typeList.add(buildPhoneType(Phone.TYPE_HOME).setSpecificMax(2)); + kind.typeList.add(buildPhoneType(Phone.TYPE_WORK).setSpecificMax(2)); + kind.typeList.add(buildPhoneType(Phone.TYPE_FAX_WORK).setSecondary(true).setSpecificMax(1)); + kind.typeList.add(buildPhoneType(Phone.TYPE_FAX_HOME).setSecondary(true).setSpecificMax(1)); + kind.typeList.add(buildPhoneType(Phone.TYPE_PAGER).setSecondary(true).setSpecificMax(1)); + kind.typeList.add(buildPhoneType(Phone.TYPE_CAR).setSecondary(true).setSpecificMax(1)); + kind.typeList.add(buildPhoneType(Phone.TYPE_COMPANY_MAIN).setSecondary(true).setSpecificMax(1)); + kind.typeList.add(buildPhoneType(Phone.TYPE_MMS).setSecondary(true).setSpecificMax(1)); + kind.typeList.add(buildPhoneType(Phone.TYPE_RADIO).setSecondary(true).setSpecificMax(1)); + kind.typeList.add(buildPhoneType(Phone.TYPE_ASSISTANT).setSecondary(true).setSpecificMax(1)); + + kind.fieldList = new ArrayList<>(); + kind.fieldList.add(new EditField(Phone.NUMBER, R.string.phoneLabelsGroup, FLAGS_PHONE)); + + return kind; + } + + @Override + protected DataKind addDataKindEmail(Context context) throws DefinitionException { + final DataKind kind = super.addDataKindEmail(context); + + kind.typeOverallMax = 3; + + kind.fieldList = new ArrayList<>(); + kind.fieldList.add(new EditField(Email.DATA, R.string.emailLabelsGroup, FLAGS_EMAIL)); + + return kind; + } + + @Override + protected DataKind addDataKindStructuredPostal(Context context) throws DefinitionException { + final DataKind kind = super.addDataKindStructuredPostal(context); + + final boolean useJapaneseOrder = + Locale.JAPANESE.getLanguage().equals(Locale.getDefault().getLanguage()); + kind.typeColumn = StructuredPostal.TYPE; + kind.typeList = new ArrayList<>(); + kind.typeList.add(buildPostalType(StructuredPostal.TYPE_WORK).setSpecificMax(1)); + kind.typeList.add(buildPostalType(StructuredPostal.TYPE_HOME).setSpecificMax(1)); + kind.typeList.add(buildPostalType(StructuredPostal.TYPE_OTHER).setSpecificMax(1)); + + kind.fieldList = new ArrayList<>(); + if (useJapaneseOrder) { + kind.fieldList.add( + new EditField(StructuredPostal.COUNTRY, R.string.postal_country, FLAGS_POSTAL) + .setOptional(true)); + kind.fieldList.add( + new EditField(StructuredPostal.POSTCODE, R.string.postal_postcode, FLAGS_POSTAL)); + kind.fieldList.add( + new EditField(StructuredPostal.REGION, R.string.postal_region, FLAGS_POSTAL)); + kind.fieldList.add(new EditField(StructuredPostal.CITY, R.string.postal_city, FLAGS_POSTAL)); + kind.fieldList.add( + new EditField(StructuredPostal.STREET, R.string.postal_street, FLAGS_POSTAL)); + } else { + kind.fieldList.add( + new EditField(StructuredPostal.STREET, R.string.postal_street, FLAGS_POSTAL)); + kind.fieldList.add(new EditField(StructuredPostal.CITY, R.string.postal_city, FLAGS_POSTAL)); + kind.fieldList.add( + new EditField(StructuredPostal.REGION, R.string.postal_region, FLAGS_POSTAL)); + kind.fieldList.add( + new EditField(StructuredPostal.POSTCODE, R.string.postal_postcode, FLAGS_POSTAL)); + kind.fieldList.add( + new EditField(StructuredPostal.COUNTRY, R.string.postal_country, FLAGS_POSTAL) + .setOptional(true)); + } + + return kind; + } + + @Override + protected DataKind addDataKindIm(Context context) throws DefinitionException { + final DataKind kind = super.addDataKindIm(context); + + // Types are not supported for IM. There can be 3 IMs, but OWA only shows only the first + kind.typeOverallMax = 3; + + kind.defaultValues = new ContentValues(); + kind.defaultValues.put(Im.TYPE, Im.TYPE_OTHER); + + kind.fieldList = new ArrayList<>(); + kind.fieldList.add(new EditField(Im.DATA, R.string.imLabelsGroup, FLAGS_EMAIL)); + + return kind; + } + + @Override + protected DataKind addDataKindOrganization(Context context) throws DefinitionException { + final DataKind kind = super.addDataKindOrganization(context); + + kind.typeOverallMax = 1; + + kind.fieldList = new ArrayList<>(); + kind.fieldList.add( + new EditField(Organization.COMPANY, R.string.ghostData_company, FLAGS_GENERIC_NAME)); + kind.fieldList.add( + new EditField(Organization.TITLE, R.string.ghostData_title, FLAGS_GENERIC_NAME)); + + return kind; + } + + @Override + protected DataKind addDataKindPhoto(Context context) throws DefinitionException { + final DataKind kind = super.addDataKindPhoto(context); + + kind.typeOverallMax = 1; + + kind.fieldList = new ArrayList<>(); + kind.fieldList.add(new EditField(Photo.PHOTO, -1, -1)); + + return kind; + } + + @Override + protected DataKind addDataKindNote(Context context) throws DefinitionException { + final DataKind kind = super.addDataKindNote(context); + + kind.fieldList = new ArrayList<>(); + kind.fieldList.add(new EditField(Note.NOTE, R.string.label_notes, FLAGS_NOTE)); + + return kind; + } + + protected DataKind addDataKindEvent(Context context) throws DefinitionException { + DataKind kind = + addKind( + new DataKind(Event.CONTENT_ITEM_TYPE, R.string.eventLabelsGroup, Weight.EVENT, true)); + kind.actionHeader = new EventActionInflater(); + kind.actionBody = new SimpleInflater(Event.START_DATE); + + kind.typeOverallMax = 1; + + kind.typeColumn = Event.TYPE; + kind.typeList = new ArrayList<>(); + kind.typeList.add(buildEventType(Event.TYPE_BIRTHDAY, false).setSpecificMax(1)); + + kind.dateFormatWithYear = CommonDateUtils.DATE_AND_TIME_FORMAT; + + kind.fieldList = new ArrayList<>(); + kind.fieldList.add(new EditField(Event.DATA, R.string.eventLabelsGroup, FLAGS_EVENT)); + + return kind; + } + + @Override + protected DataKind addDataKindWebsite(Context context) throws DefinitionException { + final DataKind kind = super.addDataKindWebsite(context); + + kind.typeOverallMax = 1; + + kind.fieldList = new ArrayList<>(); + kind.fieldList.add(new EditField(Website.URL, R.string.websiteLabelsGroup, FLAGS_WEBSITE)); + + return kind; + } + + @Override + public boolean isGroupMembershipEditable() { + return true; + } + + @Override + public boolean areContactsWritable() { + return true; + } +} diff --git a/java/com/android/contacts/common/model/account/ExternalAccountType.java b/java/com/android/contacts/common/model/account/ExternalAccountType.java new file mode 100644 index 000000000..aca1f70d2 --- /dev/null +++ b/java/com/android/contacts/common/model/account/ExternalAccountType.java @@ -0,0 +1,443 @@ +/* + * Copyright (C) 2009 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.common.model.account; + +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.content.pm.PackageManager.NameNotFoundException; +import android.content.pm.ResolveInfo; +import android.content.pm.ServiceInfo; +import android.content.res.Resources; +import android.content.res.TypedArray; +import android.content.res.XmlResourceParser; +import android.provider.ContactsContract.CommonDataKinds.Photo; +import android.provider.ContactsContract.CommonDataKinds.StructuredName; +import android.support.annotation.VisibleForTesting; +import android.text.TextUtils; +import android.util.AttributeSet; +import android.util.Log; +import android.util.Xml; +import com.android.contacts.common.R; +import com.android.contacts.common.model.dataitem.DataKind; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import org.xmlpull.v1.XmlPullParser; +import org.xmlpull.v1.XmlPullParserException; + +/** A general contacts account type descriptor. */ +public class ExternalAccountType extends BaseAccountType { + + private static final String TAG = "ExternalAccountType"; + + private static final String SYNC_META_DATA = "android.content.SyncAdapter"; + + /** + * The metadata name for so-called "contacts.xml". + * + * <p>On LMP and later, we also accept the "alternate" name. This is to allow sync adapters to + * have a contacts.xml without making it visible on older platforms. If you modify this also + * update the corresponding list in ContactsProvider/PhotoPriorityResolver + */ + private static final String[] METADATA_CONTACTS_NAMES = + new String[] { + "android.provider.ALTERNATE_CONTACTS_STRUCTURE", "android.provider.CONTACTS_STRUCTURE" + }; + + private static final String TAG_CONTACTS_SOURCE_LEGACY = "ContactsSource"; + private static final String TAG_CONTACTS_ACCOUNT_TYPE = "ContactsAccountType"; + private static final String TAG_CONTACTS_DATA_KIND = "ContactsDataKind"; + private static final String TAG_EDIT_SCHEMA = "EditSchema"; + + private static final String ATTR_EDIT_CONTACT_ACTIVITY = "editContactActivity"; + private static final String ATTR_CREATE_CONTACT_ACTIVITY = "createContactActivity"; + private static final String ATTR_INVITE_CONTACT_ACTIVITY = "inviteContactActivity"; + private static final String ATTR_INVITE_CONTACT_ACTION_LABEL = "inviteContactActionLabel"; + private static final String ATTR_VIEW_CONTACT_NOTIFY_SERVICE = "viewContactNotifyService"; + private static final String ATTR_VIEW_GROUP_ACTIVITY = "viewGroupActivity"; + private static final String ATTR_VIEW_GROUP_ACTION_LABEL = "viewGroupActionLabel"; + private static final String ATTR_DATA_SET = "dataSet"; + private static final String ATTR_EXTENSION_PACKAGE_NAMES = "extensionPackageNames"; + + // The following attributes should only be set in non-sync-adapter account types. They allow + // for the account type and resource IDs to be specified without an associated authenticator. + private static final String ATTR_ACCOUNT_TYPE = "accountType"; + private static final String ATTR_ACCOUNT_LABEL = "accountTypeLabel"; + private static final String ATTR_ACCOUNT_ICON = "accountTypeIcon"; + + private final boolean mIsExtension; + + private String mEditContactActivityClassName; + private String mCreateContactActivityClassName; + private String mInviteContactActivity; + private String mInviteActionLabelAttribute; + private int mInviteActionLabelResId; + private String mViewContactNotifyService; + private String mViewGroupActivity; + private String mViewGroupLabelAttribute; + private int mViewGroupLabelResId; + private List<String> mExtensionPackageNames; + private String mAccountTypeLabelAttribute; + private String mAccountTypeIconAttribute; + private boolean mHasContactsMetadata; + private boolean mHasEditSchema; + + public ExternalAccountType(Context context, String resPackageName, boolean isExtension) { + this(context, resPackageName, isExtension, null); + } + + /** + * Constructor used for testing to initialize with any arbitrary XML. + * + * @param injectedMetadata If non-null, it'll be used to initialize the type. Only set by tests. + * If null, the metadata is loaded from the specified package. + */ + ExternalAccountType( + Context context, + String packageName, + boolean isExtension, + XmlResourceParser injectedMetadata) { + this.mIsExtension = isExtension; + this.resourcePackageName = packageName; + this.syncAdapterPackageName = packageName; + + final XmlResourceParser parser; + if (injectedMetadata == null) { + parser = loadContactsXml(context, packageName); + } else { + parser = injectedMetadata; + } + boolean needLineNumberInErrorLog = true; + try { + if (parser != null) { + inflate(context, parser); + } + + // Done parsing; line number no longer needed in error log. + needLineNumberInErrorLog = false; + if (mHasEditSchema) { + checkKindExists(StructuredName.CONTENT_ITEM_TYPE); + checkKindExists(DataKind.PSEUDO_MIME_TYPE_DISPLAY_NAME); + checkKindExists(DataKind.PSEUDO_MIME_TYPE_PHONETIC_NAME); + checkKindExists(Photo.CONTENT_ITEM_TYPE); + } else { + // Bring in name and photo from fallback source, which are non-optional + addDataKindStructuredName(context); + addDataKindDisplayName(context); + addDataKindPhoneticName(context); + addDataKindPhoto(context); + } + } catch (DefinitionException e) { + final StringBuilder error = new StringBuilder(); + error.append("Problem reading XML"); + if (needLineNumberInErrorLog && (parser != null)) { + error.append(" in line "); + error.append(parser.getLineNumber()); + } + error.append(" for external package "); + error.append(packageName); + + Log.e(TAG, error.toString(), e); + return; + } finally { + if (parser != null) { + parser.close(); + } + } + + mExtensionPackageNames = new ArrayList<String>(); + mInviteActionLabelResId = + resolveExternalResId( + context, + mInviteActionLabelAttribute, + syncAdapterPackageName, + ATTR_INVITE_CONTACT_ACTION_LABEL); + mViewGroupLabelResId = + resolveExternalResId( + context, + mViewGroupLabelAttribute, + syncAdapterPackageName, + ATTR_VIEW_GROUP_ACTION_LABEL); + titleRes = + resolveExternalResId( + context, mAccountTypeLabelAttribute, syncAdapterPackageName, ATTR_ACCOUNT_LABEL); + iconRes = + resolveExternalResId( + context, mAccountTypeIconAttribute, syncAdapterPackageName, ATTR_ACCOUNT_ICON); + + // If we reach this point, the account type has been successfully initialized. + mIsInitialized = true; + } + + /** + * Returns the CONTACTS_STRUCTURE metadata (aka "contacts.xml") in the given apk package. + * + * <p>This method looks through all services in the package that handle sync adapter intents for + * the first one that contains CONTACTS_STRUCTURE metadata. We have to look through all sync + * adapters in the package in case there are contacts and other sync adapters (eg, calendar) in + * the same package. + * + * <p>Returns {@code null} if the package has no CONTACTS_STRUCTURE metadata. In this case the + * account type *will* be initialized with minimal configuration. + */ + public static XmlResourceParser loadContactsXml(Context context, String resPackageName) { + final PackageManager pm = context.getPackageManager(); + final Intent intent = new Intent(SYNC_META_DATA).setPackage(resPackageName); + final List<ResolveInfo> intentServices = + pm.queryIntentServices(intent, PackageManager.GET_SERVICES | PackageManager.GET_META_DATA); + + if (intentServices != null) { + for (final ResolveInfo resolveInfo : intentServices) { + final ServiceInfo serviceInfo = resolveInfo.serviceInfo; + if (serviceInfo == null) { + continue; + } + for (String metadataName : METADATA_CONTACTS_NAMES) { + final XmlResourceParser parser = serviceInfo.loadXmlMetaData(pm, metadataName); + if (parser != null) { + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d( + TAG, + String.format( + "Metadata loaded from: %s, %s, %s", + serviceInfo.packageName, serviceInfo.name, metadataName)); + } + return parser; + } + } + } + } + + // Package was found, but that doesn't contain the CONTACTS_STRUCTURE metadata. + return null; + } + + /** Returns {@code TRUE} if the package contains CONTACTS_STRUCTURE metadata. */ + public static boolean hasContactsXml(Context context, String resPackageName) { + return loadContactsXml(context, resPackageName) != null; + } + + /** + * Takes a string in the "@xxx/yyy" format and return the resource ID for the resource in the + * resource package. + * + * <p>If the argument is in the invalid format or isn't a resource name, it returns -1. + * + * @param context context + * @param resourceName Resource name in the "@xxx/yyy" format, e.g. "@string/invite_lavbel" + * @param packageName name of the package containing the resource. + * @param xmlAttributeName attribute name which the resource came from. Used for logging. + */ + @VisibleForTesting + static int resolveExternalResId( + Context context, String resourceName, String packageName, String xmlAttributeName) { + if (TextUtils.isEmpty(resourceName)) { + return -1; // Empty text is okay. + } + if (resourceName.charAt(0) != '@') { + Log.e(TAG, xmlAttributeName + " must be a resource name beginnig with '@'"); + return -1; + } + final String name = resourceName.substring(1); + final Resources res; + try { + res = context.getPackageManager().getResourcesForApplication(packageName); + } catch (NameNotFoundException e) { + Log.e(TAG, "Unable to load package " + packageName); + return -1; + } + final int resId = res.getIdentifier(name, null, packageName); + if (resId == 0) { + Log.e(TAG, "Unable to load " + resourceName + " from package " + packageName); + return -1; + } + return resId; + } + + private void checkKindExists(String mimeType) throws DefinitionException { + if (getKindForMimetype(mimeType) == null) { + throw new DefinitionException(mimeType + " must be supported"); + } + } + + @Override + public boolean isEmbedded() { + return false; + } + + @Override + public boolean isExtension() { + return mIsExtension; + } + + @Override + public boolean areContactsWritable() { + return mHasEditSchema; + } + + /** Whether this account type has the android.provider.CONTACTS_STRUCTURE metadata xml. */ + public boolean hasContactsMetadata() { + return mHasContactsMetadata; + } + + @Override + public String getEditContactActivityClassName() { + return mEditContactActivityClassName; + } + + @Override + public String getCreateContactActivityClassName() { + return mCreateContactActivityClassName; + } + + @Override + public String getInviteContactActivityClassName() { + return mInviteContactActivity; + } + + @Override + protected int getInviteContactActionResId() { + return mInviteActionLabelResId; + } + + @Override + public String getViewContactNotifyServiceClassName() { + return mViewContactNotifyService; + } + + @Override + public String getViewGroupActivity() { + return mViewGroupActivity; + } + + @Override + protected int getViewGroupLabelResId() { + return mViewGroupLabelResId; + } + + @Override + public List<String> getExtensionPackageNames() { + return mExtensionPackageNames; + } + + /** + * Inflate this {@link AccountType} from the given parser. This may only load details matching the + * publicly-defined schema. + */ + protected void inflate(Context context, XmlPullParser parser) throws DefinitionException { + final AttributeSet attrs = Xml.asAttributeSet(parser); + + try { + int type; + while ((type = parser.next()) != XmlPullParser.START_TAG + && type != XmlPullParser.END_DOCUMENT) { + // Drain comments and whitespace + } + + if (type != XmlPullParser.START_TAG) { + throw new IllegalStateException("No start tag found"); + } + + String rootTag = parser.getName(); + if (!TAG_CONTACTS_ACCOUNT_TYPE.equals(rootTag) + && !TAG_CONTACTS_SOURCE_LEGACY.equals(rootTag)) { + throw new IllegalStateException( + "Top level element must be " + TAG_CONTACTS_ACCOUNT_TYPE + ", not " + rootTag); + } + + mHasContactsMetadata = true; + + int attributeCount = parser.getAttributeCount(); + for (int i = 0; i < attributeCount; i++) { + String attr = parser.getAttributeName(i); + String value = parser.getAttributeValue(i); + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, attr + "=" + value); + } + if (ATTR_EDIT_CONTACT_ACTIVITY.equals(attr)) { + mEditContactActivityClassName = value; + } else if (ATTR_CREATE_CONTACT_ACTIVITY.equals(attr)) { + mCreateContactActivityClassName = value; + } else if (ATTR_INVITE_CONTACT_ACTIVITY.equals(attr)) { + mInviteContactActivity = value; + } else if (ATTR_INVITE_CONTACT_ACTION_LABEL.equals(attr)) { + mInviteActionLabelAttribute = value; + } else if (ATTR_VIEW_CONTACT_NOTIFY_SERVICE.equals(attr)) { + mViewContactNotifyService = value; + } else if (ATTR_VIEW_GROUP_ACTIVITY.equals(attr)) { + mViewGroupActivity = value; + } else if (ATTR_VIEW_GROUP_ACTION_LABEL.equals(attr)) { + mViewGroupLabelAttribute = value; + } else if (ATTR_DATA_SET.equals(attr)) { + dataSet = value; + } else if (ATTR_EXTENSION_PACKAGE_NAMES.equals(attr)) { + mExtensionPackageNames.add(value); + } else if (ATTR_ACCOUNT_TYPE.equals(attr)) { + accountType = value; + } else if (ATTR_ACCOUNT_LABEL.equals(attr)) { + mAccountTypeLabelAttribute = value; + } else if (ATTR_ACCOUNT_ICON.equals(attr)) { + mAccountTypeIconAttribute = value; + } else { + Log.e(TAG, "Unsupported attribute " + attr); + } + } + + // Parse all children kinds + final int startDepth = parser.getDepth(); + while (((type = parser.next()) != XmlPullParser.END_TAG || parser.getDepth() > startDepth) + && type != XmlPullParser.END_DOCUMENT) { + + if (type != XmlPullParser.START_TAG || parser.getDepth() != startDepth + 1) { + continue; // Not a direct child tag + } + + String tag = parser.getName(); + if (TAG_EDIT_SCHEMA.equals(tag)) { + mHasEditSchema = true; + parseEditSchema(context, parser, attrs); + } else if (TAG_CONTACTS_DATA_KIND.equals(tag)) { + final TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.ContactsDataKind); + final DataKind kind = new DataKind(); + + kind.mimeType = a.getString(R.styleable.ContactsDataKind_android_mimeType); + final String summaryColumn = + a.getString(R.styleable.ContactsDataKind_android_summaryColumn); + if (summaryColumn != null) { + // Inflate a specific column as summary when requested + kind.actionHeader = new SimpleInflater(summaryColumn); + } + final String detailColumn = + a.getString(R.styleable.ContactsDataKind_android_detailColumn); + if (detailColumn != null) { + // Inflate specific column as summary + kind.actionBody = new SimpleInflater(detailColumn); + } + + a.recycle(); + + addKind(kind); + } + } + } catch (XmlPullParserException e) { + throw new DefinitionException("Problem reading XML", e); + } catch (IOException e) { + throw new DefinitionException("Problem reading XML", e); + } + } +} diff --git a/java/com/android/contacts/common/model/account/FallbackAccountType.java b/java/com/android/contacts/common/model/account/FallbackAccountType.java new file mode 100644 index 000000000..976a7b892 --- /dev/null +++ b/java/com/android/contacts/common/model/account/FallbackAccountType.java @@ -0,0 +1,77 @@ +/* + * Copyright (C) 2009 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.common.model.account; + +import android.content.Context; +import android.util.Log; +import com.android.contacts.common.R; +import com.android.contacts.common.model.dataitem.DataKind; + +public class FallbackAccountType extends BaseAccountType { + + private static final String TAG = "FallbackAccountType"; + + private FallbackAccountType(Context context, String resPackageName) { + this.accountType = null; + this.dataSet = null; + this.titleRes = R.string.account_phone; + this.iconRes = R.mipmap.ic_contacts_launcher; + + // Note those are only set for unit tests. + this.resourcePackageName = resPackageName; + this.syncAdapterPackageName = resPackageName; + + try { + addDataKindStructuredName(context); + addDataKindDisplayName(context); + addDataKindPhoneticName(context); + addDataKindNickname(context); + addDataKindPhone(context); + addDataKindEmail(context); + addDataKindStructuredPostal(context); + addDataKindIm(context); + addDataKindOrganization(context); + addDataKindPhoto(context); + addDataKindNote(context); + addDataKindWebsite(context); + addDataKindSipAddress(context); + addDataKindGroupMembership(context); + + mIsInitialized = true; + } catch (DefinitionException e) { + Log.e(TAG, "Problem building account type", e); + } + } + + public FallbackAccountType(Context context) { + this(context, null); + } + + /** + * Used to compare with an {@link ExternalAccountType} built from a test contacts.xml. In order to + * build {@link DataKind}s with the same resource package name, {@code resPackageName} is + * injectable. + */ + static AccountType createWithPackageNameForTest(Context context, String resPackageName) { + return new FallbackAccountType(context, resPackageName); + } + + @Override + public boolean areContactsWritable() { + return true; + } +} diff --git a/java/com/android/contacts/common/model/account/GoogleAccountType.java b/java/com/android/contacts/common/model/account/GoogleAccountType.java new file mode 100644 index 000000000..2f1fe0ed6 --- /dev/null +++ b/java/com/android/contacts/common/model/account/GoogleAccountType.java @@ -0,0 +1,206 @@ +/* + * Copyright (C) 2009 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.common.model.account; + +import android.content.ContentValues; +import android.content.Context; +import android.provider.ContactsContract.CommonDataKinds.Email; +import android.provider.ContactsContract.CommonDataKinds.Event; +import android.provider.ContactsContract.CommonDataKinds.Phone; +import android.provider.ContactsContract.CommonDataKinds.Relation; +import android.util.Log; +import com.android.contacts.common.R; +import com.android.contacts.common.model.dataitem.DataKind; +import com.android.contacts.common.util.CommonDateUtils; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public class GoogleAccountType extends BaseAccountType { + + /** + * The package name that we should load contacts.xml from and rely on to handle G+ account + * actions. Even though this points to gms, in some cases gms will still hand off responsibility + * to the G+ app. + */ + public static final String PLUS_EXTENSION_PACKAGE_NAME = "com.google.android.gms"; + + public static final String ACCOUNT_TYPE = "com.google"; + private static final String TAG = "GoogleAccountType"; + private static final List<String> mExtensionPackages = + new ArrayList<>(Collections.singletonList(PLUS_EXTENSION_PACKAGE_NAME)); + + public GoogleAccountType(Context context, String authenticatorPackageName) { + this.accountType = ACCOUNT_TYPE; + this.resourcePackageName = null; + this.syncAdapterPackageName = authenticatorPackageName; + + try { + addDataKindStructuredName(context); + addDataKindDisplayName(context); + addDataKindPhoneticName(context); + addDataKindNickname(context); + addDataKindPhone(context); + addDataKindEmail(context); + addDataKindStructuredPostal(context); + addDataKindIm(context); + addDataKindOrganization(context); + addDataKindPhoto(context); + addDataKindNote(context); + addDataKindWebsite(context); + addDataKindSipAddress(context); + addDataKindGroupMembership(context); + addDataKindRelation(context); + addDataKindEvent(context); + + mIsInitialized = true; + } catch (DefinitionException e) { + Log.e(TAG, "Problem building account type", e); + } + } + + @Override + public List<String> getExtensionPackageNames() { + return mExtensionPackages; + } + + @Override + protected DataKind addDataKindPhone(Context context) throws DefinitionException { + final DataKind kind = super.addDataKindPhone(context); + + kind.typeColumn = Phone.TYPE; + kind.typeList = new ArrayList<>(); + kind.typeList.add(buildPhoneType(Phone.TYPE_MOBILE)); + kind.typeList.add(buildPhoneType(Phone.TYPE_WORK)); + kind.typeList.add(buildPhoneType(Phone.TYPE_HOME)); + kind.typeList.add(buildPhoneType(Phone.TYPE_MAIN)); + kind.typeList.add(buildPhoneType(Phone.TYPE_FAX_WORK).setSecondary(true)); + kind.typeList.add(buildPhoneType(Phone.TYPE_FAX_HOME).setSecondary(true)); + kind.typeList.add(buildPhoneType(Phone.TYPE_PAGER).setSecondary(true)); + kind.typeList.add(buildPhoneType(Phone.TYPE_OTHER)); + kind.typeList.add( + buildPhoneType(Phone.TYPE_CUSTOM).setSecondary(true).setCustomColumn(Phone.LABEL)); + + kind.fieldList = new ArrayList<>(); + kind.fieldList.add(new EditField(Phone.NUMBER, R.string.phoneLabelsGroup, FLAGS_PHONE)); + + return kind; + } + + @Override + protected DataKind addDataKindEmail(Context context) throws DefinitionException { + final DataKind kind = super.addDataKindEmail(context); + + kind.typeColumn = Email.TYPE; + kind.typeList = new ArrayList<>(); + kind.typeList.add(buildEmailType(Email.TYPE_HOME)); + kind.typeList.add(buildEmailType(Email.TYPE_WORK)); + kind.typeList.add(buildEmailType(Email.TYPE_OTHER)); + kind.typeList.add( + buildEmailType(Email.TYPE_CUSTOM).setSecondary(true).setCustomColumn(Email.LABEL)); + + kind.fieldList = new ArrayList<>(); + kind.fieldList.add(new EditField(Email.DATA, R.string.emailLabelsGroup, FLAGS_EMAIL)); + + return kind; + } + + private DataKind addDataKindRelation(Context context) throws DefinitionException { + DataKind kind = + addKind( + new DataKind( + Relation.CONTENT_ITEM_TYPE, + R.string.relationLabelsGroup, + Weight.RELATIONSHIP, + true)); + kind.actionHeader = new RelationActionInflater(); + kind.actionBody = new SimpleInflater(Relation.NAME); + + kind.typeColumn = Relation.TYPE; + kind.typeList = new ArrayList<>(); + kind.typeList.add(buildRelationType(Relation.TYPE_ASSISTANT)); + kind.typeList.add(buildRelationType(Relation.TYPE_BROTHER)); + kind.typeList.add(buildRelationType(Relation.TYPE_CHILD)); + kind.typeList.add(buildRelationType(Relation.TYPE_DOMESTIC_PARTNER)); + kind.typeList.add(buildRelationType(Relation.TYPE_FATHER)); + kind.typeList.add(buildRelationType(Relation.TYPE_FRIEND)); + kind.typeList.add(buildRelationType(Relation.TYPE_MANAGER)); + kind.typeList.add(buildRelationType(Relation.TYPE_MOTHER)); + kind.typeList.add(buildRelationType(Relation.TYPE_PARENT)); + kind.typeList.add(buildRelationType(Relation.TYPE_PARTNER)); + kind.typeList.add(buildRelationType(Relation.TYPE_REFERRED_BY)); + kind.typeList.add(buildRelationType(Relation.TYPE_RELATIVE)); + kind.typeList.add(buildRelationType(Relation.TYPE_SISTER)); + kind.typeList.add(buildRelationType(Relation.TYPE_SPOUSE)); + kind.typeList.add( + buildRelationType(Relation.TYPE_CUSTOM).setSecondary(true).setCustomColumn(Relation.LABEL)); + + kind.defaultValues = new ContentValues(); + kind.defaultValues.put(Relation.TYPE, Relation.TYPE_SPOUSE); + + kind.fieldList = new ArrayList<>(); + kind.fieldList.add(new EditField(Relation.DATA, R.string.relationLabelsGroup, FLAGS_RELATION)); + + return kind; + } + + private DataKind addDataKindEvent(Context context) throws DefinitionException { + DataKind kind = + addKind( + new DataKind(Event.CONTENT_ITEM_TYPE, R.string.eventLabelsGroup, Weight.EVENT, true)); + kind.actionHeader = new EventActionInflater(); + kind.actionBody = new SimpleInflater(Event.START_DATE); + + kind.typeColumn = Event.TYPE; + kind.typeList = new ArrayList<>(); + kind.dateFormatWithoutYear = CommonDateUtils.NO_YEAR_DATE_FORMAT; + kind.dateFormatWithYear = CommonDateUtils.FULL_DATE_FORMAT; + kind.typeList.add(buildEventType(Event.TYPE_BIRTHDAY, true).setSpecificMax(1)); + kind.typeList.add(buildEventType(Event.TYPE_ANNIVERSARY, false)); + kind.typeList.add(buildEventType(Event.TYPE_OTHER, false)); + kind.typeList.add( + buildEventType(Event.TYPE_CUSTOM, false).setSecondary(true).setCustomColumn(Event.LABEL)); + + kind.defaultValues = new ContentValues(); + kind.defaultValues.put(Event.TYPE, Event.TYPE_BIRTHDAY); + + kind.fieldList = new ArrayList<>(); + kind.fieldList.add(new EditField(Event.DATA, R.string.eventLabelsGroup, FLAGS_EVENT)); + + return kind; + } + + @Override + public boolean isGroupMembershipEditable() { + return true; + } + + @Override + public boolean areContactsWritable() { + return true; + } + + @Override + public String getViewContactNotifyServiceClassName() { + return "com.google.android.syncadapters.contacts." + "SyncHighResPhotoIntentService"; + } + + @Override + public String getViewContactNotifyServicePackageName() { + return "com.google.android.syncadapters.contacts"; + } +} diff --git a/java/com/android/contacts/common/model/account/SamsungAccountType.java b/java/com/android/contacts/common/model/account/SamsungAccountType.java new file mode 100644 index 000000000..45406bc2b --- /dev/null +++ b/java/com/android/contacts/common/model/account/SamsungAccountType.java @@ -0,0 +1,235 @@ +/* + * Copyright (C) 2015 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.common.model.account; + +import android.content.ContentValues; +import android.content.Context; +import android.provider.ContactsContract.CommonDataKinds.Email; +import android.provider.ContactsContract.CommonDataKinds.Event; +import android.provider.ContactsContract.CommonDataKinds.Phone; +import android.provider.ContactsContract.CommonDataKinds.Relation; +import android.provider.ContactsContract.CommonDataKinds.StructuredPostal; +import android.util.Log; +import com.android.contacts.common.R; +import com.android.contacts.common.model.dataitem.DataKind; +import com.android.contacts.common.util.CommonDateUtils; +import java.util.ArrayList; +import java.util.Locale; + +/** + * A writable account type that can be used to support samsung contacts. This may not perfectly + * match Samsung's latest intended account schema. + * + * <p>This is only used to partially support Samsung accounts. The DataKind labels & fields are + * setup to support the values used by Samsung. But, not everything in the Samsung account type is + * supported. The Samsung account type includes a "Message Type" mimetype that we have no intention + * of showing inside the Contact editor. Similarly, we don't handle the "Ringtone" mimetype here + * since managing ringtones is handled in a different flow. + */ +public class SamsungAccountType extends BaseAccountType { + + private static final String TAG = "KnownExternalAccountType"; + private static final String ACCOUNT_TYPE_SAMSUNG = "com.osp.app.signin"; + + public SamsungAccountType(Context context, String authenticatorPackageName, String type) { + this.accountType = type; + this.resourcePackageName = null; + this.syncAdapterPackageName = authenticatorPackageName; + + try { + addDataKindStructuredName(context); + addDataKindDisplayName(context); + addDataKindPhoneticName(context); + addDataKindNickname(context); + addDataKindPhone(context); + addDataKindEmail(context); + addDataKindStructuredPostal(context); + addDataKindIm(context); + addDataKindOrganization(context); + addDataKindPhoto(context); + addDataKindNote(context); + addDataKindWebsite(context); + addDataKindGroupMembership(context); + addDataKindRelation(context); + addDataKindEvent(context); + + mIsInitialized = true; + } catch (DefinitionException e) { + Log.e(TAG, "Problem building account type", e); + } + } + + /** + * Returns {@code TRUE} if this is samsung's account type and Samsung hasn't bothered to define a + * contacts.xml to provide a more accurate definition than ours. + */ + public static boolean isSamsungAccountType(Context context, String type, String packageName) { + return ACCOUNT_TYPE_SAMSUNG.equals(type) + && !ExternalAccountType.hasContactsXml(context, packageName); + } + + @Override + protected DataKind addDataKindStructuredPostal(Context context) throws DefinitionException { + final DataKind kind = super.addDataKindStructuredPostal(context); + + final boolean useJapaneseOrder = + Locale.JAPANESE.getLanguage().equals(Locale.getDefault().getLanguage()); + kind.typeColumn = StructuredPostal.TYPE; + kind.typeList = new ArrayList<>(); + kind.typeList.add(buildPostalType(StructuredPostal.TYPE_WORK).setSpecificMax(1)); + kind.typeList.add(buildPostalType(StructuredPostal.TYPE_HOME).setSpecificMax(1)); + kind.typeList.add(buildPostalType(StructuredPostal.TYPE_OTHER).setSpecificMax(1)); + + kind.fieldList = new ArrayList<>(); + if (useJapaneseOrder) { + kind.fieldList.add( + new EditField(StructuredPostal.COUNTRY, R.string.postal_country, FLAGS_POSTAL) + .setOptional(true)); + kind.fieldList.add( + new EditField(StructuredPostal.POSTCODE, R.string.postal_postcode, FLAGS_POSTAL)); + kind.fieldList.add( + new EditField(StructuredPostal.REGION, R.string.postal_region, FLAGS_POSTAL)); + kind.fieldList.add(new EditField(StructuredPostal.CITY, R.string.postal_city, FLAGS_POSTAL)); + kind.fieldList.add( + new EditField(StructuredPostal.STREET, R.string.postal_street, FLAGS_POSTAL)); + } else { + kind.fieldList.add( + new EditField(StructuredPostal.STREET, R.string.postal_street, FLAGS_POSTAL)); + kind.fieldList.add(new EditField(StructuredPostal.CITY, R.string.postal_city, FLAGS_POSTAL)); + kind.fieldList.add( + new EditField(StructuredPostal.REGION, R.string.postal_region, FLAGS_POSTAL)); + kind.fieldList.add( + new EditField(StructuredPostal.POSTCODE, R.string.postal_postcode, FLAGS_POSTAL)); + kind.fieldList.add( + new EditField(StructuredPostal.COUNTRY, R.string.postal_country, FLAGS_POSTAL) + .setOptional(true)); + } + + return kind; + } + + @Override + protected DataKind addDataKindPhone(Context context) throws DefinitionException { + final DataKind kind = super.addDataKindPhone(context); + + kind.typeColumn = Phone.TYPE; + kind.typeList = new ArrayList<>(); + kind.typeList.add(buildPhoneType(Phone.TYPE_MOBILE)); + kind.typeList.add(buildPhoneType(Phone.TYPE_HOME)); + kind.typeList.add(buildPhoneType(Phone.TYPE_WORK)); + kind.typeList.add(buildPhoneType(Phone.TYPE_MAIN)); + kind.typeList.add(buildPhoneType(Phone.TYPE_FAX_WORK).setSecondary(true)); + kind.typeList.add(buildPhoneType(Phone.TYPE_FAX_HOME).setSecondary(true)); + kind.typeList.add(buildPhoneType(Phone.TYPE_PAGER).setSecondary(true)); + kind.typeList.add(buildPhoneType(Phone.TYPE_RADIO).setSecondary(true)); + kind.typeList.add(buildPhoneType(Phone.TYPE_OTHER)); + kind.typeList.add( + buildPhoneType(Phone.TYPE_CUSTOM).setSecondary(true).setCustomColumn(Phone.LABEL)); + + kind.fieldList = new ArrayList<>(); + kind.fieldList.add(new EditField(Phone.NUMBER, R.string.phoneLabelsGroup, FLAGS_PHONE)); + + return kind; + } + + @Override + protected DataKind addDataKindEmail(Context context) throws DefinitionException { + final DataKind kind = super.addDataKindEmail(context); + + kind.typeColumn = Email.TYPE; + kind.typeList = new ArrayList<>(); + kind.typeList.add(buildEmailType(Email.TYPE_HOME)); + kind.typeList.add(buildEmailType(Email.TYPE_WORK)); + kind.typeList.add(buildEmailType(Email.TYPE_OTHER)); + kind.typeList.add( + buildEmailType(Email.TYPE_CUSTOM).setSecondary(true).setCustomColumn(Email.LABEL)); + + kind.fieldList = new ArrayList<>(); + kind.fieldList.add(new EditField(Email.DATA, R.string.emailLabelsGroup, FLAGS_EMAIL)); + + return kind; + } + + private DataKind addDataKindRelation(Context context) throws DefinitionException { + DataKind kind = + addKind(new DataKind(Relation.CONTENT_ITEM_TYPE, R.string.relationLabelsGroup, 160, true)); + kind.actionHeader = new RelationActionInflater(); + kind.actionBody = new SimpleInflater(Relation.NAME); + + kind.typeColumn = Relation.TYPE; + kind.typeList = new ArrayList<>(); + kind.typeList.add(buildRelationType(Relation.TYPE_ASSISTANT)); + kind.typeList.add(buildRelationType(Relation.TYPE_BROTHER)); + kind.typeList.add(buildRelationType(Relation.TYPE_CHILD)); + kind.typeList.add(buildRelationType(Relation.TYPE_DOMESTIC_PARTNER)); + kind.typeList.add(buildRelationType(Relation.TYPE_FATHER)); + kind.typeList.add(buildRelationType(Relation.TYPE_FRIEND)); + kind.typeList.add(buildRelationType(Relation.TYPE_MANAGER)); + kind.typeList.add(buildRelationType(Relation.TYPE_MOTHER)); + kind.typeList.add(buildRelationType(Relation.TYPE_PARENT)); + kind.typeList.add(buildRelationType(Relation.TYPE_PARTNER)); + kind.typeList.add(buildRelationType(Relation.TYPE_REFERRED_BY)); + kind.typeList.add(buildRelationType(Relation.TYPE_RELATIVE)); + kind.typeList.add(buildRelationType(Relation.TYPE_SISTER)); + kind.typeList.add(buildRelationType(Relation.TYPE_SPOUSE)); + kind.typeList.add( + buildRelationType(Relation.TYPE_CUSTOM).setSecondary(true).setCustomColumn(Relation.LABEL)); + + kind.defaultValues = new ContentValues(); + kind.defaultValues.put(Relation.TYPE, Relation.TYPE_SPOUSE); + + kind.fieldList = new ArrayList<>(); + kind.fieldList.add(new EditField(Relation.DATA, R.string.relationLabelsGroup, FLAGS_RELATION)); + + return kind; + } + + private DataKind addDataKindEvent(Context context) throws DefinitionException { + DataKind kind = + addKind(new DataKind(Event.CONTENT_ITEM_TYPE, R.string.eventLabelsGroup, 150, true)); + kind.actionHeader = new EventActionInflater(); + kind.actionBody = new SimpleInflater(Event.START_DATE); + + kind.typeColumn = Event.TYPE; + kind.typeList = new ArrayList<>(); + kind.dateFormatWithoutYear = CommonDateUtils.NO_YEAR_DATE_FORMAT; + kind.dateFormatWithYear = CommonDateUtils.FULL_DATE_FORMAT; + kind.typeList.add(buildEventType(Event.TYPE_BIRTHDAY, true).setSpecificMax(1)); + kind.typeList.add(buildEventType(Event.TYPE_ANNIVERSARY, false)); + kind.typeList.add(buildEventType(Event.TYPE_OTHER, false)); + kind.typeList.add( + buildEventType(Event.TYPE_CUSTOM, false).setSecondary(true).setCustomColumn(Event.LABEL)); + + kind.defaultValues = new ContentValues(); + kind.defaultValues.put(Event.TYPE, Event.TYPE_BIRTHDAY); + + kind.fieldList = new ArrayList<>(); + kind.fieldList.add(new EditField(Event.DATA, R.string.eventLabelsGroup, FLAGS_EVENT)); + + return kind; + } + + @Override + public boolean isGroupMembershipEditable() { + return true; + } + + @Override + public boolean areContactsWritable() { + return true; + } +} diff --git a/java/com/android/contacts/common/model/dataitem/DataItem.java b/java/com/android/contacts/common/model/dataitem/DataItem.java new file mode 100644 index 000000000..dc746055b --- /dev/null +++ b/java/com/android/contacts/common/model/dataitem/DataItem.java @@ -0,0 +1,258 @@ +/* + * Copyright (C) 2012 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.common.model.dataitem; + +import android.content.ContentValues; +import android.content.Context; +import android.provider.ContactsContract.CommonDataKinds.Email; +import android.provider.ContactsContract.CommonDataKinds.Event; +import android.provider.ContactsContract.CommonDataKinds.GroupMembership; +import android.provider.ContactsContract.CommonDataKinds.Identity; +import android.provider.ContactsContract.CommonDataKinds.Im; +import android.provider.ContactsContract.CommonDataKinds.Nickname; +import android.provider.ContactsContract.CommonDataKinds.Note; +import android.provider.ContactsContract.CommonDataKinds.Organization; +import android.provider.ContactsContract.CommonDataKinds.Phone; +import android.provider.ContactsContract.CommonDataKinds.Photo; +import android.provider.ContactsContract.CommonDataKinds.Relation; +import android.provider.ContactsContract.CommonDataKinds.SipAddress; +import android.provider.ContactsContract.CommonDataKinds.StructuredName; +import android.provider.ContactsContract.CommonDataKinds.StructuredPostal; +import android.provider.ContactsContract.CommonDataKinds.Website; +import android.provider.ContactsContract.Contacts.Data; +import android.provider.ContactsContract.Contacts.Entity; +import com.android.contacts.common.Collapser; +import com.android.contacts.common.MoreContactUtils; +import com.android.contacts.common.model.account.AccountType.EditType; + +/** This is the base class for data items, which represents a row from the Data table. */ +public class DataItem implements Collapser.Collapsible<DataItem> { + + private final ContentValues mContentValues; + protected DataKind mKind; + + protected DataItem(ContentValues values) { + mContentValues = values; + } + + /** + * Factory for creating subclasses of DataItem objects based on the mimetype in the content + * values. Raw contact is the raw contact that this data item is associated with. + */ + public static DataItem createFrom(ContentValues values) { + final String mimeType = values.getAsString(Data.MIMETYPE); + if (GroupMembership.CONTENT_ITEM_TYPE.equals(mimeType)) { + return new GroupMembershipDataItem(values); + } else if (StructuredName.CONTENT_ITEM_TYPE.equals(mimeType)) { + return new StructuredNameDataItem(values); + } else if (Phone.CONTENT_ITEM_TYPE.equals(mimeType)) { + return new PhoneDataItem(values); + } else if (Email.CONTENT_ITEM_TYPE.equals(mimeType)) { + return new EmailDataItem(values); + } else if (StructuredPostal.CONTENT_ITEM_TYPE.equals(mimeType)) { + return new StructuredPostalDataItem(values); + } else if (Im.CONTENT_ITEM_TYPE.equals(mimeType)) { + return new ImDataItem(values); + } else if (Organization.CONTENT_ITEM_TYPE.equals(mimeType)) { + return new OrganizationDataItem(values); + } else if (Nickname.CONTENT_ITEM_TYPE.equals(mimeType)) { + return new NicknameDataItem(values); + } else if (Note.CONTENT_ITEM_TYPE.equals(mimeType)) { + return new NoteDataItem(values); + } else if (Website.CONTENT_ITEM_TYPE.equals(mimeType)) { + return new WebsiteDataItem(values); + } else if (SipAddress.CONTENT_ITEM_TYPE.equals(mimeType)) { + return new SipAddressDataItem(values); + } else if (Event.CONTENT_ITEM_TYPE.equals(mimeType)) { + return new EventDataItem(values); + } else if (Relation.CONTENT_ITEM_TYPE.equals(mimeType)) { + return new RelationDataItem(values); + } else if (Identity.CONTENT_ITEM_TYPE.equals(mimeType)) { + return new IdentityDataItem(values); + } else if (Photo.CONTENT_ITEM_TYPE.equals(mimeType)) { + return new PhotoDataItem(values); + } + + // generic + return new DataItem(values); + } + + public ContentValues getContentValues() { + return mContentValues; + } + + public Long getRawContactId() { + return mContentValues.getAsLong(Data.RAW_CONTACT_ID); + } + + public void setRawContactId(long rawContactId) { + mContentValues.put(Data.RAW_CONTACT_ID, rawContactId); + } + + /** Returns the data id. */ + public long getId() { + return mContentValues.getAsLong(Data._ID); + } + + /** Returns the mimetype of the data. */ + public String getMimeType() { + return mContentValues.getAsString(Data.MIMETYPE); + } + + public void setMimeType(String mimeType) { + mContentValues.put(Data.MIMETYPE, mimeType); + } + + public boolean isPrimary() { + Integer primary = mContentValues.getAsInteger(Data.IS_PRIMARY); + return primary != null && primary != 0; + } + + public boolean isSuperPrimary() { + Integer superPrimary = mContentValues.getAsInteger(Data.IS_SUPER_PRIMARY); + return superPrimary != null && superPrimary != 0; + } + + public boolean hasKindTypeColumn(DataKind kind) { + final String key = kind.typeColumn; + return key != null + && mContentValues.containsKey(key) + && mContentValues.getAsInteger(key) != null; + } + + public int getKindTypeColumn(DataKind kind) { + final String key = kind.typeColumn; + return mContentValues.getAsInteger(key); + } + + /** + * Indicates the carrier presence value for the current {@link DataItem}. + * + * @return {@link Data#CARRIER_PRESENCE_VT_CAPABLE} if the {@link DataItem} supports carrier video + * calling, {@code 0} otherwise. + */ + public int getCarrierPresence() { + return mContentValues.getAsInteger(Data.CARRIER_PRESENCE); + } + + /** + * This builds the data string depending on the type of data item by using the generic DataKind + * object underneath. + */ + public String buildDataString(Context context, DataKind kind) { + if (kind.actionBody == null) { + return null; + } + CharSequence actionBody = kind.actionBody.inflateUsing(context, mContentValues); + return actionBody == null ? null : actionBody.toString(); + } + + /** + * This builds the data string(intended for display) depending on the type of data item. It + * returns the same value as {@link #buildDataString} by default, but certain data items can + * override it to provide their version of formatted data strings. + * + * @return Data string representing the data item, possibly formatted for display + */ + public String buildDataStringForDisplay(Context context, DataKind kind) { + return buildDataString(context, kind); + } + + public DataKind getDataKind() { + return mKind; + } + + public void setDataKind(DataKind kind) { + mKind = kind; + } + + public Integer getTimesUsed() { + return mContentValues.getAsInteger(Entity.TIMES_USED); + } + + public Long getLastTimeUsed() { + return mContentValues.getAsLong(Entity.LAST_TIME_USED); + } + + @Override + public void collapseWith(DataItem that) { + DataKind thisKind = getDataKind(); + DataKind thatKind = that.getDataKind(); + // If this does not have a type and that does, or if that's type is higher precedence, + // use that's type + if ((!hasKindTypeColumn(thisKind) && that.hasKindTypeColumn(thatKind)) + || (that.hasKindTypeColumn(thatKind) + && getTypePrecedence(thisKind, getKindTypeColumn(thisKind)) + > getTypePrecedence(thatKind, that.getKindTypeColumn(thatKind)))) { + mContentValues.put(thatKind.typeColumn, that.getKindTypeColumn(thatKind)); + mKind = thatKind; + } + + // Choose the max of the maxLines and maxLabelLines values. + mKind.maxLinesForDisplay = Math.max(thisKind.maxLinesForDisplay, thatKind.maxLinesForDisplay); + + // If any of the collapsed entries are super primary make the whole thing super primary. + if (isSuperPrimary() || that.isSuperPrimary()) { + mContentValues.put(Data.IS_SUPER_PRIMARY, 1); + mContentValues.put(Data.IS_PRIMARY, 1); + } + + // If any of the collapsed entries are primary make the whole thing primary. + if (isPrimary() || that.isPrimary()) { + mContentValues.put(Data.IS_PRIMARY, 1); + } + + // Add up the times used + mContentValues.put( + Entity.TIMES_USED, + (getTimesUsed() == null ? 0 : getTimesUsed()) + + (that.getTimesUsed() == null ? 0 : that.getTimesUsed())); + + // Use the most recent time + mContentValues.put( + Entity.LAST_TIME_USED, + Math.max( + getLastTimeUsed() == null ? 0 : getLastTimeUsed(), + that.getLastTimeUsed() == null ? 0 : that.getLastTimeUsed())); + } + + @Override + public boolean shouldCollapseWith(DataItem t, Context context) { + if (mKind == null || t.getDataKind() == null) { + return false; + } + return MoreContactUtils.shouldCollapse( + getMimeType(), + buildDataString(context, mKind), + t.getMimeType(), + t.buildDataString(context, t.getDataKind())); + } + + /** + * Return the precedence for the the given {@link EditType#rawValue}, where lower numbers are + * higher precedence. + */ + private static int getTypePrecedence(DataKind kind, int rawValue) { + for (int i = 0; i < kind.typeList.size(); i++) { + final EditType type = kind.typeList.get(i); + if (type.rawValue == rawValue) { + return i; + } + } + return Integer.MAX_VALUE; + } +} diff --git a/java/com/android/contacts/common/model/dataitem/DataKind.java b/java/com/android/contacts/common/model/dataitem/DataKind.java new file mode 100644 index 000000000..3b470a2ae --- /dev/null +++ b/java/com/android/contacts/common/model/dataitem/DataKind.java @@ -0,0 +1,132 @@ +/* + * Copyright (C) 2011 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.common.model.dataitem; + +import android.content.ContentValues; +import android.content.Context; +import android.provider.ContactsContract.Data; +import com.android.contacts.common.model.account.AccountType.EditField; +import com.android.contacts.common.model.account.AccountType.EditType; +import com.android.contacts.common.model.account.AccountType.StringInflater; +import com.google.common.collect.Iterators; +import java.text.SimpleDateFormat; +import java.util.List; + +/** + * Description of a specific data type, usually marked by a unique {@link Data#MIMETYPE}. Includes + * details about how to view and edit {@link Data} rows of this kind, including the possible {@link + * EditType} labels and editable {@link EditField}. + */ +public final class DataKind { + + public static final String PSEUDO_MIME_TYPE_DISPLAY_NAME = "#displayName"; + public static final String PSEUDO_MIME_TYPE_PHONETIC_NAME = "#phoneticName"; + public static final String PSEUDO_COLUMN_PHONETIC_NAME = "#phoneticName"; + + public String resourcePackageName; + public String mimeType; + public int titleRes; + public int iconAltRes; + public int iconAltDescriptionRes; + public int weight; + public boolean editable; + + public StringInflater actionHeader; + public StringInflater actionAltHeader; + public StringInflater actionBody; + + public String typeColumn; + + /** Maximum number of values allowed in the list. -1 represents infinity. */ + public int typeOverallMax; + + public List<EditType> typeList; + public List<EditField> fieldList; + + public ContentValues defaultValues; + + /** + * If this is a date field, this specifies the format of the date when saving. The date includes + * year, month and day. If this is not a date field or the date field is not editable, this value + * should be ignored. + */ + public SimpleDateFormat dateFormatWithoutYear; + + /** + * If this is a date field, this specifies the format of the date when saving. The date includes + * month and day. If this is not a date field, the field is not editable or dates without year are + * not supported, this value should be ignored. + */ + public SimpleDateFormat dateFormatWithYear; + + /** The number of lines available for displaying this kind of data. Defaults to 1. */ + public int maxLinesForDisplay; + + public DataKind() { + maxLinesForDisplay = 1; + } + + public DataKind(String mimeType, int titleRes, int weight, boolean editable) { + this.mimeType = mimeType; + this.titleRes = titleRes; + this.weight = weight; + this.editable = editable; + this.typeOverallMax = -1; + maxLinesForDisplay = 1; + } + + public static String toString(SimpleDateFormat format) { + return format == null ? "(null)" : format.toPattern(); + } + + public static String toString(Iterable<?> list) { + if (list == null) { + return "(null)"; + } else { + return Iterators.toString(list.iterator()); + } + } + + public String getKindString(Context context) { + return (titleRes == -1 || titleRes == 0) ? "" : context.getString(titleRes); + } + + @Override + public String toString() { + final StringBuilder sb = new StringBuilder(); + sb.append("DataKind:"); + sb.append(" resPackageName=").append(resourcePackageName); + sb.append(" mimeType=").append(mimeType); + sb.append(" titleRes=").append(titleRes); + sb.append(" iconAltRes=").append(iconAltRes); + sb.append(" iconAltDescriptionRes=").append(iconAltDescriptionRes); + sb.append(" weight=").append(weight); + sb.append(" editable=").append(editable); + sb.append(" actionHeader=").append(actionHeader); + sb.append(" actionAltHeader=").append(actionAltHeader); + sb.append(" actionBody=").append(actionBody); + sb.append(" typeColumn=").append(typeColumn); + sb.append(" typeOverallMax=").append(typeOverallMax); + sb.append(" typeList=").append(toString(typeList)); + sb.append(" fieldList=").append(toString(fieldList)); + sb.append(" defaultValues=").append(defaultValues); + sb.append(" dateFormatWithoutYear=").append(toString(dateFormatWithoutYear)); + sb.append(" dateFormatWithYear=").append(toString(dateFormatWithYear)); + + return sb.toString(); + } +} diff --git a/java/com/android/contacts/common/model/dataitem/EmailDataItem.java b/java/com/android/contacts/common/model/dataitem/EmailDataItem.java new file mode 100644 index 000000000..2fe297816 --- /dev/null +++ b/java/com/android/contacts/common/model/dataitem/EmailDataItem.java @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2012 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.common.model.dataitem; + +import android.content.ContentValues; +import android.provider.ContactsContract.CommonDataKinds.Email; + +/** + * Represents an email data item, wrapping the columns in {@link + * ContactsContract.CommonDataKinds.Email}. + */ +public class EmailDataItem extends DataItem { + + /* package */ EmailDataItem(ContentValues values) { + super(values); + } + + public String getAddress() { + return getContentValues().getAsString(Email.ADDRESS); + } + + public String getDisplayName() { + return getContentValues().getAsString(Email.DISPLAY_NAME); + } + + public String getData() { + return getContentValues().getAsString(Email.DATA); + } + + public String getLabel() { + return getContentValues().getAsString(Email.LABEL); + } +} diff --git a/java/com/android/contacts/common/model/dataitem/EventDataItem.java b/java/com/android/contacts/common/model/dataitem/EventDataItem.java new file mode 100644 index 000000000..15d9880b1 --- /dev/null +++ b/java/com/android/contacts/common/model/dataitem/EventDataItem.java @@ -0,0 +1,62 @@ +/* + * Copyright (C) 2012 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.common.model.dataitem; + +import android.content.ContentValues; +import android.content.Context; +import android.provider.ContactsContract.CommonDataKinds.Event; +import android.text.TextUtils; + +/** + * Represents an event data item, wrapping the columns in {@link + * ContactsContract.CommonDataKinds.Event}. + */ +public class EventDataItem extends DataItem { + + /* package */ EventDataItem(ContentValues values) { + super(values); + } + + public String getStartDate() { + return getContentValues().getAsString(Event.START_DATE); + } + + public String getLabel() { + return getContentValues().getAsString(Event.LABEL); + } + + @Override + public boolean shouldCollapseWith(DataItem t, Context context) { + if (!(t instanceof EventDataItem) || mKind == null || t.getDataKind() == null) { + return false; + } + final EventDataItem that = (EventDataItem) t; + // Events can be different (anniversary, birthday) but have the same start date + if (!TextUtils.equals(getStartDate(), that.getStartDate())) { + return false; + } else if (!hasKindTypeColumn(mKind) || !that.hasKindTypeColumn(that.getDataKind())) { + return hasKindTypeColumn(mKind) == that.hasKindTypeColumn(that.getDataKind()); + } else if (getKindTypeColumn(mKind) != that.getKindTypeColumn(that.getDataKind())) { + return false; + } else if (getKindTypeColumn(mKind) == Event.TYPE_CUSTOM + && !TextUtils.equals(getLabel(), that.getLabel())) { + // Check if custom types are not the same + return false; + } + return true; + } +} diff --git a/java/com/android/contacts/common/model/dataitem/GroupMembershipDataItem.java b/java/com/android/contacts/common/model/dataitem/GroupMembershipDataItem.java new file mode 100644 index 000000000..f921b3c9d --- /dev/null +++ b/java/com/android/contacts/common/model/dataitem/GroupMembershipDataItem.java @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2012 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.common.model.dataitem; + +import android.content.ContentValues; +import android.provider.ContactsContract; +import android.provider.ContactsContract.CommonDataKinds.GroupMembership; + +/** + * Represents a group memebership data item, wrapping the columns in {@link + * ContactsContract.CommonDataKinds.GroupMembership}. + */ +public class GroupMembershipDataItem extends DataItem { + + /* package */ GroupMembershipDataItem(ContentValues values) { + super(values); + } + + public Long getGroupRowId() { + return getContentValues().getAsLong(GroupMembership.GROUP_ROW_ID); + } + + public String getGroupSourceId() { + return getContentValues().getAsString(GroupMembership.GROUP_SOURCE_ID); + } +} diff --git a/java/com/android/contacts/common/model/dataitem/IdentityDataItem.java b/java/com/android/contacts/common/model/dataitem/IdentityDataItem.java new file mode 100644 index 000000000..2badf92f7 --- /dev/null +++ b/java/com/android/contacts/common/model/dataitem/IdentityDataItem.java @@ -0,0 +1,39 @@ +/* + * Copyright (C) 2012 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.common.model.dataitem; + +import android.content.ContentValues; +import android.provider.ContactsContract.CommonDataKinds.Identity; + +/** + * Represents an identity data item, wrapping the columns in {@link + * ContactsContract.CommonDataKinds.Identity}. + */ +public class IdentityDataItem extends DataItem { + + /* package */ IdentityDataItem(ContentValues values) { + super(values); + } + + public String getIdentity() { + return getContentValues().getAsString(Identity.IDENTITY); + } + + public String getNamespace() { + return getContentValues().getAsString(Identity.NAMESPACE); + } +} diff --git a/java/com/android/contacts/common/model/dataitem/ImDataItem.java b/java/com/android/contacts/common/model/dataitem/ImDataItem.java new file mode 100644 index 000000000..16b9fd094 --- /dev/null +++ b/java/com/android/contacts/common/model/dataitem/ImDataItem.java @@ -0,0 +1,109 @@ +/* + * Copyright (C) 2012 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.common.model.dataitem; + +import android.content.ContentValues; +import android.content.Context; +import android.provider.ContactsContract.CommonDataKinds.Email; +import android.provider.ContactsContract.CommonDataKinds.Im; +import android.text.TextUtils; + +/** + * Represents an IM data item, wrapping the columns in {@link ContactsContract.CommonDataKinds.Im}. + */ +public class ImDataItem extends DataItem { + + private final boolean mCreatedFromEmail; + + /* package */ ImDataItem(ContentValues values) { + super(values); + mCreatedFromEmail = false; + } + + private ImDataItem(ContentValues values, boolean createdFromEmail) { + super(values); + mCreatedFromEmail = createdFromEmail; + } + + public static ImDataItem createFromEmail(EmailDataItem item) { + final ImDataItem im = new ImDataItem(new ContentValues(item.getContentValues()), true); + im.setMimeType(Im.CONTENT_ITEM_TYPE); + return im; + } + + public String getData() { + if (mCreatedFromEmail) { + return getContentValues().getAsString(Email.DATA); + } else { + return getContentValues().getAsString(Im.DATA); + } + } + + public String getLabel() { + return getContentValues().getAsString(Im.LABEL); + } + + /** Values are one of Im.PROTOCOL_ */ + public Integer getProtocol() { + return getContentValues().getAsInteger(Im.PROTOCOL); + } + + public boolean isProtocolValid() { + return getProtocol() != null; + } + + public String getCustomProtocol() { + return getContentValues().getAsString(Im.CUSTOM_PROTOCOL); + } + + public int getChatCapability() { + Integer result = getContentValues().getAsInteger(Im.CHAT_CAPABILITY); + return result == null ? 0 : result; + } + + public boolean isCreatedFromEmail() { + return mCreatedFromEmail; + } + + @Override + public boolean shouldCollapseWith(DataItem t, Context context) { + if (!(t instanceof ImDataItem) || mKind == null || t.getDataKind() == null) { + return false; + } + final ImDataItem that = (ImDataItem) t; + // IM can have the same data put different protocol. These should not collapse. + if (!getData().equals(that.getData())) { + return false; + } else if (!isProtocolValid() || !that.isProtocolValid()) { + // Deal with invalid protocol as if it was custom. If either has a non valid + // protocol, check to see if the other has a valid that is not custom + if (isProtocolValid()) { + return getProtocol() == Im.PROTOCOL_CUSTOM; + } else if (that.isProtocolValid()) { + return that.getProtocol() == Im.PROTOCOL_CUSTOM; + } + return true; + } else if (getProtocol() != that.getProtocol()) { + return false; + } else if (getProtocol() == Im.PROTOCOL_CUSTOM + && !TextUtils.equals(getCustomProtocol(), that.getCustomProtocol())) { + // Check if custom protocols are not the same + return false; + } + return true; + } +} diff --git a/java/com/android/contacts/common/model/dataitem/NicknameDataItem.java b/java/com/android/contacts/common/model/dataitem/NicknameDataItem.java new file mode 100644 index 000000000..a448be786 --- /dev/null +++ b/java/com/android/contacts/common/model/dataitem/NicknameDataItem.java @@ -0,0 +1,39 @@ +/* + * Copyright (C) 2012 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.common.model.dataitem; + +import android.content.ContentValues; +import android.provider.ContactsContract.CommonDataKinds.Nickname; + +/** + * Represents a nickname data item, wrapping the columns in {@link + * ContactsContract.CommonDataKinds.Nickname}. + */ +public class NicknameDataItem extends DataItem { + + public NicknameDataItem(ContentValues values) { + super(values); + } + + public String getName() { + return getContentValues().getAsString(Nickname.NAME); + } + + public String getLabel() { + return getContentValues().getAsString(Nickname.LABEL); + } +} diff --git a/java/com/android/contacts/common/model/dataitem/NoteDataItem.java b/java/com/android/contacts/common/model/dataitem/NoteDataItem.java new file mode 100644 index 000000000..b55ecc3e5 --- /dev/null +++ b/java/com/android/contacts/common/model/dataitem/NoteDataItem.java @@ -0,0 +1,35 @@ +/* + * Copyright (C) 2012 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.common.model.dataitem; + +import android.content.ContentValues; +import android.provider.ContactsContract.CommonDataKinds.Note; + +/** + * Represents a note data item, wrapping the columns in {@link + * ContactsContract.CommonDataKinds.Note}. + */ +public class NoteDataItem extends DataItem { + + /* package */ NoteDataItem(ContentValues values) { + super(values); + } + + public String getNote() { + return getContentValues().getAsString(Note.NOTE); + } +} diff --git a/java/com/android/contacts/common/model/dataitem/OrganizationDataItem.java b/java/com/android/contacts/common/model/dataitem/OrganizationDataItem.java new file mode 100644 index 000000000..b33124838 --- /dev/null +++ b/java/com/android/contacts/common/model/dataitem/OrganizationDataItem.java @@ -0,0 +1,64 @@ +/* + * Copyright (C) 2012 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.common.model.dataitem; + +import android.content.ContentValues; +import android.provider.ContactsContract; +import android.provider.ContactsContract.CommonDataKinds.Organization; + +/** + * Represents an organization data item, wrapping the columns in {@link + * ContactsContract.CommonDataKinds.Organization}. + */ +public class OrganizationDataItem extends DataItem { + + /* package */ OrganizationDataItem(ContentValues values) { + super(values); + } + + public String getCompany() { + return getContentValues().getAsString(Organization.COMPANY); + } + + public String getLabel() { + return getContentValues().getAsString(Organization.LABEL); + } + + public String getTitle() { + return getContentValues().getAsString(Organization.TITLE); + } + + public String getDepartment() { + return getContentValues().getAsString(Organization.DEPARTMENT); + } + + public String getJobDescription() { + return getContentValues().getAsString(Organization.JOB_DESCRIPTION); + } + + public String getSymbol() { + return getContentValues().getAsString(Organization.SYMBOL); + } + + public String getPhoneticName() { + return getContentValues().getAsString(Organization.PHONETIC_NAME); + } + + public String getOfficeLocation() { + return getContentValues().getAsString(Organization.OFFICE_LOCATION); + } +} diff --git a/java/com/android/contacts/common/model/dataitem/PhoneDataItem.java b/java/com/android/contacts/common/model/dataitem/PhoneDataItem.java new file mode 100644 index 000000000..e1f56456a --- /dev/null +++ b/java/com/android/contacts/common/model/dataitem/PhoneDataItem.java @@ -0,0 +1,76 @@ +/* + * Copyright (C) 2012 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.common.model.dataitem; + +import android.content.ContentValues; +import android.content.Context; +import android.provider.ContactsContract.CommonDataKinds.Phone; +import com.android.contacts.common.compat.PhoneNumberUtilsCompat; + +/** + * Represents a phone data item, wrapping the columns in {@link + * ContactsContract.CommonDataKinds.Phone}. + */ +public class PhoneDataItem extends DataItem { + + public static final String KEY_FORMATTED_PHONE_NUMBER = "formattedPhoneNumber"; + + /* package */ PhoneDataItem(ContentValues values) { + super(values); + } + + public String getNumber() { + return getContentValues().getAsString(Phone.NUMBER); + } + + /** Returns the normalized phone number in E164 format. */ + public String getNormalizedNumber() { + return getContentValues().getAsString(Phone.NORMALIZED_NUMBER); + } + + public String getFormattedPhoneNumber() { + return getContentValues().getAsString(KEY_FORMATTED_PHONE_NUMBER); + } + + public String getLabel() { + return getContentValues().getAsString(Phone.LABEL); + } + + public void computeFormattedPhoneNumber(String defaultCountryIso) { + final String phoneNumber = getNumber(); + if (phoneNumber != null) { + final String formattedPhoneNumber = + PhoneNumberUtilsCompat.formatNumber( + phoneNumber, getNormalizedNumber(), defaultCountryIso); + getContentValues().put(KEY_FORMATTED_PHONE_NUMBER, formattedPhoneNumber); + } + } + + /** + * Returns the formatted phone number (if already computed using {@link + * #computeFormattedPhoneNumber}). Otherwise this method returns the unformatted phone number. + */ + @Override + public String buildDataStringForDisplay(Context context, DataKind kind) { + final String formatted = getFormattedPhoneNumber(); + if (formatted != null) { + return formatted; + } else { + return getNumber(); + } + } +} diff --git a/java/com/android/contacts/common/model/dataitem/PhotoDataItem.java b/java/com/android/contacts/common/model/dataitem/PhotoDataItem.java new file mode 100644 index 000000000..0bf7a318b --- /dev/null +++ b/java/com/android/contacts/common/model/dataitem/PhotoDataItem.java @@ -0,0 +1,39 @@ +/* + * Copyright (C) 2012 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.common.model.dataitem; + +import android.content.ContentValues; +import android.provider.ContactsContract; +import android.provider.ContactsContract.Contacts.Photo; + +/** + * Represents a photo data item, wrapping the columns in {@link ContactsContract.Contacts.Photo}. + */ +public class PhotoDataItem extends DataItem { + + /* package */ PhotoDataItem(ContentValues values) { + super(values); + } + + public Long getPhotoFileId() { + return getContentValues().getAsLong(Photo.PHOTO_FILE_ID); + } + + public byte[] getPhoto() { + return getContentValues().getAsByteArray(Photo.PHOTO); + } +} diff --git a/java/com/android/contacts/common/model/dataitem/RelationDataItem.java b/java/com/android/contacts/common/model/dataitem/RelationDataItem.java new file mode 100644 index 000000000..fdbcbb313 --- /dev/null +++ b/java/com/android/contacts/common/model/dataitem/RelationDataItem.java @@ -0,0 +1,62 @@ +/* + * Copyright (C) 2012 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.common.model.dataitem; + +import android.content.ContentValues; +import android.content.Context; +import android.provider.ContactsContract.CommonDataKinds.Relation; +import android.text.TextUtils; + +/** + * Represents a relation data item, wrapping the columns in {@link + * ContactsContract.CommonDataKinds.Relation}. + */ +public class RelationDataItem extends DataItem { + + /* package */ RelationDataItem(ContentValues values) { + super(values); + } + + public String getName() { + return getContentValues().getAsString(Relation.NAME); + } + + public String getLabel() { + return getContentValues().getAsString(Relation.LABEL); + } + + @Override + public boolean shouldCollapseWith(DataItem t, Context context) { + if (!(t instanceof RelationDataItem) || mKind == null || t.getDataKind() == null) { + return false; + } + final RelationDataItem that = (RelationDataItem) t; + // Relations can have different types (assistant, father) but have the same name + if (!TextUtils.equals(getName(), that.getName())) { + return false; + } else if (!hasKindTypeColumn(mKind) || !that.hasKindTypeColumn(that.getDataKind())) { + return hasKindTypeColumn(mKind) == that.hasKindTypeColumn(that.getDataKind()); + } else if (getKindTypeColumn(mKind) != that.getKindTypeColumn(that.getDataKind())) { + return false; + } else if (getKindTypeColumn(mKind) == Relation.TYPE_CUSTOM + && !TextUtils.equals(getLabel(), that.getLabel())) { + // Check if custom types are not the same + return false; + } + return true; + } +} diff --git a/java/com/android/contacts/common/model/dataitem/SipAddressDataItem.java b/java/com/android/contacts/common/model/dataitem/SipAddressDataItem.java new file mode 100644 index 000000000..0ca9eae6d --- /dev/null +++ b/java/com/android/contacts/common/model/dataitem/SipAddressDataItem.java @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2012 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.common.model.dataitem; + +import android.content.ContentValues; +import android.provider.ContactsContract; +import android.provider.ContactsContract.CommonDataKinds.SipAddress; + +/** + * Represents a sip address data item, wrapping the columns in {@link + * ContactsContract.CommonDataKinds.SipAddress}. + */ +public class SipAddressDataItem extends DataItem { + + /* package */ SipAddressDataItem(ContentValues values) { + super(values); + } + + public String getSipAddress() { + return getContentValues().getAsString(SipAddress.SIP_ADDRESS); + } + + public String getLabel() { + return getContentValues().getAsString(SipAddress.LABEL); + } +} diff --git a/java/com/android/contacts/common/model/dataitem/StructuredNameDataItem.java b/java/com/android/contacts/common/model/dataitem/StructuredNameDataItem.java new file mode 100644 index 000000000..22bf037f1 --- /dev/null +++ b/java/com/android/contacts/common/model/dataitem/StructuredNameDataItem.java @@ -0,0 +1,100 @@ +/* + * Copyright (C) 2012 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.common.model.dataitem; + +import android.content.ContentValues; +import android.provider.ContactsContract.CommonDataKinds.StructuredName; +import android.provider.ContactsContract.Contacts.Data; + +/** + * Represents a structured name data item, wrapping the columns in {@link + * ContactsContract.CommonDataKinds.StructuredName}. + */ +public class StructuredNameDataItem extends DataItem { + + public StructuredNameDataItem() { + super(new ContentValues()); + getContentValues().put(Data.MIMETYPE, StructuredName.CONTENT_ITEM_TYPE); + } + + /* package */ StructuredNameDataItem(ContentValues values) { + super(values); + } + + public String getDisplayName() { + return getContentValues().getAsString(StructuredName.DISPLAY_NAME); + } + + public void setDisplayName(String name) { + getContentValues().put(StructuredName.DISPLAY_NAME, name); + } + + public String getGivenName() { + return getContentValues().getAsString(StructuredName.GIVEN_NAME); + } + + public String getFamilyName() { + return getContentValues().getAsString(StructuredName.FAMILY_NAME); + } + + public String getPrefix() { + return getContentValues().getAsString(StructuredName.PREFIX); + } + + public String getMiddleName() { + return getContentValues().getAsString(StructuredName.MIDDLE_NAME); + } + + public String getSuffix() { + return getContentValues().getAsString(StructuredName.SUFFIX); + } + + public String getPhoneticGivenName() { + return getContentValues().getAsString(StructuredName.PHONETIC_GIVEN_NAME); + } + + public void setPhoneticGivenName(String name) { + getContentValues().put(StructuredName.PHONETIC_GIVEN_NAME, name); + } + + public String getPhoneticMiddleName() { + return getContentValues().getAsString(StructuredName.PHONETIC_MIDDLE_NAME); + } + + public void setPhoneticMiddleName(String name) { + getContentValues().put(StructuredName.PHONETIC_MIDDLE_NAME, name); + } + + public String getPhoneticFamilyName() { + return getContentValues().getAsString(StructuredName.PHONETIC_FAMILY_NAME); + } + + public void setPhoneticFamilyName(String name) { + getContentValues().put(StructuredName.PHONETIC_FAMILY_NAME, name); + } + + public String getFullNameStyle() { + return getContentValues().getAsString(StructuredName.FULL_NAME_STYLE); + } + + public boolean isSuperPrimary() { + final ContentValues contentValues = getContentValues(); + return contentValues == null || !contentValues.containsKey(StructuredName.IS_SUPER_PRIMARY) + ? false + : contentValues.getAsBoolean(StructuredName.IS_SUPER_PRIMARY); + } +} diff --git a/java/com/android/contacts/common/model/dataitem/StructuredPostalDataItem.java b/java/com/android/contacts/common/model/dataitem/StructuredPostalDataItem.java new file mode 100644 index 000000000..18aae282c --- /dev/null +++ b/java/com/android/contacts/common/model/dataitem/StructuredPostalDataItem.java @@ -0,0 +1,68 @@ +/* + * Copyright (C) 2012 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.common.model.dataitem; + +import android.content.ContentValues; +import android.provider.ContactsContract; +import android.provider.ContactsContract.CommonDataKinds.StructuredPostal; + +/** + * Represents a structured postal data item, wrapping the columns in {@link + * ContactsContract.CommonDataKinds.StructuredPostal}. + */ +public class StructuredPostalDataItem extends DataItem { + + /* package */ StructuredPostalDataItem(ContentValues values) { + super(values); + } + + public String getFormattedAddress() { + return getContentValues().getAsString(StructuredPostal.FORMATTED_ADDRESS); + } + + public String getLabel() { + return getContentValues().getAsString(StructuredPostal.LABEL); + } + + public String getStreet() { + return getContentValues().getAsString(StructuredPostal.STREET); + } + + public String getPOBox() { + return getContentValues().getAsString(StructuredPostal.POBOX); + } + + public String getNeighborhood() { + return getContentValues().getAsString(StructuredPostal.NEIGHBORHOOD); + } + + public String getCity() { + return getContentValues().getAsString(StructuredPostal.CITY); + } + + public String getRegion() { + return getContentValues().getAsString(StructuredPostal.REGION); + } + + public String getPostcode() { + return getContentValues().getAsString(StructuredPostal.POSTCODE); + } + + public String getCountry() { + return getContentValues().getAsString(StructuredPostal.COUNTRY); + } +} diff --git a/java/com/android/contacts/common/model/dataitem/WebsiteDataItem.java b/java/com/android/contacts/common/model/dataitem/WebsiteDataItem.java new file mode 100644 index 000000000..b8400ecd1 --- /dev/null +++ b/java/com/android/contacts/common/model/dataitem/WebsiteDataItem.java @@ -0,0 +1,39 @@ +/* + * Copyright (C) 2012 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.common.model.dataitem; + +import android.content.ContentValues; +import android.provider.ContactsContract.CommonDataKinds.Website; + +/** + * Represents a website data item, wrapping the columns in {@link + * ContactsContract.CommonDataKinds.Website}. + */ +public class WebsiteDataItem extends DataItem { + + /* package */ WebsiteDataItem(ContentValues values) { + super(values); + } + + public String getUrl() { + return getContentValues().getAsString(Website.URL); + } + + public String getLabel() { + return getContentValues().getAsString(Website.LABEL); + } +} diff --git a/java/com/android/contacts/common/preference/ContactsPreferences.java b/java/com/android/contacts/common/preference/ContactsPreferences.java new file mode 100644 index 000000000..7f0d99acd --- /dev/null +++ b/java/com/android/contacts/common/preference/ContactsPreferences.java @@ -0,0 +1,269 @@ +/* + * Copyright (C) 2010 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.common.preference; + +import android.content.Context; +import android.content.SharedPreferences; +import android.content.SharedPreferences.Editor; +import android.content.SharedPreferences.OnSharedPreferenceChangeListener; +import android.os.Handler; +import android.preference.PreferenceManager; +import android.provider.Settings; +import android.provider.Settings.SettingNotFoundException; +import android.text.TextUtils; +import com.android.contacts.common.R; +import com.android.contacts.common.model.account.AccountWithDataSet; + +/** Manages user preferences for contacts. */ +public class ContactsPreferences implements OnSharedPreferenceChangeListener { + + /** The value for the DISPLAY_ORDER key to show the given name first. */ + public static final int DISPLAY_ORDER_PRIMARY = 1; + + /** The value for the DISPLAY_ORDER key to show the family name first. */ + public static final int DISPLAY_ORDER_ALTERNATIVE = 2; + + public static final String DISPLAY_ORDER_KEY = "android.contacts.DISPLAY_ORDER"; + + /** The value for the SORT_ORDER key corresponding to sort by given name first. */ + public static final int SORT_ORDER_PRIMARY = 1; + + public static final String SORT_ORDER_KEY = "android.contacts.SORT_ORDER"; + + /** The value for the SORT_ORDER key corresponding to sort by family name first. */ + public static final int SORT_ORDER_ALTERNATIVE = 2; + + public static final String PREF_DISPLAY_ONLY_PHONES = "only_phones"; + + public static final boolean PREF_DISPLAY_ONLY_PHONES_DEFAULT = false; + + /** + * Value to use when a preference is unassigned and needs to be read from the shared preferences + */ + private static final int PREFERENCE_UNASSIGNED = -1; + + private final Context mContext; + private final SharedPreferences mPreferences; + private int mSortOrder = PREFERENCE_UNASSIGNED; + private int mDisplayOrder = PREFERENCE_UNASSIGNED; + private String mDefaultAccount = null; + private ChangeListener mListener = null; + private Handler mHandler; + private String mDefaultAccountKey; + private String mDefaultAccountSavedKey; + + public ContactsPreferences(Context context) { + mContext = context; + mHandler = new Handler(); + mPreferences = mContext.getSharedPreferences(context.getPackageName(), Context.MODE_PRIVATE); + mDefaultAccountKey = + mContext.getResources().getString(R.string.contact_editor_default_account_key); + mDefaultAccountSavedKey = + mContext.getResources().getString(R.string.contact_editor_anything_saved_key); + maybeMigrateSystemSettings(); + } + + public boolean isSortOrderUserChangeable() { + return mContext.getResources().getBoolean(R.bool.config_sort_order_user_changeable); + } + + public int getDefaultSortOrder() { + if (mContext.getResources().getBoolean(R.bool.config_default_sort_order_primary)) { + return SORT_ORDER_PRIMARY; + } else { + return SORT_ORDER_ALTERNATIVE; + } + } + + public int getSortOrder() { + if (!isSortOrderUserChangeable()) { + return getDefaultSortOrder(); + } + if (mSortOrder == PREFERENCE_UNASSIGNED) { + mSortOrder = mPreferences.getInt(SORT_ORDER_KEY, getDefaultSortOrder()); + } + return mSortOrder; + } + + public void setSortOrder(int sortOrder) { + mSortOrder = sortOrder; + final Editor editor = mPreferences.edit(); + editor.putInt(SORT_ORDER_KEY, sortOrder); + editor.commit(); + } + + public boolean isDisplayOrderUserChangeable() { + return mContext.getResources().getBoolean(R.bool.config_display_order_user_changeable); + } + + public int getDefaultDisplayOrder() { + if (mContext.getResources().getBoolean(R.bool.config_default_display_order_primary)) { + return DISPLAY_ORDER_PRIMARY; + } else { + return DISPLAY_ORDER_ALTERNATIVE; + } + } + + public int getDisplayOrder() { + if (!isDisplayOrderUserChangeable()) { + return getDefaultDisplayOrder(); + } + if (mDisplayOrder == PREFERENCE_UNASSIGNED) { + mDisplayOrder = mPreferences.getInt(DISPLAY_ORDER_KEY, getDefaultDisplayOrder()); + } + return mDisplayOrder; + } + + public void setDisplayOrder(int displayOrder) { + mDisplayOrder = displayOrder; + final Editor editor = mPreferences.edit(); + editor.putInt(DISPLAY_ORDER_KEY, displayOrder); + editor.commit(); + } + + public boolean isDefaultAccountUserChangeable() { + return mContext.getResources().getBoolean(R.bool.config_default_account_user_changeable); + } + + public String getDefaultAccount() { + if (!isDefaultAccountUserChangeable()) { + return mDefaultAccount; + } + if (TextUtils.isEmpty(mDefaultAccount)) { + final String accountString = mPreferences.getString(mDefaultAccountKey, mDefaultAccount); + if (!TextUtils.isEmpty(accountString)) { + final AccountWithDataSet accountWithDataSet = AccountWithDataSet.unstringify(accountString); + mDefaultAccount = accountWithDataSet.name; + } + } + return mDefaultAccount; + } + + public void setDefaultAccount(AccountWithDataSet accountWithDataSet) { + mDefaultAccount = accountWithDataSet == null ? null : accountWithDataSet.name; + final Editor editor = mPreferences.edit(); + if (TextUtils.isEmpty(mDefaultAccount)) { + editor.remove(mDefaultAccountKey); + } else { + editor.putString(mDefaultAccountKey, accountWithDataSet.stringify()); + } + editor.putBoolean(mDefaultAccountSavedKey, true); + editor.commit(); + } + + public void registerChangeListener(ChangeListener listener) { + if (mListener != null) { + unregisterChangeListener(); + } + + mListener = listener; + + // Reset preferences to "unknown" because they may have changed while the + // listener was unregistered. + mDisplayOrder = PREFERENCE_UNASSIGNED; + mSortOrder = PREFERENCE_UNASSIGNED; + mDefaultAccount = null; + + mPreferences.registerOnSharedPreferenceChangeListener(this); + } + + public void unregisterChangeListener() { + if (mListener != null) { + mListener = null; + } + + mPreferences.unregisterOnSharedPreferenceChangeListener(this); + } + + @Override + public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, final String key) { + // This notification is not sent on the Ui thread. Use the previously created Handler + // to switch to the Ui thread + mHandler.post( + new Runnable() { + @Override + public void run() { + refreshValue(key); + } + }); + } + + /** + * Forces the value for the given key to be looked up from shared preferences and notifies the + * registered {@link ChangeListener} + * + * @param key the {@link SharedPreferences} key to look up + */ + public void refreshValue(String key) { + if (DISPLAY_ORDER_KEY.equals(key)) { + mDisplayOrder = PREFERENCE_UNASSIGNED; + mDisplayOrder = getDisplayOrder(); + } else if (SORT_ORDER_KEY.equals(key)) { + mSortOrder = PREFERENCE_UNASSIGNED; + mSortOrder = getSortOrder(); + } else if (mDefaultAccountKey.equals(key)) { + mDefaultAccount = null; + mDefaultAccount = getDefaultAccount(); + } + if (mListener != null) { + mListener.onChange(); + } + } + + /** + * If there are currently no preferences (which means this is the first time we are run), For sort + * order and display order, check to see if there are any preferences stored in system settings + * (pre-L) which can be copied into our own SharedPreferences. For default account setting, check + * to see if there are any preferences stored in the previous SharedPreferences which can be + * copied into current SharedPreferences. + */ + private void maybeMigrateSystemSettings() { + if (!mPreferences.contains(SORT_ORDER_KEY)) { + int sortOrder = getDefaultSortOrder(); + try { + sortOrder = Settings.System.getInt(mContext.getContentResolver(), SORT_ORDER_KEY); + } catch (SettingNotFoundException e) { + } + setSortOrder(sortOrder); + } + + if (!mPreferences.contains(DISPLAY_ORDER_KEY)) { + int displayOrder = getDefaultDisplayOrder(); + try { + displayOrder = Settings.System.getInt(mContext.getContentResolver(), DISPLAY_ORDER_KEY); + } catch (SettingNotFoundException e) { + } + setDisplayOrder(displayOrder); + } + + if (!mPreferences.contains(mDefaultAccountKey)) { + final SharedPreferences previousPrefs = + PreferenceManager.getDefaultSharedPreferences(mContext); + final String defaultAccount = previousPrefs.getString(mDefaultAccountKey, null); + if (!TextUtils.isEmpty(defaultAccount)) { + final AccountWithDataSet accountWithDataSet = + AccountWithDataSet.unstringify(defaultAccount); + setDefaultAccount(accountWithDataSet); + } + } + } + + public interface ChangeListener { + + void onChange(); + } +} diff --git a/java/com/android/contacts/common/preference/DisplayOrderPreference.java b/java/com/android/contacts/common/preference/DisplayOrderPreference.java new file mode 100644 index 000000000..8dda57f9f --- /dev/null +++ b/java/com/android/contacts/common/preference/DisplayOrderPreference.java @@ -0,0 +1,89 @@ +/* + * Copyright (C) 2010 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.common.preference; + +import android.app.AlertDialog.Builder; +import android.content.Context; +import android.preference.ListPreference; +import android.util.AttributeSet; +import com.android.contacts.common.R; + +/** Custom preference: view-name-as (first name first or last name first). */ +public final class DisplayOrderPreference extends ListPreference { + + private ContactsPreferences mPreferences; + private Context mContext; + + public DisplayOrderPreference(Context context) { + super(context); + prepare(); + } + + public DisplayOrderPreference(Context context, AttributeSet attrs) { + super(context, attrs); + prepare(); + } + + private void prepare() { + mContext = getContext(); + mPreferences = new ContactsPreferences(mContext); + setEntries( + new String[] { + mContext.getString(R.string.display_options_view_given_name_first), + mContext.getString(R.string.display_options_view_family_name_first), + }); + setEntryValues( + new String[] { + String.valueOf(ContactsPreferences.DISPLAY_ORDER_PRIMARY), + String.valueOf(ContactsPreferences.DISPLAY_ORDER_ALTERNATIVE), + }); + setValue(String.valueOf(mPreferences.getDisplayOrder())); + } + + @Override + protected boolean shouldPersist() { + return false; // This preference takes care of its own storage + } + + @Override + public CharSequence getSummary() { + switch (mPreferences.getDisplayOrder()) { + case ContactsPreferences.DISPLAY_ORDER_PRIMARY: + return mContext.getString(R.string.display_options_view_given_name_first); + case ContactsPreferences.DISPLAY_ORDER_ALTERNATIVE: + return mContext.getString(R.string.display_options_view_family_name_first); + } + return null; + } + + @Override + protected boolean persistString(String value) { + int newValue = Integer.parseInt(value); + if (newValue != mPreferences.getDisplayOrder()) { + mPreferences.setDisplayOrder(newValue); + notifyChanged(); + } + return true; + } + + @Override + // UX recommendation is not to show cancel button on such lists. + protected void onPrepareDialogBuilder(Builder builder) { + super.onPrepareDialogBuilder(builder); + builder.setNegativeButton(null, null); + } +} diff --git a/java/com/android/contacts/common/preference/SortOrderPreference.java b/java/com/android/contacts/common/preference/SortOrderPreference.java new file mode 100644 index 000000000..9b6f57860 --- /dev/null +++ b/java/com/android/contacts/common/preference/SortOrderPreference.java @@ -0,0 +1,89 @@ +/* + * Copyright (C) 2010 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.common.preference; + +import android.app.AlertDialog.Builder; +import android.content.Context; +import android.preference.ListPreference; +import android.util.AttributeSet; +import com.android.contacts.common.R; + +/** Custom preference: sort-by. */ +public final class SortOrderPreference extends ListPreference { + + private ContactsPreferences mPreferences; + private Context mContext; + + public SortOrderPreference(Context context) { + super(context); + prepare(); + } + + public SortOrderPreference(Context context, AttributeSet attrs) { + super(context, attrs); + prepare(); + } + + private void prepare() { + mContext = getContext(); + mPreferences = new ContactsPreferences(mContext); + setEntries( + new String[] { + mContext.getString(R.string.display_options_sort_by_given_name), + mContext.getString(R.string.display_options_sort_by_family_name), + }); + setEntryValues( + new String[] { + String.valueOf(ContactsPreferences.SORT_ORDER_PRIMARY), + String.valueOf(ContactsPreferences.SORT_ORDER_ALTERNATIVE), + }); + setValue(String.valueOf(mPreferences.getSortOrder())); + } + + @Override + protected boolean shouldPersist() { + return false; // This preference takes care of its own storage + } + + @Override + public CharSequence getSummary() { + switch (mPreferences.getSortOrder()) { + case ContactsPreferences.SORT_ORDER_PRIMARY: + return mContext.getString(R.string.display_options_sort_by_given_name); + case ContactsPreferences.SORT_ORDER_ALTERNATIVE: + return mContext.getString(R.string.display_options_sort_by_family_name); + } + return null; + } + + @Override + protected boolean persistString(String value) { + int newValue = Integer.parseInt(value); + if (newValue != mPreferences.getSortOrder()) { + mPreferences.setSortOrder(newValue); + notifyChanged(); + } + return true; + } + + @Override + // UX recommendation is not to show cancel button on such lists. + protected void onPrepareDialogBuilder(Builder builder) { + super.onPrepareDialogBuilder(builder); + builder.setNegativeButton(null, null); + } +} diff --git a/java/com/android/contacts/common/res/color/popup_menu_color.xml b/java/com/android/contacts/common/res/color/popup_menu_color.xml new file mode 100644 index 000000000..c52bd5b50 --- /dev/null +++ b/java/com/android/contacts/common/res/color/popup_menu_color.xml @@ -0,0 +1,20 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Copyright (C) 2014 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. +--> + +<selector xmlns:android="http://schemas.android.com/apk/res/android"> + <item android:alpha="0.5" android:color="#ff000000" android:state_enabled="false"/> + <item android:color="#ff000000"/> +</selector>
\ No newline at end of file diff --git a/java/com/android/contacts/common/res/color/tab_text_color.xml b/java/com/android/contacts/common/res/color/tab_text_color.xml new file mode 100644 index 000000000..71ef3e903 --- /dev/null +++ b/java/com/android/contacts/common/res/color/tab_text_color.xml @@ -0,0 +1,21 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (C) 2014 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 + --> + +<selector xmlns:android="http://schemas.android.com/apk/res/android"> + <item android:color="@color/actionbar_text_color" android:state_selected="true"/> + <item android:color="@color/actionbar_unselected_text_color"/> +</selector>
\ No newline at end of file diff --git a/java/com/android/contacts/common/res/drawable-hdpi/ic_ab_search.png b/java/com/android/contacts/common/res/drawable-hdpi/ic_ab_search.png Binary files differnew file mode 100644 index 000000000..d86b2195a --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-hdpi/ic_ab_search.png diff --git a/java/com/android/contacts/common/res/drawable-hdpi/ic_arrow_back_24dp.png b/java/com/android/contacts/common/res/drawable-hdpi/ic_arrow_back_24dp.png Binary files differnew file mode 100644 index 000000000..ddbb2c459 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-hdpi/ic_arrow_back_24dp.png diff --git a/java/com/android/contacts/common/res/drawable-hdpi/ic_business_white_120dp.png b/java/com/android/contacts/common/res/drawable-hdpi/ic_business_white_120dp.png Binary files differnew file mode 100644 index 000000000..d5942dcad --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-hdpi/ic_business_white_120dp.png diff --git a/java/com/android/contacts/common/res/drawable-hdpi/ic_call_24dp.png b/java/com/android/contacts/common/res/drawable-hdpi/ic_call_24dp.png Binary files differnew file mode 100644 index 000000000..4dc506515 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-hdpi/ic_call_24dp.png diff --git a/java/com/android/contacts/common/res/drawable-hdpi/ic_call_note_white_24dp.png b/java/com/android/contacts/common/res/drawable-hdpi/ic_call_note_white_24dp.png Binary files differnew file mode 100644 index 000000000..503e58e22 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-hdpi/ic_call_note_white_24dp.png diff --git a/java/com/android/contacts/common/res/drawable-hdpi/ic_close_dk.png b/java/com/android/contacts/common/res/drawable-hdpi/ic_close_dk.png Binary files differnew file mode 100644 index 000000000..969552935 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-hdpi/ic_close_dk.png diff --git a/java/com/android/contacts/common/res/drawable-hdpi/ic_create_24dp.png b/java/com/android/contacts/common/res/drawable-hdpi/ic_create_24dp.png Binary files differnew file mode 100644 index 000000000..540ab4dee --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-hdpi/ic_create_24dp.png diff --git a/java/com/android/contacts/common/res/drawable-hdpi/ic_group_white_24dp.png b/java/com/android/contacts/common/res/drawable-hdpi/ic_group_white_24dp.png Binary files differnew file mode 100644 index 000000000..017e4bbf7 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-hdpi/ic_group_white_24dp.png diff --git a/java/com/android/contacts/common/res/drawable-hdpi/ic_history_white_drawable_24dp.png b/java/com/android/contacts/common/res/drawable-hdpi/ic_history_white_drawable_24dp.png Binary files differnew file mode 100644 index 000000000..703d30b92 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-hdpi/ic_history_white_drawable_24dp.png diff --git a/java/com/android/contacts/common/res/drawable-hdpi/ic_info_outline_24dp.png b/java/com/android/contacts/common/res/drawable-hdpi/ic_info_outline_24dp.png Binary files differnew file mode 100644 index 000000000..c7b1113cf --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-hdpi/ic_info_outline_24dp.png diff --git a/java/com/android/contacts/common/res/drawable-hdpi/ic_menu_back.png b/java/com/android/contacts/common/res/drawable-hdpi/ic_menu_back.png Binary files differnew file mode 100644 index 000000000..deb3a6dc1 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-hdpi/ic_menu_back.png diff --git a/java/com/android/contacts/common/res/drawable-hdpi/ic_menu_group_dk.png b/java/com/android/contacts/common/res/drawable-hdpi/ic_menu_group_dk.png Binary files differnew file mode 100644 index 000000000..06bd18fbb --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-hdpi/ic_menu_group_dk.png diff --git a/java/com/android/contacts/common/res/drawable-hdpi/ic_menu_group_lt.png b/java/com/android/contacts/common/res/drawable-hdpi/ic_menu_group_lt.png Binary files differnew file mode 100644 index 000000000..d829d11e2 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-hdpi/ic_menu_group_lt.png diff --git a/java/com/android/contacts/common/res/drawable-hdpi/ic_menu_overflow_lt.png b/java/com/android/contacts/common/res/drawable-hdpi/ic_menu_overflow_lt.png Binary files differnew file mode 100644 index 000000000..1ba12950c --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-hdpi/ic_menu_overflow_lt.png diff --git a/java/com/android/contacts/common/res/drawable-hdpi/ic_menu_person_dk.png b/java/com/android/contacts/common/res/drawable-hdpi/ic_menu_person_dk.png Binary files differnew file mode 100644 index 000000000..5ff3ac574 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-hdpi/ic_menu_person_dk.png diff --git a/java/com/android/contacts/common/res/drawable-hdpi/ic_menu_person_lt.png b/java/com/android/contacts/common/res/drawable-hdpi/ic_menu_person_lt.png Binary files differnew file mode 100644 index 000000000..b4ebfc7b2 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-hdpi/ic_menu_person_lt.png diff --git a/java/com/android/contacts/common/res/drawable-hdpi/ic_menu_remove_field_holo_light.png b/java/com/android/contacts/common/res/drawable-hdpi/ic_menu_remove_field_holo_light.png Binary files differnew file mode 100644 index 000000000..03fd2fb10 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-hdpi/ic_menu_remove_field_holo_light.png diff --git a/java/com/android/contacts/common/res/drawable-hdpi/ic_menu_star_dk.png b/java/com/android/contacts/common/res/drawable-hdpi/ic_menu_star_dk.png Binary files differnew file mode 100644 index 000000000..e8cb0f5fe --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-hdpi/ic_menu_star_dk.png diff --git a/java/com/android/contacts/common/res/drawable-hdpi/ic_menu_star_holo_light.png b/java/com/android/contacts/common/res/drawable-hdpi/ic_menu_star_holo_light.png Binary files differnew file mode 100644 index 000000000..45137967c --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-hdpi/ic_menu_star_holo_light.png diff --git a/java/com/android/contacts/common/res/drawable-hdpi/ic_menu_star_lt.png b/java/com/android/contacts/common/res/drawable-hdpi/ic_menu_star_lt.png Binary files differnew file mode 100644 index 000000000..1c9bb81fa --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-hdpi/ic_menu_star_lt.png diff --git a/java/com/android/contacts/common/res/drawable-hdpi/ic_message_24dp.png b/java/com/android/contacts/common/res/drawable-hdpi/ic_message_24dp.png Binary files differnew file mode 100644 index 000000000..57177b7c6 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-hdpi/ic_message_24dp.png diff --git a/java/com/android/contacts/common/res/drawable-hdpi/ic_person_24dp.png b/java/com/android/contacts/common/res/drawable-hdpi/ic_person_24dp.png Binary files differnew file mode 100644 index 000000000..56708b0ba --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-hdpi/ic_person_24dp.png diff --git a/java/com/android/contacts/common/res/drawable-hdpi/ic_person_add_24dp.png b/java/com/android/contacts/common/res/drawable-hdpi/ic_person_add_24dp.png Binary files differnew file mode 100644 index 000000000..10ae5a70c --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-hdpi/ic_person_add_24dp.png diff --git a/java/com/android/contacts/common/res/drawable-hdpi/ic_phone_attach.png b/java/com/android/contacts/common/res/drawable-hdpi/ic_phone_attach.png Binary files differnew file mode 100644 index 000000000..84b1227bd --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-hdpi/ic_phone_attach.png diff --git a/java/com/android/contacts/common/res/drawable-hdpi/ic_rx_videocam.png b/java/com/android/contacts/common/res/drawable-hdpi/ic_rx_videocam.png Binary files differnew file mode 100644 index 000000000..ccdda6701 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-hdpi/ic_rx_videocam.png diff --git a/java/com/android/contacts/common/res/drawable-hdpi/ic_scroll_handle.png b/java/com/android/contacts/common/res/drawable-hdpi/ic_scroll_handle.png Binary files differnew file mode 100644 index 000000000..3aa29b852 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-hdpi/ic_scroll_handle.png diff --git a/java/com/android/contacts/common/res/drawable-hdpi/ic_tx_videocam.png b/java/com/android/contacts/common/res/drawable-hdpi/ic_tx_videocam.png Binary files differnew file mode 100644 index 000000000..603ddc895 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-hdpi/ic_tx_videocam.png diff --git a/java/com/android/contacts/common/res/drawable-hdpi/ic_videocam.png b/java/com/android/contacts/common/res/drawable-hdpi/ic_videocam.png Binary files differnew file mode 100644 index 000000000..97905c9f5 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-hdpi/ic_videocam.png diff --git a/java/com/android/contacts/common/res/drawable-hdpi/ic_voicemail_avatar.png b/java/com/android/contacts/common/res/drawable-hdpi/ic_voicemail_avatar.png Binary files differnew file mode 100644 index 000000000..c74bfab13 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-hdpi/ic_voicemail_avatar.png diff --git a/java/com/android/contacts/common/res/drawable-hdpi/list_activated_holo.9.png b/java/com/android/contacts/common/res/drawable-hdpi/list_activated_holo.9.png Binary files differnew file mode 100644 index 000000000..4ea7afa00 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-hdpi/list_activated_holo.9.png diff --git a/java/com/android/contacts/common/res/drawable-hdpi/list_background_holo.9.png b/java/com/android/contacts/common/res/drawable-hdpi/list_background_holo.9.png Binary files differnew file mode 100644 index 000000000..cddf9be75 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-hdpi/list_background_holo.9.png diff --git a/java/com/android/contacts/common/res/drawable-hdpi/list_focused_holo.9.png b/java/com/android/contacts/common/res/drawable-hdpi/list_focused_holo.9.png Binary files differnew file mode 100644 index 000000000..86578be45 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-hdpi/list_focused_holo.9.png diff --git a/java/com/android/contacts/common/res/drawable-hdpi/list_longpressed_holo_light.9.png b/java/com/android/contacts/common/res/drawable-hdpi/list_longpressed_holo_light.9.png Binary files differnew file mode 100644 index 000000000..e9afcc924 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-hdpi/list_longpressed_holo_light.9.png diff --git a/java/com/android/contacts/common/res/drawable-hdpi/list_pressed_holo_light.9.png b/java/com/android/contacts/common/res/drawable-hdpi/list_pressed_holo_light.9.png Binary files differnew file mode 100644 index 000000000..2054530ed --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-hdpi/list_pressed_holo_light.9.png diff --git a/java/com/android/contacts/common/res/drawable-hdpi/list_section_divider_holo_custom.9.png b/java/com/android/contacts/common/res/drawable-hdpi/list_section_divider_holo_custom.9.png Binary files differnew file mode 100644 index 000000000..a0f17568e --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-hdpi/list_section_divider_holo_custom.9.png diff --git a/java/com/android/contacts/common/res/drawable-hdpi/list_title_holo.9.png b/java/com/android/contacts/common/res/drawable-hdpi/list_title_holo.9.png Binary files differnew file mode 100644 index 000000000..ae937176e --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-hdpi/list_title_holo.9.png diff --git a/java/com/android/contacts/common/res/drawable-ldrtl-hdpi/list_background_holo.9.png b/java/com/android/contacts/common/res/drawable-ldrtl-hdpi/list_background_holo.9.png Binary files differnew file mode 100644 index 000000000..0d80482a9 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-ldrtl-hdpi/list_background_holo.9.png diff --git a/java/com/android/contacts/common/res/drawable-ldrtl-hdpi/list_focused_holo.9.png b/java/com/android/contacts/common/res/drawable-ldrtl-hdpi/list_focused_holo.9.png Binary files differnew file mode 100644 index 000000000..4139942d6 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-ldrtl-hdpi/list_focused_holo.9.png diff --git a/java/com/android/contacts/common/res/drawable-ldrtl-hdpi/list_section_divider_holo_custom.9.png b/java/com/android/contacts/common/res/drawable-ldrtl-hdpi/list_section_divider_holo_custom.9.png Binary files differnew file mode 100644 index 000000000..569d28f54 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-ldrtl-hdpi/list_section_divider_holo_custom.9.png diff --git a/java/com/android/contacts/common/res/drawable-ldrtl-hdpi/list_title_holo.9.png b/java/com/android/contacts/common/res/drawable-ldrtl-hdpi/list_title_holo.9.png Binary files differnew file mode 100644 index 000000000..5ec4c96a7 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-ldrtl-hdpi/list_title_holo.9.png diff --git a/java/com/android/contacts/common/res/drawable-ldrtl-mdpi/list_background_holo.9.png b/java/com/android/contacts/common/res/drawable-ldrtl-mdpi/list_background_holo.9.png Binary files differnew file mode 100644 index 000000000..d86d61164 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-ldrtl-mdpi/list_background_holo.9.png diff --git a/java/com/android/contacts/common/res/drawable-ldrtl-mdpi/list_focused_holo.9.png b/java/com/android/contacts/common/res/drawable-ldrtl-mdpi/list_focused_holo.9.png Binary files differnew file mode 100644 index 000000000..4139942d6 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-ldrtl-mdpi/list_focused_holo.9.png diff --git a/java/com/android/contacts/common/res/drawable-ldrtl-mdpi/list_section_divider_holo_custom.9.png b/java/com/android/contacts/common/res/drawable-ldrtl-mdpi/list_section_divider_holo_custom.9.png Binary files differnew file mode 100644 index 000000000..065ff62ce --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-ldrtl-mdpi/list_section_divider_holo_custom.9.png diff --git a/java/com/android/contacts/common/res/drawable-ldrtl-mdpi/list_title_holo.9.png b/java/com/android/contacts/common/res/drawable-ldrtl-mdpi/list_title_holo.9.png Binary files differnew file mode 100644 index 000000000..013d5e711 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-ldrtl-mdpi/list_title_holo.9.png diff --git a/java/com/android/contacts/common/res/drawable-ldrtl-sw600dp-hdpi/list_activated_holo.9.png b/java/com/android/contacts/common/res/drawable-ldrtl-sw600dp-hdpi/list_activated_holo.9.png Binary files differnew file mode 100644 index 000000000..947f03cec --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-ldrtl-sw600dp-hdpi/list_activated_holo.9.png diff --git a/java/com/android/contacts/common/res/drawable-ldrtl-sw600dp-mdpi/list_activated_holo.9.png b/java/com/android/contacts/common/res/drawable-ldrtl-sw600dp-mdpi/list_activated_holo.9.png Binary files differnew file mode 100644 index 000000000..6d09d7278 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-ldrtl-sw600dp-mdpi/list_activated_holo.9.png diff --git a/java/com/android/contacts/common/res/drawable-ldrtl-sw600dp-xhdpi/list_activated_holo.9.png b/java/com/android/contacts/common/res/drawable-ldrtl-sw600dp-xhdpi/list_activated_holo.9.png Binary files differnew file mode 100644 index 000000000..63c7456f0 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-ldrtl-sw600dp-xhdpi/list_activated_holo.9.png diff --git a/java/com/android/contacts/common/res/drawable-ldrtl-xhdpi/list_background_holo.9.png b/java/com/android/contacts/common/res/drawable-ldrtl-xhdpi/list_background_holo.9.png Binary files differnew file mode 100644 index 000000000..f709f2ce4 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-ldrtl-xhdpi/list_background_holo.9.png diff --git a/java/com/android/contacts/common/res/drawable-ldrtl-xhdpi/list_focused_holo.9.png b/java/com/android/contacts/common/res/drawable-ldrtl-xhdpi/list_focused_holo.9.png Binary files differnew file mode 100644 index 000000000..4139942d6 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-ldrtl-xhdpi/list_focused_holo.9.png diff --git a/java/com/android/contacts/common/res/drawable-ldrtl-xhdpi/list_section_divider_holo_custom.9.png b/java/com/android/contacts/common/res/drawable-ldrtl-xhdpi/list_section_divider_holo_custom.9.png Binary files differnew file mode 100644 index 000000000..af5855420 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-ldrtl-xhdpi/list_section_divider_holo_custom.9.png diff --git a/java/com/android/contacts/common/res/drawable-ldrtl-xhdpi/list_title_holo.9.png b/java/com/android/contacts/common/res/drawable-ldrtl-xhdpi/list_title_holo.9.png Binary files differnew file mode 100644 index 000000000..cb801ac1b --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-ldrtl-xhdpi/list_title_holo.9.png diff --git a/java/com/android/contacts/common/res/drawable-mdpi/ic_ab_search.png b/java/com/android/contacts/common/res/drawable-mdpi/ic_ab_search.png Binary files differnew file mode 100644 index 000000000..2b23b1ec5 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-mdpi/ic_ab_search.png diff --git a/java/com/android/contacts/common/res/drawable-mdpi/ic_arrow_back_24dp.png b/java/com/android/contacts/common/res/drawable-mdpi/ic_arrow_back_24dp.png Binary files differnew file mode 100644 index 000000000..1a21fb400 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-mdpi/ic_arrow_back_24dp.png diff --git a/java/com/android/contacts/common/res/drawable-mdpi/ic_business_white_120dp.png b/java/com/android/contacts/common/res/drawable-mdpi/ic_business_white_120dp.png Binary files differnew file mode 100644 index 000000000..3dddca516 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-mdpi/ic_business_white_120dp.png diff --git a/java/com/android/contacts/common/res/drawable-mdpi/ic_call_24dp.png b/java/com/android/contacts/common/res/drawable-mdpi/ic_call_24dp.png Binary files differnew file mode 100644 index 000000000..77f9de5e3 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-mdpi/ic_call_24dp.png diff --git a/java/com/android/contacts/common/res/drawable-mdpi/ic_call_note_white_24dp.png b/java/com/android/contacts/common/res/drawable-mdpi/ic_call_note_white_24dp.png Binary files differnew file mode 100644 index 000000000..9d359db9f --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-mdpi/ic_call_note_white_24dp.png diff --git a/java/com/android/contacts/common/res/drawable-mdpi/ic_close_dk.png b/java/com/android/contacts/common/res/drawable-mdpi/ic_close_dk.png Binary files differnew file mode 100644 index 000000000..590a728ad --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-mdpi/ic_close_dk.png diff --git a/java/com/android/contacts/common/res/drawable-mdpi/ic_create_24dp.png b/java/com/android/contacts/common/res/drawable-mdpi/ic_create_24dp.png Binary files differnew file mode 100644 index 000000000..8a2df3992 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-mdpi/ic_create_24dp.png diff --git a/java/com/android/contacts/common/res/drawable-mdpi/ic_group_white_24dp.png b/java/com/android/contacts/common/res/drawable-mdpi/ic_group_white_24dp.png Binary files differnew file mode 100644 index 000000000..ad268bf2f --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-mdpi/ic_group_white_24dp.png diff --git a/java/com/android/contacts/common/res/drawable-mdpi/ic_history_white_drawable_24dp.png b/java/com/android/contacts/common/res/drawable-mdpi/ic_history_white_drawable_24dp.png Binary files differnew file mode 100644 index 000000000..b3000d31e --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-mdpi/ic_history_white_drawable_24dp.png diff --git a/java/com/android/contacts/common/res/drawable-mdpi/ic_info_outline_24dp.png b/java/com/android/contacts/common/res/drawable-mdpi/ic_info_outline_24dp.png Binary files differnew file mode 100644 index 000000000..353e06495 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-mdpi/ic_info_outline_24dp.png diff --git a/java/com/android/contacts/common/res/drawable-mdpi/ic_menu_back.png b/java/com/android/contacts/common/res/drawable-mdpi/ic_menu_back.png Binary files differnew file mode 100644 index 000000000..201ad40cb --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-mdpi/ic_menu_back.png diff --git a/java/com/android/contacts/common/res/drawable-mdpi/ic_menu_group_dk.png b/java/com/android/contacts/common/res/drawable-mdpi/ic_menu_group_dk.png Binary files differnew file mode 100644 index 000000000..9aac6d79b --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-mdpi/ic_menu_group_dk.png diff --git a/java/com/android/contacts/common/res/drawable-mdpi/ic_menu_group_lt.png b/java/com/android/contacts/common/res/drawable-mdpi/ic_menu_group_lt.png Binary files differnew file mode 100644 index 000000000..39c16ed2d --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-mdpi/ic_menu_group_lt.png diff --git a/java/com/android/contacts/common/res/drawable-mdpi/ic_menu_overflow_lt.png b/java/com/android/contacts/common/res/drawable-mdpi/ic_menu_overflow_lt.png Binary files differnew file mode 100644 index 000000000..841509682 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-mdpi/ic_menu_overflow_lt.png diff --git a/java/com/android/contacts/common/res/drawable-mdpi/ic_menu_person_dk.png b/java/com/android/contacts/common/res/drawable-mdpi/ic_menu_person_dk.png Binary files differnew file mode 100644 index 000000000..b8fc39aee --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-mdpi/ic_menu_person_dk.png diff --git a/java/com/android/contacts/common/res/drawable-mdpi/ic_menu_person_lt.png b/java/com/android/contacts/common/res/drawable-mdpi/ic_menu_person_lt.png Binary files differnew file mode 100644 index 000000000..736dfd6fa --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-mdpi/ic_menu_person_lt.png diff --git a/java/com/android/contacts/common/res/drawable-mdpi/ic_menu_remove_field_holo_light.png b/java/com/android/contacts/common/res/drawable-mdpi/ic_menu_remove_field_holo_light.png Binary files differnew file mode 100644 index 000000000..8c44e7015 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-mdpi/ic_menu_remove_field_holo_light.png diff --git a/java/com/android/contacts/common/res/drawable-mdpi/ic_menu_star_dk.png b/java/com/android/contacts/common/res/drawable-mdpi/ic_menu_star_dk.png Binary files differnew file mode 100644 index 000000000..c16c6c5de --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-mdpi/ic_menu_star_dk.png diff --git a/java/com/android/contacts/common/res/drawable-mdpi/ic_menu_star_lt.png b/java/com/android/contacts/common/res/drawable-mdpi/ic_menu_star_lt.png Binary files differnew file mode 100644 index 000000000..c67170e31 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-mdpi/ic_menu_star_lt.png diff --git a/java/com/android/contacts/common/res/drawable-mdpi/ic_message_24dp.png b/java/com/android/contacts/common/res/drawable-mdpi/ic_message_24dp.png Binary files differnew file mode 100644 index 000000000..3072b7569 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-mdpi/ic_message_24dp.png diff --git a/java/com/android/contacts/common/res/drawable-mdpi/ic_person_24dp.png b/java/com/android/contacts/common/res/drawable-mdpi/ic_person_24dp.png Binary files differnew file mode 100644 index 000000000..f0b1c725d --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-mdpi/ic_person_24dp.png diff --git a/java/com/android/contacts/common/res/drawable-mdpi/ic_person_add_24dp.png b/java/com/android/contacts/common/res/drawable-mdpi/ic_person_add_24dp.png Binary files differnew file mode 100644 index 000000000..38e0a2882 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-mdpi/ic_person_add_24dp.png diff --git a/java/com/android/contacts/common/res/drawable-mdpi/ic_phone_attach.png b/java/com/android/contacts/common/res/drawable-mdpi/ic_phone_attach.png Binary files differnew file mode 100644 index 000000000..fc4ddd32c --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-mdpi/ic_phone_attach.png diff --git a/java/com/android/contacts/common/res/drawable-mdpi/ic_rx_videocam.png b/java/com/android/contacts/common/res/drawable-mdpi/ic_rx_videocam.png Binary files differnew file mode 100644 index 000000000..1b43a07d0 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-mdpi/ic_rx_videocam.png diff --git a/java/com/android/contacts/common/res/drawable-mdpi/ic_scroll_handle.png b/java/com/android/contacts/common/res/drawable-mdpi/ic_scroll_handle.png Binary files differnew file mode 100644 index 000000000..af75db4b4 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-mdpi/ic_scroll_handle.png diff --git a/java/com/android/contacts/common/res/drawable-mdpi/ic_tx_videocam.png b/java/com/android/contacts/common/res/drawable-mdpi/ic_tx_videocam.png Binary files differnew file mode 100644 index 000000000..64995d147 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-mdpi/ic_tx_videocam.png diff --git a/java/com/android/contacts/common/res/drawable-mdpi/ic_videocam.png b/java/com/android/contacts/common/res/drawable-mdpi/ic_videocam.png Binary files differnew file mode 100644 index 000000000..dc9655b6d --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-mdpi/ic_videocam.png diff --git a/java/com/android/contacts/common/res/drawable-mdpi/ic_voicemail_avatar.png b/java/com/android/contacts/common/res/drawable-mdpi/ic_voicemail_avatar.png Binary files differnew file mode 100644 index 000000000..a5a30213d --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-mdpi/ic_voicemail_avatar.png diff --git a/java/com/android/contacts/common/res/drawable-mdpi/list_activated_holo.9.png b/java/com/android/contacts/common/res/drawable-mdpi/list_activated_holo.9.png Binary files differnew file mode 100644 index 000000000..3bf8e0362 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-mdpi/list_activated_holo.9.png diff --git a/java/com/android/contacts/common/res/drawable-mdpi/list_background_holo.9.png b/java/com/android/contacts/common/res/drawable-mdpi/list_background_holo.9.png Binary files differnew file mode 100644 index 000000000..7d5d66de3 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-mdpi/list_background_holo.9.png diff --git a/java/com/android/contacts/common/res/drawable-mdpi/list_focused_holo.9.png b/java/com/android/contacts/common/res/drawable-mdpi/list_focused_holo.9.png Binary files differnew file mode 100644 index 000000000..86578be45 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-mdpi/list_focused_holo.9.png diff --git a/java/com/android/contacts/common/res/drawable-mdpi/list_longpressed_holo_light.9.png b/java/com/android/contacts/common/res/drawable-mdpi/list_longpressed_holo_light.9.png Binary files differnew file mode 100644 index 000000000..3226ab760 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-mdpi/list_longpressed_holo_light.9.png diff --git a/java/com/android/contacts/common/res/drawable-mdpi/list_pressed_holo_light.9.png b/java/com/android/contacts/common/res/drawable-mdpi/list_pressed_holo_light.9.png Binary files differnew file mode 100644 index 000000000..061904c42 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-mdpi/list_pressed_holo_light.9.png diff --git a/java/com/android/contacts/common/res/drawable-mdpi/list_section_divider_holo_custom.9.png b/java/com/android/contacts/common/res/drawable-mdpi/list_section_divider_holo_custom.9.png Binary files differnew file mode 100644 index 000000000..1d9371de0 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-mdpi/list_section_divider_holo_custom.9.png diff --git a/java/com/android/contacts/common/res/drawable-mdpi/list_title_holo.9.png b/java/com/android/contacts/common/res/drawable-mdpi/list_title_holo.9.png Binary files differnew file mode 100644 index 000000000..64bd6912c --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-mdpi/list_title_holo.9.png diff --git a/java/com/android/contacts/common/res/drawable-sw600dp-hdpi/list_activated_holo.9.png b/java/com/android/contacts/common/res/drawable-sw600dp-hdpi/list_activated_holo.9.png Binary files differnew file mode 100644 index 000000000..046b24a96 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-sw600dp-hdpi/list_activated_holo.9.png diff --git a/java/com/android/contacts/common/res/drawable-sw600dp-mdpi/list_activated_holo.9.png b/java/com/android/contacts/common/res/drawable-sw600dp-mdpi/list_activated_holo.9.png Binary files differnew file mode 100644 index 000000000..1ff337370 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-sw600dp-mdpi/list_activated_holo.9.png diff --git a/java/com/android/contacts/common/res/drawable-sw600dp-xhdpi/list_activated_holo.9.png b/java/com/android/contacts/common/res/drawable-sw600dp-xhdpi/list_activated_holo.9.png Binary files differnew file mode 100644 index 000000000..2eb7c7ebc --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-sw600dp-xhdpi/list_activated_holo.9.png diff --git a/java/com/android/contacts/common/res/drawable-xhdpi/ic_ab_search.png b/java/com/android/contacts/common/res/drawable-xhdpi/ic_ab_search.png Binary files differnew file mode 100644 index 000000000..71f782701 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-xhdpi/ic_ab_search.png diff --git a/java/com/android/contacts/common/res/drawable-xhdpi/ic_arrow_back_24dp.png b/java/com/android/contacts/common/res/drawable-xhdpi/ic_arrow_back_24dp.png Binary files differnew file mode 100644 index 000000000..bb7327251 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-xhdpi/ic_arrow_back_24dp.png diff --git a/java/com/android/contacts/common/res/drawable-xhdpi/ic_business_white_120dp.png b/java/com/android/contacts/common/res/drawable-xhdpi/ic_business_white_120dp.png Binary files differnew file mode 100644 index 000000000..6256300b4 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-xhdpi/ic_business_white_120dp.png diff --git a/java/com/android/contacts/common/res/drawable-xhdpi/ic_call_24dp.png b/java/com/android/contacts/common/res/drawable-xhdpi/ic_call_24dp.png Binary files differnew file mode 100644 index 000000000..ef45e933a --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-xhdpi/ic_call_24dp.png diff --git a/java/com/android/contacts/common/res/drawable-xhdpi/ic_call_note_white_24dp.png b/java/com/android/contacts/common/res/drawable-xhdpi/ic_call_note_white_24dp.png Binary files differnew file mode 100644 index 000000000..40eed1d12 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-xhdpi/ic_call_note_white_24dp.png diff --git a/java/com/android/contacts/common/res/drawable-xhdpi/ic_close_dk.png b/java/com/android/contacts/common/res/drawable-xhdpi/ic_close_dk.png Binary files differnew file mode 100644 index 000000000..5769f1178 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-xhdpi/ic_close_dk.png diff --git a/java/com/android/contacts/common/res/drawable-xhdpi/ic_create_24dp.png b/java/com/android/contacts/common/res/drawable-xhdpi/ic_create_24dp.png Binary files differnew file mode 100644 index 000000000..48e75beee --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-xhdpi/ic_create_24dp.png diff --git a/java/com/android/contacts/common/res/drawable-xhdpi/ic_group_white_24dp.png b/java/com/android/contacts/common/res/drawable-xhdpi/ic_group_white_24dp.png Binary files differnew file mode 100644 index 000000000..09c0e3efd --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-xhdpi/ic_group_white_24dp.png diff --git a/java/com/android/contacts/common/res/drawable-xhdpi/ic_history_white_drawable_24dp.png b/java/com/android/contacts/common/res/drawable-xhdpi/ic_history_white_drawable_24dp.png Binary files differnew file mode 100644 index 000000000..e188d4a37 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-xhdpi/ic_history_white_drawable_24dp.png diff --git a/java/com/android/contacts/common/res/drawable-xhdpi/ic_info_outline_24dp.png b/java/com/android/contacts/common/res/drawable-xhdpi/ic_info_outline_24dp.png Binary files differnew file mode 100644 index 000000000..c571b2e3e --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-xhdpi/ic_info_outline_24dp.png diff --git a/java/com/android/contacts/common/res/drawable-xhdpi/ic_menu_back.png b/java/com/android/contacts/common/res/drawable-xhdpi/ic_menu_back.png Binary files differnew file mode 100644 index 000000000..d2f709942 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-xhdpi/ic_menu_back.png diff --git a/java/com/android/contacts/common/res/drawable-xhdpi/ic_menu_group_dk.png b/java/com/android/contacts/common/res/drawable-xhdpi/ic_menu_group_dk.png Binary files differnew file mode 100644 index 000000000..ce5f704ec --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-xhdpi/ic_menu_group_dk.png diff --git a/java/com/android/contacts/common/res/drawable-xhdpi/ic_menu_group_lt.png b/java/com/android/contacts/common/res/drawable-xhdpi/ic_menu_group_lt.png Binary files differnew file mode 100644 index 000000000..3d0580f93 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-xhdpi/ic_menu_group_lt.png diff --git a/java/com/android/contacts/common/res/drawable-xhdpi/ic_menu_overflow_lt.png b/java/com/android/contacts/common/res/drawable-xhdpi/ic_menu_overflow_lt.png Binary files differnew file mode 100644 index 000000000..f91b71847 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-xhdpi/ic_menu_overflow_lt.png diff --git a/java/com/android/contacts/common/res/drawable-xhdpi/ic_menu_person_dk.png b/java/com/android/contacts/common/res/drawable-xhdpi/ic_menu_person_dk.png Binary files differnew file mode 100644 index 000000000..2fbd458e9 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-xhdpi/ic_menu_person_dk.png diff --git a/java/com/android/contacts/common/res/drawable-xhdpi/ic_menu_person_lt.png b/java/com/android/contacts/common/res/drawable-xhdpi/ic_menu_person_lt.png Binary files differnew file mode 100644 index 000000000..2cdb2d7a1 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-xhdpi/ic_menu_person_lt.png diff --git a/java/com/android/contacts/common/res/drawable-xhdpi/ic_menu_remove_field_holo_light.png b/java/com/android/contacts/common/res/drawable-xhdpi/ic_menu_remove_field_holo_light.png Binary files differnew file mode 100644 index 000000000..65a6b7bbb --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-xhdpi/ic_menu_remove_field_holo_light.png diff --git a/java/com/android/contacts/common/res/drawable-xhdpi/ic_menu_star_dk.png b/java/com/android/contacts/common/res/drawable-xhdpi/ic_menu_star_dk.png Binary files differnew file mode 100644 index 000000000..48483a0b6 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-xhdpi/ic_menu_star_dk.png diff --git a/java/com/android/contacts/common/res/drawable-xhdpi/ic_menu_star_holo_light.png b/java/com/android/contacts/common/res/drawable-xhdpi/ic_menu_star_holo_light.png Binary files differnew file mode 100644 index 000000000..906791177 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-xhdpi/ic_menu_star_holo_light.png diff --git a/java/com/android/contacts/common/res/drawable-xhdpi/ic_menu_star_lt.png b/java/com/android/contacts/common/res/drawable-xhdpi/ic_menu_star_lt.png Binary files differnew file mode 100644 index 000000000..e053c757a --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-xhdpi/ic_menu_star_lt.png diff --git a/java/com/android/contacts/common/res/drawable-xhdpi/ic_message_24dp.png b/java/com/android/contacts/common/res/drawable-xhdpi/ic_message_24dp.png Binary files differnew file mode 100644 index 000000000..763767b4f --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-xhdpi/ic_message_24dp.png diff --git a/java/com/android/contacts/common/res/drawable-xhdpi/ic_person_24dp.png b/java/com/android/contacts/common/res/drawable-xhdpi/ic_person_24dp.png Binary files differnew file mode 100644 index 000000000..aea15f0be --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-xhdpi/ic_person_24dp.png diff --git a/java/com/android/contacts/common/res/drawable-xhdpi/ic_person_add_24dp.png b/java/com/android/contacts/common/res/drawable-xhdpi/ic_person_add_24dp.png Binary files differnew file mode 100644 index 000000000..7e7c289d4 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-xhdpi/ic_person_add_24dp.png diff --git a/java/com/android/contacts/common/res/drawable-xhdpi/ic_phone_attach.png b/java/com/android/contacts/common/res/drawable-xhdpi/ic_phone_attach.png Binary files differnew file mode 100644 index 000000000..fdfafed9a --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-xhdpi/ic_phone_attach.png diff --git a/java/com/android/contacts/common/res/drawable-xhdpi/ic_rx_videocam.png b/java/com/android/contacts/common/res/drawable-xhdpi/ic_rx_videocam.png Binary files differnew file mode 100644 index 000000000..43319dc92 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-xhdpi/ic_rx_videocam.png diff --git a/java/com/android/contacts/common/res/drawable-xhdpi/ic_scroll_handle.png b/java/com/android/contacts/common/res/drawable-xhdpi/ic_scroll_handle.png Binary files differnew file mode 100644 index 000000000..2d43c4d5b --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-xhdpi/ic_scroll_handle.png diff --git a/java/com/android/contacts/common/res/drawable-xhdpi/ic_tx_videocam.png b/java/com/android/contacts/common/res/drawable-xhdpi/ic_tx_videocam.png Binary files differnew file mode 100644 index 000000000..d2671edf7 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-xhdpi/ic_tx_videocam.png diff --git a/java/com/android/contacts/common/res/drawable-xhdpi/ic_videocam.png b/java/com/android/contacts/common/res/drawable-xhdpi/ic_videocam.png Binary files differnew file mode 100644 index 000000000..c1783de67 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-xhdpi/ic_videocam.png diff --git a/java/com/android/contacts/common/res/drawable-xhdpi/ic_voicemail_avatar.png b/java/com/android/contacts/common/res/drawable-xhdpi/ic_voicemail_avatar.png Binary files differnew file mode 100644 index 000000000..ca9d7d66b --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-xhdpi/ic_voicemail_avatar.png diff --git a/java/com/android/contacts/common/res/drawable-xhdpi/list_activated_holo.9.png b/java/com/android/contacts/common/res/drawable-xhdpi/list_activated_holo.9.png Binary files differnew file mode 100644 index 000000000..eda10e612 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-xhdpi/list_activated_holo.9.png diff --git a/java/com/android/contacts/common/res/drawable-xhdpi/list_background_holo.9.png b/java/com/android/contacts/common/res/drawable-xhdpi/list_background_holo.9.png Binary files differnew file mode 100644 index 000000000..b65272542 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-xhdpi/list_background_holo.9.png diff --git a/java/com/android/contacts/common/res/drawable-xhdpi/list_focused_holo.9.png b/java/com/android/contacts/common/res/drawable-xhdpi/list_focused_holo.9.png Binary files differnew file mode 100644 index 000000000..86578be45 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-xhdpi/list_focused_holo.9.png diff --git a/java/com/android/contacts/common/res/drawable-xhdpi/list_longpressed_holo_light.9.png b/java/com/android/contacts/common/res/drawable-xhdpi/list_longpressed_holo_light.9.png Binary files differnew file mode 100644 index 000000000..5532e88c2 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-xhdpi/list_longpressed_holo_light.9.png diff --git a/java/com/android/contacts/common/res/drawable-xhdpi/list_pressed_holo_light.9.png b/java/com/android/contacts/common/res/drawable-xhdpi/list_pressed_holo_light.9.png Binary files differnew file mode 100644 index 000000000..f4af92657 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-xhdpi/list_pressed_holo_light.9.png diff --git a/java/com/android/contacts/common/res/drawable-xhdpi/list_section_divider_holo_custom.9.png b/java/com/android/contacts/common/res/drawable-xhdpi/list_section_divider_holo_custom.9.png Binary files differnew file mode 100644 index 000000000..8fb0636cf --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-xhdpi/list_section_divider_holo_custom.9.png diff --git a/java/com/android/contacts/common/res/drawable-xhdpi/list_title_holo.9.png b/java/com/android/contacts/common/res/drawable-xhdpi/list_title_holo.9.png Binary files differnew file mode 100644 index 000000000..f4f00ca0f --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-xhdpi/list_title_holo.9.png diff --git a/java/com/android/contacts/common/res/drawable-xxhdpi/ic_ab_search.png b/java/com/android/contacts/common/res/drawable-xxhdpi/ic_ab_search.png Binary files differnew file mode 100644 index 000000000..142c5457d --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-xxhdpi/ic_ab_search.png diff --git a/java/com/android/contacts/common/res/drawable-xxhdpi/ic_arrow_back_24dp.png b/java/com/android/contacts/common/res/drawable-xxhdpi/ic_arrow_back_24dp.png Binary files differnew file mode 100644 index 000000000..72c51b0d5 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-xxhdpi/ic_arrow_back_24dp.png diff --git a/java/com/android/contacts/common/res/drawable-xxhdpi/ic_business_white_120dp.png b/java/com/android/contacts/common/res/drawable-xxhdpi/ic_business_white_120dp.png Binary files differnew file mode 100644 index 000000000..8d67e448f --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-xxhdpi/ic_business_white_120dp.png diff --git a/java/com/android/contacts/common/res/drawable-xxhdpi/ic_call_24dp.png b/java/com/android/contacts/common/res/drawable-xxhdpi/ic_call_24dp.png Binary files differnew file mode 100644 index 000000000..90ead2e45 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-xxhdpi/ic_call_24dp.png diff --git a/java/com/android/contacts/common/res/drawable-xxhdpi/ic_call_note_white_24dp.png b/java/com/android/contacts/common/res/drawable-xxhdpi/ic_call_note_white_24dp.png Binary files differnew file mode 100644 index 000000000..2656cad18 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-xxhdpi/ic_call_note_white_24dp.png diff --git a/java/com/android/contacts/common/res/drawable-xxhdpi/ic_close_dk.png b/java/com/android/contacts/common/res/drawable-xxhdpi/ic_close_dk.png Binary files differnew file mode 100644 index 000000000..670bf796c --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-xxhdpi/ic_close_dk.png diff --git a/java/com/android/contacts/common/res/drawable-xxhdpi/ic_create_24dp.png b/java/com/android/contacts/common/res/drawable-xxhdpi/ic_create_24dp.png Binary files differnew file mode 100644 index 000000000..24142c729 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-xxhdpi/ic_create_24dp.png diff --git a/java/com/android/contacts/common/res/drawable-xxhdpi/ic_group_white_24dp.png b/java/com/android/contacts/common/res/drawable-xxhdpi/ic_group_white_24dp.png Binary files differnew file mode 100644 index 000000000..03cad4c90 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-xxhdpi/ic_group_white_24dp.png diff --git a/java/com/android/contacts/common/res/drawable-xxhdpi/ic_history_white_drawable_24dp.png b/java/com/android/contacts/common/res/drawable-xxhdpi/ic_history_white_drawable_24dp.png Binary files differnew file mode 100644 index 000000000..f44df1afd --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-xxhdpi/ic_history_white_drawable_24dp.png diff --git a/java/com/android/contacts/common/res/drawable-xxhdpi/ic_info_outline_24dp.png b/java/com/android/contacts/common/res/drawable-xxhdpi/ic_info_outline_24dp.png Binary files differnew file mode 100644 index 000000000..c41a5fcff --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-xxhdpi/ic_info_outline_24dp.png diff --git a/java/com/android/contacts/common/res/drawable-xxhdpi/ic_menu_back.png b/java/com/android/contacts/common/res/drawable-xxhdpi/ic_menu_back.png Binary files differnew file mode 100644 index 000000000..436a82da6 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-xxhdpi/ic_menu_back.png diff --git a/java/com/android/contacts/common/res/drawable-xxhdpi/ic_menu_group_dk.png b/java/com/android/contacts/common/res/drawable-xxhdpi/ic_menu_group_dk.png Binary files differnew file mode 100644 index 000000000..a70c60c03 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-xxhdpi/ic_menu_group_dk.png diff --git a/java/com/android/contacts/common/res/drawable-xxhdpi/ic_menu_group_lt.png b/java/com/android/contacts/common/res/drawable-xxhdpi/ic_menu_group_lt.png Binary files differnew file mode 100644 index 000000000..c64b9defe --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-xxhdpi/ic_menu_group_lt.png diff --git a/java/com/android/contacts/common/res/drawable-xxhdpi/ic_menu_overflow_lt.png b/java/com/android/contacts/common/res/drawable-xxhdpi/ic_menu_overflow_lt.png Binary files differnew file mode 100644 index 000000000..ff1759b8f --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-xxhdpi/ic_menu_overflow_lt.png diff --git a/java/com/android/contacts/common/res/drawable-xxhdpi/ic_menu_person_dk.png b/java/com/android/contacts/common/res/drawable-xxhdpi/ic_menu_person_dk.png Binary files differnew file mode 100644 index 000000000..878e694ad --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-xxhdpi/ic_menu_person_dk.png diff --git a/java/com/android/contacts/common/res/drawable-xxhdpi/ic_menu_person_lt.png b/java/com/android/contacts/common/res/drawable-xxhdpi/ic_menu_person_lt.png Binary files differnew file mode 100644 index 000000000..ed4138f15 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-xxhdpi/ic_menu_person_lt.png diff --git a/java/com/android/contacts/common/res/drawable-xxhdpi/ic_menu_remove_field_holo_light.png b/java/com/android/contacts/common/res/drawable-xxhdpi/ic_menu_remove_field_holo_light.png Binary files differnew file mode 100644 index 000000000..0fec2f2b5 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-xxhdpi/ic_menu_remove_field_holo_light.png diff --git a/java/com/android/contacts/common/res/drawable-xxhdpi/ic_menu_star_dk.png b/java/com/android/contacts/common/res/drawable-xxhdpi/ic_menu_star_dk.png Binary files differnew file mode 100644 index 000000000..fa682b11b --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-xxhdpi/ic_menu_star_dk.png diff --git a/java/com/android/contacts/common/res/drawable-xxhdpi/ic_menu_star_holo_light.png b/java/com/android/contacts/common/res/drawable-xxhdpi/ic_menu_star_holo_light.png Binary files differnew file mode 100644 index 000000000..6c45bc8e6 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-xxhdpi/ic_menu_star_holo_light.png diff --git a/java/com/android/contacts/common/res/drawable-xxhdpi/ic_menu_star_lt.png b/java/com/android/contacts/common/res/drawable-xxhdpi/ic_menu_star_lt.png Binary files differnew file mode 100644 index 000000000..955f7383b --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-xxhdpi/ic_menu_star_lt.png diff --git a/java/com/android/contacts/common/res/drawable-xxhdpi/ic_message_24dp.png b/java/com/android/contacts/common/res/drawable-xxhdpi/ic_message_24dp.png Binary files differnew file mode 100644 index 000000000..0a79824b8 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-xxhdpi/ic_message_24dp.png diff --git a/java/com/android/contacts/common/res/drawable-xxhdpi/ic_person_24dp.png b/java/com/android/contacts/common/res/drawable-xxhdpi/ic_person_24dp.png Binary files differnew file mode 100644 index 000000000..184f7418d --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-xxhdpi/ic_person_24dp.png diff --git a/java/com/android/contacts/common/res/drawable-xxhdpi/ic_person_add_24dp.png b/java/com/android/contacts/common/res/drawable-xxhdpi/ic_person_add_24dp.png Binary files differnew file mode 100644 index 000000000..8f744f039 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-xxhdpi/ic_person_add_24dp.png diff --git a/java/com/android/contacts/common/res/drawable-xxhdpi/ic_phone_attach.png b/java/com/android/contacts/common/res/drawable-xxhdpi/ic_phone_attach.png Binary files differnew file mode 100644 index 000000000..6a6cdeeaa --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-xxhdpi/ic_phone_attach.png diff --git a/java/com/android/contacts/common/res/drawable-xxhdpi/ic_rx_videocam.png b/java/com/android/contacts/common/res/drawable-xxhdpi/ic_rx_videocam.png Binary files differnew file mode 100644 index 000000000..89d29b7f5 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-xxhdpi/ic_rx_videocam.png diff --git a/java/com/android/contacts/common/res/drawable-xxhdpi/ic_scroll_handle.png b/java/com/android/contacts/common/res/drawable-xxhdpi/ic_scroll_handle.png Binary files differnew file mode 100644 index 000000000..55f1d1369 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-xxhdpi/ic_scroll_handle.png diff --git a/java/com/android/contacts/common/res/drawable-xxhdpi/ic_tx_videocam.png b/java/com/android/contacts/common/res/drawable-xxhdpi/ic_tx_videocam.png Binary files differnew file mode 100644 index 000000000..8d897ba5a --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-xxhdpi/ic_tx_videocam.png diff --git a/java/com/android/contacts/common/res/drawable-xxhdpi/ic_videocam.png b/java/com/android/contacts/common/res/drawable-xxhdpi/ic_videocam.png Binary files differnew file mode 100644 index 000000000..4ab5ad0ee --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-xxhdpi/ic_videocam.png diff --git a/java/com/android/contacts/common/res/drawable-xxhdpi/ic_voicemail_avatar.png b/java/com/android/contacts/common/res/drawable-xxhdpi/ic_voicemail_avatar.png Binary files differnew file mode 100644 index 000000000..d0979e9eb --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-xxhdpi/ic_voicemail_avatar.png diff --git a/java/com/android/contacts/common/res/drawable-xxhdpi/list_activated_holo.9.png b/java/com/android/contacts/common/res/drawable-xxhdpi/list_activated_holo.9.png Binary files differnew file mode 100644 index 000000000..52c00ddcd --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-xxhdpi/list_activated_holo.9.png diff --git a/java/com/android/contacts/common/res/drawable-xxhdpi/list_focused_holo.9.png b/java/com/android/contacts/common/res/drawable-xxhdpi/list_focused_holo.9.png Binary files differnew file mode 100644 index 000000000..3e4ca684e --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-xxhdpi/list_focused_holo.9.png diff --git a/java/com/android/contacts/common/res/drawable-xxhdpi/list_longpressed_holo_light.9.png b/java/com/android/contacts/common/res/drawable-xxhdpi/list_longpressed_holo_light.9.png Binary files differnew file mode 100644 index 000000000..230d649bf --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-xxhdpi/list_longpressed_holo_light.9.png diff --git a/java/com/android/contacts/common/res/drawable-xxhdpi/list_pressed_holo_light.9.png b/java/com/android/contacts/common/res/drawable-xxhdpi/list_pressed_holo_light.9.png Binary files differnew file mode 100644 index 000000000..1352a1702 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-xxhdpi/list_pressed_holo_light.9.png diff --git a/java/com/android/contacts/common/res/drawable-xxhdpi/list_title_holo.9.png b/java/com/android/contacts/common/res/drawable-xxhdpi/list_title_holo.9.png Binary files differnew file mode 100644 index 000000000..7ddf14a0d --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-xxhdpi/list_title_holo.9.png diff --git a/java/com/android/contacts/common/res/drawable-xxxhdpi/ic_ab_search.png b/java/com/android/contacts/common/res/drawable-xxxhdpi/ic_ab_search.png Binary files differnew file mode 100644 index 000000000..2ffb2ecae --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-xxxhdpi/ic_ab_search.png diff --git a/java/com/android/contacts/common/res/drawable-xxxhdpi/ic_arrow_back_24dp.png b/java/com/android/contacts/common/res/drawable-xxxhdpi/ic_arrow_back_24dp.png Binary files differnew file mode 100644 index 000000000..ae01a04ae --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-xxxhdpi/ic_arrow_back_24dp.png diff --git a/java/com/android/contacts/common/res/drawable-xxxhdpi/ic_business_white_120dp.png b/java/com/android/contacts/common/res/drawable-xxxhdpi/ic_business_white_120dp.png Binary files differnew file mode 100644 index 000000000..1741675de --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-xxxhdpi/ic_business_white_120dp.png diff --git a/java/com/android/contacts/common/res/drawable-xxxhdpi/ic_call_24dp.png b/java/com/android/contacts/common/res/drawable-xxxhdpi/ic_call_24dp.png Binary files differnew file mode 100644 index 000000000..b0e020573 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-xxxhdpi/ic_call_24dp.png diff --git a/java/com/android/contacts/common/res/drawable-xxxhdpi/ic_call_note_white_24dp.png b/java/com/android/contacts/common/res/drawable-xxxhdpi/ic_call_note_white_24dp.png Binary files differnew file mode 100644 index 000000000..903c1623d --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-xxxhdpi/ic_call_note_white_24dp.png diff --git a/java/com/android/contacts/common/res/drawable-xxxhdpi/ic_close_dk.png b/java/com/android/contacts/common/res/drawable-xxxhdpi/ic_close_dk.png Binary files differnew file mode 100644 index 000000000..3a5540ff6 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-xxxhdpi/ic_close_dk.png diff --git a/java/com/android/contacts/common/res/drawable-xxxhdpi/ic_create_24dp.png b/java/com/android/contacts/common/res/drawable-xxxhdpi/ic_create_24dp.png Binary files differnew file mode 100644 index 000000000..d3ff0ecb6 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-xxxhdpi/ic_create_24dp.png diff --git a/java/com/android/contacts/common/res/drawable-xxxhdpi/ic_history_white_drawable_24dp.png b/java/com/android/contacts/common/res/drawable-xxxhdpi/ic_history_white_drawable_24dp.png Binary files differnew file mode 100644 index 000000000..5b96af5b7 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-xxxhdpi/ic_history_white_drawable_24dp.png diff --git a/java/com/android/contacts/common/res/drawable-xxxhdpi/ic_info_outline_24dp.png b/java/com/android/contacts/common/res/drawable-xxxhdpi/ic_info_outline_24dp.png Binary files differnew file mode 100644 index 000000000..3a82cab3b --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-xxxhdpi/ic_info_outline_24dp.png diff --git a/java/com/android/contacts/common/res/drawable-xxxhdpi/ic_message_24dp.png b/java/com/android/contacts/common/res/drawable-xxxhdpi/ic_message_24dp.png Binary files differnew file mode 100644 index 000000000..fa7c17ac4 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-xxxhdpi/ic_message_24dp.png diff --git a/java/com/android/contacts/common/res/drawable-xxxhdpi/ic_person_24dp.png b/java/com/android/contacts/common/res/drawable-xxxhdpi/ic_person_24dp.png Binary files differnew file mode 100644 index 000000000..33d40d8b6 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-xxxhdpi/ic_person_24dp.png diff --git a/java/com/android/contacts/common/res/drawable-xxxhdpi/ic_person_add_24dp.png b/java/com/android/contacts/common/res/drawable-xxxhdpi/ic_person_add_24dp.png Binary files differnew file mode 100644 index 000000000..2fa2cca80 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-xxxhdpi/ic_person_add_24dp.png diff --git a/java/com/android/contacts/common/res/drawable-xxxhdpi/ic_phone_attach.png b/java/com/android/contacts/common/res/drawable-xxxhdpi/ic_phone_attach.png Binary files differnew file mode 100644 index 000000000..b072ad11f --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-xxxhdpi/ic_phone_attach.png diff --git a/java/com/android/contacts/common/res/drawable-xxxhdpi/ic_scroll_handle.png b/java/com/android/contacts/common/res/drawable-xxxhdpi/ic_scroll_handle.png Binary files differnew file mode 100644 index 000000000..d90782a32 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-xxxhdpi/ic_scroll_handle.png diff --git a/java/com/android/contacts/common/res/drawable-xxxhdpi/ic_videocam.png b/java/com/android/contacts/common/res/drawable-xxxhdpi/ic_videocam.png Binary files differnew file mode 100644 index 000000000..0643ea55f --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-xxxhdpi/ic_videocam.png diff --git a/java/com/android/contacts/common/res/drawable-xxxhdpi/ic_voicemail_avatar.png b/java/com/android/contacts/common/res/drawable-xxxhdpi/ic_voicemail_avatar.png Binary files differnew file mode 100644 index 000000000..1d6e1aa0f --- /dev/null +++ b/java/com/android/contacts/common/res/drawable-xxxhdpi/ic_voicemail_avatar.png diff --git a/java/com/android/contacts/common/res/drawable/dialog_background_material.xml b/java/com/android/contacts/common/res/drawable/dialog_background_material.xml new file mode 100644 index 000000000..1b71cd63a --- /dev/null +++ b/java/com/android/contacts/common/res/drawable/dialog_background_material.xml @@ -0,0 +1,23 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Copyright (C) 2015 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. +--> + +<inset xmlns:android="http://schemas.android.com/apk/res/android" + android:inset="16dp"> + <shape android:shape="rectangle"> + <corners android:radius="2dp"/> + <solid android:color="@color/call_subject_history_background"/> + </shape> +</inset> diff --git a/java/com/android/contacts/common/res/drawable/fastscroll_thumb.xml b/java/com/android/contacts/common/res/drawable/fastscroll_thumb.xml new file mode 100644 index 000000000..67645ff91 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable/fastscroll_thumb.xml @@ -0,0 +1,19 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Copyright (C) 2014 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. +--> +<selector xmlns:android="http://schemas.android.com/apk/res/android"> + <item android:drawable="@drawable/ic_scroll_handle_pressed" android:state_pressed="true"/> + <item android:drawable="@drawable/ic_scroll_handle_default"/> +</selector>
\ No newline at end of file diff --git a/java/com/android/contacts/common/res/drawable/ic_back_arrow.xml b/java/com/android/contacts/common/res/drawable/ic_back_arrow.xml new file mode 100644 index 000000000..56fab8f6f --- /dev/null +++ b/java/com/android/contacts/common/res/drawable/ic_back_arrow.xml @@ -0,0 +1,20 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (C) 2014 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 + --> +<bitmap xmlns:android="http://schemas.android.com/apk/res/android" + android:autoMirrored="true" + android:src="@drawable/ic_arrow_back_24dp" + android:tint="@color/actionbar_icon_color"/>
\ No newline at end of file diff --git a/java/com/android/contacts/common/res/drawable/ic_call.xml b/java/com/android/contacts/common/res/drawable/ic_call.xml new file mode 100644 index 000000000..0fedd452f --- /dev/null +++ b/java/com/android/contacts/common/res/drawable/ic_call.xml @@ -0,0 +1,19 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (C) 2015 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 + --> +<bitmap xmlns:android="http://schemas.android.com/apk/res/android" + android:autoMirrored="true" + android:src="@drawable/ic_call_24dp"/> diff --git a/java/com/android/contacts/common/res/drawable/ic_message_24dp.xml b/java/com/android/contacts/common/res/drawable/ic_message_24dp.xml new file mode 100644 index 000000000..3c6c8b534 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable/ic_message_24dp.xml @@ -0,0 +1,19 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (C) 2015 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 + --> +<bitmap xmlns:android="http://schemas.android.com/apk/res/android" + android:autoMirrored="true" + android:src="@drawable/ic_message_24dp"/> diff --git a/java/com/android/contacts/common/res/drawable/ic_more_vert.xml b/java/com/android/contacts/common/res/drawable/ic_more_vert.xml new file mode 100644 index 000000000..fcc3d9e4f --- /dev/null +++ b/java/com/android/contacts/common/res/drawable/ic_more_vert.xml @@ -0,0 +1,9 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:height="24dp" + android:viewportHeight="24.0" + android:viewportWidth="24.0" + android:width="24dp"> + <path + android:fillColor="#FF000000" + android:pathData="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"/> +</vector> diff --git a/java/com/android/contacts/common/res/drawable/ic_person_add_tinted_24dp.xml b/java/com/android/contacts/common/res/drawable/ic_person_add_tinted_24dp.xml new file mode 100644 index 000000000..0af90edb3 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable/ic_person_add_tinted_24dp.xml @@ -0,0 +1,20 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (C) 2014 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 + --> +<bitmap xmlns:android="http://schemas.android.com/apk/res/android" + android:autoMirrored="true" + android:src="@drawable/ic_person_add_24dp" + android:tint="@color/actionbar_icon_color"/> diff --git a/java/com/android/contacts/common/res/drawable/ic_scroll_handle_default.xml b/java/com/android/contacts/common/res/drawable/ic_scroll_handle_default.xml new file mode 100644 index 000000000..ac932f87c --- /dev/null +++ b/java/com/android/contacts/common/res/drawable/ic_scroll_handle_default.xml @@ -0,0 +1,20 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2014 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. +--> + +<bitmap xmlns:android="http://schemas.android.com/apk/res/android" + android:src="@drawable/ic_scroll_handle" + android:tint="@color/dialer_secondary_text_color"/> diff --git a/java/com/android/contacts/common/res/drawable/ic_scroll_handle_pressed.xml b/java/com/android/contacts/common/res/drawable/ic_scroll_handle_pressed.xml new file mode 100644 index 000000000..4838de58a --- /dev/null +++ b/java/com/android/contacts/common/res/drawable/ic_scroll_handle_pressed.xml @@ -0,0 +1,20 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2014 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. +--> + +<bitmap xmlns:android="http://schemas.android.com/apk/res/android" + android:src="@drawable/ic_scroll_handle" + android:tint="@color/dialtacts_theme_color"/>
\ No newline at end of file diff --git a/java/com/android/contacts/common/res/drawable/ic_search_add_contact.xml b/java/com/android/contacts/common/res/drawable/ic_search_add_contact.xml new file mode 100644 index 000000000..801806044 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable/ic_search_add_contact.xml @@ -0,0 +1,20 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2014 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. +--> + +<bitmap xmlns:android="http://schemas.android.com/apk/res/android" + android:autoMirrored="true" + android:src="@drawable/ic_person_add_24dp"/> diff --git a/java/com/android/contacts/common/res/drawable/ic_search_video_call.xml b/java/com/android/contacts/common/res/drawable/ic_search_video_call.xml new file mode 100644 index 000000000..f1b5cba43 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable/ic_search_video_call.xml @@ -0,0 +1,21 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (C) 2015 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 + --> + +<bitmap xmlns:android="http://schemas.android.com/apk/res/android" + android:autoMirrored="true" + android:src="@drawable/ic_videocam" + android:tint="@color/search_video_call_icon_tint"/> diff --git a/java/com/android/contacts/common/res/drawable/ic_tab_all.xml b/java/com/android/contacts/common/res/drawable/ic_tab_all.xml new file mode 100644 index 000000000..9cc6fbc96 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable/ic_tab_all.xml @@ -0,0 +1,21 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Copyright (C) 2011 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. +--> + +<selector xmlns:android="http://schemas.android.com/apk/res/android"> + <item android:drawable="@drawable/ic_menu_person_lt" android:state_selected="false"/> + <item android:drawable="@drawable/ic_menu_person_dk" android:state_selected="true"/> +</selector> + diff --git a/java/com/android/contacts/common/res/drawable/ic_tab_groups.xml b/java/com/android/contacts/common/res/drawable/ic_tab_groups.xml new file mode 100644 index 000000000..6b3e7a415 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable/ic_tab_groups.xml @@ -0,0 +1,21 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Copyright (C) 2011 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. +--> + +<selector xmlns:android="http://schemas.android.com/apk/res/android"> + <item android:drawable="@drawable/ic_menu_group_lt" android:state_selected="false"/> + <item android:drawable="@drawable/ic_menu_group_dk" android:state_selected="true"/> +</selector> + diff --git a/java/com/android/contacts/common/res/drawable/ic_tab_starred.xml b/java/com/android/contacts/common/res/drawable/ic_tab_starred.xml new file mode 100644 index 000000000..a12e0993e --- /dev/null +++ b/java/com/android/contacts/common/res/drawable/ic_tab_starred.xml @@ -0,0 +1,21 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Copyright (C) 2008 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. +--> + +<selector xmlns:android="http://schemas.android.com/apk/res/android"> + <item android:drawable="@drawable/ic_menu_star_lt" android:state_selected="false"/> + <item android:drawable="@drawable/ic_menu_star_dk" android:state_selected="true"/> +</selector> + diff --git a/java/com/android/contacts/common/res/drawable/ic_work_profile.xml b/java/com/android/contacts/common/res/drawable/ic_work_profile.xml new file mode 100644 index 000000000..fc21100c0 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable/ic_work_profile.xml @@ -0,0 +1,16 @@ +<?xml version="1.0" encoding="utf-8"?> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:height="16dp" + android:viewportHeight="48" + android:viewportWidth="48" + android:width="16dp"> + + + <path + android:fillColor="#757575" + android:pathData="M28 33h-8v-3H6v8c0 2.2 1.8 4 4 4h28c2.2 0 4-1.8 +4-4v-8H28v3zm12-21h-7V9l-3-3H18l-3 3.1V12H8c-2.2 0-4 1.8-4 4v8c0 2.2 1.8 4 4 +4h12v-3h8v3h12c2.2 0 4-1.8 4-4v-8c0-2.2-1.8-4-4-4zm-10 0H18V9h12v3z"/> + <path + android:pathData="M0 0h48v48H0z"/> +</vector> diff --git a/java/com/android/contacts/common/res/drawable/item_background_material_borderless_dark.xml b/java/com/android/contacts/common/res/drawable/item_background_material_borderless_dark.xml new file mode 100644 index 000000000..94e309507 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable/item_background_material_borderless_dark.xml @@ -0,0 +1,19 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Copyright (C) 2014 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. +--> + +<!-- Based on the Theme.Material's default selectableItemBackgroundBorderless --> +<ripple xmlns:android="http://schemas.android.com/apk/res/android" + android:color="@color/dialer_ripple_material_dark"/>
\ No newline at end of file diff --git a/java/com/android/contacts/common/res/drawable/item_background_material_dark.xml b/java/com/android/contacts/common/res/drawable/item_background_material_dark.xml new file mode 100644 index 000000000..91ab763a5 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable/item_background_material_dark.xml @@ -0,0 +1,23 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Copyright (C) 2014 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. +--> + +<!-- Based on the Theme.Material's default selectableItemBackground --> +<ripple xmlns:android="http://schemas.android.com/apk/res/android" + android:color="@color/dialer_ripple_material_dark"> + <item android:id="@android:id/mask"> + <color android:color="@android:color/white"/> + </item> +</ripple>
\ No newline at end of file diff --git a/java/com/android/contacts/common/res/drawable/item_background_material_light.xml b/java/com/android/contacts/common/res/drawable/item_background_material_light.xml new file mode 100644 index 000000000..d41accb02 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable/item_background_material_light.xml @@ -0,0 +1,23 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Copyright (C) 2014 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. +--> + +<!-- Based on the Theme.Material's default selectableItemBackground --> +<ripple xmlns:android="http://schemas.android.com/apk/res/android" + android:color="@color/dialer_ripple_material_light"> + <item android:id="@android:id/mask"> + <color android:color="@android:color/white"/> + </item> +</ripple>
\ No newline at end of file diff --git a/java/com/android/contacts/common/res/drawable/list_item_activated_background.xml b/java/com/android/contacts/common/res/drawable/list_item_activated_background.xml new file mode 100644 index 000000000..5b774fd20 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable/list_item_activated_background.xml @@ -0,0 +1,20 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Copyright (C) 2008 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. +--> + +<selector xmlns:android="http://schemas.android.com/apk/res/android"> + <item android:drawable="@drawable/list_activated_holo" android:state_activated="true"/> + <item android:drawable="@drawable/list_background_holo"/> +</selector> diff --git a/java/com/android/contacts/common/res/drawable/list_selector_background_transition_holo_light.xml b/java/com/android/contacts/common/res/drawable/list_selector_background_transition_holo_light.xml new file mode 100644 index 000000000..35fff99c2 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable/list_selector_background_transition_holo_light.xml @@ -0,0 +1,20 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Copyright (C) 2010 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. +--> + +<transition xmlns:android="http://schemas.android.com/apk/res/android"> + <item android:drawable="@drawable/list_pressed_holo_light"/> + <item android:drawable="@drawable/list_longpressed_holo_light"/> +</transition> diff --git a/java/com/android/contacts/common/res/drawable/searchedittext_custom_cursor.xml b/java/com/android/contacts/common/res/drawable/searchedittext_custom_cursor.xml new file mode 100644 index 000000000..27614a1ac --- /dev/null +++ b/java/com/android/contacts/common/res/drawable/searchedittext_custom_cursor.xml @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Copyright 2014 Google Inc. All Rights Reserved. --> + +<shape xmlns:android="http://schemas.android.com/apk/res/android"> + <size android:width="2dp"/> + <solid android:color="@color/dialtacts_theme_color"/> +</shape>
\ No newline at end of file diff --git a/java/com/android/contacts/common/res/drawable/unread_count_background.xml b/java/com/android/contacts/common/res/drawable/unread_count_background.xml new file mode 100644 index 000000000..4fc6b9b60 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable/unread_count_background.xml @@ -0,0 +1,21 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Copyright (C) 2015 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. +--> +<shape + xmlns:android="http://schemas.android.com/apk/res/android" + android:shape="rectangle"> + <corners android:radius="@dimen/tab_unread_count_background_radius"/> + <solid android:color="@color/tab_unread_count_background_color"/> +</shape> diff --git a/java/com/android/contacts/common/res/drawable/view_pager_tab_background.xml b/java/com/android/contacts/common/res/drawable/view_pager_tab_background.xml new file mode 100644 index 000000000..bef30a434 --- /dev/null +++ b/java/com/android/contacts/common/res/drawable/view_pager_tab_background.xml @@ -0,0 +1,22 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (C) 2014 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 + --> +<ripple xmlns:android="http://schemas.android.com/apk/res/android" + android:color="@color/tab_ripple_color"> + <item android:id="@android:id/mask"> + <color android:color="@android:color/white"/> + </item> +</ripple>
\ No newline at end of file diff --git a/java/com/android/contacts/common/res/layout-ldrtl/unread_count_tab.xml b/java/com/android/contacts/common/res/layout-ldrtl/unread_count_tab.xml new file mode 100644 index 000000000..2aa97722d --- /dev/null +++ b/java/com/android/contacts/common/res/layout-ldrtl/unread_count_tab.xml @@ -0,0 +1,48 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Copyright (C) 2015 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. +--> +<!-- layoutDirection set to ltr as a workaround to a framework bug (b/22010411) causing view with + layout_centerInParent inside a RelativeLayout to expand to screen width when RTL is active --> +<RelativeLayout + xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:background="@drawable/view_pager_tab_background" + android:layoutDirection="ltr"> + <!-- The tab icon --> + <ImageView + android:id="@+id/icon" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_centerInParent="true"/> + <TextView + android:id="@+id/count" + android:layout_width="wrap_content" + android:layout_height="@dimen/tab_unread_count_background_size" + android:layout_marginTop="@dimen/tab_unread_count_margin_top" + android:layout_marginStart="@dimen/tab_unread_count_margin_left" + android:layout_toStartOf="@id/icon" + android:paddingLeft="@dimen/tab_unread_count_text_padding" + android:paddingRight="@dimen/tab_unread_count_text_padding" + android:background="@drawable/unread_count_background" + android:fontFamily="sans-serif-medium" + android:importantForAccessibility="no" + android:layoutDirection="locale" + android:minWidth="@dimen/tab_unread_count_background_size" + android:textAlignment="center" + android:textColor="@color/tab_accent_color" + android:textSize="@dimen/tab_unread_count_text_size"/> +</RelativeLayout> + diff --git a/java/com/android/contacts/common/res/layout/account_filter_header.xml b/java/com/android/contacts/common/res/layout/account_filter_header.xml new file mode 100644 index 000000000..a12ab08fd --- /dev/null +++ b/java/com/android/contacts/common/res/layout/account_filter_header.xml @@ -0,0 +1,44 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Copyright (C) 2011 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. +--> + +<!-- Layout showing the type of account filter + (e.g. All contacts filter, custom filter, etc.), + which is the header of all contact lists. --> + +<!-- Solely used to set a background color --> +<FrameLayout + xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:background="@color/background_primary"> + <!-- Used to show the touch feedback --> + <FrameLayout + android:id="@+id/account_filter_header_container" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginStart="@dimen/contact_browser_list_header_left_margin" + android:layout_marginEnd="@dimen/contact_browser_list_header_right_margin" + android:paddingTop="@dimen/list_header_extra_top_padding" + android:background="?android:attr/selectableItemBackground" + android:visibility="gone"> + <!-- Shows the text and underlining --> + <TextView + android:id="@+id/account_filter_header" + style="@style/ContactListSeparatorTextViewStyle" + android:paddingStart="@dimen/contact_browser_list_item_text_indent" + android:paddingLeft="@dimen/contact_browser_list_item_text_indent"/> + </FrameLayout> +</FrameLayout> diff --git a/java/com/android/contacts/common/res/layout/account_selector_list_item.xml b/java/com/android/contacts/common/res/layout/account_selector_list_item.xml new file mode 100644 index 000000000..587626e8d --- /dev/null +++ b/java/com/android/contacts/common/res/layout/account_selector_list_item.xml @@ -0,0 +1,57 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Copyright (C) 2010 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. +--> + +<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="match_parent" + android:layout_height="?android:attr/listPreferredItemHeight" + android:orientation="horizontal"> + <ImageView + android:id="@android:id/icon" + android:layout_width="@dimen/detail_network_icon_size" + android:layout_height="@dimen/detail_network_icon_size" + android:layout_margin="16dip" + android:layout_gravity="center_vertical"/> + + <LinearLayout + android:layout_width="0dip" + android:layout_height="wrap_content" + android:layout_weight="1" + android:layout_marginLeft="8dp" + android:layout_gravity="center_vertical" + android:orientation="vertical"> + + <TextView + android:id="@android:id/text1" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginEnd="8dip" + android:layout_marginRight="8dip" + android:ellipsize="end" + android:singleLine="true" + android:textAppearance="?android:attr/textAppearanceMedium"/> + + <TextView + android:id="@android:id/text2" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginEnd="8dip" + android:layout_marginRight="8dip" + android:ellipsize="end" + android:singleLine="true" + android:textAppearance="?android:attr/textAppearanceSmall" + android:textColor="?android:attr/textColorSecondary"/> + </LinearLayout> +</LinearLayout> diff --git a/java/com/android/contacts/common/res/layout/account_selector_list_item_condensed.xml b/java/com/android/contacts/common/res/layout/account_selector_list_item_condensed.xml new file mode 100644 index 000000000..33821166e --- /dev/null +++ b/java/com/android/contacts/common/res/layout/account_selector_list_item_condensed.xml @@ -0,0 +1,56 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Copyright (C) 2015 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. +--> + +<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="match_parent" + android:layout_height="?android:attr/listPreferredItemHeight" + android:orientation="horizontal"> + <ImageView + android:id="@android:id/icon" + android:layout_width="@dimen/detail_network_icon_size" + android:layout_height="@dimen/detail_network_icon_size" + android:layout_margin="24dip" + android:layout_gravity="center_vertical"/> + + <LinearLayout + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_weight="1" + android:layout_gravity="center_vertical" + android:orientation="vertical"> + + <TextView + android:id="@android:id/text1" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginEnd="8dip" + android:layout_marginRight="8dip" + android:ellipsize="end" + android:singleLine="true" + android:textAppearance="?android:attr/textAppearanceMedium"/> + + <TextView + android:id="@android:id/text2" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginEnd="8dip" + android:layout_marginRight="8dip" + android:ellipsize="end" + android:singleLine="true" + android:textAppearance="?android:attr/textAppearanceSmall" + android:textColor="?android:attr/textColorSecondary"/> + </LinearLayout> +</LinearLayout> diff --git a/java/com/android/contacts/common/res/layout/call_subject_history.xml b/java/com/android/contacts/common/res/layout/call_subject_history.xml new file mode 100644 index 000000000..733f1d8b6 --- /dev/null +++ b/java/com/android/contacts/common/res/layout/call_subject_history.xml @@ -0,0 +1,33 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (C) 2015 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 + --> + +<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" + android:id="@+id/background" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:background="@android:color/transparent"> + + <ListView + android:id="@+id/subject_list" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_alignParentBottom="true" + android:background="@color/call_subject_history_background" + android:divider="@null" + android:elevation="8dp"/> + +</RelativeLayout>
\ No newline at end of file diff --git a/java/com/android/contacts/common/res/layout/call_subject_history_list_item.xml b/java/com/android/contacts/common/res/layout/call_subject_history_list_item.xml new file mode 100644 index 000000000..c378f24b2 --- /dev/null +++ b/java/com/android/contacts/common/res/layout/call_subject_history_list_item.xml @@ -0,0 +1,29 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (C) 2015 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 + --> + +<TextView xmlns:android="http://schemas.android.com/apk/res/android" + android:id="@android:id/text1" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:paddingTop="@dimen/call_subject_history_item_padding" + android:paddingBottom="@dimen/call_subject_history_item_padding" + android:paddingStart="@dimen/call_subject_dialog_margin" + android:paddingEnd="@dimen/call_subject_dialog_margin" + android:gravity="center_vertical" + android:singleLine="true" + android:textColor="@color/dialer_primary_text_color" + android:textSize="@dimen/call_subject_dialog_primary_text_size"/> diff --git a/java/com/android/contacts/common/res/layout/contact_detail_list_padding.xml b/java/com/android/contacts/common/res/layout/contact_detail_list_padding.xml new file mode 100644 index 000000000..02a5c809c --- /dev/null +++ b/java/com/android/contacts/common/res/layout/contact_detail_list_padding.xml @@ -0,0 +1,27 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Copyright (C) 2011 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. +--> + +<!-- The actual padding is embedded in a FrameLayout since we cannot change the + visibility of a header view in a ListView without having a parent view. --> +<FrameLayout + xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="match_parent" + android:layout_height="wrap_content"> + <View + android:id="@+id/contact_detail_list_padding" + android:layout_width="match_parent" + android:layout_height="@dimen/list_header_extra_top_padding"/> +</FrameLayout> diff --git a/java/com/android/contacts/common/res/layout/contact_list_card.xml b/java/com/android/contacts/common/res/layout/contact_list_card.xml new file mode 100644 index 000000000..a04f4cad9 --- /dev/null +++ b/java/com/android/contacts/common/res/layout/contact_list_card.xml @@ -0,0 +1,39 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Copyright (C) 2014 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. +--> +<LinearLayout + xmlns:android="http://schemas.android.com/apk/res/android" + android:id="@+id/list_card" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:orientation="horizontal" + android:visibility="invisible"> + <View + android:layout_width="0dp" + android:layout_height="match_parent" + android:layout_weight="@integer/contact_list_space_layout_weight" + android:background="@color/background_primary"/> + <View + android:layout_width="0dp" + android:layout_height="match_parent" + android:layout_weight="@integer/contact_list_card_layout_weight" + android:background="@color/contact_all_list_background_color" + android:elevation="@dimen/contact_list_card_elevation"/> + <View + android:layout_width="0dp" + android:layout_height="match_parent" + android:layout_weight="@integer/contact_list_space_layout_weight" + android:background="@color/background_primary"/> +</LinearLayout> diff --git a/java/com/android/contacts/common/res/layout/contact_list_content.xml b/java/com/android/contacts/common/res/layout/contact_list_content.xml new file mode 100644 index 000000000..3ee27a0ad --- /dev/null +++ b/java/com/android/contacts/common/res/layout/contact_list_content.xml @@ -0,0 +1,61 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Copyright (C) 2008 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. +--> + +<!-- android:paddingTop is used instead of android:layout_marginTop. It looks + android:layout_marginTop is ignored when used with <fragment></fragment>, which + only happens in Tablet UI since we rely on ViewPager in Phone UI. + Instead, android:layout_marginTop inside <fragment /> is effective. --> + +<LinearLayout + xmlns:android="http://schemas.android.com/apk/res/android" + android:id="@+id/pinned_header_list_layout" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:background="?attr/contact_browser_background" + android:orientation="vertical"> + + <!-- Shown only when an Account filter is set. + - paddingTop should be here to show "shade" effect correctly. --> + <include layout="@layout/account_filter_header"/> + + <FrameLayout + android:layout_width="match_parent" + android:layout_height="0dip" + android:layout_weight="1"> + <include layout="@layout/contact_list_card"/> + <view + android:id="@android:id/list" + class="com.android.contacts.common.list.PinnedHeaderListView" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:layout_marginStart="?attr/contact_browser_list_padding_left" + android:layout_marginEnd="?attr/contact_browser_list_padding_right" + android:layout_marginLeft="?attr/contact_browser_list_padding_left" + android:layout_marginRight="?attr/contact_browser_list_padding_right" + android:paddingTop="?attr/list_item_padding_top" + android:clipToPadding="false" + android:fadingEdge="none" + android:fastScrollEnabled="true"/> + <ProgressBar + android:id="@+id/search_progress" + style="?android:attr/progressBarStyleLarge" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_gravity="center" + android:visibility="gone"/> + </FrameLayout> + +</LinearLayout> diff --git a/java/com/android/contacts/common/res/layout/default_account_checkbox.xml b/java/com/android/contacts/common/res/layout/default_account_checkbox.xml new file mode 100644 index 000000000..b7c0cf644 --- /dev/null +++ b/java/com/android/contacts/common/res/layout/default_account_checkbox.xml @@ -0,0 +1,36 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Copyright (C) 2014 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. +--> + +<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" + android:id="@+id/default_account_checkbox_layout" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:padding="4dp" + android:orientation="vertical"> + <CheckBox + android:id="@+id/default_account_checkbox_view" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginBottom="20dip" + android:layout_marginLeft="13dip" + android:paddingStart="15dip" + android:gravity="center" + android:text="@string/set_default_account" + android:textAlignment="viewStart" + android:textAppearance="?android:attr/textAppearanceSmall" + android:textColor="@color/dialer_secondary_text_color" + /> +</LinearLayout> diff --git a/java/com/android/contacts/common/res/layout/dialog_call_subject.xml b/java/com/android/contacts/common/res/layout/dialog_call_subject.xml new file mode 100644 index 000000000..709bb50cb --- /dev/null +++ b/java/com/android/contacts/common/res/layout/dialog_call_subject.xml @@ -0,0 +1,159 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (C) 2015 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 + --> + +<LinearLayout + xmlns:android="http://schemas.android.com/apk/res/android" + android:id="@+id/call_subject_dialog" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:background="@android:color/transparent" + android:orientation="vertical"> + + <RelativeLayout + android:layout_width="match_parent" + android:layout_height="0dp" + android:layout_weight="1"> + + <!-- The call subject dialog will be centered in the space above the subject list. --> + <LinearLayout + android:id="@+id/dialog_view" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_centerInParent="true" + android:background="@drawable/dialog_background_material" + android:clickable="true" + android:elevation="16dp" + android:orientation="vertical" + android:theme="@android:style/Theme.Material.Light.Dialog"> + + <LinearLayout + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginTop="@dimen/call_subject_dialog_margin" + android:layout_marginStart="@dimen/call_subject_dialog_margin" + android:layout_marginEnd="@dimen/call_subject_dialog_margin" + android:orientation="horizontal"> + + <QuickContactBadge + android:id="@+id/contact_photo" + android:layout_width="@dimen/call_subject_dialog_contact_photo_size" + android:layout_height="@dimen/call_subject_dialog_contact_photo_size" + android:layout_marginEnd="@dimen/call_subject_dialog_margin" + android:layout_gravity="top" + android:focusable="true"/> + + <LinearLayout + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:gravity="center_vertical" + android:orientation="vertical"> + + <TextView + android:id="@+id/name" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:singleLine="true" + android:textColor="@color/dialer_primary_text_color" + android:textSize="@dimen/call_subject_dialog_secondary_text_size"/> + + <TextView + android:id="@+id/number" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginTop="@dimen/call_subject_dialog_between_line_margin" + android:layout_gravity="center_vertical" + android:singleLine="true" + android:textColor="@color/dialer_secondary_text_color" + android:textSize="@dimen/call_subject_dialog_secondary_text_size"/> + </LinearLayout> + </LinearLayout> + + <EditText + android:id="@+id/call_subject" + android:layout_width="match_parent" + android:layout_height="0dp" + android:layout_weight="1" + android:layout_marginTop="@dimen/call_subject_dialog_edit_spacing" + android:layout_marginStart="@dimen/call_subject_dialog_margin" + android:layout_marginEnd="@dimen/call_subject_dialog_margin" + android:layout_gravity="top" + android:background="@null" + android:gravity="top" + android:hint="@string/call_subject_hint" + android:textColor="@color/dialer_secondary_text_color" + android:textSize="@dimen/call_subject_dialog_secondary_text_size" + /> + + <TextView + android:id="@+id/character_limit" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginTop="@dimen/call_subject_dialog_margin" + android:layout_marginBottom="@dimen/call_subject_dialog_margin" + android:layout_marginStart="@dimen/call_subject_dialog_margin" + android:layout_marginEnd="@dimen/call_subject_dialog_margin" + android:singleLine="true" + android:textColor="@color/dialer_secondary_text_color" + android:textSize="@dimen/call_subject_dialog_secondary_text_size"/> + + <View + android:layout_width="fill_parent" + android:layout_height="1dp" + android:background="@color/call_subject_divider"/> + + <RelativeLayout + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginTop="@dimen/call_subject_dialog_margin" + android:layout_marginBottom="@dimen/call_subject_dialog_margin" + android:layout_marginStart="@dimen/call_subject_dialog_margin" + android:layout_marginEnd="@dimen/call_subject_dialog_margin"> + + <ImageView + android:id="@+id/history_button" + android:layout_width="25dp" + android:layout_height="25dp" + android:layout_alignParentStart="true" + android:layout_centerVertical="true" + android:src="@drawable/ic_history_white_drawable_24dp" + android:tint="@color/call_subject_history_icon"/> + + <TextView + android:id="@+id/send_and_call_button" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_alignParentEnd="true" + android:layout_centerVertical="true" + android:singleLine="true" + android:text="@string/send_and_call_button" + android:textColor="@color/call_subject_button" + android:textSize="@dimen/call_subject_dialog_secondary_text_size"/> + + </RelativeLayout> + </LinearLayout> + </RelativeLayout> + <!-- The subject list is pinned to the bottom of the screen. --> + <ListView + android:id="@+id/subject_list" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_below="@id/dialog_view" + android:background="@color/call_subject_history_background" + android:divider="@null" + android:elevation="8dp"/> + +</LinearLayout> diff --git a/java/com/android/contacts/common/res/layout/directory_header.xml b/java/com/android/contacts/common/res/layout/directory_header.xml new file mode 100644 index 000000000..b8f5163c0 --- /dev/null +++ b/java/com/android/contacts/common/res/layout/directory_header.xml @@ -0,0 +1,55 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Copyright (C) 2009 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. +--> + +<!-- Layout used for list section separators. --> +<LinearLayout + xmlns:android="http://schemas.android.com/apk/res/android" + android:id="@+id/directory_header" + style="@style/DirectoryHeader" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:paddingTop="@dimen/directory_header_extra_top_padding" + android:paddingBottom="@dimen/directory_header_extra_bottom_padding" + android:paddingStart="?attr/list_item_padding_left" + android:paddingEnd="?attr/list_item_padding_right" + android:paddingLeft="?attr/list_item_padding_left" + android:paddingRight="?attr/list_item_padding_right" + android:background="?attr/contact_browser_background" + android:minHeight="@dimen/list_section_divider_min_height"> + <TextView + android:id="@+id/label" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:singleLine="true" + android:textAlignment="viewStart" + android:textAppearance="@style/DirectoryHeaderStyle"/> + <TextView + android:id="@+id/display_name" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginStart="8dp" + android:singleLine="true" + android:textAlignment="viewStart" + android:textAppearance="@style/DirectoryHeaderStyle"/> + <TextView + android:id="@+id/count" + android:layout_width="0dip" + android:layout_height="wrap_content" + android:paddingTop="1dip" + android:gravity="end" + android:singleLine="true" + android:textAppearance="@style/DirectoryHeaderStyle"/> +</LinearLayout> diff --git a/java/com/android/contacts/common/res/layout/list_separator.xml b/java/com/android/contacts/common/res/layout/list_separator.xml new file mode 100644 index 000000000..ab60605c5 --- /dev/null +++ b/java/com/android/contacts/common/res/layout/list_separator.xml @@ -0,0 +1,27 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Copyright (C) 2008 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. +--> +<TextView + xmlns:android="http://schemas.android.com/apk/res/android" + android:id="@+id/title" + android:textStyle="bold" + android:paddingTop="16dip" + android:paddingBottom="15dip" + android:paddingStart="16dip" + android:paddingEnd="16dip" + android:paddingLeft="16dip" + android:paddingRight="16dip" + android:textColor="@color/frequently_contacted_title_color" + android:textSize="@dimen/frequently_contacted_title_text_size"/> diff --git a/java/com/android/contacts/common/res/layout/search_bar_expanded.xml b/java/com/android/contacts/common/res/layout/search_bar_expanded.xml new file mode 100644 index 000000000..8a3bd6088 --- /dev/null +++ b/java/com/android/contacts/common/res/layout/search_bar_expanded.xml @@ -0,0 +1,62 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Copyright (C) 2014 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. +--> +<LinearLayout + xmlns:android="http://schemas.android.com/apk/res/android" + android:id="@+id/search_box_expanded" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:gravity="center_vertical" + android:orientation="horizontal" + android:visibility="gone"> + + <ImageButton + android:id="@+id/search_back_button" + android:layout_width="@dimen/search_box_icon_size" + android:layout_height="@dimen/search_box_icon_size" + android:layout_margin="@dimen/search_box_navigation_icon_margin" + android:background="?attr/selectableItemBackgroundBorderless" + android:contentDescription="@string/action_menu_back_from_search" + android:src="@drawable/ic_back_arrow" + android:tint="@color/contactscommon_actionbar_background_color"/> + + <EditText + android:id="@+id/search_view" + android:layout_width="0dp" + android:layout_height="@dimen/search_box_icon_size" + android:layout_weight="1" + android:layout_marginLeft="@dimen/search_box_text_left_margin" + android:background="@null" + android:fontFamily="@string/search_font_family" + android:imeOptions="flagNoExtractUi" + android:inputType="textFilter" + android:singleLine="true" + android:textColor="@color/searchbox_text_color" + android:textColorHint="@color/searchbox_hint_text_color" + android:textCursorDrawable="@drawable/searchedittext_custom_cursor" + android:textSize="@dimen/search_text_size"/> + + <ImageView + android:id="@+id/search_close_button" + android:layout_width="@dimen/search_box_close_icon_size" + android:layout_height="@dimen/search_box_close_icon_size" + android:padding="@dimen/search_box_close_icon_padding" + android:background="?attr/selectableItemBackgroundBorderless" + android:clickable="true" + android:contentDescription="@string/description_clear_search" + android:src="@drawable/ic_close_dk" + android:tint="@color/searchbox_icon_tint"/> + +</LinearLayout> diff --git a/java/com/android/contacts/common/res/layout/select_account_list_item.xml b/java/com/android/contacts/common/res/layout/select_account_list_item.xml new file mode 100644 index 000000000..fbd31e573 --- /dev/null +++ b/java/com/android/contacts/common/res/layout/select_account_list_item.xml @@ -0,0 +1,56 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Copyright (C) 2014 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. +--> + +<!-- Layout of a single item in the InCallUI Account Chooser Dialog. --> +<com.android.contacts.common.widget.ActivityTouchLinearLayout + xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:padding="8dp" + android:orientation="horizontal"> + + <ImageView + android:id="@+id/icon" + android:layout_width="48dp" + android:layout_height="48dp" + android:scaleType="center"/> + + <LinearLayout + android:id="@+id/text" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_weight="1" + android:layout_marginStart="8dp" + android:gravity="start|center_vertical" + android:orientation="vertical"> + <TextView + android:id="@+id/label" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:includeFontPadding="false" + android:textAppearance="?android:attr/textAppearanceMedium" + android:textColor="@color/dialer_primary_text_color"/> + <TextView + android:id="@+id/number" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:includeFontPadding="false" + android:maxLines="1" + android:textAppearance="?android:attr/textAppearanceSmall" + android:visibility="gone"/> + </LinearLayout> + +</com.android.contacts.common.widget.ActivityTouchLinearLayout> diff --git a/java/com/android/contacts/common/res/layout/unread_count_tab.xml b/java/com/android/contacts/common/res/layout/unread_count_tab.xml new file mode 100644 index 000000000..83481ee2d --- /dev/null +++ b/java/com/android/contacts/common/res/layout/unread_count_tab.xml @@ -0,0 +1,43 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Copyright (C) 2015 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. +--> +<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:background="@drawable/view_pager_tab_background"> + <!-- The tab icon --> + <ImageView + android:id="@+id/icon" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_centerInParent="true"/> + <TextView + android:id="@+id/count" + android:layout_width="wrap_content" + android:layout_height="@dimen/tab_unread_count_background_size" + android:layout_marginTop="@dimen/tab_unread_count_margin_top" + android:layout_marginStart="@dimen/tab_unread_count_margin_left" + android:layout_toEndOf="@id/icon" + android:paddingLeft="@dimen/tab_unread_count_text_padding" + android:paddingRight="@dimen/tab_unread_count_text_padding" + android:background="@drawable/unread_count_background" + android:fontFamily="sans-serif-medium" + android:gravity="center" + android:importantForAccessibility="no" + android:minWidth="@dimen/tab_unread_count_background_size" + android:textAlignment="center" + android:textColor="@color/tab_accent_color" + android:textSize="@dimen/tab_unread_count_text_size"/> +</RelativeLayout> diff --git a/java/com/android/contacts/common/res/mipmap-hdpi/ic_contacts_launcher.png b/java/com/android/contacts/common/res/mipmap-hdpi/ic_contacts_launcher.png Binary files differnew file mode 100644 index 000000000..64eff002f --- /dev/null +++ b/java/com/android/contacts/common/res/mipmap-hdpi/ic_contacts_launcher.png diff --git a/java/com/android/contacts/common/res/mipmap-mdpi/ic_contacts_launcher.png b/java/com/android/contacts/common/res/mipmap-mdpi/ic_contacts_launcher.png Binary files differnew file mode 100644 index 000000000..b4ee8215a --- /dev/null +++ b/java/com/android/contacts/common/res/mipmap-mdpi/ic_contacts_launcher.png diff --git a/java/com/android/contacts/common/res/mipmap-xhdpi/ic_contacts_launcher.png b/java/com/android/contacts/common/res/mipmap-xhdpi/ic_contacts_launcher.png Binary files differnew file mode 100644 index 000000000..6feeadfbe --- /dev/null +++ b/java/com/android/contacts/common/res/mipmap-xhdpi/ic_contacts_launcher.png diff --git a/java/com/android/contacts/common/res/mipmap-xxhdpi/ic_contacts_launcher.png b/java/com/android/contacts/common/res/mipmap-xxhdpi/ic_contacts_launcher.png Binary files differnew file mode 100644 index 000000000..01a3fde9d --- /dev/null +++ b/java/com/android/contacts/common/res/mipmap-xxhdpi/ic_contacts_launcher.png diff --git a/java/com/android/contacts/common/res/mipmap-xxxhdpi/ic_contacts_launcher.png b/java/com/android/contacts/common/res/mipmap-xxxhdpi/ic_contacts_launcher.png Binary files differnew file mode 100644 index 000000000..328e067ee --- /dev/null +++ b/java/com/android/contacts/common/res/mipmap-xxxhdpi/ic_contacts_launcher.png diff --git a/java/com/android/contacts/common/res/values-ja/donottranslate_config.xml b/java/com/android/contacts/common/res/values-ja/donottranslate_config.xml new file mode 100644 index 000000000..e05c6d658 --- /dev/null +++ b/java/com/android/contacts/common/res/values-ja/donottranslate_config.xml @@ -0,0 +1,20 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- If true, an option is shown in Display Options UI to choose a sort order --> + <bool name="config_sort_order_user_changeable">false</bool> + + <!-- If true, the default sort order is primary (i.e. by given name) --> + <bool name="config_default_sort_order_primary">true</bool> + + <!-- If true, an option is shown in Display Options UI to choose a name display order --> + <bool name="config_display_order_user_changeable">false</bool> + + <!-- If true, the default sort order is primary (i.e. by given name) --> + <bool name="config_default_display_order_primary">true</bool> + + <!-- If true, the order of name fields in the editor is primary (i.e. given name first) --> + <bool name="config_editor_field_order_primary">false</bool> + + <!-- If true, phonetic name is included in the contact editor by default --> + <bool name="config_editor_include_phonetic_name">true</bool> +</resources>
\ No newline at end of file diff --git a/java/com/android/contacts/common/res/values-ko/donottranslate_config.xml b/java/com/android/contacts/common/res/values-ko/donottranslate_config.xml new file mode 100644 index 000000000..8def55498 --- /dev/null +++ b/java/com/android/contacts/common/res/values-ko/donottranslate_config.xml @@ -0,0 +1,17 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- If true, an option is shown in Display Options UI to choose a sort order --> + <bool name="config_sort_order_user_changeable">false</bool> + + <!-- If true, the default sort order is primary (i.e. by given name) --> + <bool name="config_default_sort_order_primary">false</bool> + + <!-- If true, an option is shown in Display Options UI to choose a name display order --> + <bool name="config_display_order_user_changeable">false</bool> + + <!-- If true, the default sort order is primary (i.e. by given name) --> + <bool name="config_default_display_order_primary">false</bool> + + <!-- If true, the order of name fields in the editor is primary (i.e. given name first) --> + <bool name="config_editor_field_order_primary">false</bool> +</resources>
\ No newline at end of file diff --git a/java/com/android/contacts/common/res/values-land/integers.xml b/java/com/android/contacts/common/res/values-land/integers.xml new file mode 100644 index 000000000..26bac6222 --- /dev/null +++ b/java/com/android/contacts/common/res/values-land/integers.xml @@ -0,0 +1,22 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2012 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> + <integer name="contact_tile_column_count_in_favorites">3</integer> + + <!-- The number of characters in the snippet before we need to tokenize and ellipse. --> + <integer name="snippet_length_before_tokenize">60</integer> +</resources> diff --git a/java/com/android/contacts/common/res/values-sw600dp-land/integers.xml b/java/com/android/contacts/common/res/values-sw600dp-land/integers.xml new file mode 100644 index 000000000..be4eb0bb0 --- /dev/null +++ b/java/com/android/contacts/common/res/values-sw600dp-land/integers.xml @@ -0,0 +1,22 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2012 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> + <integer name="contact_tile_column_count_in_favorites">3</integer> + + <!-- The number of characters in the snippet before we need to tokenize and ellipse. --> + <integer name="snippet_length_before_tokenize">20</integer> +</resources> diff --git a/java/com/android/contacts/common/res/values-sw600dp/dimens.xml b/java/com/android/contacts/common/res/values-sw600dp/dimens.xml new file mode 100644 index 000000000..cf67a1e72 --- /dev/null +++ b/java/com/android/contacts/common/res/values-sw600dp/dimens.xml @@ -0,0 +1,29 @@ +<!-- + ~ Copyright (C) 2012 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="detail_item_side_margin">0dip</dimen> + + <dimen name="contact_browser_list_header_left_margin">@dimen/list_visible_scrollbar_padding + </dimen> + <dimen name="contact_browser_list_header_right_margin">24dip</dimen> + <dimen name="contact_browser_list_top_margin">16dip</dimen> + + <!-- Right margin of the floating action button --> + <dimen name="floating_action_button_margin_right">32dp</dimen> + <!-- Bottom margin of the floating action button --> + <dimen name="floating_action_button_margin_bottom">32dp</dimen> +</resources> diff --git a/java/com/android/contacts/common/res/values-sw600dp/integers.xml b/java/com/android/contacts/common/res/values-sw600dp/integers.xml new file mode 100644 index 000000000..31aeee995 --- /dev/null +++ b/java/com/android/contacts/common/res/values-sw600dp/integers.xml @@ -0,0 +1,24 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2012 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> + <integer name="contact_tile_column_count_in_favorites">3</integer> + + <!-- The number of characters in the snippet before we need to tokenize and ellipse. --> + <!-- Yikes, there is less space on a tablet! This makes the search experience rather + poor. Another reason to get rid of the exist tablet layout. --> + <integer name="snippet_length_before_tokenize">15</integer> +</resources> diff --git a/java/com/android/contacts/common/res/values-sw720dp-land/integers.xml b/java/com/android/contacts/common/res/values-sw720dp-land/integers.xml new file mode 100644 index 000000000..577716d24 --- /dev/null +++ b/java/com/android/contacts/common/res/values-sw720dp-land/integers.xml @@ -0,0 +1,22 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2012 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> + <integer name="contact_tile_column_count_in_favorites">4</integer> + + <!-- The number of characters in the snippet before we need to tokenize and ellipse. --> + <integer name="snippet_length_before_tokenize">30</integer> +</resources> diff --git a/java/com/android/contacts/common/res/values-sw720dp/integers.xml b/java/com/android/contacts/common/res/values-sw720dp/integers.xml new file mode 100644 index 000000000..05e309351 --- /dev/null +++ b/java/com/android/contacts/common/res/values-sw720dp/integers.xml @@ -0,0 +1,22 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2012 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> + <integer name="contact_tile_column_count_in_favorites">2</integer> + + <!-- The number of characters in the snippet before we need to tokenize and ellipse. --> + <integer name="snippet_length_before_tokenize">20</integer> +</resources> diff --git a/java/com/android/contacts/common/res/values-zh-rCN/donottranslate_config.xml b/java/com/android/contacts/common/res/values-zh-rCN/donottranslate_config.xml new file mode 100644 index 000000000..08023992b --- /dev/null +++ b/java/com/android/contacts/common/res/values-zh-rCN/donottranslate_config.xml @@ -0,0 +1,17 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- If true, an option is shown in Display Options UI to choose a sort order --> + <bool name="config_sort_order_user_changeable">false</bool> + + <!-- If true, the default sort order is primary (i.e. by given name) --> + <bool name="config_default_sort_order_primary">true</bool> + + <!-- If true, an option is shown in Display Options UI to choose a name display order --> + <bool name="config_display_order_user_changeable">false</bool> + + <!-- If true, the default sort order is primary (i.e. by given name) --> + <bool name="config_default_display_order_primary">true</bool> + + <!-- If true, the order of name fields in the editor is primary (i.e. given name first) --> + <bool name="config_editor_field_order_primary">false</bool> +</resources>
\ No newline at end of file diff --git a/java/com/android/contacts/common/res/values-zh-rTW/donottranslate_config.xml b/java/com/android/contacts/common/res/values-zh-rTW/donottranslate_config.xml new file mode 100644 index 000000000..08023992b --- /dev/null +++ b/java/com/android/contacts/common/res/values-zh-rTW/donottranslate_config.xml @@ -0,0 +1,17 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- If true, an option is shown in Display Options UI to choose a sort order --> + <bool name="config_sort_order_user_changeable">false</bool> + + <!-- If true, the default sort order is primary (i.e. by given name) --> + <bool name="config_default_sort_order_primary">true</bool> + + <!-- If true, an option is shown in Display Options UI to choose a name display order --> + <bool name="config_display_order_user_changeable">false</bool> + + <!-- If true, the default sort order is primary (i.e. by given name) --> + <bool name="config_default_display_order_primary">true</bool> + + <!-- If true, the order of name fields in the editor is primary (i.e. given name first) --> + <bool name="config_editor_field_order_primary">false</bool> +</resources>
\ No newline at end of file diff --git a/java/com/android/contacts/common/res/values/animation_constants.xml b/java/com/android/contacts/common/res/values/animation_constants.xml new file mode 100644 index 000000000..9eec7d6c8 --- /dev/null +++ b/java/com/android/contacts/common/res/values/animation_constants.xml @@ -0,0 +1,19 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (C) 2014 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> + <integer name="floating_action_button_animation_duration">250</integer> +</resources> diff --git a/java/com/android/contacts/common/res/values/attrs.xml b/java/com/android/contacts/common/res/values/attrs.xml new file mode 100644 index 000000000..44d04f025 --- /dev/null +++ b/java/com/android/contacts/common/res/values/attrs.xml @@ -0,0 +1,83 @@ +<!-- + ~ Copyright (C) 2012 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> + <declare-styleable name="Theme"> + <attr name="android:textColorSecondary"/> + </declare-styleable> + + <declare-styleable name="ContactsDataKind"> + <!-- Mime-type handled by this mapping. --> + <attr name="android:mimeType"/> + <!-- Icon used to represent data of this kind. --> + <attr name="android:icon"/> + <!-- Column in data table that summarizes this data. --> + <attr name="android:summaryColumn"/> + <!-- Column in data table that contains details for this data. --> + <attr name="android:detailColumn"/> + <!-- Flag indicating that detail should be built from SocialProvider. --> + <attr name="android:detailSocialSummary"/> + <!-- Resource representing the term "All Contacts" (e.g. "All Friends" or + "All connections"). Optional (Default is "All Contacts"). --> + <attr name="android:allContactsName"/> + </declare-styleable> + + <declare-styleable name="ContactListItemView"> + <attr format="dimension" name="list_item_height"/> + <attr format="dimension" name="list_section_header_height"/> + <attr format="reference" name="activated_background"/> + <attr format="reference" name="section_header_background"/> + <attr format="dimension" name="list_item_padding_top"/> + <attr format="dimension" name="list_item_padding_right"/> + <attr format="dimension" name="list_item_padding_bottom"/> + <attr format="dimension" name="list_item_padding_left"/> + <attr format="dimension" name="list_item_gap_between_image_and_text"/> + <attr format="dimension" name="list_item_gap_between_label_and_data"/> + <attr format="dimension" name="list_item_presence_icon_margin"/> + <attr format="dimension" name="list_item_presence_icon_size"/> + <attr format="dimension" name="list_item_photo_size"/> + <attr format="dimension" name="list_item_profile_photo_size"/> + <attr format="color" name="list_item_prefix_highlight_color"/> + <attr format="color" name="list_item_background_color"/> + <attr format="dimension" name="list_item_header_text_indent"/> + <attr format="color" name="list_item_header_text_color"/> + <attr format="dimension" name="list_item_header_text_size"/> + <attr format="dimension" name="list_item_header_height"/> + <attr format="color" name="list_item_name_text_color"/> + <attr format="dimension" name="list_item_name_text_size"/> + <attr format="dimension" name="list_item_text_indent"/> + <attr format="dimension" name="list_item_text_offset_top"/> + <attr format="integer" name="list_item_data_width_weight"/> + <attr format="integer" name="list_item_label_width_weight"/> + <attr format="dimension" name="list_item_video_call_icon_size"/> + <attr format="dimension" name="list_item_video_call_icon_margin"/> + </declare-styleable> + + <declare-styleable name="ContactBrowser"> + <attr format="dimension" name="contact_browser_list_padding_left"/> + <attr format="dimension" name="contact_browser_list_padding_right"/> + <attr format="reference" name="contact_browser_background"/> + </declare-styleable> + + <declare-styleable name="ProportionalLayout"> + <attr format="string" name="direction"/> + <attr format="float" name="ratio"/> + </declare-styleable> + + <declare-styleable name="Favorites"> + <attr format="dimension" name="favorites_padding_bottom"/> + </declare-styleable> +</resources> diff --git a/java/com/android/contacts/common/res/values/colors.xml b/java/com/android/contacts/common/res/values/colors.xml new file mode 100644 index 000000000..7524eff58 --- /dev/null +++ b/java/com/android/contacts/common/res/values/colors.xml @@ -0,0 +1,158 @@ +<!-- + ~ Copyright (C) 2012 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> + <!-- Background color corresponding to the holo list 9-patch. --> + <color name="holo_list_background_color">#eeeeee</color> + + <color name="focus_color">#44ff0000</color> + + <!-- Color of ripples used for views with dark backgrounds --> + <color name="dialer_ripple_material_dark">#a0ffffff</color> + + <!-- Color of ripples used for views with light backgrounds --> + <color name="dialer_ripple_material_light">#30000000</color> + + <!-- Divider color for header separator --> + <color name="primary_text_color">#363636</color> + + <color name="secondary_text_color">@color/dialer_secondary_text_color</color> + + <!-- Text color for section header. --> + <color name="section_header_text_color">#2A56C6</color> + + <!-- Divider color for header separator --> + <color name="main_header_separator_color">#AAAAAA</color> + + <!-- Divider color for header separator --> + <color name="secondary_header_separator_color">#D0D0D0</color> + + <!-- Color of the theme of the People app --> + <color name="people_app_theme_color">#363636</color> + + <!-- Color of image view placeholder. --> + <color name="image_placeholder">#DDDDDD</color> + + <!-- Color of the semi-transparent shadow box on contact tiles --> + <color name="contact_tile_shadow_box_color">#7F000000</color> + + <!-- Color of the status message for starred contacts in the People app --> + <color name="people_contact_tile_status_color">#CCCCCC</color> + + <color name="shortcut_overlay_text_background">#7f000000</color> + + <color name="textColorIconOverlay">#fff</color> + <color name="textColorIconOverlayShadow">#000</color> + + + <array name="letter_tile_colors"> + <item>#DB4437</item> + <item>#E91E63</item> + <item>#9C27B0</item> + <item>#673AB7</item> + <item>#3F51B5</item> + <item>#4285F4</item> + <item>#039BE5</item> + <item>#0097A7</item> + <item>#009688</item> + <item>#0F9D58</item> + <item>#689F38</item> + <item>#EF6C00</item> + <item>#FF5722</item> + <item>#757575</item> + </array> + + <!-- Darker versions of letter_tile_colors, two shades darker. These colors are used + for settings secondary activity colors. --> + <array name="letter_tile_colors_dark"> + <item>#C53929</item> + <item>#C2185B</item> + <item>#7B1FA2</item> + <item>#512DA8</item> + <item>#303F9F</item> + <item>#3367D6</item> + <item>#0277BD</item> + <item>#006064</item> + <item>#00796B</item> + <item>#0B8043</item> + <item>#33691E</item> + <item>#E65100</item> + <item>#E64A19</item> + <item>#424242</item> + </array> + + <!-- The default color used for tinting photos when no color can be extracted via Palette, + this is Blue Grey 500 --> + <color name="quickcontact_default_photo_tint_color">#607D8B</color> + <!-- The default secondary color when no color can be extracted via Palette, + this is Blue Grey 700 --> + <color name="quickcontact_default_photo_tint_color_dark">#455A64</color> + + + <color name="letter_tile_default_color">#cccccc</color> + + <color name="letter_tile_font_color">#ffffff</color> + + <color name="contactscommon_actionbar_background_color">@color/dialer_theme_color</color> + <!-- Color for icons in the actionbar --> + <color name="actionbar_icon_color">#ffffff</color> + <!-- Darker version of the actionbar color. Used for the status bar and navigation bar colors. --> + <color name="actionbar_background_color_dark">#008aa1</color> + + <color name="tab_ripple_color">#ffffff</color> + <color name="tab_accent_color">@color/tab_ripple_color</color> + <color name="tab_selected_underline_color">#f50057</color> + <color name="tab_unread_count_background_color">#1C3AA9</color> + + <!-- Color of the title to the Frequently Contacted section --> + <color name="frequently_contacted_title_color">@color/contactscommon_actionbar_background_color + </color> + + <!-- Color of action bar text. Ensure this stays in sync with packages/Telephony + phone_settings_actionbar_text_color--> + <color name="actionbar_text_color">#ffffff</color> + <color name="actionbar_unselected_text_color">#a6ffffff</color> + + <!-- Text color of the search box text as entered by user --> + <color name="searchbox_text_color">#000000</color> + <!-- Background color of the search box --> + <color name="searchbox_background_color">#ffffff</color> + + <color name="searchbox_hint_text_color">#737373</color> + <color name="searchbox_icon_tint">@color/searchbox_hint_text_color</color> + + <color name="search_shortcut_icon_color">@color/dialtacts_theme_color</color> + + <!-- Color of the background of the contact detail and editor pages --> + <color name="background_primary">#f9f9f9</color> + <color name="contact_all_list_background_color">#FFFFFF</color> + + <!-- Text color used for character counter when the max limit has been exceeded --> + <color name="call_subject_limit_exceeded">#d1041c</color> + + <!-- Tint color for the call subject history icon. --> + <color name="call_subject_history_icon">#000000</color> + + <!-- Divider line on the call subject dialog. --> + <color name="call_subject_divider">#d8d8d8</color> + + <!-- Text color for the SEND & CALL button on the call subject dialog. --> + <color name="call_subject_button">#00c853</color> + + <!-- Background color for the call subject history view. --> + <color name="call_subject_history_background">#ffffff</color> + <color name="search_video_call_icon_tint">@color/searchbox_hint_text_color</color> +</resources> diff --git a/java/com/android/contacts/common/res/values/dimens.xml b/java/com/android/contacts/common/res/values/dimens.xml new file mode 100644 index 000000000..642eb31a4 --- /dev/null +++ b/java/com/android/contacts/common/res/values/dimens.xml @@ -0,0 +1,161 @@ +<!-- + ~ Copyright (C) 2012 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> + + <!-- Padding between the action bar's bottom edge and the first header + in contacts/group lists. --> + <dimen name="list_header_extra_top_padding">0dip</dimen> + + <!-- Minimum height used with @drawable/list_section_divider_holo_custom. + Right now the drawable has implicit 32dip minimal height, which is confusing. + This value is for making the hidden configuration explicit in xml. --> + <dimen name="list_section_divider_min_height">32dip</dimen> + + <dimen name="directory_header_extra_top_padding">18dp</dimen> + <dimen name="directory_header_extra_bottom_padding">8dp</dimen> + + <!-- Horizontal padding in between contact tiles --> + <dimen name="contact_tile_divider_padding">23dip</dimen> + <!-- Horizontal whitespace (both padding and margin) before the first tile and after the last tile --> + <dimen name="contact_tile_start_end_whitespace">16dip</dimen> + + <!-- Left and right padding for a contact detail item --> + <dimen name="detail_item_side_margin">16dip</dimen> + + <!-- ContactTile Layouts --> + <!-- + Use sp instead of dip so that the shadowbox heights can all scale uniformly + when the font size is scaled for accessibility purposes + --> + <dimen name="contact_tile_shadowbox_height">48sp</dimen> + + <!-- Top padding of the ListView in the contact tile list --> + <dimen name="contact_tile_list_padding_top">0dip</dimen> + + <!-- Padding to be used between a visible scrollbar and the contact list --> + <dimen name="list_visible_scrollbar_padding">32dip</dimen> + + <dimen name="contact_browser_list_header_left_margin">16dip</dimen> + <dimen name="contact_browser_list_header_right_margin">@dimen/list_visible_scrollbar_padding + </dimen> + <dimen name="contact_browser_list_item_text_indent">8dip</dimen> + <!-- Width of a contact list item section header. --> + <dimen name="contact_list_section_header_width">56dp</dimen> + + <!-- Size of the shortcut icon. 0dip means: use the system default --> + <dimen name="shortcut_icon_size">0dip</dimen> + + <!-- Text size of shortcut icon overlay text --> + <dimen name="shortcut_overlay_text_size">12dp</dimen> + + <!-- Extra vertical padding for darkened background behind shortcut icon overlay text --> + <dimen name="shortcut_overlay_text_background_padding">1dp</dimen> + + <!-- Width of height of an icon from a third-party app in the networks section of the contact card. --> + <dimen name="detail_network_icon_size">32dip</dimen> + + <!-- Empty message margins --> + <dimen name="empty_message_top_margin">48dip</dimen> + + <!-- contact browser list margins --> + <dimen name="contact_browser_list_item_text_size">16sp</dimen> + <dimen name="contact_browser_list_item_photo_size">40dp</dimen> + <dimen name="contact_browser_list_item_gap_between_image_and_text">15dp</dimen> + <dimen name="contact_browser_list_top_margin">12dp</dimen> + + <!-- Dimensions for "No contacts" string in PhoneFavoriteFragment for the All contacts + with phone numbers section + --> + <dimen name="contact_phone_list_empty_description_size">20sp</dimen> + <dimen name="contact_phone_list_empty_description_padding">10dip</dimen> + + <!-- Dimensions for contact letter tiles --> + <dimen name="tile_letter_font_size">40dp</dimen> + <dimen name="tile_letter_font_size_small">20dp</dimen> + <dimen name="tile_divider_width">1dp</dimen> + <item name="letter_to_tile_ratio" type="dimen">67%</item> + + <!-- Height of the floating action button --> + <dimen name="floating_action_button_height">56dp</dimen> + <!-- Width of the floating action button --> + <dimen name="floating_action_button_width">56dp</dimen> + <!-- Z translation of the floating action button --> + <dimen name="floating_action_button_translation_z">8dp</dimen> + <!-- Padding to be applied to the bottom of lists to make space for the floating action + button --> + <dimen name="floating_action_button_list_bottom_padding">88dp</dimen> + <!-- Right margin of the floating action button --> + <dimen name="floating_action_button_margin_right">16dp</dimen> + <!-- Bottom margin of the floating action button --> + <dimen name="floating_action_button_margin_bottom">16dp</dimen> + + <!-- Height of the selection indicator of a tab. --> + <dimen name="tab_selected_underline_height">2dp</dimen> + <!-- Size of text in tabs. --> + <dimen name="tab_text_size">14sp</dimen> + <dimen name="tab_elevation">2dp</dimen> + <dimen name="tab_unread_count_background_size">16dp</dimen> + <dimen name="tab_unread_count_background_radius">2dp</dimen> + <dimen name="tab_unread_count_margin_left">0dp</dimen> + <dimen name="tab_unread_count_margin_top">2dp</dimen> + <dimen name="tab_unread_count_text_size">12sp</dimen> + <dimen name="tab_unread_count_text_padding">2dp</dimen> + + <!-- Padding around the icon in the search box. --> + <dimen name="search_box_icon_margin">4dp</dimen> + <!-- Size of the icon (voice search, back arrow) in the search box. --> + <dimen name="search_box_icon_size">48dp</dimen> + <!-- Size of the close icon.--> + <dimen name="search_box_close_icon_size">56dp</dimen> + <!-- Padding around the close button. It's visible size without padding is 24dp. --> + <dimen name="search_box_close_icon_padding">16dp</dimen> + <!-- Padding around back arrow icon in the search box --> + <dimen name="search_box_navigation_icon_margin">14dp</dimen> + <!-- Left margin of the text field in the search box. --> + <dimen name="search_box_text_left_margin">15dp</dimen> + <!-- Search box text size --> + <dimen name="search_text_size">20sp</dimen> + + <!-- Top margin for the Frequently Contacted section title --> + <dimen name="frequently_contacted_title_top_margin_when_first_row">16dp</dimen> + <!-- Top margin for the Frequently Contacted section title, when the title is the first + item in the list --> + <dimen name="frequently_contacted_title_top_margin">57dp</dimen> + + <dimen name="frequently_contacted_title_text_size">24sp</dimen> + + <!-- Size of icon for contacts number shortcuts --> + <dimen name="search_shortcut_radius">40dp</dimen> + + <dimen name="contact_list_card_elevation">2dp</dimen> + + <!-- Padding used around the periphery of the call subject dialog, as well as in between the + items. --> + <dimen name="call_subject_dialog_margin">20dp</dimen> + <!-- Padding used between lines of text in the call subject dialog. --> + <dimen name="call_subject_dialog_between_line_margin">8dp</dimen> + <!-- Size of the contact photo in the call subject dialog. --> + <dimen name="call_subject_dialog_contact_photo_size">50dp</dimen> + <!-- Margin above the edit text in the call subject dialog. --> + <dimen name="call_subject_dialog_edit_spacing">60dp</dimen> + <!-- Size of primary text in the call subject dialog. --> + <dimen name="call_subject_dialog_primary_text_size">16sp</dimen> + <!-- Size of secondary text in the call subject dialog. --> + <dimen name="call_subject_dialog_secondary_text_size">14sp</dimen> + <!-- Row padding for call subject history items. --> + <dimen name="call_subject_history_item_padding">15dp</dimen> +</resources> diff --git a/java/com/android/contacts/common/res/values/donottranslate_config.xml b/java/com/android/contacts/common/res/values/donottranslate_config.xml new file mode 100644 index 000000000..324437928 --- /dev/null +++ b/java/com/android/contacts/common/res/values/donottranslate_config.xml @@ -0,0 +1,95 @@ +<!-- + ~ Copyright (C) 2012 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> + <!-- Flag indicating whether Contacts app is allowed to import contacts --> + <bool name="config_allow_import_from_vcf_file">true</bool> + + <!-- If true, an option is shown in Display Options UI to choose a sort order --> + <bool name="config_sort_order_user_changeable">true</bool> + + <!-- If true, the default sort order is primary (i.e. by given name) --> + <bool name="config_default_sort_order_primary">true</bool> + + <!-- If true, an option is shown in Display Options UI to choose a name display order --> + <bool name="config_display_order_user_changeable">true</bool> + + <!-- If true, the default sort order is primary (i.e. by given name) --> + <bool name="config_default_display_order_primary">true</bool> + + <!-- If true, the order of name fields in the editor is primary (i.e. given name first) --> + <bool name="config_editor_field_order_primary">true</bool> + + <!-- If true, an option is shown in Display Options UI to choose a default account --> + <bool name="config_default_account_user_changeable">true</bool> + + <!-- Contacts preferences key for contact editor default account --> + <string name="contact_editor_default_account_key">ContactEditorUtils_default_account</string> + + <!-- Contacts preferences key for contact editor anything saved --> + <string name="contact_editor_anything_saved_key">ContactEditorUtils_anything_saved</string> + + <!-- The type of VCard for export. If you want to let the app emit vCard which is + specific to some vendor (like DoCoMo), specify this type (e.g. "docomo") --> + <string name="config_export_vcard_type" translatable="false">default</string> + + <!-- The type of vcard for improt. If the vcard importer cannot guess the exact type + of a vCard type, the improter uses this type. --> + <string name="config_import_vcard_type" translatable="false">default</string> + + <!-- Prefix of exported VCard file --> + <string name="config_export_file_prefix" translatable="false"></string> + + <!-- Suffix of exported VCard file. Attached before an extension --> + <string name="config_export_file_suffix" translatable="false"></string> + + <!-- Extension for exported VCard files --> + <string name="config_export_file_extension">vcf</string> + + <!-- The filename that is suggested that users use when exporting vCards. Should include the .vcf extension. --> + <string name="exporting_vcard_filename" translatable="false">contacts.vcf</string> + + <!-- Minimum number of exported VCard file index --> + <integer name="config_export_file_min_index">1</integer> + + <!-- Maximum number of exported VCard file index --> + <integer name="config_export_file_max_index">99999</integer> + + <!-- The list (separated by ',') of extensions should be checked in addition to + config_export_extension. e.g. If "aaa" is added to here and 00001.vcf and 00002.aaa + exist in a target directory, 00003.vcf becomes a next file name candidate. + Without this configuration, 00002.vcf becomes the candidate.--> + <string name="config_export_extensions_to_consider" translatable="false"></string> + + <!-- If true, enable the "import contacts from SIM" feature if the device + has an appropriate SIM or ICC card. + Setting this flag to false in a resource overlay allows you to + entirely disable SIM import on a per-product basis. --> + <bool name="config_allow_sim_import">true</bool> + + <!-- Flag indicating whether Contacts app is allowed to export contacts --> + <bool name="config_allow_export">true</bool> + + <!-- Flag indicating whether Contacts app is allowed to share contacts with devices outside --> + <bool name="config_allow_share_contacts">true</bool> + + <string name="pref_build_version_key">pref_build_version</string> + <string name="pref_open_source_licenses_key">pref_open_source_licenses</string> + <string name="pref_privacy_policy_key">pref_privacy_policy</string> + <string name="pref_terms_of_service_key">pref_terms_of_service</string> + + <string name="star_sign">★</string> +</resources> diff --git a/java/com/android/contacts/common/res/values/ids.xml b/java/com/android/contacts/common/res/values/ids.xml new file mode 100644 index 000000000..871f5a636 --- /dev/null +++ b/java/com/android/contacts/common/res/values/ids.xml @@ -0,0 +1,30 @@ +<!-- + Copyright (C) 2012 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> + <!-- For Debug Purpose --> + <item name="cliv_name_textview" type="id"/> + <item name="cliv_label_textview" type="id"/> + <item name="cliv_data_view" type="id"/> + + <!-- For tag ids used by ContactPhotoManager to tag views with contact details --> + <item name="tag_display_name" type="id"/> + <item name="tag_identifier" type="id"/> + <item name="tag_contact_type" type="id"/> + + <item name="contact_tile_image" type="id"/> + <item name="contact_tile_name" type="id"/> +</resources> diff --git a/java/com/android/contacts/common/res/values/integers.xml b/java/com/android/contacts/common/res/values/integers.xml new file mode 100644 index 000000000..d38ad1da0 --- /dev/null +++ b/java/com/android/contacts/common/res/values/integers.xml @@ -0,0 +1,39 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2012 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> + + <!-- Determines the number of columns in a ContactTileRow in the favorites tab --> + <integer name="contact_tile_column_count_in_favorites">2</integer> + <integer name="contact_tile_column_count_in_favorites_new">3</integer> + + <!-- The number of characters in the snippet before we need to tokenize and ellipse. --> + <integer name="snippet_length_before_tokenize">30</integer> + + <!-- Layout weight of space elements in contact list view. + Default to 0 to indicate no padding--> + <integer name="contact_list_space_layout_weight">0</integer> + <!-- Layout weight of card in contact list view. + Default to 0 to indicate no padding --> + <integer name="contact_list_card_layout_weight">0</integer> + + <!-- Duration of the animations on the call subject dialog. --> + <integer name="call_subject_animation_duration">250</integer> + + <!-- A big number to make sure "About contacts" always showing at the bottom of Settings.--> + <integer name="about_contacts_order_number">100</integer> +</resources> diff --git a/java/com/android/contacts/common/res/values/strings.xml b/java/com/android/contacts/common/res/values/strings.xml new file mode 100644 index 000000000..15e1f15d9 --- /dev/null +++ b/java/com/android/contacts/common/res/values/strings.xml @@ -0,0 +1,798 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (C) 2012 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 xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + + <!-- Toast shown when text is copied to the clipboard [CHAR LIMIT=64] --> + <string name="toast_text_copied">Text copied</string> + <!-- Option displayed in context menu to copy long pressed item to clipboard [CHAR LIMIT=64] --> + <string name="copy_text">Copy to clipboard</string> + + <!-- Action string for calling a custom phone number --> + <string name="call_custom">Call + <xliff:g id="custom">%s</xliff:g> + </string> + <!-- Action string for calling a home phone number --> + <string name="call_home">Call home</string> + <!-- Action string for calling a mobile phone number --> + <string name="call_mobile">Call mobile</string> + <!-- Action string for calling a work phone number --> + <string name="call_work">Call work</string> + <!-- Action string for calling a work fax phone number --> + <string name="call_fax_work">Call work fax</string> + <!-- Action string for calling a home fax phone number --> + <string name="call_fax_home">Call home fax</string> + <!-- Action string for calling a pager phone number --> + <string name="call_pager">Call pager</string> + <!-- Action string for calling an other phone number --> + <string name="call_other">Call</string> + <!-- Action string for calling a callback number --> + <string name="call_callback">Call callback</string> + <!-- Action string for calling a car phone number --> + <string name="call_car">Call car</string> + <!-- Action string for calling a company main phone number --> + <string name="call_company_main">Call company main</string> + <!-- Action string for calling a ISDN phone number --> + <string name="call_isdn">Call ISDN</string> + <!-- Action string for calling a main phone number --> + <string name="call_main">Call main</string> + <!-- Action string for calling an other fax phone number --> + <string name="call_other_fax">Call fax</string> + <!-- Action string for calling a radio phone number --> + <string name="call_radio">Call radio</string> + <!-- Action string for calling a Telex phone number --> + <string name="call_telex">Call telex</string> + <!-- Action string for calling a TTY/TDD phone number --> + <string name="call_tty_tdd">Call TTY/TDD</string> + <!-- Action string for calling a work mobile phone number --> + <string name="call_work_mobile">Call work mobile</string> + <!-- Action string for calling a work pager phone number --> + <string name="call_work_pager">Call work pager</string> + <!-- Action string for calling an assistant phone number --> + <string name="call_assistant">Call + <xliff:g id="assistant">%s</xliff:g> + </string> + <!-- Action string for calling a MMS phone number --> + <string name="call_mms">Call MMS</string> + <!-- Action string for calling a contact by shortcut --> + <string name="call_by_shortcut"><xliff:g id="contact_name">%s</xliff:g> (Call)</string> + + <!-- Action string for sending an SMS to a custom phone number --> + <string name="sms_custom">Text + <xliff:g id="custom">%s</xliff:g> + </string> + <!-- Action string for sending an SMS to a home phone number --> + <string name="sms_home">Text home</string> + <!-- Action string for sending an SMS to a mobile phone number --> + <string name="sms_mobile">Text mobile</string> + <!-- Action string for sending an SMS to a work phone number --> + <string name="sms_work">Text work</string> + <!-- Action string for sending an SMS to a work fax phone number --> + <string name="sms_fax_work">Text work fax</string> + <!-- Action string for sending an SMS to a home fax phone number --> + <string name="sms_fax_home">Text home fax</string> + <!-- Action string for sending an SMS to a pager phone number --> + <string name="sms_pager">Text pager</string> + <!-- Action string for sending an SMS to an other phone number --> + <string name="sms_other">Text</string> + <!-- Action string for sending an SMS to a callback number --> + <string name="sms_callback">Text callback</string> + <!-- Action string for sending an SMS to a car phone number --> + <string name="sms_car">Text car</string> + <!-- Action string for sending an SMS to a company main phone number --> + <string name="sms_company_main">Text company main</string> + <!-- Action string for sending an SMS to a ISDN phone number --> + <string name="sms_isdn">Text ISDN</string> + <!-- Action string for sending an SMS to a main phone number --> + <string name="sms_main">Text main</string> + <!-- Action string for sending an SMS to an other fax phone number --> + <string name="sms_other_fax">Text fax</string> + <!-- Action string for sending an SMS to a radio phone number --> + <string name="sms_radio">Text radio</string> + <!-- Action string for sending an SMS to a Telex phone number --> + <string name="sms_telex">Text telex</string> + <!-- Action string for sending an SMS to a TTY/TDD phone number --> + <string name="sms_tty_tdd">Text TTY/TDD</string> + <!-- Action string for sending an SMS to a work mobile phone number --> + <string name="sms_work_mobile">Text work mobile</string> + <!-- Action string for sending an SMS to a work pager phone number --> + <string name="sms_work_pager">Text work pager</string> + <!-- Action string for sending an SMS to an assistant phone number --> + <string name="sms_assistant">Text + <xliff:g id="assistant">%s</xliff:g> + </string> + <!-- Action string for sending an SMS to a MMS phone number --> + <string name="sms_mms">Text MMS</string> + <!-- Action string for sending an SMS to a contact by shortcut --> + <string name="sms_by_shortcut"><xliff:g id="contact_name">%s</xliff:g> (Message)</string> + + <!-- Title of the confirmation dialog for clearing frequents. [CHAR LIMIT=37] --> + <string name="clearFrequentsConfirmation_title">Clear frequently contacted?</string> + + <!-- Confirmation dialog for clearing frequents. [CHAR LIMIT=NONE] --> + <string name="clearFrequentsConfirmation">You\'ll clear the frequently contacted list in the + Contacts and Phone apps, and force email apps to learn your addressing preferences from + scratch. + </string> + + <!-- Title of the "Clearing frequently contacted" progress-dialog [CHAR LIMIT=35] --> + <string name="clearFrequentsProgress_title">Clearing frequently contacted\u2026</string> + + <!-- Used to display as default status when the contact is available for chat [CHAR LIMIT=19] --> + <string name="status_available">Available</string> + + <!-- Used to display as default status when the contact is away or idle for chat [CHAR LIMIT=19] --> + <string name="status_away">Away</string> + + <!-- Used to display as default status when the contact is busy or Do not disturb for chat [CHAR LIMIT=19] --> + <string name="status_busy">Busy</string> + + <!-- Directory partition name (also exists in contacts) --> + <string name="contactsList">Contacts</string> + + <!-- The name of the invisible local contact directory --> + <string name="local_invisible_directory">Other</string> + + <!-- The label in section header in the contact list for a contact directory [CHAR LIMIT=128] --> + <string name="directory_search_label">Directory</string> + + <!-- The label in section header in the contact list for a work contact directory [CHAR LIMIT=128] --> + <string name="directory_search_label_work">Work directory</string> + + <!-- The label in section header in the contact list for a local contacts [CHAR LIMIT=128] --> + <string name="local_search_label">All contacts</string> + + <!-- String describing the text on the header of the profile contact in the contacts list + This may be programatically capitalized. [CHAR LIMIT=20] --> + <string msgid="9154761216179882405" name="user_profile_contacts_list_header">Me</string> + + <!-- Title shown in the search result activity of contacts app while searching. [CHAR LIMIT=20] + (also in contacts) --> + <string name="search_results_searching">Searching\u2026</string> + + <!-- Displayed at the top of search results indicating that more contacts were found than shown [CHAR LIMIT=64] --> + <string name="foundTooManyContacts">More than <xliff:g id="count">%d</xliff:g> found.</string> + + <!-- Displayed at the top of the contacts showing the zero total number of contacts found when "Only contacts with phones" not selected. [CHAR LIMIT=30] + (also in contacts) --> + <string name="listFoundAllContactsZero">No contacts</string> + + <!-- Displayed at the top of the contacts showing the total number of contacts found when typing search query --> + <plurals name="searchFoundContacts"> + <item quantity="one">1 found</item> + <item quantity="other"><xliff:g id="count">%d</xliff:g> found</item> + </plurals> + + <!-- String describing the text for photo of a contact in a contacts list. + + Note: AccessibilityServices use this attribute to announce what the view represents. + This is especially valuable for views without textual representation like ImageView. + --> + <string name="description_quick_contact_for">Quick contact for <xliff:g id="name">%1$s</xliff:g></string> + + <!-- Shown as the display name for a person when the name is missing or unknown. [CHAR LIMIT=18]--> + <string name="missing_name">(No name)</string> + + <!-- The text displayed on the divider for the Favorites tab in Phone app indicating that items below it are frequently called as opposed to starred contacts [CHAR LIMIT = 39] --> + <string name="favoritesFrequentCalled">Frequently called</string> + + <!-- The text displayed on the divider for the Favorites tab in People app indicating that items below it are frequently contacted [CHAR LIMIT = 39] --> + <string name="favoritesFrequentContacted">Frequently contacted</string> + + <!-- String describing a contact picture that introduces users to the contact detail screen. + + Used by AccessibilityService to announce the purpose of the button. + + [CHAR LIMIT=NONE] + --> + <string msgid="2795575601596468581" name="description_view_contact_detail">View contact</string> + + <!-- Contact list filter selection indicating that the list shows all contacts with phone numbers [CHAR LIMIT=64] --> + <string name="list_filter_phones">All contacts with phone numbers</string> + + <!-- Contact list filter selection indicating that the list shows all work contacts with phone numbers [CHAR LIMIT=64] --> + <string name="list_filter_phones_work">Work profile contacts</string> + + <!-- Button to view the updates from the current group on the group detail page [CHAR LIMIT=25] --> + <string name="view_updates_from_group">View updates</string> + + <!-- Title for data source when creating or editing a contact that doesn't + belong to a specific account. This contact will only exist on the device + and will not be synced. --> + <string name="account_phone">Device-only, unsynced</string> + + <!-- Header that expands to list all name types when editing a structured name of a contact + [CHAR LIMIT=20] --> + <string name="nameLabelsGroup">Name</string> + + <!-- Header that expands to list all nickname types when editing a nickname of a contact + [CHAR LIMIT=20] --> + <string name="nicknameLabelsGroup">Nickname</string> + + <!-- Field title for the full name of a contact [CHAR LIMIT=64]--> + <string name="full_name">Name</string> + <!-- Field title for the given name of a contact --> + <string name="name_given">First name</string> + <!-- Field title for the family name of a contact --> + <string name="name_family">Last name</string> + <!-- Field title for the prefix name of a contact --> + <string name="name_prefix">Name prefix</string> + <!-- Field title for the middle name of a contact --> + <string name="name_middle">Middle name</string> + <!-- Field title for the suffix name of a contact --> + <string name="name_suffix">Name suffix</string> + + <!-- Field title for the phonetic name of a contact [CHAR LIMIT=64]--> + <string name="name_phonetic">Phonetic name</string> + + <!-- Field title for the phonetic given name of a contact --> + <string name="name_phonetic_given">Phonetic first name</string> + <!-- Field title for the phonetic middle name of a contact --> + <string name="name_phonetic_middle">Phonetic middle name</string> + <!-- Field title for the phonetic family name of a contact --> + <string name="name_phonetic_family">Phonetic last name</string> + + <!-- Header that expands to list all of the types of phone numbers when editing or creating a + phone number for a contact [CHAR LIMIT=20] --> + <string name="phoneLabelsGroup">Phone</string> + + <!-- Header that expands to list all of the types of email addresses when editing or creating + an email address for a contact [CHAR LIMIT=20] --> + <string name="emailLabelsGroup">Email</string> + + <!-- Header that expands to list all of the types of postal addresses when editing or creating + an postal address for a contact [CHAR LIMIT=20] --> + <string name="postalLabelsGroup">Address</string> + + <!-- Header that expands to list all of the types of IM account when editing or creating an IM + account for a contact [CHAR LIMIT=20] --> + <string name="imLabelsGroup">IM</string> + + <!-- Header that expands to list all organization types when editing an organization of a + contact [CHAR LIMIT=20] --> + <string name="organizationLabelsGroup">Organization</string> + + <!-- Header for the list of all relationships for a contact [CHAR LIMIT=20] --> + <string name="relationLabelsGroup">Relationship</string> + + <!-- Header that expands to list all event types when editing an event of a contact + [CHAR LIMIT=20] --> + <string name="eventLabelsGroup">Special date</string> + + <!-- Generic action string for text messaging a contact. Used by AccessibilityService to + announce the purpose of the view. [CHAR LIMIT=NONE] --> + <string name="sms">Text message</string> + + <!-- Field title for the full postal address of a contact [CHAR LIMIT=64]--> + <string name="postal_address">Address</string> + + <!-- Hint text for the organization name when editing --> + <string name="ghostData_company">Company</string> + + <!-- Hint text for the organization title when editing --> + <string name="ghostData_title">Title</string> + + <!-- The label describing the Notes field of a contact. This field allows free form text entry + about a contact --> + <string name="label_notes">Notes</string> + + <!-- The label describing the SIP address field of a contact. [CHAR LIMIT=20] --> + <string name="label_sip_address">SIP</string> + + <!-- Header that expands to list all website types when editing a website of a contact + [CHAR LIMIT=20] --> + <string name="websiteLabelsGroup">Website</string> + + <!-- Header for the list of all groups for a contact [CHAR LIMIT=20] --> + <string name="groupsLabel">Groups</string> + + <!-- Action string for sending an email to a home email address --> + <string name="email_home">Email home</string> + <!-- Action string for sending an email to a mobile email address --> + <string name="email_mobile">Email mobile</string> + <!-- Action string for sending an email to a work email address --> + <string name="email_work">Email work</string> + <!-- Action string for sending an email to an other email address --> + <string name="email_other">Email</string> + <!-- Action string for sending an email to a custom email address --> + <string name="email_custom">Email <xliff:g id="custom">%s</xliff:g></string> + + <!-- Generic action string for sending an email --> + <string name="email">Email</string> + + <!-- Field title for the street of a structured postal address of a contact --> + <string name="postal_street">Street</string> + <!-- Field title for the PO box of a structured postal address of a contact --> + <string name="postal_pobox">PO box</string> + <!-- Field title for the neighborhood of a structured postal address of a contact --> + <string name="postal_neighborhood">Neighborhood</string> + <!-- Field title for the city of a structured postal address of a contact --> + <string name="postal_city">City</string> + <!-- Field title for the region, or state, of a structured postal address of a contact --> + <string name="postal_region">State</string> + <!-- Field title for the postal code of a structured postal address of a contact --> + <string name="postal_postcode">ZIP code</string> + <!-- Field title for the country of a structured postal address of a contact --> + <string name="postal_country">Country</string> + + <!-- Action string for viewing a home postal address --> + <string name="map_home">View home address</string> + <!-- Action string for viewing a work postal address --> + <string name="map_work">View work address</string> + <!-- Action string for viewing an other postal address --> + <string name="map_other">View address</string> + <!-- Action string for viewing a custom postal address --> + <string name="map_custom">View <xliff:g id="custom">%s</xliff:g> address</string> + + <!-- Action string for starting an IM chat with the AIM protocol --> + <string name="chat_aim">Chat using AIM</string> + <!-- Action string for starting an IM chat with the MSN or Windows Live protocol --> + <string name="chat_msn">Chat using Windows Live</string> + <!-- Action string for starting an IM chat with the Yahoo protocol --> + <string name="chat_yahoo">Chat using Yahoo</string> + <!-- Action string for starting an IM chat with the Skype protocol --> + <string name="chat_skype">Chat using Skype</string> + <!-- Action string for starting an IM chat with the QQ protocol --> + <string name="chat_qq">Chat using QQ</string> + <!-- Action string for starting an IM chat with the Google Talk protocol --> + <string name="chat_gtalk">Chat using Google Talk</string> + <!-- Action string for starting an IM chat with the ICQ protocol --> + <string name="chat_icq">Chat using ICQ</string> + <!-- Action string for starting an IM chat with the Jabber protocol --> + <string name="chat_jabber">Chat using Jabber</string> + + <!-- Generic action string for starting an IM chat --> + <string name="chat">Chat</string> + + <!-- String describing the Contact Editor Minus button + + Used by AccessibilityService to announce the purpose of the button. + + [CHAR LIMIT=NONE] + --> + <string name="description_minus_button">delete</string> + + <!-- Content description for the expand or collapse name fields button. + Clicking this button causes the name editor to toggle between showing + a single field where the entire name is edited at once, or multiple + fields corresponding to each part of the name (Name Prefix, First Name, + Middle Name, Last Name, Name Suffix). + [CHAR LIMIT=NONE] --> + <string name="expand_collapse_name_fields_description">Expand or collapse name fields</string> + + <!-- Content description for the expand or collapse phonetic name fields button. [CHAR LIMIT=100] --> + <string name="expand_collapse_phonetic_name_fields_description">Expand or collapse phonetic + name fields</string> + + <!-- Contact list filter label indicating that the list is showing all available accounts [CHAR LIMIT=64] --> + <string name="list_filter_all_accounts">All contacts</string> + + <!-- Menu item to indicate you are done editing a contact and want to save the changes you've made --> + <string name="menu_done">Done</string> + + <!-- Menu item to indicate you want to cancel the current editing process and NOT save the changes you've made [CHAR LIMIT=12] --> + <string name="menu_doNotSave">Cancel</string> + + <!-- Displayed at the top of the contacts showing the account filter selected [CHAR LIMIT=64] --> + <string name="listAllContactsInAccount">Contacts in <xliff:g example="abc@gmail.com" id="name">%s</xliff:g></string> + + <!-- Displayed at the top of the contacts showing single contact. [CHAR LIMIT=64] --> + <string name="listCustomView">Contacts in custom view</string> + + <!-- Displayed at the top of the contacts showing single contact. [CHAR LIMIT=64] --> + <string name="listSingleContact">Single contact</string> + + <!-- Message asking user to select an account to save contacts imported from vcard or SIM card [CHAR LIMIT=64] --> + <string name="dialog_new_contact_account">Save imported contacts to:</string> + + <!-- Action string for selecting SIM for importing contacts --> + <string name="import_from_sim">Import from SIM card</string> + + <!-- Action string for selecting a SIM subscription for importing contacts --> + <string name="import_from_sim_summary">Import from SIM <xliff:g id="sim_name">^1</xliff:g> - <xliff:g id="sim_number">^2</xliff:g></string> + + <!-- Action string for selecting a SIM subscription for importing contacts, without a phone number --> + <string name="import_from_sim_summary_no_number">Import from SIM <xliff:g id="sim_name">%1$s</xliff:g></string> + + <!-- Action string for selecting a .vcf file to import contacts from [CHAR LIMIT=30] --> + <string name="import_from_vcf_file" product="default">Import from .vcf file</string> + + <!-- Message shown in a Dialog confirming a user's cancel request toward existing vCard import. + The argument is file name for the vCard import the user wants to cancel. + [CHAR LIMIT=128] --> + <string name="cancel_import_confirmation_message">Cancel import of <xliff:g example="import.vcf" id="filename">%s</xliff:g>?</string> + + <!-- Message shown in a Dialog confirming a user's cancel request toward existing vCard export. + The argument is file name for the vCard export the user wants to cancel. + [CHAR LIMIT=128] --> + <string name="cancel_export_confirmation_message">Cancel export of <xliff:g example="export.vcf" id="filename">%s</xliff:g>?</string> + + <!-- Title shown in a Dialog telling users cancel vCard import/export operation is failed. [CHAR LIMIT=40] --> + <string name="cancel_vcard_import_or_export_failed">Couldn\'t cancel vCard import/export</string> + + <!-- The failed reason which should not be shown but it may in some buggy condition. [CHAR LIMIT=40] --> + <string name="fail_reason_unknown">Unknown error.</string> + + <!-- The failed reason shown when vCard importer/exporter could not open the file + specified by a user. The file name should be in the message. [CHAR LIMIT=NONE] --> + <string name="fail_reason_could_not_open_file">Couldn\'t open \"<xliff:g id="file_name">%s</xliff:g>\": <xliff:g id="exact_reason">%s</xliff:g>.</string> + + <!-- The failed reason shown when contacts exporter fails to be initialized. + Some exact reason must follow this. [CHAR LIMIT=NONE]--> + <string name="fail_reason_could_not_initialize_exporter">Couldn\'t start the exporter: \"<xliff:g id="exact_reason">%s</xliff:g>\".</string> + + <!-- The failed reason shown when there's no contact which is allowed to be exported. + Note that user may have contacts data but all of them are probably not allowed to be + exported because of security/permission reasons. [CHAR LIMIT=NONE] --> + <string name="fail_reason_no_exportable_contact">There is no exportable contact.</string> + + <!-- The user doesn't have all permissions required to use the current screen. So + close the current screen and show the user this message. --> + <string name="missing_required_permission">You have disabled a required permission.</string> + + <!-- The failed reason shown when some error happend during contacts export. + Some exact reason must follow this. [CHAR LIMIT=NONE] --> + <string name="fail_reason_error_occurred_during_export">An error occurred during export: \"<xliff:g id="exact_reason">%s</xliff:g>\".</string> + + <!-- The failed reason shown when the given file name is too long for the system. + The length limit of each file is different in each Android device, so we don't need to + mention it here. [CHAR LIMIT=NONE] --> + <string name="fail_reason_too_long_filename">Required filename is too long (\"<xliff:g id="filename">%s</xliff:g>\").</string> + + <!-- The failed reason shown when Contacts app (especially vCard importer/exporter) + emitted some I/O error. Exact reason will be appended by the system. [CHAR LIMIT=NONE] --> + <string name="fail_reason_io_error">I/O error</string> + + <!-- Failure reason show when Contacts app (especially vCard importer) encountered + low memory problem and could not proceed its import procedure. [CHAR LIMIT=NONE] --> + <string name="fail_reason_low_memory_during_import">Not enough memory. The file may be too large.</string> + + <!-- The failed reason shown when vCard parser was not able to be parsed by the current vCard + implementation. This might happen even when the input vCard is completely valid, though + we believe it is rather rare in the actual world. [CHAR LIMIT=NONE] --> + <string name="fail_reason_vcard_parse_error">Couldn\'t parse vCard for an unexpected reason.</string> + + <!-- The failed reason shown when vCard importer doesn't support the format. + This may be shown when the vCard is corrupted [CHAR LIMIT=40] --> + <string name="fail_reason_not_supported">The format isn\'t supported.</string> + + <!-- Fail reason shown when vCard importer failed to look over meta information stored in vCard file(s). --> + <string name="fail_reason_failed_to_collect_vcard_meta_info">Couldn\'t collect meta information of given vCard file(s).</string> + + <!-- The failed reason shown when the import of some of vCard files failed during multiple vCard + files import. It includes the case where all files were failed to be imported. --> + <string name="fail_reason_failed_to_read_files">One or more files couldn\'t be imported (%s).</string> + + <!-- The title shown when exporting vCard is successfuly finished [CHAR LIMIT=40] --> + <string name="exporting_vcard_finished_title">Finished exporting <xliff:g example="export.vcf" id="filename">%s</xliff:g>.</string> + + <!-- The title shown when exporting vCard has finished successfully but the destination filename could not be resolved. [CHAR LIMIT=NONE] --> + <string name="exporting_vcard_finished_title_fallback">Finished exporting contacts.</string> + + <!-- The toast message shown when exporting vCard has finished and vCards are ready to be shared [CHAR LIMIT=150]--> + <string name="exporting_vcard_finished_toast">Finished exporting contacts, click the notification to share contacts.</string> + + <!-- The message on notification shown when exporting vCard has finished and vCards are ready to be shared [CHAR LIMIT=60]--> + <string name="touch_to_share_contacts">Tap to share contacts.</string> + + <!-- The title shown when exporting vCard is canceled (probably by a user) + The argument is file name the user canceled importing. + [CHAR LIMIT=40] --> + <string name="exporting_vcard_canceled_title">Exporting <xliff:g example="export.vcf" id="filename">%s</xliff:g> canceled.</string> + + <!-- Dialog title shown when the application is exporting contact data outside. [CHAR LIMIT=NONE] --> + <string name="exporting_contact_list_title">Exporting contact data</string> + + <!-- Message shown when the application is exporting contact data outside --> + <string name="exporting_contact_list_message">Contact data is being exported.</string> + + <!-- The error reason the vCard composer "may" emit when database is corrupted or + something is going wrong. Usually users should not see this text. [CHAR LIMIT=NONE] --> + <string name="composer_failed_to_get_database_infomation">Couldn\'t get database information.</string> + + <!-- This error message shown when the user actually have no contact + (e.g. just after data-wiping), or, data providers of the contact list prohibits their + contacts from being exported to outside world via vcard exporter, etc. [CHAR LIMIT=NONE] --> + <string name="composer_has_no_exportable_contact">There are no exportable contacts. If you do have contacts on your device, some data providers may not allow the contacts to be exported from the device.</string> + + <!-- The error reason the vCard composer may emit when vCard composer is not initialized + even when needed. + Users should not usually see this error message. [CHAR LIMIT=NONE] --> + <string name="composer_not_initialized">The vCard composer didn\'t start properly.</string> + + <!-- Dialog title shown when exporting Contact data failed. [CHAR LIMIT=20] --> + <string name="exporting_contact_failed_title">Couldn\'t export</string> + + <!-- Dialog message shown when exporting Contact data failed. [CHAR LIMIT=NONE] --> + <string name="exporting_contact_failed_message">The contact data wasn\'t exported.\nReason: \"<xliff:g id="fail_reason">%s</xliff:g>\"</string> + + <!-- Description shown when importing vCard data. + The argument is the name of a contact which is being read. + [CHAR LIMIT=20] --> + <string name="importing_vcard_description">Importing <xliff:g example="Joe Due" id="name">%s</xliff:g></string> + + <!-- Dialog title shown when reading vCard data failed [CHAR LIMIT=40] --> + <string name="reading_vcard_failed_title">Couldn\'t read vCard data</string> + + <!-- The title shown when reading vCard is canceled (probably by a user) + [CHAR LIMIT=40] --> + <string name="reading_vcard_canceled_title">Reading vCard data canceled</string> + + <!-- The title shown when reading vCard finished + The argument is file name the user imported. + [CHAR LIMIT=40] --> + <string name="importing_vcard_finished_title">Finished importing vCard <xliff:g example="import.vcf" id="filename">%s</xliff:g></string> + + <!-- The title shown when importing vCard is canceled (probably by a user) + The argument is file name the user canceled importing. + [CHAR LIMIT=40] --> + <string name="importing_vcard_canceled_title">Importing <xliff:g example="import.vcf" id="filename">%s</xliff:g> canceled</string> + + <!-- The message shown when vCard import request is accepted. The system may start that work soon, or do it later + when there are already other import/export requests. + The argument is file name the user imported. + [CHAR LIMIT=40] --> + <string name="vcard_import_will_start_message"><xliff:g example="import.vcf" id="filename">%s</xliff:g> will be imported shortly.</string> + <!-- The message shown when vCard import request is accepted. The system may start that work soon, or do it later when there are already other import/export requests. + "The file" is what a user selected for importing. + [CHAR LIMIT=40] --> + <string name="vcard_import_will_start_message_with_default_name">The file will be imported shortly.</string> + <!-- The message shown when a given vCard import request is rejected by the system. [CHAR LIMIT=NONE] --> + <string name="vcard_import_request_rejected_message">vCard import request was rejected. Try again later.</string> + <!-- The message shown when vCard export request is accepted. The system may start that work soon, or do it later + when there are already other import/export requests. + The argument is file name the user exported. + [CHAR LIMIT=40] --> + <string name="vcard_export_will_start_message"><xliff:g example="import.vcf" id="filename">%s</xliff:g> will be exported shortly.</string> + + <!-- The message shown when a vCard export request is accepted but the destination filename could not be resolved. [CHAR LIMIT=NONE] --> + <string name="vcard_export_will_start_message_fallback">The file will be exported shortly.</string> + + <!-- The message shown when a vCard export request is accepted and contacts will be exported shortly. [CHAR LIMIT=70]--> + <string name="contacts_export_will_start_message">Contacts will be exported shortly.</string> + + <!-- The message shown when a given vCard export request is rejected by the system. [CHAR LIMIT=NONE] --> + <string name="vcard_export_request_rejected_message">vCard export request was rejected. Try again later.</string> + <!-- Used when file name is unknown in vCard processing. It typically happens + when the file is given outside the Contacts app. [CHAR LIMIT=30] --> + <string name="vcard_unknown_filename">contact</string> + + <!-- The message shown when vCard importer is caching files to be imported into local temporary + data storage. [CHAR LIMIT=NONE] --> + <string name="caching_vcard_message">Caching vCard(s) to local temporary storage. The actual import will start soon.</string> + + <!-- Message used when vCard import has failed. [CHAR LIMIT=40] --> + <string name="vcard_import_failed">Couldn\'t import vCard.</string> + + <!-- The "file name" displayed for vCards received directly via NFC [CHAR LIMIT=16] --> + <string name="nfc_vcard_file_name">Contact received over NFC</string> + + <!-- Dialog title shown when a user confirms whether he/she export Contact data. [CHAR LIMIT=32] --> + <string name="confirm_export_title">Export contacts?</string> + + <!-- The title shown when vCard importer is caching files to be imported into local temporary + data storage. [CHAR LIMIT=40] --> + <string name="caching_vcard_title">Caching</string> + + <!-- The message shown while importing vCard(s). + First argument is current index of contacts to be imported. + Second argument is the total number of contacts. + Third argument is the name of a contact which is being read. + [CHAR LIMIT=20] --> + <string name="progress_notifier_message">Importing <xliff:g id="current_number">%s</xliff:g>/<xliff:g id="total_number">%s</xliff:g>: <xliff:g example="Joe Due" id="name">%s</xliff:g></string> + + <!-- Action that exports all contacts to a user selected destination. [CHAR LIMIT=25] --> + <string name="export_to_vcf_file" product="default">Export to .vcf file</string> + + <!-- Contact preferences related strings --> + + <!-- Label of the "sort by" display option --> + <string name="display_options_sort_list_by">Sort by</string> + + <!-- An allowable value for the "sort list by" contact display option --> + <string name="display_options_sort_by_given_name">First name</string> + + <!-- An allowable value for the "sort list by" contact display option --> + <string name="display_options_sort_by_family_name">Last name</string> + + <!-- Label of the "name format" display option [CHAR LIMIT=64]--> + <string name="display_options_view_names_as">Name format</string> + + <!-- An allowable value for the "view names as" contact display option --> + <string name="display_options_view_given_name_first">First name first</string> + + <!-- An allowable value for the "view names as" contact display option --> + <string name="display_options_view_family_name_first">Last name first</string> + + <!--Label of the "default account" setting option to set default editor account. [CHAR LIMIT=80]--> + <string name="default_editor_account">Default account for new contacts</string> + + <!--Label of the "Sync contact metadata" setting option to set sync account for Lychee. [CHAR LIMIT=80]--> + <string name="sync_contact_metadata_title">Sync contact metadata [DOGFOOD]</string> + + <!--Label of the "Sync contact metadata" setting dialog to set sync account for Lychee. [CHAR LIMIT=80]--> + <string name="sync_contact_metadata_dialog_title">Sync contact metadata</string> + + <!-- Label of the "About" setting --> + <string name="setting_about">About Contacts</string> + + <!-- Title of the settings activity [CHAR LIMIT=64] --> + <string name="activity_title_settings">Settings</string> + + <!-- Action that shares visible contacts --> + <string name="share_visible_contacts">Share visible contacts</string> + + <!-- A framework exception (ie, transaction too large) can be thrown while attempting to share all visible contacts. If so, show this toast. --> + <string name="share_visible_contacts_failure">Failed to share visible contacts.</string> + + <!-- Action that shares favorite contacts [CHAR LIMIT=40]--> + <string name="share_favorite_contacts">Share favorite contacts</string> + + <!-- Action that shares contacts [CHAR LIMIT=30]--> + <string name="share_contacts">Share all contacts</string> + + <!-- A framework exception can be thrown while attempting to share all contacts. If so, show this toast. [CHAR LIMIT=40]--> + <string name="share_contacts_failure">Failed to share contacts.</string> + + <!-- Dialog title when selecting the bulk operation to perform from a list. [CHAR LIMIT=36] --> + <string name="dialog_import_export">Import/export contacts</string> + + <!-- Dialog title when importing contacts from an external source. [CHAR LIMIT=36] --> + <string name="dialog_import">Import contacts</string> + + <!-- Toast indicating that sharing a contact has failed. [CHAR LIMIT=NONE] --> + <string name="share_error">This contact can\'t be shared.</string> + + <!-- Toast indicating that no visible contact to share [CHAR LIMIT=NONE] --> + <string name="no_contact_to_share">There are no contacts to share.</string> + + <!-- Menu item to search contacts --> + <string name="menu_search">Search</string> + + <!-- Query hint displayed inside the search field [CHAR LIMIT=64] --> + <string name="hint_findContacts">Find contacts</string> + + <!-- The description text for the favorites tab. + + Note: AccessibilityServices use this attribute to announce what the view represents. + This is especially valuable for views without textual representation like ImageView. + + [CHAR LIMIT=NONE] --> + <string name="contactsFavoritesLabel">Favorites</string> + + <!-- Displayed at the top of the contacts showing the zero total number of contacts visible when "All contacts" is selected [CHAR LIMIT=64]--> + <string name="listTotalAllContactsZero">No contacts.</string> + + <!-- Displayed at the top of the contacts showing the zero total number of contacts visible when "Custom" is selected [CHAR LIMIT=64]--> + <string name="listTotalAllContactsZeroCustom">No visible contacts.</string> + + <!-- Displayed at the top of the contacts showing the zero total number of contacts visible when starred contact list is selected [CHAR LIMIT=64]--> + <string name="listTotalAllContactsZeroStarred">No favorites</string> + + <!-- Displayed at the top of the contacts showing the zero total number of contacts visible when a group or account is selected [CHAR LIMIT=64]--> + <string name="listTotalAllContactsZeroGroup">No contacts in <xliff:g example="Friends" id="name">%s</xliff:g></string> + + <!-- The menu item to clear frequents [CHAR LIMIT=30] --> + <string name="menu_clear_frequents">Clear frequents</string> + + <!-- Menu item to select SIM card --> + <string name="menu_select_sim">Select SIM card</string> + + <!-- The menu item to open the list of accounts. [CHAR LIMIT=60]--> + <string name="menu_accounts">Manage accounts</string> + + <!-- The menu item to bulk import or bulk export contacts from SIM card or SD card. [CHAR LIMIT=30]--> + <string name="menu_import_export">Import/export</string> + + <!-- The font-family to use for tab text. --> + <string name="tab_font_family" translatable="false">sans-serif</string> + + <!-- Attribution of a contact status update, when the time of update is unknown --> + <string name="contact_status_update_attribution">via <xliff:g example="Google Talk" id="source">%1$s</xliff:g></string> + + <!-- Attribution of a contact status update, when the time of update is known --> + <string name="contact_status_update_attribution_with_date"><xliff:g example="3 hours ago" id="date">%1$s</xliff:g> via <xliff:g example="Google Talk" id="source">%2$s</xliff:g></string> + + <!-- Font family used when drawing letters for letter tile avatars. --> + <string name="letter_tile_letter_font_family" translatable="false">sans-serif-medium</string> + + <!-- Content description for the fake action menu up button as used + inside search. [CHAR LIMIT=NONE] --> + <string name="action_menu_back_from_search">stop searching</string> + + <!-- String describing the icon used to clear the search field --> + <string name="description_clear_search">Clear search</string> + + <!-- The font-family to use for the text inside the searchbox. --> + <string name="search_font_family" translatable="false">sans-serif</string> + + <!-- The title of the preference section that allows users to configure how they want their + contacts to be displayed. [CHAR LIMIT=128] --> + <string name="settings_contact_display_options_title">Contact display options</string> + + <!-- Title for Select Account Dialog [CHAR LIMIT=30] --> + <string name="select_account_dialog_title">Account</string> + + <!-- Label for the check box to toggle default sim card setting [CHAR LIMIT=35]--> + <string name="set_default_account">Always use this for calls</string> + + <!-- Title for dialog to select Phone Account for outgoing call. [CHAR LIMIT=40] --> + <string name="select_phone_account_for_calls">Call with</string> + + <!-- String used for actions in the dialer call log and the quick contact card to initiate + a call to an individual. The user is prompted to enter a note which is sent along with + the call (e.g. a call subject). [CHAR LIMIT=40] --> + <string name="call_with_a_note">Call with a note</string> + + <!-- Hint text shown in the call subject dialog. [CHAR LIMIT=255] --> + <string name="call_subject_hint">Type a note to send with call ...</string> + + <!-- Button used to start a new call with the user entered subject. [CHAR LIMIT=32] --> + <string name="send_and_call_button">SEND & CALL</string> + + <!-- String used to represent the total number of characters entered for a call subject, + compared to the character limit. Example: 2 / 64 --> + <string name="call_subject_limit"><xliff:g example="4" id="count">%1$s</xliff:g> / <xliff:g example="64" id="limit">%2$s</xliff:g></string> + + <!-- String used to build a phone number bype and phone number string. + Example: Mobile • 650-555-1212 --> + <string name="call_subject_type_and_number"><xliff:g example="Mobile" id="type">%1$s</xliff:g> • <xliff:g example="(650) 555-1212" id="number">%2$s</xliff:g></string> + + <!-- String format to describe a tab e.g.call history tab. --> + <string name="tab_title"><xliff:g id="title">%1$s</xliff:g> tab.</string> + + <!-- String format to describe the number of unread items in a tab. + + Note: AccessibilityServices use this attribute to announce what the view represents. + This is especially valuable for views without textual representation like ImageView. + --> + <plurals name="tab_title_with_unread_items"> + <item quantity="one"> + <xliff:g id="title">%1$s</xliff:g> tab. <xliff:g id="count">%2$d</xliff:g> unread item. + </item> + <item quantity="other"> + <xliff:g id="title">%1$s</xliff:g> tab. <xliff:g id="count">%2$d</xliff:g> unread items. + </item> + </plurals> + + <!-- Build version title in About preference. [CHAR LIMIT=40]--> + <string name="about_build_version">Build version</string> + + <!-- Open source licenses title in About preference. [CHAR LIMIT=60] --> + <string name="about_open_source_licenses">Open source licenses</string> + + <!-- Open source licenses summary in About preference. [CHAR LIMIT=NONE] --> + <string name="about_open_source_licenses_summary">License details for open source software</string> + + <!-- Privacy policy title in About preference. [CHAR LIMIT=40]--> + <string name="about_privacy_policy">Privacy policy</string> + + <!-- Terms of service title in about preference. [CHAR LIMIT=60]--> + <string name="about_terms_of_service">Terms of service</string> + + <!-- Title for the activity that displays licenses for open source libraries. [CHAR LIMIT=100]--> + <string name="activity_title_licenses">Open source licenses</string> + + <!-- Toast message showing when failed to open the url. [CHAR LIMIT=100]--> + <string name="url_open_error_toast">Failed to open the url.</string> + + <!-- Description string for an action button to initiate a video call from search results. + Note: AccessibilityServices use this attribute to announce what the view represents. + This is especially valuable for views without textual representation like ImageView. + + [CHAR LIMIT=NONE]--> + <string name="description_search_video_call">Place video call</string> +</resources> diff --git a/java/com/android/contacts/common/res/values/styles.xml b/java/com/android/contacts/common/res/values/styles.xml new file mode 100644 index 000000000..07d4a0225 --- /dev/null +++ b/java/com/android/contacts/common/res/values/styles.xml @@ -0,0 +1,97 @@ +<!-- + ~ Copyright (C) 2012 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> + + <style name="DirectoryHeader"> + <item name="android:background">@android:color/transparent</item> + </style> + + <style name="SectionHeaderStyle" parent="@android:style/TextAppearance.Large"> + <item name="android:textSize">16sp</item> + <item name="android:textAllCaps">true</item> + <item name="android:textColor">@color/section_header_text_color</item> + <item name="android:textStyle">bold</item> + </style> + + <style name="DirectoryHeaderStyle" parent="@android:style/TextAppearance.Small"> + <item name="android:textSize">14sp</item> + <item name="android:textColor">@color/dialer_secondary_text_color</item> + <item name="android:fontFamily">sans-serif-medium</item> + </style> + + <!-- TextView style used for headers. + +This is similar to ?android:attr/listSeparatorTextView but uses different +background and text color. See also android:style/Widget.Holo.TextView.ListSeparator +(which is private, so we cannot specify it as a parent style). --> + <style name="ContactListSeparatorTextViewStyle"> + <item name="android:layout_width">match_parent</item> + <item name="android:layout_height">wrap_content</item> + <!-- See comments for @dimen/list_section_divider_min_height --> + <item name="android:minHeight">@dimen/list_section_divider_min_height</item> + <item name="android:background">@drawable/list_section_divider_holo_custom</item> + <item name="android:textAppearance">@style/DirectoryHeaderStyle</item> + <item name="android:gravity">center_vertical</item> + <item name="android:paddingLeft">8dip</item> + <item name="android:paddingStart">8dip</item> + <item name="android:paddingTop">4dip</item> + <item name="android:paddingBottom">4dip</item> + <item name="android:ellipsize">end</item> + <item name="android:singleLine">true</item> + <item name="android:textAllCaps">true</item> + </style> + + <style name="TextAppearanceMedium" parent="@android:style/TextAppearance.Medium"> + <item name="android:textSize">16sp</item> + <item name="android:textColor">#000000</item> + </style> + + <style name="TextAppearanceSmall" parent="@android:style/TextAppearance.Small"> + <item name="android:textSize">14sp</item> + <item name="android:textColor">#737373</item> + </style> + + <style name="ListViewStyle" parent="@android:style/Widget.Material.Light.ListView"> + <item name="android:overScrollMode">always</item> + </style> + + <style name="BackgroundOnlyTheme" parent="@android:style/Theme.Material.Light"> + <item name="android:windowBackground">@null</item> + <item name="android:windowContentOverlay">@null</item> + <item name="android:windowAnimationStyle">@null</item> + <item name="android:windowNoTitle">true</item> + <!-- Activities that use this theme are background activities without obvious displays. + However, some also have dialogs. Therefore, it doesn't make sense to set this true.--> + <item name="android:windowNoDisplay">false</item> + <item name="android:windowIsFloating">true</item> + </style> + + <style name="Theme.CallSubjectDialogTheme" parent="@android:style/Theme.Material.Light.Dialog"> + <item name="android:layout_width">match_parent</item> + <item name="android:layout_height">match_parent</item> + + <!-- No backgrounds, titles or window float --> + <item name="android:windowIsTranslucent">true</item> + <item name="android:windowNoTitle">true</item> + <item name="android:windowFullscreen">false</item> + <item name="android:windowIsFloating">true</item> + <item name="android:windowBackground">@android:color/transparent</item> + <item name="android:windowDrawsSystemBarBackgrounds">false</item> + <item name="android:windowContentOverlay">@null</item> + <item name="android:windowElevation">0dp</item> + </style> +</resources> diff --git a/java/com/android/contacts/common/testing/InjectedServices.java b/java/com/android/contacts/common/testing/InjectedServices.java new file mode 100644 index 000000000..5ab5e5feb --- /dev/null +++ b/java/com/android/contacts/common/testing/InjectedServices.java @@ -0,0 +1,65 @@ +/* + * Copyright (C) 2010 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.common.testing; + +import android.content.ContentResolver; +import android.content.SharedPreferences; +import android.util.ArrayMap; +import java.util.Map; + +/** + * A mechanism for providing alternative (mock) services to the application while running tests. + * Activities, Services and the Application should check with this class to see if a particular + * service has been overridden. + */ +public class InjectedServices { + + private ContentResolver mContentResolver; + private SharedPreferences mSharedPreferences; + private Map<String, Object> mSystemServices; + + public ContentResolver getContentResolver() { + return mContentResolver; + } + + public void setContentResolver(ContentResolver contentResolver) { + this.mContentResolver = contentResolver; + } + + public SharedPreferences getSharedPreferences() { + return mSharedPreferences; + } + + public void setSharedPreferences(SharedPreferences sharedPreferences) { + this.mSharedPreferences = sharedPreferences; + } + + public void setSystemService(String name, Object service) { + if (mSystemServices == null) { + mSystemServices = new ArrayMap<>(); + } + + mSystemServices.put(name, service); + } + + public Object getSystemService(String name) { + if (mSystemServices != null) { + return mSystemServices.get(name); + } + return null; + } +} diff --git a/java/com/android/contacts/common/util/AccountFilterUtil.java b/java/com/android/contacts/common/util/AccountFilterUtil.java new file mode 100644 index 000000000..18743c65e --- /dev/null +++ b/java/com/android/contacts/common/util/AccountFilterUtil.java @@ -0,0 +1,125 @@ +/* + * Copyright (C) 2012 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.common.util; + +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.util.Log; +import android.view.View; +import android.widget.TextView; +import com.android.contacts.common.R; +import com.android.contacts.common.list.ContactListFilter; +import com.android.contacts.common.list.ContactListFilterController; + +/** Utility class for account filter manipulation. */ +public class AccountFilterUtil { + + public static final String EXTRA_CONTACT_LIST_FILTER = "contactListFilter"; + private static final String TAG = AccountFilterUtil.class.getSimpleName(); + + /** + * Find TextView with the id "account_filter_header" and set correct text for the account filter + * header. + * + * @param filterContainer View containing TextView with id "account_filter_header" + * @return true when header text is set in the call. You may use this for conditionally showing or + * hiding this entire view. + */ + public static boolean updateAccountFilterTitleForPeople( + View filterContainer, ContactListFilter filter, boolean showTitleForAllAccounts) { + return updateAccountFilterTitle(filterContainer, filter, showTitleForAllAccounts, false); + } + + /** + * Similar to {@link #updateAccountFilterTitleForPeople(View, ContactListFilter, boolean, + * boolean)}, but for Phone UI. + */ + public static boolean updateAccountFilterTitleForPhone( + View filterContainer, ContactListFilter filter, boolean showTitleForAllAccounts) { + return updateAccountFilterTitle(filterContainer, filter, showTitleForAllAccounts, true); + } + + private static boolean updateAccountFilterTitle( + View filterContainer, + ContactListFilter filter, + boolean showTitleForAllAccounts, + boolean forPhone) { + final Context context = filterContainer.getContext(); + final TextView headerTextView = + (TextView) filterContainer.findViewById(R.id.account_filter_header); + + boolean textWasSet = false; + if (filter != null) { + if (forPhone) { + if (filter.filterType == ContactListFilter.FILTER_TYPE_ALL_ACCOUNTS) { + if (showTitleForAllAccounts) { + headerTextView.setText(R.string.list_filter_phones); + textWasSet = true; + } + } else if (filter.filterType == ContactListFilter.FILTER_TYPE_ACCOUNT) { + headerTextView.setText( + context.getString(R.string.listAllContactsInAccount, filter.accountName)); + textWasSet = true; + } else if (filter.filterType == ContactListFilter.FILTER_TYPE_CUSTOM) { + headerTextView.setText(R.string.listCustomView); + textWasSet = true; + } else { + Log.w(TAG, "Filter type \"" + filter.filterType + "\" isn't expected."); + } + } else { + if (filter.filterType == ContactListFilter.FILTER_TYPE_ALL_ACCOUNTS) { + if (showTitleForAllAccounts) { + headerTextView.setText(R.string.list_filter_all_accounts); + textWasSet = true; + } + } else if (filter.filterType == ContactListFilter.FILTER_TYPE_ACCOUNT) { + headerTextView.setText( + context.getString(R.string.listAllContactsInAccount, filter.accountName)); + textWasSet = true; + } else if (filter.filterType == ContactListFilter.FILTER_TYPE_CUSTOM) { + headerTextView.setText(R.string.listCustomView); + textWasSet = true; + } else if (filter.filterType == ContactListFilter.FILTER_TYPE_SINGLE_CONTACT) { + headerTextView.setText(R.string.listSingleContact); + textWasSet = true; + } else { + Log.w(TAG, "Filter type \"" + filter.filterType + "\" isn't expected."); + } + } + } else { + Log.w(TAG, "Filter is null."); + } + return textWasSet; + } + + /** This will update filter via a given ContactListFilterController. */ + public static void handleAccountFilterResult( + ContactListFilterController filterController, int resultCode, Intent data) { + if (resultCode == Activity.RESULT_OK) { + final ContactListFilter filter = data.getParcelableExtra(EXTRA_CONTACT_LIST_FILTER); + if (filter == null) { + return; + } + if (filter.filterType == ContactListFilter.FILTER_TYPE_CUSTOM) { + filterController.selectCustomFilter(); + } else { + filterController.setContactListFilter(filter, true); + } + } + } +} diff --git a/java/com/android/contacts/common/util/BitmapUtil.java b/java/com/android/contacts/common/util/BitmapUtil.java new file mode 100644 index 000000000..20f916a3f --- /dev/null +++ b/java/com/android/contacts/common/util/BitmapUtil.java @@ -0,0 +1,167 @@ +/* + * Copyright (C) 2012 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.common.util; + +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.PorterDuff.Mode; +import android.graphics.PorterDuffXfermode; +import android.graphics.Rect; +import android.graphics.RectF; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.Drawable; + +/** Provides static functions to decode bitmaps at the optimal size */ +public class BitmapUtil { + + private BitmapUtil() {} + + /** + * Returns Width or Height of the picture, depending on which size is smaller. Doesn't actually + * decode the picture, so it is pretty efficient to run. + */ + public static int getSmallerExtentFromBytes(byte[] bytes) { + final BitmapFactory.Options options = new BitmapFactory.Options(); + + // don't actually decode the picture, just return its bounds + options.inJustDecodeBounds = true; + BitmapFactory.decodeByteArray(bytes, 0, bytes.length, options); + + // test what the best sample size is + return Math.min(options.outWidth, options.outHeight); + } + + /** + * Finds the optimal sampleSize for loading the picture + * + * @param originalSmallerExtent Width or height of the picture, whichever is smaller + * @param targetExtent Width or height of the target view, whichever is bigger. + * <p>If either one of the parameters is 0 or smaller, no sampling is applied + */ + public static int findOptimalSampleSize(int originalSmallerExtent, int targetExtent) { + // If we don't know sizes, we can't do sampling. + if (targetExtent < 1) { + return 1; + } + if (originalSmallerExtent < 1) { + return 1; + } + + // Test what the best sample size is. To do that, we find the sample size that gives us + // the best trade-off between resulting image size and memory requirement. We allow + // the down-sampled image to be 20% smaller than the target size. That way we can get around + // unfortunate cases where e.g. a 720 picture is requested for 362 and not down-sampled at + // all. Why 20%? Why not. Prove me wrong. + int extent = originalSmallerExtent; + int sampleSize = 1; + while ((extent >> 1) >= targetExtent * 0.8f) { + sampleSize <<= 1; + extent >>= 1; + } + + return sampleSize; + } + + /** Decodes the bitmap with the given sample size */ + public static Bitmap decodeBitmapFromBytes(byte[] bytes, int sampleSize) { + final BitmapFactory.Options options; + if (sampleSize <= 1) { + options = null; + } else { + options = new BitmapFactory.Options(); + options.inSampleSize = sampleSize; + } + return BitmapFactory.decodeByteArray(bytes, 0, bytes.length, options); + } + + /** + * Retrieves a copy of the specified drawable resource, rotated by a specified angle. + * + * @param resources The current resources. + * @param resourceId The resource ID of the drawable to rotate. + * @param angle The angle of rotation. + * @return Rotated drawable. + */ + public static Drawable getRotatedDrawable( + android.content.res.Resources resources, int resourceId, float angle) { + + // Get the original drawable and make a copy which will be rotated. + Bitmap original = BitmapFactory.decodeResource(resources, resourceId); + Bitmap rotated = + Bitmap.createBitmap(original.getWidth(), original.getHeight(), Bitmap.Config.ARGB_8888); + + // Perform the rotation. + Canvas tempCanvas = new Canvas(rotated); + tempCanvas.rotate(angle, original.getWidth() / 2, original.getHeight() / 2); + tempCanvas.drawBitmap(original, 0, 0, null); + + return new BitmapDrawable(resources, rotated); + } + + /** + * Given an input bitmap, scales it to the given width/height and makes it round. + * + * @param input {@link Bitmap} to scale and crop + * @param targetWidth desired output width + * @param targetHeight desired output height + * @return output bitmap scaled to the target width/height and cropped to an oval. The cropping + * algorithm will try to fit as much of the input into the output as possible, while + * preserving the target width/height ratio. + */ + public static Bitmap getRoundedBitmap(Bitmap input, int targetWidth, int targetHeight) { + if (input == null) { + return null; + } + final Bitmap.Config inputConfig = input.getConfig(); + final Bitmap result = + Bitmap.createBitmap( + targetWidth, targetHeight, inputConfig != null ? inputConfig : Bitmap.Config.ARGB_8888); + final Canvas canvas = new Canvas(result); + final Paint paint = new Paint(); + canvas.drawARGB(0, 0, 0, 0); + paint.setAntiAlias(true); + final RectF dst = new RectF(0, 0, targetWidth, targetHeight); + canvas.drawOval(dst, paint); + + // Specifies that only pixels present in the destination (i.e. the drawn oval) should + // be overwritten with pixels from the input bitmap. + paint.setXfermode(new PorterDuffXfermode(Mode.SRC_IN)); + + final int inputWidth = input.getWidth(); + final int inputHeight = input.getHeight(); + + // Choose the largest scale factor that will fit inside the dimensions of the + // input bitmap. + final float scaleBy = + Math.min((float) inputWidth / targetWidth, (float) inputHeight / targetHeight); + + final int xCropAmountHalved = (int) (scaleBy * targetWidth / 2); + final int yCropAmountHalved = (int) (scaleBy * targetHeight / 2); + + final Rect src = + new Rect( + inputWidth / 2 - xCropAmountHalved, + inputHeight / 2 - yCropAmountHalved, + inputWidth / 2 + xCropAmountHalved, + inputHeight / 2 + yCropAmountHalved); + + canvas.drawBitmap(input, src, dst, paint); + return result; + } +} diff --git a/java/com/android/contacts/common/util/CommonDateUtils.java b/java/com/android/contacts/common/util/CommonDateUtils.java new file mode 100644 index 000000000..312e691f8 --- /dev/null +++ b/java/com/android/contacts/common/util/CommonDateUtils.java @@ -0,0 +1,37 @@ +/* + * Copyright (C) 2012 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.common.util; + +import java.text.SimpleDateFormat; +import java.util.Locale; + +/** Common date utilities. */ +public class CommonDateUtils { + + // All the SimpleDateFormats in this class use the UTC timezone + public static final SimpleDateFormat NO_YEAR_DATE_FORMAT = + new SimpleDateFormat("--MM-dd", Locale.US); + public static final SimpleDateFormat FULL_DATE_FORMAT = + new SimpleDateFormat("yyyy-MM-dd", Locale.US); + public static final SimpleDateFormat DATE_AND_TIME_FORMAT = + new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US); + public static final SimpleDateFormat NO_YEAR_DATE_AND_TIME_FORMAT = + new SimpleDateFormat("--MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US); + + /** Exchange requires 8:00 for birthdays */ + public static final int DEFAULT_HOUR = 8; +} diff --git a/java/com/android/contacts/common/util/Constants.java b/java/com/android/contacts/common/util/Constants.java new file mode 100644 index 000000000..172e8c348 --- /dev/null +++ b/java/com/android/contacts/common/util/Constants.java @@ -0,0 +1,28 @@ +/* + * Copyright (C) 2009 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.common.util; + +public class Constants { + + /** + * Log tag for performance measurement. To enable: adb shell setprop log.tag.ContactsPerf VERBOSE + */ + public static final String PERFORMANCE_TAG = "ContactsPerf"; + + // Used for lookup URI that contains an encoded JSON string. + public static final String LOOKUP_URI_ENCODED = "encoded"; +} diff --git a/java/com/android/contacts/common/util/ContactDisplayUtils.java b/java/com/android/contacts/common/util/ContactDisplayUtils.java new file mode 100644 index 000000000..1586784db --- /dev/null +++ b/java/com/android/contacts/common/util/ContactDisplayUtils.java @@ -0,0 +1,307 @@ +/* + * Copyright (C) 2012 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.common.util; + +import static android.provider.ContactsContract.CommonDataKinds.Phone; + +import android.content.Context; +import android.content.res.Resources; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.text.Spannable; +import android.text.SpannableString; +import android.text.TextUtils; +import android.text.style.TtsSpan; +import android.util.Log; +import android.util.Patterns; +import com.android.contacts.common.R; +import com.android.contacts.common.compat.PhoneNumberUtilsCompat; +import com.android.contacts.common.preference.ContactsPreferences; +import java.util.Objects; + +/** Methods for handling various contact data labels. */ +public class ContactDisplayUtils { + + public static final int INTERACTION_CALL = 1; + public static final int INTERACTION_SMS = 2; + private static final String TAG = ContactDisplayUtils.class.getSimpleName(); + + /** + * Checks if the given data type is a custom type. + * + * @param type Phone data type. + * @return {@literal true} if the type is custom. {@literal false} if not. + */ + public static boolean isCustomPhoneType(Integer type) { + return type == Phone.TYPE_CUSTOM || type == Phone.TYPE_ASSISTANT; + } + + /** + * Gets a display label for a given phone type. + * + * @param type The type of number. + * @param customLabel A custom label to use if the phone is determined to be of custom type + * determined by {@link #isCustomPhoneType(Integer))} + * @param interactionType whether this is a call or sms. Either {@link #INTERACTION_CALL} or + * {@link #INTERACTION_SMS}. + * @param context The application context. + * @return An appropriate string label + */ + public static CharSequence getLabelForCallOrSms( + Integer type, CharSequence customLabel, int interactionType, @NonNull Context context) { + Objects.requireNonNull(context); + + if (isCustomPhoneType(type)) { + return (customLabel == null) ? "" : customLabel; + } else { + int resId; + if (interactionType == INTERACTION_SMS) { + resId = getSmsLabelResourceId(type); + } else { + resId = getPhoneLabelResourceId(type); + if (interactionType != INTERACTION_CALL) { + Log.e( + TAG, + "Un-recognized interaction type: " + + interactionType + + ". Defaulting to ContactDisplayUtils.INTERACTION_CALL."); + } + } + + return context.getResources().getText(resId); + } + } + + /** + * Find a label for calling. + * + * @param type The type of number. + * @return An appropriate string label. + */ + public static int getPhoneLabelResourceId(Integer type) { + if (type == null) { + return R.string.call_other; + } + switch (type) { + case Phone.TYPE_HOME: + return R.string.call_home; + case Phone.TYPE_MOBILE: + return R.string.call_mobile; + case Phone.TYPE_WORK: + return R.string.call_work; + case Phone.TYPE_FAX_WORK: + return R.string.call_fax_work; + case Phone.TYPE_FAX_HOME: + return R.string.call_fax_home; + case Phone.TYPE_PAGER: + return R.string.call_pager; + case Phone.TYPE_OTHER: + return R.string.call_other; + case Phone.TYPE_CALLBACK: + return R.string.call_callback; + case Phone.TYPE_CAR: + return R.string.call_car; + case Phone.TYPE_COMPANY_MAIN: + return R.string.call_company_main; + case Phone.TYPE_ISDN: + return R.string.call_isdn; + case Phone.TYPE_MAIN: + return R.string.call_main; + case Phone.TYPE_OTHER_FAX: + return R.string.call_other_fax; + case Phone.TYPE_RADIO: + return R.string.call_radio; + case Phone.TYPE_TELEX: + return R.string.call_telex; + case Phone.TYPE_TTY_TDD: + return R.string.call_tty_tdd; + case Phone.TYPE_WORK_MOBILE: + return R.string.call_work_mobile; + case Phone.TYPE_WORK_PAGER: + return R.string.call_work_pager; + case Phone.TYPE_ASSISTANT: + return R.string.call_assistant; + case Phone.TYPE_MMS: + return R.string.call_mms; + default: + return R.string.call_custom; + } + } + + /** + * Find a label for sending an sms. + * + * @param type The type of number. + * @return An appropriate string label. + */ + public static int getSmsLabelResourceId(Integer type) { + if (type == null) { + return R.string.sms_other; + } + switch (type) { + case Phone.TYPE_HOME: + return R.string.sms_home; + case Phone.TYPE_MOBILE: + return R.string.sms_mobile; + case Phone.TYPE_WORK: + return R.string.sms_work; + case Phone.TYPE_FAX_WORK: + return R.string.sms_fax_work; + case Phone.TYPE_FAX_HOME: + return R.string.sms_fax_home; + case Phone.TYPE_PAGER: + return R.string.sms_pager; + case Phone.TYPE_OTHER: + return R.string.sms_other; + case Phone.TYPE_CALLBACK: + return R.string.sms_callback; + case Phone.TYPE_CAR: + return R.string.sms_car; + case Phone.TYPE_COMPANY_MAIN: + return R.string.sms_company_main; + case Phone.TYPE_ISDN: + return R.string.sms_isdn; + case Phone.TYPE_MAIN: + return R.string.sms_main; + case Phone.TYPE_OTHER_FAX: + return R.string.sms_other_fax; + case Phone.TYPE_RADIO: + return R.string.sms_radio; + case Phone.TYPE_TELEX: + return R.string.sms_telex; + case Phone.TYPE_TTY_TDD: + return R.string.sms_tty_tdd; + case Phone.TYPE_WORK_MOBILE: + return R.string.sms_work_mobile; + case Phone.TYPE_WORK_PAGER: + return R.string.sms_work_pager; + case Phone.TYPE_ASSISTANT: + return R.string.sms_assistant; + case Phone.TYPE_MMS: + return R.string.sms_mms; + default: + return R.string.sms_custom; + } + } + + /** + * Whether the given text could be a phone number. + * + * <p>Note this will miss many things that are legitimate phone numbers, for example, phone + * numbers with letters. + */ + public static boolean isPossiblePhoneNumber(CharSequence text) { + return text != null && Patterns.PHONE.matcher(text.toString()).matches(); + } + + /** + * Returns a Spannable for the given message with a telephone {@link TtsSpan} set for the given + * phone number text wherever it is found within the message. + */ + public static Spannable getTelephoneTtsSpannable( + @Nullable String message, @Nullable String phoneNumber) { + if (message == null) { + return null; + } + final Spannable spannable = new SpannableString(message); + int start = phoneNumber == null ? -1 : message.indexOf(phoneNumber); + while (start >= 0) { + final int end = start + phoneNumber.length(); + final TtsSpan ttsSpan = PhoneNumberUtilsCompat.createTtsSpan(phoneNumber); + spannable.setSpan( + ttsSpan, + start, + end, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); // this is consistenly done in a misleading way.. + start = message.indexOf(phoneNumber, end); + } + return spannable; + } + + /** + * Retrieves a string from a string template that takes 1 phone number as argument, span the + * number with a telephone {@link TtsSpan}, and return the spanned string. + * + * @param resources to retrieve the string from + * @param stringId ID of the string + * @param number to pass in the template + * @return CharSequence with the phone number wrapped in a TtsSpan + */ + public static CharSequence getTtsSpannedPhoneNumber( + Resources resources, int stringId, String number) { + String msg = resources.getString(stringId, number); + return ContactDisplayUtils.getTelephoneTtsSpannable(msg, number); + } + + /** + * Returns either namePrimary or nameAlternative based on the {@link ContactsPreferences}. + * Defaults to the name that is non-null. + * + * @param namePrimary the primary name. + * @param nameAlternative the alternative name. + * @param contactsPreferences the ContactsPreferences used to determine the preferred display + * name. + * @return namePrimary or nameAlternative depending on the value of displayOrderPreference. + */ + public static String getPreferredDisplayName( + String namePrimary, + String nameAlternative, + @Nullable ContactsPreferences contactsPreferences) { + if (contactsPreferences == null) { + return namePrimary != null ? namePrimary : nameAlternative; + } + if (contactsPreferences.getDisplayOrder() == ContactsPreferences.DISPLAY_ORDER_PRIMARY) { + return namePrimary; + } + + if (contactsPreferences.getDisplayOrder() == ContactsPreferences.DISPLAY_ORDER_ALTERNATIVE + && !TextUtils.isEmpty(nameAlternative)) { + return nameAlternative; + } + + return namePrimary; + } + + /** + * Returns either namePrimary or nameAlternative based on the {@link ContactsPreferences}. + * Defaults to the name that is non-null. + * + * @param namePrimary the primary name. + * @param nameAlternative the alternative name. + * @param contactsPreferences the ContactsPreferences used to determine the preferred sort order. + * @return namePrimary or nameAlternative depending on the value of displayOrderPreference. + */ + public static String getPreferredSortName( + String namePrimary, + String nameAlternative, + @Nullable ContactsPreferences contactsPreferences) { + if (contactsPreferences == null) { + return namePrimary != null ? namePrimary : nameAlternative; + } + + if (contactsPreferences.getSortOrder() == ContactsPreferences.SORT_ORDER_PRIMARY) { + return namePrimary; + } + + if (contactsPreferences.getSortOrder() == ContactsPreferences.SORT_ORDER_ALTERNATIVE + && !TextUtils.isEmpty(nameAlternative)) { + return nameAlternative; + } + + return namePrimary; + } +} diff --git a/java/com/android/contacts/common/util/ContactListViewUtils.java b/java/com/android/contacts/common/util/ContactListViewUtils.java new file mode 100644 index 000000000..278c27d5c --- /dev/null +++ b/java/com/android/contacts/common/util/ContactListViewUtils.java @@ -0,0 +1,89 @@ +/* + * 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.common.util; + +import android.content.res.Resources; +import android.view.View; +import android.widget.ListView; +import com.android.contacts.common.R; +import com.android.dialer.util.ViewUtil; + +/** Utilities for configuring ListViews with a card background. */ +public class ContactListViewUtils { + + // These two constants will help add more padding for the text inside the card. + private static final double TEXT_LEFT_PADDING_TO_CARD_PADDING_RATIO = 1.1; + + private static void addPaddingToView( + ListView listView, int parentWidth, int listSpaceWeight, int listViewWeight) { + if (listSpaceWeight > 0 && listViewWeight > 0) { + double paddingPercent = + (double) listSpaceWeight / (double) (listSpaceWeight * 2 + listViewWeight); + listView.setPadding( + (int) (parentWidth * paddingPercent * TEXT_LEFT_PADDING_TO_CARD_PADDING_RATIO), + listView.getPaddingTop(), + (int) (parentWidth * paddingPercent * TEXT_LEFT_PADDING_TO_CARD_PADDING_RATIO), + listView.getPaddingBottom()); + // The EdgeEffect and ScrollBar need to span to the edge of the ListView's padding. + listView.setClipToPadding(false); + listView.setScrollBarStyle(View.SCROLLBARS_OUTSIDE_OVERLAY); + } + } + + /** + * Add padding to {@param listView} if this configuration has set both space weight and view + * weight on the layout. Use this util method instead of defining the padding in the layout file + * so that the {@param listView}'s padding can be set proportional to the card padding. + * + * @param listView ListView that we add padding to + * @param rootLayout layout that contains ListView and R.id.list_card + */ + public static void applyCardPaddingToView( + Resources resources, final ListView listView, final View rootLayout) { + // Set a padding on the list view so it appears in the center of the card + // in the layout if required. + final int listSpaceWeight = resources.getInteger(R.integer.contact_list_space_layout_weight); + final int listViewWeight = resources.getInteger(R.integer.contact_list_card_layout_weight); + if (listSpaceWeight > 0 && listViewWeight > 0) { + rootLayout.setBackgroundResource(0); + // Set the card view visible + View mCardView = rootLayout.findViewById(R.id.list_card); + if (mCardView == null) { + throw new RuntimeException( + "Your content must have a list card view who can be turned visible " + + "whenever it is necessary."); + } + mCardView.setVisibility(View.VISIBLE); + + // Add extra padding to the list view to make them appear in the center of the card. + // In order to avoid jumping, we skip drawing the next frame of the ListView. + ViewUtil.doOnPreDraw( + listView, + false, + new Runnable() { + @Override + public void run() { + // Use the rootLayout.getWidth() instead of listView.getWidth() since + // we sometimes hide the listView until we finish loading data. This would + // result in incorrect padding. + ContactListViewUtils.addPaddingToView( + listView, rootLayout.getWidth(), listSpaceWeight, listViewWeight); + } + }); + } + } +} diff --git a/java/com/android/contacts/common/util/ContactLoaderUtils.java b/java/com/android/contacts/common/util/ContactLoaderUtils.java new file mode 100644 index 000000000..e30971721 --- /dev/null +++ b/java/com/android/contacts/common/util/ContactLoaderUtils.java @@ -0,0 +1,78 @@ +/* + * Copyright (C) 2011 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.common.util; + +import android.content.ContentResolver; +import android.content.ContentUris; +import android.net.Uri; +import android.provider.Contacts; +import android.provider.ContactsContract; +import android.provider.ContactsContract.RawContacts; + +/** Utility methods for the {@link ContactLoader}. */ +public final class ContactLoaderUtils { + + /** Static helper, not instantiable. */ + private ContactLoaderUtils() {} + + /** + * Transforms the given Uri and returns a Lookup-Uri that represents the contact. For legacy + * contacts, a raw-contact lookup is performed. An {@link IllegalArgumentException} can be thrown + * if the URI is null or the authority is not recognized. + * + * <p>Do not call from the UI thread. + */ + @SuppressWarnings("deprecation") + public static Uri ensureIsContactUri(final ContentResolver resolver, final Uri uri) + throws IllegalArgumentException { + if (uri == null) { + throw new IllegalArgumentException("uri must not be null"); + } + + final String authority = uri.getAuthority(); + + // Current Style Uri? + if (ContactsContract.AUTHORITY.equals(authority)) { + final String type = resolver.getType(uri); + // Contact-Uri? Good, return it + if (ContactsContract.Contacts.CONTENT_ITEM_TYPE.equals(type)) { + return uri; + } + + // RawContact-Uri? Transform it to ContactUri + if (RawContacts.CONTENT_ITEM_TYPE.equals(type)) { + final long rawContactId = ContentUris.parseId(uri); + return RawContacts.getContactLookupUri( + resolver, ContentUris.withAppendedId(RawContacts.CONTENT_URI, rawContactId)); + } + + // Anything else? We don't know what this is + throw new IllegalArgumentException("uri format is unknown"); + } + + // Legacy Style? Convert to RawContact + final String OBSOLETE_AUTHORITY = Contacts.AUTHORITY; + if (OBSOLETE_AUTHORITY.equals(authority)) { + // Legacy Format. Convert to RawContact-Uri and then lookup the contact + final long rawContactId = ContentUris.parseId(uri); + return RawContacts.getContactLookupUri( + resolver, ContentUris.withAppendedId(RawContacts.CONTENT_URI, rawContactId)); + } + + throw new IllegalArgumentException("uri authority is unknown"); + } +} diff --git a/java/com/android/contacts/common/util/DateUtils.java b/java/com/android/contacts/common/util/DateUtils.java new file mode 100644 index 000000000..1935d727a --- /dev/null +++ b/java/com/android/contacts/common/util/DateUtils.java @@ -0,0 +1,283 @@ +/* + * Copyright (C) 2010 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.common.util; + +import android.content.Context; +import android.text.format.DateFormat; +import android.text.format.Time; +import java.text.ParsePosition; +import java.text.SimpleDateFormat; +import java.util.Calendar; +import java.util.Date; +import java.util.GregorianCalendar; +import java.util.Locale; +import java.util.TimeZone; + +/** Utility methods for processing dates. */ +public class DateUtils { + + public static final TimeZone UTC_TIMEZONE = TimeZone.getTimeZone("UTC"); + + /** + * When parsing a date without a year, the system assumes 1970, which wasn't a leap-year. Let's + * add a one-off hack for that day of the year + */ + public static final String NO_YEAR_DATE_FEB29TH = "--02-29"; + + // Variations of ISO 8601 date format. Do not change the order - it does affect the + // result in ambiguous cases. + private static final SimpleDateFormat[] DATE_FORMATS = { + CommonDateUtils.FULL_DATE_FORMAT, + CommonDateUtils.DATE_AND_TIME_FORMAT, + new SimpleDateFormat("yyyy-MM-dd'T'HH:mm'Z'", Locale.US), + new SimpleDateFormat("yyyyMMdd", Locale.US), + new SimpleDateFormat("yyyyMMdd'T'HHmmssSSS'Z'", Locale.US), + new SimpleDateFormat("yyyyMMdd'T'HHmmss'Z'", Locale.US), + new SimpleDateFormat("yyyyMMdd'T'HHmm'Z'", Locale.US), + }; + + static { + for (SimpleDateFormat format : DATE_FORMATS) { + format.setLenient(true); + format.setTimeZone(UTC_TIMEZONE); + } + CommonDateUtils.NO_YEAR_DATE_FORMAT.setTimeZone(UTC_TIMEZONE); + } + + /** + * Parses the supplied string to see if it looks like a date. + * + * @param string The string representation of the provided date + * @param mustContainYear If true, the string is parsed as a date containing a year. If false, the + * string is parsed into a valid date even if the year field is missing. + * @return A Calendar object corresponding to the date if the string is successfully parsed. If + * not, null is returned. + */ + public static Calendar parseDate(String string, boolean mustContainYear) { + ParsePosition parsePosition = new ParsePosition(0); + Date date; + if (!mustContainYear) { + final boolean noYearParsed; + // Unfortunately, we can't parse Feb 29th correctly, so let's handle this day seperately + if (NO_YEAR_DATE_FEB29TH.equals(string)) { + return getUtcDate(0, Calendar.FEBRUARY, 29); + } else { + synchronized (CommonDateUtils.NO_YEAR_DATE_FORMAT) { + date = CommonDateUtils.NO_YEAR_DATE_FORMAT.parse(string, parsePosition); + } + noYearParsed = parsePosition.getIndex() == string.length(); + } + + if (noYearParsed) { + return getUtcDate(date, true); + } + } + for (int i = 0; i < DATE_FORMATS.length; i++) { + SimpleDateFormat f = DATE_FORMATS[i]; + synchronized (f) { + parsePosition.setIndex(0); + date = f.parse(string, parsePosition); + if (parsePosition.getIndex() == string.length()) { + return getUtcDate(date, false); + } + } + } + return null; + } + + private static final Calendar getUtcDate(Date date, boolean noYear) { + final Calendar calendar = Calendar.getInstance(UTC_TIMEZONE, Locale.US); + calendar.setTime(date); + if (noYear) { + calendar.set(Calendar.YEAR, 0); + } + return calendar; + } + + private static final Calendar getUtcDate(int year, int month, int dayOfMonth) { + final Calendar calendar = Calendar.getInstance(UTC_TIMEZONE, Locale.US); + calendar.clear(); + calendar.set(Calendar.YEAR, year); + calendar.set(Calendar.MONTH, month); + calendar.set(Calendar.DAY_OF_MONTH, dayOfMonth); + return calendar; + } + + public static boolean isYearSet(Calendar cal) { + // use the Calendar.YEAR field to track whether or not the year is set instead of + // Calendar.isSet() because doing Calendar.get() causes Calendar.isSet() to become + // true irregardless of what the previous value was + return cal.get(Calendar.YEAR) > 1; + } + + /** + * Same as {@link #formatDate(Context context, String string, boolean longForm)}, with longForm + * set to {@code true} by default. + * + * @param context Valid context + * @param string String representation of a date to parse + * @return Returns the same date in a cleaned up format. If the supplied string does not look like + * a date, return it unchanged. + */ + public static String formatDate(Context context, String string) { + return formatDate(context, string, true); + } + + /** + * Parses the supplied string to see if it looks like a date. + * + * @param context Valid context + * @param string String representation of a date to parse + * @param longForm If true, return the date formatted into its long string representation. If + * false, return the date formatted using its short form representation (i.e. 12/11/2012) + * @return Returns the same date in a cleaned up format. If the supplied string does not look like + * a date, return it unchanged. + */ + public static String formatDate(Context context, String string, boolean longForm) { + if (string == null) { + return null; + } + + string = string.trim(); + if (string.length() == 0) { + return string; + } + final Calendar cal = parseDate(string, false); + + // we weren't able to parse the string successfully so just return it unchanged + if (cal == null) { + return string; + } + + final boolean isYearSet = isYearSet(cal); + final java.text.DateFormat outFormat; + if (!isYearSet) { + outFormat = getLocalizedDateFormatWithoutYear(context); + } else { + outFormat = + longForm ? DateFormat.getLongDateFormat(context) : DateFormat.getDateFormat(context); + } + synchronized (outFormat) { + outFormat.setTimeZone(UTC_TIMEZONE); + return outFormat.format(cal.getTime()); + } + } + + public static boolean isMonthBeforeDay(Context context) { + char[] dateFormatOrder = DateFormat.getDateFormatOrder(context); + for (int i = 0; i < dateFormatOrder.length; i++) { + if (dateFormatOrder[i] == 'd') { + return false; + } + if (dateFormatOrder[i] == 'M') { + return true; + } + } + return false; + } + + /** + * Returns a SimpleDateFormat object without the year fields by using a regular expression to + * eliminate the year in the string pattern. In the rare occurence that the resulting pattern + * cannot be reconverted into a SimpleDateFormat, it uses the provided context to determine + * whether the month field should be displayed before the day field, and returns either "MMMM dd" + * or "dd MMMM" converted into a SimpleDateFormat. + */ + public static java.text.DateFormat getLocalizedDateFormatWithoutYear(Context context) { + final String pattern = + ((SimpleDateFormat) SimpleDateFormat.getDateInstance(java.text.DateFormat.LONG)) + .toPattern(); + // Determine the correct regex pattern for year. + // Special case handling for Spanish locale by checking for "de" + final String yearPattern = + pattern.contains("de") ? "[^Mm]*[Yy]+[^Mm]*" : "[^DdMm]*[Yy]+[^DdMm]*"; + try { + // Eliminate the substring in pattern that matches the format for that of year + return new SimpleDateFormat(pattern.replaceAll(yearPattern, "")); + } catch (IllegalArgumentException e) { + return new SimpleDateFormat(DateUtils.isMonthBeforeDay(context) ? "MMMM dd" : "dd MMMM"); + } + } + + /** + * Given a calendar (possibly containing only a day of the year), returns the earliest possible + * anniversary of the date that is equal to or after the current point in time if the date does + * not contain a year, or the date converted to the local time zone (if the date contains a year. + * + * @param target The date we wish to convert(in the UTC time zone). + * @return If date does not contain a year (year < 1900), returns the next earliest anniversary + * that is after the current point in time (in the local time zone). Otherwise, returns the + * adjusted Date in the local time zone. + */ + public static Date getNextAnnualDate(Calendar target) { + final Calendar today = Calendar.getInstance(); + today.setTime(new Date()); + + // Round the current time to the exact start of today so that when we compare + // today against the target date, both dates are set to exactly 0000H. + today.set(Calendar.HOUR_OF_DAY, 0); + today.set(Calendar.MINUTE, 0); + today.set(Calendar.SECOND, 0); + today.set(Calendar.MILLISECOND, 0); + + final boolean isYearSet = isYearSet(target); + final int targetYear = target.get(Calendar.YEAR); + final int targetMonth = target.get(Calendar.MONTH); + final int targetDay = target.get(Calendar.DAY_OF_MONTH); + final boolean isFeb29 = (targetMonth == Calendar.FEBRUARY && targetDay == 29); + final GregorianCalendar anniversary = new GregorianCalendar(); + // Convert from the UTC date to the local date. Set the year to today's year if the + // there is no provided year (targetYear < 1900) + anniversary.set(!isYearSet ? today.get(Calendar.YEAR) : targetYear, targetMonth, targetDay); + // If the anniversary's date is before the start of today and there is no year set, + // increment the year by 1 so that the returned date is always equal to or greater than + // today. If the day is a leap year, keep going until we get the next leap year anniversary + // Otherwise if there is already a year set, simply return the exact date. + if (!isYearSet) { + int anniversaryYear = today.get(Calendar.YEAR); + if (anniversary.before(today) || (isFeb29 && !anniversary.isLeapYear(anniversaryYear))) { + // If the target date is not Feb 29, then set the anniversary to the next year. + // Otherwise, keep going until we find the next leap year (this is not guaranteed + // to be in 4 years time). + do { + anniversaryYear += 1; + } while (isFeb29 && !anniversary.isLeapYear(anniversaryYear)); + anniversary.set(anniversaryYear, targetMonth, targetDay); + } + } + return anniversary.getTime(); + } + + /** + * Determine the difference, in days between two dates. Uses similar logic as the {@link + * android.text.format.DateUtils.getRelativeTimeSpanString} method. + * + * @param time Instance of time object to use for calculations. + * @param date1 First date to check. + * @param date2 Second date to check. + * @return The absolute difference in days between the two dates. + */ + public static int getDayDifference(Time time, long date1, long date2) { + time.set(date1); + int startDay = Time.getJulianDay(date1, time.gmtoff); + + time.set(date2); + int currentDay = Time.getJulianDay(date2, time.gmtoff); + + return Math.abs(currentDay - startDay); + } +} diff --git a/java/com/android/contacts/common/util/FabUtil.java b/java/com/android/contacts/common/util/FabUtil.java new file mode 100644 index 000000000..b1bb2e653 --- /dev/null +++ b/java/com/android/contacts/common/util/FabUtil.java @@ -0,0 +1,71 @@ +/* + * Copyright (C) 2012 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.common.util; + +import android.content.res.Resources; +import android.graphics.Outline; +import android.view.View; +import android.view.ViewOutlineProvider; +import android.widget.ListView; +import com.android.contacts.common.R; +import com.android.dialer.compat.CompatUtils; + +/** Provides static functions to work with views */ +public class FabUtil { + + private static final ViewOutlineProvider OVAL_OUTLINE_PROVIDER = + new ViewOutlineProvider() { + @Override + public void getOutline(View view, Outline outline) { + outline.setOval(0, 0, view.getWidth(), view.getHeight()); + } + }; + + private FabUtil() {} + + /** + * Configures the floating action button, clipping it to a circle and setting its translation z + * + * @param fabView the float action button's view + * @param res the resources file + */ + public static void setupFloatingActionButton(View fabView, Resources res) { + if (CompatUtils.isLollipopCompatible()) { + fabView.setOutlineProvider(OVAL_OUTLINE_PROVIDER); + fabView.setTranslationZ( + res.getDimensionPixelSize(R.dimen.floating_action_button_translation_z)); + } + } + + /** + * Adds padding to the bottom of the given {@link ListView} so that the floating action button + * does not obscure any content. + * + * @param listView to add the padding to + * @param res valid resources object + */ + public static void addBottomPaddingToListViewForFab(ListView listView, Resources res) { + final int fabPadding = + res.getDimensionPixelSize(R.dimen.floating_action_button_list_bottom_padding); + listView.setPaddingRelative( + listView.getPaddingStart(), + listView.getPaddingTop(), + listView.getPaddingEnd(), + listView.getPaddingBottom() + fabPadding); + listView.setClipToPadding(false); + } +} diff --git a/java/com/android/contacts/common/util/MaterialColorMapUtils.java b/java/com/android/contacts/common/util/MaterialColorMapUtils.java new file mode 100644 index 000000000..a2d9847ec --- /dev/null +++ b/java/com/android/contacts/common/util/MaterialColorMapUtils.java @@ -0,0 +1,181 @@ +/* + * Copyright (C) 2014 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.common.util; + +import android.content.res.Resources; +import android.content.res.TypedArray; +import android.os.Parcel; +import android.os.Parcelable; +import android.os.Trace; +import com.android.contacts.common.R; + +public class MaterialColorMapUtils { + + private final TypedArray sPrimaryColors; + private final TypedArray sSecondaryColors; + + public MaterialColorMapUtils(Resources resources) { + sPrimaryColors = + resources.obtainTypedArray(com.android.contacts.common.R.array.letter_tile_colors); + sSecondaryColors = + resources.obtainTypedArray(com.android.contacts.common.R.array.letter_tile_colors_dark); + } + + public static MaterialPalette getDefaultPrimaryAndSecondaryColors(Resources resources) { + final int primaryColor = resources.getColor(R.color.quickcontact_default_photo_tint_color); + final int secondaryColor = + resources.getColor(R.color.quickcontact_default_photo_tint_color_dark); + return new MaterialPalette(primaryColor, secondaryColor); + } + + /** + * Returns the hue component of a color int. + * + * @return A value between 0.0f and 1.0f + */ + public static float hue(int color) { + int r = (color >> 16) & 0xFF; + int g = (color >> 8) & 0xFF; + int b = color & 0xFF; + + int V = Math.max(b, Math.max(r, g)); + int temp = Math.min(b, Math.min(r, g)); + + float H; + + if (V == temp) { + H = 0; + } else { + final float vtemp = V - temp; + final float cr = (V - r) / vtemp; + final float cg = (V - g) / vtemp; + final float cb = (V - b) / vtemp; + + if (r == V) { + H = cb - cg; + } else if (g == V) { + H = 2 + cr - cb; + } else { + H = 4 + cg - cr; + } + + H /= 6.f; + if (H < 0) { + H++; + } + } + + return H; + } + + /** + * Return primary and secondary colors from the Material color palette that are similar to {@param + * color}. + */ + public MaterialPalette calculatePrimaryAndSecondaryColor(int color) { + Trace.beginSection("calculatePrimaryAndSecondaryColor"); + + final float colorHue = hue(color); + float minimumDistance = Float.MAX_VALUE; + int indexBestMatch = 0; + for (int i = 0; i < sPrimaryColors.length(); i++) { + final int primaryColor = sPrimaryColors.getColor(i, 0); + final float comparedHue = hue(primaryColor); + // No need to be perceptually accurate when calculating color distances since + // we are only mapping to 15 colors. Being slightly inaccurate isn't going to change + // the mapping very often. + final float distance = Math.abs(comparedHue - colorHue); + if (distance < minimumDistance) { + minimumDistance = distance; + indexBestMatch = i; + } + } + + Trace.endSection(); + return new MaterialPalette( + sPrimaryColors.getColor(indexBestMatch, 0), sSecondaryColors.getColor(indexBestMatch, 0)); + } + + public static class MaterialPalette implements Parcelable { + + public static final Creator<MaterialPalette> CREATOR = + new Creator<MaterialPalette>() { + @Override + public MaterialPalette createFromParcel(Parcel in) { + return new MaterialPalette(in); + } + + @Override + public MaterialPalette[] newArray(int size) { + return new MaterialPalette[size]; + } + }; + public final int mPrimaryColor; + public final int mSecondaryColor; + + public MaterialPalette(int primaryColor, int secondaryColor) { + mPrimaryColor = primaryColor; + mSecondaryColor = secondaryColor; + } + + private MaterialPalette(Parcel in) { + mPrimaryColor = in.readInt(); + mSecondaryColor = in.readInt(); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (getClass() != obj.getClass()) { + return false; + } + MaterialPalette other = (MaterialPalette) obj; + if (mPrimaryColor != other.mPrimaryColor) { + return false; + } + if (mSecondaryColor != other.mSecondaryColor) { + return false; + } + return true; + } + + @Override + public int hashCode() { + final int prime = 31; + int result = 1; + result = prime * result + mPrimaryColor; + result = prime * result + mSecondaryColor; + return result; + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeInt(mPrimaryColor); + dest.writeInt(mSecondaryColor); + } + } +} diff --git a/java/com/android/contacts/common/util/NameConverter.java b/java/com/android/contacts/common/util/NameConverter.java new file mode 100644 index 000000000..ae3275d14 --- /dev/null +++ b/java/com/android/contacts/common/util/NameConverter.java @@ -0,0 +1,242 @@ +/* + * Copyright (C) 2011 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.common.util; + +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; +import android.net.Uri.Builder; +import android.provider.ContactsContract; +import android.provider.ContactsContract.CommonDataKinds.StructuredName; +import android.text.TextUtils; +import com.android.contacts.common.model.dataitem.StructuredNameDataItem; +import java.util.Map; +import java.util.TreeMap; + +/** + * Utility class for converting between a display name and structured name (and vice-versa), via + * calls to the contact provider. + */ +public class NameConverter { + + /** The array of fields that comprise a structured name. */ + public static final String[] STRUCTURED_NAME_FIELDS = + new String[] { + StructuredName.PREFIX, + StructuredName.GIVEN_NAME, + StructuredName.MIDDLE_NAME, + StructuredName.FAMILY_NAME, + StructuredName.SUFFIX + }; + + /** + * Converts the given structured name (provided as a map from {@link StructuredName} fields to + * corresponding values) into a display name string. + * + * <p>Note that this operates via a call back to the ContactProvider, but it does not access the + * database, so it should be safe to call from the UI thread. See ContactsProvider2.completeName() + * for the underlying method call. + * + * @param context Activity context. + * @param structuredName The structured name map to convert. + * @return The display name computed from the structured name map. + */ + public static String structuredNameToDisplayName( + Context context, Map<String, String> structuredName) { + Builder builder = ContactsContract.AUTHORITY_URI.buildUpon().appendPath("complete_name"); + for (String key : STRUCTURED_NAME_FIELDS) { + if (structuredName.containsKey(key)) { + appendQueryParameter(builder, key, structuredName.get(key)); + } + } + return fetchDisplayName(context, builder.build()); + } + + /** + * Converts the given structured name (provided as ContentValues) into a display name string. + * + * @param context Activity context. + * @param values The content values containing values comprising the structured name. + */ + public static String structuredNameToDisplayName(Context context, ContentValues values) { + Builder builder = ContactsContract.AUTHORITY_URI.buildUpon().appendPath("complete_name"); + for (String key : STRUCTURED_NAME_FIELDS) { + if (values.containsKey(key)) { + appendQueryParameter(builder, key, values.getAsString(key)); + } + } + return fetchDisplayName(context, builder.build()); + } + + /** Helper method for fetching the display name via the given URI. */ + private static String fetchDisplayName(Context context, Uri uri) { + String displayName = null; + Cursor cursor = + context + .getContentResolver() + .query( + uri, + new String[] { + StructuredName.DISPLAY_NAME, + }, + null, + null, + null); + + if (cursor != null) { + try { + if (cursor.moveToFirst()) { + displayName = cursor.getString(0); + } + } finally { + cursor.close(); + } + } + return displayName; + } + + /** + * Converts the given display name string into a structured name (as a map from {@link + * StructuredName} fields to corresponding values). + * + * <p>Note that this operates via a call back to the ContactProvider, but it does not access the + * database, so it should be safe to call from the UI thread. + * + * @param context Activity context. + * @param displayName The display name to convert. + * @return The structured name map computed from the display name. + */ + public static Map<String, String> displayNameToStructuredName( + Context context, String displayName) { + Map<String, String> structuredName = new TreeMap<String, String>(); + Builder builder = ContactsContract.AUTHORITY_URI.buildUpon().appendPath("complete_name"); + + appendQueryParameter(builder, StructuredName.DISPLAY_NAME, displayName); + Cursor cursor = + context + .getContentResolver() + .query(builder.build(), STRUCTURED_NAME_FIELDS, null, null, null); + + if (cursor != null) { + try { + if (cursor.moveToFirst()) { + for (int i = 0; i < STRUCTURED_NAME_FIELDS.length; i++) { + structuredName.put(STRUCTURED_NAME_FIELDS[i], cursor.getString(i)); + } + } + } finally { + cursor.close(); + } + } + return structuredName; + } + + /** + * Converts the given display name string into a structured name (inserting the structured values + * into a new or existing ContentValues object). + * + * <p>Note that this operates via a call back to the ContactProvider, but it does not access the + * database, so it should be safe to call from the UI thread. + * + * @param context Activity context. + * @param displayName The display name to convert. + * @param contentValues The content values object to place the structured name values into. If + * null, a new one will be created and returned. + * @return The ContentValues object containing the structured name fields derived from the display + * name. + */ + public static ContentValues displayNameToStructuredName( + Context context, String displayName, ContentValues contentValues) { + if (contentValues == null) { + contentValues = new ContentValues(); + } + Map<String, String> mapValues = displayNameToStructuredName(context, displayName); + for (String key : mapValues.keySet()) { + contentValues.put(key, mapValues.get(key)); + } + return contentValues; + } + + private static void appendQueryParameter(Builder builder, String field, String value) { + if (!TextUtils.isEmpty(value)) { + builder.appendQueryParameter(field, value); + } + } + + /** + * Parses phonetic name and returns parsed data (family, middle, given) as ContentValues. Parsed + * data should be {@link StructuredName#PHONETIC_FAMILY_NAME}, {@link + * StructuredName#PHONETIC_MIDDLE_NAME}, and {@link StructuredName#PHONETIC_GIVEN_NAME}. If this + * method cannot parse given phoneticName, null values will be stored. + * + * @param phoneticName Phonetic name to be parsed + * @param values ContentValues to be used for storing data. If null, new instance will be created. + * @return ContentValues with parsed data. Those data can be null. + */ + public static StructuredNameDataItem parsePhoneticName( + String phoneticName, StructuredNameDataItem item) { + String family = null; + String middle = null; + String given = null; + + if (!TextUtils.isEmpty(phoneticName)) { + String[] strings = phoneticName.split(" ", 3); + switch (strings.length) { + case 1: + family = strings[0]; + break; + case 2: + family = strings[0]; + given = strings[1]; + break; + case 3: + family = strings[0]; + middle = strings[1]; + given = strings[2]; + break; + } + } + + if (item == null) { + item = new StructuredNameDataItem(); + } + item.setPhoneticFamilyName(family); + item.setPhoneticMiddleName(middle); + item.setPhoneticGivenName(given); + return item; + } + + /** Constructs and returns a phonetic full name from given parts. */ + public static String buildPhoneticName(String family, String middle, String given) { + if (!TextUtils.isEmpty(family) || !TextUtils.isEmpty(middle) || !TextUtils.isEmpty(given)) { + StringBuilder sb = new StringBuilder(); + if (!TextUtils.isEmpty(family)) { + sb.append(family.trim()).append(' '); + } + if (!TextUtils.isEmpty(middle)) { + sb.append(middle.trim()).append(' '); + } + if (!TextUtils.isEmpty(given)) { + sb.append(given.trim()).append(' '); + } + sb.setLength(sb.length() - 1); // Yank the last space + return sb.toString(); + } else { + return null; + } + } +} diff --git a/java/com/android/contacts/common/util/SearchUtil.java b/java/com/android/contacts/common/util/SearchUtil.java new file mode 100644 index 000000000..314d565b2 --- /dev/null +++ b/java/com/android/contacts/common/util/SearchUtil.java @@ -0,0 +1,198 @@ +/* + * Copyright (C) 2012 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.common.util; + +import android.support.annotation.VisibleForTesting; + +/** Methods related to search. */ +public class SearchUtil { + + /** + * Given a string with lines delimited with '\n', finds the matching line to the given substring. + * + * @param contents The string to search. + * @param substring The substring to search for. + * @return A MatchedLine object containing the matching line and the startIndex of the substring + * match within that line. + */ + public static MatchedLine findMatchingLine(String contents, String substring) { + final MatchedLine matched = new MatchedLine(); + + // Snippet may contain multiple lines separated by "\n". + // Locate the lines of the content that contain the substring. + final int index = SearchUtil.contains(contents, substring); + if (index != -1) { + // Match found. Find the corresponding line. + int start = index - 1; + while (start > -1) { + if (contents.charAt(start) == '\n') { + break; + } + start--; + } + int end = index + 1; + while (end < contents.length()) { + if (contents.charAt(end) == '\n') { + break; + } + end++; + } + matched.line = contents.substring(start + 1, end); + matched.startIndex = index - (start + 1); + } + return matched; + } + + /** + * Similar to String.contains() with two main differences: + * + * <p>1) Only searches token prefixes. A token is defined as any combination of letters or + * numbers. + * + * <p>2) Returns the starting index where the substring is found. + * + * @param value The string to search. + * @param substring The substring to look for. + * @return The starting index where the substring is found. {@literal -1} if substring is not + * found in value. + */ + @VisibleForTesting + static int contains(String value, String substring) { + if (value.length() < substring.length()) { + return -1; + } + + // i18n support + // Generate the code points for the substring once. + // There will be a maximum of substring.length code points. But may be fewer. + // Since the array length is not an accurate size, we need to keep a separate variable. + final int[] substringCodePoints = new int[substring.length()]; + int substringLength = 0; // may not equal substring.length()!! + for (int i = 0; i < substring.length(); ) { + final int codePoint = Character.codePointAt(substring, i); + substringCodePoints[substringLength] = codePoint; + substringLength++; + i += Character.charCount(codePoint); + } + + for (int i = 0; i < value.length(); i = findNextTokenStart(value, i)) { + int numMatch = 0; + for (int j = i; j < value.length() && numMatch < substringLength; ++numMatch) { + int valueCp = Character.toLowerCase(value.codePointAt(j)); + int substringCp = substringCodePoints[numMatch]; + if (valueCp != substringCp) { + break; + } + j += Character.charCount(valueCp); + } + if (numMatch == substringLength) { + return i; + } + } + return -1; + } + + /** + * Find the start of the next token. A token is composed of letters and numbers. Any other + * character are considered delimiters. + * + * @param line The string to search for the next token. + * @param startIndex The index to start searching. 0 based indexing. + * @return The index for the start of the next token. line.length() if next token not found. + */ + @VisibleForTesting + static int findNextTokenStart(String line, int startIndex) { + int index = startIndex; + + // If already in token, eat remainder of token. + while (index <= line.length()) { + if (index == line.length()) { + // No more tokens. + return index; + } + final int codePoint = line.codePointAt(index); + if (!Character.isLetterOrDigit(codePoint)) { + break; + } + index += Character.charCount(codePoint); + } + + // Out of token, eat all consecutive delimiters. + while (index <= line.length()) { + if (index == line.length()) { + return index; + } + final int codePoint = line.codePointAt(index); + if (Character.isLetterOrDigit(codePoint)) { + break; + } + index += Character.charCount(codePoint); + } + + return index; + } + + /** + * Anything other than letter and numbers are considered delimiters. Remove start and end + * delimiters since they are not relevant to search. + * + * @param query The query string to clean. + * @return The cleaned query. Empty string if all characters are cleaned out. + */ + public static String cleanStartAndEndOfSearchQuery(String query) { + int start = 0; + while (start < query.length()) { + int codePoint = query.codePointAt(start); + if (Character.isLetterOrDigit(codePoint)) { + break; + } + start += Character.charCount(codePoint); + } + + if (start == query.length()) { + // All characters are delimiters. + return ""; + } + + int end = query.length() - 1; + while (end > -1) { + if (Character.isLowSurrogate(query.charAt(end))) { + // Assume valid i18n string. There should be a matching high surrogate before it. + end--; + } + int codePoint = query.codePointAt(end); + if (Character.isLetterOrDigit(codePoint)) { + break; + } + end--; + } + + // end is a letter or digit. + return query.substring(start, end + 1); + } + + public static class MatchedLine { + + public int startIndex = -1; + public String line; + + @Override + public String toString() { + return "MatchedLine{" + "line='" + line + '\'' + ", startIndex=" + startIndex + '}'; + } + } +} diff --git a/java/com/android/contacts/common/util/StopWatch.java b/java/com/android/contacts/common/util/StopWatch.java new file mode 100644 index 000000000..b944b9867 --- /dev/null +++ b/java/com/android/contacts/common/util/StopWatch.java @@ -0,0 +1,100 @@ +/* + * Copyright (C) 2012 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.common.util; + +import android.util.Log; +import java.util.ArrayList; + +/** A {@link StopWatch} records start, laps and stop, and print them to logcat. */ +public class StopWatch { + + private final String mLabel; + + private final ArrayList<Long> mTimes = new ArrayList<>(); + private final ArrayList<String> mLapLabels = new ArrayList<>(); + + private StopWatch(String label) { + mLabel = label; + lap(""); + } + + /** Create a new instance and start it. */ + public static StopWatch start(String label) { + return new StopWatch(label); + } + + /** Return a dummy instance that does no operations. */ + public static StopWatch getNullStopWatch() { + return NullStopWatch.INSTANCE; + } + + /** Record a lap. */ + public void lap(String lapLabel) { + mTimes.add(System.currentTimeMillis()); + mLapLabels.add(lapLabel); + } + + /** Stop it and log the result, if the total time >= {@code timeThresholdToLog}. */ + public void stopAndLog(String TAG, int timeThresholdToLog) { + + lap(""); + + final long start = mTimes.get(0); + final long stop = mTimes.get(mTimes.size() - 1); + + final long total = stop - start; + if (total < timeThresholdToLog) { + return; + } + + final StringBuilder sb = new StringBuilder(); + sb.append(mLabel); + sb.append(","); + sb.append(total); + sb.append(": "); + + long last = start; + for (int i = 1; i < mTimes.size(); i++) { + final long current = mTimes.get(i); + sb.append(mLapLabels.get(i)); + sb.append(","); + sb.append((current - last)); + sb.append(" "); + last = current; + } + Log.v(TAG, sb.toString()); + } + + private static class NullStopWatch extends StopWatch { + + public static final NullStopWatch INSTANCE = new NullStopWatch(); + + public NullStopWatch() { + super(null); + } + + @Override + public void lap(String lapLabel) { + // noop + } + + @Override + public void stopAndLog(String TAG, int timeThresholdToLog) { + // noop + } + } +} diff --git a/java/com/android/contacts/common/util/TelephonyManagerUtils.java b/java/com/android/contacts/common/util/TelephonyManagerUtils.java new file mode 100644 index 000000000..b664268ca --- /dev/null +++ b/java/com/android/contacts/common/util/TelephonyManagerUtils.java @@ -0,0 +1,45 @@ +/* + * Copyright (C) 2013 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.common.util; + +import android.content.Context; +import android.telephony.TelephonyManager; + +/** This class provides several TelephonyManager util functions. */ +public class TelephonyManagerUtils { + + /** + * Gets the voicemail tag from Telephony Manager. + * + * @param context Current application context + * @return Voicemail tag, the alphabetic identifier associated with the voice mail number. + */ + public static String getVoiceMailAlphaTag(Context context) { + final TelephonyManager telephonyManager = + (TelephonyManager) context.getSystemService(Context.TELEPHONY_SERVICE); + final String voiceMailLabel = telephonyManager.getVoiceMailAlphaTag(); + return voiceMailLabel; + } + + /** + * @param context Current application context. + * @return True if there is a subscription which supports video calls. False otherwise. + */ + public static boolean hasVideoCallSubscription(Context context) { + // TODO: Check the telephony manager's subscriptions to see if any support video calls. + return true; + } +} diff --git a/java/com/android/contacts/common/util/TrafficStatsTags.java b/java/com/android/contacts/common/util/TrafficStatsTags.java new file mode 100644 index 000000000..b0e7fb583 --- /dev/null +++ b/java/com/android/contacts/common/util/TrafficStatsTags.java @@ -0,0 +1,22 @@ +/* + * Copyright (C) 2015 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.common.util; + +public class TrafficStatsTags { + + public static final int CONTACT_PHOTO_DOWNLOAD_TAG = 0x0001; + public static final int TAG_MAX = 0x9999; +} diff --git a/java/com/android/contacts/common/util/UriUtils.java b/java/com/android/contacts/common/util/UriUtils.java new file mode 100644 index 000000000..4690942ba --- /dev/null +++ b/java/com/android/contacts/common/util/UriUtils.java @@ -0,0 +1,90 @@ +/* + * Copyright (C) 2011 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.common.util; + +import android.net.Uri; +import android.provider.ContactsContract; +import java.util.List; + +/** Utility methods for dealing with URIs. */ +public class UriUtils { + + /** Static helper, not instantiable. */ + private UriUtils() {} + + /** Checks whether two URI are equal, taking care of the case where either is null. */ + public static boolean areEqual(Uri uri1, Uri uri2) { + if (uri1 == null && uri2 == null) { + return true; + } + if (uri1 == null || uri2 == null) { + return false; + } + return uri1.equals(uri2); + } + + /** Parses a string into a URI and returns null if the given string is null. */ + public static Uri parseUriOrNull(String uriString) { + if (uriString == null) { + return null; + } + return Uri.parse(uriString); + } + + /** Converts a URI into a string, returns null if the given URI is null. */ + public static String uriToString(Uri uri) { + return uri == null ? null : uri.toString(); + } + + public static boolean isEncodedContactUri(Uri uri) { + if (uri == null) { + return false; + } + final String lastPathSegment = uri.getLastPathSegment(); + if (lastPathSegment == null) { + return false; + } + return lastPathSegment.equals(Constants.LOOKUP_URI_ENCODED); + } + + /** + * @return {@code uri} as-is if the authority is of contacts provider. Otherwise or {@code uri} is + * null, return null otherwise + */ + public static Uri nullForNonContactsUri(Uri uri) { + if (uri == null) { + return null; + } + return ContactsContract.AUTHORITY.equals(uri.getAuthority()) ? uri : null; + } + + /** Parses the given URI to determine the original lookup key of the contact. */ + public static String getLookupKeyFromUri(Uri lookupUri) { + // Would be nice to be able to persist the lookup key somehow to avoid having to parse + // the uri entirely just to retrieve the lookup key, but every uri is already parsed + // once anyway to check if it is an encoded JSON uri, so this has negligible effect + // on performance. + if (lookupUri != null && !UriUtils.isEncodedContactUri(lookupUri)) { + final List<String> segments = lookupUri.getPathSegments(); + // This returns the third path segment of the uri, where the lookup key is located. + // See {@link android.provider.ContactsContract.Contacts#CONTENT_LOOKUP_URI}. + return (segments.size() < 3) ? null : Uri.encode(segments.get(2)); + } else { + return null; + } + } +} diff --git a/java/com/android/contacts/common/widget/ActivityTouchLinearLayout.java b/java/com/android/contacts/common/widget/ActivityTouchLinearLayout.java new file mode 100644 index 000000000..2988a5a58 --- /dev/null +++ b/java/com/android/contacts/common/widget/ActivityTouchLinearLayout.java @@ -0,0 +1,43 @@ +/* + * Copyright (C) 2014 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.common.widget; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.MotionEvent; +import android.widget.LinearLayout; +import com.android.dialer.util.TouchPointManager; + +/** + * Linear layout for an activity that listens to all touch events on the screen and saves the touch + * point. Typically touch events are handled by child views--this class intercepts those touch + * events before passing them on to the child. + */ +public class ActivityTouchLinearLayout extends LinearLayout { + + public ActivityTouchLinearLayout(Context context, AttributeSet attrs) { + super(context, attrs); + } + + @Override + public boolean onInterceptTouchEvent(MotionEvent ev) { + if (ev.getAction() == MotionEvent.ACTION_DOWN) { + TouchPointManager.getInstance().setPoint((int) ev.getRawX(), (int) ev.getRawY()); + } + return false; + } +} diff --git a/java/com/android/contacts/common/widget/FloatingActionButtonController.java b/java/com/android/contacts/common/widget/FloatingActionButtonController.java new file mode 100644 index 000000000..f03129779 --- /dev/null +++ b/java/com/android/contacts/common/widget/FloatingActionButtonController.java @@ -0,0 +1,226 @@ +/* + * Copyright (C) 2014 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.common.widget; + +import android.app.Activity; +import android.content.res.Resources; +import android.graphics.drawable.Drawable; +import android.view.View; +import android.view.animation.AnimationUtils; +import android.view.animation.Interpolator; +import android.widget.ImageButton; +import com.android.contacts.common.R; +import com.android.contacts.common.util.FabUtil; +import com.android.dialer.animation.AnimUtils; + +/** Controls the movement and appearance of the FAB (Floating Action Button). */ +public class FloatingActionButtonController { + + public static final int ALIGN_MIDDLE = 0; + public static final int ALIGN_QUARTER_END = 1; + public static final int ALIGN_END = 2; + + private static final int FAB_SCALE_IN_DURATION = 266; + private static final int FAB_SCALE_IN_FADE_IN_DELAY = 100; + private static final int FAB_ICON_FADE_OUT_DURATION = 66; + + private final int mAnimationDuration; + private final int mFloatingActionButtonWidth; + private final int mFloatingActionButtonMarginRight; + private final View mFloatingActionButtonContainer; + private final ImageButton mFloatingActionButton; + private final Interpolator mFabInterpolator; + private int mScreenWidth; + + public FloatingActionButtonController(Activity activity, View container, ImageButton button) { + Resources resources = activity.getResources(); + mFabInterpolator = + AnimationUtils.loadInterpolator(activity, android.R.interpolator.fast_out_slow_in); + mFloatingActionButtonWidth = + resources.getDimensionPixelSize(R.dimen.floating_action_button_width); + mFloatingActionButtonMarginRight = + resources.getDimensionPixelOffset(R.dimen.floating_action_button_margin_right); + mAnimationDuration = resources.getInteger(R.integer.floating_action_button_animation_duration); + mFloatingActionButtonContainer = container; + mFloatingActionButton = button; + FabUtil.setupFloatingActionButton(mFloatingActionButtonContainer, resources); + } + + /** + * Passes the screen width into the class. Necessary for translation calculations. Should be + * called as soon as parent View width is available. + * + * @param screenWidth The width of the screen in pixels. + */ + public void setScreenWidth(int screenWidth) { + mScreenWidth = screenWidth; + } + + public boolean isVisible() { + return mFloatingActionButtonContainer.getVisibility() == View.VISIBLE; + } + + /** + * Sets FAB as View.VISIBLE or View.GONE. + * + * @param visible Whether or not to make the container visible. + */ + public void setVisible(boolean visible) { + mFloatingActionButtonContainer.setVisibility(visible ? View.VISIBLE : View.GONE); + } + + public void changeIcon(Drawable icon, String description) { + if (mFloatingActionButton.getDrawable() != icon + || !mFloatingActionButton.getContentDescription().equals(description)) { + mFloatingActionButton.setImageDrawable(icon); + mFloatingActionButton.setContentDescription(description); + } + } + + /** + * Updates the FAB location (middle to right position) as the PageView scrolls. + * + * @param positionOffset A fraction used to calculate position of the FAB during page scroll. + */ + public void onPageScrolled(float positionOffset) { + // As the page is scrolling, if we're on the first tab, update the FAB position so it + // moves along with it. + mFloatingActionButtonContainer.setTranslationX( + (int) (positionOffset * getTranslationXForAlignment(ALIGN_END))); + } + + /** + * Aligns the FAB to the described location + * + * @param align One of ALIGN_MIDDLE, ALIGN_QUARTER_RIGHT, or ALIGN_RIGHT. + * @param animate Whether or not to animate the transition. + */ + public void align(int align, boolean animate) { + align(align, 0 /*offsetX */, 0 /* offsetY */, animate); + } + + /** + * Aligns the FAB to the described location plus specified additional offsets. + * + * @param align One of ALIGN_MIDDLE, ALIGN_QUARTER_RIGHT, or ALIGN_RIGHT. + * @param offsetX Additional offsetX to translate by. + * @param offsetY Additional offsetY to translate by. + * @param animate Whether or not to animate the transition. + */ + public void align(int align, int offsetX, int offsetY, boolean animate) { + if (mScreenWidth == 0) { + return; + } + + int translationX = getTranslationXForAlignment(align); + + // Skip animation if container is not shown; animation causes container to show again. + if (animate && mFloatingActionButtonContainer.isShown()) { + mFloatingActionButtonContainer + .animate() + .translationX(translationX + offsetX) + .translationY(offsetY) + .setInterpolator(mFabInterpolator) + .setDuration(mAnimationDuration) + .start(); + } else { + mFloatingActionButtonContainer.setTranslationX(translationX + offsetX); + mFloatingActionButtonContainer.setTranslationY(offsetY); + } + } + + /** + * Resizes width and height of the floating action bar container. + * + * @param dimension The new dimensions for the width and height. + * @param animate Whether to animate this change. + */ + public void resize(int dimension, boolean animate) { + if (animate) { + AnimUtils.changeDimensions(mFloatingActionButtonContainer, dimension, dimension); + } else { + mFloatingActionButtonContainer.getLayoutParams().width = dimension; + mFloatingActionButtonContainer.getLayoutParams().height = dimension; + mFloatingActionButtonContainer.requestLayout(); + } + } + + /** + * Scales the floating action button from no height and width to its actual dimensions. This is an + * animation for showing the floating action button. + * + * @param delayMs The delay for the effect, in milliseconds. + */ + public void scaleIn(int delayMs) { + setVisible(true); + AnimUtils.scaleIn(mFloatingActionButtonContainer, FAB_SCALE_IN_DURATION, delayMs); + AnimUtils.fadeIn( + mFloatingActionButton, FAB_SCALE_IN_DURATION, delayMs + FAB_SCALE_IN_FADE_IN_DELAY, null); + } + + /** Immediately remove the affects of the last call to {@link #scaleOut}. */ + public void resetIn() { + mFloatingActionButton.setAlpha(1f); + mFloatingActionButton.setVisibility(View.VISIBLE); + mFloatingActionButtonContainer.setScaleX(1); + mFloatingActionButtonContainer.setScaleY(1); + } + + /** + * Scales the floating action button from its actual dimensions to no height and width. This is an + * animation for hiding the floating action button. + */ + public void scaleOut() { + AnimUtils.scaleOut(mFloatingActionButtonContainer, mAnimationDuration); + // Fade out the icon faster than the scale out animation, so that the icon scaling is less + // obvious. We don't want it to scale, but the resizing the container is not as performant. + AnimUtils.fadeOut(mFloatingActionButton, FAB_ICON_FADE_OUT_DURATION, null); + } + + /** + * Calculates the X offset of the FAB to the given alignment, adjusted for whether or not the view + * is in RTL mode. + * + * @param align One of ALIGN_MIDDLE, ALIGN_QUARTER_RIGHT, or ALIGN_RIGHT. + * @return The translationX for the given alignment. + */ + public int getTranslationXForAlignment(int align) { + int result = 0; + switch (align) { + case ALIGN_MIDDLE: + // Moves the FAB to exactly center screen. + return 0; + case ALIGN_QUARTER_END: + // Moves the FAB a quarter of the screen width. + result = mScreenWidth / 4; + break; + case ALIGN_END: + // Moves the FAB half the screen width. Same as aligning right with a marginRight. + result = + mScreenWidth / 2 - mFloatingActionButtonWidth / 2 - mFloatingActionButtonMarginRight; + break; + } + if (isLayoutRtl()) { + result *= -1; + } + return result; + } + + private boolean isLayoutRtl() { + return mFloatingActionButtonContainer.getLayoutDirection() == View.LAYOUT_DIRECTION_RTL; + } +} diff --git a/java/com/android/contacts/common/widget/LayoutSuppressingImageView.java b/java/com/android/contacts/common/widget/LayoutSuppressingImageView.java new file mode 100644 index 000000000..d84d8f757 --- /dev/null +++ b/java/com/android/contacts/common/widget/LayoutSuppressingImageView.java @@ -0,0 +1,39 @@ +/* + * Copyright (C) 2012 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.common.widget; + +import android.content.Context; +import android.util.AttributeSet; +import android.widget.ImageView; + +/** + * Custom {@link ImageView} that improves layouting performance. + * + * <p>This improves the performance by not passing requestLayout() to its parent, taking advantage + * of knowing that image size won't change once set. + */ +public class LayoutSuppressingImageView extends ImageView { + + public LayoutSuppressingImageView(Context context, AttributeSet attrs) { + super(context, attrs); + } + + @Override + public void requestLayout() { + forceLayout(); + } +} diff --git a/java/com/android/contacts/common/widget/SelectPhoneAccountDialogFragment.java b/java/com/android/contacts/common/widget/SelectPhoneAccountDialogFragment.java new file mode 100644 index 000000000..63f8ca580 --- /dev/null +++ b/java/com/android/contacts/common/widget/SelectPhoneAccountDialogFragment.java @@ -0,0 +1,297 @@ +/* + * Copyright (C) 2014 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.common.widget; + +import android.annotation.SuppressLint; +import android.app.AlertDialog; +import android.app.Dialog; +import android.app.DialogFragment; +import android.content.Context; +import android.content.DialogInterface; +import android.os.Bundle; +import android.os.Handler; +import android.os.ResultReceiver; +import android.support.annotation.Nullable; +import android.telecom.PhoneAccount; +import android.telecom.PhoneAccountHandle; +import android.telecom.TelecomManager; +import android.text.TextUtils; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ArrayAdapter; +import android.widget.CheckBox; +import android.widget.CompoundButton; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.ListAdapter; +import android.widget.TextView; +import com.android.contacts.common.R; +import com.android.contacts.common.compat.PhoneAccountCompat; +import com.android.contacts.common.compat.PhoneNumberUtilsCompat; +import java.util.ArrayList; +import java.util.List; + +/** + * Dialog that allows the user to select a phone accounts for a given action. Optionally provides + * the choice to set the phone account as default. + */ +public class SelectPhoneAccountDialogFragment extends DialogFragment { + + private static final String ARG_TITLE_RES_ID = "title_res_id"; + private static final String ARG_CAN_SET_DEFAULT = "can_set_default"; + private static final String ARG_ACCOUNT_HANDLES = "account_handles"; + private static final String ARG_IS_DEFAULT_CHECKED = "is_default_checked"; + private static final String ARG_LISTENER = "listener"; + private static final String ARG_CALL_ID = "call_id"; + + private int mTitleResId; + private boolean mCanSetDefault; + private List<PhoneAccountHandle> mAccountHandles; + private boolean mIsSelected; + private boolean mIsDefaultChecked; + private SelectPhoneAccountListener mListener; + + public SelectPhoneAccountDialogFragment() {} + + /** + * Create new fragment instance with default title and no option to set as default. + * + * @param accountHandles The {@code PhoneAccountHandle}s available to select from. + * @param listener The listener for the results of the account selection. + */ + public static SelectPhoneAccountDialogFragment newInstance( + List<PhoneAccountHandle> accountHandles, + SelectPhoneAccountListener listener, + @Nullable String callId) { + return newInstance( + R.string.select_account_dialog_title, false, accountHandles, listener, callId); + } + + /** + * Create new fragment instance. This method also allows specifying a custom title and "set + * default" checkbox. + * + * @param titleResId The resource ID for the string to use in the title of the dialog. + * @param canSetDefault {@code true} if the dialog should include an option to set the selection + * as the default. False otherwise. + * @param accountHandles The {@code PhoneAccountHandle}s available to select from. + * @param listener The listener for the results of the account selection. + */ + public static SelectPhoneAccountDialogFragment newInstance( + int titleResId, + boolean canSetDefault, + List<PhoneAccountHandle> accountHandles, + SelectPhoneAccountListener listener, + @Nullable String callId) { + ArrayList<PhoneAccountHandle> accountHandlesCopy = new ArrayList<>(); + if (accountHandles != null) { + accountHandlesCopy.addAll(accountHandles); + } + SelectPhoneAccountDialogFragment fragment = new SelectPhoneAccountDialogFragment(); + final Bundle args = new Bundle(); + args.putInt(ARG_TITLE_RES_ID, titleResId); + args.putBoolean(ARG_CAN_SET_DEFAULT, canSetDefault); + args.putParcelableArrayList(ARG_ACCOUNT_HANDLES, accountHandlesCopy); + args.putParcelable(ARG_LISTENER, listener); + args.putString(ARG_CALL_ID, callId); + fragment.setArguments(args); + fragment.setListener(listener); + return fragment; + } + + public void setListener(SelectPhoneAccountListener listener) { + mListener = listener; + } + + @Override + public void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); + outState.putBoolean(ARG_IS_DEFAULT_CHECKED, mIsDefaultChecked); + } + + @Override + public Dialog onCreateDialog(Bundle savedInstanceState) { + mTitleResId = getArguments().getInt(ARG_TITLE_RES_ID); + mCanSetDefault = getArguments().getBoolean(ARG_CAN_SET_DEFAULT); + mAccountHandles = getArguments().getParcelableArrayList(ARG_ACCOUNT_HANDLES); + mListener = getArguments().getParcelable(ARG_LISTENER); + if (savedInstanceState != null) { + mIsDefaultChecked = savedInstanceState.getBoolean(ARG_IS_DEFAULT_CHECKED); + } + mIsSelected = false; + + final DialogInterface.OnClickListener selectionListener = + new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + mIsSelected = true; + PhoneAccountHandle selectedAccountHandle = mAccountHandles.get(which); + Bundle result = new Bundle(); + result.putParcelable( + SelectPhoneAccountListener.EXTRA_SELECTED_ACCOUNT_HANDLE, selectedAccountHandle); + result.putBoolean(SelectPhoneAccountListener.EXTRA_SET_DEFAULT, mIsDefaultChecked); + result.putString(SelectPhoneAccountListener.EXTRA_CALL_ID, getCallId()); + if (mListener != null) { + mListener.onReceiveResult(SelectPhoneAccountListener.RESULT_SELECTED, result); + } + } + }; + + final CompoundButton.OnCheckedChangeListener checkListener = + new CompoundButton.OnCheckedChangeListener() { + @Override + public void onCheckedChanged(CompoundButton check, boolean isChecked) { + mIsDefaultChecked = isChecked; + } + }; + + AlertDialog.Builder builder = new AlertDialog.Builder(getActivity()); + ListAdapter selectAccountListAdapter = + new SelectAccountListAdapter( + builder.getContext(), R.layout.select_account_list_item, mAccountHandles); + + AlertDialog dialog = + builder + .setTitle(mTitleResId) + .setAdapter(selectAccountListAdapter, selectionListener) + .create(); + + if (mCanSetDefault) { + // Generate custom checkbox view, lint suppressed since no appropriate parent (is dialog) + @SuppressLint("InflateParams") + LinearLayout checkboxLayout = + (LinearLayout) + LayoutInflater.from(builder.getContext()) + .inflate(R.layout.default_account_checkbox, null); + + CheckBox cb = (CheckBox) checkboxLayout.findViewById(R.id.default_account_checkbox_view); + cb.setOnCheckedChangeListener(checkListener); + cb.setChecked(mIsDefaultChecked); + + dialog.getListView().addFooterView(checkboxLayout); + } + + return dialog; + } + + @Override + public void onStop() { + if (!mIsSelected && mListener != null) { + Bundle result = new Bundle(); + result.putString(SelectPhoneAccountListener.EXTRA_CALL_ID, getCallId()); + mListener.onReceiveResult(SelectPhoneAccountListener.RESULT_DISMISSED, result); + } + super.onStop(); + } + + @Nullable + private String getCallId() { + return getArguments().getString(ARG_CALL_ID); + } + + public static class SelectPhoneAccountListener extends ResultReceiver { + + static final int RESULT_SELECTED = 1; + static final int RESULT_DISMISSED = 2; + + static final String EXTRA_SELECTED_ACCOUNT_HANDLE = "extra_selected_account_handle"; + static final String EXTRA_SET_DEFAULT = "extra_set_default"; + static final String EXTRA_CALL_ID = "extra_call_id"; + + public SelectPhoneAccountListener() { + super(new Handler()); + } + + @Override + protected void onReceiveResult(int resultCode, Bundle resultData) { + if (resultCode == RESULT_SELECTED) { + onPhoneAccountSelected( + resultData.getParcelable(EXTRA_SELECTED_ACCOUNT_HANDLE), + resultData.getBoolean(EXTRA_SET_DEFAULT), + resultData.getString(EXTRA_CALL_ID)); + } else if (resultCode == RESULT_DISMISSED) { + onDialogDismissed(resultData.getString(EXTRA_CALL_ID)); + } + } + + public void onPhoneAccountSelected( + PhoneAccountHandle selectedAccountHandle, boolean setDefault, @Nullable String callId) {} + + public void onDialogDismissed(@Nullable String callId) {} + } + + private static class SelectAccountListAdapter extends ArrayAdapter<PhoneAccountHandle> { + + private int mResId; + + public SelectAccountListAdapter( + Context context, int resource, List<PhoneAccountHandle> accountHandles) { + super(context, resource, accountHandles); + mResId = resource; + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + LayoutInflater inflater = + (LayoutInflater) getContext().getSystemService(Context.LAYOUT_INFLATER_SERVICE); + + View rowView; + final ViewHolder holder; + + if (convertView == null) { + // Cache views for faster scrolling + rowView = inflater.inflate(mResId, null); + holder = new ViewHolder(); + holder.labelTextView = (TextView) rowView.findViewById(R.id.label); + holder.numberTextView = (TextView) rowView.findViewById(R.id.number); + holder.imageView = (ImageView) rowView.findViewById(R.id.icon); + rowView.setTag(holder); + } else { + rowView = convertView; + holder = (ViewHolder) rowView.getTag(); + } + + PhoneAccountHandle accountHandle = getItem(position); + PhoneAccount account = + getContext().getSystemService(TelecomManager.class).getPhoneAccount(accountHandle); + if (account == null) { + return rowView; + } + holder.labelTextView.setText(account.getLabel()); + if (account.getAddress() == null + || TextUtils.isEmpty(account.getAddress().getSchemeSpecificPart())) { + holder.numberTextView.setVisibility(View.GONE); + } else { + holder.numberTextView.setVisibility(View.VISIBLE); + holder.numberTextView.setText( + PhoneNumberUtilsCompat.createTtsSpannable( + account.getAddress().getSchemeSpecificPart())); + } + holder.imageView.setImageDrawable( + PhoneAccountCompat.createIconDrawable(account, getContext())); + return rowView; + } + + private static final class ViewHolder { + + TextView labelTextView; + TextView numberTextView; + ImageView imageView; + } + } +} |