blob: d6537ccab69c7978fa77567e978f2fe7d6a3923b [file] [log] [blame]
// 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 <UIKit/UIKit.h>
#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<UIImage>& 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<UIImage>& cachedImage) {
base::ThreadRestrictions::AssertIOAllowed();
base::scoped_nsobject<UIImage> colorImage = cachedImage;
if (!colorImage)
colorImage.reset([ReadImageFromDisk(colorPath) retain]);
if (!colorImage)
return;
base::scoped_nsobject<UIImage> 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<UIImage>() {
// Retrieve the image on a high priority thread.
return base::scoped_nsobject<UIImage>([ReadImageFromDisk(
[SnapshotCache imagePathForSessionID:sessionID]) retain]);
}),
base::BindBlock(^(base::scoped_nsobject<UIImage> 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<UIImage> 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<base::FilePath> 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<NSMutableDictionary> 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<UIImage>() {
base::scoped_nsobject<UIImage> 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<UIImage> 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<UIImage>() {
// 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: <ERROR> 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<UIImage>();
DCHECK(callback);
return base::scoped_nsobject<UIImage>(
[[UIImage imageWithData:imageData] retain]);
}),
base::BindBlock(^(base::scoped_nsobject<UIImage> 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