From ed2f88a85ab4528cac358a0e62146d61bc1d0896 Mon Sep 17 00:00:00 2001 From: opficdev Date: Wed, 22 Jul 2026 10:18:42 +0900 Subject: [PATCH 1/3] =?UTF-8?q?fix:=20Crashlytics=20=EC=88=98=EC=A7=91=20?= =?UTF-8?q?=EC=A0=84=ED=99=98=20=EC=A0=95=EC=B1=85=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../FirebaseCrashlyticsCollectionPolicy.swift | 38 +++++++++ ...baseCrashlyticsCollectionPolicyTests.swift | 80 +++++++++++++++++++ 2 files changed, 118 insertions(+) create mode 100644 Application/Infra/Sources/Service/FirebaseCrashlyticsCollectionPolicy.swift create mode 100644 Application/Infra/Tests/Service/FirebaseCrashlyticsCollectionPolicyTests.swift diff --git a/Application/Infra/Sources/Service/FirebaseCrashlyticsCollectionPolicy.swift b/Application/Infra/Sources/Service/FirebaseCrashlyticsCollectionPolicy.swift new file mode 100644 index 00000000..aef5e9fc --- /dev/null +++ b/Application/Infra/Sources/Service/FirebaseCrashlyticsCollectionPolicy.swift @@ -0,0 +1,38 @@ +// +// FirebaseCrashlyticsCollectionPolicy.swift +// Infra +// +// Created by opfic on 7/22/26. +// + +import Foundation + +struct FirebaseCrashlyticsCollectionPolicy { + enum Action: Equatable { + case deleteUnsentReports + case setCollectionEnabled(Bool) + } + + let isCollectionEnabled: Bool + + init(infoDictionaryValue: Any?) { + guard let value = infoDictionaryValue as? NSNumber, + CFGetTypeID(value) == CFBooleanGetTypeID() else { + isCollectionEnabled = false + return + } + + isCollectionEnabled = value.boolValue + } + + func actions(currentCollectionEnabled: Bool) -> [Action] { + var actions = [Action]() + + if !currentCollectionEnabled { + actions.append(.deleteUnsentReports) + } + actions.append(.setCollectionEnabled(isCollectionEnabled)) + + return actions + } +} diff --git a/Application/Infra/Tests/Service/FirebaseCrashlyticsCollectionPolicyTests.swift b/Application/Infra/Tests/Service/FirebaseCrashlyticsCollectionPolicyTests.swift new file mode 100644 index 00000000..efdfe278 --- /dev/null +++ b/Application/Infra/Tests/Service/FirebaseCrashlyticsCollectionPolicyTests.swift @@ -0,0 +1,80 @@ +// +// FirebaseCrashlyticsCollectionPolicyTests.swift +// InfraTests +// +// Created by opfic on 7/22/26. +// + +import Foundation +import Testing +@testable import Infra + +struct FirebaseCrashlyticsCollectionPolicyTests { + @Test("Info.plist true 값을 수집 활성 상태로 해석한다") + func Info_plist_true_값을_수집_활성_상태로_해석한다() { + let policy = FirebaseCrashlyticsCollectionPolicy(infoDictionaryValue: true) + + #expect(policy.isCollectionEnabled) + } + + @Test("Info.plist false 값을 수집 비활성 상태로 해석한다") + func Info_plist_false_값을_수집_비활성_상태로_해석한다() { + let policy = FirebaseCrashlyticsCollectionPolicy(infoDictionaryValue: false) + + #expect(!policy.isCollectionEnabled) + } + + @Test("Info.plist 값이 누락되면 수집 비활성 상태로 해석한다") + func Info_plist_값이_누락되면_수집_비활성_상태로_해석한다() { + let policy = FirebaseCrashlyticsCollectionPolicy(infoDictionaryValue: nil) + + #expect(!policy.isCollectionEnabled) + } + + @Test("Info.plist 값의 형식이 잘못되면 수집 비활성 상태로 해석한다") + func Info_plist_값의_형식이_잘못되면_수집_비활성_상태로_해석한다() { + let policy = FirebaseCrashlyticsCollectionPolicy(infoDictionaryValue: "true") + + #expect(!policy.isCollectionEnabled) + } + + @Test("Info.plist 값이 숫자이면 수집 비활성 상태로 해석한다") + func Info_plist_값이_숫자이면_수집_비활성_상태로_해석한다() { + let policy = FirebaseCrashlyticsCollectionPolicy( + infoDictionaryValue: NSNumber(value: 1) + ) + + #expect(!policy.isCollectionEnabled) + } + + @Test("현재 수집 상태가 비활성이면 보고서를 삭제한 뒤 목표 상태를 저장한다", arguments: [false, true]) + func 현재_수집_상태가_비활성이면_보고서를_삭제한_뒤_목표_상태를_저장한다( + isCollectionEnabled: Bool + ) { + let policy = FirebaseCrashlyticsCollectionPolicy( + infoDictionaryValue: isCollectionEnabled + ) + + #expect( + policy.actions(currentCollectionEnabled: false) == [ + .deleteUnsentReports, + .setCollectionEnabled(isCollectionEnabled) + ] + ) + } + + @Test("현재 수집 상태가 활성이면 목표 상태만 저장한다", arguments: [false, true]) + func 현재_수집_상태가_활성이면_목표_상태만_저장한다( + isCollectionEnabled: Bool + ) { + let policy = FirebaseCrashlyticsCollectionPolicy( + infoDictionaryValue: isCollectionEnabled + ) + + #expect( + policy.actions(currentCollectionEnabled: true) == [ + .setCollectionEnabled(isCollectionEnabled) + ] + ) + } +} From 1815ff58e8fb3c7b1462d9f52af04854de741191 Mon Sep 17 00:00:00 2001 From: opficdev Date: Wed, 22 Jul 2026 10:42:15 +0900 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20Xcode=20=EC=A7=81=EC=A0=91=20?= =?UTF-8?q?=EB=B9=8C=EB=93=9C=EC=9D=98=20=EB=AF=B8=EC=A0=84=EC=86=A1=20Cra?= =?UTF-8?q?shlytics=20=EB=B3=B4=EA=B3=A0=EC=84=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Service/FirebaseAppServiceImpl.swift | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/Application/Infra/Sources/Service/FirebaseAppServiceImpl.swift b/Application/Infra/Sources/Service/FirebaseAppServiceImpl.swift index 59e3f5b0..e0867057 100644 --- a/Application/Infra/Sources/Service/FirebaseAppServiceImpl.swift +++ b/Application/Infra/Sources/Service/FirebaseAppServiceImpl.swift @@ -7,14 +7,40 @@ import Data import FirebaseCore +import FirebaseCrashlytics +import Foundation final class FirebaseAppServiceImpl: FirebaseAppService { + private enum InfoKey { + static let crashlyticsCollectionEnabled = "FirebaseCrashlyticsCollectionEnabled" + } + private static var isConfigured = false func configure() { guard !Self.isConfigured else { return } FirebaseApp.configure() + + let crashlytics = Crashlytics.crashlytics() + let policy = FirebaseCrashlyticsCollectionPolicy( + infoDictionaryValue: Bundle.main.object( + forInfoDictionaryKey: InfoKey.crashlyticsCollectionEnabled + ) + ) + let actions = policy.actions( + currentCollectionEnabled: crashlytics.isCrashlyticsCollectionEnabled() + ) + + for action in actions { + switch action { + case .deleteUnsentReports: + crashlytics.deleteUnsentReports() + case let .setCollectionEnabled(isEnabled): + crashlytics.setCrashlyticsCollectionEnabled(isEnabled) + } + } + Self.isConfigured = true } } From 1b7b020f59f2e693fea6843a8fd304b9e0eaee09 Mon Sep 17 00:00:00 2001 From: opficdev Date: Wed, 22 Jul 2026 12:06:25 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20Crashlytics=20=EC=A0=80=EC=9E=A5=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EC=82=AC=EC=A0=84=20=EB=A7=88=EC=9D=B4?= =?UTF-8?q?=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Service/FirebaseAppServiceImpl.swift | 15 +- .../FirebaseCrashlyticsCollectionPolicy.swift | 4 + ...ticsStoredCollectionOverrideMigrator.swift | 73 ++++++++++ ...baseCrashlyticsCollectionPolicyTests.swift | 2 + ...toredCollectionOverrideMigratorTests.swift | 137 ++++++++++++++++++ 5 files changed, 228 insertions(+), 3 deletions(-) create mode 100644 Application/Infra/Sources/Service/FirebaseCrashlyticsStoredCollectionOverrideMigrator.swift create mode 100644 Application/Infra/Tests/Service/FirebaseCrashlyticsStoredCollectionOverrideMigratorTests.swift diff --git a/Application/Infra/Sources/Service/FirebaseAppServiceImpl.swift b/Application/Infra/Sources/Service/FirebaseAppServiceImpl.swift index e0867057..a7b572ee 100644 --- a/Application/Infra/Sources/Service/FirebaseAppServiceImpl.swift +++ b/Application/Infra/Sources/Service/FirebaseAppServiceImpl.swift @@ -20,14 +20,23 @@ final class FirebaseAppServiceImpl: FirebaseAppService { func configure() { guard !Self.isConfigured else { return } - FirebaseApp.configure() - - let crashlytics = Crashlytics.crashlytics() let policy = FirebaseCrashlyticsCollectionPolicy( infoDictionaryValue: Bundle.main.object( forInfoDictionaryKey: InfoKey.crashlyticsCollectionEnabled ) ) + + if policy.shouldRemoveStoredOverride { + do { + try FirebaseCrashlyticsOverrideMigrator().removeStoredOverride() + } catch { + preconditionFailure("Failed to migrate Crashlytics collection state: \(error)") + } + } + + FirebaseApp.configure() + + let crashlytics = Crashlytics.crashlytics() let actions = policy.actions( currentCollectionEnabled: crashlytics.isCrashlyticsCollectionEnabled() ) diff --git a/Application/Infra/Sources/Service/FirebaseCrashlyticsCollectionPolicy.swift b/Application/Infra/Sources/Service/FirebaseCrashlyticsCollectionPolicy.swift index aef5e9fc..495d4d47 100644 --- a/Application/Infra/Sources/Service/FirebaseCrashlyticsCollectionPolicy.swift +++ b/Application/Infra/Sources/Service/FirebaseCrashlyticsCollectionPolicy.swift @@ -15,6 +15,10 @@ struct FirebaseCrashlyticsCollectionPolicy { let isCollectionEnabled: Bool + var shouldRemoveStoredOverride: Bool { + !isCollectionEnabled + } + init(infoDictionaryValue: Any?) { guard let value = infoDictionaryValue as? NSNumber, CFGetTypeID(value) == CFBooleanGetTypeID() else { diff --git a/Application/Infra/Sources/Service/FirebaseCrashlyticsStoredCollectionOverrideMigrator.swift b/Application/Infra/Sources/Service/FirebaseCrashlyticsStoredCollectionOverrideMigrator.swift new file mode 100644 index 00000000..e713e1a6 --- /dev/null +++ b/Application/Infra/Sources/Service/FirebaseCrashlyticsStoredCollectionOverrideMigrator.swift @@ -0,0 +1,73 @@ +// +// FirebaseCrashlyticsStoredCollectionOverrideMigrator.swift +// Infra +// +// Created by opfic on 7/22/26. +// + +import Foundation + +struct FirebaseCrashlyticsOverrideMigrator { + enum MigrationError: Error { + case invalidStoredProperties + } + + private enum Storage { + static let directoryName = "com.crashlytics" + static let fileName = "CLSUserDefaults.plist" + static let collectionEnabledKey = "com.crashlytics.data_collection" + } + + private let fileManager: FileManager + private let applicationSupportDirectoryURL: URL? + + init( + fileManager: FileManager = .default, + applicationSupportDirectoryURL: URL? = nil + ) { + self.fileManager = fileManager + self.applicationSupportDirectoryURL = applicationSupportDirectoryURL + } + + func removeStoredOverride() throws { + let directoryURL: URL + if let applicationSupportDirectoryURL { + directoryURL = applicationSupportDirectoryURL + } else { + directoryURL = try fileManager.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: false + ) + } + + let fileURL = directoryURL + .appendingPathComponent(Storage.directoryName, isDirectory: true) + .appendingPathComponent(Storage.fileName, isDirectory: false) + + guard fileManager.fileExists(atPath: fileURL.path) else { return } + + let data = try Data(contentsOf: fileURL) + var format = PropertyListSerialization.PropertyListFormat.xml + guard var properties = try PropertyListSerialization.propertyList( + from: data, + options: [], + format: &format + ) as? [String: Any] else { + throw MigrationError.invalidStoredProperties + } + guard properties.removeValue(forKey: Storage.collectionEnabledKey) != nil else { return } + + let migratedData = try PropertyListSerialization.data( + fromPropertyList: properties, + format: format, + options: 0 + ) + try migratedData.write(to: fileURL, options: .atomic) + try fileManager.setAttributes( + [.protectionKey: FileProtectionType.none], + ofItemAtPath: fileURL.path + ) + } +} diff --git a/Application/Infra/Tests/Service/FirebaseCrashlyticsCollectionPolicyTests.swift b/Application/Infra/Tests/Service/FirebaseCrashlyticsCollectionPolicyTests.swift index efdfe278..90f9a7f5 100644 --- a/Application/Infra/Tests/Service/FirebaseCrashlyticsCollectionPolicyTests.swift +++ b/Application/Infra/Tests/Service/FirebaseCrashlyticsCollectionPolicyTests.swift @@ -15,6 +15,7 @@ struct FirebaseCrashlyticsCollectionPolicyTests { let policy = FirebaseCrashlyticsCollectionPolicy(infoDictionaryValue: true) #expect(policy.isCollectionEnabled) + #expect(!policy.shouldRemoveStoredOverride) } @Test("Info.plist false 값을 수집 비활성 상태로 해석한다") @@ -22,6 +23,7 @@ struct FirebaseCrashlyticsCollectionPolicyTests { let policy = FirebaseCrashlyticsCollectionPolicy(infoDictionaryValue: false) #expect(!policy.isCollectionEnabled) + #expect(policy.shouldRemoveStoredOverride) } @Test("Info.plist 값이 누락되면 수집 비활성 상태로 해석한다") diff --git a/Application/Infra/Tests/Service/FirebaseCrashlyticsStoredCollectionOverrideMigratorTests.swift b/Application/Infra/Tests/Service/FirebaseCrashlyticsStoredCollectionOverrideMigratorTests.swift new file mode 100644 index 00000000..976f17e7 --- /dev/null +++ b/Application/Infra/Tests/Service/FirebaseCrashlyticsStoredCollectionOverrideMigratorTests.swift @@ -0,0 +1,137 @@ +// +// FirebaseCrashlyticsStoredCollectionOverrideMigratorTests.swift +// InfraTests +// +// Created by opfic on 7/22/26. +// + +import Foundation +import Testing +@testable import Infra + +struct FirebaseCrashlyticsOverrideMigratorTests { + private enum Storage { + static let directoryName = "com.crashlytics" + static let fileName = "CLSUserDefaults.plist" + static let collectionEnabledKey = "com.crashlytics.data_collection" + } + + @Test("저장 파일이 없으면 아무 작업도 하지 않는다") + func 저장_파일이_없으면_아무_작업도_하지_않는다() throws { + let directoryURL = try makeTemporaryDirectoryURL() + defer { try? FileManager.default.removeItem(at: directoryURL) } + let migrator = FirebaseCrashlyticsOverrideMigrator( + applicationSupportDirectoryURL: directoryURL + ) + + try migrator.removeStoredOverride() + } + + @Test("저장된 수집 상태만 제거하고 다른 속성은 유지한다") + func 저장된_수집_상태만_제거하고_다른_속성은_유지한다() throws { + let directoryURL = try makeTemporaryDirectoryURL() + defer { try? FileManager.default.removeItem(at: directoryURL) } + let fileURL = try writeStoredProperties( + [ + Storage.collectionEnabledKey: 1, + "preserved": "value" + ], + to: directoryURL + ) + let migrator = FirebaseCrashlyticsOverrideMigrator( + applicationSupportDirectoryURL: directoryURL + ) + + try migrator.removeStoredOverride() + + let properties = try readStoredProperties(from: fileURL) + #expect(properties[Storage.collectionEnabledKey] == nil) + #expect(properties["preserved"] as? String == "value") + } + + @Test("저장된 수집 상태가 없으면 기존 속성을 유지한다") + func 저장된_수집_상태가_없으면_기존_속성을_유지한다() throws { + let directoryURL = try makeTemporaryDirectoryURL() + defer { try? FileManager.default.removeItem(at: directoryURL) } + let fileURL = try writeStoredProperties( + ["preserved": "value"], + to: directoryURL + ) + let migrator = FirebaseCrashlyticsOverrideMigrator( + applicationSupportDirectoryURL: directoryURL + ) + + try migrator.removeStoredOverride() + + let properties = try readStoredProperties(from: fileURL) + #expect(properties["preserved"] as? String == "value") + } + + @Test("저장 파일의 최상위 값이 딕셔너리가 아니면 실패한다") + func 저장_파일의_최상위_값이_딕셔너리가_아니면_실패한다() throws { + let directoryURL = try makeTemporaryDirectoryURL() + defer { try? FileManager.default.removeItem(at: directoryURL) } + let fileURL = try makeStoredPropertiesURL(in: directoryURL) + let data = try PropertyListSerialization.data( + fromPropertyList: ["value"], + format: .xml, + options: 0 + ) + try data.write(to: fileURL) + let migrator = FirebaseCrashlyticsOverrideMigrator( + applicationSupportDirectoryURL: directoryURL + ) + + #expect(throws: FirebaseCrashlyticsOverrideMigrator.MigrationError.self) { + try migrator.removeStoredOverride() + } + } +} + +private extension FirebaseCrashlyticsOverrideMigratorTests { + func makeTemporaryDirectoryURL() throws -> URL { + let directoryURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory( + at: directoryURL, + withIntermediateDirectories: true + ) + return directoryURL + } + + func makeStoredPropertiesURL(in directoryURL: URL) throws -> URL { + let crashlyticsDirectoryURL = directoryURL + .appendingPathComponent(Storage.directoryName, isDirectory: true) + try FileManager.default.createDirectory( + at: crashlyticsDirectoryURL, + withIntermediateDirectories: true + ) + return crashlyticsDirectoryURL + .appendingPathComponent(Storage.fileName, isDirectory: false) + } + + func writeStoredProperties( + _ properties: [String: Any], + to directoryURL: URL + ) throws -> URL { + let fileURL = try makeStoredPropertiesURL(in: directoryURL) + let data = try PropertyListSerialization.data( + fromPropertyList: properties, + format: .xml, + options: 0 + ) + try data.write(to: fileURL) + return fileURL + } + + func readStoredProperties(from fileURL: URL) throws -> [String: Any] { + let data = try Data(contentsOf: fileURL) + return try #require( + PropertyListSerialization.propertyList( + from: data, + options: [], + format: nil + ) as? [String: Any] + ) + } +}