VCardComposer.java revision 56174dfd0654acbe828e4db38537ec5a3a04d466
1/*
2 * Copyright (C) 2009 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License"); you may not
5 * use this file except in compliance with the License. You may obtain a copy of
6 * the License at
7 *
8 * http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12 * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13 * License for the specific language governing permissions and limitations under
14 * the License.
15 */
16package com.android.vcard;
17
18import android.content.ContentResolver;
19import android.content.ContentValues;
20import android.content.Context;
21import android.content.Entity;
22import android.content.Entity.NamedContentValues;
23import android.content.EntityIterator;
24import android.database.Cursor;
25import android.database.sqlite.SQLiteException;
26import android.net.Uri;
27import android.provider.ContactsContract.CommonDataKinds.Email;
28import android.provider.ContactsContract.CommonDataKinds.Event;
29import android.provider.ContactsContract.CommonDataKinds.Im;
30import android.provider.ContactsContract.CommonDataKinds.Nickname;
31import android.provider.ContactsContract.CommonDataKinds.Note;
32import android.provider.ContactsContract.CommonDataKinds.Organization;
33import android.provider.ContactsContract.CommonDataKinds.Phone;
34import android.provider.ContactsContract.CommonDataKinds.Photo;
35import android.provider.ContactsContract.CommonDataKinds.Relation;
36import android.provider.ContactsContract.CommonDataKinds.SipAddress;
37import android.provider.ContactsContract.CommonDataKinds.StructuredName;
38import android.provider.ContactsContract.CommonDataKinds.StructuredPostal;
39import android.provider.ContactsContract.CommonDataKinds.Website;
40import android.provider.ContactsContract.Contacts;
41import android.provider.ContactsContract.Data;
42import android.provider.ContactsContract.RawContacts;
43import android.provider.ContactsContract.RawContactsEntity;
44import android.text.TextUtils;
45import android.util.Log;
46
47import java.lang.reflect.InvocationTargetException;
48import java.lang.reflect.Method;
49import java.util.ArrayList;
50import java.util.HashMap;
51import java.util.List;
52import java.util.Map;
53
54/**
55 * <p>
56 * The class for composing vCard from Contacts information.
57 * </p>
58 * <p>
59 * Usually, this class should be used like this.
60 * </p>
61 * <pre class="prettyprint">VCardComposer composer = null;
62 * try {
63 *     composer = new VCardComposer(context);
64 *     composer.addHandler(
65 *             composer.new HandlerForOutputStream(outputStream));
66 *     if (!composer.init()) {
67 *         // Do something handling the situation.
68 *         return;
69 *     }
70 *     while (!composer.isAfterLast()) {
71 *         if (mCanceled) {
72 *             // Assume a user may cancel this operation during the export.
73 *             return;
74 *         }
75 *         if (!composer.createOneEntry()) {
76 *             // Do something handling the error situation.
77 *             return;
78 *         }
79 *     }
80 * } finally {
81 *     if (composer != null) {
82 *         composer.terminate();
83 *     }
84 * }</pre>
85 * <p>
86 * Users have to manually take care of memory efficiency. Even one vCard may contain
87 * image of non-trivial size for mobile devices.
88 * </p>
89 * <p>
90 * {@link VCardBuilder} is used to build each vCard.
91 * </p>
92 */
93public class VCardComposer {
94    private static final String LOG_TAG = "VCardComposer";
95    private static final boolean DEBUG = false;
96
97    public static final String FAILURE_REASON_FAILED_TO_GET_DATABASE_INFO =
98        "Failed to get database information";
99
100    public static final String FAILURE_REASON_NO_ENTRY =
101        "There's no exportable in the database";
102
103    public static final String FAILURE_REASON_NOT_INITIALIZED =
104        "The vCard composer object is not correctly initialized";
105
106    /** Should be visible only from developers... (no need to translate, hopefully) */
107    public static final String FAILURE_REASON_UNSUPPORTED_URI =
108        "The Uri vCard composer received is not supported by the composer.";
109
110    public static final String NO_ERROR = "No error";
111
112    // Strictly speaking, "Shift_JIS" is the most appropriate, but we use upper version here,
113    // since usual vCard devices for Japanese devices already use it.
114    private static final String SHIFT_JIS = "SHIFT_JIS";
115    private static final String UTF_8 = "UTF-8";
116
117    private static final Map<Integer, String> sImMap;
118
119    static {
120        sImMap = new HashMap<Integer, String>();
121        sImMap.put(Im.PROTOCOL_AIM, VCardConstants.PROPERTY_X_AIM);
122        sImMap.put(Im.PROTOCOL_MSN, VCardConstants.PROPERTY_X_MSN);
123        sImMap.put(Im.PROTOCOL_YAHOO, VCardConstants.PROPERTY_X_YAHOO);
124        sImMap.put(Im.PROTOCOL_ICQ, VCardConstants.PROPERTY_X_ICQ);
125        sImMap.put(Im.PROTOCOL_JABBER, VCardConstants.PROPERTY_X_JABBER);
126        sImMap.put(Im.PROTOCOL_SKYPE, VCardConstants.PROPERTY_X_SKYPE_USERNAME);
127        // We don't add Google talk here since it has to be handled separately.
128    }
129
130    private final int mVCardType;
131    private final ContentResolver mContentResolver;
132
133    private final boolean mIsDoCoMo;
134    /**
135     * Used only when {@link #mIsDoCoMo} is true. Set to true when the first vCard for DoCoMo
136     * vCard is emitted.
137     */
138    private boolean mFirstVCardEmittedInDoCoMoCase;
139
140    private Cursor mCursor;
141    private boolean mCursorSuppliedFromOutside;
142    private int mIdColumn;
143    private Uri mContentUriForRawContactsEntity;
144
145    private final String mCharset;
146
147    private boolean mInitDone;
148    private String mErrorReason = NO_ERROR;
149
150    /**
151     * Set to false when one of {@link #init()} variants is called, and set to true when
152     * {@link #terminate()} is called. Initially set to true.
153     */
154    private boolean mTerminateCalled = true;
155
156    private static final String[] sContactsProjection = new String[] {
157        Contacts._ID,
158    };
159
160    public VCardComposer(Context context) {
161        this(context, VCardConfig.VCARD_TYPE_DEFAULT, null, true);
162    }
163
164    /**
165     * The variant which sets charset to null and sets careHandlerErrors to true.
166     */
167    public VCardComposer(Context context, int vcardType) {
168        this(context, vcardType, null, true);
169    }
170
171    public VCardComposer(Context context, int vcardType, String charset) {
172        this(context, vcardType, charset, true);
173    }
174
175    /**
176     * The variant which sets charset to null.
177     */
178    public VCardComposer(final Context context, final int vcardType,
179            final boolean careHandlerErrors) {
180        this(context, vcardType, null, careHandlerErrors);
181    }
182
183    /**
184     * Constructs for supporting call log entry vCard composing.
185     *
186     * @param context Context to be used during the composition.
187     * @param vcardType The type of vCard, typically available via {@link VCardConfig}.
188     * @param charset The charset to be used. Use null when you don't need the charset.
189     * @param careHandlerErrors If true, This object returns false everytime
190     */
191    public VCardComposer(final Context context, final int vcardType, String charset,
192            final boolean careHandlerErrors) {
193        this(context, context.getContentResolver(), vcardType, charset, careHandlerErrors);
194    }
195
196    /**
197     * Just for testing for now.
198     * @param resolver {@link ContentResolver} which used by this object.
199     * @hide
200     */
201    public VCardComposer(final Context context, ContentResolver resolver,
202            final int vcardType, String charset, final boolean careHandlerErrors) {
203        // Not used right now
204        // mContext = context;
205        mVCardType = vcardType;
206        mContentResolver = resolver;
207
208        mIsDoCoMo = VCardConfig.isDoCoMo(vcardType);
209
210        charset = (TextUtils.isEmpty(charset) ? VCardConfig.DEFAULT_EXPORT_CHARSET : charset);
211        final boolean shouldAppendCharsetParam = !(
212                VCardConfig.isVersion30(vcardType) && UTF_8.equalsIgnoreCase(charset));
213
214        if (mIsDoCoMo || shouldAppendCharsetParam) {
215            // TODO: clean up once we're sure CharsetUtils are really unnecessary any more.
216            if (SHIFT_JIS.equalsIgnoreCase(charset)) {
217                /*if (mIsDoCoMo) {
218                    try {
219                        charset = CharsetUtils.charsetForVendor(SHIFT_JIS, "docomo").name();
220                    } catch (UnsupportedCharsetException e) {
221                        Log.e(LOG_TAG,
222                                "DoCoMo-specific SHIFT_JIS was not found. "
223                                + "Use SHIFT_JIS as is.");
224                        charset = SHIFT_JIS;
225                    }
226                } else {
227                    try {
228                        charset = CharsetUtils.charsetForVendor(SHIFT_JIS).name();
229                    } catch (UnsupportedCharsetException e) {
230                        // Log.e(LOG_TAG,
231                        // "Career-specific SHIFT_JIS was not found. "
232                        // + "Use SHIFT_JIS as is.");
233                        charset = SHIFT_JIS;
234                    }
235                }*/
236                mCharset = charset;
237            } else {
238                /* Log.w(LOG_TAG,
239                        "The charset \"" + charset + "\" is used while "
240                        + SHIFT_JIS + " is needed to be used."); */
241                if (TextUtils.isEmpty(charset)) {
242                    mCharset = SHIFT_JIS;
243                } else {
244                    /*
245                    try {
246                        charset = CharsetUtils.charsetForVendor(charset).name();
247                    } catch (UnsupportedCharsetException e) {
248                        Log.i(LOG_TAG,
249                                "Career-specific \"" + charset + "\" was not found (as usual). "
250                                + "Use it as is.");
251                    }*/
252                    mCharset = charset;
253                }
254            }
255        } else {
256            if (TextUtils.isEmpty(charset)) {
257                mCharset = UTF_8;
258            } else {
259                /*try {
260                    charset = CharsetUtils.charsetForVendor(charset).name();
261                } catch (UnsupportedCharsetException e) {
262                    Log.i(LOG_TAG,
263                            "Career-specific \"" + charset + "\" was not found (as usual). "
264                            + "Use it as is.");
265                }*/
266                mCharset = charset;
267            }
268        }
269
270        Log.d(LOG_TAG, "Use the charset \"" + mCharset + "\"");
271    }
272
273    /**
274     * Initializes this object using default {@link Contacts#CONTENT_URI}.
275     *
276     * You can call this method or a variant of this method just once. In other words, you cannot
277     * reuse this object.
278     *
279     * @return Returns true when initialization is successful and all the other
280     *          methods are available. Returns false otherwise.
281     */
282    public boolean init() {
283        return init(null, null);
284    }
285
286    /**
287     * Special variant of init(), which accepts a Uri for obtaining {@link RawContactsEntity} from
288     * {@link ContentResolver} with {@link Contacts#_ID}.
289     * <code>
290     * String selection = Data.CONTACT_ID + "=?";
291     * String[] selectionArgs = new String[] {contactId};
292     * Cursor cursor = mContentResolver.query(
293     *         contentUriForRawContactsEntity, null, selection, selectionArgs, null)
294     * </code>
295     *
296     * You can call this method or a variant of this method just once. In other words, you cannot
297     * reuse this object.
298     *
299     * @deprecated Use {@link #init(Uri, String[], String, String[], String, Uri)} if you really
300     * need to change the default Uri.
301     */
302    @Deprecated
303    public boolean initWithRawContactsEntityUri(Uri contentUriForRawContactsEntity) {
304        return init(Contacts.CONTENT_URI, sContactsProjection, null, null, null,
305                contentUriForRawContactsEntity);
306    }
307
308    /**
309     * Initializes this object using default {@link Contacts#CONTENT_URI} and given selection
310     * arguments.
311     */
312    public boolean init(final String selection, final String[] selectionArgs) {
313        return init(Contacts.CONTENT_URI, sContactsProjection, selection, selectionArgs,
314                null, null);
315    }
316
317    /**
318     * Note that this is unstable interface, may be deleted in the future.
319     */
320    public boolean init(final Uri contentUri, final String selection,
321            final String[] selectionArgs, final String sortOrder) {
322        return init(contentUri, sContactsProjection, selection, selectionArgs, sortOrder, null);
323    }
324
325    /**
326     * A variant of init(). Currently just for testing. Use other variants for init().
327     *
328     * First we'll create {@link Cursor} for the list of contactId.
329     *
330     * <code>
331     * Cursor cursorForId = mContentResolver.query(
332     *         contentUri, projection, selection, selectionArgs, sortOrder);
333     * </code>
334     *
335     * After that, we'll obtain data for each contactId in the list.
336     *
337     * <code>
338     * Cursor cursorForContent = mContentResolver.query(
339     *         contentUriForRawContactsEntity, null,
340     *         Data.CONTACT_ID + "=?", new String[] {contactId}, null)
341     * </code>
342     *
343     * {@link #createOneEntry()} or its variants let the caller obtain each entry from
344     * <code>cursorForContent</code> above.
345     *
346     * @param contentUri Uri for obtaining the list of contactId. Used with
347     * {@link ContentResolver#query(Uri, String[], String, String[], String)}
348     * @param projection projection used with
349     * {@link ContentResolver#query(Uri, String[], String, String[], String)}
350     * @param selection selection used with
351     * {@link ContentResolver#query(Uri, String[], String, String[], String)}
352     * @param selectionArgs selectionArgs used with
353     * {@link ContentResolver#query(Uri, String[], String, String[], String)}
354     * @param sortOrder sortOrder used with
355     * {@link ContentResolver#query(Uri, String[], String, String[], String)}
356     * @param contentUriForRawContactsEntity Uri for obtaining entries relevant to each
357     * contactId.
358     * @return true when successful
359     *
360     * @hide
361     */
362    public boolean init(final Uri contentUri, final String[] projection,
363            final String selection, final String[] selectionArgs,
364            final String sortOrder, Uri contentUriForRawContactsEntity) {
365        if (!Contacts.CONTENT_URI.equals(contentUri)) {
366            if (DEBUG) Log.d(LOG_TAG, "Unexpected contentUri: " + contentUri);
367            mErrorReason = FAILURE_REASON_UNSUPPORTED_URI;
368            return false;
369        }
370        if (!initInterFirstPart(contentUriForRawContactsEntity)) {
371            return false;
372        }
373        if (!initInterCursorCreationPart(contentUri, projection, selection, selectionArgs,
374                sortOrder)) {
375            return false;
376        }
377        if (!initInterMainPart()) {
378            return false;
379        }
380        return initInterLastPart();
381    }
382
383    /**
384     * Just for testing for now. Do not use.
385     * @hide
386     */
387    public boolean init(Cursor cursor) {
388        if (!initInterFirstPart(null)) {
389            return false;
390        }
391        mCursorSuppliedFromOutside = true;
392        mCursor = cursor;
393        if (!initInterMainPart()) {
394            return false;
395        }
396        return initInterLastPart();
397    }
398
399    private boolean initInterFirstPart(Uri contentUriForRawContactsEntity) {
400        mContentUriForRawContactsEntity =
401                (contentUriForRawContactsEntity != null ? contentUriForRawContactsEntity :
402                        RawContactsEntity.CONTENT_URI);
403        if (mInitDone) {
404            Log.e(LOG_TAG, "init() is already called");
405            return false;
406        }
407        return true;
408    }
409
410    private boolean initInterCursorCreationPart(
411            final Uri contentUri, final String[] projection,
412            final String selection, final String[] selectionArgs, final String sortOrder) {
413        mCursorSuppliedFromOutside = false;
414        mCursor = mContentResolver.query(
415                contentUri, projection, selection, selectionArgs, sortOrder);
416
417        if (mCursor == null) {
418            Log.e(LOG_TAG, String.format("Cursor became null unexpectedly"));
419            mErrorReason = FAILURE_REASON_FAILED_TO_GET_DATABASE_INFO;
420            return false;
421        }
422        return true;
423    }
424
425    private boolean initInterMainPart() {
426        if (mCursor.getCount() == 0 || !mCursor.moveToFirst()) {
427            if (DEBUG) {
428                Log.d(LOG_TAG,
429                    String.format("mCursor has an error (getCount: %d): ", mCursor.getCount()));
430            }
431            closeCursorIfAppropriate();
432            return false;
433        }
434        mIdColumn = mCursor.getColumnIndex(Contacts._ID);
435        return mIdColumn >= 0;
436    }
437
438    private boolean initInterLastPart() {
439        mInitDone = true;
440        mTerminateCalled = false;
441        return true;
442    }
443
444    /**
445     * @return a vCard string.
446     */
447    public String createOneEntry() {
448        return createOneEntry(null);
449    }
450
451    /**
452     * @hide
453     */
454    public String createOneEntry(Method getEntityIteratorMethod) {
455        if (mIsDoCoMo && !mFirstVCardEmittedInDoCoMoCase) {
456            mFirstVCardEmittedInDoCoMoCase = true;
457            // Previously we needed to emit empty data for this specific case, but actually
458            // this doesn't work now, as resolver doesn't return any data with "-1" contactId.
459            // TODO: re-introduce or remove this logic. Needs to modify unit test when we
460            // re-introduce the logic.
461            // return createOneEntryInternal("-1", getEntityIteratorMethod);
462        }
463
464        final String vcard = createOneEntryInternal(mCursor.getString(mIdColumn),
465                getEntityIteratorMethod);
466        if (!mCursor.moveToNext()) {
467            Log.e(LOG_TAG, "Cursor#moveToNext() returned false");
468        }
469        return vcard;
470    }
471
472    private String createOneEntryInternal(final String contactId,
473            final Method getEntityIteratorMethod) {
474        final Map<String, List<ContentValues>> contentValuesListMap =
475                new HashMap<String, List<ContentValues>>();
476        // The resolver may return the entity iterator with no data. It is possible.
477        // e.g. If all the data in the contact of the given contact id are not exportable ones,
478        //      they are hidden from the view of this method, though contact id itself exists.
479        EntityIterator entityIterator = null;
480        try {
481            final Uri uri = mContentUriForRawContactsEntity;
482            final String selection = Data.CONTACT_ID + "=?";
483            final String[] selectionArgs = new String[] {contactId};
484            if (getEntityIteratorMethod != null) {
485                // Please note that this branch is executed by unit tests only
486                try {
487                    entityIterator = (EntityIterator)getEntityIteratorMethod.invoke(null,
488                            mContentResolver, uri, selection, selectionArgs, null);
489                } catch (IllegalArgumentException e) {
490                    Log.e(LOG_TAG, "IllegalArgumentException has been thrown: " +
491                            e.getMessage());
492                } catch (IllegalAccessException e) {
493                    Log.e(LOG_TAG, "IllegalAccessException has been thrown: " +
494                            e.getMessage());
495                } catch (InvocationTargetException e) {
496                    Log.e(LOG_TAG, "InvocationTargetException has been thrown: ", e);
497                    throw new RuntimeException("InvocationTargetException has been thrown");
498                }
499            } else {
500                entityIterator = RawContacts.newEntityIterator(mContentResolver.query(
501                        uri, null, selection, selectionArgs, null));
502            }
503
504            if (entityIterator == null) {
505                Log.e(LOG_TAG, "EntityIterator is null");
506                return "";
507            }
508
509            if (!entityIterator.hasNext()) {
510                Log.w(LOG_TAG, "Data does not exist. contactId: " + contactId);
511                return "";
512            }
513
514            while (entityIterator.hasNext()) {
515                Entity entity = entityIterator.next();
516                for (NamedContentValues namedContentValues : entity.getSubValues()) {
517                    ContentValues contentValues = namedContentValues.values;
518                    String key = contentValues.getAsString(Data.MIMETYPE);
519                    if (key != null) {
520                        List<ContentValues> contentValuesList =
521                                contentValuesListMap.get(key);
522                        if (contentValuesList == null) {
523                            contentValuesList = new ArrayList<ContentValues>();
524                            contentValuesListMap.put(key, contentValuesList);
525                        }
526                        contentValuesList.add(contentValues);
527                    }
528                }
529            }
530        } finally {
531            if (entityIterator != null) {
532                entityIterator.close();
533            }
534        }
535
536        return buildVCard(contentValuesListMap);
537    }
538
539    /**
540     * Builds and returns vCard using given map, whose key is CONTENT_ITEM_TYPE defined in
541     * {ContactsContract}. Developers can override this method to customize the output.
542     */
543    public String buildVCard(final Map<String, List<ContentValues>> contentValuesListMap) {
544        if (contentValuesListMap == null) {
545            Log.e(LOG_TAG, "The given map is null. Ignore and return empty String");
546            return "";
547        } else {
548            final VCardBuilder builder = new VCardBuilder(mVCardType, mCharset);
549            builder.appendNameProperties(contentValuesListMap.get(StructuredName.CONTENT_ITEM_TYPE))
550                    .appendNickNames(contentValuesListMap.get(Nickname.CONTENT_ITEM_TYPE))
551                    .appendPhones(contentValuesListMap.get(Phone.CONTENT_ITEM_TYPE))
552                    .appendEmails(contentValuesListMap.get(Email.CONTENT_ITEM_TYPE))
553                    .appendPostals(contentValuesListMap.get(StructuredPostal.CONTENT_ITEM_TYPE))
554                    .appendOrganizations(contentValuesListMap.get(Organization.CONTENT_ITEM_TYPE))
555                    .appendWebsites(contentValuesListMap.get(Website.CONTENT_ITEM_TYPE));
556            if ((mVCardType & VCardConfig.FLAG_REFRAIN_IMAGE_EXPORT) == 0) {
557                builder.appendPhotos(contentValuesListMap.get(Photo.CONTENT_ITEM_TYPE));
558            }
559            builder.appendNotes(contentValuesListMap.get(Note.CONTENT_ITEM_TYPE))
560                    .appendEvents(contentValuesListMap.get(Event.CONTENT_ITEM_TYPE))
561                    .appendIms(contentValuesListMap.get(Im.CONTENT_ITEM_TYPE))
562                    .appendSipAddresses(contentValuesListMap.get(SipAddress.CONTENT_ITEM_TYPE))
563                    .appendRelation(contentValuesListMap.get(Relation.CONTENT_ITEM_TYPE));
564            return builder.toString();
565        }
566    }
567
568    public void terminate() {
569        closeCursorIfAppropriate();
570        mTerminateCalled = true;
571    }
572
573    private void closeCursorIfAppropriate() {
574        if (!mCursorSuppliedFromOutside && mCursor != null) {
575            try {
576                mCursor.close();
577            } catch (SQLiteException e) {
578                Log.e(LOG_TAG, "SQLiteException on Cursor#close(): " + e.getMessage());
579            }
580            mCursor = null;
581        }
582    }
583
584    @Override
585    protected void finalize() throws Throwable {
586        try {
587            if (!mTerminateCalled) {
588                Log.e(LOG_TAG, "finalized() is called before terminate() being called");
589            }
590        } finally {
591            super.finalize();
592        }
593    }
594
595    /**
596     * @return returns the number of available entities. The return value is undefined
597     * when this object is not ready yet (typically when {{@link #init()} is not called
598     * or when {@link #terminate()} is already called).
599     */
600    public int getCount() {
601        if (mCursor == null) {
602            Log.w(LOG_TAG, "This object is not ready yet.");
603            return 0;
604        }
605        return mCursor.getCount();
606    }
607
608    /**
609     * @return true when there's no entity to be built. The return value is undefined
610     * when this object is not ready yet.
611     */
612    public boolean isAfterLast() {
613        if (mCursor == null) {
614            Log.w(LOG_TAG, "This object is not ready yet.");
615            return false;
616        }
617        return mCursor.isAfterLast();
618    }
619
620    /**
621     * @return Returns the error reason.
622     */
623    public String getErrorReason() {
624        return mErrorReason;
625    }
626}
627