Skip to content

feat(android): expose ongoing notification presentation options - #327

Open
V3RON wants to merge 12 commits into
mainfrom
feat/android-ongoing-notification-presentation-options
Open

V3RON wants to merge 12 commits into
mainfrom
feat/android-ongoing-notification-presentation-options

Conversation

@V3RON

@V3RON V3RON commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

What is this?

Android ongoing notifications can now be presented the way the platform allows. An app that shows a
ride, a delivery or a workout used to be able to choose only the channel, the small icon, the deep
link and whether to ask for promoted presentation; everything else about how the notification behaves
in the shade and on the lock screen was fixed by Voltra.

Starting, updating and upserting an ongoing notification now also accept visibility, color,
category, timeoutMs, localOnly, group, sortKey and
allowSystemGeneratedContextualActions. The content accepts a publicVersion lock-screen copy, and
showWhen next to the existing when and chronometer (a countdown is #325's chronometer: 'countDown'). An update can
also alert once, instead of every update after the first being silent by construction.

Closes #317.

How does it work?

Every field went through one test, recorded in ADR 0010: if a server that renders payloads but knows
nothing about the app's policy could reasonably send it on every update, it is payload; if losing it
on an update would be a bug, it is an option. publicVersion is the pair that shows the split
working — the lock-screen line is text and travels with the rest of the text, while visibility is
the app's privacy policy and is stored with the notification, so the copy can change per update
without the visibility ever having to be re-sent.

Stored options need to tell three things apart, so AndroidOngoingNotificationOption is a three-case
sealed type read from the bridge with hasKey and isNull, and merged into the record with
mergedWith. The pre-existing ?: merge could not express clearing, which is why the presentation
options merge on the record's own type; smallIcon and its neighbours keep their old two-state
behaviour, and the docs say so instead of implying the whole options object behaves the same way.
alert is neither merged nor stored: it describes the post it arrives with.

Validation is split by what each layer can know. JavaScript checks shape before any native call, for
local calls and for the options object of a push alike, in a module holding one validator per key of
the options type — a new option cannot be added to the public type without saying how it is validated,
which is the trap the issue found, where a new option had to be added in four places and was silently
dropped when it was not. Native checks the one thing JavaScript cannot: whether a color string is a
static color rather than a dynamic theme token. That rejects the promise rather than throwing out of
the TurboModule method, which also fixes the pre-existing channelId case that had the same shape. An
unknown category or visibility is ignored with a warning, because the alternative is a push written
for a newer release failing on the device that has to show it today.

Coverage is a Robolectric suite that reads back the notification from the NotificationManager
shadow at API 24, 26, 28, 29, 31 and 35 — nothing is mocked, so what is asserted is what
Notification.Builder produced — plus renderer tests for the new payload keys and their validation,
and tests for the option validator and the three-state bridge shaping. The example's ongoing
notification screen now drives every new option, and the push envelope it generates carries them, so
the same checks can be run through a real remote push.

What still needs a device to prove. Robolectric cannot show what a real system does with these
fields, and its pinned version cannot reach SDK 36, so the following is what an E2E pass has to
confirm, mostly on the testing-grounds screen:

  • With visibility: 'private' and a publicVersion, the secured lock screen shows only the public
    copy, and the private content appears once unlocked; 'secret' hides the notification there.
  • color and category visibly reach the heads-up and shade presentation.
  • timeoutMs really removes a notification that stops being updated, whether the delete intent fires
    when the system does it (unlike a swipe), and that start then needs stop before that id works.
  • localOnly: true stops the notification reaching a Wear companion or Android Auto, and does not
    when left off.
  • group and sortKey bundle and order the notifications in the shade.
  • alert: true makes one update audibly alert on a high-importance channel, and the next one is
    silent again.
  • showWhen: false hides an age that would never change while keeping the timestamp for ordering, and
    chronometer: 'countDown' counts down.
  • On an API 36 device, getAndroidOngoingNotificationStatus().hasPromotableCharacteristics is still
    true with the new fields set, and promotion still happens. This is the one that would be a real
    regression rather than a missing feature.
  • The remote path: a push whose options carry the new keys reaches the device through the registered
    background task, since the example handler now forwards them.

Why is this useful?

Apps can build the notification they mean instead of the one Voltra picked: private content with a
copy of the app's own choosing on the lock screen, an accent color that matches the brand, a category
that places it with the right kind of notification, and a timeout so a ride that stopped being
updated does not sit in the shade claiming to be in progress forever. Re-alerting on one update is
what "your driver is waiting outside" needs from an ongoing notification that is otherwise silent.

The layering is the other half of the value. Updates cannot quietly drop an app's privacy settings, a
server can vary lock-screen copy per update without learning anything about the app, and adding the
next option costs one validator entry in the client and one read and one merge line natively rather
than four edits and a silent drop. What was refused is written down with its reason, in the docs and
in ADR 0010, so the next person does not have to relitigate why there is no badge count.

Merge with main (after #325 and #326)

#325 landed overlapping pieces of this PR, so the merge commit reconciles them:

Ongoing notifications can now set lock-screen visibility with a public
version for the private copy, an accent color, a notification category, a
timeout that retires a stale notification, local-only delivery, and a
group with a sort key. A single update can alert again with `alert`, and a
timestamp or chronometer can be kept for ordering without being shown with
`showWhen` and `chronometerCountDown`.

Lifetime fields are options: they are stored with the record, survive every
update, and are cleared by name with an explicit null. The lock-screen copy
is payload, so an update replaces it like any other text. ADR 0008 records
that split and the fields this issue refused.

Client-side validation lives in one module keyed by every option, so an
option cannot be added to the public type without saying how it is
validated, and an unresolvable color rejects the promise instead of
escaping the TurboModule method.
@V3RON
V3RON force-pushed the feat/android-ongoing-notification-presentation-options branch from 16b3f29 to f57f274 Compare September 22, 2026 09:28

V3RON commented Sep 22, 2026

Copy link
Copy Markdown
Contributor Author

Adversarial review

Verdict. On the primary question — API-level safety across 24…36 — this holds up: I walked every platform call this PR adds or touches against its true @since, and found no ungated call and no crash risk on API 24/25. setTimeoutAfter (26) and setAllowSystemGeneratedContextualActions (29) are gated in-method with plain SDK_INT checks inside inlined let blocks, setChronometerCountDown really is API 24 (= minSdk, confirmed packages/android-client/android/build.gradle:38,51), and everything else (setVisibility, setColor, setPublicVersion, setCategory @21; setLocalOnly, setGroup, setSortKey @20; setShowWhen @17) is below the floor. Every Notification.* symbol referenced is a compile-time static final String/int, so it inlines and is safe on old ART; no non-constant static field or new-API instance getter is referenced outside a gate; categories are literal strings from a Set<String> rather than Notification.CATEGORY_WORKOUT symbols; no java.time. So: nothing blocking on API levels. What I did find is one real behavioural bug in the alert lifetime, one spec gap where the JS wrapper makes upsert strictly less capable than the Kotlin it is tested against, and a low-end test-coverage gap that means the 24/25 story is argued rather than proven.

Blocking

alert becomes permanently sticky for autoUpdate hook users — violates requirement 8's "per post, never persisted".
packages/android-client/src/ongoing-notification/api.ts:114-115 and packages/android-client/src/ongoing-notification/api.ts:147

const updateOptions = { ...optionsRef.current, ...options }
lastUpdateOptionsRef.current = updateOptions
void update(lastUpdateOptionsRef.current)

Call update({ alert: true }) once and alert: true is written into lastUpdateOptionsRef. The autoUpdate effect replays that exact object on every content change, and update writes the replayed object back into the ref, so the flag never clears. The result is a notification that makes a sound and vibrates on every subsequent content change — the opposite of "the next update is quiet again unless it asks", which is what issue requirement 8 mandates and what website/docs/v2/android/development/managing-ongoing-notifications.md promises. All API levels; worse the more often the app re-renders. The Kotlin side is innocent here — VoltraNotificationManager.kt:236 correctly treats alert as per-post and never stores it; the leak is purely in the hook.

Minimal fix: strip the per-post key before caching, e.g. in update, const { alert: _alert, ...replayable } = updateOptions; lastUpdateOptionsRef.current = replayable. Add a hook-level test that asserts the replayed options carry no alert.

Correctness

1. alert and three-state clearing are unreachable through upsertAndroidOngoingNotification, and the Robolectric test hides it.
packages/android-client/src/ongoing-notification/api.ts:168-176, packages/android-client/src/ongoing-notification/options.ts:110-129

upsertAndroidOngoingNotification takes StartAndroidOngoingNotificationOptions and runs it through getStartAndroidOngoingNotificationOptions, which throws on alert (options.ts:113-117) and validates presentation options with allowNull: false (options.ts:119). So from JS:

  • upsert(input, { alert: true }) → throws option "alert" is only available when updating.
  • upsert(input, { color: null }) → throws option "color" must be a non-empty string (it does not clear, and it does not silently keep — it fails the call).

Requirement 13 says the upsert update branch must "merge with the three states and honour alert", and requirement 14 says a push options object must "behave exactly like local options". The Kotlin does exactly that (VoltraNotificationManager.kt:246-268 delegates to updateOngoingNotification, which honours both) — but the only JS entry point that reaches it refuses. What makes this easy to miss is VoltraNotificationManagerTest.kt:239-261 (upsertStoresOptionsWhenStartingAndMergesThemWhenUpdating): it constructs AndroidOngoingNotificationOptions(... presentation = …(color = cleared()), alert = true) and calls the manager directly, bypassing options.ts entirely. The test passes and proves the requirement — for a code path no caller of the public API can take.

Fix: have upsertAndroidOngoingNotification accept StartAndroidOngoingNotificationOptions & Clearable<AndroidOngoingNotificationPresentationOptions> & { alert?: boolean } and validate with allowNull: true, or state in the docs and ADR that upsert is start-shaped only and drop requirement 13's alert clause. Right now remote-ongoing-notifications.md documents the alert half of this as deliberate but says nothing about clearing being impossible from a push, which is the half that silently differs from local update.

2. resolveOngoingNotificationMutation catches only IllegalArgumentException; the sibling throw it was written to fix still escapes.
packages/android-client/android/src/main/java/voltra/VoltraModule.kt:176-186, thrown at packages/android-client/android/src/main/java/voltra/VoltraNotificationManager.kt:400

The new wrapper is the right shape and correctly converts the color and channelId IllegalArgumentExceptions into promise rejections. But postNotification also throws IllegalStateException("Promoted ongoing notifications are unavailable on this device/app configuration.") when requestPromotedOngoing is set with fallbackBehavior: 'error', and that still propagates out of runBlocking and out of the TurboModule method unhandled. Requirement 17's "no TurboModule method MUST let an exception escape for a validation failure" is only half satisfied. Pre-existing, but this PR rewrote precisely these three methods, so it is the moment to fix it: catch IllegalStateException too (separate error code, e.g. VOLTRA_NOTIFICATION_UNAVAILABLE), or catch Exception and map by type.

3. Low-end Robolectric coverage is thin enough that the 24/25 claims are untested, not just unproven.
packages/android-client/android/src/test/java/voltra/VoltraNotificationManagerTest.kt — packages/android-client/android/src/test/resources/robolectric.properties pins sdk=35, so every test without @Config runs at 35 only. Exactly two tests run below that: :138 (@Config(sdk = [24]), category literals) and :167 (@Config(sdk = [24]), timeout persisted-but-not-applied). The PR body claims "API 24, 26, 28, 29, 31 and 35"; 24 covers two assertions.

Never executed at 24 or 25:

  • the whole public-version path (VoltraNotificationManager.kt:440-458) — which is the one place the PR constructs a second Notification.Builder and goes down newBuilder's deprecated Builder(Context) branch (:383-388). This is the highest-risk new code on old devices and it has zero coverage there.
  • setChronometerCountDown (VoltraNotificationManager.kt:511) — sitting exactly on the minSdk boundary. If that @since were 25 or 26 rather than 24, every 7.0 device would take a NoSuchMethodError. It is 24, so the code is right, but a one-line @Config(sdk = [24]) is the difference between "we checked the docs" and "CI proves it".
  • setVisibility, setColor, setLocalOnly, setGroup, setSortKey, setShowWhen, and the legacy-record decode.

Fix: add @Config(sdk = [24]) variants for publicVersionBecomesTheLockScreenCopyOfTheNotification (guarding the getChannelId() assertion as :88 already does), chronometerCountDownCountsTheChronometerDown, and one combined test that starts with visibility + color + localOnly + group + sortKey at 24 and reads them back. That is roughly 20 lines and converts the entire "safe on 24" argument into a regression test. I'd also add @Config(sdk = [25]) to one of them — 25 is in the supported range and is currently untouched by any test.

Nits

  • packages/android-client/src/ongoing-notification/__tests__/options.node.test.ts does not typecheck. Running tsc over it with the repo's strict settings gives 11 real errors (:38, :44, :47, :54, :67, :70, :76, :132, :135 are TS2322; :82 is TS2353 for alert on start options). CI is green only because tsconfig.base.json's inherited "exclude": ["**/__tests__/*"] keeps them out of [JS] Types validation and ts-jest's default diagnostics don't fail the run. The values are deliberately invalid — that is the point of the tests — so mark them as such with @ts-expect-error or as unknown as …, the way src/dynamic-widget/**/*.node.test.ts already does. Otherwise the next person to turn on ts-jest diagnostics inherits a red suite.
  • packages/android/src/ongoing-notification/renderer.ts:263 validates publicVersion.text with assertOptionalNonEmptyString, so text: '' throws. The issue only asks for "publicVersion.text not a string when present". Harmless, but it is stricter than spec and inconsistent with subText/text, which use assertOptionalString.
  • packages/android-client/android/src/main/java/voltra/VoltraNotificationManager.kt:456: the public version runs the full applyTimestampFields, so it inherits setUsesChronometer and setChronometerCountDown as well as setWhen/setShowWhen. Requirement 2 lists only setWhen/setShowWhen. The effect is a lock-screen copy showing a running timer with no content explaining it. Either narrow the helper for the public version or record the deviation in the ADR.
  • Requirement 18 asks for one test per rejected field; noPostUsesAFieldThisIssueRejects (:284-296) covers colorized, group summary, auto-cancel, number, badge icon type, timeout, content info and settings text, but nothing for setSound/setVibrate/setLights/setDefaults/setTicker. Cheap to add, and they are on the MUST-NEVER list.

Checked and looks correct

  • API gating, every new call: setTimeoutAfter API 26 gated with Build.VERSION_CODES.O and the value still persisted below (AndroidOngoingNotificationPresentation.kt:86-91, matching the issue exactly); setAllowSystemGeneratedContextualActions API 29 gated with .Q (:99-103); setChronometerCountDown API 24 = minSdk, correctly ungated; setVisibility/setColor/setPublicVersion/setCategory API 21, setLocalOnly/setGroup/setSortKey API 20, setShowWhen API 17 — all below 24, correctly ungated. minSdk 24 / compileSdk 36 confirmed at build.gradle:38,48,51.
  • No VerifyError/NoSuchMethodError hazard: both gated calls sit in the same method as their SDK_INT check inside inlined let blocks; no new type appears in a field type or method signature that would need resolving on 24; applyAndroidOngoingNotificationPresentation and buildPublicVersion are reachable on 24 and only touch API ≤ 24 types.
  • Constants: VISIBILITY_*, CATEGORY_PROGRESS, FLAG_LOCAL_ONLY, FLAG_GROUP_SUMMARY, BADGE_ICON_NONE, EXTRA_COLORIZED, EXTRA_CHRONOMETER_COUNT_DOWN are all compile-time static final and inline — safe on 24 even where the field was added later.
  • Categories are literal strings validated against supportedAndroidOngoingNotificationCategories, never Notification.CATEGORY_WORKOUT symbols, so navigation/workout/stopwatch/location_sharing reach the builder verbatim on 24 and are ignored by the system there. An unrecognised value is logged and dropped, falling back to the payload category — a hand-written push cannot crash or smuggle a category in.
  • No java.time anywhere in the new Kotlin, so the absent coreLibraryDesugaringEnabled is fine.
  • Test-side API usage is itself gated: getChannelId() behind SDK_INT >= O at :88, getTimeoutAfter()/badgeIconType only in tests running at 26+/default 35, getAllowSystemGeneratedContextualActions() only under @Config(sdk = [29]).
  • The MUST-NEVER-call list holds: grep over the diff finds no setColorized, setGroupSummary, setAutoCancel, setNumber, setBadgeIconType, setSettingsText, setTicker, setContentInfo, setCustomContentView, setSound, setVibrate, setLights or setDefaults, and the API 24/25 PRIORITY_DEFAULT is untouched.
  • Three-state bridge decoding is genuinely three-state: hasKey → isNull → getType mismatch → value (VoltraRNBridgeExtensions.kt:56-80), with a wrong-typed value logged and degraded to Unset rather than thrown, and alert deliberately collapsed to two states via booleanValueOrNull.
  • Native color validation runs inside mergedWith (AndroidOngoingNotificationPresentation.kt:45-47), i.e. before anything is posted or saved, surfaces as a promise rejection, and anUnresolvableColorIsRejectedByNameAndPostsNothing asserts the tray stayed empty. Good catch on ordering.
  • Backwards compatibility (requirement 15) is tested against a real 2.3.1-shaped record with no presentation key, and every new record field is nullable with a default, so ignoreUnknownKeys decoding holds.
  • v: 1 is kept and new payload keys are omitted when unset — JSON.stringify drops the undefineds, and packages/android/test/ongoing-notification.test.js asserts 'showWhen' in payload === false rather than just undefined, which is the right assertion for a wire format.
  • showWhen default preserves the old behaviour exactly (setShowWhen(payload.showWhen ?: hasTimestamp) is equivalent to the old if/else when the prop is absent), and chronometerCountDown is additionally guarded natively by chronometer == true so a hand-written remote payload cannot reach the setter alone.
  • Alert semantics at the manager level: start posts keep FLAG_ONLY_ALERT_ONCE clear, updates set it, alert: true clears it for exactly one post, and it is never written to the record.
  • Merge semantics for the pre-existing options (smallIcon, deepLinkUrl, requestPromotedOngoing, fallbackBehavior) are untouched and the docs say so explicitly instead of implying the whole options object is three-state.
  • Docs, ADR 0008 (+ README table, and 0008 is the correct next free number), and the changeset with a minor bump for both packages are all present.

Generated by Claude Code

…cation hook replays

The hook cached every update option for the autoUpdate effect, including
`alert`, so one call to `update({ alert: true })` made every later content
change alert again. `alert` describes the single post it arrives with and is
never stored natively, so it is dropped before caching. A react-test-renderer
test drives the real hook and pins that the replayed update carries no alert
while the other options still replay.
The native upsert update branch already merges the three states and honours
`alert`, and the Kotlin test drove it directly, but the JS upsert entry point
took start-shaped options and refused both, so no public caller could reach
that behavior and a push could not clear what an earlier push stored.
Upsert options are now the start shape plus what only an update can use:
clearable presentation options and `alert`, validated the way an update
validates them. The example background task forwards them from a push, the
testing screen drives both toggles through upsert, and the docs no longer
promise that a remote upsert cannot clear or re-alert.
…ication is unavailable

resolveOngoingNotificationMutation only mapped IllegalArgumentException, so
the IllegalStateException thrown for requestPromotedOngoing combined with
fallbackBehavior 'error' still escaped the TurboModule method with a generic
error. Map it to its own VOLTRA_NOTIFICATION_UNAVAILABLE rejection, so every
refusal an ongoing notification call can hit arrives as a coded promise
rejection.
…24 and 25

The public-version path is the only place a second Notification.Builder is
built and it takes the deprecated Builder(Context) branch on old releases;
setChronometerCountDown sits exactly on minSdk; and visibility, color,
localOnly, group and sortKey were never read back below API 26. Each now has
a pinned @config run, including API 25, which no test used to touch.
…ests type-invalid

The validators reject these values at runtime, but the test file itself did not
type-check: the rejected literals contradicted the public option types, twelve
errors in total. Route them through one small helper that says out loud what
they are - values the types forbid, handed to the guard on purpose - the same
way the dynamic-widget tests cast their malformed specs.
…er text field

The content's own text, subText and bigText fields accept an empty string - it
is how a caller blanks a line - but publicVersion.text alone rejected it, even
though the type allows it and the native side handles it. A public version
still needs a usable title; its text now follows the same rule as its siblings.
… too

The ADR places publicVersion under 'it is text', but buildPublicVersion also
mirrors when, showWhen and the chronometer with its count-down onto the
lock-screen copy. The behavior is deliberate - a timestamp reveals no content,
and dropping the timer the real notification started would be stranger than
showing it - but it is a deviation from the rule, so it now says so where the
rule lives.
… ticker as refused

The rejected-fields test checked every refusal except the channel-owned alert
fields and the ticker. Sound and vibration live on fields the modern SDK stops
naming, so the assertions read them the way the runtime keeps them; lights and
defaults are read as delivered by the platform builder, ticker as its plain
field. Now the test covers the full refused list from the ADR.
@V3RON
V3RON force-pushed the feat/android-ongoing-notification-presentation-options branch from 57729ca to 764942e Compare September 22, 2026 13:04
@V3RON

V3RON commented Sep 22, 2026

Copy link
Copy Markdown
Contributor Author

All review items are addressed, one commit each:

Blocking — sticky alert in the autoUpdate hook — b4150005. The hook now caches the options with alert stripped, so a replayed auto-update is silent again unless it asks, and a regression test drives the whole chain through the native mock (it fails against the old code).

Correctness 1 — upsert could not clear options or alert — 86f89c98. upsertAndroidOngoingNotification now takes the clearable three-state presentation options plus per-post alert (getUpsertAndroidOngoingNotificationOptions), matching what the native upsert already honored and the Kotlin test already proved. The example background task and testing screen forward them, and the docs' "remote upsert always updates silently" claim is corrected.

Correctness 2 — IllegalStateException escaped the TurboModule method — 77f85abc. resolveOngoingNotificationMutation maps it to a VOLTRA_NOTIFICATION_UNAVAILABLE promise rejection alongside the existing IllegalArgumentException mapping, so every ongoing-notification refusal arrives as a coded rejection.

Correctness 3 — low-end behavior was argued, not proven — 01b673ab. Pinned @Config runs added: the public-version build on API 24, chronometerCountDown on 24+25 (the setter sits exactly on minSdk), and a combined visibility+color+localOnly+group+sortKey read-back on 24+25 — API 25 was previously untouched.

Nit 1 — options.node.test.ts did not typecheck — d88cbd2b. The twelve deliberately-malformed values now go through one invalid() helper, in the spirit of the dynamic-widget tests' casts; the file is clean under tsc (verified by re-running the baseline: 12 errors before, 0 after).

Nit 2 — publicVersion.text used assertOptionalNonEmptyString — c4d9e586. It now uses assertOptionalString like text/subText/bigText, with a test that an empty public text passes through; the title stays required.

Nit 3 — public version mirroring the chronometer was undocumented — f8320e90. ADR 0008 now records the deviation: the public copy mirrors the whole timestamp block (when, showWhen, chronometer, count-down) beside the two text lines.

Nit 4 — rejected-fields test missed the alert-family fields — 764942ed. noPostUsesAFieldThisIssueRejects now also pins sound, vibration, lights, defaults and ticker as untouched (sound/vibration read via reflection, since the modern SDK stopped naming those fields).

Also: the lockfile in the first commit had been produced by a mismatched pnpm (9.7.0 vs the pinned 11.5.0), which dragged recently-published transitive entries into the lockfile and tripped the minimumReleaseAge supply-chain policy during CI install. It was regenerated with the pinned pnpm (b4150005 carries it now); pnpm install --frozen-lockfile under 11.5.0 is a no-op.

Validation: JS lint/format/typecheck green; android-client jest 28/28; android node tests 24/24; Robolectric 33/33 incl. the new 24/25 matrix; strict Android lint clean; changeset status clean. The two ios-client jest failures seen locally are an artifact of this worktree sitting inside another checkout (an undeclared react-test-renderer require resolving to a second React copy — all 21 pass when pinned to one copy), and CI's JS Test is green.

@V3RON — PTAL.

@V3RON

V3RON commented Sep 22, 2026

Copy link
Copy Markdown
Contributor Author

📱 On-device validation (emulator, API 35)

Ran the PR end-to-end on a bootful Android 15 (API 35) emulator using the example app's ongoing-notification testing screen (expo run:android --no-bundler + manual Metro). Ground truth via adb shell dumpsys notification --noredact.

All checks passed:

Behavior Result
Start posts ongoing notification ✅ FLAG_ONGOING_EVENT, 2 actions, progress 32/100, subText, audible first post
Start options (color, category, groupKey, sortKey, onlyForThisDevice, timeout) ✅ all present in the record (color=0xff1e88e5, category=navigation, group rides, sortKey, LOCAL_ONLY, timeout PT2M)
Update replaces payload ✅
alert → setOnlyAlertOnce ✅ per-post: alert:false → FLAG_ONLY_ALERT_ONCE present (silent), alert:true → flag absent (noisy)
Timeout auto-cancel ✅ record removed after 120 s via deleteIntent path
Dismissed-record rejection ✅ after timeout cancel, upsert rejects with reason dismissed until a fresh start (designed behavior)
Clear-on-update ✅ omitting options reverts everything (color→0, LOCAL_ONLY gone, category/group/sortKey/timeout cleared)
visibility: 'private' + publicVersion ✅ main vis=PRIVATE, publicVersion=Notification(... vis=PUBLIC) with exact title/text
Chronometer + ADR 0008 mirror ✅ chronometerCountDown / showWhen mirrored into publicVersion; live count-down ticking on keyguard
Upsert (action=updated) with alert:true ✅ status "Upserted … via updated", no silent flag
Stop ✅ notification removed (0 records), UI back to Idle

Notes (not PR issues):

  • Visual redaction of private content on the keyguard didn't trigger on this AOSP emulator image even with sensitive-content hiding forced via secure settings — SystemUI/environment behavior. The platform contract is correct: publicVersion is built and attached with vis=PUBLIC (dumpsys + Robolectric coverage of setPublicVersion).
  • usesChronometer is not named in dumpsys flags, but the android.chronometerCountDown extra (only written when chronometer === true) and the live ticking countdown on screen confirm it; the Robolectric suite asserts the getter directly.

Combined with CI green on 764942e (jest 28/28, Robolectric 33/33 incl. API 24/25 matrix, node tests 24/24, strict Kotlin lint clean), the PR is validated both in CI and on a real emulator.

…-options

Resolves the conflicts with the Live Updates work (#325) and the Metric
layout (#326), which landed overlapping pieces of this PR:

- A countdown is expressed with #325's `chronometer: 'countDown'`; the
  separate `chronometerCountDown` prop is dropped. The payload key is
  unchanged, so the Kotlin side and remote payloads are unaffected, and
  `showWhen` layers on top of #325's timestamp handling.
- Errors go through #325's VoltraNotificationException path. An
  unresolvable `color` now rejects with VOLTRA_NOTIFICATION_INVALID_OPTIONS;
  the promoted-unavailable case is #325's VOLTRA_NOTIFICATION_NOT_PROMOTABLE.
- `showWhen` and `publicVersion` move into normalizeCommonDisplayFields and
  the Metric payload, so every kind carries them.
- This PR's manager tests move to VoltraNotificationManagerPresentationTest,
  since #325 took VoltraNotificationManagerTest for its per-API suite.
- The field-placement ADR is renumbered to 0010: main took 0008 and #332
  holds 0009.
# Conflicts:
#	docs/adr/README.md
…notification-presentation-options

# Conflicts:
#	example/screens/testing-grounds/AndroidOngoingNotificationTestingScreen.tsx
#	packages/android-client/android/src/main/java/voltra/VoltraNotificationManager.kt
#	packages/android/test/ongoing-notification.test.js

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant