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