// Copyright 2023 The Chromium Authors
// 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/model/legacy_image_file_manager.h"
#import "base/apple/backup_util.h"
#import "base/apple/foundation_util.h"
#import "base/files/file_enumerator.h"
#import "base/files/file_path.h"
#import "base/files/file_util.h"
#import "base/logging.h"
#import "base/sequence_checker.h"
#import "base/strings/stringprintf.h"
#import "base/strings/sys_string_conversions.h"
#import "base/task/sequenced_task_runner.h"
#import "base/task/thread_pool.h"
#import "base/threading/scoped_blocking_call.h"
#import "ios/chrome/browser/shared/ui/util/uikit_ui_util.h"
#import "ios/chrome/browser/snapshots/model/features.h"
#import "ios/chrome/browser/snapshots/model/snapshot_id.h"
#import "ios/chrome/browser/snapshots/model/snapshot_scale.h"
namespace {
enum ImageType {
IMAGE_TYPE_COLOR,
IMAGE_TYPE_GREYSCALE,
};
const ImageType kImageTypes[] = {
IMAGE_TYPE_COLOR,
IMAGE_TYPE_GREYSCALE,
};
const CGFloat kJPEGImageQuality = 1.0; // Highest quality. No compression.
// Returns the suffix to append to image filename for `image_type`.
const char* SuffixForImageType(ImageType image_type) {
switch (image_type) {
case IMAGE_TYPE_COLOR:
return "";
case IMAGE_TYPE_GREYSCALE:
return "Grey";
}
}
// Returns the suffix to append to image filename for `image_scale`.
const char* SuffixForImageScale(ImageScale image_scale) {
switch (image_scale) {
case kImageScale1X:
return "";
case kImageScale2X:
return "@2x";
}
}
// Returns the path of the image for `snapshot_id`, in `directory`,
// of type `image_type` and scale `image_scale`.
base::FilePath ImagePath(SnapshotID snapshot_id,
ImageType image_type,
ImageScale image_scale,
const base::FilePath& directory) {
const std::string filename = base::StringPrintf(
"%08u%s%s.jpg", snapshot_id.identifier(), SuffixForImageType(image_type),
SuffixForImageScale(image_scale));
return directory.Append(filename);
}
// Returns the path of the image for `snapshot_id`, in `directory`,
// of type `image_type` and scale `image_scale`.
base::FilePath LegacyImagePath(NSString* snapshot_id,
ImageType image_type,
ImageScale image_scale,
const base::FilePath& directory) {
const std::string filename = base::StringPrintf(
"%s%s%s.jpg", base::SysNSStringToUTF8(snapshot_id).c_str(),
SuffixForImageType(image_type), SuffixForImageScale(image_scale));
return directory.Append(filename);
}
// Creates a directory that images are stored.
void CreateStorageDirectory(const base::FilePath& directory,
const base::FilePath& legacy_directory) {
base::ScopedBlockingCall scoped_blocking_call(FROM_HERE,
base::BlockingType::WILL_BLOCK);
// This is a NO-OP if the directory already exists.
if (!base::CreateDirectory(directory)) {
const base::File::Error error = base::File::GetLastFileError();
DLOG(ERROR) << "Error creating snapshot storage: "
<< directory.AsUTF8Unsafe() << ": "
<< base::File::ErrorToString(error);
return;
}
if (!base::DirectoryExists(legacy_directory)) {
return;
}
// If `legacy_directory` exists and is a directory, move its content to
// `directory` and then delete the directory. As this function is
// used to move snapshot file which are not stored recursively, limit
// the enumeration to files and do not perform a recursive enumeration.
base::FileEnumerator iter(legacy_directory, /*recursive=*/false,
base::FileEnumerator::FILES);
for (base::FilePath item = iter.Next(); !item.empty(); item = iter.Next()) {
base::FilePath to_path = directory;
legacy_directory.AppendRelativePath(item, &to_path);
base::Move(item, to_path);
}
// Delete the `legacy_directory` once the existing files have been moved.
base::DeletePathRecursively(legacy_directory);
}
// Helper function to read an image from disk.
UIImage* ReadImageForSnapshotIDFromDisk(SnapshotID snapshot_id,
ImageScale image_scale,
const base::FilePath& directory) {
base::ScopedBlockingCall scoped_blocking_call(FROM_HERE,
base::BlockingType::WILL_BLOCK);
// TODO(crbug.com/41056111): consider changing back to
// -imageWithContentsOfFile instead of -imageWithData if both rdar://15747161
// and the bug incorrectly reporting the image as damaged
// https://stackoverflow.com/q/5081297/5353 are fixed.
base::FilePath file_path =
ImagePath(snapshot_id, IMAGE_TYPE_COLOR, image_scale, directory);
NSString* path = base::apple::FilePathToNSString(file_path);
return [UIImage imageWithData:[NSData dataWithContentsOfFile:path]
scale:[SnapshotImageScale floatImageScaleForDevice]];
}
// Helper function to write an image to disk.
void WriteImageToDisk(UIImage* image, const base::FilePath& file_path) {
if (!image) {
return;
}
if (!image.CGImage) {
// It's possible that CGImage doesn't exist for the chrome:// pages when
// it's an official build.
// TODO(crbug.com/40284759): Investigate why it happens and how to solve it.
return;
}
base::ScopedBlockingCall scoped_blocking_call(FROM_HERE,
base::BlockingType::WILL_BLOCK);
base::FilePath directory = file_path.DirName();
if (!base::DirectoryExists(directory)) {
bool success = base::CreateDirectory(directory);
if (!success) {
DLOG(ERROR) << "Error creating thumbnail directory "
<< directory.AsUTF8Unsafe();
return;
}
}
NSString* path = base::apple::FilePathToNSString(file_path);
NSData* data = UIImageJPEGRepresentation(image, kJPEGImageQuality);
if (!data) {
// Use UIImagePNGRepresentation instead when ImageJPEGRepresentation returns
// nil. It happens when the underlying CGImageRef contains data in an
// unsupported bitmap format.
data = UIImagePNGRepresentation(image);
}
[data writeToFile:path atomically:YES];
// Encrypt the snapshot file (mostly for Incognito, but can't hurt to
// always do it).
NSDictionary* attribute_dict = [NSDictionary
dictionaryWithObject:NSFileProtectionCompleteUntilFirstUserAuthentication
forKey:NSFileProtectionKey];
NSError* error = nil;
BOOL success = [[NSFileManager defaultManager] setAttributes:attribute_dict
ofItemAtPath:path
error:&error];
if (!success) {
DLOG(ERROR) << "Error encrypting thumbnail file "
<< base::SysNSStringToUTF8([error description]);
}
}
// Helper function to delete an image from disk.
void DeleteImageWithSnapshotID(const base::FilePath& directory,
SnapshotID snapshot_id,
ImageScale snapshot_scale) {
base::ScopedBlockingCall scoped_blocking_call(FROM_HERE,
base::BlockingType::WILL_BLOCK);
for (const ImageType image_type : kImageTypes) {
base::DeleteFile(
ImagePath(snapshot_id, image_type, snapshot_scale, directory));
}
}
void RemoveAllImages(const base::FilePath& directory) {
base::ScopedBlockingCall scoped_blocking_call(FROM_HERE,
base::BlockingType::WILL_BLOCK);
if (!base::DirectoryExists(directory)) {
return;
}
if (!base::DeletePathRecursively(directory)) {
DLOG(ERROR) << "Error deleting snapshots storage. "
<< directory.AsUTF8Unsafe();
}
if (!base::CreateDirectory(directory)) {
DLOG(ERROR) << "Error creating snapshot storage "
<< directory.AsUTF8Unsafe();
}
}
// Helper function to delete images created before `threshold_date` from disk.
void PurgeImagesOlderThan(
const base::FilePath& directory,
const base::Time& threshold_date,
const std::vector<SnapshotID>& keep_alive_snapshot_ids,
ImageScale snapshot_scale) {
base::ScopedBlockingCall scoped_blocking_call(FROM_HERE,
base::BlockingType::WILL_BLOCK);
if (!base::DirectoryExists(directory)) {
return;
}
std::set<base::FilePath> files_to_keep;
for (SnapshotID snapshot_id : keep_alive_snapshot_ids) {
for (const ImageType image_type : kImageTypes) {
files_to_keep.insert(
ImagePath(snapshot_id, image_type, snapshot_scale, directory));
}
}
base::FileEnumerator enumerator(directory, false,
base::FileEnumerator::FILES);
for (base::FilePath current_file = enumerator.Next(); !current_file.empty();
current_file = enumerator.Next()) {
if (current_file.Extension() != ".jpg") {
continue;
}
if (base::Contains(files_to_keep, current_file)) {
continue;
}
base::FileEnumerator::FileInfo file_info = enumerator.GetInfo();
if (file_info.GetLastModifiedTime() > threshold_date) {
continue;
}
base::DeleteFile(current_file);
}
}
// Helper function to rename images from `old_ids` to `new_ids`.
void RenameSnapshots(const base::FilePath& directory,
NSArray<NSString*>* old_ids,
const std::vector<SnapshotID>& new_ids,
ImageScale snapshot_scale) {
base::ScopedBlockingCall scoped_blocking_call(FROM_HERE,
base::BlockingType::WILL_BLOCK);
DCHECK(base::DirectoryExists(directory));
DCHECK_EQ(old_ids.count, new_ids.size());
const NSUInteger count = old_ids.count;
for (NSUInteger index = 0; index < count; ++index) {
for (const ImageType image_type : kImageTypes) {
const base::FilePath old_image_path = LegacyImagePath(
old_ids[index], image_type, snapshot_scale, directory);
const base::FilePath new_image_path =
ImagePath(new_ids[index], image_type, snapshot_scale, directory);
// Only migrate snapshots that are needed.
if (!base::PathExists(old_image_path) ||
base::PathExists(new_image_path)) {
continue;
}
if (!base::Move(old_image_path, new_image_path)) {
DLOG(ERROR) << "Error migrating file: " << old_image_path.AsUTF8Unsafe()
<< " to: " << new_image_path.AsUTF8Unsafe();
}
}
}
}
// Helper function to copy an image from `old_image_path` to `new_image_path`.
void CopyImageFile(const base::FilePath& old_image_path,
const base::FilePath& new_image_path) {
base::ScopedBlockingCall scoped_blocking_call(FROM_HERE,
base::BlockingType::WILL_BLOCK);
// Only migrate files that are needed.
if (!base::PathExists(old_image_path) || base::PathExists(new_image_path)) {
return;
}
if (!base::CopyFile(old_image_path, new_image_path)) {
DLOG(ERROR) << "Error copying file: " << old_image_path.AsUTF8Unsafe()
<< " to: " << new_image_path.AsUTF8Unsafe();
}
}
// Frees up disk by deleting all grey snapshots if they exist in `directory`
// because grey snapshots are not stored anymore when
// `kGreySnapshotOptimization` feature is enabled.
// TODO(crbug.com/40279302): This function should be removed in a few milestones
// after `kGreySnapshotOptimization` feature is enabled by default.
void DeleteAllGreyImages(const base::FilePath& directory) {
base::ScopedBlockingCall scoped_blocking_call(FROM_HERE,
base::BlockingType::WILL_BLOCK);
if (!base::DirectoryExists(directory)) {
return;
}
base::FileEnumerator iter(directory, /*recursive=*/false,
base::FileEnumerator::FILES);
for (base::FilePath item = iter.Next(); !item.empty(); item = iter.Next()) {
if (item.BaseName().value().find(
SuffixForImageType(IMAGE_TYPE_GREYSCALE)) != std::string::npos) {
base::DeleteFile(item);
}
}
}
} // anonymous namespace
@implementation LegacyImageFileManager {
// Directory where the thumbnails are saved.
base::FilePath _storageDirectory;
// Scale for snapshot images. May be smaller than the screen scale in order
// to save memory on some devices.
ImageScale _snapshotsScale;
// Task runner used to run tasks in the background. Will be invalidated when
// -shutdown is invoked. Code should support this value to be null (generally
// by not posting the task).
scoped_refptr<base::SequencedTaskRunner> _taskRunner;
// Check that public API is called from the correct sequence.
SEQUENCE_CHECKER(_sequenceChecker);
}
- (instancetype)initWithStoragePath:(const base::FilePath&)storagePath
legacyPath:(const base::FilePath&)legacyPath {
DCHECK_CALLED_ON_VALID_SEQUENCE(_sequenceChecker);
if ((self = [super init])) {
_storageDirectory = storagePath;
_snapshotsScale = [SnapshotImageScale imageScaleForDevice];
_taskRunner = base::ThreadPool::CreateSequencedTaskRunner(
{base::MayBlock(), base::TaskPriority::USER_VISIBLE});
_taskRunner->PostTask(
FROM_HERE,
base::BindOnce(CreateStorageDirectory, _storageDirectory, legacyPath));
// TODO(crbug.com/40279302): Delete this logic after a few milestones.
_taskRunner->PostTask(
FROM_HERE, base::BindOnce(DeleteAllGreyImages, _storageDirectory));
}
return self;
}
- (void)readImageWithSnapshotID:(SnapshotID)snapshotID
completion:(ImageReadCompletionBlock)completion {
DCHECK_CALLED_ON_VALID_SEQUENCE(_sequenceChecker);
DCHECK(snapshotID.valid());
DCHECK(completion);
if (!_taskRunner) {
std::move(completion).Run(nil);
return;
}
_taskRunner->PostTaskAndReplyWithResult(
FROM_HERE,
base::BindOnce(&ReadImageForSnapshotIDFromDisk, snapshotID,
_snapshotsScale, _storageDirectory),
std::move(completion));
}
- (void)writeImage:(UIImage*)image withSnapshotID:(SnapshotID)snapshotID {
DCHECK_CALLED_ON_VALID_SEQUENCE(_sequenceChecker);
if (!_taskRunner) {
return;
}
_taskRunner->PostTask(
FROM_HERE, base::BindOnce(&WriteImageToDisk, image,
ImagePath(snapshotID, IMAGE_TYPE_COLOR,
_snapshotsScale, _storageDirectory)));
}
- (void)removeImageWithSnapshotID:(SnapshotID)snapshotID {
DCHECK_CALLED_ON_VALID_SEQUENCE(_sequenceChecker);
if (!_taskRunner) {
return;
}
_taskRunner->PostTask(
FROM_HERE, base::BindOnce(&DeleteImageWithSnapshotID, _storageDirectory,
snapshotID, _snapshotsScale));
}
- (void)removeAllImages {
DCHECK_CALLED_ON_VALID_SEQUENCE(_sequenceChecker);
if (!_taskRunner) {
return;
}
_taskRunner->PostTask(FROM_HERE,
base::BindOnce(&RemoveAllImages, _storageDirectory));
}
- (void)purgeImagesOlderThan:(base::Time)date
keeping:(const std::vector<SnapshotID>&)liveSnapshotIDs {
DCHECK_CALLED_ON_VALID_SEQUENCE(_sequenceChecker);
if (!_taskRunner) {
return;
}
_taskRunner->PostTask(
FROM_HERE, base::BindOnce(&PurgeImagesOlderThan, _storageDirectory, date,
liveSnapshotIDs, _snapshotsScale));
}
- (void)renameSnapshotsWithIDs:(NSArray<NSString*>*)oldIDs
toIDs:(const std::vector<SnapshotID>&)newIDs {
DCHECK_CALLED_ON_VALID_SEQUENCE(_sequenceChecker);
DCHECK_EQ(oldIDs.count, newIDs.size());
if (!_taskRunner) {
return;
}
_taskRunner->PostTask(
FROM_HERE, base::BindOnce(&RenameSnapshots, _storageDirectory, oldIDs,
newIDs, _snapshotsScale));
}
- (void)copyImage:(const base::FilePath&)oldPath
toNewPath:(const base::FilePath&)newPath {
DCHECK_CALLED_ON_VALID_SEQUENCE(_sequenceChecker);
if (!_taskRunner) {
return;
}
_taskRunner->PostTask(FROM_HERE,
base::BindOnce(&CopyImageFile, oldPath, newPath));
}
- (base::FilePath)imagePathForSnapshotID:(SnapshotID)snapshotID {
return ImagePath(snapshotID, IMAGE_TYPE_COLOR, _snapshotsScale,
_storageDirectory);
}
- (base::FilePath)legacyImagePathForSnapshotID:(NSString*)snapshotID {
return LegacyImagePath(snapshotID, IMAGE_TYPE_COLOR, _snapshotsScale,
_storageDirectory);
}
- (void)shutdown {
_taskRunner = nullptr;
}
- (void)dealloc {
DCHECK(!_taskRunner) << "-shutdown must be called before -dealloc";
}
@end