ConcordManual Home GitHub
Developers
On this pageWhat already exists, and what does notFirebase does not start until it is configuredClient side: switching Android oniOSNode side: giving a rendezvous credentialsFCM (Android)APNs (iOS)Token storageWhat the node learnsIntended follow-on: "Stay connected" once push worksTroubleshooting

Push notifications

Concord delivers messages over live connections. When the app is open, or backgrounded with "Stay connected" on, nothing here is needed — the node holds its connections and gossip arrives instantly. When the app has been closed, or Android has reclaimed the process, a message deposited for you sits in a rendezvous mailbox until you next open Concord.

Push closes that gap. A rendezvous node that holds credentials sends a contentless wake when a deposit lands: no sender, no text, nothing but "you have mail". The app wakes, drains the mailbox and decrypts locally, exactly as it would have on reopening.

Everything in this document is optional. A rendezvous without credentials and a build without a Firebase configuration both work; the mailbox behaves the same, minus the wake.

What already exists, and what does not

All the code is written and shipped. What is missing is a Firebase project, which needs the account of whoever publishes the app.

PieceWhereState
Contentless wake on depositinternal/mailbox/push.goDone
Persistent token storePushStore, same fileDone
Node wiringcmd/rendezvous/main.goDone
Token registration from the clientService.RegisterPush, internal/app/mailbox.goDone
RPC surfaceRegisterPush in internal/bridge/bridge.goDone
Client registration + wake handlingregisterPushToken in frontend/src/App.svelteDone, gated
Capacitor plugin, Gradle pluginapps/mobile/android/app/build.gradleDone
google-services.jsonapps/mobile/android/app/Missing — the only step
Firebase service account for the nodeFCM_SERVICE_ACCOUNT_JSONMissing (same project)

The gate is automatic. ConcordCorePlugin.pushAvailable() reports whether the build can reach Firebase at all, by checking for the google_app_id string resource that the google-services Gradle plugin generates from google-services.json — the same resource Firebase's own initialisation reads. The frontend asks before registering. With no configuration the answer is false, nothing registers, and the app behaves exactly as it does today.

That check exists because getting it wrong is fatal rather than untidy: PushNotifications.register() reaches FirebaseMessaging, which throws on a handler thread where no JavaScript try/catch can reach it, and the app dies.

Firebase does not start until it is configured

firebase-messaging arrives with @capacitor/push-notifications and brings FirebaseInitProvider with it: a ContentProvider the system instantiates before Application.onCreate, whose only job is to call FirebaseApp.initializeApp() from resources the google-services plugin generates. With no google-services.json those resources do not exist, so every launch of every build ran a provider that loaded the Firebase component runtime, found no google_app_id, and gave up:

W FirebaseApp: Default FirebaseApp failed to initialize because no default
               options were found.
I FirebaseInitProvider: FirebaseApp initialization unsuccessful

So the provider is now switched off until it has something to do. app/build.gradle sets a firebaseAutoInit manifest placeholder from the same condition that decides whether to apply the google-services plugin at all, and AndroidManifest.xml binds it to android:enabled on the provider. A disabled component is never instantiated.

Nothing about the drop-in below changes. Put google-services.json in place and the placeholder flips to true on the next configure, the provider comes back enabled, and FirebaseApp initialises from the resources it was looking for.

Note this is FirebaseApp initialisation itself, not the firebase_messaging_auto_init_enabled meta-data. That flag only stops FCM minting a registration token; the provider would still run and still fail, because it fails before messaging is reached.

Client side: switching Android on

  1. Create a Firebase project. https://console.firebase.google.com. Any name. Google Analytics is not used and can be declined.

  2. Add an Android app to it. The package name must be exactly:

    app.concord.mobile

    (this is applicationId in apps/mobile/android/app/build.gradle; if you ship under your own id, use that instead, in both places). A signing certificate SHA-1 is not needed — FCM messaging does not use it.

  3. Download google-services.json and put it at:

    apps/mobile/android/app/google-services.json

    Nothing else moves. app/build.gradle already applies the google-services plugin when — and only when — that file is present, and the same check re-enables FirebaseInitProvider (see above).

  4. Rebuild the app.

    make android-app
  5. Confirm. With the app running, ConcordCore.pushAvailable() returns {available: true}, the app asks for the notifications permission on first login, and RegisterPush calls appear against every connected rendezvous.

google-services.json is gitignored, and should stay that way: it identifies your Firebase project and belongs with your signing keys, not in a public repository.

iOS

iOS is not wired to a runtime probe, because an APNs entitlement offers nothing equivalent to read. Set window.__CONCORD_PUSH = true at build time once the entitlement is in place; the same registerPushToken path then runs and registers an apns token. Everything below about the node applies unchanged.

Node side: giving a rendezvous credentials

The node needs credentials for the platforms it will wake. It reads them from the environment at startup and disables itself if none are present — see NewNotifier in internal/mailbox/push.go.

FCM (Android)

VariableValue
FCM_SERVICE_ACCOUNT_JSONThe contents of a Firebase service-account JSON key, not a path

Get it from the same Firebase project: Project settings → Service accounts → Generate new private key. The node uses it to mint an OAuth2 token for the FCM HTTP v1 API; it needs client_email, private_key and project_id.

APNs (iOS)

VariableValue
APNS_AUTH_KEY_P8PEM contents of the APNs .p8 signing key
APNS_KEY_IDThat key's Key ID
APNS_TEAM_IDApple developer Team ID
APNS_TOPICThe app bundle id, e.g. app.concord.mobile
APNS_PRODUCTION1 for the production host; anything else uses sandbox

Token storage

VariableDefaultMeaning
CONCORD_PUSH_TOKENSpush-tokens.jsonWhere device tokens are persisted

This one file must survive restarts. Envelopes are in-memory and cheap to lose — they are re-sent — but a lost token is a device that silently stops being woken until its owner next logs in. On a container, put it on a mounted volume.

On startup a node with credentials prints:

Push wake bridge enabled (tokens: push-tokens.json).

and a node whose credentials fail to parse prints push notifier disabled: with the reason, then carries on without push.

What the node learns

Nothing it did not already hold. Tokens are keyed by the opaque 16-byte mailbox tag, never by identity, so the token store says "this device wants waking for this mailbox" and cannot be turned into a member list. The wake itself carries no content — the node has never had plaintext to put in it. Wakes are collapsed to at most one per mailbox per 30 seconds, and a token not refreshed in 60 days is forgotten (clients re-register on every login).

Intended follow-on: "Stay connected" once push works

Android's "Stay connected" preference runs a foreground service so the process survives backgrounding. It exists precisely because there is no push: without it, a closed app receives nothing until reopened. It also costs a permanent notification in the tray and keeps a libp2p node running on the user's battery.

Once push is configured, that trade changes — a wake can start the process on demand — and the preference should default off for builds where pushAvailable() is true, while staying on where it is false.

This is deliberately not implemented. The branch cannot be exercised by anybody today, so it would ship as untested code guarding a default that no build reaches; and it changes a visible preference, which wants to be done alongside the settings copy explaining why the tray notification disappeared. It belongs in the same change as the first build that actually has google-services.json.

Troubleshooting

pushAvailable() says false after adding the file. The Gradle plugin only runs on a clean configure. Rebuild via make android-app, and check the build log does not contain google-services.json not found.

Nothing Firebase appears in logcat at all after adding the file. The firebaseAutoInit placeholder is read at configure time, so a build that was already configured without the file keeps the provider disabled. The merged manifest is the check: android:enabled="false" on FirebaseInitProvider in app/build/intermediates/merged_manifest/…/AndroidManifest.xml means the configure did not see the file.

Registered, but no wakes arrive. Confirm the node has credentials: its startup line is the fastest check. A client will register a token happily against a rendezvous that can do nothing with it.

Wakes arrive but nothing appears. The wake is contentless by design. What posts a notification is the app draining the mailbox and decrypting; if the mailbox is empty by then — another device drained it first — silence is correct.

Edit this page on GitHub

Concord is free software under the GNU Affero General Public License, version 3 or later. These pages are built from the Markdown in the repository.