Push on the device: what does the app do, and what does it leave to the framework?
The app decides three things: which transports it ships, when to ask for permission, and where
an opened notification leads. Everything between — keeping the server's registration in step
with the token and the signed-in account, holding the notification that started the app until
something can route it, decoding the payload into the project's own class — is
dartway_push_flutter, reached as dw.plugins.push. It carries no vendor SDK; the transports are
separate packages, so an app downloads only what it ships:
| Package | Transport |
|---|---|
dartway_push_firebase | FCM — Android, iOS, web |
dartway_push_rustore | RuStore — Android devices with RuStore |
The server half — queue, eligibility, retries — is Push delivery.
Wiring
dw = DwFlutterCore(
config: DwFlutterConfig(...),
protocol: appProtocol, // DwWireProtocol(dwPushProtocolEntries, include: appGeneratedProtocol)
baseUrl: baseUrl,
plugins: [
DwSharedPreferences(),
DwPush(transports: [DwRuStorePush(), DwFirebasePush(webVapidKey: webVapidKey)]),
],
);
main initializes Firebase before dw.init() and registers FCM's background handler:
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
DwFirebasePush.registerBackgroundHandler();
The plugin takes the first transport that supports the platform and is available on the device — the list above means RuStore on Android phones that have it, FCM everywhere else. A Firebase transport without an initialized Firebase app is not available, so a build without a Firebase project simply has no push.
Push is not on the path of the app opening. dw.init() waits only for the plugin's own checks;
choosing the transport, listening, the permission, the token and the notification that started the
app continue in the background. Those are vendor SDK calls that may never answer — on iOS,
firebase_messaging waits for an APNs registration that the simulator never delivers, and neither
does a device whose bundle id differs from GoogleService-Info.plist — and one of them awaited at
start kept an app on its splash screen. A call still unanswered after
DwPush(reportUnansweredAfter:) (ten seconds) is reported by name, push: takeInitialOpen did not answer in 10000 ms, and — within the background start itself — still waited for. Right after
dw.init(), push.transport and push.token may therefore still be null; requestPermission()
and permission() wait for the transport too, but only up to DwPush(permissionDeadline:) (ten
seconds by default, separate from reportUnansweredAfter — lowering how soon a background call is
reported must not, as a side effect, shorten how long these two wait on the user's behalf) — past
that deadline they answer DwPushPermission.unanswered: the real state is unknown, not "nobody has
asked" (notDetermined keeps only that meaning) and not a guess at granted or denied. permission()
bounds the transport's own answer by the same deadline once attached; requestPermission()'s call to
the platform is never bounded by it — it may be showing the user a system dialog, and their own time
answering it is not silence. A plugin whose start fails does not block the app (blocksStartup is
false): the failure is reported, and dw.plugins.maybeOf<DwPush>() answers null.
The plugin reads everything it needs from the core it is initialized with — the client, its
protocol, its session — and never the app's dw, which does not exist yet while the plugins:
list is built (#54).
Registration follows the session
The token and the signed-in account arrive independently and in either order. Whenever both are
known and that pair has not been registered, the plugin sends DwRegisterPushToken — once per
pair: a start signed in, a sign-in, an account switch and a refreshed token cost one call each,
and repeats cost none. Nothing is configured about whose device it is: the server takes the caller.
A sign-out needs no call. The server binds the registration to the session key the call was made with and stops sending when that key is revoked — by this sign-out, by a sign-out on another device, by an admin. The next sign-in registers the device under its new key. There is no "unregister before signing out" step to forget, and no window in which the previous account's notifications reach a phone somebody else now holds.
A registration the server refuses or that fails is reported through the app's error reporting and tried again with the next token, sign-in or start.
Permission, when the user understands why
The plugin asks for nothing on its own. Where the app decides:
final permission = await dw.plugins.push.requestPermission(); // registers the token on yes
A refusal is an answer, not an error. DwPushPermission.permanentlyDenied means only the system
settings can change it. dw.plugins.push.permission() reads the state without asking.
DwPushPermission.unanswered means neither: the push service did not answer within
permissionDeadline, so the real state is unknown — do not treat it as granted or as denied, and
do not persist a settings toggle as on the strength of it; offer the user a retry instead.
A user who turns notifications off inside the app calls dw.plugins.push.pause(): the device's
registration is removed and nothing is registered until resume(). The choice is the app's to
keep; DwPush(isEnabled: ...) reads it at start, so an app started with notifications off
registers nothing.
Opened notifications
dw.plugins.push.opened.listen((opened) {
if (opened.payloadAs<NewsAlert>() != null) {
router.goNamed(AppNavigationZone.news.name);
} else if (opened.link case final link?) {
router.go(link);
}
});
A DwPushOpened carries the server's typed payload (payload, payloadAs<T>()), its link and
its source: coldStart, background or webClick. The payload is decoded by the app's protocol
with the same DwPushData the server encoded it with. A payload this build cannot read — a class
of a newer server — is reported, and the link still opens.
The notification that started the app is held until the first listener subscribes, so a
listener attached when the router exists still receives it. The example's PushOpenedListener
wraps the app under MaterialApp.builder; the router's guards send a signed-out user to sign in.
dw.plugins.push.received reports notifications that arrived while the app was on screen, never
acting on them.
On the web: the click
Copy web/firebase-messaging-sw.js from dartway_push_firebase into the app's web/ and fill in
the Firebase config — nothing else. The order inside it is the whole point (#78): the Firebase SDK
registers a notificationclick listener that calls stopImmediatePropagation(), so a listener
added after the SDK never runs, on any browser, with no error. The template registers its listener
before the SDK is even loaded and stops the SDK's instead. With a tab of the app open, it focuses
that tab and posts the link, which arrives as DwPushOpenSource.webClick; with none, it opens the
link on the app's origin, and the router starts there.
The server also sends the link as webpush.fcm_options.link, so the browser opens the right page
even where no worker handles the click. The package's test executes the template in Node with the
SDK's behaviour stubbed and clicks a notification: a template with the order swapped fails it.
On Android with RuStore
dartway_push_rustore replaces the RuStore SDK's messaging service with its own: it writes each
message's data down when it arrives (a tap may come back to a process that no longer exists), hands
taps back without a MainActivity override, raises the Android 13 permission prompt, and draws a
data-only message — the server sends a picture that way, because RuStore ignores an image in its
notification block. The app's manifest names the RuStore project id and the notification icon and
colour; the package's README lists the entries. RuStore push runs only on a physical device with
RuStore installed and signed in.
Testing
package:dartway_push_flutter/testing.dart has DwFakePushTransport, a transport the test drives:
it issues a token, grants permission, and opens or delivers notifications when told. With the
in-memory DwFakeServer answering DwRegisterPushToken, a widget test checks what the app sends
and where an opened notification leads:
final transport = DwFakePushTransport(issuedToken: 'device-token');
final app = await TestApp.start(tester, FakeClub(), pushTransports: [transport]);
expect(app.server.callsOf<DwRegisterPushToken>(), hasLength(1));
transport.open(const DwPushData(payload: NewsAlert(id: 1)));
await app.settle(tester);
expect(find.byType(NewsPage), findsOneWidget);
The example's test/core/push/push_opened_listener_test.dart holds these, and a cold start that lands on the news.
See also
- Plugins — how
dw.plugins.<name>works. - Push delivery — the server half.