Notifications & Background Modes
Foreground services, periodic WorkManager task schedulers, signature-polling status checks, and flat-file inbox buffering.
Overview
Wiltkey ships in two build flavors (Gradle product flavors from a single codebase) that differ only in how the app wakes for background messages:
- FOSS (GitHub / website, applicationId
xyz.artfacility.wiltkey.foss) — zero Google Play Services. Background detection runs entirely in client isolates. - Play (Google Play,
xyz.artfacility.wiltkey) — adds Firebase Cloud Messaging only as a content-free wake-up trigger. No message content ever passes through Google; the encrypted payload stays queued on the relay and is pulled by the client on its next connection.
Both flavors expose the same three user-facing modes: Instant, Low Power (periodic HTTP polling), and Off. The only difference is what backs Instant: a persistent foreground WebSocket on FOSS, versus an FCM wake-up ping on Play (no persistent service, lighter on battery, and immune to the Android 15 6-hour dataSync timeout). The flavor is selected at build time via a Gradle flavor + the --dart-define=WK_FCM= flag, which gates every Firebase touchpoint on the Dart side (kFcmEnabled).
Google Play throttles apps that hold long-lived background connections. Rather than fight that (or risk removal), the Play build hands the "a message is waiting" wake-up to FCM — a dumb pipe — while keeping all content encrypted and off Google's servers. Users who don't want Google in the loop at all can install the FOSS build, or simply pick Low Power / Off on the Play build.
Background Sync Architectures
1. Instant Mode (Foreground Service)
Instant Mode spawns a persistent background isolate (_MessageTaskHandler) using flutter_foreground_task. It maintains an active, authenticated WebSocket connection to the relay, streaming messages as they arrive.
Secure Flat-File Buffering: When a message arrives while the device is locked, the master key is not in RAM, meaning the SQLite database cannot be decrypted or updated. To prevent dropping messages (the relay deletes messages once delivered), the background isolate appends the raw, encrypted envelope to a flat, append-only JSON lines file:
text, image, group_message), a local notification is fired. The main isolate reads this file, decrypts all buffered envelopes, writes them to SQLite, and deletes the file — this drain runs both when the user unlocks the app and on every foreground-resume while already unlocked (e.g. reopening inside the biometric window, or with immediate-lock disabled). A reentrancy guard ensures the two callers can never drain the buffer concurrently.
All frame types are buffered — not just user messages — including
delivery_receipt, delivery_check, and delivery_check_response. Crucially, _handleDeliveryReceipt writes the delivered flag straight to SQLite (by message id and by sender offset), independent of whether that chat is currently loaded in memory. A receipt almost always lands while the target chat isn't windowed — right after the app reopens from a closed state and replays the buffer — and an in-memory-only update would silently drop it, stranding the message on a single check forever. Receipts that expire from the relay's 24h queue (app closed longer than that) are recovered separately by the on-demand delivery-check reconciliation (syncOneOnOneChat).The background isolate only buffers these control frames — it cannot answer a
delivery_check (no master key, no state). The reply is produced when the buffer is drained on the next foreground, which is why the drain now runs on resume as well as unlock: it lets reconciliation converge on the next foreground of each side rather than requiring both apps to be open simultaneously. The relay store-and-forwards all of these frames with a 24h TTL, so no server change is involved.
2. Low Power Mode (Periodic Polling)
Low Power Mode utilizes Android's WorkManager to run a periodic background task (callbackDispatcher) approximately every 10 minutes. Rather than establishing a full WebSocket session or downloading messages (which would violate privacy and drain battery), it calls a signature-authenticated endpoint on the server:
Future<bool> _pollQueueStatus(_BgCreds creds) async {
final ts = (DateTime.now().millisecondsSinceEpoch ~/ 1000).toString();
// Sign identity with Ed25519 key stashed in secure storage
final sig = _sign(creds.privKeyHex, '${creds.userId}:$ts');
final response = await client.get("/api/v1/queue/status?id=${creds.userId}×tamp=$ts&sig=$sig");
return response.json['has_payload'] == true;
}
If has_payload is true, a local notification is shown. Messages are not downloaded until the user opens the application, ensuring decryption keys remain locked.
3. FCM Wake-up Push (Play Flavor Only)
On the Play build, Instant mode drops the persistent foreground service entirely and lets Firebase Cloud Messaging do the waking. The relay stores each Play user's FCM registration token (in Redis, keyed push:{userId}, 60-day TTL, refreshed on every connect); the FOSS build never registers one.
In-place upgrade healing: a user who ran the old foreground-service Instant build and updated in place keeps a stored notificationMode == instant, but the FCM token/permission setup only ever ran inside setNotificationMode — which they never re-triggered — so Instant looked selected yet stayed silent until they toggled the mode off, killed the app, and back on. reconcileInstantModeAfterUpgrade() now does that once automatically on the first unlock after the update (request permission + register token), gated by a persisted flag so it never re-nags. No-op on FOSS.
Delivery flow:
- Register: the client fetches its FCM token over the native
wiltkey/pushMethodChannel and registers it with the relay (POST /api/v1/push/register), authenticated with the same Ed25519 signature scheme asqueue/status— so a device can only set its own token. - Ping: when a message is queued for an offline recipient that has a token, the relay sends a data-only, high-priority FCM message carrying nothing but the sender's key hash (a one-way hash, for deep-linking) — never message content.
- Notify: a native service (
WiltkeyFirebaseService, compiled into the Play flavor only) receives the ping and posts a content-free "New secure message" notification — a genuine, user-perceptible alert (no policy-dodging placeholder tricks). - Pull: the encrypted payload is still sitting in the relay's offline queue. It's delivered normally the next time the app connects (foreground), decrypted with the master key, and written to SQLite — exactly like the FOSS pull path.
Only that a device (by FCM token) should wake, plus at most the sender's key hash. No message content, no plaintext identities, no readable metadata — the relay still never decrypts anything, and the payload never touches Google's servers. This is the one piece of longer-lived state the relay keeps beyond a live socket (the token map), which is the irreducible cost of waking a closed app. The token is dropped on Off/Low-Power/nuke, and the relay prunes it automatically if FCM reports it stale.
// fcm.go — service-account JWT → OAuth2 token → FCM HTTP v1 data-only push.
// Disabled/no-op unless FCM_CREDENTIALS_FILE is set, so the self-hosted /
// FOSS relay is unaffected. Only sender_id (a hash) is ever transmitted.
"message": { "token": token, "data": {"sender_id": senderID},
"android": {"priority": "HIGH"} }
Key Files & Symbols
| File Path | Symbol Name | Description |
|---|---|---|
lib/core/notifications/background_handler.dart |
startCallback() |
Entry point for the Foreground Service background isolate. |
lib/core/notifications/background_handler.dart |
callbackDispatcher() |
Entry point for the WorkManager background polling dispatcher. |
lib/core/notifications/pending_inbox.dart |
PendingInbox |
Manages appends, reads, and clears of the raw JSON lines buffer wiltkey_pending_inbox.jsonl. |
lib/core/state_inbound.dart |
processPendingInbox() |
Processes and decrypts the flat-file buffer on application unlock and on foreground-resume; reentrancy-guarded against concurrent drains. |
lib/core/build_flavor.dart |
kFcmEnabled |
Compile-time flavor flag (--dart-define=WK_FCM). Gates every Firebase/FCM code path; false on the FOSS build. |
lib/core/state_push.dart |
registerPushToken() / unregisterPushToken() |
Registers/clears the FCM token with the relay (Play + Instant only). Signed with the identity key; re-asserted on every WebSocket connect. |
android/app/src/play/…/WiltkeyFirebaseService.kt |
WiltkeyFirebaseService |
Native FCM receiver (Play source set only). Posts the content-free notification and deep-links via sender_id. |
wiltkey_server/push.go, fcm.go |
handlePushRegister, PushSender |
Relay token endpoints + the stdlib FCM sender. Sender is a no-op unless FCM_CREDENTIALS_FILE is configured. |
Gotchas & Edge Cases
SQLite does not allow simultaneous write operations from separate OS processes or isolates. Writing to SQLite from a background isolate while the main isolate is running will cause write lock collisions. For this reason,
PendingInbox uses a simple flat-file write rather than the SQLite database.