Use thumbnail of other sizes if it's missing in current size.

Bug: 26881628

Change-Id: Id7aa6f5c8c1a415f7dd97143a088ba89fae43eea
diff --git a/src/com/android/documentsui/DocumentsApplication.java b/src/com/android/documentsui/DocumentsApplication.java
index 5ea6cfa..cb9ce25 100644
--- a/src/com/android/documentsui/DocumentsApplication.java
+++ b/src/com/android/documentsui/DocumentsApplication.java
@@ -24,7 +24,6 @@
 import android.content.Context;
 import android.content.Intent;
 import android.content.IntentFilter;
-import android.graphics.Point;
 import android.net.Uri;
 import android.os.RemoteException;
 import android.text.format.DateUtils;
@@ -33,21 +32,16 @@
     private static final long PROVIDER_ANR_TIMEOUT = 20 * DateUtils.SECOND_IN_MILLIS;
 
     private RootsCache mRoots;
-    private Point mThumbnailsSize;
-    private ThumbnailCache mThumbnails;
+
+    private ThumbnailCache mThumbnailCache;
 
     public static RootsCache getRootsCache(Context context) {
         return ((DocumentsApplication) context.getApplicationContext()).mRoots;
     }
 
-    public static ThumbnailCache getThumbnailsCache(Context context, Point size) {
+    public static ThumbnailCache getThumbnailCache(Context context) {
         final DocumentsApplication app = (DocumentsApplication) context.getApplicationContext();
-        final ThumbnailCache thumbnails = app.mThumbnails;
-        if (!size.equals(app.mThumbnailsSize)) {
-            thumbnails.evictAll();
-            app.mThumbnailsSize = size;
-        }
-        return thumbnails;
+        return app.mThumbnailCache;
     }
 
     public static ContentProviderClient acquireUnstableProviderOrThrow(
@@ -71,7 +65,7 @@
         mRoots = new RootsCache(this);
         mRoots.updateAsync(false);
 
-        mThumbnails = new ThumbnailCache(memoryClassBytes / 4);
+        mThumbnailCache = new ThumbnailCache(memoryClassBytes / 4);
 
         final IntentFilter packageFilter = new IntentFilter();
         packageFilter.addAction(Intent.ACTION_PACKAGE_ADDED);
@@ -90,11 +84,7 @@
     public void onTrimMemory(int level) {
         super.onTrimMemory(level);
 
-        if (level >= TRIM_MEMORY_MODERATE) {
-            mThumbnails.evictAll();
-        } else if (level >= TRIM_MEMORY_BACKGROUND) {
-            mThumbnails.trimToSize(mThumbnails.size() / 2);
-        }
+        mThumbnailCache.onTrimMemory(level);
     }
 
     private BroadcastReceiver mCacheReceiver = new BroadcastReceiver() {
diff --git a/src/com/android/documentsui/ThumbnailCache.java b/src/com/android/documentsui/ThumbnailCache.java
index ad7cbf6..25cf806 100644
--- a/src/com/android/documentsui/ThumbnailCache.java
+++ b/src/com/android/documentsui/ThumbnailCache.java
@@ -1,5 +1,5 @@
 /*
- * Copyright (C) 2013 The Android Open Source Project
+ * Copyright (C) 2016 The Android Open Source Project
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -16,17 +16,243 @@
 
 package com.android.documentsui;
 
+import android.annotation.IntDef;
+import android.annotation.Nullable;
+import android.content.ComponentCallbacks2;
 import android.graphics.Bitmap;
+import android.graphics.Point;
 import android.net.Uri;
 import android.util.LruCache;
+import android.util.Pair;
+import android.util.Pools;
 
-public class ThumbnailCache extends LruCache<Uri, Bitmap> {
-    public ThumbnailCache(int maxSizeBytes) {
-        super(maxSizeBytes);
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.TreeMap;
+
+/**
+ * An LRU cache that supports finding the thumbnail of the requested uri with a different size than
+ * the requested one.
+ */
+public class ThumbnailCache {
+
+    private static final SizeComparator SIZE_COMPARATOR = new SizeComparator();
+
+    /**
+     * A 2-dimensional index into {@link #mCache} entries. Pair<Uri, Point> is the key to
+     * {@link #mCache}. TreeMap is used to search the closest size to a given size and a given uri.
+     */
+    private final HashMap<Uri, TreeMap<Point, Pair<Uri, Point>>> mSizeIndex;
+    private final Cache mCache;
+
+    /**
+     * Creates a thumbnail LRU cache.
+     *
+     * @param maxCacheSizeInBytes the maximum size of thumbnails in bytes this cache can hold.
+     */
+    public ThumbnailCache(int maxCacheSizeInBytes) {
+        mSizeIndex = new HashMap<>();
+        mCache = new Cache(maxCacheSizeInBytes);
     }
 
-    @Override
-    protected int sizeOf(Uri key, Bitmap value) {
-        return value.getByteCount();
+    /**
+     * Obtains thumbnail given a uri and a size.
+     *
+     * @param uri the uri of the thumbnail in need
+     * @param size the desired size of the thumbnail
+     * @return the thumbnail result
+     */
+    public Result getThumbnail(Uri uri, Point size) {
+        Result result = Result.obtain(Result.CACHE_MISS, null, null);
+
+        TreeMap<Point, Pair<Uri, Point>> sizeMap;
+        sizeMap = mSizeIndex.get(uri);
+        if (sizeMap == null || sizeMap.isEmpty()) {
+            // There is not any thumbnail for this uri.
+            return result;
+        }
+
+        // Look for thumbnail of the same size.
+        Pair<Uri, Point> cacheKey = sizeMap.get(size);
+        if (cacheKey != null) {
+            Bitmap thumbnail = mCache.get(cacheKey);
+            if (thumbnail != null) {
+                result.mStatus = Result.CACHE_HIT_EXACT;
+                result.mThumbnail = thumbnail;
+                result.mSize = size;
+                return result;
+            }
+        }
+
+        // Look for thumbnail of bigger sizes.
+        Point otherSize = sizeMap.higherKey(size);
+        if (otherSize != null) {
+            cacheKey = sizeMap.get(otherSize);
+
+            if (cacheKey != null) {
+                Bitmap thumbnail = mCache.get(cacheKey);
+                if (thumbnail != null) {
+                    result.mStatus = Result.CACHE_HIT_LARGER;
+                    result.mThumbnail = thumbnail;
+                    result.mSize = otherSize;
+                    return result;
+                }
+            }
+        }
+
+        // Look for thumbnail of smaller sizes.
+        otherSize = sizeMap.lowerKey(size);
+        if (otherSize != null) {
+            cacheKey = sizeMap.get(otherSize);
+
+            if (cacheKey != null) {
+                Bitmap thumbnail = mCache.get(cacheKey);
+                if (thumbnail != null) {
+                    result.mStatus = Result.CACHE_HIT_SMALLER;
+                    result.mThumbnail = thumbnail;
+                    result.mSize = otherSize;
+                    return result;
+                }
+            }
+        }
+
+        // Cache miss.
+        return result;
+    }
+
+    public void putThumbnail(Uri uri, Point size, Bitmap thumbnail) {
+        Pair<Uri, Point> cacheKey = Pair.create(uri, size);
+
+        TreeMap<Point, Pair<Uri, Point>> sizeMap;
+        synchronized (mSizeIndex) {
+            sizeMap = mSizeIndex.get(uri);
+            if (sizeMap == null) {
+                sizeMap = new TreeMap<>(SIZE_COMPARATOR);
+                mSizeIndex.put(uri, sizeMap);
+            }
+        }
+
+        mCache.put(cacheKey, thumbnail);
+        synchronized (sizeMap) {
+            sizeMap.put(size, cacheKey);
+        }
+    }
+
+    public void onTrimMemory(int level) {
+        if (level >= ComponentCallbacks2.TRIM_MEMORY_MODERATE) {
+            synchronized (mSizeIndex) {
+                mSizeIndex.clear();
+            }
+            mCache.evictAll();
+        } else if (level >= ComponentCallbacks2.TRIM_MEMORY_BACKGROUND) {
+            mCache.trimToSize(mCache.size() / 2);
+        }
+    }
+
+    /**
+     * A class that holds thumbnail and cache status.
+     */
+    public static final class Result {
+
+        @Retention(RetentionPolicy.SOURCE)
+        @IntDef({CACHE_MISS, CACHE_HIT_EXACT, CACHE_HIT_SMALLER, CACHE_HIT_LARGER})
+        @interface Status {}
+
+        /**
+         * Indicates there is no thumbnail for the requested uri. The thumbnail will be null.
+         */
+        public static final int CACHE_MISS = 0;
+        /**
+         * Indicates the thumbnail matches the requested size and requested uri.
+         */
+        public static final int CACHE_HIT_EXACT = 1;
+        /**
+         * Indicates the thumbnail is in a smaller size than the requested one from the requested
+         * uri.
+         */
+        public static final int CACHE_HIT_SMALLER = 2;
+        /**
+         * Indicates the thumbnail is in a larger size than the requested one from the requested
+         * uri.
+         */
+        public static final int CACHE_HIT_LARGER = 3;
+
+        private static final Pools.SimplePool<Result> sPool = new Pools.SimplePool<>(1);
+
+        private @Status int mStatus;
+
+        private @Nullable Bitmap mThumbnail;
+
+        private @Nullable Point mSize;
+
+        private static Result obtain(@Status int status, @Nullable Bitmap thumbnail,
+                @Nullable Point size) {
+            Result instance = sPool.acquire();
+            instance = (instance != null ? instance : new Result());
+
+            instance.mStatus = status;
+            instance.mThumbnail = thumbnail;
+            instance.mSize = size;
+
+            return instance;
+        }
+
+        private Result() {
+        }
+
+        public void recycle() {
+            mStatus = -1;
+            mThumbnail = null;
+            mSize = null;
+
+            boolean released = sPool.release(this);
+            // This assert is used to guarantee we won't generate too many instances that can't be
+            // held in the pool, which indicates our pool size is too small.
+            //
+            // Right now one instance is enough because we expect all instances are only used in
+            // main thread.
+            assert (released);
+        }
+
+        public @Status int getStatus() {
+            return mStatus;
+        }
+
+        public @Nullable Bitmap getThumbnail() {
+            return mThumbnail;
+        }
+
+        public @Nullable Point getSize() {
+            return mSize;
+        }
+
+        public boolean isHit() {
+            return (mStatus != CACHE_MISS);
+        }
+
+        public boolean isExactHit() {
+            return (mStatus == CACHE_HIT_EXACT);
+        }
+    }
+
+    private static final class Cache extends LruCache<Pair<Uri, Point>, Bitmap> {
+        private Cache(int maxSizeBytes) {
+            super(maxSizeBytes);
+        }
+
+        @Override
+        protected int sizeOf(Pair<Uri, Point> key, Bitmap value) {
+            return value.getByteCount();
+        }
+    }
+
+    private static final class SizeComparator implements Comparator<Point> {
+        @Override
+        public int compare(Point size0, Point size1) {
+            // Assume all sizes are roughly square, so we only compare them in one dimension.
+            return size0.x - size1.x;
+        }
     }
 }
diff --git a/src/com/android/documentsui/dirlist/GridDocumentHolder.java b/src/com/android/documentsui/dirlist/GridDocumentHolder.java
index c4f6f11..8b10257 100644
--- a/src/com/android/documentsui/dirlist/GridDocumentHolder.java
+++ b/src/com/android/documentsui/dirlist/GridDocumentHolder.java
@@ -135,8 +135,7 @@
         mIconThumb.setAlpha(0f);
 
         final Uri uri = DocumentsContract.buildDocumentUri(docAuthority, docId);
-        mIconHelper.loadThumbnail(uri, docMimeType, docFlags, docIcon, mIconThumb, mIconMimeLg,
-                mIconMimeSm);
+        mIconHelper.load(uri, docMimeType, docFlags, docIcon, mIconThumb, mIconMimeLg, mIconMimeSm);
 
         if (mHideTitles) {
             mTitle.setVisibility(View.GONE);
diff --git a/src/com/android/documentsui/dirlist/IconHelper.java b/src/com/android/documentsui/dirlist/IconHelper.java
index ff0f4b1..d1f792e 100644
--- a/src/com/android/documentsui/dirlist/IconHelper.java
+++ b/src/com/android/documentsui/dirlist/IconHelper.java
@@ -34,6 +34,7 @@
 import android.provider.DocumentsContract.Document;
 import android.support.annotation.Nullable;
 import android.util.Log;
+import android.view.View;
 import android.widget.ImageView;
 
 import com.android.documentsui.DocumentsApplication;
@@ -45,21 +46,33 @@
 import com.android.documentsui.State;
 import com.android.documentsui.State.ViewMode;
 import com.android.documentsui.ThumbnailCache;
+import com.android.documentsui.ThumbnailCache.Result;
+
+import java.util.function.BiConsumer;
 
 /**
  * A class to assist with loading and managing the Images (i.e. thumbnails and icons) associated
  * with items in the directory listing.
  */
 public class IconHelper {
-    private static String TAG = "IconHelper";
+    private static final String TAG = "IconHelper";
+
+    // Two animations applied to image views. The first is used to switch mime icon and thumbnail.
+    // The second is used when we need to update thumbnail.
+    private static final BiConsumer<View, View> ANIM_FADE_IN = (mime, thumb) -> {
+        float alpha = mime.getAlpha();
+        mime.animate().alpha(0f).start();
+        thumb.setAlpha(0f);
+        thumb.animate().alpha(alpha).start();
+    };
+    private static final BiConsumer<View, View> ANIM_NO_OP = (mime, thumb) -> {};
 
     private final Context mContext;
+    private final ThumbnailCache mThumbnailCache;
 
-    // Updated when icon size is set.
-    private ThumbnailCache mCache;
-    private Point mThumbSize;
     // The display mode (MODE_GRID, MODE_LIST, etc).
     private int mMode;
+    private Point mCurrentSize;
     private boolean mThumbnailsEnabled = true;
 
     /**
@@ -69,7 +82,7 @@
     public IconHelper(Context context, int mode) {
         mContext = context;
         setViewMode(mode);
-        mCache = DocumentsApplication.getThumbnailsCache(context, mThumbSize);
+        mThumbnailCache = DocumentsApplication.getThumbnailCache(context);
     }
 
     /**
@@ -83,14 +96,14 @@
     }
 
     /**
-     * Sets the current display mode.  This affects the thumbnail sizes that are loaded.
+     * Sets the current display mode. This affects the thumbnail sizes that are loaded.
+     *
      * @param mode See {@link State.MODE_LIST} and {@link State.MODE_GRID}.
      */
     public void setViewMode(@ViewMode int mode) {
         mMode = mode;
         int thumbSize = getThumbSize(mode);
-        mThumbSize = new Point(thumbSize, thumbSize);
-        mCache = DocumentsApplication.getThumbnailsCache(mContext, mThumbSize);
+        mCurrentSize = new Point(thumbSize, thumbSize);
     }
 
     private int getThumbSize(int mode) {
@@ -111,6 +124,7 @@
 
     /**
      * Cancels any ongoing load operations associated with the given ImageView.
+     *
      * @param icon
      */
     public void stopLoading(ImageView icon) {
@@ -129,14 +143,19 @@
         private final ImageView mIconMime;
         private final ImageView mIconThumb;
         private final Point mThumbSize;
+
+        // A callback to apply animation to image views after the thumbnail is loaded.
+        private final BiConsumer<View, View> mImageAnimator;
+
         private final CancellationSignal mSignal;
 
         public LoaderTask(Uri uri, ImageView iconMime, ImageView iconThumb,
-                Point thumbSize) {
+                Point thumbSize, BiConsumer<View, View> animator) {
             mUri = uri;
             mIconMime = iconMime;
             mIconThumb = iconThumb;
             mThumbSize = thumbSize;
+            mImageAnimator = animator;
             mSignal = new CancellationSignal();
             if (DEBUG) Log.d(TAG, "Starting icon loader task for " + mUri);
         }
@@ -150,8 +169,9 @@
 
         @Override
         protected Bitmap doInBackground(Uri... params) {
-            if (isCancelled())
+            if (isCancelled()) {
                 return null;
+            }
 
             final Context context = mIconThumb.getContext();
             final ContentResolver resolver = context.getContentResolver();
@@ -163,9 +183,8 @@
                         resolver, mUri.getAuthority());
                 result = DocumentsContract.getDocumentThumbnail(client, mUri, mThumbSize, mSignal);
                 if (result != null) {
-                    final ThumbnailCache thumbs = DocumentsApplication.getThumbnailsCache(
-                            context, mThumbSize);
-                    thumbs.put(mUri, result);
+                    final ThumbnailCache cache = DocumentsApplication.getThumbnailCache(context);
+                    cache.putThumbnail(mUri, mThumbSize, result);
                 }
             } catch (Exception e) {
                 if (!(e instanceof OperationCanceledException)) {
@@ -185,16 +204,14 @@
                 mIconThumb.setTag(null);
                 mIconThumb.setImageBitmap(result);
 
-                float alpha = mIconMime.getAlpha();
-                mIconMime.animate().alpha(0f).start();
-                mIconThumb.setAlpha(0f);
-                mIconThumb.animate().alpha(alpha).start();
+                mImageAnimator.accept(mIconMime, mIconThumb);
             }
         }
     }
 
     /**
      * Load thumbnails for a directory list item.
+     *
      * @param uri The URI for the file being represented.
      * @param mimeType The mime type of the file being represented.
      * @param docFlags Flags for the file being represented.
@@ -204,9 +221,9 @@
      * @param subIconMime The second itemview's mime icon. Always visible.
      * @return
      */
-    public void loadThumbnail(Uri uri, String mimeType, int docFlags, int docIcon,
+    public void load(Uri uri, String mimeType, int docFlags, int docIcon,
             ImageView iconThumb, ImageView iconMime, @Nullable ImageView subIconMime) {
-        boolean cacheHit = false;
+        boolean loadedThumbnail = false;
 
         final String docAuthority = uri.getAuthority();
 
@@ -215,39 +232,59 @@
                 || MimePredicate.mimeMatches(MimePredicate.VISUAL_MIMES, mimeType);
         final boolean showThumbnail = supportsThumbnail && allowThumbnail && mThumbnailsEnabled;
         if (showThumbnail) {
-            final Bitmap cachedResult = mCache.get(uri);
-            if (cachedResult != null) {
-                iconThumb.setImageBitmap(cachedResult);
-                cacheHit = true;
-            } else {
-                iconThumb.setImageDrawable(null);
-                final LoaderTask task = new LoaderTask(uri, iconMime, iconThumb, mThumbSize);
-                iconThumb.setTag(task);
-                ProviderExecutor.forAuthority(docAuthority).execute(task);
-            }
+            loadedThumbnail = loadThumbnail(uri, docAuthority, iconThumb, iconMime);
         }
 
-        final Drawable icon = getDocumentIcon(mContext, docAuthority,
+        final Drawable mimeIcon = getDocumentIcon(mContext, docAuthority,
                 DocumentsContract.getDocumentId(uri), mimeType, docIcon);
         if (subIconMime != null) {
-            subIconMime.setImageDrawable(icon);
+            setMimeIcon(subIconMime, mimeIcon);
         }
 
-        if (cacheHit) {
-            iconMime.setImageDrawable(null);
-            iconMime.setAlpha(0f);
-            iconThumb.setAlpha(1f);
+        if (loadedThumbnail) {
+            hideImageView(iconMime);
         } else {
-            // Add a mime icon if the thumbnail is being loaded in the background.
-            iconThumb.setImageDrawable(null);
-            iconMime.setImageDrawable(icon);
-            iconMime.setAlpha(1f);
-            iconThumb.setAlpha(0f);
+            // Add a mime icon if the thumbnail is not shown.
+            setMimeIcon(iconMime, mimeIcon);
+            hideImageView(iconThumb);
         }
     }
 
+    private boolean loadThumbnail(Uri uri, String docAuthority, ImageView iconThumb,
+            ImageView iconMime) {
+        final Result result = mThumbnailCache.getThumbnail(uri, mCurrentSize);
+
+        final Bitmap cachedThumbnail = result.getThumbnail();
+        iconThumb.setImageBitmap(cachedThumbnail);
+
+        if (!result.isExactHit()) {
+            final BiConsumer<View, View> animator =
+                    (cachedThumbnail == null ? ANIM_FADE_IN : ANIM_NO_OP);
+            final LoaderTask task =
+                    new LoaderTask(uri, iconMime, iconThumb, mCurrentSize, animator);
+
+            iconThumb.setTag(task);
+
+            ProviderExecutor.forAuthority(docAuthority).execute(task);
+        }
+        result.recycle();
+
+        return result.isHit();
+    }
+
+    private void setMimeIcon(ImageView view, Drawable icon) {
+        view.setImageDrawable(icon);
+        view.setAlpha(1f);
+    }
+
+    private void hideImageView(ImageView view) {
+        view.setImageDrawable(null);
+        view.setAlpha(0f);
+    }
+
     /**
      * Gets a mime icon or package icon for a file.
+     *
      * @param context
      * @param authority The authority string of the file.
      * @param id The document ID of the file.
@@ -263,5 +300,4 @@
             return IconUtils.loadMimeIcon(context, mimeType, authority, id, mMode);
         }
     }
-
 }
diff --git a/src/com/android/documentsui/dirlist/ListDocumentHolder.java b/src/com/android/documentsui/dirlist/ListDocumentHolder.java
index ace53e0..98916a1 100644
--- a/src/com/android/documentsui/dirlist/ListDocumentHolder.java
+++ b/src/com/android/documentsui/dirlist/ListDocumentHolder.java
@@ -133,7 +133,7 @@
         mIconThumb.setAlpha(0f);
 
         final Uri uri = DocumentsContract.buildDocumentUri(docAuthority, docId);
-        mIconHelper.loadThumbnail(uri, docMimeType, docFlags, docIcon, mIconThumb, mIconMime, null);
+        mIconHelper.load(uri, docMimeType, docFlags, docIcon, mIconThumb, mIconMime, null);
 
         mTitle.setText(docDisplayName, TextView.BufferType.SPANNABLE);
         mTitle.setVisibility(View.VISIBLE);