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