1369 Commits
Author SHA1 Message Date
darken a427fcbf4e chore(screenshots): Commit only English Play Store screenshots
Only en-US screenshots are tracked from now on. The other 67 locales are
generated on demand and gitignored, which drops 35 PNGs and roughly 5 MB from
the repository and stops every screenshot refresh from churning binaries in
five languages nobody reviews.

Play Store still gets the full 68 locales. supply only uploads what is present
under fastlane/metadata/android/<locale>/images/phoneScreenshots/ and retains
whatever was last pushed for locales absent from an upload, so localization is
maintained by an occasional manual regen plus screenshots_only rather than by
every PR.

PlayStoreLocales.kt had been committed since February holding a truncated
four-locale batch slice, left behind when a run was killed before its EXIT trap
restored the file. The next run then copied that slice over its own backup and
faithfully restored the corruption, which is why the remnant survived six
months. It is replaced with a documented en-US placeholder, the generator no
longer emits an annotation nothing references, and the script now refuses to
start when a stale .bak is present instead of clobbering the only good copy.

The refresh runbook is corrected as well: it ended with a checkout that restores
tracked files from the index, which would have reverted the freshly rendered
English screenshots while the store received them, and its upload step is now
gated so a failed upload leaves them staged for a retry rather than committed as
though deployed.
2026-08-25 20:08:48 +02:00
darken c49cd62b2a chore: Rename support-investigator plugin entry to support 2026-08-25 20:08:48 +02:00
d4rken-org-releaser[bot] abf28f335c Release: 5.2.4-rc0 v5.2.4-rc0 2026-08-22 04:42:29 +00:00
Matthias Urhahn 540e6d1dbc Merge pull request #690 from d4rken-org/crowdin-update-20260821
General: Update translations from Crowdin
2026-08-21 20:54:10 +02:00
darken 76e1128e93 General: Update app translations from Crowdin 2026-08-21 19:14:22 +02:00
darken cbf681ccfc General: Update fastlane translations from Crowdin 2026-08-21 18:56:35 +02:00
darken 30f17b4791 fix(ui): Drop the removed monitor mode setting from the auto connect hint
The auto connect description told users the toggle would set the monitor mode
setting to 'Always'. That setting was removed in 5.1.6 and the mode is derived
from profile state now, so the sentence pointed at a settings entry that no
longer exists. The behaviour it described is unchanged: enabling auto connect
still resolves to continuous monitoring.

Base string only, so Crowdin flags the existing translations as outdated.
2026-08-21 10:52:55 +02:00
Matthias Urhahn cd6aaaf165 Merge pull request #687 from d4rken-org/worktree-settings-plain-language
General: Explain the compatibility settings in plain language
2026-08-21 01:20:03 +02:00
darken 289dcd660e ui(settings): Explain compatibility options in plain language
The three compatibility toggles described their own mechanism ("Don't
delegate data filtering to the system", "broadcast instead of callback")
without saying which symptom they address, so a non-technical user had no
way to tell whether a row applied to them. Each summary now leads with the
symptom and keeps a plain description of what changes. Titles are
unchanged: they name the mechanism and are referenced verbatim in issue
replies.

The category description "Don't touch if everything works ;)" stopped
rendering in 63692595 when the res/xml preference screens were replaced by
Compose, since SettingsCategoryHeader only took a title. The string and its
~75 translations survived unused. Added an optional subtitle to the header
and wired it back up.

Also rewrote the extra-notification summary, which named the "Device
status" channel but never said where to find it.

Reported via a Play Store review (vi, 4 stars, 5.2.3-rc0).
2026-08-21 01:13:13 +02:00
Matthias Urhahn b07428f894 Merge pull request #686 from d4rken-org/worktree-anc-adaptive-misreport
Device: Fix duplicate listening mode commands and silent failures
2026-08-20 18:16:20 +02:00
darken dbd1d422c6 refactor(aap): Split the echo classifier out of this change
Everything that reinterprets what the device reported moves to its own branch
(anc-echo-classifier), leaving only changes that stand on their own.

The classifier addresses a fault that has never been observed being handled: it
reproduced on two of four sessions and none since it was written. It also adds a
failure mode that did not exist before, where a report misattributed to our own
write makes CAPod show a mode the device is not in. That is a poor trade to carry
into main on the strength of tests alone, so it waits until it can be seen working
against a live fault.

What remains does not depend on the misreport:

- the verification deadline was 1000ms while the device answers in 833-1008ms, so
  a healthy reply could land just after the timer and trigger a bogus divergence
  plus a redundant re-send; this was captured live
- a listening mode request the device did not confirm produced no feedback at all
  for any mode except Off, which was a gap in the event plumbing rather than a
  timing artifact
- a mode outside the device's listening mode cycle was rendered as an ordinary
  selectable button whenever it happened to be the current mode
- an Off report arriving while a different mode was requested could teach the Allow
  Off inference, persisting "Off is permitted" into the device profile

Also drops effectiveAncMode, which only had an effect while the classifier was
present.
2026-08-20 12:38:30 +02:00
darken 0b83e86c5e fix(aap): Correct an overstated safety claim and a test that proved nothing
Review follow-ups on the previous commit.

Its claim that "nothing is learned or persisted" was wrong. BatteryEstimator
buckets drain samples by the current listening mode and force-persists the
accumulated window whenever that mode changes, so a misattributed mode can write
a drain rate to disk under the wrong bucket, and the corrective report does not
remove it. This is not new: the misreport being fixed here already mis-buckets in
the same way, and more often, since the device claims OFF while the pods play
Adaptive. Classifying corrects the common case and only gets it wrong on the rarer
misattribution. The design stands, the claim does not.

Two other claims were also too strong. The recorded echo is the first report after
the verification was installed, which is not exactly the wire write, so write
contention above the latency boundary can still inflate a fast refusal. And a
superseded write is not left alone entirely: classification is skipped, but it
still falls through to the ordinary retry path.

The supersession regression test asserted nothing: it fed a fresh ADAPTIVE report
in before its only assertion, overwriting either outcome, so it passed whether or
not the guard existed. It now asserts on the state left by the delayed echo, and
fails with the guard removed.

Also drops an unused import and restores an indent lost when the remap argument
was removed.
2026-08-20 00:06:03 +02:00
darken b396ce457d fix(aap): Attribute a listening mode echo before drawing conclusions from it
Extends the previous commit's classifier so it only judges evidence it can
actually attribute to our own write.

The classifier judged whatever mode happened to be current when the deadline
fired, and never looked at timing, despite the refusal-versus-misreport
distinction resting on it. A delayed answer to an earlier write, or a mode change
made on the pods themselves mid-request, could be taken for the answer to the
outstanding write.

Now the first report after a write is recorded with its latency, and that
recorded frame is what gets classified. A write is left alone entirely when it
was superseded by another listening mode write or by a stem press, since its
echoes can no longer be attributed. An answer arriving faster than 500ms is a
refusal, never a change: captures put refusals at 25-267ms and real changes at
815-1010ms. A re-send restamps its own send time and drops the previous attempt's
echo, so a retry is never judged on stale evidence. Latency is measured with the
monotonic clock, so a wall clock correction cannot turn a fast refusal into an
apparent change.

A misattribution is still possible, because AAP reports carry no correlation id
and a change made from iOS or another paired phone is invisible here. That is why
nothing is learned or persisted from this: the worst case is one wrong reading
that the device's next report corrects.

An earlier version of this work also carried a session-scoped remap, so that a
stem-initiated switch could be read correctly after our own write had proven the
device mislabels a value (issue #594). It is not included. The same
unattributability that bounds the classifier to a single wrong reading would have
let one misattribution rewrite every later report in the session, and a wrongly
resolved Off could persist AllowOffOption into the device profile, outliving the
session that produced it. Stem-initiated switches on an affected session are
therefore still not shown correctly.

Refs #594
2026-08-19 23:52:52 +02:00
darken 734e15c94d fix(aap): Tell a listening mode refusal apart from an unusable echo
Follow-up to the previous commit, which left the wrong half of this in place.
Distrusting the report only while the request was outstanding meant that four
seconds later the rejection cleared the pending mode, the bogus value came back,
and the user was shown an error for a mode change that had actually worked.

The two cases have different signatures, both readable from state the engine
already holds:

- A refusal echoes the mode the device is staying in, quickly. Captured Off
  refusals answer in 25-267ms with the previous mode.
- The Pro 3 misreport answers with a third mode, neither the one requested nor
  the one it was in, at normal change latency (815-1010ms).

So an echo that is neither the requested nor the previous mode is treated as an
unusable report rather than a refusal: no re-send of a write that already took
effect, no rejection, no error, and the requested mode is recorded as current.
Refusals still work, which is what the Off rejection message and the Allow Off
learning depend on.

This is deliberately engine-local. Seeding the cycle mask and Allow Off belief
from the device profile into the session would have encoded the rule directly,
but it inverts the current engine-to-profile data flow and creates a belief that
no device report can ever correct, since AirPods never report 0x1A or 0x34.
librepods keeps the same knowledge in its service layer and preferences, not in
its protocol manager.

The fault is per-session rather than per-request: across four sessions today the
pods either misreported every Adaptive write or none of them. The heuristic is
covered by unit tests but has not yet been observed handling a live bad session.
2026-08-19 20:33:32 +02:00
darken edf84248ab fix(anc): Don't show a phantom Off mode when AirPods misreport the listening mode
AirPods Pro 3 can answer a listening mode write with 0x0D 0x01 (Off) while
audibly switching to the requested mode. Seen on firmware
81.2675000075000000.6503, intermittently, and not reproducible on demand.

CAPod took that report at face value: it surfaced an Off button that isn't even
in the device's listening mode cycle, selected it, and said nothing about the
request not having been confirmed.

- visibleAncModes no longer re-admits a mode purely because it is the current
  one. That escape clause was what conjured the extra button.
- effectiveAncMode keeps showing the requested mode while our own request is
  outstanding and the device reports a mode it should not be able to reach.
- A rejected listening mode request now surfaces a message for every mode, not
  only Off. Other modes were dropped silently.
- The Allow Off inference ignores an Off report that arrived while a different
  mode was pending, so a single glitch cannot permanently persist "Off is
  allowed" into the device profile. An unsolicited Off still trains it, which
  is what keeps the option discoverable after it is enabled elsewhere.
- Verification deadline moved from a hardcoded 1000ms to 2000ms, and a matching
  device report now settles the verification when it arrives. Measured reply
  latency is 833-1008ms, so the old deadline sat inside the device's normal
  spread and could fire a bogus divergence plus a redundant re-send.

Settling verification on arrival is limited to SetAncMode deliberately. Every
other verified command is optimistically written into state when it is queued,
so its predicate is satisfied immediately and only the device's contradicting
echo makes it fail. Settling those early would swallow the rejection.

Note that AirPods never report AllowOffOption (0x34) or ListeningModeCycle
(0x1A), so which modes are permitted is always inferred, never device truth.
2026-08-19 19:09:20 +02:00
Matthias Urhahn 83dfdb9219 Merge pull request #682 from d4rken-org/feat/focus-duck-fallback
Reaction: Lower conversation volume even on phones that block it
2026-08-19 09:05:16 +02:00
darken 70b9c2981e fix(upgrade): Align the androidx.hilt declarations with the resolved 1.2.0
addWorkerManager() needed androidx.hilt 1.2.0 for its KSP compiler, which left
addDagger() asking for 1.0.0 while conflict resolution silently handed it 1.2.0,
and hilt-compiler declared twice at two different versions.

The version moves to Versions.AndroidX.Hilt.core, addDagger() stays the single
place that registers hilt-common and the hilt-compiler, and addWorkerManager()
declares only hilt-work. Declared and resolved versions now match, so the
dependency report no longer shows a 1.0.0 -> 1.2.0 substitution.

No dependency graph change: 1.2.0 is what already resolved.
2026-08-18 17:55:19 +02:00
darken 971fcbd34c fix(upgrade): Add a persistent acknowledgement safety net for Play purchases
Play auto-refunds (and revokes) purchases not acknowledged within 3 days.
The in-process ack machinery covers every case where the process lives
long enough; what it cannot cover is a process death around the Play
sheet (aggressive OEM task killers) followed by the user not reopening
the app before the deadline.

Add a gplay-only WorkManager safety net:
- PurchaseAckWorker: self-completing sweep via a new bounded
  BillingManager.ensureAllAcknowledged() that refreshes and acknowledges
  in the same coroutine (the reactive ack collector is async, so a worker
  cannot prove its acks happened through it). Retries with exponential
  backoff until the purchase's refund deadline, then gives up visibly.
- PurchaseAckScheduler: two unique work identities. A launch watch
  (REPLACE, armed and awaited before launchBillingFlow with a 30min delay
  so it cannot complete while the user is still in the sheet) and a
  discovered-purchase rescue (KEEP, 1min delay, armed directly from an
  ack pass that finds unacknowledged purchases, pre-attempt). Separate
  identities so a new purchase flow can never displace a pending rescue.
  Both triggers are fail-open: a broken WorkManager never blocks a
  purchase or an ack. WorkManager resolves via Provider at first arm so
  eager Application-time construction of the billing stack cannot
  trigger WorkManager's on-demand initialization prematurely.
- Nothing cancels the work from the foreground path: an ack pass can see
  zero unacked purchases while the sheet is still open, so the worker
  completes itself after its own reconciliation instead.

The ack pass now runs under a mutex (the worker sweep and the reactive
collector would otherwise race the token bookkeeping) and reports
per-outcome counts for the sweep result mapping.

This is a port of d4rken-org/sdmaid-se#2685; the ported sources are
byte-identical to the donor apart from the package rename.

CAPod had no explicit WorkManager wiring at all (work-runtime only
arrived transitively through Glance), so this also adds it:
- addWorkerManager() pinning androidx.work 2.7.1, the version already
  resolved via Glance, plus androidx.hilt:hilt-work and its KSP
  compiler. work-runtime-ktx is required at 2.7.1: CoroutineWorker,
  Operation.await, OneTimeWorkRequestBuilder and workDataOf all still
  live in the ktx artifact at that version. androidx.hilt moves 1.0.0 ->
  1.2.0 (by conflict resolution) because 1.0.0's hilt-compiler ships no
  KSP SymbolProcessorProvider, so @HiltWorker would generate nothing.
- WorkManagerModule providing the singleton WorkManager.
- App implements Configuration.Provider with the injected
  HiltWorkerFactory. WorkManager 2.7.1 still declares that interface as
  getWorkManagerConfiguration(), not the later property form.
- The manifest removes androidx.work's startup initializer so the
  on-demand configuration is the one that takes effect.

FOSS stays untouched behaviour-wise: all new billing types live in
src/gplay, workers need no manifest entry, and the worker factory
resolves the worker only in gplay variants.
2026-08-18 17:55:19 +02:00
Matthias Urhahn cebed0a60d Merge pull request #683 from d4rken-org/worktree-anc-selector-redesign
General: Redesign the noise control mode selector
2026-08-18 15:18:38 +02:00
darken 8817da309f ui(anc): Redesign the listening mode selector
Replaces the outlined segmented row with a filled track carrying a single
sliding thumb. The old control stacked three signals for one state (container
fill, bold, underline) in an outline treatment nothing else in the card uses,
which is what made it read as bolted on.

The track now sits at the same tonal step as the battery panel above it and
reuses the card's 16dp/12dp radii, so selection is carried by thumb position
and fill alone. Only the active mode is named and its slot expands to fit,
which keeps long translations off the width budget entirely.

Slot widths, label reveal and content tint are all derived from one clamped
fractional position, so geometry and content stay in lockstep. The thumb's
leading edge is that position in collapsed-slot units rather than a sum of
the animating widths, which would overshoot the target and crawl back.

Also fixes an invisible pending state: the in-flight branch set
activeContainerColor to secondaryContainer, which is exactly what
SegmentedButtonDefaults already used for the selected segment, so a requested
mode was indistinguishable from a confirmed one. An unconfirmed request now
draws a hollow pulsing thumb and tints the mode the pods are still in.

Transparency moves off Icons.TwoTone.Hearing, which is already the "In ear"
status chip and rendered identically two rows below it in the same card.
2026-08-18 15:04:50 +02:00
darken 27cc0e8d00 test(reaction): Cover the audio-focus duck fallback
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.
2026-08-18 12:37:33 +02:00
darken cecd3088be feat(reaction): Duck via audio focus when the volume write is ignored
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.
2026-08-18 12:36:30 +02:00
Matthias Urhahn 412ea369f1 Merge pull request #681 from d4rken-org/worktree-ca-duck-noop-diag
Reaction: Retry conversation volume lowering when the system ignores it
2026-08-17 18:40:57 +02:00
darken 22ca3a2a46 fix(bluetooth): Fix bogus scan-gap delays in debug logs
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.
2026-08-17 13:00:02 +02:00
darken 92feea8a10 fix(reaction): Don't report a volume duck the system ignored
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.
2026-08-17 13:00:02 +02:00
d4rken-org-releaser[bot] 3af1af872e Release: 5.2.3-rc0 v5.2.3-rc0 2026-08-16 19:51:18 +00:00
Matthias Urhahn 1f00925cd8 Merge pull request #680 from d4rken-org/crowdin-update-20260816
General: Update translations from Crowdin
2026-08-16 20:47:28 +02:00
darken 50079e2b7a fix(upgrade): Remove orphaned translation strings failing lint vitals
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.
2026-08-16 20:17:47 +02:00
darken 02ec76980c General: Update app translations from Crowdin 2026-08-16 19:52:33 +02:00
darken 52ad90f77e General: Update fastlane translations from Crowdin 2026-08-16 19:39:06 +02:00
darken 563c6e71f7 fix(upgrade): Invalidate a dead billing connection from refreshStrict too
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.
2026-08-16 17:44:02 +02:00
darken a357d77b00 test(upgrade): Cover pending purchases across the billing stack and screen
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.
2026-08-16 17:44:02 +02:00
darken abc82b0f09 fix(upgrade): Show supporter status on every upgrade screen route
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.
2026-08-16 17:44:02 +02:00
darken 3e6541deec feat(upgrade): Explain a pending Google Play payment on the upgrade screen
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.
2026-08-16 17:44:02 +02:00
darken 33a972001e feat(upgrade): Carry pending Google Play purchases through the billing stack
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.
2026-08-16 17:44:02 +02:00
Matthias Urhahn fe67ce0365 Shrink issue screenshots uploaded as HTML img tags 2026-08-11 14:43:10 +02:00
darken 511944c21c fix(upgrade): Share the settings row title with the upgrade screen
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.
2026-08-07 22:16:00 +02:00
d4rken-org-releaser[bot] 007282d3f5 Release: 5.2.2-rc1 v5.2.2-rc1 2026-08-07 18:28:44 +00:00
darken 5ffdf66b9d Fix release builds failing on a missing Play Core annotation
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.
2026-08-07 19:30:15 +02:00
d4rken-org-releaser[bot] 7e90d115e0 Release: 5.2.2-rc0 v5.2.2-rc0 2026-08-07 16:58:23 +00:00
darken ffc3ffb718 test(upgrade): Sweep locales in both flavours now the template is shared
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.
2026-08-07 18:27:33 +02:00
darken 6b358cb101 refactor(upgrade): Move the title template out of the flavour source sets
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.
2026-08-07 18:27:33 +02:00
darken 7cbcde50d3 General: Update app translations from Crowdin 2026-08-07 18:27:33 +02:00
darken 5365a5da29 chore(upgrade): Add the title template to main ahead of the flavour move
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.
2026-08-07 18:27:33 +02:00
darken 908acc4ea0 test(upgrade): Assert the splice honoured the template, not just its output
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.
2026-08-07 16:51:44 +02:00
darken 5a635b424b fix(upgrade): Retire the composed app_name_pro and app_name_foss keys
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.
2026-08-07 16:51:44 +02:00
darken f1e4ffb099 General: Update app translations from Crowdin 2026-08-07 16:51:44 +02:00
darken d012799d49 fix(upgrade): Compose the Pro title from a translatable template
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.
2026-08-07 16:51:44 +02:00
darken 06fd256889 fix(upgrade): Show a failed error-dialog fix action inline instead of a toast
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.
2026-08-07 09:49:57 +02:00
darken 2f715ccac9 fix(upgrade): Report empty Play product results as a merchandising error
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.
2026-08-07 09:49:57 +02:00