App Gates

Biometric lock, updates, onboarding, legal consent, and auth routing.

App gates are the launch-time and cross-cutting flows that decide what the user sees before the main shell: biometric unlock, error fallback, updates, onboarding, legal consent, and session-driven auth routing.

Some template notes list an older order. The current code order is BiometricGate -> ErrorBoundary -> UpdateGate -> OnboardingGate -> ConsentGate -> MainNavGraph.

What it provides

GateStatusImplementation
Biometric app lockIntegratedBiometricGate wraps the entire app content
Error fallbackIntegratedErrorBoundary observes AppErrorBus
Update gatePlay-onlyUpdateGate shows force or soft update dialogs
OnboardingIntegratedFour-page HorizontalPager, persisted in DataStore
Legal consentIntegratedVersioned terms/privacy/EULA acceptance in DataStore
Auth routingIntegratedSessionController.currentUser pops the auth graph after sign-in
Store reviewPlay-onlyRatingController prompts after smart triggers

Key files

Launch composition

MainActivity sets the app theme and locale host, then wraps the navigation graph like this:

LocalizedAppHost
  -> BiometricGate
  -> ErrorBoundary
  -> AppNavGraph
      -> UpdateGate
      -> OnboardingGate
      -> ConsentGate
      -> MainNavGraph
          -> MainShell or AuthGraph

MainNavGraph is session-driven. It starts at the main shell, can open the auth graph when a screen requires sign-in, and pops the auth graph when SessionController.currentUser becomes non-null.

Biometric gate

BiometricGate protects the whole app only when the user enabled biometric lock from Account Security. It uses BiometricManager with BIOMETRIC_STRONG, stores the enabled flag in DataStore, and keeps lastAuthenticatedAtMillis in memory.

Behavior:

  • If biometric lock is disabled, the gate immediately renders content.
  • If no strong biometric is enrolled, Account Security reports NotEnrolled or Unavailable.
  • If enabled and the relock delay has passed, the gate shows a lock screen and opens BiometricPrompt.
  • The default relock delay is 30_000 milliseconds.

Update gate

UpdateGate is Play-only for the real update flow. It reads these BuildConfig values from local.properties:

MIN_APP_VERSION=1.0.0
LATEST_APP_VERSION=1.1.0
APP_UPDATE_URL=https://play.google.com/store/apps/details?id=com.soarstarter.kotlin
APP_UPDATE_MESSAGE=Update to keep using the latest experience.

UpdateController compares those versions against BuildConfig.VERSION_NAME. If the installed version is below MIN_APP_VERSION, it shows a non-dismissable force-update dialog and requests a Play In-App Updates immediate update. If the installed version is below LATEST_APP_VERSION, it shows a dismissible soft update and requests a flexible update.

When Play In-App Updates cannot start, the app opens APP_UPDATE_URL, or the default Play Store URL for the package. Local debug builds can test version comparison and the fallback link; Play-delivered builds are needed to verify the real in-app update flow.

Onboarding gate

OnboardingGate waits for the DataStore-backed onboardingCompleted flag to hydrate. If the flag is false, it shows OnboardingScreen, a four-page HorizontalPager with skip, next, and get-started actions.

When onboarding completes, the gate:

  1. Tracks AnalyticsEvent.OnboardingCompleted(slideCount).
  2. Persists onboarding_completed = true in DataStore.
  3. Records a store-review trigger with reason onboarding_completed.

The Showcase tab also contains an onboarding replay screen, but that replay does not change the real DataStore flag.

ConsentGate waits for the accepted legal version to hydrate. The current version is a code constant:

const val LEGAL_VERSION = "2026-04-30"

It is not a local.properties key. Bump this constant when bundled terms/privacy/EULA text changes and you want users to accept again.

Legal documents are bundled string resources with optional online URLs from:

LEGAL_TERMS_URL=https://example.com/terms
LEGAL_PRIVACY_URL=https://example.com/privacy
LEGAL_EULA_URL=https://example.com/eula

The gate stores the accepted version in DataStore under accepted_legal_version.

The current app starts PostHog in SoarApp.onCreate() before ConsentGate. The legal gate captures acceptance of bundled terms; it does not currently block analytics initialization. If your policy requires consent-before- tracking, move analyticsController.start() behind hasAcceptedCurrent or keep POSTHOG_DISABLED=true until consent is collected.

Store review

RatingController wraps Google Play In-App Review. It records trigger actions from onboarding completion and referral sharing, and the Showcase rating demo can request the flow manually.

The smart prompt rules are:

RuleValue
Minimum recorded actions3
Same-version repeat promptBlocked
Cooldown after a shown prompt90 days

The real review UI is Play-only and controlled by Google Play. A debug build can call the controller, but Play may choose not to show a dialog.

Customizing gates

  • Reorder gates by changing the wrappers in MainActivity and AppNavGraph.
  • Remove onboarding by deleting OnboardingGate and its DataStore key reads.
  • Remove legal consent by deleting ConsentGate, or keep the legal screens and open them from Settings only.
  • Change biometric timing by passing a different relockDelayMillis to BiometricGate.
  • Replace static update values with hosted config by feeding UpdateController.overrideRemoteConfig().

Verify

  • Fresh install shows onboarding, then legal consent, then the main shell.
  • Bumping LEGAL_VERSION re-prompts the consent gate.
  • Setting MIN_APP_VERSION above the current versionName shows a force update dialog.
  • Setting LATEST_APP_VERSION above the current versionName shows a soft update dialog that can be dismissed once for that version.
  • Enabling biometric lock from Account Security locks the app after the relock delay.
  • Store review triggers only after the action count, version, and cooldown rules allow it.

On this page