1/*
2 * Copyright (C) 2014 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.settings.widget;
18
19import android.app.ActivityManager;
20import android.content.ContentResolver;
21import android.content.Context;
22import android.graphics.Bitmap;
23import android.util.Log;
24import android.util.LruCache;
25import android.widget.ImageView;
26
27import com.android.tv.settings.R;
28import com.android.tv.settings.util.AccountImageChangeObserver;
29import com.android.tv.settings.util.UriUtils;
30
31import java.lang.ref.SoftReference;
32import java.util.concurrent.Executor;
33import java.util.concurrent.Executors;
34import java.util.Map;
35
36/**
37 * Downloader class which loads a resource URI into an image view.
38 * <p>
39 * This class adds a cache over BitmapWorkerTask.
40 */
41public class BitmapDownloader {
42
43    private static final String TAG = "BitmapDownloader";
44
45    private static final boolean DEBUG = false;
46
47    private static final int CORE_POOL_SIZE = 5;
48
49    private static final Executor BITMAP_DOWNLOADER_THREAD_POOL_EXECUTOR =
50            Executors.newFixedThreadPool(CORE_POOL_SIZE);
51
52    // 1/4 of max memory is used for bitmap mem cache
53    private static final int MEM_TO_CACHE = 4;
54
55    // hard limit for bitmap mem cache in MB
56    private static final int CACHE_HARD_LIMIT = 32;
57
58    /**
59     * bitmap cache item structure saved in LruCache
60     */
61    private static class BitmapItem {
62        /**
63         * cached bitmap
64         */
65        final Bitmap mBitmap;
66        /**
67         * indicate if the bitmap is scaled down from original source (never scale up)
68         */
69        final boolean mScaled;
70
71        public BitmapItem(Bitmap bitmap, boolean scaled) {
72            mBitmap = bitmap;
73            mScaled = scaled;
74        }
75    }
76
77    private final LruCache<String, BitmapItem> mMemoryCache;
78
79    private static BitmapDownloader sBitmapDownloader;
80
81    private static final Object sBitmapDownloaderLock = new Object();
82
83    // Bitmap cache also uses size of Bitmap as part of key.
84    // Bitmap cache is divided into following buckets by height:
85    // TODO: we currently care more about height, what about width in key?
86    // height <= 128, 128 < height <= 512, height > 512
87    // Different bitmap cache buckets save different bitmap cache items.
88    // Bitmaps within same bucket share the largest cache item.
89    private static final int[] SIZE_BUCKET = new int[]{128, 512, Integer.MAX_VALUE};
90
91    public static abstract class BitmapCallback {
92        SoftReference<BitmapWorkerTask> mTask;
93
94        public abstract void onBitmapRetrieved(Bitmap bitmap);
95    }
96
97    /**
98     * get the singleton BitmapDownloader for the application
99     */
100    public static BitmapDownloader getInstance(Context context) {
101        if (sBitmapDownloader == null) {
102            synchronized(sBitmapDownloaderLock) {
103                if (sBitmapDownloader == null) {
104                    sBitmapDownloader = new BitmapDownloader(context);
105                }
106            }
107        }
108        return sBitmapDownloader;
109    }
110
111    public BitmapDownloader(Context context) {
112        int memClass = ((ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE))
113                .getMemoryClass();
114        memClass = memClass / MEM_TO_CACHE;
115        if (memClass > CACHE_HARD_LIMIT) {
116            memClass = CACHE_HARD_LIMIT;
117        }
118        int cacheSize = 1024 * 1024 * memClass;
119        mMemoryCache = new LruCache<String, BitmapItem>(cacheSize) {
120            @Override
121            protected int sizeOf(String key, BitmapItem bitmap) {
122                return bitmap.mBitmap.getByteCount();
123            }
124        };
125    }
126
127    /**
128     * load bitmap in current thread, will *block* current thread.
129     * FIXME: Should avoid using this function at all cost.
130     * @deprecated
131     */
132    @Deprecated
133    public final Bitmap loadBitmapBlocking(BitmapWorkerOptions options) {
134        final boolean hasAccountImageUri = UriUtils.isAccountImageUri(options.getResourceUri());
135        Bitmap bitmap = null;
136        if (hasAccountImageUri) {
137            AccountImageChangeObserver.getInstance().registerChangeUriIfPresent(options);
138        } else {
139            bitmap = getBitmapFromMemCache(options);
140        }
141
142        if (bitmap == null) {
143            BitmapWorkerTask task = new BitmapWorkerTask(null) {
144                @Override
145                protected Bitmap doInBackground(BitmapWorkerOptions... params) {
146                    final Bitmap bitmap = super.doInBackground(params);
147                    if (bitmap != null && !hasAccountImageUri) {
148                        addBitmapToMemoryCache(params[0], bitmap, isScaled());
149                    }
150                    return bitmap;
151                }
152            };
153
154            return task.doInBackground(options);
155        }
156        return bitmap;
157    }
158
159    /**
160     * Loads the bitmap into the image view.
161     */
162    public void loadBitmap(BitmapWorkerOptions options, final ImageView imageView) {
163        cancelDownload(imageView);
164        final boolean hasAccountImageUri = UriUtils.isAccountImageUri(options.getResourceUri());
165        Bitmap bitmap = null;
166        if (hasAccountImageUri) {
167            AccountImageChangeObserver.getInstance().registerChangeUriIfPresent(options);
168        } else {
169            bitmap = getBitmapFromMemCache(options);
170        }
171
172        if (bitmap != null) {
173            imageView.setImageBitmap(bitmap);
174        } else {
175            BitmapWorkerTask task = new BitmapWorkerTask(imageView) {
176                @Override
177                protected Bitmap doInBackground(BitmapWorkerOptions... params) {
178                    Bitmap bitmap = super.doInBackground(params);
179                    if (bitmap != null && !hasAccountImageUri) {
180                        addBitmapToMemoryCache(params[0], bitmap, isScaled());
181                    }
182                    return bitmap;
183                }
184            };
185            imageView.setTag(R.id.imageDownloadTask, new SoftReference<>(task));
186            task.execute(options);
187        }
188    }
189
190    /**
191     * Loads the bitmap.
192     * <p>
193     * This will be sent back to the callback object.
194     */
195    public void getBitmap(BitmapWorkerOptions options, final BitmapCallback callback) {
196        cancelDownload(callback);
197        final boolean hasAccountImageUri = UriUtils.isAccountImageUri(options.getResourceUri());
198        final Bitmap bitmap = hasAccountImageUri ? null : getBitmapFromMemCache(options);
199        if (hasAccountImageUri) {
200            AccountImageChangeObserver.getInstance().registerChangeUriIfPresent(options);
201        }
202
203        BitmapWorkerTask task = new BitmapWorkerTask(null) {
204            @Override
205            protected Bitmap doInBackground(BitmapWorkerOptions... params) {
206                if (bitmap != null) {
207                    return bitmap;
208                }
209                final Bitmap bitmap = super.doInBackground(params);
210                if (bitmap != null && !hasAccountImageUri) {
211                    addBitmapToMemoryCache(params[0], bitmap, isScaled());
212                }
213                return bitmap;
214            }
215
216            @Override
217            protected void onPostExecute(Bitmap bitmap) {
218                callback.onBitmapRetrieved(bitmap);
219            }
220        };
221        callback.mTask = new SoftReference<>(task);
222        task.executeOnExecutor(BITMAP_DOWNLOADER_THREAD_POOL_EXECUTOR, options);
223    }
224
225    /**
226     * Cancel download<p>
227     * @param key {@link BitmapCallback} or {@link ImageView}
228     */
229    public boolean cancelDownload(Object key) {
230        BitmapWorkerTask task = null;
231        if (key instanceof ImageView) {
232            ImageView imageView = (ImageView)key;
233            SoftReference<BitmapWorkerTask> softReference =
234                    (SoftReference<BitmapWorkerTask>) imageView.getTag(R.id.imageDownloadTask);
235            if (softReference != null) {
236                task = softReference.get();
237                softReference.clear();
238            }
239        } else if (key instanceof BitmapCallback) {
240            BitmapCallback callback = (BitmapCallback)key;
241            if (callback.mTask != null) {
242                task = callback.mTask.get();
243                callback.mTask = null;
244            }
245        }
246        if (task != null) {
247            return task.cancel(true);
248        }
249        return false;
250    }
251
252    private static String getBucketKey(String baseKey, Bitmap.Config bitmapConfig, int width) {
253        for (int i = 0; i < SIZE_BUCKET.length; i++) {
254            if (width <= SIZE_BUCKET[i]) {
255                return new StringBuilder(baseKey.length() + 16).append(baseKey)
256                        .append(":").append(bitmapConfig == null ? "" : bitmapConfig.ordinal())
257                        .append(":").append(SIZE_BUCKET[i]).toString();
258            }
259        }
260        // should never happen because last bucket is Integer.MAX_VALUE
261        throw new RuntimeException();
262    }
263
264    private void addBitmapToMemoryCache(BitmapWorkerOptions key, Bitmap bitmap, boolean isScaled) {
265        if (!key.isMemCacheEnabled()) {
266            return;
267        }
268        String bucketKey = getBucketKey(
269                key.getCacheKey(), key.getBitmapConfig(), bitmap.getHeight());
270        BitmapItem bitmapItem = mMemoryCache.get(bucketKey);
271        if (bitmapItem != null) {
272            Bitmap currentBitmap = bitmapItem.mBitmap;
273            // If somebody else happened to get a larger one in the bucket, discard our bitmap.
274            // TODO: need a better way to prevent current downloading for the same Bitmap
275            if (currentBitmap.getWidth() >= bitmap.getWidth() && currentBitmap.getHeight()
276                    >= bitmap.getHeight()) {
277                return;
278            }
279        }
280        if (DEBUG) {
281            Log.d(TAG, "add cache "+bucketKey+" isScaled = "+isScaled);
282        }
283        bitmapItem = new BitmapItem(bitmap, isScaled);
284        mMemoryCache.put(bucketKey, bitmapItem);
285    }
286
287    private Bitmap getBitmapFromMemCache(BitmapWorkerOptions key) {
288        if (key.getHeight() != BitmapWorkerOptions.MAX_IMAGE_DIMENSION_PX) {
289            // 1. find the bitmap in the size bucket
290            String bucketKey =
291                    getBucketKey(key.getCacheKey(), key.getBitmapConfig(), key.getHeight());
292            BitmapItem bitmapItem = mMemoryCache.get(bucketKey);
293            if (bitmapItem != null) {
294                Bitmap bitmap = bitmapItem.mBitmap;
295                // now we have the bitmap in the bucket, use it when the bitmap is not scaled or
296                // if the size is larger than or equals to the output size
297                if (!bitmapItem.mScaled) {
298                    return bitmap;
299                }
300                if (bitmap.getHeight() >= key.getHeight()) {
301                    return bitmap;
302                }
303            }
304            // 2. find un-scaled bitmap in smaller buckets.  If the un-scaled bitmap exists
305            // in higher buckets,  we still need to scale it down.  Right now we just
306            // return null and let the BitmapWorkerTask to do the same job again.
307            // TODO: use the existing unscaled bitmap and we don't need to load it from resource
308            // or network again.
309            for (int i = SIZE_BUCKET.length - 1; i >= 0; i--) {
310                if (SIZE_BUCKET[i] >= key.getHeight()) {
311                    continue;
312                }
313                bucketKey = getBucketKey(key.getCacheKey(), key.getBitmapConfig(), SIZE_BUCKET[i]);
314                bitmapItem = mMemoryCache.get(bucketKey);
315                if (bitmapItem != null && !bitmapItem.mScaled) {
316                    return bitmapItem.mBitmap;
317                }
318            }
319            return null;
320        }
321        // 3. find un-scaled bitmap if size is not specified
322        for (int i = SIZE_BUCKET.length - 1; i >= 0; i--) {
323            String bucketKey =
324                    getBucketKey(key.getCacheKey(), key.getBitmapConfig(), SIZE_BUCKET[i]);
325            BitmapItem bitmapItem = mMemoryCache.get(bucketKey);
326            if (bitmapItem != null && !bitmapItem.mScaled) {
327                return bitmapItem.mBitmap;
328            }
329        }
330        return null;
331    }
332
333    public Bitmap getLargestBitmapFromMemCache(BitmapWorkerOptions key) {
334        // find largest bitmap matching the key
335        for (int i = SIZE_BUCKET.length - 1; i >= 0; i--) {
336            String bucketKey =
337                    getBucketKey(key.getCacheKey(), key.getBitmapConfig(), SIZE_BUCKET[i]);
338            BitmapItem bitmapItem = mMemoryCache.get(bucketKey);
339            if (bitmapItem != null) {
340                return bitmapItem.mBitmap;
341            }
342        }
343        return null;
344    }
345
346    public void invalidateCachedResources() {
347        Map<String, BitmapItem> snapshot = mMemoryCache.snapshot();
348        for (String uri: snapshot.keySet()) {
349            Log.d(TAG, "remove cached image: " + uri);
350            if (uri.startsWith(ContentResolver.SCHEME_ANDROID_RESOURCE)) {
351                mMemoryCache.remove(uri);
352            }
353        }
354    }
355}
356