diff --git a/Sources/MetaRouter/identity/IdentityStorage.swift b/Sources/MetaRouter/identity/IdentityStorage.swift index 4cf33f6..499bd83 100644 --- a/Sources/MetaRouter/identity/IdentityStorage.swift +++ b/Sources/MetaRouter/identity/IdentityStorage.swift @@ -38,5 +38,16 @@ public struct IdentityStorage: @unchecked Sendable { remove(.groupId) remove(.advertisingId) } + + /// Returns `true` if any identity field is currently persisted. + /// Used by `LifecycleEventEmitter` to differentiate a true fresh install + /// (no identity, no lifecycle storage) from an existing user upgrading from + /// a pre-lifecycle SDK build (identity present, no lifecycle storage). + public func hasAnyValue() -> Bool { + return get(.anonymousId) != nil + || get(.userId) != nil + || get(.groupId) != nil + || get(.advertisingId) != nil + } } diff --git a/Sources/MetaRouter/lifecycle/AppForegroundState.swift b/Sources/MetaRouter/lifecycle/AppForegroundState.swift new file mode 100644 index 0000000..0d4efa0 --- /dev/null +++ b/Sources/MetaRouter/lifecycle/AppForegroundState.swift @@ -0,0 +1,11 @@ +import Foundation + +/// Process-level foreground state used to gate cold-launch and resume emits +/// in the lifecycle subsystem. Mirrors `UIApplication.State` (and the AppKit +/// equivalent) but is platform-neutral so non-UI code can pass it across +/// actor / Task boundaries. +public enum AppForegroundState: Sendable, Equatable { + case active + case inactive + case background +} diff --git a/Sources/MetaRouter/types/EventContext.swift b/Sources/MetaRouter/types/EventContext.swift index dd58f21..250efa7 100644 --- a/Sources/MetaRouter/types/EventContext.swift +++ b/Sources/MetaRouter/types/EventContext.swift @@ -1,7 +1,9 @@ import Foundation -/// App-specific context information -public struct AppContext: Codable, Sendable { +/// App-specific context information. Cached once at SDK init from the bundle +/// (see `fromBundle`) — `Bundle.main.infoDictionary` is OS-loaded at process +/// start and immutable, so the cached value is stable for the SDK lifetime. +public struct AppContext: Codable, Sendable, Equatable { public let name: String public let version: String public let build: String @@ -13,6 +15,19 @@ public struct AppContext: Codable, Sendable { self.build = build self.namespace = namespace } + + public static func fromBundle(_ bundle: Bundle = .main) -> AppContext { + let info = bundle.infoDictionary ?? [:] + let name = (info["CFBundleDisplayName"] as? String) + ?? (info["CFBundleName"] as? String) + ?? "Unknown" + return AppContext( + name: name, + version: info["CFBundleShortVersionString"] as? String ?? "unknown", + build: info["CFBundleVersion"] as? String ?? "unknown", + namespace: bundle.bundleIdentifier ?? "unknown" + ) + } } /// Device-specific context information diff --git a/Sources/MetaRouter/utils/LifecycleStorage.swift b/Sources/MetaRouter/utils/LifecycleStorage.swift new file mode 100644 index 0000000..e1e06a5 --- /dev/null +++ b/Sources/MetaRouter/utils/LifecycleStorage.swift @@ -0,0 +1,49 @@ +import Foundation + +/// Keys for persisting the last-seen application version/build in UserDefaults. +/// These keys live in a namespace separate from `IdentityStorageKey` so they +/// are unaffected by `IdentityStorage.clear()` (and therefore by `reset()`). +public enum LifecycleStorageKey: String { + case version = "metarouter:lifecycle:version" + case build = "metarouter:lifecycle:build" +} + +/// Persists the last application version/build the SDK observed on cold launch. +/// Used by `LifecycleEventEmitter` to decide whether to emit `Application Installed` +/// or `Application Updated`. +/// +/// Storage is kept intentionally separate from `IdentityStorage` so a user's +/// `reset()` call cannot wipe install/update state. +public struct LifecycleStorage: @unchecked Sendable { + private let userDefaults: UserDefaults + + public init(userDefaults: UserDefaults = .standard) { + self.userDefaults = userDefaults + } + + public func getVersion() -> String? { + return userDefaults.string(forKey: LifecycleStorageKey.version.rawValue) + } + + public func getBuild() -> String? { + return userDefaults.string(forKey: LifecycleStorageKey.build.rawValue) + } + + /// Version and build are persisted together to keep them in lockstep — the + /// lifecycle emitter treats `(version, build)` as a pair, so independent + /// setters would let the two halves drift and trigger spurious + /// `Application Updated` events on the next cold launch. + public func setVersionBuild(version: String, build: String) { + userDefaults.set(version, forKey: LifecycleStorageKey.version.rawValue) + userDefaults.set(build, forKey: LifecycleStorageKey.build.rawValue) + } + + /// Removes the persisted version and build. Test-only seam — production code + /// must never call this. The whole point of the `metarouter:lifecycle:*` + /// namespace separation is that nothing — not even `reset()` — can wipe + /// install/update state. + internal func clear() { + userDefaults.removeObject(forKey: LifecycleStorageKey.version.rawValue) + userDefaults.removeObject(forKey: LifecycleStorageKey.build.rawValue) + } +} diff --git a/Tests/MetaRouterTests/IdentityStorageTests.swift b/Tests/MetaRouterTests/IdentityStorageTests.swift index 5e344a3..4538c3a 100644 --- a/Tests/MetaRouterTests/IdentityStorageTests.swift +++ b/Tests/MetaRouterTests/IdentityStorageTests.swift @@ -177,8 +177,21 @@ final class IdentityStorageTests: XCTestCase { func testSetUnicodeCharacters() { let unicodeString = "用户-123-🎉" storage.set(.userId, value: unicodeString) - + XCTAssertEqual(storage.get(.userId), unicodeString) } + + func testHasAnyValueDetectsAnyKey() { + XCTAssertFalse(storage.hasAnyValue()) + + storage.set(.anonymousId, value: "abc") + XCTAssertTrue(storage.hasAnyValue()) + + storage.clear() + XCTAssertFalse(storage.hasAnyValue()) + + storage.set(.userId, value: "u") + XCTAssertTrue(storage.hasAnyValue()) + } } diff --git a/Tests/MetaRouterTests/LifecycleStorageTests.swift b/Tests/MetaRouterTests/LifecycleStorageTests.swift new file mode 100644 index 0000000..1b1e4ee --- /dev/null +++ b/Tests/MetaRouterTests/LifecycleStorageTests.swift @@ -0,0 +1,69 @@ +import XCTest +@testable import MetaRouter + +final class LifecycleStorageTests: XCTestCase { + private var suiteName: String! + private var defaults: UserDefaults! + + override func setUp() { + super.setUp() + suiteName = "com.metarouter.test.lifecycleStorage.\(UUID().uuidString)" + defaults = UserDefaults(suiteName: suiteName) + } + + override func tearDown() { + defaults.removePersistentDomain(forName: suiteName) + defaults = nil + suiteName = nil + super.tearDown() + } + + func testRoundTripVersionAndBuild() { + let storage = LifecycleStorage(userDefaults: defaults) + + XCTAssertNil(storage.getVersion()) + XCTAssertNil(storage.getBuild()) + + storage.setVersionBuild(version: "1.5.0", build: "42") + + XCTAssertEqual(storage.getVersion(), "1.5.0") + XCTAssertEqual(storage.getBuild(), "42") + } + + func testClearRemovesBothKeys() { + let storage = LifecycleStorage(userDefaults: defaults) + storage.setVersionBuild(version: "1.0", build: "1") + storage.clear() + + XCTAssertNil(storage.getVersion()) + XCTAssertNil(storage.getBuild()) + } + + /// Lifecycle storage uses the `metarouter:lifecycle:*` key prefix and is + /// NOT enumerated by `IdentityStorage.clear()`. This is the structural + /// guarantee that `reset()` cannot wipe install/update state. + func testIdentityStorageClearDoesNotTouchLifecycleKeys() { + // Seed both stores on the same backing UserDefaults + let identityStorage = IdentityStorage(userDefaults: defaults) + identityStorage.set(.anonymousId, value: "abc") + identityStorage.set(.userId, value: "user-1") + + let lifecycleStorage = LifecycleStorage(userDefaults: defaults) + lifecycleStorage.setVersionBuild(version: "1.5.0", build: "42") + + // Clearing identity must not touch lifecycle + identityStorage.clear() + + XCTAssertNil(identityStorage.get(.anonymousId), "identity cleared") + XCTAssertNil(identityStorage.get(.userId), "identity cleared") + XCTAssertEqual(lifecycleStorage.getVersion(), "1.5.0", + "lifecycle storage must survive IdentityStorage.clear()") + XCTAssertEqual(lifecycleStorage.getBuild(), "42", + "lifecycle storage must survive IdentityStorage.clear()") + } + + func testKeysUseExpectedNamespace() { + XCTAssertEqual(LifecycleStorageKey.version.rawValue, "metarouter:lifecycle:version") + XCTAssertEqual(LifecycleStorageKey.build.rawValue, "metarouter:lifecycle:build") + } +}