1 /* 2 * Copyright (C) 2013 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.photos.data; 17 18 import android.content.Context; 19 import android.database.sqlite.SQLiteDatabase; 20 import android.net.Uri; 21 import android.os.Environment; 22 import android.util.Log; 23 24 import com.android.photos.data.MediaCacheDatabase.Action; 25 import com.android.photos.data.MediaRetriever.MediaSize; 26 27 import java.io.ByteArrayInputStream; 28 import java.io.File; 29 import java.io.FileInputStream; 30 import java.io.FileNotFoundException; 31 import java.io.InputStream; 32 import java.util.ArrayList; 33 import java.util.HashMap; 34 import java.util.LinkedList; 35 import java.util.List; 36 import java.util.Map; 37 import java.util.Queue; 38 39 /** 40 * MediaCache keeps a cache of images, videos, thumbnails and previews. Calls to 41 * retrieve a specific media item are executed asynchronously. The caller has an 42 * option to receive a notification for lower resolution images that happen to 43 * be available prior to the one requested. 44 * <p> 45 * When an media item has been retrieved, the notification for it is called on a 46 * separate notifier thread. This thread should not be held for a long time so 47 * that other notifications may happen. 48 * </p> 49 * <p> 50 * Media items are uniquely identified by their content URIs. Each 51 * scheme/authority can offer its own MediaRetriever, running in its own thread. 52 * </p> 53 * <p> 54 * The MediaCache is an LRU cache, but does not allow the thumbnail cache to 55 * drop below a minimum size. This prevents browsing through original images to 56 * wipe out the thumbnails. 57 * </p> 58 */ 59 public class MediaCache { 60 static final String TAG = MediaCache.class.getSimpleName(); 61 /** Subdirectory containing the image cache. */ 62 static final String IMAGE_CACHE_SUBDIR = "image_cache"; 63 /** File name extension to use for cached images. */ 64 static final String IMAGE_EXTENSION = ".cache"; 65 /** File name extension to use for temporary cached images while retrieving. */ 66 static final String TEMP_IMAGE_EXTENSION = ".temp"; 67 68 public static interface ImageReady { 69 void imageReady(InputStream bitmapInputStream); 70 } 71 72 public static interface OriginalReady { 73 void originalReady(File originalFile); 74 } 75 76 /** A Thread for each MediaRetriever */ 77 private class ProcessQueue extends Thread { 78 private Queue<ProcessingJob> mQueue; 79 80 public ProcessQueue(Queue<ProcessingJob> queue) { 81 mQueue = queue; 82 } 83 84 @Override 85 public void run() { 86 while (mRunning) { 87 ProcessingJob status; 88 synchronized (mQueue) { 89 while (mQueue.isEmpty()) { 90 try { 91 mQueue.wait(); 92 } catch (InterruptedException e) { 93 if (!mRunning) { 94 return; 95 } 96 Log.w(TAG, "Unexpected interruption", e); 97 } 98 } 99 status = mQueue.remove(); 100 } 101 processTask(status); 102 } 103 } 104 }; 105 106 private interface NotifyReady { 107 void notifyReady(); 108 109 void setFile(File file) throws FileNotFoundException; 110 111 boolean isPrefetch(); 112 } 113 114 private static class NotifyOriginalReady implements NotifyReady { 115 private final OriginalReady mCallback; 116 private File mFile; 117 118 public NotifyOriginalReady(OriginalReady callback) { 119 mCallback = callback; 120 } 121 122 @Override 123 public void notifyReady() { 124 if (mCallback != null) { 125 mCallback.originalReady(mFile); 126 } 127 } 128 129 @Override 130 public void setFile(File file) { 131 mFile = file; 132 } 133 134 @Override 135 public boolean isPrefetch() { 136 return mCallback == null; 137 } 138 } 139 140 private static class NotifyImageReady implements NotifyReady { 141 private final ImageReady mCallback; 142 private InputStream mInputStream; 143 144 public NotifyImageReady(ImageReady callback) { 145 mCallback = callback; 146 } 147 148 @Override 149 public void notifyReady() { 150 if (mCallback != null) { 151 mCallback.imageReady(mInputStream); 152 } 153 } 154 155 @Override 156 public void setFile(File file) throws FileNotFoundException { 157 mInputStream = new FileInputStream(file); 158 } 159 160 public void setBytes(byte[] bytes) { 161 mInputStream = new ByteArrayInputStream(bytes); 162 } 163 164 @Override 165 public boolean isPrefetch() { 166 return mCallback == null; 167 } 168 } 169 170 /** A media item to be retrieved and its notifications. */ 171 private static class ProcessingJob { 172 public ProcessingJob(Uri uri, MediaSize size, NotifyReady complete, 173 NotifyImageReady lowResolution) { 174 this.contentUri = uri; 175 this.size = size; 176 this.complete = complete; 177 this.lowResolution = lowResolution; 178 } 179 public Uri contentUri; 180 public MediaSize size; 181 public NotifyImageReady lowResolution; 182 public NotifyReady complete; 183 } 184 185 private boolean mRunning = true; 186 private static MediaCache sInstance; 187 private File mCacheDir; 188 private Context mContext; 189 private Queue<NotifyReady> mCallbacks = new LinkedList<NotifyReady>(); 190 private Map<String, MediaRetriever> mRetrievers = new HashMap<String, MediaRetriever>(); 191 private Map<String, List<ProcessingJob>> mTasks = new HashMap<String, List<ProcessingJob>>(); 192 private List<ProcessQueue> mProcessingThreads = new ArrayList<ProcessQueue>(); 193 private MediaCacheDatabase mDatabaseHelper; 194 private long mTempImageNumber = 1; 195 private Object mTempImageNumberLock = new Object(); 196 197 private long mMaxCacheSize = 40 * 1024 * 1024; // 40 MB 198 private long mMinThumbCacheSize = 4 * 1024 * 1024; // 4 MB 199 private long mCacheSize = -1; 200 private long mThumbCacheSize = -1; 201 private Object mCacheSizeLock = new Object(); 202 203 private Action mNotifyCachedLowResolution = new Action() { 204 @Override 205 public void execute(Uri uri, long id, MediaSize size, Object parameter) { 206 ProcessingJob job = (ProcessingJob) parameter; 207 File file = createCacheImagePath(id); 208 addNotification(job.lowResolution, file); 209 } 210 }; 211 212 private Action mMoveTempToCache = new Action() { 213 @Override 214 public void execute(Uri uri, long id, MediaSize size, Object parameter) { 215 File tempFile = (File) parameter; 216 File cacheFile = createCacheImagePath(id); 217 tempFile.renameTo(cacheFile); 218 } 219 }; 220 221 private Action mDeleteFile = new Action() { 222 @Override 223 public void execute(Uri uri, long id, MediaSize size, Object parameter) { 224 File file = createCacheImagePath(id); 225 file.delete(); 226 synchronized (mCacheSizeLock) { 227 if (mCacheSize != -1) { 228 long length = (Long) parameter; 229 mCacheSize -= length; 230 if (size == MediaSize.Thumbnail) { 231 mThumbCacheSize -= length; 232 } 233 } 234 } 235 } 236 }; 237 238 /** The thread used to make ImageReady and OriginalReady callbacks. */ 239 private Thread mProcessNotifications = new Thread() { 240 @Override 241 public void run() { 242 while (mRunning) { 243 NotifyReady notifyImage; 244 synchronized (mCallbacks) { 245 while (mCallbacks.isEmpty()) { 246 try { 247 mCallbacks.wait(); 248 } catch (InterruptedException e) { 249 if (!mRunning) { 250 return; 251 } 252 Log.w(TAG, "Unexpected Interruption, continuing"); 253 } 254 } 255 notifyImage = mCallbacks.remove(); 256 } 257 258 notifyImage.notifyReady(); 259 } 260 } 261 }; 262 263 public static synchronized void initialize(Context context) { 264 if (sInstance == null) { 265 sInstance = new MediaCache(context); 266 MediaCacheUtils.initialize(context); 267 } 268 } 269 270 public static MediaCache getInstance() { 271 return sInstance; 272 } 273 274 public static synchronized void shutdown() { 275 sInstance.mRunning = false; 276 sInstance.mProcessNotifications.interrupt(); 277 for (ProcessQueue processingThread : sInstance.mProcessingThreads) { 278 processingThread.interrupt(); 279 } 280 sInstance = null; 281 } 282 283 private MediaCache(Context context) { 284 mDatabaseHelper = new MediaCacheDatabase(context); 285 mProcessNotifications.start(); 286 mContext = context; 287 } 288 289 // This is used for testing. 290 public void setCacheDir(File cacheDir) { 291 cacheDir.mkdirs(); 292 mCacheDir = cacheDir; 293 } 294 295 public File getCacheDir() { 296 synchronized (mContext) { 297 if (mCacheDir == null) { 298 String state = Environment.getExternalStorageState(); 299 File baseDir; 300 if (Environment.MEDIA_MOUNTED.equals(state)) { 301 baseDir = mContext.getExternalCacheDir(); 302 } else { 303 // Stored in internal cache 304 baseDir = mContext.getCacheDir(); 305 } 306 mCacheDir = new File(baseDir, IMAGE_CACHE_SUBDIR); 307 mCacheDir.mkdirs(); 308 } 309 return mCacheDir; 310 } 311 } 312 313 /** 314 * Invalidates all cached images related to a given contentUri. This call 315 * doesn't complete until the images have been removed from the cache. 316 */ 317 public void invalidate(Uri contentUri) { 318 mDatabaseHelper.delete(contentUri, mDeleteFile); 319 } 320 321 public void clearCacheDir() { 322 File[] cachedFiles = getCacheDir().listFiles(); 323 if (cachedFiles != null) { 324 for (File cachedFile : cachedFiles) { 325 cachedFile.delete(); 326 } 327 } 328 } 329 330 /** 331 * Add a MediaRetriever for a Uri scheme and authority. This MediaRetriever 332 * will be granted its own thread for retrieving images. 333 */ 334 public void addRetriever(String scheme, String authority, MediaRetriever retriever) { 335 String differentiator = getDifferentiator(scheme, authority); 336 synchronized (mRetrievers) { 337 mRetrievers.put(differentiator, retriever); 338 } 339 synchronized (mTasks) { 340 LinkedList<ProcessingJob> queue = new LinkedList<ProcessingJob>(); 341 mTasks.put(differentiator, queue); 342 new ProcessQueue(queue).start(); 343 } 344 } 345 346 /** 347 * Retrieves a thumbnail. complete will be called when the thumbnail is 348 * available. If lowResolution is not null and a lower resolution thumbnail 349 * is available before the thumbnail, lowResolution will be called prior to 350 * complete. All callbacks will be made on a thread other than the calling 351 * thread. 352 * 353 * @param contentUri The URI for the full resolution image to search for. 354 * @param complete Callback for when the image has been retrieved. 355 * @param lowResolution If not null and a lower resolution image is 356 * available prior to retrieving the thumbnail, this will be 357 * called with the low resolution bitmap. 358 */ 359 public void retrieveThumbnail(Uri contentUri, ImageReady complete, ImageReady lowResolution) { 360 addTask(contentUri, complete, lowResolution, MediaSize.Thumbnail); 361 } 362 363 /** 364 * Retrieves a preview. complete will be called when the preview is 365 * available. If lowResolution is not null and a lower resolution preview is 366 * available before the preview, lowResolution will be called prior to 367 * complete. All callbacks will be made on a thread other than the calling 368 * thread. 369 * 370 * @param contentUri The URI for the full resolution image to search for. 371 * @param complete Callback for when the image has been retrieved. 372 * @param lowResolution If not null and a lower resolution image is 373 * available prior to retrieving the preview, this will be called 374 * with the low resolution bitmap. 375 */ 376 public void retrievePreview(Uri contentUri, ImageReady complete, ImageReady lowResolution) { 377 addTask(contentUri, complete, lowResolution, MediaSize.Preview); 378 } 379 380 /** 381 * Retrieves the original image or video. complete will be called when the 382 * media is available on the local file system. If lowResolution is not null 383 * and a lower resolution preview is available before the original, 384 * lowResolution will be called prior to complete. All callbacks will be 385 * made on a thread other than the calling thread. 386 * 387 * @param contentUri The URI for the full resolution image to search for. 388 * @param complete Callback for when the image has been retrieved. 389 * @param lowResolution If not null and a lower resolution image is 390 * available prior to retrieving the preview, this will be called 391 * with the low resolution bitmap. 392 */ 393 public void retrieveOriginal(Uri contentUri, OriginalReady complete, ImageReady lowResolution) { 394 File localFile = getLocalFile(contentUri); 395 if (localFile != null) { 396 addNotification(new NotifyOriginalReady(complete), localFile); 397 } else { 398 NotifyImageReady notifyLowResolution = (lowResolution == null) ? null 399 : new NotifyImageReady(lowResolution); 400 addTask(contentUri, new NotifyOriginalReady(complete), notifyLowResolution, 401 MediaSize.Original); 402 } 403 } 404 405 /** 406 * Looks for an already cached media at a specific size. 407 * 408 * @param contentUri The original media item content URI 409 * @param size The target size to search for in the cache 410 * @return The cached file location or null if it is not cached. 411 */ 412 public File getCachedFile(Uri contentUri, MediaSize size) { 413 Long cachedId = mDatabaseHelper.getCached(contentUri, size); 414 File file = null; 415 if (cachedId != null) { 416 file = createCacheImagePath(cachedId); 417 if (!file.exists()) { 418 mDatabaseHelper.delete(contentUri, size, mDeleteFile); 419 file = null; 420 } 421 } 422 return file; 423 } 424 425 /** 426 * Inserts a media item into the cache. 427 * 428 * @param contentUri The original media item URI. 429 * @param size The size of the media item to store in the cache. 430 * @param tempFile The temporary file where the image is stored. This file 431 * will no longer exist after executing this method. 432 * @return The new location, in the cache, of the media item or null if it 433 * wasn't possible to move into the cache. 434 */ 435 public File insertIntoCache(Uri contentUri, MediaSize size, File tempFile) { 436 long fileSize = tempFile.length(); 437 if (fileSize == 0) { 438 return null; 439 } 440 File cacheFile = null; 441 SQLiteDatabase db = mDatabaseHelper.getWritableDatabase(); 442 // Ensure that this step is atomic 443 db.beginTransaction(); 444 try { 445 Long id = mDatabaseHelper.getCached(contentUri, size); 446 if (id != null) { 447 cacheFile = createCacheImagePath(id); 448 if (tempFile.renameTo(cacheFile)) { 449 mDatabaseHelper.updateLength(id, fileSize); 450 } else { 451 Log.w(TAG, "Could not update cached file with " + tempFile); 452 tempFile.delete(); 453 cacheFile = null; 454 } 455 } else { 456 ensureFreeCacheSpace(tempFile.length(), size); 457 id = mDatabaseHelper.insert(contentUri, size, mMoveTempToCache, tempFile); 458 cacheFile = createCacheImagePath(id); 459 } 460 db.setTransactionSuccessful(); 461 } finally { 462 db.endTransaction(); 463 } 464 return cacheFile; 465 } 466 467 /** 468 * For testing purposes. 469 */ 470 public void setMaxCacheSize(long maxCacheSize) { 471 synchronized (mCacheSizeLock) { 472 mMaxCacheSize = maxCacheSize; 473 mMinThumbCacheSize = mMaxCacheSize / 10; 474 mCacheSize = -1; 475 mThumbCacheSize = -1; 476 } 477 } 478 479 private File createCacheImagePath(long id) { 480 return new File(getCacheDir(), String.valueOf(id) + IMAGE_EXTENSION); 481 } 482 483 private void addTask(Uri contentUri, ImageReady complete, ImageReady lowResolution, 484 MediaSize size) { 485 NotifyReady notifyComplete = new NotifyImageReady(complete); 486 NotifyImageReady notifyLowResolution = null; 487 if (lowResolution != null) { 488 notifyLowResolution = new NotifyImageReady(lowResolution); 489 } 490 addTask(contentUri, notifyComplete, notifyLowResolution, size); 491 } 492 493 private void addTask(Uri contentUri, NotifyReady complete, NotifyImageReady lowResolution, 494 MediaSize size) { 495 MediaRetriever retriever = getMediaRetriever(contentUri); 496 Uri uri = retriever.normalizeUri(contentUri, size); 497 if (uri == null) { 498 throw new IllegalArgumentException("No MediaRetriever for " + contentUri); 499 } 500 size = retriever.normalizeMediaSize(uri, size); 501 502 File cachedFile = getCachedFile(uri, size); 503 if (cachedFile != null) { 504 addNotification(complete, cachedFile); 505 return; 506 } 507 String differentiator = getDifferentiator(uri.getScheme(), uri.getAuthority()); 508 synchronized (mTasks) { 509 List<ProcessingJob> tasks = mTasks.get(differentiator); 510 if (tasks == null) { 511 throw new IllegalArgumentException("Cannot find retriever for: " + uri); 512 } 513 synchronized (tasks) { 514 ProcessingJob job = new ProcessingJob(uri, size, complete, lowResolution); 515 if (complete.isPrefetch()) { 516 tasks.add(job); 517 } else { 518 int index = tasks.size() - 1; 519 while (index >= 0 && tasks.get(index).complete.isPrefetch()) { 520 index--; 521 } 522 tasks.add(index + 1, job); 523 } 524 tasks.notifyAll(); 525 } 526 } 527 } 528 529 private MediaRetriever getMediaRetriever(Uri uri) { 530 String differentiator = getDifferentiator(uri.getScheme(), uri.getAuthority()); 531 MediaRetriever retriever; 532 synchronized (mRetrievers) { 533 retriever = mRetrievers.get(differentiator); 534 } 535 if (retriever == null) { 536 throw new IllegalArgumentException("No MediaRetriever for " + uri); 537 } 538 return retriever; 539 } 540 541 private File getLocalFile(Uri uri) { 542 MediaRetriever retriever = getMediaRetriever(uri); 543 File localFile = null; 544 if (retriever != null) { 545 localFile = retriever.getLocalFile(uri); 546 } 547 return localFile; 548 } 549 550 private MediaSize getFastImageSize(Uri uri, MediaSize size) { 551 MediaRetriever retriever = getMediaRetriever(uri); 552 return retriever.getFastImageSize(uri, size); 553 } 554 555 private boolean isFastImageBetter(MediaSize fastImageType, MediaSize size) { 556 if (fastImageType == null) { 557 return false; 558 } 559 if (size == null) { 560 return true; 561 } 562 return fastImageType.isBetterThan(size); 563 } 564 565 private byte[] getTemporaryImage(Uri uri, MediaSize fastImageType) { 566 MediaRetriever retriever = getMediaRetriever(uri); 567 return retriever.getTemporaryImage(uri, fastImageType); 568 } 569 570 private void processTask(ProcessingJob job) { 571 File cachedFile = getCachedFile(job.contentUri, job.size); 572 if (cachedFile != null) { 573 addNotification(job.complete, cachedFile); 574 return; 575 } 576 577 boolean hasLowResolution = job.lowResolution != null; 578 if (hasLowResolution) { 579 MediaSize cachedSize = mDatabaseHelper.executeOnBestCached(job.contentUri, job.size, 580 mNotifyCachedLowResolution); 581 MediaSize fastImageSize = getFastImageSize(job.contentUri, job.size); 582 if (isFastImageBetter(fastImageSize, cachedSize)) { 583 if (fastImageSize.isTemporary()) { 584 byte[] bytes = getTemporaryImage(job.contentUri, fastImageSize); 585 if (bytes != null) { 586 addNotification(job.lowResolution, bytes); 587 } 588 } else { 589 File lowFile = getMedia(job.contentUri, fastImageSize); 590 if (lowFile != null) { 591 addNotification(job.lowResolution, lowFile); 592 } 593 } 594 } 595 } 596 597 // Now get the full size desired 598 File fullSizeFile = getMedia(job.contentUri, job.size); 599 if (fullSizeFile != null) { 600 addNotification(job.complete, fullSizeFile); 601 } 602 } 603 604 private void addNotification(NotifyReady callback, File file) { 605 try { 606 callback.setFile(file); 607 synchronized (mCallbacks) { 608 mCallbacks.add(callback); 609 mCallbacks.notifyAll(); 610 } 611 } catch (FileNotFoundException e) { 612 Log.e(TAG, "Unable to read file " + file, e); 613 } 614 } 615 616 private void addNotification(NotifyImageReady callback, byte[] bytes) { 617 callback.setBytes(bytes); 618 synchronized (mCallbacks) { 619 mCallbacks.add(callback); 620 mCallbacks.notifyAll(); 621 } 622 } 623 624 private File getMedia(Uri uri, MediaSize size) { 625 long imageNumber; 626 synchronized (mTempImageNumberLock) { 627 imageNumber = mTempImageNumber++; 628 } 629 File tempFile = new File(getCacheDir(), String.valueOf(imageNumber) + TEMP_IMAGE_EXTENSION); 630 MediaRetriever retriever = getMediaRetriever(uri); 631 boolean retrieved = retriever.getMedia(uri, size, tempFile); 632 File cachedFile = null; 633 if (retrieved) { 634 ensureFreeCacheSpace(tempFile.length(), size); 635 long id = mDatabaseHelper.insert(uri, size, mMoveTempToCache, tempFile); 636 cachedFile = createCacheImagePath(id); 637 } 638 return cachedFile; 639 } 640 641 private static String getDifferentiator(String scheme, String authority) { 642 if (authority == null) { 643 return scheme; 644 } 645 StringBuilder differentiator = new StringBuilder(scheme); 646 differentiator.append(':'); 647 differentiator.append(authority); 648 return differentiator.toString(); 649 } 650 651 private void ensureFreeCacheSpace(long size, MediaSize mediaSize) { 652 synchronized (mCacheSizeLock) { 653 if (mCacheSize == -1 || mThumbCacheSize == -1) { 654 mCacheSize = mDatabaseHelper.getCacheSize(); 655 mThumbCacheSize = mDatabaseHelper.getThumbnailCacheSize(); 656 if (mCacheSize == -1 || mThumbCacheSize == -1) { 657 Log.e(TAG, "Can't determine size of the image cache"); 658 return; 659 } 660 } 661 mCacheSize += size; 662 if (mediaSize == MediaSize.Thumbnail) { 663 mThumbCacheSize += size; 664 } 665 if (mCacheSize > mMaxCacheSize) { 666 shrinkCacheLocked(); 667 } 668 } 669 } 670 671 private void shrinkCacheLocked() { 672 long deleteSize = mMinThumbCacheSize; 673 boolean includeThumbnails = (mThumbCacheSize - deleteSize) > mMinThumbCacheSize; 674 mDatabaseHelper.deleteOldCached(includeThumbnails, deleteSize, mDeleteFile); 675 } 676 } 677