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