diff --git a/Localization/app.json b/Localization/app.json index 69d513600..1833e00e5 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -43,6 +43,10 @@ "delete_post": { "title": "Are you sure you want to delete this post?", "delete": "Delete" + }, + "clean_cache": { + "title": "Clean Cache", + "message": "Successfully clean %s cache." } }, "controls": { diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index 5790f7134..dfae5e2dd 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -21,6 +21,14 @@ internal enum L10n { return L10n.tr("Localizable", "Common.Alerts.BlockDomain.Title", String(describing: p1)) } } + internal enum CleanCache { + /// Successfully clean %@ cache. + internal static func message(_ p1: Any) -> String { + return L10n.tr("Localizable", "Common.Alerts.CleanCache.Message", String(describing: p1)) + } + /// Clean Cache + internal static let title = L10n.tr("Localizable", "Common.Alerts.CleanCache.Title") + } internal enum Common { /// Please try again. internal static let pleaseTryAgain = L10n.tr("Localizable", "Common.Alerts.Common.PleaseTryAgain") diff --git a/Mastodon/Resources/ar.lproj/Localizable.strings b/Mastodon/Resources/ar.lproj/Localizable.strings index 81bf772e1..39c4c4fec 100644 --- a/Mastodon/Resources/ar.lproj/Localizable.strings +++ b/Mastodon/Resources/ar.lproj/Localizable.strings @@ -1,5 +1,7 @@ "Common.Alerts.BlockDomain.BlockEntireDomain" = "Block entire domain"; "Common.Alerts.BlockDomain.Title" = "Are you really, really sure you want to block the entire %@? In most cases a few targeted blocks or mutes are sufficient and preferable. You will not see content from that domain in any public timelines or your notifications. Your followers from that domain will be removed."; +"Common.Alerts.CleanCache.Message" = "Successfully clean %@ cache."; +"Common.Alerts.CleanCache.Title" = "Clean Cache"; "Common.Alerts.Common.PleaseTryAgain" = "Please try again."; "Common.Alerts.Common.PleaseTryAgainLater" = "Please try again later."; "Common.Alerts.DeletePost.Delete" = "Delete"; diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index 81bf772e1..39c4c4fec 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -1,5 +1,7 @@ "Common.Alerts.BlockDomain.BlockEntireDomain" = "Block entire domain"; "Common.Alerts.BlockDomain.Title" = "Are you really, really sure you want to block the entire %@? In most cases a few targeted blocks or mutes are sufficient and preferable. You will not see content from that domain in any public timelines or your notifications. Your followers from that domain will be removed."; +"Common.Alerts.CleanCache.Message" = "Successfully clean %@ cache."; +"Common.Alerts.CleanCache.Title" = "Clean Cache"; "Common.Alerts.Common.PleaseTryAgain" = "Please try again."; "Common.Alerts.Common.PleaseTryAgainLater" = "Please try again later."; "Common.Alerts.DeletePost.Delete" = "Delete"; diff --git a/Mastodon/Scene/Settings/SettingsViewController.swift b/Mastodon/Scene/Settings/SettingsViewController.swift index 0ffb09cfb..ad85bee8f 100644 --- a/Mastodon/Scene/Settings/SettingsViewController.swift +++ b/Mastodon/Scene/Settings/SettingsViewController.swift @@ -12,8 +12,7 @@ import ActiveLabel import CoreData import CoreDataStack import MastodonSDK -import AlamofireImage -import Kingfisher + class SettingsViewController: UIViewController, NeedsDependency { @@ -319,36 +318,44 @@ extension SettingsViewController: UITableViewDelegate { func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { guard let dataSource = viewModel.dataSource else { return } - let item = dataSource.itemIdentifier(for: indexPath) + guard let item = dataSource.itemIdentifier(for: indexPath) else { return } switch item { - case .boringZone: - guard let url = viewModel.privacyURL else { break } - coordinator.present( - scene: .safari(url: url), - from: self, - transition: .safariPresent(animated: true, completion: nil) - ) - case .spicyZone(let link): - // clear media cache - if link.title == L10n.Scene.Settings.Section.Spicyzone.clear { - // clean image cache for AlamofireImage - let diskBytes = ImageDownloader.defaultURLCache().currentDiskUsage - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: diskBytes %d", ((#file as NSString).lastPathComponent), #line, #function, diskBytes) - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: clean image cache", ((#file as NSString).lastPathComponent), #line, #function) - ImageDownloader.defaultURLCache().removeAllCachedResponses() - let cleanedDiskBytes = ImageDownloader.defaultURLCache().currentDiskUsage - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: diskBytes %d", ((#file as NSString).lastPathComponent), #line, #function, cleanedDiskBytes) - - // clean Kingfisher Cache - KingfisherManager.shared.cache.clearDiskCache() - } - // logout - if link.title == L10n.Scene.Settings.Section.Spicyzone.signout { + case .apperance: + // do nothing + break + case .notification: + // do nothing + break + case .boringZone(let link), .spicyZone(let link): + switch link { + case .termsOfService, .privacyPolicy: + // same URL + guard let url = viewModel.privacyURL else { break } + coordinator.present( + scene: .safari(url: url), + from: self, + transition: .safariPresent(animated: true, completion: nil) + ) + case .clearMediaCache: + context.purgeCache() + .receive(on: RunLoop.main) + .sink { [weak self] byteCount in + guard let self = self else { return } + let byteCountformatted = AppContext.byteCountFormatter.string(fromByteCount: Int64(byteCount)) + let alertController = UIAlertController( + title: L10n.Common.Alerts.CleanCache.title, + message: L10n.Common.Alerts.CleanCache.message(byteCountformatted), + preferredStyle: .alert + ) + let okAction = UIAlertAction(title: L10n.Common.Controls.Actions.ok, style: .default, handler: nil) + alertController.addAction(okAction) + self.coordinator.present(scene: .alertController(alertController: alertController), from: nil, transition: .alertController(animated: true, completion: nil)) + } + .store(in: &disposeBag) + case .signOut: alertToSignout() } - default: - break } } } diff --git a/Mastodon/State/AppContext.swift b/Mastodon/State/AppContext.swift index 55d5841f7..8c0fa364b 100644 --- a/Mastodon/State/AppContext.swift +++ b/Mastodon/State/AppContext.swift @@ -10,6 +10,8 @@ import UIKit import Combine import CoreData import CoreDataStack +import AlamofireImage +import Kingfisher class AppContext: ObservableObject { @@ -99,3 +101,107 @@ class AppContext: ObservableObject { } } + +extension AppContext { + + typealias ByteCount = Int + + static let byteCountFormatter: ByteCountFormatter = { + let formatter = ByteCountFormatter() + return formatter + }() + + private static let purgeCacheWorkingQueue = DispatchQueue(label: "org.joinmastodon.Mastodon.AppContext.purgeCacheWorkingQueue") + + func purgeCache() -> AnyPublisher { + Publishers.MergeMany([ + AppContext.purgeAlamofireImageCache(), + AppContext.purgeKingfisherCache(), + AppContext.purgeTemporaryDirectory(), + ]) + .reduce(0, +) + .eraseToAnyPublisher() + } + + private static func purgeAlamofireImageCache() -> AnyPublisher { + Future { promise in + AppContext.purgeCacheWorkingQueue.async { + // clean image cache for AlamofireImage + let diskBytes = ImageDownloader.defaultURLCache().currentDiskUsage + ImageDownloader.defaultURLCache().removeAllCachedResponses() + let currentDiskBytes = ImageDownloader.defaultURLCache().currentDiskUsage + let purgedDiskBytes = max(0, diskBytes - currentDiskBytes) + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: purge AlamofireImage cache bytes: %ld -> %ld (%ld)", ((#file as NSString).lastPathComponent), #line, #function, diskBytes, currentDiskBytes, purgedDiskBytes) + promise(.success(purgedDiskBytes)) + } + } + .eraseToAnyPublisher() + } + + private static func purgeKingfisherCache() -> AnyPublisher { + Future { promise in + KingfisherManager.shared.cache.calculateDiskStorageSize { result in + switch result { + case .success(let diskBytes): + KingfisherManager.shared.cache.clearCache() + KingfisherManager.shared.cache.calculateDiskStorageSize { currentResult in + switch currentResult { + case .success(let currentDiskBytes): + let purgedDiskBytes = max(0, Int(diskBytes) - Int(currentDiskBytes)) + promise(.success(purgedDiskBytes)) + case .failure: + promise(.success(0)) + } + } + case .failure: + promise(.success(0)) + } + } + } + .eraseToAnyPublisher() + } + + private static func purgeTemporaryDirectory() -> AnyPublisher { + Future { promise in + AppContext.purgeCacheWorkingQueue.async { + let fileManager = FileManager.default + let temporaryDirectoryURL = fileManager.temporaryDirectory + + let resourceKeys = Set([.fileSizeKey, .isDirectoryKey]) + guard let directoryEnumerator = fileManager.enumerator( + at: temporaryDirectoryURL, + includingPropertiesForKeys: Array(resourceKeys), + options: .skipsHiddenFiles + ) else { + promise(.success(0)) + return + } + + var fileURLs: [URL] = [] + var totalFileSizeInBytes = 0 + for case let fileURL as URL in directoryEnumerator { + guard let resourceValues = try? fileURL.resourceValues(forKeys: resourceKeys), + let isDirectory = resourceValues.isDirectory else { + continue + } + + guard !isDirectory else { + continue + } + fileURLs.append(fileURL) + totalFileSizeInBytes += resourceValues.fileSize ?? 0 + } + + for fileURL in fileURLs { + try? fileManager.removeItem(at: fileURL) + } + + promise(.success(totalFileSizeInBytes)) + } + } + .eraseToAnyPublisher() + } +// +// os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: purge temporary directory success", ((#file as NSString).lastPathComponent), #line, #function) +// } +}