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
11 changes: 11 additions & 0 deletions Sources/MetaRouter/identity/IdentityStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

11 changes: 11 additions & 0 deletions Sources/MetaRouter/lifecycle/AppForegroundState.swift
Original file line number Diff line number Diff line change
@@ -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
}
19 changes: 17 additions & 2 deletions Sources/MetaRouter/types/EventContext.swift
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down
49 changes: 49 additions & 0 deletions Sources/MetaRouter/utils/LifecycleStorage.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
15 changes: 14 additions & 1 deletion Tests/MetaRouterTests/IdentityStorageTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
}

69 changes: 69 additions & 0 deletions Tests/MetaRouterTests/LifecycleStorageTests.swift
Original file line number Diff line number Diff line change
@@ -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")
}
}
Loading