diff --git a/app/src/main/java/com/limelight/grid/AppGridAdapter.java b/app/src/main/java/com/limelight/grid/AppGridAdapter.java index 381d4def..9e6d4eaa 100644 --- a/app/src/main/java/com/limelight/grid/AppGridAdapter.java +++ b/app/src/main/java/com/limelight/grid/AppGridAdapter.java @@ -1,7 +1,7 @@ package com.limelight.grid; import android.app.Activity; -import android.graphics.Bitmap; +import android.graphics.BitmapFactory; import android.widget.ImageView; import android.widget.TextView; @@ -14,27 +14,18 @@ import com.limelight.grid.assets.MemoryAssetLoader; import com.limelight.grid.assets.NetworkAssetLoader; import com.limelight.nvstream.http.ComputerDetails; -import java.lang.ref.WeakReference; import java.security.KeyManagementException; import java.security.NoSuchAlgorithmException; -import java.util.Collection; import java.util.Collections; import java.util.Comparator; -import java.util.Iterator; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; @SuppressWarnings("unchecked") public class AppGridAdapter extends GenericGridAdapter { - private final Activity activity; - private static final int ART_WIDTH_PX = 300; private static final int SMALL_WIDTH_DP = 100; private static final int LARGE_WIDTH_DP = 150; private final CachedAppAssetLoader loader; - private final ConcurrentHashMap, CachedAppAssetLoader.LoaderTuple> loadingTuples = new ConcurrentHashMap<>(); - private final ConcurrentHashMap backgroundLoadingTuples = new ConcurrentHashMap<>(); public AppGridAdapter(Activity activity, boolean listMode, boolean small, ComputerDetails computer, String uniqueId) throws KeyManagementException, NoSuchAlgorithmException { super(activity, listMode ? R.layout.simple_row : (small ? R.layout.app_grid_item_small : R.layout.app_grid_item), R.drawable.image_loading); @@ -56,26 +47,20 @@ public class AppGridAdapter extends GenericGridAdapter { } LimeLog.info("Art scaling divisor: " + scalingDivisor); - this.activity = activity; + BitmapFactory.Options options = new BitmapFactory.Options(); + options.inSampleSize = (int) scalingDivisor; + this.loader = new CachedAppAssetLoader(computer, scalingDivisor, new NetworkAssetLoader(context, uniqueId), - new MemoryAssetLoader(), new DiskAssetLoader(context.getCacheDir())); - } - - private static void cancelTuples(ConcurrentHashMap map) { - Collection tuples = map.values(); - - for (CachedAppAssetLoader.LoaderTuple tuple : tuples) { - tuple.cancel(); - } - - map.clear(); + new MemoryAssetLoader(), + new DiskAssetLoader(context.getCacheDir()), + BitmapFactory.decodeResource(activity.getResources(), + R.drawable.image_loading, options)); } public void cancelQueuedOperations() { - cancelTuples(loadingTuples); - cancelTuples(backgroundLoadingTuples); - + loader.cancelForegroundLoads(); + loader.cancelBackgroundLoads(); loader.freeCacheMemory(); } @@ -89,14 +74,10 @@ public class AppGridAdapter extends GenericGridAdapter { } public void addApp(AppView.AppObject app) { - // Queue a request to fetch this bitmap in the background - Object tupleKey = new Object(); - CachedAppAssetLoader.LoaderTuple tuple = - loader.loadBitmapWithContextInBackground(app.app, tupleKey, backgroundLoadListener); - if (tuple != null) { - backgroundLoadingTuples.put(tupleKey, tuple); - } + // Queue a request to fetch this bitmap into cache + loader.queueCacheLoad(app.app); + // Add the app to our sorted list itemList.add(app); sortList(); } @@ -105,100 +86,9 @@ public class AppGridAdapter extends GenericGridAdapter { itemList.remove(app); } - private final CachedAppAssetLoader.LoadListener imageViewLoadListener = new CachedAppAssetLoader.LoadListener() { - @Override - public void notifyLongLoad(Object object) { - final WeakReference viewRef = (WeakReference) object; - - // If the view isn't there anymore, don't bother scheduling on the UI thread - if (viewRef.get() == null) { - return; - } - - activity.runOnUiThread(new Runnable() { - @Override - public void run() { - ImageView view = viewRef.get(); - if (view != null) { - view.setImageResource(R.drawable.image_loading); - fadeInImage(view); - } - } - }); - } - - @Override - public void notifyLoadComplete(Object object, final Bitmap bitmap) { - final WeakReference viewRef = (WeakReference) object; - - loadingTuples.remove(viewRef); - - // Just leave the loading icon in place - if (bitmap == null) { - return; - } - - // If the view isn't there anymore, don't bother scheduling on the UI thread - if (viewRef.get() == null) { - return; - } - - activity.runOnUiThread(new Runnable() { - @Override - public void run() { - ImageView view = viewRef.get(); - if (view != null) { - view.setImageBitmap(bitmap); - fadeInImage(view); - } - } - }); - } - }; - - private final CachedAppAssetLoader.LoadListener backgroundLoadListener = new CachedAppAssetLoader.LoadListener() { - @Override - public void notifyLongLoad(Object object) {} - - @Override - public void notifyLoadComplete(Object object, final Bitmap bitmap) { - backgroundLoadingTuples.remove(object); - } - }; - - private void reapLoaderTuples(ImageView view) { - // Poor HashMap doesn't deserve this... - Iterator, CachedAppAssetLoader.LoaderTuple>> i = loadingTuples.entrySet().iterator(); - while (i.hasNext()) { - Map.Entry, CachedAppAssetLoader.LoaderTuple> entry = i.next(); - ImageView imageView = entry.getKey().get(); - - // Remove tuples that refer to this view or no view - if (imageView == null || imageView == view) { - // FIXME: There's a small chance that this can race if we've already gone down - // the path to notification but haven't been notified yet - entry.getValue().cancel(); - - // Remove it from the tuple list - i.remove(); - } - } - } - - public boolean populateImageView(final ImageView imgView, final AppView.AppObject obj) { - // Cancel pending loads on this image view - reapLoaderTuples(imgView); - - // Clear existing contents of the image view - imgView.setAlpha(0.0f); - - // Start loading the bitmap - WeakReference viewRef = new WeakReference<>(imgView); - CachedAppAssetLoader.LoaderTuple tuple = loader.loadBitmapWithContext(obj.app, viewRef, imageViewLoadListener); - if (tuple != null) { - // The load was issued asynchronously - loadingTuples.put(viewRef, tuple); - } + public boolean populateImageView(ImageView imgView, AppView.AppObject obj) { + // Let the cached asset loader handle it + loader.populateImageView(obj.app, imgView); return true; } @@ -222,8 +112,4 @@ public class AppGridAdapter extends GenericGridAdapter { // No overlay return false; } - - private static void fadeInImage(ImageView view) { - view.animate().alpha(1.0f).setDuration(100).start(); - } } diff --git a/app/src/main/java/com/limelight/grid/assets/CachedAppAssetLoader.java b/app/src/main/java/com/limelight/grid/assets/CachedAppAssetLoader.java index 611a2aed..9247677d 100644 --- a/app/src/main/java/com/limelight/grid/assets/CachedAppAssetLoader.java +++ b/app/src/main/java/com/limelight/grid/assets/CachedAppAssetLoader.java @@ -1,148 +1,318 @@ package com.limelight.grid.assets; +import android.content.res.Resources; import android.graphics.Bitmap; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.Drawable; +import android.os.AsyncTask; +import android.widget.ImageView; import com.limelight.nvstream.http.ComputerDetails; import com.limelight.nvstream.http.NvApp; import java.io.InputStream; +import java.lang.ref.WeakReference; import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; public class CachedAppAssetLoader { + private static final int MAX_CONCURRENT_FOREGROUND_LOADS = 8; + private static final int MAX_CONCURRENT_CACHE_LOADS = 2; + + private static final int MAX_PENDING_CACHE_LOADS = 100; + private static final int MAX_PENDING_FOREGROUND_LOADS = 30; + + private final ThreadPoolExecutor cacheExecutor = new ThreadPoolExecutor( + MAX_CONCURRENT_CACHE_LOADS, MAX_CONCURRENT_CACHE_LOADS, + Long.MAX_VALUE, TimeUnit.DAYS, + new LinkedBlockingQueue(MAX_PENDING_CACHE_LOADS), + new ThreadPoolExecutor.DiscardOldestPolicy()); + + private final ThreadPoolExecutor foregroundExecutor = new ThreadPoolExecutor( + MAX_CONCURRENT_FOREGROUND_LOADS, MAX_CONCURRENT_FOREGROUND_LOADS, + Long.MAX_VALUE, TimeUnit.DAYS, + new LinkedBlockingQueue(MAX_PENDING_FOREGROUND_LOADS), + new ThreadPoolExecutor.DiscardOldestPolicy()); + private final ComputerDetails computer; private final double scalingDivider; - private final ThreadPoolExecutor foregroundExecutor = new ThreadPoolExecutor(8, 8, Long.MAX_VALUE, TimeUnit.DAYS, new LinkedBlockingQueue()); - private final ThreadPoolExecutor backgroundExecutor = new ThreadPoolExecutor(2, 2, Long.MAX_VALUE, TimeUnit.DAYS, new LinkedBlockingQueue()); private final NetworkAssetLoader networkLoader; private final MemoryAssetLoader memoryLoader; private final DiskAssetLoader diskLoader; + private final Bitmap placeholderBitmap; public CachedAppAssetLoader(ComputerDetails computer, double scalingDivider, NetworkAssetLoader networkLoader, MemoryAssetLoader memoryLoader, - DiskAssetLoader diskLoader) { + DiskAssetLoader diskLoader, Bitmap placeholderBitmap) { this.computer = computer; this.scalingDivider = scalingDivider; - this.networkLoader = networkLoader; this.memoryLoader = memoryLoader; this.diskLoader = diskLoader; + this.placeholderBitmap = placeholderBitmap; + } + + public void cancelBackgroundLoads() { + Runnable r; + while ((r = cacheExecutor.getQueue().poll()) != null) { + cacheExecutor.remove(r); + } + } + + public void cancelForegroundLoads() { + Runnable r; + while ((r = foregroundExecutor.getQueue().poll()) != null) { + foregroundExecutor.remove(r); + } } public void freeCacheMemory() { memoryLoader.clearCache(); } - private Runnable createLoaderRunnable(final LoaderTuple tuple, final Object context, final LoadListener listener) { - return new Runnable() { - @Override - public void run() { - // Abort if we've been cancelled - if (tuple.cancelled) { + private Bitmap doNetworkAssetLoad(LoaderTuple tuple, LoaderTask task) { + Bitmap bmp; + + // Try 3 times + for (int i = 0; i < 3; i++) { + // Check again whether we've been cancelled or the image view is gone + if (task != null && (task.isCancelled() || task.imageViewRef.get() == null)) { + return null; + } + + InputStream in = networkLoader.getBitmapStream(tuple); + if (in != null) { + // Write the stream straight to disk + diskLoader.populateCacheWithStream(tuple, in); + + // Read it back scaled + bmp = diskLoader.loadBitmapFromCache(tuple, (int) scalingDivider); + if (bmp != null) { + return bmp; + } + } + + // Wait 1 second with a bit of fuzz + try { + Thread.sleep((int) (1000 + (Math.random() * 500))); + } catch (InterruptedException e) { + return null; + } + } + + return null; + } + + private class LoaderTask extends AsyncTask { + private final WeakReference imageViewRef; + private LoaderTuple tuple; + private boolean loadFinished; + + public LoaderTask(ImageView imageView) { + imageViewRef = new WeakReference(imageView); + } + + @Override + protected Bitmap doInBackground(LoaderTuple... params) { + tuple = params[0]; + + // Check whether it has been cancelled or the image view is gone + if (isCancelled() || imageViewRef.get() == null) { + System.out.println("Cancelled or no image view in doInBackground"); + return null; + } + + Bitmap bmp = diskLoader.loadBitmapFromCache(tuple, (int) scalingDivider); + if (bmp == null) { + // Report progress to display the placeholder + publishProgress(); + + // Try to load the asset from the network + bmp = doNetworkAssetLoad(tuple, this); + } + + // Cache the bitmap + if (bmp != null) { + loadFinished = true; + memoryLoader.populateCache(tuple, bmp); + } + + return bmp; + } + + @Override + protected void onProgressUpdate(Void... nothing) { + // Do nothing if the load has already completed + if (loadFinished) { + return; + } + + // Do nothing if cancelled + if (isCancelled()) { + return; + } + + final ImageView imageView = imageViewRef.get(); + if (imageView != null) { + // If the current loader task for this view isn't us, do nothing + if (getLoaderTask(imageView) != this) { return; } - Bitmap bmp = diskLoader.loadBitmapFromCache(tuple, (int) scalingDivider); - if (bmp == null) { - // Notify the listener that this may take a while - listener.notifyLongLoad(context); - - // Try 5 times maximum - for (int i = 0; i < 5; i++) { - // Check again whether we've been cancelled - if (tuple.cancelled) { - return; - } - - InputStream in = networkLoader.getBitmapStream(tuple); - if (in != null) { - // Write the stream straight to disk - diskLoader.populateCacheWithStream(tuple, in); - - // Read it back scaled - bmp = diskLoader.loadBitmapFromCache(tuple, (int) scalingDivider); - if (bmp != null) { - break; - } - } - - // Wait 1 second with a bit of fuzz - try { - Thread.sleep((int) (1000 + (Math.random() * 500))); - } catch (InterruptedException e) { - break; - } - } - } - - if (bmp != null) { - // Populate the memory cache - memoryLoader.populateCache(tuple, bmp); - } - - // Check one last time whether we've been cancelled - synchronized (tuple) { - if (tuple.cancelled) { - return; - } - else { - tuple.notified = true; - } - } - - // Call the load complete callback (possible with a null bitmap) - listener.notifyLoadComplete(context, bmp); + // Show the placeholder by setting alpha to 1.0 + imageView.setAlpha(1.0f); } - }; + } + + @Override + protected void onPostExecute(Bitmap bitmap) { + // Do nothing if cancelled + if (isCancelled()) { + return; + } + + final ImageView imageView = imageViewRef.get(); + if (imageView != null) { + // If the current loader task for this view isn't us, do nothing + if (getLoaderTask(imageView) != this) { + return; + } + + // Set the bitmap + if (bitmap != null) { + imageView.setImageBitmap(bitmap); + } + + // Show the view + imageView.setAlpha(1.0f); + } + } } - public LoaderTuple loadBitmapWithContext(NvApp app, Object context, LoadListener listener) { - return loadBitmapWithContext(app, context, listener, false); + static class AsyncDrawable extends BitmapDrawable { + private final WeakReference loaderTaskReference; + + public AsyncDrawable(Resources res, Bitmap bitmap, + LoaderTask loaderTask) { + super(res, bitmap); + loaderTaskReference = new WeakReference(loaderTask); + } + + public LoaderTask getLoaderTask() { + return loaderTaskReference.get(); + } } - public LoaderTuple loadBitmapWithContextInBackground(NvApp app, Object context, LoadListener listener) { - return loadBitmapWithContext(app, context, listener, true); + private static LoaderTask getLoaderTask(ImageView imageView) { + final Drawable drawable = imageView.getDrawable(); + + // If our drawable is in play, get the loader task + if (drawable instanceof AsyncDrawable) { + final AsyncDrawable asyncDrawable = (AsyncDrawable) drawable; + return asyncDrawable.getLoaderTask(); + } + + return null; } - private LoaderTuple loadBitmapWithContext(NvApp app, Object context, LoadListener listener, boolean background) { + private static boolean cancelPendingLoad(LoaderTuple tuple, ImageView imageView) { + final LoaderTask loaderTask = getLoaderTask(imageView); + + // Check if any task was pending for this image view + if (loaderTask != null && !loaderTask.isCancelled()) { + final LoaderTuple taskTuple = loaderTask.tuple; + + // Cancel the task if it's not already loading the same data + if (taskTuple == null || !taskTuple.equals(tuple)) { + loaderTask.cancel(true); + } else { + // It's already loading what we want + return false; + } + } + + // Allow the load to proceed + return true; + } + + public void queueCacheLoad(NvApp app) { + final LoaderTuple tuple = new LoaderTuple(computer, app); + + if (memoryLoader.loadBitmapFromCache(tuple) != null) { + // It's in memory which means it must also be on disk + return; + } + + // Queue a fetch in the cache executor + cacheExecutor.execute(new Runnable() { + @Override + public void run() { + Bitmap bmp; + + // Check if the image is cached on disk + bmp = diskLoader.loadBitmapFromCache(tuple, (int) scalingDivider); + if (bmp == null) { + // Try to load the asset from the network and cache on disk + bmp = doNetworkAssetLoad(tuple, null); + } + + // If the bitmap was loaded, recycle it immediately. We can do this + // because it's not loaded into any image views or cached in memory + if (bmp != null) { + bmp.recycle(); + } + } + }); + } + + public void populateImageView(NvApp app, ImageView view) { LoaderTuple tuple = new LoaderTuple(computer, app); // First, try the memory cache in the current context Bitmap bmp = memoryLoader.loadBitmapFromCache(tuple); if (bmp != null) { - // The caller never sees our tuple in this case - listener.notifyLoadComplete(context, bmp); - return null; + // Show the bitmap immediately + view.setImageBitmap(bmp); + return; } - // If it's not in memory, throw this in our executor - if (background) { - backgroundExecutor.execute(createLoaderRunnable(tuple, context, listener)); + // If there's already a task in progress for this view, + // cancel it. If the task is already loading the same image, + // we return and let that load finish. + if (!cancelPendingLoad(tuple, view)) { + return; } - else { - foregroundExecutor.execute(createLoaderRunnable(tuple, context, listener)); - } - return tuple; + + // If it's not in memory, create an async task to load it. This task will be attached + // via AsyncDrawable to this view. + final LoaderTask task = new LoaderTask(view); + final AsyncDrawable asyncDrawable = new AsyncDrawable(view.getResources(), placeholderBitmap, task); + view.setAlpha(0.0f); + view.setImageDrawable(asyncDrawable); + + // Run the task on our foreground executor + task.executeOnExecutor(foregroundExecutor, tuple); } public class LoaderTuple { public final ComputerDetails computer; public final NvApp app; - public boolean notified; - public boolean cancelled; - public LoaderTuple(ComputerDetails computer, NvApp app) { this.computer = computer; this.app = app; } - public boolean cancel() { - synchronized (this) { - cancelled = true; - return !notified; + @Override + public boolean equals(Object o) { + if (!(o instanceof LoaderTuple)) { + return false; } + + LoaderTuple other = (LoaderTuple) o; + return computer.uuid.equals(other.computer.uuid) && app.getAppId() == other.app.getAppId(); } @Override @@ -150,13 +320,4 @@ public class CachedAppAssetLoader { return "("+computer.uuid+", "+app.getAppId()+")"; } } - - public interface LoadListener { - // Notifies that the load didn't hit any cache and is about to be dispatched - // over the network - public void notifyLongLoad(Object context); - - // Bitmap may be null if the load failed - public void notifyLoadComplete(Object context, Bitmap bitmap); - } } diff --git a/app/src/main/java/com/limelight/grid/assets/NetworkAssetLoader.java b/app/src/main/java/com/limelight/grid/assets/NetworkAssetLoader.java index f1d09d13..6114f4ac 100644 --- a/app/src/main/java/com/limelight/grid/assets/NetworkAssetLoader.java +++ b/app/src/main/java/com/limelight/grid/assets/NetworkAssetLoader.java @@ -10,8 +10,6 @@ import com.limelight.nvstream.http.NvHTTP; import java.io.IOException; import java.io.InputStream; import java.net.InetAddress; -import java.security.KeyManagementException; -import java.security.NoSuchAlgorithmException; public class NetworkAssetLoader { private final Context context;