BluetoothPbapVcardManager.java revision 86d30be8aa68d0a2b561c36b53f4e5ebe586fe72
1/* 2 * Copyright (c) 2008-2009, Motorola, Inc. 3 * 4 * All rights reserved. 5 * 6 * Redistribution and use in source and binary forms, with or without 7 * modification, are permitted provided that the following conditions are met: 8 * 9 * - Redistributions of source code must retain the above copyright notice, 10 * this list of conditions and the following disclaimer. 11 * 12 * - Redistributions in binary form must reproduce the above copyright notice, 13 * this list of conditions and the following disclaimer in the documentation 14 * and/or other materials provided with the distribution. 15 * 16 * - Neither the name of the Motorola, Inc. nor the names of its contributors 17 * may be used to endorse or promote products derived from this software 18 * without specific prior written permission. 19 * 20 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 23 * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE 24 * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 25 * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 26 * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 27 * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 28 * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 29 * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 30 * POSSIBILITY OF SUCH DAMAGE. 31 */ 32 33package com.android.bluetooth.pbap; 34 35import android.content.ContentResolver; 36import android.content.Context; 37import android.database.Cursor; 38import android.net.Uri; 39import android.provider.CallLog; 40import android.provider.CallLog.Calls; 41import android.provider.ContactsContract.CommonDataKinds; 42import android.provider.ContactsContract.Contacts; 43import android.provider.ContactsContract.Data; 44import android.provider.ContactsContract.CommonDataKinds.Phone; 45import android.provider.ContactsContract.PhoneLookup; 46import android.text.TextUtils; 47import android.util.Log; 48 49import com.android.bluetooth.R; 50import com.android.internal.telephony.CallerInfo; 51import com.android.vcard.VCardComposer; 52import com.android.vcard.VCardConfig; 53 54import java.io.IOException; 55import java.io.OutputStream; 56import java.util.ArrayList; 57 58import javax.obex.ServerOperation; 59import javax.obex.Operation; 60import javax.obex.ResponseCodes; 61 62public class BluetoothPbapVcardManager { 63 private static final String TAG = "BluetoothPbapVcardManager"; 64 65 private static final boolean V = BluetoothPbapService.VERBOSE; 66 67 private ContentResolver mResolver; 68 69 private Context mContext; 70 71 private StringBuilder mVcardResults = null; 72 73 static final String[] PHONES_PROJECTION = new String[] { 74 Data._ID, // 0 75 CommonDataKinds.Phone.TYPE, // 1 76 CommonDataKinds.Phone.LABEL, // 2 77 CommonDataKinds.Phone.NUMBER, // 3 78 Contacts.DISPLAY_NAME, // 4 79 }; 80 81 private static final int PHONE_NUMBER_COLUMN_INDEX = 3; 82 83 static final String SORT_ORDER_PHONE_NUMBER = CommonDataKinds.Phone.NUMBER + " ASC"; 84 85 static final String[] CONTACTS_PROJECTION = new String[] { 86 Contacts._ID, // 0 87 Contacts.DISPLAY_NAME, // 1 88 }; 89 90 static final int CONTACTS_ID_COLUMN_INDEX = 0; 91 92 static final int CONTACTS_NAME_COLUMN_INDEX = 1; 93 94 // call histories use dynamic handles, and handles should order by date; the 95 // most recently one should be the first handle. In table "calls", _id and 96 // date are consistent in ordering, to implement simply, we sort by _id 97 // here. 98 static final String CALLLOG_SORT_ORDER = Calls._ID + " DESC"; 99 100 private static final String CLAUSE_ONLY_VISIBLE = Contacts.IN_VISIBLE_GROUP + "=1"; 101 102 public BluetoothPbapVcardManager(final Context context) { 103 mContext = context; 104 mResolver = mContext.getContentResolver(); 105 } 106 107 public final String getOwnerPhoneNumberVcard(final boolean vcardType21) { 108 BluetoothPbapCallLogComposer composer = new BluetoothPbapCallLogComposer(mContext); 109 String name = BluetoothPbapService.getLocalPhoneName(); 110 String number = BluetoothPbapService.getLocalPhoneNum(); 111 String vcard = composer.composeVCardForPhoneOwnNumber(Phone.TYPE_MOBILE, name, number, 112 vcardType21); 113 return vcard; 114 } 115 116 public final int getPhonebookSize(final int type) { 117 int size; 118 switch (type) { 119 case BluetoothPbapObexServer.ContentType.PHONEBOOK: 120 size = getContactsSize(); 121 break; 122 default: 123 size = getCallHistorySize(type); 124 break; 125 } 126 if (V) Log.v(TAG, "getPhonebookSzie size = " + size + " type = " + type); 127 return size; 128 } 129 130 public final int getContactsSize() { 131 final Uri myUri = Contacts.CONTENT_URI; 132 int size = 0; 133 Cursor contactCursor = null; 134 try { 135 contactCursor = mResolver.query(myUri, null, CLAUSE_ONLY_VISIBLE, null, null); 136 if (contactCursor != null) { 137 size = contactCursor.getCount() + 1; // always has the 0.vcf 138 } 139 } finally { 140 if (contactCursor != null) { 141 contactCursor.close(); 142 } 143 } 144 return size; 145 } 146 147 public final int getCallHistorySize(final int type) { 148 final Uri myUri = CallLog.Calls.CONTENT_URI; 149 String selection = BluetoothPbapObexServer.createSelectionPara(type); 150 int size = 0; 151 Cursor callCursor = null; 152 try { 153 callCursor = mResolver.query(myUri, null, selection, null, 154 CallLog.Calls.DEFAULT_SORT_ORDER); 155 if (callCursor != null) { 156 size = callCursor.getCount(); 157 } 158 } finally { 159 if (callCursor != null) { 160 callCursor.close(); 161 } 162 } 163 return size; 164 } 165 166 public final ArrayList<String> loadCallHistoryList(final int type) { 167 final Uri myUri = CallLog.Calls.CONTENT_URI; 168 String selection = BluetoothPbapObexServer.createSelectionPara(type); 169 String[] projection = new String[] { 170 Calls.NUMBER, Calls.CACHED_NAME 171 }; 172 final int CALLS_NUMBER_COLUMN_INDEX = 0; 173 final int CALLS_NAME_COLUMN_INDEX = 1; 174 175 Cursor callCursor = null; 176 ArrayList<String> list = new ArrayList<String>(); 177 try { 178 callCursor = mResolver.query(myUri, projection, selection, null, 179 CALLLOG_SORT_ORDER); 180 if (callCursor != null) { 181 for (callCursor.moveToFirst(); !callCursor.isAfterLast(); 182 callCursor.moveToNext()) { 183 String name = callCursor.getString(CALLS_NAME_COLUMN_INDEX); 184 if (TextUtils.isEmpty(name)) { 185 // name not found, use number instead 186 name = callCursor.getString(CALLS_NUMBER_COLUMN_INDEX); 187 if (CallerInfo.UNKNOWN_NUMBER.equals(name) || 188 CallerInfo.PRIVATE_NUMBER.equals(name) || 189 CallerInfo.PAYPHONE_NUMBER.equals(name)) { 190 name = mContext.getString(R.string.unknownNumber); 191 } 192 } 193 list.add(name); 194 } 195 } 196 } finally { 197 if (callCursor != null) { 198 callCursor.close(); 199 } 200 } 201 return list; 202 } 203 204 public final ArrayList<String> getPhonebookNameList(final int orderByWhat) { 205 ArrayList<String> nameList = new ArrayList<String>(); 206 nameList.add(BluetoothPbapService.getLocalPhoneName()); 207 208 final Uri myUri = Contacts.CONTENT_URI; 209 Cursor contactCursor = null; 210 try { 211 if (orderByWhat == BluetoothPbapObexServer.ORDER_BY_INDEXED) { 212 if (V) Log.v(TAG, "getPhonebookNameList, order by index"); 213 contactCursor = mResolver.query(myUri, CONTACTS_PROJECTION, CLAUSE_ONLY_VISIBLE, 214 null, Contacts._ID); 215 } else if (orderByWhat == BluetoothPbapObexServer.ORDER_BY_ALPHABETICAL) { 216 if (V) Log.v(TAG, "getPhonebookNameList, order by alpha"); 217 contactCursor = mResolver.query(myUri, CONTACTS_PROJECTION, CLAUSE_ONLY_VISIBLE, 218 null, Contacts.DISPLAY_NAME); 219 } 220 if (contactCursor != null) { 221 for (contactCursor.moveToFirst(); !contactCursor.isAfterLast(); contactCursor 222 .moveToNext()) { 223 String name = contactCursor.getString(CONTACTS_NAME_COLUMN_INDEX); 224 if (TextUtils.isEmpty(name)) { 225 name = mContext.getString(android.R.string.unknownName); 226 } 227 nameList.add(name); 228 } 229 } 230 } finally { 231 if (contactCursor != null) { 232 contactCursor.close(); 233 } 234 } 235 return nameList; 236 } 237 238 public final ArrayList<String> getContactNamesByNumber(final String phoneNumber) { 239 ArrayList<String> nameList = new ArrayList<String>(); 240 241 Cursor contactCursor = null; 242 Uri uri = null; 243 244 if (phoneNumber != null && phoneNumber.length() == 0) { 245 uri = Contacts.CONTENT_URI; 246 } else { 247 uri = Uri.withAppendedPath(PhoneLookup.CONTENT_FILTER_URI, 248 Uri.encode(phoneNumber)); 249 } 250 251 try { 252 contactCursor = mResolver.query(uri, CONTACTS_PROJECTION, CLAUSE_ONLY_VISIBLE, 253 null, Contacts._ID); 254 255 if (contactCursor != null) { 256 for (contactCursor.moveToFirst(); !contactCursor.isAfterLast(); contactCursor 257 .moveToNext()) { 258 String name = contactCursor.getString(CONTACTS_NAME_COLUMN_INDEX); 259 long id = contactCursor.getLong(CONTACTS_ID_COLUMN_INDEX); 260 if (TextUtils.isEmpty(name)) { 261 name = mContext.getString(android.R.string.unknownName); 262 } 263 if (V) Log.v(TAG, "got name " + name + " by number " + phoneNumber + " @" + id); 264 nameList.add(name); 265 } 266 } 267 } finally { 268 if (contactCursor != null) { 269 contactCursor.close(); 270 } 271 } 272 return nameList; 273 } 274 275 public final int composeAndSendCallLogVcards(final int type, Operation op, 276 final int startPoint, final int endPoint, final boolean vcardType21) { 277 if (startPoint < 1 || startPoint > endPoint) { 278 Log.e(TAG, "internal error: startPoint or endPoint is not correct."); 279 return ResponseCodes.OBEX_HTTP_INTERNAL_ERROR; 280 } 281 String typeSelection = BluetoothPbapObexServer.createSelectionPara(type); 282 283 final Uri myUri = CallLog.Calls.CONTENT_URI; 284 final String[] CALLLOG_PROJECTION = new String[] { 285 CallLog.Calls._ID, // 0 286 }; 287 final int ID_COLUMN_INDEX = 0; 288 289 Cursor callsCursor = null; 290 long startPointId = 0; 291 long endPointId = 0; 292 try { 293 // Need test to see if order by _ID is ok here, or by date? 294 callsCursor = mResolver.query(myUri, CALLLOG_PROJECTION, typeSelection, null, 295 CALLLOG_SORT_ORDER); 296 if (callsCursor != null) { 297 callsCursor.moveToPosition(startPoint - 1); 298 startPointId = callsCursor.getLong(ID_COLUMN_INDEX); 299 if (V) Log.v(TAG, "Call Log query startPointId = " + startPointId); 300 if (startPoint == endPoint) { 301 endPointId = startPointId; 302 } else { 303 callsCursor.moveToPosition(endPoint - 1); 304 endPointId = callsCursor.getLong(ID_COLUMN_INDEX); 305 } 306 if (V) Log.v(TAG, "Call log query endPointId = " + endPointId); 307 } 308 } finally { 309 if (callsCursor != null) { 310 callsCursor.close(); 311 } 312 } 313 314 String recordSelection; 315 if (startPoint == endPoint) { 316 recordSelection = Calls._ID + "=" + startPointId; 317 } else { 318 // The query to call table is by "_id DESC" order, so change 319 // correspondingly. 320 recordSelection = Calls._ID + ">=" + endPointId + " AND " + Calls._ID + "<=" 321 + startPointId; 322 } 323 324 String selection; 325 if (typeSelection == null) { 326 selection = recordSelection; 327 } else { 328 selection = "(" + typeSelection + ") AND (" + recordSelection + ")"; 329 } 330 331 if (V) Log.v(TAG, "Call log query selection is: " + selection); 332 333 return composeAndSendVCards(op, selection, vcardType21, null, false); 334 } 335 336 public final int composeAndSendPhonebookVcards(Operation op, final int startPoint, 337 final int endPoint, final boolean vcardType21, String ownerVCard) { 338 if (startPoint < 1 || startPoint > endPoint) { 339 Log.e(TAG, "internal error: startPoint or endPoint is not correct."); 340 return ResponseCodes.OBEX_HTTP_INTERNAL_ERROR; 341 } 342 final Uri myUri = Contacts.CONTENT_URI; 343 344 Cursor contactCursor = null; 345 long startPointId = 0; 346 long endPointId = 0; 347 try { 348 contactCursor = mResolver.query(myUri, CONTACTS_PROJECTION, CLAUSE_ONLY_VISIBLE, null, 349 Contacts._ID); 350 if (contactCursor != null) { 351 contactCursor.moveToPosition(startPoint - 1); 352 startPointId = contactCursor.getLong(CONTACTS_ID_COLUMN_INDEX); 353 if (V) Log.v(TAG, "Query startPointId = " + startPointId); 354 if (startPoint == endPoint) { 355 endPointId = startPointId; 356 } else { 357 contactCursor.moveToPosition(endPoint - 1); 358 endPointId = contactCursor.getLong(CONTACTS_ID_COLUMN_INDEX); 359 } 360 if (V) Log.v(TAG, "Query endPointId = " + endPointId); 361 } 362 } finally { 363 if (contactCursor != null) { 364 contactCursor.close(); 365 } 366 } 367 368 final String selection; 369 if (startPoint == endPoint) { 370 selection = Contacts._ID + "=" + startPointId + " AND " + CLAUSE_ONLY_VISIBLE; 371 } else { 372 selection = Contacts._ID + ">=" + startPointId + " AND " + Contacts._ID + "<=" 373 + endPointId + " AND " + CLAUSE_ONLY_VISIBLE; 374 } 375 376 if (V) Log.v(TAG, "Query selection is: " + selection); 377 378 return composeAndSendVCards(op, selection, vcardType21, ownerVCard, true); 379 } 380 381 public final int composeAndSendPhonebookOneVcard(Operation op, final int offset, 382 final boolean vcardType21, String ownerVCard, int orderByWhat) { 383 if (offset < 1) { 384 Log.e(TAG, "Internal error: offset is not correct."); 385 return ResponseCodes.OBEX_HTTP_INTERNAL_ERROR; 386 } 387 final Uri myUri = Contacts.CONTENT_URI; 388 Cursor contactCursor = null; 389 String selection = null; 390 long contactId = 0; 391 if (orderByWhat == BluetoothPbapObexServer.ORDER_BY_INDEXED) { 392 try { 393 contactCursor = mResolver.query(myUri, CONTACTS_PROJECTION, CLAUSE_ONLY_VISIBLE, 394 null, Contacts._ID); 395 if (contactCursor != null) { 396 contactCursor.moveToPosition(offset - 1); 397 contactId = contactCursor.getLong(CONTACTS_ID_COLUMN_INDEX); 398 if (V) Log.v(TAG, "Query startPointId = " + contactId); 399 } 400 } finally { 401 if (contactCursor != null) { 402 contactCursor.close(); 403 } 404 } 405 } else if (orderByWhat == BluetoothPbapObexServer.ORDER_BY_ALPHABETICAL) { 406 try { 407 contactCursor = mResolver.query(myUri, CONTACTS_PROJECTION, CLAUSE_ONLY_VISIBLE, 408 null, Contacts.DISPLAY_NAME); 409 if (contactCursor != null) { 410 contactCursor.moveToPosition(offset - 1); 411 contactId = contactCursor.getLong(CONTACTS_ID_COLUMN_INDEX); 412 if (V) Log.v(TAG, "Query startPointId = " + contactId); 413 } 414 } finally { 415 if (contactCursor != null) { 416 contactCursor.close(); 417 } 418 } 419 } else { 420 Log.e(TAG, "Parameter orderByWhat is not supported!"); 421 return ResponseCodes.OBEX_HTTP_INTERNAL_ERROR; 422 } 423 selection = Contacts._ID + "=" + contactId; 424 425 if (V) Log.v(TAG, "Query selection is: " + selection); 426 427 return composeAndSendVCards(op, selection, vcardType21, ownerVCard, true); 428 } 429 430 public final int composeAndSendVCards(Operation op, final String selection, 431 final boolean vcardType21, String ownerVCard, boolean isContacts) { 432 long timestamp = 0; 433 if (V) timestamp = System.currentTimeMillis(); 434 435 if (isContacts) { 436 VCardComposer composer = null; 437 HandlerForStringBuffer buffer = null; 438 try { 439 // Currently only support Generic Vcard 2.1 and 3.0 440 int vcardType; 441 if (vcardType21) { 442 vcardType = VCardConfig.VCARD_TYPE_V21_GENERIC; 443 } else { 444 vcardType = VCardConfig.VCARD_TYPE_V30_GENERIC; 445 } 446 vcardType |= VCardConfig.FLAG_REFRAIN_IMAGE_EXPORT; 447 vcardType |= VCardConfig.FLAG_REFRAIN_PHONE_NUMBER_FORMATTING; 448 449 composer = new VCardComposer(mContext, vcardType, true); 450 buffer = new HandlerForStringBuffer(op, ownerVCard); 451 if (!composer.init(Contacts.CONTENT_URI, selection, null, Contacts._ID) || 452 !buffer.onInit(mContext)) { 453 return ResponseCodes.OBEX_HTTP_INTERNAL_ERROR; 454 } 455 456 while (!composer.isAfterLast()) { 457 if (BluetoothPbapObexServer.sIsAborted) { 458 ((ServerOperation)op).isAborted = true; 459 BluetoothPbapObexServer.sIsAborted = false; 460 break; 461 } 462 String vcard = composer.createOneEntry(); 463 if (vcard == null) { 464 Log.e(TAG, "Failed to read a contact. Error reason: " 465 + composer.getErrorReason()); 466 return ResponseCodes.OBEX_HTTP_INTERNAL_ERROR; 467 } 468 if (!buffer.onEntryCreated(vcard)) { 469 // onEntryCreate() already emits error. 470 return ResponseCodes.OBEX_HTTP_INTERNAL_ERROR; 471 } 472 } 473 } finally { 474 if (composer != null) { 475 composer.terminate(); 476 } 477 if (buffer != null) { 478 buffer.onTerminate(); 479 } 480 } 481 } else { // CallLog 482 BluetoothPbapCallLogComposer composer = null; 483 HandlerForStringBuffer buffer = null; 484 try { 485 486 composer = new BluetoothPbapCallLogComposer(mContext); 487 buffer = new HandlerForStringBuffer(op, ownerVCard); 488 if (!composer.init(CallLog.Calls.CONTENT_URI, selection, null, 489 CALLLOG_SORT_ORDER) || 490 !buffer.onInit(mContext)) { 491 return ResponseCodes.OBEX_HTTP_INTERNAL_ERROR; 492 } 493 494 while (!composer.isAfterLast()) { 495 if (BluetoothPbapObexServer.sIsAborted) { 496 ((ServerOperation)op).isAborted = true; 497 BluetoothPbapObexServer.sIsAborted = false; 498 break; 499 } 500 String vcard = composer.createOneEntry(vcardType21); 501 if (vcard == null) { 502 Log.e(TAG, "Failed to read a contact. Error reason: " 503 + composer.getErrorReason()); 504 return ResponseCodes.OBEX_HTTP_INTERNAL_ERROR; 505 } 506 buffer.onEntryCreated(vcard); 507 } 508 } finally { 509 if (composer != null) { 510 composer.terminate(); 511 } 512 if (buffer != null) { 513 buffer.onTerminate(); 514 } 515 } 516 } 517 518 if (V) Log.v(TAG, "Total vcard composing and sending out takes " 519 + (System.currentTimeMillis() - timestamp) + " ms"); 520 521 return ResponseCodes.OBEX_HTTP_OK; 522 } 523 524 /** 525 * Handler to emit VCard String to PCE once size grow to maxPacketSize. 526 */ 527 public class HandlerForStringBuffer { 528 private Operation operation; 529 530 private OutputStream outputStream; 531 532 private int maxPacketSize; 533 534 private String phoneOwnVCard = null; 535 536 public HandlerForStringBuffer(Operation op, String ownerVCard) { 537 operation = op; 538 maxPacketSize = operation.getMaxPacketSize(); 539 if (V) Log.v(TAG, "getMaxPacketSize() = " + maxPacketSize); 540 if (ownerVCard != null) { 541 phoneOwnVCard = ownerVCard; 542 if (V) Log.v(TAG, "phone own number vcard:"); 543 if (V) Log.v(TAG, phoneOwnVCard); 544 } 545 } 546 547 public boolean onInit(Context context) { 548 try { 549 outputStream = operation.openOutputStream(); 550 mVcardResults = new StringBuilder(); 551 if (phoneOwnVCard != null) { 552 mVcardResults.append(phoneOwnVCard); 553 } 554 } catch (IOException e) { 555 Log.e(TAG, "open outputstrem failed" + e.toString()); 556 return false; 557 } 558 if (V) Log.v(TAG, "openOutputStream() ok."); 559 return true; 560 } 561 562 public boolean onEntryCreated(String vcard) { 563 int vcardLen = vcard.length(); 564 if (V) Log.v(TAG, "The length of this vcard is: " + vcardLen); 565 566 mVcardResults.append(vcard); 567 int vcardByteLen = mVcardResults.toString().getBytes().length; 568 if (V) Log.v(TAG, "The byte length of this vcardResults is: " + vcardByteLen); 569 570 if (vcardByteLen >= maxPacketSize) { 571 long timestamp = 0; 572 int position = 0; 573 574 // Need while loop to handle the big vcard case 575 while (!BluetoothPbapObexServer.sIsAborted 576 && position < (vcardByteLen - maxPacketSize)) { 577 if (V) timestamp = System.currentTimeMillis(); 578 579 String subStr = mVcardResults.toString().substring(position, 580 position + maxPacketSize); 581 try { 582 outputStream.write(subStr.getBytes(), 0, maxPacketSize); 583 } catch (IOException e) { 584 Log.e(TAG, "write outputstrem failed" + e.toString()); 585 return false; 586 } 587 if (V) Log.v(TAG, "Sending vcard String " + maxPacketSize + " bytes took " 588 + (System.currentTimeMillis() - timestamp) + " ms"); 589 590 position += maxPacketSize; 591 } 592 mVcardResults.delete(0, position); 593 } 594 return true; 595 } 596 597 public void onTerminate() { 598 // Send out last packet 599 byte[] lastBytes = mVcardResults.toString().getBytes(); 600 try { 601 outputStream.write(lastBytes, 0, lastBytes.length); 602 } catch (IOException e) { 603 Log.e(TAG, "write outputstrem failed" + e.toString()); 604 } 605 if (V) Log.v(TAG, "Last packet sent out, sending process complete!"); 606 607 if (!BluetoothPbapObexServer.closeStream(outputStream, operation)) { 608 if (V) Log.v(TAG, "CloseStream failed!"); 609 } else { 610 if (V) Log.v(TAG, "CloseStream ok!"); 611 } 612 } 613 } 614} 615