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 */ 16 package com.android.vcard; 17 18 import android.content.ContentResolver; 19 import android.content.ContentValues; 20 import android.content.Context; 21 import android.content.Entity; 22 import android.content.Entity.NamedContentValues; 23 import android.content.EntityIterator; 24 import android.database.Cursor; 25 import android.database.sqlite.SQLiteException; 26 import android.net.Uri; 27 import android.provider.ContactsContract.CommonDataKinds.Email; 28 import android.provider.ContactsContract.CommonDataKinds.Event; 29 import android.provider.ContactsContract.CommonDataKinds.Im; 30 import android.provider.ContactsContract.CommonDataKinds.Nickname; 31 import android.provider.ContactsContract.CommonDataKinds.Note; 32 import android.provider.ContactsContract.CommonDataKinds.Organization; 33 import android.provider.ContactsContract.CommonDataKinds.Phone; 34 import android.provider.ContactsContract.CommonDataKinds.Photo; 35 import android.provider.ContactsContract.CommonDataKinds.Relation; 36 import android.provider.ContactsContract.CommonDataKinds.SipAddress; 37 import android.provider.ContactsContract.CommonDataKinds.StructuredName; 38 import android.provider.ContactsContract.CommonDataKinds.StructuredPostal; 39 import android.provider.ContactsContract.CommonDataKinds.Website; 40 import android.provider.ContactsContract.Contacts; 41 import android.provider.ContactsContract.Data; 42 import android.provider.ContactsContract.RawContacts; 43 import android.provider.ContactsContract.RawContactsEntity; 44 import android.provider.ContactsContract; 45 import android.text.TextUtils; 46 import android.util.Log; 47 48 import java.lang.reflect.InvocationTargetException; 49 import java.lang.reflect.Method; 50 import java.util.ArrayList; 51 import java.util.HashMap; 52 import java.util.List; 53 import 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 */ 94 public 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