1/* 2 * Copyright (C) 2009 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of 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, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17package com.android.loaderapp; 18 19import android.Manifest; 20import android.content.AsyncQueryHandler; 21import android.content.ContentResolver; 22import android.content.ContentUris; 23import android.content.ContentValues; 24import android.content.Context; 25import android.content.pm.PackageManager; 26import android.content.pm.PackageManager.NameNotFoundException; 27import android.content.res.Resources; 28import android.content.res.Resources.NotFoundException; 29import android.database.Cursor; 30import android.graphics.Bitmap; 31import android.graphics.BitmapFactory; 32import android.net.Uri; 33import android.os.SystemClock; 34import android.provider.ContactsContract.Contacts; 35import android.provider.ContactsContract.Data; 36import android.provider.ContactsContract.PhoneLookup; 37import android.provider.ContactsContract.RawContacts; 38import android.provider.ContactsContract.StatusUpdates; 39import android.provider.ContactsContract.CommonDataKinds.Email; 40import android.provider.ContactsContract.CommonDataKinds.Photo; 41import android.text.TextUtils; 42import android.text.format.DateUtils; 43import android.util.AttributeSet; 44import android.util.Log; 45import android.view.LayoutInflater; 46import android.view.View; 47import android.widget.CheckBox; 48import android.widget.QuickContactBadge; 49import android.widget.FrameLayout; 50import android.widget.ImageView; 51import android.widget.TextView; 52 53/** 54 * Header used across system for displaying a title bar with contact info. You 55 * can bind specific values on the header, or use helper methods like 56 * {@link #bindFromContactLookupUri(Uri)} to populate asynchronously. 57 * <p> 58 * The parent must request the {@link Manifest.permission#READ_CONTACTS} 59 * permission to access contact data. 60 */ 61public class ContactHeaderWidget extends FrameLayout implements View.OnClickListener { 62 63 private static final String TAG = "ContactHeaderWidget"; 64 65 private TextView mDisplayNameView; 66 private View mAggregateBadge; 67 private TextView mPhoneticNameView; 68 private CheckBox mStarredView; 69 private QuickContactBadge mPhotoView; 70 private ImageView mPresenceView; 71 private TextView mStatusView; 72 private TextView mStatusAttributionView; 73 private int mNoPhotoResource; 74 private QueryHandler mQueryHandler; 75 76 protected Uri mContactUri; 77 78 protected String[] mExcludeMimes = null; 79 80 protected ContentResolver mContentResolver; 81 82 /** 83 * Interface for callbacks invoked when the user interacts with a header. 84 */ 85 public interface ContactHeaderListener { 86 public void onPhotoClick(View view); 87 public void onDisplayNameClick(View view); 88 } 89 90 private ContactHeaderListener mListener; 91 92 93 private interface ContactQuery { 94 //Projection used for the summary info in the header. 95 String[] COLUMNS = new String[] { 96 Contacts._ID, 97 Contacts.LOOKUP_KEY, 98 Contacts.PHOTO_ID, 99 Contacts.DISPLAY_NAME, 100 Contacts.PHONETIC_NAME, 101 Contacts.STARRED, 102 Contacts.CONTACT_PRESENCE, 103 Contacts.CONTACT_STATUS, 104 Contacts.CONTACT_STATUS_TIMESTAMP, 105 Contacts.CONTACT_STATUS_RES_PACKAGE, 106 Contacts.CONTACT_STATUS_LABEL, 107 }; 108 int _ID = 0; 109 int LOOKUP_KEY = 1; 110 int PHOTO_ID = 2; 111 int DISPLAY_NAME = 3; 112 int PHONETIC_NAME = 4; 113 //TODO: We need to figure out how we're going to get the phonetic name. 114 //static final int HEADER_PHONETIC_NAME_COLUMN_INDEX 115 int STARRED = 5; 116 int CONTACT_PRESENCE_STATUS = 6; 117 int CONTACT_STATUS = 7; 118 int CONTACT_STATUS_TIMESTAMP = 8; 119 int CONTACT_STATUS_RES_PACKAGE = 9; 120 int CONTACT_STATUS_LABEL = 10; 121 } 122 123 private interface PhotoQuery { 124 String[] COLUMNS = new String[] { 125 Photo.PHOTO 126 }; 127 128 int PHOTO = 0; 129 } 130 131 //Projection used for looking up contact id from phone number 132 protected static final String[] PHONE_LOOKUP_PROJECTION = new String[] { 133 PhoneLookup._ID, 134 PhoneLookup.LOOKUP_KEY, 135 }; 136 protected static final int PHONE_LOOKUP_CONTACT_ID_COLUMN_INDEX = 0; 137 protected static final int PHONE_LOOKUP_CONTACT_LOOKUP_KEY_COLUMN_INDEX = 1; 138 139 //Projection used for looking up contact id from email address 140 protected static final String[] EMAIL_LOOKUP_PROJECTION = new String[] { 141 RawContacts.CONTACT_ID, 142 Contacts.LOOKUP_KEY, 143 }; 144 protected static final int EMAIL_LOOKUP_CONTACT_ID_COLUMN_INDEX = 0; 145 protected static final int EMAIL_LOOKUP_CONTACT_LOOKUP_KEY_COLUMN_INDEX = 1; 146 147 protected static final String[] CONTACT_LOOKUP_PROJECTION = new String[] { 148 Contacts._ID, 149 }; 150 protected static final int CONTACT_LOOKUP_ID_COLUMN_INDEX = 0; 151 152 private static final int TOKEN_CONTACT_INFO = 0; 153 private static final int TOKEN_PHONE_LOOKUP = 1; 154 private static final int TOKEN_EMAIL_LOOKUP = 2; 155 private static final int TOKEN_PHOTO_QUERY = 3; 156 157 public ContactHeaderWidget(Context context) { 158 this(context, null); 159 } 160 161 public ContactHeaderWidget(Context context, AttributeSet attrs) { 162 this(context, attrs, 0); 163 } 164 165 public ContactHeaderWidget(Context context, AttributeSet attrs, int defStyle) { 166 super(context, attrs, defStyle); 167 168 mContentResolver = mContext.getContentResolver(); 169 170 LayoutInflater inflater = 171 (LayoutInflater) mContext.getSystemService(Context.LAYOUT_INFLATER_SERVICE); 172 inflater.inflate(R.layout.contact_header, this); 173 174 mDisplayNameView = (TextView) findViewById(R.id.name); 175 176 mPhoneticNameView = (TextView) findViewById(R.id.phonetic_name); 177 178 mPhotoView = (QuickContactBadge) findViewById(R.id.photo); 179 180 mPresenceView = (ImageView) findViewById(R.id.presence); 181 182 mStatusView = (TextView)findViewById(R.id.status); 183 mStatusAttributionView = (TextView)findViewById(R.id.status_date); 184 185 // Set the photo with a random "no contact" image 186 long now = SystemClock.elapsedRealtime(); 187 int num = (int) now & 0xf; 188 if (num < 9) { 189 // Leaning in from right, common 190 mNoPhotoResource = R.drawable.ic_contact_picture; 191 } else if (num < 14) { 192 // Leaning in from left uncommon 193 mNoPhotoResource = R.drawable.ic_contact_picture_2; 194 } else { 195 // Coming in from the top, rare 196 mNoPhotoResource = R.drawable.ic_contact_picture_3; 197 } 198 199 resetAsyncQueryHandler(); 200 } 201 202 public void enableClickListeners() { 203 mDisplayNameView.setOnClickListener(this); 204 mPhotoView.setOnClickListener(this); 205 } 206 207 /** 208 * Set the given {@link ContactHeaderListener} to handle header events. 209 */ 210 public void setContactHeaderListener(ContactHeaderListener listener) { 211 mListener = listener; 212 } 213 214 private void performPhotoClick() { 215 if (mListener != null) { 216 mListener.onPhotoClick(mPhotoView); 217 } 218 } 219 220 private void performDisplayNameClick() { 221 if (mListener != null) { 222 mListener.onDisplayNameClick(mDisplayNameView); 223 } 224 } 225 226 private class QueryHandler extends AsyncQueryHandler { 227 228 public QueryHandler(ContentResolver cr) { 229 super(cr); 230 } 231 232 @Override 233 protected void onQueryComplete(int token, Object cookie, Cursor cursor) { 234 try{ 235 if (this != mQueryHandler) { 236 Log.d(TAG, "onQueryComplete: discard result, the query handler is reset!"); 237 return; 238 } 239 240 switch (token) { 241 case TOKEN_PHOTO_QUERY: { 242 //Set the photo 243 Bitmap photoBitmap = null; 244 if (cursor != null && cursor.moveToFirst() 245 && !cursor.isNull(PhotoQuery.PHOTO)) { 246 byte[] photoData = cursor.getBlob(PhotoQuery.PHOTO); 247 photoBitmap = BitmapFactory.decodeByteArray(photoData, 0, 248 photoData.length, null); 249 } 250 251 if (photoBitmap == null) { 252 photoBitmap = loadPlaceholderPhoto(null); 253 } 254 setPhoto(photoBitmap); 255 if (cookie != null && cookie instanceof Uri) { 256 mPhotoView.assignContactUri((Uri) cookie); 257 } 258 invalidate(); 259 break; 260 } 261 case TOKEN_CONTACT_INFO: { 262 if (cursor != null && cursor.moveToFirst()) { 263 bindContactInfo(cursor); 264 final Uri lookupUri = Contacts.getLookupUri( 265 cursor.getLong(ContactQuery._ID), 266 cursor.getString(ContactQuery.LOOKUP_KEY)); 267 268 final long photoId = cursor.getLong(ContactQuery.PHOTO_ID); 269 270 setPhotoId(photoId, lookupUri); 271 } else { 272 // shouldn't really happen 273 setDisplayName(null, null); 274 setSocialSnippet(null); 275 setPhoto(loadPlaceholderPhoto(null)); 276 } 277 break; 278 } 279 case TOKEN_PHONE_LOOKUP: { 280 if (cursor != null && cursor.moveToFirst()) { 281 long contactId = cursor.getLong(PHONE_LOOKUP_CONTACT_ID_COLUMN_INDEX); 282 String lookupKey = cursor.getString( 283 PHONE_LOOKUP_CONTACT_LOOKUP_KEY_COLUMN_INDEX); 284 bindFromContactUriInternal(Contacts.getLookupUri(contactId, lookupKey), 285 false /* don't reset query handler */); 286 } else { 287 String phoneNumber = (String) cookie; 288 setDisplayName(phoneNumber, null); 289 setSocialSnippet(null); 290 setPhoto(loadPlaceholderPhoto(null)); 291 mPhotoView.assignContactFromPhone(phoneNumber, true); 292 } 293 break; 294 } 295 case TOKEN_EMAIL_LOOKUP: { 296 if (cursor != null && cursor.moveToFirst()) { 297 long contactId = cursor.getLong(EMAIL_LOOKUP_CONTACT_ID_COLUMN_INDEX); 298 String lookupKey = cursor.getString( 299 EMAIL_LOOKUP_CONTACT_LOOKUP_KEY_COLUMN_INDEX); 300 bindFromContactUriInternal(Contacts.getLookupUri(contactId, lookupKey), 301 false /* don't reset query handler */); 302 } else { 303 String emailAddress = (String) cookie; 304 setDisplayName(emailAddress, null); 305 setSocialSnippet(null); 306 setPhoto(loadPlaceholderPhoto(null)); 307 mPhotoView.assignContactFromEmail(emailAddress, true); 308 } 309 break; 310 } 311 } 312 } finally { 313 if (cursor != null) { 314 cursor.close(); 315 } 316 } 317 } 318 } 319 320 /** 321 * Manually set the presence. 322 */ 323 public void setPresence(int presence) { 324 mPresenceView.setImageResource(StatusUpdates.getPresenceIconResourceId(presence)); 325 } 326 327 /** 328 * Manually set the presence. If presence is null, it is hidden. 329 * This doesn't change the underlying {@link Contacts} value, only the UI state. 330 * @hide 331 */ 332 public void setPresence(Integer presence) { 333 if (presence == null) { 334 showPresence(false); 335 } else { 336 showPresence(true); 337 setPresence(presence.intValue()); 338 } 339 } 340 341 /** 342 * Turn on/off showing the presence. 343 * @hide this is here for consistency with setStared/showStar and should be public 344 */ 345 public void showPresence(boolean showPresence) { 346 mPresenceView.setVisibility(showPresence ? View.VISIBLE : View.GONE); 347 } 348 349 /** 350 * Manually set the contact uri without loading any data 351 */ 352 public void setContactUri(Uri uri) { 353 setContactUri(uri, true); 354 } 355 356 /** 357 * Manually set the contact uri without loading any data 358 */ 359 public void setContactUri(Uri uri, boolean sendToQuickContact) { 360 mContactUri = uri; 361 if (sendToQuickContact) { 362 mPhotoView.assignContactUri(uri); 363 } 364 } 365 366 /** 367 * Manually set the photo to display in the header. This doesn't change the 368 * underlying {@link Contacts}, only the UI state. 369 */ 370 public void setPhoto(Bitmap bitmap) { 371 mPhotoView.setImageBitmap(bitmap); 372 } 373 374 /** 375 * Manually set the photo given its id. If the id is 0, a placeholder picture will 376 * be loaded. For any other Id, an async query is started 377 * @hide 378 */ 379 public void setPhotoId(final long photoId, final Uri lookupUri) { 380 if (photoId == 0) { 381 setPhoto(loadPlaceholderPhoto(null)); 382 mPhotoView.assignContactUri(lookupUri); 383 invalidate(); 384 } else { 385 startPhotoQuery(photoId, lookupUri, 386 false /* don't reset query handler */); 387 } 388 } 389 390 /** 391 * Manually set the display name and phonetic name to show in the header. 392 * This doesn't change the underlying {@link Contacts}, only the UI state. 393 */ 394 public void setDisplayName(CharSequence displayName, CharSequence phoneticName) { 395 mDisplayNameView.setText(displayName); 396 if (!TextUtils.isEmpty(phoneticName)) { 397 mPhoneticNameView.setText(phoneticName); 398 mPhoneticNameView.setVisibility(View.VISIBLE); 399 } else { 400 mPhoneticNameView.setVisibility(View.GONE); 401 } 402 } 403 404 /** 405 * Manually set the social snippet text to display in the header. This doesn't change the 406 * underlying {@link Contacts}, only the UI state. 407 */ 408 public void setSocialSnippet(CharSequence snippet) { 409 if (snippet == null) { 410 mStatusView.setVisibility(View.GONE); 411 mStatusAttributionView.setVisibility(View.GONE); 412 } else { 413 mStatusView.setText(snippet); 414 mStatusView.setVisibility(View.VISIBLE); 415 } 416 } 417 418 /** 419 * Manually set the status attribution text to display in the header. 420 * This doesn't change the underlying {@link Contacts}, only the UI state. 421 * @hide 422 */ 423 public void setStatusAttribution(CharSequence attribution) { 424 if (attribution != null) { 425 mStatusAttributionView.setText(attribution); 426 mStatusAttributionView.setVisibility(View.VISIBLE); 427 } else { 428 mStatusAttributionView.setVisibility(View.GONE); 429 } 430 } 431 432 /** 433 * Set a list of specific MIME-types to exclude and not display. For 434 * example, this can be used to hide the {@link Contacts#CONTENT_ITEM_TYPE} 435 * profile icon. 436 */ 437 public void setExcludeMimes(String[] excludeMimes) { 438 mExcludeMimes = excludeMimes; 439 mPhotoView.setExcludeMimes(excludeMimes); 440 } 441 442 /** 443 * Manually set all the status values to display in the header. 444 * This doesn't change the underlying {@link Contacts}, only the UI state. 445 * @hide 446 * @param status The status of the contact. If this is either null or empty, 447 * the status is cleared and the other parameters are ignored. 448 * @param statusTimestamp The timestamp (retrieved via a call to 449 * {@link System#currentTimeMillis()}) of the last status update. 450 * This value can be null if it is not known. 451 * @param statusLabel The id of a resource string that specifies the current 452 * status. This value can be null if no Label should be used. 453 * @param statusResPackage The name of the resource package containing the resource string 454 * referenced in the parameter statusLabel. 455 */ 456 public void setStatus(final String status, final Long statusTimestamp, 457 final Integer statusLabel, final String statusResPackage) { 458 if (TextUtils.isEmpty(status)) { 459 setSocialSnippet(null); 460 return; 461 } 462 463 setSocialSnippet(status); 464 465 final CharSequence timestampDisplayValue; 466 467 if (statusTimestamp != null) { 468 // Set the date/time field by mixing relative and absolute 469 // times. 470 int flags = DateUtils.FORMAT_ABBREV_RELATIVE; 471 472 timestampDisplayValue = DateUtils.getRelativeTimeSpanString( 473 statusTimestamp.longValue(), System.currentTimeMillis(), 474 DateUtils.MINUTE_IN_MILLIS, flags); 475 } else { 476 timestampDisplayValue = null; 477 } 478 479 480 String labelDisplayValue = null; 481 482 if (statusLabel != null) { 483 Resources resources; 484 if (TextUtils.isEmpty(statusResPackage)) { 485 resources = getResources(); 486 } else { 487 PackageManager pm = getContext().getPackageManager(); 488 try { 489 resources = pm.getResourcesForApplication(statusResPackage); 490 } catch (NameNotFoundException e) { 491 Log.w(TAG, "Contact status update resource package not found: " 492 + statusResPackage); 493 resources = null; 494 } 495 } 496 497 if (resources != null) { 498 try { 499 labelDisplayValue = resources.getString(statusLabel.intValue()); 500 } catch (NotFoundException e) { 501 Log.w(TAG, "Contact status update resource not found: " + statusResPackage + "@" 502 + statusLabel.intValue()); 503 } 504 } 505 } 506 507 final CharSequence attribution; 508 if (timestampDisplayValue != null && labelDisplayValue != null) { 509 attribution = getContext().getString( 510 R.string.contact_status_update_attribution_with_date, 511 timestampDisplayValue, labelDisplayValue); 512 } else if (timestampDisplayValue == null && labelDisplayValue != null) { 513 attribution = getContext().getString( 514 R.string.contact_status_update_attribution, 515 labelDisplayValue); 516 } else if (timestampDisplayValue != null) { 517 attribution = timestampDisplayValue; 518 } else { 519 attribution = null; 520 } 521 setStatusAttribution(attribution); 522 } 523 524 /** 525 * Convenience method for binding all available data from an existing 526 * contact. 527 * 528 * @param contactLookupUri a {Contacts.CONTENT_LOOKUP_URI} style URI. 529 */ 530 public void bindFromContactLookupUri(Uri contactLookupUri) { 531 bindFromContactUriInternal(contactLookupUri, true /* reset query handler */); 532 } 533 534 /** 535 * Convenience method for binding all available data from an existing 536 * contact. 537 * 538 * @param contactUri a {Contacts.CONTENT_URI} style URI. 539 * @param resetQueryHandler whether to use a new AsyncQueryHandler or not. 540 */ 541 private void bindFromContactUriInternal(Uri contactUri, boolean resetQueryHandler) { 542 mContactUri = contactUri; 543 startContactQuery(contactUri, resetQueryHandler); 544 } 545 546 /** 547 * Convenience method for binding all available data from an existing 548 * contact. 549 * 550 * @param emailAddress The email address used to do a reverse lookup in 551 * the contacts database. If more than one contact contains this email 552 * address, one of them will be chosen to bind to. 553 */ 554 public void bindFromEmail(String emailAddress) { 555 resetAsyncQueryHandler(); 556 557 mQueryHandler.startQuery(TOKEN_EMAIL_LOOKUP, emailAddress, 558 Uri.withAppendedPath(Email.CONTENT_LOOKUP_URI, Uri.encode(emailAddress)), 559 EMAIL_LOOKUP_PROJECTION, null, null, null); 560 } 561 562 /** 563 * Convenience method for binding all available data from an existing 564 * contact. 565 * 566 * @param number The phone number used to do a reverse lookup in 567 * the contacts database. If more than one contact contains this phone 568 * number, one of them will be chosen to bind to. 569 */ 570 public void bindFromPhoneNumber(String number) { 571 resetAsyncQueryHandler(); 572 573 mQueryHandler.startQuery(TOKEN_PHONE_LOOKUP, number, 574 Uri.withAppendedPath(PhoneLookup.CONTENT_FILTER_URI, Uri.encode(number)), 575 PHONE_LOOKUP_PROJECTION, null, null, null); 576 } 577 578 /** 579 * startContactQuery 580 * 581 * internal method to query contact by Uri. 582 * 583 * @param contactUri the contact uri 584 * @param resetQueryHandler whether to use a new AsyncQueryHandler or not 585 */ 586 private void startContactQuery(Uri contactUri, boolean resetQueryHandler) { 587 if (resetQueryHandler) { 588 resetAsyncQueryHandler(); 589 } 590 591 mQueryHandler.startQuery(TOKEN_CONTACT_INFO, contactUri, contactUri, ContactQuery.COLUMNS, 592 null, null, null); 593 } 594 595 /** 596 * startPhotoQuery 597 * 598 * internal method to query contact photo by photo id and uri. 599 * 600 * @param photoId the photo id. 601 * @param lookupKey the lookup uri. 602 * @param resetQueryHandler whether to use a new AsyncQueryHandler or not. 603 */ 604 protected void startPhotoQuery(long photoId, Uri lookupKey, boolean resetQueryHandler) { 605 if (resetQueryHandler) { 606 resetAsyncQueryHandler(); 607 } 608 609 mQueryHandler.startQuery(TOKEN_PHOTO_QUERY, lookupKey, 610 ContentUris.withAppendedId(Data.CONTENT_URI, photoId), PhotoQuery.COLUMNS, 611 null, null, null); 612 } 613 614 /** 615 * Method to force this widget to forget everything it knows about the contact. 616 * We need to stop any existing async queries for phone, email, contact, and photos. 617 */ 618 public void wipeClean() { 619 resetAsyncQueryHandler(); 620 621 setDisplayName(null, null); 622 setPhoto(loadPlaceholderPhoto(null)); 623 setSocialSnippet(null); 624 setPresence(0); 625 mContactUri = null; 626 mExcludeMimes = null; 627 } 628 629 630 private void resetAsyncQueryHandler() { 631 // the api AsyncQueryHandler.cancelOperation() doesn't really work. Since we really 632 // need the old async queries to be cancelled, let's do it the hard way. 633 mQueryHandler = new QueryHandler(mContentResolver); 634 } 635 636 /** 637 * Bind the contact details provided by the given {@link Cursor}. 638 */ 639 protected void bindContactInfo(Cursor c) { 640 final String displayName = c.getString(ContactQuery.DISPLAY_NAME); 641 final String phoneticName = c.getString(ContactQuery.PHONETIC_NAME); 642 this.setDisplayName(displayName, phoneticName); 643 644 //Set the presence status 645 if (!c.isNull(ContactQuery.CONTACT_PRESENCE_STATUS)) { 646 int presence = c.getInt(ContactQuery.CONTACT_PRESENCE_STATUS); 647 setPresence(presence); 648 showPresence(true); 649 } else { 650 showPresence(false); 651 } 652 653 //Set the status update 654 final String status = c.getString(ContactQuery.CONTACT_STATUS); 655 final Long statusTimestamp = c.isNull(ContactQuery.CONTACT_STATUS_TIMESTAMP) 656 ? null 657 : c.getLong(ContactQuery.CONTACT_STATUS_TIMESTAMP); 658 final Integer statusLabel = c.isNull(ContactQuery.CONTACT_STATUS_LABEL) 659 ? null 660 : c.getInt(ContactQuery.CONTACT_STATUS_LABEL); 661 final String statusResPackage = c.getString(ContactQuery.CONTACT_STATUS_RES_PACKAGE); 662 663 setStatus(status, statusTimestamp, statusLabel, statusResPackage); 664 } 665 666 public void onClick(View view) { 667 switch (view.getId()) { 668 case R.id.photo: { 669 performPhotoClick(); 670 break; 671 } 672 case R.id.name: { 673 performDisplayNameClick(); 674 break; 675 } 676 } 677 } 678 679 private Bitmap loadPlaceholderPhoto(BitmapFactory.Options options) { 680 if (mNoPhotoResource == 0) { 681 return null; 682 } 683 return BitmapFactory.decodeResource(mContext.getResources(), 684 mNoPhotoResource, options); 685 } 686} 687