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.
Suppressing ForegroundServiceDidNotStartInTimeException and re-entering
Looper.loop() left zombie processes behind that kept collecting ANRs.
Always delegate to the previous handler instead.
Every startForegroundService() re-arms the 10s startForeground() deadline,
even when the service is already foreground. The service only promoted in
onCreate(), so repeated start requests could time out and ANR.
super.onCreate() triggers Hilt's singleton construction, so any log emitted
during that graph build was discarded before the logger existed.
Fixes review finding F2
The test claimed to guard against a main-looper binding, but the
assertion only checks that the injected handler instance is forwarded.
Rename it and document that the Looper identity is covered by the
AndroidModule provider and the runtime thread-name QA check instead.
Fixes review finding F1
MediaControl registered its AudioPlaybackCallback with a null Handler,
binding delivery to the main looper. Both the callback body and the
constructor's seed read call AudioManager.isMusicActive, a binder
transaction into AudioService, producing two ANR clusters: one in
onPlaybackConfigChanged and one in <init>, the latter on the cold-start
critical path since MediaControl is constructed during App.onCreate.
Registration and seeding now run on a dedicated, injected Handler backed
by a "CAPod-MediaControl" HandlerThread, and the callback is delivered on
that same looper. Registration happens before seeding so a transition
during registration is queued behind the seed instead of being lost.
The handler is constructor-injected via a new @AudioCallbackHandler
qualifier so unit tests can drive it without Robolectric.
Scrolling content now slides under the transparent status and
navigation bars instead of clipping at the inset boundary. Adds
PaddingValues.plus and systemBarsAndCutoutInsets helpers, moves inset
consumption from scroll viewports into content padding on every screen,
fixes reorder auto-scroll thresholds for content padding, adds IME
handling to form screens, and removes the unused EdgeToEdgeHelper.
On entry upgradeInfo looks like a non-owner until Play reconciles, so if the SKU query resolves first the offers box briefly rendered the red "unavailable" card before the owner/grace status or prices appeared. Gate that card behind settled and !skuQueryInProgress and show a neutral spinner during warm-up.
Also label the restore-failed dialog's dismiss button "Close" instead of "Cancel" — it reports a result, it doesn't ask to abort an action.
Reshape the Google Play upgrade screen into SD Maid SE's offercard layout:
purchase options as titled offer rows (name · price, terms, action) with an
"or" divider inside one action card, extracted into gplay-local UpgradeContent
/ UpgradeOffers / UpgradeOwnership / UpgradeRestore primitives. Keeps capod's
icon benefits card, splash graphic, and floating back arrow.
Restore now mirrors SD Maid: a reusable restore section (emphasized for
returning buyers), verification-gated across all surfaces, and a restore-failed
dialog that leads with the live Play check and offers Contact support.
Billing logic is unchanged apart from onContactSupport() navigating to the
contact form. Offer rows render conditionally on offer availability; the offers
box AnimatedContent keys on an availability phase so same-state updates
recompose in place.
* feat(upgrade): Add Pro status view, grace UI and sub-to-IAP switch
* fix(upgrade): Pad restore purchase to a minimum visible duration
* ui(upgrade): Mention Play-website install fix in restore troubleshooting
* fix(upgrade): Stop re-acknowledging already-acked purchases
* ui(settings): Move upgrade status row into the Other category
A mode's own drain-rate bucket starts empty until it accumulates history,
so toggling ANC into an unlearned mode fell straight through to Apple's
optimistic spec rating while the mode just left showed its worse measured
rate. Result: enabling ANC could make the displayed time jump up ~1h.
Fill an empty ANC bucket at display time with the less-optimistic of the
mode-agnostic UNKNOWN reading and a sibling mode's learned rate, scaled by
the ratio of the two modes' rated drain. Scoped to spec'd models and
device-supported modes; picks the best-evidenced sibling, tie-broken by
closest rated drain then recency. No persistence or UI change.
The existing spec ceiling and display clamp still backstop the borrowed rate.
- Show the time until the case is full inside its charging chip, learned from
the case's own rising level with the existing charge-band model; no rating
exists for case charging, so the first charge learns before it shows
- Derive a case battery health from observed transfer efficiency: pod percent
gained per case percent spent while docked and unplugged, corrected by each
pod's own health, compared against Apple's "with charging case" totals
- Case data is only genuine while a pod is docked — both transports silently
freeze the last value otherwise. Battery updates now flag whether the case
entry is live, and BLE gains strict same-frame case accessors, so estimates
never learn from frozen echoes
- The case deliberately gets no runtime estimate: idle-then-burst drain has no
meaningful hourly rate
- Mark the case metrics as experimental in the Battery settings card
- Show a "Still determining — check back after a few more listening sessions"
placeholder under Battery Health in the device info sheet while the estimate
is still accumulating data, so the feature is discoverable from day one; no
placeholder for profiles without a paired device, where it could never resolve
- Reorder the Battery card: time remaining & health settings first, then a
divider, then the charging-side settings (charge limit, charged notification)
- Mention the battery-health component in the estimate toggle and reset texts
- Ground the charge-band boundaries in Apple's documented fast-to-80%/trickle
charging behavior and standard lithium CC/CV charging references
- Replace the single linear charge rate with a three-band model (bulk / taper /
trickle) matching lithium CC/CV charging: each band learns its own rate, the
ETA walks the remaining bands, and the spec seed gets a taper haircut for the
slow bands — no more over-promising above 80%
- Base the battery-health figure exclusively on drain observed while the pod is
worn, audio is playing, AND this device is the system's audio sink; idle wear
previously diluted health upward against Apple's listening ratings
- Listening segments are flushed for persistence the moment their gate breaks
(playback stop, docking, transport flip) instead of being discarded with the
cleared window
- The time-remaining estimate keeps learning from all usage — actual current
drain, idle included, is the right basis for "how long will they last"
- The gauge line under the percentage now always shows the runtime estimate
("if used now") even while charging; the time-until-charged moved into the
charging chip itself ("Charging · 25m"), so the two can't be confused
- Battery health is now computed and shown per pod (Left/Right paired row in
the info sheet, mirroring the serial rows) — single-pod listening habits or
a replaced earbud make the sides genuinely diverge, and a combined figure
would mask a failing pod
- While charging, the gauge showed the bare runtime projection when no charge
rate existed yet — "1% · 4m" next to a charging chip reads as a four-minute
charge. The line now shows the "until charged" ETA or nothing
- Seed the charge rate from Apple's published quick-charge claims ("5 minutes
in the case provides around 1 hour of listening"), normalized against the
rated listening hours, so the ETA is present from the very first charge; a
live fit still takes over within minutes