/* * Copyright (C) 2015 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. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.android.tv.util.images; import android.content.Context; import android.graphics.Bitmap; import android.graphics.drawable.BitmapDrawable; import android.graphics.drawable.Drawable; import android.media.tv.TvInputInfo; import android.os.AsyncTask; import android.os.Handler; import android.os.Looper; import android.support.annotation.Nullable; import android.support.annotation.UiThread; import android.support.annotation.WorkerThread; import android.util.ArraySet; import android.util.Log; import com.android.tv.R; import com.android.tv.common.concurrent.NamedThreadFactory; import com.android.tv.util.images.BitmapUtils.ScaledBitmapInfo; import java.lang.ref.WeakReference; import java.util.HashMap; import java.util.Map; import java.util.Set; import java.util.concurrent.BlockingQueue; import java.util.concurrent.Executor; import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.ThreadFactory; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; /** * This class wraps up completing some arbitrary long running work when loading a bitmap. It handles * things like using a memory cache, running the work in a background thread. */ public final class ImageLoader { private static final String TAG = "ImageLoader"; private static final boolean DEBUG = false; private static final int CPU_COUNT = Runtime.getRuntime().availableProcessors(); // We want at least 2 threads and at most 4 threads in the core pool, // preferring to have 1 less than the CPU count to avoid saturating // the CPU with background work private static final int CORE_POOL_SIZE = Math.max(2, Math.min(CPU_COUNT - 1, 4)); private static final int MAXIMUM_POOL_SIZE = CPU_COUNT * 2 + 1; private static final int KEEP_ALIVE_SECONDS = 30; private static final ThreadFactory sThreadFactory = new NamedThreadFactory("ImageLoader"); private static final BlockingQueue sPoolWorkQueue = new LinkedBlockingQueue<>(128); /** * An private {@link Executor} that can be used to execute tasks in parallel. * *

{@code IMAGE_THREAD_POOL_EXECUTOR} setting are copied from {@link AsyncTask} Since we do a * lot of concurrent image loading we can exhaust a thread pool. ImageLoader catches the error, * and just leaves the image blank. However other tasks will fail and crash the application. * *

Using a separate thread pool prevents image loading from causing other tasks to fail. */ private static final Executor IMAGE_THREAD_POOL_EXECUTOR; static { ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor( CORE_POOL_SIZE, MAXIMUM_POOL_SIZE, KEEP_ALIVE_SECONDS, TimeUnit.SECONDS, sPoolWorkQueue, sThreadFactory); threadPoolExecutor.allowCoreThreadTimeOut(true); IMAGE_THREAD_POOL_EXECUTOR = threadPoolExecutor; } private static Handler sMainHandler; /** * Handles when image loading is finished. * *

Use this to prevent leaking an Activity or other Context while image loading is still * pending. When you extend this class you MUST NOT use a non static inner * class, or the containing object will still be leaked. */ @UiThread public abstract static class ImageLoaderCallback { private final WeakReference mWeakReference; /** * Creates an callback keeping a weak reference to {@code referent}. * *

If the "referent" is no longer valid, it no longer makes sense to run the callback. * The referent is the View, or Activity or whatever that actually needs to receive the * Bitmap. If the referent has been GC, then no need to run the callback. */ public ImageLoaderCallback(T referent) { mWeakReference = new WeakReference<>(referent); } /** Called when bitmap is loaded. */ private void onBitmapLoaded(@Nullable Bitmap bitmap) { T referent = mWeakReference.get(); if (referent != null) { onBitmapLoaded(referent, bitmap); } else { if (DEBUG) Log.d(TAG, "onBitmapLoaded not called because weak reference is gone"); } } /** Called when bitmap is loaded if the weak reference is still valid. */ public abstract void onBitmapLoaded(T referent, @Nullable Bitmap bitmap); } private static final Map sPendingListMap = new HashMap<>(); /** * Preload a bitmap image into the cache. * *

Not to make heavy CPU load, AsyncTask.SERIAL_EXECUTOR is used for the image loading. * *

This method is thread safe. */ public static void prefetchBitmap( Context context, final String uriString, final int maxWidth, final int maxHeight) { if (DEBUG) Log.d(TAG, "prefetchBitmap() " + uriString); if (Looper.getMainLooper() == Looper.myLooper()) { doLoadBitmap(context, uriString, maxWidth, maxHeight, null, AsyncTask.SERIAL_EXECUTOR); } else { final Context appContext = context.getApplicationContext(); getMainHandler() .post( () -> doLoadBitmap( appContext, uriString, maxWidth, maxHeight, null, AsyncTask.SERIAL_EXECUTOR)); } } /** * Load a bitmap image with the cache using a ContentResolver. * *

Note that the callback will be called synchronously if the bitmap already is in the * cache. * * @return {@code true} if the load is complete and the callback is executed. */ @UiThread public static boolean loadBitmap( Context context, String uriString, ImageLoaderCallback callback) { return loadBitmap(context, uriString, Integer.MAX_VALUE, Integer.MAX_VALUE, callback); } /** * Load a bitmap image with the cache and resize it with given params. * *

Note that the callback will be called synchronously if the bitmap already is in the * cache. * * @return {@code true} if the load is complete and the callback is executed. */ @UiThread public static boolean loadBitmap( Context context, String uriString, int maxWidth, int maxHeight, ImageLoaderCallback callback) { if (DEBUG) { Log.d(TAG, "loadBitmap() " + uriString); } return doLoadBitmap( context, uriString, maxWidth, maxHeight, callback, IMAGE_THREAD_POOL_EXECUTOR); } private static boolean doLoadBitmap( Context context, String uriString, int maxWidth, int maxHeight, ImageLoaderCallback callback, Executor executor) { // Check the cache before creating a Task. The cache will be checked again in doLoadBitmap // but checking a cache is much cheaper than creating an new task. ImageCache imageCache = ImageCache.getInstance(); ScaledBitmapInfo bitmapInfo = imageCache.get(uriString); if (bitmapInfo != null && !bitmapInfo.needToReload(maxWidth, maxHeight)) { if (callback != null) { callback.onBitmapLoaded(bitmapInfo.bitmap); } return true; } return doLoadBitmap( callback, executor, new LoadBitmapFromUriTask(context, imageCache, uriString, maxWidth, maxHeight)); } /** * Load a bitmap image with the cache and resize it with given params. * *

The LoadBitmapTask will be executed on a non ui thread. * * @return {@code true} if the load is complete and the callback is executed. */ @UiThread public static boolean loadBitmap(ImageLoaderCallback callback, LoadBitmapTask loadBitmapTask) { if (DEBUG) { Log.d(TAG, "loadBitmap() " + loadBitmapTask); } return doLoadBitmap(callback, IMAGE_THREAD_POOL_EXECUTOR, loadBitmapTask); } /** @return {@code true} if the load is complete and the callback is executed. */ @UiThread private static boolean doLoadBitmap( ImageLoaderCallback callback, Executor executor, LoadBitmapTask loadBitmapTask) { ScaledBitmapInfo bitmapInfo = loadBitmapTask.getFromCache(); boolean needToReload = loadBitmapTask.isReloadNeeded(); if (bitmapInfo != null && !needToReload) { if (callback != null) { callback.onBitmapLoaded(bitmapInfo.bitmap); } return true; } LoadBitmapTask existingTask = sPendingListMap.get(loadBitmapTask.getKey()); if (existingTask != null && !loadBitmapTask.isReloadNeeded(existingTask)) { // The image loading is already scheduled and is large enough. if (callback != null) { existingTask.mCallbacks.add(callback); } } else { if (callback != null) { loadBitmapTask.mCallbacks.add(callback); } sPendingListMap.put(loadBitmapTask.getKey(), loadBitmapTask); try { loadBitmapTask.executeOnExecutor(executor); } catch (RejectedExecutionException e) { Log.e(TAG, "Failed to create new image loader", e); sPendingListMap.remove(loadBitmapTask.getKey()); } } return false; } /** * Loads and caches a a possibly scaled down version of a bitmap. * *

Implement {@link #doGetBitmapInBackground} to do the actual loading. */ public abstract static class LoadBitmapTask extends AsyncTask { protected final Context mAppContext; protected final int mMaxWidth; protected final int mMaxHeight; private final Set mCallbacks = new ArraySet<>(); private final ImageCache mImageCache; private final String mKey; /** * Returns true if a reload is needed compared to current results in the cache or false if * there is not match in the cache. */ private boolean isReloadNeeded() { ScaledBitmapInfo bitmapInfo = getFromCache(); boolean needToReload = bitmapInfo != null && bitmapInfo.needToReload(mMaxWidth, mMaxHeight); if (DEBUG) { if (needToReload) { Log.d( TAG, "Bitmap needs to be reloaded. {" + "originalWidth=" + bitmapInfo.bitmap.getWidth() + ", originalHeight=" + bitmapInfo.bitmap.getHeight() + ", reqWidth=" + mMaxWidth + ", reqHeight=" + mMaxHeight + "}"); } } return needToReload; } /** Checks if a reload would be needed if the results of other was available. */ private boolean isReloadNeeded(LoadBitmapTask other) { return (other.mMaxHeight != Integer.MAX_VALUE && mMaxHeight >= other.mMaxHeight * 2) || (other.mMaxWidth != Integer.MAX_VALUE && mMaxWidth >= other.mMaxWidth * 2); } @Nullable public final ScaledBitmapInfo getFromCache() { return mImageCache.get(mKey); } public LoadBitmapTask( Context context, ImageCache imageCache, String key, int maxHeight, int maxWidth) { if (maxWidth == 0 || maxHeight == 0) { throw new IllegalArgumentException( "Image size should not be 0. {width=" + maxWidth + ", height=" + maxHeight + "}"); } mAppContext = context.getApplicationContext(); mKey = key; mImageCache = imageCache; mMaxHeight = maxHeight; mMaxWidth = maxWidth; } /** Loads the bitmap returning a possibly scaled down version. */ @Nullable @WorkerThread public abstract ScaledBitmapInfo doGetBitmapInBackground(); @Override @Nullable public final ScaledBitmapInfo doInBackground(Void... params) { ScaledBitmapInfo bitmapInfo = getFromCache(); if (bitmapInfo != null && !isReloadNeeded()) { return bitmapInfo; } bitmapInfo = doGetBitmapInBackground(); if (bitmapInfo != null) { mImageCache.putIfNeeded(bitmapInfo); } return bitmapInfo; } @Override public final void onPostExecute(ScaledBitmapInfo scaledBitmapInfo) { if (DEBUG) Log.d(ImageLoader.TAG, "Bitmap is loaded " + mKey); for (ImageLoader.ImageLoaderCallback callback : mCallbacks) { callback.onBitmapLoaded(scaledBitmapInfo == null ? null : scaledBitmapInfo.bitmap); } ImageLoader.sPendingListMap.remove(mKey); } public final String getKey() { return mKey; } @Override public String toString() { return this.getClass().getSimpleName() + "(" + mKey + " " + mMaxWidth + "x" + mMaxHeight + ")"; } } private static final class LoadBitmapFromUriTask extends LoadBitmapTask { private LoadBitmapFromUriTask( Context context, ImageCache imageCache, String uriString, int maxWidth, int maxHeight) { super(context, imageCache, uriString, maxHeight, maxWidth); } @Override @Nullable public final ScaledBitmapInfo doGetBitmapInBackground() { return BitmapUtils.decodeSampledBitmapFromUriString( mAppContext, getKey(), mMaxWidth, mMaxHeight); } } /** Loads and caches the logo for a given {@link TvInputInfo} */ public static final class LoadTvInputLogoTask extends LoadBitmapTask { private final TvInputInfo mInfo; public LoadTvInputLogoTask(Context context, ImageCache cache, TvInputInfo info) { super( context, cache, getTvInputLogoKey(info.getId()), context.getResources() .getDimensionPixelSize(R.dimen.channel_banner_input_logo_size), context.getResources() .getDimensionPixelSize(R.dimen.channel_banner_input_logo_size)); mInfo = info; } @Nullable @Override public ScaledBitmapInfo doGetBitmapInBackground() { Drawable drawable = mInfo.loadIcon(mAppContext); Bitmap bm = drawable instanceof BitmapDrawable ? ((BitmapDrawable) drawable).getBitmap() : BitmapUtils.drawableToBitmap(drawable); return bm == null ? null : BitmapUtils.createScaledBitmapInfo(getKey(), bm, mMaxWidth, mMaxHeight); } /** Returns key of TV input logo. */ public static String getTvInputLogoKey(String inputId) { return inputId + "-logo"; } } private static synchronized Handler getMainHandler() { if (sMainHandler == null) { sMainHandler = new Handler(Looper.getMainLooper()); } return sMainHandler; } private ImageLoader() {} }