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