Commit Graph
1241 Commits
Author SHA1 Message Date
darken 26633b940d fix(upgrade): Make the FOSS supporter persist create-only-if-absent
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.
2026-08-03 08:30:33 +02:00
darken d98c118f37 test(overview): Cover the background-monitoring-off signal
Adds monitoringStatus decision tests to OverviewViewModelTest plus Compose
tests for the new dashboard card and the missing-paired-device banner.
2026-08-02 14:51:43 +02:00
darken 8f0ec5a374 fix(overview): Signal when background monitoring is off
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
2026-08-02 14:51:43 +02:00
darken 53b4150ff6 fix(ui): Stop translating the flavor brand labels
"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.
2026-08-02 12:40:32 +02:00
darken 6b2536c74f fix(debug): Keep debug recording available when diagnostics hang
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.
2026-08-02 12:40:32 +02:00
darken 72fb1d3b17 fix(upgrade): Never let a failed cache stamp abort entitlement bookkeeping
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.
2026-08-02 12:40:32 +02:00
darken 31c3b47bcd fix(upgrade): Only count a sponsor visit when the page actually opened
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.
2026-08-02 12:40:32 +02:00
Matthias Urhahn 7cb31ce783 Merge pull request #656 from d4rken-org/worktree-fix-unknown-notification
Fix: Ongoing notification stuck showing "Unknown"
2026-07-30 14:30:33 +02:00
darken 2f69090bc2 fix(monitor): Invalidate the notification cache at session launch 2026-07-30 13:23:49 +02:00
darken 0c39ca89c8 fix(upgrade): Show the supporter-since date on the FOSS status screen
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.
2026-07-30 12:55:31 +02:00
darken 10317b373f fix(upgrade): Polish the GPlay offers-unavailable card
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.
2026-07-30 12:55:31 +02:00
darken 7fb1f7aabd fix(debug): Bound the billing cache and fold pro history into diagnostics
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.
2026-07-30 12:55:31 +02:00
darken 54d3c9d824 fix(monitor): Stop re-promoting a stale notification into a new session 2026-07-30 12:47:23 +02:00
darken b616696a41 fix(monitor): Retract the stale ongoing notification on teardown 2026-07-29 20:09:12 +02:00
darken e364a5b02c fix(upgrade): Cover widget entry refresh and recorder edge cases
- 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.
2026-07-29 14:05:26 +02:00
darken 3651bb3d55 refactor(upgrade): Converge GPlay billing on the canonical stack
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.
2026-07-29 14:05:26 +02:00
darken 0192ae7081 feat(core): Add Pro-state history and safe state collection helpers
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.
2026-07-29 14:05:26 +02:00
darken 8c1b57a47c refactor(upgrade): Adopt canonical entitlement interface and gates
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.
2026-07-29 14:05:26 +02:00
darken fb48ff9d43 chore(claude): Enable google-play and devtools plugins 2026-07-29 13:11:55 +02:00
darken 2d604c1e6a fix(reaction): Serialize pause arming behind queued snapshots 2026-07-28 23:52:04 +02:00
darken 5e28eeed38 fix(reaction): Serialize media key dispatch and read playback snapshots
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.
2026-07-28 23:52:04 +02:00
darken edd3cf0c2b ui(monitor): Give the early service notification a content text 2026-07-28 23:51:46 +02:00
darken 9f322f0a3f test(monitor): Cover foreground re-promotion and start-rejection logging 2026-07-28 23:51:46 +02:00
darken de287fcb66 fix(monitor): Log FGS start rejections distinctly with full stack 2026-07-28 23:51:46 +02:00
darken 28ad09e96d fix: Stop suppressing foreground service timing exceptions
Suppressing ForegroundServiceDidNotStartInTimeException and re-entering
Looper.loop() left zombie processes behind that kept collecting ANRs.
Always delegate to the previous handler instead.
2026-07-28 23:51:46 +02:00
darken 8f71af8c5d fix(monitor): Re-satisfy foreground obligation on every start command
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.
2026-07-28 23:51:46 +02:00
darken 305a6d6c2a docs(aap): Warn that 0x0001 closes the stream mid-session 2026-07-28 20:18:54 +02:00
darken 26b04854c4 chore(claude): Document AAP protocol landmines and add scratch dir 2026-07-28 20:18:54 +02:00
darken 7fc13ac2c5 chore(claude): Correct modifier-position claim in code style rule 2026-07-28 20:18:54 +02:00
darken 7c75ec02bd chore(claude): Port code-style, test gotchas, and PR labels from sdmaid-se 2026-07-28 20:18:54 +02:00
darken f39ea20690 chore(claude): Restore always-on release guardrails 2026-07-28 20:18:54 +02:00
darken b573a79582 chore(claude): Path-scope rules and align with Opus 5 guidance 2026-07-28 20:18:54 +02:00
darken 82cc9764e5 fix(logging): Install logger before Hilt injection
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
2026-07-28 19:07:54 +02:00
darken c9a5db1ac8 test(mediacontrol): Name handler registration test after what it asserts
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
2026-07-28 19:07:54 +02:00
darken a95377cf7b fix(media): Move audio playback callback off the main thread
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.
2026-07-28 19:07:54 +02:00
darken 80ea6fbd68 General: Update app translations from Crowdin 2026-07-28 15:53:54 +02:00
darken c08c6f1129 refactor(strings): Move flavor-specific upgrade-status strings into flavor source sets 2026-07-24 17:53:19 +02:00
darken 73dc10aee9 fix(ui): Draw all screens edge-to-edge under system bars
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.
2026-07-24 17:53:13 +02:00
Matthias Urhahn 37278af34e Merge pull request #644 from d4rken-org/fix/upgrade-offercard-flash
Fix: Prevent red flash when opening the Pro upgrade screen
2026-07-24 15:15:02 +02:00
darken c822569a63 fix(upgrade): Avoid red unavailable flash during billing warm-up
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.
2026-07-24 14:42:58 +02:00
Matthias Urhahn eb41d84a0f Merge pull request #643 from d4rken-org/worktree-upgrade-offercard-ui
General: Redesign the Pro upgrade screen and restore flow
2026-07-24 14:32:20 +02:00
darken c072b90876 feat(upgrade): Adopt SD Maid offercard layout and restore UX
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.
2026-07-24 14:15:12 +02:00
Matthias Urhahn 6175bd3bb1 Merge pull request #642 from d4rken-org/worktree-billing-sdmaid-backport
General: Make Pro purchases more reliable when Google Play is flaky
2026-07-23 13:25:59 +02:00
darken 36c54d5d19 fix(upgrade): Harden billing storage, restore races, and offer retry 2026-07-23 13:01:14 +02:00
Matthias Urhahn f629f05f23 Merge pull request #641 from d4rken-org/crowdin-update-20260722
General: Update translations from Crowdin
2026-07-22 22:21:35 +02:00
Matthias Urhahn 2e969d3439 Merge pull request #640 from d4rken-org/worktree-flavor-string-placement
General: Tidy up FOSS and Play Store specific upgrade texts
2026-07-22 22:21:23 +02:00
darken 809305c4bd General: Update app translations from Crowdin 2026-07-22 21:14:42 +02:00
darken 3398da68ba General: Update fastlane translations from Crowdin 2026-07-22 20:56:07 +02:00
darken 061aa21c85 refactor(l10n): Move flavor-specific upgrade strings to source sets 2026-07-22 17:09:46 +02:00
darken 28b5b85be2 ui(overview): Redesign unmatched devices card with icon and description 2026-07-22 16:32:27 +02:00