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;
}