1 /* 2 * Copyright (C) 2016 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 package com.android.contacts; 17 18 import android.annotation.TargetApi; 19 import android.app.ActivityManager; 20 import android.app.job.JobInfo; 21 import android.app.job.JobParameters; 22 import android.app.job.JobScheduler; 23 import android.app.job.JobService; 24 import android.content.BroadcastReceiver; 25 import android.content.ComponentName; 26 import android.content.ContentResolver; 27 import android.content.ContentUris; 28 import android.content.Context; 29 import android.content.Intent; 30 import android.content.IntentFilter; 31 import android.content.pm.ShortcutInfo; 32 import android.content.pm.ShortcutManager; 33 import android.database.Cursor; 34 import android.graphics.Bitmap; 35 import android.graphics.BitmapFactory; 36 import android.graphics.BitmapRegionDecoder; 37 import android.graphics.Canvas; 38 import android.graphics.Rect; 39 import android.graphics.drawable.AdaptiveIconDrawable; 40 import android.graphics.drawable.Drawable; 41 import android.graphics.drawable.Icon; 42 import android.net.Uri; 43 import android.os.AsyncTask; 44 import android.os.Build; 45 import android.os.PersistableBundle; 46 import android.provider.ContactsContract; 47 import android.provider.ContactsContract.Contacts; 48 import android.support.annotation.VisibleForTesting; 49 import android.support.v4.content.LocalBroadcastManager; 50 import android.support.v4.os.BuildCompat; 51 import android.util.Log; 52 53 import com.android.contacts.activities.RequestPermissionsActivity; 54 import com.android.contacts.compat.CompatUtils; 55 import com.android.contacts.lettertiles.LetterTileDrawable; 56 import com.android.contacts.util.BitmapUtil; 57 import com.android.contacts.util.ImplicitIntentsUtil; 58 import com.android.contacts.util.PermissionsUtil; 59 import com.android.contactsbind.experiments.Flags; 60 61 import java.io.IOException; 62 import java.io.InputStream; 63 import java.util.ArrayList; 64 import java.util.Collections; 65 import java.util.List; 66 67 /** 68 * This class creates and updates the dynamic shortcuts displayed on the Nexus launcher for the 69 * Contacts app. 70 * 71 * Currently it adds shortcuts for the top 3 contacts in the {@link Contacts#CONTENT_STREQUENT_URI} 72 * 73 * Usage: DynamicShortcuts.initialize should be called during Application creation. This will 74 * schedule a Job to keep the shortcuts up-to-date so no further interactions should be necessary. 75 */ 76 @TargetApi(Build.VERSION_CODES.N_MR1) 77 public class DynamicShortcuts { 78 private static final String TAG = "DynamicShortcuts"; 79 80 // Must be the same as shortcutId in res/xml/shortcuts.xml 81 // Note: This doesn't fit very well because this is a "static" shortcut but it's still the most 82 // sensible place to put it right now. 83 public static final String SHORTCUT_ADD_CONTACT = "shortcut-add-contact"; 84 85 // Note the Nexus launcher automatically truncates shortcut labels if they exceed these limits 86 // however, we implement our own truncation in case the shortcut is shown on a launcher that 87 // has different behavior 88 private static final int SHORT_LABEL_MAX_LENGTH = 12; 89 private static final int LONG_LABEL_MAX_LENGTH = 30; 90 private static final int MAX_SHORTCUTS = 3; 91 92 private static final String EXTRA_SHORTCUT_TYPE = "extraShortcutType"; 93 94 // Because pinned shortcuts persist across app upgrades these values should not be changed 95 // though new ones may be added 96 private static final int SHORTCUT_TYPE_UNKNOWN = 0; 97 private static final int SHORTCUT_TYPE_CONTACT_URI = 1; 98 private static final int SHORTCUT_TYPE_ACTION_URI = 2; 99 100 @VisibleForTesting 101 static final String[] PROJECTION = new String[] { 102 Contacts._ID, Contacts.LOOKUP_KEY, Contacts.DISPLAY_NAME_PRIMARY 103 }; 104 105 private final Context mContext; 106 107 private final ContentResolver mContentResolver; 108 private final ShortcutManager mShortcutManager; 109 private int mShortLabelMaxLength = SHORT_LABEL_MAX_LENGTH; 110 private int mLongLabelMaxLength = LONG_LABEL_MAX_LENGTH; 111 private int mIconSize; 112 private final int mContentChangeMinUpdateDelay; 113 private final int mContentChangeMaxUpdateDelay; 114 private final JobScheduler mJobScheduler; 115 116 public DynamicShortcuts(Context context) { 117 this(context, context.getContentResolver(), (ShortcutManager) 118 context.getSystemService(Context.SHORTCUT_SERVICE), 119 (JobScheduler) context.getSystemService(Context.JOB_SCHEDULER_SERVICE)); 120 } 121 122 @VisibleForTesting 123 public DynamicShortcuts(Context context, ContentResolver contentResolver, 124 ShortcutManager shortcutManager, JobScheduler jobScheduler) { 125 mContext = context; 126 mContentResolver = contentResolver; 127 mShortcutManager = shortcutManager; 128 mJobScheduler = jobScheduler; 129 mContentChangeMinUpdateDelay = Flags.getInstance() 130 .getInteger(Experiments.DYNAMIC_MIN_CONTENT_CHANGE_UPDATE_DELAY_MILLIS); 131 mContentChangeMaxUpdateDelay = Flags.getInstance() 132 .getInteger(Experiments.DYNAMIC_MAX_CONTENT_CHANGE_UPDATE_DELAY_MILLIS); 133 final ActivityManager am = (ActivityManager) context 134 .getSystemService(Context.ACTIVITY_SERVICE); 135 mIconSize = context.getResources().getDimensionPixelSize(R.dimen.shortcut_icon_size); 136 if (mIconSize == 0) { 137 mIconSize = am.getLauncherLargeIconSize(); 138 } 139 } 140 141 @VisibleForTesting 142 void setShortLabelMaxLength(int length) { 143 this.mShortLabelMaxLength = length; 144 } 145 146 @VisibleForTesting 147 void setLongLabelMaxLength(int length) { 148 this.mLongLabelMaxLength = length; 149 } 150 151 @VisibleForTesting 152 void refresh() { 153 // Guard here in addition to initialize because this could be run by the JobScheduler 154 // after permissions are revoked (maybe) 155 if (!hasRequiredPermissions()) return; 156 157 final List<ShortcutInfo> shortcuts = getStrequentShortcuts(); 158 mShortcutManager.setDynamicShortcuts(shortcuts); 159 if (Log.isLoggable(TAG, Log.DEBUG)) { 160 Log.d(TAG, "set dynamic shortcuts " + shortcuts); 161 } 162 updatePinned(); 163 } 164 165 @VisibleForTesting 166 void updatePinned() { 167 final List<ShortcutInfo> updates = new ArrayList<>(); 168 final List<String> removedIds = new ArrayList<>(); 169 final List<String> enable = new ArrayList<>(); 170 171 for (ShortcutInfo shortcut : mShortcutManager.getPinnedShortcuts()) { 172 final PersistableBundle extras = shortcut.getExtras(); 173 174 if (extras == null || extras.getInt(EXTRA_SHORTCUT_TYPE, SHORTCUT_TYPE_UNKNOWN) != 175 SHORTCUT_TYPE_CONTACT_URI) { 176 continue; 177 } 178 179 // The contact ID may have changed but that's OK because it is just an optimization 180 final long contactId = extras.getLong(Contacts._ID); 181 182 final ShortcutInfo update = createShortcutForUri( 183 Contacts.getLookupUri(contactId, shortcut.getId())); 184 if (update != null) { 185 updates.add(update); 186 if (!shortcut.isEnabled()) { 187 // Handle the case that a contact is disabled because it doesn't exist but 188 // later is created (for instance by a sync) 189 enable.add(update.getId()); 190 } 191 } else if (shortcut.isEnabled()) { 192 removedIds.add(shortcut.getId()); 193 } 194 } 195 196 if (Log.isLoggable(TAG, Log.DEBUG)) { 197 Log.d(TAG, "updating " + updates); 198 Log.d(TAG, "enabling " + enable); 199 Log.d(TAG, "disabling " + removedIds); 200 } 201 202 mShortcutManager.updateShortcuts(updates); 203 mShortcutManager.enableShortcuts(enable); 204 mShortcutManager.disableShortcuts(removedIds, 205 mContext.getString(R.string.dynamic_shortcut_contact_removed_message)); 206 } 207 208 private ShortcutInfo createShortcutForUri(Uri contactUri) { 209 final Cursor cursor = mContentResolver.query(contactUri, PROJECTION, null, null, null); 210 if (cursor == null) return null; 211 212 try { 213 if (cursor.moveToFirst()) { 214 return createShortcutFromRow(cursor); 215 } 216 } finally { 217 cursor.close(); 218 } 219 return null; 220 } 221 222 public List<ShortcutInfo> getStrequentShortcuts() { 223 // The limit query parameter doesn't seem to work for this uri but we'll leave it because in 224 // case it does work on some phones or platform versions. 225 final Uri uri = Contacts.CONTENT_STREQUENT_URI.buildUpon() 226 .appendQueryParameter(ContactsContract.LIMIT_PARAM_KEY, 227 String.valueOf(MAX_SHORTCUTS)) 228 .build(); 229 final Cursor cursor = mContentResolver.query(uri, PROJECTION, null, null, null); 230 231 if (cursor == null) return Collections.emptyList(); 232 233 final List<ShortcutInfo> result = new ArrayList<>(); 234 235 try { 236 int i = 0; 237 while (i < MAX_SHORTCUTS && cursor.moveToNext()) { 238 final ShortcutInfo shortcut = createShortcutFromRow(cursor); 239 if (shortcut == null) { 240 continue; 241 } 242 result.add(shortcut); 243 i++; 244 } 245 } finally { 246 cursor.close(); 247 } 248 return result; 249 } 250 251 252 @VisibleForTesting 253 ShortcutInfo createShortcutFromRow(Cursor cursor) { 254 final ShortcutInfo.Builder builder = builderForContactShortcut(cursor); 255 if (builder == null) { 256 return null; 257 } 258 addIconForContact(cursor, builder); 259 return builder.build(); 260 } 261 262 @VisibleForTesting 263 ShortcutInfo.Builder builderForContactShortcut(Cursor cursor) { 264 final long id = cursor.getLong(0); 265 final String lookupKey = cursor.getString(1); 266 final String displayName = cursor.getString(2); 267 return builderForContactShortcut(id, lookupKey, displayName); 268 } 269 270 @VisibleForTesting 271 ShortcutInfo.Builder builderForContactShortcut(long id, String lookupKey, String displayName) { 272 if (lookupKey == null || displayName == null) { 273 return null; 274 } 275 final PersistableBundle extras = new PersistableBundle(); 276 extras.putLong(Contacts._ID, id); 277 extras.putInt(EXTRA_SHORTCUT_TYPE, SHORTCUT_TYPE_CONTACT_URI); 278 279 final ShortcutInfo.Builder builder = new ShortcutInfo.Builder(mContext, lookupKey) 280 .setIntent(ImplicitIntentsUtil.getIntentForQuickContactLauncherShortcut(mContext, 281 Contacts.getLookupUri(id, lookupKey))) 282 .setDisabledMessage(mContext.getString(R.string.dynamic_shortcut_disabled_message)) 283 .setExtras(extras); 284 285 setLabel(builder, displayName); 286 return builder; 287 } 288 289 @VisibleForTesting 290 ShortcutInfo getActionShortcutInfo(String id, String label, Intent action, Icon icon) { 291 if (id == null || label == null) { 292 return null; 293 } 294 final PersistableBundle extras = new PersistableBundle(); 295 extras.putInt(EXTRA_SHORTCUT_TYPE, SHORTCUT_TYPE_ACTION_URI); 296 297 final ShortcutInfo.Builder builder = new ShortcutInfo.Builder(mContext, id) 298 .setIntent(action) 299 .setIcon(icon) 300 .setExtras(extras) 301 .setDisabledMessage(mContext.getString(R.string.dynamic_shortcut_disabled_message)); 302 303 setLabel(builder, label); 304 return builder.build(); 305 } 306 307 public ShortcutInfo getQuickContactShortcutInfo(long id, String lookupKey, String displayName) { 308 final ShortcutInfo.Builder builder = builderForContactShortcut(id, lookupKey, displayName); 309 if (builder == null) { 310 return null; 311 } 312 addIconForContact(id, lookupKey, displayName, builder); 313 return builder.build(); 314 } 315 316 private void setLabel(ShortcutInfo.Builder builder, String label) { 317 if (label.length() < mLongLabelMaxLength) { 318 builder.setLongLabel(label); 319 } else { 320 builder.setLongLabel(label.substring(0, mLongLabelMaxLength - 1).trim() + ""); 321 } 322 323 if (label.length() < mShortLabelMaxLength) { 324 builder.setShortLabel(label); 325 } else { 326 builder.setShortLabel(label.substring(0, mShortLabelMaxLength - 1).trim() + ""); 327 } 328 } 329 330 private void addIconForContact(Cursor cursor, ShortcutInfo.Builder builder) { 331 final long id = cursor.getLong(0); 332 final String lookupKey = cursor.getString(1); 333 final String displayName = cursor.getString(2); 334 addIconForContact(id, lookupKey, displayName, builder); 335 } 336 337 private void addIconForContact(long id, String lookupKey, String displayName, 338 ShortcutInfo.Builder builder) { 339 Bitmap bitmap = getContactPhoto(id); 340 if (bitmap == null) { 341 bitmap = getFallbackAvatar(displayName, lookupKey); 342 } 343 final Icon icon; 344 if (BuildCompat.isAtLeastO()) { 345 icon = Icon.createWithAdaptiveBitmap(bitmap); 346 } else { 347 icon = Icon.createWithBitmap(bitmap); 348 } 349 350 builder.setIcon(icon); 351 } 352 353 private Bitmap getContactPhoto(long id) { 354 final InputStream photoStream = Contacts.openContactPhotoInputStream( 355 mContext.getContentResolver(), 356 ContentUris.withAppendedId(Contacts.CONTENT_URI, id), true); 357 358 if (photoStream == null) return null; 359 try { 360 final Bitmap bitmap = decodeStreamForShortcut(photoStream); 361 photoStream.close(); 362 return bitmap; 363 } catch (IOException e) { 364 Log.e(TAG, "Failed to decode contact photo for shortcut. ID=" + id, e); 365 return null; 366 } finally { 367 try { 368 photoStream.close(); 369 } catch (IOException e) { 370 // swallow 371 } 372 } 373 } 374 375 private Bitmap decodeStreamForShortcut(InputStream stream) throws IOException { 376 final BitmapRegionDecoder bitmapDecoder = BitmapRegionDecoder.newInstance(stream, false); 377 378 final int sourceWidth = bitmapDecoder.getWidth(); 379 final int sourceHeight = bitmapDecoder.getHeight(); 380 381 final int iconMaxWidth = mShortcutManager.getIconMaxWidth(); 382 final int iconMaxHeight = mShortcutManager.getIconMaxHeight(); 383 384 final int sampleSize = Math.min( 385 BitmapUtil.findOptimalSampleSize(sourceWidth, mIconSize), 386 BitmapUtil.findOptimalSampleSize(sourceHeight, mIconSize)); 387 final BitmapFactory.Options opts = new BitmapFactory.Options(); 388 opts.inSampleSize = sampleSize; 389 390 final int scaledWidth = sourceWidth / opts.inSampleSize; 391 final int scaledHeight = sourceHeight / opts.inSampleSize; 392 393 final int targetWidth = Math.min(scaledWidth, iconMaxWidth); 394 final int targetHeight = Math.min(scaledHeight, iconMaxHeight); 395 396 // Make it square. 397 final int targetSize = Math.min(targetWidth, targetHeight); 398 399 // The region is defined in the coordinates of the source image then the sampling is 400 // done on the extracted region. 401 final int prescaledXOffset = ((scaledWidth - targetSize) * opts.inSampleSize) / 2; 402 final int prescaledYOffset = ((scaledHeight - targetSize) * opts.inSampleSize) / 2; 403 404 final Bitmap bitmap = bitmapDecoder.decodeRegion(new Rect( 405 prescaledXOffset, prescaledYOffset, 406 sourceWidth - prescaledXOffset, sourceHeight - prescaledYOffset 407 ), opts); 408 bitmapDecoder.recycle(); 409 410 if (!BuildCompat.isAtLeastO()) { 411 return BitmapUtil.getRoundedBitmap(bitmap, targetSize, targetSize); 412 } 413 414 return bitmap; 415 } 416 417 private Bitmap getFallbackAvatar(String displayName, String lookupKey) { 418 // Use a circular icon if we're not on O or higher. 419 final boolean circularIcon = !BuildCompat.isAtLeastO(); 420 421 final ContactPhotoManager.DefaultImageRequest request = 422 new ContactPhotoManager.DefaultImageRequest(displayName, lookupKey, circularIcon); 423 if (BuildCompat.isAtLeastO()) { 424 // On O, scale the image down to add the padding needed by AdaptiveIcons. 425 request.scale = LetterTileDrawable.getAdaptiveIconScale(); 426 } 427 final Drawable avatar = ContactPhotoManager.getDefaultAvatarDrawableForContact( 428 mContext.getResources(), true, request); 429 final Bitmap result = Bitmap.createBitmap(mIconSize, mIconSize, Bitmap.Config.ARGB_8888); 430 // The avatar won't draw unless it thinks it is visible 431 avatar.setVisible(true, true); 432 final Canvas canvas = new Canvas(result); 433 avatar.setBounds(0, 0, mIconSize, mIconSize); 434 avatar.draw(canvas); 435 return result; 436 } 437 438 @VisibleForTesting 439 void handleFlagDisabled() { 440 removeAllShortcuts(); 441 mJobScheduler.cancel(ContactsJobService.DYNAMIC_SHORTCUTS_JOB_ID); 442 } 443 444 private void removeAllShortcuts() { 445 mShortcutManager.removeAllDynamicShortcuts(); 446 447 final List<ShortcutInfo> pinned = mShortcutManager.getPinnedShortcuts(); 448 final List<String> ids = new ArrayList<>(pinned.size()); 449 for (ShortcutInfo shortcut : pinned) { 450 ids.add(shortcut.getId()); 451 } 452 mShortcutManager.disableShortcuts(ids, mContext 453 .getString(R.string.dynamic_shortcut_disabled_message)); 454 if (Log.isLoggable(TAG, Log.DEBUG)) { 455 Log.d(TAG, "DynamicShortcuts have been removed."); 456 } 457 } 458 459 @VisibleForTesting 460 void scheduleUpdateJob() { 461 final JobInfo job = new JobInfo.Builder( 462 ContactsJobService.DYNAMIC_SHORTCUTS_JOB_ID, 463 new ComponentName(mContext, ContactsJobService.class)) 464 // We just observe all changes to contacts. It would be better to be more granular 465 // but CP2 only notifies using this URI anyway so there isn't any point in adding 466 // that complexity. 467 .addTriggerContentUri(new JobInfo.TriggerContentUri(ContactsContract.AUTHORITY_URI, 468 JobInfo.TriggerContentUri.FLAG_NOTIFY_FOR_DESCENDANTS)) 469 .setTriggerContentUpdateDelay(mContentChangeMinUpdateDelay) 470 .setTriggerContentMaxDelay(mContentChangeMaxUpdateDelay) 471 .build(); 472 mJobScheduler.schedule(job); 473 } 474 475 void updateInBackground() { 476 new ShortcutUpdateTask(this).execute(); 477 } 478 479 public synchronized static void initialize(Context context) { 480 if (Log.isLoggable(TAG, Log.DEBUG)) { 481 final Flags flags = Flags.getInstance(); 482 Log.d(TAG, "DyanmicShortcuts.initialize\nVERSION >= N_MR1? " + 483 (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) + 484 "\nisJobScheduled? " + 485 (CompatUtils.isLauncherShortcutCompatible() && isJobScheduled(context)) + 486 "\nminDelay=" + 487 flags.getInteger(Experiments.DYNAMIC_MIN_CONTENT_CHANGE_UPDATE_DELAY_MILLIS) + 488 "\nmaxDelay=" + 489 flags.getInteger(Experiments.DYNAMIC_MAX_CONTENT_CHANGE_UPDATE_DELAY_MILLIS)); 490 } 491 492 if (!CompatUtils.isLauncherShortcutCompatible()) return; 493 494 final DynamicShortcuts shortcuts = new DynamicShortcuts(context); 495 496 if (!shortcuts.hasRequiredPermissions()) { 497 final IntentFilter filter = new IntentFilter(); 498 filter.addAction(RequestPermissionsActivity.BROADCAST_PERMISSIONS_GRANTED); 499 LocalBroadcastManager.getInstance(shortcuts.mContext).registerReceiver( 500 new PermissionsGrantedReceiver(), filter); 501 } else if (!isJobScheduled(context)) { 502 // Update the shortcuts. If the job is already scheduled then either the app is being 503 // launched to run the job in which case the shortcuts will get updated when it runs or 504 // it has been launched for some other reason and the data we care about for shortcuts 505 // hasn't changed. Because the job reschedules itself after completion this check 506 // essentially means that this will run on each app launch that happens after a reboot. 507 // Note: the task schedules the job after completing. 508 new ShortcutUpdateTask(shortcuts).execute(); 509 } 510 } 511 512 @VisibleForTesting 513 public static void reset(Context context) { 514 final JobScheduler jobScheduler = 515 (JobScheduler) context.getSystemService(Context.JOB_SCHEDULER_SERVICE); 516 jobScheduler.cancel(ContactsJobService.DYNAMIC_SHORTCUTS_JOB_ID); 517 518 if (!CompatUtils.isLauncherShortcutCompatible()) { 519 return; 520 } 521 new DynamicShortcuts(context).removeAllShortcuts(); 522 } 523 524 @VisibleForTesting 525 boolean hasRequiredPermissions() { 526 return PermissionsUtil.hasContactsPermissions(mContext); 527 } 528 529 public static void updateFromJob(final JobService service, final JobParameters jobParams) { 530 new ShortcutUpdateTask(new DynamicShortcuts(service)) { 531 @Override 532 protected void onPostExecute(Void aVoid) { 533 // Must call super first which will reschedule the job before we call jobFinished 534 super.onPostExecute(aVoid); 535 service.jobFinished(jobParams, false); 536 } 537 }.execute(); 538 } 539 540 @VisibleForTesting 541 public static boolean isJobScheduled(Context context) { 542 final JobScheduler scheduler = (JobScheduler) context 543 .getSystemService(Context.JOB_SCHEDULER_SERVICE); 544 return scheduler.getPendingJob(ContactsJobService.DYNAMIC_SHORTCUTS_JOB_ID) != null; 545 } 546 547 public static void reportShortcutUsed(Context context, String lookupKey) { 548 if (!CompatUtils.isLauncherShortcutCompatible() || lookupKey == null) return; 549 final ShortcutManager shortcutManager = (ShortcutManager) context 550 .getSystemService(Context.SHORTCUT_SERVICE); 551 shortcutManager.reportShortcutUsed(lookupKey); 552 } 553 554 private static class ShortcutUpdateTask extends AsyncTask<Void, Void, Void> { 555 private DynamicShortcuts mDynamicShortcuts; 556 557 public ShortcutUpdateTask(DynamicShortcuts shortcuts) { 558 mDynamicShortcuts = shortcuts; 559 } 560 561 @Override 562 protected Void doInBackground(Void... voids) { 563 mDynamicShortcuts.refresh(); 564 return null; 565 } 566 567 @Override 568 protected void onPostExecute(Void aVoid) { 569 if (Log.isLoggable(TAG, Log.DEBUG)) { 570 Log.d(TAG, "ShorcutUpdateTask.onPostExecute"); 571 } 572 // The shortcuts may have changed so update the job so that we are observing the 573 // correct Uris 574 mDynamicShortcuts.scheduleUpdateJob(); 575 } 576 } 577 578 private static class PermissionsGrantedReceiver extends BroadcastReceiver { 579 @Override 580 public void onReceive(Context context, Intent intent) { 581 // Clear the receiver. 582 LocalBroadcastManager.getInstance(context).unregisterReceiver(this); 583 DynamicShortcuts.initialize(context); 584 } 585 } 586 } 587