Skip to main content

Push on the device

Receiving a notification is one SDK call. Everything around it is not, and that is where the afternoons go: which transport this device can actually use, when to ask for permission without being refused, keeping the server's copy of the token in step with the platform's while people sign in and out, and making sure a tap opens the right screen exactly once — whether the app was cold, backgrounded, or already on screen.

DartWay puts that part in dartway_push_flutter, reached as dw.plugins.push. It carries no vendor SDK at all; the transports are separate packages, so an app downloads only what it ships:

PackageTransport
dartway_push_firebaseFCM — Android, iOS, web
dartway_push_rustoreRuStore — Android

The other half — the queue, the retries, the fan-out — is dartway_push_server.

Wiring

dw = DwCore<Client, UserProfile>(
// ...
plugins: [
DwPush(
config: DwPushConfig(
providers: [DwRuStorePush(), DwFirebasePush(webVapidKey: kVapidKey)],
recipientIdProvider: dw.userProfileProvider.select((p) => p?.id),
onOpened: (opened) => appRouter.go(routeFor(opened.data)),
),
),
],
);

and then, around the app's root:

DwPushScope(push: dw.plugins.push, child: MaterialApp.router(...))

The scope exists because nothing about push can happen at bootstrap: a permission prompt needs a screen to belong to, and a notification tap needs an app to reach.

Declaration order is the policy

The first transport that both builds for this platform and answers for itself on this device wins. The pair above therefore means RuStore on Android, FCM on iOS and web — and on an Android phone without the RuStore app, FCM there too. There is no platform switch in your code and none in the plugin; the list is the whole rule. An app that only knows FCM declares one entry.

It does not navigate

onOpened hands you the notification's data map and one guarantee: it fires once per tap, after a frame, however the app was launched. Turning that map into a screen is yours, because the keys and the routes are yours.

The guarantee is less obvious than it looks. A cold start delivers the tap before there is anything to route it into, so the plugin holds it until the app says it is ready. And a tap while the app is already on screen changes no widget — so Flutter schedules no frame, a post-frame callback would simply wait, and the navigation would land minutes later on an unrelated gesture. The plugin asks for that frame itself.

Permission, at a moment that makes sense

By default the plugin asks as soon as it attaches, which is honest for an app whose whole point is notifications. For everything else:

DwPushConfig(requestPermissionOnAttach: false, /* ... */)
// later, after the first order / at the end of onboarding:
final permission = await dw.plugins.push.requestPermission();

A refusal is an answer, not an error: the transport stays attached, so a user who changes their mind in the system settings becomes reachable without restarting the app. DwPushPermission.permanentlyDenied is the only state where asking again does nothing — send them to the settings screen instead.

The token, and signing out

The token and the signed-in user arrive independently and in either order, so the plugin sends the pair whenever both exist, once, and re-sends when either changes. Registration goes through the push module's CRUD action; an app on somebody else's backend passes registerToken: instead.

Call dw.plugins.push.revokeToken() before signing out, while the session is still valid. Afterwards there is nobody to authenticate the call, and the device keeps receiving the previous user's notifications until the server happens to find out.

What stays in the app

The native setup, because it is about your project, not about the framework: Firebase.initializeApp and DwFirebasePush.registerBackgroundHandler() in main(), google-services.json / GoogleService-Info.plist, the web service worker (there is a template in dartway_push_firebase), and the RuStore project_id and notification icon in the Android manifest. Each package's README lists its own.

See also

  • Plugins — how dw.plugins.<name> works, and why the accessor lives in the plugin's package.
  • Push delivery — the server half.