diff --git a/README.md b/README.md index 3a85704..641c041 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ A lightweight iOS analytics SDK that transmits events to your MetaRouter cluster - [Compatibility](#-compatibility) - [Debugging](#debugging) - [Identity Persistence](#identity-persistence) +- [Lifecycle Events](#lifecycle-events) - [Event Queue Persistence](#event-queue-persistence) - [Advertising ID (IDFA)](#advertising-id-idfa) - [Using the alias() Method](#using-the-alias-method) @@ -229,6 +230,7 @@ The analytics client provides the following methods: - `enableDebugLogging()`: Enable debug logging - `getDebugInfo() async`: Get current debug information - `setTracing(_ enabled: Bool)`: Enable or disable tracing headers on API requests. When enabled, adds a `Trace: true` header to all outgoing events for backend debugging and diagnostics +- `openURL(_ url: URL, sourceApplication: String?)`: Forward an inbound deep-link URL so the next `Application Opened` event carries `url` and `referring_application` properties. Mirrors UIKit's `application(_:open:options:)` shape. See [Lifecycle Events](#lifecycle-events) for wiring ### Testing APIs @@ -257,6 +259,7 @@ await MetaRouter.Analytics.resetAndWait() - ๐Ÿ’ฟ **Disk-Backed Queue**: Events survive app termination and are rehydrated on next launch - ๐Ÿ”Œ **Circuit Breaker**: Intelligent retry logic with exponential backoff - โšก **Batching**: Automatic event batching for network efficiency +- ๐Ÿ“ฒ **Lifecycle Events** (opt-in): Automatic `Application Installed` / `Updated` / `Opened` / `Backgrounded` tracking with deep-link attribution support ## โœ… Compatibility @@ -591,7 +594,143 @@ The SDK automatically handles app lifecycle events: - **App Termination**: Best-effort disk snapshot (not guaranteed โ€” process may exit before completion) - **Identity Persistence**: Anonymous ID, user ID, group ID, and advertising ID are persisted across app launches -### Event Queue Persistence +## Lifecycle Events + +When `trackLifecycleEvents` is enabled (default `false` โ€” opt-in), the SDK automatically emits four canonical lifecycle events. They flow through the same enrichment + dispatch pipeline as any other event, so they pick up `anonymousId`, `userId`, `groupId`, device context, and timestamps. + +### The Four Events + +| Event | Fires when | Properties | +| ----- | ---------- | ---------- | +| `Application Installed` | First launch on a device โ€” no prior identity, no prior `(version, build)` persisted | `version`, `build` | +| `Application Updated` | App `(version, build)` changed since last launch, **or** lifecycle tracking is being enabled for the first time on an existing user (no install spike for the upgraded population) | `version`, `build`, `previous_version`, `previous_build` | +| `Application Opened` | After cold launch (process foregrounded) and on every `background โ†’ active` resume | `from_background` (Bool), `version`, `build`, optional `url`, optional `referring_application` | +| `Application Backgrounded` | App enters background. Emitted **before** the dispatcher's flush-to-disk so the event is captured in the same drain | _(none)_ | + +### Cold Launch Sequencing + +On a cold launch with `trackLifecycleEvents: true`, the SDK emits in this order once initialization completes: + +1. `Application Installed` **or** `Application Updated` (or neither, if version/build hasn't changed) +2. `Application Opened` with `from_background: false` + +If the process was woken in the background (silent push, background fetch, location update), the cold-launch `Application Opened` is **suppressed**. The next true `background โ†’ active` transition emits `Application Opened` with `from_background: false` as the cold-launch bridge. + +### Resume vs. inactive + +Only `background โ†’ active` transitions emit `Application Opened`. Brief `inactive` states (Control Center, FaceID prompt, system alert) do **not** emit โ€” they're not real foregrounds. + +### Enabling + +Lifecycle tracking is **opt-in** โ€” set `trackLifecycleEvents: true` in `InitOptions` to turn it on. When disabled (the default), no lifecycle events are emitted and `openURL` is a no-op (logs a debug warning the first time it's called). + +```swift +let options = InitOptions( + writeKey: "YOUR_WRITE_KEY", + ingestionHost: "https://your-ingestion-host.com", + trackLifecycleEvents: true +) +``` + +### Deep Link Attribution + +Forward inbound deep-link URLs to the SDK so the next `Application Opened` event carries `url` and `referring_application` properties. This is a one-shot buffer โ€” the next Opened consumes and clears it. + +**`UIScene` (iOS 13+, recommended):** + +```swift +import UIKit +import MetaRouterSwiftSDK + +final class SceneDelegate: UIResponder, UIWindowSceneDelegate { + + // Cold-launch deep link arrives here in connectionOptions + func scene(_ scene: UIScene, + willConnectTo session: UISceneSession, + options connectionOptions: UIScene.ConnectionOptions) { + + if let urlContext = connectionOptions.urlContexts.first { + MetaRouter.Analytics.shared.openURL( + urlContext.url, + sourceApplication: urlContext.options.sourceApplication + ) + } + } + + // Resume deep link arrives here on background โ†’ active + func scene(_ scene: UIScene, openURLContexts URLContexts: Set) { + guard let urlContext = URLContexts.first else { return } + MetaRouter.Analytics.shared.openURL( + urlContext.url, + sourceApplication: urlContext.options.sourceApplication + ) + } +} +``` + +**`UIApplicationDelegate` (legacy, single-scene apps):** + +```swift +import UIKit +import MetaRouterSwiftSDK + +@main +final class AppDelegate: UIResponder, UIApplicationDelegate { + + func application(_ app: UIApplication, + open url: URL, + options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool { + MetaRouter.Analytics.shared.openURL( + url, + sourceApplication: options[.sourceApplication] as? String + ) + return true + } +} +``` + +**Universal Links** are delivered through `NSUserActivity`, not the `openURL` callback. Pull the `webpageURL` out yourself and forward it the same way: + +```swift +func scene(_ scene: UIScene, continue userActivity: NSUserActivity) { + guard userActivity.activityType == NSUserActivityTypeBrowsingWeb, + let url = userActivity.webpageURL else { return } + MetaRouter.Analytics.shared.openURL(url, sourceApplication: nil) +} +``` + +#### Buffer Semantics + +`openURL` stores **one** URL until the next `Application Opened` emits. Practical implications: + +- **Calling twice before an Opened**: only the most recent URL is attached. No queue. +- **Calling without an Opened ever firing**: the URL sits in the buffer until the next Opened, whenever that is. +- **After emit**: the buffer is cleared. Subsequent Opened events get no URL unless `openURL` is called again. + +This shape matches how iOS delivers URLs โ€” at one moment, correlated with one Opened. + +#### Privacy + +Deep-link URLs frequently carry sensitive data โ€” auth tokens, password reset codes, magic-link secrets, OTPs. **The host app is responsible for sanitizing URLs before forwarding them.** Strip query parameters that shouldn't leave the device: + +```swift +func sanitized(_ url: URL) -> URL { + var components = URLComponents(url: url, resolvingAgainstBaseURL: false) + components?.queryItems = components?.queryItems?.filter { item in + !["token", "code", "otp", "secret"].contains(item.name.lowercased()) + } + return components?.url ?? url +} + +MetaRouter.Analytics.shared.openURL( + sanitized(incomingURL), + sourceApplication: nil +) +``` + +The SDK does not auto-instrument deep-link capture (no method swizzling, no `UIApplicationDelegate` proxy). Manual forwarding keeps integration explicit, avoids conflicts with other SDKs that swizzle (Firebase, Adjust, AppsFlyer, Branch), and gives the host control over what data is captured. + +## Event Queue Persistence Unsent events are automatically persisted to disk and recovered across app launches. This prevents event loss when the app is backgrounded, terminated, or encounters network issues. diff --git a/Sources/MetaRouter/analytics/AnalyticsClient.swift b/Sources/MetaRouter/analytics/AnalyticsClient.swift index e250fca..ef25514 100644 --- a/Sources/MetaRouter/analytics/AnalyticsClient.swift +++ b/Sources/MetaRouter/analytics/AnalyticsClient.swift @@ -12,6 +12,12 @@ internal struct AnalyticsDependencies: Sendable { var dispatcher: Dispatcher? var persistentQueue: PersistentEventQueue? var networkMonitor: NetworkReachability? + var lifecycleStorage: LifecycleStorage? + var identityStorage: IdentityStorage? + var appContext: AppContext? + /// Override the initial app foreground state read at cold launch. + /// Tests pass `.active` / `.background` directly to skip the UIKit probe. + var initialAppState: AppForegroundState? static let production = AnalyticsDependencies() } @@ -29,12 +35,18 @@ internal final class AnalyticsClient: AnalyticsInterface, CustomStringConvertibl private var lifecycleState: LifecycleState = .idle private var disabled = false private var initTask: Task? + private let lifecycleCoordinator: LifecycleCoordinator? private init(options: InitOptions, deps: AnalyticsDependencies = .production) { self.lifecycleState = .initializing self.options = options - self.contextProvider = deps.contextProvider ?? DeviceContextProvider() + // Snapshot bundle metadata once โ€” used by both DeviceContextProvider (per-event + // app context) and LifecycleEventEmitter (install/update detection). Bundle + // is OS-loaded at process start and immutable, so caching is safe. + let appContext = deps.appContext ?? .fromBundle() + self.contextProvider = deps.contextProvider + ?? DeviceContextProvider(appContext: appContext) self.identityManager = deps.identityManager ?? IdentityManager( writeKey: options.writeKey, host: options.ingestionHost.absoluteString @@ -60,6 +72,25 @@ internal final class AnalyticsClient: AnalyticsInterface, CustomStringConvertibl config: deps.dispatcherConfig ?? Dispatcher.Config(endpointPath: "/v1/batch", timeoutMs: 8000, autoFlushThreshold: 20, initialMaxBatchSize: 100) ) + // Build the lifecycle coordinator only when the feature is enabled. Construction + // happens BEFORE identityManager.initialize() runs (in initTask), so the + // emitter's snapshot of "did identity exist before this launch?" is honest. + if options.trackLifecycleEvents { + let emitter = LifecycleEventEmitter( + enrichmentService: self.enrichmentService, + dispatcher: self.dispatcher, + storage: deps.lifecycleStorage ?? LifecycleStorage(), + identityStorage: deps.identityStorage ?? IdentityStorage(), + appContext: appContext + ) + self.lifecycleCoordinator = LifecycleCoordinator( + emitter: emitter, + initialStateOverride: deps.initialAppState + ) + } else { + self.lifecycleCoordinator = nil + } + let rawMonitor = deps.networkMonitor ?? NetworkMonitor() let monitor = DebouncedNetworkMonitor(inner: rawMonitor) @@ -85,21 +116,8 @@ internal final class AnalyticsClient: AnalyticsInterface, CustomStringConvertibl } self.lifecycle = AppLifecycleObserver( - onForeground: { [weak self] in - guard let self, self.lifecycleState == .ready else { return } - Task { [weak self] in - guard let self else { return } - await self.dispatcher.startFlushLoop(intervalSeconds: self.options.flushIntervalSeconds) - await self.dispatcher.flush() - } - }, - onBackgroundAsync: { [weak self] in - guard let self else { return } - await self.dispatcher.flush() - await self.dispatcher.flushToDisk() - await self.dispatcher.stopFlushLoop() - await self.dispatcher.cancelScheduledRetry() - } + onForeground: { [weak self] in self?.handleForeground() }, + onBackgroundAsync: { [weak self] in await self?.handleBackground() } ) // Wire network monitor: set initial state and subscribe to changes @@ -146,6 +164,10 @@ internal final class AnalyticsClient: AnalyticsInterface, CustomStringConvertibl writeKey: self.options.writeKey, host: self.options.ingestionHost.absoluteString) + // Emit cold-launch lifecycle sequence (Installed/Updated then Opened). + // Runs after .ready so events flow through the standard track path. + await self.lifecycleCoordinator?.onReady() + // Drain any persisted events from a previous session if monitor.currentStatus == .connected { await self.dispatcher.drainDiskStoreToNetwork() @@ -153,6 +175,26 @@ internal final class AnalyticsClient: AnalyticsInterface, CustomStringConvertibl } } + private func handleForeground() { + guard lifecycleState == .ready else { return } + Task { [weak self] in + guard let self else { return } + await self.dispatcher.startFlushLoop(intervalSeconds: self.options.flushIntervalSeconds) + await self.dispatcher.flush() + await self.lifecycleCoordinator?.onForeground() + } + } + + /// Emit `Application Backgrounded` BEFORE flush/disk-flush so the event + /// is captured by the same drain that ships pending events to disk. + private func handleBackground() async { + await lifecycleCoordinator?.onBackground() + await dispatcher.flush() + await dispatcher.flushToDisk() + await dispatcher.stopFlushLoop() + await dispatcher.cancelScheduledRetry() + } + internal static func initialize(options: InitOptions, deps: AnalyticsDependencies = .production) -> AnalyticsClient { AnalyticsClient(options: options, deps: deps) } @@ -477,4 +519,14 @@ internal final class AnalyticsClient: AnalyticsInterface, CustomStringConvertibl await self.dispatcher.setTracing(enabled) } } + + public func openURL(_ url: URL, sourceApplication: String?) { + guard let coordinator = lifecycleCoordinator else { + Logger.warn("openURL called but trackLifecycleEvents is disabled โ€” ignoring") + return + } + Task { + await coordinator.openURL(url, sourceApplication: sourceApplication) + } + } } diff --git a/Sources/MetaRouter/analytics/AnalyticsInterface.swift b/Sources/MetaRouter/analytics/AnalyticsInterface.swift index 164ae71..7028c20 100644 --- a/Sources/MetaRouter/analytics/AnalyticsInterface.swift +++ b/Sources/MetaRouter/analytics/AnalyticsInterface.swift @@ -25,4 +25,13 @@ public protocol AnalyticsInterface: AnyObject, Sendable { func setAdvertisingId(_ advertisingId: String?) func clearAdvertisingId() func setTracing(_ enabled: Bool) + + /// Tells the SDK the app is opening with this URL. Buffers the URL to be + /// attached to the next `Application Opened` event as the `url` (and + /// optionally `referring_application`) property. Call from + /// `application(_:open:options:)` or `scene(_:openURLContexts:)`, and from + /// `application(_:didFinishLaunchingWithOptions:)` for cold-launch capture + /// using `launchOptions[.url]` / `[.sourceApplication]`. One-shot โ€” cleared + /// after the next emit. + func openURL(_ url: URL, sourceApplication: String?) } diff --git a/Sources/MetaRouter/analytics/AnalyticsProxy.swift b/Sources/MetaRouter/analytics/AnalyticsProxy.swift index 9af2e54..e895417 100644 --- a/Sources/MetaRouter/analytics/AnalyticsProxy.swift +++ b/Sources/MetaRouter/analytics/AnalyticsProxy.swift @@ -105,6 +105,10 @@ internal final class AnalyticsProxy: AnalyticsInterface, CustomStringConvertible public func setTracing(_ enabled: Bool) { Task { await state.enqueue(.setTracing(enabled)) } } + + public func openURL(_ url: URL, sourceApplication: String?) { + Task { await state.enqueue(.openURL(url, sourceApplication)) } + } } extension AnalyticsProxy { @@ -128,6 +132,7 @@ private enum Call { case setAdvertisingId(String?) case clearAdvertisingId case setTracing(Bool) + case openURL(URL, String?) } private actor ProxyState { @@ -210,6 +215,7 @@ private actor ProxyState { case .setAdvertisingId(let advertisingId): r.setAdvertisingId(advertisingId) case .clearAdvertisingId: r.clearAdvertisingId() case .setTracing(let enabled): r.setTracing(enabled) + case .openURL(let url, let source): r.openURL(url, sourceApplication: source) } } } diff --git a/Sources/MetaRouter/analytics/InitOptions.swift b/Sources/MetaRouter/analytics/InitOptions.swift index f2a3cb4..2401328 100644 --- a/Sources/MetaRouter/analytics/InitOptions.swift +++ b/Sources/MetaRouter/analytics/InitOptions.swift @@ -7,6 +7,7 @@ public struct InitOptions: Sendable { public let debug: Bool public let maxQueueEvents: Int public let maxDiskEvents: Int + public let trackLifecycleEvents: Bool public init( writeKey: String, @@ -14,7 +15,8 @@ public struct InitOptions: Sendable { flushIntervalSeconds: Int = 10, debug: Bool = false, maxQueueEvents: Int = 2000, - maxDiskEvents: Int = 10000 + maxDiskEvents: Int = 10000, + trackLifecycleEvents: Bool = false ) { precondition(!writeKey.isEmpty, "writeKey must not be empty") @@ -30,6 +32,7 @@ public struct InitOptions: Sendable { self.debug = debug self.maxQueueEvents = max(1, maxQueueEvents) self.maxDiskEvents = maxDiskEvents + self.trackLifecycleEvents = trackLifecycleEvents if self.maxDiskEvents > 0 && self.maxDiskEvents < self.maxQueueEvents { Logger.warn("maxDiskEvents (\(self.maxDiskEvents)) is less than maxQueueEvents (\(self.maxQueueEvents)) โ€” memory can hold more events than disk can preserve; events may be dropped during background flush") @@ -44,7 +47,8 @@ extension InitOptions { flushIntervalSeconds: Int = 10, debug: Bool = false, maxQueueEvents: Int = 2000, - maxDiskEvents: Int = 10000 + maxDiskEvents: Int = 10000, + trackLifecycleEvents: Bool = false ) { var host = ingestionHost.trimmingCharacters(in: .whitespacesAndNewlines) if host.hasSuffix("/") { @@ -59,7 +63,8 @@ extension InitOptions { flushIntervalSeconds: flushIntervalSeconds, debug: debug, maxQueueEvents: maxQueueEvents, - maxDiskEvents: maxDiskEvents + maxDiskEvents: maxDiskEvents, + trackLifecycleEvents: trackLifecycleEvents ) } } diff --git a/Sources/MetaRouter/context/DeviceContextProvider.swift b/Sources/MetaRouter/context/DeviceContextProvider.swift index 546d271..22f7867 100644 --- a/Sources/MetaRouter/context/DeviceContextProvider.swift +++ b/Sources/MetaRouter/context/DeviceContextProvider.swift @@ -29,13 +29,16 @@ public final class DeviceContextProvider: ContextProvider, @unchecked Sendable { private let contextActor = ContextActor() private let library: LibraryContext + private let appContext: AppContext private let advertisingIdActor = AdvertisingIdActor() public init( libraryName: String = "metarouter-ios-sdk", - libraryVersion: String = MetaRouterSDK.version + libraryVersion: String = MetaRouterSDK.version, + appContext: AppContext = .fromBundle() ) { self.library = LibraryContext(name: libraryName, version: libraryVersion) + self.appContext = appContext } public func getContext() async -> EventContext { @@ -76,17 +79,7 @@ public final class DeviceContextProvider: ContextProvider, @unchecked Sendable { } private func collectAppContext() async -> AppContext { - let bundle = Bundle.main - let info = bundle.infoDictionary ?? [:] - - let name = (info["CFBundleDisplayName"] as? String) - ?? (info["CFBundleName"] as? String) - ?? "Unknown" - let version = info["CFBundleShortVersionString"] as? String ?? "unknown" - let build = info["CFBundleVersion"] as? String ?? "unknown" - let namespace = bundle.bundleIdentifier ?? "unknown" - - return AppContext(name: name, version: version, build: build, namespace: namespace) + appContext } private func collectDeviceContext() async -> DeviceContext { 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/LifecycleCoordinator.swift b/Sources/MetaRouter/lifecycle/LifecycleCoordinator.swift new file mode 100644 index 0000000..9b56335 --- /dev/null +++ b/Sources/MetaRouter/lifecycle/LifecycleCoordinator.swift @@ -0,0 +1,67 @@ +import Foundation + +/// Bridges `AnalyticsClient`'s init / foreground / background / deep-link callbacks +/// to `LifecycleEventEmitter`. Owns the cold-launch app-state probe so the UIKit +/// dependency stays out of `AnalyticsClient`. Designed as an extension point for +/// richer deep-linking and lifecycle hooks. +internal final class LifecycleCoordinator: @unchecked Sendable { + private let emitter: LifecycleEventEmitter + private let initialStateOverride: AppForegroundState? + + init( + emitter: LifecycleEventEmitter, + initialStateOverride: AppForegroundState? = nil + ) { + self.emitter = emitter + self.initialStateOverride = initialStateOverride + } + + func onForeground() async { + await emitter.emitForegroundFromBackground() + } + + func onBackground() async { + await emitter.emitBackgrounded() + } + + func onReady() async { + let initialState: AppForegroundState + if let override = initialStateOverride { + initialState = override + } else { + initialState = await currentAppForegroundState() + } + await emitter.emitColdLaunchSequence(initialAppState: initialState) + } + + func openURL(_ url: URL, sourceApplication: String?) async { + await emitter.openURL( + url: url.absoluteString, + sourceApplication: sourceApplication + ) + } +} + +#if canImport(UIKit) +import UIKit + +/// Reads `UIApplication.applicationState` on the main actor (it's main-actor +/// isolated) and maps to our platform-neutral `AppForegroundState`. +fileprivate func currentAppForegroundState() async -> AppForegroundState { + await MainActor.run { + switch UIApplication.shared.applicationState { + case .active: return .active + case .inactive: return .inactive + case .background: return .background + @unknown default: return .active + } + } +} +#else +/// UIKit unavailable (macOS native, Linux). macOS apps don't have the iOS +/// background-launch scenario (silent push, background fetch), so `.active` +/// is the correct cold-launch assumption โ€” `Application Opened` fires normally. +fileprivate func currentAppForegroundState() async -> AppForegroundState { + .active +} +#endif diff --git a/Sources/MetaRouter/lifecycle/LifecycleEventEmitter.swift b/Sources/MetaRouter/lifecycle/LifecycleEventEmitter.swift new file mode 100644 index 0000000..6b284a0 --- /dev/null +++ b/Sources/MetaRouter/lifecycle/LifecycleEventEmitter.swift @@ -0,0 +1,198 @@ +import Foundation + +/// Process-level foreground state used to gate cold-launch and resume emits. +public enum AppForegroundState: Sendable, Equatable { + case active + case inactive + case background +} + +internal enum LifecycleEventNames { + static let applicationInstalled = "Application Installed" + static let applicationUpdated = "Application Updated" + static let applicationOpened = "Application Opened" + static let applicationBackgrounded = "Application Backgrounded" +} + +internal enum LifecycleEventProperties { + static let version = "version" + static let build = "build" + static let previousVersion = "previous_version" + static let previousBuild = "previous_build" + static let fromBackground = "from_background" + static let url = "url" + static let referringApplication = "referring_application" +} + +/// Owns install/update detection, cold-launch vs. resume disambiguation, and +/// the choreography for emitting the four `Application *` lifecycle events. +/// +/// The emitter is an `actor` so internal flags (`coldLaunchEmitted`, +/// `coldLaunchSuppressed`, `lastTrackedAppState`, `pendingDeepLink`) are +/// serialised against any concurrent foreground/background notification. +internal actor LifecycleEventEmitter { + private let enrichmentService: EventEnrichmentService + private let dispatcher: Dispatcher + private let storage: LifecycleStorage + private let appContext: AppContext + private let hadIdentityBeforeInit: Bool + + private var coldLaunchEmitted: Bool = false + private var coldLaunchSuppressed: Bool = false + private var lastTrackedAppState: AppForegroundState = .active + private var pendingDeepLink: PendingDeepLink? + + private struct PendingDeepLink { + let url: String + let source: String? + } + + init( + enrichmentService: EventEnrichmentService, + dispatcher: Dispatcher, + storage: LifecycleStorage = LifecycleStorage(), + identityStorage: IdentityStorage = IdentityStorage(), + appContext: AppContext + ) { + self.enrichmentService = enrichmentService + self.dispatcher = dispatcher + self.storage = storage + self.appContext = appContext + // Snapshot before IdentityManager.initialize() auto-creates an anonymousId, + // so we can tell a true fresh install (no identity) from an existing user + // upgrading to a lifecycle-aware SDK build (identity already present). + self.hadIdentityBeforeInit = identityStorage.hasAnyValue() + } + + /// Runs once after `AnalyticsClient.lifecycleState = .ready`. + /// + /// 1. Compares the persisted `(version, build)` against the current bundle values. + /// 2. Emits `Application Installed` (true fresh install) or `Application Updated` + /// (build/version drift, or first lifecycle launch with prior identity). + /// 3. Persists current `(version, build)`. + /// 4. Emits `Application Opened` with `from_background: false` IF the process is + /// in the foreground at emit time. Background-launched processes (silent push, + /// background fetch) suppress the cold-launch Opened; the next + /// `background โ†’ active` transition emits instead. + func emitColdLaunchSequence(initialAppState: AppForegroundState) async { + let prevVersion = storage.getVersion() + let prevBuild = storage.getBuild() + let curr = appContext + + if prevVersion == nil && prevBuild == nil { + if hadIdentityBeforeInit { + // Existing user upgrading from a pre-lifecycle SDK build โ€” + // avoid spurious install spike for the upgraded population. + await emit( + LifecycleEventNames.applicationUpdated, + properties: [ + LifecycleEventProperties.version: .string(curr.version), + LifecycleEventProperties.build: .string(curr.build), + LifecycleEventProperties.previousVersion: .string("unknown"), + LifecycleEventProperties.previousBuild: .string("unknown"), + ] + ) + } else { + await emit( + LifecycleEventNames.applicationInstalled, + properties: [ + LifecycleEventProperties.version: .string(curr.version), + LifecycleEventProperties.build: .string(curr.build), + ] + ) + } + } else if prevVersion != curr.version || prevBuild != curr.build { + await emit( + LifecycleEventNames.applicationUpdated, + properties: [ + LifecycleEventProperties.version: .string(curr.version), + LifecycleEventProperties.build: .string(curr.build), + LifecycleEventProperties.previousVersion: .string(prevVersion ?? "unknown"), + LifecycleEventProperties.previousBuild: .string(prevBuild ?? "unknown"), + ] + ) + } + + storage.setVersionBuild(version: curr.version, build: curr.build) + lastTrackedAppState = initialAppState + + if initialAppState == .active { + await emitOpened(fromBackground: false) + coldLaunchEmitted = true + } else { + // Process was woken in background โ€” defer to the next true foreground entry. + coldLaunchSuppressed = true + } + } + + /// Called from `AppLifecycleObserver.onForeground` (didBecomeActive). + /// No-ops in three cases: + /// 1. `coldLaunchSuppressed` was set because cold-launch ran in non-active state โ€” + /// emit `Application Opened {from_background: false}` here as the cold-launch bridge. + /// 2. `coldLaunchEmitted == false` โ€” first didBecomeActive on launch; the cold-launch + /// path is the sole producer of the first Opened. Suppress. + /// 3. `lastTrackedAppState != .background` โ€” `inactive โ†’ active` transition + /// (Control Center, FaceID prompt, system alert). Only `background โ†’ active` emits. + func emitForegroundFromBackground() async { + if coldLaunchSuppressed { + await emitOpened(fromBackground: false) + coldLaunchSuppressed = false + coldLaunchEmitted = true + lastTrackedAppState = .active + return + } + + guard coldLaunchEmitted else { + // First didBecomeActive during init โ€” cold-launch path will (or did) emit. + lastTrackedAppState = .active + return + } + + let wasBackground = lastTrackedAppState == .background + lastTrackedAppState = .active + guard wasBackground else { return } + + await emitOpened(fromBackground: true) + } + + /// Called from `AppLifecycleObserver.onBackgroundAsync` BEFORE the dispatcher + /// flushes to disk so the event is captured by `flushToDisk()`. + func emitBackgrounded() async { + lastTrackedAppState = .background + let event = await enrichmentService.createTrackEvent( + event: LifecycleEventNames.applicationBackgrounded, + properties: nil + ) + await dispatcher.offer(event) + } + + /// Buffers a deep-link URL (and optional source application) to be attached + /// to the next `Application Opened` event. One-shot โ€” cleared on emit. + func openURL(url: String, sourceApplication: String?) { + pendingDeepLink = PendingDeepLink(url: url, source: sourceApplication) + } + + private func emitOpened(fromBackground: Bool) async { + var properties: [String: CodableValue] = [ + LifecycleEventProperties.fromBackground: .bool(fromBackground), + LifecycleEventProperties.version: .string(appContext.version), + LifecycleEventProperties.build: .string(appContext.build), + ] + if let buf = pendingDeepLink { + properties[LifecycleEventProperties.url] = .string(buf.url) + if let source = buf.source { + properties[LifecycleEventProperties.referringApplication] = .string(source) + } + pendingDeepLink = nil + } + await emit(LifecycleEventNames.applicationOpened, properties: properties) + } + + private func emit(_ name: String, properties: [String: CodableValue]?) async { + let event = await enrichmentService.createTrackEvent( + event: name, + properties: properties + ) + await dispatcher.offer(event) + } +} 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/AppLifecycleEventIntegrationTests.swift b/Tests/MetaRouterTests/AppLifecycleEventIntegrationTests.swift new file mode 100644 index 0000000..76ec596 --- /dev/null +++ b/Tests/MetaRouterTests/AppLifecycleEventIntegrationTests.swift @@ -0,0 +1,254 @@ +import XCTest +@testable import MetaRouter + +#if canImport(UIKit) +import UIKit +#endif + +#if canImport(AppKit) +import AppKit +#endif + +/// End-to-end coverage that exercises a real `AnalyticsClient` wired with +/// dependency injection. Posts `NotificationCenter` notifications to drive the +/// lifecycle observer, and asserts events flow through the standard track path. +/// +/// Events may end up in either the dispatcher's memory queue (no flush yet) or +/// in the network stub (already flushed). Test assertions consult both. +final class AppLifecycleEventIntegrationTests: XCTestCase { + + private static var foregroundNotificationName: Notification.Name { + #if canImport(UIKit) + return UIApplication.didBecomeActiveNotification + #elseif canImport(AppKit) + return NSApplication.didBecomeActiveNotification + #else + return Notification.Name("metarouter.test.foreground") + #endif + } + + private static var backgroundNotificationName: Notification.Name { + #if canImport(UIKit) + return UIApplication.didEnterBackgroundNotification + #elseif canImport(AppKit) + return NSApplication.didResignActiveNotification + #else + return Notification.Name("metarouter.test.background") + #endif + } + + private var suiteName: String! + private var defaults: UserDefaults! + + override func setUp() { + super.setUp() + suiteName = "com.metarouter.test.lifecycleIntegration.\(UUID().uuidString)" + defaults = UserDefaults(suiteName: suiteName) + } + + override func tearDown() { + defaults.removePersistentDomain(forName: suiteName) + defaults = nil + suiteName = nil + super.tearDown() + } + + func testColdLaunchEmitsInstalledThenOpenedThroughClient() async { + let bundle = Setup(defaults: defaults, trackLifecycleEvents: true) + await bundle.waitForInit() + + let events = await bundle.collectEvents() + XCTAssertGreaterThanOrEqual(events.count, 2) + XCTAssertEqual(events[0].event, "Application Installed") + XCTAssertEqual(events[1].event, "Application Opened") + XCTAssertEqual(events[1].properties?["from_background"], .bool(false)) + + XCTAssertEqual(bundle.lifecycleStorage.getVersion(), "1.5.0") + XCTAssertEqual(bundle.lifecycleStorage.getBuild(), "42") + } + + func testFlagDisabledEmitsNoLifecycleEvents() async { + let bundle = Setup(defaults: defaults, trackLifecycleEvents: false) + await bundle.waitForInit() + + let events = await bundle.collectEvents() + XCTAssertTrue(events.isEmpty, + "trackLifecycleEvents=false must produce zero lifecycle events on init") + + // Sanity: regular track() still works + bundle.client.track("user_event") + try? await Task.sleep(nanoseconds: 100_000_000) + let after = await bundle.collectEvents() + XCTAssertEqual(after.first?.event, "user_event") + } + + /// `reset()` must NOT clear lifecycle storage โ€” install/update state survives + /// because lifecycle keys live in a separate UserDefaults namespace. + func testResetPreservesLifecycleStorage() async { + let bundle = Setup(defaults: defaults, trackLifecycleEvents: true) + await bundle.waitForInit() + XCTAssertEqual(bundle.lifecycleStorage.getVersion(), "1.5.0") + + bundle.client.reset() + try? await Task.sleep(nanoseconds: 300_000_000) + + XCTAssertEqual(bundle.lifecycleStorage.getVersion(), "1.5.0", + "reset() must not clear lifecycle storage") + XCTAssertEqual(bundle.lifecycleStorage.getBuild(), "42", + "reset() must not clear lifecycle storage") + } + + /// Re-init with persisted same version must NOT emit Installed/Updated โ€” + /// only Opened. + func testReinitWithSameVersionEmitsOnlyOpened() async { + // Pre-seed lifecycle storage to simulate a previous successful run + LifecycleStorage(userDefaults: defaults) + .setVersionBuild(version: "1.5.0", build: "42") + + let bundle = Setup(defaults: defaults, trackLifecycleEvents: true) + await bundle.waitForInit() + + let events = await bundle.collectEvents() + XCTAssertEqual(events.count, 1) + XCTAssertEqual(events[0].event, "Application Opened") + XCTAssertEqual(events[0].properties?["from_background"], .bool(false)) + } + + func testBackgroundNotificationEnqueuesApplicationBackgrounded() async { + let bundle = Setup(defaults: defaults, trackLifecycleEvents: true) + await bundle.waitForInit() + await bundle.consumeAll() // drop cold-launch events + + await MainActor.run { + NotificationCenter.default.post( + name: Self.backgroundNotificationName, + object: nil + ) + } + try? await Task.sleep(nanoseconds: 500_000_000) + + let events = await bundle.collectEvents() + XCTAssertTrue(events.contains(where: { $0.event == "Application Backgrounded" }), + "expected Application Backgrounded after background notification, got \(events.map { $0.event ?? "?" })") + } + + func testForegroundAfterBackgroundEmitsOpenedFromBackground() async { + let bundle = Setup(defaults: defaults, trackLifecycleEvents: true) + await bundle.waitForInit() + await bundle.consumeAll() + + await MainActor.run { + NotificationCenter.default.post(name: Self.backgroundNotificationName, object: nil) + } + try? await Task.sleep(nanoseconds: 400_000_000) + + await MainActor.run { + NotificationCenter.default.post(name: Self.foregroundNotificationName, object: nil) + } + try? await Task.sleep(nanoseconds: 400_000_000) + + let events = await bundle.collectEvents() + let opened = events.first(where: { $0.event == "Application Opened" }) + XCTAssertNotNil(opened, "expected Application Opened after foreground, got \(events.map { $0.event ?? "?" })") + XCTAssertEqual(opened?.properties?["from_background"], .bool(true)) + } +} + +/// Bundles all dependencies needed to drive an `AnalyticsClient` with a +/// drainable queue + a recording network sink that captures flushed batches. +private final class Setup { + let client: AnalyticsClient + let queue: PersistentEventQueue + let lifecycleStorage: LifecycleStorage + let recorder: RecordingNetworking + + init(defaults: UserDefaults, trackLifecycleEvents: Bool) { + let options = InitOptions( + writeKey: "wk", + ingestionHost: URL(string: "https://example.com")!, + flushIntervalSeconds: 999, // effectively never auto-flush during tests + trackLifecycleEvents: trackLifecycleEvents + ) + + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("metarouter-integration-\(UUID().uuidString)") + let diskStore = DiskStorage(baseDirectory: tempDir) + self.queue = PersistentEventQueue(diskStore: diskStore, maxEventCount: 1000) + self.lifecycleStorage = LifecycleStorage(userDefaults: defaults) + self.recorder = RecordingNetworking() + + let identityStorage = IdentityStorage(userDefaults: defaults) + let identityManager = IdentityManager( + storage: identityStorage, + writeKey: options.writeKey, + host: options.ingestionHost.absoluteString + ) + + var deps = AnalyticsDependencies() + deps.persistentQueue = self.queue + deps.networking = self.recorder + // Connected so flush() actually POSTs (offline writes to disk instead). + // No persisted data exists on a fresh test, so drainDiskStore is a no-op. + deps.networkMonitor = StubNetworkMonitor(status: .connected) + deps.identityManager = identityManager + deps.lifecycleStorage = self.lifecycleStorage + deps.identityStorage = identityStorage + deps.appContext = AppContext(name: "test-app", version: "1.5.0", build: "42", namespace: "com.metarouter.test") + // Force "active" on cold launch so we can assert the Opened event regardless of platform. + deps.initialAppState = .active + deps.dispatcherConfig = Dispatcher.Config( + endpointPath: "/v1/batch", + timeoutMs: 1000, + autoFlushThreshold: 9999, // don't auto-flush during tests + initialMaxBatchSize: 100 + ) + + self.client = AnalyticsClient.initialize(options: options, deps: deps) + } + + /// Block until init's task chain has settled (cold-launch sequence emitted). + func waitForInit() async { + _ = await client.getAnonymousId() + try? await Task.sleep(nanoseconds: 200_000_000) + } + + /// Returns the union of queue-pending events and network-flushed events + /// in roughly chronological order. Drains the queue side-effectfully. + func collectEvents() async -> [EnrichedEventPayload] { + let queued = await queue.drain(max: 100) + let flushed = recorder.recorded + return flushed + queued + } + + /// Drops every captured/queued event, used to clear cold-launch noise. + func consumeAll() async { + _ = await queue.drain(max: 100) + recorder.clear() + } +} + +private final class RecordingNetworking: Networking, @unchecked Sendable { + private let lock = NSLock() + private var _events: [EnrichedEventPayload] = [] + + var recorded: [EnrichedEventPayload] { + lock.withLock { _events } + } + + func clear() { + lock.withLock { _events.removeAll() } + } + + private struct Batch: Decodable { + let batch: [EnrichedEventPayload] + } + + func postJSON(url: URL, body: Data, timeoutMs: Int, additionalHeaders: [String: String]?) async throws -> NetworkResponse { + if let decoded = try? JSONDecoder().decode(Batch.self, from: body) { + lock.withLock { _events.append(contentsOf: decoded.batch) } + } + return NetworkResponse(statusCode: 200, headers: [:], body: nil) + } + + func parseRetryAfterMs(from headers: [String: String]) -> Int? { nil } +} diff --git a/Tests/MetaRouterTests/Helpers/TestHelpers.swift b/Tests/MetaRouterTests/Helpers/TestHelpers.swift index 88e0941..b7f6b2c 100644 --- a/Tests/MetaRouterTests/Helpers/TestHelpers.swift +++ b/Tests/MetaRouterTests/Helpers/TestHelpers.swift @@ -155,6 +155,10 @@ final class MockAnalyticsInterface: AnalyticsInterface, @unchecked Sendable { func setTracing(_ enabled: Bool) { recordCall(.setTracing(enabled: enabled)) } + + func openURL(_ url: URL, sourceApplication: String?) { + recordCall(.openURL(url: url, sourceApplication: sourceApplication)) + } } // Analytics Call Recording @@ -175,6 +179,7 @@ enum AnalyticsCall: Equatable { case setAdvertisingId(advertisingId: String?) case clearAdvertisingId case setTracing(enabled: Bool) + case openURL(url: URL, sourceApplication: String?) } // CodableValue Test Extensions diff --git a/Tests/MetaRouterTests/InitOptionsTests.swift b/Tests/MetaRouterTests/InitOptionsTests.swift index 9eec840..e8040e3 100644 --- a/Tests/MetaRouterTests/InitOptionsTests.swift +++ b/Tests/MetaRouterTests/InitOptionsTests.swift @@ -80,6 +80,38 @@ final class InitOptionsTests: XCTestCase { XCTAssertFalse(output.contains("less than"), "equal values are not an inversion") } + + func testTrackLifecycleEventsDefaultsToFalse() { + let urlOptions = InitOptions( + writeKey: "wk", + ingestionHost: URL(string: "https://example.com")! + ) + XCTAssertFalse(urlOptions.trackLifecycleEvents, + "trackLifecycleEvents should default to false (URL initializer) โ€” opt-in feature") + + let stringOptions = InitOptions( + writeKey: "wk", + ingestionHost: "https://example.com" + ) + XCTAssertFalse(stringOptions.trackLifecycleEvents, + "trackLifecycleEvents should default to false (String initializer) โ€” opt-in feature") + } + + func testTrackLifecycleEventsCanBeEnabled() { + let urlOptions = InitOptions( + writeKey: "wk", + ingestionHost: URL(string: "https://example.com")!, + trackLifecycleEvents: true + ) + XCTAssertTrue(urlOptions.trackLifecycleEvents) + + let stringOptions = InitOptions( + writeKey: "wk", + ingestionHost: "https://example.com", + trackLifecycleEvents: true + ) + XCTAssertTrue(stringOptions.trackLifecycleEvents) + } } /// Captures both stdout and stderr during `block`. Used to assert on Logger.warn output. diff --git a/Tests/MetaRouterTests/LifecycleEventEmitterTests.swift b/Tests/MetaRouterTests/LifecycleEventEmitterTests.swift new file mode 100644 index 0000000..bb6c16d --- /dev/null +++ b/Tests/MetaRouterTests/LifecycleEventEmitterTests.swift @@ -0,0 +1,345 @@ +import XCTest +@testable import MetaRouter + +/// Unit tests for `LifecycleEventEmitter`. Each test owns: +/// - a unique UserDefaults suite (lifecycle + identity storage isolation) +/// - a real `EventEnrichmentService` over a stub `ContextProvider` +/// - a real `Dispatcher` whose memory queue we drain directly to inspect emits +/// +/// `Dispatcher.offer` does not auto-flush below `autoFlushThreshold` (20), so +/// 1โ€“2 emitted events stay in the queue and we drain them to assert. +final class LifecycleEventEmitterTests: XCTestCase { + private var suiteName: String! + private var defaults: UserDefaults! + private var queue: PersistentEventQueue! + private var dispatcher: Dispatcher! + private var enrichmentService: EventEnrichmentService! + private var identityStorage: IdentityStorage! + private var lifecycleStorage: LifecycleStorage! + private let appContext = AppContext(name: "test-app", version: "1.5.0", build: "42", namespace: "com.metarouter.test") + + override func setUp() async throws { + try await super.setUp() + suiteName = "com.metarouter.test.lifecycleEmitter.\(UUID().uuidString)" + defaults = UserDefaults(suiteName: suiteName) + identityStorage = IdentityStorage(userDefaults: defaults) + lifecycleStorage = LifecycleStorage(userDefaults: defaults) + + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("metarouter-emitter-\(UUID().uuidString)") + let diskStore = DiskStorage(baseDirectory: tempDir) + queue = PersistentEventQueue(diskStore: diskStore, maxEventCount: 1000) + + let options = TestDataFactory.makeInitOptions() + dispatcher = Dispatcher( + options: options, + http: SilentNetworking(), + persistentQueue: queue, + // Push thresholds high so offer() doesn't auto-flush during tests. + config: Dispatcher.Config( + endpointPath: "/v1/batch", + timeoutMs: 8000, + autoFlushThreshold: 9999, + initialMaxBatchSize: 100 + ) + ) + + let identityManager = IdentityManager( + storage: identityStorage, + writeKey: options.writeKey, + host: options.ingestionHost.absoluteString + ) + enrichmentService = EventEnrichmentService( + contextProvider: StubContextProvider(), + identityManager: identityManager, + writeKey: options.writeKey + ) + } + + override func tearDown() async throws { + defaults.removePersistentDomain(forName: suiteName) + defaults = nil + suiteName = nil + queue = nil + dispatcher = nil + enrichmentService = nil + identityStorage = nil + lifecycleStorage = nil + try await super.tearDown() + } + + // cold launch + + func testColdLaunchFreshInstallEmitsInstalledThenOpened() async { + let emitter = makeEmitter() + await emitter.emitColdLaunchSequence(initialAppState: .active) + + let events = await drain() + XCTAssertEqual(events.count, 2) + XCTAssertEqual(events[0].event, "Application Installed") + XCTAssertEqual(events[1].event, "Application Opened") + + XCTAssertEqual(events[0].properties?["version"], .string("1.5.0")) + XCTAssertEqual(events[0].properties?["build"], .string("42")) + + XCTAssertEqual(events[1].properties?["from_background"], .bool(false)) + XCTAssertEqual(events[1].properties?["version"], .string("1.5.0")) + XCTAssertEqual(events[1].properties?["build"], .string("42")) + + XCTAssertEqual(lifecycleStorage.getVersion(), "1.5.0") + XCTAssertEqual(lifecycleStorage.getBuild(), "42") + } + + /// Existing user upgrading from a pre-lifecycle SDK build: + /// no lifecycle storage, but identity storage already exists. Should be + /// `Application Updated{previous_*=unknown}`, NOT `Application Installed`. + func testColdLaunchSdkUpgradeEmitsUpdatedThenOpened() async { + identityStorage.set(.anonymousId, value: "existing-anon") + let emitter = makeEmitter() + await emitter.emitColdLaunchSequence(initialAppState: .active) + + let events = await drain() + XCTAssertEqual(events.count, 2) + XCTAssertEqual(events[0].event, "Application Updated") + XCTAssertEqual(events[0].properties?["previous_version"], .string("unknown")) + XCTAssertEqual(events[0].properties?["previous_build"], .string("unknown")) + XCTAssertEqual(events[0].properties?["version"], .string("1.5.0")) + XCTAssertEqual(events[0].properties?["build"], .string("42")) + + XCTAssertEqual(events[1].event, "Application Opened") + XCTAssertEqual(events[1].properties?["from_background"], .bool(false)) + } + + func testColdLaunchSameVersionEmitsOnlyOpened() async { + lifecycleStorage.setVersionBuild(version: "1.5.0", build: "42") + let emitter = makeEmitter() + await emitter.emitColdLaunchSequence(initialAppState: .active) + + let events = await drain() + XCTAssertEqual(events.count, 1) + XCTAssertEqual(events[0].event, "Application Opened") + XCTAssertEqual(events[0].properties?["from_background"], .bool(false)) + } + + func testColdLaunchVersionDifferenceEmitsUpdatedThenOpened() async { + lifecycleStorage.setVersionBuild(version: "1.4.0", build: "37") + let emitter = makeEmitter() + await emitter.emitColdLaunchSequence(initialAppState: .active) + + let events = await drain() + XCTAssertEqual(events.count, 2) + XCTAssertEqual(events[0].event, "Application Updated") + XCTAssertEqual(events[0].properties?["previous_version"], .string("1.4.0")) + XCTAssertEqual(events[0].properties?["previous_build"], .string("37")) + XCTAssertEqual(events[0].properties?["version"], .string("1.5.0")) + XCTAssertEqual(events[0].properties?["build"], .string("42")) + + XCTAssertEqual(events[1].event, "Application Opened") + } + + /// Build-only changes count as Updated (parity with Android plan). + func testColdLaunchBuildOnlyDifferenceEmitsUpdated() async { + lifecycleStorage.setVersionBuild(version: "1.5.0", build: "41") + let emitter = makeEmitter() + await emitter.emitColdLaunchSequence(initialAppState: .active) + + let events = await drain() + XCTAssertEqual(events.count, 2) + XCTAssertEqual(events[0].event, "Application Updated") + XCTAssertEqual(events[0].properties?["previous_version"], .string("1.5.0")) + XCTAssertEqual(events[0].properties?["previous_build"], .string("41")) + } + + /// Background-launched processes (silent push, background fetch) suppress + /// the cold-launch Opened. The next true foreground entry emits it. + func testColdLaunchInBackgroundSuppressesOpenedUntilForeground() async { + let emitter = makeEmitter() + await emitter.emitColdLaunchSequence(initialAppState: .background) + + var events = await drain() + XCTAssertEqual(events.count, 1) + XCTAssertEqual(events[0].event, "Application Installed") + + // First foreground entry emits Opened with from_background:false (cold-launch bridge) + await emitter.emitForegroundFromBackground() + events = await drain() + XCTAssertEqual(events.count, 1) + XCTAssertEqual(events[0].event, "Application Opened") + XCTAssertEqual(events[0].properties?["from_background"], .bool(false)) + } + + // foreground / background + + func testBackgroundedEmitsApplicationBackgrounded() async { + let emitter = makeEmitter() + await emitter.emitColdLaunchSequence(initialAppState: .active) + _ = await drain() + + await emitter.emitBackgrounded() + let events = await drain() + XCTAssertEqual(events.count, 1) + XCTAssertEqual(events[0].event, "Application Backgrounded") + // Spec: empty properties payload (Codable encodes nil as omitted) + XCTAssertNil(events[0].properties) + } + + func testForegroundAfterBackgroundEmitsOpenedFromBackground() async { + let emitter = makeEmitter() + await emitter.emitColdLaunchSequence(initialAppState: .active) + await emitter.emitBackgrounded() + _ = await drain() + + await emitter.emitForegroundFromBackground() + let events = await drain() + XCTAssertEqual(events.count, 1) + XCTAssertEqual(events[0].event, "Application Opened") + XCTAssertEqual(events[0].properties?["from_background"], .bool(true)) + } + + /// `inactive โ†’ active` transitions (Control Center, FaceID prompt, system + /// alert) must NOT emit Application Opened. Only `background โ†’ active` does. + func testInactiveToActiveTransitionDoesNotEmit() async { + let emitter = makeEmitter() + await emitter.emitColdLaunchSequence(initialAppState: .active) + _ = await drain() + + // Simulate Control Center: didBecomeActive fires without prior didEnterBackground. + // lastTrackedAppState was .active โ†’ still .active โ†’ no emit. + await emitter.emitForegroundFromBackground() + let events = await drain() + XCTAssertTrue(events.isEmpty, + "inactiveโ†’active transition must not emit Application Opened") + } + + /// First `didBecomeActive` during init should NOT double-emit. Cold-launch + /// path is the sole producer of the first Opened โ€” the observer's didBecomeActive + /// is suppressed until cold-launch flips `coldLaunchEmitted`. + func testFirstForegroundCallBeforeColdLaunchIsSuppressed() async { + let emitter = makeEmitter() + // Imagine the observer's didBecomeActive arriving before emitColdLaunchSequence. + await emitter.emitForegroundFromBackground() + let preCold = await drain() + XCTAssertTrue(preCold.isEmpty, + "Foreground call before cold launch must suppress (cold-launch path is sole producer)") + + await emitter.emitColdLaunchSequence(initialAppState: .active) + let cold = await drain() + XCTAssertEqual(cold.count, 2, "Cold launch should still emit Installed + Opened") + XCTAssertEqual(cold[0].event, "Application Installed") + XCTAssertEqual(cold[1].event, "Application Opened") + } + + /// Second backgroundโ†’active cycle still emits with from_background:true. + func testTwoBackgroundForegroundCyclesEmitOpenedTwice() async { + let emitter = makeEmitter() + await emitter.emitColdLaunchSequence(initialAppState: .active) + _ = await drain() + + await emitter.emitBackgrounded() + await emitter.emitForegroundFromBackground() + var events = await drain() + XCTAssertEqual(events.count, 2) + XCTAssertEqual(events[0].event, "Application Backgrounded") + XCTAssertEqual(events[1].event, "Application Opened") + XCTAssertEqual(events[1].properties?["from_background"], .bool(true)) + + await emitter.emitBackgrounded() + await emitter.emitForegroundFromBackground() + events = await drain() + XCTAssertEqual(events.count, 2) + XCTAssertEqual(events[1].event, "Application Opened") + XCTAssertEqual(events[1].properties?["from_background"], .bool(true)) + } + + // deep-link buffer + + func testOpenURLAttachesUrlAndReferringApplicationToNextOpened() async { + let emitter = makeEmitter() + await emitter.openURL( + url: "myapp://product/42", + sourceApplication: "com.example.referrer" + ) + await emitter.emitColdLaunchSequence(initialAppState: .active) + + let events = await drain() + XCTAssertEqual(events.count, 2) + let opened = events[1] + XCTAssertEqual(opened.event, "Application Opened") + XCTAssertEqual(opened.properties?["url"], .string("myapp://product/42")) + XCTAssertEqual(opened.properties?["referring_application"], .string("com.example.referrer")) + } + + func testDeepLinkBufferIsOneShot() async { + let emitter = makeEmitter() + await emitter.openURL(url: "myapp://x", sourceApplication: nil) + await emitter.emitColdLaunchSequence(initialAppState: .active) + _ = await drain() + + // Second Opened (backgroundโ†’active) without a new openURL should not + // carry buffered URL. + await emitter.emitBackgrounded() + await emitter.emitForegroundFromBackground() + let events = await drain() + XCTAssertEqual(events.count, 2) + let opened = events[1] + XCTAssertEqual(opened.event, "Application Opened") + XCTAssertNil(opened.properties?["url"]) + XCTAssertNil(opened.properties?["referring_application"]) + } + + func testOpenURLWithoutSourceOmitsReferringApplication() async { + let emitter = makeEmitter() + await emitter.openURL(url: "myapp://x", sourceApplication: nil) + await emitter.emitColdLaunchSequence(initialAppState: .active) + + let events = await drain() + let opened = events[1] + XCTAssertEqual(opened.properties?["url"], .string("myapp://x")) + XCTAssertNil(opened.properties?["referring_application"], + "Optional source must be omitted, not emitted as null") + } + + // helpers + + private func makeEmitter() -> LifecycleEventEmitter { + return LifecycleEventEmitter( + enrichmentService: enrichmentService, + dispatcher: dispatcher, + storage: lifecycleStorage, + identityStorage: identityStorage, + appContext: appContext + ) + } + + private func drain() async -> [EnrichedEventPayload] { + return await queue.drain(max: 100) + } +} + +/// Networking stub that swallows every request so flushes have no side-effects +/// in tests that nonetheless drive the dispatcher path. +private final class SilentNetworking: Networking, @unchecked Sendable { + func postJSON(url: URL, body: Data, timeoutMs: Int, additionalHeaders: [String: String]?) async throws -> NetworkResponse { + return NetworkResponse(statusCode: 200, headers: [:], body: nil) + } + func parseRetryAfterMs(from headers: [String: String]) -> Int? { nil } +} + +/// Minimal context provider for enrichment tests โ€” returns a deterministic +/// EventContext without touching real platform APIs. +private struct StubContextProvider: ContextProvider { + func getContext() async -> EventContext { + return EventContext( + app: AppContext(name: "test-app", version: "1.5.0", build: "42", namespace: "com.metarouter.test"), + device: DeviceContext(manufacturer: "Apple", model: "test-model", type: "ios"), + library: LibraryContext(name: "metarouter-ios-sdk", version: MetaRouterSDK.version), + os: OSContext(name: "iOS", version: "18.0"), + screen: ScreenContext(density: 2.0, width: 100, height: 100), + network: nil, + locale: "en_US", + timezone: "UTC" + ) + } + + func clearCache() {} +} 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()) + } +} diff --git a/Tests/MetaRouterTests/PersistentEventQueueTests.swift b/Tests/MetaRouterTests/PersistentEventQueueTests.swift index ad741ad..43df9e2 100644 --- a/Tests/MetaRouterTests/PersistentEventQueueTests.swift +++ b/Tests/MetaRouterTests/PersistentEventQueueTests.swift @@ -410,7 +410,7 @@ final class PersistentEventQueueTests: XCTestCase { "Events older than 7 days should be filtered out on drain") } - // MARK: - Pending overflow on disk write failure + // Pending overflow on disk write failure /// Poison the tempDir path so DiskStorage.ensureDirectory fails. /// Creates a regular file where the queue expects a directory. @@ -461,7 +461,7 @@ final class PersistentEventQueueTests: XCTestCase { XCTAssertEqual(snapshot?.events.count, 6) } - // MARK: - Byte cap enforcement on enqueue + // Byte cap enforcement on enqueue func testEnqueueAtByteCapFlushesMemoryToDisk() async throws { // maxSizeBytes small enough that a few test events exceed it.