1 /*
2  * Copyright (C) 2015 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 package com.android.messaging.datamodel.media;
17 
18 import android.os.AsyncTask;
19 
20 import com.android.messaging.Factory;
21 import com.android.messaging.util.Assert;
22 import com.android.messaging.util.Assert.RunsOnAnyThread;
23 import com.android.messaging.util.LogUtil;
24 import com.google.common.annotations.VisibleForTesting;
25 
26 import java.util.ArrayList;
27 import java.util.List;
28 import java.util.concurrent.Executor;
29 import java.util.concurrent.Executors;
30 import java.util.concurrent.ThreadFactory;
31 
32 /**
33  * <p>Loads and maintains a set of in-memory LRU caches for different types of media resources.
34  * Right now we don't utilize any disk cache as all media urls are expected to be resolved to
35  * local content.<p/>
36  *
37  * <p>The MediaResourceManager takes media loading requests through one of two ways:</p>
38  *
39  * <ol>
40  * <li>{@link #requestMediaResourceAsync(MediaRequest)} that takes a MediaRequest, which may be a
41  *  regular request if the caller doesn't want to listen for events (fire-and-forget),
42  *  or an async request wrapper if event callback is needed.</li>
43  * <li>{@link #requestMediaResourceSync(MediaRequest)} which takes a MediaRequest and synchronously
44  *  returns the loaded result, or null if failed.</li>
45  * </ol>
46  *
47  * <p>For each media loading task, MediaResourceManager starts an AsyncTask that runs on a
48  * dedicated thread, which calls MediaRequest.loadMediaBlocking() to perform the actual media
49  * loading work. As the media resources are loaded, MediaResourceManager notifies the callers
50  * (which must implement the MediaResourceLoadListener interface) via onMediaResourceLoaded()
51  * callback. Meanwhile, MediaResourceManager also pushes the loaded resource onto its dedicated
52  * cache.</p>
53  *
54  * <p>The media resource caches ({@link MediaCache}) are maintained as a set of LRU caches. They are
55  * created on demand by the incoming MediaRequest's getCacheId() method. The implementations of
56  * MediaRequest (such as {@link ImageRequest}) get to determine the desired cache id. For Bugle,
57  * the list of available caches are in {@link BugleMediaCacheManager}</p>
58  *
59  * <p>Optionally, media loading can support on-demand media encoding and decoding.
60  * All {@link MediaRequest}'s can opt to chain additional {@link MediaRequest}'s to be executed
61  * after the completion of the main media loading task, by adding new tasks to the chained
62  * task list in {@link MediaRequest#loadMediaBlocking(List)}. One possible type of chained task is
63  * media encoding task. Loaded media will be encoded on a dedicated single threaded executor
64  * *after* the UI is notified of the loaded media. In this case, the encoded media resource will
65  * be eventually pushed to the cache, which will later be decoded before posting to the UI thread
66  * on cache hit.</p>
67  *
68  * <p><b>To add support for a new type of media resource,</b></p>
69  *
70  * <ol>
71  * <li>Create a new subclass of {@link RefCountedMediaResource} for the new resource type (example:
72  *    {@link ImageResource} class).</li>
73  *
74  * <li>Implement the {@link MediaRequest} interface (example: {@link ImageRequest}). Perform the
75  *    media loading work in loadMediaBlocking() and return a cache id in getCacheId().</li>
76  *
77  * <li>For the UI component that requests the media resource, let it implement
78  *    {@link MediaResourceLoadListener} interface to listen for resource load callback. Let the
79  *    UI component call MediaResourceManager.requestMediaResourceAsync() to request a media source.
80  *    (example: {@link com.android.messaging.ui.ContactIconView}</li>
81  * </ol>
82  */
83 public class MediaResourceManager {
84     private static final String TAG = LogUtil.BUGLE_TAG;
85 
get()86     public static MediaResourceManager get() {
87         return Factory.get().getMediaResourceManager();
88     }
89 
90     /**
91      * Listener for asynchronous callback from media loading events.
92      */
93     public interface MediaResourceLoadListener<T extends RefCountedMediaResource> {
onMediaResourceLoaded(MediaRequest<T> request, T resource, boolean cached)94         void onMediaResourceLoaded(MediaRequest<T> request, T resource, boolean cached);
onMediaResourceLoadError(MediaRequest<T> request, Exception exception)95         void onMediaResourceLoadError(MediaRequest<T> request, Exception exception);
96     }
97 
98     // We use a fixed thread pool for handling media loading tasks. Using a cached thread pool
99     // allows for unlimited thread creation which can lead to OOMs so we limit the threads here.
100     private static final Executor MEDIA_LOADING_EXECUTOR = Executors.newFixedThreadPool(10);
101 
102     // A dedicated single thread executor for performing background task after loading the resource
103     // on the media loading executor. This includes work such as encoding loaded media to be cached.
104     // These tasks are run on a single worker thread with low priority so as not to contend with the
105     // media loading tasks.
106     private static final Executor MEDIA_BACKGROUND_EXECUTOR = Executors.newSingleThreadExecutor(
107             new ThreadFactory() {
108                 @Override
109                 public Thread newThread(final Runnable runnable) {
110                     final Thread encodingThread = new Thread(runnable);
111                     encodingThread.setPriority(Thread.MIN_PRIORITY);
112                     return encodingThread;
113                 }
114             });
115 
116     /**
117      * Requests a media resource asynchronously. Upon completion of the media loading task,
118      * the listener will be notified of success/failure iff it's still bound. A refcount on the
119      * resource is held and guaranteed for the caller for the duration of the
120      * {@link MediaResourceLoadListener#onMediaResourceLoaded(
121      * MediaRequest, RefCountedMediaResource, boolean)} callback.
122      * @param mediaRequest the media request. May be either an
123      * {@link AsyncMediaRequestWrapper} for listening for event callbacks, or a regular media
124      * request for fire-and-forget type of behavior.
125      */
requestMediaResourceAsync( final MediaRequest<T> mediaRequest)126     public <T extends RefCountedMediaResource> void requestMediaResourceAsync(
127             final MediaRequest<T> mediaRequest) {
128         scheduleAsyncMediaRequest(mediaRequest, MEDIA_LOADING_EXECUTOR);
129     }
130 
131     /**
132      * Requests a media resource synchronously.
133      * @return the loaded resource with a refcount reserved for the caller. The caller must call
134      * release() on the resource once it's done using it (like with Cursors).
135      */
requestMediaResourceSync( final MediaRequest<T> mediaRequest)136     public <T extends RefCountedMediaResource> T requestMediaResourceSync(
137             final MediaRequest<T> mediaRequest) {
138         Assert.isNotMainThread();
139         // Block and load media.
140         MediaLoadingResult<T> loadResult = null;
141         try {
142             loadResult = processMediaRequestInternal(mediaRequest);
143             // The loaded resource should have at least one refcount by now reserved for the caller.
144             Assert.isTrue(loadResult.loadedResource.getRefCount() > 0);
145             return loadResult.loadedResource;
146         } catch (final Exception e) {
147             LogUtil.e(LogUtil.BUGLE_TAG, "Synchronous media loading failed, key=" +
148                     mediaRequest.getKey(), e);
149             return null;
150         } finally {
151             if (loadResult != null) {
152                 // Schedule the background requests chained to the main request.
153                 loadResult.scheduleChainedRequests();
154             }
155         }
156     }
157 
158     @SuppressWarnings("unchecked")
processMediaRequestInternal( final MediaRequest<T> mediaRequest)159     private <T extends RefCountedMediaResource> MediaLoadingResult<T> processMediaRequestInternal(
160             final MediaRequest<T> mediaRequest)
161                     throws Exception {
162         final List<MediaRequest<T>> chainedRequests = new ArrayList<>();
163         T loadedResource = null;
164         // Try fetching from cache first.
165         final T cachedResource = loadMediaFromCache(mediaRequest);
166         if (cachedResource != null) {
167             if (cachedResource.isEncoded()) {
168                 // The resource is encoded, issue a decoding request.
169                 final MediaRequest<T> decodeRequest = (MediaRequest<T>) cachedResource
170                         .getMediaDecodingRequest(mediaRequest);
171                 Assert.notNull(decodeRequest);
172                 cachedResource.release();
173                 loadedResource = loadMediaFromRequest(decodeRequest, chainedRequests);
174             } else {
175                 // The resource is ready-to-use.
176                 loadedResource = cachedResource;
177             }
178         } else {
179             // Actually load the media after cache miss.
180             loadedResource = loadMediaFromRequest(mediaRequest, chainedRequests);
181         }
182         return new MediaLoadingResult<>(loadedResource, cachedResource != null /* fromCache */,
183                 chainedRequests);
184     }
185 
loadMediaFromCache( final MediaRequest<T> mediaRequest)186     private <T extends RefCountedMediaResource> T loadMediaFromCache(
187             final MediaRequest<T> mediaRequest) {
188         if (mediaRequest.getRequestType() != MediaRequest.REQUEST_LOAD_MEDIA) {
189             // Only look up in the cache if we are loading media.
190             return null;
191         }
192         final MediaCache<T> mediaCache = mediaRequest.getMediaCache();
193         if (mediaCache != null) {
194             final T mediaResource = mediaCache.fetchResourceFromCache(mediaRequest.getKey());
195             if (mediaResource != null) {
196                 return mediaResource;
197             }
198         }
199         return null;
200     }
201 
loadMediaFromRequest( final MediaRequest<T> mediaRequest, final List<MediaRequest<T>> chainedRequests)202     private <T extends RefCountedMediaResource> T loadMediaFromRequest(
203             final MediaRequest<T> mediaRequest, final List<MediaRequest<T>> chainedRequests)
204                     throws Exception {
205         final T resource = mediaRequest.loadMediaBlocking(chainedRequests);
206         // mediaRequest.loadMediaBlocking() should never return null without
207         // throwing an exception.
208         Assert.notNull(resource);
209         // It's possible for the media to be evicted right after it's added to
210         // the cache (possibly because it's by itself too big for the cache).
211         // It's also possible that, after added to the cache, something else comes
212         // to the cache and evicts this media resource. To prevent this from
213         // recycling the underlying resource objects, make sure to add ref before
214         // adding to cache so that the caller is guaranteed a ref on the resource.
215         resource.addRef();
216         // Don't cache the media request if it is defined as non-cacheable.
217         if (resource.isCacheable()) {
218             addResourceToMemoryCache(mediaRequest, resource);
219         }
220         return resource;
221     }
222 
223     /**
224      * Schedule an async media request on the given <code>executor</code>.
225      * @param mediaRequest the media request to be processed asynchronously. May be either an
226      * {@link AsyncMediaRequestWrapper} for listening for event callbacks, or a regular media
227      * request for fire-and-forget type of behavior.
228      */
scheduleAsyncMediaRequest( final MediaRequest<T> mediaRequest, final Executor executor)229     private <T extends RefCountedMediaResource> void scheduleAsyncMediaRequest(
230             final MediaRequest<T> mediaRequest, final Executor executor) {
231         final BindableMediaRequest<T> bindableRequest =
232                 (mediaRequest instanceof BindableMediaRequest<?>) ?
233                         (BindableMediaRequest<T>) mediaRequest : null;
234         if (bindableRequest != null && !bindableRequest.isBound()) {
235             return; // Request is obsolete
236         }
237         // We don't use SafeAsyncTask here since it enforces the shared thread pool executor
238         // whereas we want a dedicated thread pool executor.
239         AsyncTask<Void, Void, MediaLoadingResult<T>> mediaLoadingTask =
240                 new AsyncTask<Void, Void, MediaLoadingResult<T>>() {
241             private Exception mException;
242 
243             @Override
244             protected MediaLoadingResult<T> doInBackground(Void... params) {
245                 // Double check the request is still valid by the time we start processing it
246                 if (bindableRequest != null && !bindableRequest.isBound()) {
247                     return null; // Request is obsolete
248                 }
249                 try {
250                     return processMediaRequestInternal(mediaRequest);
251                 } catch (Exception e) {
252                     mException = e;
253                     return null;
254                 }
255             }
256 
257             @Override
258             protected void onPostExecute(final MediaLoadingResult<T> result) {
259                 if (result != null) {
260                     Assert.isNull(mException);
261                     Assert.isTrue(result.loadedResource.getRefCount() > 0);
262                     try {
263                         if (bindableRequest != null) {
264                             bindableRequest.onMediaResourceLoaded(
265                                     bindableRequest, result.loadedResource, result.fromCache);
266                         }
267                     } finally {
268                         result.loadedResource.release();
269                         result.scheduleChainedRequests();
270                     }
271                 } else if (mException != null) {
272                     LogUtil.e(LogUtil.BUGLE_TAG, "Asynchronous media loading failed, key=" +
273                             mediaRequest.getKey(), mException);
274                     if (bindableRequest != null) {
275                         bindableRequest.onMediaResourceLoadError(bindableRequest, mException);
276                     }
277                 } else {
278                     Assert.isTrue(bindableRequest == null || !bindableRequest.isBound());
279                     if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
280                         LogUtil.v(TAG, "media request not processed, no longer bound; key=" +
281                                 LogUtil.sanitizePII(mediaRequest.getKey()) /* key with phone# */);
282                     }
283                 }
284             }
285         };
286         mediaLoadingTask.executeOnExecutor(executor, (Void) null);
287     }
288 
289     @VisibleForTesting
290     @RunsOnAnyThread
addResourceToMemoryCache( final MediaRequest<T> mediaRequest, final T mediaResource)291     <T extends RefCountedMediaResource> void addResourceToMemoryCache(
292             final MediaRequest<T> mediaRequest, final T mediaResource) {
293         Assert.isTrue(mediaResource != null);
294         final MediaCache<T> mediaCache = mediaRequest.getMediaCache();
295         if (mediaCache != null) {
296             mediaCache.addResourceToCache(mediaRequest.getKey(), mediaResource);
297             if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) {
298                 LogUtil.v(TAG, "added media resource to " + mediaCache.getName() + ". key=" +
299                         LogUtil.sanitizePII(mediaRequest.getKey()) /* key can contain phone# */);
300             }
301         }
302     }
303 
304     private class MediaLoadingResult<T extends RefCountedMediaResource> {
305         public final T loadedResource;
306         public final boolean fromCache;
307         private final List<MediaRequest<T>> mChainedRequests;
308 
MediaLoadingResult(final T loadedResource, final boolean fromCache, final List<MediaRequest<T>> chainedRequests)309         MediaLoadingResult(final T loadedResource, final boolean fromCache,
310                 final List<MediaRequest<T>> chainedRequests) {
311             this.loadedResource = loadedResource;
312             this.fromCache = fromCache;
313             mChainedRequests = chainedRequests;
314         }
315 
316         /**
317          * Asynchronously schedule a list of chained requests on the background thread.
318          */
scheduleChainedRequests()319         public void scheduleChainedRequests() {
320             for (final MediaRequest<T> mediaRequest : mChainedRequests) {
321                 scheduleAsyncMediaRequest(mediaRequest, MEDIA_BACKGROUND_EXECUTOR);
322             }
323         }
324     }
325 }
326