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 17 package com.android.tv.util.images; 18 19 import android.content.Context; 20 import android.graphics.Bitmap; 21 import android.graphics.drawable.BitmapDrawable; 22 import android.graphics.drawable.Drawable; 23 import android.media.tv.TvInputInfo; 24 import android.os.AsyncTask; 25 import android.os.Handler; 26 import android.os.Looper; 27 import android.support.annotation.Nullable; 28 import android.support.annotation.UiThread; 29 import android.support.annotation.WorkerThread; 30 import android.util.ArraySet; 31 import android.util.Log; 32 33 import androidx.tvprovider.media.tv.TvContractCompat.PreviewPrograms; 34 35 import com.android.tv.R; 36 import com.android.tv.common.concurrent.NamedThreadFactory; 37 import com.android.tv.util.images.BitmapUtils.ScaledBitmapInfo; 38 39 import java.lang.ref.WeakReference; 40 import java.util.HashMap; 41 import java.util.Map; 42 import java.util.Set; 43 import java.util.concurrent.BlockingQueue; 44 import java.util.concurrent.Executor; 45 import java.util.concurrent.LinkedBlockingQueue; 46 import java.util.concurrent.RejectedExecutionException; 47 import java.util.concurrent.ThreadFactory; 48 import java.util.concurrent.ThreadPoolExecutor; 49 import java.util.concurrent.TimeUnit; 50 51 /** 52 * This class wraps up completing some arbitrary long running work when loading a bitmap. It handles 53 * things like using a memory cache, running the work in a background thread. 54 */ 55 public final class ImageLoader { 56 private static final String TAG = "ImageLoader"; 57 private static final boolean DEBUG = false; 58 59 private static final int CPU_COUNT = Runtime.getRuntime().availableProcessors(); 60 // We want at least 2 threads and at most 4 threads in the core pool, 61 // preferring to have 1 less than the CPU count to avoid saturating 62 // the CPU with background work 63 private static final int CORE_POOL_SIZE = Math.max(2, Math.min(CPU_COUNT - 1, 4)); 64 private static final int MAXIMUM_POOL_SIZE = CPU_COUNT * 2 + 1; 65 private static final int KEEP_ALIVE_SECONDS = 30; 66 67 private static final ThreadFactory sThreadFactory = new NamedThreadFactory("ImageLoader"); 68 69 private static final BlockingQueue<Runnable> sPoolWorkQueue = new LinkedBlockingQueue<>(128); 70 71 /** 72 * An private {@link Executor} that can be used to execute tasks in parallel. 73 * 74 * <p>{@code IMAGE_THREAD_POOL_EXECUTOR} setting are copied from {@link AsyncTask} Since we do a 75 * lot of concurrent image loading we can exhaust a thread pool. ImageLoader catches the error, 76 * and just leaves the image blank. However other tasks will fail and crash the application. 77 * 78 * <p>Using a separate thread pool prevents image loading from causing other tasks to fail. 79 */ 80 private static final Executor IMAGE_THREAD_POOL_EXECUTOR; 81 82 static { 83 ThreadPoolExecutor threadPoolExecutor = 84 new ThreadPoolExecutor( 85 CORE_POOL_SIZE, 86 MAXIMUM_POOL_SIZE, 87 KEEP_ALIVE_SECONDS, 88 TimeUnit.SECONDS, 89 sPoolWorkQueue, 90 sThreadFactory); 91 threadPoolExecutor.allowCoreThreadTimeOut(true); 92 IMAGE_THREAD_POOL_EXECUTOR = threadPoolExecutor; 93 } 94 95 private static Handler sMainHandler; 96 97 /** 98 * Handles when image loading is finished. 99 * 100 * <p>Use this to prevent leaking an Activity or other Context while image loading is still 101 * pending. When you extend this class you <strong>MUST NOT</strong> use a non static inner 102 * class, or the containing object will still be leaked. 103 */ 104 @UiThread 105 public abstract static class ImageLoaderCallback<T> { 106 private final WeakReference<T> mWeakReference; 107 108 /** 109 * Creates an callback keeping a weak reference to {@code referent}. 110 * 111 * <p>If the "referent" is no longer valid, it no longer makes sense to run the callback. 112 * The referent is the View, or Activity or whatever that actually needs to receive the 113 * Bitmap. If the referent has been GC, then no need to run the callback. 114 */ ImageLoaderCallback(T referent)115 public ImageLoaderCallback(T referent) { 116 mWeakReference = new WeakReference<>(referent); 117 } 118 119 /** Called when bitmap is loaded. */ onBitmapLoaded(@ullable Bitmap bitmap)120 private void onBitmapLoaded(@Nullable Bitmap bitmap) { 121 T referent = mWeakReference.get(); 122 if (referent != null) { 123 onBitmapLoaded(referent, bitmap); 124 } else { 125 if (DEBUG) Log.d(TAG, "onBitmapLoaded not called because weak reference is gone"); 126 } 127 } 128 129 /** Called when bitmap is loaded if the weak reference is still valid. */ onBitmapLoaded(T referent, @Nullable Bitmap bitmap)130 public abstract void onBitmapLoaded(T referent, @Nullable Bitmap bitmap); 131 } 132 133 private static final Map<String, LoadBitmapTask> sPendingListMap = new HashMap<>(); 134 135 /** 136 * Preload a bitmap image into the cache. 137 * 138 * <p>Not to make heavy CPU load, AsyncTask.SERIAL_EXECUTOR is used for the image loading. 139 * 140 * <p>This method is thread safe. 141 */ prefetchBitmap( Context context, final String uriString, final int maxWidth, final int maxHeight)142 public static void prefetchBitmap( 143 Context context, final String uriString, final int maxWidth, final int maxHeight) { 144 if (DEBUG) Log.d(TAG, "prefetchBitmap() " + uriString); 145 if (Looper.getMainLooper() == Looper.myLooper()) { 146 doLoadBitmap(context, uriString, maxWidth, maxHeight, null, AsyncTask.SERIAL_EXECUTOR); 147 } else { 148 final Context appContext = context.getApplicationContext(); 149 getMainHandler() 150 .post( 151 () -> 152 doLoadBitmap( 153 appContext, 154 uriString, 155 maxWidth, 156 maxHeight, 157 null, 158 AsyncTask.SERIAL_EXECUTOR)); 159 } 160 } 161 162 /** 163 * Load a bitmap image with the cache using a ContentResolver. 164 * 165 * <p><b>Note</b> that the callback will be called synchronously if the bitmap already is in the 166 * cache. 167 * 168 * @return {@code true} if the load is complete and the callback is executed. 169 */ 170 @UiThread loadBitmap( Context context, String uriString, ImageLoaderCallback<T> callback)171 public static <T> boolean loadBitmap( 172 Context context, String uriString, ImageLoaderCallback<T> callback) { 173 return loadBitmap(context, uriString, Integer.MAX_VALUE, Integer.MAX_VALUE, callback); 174 } 175 176 /** 177 * Load a bitmap image with the cache and resize it with given params. 178 * 179 * <p><b>Note</b> that the callback will be called synchronously if the bitmap already is in the 180 * cache. 181 * 182 * @return {@code true} if the load is complete and the callback is executed. 183 */ 184 @UiThread loadBitmap( Context context, String uriString, int maxWidth, int maxHeight, ImageLoaderCallback<T> callback)185 public static <T> boolean loadBitmap( 186 Context context, 187 String uriString, 188 int maxWidth, 189 int maxHeight, 190 ImageLoaderCallback<T> callback) { 191 if (DEBUG) { 192 Log.d(TAG, "loadBitmap() " + uriString); 193 } 194 return doLoadBitmap( 195 context, uriString, maxWidth, maxHeight, callback, IMAGE_THREAD_POOL_EXECUTOR); 196 } 197 doLoadBitmap( Context context, String uriString, int maxWidth, int maxHeight, ImageLoaderCallback<T> callback, Executor executor)198 private static <T> boolean doLoadBitmap( 199 Context context, 200 String uriString, 201 int maxWidth, 202 int maxHeight, 203 ImageLoaderCallback<T> callback, 204 Executor executor) { 205 // Check the cache before creating a Task. The cache will be checked again in doLoadBitmap 206 // but checking a cache is much cheaper than creating an new task. 207 ImageCache imageCache = ImageCache.getInstance(); 208 ScaledBitmapInfo bitmapInfo = imageCache.get(uriString); 209 if (bitmapInfo != null && !bitmapInfo.needToReload(maxWidth, maxHeight)) { 210 if (callback != null) { 211 callback.onBitmapLoaded(bitmapInfo.bitmap); 212 } 213 return true; 214 } 215 return doLoadBitmap( 216 callback, 217 executor, 218 new LoadBitmapFromUriTask(context, imageCache, uriString, maxWidth, maxHeight)); 219 } 220 221 /** 222 * Load a bitmap image with the cache and resize it with given params. 223 * 224 * <p>The LoadBitmapTask will be executed on a non ui thread. 225 * 226 * @return {@code true} if the load is complete and the callback is executed. 227 */ 228 @UiThread loadBitmap( ImageLoaderCallback<T> callback, LoadBitmapTask<T> loadBitmapTask)229 public static <T> boolean loadBitmap( 230 ImageLoaderCallback<T> callback, LoadBitmapTask<T> loadBitmapTask) { 231 if (DEBUG) { 232 Log.d(TAG, "loadBitmap() " + loadBitmapTask); 233 } 234 return doLoadBitmap(callback, IMAGE_THREAD_POOL_EXECUTOR, loadBitmapTask); 235 } 236 237 /** @return {@code true} if the load is complete and the callback is executed. */ 238 @UiThread doLoadBitmap( ImageLoaderCallback<T> callback, Executor executor, LoadBitmapTask<T> loadBitmapTask)239 private static <T> boolean doLoadBitmap( 240 ImageLoaderCallback<T> callback, Executor executor, LoadBitmapTask<T> loadBitmapTask) { 241 ScaledBitmapInfo bitmapInfo = loadBitmapTask.getFromCache(); 242 boolean needToReload = loadBitmapTask.isReloadNeeded(); 243 if (bitmapInfo != null && !needToReload) { 244 if (callback != null) { 245 callback.onBitmapLoaded(bitmapInfo.bitmap); 246 } 247 return true; 248 } 249 LoadBitmapTask existingTask = sPendingListMap.get(loadBitmapTask.getKey()); 250 if (existingTask != null && !loadBitmapTask.isReloadNeeded(existingTask)) { 251 // The image loading is already scheduled and is large enough. 252 if (callback != null) { 253 existingTask.mCallbacks.add(callback); 254 } 255 } else { 256 if (callback != null) { 257 loadBitmapTask.mCallbacks.add(callback); 258 } 259 sPendingListMap.put(loadBitmapTask.getKey(), loadBitmapTask); 260 try { 261 loadBitmapTask.executeOnExecutor(executor); 262 } catch (RejectedExecutionException e) { 263 Log.e(TAG, "Failed to create new image loader", e); 264 sPendingListMap.remove(loadBitmapTask.getKey()); 265 } 266 } 267 return false; 268 } 269 270 /** 271 * Loads and caches a a possibly scaled down version of a bitmap. 272 * 273 * <p>Implement {@link #doGetBitmapInBackground} to do the actual loading. 274 */ 275 public abstract static class LoadBitmapTask<T> extends AsyncTask<Void, Void, ScaledBitmapInfo> { 276 protected final Context mAppContext; 277 protected final int mMaxWidth; 278 protected final int mMaxHeight; 279 private final Set<ImageLoaderCallback<T>> mCallbacks = new ArraySet<>(); 280 private final ImageCache mImageCache; 281 private final String mKey; 282 283 /** 284 * Returns true if a reload is needed compared to current results in the cache or false if 285 * there is not match in the cache. 286 */ isReloadNeeded()287 private boolean isReloadNeeded() { 288 ScaledBitmapInfo bitmapInfo = getFromCache(); 289 boolean needToReload = 290 bitmapInfo != null && bitmapInfo.needToReload(mMaxWidth, mMaxHeight); 291 if (DEBUG) { 292 if (needToReload) { 293 Log.d( 294 TAG, 295 "Bitmap needs to be reloaded. {" 296 + "originalWidth=" 297 + bitmapInfo.bitmap.getWidth() 298 + ", originalHeight=" 299 + bitmapInfo.bitmap.getHeight() 300 + ", reqWidth=" 301 + mMaxWidth 302 + ", reqHeight=" 303 + mMaxHeight 304 + "}"); 305 } 306 } 307 return needToReload; 308 } 309 310 /** Checks if a reload would be needed if the results of other was available. */ isReloadNeeded(LoadBitmapTask other)311 private boolean isReloadNeeded(LoadBitmapTask other) { 312 return (other.mMaxHeight != Integer.MAX_VALUE && mMaxHeight >= other.mMaxHeight * 2) 313 || (other.mMaxWidth != Integer.MAX_VALUE && mMaxWidth >= other.mMaxWidth * 2); 314 } 315 316 @Nullable getFromCache()317 public final ScaledBitmapInfo getFromCache() { 318 return mImageCache.get(mKey); 319 } 320 LoadBitmapTask( Context context, ImageCache imageCache, String key, int maxHeight, int maxWidth)321 public LoadBitmapTask( 322 Context context, ImageCache imageCache, String key, int maxHeight, int maxWidth) { 323 if (maxWidth == 0 || maxHeight == 0) { 324 throw new IllegalArgumentException( 325 "Image size should not be 0. {width=" 326 + maxWidth 327 + ", height=" 328 + maxHeight 329 + "}"); 330 } 331 mAppContext = context.getApplicationContext(); 332 mKey = key; 333 mImageCache = imageCache; 334 mMaxHeight = maxHeight; 335 mMaxWidth = maxWidth; 336 } 337 338 /** Loads the bitmap returning a possibly scaled down version. */ 339 @Nullable 340 @WorkerThread doGetBitmapInBackground()341 public abstract ScaledBitmapInfo doGetBitmapInBackground(); 342 343 @Override 344 @Nullable doInBackground(Void... params)345 public final ScaledBitmapInfo doInBackground(Void... params) { 346 ScaledBitmapInfo bitmapInfo = getFromCache(); 347 if (bitmapInfo != null && !isReloadNeeded()) { 348 return bitmapInfo; 349 } 350 bitmapInfo = doGetBitmapInBackground(); 351 if (bitmapInfo != null) { 352 mImageCache.putIfNeeded(bitmapInfo); 353 } 354 return bitmapInfo; 355 } 356 357 @Override onPostExecute(ScaledBitmapInfo scaledBitmapInfo)358 public final void onPostExecute(ScaledBitmapInfo scaledBitmapInfo) { 359 if (DEBUG) Log.d(ImageLoader.TAG, "Bitmap is loaded " + mKey); 360 361 for (ImageLoader.ImageLoaderCallback<T> callback : mCallbacks) { 362 callback.onBitmapLoaded(scaledBitmapInfo == null ? null : scaledBitmapInfo.bitmap); 363 } 364 ImageLoader.sPendingListMap.remove(mKey); 365 } 366 getKey()367 public final String getKey() { 368 return mKey; 369 } 370 371 @Override toString()372 public String toString() { 373 return this.getClass().getSimpleName() 374 + "(" 375 + mKey 376 + " " 377 + mMaxWidth 378 + "x" 379 + mMaxHeight 380 + ")"; 381 } 382 } 383 384 private static final class LoadBitmapFromUriTask<T> extends LoadBitmapTask<T> { LoadBitmapFromUriTask( Context context, ImageCache imageCache, String uriString, int maxWidth, int maxHeight)385 private LoadBitmapFromUriTask( 386 Context context, 387 ImageCache imageCache, 388 String uriString, 389 int maxWidth, 390 int maxHeight) { 391 super(context, imageCache, uriString, maxHeight, maxWidth); 392 } 393 394 @Override 395 @Nullable doGetBitmapInBackground()396 public final ScaledBitmapInfo doGetBitmapInBackground() { 397 return BitmapUtils.decodeSampledBitmapFromUriString( 398 mAppContext, getKey(), mMaxWidth, mMaxHeight); 399 } 400 } 401 402 /** Loads and caches the logo for a given {@link TvInputInfo} */ 403 public static final class LoadTvInputLogoTask<T> extends LoadBitmapTask<T> { 404 private final TvInputInfo mInfo; 405 LoadTvInputLogoTask(Context context, ImageCache cache, TvInputInfo info)406 public LoadTvInputLogoTask(Context context, ImageCache cache, TvInputInfo info) { 407 super( 408 context, 409 cache, 410 getTvInputLogoKey(info.getId()), 411 context.getResources() 412 .getDimensionPixelSize(R.dimen.channel_banner_input_logo_size), 413 context.getResources() 414 .getDimensionPixelSize(R.dimen.channel_banner_input_logo_size)); 415 mInfo = info; 416 } 417 418 @Nullable 419 @Override doGetBitmapInBackground()420 public ScaledBitmapInfo doGetBitmapInBackground() { 421 Drawable drawable = mInfo.loadIcon(mAppContext); 422 Bitmap bm = 423 drawable instanceof BitmapDrawable 424 ? ((BitmapDrawable) drawable).getBitmap() 425 : BitmapUtils.drawableToBitmap(drawable); 426 return bm == null 427 ? null 428 : BitmapUtils.createScaledBitmapInfo(getKey(), bm, mMaxWidth, mMaxHeight); 429 } 430 431 /** Returns key of TV input logo. */ getTvInputLogoKey(String inputId)432 public static String getTvInputLogoKey(String inputId) { 433 return inputId + "-logo"; 434 } 435 } 436 437 /** 438 * Calculates Aspect Ratio of Poster Art from Uri. 439 * 440 * <p><b>Note</b> the function will check the cache before loading the bitmap 441 * 442 * @return the Aspect Ratio of the Poster Art. 443 */ getAspectRatioFromPosterArtUri(Context context, String uriString)444 public static int getAspectRatioFromPosterArtUri(Context context, String uriString) { 445 // Check the cache before loading the bitmap. 446 ImageCache imageCache = ImageCache.getInstance(); 447 ScaledBitmapInfo bitmapInfo = imageCache.get(uriString); 448 int bitmapWidth; 449 int bitmapHeight; 450 float bitmapAspectRatio; 451 int aspectRatio; 452 if (bitmapInfo == null) { 453 bitmapInfo = 454 BitmapUtils.decodeSampledBitmapFromUriString( 455 context, uriString, Integer.MAX_VALUE, Integer.MAX_VALUE); 456 } 457 bitmapWidth = bitmapInfo.bitmap.getWidth(); 458 bitmapHeight = bitmapInfo.bitmap.getHeight(); 459 bitmapAspectRatio = (float) bitmapWidth / bitmapHeight; 460 /* Assign nearest aspect ratio from the defined values in Preview Programs */ 461 if (bitmapAspectRatio > 0 && bitmapAspectRatio <= 0.6803) { 462 aspectRatio = PreviewPrograms.ASPECT_RATIO_2_3; 463 } else if (bitmapAspectRatio > 0.6803 && bitmapAspectRatio <= 0.8469) { 464 aspectRatio = PreviewPrograms.ASPECT_RATIO_MOVIE_POSTER; 465 } else if (bitmapAspectRatio > 0.8469 && bitmapAspectRatio <= 1.1667) { 466 aspectRatio = PreviewPrograms.ASPECT_RATIO_1_1; 467 } else if (bitmapAspectRatio > 1.1667 && bitmapAspectRatio <= 1.4167) { 468 aspectRatio = PreviewPrograms.ASPECT_RATIO_4_3; 469 } else if (bitmapAspectRatio > 1.4167 && bitmapAspectRatio <= 1.6389) { 470 aspectRatio = PreviewPrograms.ASPECT_RATIO_3_2; 471 } else { 472 aspectRatio = PreviewPrograms.ASPECT_RATIO_16_9; 473 } 474 return aspectRatio; 475 } 476 getMainHandler()477 private static synchronized Handler getMainHandler() { 478 if (sMainHandler == null) { 479 sMainHandler = new Handler(Looper.getMainLooper()); 480 } 481 return sMainHandler; 482 } 483 ImageLoader()484 private ImageLoader() {} 485 } 486