Make remote notification work again#487
Conversation
PR #487 Review — Make remote notification work againOverall this is a nice, well-scoped change. Replacing the A few real concerns to look at before merging. Correctness1. Cold-start race on
|
|
^^ Let's avoid any post frame callbacks - that almost always is a bug. There's always a race condition. Anything that can be done in post frame callback should be done in another way. |
|
I fixed some issues |
|
Reviewed PR #487 — the diff, current files in repo, the bugbot findings, and my previous review on this PR (Past discussion of this PR). Below is a focused follow-up against the latest state of the branch — the What's now solid
Still blocking1. Silent error swallow in
|
| class _TransactionDetailSheet extends StatelessWidget { | ||
| final TransactionEvent tx; | ||
| final String activeAccountId; | ||
| final String? activeAccountId; |
There was a problem hiding this comment.
Null activeAccountId causes incorrect send/receive display
Low Severity
When activeAccountId is null, _isSend silently defaults to false, causing _AmountSection to always colorize the amount as received and _DetailsSection to always show the "FROM" label with tx.from. The _title getter properly guards against null with a generic "Transaction Details" string, but this guard isn't propagated to the child widgets that rely on _isSend. On cold start from a notification tap, activeAccountProvider may still be loading when the listenManual callback fires with fireImmediately: true, making this a reachable path.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 19d0556. Configure here.
There was a problem hiding this comment.
active account ID can't be null here should not be optional
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
There are 2 total unresolved issues (including 1 from previous review).
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit b8979cd. Configure here.
| ref.read(transactionIntentProvider.notifier).state = null; | ||
| final active = ref.read(activeAccountProvider).value; | ||
| showTransactionDetailSheet(context, transaction, active?.account.accountId); | ||
| }, fireImmediately: true); |
There was a problem hiding this comment.
Showing UI from listenManual fireImmediately during initState
Medium Severity
The ref.listenManual calls with fireImmediately: true in initState will synchronously invoke the callback if the provider already holds a non-null value. This attempts to show a bottom sheet or push a navigation route (showTransactionDetailSheet, Navigator.push) during initState, before the widget's first build has completed. On a cold-start from a notification, handleLaunchByNotification (async, fire-and-forget from app.dart) could resolve and populate transactionIntentProvider before HomeScreen is created, triggering immediate UI display during construction.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit b8979cd. Configure here.
|
Explaining this AI code Here is the precise flow when the user taps a push notification while the app is fully terminated, mapped to the current code on Phase 1 — OS launches the processThe OS hands the Flutter engine the notification metadata. Two channels coexist:
Crucially, neither is consumed synchronously. They both have to wait for Phase 2 —
|
| Task | Effect |
|---|---|
WalletInitializer._checkWalletAndMigration |
reads secure storage, decides HomeScreen vs WelcomeScreen vs migration sheet |
localNotifications.handleLaunchByNotification |
decodes payload, sets transactionIntentProvider |
FCM _handleInitialMessage (once setupNotificationTapHandlers runs via remoteConfigProvider) |
decodes RemoteMessage.data, sets transactionIntentProvider |
activeAccountProvider |
asynchronously loads the active DisplayAccount |
There is no single "winner" we can guarantee — it depends on keychain latency, whether migration runs, Firebase warm-up cost, Android vs iOS, etc. This is exactly the race n13 flagged.
Phase 5 — HomeScreen.initState (the part our fix touches)
@override
void initState() {
super.initState();
ref.listenManual<TransactionEvent?>(transactionIntentProvider, _onTransactionIntent);
ref.listenManual<PaymentIntent?>(paymentIntentProvider, _onPaymentIntent);
ref.listenManual<String?>(sharedAccountIntentProvider, _onSharedIntent);
ref.listenManual<AsyncValue<DisplayAccount?>>(activeAccountProvider, (_, async) {
if (async.value == null) return;
_onTransactionIntent(null, ref.read(transactionIntentProvider));
});
Future.microtask(_drainPendingIntents);
}Two distinct mechanisms are wired up here:
- The three intent listeners catch any state change that happens after
HomeScreenmounted. NofireImmediately, so they will not run synchronously insideinitState. This is what fixes bugbot's "Showing UI from listenManual fireImmediately during initState" finding. - The
activeAccountProviderlistener is the "second-chance" path: even if a tx intent is already set butactiveAccountProvideris still loading, we will re-attempt the navigation the moment the account resolves.
Then Future.microtask(_drainPendingIntents) schedules a one-shot drain to run after initState returns but before the next event-loop turn. That handles the cold-start case where the intent was already set before HomeScreen ever subscribed.
void _onTransactionIntent(TransactionEvent? _, TransactionEvent? transaction) {
if (transaction == null || !mounted) return;
final active = ref.read(activeAccountProvider).value;
if (active == null) return;
ref.read(transactionIntentProvider.notifier).state = null;
showTransactionDetailSheet(context, transaction, active.account.accountId);
}The intent is only cleared after we have both a non-null transaction and a non-null active account — so we never silently drop the intent (n13's correctness concern), and the sheet is never opened with a null identity (n13's _isSend concern).
Phase 6 — HomeScreen.build (first frame)
HomeScreen.build runs immediately after initState. It paints the empty balance skeleton + activity skeleton (because balanceDisplayProvider, activeAccountTransactionsProvider are still loading).
Important: at this point no modal sheet has been pushed yet. The microtask has not fired. This is why removing fireImmediately: true matters — without that change, showTransactionDetailSheet (which calls showModalBottomSheet) would have been invoked synchronously inside initState, while the widget had no rendered subtree.
Phase 7 — The microtask fires (post-build, pre-next-frame)
_drainPendingIntents() runs. Four outcomes depending on what raced:
Case A — Intent already set, active already loaded (the "iPhone 14 happy path"):
- Reads
transactionIntentProvider→ non-null event. - Reads
activeAccountProvider.value→ non-null account. - Clears intent, calls
showTransactionDetailSheet. - User sees
HomeScreenwith the transaction detail sheet animated up over it.
Case B — Intent already set, active still loading (slow keychain / migration path / Android cold start):
_onTransactionIntentearly-returns becauseactive == null. Intent stays set.- A bit later,
ActiveAccountNotifieremits itsDisplayAccount?value. - The
listenManual<AsyncValue<DisplayAccount?>>(activeAccountProvider, …)listener fires, reads the still-pending intent, calls_onTransactionIntentagain — this timeactiveis non-null, intent clears, sheet shows. - User briefly sees
HomeScreenskeleton, then the sheet pops in.
Case C — Intent set after HomeScreen mounted (FCM init was slow, or a runtime tap):
- The microtask sees no pending intent.
- Later,
transactionIntentProviderflips → the dedicatedlistenManual<TransactionEvent?>fires → same_onTransactionIntentpath runs.
Case D — Intent set after HomeScreen mounted but active still loading:
- Tx-intent listener fires → active null → returns without clearing.
- Active loads → active-account listener re-drains → sheet shows.
Why this is correct
- No lost intents. Across every race ordering, the intent is only cleared at the moment we actually call
showTransactionDetailSheet. Either the tx-intent listener or the active-account listener (whichever happens to be the second one to receive its value) drives the sheet. - No UI from
initState.Future.microtaskruns afterinitStatereturns and after the synchronous build step is queued, soshowModalBottomSheetalways finds a fully constructedNavigator. NofireImmediately: trueanywhere. - No
addPostFrameCallback. Per your rule. The microtask handles "after current sync stack" without any frame-callback dependency. - No
_isSendambiguity.showTransactionDetailSheetand_TransactionDetailSheetnow take a non-nullableString activeAccountId. The compiler enforces that we never call it without identity, so_isSend,_DetailsSection'sTO/FROMlabel, and_AmountSection's colorization are all consistent. - No telemetry payload leak. If deserialization fails, we send
e.runtimeType.toString()(e.g."FormatException") plus the controlledtxTypeenum name — never the FormatException message, which would have included the raw payload value via thequantus_sdkjson helpers. - Pattern parity. Payment intents (deep-link "pay" URLs) and shared-account intents (deep-link "share" URLs) follow the exact same listen + drain shape, so we get the same race-safety for free across all three entry points.
What the user actually sees, in order
- OS launch animation / native splash.
- ~100–600ms of
WalletInitializer'sCircularProgressIndicator. HomeScreenrendered with empty balance + activity skeletons.TransactionDetailSheetanimates up from the bottom — either immediately on the same frame (Case A/C), or afteractiveAccountProviderresolves (Case B/D, typically a few hundred ms later).- When the user dismisses the sheet, they're left on
HomeScreenwith balance/activity populated normally.
|
Now I have a comprehensive understanding of the PR. Here's my review: PR #487 Review: "Make remote notification work again"Author: @dewabisma | Branch: SummaryThis PR replaces the global Things I Like
Issues / Concerns1. Race condition: intent consumed before In The 2. await _senotiService.unregisterDevice(token, _platform);
_cachedToken = null;This is correct behavior (clearing the cache after unregister), but the method accepts 3. The parameter changed from 4. This throws a 5. Removed routes routes: {'/': (context) => const WalletInitializer()},These routes were previously used for deep linking. The PR now routes everything through intent providers, which is fine — but make sure no other code (e.g., web URLs registered in AndroidManifest/Info.plist) still references these named routes. If something externally tries to navigate to Minor Nits
VerdictSolid PR that fixes a real problem (notifications broken due to navigator key timing issues) with a better architectural pattern. The FCM payload parsing is well-handled. The main thing to verify is the cold-start race condition around intent delivery vs. account loading, which the active-account listener mostly covers. Ship it with confidence. |
n13
left a comment
There was a problem hiding this comment.
App init race conditions are always a bit of a pain but I think it's good enough for now
LGTM


Summary
Already tested on my iPhone 14 it works!
Note
Medium Risk
Medium risk because it changes push-notification/deep-link handling and transaction JSON deserialization (including SDK model parsing), which can affect navigation and transaction display across multiple entry points.
Overview
Restores notification-driven navigation by removing the global
navigatorKeypattern and routing everything through Riverpod intent providers.Push/local notification taps and app launches now set
transactionIntentProvider, andHomeScreenconsumes intents ininitStateto openshowTransactionDetailSheet(also making the sheet tolerate a missing active account id). Local-notification payload decoding is wrapped with error handling and telemetry.Improves transaction event parsing to support FCM-style payloads (string-encoded nested objects, int amounts, top-level
extrinsicHash) via new SDK helpers injson_dynamic_parse.dart, updatesTransferEvent/ReversibleTransferEventdeserialization accordingly, and adds unit tests in both the app andquantus_sdk.Reviewed by Cursor Bugbot for commit b8979cd. Bugbot is set up for automated code reviews on this repo. Configure here.