1 /*
2  * Copyright (C) 2015 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.tv.util;
18 
19 import android.content.Context;
20 import android.graphics.Bitmap;
21 import android.graphics.drawable.BitmapDrawable;
22 import android.graphics.drawable.Drawable;
23 import android.media.tv.TvInputInfo;
24 import android.os.AsyncTask;
25 import android.os.Handler;
26 import android.os.Looper;
27 import android.support.annotation.MainThread;
28 import android.support.annotation.Nullable;
29 import android.support.annotation.UiThread;
30 import android.support.annotation.WorkerThread;
31 import android.util.ArraySet;
32 import android.util.Log;
33 
34 import com.android.tv.R;
35 import com.android.tv.util.BitmapUtils.ScaledBitmapInfo;
36 
37 import java.lang.ref.WeakReference;
38 import java.util.HashMap;
39 import java.util.Map;
40 import java.util.Set;
41 import java.util.concurrent.BlockingQueue;
42 import java.util.concurrent.Executor;
43 import java.util.concurrent.LinkedBlockingQueue;
44 import java.util.concurrent.RejectedExecutionException;
45 import java.util.concurrent.ThreadFactory;
46 import java.util.concurrent.ThreadPoolExecutor;
47 import java.util.concurrent.TimeUnit;
48 
49 /**
50  * This class wraps up completing some arbitrary long running work when loading a bitmap. It
51  * handles things like using a memory cache, running the work in a background thread.
52  */
53 public final class ImageLoader {
54     private static final String TAG = "ImageLoader";
55     private static final boolean DEBUG = false;
56 
57     private static final int CPU_COUNT = Runtime.getRuntime().availableProcessors();
58     // We want at least 2 threads and at most 4 threads in the core pool,
59     // preferring to have 1 less than the CPU count to avoid saturating
60     // the CPU with background work
61     private static final int CORE_POOL_SIZE = Math.max(2, Math.min(CPU_COUNT - 1, 4));
62     private static final int MAXIMUM_POOL_SIZE = CPU_COUNT * 2 + 1;
63     private static final int KEEP_ALIVE_SECONDS = 30;
64 
65     private static final ThreadFactory sThreadFactory = new NamedThreadFactory("ImageLoader");
66 
67     private static final BlockingQueue<Runnable> sPoolWorkQueue = new LinkedBlockingQueue<Runnable>(
68             128);
69 
70     /**
71      * An private {@link Executor} that can be used to execute tasks in parallel.
72      *
73      * <p>{@code IMAGE_THREAD_POOL_EXECUTOR} setting are copied from {@link AsyncTask}
74      * Since we do a lot of concurrent image loading we can exhaust a thread pool.
75      * ImageLoader catches the error, and just leaves the image blank.
76      * However other tasks will fail and crash the application.
77      *
78      * <p>Using a separate thread pool prevents image loading from causing other tasks to fail.
79      */
80     private static final Executor IMAGE_THREAD_POOL_EXECUTOR;
81 
82     static {
83         ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(CORE_POOL_SIZE,
84                 MAXIMUM_POOL_SIZE, KEEP_ALIVE_SECONDS, TimeUnit.SECONDS, sPoolWorkQueue,
85                 sThreadFactory);
86         threadPoolExecutor.allowCoreThreadTimeOut(true);
87         IMAGE_THREAD_POOL_EXECUTOR = threadPoolExecutor;
88     }
89 
90     private static Handler sMainHandler;
91 
92     /**
93      * Handles when image loading is finished.
94      *
95      * <p>Use this to prevent leaking an Activity or other Context while image loading is
96      *  still pending. When you extend this class you <strong>MUST NOT</strong> use a non static
97      *  inner class, or the containing object will still be leaked.
98      */
99     @UiThread
100     public static abstract class ImageLoaderCallback<T> {
101         private final WeakReference<T> mWeakReference;
102 
103         /**
104          * Creates an callback keeping a weak reference to {@code referent}.
105          *
106          * <p> If the "referent" is no longer valid, it no longer makes sense to run the
107          * callback. The referent is the View, or Activity or whatever that actually needs to
108          * receive the Bitmap.  If the referent has been GC, then no need to run the callback.
109          */
ImageLoaderCallback(T referent)110         public ImageLoaderCallback(T referent) {
111             mWeakReference = new WeakReference<>(referent);
112         }
113 
114         /**
115          * Called when bitmap is loaded.
116          */
onBitmapLoaded(@ullable Bitmap bitmap)117         private void onBitmapLoaded(@Nullable Bitmap bitmap) {
118             T referent = mWeakReference.get();
119             if (referent != null) {
120                 onBitmapLoaded(referent, bitmap);
121             } else {
122                 if (DEBUG) Log.d(TAG, "onBitmapLoaded not called because weak reference is gone");
123             }
124         }
125 
126         /**
127          * Called when bitmap is loaded if the weak reference is still valid.
128          */
onBitmapLoaded(T referent, @Nullable Bitmap bitmap)129         public abstract void onBitmapLoaded(T referent, @Nullable Bitmap bitmap);
130     }
131 
132     private static final Map<String, LoadBitmapTask> sPendingListMap = new HashMap<>();
133 
134     /**
135      * Preload a bitmap image into the cache.
136      *
137      * <p>Not to make heavy CPU load, AsyncTask.SERIAL_EXECUTOR is used for the image loading.
138      * <p>This method is thread safe.
139      */
prefetchBitmap(Context context, final String uriString, final int maxWidth, final int maxHeight)140     public static void prefetchBitmap(Context context, final String uriString, final int maxWidth,
141             final int maxHeight) {
142         if (DEBUG) Log.d(TAG, "prefetchBitmap() " + uriString);
143         if (Looper.getMainLooper() == Looper.myLooper()) {
144             doLoadBitmap(context, uriString, maxWidth, maxHeight, null, AsyncTask.SERIAL_EXECUTOR);
145         } else {
146             final Context appContext = context.getApplicationContext();
147             getMainHandler().post(new Runnable() {
148                 @Override
149                 @MainThread
150                 public void run() {
151                     // Calling from the main thread prevents a ConcurrentModificationException
152                     // in LoadBitmapTask.onPostExecute
153                     doLoadBitmap(appContext, uriString, maxWidth, maxHeight, null,
154                             AsyncTask.SERIAL_EXECUTOR);
155                 }
156             });
157         }
158     }
159 
160     /**
161      * Load a bitmap image with the cache using a ContentResolver.
162      *
163      * <p><b>Note</b> that the callback will be called synchronously if the bitmap already is in
164      * the cache.
165      *
166      * @return {@code true} if the load is complete and the callback is executed.
167      */
168     @UiThread
loadBitmap(Context context, String uriString, ImageLoaderCallback callback)169     public static boolean loadBitmap(Context context, String uriString,
170             ImageLoaderCallback callback) {
171         return loadBitmap(context, uriString, Integer.MAX_VALUE, Integer.MAX_VALUE, callback);
172     }
173 
174     /**
175      * Load a bitmap image with the cache and resize it with given params.
176      *
177      * <p><b>Note</b> that the callback will be called synchronously if the bitmap already is in
178      * the cache.
179      *
180      * @return {@code true} if the load is complete and the callback is executed.
181      */
182     @UiThread
loadBitmap(Context context, String uriString, int maxWidth, int maxHeight, ImageLoaderCallback callback)183     public static boolean loadBitmap(Context context, String uriString, int maxWidth, int maxHeight,
184             ImageLoaderCallback callback) {
185         if (DEBUG) {
186             Log.d(TAG, "loadBitmap() " + uriString);
187         }
188         return doLoadBitmap(context, uriString, maxWidth, maxHeight, callback,
189                 IMAGE_THREAD_POOL_EXECUTOR);
190     }
191 
doLoadBitmap(Context context, String uriString, int maxWidth, int maxHeight, ImageLoaderCallback callback, Executor executor)192     private static boolean doLoadBitmap(Context context, String uriString,
193             int maxWidth, int maxHeight, ImageLoaderCallback callback, Executor executor) {
194         // Check the cache before creating a Task.  The cache will be checked again in doLoadBitmap
195         // but checking a cache is much cheaper than creating an new task.
196         ImageCache imageCache = ImageCache.getInstance();
197         ScaledBitmapInfo bitmapInfo = imageCache.get(uriString);
198         if (bitmapInfo != null && !bitmapInfo.needToReload(maxWidth, maxHeight)) {
199             if (callback != null) {
200                 callback.onBitmapLoaded(bitmapInfo.bitmap);
201             }
202             return true;
203         }
204         return doLoadBitmap(callback, executor,
205                 new LoadBitmapFromUriTask(context, imageCache, uriString, maxWidth, maxHeight));
206     }
207 
208     /**
209      * Load a bitmap image with the cache and resize it with given params.
210      *
211      * <p>The LoadBitmapTask will be executed on a non ui thread.
212      *
213      * @return {@code true} if the load is complete and the callback is executed.
214      */
215     @UiThread
loadBitmap(ImageLoaderCallback callback, LoadBitmapTask loadBitmapTask)216     public static boolean loadBitmap(ImageLoaderCallback callback, LoadBitmapTask loadBitmapTask) {
217         if (DEBUG) {
218             Log.d(TAG, "loadBitmap() " + loadBitmapTask);
219         }
220         return doLoadBitmap(callback, IMAGE_THREAD_POOL_EXECUTOR, loadBitmapTask);
221     }
222 
223     /**
224      * @return {@code true} if the load is complete and the callback is executed.
225      */
226     @UiThread
doLoadBitmap(ImageLoaderCallback callback, Executor executor, LoadBitmapTask loadBitmapTask)227     private static boolean doLoadBitmap(ImageLoaderCallback callback, Executor executor,
228             LoadBitmapTask loadBitmapTask) {
229         ScaledBitmapInfo bitmapInfo = loadBitmapTask.getFromCache();
230         boolean needToReload = loadBitmapTask.isReloadNeeded();
231         if (bitmapInfo != null && !needToReload) {
232             if (callback != null) {
233                 callback.onBitmapLoaded(bitmapInfo.bitmap);
234             }
235             return true;
236         }
237         LoadBitmapTask existingTask = sPendingListMap.get(loadBitmapTask.getKey());
238         if (existingTask != null && !loadBitmapTask.isReloadNeeded(existingTask)) {
239             // The image loading is already scheduled and is large enough.
240             if (callback != null) {
241                 existingTask.mCallbacks.add(callback);
242             }
243         } else {
244             if (callback != null) {
245                 loadBitmapTask.mCallbacks.add(callback);
246             }
247             sPendingListMap.put(loadBitmapTask.getKey(), loadBitmapTask);
248             try {
249                 loadBitmapTask.executeOnExecutor(executor);
250             } catch (RejectedExecutionException e) {
251                 Log.e(TAG, "Failed to create new image loader", e);
252                 sPendingListMap.remove(loadBitmapTask.getKey());
253             }
254         }
255         return false;
256     }
257 
258     /**
259      * Loads and caches a a possibly scaled down version of a bitmap.
260      *
261      * <p>Implement {@link #doGetBitmapInBackground} to do the actual loading.
262      */
263     public static abstract class LoadBitmapTask extends AsyncTask<Void, Void, ScaledBitmapInfo> {
264         protected final Context mAppContext;
265         protected final int mMaxWidth;
266         protected final int mMaxHeight;
267         private final Set<ImageLoaderCallback> mCallbacks = new ArraySet<>();
268         private final ImageCache mImageCache;
269         private final String mKey;
270 
271         /**
272          * Returns true if a reload is needed compared to current results in the cache or false if
273          * there is not match in the cache.
274          */
isReloadNeeded()275         private boolean isReloadNeeded() {
276             ScaledBitmapInfo bitmapInfo = getFromCache();
277             boolean needToReload = bitmapInfo != null && bitmapInfo
278                     .needToReload(mMaxWidth, mMaxHeight);
279             if (DEBUG) {
280                 if (needToReload) {
281                     Log.d(TAG, "Bitmap needs to be reloaded. {"
282                             + "originalWidth=" + bitmapInfo.bitmap.getWidth()
283                             + ", originalHeight=" + bitmapInfo.bitmap.getHeight()
284                             + ", reqWidth=" + mMaxWidth
285                             + ", reqHeight=" + mMaxHeight
286                             + "}");
287                 }
288             }
289             return needToReload;
290         }
291 
292         /**
293          * Checks if a reload would be needed if the results of other was available.
294          */
isReloadNeeded(LoadBitmapTask other)295         private boolean isReloadNeeded(LoadBitmapTask other) {
296             return mMaxHeight >= other.mMaxHeight * 2 || mMaxWidth >= other.mMaxWidth * 2;
297         }
298 
299         @Nullable
getFromCache()300         public final ScaledBitmapInfo getFromCache() {
301             return mImageCache.get(mKey);
302         }
303 
LoadBitmapTask(Context context, ImageCache imageCache, String key, int maxHeight, int maxWidth)304         public LoadBitmapTask(Context context, ImageCache imageCache, String key, int maxHeight,
305                 int maxWidth) {
306             if (maxWidth == 0 || maxHeight == 0) {
307                 throw new IllegalArgumentException(
308                         "Image size should not be 0. {width=" + maxWidth + ", height=" + maxHeight
309                                 + "}");
310             }
311             mAppContext = context.getApplicationContext();
312             mKey = key;
313             mImageCache = imageCache;
314             mMaxHeight = maxHeight;
315             mMaxWidth = maxWidth;
316         }
317 
318         /**
319          * Loads the bitmap returning a possibly scaled down version.
320          */
321         @Nullable
322         @WorkerThread
doGetBitmapInBackground()323         public abstract ScaledBitmapInfo doGetBitmapInBackground();
324 
325         @Override
326         @Nullable
doInBackground(Void... params)327         public final ScaledBitmapInfo doInBackground(Void... params) {
328             ScaledBitmapInfo bitmapInfo = getFromCache();
329             if (bitmapInfo != null && !isReloadNeeded()) {
330                 return bitmapInfo;
331             }
332             bitmapInfo = doGetBitmapInBackground();
333             if (bitmapInfo != null) {
334                 mImageCache.putIfNeeded(bitmapInfo);
335             }
336             return bitmapInfo;
337         }
338 
339         @Override
onPostExecute(ScaledBitmapInfo scaledBitmapInfo)340         public final void onPostExecute(ScaledBitmapInfo scaledBitmapInfo) {
341             if (DEBUG) Log.d(ImageLoader.TAG, "Bitmap is loaded " + mKey);
342 
343             for (ImageLoader.ImageLoaderCallback callback : mCallbacks) {
344                 callback.onBitmapLoaded(scaledBitmapInfo == null ? null : scaledBitmapInfo.bitmap);
345             }
346             ImageLoader.sPendingListMap.remove(mKey);
347         }
348 
getKey()349         public final String getKey() {
350             return mKey;
351         }
352 
353         @Override
toString()354         public String toString() {
355             return this.getClass().getSimpleName() + "(" + mKey + " " + mMaxWidth + "x" + mMaxHeight
356                     + ")";
357         }
358     }
359 
360     private static final class LoadBitmapFromUriTask extends LoadBitmapTask {
LoadBitmapFromUriTask(Context context, ImageCache imageCache, String uriString, int maxWidth, int maxHeight)361         private LoadBitmapFromUriTask(Context context, ImageCache imageCache, String uriString,
362                 int maxWidth, int maxHeight) {
363             super(context, imageCache, uriString, maxHeight, maxWidth);
364         }
365 
366         @Override
367         @Nullable
doGetBitmapInBackground()368         public final ScaledBitmapInfo doGetBitmapInBackground() {
369             return BitmapUtils
370                     .decodeSampledBitmapFromUriString(mAppContext, getKey(), mMaxWidth, mMaxHeight);
371         }
372     }
373 
374     /**
375      * Loads and caches the logo for a given {@link TvInputInfo}
376      */
377     public static final class LoadTvInputLogoTask extends LoadBitmapTask {
378         private final TvInputInfo mInfo;
379 
LoadTvInputLogoTask(Context context, ImageCache cache, TvInputInfo info)380         public LoadTvInputLogoTask(Context context, ImageCache cache, TvInputInfo info) {
381             super(context,
382                     cache,
383                     info.getId() + "-logo",
384                     context.getResources()
385                             .getDimensionPixelSize(R.dimen.channel_banner_input_logo_size),
386                     context.getResources()
387                             .getDimensionPixelSize(R.dimen.channel_banner_input_logo_size)
388             );
389             mInfo = info;
390         }
391 
392         @Nullable
393         @Override
doGetBitmapInBackground()394         public ScaledBitmapInfo doGetBitmapInBackground() {
395             Drawable drawable = mInfo.loadIcon(mAppContext);
396             if (!(drawable instanceof BitmapDrawable)) {
397                 return null;
398             }
399             Bitmap original = ((BitmapDrawable) drawable).getBitmap();
400             if (original == null) {
401                 return null;
402             }
403             return BitmapUtils.createScaledBitmapInfo(getKey(), original, mMaxWidth, mMaxHeight);
404         }
405     }
406 
getMainHandler()407     private static synchronized Handler getMainHandler() {
408         if (sMainHandler == null) {
409             sMainHandler = new Handler(Looper.getMainLooper());
410         }
411         return sMainHandler;
412     }
413 
ImageLoader()414     private ImageLoader() {
415     }
416 }
417