• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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