// Copyright 2012 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. #import "ios/chrome/browser/snapshots/snapshot_cache.h" #import #include "base/critical_closure.h" #include "base/files/file_enumerator.h" #include "base/files/file_path.h" #include "base/files/file_util.h" #include "base/location.h" #include "base/logging.h" #include "base/mac/bind_objc_block.h" #include "base/mac/scoped_cftyperef.h" #include "base/strings/sys_string_conversions.h" #include "base/task_runner_util.h" #include "base/threading/thread_restrictions.h" #include "ios/chrome/browser/experimental_flags.h" #include "ios/chrome/browser/ui/ui_util.h" #import "ios/chrome/browser/ui/uikit_ui_util.h" #include "ios/web/public/web_thread.h" @interface SnapshotCache () + (base::FilePath)imagePathForSessionID:(NSString*)sessionID; + (base::FilePath)greyImagePathForSessionID:(NSString*)sessionID; // Returns the directory where the thumbnails are saved. + (base::FilePath)cacheDirectory; // Returns the directory where the thumbnails were stored in M28 and earlier. - (base::FilePath)oldCacheDirectory; // Remove all UIImages from |imageDictionary_|. - (void)handleEnterBackground; // Remove all but adjacent UIImages from |imageDictionary_|. - (void)handleLowMemory; // Restore adjacent UIImages to |imageDictionary_|. - (void)handleBecomeActive; // Clear most recent caller information. - (void)clearGreySessionInfo; // Load uncached snapshot image and convert image to grey. - (void)loadGreyImageAsync:(NSString*)sessionID; // Save grey image to |greyImageDictionary_| and call into most recent // |mostRecentGreyBlock_| if |mostRecentGreySessionId_| matches |sessionID|. - (void)saveGreyImage:(UIImage*)greyImage forKey:(NSString*)sessionID; @end namespace { static NSArray* const kSnapshotCacheDirectory = @[ @"Chromium", @"Snapshots" ]; const NSUInteger kCacheInitialCapacity = 100; const NSUInteger kGreyInitialCapacity = 8; const CGFloat kJPEGImageQuality = 1.0; // Highest quality. No compression. // Sequence token to make sure creation/deletion of snapshots don't overlap. const char kSequenceToken[] = "SnapshotCacheSequenceToken"; // Maximum size in number of elements that the LRU cache can hold before // starting to evict elements. const NSUInteger kLRUCacheMaxCapacity = 6; // The paths of the images saved to disk, given a cache directory. base::FilePath FilePathForSessionID(NSString* sessionID, const base::FilePath& directory) { base::FilePath path = directory.Append(base::SysNSStringToUTF8(sessionID)) .ReplaceExtension(".jpg"); if ([SnapshotCache snapshotScaleForDevice] == 2.0) { path = path.InsertBeforeExtension("@2x"); } else if ([SnapshotCache snapshotScaleForDevice] == 3.0) { path = path.InsertBeforeExtension("@3x"); } return path; } base::FilePath GreyFilePathForSessionID(NSString* sessionID, const base::FilePath& directory) { base::FilePath path = directory.Append(base::SysNSStringToUTF8(sessionID) + "Grey").ReplaceExtension(".jpg"); if ([SnapshotCache snapshotScaleForDevice] == 2.0) { path = path.InsertBeforeExtension("@2x"); } else if ([SnapshotCache snapshotScaleForDevice] == 3.0) { path = path.InsertBeforeExtension("@3x"); } return path; } UIImage* ReadImageFromDisk(const base::FilePath& filePath) { base::ThreadRestrictions::AssertIOAllowed(); // TODO(justincohen): Consider changing this back to -imageWithContentsOfFile // instead of -imageWithData, if the crashing rdar://15747161 is ever fixed. // Tracked in crbug.com/295891. NSString* path = base::SysUTF8ToNSString(filePath.value()); return [UIImage imageWithData:[NSData dataWithContentsOfFile:path] scale:[SnapshotCache snapshotScaleForDevice]]; } void WriteImageToDisk(const base::scoped_nsobject& image, const base::FilePath& filePath) { base::ThreadRestrictions::AssertIOAllowed(); if (!image) return; NSString* path = base::SysUTF8ToNSString(filePath.value()); [UIImageJPEGRepresentation(image, kJPEGImageQuality) writeToFile:path atomically:YES]; // Encrypt the snapshot file (mostly for Incognito, but can't hurt to // always do it). NSDictionary* attributeDict = [NSDictionary dictionaryWithObject:NSFileProtectionComplete forKey:NSFileProtectionKey]; NSError* error = nil; BOOL success = [[NSFileManager defaultManager] setAttributes:attributeDict ofItemAtPath:path error:&error]; if (!success) { DLOG(ERROR) << "Error encrypting thumbnail file" << base::SysNSStringToUTF8([error description]); } } void ConvertAndSaveGreyImage( const base::FilePath& colorPath, const base::FilePath& greyPath, const base::scoped_nsobject& cachedImage) { base::ThreadRestrictions::AssertIOAllowed(); base::scoped_nsobject colorImage = cachedImage; if (!colorImage) colorImage.reset([ReadImageFromDisk(colorPath) retain]); if (!colorImage) return; base::scoped_nsobject greyImage([GreyImage(colorImage) retain]); WriteImageToDisk(greyImage, greyPath); } } // anonymous namespace @implementation SnapshotCache @synthesize pinnedIDs = pinnedIDs_; + (SnapshotCache*)sharedInstance { static SnapshotCache* instance = [[SnapshotCache alloc] init]; return instance; } - (id)init { if ((self = [super init])) { DCHECK_CURRENTLY_ON(web::WebThread::UI); propertyReleaser_SnapshotCache_.Init(self, [SnapshotCache class]); // Always use the LRUCache when the tab switcher is enabled. if (experimental_flags::IsTabSwitcherEnabled() || experimental_flags::IsLRUSnapshotCacheEnabled()) { lruCache_.reset( [[LRUCache alloc] initWithCacheSize:kLRUCacheMaxCapacity]); } else { imageDictionary_.reset( [[NSMutableDictionary alloc] initWithCapacity:kCacheInitialCapacity]); } if (!IsIPadIdiom() || experimental_flags::IsTabSwitcherEnabled()) { [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleLowMemory) name:UIApplicationDidReceiveMemoryWarningNotification object:nil]; [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleEnterBackground) name:UIApplicationDidEnterBackgroundNotification object:nil]; [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleBecomeActive) name:UIApplicationDidBecomeActiveNotification object:nil]; } } return self; } - (void)dealloc { if (!IsIPadIdiom() || experimental_flags::IsTabSwitcherEnabled()) { [[NSNotificationCenter defaultCenter] removeObserver:self name:UIApplicationDidReceiveMemoryWarningNotification object:nil]; [[NSNotificationCenter defaultCenter] removeObserver:self name:UIApplicationDidEnterBackgroundNotification object:nil]; [[NSNotificationCenter defaultCenter] removeObserver:self name:UIApplicationDidBecomeActiveNotification object:nil]; } [super dealloc]; } + (CGFloat)snapshotScaleForDevice { // On handset, the color snapshot is used for the stack view, so the scale of // the snapshot images should match the scale of the device. // On tablet, the color snapshot is only used to generate the grey snapshot, // which does not have to be high quality, so use scale of 1.0 on all tablets. if (IsIPadIdiom()) { return 1.0; } // Cap snapshot resolution to 2x to reduce the amount of memory they use. return MIN([UIScreen mainScreen].scale, 2.0); } - (void)retrieveImageForSessionID:(NSString*)sessionID callback:(void (^)(UIImage*))callback { DCHECK_CURRENTLY_ON(web::WebThread::UI); DCHECK(sessionID); // Cache on iPad is enabled only when the tab switcher is enabled. if ((IsIPadIdiom() && !experimental_flags::IsTabSwitcherEnabled()) && !callback) return; UIImage* img = nil; if (lruCache_) img = [lruCache_ objectForKey:sessionID]; else img = [imageDictionary_ objectForKey:sessionID]; if (img) { if (callback) callback(img); return; } base::PostTaskAndReplyWithResult( web::WebThread::GetTaskRunnerForThread(web::WebThread::FILE_USER_BLOCKING) .get(), FROM_HERE, base::BindBlock(^base::scoped_nsobject() { // Retrieve the image on a high priority thread. return base::scoped_nsobject([ReadImageFromDisk( [SnapshotCache imagePathForSessionID:sessionID]) retain]); }), base::BindBlock(^(base::scoped_nsobject image) { // Cache on iPad is enabled only when the tab switcher is enabled. if ((!IsIPadIdiom() || experimental_flags::IsTabSwitcherEnabled()) && image) { if (lruCache_) [lruCache_ setObject:image forKey:sessionID]; else [imageDictionary_ setObject:image forKey:sessionID]; } if (callback) callback(image); })); } - (void)setImage:(UIImage*)img withSessionID:(NSString*)sessionID { DCHECK_CURRENTLY_ON(web::WebThread::UI); if (!img || !sessionID) return; // Cache on iPad is enabled only when the tab switcher is enabled. if (!IsIPadIdiom() || experimental_flags::IsTabSwitcherEnabled()) { if (lruCache_) [lruCache_ setObject:img forKey:sessionID]; else [imageDictionary_ setObject:img forKey:sessionID]; } // Save the image to disk. web::WebThread::PostBlockingPoolSequencedTask( kSequenceToken, FROM_HERE, base::BindBlock(^{ base::scoped_nsobject image([img retain]); WriteImageToDisk(image, [SnapshotCache imagePathForSessionID:sessionID]); })); } - (void)removeImageWithSessionID:(NSString*)sessionID { DCHECK_CURRENTLY_ON(web::WebThread::UI); if (lruCache_) [lruCache_ removeObjectForKey:sessionID]; else [imageDictionary_ removeObjectForKey:sessionID]; web::WebThread::PostBlockingPoolSequencedTask( kSequenceToken, FROM_HERE, base::BindBlock(^{ base::FilePath imagePath = [SnapshotCache imagePathForSessionID:sessionID]; base::DeleteFile(imagePath, false); base::DeleteFile([SnapshotCache greyImagePathForSessionID:sessionID], false); })); } - (base::FilePath)oldCacheDirectory { DCHECK_CURRENTLY_ON(web::WebThread::UI); NSArray* paths = NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES); NSString* path = [paths objectAtIndex:0]; NSArray* path_components = [NSArray arrayWithObjects:path, kSnapshotCacheDirectory[1], nil]; return base::FilePath( base::SysNSStringToUTF8([NSString pathWithComponents:path_components])); } + (base::FilePath)cacheDirectory { NSArray* paths = NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES); NSString* path = [paths objectAtIndex:0]; NSArray* path_components = [NSArray arrayWithObjects:path, kSnapshotCacheDirectory[0], kSnapshotCacheDirectory[1], nil]; return base::FilePath( base::SysNSStringToUTF8([NSString pathWithComponents:path_components])); } + (base::FilePath)imagePathForSessionID:(NSString*)sessionID { base::ThreadRestrictions::AssertIOAllowed(); base::FilePath path([SnapshotCache cacheDirectory]); BOOL exists = base::PathExists(path); DCHECK(base::DirectoryExists(path) || !exists); if (!exists) { bool result = base::CreateDirectory(path); DCHECK(result); } return FilePathForSessionID(sessionID, path); } + (base::FilePath)greyImagePathForSessionID:(NSString*)sessionID { base::ThreadRestrictions::AssertIOAllowed(); base::FilePath path([self cacheDirectory]); BOOL exists = base::PathExists(path); DCHECK(base::DirectoryExists(path) || !exists); if (!exists) { bool result = base::CreateDirectory(path); DCHECK(result); } return GreyFilePathForSessionID(sessionID, path); } - (void)purgeCacheOlderThan:(const base::Time&)date keeping:(NSSet*)liveSessionIds { DCHECK_CURRENTLY_ON(web::WebThread::UI); // Copying the date, as the block must copy the value, not the reference. const base::Time dateCopy = date; web::WebThread::PostBlockingPoolSequencedTask( kSequenceToken, FROM_HERE, base::BindBlock(^{ std::set filesToKeep; for (NSString* sessionID : liveSessionIds) { base::FilePath curImagePath = [SnapshotCache imagePathForSessionID:sessionID]; filesToKeep.insert(curImagePath); filesToKeep.insert( [SnapshotCache greyImagePathForSessionID:sessionID]); } base::FileEnumerator enumerator([SnapshotCache cacheDirectory], false, base::FileEnumerator::FILES); base::FilePath cur_file; while (!(cur_file = enumerator.Next()).value().empty()) { if (cur_file.Extension() != ".jpg") continue; if (filesToKeep.find(cur_file) != filesToKeep.end()) { continue; } base::FileEnumerator::FileInfo fileInfo = enumerator.GetInfo(); if (fileInfo.GetLastModifiedTime() > dateCopy) { continue; } base::DeleteFile(cur_file, false); } })); } - (void)willBeSavedGreyWhenBackgrounding:(NSString*)sessionID { DCHECK_CURRENTLY_ON(web::WebThread::UI); if (!sessionID) return; backgroundingImageSessionId_.reset([sessionID copy]); if (lruCache_) { backgroundingColorImage_.reset([[lruCache_ objectForKey:sessionID] retain]); } else { backgroundingColorImage_.reset( [[imageDictionary_ objectForKey:sessionID] retain]); } } - (void)handleLowMemory { DCHECK(!IsIPadIdiom() || experimental_flags::IsTabSwitcherEnabled()); DCHECK_CURRENTLY_ON(web::WebThread::UI); base::scoped_nsobject dictionary( [[NSMutableDictionary alloc] initWithCapacity:2]); for (NSString* sessionID in pinnedIDs_) { UIImage* image = nil; if (lruCache_) image = [lruCache_ objectForKey:sessionID]; else image = [imageDictionary_ objectForKey:sessionID]; if (image) [dictionary setObject:image forKey:sessionID]; } if (lruCache_) { [lruCache_ removeAllObjects]; for (NSString* sessionID in pinnedIDs_) [lruCache_ setObject:[dictionary objectForKey:sessionID] forKey:sessionID]; } else { imageDictionary_ = dictionary; } } - (void)handleEnterBackground { DCHECK(!IsIPadIdiom() || experimental_flags::IsTabSwitcherEnabled()); DCHECK_CURRENTLY_ON(web::WebThread::UI); [imageDictionary_ removeAllObjects]; [lruCache_ removeAllObjects]; } - (void)handleBecomeActive { DCHECK(!IsIPadIdiom() || experimental_flags::IsTabSwitcherEnabled()); DCHECK_CURRENTLY_ON(web::WebThread::UI); for (NSString* sessionID in pinnedIDs_) [self retrieveImageForSessionID:sessionID callback:nil]; } - (void)saveGreyImage:(UIImage*)greyImage forKey:(NSString*)sessionID { DCHECK_CURRENTLY_ON(web::WebThread::UI); if (greyImage) [greyImageDictionary_ setObject:greyImage forKey:sessionID]; if ([sessionID isEqualToString:mostRecentGreySessionId_]) { mostRecentGreyBlock_.get()(greyImage); [self clearGreySessionInfo]; } } - (void)loadGreyImageAsync:(NSString*)sessionID { DCHECK_CURRENTLY_ON(web::WebThread::UI); // Don't call -retrieveImageForSessionID here because it caches the colored // image, which we don't need for the grey image cache. But if the image is // already in the cache, use it. UIImage* img = nil; if (lruCache_) img = [lruCache_ objectForKey:sessionID]; else img = [imageDictionary_ objectForKey:sessionID]; base::PostTaskAndReplyWithResult( web::WebThread::GetTaskRunnerForThread(web::WebThread::FILE_USER_BLOCKING) .get(), FROM_HERE, base::BindBlock(^base::scoped_nsobject() { base::scoped_nsobject result([img retain]); // If the image is not in the cache, load it from disk. if (!result) result.reset([ReadImageFromDisk( [SnapshotCache imagePathForSessionID:sessionID]) retain]); if (result) result.reset([GreyImage(result) retain]); return result; }), base::BindBlock(^(base::scoped_nsobject greyImage) { [self saveGreyImage:greyImage forKey:sessionID]; })); } - (void)createGreyCache:(NSArray*)sessionIDs { DCHECK_CURRENTLY_ON(web::WebThread::UI); greyImageDictionary_.reset( [[NSMutableDictionary alloc] initWithCapacity:kGreyInitialCapacity]); for (NSString* sessionID in sessionIDs) [self loadGreyImageAsync:sessionID]; } - (void)removeGreyCache { DCHECK_CURRENTLY_ON(web::WebThread::UI); greyImageDictionary_.reset(); [self clearGreySessionInfo]; } - (void)clearGreySessionInfo { DCHECK_CURRENTLY_ON(web::WebThread::UI); mostRecentGreySessionId_.reset(); mostRecentGreyBlock_.reset(); } - (void)greyImageForSessionID:(NSString*)sessionID callback:(void (^)(UIImage*))callback { DCHECK_CURRENTLY_ON(web::WebThread::UI); DCHECK(greyImageDictionary_); UIImage* image = [greyImageDictionary_ objectForKey:sessionID]; if (image) { callback(image); [self clearGreySessionInfo]; } else { mostRecentGreySessionId_.reset([sessionID copy]); mostRecentGreyBlock_.reset([callback copy]); } } - (void)retrieveGreyImageForSessionID:(NSString*)sessionID callback:(void (^)(UIImage*))callback { DCHECK_CURRENTLY_ON(web::WebThread::UI); if (greyImageDictionary_) { UIImage* image = [greyImageDictionary_ objectForKey:sessionID]; if (image) { callback(image); return; } } base::PostTaskAndReplyWithResult( web::WebThread::GetTaskRunnerForThread(web::WebThread::FILE_USER_BLOCKING) .get(), FROM_HERE, base::BindBlock(^base::scoped_nsobject() { // Retrieve the image on a high priority thread. // Loading the file into NSData is more reliable. // -imageWithContentsOfFile would ocassionally claim the image was not a // valid jpg. // "ImageIO: JPEGNot a JPEG file: starts with 0xff 0xd9" // See // http://stackoverflow.com/questions/5081297/ios-uiimagejpegrepresentation-error-not-a-jpeg-file-starts-with-0xff-0xd9 NSData* imageData = [NSData dataWithContentsOfFile:base::SysUTF8ToNSString( [SnapshotCache greyImagePathForSessionID:sessionID].value())]; if (!imageData) return base::scoped_nsobject(); DCHECK(callback); return base::scoped_nsobject( [[UIImage imageWithData:imageData] retain]); }), base::BindBlock(^(base::scoped_nsobject image) { if (!image) { [self retrieveImageForSessionID:sessionID callback:^(UIImage* img) { if (callback && img) callback(GreyImage(img)); }]; } else if (callback) { callback(image); } })); } - (void)saveGreyInBackgroundForSessionID:(NSString*)sessionID { DCHECK_CURRENTLY_ON(web::WebThread::UI); if (!sessionID) return; base::FilePath greyImagePath = GreyFilePathForSessionID(sessionID, [SnapshotCache cacheDirectory]); base::FilePath colorImagePath = FilePathForSessionID(sessionID, [SnapshotCache cacheDirectory]); // The color image may still be in memory. Verify the sessionID matches. if (backgroundingColorImage_) { if (![backgroundingImageSessionId_ isEqualToString:sessionID]) { backgroundingColorImage_.reset(); backgroundingImageSessionId_.reset(); } } web::WebThread::PostBlockingPoolTask( FROM_HERE, base::Bind(&ConvertAndSaveGreyImage, colorImagePath, greyImagePath, backgroundingColorImage_)); } @end @implementation SnapshotCache (TestingAdditions) - (BOOL)hasImageInMemory:(NSString*)sessionID { if (experimental_flags::IsLRUSnapshotCacheEnabled()) return [lruCache_ objectForKey:sessionID] != nil; else return [imageDictionary_ objectForKey:sessionID] != nil; } - (BOOL)hasGreyImageInMemory:(NSString*)sessionID { return [greyImageDictionary_ objectForKey:sessionID] != nil; } - (NSUInteger)lruCacheMaxSize { return [lruCache_ maxCacheSize]; } @end