VCardComposer.java revision be378d5b188f51cf717e5309e3c39180e85833a8
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.EntityIterator;
23import android.content.Entity.NamedContentValues;
24import android.database.Cursor;
25import android.database.sqlite.SQLiteException;
26import android.net.Uri;
27import android.provider.ContactsContract.Contacts;
28import android.provider.ContactsContract.Data;
29import android.provider.ContactsContract.RawContacts;
30import android.provider.ContactsContract.RawContactsEntity;
31import android.provider.ContactsContract.CommonDataKinds.Email;
32import android.provider.ContactsContract.CommonDataKinds.Event;
33import android.provider.ContactsContract.CommonDataKinds.Im;
34import android.provider.ContactsContract.CommonDataKinds.Nickname;
35import android.provider.ContactsContract.CommonDataKinds.Note;
36import android.provider.ContactsContract.CommonDataKinds.Organization;
37import android.provider.ContactsContract.CommonDataKinds.Phone;
38import android.provider.ContactsContract.CommonDataKinds.Photo;
39import android.provider.ContactsContract.CommonDataKinds.Relation;
40import android.provider.ContactsContract.CommonDataKinds.StructuredName;
41import android.provider.ContactsContract.CommonDataKinds.StructuredPostal;
42import android.provider.ContactsContract.CommonDataKinds.Website;
43import android.text.TextUtils;
44import android.util.CharsetUtils;
45import android.util.Log;
46
47import com.android.vcard.exception.VCardException;
48
49import java.io.BufferedWriter;
50import java.io.FileOutputStream;
51import java.io.IOException;
52import java.io.OutputStream;
53import java.io.OutputStreamWriter;
54import java.io.UnsupportedEncodingException;
55import java.io.Writer;
56import java.lang.reflect.InvocationTargetException;
57import java.lang.reflect.Method;
58import java.nio.charset.UnsupportedCharsetException;
59import java.util.ArrayList;
60import java.util.HashMap;
61import java.util.List;
62import java.util.Map;
63
64/**
65 * <p>
66 * The class for composing vCard from Contacts information.
67 * </p>
68 * <p>
69 * Usually, this class should be used like this.
70 * </p>
71 * <pre class="prettyprint">VCardComposer composer = null;
72 * try {
73 *     composer = new VCardComposer(context);
74 *     composer.addHandler(
75 *             composer.new HandlerForOutputStream(outputStream));
76 *     if (!composer.init()) {
77 *         // Do something handling the situation.
78 *         return;
79 *     }
80 *     while (!composer.isAfterLast()) {
81 *         if (mCanceled) {
82 *             // Assume a user may cancel this operation during the export.
83 *             return;
84 *         }
85 *         if (!composer.createOneEntry()) {
86 *             // Do something handling the error situation.
87 *             return;
88 *         }
89 *     }
90 * } finally {
91 *     if (composer != null) {
92 *         composer.terminate();
93 *     }
94 * }</pre>
95 * <p>
96 * Users have to manually take care of memory efficiency. Even one vCard may contain
97 * image of non-trivial size for mobile devices.
98 * </p>
99 * <p>
100 * {@link VCardBuilder} is used to build each vCard.
101 * </p>
102 */
103public class VCardComposer {
104    private static final String LOG_TAG = "VCardComposer";
105
106    public static final String FAILURE_REASON_FAILED_TO_GET_DATABASE_INFO =
107        "Failed to get database information";
108
109    public static final String FAILURE_REASON_NO_ENTRY =
110        "There's no exportable in the database";
111
112    public static final String FAILURE_REASON_NOT_INITIALIZED =
113        "The vCard composer object is not correctly initialized";
114
115    /** Should be visible only from developers... (no need to translate, hopefully) */
116    public static final String FAILURE_REASON_UNSUPPORTED_URI =
117        "The Uri vCard composer received is not supported by the composer.";
118
119    public static final String NO_ERROR = "No error";
120
121    public static final String VCARD_TYPE_STRING_DOCOMO = "docomo";
122
123    // Strictly speaking, "Shift_JIS" is the most appropriate, but we use upper version here,
124    // since usual vCard devices for Japanese devices already use it.
125    private static final String SHIFT_JIS = "SHIFT_JIS";
126    private static final String UTF_8 = "UTF-8";
127
128    /**
129     * Special URI for testing.
130     */
131    public static final String VCARD_TEST_AUTHORITY = "com.android.unit_tests.vcard";
132    public static final Uri VCARD_TEST_AUTHORITY_URI =
133        Uri.parse("content://" + VCARD_TEST_AUTHORITY);
134    public static final Uri CONTACTS_TEST_CONTENT_URI =
135        Uri.withAppendedPath(VCARD_TEST_AUTHORITY_URI, "contacts");
136
137    private static final Map<Integer, String> sImMap;
138
139    static {
140        sImMap = new HashMap<Integer, String>();
141        sImMap.put(Im.PROTOCOL_AIM, VCardConstants.PROPERTY_X_AIM);
142        sImMap.put(Im.PROTOCOL_MSN, VCardConstants.PROPERTY_X_MSN);
143        sImMap.put(Im.PROTOCOL_YAHOO, VCardConstants.PROPERTY_X_YAHOO);
144        sImMap.put(Im.PROTOCOL_ICQ, VCardConstants.PROPERTY_X_ICQ);
145        sImMap.put(Im.PROTOCOL_JABBER, VCardConstants.PROPERTY_X_JABBER);
146        sImMap.put(Im.PROTOCOL_SKYPE, VCardConstants.PROPERTY_X_SKYPE_USERNAME);
147        // We don't add Google talk here since it has to be handled separately.
148    }
149
150    public static interface OneEntryHandler {
151        public boolean onInit(Context context);
152        public boolean onEntryCreated(String vcard);
153        public void onTerminate();
154    }
155
156    /**
157     * <p>
158     * An useful handler for emitting vCard String to an OutputStream object one by one.
159     * </p>
160     * <p>
161     * The input OutputStream object is closed() on {@link #onTerminate()}.
162     * Must not close the stream outside this class.
163     * </p>
164     */
165    public final class HandlerForOutputStream implements OneEntryHandler {
166        @SuppressWarnings("hiding")
167        private static final String LOG_TAG = "VCardComposer.HandlerForOutputStream";
168
169        private boolean mOnTerminateIsCalled = false;
170
171        private final OutputStream mOutputStream; // mWriter will close this.
172        private Writer mWriter;
173
174        /**
175         * Input stream will be closed on the detruction of this object.
176         */
177        public HandlerForOutputStream(final OutputStream outputStream) {
178            mOutputStream = outputStream;
179        }
180
181        public boolean onInit(final Context context) {
182            try {
183                mWriter = new BufferedWriter(new OutputStreamWriter(
184                        mOutputStream, mCharset));
185            } catch (UnsupportedEncodingException e1) {
186                Log.e(LOG_TAG, "Unsupported charset: " + mCharset);
187                mErrorReason = "Encoding is not supported (usually this does not happen!): "
188                        + mCharset;
189                return false;
190            }
191
192            if (mIsDoCoMo) {
193                try {
194                    // Create one empty entry.
195                    mWriter.write(createOneEntryInternal("-1", null));
196                } catch (VCardException e) {
197                    Log.e(LOG_TAG, "VCardException has been thrown during on Init(): " +
198                            e.getMessage());
199                    return false;
200                } catch (IOException e) {
201                    Log.e(LOG_TAG,
202                            "IOException occurred during exportOneContactData: "
203                                    + e.getMessage());
204                    mErrorReason = "IOException occurred: " + e.getMessage();
205                    return false;
206                }
207            }
208            return true;
209        }
210
211        public boolean onEntryCreated(String vcard) {
212            try {
213                mWriter.write(vcard);
214            } catch (IOException e) {
215                Log.e(LOG_TAG,
216                        "IOException occurred during exportOneContactData: "
217                                + e.getMessage());
218                mErrorReason = "IOException occurred: " + e.getMessage();
219                return false;
220            }
221            return true;
222        }
223
224        public void onTerminate() {
225            mOnTerminateIsCalled = true;
226            if (mWriter != null) {
227                try {
228                    // Flush and sync the data so that a user is able to pull
229                    // the SDCard just after
230                    // the export.
231                    mWriter.flush();
232                    if (mOutputStream != null
233                            && mOutputStream instanceof FileOutputStream) {
234                            ((FileOutputStream) mOutputStream).getFD().sync();
235                    }
236                } catch (IOException e) {
237                    Log.d(LOG_TAG,
238                            "IOException during closing the output stream: "
239                                    + e.getMessage());
240                } finally {
241                    closeOutputStream();
242                }
243            }
244        }
245
246        public void closeOutputStream() {
247            try {
248                mWriter.close();
249            } catch (IOException e) {
250                Log.w(LOG_TAG, "IOException is thrown during close(). Ignoring.");
251            }
252        }
253
254        @Override
255        public void finalize() {
256            if (!mOnTerminateIsCalled) {
257                onTerminate();
258            }
259        }
260    }
261
262    private final Context mContext;
263    private final int mVCardType;
264    private final boolean mCareHandlerErrors;
265    private final ContentResolver mContentResolver;
266
267    private final boolean mIsDoCoMo;
268    private Cursor mCursor;
269    private int mIdColumn;
270
271    private final String mCharset;
272    private boolean mTerminateIsCalled;
273    private final List<OneEntryHandler> mHandlerList;
274
275    private String mErrorReason = NO_ERROR;
276
277    private static final String[] sContactsProjection = new String[] {
278        Contacts._ID,
279    };
280
281    public VCardComposer(Context context) {
282        this(context, VCardConfig.VCARD_TYPE_DEFAULT, null, true);
283    }
284
285    /**
286     * The variant which sets charset to null and sets careHandlerErrors to true.
287     */
288    public VCardComposer(Context context, int vcardType) {
289        this(context, vcardType, null, true);
290    }
291
292    public VCardComposer(Context context, int vcardType, String charset) {
293        this(context, vcardType, charset, true);
294    }
295
296    /**
297     * The variant which sets charset to null.
298     */
299    public VCardComposer(final Context context, final int vcardType,
300            final boolean careHandlerErrors) {
301        this(context, vcardType, null, careHandlerErrors);
302    }
303
304    /**
305     * Construct for supporting call log entry vCard composing.
306     *
307     * @param context Context to be used during the composition.
308     * @param vcardType The type of vCard, typically available via {@link VCardConfig}.
309     * @param charset The charset to be used. Use null when you don't need the charset.
310     * @param careHandlerErrors If true, This object returns false everytime
311     * a Handler object given via {{@link #addHandler(OneEntryHandler)} returns false.
312     * If false, this ignores those errors.
313     */
314    public VCardComposer(final Context context, final int vcardType, String charset,
315            final boolean careHandlerErrors) {
316        mContext = context;
317        mVCardType = vcardType;
318        mCareHandlerErrors = careHandlerErrors;
319        mContentResolver = context.getContentResolver();
320
321        mIsDoCoMo = VCardConfig.isDoCoMo(vcardType);
322        mHandlerList = new ArrayList<OneEntryHandler>();
323
324        charset = (TextUtils.isEmpty(charset) ? VCardConfig.DEFAULT_EXPORT_CHARSET : charset);
325        final boolean shouldAppendCharsetParam = !(
326                VCardConfig.isVersion30(vcardType) && UTF_8.equalsIgnoreCase(charset));
327
328        if (mIsDoCoMo || shouldAppendCharsetParam) {
329            if (SHIFT_JIS.equalsIgnoreCase(charset)) {
330                if (mIsDoCoMo) {
331                    try {
332                        charset = CharsetUtils.charsetForVendor(SHIFT_JIS, "docomo").name();
333                    } catch (UnsupportedCharsetException e) {
334                        Log.e(LOG_TAG,
335                                "DoCoMo-specific SHIFT_JIS was not found. "
336                                + "Use SHIFT_JIS as is.");
337                        charset = SHIFT_JIS;
338                    }
339                } else {
340                    try {
341                        charset = CharsetUtils.charsetForVendor(SHIFT_JIS).name();
342                    } catch (UnsupportedCharsetException e) {
343                        Log.e(LOG_TAG,
344                                "Career-specific SHIFT_JIS was not found. "
345                                + "Use SHIFT_JIS as is.");
346                        charset = SHIFT_JIS;
347                    }
348                }
349                mCharset = charset;
350            } else {
351                Log.w(LOG_TAG,
352                        "The charset \"" + charset + "\" is used while "
353                        + SHIFT_JIS + " is needed to be used.");
354                if (TextUtils.isEmpty(charset)) {
355                    mCharset = SHIFT_JIS;
356                } else {
357                    try {
358                        charset = CharsetUtils.charsetForVendor(charset).name();
359                    } catch (UnsupportedCharsetException e) {
360                        Log.i(LOG_TAG,
361                                "Career-specific \"" + charset + "\" was not found (as usual). "
362                                + "Use it as is.");
363                    }
364                    mCharset = charset;
365                }
366            }
367        } else {
368            if (TextUtils.isEmpty(charset)) {
369                mCharset = UTF_8;
370            } else {
371                try {
372                    charset = CharsetUtils.charsetForVendor(charset).name();
373                } catch (UnsupportedCharsetException e) {
374                    Log.i(LOG_TAG,
375                            "Career-specific \"" + charset + "\" was not found (as usual). "
376                            + "Use it as is.");
377                }
378                mCharset = charset;
379            }
380        }
381
382        Log.d(LOG_TAG, "Use the charset \"" + mCharset + "\"");
383    }
384
385    /**
386     * Must be called before {@link #init()}.
387     */
388    public void addHandler(OneEntryHandler handler) {
389        if (handler != null) {
390            mHandlerList.add(handler);
391        }
392    }
393
394    /**
395     * @return Returns true when initialization is successful and all the other
396     *          methods are available. Returns false otherwise.
397     */
398    public boolean init() {
399        return init(null, null);
400    }
401
402    public boolean init(final String selection, final String[] selectionArgs) {
403        return init(Contacts.CONTENT_URI, selection, selectionArgs, null);
404    }
405
406    /**
407     * Note that this is unstable interface, may be deleted in the future.
408     */
409    public boolean init(final Uri contentUri, final String selection,
410            final String[] selectionArgs, final String sortOrder) {
411        if (contentUri == null) {
412            return false;
413        }
414
415        if (mCareHandlerErrors) {
416            final List<OneEntryHandler> finishedList = new ArrayList<OneEntryHandler>(
417                    mHandlerList.size());
418            for (OneEntryHandler handler : mHandlerList) {
419                if (!handler.onInit(mContext)) {
420                    for (OneEntryHandler finished : finishedList) {
421                        finished.onTerminate();
422                    }
423                    return false;
424                }
425            }
426        } else {
427            // Just ignore the false returned from onInit().
428            for (OneEntryHandler handler : mHandlerList) {
429                handler.onInit(mContext);
430            }
431        }
432
433        final String[] projection;
434        if (Contacts.CONTENT_URI.equals(contentUri) ||
435                CONTACTS_TEST_CONTENT_URI.equals(contentUri)) {
436            projection = sContactsProjection;
437        } else {
438            mErrorReason = FAILURE_REASON_UNSUPPORTED_URI;
439            return false;
440        }
441        mCursor = mContentResolver.query(
442                contentUri, projection, selection, selectionArgs, sortOrder);
443
444        if (mCursor == null) {
445            mErrorReason = FAILURE_REASON_FAILED_TO_GET_DATABASE_INFO;
446            return false;
447        }
448
449        if (getCount() == 0 || !mCursor.moveToFirst()) {
450            try {
451                mCursor.close();
452            } catch (SQLiteException e) {
453                Log.e(LOG_TAG, "SQLiteException on Cursor#close(): " + e.getMessage());
454            } finally {
455                mCursor = null;
456                mErrorReason = FAILURE_REASON_NO_ENTRY;
457            }
458            return false;
459        }
460
461        mIdColumn = mCursor.getColumnIndex(Contacts._ID);
462
463        return true;
464    }
465
466    public boolean createOneEntry() {
467        return createOneEntry(null);
468    }
469
470    /**
471     * @param getEntityIteratorMethod For Dependency Injection.
472     * @hide just for testing.
473     */
474    public boolean createOneEntry(Method getEntityIteratorMethod) {
475        if (mCursor == null || mCursor.isAfterLast()) {
476            mErrorReason = FAILURE_REASON_NOT_INITIALIZED;
477            return false;
478        }
479        final String vcard;
480        try {
481            if (mIdColumn >= 0) {
482                vcard = createOneEntryInternal(mCursor.getString(mIdColumn),
483                        getEntityIteratorMethod);
484            } else {
485                Log.e(LOG_TAG, "Incorrect mIdColumn: " + mIdColumn);
486                return true;
487            }
488        } catch (VCardException e) {
489            Log.e(LOG_TAG, "VCardException has been thrown: " + e.getMessage());
490            return false;
491        } catch (OutOfMemoryError error) {
492            // Maybe some data (e.g. photo) is too big to have in memory. But it
493            // should be rare.
494            Log.e(LOG_TAG, "OutOfMemoryError occured. Ignore the entry.");
495            System.gc();
496            // TODO: should tell users what happened?
497            return true;
498        } finally {
499            mCursor.moveToNext();
500        }
501
502        // This function does not care the OutOfMemoryError on the handler side :-P
503        if (mCareHandlerErrors) {
504            List<OneEntryHandler> finishedList = new ArrayList<OneEntryHandler>(
505                    mHandlerList.size());
506            for (OneEntryHandler handler : mHandlerList) {
507                if (!handler.onEntryCreated(vcard)) {
508                    return false;
509                }
510            }
511        } else {
512            for (OneEntryHandler handler : mHandlerList) {
513                handler.onEntryCreated(vcard);
514            }
515        }
516
517        return true;
518    }
519
520    private String createOneEntryInternal(final String contactId,
521            final Method getEntityIteratorMethod) throws VCardException {
522        final Map<String, List<ContentValues>> contentValuesListMap =
523                new HashMap<String, List<ContentValues>>();
524        // The resolver may return the entity iterator with no data. It is possible.
525        // e.g. If all the data in the contact of the given contact id are not exportable ones,
526        //      they are hidden from the view of this method, though contact id itself exists.
527        EntityIterator entityIterator = null;
528        try {
529            final Uri uri = RawContactsEntity.CONTENT_URI.buildUpon()
530                    // .appendQueryParameter("for_export_only", "1")
531                    .appendQueryParameter(Data.FOR_EXPORT_ONLY, "1")
532                    .build();
533            final String selection = Data.CONTACT_ID + "=?";
534            final String[] selectionArgs = new String[] {contactId};
535            if (getEntityIteratorMethod != null) {
536                // Please note that this branch is executed by unit tests only
537                try {
538                    entityIterator = (EntityIterator)getEntityIteratorMethod.invoke(null,
539                            mContentResolver, uri, selection, selectionArgs, null);
540                } catch (IllegalArgumentException e) {
541                    Log.e(LOG_TAG, "IllegalArgumentException has been thrown: " +
542                            e.getMessage());
543                } catch (IllegalAccessException e) {
544                    Log.e(LOG_TAG, "IllegalAccessException has been thrown: " +
545                            e.getMessage());
546                } catch (InvocationTargetException e) {
547                    Log.e(LOG_TAG, "InvocationTargetException has been thrown: ");
548                    StackTraceElement[] stackTraceElements = e.getCause().getStackTrace();
549                    for (StackTraceElement element : stackTraceElements) {
550                        Log.e(LOG_TAG, "    at " + element.toString());
551                    }
552                    throw new VCardException("InvocationTargetException has been thrown: " +
553                            e.getCause().getMessage());
554                }
555            } else {
556                entityIterator = RawContacts.newEntityIterator(mContentResolver.query(
557                        uri, null, selection, selectionArgs, null));
558            }
559
560            if (entityIterator == null) {
561                Log.e(LOG_TAG, "EntityIterator is null");
562                return "";
563            }
564
565            if (!entityIterator.hasNext()) {
566                Log.w(LOG_TAG, "Data does not exist. contactId: " + contactId);
567                return "";
568            }
569
570            while (entityIterator.hasNext()) {
571                Entity entity = entityIterator.next();
572                for (NamedContentValues namedContentValues : entity.getSubValues()) {
573                    ContentValues contentValues = namedContentValues.values;
574                    String key = contentValues.getAsString(Data.MIMETYPE);
575                    if (key != null) {
576                        List<ContentValues> contentValuesList =
577                                contentValuesListMap.get(key);
578                        if (contentValuesList == null) {
579                            contentValuesList = new ArrayList<ContentValues>();
580                            contentValuesListMap.put(key, contentValuesList);
581                        }
582                        contentValuesList.add(contentValues);
583                    }
584                }
585            }
586        } finally {
587            if (entityIterator != null) {
588                entityIterator.close();
589            }
590        }
591
592        return buildVCard(contentValuesListMap);
593    }
594
595    /**
596     * Builds and returns vCard using given map, whose key is CONTENT_ITEM_TYPE defined in
597     * {ContactsContract}. Developers can override this method to customize the output.
598     */
599    public String buildVCard(final Map<String, List<ContentValues>> contentValuesListMap) {
600        if (contentValuesListMap == null) {
601            Log.e(LOG_TAG, "The given map is null. Ignore and return empty String");
602            return "";
603        } else {
604            final VCardBuilder builder = new VCardBuilder(mVCardType, mCharset);
605            builder.appendNameProperties(contentValuesListMap.get(StructuredName.CONTENT_ITEM_TYPE))
606                    .appendNickNames(contentValuesListMap.get(Nickname.CONTENT_ITEM_TYPE))
607                    .appendPhones(contentValuesListMap.get(Phone.CONTENT_ITEM_TYPE))
608                    .appendEmails(contentValuesListMap.get(Email.CONTENT_ITEM_TYPE))
609                    .appendPostals(contentValuesListMap.get(StructuredPostal.CONTENT_ITEM_TYPE))
610                    .appendOrganizations(contentValuesListMap.get(Organization.CONTENT_ITEM_TYPE))
611                    .appendWebsites(contentValuesListMap.get(Website.CONTENT_ITEM_TYPE))
612                    .appendPhotos(contentValuesListMap.get(Photo.CONTENT_ITEM_TYPE))
613                    .appendNotes(contentValuesListMap.get(Note.CONTENT_ITEM_TYPE))
614                    .appendEvents(contentValuesListMap.get(Event.CONTENT_ITEM_TYPE))
615                    .appendIms(contentValuesListMap.get(Im.CONTENT_ITEM_TYPE))
616                    .appendRelation(contentValuesListMap.get(Relation.CONTENT_ITEM_TYPE));
617            return builder.toString();
618        }
619    }
620
621    public void terminate() {
622        for (OneEntryHandler handler : mHandlerList) {
623            handler.onTerminate();
624        }
625
626        if (mCursor != null) {
627            try {
628                mCursor.close();
629            } catch (SQLiteException e) {
630                Log.e(LOG_TAG, "SQLiteException on Cursor#close(): " + e.getMessage());
631            }
632            mCursor = null;
633        }
634
635        mTerminateIsCalled = true;
636    }
637
638    @Override
639    public void finalize() {
640        if (!mTerminateIsCalled) {
641            Log.w(LOG_TAG, "terminate() is not called yet. We call it in finalize() step.");
642            terminate();
643        }
644    }
645
646    /**
647     * @return returns the number of available entities. The return value is undefined
648     * when this object is not ready yet (typically when {{@link #init()} is not called
649     * or when {@link #terminate()} is already called).
650     */
651    public int getCount() {
652        if (mCursor == null) {
653            Log.w(LOG_TAG, "This object is not ready yet.");
654            return 0;
655        }
656        return mCursor.getCount();
657    }
658
659    /**
660     * @return true when there's no entity to be built. The return value is undefined
661     * when this object is not ready yet.
662     */
663    public boolean isAfterLast() {
664        if (mCursor == null) {
665            Log.w(LOG_TAG, "This object is not ready yet.");
666            return false;
667        }
668        return mCursor.isAfterLast();
669    }
670
671    /**
672     * @return Returns the error reason.
673     */
674    public String getErrorReason() {
675        return mErrorReason;
676    }
677}
678