/* Viewer for Khan Academy Copyright (C) 2012 Concentric Sky, Inc. This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see <http://www.gnu.org/licenses/>. */ package com.concentricsky.android.khanacademy.util; import java.io.File; import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.net.CacheResponse; import java.net.HttpURLConnection; import java.net.MalformedURLException; import java.net.ResponseCache; import java.net.SocketTimeoutException; import java.net.URL; import java.util.Locale; import android.content.Context; import android.content.pm.PackageManager.NameNotFoundException; import android.graphics.Bitmap; import android.graphics.BitmapFactory; import android.net.ConnectivityManager; import android.net.NetworkInfo; import android.os.Environment; import android.support.v4.util.LruCache; import com.concentricsky.android.khanacademy.data.KADataService; import com.concentricsky.android.khanacademy.data.db.Thumbnail; import com.jakewharton.DiskLruCache; /** * Handles downloading thumbnails, caching them, and returning them to the list fragment. * * * @author austinlally * */ public class ThumbnailManager { public static final String LOG_TAG = ThumbnailManager.class.getSimpleName(); private static final int CONNECT_TIMEOUT = 3000; private static ThumbnailManager sharedInstance; /* *********************** PRIVATE ***************************/ private final KADataService dataService; private final ConnectivityManager connectivityManager; private final LruCache<Thumbnail, Bitmap> cache; private final DiskLruCache diskCache; private boolean isDestroyed; public static ThumbnailManager getSharedInstance(KADataService dataService) { if (sharedInstance == null || sharedInstance.isDestroyed) { sharedInstance = new ThumbnailManager(dataService); } return sharedInstance; } private ThumbnailManager(final KADataService dataService) { this.dataService = dataService; connectivityManager = (ConnectivityManager) dataService.getSystemService(Context.CONNECTIVITY_SERVICE); cache = prepareCache(); diskCache = prepareDiskCache(); } private DiskLruCache prepareDiskCache() { int v = 0; try { v = dataService.getPackageManager().getPackageInfo(dataService.getPackageName(), 0).versionCode; } catch (NameNotFoundException e) { // Huh? Really? } // TODO : allow user to configure this. long maxSize = 1024 * 1024 * 1024; File cacheDir = new File(dataService.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), "thumbnail_cache"); int valueCount = 8; try { // TODO : This is slow on first run. Look into improving that. return DiskLruCache.open(cacheDir, v, valueCount, maxSize); } catch (IOException e) { e.printStackTrace(); } return null; } private LruCache<Thumbnail, Bitmap> prepareCache() { // Total available heap size. This changes based on manifest android:largeHeap="true". (Fire HD, transformer both go from 48MB to 256MB) Runtime rt = Runtime.getRuntime(); long maxMemory = rt.maxMemory(); Log.v(LOG_TAG, "maxMemory:" + Long.toString(maxMemory)); // Want to use at most about 1/2 of available memory for thumbs. // In SAT Math category (116 videos), with a heap size of 48MB, this setting // allows 109 thumbs to be cached resulting in total heap usage around 34MB. long usableMemory = maxMemory / 2; return new LruCache<Thumbnail, Bitmap>((int) usableMemory) { @Override protected int sizeOf(Thumbnail key, Bitmap value) { return value.getByteCount(); } @Override protected void entryRemoved(boolean evicted, Thumbnail key, Bitmap oldValue, Bitmap newValue) { if (oldValue != newValue) { oldValue.recycle(); } } }; } /* *********************** PUBLIC ***************************/ public void destroy() { cache.evictAll(); if (diskCache != null) { try { diskCache.close(); } catch (IOException e) { e.printStackTrace(); } } isDestroyed = true; } /** * Cache of thumbnails by youtube id. * @return */ public LruCache<Thumbnail, Bitmap> getCache() { return cache; } /** * Get the thumbnail for the video with the given youtube id, or start a download if it isn't yet stored locally. * * @param y_id The youtube id of the video whose thumbnail we need. * @return The thumbnail as a {@link Bitmap}, or {@code null} if none exists. */ public Bitmap getThumbnail(String y_id, byte quality, boolean useCache) { Bitmap result = null; if (useCache) { Thumbnail thumbnail = new Thumbnail(y_id, quality); result = cache.get(thumbnail); } if (result == null) { result = getThumbnail(y_id, quality); } return result; } private int indexForAvailability(byte q) { switch (q) { case Thumbnail.QUALITY_LOW: return 4; case Thumbnail.QUALITY_MEDIUM: return 5; case Thumbnail.QUALITY_HIGH: return 6; case Thumbnail.QUALITY_SD: return 7; default: throw new IllegalArgumentException("invalid thumb quality"); } } private int indexForQuality(byte q) { switch (q) { case Thumbnail.QUALITY_LOW: return 0; case Thumbnail.QUALITY_MEDIUM: return 1; case Thumbnail.QUALITY_HIGH: return 2; case Thumbnail.QUALITY_SD: return 3; default: throw new IllegalArgumentException("invalid thumb quality"); } } public Bitmap getThumbnailFromDiskCache(String youtubeId, byte quality) { String key = youtubeId.toLowerCase(Locale.US); Bitmap result = null; DiskLruCache.Snapshot snap = null; DiskLruCache.Editor editor = null; // Ensure we have a cache entry for this youtube id. try { // null while another editor is open while ((editor = diskCache.edit(key)) == null) { } if (editor.getString(indexForAvailability(Thumbnail.QUALITY_HIGH)) == null) { // values only null if they've never been set, so this must be a new entry editor.set(indexForQuality(Thumbnail.QUALITY_HIGH), ""); editor.set(indexForAvailability(Thumbnail.QUALITY_HIGH), String.valueOf(Thumbnail.AVAILABILITY_UNKNOWN)); editor.set(indexForQuality(Thumbnail.QUALITY_MEDIUM), ""); editor.set(indexForAvailability(Thumbnail.QUALITY_MEDIUM), String.valueOf(Thumbnail.AVAILABILITY_UNKNOWN)); editor.set(indexForQuality(Thumbnail.QUALITY_LOW), ""); editor.set(indexForAvailability(Thumbnail.QUALITY_LOW), String.valueOf(Thumbnail.AVAILABILITY_UNKNOWN)); editor.set(indexForQuality(Thumbnail.QUALITY_SD), ""); editor.set(indexForAvailability(Thumbnail.QUALITY_SD), String.valueOf(Thumbnail.AVAILABILITY_UNKNOWN)); editor.commit(); } } catch (IOException e) { e.printStackTrace(); } finally { if (editor != null) editor.abortUnlessCommitted(); } while (quality >= Thumbnail.QUALITY_LOW && result == null) { try { // Try getting a bitmap for this quality from disk. snap = diskCache.get(key); InputStream is = snap.getInputStream(indexForQuality(quality)); try { result = BitmapFactory.decodeStream(is); } finally { if (is != null) { try { is.close(); } catch (IOException ex) { } } } if (result != null) { return result; } // If none exists, try fetching it if we haven't before. int availability = Integer.parseInt(snap.getString(indexForAvailability(quality))); if (availability != Thumbnail.AVAILABILITY_UNAVAILABLE) { NetworkInfo activeNetwork = connectivityManager.getActiveNetworkInfo(); if (activeNetwork != null && activeNetwork.isConnected()) { try { result = bitmap_from_url(Thumbnail.getDownloadUrl(dataService, quality, youtubeId)); } catch (MalformedURLException e) { e.printStackTrace(); } catch (IOException e) { // FileNotFoundException on 404. Mark as unavailable. if (e instanceof FileNotFoundException) { try { while ((editor = diskCache.edit(key)) == null) { } editor.set(indexForAvailability(quality), String.valueOf(Thumbnail.AVAILABILITY_UNAVAILABLE)); editor.commit(); } catch (IOException ex) { ex.printStackTrace(); } finally { if (editor != null) editor.abortUnlessCommitted(); } } else { e.printStackTrace(); } } } // If we receive a thumbnail response, store it in the cache and return it. if (result != null) { try { while ((editor = diskCache.edit(key)) == null) { } OutputStream os = editor.newOutputStream(indexForQuality(quality)); try { result.compress(Bitmap.CompressFormat.PNG, 100, os); } finally { if (os != null) os.close(); } editor.set(indexForAvailability(quality), String.valueOf(Thumbnail.AVAILABILITY_AVAILABLE)); editor.commit(); } catch (IOException e) { e.printStackTrace(); } finally { if (editor != null) editor.abortUnlessCommitted(); } return result; } } } catch (IOException e) { e.printStackTrace(); } finally { if (snap != null) snap.close(); } quality--; } return result; } public Bitmap getThumbnail(String y_id, byte quality) { Log.v(LOG_TAG, ".getThumbnailForYoutubeId"); return getThumbnailFromDiskCache(y_id, quality); } /** * Attempt to download a bitmap from the given url. * * @param url The url of the thumbnail to download. * @return A {@link Bitmap} of the thumbnail, or {@code null} if it cannot be downloaded and is not cached locally. * @throws java.net.MalformedURLException if the url is malformed! * @throws java.io.IOException if a connection cannot be opened to the given url, or if an IOException is thrown by {@link ResponseCache} or by {@link CacheResponse}. */ public static Bitmap bitmap_from_url(String url) throws java.net.MalformedURLException, IOException { HttpURLConnection connection = (HttpURLConnection) new URL(url).openConnection(); connection.setConnectTimeout(CONNECT_TIMEOUT); connection.setUseCaches(true); InputStream input = null; try { connection.connect(); input = connection.getInputStream(); return BitmapFactory.decodeStream(input); } catch (SocketTimeoutException e) { e.printStackTrace(); return null; } finally { if (input != null) input.close(); } } /* "media$thumbnail": [ { "url": "http://i.ytimg.com/vi/l-QFT7XNeb4/default.jpg", "height": 90, "width": 120, "time": "00:08:31", "yt$name": "default" }, { "url": "http://i.ytimg.com/vi/l-QFT7XNeb4/mqdefault.jpg", "height": 180, "width": 320, "yt$name": "mqdefault" }, { "url": "http://i.ytimg.com/vi/l-QFT7XNeb4/hqdefault.jpg", "height": 360, "width": 480, "yt$name": "hqdefault" }, { "url": "http://i.ytimg.com/vi/l-QFT7XNeb4/sddefault.jpg", "height": 480, "width": 640, "yt$name": "sddefault" }, { "url": "http://i.ytimg.com/vi/l-QFT7XNeb4/1.jpg", "height": 90, "width": 120, "time": "00:04:15.500", "yt$name": "start" }, { "url": "http://i.ytimg.com/vi/l-QFT7XNeb4/2.jpg", "height": 90, "width": 120, "time": "00:08:31", "yt$name": "middle" }, { "url": "http://i.ytimg.com/vi/l-QFT7XNeb4/3.jpg", "height": 90, "width": 120, "time": "00:12:46.500", "yt$name": "end" } ], */ }