Skip to content

feat: app-lifecycle [ios]#41

Closed
choudlet wants to merge 9 commits into
mainfrom
chrish/sc-36764/story-application-lifecycle-events-ios
Closed

feat: app-lifecycle [ios]#41
choudlet wants to merge 9 commits into
mainfrom
chrish/sc-36764/story-application-lifecycle-events-ios

Conversation

@choudlet
Copy link
Copy Markdown
Collaborator

Ticket

sc-36764 — Application lifecycle events for iOS.

Summary

Adds the four standard lifecycle events to the iOS SDK, gated behind InitOptions.trackLifecycleEvents (default true):

Event Properties
Application Installed version, build
Application Updated version, build, previous_version, previous_build
Application Opened from_background, version, build, optional referring_application, optional url
Application Backgrounded (none)

All events flow through the standard track() → enrichment → dispatcher path.

Cold launch

  • Compares persisted (version, build) to the current bundle to decide between Installed, Updated, or no-op.
  • Persists current (version, build) under metarouter:lifecycle:* keys in UserDefaults.standard — namespace is independent of identity keys, so reset() cannot wipe install/update state.
  • Existing user upgrading from a pre-lifecycle SDK build (no persisted version, but identity already present) emits Application Updated{previous_*=unknown} to avoid a spurious install spike.
  • Application Opened with from_background: false only emits when the process is in foreground at emit time. Background-launched processes (silent push, background fetch) suppress; the next true foreground entry emits instead.

Resume / background

  • Only background → active transitions emit Application Opened {from_background: true}. inactive → active (Control Center, FaceID prompt, system alert) is suppressed.
  • Application Backgrounded is enqueued at the front of onBackgroundAsync so it lands in the same flushToDisk() drain.
  • The first didBecomeActive after launch is suppressed via coldLaunchEmitted — the cold-launch path is the sole producer of the first Application Opened.

Public API

  • InitOptions.trackLifecycleEvents: Bool = true
  • AnalyticsInterface.handleDeepLink(url: URL, sourceApplication: String?) — host calls from application(_:didFinishLaunchingWithOptions:) / application(_:open:options:) / SceneDelegate. Buffered values attach to the next Application Opened (one-shot).

Implementation notes

  • New LifecycleEventEmitter actor owns install/update detection, the cold-launch flag, the deep-link buffer, and the three emit entrypoints. Concurrency is serialised through actor isolation.
  • New LifecycleStorage wraps the two UserDefaults keys.
  • New IdentityStorage.hasAnyValue() helper lets the emitter snapshot identity presence at construction time, before IdentityManager.initialize() auto-creates an anonymousId.
  • SDK version bumped 1.4.0 → 1.5.0.

Test plan

  • swift test — full suite passes (446 tests, 0 failures).
  • swift test --filter Lifecycle — 28 lifecycle-specific tests pass across LifecycleStorageTests, LifecycleEventEmitterTests, AppLifecycleEventIntegrationTests.
  • InitOptionsTests covers the new flag default + explicit override.
  • Storage isolation: LifecycleStorage keys survive IdentityStorage.clear().
  • Emitter unit tests cover fresh install, SDK upgrade, same version, version drift, build-only drift, background cold launch, foreground resume, inactive → active suppression, double-emit suppression, deep-link buffer (one-shot), and trackLifecycleEvents=false.
  • Integration tests drive a real AnalyticsClient via dependency injection, post NSApplication.*Notifications through NotificationCenter, and assert events flow through the queue/network path. Includes reset() not wiping lifecycle storage and re-init on same version emitting only Opened.
  • Manual smoke test in a host iOS app (not gated on this PR).

Notes for reviewers

  • Build-only changes (same version, different build) emit Application Updated for parity with the Android plan.
  • Process-level UIApplication.*Notifications are observed; per-scene UIScene.*Notification transitions are not tracked (documented limitation; revisit if a customer needs scene-level granularity).
  • getDebugInfo() exposure of stored lifecycle version/build was deliberately not added — not in the acceptance criteria; can follow up if reviewers want it.

Adds the four standard lifecycle events (Application Installed/Updated/Opened/
Backgrounded), gated behind InitOptions.trackLifecycleEvents (default true).
Events flow through the standard track() path so they pick up enrichment,
identity, and dispatcher batching like any other event.

Cold launch compares persisted (version, build) — stored under
metarouter:lifecycle:* in UserDefaults, separate from identity keys so reset()
cannot wipe install/update state — to the current bundle to decide whether to
emit Installed, Updated, or neither, then emits Opened with from_background:false
when the process is in foreground at emit time.

Resume: only background → active emits Application Opened{from_background:true};
inactive → active transitions (Control Center, FaceID prompt, system alert) are
suppressed.

Background: Application Backgrounded is enqueued at the front of
onBackgroundAsync so it lands in the same flushToDisk() drain.

Adds AnalyticsInterface.handleDeepLink(url:sourceApplication:) — host calls it
from didFinishLaunchingWithOptions / SceneDelegate; buffered values attach to
the next Application Opened (one-shot).

Bumps SDK version to 1.5.0.
var lifecycleStorage: LifecycleStorage?
var identityStorage: IdentityStorage?
var appVersionInfo: AppVersionInfo?
/// Override the initial app foreground state read at cold launch.
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

only for testing purposes

@@ -85,21 +111,8 @@ internal final class AnalyticsClient: AnalyticsInterface, CustomStringConvertibl
}

self.lifecycle = AppLifecycleObserver(
Copy link
Copy Markdown
Collaborator Author

@choudlet choudlet Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getting verbose and split these closures out into named methods, small refactor


public func openURL(_ url: URL, sourceApplication: String?) {
guard let coordinator = lifecycleCoordinator else {
Logger.warn("openURL called but trackLifecycleEvents is disabled — ignoring")
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

currently we are only using this openURL to enrich Application Opened events when trackLifecycleEvents is true. In the future we will add additional methods to more robustly handle deep links. Most notable here would be setting campaignvalues and enriching all events with that ad or campaign level information .

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)
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

straight pass through once client is bound


/// Reads `UIApplication.applicationState` on the main actor (it's main-actor
/// isolated) and maps to our platform-neutral `AppForegroundState`.
fileprivate func currentAppForegroundState() async -> AppForegroundState {
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

need to run this on MainActor as UIApplication.shared is @mainactor isolated.

import Foundation

/// Process-level foreground state used to gate cold-launch and resume emits.
public enum AppForegroundState: Sendable, Equatable {
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same value as UIApplication.state but not dependenant on UIKit (so this would work on macOS)

// 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 appMetadata = deps.appMetadata ?? .fromBundle()
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

since we are reading this same appMetadata twice (one in per event context and the other for comparing version + build number for LifecycleEvents) I went consolidated the read from info bundle to one time in init and then utilize in both places.

@choudlet
Copy link
Copy Markdown
Collaborator Author

Superseded by a stack of four smaller, reviewable PRs (~250–600 LOC each):

Same ticket (sc-36764); each slice has its own sub-story (sc-38228 → sc-38231) with scoped acceptance criteria. Stack each merges sequentially; feature stays gated by trackLifecycleEvents: false (default) until #44 lands so partial-feature exposure on main is impossible.

Closing this PR; the original branch will be deleted once all four sub-PRs merge.

@choudlet choudlet closed this Apr 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant