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