1 /*
2  * Copyright 2018 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.pump.util;
18 
19 import android.content.ContentResolver;
20 import android.content.UriMatcher;
21 import android.content.res.AssetFileDescriptor;
22 import android.graphics.Bitmap;
23 import android.graphics.BitmapFactory;
24 import android.net.Uri;
25 import android.os.Build;
26 import android.provider.MediaStore;
27 
28 import androidx.annotation.AnyThread;
29 import androidx.annotation.NonNull;
30 import androidx.annotation.Nullable;
31 import androidx.collection.ArrayMap;
32 import androidx.collection.ArraySet;
33 
34 import com.android.pump.concurrent.Executors;
35 
36 import java.io.File;
37 import java.io.FileNotFoundException;
38 import java.io.IOException;
39 import java.util.AbstractMap.SimpleEntry;
40 import java.util.LinkedList;
41 import java.util.List;
42 import java.util.Map;
43 import java.util.Set;
44 import java.util.concurrent.Executor;
45 
46 @AnyThread
47 public class ImageLoader {
48     private static final String TAG = Clog.tag(ImageLoader.class);
49 
50     // TODO Replace with Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q throughout the code.
isAtLeastRunningQ()51     private static boolean isAtLeastRunningQ() {
52         return Build.VERSION.SDK_INT > Build.VERSION_CODES.P
53                 || (Build.VERSION.SDK_INT == Build.VERSION_CODES.P
54                 && Build.VERSION.PREVIEW_SDK_INT > 0);
55     }
56 
57     private static final UriMatcher VIDEO_THUMBNAIL_URI_MATCHER =
58             new UriMatcher(UriMatcher.NO_MATCH);
59     static {
60         VIDEO_THUMBNAIL_URI_MATCHER.addURI("media", "*/video/media/#/thumbnail", 0);
61     }
62 
63     private final BitmapCache mBitmapCache = new BitmapCache();
64     private final OrientationCache mOrientationCache = new OrientationCache();
65     private final ContentResolver mContentResolver;
66     private final Executor mExecutor;
67     private final Set<Map.Entry<Executor, Callback>> mCallbacks = new ArraySet<>();
68     private final Map<Uri, List<Map.Entry<Executor, Callback>>> mLoadCallbacks = new ArrayMap<>();
69 
70     @FunctionalInterface
71     public interface Callback {
onImageLoaded(@onNull Uri uri, @Nullable Bitmap bitmap)72         void onImageLoaded(@NonNull Uri uri, @Nullable Bitmap bitmap);
73     }
74 
ImageLoader(@onNull ContentResolver contentResolver, @NonNull Executor executor)75     public ImageLoader(@NonNull ContentResolver contentResolver, @NonNull Executor executor) {
76         mContentResolver = contentResolver;
77         mExecutor = executor;
78     }
79 
addCallback(@onNull Callback callback)80     public void addCallback(@NonNull Callback callback) {
81         addCallback(callback, Executors.uiThreadExecutor());
82     }
83 
addCallback(@onNull Callback callback, @NonNull Executor executor)84     public void addCallback(@NonNull Callback callback, @NonNull Executor executor) {
85         synchronized (this) { // TODO(b/123708613) other lock
86             if (!mCallbacks.add(new SimpleEntry<>(executor, callback))) {
87                 throw new IllegalArgumentException("Callback " + callback + " already added");
88             }
89         }
90     }
91 
removeCallback(@onNull Callback callback)92     public void removeCallback(@NonNull Callback callback) {
93         removeCallback(callback, Executors.uiThreadExecutor());
94     }
95 
removeCallback(@onNull Callback callback, @NonNull Executor executor)96     public void removeCallback(@NonNull Callback callback, @NonNull Executor executor) {
97         synchronized (this) { // TODO(b/123708613) other lock
98             if (!mCallbacks.remove(new SimpleEntry<>(executor, callback))) {
99                 throw new IllegalArgumentException("Callback " + callback + " not found");
100             }
101         }
102     }
103 
loadImage(@onNull Uri uri, @NonNull Callback callback)104     public void loadImage(@NonNull Uri uri, @NonNull Callback callback) {
105         loadImage(uri, callback, Executors.uiThreadExecutor());
106     }
107 
loadImage(@onNull Uri uri, @NonNull Callback callback, @NonNull Executor executor)108     public void loadImage(@NonNull Uri uri, @NonNull Callback callback,
109             @NonNull Executor executor) {
110         Bitmap bitmap;
111         Runnable loader = null;
112         synchronized (this) { // TODO(b/123708613) other lock
113             bitmap = mBitmapCache.get(uri);
114             if (bitmap == null) {
115                 List<Map.Entry<Executor, Callback>> callbacks = mLoadCallbacks.get(uri);
116                 if (callbacks == null) {
117                     callbacks = new LinkedList<>();
118                     mLoadCallbacks.put(uri, callbacks);
119                     loader = new ImageLoaderTask(uri);
120                 }
121                 callbacks.add(new SimpleEntry<>(executor, callback));
122             }
123         }
124         if (bitmap != null) {
125             executor.execute(() -> callback.onImageLoaded(uri, bitmap));
126         } else if (loader != null) {
127             mExecutor.execute(loader);
128         }
129     }
130 
getOrientation(@onNull Uri uri)131     public @Orientation int getOrientation(@NonNull Uri uri) {
132         return mOrientationCache.get(uri);
133     }
134 
135     private class ImageLoaderTask implements Runnable {
136         private final Uri mUri;
137 
ImageLoaderTask(@onNull Uri uri)138         private ImageLoaderTask(@NonNull Uri uri) {
139             mUri = uri;
140         }
141 
142         @Override
run()143         public void run() {
144             try {
145                 Bitmap bitmap;
146                 if (isAtLeastRunningQ() || !isVideoThumbnailUri(mUri)) {
147                     byte[] data;
148                     if (Scheme.isContent(mUri)) {
149                         data = readFromContent(mUri);
150                     } else if (Scheme.isFile(mUri)) {
151                         data = IoUtils.readFromFile(new File(mUri.getPath()));
152                     } else if (Scheme.isHttp(mUri) || Scheme.isHttps(mUri)) {
153                         data = Http.get(mUri.toString());
154                     } else {
155                         throw new IllegalArgumentException(
156                                 "Unknown scheme '" + mUri.getScheme() + "'");
157                     }
158                     bitmap = decodeBitmapFromByteArray(data);
159                 } else {
160                     // TODO This will always return a bitmap which is inconsistent with Q.
161                     bitmap = MediaStore.Video.Thumbnails.getThumbnail(mContentResolver,
162                             Long.parseLong(mUri.getPathSegments().get(3)),
163                             MediaStore.Video.Thumbnails.MINI_KIND, null);
164                 }
165                 Set<Map.Entry<Executor, Callback>> callbacks;
166                 List<Map.Entry<Executor, Callback>> loadCallbacks;
167                 synchronized (ImageLoader.this) { // TODO(b/123708613) proper lock
168                     if (bitmap != null) {
169                         mBitmapCache.put(mUri, bitmap);
170                         mOrientationCache.put(mUri, bitmap);
171                     }
172                     callbacks = new ArraySet<>(mCallbacks);
173                     loadCallbacks = mLoadCallbacks.remove(mUri);
174                 }
175                 for (Map.Entry<Executor, Callback> callback : callbacks) {
176                     callback.getKey().execute(() ->
177                             callback.getValue().onImageLoaded(mUri, bitmap));
178                 }
179                 for (Map.Entry<Executor, Callback> callback : loadCallbacks) {
180                     callback.getKey().execute(() ->
181                             callback.getValue().onImageLoaded(mUri, bitmap));
182                 }
183             } catch (IOException | OutOfMemoryError e) {
184                 Clog.e(TAG, "Failed to load image " + mUri, e);
185                 // TODO(b/123708676) remove from mLoadCallbacks
186             }
187         }
188 
decodeBitmapFromByteArray(@onNull byte[] data)189         private @Nullable Bitmap decodeBitmapFromByteArray(@NonNull byte[] data) {
190             BitmapFactory.Options options = new BitmapFactory.Options();
191 
192             options.inJustDecodeBounds = true;
193             BitmapFactory.decodeByteArray(data, 0, data.length, options);
194 
195             options.inJustDecodeBounds = false;
196             options.inSampleSize = 1; // TODO(b/123708796) add scaling
197             return BitmapFactory.decodeByteArray(data, 0, data.length, options);
198         }
199 
readFromContent(@onNull Uri uri)200         private @NonNull byte[] readFromContent(@NonNull Uri uri) throws IOException {
201             // TODO(b/123708796) set EXTRA_SIZE in opts
202             AssetFileDescriptor assetFileDescriptor =
203                     mContentResolver.openTypedAssetFileDescriptor(uri, "image/*", null);
204             if (assetFileDescriptor == null) {
205                 throw new FileNotFoundException(uri.toString());
206             }
207             try {
208                 return IoUtils.readFromAssetFileDescriptor(assetFileDescriptor);
209             } finally {
210                 IoUtils.close(assetFileDescriptor);
211             }
212         }
213 
isVideoThumbnailUri(@onNull Uri uri)214         private boolean isVideoThumbnailUri(@NonNull Uri uri) {
215             return VIDEO_THUMBNAIL_URI_MATCHER.match(uri) != UriMatcher.NO_MATCH;
216         }
217     }
218 }
219