Native push (iOS, Android)
The same push rules deliver to iOS through APNs and to Android through FCM, with the spooky_push Flutter plugin on the device and no Firebase setup in the app.
Native push is experimental. The rule format is shared with web push and stable; the plugin API may still change.
The push rules that reach browsers also reach phones. Configure Apple’s and Google’s credentials once in sp00ky.yml, add the spooky_push Flutter plugin, and every rule and direct message is delivered to iOS through APNs and to Android through FCM, with the same throttling, limits, device list and sign-out handling.
There is no Firebase setup in the app: iOS talks to APNs with its raw device token, and the Android plugin initialises Firebase at runtime from a client config the server hands out. No google-services.json, no Google services Gradle plugin, no FlutterFire.
How it fits together
| Web | iOS | Android | |
|---|---|---|---|
| Device registers with | db.webPush.subscribe() | spooky_push → fn::push::register | spooky_push → fn::push::register |
| Delivered through | the browser’s push service (VAPID) | APNs (token auth, HTTP/2) | FCM HTTP v1 |
| Server credential | derived from the auth secret | push.apns (.p8 key) | push.fcm (service account) |
| Content push | service worker shows it | iOS shows the alert | Android shows the notification |
| Nudge (no notification) | service worker renders it | silent push (content-available) | data message |
Every device is a row of _00_push_subscription with a kind (web, apns, fcm); db.webPush.devices() and db.push.devices() list all of them.
Setup
- Configure the providers
Add
apns,fcmor both to thepush:block. The keys are secret references:{ vault: KEY }(recommended),{ env: KEY }or{ file: ./AuthKey.p8 }. The CLI resolves them at everyspky migrate/spky deploy, checks them, and stores them in the root-only_00_push_credentialtable;_00_push_configonly ever holds the redacted block.sp00ky.yml - Add the pluginpubspec.yaml
iOS: in Xcode, Signing & Capabilities, add Push Notifications and Background Modes → Remote notifications. Android: nothing to add, unless the app already has a
com.google.firebase.MESSAGING_EVENTservice (see below). - Start it and give users a switch
- Send a test
await db.push.test()from the app, orspky push send --to user:abc --title Hellofrom a terminal.spky push devices user:abclists the user’s devices with kind, app, APNs environment and last error.
Rules for phones
A rule’s notification is what phones show too. Add a native: block to change it for phones only, or to show something on phones for a rule that is a nudge on the web:
native.notificationoverrides the rule’snotificationkey by key. On a phone onlytitle,body,image,tagandurlmean something.native.apnssetsapskeys:sound(alerts default todefault,noneis silent),badge,category,threadId(defaults to the tag),interruptionLevel,relevanceScore,mutableContent; anything else goes intoapsas written.native.androidsetsandroid.notificationkeys:channelId,sound,icon,color, andpriority(the message priority,highornormal); anything else goes in as written, in FCM’s snake_case.platforms: [web, ios, android]limits a rule to some kinds of device.defaults.nativeapplies under every rule.- Strings are templates like everywhere else in
push:, with the same context.
A rule with no notification and no native.notification is a nudge: iOS gets a silent push (content-available, low priority, may be delayed or dropped by iOS), Android a data message. The plugin wakes the client so it syncs, then lets the OS suspend the app again.
Direct messages (_00_push_message, spky push send, db.push.notify()) take a native object with the same keys, taken as written.
Devices, sign-in and sign-out
push.start() keeps the registration right without any further code: after a sign-in, when the connection comes back, when the app returns with a changed permission, and when the OS hands out a new token. The rules it follows:
- Nothing is registered until the user enables push on this device (
enable()), unless you passautoRegister: true. An app that asks for notification permission for something else must not opt users in silently. disable()is remembered per account: no automatic sync turns it back on.- On sign-out the device’s row is removed while the session is still valid, and the choice is kept for that account’s next sign-in. On Android the FCM token is also dropped, so the old token dies even if the server could not be reached.
- The same phone signed in as another user: the newest owner of a token gets its pushes and the previous owner’s row is deleted.
- A signed-out phone never shows pushes in the foreground.
- While impersonating, nothing is registered or removed.
Development builds get sandbox APNs tokens, TestFlight and App Store builds production ones. The plugin reads the build’s provisioning profile to tell which, and the server retries the other APNs host once on BadDeviceToken and corrects the row.
Android and other Firebase users
FCM delivers every message of an app to one MESSAGING_EVENT service. spooky_push declares one; if the app or another plugin declares one too (the live_activities plugin does), only one of them receives anything. Remove the one you do not need:
Or keep your own service and forward from it: Sp00kyPushPlugin.handleMessage(context, message) and Sp00kyPushPlugin.handleNewToken(context, token).
If the app ships its own google-services.json, it must be for the same Firebase project as push.fcm; tokens of another project are refused by sp00ky’s service account, and configureFirebase reports the conflict.
Web Push payloads are end-to-end encrypted to the browser. APNs and FCM payloads are not: Apple and Google can read what a content push says. When that matters, keep the notification text short and neutral, or send a nudge and let the app fetch the details.
Without Flutter
db.push in spooky_core is the whole server side in plain Dart; the plugin only adds the OS half. Any Dart app that gets a token another way can use it:
| Prop | Type | Default | Description |
|---|---|---|---|
| info({refresh}) | Future<PushInfo> | - | providers (web, apns, fcm) and the Android Firebase client config. Cached. |
| register(token, {label, rules, meta, userAgent}) | Future<PushDevice> | - | Register this device for the signed-in user and remember the choice. |
| sync(token, {permission, autoRegister}) | Future<PushSyncStatus> | - | Reconcile after sign-in, a token rotation, an environment switch or a permission change. Never throws. |
| unsubscribe({all}) | Future<int> | - | Remove this device (or all) and remember the opt-out. |
| update({label, rules, meta}) | Future<PushDevice?> | - | Change this device; rules: [] means every rule again. |
| devices() | Future<List<PushDevice>> | - | Every device of the user, web and native. |
| notify(message) / cancel(id) / test() | Future<...> | - | Self-pushes, as db.webPush. |
Plugin reference
| Prop | Type | Default | Description |
|---|---|---|---|
| Sp00kyPush(db, {...}) | constructor | - | autoRegister (false), foreground (hide | show), apnsEnvironment (from the profile), androidChannel, rotateTokenOnSignOut (true), onNudge, label. |
| start() | Future<void> | - | Hand-shake with the OS side and keep the device registered. Idempotent. |
| enable({label, rules, meta}) | Future<PushDevice> | - | Permission, token, registration. Throws PushError permissionDenied / noRegistration. |
| disable({all}) | Future<int> | - | Stop pushes to this device. |
| sync() | Future<PushSyncStatus> | - | Reconcile now; start() already calls it when needed. |
| permission() / requestPermission({provisional}) | Future<PushPermission> | - | granted, provisional (iOS quiet delivery), denied, notDetermined. |
| token() | Future<PushToken?> | - | The device token, or null. |
| onMessage / onOpened | Stream<PushEvent> | - | Pushes received while the app runs; notifications tapped. PushEvent: payload, url, title, body, action, isNudge. |
| initialMessage() | Future<PushEvent?> | - | The tap that launched the app, once. |
| setBadge(n) / openSettings() / createChannel(c) | Future | - | iOS badge, the notification settings screen, an extra Android channel. |
Troubleshooting
| Symptom | Check |
|---|---|
enable() throws disabled | The project has no usable push.apns / push.fcm. spky push status shows each provider; a credential that did not resolve at the last deploy is reported there. |
noRegistration on iOS | The app lacks the Push Notifications capability, or the simulator is not on Apple silicon / a recent Xcode. |
noRegistration on Android | No Google Play services on the device, or push.fcm.android is missing (info().android is null). |
| iOS devices end up disabled | spky push devices user:abc shows the reason: bad_app_id (the bundle id is not in push.apns.bundleIds), apns_400: DeviceTokenNotForTopic (the key or bundle id belongs to another app or team). A refused key (apns_403) leaves devices alone and shows in spky push status. |
Android: configureFirebase reports a conflict | The app ships a google-services.json for another Firebase project. Use the same project in push.fcm, or remove the file. |
| Android: nothing arrives, FCM accepts the push | Another MESSAGING_EVENT service took the message. See “Android and other Firebase users”. |
| Silent pushes do nothing on iOS | Background Modes → Remote notifications is missing, the user force-quit the app, or iOS throttled it (they are low priority by design). |