/* * This file is part of the SDWebImage package. * (c) Olivier Poitrey * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ #import "SDDiskCache.h" #import "SDImageCacheConfig.h" #import "SDFileAttributeHelper.h" #import static NSString * const SDDiskCacheExtendedAttributeName = @"com.hackemist.SDDiskCache"; @interface SDDiskCache () @property (nonatomic, copy) NSString *diskCachePath; @property (nonatomic, strong, nonnull) NSFileManager *fileManager; @end @implementation SDDiskCache - (instancetype)init { NSAssert(NO, @"Use `initWithCachePath:` with the disk cache path"); return nil; } #pragma mark - SDcachePathForKeyDiskCache Protocol - (instancetype)initWithCachePath:(NSString *)cachePath config:(nonnull SDImageCacheConfig *)config { if (self = [super init]) { _diskCachePath = cachePath; _config = config; [self commonInit]; } return self; } - (void)commonInit { if (self.config.fileManager) { self.fileManager = self.config.fileManager; } else { self.fileManager = [NSFileManager new]; } [self createDirectory]; } - (BOOL)containsDataForKey:(NSString *)key { NSParameterAssert(key); NSString *filePath = [self cachePathForKey:key]; BOOL exists = [self.fileManager fileExistsAtPath:filePath]; // fallback because of https://github.com/rs/SDWebImage/pull/976 that added the extension to the disk file name // checking the key with and without the extension if (!exists) { exists = [self.fileManager fileExistsAtPath:filePath.stringByDeletingPathExtension]; } return exists; } - (NSData *)dataForKey:(NSString *)key { NSParameterAssert(key); NSString *filePath = [self cachePathForKey:key]; // if filePath is nil or (null),framework will crash with this: // Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: '*** -[_NSPlaceholderData initWithContentsOfFile:options:maxLength:error:]: nil file argument' if (filePath == nil || [@"(null)" isEqualToString: filePath]) { return nil; } NSData *data = [NSData dataWithContentsOfFile:filePath options:self.config.diskCacheReadingOptions error:nil]; if (data) { [[NSURL fileURLWithPath:filePath] setResourceValue:[NSDate date] forKey:NSURLContentAccessDateKey error:nil]; return data; } // fallback because of https://github.com/rs/SDWebImage/pull/976 that added the extension to the disk file name // checking the key with and without the extension filePath = filePath.stringByDeletingPathExtension; data = [NSData dataWithContentsOfFile:filePath options:self.config.diskCacheReadingOptions error:nil]; if (data) { [[NSURL fileURLWithPath:filePath] setResourceValue:[NSDate date] forKey:NSURLContentAccessDateKey error:nil]; return data; } return nil; } - (void)setData:(NSData *)data forKey:(NSString *)key { NSParameterAssert(data); NSParameterAssert(key); // get cache Path for image key NSString *cachePathForKey = [self cachePathForKey:key]; // transform to NSURL NSURL *fileURL = [NSURL fileURLWithPath:cachePathForKey isDirectory:NO]; [data writeToURL:fileURL options:self.config.diskCacheWritingOptions error:nil]; } - (NSData *)extendedDataForKey:(NSString *)key { NSParameterAssert(key); // get cache Path for image key NSString *cachePathForKey = [self cachePathForKey:key]; NSData *extendedData = [SDFileAttributeHelper extendedAttribute:SDDiskCacheExtendedAttributeName atPath:cachePathForKey traverseLink:NO error:nil]; return extendedData; } - (void)setExtendedData:(NSData *)extendedData forKey:(NSString *)key { NSParameterAssert(key); // get cache Path for image key NSString *cachePathForKey = [self cachePathForKey:key]; if (!extendedData) { // Remove [SDFileAttributeHelper removeExtendedAttribute:SDDiskCacheExtendedAttributeName atPath:cachePathForKey traverseLink:NO error:nil]; } else { // Override [SDFileAttributeHelper setExtendedAttribute:SDDiskCacheExtendedAttributeName value:extendedData atPath:cachePathForKey traverseLink:NO overwrite:YES error:nil]; } } - (void)removeDataForKey:(NSString *)key { NSParameterAssert(key); NSString *filePath = [self cachePathForKey:key]; [self.fileManager removeItemAtPath:filePath error:nil]; } - (void)removeAllData { [self.fileManager removeItemAtPath:self.diskCachePath error:nil]; [self createDirectory]; } - (void)createDirectory { [self.fileManager createDirectoryAtPath:self.diskCachePath withIntermediateDirectories:YES attributes:nil error:NULL]; // disable iCloud backup if (self.config.shouldDisableiCloud) { // ignore iCloud backup resource value error [[NSURL fileURLWithPath:self.diskCachePath isDirectory:YES] setResourceValue:@YES forKey:NSURLIsExcludedFromBackupKey error:nil]; } } - (void)removeExpiredData { NSURL *diskCacheURL = [NSURL fileURLWithPath:self.diskCachePath isDirectory:YES]; // Compute content date key to be used for tests NSURLResourceKey cacheContentDateKey; switch (self.config.diskCacheExpireType) { case SDImageCacheConfigExpireTypeModificationDate: cacheContentDateKey = NSURLContentModificationDateKey; break; case SDImageCacheConfigExpireTypeCreationDate: cacheContentDateKey = NSURLCreationDateKey; break; case SDImageCacheConfigExpireTypeChangeDate: cacheContentDateKey = NSURLAttributeModificationDateKey; break; case SDImageCacheConfigExpireTypeAccessDate: default: cacheContentDateKey = NSURLContentAccessDateKey; break; } NSArray *resourceKeys = @[NSURLIsDirectoryKey, cacheContentDateKey, NSURLTotalFileAllocatedSizeKey]; // This enumerator prefetches useful properties for our cache files. NSDirectoryEnumerator *fileEnumerator = [self.fileManager enumeratorAtURL:diskCacheURL includingPropertiesForKeys:resourceKeys options:NSDirectoryEnumerationSkipsHiddenFiles errorHandler:NULL]; NSDate *expirationDate = (self.config.maxDiskAge < 0) ? nil: [NSDate dateWithTimeIntervalSinceNow:-self.config.maxDiskAge]; NSMutableDictionary *> *cacheFiles = [NSMutableDictionary dictionary]; NSUInteger currentCacheSize = 0; // Enumerate all of the files in the cache directory. This loop has two purposes: // // 1. Removing files that are older than the expiration date. // 2. Storing file attributes for the size-based cleanup pass. NSMutableArray *urlsToDelete = [[NSMutableArray alloc] init]; for (NSURL *fileURL in fileEnumerator) { @autoreleasepool { NSError *error; NSDictionary *resourceValues = [fileURL resourceValuesForKeys:resourceKeys error:&error]; // Skip directories and errors. if (error || !resourceValues || [resourceValues[NSURLIsDirectoryKey] boolValue]) { continue; } // Remove files that are older than the expiration date; NSDate *modifiedDate = resourceValues[cacheContentDateKey]; if (expirationDate && [[modifiedDate laterDate:expirationDate] isEqualToDate:expirationDate]) { [urlsToDelete addObject:fileURL]; continue; } // Store a reference to this file and account for its total size. NSNumber *totalAllocatedSize = resourceValues[NSURLTotalFileAllocatedSizeKey]; currentCacheSize += totalAllocatedSize.unsignedIntegerValue; cacheFiles[fileURL] = resourceValues; } } for (NSURL *fileURL in urlsToDelete) { [self.fileManager removeItemAtURL:fileURL error:nil]; } // If our remaining disk cache exceeds a configured maximum size, perform a second // size-based cleanup pass. We delete the oldest files first. NSUInteger maxDiskSize = self.config.maxDiskSize; if (maxDiskSize > 0 && currentCacheSize > maxDiskSize) { // Target half of our maximum cache size for this cleanup pass. const NSUInteger desiredCacheSize = maxDiskSize / 2; // Sort the remaining cache files by their last modification time or last access time (oldest first). NSArray *sortedFiles = [cacheFiles keysSortedByValueWithOptions:NSSortConcurrent usingComparator:^NSComparisonResult(id obj1, id obj2) { return [obj1[cacheContentDateKey] compare:obj2[cacheContentDateKey]]; }]; // Delete files until we fall below our desired cache size. for (NSURL *fileURL in sortedFiles) { if ([self.fileManager removeItemAtURL:fileURL error:nil]) { NSDictionary *resourceValues = cacheFiles[fileURL]; NSNumber *totalAllocatedSize = resourceValues[NSURLTotalFileAllocatedSizeKey]; currentCacheSize -= totalAllocatedSize.unsignedIntegerValue; if (currentCacheSize < desiredCacheSize) { break; } } } } } - (nullable NSString *)cachePathForKey:(NSString *)key { NSParameterAssert(key); return [self cachePathForKey:key inPath:self.diskCachePath]; } - (NSUInteger)totalSize { NSUInteger size = 0; // Use URL-based enumerator instead of Path(NSString *)-based enumerator to reduce // those objects(ex. NSPathStore2/_NSCFString/NSConcreteData) created during traversal. // Even worse, those objects are added into AutoreleasePool, in background threads, // the time to release those objects is undifined(according to the usage of CPU) // It will truely consumes a lot of VM, up to cause OOMs. @autoreleasepool { NSURL *pathURL = [NSURL fileURLWithPath:self.diskCachePath isDirectory:YES]; NSDirectoryEnumerator *fileEnumerator = [self.fileManager enumeratorAtURL:pathURL includingPropertiesForKeys:@[NSURLFileSizeKey] options:(NSDirectoryEnumerationOptions)0 errorHandler:NULL]; for (NSURL *fileURL in fileEnumerator) { @autoreleasepool { NSNumber *fileSize; [fileURL getResourceValue:&fileSize forKey:NSURLFileSizeKey error:NULL]; size += fileSize.unsignedIntegerValue; } } } return size; } - (NSUInteger)totalCount { NSUInteger count = 0; @autoreleasepool { NSURL *diskCacheURL = [NSURL fileURLWithPath:self.diskCachePath isDirectory:YES]; NSDirectoryEnumerator *fileEnumerator = [self.fileManager enumeratorAtURL:diskCacheURL includingPropertiesForKeys:@[] options:(NSDirectoryEnumerationOptions)0 errorHandler:nil]; count = fileEnumerator.allObjects.count; } return count; } #pragma mark - Cache paths - (nullable NSString *)cachePathForKey:(nullable NSString *)key inPath:(nonnull NSString *)path { NSString *filename = SDDiskCacheFileNameForKey(key); return [path stringByAppendingPathComponent:filename]; } - (void)moveCacheDirectoryFromPath:(nonnull NSString *)srcPath toPath:(nonnull NSString *)dstPath { NSParameterAssert(srcPath); NSParameterAssert(dstPath); // Check if old path is equal to new path if ([srcPath isEqualToString:dstPath]) { return; } BOOL isDirectory; // Check if old path is directory if (![self.fileManager fileExistsAtPath:srcPath isDirectory:&isDirectory] || !isDirectory) { return; } // Check if new path is directory if (![self.fileManager fileExistsAtPath:dstPath isDirectory:&isDirectory] || !isDirectory) { if (!isDirectory) { // New path is not directory, remove file [self.fileManager removeItemAtPath:dstPath error:nil]; } NSString *dstParentPath = [dstPath stringByDeletingLastPathComponent]; // Creates any non-existent parent directories as part of creating the directory in path if (![self.fileManager fileExistsAtPath:dstParentPath]) { [self.fileManager createDirectoryAtPath:dstParentPath withIntermediateDirectories:YES attributes:nil error:NULL]; } // New directory does not exist, rename directory [self.fileManager moveItemAtPath:srcPath toPath:dstPath error:nil]; // disable iCloud backup if (self.config.shouldDisableiCloud) { // ignore iCloud backup resource value error [[NSURL fileURLWithPath:dstPath isDirectory:YES] setResourceValue:@YES forKey:NSURLIsExcludedFromBackupKey error:nil]; } } else { // New directory exist, merge the files NSURL *srcURL = [NSURL fileURLWithPath:srcPath isDirectory:YES]; NSDirectoryEnumerator *srcDirEnumerator = [self.fileManager enumeratorAtURL:srcURL includingPropertiesForKeys:@[] options:(NSDirectoryEnumerationOptions)0 errorHandler:NULL]; for (NSURL *url in srcDirEnumerator) { @autoreleasepool { NSString *dstFilePath = [dstPath stringByAppendingPathComponent:url.lastPathComponent]; NSURL *dstFileURL = [NSURL fileURLWithPath:dstFilePath isDirectory:NO]; [self.fileManager moveItemAtURL:url toURL:dstFileURL error:nil]; } } // Remove the old path [self.fileManager removeItemAtURL:srcURL error:nil]; } } #pragma mark - Hash static inline NSString *SDSanitizeFileNameString(NSString * _Nullable fileName) { if ([fileName length] == 0) { return fileName; } // note: `:` is the only invalid char on Apple file system // but `/` or `\` is valid // \0 is also special case (which cause Foundation API treat the C string as EOF) NSCharacterSet* illegalFileNameCharacters = [NSCharacterSet characterSetWithCharactersInString:@"\0:"]; return [[fileName componentsSeparatedByCharactersInSet:illegalFileNameCharacters] componentsJoinedByString:@""]; } #define SD_MAX_FILE_EXTENSION_LENGTH (NAME_MAX - CC_MD5_DIGEST_LENGTH * 2 - 1) #pragma clang diagnostic push #pragma clang diagnostic ignored "-Wdeprecated-declarations" static inline NSString * _Nonnull SDDiskCacheFileNameForKey(NSString * _Nullable key) { const char *str = key.UTF8String; if (str == NULL) { str = ""; } unsigned char r[CC_MD5_DIGEST_LENGTH]; CC_MD5(str, (CC_LONG)strlen(str), r); NSString *ext; // 1. Use URL path extname if valid NSURL *keyURL = [NSURL URLWithString:key]; if (keyURL) { ext = keyURL.pathExtension; } // 2. Use file extname if valid if (!ext) { ext = key.pathExtension; } // 3. Check if extname valid on file system ext = SDSanitizeFileNameString(ext); // File system has file name length limit, we need to check if ext is too long, we don't add it to the filename if (ext.length > SD_MAX_FILE_EXTENSION_LENGTH) { ext = nil; } NSString *filename = [NSString stringWithFormat:@"%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%@", r[0], r[1], r[2], r[3], r[4], r[5], r[6], r[7], r[8], r[9], r[10], r[11], r[12], r[13], r[14], r[15], ext.length == 0 ? @"" : [NSString stringWithFormat:@".%@", ext]]; return filename; } #pragma clang diagnostic pop @end