1 /* 2 * Copyright 2019 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.car.apps.common.imaging; 18 19 import android.annotation.UiThread; 20 import android.content.ContentResolver; 21 import android.content.Context; 22 import android.content.res.AssetFileDescriptor; 23 import android.content.res.Resources; 24 import android.graphics.Bitmap; 25 import android.graphics.ImageDecoder; 26 import android.graphics.drawable.BitmapDrawable; 27 import android.graphics.drawable.Drawable; 28 import android.net.Uri; 29 import android.os.AsyncTask; 30 import android.util.Log; 31 import android.util.LruCache; 32 33 import com.android.car.apps.common.BitmapUtils; 34 import com.android.car.apps.common.CommonFlags; 35 import com.android.car.apps.common.R; 36 import com.android.car.apps.common.UriUtils; 37 import com.android.car.apps.common.util.CarAppsIOUtils; 38 39 import libcore.io.IoUtils; 40 41 import java.io.BufferedInputStream; 42 import java.io.ByteArrayOutputStream; 43 import java.io.FileNotFoundException; 44 import java.io.IOException; 45 import java.io.InputStream; 46 import java.lang.ref.WeakReference; 47 import java.net.URL; 48 import java.util.HashMap; 49 import java.util.HashSet; 50 import java.util.Map; 51 import java.util.concurrent.CancellationException; 52 import java.util.concurrent.Executor; 53 import java.util.concurrent.Executors; 54 import java.util.function.BiConsumer; 55 56 57 /** 58 * A singleton that fetches images and offers a simple memory cache. The requests and the replies 59 * all happen on the UI thread. 60 */ 61 public class LocalImageFetcher { 62 63 private static final String TAG = "LocalImageFetcher"; 64 private static final boolean L_WARN = Log.isLoggable(TAG, Log.WARN); 65 private static final boolean L_DEBUG = Log.isLoggable(TAG, Log.DEBUG); 66 67 private static final int KB = 1024; 68 private static final int MB = KB * KB; 69 70 /** Should not be reset to null once created. */ 71 private static LocalImageFetcher sInstance; 72 73 /** Returns the singleton. */ getInstance(Context context)74 public static LocalImageFetcher getInstance(Context context) { 75 if (sInstance == null) { 76 sInstance = new LocalImageFetcher(context); 77 } 78 return sInstance; 79 } 80 81 private final int mPoolSize; 82 83 private final LruCache<String, Executor> mThreadPools; 84 85 private final Map<ImageKey, HashSet<BiConsumer<ImageKey, Drawable>>> mConsumers = 86 new HashMap<>(20); 87 private final Map<ImageKey, ImageLoadingTask> mTasks = new HashMap<>(20); 88 89 private final LruCache<ImageKey, Drawable> mMemoryCache; 90 91 private final boolean mFlagRemoteImages; 92 93 @UiThread LocalImageFetcher(Context context)94 private LocalImageFetcher(Context context) { 95 Resources res = context.getResources(); 96 int maxPools = res.getInteger(R.integer.image_fetcher_thread_pools_max_count); 97 mPoolSize = res.getInteger(R.integer.image_fetcher_thread_pool_size); 98 mThreadPools = new LruCache<>(maxPools); 99 100 int cacheSizeMB = res.getInteger(R.integer.bitmap_memory_cache_max_size_mb); 101 int drawableDefaultWeightKB = res.getInteger(R.integer.drawable_default_weight_kb); 102 mMemoryCache = new LruCache<ImageKey, Drawable>(cacheSizeMB * MB) { 103 @Override 104 protected int sizeOf(ImageKey key, Drawable drawable) { 105 if (drawable instanceof BitmapDrawable) { 106 return ((BitmapDrawable) drawable).getBitmap().getAllocationByteCount(); 107 } else { 108 // For now 109 // TODO(b/139386940): consider a more accurate sizing / caching strategy. 110 return drawableDefaultWeightKB * KB; 111 } 112 } 113 }; 114 115 mFlagRemoteImages = CommonFlags.getInstance(context).shouldFlagImproperImageRefs(); 116 } 117 getThreadPool(String packageName)118 private Executor getThreadPool(String packageName) { 119 Executor result = mThreadPools.get(packageName); 120 if (result == null) { 121 result = Executors.newFixedThreadPool(mPoolSize); 122 mThreadPools.put(packageName, result); 123 } 124 return result; 125 } 126 127 /** Fetches an image. The resulting drawable may be null. */ 128 @UiThread getImage(Context context, ImageKey key, BiConsumer<ImageKey, Drawable> consumer)129 public void getImage(Context context, ImageKey key, BiConsumer<ImageKey, Drawable> consumer) { 130 Drawable cached = mMemoryCache.get(key); 131 if (cached != null) { 132 consumer.accept(key, cached); 133 return; 134 } 135 136 ImageLoadingTask task = mTasks.get(key); 137 138 HashSet<BiConsumer<ImageKey, Drawable>> consumers = mConsumers.get(key); 139 if (consumers == null) { 140 consumers = new HashSet<>(3); 141 if (task != null && L_WARN) { 142 Log.w(TAG, "Expected no task here for " + key); 143 } 144 mConsumers.put(key, consumers); 145 } 146 consumers.add(consumer); 147 148 if (task == null) { 149 String packageName = UriUtils.getPackageName(context, key.mImageUri); 150 if (packageName != null) { 151 task = new ImageLoadingTask(context, key, mFlagRemoteImages); 152 mTasks.put(key, task); 153 task.executeOnExecutor(getThreadPool(packageName)); 154 if (L_DEBUG) { 155 Log.d(TAG, "Added task " + key.mImageUri); 156 } 157 } else { 158 Log.e(TAG, "No package for " + key.mImageUri); 159 } 160 } 161 } 162 163 /** Cancels a request made via {@link #getImage}. */ 164 @UiThread cancelRequest(ImageKey key, BiConsumer<ImageKey, Drawable> consumer)165 public void cancelRequest(ImageKey key, BiConsumer<ImageKey, Drawable> consumer) { 166 HashSet<BiConsumer<ImageKey, Drawable>> consumers = mConsumers.get(key); 167 if (consumers != null) { 168 boolean removed = consumers.remove(consumer); 169 if (consumers.isEmpty()) { 170 // Nobody else wants this image, remove the set and cancel the task. 171 mConsumers.remove(key); 172 ImageLoadingTask task = mTasks.remove(key); 173 if (task != null) { 174 task.cancel(true); 175 if (L_DEBUG) { 176 Log.d(TAG, "Canceled task " + key.mImageUri); 177 } 178 } else if (L_WARN) { 179 Log.w(TAG, "cancelRequest missing task for: " + key); 180 } 181 } 182 183 if (!removed && L_WARN) { 184 Log.w(TAG, "cancelRequest missing consumer for: " + key); 185 } 186 } else if (L_WARN) { 187 Log.w(TAG, "cancelRequest has no consumers for: " + key); 188 } 189 } 190 191 192 @UiThread fulfilRequests(ImageLoadingTask task, Drawable drawable)193 private void fulfilRequests(ImageLoadingTask task, Drawable drawable) { 194 ImageKey key = task.mImageKey; 195 ImageLoadingTask pendingTask = mTasks.get(key); 196 if (pendingTask == task) { 197 if (drawable != null) { 198 mMemoryCache.put(key, drawable); 199 } 200 201 HashSet<BiConsumer<ImageKey, Drawable>> consumers = mConsumers.remove(key); 202 mTasks.remove(key); 203 if (consumers != null) { 204 for (BiConsumer<ImageKey, Drawable> consumer : consumers) { 205 consumer.accept(key, drawable); 206 } 207 } 208 } else if (L_WARN) { 209 // This case would possible if a running task was canceled, a new one was restarted 210 // right away for the same key, and the canceled task still managed to call 211 // fulfilRequests (despite the !isCancelled check). 212 Log.w(TAG, "A new task already started for: " + task.mImageKey); 213 } 214 } 215 216 217 private static class ImageLoadingTask extends AsyncTask<Void, Void, Drawable> { 218 219 private final WeakReference<Context> mWeakContext; 220 private final ImageKey mImageKey; 221 private final boolean mFlagRemoteImages; 222 223 224 @UiThread ImageLoadingTask(Context context, ImageKey request, boolean flagRemoteImages)225 ImageLoadingTask(Context context, ImageKey request, boolean flagRemoteImages) { 226 mWeakContext = new WeakReference<>(context.getApplicationContext()); 227 mImageKey = request; 228 mFlagRemoteImages = flagRemoteImages; 229 } 230 231 /** Runs in the background. */ 232 private final ImageDecoder.OnHeaderDecodedListener mOnHeaderDecodedListener = 233 new ImageDecoder.OnHeaderDecodedListener() { 234 @Override 235 public void onHeaderDecoded(ImageDecoder decoder, ImageDecoder.ImageInfo info, 236 ImageDecoder.Source source) { 237 if (isCancelled()) throw new CancellationException(); 238 decoder.setAllocator(mAllocatorMode); 239 int maxW = mImageKey.mMaxImageSize.getWidth(); 240 int maxH = mImageKey.mMaxImageSize.getHeight(); 241 int imgW = info.getSize().getWidth(); 242 int imgH = info.getSize().getHeight(); 243 if (imgW > maxW || imgH > maxH) { 244 float scale = Math.min(maxW / (float) imgW, maxH / (float) imgH); 245 decoder.setTargetSize(Math.round(scale * imgW), Math.round(scale * imgH)); 246 } 247 } 248 }; 249 250 // ALLOCATOR_HARDWARE causes crashes on some emulators (in media center's queue). 251 private @ImageDecoder.Allocator int mAllocatorMode = ImageDecoder.ALLOCATOR_SOFTWARE; 252 253 @Override doInBackground(Void... voids)254 protected Drawable doInBackground(Void... voids) { 255 try { 256 if (isCancelled()) return null; 257 Uri imageUri = mImageKey.mImageUri; 258 259 Context context = mWeakContext.get(); 260 if (context == null) return null; 261 262 if (UriUtils.isAndroidResourceUri(imageUri)) { 263 // ImageDecoder doesn't support all resources via the content provider... 264 return UriUtils.getDrawable(context, 265 UriUtils.getIconResource(context, imageUri)); 266 } else if (UriUtils.isContentUri(imageUri)) { 267 ContentResolver resolver = context.getContentResolver(); 268 269 // TODO(b/140959390): Remove the check once the bug is fixed in framework. 270 if (!hasFile(resolver, imageUri)) { 271 if (L_WARN) { 272 Log.w(TAG, "File not found in uri: " + imageUri); 273 } 274 return null; 275 } 276 277 ImageDecoder.Source src = ImageDecoder.createSource(resolver, imageUri); 278 return ImageDecoder.decodeDrawable(src, mOnHeaderDecodedListener); 279 280 } else if (mFlagRemoteImages) { 281 mAllocatorMode = ImageDecoder.ALLOCATOR_SOFTWARE; // Needed for canvas drawing. 282 URL url = new URL(imageUri.toString()); 283 284 try (InputStream is = new BufferedInputStream(url.openStream()); 285 ByteArrayOutputStream bytes = new ByteArrayOutputStream()) { 286 287 CarAppsIOUtils.copy(is, bytes); 288 ImageDecoder.Source src = ImageDecoder.createSource(bytes.toByteArray()); 289 Bitmap decoded = ImageDecoder.decodeBitmap(src, mOnHeaderDecodedListener); 290 Bitmap tinted = BitmapUtils.createTintedBitmap(decoded, 291 context.getColor(R.color.improper_image_refs_tint_color)); 292 return new BitmapDrawable(context.getResources(), tinted); 293 } 294 } 295 } catch (IOException ioe) { 296 Log.e(TAG, "ImageLoadingTask#doInBackground: " + ioe); 297 } catch (CancellationException e) { 298 return null; 299 } 300 return null; 301 } 302 hasFile(ContentResolver resolver, Uri uri)303 private boolean hasFile(ContentResolver resolver, Uri uri) { 304 AssetFileDescriptor assetFd = null; 305 try { 306 if (uri.getScheme() == ContentResolver.SCHEME_CONTENT) { 307 assetFd = resolver.openTypedAssetFileDescriptor(uri, "image/*", null); 308 } else { 309 assetFd = resolver.openAssetFileDescriptor(uri, "r"); 310 } 311 } catch (FileNotFoundException e) { 312 // Some images cannot be opened as AssetFileDescriptors (e.g.bmp, ico). Open them 313 // as InputStreams. 314 try { 315 InputStream is = resolver.openInputStream(uri); 316 if (is != null) { 317 IoUtils.closeQuietly(is); 318 return true; 319 } 320 } catch (IOException exception) { 321 return false; 322 } 323 } 324 if (assetFd != null) { 325 IoUtils.closeQuietly(assetFd); 326 return true; 327 } 328 return false; 329 } 330 331 @UiThread 332 @Override onPostExecute(Drawable drawable)333 protected void onPostExecute(Drawable drawable) { 334 if (L_DEBUG) { 335 Log.d(TAG, "onPostExecute canceled: " + isCancelled() + " drawable: " + drawable 336 + " " + mImageKey.mImageUri); 337 } 338 if (!isCancelled()) { 339 if (sInstance != null) { 340 sInstance.fulfilRequests(this, drawable); 341 } else { 342 Log.e(TAG, "ImageLoadingTask#onPostExecute: LocalImageFetcher was reset !"); 343 } 344 } 345 } 346 } 347 } 348