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 17 package com.android.dialer.contactphoto; 18 19 import android.app.ActivityManager; 20 import android.content.ComponentCallbacks2; 21 import android.content.ContentResolver; 22 import android.content.ContentUris; 23 import android.content.Context; 24 import android.content.res.Resources; 25 import android.database.Cursor; 26 import android.graphics.Bitmap; 27 import android.graphics.Canvas; 28 import android.graphics.Color; 29 import android.graphics.Paint; 30 import android.graphics.Paint.Style; 31 import android.graphics.drawable.BitmapDrawable; 32 import android.graphics.drawable.Drawable; 33 import android.graphics.drawable.TransitionDrawable; 34 import android.media.ThumbnailUtils; 35 import android.net.TrafficStats; 36 import android.net.Uri; 37 import android.os.Handler; 38 import android.os.Handler.Callback; 39 import android.os.HandlerThread; 40 import android.os.Message; 41 import android.provider.ContactsContract; 42 import android.provider.ContactsContract.Contacts; 43 import android.provider.ContactsContract.Contacts.Photo; 44 import android.provider.ContactsContract.Data; 45 import android.provider.ContactsContract.Directory; 46 import android.support.annotation.UiThread; 47 import android.support.annotation.WorkerThread; 48 import android.support.v4.graphics.drawable.RoundedBitmapDrawable; 49 import android.support.v4.graphics.drawable.RoundedBitmapDrawableFactory; 50 import android.text.TextUtils; 51 import android.util.LruCache; 52 import android.view.View; 53 import android.view.ViewGroup; 54 import android.widget.ImageView; 55 import com.android.dialer.common.LogUtil; 56 import com.android.dialer.constants.Constants; 57 import com.android.dialer.constants.TrafficStatsTags; 58 import com.android.dialer.util.PermissionsUtil; 59 import com.android.dialer.util.UriUtils; 60 import java.io.ByteArrayOutputStream; 61 import java.io.IOException; 62 import java.io.InputStream; 63 import java.lang.ref.Reference; 64 import java.lang.ref.SoftReference; 65 import java.net.HttpURLConnection; 66 import java.net.URL; 67 import java.util.ArrayList; 68 import java.util.HashSet; 69 import java.util.Iterator; 70 import java.util.List; 71 import java.util.Map.Entry; 72 import java.util.Set; 73 import java.util.concurrent.ConcurrentHashMap; 74 import java.util.concurrent.atomic.AtomicInteger; 75 76 class ContactPhotoManagerImpl extends ContactPhotoManager implements Callback { 77 78 private static final String LOADER_THREAD_NAME = "ContactPhotoLoader"; 79 80 private static final int FADE_TRANSITION_DURATION = 200; 81 82 /** 83 * Type of message sent by the UI thread to itself to indicate that some photos need to be loaded. 84 */ 85 private static final int MESSAGE_REQUEST_LOADING = 1; 86 87 /** Type of message sent by the loader thread to indicate that some photos have been loaded. */ 88 private static final int MESSAGE_PHOTOS_LOADED = 2; 89 90 private static final String[] EMPTY_STRING_ARRAY = new String[0]; 91 92 private static final String[] COLUMNS = new String[] {Photo._ID, Photo.PHOTO}; 93 94 /** 95 * Placeholder object used to indicate that a bitmap for a given key could not be stored in the 96 * cache. 97 */ 98 private static final BitmapHolder BITMAP_UNAVAILABLE; 99 /** Cache size for {@link #bitmapHolderCache} for devices with "large" RAM. */ 100 private static final int HOLDER_CACHE_SIZE = 2000000; 101 /** Cache size for {@link #bitmapCache} for devices with "large" RAM. */ 102 private static final int BITMAP_CACHE_SIZE = 36864 * 48; // 1728K 103 /** Height/width of a thumbnail image */ 104 private static int thumbnailSize; 105 106 static { 107 BITMAP_UNAVAILABLE = new BitmapHolder(new byte[0], 0); 108 BITMAP_UNAVAILABLE.bitmapRef = new SoftReference<Bitmap>(null); 109 } 110 111 private final Context context; 112 /** 113 * An LRU cache for bitmap holders. The cache contains bytes for photos just as they come from the 114 * database. Each holder has a soft reference to the actual bitmap. 115 */ 116 private final LruCache<Object, BitmapHolder> bitmapHolderCache; 117 /** Cache size threshold at which bitmaps will not be preloaded. */ 118 private final int bitmapHolderCacheRedZoneBytes; 119 /** 120 * Level 2 LRU cache for bitmaps. This is a smaller cache that holds the most recently used 121 * bitmaps to save time on decoding them from bytes (the bytes are stored in {@link 122 * #bitmapHolderCache}. 123 */ 124 private final LruCache<Object, Bitmap> bitmapCache; 125 /** 126 * A map from ImageView to the corresponding photo ID or uri, encapsulated in a request. The 127 * request may swapped out before the photo loading request is started. 128 */ 129 private final ConcurrentHashMap<ImageView, Request> pendingRequests = 130 new ConcurrentHashMap<ImageView, Request>(); 131 /** Handler for messages sent to the UI thread. */ 132 private final Handler mainThreadHandler = new Handler(this); 133 /** For debug: How many times we had to reload cached photo for a stale entry */ 134 private final AtomicInteger staleCacheOverwrite = new AtomicInteger(); 135 /** For debug: How many times we had to reload cached photo for a fresh entry. Should be 0. */ 136 private final AtomicInteger freshCacheOverwrite = new AtomicInteger(); 137 /** {@code true} if ALL entries in {@link #bitmapHolderCache} are NOT fresh. */ 138 private volatile boolean bitmapHolderCacheAllUnfresh = true; 139 /** Thread responsible for loading photos from the database. Created upon the first request. */ 140 private LoaderThread loaderThread; 141 /** A gate to make sure we only send one instance of MESSAGE_PHOTOS_NEEDED at a time. */ 142 private boolean loadingRequested; 143 /** Flag indicating if the image loading is paused. */ 144 private boolean paused; 145 /** The user agent string to use when loading URI based photos. */ 146 private String userAgent; 147 ContactPhotoManagerImpl(Context context)148 public ContactPhotoManagerImpl(Context context) { 149 this.context = context; 150 151 final ActivityManager am = 152 ((ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE)); 153 154 final float cacheSizeAdjustment = (am.isLowRamDevice()) ? 0.5f : 1.0f; 155 156 final int bitmapCacheSize = (int) (cacheSizeAdjustment * BITMAP_CACHE_SIZE); 157 bitmapCache = 158 new LruCache<Object, Bitmap>(bitmapCacheSize) { 159 @Override 160 protected int sizeOf(Object key, Bitmap value) { 161 return value.getByteCount(); 162 } 163 164 @Override 165 protected void entryRemoved( 166 boolean evicted, Object key, Bitmap oldValue, Bitmap newValue) { 167 if (DEBUG) { 168 dumpStats(); 169 } 170 } 171 }; 172 final int holderCacheSize = (int) (cacheSizeAdjustment * HOLDER_CACHE_SIZE); 173 bitmapHolderCache = 174 new LruCache<Object, BitmapHolder>(holderCacheSize) { 175 @Override 176 protected int sizeOf(Object key, BitmapHolder value) { 177 return value.bytes != null ? value.bytes.length : 0; 178 } 179 180 @Override 181 protected void entryRemoved( 182 boolean evicted, Object key, BitmapHolder oldValue, BitmapHolder newValue) { 183 if (DEBUG) { 184 dumpStats(); 185 } 186 } 187 }; 188 bitmapHolderCacheRedZoneBytes = (int) (holderCacheSize * 0.75); 189 LogUtil.i( 190 "ContactPhotoManagerImpl.ContactPhotoManagerImpl", "cache adj: " + cacheSizeAdjustment); 191 if (DEBUG) { 192 LogUtil.d( 193 "ContactPhotoManagerImpl.ContactPhotoManagerImpl", 194 "Cache size: " + btk(bitmapHolderCache.maxSize()) + " + " + btk(bitmapCache.maxSize())); 195 } 196 197 thumbnailSize = 198 context.getResources().getDimensionPixelSize(R.dimen.contact_browser_list_item_photo_size); 199 200 // Get a user agent string to use for URI photo requests. 201 userAgent = Constants.get().getUserAgent(context); 202 if (userAgent == null) { 203 userAgent = ""; 204 } 205 } 206 207 /** Converts bytes to K bytes, rounding up. Used only for debug log. */ btk(int bytes)208 private static String btk(int bytes) { 209 return ((bytes + 1023) / 1024) + "K"; 210 } 211 safeDiv(int dividend, int divisor)212 private static final int safeDiv(int dividend, int divisor) { 213 return (divisor == 0) ? 0 : (dividend / divisor); 214 } 215 isChildView(View parent, View potentialChild)216 private static boolean isChildView(View parent, View potentialChild) { 217 return potentialChild.getParent() != null 218 && (potentialChild.getParent() == parent 219 || (potentialChild.getParent() instanceof ViewGroup 220 && isChildView(parent, (ViewGroup) potentialChild.getParent()))); 221 } 222 223 /** 224 * If necessary, decodes bytes stored in the holder to Bitmap. As long as the bitmap is held 225 * either by {@link #bitmapCache} or by a soft reference in the holder, it will not be necessary 226 * to decode the bitmap. 227 */ inflateBitmap(BitmapHolder holder, int requestedExtent)228 private static void inflateBitmap(BitmapHolder holder, int requestedExtent) { 229 final int sampleSize = 230 BitmapUtil.findOptimalSampleSize(holder.originalSmallerExtent, requestedExtent); 231 byte[] bytes = holder.bytes; 232 if (bytes == null || bytes.length == 0) { 233 return; 234 } 235 236 if (sampleSize == holder.decodedSampleSize) { 237 // Check the soft reference. If will be retained if the bitmap is also 238 // in the LRU cache, so we don't need to check the LRU cache explicitly. 239 if (holder.bitmapRef != null) { 240 holder.bitmap = holder.bitmapRef.get(); 241 if (holder.bitmap != null) { 242 return; 243 } 244 } 245 } 246 247 try { 248 Bitmap bitmap = BitmapUtil.decodeBitmapFromBytes(bytes, sampleSize); 249 250 // TODO: As a temporary workaround while framework support is being added to 251 // clip non-square bitmaps into a perfect circle, manually crop the bitmap into 252 // into a square if it will be displayed as a thumbnail so that it can be cropped 253 // into a circle. 254 final int height = bitmap.getHeight(); 255 final int width = bitmap.getWidth(); 256 257 // The smaller dimension of a scaled bitmap can range from anywhere from 0 to just 258 // below twice the length of a thumbnail image due to the way we calculate the optimal 259 // sample size. 260 if (height != width && Math.min(height, width) <= thumbnailSize * 2) { 261 final int dimension = Math.min(height, width); 262 bitmap = ThumbnailUtils.extractThumbnail(bitmap, dimension, dimension); 263 } 264 // make bitmap mutable and draw size onto it 265 if (DEBUG_SIZES) { 266 Bitmap original = bitmap; 267 bitmap = bitmap.copy(bitmap.getConfig(), true); 268 original.recycle(); 269 Canvas canvas = new Canvas(bitmap); 270 Paint paint = new Paint(); 271 paint.setTextSize(16); 272 paint.setColor(Color.BLUE); 273 paint.setStyle(Style.FILL); 274 canvas.drawRect(0.0f, 0.0f, 50.0f, 20.0f, paint); 275 paint.setColor(Color.WHITE); 276 paint.setAntiAlias(true); 277 canvas.drawText(bitmap.getWidth() + "/" + sampleSize, 0, 15, paint); 278 } 279 280 holder.decodedSampleSize = sampleSize; 281 holder.bitmap = bitmap; 282 holder.bitmapRef = new SoftReference<Bitmap>(bitmap); 283 if (DEBUG) { 284 LogUtil.d( 285 "ContactPhotoManagerImpl.inflateBitmap", 286 "inflateBitmap " 287 + btk(bytes.length) 288 + " -> " 289 + bitmap.getWidth() 290 + "x" 291 + bitmap.getHeight() 292 + ", " 293 + btk(bitmap.getByteCount())); 294 } 295 } catch (OutOfMemoryError e) { 296 // Do nothing - the photo will appear to be missing 297 } 298 } 299 300 /** Dump cache stats on logcat. */ dumpStats()301 private void dumpStats() { 302 if (!DEBUG) { 303 return; 304 } 305 { 306 int numHolders = 0; 307 int rawBytes = 0; 308 int bitmapBytes = 0; 309 int numBitmaps = 0; 310 for (BitmapHolder h : bitmapHolderCache.snapshot().values()) { 311 numHolders++; 312 if (h.bytes != null) { 313 rawBytes += h.bytes.length; 314 } 315 Bitmap b = h.bitmapRef != null ? h.bitmapRef.get() : null; 316 if (b != null) { 317 numBitmaps++; 318 bitmapBytes += b.getByteCount(); 319 } 320 } 321 LogUtil.d( 322 "ContactPhotoManagerImpl.dumpStats", 323 "L1: " 324 + btk(rawBytes) 325 + " + " 326 + btk(bitmapBytes) 327 + " = " 328 + btk(rawBytes + bitmapBytes) 329 + ", " 330 + numHolders 331 + " holders, " 332 + numBitmaps 333 + " bitmaps, avg: " 334 + btk(safeDiv(rawBytes, numHolders)) 335 + "," 336 + btk(safeDiv(bitmapBytes, numBitmaps))); 337 LogUtil.d( 338 "ContactPhotoManagerImpl.dumpStats", 339 "L1 Stats: " 340 + bitmapHolderCache.toString() 341 + ", overwrite: fresh=" 342 + freshCacheOverwrite.get() 343 + " stale=" 344 + staleCacheOverwrite.get()); 345 } 346 347 { 348 int numBitmaps = 0; 349 int bitmapBytes = 0; 350 for (Bitmap b : bitmapCache.snapshot().values()) { 351 numBitmaps++; 352 bitmapBytes += b.getByteCount(); 353 } 354 LogUtil.d( 355 "ContactPhotoManagerImpl.dumpStats", 356 "L2: " 357 + btk(bitmapBytes) 358 + ", " 359 + numBitmaps 360 + " bitmaps" 361 + ", avg: " 362 + btk(safeDiv(bitmapBytes, numBitmaps))); 363 // We don't get from L2 cache, so L2 stats is meaningless. 364 } 365 } 366 367 @Override onTrimMemory(int level)368 public void onTrimMemory(int level) { 369 if (DEBUG) { 370 LogUtil.d("ContactPhotoManagerImpl.onTrimMemory", "onTrimMemory: " + level); 371 } 372 if (level >= ComponentCallbacks2.TRIM_MEMORY_BACKGROUND) { 373 // Clear the caches. Note all pending requests will be removed too. 374 clear(); 375 } 376 } 377 378 @Override preloadPhotosInBackground()379 public void preloadPhotosInBackground() { 380 ensureLoaderThread(); 381 loaderThread.requestPreloading(); 382 } 383 384 @Override loadThumbnail( ImageView view, long photoId, boolean darkTheme, boolean isCircular, DefaultImageRequest defaultImageRequest, DefaultImageProvider defaultProvider)385 public void loadThumbnail( 386 ImageView view, 387 long photoId, 388 boolean darkTheme, 389 boolean isCircular, 390 DefaultImageRequest defaultImageRequest, 391 DefaultImageProvider defaultProvider) { 392 if (photoId == 0) { 393 // No photo is needed 394 defaultProvider.applyDefaultImage(view, -1, darkTheme, defaultImageRequest); 395 pendingRequests.remove(view); 396 } else { 397 if (DEBUG) { 398 LogUtil.d("ContactPhotoManagerImpl.loadThumbnail", "loadPhoto request: " + photoId); 399 } 400 loadPhotoByIdOrUri( 401 view, Request.createFromThumbnailId(photoId, darkTheme, isCircular, defaultProvider)); 402 } 403 } 404 405 @Override loadPhoto( ImageView view, Uri photoUri, int requestedExtent, boolean darkTheme, boolean isCircular, DefaultImageRequest defaultImageRequest, DefaultImageProvider defaultProvider)406 public void loadPhoto( 407 ImageView view, 408 Uri photoUri, 409 int requestedExtent, 410 boolean darkTheme, 411 boolean isCircular, 412 DefaultImageRequest defaultImageRequest, 413 DefaultImageProvider defaultProvider) { 414 if (photoUri == null) { 415 // No photo is needed 416 defaultProvider.applyDefaultImage(view, requestedExtent, darkTheme, defaultImageRequest); 417 pendingRequests.remove(view); 418 return; 419 } 420 if (isDrawableUri(photoUri)) { 421 view.setImageURI(photoUri); 422 pendingRequests.remove(view); 423 return; 424 } 425 if (DEBUG) { 426 LogUtil.d("ContactPhotoManagerImpl.loadPhoto", "loadPhoto request: " + photoUri); 427 } 428 429 if (isDefaultImageUri(photoUri)) { 430 createAndApplyDefaultImageForUri( 431 view, photoUri, requestedExtent, darkTheme, isCircular, defaultProvider); 432 } else { 433 loadPhotoByIdOrUri( 434 view, 435 Request.createFromUri(photoUri, requestedExtent, darkTheme, isCircular, defaultProvider)); 436 } 437 } 438 isDrawableUri(Uri uri)439 private static boolean isDrawableUri(Uri uri) { 440 if (!ContentResolver.SCHEME_ANDROID_RESOURCE.equals(uri.getScheme())) { 441 return false; 442 } 443 return uri.getPathSegments().get(0).equals("drawable"); 444 } 445 createAndApplyDefaultImageForUri( ImageView view, Uri uri, int requestedExtent, boolean darkTheme, boolean isCircular, DefaultImageProvider defaultProvider)446 private void createAndApplyDefaultImageForUri( 447 ImageView view, 448 Uri uri, 449 int requestedExtent, 450 boolean darkTheme, 451 boolean isCircular, 452 DefaultImageProvider defaultProvider) { 453 DefaultImageRequest request = getDefaultImageRequestFromUri(uri); 454 request.isCircular = isCircular; 455 defaultProvider.applyDefaultImage(view, requestedExtent, darkTheme, request); 456 } 457 loadPhotoByIdOrUri(ImageView view, Request request)458 private void loadPhotoByIdOrUri(ImageView view, Request request) { 459 boolean loaded = loadCachedPhoto(view, request, false); 460 if (loaded) { 461 pendingRequests.remove(view); 462 } else { 463 pendingRequests.put(view, request); 464 if (!paused) { 465 // Send a request to start loading photos 466 requestLoading(); 467 } 468 } 469 } 470 471 @Override removePhoto(ImageView view)472 public void removePhoto(ImageView view) { 473 view.setImageDrawable(null); 474 pendingRequests.remove(view); 475 } 476 477 /** 478 * Cancels pending requests to load photos asynchronously for views inside {@param 479 * fragmentRootView}. If {@param fragmentRootView} is null, cancels all requests. 480 */ 481 @Override cancelPendingRequests(View fragmentRootView)482 public void cancelPendingRequests(View fragmentRootView) { 483 if (fragmentRootView == null) { 484 pendingRequests.clear(); 485 return; 486 } 487 final Iterator<Entry<ImageView, Request>> iterator = pendingRequests.entrySet().iterator(); 488 while (iterator.hasNext()) { 489 final ImageView imageView = iterator.next().getKey(); 490 // If an ImageView is orphaned (currently scrap) or a child of fragmentRootView, then 491 // we can safely remove its request. 492 if (imageView.getParent() == null || isChildView(fragmentRootView, imageView)) { 493 iterator.remove(); 494 } 495 } 496 } 497 498 @Override refreshCache()499 public void refreshCache() { 500 if (bitmapHolderCacheAllUnfresh) { 501 if (DEBUG) { 502 LogUtil.d("ContactPhotoManagerImpl.refreshCache", "refreshCache -- no fresh entries."); 503 } 504 return; 505 } 506 if (DEBUG) { 507 LogUtil.d("ContactPhotoManagerImpl.refreshCache", "refreshCache"); 508 } 509 bitmapHolderCacheAllUnfresh = true; 510 for (BitmapHolder holder : bitmapHolderCache.snapshot().values()) { 511 if (holder != BITMAP_UNAVAILABLE) { 512 holder.fresh = false; 513 } 514 } 515 } 516 517 /** 518 * Checks if the photo is present in cache. If so, sets the photo on the view. 519 * 520 * @return false if the photo needs to be (re)loaded from the provider. 521 */ 522 @UiThread loadCachedPhoto(ImageView view, Request request, boolean fadeIn)523 private boolean loadCachedPhoto(ImageView view, Request request, boolean fadeIn) { 524 BitmapHolder holder = bitmapHolderCache.get(request.getKey()); 525 if (holder == null) { 526 // The bitmap has not been loaded ==> show default avatar 527 request.applyDefaultImage(view, request.isCircular); 528 return false; 529 } 530 531 if (holder.bytes == null) { 532 request.applyDefaultImage(view, request.isCircular); 533 return holder.fresh; 534 } 535 536 Bitmap cachedBitmap = holder.bitmapRef == null ? null : holder.bitmapRef.get(); 537 if (cachedBitmap == null) { 538 request.applyDefaultImage(view, request.isCircular); 539 return false; 540 } 541 542 final Drawable previousDrawable = view.getDrawable(); 543 if (fadeIn && previousDrawable != null) { 544 final Drawable[] layers = new Drawable[2]; 545 // Prevent cascade of TransitionDrawables. 546 if (previousDrawable instanceof TransitionDrawable) { 547 final TransitionDrawable previousTransitionDrawable = (TransitionDrawable) previousDrawable; 548 layers[0] = 549 previousTransitionDrawable.getDrawable( 550 previousTransitionDrawable.getNumberOfLayers() - 1); 551 } else { 552 layers[0] = previousDrawable; 553 } 554 layers[1] = getDrawableForBitmap(context.getResources(), cachedBitmap, request); 555 TransitionDrawable drawable = new TransitionDrawable(layers); 556 view.setImageDrawable(drawable); 557 drawable.startTransition(FADE_TRANSITION_DURATION); 558 } else { 559 view.setImageDrawable(getDrawableForBitmap(context.getResources(), cachedBitmap, request)); 560 } 561 562 // Put the bitmap in the LRU cache. But only do this for images that are small enough 563 // (we require that at least six of those can be cached at the same time) 564 if (cachedBitmap.getByteCount() < bitmapCache.maxSize() / 6) { 565 bitmapCache.put(request.getKey(), cachedBitmap); 566 } 567 568 // Soften the reference 569 holder.bitmap = null; 570 571 return holder.fresh; 572 } 573 574 /** 575 * Given a bitmap, returns a drawable that is configured to display the bitmap based on the 576 * specified request. 577 */ getDrawableForBitmap(Resources resources, Bitmap bitmap, Request request)578 private Drawable getDrawableForBitmap(Resources resources, Bitmap bitmap, Request request) { 579 if (request.isCircular) { 580 final RoundedBitmapDrawable drawable = RoundedBitmapDrawableFactory.create(resources, bitmap); 581 drawable.setAntiAlias(true); 582 drawable.setCornerRadius(drawable.getIntrinsicHeight() / 2); 583 return drawable; 584 } else { 585 return new BitmapDrawable(resources, bitmap); 586 } 587 } 588 clear()589 public void clear() { 590 if (DEBUG) { 591 LogUtil.d("ContactPhotoManagerImpl.clear", "clear"); 592 } 593 pendingRequests.clear(); 594 bitmapHolderCache.evictAll(); 595 bitmapCache.evictAll(); 596 } 597 598 @Override pause()599 public void pause() { 600 paused = true; 601 } 602 603 @Override resume()604 public void resume() { 605 paused = false; 606 if (DEBUG) { 607 dumpStats(); 608 } 609 if (!pendingRequests.isEmpty()) { 610 requestLoading(); 611 } 612 } 613 614 /** 615 * Sends a message to this thread itself to start loading images. If the current view contains 616 * multiple image views, all of those image views will get a chance to request their respective 617 * photos before any of those requests are executed. This allows us to load images in bulk. 618 */ requestLoading()619 private void requestLoading() { 620 if (!loadingRequested) { 621 loadingRequested = true; 622 mainThreadHandler.sendEmptyMessage(MESSAGE_REQUEST_LOADING); 623 } 624 } 625 626 /** Processes requests on the main thread. */ 627 @Override handleMessage(Message msg)628 public boolean handleMessage(Message msg) { 629 switch (msg.what) { 630 case MESSAGE_REQUEST_LOADING: 631 { 632 loadingRequested = false; 633 if (!paused) { 634 ensureLoaderThread(); 635 loaderThread.requestLoading(); 636 } 637 return true; 638 } 639 640 case MESSAGE_PHOTOS_LOADED: 641 { 642 if (!paused) { 643 processLoadedImages(); 644 } 645 if (DEBUG) { 646 dumpStats(); 647 } 648 return true; 649 } 650 default: 651 return false; 652 } 653 } 654 ensureLoaderThread()655 public void ensureLoaderThread() { 656 if (loaderThread == null) { 657 loaderThread = new LoaderThread(context.getContentResolver()); 658 loaderThread.start(); 659 } 660 } 661 662 /** 663 * Goes over pending loading requests and displays loaded photos. If some of the photos still 664 * haven't been loaded, sends another request for image loading. 665 */ processLoadedImages()666 private void processLoadedImages() { 667 final Iterator<Entry<ImageView, Request>> iterator = pendingRequests.entrySet().iterator(); 668 while (iterator.hasNext()) { 669 final Entry<ImageView, Request> entry = iterator.next(); 670 // TODO: Temporarily disable contact photo fading in, until issues with 671 // RoundedBitmapDrawables overlapping the default image drawables are resolved. 672 final boolean loaded = loadCachedPhoto(entry.getKey(), entry.getValue(), false); 673 if (loaded) { 674 iterator.remove(); 675 } 676 } 677 678 softenCache(); 679 680 if (!pendingRequests.isEmpty()) { 681 requestLoading(); 682 } 683 } 684 685 /** 686 * Removes strong references to loaded bitmaps to allow them to be garbage collected if needed. 687 * Some of the bitmaps will still be retained by {@link #bitmapCache}. 688 */ softenCache()689 private void softenCache() { 690 for (BitmapHolder holder : bitmapHolderCache.snapshot().values()) { 691 holder.bitmap = null; 692 } 693 } 694 695 /** Stores the supplied bitmap in cache. */ cacheBitmap(Object key, byte[] bytes, boolean preloading, int requestedExtent)696 private void cacheBitmap(Object key, byte[] bytes, boolean preloading, int requestedExtent) { 697 if (DEBUG) { 698 BitmapHolder prev = bitmapHolderCache.get(key); 699 if (prev != null && prev.bytes != null) { 700 LogUtil.d( 701 "ContactPhotoManagerImpl.cacheBitmap", 702 "overwriting cache: key=" + key + (prev.fresh ? " FRESH" : " stale")); 703 if (prev.fresh) { 704 freshCacheOverwrite.incrementAndGet(); 705 } else { 706 staleCacheOverwrite.incrementAndGet(); 707 } 708 } 709 LogUtil.d( 710 "ContactPhotoManagerImpl.cacheBitmap", 711 "caching data: key=" + key + ", " + (bytes == null ? "<null>" : btk(bytes.length))); 712 } 713 BitmapHolder holder = 714 new BitmapHolder(bytes, bytes == null ? -1 : BitmapUtil.getSmallerExtentFromBytes(bytes)); 715 716 // Unless this image is being preloaded, decode it right away while 717 // we are still on the background thread. 718 if (!preloading) { 719 inflateBitmap(holder, requestedExtent); 720 } 721 722 if (bytes != null) { 723 bitmapHolderCache.put(key, holder); 724 if (bitmapHolderCache.get(key) != holder) { 725 LogUtil.w("ContactPhotoManagerImpl.cacheBitmap", "bitmap too big to fit in cache."); 726 bitmapHolderCache.put(key, BITMAP_UNAVAILABLE); 727 } 728 } else { 729 bitmapHolderCache.put(key, BITMAP_UNAVAILABLE); 730 } 731 732 bitmapHolderCacheAllUnfresh = false; 733 } 734 735 /** 736 * Populates an array of photo IDs that need to be loaded. Also decodes bitmaps that we have 737 * already loaded 738 */ obtainPhotoIdsAndUrisToLoad( Set<Long> photoIds, Set<String> photoIdsAsStrings, Set<Request> uris)739 private void obtainPhotoIdsAndUrisToLoad( 740 Set<Long> photoIds, Set<String> photoIdsAsStrings, Set<Request> uris) { 741 photoIds.clear(); 742 photoIdsAsStrings.clear(); 743 uris.clear(); 744 745 boolean jpegsDecoded = false; 746 747 /* 748 * Since the call is made from the loader thread, the map could be 749 * changing during the iteration. That's not really a problem: 750 * ConcurrentHashMap will allow those changes to happen without throwing 751 * exceptions. Since we may miss some requests in the situation of 752 * concurrent change, we will need to check the map again once loading 753 * is complete. 754 */ 755 Iterator<Request> iterator = pendingRequests.values().iterator(); 756 while (iterator.hasNext()) { 757 Request request = iterator.next(); 758 final BitmapHolder holder = bitmapHolderCache.get(request.getKey()); 759 if (holder == BITMAP_UNAVAILABLE) { 760 continue; 761 } 762 if (holder != null 763 && holder.bytes != null 764 && holder.fresh 765 && (holder.bitmapRef == null || holder.bitmapRef.get() == null)) { 766 // This was previously loaded but we don't currently have the inflated Bitmap 767 inflateBitmap(holder, request.getRequestedExtent()); 768 jpegsDecoded = true; 769 } else { 770 if (holder == null || !holder.fresh) { 771 if (request.isUriRequest()) { 772 uris.add(request); 773 } else { 774 photoIds.add(request.getId()); 775 photoIdsAsStrings.add(String.valueOf(request.id)); 776 } 777 } 778 } 779 } 780 781 if (jpegsDecoded) { 782 mainThreadHandler.sendEmptyMessage(MESSAGE_PHOTOS_LOADED); 783 } 784 } 785 786 /** Maintains the state of a particular photo. */ 787 private static class BitmapHolder { 788 789 final byte[] bytes; 790 final int originalSmallerExtent; 791 792 volatile boolean fresh; 793 Bitmap bitmap; 794 Reference<Bitmap> bitmapRef; 795 int decodedSampleSize; 796 BitmapHolder(byte[] bytes, int originalSmallerExtent)797 public BitmapHolder(byte[] bytes, int originalSmallerExtent) { 798 this.bytes = bytes; 799 this.fresh = true; 800 this.originalSmallerExtent = originalSmallerExtent; 801 } 802 } 803 804 /** 805 * A holder for either a Uri or an id and a flag whether this was requested for the dark or light 806 * theme 807 */ 808 private static final class Request { 809 810 private final long id; 811 private final Uri uri; 812 private final boolean darkTheme; 813 private final int requestedExtent; 814 private final DefaultImageProvider defaultProvider; 815 /** Whether or not the contact photo is to be displayed as a circle */ 816 private final boolean isCircular; 817 Request( long id, Uri uri, int requestedExtent, boolean darkTheme, boolean isCircular, DefaultImageProvider defaultProvider)818 private Request( 819 long id, 820 Uri uri, 821 int requestedExtent, 822 boolean darkTheme, 823 boolean isCircular, 824 DefaultImageProvider defaultProvider) { 825 this.id = id; 826 this.uri = uri; 827 this.darkTheme = darkTheme; 828 this.isCircular = isCircular; 829 this.requestedExtent = requestedExtent; 830 this.defaultProvider = defaultProvider; 831 } 832 createFromThumbnailId( long id, boolean darkTheme, boolean isCircular, DefaultImageProvider defaultProvider)833 public static Request createFromThumbnailId( 834 long id, boolean darkTheme, boolean isCircular, DefaultImageProvider defaultProvider) { 835 return new Request(id, null /* no URI */, -1, darkTheme, isCircular, defaultProvider); 836 } 837 createFromUri( Uri uri, int requestedExtent, boolean darkTheme, boolean isCircular, DefaultImageProvider defaultProvider)838 public static Request createFromUri( 839 Uri uri, 840 int requestedExtent, 841 boolean darkTheme, 842 boolean isCircular, 843 DefaultImageProvider defaultProvider) { 844 return new Request( 845 0 /* no ID */, uri, requestedExtent, darkTheme, isCircular, defaultProvider); 846 } 847 isUriRequest()848 public boolean isUriRequest() { 849 return uri != null; 850 } 851 getUri()852 public Uri getUri() { 853 return uri; 854 } 855 getId()856 public long getId() { 857 return id; 858 } 859 getRequestedExtent()860 public int getRequestedExtent() { 861 return requestedExtent; 862 } 863 864 @Override hashCode()865 public int hashCode() { 866 final int prime = 31; 867 int result = 1; 868 result = prime * result + (int) (id ^ (id >>> 32)); 869 result = prime * result + requestedExtent; 870 result = prime * result + ((uri == null) ? 0 : uri.hashCode()); 871 return result; 872 } 873 874 @Override equals(Object obj)875 public boolean equals(Object obj) { 876 if (this == obj) { 877 return true; 878 } 879 if (obj == null) { 880 return false; 881 } 882 if (getClass() != obj.getClass()) { 883 return false; 884 } 885 final Request that = (Request) obj; 886 if (id != that.id) { 887 return false; 888 } 889 if (requestedExtent != that.requestedExtent) { 890 return false; 891 } 892 if (!UriUtils.areEqual(uri, that.uri)) { 893 return false; 894 } 895 // Don't compare equality of mDarkTheme because it is only used in the default contact 896 // photo case. When the contact does have a photo, the contact photo is the same 897 // regardless of mDarkTheme, so we shouldn't need to put the photo request on the queue 898 // twice. 899 return true; 900 } 901 getKey()902 public Object getKey() { 903 return uri == null ? id : uri; 904 } 905 906 /** 907 * Applies the default image to the current view. If the request is URI-based, looks for the 908 * contact type encoded fragment to determine if this is a request for a business photo, in 909 * which case we will load the default business photo. 910 * 911 * @param view The current image view to apply the image to. 912 * @param isCircular Whether the image is circular or not. 913 */ applyDefaultImage(ImageView view, boolean isCircular)914 public void applyDefaultImage(ImageView view, boolean isCircular) { 915 final DefaultImageRequest request; 916 917 if (isCircular) { 918 request = 919 ContactPhotoManager.isBusinessContactUri(uri) 920 ? DefaultImageRequest.EMPTY_CIRCULAR_BUSINESS_IMAGE_REQUEST 921 : DefaultImageRequest.EMPTY_CIRCULAR_DEFAULT_IMAGE_REQUEST; 922 } else { 923 request = 924 ContactPhotoManager.isBusinessContactUri(uri) 925 ? DefaultImageRequest.EMPTY_DEFAULT_BUSINESS_IMAGE_REQUEST 926 : DefaultImageRequest.EMPTY_DEFAULT_IMAGE_REQUEST; 927 } 928 defaultProvider.applyDefaultImage(view, requestedExtent, darkTheme, request); 929 } 930 } 931 932 /** The thread that performs loading of photos from the database. */ 933 private class LoaderThread extends HandlerThread implements Callback { 934 935 private static final int BUFFER_SIZE = 1024 * 16; 936 private static final int MESSAGE_PRELOAD_PHOTOS = 0; 937 private static final int MESSAGE_LOAD_PHOTOS = 1; 938 939 /** A pause between preload batches that yields to the UI thread. */ 940 private static final int PHOTO_PRELOAD_DELAY = 1000; 941 942 /** Number of photos to preload per batch. */ 943 private static final int PRELOAD_BATCH = 25; 944 945 /** 946 * Maximum number of photos to preload. If the cache size is 2Mb and the expected average size 947 * of a photo is 4kb, then this number should be 2Mb/4kb = 500. 948 */ 949 private static final int MAX_PHOTOS_TO_PRELOAD = 100; 950 951 private static final int PRELOAD_STATUS_NOT_STARTED = 0; 952 private static final int PRELOAD_STATUS_IN_PROGRESS = 1; 953 private static final int PRELOAD_STATUS_DONE = 2; 954 private final ContentResolver resolver; 955 private final StringBuilder stringBuilder = new StringBuilder(); 956 private final Set<Long> photoIds = new HashSet<>(); 957 private final Set<String> photoIdsAsStrings = new HashSet<>(); 958 private final Set<Request> photoUris = new HashSet<>(); 959 private final List<Long> preloadPhotoIds = new ArrayList<>(); 960 private Handler loaderThreadHandler; 961 private byte[] buffer; 962 private int preloadStatus = PRELOAD_STATUS_NOT_STARTED; 963 LoaderThread(ContentResolver resolver)964 public LoaderThread(ContentResolver resolver) { 965 super(LOADER_THREAD_NAME); 966 this.resolver = resolver; 967 } 968 ensureHandler()969 public void ensureHandler() { 970 if (loaderThreadHandler == null) { 971 loaderThreadHandler = new Handler(getLooper(), this); 972 } 973 } 974 975 /** 976 * Kicks off preloading of the next batch of photos on the background thread. Preloading will 977 * happen after a delay: we want to yield to the UI thread as much as possible. 978 * 979 * <p>If preloading is already complete, does nothing. 980 */ requestPreloading()981 public void requestPreloading() { 982 if (preloadStatus == PRELOAD_STATUS_DONE) { 983 return; 984 } 985 986 ensureHandler(); 987 if (loaderThreadHandler.hasMessages(MESSAGE_LOAD_PHOTOS)) { 988 return; 989 } 990 991 loaderThreadHandler.sendEmptyMessageDelayed(MESSAGE_PRELOAD_PHOTOS, PHOTO_PRELOAD_DELAY); 992 } 993 994 /** 995 * Sends a message to this thread to load requested photos. Cancels a preloading request, if 996 * any: we don't want preloading to impede loading of the photos we need to display now. 997 */ requestLoading()998 public void requestLoading() { 999 ensureHandler(); 1000 loaderThreadHandler.removeMessages(MESSAGE_PRELOAD_PHOTOS); 1001 loaderThreadHandler.sendEmptyMessage(MESSAGE_LOAD_PHOTOS); 1002 } 1003 1004 /** 1005 * Receives the above message, loads photos and then sends a message to the main thread to 1006 * process them. 1007 */ 1008 @Override handleMessage(Message msg)1009 public boolean handleMessage(Message msg) { 1010 switch (msg.what) { 1011 case MESSAGE_PRELOAD_PHOTOS: 1012 preloadPhotosInBackground(); 1013 break; 1014 case MESSAGE_LOAD_PHOTOS: 1015 loadPhotosInBackground(); 1016 break; 1017 } 1018 return true; 1019 } 1020 1021 /** 1022 * The first time it is called, figures out which photos need to be preloaded. Each subsequent 1023 * call preloads the next batch of photos and requests another cycle of preloading after a 1024 * delay. The whole process ends when we either run out of photos to preload or fill up cache. 1025 */ 1026 @WorkerThread preloadPhotosInBackground()1027 private void preloadPhotosInBackground() { 1028 if (!PermissionsUtil.hasPermission(context, android.Manifest.permission.READ_CONTACTS)) { 1029 return; 1030 } 1031 1032 if (preloadStatus == PRELOAD_STATUS_DONE) { 1033 return; 1034 } 1035 1036 if (preloadStatus == PRELOAD_STATUS_NOT_STARTED) { 1037 queryPhotosForPreload(); 1038 if (preloadPhotoIds.isEmpty()) { 1039 preloadStatus = PRELOAD_STATUS_DONE; 1040 } else { 1041 preloadStatus = PRELOAD_STATUS_IN_PROGRESS; 1042 } 1043 requestPreloading(); 1044 return; 1045 } 1046 1047 if (bitmapHolderCache.size() > bitmapHolderCacheRedZoneBytes) { 1048 preloadStatus = PRELOAD_STATUS_DONE; 1049 return; 1050 } 1051 1052 photoIds.clear(); 1053 photoIdsAsStrings.clear(); 1054 1055 int count = 0; 1056 int preloadSize = preloadPhotoIds.size(); 1057 while (preloadSize > 0 && photoIds.size() < PRELOAD_BATCH) { 1058 preloadSize--; 1059 count++; 1060 Long photoId = preloadPhotoIds.get(preloadSize); 1061 photoIds.add(photoId); 1062 photoIdsAsStrings.add(photoId.toString()); 1063 preloadPhotoIds.remove(preloadSize); 1064 } 1065 1066 loadThumbnails(true); 1067 1068 if (preloadSize == 0) { 1069 preloadStatus = PRELOAD_STATUS_DONE; 1070 } 1071 1072 LogUtil.v( 1073 "ContactPhotoManagerImpl.preloadPhotosInBackground", 1074 "preloaded " + count + " photos. cached bytes: " + bitmapHolderCache.size()); 1075 1076 requestPreloading(); 1077 } 1078 1079 @WorkerThread queryPhotosForPreload()1080 private void queryPhotosForPreload() { 1081 Cursor cursor = null; 1082 try { 1083 Uri uri = 1084 Contacts.CONTENT_URI 1085 .buildUpon() 1086 .appendQueryParameter( 1087 ContactsContract.DIRECTORY_PARAM_KEY, String.valueOf(Directory.DEFAULT)) 1088 .appendQueryParameter( 1089 ContactsContract.LIMIT_PARAM_KEY, String.valueOf(MAX_PHOTOS_TO_PRELOAD)) 1090 .build(); 1091 cursor = 1092 resolver.query( 1093 uri, 1094 new String[] {Contacts.PHOTO_ID}, 1095 Contacts.PHOTO_ID + " NOT NULL AND " + Contacts.PHOTO_ID + "!=0", 1096 null, 1097 Contacts.STARRED + " DESC, " + Contacts.LAST_TIME_CONTACTED + " DESC"); 1098 1099 if (cursor != null) { 1100 while (cursor.moveToNext()) { 1101 // Insert them in reverse order, because we will be taking 1102 // them from the end of the list for loading. 1103 preloadPhotoIds.add(0, cursor.getLong(0)); 1104 } 1105 } 1106 } finally { 1107 if (cursor != null) { 1108 cursor.close(); 1109 } 1110 } 1111 } 1112 1113 @WorkerThread loadPhotosInBackground()1114 private void loadPhotosInBackground() { 1115 if (!PermissionsUtil.hasPermission(context, android.Manifest.permission.READ_CONTACTS)) { 1116 return; 1117 } 1118 obtainPhotoIdsAndUrisToLoad(photoIds, photoIdsAsStrings, photoUris); 1119 loadThumbnails(false); 1120 loadUriBasedPhotos(); 1121 requestPreloading(); 1122 } 1123 1124 /** Loads thumbnail photos with ids */ 1125 @WorkerThread loadThumbnails(boolean preloading)1126 private void loadThumbnails(boolean preloading) { 1127 if (photoIds.isEmpty()) { 1128 return; 1129 } 1130 1131 // Remove loaded photos from the preload queue: we don't want 1132 // the preloading process to load them again. 1133 if (!preloading && preloadStatus == PRELOAD_STATUS_IN_PROGRESS) { 1134 for (Long id : photoIds) { 1135 preloadPhotoIds.remove(id); 1136 } 1137 if (preloadPhotoIds.isEmpty()) { 1138 preloadStatus = PRELOAD_STATUS_DONE; 1139 } 1140 } 1141 1142 stringBuilder.setLength(0); 1143 stringBuilder.append(Photo._ID + " IN("); 1144 for (int i = 0; i < photoIds.size(); i++) { 1145 if (i != 0) { 1146 stringBuilder.append(','); 1147 } 1148 stringBuilder.append('?'); 1149 } 1150 stringBuilder.append(')'); 1151 1152 Cursor cursor = null; 1153 try { 1154 if (DEBUG) { 1155 LogUtil.d( 1156 "ContactPhotoManagerImpl.loadThumbnails", 1157 "loading " + TextUtils.join(",", photoIdsAsStrings)); 1158 } 1159 cursor = 1160 resolver.query( 1161 Data.CONTENT_URI, 1162 COLUMNS, 1163 stringBuilder.toString(), 1164 photoIdsAsStrings.toArray(EMPTY_STRING_ARRAY), 1165 null); 1166 1167 if (cursor != null) { 1168 while (cursor.moveToNext()) { 1169 Long id = cursor.getLong(0); 1170 byte[] bytes = cursor.getBlob(1); 1171 cacheBitmap(id, bytes, preloading, -1); 1172 photoIds.remove(id); 1173 } 1174 } 1175 } finally { 1176 if (cursor != null) { 1177 cursor.close(); 1178 } 1179 } 1180 1181 // Remaining photos were not found in the contacts database (but might be in profile). 1182 for (Long id : photoIds) { 1183 if (ContactsContract.isProfileId(id)) { 1184 Cursor profileCursor = null; 1185 try { 1186 profileCursor = 1187 resolver.query( 1188 ContentUris.withAppendedId(Data.CONTENT_URI, id), COLUMNS, null, null, null); 1189 if (profileCursor != null && profileCursor.moveToFirst()) { 1190 cacheBitmap(profileCursor.getLong(0), profileCursor.getBlob(1), preloading, -1); 1191 } else { 1192 // Couldn't load a photo this way either. 1193 cacheBitmap(id, null, preloading, -1); 1194 } 1195 } finally { 1196 if (profileCursor != null) { 1197 profileCursor.close(); 1198 } 1199 } 1200 } else { 1201 // Not a profile photo and not found - mark the cache accordingly 1202 cacheBitmap(id, null, preloading, -1); 1203 } 1204 } 1205 1206 mainThreadHandler.sendEmptyMessage(MESSAGE_PHOTOS_LOADED); 1207 } 1208 1209 /** 1210 * Loads photos referenced with Uris. Those can be remote thumbnails (from directory searches), 1211 * display photos etc 1212 */ 1213 @WorkerThread loadUriBasedPhotos()1214 private void loadUriBasedPhotos() { 1215 for (Request uriRequest : photoUris) { 1216 // Keep the original URI and use this to key into the cache. Failure to do so will 1217 // result in an image being continually reloaded into cache if the original URI 1218 // has a contact type encodedFragment (eg nearby places business photo URLs). 1219 Uri originalUri = uriRequest.getUri(); 1220 1221 // Strip off the "contact type" we added to the URI to ensure it was identifiable as 1222 // a business photo -- there is no need to pass this on to the server. 1223 Uri uri = ContactPhotoManager.removeContactType(originalUri); 1224 1225 if (buffer == null) { 1226 buffer = new byte[BUFFER_SIZE]; 1227 } 1228 try { 1229 if (DEBUG) { 1230 LogUtil.d("ContactPhotoManagerImpl.loadUriBasedPhotos", "loading " + uri); 1231 } 1232 final String scheme = uri.getScheme(); 1233 InputStream is = null; 1234 if (scheme.equals("http") || scheme.equals("https")) { 1235 TrafficStats.setThreadStatsTag(TrafficStatsTags.CONTACT_PHOTO_DOWNLOAD_TAG); 1236 try { 1237 final HttpURLConnection connection = 1238 (HttpURLConnection) new URL(uri.toString()).openConnection(); 1239 1240 // Include the user agent if it is specified. 1241 if (!TextUtils.isEmpty(userAgent)) { 1242 connection.setRequestProperty("User-Agent", userAgent); 1243 } 1244 try { 1245 is = connection.getInputStream(); 1246 } catch (IOException e) { 1247 connection.disconnect(); 1248 is = null; 1249 } 1250 } finally { 1251 TrafficStats.clearThreadStatsTag(); 1252 } 1253 } else { 1254 is = resolver.openInputStream(uri); 1255 } 1256 if (is != null) { 1257 ByteArrayOutputStream baos = new ByteArrayOutputStream(); 1258 try { 1259 int size; 1260 while ((size = is.read(buffer)) != -1) { 1261 baos.write(buffer, 0, size); 1262 } 1263 } finally { 1264 is.close(); 1265 } 1266 cacheBitmap(originalUri, baos.toByteArray(), false, uriRequest.getRequestedExtent()); 1267 mainThreadHandler.sendEmptyMessage(MESSAGE_PHOTOS_LOADED); 1268 } else { 1269 LogUtil.v("ContactPhotoManagerImpl.loadUriBasedPhotos", "cannot load photo " + uri); 1270 cacheBitmap(originalUri, null, false, uriRequest.getRequestedExtent()); 1271 } 1272 } catch (final Exception | OutOfMemoryError ex) { 1273 LogUtil.v("ContactPhotoManagerImpl.loadUriBasedPhotos", "cannot load photo " + uri, ex); 1274 cacheBitmap(originalUri, null, false, uriRequest.getRequestedExtent()); 1275 } 1276 } 1277 } 1278 } 1279 } 1280