diff --git a/DevLog/App/Assembler/DataAssembler.swift b/DevLog/App/Assembler/DataAssembler.swift index b193137a..aaba3440 100644 --- a/DevLog/App/Assembler/DataAssembler.swift +++ b/DevLog/App/Assembler/DataAssembler.swift @@ -87,6 +87,12 @@ final class DataAssembler: Assembler { ) } + container.register(WebPageImageRepository.self) { + WebPageImageRepositoryImpl( + store: container.resolve(WebPageImageStore.self) + ) + } + container.register(UserPreferencesRepository.self) { UserPreferencesRepositoryImpl( store: container.resolve(UserDefaultsStore.self), diff --git a/DevLog/App/Assembler/DomainAssembler.swift b/DevLog/App/Assembler/DomainAssembler.swift index da962efc..9824e444 100644 --- a/DevLog/App/Assembler/DomainAssembler.swift +++ b/DevLog/App/Assembler/DomainAssembler.swift @@ -145,10 +145,18 @@ private extension DomainAssembler { FetchWebPagesUseCaseImpl(container.resolve(WebPageRepository.self)) } + container.register(FetchWebPageImageDirSizeUseCase.self) { + FetchWebPageImageDirSizeUseCaseImpl(container.resolve(WebPageImageRepository.self)) + } + container.register(AddWebPageUseCase.self) { AddWebPageUseCaseImpl(container.resolve(WebPageRepository.self)) } + container.register(ClearWebPageImageDirectoryUseCase.self) { + ClearWebPageImageDirectoryUseCaseImpl(container.resolve(WebPageImageRepository.self)) + } + container.register(DeleteWebPageUseCase.self) { DeleteWebPageUseCaseImpl(container.resolve(WebPageRepository.self)) } diff --git a/DevLog/App/Assembler/InfraAssembler.swift b/DevLog/App/Assembler/InfraAssembler.swift index 33fcce01..bd4827bb 100644 --- a/DevLog/App/Assembler/InfraAssembler.swift +++ b/DevLog/App/Assembler/InfraAssembler.swift @@ -53,7 +53,9 @@ final class InfraAssembler: Assembler { } container.register(WebPageMetadataService.self) { - WebPageMetadataService() + WebPageMetadataService( + store: container.resolve(WebPageImageStore.self) + ) } container.register(NWPathConnectivityProvider.self) { diff --git a/DevLog/App/Assembler/PersistenceAssembler.swift b/DevLog/App/Assembler/PersistenceAssembler.swift index c904a1ec..70bd6ced 100644 --- a/DevLog/App/Assembler/PersistenceAssembler.swift +++ b/DevLog/App/Assembler/PersistenceAssembler.swift @@ -14,5 +14,9 @@ final class PersistenceAssembler: Assembler { container.register(ThemeStore.self) { ThemeStore() } + + container.register(WebPageImageStore.self) { + WebPageImageStore() + } } } diff --git a/DevLog/Data/Repository/WebPageImageRepositoryImpl.swift b/DevLog/Data/Repository/WebPageImageRepositoryImpl.swift new file mode 100644 index 00000000..df991373 --- /dev/null +++ b/DevLog/Data/Repository/WebPageImageRepositoryImpl.swift @@ -0,0 +1,28 @@ +// +// WebPageImageRepositoryImpl.swift +// DevLog +// +// Created by opfic on 4/14/26. +// + +final class WebPageImageRepositoryImpl: WebPageImageRepository { + private let store: WebPageImageStore + + init(store: WebPageImageStore) { + self.store = store + } + + func fetchDirSizeInBytes() async -> Int64 { + let store = self.store + return await Task.detached(priority: .utility) { + store.dirSizeInBytes() + }.value + } + + func clearDirectory() async throws { + let store = self.store + try await Task.detached(priority: .utility) { + try store.clearDirectory() + }.value + } +} diff --git a/DevLog/Domain/Protocol/WebPageImageRepository.swift b/DevLog/Domain/Protocol/WebPageImageRepository.swift new file mode 100644 index 00000000..d44e0b66 --- /dev/null +++ b/DevLog/Domain/Protocol/WebPageImageRepository.swift @@ -0,0 +1,11 @@ +// +// WebPageImageRepository.swift +// DevLog +// +// Created by opfic on 4/14/26. +// + +protocol WebPageImageRepository { + func fetchDirSizeInBytes() async -> Int64 + func clearDirectory() async throws +} diff --git a/DevLog/Domain/UseCase/WebPage/Fetch/FetchWebPageImageDirSizeUseCase.swift b/DevLog/Domain/UseCase/WebPage/Fetch/FetchWebPageImageDirSizeUseCase.swift new file mode 100644 index 00000000..cdba5c2f --- /dev/null +++ b/DevLog/Domain/UseCase/WebPage/Fetch/FetchWebPageImageDirSizeUseCase.swift @@ -0,0 +1,10 @@ +// +// FetchWebPageImageDirSizeUseCase.swift +// DevLog +// +// Created by opfic on 4/14/26. +// + +protocol FetchWebPageImageDirSizeUseCase { + func execute() async -> Int64 +} diff --git a/DevLog/Domain/UseCase/WebPage/Fetch/FetchWebPageImageDirSizeUseCaseImpl.swift b/DevLog/Domain/UseCase/WebPage/Fetch/FetchWebPageImageDirSizeUseCaseImpl.swift new file mode 100644 index 00000000..513262f5 --- /dev/null +++ b/DevLog/Domain/UseCase/WebPage/Fetch/FetchWebPageImageDirSizeUseCaseImpl.swift @@ -0,0 +1,18 @@ +// +// FetchWebPageImageDirSizeUseCaseImpl.swift +// DevLog +// +// Created by opfic on 4/14/26. +// + +final class FetchWebPageImageDirSizeUseCaseImpl: FetchWebPageImageDirSizeUseCase { + private let repository: WebPageImageRepository + + init(_ repository: WebPageImageRepository) { + self.repository = repository + } + + func execute() async -> Int64 { + await repository.fetchDirSizeInBytes() + } +} diff --git a/DevLog/Domain/UseCase/WebPage/Upsert/ClearWebPageImageDirectoryUseCase.swift b/DevLog/Domain/UseCase/WebPage/Upsert/ClearWebPageImageDirectoryUseCase.swift new file mode 100644 index 00000000..f2b5702a --- /dev/null +++ b/DevLog/Domain/UseCase/WebPage/Upsert/ClearWebPageImageDirectoryUseCase.swift @@ -0,0 +1,10 @@ +// +// ClearWebPageImageDirectoryUseCase.swift +// DevLog +// +// Created by opfic on 4/14/26. +// + +protocol ClearWebPageImageDirectoryUseCase { + func execute() async throws +} diff --git a/DevLog/Domain/UseCase/WebPage/Upsert/ClearWebPageImageDirectoryUseCaseImpl.swift b/DevLog/Domain/UseCase/WebPage/Upsert/ClearWebPageImageDirectoryUseCaseImpl.swift new file mode 100644 index 00000000..7443208a --- /dev/null +++ b/DevLog/Domain/UseCase/WebPage/Upsert/ClearWebPageImageDirectoryUseCaseImpl.swift @@ -0,0 +1,18 @@ +// +// ClearWebPageImageDirectoryUseCaseImpl.swift +// DevLog +// +// Created by opfic on 4/14/26. +// + +final class ClearWebPageImageDirectoryUseCaseImpl: ClearWebPageImageDirectoryUseCase { + private let repository: WebPageImageRepository + + init(_ repository: WebPageImageRepository) { + self.repository = repository + } + + func execute() async throws { + try await repository.clearDirectory() + } +} diff --git a/DevLog/Infra/Service/WebPageMetadataService.swift b/DevLog/Infra/Service/WebPageMetadataService.swift index db35e3ad..c7dcfa2a 100644 --- a/DevLog/Infra/Service/WebPageMetadataService.swift +++ b/DevLog/Infra/Service/WebPageMetadataService.swift @@ -10,8 +10,13 @@ import LinkPresentation import UIKit final class WebPageMetadataService { + private let imageStore: WebPageImageStore private let logger = Logger(category: "WebPageMetadataService") + init(store: WebPageImageStore) { + self.imageStore = store + } + func fetchMetadata(from urlString: String) async throws -> WebPageMetadataResponse { logger.info("Fetching metadata for URL: \(urlString)") @@ -46,12 +51,7 @@ final class WebPageMetadataService { } do { - let removed = try await Task.detached(priority: .utility) { - let fileURL = try Self.cacheFileURL(for: url) - guard FileManager.default.fileExists(atPath: fileURL.path) else { return false } - try FileManager.default.removeItem(at: fileURL) - return true - }.value + let removed = try imageStore.removeImage(for: url) if removed { logger.info("Removed cached image for URL: \(urlString)") @@ -66,11 +66,12 @@ final class WebPageMetadataService { throw URLError(.badURL) } - return try Self.cacheFileURL(for: url) + return try imageStore.cachedImageURL(for: url) } private func extractImageURL(from imageProvider: NSItemProvider?, url: URL) async throws -> URL? { guard let imageProvider else { return nil } + let imageStore = self.imageStore return try await withCheckedThrowingContinuation { continuation in imageProvider.loadObject(ofClass: UIImage.self) { image, error in @@ -86,45 +87,12 @@ final class WebPageMetadataService { } do { - let fileURL = try Self.cacheFileURL(for: url) - Task.detached { [data, fileURL] in - do { - try data.write(to: fileURL, options: [.atomic]) - continuation.resume(returning: fileURL) - } catch { - continuation.resume(throwing: error) - } - } + let fileURL = try imageStore.saveImage(data, for: url) + continuation.resume(returning: fileURL) } catch { continuation.resume(throwing: error) } } } } - - private static func cacheFileURL(for url: URL) throws -> URL { - let imageDir = try imageDirectoryURL() - - let fileName = url.absoluteString - .addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? UUID().uuidString - - return imageDir - .appendingPathComponent(fileName) - .appendingPathExtension("jpeg") - } - - private static func imageDirectoryURL() throws -> URL { - let cachesDir = try FileManager.default.url( - for: .cachesDirectory, - in: .userDomainMask, - appropriateFor: nil, - create: true - ) - let imageDir = cachesDir.appendingPathComponent("webPageImages", isDirectory: true) - if !FileManager.default.fileExists(atPath: imageDir.path) { - try FileManager.default.createDirectory(at: imageDir, withIntermediateDirectories: true) - } - - return imageDir - } } diff --git a/DevLog/Presentation/ViewModel/SettingViewModel.swift b/DevLog/Presentation/ViewModel/SettingViewModel.swift index 482b47d5..c79d3c92 100644 --- a/DevLog/Presentation/ViewModel/SettingViewModel.swift +++ b/DevLog/Presentation/ViewModel/SettingViewModel.swift @@ -24,6 +24,7 @@ final class SettingViewModel: Store { enum Action { case networkStatusChanged(Bool) case setAlert(isPresented: Bool, type: AlertType? = nil) + case setDirSize(Int64) case setLoading(Bool) case setTheme(SystemTheme) case updateDirSize @@ -34,7 +35,9 @@ final class SettingViewModel: Store { } enum SideEffect { + case clearWebPageImageDirectory case deleteAuth + case fetchWebPageImageDirSize case signOut } @@ -48,6 +51,8 @@ final class SettingViewModel: Store { private let networkConnectivityUseCase: ObserveNetworkConnectivityUseCase private let systemThemeUseCase: ObserveSystemThemeUseCase private let updateSystemThemeUseCase: UpdateSystemThemeUseCase + private let fetchWebPageImageDirSizeUseCase: FetchWebPageImageDirSizeUseCase + private let clearWebPageImageDirectoryUseCase: ClearWebPageImageDirectoryUseCase private let loadingState = LoadingState() private var cancellables = Set() @@ -60,13 +65,17 @@ final class SettingViewModel: Store { signOutUseCase: SignOutUseCase, networkConnectivityUseCase: ObserveNetworkConnectivityUseCase, systemThemeUseCase: ObserveSystemThemeUseCase, - updateSystemThemeUseCase: UpdateSystemThemeUseCase + updateSystemThemeUseCase: UpdateSystemThemeUseCase, + fetchWebPageImageDirSizeUseCase: FetchWebPageImageDirSizeUseCase, + clearWebPageImageDirectoryUseCase: ClearWebPageImageDirectoryUseCase ) { self.deleteAuthuseCase = deleteAuthUseCase self.signOutUseCase = signOutUseCase self.networkConnectivityUseCase = networkConnectivityUseCase self.systemThemeUseCase = systemThemeUseCase self.updateSystemThemeUseCase = updateSystemThemeUseCase + self.fetchWebPageImageDirSizeUseCase = fetchWebPageImageDirSizeUseCase + self.clearWebPageImageDirectoryUseCase = clearWebPageImageDirectoryUseCase setupNetworkObserving() setupThemeMonitoring() } @@ -80,13 +89,15 @@ final class SettingViewModel: Store { state.isNetworkConnected = isConnected case .setAlert(let isPresented, let type): setAlert(&state, isPresented: isPresented, type: type) + case .setDirSize(let value): + state.dirSize = value case .setLoading(let value): state.isLoading = value case .setTheme(let value): state.theme = value updateSystemThemeUseCase.execute(value) case .updateDirSize: - state.dirSize = dirSizeInBytes() + effects = [.fetchWebPageImageDirSize] case .tapDeleteAuthButton: effects = [.deleteAuth] case .tapSignOutButton: @@ -94,13 +105,8 @@ final class SettingViewModel: Store { case .tapRemoveCacheButton: setAlert(&state, isPresented: true, type: .removeCache) case .confirmRemoveCache: - do { - setAlert(&state, isPresented: false) - try clearCacheDirectory() - state.dirSize = dirSizeInBytes() - } catch { - setAlert(&state, isPresented: true, type: .error) - } + setAlert(&state, isPresented: false) + effects = [.clearWebPageImageDirectory] } if self.state != state { self.state = state } @@ -109,6 +115,16 @@ final class SettingViewModel: Store { func run(_ effect: SideEffect) { switch effect { + case .clearWebPageImageDirectory: + Task { + do { + try await clearWebPageImageDirectoryUseCase.execute() + let dirSize = await fetchWebPageImageDirSizeUseCase.execute() + send(.setDirSize(dirSize)) + } catch { + send(.setAlert(isPresented: true, type: .error)) + } + } case .deleteAuth: beginLoading(.delayed) Task { @@ -120,6 +136,11 @@ final class SettingViewModel: Store { send(.setAlert(isPresented: true, type: .error)) } } + case .fetchWebPageImageDirSize: + Task { + let dirSize = await fetchWebPageImageDirSizeUseCase.execute() + send(.setDirSize(dirSize)) + } case .signOut: beginLoading(.delayed) Task { @@ -181,43 +202,6 @@ private extension SettingViewModel { .store(in: &cancellables) } - func dirSizeInBytes() -> Int64 { - do { - let cachesDir = try FileManager.default.url( - for: .cachesDirectory, - in: .userDomainMask, - appropriateFor: nil, - create: false - ) - guard FileManager.default.fileExists(atPath: cachesDir.path) else { return 0 } - return directorySize(at: cachesDir) - } catch { - return 0 - } - } - - private func directorySize(at url: URL) -> Int64 { - guard FileManager.default.fileExists(atPath: url.path) else { return 0 } - guard let enumerator = FileManager.default.enumerator( - at: url, - includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey], - options: [.skipsHiddenFiles] - ) else { - return 0 - } - - var total: Int64 = 0 - for case let fileURL as URL in enumerator { - guard let resourceValues = try? fileURL.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey]), - resourceValues.isRegularFile == true, - let fileSize = resourceValues.fileSize else { - continue - } - total += Int64(fileSize) - } - return total - } - private func beginLoading(_ mode: LoadingState.Mode) { loadingState.begin(mode: mode) { [weak self] isLoading in self?.send(.setLoading(isLoading)) @@ -230,21 +214,4 @@ private extension SettingViewModel { } } - private func clearCacheDirectory() throws { - let cachesDir = try FileManager.default.url( - for: .cachesDirectory, - in: .userDomainMask, - appropriateFor: nil, - create: false - ) - guard FileManager.default.fileExists(atPath: cachesDir.path) else { return } - let contents = try FileManager.default.contentsOfDirectory( - at: cachesDir, - includingPropertiesForKeys: nil, - options: [.skipsHiddenFiles] - ) - for url in contents { - try FileManager.default.removeItem(at: url) - } - } } diff --git a/DevLog/Storage/Persistence/WebPageImageStore.swift b/DevLog/Storage/Persistence/WebPageImageStore.swift new file mode 100644 index 00000000..e89e2d90 --- /dev/null +++ b/DevLog/Storage/Persistence/WebPageImageStore.swift @@ -0,0 +1,113 @@ +// +// WebPageImageStore.swift +// DevLog +// +// Created by opfic on 4/14/26. +// + +import Combine +import CryptoKit +import Foundation + +final class WebPageImageStore { + private let fileManager: FileManager + private let subject = CurrentValueSubject(0) + + init(fileManager: FileManager = .default) { + self.fileManager = fileManager + subject.send(dirSizeInBytes()) + } + + func cachedImageURL(for url: URL) throws -> URL { + let imageDirectoryURL = try self.imageDirectoryURL(create: true) + let fileName = hashedFileName(for: url) + + return imageDirectoryURL + .appendingPathComponent(fileName) + .appendingPathExtension("jpeg") + } + + func saveImage(_ data: Data, for url: URL) throws -> URL { + let fileURL = try cachedImageURL(for: url) + try data.write(to: fileURL, options: [.atomic]) + subject.send(dirSizeInBytes()) + return fileURL + } + + func dirSizeInBytes() -> Int64 { + do { + let imageDirectoryURL = try self.imageDirectoryURL(create: false) + guard fileManager.fileExists(atPath: imageDirectoryURL.path) else { return 0 } + guard let enumerator = fileManager.enumerator( + at: imageDirectoryURL, + includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey], + options: [.skipsHiddenFiles] + ) else { + return 0 + } + + var total: Int64 = 0 + for case let fileURL as URL in enumerator { + guard let resourceValues = try? fileURL.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey]), + resourceValues.isRegularFile == true, + let fileSize = resourceValues.fileSize else { + continue + } + total += Int64(fileSize) + } + return total + } catch { + return 0 + } + } + + func clearDirectory() throws { + let imageDirectoryURL = try self.imageDirectoryURL(create: false) + guard fileManager.fileExists(atPath: imageDirectoryURL.path) else { return } + let contentURLs = try fileManager.contentsOfDirectory( + at: imageDirectoryURL, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles] + ) + for contentURL in contentURLs { + try fileManager.removeItem(at: contentURL) + } + subject.send(dirSizeInBytes()) + } + + func removeImage(for url: URL) throws -> Bool { + let fileURL = try cachedImageURL(for: url) + guard fileManager.fileExists(atPath: fileURL.path) else { return false } + try fileManager.removeItem(at: fileURL) + subject.send(dirSizeInBytes()) + return true + } +} + +private extension WebPageImageStore { + func hashedFileName(for url: URL) -> String { + let hashValue = SHA256.hash(data: Data(url.absoluteString.utf8)) + return hashValue.map { String(format: "%02x", $0) }.joined() + } + + func imageDirectoryURL(create: Bool) throws -> URL { + let directory = try fileManager.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: create + ) + let imageDirectory = directory.appendingPathComponent("webPageImages", isDirectory: true) + if create && !fileManager.fileExists(atPath: imageDirectory.path) { + try fileManager.createDirectory(at: imageDirectory, withIntermediateDirectories: true) + } + if create { + var resourceValues = URLResourceValues() + resourceValues.isExcludedFromBackup = true + var imageDirectory = imageDirectory + try imageDirectory.setResourceValues(resourceValues) + } + + return imageDirectory + } +} diff --git a/DevLog/UI/Profile/ProfileView.swift b/DevLog/UI/Profile/ProfileView.swift index 459af9ac..9723059b 100644 --- a/DevLog/UI/Profile/ProfileView.swift +++ b/DevLog/UI/Profile/ProfileView.swift @@ -103,7 +103,9 @@ struct ProfileView: View { signOutUseCase: container.resolve(SignOutUseCase.self), networkConnectivityUseCase: container.resolve(ObserveNetworkConnectivityUseCase.self), systemThemeUseCase: container.resolve(ObserveSystemThemeUseCase.self), - updateSystemThemeUseCase: container.resolve(UpdateSystemThemeUseCase.self) + updateSystemThemeUseCase: container.resolve(UpdateSystemThemeUseCase.self), + fetchWebPageImageDirSizeUseCase: container.resolve(FetchWebPageImageDirSizeUseCase.self), + clearWebPageImageDirectoryUseCase: container.resolve(ClearWebPageImageDirectoryUseCase.self) )) .environment(router) case .activity(let todoId):