Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions Application/Infra/Sources/Service/FirebaseAppServiceImpl.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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
)
}
}
Original file line number Diff line number Diff line change
@@ -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)
]
)
}
}
Original file line number Diff line number Diff line change
@@ -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]
)
}
}
Loading