Skip to content

M5-05 Android push service without the Flutter engine

No Date Ruling Reason
D-M5-05-0 2026-10-25 Built on main after M5-03 and M5-04 landed; the changelog entry and these rulings are fragments. Differences to the brief's names, following the merged code: the Kotlin package is com.example.client.push (the runner's namespace of D-M5-03-0), with PushReceiver (the handling of one message, without Firebase types, which the service calls and the tests drive), PreviewJob (the worker's work), PushRuntime (what the service, the worker and the Pigeon host share), PushAndroidHost, PushTokens, PushTaps and PreviewRenderer beside the brief's files; PushMessagingService and the token source PushBackend lie in src/firebase/kotlin (and a stand-in in src/nofirebase/kotlin, D-M5-05-5); the preview request carries kind as on iOS (D-M5-04-0); the strings generator stays tools/catalog/apple_strings.py (make apple-strings) and now also writes res/values/strings.xml and res/values-de/strings.xml with the lower-case keys; the Pigeon output is lib/src/generated/push_android_api.g.dart and push/PushAndroidApi.g.kt, whose Flutter API Pigeon writes with trailing blanks, so the whitespace hook skips *.g.kt and make codegen-check still holds the file byte for byte; the Dart interface PushPlatform (lib/platform/push_platform.dart) now holds what ApplePush and AndroidPush share (PushAccount, PushRegistrations with a provider parameter, the tap path and the counters), the port is AppPorts.push and the provider pushPlatformProvider (lib/notifications/push_provider.dart); the small icon is a monochrome speech bubble (res/drawable/ic_notification.xml), because the design tokens have no monochrome mark yet; the instrumented tests run their TLS fake server on the device's own loopback with a CA made per run that only the test's connection trusts (as the phone suite's fake server of D-M5-03-7), so neither adb reverse nor a network security configuration of the debug build is needed, and no build trusts a test CA. D-W-100, D-W-120.
D-M5-05-1 2026-10-25 Immediate placeholder and one worker per nid: onMessageReceived posts the placeholder before anything else, under the lock of the thread's tag together with recording the nid as the newest of the tag, and schedules a unique expedited PreviewWorker named after the nid (ExistingWorkPolicy.KEEP, OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST, no network constraint, so a device without network fails fast); it makes no network call. Nothing is fetched with notify.preview_mode = none, before the app wrote the settings, without a stored registration of the instance i, or for a kind without a preview (security, admin, unknown kinds), whose placeholder is final. A hint without a thread is tagged kind:<kind>, so it replaces the earlier one of its kind. Below API 31 the expedited worker runs as a foreground service with a silent notification in the channel of the kind. 13 5.2, 6.2; 15 10.2; brief Design note 1.
D-M5-05-2 2026-10-25 No retry after the timeout: the worker fetches within what is left of client.push.fetch_timeout since receipt (connect and read timeouts), then posts under the tag's lock only when its nid is still the newest and the time since receipt is within the timeout; a stale run posts and counts nothing, a late run (also one deferred as normal work) and every failure keep the placeholder and count it, and the worker always ends with success, so WorkManager never repeats it. The client only calls https addresses, does not follow redirects, caches nothing, sends no cookie, reads at most a fixed body limit (MAX_BODY of PreviewClient), and repeats once on 401 use_dpop_nonce with a new nonce; the last nonce per server origin is kept in the store. The system sheds updates of an app's notifications above its enqueue rate (a few per second) without telling the app, so in a burst of pushes some replacements stay unshown and their placeholders stay; the instrumented tests therefore read replacements from what the poster hands to the system (PushRuntime.posted) and the drawer only for first posts and cancels. 13 6.4; 08 7.3, 7.4; brief Design note 2.
D-M5-05-3 2026-10-25 Sync and badge: k = "sync" cancels the notifications whose tag is one of cleared and forgets their newest nid, then re-posts every other shown push notification with setNumber(badge) and setOnlyAlertOnce, so launchers that show a number show the new one; it posts nothing new and never starts the worker. Hints carry their badge in setNumber as well. Launchers differ in what they show (a dot, the sum or the number), which only device tests of M5-11 can judge. 13 8; brief Design note 3.
D-M5-05-4 2026-10-25 The push store: the runner's private preferences file push_store, every value sealed with AES-256-GCM under a Keystore key of its own alias push_store (no user authentication, because the service runs on a locked device; not one of the device keys), the value's key name bound as additional data; key names carrying an ID or an address use its SHA-256. It holds per instance the server address, the registration ID and the preview token, and globally the signed-in instance, notify.preview_mode, client.push.fetch_timeout in milliseconds, the last DPoP-Nonce per server, the newest nid per thread tag and the display counters; no content and no names. A value that does not open counts as absent. The Pigeon host writes the record of registerPush at once; Dart keeps only the FCM token last registered (in the instance's secrets). Every end of the account (forget) deletes the instance's entries, its settings, nonces and newest nids, and cancels the shown push notifications. The backup rules of D-M5-03-1 exclude the file. 13 6.4, 13; 15 6.3, 9; brief Design note 4 and requirement 16.
D-M5-05-5 2026-10-25 Firebase only with a build configuration (ADR 0072): with android/app/google-services.json (from the operator's build branding, ignored by git) the build applies the Google services plugin 4.5.0, links firebase-messaging 26.0.1, compiles src/firebase/kotlin, enables the service through the manifest placeholder pushEnabled and locks its dependencies in gradle-firebase.lockfile; without it the build compiles src/nofirebase/kotlin, whose disabled stand-in service never runs, links no Firebase or Play services code, and isAvailable is false, so the app registers nothing (the settings then show push as unavailable when that entry point exists). firebase_messaging_auto_init_enabled and firebase_analytics_collection_enabled are false: the token is requested only when the app registers after the permission. getToken and onNewToken, deprecated in firebase-messaging 26 in favour of register and onRegistered, are still used for the token the registration needs, and the service publishes both callbacks. android/fixtures/google-services.json is a fake configuration for the tests and the build check. 15 10.2; brief requirement 17 and Design note 4; docs/quality/licenses.md.
D-M5-05-6 2026-10-25 Permission timing on API 33 and later: the same moment as on iOS (D-M5-04-5): never before the login, asked once per run when the person first opens a thread, through the Activity Result API of the main activity; with the permission given earlier, every start registers. Below API 33 the app registers when its notifications are not turned off. The manifest declares only POST_NOTIFICATIONS as a new permission (the library manifests bring their own). 13 13; brief Design note 4.