M5-05: Android push service without the Flutter engine¶
Status: ready | Depends on: M5-03, M5-04 | Milestone: M5
Goal¶
After this ticket the Android app receives FCM data messages that carry only IDs and shows them at once, then with text (13 5.2, 6.2; 15 10.2; ADR 0056 item 3): a FirebaseMessagingService in Kotlin, without a Flutter engine, posts a notification with the localized placeholder immediately into the channel of the kind (the mapping of M5-03), tagged with the thread ID so a later one replaces it, and starts an expedited WorkManager job that fetches the preview from GET /api/v1/push/preview with the preview token and a DPoP proof of the dpop device key (the Kotlin core of M5-01) within client.push.fetch_timeout and replaces the same notification with "notify.preview_mode allows; without network or after the timeout the placeholder stays; on 404 "Message not available"; with notify.preview_mode = none nothing is fetched. Quiet notifications post silently. The message k = "sync" removes the notifications of the cleared threads and updates the badge (13 8). The service passes the FCM token to Dart through Pigeon; Dart registers it with registerPush (provider fcm) after the notification permission is granted (runtime permission from API 33), and the Kotlin side stores the preview token in its own Keystore-encrypted store. Tapping opens the thread at the message. Without a Firebase project in the build configuration the app builds and runs without push (15 10.2). An instrumented test drives the service with a fake FCM payload against a fake server, and without network the placeholder stays. Why: push carries only IDs (R-Push), and starting a Flutter engine in the background would cost the few seconds FCM gives (15 10.2).
Read first¶
docs/adr/0056-native-extensions-without-the-flutter-engine.md(item 3, "Confirmation": this ticket).docs/spec/13-push-notifications.md: 2, 3.3 (notify.preview_mode), 3.4 (quiet hours), 5.2 (FCM data messages only, priority, nocollapse_key, the app replaces notifications by a tag equal to the thread ID, overflow, time budget with WorkManager), 6.2 (payload keysv,nid,k,i,t,m,badge,quiet, andk = "sync"withbadgeandcleared), 6.4 (preview endpoint and token, fields per mode,404), 8 (badge and sync), 9 (t2), 13 (push.register), 14.docs/spec/15-client-architecture.md: 9 (row Android), 10, 10.2 (all of it), 11, 17 (client.push_display.duration,client.push.placeholder), 21 (R-CL-06), 22 (client.push.fetch_timeout).docs/spec/08-identity.md: 7.3 (DPoP), 7.4 (no refresh outside the app).docs/spec/01-product.md: 10 (10.2 content, 10.3 row Android).docs/api/openapi.yaml:registerPush,unregisterPush,getPushPreview,PushRegistrationCreate,PushPreview.- Android documentation:
FirebaseMessagingService(onMessageReceived,onNewToken), message priority and deprioritization (https://firebase.google.com/docs/cloud-messaging/android/message-priority), WorkManager expedited work (setExpedited,OutOfQuotaPolicy),POST_NOTIFICATIONS(API 33),NotificationCompatwith tag and group. docs/DECISIONS.md: D-PN-01, D-W-100 and its extension to M5 and M6 (D-W-120), D-W-115; section 10 (format ofD-M5-05-<n>rows) and the rows of M3-21, M3-22, M5-01, M5-03, M5-04.- Briefs (merged names win, D-W-100):
M3-21-fcm-adapter.md(the exact FCM payload),M3-22-push-registration.md,M5-01-device-key-android.md(Kotlin core module),M5-03-android-layout.md(channels and the kind mapping),M5-04-clientkit-nse.md(the content rules of the NSE, which this service mirrors; the DPoP contract vector; the native strings generator), the brief of M4-31 (client metrics). - Code on the base branch:
apps/client/android,packages/device_key/android/devicekeycore,apps/client/lib/platform/.
This ticket touches no table, no action, no event and no server configuration key.
Fixed requirements that apply¶
Owner's fixed build rules (apply verbatim):
- No MVP. New from scratch and directly in the final state; stages are only the order.
- Strictly modular: clear module and service boundaries, dependencies only through defined interfaces, no cross access.
- Documentation in code: every public module, class, function and interface with a docstring/doc comment (purpose, parameters, return value, errors, example where useful); comments explain the why, not the what. API documentation is generated from code (OpenAPI from the routes, dartdoc, Python API docs in MkDocs).
- Documentation in the repo: README per module/service, ADR for every architecture decision, CHANGELOG per release, keep the documentation site current. A change without matching documentation is not done.
- Quality gates (SonarQube, linters, type checking, security scanners, test coverage) block the merge.
Rules for every brief:
- Test first: write the failing test, run it and keep the red output for the report, then make it green, then clean up.
- No en dash and no em dash in any file you write; the
text-rulespre-commit hook rejects them, and the ARB test of M4-06 rejects them in interface texts. Use a hyphen or rephrase. - White label: examples and test data use
example.comand the people Alex, Sam and Robin; no company, host or personal names, no paths under a home directory. Placeholder texts come frompackages/l10n, generated into Android string resources (Design note 5). - Limits and timeouts only through named configuration values (
client.push.fetch_timeoutfromAppConfig, D-W-115); code comments name the key, never the number. - Specification first: a change to behaviour updates
docs/spec/in the same change. The core contract is not edited by this ticket.
Product requirements implemented here:
- No Flutter engine starts for a push; the service and the worker are Kotlin only (15 10.2, ADR 0056).
- A notification appears for every message of the kinds of 13 2 at once with the placeholder, so FCM does not deprioritize the app (13 5.2); the same notification is replaced (tag = thread ID, 13 5.2).
- The service authenticates only with the preview token and a DPoP proof of the
dpopkey; it never refreshes a token (13 6.4, 08 7.4). - With
notify.preview_mode = noneno request;404shows "Message not available"; no network or a timeout keeps the placeholder (13 5.2, 6.4). k = "sync"removes notifications of theclearedthreads and sets the badge (13 8); quiet notifications (quiet = 1) post silently.- The preview token is stored only in the Kotlin side's Keystore-encrypted store, never in Dart storage or logs; no content is stored outside the encrypted database.
- Without a Firebase project in the build configuration there is no push on Android and no FCM dependency initialised (15 10.2).
Scope¶
Create in apps/client/android/:
app/src/main/kotlin/.../push/PushMessagingService.kt(no product name in code names, 02 6 "Places that carry the product name"),PreviewWorker.kt(expeditedCoroutineWorker),PreviewClient.kt(OkHttp orHttpURLConnection, no cookies, no cache),DPoPProof.kt(same profile as M5-04 Design note 2, signed through thedevicekeycoremodule of M5-01),PushStore.kt(Keystore-encrypted values: preview token, registration ID, server address, preview mode, counters),NotificationPoster.kt(channels, tag, group per thread, silent posting, replacement,synchandling) (Design notes 1 to 4).- Pigeon interface
apps/client/pigeons/push_android.dartand the generated code (token stream to Dart, store calls from Dart). src/test(JVM) andsrc/androidTest(instrumented) tests.
Create elsewhere:
apps/client/lib/platform/push_android.dart: permission request (API 33 and later), token handling,registerPush, handing the preview token and registration to Kotlin, re-registration ononNewToken, unregistration on logout, tap routing,preview_modemirroring, counters to client metrics (the same rules aspush_apple.dartof M5-04 Design note 5; share a common Dart interfacePushPlatformif it reduces duplication).
Change:
- Gradle files: the Firebase messaging dependency and the Google services plugin applied only when the build configuration provides a Firebase configuration file;
androidx.workdependency; license gate green. AndroidManifest.xml: the service withcom.google.firebase.MESSAGING_EVENT,POST_NOTIFICATIONS, no other new permission.- The native strings generator of M5-04 (Design note 6 there): an Android output
values/strings.xmlandvalues-de/strings.xmlfor the placeholder keys and the content format strings, checked bymake codegen-check. apps/client/platform-tests.jsonandroidentry: the instrumented push tests.- Docs:
apps/client/README.md(Android push, Firebase configuration in the build configuration, how to run the tests),docs/spec/15-client-architecture.md10.2 (where the preview token lives on Android, the worker, the sync handling), rootCHANGELOG.mdunder[Unreleased],docs/DECISIONS.mdsection 10 rowsD-M5-05-1toD-M5-05-6.
Out of scope¶
- UnifiedPush (later, 13 5.4); notification actions; the iOS side (M5-04); sharing (M5-06).
- Device E2E against the Compose stack (M5-11).
- Changes to the core contract.
Boundaries¶
- Work in a dedicated git worktree on the branch
feat/M5-05-android-push; one writer per worktree. - Conventional Commits with the component as scope (for example
feat(client-android): ...) and a DCO sign-off on every commit (git commit -s); squash into meaningful commits before the merge (docs/quality/push-discipline.md). - Never push to
main, never tag and never create remote repositories; the only remote steps are the branch push and the pull request ofAGENTS.md. Never use a real Firebase project, never send a real FCM message. - Do not modify files outside the scope above, except the CHANGELOG, README and
docs/DECISIONS.mdentries named in this brief. - Follow the host rules supplied by the launcher (ports, machine rules).
- Tests bind only
127.0.0.1with port 0 (the emulator reaches the host's fake server throughadb reverse tcp:<port> tcp:<port>, so the app connects to its own127.0.0.1) and make no other network call; FCM messages are constructed in the test (RemoteMessage.Builder) and passed to the service directly. Temporary directories only viamktemp -d, removed at the end. Stop only processes you started, by PID or emulator serial. - Never read or print secrets; never log a preview token, an FCM token, a DPoP proof or a preview response.
- A JDK and the Android SDK must be available; the instrumented tests run on an attached emulator or device (
make apps-platform) and in theapps-androidCI job; the report names where they ran. - Never lower a threshold, disable a gate, delete a test, raise
repo.max_file_kbor extendquality/licenses/allowed.txt; never exclude hand-written code from a lint, format, coverage or doc gate. - No permission logic in the client (15 1 principle 1).
- If the core contract or
docs/api/lacks something this ticket needs, stop withVerdict: BLOCKED <reason>instead of building a deviation (P-11). - This brief was written before milestone M1 closed (D-W-100, extended to M5 and M6 by D-W-120). Where the merged code of an earlier ticket names a package, type, function, file, provider or make target differently from this brief, follow the code and list each difference in the report; where it behaves differently from the specification, stop with
Verdict: BLOCKED <reason>.
Design notes¶
-
Receiving (13 5.2, 6.2; Ruling for docs/DECISIONS.md).
onMessageReceived: readdata;vmust be"1", otherwise ignore; fork = "sync"go to Design note 3; else post the placeholder at once throughNotificationPoster(channel from the kind mapping of M5-03, tag =t, group =t, silent whenquiet = "1", small icon from the design tokens' monochrome mark, content intent to/t/{t}?m={m}), then, unlesspreview_modeisnoneor no preview token is stored for instancei, enqueue a unique expeditedPreviewWorkerkeyed bynidwithOutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST. The service returns within the FCM budget; no network inonMessageReceived. -
Preview worker (13 6.4; 15 10.2). The worker input carries
i,t,nidandmof the payload and the receipt time;onMessageReceivedrecordsnidas the newest one of threadtinPushStoreunder the per-thread lock ofNotificationPosterthat also guards posting. The worker reads server address, preview token and nonce fromPushStore, builds the DPoP proof withdevicekeycore, callsgetPushPreviewwithnidandmessage_id(themof the payload) with timeoutclient.push.fetch_timeout(the value written by the app at start), only to anhttpsserver address with the platform's certificate and hostname validation and without following redirects (a redirect counts as a failure, so the preview token and the proof never travel in cleartext or to another host), retries once onuse_dpop_nonce, and then, inside the same per-thread lock, checks that itsnidis still the newest one of the thread and that the time since receipt is withinclient.push.fetch_timeout(a run deferred as non-expedited work keeps the placeholder) and only then posts the replacement with the same tag:contentas the NSE does (M5-04 Design note 4),404"Message not available", failure keeps the placeholder (no retry by WorkManager after the timeout, because a late text would replace a notification the person may have read). It records the display duration from receipt and whether the placeholder stayed inPushStorecounters. -
Sync messages (13 8).
k = "sync": cancel the notifications whose tag is one ofcleared(comma-separated thread IDs); set the badge number on the summary notification where the launcher supports it (setNumber); post nothing new. Asyncmessage never starts the worker. -
Token and store (13 13; Ruling for docs/DECISIONS.md).
onNewTokenandFirebaseMessaging.getInstance().tokenfeed the Pigeon token stream; Dart registers after permission (API 33 and later:POST_NOTIFICATIONSrequested at the same moment rule as iOS, M5-04 Design note 5) and hands the returned preview token and registration ID to Kotlin, which stores them encrypted with a Keystore key (not the device keys; a dedicated AES-GCM key without user authentication).PushStorekeeps no content and no names. Without a Firebase configuration in the build,FirebaseAppis never initialised, the service is disabled in the manifest merge, and the app shows in settings that push is unavailable on this instance. -
Strings (13 6.1 keys reused for Android). The generator of M5-04 writes
strings.xmlper language for the placeholder keys, "Message not available" and the format strings "%1$s · %2$s" and "%1$s: %2$s" (positional, so translators can reorder). -
Rulings for
docs/DECISIONS.md(D-M5-05-<n>): immediate placeholder and unique worker pernid(note 1); no retry after the timeout (note 2); sync handling and badge (note 3); the push store key and contents (note 4); Firebase only with a build configuration (note 4); permission timing on API 33 (note 4).
Tests (write them first)¶
Kotlin JVM (src/test): DPoPProofTest (the contract vector of M5-04 Design note 2, signing input equal; verification with the public key), PayloadParsingTest (every kind, quiet, unknown v, sync with three cleared IDs), KindToChannelTest (the mapping of M5-03), PushStoreTest (no content keys; round trip with an in-memory key).
Instrumented (src/androidTest, emulator or device, fake server on the host loopback, served over TLS with a per-run test CA that only the debug build trusts through its network security configuration, because the worker refuses plain http):
placeholderPostedImmediately: a constructedRemoteMessagepassed toonMessageReceivedposts a notification with the placeholder in the expected channel with tagtbefore any network call (the fake server has recorded nothing yet).textReplacesPlaceholderWhenReachable: the worker runs (WorkManager test driver), the fake server answers the preview, the notification with tagtshows the text.placeholderStaysWithoutNetwork(PLAN row): the fake server is stopped; after the timeout of the test configuration the placeholder stays; the counter records a placeholder.notAvailableOn404,previewModeNoneFetchesNothing,quietPostsSilently,syncClearsThreads.previewRequestCarriesNidAndMessageId(the fake server records both parameters),stalePreviewNeverReplacesNewer(two pushes for one thread, the older worker finishes last and posts nothing),lateRunKeepsPlaceholder(a run started afterclient.push.fetch_timeoutposts nothing),plainHttpOrRedirectSendsNoToken(anhttpaddress and a redirecting fake both end as failure without the token leaving).neverRefreshes: the fake server records no call to the refresh, login or session endpoints during all push tests.noFirebaseConfigNoService: a build variant without the Firebase configuration has the service disabled (merged manifest check).
Dart (apps/client/test/): push_android_test.dart with a fake Pigeon host: registration only after permission, token change re-registers, logout unregisters and clears the Kotlin store, tap routing, preview_mode mirroring, counters submitted.
Commands that must pass, from the repository root:
cd apps/client && flutter analyze --fatal-infos && flutter test --coveragecd apps/client && flutter build apk --debug(with and without a Firebase configuration file from a test fixture)cd apps/client/android && ./gradlew :app:testDebugUnitTest- with an attached emulator or device:
make apps-platform; otherwise it prints its SKIP line make codegen-check,make arch,make lint,make fmt-check,make coverage,make licensesmake check
Acceptance criteria¶
- Instrumented test with a fake FCM payload: the placeholder appears at once and is replaced by the text when the fake server is reachable; without network the placeholder stays.
-
nonefetches nothing;404shows "Message not available"; quiet posts silently;syncclears threads. - No Flutter engine starts for a push; the service never refreshes a token; the preview token lives only in the Kotlin store.
- The app builds and runs without a Firebase configuration, without push.
- README, 15 10.2, the generated strings, CHANGELOG and the rulings are updated.
-
make checkis green.
Definition of done¶
- Code and tests as specified; KDoc on every public Kotlin declaration and doc comments on every public Dart symbol; comments explain why.
apps/client/README.mdupdated in English.- Rulings of Design note 6 recorded in
docs/DECISIONS.mdsection 10. - Root
CHANGELOG.mdentry under 'Unreleased'. - An ADR only if you take an architecture decision beyond this brief (MADR 4 as in
docs/adr/0000-template-madr-4.md, headings and text in English, next free number,make adr-index). make checkgreen.- An independent code review over the diff, then the red team gate (
docs/quality/red-team-gate.md, verdictJAorNEIN); onNEINfix with a test per finding and have the delta checked again. Record both verdicts. - Land the change as
AGENTS.md("How work lands onmain") describes: a pull request from this branch, squash-merged only when review, red team andmake check,ci-okand the review threads are green; thengit worktree remove.
Report¶
Write to {{REPORT_PATH}}:
- First line exactly
Verdict: DONEorVerdict: BLOCKED <reason>. - Commits (SHA and subject), changed files with line counts, test results with counts (the red run before the change and the green run after), the summary of
make check. - Review and red team verdicts and the findings fixed.
- Where the instrumented tests ran, the measured time from
onMessageReceivedto the placeholder and to the text. - What is not proven (real FCM delivery and deprioritization, Doze on a physical device; M5-11 covers devices against the Compose stack).
- Follow-ups that could not be done in this scope, each with its reason, every difference to the names of this brief (D-W-100), and spec gaps found.
Then write {{DONE_PATH}} with exit=0 sha=<sha> or exit=1 <reason>.