The Android app's glanceable surfaces are the ongoing "Watch" notification
(the Android counterpart of the iOS Live Activity), needs-you notifications
with actions, and the widgets they refresh. The API can feed them over
Firebase Cloud Messaging (FCM HTTP v1). Server push is optional. Without
it, the app uses its on-device baseline: /ws/events while it runs, a
15-minute WorkManager poll of GET /api/glance/watch, and an opt-in "Keep
watching" foreground service.
This page covers setup, the wire contract, and how to test without a device.
The iOS twin is docs/ios-push.md. Both platforms get the same events,
categories, deep links and preference keys from the same fan-out
(apps/api/src/services/push-fanout.ts). Web push (VAPID), APNs and FCM share
the per-user preference map at GET/PUT /api/notifications/preferences.
The server authenticates with a service-account key and doesn't need a
Firebase SDK. It signs an RS256 JWT with node:crypto and exchanges it at
https://oauth2.googleapis.com/token for a one-hour access token (scope
firebase.messaging). The token is cached until 5 minutes before it expires,
and a 401 from FCM mints a new one once. Traffic is outbound only, to
oauth2.googleapis.com and fcm.googleapis.com on port 443, so like APNs it
needs no ingress change.
-
Create a Firebase project in the Firebase console. Analytics is not needed.
-
Add an Android app to the project with package name
dev.optio.android. Downloadgoogle-services.jsonand put it atapps/android/app/google-services.jsonbefore you build the app. The Gradle build applies the google-services plugin only when that file exists. Without it, the app's FCM code paths are no-ops. The file holds project identifiers, not secrets, but it is specific to one project, so keep it out of git. -
Create a service-account key for the server. In the Firebase console, open Project settings → Service accounts and click Generate new private key. This downloads a JSON key.
You can also use Google Cloud IAM. Pick a service account that has the role Firebase Cloud Messaging API Admin (
roles/firebasecloudmessaging.admin), then choose Keys → Add key → JSON.The Firebase Cloud Messaging API (V1) must be enabled for the project. New Firebase projects have it enabled.
-
Paste the key into the Helm values, or use
--set-file notifications.fcm.serviceAccount=optio-fcm.json:notifications: fcm: serviceAccount: | { "type": "service_account", "project_id": "my-optio", "private_key_id": "…", "private_key": "-----BEGIN PRIVATE KEY-----\n…\n-----END PRIVATE KEY-----\n", "client_email": "firebase-adminsdk-xxxxx@my-optio.iam.gserviceaccount.com", … } projectId: "" # optional; defaults to the key's project_id
To keep the key out of values, create a Secret yourself and set
notifications.fcm.existingSecret: optio-fcm. The Secret's keys areOPTIO_FCM_SERVICE_ACCOUNTand, optionally,OPTIO_FCM_PROJECT_ID:kubectl -n optio create secret generic optio-fcm \ --from-file=OPTIO_FCM_SERVICE_ACCOUNT=optio-fcm.json
-
Run
helm upgrade optio helm/optio -n optio --reuse-values -f values.yaml. At boot the API logs one of these lines:FCM configured, with the project id and the service account's email.FCM not configured — Android push disabled, logged once when there is no key. Every hook is then a no-op.FCM: invalid service account key, with a reason (never the key).
Outside Helm, these environment variables apply:
OPTIO_FCM_SERVICE_ACCOUNT: the key JSON, or base64 of it. Literal\nescapes inprivate_keyare accepted.OPTIO_FCM_SERVICE_ACCOUNT_FILE: a path to the key JSON, used instead of the variable above.OPTIO_FCM_PROJECT_ID: defaults to the key'sproject_id.OPTIO_FCM_TRANSPORT=fake: record sends instead of sending them.OPTIO_FCM_FAKE_OUTBOX=<file>: with the fake transport, also append every request as one JSON line.
An FCM registration token is bound to the Firebase project in the app's
google-services.json. Every Optio server that pushes to that build must use a
service-account key from the same project. A token from another project
comes back as SENDER_ID_MISMATCH, and the server drops the device row. The
symptom is devices disappearing from GET /api/notifications/devices, which is
the FCM equivalent of an APNs sandbox/production mismatch.
These routes are shared with iOS. They need an authenticated user, and writes need the member role. Rows are always scoped to the caller.
| Method | Path | Body | Response |
|---|---|---|---|
POST |
/api/notifications/devices |
{ platform: "android", token, appId, appVersion?, deviceName?, serverId? } |
201 { device } (token masked) |
GET |
/api/notifications/devices |
— | 200 { devices: [...], push: { apns, fcm } } |
DELETE |
/api/notifications/devices/:ref |
— | 204 (also when absent) |
POST |
/api/notifications/devices/test |
— | 200 { sent } / 503 |
GET |
/api/glance/watch |
— | 200 WatchState |
- Register (
POST). Upserts by token. Re-registering resets the failure counter, and a token that re-registers under another user moves to that user.- FCM tokens must match
[A-Za-z0-9_:.-]{32,1024}. They are case-sensitive and stored exactly as sent, unlike APNs hex tokens, which are lower-cased. serverIdis the app's own id for this server (its paired-server profile). The server echoes it asserverIdin every message, so an app paired with several servers can route a push without probing each one.- iOS keeps its body:
{ token, platform?: "ios", environment?, bundleId, … }.
- FCM tokens must match
- List (
GET). Returns iOS and Android rows. Every row has aplatform. iOS rows also carryenvironmentandbundleId; Android rows carryappIdandserverId. Thepushobject says which providers the server holds credentials for. - Delete (
DELETE).:refis a raw token (APNs hex or FCM) or a rowidfrom the list. The list masks tokens, so use the rowidto delete a device you don't hold. - Test. Sends one alert to every device, through APNs and FCM.
sentcounts accepted sends across both. The route returns 503 only when neither provider is configured.
The app should register its token with every paired server on each launch
and whenever FirebaseMessagingService.onNewToken fires. When the user forgets
a server, the app should DELETE the token from that server.
Alerts go through the same per-user preference checks as web push and APNs, keyed by the event type in the second column.
| Event | Preference key | category |
sound |
threadId · url |
Watch (Android) |
|---|---|---|---|---|---|
Running agent terminal → needs_you (stop/notification/quiet) |
local.needs_you |
LOCAL_NEEDS_YOU (+ timeSensitive) |
default only if the queue was empty, else none |
terminal id · optio://local/<id>?compose=1 |
update (HIGH when the queue was empty) |
Automated terminal (spawnedBy != manual) exits |
local.needs_you |
LOCAL_EXIT |
none |
terminal id · optio://local/<id> |
update |
| Host goes offline with agents running | local.host_offline |
HOST_OFFLINE |
default (once per outage) |
host-<id> · optio://local |
update (phase: offline) |
Task → needs_attention / failed |
task.needs_attention / task.failed |
TASK_ATTENTION (+ timeSensitive) |
default |
task-<id> · optio://tasks/<id> |
refresh (only while a Watch is showing) |
Task → pr_opened |
task.pr_opened |
TASK_PR_OPENED (+ prUrl) |
default |
task-<id> · optio://tasks/<id> |
refresh (only while a Watch is showing) |
| Agent turn halts after draining a user's message | agent.turn_completed |
AGENT_REPLY |
default |
agent-<id> · optio://agents/<id>?compose=1 |
— |
Agent → failed (consecutive-failure limit) |
agent.failed |
AGENT_FAILED (+ timeSensitive) |
default |
agent-<id> · optio://agents/<id> |
— |
| Snooze / unsnooze / snooze expiry | — | — | — | — | update |
| First active frame (a running agent, or one needing you) | — | — | — | — | start |
| Nothing running or waiting for 2 min | — | — | — | — | end (phase: done, summary: "Quiet.") |
The category values are the iOS ones, so the Android app should create
notification channels and actions per category, mirroring
apps/ios/Optio/Core/Notifications/NotificationCategories.swift:
LOCAL_NEEDS_YOU: Reply, LaterLOCAL_EXIT: OpenTASK_ATTENTION: Resume, Retry, OpenTASK_PR_OPENED: Open PRAGENT_REPLY: ReplyAGENT_FAILED: Resume
TEST is used only by the test route.
Every message is data-only, with no notification block. That means
onMessageReceived runs in the background too, and the app renders every
notification itself. All data values are strings.
The request body the server POSTs to
https://fcm.googleapis.com/v1/projects/<project>/messages:send:
{
"message": {
"token": "<FCM registration token>",
"data": {
"type": "alert",
"category": "LOCAL_NEEDS_YOU",
"title": "Needs you · web",
"subtitle": "claude-code · mbp",
"body": "Waiting on a permission · Allow Bash(rm -rf)?",
"url": "optio://local/<terminal id>?compose=1",
"kind": "local",
"id": "<terminal id>",
"threadId": "<terminal id>",
"sound": "default",
"timeSensitive": "1",
"collapseId": "local-<terminal id>",
"serverId": "<the app's id for this server>"
},
"android": { "priority": "HIGH", "ttl": "86400s" }
}
}| Key | Always | Meaning |
|---|---|---|
type |
yes | alert |
category |
yes | see the table above; drives the channel and actions |
title |
yes | ≤ 200 chars |
subtitle |
no | ≤ 200 chars (the terminal title for Local alerts) |
body |
yes | ≤ 500 chars |
url |
yes | deep link to open on tap |
kind |
yes | local · host · task · agent · test |
id |
yes | subject id; the target of actions (reply, snooze, resume, …) |
threadId |
yes | group key (the APNs thread-id) |
sound |
yes | default or none |
timeSensitive |
no | "1" when iOS would mark the alert time-sensitive |
prUrl |
no | TASK_PR_OPENED when the task has a PR |
collapseId |
yes | the APNs apns-collapse-id (local-<id>, task-<id>, agent-<id>-<turn>); use it as the notification tag so a newer alert replaces an older one exactly as on iOS |
serverId |
no | the serverId the app registered with |
Alerts carry no collapse_key. FCM collapsible messages are throttled and only
four collapse keys are kept per device, so alerts must never replace each other
inside FCM. The app does the replacing, with collapseId as the notification
tag.
{
"message": {
"token": "<FCM registration token>",
"data": {
"type": "watch",
"event": "update",
"state": "{\"phase\":\"waiting\",\"head\":{\"kind\":\"local\",\"id\":\"…\",\"title\":\"claude-code\",\"mono\":\"web\",\"reason\":\"Waiting on a permission\",\"preview\":\"Allow Bash(rm -rf)?\",\"since\":811339200,\"state\":\"needs_you\",\"link\":\"optio://local/…?compose=1\",\"source\":\"local-terminal\",\"when\":\"now\",\"where\":{\"target\":\"machine\",\"detail\":\"mbp · ~/repos/optio/apps/web\"},\"who\":\"claude-code\",\"then\":\"waits-for-me\",\"statusLabel\":\"needs you\"},\"others\":[],\"needsYouCount\":1,\"runningCount\":2,\"waitingCount\":1,\"recurringCount\":4,\"agentCount\":2,\"offlineSince\":null,\"summary\":null,\"asOf\":811339200}",
"serverId": "<the app's id for this server>"
},
"android": { "priority": "HIGH", "ttl": "3600s", "collapse_key": "watch" }
}
}-
stateis the JSON-encodedWatchStatefrompackages/shared/src/types/glance.ts. It is the same frame as the iOS Live Activity'scontent-stateandGET /api/glance/watch. Dates are Apple reference-date seconds; add978307200to get unix seconds. -
eventfollows the Watch lifecycle as the server sees it:start: the first active frame since the lastend, or since the API process started.update: every frame after that.end: the finaldoneframe, sent after 2 minutes with nothing running or waiting.
Every event carries the full frame, and the app decides what to show:
- Treat an
updatefor a Watch you aren't showing like astart. - Treat an
endfor a Watch you aren't showing as a no-op.
-
Priority is
NORMAL, orHIGHwhen the frame alerts. A frame alerts when something newly needs you and the queue was empty. A separateLOCAL_NEEDS_YOUalert carries the sound, so the Watch update itself can be silent. -
Ordering. FCM doesn't guarantee order across priorities. Drop a frame whose
state.asOfis older than the one you are showing. -
Collapsing. All frames share
collapse_key: "watch", so a device coming back online gets only the newest one. FCM also throttles collapsible messages per device: a burst of 20, then about one every 3 minutes. Treat the Watch as eventually consistent, and refresh fromGET /api/glance/watchor/ws/eventswhile the app is open. -
Recipients. Android has no per-activity push tokens, so every Watch frame goes to all of the user's Android devices.
The generated Kotlin models (pnpm gen:kotlin, package dev.optio.core.model)
cover both message types, with a sealed AndroidPushMessage (AndroidPushAlert,
AndroidPushWatch, or Unknown) plus WatchState, PushDevice, and
PushDevicesResponse:
val message = OptioJson.decodeFromJsonElement(
AndroidPushMessage.serializer(),
JsonObject(remoteMessage.data.mapValues { JsonPrimitive(it.value) }),
)
when (message) {
is AndroidPushAlert -> postAlert(message)
is AndroidPushWatch -> updateWatch(message.event, OptioJson.decodeFromString<WatchState>(message.state))
is AndroidPushMessage.Unknown -> Unit
}Android may lower the priority of an app whose HIGH-priority messages don't show anything to the user. Every HIGH message, whether an alert or an alerting Watch frame, should post or update a visible notification.
-
Size. FCM caps the payload at 4 KB, measured here on the JSON-encoded
data(escapes included). Alert fields are clipped as on iOS. A Watch frame over the cap sheds detail in this order until it fits:- previews of the other rows
- the head row's preview
- the other rows
- the head row's long fields
The counts always survive.
-
Watch rate. Each device gets at most one Watch message per second. This is the Live Activity coalescer (
watch-coalescer.ts, shared with APNs): the trailing edge sends, the latest frame wins, and an alerting frame is never downgraded by a later silent one.startandendare never downgraded toupdate. Anupdateidentical to the last frame sent is dropped (asOfis ignored in the comparison). The coalescer is per API instance; the rest of the bookkeeping (last attention state, needs-you and running counts, hosts alerted offline, the quiet-period and snooze timers) is shared in theglance_statetable, as described indocs/ios-push.md. -
Bad tokens. The server deletes the row at once on any of these:
UNREGISTERED, or HTTP 404SENDER_ID_MISMATCHINVALID_ARGUMENTthat namesmessage.token
Five consecutive failures of any other kind also delete the row, and a success resets the counter.
-
Server-side failures don't count against devices. If the token exchange fails, or FCM answers 401 or 403
PERMISSION_DENIED(the server's own credentials), the device's failure counter is left alone. The error is logged at most once a minute. -
Fire and forget. Everything is fire-and-forget from the producing service, through
apps/api/src/services/glance-service.tsand the provider fan-out inpush-fanout.ts. A provider that fails never blocks the other.
Without Firebase or a device (what CI runs):
pnpm turbo testruns these unit tests:fcm-payloads.test.ts: payloads, parity with APNs, size cap and sheddingfcm-auth.test.ts: key parsing, JWT signature, token cache and backofffcm-transport.test.ts: HTTP v1 requests, the 401 retry, error → drop decisionsfcm-service.test.ts: fan-out, drops, the Watch lifecycle, coalescingwatch-coalescer.test.tsglance-service.test.ts: one fan-out over APNs and FCMnotifications-apns.test.ts: the device routes for both platforms
pnpm --filter @optio/api test:integrationrunsfcm.int.test.tsagainst real Postgres. It covers thefcm_devicesstore, the preference gate shared by both providers, and both providers driven by real terminal events.pnpm --filter @optio/api test:e2erunsandroid-push.e2e.test.ts. It boots the real server with auth enabled andOPTIO_FCM_TRANSPORT=fake, registers a device, drives a Local agent terminal and repo tasks, and reads the requests the server would have sent fromOPTIO_FCM_FAKE_OUTBOX.
Grabbing real payloads. Run any API with
OPTIO_FCM_TRANSPORT=fake OPTIO_FCM_FAKE_OUTBOX=/tmp/fcm.jsonl. Each line in
that file is one HTTP v1 request body. LOG_LEVEL=debug also logs each send as
FCM (fake): recorded send.
The Android private test API ignores those variables, because its launcher drops
every OPTIO_* variable it inherits from your shell. Start it with
apps/android/scripts/test-api.sh start --auth --fcm-fake instead. The device
routes need auth, and the outbox is written to
apps/android/e2e/.run/<port>/fcm-outbox.jsonl.
To exercise the app's handler without Firebase, feed a line's data to a
Robolectric test with RemoteMessage.Builder("x").setData(data).build().
On a device (needs the key above and a build with google-services.json):
- Install the build and allow notifications.
- Check that the device appears in
GET /api/notifications/deviceswithplatform: "android"andpush.fcm: true. - Send
POST /api/notifications/devices/test. - For the Watch, start an agent terminal from the Local page. The ongoing notification should appear within a couple of seconds.
To isolate a token problem, send to the token directly with the key:
gcloud auth activate-service-account --key-file=optio-fcm.json
curl -sS -X POST "https://fcm.googleapis.com/v1/projects/<project>/messages:send" \
-H "Authorization: Bearer $(gcloud auth print-access-token)" \
-H "Content-Type: application/json" \
-d '{"message":{"token":"<token>","data":{"type":"alert","category":"TEST","title":"Optio test","body":"Hello","url":"optio://settings","kind":"test","id":"test","threadId":"test","sound":"default","collapseId":"test-test"},"android":{"priority":"HIGH"}}}'