diff --git a/Application/Infra/Sources/Service/FirebaseAppServiceImpl.swift b/Application/Infra/Sources/Service/FirebaseAppServiceImpl.swift index 59e3f5b0..a7b572ee 100644 --- a/Application/Infra/Sources/Service/FirebaseAppServiceImpl.swift +++ b/Application/Infra/Sources/Service/FirebaseAppServiceImpl.swift @@ -7,14 +7,49 @@ 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 } + 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() + ) + + for action in actions { + switch action { + case .deleteUnsentReports: + crashlytics.deleteUnsentReports() + case let .setCollectionEnabled(isEnabled): + crashlytics.setCrashlyticsCollectionEnabled(isEnabled) + } + } + Self.isConfigured = true } } diff --git a/Application/Infra/Sources/Service/FirebaseCrashlyticsCollectionPolicy.swift b/Application/Infra/Sources/Service/FirebaseCrashlyticsCollectionPolicy.swift new file mode 100644 index 00000000..495d4d47 --- /dev/null +++ b/Application/Infra/Sources/Service/FirebaseCrashlyticsCollectionPolicy.swift @@ -0,0 +1,42 @@ +// +// 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 + + var shouldRemoveStoredOverride: Bool { + !isCollectionEnabled + } + + 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/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 new file mode 100644 index 00000000..90f9a7f5 --- /dev/null +++ b/Application/Infra/Tests/Service/FirebaseCrashlyticsCollectionPolicyTests.swift @@ -0,0 +1,82 @@ +// +// 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) + #expect(!policy.shouldRemoveStoredOverride) + } + + @Test("Info.plist false 값을 수집 비활성 상태로 해석한다") + func Info_plist_false_값을_수집_비활성_상태로_해석한다() { + let policy = FirebaseCrashlyticsCollectionPolicy(infoDictionaryValue: false) + + #expect(!policy.isCollectionEnabled) + #expect(policy.shouldRemoveStoredOverride) + } + + @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) + ] + ) + } +} 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] + ) + } +}