/* * 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.vcard; import android.content.ContentResolver; import android.content.ContentValues; import android.content.Context; import android.content.Entity; import android.content.Entity.NamedContentValues; import android.content.EntityIterator; import android.database.Cursor; import android.database.sqlite.SQLiteException; import android.net.Uri; 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.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; import android.provider.ContactsContract.Data; import android.provider.ContactsContract.RawContacts; import android.provider.ContactsContract.RawContactsEntity; import android.provider.ContactsContract; import android.text.TextUtils; import android.util.Log; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; /** *

* The class for composing vCard from Contacts information. *

*

* Usually, this class should be used like this. *

*
VCardComposer composer = null;
 * try {
 *     composer = new VCardComposer(context);
 *     composer.addHandler(
 *             composer.new HandlerForOutputStream(outputStream));
 *     if (!composer.init()) {
 *         // Do something handling the situation.
 *         return;
 *     }
 *     while (!composer.isAfterLast()) {
 *         if (mCanceled) {
 *             // Assume a user may cancel this operation during the export.
 *             return;
 *         }
 *         if (!composer.createOneEntry()) {
 *             // Do something handling the error situation.
 *             return;
 *         }
 *     }
 * } finally {
 *     if (composer != null) {
 *         composer.terminate();
 *     }
 * }
*

* Users have to manually take care of memory efficiency. Even one vCard may contain * image of non-trivial size for mobile devices. *

*

* {@link VCardBuilder} is used to build each vCard. *

*/ public class VCardComposer { private static final String LOG_TAG = "VCardComposer"; private static final boolean DEBUG = false; public static final String FAILURE_REASON_FAILED_TO_GET_DATABASE_INFO = "Failed to get database information"; public static final String FAILURE_REASON_NO_ENTRY = "There's no exportable in the database"; public static final String FAILURE_REASON_NOT_INITIALIZED = "The vCard composer object is not correctly initialized"; /** Should be visible only from developers... (no need to translate, hopefully) */ public static final String FAILURE_REASON_UNSUPPORTED_URI = "The Uri vCard composer received is not supported by the composer."; public static final String NO_ERROR = "No error"; // Strictly speaking, "Shift_JIS" is the most appropriate, but we use upper version here, // since usual vCard devices for Japanese devices already use it. private static final String SHIFT_JIS = "SHIFT_JIS"; private static final String UTF_8 = "UTF-8"; private static final Map sImMap; static { sImMap = new HashMap(); sImMap.put(Im.PROTOCOL_AIM, VCardConstants.PROPERTY_X_AIM); sImMap.put(Im.PROTOCOL_MSN, VCardConstants.PROPERTY_X_MSN); sImMap.put(Im.PROTOCOL_YAHOO, VCardConstants.PROPERTY_X_YAHOO); sImMap.put(Im.PROTOCOL_ICQ, VCardConstants.PROPERTY_X_ICQ); sImMap.put(Im.PROTOCOL_JABBER, VCardConstants.PROPERTY_X_JABBER); sImMap.put(Im.PROTOCOL_SKYPE, VCardConstants.PROPERTY_X_SKYPE_USERNAME); // We don't add Google talk here since it has to be handled separately. } private final int mVCardType; private final ContentResolver mContentResolver; private final boolean mIsDoCoMo; /** * Used only when {@link #mIsDoCoMo} is true. Set to true when the first vCard for DoCoMo * vCard is emitted. */ private boolean mFirstVCardEmittedInDoCoMoCase; private Cursor mCursor; private boolean mCursorSuppliedFromOutside; private int mIdColumn; private Uri mContentUriForRawContactsEntity; private final String mCharset; private boolean mInitDone; private String mErrorReason = NO_ERROR; /** * Set to false when one of {@link #init()} variants is called, and set to true when * {@link #terminate()} is called. Initially set to true. */ private boolean mTerminateCalled = true; private RawContactEntitlesInfoCallback mRawContactEntitlesInfoCallback; private static final String[] sContactsProjection = new String[] { Contacts._ID, }; public VCardComposer(Context context) { this(context, VCardConfig.VCARD_TYPE_DEFAULT, null, true); } /** * The variant which sets charset to null and sets careHandlerErrors to true. */ public VCardComposer(Context context, int vcardType) { this(context, vcardType, null, true); } public VCardComposer(Context context, int vcardType, String charset) { this(context, vcardType, charset, true); } /** * The variant which sets charset to null. */ public VCardComposer(final Context context, final int vcardType, final boolean careHandlerErrors) { this(context, vcardType, null, careHandlerErrors); } /** * Constructs for supporting call log entry vCard composing. * * @param context Context to be used during the composition. * @param vcardType The type of vCard, typically available via {@link VCardConfig}. * @param charset The charset to be used. Use null when you don't need the charset. * @param careHandlerErrors If true, This object returns false everytime */ public VCardComposer(final Context context, final int vcardType, String charset, final boolean careHandlerErrors) { this(context, context.getContentResolver(), vcardType, charset, careHandlerErrors); } /** * Just for testing for now. * @param resolver {@link ContentResolver} which used by this object. * @hide */ public VCardComposer(final Context context, ContentResolver resolver, final int vcardType, String charset, final boolean careHandlerErrors) { // Not used right now // mContext = context; mVCardType = vcardType; mContentResolver = resolver; mIsDoCoMo = VCardConfig.isDoCoMo(vcardType); charset = (TextUtils.isEmpty(charset) ? VCardConfig.DEFAULT_EXPORT_CHARSET : charset); final boolean shouldAppendCharsetParam = !( VCardConfig.isVersion30(vcardType) && UTF_8.equalsIgnoreCase(charset)); if (mIsDoCoMo || shouldAppendCharsetParam) { if (SHIFT_JIS.equalsIgnoreCase(charset)) { mCharset = charset; } else { /* Log.w(LOG_TAG, "The charset \"" + charset + "\" is used while " + SHIFT_JIS + " is needed to be used."); */ if (TextUtils.isEmpty(charset)) { mCharset = SHIFT_JIS; } else { mCharset = charset; } } } else { if (TextUtils.isEmpty(charset)) { mCharset = UTF_8; } else { mCharset = charset; } } Log.d(LOG_TAG, "Use the charset \"" + mCharset + "\""); } /** * Initializes this object using default {@link Contacts#CONTENT_URI}. * * You can call this method or a variant of this method just once. In other words, you cannot * reuse this object. * * @return Returns true when initialization is successful and all the other * methods are available. Returns false otherwise. */ public boolean init() { return init(null, null); } /** * Special variant of init(), which accepts a Uri for obtaining {@link RawContactsEntity} from * {@link ContentResolver} with {@link Contacts#_ID}. * * String selection = Data.CONTACT_ID + "=?"; * String[] selectionArgs = new String[] {contactId}; * Cursor cursor = mContentResolver.query( * contentUriForRawContactsEntity, null, selection, selectionArgs, null) * * * You can call this method or a variant of this method just once. In other words, you cannot * reuse this object. * * @deprecated Use {@link #init(Uri, String[], String, String[], String, Uri)} if you really * need to change the default Uri. */ @Deprecated public boolean initWithRawContactsEntityUri(Uri contentUriForRawContactsEntity) { return init(Contacts.CONTENT_URI, sContactsProjection, null, null, null, contentUriForRawContactsEntity); } /** * Initializes this object using default {@link Contacts#CONTENT_URI} and given selection * arguments. */ public boolean init(final String selection, final String[] selectionArgs) { return init(Contacts.CONTENT_URI, sContactsProjection, selection, selectionArgs, null, null); } /** * Note that this is unstable interface, may be deleted in the future. */ public boolean init(final Uri contentUri, final String selection, final String[] selectionArgs, final String sortOrder) { return init(contentUri, sContactsProjection, selection, selectionArgs, sortOrder, null); } /** * @param contentUri Uri for obtaining the list of contactId. Used with * {@link ContentResolver#query(Uri, String[], String, String[], String)} * @param selection selection used with * {@link ContentResolver#query(Uri, String[], String, String[], String)} * @param selectionArgs selectionArgs used with * {@link ContentResolver#query(Uri, String[], String, String[], String)} * @param sortOrder sortOrder used with * {@link ContentResolver#query(Uri, String[], String, String[], String)} * @param contentUriForRawContactsEntity Uri for obtaining entries relevant to each * contactId. * Note that this is an unstable interface, may be deleted in the future. */ public boolean init(final Uri contentUri, final String selection, final String[] selectionArgs, final String sortOrder, final Uri contentUriForRawContactsEntity) { return init(contentUri, sContactsProjection, selection, selectionArgs, sortOrder, contentUriForRawContactsEntity); } /** * A variant of init(). Currently just for testing. Use other variants for init(). * * First we'll create {@link Cursor} for the list of contactId. * * * Cursor cursorForId = mContentResolver.query( * contentUri, projection, selection, selectionArgs, sortOrder); * * * After that, we'll obtain data for each contactId in the list. * * * Cursor cursorForContent = mContentResolver.query( * contentUriForRawContactsEntity, null, * Data.CONTACT_ID + "=?", new String[] {contactId}, null) * * * {@link #createOneEntry()} or its variants let the caller obtain each entry from * cursorForContent above. * * @param contentUri Uri for obtaining the list of contactId. Used with * {@link ContentResolver#query(Uri, String[], String, String[], String)} * @param projection projection used with * {@link ContentResolver#query(Uri, String[], String, String[], String)} * @param selection selection used with * {@link ContentResolver#query(Uri, String[], String, String[], String)} * @param selectionArgs selectionArgs used with * {@link ContentResolver#query(Uri, String[], String, String[], String)} * @param sortOrder sortOrder used with * {@link ContentResolver#query(Uri, String[], String, String[], String)} * @param contentUriForRawContactsEntity Uri for obtaining entries relevant to each * contactId. * @return true when successful * * @hide */ public boolean init(final Uri contentUri, final String[] projection, final String selection, final String[] selectionArgs, final String sortOrder, Uri contentUriForRawContactsEntity) { if (!ContactsContract.AUTHORITY.equals(contentUri.getAuthority())) { if (DEBUG) Log.d(LOG_TAG, "Unexpected contentUri: " + contentUri); mErrorReason = FAILURE_REASON_UNSUPPORTED_URI; return false; } if (!initInterFirstPart(contentUriForRawContactsEntity)) { return false; } if (!initInterCursorCreationPart(contentUri, projection, selection, selectionArgs, sortOrder)) { return false; } if (!initInterMainPart()) { return false; } return initInterLastPart(); } /** * Just for testing for now. Do not use. * @hide */ public boolean init(Cursor cursor) { return initWithCallback(cursor, null); } /** * @param cursor Cursor that used to get contact id * @param rawContactEntitlesInfoCallback Callback that return RawContactEntitlesInfo * Note that this is an unstable interface, may be deleted in the future. * * @return true when successful */ public boolean initWithCallback(Cursor cursor, RawContactEntitlesInfoCallback rawContactEntitlesInfoCallback) { if (!initInterFirstPart(null)) { return false; } mCursorSuppliedFromOutside = true; mCursor = cursor; mRawContactEntitlesInfoCallback = rawContactEntitlesInfoCallback; if (!initInterMainPart()) { return false; } return initInterLastPart(); } private boolean initInterFirstPart(Uri contentUriForRawContactsEntity) { mContentUriForRawContactsEntity = (contentUriForRawContactsEntity != null ? contentUriForRawContactsEntity : RawContactsEntity.CONTENT_URI); if (mInitDone) { Log.e(LOG_TAG, "init() is already called"); return false; } return true; } private boolean initInterCursorCreationPart( final Uri contentUri, final String[] projection, final String selection, final String[] selectionArgs, final String sortOrder) { mCursorSuppliedFromOutside = false; mCursor = mContentResolver.query( contentUri, projection, selection, selectionArgs, sortOrder); if (mCursor == null) { Log.e(LOG_TAG, String.format("Cursor became null unexpectedly")); mErrorReason = FAILURE_REASON_FAILED_TO_GET_DATABASE_INFO; return false; } return true; } private boolean initInterMainPart() { if (mCursor.getCount() == 0 || !mCursor.moveToFirst()) { if (DEBUG) { Log.d(LOG_TAG, String.format("mCursor has an error (getCount: %d): ", mCursor.getCount())); } closeCursorIfAppropriate(); return false; } mIdColumn = mCursor.getColumnIndex(Data.CONTACT_ID); if (mIdColumn < 0) { mIdColumn = mCursor.getColumnIndex(Contacts._ID); } return mIdColumn >= 0; } private boolean initInterLastPart() { mInitDone = true; mTerminateCalled = false; return true; } /** * @return a vCard string. */ public String createOneEntry() { return createOneEntry(null); } /** * @hide */ public String createOneEntry(Method getEntityIteratorMethod) { if (mIsDoCoMo && !mFirstVCardEmittedInDoCoMoCase) { mFirstVCardEmittedInDoCoMoCase = true; // Previously we needed to emit empty data for this specific case, but actually // this doesn't work now, as resolver doesn't return any data with "-1" contactId. // TODO: re-introduce or remove this logic. Needs to modify unit test when we // re-introduce the logic. // return createOneEntryInternal("-1", getEntityIteratorMethod); } final String vcard = createOneEntryInternal(mCursor.getLong(mIdColumn), getEntityIteratorMethod); if (!mCursor.moveToNext()) { Log.e(LOG_TAG, "Cursor#moveToNext() returned false"); } return vcard; } /** * Class that store rawContactEntitlesUri and contactId */ public static class RawContactEntitlesInfo { public final Uri rawContactEntitlesUri; public final long contactId; public RawContactEntitlesInfo(Uri rawContactEntitlesUri, long contactId) { this.rawContactEntitlesUri = rawContactEntitlesUri; this.contactId = contactId; } } /** * Listener for getting raw contact entitles info */ public interface RawContactEntitlesInfoCallback { /** * Callback to get RawContactEntitlesInfo from contact id * * @param contactId Contact id that you want to process. * @return RawContactEntitlesInfo that ready to process. */ RawContactEntitlesInfo getRawContactEntitlesInfo(long contactId); } private String createOneEntryInternal(long contactId, final Method getEntityIteratorMethod) { final Map> contentValuesListMap = new HashMap>(); // The resolver may return the entity iterator with no data. It is possible. // e.g. If all the data in the contact of the given contact id are not exportable ones, // they are hidden from the view of this method, though contact id itself exists. EntityIterator entityIterator = null; try { Uri uri = mContentUriForRawContactsEntity; if (mRawContactEntitlesInfoCallback != null) { RawContactEntitlesInfo rawContactEntitlesInfo = mRawContactEntitlesInfoCallback.getRawContactEntitlesInfo(contactId); uri = rawContactEntitlesInfo.rawContactEntitlesUri; contactId = rawContactEntitlesInfo.contactId; } final String selection = Data.CONTACT_ID + "=?"; final String[] selectionArgs = new String[] {String.valueOf(contactId)}; if (getEntityIteratorMethod != null) { // Please note that this branch is executed by unit tests only try { entityIterator = (EntityIterator)getEntityIteratorMethod.invoke(null, mContentResolver, uri, selection, selectionArgs, null); } catch (IllegalArgumentException e) { Log.e(LOG_TAG, "IllegalArgumentException has been thrown: " + e.getMessage()); } catch (IllegalAccessException e) { Log.e(LOG_TAG, "IllegalAccessException has been thrown: " + e.getMessage()); } catch (InvocationTargetException e) { Log.e(LOG_TAG, "InvocationTargetException has been thrown: ", e); throw new RuntimeException("InvocationTargetException has been thrown"); } } else { entityIterator = RawContacts.newEntityIterator(mContentResolver.query( uri, null, selection, selectionArgs, null)); } if (entityIterator == null) { Log.e(LOG_TAG, "EntityIterator is null"); return ""; } if (!entityIterator.hasNext()) { Log.w(LOG_TAG, "Data does not exist. contactId: " + contactId); return ""; } while (entityIterator.hasNext()) { Entity entity = entityIterator.next(); for (NamedContentValues namedContentValues : entity.getSubValues()) { ContentValues contentValues = namedContentValues.values; String key = contentValues.getAsString(Data.MIMETYPE); if (key != null) { List contentValuesList = contentValuesListMap.get(key); if (contentValuesList == null) { contentValuesList = new ArrayList(); contentValuesListMap.put(key, contentValuesList); } contentValuesList.add(contentValues); } } } } finally { if (entityIterator != null) { entityIterator.close(); } } return buildVCard(contentValuesListMap); } private VCardPhoneNumberTranslationCallback mPhoneTranslationCallback; /** *

* Set a callback for phone number formatting. It will be called every time when this object * receives a phone number for printing. *

*

* When this is set {@link VCardConfig#FLAG_REFRAIN_PHONE_NUMBER_FORMATTING} will be ignored * and the callback should be responsible for everything about phone number formatting. *

*

* Caution: This interface will change. Please don't use without any strong reason. *

*/ public void setPhoneNumberTranslationCallback(VCardPhoneNumberTranslationCallback callback) { mPhoneTranslationCallback = callback; } /** * Builds and returns vCard using given map, whose key is CONTENT_ITEM_TYPE defined in * {ContactsContract}. Developers can override this method to customize the output. */ public String buildVCard(final Map> contentValuesListMap) { if (contentValuesListMap == null) { Log.e(LOG_TAG, "The given map is null. Ignore and return empty String"); return ""; } else { final VCardBuilder builder = new VCardBuilder(mVCardType, mCharset); builder.appendNameProperties(contentValuesListMap.get(StructuredName.CONTENT_ITEM_TYPE)) .appendNickNames(contentValuesListMap.get(Nickname.CONTENT_ITEM_TYPE)) .appendPhones(contentValuesListMap.get(Phone.CONTENT_ITEM_TYPE), mPhoneTranslationCallback) .appendEmails(contentValuesListMap.get(Email.CONTENT_ITEM_TYPE)) .appendPostals(contentValuesListMap.get(StructuredPostal.CONTENT_ITEM_TYPE)) .appendOrganizations(contentValuesListMap.get(Organization.CONTENT_ITEM_TYPE)) .appendWebsites(contentValuesListMap.get(Website.CONTENT_ITEM_TYPE)); if ((mVCardType & VCardConfig.FLAG_REFRAIN_IMAGE_EXPORT) == 0) { builder.appendPhotos(contentValuesListMap.get(Photo.CONTENT_ITEM_TYPE)); } builder.appendNotes(contentValuesListMap.get(Note.CONTENT_ITEM_TYPE)) .appendEvents(contentValuesListMap.get(Event.CONTENT_ITEM_TYPE)) .appendIms(contentValuesListMap.get(Im.CONTENT_ITEM_TYPE)) .appendSipAddresses(contentValuesListMap.get(SipAddress.CONTENT_ITEM_TYPE)) .appendRelation(contentValuesListMap.get(Relation.CONTENT_ITEM_TYPE)); return builder.toString(); } } public void terminate() { closeCursorIfAppropriate(); mTerminateCalled = true; } private void closeCursorIfAppropriate() { if (!mCursorSuppliedFromOutside && mCursor != null) { try { mCursor.close(); } catch (SQLiteException e) { Log.e(LOG_TAG, "SQLiteException on Cursor#close(): " + e.getMessage()); } mCursor = null; } } @Override protected void finalize() throws Throwable { try { if (!mTerminateCalled) { Log.e(LOG_TAG, "finalized() is called before terminate() being called"); } } finally { super.finalize(); } } /** * @return returns the number of available entities. The return value is undefined * when this object is not ready yet (typically when {{@link #init()} is not called * or when {@link #terminate()} is already called). */ public int getCount() { if (mCursor == null) { Log.w(LOG_TAG, "This object is not ready yet."); return 0; } return mCursor.getCount(); } /** * @return true when there's no entity to be built. The return value is undefined * when this object is not ready yet. */ public boolean isAfterLast() { if (mCursor == null) { Log.w(LOG_TAG, "This object is not ready yet."); return false; } return mCursor.isAfterLast(); } /** * @return Returns the error reason. */ public String getErrorReason() { return mErrorReason; } }