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.
On Android 8 and newer, both transport plugins create the quiet dw_push_silent notification
channel as soon as they attach. It uses low importance with sound and vibration disabled, so it
exists before the first silent push arrives. Apps can localize or replace its identity with
manifest meta-data:
<meta-data android:name="dev.dartway.push.firebase.silent_channel_id" android:value="app_quiet" />
<meta-data android:name="dev.dartway.push.firebase.silent_channel_name" android:value="Quiet notifications" />
<meta-data android:name="dev.dartway.push.rustore.silent_channel_id" android:value="app_quiet" />
<meta-data android:name="dev.dartway.push.rustore.silent_channel_name" android:value="Quiet notifications" />
Use the keys for the transports the app ships. The default channel remains unchanged.
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.