feat(android): add BigPicture and Inbox ongoing notification payloads - #324
Conversation
## What is this? Android ongoing notifications could post two layouts: a progress bar and a block of big text. Two more now exist, `AndroidOngoingNotification.BigPicture` for a notification whose status is an image, and `AndroidOngoingNotification.Inbox` for one that is a short list of lines. Both work locally and through the server renderer, and both take action children. ## How does it work? `bigPicture` and `inbox` are two new payload kinds the native side parses and maps onto `BigPictureStyle` and `InboxStyle`, keeping the ongoing flags, the channel, the deep links and the action children unchanged. A picture accepts the same sources as every other notification image; a bundled drawable is handed to Android as a resource icon on Android 12 and later, and rendered to a bitmap below that, where the style needs one. Decoded artwork is downscaled on a bounds pass before it is ever held at full size, to 1024 px for a picture and 256 px for an icon. `showPictureWhenCollapsed` and `pictureContentDescription` are read on Android 12 and later; the picture itself posts on every supported version. ## Why is this useful? Delivery, fitness, and travel apps get the two most requested notification shapes without reaching for a second library, and the rejected templates (conversations, media controls, bubbles, full-screen intents) are now named in the docs with what to use instead.
|
Adversarial review, focused on Android API-level fragmentation and crash safety from API 24 to 36+. Nothing here is blocking. I checked every new platform call against the API reference rather than the numbers in #320, and the version split is right: Correctness1. val drawable = ContextCompat.getDrawable(context, resId) ?: return null
val width = drawable.intrinsicWidthOn API 31+ a drawable Fix: try 2. A vector drawable
3. Requirement 17 is neither tested nor recorded as device-verified. #320 requirement 17 asks that a 4. The "below" branch is proved by exactly one test, and nothing runs at the minSdk floor.
5. Requirements 10 and 19 assert sizes on the resolver, not on the posted notification, and 19's specific case is not the one that was written. Requirement 19 asks for "a test that a 2000 px base64 6.
7.
Spec deviation worth recording
#320's decision table and requirement 14 both say Nits
Checked and looks correct
Generated by Claude Code |
On API 24-30 a `bigPicture` pointing at a bundled drawable has to reach the builder as pixels, and rendering it through `ContextCompat.getDrawable` decoded the resource at full resolution before the cap was applied — the allocation the cap exists to avoid, on the oldest devices. Read the bounds and let the decoder subsample instead, and keep the canvas path only for drawables that are not bitmaps, which report no bounds. Covers the branch through a posted notification on API 30 as well, which had no coverage before: the existing bundled-drawable tests use a vector and run on the default SDK.
…s dp size `targetSize` never upscales, which is right for pixels and wrong for a drawable that has none: a 24dp vector reached the pre-31 picture path as 72 px at 3x and the launcher stretched that across a 416dp slot, while the same asset on API 31+ goes over as a resource and draws crisp. The fallback was supposed to make the old releases show *something*, not something soft, so a drawable with no pixels of its own is now drawn at the whole budget and only a bitmap-backed one is merely capped.
The two-pass decode needed the bytes twice, so the preloaded path slurped the whole content URI into a ByteArray first: a photo's file size sitting in the heap next to the bitmap it produced, which is a regression against the single `decodeStream` this replaced. Open the URI twice instead, once for the bounds and once for the pixels, and let the shadowed content resolver hand out a fresh stream per open the way a real provider does.
…scaled `inSampleSize` leaves the decoded long edge just under twice the cap, so scaling to 1024 decoded ~2047 px and then held both bitmaps until the collector got round to it. The sampled bitmap never leaves the resolver, so recycle it as soon as the scaled copy exists and say so in the KDoc, which was overselling the old peak.
… decode `bigLargeIcon` beats `hideLargeIconWhenExpanded`, which is the documented precedence, but the branch was taken on the *field* being present rather than on artwork having resolved. A `bigLargeIcon` whose bytes could not be decoded therefore skipped both branches and left the collapsed thumbnail showing in the expanded view, quietly ignoring an explicit request to hide it. Resolve first, then decide on what actually came back.
The warning compared `base64.length` against a byte budget, so it fired at roughly 192 KB of picture and then quoted a character count as if it were bytes. Convert the four-thirds before comparing and name the image size in the message, and have the test straddle the real budget from both sides so a character count can no longer pass for a byte count.
…th a promotion asked for Nothing ran below API 31 except one inline-base64 picture, so the deprecated no-channel builder and the whole inbox path were only ever exercised on devices nobody tests on. Posting both layouts at API 24 covers the branch that discards the channel, and it caught that the test's own channel setup calls an API 26 method that does not exist at the floor. Also posts a bigPicture with requestPromotedOngoing and a standard fallback, which is the combination issue #320 requires to come back ok rather than throw. The promotable-characteristics half of that requirement still needs a device: Robolectric stops at API 35 and the platform only answers that question from 36.
|
Went through it item by item. All ten are in, one commit each. Two of them needed a different test than the one suggested, and the reason is worth writing down. 1. Full-resolution resource decode — 2. Vector posted at its dp size — 3. Requirement 17 — 4. minSdk floor — 5. Caps through a posted notification — 6. Preloaded buffering — 7. Scale peak — The API 31 deviation — agreed, it needed recording. The PR body gained a "Where this disagrees with #320" section, and the four places in #320 that said 32 now say 31 with a comment on the issue, so nobody "fixes" it back. Nits
Validation: JS suites, Kotlin unit tests, |
Issue #320 asks for a 2000 px thumbnail on a progress update to come back at 256 px, and for the picture budget to be asserted on what gets posted; both were only asserted through the resolver. They cannot be asserted at the default mdpi test density: the platform rescales a built notification into its own 416dp by 284dp and 48dp boxes, which is smaller than either budget, so the framework would have produced the expected number on its own. Running those two tests at a density whose box is larger than the budget makes the posted size Voltra's.
…ource "Bundled drawables are passed to Android as resources and resized by the system" is only true from Android 12 for a picture: below that the platform's picture API takes bitmaps and Voltra renders the drawable itself. The PR body had the split right and the page flattened it.
Issue #320 requires that nothing here asks for a full-screen intent, and the only guard offered for the Expo plugin was a grep over the sources. Asserting the list the plugin writes is the same guard with teeth: it fails on anything new in that list, whether or not it is spelled in a way a grep would have caught, and it says in the test why the entry must not appear.
3ffeafe to
72f9bdd
Compare
Device verification (API 35,
|
Device verification round 2 — the pre-Android-12 branch (API 24,
|
Device re-check after merge with main (API 35, b668428)Head
No failures. Nothing was skipped. Notes:
|
What is this?
Android ongoing notifications supported two layouts: a progress bar and a block of big text. This adds the two shapes that were missing for apps that show status rather than send messages —
AndroidOngoingNotification.BigPicture, for a notification whose status is an image, andAndroidOngoingNotification.Inbox, for one that is a short list of lines. Both can be started locally, updated, and rendered on a server for a push payload, and both take the same action children as the existing layouts.The templates that look like ongoing status updates but are not —
MessagingStyle,MediaStyle, bubbles, full-screen intent — stay out, and the docs now say why and what to use instead.Closes #320
How does it work?
bigPictureandinboxare two new payload kinds, selected by thekindfield the server renderer already writes. The native side parses them into the same record the other kinds use, so the ongoing flags, the channel, the deep link,subText,shortCriticalText, the chronometer, and the action children behave exactly as they do today. What changes is the style:BigPictureStyleorInboxStyleinstead ofProgressStyleorBigTextStyle, with no notification category attached.A picture accepts the same sources as every other notification image:
assetNamefor something bundled or preloaded, or inlinebase64. A drawable resource is handed to Android as a resourceIconon Android 12 and later, which is what the platform asks for there; below that the style needs aBitmap, so the resource is decoded to one instead of the layout quietly showing nothing. That decode never inflates more than it will keep: a bitmap resource is subsampled by the decoder itself rather than drawn at full resolution and shrunk afterwards, a drawable with no pixels of its own is drawn at the whole budget so a 24dp vector does not arrive as a smudge, and a preloaded file is opened twice — once for its bounds, once for its pixels — instead of being buffered. Decoded images are capped to 1024 px on the long edge for a picture and 256 px for an icon, including thelargeIconthumbnail thatprogressandbigTextalso use.showPictureWhenCollapsedandpictureContentDescriptionare Android 12 APIs and are read only there; the picture itself posts on every supported version, becausebigPicture(Bitmap)is 24. An inbox posts six lines and warns if a hand-written payload carries more, since the framework renders six and truncates nothing.textdefaults to the first line when omitted.Image decoding lives in one new class,
VoltraNotificationImageResolver, which the manager also uses for its existing icon lookups; the style mapping stays private to the manager next to the progress and big-text code.Why is this useful?
Delivery, ride, fitness, and travel apps get the two notification shapes they ask for most, from the library that already owns the ongoing lifecycle, without adding a second notification dependency for one screen. A picture notification is the difference between "Parcel delivered" and a photo of where it was left; six lines is the difference between "3 stops remaining" and a route the user can read without opening the app.
The caps and the version split are also the part every app would otherwise get wrong: decoding a full-resolution camera photo to post a 416dp picture, gating the whole layout to Android 12 and losing older devices, or passing decoded bitmaps where Android 12 asks for a resource icon.
Where this disagrees with #320
The decision table in #320 puts
BigPictureStyle.setContentDescriptionat API 32 and asks for it to be ignored below 32. It is API 31, as isEXTRA_PICTURE_CONTENT_DESCRIPTION, so it sits behind the same>= Sguard asshowBigPictureWhenCollapsedand is asserted at@Config(sdk = [31]). A 32 guard would have silently dropped the screen-reader label for every Android 12.0 user. Requirement 14 in #320 is corrected to say 31.Requirement 17 has the same shape from the other side: the part a unit test can reach is now asserted — a
bigPicturewithrequestPromotedOngoing: trueandfallbackBehavior: 'standard'comes backok: true, and the promotable flag staysnullbelow API 36 — while the Android 16 answer itself stays on the device list below, because Robolectric in this repository stops at API 35.One more thing worth recording, and not this PR's business:
VoltraModulewraps the post inrunBlocking, so the decode runs on the NativeModules thread even though it never blocks the JS bridge. That was one decode here before and is up to three two-pass decodes plus rescales now. Worth moving off that thread before anyone drives picture updates at a high rate.Needs a real device
Unit tests cover the payload parsing, the resolver's sampling maths, and which framework extras end up filled; a launcher's drawing is still out of reach. Worth checking in the example app's Android ongoing notification screen:
Update: an API 35 run (Android 15, 420 dpi) has now covered the picture sources, the caps, the inbox lines, the thumbnail props and the cannot-promote path — see this comment. What is left below is the pre-31 bitmap fallback, Android 16 promotion, TalkBack, and remote push.
bigPicture(Bitmap)fallback) — see this comment: a bitmap resource, a vector drawable, preloaded and inline pictures all draw correctly at 420 dpi with the 1024 px budget applied, andhideLargeIconWhenExpanded,bigLargeIcon, and an expanded thumbnail that fails to decode each read as intended. API 25–30 share this branch but were not run.base64and through a preloadedassetName, expanded and collapsed, includingshowPictureWhenCollapsedon Android 12+.pictureContentDescriptionon Android 12+.bigPictureorinboxnotification asked to promote should stay a standard ongoing notification,getAndroidOngoingNotificationStatus().hasPromotableCharacteristicsshould come back false, and switching the samenotificationIdfromprogresstobigPictureshould drop out of the promoted presentation. This is the half of Android ongoing notifications: add BigPicture and Inbox payload kinds; reject Messaging, Media, Bubbles and full-screen intent #320 requirement 17 that Robolectric cannot reach.bigPicturepayload usingassetName, and that abase64picture does not fit an FCM data message.