Zam has three notification paths. Only two of them involve a push gateway; mixing them up is the usual source of confusion, so they are listed separately here.
| path | when it fires | who displays it | needs Sygnal? |
|---|---|---|---|
| Foreground | the app is open and syncing | new Notification(...) from the sync handler in AppShell.svelte — for messages and for incoming calls |
no |
| Background web / PWA | the tab is closed or backgrounded | static/sw.js push handler |
yes (webpush app) |
| Background / killed Android | the app is not running | MatrixMessagingService.java |
yes (gcm/fcm_v1 app) |
| Background / killed Android, UnifiedPush | the app is not running | UnifiedPushService.java → same display code as above |
no (a Matrix gateway paired with the distributor) |
The foreground path is guarded on typeof Notification === "undefined", so it is
the web/desktop path in practice — it only fires on Android if that WebView
exposes the Notification API.
The Capacitor plugin's pushNotificationReceived listener in src/lib/push.ts
only writes to the console — it does not display anything. Anything a user
actually sees while the app is open comes from the web layer's sync path, not
from push.ts.
The two background paths register a Matrix pusher with the homeserver pointing at
Sygnal (https://github.com/matrix-org/sygnal), which fans out to the right
transport. They use two different app_ids and can be served by the same Sygnal:
- Android app (
moe.crafty.matrix) → Sygnalgcm/fcm_v1app → FCM. Needs a Firebase project +google-services.json. - PWA / browser (
moe.crafty.matrix.webpush) → Sygnalwebpushapp → the browser's own Web Push service. No Firebase / FCM — uses a VAPID keypair and the standard Web Push API. See "PWA / Web Push" below.
Android: App (FCM token) ─registers pusher─▶ Homeserver ─▶ Sygnal(gcm) ─▶ FCM ─▶ device
PWA: PushManager sub ─registers pusher─▶ Homeserver ─▶ Sygnal(webpush) ─▶ browser push ─▶ service worker
Neither gateway setting is "off by default". Both have a fallback baked into the source:
src/lib/push.ts—PUSH_GATEWAY_URLfalls back tohttps://sygnal.crafty.moe/_matrix/push/v1/notify.src/lib/webPush.ts— the same gateway fallback, plus a committed public VAPID key.
So a build with no env vars set will happily register pushers pointing at this project's Sygnal instance. See "Turning push off" below for what actually disables it.
- Create a project at https://console.firebase.google.com/.
- Add an Android app with package name
moe.crafty.matrix. - Download
google-services.json→ place atandroid/app/google-services.json. (The Gradle build only enables FCM when this file is present; without it the app still runs, just without push.) - Project Settings → Service Accounts → Generate new private key — you'll give this to Sygnal.
Follow the Sygnal docs. A minimal sygnal.yaml app entry for this client:
apps:
moe.crafty.matrix:
type: gcm
# FCM v1 with a service account (recommended):
api_version: v1
project_id: your-firebase-project-id
service_account_file: /path/to/service-account.jsonRun Sygnal behind HTTPS so the homeserver can reach it, e.g.
https://sygnal.your-domain.example/_matrix/push/v1/notify.
⚠ Don't use the literal host
sygnal.example.comin a real build.push.tstreats a gateway URL containing that string as the "not configured" sentinel and skips the whole FCM stack — see "Turning push off". Every example below usessygnal.your-domain.examplefor that reason.
Set the gateway URL at build time (preferred):
VITE_PUSH_GATEWAY_URL="https://sygnal.your-domain.example/_matrix/push/v1/notify" npm run buildIn CI it's read from the repository variable PUSH_GATEWAY_URL
(Settings → Secrets and variables → Actions → Variables). If unset, the build
falls back to the project gateway (see the warning above) — it does not
disable push.
Alternatively, edit the fallback in src/lib/push.ts.
npm run build
npx cap sync android # copies web assets, wires the FCM plugin + google-services.json
npx cap open android # then Run from Android StudioFor CI builds to include push you must also provide google-services.json to
the workflow (e.g. base64 in a secret, decoded before npx cap sync).
- Launch the app, log in, grant the notification permission.
- Confirm an HTTP pusher was registered with the homeserver pointing at your Sygnal URL. Settings → Debug Info does this for you (see "Diagnostics").
- Background the app and send a message from another account → notification appears. Tapping it opens the room.
There is no single "push: off" switch. What each knob actually does:
- Android/FCM:
src/lib/push.tstreats a gateway URL containingsygnal.example.comas "not configured" and skips the whole FCM stack. So building withVITE_PUSH_GATEWAY_URL="https://sygnal.example.com/..."is the supported way to disable Android push. Not setting the variable at all does the opposite of what you'd expect — it selects the project gateway. - Web push: there is currently no build-time off switch.
webPushConfigured()is!!VAPID_PUBLIC_KEY, and the key has a committed fallback, so it is always true. SettingVITE_VAPID_PUBLIC_KEY=""falls back to the committed key rather than clearing it. To disable it you must edit the fallback insrc/lib/webPush.tsto an empty string. - Per user, at runtime: both paths only ever run after the user grants the
browser/OS notification permission, so a user who never grants it never gets a
pusher registered. An explicit logout deletes both pushers
(
unregisterPush+teardownWebPush). Note that session expiry and account switching do not — expiry calls onlyunregisterPush, which is itself no-op'd off native, so an expired browser/PWA session leaves itswebpushpusher registered on the homeserver until the account is logged out properly.
Settings → Debug Info is the fastest way to answer "is push actually set up?" without a dev console. It surfaces:
- the gateway URL this build was compiled with, and whether push is considered enabled;
- the notification permission state and the FCM token (Android);
- the pushers the homeserver actually has for this account (
GET /pushers), with each one'sapp_id, the gatewaydata.urland a truncated pushkey — this is the source of truth for "did we tell the homeserver about our gateway?"; - a Sygnal
/healthprobe derived from the configured notify endpoint (200 means Sygnal loaded its app/FCM config); - web-push state (only rendered where web push is supported): whether the VAPID
key is set, the notification permission, whether a
PushManagersubscription exists, and whether the homeserver holds a matchingwebpushpusher.
- Background/killed Android notifications are displayed by a native service,
MatrixMessagingService(android/app/src/main/java/moe/crafty/matrix/). Sygnal sends data-only FCM messages, and the Capacitor push plugin only shows a notification when the message contains anotificationblock — so without this service, backgrounded pushes arrive but are never displayed. - Notification enrichment: Sygnal's
event_id_onlypushes carry only IDs, so both background paths call the homeserver to fetch the message body, sender display name, room name, and room avatar. Android uses the session (homeserver URL + access token) that the web layer mirrors into native storage viasrc/lib/nativeSession.ts(@capacitor/preferences→ SharedPreferencesCapacitorStorage); the service worker uses the copy the app posts into its IndexedDB store. If the session is missing or a request fails, both fall back to a generic "New message" notification. - Active-session suppression: both background paths check a shared
account-data heartbeat (
moe.crafty.matrix.active_session, mirrored fromsrc/lib/utils/activeSession.tsintostatic/sw.jsand the Java service) and stay quiet when another device is actively in use. It fails open — when in doubt, it notifies. - Tapping an Android notification deep-links to the room via
window.__matrixOpenRoom(wired inMainActivity.java); the service worker'snotificationclickhandler focuses the app and opens the room. unregisterPushremoves the Android pusher andteardownWebPushremoves the web pusher (and unsubscribes thePushManager) — on explicit logout only; see "Turning push off" for what expiry and account switching do instead.- Foreground notifications on every platform come from the in-app Notification API while the client is running, and use no gateway at all.
MatrixMessagingService.java, MainActivity.java, the manifest, and the app
build.gradle are committed native files. After pulling changes rebuild the
APK (Android Studio or the release workflow). No npx cap sync is needed for
edits under android/.
Android can also receive push through UnifiedPush
(https://unifiedpush.org), using a distributor app the user installs (ntfy,
NextPush, Sunup, ...). It needs no Firebase, no Google Play services and no
Sygnal, so it works on de-Googled devices and on builds without
google-services.json.
App (UP endpoint) ─registers pusher─▶ Homeserver ─▶ Matrix gateway (e.g. ntfy) ─▶ distributor ─▶ UnifiedPushService
Choosing the transport. Settings → Notifications → Push service (Android only, stored per device):
- Automatic (default): keeps a distributor used before; otherwise FCM if this build has a Firebase config, a real gateway URL and the device has Play services; otherwise the only installed distributor. With several distributors and no FCM, nothing is picked until the user chooses.
- Google (Firebase): always FCM.
- UnifiedPush: : always that distributor.
Switching removes this device's old pusher and registers a new one.
Pusher shape. Same app_id (moe.crafty.matrix) and event_id_only
format as FCM, but pushkey is the endpoint URL and data.url is the
Matrix gateway paired with it. UnifiedPushService.discoverGateway finds that
gateway as the UnifiedPush Matrix spec describes: it uses
<endpoint origin>/_matrix/push/v1/notify if that answers
{"unifiedpush":{"gateway":"matrix"}} (ntfy does). Otherwise it falls back to
the public https://matrix.gateway.unifiedpush.org/_matrix/push/v1/notify.
Code.
UnifiedPushService.javareceives endpoints and messages. It unwraps the Matrix notification body and hands it toMatrixMessagingService.handleMatrixPush, so enrichment, active-session suppression, call ringing and Reply/Mark-as-read behave exactly like FCM.UnifiedPushPlugin.java/src/lib/unifiedPush.tsform the bridge: they list distributors, register and report the endpoint. The latest endpoint is persisted natively, so one handed over while the app was closed gets registered on the next start.src/lib/push.tspicks the transport (resolvePushProvider) and registers the pusher. After registering, it deletes any other pusher with this device's display name, which covers a rotated token/endpoint and a transport switch.- Logout deletes the pusher but keeps the distributor registration. The endpoint is not tied to an account.
- Gateway verification (SEC-L4) checks this device's UnifiedPush pusher against the gateway it registered. Other devices' UnifiedPush pushers are skipped, because only they know their gateway.
The installable web app gets notifications via the W3C Web Push API + Sygnal's
webpush pushkin. No Firebase, no google-services.json — just a VAPID keypair.
npx web-push generate-vapid-keysKeep the private key for Sygnal; the public key goes to the app build.
Sygnal wants the private key as a PKCS#8 PEM rather than the base64url string
web-push prints — node scripts/gen-vapid.mjs <base64url-private-key> does
that conversion, writing vapid_private_key.pem into the current directory.
apps:
moe.crafty.matrix.webpush:
type: webpush
vapid_private_key: "<private key>"
vapid_contact_email: you@example.com(Add this alongside the moe.crafty.matrix gcm app — both can coexist.)
Set VITE_VAPID_PUBLIC_KEY (and VITE_PUSH_GATEWAY_URL) at build time:
VITE_VAPID_PUBLIC_KEY="<public key>" \
VITE_PUSH_GATEWAY_URL="https://sygnal.your-domain.example/_matrix/push/v1/notify" \
npm run buildIn CI these come from repository variables VAPID_PUBLIC_KEY and
PUSH_GATEWAY_URL. Leaving VITE_VAPID_PUBLIC_KEY unset does not disable
web push — it falls back to the public key committed in src/lib/webPush.ts.
See "Turning push off" above.
- Open the deployed site over HTTPS, install it as a PWA (or just use it in the browser), log in, and grant the notification permission.
- The app subscribes via
PushManagerand registers awebpushpusher. - Background the tab / app and send a message from another account → the service worker shows the notification (enriched with sender, message text, room name and avatar — it fetches these from the homeserver using the auth already stored in the SW). Tapping it focuses the app and opens the room.
-
src/lib/webPush.ts— permission,PushManagersubscription, and pusher registration. The wire shape is not the obvious one: Sygnal'swebpushpushkin treats the p256dh key as the pushkey, and reads the subscriptionendpointandauthsecret out ofdata. So the registered pusher is:app_id : moe.crafty.matrix.webpush pushkey : <subscription p256dh key> ← not the endpoint data : { url, format: "event_id_only", endpoint, auth, default_payload }(
teardownWebPushdeletes under both the p256dh key and the endpoint, so a pusher registered under either shape gets cleaned up on logout.) -
static/sw.js—pushandnotificationclickhandlers, with the same homeserver-enrichment as the Android service, plus the "hide message text" privacy flag and the active-session suppression described above. The same worker also injects the mediaAuthorizationheader for<img src>on/_matrix/client/v1/media/requests. -
Requires HTTPS and a registered service worker (already used for media auth).
-
iOS Safari supports web push only for apps added to the Home Screen (iOS 16.4+).