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/mac/bind_objc_block.h"
11 #include "base/files/file_enumerator.h"
12 #include "base/files/file_path.h"
13 #include "base/files/file_util.h"
14 #include "base/location.h"
15 #include "base/logging.h"
16 #include "base/mac/bind_objc_block.h"
17 #include "base/mac/scoped_cftyperef.h"
18 #include "base/strings/sys_string_conversions.h"
19 #include "base/task_runner_util.h"
20 #include "base/threading/thread_restrictions.h"
21 #include "ios/chrome/browser/ui/ui_util.h"
22 #import "ios/chrome/browser/ui/uikit_ui_util.h"
23 #include "ios/web/public/web_thread.h"
25 @interface SnapshotCache ()
26 + (base::FilePath)imagePathForSessionID:(NSString*)sessionID;
27 + (base::FilePath)greyImagePathForSessionID:(NSString*)sessionID;
28 // Returns the directory where the thumbnails are saved.
29 + (base::FilePath)cacheDirectory;
30 // Returns the directory where the thumbnails were stored in M28 and earlier.
31 - (base::FilePath)oldCacheDirectory;
32 // Remove all UIImages from |imageDictionary_|.
33 - (void)handleEnterBackground;
34 // Remove all but adjacent UIImages from |imageDictionary_|.
35 - (void)handleLowMemory;
36 // Restore adjacent UIImages to |imageDictionary_|.
37 - (void)handleBecomeActive;
38 // Clear most recent caller information.
39 - (void)clearGreySessionInfo;
40 // Load uncached snapshot image and convert image to grey.
41 - (void)loadGreyImageAsync:(NSString*)sessionID;
42 // Save grey image to |greyImageDictionary_| and call into most recent
43 // |mostRecentGreyBlock_| if |mostRecentGreySessionId_| matches |sessionID|.
44 - (void)saveGreyImage:(UIImage*)greyImage forKey:(NSString*)sessionID;
48 static NSArray* const kSnapshotCacheDirectory = @[ @"Chromium", @"Snapshots" ];
50 const NSUInteger kCacheInitialCapacity = 100;
51 const NSUInteger kGreyInitialCapacity = 8;
52 const CGFloat kJPEGImageQuality = 1.0; // Highest quality. No compression.
53 // Sequence token to make sure creation/deletion of snapshots don't overlap.
54 const char kSequenceToken[] = "SnapshotCacheSequenceToken";
56 // The paths of the images saved to disk, given a cache directory.
57 base::FilePath FilePathForSessionID(NSString* sessionID,
58 const base::FilePath& directory) {
59 base::FilePath path = directory.Append(base::SysNSStringToUTF8(sessionID))
60 .ReplaceExtension(".jpg");
61 if ([SnapshotCache snapshotScaleForDevice] == 2.0) {
62 path = path.InsertBeforeExtension("@2x");
63 } else if ([SnapshotCache snapshotScaleForDevice] == 3.0) {
64 path = path.InsertBeforeExtension("@3x");
69 base::FilePath GreyFilePathForSessionID(NSString* sessionID,
70 const base::FilePath& directory) {
71 base::FilePath path = directory.Append(base::SysNSStringToUTF8(sessionID) +
72 "Grey").ReplaceExtension(".jpg");
73 if ([SnapshotCache snapshotScaleForDevice] == 2.0) {
74 path = path.InsertBeforeExtension("@2x");
75 } else if ([SnapshotCache snapshotScaleForDevice] == 3.0) {
76 path = path.InsertBeforeExtension("@3x");
81 UIImage* ReadImageFromDisk(const base::FilePath& filePath) {
82 base::ThreadRestrictions::AssertIOAllowed();
83 // TODO(justincohen): Consider changing this back to -imageWithContentsOfFile
84 // instead of -imageWithData, if the crashing rdar://15747161 is ever fixed.
85 // Tracked in crbug.com/295891.
86 NSString* path = base::SysUTF8ToNSString(filePath.value());
87 return [UIImage imageWithData:[NSData dataWithContentsOfFile:path]
88 scale:[SnapshotCache snapshotScaleForDevice]];
91 void WriteImageToDisk(const base::scoped_nsobject<UIImage>& image,
92 const base::FilePath& filePath) {
93 base::ThreadRestrictions::AssertIOAllowed();
96 NSString* path = base::SysUTF8ToNSString(filePath.value());
97 [UIImageJPEGRepresentation(image, kJPEGImageQuality) writeToFile:path
99 // Encrypt the snapshot file (mostly for Incognito, but can't hurt to
101 NSDictionary* attributeDict =
102 [NSDictionary dictionaryWithObject:NSFileProtectionComplete
103 forKey:NSFileProtectionKey];
104 NSError* error = nil;
105 BOOL success = [[NSFileManager defaultManager] setAttributes:attributeDict
109 DLOG(ERROR) << "Error encrypting thumbnail file"
110 << base::SysNSStringToUTF8([error description]);
114 void ConvertAndSaveGreyImage(
115 const base::FilePath& colorPath,
116 const base::FilePath& greyPath,
117 const base::scoped_nsobject<UIImage>& cachedImage) {
118 base::ThreadRestrictions::AssertIOAllowed();
119 base::scoped_nsobject<UIImage> colorImage = cachedImage;
121 colorImage.reset([ReadImageFromDisk(colorPath) retain]);
124 base::scoped_nsobject<UIImage> greyImage([GreyImage(colorImage) retain]);
125 WriteImageToDisk(greyImage, greyPath);
128 } // anonymous namespace
130 @implementation SnapshotCache
132 @synthesize pinnedIDs = pinnedIDs_;
134 + (SnapshotCache*)sharedInstance {
135 static SnapshotCache* instance = [[SnapshotCache alloc] init];
140 if ((self = [super init])) {
141 DCHECK_CURRENTLY_ON_WEB_THREAD(web::WebThread::UI);
142 propertyReleaser_SnapshotCache_.Init(self, [SnapshotCache class]);
144 // TODO(andybons): 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
167 + (CGFloat)snapshotScaleForDevice {
168 // On handset, the color snapshot is used for the stack view, so the scale of
169 // the snapshot images should match the scale of the device.
170 // On tablet, the color snapshot is only used to generate the grey snapshot,
171 // which does not have to be high quality, so use scale of 1.0 on all tablets.
175 // Cap snapshot resolution to 2x to reduce the amount of memory they use.
176 return MIN([UIScreen mainScreen].scale, 2.0);
179 - (void)retrieveImageForSessionID:(NSString*)sessionID
180 callback:(void (^)(UIImage*))callback {
181 DCHECK_CURRENTLY_ON_WEB_THREAD(web::WebThread::UI);
183 UIImage* img = [imageDictionary_ objectForKey:sessionID];
190 base::PostTaskAndReplyWithResult(
191 web::WebThread::GetMessageLoopProxyForThread(
192 web::WebThread::FILE_USER_BLOCKING).get(),
194 base::BindBlock(^base::scoped_nsobject<UIImage>() {
195 // Retrieve the image on a high priority thread.
196 return base::scoped_nsobject<UIImage>([ReadImageFromDisk(
197 [SnapshotCache imagePathForSessionID:sessionID]) retain]);
199 base::BindBlock(^(base::scoped_nsobject<UIImage> image) {
201 [imageDictionary_ setObject:image forKey:sessionID];
207 - (void)setImage:(UIImage*)img withSessionID:(NSString*)sessionID {
208 DCHECK_CURRENTLY_ON_WEB_THREAD(web::WebThread::UI);
209 if (!img || !sessionID)
212 // Color snapshots are not used on tablets, so don't keep them in memory.
213 if (!IsIPadIdiom()) {
214 [imageDictionary_ setObject:img forKey:sessionID];
216 // Save the image to disk.
217 web::WebThread::PostBlockingPoolSequencedTask(
218 kSequenceToken, FROM_HERE,
220 base::scoped_nsobject<UIImage> image([img retain]);
221 WriteImageToDisk(image,
222 [SnapshotCache imagePathForSessionID:sessionID]);
226 - (void)removeImageWithSessionID:(NSString*)sessionID {
227 DCHECK_CURRENTLY_ON_WEB_THREAD(web::WebThread::UI);
228 [imageDictionary_ removeObjectForKey:sessionID];
229 web::WebThread::PostBlockingPoolSequencedTask(
230 kSequenceToken, FROM_HERE,
232 base::FilePath imagePath =
233 [SnapshotCache imagePathForSessionID:sessionID];
234 base::DeleteFile(imagePath, false);
235 base::DeleteFile([SnapshotCache greyImagePathForSessionID:sessionID],
240 - (base::FilePath)oldCacheDirectory {
241 DCHECK_CURRENTLY_ON_WEB_THREAD(web::WebThread::UI);
242 NSArray* paths = NSSearchPathForDirectoriesInDomains(NSCachesDirectory,
243 NSUserDomainMask, YES);
244 NSString* path = [paths objectAtIndex:0];
245 NSArray* path_components =
246 [NSArray arrayWithObjects:path, kSnapshotCacheDirectory[1], nil];
247 return base::FilePath(
248 base::SysNSStringToUTF8([NSString pathWithComponents:path_components]));
251 + (base::FilePath)cacheDirectory {
252 NSArray* paths = NSSearchPathForDirectoriesInDomains(NSCachesDirectory,
253 NSUserDomainMask, YES);
254 NSString* path = [paths objectAtIndex:0];
255 NSArray* path_components =
256 [NSArray arrayWithObjects:path, kSnapshotCacheDirectory[0],
257 kSnapshotCacheDirectory[1], nil];
258 return base::FilePath(
259 base::SysNSStringToUTF8([NSString pathWithComponents:path_components]));
262 + (base::FilePath)imagePathForSessionID:(NSString*)sessionID {
263 base::ThreadRestrictions::AssertIOAllowed();
265 base::FilePath path([SnapshotCache cacheDirectory]);
267 BOOL exists = base::PathExists(path);
268 DCHECK(base::DirectoryExists(path) || !exists);
270 bool result = base::CreateDirectory(path);
273 return FilePathForSessionID(sessionID, path);
276 + (base::FilePath)greyImagePathForSessionID:(NSString*)sessionID {
277 base::ThreadRestrictions::AssertIOAllowed();
279 base::FilePath path([self cacheDirectory]);
281 BOOL exists = base::PathExists(path);
282 DCHECK(base::DirectoryExists(path) || !exists);
284 bool result = base::CreateDirectory(path);
287 return GreyFilePathForSessionID(sessionID, path);
290 - (void)purgeCacheOlderThan:(const base::Time&)date
291 keeping:(NSSet*)liveSessionIds {
292 DCHECK_CURRENTLY_ON_WEB_THREAD(web::WebThread::UI);
293 // Copying the date, as the block must copy the value, not the reference.
294 const base::Time dateCopy = date;
295 web::WebThread::PostBlockingPoolSequencedTask(
296 kSequenceToken, FROM_HERE,
298 std::set<base::FilePath> filesToKeep;
299 for (NSString* sessionID : liveSessionIds) {
300 base::FilePath curImagePath =
301 [SnapshotCache imagePathForSessionID:sessionID];
302 filesToKeep.insert(curImagePath);
304 [SnapshotCache greyImagePathForSessionID:sessionID]);
306 base::FileEnumerator enumerator([SnapshotCache cacheDirectory], false,
307 base::FileEnumerator::FILES);
308 base::FilePath cur_file;
309 while (!(cur_file = enumerator.Next()).value().empty()) {
310 if (cur_file.Extension() != ".jpg")
312 if (filesToKeep.find(cur_file) != filesToKeep.end()) {
315 base::FileEnumerator::FileInfo fileInfo = enumerator.GetInfo();
316 if (fileInfo.GetLastModifiedTime() > dateCopy) {
319 base::DeleteFile(cur_file, false);
324 - (void)willBeSavedGreyWhenBackgrounding:(NSString*)sessionID {
325 DCHECK_CURRENTLY_ON_WEB_THREAD(web::WebThread::UI);
328 backgroundingImageSessionId_.reset([sessionID copy]);
329 backgroundingColorImage_.reset(
330 [[imageDictionary_ objectForKey:sessionID] retain]);
333 - (void)handleLowMemory {
334 DCHECK_CURRENTLY_ON_WEB_THREAD(web::WebThread::UI);
335 NSMutableDictionary* dictionary =
336 [[NSMutableDictionary alloc] initWithCapacity:2];
337 for (NSString* sessionID in pinnedIDs_) {
338 UIImage* image = [imageDictionary_ objectForKey:sessionID];
340 [dictionary setObject:image forKey:sessionID];
342 imageDictionary_.reset(dictionary);
345 - (void)handleEnterBackground {
346 DCHECK_CURRENTLY_ON_WEB_THREAD(web::WebThread::UI);
347 [imageDictionary_ removeAllObjects];
350 - (void)handleBecomeActive {
351 DCHECK_CURRENTLY_ON_WEB_THREAD(web::WebThread::UI);
352 for (NSString* sessionID in pinnedIDs_)
353 [self retrieveImageForSessionID:sessionID callback:nil];
356 - (void)saveGreyImage:(UIImage*)greyImage forKey:(NSString*)sessionID {
357 DCHECK_CURRENTLY_ON_WEB_THREAD(web::WebThread::UI);
359 [greyImageDictionary_ setObject:greyImage forKey:sessionID];
360 if ([sessionID isEqualToString:mostRecentGreySessionId_]) {
361 mostRecentGreyBlock_.get()(greyImage);
362 [self clearGreySessionInfo];
366 - (void)loadGreyImageAsync:(NSString*)sessionID {
367 DCHECK_CURRENTLY_ON_WEB_THREAD(web::WebThread::UI);
368 // Don't call -retrieveImageForSessionID here because it caches the colored
369 // image, which we don't need for the grey image cache. But if the image is
370 // already in the cache, use it.
371 UIImage* img = [imageDictionary_ objectForKey:sessionID];
372 base::PostTaskAndReplyWithResult(
373 web::WebThread::GetMessageLoopProxyForThread(
374 web::WebThread::FILE_USER_BLOCKING).get(),
376 base::BindBlock(^base::scoped_nsobject<UIImage>() {
377 base::scoped_nsobject<UIImage> result([img retain]);
378 // If the image is not in the cache, load it from disk.
380 result.reset([ReadImageFromDisk(
381 [SnapshotCache imagePathForSessionID:sessionID]) retain]);
383 result.reset([GreyImage(result) retain]);
386 base::BindBlock(^(base::scoped_nsobject<UIImage> greyImage) {
387 [self saveGreyImage:greyImage forKey:sessionID];
391 - (void)createGreyCache:(NSArray*)sessionIDs {
392 DCHECK_CURRENTLY_ON_WEB_THREAD(web::WebThread::UI);
393 greyImageDictionary_.reset(
394 [[NSMutableDictionary alloc] initWithCapacity:kGreyInitialCapacity]);
395 for (NSString* sessionID in sessionIDs)
396 [self loadGreyImageAsync:sessionID];
399 - (void)removeGreyCache {
400 DCHECK_CURRENTLY_ON_WEB_THREAD(web::WebThread::UI);
401 greyImageDictionary_.reset();
402 [self clearGreySessionInfo];
405 - (void)clearGreySessionInfo {
406 DCHECK_CURRENTLY_ON_WEB_THREAD(web::WebThread::UI);
407 mostRecentGreySessionId_.reset();
408 mostRecentGreyBlock_.reset();
411 - (void)greyImageForSessionID:(NSString*)sessionID
412 callback:(void (^)(UIImage*))callback {
413 DCHECK_CURRENTLY_ON_WEB_THREAD(web::WebThread::UI);
414 DCHECK(greyImageDictionary_);
415 UIImage* image = [greyImageDictionary_ objectForKey:sessionID];
418 [self clearGreySessionInfo];
420 mostRecentGreySessionId_.reset([sessionID copy]);
421 mostRecentGreyBlock_.reset([callback copy]);
425 - (void)retrieveGreyImageForSessionID:(NSString*)sessionID
426 callback:(void (^)(UIImage*))callback {
427 DCHECK_CURRENTLY_ON_WEB_THREAD(web::WebThread::UI);
428 if (greyImageDictionary_) {
429 UIImage* image = [greyImageDictionary_ objectForKey:sessionID];
436 base::PostTaskAndReplyWithResult(
437 web::WebThread::GetMessageLoopProxyForThread(
438 web::WebThread::FILE_USER_BLOCKING).get(),
440 base::BindBlock(^base::scoped_nsobject<UIImage>() {
441 // Retrieve the image on a high priority thread.
442 // Loading the file into NSData is more reliable.
443 // -imageWithContentsOfFile would ocassionally claim the image was not a
445 // "ImageIO: <ERROR> JPEGNot a JPEG file: starts with 0xff 0xd9"
447 // http://stackoverflow.com/questions/5081297/ios-uiimagejpegrepresentation-error-not-a-jpeg-file-starts-with-0xff-0xd9
448 NSData* imageData = [NSData
449 dataWithContentsOfFile:base::SysUTF8ToNSString(
450 [SnapshotCache greyImagePathForSessionID:sessionID].value())];
452 return base::scoped_nsobject<UIImage>();
454 return base::scoped_nsobject<UIImage>(
455 [[UIImage imageWithData:imageData] retain]);
457 base::BindBlock(^(base::scoped_nsobject<UIImage> image) {
459 [self retrieveImageForSessionID:sessionID
460 callback:^(UIImage* img) {
462 callback(GreyImage(img));
464 } else if (callback) {
470 - (void)saveGreyInBackgroundForSessionID:(NSString*)sessionID {
471 DCHECK_CURRENTLY_ON_WEB_THREAD(web::WebThread::UI);
475 base::FilePath greyImagePath =
476 GreyFilePathForSessionID(sessionID, [SnapshotCache cacheDirectory]);
477 base::FilePath colorImagePath =
478 FilePathForSessionID(sessionID, [SnapshotCache cacheDirectory]);
480 // The color image may still be in memory. Verify the sessionID matches.
481 if (backgroundingColorImage_) {
482 if (![backgroundingImageSessionId_ isEqualToString:sessionID]) {
483 backgroundingColorImage_.reset();
484 backgroundingImageSessionId_.reset();
488 web::WebThread::PostBlockingPoolTask(
489 FROM_HERE, base::Bind(&ConvertAndSaveGreyImage, colorImagePath,
490 greyImagePath, backgroundingColorImage_));