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