Skip to content

feat: LifecycleEventTracker algorithm [2/4]#31

Open
choudlet wants to merge 2 commits into
chrish/sc-38233/lifecycle-storage-foundationfrom
chrish/sc-38234/lifecycle-event-tracker
Open

feat: LifecycleEventTracker algorithm [2/4]#31
choudlet wants to merge 2 commits into
chrish/sc-38233/lifecycle-storage-foundationfrom
chrish/sc-38234/lifecycle-event-tracker

Conversation

@choudlet

Copy link
Copy Markdown
Collaborator

Shortcut: sc-38234
Parent: sc-36799
iOS reference: sc-38229
Slice 2 of 4 — standalone tracker; not yet wired in.

Stacked on #30 (sc-38233). Merge that first; this PR will retarget to main automatically.

Summary

The lifecycle-event algorithm — install/update detection, cold-launch sequencing, foreground/background transitions, and the one-shot deep-link buffer. Lands as one self-contained class with thorough Robolectric-backed unit tests so reviewers can validate the state machine in isolation.

Cold-launch decision tree

  • no persisted (version, build), no identity → Application Installed
  • no persisted (version, build), identity present → Application Updated{previous_version:"unknown", previous_build:"unknown"} (SDK upgrade)
  • persisted (version, build) matches → no install/update event
  • persisted (version, build) differs (version OR build) → Application Updated with prior values

Cold-launch Opened ordering

  • foreground at SDK init → emit immediately, suppress the imminent ProcessLifecycleOwner.onStart so we don't double-emit
  • background at SDK init (silent push, JobScheduler, WorkManager) → defer; first true ON_START emits with from_background=false as the cold-launch bridge

Deep-link buffer

  • one-shot, last-write-wins, cleared on next Application Opened emit
  • openURL(uri, sourceApplication) is the named-after-signal API — slice 3 exposes it publicly

The tracker has no on/off switch; when the feature is disabled the host (LifecycleCoordinator, slice 3) simply never constructs an instance. AppContext is injected (the cached snapshot from slice 1) so the tracker never touches PackageManager.

Stack

  1. sc-38233 — storage + bundle metadata foundation
  2. this PRLifecycleEventTracker algorithm + tests
  3. sc-38235 — MetaRouterAnalyticsClient wiring + openURL public API
  4. sc-38236 — README documentation

Test plan

  • ./gradlew :metarouter-sdk:test passes
  • LifecycleEventTrackerTest — full state-machine coverage (cold-launch tree, foreground deferral, deep-link one-shot/last-write-wins, two-cycle background→foreground)

storedVersion == null && storedBuild == null -> {
if (identityManager.hasAnyValue()) {
// Existing user upgrading from a pre-lifecycle SDK build.
emitUpdated(currentVersion, currentBuild, LIFECYCLE_UNKNOWN, LIFECYCLE_UNKNOWN)

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.

in an unknown state here where there is identify info stored but no previous builds and versions so just run with UNKNOWN

The lifecycle event tracker — install/update detection, cold-launch
sequencing, foreground/background transitions, and the one-shot
deep-link buffer. Standalone class; not yet wired into
MetaRouterAnalyticsClient (slice 3 does the wiring).

Cold-launch decision tree:
- no persisted state, no identity → Application Installed
- no persisted state, identity present → Application Updated with
  previous_version/previous_build = 'unknown' (SDK upgrade)
- persisted (version, build) matches → no install/update event
- persisted (version, build) differs → Application Updated with prior
  values

Cold-launch Opened ordering depends on the foreground-state probe:
- foreground at SDK init → emit Opened immediately and suppress the
  imminent ProcessLifecycleOwner.onStart so we don't double-emit
- background at SDK init (silent push, JobScheduler, WorkManager) →
  defer Opened to the first true ON_START transition, which then emits
  with from_background=false as the cold-launch bridge

Deep-link buffer is one-shot and last-write-wins. Multiple openURL
calls before the next Opened keep only the most recent URL; the buffer
clears once attached to an Opened payload.

The tracker has no on/off switch — when the feature is disabled the
host (LifecycleCoordinator, slice 3) simply never constructs an
instance. AppContext is injected (the cached snapshot from slice 1)
so the tracker never reads PackageManager itself.

Refs: sc-38234
Three follow-ups from code review on slice 2.

- defaultForegroundCheck: narrow the catch from Throwable to
  IllegalStateException + NoClassDefFoundError. The wide catch swallowed
  OOM / StackOverflowError silently. ProcessLifecycleOwner.get() throws
  ISE off the main thread; the lifecycle-process artifact may be
  stripped in test setups, hence NoClassDefFoundError. Anything else
  surfaces as a real bug.
- onSdkReady: AtomicBoolean idempotency guard. If the host accidentally
  invokes the cold-launch sequence twice (future re-init path, test
  misuse) we used to re-emit Installed/Updated/Opened and stomp
  suppressNextForeground, which would eat the next legitimate foreground
  transition.
- KDoc: openURL / handleDeepLink reference said EXTRA_REFERRER goes
  through getStringExtra. Per Android docs EXTRA_REFERRER is a Uri, not
  a String — getStringExtra on it is essentially always null. Point
  hosts at Activity.referrer?.host instead.
- Tests cover the new idempotency guard and the onForeground-before-
  onSdkReady contract (documenting what happens if the host wiring
  invariant breaks).

Refs: sc-38234
@choudlet choudlet force-pushed the chrish/sc-38234/lifecycle-event-tracker branch from 78e1343 to f30267a Compare April 27, 2026 21:55
@choudlet

Copy link
Copy Markdown
Collaborator Author

This has the most complex logic in terms of when the SDK decides to emit certain events. Tried to be verbose with the comments.

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.

2 participants