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