Skip to content

feat(android): add BigPicture and Inbox ongoing notification payloads - #324

Merged
V3RON merged 12 commits into
mainfrom
feat/android-ongoing-notification-big-picture-inbox
Sep 29, 2026
Merged

V3RON merged 12 commits into
mainfrom
feat/android-ongoing-notification-big-picture-inbox

Conversation

@V3RON

@V3RON V3RON commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

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, and AndroidOngoingNotification.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?

bigPicture and inbox are two new payload kinds, selected by the kind field 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: BigPictureStyle or InboxStyle instead of ProgressStyle or BigTextStyle, with no notification category attached.

A picture accepts the same sources as every other notification image: assetName for something bundled or preloaded, or inline base64. A drawable resource is handed to Android as a resource Icon on Android 12 and later, which is what the platform asks for there; below that the style needs a Bitmap, 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 the largeIcon thumbnail that progress and bigText also use. showPictureWhenCollapsed and pictureContentDescription are Android 12 APIs and are read only there; the picture itself posts on every supported version, because bigPicture(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. text defaults 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.setContentDescription at API 32 and asks for it to be ignored below 32. It is API 31, as is EXTRA_PICTURE_CONTENT_DESCRIPTION, so it sits behind the same >= S guard as showBigPictureWhenCollapsed and 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 bigPicture with requestPromotedOngoing: true and fallbackBehavior: 'standard' comes back ok: true, and the promotable flag stays null below 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: VoltraModule wraps the post in runBlocking, 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.

  • Done at the floor on API 24 (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, and hideLargeIconWhenExpanded, bigLargeIcon, and an expanded thumbnail that fails to decode each read as intended. API 25–30 share this branch but were not run.
  • A real photo through base64 and through a preloaded assetName, expanded and collapsed, including showPictureWhenCollapsed on Android 12+.
  • TalkBack announcing pictureContentDescription on Android 12+.
  • Live Update promotion on Android 16: a bigPicture or inbox notification asked to promote should stay a standard ongoing notification, getAndroidOngoingNotificationStatus().hasPromotableCharacteristics should come back false, and switching the same notificationId from progress to bigPicture should 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.
  • A remote push with a bigPicture payload using assetName, and that a base64 picture does not fit an FCM data message.

## 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.

V3RON commented Sep 22, 2026

Copy link
Copy Markdown
Contributor Author

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: bigPicture(Icon) (31) and showBigPictureWhenCollapsed (31) are behind >= S, bigPicture(Bitmap) (16), bigLargeIcon(Icon) (23, including the null as Icon? overload the null-ambiguity would otherwise send to the Bitmap method), InboxStyle/addLine (16) and Icon.createWithResource/createWithBitmap (23) are all at or below minSdk 24, and nothing new hides above minSdk in a lambda, inline function, field type or parameter type — so no NoSuchMethodError/VerifyError on 24–30. The sampling maths is correct, including the 800x600 no-upscale case and the 2048/1024 boundary. What is left is one real memory risk and one real visual regression, both confined to the API 24–30 drawable-resource picture path, which happens to be the one branch with zero test coverage.

Correctness

1. decodeDrawable decodes a resource at full resolution before capping — API 24–30 only, and it is the OOM the rest of this class exists to prevent.
packages/android-client/android/src/main/java/voltra/ongoingnotification/VoltraNotificationImageResolver.kt:162-180

val drawable = ContextCompat.getDrawable(context, resId) ?: return null
val width = drawable.intrinsicWidth

On API 31+ a drawable picture never reaches here — resolveIcon hands over Icon.createWithResource and the framework does the work. On API 24–30 applyBigPictureStyle takes the else branch and calls resolveBitmap, which routes a drawable straight into ContextCompat.getDrawable. That fully decodes the resource into the app heap (a bundled 4000x3000 PNG is ~48 MB of ARGB_8888 at matching density) before targetSize clamps anything. The two-pass inJustDecodeBounds + inSampleSize in decodeBytes is skipped entirely for this source kind. Symptom: OutOfMemoryError out of postNotification on exactly the oldest, lowest-RAM devices, where it is least recoverable — and unlike the base64 path this one is not wrapped in a per-source catch that degrades to "no picture", it propagates (the try here catches Exception, and OutOfMemoryError is an Error).

Fix: try BitmapFactory.decodeResource(context.resources, resId, options) with the same two-pass bounds/inSampleSize first, and fall back to ContextCompat.getDrawable + Canvas only when that returns null (vectors, layer lists, state lists).

2. A vector drawable picture renders at its intrinsic size on API 24–30, so it is posted blurry.
packages/android-client/android/src/main/java/voltra/ongoingnotification/VoltraNotificationImageResolver.kt:168-178, with targetSize at :224-238

targetSize returns the input unchanged when the long edge is already under the cap, which is right for photos and wrong for vectors. A 24dp vector is 72 px at 3x, so decodeDrawable produces a 72x72 bitmap that the launcher then stretches across a 416dp x 284dp picture slot. The same asset on API 31+ goes through Icon.createWithResource and draws crisply, so the identical payload looks fine on 12+ and mushy on 7–11 — which is the opposite of the fallback's stated purpose ("so the layout doesn't quietly show nothing"). Fix: in decodeDrawable, when the drawable is not a BitmapDrawable (no natural pixel size), render at maxLongEdgePx instead of at the intrinsic size.

3. Requirement 17 is neither tested nor recorded as device-verified.
packages/android-client/android/src/test/java/voltra/VoltraNotificationManagerOngoingStyleTest.kt (no occurrence of requestPromotedOngoing)

#320 requirement 17 asks that a bigPicture start with requestPromotedOngoing: true and fallbackBehavior: 'standard' return ok: true and that hasPromotableCharacteristics be false at SDK 36 — "If Robolectric cannot run at SDK 36 in this repository, this requirement is verified on a device and recorded in the PR." The PR body lists it under "Needs a real device" as something worth checking, not as checked. The behaviour is correct by inspection (postNotification only throws when fallbackBehavior resolves to ERROR, and requestPromotionIfPossible is a no-op without the capability), but nothing asserts it. A default-SDK test asserting result.ok with requestPromotedOngoing = true costs four lines and closes the requirement.

4. The "below" branch is proved by exactly one test, and nothing runs at the minSdk floor.
packages/android-client/android/src/test/java/voltra/VoltraNotificationManagerOngoingStyleTest.kt:177, VoltraNotificationImageResolverTest.kt (no @Config at all)

@Config(sdk = [30]) covers only the inline-base64 picture. Never run below 31: the drawable→bitmap branch of finding 1 (posts a bundled drawable as a resource icon at :73 and renders a bundled drawable when the platform only takes a bitmap at VoltraNotificationImageResolverTest.kt:98 both run at the default sdk=35), bigLargeIcon, hideLargeIconWhenExpanded, and the entire inbox path. Nothing at all runs at 24/25, where newBuilder drops to the deprecated Notification.Builder(Context) and discards the channel. Suggested minimum: @Config(sdk = [24]) on one inbox post and one bigPicture post asserting EXTRA_TEMPLATE and FLAG_ONGOING_EVENT (no bitmaps needed, so Robolectric's legacy graphics is fine — GraphicsMode.NATIVE only applies from 26), plus a @Config(sdk = [30]) drawable-picture test asserting EXTRA_PICTURE is a Bitmap and EXTRA_PICTURE_ICON is null.

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.
packages/android-client/android/src/test/java/voltra/VoltraNotificationManagerOngoingStyleTest.kt:117

Requirement 19 asks for "a test that a 2000 px base64 largeIcon on progress posts at 256 px"; the test posts inlineField("largeIcon", 40, 40) and asserts 40. Requirement 10's 3000x2000 → ≤1024 and 800x600 → unchanged are likewise only asserted through resolveIcon in VoltraNotificationImageResolverTest, never through a posted notification. The maths is covered, so this is a coverage gap rather than a defect, but the cheap version is to swap the two source sizes in the two existing manager tests.

6. decodePreloaded now holds the whole preloaded file in the heap.
packages/android-client/android/src/main/java/voltra/ongoingnotification/VoltraNotificationImageResolver.kt:99-115

readFully buffers the entire content-URI stream into a ByteArray purely so decodeBytes can decode it twice. For a preloaded photo that is the file size on top of the decoded bitmap, and it is a regression against the BitmapFactory.decodeStream(stream) it replaced. Opening the content URI twice — once with inJustDecodeBounds, once with the computed inSampleSize — is the standard pattern and keeps the bytes out of the heap.

7. scaleDown doubles the peak rather than keeping it "near the target".
packages/android-client/android/src/main/java/voltra/ongoingnotification/VoltraNotificationImageResolver.kt:187-198

inSampleSize leaves the long edge just under 2x the cap, so a picture can be decoded at ~2047 px (~11 MB) and then createScaledBitmap allocates the 1024 px copy (~4 MB) while the source is still referenced. Nothing leaks, but the KDoc's "keeps the peak allocation near the target" is off by about 4x, and on API 24–25 those pixels are still in the Java heap. Recycling the sampled source after the scale (it is never handed out) would make the claim true.

Spec deviation worth recording

setContentDescription is API 31, not 32 — the PR is right and #320 is wrong.
packages/android-client/android/src/main/java/voltra/VoltraNotificationManager.kt:519-525

#320's decision table and requirement 14 both say setContentDescription is API 32 and ask for a @Config(sdk = [32]) assertion with it "ignored below". The platform reference says Notification.BigPictureStyle.setContentDescription "Added in API level 31" and EXTRA_PICTURE_CONTENT_DESCRIPTION "Added in API level 31". Putting it behind the same >= S guard as showBigPictureWhenCollapsed and asserting it at @Config(sdk = [31]) (VoltraNotificationManagerOngoingStyleTest.kt:165) is correct, and a >= 32 guard would have silently dropped the content description for every Android 12.0 user. But the deviation is unexplained in the PR body, and requirement 14 as written now contradicts a passing test — worth a line in the description and an edit to #320 so the next reader does not "fix" it back.

Nits

  • packages/android/src/ongoing-notification/renderer.ts:262 — base64.length is characters, not bytes; base64 is 4/3 of the payload, so the 256 KB warning actually fires at ~192 KB of image, and the message reads "carries N bytes of base64" for a character count. Either compute Math.ceil(base64.length * 3 / 4) or rename the constant to ..._CHARS.
  • packages/android-client/android/src/main/java/voltra/VoltraNotificationManager.kt:533-544 — the when takes the payload.bigLargeIcon != null branch before checking hideLargeIconWhenExpanded, so a bigLargeIcon that is present but fails to decode silently leaves the collapsed largeIcon showing in the expanded view, ignoring an explicit hideLargeIconWhenExpanded: true. Falling through to bigLargeIcon(null as Icon?) when the resolve returns null would honour both.
  • website/docs/v2/android/development/managing-ongoing-notifications.md — "Bundled drawables are passed to Android as resources and resized by the system" is only true on API 31+ for picture; below that Voltra renders the drawable itself. The PR body states the split correctly, the docs flatten it.
  • Android ongoing notifications: add BigPicture and Inbox payload kinds; reject Messaging, Media, Bubbles and full-screen intent #320 requirement 31 allows a grep test for USE_FULL_SCREEN_INTENT in the Expo plugin; none was added. The requirement itself is satisfied (see below), so this is only the missing regression guard.
  • Pre-existing, but this PR amplifies it: packages/android-client/android/src/main/java/voltra/VoltraModule.kt:175 wraps the post in runBlocking. The decode itself is correctly off the JS thread (withContext(Dispatchers.Default)), so nothing here blocks the bridge — but runBlocking holds the NativeModules thread for the whole post, and this PR takes that from one decode to as many as three two-pass decodes plus rescales. Worth knowing before someone drives picture updates at a high rate.

Checked and looks correct

  • Every new platform API against its true minimum: BigPictureStyle() 16, bigPicture(Bitmap) 16, bigPicture(Icon) 31 (gated), bigLargeIcon(Bitmap) 16, bigLargeIcon(Icon) 23 (safe at 24, and bigLargeIcon(null as Icon?) resolves to the Icon overload, not the Bitmap one), setSummaryText 16, showBigPictureWhenCollapsed 31 (gated), setContentDescription 31 (gated), InboxStyle()/addLine 16, Icon.createWithResource/createWithBitmap 23, Builder.setLargeIcon(Icon) 23. No ungated new API inside a lambda, inline function, field type or parameter type in VoltraNotificationImageResolver or the style mapping, so no NoSuchMethodError/NoClassDefFoundError/VerifyError at class verification on 24–30. No ImageDecoder anywhere — BitmapFactory throughout.
  • Every Notification.EXTRA_* touched is a compile-time String constant, so the API-31 ones referenced from the @Config(sdk = [30]) test are inlined and safe; no non-constant field or instance getter is used.
  • Sampling maths: computeInSampleSize(3000, 2000, 1024) == 2, (800, 600, 1024) == 1 with targetSize leaving 800x600 untouched (no upscale), no off-by-one at the exact 2048/1024 and 1024/1024 boundaries, zero and negative inputs guarded, loop cannot overflow. targetSize preserves the ratio within a pixel and clamps to ≥1.
  • Requirement 18: a malformed base64 picture is caught inside decodeBase64/decodeBytes, the notification posts without a picture, the failure is logged, and nothing escapes postNotification — asserted by test.
  • Live Update interaction: requestPromotedOngoing: true with fallbackBehavior: 'standard' cannot throw for these kinds, and postNotification builds a fresh Builder on every post, so a progress → bigPicture switch on the same notificationId cannot carry a stale promoted flag (the template swap is tested at :216).
  • Requirement 31: grep over packages/ for MessagingStyle, MediaStyle, DecoratedMediaCustomViewStyle, BubbleMetadata, setShortcutId, setFullScreenIntent and USE_FULL_SCREEN_INTENT returns nothing, in Kotlin, TS and the Expo plugin.
  • Six-line inbox cap: rejected at render in JS (normalizeInboxLines), dropped with a log in Kotlin (applyInboxStyle, :552-570) — both tested, including the seven-line hand-written payload.
  • text defaults to lines[0]; an explicit text is kept and must be non-empty (assertString rejects '', so requirements 21 and 22 hold for both lines entries and text).
  • bigLargeIcon beats hideLargeIconWhenExpanded; EXTRA_LARGE_ICON_BIG present-but-null vs absent vs non-null — all three states asserted.
  • Category is null for both new kinds, enforced by the exhaustive when over the sealed class (:571-578), so a future kind is a compile error rather than a silent category.
  • v: 1 kept; parser keeps ignoreUnknownKeys (tested with a futureField); an unknown kind still throws and names the kind, matching the documented compatibility caveat, and the remote path is app-owned so it rejects the promise rather than crashing the FCM handler.
  • Byte-identical server/client renderer output for both examples, plus the Action-children case, in packages/android-server/test/index.test.js.
  • Requirement 30 exports present in @use-voltra/android, @use-voltra/android/server and @use-voltra/android-server; AndroidOngoingNotificationPayload is the four-way union.
  • Changeset present with minor bumps for all three packages, user-facing wording.

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.
@V3RON

V3RON commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor Author

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 — 723054ae. decodeDrawable reads bounds and lets decodeResource subsample; the canvas path stays only for drawables that answer no bounds. Added a 3000x2000 PNG under drawable-nodpi, because the existing test resource is a vector — which is exactly why the branch looked covered and wasn't. What the tests can prove is that the branch decodes and caps correctly: both paths end at the same bitmap, so the allocation you flagged is guarded by the sampling now living in the decoder, not by an assertion.

2. Vector posted at its dp size — a241dc75. Drawables with no pixels of their own are drawn at the budget, only bitmap-backed ones are capped. renderSize is split from targetSize so "never upscale pixels" and "fill the slot" are each asserted. The existing 24dp vector is the test: 24x24 before, 1024x1024 now.

3. Requirement 17 — 63b3b489 posts a bigPicture with requestPromotedOngoing: true and fallbackBehavior: 'standard', asserts ok, and asserts the promotable flag is null below 36. The false at 36 is still device-only and the PR body now lists it as a gap rather than as something worth checking: Robolectric 4.14.1 stops at API 35.

4. minSdk floor — 63b3b489 posts both layouts at API 24 and asserts template plus ongoing flag. It found something on the way in: the test class's own @Before calls createNotificationChannel, which is a NoSuchMethodError at 24, so the floor wasn't merely untested, it was unenterable. The API 30 drawable-picture test is in 723054ae next to the branch it covers.

5. Caps through a posted notification — bbee3c8d. Both budgets are now asserted on the posted notification, but the framing in the requirement doesn't survive the framework: Notification.Builder.build() calls reduceImageSizes, which clamps a picture to 416dp x 284dp and a large icon to 48dp — both below Voltra's budgets at the default mdpi test density. Swapping the source sizes at mdpi gives 416 and 48, and the assertion would pass with the cap deleted entirely. The two new tests run at a density whose box is bigger than the budget (4x for the picture, 6.25x for the icon — the second is not a shipping density, it's the cheapest way to make the platform get out of the way), which is the only setting where the number in the extras is Voltra's. The 40px and 32px tests stay: their job is that nothing rescales small artwork.

6. Preloaded buffering — 1d049236. Two opens, readFully gone. The helper moved to registerInputStreamSupplier, which is also the more honest provider: a single registered stream would fail on the second open rather than silently handing back an exhausted one.

7. Scale peak — 4d7a9240. Recycles the sampled source, guarded against createScaledBitmap returning the instance it was given, and the KDoc says "within one doubling of the target on a side" instead of "near the target".

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

  • base64 characters vs bytes — e1f85ea3, and the test now straddles the real budget from both sides so a character count can't pass for a byte count.
  • bigLargeIcon that fails to decode — 7c115519: resolve first, then choose, with a test for the explicit-hide case.
  • docs flattening the drawable split — 46c992e9.
  • requirement 31's grep test — 72f9bddb asserts the exact permission list the plugin writes instead of grepping for one name in the sources. Same guard, but it fails on anything new in the list, and the test says why full-screen intent may not be in it.
  • runBlocking on the NativeModules thread — left as is, but recorded in the PR body. Three two-pass decodes is a real change in shape and it deserves its own issue rather than riding along here.

Validation: JS suites, Kotlin unit tests, lintDebug -PvoltraLintStrict, ktlint, typecheck, lint and format all green.

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.
@V3RON
V3RON force-pushed the feat/android-ongoing-notification-big-picture-inbox branch from 3ffeafe to 72f9bdd Compare September 22, 2026 10:13
@V3RON

V3RON commented Sep 22, 2026

Copy link
Copy Markdown
Contributor Author

Device verification (API 35, Pixel_8_API_35 emulator)

Ran the example app against a scratch playground screen (one button per case, not committed) and drove it from the notification shade. Payload evidence comes from dumpsys notification --noredact, visuals from shade screenshots.

Closed by this run

  • Picture sources. Bundled vector drawable and bundled jpg both go over as pictureIcon=Icon(typ=RESOURCE …), so no pixels sit in the app heap; the vector renders razor-sharp because the framework rasterises it at display size. Preloaded and inline pictures come as bitmaps:
    • preloaded 2400×1600 (FileProvider / content-URI path) → android.picture=Bitmap (1024x683) — exactly the 1024 px budget, source aspect preserved, and the two-pass streaming decode works end to end (hairlines and the diagonal in the test image stay sharp)
    • preloaded 200×150 → Bitmap (200x150), inline base64 640×480 → Bitmap (640x480) — both untouched, no upscaling
    • broken base64 → the notification still posts, with no picture/pictureIcon key at all
    • oversized inline base64 → warns with the decoded size (carries a 262146-byte image, which exceeds 262144 bytes) and still renders
  • Where the cap binds. This device is 420 dpi, so the platform's own picture box is ~1092 px; the observed 1024×683 is Voltra's budget doing the work, not the framework clamping behind our back.
  • Inbox. All six lines render, in order. android.text.lines reports 6 / 3 / 1 for the three cases, and omitting text yields android.text = lines[0], which is what the collapsed row shows. 7 lines and 0 lines are rejected by the renderer before reaching native.
  • Thumbnails. largeIcon shows collapsed; bigLargeIcon replaces it while expanded; hideLargeIconWhenExpanded removes it while expanded only; and a broken bigLargeIcon combined with hideLargeIconWhenExpanded still hides the thumbnail rather than leaving the previous one up.
  • Remaining props. showPictureWhenCollapsed draws the picture inside the collapsed row, pictureContentDescription arrives as android.pictureContentDescription, summaryText renders under the title, and both action buttons render. A tap on a notification body (an accidental one, on a heads-up banner) opened the app on its deepLinkUrl route, which confirms the content intent wiring; the action buttons themselves were only checked visually.
  • Flags. Every bigPicture / inbox record carries flags=ONGOING_EVENT with the expected BigPictureStyle / InboxStyle template and no category.
  • Promotion parity on a device that cannot promote. Capabilities read {apiLevel: 35, supportsPromotedNotifications: false, canRequestPromotedOngoing: false}. requestPromotedOngoing: true with fallbackBehavior: 'standard' posts a standard ongoing notification (ok: true); with 'error' it fails with "Promoted ongoing notifications are unavailable on this device/app configuration." Switching a notificationId from progress to bigPicture updates in place and the template changes accordingly.

Still needing a device

  • API 24–30, the bigPicture(Bitmap) fallback. No pre-31 emulator was running, so the branch that hands the platform a bitmap instead of an Icon is still only covered by unit tests. A Pixel_API_24 AVD exists locally if we want to close it.
  • Android 16+ promotion. Needs API 36 to exercise the Live Update path and to read hasPromotableCharacteristics at all. API 35 confirms the negative side (canRequestPromotedOngoing: false, promotion requested falls back as documented).
  • API 31–33 visuals specifically; this run was API 35 only.

One pre-existing thing noticed, not changed here

VoltraModule.kt builds the status map with putBoolean("hasPromotableCharacteristics", status.hasPromotableCharacteristics ?: false), so below API 36 JS sees false where the manager returns null: "not applicable on this OS" is indistinguishable from "not promotable". That mapping predates this PR (it came in with the Turbo Module migration in #149), so I left it alone — worth a separate issue if the distinction is useful to callers.

@V3RON

V3RON commented Sep 22, 2026

Copy link
Copy Markdown
Contributor Author

Device verification round 2 — the pre-Android-12 branch (API 24, Pixel_API_24)

Booted the API 24 emulator (Android 7.0, 420 dpi) and installed the same debug APK used for the API 35 run — minSdk 24 / arm64-v8a, so no rebuild. This is the branch where the picture has to travel as a Bitmap and the Icon overloads are off the table. All cases posted; no native errors in logcat.

What the device showed

  • Bitmap picture path, caps identical to API 35. android.picture=Bitmap (1024x683) for the 2400×1600 preloaded image — the same budget and the same aspect as on 12+, so the content-URI two-pass decode behaves the same on 7.0. The bundled jpg came out Bitmap (1024x1024): the asset is only 512×512, but it lives in plain res/drawable/, so on a 420 dpi device the framework reports it density-scaled to ~1344 and the budget pulls it back to 1024 — a real case where the cap saves roughly 3 MB of decoded bitmap per post instead of merely clipping an obviously oversized file. Inline base64 → Bitmap (640x480) and small preloaded → Bitmap (200x150), both untouched. Checker edges, hairlines and the diagonal all render sharp.
  • Vector drawable as a picture → Bitmap (1024x225) against a 111×24.61 viewport (aspect 4.51 → 4.55): rasterised once at the full budget with the source aspect kept, and drawn large and crisp in the shade. This is the case that would have come out blurry if it had been decoded at intrinsic size, and it does not.
  • API levels I had guessed wrong, corrected by the platform database. BigPictureStyle.bigLargeIcon(Icon) is listed as API 23, not 31 — only bigPicture(Icon), showBigPictureWhenCollapsed and setContentDescription are 31. So using the Icon overload on all supported versions is correct, which is why lint is quiet and why nothing throws at 24. N's SystemUI honours it: the expanded thumbnail is the bigLargeIcon image, not the collapsed largeIcon.
  • hideLargeIconWhenExpanded works below 31. android.largeIcon.big=null, no thumbnail while expanded, largeIcon still shown while collapsed. Combined with a bigLargeIcon that cannot decode, the thumbnail is still hidden — the review nit holds on this branch too.
  • Props that need 31 are skipped, not fatal. showPictureWhenCollapsed and pictureContentDescription produce no extras and no failure on 7.0, and a broken base64 still posts a picture-less notification.
  • Inbox. android.textLines reports 6 and 1; all six lines render in order; the collapsed line defaults to lines[0]; 7 lines are rejected in JS before reaching native.
  • Capabilities read {apiLevel: 24, supportsPromotedNotifications: false, canRequestPromotedOngoing: false}, and a tap on the notification body fired the deep link on 7.0 as well.

One measurement trap worth noting

grep -c over dumpsys notification overcounts on 7.0 — the ranker and post-frequency sections repeat the package — which briefly looked like five ongoing notifications that refused to cancel. Both the shade and the record list came back empty after the stop, so stopAndroidOngoingNotification is fine; the count was the artifact. Anyone scripting checks against API 24 should read the record list or the shade, not a line count.

Still open after this round: Android 16 promotion (needs API 36), TalkBack reading pictureContentDescription, a remote FCM push carrying a bigPicture, and API 25–30 / 31–33 specifically — API 24 is the floor of each branch, but it is not every level in it.

…inbox

Resolves the conflicts with the Metric layout (#326) and the Live Updates
work (#325). BigPicture and Inbox now go through normalizeCommonDisplayFields
and carry chronometerCountDown, like every other kind, so they accept
chronometer: 'countDown'.
@V3RON

V3RON commented Sep 29, 2026

Copy link
Copy Markdown
Contributor Author

Device re-check after merge with main (API 35, b668428)

Head b668428a (merge of main, 2026-09-28) on Pixel_8_API_35 (420 dpi), debug build of the example app from a clean worktree. Payloads were posted from a temporary uncommitted playground route; evidence is dumpsys notification --noredact plus shade screenshots. Nothing was committed or pushed.

# Case Result Key evidence
1 bigPicture, preloaded 2400x1600 PASS android.picture=Bitmap (1024x683), pictureIcon=null; 1024 long edge, aspect kept
2 bigPicture, bundled drawable PASS android.pictureIcon=Icon(typ=RESOURCE ... id=0x7f07012a), android.picture=null
3 bigPicture, broken base64 PASS ok:true, record posted with BigPictureStyle, no picture / pictureIcon key
4 inbox, 6 lines, no text PASS InboxStyle, android.textLines=CharSequence[] (6), android.text=Line 1; 7 lines throws in JS: "lines" must contain at most 6 lines, got 7
5 largeIcon / bigLargeIcon / hideLargeIconWhenExpanded PASS largeIcon + bigLargeIcon: android.largeIcon and android.largeIcon.big set; collapsed shows the thumbnail, expanded shows the big icon. hideLargeIconWhenExpanded: largeIcon.big=null, thumbnail shown collapsed, gone when expanded. largeIcon only: thumbnail in both states
6 Record flags / template / category PASS flags=ONGOING_EVENT on every bigPicture and inbox record, BigPictureStyle / InboxStyle templates, no category (only the progress record carries category=progress)
7 requestPromotedOngoing:true + fallbackBehavior:'standard'; progress to bigPicture on the same id PASS ok:true, promotion.eligible=false, reasons=[unsupported_api_level], standard notification posted. Switching progress to bigPicture returned action:"updated", kept the same system id (10020) and the single record, and changed the template to BigPictureStyle with pictureIcon=Icon(RESOURCE) (ONLY_ALERT_ONCE added)
8 stop removes the notification PASS action:"stopped", ok:true; the record is gone from dumpsys for each id

No failures. Nothing was skipped.

Notes:

  • Case 5 used one jpg for the picture and both icons, so the shade screenshot alone cannot tell them apart; the android.largeIcon / android.largeIcon.big extras carry that distinction.
  • A first pass of case 5 used a nonexistent drawable name for largeIcon (my playground error, not the PR); rerun with the real drawable for the results above.

@V3RON
V3RON merged commit 9acf6c1 into main Sep 29, 2026
16 checks passed
@V3RON
V3RON deleted the feat/android-ongoing-notification-big-picture-inbox branch September 29, 2026 16:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Android ongoing notifications: add BigPicture and Inbox payload kinds; reject Messaging, Media, Bubbles and full-screen intent

1 participant