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}/policy rings the changed device after bumping its version (see server.md).

The message is data-onlyputData("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 a SyncWorker rather 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.