Skip to content

Merge release/3.2.3 into develop#178

Merged
leeari95 merged 3 commits into
developfrom
release/3.2.3
Apr 29, 2026
Merged

Merge release/3.2.3 into develop#178
leeari95 merged 3 commits into
developfrom
release/3.2.3

Conversation

@leeari95

@leeari95 leeari95 commented Apr 22, 2026

Copy link
Copy Markdown
Owner

Summary by CodeRabbit

Release Notes: Version 3.2.3

  • New Features

    • Added automatic local backup creation with manual restore capability
    • Added manual duplicate data cleanup option
    • Enhanced iCloud recovery with two-step confirmation and explicit data-overwrite warnings
  • Improvements

    • Better protection against data loss during iCloud synchronization failures
    • Local backup metadata now visible in settings

leeari95 and others added 3 commits April 22, 2026 18:30
* ๐Ÿ› [fix] iCloud ์ž๋™ ๋ณต์›์œผ๋กœ ์ธํ•œ ๋กœ์ปฌ ๋ฐ์ดํ„ฐ ์ดˆ๊ธฐํ™” ๋ฐฉ์ง€

Change Token Expired(CKError 21) ๋ฐœ์ƒ ์‹œ NSCloudKitMirroringDelegate๊ฐ€
๋กœ์ปฌ store๋ฅผ iOS ๋‚ด๋ถ€ ๋กœ์ง์œผ๋กœ purgeํ•˜๋ฉด์„œ ์‚ฌ์šฉ์ž ์ˆ˜์ง‘ ๊ธฐ๋ก์ด ์ดˆ๊ธฐํ™”๋˜๋Š”
๋ฒ„๊ทธ๋ฅผ ์ฐจ๋‹จํ•œ๋‹ค. ์šฐ๋ฆฌ ์ฝ”๋“œ์˜ ์ž๋™ ์‚ญ์ œ ๊ฒฝ๋กœ๋ฅผ ๋ชจ๋‘ ์ œ๊ฑฐํ•˜๊ณ , iOS๊ฐ€
๋กœ์ปฌ์„ ์ง€์šฐ๋”๋ผ๋„ ์‚ด๋ฆด ์ˆ˜ ์žˆ๋„๋ก Documents์— ์•ˆ์ „ ์Šค๋ƒ…์ƒท์„ ์œ ์ง€ํ•œ๋‹ค.

## ์ž๋™ consolidation ์ œ๊ฑฐ (Stage 1)
- handleCloudKitEvent() Import ์™„๋ฃŒ ํ›„ 5์ดˆ ์ง€์—ฐ consolidation ์ œ๊ฑฐ
- SceneDelegate.setupApp() ์•ฑ ์‹œ์ž‘ ์‹œ consolidation ์ œ๊ฑฐ
- consolidateUserCollections/cleanupOrphanedEntities๋Š” ์‚ฌ์šฉ์ž๊ฐ€ ์„ค์ •์—์„œ
  "์ค‘๋ณต/๊ณ ์•„ ๋ฐ์ดํ„ฐ ์ •๋ฆฌ" ๋ฒ„ํŠผ์„ ์ง์ ‘ ๋ˆŒ๋ €์„ ๋•Œ๋งŒ ์‹คํ–‰
- ์‚ญ์ œ ์ง์ „ ๋กœ๊ทธ๋ฅผ os_log(.error)๋กœ ์Šน๊ฒฉํ•˜์—ฌ ํ”„๋กœ๋•์…˜ ๊ฐ์‚ฌ ๊ฐ€๋Šฅ

## Recovery grace period ์ถ”๊ฐ€
- performCloudKitRecovery ์‹คํ–‰ ์‹œ recoveryInitiatedAt ํƒ€์ž„์Šคํƒฌํ”„ ๊ธฐ๋ก
- getUserCollection()์—์„œ 10๋ถ„ grace window ๋‚ด์—๋Š” hasEverHadUserCollection
  ์ฒดํฌ๋ฅผ ์šฐํšŒํ•˜์—ฌ UC ์ƒ์„ฑ ํ—ˆ์šฉ โ†’ ์žฌ์‹œ์ž‘ ํ›„ import ์ง€์—ฐ๋˜์–ด๋„ ์•ฑ ์ž ๊น€ ๋ฐฉ์ง€

## ์„ค์ • ํ™”๋ฉด ๊ฐœ์„ 
- "iCloud์—์„œ ๋ณต์›" 2๋‹จ ํ™•์ธ Alert๋กœ ๊ฐ•ํ™” + ํŒŒ๊ดด์  ๋™์ž‘์ž„์„ ๋ฌธ๊ตฌ์— ๋ช…์‹œ
- "์ค‘๋ณต/๊ณ ์•„ ๋ฐ์ดํ„ฐ ์ •๋ฆฌ" ๋ฒ„ํŠผ ์‹ ๊ทœ ์ถ”๊ฐ€ (ํ™•์ธ Alert ํ›„ ์ˆ˜๋™ ์‹คํ–‰)
- "๋กœ์ปฌ ๋ฐฑ์—…์—์„œ ๋ณต์›" ๋ฒ„ํŠผ + ๋งˆ์ง€๋ง‰ ๋ฐฑ์—… ์‹œ๊ฐ/ํ•ญ๋ชฉ์ˆ˜ ํ‘œ์‹œ

## LocalSafetySnapshot (Stage 1.5)
- Documents/local_safety_snapshot.plist์— UC ๊ทธ๋ž˜ํ”„๋ฅผ attribute-dict๋กœ
  binary plist ์ง๋ ฌํ™”ํ•˜์—ฌ ์œ ์ง€ (debounce 30s, atomic write)
- ์•ฑ ์‹œ์ž‘ ์‹œ purge ์˜์‹ฌ ์ƒํ™ฉ(isFreshInstall && hasEverHadUserCollection &&
  snapshot exists) ๊ฐ์ง€๋˜๋ฉด ๋ณต์› ์˜ต์…˜ ์ž๋™ ์ œ์•ˆ
- ๋ฐฑ๊ทธ๋ผ์šด๋“œ ์ง„์ž… ์ง์ „ pending ์Šค๋ƒ…์ƒท ๊ฐ•์ œ flush
- ๋„๋ฉ”์ธ ๋ชจ๋ธ Codable ์˜์กด์„ฑ ์—†์Œ โ€” NSArray/NSDictionary transformable๋„
  plist๊ฐ€ ๊ทธ๋Œ€๋กœ ์ฒ˜๋ฆฌ

## ๋ฌธ์„œ
- docs/features/icloud-sync.md โ€” ์ž๋™ consolidation ์ œ๊ฑฐ, grace period,
  ์ˆ˜๋™ ๋ณต์› ํ”Œ๋กœ์šฐ ๋ฐ˜์˜
- docs/plans/local-backup-split.md โ€” Stage 2 2-container ์„ค๊ณ„ ๋ฐ
  migration/ํ…Œ์ŠคํŠธ ์ „๋žต ์ดˆ์•ˆ

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ๐Ÿ“ [docs] iCloud ๋ฐ์ดํ„ฐ ์ดˆ๊ธฐํ™” ๋ฒ„๊ทธ ์ˆ˜์ • ์š”์•ฝ ๋ฌธ์„œ ์ถ”๊ฐ€

๊ธฐํšยทCS ๋‹ด๋‹น์ž ๋“ฑ ๋น„๊ฐœ๋ฐœ ์ธ์›๋„ ์ดํ•ดํ•  ์ˆ˜ ์žˆ๋„๋ก
์ด๋ฒˆ ์ˆ˜์ •์˜ ๋ฐฐ๊ฒฝ/ํ•ด๊ฒฐ ๋ฐฉ์‹/QA ์ฒดํฌ๋ฆฌ์ŠคํŠธ๋ฅผ ์ •๋ฆฌ.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ๐Ÿ› [fix] SafetySnapshot์ด ์‹ค์ œ๋กœ ์ž‘์„ฑ๋˜์ง€ ์•Š๋˜ ๋ฌธ์ œ ์ˆ˜์ •

๋กœ๊ทธ ๋ถ„์„ ๊ฒฐ๊ณผ, startObserving์ด NSManagedObjectContextDidSave๋งŒ ๊ด€์ฐฐํ•˜์—ฌ
- ์‚ฌ์šฉ์ž ํŽธ์ง‘ ์—†์ด ์•ฑ์„ ์ผœ๊ณ  ๋‹ซ์€ ๊ฒฝ์šฐ ์Šค๋ƒ…์ƒท์ด ํ•œ ๋ฒˆ๋„ ์ž‘์„ฑ๋˜์ง€ ์•Š์Œ
- CloudKit Import๋กœ ๋“ค์–ด์˜จ ๋ณ€๊ฒฝ์€ merge ๊ฒฝ๋กœ๋ผ DidSave๋ฅผ ํ•ญ์ƒ ํŠธ๋ฆฌ๊ฑฐํ•˜์ง€ ์•Š์Œ

iOS purge๊ฐ€ ๋‹ค์Œ ์„ธ์…˜์— ๋ฐœ์ƒํ•˜๋ฉด Stage 1.5 ์•ˆ์ „๋ง์ด ๋น„์–ด์žˆ๋Š” ์ƒํƒœ๋กœ ์‹คํŒจํ•  ์ˆ˜ ์žˆ์Œ.

์ˆ˜์ •:
1. startObserving ์‹œ initial snapshot์„ background queue์—์„œ ์ฆ‰์‹œ 1ํšŒ ์ž‘์„ฑ
2. CoreDataStorage.didFinishCloudImport / didReceiveRemoteChanges ์•Œ๋ฆผ๋„ ๊ตฌ๋…ํ•˜์—ฌ
   CloudKit์œผ๋กœ ์ˆ˜์‹ ๋œ ๋ฐ์ดํ„ฐ๋„ ์Šค๋ƒ…์ƒท์— ๋ฐ˜์˜
3. ๊ธฐ์กด NSManagedObjectContextDidSave ๊ด€์ฐฐ์€ ์œ ์ง€ (๋กœ์ปฌ ํŽธ์ง‘ ๊ฒฝ๋กœ)

์ด๋กœ์จ ์•ฑ ์‹คํ–‰ ์งํ›„ "๐Ÿ›Ÿ SafetySnapshot written" ๋กœ๊ทธ๊ฐ€ ๋ฐ˜๋“œ์‹œ ์ฐํ˜€์•ผ ์ •์ƒ.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ๐Ÿ› [fix] SafetySnapshot serialization ์‹คํŒจ ์ˆ˜์ • + WillReset ์ฆ‰์‹œ flush

## ๋ฌธ์ œ 1: serialization ์‹คํŒจ (์‹ค๊ธฐ๊ธฐ ๋กœ๊ทธ์—์„œ ํ™•์ธ)
```
๐Ÿ›Ÿ SafetySnapshot write failed: (ACNH_wiki.SafetySnapshotError ์˜ค๋ฅ˜ 0.)
```

PropertyListSerialization์€ NSString/NSNumber/NSDate/NSData/NSArray/NSDictionary๋งŒ ํ—ˆ์šฉ.
ItemEntity์˜ ์ผ๋ถ€ Transformable(variations, recipe ๋“ฑ)์€ NSArray/NSDictionary๋กœ declared ๋˜์–ด
์žˆ์ง€๋งŒ ๋‚ด๋ถ€์— ์ปค์Šคํ…€ NSCoding DTO๋ฅผ ํฌํ•จํ•˜๋ฏ€๋กœ plist ์ง๋ ฌํ™” ๋‹จ๊ณ„์—์„œ throw.

ํ•ด๊ฒฐ: PropertyListSerialization โ†’ NSKeyedArchiver๋กœ ๊ต์ฒด.
- NSCoding์„ ์ค€์ˆ˜ํ•˜๋Š” ๋ชจ๋“  ๊ฐ์ฒด๋ฅผ ์ฒ˜๋ฆฌ (Core Data Transformable์€ ์ด๋ฏธ NSCoding ์ „์ œ).
- requiringSecureCoding=false: ์•ฑ์ด ์ž๊ธฐ ์ž์‹ ์ด ์“ด ํŒŒ์ผ๋งŒ ๋ณตํ˜ธํ™”ํ•˜๋ฏ€๋กœ ์•ˆ์ „ํ•˜๋ฉฐ,
  ๋ชจ๋“  ์ปค์Šคํ…€ DTO ํด๋ž˜์Šค๋ฅผ ํ—ˆ์šฉ ๋ชฉ๋ก์— ๋‚˜์—ดํ•  ํ•„์š” ์—†์Œ.
- ํŒŒ์ผ์€ ์‚ฌ๋žŒ์ด ์ฝ๊ธฐ ์–ด๋ ต์ง€๋งŒ ๋‚ด๋ถ€ ์•ˆ์ „๋ง ์šฉ๋„์ด๋ฏ€๋กœ ์ˆ˜์šฉ.

## ๋ฌธ์ œ 2: iOS purge ์ง์ „ ๋งˆ์ง€๋ง‰ ์ˆœ๊ฐ„ flush ๋ˆ„๋ฝ
์‹ค๊ธฐ๊ธฐ ๋กœ๊ทธ์—์„œ CKError 134301 โ†’ WillResetSync ์•Œ๋ฆผ โ†’ iOS๊ฐ€ ๋กœ์ปฌ store purge ์‹œํ€€์Šค ๊ด€์ฐฐ๋จ.

ํ•ด๊ฒฐ: SafetySnapshotService๊ฐ€ "NSCloudKitMirroringDelegateWillResetSyncNotificationName"
์•Œ๋ฆผ์„ ๊ตฌ๋… โ†’ debounce ์šฐํšŒํ•˜์—ฌ flushNow() ์ฆ‰์‹œ ์‹คํ–‰.
WillReset ์‹œ์ ์—๋Š” ์•„์ง ๋กœ์ปฌ ๋ฐ์ดํ„ฐ๊ฐ€ ์˜จ์ „ํ•˜๋ฏ€๋กœ, ์ด ์ˆœ๊ฐ„ ๋””์Šคํฌ์— ๋คํ”„ํ•˜๋ฉด
iOS purge ํ›„์—๋„ Documents/์— ๋งˆ์ง€๋ง‰ ์ •์ƒ ์ƒํƒœ๊ฐ€ ๋‚จ์Œ.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* โ™ป๏ธ [refactor] SafetySnapshot ์ค‘๋ณต ์ œ๊ฑฐ ๋ฐ ํšจ์œจํ™”

## ์ฃผ์š” ๋ณ€๊ฒฝ

- **DateFormatters.syncRelativeDate ์žฌ์‚ฌ์šฉ** (3๊ฐœ ํŒŒ์ผ ์ค‘๋ณต ์ œ๊ฑฐ):
  SceneDelegate, AppSettingReactor, AppSettingView์—์„œ RelativeDateTimeFormatter๋ฅผ
  ๋งค๋ฒˆ ์ธ์Šคํ„ด์Šคํ™”ํ•˜๋˜ ๊ฒƒ์„ ๊ธฐ์กด static ์‹ฑ๊ธ€ํ†ค์œผ๋กœ ๊ต์ฒด.

- **Metadata ๊ฒฝ๋Ÿ‰ ์บ์‹œ**: readMetadata()๊ฐ€ ๋งค๋ฒˆ 3MB+ ํŒŒ์ผ์„ unarchiveํ•˜๋˜ ๋ฌธ์ œ ์ˆ˜์ •.
  ์Šค๋ƒ…์ƒท ์ €์žฅ ์‹œ createdAt/childCount๋ฅผ UserDefaults์— ์‚ฌ์ด๋“œ์นด๋กœ ๊ธฐ๋กํ•˜๊ณ ,
  UI๋Š” ์บ์‹œ์—์„œ O(1)๋กœ ์ฝ์Œ. ํŒŒ์ผ์ด ์™ธ๋ถ€์—์„œ ์‚ญ์ œ๋˜๋ฉด ์บ์‹œ๋„ ์ •๋ฆฌ.

- **SyncResetNotification ์žฌ์‚ฌ์šฉ**: SafetySnapshotService๊ฐ€ ์‚ฌ์šฉํ•˜๋˜ raw string
  "NSCloudKitMirroringDelegateWillResetSyncNotificationName"์„ CoreDataStorage
  ๋‚ด๋ถ€ enum์„ internal๋กœ ๊ณต๊ฐœํ•œ ๋’ค ์ฐธ์กฐํ•˜๋„๋ก ๋ณ€๊ฒฝ.

- **startObserving/flushNow/restore์—์„œ container ํŒŒ๋ผ๋ฏธํ„ฐ ์ œ๊ฑฐ**:
  ํ•ญ์ƒ CoreDataStorage.shared.persistentContainer๋ฅผ ๋ฐ›๋Š” ์ผ€์ด์Šค๋ฟ์ด๋ผ,
  ์„œ๋น„์Šค ๋‚ด๋ถ€์—์„œ ์ง์ ‘ ์ ‘๊ทผ. ํ˜ธ์ถœ๋ถ€ ๊ฐ„๊ฒฐํ™”.

- **Metadata.fileSize ํ•„๋“œ ์‚ญ์ œ**: ์„ค์ •๋˜๊ธฐ๋งŒ ํ•˜๊ณ  ์‚ฌ์šฉ์ฒ˜๊ฐ€ ์—†๋˜ dead field.

- **TOCTOU ์ œ๊ฑฐ**: restore()์—์„œ snapshotExists ์„ ๊ฒ€์‚ฌ โ†’ Data(contentsOf:) ์ด์ค‘
  ํŒŒ์ผ ์ฒดํฌ๋ฅผ ์—†์• ๊ณ  ์ง์ ‘ read ํ›„ NSFileReadNoSuchFileError๋กœ .noSnapshot ๋ถ„๊ธฐ.

- **force-unwrap ์ œ๊ฑฐ**: snapshotURL์˜ `.first!`๋ฅผ guard let + fatalError๋กœ ๊ต์ฒด
  (Documents ๋””๋ ‰ํ† ๋ฆฌ ๋ถ€์žฌ๋Š” ์‚ฌ์‹ค์ƒ ๋ณต๊ตฌ ๋ถˆ๊ฐ€ ์ƒํƒœ).

- ์„œ์ˆ ํ˜• WHAT-comment ์ •๋ฆฌ: "์ž๋™ consolidation ์ œ๊ฑฐ๋จ" ๋“ฑ ์ฝ”๋“œ๊ฐ€ ์ž๋ช…ํ•˜๊ฒŒ
  ๋ณด์—ฌ์ฃผ๋Š” ๋‚ด์šฉ์€ ์ปค๋ฐ‹ ํžˆ์Šคํ† ๋ฆฌ๋กœ ์˜ฎ๊ธฐ๊ณ  ์ฃผ์„์€ WHY๋งŒ ๋‚จ๊น€.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
3.2.0 ์ดํ›„ "๋กœ์ปฌ ๋ฐ์ดํ„ฐ๊ฐ€ ์ดˆ๊ธฐํ™”๋˜์—ˆ๋‹ค"๋Š” ํด๋ ˆ์ž„์„ ์›๊ฒฉ์—์„œ ์ถ”์ ํ•˜๊ธฐ ์œ„ํ•ด
Firebase Crashlytics + Analytics ํ†ตํ•ฉ ๋ž˜ํผ Log ๋„์ž….

- Utility/Log.swift: 3-way fan-out ๋กœ๊น… (os_log / Crashlytics breadcrumb / Analytics)
  verboseยทdebugยทinfoยทwarningยทerror ๋ ˆ๋ฒจ + eventยทclick ์ง‘๊ณ„ ์ด๋ฒคํŠธ + snapshot
- CoreDataStorage 6๊ฐœ ๊ฒฐ์ • ์ง€์  instrument:
  recovery / orphan cleanup / UC ํ†ตํ•ฉ / token expired / UC missing / UC ์ƒ์„ฑ ์–ต์ œ
- logSyncDiagnostics + captureTelemetrySnapshot ํ†ตํ•ฉ (์ค‘๋ณต fetch ์ œ๊ฑฐ)
- SuppressionReasonยทUCCreationPath enum์œผ๋กœ stringly-typed ํŒŒ๋ผ๋ฏธํ„ฐ ์ œ๊ฑฐ
- docs/features/icloud-sync.md ์— Remote Telemetry ์„น์…˜ ์ถ”๊ฐ€

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@leeari95 leeari95 changed the title Merge release/3.2.2 into develop Merge release/3.2.3 into develop Apr 22, 2026
@coderabbitai

coderabbitai Bot commented Apr 22, 2026

Copy link
Copy Markdown
๐Ÿ“ Walkthrough

Walkthrough

This PR implements a comprehensive local backup and recovery system to mitigate iCloud data loss. It introduces persistent local snapshots of user collections, a 10-minute recovery grace period, manual consolidation controls, enhanced centralized logging via Firebase/Crashlytics, and updated UI flows for snapshot-based restoration alongside improved iCloud recovery confirmations.

Changes

Cohort / File(s) Summary
Configuration & Version
Animal-Crossing-Wiki/Configurations/TargetVersion.xcconfig
Updated target version from 3.2.2 to 3.2.3.
Localization Strings
Animal-Crossing-Wiki/Projects/App/Resources/en.lproj/Localizable.strings, Animal-Crossing-Wiki/Projects/App/Resources/ko.lproj/Localizable.strings
Expanded recovery/restore UI messaging; added labels for iCloud recovery (with local data overwrite warning), local backup detection/restoration flow, duplicate data cleanup, and confirmation prompts. Updated existing recovery strings to explicitly describe destructiveness and irreversibility.
Core Data Storage
Animal-Crossing-Wiki/Projects/App/Sources/CoreDataStorage/CoreDataStorage.swift
Added recovery grace period tracking via UserDefaults (markRecoveryInitiated, clearRecoveryInitiated, isWithinRecoveryGracePeriod); refactored logging to use centralized Log API; modified UC creation during grace window; removed automatic post-import consolidation call; added manual consolidation entry point consolidateUserCollectionsManually; promoted orphan cleanup logging; extended logSyncDiagnostics with throttling parameter and Crashlytics snapshot integration.
Safety Snapshot Service
Animal-Crossing-Wiki/Projects/App/Sources/CoreDataStorage/SafetySnapshot/SafetySnapshotService.swift
New service managing persistent local backup snapshots (local_safety_snapshot.plist). Provides snapshot lifecycle management via startObserving/stopObserving, debounced writes on context save/CloudKit import/remote change, metadata caching, and destructive restore flow with outcome reporting.
Snapshot Serialization
Animal-Crossing-Wiki/Projects/App/Sources/CoreDataStorage/SafetySnapshot/UserCollectionSnapshot.swift
New model for snapshot data serialization/deserialization via NSKeyedArchiver. Implements dump(from:) to extract Core Data entities and apply(to:) to recreate them. Enforces version compatibility and handles missing user collection errors.
Logging Utility
Animal-Crossing-Wiki/Projects/App/Sources/Utility/Log.swift
New centralized logging enum with 3-way fan-out: os_log, Firebase Crashlytics (non-fatal errors/breadcrumbs), and Analytics (with log-level filtering). Adds typed namespaces for events (Log.Event), parameters (Log.Param), Crashlytics keys (Log.Key), and snapshot telemetry via Log.Snapshot.
Presentation Layer
Animal-Crossing-Wiki/Projects/App/Sources/Presentation/Dashboard/Coordinator/DashboardCoordinator.swift, Animal-Crossing-Wiki/Projects/App/Sources/Presentation/Dashboard/ViewModels/AppSettingReactor.swift, Animal-Crossing-Wiki/Projects/App/Sources/Presentation/Dashboard/Views/AppSettingView.swift
Updated recovery UI flows: added showLocalRestoreResult alert; extended reactor with new actions/mutations/state for manual consolidation and local backup metadata; added UI sections for backup info, duplicate cleanup, and local restore; wired activity indicators and metadata display bindings.
App Initialization
Animal-Crossing-Wiki/Projects/App/Sources/SceneDelegate.swift
Integrated SafetySnapshotService observation on app setup; added on-launch detection of missing collections with local backup present (prompts user for restore/wait choice); added restoration handling with UI feedback; added flushNow() call before background task to persist snapshots on suspend.
Documentation
docs/features/icloud-sync.md, docs/iCloud-data-loss-fix-summary.md, docs/plans/local-backup-split.md
Documented iCloud recovery workflow with grace period and 2-step confirmation; added manual consolidation section; added Firebase telemetry integration details; added new non-developer summary of the data loss fix with scenario coverage table and QA checklist; added Stage 2 architecture design for dual-store persistence split.

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant App as App Startup
    participant CoreData as CoreDataStorage
    participant Snapshot as SafetySnapshotService
    participant UI as Alert Controller

    App->>CoreData: Check hasEverHadUserCollection & isFreshInstall
    App->>Snapshot: readMetadata()
    Snapshot-->>App: Metadata (if snapshot exists)
    
    alt Snapshot found & data missing
        App->>UI: Show "Restore from local or wait for iCloud?"
        User->>UI: Choose restore or wait
        
        alt User chooses restore
            App->>Snapshot: restore(completion:)
            Snapshot->>CoreData: Load snapshot, wipe entities, apply data
            Snapshot->>CoreData: Save context
            Snapshot-->>App: RestoreOutcome.success(count)
            App->>UI: Show "Restore complete"
            App->>App: Refresh user collection
        else User chooses wait
            App->>CoreData: markRecoveryInitiated()
            App->>CoreData: Grace period active (10 min)
        end
    else Snapshot not found
        App->>App: Continue normal startup
    end
Loading

Estimated code review effort

๐ŸŽฏ 5 (Critical) | โฑ๏ธ ~110 minutes

Possibly related PRs

  • #171: Modifies core recovery-related code paths and resources across CoreDataStorage, settings UI, and localization strings for iCloud recovery flows.
  • #156: Modifies CoreDataStorage, SceneDelegate, localization, and iCloud sync workflows for consolidation and import handling.
  • #174: Updates the same iCloud recovery/sync code paths, Core Data storage, and TARGET_VERSION configuration.

Suggested labels

feature, fix

Poem

๐Ÿฐ A snapshot saved, a backup blessed,
Recovery grace puts minds at rest,
When iCloud clouds fade into night,
Local whispers bring data light,
Consolidate, restore, and thriveโ€”
The warren's collections stay alive! โœจ

๐Ÿšฅ Pre-merge checks | โœ… 3 | โŒ 2

โŒ Failed checks (2 warnings)

Check name Status Explanation Resolution
Description check โš ๏ธ Warning The PR description is entirely empty with no content provided, failing to meet the repository's required template structure and content guidelines. Add a comprehensive description following the template, including issue reference, work summary, work type classification, and completion checklist.
Docstring Coverage โš ๏ธ Warning Docstring coverage is 39.13% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
โœ… Passed checks (3 passed)
Check name Status Explanation
Title check โœ… Passed The PR title accurately describes the merge operation from release/3.2.3 to develop branch, which aligns with the version bump in the changeset.
Linked Issues check โœ… Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check โœ… Passed Check skipped because no linked issues were found for this pull request.

โœ๏ธ Tip: You can configure your own custom pre-merge checks in the settings.

โœจ Finishing Touches
๐Ÿ“ Generate docstrings
  • Create stacked PR
  • Commit on current branch
๐Ÿงช Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch release/3.2.3

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

โค๏ธ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

Caution

Some comments are outside the diff and canโ€™t be posted inline due to platform limitations.

โš ๏ธ Outside diff range comments (1)
Animal-Crossing-Wiki/Projects/App/Sources/SceneDelegate.swift (1)

301-320: โš ๏ธ Potential issue | ๐ŸŸ  Major

Start the background task before flushing the snapshot.

flushNow() performs synchronous snapshot dump/serialization/disk I/O, but it currently runs on the main thread before beginBackgroundTask; a slow snapshot can block background entry and leave no extended time for the write.

โฑ๏ธ Proposed ordering fix
     func sceneDidEnterBackground(_ scene: UIScene) {
         ToastManager.shared.dismiss()
-
-        // Stage 1.5: ๋ฐฑ๊ทธ๋ผ์šด๋“œ ์ง„์ž… ์ง์ „ pending ์Šค๋ƒ…์ƒท์„ ๊ฐ•์ œ flush
-        SafetySnapshotService.shared.flushNow()
 
         // Extend execution time for pending CloudKit sync operations (import/export)
         var backgroundTaskID: UIBackgroundTaskIdentifier = .invalid
         let endTask = {
@@
         }
         backgroundTaskID = UIApplication.shared.beginBackgroundTask(expirationHandler: endTask)
+
+        // Stage 1.5: ๋ฐฑ๊ทธ๋ผ์šด๋“œ ์ง„์ž… ์ง์ „ pending ์Šค๋ƒ…์ƒท์„ ๊ฐ•์ œ flush
+        DispatchQueue.global(qos: .utility).async {
+            SafetySnapshotService.shared.flushNow()
+        }
 
         DispatchQueue.main.asyncAfter(deadline: .now() + 30.0) {
             endTask()
         }
๐Ÿค– Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Animal-Crossing-Wiki/Projects/App/Sources/SceneDelegate.swift` around lines
301 - 320, In sceneDidEnterBackground, start the background task before calling
SafetySnapshotService.shared.flushNow() so the synchronous disk I/O is covered
by the extended execution time: move the beginBackgroundTask(...) call (and
setting of backgroundTaskID and endTask closure) above the flushNow()
invocation, keep the endTask closure and the DispatchQueue.main.asyncAfter(...
+30s) termination logic intact, and ensure backgroundTaskID is ended via
UIApplication.shared.endBackgroundTask(backgroundTaskID) in the same endTask
closure after flush completes or on the timeout.
๐Ÿงน Nitpick comments (2)
Animal-Crossing-Wiki/Projects/App/Sources/Presentation/Dashboard/Coordinator/DashboardCoordinator.swift (1)

162-170: Apply the SwiftLint conditional-return style.

Line 164 triggers conditional_returns_on_newline; expand the guard body.

๐Ÿงน Proposed lint fix
         DispatchQueue.main.async { [weak self] in
-            guard let currentVC = self?.rootViewController.visibleViewController else { return }
+            guard let currentVC = self?.rootViewController.visibleViewController else {
+                return
+            }
             let title = success ? "Restore complete".localized : "Restore failed".localized

As per coding guidelines, **/*.swift: SwiftLint ์„ค์ •์— ๋”ฐ๋ผ ๋ฆฐํŠธ ๊ฒ€์‚ฌ๋ฅผ ์ˆ˜ํ–‰ํ•˜๊ณ , ์ž๋™ ์ˆ˜์ • ๊ฐ€๋Šฅํ•œ ๋ฌธ์ œ๋Š” swiftlint --config .swiftlint.yml --fix๋กœ ์ฒ˜๋ฆฌ.

๐Ÿค– Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@Animal-Crossing-Wiki/Projects/App/Sources/Presentation/Dashboard/Coordinator/DashboardCoordinator.swift`
around lines 162 - 170, The guard in showLocalRestoreResult is written as a
single-line conditional which triggers SwiftLint's
conditional_returns_on_newline; rewrite the guard binding to a multi-line form
so the else { return } is on its own line and then place the alert
creation/presentation code inside the scope that follows the guard (use the
existing currentVC constant from
self?.rootViewController.visibleViewController), e.g. expand the guard let
currentVC = self?.rootViewController.visibleViewController into a multi-line
guard and move the ensuing alert construction and currentVC.present(...) into
the guarded block.
Animal-Crossing-Wiki/Projects/App/Sources/Presentation/Dashboard/ViewModels/AppSettingReactor.swift (1)

81-134: Inconsistent [weak self] capture in chained flatMap closures.

In .recoverFromCloud, .consolidateManually, and .restoreLocalBackup, the outer flatMap uses [weak self] but the inner flatMap (that kicks off performRecovery() / performConsolidation() / performLocalRestore()) captures self implicitly (strong). Functionally fine because these operations are short-lived, but the mixed capture style is easy to misread as a leak, and any future refactor that moves these helpers elsewhere could regress into an actual cycle.

โ™ป๏ธ Proposed tweak for consistency
-                .flatMap { confirmed -> Observable<Mutation> in
-                    guard confirmed else { return .empty() }
+                .flatMap { [weak self] confirmed -> Observable<Mutation> in
+                    guard let self, confirmed else { return .empty() }
                     return Observable.concat(
                         .just(.setRecoveryInProgress(true)),
                         self.performRecovery()
                     )
                 }

Apply the same pattern to the .consolidateManually (line 103) and .restoreLocalBackup (line 127) chains.

๐Ÿค– Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@Animal-Crossing-Wiki/Projects/App/Sources/Presentation/Dashboard/ViewModels/AppSettingReactor.swift`
around lines 81 - 134, The chained flatMap closures for .consolidateManually and
.restoreLocalBackup currently capture self strongly in the inner flatMap that
returns
Observable.concat(self.performConsolidation()/self.performLocalRestore()),
creating an inconsistent capture pattern versus .recoverFromCloud; update those
inner flatMap closures to use a weak self capture (e.g., flatMap { [weak self]
confirmed -> Observable<Mutation> in guard let self = self, confirmed else {
return .empty() } ... }) and call self.performConsolidation() /
self.performLocalRestore() so the capture style matches the .recoverFromCloud
chain and avoids accidental retains.
๐Ÿค– Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@Animal-Crossing-Wiki/Projects/App/Sources/CoreDataStorage/SafetySnapshot/SafetySnapshotService.swift`:
- Around line 99-127: pendingWorkItem is read/written from multiple threads
causing races; funnel all accesses through the serial queue. Change
scheduleSnapshot() to perform the cancel, create the DispatchWorkItem, assign
pendingWorkItem and call queue.asyncAfter from inside queue.async (or
queue.async { ... }) so the assignment happens on queue; change flushNow() to
dispatch to queue (use queue.sync if you need synchronous flush) to cancel/clear
pendingWorkItem and then call writeSnapshotNow() on that queue; and change
stopObserving() to dispatch the pendingWorkItem cancel/clear to queue as well
(leave NotificationCenter removals on the caller thread if desired). Ensure all
references to pendingWorkItem, scheduleSnapshot(), flushNow(), stopObserving(),
and writeSnapshotNow() mutate state only on queue.

In `@Animal-Crossing-Wiki/Projects/App/Sources/SceneDelegate.swift`:
- Around line 66-74: The current implementation of
offerSafetySnapshotRestoreIfNeeded() reads metadata then waits 2 seconds before
calling presentSafetySnapshotPrompt(metadata:), but CloudKit imports can
complete during that delay; update the closure to re-check the purge conditions
before presenting: inside the DispatchQueue.main.asyncAfter block, re-evaluate
CoreDataStorage.shared.hasEverHadUserCollection and
CoreDataStorage.shared.isFreshInstall() (and optionally re-read
SafetySnapshotService.shared.readMetadata()) and only call
presentSafetySnapshotPrompt(metadata:) if those checks still pass; keep the
initial early-return checks but add this secondary validation to avoid offering
a destructive restore over newly imported data.

In `@Animal-Crossing-Wiki/Projects/App/Sources/Utility/Log.swift`:
- Around line 88-138: The Analytics calls currently send free-form text
(Param.message in emit(level:symbol:osLogType:message:sendToAnalytics:) and
Param.reason in error(name:reason:userInfo:)), which must be removed; update
emit(...) so Analytics.logEvent("log_\(level)", parameters: []) or omit
parameters entirely (do not include Param.message), and update error(...) to
call Analytics.logEvent("log_error", parameters: [Param.errorName:
truncate(name)]) only (remove Param.reason), ensuring crashlytics.record and
os_log behavior remain unchanged.

In `@docs/features/icloud-sync.md`:
- Around line 227-262: Update the stale sentence that claims
consolidateUserCollections() runs automatically on app start/after import;
replace or delete it so the doc states automatic consolidation was removed and
consolidation now runs only via
CoreDataStorage.consolidateUserCollectionsManually(completion:), and mention
that previous automatic callers (CoreDataStorage.handleCloudKitEvent() import
path and SceneDelegate.setupApp()) were removed due to the bug.

In `@docs/iCloud-data-loss-fix-summary.md`:
- Around line 175-180: The release docs state the target version is `3.2.4` but
the build config `TargetVersion.xcconfig` sets TARGET_VERSION = 3.2.3 and the PR
branch is `release/3.2.3`; reconcile them by either updating the docs section
that lists "๋Œ€์ƒ ๋ฒ„์ „" to `3.2.3` to match `TARGET_VERSION` and the branch, or
change `TARGET_VERSION` in `TargetVersion.xcconfig` to `3.2.4` (and ensure the
PR/branch is updated accordingly) so the documented target, the config variable
TARGET_VERSION, and the source branch (`release/3.2.3`) are consistent.

---

Outside diff comments:
In `@Animal-Crossing-Wiki/Projects/App/Sources/SceneDelegate.swift`:
- Around line 301-320: In sceneDidEnterBackground, start the background task
before calling SafetySnapshotService.shared.flushNow() so the synchronous disk
I/O is covered by the extended execution time: move the beginBackgroundTask(...)
call (and setting of backgroundTaskID and endTask closure) above the flushNow()
invocation, keep the endTask closure and the DispatchQueue.main.asyncAfter(...
+30s) termination logic intact, and ensure backgroundTaskID is ended via
UIApplication.shared.endBackgroundTask(backgroundTaskID) in the same endTask
closure after flush completes or on the timeout.

---

Nitpick comments:
In
`@Animal-Crossing-Wiki/Projects/App/Sources/Presentation/Dashboard/Coordinator/DashboardCoordinator.swift`:
- Around line 162-170: The guard in showLocalRestoreResult is written as a
single-line conditional which triggers SwiftLint's
conditional_returns_on_newline; rewrite the guard binding to a multi-line form
so the else { return } is on its own line and then place the alert
creation/presentation code inside the scope that follows the guard (use the
existing currentVC constant from
self?.rootViewController.visibleViewController), e.g. expand the guard let
currentVC = self?.rootViewController.visibleViewController into a multi-line
guard and move the ensuing alert construction and currentVC.present(...) into
the guarded block.

In
`@Animal-Crossing-Wiki/Projects/App/Sources/Presentation/Dashboard/ViewModels/AppSettingReactor.swift`:
- Around line 81-134: The chained flatMap closures for .consolidateManually and
.restoreLocalBackup currently capture self strongly in the inner flatMap that
returns
Observable.concat(self.performConsolidation()/self.performLocalRestore()),
creating an inconsistent capture pattern versus .recoverFromCloud; update those
inner flatMap closures to use a weak self capture (e.g., flatMap { [weak self]
confirmed -> Observable<Mutation> in guard let self = self, confirmed else {
return .empty() } ... }) and call self.performConsolidation() /
self.performLocalRestore() so the capture style matches the .recoverFromCloud
chain and avoids accidental retains.
๐Ÿช„ Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

โ„น๏ธ Review info
โš™๏ธ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 72d0453f-c98f-48bd-8103-2f4c36c0357a

๐Ÿ“ฅ Commits

Reviewing files that changed from the base of the PR and between 1dccf7e and 65d8fe6.

๐Ÿ“’ Files selected for processing (14)
  • Animal-Crossing-Wiki/Configurations/TargetVersion.xcconfig
  • Animal-Crossing-Wiki/Projects/App/Resources/en.lproj/Localizable.strings
  • Animal-Crossing-Wiki/Projects/App/Resources/ko.lproj/Localizable.strings
  • Animal-Crossing-Wiki/Projects/App/Sources/CoreDataStorage/CoreDataStorage.swift
  • Animal-Crossing-Wiki/Projects/App/Sources/CoreDataStorage/SafetySnapshot/SafetySnapshotService.swift
  • Animal-Crossing-Wiki/Projects/App/Sources/CoreDataStorage/SafetySnapshot/UserCollectionSnapshot.swift
  • Animal-Crossing-Wiki/Projects/App/Sources/Presentation/Dashboard/Coordinator/DashboardCoordinator.swift
  • Animal-Crossing-Wiki/Projects/App/Sources/Presentation/Dashboard/ViewModels/AppSettingReactor.swift
  • Animal-Crossing-Wiki/Projects/App/Sources/Presentation/Dashboard/Views/AppSettingView.swift
  • Animal-Crossing-Wiki/Projects/App/Sources/SceneDelegate.swift
  • Animal-Crossing-Wiki/Projects/App/Sources/Utility/Log.swift
  • docs/features/icloud-sync.md
  • docs/iCloud-data-loss-fix-summary.md
  • docs/plans/local-backup-split.md

Comment on lines +99 to +127
queue.async { [weak self] in
self?.writeSnapshotNow()
}
}

func stopObserving() {
for observer in observers {
NotificationCenter.default.removeObserver(observer)
}
observers.removeAll()
pendingWorkItem?.cancel()
pendingWorkItem = nil
}

private func scheduleSnapshot() {
pendingWorkItem?.cancel()
let workItem = DispatchWorkItem { [weak self] in
self?.writeSnapshotNow()
}
pendingWorkItem = workItem
queue.asyncAfter(deadline: .now() + Self.debounceSeconds, execute: workItem)
}

/// ๊ฐ•์ œ ์ €์žฅ โ€” ์•ฑ ์ข…๋ฃŒ ์ง์ „/sync-reset ์ง์ „ ๋“ฑ์—์„œ flushing ์šฉ๋„.
func flushNow() {
pendingWorkItem?.cancel()
pendingWorkItem = nil
writeSnapshotNow()
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

โš ๏ธ Potential issue | ๐ŸŸ  Major

Data race on pendingWorkItem across threads.

pendingWorkItem is mutated from multiple threads without synchronization:

  • scheduleSnapshot() is invoked inside NotificationCenter callbacks registered with queue: nil, so the closure runs on the posting thread (Core Data save โ†’ background context thread; CloudKit events โ†’ internal CloudKit thread). It does pendingWorkItem?.cancel() then pendingWorkItem = workItem.
  • flushNow() is called from SceneDelegate (main thread) per the PR summary.
  • stopObserving() also mutates it.
  • The initial queue.async { self?.writeSnapshotNow() } at line 99 runs on queue but doesn't touch pendingWorkItem.

Concurrent cancel/assign from different threads is undefined behavior (torn writes, double-cancel, lost cancellation). Serialize via the existing queue (or a dedicated lock) for every access.

๐Ÿ”’ Proposed fix โ€” funnel all state mutations through the serial queue
     private func scheduleSnapshot() {
-        pendingWorkItem?.cancel()
-        let workItem = DispatchWorkItem { [weak self] in
-            self?.writeSnapshotNow()
-        }
-        pendingWorkItem = workItem
-        queue.asyncAfter(deadline: .now() + Self.debounceSeconds, execute: workItem)
+        queue.async { [weak self] in
+            guard let self else { return }
+            self.pendingWorkItem?.cancel()
+            let workItem = DispatchWorkItem { [weak self] in
+                self?.writeSnapshotNow()
+            }
+            self.pendingWorkItem = workItem
+            self.queue.asyncAfter(deadline: .now() + Self.debounceSeconds, execute: workItem)
+        }
     }

     func flushNow() {
-        pendingWorkItem?.cancel()
-        pendingWorkItem = nil
-        writeSnapshotNow()
+        queue.sync {
+            self.pendingWorkItem?.cancel()
+            self.pendingWorkItem = nil
+        }
+        queue.async { [weak self] in self?.writeSnapshotNow() }
     }

stopObserving() should similarly dispatch the cancel onto queue.

๐Ÿค– Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@Animal-Crossing-Wiki/Projects/App/Sources/CoreDataStorage/SafetySnapshot/SafetySnapshotService.swift`
around lines 99 - 127, pendingWorkItem is read/written from multiple threads
causing races; funnel all accesses through the serial queue. Change
scheduleSnapshot() to perform the cancel, create the DispatchWorkItem, assign
pendingWorkItem and call queue.asyncAfter from inside queue.async (or
queue.async { ... }) so the assignment happens on queue; change flushNow() to
dispatch to queue (use queue.sync if you need synchronous flush) to cancel/clear
pendingWorkItem and then call writeSnapshotNow() on that queue; and change
stopObserving() to dispatch the pendingWorkItem cancel/clear to queue as well
(leave NotificationCenter removals on the caller thread if desired). Ensure all
references to pendingWorkItem, scheduleSnapshot(), flushNow(), stopObserving(),
and writeSnapshotNow() mutate state only on queue.

Comment on lines +66 to +74
private func offerSafetySnapshotRestoreIfNeeded() {
guard CoreDataStorage.shared.hasEverHadUserCollection,
CoreDataStorage.shared.isFreshInstall(),
let metadata = SafetySnapshotService.shared.readMetadata() else {
return
}
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
self?.presentSafetySnapshotPrompt(metadata: metadata)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

โš ๏ธ Potential issue | ๐ŸŸ  Major

Re-check the purge condition before showing the restore prompt.

CloudKit import can finish during the 2-second delay, making the local store non-empty; this prompt would still offer a destructive local restore over newly imported data.

๐Ÿ›ก๏ธ Proposed re-validation
         DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
-            self?.presentSafetySnapshotPrompt(metadata: metadata)
+            guard CoreDataStorage.shared.hasEverHadUserCollection,
+                  CoreDataStorage.shared.isFreshInstall(),
+                  let latestMetadata = SafetySnapshotService.shared.readMetadata() else {
+                return
+            }
+            self?.presentSafetySnapshotPrompt(metadata: latestMetadata)
         }
๐Ÿค– Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Animal-Crossing-Wiki/Projects/App/Sources/SceneDelegate.swift` around lines
66 - 74, The current implementation of offerSafetySnapshotRestoreIfNeeded()
reads metadata then waits 2 seconds before calling
presentSafetySnapshotPrompt(metadata:), but CloudKit imports can complete during
that delay; update the closure to re-check the purge conditions before
presenting: inside the DispatchQueue.main.asyncAfter block, re-evaluate
CoreDataStorage.shared.hasEverHadUserCollection and
CoreDataStorage.shared.isFreshInstall() (and optionally re-read
SafetySnapshotService.shared.readMetadata()) and only call
presentSafetySnapshotPrompt(metadata:) if those checks still pass; keep the
initial early-return checks but add this secondary validation to avoid offering
a destructive restore over newly imported data.

Comment on lines +88 to +138
private static func emit(level: String, symbol: String, osLogType: OSLogType, message: String, sendToAnalytics: Bool) {
crashlytics.log("[\(level.uppercased())] \(message)")
os_log(osLogType, log: .default, "%{public}@ %{public}@", symbol, message)
guard sendToAnalytics else {
return
}
Analytics.logEvent("log_\(level)", parameters: [Param.message: truncate(message)])
}

static func verbose(_ message: String) {
#if DEBUG
emit(level: "verbose", symbol: "๐Ÿ”", osLogType: .debug, message: message, sendToAnalytics: true)
#else
emit(level: "verbose", symbol: "๐Ÿ”", osLogType: .debug, message: message, sendToAnalytics: false)
#endif
}

static func debug(_ message: String) {
#if DEBUG
emit(level: "debug", symbol: "๐Ÿ›", osLogType: .debug, message: message, sendToAnalytics: true)
#else
emit(level: "debug", symbol: "๐Ÿ›", osLogType: .debug, message: message, sendToAnalytics: false)
#endif
}

static func info(_ message: String) {
emit(level: "info", symbol: "โ„น๏ธ", osLogType: .info, message: message, sendToAnalytics: true)
}

static func warning(_ message: String) {
emit(level: "warning", symbol: "โš ๏ธ", osLogType: .error, message: message, sendToAnalytics: true)
}

// MARK: - Non-fatal Error

/// Crashlytics ๋น„์น˜๋ช… ์—๋Ÿฌ ์—…๋กœ๋“œ. ์„ธ์…˜์˜ breadcrumb + custom keys๊ฐ€ ํ•จ๊ป˜ ์ „์†ก๋œ๋‹ค.
/// ์‚ฌ์šฉ์ž ํด๋ ˆ์ž„ ์ถ”์ ์˜ ํ•ต์‹ฌ ์ง„์ž…์ .
static func error(
name: String,
reason: String,
userInfo: [String: Any] = [:]
) {
var info = userInfo
info[NSLocalizedDescriptionKey] = reason
let nsError = NSError(domain: "Log.\(name)", code: 0, userInfo: info)
crashlytics.record(error: nsError)
os_log(.error, log: .default, "โ—๏ธ non-fatal: %{public}@ โ€” %{public}@", name, reason)
Analytics.logEvent("log_error", parameters: [
Param.errorName: truncate(name),
Param.reason: truncate(reason)
])

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

โš ๏ธ Potential issue | ๐ŸŸ  Major

๐Ÿงฉ Analysis chain

๐ŸŒ Web query:

Firebase Analytics iOS best practices for event parameters: PII, user-generated content, and high-cardinality free-form log messages

๐Ÿ’ก Result:

Firebase Analytics iOS best practices for event parameters emphasize using recommended events and parameters for optimal reporting, registering custom parameters/dimensions, and avoiding issues with PII, user-generated content, and high-cardinality data. PII (Personally Identifiable Information): Do not log PII such as names, emails, phone numbers, or identifiers that can uniquely identify users in event parameters. Firebase collects device identifiers like IDFA (with consent) or app-instance ID automatically, but developers must ensure custom parameters exclude PII to comply with privacy regulations like GDPR/CCPA and avoid violations. Use anonymized or aggregated data instead. User-generated content: Avoid logging raw user-generated content (e.g., full text messages, usernames) as parameters due to PII risks and high cardinality. Truncate or hash if necessary, but prefer categorizing (e.g., "content_type": "image") over full content. Examples from docs show limited use like "name" or "full_text" only for non-sensitive recommended events. High-cardinality free-form log messages: Avoid high-cardinality parameters (>500 unique values/day, e.g., unique error messages, user IDs, paths) as they cause data sampling, "(other)" rows in reports, and hit row limits (50,000 cardinality limit). Use low-cardinality categorizations (e.g., "error_type": "network" instead of full message). For detailed logs/errors, use Firebase Crashlytics instead of Analytics events. General best practices: - Up to 25 parameters per event; names <=40 chars (alphanumeric + _, start with letter), values strings <=100 chars. - Use recommended parameters (e.g., value, currency) and register customs in console for reporting. - Prefix events logically (e.g., "home_event_select_book"). - Set user properties (up to 25) for stable attributes like "user_type", not high-cardinality or PII. - iOS-specific: Disable IDFV/IDFA collection via plist if needed; use setUserProperty for non-PII properties. Implement via Analytics.logEvent(name, parameters: [...]) in Swift.

Citations:


๐Ÿ Script executed:

# Check the full Log.swift file to see the complete context and confirm the snippet
cat -n Projects/App/Sources/Utility/Log.swift

Repository: leeari95/ACNH-wiki

Length of output: 132


๐Ÿ Script executed:

fd -name "Log.swift" -type f

Repository: leeari95/ACNH-wiki

Length of output: 292


๐Ÿ Script executed:

fd "Log.swift" --type f

Repository: leeari95/ACNH-wiki

Length of output: 121


๐Ÿ Script executed:

cat -n Animal-Crossing-Wiki/Projects/App/Sources/Utility/Log.swift

Repository: leeari95/ACNH-wiki

Length of output: 9142


Don't send free-form log bodies to Firebase Analyticsโ€”they are high-cardinality and risk data quality issues.

Line 94 (emit()) and lines 136โ€“137 (error()) send arbitrary message and reason strings to Analytics. These can include dynamic Core Data/CloudKit details or user-derived content, causing high-cardinality pollution (>500 unique values/day) and exceeding quotas. Firebase best practices mandate using Crashlytics for detailed diagnostics and keeping Analytics events to low-cardinality, stable identifiers. Truncation does not solve the cardinality problem.

Remove Param.message from emit() and Param.reason from error(); keep Param.errorName only as a stable error type identifier.

๐Ÿค– Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Animal-Crossing-Wiki/Projects/App/Sources/Utility/Log.swift` around lines 88
- 138, The Analytics calls currently send free-form text (Param.message in
emit(level:symbol:osLogType:message:sendToAnalytics:) and Param.reason in
error(name:reason:userInfo:)), which must be removed; update emit(...) so
Analytics.logEvent("log_\(level)", parameters: []) or omit parameters entirely
(do not include Param.message), and update error(...) to call
Analytics.logEvent("log_error", parameters: [Param.errorName: truncate(name)])
only (remove Param.reason), ensuring crashlytics.record and os_log behavior
remain unchanged.

Comment on lines +227 to +262
## Data Recovery (์ˆ˜๋™ ๋ณต์›)

์„ค์ • ํ™”๋ฉด์—์„œ "iCloud์—์„œ ๋ฐ์ดํ„ฐ ๋ณต๊ตฌ" ๊ธฐ๋Šฅ ์ œ๊ณต. ์•ˆ์ •ํ™” ํ›„ ์ œ๊ฑฐ ์˜ˆ์ •.
์„ค์ • ํ™”๋ฉด์—์„œ "iCloud์—์„œ ๋ณต์› (๋กœ์ปฌ ๋ฐ์ดํ„ฐ ๋ฎ์–ด์”€)" ๊ธฐ๋Šฅ ์ œ๊ณต. 3.2.4๋ถ€ํ„ฐ ์ •์‹ ๊ธฐ๋Šฅ์œผ๋กœ ์Šน๊ฒฉ.
**ํŒŒ๊ดด์  ๋™์ž‘** โ€” 2๋‹จ ํ™•์ธ Alert ํ›„์—๋งŒ ์‹คํ–‰๋จ.

**๋™์ž‘ ์›๋ฆฌ**:
1. iCloud ๊ณ„์ • ํ™•์ธ โ†’ store coordinator์—์„œ ๊ธฐ์กด store ๋ถ„๋ฆฌ
2. SQLite ํŒŒ์ผ (.sqlite, -shm, -wal) + ckAssets ํด๋” ์‚ญ์ œ
3. ์•ฑ ์ข…๋ฃŒ (`exit(0)`) โ†’ ์žฌ์‹œ์ž‘ ์‹œ `loadPersistentStores`๊ฐ€ ๋นˆ store ์ƒ์„ฑ
4. `NSPersistentCloudKitContainer`๊ฐ€ CloudKit์—์„œ ์ „์ฒด ๋ฐ์ดํ„ฐ ์ž๋™ import
3. `recoveryInitiatedAt` ํƒ€์ž„์Šคํƒฌํ”„ ๊ธฐ๋ก (10๋ถ„ grace period)
4. ์•ฑ ์ข…๋ฃŒ (`exit(0)`) โ†’ ์žฌ์‹œ์ž‘ ์‹œ `loadPersistentStores`๊ฐ€ ๋นˆ store ์ƒ์„ฑ
5. `NSPersistentCloudKitContainer`๊ฐ€ CloudKit์—์„œ ์ „์ฒด ๋ฐ์ดํ„ฐ ์ž๋™ import

**Recovery Grace Period (10๋ถ„)**:
- ์žฌ์‹œ์ž‘ ํ›„ CloudKit import๊ฐ€ ์ง€์—ฐ๋˜๊ฑฐ๋‚˜ ์‹คํŒจํ•ด๋„ ์•ฑ์ด ์‚ฌ์šฉ ๊ฐ€๋Šฅ ์ƒํƒœ๊ฐ€ ๋˜๋„๋ก ๋ณด์žฅ
- `getUserCollection()`์—์„œ `hasEverHadUserCollection == true`์ด๋”๋ผ๋„ grace ๊ธฐ๊ฐ„ ๋‚ด์—๋Š” UC ์‹ ๊ทœ ์ƒ์„ฑ ํ—ˆ์šฉ
- 10๋ถ„ ๊ฒฝ๊ณผ ๋˜๋Š” ์ •์ƒ import ์™„๋ฃŒ ์‹œ ํ”Œ๋ž˜๊ทธ ์ž๋™ ์ •๋ฆฌ

**๊ด€๋ จ ํŒŒ์ผ** (๋ชจ๋‘ `// TEMPORARY: Recovery` ์ฃผ์„):
- `CoreDataStorage.performCloudKitRecovery()`, `RecoveryError`
- `AppSettingReactor` โ€” `.recoverFromCloud` Action, `.setRecoveryInProgress` Mutation
**๊ด€๋ จ ํŒŒ์ผ**:
- `CoreDataStorage.performCloudKitRecovery()`, `RecoveryError`, `markRecoveryInitiated`, `isWithinRecoveryGracePeriod`
- `AppSettingReactor` โ€” `.recoverFromCloud` Action (2๋‹จ ํ™•์ธ), `.setRecoveryInProgress` Mutation
- `AppSettingView` โ€” ๋ณต๊ตฌ ๋ฒ„ํŠผ + ActivityIndicator
- `DashboardCoordinator.showRecoveryResultAlert()`
- `Localizable.strings` (ko/en) โ€” ๋ณต๊ตฌ ๊ด€๋ จ ๋ฌธ์ž์—ด 6๊ฐœ
- `Localizable.strings` (ko/en) โ€” ๋ณต๊ตฌ ๊ด€๋ จ ๋ฌธ์ž์—ด

## Manual Consolidation (์ค‘๋ณต/๊ณ ์•„ ๋ฐ์ดํ„ฐ ์ •๋ฆฌ)

**3.2.4๋ถ€ํ„ฐ ์ž๋™ consolidation ์ œ๊ฑฐ๋จ** โ€” ๋กœ์ปฌ ๋ฐ์ดํ„ฐ๊ฐ€ ์˜๋„์น˜ ์•Š๊ฒŒ ์‚ญ์ œ๋˜๋Š” ๋ฒ„๊ทธ๋กœ ์ธํ•ด,
์‚ฌ์šฉ์ž๊ฐ€ ์„ค์ •์—์„œ "์ค‘๋ณต/๊ณ ์•„ ๋ฐ์ดํ„ฐ ์ •๋ฆฌ" ๋ฒ„ํŠผ์„ ์ง์ ‘ ๋ˆŒ๋ €์„ ๋•Œ๋งŒ ์‹คํ–‰.

**์ œ๊ฑฐ๋œ ์ž๋™ ํ˜ธ์ถœ๋ถ€**:
- ~~`CoreDataStorage.handleCloudKitEvent()` Import ์™„๋ฃŒ ํ›„ 5์ดˆ ์ง€์—ฐ~~
- ~~`SceneDelegate.setupApp()` ์•ฑ ์‹œ์ž‘ ์‹œ~~

**์ˆ˜๋™ ํ˜ธ์ถœ**:
- `CoreDataStorage.consolidateUserCollectionsManually(completion:)` โ€” ์„ค์ • ๋ฒ„ํŠผ์—์„œ๋งŒ ํ˜ธ์ถœ
- `consolidateUserCollections()` (๊ธฐ์กด ํ•จ์ˆ˜)๋Š” ์œ ์ง€ํ•˜๋˜ ์ž๋™ ํ˜ธ์ถœ์ฒ˜ ์—†์Œ

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

โš ๏ธ Potential issue | ๐ŸŸก Minor

๐Ÿงฉ Analysis chain

๐Ÿ Script executed:

#!/bin/bash
# Verify whether exit(0) / kill are invoked as part of the recovery flow
rg -nP --type=swift -C3 '\bexit\s*\(\s*0\s*\)'
rg -nP --type=swift -C3 'showRecoveryResultAlert\s*\('

Repository: leeari95/ACNH-wiki

Length of output: 3615


๐Ÿ Script executed:

#!/bin/bash
# View the context around line 190 to verify the stale consolidation description
sed -n '185,195p' docs/features/icloud-sync.md

Repository: leeari95/ACNH-wiki

Length of output: 704


๐Ÿ Script executed:

#!/bin/bash
# Check CoreDataStorage.swift around lines 410-411 for consolidation removal
sed -n '405,420p' Animal-Crossing-Wiki/Projects/App/Sources/CoreDataStorage/CoreDataStorage.swift

Repository: leeari95/ACNH-wiki

Length of output: 640


Remove stale automatic consolidation description at line 190.

The line still reads: "consolidateUserCollections() โ€” ์•ฑ ์‹œ์ž‘/Import ์™„๋ฃŒ ์‹œ ์ž๋™ ์‹คํ–‰ (5์ดˆ ์ง€์—ฐ, DispatchWorkItem์œผ๋กœ ์ค‘๋ณต ๋ฐฉ์ง€)". This contradicts the new 3.2.4 note and the Manual Consolidation section (lines 253โ€“262) which correctly state that automatic calls were removed. CoreDataStorage.swift confirms the removal with the comment: "Import ํ›„ ์ž๋™ consolidation/orphan cleanup์ด ๋กœ์ปฌ ๋ฐ์ดํ„ฐ๋ฅผ ์‚ญ์ œํ•˜๋Š” ๋ฒ„๊ทธ๋กœ ์ œ๊ฑฐ๋จ". Update or delete this line to reflect manual-only invocation.

๐Ÿค– Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/features/icloud-sync.md` around lines 227 - 262, Update the stale
sentence that claims consolidateUserCollections() runs automatically on app
start/after import; replace or delete it so the doc states automatic
consolidation was removed and consolidation now runs only via
CoreDataStorage.consolidateUserCollectionsManually(completion:), and mention
that previous automatic callers (CoreDataStorage.handleCloudKitEvent() import
path and SceneDelegate.setupApp()) were removed due to the bug.

Comment on lines +175 to +180
## ๐Ÿ“ฆ ์ด๋ฒˆ ๋ฆด๋ฆฌ์Šค ์ •๋ณด

- **๋ธŒ๋žœ์น˜**: `fix/icloud-auto-restore-data-loss`
- **๊ธฐ๋ฐ˜ ๋ฒ„์ „**: `release/3.2.3`
- **๋Œ€์ƒ ๋ฒ„์ „**: `3.2.4` ์˜ˆ์ •
- **์ปค๋ฐ‹**: `๐Ÿ› [fix] iCloud ์ž๋™ ๋ณต์›์œผ๋กœ ์ธํ•œ ๋กœ์ปฌ ๋ฐ์ดํ„ฐ ์ดˆ๊ธฐํ™” ๋ฐฉ์ง€`

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

โš ๏ธ Potential issue | ๐ŸŸก Minor

Align the documented release version with the actual target config.

This section says the target version is 3.2.4, but TargetVersion.xcconfig now sets TARGET_VERSION = 3.2.3 and the PR source branch is release/3.2.3. Please update either the docs or the version config before release.

๐Ÿค– Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/iCloud-data-loss-fix-summary.md` around lines 175 - 180, The release
docs state the target version is `3.2.4` but the build config
`TargetVersion.xcconfig` sets TARGET_VERSION = 3.2.3 and the PR branch is
`release/3.2.3`; reconcile them by either updating the docs section that lists
"๋Œ€์ƒ ๋ฒ„์ „" to `3.2.3` to match `TARGET_VERSION` and the branch, or change
`TARGET_VERSION` in `TargetVersion.xcconfig` to `3.2.4` (and ensure the
PR/branch is updated accordingly) so the documented target, the config variable
TARGET_VERSION, and the source branch (`release/3.2.3`) are consistent.

@leeari95
leeari95 merged commit d96c712 into develop Apr 29, 2026
12 of 14 checks passed
@leeari95
leeari95 deleted the release/3.2.3 branch April 29, 2026 13:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant