diff --git a/damus/Core/Storage/DamusCacheManager.swift b/damus/Core/Storage/DamusCacheManager.swift index 00413817b..bab1149b7 100644 --- a/damus/Core/Storage/DamusCacheManager.swift +++ b/damus/Core/Storage/DamusCacheManager.swift @@ -10,6 +10,12 @@ import Kingfisher struct DamusCacheManager { static var shared: DamusCacheManager = DamusCacheManager() + private static let byteCountFormatter: ByteCountFormatter = { + let formatter = ByteCountFormatter() + formatter.allowedUnits = [.useMB, .useGB] + formatter.countStyle = .file + return formatter + }() func clear_cache(damus_state: DamusState, completion: (() -> Void)? = nil) { Log.info("Clearing all caches", for: .storage) @@ -23,8 +29,18 @@ struct DamusCacheManager { func clear_kingfisher_cache(completion: (() -> Void)? = nil) { Log.info("Clearing Kingfisher cache", for: .storage) + KingfisherManager.shared.cache.calculateDiskStorageSize { result in + if case .success(let size) = result { + Log.info("Kingfisher disk cache before clear: %s", for: .storage, self.formattedByteCount(from: UInt64(max(size, 0)))) + } + } KingfisherManager.shared.cache.clearMemoryCache() KingfisherManager.shared.cache.clearDiskCache { + KingfisherManager.shared.cache.calculateDiskStorageSize { result in + if case .success(let size) = result { + Log.info("Kingfisher disk cache after clear: %s", for: .storage, self.formattedByteCount(from: UInt64(max(size, 0)))) + } + } Log.info("Kingfisher cache cleared", for: .storage) completion?() } @@ -36,24 +52,59 @@ struct DamusCacheManager { do { let fileNames = try FileManager.default.contentsOfDirectory(atPath: cacheURL.path) + let fileURLs = fileNames.map { cacheURL.appendingPathComponent($0) } + let initialSize = totalAllocatedSize(of: fileURLs) + Log.info("Cache folder contains %d items totaling %s", for: .storage, fileNames.count, formattedByteCount(from: initialSize)) + var removedCount = 0 + var freedBytes: UInt64 = 0 for fileName in fileNames { let filePath = cacheURL.appendingPathComponent(fileName) // Prevent issues by double-checking if files are in use, and do not delete them if they are. // This is not perfect. There is still a small chance for a race condition if a file is opened between this check and the file removal. - let isBusy = (!(access(filePath.path, F_OK) == -1 && errno == ETXTBSY)) + errno = 0 + let isBusy = (access(filePath.path, F_OK) == -1 && errno == ETXTBSY) if isBusy { + Log.debug("Skipping busy cache file: %s", for: .storage, filePath.lastPathComponent) continue } + let fileSize = allocatedSize(of: filePath) + try FileManager.default.removeItem(at: filePath) + removedCount += 1 + freedBytes &+= fileSize } - Log.info("Cache folder cleared successfully.", for: .storage) + Log.info("Cache folder cleared successfully. Removed %d items freeing %s", for: .storage, removedCount, formattedByteCount(from: freedBytes)) completion?() } catch { Log.error("Could not clear cache folder", for: .storage) } } + + private func formattedByteCount(from bytes: UInt64) -> String { + let clamped = min(bytes, UInt64(Int64.max)) + return Self.byteCountFormatter.string(fromByteCount: Int64(clamped)) + } + + private func allocatedSize(of url: URL) -> UInt64 { + guard let values = try? url.resourceValues(forKeys: [.totalFileAllocatedSizeKey, .fileAllocatedSizeKey]) else { + return 0 + } + if let total = values.totalFileAllocatedSize { + return UInt64(max(total, 0)) + } + if let single = values.fileAllocatedSize { + return UInt64(max(single, 0)) + } + return 0 + } + + private func totalAllocatedSize(of urls: [URL]) -> UInt64 { + return urls.reduce(0) { partialResult, url in + partialResult &+ allocatedSize(of: url) + } + } } diff --git a/damus/Shared/Media/ImageCacheMigrations.swift b/damus/Shared/Media/ImageCacheMigrations.swift index c50117fd4..ea1715880 100644 --- a/damus/Shared/Media/ImageCacheMigrations.swift +++ b/damus/Shared/Media/ImageCacheMigrations.swift @@ -11,6 +11,12 @@ import Kingfisher struct ImageCacheMigrations { static func migrateKingfisherCacheIfNeeded() { let fileManager = FileManager.default + + guard fileManager.containerURL(forSecurityApplicationGroupIdentifier: Constants.DAMUS_APP_GROUP_IDENTIFIER) != nil else { + Log.error("Skipping Kingfisher cache migration because app group container is unavailable", for: .storage) + return + } + let defaults = UserDefaults.standard let migration1Key = "KingfisherCacheMigrated" // Never ever changes let migration2Key = "KingfisherCacheMigratedV2" // Never ever changes @@ -60,8 +66,15 @@ struct ImageCacheMigrations { static private func migration1KingfisherCachePath() -> String { // Implementation note: These are old, so they are hard-coded on purpose, because we can't change these values from the past. - let groupURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.com.damus")! - return groupURL.appendingPathComponent("ImageCache").path + if let groupURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.com.damus") { + return groupURL.appendingPathComponent("ImageCache").path + } + + let fallback = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] + .appendingPathComponent(Constants.IMAGE_CACHE_DIRNAME) + .path + Log.error("Legacy Kingfisher cache path unavailable; using fallback at %s", for: .storage, fallback) + return fallback } /// The latest path for kingfisher to store cached images on. @@ -70,10 +83,16 @@ struct ImageCacheMigrations { /// - https://developer.apple.com/documentation/foundation/filemanager/containerurl(forsecurityapplicationgroupidentifier:)#:~:text=The%20system%20creates%20only%20the%20Library/Caches%20subdirectory%20automatically /// - https://developer.apple.com/library/archive/documentation/FileManagement/Conceptual/FileSystemProgrammingGuide/FileSystemOverview/FileSystemOverview.html#:~:text=Put%20data%20cache,files%20as%20needed. static func kingfisherCachePath() -> URL { - let groupURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: Constants.DAMUS_APP_GROUP_IDENTIFIER)! - return groupURL - .appendingPathComponent("Library") - .appendingPathComponent("Caches") + if let groupURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: Constants.DAMUS_APP_GROUP_IDENTIFIER) { + return groupURL + .appendingPathComponent("Library") + .appendingPathComponent("Caches") + .appendingPathComponent(Constants.IMAGE_CACHE_DIRNAME) + } + + let fallbackURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] .appendingPathComponent(Constants.IMAGE_CACHE_DIRNAME) + Log.error("App group container unavailable; using fallback cache directory at %s", for: .storage, fallbackURL.path) + return fallbackURL } } diff --git a/damus/damusApp.swift b/damus/damusApp.swift index a53f57842..bc2f36d91 100644 --- a/damus/damusApp.swift +++ b/damus/damusApp.swift @@ -115,6 +115,11 @@ class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDele private func configureKingfisherCache() { let cachePath = ImageCacheMigrations.kingfisherCachePath() + do { + try FileManager.default.createDirectory(at: cachePath, withIntermediateDirectories: true) + } catch { + Log.error("Failed to create Kingfisher cache directory: %s", for: .storage, error.localizedDescription) + } if let cache = try? ImageCache(name: "sharedCache", cacheDirectoryURL: cachePath) { KingfisherManager.shared.cache = cache }