diff --git a/Animal-Crossing-Wiki/Projects/App/Resources/en.lproj/Localizable.strings b/Animal-Crossing-Wiki/Projects/App/Resources/en.lproj/Localizable.strings index 69538471..de6331dc 100644 --- a/Animal-Crossing-Wiki/Projects/App/Resources/en.lproj/Localizable.strings +++ b/Animal-Crossing-Wiki/Projects/App/Resources/en.lproj/Localizable.strings @@ -80,6 +80,7 @@ Find the villagers you have visited and tap the home icon on the villager's page "Restore complete" = "Restore complete"; "Restored %d items" = "Restored %d items."; "Restore failed" = "Restore failed"; +"Unknown error" = "Unknown error"; "No local backup found" = "No local backup found."; "Local backup info" = "Last local backup"; "Local backup info format" = "%@ · %d items"; diff --git a/Animal-Crossing-Wiki/Projects/App/Resources/ko.lproj/Localizable.strings b/Animal-Crossing-Wiki/Projects/App/Resources/ko.lproj/Localizable.strings index d46112fd..712534cf 100644 --- a/Animal-Crossing-Wiki/Projects/App/Resources/ko.lproj/Localizable.strings +++ b/Animal-Crossing-Wiki/Projects/App/Resources/ko.lproj/Localizable.strings @@ -82,6 +82,7 @@ "Restore complete" = "복원 완료"; "Restored %d items" = "%d개 항목을 복원했습니다."; "Restore failed" = "복원 실패"; +"Unknown error" = "알 수 없는 오류"; "No local backup found" = "저장된 로컬 백업이 없습니다."; "Local backup info" = "마지막 로컬 백업"; "Local backup info format" = "%@ · 항목 %d개"; diff --git a/Animal-Crossing-Wiki/Projects/App/Sources/AppCoordinator.swift b/Animal-Crossing-Wiki/Projects/App/Sources/AppCoordinator.swift index 66e0668e..eb6b4d61 100644 --- a/Animal-Crossing-Wiki/Projects/App/Sources/AppCoordinator.swift +++ b/Animal-Crossing-Wiki/Projects/App/Sources/AppCoordinator.swift @@ -40,6 +40,7 @@ final class AppCoordinator: Coordinator { let animalsCoordinator = AnimalsCoordinator() animalsCoordinator.start() + animalsCoordinator.setUpParent(to: self) addViewController(animalsCoordinator.rootViewController, title: "animals".localized, icon: "icon-book-tabbar") childCoordinators.append(animalsCoordinator) diff --git a/Animal-Crossing-Wiki/Projects/App/Sources/CoreDataStorage/DailyTaskStorage/CoreDataDailyTaskStorage.swift b/Animal-Crossing-Wiki/Projects/App/Sources/CoreDataStorage/DailyTaskStorage/CoreDataDailyTaskStorage.swift index e970ee62..8b3d2cdd 100644 --- a/Animal-Crossing-Wiki/Projects/App/Sources/CoreDataStorage/DailyTaskStorage/CoreDataDailyTaskStorage.swift +++ b/Animal-Crossing-Wiki/Projects/App/Sources/CoreDataStorage/DailyTaskStorage/CoreDataDailyTaskStorage.swift @@ -90,6 +90,13 @@ final class CoreDataDailyTaskStorage: DailyTaskStorage { let itemEntities = object.dailyTasks?.allObjects as? [DailyTaskEntity] ?? [] if let index = itemEntities.firstIndex(where: { $0.id == task.id }) { var progressList = (itemEntities[index].progressList as? [Bool]) ?? [] + + // CloudKit 부분 import 후 저장된 progressList가 짧을 수 있음 — 길이를 보정해 + // index out of range 크래시를 방지하고 손상된 데이터를 자가 치유한다. + if progressList.count <= progressIndex { + let padding = progressIndex + 1 - progressList.count + progressList.append(contentsOf: Array(repeating: false, count: padding)) + } progressList[progressIndex] = !progressList[progressIndex] itemEntities[index].progressList = progressList as NSArray } diff --git a/Animal-Crossing-Wiki/Projects/App/Sources/CoreDataStorage/DailyTaskStorage/EntityMapping/DailyTaskEntity+Mapping.swift b/Animal-Crossing-Wiki/Projects/App/Sources/CoreDataStorage/DailyTaskStorage/EntityMapping/DailyTaskEntity+Mapping.swift index 647a1519..2108f228 100644 --- a/Animal-Crossing-Wiki/Projects/App/Sources/CoreDataStorage/DailyTaskStorage/EntityMapping/DailyTaskEntity+Mapping.swift +++ b/Animal-Crossing-Wiki/Projects/App/Sources/CoreDataStorage/DailyTaskStorage/EntityMapping/DailyTaskEntity+Mapping.swift @@ -21,12 +21,22 @@ extension DailyTaskEntity { } func toDomain() -> DailyTask { + let amount = Int(self.amount) + + // CloudKit 부분 import/sync reset 후 progressList가 nil이거나 amount보다 짧을 수 있음. + // UI는 amount 기준으로 체크박스를 만들기 때문에 길이를 amount에 맞춰 정규화해 + // index out of range 크래시를 방지한다. + var progressList = (self.progressList as? [Bool]) ?? [] + if progressList.count < amount { + progressList.append(contentsOf: Array(repeating: false, count: amount - progressList.count)) + } + return DailyTask( id: self.id ?? UUID(), name: self.name ?? "", icon: self.icon ?? "", - progressList: (self.progressList as? [Bool]) ?? [], - amount: Int(self.amount), + progressList: progressList, + amount: amount, createdDate: self.date ?? Date() ) } diff --git a/Animal-Crossing-Wiki/Projects/App/Sources/CoreDataStorage/SafetySnapshot/SafetySnapshotService.swift b/Animal-Crossing-Wiki/Projects/App/Sources/CoreDataStorage/SafetySnapshot/SafetySnapshotService.swift index 582b9aba..0d7734a7 100644 --- a/Animal-Crossing-Wiki/Projects/App/Sources/CoreDataStorage/SafetySnapshot/SafetySnapshotService.swift +++ b/Animal-Crossing-Wiki/Projects/App/Sources/CoreDataStorage/SafetySnapshot/SafetySnapshotService.swift @@ -47,7 +47,9 @@ final class SafetySnapshotService { private static let debounceSeconds: TimeInterval = 30 private let queue = DispatchQueue(label: "app.safety.snapshot", qos: .utility) - private var pendingWorkItem: DispatchWorkItem? + + /// CoreData 저장 알림(임의 스레드)·main 스레드·sync-reset 알림이 동시에 접근하므로 lock으로 보호 + private let _pendingWorkItem = OSAllocatedUnfairLock(initialState: nil) private var observers: [NSObjectProtocol] = [] private let containerProvider: () -> NSPersistentContainer private let snapshotDirectoryProvider: () -> URL? @@ -131,24 +133,33 @@ final class SafetySnapshotService { NotificationCenter.default.removeObserver(observer) } observers.removeAll() - pendingWorkItem?.cancel() - pendingWorkItem = nil + _pendingWorkItem.withLock { item in + item?.cancel() + item = nil + } } private func scheduleSnapshot() { - pendingWorkItem?.cancel() let workItem = DispatchWorkItem { [weak self] in self?.writeSnapshotNow() } - pendingWorkItem = workItem + _pendingWorkItem.withLock { item in + item?.cancel() + item = workItem + } queue.asyncAfter(deadline: .now() + Self.debounceSeconds, execute: workItem) } /// 강제 저장 — 앱 종료 직전/sync-reset 직전 등에서 flushing 용도. + /// debounce 중인 쓰기와 파일 I/O가 겹치지 않도록 queue에서 동기 실행한다. func flushNow() { - pendingWorkItem?.cancel() - pendingWorkItem = nil - writeSnapshotNow() + _pendingWorkItem.withLock { item in + item?.cancel() + item = nil + } + queue.sync { [weak self] in + self?.writeSnapshotNow() + } } private func writeSnapshotNow() { diff --git a/Animal-Crossing-Wiki/Projects/App/Sources/CoreDataStorage/VillagersLikeStorage/EntityMapping/NPCLikeEntity+Mapping.swift b/Animal-Crossing-Wiki/Projects/App/Sources/CoreDataStorage/VillagersLikeStorage/EntityMapping/NPCLikeEntity+Mapping.swift index f4a40e11..81d92b9d 100644 --- a/Animal-Crossing-Wiki/Projects/App/Sources/CoreDataStorage/VillagersLikeStorage/EntityMapping/NPCLikeEntity+Mapping.swift +++ b/Animal-Crossing-Wiki/Projects/App/Sources/CoreDataStorage/VillagersLikeStorage/EntityMapping/NPCLikeEntity+Mapping.swift @@ -31,7 +31,7 @@ extension NPCLikeEntity { iconImage: self.iconImage ?? "", photoImage: self.photoImage ?? "", gender: Gender(rawValue: self.gender ?? "") ?? .male, - genderAsia: Gender(rawValue: self.gender ?? "") ?? .male, + genderAsia: Gender(rawValue: self.genderAsia ?? "") ?? .male, species: self.species ?? "", birthday: self.birthday ?? "", appearanceLocation: appearanceLocation ?? [], diff --git a/Animal-Crossing-Wiki/Projects/App/Sources/Models/DailyTask.swift b/Animal-Crossing-Wiki/Projects/App/Sources/Models/DailyTask.swift index 7d5ec9fe..fe6d9458 100644 --- a/Animal-Crossing-Wiki/Projects/App/Sources/Models/DailyTask.swift +++ b/Animal-Crossing-Wiki/Projects/App/Sources/Models/DailyTask.swift @@ -16,6 +16,10 @@ struct DailyTask { let createdDate: Date mutating func toggleCompleted(_ index: Int) { + // 동기화로 progressList가 amount보다 짧아질 수 있음 — 길이를 보정해 크래시 방지 + if progressList.count <= index { + progressList.append(contentsOf: Array(repeating: false, count: index + 1 - progressList.count)) + } self.progressList[index].toggle() } diff --git a/Animal-Crossing-Wiki/Projects/App/Sources/Presentation/Animals/ViewModels/NPCCellReactor.swift b/Animal-Crossing-Wiki/Projects/App/Sources/Presentation/Animals/ViewModels/NPCCellReactor.swift index 13b4e559..44aa7f22 100644 --- a/Animal-Crossing-Wiki/Projects/App/Sources/Presentation/Animals/ViewModels/NPCCellReactor.swift +++ b/Animal-Crossing-Wiki/Projects/App/Sources/Presentation/Animals/ViewModels/NPCCellReactor.swift @@ -63,6 +63,7 @@ final class NPCCellReactor: Reactor { HapticManager.shared.impact(style: .medium) Items.shared.updateNPCLike(npc) likeStorage.update(npc) + newState.isLiked = !(newState.isLiked ?? false) } return newState } diff --git a/Animal-Crossing-Wiki/Projects/App/Sources/Presentation/Animals/ViewModels/VillagersCellReactor.swift b/Animal-Crossing-Wiki/Projects/App/Sources/Presentation/Animals/ViewModels/VillagersCellReactor.swift index 4d0b2707..dcf2fcb8 100644 --- a/Animal-Crossing-Wiki/Projects/App/Sources/Presentation/Animals/ViewModels/VillagersCellReactor.swift +++ b/Animal-Crossing-Wiki/Projects/App/Sources/Presentation/Animals/ViewModels/VillagersCellReactor.swift @@ -84,11 +84,13 @@ final class VillagersCellReactor: Reactor { HapticManager.shared.impact(style: .medium) Items.shared.updateVillagerHouse(villager) houseStorage.update(villager) + newState.isResident = !(newState.isResident ?? false) case .updateLike: HapticManager.shared.impact(style: .medium) Items.shared.updateVillagerLike(villager) likeStorage.update(villager) + newState.isLiked = !(newState.isLiked ?? false) } return newState } diff --git a/Animal-Crossing-Wiki/Projects/App/Sources/Utility/Items.swift b/Animal-Crossing-Wiki/Projects/App/Sources/Utility/Items.swift index 4c7c5ff0..0ea8cd5e 100644 --- a/Animal-Crossing-Wiki/Projects/App/Sources/Utility/Items.swift +++ b/Animal-Crossing-Wiki/Projects/App/Sources/Utility/Items.swift @@ -499,6 +499,7 @@ extension Items { func reset() { villagersLike.accept([]) villagersHouse.accept([]) + npcLike.accept([]) let currentItems = userItems.value.map { $0.key } var resetItem = userItems.value currentItems.forEach { category in diff --git a/Animal-Crossing-Wiki/Projects/App/Sources/Utility/MusicPlayerManager.swift b/Animal-Crossing-Wiki/Projects/App/Sources/Utility/MusicPlayerManager.swift index b8b0ca06..0eef20df 100644 --- a/Animal-Crossing-Wiki/Projects/App/Sources/Utility/MusicPlayerManager.swift +++ b/Animal-Crossing-Wiki/Projects/App/Sources/Utility/MusicPlayerManager.swift @@ -23,8 +23,10 @@ final class MusicPlayerManager { static let shared = MusicPlayerManager() private let disposeBag = DisposeBag() + private var backgroundDisposeBag = DisposeBag() private var player: AVPlayer? private var timer: Timer? + private var endPlaybackObserver: NSObjectProtocol? private let isPlaying = BehaviorRelay(value: nil) private let currentSong = BehaviorRelay(value: nil) @@ -54,6 +56,9 @@ final class MusicPlayerManager { .compactMap { URL(string: $0) } .subscribe(with: self, onNext: { owner, musicURL in owner.player = AVPlayer(url: musicURL) + // 곡이 바뀔 때마다(player 재생성) 종료 알림 옵저버를 재등록해 + // 자동 다음 곡/반복 재생이 끊기지 않도록 보장한다. + owner.setUpNotification() }).disposed(by: disposeBag) currentSong @@ -144,16 +149,25 @@ final class MusicPlayerManager { } private func setUpNotification() { - NotificationCenter.default.removeObserver(self, name: .AVPlayerItemDidPlayToEndTime, object: nil) - - NotificationCenter.default.addObserver( + // block 기반 옵저버는 removeObserver(self,...)로 제거되지 않으므로 토큰을 보관해 제거한다. + // object를 특정 AVPlayerItem에 바인딩하면 곡 전환(changeSong) 후 알림을 못 받으므로 + // object: nil로 등록하고 핸들러에서 현재 재생 item인지 확인한다. + if let endPlaybackObserver { + NotificationCenter.default.removeObserver(endPlaybackObserver) + } + + endPlaybackObserver = NotificationCenter.default.addObserver( forName: .AVPlayerItemDidPlayToEndTime, - object: player?.currentItem, + object: nil, queue: .main - ) { [weak self] _ in + ) { [weak self] notification in guard let owner = self else { return } + guard let endedItem = notification.object as? AVPlayerItem, + endedItem === owner.player?.currentItem else { + return + } owner.playerProgress.accept(0) owner.elapsedTime.accept("0:00") owner.isPlaying.accept(false) @@ -180,6 +194,9 @@ final class MusicPlayerManager { playingInfo[MPMediaItemPropertyAlbumTitle] = "K.K. Slider" playingInfo[MPMediaItemPropertyTitle] = currentSong.translations.localizedName() + // 곡 전환 시 이전 곡의 아트워크 다운로드를 취소해, 늦게 도착한 이미지가 + // 잠금화면 now playing 정보를 덮어쓰는 것을 방지한다. + backgroundDisposeBag = DisposeBag() UIImage.downloadImage(urlString: currentSong.image ?? "") .compactMap { $0 } .subscribe(onNext: { image in @@ -189,7 +206,7 @@ final class MusicPlayerManager { } ) MPNowPlayingInfoCenter.default().nowPlayingInfo = playingInfo - }).disposed(by: disposeBag) + }).disposed(by: backgroundDisposeBag) } } @@ -295,7 +312,6 @@ extension MusicPlayerManager { close() currentSong.accept(item) isPlaying.accept(true) - setUpNotification() } func close() { @@ -309,9 +325,12 @@ extension MusicPlayerManager { elapsedTime.accept("0:00") durationTime.accept("0:00") playerProgress.accept(0) - - NotificationCenter.default.removeObserver(self, name: .AVPlayerItemDidPlayToEndTime, object: nil) - + + if let endPlaybackObserver { + NotificationCenter.default.removeObserver(endPlaybackObserver) + self.endPlaybackObserver = nil + } + try? AVAudioSession.sharedInstance().setActive(false, options: []) } } diff --git a/Animal-Crossing-Wiki/Projects/App/Tests/CoreDataStorage/CoreDataStorageICloudResetTests.swift b/Animal-Crossing-Wiki/Projects/App/Tests/CoreDataStorage/CoreDataStorageICloudResetTests.swift index fbdf71d3..b7882932 100644 --- a/Animal-Crossing-Wiki/Projects/App/Tests/CoreDataStorage/CoreDataStorageICloudResetTests.swift +++ b/Animal-Crossing-Wiki/Projects/App/Tests/CoreDataStorage/CoreDataStorageICloudResetTests.swift @@ -324,6 +324,56 @@ final class CoreDataStorageICloudResetTests: XCTestCase { XCTAssertTrue(storage.isWithinRecoveryGracePeriod) } + func testCorruptedProgressListNormalizedOnToDomain() throws { + let storage = try makeStorage() + let context = storage.persistentContainer.viewContext + + let userCollection = try insertUserCollection(in: context) + let taskEntity = try insertDailyTask(in: context, linkedTo: userCollection) + taskEntity.amount = 6 + taskEntity.progressList = [] as NSArray // CloudKit 부분 import로 손상된 상태 + try context.save() + + let domain = taskEntity.toDomain() + + XCTAssertEqual(domain.progressList.count, 6) + XCTAssertEqual(domain.progressList, Array(repeating: false, count: 6)) + } + + func testToggleCompletedOnCorruptedProgressListDoesNotCrashAndHeals() throws { + let storage = try makeStorage() + let context = storage.persistentContainer.viewContext + + let userCollection = try insertUserCollection(in: context) + let taskEntity = try insertDailyTask(in: context, linkedTo: userCollection) + taskEntity.amount = 6 + taskEntity.progressList = [] as NSArray + try context.save() + + var domainTask = taskEntity.toDomain() + + // 모델 toggle — 손상 길이에서도 크래시 없이 보정 후 토글 + domainTask.toggleCompleted(5) + XCTAssertEqual(domainTask.progressList.count, 6) + XCTAssertTrue(domainTask.progressList[5]) + + // 저장소 toggle — background context에서 보정 후 저장되는지 폴링으로 확인 + let taskStorage = CoreDataDailyTaskStorage(coreDataStorage: storage) + taskStorage.toggleCompleted(domainTask, progressIndex: 5) + + let deadline = Date().addingTimeInterval(5) + var healed = false + while !healed && Date() < deadline { + context.refreshAllObjects() + if let stored = taskEntity.progressList as? [Bool], stored.count == 6, stored[5] { + healed = true + } else { + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + } + } + XCTAssertTrue(healed, "storage toggleCompleted should heal corrupted progressList without crashing") + } + func testSyncProtectionStateDetectsSuppressedEmptyCollection() throws { let storage = try makeStorage() let context = storage.persistentContainer.viewContext diff --git a/docs/features/dashboard.md b/docs/features/dashboard.md index 10051c5d..9ad478fd 100644 --- a/docs/features/dashboard.md +++ b/docs/features/dashboard.md @@ -84,6 +84,10 @@ viewController.setUpViewModels( ## Special Patterns +- **DailyTask progressList 정규화**: CloudKit 부분 import/sync reset 후 `DailyTaskEntity.progressList`가 + nil이거나 `amount`보다 짧을 수 있다. UI는 `amount` 기준으로 체크박스를 만들기 때문에 + `toDomain()`에서 길이를 `amount`로 보정하고, `toggleCompleted`(모델·storage 양쪽)도 + 보정 후 토글한다 — index out of range 크래시 방지 + 손상 데이터 자가 치유 - **CustomTaskViewControllerDelegate**: DashboardCoordinator가 IconChooser → CustomTaskVC 간 아이콘 선택 결과를 전달하는 delegate - **showAlert()**: DashboardCoordinator의 `showAlert(title:message:) -> Observable` 메서드로 확인 다이얼로그 표시 - **showRecoveryResultAlert()**: (TEMPORARY) iCloud 데이터 복구 결과 알림. 성공 시 앱 종료 유도 diff --git a/docs/features/icloud-sync.md b/docs/features/icloud-sync.md index bed52d6e..ff1a0e98 100644 --- a/docs/features/icloud-sync.md +++ b/docs/features/icloud-sync.md @@ -273,6 +273,9 @@ CloudKit import, remote change, sync reset 직전에는 최신 로컬 상태를 - 스냅샷 파일은 첫 잠금 해제 후 백그라운드 CloudKit flush에서도 갱신될 수 있도록 `completeUntilFirstUserAuthentication` 보호 등급으로 저장한다. - 파일은 기기/iCloud 백업에서 제외하여 Core Data 원본과 별도로 장기 보관되지 않게 한다. - 복원은 `wipeExistingCollection → snapshot.apply → context.save()`를 단일 context rollback 경계에 묶는다. 중간 실패 시 기존 로컬 컬렉션 삭제가 저장되지 않는다. +- debounce용 `pendingWorkItem`은 CoreData 저장 알림(임의 스레드)·main 스레드·sync-reset 알림이 + 동시에 접근하므로 `OSAllocatedUnfairLock`으로 보호하고, `flushNow()`는 debounce 쓰기와 + 파일 I/O가 겹치지 않도록 전용 queue에서 동기 실행한다. ## Manual Consolidation (중복/고아 데이터 정리) diff --git a/docs/features/music-player.md b/docs/features/music-player.md index dbd49bde..bd7a20d9 100644 --- a/docs/features/music-player.md +++ b/docs/features/music-player.md @@ -49,6 +49,12 @@ topAnchor constraint 조절 + spring animation으로 minimize/maximize 전환. - `MPRemoteCommandCenter` 연동 (잠금화면 컨트롤) - 재생 모드: shuffle, fullRepeat, oneSongRepeat +**곡 종료 알림 (`AVPlayerItemDidPlayToEndTime`) 처리 규칙**: +- block 기반 옵저버는 `removeObserver(self,...)`로 제거되지 않으므로 **토큰**(`endPlaybackObserver`)을 보관해 제거한다 (누적 등록 시 자동 다음 곡이 여러 번 실행되는 버그 방지) +- `object: nil`로 등록하고 핸들러에서 `notification.object === player?.currentItem`을 확인한다 — 특정 `AVPlayerItem`에 바인딩하면 곡 전환 후 알림을 받지 못함 +- 등록 지점은 한 곳: `currentSong` 구독에서 `AVPlayer` 재생성 직후 `setUpNotification()` 호출 +- 잠금화면 아트워크 다운로드는 `backgroundDisposeBag`(곡마다 재생성)에 담아, 이전 곡 이미지가 늦게 도착해 now playing 정보를 덮어쓰는 것을 방지 + ## 데이터 흐름 ```