From 31063e3a02420e1760c769666c22d0b0de48391d Mon Sep 17 00:00:00 2001 From: Christopher Houdlette Date: Mon, 27 Apr 2026 12:05:23 -0600 Subject: [PATCH 1/3] feat(lifecycle): storage + bundle metadata foundation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 1 of 4 of the iOS lifecycle events feature (parent: sc-36764). Adds the storage layer and shared types that the upcoming emitter / wiring slices depend on. Pure additions — no behavior change to AnalyticsClient, no events emitted yet. - LifecycleStorage: UserDefaults wrapper for (version, build) under the metarouter:lifecycle:* namespace, separate from identity keys so reset() cannot wipe install/update state. - IdentityStorage.hasAnyValue(): helper for the emitter (slice 2) to snapshot identity presence at construction time, before IdentityManager.initialize() auto-creates an anonymousId. - AppContext: gains Equatable + fromBundle(_:) — single source of truth for app metadata, replacing the per-event Bundle.main.infoDictionary reads that DeviceContextProvider does today (consumed in slice 3). - AppForegroundState enum: platform-neutral active/inactive/background trichotomy used by the emitter and coordinator. Ticket: sc-38228 Parent: sc-36764 Stack: this PR -> sc-38229 -> sc-38230 -> sc-38231 --- .../MetaRouter/identity/IdentityStorage.swift | 11 +++ .../lifecycle/AppForegroundState.swift | 11 +++ Sources/MetaRouter/types/EventContext.swift | 19 +++- .../MetaRouter/utils/LifecycleStorage.swift | 50 ++++++++++ .../LifecycleStorageTests.swift | 92 +++++++++++++++++++ 5 files changed, 181 insertions(+), 2 deletions(-) create mode 100644 Sources/MetaRouter/lifecycle/AppForegroundState.swift create mode 100644 Sources/MetaRouter/utils/LifecycleStorage.swift create mode 100644 Tests/MetaRouterTests/LifecycleStorageTests.swift 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..91927ed --- /dev/null +++ b/Sources/MetaRouter/utils/LifecycleStorage.swift @@ -0,0 +1,50 @@ +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) + } + + public func setVersion(_ value: String) { + userDefaults.set(value, forKey: LifecycleStorageKey.version.rawValue) + } + + public func setBuild(_ value: String) { + userDefaults.set(value, forKey: LifecycleStorageKey.build.rawValue) + } + + public func setVersionBuild(version: String, build: String) { + setVersion(version) + setBuild(build) + } + + /// Removes the persisted version and build. Intended for tests. + public func clear() { + userDefaults.removeObject(forKey: LifecycleStorageKey.version.rawValue) + userDefaults.removeObject(forKey: LifecycleStorageKey.build.rawValue) + } +} diff --git a/Tests/MetaRouterTests/LifecycleStorageTests.swift b/Tests/MetaRouterTests/LifecycleStorageTests.swift new file mode 100644 index 0000000..d58d8ab --- /dev/null +++ b/Tests/MetaRouterTests/LifecycleStorageTests.swift @@ -0,0 +1,92 @@ +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.setVersion("1.5.0") + storage.setBuild("42") + + XCTAssertEqual(storage.getVersion(), "1.5.0") + XCTAssertEqual(storage.getBuild(), "42") + } + + func testSetVersionBuildHelperSetsBoth() { + let storage = LifecycleStorage(userDefaults: defaults) + storage.setVersionBuild(version: "2.0.0", build: "100") + + XCTAssertEqual(storage.getVersion(), "2.0.0") + XCTAssertEqual(storage.getBuild(), "100") + } + + 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") + } + + func testIdentityStorageHasAnyValueDetectsAnyKey() { + let storage = IdentityStorage(userDefaults: defaults) + XCTAssertFalse(storage.hasAnyValue()) + + storage.set(.anonymousId, value: "abc") + XCTAssertTrue(storage.hasAnyValue()) + + storage.clear() + XCTAssertFalse(storage.hasAnyValue()) + + storage.set(.userId, value: "u") + XCTAssertTrue(storage.hasAnyValue()) + } +} From d8bb1145b01e1c5f362388bda664175bb58b13cc Mon Sep 17 00:00:00 2001 From: Christopher Houdlette Date: Mon, 27 Apr 2026 15:50:33 -0600 Subject: [PATCH 2/3] refactor(lifecycle): mark LifecycleStorage.clear() as internal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code review follow-up (sc-38228 M1). The `clear()` method is a test-only seam, not part of the SDK's public contract. Marking it `public` would expose a "wipe install/update state" affordance to consumers that contradicts the entire rationale for the `metarouter:lifecycle:*` namespace separation: nothing — not even `reset()` — should be able to wipe this state. `@testable import MetaRouter` already gives test code access to `internal` symbols, so this is purely a tightening of the public surface with no functional change. --- Sources/MetaRouter/utils/LifecycleStorage.swift | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/Sources/MetaRouter/utils/LifecycleStorage.swift b/Sources/MetaRouter/utils/LifecycleStorage.swift index 91927ed..e542625 100644 --- a/Sources/MetaRouter/utils/LifecycleStorage.swift +++ b/Sources/MetaRouter/utils/LifecycleStorage.swift @@ -42,8 +42,11 @@ public struct LifecycleStorage: @unchecked Sendable { setBuild(build) } - /// Removes the persisted version and build. Intended for tests. - public func clear() { + /// 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) } From a8be8643f2e1b67c2a8fdac428ec2eeaad6fbd49 Mon Sep 17 00:00:00 2001 From: Christopher Houdlette Date: Mon, 11 May 2026 08:06:22 -0600 Subject: [PATCH 3/3] fix: fixing review comments --- .../MetaRouter/utils/LifecycleStorage.swift | 16 +++++------- .../IdentityStorageTests.swift | 15 ++++++++++- .../LifecycleStorageTests.swift | 25 +------------------ 3 files changed, 21 insertions(+), 35 deletions(-) diff --git a/Sources/MetaRouter/utils/LifecycleStorage.swift b/Sources/MetaRouter/utils/LifecycleStorage.swift index e542625..e1e06a5 100644 --- a/Sources/MetaRouter/utils/LifecycleStorage.swift +++ b/Sources/MetaRouter/utils/LifecycleStorage.swift @@ -29,17 +29,13 @@ public struct LifecycleStorage: @unchecked Sendable { return userDefaults.string(forKey: LifecycleStorageKey.build.rawValue) } - public func setVersion(_ value: String) { - userDefaults.set(value, forKey: LifecycleStorageKey.version.rawValue) - } - - public func setBuild(_ value: String) { - userDefaults.set(value, 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) { - setVersion(version) - setBuild(build) + 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 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 index d58d8ab..1b1e4ee 100644 --- a/Tests/MetaRouterTests/LifecycleStorageTests.swift +++ b/Tests/MetaRouterTests/LifecycleStorageTests.swift @@ -24,21 +24,12 @@ final class LifecycleStorageTests: XCTestCase { XCTAssertNil(storage.getVersion()) XCTAssertNil(storage.getBuild()) - storage.setVersion("1.5.0") - storage.setBuild("42") + storage.setVersionBuild(version: "1.5.0", build: "42") XCTAssertEqual(storage.getVersion(), "1.5.0") XCTAssertEqual(storage.getBuild(), "42") } - func testSetVersionBuildHelperSetsBoth() { - let storage = LifecycleStorage(userDefaults: defaults) - storage.setVersionBuild(version: "2.0.0", build: "100") - - XCTAssertEqual(storage.getVersion(), "2.0.0") - XCTAssertEqual(storage.getBuild(), "100") - } - func testClearRemovesBothKeys() { let storage = LifecycleStorage(userDefaults: defaults) storage.setVersionBuild(version: "1.0", build: "1") @@ -75,18 +66,4 @@ final class LifecycleStorageTests: XCTestCase { XCTAssertEqual(LifecycleStorageKey.version.rawValue, "metarouter:lifecycle:version") XCTAssertEqual(LifecycleStorageKey.build.rawValue, "metarouter:lifecycle:build") } - - func testIdentityStorageHasAnyValueDetectsAnyKey() { - let storage = IdentityStorage(userDefaults: defaults) - XCTAssertFalse(storage.hasAnyValue()) - - storage.set(.anonymousId, value: "abc") - XCTAssertTrue(storage.hasAnyValue()) - - storage.clear() - XCTAssertFalse(storage.hasAnyValue()) - - storage.set(.userId, value: "u") - XCTAssertTrue(storage.hasAnyValue()) - } }