1369 Commits
Author SHA1 Message Date
d4rken-org-releaser[bot] 8a57257f03 Release: 5.2.6-rc0 v5.2.6-rc0 2026-09-10 14:31:40 +00:00
darken fc4d2674e9 General: Update app translations from Crowdin 2026-09-10 16:13:30 +02:00
darken 1a176e3df8 chore: Remove retired Claude Cafe plugin settings 2026-09-08 18:27:44 +02:00
Matthias Urhahn 1fff40b9d4 Merge pull request #707 from d4rken-org/chore/clanker-cafe-plugins
General: Move project assistant plugins to Clanker Cafe
2026-09-07 13:12:59 +02:00
darken 9a9d4452d1 chore: Migrate Claude plugins to Clanker Cafe
Enable the six migrated plugins at project scope and retain explicit disabled entries for the old marketplace. The local DebugBadger pilot has been promoted to a project installation.

Validation: a fresh Claude Code session loaded all six new plugin identities, their commands and agents, the migrated hooks and all four MCP services. Read-only schema discovery passed without device actions or service writes.
2026-09-07 13:06:08 +02:00
darken 43740d8b2e chore(build): Enable R8 obfuscation for the Google Play flavor
Google Play scores the obfuscation share of uploaded bundles and flags
listings below its threshold ("App optimisation is below our threshold",
fix by Feb 2027). Minify and shrink were already on; the shared
-dontobfuscate was the only thing keeping the score at 0%.

-dontobfuscate moves to a FOSS-only rule file; the Play rule file keeps
SourceFile/LineNumberTable for retracing and pins names only where they
reach users or logs: AapSetting/AapCommand subclasses (session logs),
ViewModel1 subclasses (log tag), Throwables (error dialog label). All
reflective targets (BuildConfig, ArtMirror, NeverCall, InvokeStub,
AncModeActionCallback) already carry @Keep or explicit keeps and are
identity-mapped in the gplayRelease mapping. The release workflow
archives the mapping next to the Play upload.
2026-09-06 10:41:37 +02:00
darken 83849efe80 refactor(logging): Replace receiver-derived log tags with explicit tags
The Any.log overload built its tag from this::class.java.name, which turns
into CAP:a once the class is obfuscated. Every call site now passes a
logTag(...) constant and the overload is gone so it cannot be reintroduced.
2026-09-06 10:41:37 +02:00
darken ed66731207 chore(fastlane): Roll out beta track releases to 100%
Both supply lanes staged uploads at 10% of the beta track, which left the
remaining 90% waiting on a manual Play Console rollout step for every
release. Beta-track users have already opted in, so staging within that
audience adds a manual gate without adding safety.

Promotion from the beta track to production stays manual.
2026-09-03 18:20:01 +02:00
d4rken-org-releaser[bot] a41bef21b5 Release: 5.2.5-rc0 v5.2.5-rc0 2026-09-03 11:25:44 +00:00
darken 91b49536b7 chore: Disable kotlin-lsp plugin 2026-09-03 13:10:25 +02:00
darken 8690072ee2 General: Update app translations from Crowdin 2026-09-03 13:10:25 +02:00
darken 92c829c7e6 General: Update fastlane translations from Crowdin 2026-09-03 13:10:25 +02:00
Matthias Urhahn 1c67a78130 Merge pull request #703 from d4rken-org/docs/crowdin-context-ownership
General: Note that translator context is edited on Crowdin
2026-09-03 11:09:08 +02:00
darken 7a64a23d1e docs: Record that translator context lives on Crowdin
Every string in the three Android source files now carries a custom
context written on Crowdin. A custom value is sticky per string: Crowdin
stops re-deriving that string's context from its XML comment on later
source pushes, so editing the comment in values/strings.xml silently
reaches no one.

Nothing in the tree recorded that, which makes the next comment edit a
trap. The rule file is where a reader lands when they touch strings.xml,
so it says where context is edited now.
2026-09-03 11:02:58 +02:00
Matthias Urhahn e2fcbbfdfd Merge pull request #702 from d4rken-org/worktree-remove-dead-strings
General: Stop asking translators for strings the app no longer uses
2026-09-03 10:59:33 +02:00
darken 29639cdcd1 chore(strings): Remove 49 unused string resources
None of these are referenced from any source set: a fixed-string grep over
app/src (Kotlin, Java, XML including the manifest, excluding res/values*)
finds zero hits for each, and the app has no getIdentifier-style dynamic
resource lookup, so nothing can reach them by name at runtime.

43 lost their last usage in an identifiable commit, mostly the Compose
migration (63692595), the upgrade/billing rework (c072b908, 3651bb3d,
e35bce4d, abb189f4) and the device-settings card grouping (65a74e9b,
32ff7e5b, 8a3a6cad). The other 6 were never referenced at all after the
commit that added them: debug_debuglog_recording_progress,
troubleshooter_ble_result_failure_body,
profilessettings_maindevice_identitykey_description,
profiles_maindevice_encryptionkey_description, pods_dual_left_short_label
and pods_dual_right_short_label.

Removed from the three base files and from all 75 locale files each, so a
Crowdin pull does not reintroduce them.
2026-09-03 00:22:11 +02:00
darken d8a6ef2286 feat(monitor): Show remaining case charges in the notification
The case row in the expanded notification carried a percentage alone, which
doesn't answer whether the case can still top the earbuds up. It now appends
the count the overview card already computes, in the same " . <value>" shape
the earbud rows use for their time estimates.

The figure is shown as a fraction rather than the card's integer because the
integer barely moves on a small case: AirPods Pro 3 publishes a 2.0 charge
case, so the count only ever reads 0, 1 or 2, and everything below 50% reads
0. A notification suffix makes no adequacy claim, so it can report the number
without the interval logic the coloured card line needs.

The decimal is cut rather than rounded. A 4.0 case at 24% covers [0.96, 1.00),
which the card calls short of a full charge, and rounding to the nearest tenth
would print "1.0" beside that. Cutting also keeps the lower-bound wording
honest, since 3.8 at 20% is 0.76 and may not claim "0.8+". The epsilon before
the cut absorbs binary float error only: 2.0 at 35% lands on 0.69999999 and
still has to read 0.7.

An empty case drops the "+" even on a lower-bound spec, matching the card's
treatment of zero as exact rather than as an underestimate.

Apple's Optimized Charge Limit is deliberately not modelled. It pauses around
80% only when the routine predicts you won't need the pods yet, so it applies
to some top-ups and not others, and the unit stays whole pair charges.

The case label gets a width cap because the row's progress bar is its only
weighted child. The added word is translatable, and a long rendering at a
large font scale would otherwise take the row and leave the bar at zero width.
2026-09-02 22:05:16 +02:00
darken e68927595c chore: Update default Play Store release notes and translations 2026-09-02 20:25:41 +02:00
darken aa928de265 General: Update app translations from Crowdin 2026-09-02 10:31:46 +02:00
darken 3662904d75 fix(debug): Make the recorder the only writer of the debug flag
The module's flag collector was pure redundancy: State.recorder is only
ever written right after a Recorder.start()/stop() that already published
the same value. Its one added behaviour was a hazard - a late-delivered
committed stop could overwrite the flag a newer session's start() had just
set, blanking diagnostics for up to the five seconds the header read is
bounded at.

Recorder.start()/stop() stay the single writers, next to the file logger
they install.

The collector's test goes with it: it hard-coded a two-collector queue
depth that can no longer be reached. In its place, a test asserting the
flag against the recorder itself, and one that forces the late-stop
delivery through a hand-stepped app-scope dispatcher.

Fixes review findings F2, F3, F4
2026-09-01 10:11:01 +02:00
darken cec36268e0 fix(debug): Keep the flag collector off uncommitted recorder states
The collector mirrored isRecording from every state emission, including the
start and stop requests, whose value the recorder has already moved past. On a
loaded device that write can land after Recorder.start()'s, leaving isDebug
false for the whole header-read window (5s) while the file logger is live.

distinctUntilChangedBy collapses the initial state and the start request into
one emission, drop(1) removes it, so only committed transitions are published.
The operator order matters: drop(1) first would let the start request through.

Fixes review finding F1.
2026-09-01 10:11:01 +02:00
darken 5895ccbc27 fix(tests): Match only power snapshot lines in the log filter 2026-09-01 10:11:01 +02:00
darken 798ac32ec9 fix(tests): Pin the new Robolectric tests to SDK 33 2026-09-01 10:11:01 +02:00
darken 6ed0bf8257 feat(debug): Log the device power state while recording
A reception blackout in a capture is as easily the display going off as
a broken scan, and nothing in a capture currently says which. The full
snapshot is written on every screen, doze and power-save broadcast, plus
once when the recording starts, so a single line format answers both
"what is it now" and "what just changed".

The receiver is registered before the first snapshot is taken:
ACTION_SCREEN_OFF is not sticky, so a screen-off in between would appear
as neither a transition nor a corrected state.

The catch sits on the inner receiver flow rather than after
flatMapLatest. Flow.catch completes the flow it is applied to, so a
top-level catch would end the recording-flag collection on the first
failure and every later recording in the process would carry no power
state at all.

Nothing here may throw: onReceive is an Android callback outside any
flow, so a vendor PowerManager that throws would take the process down.
Each field is guarded individually so a bad read costs one value rather
than the line.
2026-09-01 10:11:01 +02:00
darken 39a6c3c89f feat(device): Log the effective BLE scan configuration
A capture that shows no scan results cannot currently answer whether the
scan was filtered. The configuration is decided once when the scan
starts, which is usually long before the recording that is meant to
diagnose it, so the line is re-emitted when a recording begins while the
scan is already running.

The re-emission drops against the value captured at the first emission
instead of drop(1): launchIn subscribes asynchronously, so a StateFlow
replays whatever is current at subscription time. A recording started in
that gap would be swallowed as if it were the initial value, which is
exactly the case the line exists for.

filterPolicy is a parameter rather than something derived from the
filter set, because the unfiltered mode is implemented as a single
match-all filter. A count-based summary would report it as a filtered
scan, inverting the answer.

"Requested" and not "filtering"/"batching": adapter capability plus our
own setting is what we asked the platform for, not proof that the
controller offloaded anything.
2026-09-01 10:11:01 +02:00
darken 2ba90e1d18 feat(debug): Flip the debug flag when the recorder starts writing
The flag was only written from committed recorder module state, which
publishes after the recording header has been read. That read is bounded
at 5s, so for up to five seconds the file logger is live while the flag
still says false, and diagnostics keyed off it are missing from exactly
the window a reporter uses to reproduce a screen-off issue.

The module-state writer stays: it covers a resumed session and any path
that reaches isRecording=false without going through Recorder.stop().
A rolled-back start self-corrects, because the rollback stops the
recorder before publishing the failure.

BaseTest resets the flag per test instance because it is JVM-global. It
goes into init rather than the companion teardown, which uses JUnit 5's
@AfterAll and never fires under the JUnit 4 Robolectric runner.
2026-09-01 10:11:01 +02:00
darken 92dac661c4 fix(device): Keep a log line for rejected malformed addresses
Structurally broken addresses now fail the octet parse instead of
throwing, so the catch-all error log no longer fires for them. Log the
rejection at WARN with a redacted address so support logs still show it.

Fixes review finding F1.
2026-08-31 15:54:51 +02:00
darken 7712b18388 fix(monitor): Re-arm the teardown when a start request is short-circuited
In AUTOMATIC mode with nothing connected the session is torn down 15 seconds
after the state that said so, and only a new state emission cancels that. A
start request arriving while a session is live is acknowledged and returns
without touching the pending teardown, and the connection state that would
abort it lags the Bluetooth event that caused the start by roughly 0.75s. A
request landing in the tail of the window was therefore answered with
"keeping current session" and the session went down anyway, taking every
reaction with it — in one recording the popup reaction was down for 34
seconds spanning an entire lid cycle.

The short-circuit now bumps a start signal that the mode pipeline combines
in, so the bump cancels the pending inner flow through the existing
cancellation topology and arms a fresh window. The countdown also re-reads
the signal after its delay, which closes the case where the bump lands while
an expired countdown is already unwinding. A re-armed window runs 15 seconds
from the start request, so the total dwell can exceed 15 seconds: the request
is fresh evidence of activity.

The decision segment moves to a top-level internal function so it can be
driven directly in tests, following MonitorModeState and
buildMonitorModeState which are top-level for the same reason.
2026-08-31 15:54:51 +02:00
darken d7988189a0 fix(reaction): Stamp connect times from the ACL broadcast
The connection timestamp cache was only maintained while the connected-devices
flow had a subscriber. If the process died while a device stayed connected and
that device then disconnected and reconnected with nothing running, the
restarted collection found the old entry still keyed by a currently-connected
address, kept it through the prune, and reported the original connect time —
so the popup age check rejected an arbitrarily old connection.

The ACL broadcasts arrive at a manifest-registered receiver that wakes the
process regardless of any flow subscription, so the stamp is taken there
instead: connect stamps (keeping an existing one, which is the earlier and
therefore truer time), disconnect drops the entry. The flow keeps its own
stamp-on-first-sight and prune as a backstop for the force-stopped state and
missed broadcasts.

ACL_DISCONNECTED was already in the receiver's expected actions but was never
registered in the manifest. It does not start the monitor: a disconnect is not
a reason to start monitoring, and the start triggers here are deliberately
conservative.
2026-08-31 15:54:51 +02:00
darken ff5d0cedfa fix(reaction): Stop bonded-device queries from poisoning connect times
seenFirstAt on a connected device is meant to be the connect time, and the
connected-devices flow maintains that by pruning its cache to the currently
connected addresses on every emission. bondedDevices() wrote into the same
cache for every bonded device, connected or not, and never pruned.

AapAutoConnect queries bonded devices on every connected-devices emission,
the disconnect one included, so an entry pruned at disconnect was re-stamped
milliseconds later at disconnect time. The next reconnect then inherited the
previous disconnect as its connect time, and the popup reaction rejects a
connection older than 30 seconds — so any reconnect more than half a minute
after the previous disconnect silently lost its popup.

bondedDevices() is now a cache reader: a connected bonded device still
reports its true connect time, a non-connected one gets the current time,
which no caller reads. The connected-devices path becomes the cache's only
writer, which is the invariant its prune-and-stamp logic already assumed.
2026-08-31 15:54:51 +02:00
darken f82adeba42 fix(monitor): Persist AAP keys only when their content changed
AppleDeviceProfile is a data class whose key fields are ByteArrays, so the
generated equals() compares them by reference and the "did anything change"
guard was true on every key exchange. That meant a redundant profile write on
every connect and a "Persisted keys" line that said nothing about whether a
key had actually changed.

Compare content instead, per key, and name the key that changed in the log.
2026-08-31 15:54:51 +02:00
darken cc99ad59ab fix(device): Resolve identity keys against reversed address octets
On at least one vendor stack the address handed up by the BLE scan callback
and the identity key stored for a profile disagree on octet order: the key
resolves the address only when its octets are reversed. Identity resolution
then fails on every advertisement, so no frame is attributed to the profile
and the case popup, connection popup, encrypted 1% battery granularity and
session reconnection all go with it.

RPAChecker gains resolve(), which reports the order that resolved. The
standard-order attempt is unchanged and ungated, so no currently-resolving
device can start failing. The reversed attempt only runs when the reversed
form carries the resolvable-private-address type marker (top bits 01), which
skips roughly three quarters of the extra comparisons for generic random
addresses. verify() is now a thin wrapper over resolve().

Address parsing is validated explicitly: exactly six components, each in
0..255. A seven-component string used to be silently mis-sliced, and
"15A:..." truncated to a valid octet — both cases could resolve.

The failure log no longer prints the identity key. A malformed address or
key wrote an identity-tracking secret into exactly the debug logs users mail
to support; the event and its level stay, the key is reduced to its length
and the address goes through redactedForLogs().

The history lookup that recovers a device by key now skips candidates bound
to a different profile. A 24-bit forward collision could already select the
wrong history; attempting two orders roughly doubles that exposure.

Test vectors are synthetic and derived from the key already committed in
RPACheckerTest. Two of them are complementary: one has an RPA-shaped
reversed form that fails the hash (proving the comparison runs), the other
has a reversed form that resolves cryptographically but carries marker 00
(proving the gate runs).
2026-08-31 15:54:51 +02:00
Matthias Urhahn b66c756741 Merge pull request #695 from d4rken-org/worktree-case-charges-align
General: Tidy up the captions under the battery levels
2026-08-31 06:34:50 +02:00
darken fbbdb4e131 ui(overview): Give the last known caption more room above
At 4.dp the caption crowded the bottom edge of the battery surface it
describes. 8.dp separates the two without pushing it far enough to read as
its own block, which 12.dp (the gap used before the ANC selector) would.
Both pod cards carry the caption, so both move together.
2026-08-30 08:12:23 +02:00
darken 0bf6bf7b5f ui(overview): Center the case charges line in the battery card
The line sat at a fixed 36.dp start indent, which put its left edge under
the case percentage label (28.dp icon + 8.dp spacer). That follows the M3
"supporting text aligns with the item's text column" convention, but the
case row is not a list item, and the sentence runs nearly full width, so
the lone left gutter read as an accident rather than an alignment.

Centering on the capsule instead would have been the other candidate, but
the capsule is weight(1f) between the percentage label and the status-chip
FlowRow, so its x-offset and width are both runtime values that vary with
locale and font scale. No static padding reaches it; it would take
onGloballyPositioned or a custom Layout, which is not worth it for a
caption. Centering in the surface is stable and needs neither.
2026-08-30 08:12:15 +02:00
darken 865a609203 fix(ui): Keep the device detail sheet reachable while scrolled
The detail sheet was composed inside the device-info lazy item, so tapping the
battery runtime warning banner after that item had scrolled out of composition
did nothing visible while still setting the visibility flag, which made the
sheet pop up unprompted on scrolling back up. Build the detail items and compose
the sheet at screen scope instead; the card now only reports the tap.

Fixes review finding F1
2026-08-25 23:42:23 +02:00
darken 9f213cf499 ui(settings): Reframe battery health as estimated listening time
The figure compares observed listening drain against Apple's rated hours, so
it cannot separate a degraded cell from loud volume, cold weather or a hungry
codec. Runtime wording is true either way, "Battery Health" is not.

String keys are kept as they are so the translated locale files aren't
orphaned.
2026-08-25 23:42:23 +02:00
darken 1330c77ed8 feat(battery): Warn when a pod's listening time drops by half
The per-pod listening-time estimate was only visible behind the info icon on
the device settings header card. A pod that reaches half its rated listening
hours or less now raises a banner on the device settings screen, tapping it
opens the same detail sheet.

A displayed number needs less backing than one that raises a warning, so a
reading is only promoted into the banner when its slot has accumulated at
least 8 listening sessions across its qualifying rates and the newest of
those rates is at most 60 days old. The banner reads the already-gated state
field, so the per-profile battery estimate toggle suppresses it too.

The detail sheet's visibility moves out of DeviceInfoCard so both the info
icon and the banner can open it.
2026-08-25 23:42:23 +02:00
darken 2356726417 fix(overview): Call an empty case not enough on older models
AirPods Gen 1 and Gen 2 publish their case capacity as a lower bound, and
the adequacy check let that guard run before any zero-reading check. At a
0% case those two models rendered the definite "no charges left" text in
the neutral uncertainty colour while the screen reader hedged with "may
not be enough", contradicting the same node three ways.

The empty reading is now handled before the lower-bound guard. It sits
after the "enough" branch, which a zero reading can never satisfy, so it
cannot mask a positive claim. The lower-bound rule exists to avoid
underselling a case whose published capacity is only a floor, and an
empty case has nothing to undersell.

Fixes review finding F4
2026-08-25 22:33:42 +02:00
darken 03c09917da fix(overview): Stop denying a charge the reading still allows
The visible count came from the pessimistic end of the interval alone, so
an interval straddling a full charge said "Less than one more full charge"
while the colour stayed neutral and TalkBack said "may not be enough". An
uncertain interval now rounds to a single charge instead, and an
open-ended spec that has not reached one charge names no number at all.

Fixes review finding F2
2026-08-25 22:33:42 +02:00
darken 70830cf2ca fix(overview): Call a case exactly one charge short "not enough"
The reading is an interval that excludes its upper end, so an upper end
landing exactly on one full charge still leaves every reachable value
below one. AirPods Gen 4 at 10% and Pro 3 at 40% read neutral instead of
orange because of it.

Fixes review finding F1
2026-08-25 22:33:42 +02:00
darken 43140005f2 chore(overview): Sort the imports added for the case charges line 2026-08-25 22:33:42 +02:00
darken 59e2a16a39 test(overview): Cover the battery level states and the case charges line
Colour is not exposed through the semantics tree, and captureToImage() times
out under Robolectric, so the rendered result is pinned in two halves: which
tier each of the five slots reports, and which colour a tier resolves to per
theme mode. The theme test drives an in-app dark override on a light host,
which is the case a plain isSystemInDarkTheme() read would get wrong.

The case charges line is covered for presence, the three adequacy states, both
special renderings, and under RTL, a narrow card and a doubled font scale.
2026-08-25 22:33:42 +02:00
darken 11d6ddf3fa feat(overview): Say how many earbud charges the case still holds
The case percentage alone doesn't answer the question people actually have,
which is whether the case can top the earbuds up again. The card now says so in
words, below the case row.

The reading is treated as the interval it really is: a decile source at 20% on
a 4.0-charge case means 0.8 to 1.2 charges, which is not an answer, so the line
stays neutral instead of flipping colour as the reading bounces between
adjacent frames. Only an interval that lies wholly above or below one full
charge is coloured, which removes the need to remember anything between frames.

The line is stacked rather than inline: the case row already has four children
with only the capsule weighted, so a plural in front of it would collapse the
capsule at large font scales and in locales with long plural forms.
2026-08-25 22:33:42 +02:00
darken b10b42eede feat(monitor): Expose how coarse a case battery reading is
PodDevice.batteryCase merges an AAP notification, the encrypted advertisement
payload, the public advertisement nibble and the cache into one Float, and the
provenance is gone by the time anyone reads it. The public nibble only carries
deciles, so 20% there can mean anything up to 29%.

batteryCaseReading walks the same precedence and keeps the step size of the
source that won, leaving batteryCase untouched for every existing caller.
2026-08-25 22:33:42 +02:00
darken 5801b8ea56 feat(device): Record how many earbud charges a case holds
Derived per model as hoursWithCase / hoursListening - 1, with both figures read
under the same noise control condition Apple quotes them under, matching their
stated test method (drained AirPods recharged to 100% and playback resumed
until both the AirPods and the case were fully discharged).

Models whose published total reads "more than N hours" are marked as a lower
bound so nothing downstream can turn an unbounded figure into a confident
claim. AirPods Pro is deliberately left without a spec: its 4.5 h ANC listening
figure is verified but the matching with-case total is not, and an unsourced
number is worse than no line at all.

The spec gates on itself rather than on hasCase — twenty models set hasCase,
including Beats Solo Buds, whose case holds no battery.
2026-08-25 22:33:42 +02:00
darken b9461d3259 feat(a11y): Announce a low battery instead of only colouring it
Crossing the warn or critical threshold was expressed as a colour change only,
so it did not exist for a screen reader. Each battery slot on the overview card
now carries the level as a state description.
2026-08-25 22:33:42 +02:00
darken 7960144be6 ui(overview): Give a low battery a colour that means "low"
The warn band was drawn in colorScheme.tertiary, which is whatever the palette
seed produces: olive under the amber theme, where the healthy band (primary) is
burnt orange, so a warning read as decoration and healthy read as a warning.

The warn band now uses fixed light/dark tokens, and the percentage text is
tinted at warn and critical instead of the level living in the gauge alone.
Values are derived against the composited backgrounds the cards actually draw
on: the gauge Surface at 4dp tonal elevation, and the same surface at alpha 0.7
that a card without live data uses. Measuring against raw surface would have
admitted values below 3:1 on screen.

The tokens are resolved from the theme mode CapodTheme already computed, not
from isSystemInDarkTheme(), so an in-app dark override on a light system does
not pick the light tokens.
2026-08-25 22:33:42 +02:00
darken 0f95538f39 feat(battery): Add a shared battery tier for level colours
The overview duplicates the same 30%/15% thresholds in four places, with two
subtly different unknown checks: the capsule and the pod gauge accept any
percent >= 0f, so Float.POSITIVE_INFINITY renders as a full, healthy ring.
batteryTier() settles that on the finite check the other two already use.
2026-08-25 22:33:42 +02:00
darken fec512175b fix: Prevent crash in Amharic battery time formatting
The Amharic translation of battery_time_remaining_format_hm dropped the
conversion character from its second placeholder: "%1$dሰ %2$ደ" instead of
"%1$dሰ %2$dደ". Java's formatter reads "%2$" followed by "ደ" as an unknown
conversion and throws UnknownFormatConversionException.

formatBatteryDurationShort() takes that branch whenever an estimate has both
hours and minutes, so any duration between 1h1m and 23h59m threw. It is
reached from the dashboard pod cards, the home screen widget, and the
foreground service notification, which makes it a crash on the common path
rather than an edge case.

Found because the Play Store rejected the Amharic screenshot upload: the
widget configuration preview had rendered as a 1x1 image, the layoutlib
render having aborted on the same exception.

Corrected at source in Crowdin, and in CAPod's translation memory, which
still mapped the English source to the broken string at 100% match and would
have re-injected it on the next pre-translate run.
2026-08-25 21:52:33 +02:00