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.
LocalizedError can now carry a fix action, and the shared error dialog shows
it next to a Dismiss button when one is present; errors without a fix keep
today's OK-only shape. The Google Play billing-unavailable error uses it to
open Play's app info as a generic troubleshooting affordance.
The acquisition top bar reused the widgets' flat upgrade label, so the Pro
postfix stayed uncolored while the owned/grace title highlighted it. The
title is now a gplay-only template that takes the composed brand as a
placeholder, spliced back in as the same styled AnnotatedString the status
title uses — word order stays the translation's business. The widget label
keeps its own key and its own wording.
Adds UpgradeHeroCard, which pairs the mascot with the preamble copy inside a
single ElevatedCard and stacks them once the copy runs out of room. Used by
the FOSS pitch view and the GPLAY acquisition view; grace episodes and the
FOSS status views keep their standalone header, which has no preamble to
pair with. Screen tests pin the hero's presence and absence per state.
Restore the consumed sponsor marker only when no newer launch was armed
meanwhile. Make the recorder's monotonic base a nullable sentinel so a
boot-adjacent elapsedRealtime of 0 is not mistaken for a resumed session,
and clear it on stop. Pin the contention test's collector subscription and
assert it actually echoed.
A thrown cache read used to die inside shareIn's sharing coroutine, leaving
every collector waiting forever. Catch inside flatMapLatest, keep the last
known entitlement on late failures, and let a successful persist revive an
error-stuck inner flow.
updateBlocking emitted its update into updateActions FIRST and only then
subscribed internalFlow.first { it.updatedBy == update } against a
shareIn(replay = 1). That is a lost wakeup: if the producer processes our
update plus a successor before the first{} collector is registered, our
identity-matched State is displaced from the replay-1 cache and the await
never completes. A reactive collector makes the successor guaranteed rather
than unlikely - RecorderModule reacts to every state with an update of its
own, so each caller's update immediately breeds another.
The trigger is scheduling pressure: on a 2-core machine the producer and the
awaiting caller no longer run in parallel, so the emit-to-subscribe window is
wide. The CI test jobs wedged at GitHub's 6h job timeout; reproduced locally
under taskset -c 0,1 and confirmed by jstack - the caller parked forever in
updateBlocking's first{} while the producer sat idle with the update long
since processed.
The awaiter is now started UNDISPATCHED before the emit, so it runs
synchronously up to its first suspension inside first's collect and the
collector exists on the shared flow before the update can be processed.
Covered by a contention test that mirrors the module interaction (two
concurrent updateBlocking workers plus a value-neutral reactive echo
collector), and the two recorder test harnesses now wrap their block and
their cleanup stop in timeouts - a regressed await has to fail in seconds
instead of wedging a runner for six hours.
The realtime harness cancelled its module scope but never stopped the
recorder, and cancelling a scope does not uninstall a running recorder's
globally installed FileLogger. A test that started a recording therefore left
one writing into every test that followed, and an assertion failing before the
explicit stop did the same.
The harness now stops the module in a nested finally and fails its own test if
a file logger survived, removing the straggler afterwards so a single leak
cannot cascade. The tracked-recording test gets the same finally treatment.
The "that recording looks very short" prompt measured duration against the
wall clock, so any adjustment mid-recording decided it: an NTP sync or a
manual clock change moving forward made a three-second recording look like an
hour and skipped the prompt, moving backward trapped a long recording in it
with no way past but "stop anyway".
A live session now measures from a monotonic base taken at the start. Only a
session resumed from the trigger file still uses the persisted wall time -
that file has to survive reboots, which monotonic time does not - and a
negative duration there fails open instead of warning.
The threshold moves from 5s to 10s: a recording stopped that quickly holds
nothing but the recorder starting and stopping, which costs a support
round-trip to re-request. It stays a prompt, not a block, because a crash is
logged and flushed immediately.
persistUpgrade() wrote an unconditional record, so any sponsor return that
got past the ViewModel's isPro guard replaced an existing supporter's
upgradedAt - the "supporter since" date the status screen shows - and, for
the legacy records every existing supporter has, their stored reason too.
That guard reads a shareIn replay and can be stale, so it is not a race-free
answer on its own.
The write now happens inside the store transaction: an existing record is
kept and reported back, only an absent one is created. The ViewModel thanks
the user only for an actual unlock and stays quiet otherwise. It also no
longer eats the sponsor visit when something fails - a failed entitlement
read or a failed write restores the pending-launch marker so the next return
can retry, while the error still travels the normal path.
FossCache gains the same constructor test seam BillingCache has, so the
transaction can be exercised against a real DataStore on a temp file.
An auto-created profile without a paired Bluetooth device resolves to MANUAL
mode, so nothing runs in the background while the dashboard claimed to be
monitoring. The dashboard now states that background monitoring is off and
offers to pick a paired device, and the per-card banner names what a missing
paired device costs.
Closes#658
"FOSS" is the flavor's name, not prose. Locale copies of the FOSS badge
and of the composed "CAPod FOSS" title had drifted - some translated the
word, one Thai entry into an unrelated word entirely - all of it shown as
the app's own brand.
Both base entries are now translatable="false" and every locale copy is
gone; with the flag set, any remaining locale entry would trip a fatal
ExtraTranslation in the release lint. The GPlay "Pro" badge is prose and
stays translated.
The debug log header read the flavor's upgrade diagnostics unbounded. A
wedged source (a stuck DataStore file lock, a billing store that never
answers) left the recorder started but never committed, so the user asking
for a log got nothing at exactly the moment the app was misbehaving.
The read now runs under a deadline: a source that hangs or fails degrades
to "unavailable" and the recording starts. Completion is tracked
separately from the value, so a flavor that legitimately has nothing to
report (FOSS) still logs no line at all instead of claiming a failure.
Cancellation is unchanged: an outer scope death still rolls the
uncommitted recorder back.
The GPlay diagnostics' pro-history read gets the same bound its billing
cache read already had.
stampLastProState() was bounded against a wedged file lock, but a write
that failed outright (corrupt preferences file, no disk space) still threw
straight through into the entitlement path it only decorates.
Non-cancellation exceptions from the edit now log a warning and skip the
stamp, same as the timeout does. Cancellation keeps propagating - caught
first on purpose, swallowing it would break the caller's structured
concurrency. Reads stay loud: a snapshot that couldn't be read must not
be mistaken for a never-bought install.
The sponsor unlock heuristic armed itself on every tap, regardless of
whether a browser ever opened the page. An unrelated later background
round-trip could then hand out supporter status with no page ever shown.
WebpageTool.open() now reports whether an activity was actually started
and the FOSS repo passes that through synchronously, so the ViewModel can
only arm after a successful launch. A second tap while a launch is still
pending is ignored, and the upgraded status view's donate button gets its
own unarmed entry point - an existing supporter has nothing left to
unlock, and re-persisting would rewrite their "supporter since" date.
The upgraded status now renders the date the supporter unlocked, derived in the
same emission as the view so the screen never shows the status without the date
it is supposed to carry.
Guards that date: a return from the recurring-donation button no longer runs
persistUpgrade() when the install is already Pro, which would have rewritten
upgradedAt and visibly reset the displayed date. The sponsor-return tracker is
seeded from the handle-backed pending launch so a process death while the
sponsor page is in front does not swallow the first return.
The status views are titled "CAPod FOSS" instead of "CAPod Pro" -- on FOSS the
flavor name is the brand.
The card reports that PRICES could not be loaded, so it now says so instead
of borrowing the generic "Google Play services are unavailable" title, which
contradicted its own body.
The retry latches after the first tap: the guard sits inside onClick because
`enabled` only takes effect after recomposition, so two taps in the same frame
would both fire. It resets naturally when the card leaves composition.
Returning to the screen re-runs the SKU query when it is in the unavailable
state. MainActivity's per-resume refresh only covers the entitlement, so a
transient Play outage left the retry card up until it was tapped by hand.
BillingCache reads and writes are now bounded by a timeout seam: a wedged
DataStore file lock made the debug-log header hang, and a silent fallback to
the default snapshot would have reported "never bought" for an install whose
evidence merely could not be read. Reads now fail loudly, writes fail soft.
UpgradeDiagnosticsGplay absorbs the pro-state history that the recorder header
used to read directly, with a separate failure boundary per source so one
broken DataStore cannot suppress the other's evidence.
RecorderModule's start-failure guard now covers ordinary exceptions, not just
cancellation, stops the uncommitted recorder under NonCancellable and appears
once instead of per resume branch.
- WidgetConfigurationActivity refreshes the entitlement on resume: it is a
second launcher entry point and can't rely on MainActivity reconciling.
- The upgrade-return callback re-asks decideConfirm() instead of trusting the
upgrade activity's result code, so RESULT_OK stays entitlement-gated.
- RecorderModule stops the freshly started recorder when the header's
diagnostics reads are cancelled, instead of leaking an untracked recording.
- FOSS beta channel points at the GitHub releases page; the Play testing URL
is signature-incompatible for FOSS builds.
- Billing bug reports carry the contextual wrapper again, so the report is
grouped by call site instead of the raw billing exception.
Fixes review findings F1, F2, F3, F4, F5.
Replaces capod's older billing core, upgrade UI and their tests with the
canonical sdmaid-se stack at the pinned revision.
Core (gplay): BillingManager/BillingConnection/BillingConnectionProvider on
billing 8.3 with the centralized connect loop, merging purchases-listener
overlay and the canonical ack pipeline; the dying ack collector, the
ackedTokens gate and the in-billing foreground loop are gone. Full canonical
exception set (internal/network/offer-unavailable added), OurSku with capod's
product ids, BillingCache with snapshot()/episode-guarded stampLastProState.
FOSS: UpgradeControlFoss becomes UpgradeRepoFoss and exposes the canonical API
surface over capod's RETAINED FossUpgrade/FossCache schema — existing supporter
records must keep decoding.
Diagnostics: UpgradeDiagnostics + gplay/foss implementations, read by
RecorderModule next to CurriculumVitae's Pro history as two independent,
isolated header reads.
UI: canonical upgrade screens for both flavors under common/upgrade/ui with
capod chrome (M3 AlertDialog keeping rotation-safety, capod Scaffold, capod
previews). Nav.Main.Upgrade gains `forced`. Entitlement refresh moves to a
per-resume, unthrottled MainActivity call.
Strings reuse capod's existing translated ids wherever equivalent; only
referenced-but-missing ones are authored.
mockk 1.12.4 -> 1.14.9: 1.12.4 cannot synthesize a sealed-class return value
while recording, which the ported restore tests need.
Additive infrastructure for the canonical billing port, no coupling to the
billing core yet.
- CurriculumVitae: Pro-state slice only (ProState, ProHistory,
updateProState, proHistory, transition classification, tolerant enum
decode). Raw preference keys so a transition updates state, counter and
timestamp in one DataStore transaction.
- ViewModel4.safeStateIn: render-state flows forward recoverable failures
to errorEvents and emit an explicit fallback state instead of throwing
into collectAsStateWithLifecycle().
- testhelpers: TestApplication, BaseComposeRobolectricTest and the
mockDataStoreValue helper.
UpgradeRepo gains the canonical shape: settledness rides each Info
emission, plus storeSite/upgradeSite/betaSite and a suspend refresh().
getSponsorUrl() is replaced by upgradeSite (FOSS only, GPlay keeps the
heart icon hidden). UpgradeRepoExtensions is the canonical file with
isPro/isProSettled/isProForUi.
UpgradeRepoGplay folds its parallel isSettled flow into Info.isSettled
(behaviour preserving) and implements refresh() as a bounded, unthrottled
call to the existing billing refresh. UpgradeControlFoss is settled from
its first emission and no-ops refresh().
Interactive gates move to isProForUi so a paying user isn't bounced to
the upgrade screen during the GPlay cold-start race: the device-settings
and press-controls pro gates, the theme setters, and the widget confirm
action, which now goes through a sealed ConfirmOutcome so the activity
can only return RESULT_OK for an entitled, valid configuration.
Presentation paths that can't reach a suspending gate (general settings
theme items, overview device limit) render the upgrade branch only when
the entitlement is hard-locked: settled, error-free and not pro.
Two pre-existing concurrency defects in MediaControl (#647).
Lost update: sendPlay() wrote capPaused after the suspending sendKey(),
whose delay(100) is a window in which a concurrent
sendPause(rememberForResume = true) could arm the flag only to have it
overwritten. Stem presses run on the app scope while ear, sleep and
conversation reactions run on the monitor scope, so the senders really
do race. The compound check/dispatch/flag sequence now runs under a
Mutex, the flag is cleared before the first suspension, and the key
pair completes under NonCancellable so cancellation cannot strand an
unpaired DOWN event.
Coalescing blind spot: the playback callback ignored its configs
argument and read live isMusicActive, so queued deliveries all observed
the newest state and an inactive to active edge in between was never
seen, leaving capPaused stale. The edge is now derived from the
delivered snapshot. A pause that passes the live active check records
that observation so an already-queued music-start snapshot cannot drain
later and read as a fresh edge.