1 // Copyright 2012 The Chromium Authors. All rights reserved.
2 // Use of this source code is governed by a BSD-style license that can be
3 // found in the LICENSE file.
5 #import "ios/chrome/browser/snapshots/snapshot_cache.h"
7 #import <UIKit/UIKit.h>
9 #include "base/critical_closure.h"
10 #include "base/files/file_enumerator.h"
11 #include "base/files/file_path.h"
12 #include "base/files/file_util.h"
13 #include "base/location.h"
14 #include "base/logging.h"
15 #include "base/mac/bind_objc_block.h"
16 #include "base/mac/scoped_cftyperef.h"
17 #include "base/strings/sys_string_conversions.h"
18 #include "base/task_runner_util.h"
19 #include "base/threading/thread_restrictions.h"
20 #include "ios/chrome/browser/ui/ui_util.h"
21 #import "ios/chrome/browser/ui/uikit_ui_util.h"
22 #include "ios/web/public/web_thread.h"
24 @interface SnapshotCache ()
25 + (base::FilePath)imagePathForSessionID:(NSString*)sessionID;
26 + (base::FilePath)greyImagePathForSessionID:(NSString*)sessionID;
27 // Returns the directory where the thumbnails are saved.
28 + (base::FilePath)cacheDirectory;
29 // Returns the directory where the thumbnails were stored in M28 and earlier.
30 - (base::FilePath)oldCacheDirectory;
31 // Remove all UIImages from |imageDictionary_|.
32 - (void)handleEnterBackground;
33 // Remove all but adjacent UIImages from |imageDictionary_|.
34 - (void)handleLowMemory;
35 // Restore adjacent UIImages to |imageDictionary_|.
36 - (void)handleBecomeActive;
37 // Clear most recent caller information.
38 - (void)clearGreySessionInfo;
39 // Load uncached snapshot image and convert image to grey.
40 - (void)loadGreyImageAsync:(NSString*)sessionID;
41 // Save grey image to |greyImageDictionary_| and call into most recent
42 // |mostRecentGreyBlock_| if |mostRecentGreySessionId_| matches |sessionID|.
43 - (void)saveGreyImage:(UIImage*)greyImage forKey:(NSString*)sessionID;
47 static NSArray* const kSnapshotCacheDirectory = @[ @"Chromium", @"Snapshots" ];
49 const NSUInteger kCacheInitialCapacity = 100;
50 const NSUInteger kGreyInitialCapacity = 8;
51 const CGFloat kJPEGImageQuality = 1.0; // Highest quality. No compression.
52 // Sequence token to make sure creation/deletion of snapshots don't overlap.
53 const char kSequenceToken[] = "SnapshotCacheSequenceToken";
55 // The paths of the images saved to disk, given a cache directory.
56 base::FilePath FilePathForSessionID(NSString* sessionID,
57 const base::FilePath& directory) {
58 base::FilePath path = directory.Append(base::SysNSStringToUTF8(sessionID))
59 .ReplaceExtension(".jpg");
60 if ([SnapshotCache snapshotScaleForDevice] == 2.0) {
61 path = path.InsertBeforeExtension("@2x");
62 } else if ([SnapshotCache snapshotScaleForDevice] == 3.0) {
63 path = path.InsertBeforeExtension("@3x");
68 base::FilePath GreyFilePathForSessionID(NSString* sessionID,
69 const base::FilePath& directory) {
70 base::FilePath path = directory.Append(base::SysNSStringToUTF8(sessionID) +
71 "Grey").ReplaceExtension(".jpg");
72 if ([SnapshotCache snapshotScaleForDevice] == 2.0) {
73 path = path.InsertBeforeExtension("@2x");
74 } else if ([SnapshotCache snapshotScaleForDevice] == 3.0) {
75 path = path.InsertBeforeExtension("@3x");
80 UIImage* ReadImageFromDisk(const base::FilePath& filePath) {
81 base::ThreadRestrictions::AssertIOAllowed();
82 // TODO(justincohen): Consider changing this back to -imageWithContentsOfFile
83 // instead of -imageWithData, if the crashing rdar://15747161 is ever fixed.
84 // Tracked in crbug.com/295891.
85 NSString* path = base::SysUTF8ToNSString(filePath.value());
86 return [UIImage imageWithData:[NSData dataWithContentsOfFile:path]
87 scale:[SnapshotCache snapshotScaleForDevice]];
90 void WriteImageToDisk(const base::scoped_nsobject<UIImage>& image,
91 const base::FilePath& filePath) {
92 base::ThreadRestrictions::AssertIOAllowed();
95 NSString* path = base::SysUTF8ToNSString(filePath.value());
96 [UIImageJPEGRepresentation(image, kJPEGImageQuality) writeToFile:path
98 // Encrypt the snapshot file (mostly for Incognito, but can't hurt to
100 NSDictionary* attributeDict =
101 [NSDictionary dictionaryWithObject:NSFileProtectionComplete
102 forKey:NSFileProtectionKey];
103 NSError* error = nil;
104 BOOL success = [[NSFileManager defaultManager] setAttributes:attributeDict
108 DLOG(ERROR) << "Error encrypting thumbnail file"
109 << base::SysNSStringToUTF8([error description]);
113 void ConvertAndSaveGreyImage(
114 const base::FilePath& colorPath,
115 const base::FilePath& greyPath,
116 const base::scoped_nsobject<UIImage>& cachedImage) {
117 base::ThreadRestrictions::AssertIOAllowed();
118 base::scoped_nsobject<UIImage> colorImage = cachedImage;
120 colorImage.reset([ReadImageFromDisk(colorPath) retain]);
123 base::scoped_nsobject<UIImage> greyImage([GreyImage(colorImage) retain]);
124 WriteImageToDisk(greyImage, greyPath);
127 } // anonymous namespace
129 @implementation SnapshotCache
131 @synthesize pinnedIDs = pinnedIDs_;
133 + (SnapshotCache*)sharedInstance {
134 static SnapshotCache* instance = [[SnapshotCache alloc] init];
139 if ((self = [super init])) {
140 DCHECK_CURRENTLY_ON_WEB_THREAD(web::WebThread::UI);
141 propertyReleaser_SnapshotCache_.Init(self, [SnapshotCache class]);
143 if (!IsIPadIdiom()) {
144 // TODO(jbbegue): In the case where the cache grows, it is expensive.
145 // Make sure this doesn't suck when there are more than ten tabs.
146 imageDictionary_.reset(
147 [[NSMutableDictionary alloc] initWithCapacity:kCacheInitialCapacity]);
148 [[NSNotificationCenter defaultCenter]
150 selector:@selector(handleLowMemory)
151 name:UIApplicationDidReceiveMemoryWarningNotification
153 [[NSNotificationCenter defaultCenter]
155 selector:@selector(handleEnterBackground)
156 name:UIApplicationDidEnterBackgroundNotification
158 [[NSNotificationCenter defaultCenter]
160 selector:@selector(handleBecomeActive)
161 name:UIApplicationDidBecomeActiveNotification
169 if (!IsIPadIdiom()) {
170 [[NSNotificationCenter defaultCenter]
172 name:UIApplicationDidReceiveMemoryWarningNotification
174 [[NSNotificationCenter defaultCenter]
176 name:UIApplicationDidEnterBackgroundNotification
178 [[NSNotificationCenter defaultCenter]
180 name:UIApplicationDidBecomeActiveNotification
186 + (CGFloat)snapshotScaleForDevice {
187 // On handset, the color snapshot is used for the stack view, so the scale of
188 // the snapshot images should match the scale of the device.
189 // On tablet, the color snapshot is only used to generate the grey snapshot,
190 // which does not have to be high quality, so use scale of 1.0 on all tablets.
194 // Cap snapshot resolution to 2x to reduce the amount of memory they use.
195 return MIN([UIScreen mainScreen].scale, 2.0);
198 - (void)retrieveImageForSessionID:(NSString*)sessionID
199 callback:(void (^)(UIImage*))callback {
200 DCHECK_CURRENTLY_ON_WEB_THREAD(web::WebThread::UI);
202 // iPad does not cache images, so if there is no callback we can avoid an
203 // expensive read from storage.
204 if (IsIPadIdiom() && !callback)
207 UIImage* img = [imageDictionary_ objectForKey:sessionID];
214 base::PostTaskAndReplyWithResult(
215 web::WebThread::GetTaskRunnerForThread(web::WebThread::FILE_USER_BLOCKING)
217 FROM_HERE, base::BindBlock(^base::scoped_nsobject<UIImage>() {
218 // Retrieve the image on a high priority thread.
219 return base::scoped_nsobject<UIImage>([ReadImageFromDisk(
220 [SnapshotCache imagePathForSessionID:sessionID]) retain]);
222 base::BindBlock(^(base::scoped_nsobject<UIImage> image) {
223 // The iPad tab switcher is currently using its own memory cache so the
224 // image is not stored in memory here if running on iPad.
225 // The same logic is used on image writes (code below).
226 if (!IsIPadIdiom() && image)
227 [imageDictionary_ setObject:image forKey:sessionID];
233 - (void)setImage:(UIImage*)img withSessionID:(NSString*)sessionID {
234 DCHECK_CURRENTLY_ON_WEB_THREAD(web::WebThread::UI);
235 if (!img || !sessionID)
238 // The iPad tab switcher is currently using its own memory cache so the image
239 // is not stored in memory here if running on iPad.
240 // The same logic is used on image reads (code above).
241 if (!IsIPadIdiom()) {
242 [imageDictionary_ setObject:img forKey:sessionID];
244 // Save the image to disk.
245 web::WebThread::PostBlockingPoolSequencedTask(
246 kSequenceToken, FROM_HERE,
248 base::scoped_nsobject<UIImage> image([img retain]);
249 WriteImageToDisk(image,
250 [SnapshotCache imagePathForSessionID:sessionID]);
254 - (void)removeImageWithSessionID:(NSString*)sessionID {
255 DCHECK_CURRENTLY_ON_WEB_THREAD(web::WebThread::UI);
256 [imageDictionary_ removeObjectForKey:sessionID];
257 web::WebThread::PostBlockingPoolSequencedTask(
258 kSequenceToken, FROM_HERE,
260 base::FilePath imagePath =
261 [SnapshotCache imagePathForSessionID:sessionID];
262 base::DeleteFile(imagePath, false);
263 base::DeleteFile([SnapshotCache greyImagePathForSessionID:sessionID],
268 - (base::FilePath)oldCacheDirectory {
269 DCHECK_CURRENTLY_ON_WEB_THREAD(web::WebThread::UI);
270 NSArray* paths = NSSearchPathForDirectoriesInDomains(NSCachesDirectory,
271 NSUserDomainMask, YES);
272 NSString* path = [paths objectAtIndex:0];
273 NSArray* path_components =
274 [NSArray arrayWithObjects:path, kSnapshotCacheDirectory[1], nil];
275 return base::FilePath(
276 base::SysNSStringToUTF8([NSString pathWithComponents:path_components]));
279 + (base::FilePath)cacheDirectory {
280 NSArray* paths = NSSearchPathForDirectoriesInDomains(NSCachesDirectory,
281 NSUserDomainMask, YES);
282 NSString* path = [paths objectAtIndex:0];
283 NSArray* path_components =
284 [NSArray arrayWithObjects:path, kSnapshotCacheDirectory[0],
285 kSnapshotCacheDirectory[1], nil];
286 return base::FilePath(
287 base::SysNSStringToUTF8([NSString pathWithComponents:path_components]));
290 + (base::FilePath)imagePathForSessionID:(NSString*)sessionID {
291 base::ThreadRestrictions::AssertIOAllowed();
293 base::FilePath path([SnapshotCache cacheDirectory]);
295 BOOL exists = base::PathExists(path);
296 DCHECK(base::DirectoryExists(path) || !exists);
298 bool result = base::CreateDirectory(path);
301 return FilePathForSessionID(sessionID, path);
304 + (base::FilePath)greyImagePathForSessionID:(NSString*)sessionID {
305 base::ThreadRestrictions::AssertIOAllowed();
307 base::FilePath path([self cacheDirectory]);
309 BOOL exists = base::PathExists(path);
310 DCHECK(base::DirectoryExists(path) || !exists);
312 bool result = base::CreateDirectory(path);
315 return GreyFilePathForSessionID(sessionID, path);
318 - (void)purgeCacheOlderThan:(const base::Time&)date
319 keeping:(NSSet*)liveSessionIds {
320 DCHECK_CURRENTLY_ON_WEB_THREAD(web::WebThread::UI);
321 // Copying the date, as the block must copy the value, not the reference.
322 const base::Time dateCopy = date;
323 web::WebThread::PostBlockingPoolSequencedTask(
324 kSequenceToken, FROM_HERE,
326 std::set<base::FilePath> filesToKeep;
327 for (NSString* sessionID : liveSessionIds) {
328 base::FilePath curImagePath =
329 [SnapshotCache imagePathForSessionID:sessionID];
330 filesToKeep.insert(curImagePath);
332 [SnapshotCache greyImagePathForSessionID:sessionID]);
334 base::FileEnumerator enumerator([SnapshotCache cacheDirectory], false,
335 base::FileEnumerator::FILES);
336 base::FilePath cur_file;
337 while (!(cur_file = enumerator.Next()).value().empty()) {
338 if (cur_file.Extension() != ".jpg")
340 if (filesToKeep.find(cur_file) != filesToKeep.end()) {
343 base::FileEnumerator::FileInfo fileInfo = enumerator.GetInfo();
344 if (fileInfo.GetLastModifiedTime() > dateCopy) {
347 base::DeleteFile(cur_file, false);
352 - (void)willBeSavedGreyWhenBackgrounding:(NSString*)sessionID {
353 DCHECK_CURRENTLY_ON_WEB_THREAD(web::WebThread::UI);
356 backgroundingImageSessionId_.reset([sessionID copy]);
357 backgroundingColorImage_.reset(
358 [[imageDictionary_ objectForKey:sessionID] retain]);
361 - (void)handleLowMemory {
362 DCHECK(!IsIPadIdiom());
363 DCHECK_CURRENTLY_ON_WEB_THREAD(web::WebThread::UI);
364 NSMutableDictionary* dictionary =
365 [[NSMutableDictionary alloc] initWithCapacity:2];
366 for (NSString* sessionID in pinnedIDs_) {
367 UIImage* image = [imageDictionary_ objectForKey:sessionID];
369 [dictionary setObject:image forKey:sessionID];
371 imageDictionary_.reset(dictionary);
374 - (void)handleEnterBackground {
375 DCHECK(!IsIPadIdiom());
376 DCHECK_CURRENTLY_ON_WEB_THREAD(web::WebThread::UI);
377 [imageDictionary_ removeAllObjects];
380 - (void)handleBecomeActive {
381 DCHECK(!IsIPadIdiom());
382 DCHECK_CURRENTLY_ON_WEB_THREAD(web::WebThread::UI);
383 for (NSString* sessionID in pinnedIDs_)
384 [self retrieveImageForSessionID:sessionID callback:nil];
387 - (void)saveGreyImage:(UIImage*)greyImage forKey:(NSString*)sessionID {
388 DCHECK_CURRENTLY_ON_WEB_THREAD(web::WebThread::UI);
390 [greyImageDictionary_ setObject:greyImage forKey:sessionID];
391 if ([sessionID isEqualToString:mostRecentGreySessionId_]) {
392 mostRecentGreyBlock_.get()(greyImage);
393 [self clearGreySessionInfo];
397 - (void)loadGreyImageAsync:(NSString*)sessionID {
398 DCHECK_CURRENTLY_ON_WEB_THREAD(web::WebThread::UI);
399 // Don't call -retrieveImageForSessionID here because it caches the colored
400 // image, which we don't need for the grey image cache. But if the image is
401 // already in the cache, use it.
402 UIImage* img = [imageDictionary_ objectForKey:sessionID];
403 base::PostTaskAndReplyWithResult(
404 web::WebThread::GetTaskRunnerForThread(web::WebThread::FILE_USER_BLOCKING)
406 FROM_HERE, base::BindBlock(^base::scoped_nsobject<UIImage>() {
407 base::scoped_nsobject<UIImage> result([img retain]);
408 // If the image is not in the cache, load it from disk.
410 result.reset([ReadImageFromDisk(
411 [SnapshotCache imagePathForSessionID:sessionID]) retain]);
413 result.reset([GreyImage(result) retain]);
416 base::BindBlock(^(base::scoped_nsobject<UIImage> greyImage) {
417 [self saveGreyImage:greyImage forKey:sessionID];
421 - (void)createGreyCache:(NSArray*)sessionIDs {
422 DCHECK_CURRENTLY_ON_WEB_THREAD(web::WebThread::UI);
423 greyImageDictionary_.reset(
424 [[NSMutableDictionary alloc] initWithCapacity:kGreyInitialCapacity]);
425 for (NSString* sessionID in sessionIDs)
426 [self loadGreyImageAsync:sessionID];
429 - (void)removeGreyCache {
430 DCHECK_CURRENTLY_ON_WEB_THREAD(web::WebThread::UI);
431 greyImageDictionary_.reset();
432 [self clearGreySessionInfo];
435 - (void)clearGreySessionInfo {
436 DCHECK_CURRENTLY_ON_WEB_THREAD(web::WebThread::UI);
437 mostRecentGreySessionId_.reset();
438 mostRecentGreyBlock_.reset();
441 - (void)greyImageForSessionID:(NSString*)sessionID
442 callback:(void (^)(UIImage*))callback {
443 DCHECK_CURRENTLY_ON_WEB_THREAD(web::WebThread::UI);
444 DCHECK(greyImageDictionary_);
445 UIImage* image = [greyImageDictionary_ objectForKey:sessionID];
448 [self clearGreySessionInfo];
450 mostRecentGreySessionId_.reset([sessionID copy]);
451 mostRecentGreyBlock_.reset([callback copy]);
455 - (void)retrieveGreyImageForSessionID:(NSString*)sessionID
456 callback:(void (^)(UIImage*))callback {
457 DCHECK_CURRENTLY_ON_WEB_THREAD(web::WebThread::UI);
458 if (greyImageDictionary_) {
459 UIImage* image = [greyImageDictionary_ objectForKey:sessionID];
466 base::PostTaskAndReplyWithResult(
467 web::WebThread::GetTaskRunnerForThread(web::WebThread::FILE_USER_BLOCKING)
469 FROM_HERE, base::BindBlock(^base::scoped_nsobject<UIImage>() {
470 // Retrieve the image on a high priority thread.
471 // Loading the file into NSData is more reliable.
472 // -imageWithContentsOfFile would ocassionally claim the image was not a
474 // "ImageIO: <ERROR> JPEGNot a JPEG file: starts with 0xff 0xd9"
476 // http://stackoverflow.com/questions/5081297/ios-uiimagejpegrepresentation-error-not-a-jpeg-file-starts-with-0xff-0xd9
477 NSData* imageData = [NSData
478 dataWithContentsOfFile:base::SysUTF8ToNSString(
479 [SnapshotCache greyImagePathForSessionID:sessionID].value())];
481 return base::scoped_nsobject<UIImage>();
483 return base::scoped_nsobject<UIImage>(
484 [[UIImage imageWithData:imageData] retain]);
486 base::BindBlock(^(base::scoped_nsobject<UIImage> image) {
488 [self retrieveImageForSessionID:sessionID
489 callback:^(UIImage* img) {
491 callback(GreyImage(img));
493 } else if (callback) {
499 - (void)saveGreyInBackgroundForSessionID:(NSString*)sessionID {
500 DCHECK_CURRENTLY_ON_WEB_THREAD(web::WebThread::UI);
504 base::FilePath greyImagePath =
505 GreyFilePathForSessionID(sessionID, [SnapshotCache cacheDirectory]);
506 base::FilePath colorImagePath =
507 FilePathForSessionID(sessionID, [SnapshotCache cacheDirectory]);
509 // The color image may still be in memory. Verify the sessionID matches.
510 if (backgroundingColorImage_) {
511 if (![backgroundingImageSessionId_ isEqualToString:sessionID]) {
512 backgroundingColorImage_.reset();
513 backgroundingImageSessionId_.reset();
517 web::WebThread::PostBlockingPoolTask(
518 FROM_HERE, base::Bind(&ConvertAndSaveGreyImage, colorImagePath,
519 greyImagePath, backgroundingColorImage_));