The FCM doorbell
Polling is wasteful and slow; pushing the policy is fragile. So MDMCore splits the two:
Push wakes the device. HTTP moves the data.
A policy change sends a tiny data-only FCM message that means nothing more than “come check”. The device wakes and runs its normal sync over HTTP, which is where the real policy travels. The doorbell is best-effort: if it never arrives, the agent still catches up on its next scheduled sync. Nothing depends on the push being reliable — only on it being cheap.
Grounding: the FCM delivery and execution-window claims below are grounded against the Firebase Receive messages on Android doc (
kb://firebase/docs/cloud-messaging/android/receive-messages, https://firebase.google.com/docs/cloud-messaging/android/receive).
End to end
sequenceDiagram
autonumber
participant C as Console
participant S as Server
participant F as FCM (Firebase)
participant M as MdmMessagingService
participant W as SyncWorker (WorkManager)
C->>S: PUT /devices/{id}/policy (PolicyUpdate)
S->>S: bump version
S-->>C: DevicePolicy (new version)
S->>F: Doorbell.ring(token) — data-only {type: "policy_changed"}
Note over S,F: fire-and-forget (sendAsync); no-op if FCM not configured
F--)M: data message → onMessageReceived (delivered even in background)
M->>W: enqueue OneTimeWorkRequest<SyncWorker>
W->>S: runMdmSync — register token, GET policy, enforce, report
Note over W,S: the policy itself travels here, over HTTP — never in the push
Server side: Doorbell
Doorbell.kt sends the wake via the Firebase Admin SDK (FCM HTTP v1). Two routes ring it:
PUT /devices/{id}/policyrings the changed device after bumping its version (see server.md).
The message is data-only — putData("type", "policy_changed"), no notification block — and the send is fire-and-forget (sendAsync) so it never blocks the HTTP response. A stale token or network blip is ignored; the agent catches up on its next sync anyway.
Doorbell degrades gracefully: if no credential is configured, ring() is a no-op and the server runs fine without push. It logs its mode at boot (FCM doorbell: ready / NOT configured). Credential setup is in firebase-setup.md.
Agent side: MdmMessagingService + SyncWorker
MdmMessagingService.kt extends FirebaseMessagingService:
onMessageReceived— when a data message arrives, it enqueues aSyncWorkerrather than syncing inline.onNewToken— when FCM rotates the token, it enqueues a sync so the new token reaches the server (POST /devices/{id}/token).onDeletedMessages— when FCM drops queued messages, it enqueues a full sync as recovery.
SyncWorker.kt is a CoroutineWorker whose doWork() simply calls runMdmSync(...) and returns Result.retry() on failure (so WorkManager backs off and tries again).
Two design decisions, and why
Why data-only (not a notification)
FCM delivers the two message types differently depending on app state:
| App state | Notification message | Data message |
|---|---|---|
| Foreground | onMessageReceived | onMessageReceived |
| Background | system tray (never onMessageReceived) | onMessageReceived |
A silent doorbell that must reliably reach our code while the app is backgrounded therefore has to be data-only. A notification payload would be swallowed by the system tray and never trigger a sync. Bonus: a data-only message shows no UI, so the agent needs no POST_NOTIFICATIONS permission.
Why offload to WorkManager
onMessageReceived has a short execution window — Firebase’s guidance is to use WorkManager for anything that might take 10 seconds or more. Networking (enroll/pull/enforce/report) easily exceeds that, and the callback’s process can be killed mid-flight. Enqueuing a SyncWorker hands the work to a scheduler built to survive process death and retry.
One sync path for every trigger
runMdmSync is the single routine used by the foreground AgentViewModel (on launch), the background SyncWorker (doorbell, token rotation, dropped messages), and retries. Whatever woke the agent, the data always moves the same way. See agent.md.