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