diff --git a/Keychy/Keychy/Core/Components/View/Popup/BundleSwitchPopup.swift b/Keychy/Keychy/Core/Components/View/Popup/BundleSwitchPopup.swift index 7fb780bab..4b6245b69 100644 --- a/Keychy/Keychy/Core/Components/View/Popup/BundleSwitchPopup.swift +++ b/Keychy/Keychy/Core/Components/View/Popup/BundleSwitchPopup.swift @@ -119,7 +119,11 @@ struct BundleSwitchButton: View { let onTap: () -> Void var body: some View { - Button(action: onTap) { + Button { + if isEnabled { + onTap() + } + } label: { HStack(spacing: 12) { Text(bundleName) .typography(.nanum24EB) @@ -139,6 +143,5 @@ struct BundleSwitchButton: View { } } .buttonStyle(.plain) - .disabled(!isEnabled) } } diff --git a/Keychy/Keychy/Core/FileManagers/EffectManager.swift b/Keychy/Keychy/Core/FileManagers/EffectManager.swift index 2136d3f1a..22aa12001 100644 --- a/Keychy/Keychy/Core/FileManagers/EffectManager.swift +++ b/Keychy/Keychy/Core/FileManagers/EffectManager.swift @@ -9,6 +9,7 @@ import Foundation import FirebaseStorage import FirebaseFirestore import AVFoundation +import Lottie /// 이펙트(사운드, 파티클) 다운로드 및 재생을 관리하는 매니저 @MainActor @@ -69,6 +70,11 @@ class EffectManager { // 이미 다운로드 중이면 무시 guard !downloadingItemIds.contains(soundId) else { return } + // 캐시 검증: 파일이 존재하고 URL이 일치하면 다운로드 스킵 + if isInCache(soundId: soundId) && isCacheValid(id: soundId, currentURL: sound.soundData, type: .sound) { + return + } + // 다운로드 시작 downloadingItemIds.insert(soundId) downloadProgress[soundId] = 0.0 @@ -124,6 +130,9 @@ class EffectManager { // 사운드 프리로드 (재생 준비) await SoundEffectComponent.shared.preloadSound(named: soundId) + // 캐시 URL 저장 (다음 번 검증용) + saveCacheURL(id: soundId, url: sound.soundData, type: .sound) + // 다운로드 상태 초기화 downloadingItemIds.remove(soundId) downloadProgress.removeValue(forKey: soundId) @@ -136,6 +145,11 @@ class EffectManager { // 이미 다운로드 중이면 무시 guard !downloadingItemIds.contains(particleId) else { return } + // 캐시 검증: 파일이 존재하고 URL이 일치하면 다운로드 스킵 + if isInCache(particleId: particleId) && isCacheValid(id: particleId, currentURL: particle.particleData, type: .particle) { + return + } + // 다운로드 시작 downloadingItemIds.insert(particleId) downloadProgress[particleId] = 0.0 @@ -188,6 +202,12 @@ class EffectManager { attempts += 1 } + // 캐시 URL 저장 (다음 번 검증용) + saveCacheURL(id: particleId, url: particle.particleData, type: .particle) + + // Lottie 애니메이션 캐시 클리어 (새 파일 로드를 위해) + LottieAnimationCache.shared?.clearCache() + // 다운로드 상태 초기화 downloadingItemIds.remove(particleId) downloadProgress.removeValue(forKey: particleId) @@ -262,6 +282,31 @@ class EffectManager { } } + // MARK: - Cache URL Validation + + /// 캐시된 URL을 저장하는 UserDefaults 키 + private func cacheURLKey(for id: String, type: ItemType) -> String { + switch type { + case .sound: return "cachedSoundURL_\(id)" + case .particle: return "cachedParticleURL_\(id)" + } + } + + /// 캐시가 유효한지 확인 (파일 존재 + URL 일치) + private func isCacheValid(id: String, currentURL: String, type: ItemType) -> Bool { + let key = cacheURLKey(for: id, type: type) + guard let savedURL = UserDefaults.standard.string(forKey: key) else { + return false + } + return savedURL == currentURL + } + + /// 캐시 URL 저장 + private func saveCacheURL(id: String, url: String, type: ItemType) { + let key = cacheURLKey(for: id, type: type) + UserDefaults.standard.set(url, forKey: key) + } + /// UserManager의 유저 데이터 새로고침 private func refreshUserData(userManager: UserManager) async { guard let userId = userManager.currentUser?.id else { return } diff --git a/Keychy/Keychy/Core/Firebase/EffectSyncManager.swift b/Keychy/Keychy/Core/Firebase/EffectSyncManager.swift index a76d9f191..2999b33f7 100644 --- a/Keychy/Keychy/Core/Firebase/EffectSyncManager.swift +++ b/Keychy/Keychy/Core/Firebase/EffectSyncManager.swift @@ -10,6 +10,7 @@ import Foundation import FirebaseFirestore import FirebaseStorage +import Lottie @Observable class EffectSyncManager { @@ -169,14 +170,11 @@ class EffectSyncManager { print("[EffectSync] 구매한 사운드: \(soundEffects.count)개") - // 병렬 다운로드 + // 병렬 다운로드 (URL 검증은 downloadSoundIfNeeded 내부에서 처리) await withTaskGroup(of: Void.self) { group in for soundId in soundEffects { - // 캐시에 없으면 다운로드 - if !isInCache(soundId: soundId) { - group.addTask { - await self.downloadSoundIfNeeded(soundId: soundId) - } + group.addTask { + await self.downloadSoundIfNeeded(soundId: soundId) } } } @@ -202,14 +200,11 @@ class EffectSyncManager { print("[EffectSync] 구매한 파티클: \(particleEffects.count)개") - // 병렬 다운로드 + // 병렬 다운로드 (URL 검증은 downloadParticleIfNeeded 내부에서 처리) await withTaskGroup(of: Void.self) { group in for particleId in particleEffects { - // 캐시에 없으면 다운로드 - if !isInCache(particleId: particleId) { - group.addTask { - await self.downloadParticleIfNeeded(particleId: particleId) - } + group.addTask { + await self.downloadParticleIfNeeded(particleId: particleId) } } } @@ -218,7 +213,7 @@ class EffectSyncManager { } } - /// 사운드 다운로드 (캐시에 없을 때만) + /// 사운드 다운로드 (캐시에 없거나 URL이 변경된 경우) private func downloadSoundIfNeeded(soundId: String) async { // soundId가 URL 형태인지 확인 (커스텀 사운드) if soundId.hasPrefix("http://") || soundId.hasPrefix("https://") { @@ -228,13 +223,6 @@ class EffectSyncManager { // 번들에 있으면 스킵 (무료 이펙트) if isInBundle(soundId: soundId) { - print("[EffectSync] 사운드 번들에 있음 (스킵): \(soundId)") - return - } - - // 캐시에 있으면 스킵 - guard !isInCache(soundId: soundId) else { - print("[EffectSync] 사운드 이미 캐시에 있음: \(soundId)") return } @@ -252,6 +240,11 @@ class EffectSyncManager { return } + // 캐시 검증: 파일이 존재하고 URL이 일치하면 스킵 + if isInCache(soundId: soundId) && isCacheValid(id: soundId, currentURL: soundURLString, type: .sound) { + return + } + let cacheDirectory = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] let soundsDir = cacheDirectory.appendingPathComponent("sounds") @@ -271,6 +264,9 @@ class EffectSyncManager { } try FileManager.default.moveItem(at: tempURL, to: localURL) + // 캐시 URL 저장 + saveCacheURL(id: soundId, url: soundURLString, type: .sound) + print("[EffectSync] 사운드 다운로드 완료: \(soundId)") } catch { @@ -316,17 +312,10 @@ class EffectSyncManager { } } - /// 파티클 다운로드 (캐시에 없을 때만) + /// 파티클 다운로드 (캐시에 없거나 URL이 변경된 경우) private func downloadParticleIfNeeded(particleId: String) async { // 번들에 있으면 스킵 (무료 이펙트) if isInBundle(particleId: particleId) { - print("[EffectSync] 파티클 번들에 있음 (스킵): \(particleId)") - return - } - - // 캐시에 있으면 스킵 - guard !isInCache(particleId: particleId) else { - print("[EffectSync] 파티클 이미 캐시에 있음: \(particleId)") return } @@ -344,6 +333,11 @@ class EffectSyncManager { return } + // 캐시 검증: 파일이 존재하고 URL이 일치하면 스킵 + if isInCache(particleId: particleId) && isCacheValid(id: particleId, currentURL: particleURLString, type: .particle) { + return + } + let cacheDirectory = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] let particlesDir = cacheDirectory.appendingPathComponent("particles") @@ -363,6 +357,14 @@ class EffectSyncManager { } try FileManager.default.moveItem(at: tempURL, to: localURL) + // 캐시 URL 저장 + saveCacheURL(id: particleId, url: particleURLString, type: .particle) + + // Lottie 애니메이션 캐시 클리어 (새 파일 로드를 위해) + await MainActor.run { + LottieAnimationCache.shared?.clearCache() + } + print("[EffectSync] 파티클 다운로드 완료: \(particleId)") } catch { @@ -395,4 +397,34 @@ class EffectSyncManager { private func isInBundle(particleId: String) -> Bool { return Bundle.main.path(forResource: particleId, ofType: "json") != nil } + + // MARK: - Cache URL Validation + + private enum ItemType { + case sound + case particle + } + + /// 캐시된 URL을 저장하는 UserDefaults 키 + private func cacheURLKey(for id: String, type: ItemType) -> String { + switch type { + case .sound: return "cachedSoundURL_\(id)" + case .particle: return "cachedParticleURL_\(id)" + } + } + + /// 캐시가 유효한지 확인 (파일 존재 + URL 일치) + private func isCacheValid(id: String, currentURL: String, type: ItemType) -> Bool { + let key = cacheURLKey(for: id, type: type) + guard let savedURL = UserDefaults.standard.string(forKey: key) else { + return false + } + return savedURL == currentURL + } + + /// 캐시 URL 저장 + private func saveCacheURL(id: String, url: String, type: ItemType) { + let key = cacheURLKey(for: id, type: type) + UserDefaults.standard.set(url, forKey: key) + } } diff --git a/Keychy/Keychy/Core/Firebase/StorageManager.swift b/Keychy/Keychy/Core/Firebase/StorageManager.swift index c0ffcdadc..7e2a897b2 100644 --- a/Keychy/Keychy/Core/Firebase/StorageManager.swift +++ b/Keychy/Keychy/Core/Firebase/StorageManager.swift @@ -144,23 +144,39 @@ class StorageManager { let bodyImagesPath = "Keyrings/BodyImages/\(uid)" let customSoundsPath = "Keyrings/CustomSounds/\(uid)" - try await deleteFolder(path: bodyImagesPath) - try await deleteFolder(path: customSoundsPath) + // 두 폴더 병렬 삭제 + try await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { try await self.deleteFolder(path: bodyImagesPath) } + group.addTask { try await self.deleteFolder(path: customSoundsPath) } + try await group.waitForAll() + } } - /// 폴더 삭제 (모든 하위 파일 삭제) + /// 폴더 삭제 (모든 하위 파일 병렬 삭제) private func deleteFolder(path: String) async throws { let storageRef = Storage.storage().reference().child(path) do { let result = try await storageRef.listAll() - for item in result.items { - try await item.delete() + // 파일들 병렬 삭제 + try await withThrowingTaskGroup(of: Void.self) { group in + for item in result.items { + group.addTask { + try await item.delete() + } + } + try await group.waitForAll() } - for prefix in result.prefixes { - try await deleteFolder(path: prefix.fullPath) + // 하위 폴더들 병렬 삭제 + try await withThrowingTaskGroup(of: Void.self) { group in + for prefix in result.prefixes { + group.addTask { + try await self.deleteFolder(path: prefix.fullPath) + } + } + try await group.waitForAll() } print("Storage 폴더 삭제 완료: \(path)") diff --git a/Keychy/Keychy/Presentation/Intro/ViewModels/IntroViewModel+WelcomeKeyring.swift b/Keychy/Keychy/Presentation/Intro/ViewModels/IntroViewModel+WelcomeKeyring.swift index 57729e2ed..25ec09f34 100644 --- a/Keychy/Keychy/Presentation/Intro/ViewModels/IntroViewModel+WelcomeKeyring.swift +++ b/Keychy/Keychy/Presentation/Intro/ViewModels/IntroViewModel+WelcomeKeyring.swift @@ -27,6 +27,9 @@ extension IntroViewModel { // 웰컴 카라비너를 사용자의 carabiners 필드에 추가 try await addWelcomeCarabinerToUser(uid: uid) + // Confetti 파티클 캐시 검증 및 다운로드 + await EffectSyncManager.shared.syncKeyringEffects(soundId: nil, particleId: "Confetti") + Task.detached { await self.cacheWelcomeKeyring( keyringId: keyringId, diff --git a/Keychy/Keychy/Presentation/Intro/ViewModels/WelcomeKeyringViewModel.swift b/Keychy/Keychy/Presentation/Intro/ViewModels/WelcomeKeyringViewModel.swift index ae1ec8c5b..600dc35b8 100644 --- a/Keychy/Keychy/Presentation/Intro/ViewModels/WelcomeKeyringViewModel.swift +++ b/Keychy/Keychy/Presentation/Intro/ViewModels/WelcomeKeyringViewModel.swift @@ -58,13 +58,21 @@ class WelcomeKeyringViewModel: KeyringViewModelProtocol { func fetchEffects() async {} func isOwned(soundId: String) -> Bool { false } - func isOwned(particleId: String) -> Bool { particleId == "Confetti" } + func isOwned(particleId: String) -> Bool { false } - func isInBundle(soundId: String) -> Bool { false } - func isInBundle(particleId: String) -> Bool { particleId == "Confetti" } + func isInBundle(soundId: String) -> Bool { + EffectManager.shared.isInBundle(soundId: soundId) + } + func isInBundle(particleId: String) -> Bool { + EffectManager.shared.isInBundle(particleId: particleId) + } - func isInCache(soundId: String) -> Bool { false } - func isInCache(particleId: String) -> Bool { particleId == "Confetti" } + func isInCache(soundId: String) -> Bool { + EffectManager.shared.isInCache(soundId: soundId) + } + func isInCache(particleId: String) -> Bool { + EffectManager.shared.isInCache(particleId: particleId) + } func downloadSound(_ sound: Sound) async {} func downloadParticle(_ particle: Particle) async {} diff --git a/Keychy/Keychy/Presentation/KeyringMaker/Shared/Views/EffectSelectorView.swift b/Keychy/Keychy/Presentation/KeyringMaker/Shared/Views/EffectSelectorView.swift index 615e389fc..1397f4a72 100644 --- a/Keychy/Keychy/Presentation/KeyringMaker/Shared/Views/EffectSelectorView.swift +++ b/Keychy/Keychy/Presentation/KeyringMaker/Shared/Views/EffectSelectorView.swift @@ -222,40 +222,23 @@ struct EffectSelectorView: View { // 장바구니에서 사운드 타입 제거 (무료로 교체) cartItems.removeAll { $0.type == .sound } } - // 케이스 2: 구매 + 캐시 있음 → 바로 사용 - else if isOwned && isInCache { - viewModel.updateSound(sound) - // 장바구니에서 사운드 타입 제거 (보유템으로 교체) - cartItems.removeAll { $0.type == .sound } - } - // 케이스 3: 구매 + 캐시 없음 → 재다운로드 - else if isOwned && !isInCache { + // 케이스 2: 구매 → 캐시 검증 후 사용/재다운로드 + else if isOwned { Task { await viewModel.downloadSound(sound) } // 장바구니에서 사운드 타입 제거 (보유템으로 교체) cartItems.removeAll { $0.type == .sound } } - // 케이스 4: 미구매 + 무료 + 캐시 있음 → 바로 사용 - else if !isOwned && sound.isFree && isInCache { - viewModel.updateSound(sound) - // 장바구니에서 사운드 타입 제거 (무료로 교체) - cartItems.removeAll { $0.type == .sound } - } - // 케이스 5: 미구매 + 무료 + 캐시 없음 → 다운로드 - else if !isOwned && sound.isFree && !isInCache { + // 케이스 3: 미구매 + 무료 → 캐시 검증 후 사용/재다운로드 + else if !isOwned && sound.isFree { Task { await viewModel.downloadSound(sound) } // 장바구니에서 사운드 타입 제거 (무료로 교체) cartItems.removeAll { $0.type == .sound } } - // 케이스 6: 미구매 + 유료 + 캐시 있음 → 다운로드 + 장바구니 추가 - else if !isOwned && !sound.isFree && isInCache { - viewModel.updateSound(sound) - addSoundToCart(sound) - } - // 케이스 7: 미구매 + 유료 + 캐시 없음 → 다운로드 + 장바구니 추가 + // 케이스 4: 미구매 + 유료 → 캐시 검증 후 다운로드 + 장바구니 추가 else { Task { await viewModel.downloadSound(sound) @@ -370,40 +353,23 @@ struct EffectSelectorView: View { // 장바구니에서 파티클 타입 제거 (무료로 교체) cartItems.removeAll { $0.type == .particle } } - // 케이스 2: 구매 + 캐시 있음 → 바로 사용 - else if isOwned && isInCache { - viewModel.updateParticle(particle) - // 장바구니에서 파티클 타입 제거 (보유템으로 교체) - cartItems.removeAll { $0.type == .particle } - } - // 케이스 3: 구매 + 캐시 없음 → 재다운로드 - else if isOwned && !isInCache { + // 케이스 2: 구매 → 캐시 검증 후 사용/재다운로드 + else if isOwned { Task { await viewModel.downloadParticle(particle) } // 장바구니에서 파티클 타입 제거 (보유템으로 교체) cartItems.removeAll { $0.type == .particle } } - // 케이스 4: 미구매 + 무료 + 캐시 있음 → 바로 사용 - else if !isOwned && particle.isFree && isInCache { - viewModel.updateParticle(particle) - // 장바구니에서 파티클 타입 제거 (무료로 교체) - cartItems.removeAll { $0.type == .particle } - } - // 케이스 5: 미구매 + 무료 + 캐시 없음 → 다운로드 - else if !isOwned && particle.isFree && !isInCache { + // 케이스 3: 미구매 + 무료 → 캐시 검증 후 사용/재다운로드 + else if !isOwned && particle.isFree { Task { await viewModel.downloadParticle(particle) } // 장바구니에서 파티클 타입 제거 (무료로 교체) cartItems.removeAll { $0.type == .particle } } - // 케이스 6: 미구매 + 유료 + 캐시 있음 → 다운로드 + 장바구니 추가 - else if !isOwned && !particle.isFree && isInCache { - viewModel.updateParticle(particle) - addParticleToCart(particle) - } - // 케이스 7: 미구매 + 유료 + 캐시 없음 → 다운로드 + 장바구니 추가 + // 케이스 4: 미구매 + 유료 → 캐시 검증 후 다운로드 + 장바구니 추가 else { Task { await viewModel.downloadParticle(particle)