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
17package com.android.tv.util;
18
19import android.content.Context;
20import android.graphics.Bitmap;
21import android.graphics.drawable.BitmapDrawable;
22import android.graphics.drawable.Drawable;
23import android.media.tv.TvInputInfo;
24import android.os.AsyncTask;
25import android.os.Handler;
26import android.os.Looper;
27import android.support.annotation.MainThread;
28import android.support.annotation.Nullable;
29import android.support.annotation.UiThread;
30import android.support.annotation.WorkerThread;
31import android.util.ArraySet;
32import android.util.Log;
33
34import com.android.tv.R;
35import com.android.tv.util.BitmapUtils.ScaledBitmapInfo;
36
37import java.lang.ref.WeakReference;
38import java.util.HashMap;
39import java.util.Map;
40import java.util.Set;
41import java.util.concurrent.BlockingQueue;
42import java.util.concurrent.Executor;
43import java.util.concurrent.LinkedBlockingQueue;
44import java.util.concurrent.RejectedExecutionException;
45import java.util.concurrent.ThreadFactory;
46import java.util.concurrent.ThreadPoolExecutor;
47import 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 */
53public 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<>(128);
68
69    /**
70     * An private {@link Executor} that can be used to execute tasks in parallel.
71     *
72     * <p>{@code IMAGE_THREAD_POOL_EXECUTOR} setting are copied from {@link AsyncTask}
73     * Since we do a lot of concurrent image loading we can exhaust a thread pool.
74     * ImageLoader catches the error, and just leaves the image blank.
75     * However other tasks will fail and crash the application.
76     *
77     * <p>Using a separate thread pool prevents image loading from causing other tasks to fail.
78     */
79    private static final Executor IMAGE_THREAD_POOL_EXECUTOR;
80
81    static {
82        ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(CORE_POOL_SIZE,
83                MAXIMUM_POOL_SIZE, KEEP_ALIVE_SECONDS, TimeUnit.SECONDS, sPoolWorkQueue,
84                sThreadFactory);
85        threadPoolExecutor.allowCoreThreadTimeOut(true);
86        IMAGE_THREAD_POOL_EXECUTOR = threadPoolExecutor;
87    }
88
89    private static Handler sMainHandler;
90
91    /**
92     * Handles when image loading is finished.
93     *
94     * <p>Use this to prevent leaking an Activity or other Context while image loading is
95     *  still pending. When you extend this class you <strong>MUST NOT</strong> use a non static
96     *  inner class, or the containing object will still be leaked.
97     */
98    @UiThread
99    public static abstract class ImageLoaderCallback<T> {
100        private final WeakReference<T> mWeakReference;
101
102        /**
103         * Creates an callback keeping a weak reference to {@code referent}.
104         *
105         * <p> If the "referent" is no longer valid, it no longer makes sense to run the
106         * callback. The referent is the View, or Activity or whatever that actually needs to
107         * receive the Bitmap.  If the referent has been GC, then no need to run the callback.
108         */
109        public ImageLoaderCallback(T referent) {
110            mWeakReference = new WeakReference<>(referent);
111        }
112
113        /**
114         * Called when bitmap is loaded.
115         */
116        private void onBitmapLoaded(@Nullable Bitmap bitmap) {
117            T referent = mWeakReference.get();
118            if (referent != null) {
119                onBitmapLoaded(referent, bitmap);
120            } else {
121                if (DEBUG) Log.d(TAG, "onBitmapLoaded not called because weak reference is gone");
122            }
123        }
124
125        /**
126         * Called when bitmap is loaded if the weak reference is still valid.
127         */
128        public abstract void onBitmapLoaded(T referent, @Nullable Bitmap bitmap);
129    }
130
131    private static final Map<String, LoadBitmapTask> sPendingListMap = new HashMap<>();
132
133    /**
134     * Preload a bitmap image into the cache.
135     *
136     * <p>Not to make heavy CPU load, AsyncTask.SERIAL_EXECUTOR is used for the image loading.
137     * <p>This method is thread safe.
138     */
139    public static void prefetchBitmap(Context context, final String uriString, final int maxWidth,
140            final int maxHeight) {
141        if (DEBUG) Log.d(TAG, "prefetchBitmap() " + uriString);
142        if (Looper.getMainLooper() == Looper.myLooper()) {
143            doLoadBitmap(context, uriString, maxWidth, maxHeight, null, AsyncTask.SERIAL_EXECUTOR);
144        } else {
145            final Context appContext = context.getApplicationContext();
146            getMainHandler().post(new Runnable() {
147                @Override
148                @MainThread
149                public void run() {
150                    // Calling from the main thread prevents a ConcurrentModificationException
151                    // in LoadBitmapTask.onPostExecute
152                    doLoadBitmap(appContext, uriString, maxWidth, maxHeight, null,
153                            AsyncTask.SERIAL_EXECUTOR);
154                }
155            });
156        }
157    }
158
159    /**
160     * Load a bitmap image with the cache using a ContentResolver.
161     *
162     * <p><b>Note</b> that the callback will be called synchronously if the bitmap already is in
163     * the cache.
164     *
165     * @return {@code true} if the load is complete and the callback is executed.
166     */
167    @UiThread
168    public static boolean loadBitmap(Context context, String uriString,
169            ImageLoaderCallback callback) {
170        return loadBitmap(context, uriString, Integer.MAX_VALUE, Integer.MAX_VALUE, callback);
171    }
172
173    /**
174     * Load a bitmap image with the cache and resize it with given params.
175     *
176     * <p><b>Note</b> that the callback will be called synchronously if the bitmap already is in
177     * the cache.
178     *
179     * @return {@code true} if the load is complete and the callback is executed.
180     */
181    @UiThread
182    public static boolean loadBitmap(Context context, String uriString, int maxWidth, int maxHeight,
183            ImageLoaderCallback callback) {
184        if (DEBUG) {
185            Log.d(TAG, "loadBitmap() " + uriString);
186        }
187        return doLoadBitmap(context, uriString, maxWidth, maxHeight, callback,
188                IMAGE_THREAD_POOL_EXECUTOR);
189    }
190
191    private static boolean doLoadBitmap(Context context, String uriString,
192            int maxWidth, int maxHeight, ImageLoaderCallback callback, Executor executor) {
193        // Check the cache before creating a Task.  The cache will be checked again in doLoadBitmap
194        // but checking a cache is much cheaper than creating an new task.
195        ImageCache imageCache = ImageCache.getInstance();
196        ScaledBitmapInfo bitmapInfo = imageCache.get(uriString);
197        if (bitmapInfo != null && !bitmapInfo.needToReload(maxWidth, maxHeight)) {
198            if (callback != null) {
199                callback.onBitmapLoaded(bitmapInfo.bitmap);
200            }
201            return true;
202        }
203        return doLoadBitmap(callback, executor,
204                new LoadBitmapFromUriTask(context, imageCache, uriString, maxWidth, maxHeight));
205    }
206
207    /**
208     * Load a bitmap image with the cache and resize it with given params.
209     *
210     * <p>The LoadBitmapTask will be executed on a non ui thread.
211     *
212     * @return {@code true} if the load is complete and the callback is executed.
213     */
214    @UiThread
215    public static boolean loadBitmap(ImageLoaderCallback callback, LoadBitmapTask loadBitmapTask) {
216        if (DEBUG) {
217            Log.d(TAG, "loadBitmap() " + loadBitmapTask);
218        }
219        return doLoadBitmap(callback, IMAGE_THREAD_POOL_EXECUTOR, loadBitmapTask);
220    }
221
222    /**
223     * @return {@code true} if the load is complete and the callback is executed.
224     */
225    @UiThread
226    private static boolean doLoadBitmap(ImageLoaderCallback callback, Executor executor,
227            LoadBitmapTask loadBitmapTask) {
228        ScaledBitmapInfo bitmapInfo = loadBitmapTask.getFromCache();
229        boolean needToReload = loadBitmapTask.isReloadNeeded();
230        if (bitmapInfo != null && !needToReload) {
231            if (callback != null) {
232                callback.onBitmapLoaded(bitmapInfo.bitmap);
233            }
234            return true;
235        }
236        LoadBitmapTask existingTask = sPendingListMap.get(loadBitmapTask.getKey());
237        if (existingTask != null && !loadBitmapTask.isReloadNeeded(existingTask)) {
238            // The image loading is already scheduled and is large enough.
239            if (callback != null) {
240                existingTask.mCallbacks.add(callback);
241            }
242        } else {
243            if (callback != null) {
244                loadBitmapTask.mCallbacks.add(callback);
245            }
246            sPendingListMap.put(loadBitmapTask.getKey(), loadBitmapTask);
247            try {
248                loadBitmapTask.executeOnExecutor(executor);
249            } catch (RejectedExecutionException e) {
250                Log.e(TAG, "Failed to create new image loader", e);
251                sPendingListMap.remove(loadBitmapTask.getKey());
252            }
253        }
254        return false;
255    }
256
257    /**
258     * Loads and caches a a possibly scaled down version of a bitmap.
259     *
260     * <p>Implement {@link #doGetBitmapInBackground} to do the actual loading.
261     */
262    public static abstract class LoadBitmapTask extends AsyncTask<Void, Void, ScaledBitmapInfo> {
263        protected final Context mAppContext;
264        protected final int mMaxWidth;
265        protected final int mMaxHeight;
266        private final Set<ImageLoaderCallback> mCallbacks = new ArraySet<>();
267        private final ImageCache mImageCache;
268        private final String mKey;
269
270        /**
271         * Returns true if a reload is needed compared to current results in the cache or false if
272         * there is not match in the cache.
273         */
274        private boolean isReloadNeeded() {
275            ScaledBitmapInfo bitmapInfo = getFromCache();
276            boolean needToReload = bitmapInfo != null && bitmapInfo
277                    .needToReload(mMaxWidth, mMaxHeight);
278            if (DEBUG) {
279                if (needToReload) {
280                    Log.d(TAG, "Bitmap needs to be reloaded. {"
281                            + "originalWidth=" + bitmapInfo.bitmap.getWidth()
282                            + ", originalHeight=" + bitmapInfo.bitmap.getHeight()
283                            + ", reqWidth=" + mMaxWidth
284                            + ", reqHeight=" + mMaxHeight
285                            + "}");
286                }
287            }
288            return needToReload;
289        }
290
291        /**
292         * Checks if a reload would be needed if the results of other was available.
293         */
294        private boolean isReloadNeeded(LoadBitmapTask other) {
295            return (other.mMaxHeight != Integer.MAX_VALUE && mMaxHeight >= other.mMaxHeight * 2)
296                    || (other.mMaxWidth != Integer.MAX_VALUE && mMaxWidth >= other.mMaxWidth * 2);
297        }
298
299        @Nullable
300        public final ScaledBitmapInfo getFromCache() {
301            return mImageCache.get(mKey);
302        }
303
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
323        public abstract ScaledBitmapInfo doGetBitmapInBackground();
324
325        @Override
326        @Nullable
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
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
349        public final String getKey() {
350            return mKey;
351        }
352
353        @Override
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 {
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
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
380        public LoadTvInputLogoTask(Context context, ImageCache cache, TvInputInfo info) {
381            super(context,
382                    cache,
383                    getTvInputLogoKey(info.getId()),
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
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        /**
407         * Returns key of TV input logo.
408         */
409        public static String getTvInputLogoKey(String inputId) {
410            return inputId + "-logo";
411        }
412    }
413
414    private static synchronized Handler getMainHandler() {
415        if (sMainHandler == null) {
416            sMainHandler = new Handler(Looper.getMainLooper());
417        }
418        return sMainHandler;
419    }
420
421    private ImageLoader() {
422    }
423}
424