Enabling FCM push (companion app)
The Android companion app has two push paths:
- WebSocket (default, FOSS). A foreground
Serviceholds a socket on/api/v1/ws. Works with no external service - this is theminimal(F-Droid) flavor’s only path. The socket sleeps when the screen is off or the network is metered, so a backgrounded phone can miss a wake. - FCM (optional,
full/Play flavor). Firebase Cloud Messaging wakes the phone even when the app is backgrounded and the socket is asleep. Push-worthy frames - including the agent’s device commands (phone_command: alarms, timers, speak, navigation, and more) - trigger an FCM wake so they reach the phone reliably. The FCM message itself carries no content (see “wake-and-fetch” below); it only nudges the phone to fetch the real frame over its own authenticated channel.
FCM is off until configured on both sides: the server needs a Firebase
service account, and the app’s full build needs the matching client config.
Without either, everything degrades cleanly to the WebSocket path.
The code is already wired end-to-end: the server sender lives in
realtime/fcm.pyand is fanned out from the singlepublish_user_eventfunnel (realtime/bus/user_events.py); thepush_tokenstable backs the registry; the app’sPersonalAgentFcmServicereceives the wake. This guide is the operational setup.
1. Create the Firebase project
- In the Firebase console create a project (or reuse one). FCM uses the HTTP v1 API - no legacy server key needed.
- Project settings → Cloud Messaging: confirm the Firebase Cloud Messaging API (V1) is Enabled.
- Register an Android app in the project with the companion app’s
application id (
org.personalagent.android, or your own if you re-brand it via theapplicationIdinapp/build.gradle.ktsin the Android app repo,personal-agent-org/android).
You do not need google-services.json - the app initializes Firebase
programmatically from four build values (see §3).
2. Server: mount the service account
-
Project settings → Service accounts → Generate new private key. This downloads a JSON file (contains
project_id,client_email,private_key,token_uri). Treat it as a secret. -
Mount it into both the
backendandworkercontainers (both publish user events through the same funnel, so both need the sender) and point the app at it. With the bundled Compose file in thepersonal-agent-org/deployrepo (compose/docker-compose.prod.yml), add a read-only mount to thebackendandworkerservices and set the env var on the sharedx-backend-envanchor:x-backend-env: &backend-env # … existing keys … PERSONAL_AGENT__PUSH__FCM_CREDENTIALS_FILE: /run/secrets/fcm-service-account.json services: backend: # … existing config … volumes: - ./secrets/fcm-service-account.json:/run/secrets/fcm-service-account.json:ro worker: # … existing config … volumes: - ./secrets/fcm-service-account.json:/run/secrets/fcm-service-account.json:roOn Kubernetes, mount the JSON from a
Secretand set the same env var via the Helm chart’s values. -
Restart
backend+worker. On startup each logsfcm_enabled project_id=…. If the file is missing/unreadable you’ll seefcm_credentials_unreadable(or nothing) and the sender stays a no-op - the app keeps working over the WebSocket.
The env var also gates an optional knob, PERSONAL_AGENT__PUSH__TOKEN_TTL_DAYS
(default 90): a daily maintenance job reaps push tokens not seen for that long.
Set 0 to disable the reaper.
3. Android: build the full flavor with the client config
The full flavor reads four gradle properties at build time
(app/build.gradle.kts in the Android app repo, personal-agent-org/android). Get the values from
the Firebase Android app you registered in §1 (Project settings → General → Your apps):
| Gradle property | Firebase value |
|---|---|
personalAgentFcmProjectId |
Project ID |
personalAgentFcmAppId |
App ID (1android:…) |
personalAgentFcmApiKey |
Web/Android API key |
personalAgentFcmSenderId |
Project number / Sender ID |
Build the full variant (not the default minimal), e.g.:
# in a clone of personal-agent-org/android:
./gradlew assembleFullRelease \
-PpersonalAgentFcmProjectId=my-project \
-PpersonalAgentFcmAppId=1android:abcdef \
-PpersonalAgentFcmApiKey=AIza… \
-PpersonalAgentFcmSenderId=1234567890
Prefer keeping these in ~/.gradle/gradle.properties or injecting them from CI
secrets rather than committing them. Empty values ⇒ the full build still
compiles but runs FCM-off (reports push_type:"ws" and never fetches a token),
so the build never breaks for contributors without a Firebase project.
When FCM is configured, the app’s default connection mode switches to screen-on (the socket can sleep because FCM wakes the phone); without it the default stays always-on.
4. Verify end-to-end
- App registers a token. Sign in on a
fullbuild. The app POSTs toPOST /api/v1/push/tokens(push_type:"fcm"). Confirm withGET /api/v1/push/tokens(as that user) - you should see anfcmrow (the token is returned only as itslast4, never in full). Tokens deregister viaDELETE /api/v1/push/tokens/{token}. - Server can send. Trigger a push-worthy event - the simplest is an agent
device command (e.g. “weck mich um 7” / set an alarm) with the app
backgrounded and the screen off. The phone should wake and act. Watch the
backend/worker logs: a failure logs
fcm_send_failedwith the FCM status; a dead token (a 404/410 or anUNREGISTEREDresponse) logsfcm_token_unregistered_dropped, deletes the row, and self-heals on the next app launch. - Quiet hours don’t block commands.
phone_commandframes bypass the notify-pref gate (master toggle + per-event toggle + quiet hours) - they’re functional actuation the user explicitly asked for, not notifications - so a nightly alarm still goes through. Every other push-worthy frame respects the gate (the in-app WebSocket publish is never suppressed, only the FCM wake).
Notes & limitations
- One Firebase project per backend. The sender is process-global/single project - fine for self-host and single-tenant. Per-org Firebase projects would need a rethink.
- Wake-and-fetch (content stays off Google). The FCM data message carries
only
{type, payload_id}, never the notification content. The full frame is stashed in Redis under an owner-scoped key (1-hour TTL,PUSH_PAYLOAD_TTL_SECONDS); the app fetches it viaGET /api/v1/push/payload/{payload_id}over its own authenticated connection, so the content never transits FCM/Google. An expired/unknown id is a 404. - Payload cap. The wake frame is tiny, but the sender still enforces a ~3.8 KB
safety cap (
_MAX_DATA_BYTES): an oversized frame is logged (fcm_frame_too_large) and skipped for FCM rather than rejected - the WebSocket still delivers it in-app. - Command acks over FCM. The agent waits a few seconds for the phone to confirm a command. FCM high-priority data messages usually arrive within seconds, but under Doze/throttling the agent may report the action as unconfirmed (sent, not yet acked) rather than failed - which is accurate.