GitHub sends X-RateLimit-Reset on every REST response, an ordinary 403
included, and it always points at the current window. Feeding it through
retryAtMillis() therefore produced a non-null deadline for any 403, and
the classifier accepted that as proof of a limit. A permissions failure
with the quota untouched came back as RateLimited, so the caller persisted
a cooldown on that route and served stale metadata until a reset window
the failure had nothing to do with.
Classification now looks only at signals that actually mean this request
was the one refused: a spent quota, or an explicit Retry-After. Nothing
real is lost, because GitHub marks a primary limit with
X-RateLimit-Remaining: 0 and a secondary limit with Retry-After. The reset
header keeps its job of supplying the deadline once a limit is
established some other way.
ApkDownloadSourceTest already claimed this contract - its name is "403 is
only treated as a limit when response headers say so" - but its
permissions case passed no reset header at all, which is the one input
that hides the bug. Adds the case it was missing, which fails without this
change, and pins the secondary-limit path so tightening the reset header
cannot blind the client to a Retry-After.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The cooldown was tracked in two places. ApkRateLimitStore persisted it per
scope and per route, and the ViewModel kept a second copy in
downloadRetryAtMillis plus a retryAtMillis on Resumable and Error, frozen
into WorkManager output data on the way through. The copy was the weaker
of the two: it lived in memory, so a cold start lost it, and the deadline
it froze belonged to whichever route earned it, which is exactly the drift
the per-route store exists to prevent.
It also could not expire on its own. downloadRetryBlocked was computed
with System.currentTimeMillis() during composition, so nothing recomposed
when the deadline passed; scheduleRetryUnlock papered over that with a
viewModelScope delay that died with the process. Meanwhile the disabled
row and icon gave the user no countdown to read, so a tap simply did
nothing.
Drops the copy. The store is consulted where the request is actually made
and the UI stays enabled, which costs a worker that fails in well under a
tenth of a second without touching the network.
RateLimitedWithWait goes with it. Its "try again in %2$s min" was computed
at failure time and baked into static text that never ticked down, so it
was wrong within a minute; RateLimited says "try again later" and stays
true. Removing it leaves nothing pre-formatted, so Resumable and Error now
carry an ApkFailureMessage of string id plus arguments and the row
resolves it during composition. Failure text follows the device locale
rather than the locale the worker happened to run under.
Anchors the GitHub cooldown at the moment it is judged. now was sampled
before awaitRoute(), which can hold a request for the full 60-second route
timeout, so a relative Retry-After interpreted against it could land in
the past and let the very next check reach GitHub - the loop this branch
set out to close. Reads the clock again once the route is ready and once
the response arrives, and uses each where it applies.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Codex flagged this on the string-extraction change and it is right.
WorkManager keeps failed records in its own database across app updates,
and AAPT2 reassigns R.string ids on every build. A failure written by one
build and read by the next would resolve its persisted int against a
different resource table: wrong string, or NotFoundException, or
IllegalFormatException when the placeholder arity no longer matches. The
existing zero-check only caught an absent key, not a stale valid one.
ApkDownloadFailureReason now names each failure and owns its string, and
the boundary carries the enum name. This is the same treatment
DownloadPhase.fromKey already gives the phase across the same boundary,
including tolerating a name this build no longer has.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Progress moves out of the trailing slot and under the subtitle, so every
status now shows exactly one 48dp control there instead of a spinner and
a button competing for the same space. The trailing slot had three
different widths across states, which made the text column re-wrap on
every status change; it is now one width throughout.
"Get universal" and "Retry" become icon buttons. That removes the labels
they were leaning on, so both gain tooltips and real content
descriptions, and prepareRowTapAction() now drives the row's enabled flag
and its tap handler from one mapping. Previously the row rendered as
clickable in the ready state but onPrepareRowClicked ignored it, leaving
the icon as the only way to reach the universal download.
Resumable downloads get a progress bar for the first time, drawn flat via
amplitude 0 so a stalled download does not look like a live one.
Strings: every user-facing literal now lives in strings.xml. Download
failures were assembled as English sentences in the util layer, which has
no Context by design, so they crossed the WorkManager boundary already
formatted and could never be translated. ApkDownloadException now carries
a string resource and its arguments, and the ViewModel resolves them
against the device locale. util/ stays Context-free and its tests stay
plain JUnit.
Also drops translatable="false" from seven strings that were visible
prose, and stops showing raw exception text when the APK status cannot be
read.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>