build method

Initialize a Notifier.

It is safe to use Ref.watch or Ref.listen inside this method.

If a dependency of this Notifier (when using Ref.watch) changes, then build will be re-executed. On the other hand, the Notifier will not be recreated. Its instance will be preserved between executions of build.

If this method throws, reading this provider will rethrow the error.

Implementation

@override
WalletSyncDrive build() {
  // Riverpod REUSES this notifier instance across a dependency-change rebuild
  // (it re-runs build() rather than constructing a fresh object), and the
  // prior build's onDispose fires first — which would leave `_disposed` stuck
  // true and `_wasPaused` stale on the reused instance. Reset both at the top
  // so the controller is correct on every (re)build, fresh instance or not.
  // (Since FR-51 production DOES rebuild null→session: a host that listens
  // to walletSyncDriveProvider at its root builds the drive before the
  // wallet opens. This reset is what makes that path correct.)
  _disposed = false;
  _wasPaused = false;
  _generation++;

  final session = ref.watch(walletSessionProvider);
  if (!identical(session, _lastSession)) {
    _lastSession = session;
    _startFailed = false; // a new wallet has not failed anything yet
  }
  ref.onDispose(() {
    _disposed = true;
    // Best-effort stop on teardown — idempotent and loses no progress.
    // Dispose can't await, but the stop MUST ride the [_inFlight] serializer
    // (fold, the one command that didn't): since #383 made build()
    // watch the sync POLICY, a policy flip re-runs build() on the SAME
    // session — the first same-handle stop/start neighborhood. Dispatched
    // fire-and-forget (the pre-#383 shape), this stop and an in-flight or
    // just-issued start are two unordered FFI calls racing on one Rust
    // controller: orderings ending in the start leave the loop RUNNING under
    // a "Sync off" badge; the mirror flip leaves a stopped loop under
    // `running` — which desktop (no `paused` lifecycle event) never heals.
    // Chained, it provably runs after any in-flight start and before the
    // next build's `_command(start)` (the notifier and `_inFlight` survive
    // the rebuild). On a session SWITCH this also orders the dead handle's
    // stop before the new session's start — a LIVENESS COUPLING, not free
    // tidiness: a wedged dead-handle `stopSync` would
    // otherwise starve the NEW wallet's first start forever behind
    // "Connecting…" (desktop never gets the lifecycle re-drive, and
    // retry() chains behind the same tail). So this one link — best-effort
    // by contract — is BOUNDED by the package FFI wedge timeout: on
    // timeout the wedged stop is abandoned and the chain proceeds. The
    // bounded window deliberately re-opens a sliver of the stop/start race
    // it serializes, only in the already-pathological wedged-FFI case.
    // `.catchError` is REQUIRED: the chain must never reject (it is
    // re-`then`-ed forever). On an abrupt process kill the stop may never
    // reach the SDK at all, but that is safe: scan progress is durable
    // (the chain is the source of truth), so the next open resumes where
    // it left off.
    if (session != null) {
      _inFlight = _inFlight
          .then(
            (_) => session.stopSync().timeout(
              walletFfiWedgeTimeout,
              onTimeout: () {},
            ),
          )
          .catchError((Object _) {});
    }
  });
  ref.listen<AppLifecycleState>(appLifecycleProvider, (_, next) {
    _onLifecycle(next);
  });

  if (session == null) return WalletSyncDrive.inactive;

  // The host's sync policy (#383 R1) — WATCHED, so a host flip re-runs this
  // build: policy→false rebuilds through the prior cycle's onDispose (which
  // best-effort stops the loop) and settles here without a start;
  // policy→true falls through to the normal start below. Checked BEFORE the
  // lifecycle arm so a born-paused disabled host reads "disabled", not
  // "suspended" (the honest reason wins).
  if (!ref.watch(walletSyncPolicyProvider)) {
    return WalletSyncDrive.disabledByHost;
  }

  // Born-backgrounded cold start (Android push-trampoline): do NOT start the
  // loop while paused — the first real resume starts it (no background scan).
  //
  // #407 R7 — PRESERVE A FAILED START HERE TOO. #403 R5 taught the stop-settle
  // to keep `failed` across a background cycle, but this arm returned
  // `suspended` unconditionally, and `suspended` is a PASSES-RUN state. A host
  // whose sync policy flips while backgrounded (battery saver, metered, an org
  // policy) re-runs build() through this line, so the unqualified "your wallet
  // will send this on a later sync" promise came back — and it is still there
  // on the first frames after the user foregrounds, because the resume's
  // `_command(start)` writes no optimistic state and only corrects to `failed`
  // once it settles. That flash is exactly what #403 R5 removed.
  //
  // IT READS `_startFailed`, NOT THE CURRENT STATE — measured, after a first
  // attempt at the latter was dead code. The only path that re-runs build()
  // while paused is a POLICY flip, and a flip to `false` legitimately settles
  // the state to `disabledByHost` on the way; by the time the flip back to
  // `true` reaches this arm, the `failed` it was supposed to preserve is gone.
  // Probe, with the state-reading version in place:
  //   C policy-off-while-bg  drive=disabledByHost passesRun=false
  //   D policy-on-while-bg   drive=suspended      passesRun=true   <-- still wrong
  // The remembered flag survives that detour, which is the whole point.
  if (ref.read(appLifecycleProvider) == AppLifecycleState.paused) {
    _wasPaused = true;
    return _startFailed ? WalletSyncDrive.failed : WalletSyncDrive.suspended;
  }

  _command(session, start: true);
  // Optimistic; _command corrects to `failed` if the start command throws.
  // Safe to show because this state is internal (not rendered) — see the
  // [WalletSyncDrive] doc.
  return WalletSyncDrive.running;
}