dreamicBootstrap function

Future<void> dreamicBootstrap({
  1. required FirebaseOptions firebaseOptions,
  2. required DreamicServicesInitializer servicesInitializer,
  3. Map<String, dynamic>? additionalRemoteConfigDefaults,
  4. DreamicBootstrapHook? afterFirebaseInit,
  5. DreamicBootstrapHook? registerBeforeServices,
  6. DreamicBootstrapHook? registerAfterServices,
  7. DreamicBootstrapHook? captureEntryIntents,
  8. bool appCubitNetworkRequired = true,
  9. Uri? appCubitEntranceUri,
  10. Duration? bootstrapTimeout = const Duration(seconds: 45),
  11. bool? attachErrorReportingFirst,
  12. Duration? firebaseInitTimeout = const Duration(seconds: 30),
  13. Duration? firebaseRecoverIfRegisteredAfter = const Duration(seconds: 4),
  14. AppCheckConfig? appCheck,
})

Runs the cold-start init chain that today sits in main() — Firebase, error backend attach, remote config, app configs base, emulator connect, DreamicServices.initialize, appInitAppCubit — now behind the splash rather than before runApp. Returns a single Future<void> the DreamicAppInitHost/DreamicAppInitGate gates the router on.

Ordering (fixed)

appInitFirebase
  → [hook] afterFirebaseInit          (Firebase/Firestore instance settings)
  → appInitAppCheck                   (dreamic-owned, opt-in via [appCheck])
  → appInitErrorHandling              (attach reporter + FLUSH early buffer)
  → appInitRemoteConfig
  → appInitAppConfigsBase
  → appInitConnectToFirebaseEmulatorIfNecessary
  → [hook] registerBeforeServices     (DDS-006: AppRouter + UserRepoInt)
  → servicesInitializer               (DreamicServices.initialize)
  → [hook] registerAfterServices
  → appInitAppCubit                   (network check inside the splash)
  → [hook] captureEntryIntents        (cold deep-link capture)

The whole sequence is wrapped in an outer hang-timeout (bootstrapTimeout, default 45s, nullable to disable) so a true hang becomes an init-error (gate errorWidget) rather than an infinite splash. On expiry the thrown TimeoutException names the step that was in flight (the → [stepName] stamped above), so a hang is diagnosable in the backend.

Early error-reporting attach (attachErrorReportingFirst)

appInitErrorHandling (the reporter attach + early-buffer flush) can run at one of two points:

  • step 2, AFTER Firebase init + the afterFirebaseInit hook — required for a Firebase-dependent reporter (e.g. Crashlytics), which cannot attach until Firebase exists; but a hang or throw in step 1 / that hook then reports only into the early buffer, which never transmits if the bootstrap then hangs (the gate's loge fires before any backend is attached);
  • step 0, BEFORE Firebase — possible for a self-contained reporter (Sentry and the like) that needs no Firebase, catching the most startup errors (a step-1 / afterFirebaseInit failure, including the outer hang-timeout firing during them, reaches the backend). The step-2 attach is then skipped (single init).

Great default (no app wiring): when attachErrorReportingFirst is left null (the default), dreamic derives the right choice from the configured errorReportingConfig — attach at step 0 iff there is a reporter that does NOT require Firebase (reporterRequiresFirebase: false, e.g. Sentry), else step 2. So a self-contained (Sentry-style) consumer gets maximal startup coverage for free; a Firebase-dependent reporter (e.g. Crashlytics, declared with reporterRequiresFirebase: true) keeps the post-Firebase attach. Pass an explicit true/false only to override this derivation. For the derivation to see it, configureErrorReporting(...) must run before dreamicBootstrap — the canonical pre-runApp main() line.

Per-step Firebase-init recovery (firebaseInitTimeout +

firebaseRecoverIfRegisteredAfter)

Bounds step 1 (appInitFirebase) — the CONFIRMED hang site on a returning iOS WebKit device: Firebase.initializeApp registers the app, then auth's onWaitInitState permanently hangs on the persisted-session IndexedDB read of firebaseLocalStorageDb. These thread into appInitFirebase as a two-tier recovery (no per-app tuning needed):

  • firebaseRecoverIfRegisteredAfter (short grace, default 4s): if init hasn't settled but the app IS registered, recover via Firebase.app() on the FIRST attempt — fast, no error screen / retry cycle. Reports the occurrence (which reaches the backend whether the reporter attached before or after Firebase).
  • firebaseInitTimeout (outer settle bound, default 30s): if the app is NOT registered at the grace (a slow-but-healthy SDK load), keep waiting the remainder rather than false-tripping a slow cold start into a retry; on the bound, recover if finally registered, else throw a diagnosable TimeoutException into the host's auto-retry / error path.

These defaults make the recovery universal — every consumer (incl. Crashlytics apps) benefits with zero per-app config. The outer bootstrapTimeout remains a coarse whole-sequence backstop. Pass firebaseInitTimeout: null to disable the bound entirely (the grace is then ignored).

App Check (appCheck)

dreamic owns App Check activation as a first-class capability (like Remote Config): pass an AppCheckConfig (with the web reCAPTCHA site key + optional provider overrides) and dreamic selects providers (debug fallbacks + keyless-web guard), activates bounded + non-critical, and enables auto-refresh. Opt-in by presence — a null appCheck (the default) skips it. Activation NEVER blocks boot: App Check is consumed lazily (token fetched on the first attested call; enforcement is server-side), so a timeout/failure is reported (defer/flush, so Crashlytics consumers see it) and boot continues.

Per-task failure policy (Issues 81/84)

  • Fatal (uncaught throw aborts the Future → gate retry): appInitFirebase, appInitErrorHandling, appInitAppConfigsBase, emulator connect, servicesInitializer (DreamicServices.initialize), appInitAppCubit, and any uncaught throw from an app hook — dreamic-core does not wrap hooks.
  • Non-fatal dreamic-core-owned: only appInitRemoteConfig, which already internally swallows its fetch error and falls back to defaults, so it needs no extra wrapper here. Non-critical hook work is the app's own try/catch responsibility.

Idempotency (REQUIRED for retry to recover)

Every dreamic-core step is re-runnable: Firebase init is guarded, remote-config / app-cubit registrations are isRegistered-guarded, the emulator-connect and isolate-listener adds are apply-once. App hooks must be idempotent too (the app's responsibility).

Implementation

Future<void> dreamicBootstrap({
  required FirebaseOptions firebaseOptions,
  required DreamicServicesInitializer servicesInitializer,
  Map<String, dynamic>? additionalRemoteConfigDefaults,
  DreamicBootstrapHook? afterFirebaseInit,
  DreamicBootstrapHook? registerBeforeServices,
  DreamicBootstrapHook? registerAfterServices,
  DreamicBootstrapHook? captureEntryIntents,
  bool appCubitNetworkRequired = true,
  Uri? appCubitEntranceUri,
  Duration? bootstrapTimeout = const Duration(seconds: 45),
  bool? attachErrorReportingFirst,
  Duration? firebaseInitTimeout = const Duration(seconds: 30),
  Duration? firebaseRecoverIfRegisteredAfter = const Duration(seconds: 4),
  AppCheckConfig? appCheck,
}) {
  final future = _runBootstrap(
    firebaseOptions: firebaseOptions,
    servicesInitializer: servicesInitializer,
    additionalRemoteConfigDefaults: additionalRemoteConfigDefaults,
    afterFirebaseInit: afterFirebaseInit,
    registerBeforeServices: registerBeforeServices,
    registerAfterServices: registerAfterServices,
    captureEntryIntents: captureEntryIntents,
    appCubitNetworkRequired: appCubitNetworkRequired,
    appCubitEntranceUri: appCubitEntranceUri,
    attachErrorReportingFirst:
        resolveAttachErrorReportingFirst(attachErrorReportingFirst),
    firebaseInitTimeout: firebaseInitTimeout,
    firebaseRecoverIfRegisteredAfter: firebaseRecoverIfRegisteredAfter,
    appCheck: appCheck,
  );

  // Compose the outer hang-timeout INSIDE the bootstrap Future (the gate has no
  // internal timeout). On expiry a `TimeoutException` is thrown → gate
  // `errorWidget`. Null disables (tests, special cases). No in-Future
  // retry-with-backoff — recovery is the user-facing idempotent re-mount
  // (Issue 81). Note: `Future.timeout` does NOT cancel the underlying work
  // (accepted stop-gap, Issue 53).
  if (bootstrapTimeout == null) {
    return future;
  }
  // The `onTimeout` names the step that was in flight so the resulting error is
  // diagnosable. A pure `Future.timeout(d)` throws a bare `TimeoutException`
  // ("Future not completed") with no clue where the sequence stalled — useless
  // for a hang that, by definition, leaves no stack trace. With
  // `attachErrorReportingFirst` the reporter is already attached when this
  // throws, so the named message reaches the backend (Sentry/Crashlytics) via
  // the gate's `onError → loge`.
  return future.timeout(
    bootstrapTimeout,
    onTimeout: () => throw TimeoutException(
      'dreamicBootstrap hung for ${bootstrapTimeout.inSeconds}s '
      'during step "$_currentBootstrapStep"',
      bootstrapTimeout,
    ),
  );
}