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