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