Push notifications: fespalier_push
fespalier_push (since 0.13.0) makes a tap on a notification open a typed route: the tap that cold-starts the app is the router's initial location, and a tap while the app runs or sits in the background is a navigation, both marked source=notification in telemetry. It is an adapter: you list it under fespalier: adapters:, and the generated main() calls it.
It is the routing half of push, and only that. Where the push comes from is a PushSource that you hand to it. The vendor SDK is not a dependency: Firebase Messaging (and a local-notifications plugin, or an APNs-only source) is a compiled recipe in the fespalier-routing skill, so an app that uses OneSignal or Expo does not link Firebase pods. The package has no HTTP code and never posts a token: onToken is yours.
Install
Add fespalier_push under dependencies: next to fespalier, with the same git url and the same ref: pub resolves the two to one package only if they are the same repository dependency. The snippet, kept at the release's tag, is in packages/fespalier_push/README.md. Then list it as an adapter and run fsp gen:
# pubspec.yaml
fespalier:
telemetry: true # optional: reports the navigation's source
adapters: [fespalier_push]Any onEnter makes go_router parse every navigation asynchronously, but fespalier_push has none (it opens a tap through launch() and attach()), so an app that lists it keeps go_router's synchronous parsing; only an adapter with an onEnter getter makes the router pass one (since 0.16.0; before, any adapter did: see Adapters in the generated main()).
Configure
An adapter takes no options from the pubspec, and launch() runs before any ProviderScope exists, so the app's own code reaches it through one static call in main(), before AppMain.run() (Adapters that need your code):
// lib/main.dart
Future<void> main() {
FespalierPush.configure(source: MyPushSource(), route: pushRoute, onToken: sendToBackend,
onTokenRevoked: dropFromBackend);
return AppMain.run();
}sourceis aPushSource(below),routemaps a payload to a place,onTokenandonTokenRevoked(since 0.14.0) are optional.- Calling
configuretwice replaces (a hot restart runsmain()again). - Unconfigured, the adapter says so once and does nothing: one
FlutterErrorreportsfespalier_push is listed under `fespalier: adapters:` but was never configured: call FespalierPush.configure(source: ..., route: ...) in main() before AppMain.run(), andlaunch()andattach()do nothing. It never throws out of them: an adapter must not stop an app from starting. - With
main: manual, callconfigurein your ownmain()beforeAppAdapters.zone.
A PushSource is what a push provider gives the package:
| Member | What it is |
|---|---|
initialTap() | The tap that cold-started the app, answered once. A local read (getInitialMessage): it runs before the first frame, so a Future delays that frame by one channel round trip. |
taps | Taps while the app runs or is in the background. |
received | Messages delivered in the foreground (default: none). Nothing is shown for them; pushReceived exposes them. |
tokens | The current token, then each refresh, as a PushToken (a String before 0.14.0). Listened to more than once (the pushToken provider and the adapter): each listen gets the current token first, and a source on a platform channel opens the native stream once, not per listen. |
revocations | A token that stopped being valid, as a PushTokenRevoked (since 0.14.0; default: none). A source maps the vendor's unregister or invalidation callback to it. |
permission() and requestPermission() | The platform's answer, and its prompt. The package never calls requestPermission. |
Mapping a payload
PushRoute is PushTarget? Function(PushMessage message): a PushTarget(location, extra:, open:) (or PushTarget.to(OrderRoute(id: 42))) to open, or null to open the app and navigate nowhere. PushOpen.go (the default) replaces the stack as a link does, PushOpen.push goes on top of the current page. A mapping that throws is reported and counts as null.
The common mapping is linkRoute: the payload's link key holds an in-app path or an https URL on one of hosts, and it is kept only when matches accepts it:
final pushRoute = linkRoute(
hosts: {'shop.example.com'},
matches: (uri) => AppRoutes.matchUrl(uri) != null,
);Anything else is ignored and never navigated to, by the same rules as returnTo and stricter: a scheme-relative //host/x, a backslash, a control character, a foreign host, a scheme other than https, credentials in the URL. A URL's host is dropped and so is its fragment. A payload is untrusted input: a notification can be sent by anyone who has the app's token or topic.
Cold start and taps
- A cold start is answered in
launch():initialTap()is mapped to anInboundLaunchwithsource: NavigationSource.notification, so the router is built at that location (over the platform's initial route) and the first navigation is marked.open:does not apply: a cold start is always the initial location. The guards run as for any link: a signed-out tap on a guarded page lands on the login page withfrom. - A warm tap is handled in
attach(router, container): the package subscribes totapswith onecontainer.listen, so the subscription goes with theProviderScope, and callsrouter.goorrouter.pushinsidenavigateFrom(NavigationSource.notification, ...). - A tap seen twice is opened once: some Android and plugin combinations deliver the cold-start notification both as the launch and on the tap stream. The package remembers the message id of the launch and drops the first tap with the same id, once; any other tap opens, even one with an id it has seen. A message without an id is told by its payload only while the app starts (a tap kept before the first frame with the cold-start notification's
dataandcategory, since 0.16.0); after that it cannot be deduplicated, and a source must give a real, unique id or null (never a constant). - A tap while the app starts is kept (since 0.16.0).
launch()runs beforerunAppandattachafterstartup(),ready()and the first frame of the router, andtapsis a broadcast stream, so a tap in between used to be lost (the window is the whole startup, not one frame). Nowlaunch()listens totapsin a container of its own and keeps the latest tap (one, never a queue: the newest replaces the one before, with no timer);attachmakes the scope's subscription first, ends the hold, and opens what it kept once, as a warm tap, after the rest of the adapter is attached. The cold-start notification is not opened twice: a kept tap with the id of the onelaunch()opened is dropped (once), and so is one without an id that carries the same payload. Still lost: a tap beforelaunch()runs (the plugin's own buffering decides: OneSignal buffers), on the web (launch()is never asked), and in a widget test that does not callAppAdapters.launch()first. Sotapsis listened to twice in a running app, once fromlaunch()untilattachand once per scope: a source built on a platform channel returns one stream every time (alate finalfield, as fortokens). - One subscription per
ProviderScope: a second router built on the same container (a test) is ignored, and the first router gets the taps. fespalier.push.routedsays the mapping gave a place and the navigation call was made; on a cold start another adapter'slaunch()may still have won.- On the web
launch()is never asked, and a notification click is an ordinary URL.
Telemetry (the package's own names, FespalierPushConventions): fespalier.push.open with fespalier.push.state (cold or warm) and, on its end, fespalier.push.routed (bool). The navigation it starts carries fespalier.navigation.source=notification. A payload, a title, a body, a message id and a token are never reported. No token event is reported; a future one may carry kind, never value or properties.
Tokens and permission
- A token is a
PushToken(since 0.14.0; it was aString, which tied the package to FCM and APNs):kind(an open string, withPushTokenKind.fcm,apns,hms,unifiedpush,onesignal,mipush,oppo,vivo,honorandjpushfor the usual ones),value(never null) andproperties(an unmodifiableMap<String, String>, empty by default) for what the backend needs beyond the token: OneSignal's subscription id, a UnifiedPush instance. It has value equality, and itstoStringprints the kind only, never the value or the properties. The same goes forPushTokenRevoked. onToken(token)is called with the current token and again for each different refresh. It runs in the app's container, after the first frame: send the token to your backend there. The package has no HTTP code, so it cannot post a token by itself.pushTokenis the same stream as aStreamProvider<PushToken>, for an app that prefers Riverpod.- A revocation is its own event, not a null token (since 0.14.0):
onTokenRevoked(revoked)and thepushTokenRevokedStreamProvider<PushTokenRevoked>come fromPushSource.revocations. Tell the backend to drop the registration (revoked.kindandrevoked.propertiessay which). A source with no unregister callback leavesrevocationsout. Every event reaches the callbacks, including the same token again after a revocation and a second equal revocation (an APNs token is stable across unregister and register).pushTokenandpushTokenRevokedare Riverpod state, not an event log: they hold the last value (pushTokenkeeps answering a revoked token) and an equal repeat does not notify, so use the callbacks for events. A revocation that happened before the adapter attached is not replayed. - The vendor recipes (FCM, APNs, HMS, UnifiedPush, OneSignal, JPush, flutter_mix_push) are in the fespalier-routing skill's
push-sources.md; each gives thekindand thepropertiesits backend needs. - The permission prompt is yours:
requestPushPermission(ref)shows the platform's prompt and refreshespushPermission, aFutureProvideroverpermission()(a plugin that throws is anAsyncError, read once: since 0.16.0 the provider does not retry, which would be aTimer;ref.invalidate(pushPermission)asks again). Ask at a moment the person understands (after they enable alerts), not at start.
Testing
package:fespalier_push/testing.dart has FakePushSource (initial:, token:, permissionAnswer:; tap(message), deliver(message), emitToken(PushToken), revokeToken(kind, properties:), permissionRequests) and pushTestOverrides(source). A widget test boots the app as it runs, with configure called first, because AppMain.root() runs no main():
FespalierPush.configure(source: fake, route: pushRoute);
final launch = await AppAdapters.launch(); // what main() does before runApp
await tester.pumpWidget(AppMain.root(router: () => AppRoutes.router(launch: launch)));
fake.tap(const PushMessage(id: '1', data: {'link': '/orders/42'}));
await tester.pumpAndSettle();A tap while the app starts (since 0.16.0) is the same test with fake.tap(...) between AppAdapters.launch() and pumpWidget: it opens once, after the first frame, and only the latest of several does. Call FespalierPush.debugReset() in setUp and tearDown (it also ends the hold that launch() started). examples/plugins tests a cold start, a warm tap, a guard, a foreign link, a repeated id, the token and its revocation through AppMain.root(); no device is needed. What no test can see: a real tap from the system tray, cold and warm, on each platform. Do it on a device before a release.