From c7e78117c23ca0981406fbe2f758cd3046da40bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=B1=EC=A2=85=EC=97=B4?= Date: Thu, 4 Jun 2026 02:38:53 +0900 Subject: [PATCH 1/8] Fix iCloud sync reset guard --- .swiftlint.yml | 2 +- .../Configurations/Base.xcconfig | 1 - .../Projects/App/Project.swift | 15 +- .../CoreDataStorage/CoreDataStorage.swift | 96 ++++++-- .../Sources/Extension/String+extension.swift | 2 +- .../Projects/App/Sources/SceneDelegate.swift | 28 ++- .../Projects/App/Sources/Utility/Log.swift | 37 ++- .../CoreDataStorageICloudResetTests.swift | 230 ++++++++++++++++++ Tuist/Package.swift | 7 +- docs/features/icloud-sync.md | 38 +-- docs/gotchas.md | 9 +- docs/guides/build-and-run.md | 18 +- docs/iCloud-data-loss-fix-summary.md | 11 +- 13 files changed, 418 insertions(+), 76 deletions(-) create mode 100644 Animal-Crossing-Wiki/Projects/App/Tests/CoreDataStorage/CoreDataStorageICloudResetTests.swift diff --git a/.swiftlint.yml b/.swiftlint.yml index 0507e148..6b7354d7 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -8,11 +8,11 @@ excluded: # 린트 과정에서 무시할 파일 경로. `included`보다 우선 - Tuist - Workspace.swift - Animal-Crossing-Wiki/Projects/*/Project.swift + - Animal-Crossing-Wiki/Projects/App/build identifier_name: min_length: - 2 # only min_length - error: 4 # only error excluded: # 제외할 문자열 목록 사용 - a - b diff --git a/Animal-Crossing-Wiki/Configurations/Base.xcconfig b/Animal-Crossing-Wiki/Configurations/Base.xcconfig index d1889fe6..44d21b41 100644 --- a/Animal-Crossing-Wiki/Configurations/Base.xcconfig +++ b/Animal-Crossing-Wiki/Configurations/Base.xcconfig @@ -4,7 +4,6 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor CODE_SIGN_IDENTITY = iPhone Developer ENABLE_PREVIEWS = YES -EXCLUDED_ARCHS[sdk=iphonesimulator*] = arm64 FRAMEWORK_SEARCH_PATHS = $(inherited) GCC_GENERATE_DEBUGGING_SYMBOLS = YES HEADER_SEARCH_PATHS = $(inherited) diff --git a/Animal-Crossing-Wiki/Projects/App/Project.swift b/Animal-Crossing-Wiki/Projects/App/Project.swift index d327d023..d40a4a9a 100644 --- a/Animal-Crossing-Wiki/Projects/App/Project.swift +++ b/Animal-Crossing-Wiki/Projects/App/Project.swift @@ -53,7 +53,7 @@ let schemes: [Scheme] = [ name: "ACNH-wiki", shared: true, buildAction: .buildAction(targets: ["ACNH-wiki"]), - testAction: nil, + testAction: .targets(["ACNH-wikiTests"]), runAction: .runAction( configuration: .debug, executable: "ACNH-wiki", @@ -93,6 +93,19 @@ let project = Project( coreDataModels: [ CoreDataModel.coreDataModel("CoreDataModels/CoreDataStorage.xcdatamodeld") ] + ), + .target( + name: "ACNH-wikiTests", + destinations: .iOS, + product: .unitTests, + bundleId: "leeari.NookPortalPlusTests", + deploymentTargets: .iOS("16.0"), + infoPlist: .default, + sources: ["Tests/**"], + dependencies: [ + .target(name: "ACNH-wiki") + ], + settings: settings ) ], schemes: schemes, diff --git a/Animal-Crossing-Wiki/Projects/App/Sources/CoreDataStorage/CoreDataStorage.swift b/Animal-Crossing-Wiki/Projects/App/Sources/CoreDataStorage/CoreDataStorage.swift index 88da826c..028df13c 100644 --- a/Animal-Crossing-Wiki/Projects/App/Sources/CoreDataStorage/CoreDataStorage.swift +++ b/Animal-Crossing-Wiki/Projects/App/Sources/CoreDataStorage/CoreDataStorage.swift @@ -64,6 +64,14 @@ final class CoreDataStorage { set { _isSyncResetInProgress.withLock { $0 = newValue } } } + /// Fresh install에서 CloudKit 첫 import 대기가 timeout된 상태. + /// timeout은 "원격 데이터 없음"이 아니라 "아직 모름"이므로 빈 UC 생성을 막는다. + private let _isFirstImportTimedOut = OSAllocatedUnfairLock(initialState: false) + private(set) var isFirstImportTimedOut: Bool { + get { _isFirstImportTimedOut.withLock { $0 } } + set { _isFirstImportTimedOut.withLock { $0 = newValue } } + } + /// 첫 번째 Import 완료 시점 — grace period 계산에 사용 private let _firstImportCompletedAt = OSAllocatedUnfairLock(initialState: nil) @@ -115,8 +123,8 @@ final class CoreDataStorage { private static let recoveryInitiatedAtKey = "CoreDataStorage_recoveryInitiatedAt" - /// performCloudKitRecovery 후 재시작했는데 import가 지연되는 동안 - /// getUserCollection이 .notFound를 영구히 throw하는 것을 막기 위한 유예 시간 (10분). + /// performCloudKitRecovery 후 재시작했는데 import가 지연되는 상황을 + /// UI/로그에서 구분하기 위한 유예 시간 (10분). private static let recoveryGracePeriodSeconds: TimeInterval = 600 /// 복구 시작 시각 기록 — 재시작 후 grace window 계산에 사용 @@ -130,7 +138,7 @@ final class CoreDataStorage { UserDefaults.standard.removeObject(forKey: Self.recoveryInitiatedAtKey) } - /// 복구 시작 후 grace period 내인지 확인 — 이 기간에는 hasEverHadUserCollection 체크를 우회하여 UC 생성을 허용 + /// 복구 시작 후 grace period 내인지 확인 — 상태 표시에만 사용하고, UC 생성 허용에는 사용하지 않는다. var isWithinRecoveryGracePeriod: Bool { let timestamp = UserDefaults.standard.double(forKey: Self.recoveryInitiatedAtKey) guard timestamp > 0 else { return false } @@ -157,7 +165,11 @@ final class CoreDataStorage { /// 주의: hasEverHadUserCollection은 여기에 포함하지 않음 — 그 플래그는 getUserCollection()에서만 사용 /// 여기에 포함하면 기존 유저의 DailyTask 자동 생성이 영구적으로 차단됨 var shouldSuppressDataCreation: Bool { - isWaitingForFirstImport || isImportInProgress || isSyncResetInProgress || isWithinGracePeriod + isWaitingForFirstImport + || isImportInProgress + || isSyncResetInProgress + || isFirstImportTimedOut + || isWithinGracePeriod } // MARK: - Private API Notification Names (fragile) @@ -168,9 +180,17 @@ final class CoreDataStorage { static let didReset = Notification.Name("NSCloudKitMirroringDelegateDidResetSyncNotificationName") } - private init() {} + private let injectedPersistentContainer: NSPersistentCloudKitContainer? + + private init(persistentContainer: NSPersistentCloudKitContainer? = nil) { + self.injectedPersistentContainer = persistentContainer + } lazy var persistentContainer: NSPersistentCloudKitContainer = { + if let injectedPersistentContainer { + return injectedPersistentContainer + } + let container = NSPersistentCloudKitContainer(name: "CoreDataStorage") container.persistentStoreDescriptions.forEach { description in @@ -243,9 +263,29 @@ final class CoreDataStorage { /// Import 대기 플래그 해제 — setupApp() 또는 no-iCloud 경로에서 호출 func clearWaitingForFirstImport() { isWaitingForFirstImport = false + isFirstImportTimedOut = false Log.info("clearWaitingForFirstImport") } + enum FirstImportWaitCompletionReason { + case importArrived + case noICloud + case timeout + } + + /// Fresh install 첫 CloudKit import 대기 종료. + /// timeout은 데이터 없음의 증거가 아니므로 UC 생성을 계속 억제한다. + func completeFirstImportWait(reason: FirstImportWaitCompletionReason) { + isWaitingForFirstImport = false + switch reason { + case .importArrived, .noICloud: + isFirstImportTimedOut = false + case .timeout: + isFirstImportTimedOut = lastSuccessfulImportDate == nil + } + Log.info("completeFirstImportWait reason=\(reason)") + } + // MARK: - Persistent History Cleanup func cleanupPersistentHistory() { @@ -390,6 +430,7 @@ final class CoreDataStorage { // 동기화 성공 시각 기록 (설정 화면 표시용) if event.type == .import { lastSuccessfulImportDate = Date() + isFirstImportTimedOut = false } else if event.type == .export { lastSuccessfulExportDate = Date() } @@ -397,6 +438,7 @@ final class CoreDataStorage { if event.type == .import { isImportInProgress = false isWaitingForFirstImport = false + isFirstImportTimedOut = false isSyncResetInProgress = false _exportRetryCount.withLock { $0 = 0 } @@ -905,15 +947,21 @@ extension CoreDataStorage { // 1. Import 대기 중 (신규 설치 시 CloudKit Import 완료 전) // 2. Import 진행 중 (timeout 후에도 import가 아직 끝나지 않은 경우) // 3. Sync reset 진행 중 (Change Token Expired 후 re-import 대기) - // 4. 첫 Import 완료 후 120초 유예 (relationship 해소 시간 확보) - // 5. 기존 유저 — 이전에 UC가 존재했으므로, CloudKit re-import 대기 필요 - if isWaitingForFirstImport || isImportInProgress || isSyncResetInProgress { - Log.info("getUserCollection: No UC — skipping (waiting=\(isWaitingForFirstImport), importing=\(isImportInProgress), reset=\(isSyncResetInProgress))") + // 4. fresh install 첫 import 대기가 timeout됨 (CloudKit 데이터 유무가 아직 불명확) + // 5. 첫 Import 완료 후 120초 유예 (relationship 해소 시간 확보) + // 6. 기존 유저 — 이전에 UC가 존재했으므로, CloudKit re-import 대기 필요 + if isWaitingForFirstImport || isImportInProgress || isSyncResetInProgress || isFirstImportTimedOut { + Log.info( + "getUserCollection: No UC — skipping " + + "(waiting=\(isWaitingForFirstImport), importing=\(isImportInProgress), " + + "reset=\(isSyncResetInProgress), timedOut=\(isFirstImportTimedOut))" + ) Log.event(.ucCreationSuppressed, parameters: [ Log.Param.reason: SuppressionReason.syncInProgress.rawValue, Log.Param.waiting: isWaitingForFirstImport.description, Log.Param.importing: isImportInProgress.description, - Log.Param.reset: isSyncResetInProgress.description + Log.Param.reset: isSyncResetInProgress.description, + Log.Param.timedOut: isFirstImportTimedOut.description ]) throw CoreDataStorageError.notFound } @@ -928,17 +976,7 @@ extension CoreDataStorage { // 기존 유저인데 UC가 0개 → CloudKit 미러 재구성 또는 re-import 대기 상태 // 빈 UC를 생성하면 CloudKit에 빈 데이터가 Export되어 기존 데이터를 오염시킬 수 있음 - // - // 예외: performCloudKitRecovery 직후 grace period(10분) 내에는 - // import가 지연되더라도 앱이 동작 가능하도록 UC 생성을 허용한다. - // 복구 자체가 "로컬 재생성 + CloudKit에서 재import" 플로우이므로 안전. if hasEverHadUserCollection { - if isWithinRecoveryGracePeriod { - Log.info("UC created within recovery grace period (hasEverHadUC=true)") - Log.event(.ucCreated, parameters: [Log.Param.path: UCCreationPath.recoveryGrace.rawValue]) - logSyncDiagnostics(phase: "UC-created-recovery", throttled: false) - return UserCollectionEntity(UserInfo(), context: context) - } // 핵심 데이터 유실 증상: "기존 유저인데 UC가 사라짐". // 3.2.0 이후 클레임의 주 증상으로 추정되는 상태. Log.warning("UC missing but hasEverHadUC=true — user data appears reset, blocking empty UC to protect cloud") @@ -973,6 +1011,19 @@ extension CoreDataStorage { } } +#if DEBUG +extension CoreDataStorage { + convenience init(testingPersistentContainer: NSPersistentCloudKitContainer) { + self.init(persistentContainer: testingPersistentContainer) + } + + static func resetPersistentSyncFlagsForTesting() { + UserDefaults.standard.removeObject(forKey: hasEverHadUserCollectionKey) + UserDefaults.standard.removeObject(forKey: recoveryInitiatedAtKey) + } +} +#endif + // MARK: - Data Recovery extension CoreDataStorage { @@ -1041,8 +1092,8 @@ extension CoreDataStorage { // 기존 유저 플래그 유지 — 재시작 후 CloudKit re-import 전까지 빈 UC 생성 방지 // (복구 = 기존 유저이므로 true 유지가 올바름) - // Recovery grace period 시작 — 재시작 후 import가 지연되어도 - // 10분간은 UC 생성을 허용하여 앱이 동작 가능하도록 보장 + // Recovery grace period 시작 — 재시작 후 import 지연/실패 상태를 UI와 진단 로그에서 + // 구분하기 위한 표시용 플래그. 빈 UC 생성을 허용하지는 않는다. self.markRecoveryInitiated() Log.warning("recovery: local store wiped, awaiting restart + CloudKit re-import") @@ -1058,7 +1109,6 @@ extension CoreDataStorage { private enum UCCreationPath: String { case freshUser = "fresh_user" - case recoveryGrace = "recovery_grace" } private enum SuppressionReason: String { diff --git a/Animal-Crossing-Wiki/Projects/App/Sources/Extension/String+extension.swift b/Animal-Crossing-Wiki/Projects/App/Sources/Extension/String+extension.swift index b9304e78..7160d7f7 100644 --- a/Animal-Crossing-Wiki/Projects/App/Sources/Extension/String+extension.swift +++ b/Animal-Crossing-Wiki/Projects/App/Sources/Extension/String+extension.swift @@ -27,7 +27,7 @@ extension String { var isChosung: Bool { var isChosung = false for char in self { - if 0 < hangul.filter({ $0.contains(char)}).count { + if hangul.contains(where: { $0.contains(char) }) { isChosung = true } else { isChosung = false diff --git a/Animal-Crossing-Wiki/Projects/App/Sources/SceneDelegate.swift b/Animal-Crossing-Wiki/Projects/App/Sources/SceneDelegate.swift index f09a992a..e1aa4475 100644 --- a/Animal-Crossing-Wiki/Projects/App/Sources/SceneDelegate.swift +++ b/Animal-Crossing-Wiki/Projects/App/Sources/SceneDelegate.swift @@ -15,6 +15,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { var appCoordinator: AppCoordinator? private var isAppSetup = false private var importObserver: NSObjectProtocol? + private var pendingFirstImportCompletionReason: CoreDataStorage.FirstImportWaitCompletionReason? func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { guard let windowScene = (scene as? UIWindowScene) else { @@ -30,7 +31,8 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { CoreDataStorage.shared.markWaitingForFirstImport() showSplashScreen() window?.makeKeyAndVisible() - waitForCloudKitImport(timeout: 10) { [weak self] in + waitForCloudKitImport(timeout: 10) { [weak self] reason in + self?.pendingFirstImportCompletionReason = reason self?.setupApp() } } else { @@ -43,7 +45,12 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { private func setupApp() { isAppSetup = true - CoreDataStorage.shared.clearWaitingForFirstImport() + if let reason = pendingFirstImportCompletionReason { + CoreDataStorage.shared.completeFirstImportWait(reason: reason) + pendingFirstImportCompletionReason = nil + } else { + CoreDataStorage.shared.clearWaitingForFirstImport() + } CoreDataStorage.shared.logSyncDiagnostics(phase: "Pre-setup") appCoordinator = AppCoordinator() @@ -122,11 +129,14 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { window?.rootViewController = CloudSyncSplashViewController() } - private func waitForCloudKitImport(timeout: TimeInterval, completion: @escaping () -> Void) { + private func waitForCloudKitImport( + timeout: TimeInterval, + completion: @escaping (CoreDataStorage.FirstImportWaitCompletionReason) -> Void + ) { var hasCompleted = false // hasCompleted 접근을 main queue로 한정하여 race condition 방지 - let complete: (String) -> Void = { [weak self] reason in + let complete: (CoreDataStorage.FirstImportWaitCompletionReason) -> Void = { [weak self] reason in DispatchQueue.main.async { guard !hasCompleted else { return @@ -137,8 +147,8 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { NotificationCenter.default.removeObserver(observer) self?.importObserver = nil } - os_log(.info, log: .default, "🚀 CloudKit wait finished (%{public}@) — launching app", reason) - completion() + os_log(.info, log: .default, "🚀 CloudKit wait finished (%{public}@) — launching app", "\(reason)") + completion(reason) } } @@ -146,7 +156,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { CoreDataStorage.shared.checkiCloudAccountStatus { status in if status != .available { os_log(.info, log: .default, "🚀 iCloud not available (status=%d) — skipping wait", status.rawValue) - complete("no-icloud") + complete(.noICloud) } } @@ -155,11 +165,11 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { object: nil, queue: .main ) { _ in - complete("import-arrived") + complete(.importArrived) } DispatchQueue.main.asyncAfter(deadline: .now() + timeout) { - complete("timeout") + complete(.timeout) } } diff --git a/Animal-Crossing-Wiki/Projects/App/Sources/Utility/Log.swift b/Animal-Crossing-Wiki/Projects/App/Sources/Utility/Log.swift index 63a4c577..eed87575 100644 --- a/Animal-Crossing-Wiki/Projects/App/Sources/Utility/Log.swift +++ b/Animal-Crossing-Wiki/Projects/App/Sources/Utility/Log.swift @@ -7,6 +7,7 @@ import Foundation import OSLog +import FirebaseCore import FirebaseCrashlytics import FirebaseAnalytics @@ -42,6 +43,7 @@ enum Log { static let waiting = "waiting" static let importing = "importing" static let reset = "reset" + static let timedOut = "timed_out" static let recoveryGrace = "recovery_grace" } @@ -65,9 +67,19 @@ enum Log { // MARK: - Internal - private static let crashlytics = Crashlytics.crashlytics() private static let analyticsStringLimit = 100 + private static var isFirebaseConfigured: Bool { + FirebaseApp.app() != nil + } + + private static var crashlytics: Crashlytics? { + guard isFirebaseConfigured else { + return nil + } + return Crashlytics.crashlytics() + } + private static func truncate(_ message: String) -> String { guard message.count > analyticsStringLimit else { return message @@ -86,9 +98,9 @@ enum Log { // verbose/debug는 Analytics 쿼터 보호를 위해 DEBUG 빌드에서만 Analytics로 전송된다. private static func emit(level: String, symbol: String, osLogType: OSLogType, message: String, sendToAnalytics: Bool) { - crashlytics.log("[\(level.uppercased())] \(message)") + crashlytics?.log("[\(level.uppercased())] \(message)") os_log(osLogType, log: .default, "%{public}@ %{public}@", symbol, message) - guard sendToAnalytics else { + guard sendToAnalytics, isFirebaseConfigured else { return } Analytics.logEvent("log_\(level)", parameters: [Param.message: truncate(message)]) @@ -130,8 +142,11 @@ enum Log { var info = userInfo info[NSLocalizedDescriptionKey] = reason let nsError = NSError(domain: "Log.\(name)", code: 0, userInfo: info) - crashlytics.record(error: nsError) + crashlytics?.record(error: nsError) os_log(.error, log: .default, "❗️ non-fatal: %{public}@ — %{public}@", name, reason) + guard isFirebaseConfigured else { + return + } Analytics.logEvent("log_error", parameters: [ Param.errorName: truncate(name), Param.reason: truncate(reason) @@ -141,8 +156,10 @@ enum Log { // MARK: - Analytics static func event(_ event: Event, parameters: [String: Any] = [:]) { - Analytics.logEvent(event.rawValue, parameters: parameters) - crashlytics.log("[EVENT] \(event.rawValue) \(parameters)") + if isFirebaseConfigured { + Analytics.logEvent(event.rawValue, parameters: parameters) + crashlytics?.log("[EVENT] \(event.rawValue) \(parameters)") + } os_log(.info, log: .default, "📈 %{public}@", event.rawValue) } @@ -151,7 +168,9 @@ enum Log { var params = parameters params[AnalyticsParameterItemID] = name params[AnalyticsParameterContentType] = "click" - Analytics.logEvent(AnalyticsEventSelectContent, parameters: params) + if isFirebaseConfigured { + Analytics.logEvent(AnalyticsEventSelectContent, parameters: params) + } os_log(.info, log: .default, "👆 click=%{public}@", name) } @@ -159,10 +178,10 @@ enum Log { static func setContext(_ key: String, _ value: Any?) { guard let value else { - crashlytics.setCustomValue("", forKey: key) + crashlytics?.setCustomValue("", forKey: key) return } - crashlytics.setCustomValue(value, forKey: key) + crashlytics?.setCustomValue(value, forKey: key) } /// 엔티티 카운트와 sync 플래그를 한 번에 custom keys로 전송. diff --git a/Animal-Crossing-Wiki/Projects/App/Tests/CoreDataStorage/CoreDataStorageICloudResetTests.swift b/Animal-Crossing-Wiki/Projects/App/Tests/CoreDataStorage/CoreDataStorageICloudResetTests.swift new file mode 100644 index 00000000..290bacb4 --- /dev/null +++ b/Animal-Crossing-Wiki/Projects/App/Tests/CoreDataStorage/CoreDataStorageICloudResetTests.swift @@ -0,0 +1,230 @@ +import XCTest +import CoreData +import RxSwift +@testable import ACNH_wiki + +final class CoreDataStorageICloudResetTests: XCTestCase { + + override func setUp() { + super.setUp() + CoreDataStorage.resetPersistentSyncFlagsForTesting() + } + + override func tearDown() { + CoreDataStorage.resetPersistentSyncFlagsForTesting() + super.tearDown() + } + + func testRecoveryGraceDoesNotCreateEmptyUserCollectionForKnownUser() throws { + let storage = try makeStorage() + let context = storage.persistentContainer.viewContext + + let existingUserCollection = UserCollectionEntity(UserInfo(), context: context) + try context.save() + + _ = try storage.getUserCollection(context) + XCTAssertTrue(storage.hasEverHadUserCollection) + + context.delete(existingUserCollection) + try context.save() + storage.markRecoveryInitiated() + + XCTAssertThrowsError(try storage.getUserCollection(context)) { error in + assertNotFound(error) + } + XCTAssertTrue(context.insertedObjects.isEmpty) + XCTAssertEqual(try userCollectionCount(in: context), 0) + } + + func testFirstImportTimeoutDoesNotCreateUserCollectionBeforeImportArrives() throws { + let storage = try makeStorage() + let context = storage.persistentContainer.viewContext + + storage.markWaitingForFirstImport() + storage.completeFirstImportWait(reason: .timeout) + + XCTAssertThrowsError(try storage.getUserCollection(context)) { error in + assertNotFound(error) + } + XCTAssertTrue(context.insertedObjects.isEmpty) + XCTAssertEqual(try userCollectionCount(in: context), 0) + } + + func testNoICloudPathStillAllowsFreshLocalUserCollection() throws { + let storage = try makeStorage() + let context = storage.persistentContainer.viewContext + + storage.markWaitingForFirstImport() + storage.completeFirstImportWait(reason: .noICloud) + + let userCollection = try storage.getUserCollection(context) + XCTAssertFalse(userCollection.isDeleted) + XCTAssertEqual(context.insertedObjects.count, 1) + } + + func testTimeoutAfterAlreadyObservedImportDoesNotKeepTimeoutSuppression() throws { + let storage = try makeStorage() + + storage.lastSuccessfulImportDate = Date() + storage.markWaitingForFirstImport() + storage.completeFirstImportWait(reason: .timeout) + + XCTAssertFalse(storage.isFirstImportTimedOut) + XCTAssertFalse(storage.shouldSuppressDataCreation) + } + + func testAppFlowFetchTasksDuringRecoveryGraceDoesNotCreateEmptyLocalDataWithoutICloudAccount() throws { + let storage = try makeStorage() + let context = storage.persistentContainer.viewContext + + let existingUserCollection = UserCollectionEntity(UserInfo(), context: context) + try context.save() + + _ = try storage.getUserCollection(context) + XCTAssertTrue(storage.hasEverHadUserCollection) + + context.delete(existingUserCollection) + try context.save() + storage.markRecoveryInitiated() + + let result = waitForFetchTasks(using: CoreDataDailyTaskStorage(coreDataStorage: storage)) + + assertReadErrorWrappingNotFound(result) + XCTAssertEqual(try entityCount("UserCollectionEntity", in: storage), 0) + XCTAssertEqual(try entityCount("DailyTaskEntity", in: storage), 0) + } + + func testAppFlowFetchTasksAfterFirstImportTimeoutDoesNotCreateEmptyLocalDataWithoutICloudAccount() throws { + let storage = try makeStorage() + + storage.markWaitingForFirstImport() + storage.completeFirstImportWait(reason: .timeout) + + let result = waitForFetchTasks(using: CoreDataDailyTaskStorage(coreDataStorage: storage)) + + assertReadErrorWrappingNotFound(result) + XCTAssertEqual(try entityCount("UserCollectionEntity", in: storage), 0) + XCTAssertEqual(try entityCount("DailyTaskEntity", in: storage), 0) + } + + func testAppFlowFetchTasksNoICloudFreshUserStillCreatesLocalDefaults() throws { + let storage = try makeStorage() + + storage.markWaitingForFirstImport() + storage.completeFirstImportWait(reason: .noICloud) + + let result = waitForFetchTasks(using: CoreDataDailyTaskStorage(coreDataStorage: storage)) + + guard case .success(let tasks) = result else { + XCTFail("Expected fetchTasks to succeed for no-iCloud fresh user, got \(result)") + return + } + XCTAssertEqual(tasks.count, DailyTask.tasks.count) + XCTAssertEqual(try entityCount("UserCollectionEntity", in: storage), 1) + XCTAssertEqual(try entityCount("DailyTaskEntity", in: storage), DailyTask.tasks.count) + } +} + +private extension CoreDataStorageICloudResetTests { + + func makeStorage() throws -> CoreDataStorage { + let model = try makeManagedObjectModel() + let container = NSPersistentCloudKitContainer(name: "CoreDataStorage", managedObjectModel: model) + let description = NSPersistentStoreDescription() + description.type = NSInMemoryStoreType + description.shouldAddStoreAsynchronously = false + container.persistentStoreDescriptions = [description] + + var loadError: Error? + container.loadPersistentStores { _, error in + loadError = error + } + if let loadError { + throw loadError + } + + return CoreDataStorage(testingPersistentContainer: container) + } + + func makeManagedObjectModel() throws -> NSManagedObjectModel { + let bundle = Bundle(for: CoreDataStorage.self) + guard let modelURL = bundle.url(forResource: "CoreDataStorage", withExtension: "momd"), + let model = NSManagedObjectModel(contentsOf: modelURL) else { + throw TestError.modelNotFound + } + return model + } + + func userCollectionCount(in context: NSManagedObjectContext) throws -> Int { + let request = UserCollectionEntity.fetchRequest() + return try context.count(for: request) + } + + func entityCount(_ entityName: String, in storage: CoreDataStorage) throws -> Int { + let context = storage.persistentContainer.newBackgroundContext() + var count = 0 + var caughtError: Error? + + context.performAndWait { + let request = NSFetchRequest(entityName: entityName) + do { + count = try context.count(for: request) + } catch { + caughtError = error + } + } + + if let caughtError { + throw caughtError + } + return count + } + + func waitForFetchTasks(using storage: CoreDataDailyTaskStorage) -> Result<[DailyTask], Error> { + let expectation = expectation(description: "fetchTasks") + var result: Result<[DailyTask], Error>? + + let disposable = storage.fetchTasks().subscribe( + onSuccess: { tasks in + result = .success(tasks) + expectation.fulfill() + }, + onFailure: { error in + result = .failure(error) + expectation.fulfill() + } + ) + + wait(for: [expectation], timeout: 2) + disposable.dispose() + return result ?? .failure(TestError.timeout) + } + + func assertNotFound(_ error: Error, file: StaticString = #filePath, line: UInt = #line) { + guard case CoreDataStorageError.notFound = error else { + XCTFail("Expected CoreDataStorageError.notFound, got \(error)", file: file, line: line) + return + } + } + + func assertReadErrorWrappingNotFound( + _ result: Result<[DailyTask], Error>, + file: StaticString = #filePath, + line: UInt = #line + ) { + guard case .failure(let error) = result else { + XCTFail("Expected fetchTasks to fail, got \(result)", file: file, line: line) + return + } + guard case CoreDataStorageError.readError(let underlying) = error else { + XCTFail("Expected CoreDataStorageError.readError, got \(error)", file: file, line: line) + return + } + assertNotFound(underlying, file: file, line: line) + } + + enum TestError: Error { + case modelNotFound + case timeout + } +} diff --git a/Tuist/Package.swift b/Tuist/Package.swift index c0963db2..1d7658ad 100644 --- a/Tuist/Package.swift +++ b/Tuist/Package.swift @@ -18,12 +18,7 @@ import PackageDescription "ReactorKit": .framework, "RxCocoaRuntime" : .framework, "RxRelay" : .framework - ], - baseSettings: .settings( - base: [ - "EXCLUDED_ARCHS[sdk=iphonesimulator*]": "arm64" - ] - ) + ] ) #endif diff --git a/docs/features/icloud-sync.md b/docs/features/icloud-sync.md index 58526bc1..aa7f8823 100644 --- a/docs/features/icloud-sync.md +++ b/docs/features/icloud-sync.md @@ -147,27 +147,34 @@ markWaitingForFirstImport() ← UC 생성 억제 플래그 CloudSyncSplashViewController 표시 ↓ waitForCloudKitImport(timeout: 10) - ├── import-arrived → setupApp() - ├── no-icloud → setupApp() - └── timeout → setupApp() - ↓ - setupApp() → clearWaitingForFirstImport() - (모든 경로에서 플래그 해제 보장) + ├── import-arrived → setupApp() → completeFirstImportWait(.importArrived) + ├── no-icloud → setupApp() → completeFirstImportWait(.noICloud) + └── timeout → setupApp() → completeFirstImportWait(.timeout) ``` +`timeout`은 "CloudKit에 데이터가 없다"는 의미가 아니라 "아직 확인하지 못했다"는 의미로 처리한다. +따라서 앱은 열리지만 `isFirstImportTimedOut`을 유지하여 빈 `UserCollectionEntity`와 기본 DailyTask 생성을 계속 억제한다. +이후 Import 이벤트가 실제로 도착하면 `handleCloudKitEvent()`가 timeout 상태를 해제하고 Path-B가 데이터를 다시 로드한다. +단, timeout 콜백보다 먼저 Import 성공 이벤트가 이미 관측된 경우에는 timeout 상태를 남기지 않는다. + +계정 없는 회귀 테스트는 `CoreDataStorageICloudResetTests`에서 관리한다. 이 테스트는 실제 iCloud 로그인 없이 +로컬 store와 sync 플래그를 조작한 뒤 앱 초기화 경로의 `CoreDataDailyTaskStorage.fetchTasks()`까지 실행하여, +애매한 동기화 상태에서 빈 `UserCollectionEntity`나 기본 `DailyTaskEntity`가 생성되면 실패한다. + ## UC Duplication Prevention **문제**: 신규 설치 시 로컬 UC 생성 → CloudKit Import로 기존 UC 도착 → UC 2개 존재 (영구 중복) **해결**: 다중 억제 플래그 + 기존 유저 보호 -`getUserCollection()`에서 UC가 없을 때 새 UC 생성을 억제하는 5가지 조건: +`getUserCollection()`에서 UC가 없을 때 새 UC 생성을 억제하는 6가지 조건: 1. `isWaitingForFirstImport` — 신규 설치 시 Import 완료 전 2. `isImportInProgress` — Import가 진행 중 (timeout 후에도 import가 끝나지 않은 경우) 3. `isSyncResetInProgress` — Change Token Expired 후 re-import 대기 -4. `_firstImportCompletedAt` grace period — 첫 Import 완료 후 120초간 UC 생성 유예 -5. `hasEverHadUserCollection` — 기존 유저 보호 (아래 참조) +4. `isFirstImportTimedOut` — 첫 Import 대기가 timeout됐지만 CloudKit 데이터 유무가 아직 불명확 +5. `_firstImportCompletedAt` grace period — 첫 Import 완료 후 120초간 UC 생성 유예 +6. `hasEverHadUserCollection` — 기존 유저 보호 (아래 참조) 모든 Storage 호출은 `.notFound` 에러를 graceful하게 처리 (`try?` → nil, do-catch → `os_log`). Import 완료 후 Path-B(`setUpUserCollection`)가 재실행되어 데이터가 정상 로드됨. @@ -183,11 +190,11 @@ Import 완료 후 Path-B(`setUpUserCollection`)가 재실행되어 데이터가 - `performCloudKitRecovery()`에서도 플래그 유지 (복구 = 기존 유저) **`shouldSuppressDataCreation` 통합 프로퍼티**: DailyTask 등 외부 Storage에서도 기본값 생성 억제 판단에 사용: -- `isWaitingForFirstImport || isImportInProgress || isSyncResetInProgress` 중 하나라도 true +- `isWaitingForFirstImport || isImportInProgress || isSyncResetInProgress || isFirstImportTimedOut` 중 하나라도 true - `isWithinGracePeriod` — 첫 Import 완료 후 `gracePeriodSeconds` (120초) 내 - 주의: `hasEverHadUserCollection`은 포함하지 않음 — `getUserCollection()`에서만 사용 (포함 시 DailyTask 자동 생성 영구 차단) -**기존 중복 정리**: `consolidateUserCollections()` — 앱 시작/Import 완료 시 자동 실행 (5초 지연, DispatchWorkItem으로 중복 방지): +**기존 중복 정리**: `consolidateUserCollectionsManually()` — 설정 화면에서 사용자가 명시적으로 실행: - UC가 2개 이상이면 관계(relationships)가 가장 많은 UC 보존 - 나머지 UC의 자식 엔티티를 보존 UC로 `reassignRelationships` - 고아 UC 삭제 → CloudKit Export로 iCloud에서도 정리 @@ -237,8 +244,9 @@ Import 완료 후 Path-B(`setUpUserCollection`)가 재실행되어 데이터가 5. `NSPersistentCloudKitContainer`가 CloudKit에서 전체 데이터 자동 import **Recovery Grace Period (10분)**: -- 재시작 후 CloudKit import가 지연되거나 실패해도 앱이 사용 가능 상태가 되도록 보장 -- `getUserCollection()`에서 `hasEverHadUserCollection == true`이더라도 grace 기간 내에는 UC 신규 생성 허용 +- 재시작 후 CloudKit import가 지연되거나 실패한 상태를 UI/로그에서 구분하기 위한 표시용 플래그 +- `getUserCollection()`은 grace 기간 안에서도 `hasEverHadUserCollection == true`이면 UC 신규 생성을 막음 +- 빈 UC를 만들면 CloudKit으로 빈 데이터가 Export되어 기존 iCloud 데이터를 오염시킬 수 있으므로 허용하지 않음 - 10분 경과 또는 정상 import 완료 시 플래그 자동 정리 **관련 파일**: @@ -318,7 +326,7 @@ Crashlytics(세션 breadcrumb + custom keys + 비치명 에러)와 Analytics(집 | `sync_recovery_triggered` | `performCloudKitRecovery` 성공 — 사용자가 설정에서 복원 실행 | | `sync_orphan_cleanup` | `cleanupOrphanedEntities`가 실제로 레코드 삭제 (entity/count 파라미터) | | `sync_uc_consolidated` | 중복 UC가 통합됨 (uc_total/kept_relationships) | -| `sync_uc_created` | 새 UC 생성 (path: fresh_user \| recovery_grace) | +| `sync_uc_created` | 새 UC 생성 (path: fresh_user) | | `sync_uc_creation_suppressed` | UC 생성이 억제됨 (reason: sync_in_progress \| grace_period) | | `sync_token_expired` | `NSCloudKitMirroringDelegateWillReset` 감지 | | `sync_user_collection_missing` | **핵심 증상**: hasEverHadUC=true인데 UC=0 | @@ -327,7 +335,7 @@ Crashlytics(세션 breadcrumb + custom keys + 비치명 에러)와 Analytics(집 ### Crashlytics Custom Keys (세션 스냅샷) `logSyncDiagnostics(phase:throttled:)` 호출 시 os_log 진단 + `Log.snapshot` 갱신을 한 번의 background fetch로 수행. -Import 종료, UC 생성(recovery grace), UC missing 시점에 자동 전송. UC missing처럼 즉시 컨텍스트가 필요한 +Import 종료, UC missing 시점에 자동 전송. UC missing처럼 즉시 컨텍스트가 필요한 경우 `throttled: false` 로 호출하여 5초 throttle을 우회. - `sync_uc_count` / `sync_item_count` / `sync_task_count` / `sync_villager_count` diff --git a/docs/gotchas.md b/docs/gotchas.md index c7b4b35d..093f940a 100644 --- a/docs/gotchas.md +++ b/docs/gotchas.md @@ -73,10 +73,15 @@ Dashboard 폴더 안이지만 **앱 전체에서 사용**됨. 이동하지 말 ## 9. Tuist 버전 -`.mise.toml`에 `tuist = "4.115.1"` 지정. 항상 `mise x -- tuist` 접두어로 명령어 실행. +`.mise.toml`에 `tuist = "4.152.0"` 지정. 항상 `mise x -- tuist` 접두어로 명령어 실행. 직접 `tuist` 호출 시 다른 버전이 실행될 수 있음. -## 10. MusicPlayer에는 Coordinator가 없음 +## 10. Apple Silicon simulator는 arm64 필요 + +Xcode 26.5의 iOS simulator runtime은 arm64 destination으로 잡힌다. +`EXCLUDED_ARCHS[sdk=iphonesimulator*] = arm64` 설정을 추가하면 실제 simulator가 destination 목록에서 사라져 테스트가 실행되지 않을 수 있음. + +## 11. MusicPlayer에는 Coordinator가 없음 `PlayerViewController`는 **AppCoordinator**가 직접 관리. 탭바 위에 오버레이로 표시되며 `showMusicPlayer()`, `minimize()`, `maximize()`, `removePlayerViewController()`로 제어. diff --git a/docs/guides/build-and-run.md b/docs/guides/build-and-run.md index c7b4e8c9..f7689e4a 100644 --- a/docs/guides/build-and-run.md +++ b/docs/guides/build-and-run.md @@ -4,7 +4,7 @@ - macOS, Xcode 16+ - Mise (toolchain manager) -- Tuist 4.115.1 (`.mise.toml`에 고정) +- Tuist 4.152.0 (`.mise.toml`에 고정) ## Setup @@ -38,10 +38,22 @@ xcodebuild \ -destination generic/platform="iOS Simulator" \ -configuration Debug \ build + +# 특정 유닛 테스트 실행 +xcodebuild \ + -workspace Animal-Crossing-Wiki.xcworkspace \ + -scheme ACNH-wiki \ + -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.5' \ + -only-testing:ACNH-wikiTests/CoreDataStorageICloudResetTests \ + test ``` > 설정 변경 후 반드시 `mise x -- tuist generate` 재실행 +Apple Silicon + Xcode 26.5 환경에서는 iOS 26.5 simulator runtime을 설치하고 arm64 simulator로 빌드한다. +`EXCLUDED_ARCHS[sdk=iphonesimulator*] = arm64`를 되살리면 Xcode가 실제 simulator destination을 찾지 못하고 +`Any iOS Simulator Device` placeholder만 표시할 수 있다. + ## SwiftLint ```bash @@ -66,7 +78,7 @@ swiftlint --config .swiftlint.yml --fix | 항목 | 값 | |------|---| | 최소 길이 (warning) | **2자** | -| 최소 길이 (error) | **4자** | +| 최소 길이 (error) | — | | 최대 길이 | 40자 (기본값) | | 예외 허용 | `a`, `b` | @@ -183,7 +195,7 @@ make ci |------|---| | Trigger | PR to `develop` 또는 PR 코멘트 `/build` | | Runner | `macos-26` | -| Xcode | 26.1.1 | +| Xcode | 26.5 | | Swift | 6.2 | ## Project Config Files diff --git a/docs/iCloud-data-loss-fix-summary.md b/docs/iCloud-data-loss-fix-summary.md index 73c8f47a..b76c6a3f 100644 --- a/docs/iCloud-data-loss-fix-summary.md +++ b/docs/iCloud-data-loss-fix-summary.md @@ -123,13 +123,14 @@ iOS에는 **"CloudKit 동기화 토큰이 만료됐다"** 고 판단되면 **우 ## 🛡️ 사용자 보호 강화 포인트 -### "영구적으로 앱이 잠기는 문제" 방지 +### "빈 데이터가 iCloud로 다시 올라가는 문제" 방지 기존에는 "iCloud에서 복원"을 누르고 앱 재시작 후, iCloud 네트워크가 느리면 -**앱이 아무 데이터도 못 보여주고 무한 대기**하는 경우가 있었습니다. +로컬 `UserCollection`이 0개인 상태를 새 사용자로 오판해 빈 데이터를 만들 수 있었습니다. -이제는 복원 시작 후 **10분 동안 유예 시간**을 두어, -iCloud 복구가 늦어지더라도 앱이 정상적으로 열리도록 했습니다. +이제는 복원 시작 후 **10분 동안 복구 유예 상태를 기록**하되, +그 기간에도 기존 사용자(`hasEverHadUserCollection == true`)라면 빈 `UserCollection` 생성을 막습니다. +iCloud 복구가 늦어지면 앱은 빈 데이터를 내보내지 않고, CloudKit Import 또는 로컬 백업 복원을 기다립니다. ### "실수 방지"를 위한 2단 확인 @@ -155,7 +156,7 @@ iCloud 복구가 늦어지더라도 앱이 정상적으로 열리도록 했습 | iOS가 로컬을 지웠는데 iCloud 복구 실패 | ❌ **데이터 유실** | ✅ 로컬 백업에서 복원 가능 | | iCloud 동기화 도중 우리 앱이 실수로 삭제 | ❌ **데이터 유실** | ✅ 삭제 경로가 막혔음 | | 사용자가 직접 복원 버튼 누름 | ⚠️ 1단 확인 | ✅ 2단 확인 + 경고 문구 | -| 복원 후 재시작 시 iCloud 느림 | ❌ 앱 잠김 | ✅ 10분 유예 후 정상 동작 | +| 복원 후 재시작 시 iCloud 느림 | ❌ 앱 잠김 또는 빈 데이터 생성 | ✅ 빈 데이터 Export 차단 + 복구/백업 대기 | --- From 1b1fb733a6bda4c9c8b63b4c1550981b9ab43e9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=B1=EC=A2=85=EC=97=B4?= Date: Thu, 4 Jun 2026 03:09:25 +0900 Subject: [PATCH 2/8] Harden iCloud reset regression coverage --- .github/workflows/develop-build.yml | 12 +- .../Projects/App/Project.swift | 5 +- .../Projects/App/Sources/AppDelegate.swift | 4 + .../CoreDataStorage/CoreDataStorage.swift | 119 ++++++++---- .../SafetySnapshotService.swift | 70 ++++++-- .../Projects/App/Sources/SceneDelegate.swift | 29 ++- .../App/Sources/Utility/AppEnvironment.swift | 15 ++ .../Projects/App/Sources/Utility/Log.swift | 5 +- .../CoreDataStorageICloudResetTests.swift | 169 ++++++++++++++++-- CLAUDE.md | 3 +- Makefile | 15 +- docs/features/icloud-sync.md | 13 +- docs/features/music-player.md | 2 +- docs/guides/build-and-run.md | 10 +- 14 files changed, 388 insertions(+), 83 deletions(-) create mode 100644 Animal-Crossing-Wiki/Projects/App/Sources/Utility/AppEnvironment.swift diff --git a/.github/workflows/develop-build.yml b/.github/workflows/develop-build.yml index 99304163..a74d406f 100644 --- a/.github/workflows/develop-build.yml +++ b/.github/workflows/develop-build.yml @@ -25,7 +25,7 @@ jobs: - uses: maxim-lobanov/setup-xcode@v1 with: - xcode-version: '26.1.1' + xcode-version: '26.5' - uses: actions/checkout@v4 with: @@ -61,3 +61,13 @@ jobs: -destination generic/platform="iOS Simulator" \ -configuration Debug \ build + + - name: Run iCloud Reset Regression Tests + run: | + xcodebuild \ + -workspace Animal-Crossing-Wiki.xcworkspace \ + -scheme ACNH-wiki \ + -destination 'platform=iOS Simulator,name=iPhone 17' \ + -configuration Debug \ + -only-testing:ACNH-wikiTests/CoreDataStorageICloudResetTests \ + test diff --git a/Animal-Crossing-Wiki/Projects/App/Project.swift b/Animal-Crossing-Wiki/Projects/App/Project.swift index d40a4a9a..55c9d268 100644 --- a/Animal-Crossing-Wiki/Projects/App/Project.swift +++ b/Animal-Crossing-Wiki/Projects/App/Project.swift @@ -53,7 +53,10 @@ let schemes: [Scheme] = [ name: "ACNH-wiki", shared: true, buildAction: .buildAction(targets: ["ACNH-wiki"]), - testAction: .targets(["ACNH-wikiTests"]), + testAction: .targets( + ["ACNH-wikiTests"], + arguments: .arguments(environmentVariables: ["IS_UNIT_TESTING": "1"]) + ), runAction: .runAction( configuration: .debug, executable: "ACNH-wiki", diff --git a/Animal-Crossing-Wiki/Projects/App/Sources/AppDelegate.swift b/Animal-Crossing-Wiki/Projects/App/Sources/AppDelegate.swift index fc543e2c..e333973b 100644 --- a/Animal-Crossing-Wiki/Projects/App/Sources/AppDelegate.swift +++ b/Animal-Crossing-Wiki/Projects/App/Sources/AppDelegate.swift @@ -18,6 +18,10 @@ class AppDelegate: UIResponder, UIApplicationDelegate { AppAppearance.setUpAppearance() + if AppEnvironment.isUnitTesting { + return true + } + FirebaseApp.configure() Crashlytics.crashlytics().setCrashlyticsCollectionEnabled(true) diff --git a/Animal-Crossing-Wiki/Projects/App/Sources/CoreDataStorage/CoreDataStorage.swift b/Animal-Crossing-Wiki/Projects/App/Sources/CoreDataStorage/CoreDataStorage.swift index 028df13c..c4210631 100644 --- a/Animal-Crossing-Wiki/Projects/App/Sources/CoreDataStorage/CoreDataStorage.swift +++ b/Animal-Crossing-Wiki/Projects/App/Sources/CoreDataStorage/CoreDataStorage.swift @@ -101,15 +101,19 @@ final class CoreDataStorage { /// 한 번이라도 UserCollectionEntity가 존재했는지 여부 (UserDefaults 기반, 메모리 캐싱) /// 이 플래그가 true인데 UC가 0개면, 빈 UC 자동 생성 대신 .notFound를 throw - private var _hasEverHadUserCollectionCached = UserDefaults.standard.bool( - forKey: CoreDataStorage.hasEverHadUserCollectionKey + private let _hasEverHadUserCollectionCached = OSAllocatedUnfairLock( + initialState: UserDefaults.standard.bool(forKey: CoreDataStorage.hasEverHadUserCollectionKey) ) private(set) var hasEverHadUserCollection: Bool { - get { _hasEverHadUserCollectionCached } + get { _hasEverHadUserCollectionCached.withLock { $0 } } set { - guard _hasEverHadUserCollectionCached != newValue else { return } - _hasEverHadUserCollectionCached = newValue - UserDefaults.standard.set(newValue, forKey: Self.hasEverHadUserCollectionKey) + _hasEverHadUserCollectionCached.withLock { cached in + guard cached != newValue else { + return + } + cached = newValue + UserDefaults.standard.set(newValue, forKey: Self.hasEverHadUserCollectionKey) + } } } @@ -141,7 +145,9 @@ final class CoreDataStorage { /// 복구 시작 후 grace period 내인지 확인 — 상태 표시에만 사용하고, UC 생성 허용에는 사용하지 않는다. var isWithinRecoveryGracePeriod: Bool { let timestamp = UserDefaults.standard.double(forKey: Self.recoveryInitiatedAtKey) - guard timestamp > 0 else { return false } + guard timestamp > 0 else { + return false + } let elapsed = Date().timeIntervalSince1970 - timestamp if elapsed < 0 || elapsed > Self.recoveryGracePeriodSeconds { // 만료 시 자동 정리 @@ -156,7 +162,9 @@ final class CoreDataStorage { /// 첫 Import 완료 후 grace period 내인지 확인 private var isWithinGracePeriod: Bool { - guard let firstImportDate = _firstImportCompletedAt.withLock({ $0 }) else { return false } + guard let firstImportDate = _firstImportCompletedAt.withLock({ $0 }) else { + return false + } return Date().timeIntervalSince(firstImportDate) < Self.gracePeriodSeconds } @@ -267,7 +275,7 @@ final class CoreDataStorage { Log.info("clearWaitingForFirstImport") } - enum FirstImportWaitCompletionReason { + enum FirstImportWaitCompletionReason: Equatable { case importArrived case noICloud case timeout @@ -422,41 +430,19 @@ final class CoreDataStorage { } if event.endDate != nil { + let didSucceed = event.error == nil if let error = event.error { os_log(.error, log: .default, "CloudKit %{public}@ failed: %{public}@", type, error.localizedDescription) postSyncFailureIfNeeded(error) } else { os_log(.info, log: .default, "CloudKit %{public}@ succeeded", type) // 동기화 성공 시각 기록 (설정 화면 표시용) - if event.type == .import { - lastSuccessfulImportDate = Date() - isFirstImportTimedOut = false - } else if event.type == .export { + if event.type == .export { lastSuccessfulExportDate = Date() } } if event.type == .import { - isImportInProgress = false - isWaitingForFirstImport = false - isFirstImportTimedOut = false - isSyncResetInProgress = false - _exportRetryCount.withLock { $0 = 0 } - - _firstImportCompletedAt.withLock { date in - if date == nil { date = Date() } - } - - let hasChanges = hasImportedChanges() - logSyncDiagnostics(phase: "Import-end") - - // Import 후 자동 consolidation/orphan cleanup이 로컬 데이터를 삭제하는 버그로 - // 제거됨 — 사용자가 설정에서 명시적으로 실행할 때만 돌아간다. - - NotificationCenter.default.post( - name: Self.didFinishCloudImport, - object: nil, - userInfo: hasChanges ? ["hasChanges": true] : nil - ) + finishCloudImport(succeeded: didSucceed) } if event.type == .export { if event.error != nil { @@ -474,6 +460,39 @@ final class CoreDataStorage { } } + private func finishCloudImport(succeeded: Bool) { + isImportInProgress = false + + guard succeeded else { + // Failed import means CloudKit data is still unknown. Keep waiting/timeout/reset + // suppression flags so the app cannot create and export an empty UC after grace. + logSyncDiagnostics(phase: "Import-failed", throttled: false) + return + } + + lastSuccessfulImportDate = Date() + isWaitingForFirstImport = false + isFirstImportTimedOut = false + isSyncResetInProgress = false + _exportRetryCount.withLock { $0 = 0 } + + _firstImportCompletedAt.withLock { date in + if date == nil { date = Date() } + } + + let hasChanges = hasImportedChanges() + logSyncDiagnostics(phase: "Import-end") + + // Import 후 자동 consolidation/orphan cleanup이 로컬 데이터를 삭제하는 버그로 + // 제거됨 — 사용자가 설정에서 명시적으로 실행할 때만 돌아간다. + + NotificationCenter.default.post( + name: Self.didFinishCloudImport, + object: nil, + userInfo: hasChanges ? ["hasChanges": true] : nil + ) + } + // MARK: - Export Retry private func retryExportAfterMergeError() { @@ -599,7 +618,9 @@ extension CoreDataStorage { if throttled { let shouldProceed = lastDiagnosticsDate.withLock { lastDate -> Bool in let now = Date() - guard now.timeIntervalSince(lastDate) >= 5 else { return false } + guard now.timeIntervalSince(lastDate) >= 5 else { + return false + } lastDate = now return true } @@ -610,7 +631,9 @@ extension CoreDataStorage { } persistentContainer.performBackgroundTask { [weak self] context in - guard let self else { return } + guard let self else { + return + } context.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy let counts = self.entityCounts(in: context) @@ -643,10 +666,14 @@ extension CoreDataStorage { )) // UC가 2개 이상일 때만 상세 진단 (중복 탐지) - guard ucCount > 1 else { return } + guard ucCount > 1 else { + return + } let ucRequest = UserCollectionEntity.fetchRequest() - guard let ucResults = try? context.fetch(ucRequest) else { return } + guard let ucResults = try? context.fetch(ucRequest) else { + return + } for (index, uc) in ucResults.enumerated() { let critters = uc.critters?.count ?? 0 @@ -1017,6 +1044,18 @@ extension CoreDataStorage { self.init(persistentContainer: testingPersistentContainer) } + func markImportInProgressForTesting() { + isImportInProgress = true + } + + func markSyncResetInProgressForTesting() { + isSyncResetInProgress = true + } + + func finishCloudImportForTesting(succeeded: Bool) { + finishCloudImport(succeeded: succeeded) + } + static func resetPersistentSyncFlagsForTesting() { UserDefaults.standard.removeObject(forKey: hasEverHadUserCollectionKey) UserDefaults.standard.removeObject(forKey: recoveryInitiatedAtKey) @@ -1051,7 +1090,9 @@ extension CoreDataStorage { completion(.failure(RecoveryError.iCloudNotAvailable)) return } - guard let self else { return } + guard let self else { + return + } guard let storeDescription = self.persistentContainer.persistentStoreDescriptions.first, let storeURL = storeDescription.url else { 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 b10cbe09..7d426d01 100644 --- a/Animal-Crossing-Wiki/Projects/App/Sources/CoreDataStorage/SafetySnapshot/SafetySnapshotService.swift +++ b/Animal-Crossing-Wiki/Projects/App/Sources/CoreDataStorage/SafetySnapshot/SafetySnapshotService.swift @@ -18,12 +18,13 @@ final class SafetySnapshotService { // MARK: - File Location private static let fileName = "local_safety_snapshot.plist" + private static let snapshotFileProtection: FileProtectionType = .completeUntilFirstUserAuthentication var snapshotURL: URL { - guard let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { - fatalError("Documents directory unavailable") + guard let directory = snapshotDirectoryProvider() else { + fatalError("Safety snapshot directory unavailable") } - return documents.appendingPathComponent(Self.fileName) + return directory.appendingPathComponent(Self.fileName) } var snapshotExists: Bool { @@ -48,9 +49,29 @@ final class SafetySnapshotService { private let queue = DispatchQueue(label: "app.safety.snapshot", qos: .utility) private var pendingWorkItem: DispatchWorkItem? private var observers: [NSObjectProtocol] = [] + private let containerProvider: () -> NSPersistentContainer + private let snapshotDirectoryProvider: () -> URL? + private let fileAttributeSetter: (URL, [FileAttributeKey: Any]) throws -> Void + private let beforeApplyingSnapshot: ((NSManagedObjectContext) throws -> Void)? + + init( + containerProvider: @escaping () -> NSPersistentContainer = { CoreDataStorage.shared.persistentContainer }, + snapshotDirectoryProvider: @escaping () -> URL? = { + FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first + }, + fileAttributeSetter: @escaping (URL, [FileAttributeKey: Any]) throws -> Void = { url, attributes in + try FileManager.default.setAttributes(attributes, ofItemAtPath: url.path) + }, + beforeApplyingSnapshot: ((NSManagedObjectContext) throws -> Void)? = nil + ) { + self.containerProvider = containerProvider + self.snapshotDirectoryProvider = snapshotDirectoryProvider + self.fileAttributeSetter = fileAttributeSetter + self.beforeApplyingSnapshot = beforeApplyingSnapshot + } private var container: NSPersistentContainer { - CoreDataStorage.shared.persistentContainer + containerProvider() } /// 앱 시작 시 1회 호출 — 다음 이벤트에 대해 모두 스냅샷 작성을 예약: @@ -131,7 +152,11 @@ final class SafetySnapshotService { do { let snapshot = try UserCollectionSnapshot.dump(from: context) let data = try snapshot.toData() - try data.write(to: snapshotURL, options: [.atomic]) + // CloudKit can deliver background imports while the device is locked. The snapshot must + // remain writable after the first unlock so a sync-reset flush can preserve recent data. + try data.write(to: snapshotURL, options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication]) + try fileAttributeSetter(snapshotURL, [.protectionKey: Self.snapshotFileProtection]) + applySnapshotResourceValues() updateMetadataCache(createdAt: snapshot.createdAt, childCount: snapshot.totalChildCount) os_log(.info, log: .default, "🛟 SafetySnapshot written: %d children, %d bytes", @@ -146,6 +171,19 @@ final class SafetySnapshotService { } } + private func applySnapshotResourceValues() { + var url = snapshotURL + var values = URLResourceValues() + values.isExcludedFromBackup = true + do { + try url.setResourceValues(values) + } catch { + os_log(.error, log: .default, + "🛟 SafetySnapshot resource value update failed: %{public}@", + error.localizedDescription) + } + } + // MARK: - Restore enum RestoreOutcome { @@ -158,12 +196,15 @@ final class SafetySnapshotService { /// **파괴적 동작** — 호출 전 사용자 명시적 동의 필요. func restore(completion: @escaping (RestoreOutcome) -> Void) { container.performBackgroundTask { [weak self] context in - guard let self else { return } + guard let self else { + return + } let outcome: RestoreOutcome do { let data = try Data(contentsOf: self.snapshotURL) let snapshot = try UserCollectionSnapshot.from(data: data) try Self.wipeExistingCollection(in: context) + try self.beforeApplyingSnapshot?(context) try snapshot.apply(to: context) try context.save() os_log(.error, log: .default, @@ -173,6 +214,7 @@ final class SafetySnapshotService { } catch let error as NSError where error.domain == NSCocoaErrorDomain && error.code == NSFileReadNoSuchFileError { outcome = .noSnapshot } catch { + context.rollback() os_log(.error, log: .default, "🛟 SafetySnapshot restore FAILED: %{public}@", error.localizedDescription) @@ -189,14 +231,10 @@ final class SafetySnapshotService { "UserCollectionEntity" ] for name in entityNames { - let request = NSFetchRequest(entityName: name) - let delete = NSBatchDeleteRequest(fetchRequest: request) - delete.resultType = .resultTypeObjectIDs - if let result = try context.execute(delete) as? NSBatchDeleteResult, - let objectIDs = result.result as? [NSManagedObjectID], !objectIDs.isEmpty { - let changes: [AnyHashable: Any] = [NSDeletedObjectsKey: objectIDs] - NSManagedObjectContext.mergeChanges(fromRemoteContextSave: changes, into: [context]) - } + let request = NSFetchRequest(entityName: name) + request.includesPropertyValues = false + let objects = try context.fetch(request) + objects.forEach(context.delete) } } @@ -230,7 +268,9 @@ final class SafetySnapshotService { } private func readMetadataFromFile() -> Metadata? { - guard snapshotExists else { return nil } + guard snapshotExists else { + return nil + } do { let data = try Data(contentsOf: snapshotURL) let snapshot = try UserCollectionSnapshot.from(data: data) diff --git a/Animal-Crossing-Wiki/Projects/App/Sources/SceneDelegate.swift b/Animal-Crossing-Wiki/Projects/App/Sources/SceneDelegate.swift index e1aa4475..d25819d6 100644 --- a/Animal-Crossing-Wiki/Projects/App/Sources/SceneDelegate.swift +++ b/Animal-Crossing-Wiki/Projects/App/Sources/SceneDelegate.swift @@ -23,6 +23,12 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { } window = UIWindow(windowScene: windowScene) + if AppEnvironment.isUnitTesting { + window?.rootViewController = UIViewController() + window?.makeKeyAndVisible() + return + } + os_log(.info, log: .default, "🚀 App launch — checking fresh install") let isFresh = CoreDataStorage.shared.isFreshInstall() os_log(.info, log: .default, "🚀 isFreshInstall = %{public}@", isFresh ? "true" : "false") @@ -154,9 +160,11 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { // iCloud 계정 확인 — 미로그인이면 Import 대기 불필요 CoreDataStorage.shared.checkiCloudAccountStatus { status in - if status != .available { - os_log(.info, log: .default, "🚀 iCloud not available (status=%d) — skipping wait", status.rawValue) - complete(.noICloud) + if let reason = Self.firstImportWaitCompletionReason(for: status) { + os_log(.info, log: .default, + "🚀 iCloud wait completed from account status=%d reason=%{public}@", + status.rawValue, "\(reason)") + complete(reason) } } @@ -173,6 +181,21 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { } } + static func firstImportWaitCompletionReason( + for status: CKAccountStatus + ) -> CoreDataStorage.FirstImportWaitCompletionReason? { + switch status { + case .available: + return nil + case .noAccount, .restricted: + return .noICloud + case .couldNotDetermine, .temporarilyUnavailable: + return .timeout + @unknown default: + return .timeout + } + } + // MARK: - iCloud Account Check private func checkiCloudAccount() { diff --git a/Animal-Crossing-Wiki/Projects/App/Sources/Utility/AppEnvironment.swift b/Animal-Crossing-Wiki/Projects/App/Sources/Utility/AppEnvironment.swift new file mode 100644 index 00000000..317ca04d --- /dev/null +++ b/Animal-Crossing-Wiki/Projects/App/Sources/Utility/AppEnvironment.swift @@ -0,0 +1,15 @@ +// +// AppEnvironment.swift +// Animal-Crossing-Wiki +// +// Created by Codex on 2026/06/04. +// + +import Foundation + +enum AppEnvironment { + static var isUnitTesting: Bool { + ProcessInfo.processInfo.environment["IS_UNIT_TESTING"] == "1" + || NSClassFromString("XCTestCase") != nil + } +} diff --git a/Animal-Crossing-Wiki/Projects/App/Sources/Utility/Log.swift b/Animal-Crossing-Wiki/Projects/App/Sources/Utility/Log.swift index eed87575..3cc30a0d 100644 --- a/Animal-Crossing-Wiki/Projects/App/Sources/Utility/Log.swift +++ b/Animal-Crossing-Wiki/Projects/App/Sources/Utility/Log.swift @@ -70,7 +70,10 @@ enum Log { private static let analyticsStringLimit = 100 private static var isFirebaseConfigured: Bool { - FirebaseApp.app() != nil + guard !AppEnvironment.isUnitTesting else { + return false + } + return FirebaseApp.app() != nil } private static var crashlytics: Crashlytics? { diff --git a/Animal-Crossing-Wiki/Projects/App/Tests/CoreDataStorage/CoreDataStorageICloudResetTests.swift b/Animal-Crossing-Wiki/Projects/App/Tests/CoreDataStorage/CoreDataStorageICloudResetTests.swift index 290bacb4..c21c2649 100644 --- a/Animal-Crossing-Wiki/Projects/App/Tests/CoreDataStorage/CoreDataStorageICloudResetTests.swift +++ b/Animal-Crossing-Wiki/Projects/App/Tests/CoreDataStorage/CoreDataStorageICloudResetTests.swift @@ -19,7 +19,7 @@ final class CoreDataStorageICloudResetTests: XCTestCase { let storage = try makeStorage() let context = storage.persistentContainer.viewContext - let existingUserCollection = UserCollectionEntity(UserInfo(), context: context) + let existingUserCollection = try insertUserCollection(in: context) try context.save() _ = try storage.getUserCollection(context) @@ -77,7 +77,7 @@ final class CoreDataStorageICloudResetTests: XCTestCase { let storage = try makeStorage() let context = storage.persistentContainer.viewContext - let existingUserCollection = UserCollectionEntity(UserInfo(), context: context) + let existingUserCollection = try insertUserCollection(in: context) try context.save() _ = try storage.getUserCollection(context) @@ -107,6 +107,127 @@ final class CoreDataStorageICloudResetTests: XCTestCase { XCTAssertEqual(try entityCount("DailyTaskEntity", in: storage), 0) } + func testFailedCloudImportAfterTimeoutKeepsSuppressionAndDoesNotCreateEmptyData() throws { + let storage = try makeStorage() + + storage.markWaitingForFirstImport() + storage.completeFirstImportWait(reason: .timeout) + storage.markImportInProgressForTesting() + storage.finishCloudImportForTesting(succeeded: false) + + XCTAssertTrue(storage.isFirstImportTimedOut) + XCTAssertFalse(storage.isImportInProgress) + XCTAssertTrue(storage.shouldSuppressDataCreation) + + let result = waitForFetchTasks(using: CoreDataDailyTaskStorage(coreDataStorage: storage)) + + assertReadErrorWrappingNotFound(result) + XCTAssertEqual(try entityCount("UserCollectionEntity", in: storage), 0) + XCTAssertEqual(try entityCount("DailyTaskEntity", in: storage), 0) + } + + func testFailedCloudImportDuringSyncResetKeepsResetSuppressionAndDoesNotCreateEmptyData() throws { + let storage = try makeStorage() + + storage.markSyncResetInProgressForTesting() + storage.markImportInProgressForTesting() + storage.finishCloudImportForTesting(succeeded: false) + + XCTAssertTrue(storage.isSyncResetInProgress) + XCTAssertFalse(storage.isImportInProgress) + XCTAssertTrue(storage.shouldSuppressDataCreation) + + let result = waitForFetchTasks(using: CoreDataDailyTaskStorage(coreDataStorage: storage)) + + assertReadErrorWrappingNotFound(result) + XCTAssertEqual(try entityCount("UserCollectionEntity", in: storage), 0) + XCTAssertEqual(try entityCount("DailyTaskEntity", in: storage), 0) + } + + func testSuccessfulCloudImportClearsResetAndTimeoutSuppression() throws { + let storage = try makeStorage() + + storage.markWaitingForFirstImport() + storage.completeFirstImportWait(reason: .timeout) + storage.markSyncResetInProgressForTesting() + storage.markImportInProgressForTesting() + storage.finishCloudImportForTesting(succeeded: true) + + XCTAssertFalse(storage.isFirstImportTimedOut) + XCTAssertFalse(storage.isImportInProgress) + XCTAssertFalse(storage.isSyncResetInProgress) + } + + func testTransientICloudAccountStatusesKeepFirstImportUnknown() { + XCTAssertNil(SceneDelegate.firstImportWaitCompletionReason(for: .available)) + XCTAssertEqual(SceneDelegate.firstImportWaitCompletionReason(for: .noAccount), .noICloud) + XCTAssertEqual(SceneDelegate.firstImportWaitCompletionReason(for: .restricted), .noICloud) + XCTAssertEqual(SceneDelegate.firstImportWaitCompletionReason(for: .temporarilyUnavailable), .timeout) + XCTAssertEqual(SceneDelegate.firstImportWaitCompletionReason(for: .couldNotDetermine), .timeout) + } + + func testSafetySnapshotWriteUsesBackgroundSafeProtectionAndExcludesBackup() throws { + let storage = try makeStorage() + let context = storage.persistentContainer.viewContext + try insertUserCollection(in: context) + try context.save() + + let snapshotDirectory = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: snapshotDirectory) } + var appliedProtection: FileProtectionType? + let service = SafetySnapshotService( + containerProvider: { storage.persistentContainer }, + snapshotDirectoryProvider: { snapshotDirectory }, + fileAttributeSetter: { _, attributes in + appliedProtection = attributes[.protectionKey] as? FileProtectionType + } + ) + + service.flushNow() + + XCTAssertTrue(FileManager.default.fileExists(atPath: service.snapshotURL.path)) + let resourceValues = try service.snapshotURL.resourceValues(forKeys: [.isExcludedFromBackupKey]) + XCTAssertEqual(resourceValues.isExcludedFromBackup, true) + XCTAssertEqual(appliedProtection, .completeUntilFirstUserAuthentication) + } + + func testSafetySnapshotRestoreFailureAfterWipeRollsBackExistingCollection() throws { + enum InjectedRestoreFailure: Error { + case failure + } + + let storage = try makeStorage() + let context = storage.persistentContainer.viewContext + try insertUserCollection(in: context) + try context.save() + + let snapshotDirectory = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: snapshotDirectory) } + let snapshot = try UserCollectionSnapshot.dump(from: context) + let snapshotURL = snapshotDirectory.appendingPathComponent("local_safety_snapshot.plist") + try snapshot.toData().write(to: snapshotURL) + + let service = SafetySnapshotService( + containerProvider: { storage.persistentContainer }, + snapshotDirectoryProvider: { snapshotDirectory }, + beforeApplyingSnapshot: { _ in throw InjectedRestoreFailure.failure } + ) + + let expectation = expectation(description: "restore completes") + service.restore { outcome in + guard case .failed(let error) = outcome else { + XCTFail("Expected restore failure after injected error, got \(outcome)") + expectation.fulfill() + return + } + XCTAssertTrue(error is InjectedRestoreFailure) + expectation.fulfill() + } + + wait(for: [expectation], timeout: 2) + XCTAssertEqual(try entityCount("UserCollectionEntity", in: storage), 1) + } + func testAppFlowFetchTasksNoICloudFreshUserStillCreatesLocalDefaults() throws { let storage = try makeStorage() @@ -125,9 +246,9 @@ final class CoreDataStorageICloudResetTests: XCTestCase { } } -private extension CoreDataStorageICloudResetTests { +extension CoreDataStorageICloudResetTests { - func makeStorage() throws -> CoreDataStorage { + private func makeStorage() throws -> CoreDataStorage { let model = try makeManagedObjectModel() let container = NSPersistentCloudKitContainer(name: "CoreDataStorage", managedObjectModel: model) let description = NSPersistentStoreDescription() @@ -146,7 +267,7 @@ private extension CoreDataStorageICloudResetTests { return CoreDataStorage(testingPersistentContainer: container) } - func makeManagedObjectModel() throws -> NSManagedObjectModel { + private func makeManagedObjectModel() throws -> NSManagedObjectModel { let bundle = Bundle(for: CoreDataStorage.self) guard let modelURL = bundle.url(forResource: "CoreDataStorage", withExtension: "momd"), let model = NSManagedObjectModel(contentsOf: modelURL) else { @@ -155,12 +276,31 @@ private extension CoreDataStorageICloudResetTests { return model } - func userCollectionCount(in context: NSManagedObjectContext) throws -> Int { + @discardableResult + private func insertUserCollection( + in context: NSManagedObjectContext, + userInfo: UserInfo = UserInfo() + ) throws -> UserCollectionEntity { + guard let object = NSEntityDescription.insertNewObject( + forEntityName: "UserCollectionEntity", + into: context + ) as? UserCollectionEntity else { + throw TestError.entityCastFailed + } + object.name = userInfo.name + object.islandName = userInfo.islandName + object.islandFruit = userInfo.islandFruit.imageName + object.hemisphere = userInfo.hemisphere.rawValue.capitalized + object.islandReputation = Int16(userInfo.islandReputation) + return object + } + + private func userCollectionCount(in context: NSManagedObjectContext) throws -> Int { let request = UserCollectionEntity.fetchRequest() return try context.count(for: request) } - func entityCount(_ entityName: String, in storage: CoreDataStorage) throws -> Int { + private func entityCount(_ entityName: String, in storage: CoreDataStorage) throws -> Int { let context = storage.persistentContainer.newBackgroundContext() var count = 0 var caughtError: Error? @@ -180,7 +320,7 @@ private extension CoreDataStorageICloudResetTests { return count } - func waitForFetchTasks(using storage: CoreDataDailyTaskStorage) -> Result<[DailyTask], Error> { + private func waitForFetchTasks(using storage: CoreDataDailyTaskStorage) -> Result<[DailyTask], Error> { let expectation = expectation(description: "fetchTasks") var result: Result<[DailyTask], Error>? @@ -200,14 +340,14 @@ private extension CoreDataStorageICloudResetTests { return result ?? .failure(TestError.timeout) } - func assertNotFound(_ error: Error, file: StaticString = #filePath, line: UInt = #line) { + private func assertNotFound(_ error: Error, file: StaticString = #filePath, line: UInt = #line) { guard case CoreDataStorageError.notFound = error else { XCTFail("Expected CoreDataStorageError.notFound, got \(error)", file: file, line: line) return } } - func assertReadErrorWrappingNotFound( + private func assertReadErrorWrappingNotFound( _ result: Result<[DailyTask], Error>, file: StaticString = #filePath, line: UInt = #line @@ -223,8 +363,15 @@ private extension CoreDataStorageICloudResetTests { assertNotFound(underlying, file: file, line: line) } - enum TestError: Error { + private func makeTemporaryDirectory() throws -> URL { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + return directory + } + + private enum TestError: Error { case modelNotFound + case entityCastFailed case timeout } } diff --git a/CLAUDE.md b/CLAUDE.md index 40482fbd..7e0f5d0f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,10 +27,11 @@ UIKit + RxSwift + ReactorKit. 단일 App 모듈, Tuist 빌드 시스템. ## Build Commands ```bash -mise install # Tuist 4.115.1 설치 +mise install # Tuist 4.152.0 설치 mise x -- tuist install # SPM 의존성 설치 mise x -- tuist generate --no-open # Xcode 프로젝트 생성 mise x -- tuist build # CLI 빌드 +make test # 핵심 유닛 테스트 실행 swiftlint --config .swiftlint.yml # 린트 검사 swiftlint --config .swiftlint.yml --fix # 자동 수정 make validate # 아키텍처 + 패턴 검증 diff --git a/Makefile b/Makefile index c4acb233..245b9cc8 100644 --- a/Makefile +++ b/Makefile @@ -11,13 +11,13 @@ # Usage: # make — Show available targets # make setup — One-time project bootstrap -# make ci — Full CI pipeline (lint + validate + build) +# make ci — Full CI pipeline (lint + validate + test + build) # # ============================================================================ .DEFAULT_GOAL := help -.PHONY: setup build lint lint-fix validate-arch validate-patterns validate-docs validate ci hooks help +.PHONY: setup build test lint lint-fix validate-arch validate-patterns validate-docs validate ci hooks help # ---------------------------------------------------------------------------- # Setup @@ -39,6 +39,15 @@ hooks: ## Configure git to use .githooks/ directory build: ## Build the project with Tuist mise x -- tuist build +test: ## Run focused unit tests + xcodebuild \ + -workspace Animal-Crossing-Wiki.xcworkspace \ + -scheme ACNH-wiki \ + -destination 'platform=iOS Simulator,name=iPhone 17' \ + -configuration Debug \ + -only-testing:ACNH-wikiTests/CoreDataStorageICloudResetTests \ + test + # ---------------------------------------------------------------------------- # Lint # ---------------------------------------------------------------------------- @@ -68,7 +77,7 @@ validate: validate-arch validate-patterns validate-docs ## Run all validations ( # CI # ---------------------------------------------------------------------------- -ci: lint validate build ## Full CI check: lint + validate + build +ci: lint validate test build ## Full CI check: lint + validate + test + build # ---------------------------------------------------------------------------- # Help diff --git a/docs/features/icloud-sync.md b/docs/features/icloud-sync.md index aa7f8823..a99e818b 100644 --- a/docs/features/icloud-sync.md +++ b/docs/features/icloud-sync.md @@ -38,6 +38,7 @@ Device A CloudKit Server Device B | `ToastManager.swift` | 전용 UIWindow 기반 토스트 매니저. 레퍼런스 카운팅, 타임아웃, 백그라운드 dismiss | | `ToastView.swift` | Import 상태 토스트 UI (캡슐형 디자인, ActivityIndicator + Label, slide 애니메이션) | | `CloudSyncSplashViewController.swift` | 신규 설치 시 CloudKit Import 대기 스플래시 화면 | +| `SafetySnapshotService.swift` | CloudKit purge/reset에 대비한 로컬 안전 스냅샷 작성 및 수동 복원 | ## Data Sync Flow @@ -154,7 +155,8 @@ waitForCloudKitImport(timeout: 10) `timeout`은 "CloudKit에 데이터가 없다"는 의미가 아니라 "아직 확인하지 못했다"는 의미로 처리한다. 따라서 앱은 열리지만 `isFirstImportTimedOut`을 유지하여 빈 `UserCollectionEntity`와 기본 DailyTask 생성을 계속 억제한다. -이후 Import 이벤트가 실제로 도착하면 `handleCloudKitEvent()`가 timeout 상태를 해제하고 Path-B가 데이터를 다시 로드한다. +이후 Import 이벤트가 실제로 성공하면 `handleCloudKitEvent()`가 timeout 상태를 해제하고 Path-B가 데이터를 다시 로드한다. +Import가 에러로 종료되면 CloudKit 데이터 유무가 여전히 불명확하므로 timeout/reset 억제 상태를 유지한다. 단, timeout 콜백보다 먼저 Import 성공 이벤트가 이미 관측된 경우에는 timeout 상태를 남기지 않는다. 계정 없는 회귀 테스트는 `CoreDataStorageICloudResetTests`에서 관리한다. 이 테스트는 실제 iCloud 로그인 없이 @@ -256,6 +258,15 @@ Import 완료 후 Path-B(`setUpUserCollection`)가 재실행되어 데이터가 - `DashboardCoordinator.showRecoveryResultAlert()` - `Localizable.strings` (ko/en) — 복구 관련 문자열 +### Local Safety Snapshot + +`SafetySnapshotService`는 UC 그래프를 `local_safety_snapshot.plist`로 유지한다. +CloudKit import, remote change, sync reset 직전에는 최신 로컬 상태를 스냅샷으로 남겨 iOS가 Core Data store를 purge해도 사용자가 수동 복원할 수 있게 한다. + +- 스냅샷 파일은 첫 잠금 해제 후 백그라운드 CloudKit flush에서도 갱신될 수 있도록 `completeUntilFirstUserAuthentication` 보호 등급으로 저장한다. +- 파일은 기기/iCloud 백업에서 제외하여 Core Data 원본과 별도로 장기 보관되지 않게 한다. +- 복원은 `wipeExistingCollection → snapshot.apply → context.save()`를 단일 context rollback 경계에 묶는다. 중간 실패 시 기존 로컬 컬렉션 삭제가 저장되지 않는다. + ## Manual Consolidation (중복/고아 데이터 정리) **3.2.4부터 자동 consolidation 제거됨** — 로컬 데이터가 의도치 않게 삭제되는 버그로 인해, diff --git a/docs/features/music-player.md b/docs/features/music-player.md index 5d0e68b1..dbd49bde 100644 --- a/docs/features/music-player.md +++ b/docs/features/music-player.md @@ -61,5 +61,5 @@ PlayerViewController UI 업데이트 ## 주의사항 -- 별도 Coordinator를 만들지 말 것 → [gotchas.md](../gotchas.md) #10 +- 별도 Coordinator를 만들지 말 것 → [gotchas.md](../gotchas.md) #11 - `PlayerReactor`의 coordinator 타입은 `AppCoordinator` diff --git a/docs/guides/build-and-run.md b/docs/guides/build-and-run.md index f7689e4a..4373b6c7 100644 --- a/docs/guides/build-and-run.md +++ b/docs/guides/build-and-run.md @@ -40,12 +40,7 @@ xcodebuild \ build # 특정 유닛 테스트 실행 -xcodebuild \ - -workspace Animal-Crossing-Wiki.xcworkspace \ - -scheme ACNH-wiki \ - -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.5' \ - -only-testing:ACNH-wikiTests/CoreDataStorageICloudResetTests \ - test +make test ``` > 설정 변경 후 반드시 `mise x -- tuist generate` 재실행 @@ -198,6 +193,9 @@ make ci | Xcode | 26.5 | | Swift | 6.2 | +CI는 `develop-build.yml`에서 빌드 후 `ACNH-wikiTests/CoreDataStorageICloudResetTests`를 실행하여 +iCloud 동기화 초기화 회귀 테스트를 PR 체크에 포함한다. + ## Project Config Files | File | 역할 | From 6710d5baf710789c6339a754df06d74014134cc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=B1=EC=A2=85=EC=97=B4?= Date: Thu, 4 Jun 2026 03:21:59 +0900 Subject: [PATCH 3/8] Stabilize iCloud reset regression wait --- .../Tests/CoreDataStorage/CoreDataStorageICloudResetTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Animal-Crossing-Wiki/Projects/App/Tests/CoreDataStorage/CoreDataStorageICloudResetTests.swift b/Animal-Crossing-Wiki/Projects/App/Tests/CoreDataStorage/CoreDataStorageICloudResetTests.swift index c21c2649..fefcb1bc 100644 --- a/Animal-Crossing-Wiki/Projects/App/Tests/CoreDataStorage/CoreDataStorageICloudResetTests.swift +++ b/Animal-Crossing-Wiki/Projects/App/Tests/CoreDataStorage/CoreDataStorageICloudResetTests.swift @@ -335,7 +335,7 @@ extension CoreDataStorageICloudResetTests { } ) - wait(for: [expectation], timeout: 2) + wait(for: [expectation], timeout: 10) disposable.dispose() return result ?? .failure(TestError.timeout) } From 484a13fe3b1c392f4cdd9a95ed02ff3471ff4314 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 4 Jun 2026 01:52:04 +0000 Subject: [PATCH 4/8] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20[refactor]=20PR=20#179?= =?UTF-8?q?=20=EB=A6=AC=EB=B7=B0=20=EC=BD=94=EB=A9=98=ED=8A=B8=20=EB=B0=98?= =?UTF-8?q?=EC=98=81=20=E2=80=94=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20seam=20?= =?UTF-8?q?=EA=B2=A9=EB=A6=AC=C2=B7=EC=A7=84=EB=8B=A8=C2=B7=EC=BB=A8?= =?UTF-8?q?=EB=B2=A4=EC=85=98=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 리뷰에서 타당하다고 판단한 항목을 패치한다. - AppEnvironment: isUnitTesting의 NSClassFromString("XCTestCase") fallback 제거. 테스트 스킴이 IS_UNIT_TESTING=1을 주입하므로 환경 변수만으로 판정해 릴리스 텔레메트리 무력화/향후 UI 테스트 부팅 실패 리스크를 차단. - SafetySnapshotService: 테스트 전용 beforeApplyingSnapshot seam을 #if DEBUG로 격리(production restore 죽은 코드 제거). backup 제외 실패를 Log.error로 보고. - CoreDataStorage: 동기화 영속 플래그 저장소를 주입 가능한 userDefaults로 분리해 테스트가 .standard(실제 앱 suite)를 오염시키지 않도록 함. hasEverHadUserCollection setter의 UserDefaults I/O를 lock 임계영역 밖으로 이동. completeFirstImportWait의 timedOut/waiting 갱신 순서를 fail-safe하게 조정해 TOCTOU 윈도우 제거. - Log.Snapshot에 isFirstImportTimedOut 추가(Crashlytics 진단 공백 보완), event/click의 isFirebaseConfigured 가드 스타일 통일. - logSyncDiagnostics 클로저 owner 네이밍/guard 뒤 빈 줄, #if DEBUG 및 테스트 helper extension MARK 추가, 신규 파일 작성자 헤더 정렬. - 테스트: 격리된 UserDefaults suite 주입으로 전환하고 정적 reset helper 제거. https://claude.ai/code/session_01L4kUZhiUNUtnr18SabrHzR --- .../CoreDataStorage/CoreDataStorage.swift | 77 ++++++++++++------- .../SafetySnapshotService.swift | 25 ++++-- .../App/Sources/Utility/AppEnvironment.swift | 9 ++- .../Projects/App/Sources/Utility/Log.swift | 21 +++-- .../CoreDataStorageICloudResetTests.swift | 20 +++-- 5 files changed, 101 insertions(+), 51 deletions(-) diff --git a/Animal-Crossing-Wiki/Projects/App/Sources/CoreDataStorage/CoreDataStorage.swift b/Animal-Crossing-Wiki/Projects/App/Sources/CoreDataStorage/CoreDataStorage.swift index c4210631..a9a627ce 100644 --- a/Animal-Crossing-Wiki/Projects/App/Sources/CoreDataStorage/CoreDataStorage.swift +++ b/Animal-Crossing-Wiki/Projects/App/Sources/CoreDataStorage/CoreDataStorage.swift @@ -101,19 +101,23 @@ final class CoreDataStorage { /// 한 번이라도 UserCollectionEntity가 존재했는지 여부 (UserDefaults 기반, 메모리 캐싱) /// 이 플래그가 true인데 UC가 0개면, 빈 UC 자동 생성 대신 .notFound를 throw - private let _hasEverHadUserCollectionCached = OSAllocatedUnfairLock( - initialState: UserDefaults.standard.bool(forKey: CoreDataStorage.hasEverHadUserCollectionKey) - ) + /// 초기값은 init에서 주입된 `userDefaults`로부터 읽는다. + private let _hasEverHadUserCollectionCached: OSAllocatedUnfairLock private(set) var hasEverHadUserCollection: Bool { get { _hasEverHadUserCollectionCached.withLock { $0 } } set { - _hasEverHadUserCollectionCached.withLock { cached in + // UserDefaults I/O는 unfair lock 임계영역 밖에서 수행한다 (잠재적 재진입 트랩 회피). + let didChange = _hasEverHadUserCollectionCached.withLock { cached -> Bool in guard cached != newValue else { - return + return false } cached = newValue - UserDefaults.standard.set(newValue, forKey: Self.hasEverHadUserCollectionKey) + return true + } + guard didChange else { + return } + userDefaults.set(newValue, forKey: Self.hasEverHadUserCollectionKey) } } @@ -133,25 +137,25 @@ final class CoreDataStorage { /// 복구 시작 시각 기록 — 재시작 후 grace window 계산에 사용 func markRecoveryInitiated() { - UserDefaults.standard.set(Date().timeIntervalSince1970, forKey: Self.recoveryInitiatedAtKey) + userDefaults.set(Date().timeIntervalSince1970, forKey: Self.recoveryInitiatedAtKey) Log.info("recovery initiated timestamp recorded (10min grace started)") } /// Recovery 완료 후 UC가 정상 복구되면 호출하여 플래그 정리 func clearRecoveryInitiated() { - UserDefaults.standard.removeObject(forKey: Self.recoveryInitiatedAtKey) + userDefaults.removeObject(forKey: Self.recoveryInitiatedAtKey) } /// 복구 시작 후 grace period 내인지 확인 — 상태 표시에만 사용하고, UC 생성 허용에는 사용하지 않는다. var isWithinRecoveryGracePeriod: Bool { - let timestamp = UserDefaults.standard.double(forKey: Self.recoveryInitiatedAtKey) + let timestamp = userDefaults.double(forKey: Self.recoveryInitiatedAtKey) guard timestamp > 0 else { return false } let elapsed = Date().timeIntervalSince1970 - timestamp if elapsed < 0 || elapsed > Self.recoveryGracePeriodSeconds { // 만료 시 자동 정리 - UserDefaults.standard.removeObject(forKey: Self.recoveryInitiatedAtKey) + userDefaults.removeObject(forKey: Self.recoveryInitiatedAtKey) return false } return true @@ -190,8 +194,19 @@ final class CoreDataStorage { private let injectedPersistentContainer: NSPersistentCloudKitContainer? - private init(persistentContainer: NSPersistentCloudKitContainer? = nil) { + /// 동기화 영속 플래그(known-user/recovery)의 저장소. production은 `.standard`, + /// 테스트는 격리된 suite를 주입해 실기기의 실제 앱 상태를 오염시키지 않는다. + private let userDefaults: UserDefaults + + private init( + persistentContainer: NSPersistentCloudKitContainer? = nil, + userDefaults: UserDefaults = .standard + ) { self.injectedPersistentContainer = persistentContainer + self.userDefaults = userDefaults + self._hasEverHadUserCollectionCached = OSAllocatedUnfairLock( + initialState: userDefaults.bool(forKey: Self.hasEverHadUserCollectionKey) + ) } lazy var persistentContainer: NSPersistentCloudKitContainer = { @@ -284,13 +299,16 @@ final class CoreDataStorage { /// Fresh install 첫 CloudKit import 대기 종료. /// timeout은 데이터 없음의 증거가 아니므로 UC 생성을 계속 억제한다. func completeFirstImportWait(reason: FirstImportWaitCompletionReason) { - isWaitingForFirstImport = false + // timedOut 플래그를 먼저 확정한 뒤 waiting을 해제한다. 순서를 뒤집으면 두 플래그가 + // 모두 false가 되는 찰나에 shouldSuppressDataCreation이 잠깐 풀리는 TOCTOU 윈도우가 + // 생긴다 (timeout 경로). 이 순서면 억제는 항상 fail-safe하게 유지된다. switch reason { case .importArrived, .noICloud: isFirstImportTimedOut = false case .timeout: isFirstImportTimedOut = lastSuccessfulImportDate == nil } + isWaitingForFirstImport = false Log.info("completeFirstImportWait reason=\(reason)") } @@ -466,6 +484,8 @@ final class CoreDataStorage { guard succeeded else { // Failed import means CloudKit data is still unknown. Keep waiting/timeout/reset // suppression flags so the app cannot create and export an empty UC after grace. + // _exportRetryCount is export-only (never incremented by import), so the success + // path's reset is intentionally absent here. logSyncDiagnostics(phase: "Import-failed", throttled: false) return } @@ -631,12 +651,13 @@ extension CoreDataStorage { } persistentContainer.performBackgroundTask { [weak self] context in - guard let self else { + guard let owner = self else { return } + context.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy - let counts = self.entityCounts(in: context) + let counts = owner.entityCounts(in: context) let ucCount = counts["UserCollectionEntity"] ?? -1 let itemCount = counts["ItemEntity"] ?? -1 @@ -655,14 +676,15 @@ extension CoreDataStorage { itemCount: itemCount, taskCount: counts["DailyTaskEntity"] ?? -1, villagerCount: (counts["VillagersLikeEntity"] ?? 0) + (counts["VillagersHouseEntity"] ?? 0), - hasEverHadUC: self.hasEverHadUserCollection, + hasEverHadUC: owner.hasEverHadUserCollection, isFreshInstall: nil, - isWaitingForFirstImport: self.isWaitingForFirstImport, - isImportInProgress: self.isImportInProgress, - isSyncResetInProgress: self.isSyncResetInProgress, - isWithinRecoveryGracePeriod: self.isWithinRecoveryGracePeriod, - lastImportDate: self.lastSuccessfulImportDate, - lastExportDate: self.lastSuccessfulExportDate + isWaitingForFirstImport: owner.isWaitingForFirstImport, + isFirstImportTimedOut: owner.isFirstImportTimedOut, + isImportInProgress: owner.isImportInProgress, + isSyncResetInProgress: owner.isSyncResetInProgress, + isWithinRecoveryGracePeriod: owner.isWithinRecoveryGracePeriod, + lastImportDate: owner.lastSuccessfulImportDate, + lastExportDate: owner.lastSuccessfulExportDate )) // UC가 2개 이상일 때만 상세 진단 (중복 탐지) @@ -1039,9 +1061,13 @@ extension CoreDataStorage { } #if DEBUG +// MARK: - Testing extension CoreDataStorage { - convenience init(testingPersistentContainer: NSPersistentCloudKitContainer) { - self.init(persistentContainer: testingPersistentContainer) + convenience init( + testingPersistentContainer: NSPersistentCloudKitContainer, + userDefaults: UserDefaults = .standard + ) { + self.init(persistentContainer: testingPersistentContainer, userDefaults: userDefaults) } func markImportInProgressForTesting() { @@ -1055,11 +1081,6 @@ extension CoreDataStorage { func finishCloudImportForTesting(succeeded: Bool) { finishCloudImport(succeeded: succeeded) } - - static func resetPersistentSyncFlagsForTesting() { - UserDefaults.standard.removeObject(forKey: hasEverHadUserCollectionKey) - UserDefaults.standard.removeObject(forKey: recoveryInitiatedAtKey) - } } #endif 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 7d426d01..582b9aba 100644 --- a/Animal-Crossing-Wiki/Projects/App/Sources/CoreDataStorage/SafetySnapshot/SafetySnapshotService.swift +++ b/Animal-Crossing-Wiki/Projects/App/Sources/CoreDataStorage/SafetySnapshot/SafetySnapshotService.swift @@ -52,7 +52,13 @@ final class SafetySnapshotService { private let containerProvider: () -> NSPersistentContainer private let snapshotDirectoryProvider: () -> URL? private let fileAttributeSetter: (URL, [FileAttributeKey: Any]) throws -> Void - private let beforeApplyingSnapshot: ((NSManagedObjectContext) throws -> Void)? + + #if DEBUG + /// 테스트 전용 fault-injection seam. `restore()`가 기존 데이터를 wipe한 직후, + /// 스냅샷을 적용하기 직전에 호출한다. production 빌드에는 컴파일되지 않으므로 + /// 복원 경로에 죽은 코드가 남지 않는다. + var beforeApplyingSnapshotForTesting: ((NSManagedObjectContext) throws -> Void)? + #endif init( containerProvider: @escaping () -> NSPersistentContainer = { CoreDataStorage.shared.persistentContainer }, @@ -61,13 +67,11 @@ final class SafetySnapshotService { }, fileAttributeSetter: @escaping (URL, [FileAttributeKey: Any]) throws -> Void = { url, attributes in try FileManager.default.setAttributes(attributes, ofItemAtPath: url.path) - }, - beforeApplyingSnapshot: ((NSManagedObjectContext) throws -> Void)? = nil + } ) { self.containerProvider = containerProvider self.snapshotDirectoryProvider = snapshotDirectoryProvider self.fileAttributeSetter = fileAttributeSetter - self.beforeApplyingSnapshot = beforeApplyingSnapshot } private var container: NSPersistentContainer { @@ -178,9 +182,12 @@ final class SafetySnapshotService { do { try url.setResourceValues(values) } catch { - os_log(.error, log: .default, - "🛟 SafetySnapshot resource value update failed: %{public}@", - error.localizedDescription) + // 백업 제외(isExcludedFromBackup) 회귀를 Crashlytics에서 관측할 수 있도록 비치명 에러로 보고한다. + // 파일 보호 등급은 write 옵션 + setAttributes로 이미 적용되므로 암호화 경계 자체는 유지된다. + Log.error( + name: "SafetySnapshotBackupExclusion", + reason: error.localizedDescription + ) } } @@ -204,7 +211,9 @@ final class SafetySnapshotService { let data = try Data(contentsOf: self.snapshotURL) let snapshot = try UserCollectionSnapshot.from(data: data) try Self.wipeExistingCollection(in: context) - try self.beforeApplyingSnapshot?(context) + #if DEBUG + try self.beforeApplyingSnapshotForTesting?(context) + #endif try snapshot.apply(to: context) try context.save() os_log(.error, log: .default, diff --git a/Animal-Crossing-Wiki/Projects/App/Sources/Utility/AppEnvironment.swift b/Animal-Crossing-Wiki/Projects/App/Sources/Utility/AppEnvironment.swift index 317ca04d..85cb4431 100644 --- a/Animal-Crossing-Wiki/Projects/App/Sources/Utility/AppEnvironment.swift +++ b/Animal-Crossing-Wiki/Projects/App/Sources/Utility/AppEnvironment.swift @@ -2,14 +2,19 @@ // AppEnvironment.swift // Animal-Crossing-Wiki // -// Created by Codex on 2026/06/04. +// Created by Ari on 2026/06/04. // import Foundation enum AppEnvironment { + /// 단위 테스트 실행 여부. + /// + /// 테스트 스킴(`Project.swift`)이 `IS_UNIT_TESTING=1`을 주입하므로 환경 변수만으로 판정한다. + /// `NSClassFromString("XCTestCase")` 같은 런타임 probe는 (1) XCTest를 링크하는 향후 UI 테스트에서 + /// 앱이 빈 화면으로 부팅되고, (2) 릴리스 바이너리에 XCTest가 새어 들어갈 경우 크래시 리포팅/애널리틱스가 + /// 조용히 비활성화될 수 있어 사용하지 않는다. static var isUnitTesting: Bool { ProcessInfo.processInfo.environment["IS_UNIT_TESTING"] == "1" - || NSClassFromString("XCTestCase") != nil } } diff --git a/Animal-Crossing-Wiki/Projects/App/Sources/Utility/Log.swift b/Animal-Crossing-Wiki/Projects/App/Sources/Utility/Log.swift index 3cc30a0d..b4030ad7 100644 --- a/Animal-Crossing-Wiki/Projects/App/Sources/Utility/Log.swift +++ b/Animal-Crossing-Wiki/Projects/App/Sources/Utility/Log.swift @@ -57,6 +57,7 @@ enum Log { static let hasEverHadUC = "sync_has_ever_had_uc" static let isFreshInstall = "sync_is_fresh_install" static let isWaitingForFirstImport = "sync_waiting_first_import" + static let isFirstImportTimedOut = "sync_first_import_timed_out" static let isImportInProgress = "sync_import_in_progress" static let isSyncResetInProgress = "sync_reset_in_progress" static let isWithinRecoveryGracePeriod = "sync_within_recovery_grace" @@ -159,22 +160,24 @@ enum Log { // MARK: - Analytics static func event(_ event: Event, parameters: [String: Any] = [:]) { - if isFirebaseConfigured { - Analytics.logEvent(event.rawValue, parameters: parameters) - crashlytics?.log("[EVENT] \(event.rawValue) \(parameters)") - } os_log(.info, log: .default, "📈 %{public}@", event.rawValue) + guard isFirebaseConfigured else { + return + } + Analytics.logEvent(event.rawValue, parameters: parameters) + crashlytics?.log("[EVENT] \(event.rawValue) \(parameters)") } /// 사용자 탭/클릭 추적. Firebase Analytics의 `select_content` 스키마로 기록. static func click(_ name: String, parameters: [String: Any] = [:]) { + os_log(.info, log: .default, "👆 click=%{public}@", name) + guard isFirebaseConfigured else { + return + } var params = parameters params[AnalyticsParameterItemID] = name params[AnalyticsParameterContentType] = "click" - if isFirebaseConfigured { - Analytics.logEvent(AnalyticsEventSelectContent, parameters: params) - } - os_log(.info, log: .default, "👆 click=%{public}@", name) + Analytics.logEvent(AnalyticsEventSelectContent, parameters: params) } // MARK: - Context (Crashlytics custom keys) @@ -196,6 +199,7 @@ enum Log { var hasEverHadUC: Bool var isFreshInstall: Bool? var isWaitingForFirstImport: Bool + var isFirstImportTimedOut: Bool var isImportInProgress: Bool var isSyncResetInProgress: Bool var isWithinRecoveryGracePeriod: Bool @@ -213,6 +217,7 @@ enum Log { setContext(Key.isFreshInstall, isFreshInstall) } setContext(Key.isWaitingForFirstImport, snapshot.isWaitingForFirstImport) + setContext(Key.isFirstImportTimedOut, snapshot.isFirstImportTimedOut) setContext(Key.isImportInProgress, snapshot.isImportInProgress) setContext(Key.isSyncResetInProgress, snapshot.isSyncResetInProgress) setContext(Key.isWithinRecoveryGracePeriod, snapshot.isWithinRecoveryGracePeriod) diff --git a/Animal-Crossing-Wiki/Projects/App/Tests/CoreDataStorage/CoreDataStorageICloudResetTests.swift b/Animal-Crossing-Wiki/Projects/App/Tests/CoreDataStorage/CoreDataStorageICloudResetTests.swift index fefcb1bc..004b4f13 100644 --- a/Animal-Crossing-Wiki/Projects/App/Tests/CoreDataStorage/CoreDataStorageICloudResetTests.swift +++ b/Animal-Crossing-Wiki/Projects/App/Tests/CoreDataStorage/CoreDataStorageICloudResetTests.swift @@ -5,13 +5,21 @@ import RxSwift final class CoreDataStorageICloudResetTests: XCTestCase { + /// 테스트마다 격리된 UserDefaults suite. `.standard`(실제 앱 suite)를 건드리지 않아 + /// 실기기에서 `make test`를 돌려도 production 앱의 known-user/recovery 플래그를 오염시키지 않는다. + private var testSuiteName: String! + private var testDefaults: UserDefaults! + override func setUp() { super.setUp() - CoreDataStorage.resetPersistentSyncFlagsForTesting() + testSuiteName = "CoreDataStorageICloudResetTests.\(UUID().uuidString)" + testDefaults = UserDefaults(suiteName: testSuiteName) } override func tearDown() { - CoreDataStorage.resetPersistentSyncFlagsForTesting() + testDefaults.removePersistentDomain(forName: testSuiteName) + testDefaults = nil + testSuiteName = nil super.tearDown() } @@ -209,9 +217,9 @@ final class CoreDataStorageICloudResetTests: XCTestCase { let service = SafetySnapshotService( containerProvider: { storage.persistentContainer }, - snapshotDirectoryProvider: { snapshotDirectory }, - beforeApplyingSnapshot: { _ in throw InjectedRestoreFailure.failure } + snapshotDirectoryProvider: { snapshotDirectory } ) + service.beforeApplyingSnapshotForTesting = { _ in throw InjectedRestoreFailure.failure } let expectation = expectation(description: "restore completes") service.restore { outcome in @@ -246,6 +254,8 @@ final class CoreDataStorageICloudResetTests: XCTestCase { } } +// MARK: - Helpers + extension CoreDataStorageICloudResetTests { private func makeStorage() throws -> CoreDataStorage { @@ -264,7 +274,7 @@ extension CoreDataStorageICloudResetTests { throw loadError } - return CoreDataStorage(testingPersistentContainer: container) + return CoreDataStorage(testingPersistentContainer: container, userDefaults: testDefaults) } private func makeManagedObjectModel() throws -> NSManagedObjectModel { From 621bbf00be08465c13e53d26305249d691d59c50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=B1=EC=A2=85=EC=97=B4?= Date: Thu, 11 Jun 2026 00:22:27 +0900 Subject: [PATCH 5/8] =?UTF-8?q?=F0=9F=90=9B=20[fix]=20=EB=8F=99=EA=B8=B0?= =?UTF-8?q?=ED=99=94=20=EC=9E=94=EC=A1=B4=20=EB=B2=84=EA=B7=B8=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=E2=80=94=20orphan=20cleanup=20grace=20=EA=B0=80?= =?UTF-8?q?=EB=93=9C=C2=B7recovery=20=ED=94=8C=EB=9E=98=EA=B7=B8=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC=C2=B7=EB=B3=B4=ED=98=B8=20=EB=AA=A8=EB=93=9C?= =?UTF-8?q?=20=EC=95=88=EB=82=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - cleanupOrphanedEntities가 첫 import 후 120초 grace period를 무시하고 일시적 orphan을 삭제할 수 있던 경로 차단 (isWithinGracePeriod 가드 추가) - 성공 import로 UC가 복구되면 recovery grace 타임스탬프를 정리하는 clearRecoveryInitiatedIfRecovered 연결 (기존 clearRecoveryInitiated 데드 API 해소) - 반복 sync 실패로 컬렉션 생성이 억제된 보호 모드 상태를 세션당 1회 Alert로 안내 (isAwaitingCloudDataWithoutCollection + SceneDelegate.handleCloudSyncError) - CoreDataStorage 파일 길이 한도 초과로 Data Recovery extension을 CoreDataStorage+Recovery.swift로 분리 - 회귀 테스트 6개 추가 (총 19개 통과) Co-Authored-By: Claude Fable 5 --- .../Resources/en.lproj/Localizable.strings | 1 + .../Resources/ko.lproj/Localizable.strings | 1 + .../CoreDataStorage+Recovery.swift | 95 +++++++++++++ .../CoreDataStorage/CoreDataStorage.swift | 129 ++++++------------ .../Projects/App/Sources/SceneDelegate.swift | 19 ++- .../CoreDataStorageICloudResetTests.swift | 112 +++++++++++++++ docs/features/icloud-sync.md | 9 +- 7 files changed, 274 insertions(+), 92 deletions(-) create mode 100644 Animal-Crossing-Wiki/Projects/App/Sources/CoreDataStorage/CoreDataStorage+Recovery.swift 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 d4558c83..69538471 100644 --- a/Animal-Crossing-Wiki/Projects/App/Resources/en.lproj/Localizable.strings +++ b/Animal-Crossing-Wiki/Projects/App/Resources/en.lproj/Localizable.strings @@ -98,6 +98,7 @@ Find the villagers you have visited and tap the home icon on the villager's page "iCloud is temporarily unavailable. Data will sync when available." = "iCloud is temporarily unavailable. Data will sync when available."; "iCloud status could not be determined. Data will be saved locally only." = "iCloud status could not be determined. Data will be saved locally only."; "iCloud storage is full. Please free up space to continue syncing." = "iCloud storage is full. Please free up space to continue syncing."; +"iCloud sync keeps failing. Creating new data is paused to protect your existing iCloud data. Please check your iCloud sign-in and storage." = "iCloud sync keeps failing. Creating new data is paused to protect your existing iCloud data. Please check your iCloud sign-in and storage."; // MARK: - TaskEditViewController "Today's Tasks" = "Today's Tasks"; 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 0d1b9403..d46112fd 100644 --- a/Animal-Crossing-Wiki/Projects/App/Resources/ko.lproj/Localizable.strings +++ b/Animal-Crossing-Wiki/Projects/App/Resources/ko.lproj/Localizable.strings @@ -100,6 +100,7 @@ "iCloud is temporarily unavailable. Data will sync when available." = "iCloud를 일시적으로 사용할 수 없습니다. 사용 가능해지면 동기화됩니다."; "iCloud status could not be determined. Data will be saved locally only." = "iCloud 상태를 확인할 수 없습니다. 데이터는 기기에만 저장됩니다."; "iCloud storage is full. Please free up space to continue syncing." = "iCloud 저장 공간이 가득 찼습니다. 동기화를 계속하려면 공간을 확보하세요."; +"iCloud sync keeps failing. Creating new data is paused to protect your existing iCloud data. Please check your iCloud sign-in and storage." = "iCloud 동기화가 계속 실패하고 있습니다. 기존 iCloud 데이터를 보호하기 위해 새 데이터 생성을 보류하고 있습니다. iCloud 로그인 상태와 저장 공간을 확인해 주세요."; // MARK: - TaskEditViewController "Today's Tasks" = "오늘의 할일"; diff --git a/Animal-Crossing-Wiki/Projects/App/Sources/CoreDataStorage/CoreDataStorage+Recovery.swift b/Animal-Crossing-Wiki/Projects/App/Sources/CoreDataStorage/CoreDataStorage+Recovery.swift new file mode 100644 index 00000000..7effbb41 --- /dev/null +++ b/Animal-Crossing-Wiki/Projects/App/Sources/CoreDataStorage/CoreDataStorage+Recovery.swift @@ -0,0 +1,95 @@ +// +// CoreDataStorage+Recovery.swift +// Animal-Crossing-Wiki +// +// Created by Ari on 2026/06/11. +// + +import Foundation +import CoreData +import os + +// MARK: - Data Recovery + +extension CoreDataStorage { + + /// 사용자가 설정에서 "iCloud에서 복원"을 명시적으로 눌렀을 때만 실행되는 복원 플로우. + /// 로컬 store를 삭제하고 앱 재시작 시 CloudKit에서 전체 re-import 유도. + /// 로컬 데이터가 iCloud 백업으로 완전히 대체되므로 파괴적 동작 — 2단 확인 alert 후에만 호출. + enum RecoveryError: LocalizedError { + case iCloudNotAvailable + case storeNotFound + + var errorDescription: String? { + switch self { + case .iCloudNotAvailable: return "iCloud is not available" + case .storeNotFound: return "CoreData store not found" + } + } + } + + /// 로컬 store 파일을 삭제하고 앱 재시작 시 CloudKit에서 전체 re-import 유도. + /// store를 런타임에 재등록하면 CloudKit 옵션이 누락되므로, 파일만 삭제하고 재시작을 안내한다. + func performCloudKitRecovery(completion: @escaping (Result) -> Void) { + checkiCloudAccountStatus { [weak self] status in + guard status == .available else { + completion(.failure(RecoveryError.iCloudNotAvailable)) + return + } + guard let self else { + return + } + + guard let storeDescription = self.persistentContainer.persistentStoreDescriptions.first, + let storeURL = storeDescription.url else { + completion(.failure(RecoveryError.storeNotFound)) + return + } + + do { + // 기존 store 분리 + let coordinator = self.persistentContainer.persistentStoreCoordinator + if let store = coordinator.persistentStore(for: storeURL) { + try coordinator.remove(store) + } + + // Store 파일 삭제 — fileExists 대신 직접 시도 + 부재 에러 무시 (TOCTOU 방지) + let fileManager = FileManager.default + let storePath = storeURL.path + for suffix in ["", "-shm", "-wal"] { + do { + try fileManager.removeItem(atPath: storePath + suffix) + } catch let error as NSError where error.code == NSFileNoSuchFileError { + // 파일이 이미 없음 — 정상 + } + } + + // ckAssets 폴더 삭제 + let ckAssetsURL = storeURL.deletingLastPathComponent() + .appendingPathComponent("ckAssets") + do { + try fileManager.removeItem(at: ckAssetsURL) + } catch let error as NSError where error.code == NSFileNoSuchFileError { + // 폴더가 이미 없음 — 정상 + } + + // migration flag 유지 — 재시작 시 re-export 중복 방지 + UserDefaults.standard.set(true, forKey: "didMigrateExistingDataToCloudKit_v2") + + // 기존 유저 플래그 유지 — 재시작 후 CloudKit re-import 전까지 빈 UC 생성 방지 + // (복구 = 기존 유저이므로 true 유지가 올바름) + + // Recovery grace period 시작 — 재시작 후 import 지연/실패 상태를 UI와 진단 로그에서 + // 구분하기 위한 표시용 플래그. 빈 UC 생성을 허용하지는 않는다. + self.markRecoveryInitiated() + + Log.warning("recovery: local store wiped, awaiting restart + CloudKit re-import") + Log.event(.recoveryTriggered) + completion(.success(())) + } catch { + os_log(.error, log: .default, "🔄 Recovery failed: %{public}@", error.localizedDescription) + completion(.failure(error)) + } + } + } +} diff --git a/Animal-Crossing-Wiki/Projects/App/Sources/CoreDataStorage/CoreDataStorage.swift b/Animal-Crossing-Wiki/Projects/App/Sources/CoreDataStorage/CoreDataStorage.swift index a9a627ce..7dd58b6d 100644 --- a/Animal-Crossing-Wiki/Projects/App/Sources/CoreDataStorage/CoreDataStorage.swift +++ b/Animal-Crossing-Wiki/Projects/App/Sources/CoreDataStorage/CoreDataStorage.swift @@ -146,6 +146,28 @@ final class CoreDataStorage { userDefaults.removeObject(forKey: Self.recoveryInitiatedAtKey) } + /// 성공한 import로 UC가 실제 복구되었으면 recovery grace 타임스탬프를 정리한다. + /// UC가 아직 없으면(부분 import) 타임스탬프를 유지해 진단 상태가 남도록 한다. + private func clearRecoveryInitiatedIfRecovered() { + guard isWithinRecoveryGracePeriod else { + return + } + + persistentContainer.performBackgroundTask { [weak self] context in + guard let owner = self else { + return + } + + let count = (try? context.count(for: UserCollectionEntity.fetchRequest())) ?? 0 + guard count > 0 else { + return + } + + owner.clearRecoveryInitiated() + Log.info("recovery grace cleared — UC restored by successful import") + } + } + /// 복구 시작 후 grace period 내인지 확인 — 상태 표시에만 사용하고, UC 생성 허용에는 사용하지 않는다. var isWithinRecoveryGracePeriod: Bool { let timestamp = userDefaults.double(forKey: Self.recoveryInitiatedAtKey) @@ -184,6 +206,17 @@ final class CoreDataStorage { || isWithinGracePeriod } + /// 반복되는 sync 실패로 사용자가 "빈 컬렉션" 보호 모드에 갇힌 상태인지. + /// UC가 없는데 생성이 억제 중이거나 known-user 보호로 차단된 경우 true. + /// SceneDelegate가 동기화 실패 시 보호 모드 안내 노출 여부를 판단할 때 사용한다. + var isAwaitingCloudDataWithoutCollection: Bool { + guard shouldSuppressDataCreation || hasEverHadUserCollection else { + return false + } + + return isFreshInstall() + } + // MARK: - Private API Notification Names (fragile) // These notification names are undocumented and may change without notice. // Verified working on iOS 16–18. Remove if Apple provides a public API. @@ -500,6 +533,8 @@ final class CoreDataStorage { if date == nil { date = Date() } } + clearRecoveryInitiatedIfRecovered() + let hasChanges = hasImportedChanges() logSyncDiagnostics(phase: "Import-end") @@ -843,10 +878,11 @@ extension CoreDataStorage { /// UC 관계가 nil인 고아 엔티티를 삭제 private func cleanupOrphanedEntities(in context: NSManagedObjectContext) { - // Import 또는 sync reset 진행 중에는 cleanup 건너뜀 - // — CloudKit이 relationship을 비동기로 해소하므로 일시적으로 orphan처럼 보일 수 있음 - guard !isImportInProgress, !isSyncResetInProgress else { - os_log(.info, log: .default, "🔧 Orphan cleanup skipped — sync in progress") + // Import/sync reset 진행 중이거나 import 완료 직후 grace period에는 cleanup 건너뜀 + // — CloudKit이 relationship을 비동기로 해소하므로 import가 끝난 뒤에도 + // 일시적으로 orphan처럼 보일 수 있음 (grace period가 존재하는 이유와 동일) + guard !isImportInProgress, !isSyncResetInProgress, !isWithinGracePeriod else { + os_log(.info, log: .default, "🔧 Orphan cleanup skipped — sync in progress or within grace period") return } @@ -1084,91 +1120,6 @@ extension CoreDataStorage { } #endif -// MARK: - Data Recovery - -extension CoreDataStorage { - - /// 사용자가 설정에서 "iCloud에서 복원"을 명시적으로 눌렀을 때만 실행되는 복원 플로우. - /// 로컬 store를 삭제하고 앱 재시작 시 CloudKit에서 전체 re-import 유도. - /// 로컬 데이터가 iCloud 백업으로 완전히 대체되므로 파괴적 동작 — 2단 확인 alert 후에만 호출. - enum RecoveryError: LocalizedError { - case iCloudNotAvailable - case storeNotFound - - var errorDescription: String? { - switch self { - case .iCloudNotAvailable: return "iCloud is not available" - case .storeNotFound: return "CoreData store not found" - } - } - } - - /// 로컬 store 파일을 삭제하고 앱 재시작 시 CloudKit에서 전체 re-import 유도. - /// store를 런타임에 재등록하면 CloudKit 옵션이 누락되므로, 파일만 삭제하고 재시작을 안내한다. - func performCloudKitRecovery(completion: @escaping (Result) -> Void) { - checkiCloudAccountStatus { [weak self] status in - guard status == .available else { - completion(.failure(RecoveryError.iCloudNotAvailable)) - return - } - guard let self else { - return - } - - guard let storeDescription = self.persistentContainer.persistentStoreDescriptions.first, - let storeURL = storeDescription.url else { - completion(.failure(RecoveryError.storeNotFound)) - return - } - - do { - // 기존 store 분리 - let coordinator = self.persistentContainer.persistentStoreCoordinator - if let store = coordinator.persistentStore(for: storeURL) { - try coordinator.remove(store) - } - - // Store 파일 삭제 — fileExists 대신 직접 시도 + 부재 에러 무시 (TOCTOU 방지) - let fileManager = FileManager.default - let storePath = storeURL.path - for suffix in ["", "-shm", "-wal"] { - do { - try fileManager.removeItem(atPath: storePath + suffix) - } catch let error as NSError where error.code == NSFileNoSuchFileError { - // 파일이 이미 없음 — 정상 - } - } - - // ckAssets 폴더 삭제 - let ckAssetsURL = storeURL.deletingLastPathComponent() - .appendingPathComponent("ckAssets") - do { - try fileManager.removeItem(at: ckAssetsURL) - } catch let error as NSError where error.code == NSFileNoSuchFileError { - // 폴더가 이미 없음 — 정상 - } - - // migration flag 유지 — 재시작 시 re-export 중복 방지 - UserDefaults.standard.set(true, forKey: "didMigrateExistingDataToCloudKit_v2") - - // 기존 유저 플래그 유지 — 재시작 후 CloudKit re-import 전까지 빈 UC 생성 방지 - // (복구 = 기존 유저이므로 true 유지가 올바름) - - // Recovery grace period 시작 — 재시작 후 import 지연/실패 상태를 UI와 진단 로그에서 - // 구분하기 위한 표시용 플래그. 빈 UC 생성을 허용하지는 않는다. - self.markRecoveryInitiated() - - Log.warning("recovery: local store wiped, awaiting restart + CloudKit re-import") - Log.event(.recoveryTriggered) - completion(.success(())) - } catch { - os_log(.error, log: .default, "🔄 Recovery failed: %{public}@", error.localizedDescription) - completion(.failure(error)) - } - } - } -} - private enum UCCreationPath: String { case freshUser = "fresh_user" } diff --git a/Animal-Crossing-Wiki/Projects/App/Sources/SceneDelegate.swift b/Animal-Crossing-Wiki/Projects/App/Sources/SceneDelegate.swift index d25819d6..73f1e35b 100644 --- a/Animal-Crossing-Wiki/Projects/App/Sources/SceneDelegate.swift +++ b/Animal-Crossing-Wiki/Projects/App/Sources/SceneDelegate.swift @@ -14,6 +14,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { var window: UIWindow? var appCoordinator: AppCoordinator? private var isAppSetup = false + private var hasShownSyncProtectionNotice = false private var importObserver: NSObjectProtocol? private var pendingFirstImportCompletionReason: CoreDataStorage.FirstImportWaitCompletionReason? @@ -301,6 +302,10 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { return } DispatchQueue.main.async { [weak self] in + guard let owner = self else { + return + } + let message: String switch reason { case "quota_exceeded": @@ -308,12 +313,22 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { case "not_authenticated": message = "iCloud is not signed in. Data will be saved locally only.".localized default: - return + // 반복 sync 실패로 컬렉션 생성이 보호 모드에 들어가 빈 화면이 유지되는 상태라면, + // 세션당 한 번 보호 중임을 안내한다 (조용한 빈 컬렉션 방지). + guard owner.isAppSetup, + !owner.hasShownSyncProtectionNotice, + CoreDataStorage.shared.isAwaitingCloudDataWithoutCollection else { + return + } + + owner.hasShownSyncProtectionNotice = true + // swiftlint:disable:next line_length + message = "iCloud sync keeps failing. Creating new data is paused to protect your existing iCloud data. Please check your iCloud sign-in and storage.".localized } let alert = UIAlertController(title: "iCloud".localized, message: message, preferredStyle: .alert) alert.addAction(UIAlertAction(title: "OK".localized, style: .default)) - self?.presentAlert(alert) + owner.presentAlert(alert) } } diff --git a/Animal-Crossing-Wiki/Projects/App/Tests/CoreDataStorage/CoreDataStorageICloudResetTests.swift b/Animal-Crossing-Wiki/Projects/App/Tests/CoreDataStorage/CoreDataStorageICloudResetTests.swift index 004b4f13..fbdf71d3 100644 --- a/Animal-Crossing-Wiki/Projects/App/Tests/CoreDataStorage/CoreDataStorageICloudResetTests.swift +++ b/Animal-Crossing-Wiki/Projects/App/Tests/CoreDataStorage/CoreDataStorageICloudResetTests.swift @@ -252,6 +252,93 @@ final class CoreDataStorageICloudResetTests: XCTestCase { XCTAssertEqual(try entityCount("UserCollectionEntity", in: storage), 1) XCTAssertEqual(try entityCount("DailyTaskEntity", in: storage), DailyTask.tasks.count) } + + func testManualConsolidationDuringPostImportGracePeriodKeepsTransientOrphans() throws { + let storage = try makeStorage() + let context = storage.persistentContainer.viewContext + + let userCollection = try insertUserCollection(in: context) + try insertDailyTask(in: context, linkedTo: userCollection) + try insertDailyTask(in: context, linkedTo: nil) + try context.save() + + // 성공 import 직후 — CloudKit relationship 해소 대기용 grace period 활성 + storage.finishCloudImportForTesting(succeeded: true) + + waitForManualConsolidation(of: storage) + + // grace period 내에는 일시적 orphan을 삭제하지 않아야 한다 + XCTAssertEqual(try entityCount("DailyTaskEntity", in: storage), 2) + } + + func testManualConsolidationOutsideGracePeriodStillDeletesOrphans() throws { + let storage = try makeStorage() + let context = storage.persistentContainer.viewContext + + let userCollection = try insertUserCollection(in: context) + try insertDailyTask(in: context, linkedTo: userCollection) + try insertDailyTask(in: context, linkedTo: nil) + try context.save() + + waitForManualConsolidation(of: storage) + + XCTAssertEqual(try entityCount("DailyTaskEntity", in: storage), 1) + } + + func testSuccessfulImportClearsRecoveryGraceOnceCollectionRestored() throws { + let storage = try makeStorage() + let context = storage.persistentContainer.viewContext + + try insertUserCollection(in: context) + try context.save() + storage.markRecoveryInitiated() + XCTAssertTrue(storage.isWithinRecoveryGracePeriod) + + storage.finishCloudImportForTesting(succeeded: true) + + // 정리는 background context에서 수행되므로 폴링으로 대기 + let deadline = Date().addingTimeInterval(5) + while storage.isWithinRecoveryGracePeriod && Date() < deadline { + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + } + XCTAssertFalse(storage.isWithinRecoveryGracePeriod) + } + + func testFailedImportKeepsRecoveryGrace() throws { + let storage = try makeStorage() + + storage.markRecoveryInitiated() + storage.finishCloudImportForTesting(succeeded: false) + + XCTAssertTrue(storage.isWithinRecoveryGracePeriod) + } + + func testSuccessfulImportWithoutCollectionKeepsRecoveryGrace() throws { + let storage = try makeStorage() + + storage.markRecoveryInitiated() + storage.finishCloudImportForTesting(succeeded: true) + + // UC가 복구되지 않았다면 grace 타임스탬프는 유지되어야 한다 + RunLoop.current.run(until: Date().addingTimeInterval(0.5)) + XCTAssertTrue(storage.isWithinRecoveryGracePeriod) + } + + func testSyncProtectionStateDetectsSuppressedEmptyCollection() throws { + let storage = try makeStorage() + let context = storage.persistentContainer.viewContext + + XCTAssertFalse(storage.isAwaitingCloudDataWithoutCollection) + + storage.markWaitingForFirstImport() + storage.completeFirstImportWait(reason: .timeout) + XCTAssertTrue(storage.isAwaitingCloudDataWithoutCollection) + + // UC가 생기면 더 이상 보호 모드 안내 대상이 아니다 + try insertUserCollection(in: context) + try context.save() + XCTAssertFalse(storage.isAwaitingCloudDataWithoutCollection) + } } // MARK: - Helpers @@ -305,6 +392,31 @@ extension CoreDataStorageICloudResetTests { return object } + @discardableResult + private func insertDailyTask( + in context: NSManagedObjectContext, + linkedTo userCollection: UserCollectionEntity? + ) throws -> DailyTaskEntity { + guard let object = NSEntityDescription.insertNewObject( + forEntityName: "DailyTaskEntity", + into: context + ) as? DailyTaskEntity else { + throw TestError.entityCastFailed + } + object.id = UUID() + object.name = "task" + object.setValue(userCollection, forKey: "userCollection") + return object + } + + private func waitForManualConsolidation(of storage: CoreDataStorage) { + let expectation = expectation(description: "consolidation") + storage.consolidateUserCollectionsManually { + expectation.fulfill() + } + wait(for: [expectation], timeout: 10) + } + private func userCollectionCount(in context: NSManagedObjectContext) throws -> Int { let request = UserCollectionEntity.fetchRequest() return try context.count(for: request) diff --git a/docs/features/icloud-sync.md b/docs/features/icloud-sync.md index a99e818b..bed52d6e 100644 --- a/docs/features/icloud-sync.md +++ b/docs/features/icloud-sync.md @@ -108,7 +108,12 @@ setUpUserCollection() → BehaviorRelay.accept() → UI 자동 갱신 | `.networkFailure` / `.networkUnavailable` | 로그 기록 (자동 재시도 대기) | | Export 134301 (merge error) | `retryExportAfterMergeError()` — 최대 3회 지수 백오프 재시도 (5s, 10s, 15s) | | Change Token Expired (CKError 21) | sync reset 감지 → orphan cleanup/UC 생성 억제 (아래 참조) | -| 기타 | `os_log(.error)` 기록 | +| 기타 | `os_log(.error)` 기록 + 보호 모드 안내 (아래) | + +**Sync 보호 모드 안내**: sync 실패가 반복되어 UC가 없는데 생성이 억제된 상태 +(`isAwaitingCloudDataWithoutCollection == true`)라면, `SceneDelegate.handleCloudSyncError`가 +세션당 한 번 "데이터 보호를 위해 새 데이터 생성을 보류 중" Alert를 표시한다. +사용자가 조용한 빈 컬렉션 화면에 갇히는 것을 방지하기 위한 안내. ### Change Token Expired 대응 @@ -203,6 +208,8 @@ Import 완료 후 Path-B(`setUpUserCollection`)가 재실행되어 데이터가 **Orphan Cleanup 안전장치** (`cleanupOrphanedEntities()`): - Import 또는 sync reset 진행 중에는 실행하지 않음 (relationship이 아직 해소되지 않았을 수 있음) +- 첫 Import 완료 후 grace period(120초) 내에도 실행하지 않음 — CloudKit이 relationship을 + 비동기로 해소하는 동안 일시적 orphan을 실제 orphan으로 오판해 삭제하는 것을 방지 - UC가 0개이면 실행하지 않음 (orphan 판단 기준 자체 없음) - 전체 레코드가 모두 orphan이면 삭제하지 않음 (데이터 유실 방지) - Count-first 최적화: 삭제 전 수량만 확인하여 불필요한 객체 로딩 방지 From 4cfa02615afd4b2d1fc03588bec99ff8110679d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=B1=EC=A2=85=EC=97=B4?= Date: Thu, 11 Jun 2026 00:22:27 +0900 Subject: [PATCH 6/8] =?UTF-8?q?=F0=9F=8E=A8=20[style]=20SwiftLint=20autoco?= =?UTF-8?q?rrect=20=EC=9E=90=EB=8F=99=20=EC=88=98=EC=A0=95=20=EB=B0=98?= =?UTF-8?q?=EC=98=81=20(Catalog)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 빌드의 SwiftLint autocorrect 단계가 수정한 unneeded_override 등 기계적 변경. Co-Authored-By: Claude Fable 5 --- .../Catalog/ViewControllers/ItemsViewController.swift | 4 ++-- .../Presentation/Catalog/ViewModels/ItemsReactor.swift | 2 +- .../App/Sources/Presentation/Catalog/Views/CategoryRow.swift | 4 ---- .../Sources/Presentation/Catalog/Views/ItemSeasonView.swift | 2 +- 4 files changed, 4 insertions(+), 8 deletions(-) diff --git a/Animal-Crossing-Wiki/Projects/App/Sources/Presentation/Catalog/ViewControllers/ItemsViewController.swift b/Animal-Crossing-Wiki/Projects/App/Sources/Presentation/Catalog/ViewControllers/ItemsViewController.swift index 1bd5d797..9a32c2e3 100644 --- a/Animal-Crossing-Wiki/Projects/App/Sources/Presentation/Catalog/ViewControllers/ItemsViewController.swift +++ b/Animal-Crossing-Wiki/Projects/App/Sources/Presentation/Catalog/ViewControllers/ItemsViewController.swift @@ -429,7 +429,7 @@ extension ItemsViewController { let allSelectAction = UIAction( title: Menu.allSelect.title, image: UIImage(systemName: "text.badge.checkmark") - ) { [weak self] action in + ) { [weak self] _ in guard let owner = self else { return } @@ -447,7 +447,7 @@ extension ItemsViewController { let resetAction = UIAction( title: Menu.reset.title, image: UIImage(systemName: "arrow.counterclockwise") - ) { [weak self] action in + ) { [weak self] _ in guard let owner = self else { return } diff --git a/Animal-Crossing-Wiki/Projects/App/Sources/Presentation/Catalog/ViewModels/ItemsReactor.swift b/Animal-Crossing-Wiki/Projects/App/Sources/Presentation/Catalog/ViewModels/ItemsReactor.swift index 6b3b9956..63d371d5 100644 --- a/Animal-Crossing-Wiki/Projects/App/Sources/Presentation/Catalog/ViewModels/ItemsReactor.swift +++ b/Animal-Crossing-Wiki/Projects/App/Sources/Presentation/Catalog/ViewModels/ItemsReactor.swift @@ -318,7 +318,7 @@ final class ItemsReactor: Reactor { } } - private func setUpUserItem() -> Observable<[Item]> { + private func setUpUserItem() -> Observable<[Item]> { switch mode { case .all: return Items.shared.itemList diff --git a/Animal-Crossing-Wiki/Projects/App/Sources/Presentation/Catalog/Views/CategoryRow.swift b/Animal-Crossing-Wiki/Projects/App/Sources/Presentation/Catalog/Views/CategoryRow.swift index 9c9104eb..2e3832e5 100644 --- a/Animal-Crossing-Wiki/Projects/App/Sources/Presentation/Catalog/Views/CategoryRow.swift +++ b/Animal-Crossing-Wiki/Projects/App/Sources/Presentation/Catalog/Views/CategoryRow.swift @@ -30,10 +30,6 @@ final class CategoryRow: UITableViewCell { itemCountLabel.text = nil } - override func setSelected(_ selected: Bool, animated: Bool) { - super.setSelected(selected, animated: animated) - } - func setUp(iconName: String, title: String, itemCount: Int) { iconImage.image = UIImage(named: iconName) titleLabel.text = title diff --git a/Animal-Crossing-Wiki/Projects/App/Sources/Presentation/Catalog/Views/ItemSeasonView.swift b/Animal-Crossing-Wiki/Projects/App/Sources/Presentation/Catalog/Views/ItemSeasonView.swift index 12e03d8a..49c88f9a 100644 --- a/Animal-Crossing-Wiki/Projects/App/Sources/Presentation/Catalog/Views/ItemSeasonView.swift +++ b/Animal-Crossing-Wiki/Projects/App/Sources/Presentation/Catalog/Views/ItemSeasonView.swift @@ -73,7 +73,7 @@ final class ItemSeasonView: UIView { } private func setUpCalendar(months: [Int]) { - if backgroundStackView.arrangedSubviews.last as? CalendarView != nil { + if backgroundStackView.arrangedSubviews.last is CalendarView { backgroundStackView.arrangedSubviews.last?.removeFromSuperview() } let calendarView = CalendarView(months: months) From ede23f62c25c9963bb8cd9f7339e400dbaa2cf1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=B1=EC=A2=85=EC=97=B4?= Date: Thu, 11 Jun 2026 00:39:08 +0900 Subject: [PATCH 7/8] =?UTF-8?q?=F0=9F=90=9B=20[fix]=20=EC=A0=84=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=98=81=EC=97=AD=20=EB=B2=84=EA=B7=B8=20?= =?UTF-8?q?=EC=9D=BC=EA=B4=84=20=EC=88=98=EC=A0=95=20=E2=80=94=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EC=B4=88=EA=B8=B0=ED=99=94=20=EB=88=84?= =?UTF-8?q?=EB=9D=BD=C2=B7=EB=A7=A4=ED=95=91=20=EC=98=A4=EB=A5=98=C2=B7?= =?UTF-8?q?=ED=81=AC=EB=9E=98=EC=8B=9C=C2=B7=ED=94=8C=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=96=B4=20=EC=98=B5=EC=A0=80=EB=B2=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 코드베이스 전수 조사에서 발견·검증된 버그 수정: - Items.reset()이 npcLike relay를 초기화하지 않아 데이터 초기화 후에도 NPC 좋아요 목록이 화면에 남던 문제 수정 - NPCLikeEntity.toDomain()이 genderAsia 대신 gender를 읽어 CoreData 왕복 시 genderAsia가 손실되던 매핑 오류 수정 - CloudKit 부분 import로 DailyTask progressList가 amount보다 짧아지면 toggle 시 index out of range로 크래시하던 문제 수정 — toDomain 정규화 + 모델/storage toggle 길이 보정 (자가 치유) - MusicPlayerManager: block 기반 곡 종료 옵저버가 removeObserver(self)로 제거되지 않아 누적 등록되고(자동 다음 곡 다중 실행), 특정 AVPlayerItem에 바인딩되어 곡 전환 후 알림을 못 받던 문제 수정 — 토큰 보관 + object nil + currentItem 검증, 등록 지점을 player 재생성 시점으로 일원화 - MusicPlayerManager: 잠금화면 아트워크 다운로드를 곡별 disposeBag으로 교체해 이전 곡 이미지가 늦게 도착해 now playing 정보를 덮어쓰던 문제 수정 - VillagersCellReactor/NPCCellReactor: updateLike/updateHouse 시 state를 갱신하지 않아 목록 셀의 하트/집 버튼이 시각적으로 토글되지 않던 문제 수정 - SafetySnapshotService.pendingWorkItem을 OSAllocatedUnfairLock으로 보호하고 flushNow를 전용 queue 동기 실행으로 변경 (멀티스레드 data race 해소) - AppCoordinator: AnimalsCoordinator만 setUpParent 누락되어 있던 비대칭 수정 - DashboardCoordinator가 사용하는 "Unknown error" 로컬라이제이션 키 추가 (ko/en) - 회귀 테스트 2개 추가 (progressList 손상 시나리오, 총 21개 통과) Co-Authored-By: Claude Fable 5 --- .../Resources/en.lproj/Localizable.strings | 1 + .../Resources/ko.lproj/Localizable.strings | 1 + .../Projects/App/Sources/AppCoordinator.swift | 1 + .../CoreDataDailyTaskStorage.swift | 7 +++ .../DailyTaskEntity+Mapping.swift | 14 +++++- .../SafetySnapshotService.swift | 27 +++++++--- .../EntityMapping/NPCLikeEntity+Mapping.swift | 2 +- .../App/Sources/Models/DailyTask.swift | 4 ++ .../Animals/ViewModels/NPCCellReactor.swift | 1 + .../ViewModels/VillagersCellReactor.swift | 2 + .../Projects/App/Sources/Utility/Items.swift | 1 + .../Sources/Utility/MusicPlayerManager.swift | 39 +++++++++++---- .../CoreDataStorageICloudResetTests.swift | 50 +++++++++++++++++++ 13 files changed, 129 insertions(+), 21 deletions(-) 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 From 539910770ddc43ab43df52ad751d85b5039a2321 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=B1=EC=A2=85=EC=97=B4?= Date: Thu, 11 Jun 2026 00:39:08 +0900 Subject: [PATCH 8/8] =?UTF-8?q?=F0=9F=93=9D=20[docs]=20=EB=B2=84=EA=B7=B8?= =?UTF-8?q?=20=EC=88=98=EC=A0=95=20=EA=B4=80=EB=A0=A8=20=EB=AC=B8=EC=84=9C?= =?UTF-8?q?=20=EB=8F=99=EA=B8=B0=ED=99=94=20=E2=80=94=20music-player=C2=B7?= =?UTF-8?q?dashboard=C2=B7icloud-sync?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- docs/features/dashboard.md | 4 ++++ docs/features/icloud-sync.md | 3 +++ docs/features/music-player.md | 6 ++++++ 3 files changed, 13 insertions(+) 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 정보를 덮어쓰는 것을 방지 + ## 데이터 흐름 ```