You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
The app has no app-lifecycle handling at all today: there is no WidgetsBindingObserver / AppLifecycleState listener anywhere in lib/ or test/. Nothing reacts when the OS suspends the process (background) or brings it back (resume). This issue proposes designing lifecycle handling as a first-class, testable component now, before push notifications and the remaining bridge integrations land — because the current architecture has exactly the shape that produced a real, hard-to-diagnose bug in the v1 app, and v2 is in the perfect position to prevent the entire bug class instead of patching instances of it one by one.
Context: the v1 bug this is designed to prevent
A user with an open dispute received three push notifications while the v1 app was backgrounded. On reopening the app, the dispute chat showed nothing. The admin's three messages only appeared six hours later, after fully killing and restarting the app. Root cause (fixed in MostroP2P/mobile#675):
While backgrounded, v1's background service received the admin's kind-14 envelopes, decrypted them, persisted them to disk, and fired the push notifications.
On resume, the foreground refresh routine (LifecycleManager._switchToForeground()) refreshed orders, P2P chats and trades — but nobody ever told the dispute-chat notifier. That notifier loads from disk exactly once in its lifetime (an _isInitialized guard) and its relay subscription had gone stale across the background/foreground cycle. The messages sat on disk, invisible, until an app restart recreated the notifier.
Two structural properties made this bug possible, and both exist (or will soon exist) in this app:
Two writers, one reader: a background context persists events while the foreground holds stale in-memory state that is only hydrated incrementally, never re-synced.
Wiring instead of a principle: v1's resume path is a hand-maintained list of per-feature refresh calls. Every new feature must remember to add itself to that list; forgetting is silent and only detectable by manually testing the background→resume path — which almost nobody does.
The v1 fix was necessarily another entry in that list (ref.invalidate(disputeChatNotifierProvider)). It works, but it is a patch on an instance, not a cure for the class. Worse, v1's LifecycleManager is effectively untestable: it gates on dart:io's Platform.isAndroid || Platform.isIOS (cannot be overridden in host tests, unlike TargetPlatform) and reads six heavyweight providers plus a hardcoded 500 ms delay. No test protects any of its wiring.
Why this app is exposed
The relevant pieces, as of current main:
UI state is purely in-memory and event-fed.DisputeNotifier (lib/features/disputes/providers/disputes_providers.dart) is a StateNotifier<List<DisputeItem>> fed by upsert() calls from bridge events, with a comment noting it stays empty until bridge events are integrated (Phase 12+). Notifiers hydrated only by incremental events silently lose everything that happens while the process is suspended or a stream is momentarily broken.
The Rust relay pool reconnects, but nobody triggers or follows up on it.rust/src/nostr/relay_pool.rs models a Reconnecting state, but when the OS suspends the process the sockets die and no Dart-side code (a) nudges the pool to reconnect promptly on resume, or (b) re-hydrates Dart state after reconnection catches up.
Push is half-wired and the dangerous stub is still empty.firebase_messaging is a dependency, PushNotificationService registers FirebaseMessaging.onBackgroundMessage(_backgroundMessageHandler) — and that handler is currently a debugPrint no-op. This is the exact spot where v1's "two writers" problem gets introduced the moment someone makes the background isolate process or persist protocol data. We should decide the rule before that code gets written.
The spec already commits to the split: "Push notifications use a background delivery mechanism; in-app notifications handle foreground delivery" (specs/004-mostro-p2p-client/spec.md).
Concrete failure scenarios if nothing is done
The v1 dispute bug, verbatim. User backgrounds the app mid-dispute. Admin sends messages; pushes arrive (background mechanism). User reopens the app: relay pool reconnects eventually, but subscriptions resume from live-time or a stale cursor, the missed events are never replayed into DisputeNotifier, and the chat looks frozen until an app kill. Support cost: "the app doesn't work", impossible to reproduce at a desk.
Missed trade-state transitions. An order moves fiat-sent → released while suspended. The trade detail screen still shows the old state on resume. In a trading app this is not cosmetic: a user may re-send fiat, open an unnecessary dispute, or miss a payout window.
Ghost outbox.rust/src/queue/outbox.rs holds queued outgoing messages. If nothing flushes it on resume, a message "sent" seconds before backgrounding may silently sit until the next cold start.
Web is not exempt. On sembast_web/IndexedDB, a backgrounded tab gets throttled and sockets die too; the same re-hydration gap applies, just with different timing.
Each of these will otherwise get its own one-off fix, in its own PR, after its own painful field report — that is exactly the v1 history we can skip.
Why now, and why it is cheap here
Rust is already the single source of truth (db, chat cursors, outbox, gift-wrap handling all live in rust/src/). A resume can be conceptually one operation — resync() on the bridge plus re-hydrating Dart notifiers from Rust queries — instead of v1's six-step orchestration of independent Dart services. Small surface, hard to desync.
Chat cursors already exist in Rust (db::settings_keys::CHAT_CURSOR_PREFIX, chat_cursor(order_id)), so "resubscribe from the persisted cursor and replay what was missed" is a natural extension of the existing design, not a new subsystem.
Greenfield seams are free. Nothing exists yet, so the platform check and the lifecycle observer can be injectable from day one, making the whole path testable — the very thing that is infeasible in v1 without a production refactor.
The FCM background handler is still empty. The most dangerous line of future code has not been written yet.
Proposed design
1. One AppLifecycleService, with injected seams
A single service registered from app_bootstrap.dart (which already centralizes bridge-stream consumption with resilient loops — the right home):
Never gate on dart:io Platform directly. Inject a predicate (default: real platform check). dart:io's Platform reflects the host OS and cannot be faked in host tests; this single line is what makes v1's manager untestable. Note web needs the equivalent hook via visibilitychange (recent Flutter versions surface this through the same lifecycle events — verify).
Keep the observer dumb, the logic pure.didChangeAppLifecycleState should only translate OS events into calls to an injectable onResume/onPause. All actual work lives in plain async functions that tests can call and verify directly.
Debounce.inactive → resumed flaps on every permission dialog and app-switcher peek; a short debounce (and an "only after a real paused" latch, like v1's _isInBackground flag) avoids re-sync storms.
2. On resume: one bridge call + one hydration principle
Bridge side (Rust): add api::resync() (name open) that:
Nudges the relay pool to reconnect now rather than on its own backoff schedule.
Re-establishes subscriptions from the persisted cursors (chat_cursor:*, plus whatever order/dispute cursors exist by then), so missed events are replayed from relays.
Flushes the outbox.
Returns when the initial catch-up is done (or exposes progress via the existing connection-state stream).
Dart side: after resync() completes, re-hydrate every stateful notifier from Rust queries, not from memory. This is the principle that cures the bug class:
Streams are for live updates; queries are for hydration. Resume always re-hydrates.
Any notifier that holds protocol-derived state (disputes, dispute chat, P2P chat, trades, notifications) must have a hydrate() path that reads current truth from the bridge and replaces (or merges into) its in-memory state — the same path used at cold start. Resume then simply calls all registered hydrate()s. New features register a hydration hook; they cannot "forget to add themselves to the resume list" because hydration is the same code cold start already requires. DisputeNotifier.upsert() already preserves the UI-managed isRead flag across server-driven updates, which is exactly the merge shape hydration needs — extend that pattern.
3. On pause: keep it minimal
Persist any pending UI-side cursors/state, optionally tell Rust it may reduce activity. Do not replicate v1's subscription-transfer dance unless a real background service exists — on this architecture the OS freezes the process wholesale; the work belongs on the resume side.
4. The FCM background isolate: display-only, forever
Write the rule into the handler before someone fills the stub:
_backgroundMessageHandler must remain display-only (parse payload, show the local notification, optionally record a "wake hint" flag). It must not initialize the Rust core, decrypt protocol messages, or write to the app database from its isolate.
All state recovery happens via resync() + hydration on the next resume/cold start. The push is a doorbell, not a courier.
This single rule eliminates the two-writers problem structurally. (If iOS delivery semantics later force background processing, that becomes a deliberate design change with the cursor/replay model as backstop — the resume path heals whatever the background missed either way.)
5. Tests (finally possible, so make them mandatory)
Unit: the pure resume routine, with a fake bridge — asserts resync() is awaited and every registered hydration hook runs; debounce/latch behavior (inactive flaps don't trigger; paused → resumed does).
Widget/integration: with the platform predicate injected, drive real lifecycle events (TestWidgetsFlutterBinding can deliver AppLifecycleState changes) and assert a seeded fake bridge's new events appear in DisputeNotifier/chat state after resume. This is the exact regression shape of the v1 bug — encode it here so it can never ship.
Rust:resync() unit tests — resubscribes from persisted cursors, flushes outbox, idempotent when called twice.
Suggested acceptance criteria
AppLifecycleService exists, registered in app_bootstrap.dart, with injected platform predicate and injected resume/pause handlers; no direct dart:io Platform gate in the logic path.
Every protocol-state notifier exposes hydrate() used by both cold start and resume; resume calls all of them after resync().
_backgroundMessageHandler documented and enforced as display-only (comment + review rule; ideally a lint/test guard that it imports no bridge/db code).
Regression test: events arriving "while suspended" (seeded into a fake bridge between simulated paused and resumed) are visible in dispute chat state after resume, without a restart.
Web: verify resume/visibility events fire and trigger the same path.
Order of work
AppLifecycleService + pure resume routine + tests (no Rust changes yet; resume can start as "reconnect nudge + hydrate from current Rust state").
api::resync() in Rust with cursor-based replay + outbox flush.
Hydration hooks per notifier as their bridge integrations land (disputes/chat first — Phase 12+ is the natural moment).
FCM handler rule + guard, before the push server integration fills the stub.
Summary
The app has no app-lifecycle handling at all today: there is no
WidgetsBindingObserver/AppLifecycleStatelistener anywhere inlib/ortest/. Nothing reacts when the OS suspends the process (background) or brings it back (resume). This issue proposes designing lifecycle handling as a first-class, testable component now, before push notifications and the remaining bridge integrations land — because the current architecture has exactly the shape that produced a real, hard-to-diagnose bug in the v1 app, and v2 is in the perfect position to prevent the entire bug class instead of patching instances of it one by one.Context: the v1 bug this is designed to prevent
A user with an open dispute received three push notifications while the v1 app was backgrounded. On reopening the app, the dispute chat showed nothing. The admin's three messages only appeared six hours later, after fully killing and restarting the app. Root cause (fixed in MostroP2P/mobile#675):
LifecycleManager._switchToForeground()) refreshed orders, P2P chats and trades — but nobody ever told the dispute-chat notifier. That notifier loads from disk exactly once in its lifetime (an_isInitializedguard) and its relay subscription had gone stale across the background/foreground cycle. The messages sat on disk, invisible, until an app restart recreated the notifier.Two structural properties made this bug possible, and both exist (or will soon exist) in this app:
The v1 fix was necessarily another entry in that list (
ref.invalidate(disputeChatNotifierProvider)). It works, but it is a patch on an instance, not a cure for the class. Worse, v1'sLifecycleManageris effectively untestable: it gates ondart:io'sPlatform.isAndroid || Platform.isIOS(cannot be overridden in host tests, unlikeTargetPlatform) and reads six heavyweight providers plus a hardcoded 500 ms delay. No test protects any of its wiring.Why this app is exposed
The relevant pieces, as of current
main:DisputeNotifier(lib/features/disputes/providers/disputes_providers.dart) is aStateNotifier<List<DisputeItem>>fed byupsert()calls from bridge events, with a comment noting it stays empty until bridge events are integrated (Phase 12+). Notifiers hydrated only by incremental events silently lose everything that happens while the process is suspended or a stream is momentarily broken.rust/src/nostr/relay_pool.rsmodels aReconnectingstate, but when the OS suspends the process the sockets die and no Dart-side code (a) nudges the pool to reconnect promptly on resume, or (b) re-hydrates Dart state after reconnection catches up.firebase_messagingis a dependency,PushNotificationServiceregistersFirebaseMessaging.onBackgroundMessage(_backgroundMessageHandler)— and that handler is currently adebugPrintno-op. This is the exact spot where v1's "two writers" problem gets introduced the moment someone makes the background isolate process or persist protocol data. We should decide the rule before that code gets written.specs/004-mostro-p2p-client/spec.md).Concrete failure scenarios if nothing is done
DisputeNotifier, and the chat looks frozen until an app kill. Support cost: "the app doesn't work", impossible to reproduce at a desk.fiat-sent → releasedwhile suspended. The trade detail screen still shows the old state on resume. In a trading app this is not cosmetic: a user may re-send fiat, open an unnecessary dispute, or miss a payout window.rust/src/queue/outbox.rsholds queued outgoing messages. If nothing flushes it on resume, a message "sent" seconds before backgrounding may silently sit until the next cold start.sembast_web/IndexedDB, a backgrounded tab gets throttled and sockets die too; the same re-hydration gap applies, just with different timing.Each of these will otherwise get its own one-off fix, in its own PR, after its own painful field report — that is exactly the v1 history we can skip.
Why now, and why it is cheap here
rust/src/). A resume can be conceptually one operation —resync()on the bridge plus re-hydrating Dart notifiers from Rust queries — instead of v1's six-step orchestration of independent Dart services. Small surface, hard to desync.db::settings_keys::CHAT_CURSOR_PREFIX,chat_cursor(order_id)), so "resubscribe from the persisted cursor and replay what was missed" is a natural extension of the existing design, not a new subsystem.Proposed design
1. One
AppLifecycleService, with injected seamsA single service registered from
app_bootstrap.dart(which already centralizes bridge-stream consumption with resilient loops — the right home):Key rules learned from v1:
dart:io Platformdirectly. Inject a predicate (default: real platform check).dart:io'sPlatformreflects the host OS and cannot be faked in host tests; this single line is what makes v1's manager untestable. Note web needs the equivalent hook viavisibilitychange(recent Flutter versions surface this through the same lifecycle events — verify).didChangeAppLifecycleStateshould only translate OS events into calls to an injectableonResume/onPause. All actual work lives in plain async functions that tests can call and verify directly.inactive → resumedflaps on every permission dialog and app-switcher peek; a short debounce (and an "only after a realpaused" latch, like v1's_isInBackgroundflag) avoids re-sync storms.2. On resume: one bridge call + one hydration principle
Bridge side (Rust): add
api::resync()(name open) that:chat_cursor:*, plus whatever order/dispute cursors exist by then), so missed events are replayed from relays.Dart side: after
resync()completes, re-hydrate every stateful notifier from Rust queries, not from memory. This is the principle that cures the bug class:Any notifier that holds protocol-derived state (disputes, dispute chat, P2P chat, trades, notifications) must have a
hydrate()path that reads current truth from the bridge and replaces (or merges into) its in-memory state — the same path used at cold start. Resume then simply calls all registeredhydrate()s. New features register a hydration hook; they cannot "forget to add themselves to the resume list" because hydration is the same code cold start already requires.DisputeNotifier.upsert()already preserves the UI-managedisReadflag across server-driven updates, which is exactly the merge shape hydration needs — extend that pattern.3. On pause: keep it minimal
Persist any pending UI-side cursors/state, optionally tell Rust it may reduce activity. Do not replicate v1's subscription-transfer dance unless a real background service exists — on this architecture the OS freezes the process wholesale; the work belongs on the resume side.
4. The FCM background isolate: display-only, forever
Write the rule into the handler before someone fills the stub:
_backgroundMessageHandlermust remain display-only (parse payload, show the local notification, optionally record a "wake hint" flag). It must not initialize the Rust core, decrypt protocol messages, or write to the app database from its isolate.resync()+ hydration on the next resume/cold start. The push is a doorbell, not a courier.This single rule eliminates the two-writers problem structurally. (If iOS delivery semantics later force background processing, that becomes a deliberate design change with the cursor/replay model as backstop — the resume path heals whatever the background missed either way.)
5. Tests (finally possible, so make them mandatory)
resync()is awaited and every registered hydration hook runs; debounce/latch behavior (inactiveflaps don't trigger;paused → resumeddoes).TestWidgetsFlutterBindingcan deliverAppLifecycleStatechanges) and assert a seeded fake bridge's new events appear inDisputeNotifier/chat state after resume. This is the exact regression shape of the v1 bug — encode it here so it can never ship.resync()unit tests — resubscribes from persisted cursors, flushes outbox, idempotent when called twice.Suggested acceptance criteria
AppLifecycleServiceexists, registered inapp_bootstrap.dart, with injected platform predicate and injected resume/pause handlers; no directdart:io Platformgate in the logic path.api::resync()implemented in Rust: reconnect nudge + cursor-based resubscribe + outbox flush; idempotent.hydrate()used by both cold start and resume; resume calls all of them afterresync()._backgroundMessageHandlerdocumented and enforced as display-only (comment + review rule; ideally a lint/test guard that it imports no bridge/db code).pausedandresumed) are visible in dispute chat state after resume, without a restart.Order of work
AppLifecycleService+ pure resume routine + tests (no Rust changes yet; resume can start as "reconnect nudge + hydrate from current Rust state").api::resync()in Rust with cursor-based replay + outbox flush.References
LifecycleManager._switchToForeground()now invalidatesdisputeChatNotifierProvider; the PR description documents the full root cause.dart:io Platformgate, hardcoded 500 ms transition delay, per-feature resume wiring, background/foreground subscription hand-off with persisted filter snapshots.rust/src/nostr/relay_pool.rs(Reconnectingstate),rust/src/db/mod.rs(chat_cursor:*keys),rust/src/queue/outbox.rs,lib/core/app_bootstrap.dart(resilient stream-consume loops),DisputeNotifier.upsert()read-flag-preserving merge.