Pins the outcome mapping of duckMusicVolume (a higher read-back stays a skip,
an unmoved one is a refusal), the focus request lifecycle (idempotent grant,
retry after denial, abandon only what is held, permanent loss clears the state
but a transient one does not), and the reaction side: focus is requested only
for an ignored write, released at teardown, re-requested by a keep-alive after
a permanent loss, and a late-landing volume write is restored.
The negative assertions are load-bearing: the MediaControl mock is relaxed, so
an accidental focus request on a healthy duck would pass silently.
ColorOS 16 accepts setStreamVolume from a backgrounded app and leaves the
level where it was, so the conversation reaction ducked nothing. duckMusicVolume
now classifies the outcome (Ducked / Unchanged / Skipped) instead of collapsing
everything into a nullable duck, and a level that came back *higher* stays a
skip: that is the user raising the volume between the two reads, not a refusal.
On Unchanged the reaction requests AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK and lets
the framework attenuate the other player. Teardown abandons the focus and, if
the volume ended up below the pre-duck level anyway, restores it (guard for a
device that applies the write asynchronously). The focus request is built by an
injected factory because AudioFocusRequest.Builder is an unmocked stub in plain
JVM unit tests.
lastScanAt was read and written inside the log lambdas, which only run while
a logger is attached (log() checks Logging.hasReceivers first). In a release
build with recording off, the bookkeeping therefore never happened, so the
first delay of every debug recording reported the time since the *previous*
recording ended. Two logs from a support case opened with delay=878453ms and
delay=359983ms, which read as 14 and 6 minutes of suppressed scanning but
were just the gap between recordings.
The bookkeeping moves out of the lambdas, and the clock changes from
currentTimeMillis to elapsedRealtime so a wall-clock correction cannot
fabricate a gap either. That also puts the delay in the same boot-clock
domain as ScanResult.timestampNanos.
On ColorOS 16, setStreamVolume from a backgrounded app is accepted without
an exception and leaves the volume untouched: a debug log from an OPPO
PME110 shows "duckMusicVolume(100%): 40 -> 40 (requested 0)" while the same
call succeeded in the foreground.
duckMusicVolume returned a VolumeDuck for that, so ConversationReaction
recorded an Active session, armed the stale backstop, and later "restored" a
level that was never left. It now returns null when the read-back shows no
decrease, which routes the caller into its existing duck-no-op path: nothing
armed, nothing to restore, and a repeat START retries the duck instead of
treating the dead session as a keep-alive.
The predicate is applied >= prior rather than == prior. A route that
quantizes the target back up to the starting index attenuated nothing
either, and a volume that came back higher (user raised it between the two
reads) must not produce a duck whose restore would undo their change. The
WARN says "volume did not decrease" rather than blaming the ROM, since the
read-back alone cannot distinguish those three causes.
upgrade_screen_sub_check_failed_title/_message no longer exist in the
English source (removed in 3e6541de). Seven locales still carried
Crowdin TM-poisoned translations for them, tripping ExtraTranslation
in lintVitalGplayBeta/Release.
refreshStrict() throws its incomplete-result error AFTER useConnection
already returned: refreshPurchases hands back a partial result instead of
throwing, so useConnection's dead-binder detection never sees it. A gate
that ran against a connection whose binder died mid-query (partialError
cause chain carrying SERVICE_DISCONNECTED / SERVICE_TIMEOUT) left that
connection installed, and every later purchase check kept talking to the
corpse until something else tore it down.
processReconciliation() already compensated for the refresh()/connect-loop
paths; the strict path had no such call. Extract that dead-connection block
into invalidateOnDeadConnection() and call it from both. The strict path
deliberately does NOT feed the episode clock — a gate the user aborted
mid-purchase is not a reconciliation outcome.
Ports the pending-purchase coverage alongside the production change.
- BillingConnectionTest / BillingManagerTest: PENDING ingestion, the
PURCHASED-only entitlement exits, provesAbsence ignoring a surviving
pending overlay, the reconciliation pass and the ack pass skipping
pending purchases.
- UpgradeRepoGplayTest: pendingSkus never feeding isPro, the strict
verify path and PendingPurchaseBillingException on an already-owned
recovery.
- GplayUpgradeViewModelTest: the shared pre-purchase gate on both paths
(pending, timeout, error, owned upgrade, renewing subscription with an
unknown product), the pending-payment launch failure, and the pending
card rendering while prices are still loading or have failed.
- GplayUpgradeScreenTest / GplayUpgradeOwnershipTest /
GplayUpgradeScreenHostTest: the card for all three audiences, the
locked offers and switch button, restore staying enabled, and the
informational dialog reaching the composition.
The FOSS upgrade screen's view mapping only surfaced STATUS_UPGRADED on
the manage route. Forced routes (the Pro-locked settings entry)
deliberately don't auto-close, so a supporter completing the sponsor
flow from there stayed on the sales pitch with a live sponsor button --
reading as if sponsoring didn't work, with only the transient thanks
toast saying otherwise. The gplay flavour already renders ownership
state route-independently on this path.
The isPro branch now wins on every route, matching the adjacent
comment's stated intent and gplay's behaviour. Forced routes keep their
don't-auto-close semantics; the durable status view is what
acknowledges the upgrade.
A user whose payment Play is still processing now gets an explanation
instead of a screen that keeps selling them the upgrade they already
bought.
- New PendingPurchaseCard, rendered above the ownership/acquisition split
so it reaches every audience: the acquisition buyer, the owner switching
products, and the grace user (whose offers box is hidden entirely during
a young episode) — all three have their purchase actions locked and need
the same explanation.
- Loaded.hasPendingPurchase is SKU-agnostic: the subscription and the
one-time purchase are alternatives, so a pending payment for either one
disables both offers and the ownership switch button. Restore stays
enabled — re-checking with Play is the useful action here.
- Both purchase paths now run the same pre-purchase gate
(verifyPurchaseStateNow, bounded): a pending payment answers with the
informational PurchasePending dialog instead of launching a flow Play
would reject. The subscription path previously launched unverified, and
its gate also blocks on an owned upgrade (RestoreSucceeded) and on a
still-renewing subscription (SubscriptionStillRenewing), so a stale
screen can't sell Pro to someone who already owns it.
- restorePurchase() reports a found-but-unpaid purchase as PurchasePending
rather than RestoreFailed, whose copy asserts a completed check and
steers toward multi-account troubleshooting and support.
- A PendingPurchaseBillingException from a launch maps to the same dialog.
- SubscriptionCheckFailed becomes PurchaseCheckFailed (both paths use it
now); its string is replaced by
upgrade_screen_purchase_check_failed_message and the translated entries
of the old key are removed, along with the already-orphaned
upgrade_screen_sub_check_failed_title, so ExtraTranslation lint stays
quiet.
Play reports a purchase as PENDING while a slow payment method (cash,
carrier billing, bank transfer) is still being processed. Until now
BillingConnection dropped those at ingestion, so the app had no idea the
user had bought anything: the upgrade screen kept selling, and a second
purchase attempt was rejected by Play with ITEM_ALREADY_OWNED.
Pending purchases now enter the reducer state and travel to the UI, while
every entitlement exit stays PURCHASED-only:
- BillingConnection ingests PURCHASED + PENDING (UNSPECIFIED_STATE is
still dropped everywhere). The freshUpdates stream keeps receiving only
PURCHASED, and provesAbsence now ignores a surviving PENDING overlay
entry, so a payment in progress can't freeze the unconfirmed-episode
clock.
- combinePurchaseResults gets the sku-type resolver: a PENDING result only
suppresses the couldn't-verify error when it maps to a known Pro SKU. An
unknown pending product proves nothing about the type whose query failed.
- PurchaseRefresh now carries provenance (confirmed set,
hasConfirmedProPurchase, commit-time occurredAt, partialError) instead of
just the merged view plus isComplete.
- BillingData splits into purchases (entitlement carrier) and
pendingPurchases via a single from() classifier used at every exit.
- BillingManager gains processReconciliation(), run after the connect
loop's initial refresh and by refresh(): it re-signals dead-binder
invalidation and feeds the grace episode clock with the refresh's COMMIT
time. The ack pass skips pending purchases, which Play rejects
permanently and would report as a bug every pass.
- BillingConnection.querySubscriptions / BillingManager.querySubscriptions
are replaced by refreshStrict(): the pre-purchase gate needs both product
types and the pending state, and still fails closed on anything short of
a complete round-trip.
- UpgradeRepoGplay exposes Info.pendingSkus (never part of isPro),
Info.hasAutoRenewingSubscription, verifyPurchaseStateNow() for the gates,
and reports PendingPurchaseBillingException when an already-owned
recovery finds a pending payment. The grace branch now carries
billingData through so pending stays visible while Pro runs on grace.
settings_upgrade_status_label lived twice per flavor: once for the
Settings row, once as a raw locale-translated string standing in for
what the upgrade screen otherwise composes from the app name and the
tier qualifier. On gplay the two could drift apart -- 15 of 75
locales showed the wrong word order or an untranslated English
fallback where the composed title used the correct language. The row
now composes through the same brandTitle template gplay's own
upgrade screen title already used, so the two can no longer disagree.
FOSS's value was a support ask ("Sponsor CAPod"), not a composed
brand title, so its wording stays untouched -- the key is renamed to
upgrade_foss_sponsor_label (byte-identical text, all 75 locales) and
both FOSS call sites read that one resource instead.
R8 reported com.google.android.gms.common.annotation.NoNullnessRewrite as missing; it is a compile-time-only annotation referenced by review-ktx and absent from the runtime classpath. Adds the same targeted -dontwarn SD Maid SE already uses.
The template moved to main, so FOSS resolves per-language arrangements
too and composes reordered titles for the first time. Running the sweep
only for gplay would leave that path unguarded.
Arrangement is a property of the language, not the flavour, so the key
now lives once in main alongside app_name. The qualifier stays
flavour-specific: only the word order and punctuation move.
FOSS consequently inherits each language's arrangement instead of being
frozen at the default order.
Phase 1 of the Crowdin migration: the flavour copies still override this
for their own builds, so behaviour is unchanged while translations are
gathered against the new source string.
The locale sweep checked format specifiers with a regex that does not know
%<s, which reuses the previous argument and so emits the qualifier twice.
That damages the template, triggers the fallback, and still satisfies every
output assertion because a fallback title also carries one correctly styled
qualifier. Both checks now run against the formatter's own output.
Also pins the highlight colour: it is a parameter because the toolbar tints
FOSS and Pro differently, so hardcoding it back would have stayed green.
Both are now derived: the title is composed from app_name and the
flavour's upgrade_badge_label through app_name_upgraded_template, so the
pre-composed strings have no remaining reader.
The composed app_name_pro was split on spaces and only styled when it
produced exactly two tokens. Arabic has four (kabud en-dash two-word
qualifier) and lost its branding entirely; Estonian puts the qualifier
first and so passed the guard while highlighting the brand instead.
Titles are now built from a per-flavour app_name_upgraded_template with
the app name and the tier qualifier as placeholders, so translators own
word order and punctuation.
The Google Play launch-failure message was shown via Toast, which Android caps
at 2 lines: English lost the trailing "device.", French was cut mid-word and
lost an entire condition. The strings are fine, the container was wrong.
The fix action now rethrows after logging, the failure reaches the dialog, and
the dialog renders the message inline while staying open. The dismiss button
stays available, so the dialog is never latched.
The message is passed per dispatch rather than read from the LocalizedError, so
no future action button can surface the fix action's failure copy. The inline
state is keyed on the throwable, not the LocalizedError, which is rebuilt with
fresh action lambdas on every recomposition.
When both the IAP and SUB queries came back with nothing, the screen always
reported a connectivity failure, telling users to clear Play's cache and reboot.
Play can answer OK and simply have no sellable offer (region, account
eligibility, pulled product), where that advice is futile.
Both causes are now inspected: only when BOTH are OfferUnavailableBillingException
does the merchandising copy surface. A single non-merchandising failure can't
rule out a real Play problem, so the conservative copy stays.
The card is a keyed item in the overview's lazy list, which restores the
saveable state of removed items when they come back. A review tap left
the dismiss action permanently disabled once a higher priority card took
the slot and gave it back, and a dismissed card returned fully dead.
Fixes review finding F1.
Adds timing, caching and boundary coverage for the review tool plus the
card's latch matrix. The existing both-actions card test is superseded:
its dismiss-then-review sequence is exactly what the latch blocks.
The card stays up until the next state emission, so a dismiss after a
review would overwrite the review bookkeeping with a snooze and a review
after a dismiss would re-open what was just closed. Repeated review taps
stay allowed so a failed Play request can still be retried.
Timeouts on all three Play calls, a dismiss generation backstop for the
tap race, and a 3-state probe verdict that caches Play's definitive
answers for the process, retries transient failures on a bounded budget,
and re-evaluates eligibility at the snooze and pro-grace boundaries.
Corrupt review settings terminated the shared state flow on AppScope,
so the exception crashed the process instead of reaching the ViewModel's
catch. Absorb it upstream of both replayingShare calls.
The review card also no longer stacks on top of the enable-Bluetooth
prompt.
Fixes review findings F1, F2
Pins the Play review tool's eligibility gate, probe retries, single-flight
guard and cancellation handling, the DataStore round trip of the review
timestamps, the overview's card priority gate and the card itself.
Adds a review prompt card to the overview. On Google Play it uses the
in-app review flow, gated on the user having been Pro for a while, not
having dismissed it recently and not having reviewed yet. FOSS gets a
no-op implementation.
The card is the lowest priority item on the overview and stays hidden
while a permission, troubleshooter, background-monitoring-off or
no-profiles card is on screen.
Shrinks oversized screenshots posted in issues and comments into clickable
thumbnails. Implementation shared from d4rken-org/.github; this stub only
supplies the triggers, because workflow_call cannot be driven by issue_comment
directly. Pinned by commit SHA rather than a moving tag.
The toast claimed Google Play was not installed for every failed launch,
but the SecurityException path means Play is installed and merely disabled
or profile-restricted - telling those users to install an app they already
have. Neutral wording covers both cases; the resource key is unchanged (no
translations exist yet).
The dialog's fix dispatch ran unguarded: a throwing action crashed the UI
thread from inside a click handler and skipped onDismiss(), leaving the
dialog latched on the current error. The dispatch is now wrapped in
try/catch with onDismiss() in a finally block.
Google Play fix action:
- Drop FLAG_ACTIVITY_NEW_TASK. The action runs on an activity context, so
the flag only detached Play's app info from the caller's task and back
stack.
- Catch SecurityException next to ActivityNotFoundException: Play can be
installed but blocked (disabled app, restricted profile, guarding ROM),
which denies the launch instead of failing to resolve it.
- The fallback toast is now a translatable string resource instead of a
hardcoded literal.
New coverage: ComposeErrorDialogGuardTest pins that a throwing fix action
still dismisses the dialog (shared source set, so both flavors run it),
GplayFixActionTest pins the denied and unresolvable launches showing a
toast instead of crashing, and ComposeErrorDialogTest now asserts the
launch intent carries no NEW_TASK flag.
Two rollback defects found reviewing the failed-start handling, both cases
where cleaning up after a start that could not finish damaged something it
did not own:
- FileLogger.start() deletes the log file only when that same call created
it, and reports the failure instead of swallowing it. A resumed session
appends to the previous recording's core.log, and a failed append used to
erase it while telling the recorder the start had succeeded. The writer is
published only once it is usable, so a failed attempt leaves nothing behind
that would make a later start() a no-op (F3).
- The module's rollback skips self-suppression: a recorder broken in one way
throws the same instance on the start line and again when the rollback
stops it, and addSuppressed(self) raises IllegalArgumentException — which
aborted the rollback before the failure state was committed and took the
shared state collector with it (F4).
Recorder.start() is the only production caller of FileLogger.start(), and it
runs inside the module's whole-branch guard, so the new throw lands in the
rollback rather than escaping.
Fixes review findings F3, F4
A start commits its session dir into the recorder state only once the
recorder is live. For the whole window before that, shouldRecord is set
but isRecording is not, so a scan sees a directory with a non-empty
core.log and no sibling zip - an orphan - and the manager's auto-zip
compresses the directory the recorder is writing into. When the start
then fails, the rollback deletion races that zipper, and the archive
left behind defeats the collision check in createSessionDir: the retry
reuses the session ID of the attempt that just died.
The scan and the recorder state it was taken against now travel as one
value, so the reconciliation can tell a pending start apart from a
settled one and defer new zips until the state is terminal. Running
zips are untouched. A sibling '.zip' or '.zip.tmp' now counts as a name
collision as well, since the session ID is derived from that name.
Fixes review finding F1.
Starting a recording spans several steps — create the session directory,
start the recorder, persist the trigger file, write the header — and only
the last of them commits the recorder into the module's state. Anything
throwing inside that window escaped the reactive collector, which then
died for the rest of the process: the started recorder kept writing where
nothing could stop it, the trigger file survived to re-attempt the dead
session on every launch, and startRecorder() waited forever for a state
nobody would publish. The debug log toggle stayed dead until reinstall.
The whole start branch is now guarded. A failure rolls back first — stop
the recorder, clear the log dir mirror, remove the trigger file, delete a
session dir this attempt created — and only then decides what the failure
means: our own scope dying still takes the collector with it, anything
else (a cancellation from inside the start work included) is committed as
a start failure and surfaced to the caller. shouldRecord is reset with it,
so the every-state collector lands in the idle branch instead of retrying.
The stop branch gets the same treatment: a recorder that cannot stop is
logged and the cleared state committed anyway, so an awaiting stop
completes. Recorder.stop() itself now guarantees logger removal, writer
closure and reference clearing. Session directory names get a collision
suffix, since a same-second retry would otherwise share a directory with
the attempt it replaces, and the public start/stop entry points are
serialized so two callers cannot race the same transition.
The tracking onEach sat downstream of flatMapLatest, so its channel buffer
could hold the Pro emission while the inner flow already threw: the catch
then read a null last-known state and emitted a non-Pro error Info, kicking
a supporter back to the pitch. Tracking now runs in the same coroutine as
the throw.
The retry sits inside the errorContainer card but drew itself with the
default primary-on-surface outlined colors, which clashes with the card and
loses contrast once the tap latch disables it. Content and border now follow
onErrorContainer, with a dimmed disabled pair, and both states get a
preview.