Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -139,6 +143,5 @@ struct BundleSwitchButton: View {
}
}
.buttonStyle(.plain)
.disabled(!isEnabled)
}
}
45 changes: 45 additions & 0 deletions Keychy/Keychy/Core/FileManagers/EffectManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import Foundation
import FirebaseStorage
import FirebaseFirestore
import AVFoundation
import Lottie

/// 이펙트(사운드, 파티클) 다운로드 및 재생을 관리하는 매니저
@MainActor
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 }
Expand Down
88 changes: 60 additions & 28 deletions Keychy/Keychy/Core/Firebase/EffectSyncManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import Foundation
import FirebaseFirestore
import FirebaseStorage
import Lottie

@Observable
class EffectSyncManager {
Expand Down Expand Up @@ -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)
}
}
}
Expand All @@ -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)
}
}
}
Expand All @@ -218,7 +213,7 @@ class EffectSyncManager {
}
}

/// 사운드 다운로드 (캐시에 없을 때만)
/// 사운드 다운로드 (캐시에 없거나 URL이 변경된 경우)
private func downloadSoundIfNeeded(soundId: String) async {
// soundId가 URL 형태인지 확인 (커스텀 사운드)
if soundId.hasPrefix("http://") || soundId.hasPrefix("https://") {
Expand All @@ -228,13 +223,6 @@ class EffectSyncManager {

// 번들에 있으면 스킵 (무료 이펙트)
if isInBundle(soundId: soundId) {
print("[EffectSync] 사운드 번들에 있음 (스킵): \(soundId)")
return
}

// 캐시에 있으면 스킵
guard !isInCache(soundId: soundId) else {
print("[EffectSync] 사운드 이미 캐시에 있음: \(soundId)")
return
}

Expand All @@ -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")

Expand All @@ -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 {
Expand Down Expand Up @@ -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
}

Expand All @@ -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")

Expand All @@ -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 {
Expand Down Expand Up @@ -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)
}
}
30 changes: 23 additions & 7 deletions Keychy/Keychy/Core/Firebase/StorageManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
Expand Down
Loading