Compare commits

...
26 Commits
Author SHA1 Message Date
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
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
86 changed files with 2709 additions and 315 deletions
+16 -5
View File
@@ -32,13 +32,15 @@ ScreenshotContent.kt (mock data + composables)
|------|---------|
| `app/src/debug/java/.../screenshots/ScreenshotContent.kt` | Mock data composables (7 exist; `HomescreenWidgetContent` has an IDE preview only and is **not** in the Play Store pipeline) |
| `app/src/screenshotTest/kotlin/.../screenshots/PlayStoreScreenshots.kt` | `@PreviewTest` functions (currently: `DashboardLight`, `DashboardDark`, `CasePopUp`, `DeviceProfiles`, `AddProfile`, `DeviceSettingsReactions`, `WidgetConfiguration`) |
| `app/src/screenshotTest/kotlin/.../screenshots/PlayStoreLocales.kt` | Multi-preview annotations (auto-generated by batch script) |
| `app/src/screenshotTest/kotlin/.../screenshots/PlayStoreLocales.kt` | Multi-preview annotations. The committed content is an en-US placeholder, not meaningful data — `generate_screenshots.sh` rewrites it per batch and restores it from a `.bak` on exit. A run killed hard leaves that `.bak` behind, so the script now refuses to start until it is restored by hand |
| `fastlane/generate_screenshots.sh` | Batched generation; locale list (`ALL_LOCALES`) and `BATCH_SIZE` are defined inside the script |
| `fastlane/copy_screenshots.sh` | Copies rendered PNGs into fastlane structure |
## Commit policy
Only the 6 smoke locales (en-US, de-DE, ja-JP, ar, zh-CN, pt-BR) have `phoneScreenshots/*.png` checked into the repo. Non-smoke locales are excluded by `.gitignore`. This mirrors permission-pilot and keeps repo size small (~7 MB vs ~67 MB for the full 68 locales).
Only `en-US` has `phoneScreenshots/*.png` checked into the repo — 7 PNGs, ~1 MB tracked. Every other locale is excluded by `.gitignore`.
`--smoke` still *renders* 6 locales (en-US, de-DE, ja-JP, ar, zh-CN, pt-BR), but only en-US is committed. The other five cover LTR, RTL and CJK layout so a render that breaks on non-Latin script fails during generation, and the resulting PNGs sit in the working tree for manual inspection. Nothing compares them against a baseline, so this is render coverage plus eyeballing, not regression checking.
Play Store's `supply` only uploads what's present in `fastlane/metadata/android/<locale>/images/phoneScreenshots/`. For locales not in the upload, Play Store retains whatever was last pushed. So full localization on Play Store is maintained by an **occasional manual** full regen + `:screenshots_only` upload — not by every PR.
@@ -90,11 +92,20 @@ Periodic, manual operation — not per-PR:
```bash
./fastlane/generate_screenshots.sh # full, ~30 min, 476 PNGs (68 locales x 7)
./fastlane/copy_screenshots.sh --clean
bundle exec fastlane screenshots_only # uploads all 68 locales to Play Store
git checkout -- fastlane/metadata/android/ # discard non-smoke changes (gitignored anyway)
git add fastlane/metadata/android/en-US/images/phoneScreenshots/
if bundle exec fastlane screenshots_only; then
git checkout -- fastlane/metadata/android/ &&
git commit --only -m "chore(screenshots): Refresh Play Store screenshots" -- \
fastlane/metadata/android/en-US/images/phoneScreenshots/
else
git checkout -- fastlane/metadata/android/
echo "Upload failed; the refreshed en-US screenshots remain staged for retry."
fi
```
The `.gitignore` rule keeps non-smoke output unstaged automatically, so only the smoke locales' refreshed PNGs would show up as modifications and can be committed.
The `git add` has to happen before the upload. The final `git checkout` restores every tracked file under that path **from the index**, so staging the refreshed English set is precisely what makes it survive the checkout — skip the `git add` and the checkout silently reverts the refresh while the store still receives the new images.
Restoring is the checkout's job otherwise: `screenshots_only` runs `remove_unsupported_languages.sh`, which deletes 9 tracked locale directories (es-AR, sc-IT, sq-AL, uz, kmr-TR, ur-IN, zu, si-LK, nb) from the working tree before uploading — 35 tracked files, a subset of the 309 tracked non-screenshot metadata files under that path, all of them put back by the checkout. It does **not** touch the regenerated non-English PNGs: those are untracked and ignored, so they stay on disk and never show up in `git status`. Because the checkout discards any uncommitted metadata text edits too, run this refresh only with an otherwise-clean metadata tree. The final commit is path-limited on purpose, so an unrelated staged change can't ride along, and it is gated on `screenshots_only` succeeding rather than merely sequenced after it: if the upload fails, the refreshed English files stay staged for a retry instead of being committed as though they were deployed. The deleted locale directories are restored on either path.
## Technical Notes
+1 -1
View File
@@ -12,7 +12,7 @@
"jvm-tools@claude-code-cafe": true,
"frontend-design@claude-plugins-official": true,
"kotlin-lsp@claude-plugins-official": true,
"support-investigator@claude-code-cafe": true,
"support@claude-code-cafe": true,
"google-play@claude-code-cafe": true,
"devtools@claude-code-cafe": true
}
+1 -6
View File
@@ -17,16 +17,11 @@
.kotlin
# Screenshot test reference images (ephemeral, regenerated on demand)
app/src/screenshotTest*/reference/
# Play Store phone screenshots: commit only the 6 smoke locales (mirrors permission-pilot).
# Play Store phone screenshots: commit only en-US.
# Full localization is uploaded by occasional manual regen + screenshots_only;
# Play Store retains previously-uploaded screenshots for locales not pushed.
fastlane/metadata/android/*/images/phoneScreenshots/*.png
!fastlane/metadata/android/en-US/images/phoneScreenshots/*.png
!fastlane/metadata/android/de-DE/images/phoneScreenshots/*.png
!fastlane/metadata/android/ja-JP/images/phoneScreenshots/*.png
!fastlane/metadata/android/ar/images/phoneScreenshots/*.png
!fastlane/metadata/android/zh-CN/images/phoneScreenshots/*.png
!fastlane/metadata/android/pt-BR/images/phoneScreenshots/*.png
.codex
protocol-research/
_site/
+1
View File
@@ -86,6 +86,7 @@
android:label="Service trigger">
<intent-filter>
<action android:name="android.bluetooth.device.action.ACL_CONNECTED" />
<action android:name="android.bluetooth.device.action.ACL_DISCONNECTED" />
</intent-filter>
</receiver>
@@ -237,6 +237,26 @@ class BluetoothManager2 @Inject constructor(
private val seenDevicesLock = Mutex()
private val seenDevicesCache = mutableMapOf<String, Instant>()
/**
* Stamps the connect time from the ACL broadcast, which reaches a manifest-registered receiver
* whether or not [connectedDevices] is being collected. An existing stamp wins: the earlier one
* is the real connect time.
*/
fun markDeviceConnected(address: BluetoothAddress) {
appScope.launch {
seenDevicesLock.withLock {
if (seenDevicesCache.containsKey(address)) return@withLock
seenDevicesCache[address] = timeSource.now()
}
}
}
fun markDeviceDisconnected(address: BluetoothAddress) {
appScope.launch {
seenDevicesLock.withLock { seenDevicesCache.remove(address) }
}
}
val connectedDevices: Flow<List<BluetoothDevice2>> = isBluetoothEnabled
.flatMapLatest { enabled ->
if (enabled) monitorProfile(BluetoothProfile.HEADSET)
@@ -311,12 +331,10 @@ class BluetoothManager2 @Inject constructor(
address = device.address,
name = device.name,
internal = device,
// Read-only: a bonded device is not necessarily connected, and writing here would
// re-stamp entries that [connectedDevices] just pruned.
seenFirstAt = seenDevicesLock.withLock {
seenDevicesCache[device.address] ?: run {
val now = timeSource.now()
seenDevicesCache[device.address] = now
now
}
seenDevicesCache[device.address] ?: timeSource.now()
}
)
@@ -264,6 +264,51 @@ object MockPodDataProvider {
),
)
/**
* Dual pods with hand-picked battery levels. The case reads at decile granularity, like the
* public advertisement it would come from.
*/
fun dualPodBatteries(
left: Float? = 0.80f,
right: Float? = 0.45f,
case: Float? = 0.60f,
model: PodModel = PodModel.AIRPODS_PRO2,
label: String = "My AirPods Pro",
): PodDevice = PodDevice(
profileId = "preview-dual-batteries",
label = label,
ble = MockDualBlePodSnapshot(
_model = model,
_label = label,
batteryLeftPodPercent = left,
batteryRightPodPercent = right,
_batteryCasePercent = case,
leftPodIcon = model.leftPodIconRes ?: R.drawable.device_airpods_gen1_left,
rightPodIcon = model.rightPodIconRes ?: R.drawable.device_airpods_gen1_right,
_caseIcon = model.caseIconRes ?: R.drawable.device_airpods_gen1_case,
_address = "AA:BB:CC:DD:EE:FF",
),
aap = null,
)
/** A single-pod device with a hand-picked battery level. */
fun singlePodBattery(
percent: Float,
model: PodModel = PodModel.AIRPODS_MAX,
label: String = "AirPods Max",
): PodDevice = PodDevice(
profileId = "preview-single-battery",
label = label,
ble = MockSingleBlePodSnapshot(
_model = model,
_label = label,
batteryHeadsetPercent = percent,
_isBeingWorn = true,
_address = "AA:BB:CC:DD:EE:FF",
),
aap = null,
)
fun singlePodMonitored(): PodDevice = PodDevice(
profileId = "preview-single",
label = "AirPods Max",
@@ -0,0 +1,60 @@
package eu.darken.capod.common.theming
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color
import eu.darken.capod.monitor.core.battery.BatteryTier
/**
* Battery colours that must not move with the palette. `colorScheme.tertiary` is whatever the seed
* happens to produce (olive under the amber theme, teal under blue), so a level warning drawn from
* it reads as decoration rather than as a warning.
*
* The values are picked against the composited backgrounds the overview actually draws on, see
* `BatteryColorsContrastTest`. Critical stays on `colorScheme.error` and good on
* `colorScheme.primary` — both already carry the meaning we want.
*/
@Immutable
data class BatteryColors(
val warnFill: Color,
val warnText: Color,
val positiveText: Color,
) {
companion object {
val Light = BatteryColors(
warnFill = Color(0xFF874400),
warnText = Color(0xFF522300),
positiveText = Color(0xFF003B0C),
)
val Dark = BatteryColors(
warnFill = Color(0xFFFFA726),
warnText = Color(0xFFFFCC80),
positiveText = Color(0xFFA5D6A7),
)
}
}
/**
* Provided by [CapodTheme] from the theme mode it already resolved. Reading
* `isSystemInDarkTheme()` here instead would pick light tokens for an in-app dark override on a
* light system.
*/
val LocalBatteryColors = staticCompositionLocalOf { BatteryColors.Light }
@Composable
fun BatteryTier.fillColor(): Color = when (this) {
BatteryTier.UNKNOWN -> MaterialTheme.colorScheme.surfaceVariant
BatteryTier.CRITICAL -> MaterialTheme.colorScheme.error
BatteryTier.WARN -> LocalBatteryColors.current.warnFill
BatteryTier.GOOD -> MaterialTheme.colorScheme.primary
}
/** Null where the level makes no claim, so callers keep their own default text colour. */
@Composable
fun BatteryTier.textColorOrNull(): Color? = when (this) {
BatteryTier.CRITICAL -> MaterialTheme.colorScheme.error
BatteryTier.WARN -> LocalBatteryColors.current.warnText
BatteryTier.UNKNOWN, BatteryTier.GOOD -> null
}
@@ -6,6 +6,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
@@ -32,8 +33,12 @@ fun CapodTheme(
}
}
MaterialTheme(
colorScheme = colorScheme,
content = content,
)
CompositionLocalProvider(
LocalBatteryColors provides if (darkTheme) BatteryColors.Dark else BatteryColors.Light,
) {
MaterialTheme(
colorScheme = colorScheme,
content = content,
)
}
}
@@ -45,7 +45,9 @@ import eu.darken.capod.common.settings.SettingsSection
import eu.darken.capod.main.ui.devicesettings.cards.AapUnavailableCard
import eu.darken.capod.main.ui.devicesettings.cards.BatteryCard
import eu.darken.capod.main.ui.devicesettings.cards.BatteryHealthTexts
import eu.darken.capod.main.ui.devicesettings.cards.BatteryRuntimeWarningBanner
import eu.darken.capod.main.ui.devicesettings.cards.ControlsCard
import eu.darken.capod.main.ui.devicesettings.cards.DeviceInfoBottomSheet
import eu.darken.capod.main.ui.devicesettings.cards.DeviceInfoCard
import eu.darken.capod.main.ui.devicesettings.cards.NoiseControlCard
import eu.darken.capod.main.ui.devicesettings.cards.NotConnectedCard
@@ -240,6 +242,65 @@ fun DeviceSettingsScreen(
val features = device?.model?.features
val enabled = device?.isAapReady == true
val isPro = state.isPro
// Hoisted out of DeviceInfoCard: the runtime warning banner opens the same detail sheet as the
// card's info icon, and both entry points can be scrolled out of composition independently.
var showDeviceDetails by rememberSaveable { mutableStateOf(false) }
val context = LocalContext.current
val stateDetection = device?.ble as? HasStateDetection
val seenFirst = device?.seenFirstAt
val seenLast = device?.seenLastAt
val firstSeen = if (device != null && seenFirst != null && seenLast != null &&
Duration.between(seenFirst, seenLast).toMinutes() >= 1
) {
device.firstSeenFormatted(state.now)
} else null
val locale = LocalConfiguration.current.locales[0]
val zoneId = ZoneId.systemDefault()
val dateFormatter = remember(locale, zoneId) {
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
.withLocale(locale)
.withZone(zoneId)
}
val detailLabels = rememberDeviceInfoDetailLabels()
val batteryHealthTexts = when {
state.batteryHealth != null -> BatteryHealthTexts(
left = state.batteryHealth.left?.let {
stringResource(R.string.device_settings_info_battery_health_value, it.percent)
},
right = state.batteryHealth.right?.let {
stringResource(R.string.device_settings_info_battery_health_value, it.percent)
},
headset = state.batteryHealth.headset?.let {
stringResource(R.string.device_settings_info_battery_health_value, it.percent)
},
)
state.batteryHealthPending -> BatteryHealthTexts(
pending = stringResource(R.string.device_settings_info_battery_health_pending),
)
else -> null
}
val detailItems = if (device != null) {
buildDeviceInfoDetailItems(
info = device.deviceInfo,
labels = detailLabels,
formatDate = { instant -> dateFormatter.format(instant) },
batteryHealth = batteryHealthTexts,
)
} else {
emptyList()
}
LaunchedEffect(detailItems) {
if (detailItems.isEmpty()) showDeviceDetails = false
}
if (showDeviceDetails && detailItems.isNotEmpty()) {
DeviceInfoBottomSheet(
items = detailItems,
onDismiss = { showDeviceDetails = false },
)
}
Scaffold(
topBar = {
@@ -282,45 +343,6 @@ fun DeviceSettingsScreen(
// Device Info
if (device != null) {
item("device_info") {
val context = LocalContext.current
val stateDetection = device.ble as? HasStateDetection
val seenFirst = device.seenFirstAt
val seenLast = device.seenLastAt
val firstSeen = if (seenFirst != null && seenLast != null && Duration.between(seenFirst, seenLast)
.toMinutes() >= 1
) {
device.firstSeenFormatted(state.now)
} else null
val info = device.deviceInfo
val locale = LocalConfiguration.current.locales[0]
val zoneId = ZoneId.systemDefault()
val dateFormatter = remember(locale, zoneId) {
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
.withLocale(locale)
.withZone(zoneId)
}
val detailItems = buildDeviceInfoDetailItems(
info = info,
labels = rememberDeviceInfoDetailLabels(),
formatDate = { instant -> dateFormatter.format(instant) },
batteryHealth = when {
state.batteryHealth != null -> BatteryHealthTexts(
left = state.batteryHealth.left?.let {
stringResource(R.string.device_settings_info_battery_health_value, it)
},
right = state.batteryHealth.right?.let {
stringResource(R.string.device_settings_info_battery_health_value, it)
},
headset = state.batteryHealth.headset?.let {
stringResource(R.string.device_settings_info_battery_health_value, it)
},
)
state.batteryHealthPending -> BatteryHealthTexts(
pending = stringResource(R.string.device_settings_info_battery_health_pending),
)
else -> null
},
)
DeviceInfoCard(
deviceInfo = device.deviceInfo,
modelLabel = buildModelLabel(device),
@@ -331,6 +353,7 @@ fun DeviceSettingsScreen(
detailItems = detailItems,
canRename = device.isAapReady,
onRename = onDeviceNameChange,
onShowDetails = { showDeviceDetails = true },
)
}
}
@@ -345,6 +368,19 @@ fun DeviceSettingsScreen(
}
}
// Listening time has dropped far below the model's rating — details live in the sheet
val runtimeWarning = state.batteryRuntimeWarning
if (device != null && device.hasSelectedPairedDevice && runtimeWarning != null) {
item("battery_runtime_warning") {
BatteryRuntimeWarningBanner(
slot = runtimeWarning.slot,
percent = runtimeWarning.reading.percent,
onClick = { showDeviceDetails = true },
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
}
}
// Not nearby — no live BLE; settings require the device to be present
if (device != null && device.hasSelectedPairedDevice &&
device.ble == null && !state.isClassicallyConnected
@@ -160,9 +160,15 @@ class DeviceSettingsViewModel @Inject constructor(
null
}
}
val now = timeSource.now()
// The runtime figure rides on the same learned data as the estimate — the per-device
// toggle governs both.
val batteryHealth = device
?.takeIf { appleProfile?.batteryEstimateEnabled ?: true }
?.let { BatteryHealth.estimate(drainProfiles[profileId], it.model, now) }
State(
device = device,
now = timeSource.now(),
now = now,
isPro = upgrade.isPro,
isNudgeAvailable = nudgeAvailability != NudgeAvailability.BROKEN,
isForceConnecting = forcing,
@@ -174,12 +180,10 @@ class DeviceSettingsViewModel @Inject constructor(
(it.rightLong !is StemAction.None && it.rightLong !is StemAction.CycleAnc)
} == true,
batteryEstimateEnabled = appleProfile?.batteryEstimateEnabled ?: true,
// Health rides on the same learned data as the estimate — the per-device toggle
// governs both.
batteryHealth = device
?.takeIf { appleProfile?.batteryEstimateEnabled ?: true }
?.let { BatteryHealth.estimate(drainProfiles[profileId], it.model) },
// A model with a rating WILL eventually produce a health figure — surface the
batteryHealth = batteryHealth,
batteryRuntimeWarning = batteryHealth?.lowestPromotable
?.takeIf { it.reading.percent <= BatteryHealth.LOW_RUNTIME_PERCENT },
// A model with a rating WILL eventually produce a runtime figure — surface the
// feature as "still determining" until the listening sessions accumulate. Without
// a paired device the listening gate can never open, so no promise is made.
batteryHealthPending = device != null &&
@@ -212,10 +216,14 @@ class DeviceSettingsViewModel @Inject constructor(
val systemBluetoothName: String? = null,
val hasCustomLongPressStemAction: Boolean = false,
val batteryEstimateEnabled: Boolean = true,
/** Derived per-pod battery health (1..100 each), or null when there isn't enough learned data. */
/** Derived per-pod share of the rated listening time (1..100 each), or null when there
* isn't enough learned data. */
val batteryHealth: BatteryHealth.PerPod? = null,
/** True when health CAN be derived for this device (rated model, feature on) — shows the
* "still determining" placeholder while [batteryHealth] is null. */
/** The worst pod whose runtime has dropped to [BatteryHealth.LOW_RUNTIME_PERCENT] or below
* on enough recent evidence to warn about — drives the warning banner. */
val batteryRuntimeWarning: BatteryHealth.SlotReading? = null,
/** True when a runtime figure CAN be derived for this device (rated model, feature on) —
* shows the "still determining" placeholder while [batteryHealth] is null. */
val batteryHealthPending: Boolean = false,
) {
val reactions: ReactionConfig get() = device?.reactions ?: ReactionConfig()
@@ -0,0 +1,98 @@
package eu.darken.capod.main.ui.devicesettings.cards
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Warning
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import eu.darken.capod.R
import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper
import eu.darken.capod.monitor.core.battery.BatteryHealth
@Composable
internal fun BatteryRuntimeWarningBanner(
slot: BatteryHealth.Slot,
percent: Int,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val podLabel = when (slot) {
BatteryHealth.Slot.LEFT -> stringResource(R.string.pods_dual_left_label)
BatteryHealth.Slot.RIGHT -> stringResource(R.string.pods_dual_right_label)
BatteryHealth.Slot.HEADSET -> stringResource(R.string.device_settings_battery_runtime_warning_headset_label)
}
Surface(
onClick = onClick,
color = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.4f),
shape = RoundedCornerShape(12.dp),
modifier = modifier
.fillMaxWidth()
.semantics(mergeDescendants = true) {},
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.Outlined.Warning,
contentDescription = null,
tint = MaterialTheme.colorScheme.tertiary,
modifier = Modifier
.padding(end = 10.dp)
.size(20.dp),
)
Column(
modifier = Modifier.weight(1f),
) {
Text(
text = stringResource(R.string.device_settings_battery_runtime_warning_title),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.9f),
)
Text(
text = stringResource(
R.string.device_settings_battery_runtime_warning_description,
podLabel,
percent,
),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
)
}
}
}
}
@Preview2
@Composable
private fun BatteryRuntimeWarningBannerEarbudPreview() = PreviewWrapper {
BatteryRuntimeWarningBanner(
slot = BatteryHealth.Slot.LEFT,
percent = 42,
onClick = {},
)
}
@Preview2
@Composable
private fun BatteryRuntimeWarningBannerHeadsetPreview() = PreviewWrapper {
BatteryRuntimeWarningBanner(
slot = BatteryHealth.Slot.HEADSET,
percent = 50,
onClick = {},
)
}
@@ -10,7 +10,6 @@ import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.twotone.Edit
import androidx.compose.material.icons.twotone.Info
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.Icon
@@ -53,13 +52,9 @@ internal fun DeviceInfoCard(
detailItems: List<DeviceDetailItem> = emptyList(),
canRename: Boolean = false,
onRename: (String) -> Unit = {},
onShowDetails: () -> Unit = {},
) {
var showRenameDialog by remember { mutableStateOf(false) }
var showBottomSheet by remember { mutableStateOf(false) }
LaunchedEffect(detailItems) {
if (detailItems.isEmpty()) showBottomSheet = false
}
if (showRenameDialog && deviceInfo != null) {
RenameDialog(
@@ -72,13 +67,6 @@ internal fun DeviceInfoCard(
)
}
if (showBottomSheet && detailItems.isNotEmpty()) {
DeviceInfoBottomSheet(
items = detailItems,
onDismiss = { showBottomSheet = false },
)
}
ElevatedCard(
modifier = Modifier
.fillMaxWidth()
@@ -103,7 +91,7 @@ internal fun DeviceInfoCard(
modifier = Modifier.weight(1f),
)
if (showInfoIconInModelRow) {
IconButton(onClick = { showBottomSheet = true }) {
IconButton(onClick = onShowDetails) {
Icon(
imageVector = Icons.TwoTone.Info,
contentDescription = stringResource(R.string.device_settings_info_details_action),
@@ -144,7 +132,7 @@ internal fun DeviceInfoCard(
} else null,
)
if (showInfoIconInNameRow) {
IconButton(onClick = { showBottomSheet = true }) {
IconButton(onClick = onShowDetails) {
Icon(
imageVector = Icons.TwoTone.Info,
contentDescription = stringResource(R.string.device_settings_info_details_action),
@@ -159,7 +147,7 @@ internal fun DeviceInfoCard(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
IconButton(onClick = { showBottomSheet = true }) {
IconButton(onClick = onShowDetails) {
Icon(
imageVector = Icons.TwoTone.Info,
contentDescription = stringResource(R.string.device_settings_info_details_action),
@@ -29,6 +29,7 @@ import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedIconButton
import androidx.compose.material3.Surface
@@ -41,7 +42,11 @@ import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import eu.darken.capod.R
@@ -49,6 +54,7 @@ import eu.darken.capod.monitor.core.visibleAncModes
import eu.darken.capod.main.ui.overview.cards.components.AncModeSelector
import eu.darken.capod.main.ui.overview.cards.components.BatteryCapsule
import eu.darken.capod.main.ui.overview.cards.components.CompactBatterySummary
import eu.darken.capod.main.ui.overview.cards.components.batteryTierState
import eu.darken.capod.main.ui.overview.cards.components.DebugSection
import eu.darken.capod.main.ui.overview.cards.components.DeviceConnectionBadge
import eu.darken.capod.main.ui.overview.cards.components.MissingPairedDeviceBanner
@@ -59,11 +65,21 @@ import eu.darken.capod.common.SystemTimeSource
import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper
import eu.darken.capod.common.compose.preview.MockPodDataProvider
import eu.darken.capod.common.theming.LocalBatteryColors
import eu.darken.capod.common.theming.fillColor
import eu.darken.capod.common.theming.textColorOrNull
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.monitor.core.battery.BatteryEstimate
import eu.darken.capod.monitor.core.battery.BatteryTier
import eu.darken.capod.monitor.core.battery.CaseCharges
import eu.darken.capod.monitor.core.battery.batteryTier
import eu.darken.capod.monitor.core.battery.caseCharges
import eu.darken.capod.monitor.core.batteryCaseReading
import eu.darken.capod.monitor.core.cachedBatteryFormatted
import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.pods.core.apple.aap.AapPodState
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import eu.darken.capod.pods.core.apple.ble.batteryProgress
import eu.darken.capod.pods.core.apple.ble.devices.DualApplePods
import eu.darken.capod.pods.core.apple.ble.devices.DualApplePods.LidState
import eu.darken.capod.pods.core.apple.ble.devices.HasPodStyle
@@ -272,7 +288,7 @@ private fun ColumnScope.DualPodsCardExpanded(
// Cached battery indicator
if (device.isBatteryCached && !device.isLive) {
Spacer(modifier = Modifier.height(4.dp))
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.battery_cached_label, device.cachedBatteryFormatted(now)),
style = MaterialTheme.typography.bodySmall,
@@ -313,22 +329,18 @@ private fun PodGauge(
untilCharged: String? = null,
) {
val context = LocalContext.current
val clamped = if (batteryPercent >= 0f) batteryPercent.coerceIn(0f, 1f) else -1f
val tier = batteryTier(batteryPercent)
val isKnown = tier != BatteryTier.UNKNOWN
val animatedProgress by animateFloatAsState(
targetValue = if (clamped >= 0f) clamped else 0f,
targetValue = batteryProgress(batteryPercent),
animationSpec = tween(600, easing = FastOutSlowInEasing),
label = "gaugeProgress",
)
val ringColor = when {
clamped < 0f -> MaterialTheme.colorScheme.surfaceVariant
clamped > 0.30f -> MaterialTheme.colorScheme.primary
clamped >= 0.15f -> MaterialTheme.colorScheme.tertiary
else -> MaterialTheme.colorScheme.error
}
val ringColor = tier.fillColor()
Column(
modifier = modifier,
modifier = modifier.batteryTierState(tier),
horizontalAlignment = Alignment.CenterHorizontally,
) {
// Ring with icon inside
@@ -347,7 +359,7 @@ private fun PodGauge(
)
// Progress ring
if (clamped >= 0f) {
if (isKnown) {
CircularProgressIndicator(
progress = { animatedProgress },
modifier = Modifier.size(68.dp),
@@ -373,7 +385,7 @@ private fun PodGauge(
Text(
text = formatBatteryPercent(context, batteryPercent),
style = MaterialTheme.typography.titleMedium,
color = if (batteryPercent >= 0f) {
color = tier.textColorOrNull() ?: if (isKnown) {
MaterialTheme.colorScheme.onSurface
} else {
MaterialTheme.colorScheme.onSurfaceVariant
@@ -414,66 +426,129 @@ private fun CaseRow(
device: PodDevice,
) {
val context = LocalContext.current
val tier = batteryTier(device.batteryCase)
val charges = caseCharges(device.model.caseSpec, device.batteryCaseReading)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Image(
painter = painterResource(device.caseIcon),
contentDescription = null,
modifier = Modifier.size(28.dp),
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = formatBatteryPercent(context, device.batteryCase),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(end = 8.dp),
)
BatteryCapsule(
percent = device.batteryCase,
Column(modifier = Modifier.fillMaxWidth()) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.weight(1f)
.height(8.dp),
)
Spacer(modifier = Modifier.width(8.dp))
FlowRow(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
.fillMaxWidth()
.batteryTierState(tier),
) {
when (val caseState = device.caseChargingState
?: device.isCaseCharging?.let { if (it) AapPodState.ChargingState.CHARGING else null }) {
AapPodState.ChargingState.CHARGING_OPTIMIZED -> StatusChip(
icon = Icons.TwoTone.BatteryChargingFull,
label = stringResource(R.string.pods_charging_optimized_label),
)
AapPodState.ChargingState.CHARGING -> StatusChip(
icon = Icons.TwoTone.BatteryChargingFull,
label = stringResource(R.string.pods_charging_label),
)
else -> Unit
}
Image(
painter = painterResource(device.caseIcon),
contentDescription = null,
modifier = Modifier.size(28.dp),
)
val lidState = device.caseLidState
if (lidState == LidState.OPEN || lidState == LidState.CLOSED) {
StatusChip(
icon = Icons.TwoTone.GridView,
label = when (lidState) {
LidState.OPEN -> stringResource(R.string.pods_case_status_open_label)
LidState.CLOSED -> stringResource(R.string.pods_case_status_closed_label)
else -> ""
},
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = formatBatteryPercent(context, device.batteryCase),
style = MaterialTheme.typography.bodyMedium,
color = tier.textColorOrNull() ?: LocalContentColor.current,
modifier = Modifier.padding(end = 8.dp),
)
BatteryCapsule(
percent = device.batteryCase,
modifier = Modifier
.weight(1f)
.height(8.dp),
)
Spacer(modifier = Modifier.width(8.dp))
FlowRow(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
when (val caseState = device.caseChargingState
?: device.isCaseCharging?.let { if (it) AapPodState.ChargingState.CHARGING else null }) {
AapPodState.ChargingState.CHARGING_OPTIMIZED -> StatusChip(
icon = Icons.TwoTone.BatteryChargingFull,
label = stringResource(R.string.pods_charging_optimized_label),
)
AapPodState.ChargingState.CHARGING -> StatusChip(
icon = Icons.TwoTone.BatteryChargingFull,
label = stringResource(R.string.pods_charging_label),
)
else -> Unit
}
val lidState = device.caseLidState
if (lidState == LidState.OPEN || lidState == LidState.CLOSED) {
StatusChip(
icon = Icons.TwoTone.GridView,
label = when (lidState) {
LidState.OPEN -> stringResource(R.string.pods_case_status_open_label)
LidState.CLOSED -> stringResource(R.string.pods_case_status_closed_label)
else -> ""
},
)
}
}
}
if (charges != null) {
CaseChargesLine(
charges = charges,
modifier = Modifier.padding(top = 2.dp),
)
}
}
}
/**
* Stacked below the case row rather than inline: that row already carries four children of which
* only the capsule is weighted, so an unbounded plural in front of it squeezes the capsule away at
* large font scales and in locales with long plural forms.
*/
@Composable
private fun CaseChargesLine(
charges: CaseCharges,
modifier: Modifier = Modifier,
) {
val text = when (charges.display) {
CaseCharges.Display.EMPTY -> stringResource(R.string.battery_case_charges_empty)
CaseCharges.Display.LESS_THAN_ONE -> stringResource(R.string.battery_case_charges_less_than_one)
CaseCharges.Display.UNCERTAIN -> stringResource(R.string.battery_case_charges_uncertain)
CaseCharges.Display.AT_LEAST -> pluralStringResource(
R.plurals.battery_case_charges_at_least,
charges.count,
charges.count,
)
CaseCharges.Display.APPROXIMATE -> pluralStringResource(
R.plurals.battery_case_charges_approx,
charges.count,
charges.count,
)
}
// The same visible count can be adequate or not depending on how coarse the reading was, so
// the adequacy needs its own announcement rather than riding on the case battery's state.
val state = when (charges.adequacy) {
CaseCharges.Adequacy.ENOUGH -> stringResource(R.string.battery_case_charges_state_enough_cd)
CaseCharges.Adequacy.NOT_ENOUGH -> stringResource(R.string.battery_case_charges_state_not_enough_cd)
CaseCharges.Adequacy.UNCERTAIN -> stringResource(R.string.battery_case_charges_state_uncertain_cd)
}
val color = when (charges.adequacy) {
CaseCharges.Adequacy.ENOUGH -> LocalBatteryColors.current.positiveText
CaseCharges.Adequacy.NOT_ENOUGH -> LocalBatteryColors.current.warnText
CaseCharges.Adequacy.UNCERTAIN -> MaterialTheme.colorScheme.onSurfaceVariant
}
Text(
text = text,
style = MaterialTheme.typography.bodySmall,
color = color,
textAlign = TextAlign.Center,
modifier = modifier
.fillMaxWidth()
.semantics { stateDescription = state },
)
}
@Preview2
@Composable
private fun DualPodsCardFullPreview() = PreviewWrapper {
@@ -525,6 +600,54 @@ private fun DualPodsCardEstimateProvisionalPreview() = PreviewWrapper {
)
}
@Preview2
@Composable
private fun DualPodsCardWarnPreview() = PreviewWrapper {
// Case at 30% of a 4.0-charge spec: even the bottom of the decile band clears a full charge.
DualPodsCard(
device = MockPodDataProvider.dualPodBatteries(left = 0.22f, right = 0.18f, case = 0.30f),
showDebug = false,
now = SystemTimeSource.now(),
)
}
@Preview2
@Composable
private fun DualPodsCardCriticalPreview() = PreviewWrapper {
// Case at 10%: the whole band stays under a full charge.
DualPodsCard(
device = MockPodDataProvider.dualPodBatteries(left = 0.10f, right = 0.05f, case = 0.10f),
showDebug = false,
now = SystemTimeSource.now(),
)
}
@Preview2
@Composable
private fun DualPodsCardCaseChargesUncertainPreview() = PreviewWrapper {
// Case at 20%: the decile band straddles a full charge, so the line makes no claim.
DualPodsCard(
device = MockPodDataProvider.dualPodBatteries(case = 0.20f),
showDebug = false,
now = SystemTimeSource.now(),
)
}
@Preview2
@Composable
private fun DualPodsCardCaseWithoutSpecPreview() = PreviewWrapper {
// PowerBeats Pro has a case, but Apple publishes no figures for it, so there is no line.
DualPodsCard(
device = MockPodDataProvider.dualPodBatteries(
case = 0.60f,
model = PodModel.POWERBEATS_PRO,
label = "PowerBeats Pro",
),
showDebug = false,
now = SystemTimeSource.now(),
)
}
@Preview2
@Composable
private fun DualPodsCardMinimalPreview() = PreviewWrapper {
@@ -52,19 +52,23 @@ import eu.darken.capod.main.ui.overview.cards.components.DeviceConnectionBadge
import eu.darken.capod.main.ui.overview.cards.components.MissingPairedDeviceBanner
import eu.darken.capod.main.ui.overview.cards.components.SignalIndicator
import eu.darken.capod.main.ui.overview.cards.components.StatusChip
import eu.darken.capod.main.ui.overview.cards.components.batteryTierState
import eu.darken.capod.common.SystemTimeSource
import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper
import eu.darken.capod.common.compose.preview.MockPodDataProvider
import eu.darken.capod.common.theming.fillColor
import eu.darken.capod.common.theming.textColorOrNull
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.monitor.core.battery.BatteryEstimate
import eu.darken.capod.monitor.core.battery.BatteryTier
import eu.darken.capod.monitor.core.battery.batteryTier
import eu.darken.capod.monitor.core.cachedBatteryFormatted
import eu.darken.capod.pods.core.apple.aap.AapPodState
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import eu.darken.capod.pods.core.apple.ble.batteryProgress
import eu.darken.capod.pods.core.apple.ble.formatBatteryDurationShort
import eu.darken.capod.pods.core.apple.ble.formatBatteryPercent
import eu.darken.capod.pods.core.apple.ble.isKnownBattery
import java.time.Instant
@OptIn(ExperimentalLayoutApi::class)
@@ -200,19 +204,15 @@ private fun ColumnScope.SinglePodsCardExpanded(
val context = LocalContext.current
val percent = device.batteryHeadset
val isKnown = isKnownBattery(percent)
val tier = batteryTier(percent)
val isKnown = tier != BatteryTier.UNKNOWN
val animatedProgress by animateFloatAsState(
targetValue = batteryProgress(percent),
animationSpec = tween(600, easing = FastOutSlowInEasing),
label = "gaugeProgress",
)
val ringColor = when {
!isKnown -> MaterialTheme.colorScheme.surfaceVariant
percent > 0.30f -> MaterialTheme.colorScheme.primary
percent >= 0.15f -> MaterialTheme.colorScheme.tertiary
else -> MaterialTheme.colorScheme.error
}
val ringColor = tier.fillColor()
Spacer(modifier = Modifier.height(12.dp))
@@ -232,7 +232,9 @@ private fun ColumnScope.SinglePodsCardExpanded(
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.size(88.dp),
modifier = Modifier
.size(88.dp)
.batteryTierState(tier),
) {
// Track ring
CircularProgressIndicator(
@@ -261,7 +263,7 @@ private fun ColumnScope.SinglePodsCardExpanded(
Text(
text = formatBatteryPercent(context, percent),
style = MaterialTheme.typography.headlineSmall,
color = if (isKnown) {
color = tier.textColorOrNull() ?: if (isKnown) {
MaterialTheme.colorScheme.onSurface
} else {
MaterialTheme.colorScheme.onSurfaceVariant
@@ -320,7 +322,7 @@ private fun ColumnScope.SinglePodsCardExpanded(
// Cached battery indicator
if (device.isBatteryCached && !device.isLive) {
Spacer(modifier = Modifier.height(4.dp))
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.battery_cached_label, device.cachedBatteryFormatted(now)),
style = MaterialTheme.typography.bodySmall,
@@ -373,6 +375,26 @@ private fun SinglePodsCardEstimatePreview() = PreviewWrapper {
)
}
@Preview2
@Composable
private fun SinglePodsCardWarnPreview() = PreviewWrapper {
SinglePodsCard(
device = MockPodDataProvider.singlePodBattery(percent = 0.20f),
showDebug = false,
now = SystemTimeSource.now(),
)
}
@Preview2
@Composable
private fun SinglePodsCardCriticalPreview() = PreviewWrapper {
SinglePodsCard(
device = MockPodDataProvider.singlePodBattery(percent = 0.08f),
showDebug = false,
now = SystemTimeSource.now(),
)
}
@Preview2
@Composable
private fun SinglePodsCardMinimalPreview() = PreviewWrapper {
@@ -18,6 +18,10 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper
import eu.darken.capod.common.theming.fillColor
import eu.darken.capod.monitor.core.battery.BatteryTier
import eu.darken.capod.monitor.core.battery.batteryTier
import eu.darken.capod.pods.core.apple.ble.batteryProgress
private val CapsuleShape = RoundedCornerShape(6.dp)
@@ -26,26 +30,22 @@ fun BatteryCapsule(
percent: Float,
modifier: Modifier = Modifier,
) {
val clamped = if (percent >= 0f) percent.coerceIn(0f, 1f) else -1f
val tier = batteryTier(percent)
val isKnown = tier != BatteryTier.UNKNOWN
val animatedFraction by animateFloatAsState(
targetValue = if (clamped >= 0f) clamped else 0f,
targetValue = batteryProgress(percent),
animationSpec = tween(600, easing = FastOutSlowInEasing),
label = "batteryFill",
)
val barColor = when {
clamped < 0f -> MaterialTheme.colorScheme.surfaceVariant
clamped > 0.30f -> MaterialTheme.colorScheme.primary
clamped >= 0.15f -> MaterialTheme.colorScheme.tertiary
else -> MaterialTheme.colorScheme.error
}
val barColor = tier.fillColor()
Box(
modifier = modifier
.clip(CapsuleShape)
.background(MaterialTheme.colorScheme.surfaceVariant),
) {
if (clamped >= 0f) {
if (isKnown) {
Box(
modifier = Modifier
.fillMaxHeight()
@@ -0,0 +1,23 @@
package eu.darken.capod.main.ui.overview.cards.components
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
import eu.darken.capod.R
import eu.darken.capod.monitor.core.battery.BatteryTier
/**
* Announces a low or critical level on a battery slot — crossing either threshold is otherwise
* only visible as a colour change. A no-op at the other tiers, which make no claim to announce.
*/
@Composable
fun Modifier.batteryTierState(tier: BatteryTier): Modifier {
val description = when (tier) {
BatteryTier.CRITICAL -> stringResource(R.string.battery_state_critical_cd)
BatteryTier.WARN -> stringResource(R.string.battery_state_low_cd)
BatteryTier.UNKNOWN, BatteryTier.GOOD -> return this
}
return semantics(mergeDescendants = true) { stateDescription = description }
}
@@ -32,7 +32,11 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import eu.darken.capod.R
import eu.darken.capod.common.theming.fillColor
import eu.darken.capod.common.theming.textColorOrNull
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.monitor.core.battery.BatteryTier
import eu.darken.capod.monitor.core.battery.batteryTier
import eu.darken.capod.pods.core.apple.ble.batteryProgress
import eu.darken.capod.pods.core.apple.ble.formatBatteryPercent
import eu.darken.capod.pods.core.apple.ble.isKnownBattery
@@ -127,22 +131,18 @@ private fun MiniPodRing(
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val isKnown = isKnownBattery(percent)
val tier = batteryTier(percent)
val isKnown = tier != BatteryTier.UNKNOWN
val animatedProgress by animateFloatAsState(
targetValue = batteryProgress(percent),
animationSpec = tween(600, easing = FastOutSlowInEasing),
label = "miniGaugeProgress",
)
val ringColor = when {
!isKnown -> MaterialTheme.colorScheme.surfaceVariant
percent > 0.30f -> MaterialTheme.colorScheme.primary
percent >= 0.15f -> MaterialTheme.colorScheme.tertiary
else -> MaterialTheme.colorScheme.error
}
val ringColor = tier.fillColor()
Row(
modifier = modifier,
modifier = modifier.batteryTierState(tier),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
@@ -179,7 +179,7 @@ private fun MiniPodRing(
Text(
text = formatBatteryPercent(context, percent),
style = MaterialTheme.typography.titleSmall,
color = if (isKnown) {
color = tier.textColorOrNull() ?: if (isKnown) {
MaterialTheme.colorScheme.onSurface
} else {
MaterialTheme.colorScheme.onSurfaceVariant
@@ -194,9 +194,10 @@ private fun MiniCaseCluster(
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val tier = batteryTier(device.batteryCase)
Row(
modifier = modifier,
modifier = modifier.batteryTierState(tier),
verticalAlignment = Alignment.CenterVertically,
) {
Image(
@@ -215,7 +216,7 @@ private fun MiniCaseCluster(
Text(
text = formatBatteryPercent(context, device.batteryCase),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurface,
color = tier.textColorOrNull() ?: MaterialTheme.colorScheme.onSurface,
)
}
}
@@ -0,0 +1,29 @@
package eu.darken.capod.monitor.core
import eu.darken.capod.pods.core.apple.ble.BATTERY_RESOLUTION_DECILE
import eu.darken.capod.pods.core.apple.ble.BATTERY_RESOLUTION_PERCENT
import eu.darken.capod.pods.core.apple.ble.devices.HasCase
/**
* A battery percentage together with the step size of the source it came from. A reading of 20%
* from a decile source means "somewhere in [20%, 30%)", which is the difference between a claim we
* can make and one we can't.
*/
data class BatteryReading(
val percent: Float,
val resolution: Float,
)
/**
* [PodDevice.batteryCase] resolved through the same precedence, but keeping the resolution of
* whichever source won. Cache entries store no resolution, so they report the coarsest one.
*/
val PodDevice.batteryCaseReading: BatteryReading?
get() {
aap?.batteryCase?.let { return BatteryReading(it, BATTERY_RESOLUTION_PERCENT) }
(ble as? HasCase)?.let { snapshot ->
snapshot.batteryCasePercent?.let { return BatteryReading(it, snapshot.batteryCaseResolution) }
}
cached?.case?.percent?.let { return BatteryReading(it, BATTERY_RESOLUTION_DECILE) }
return null
}
@@ -1,5 +1,6 @@
package eu.darken.capod.monitor.core.aap
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.flow.setupCommonEventHandlers
@@ -33,15 +34,22 @@ class AapKeyPersister @Inject constructor(
return@onEach
}
val updated = profile.copy(
identityKey = keys.irk ?: profile.identityKey,
encryptionKey = keys.encKey ?: profile.encryptionKey,
)
// The profile's key fields are ByteArrays, so the generated equals() compares them by
// reference — content comparison is what decides whether anything actually changed.
val irkChanged = keys.irk != null && !keys.irk.contentEquals(profile.identityKey)
val encChanged = keys.encKey != null && !keys.encKey.contentEquals(profile.encryptionKey)
if (updated != profile) {
profilesRepo.updateProfile(updated)
log(TAG) { "Persisted keys for $address (IRK=${keys.irk != null}, ENC=${keys.encKey != null})" }
if (!irkChanged && !encChanged) {
log(TAG, VERBOSE) { "Keys for $address are unchanged, skipping key persistence" }
return@onEach
}
val updated = profile.copy(
identityKey = if (irkChanged) keys.irk else profile.identityKey,
encryptionKey = if (encChanged) keys.encKey else profile.encryptionKey,
)
profilesRepo.updateProfile(updated)
log(TAG) { "Persisted keys for $address (IRK changed=$irkChanged, ENC changed=$encChanged)" }
}
.map { }
.setupCommonEventHandlers(TAG) { "keyPersister" }
@@ -2,72 +2,129 @@ package eu.darken.capod.monitor.core.battery
import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import java.time.Duration
import java.time.Instant
import kotlin.math.roundToInt
/**
* Derives a rough per-pod battery-health percentage from learned drain rates vs the model's rated
* battery life. Nothing on the wire exposes Apple's real health/cycle data, so this is a usage-based
* proxy: a pod that only lasts 4.5h of a rated 6h reads as ~75%.
* Derives how much of a model's rated listening time a pod still delivers, from learned drain rates
* vs that rating: a pod that only lasts 4.5h of a rated 6h reads as ~75%. Nothing on the wire
* exposes Apple's real health/cycle data, and a short runtime can just as well come from loud
* volume, cold weather or a hungry codec — so this is a runtime figure, not a cell-health verdict.
*
* Health is computed PER POD — single-pod listening habits or a replaced earbud make the two sides
* It is computed PER POD — single-pod listening habits or a replaced earbud make the two sides
* genuinely diverge, and a combined figure would mask a failing pod. Only [DrainProfile.listeningRates]
* feed it (segments where the pod was worn AND audio was playing on this device), because Apple's
* ratings are listening figures — general rates include idle wear and would flatter health. Within a
* pod, the MEDIAN of its qualifying rates is used rather than the best or worst, damping remaining
* confounds (volume, calls, cold) in either direction. It remains an estimate — label it as such in
* the UI.
* ratings are listening figures — general rates include idle wear and would flatter the result.
* Within a pod, the MEDIAN of its qualifying rates is used rather than the best or worst, damping
* remaining confounds (volume, calls, cold) in either direction. It remains an estimate — label it
* as such in the UI.
*
* A figure that is merely displayed needs less backing than one that raises a warning, hence
* [Reading.isPromotable].
*/
object BatteryHealth {
/** A learned rate must have accumulated this many separate sessions before it counts. */
const val MIN_UPDATE_COUNT = 3
/**
* Sessions a slot must have accumulated across all its qualifying rates before its figure may
* be promoted into a warning. `updateCount` rises once per listening session, so this is
* roughly a week of real use.
*/
const val MIN_PROMOTE_UPDATE_COUNT = 8
/** A slot's newest qualifying rate may be at most this old, or its figure is too stale to warn on. */
val MAX_PROMOTE_AGE: Duration = Duration.ofDays(60)
/** At or below this percentage of the rated listening time, a promotable reading warrants a warning. */
const val LOW_RUNTIME_PERCENT = 50
enum class Slot { LEFT, RIGHT, HEADSET }
/**
* @param percent share of the rated listening time this pod still reaches, 1..100
* @param isPromotable whether enough recent evidence backs [percent] to act on it
*/
data class Reading(
val percent: Int,
val isPromotable: Boolean,
)
data class SlotReading(
val slot: Slot,
val reading: Reading,
)
data class PerPod(
val left: Int? = null,
val right: Int? = null,
val headset: Int? = null,
val left: Reading? = null,
val right: Reading? = null,
val headset: Reading? = null,
) {
val hasAny: Boolean get() = left != null || right != null || headset != null
/** The worst reading that rests on enough recent evidence to act on, or null if none does. */
val lowestPromotable: SlotReading?
get() = listOfNotNull(
left?.let { SlotReading(Slot.LEFT, it) },
right?.let { SlotReading(Slot.RIGHT, it) },
headset?.let { SlotReading(Slot.HEADSET, it) },
)
.filter { it.reading.isPromotable }
.minByOrNull { it.reading.percent }
}
fun estimate(profile: DrainProfile?, model: PodModel): PerPod? {
fun estimate(profile: DrainProfile?, model: PodModel, now: Instant): PerPod? {
if (profile == null) return null
val spec = model.batterySpec ?: return null
if (!profile.matchesModel(model)) return null
return PerPod(
left = slotPercent(profile, spec, "LEFT"),
right = slotPercent(profile, spec, "RIGHT"),
headset = slotPercent(profile, spec, "HEADSET"),
left = slotReading(profile, spec, Slot.LEFT, now),
right = slotReading(profile, spec, Slot.RIGHT, now),
headset = slotReading(profile, spec, Slot.HEADSET, now),
).takeIf { it.hasAny }
}
private fun slotPercent(profile: DrainProfile, spec: PodModel.BatterySpec, slot: String): Int? {
val ratios = profile.listeningRates.mapNotNull { (key, rate) ->
private fun slotReading(
profile: DrainProfile,
spec: PodModel.BatterySpec,
slot: Slot,
now: Instant,
): Reading? {
val qualifying = profile.listeningRates.mapNotNull { (key, rate) ->
// Keys must be exactly "<bucket>/<slot>" — anything else is corrupted or
// future-format data and must not feed a health figure.
// future-format data and must not feed a runtime figure.
val parts = key.split('/')
if (parts.size != 2 || parts[1] != slot) return@mapNotNull null
if (parts.size != 2 || parts[1] != slot.name) return@mapNotNull null
val specHours = specHoursFor(spec, parts[0]) ?: return@mapNotNull null
if (rate.updateCount < MIN_UPDATE_COUNT) return@mapNotNull null
if (!rate.fractionPerHour.isFinite() || rate.fractionPerHour <= 0f) return@mapNotNull null
(1f / specHours) / rate.fractionPerHour
(1f / specHours) / rate.fractionPerHour to rate
}
if (ratios.isEmpty()) return null
if (qualifying.isEmpty()) return null
val sorted = ratios.sorted()
val sorted = qualifying.map { it.first }.sorted()
val median = if (sorted.size % 2 == 1) {
sorted[sorted.size / 2]
} else {
(sorted[sorted.size / 2 - 1] + sorted[sorted.size / 2]) / 2f
}
return (median * 100f).roundToInt().coerceIn(1, 100)
val sessions = qualifying.sumOf { it.second.updateCount }
val newest = qualifying.maxOf { it.second.updatedAt }
return Reading(
percent = (median * 100f).roundToInt().coerceIn(1, 100),
isPromotable = sessions >= MIN_PROMOTE_UPDATE_COUNT &&
Duration.between(newest, now) <= MAX_PROMOTE_AGE,
)
}
/**
* The rated hours a rate learned in [bucket] should be judged against. UNKNOWN-bucket usage
* can't be matched to a specific mode, so it's compared to the middle of the two ratings —
* the shorter one would systematically flatter health, the longer one would slander it.
* the shorter one would systematically flatter the figure, the longer one would slander it.
* Malformed or unrecognized bucket keys yield null (entry is skipped).
*/
private fun specHoursFor(spec: PodModel.BatterySpec, bucket: String): Float? {
@@ -0,0 +1,23 @@
package eu.darken.capod.monitor.core.battery
/** Battery bands behind the overview's colour semantics. */
enum class BatteryTier {
UNKNOWN,
CRITICAL,
WARN,
GOOD,
}
/**
* Maps a battery fraction onto a [BatteryTier]. Only a finite, non-negative fraction has a level;
* anything else is [BatteryTier.UNKNOWN]. Values above 1 count as full.
*/
fun batteryTier(percent: Float): BatteryTier {
if (!percent.isFinite() || percent < 0f) return BatteryTier.UNKNOWN
val clamped = percent.coerceIn(0f, 1f)
return when {
clamped > 0.30f -> BatteryTier.GOOD
clamped >= 0.15f -> BatteryTier.WARN
else -> BatteryTier.CRITICAL
}
}
@@ -0,0 +1,81 @@
package eu.darken.capod.monitor.core.battery
import eu.darken.capod.monitor.core.BatteryReading
import eu.darken.capod.pods.core.apple.PodModel
import kotlin.math.floor
/** How many more times the case can recharge both earbuds, and how firm that number is. */
data class CaseCharges(
val count: Int,
val display: Display,
val adequacy: Adequacy,
) {
enum class Display {
/** Nothing left to give — distinct from [LESS_THAN_ONE], which still implies some charge. */
EMPTY,
LESS_THAN_ONE,
/** No number at all: an open-ended interval that has not yet reached a full charge. */
UNCERTAIN,
AT_LEAST,
APPROXIMATE,
}
enum class Adequacy { ENOUGH, NOT_ENOUGH, UNCERTAIN }
}
/**
* Reads a truncating [reading] as the interval `[percent, percent + resolution)` and scales it by
* the spec, so a decile source at 20% asks "is 4.0 x 0.20 .. 4.0 x 0.30 above one charge?" rather
* than pretending 0.8 is exact. Only an interval that lies wholly on one side of a full charge
* makes a claim; anything straddling it is [CaseCharges.Adequacy.UNCERTAIN]. That also absorbs the
* bounce between adjacent readings, so no state has to be remembered between frames.
*
* The interval excludes its upper end, so an upper end of exactly one charge is still short of one.
*
* A spec that is itself a lower bound has no upper end, so it can never claim "not enough" — except
* at an empty reading, where a floor on the published capacity has nothing left to undersell.
*
* The wording follows the whole interval, not its pessimistic end: an uncertain one rounds to a
* single charge, or names no number at all ([CaseCharges.Display.UNCERTAIN]) when the spec is a
* lower bound and there is no upper end to round from.
*
* Null when the model publishes no case figures or the reading is missing or nonsense — the caller
* shows no line at all rather than a neutral one.
*/
fun caseCharges(spec: PodModel.CaseSpec?, reading: BatteryReading?): CaseCharges? {
if (spec == null || !spec.fullPairRecharges.isFinite() || spec.fullPairRecharges <= 0f) return null
if (reading == null || !reading.percent.isFinite() || reading.percent < 0f) return null
val resolution = if (reading.resolution.isFinite() && reading.resolution > 0f) reading.resolution else 0f
val lowest = spec.fullPairRecharges * reading.percent
val highest = spec.fullPairRecharges * (reading.percent + resolution)
val adequacy = when {
lowest >= 1f -> CaseCharges.Adequacy.ENOUGH
reading.percent == 0f -> CaseCharges.Adequacy.NOT_ENOUGH
spec.isLowerBound -> CaseCharges.Adequacy.UNCERTAIN
highest <= 1f -> CaseCharges.Adequacy.NOT_ENOUGH
else -> CaseCharges.Adequacy.UNCERTAIN
}
val floored = floor(lowest).toInt()
val display = when {
reading.percent == 0f -> CaseCharges.Display.EMPTY
adequacy == CaseCharges.Adequacy.NOT_ENOUGH -> CaseCharges.Display.LESS_THAN_ONE
adequacy == CaseCharges.Adequacy.UNCERTAIN && spec.isLowerBound -> CaseCharges.Display.UNCERTAIN
adequacy == CaseCharges.Adequacy.UNCERTAIN -> CaseCharges.Display.APPROXIMATE
spec.isLowerBound -> CaseCharges.Display.AT_LEAST
else -> CaseCharges.Display.APPROXIMATE
}
val count = when {
reading.percent == 0f -> 0
adequacy == CaseCharges.Adequacy.NOT_ENOUGH -> 0
adequacy == CaseCharges.Adequacy.UNCERTAIN && !spec.isLowerBound -> 1
else -> floored
}
return CaseCharges(count = count, display = display, adequacy = adequacy)
}
@@ -7,6 +7,7 @@ import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import dagger.hilt.android.AndroidEntryPoint
import eu.darken.capod.common.bluetooth.BluetoothManager2
import eu.darken.capod.common.bluetooth.hasFeature
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
import eu.darken.capod.common.debug.logging.log
@@ -19,6 +20,7 @@ import javax.inject.Inject
class BluetoothEventReceiver : BroadcastReceiver() {
@Inject lateinit var monitorControl: MonitorControl
@Inject lateinit var bluetoothManager: BluetoothManager2
override fun onReceive(context: Context, intent: Intent) {
log(TAG) { "onReceive($context, $intent)" }
@@ -48,6 +50,15 @@ class BluetoothEventReceiver : BroadcastReceiver() {
log { "Device has the following we features we support $supportedFeatures" }
}
when (intent.action) {
BluetoothDevice.ACTION_ACL_CONNECTED -> bluetoothManager.markDeviceConnected(bluetoothDevice.address)
BluetoothDevice.ACTION_ACL_DISCONNECTED -> {
bluetoothManager.markDeviceDisconnected(bluetoothDevice.address)
// A disconnect is not a reason to start monitoring.
return
}
}
log(TAG) { "Starting monitor" }
monitorControl.startMonitor(forceStart = false)
}
@@ -55,6 +55,9 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.cancelChildren
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
@@ -95,6 +98,7 @@ class MonitorService : Service() {
private val monitorScope = MonitorCoroutineScope()
private var monitoringJob: Job? = null
private val startSignal = MutableStateFlow(0L)
@Volatile private var monitorGeneration = 0
private var foregroundStartFailed = false
private var injectionComplete = false
@@ -212,6 +216,8 @@ class MonitorService : Service() {
if (monitoringJob?.isActive == true && !forceStart) {
log(TAG) { "Already monitoring and forceStart=false, keeping current session." }
// Fresh evidence of activity: re-arm any pending teardown countdown.
startSignal.value++
return START_STICKY
}
@@ -314,7 +320,7 @@ class MonitorService : Service() {
}
.launchIn(monitorScope)
permissionTool.missingScanPermissions
val modeStates = permissionTool.missingScanPermissions
.flatMapLatest { missingPermsFlow ->
if (missingPermsFlow.isNotEmpty()) {
log(TAG, WARN) { "Aborting, scan permissions are missing: $missingPermsFlow" }
@@ -333,38 +339,13 @@ class MonitorService : Service() {
}
.distinctUntilChanged()
.setupCommonEventHandlers(TAG) { "MonitorMode" }
.flatMapLatest { state ->
log(TAG) { "Monitor mode: ${state.mode}" }
log(TAG) { "connectedAddresses: ${state.connectedAddresses}" }
log(TAG) { "knownAddresses: ${state.knownAddresses}" }
when (state.mode) {
MonitorMode.MANUAL -> flow<Unit> {
monitorScope.coroutineContext.cancelChildren()
}
MonitorMode.ALWAYS -> emptyFlow()
MonitorMode.AUTOMATIC -> flow {
when {
!state.hasProfiles && state.connectedAddresses.isNotEmpty() -> {
log(TAG, WARN) { "Main device address not set, staying alive while any is connected" }
}
state.knownAddresses.any { it in state.connectedAddresses } || state.hasAapSession -> {
log(TAG) { "A device is connected, aborting any timeout." }
}
else -> {
log(TAG) { "No known Pods are connected, stopping service soon." }
delay(15 * 1000)
log(TAG) { "Stopping service now, still no Pods connected." }
monitorScope.coroutineContext.cancelChildren()
}
}
}
}
}
monitorModeFlow(
tag = TAG,
modeStates = modeStates,
startSignal = startSignal,
onTeardown = { monitorScope.coroutineContext.cancelChildren() },
)
.catch {
log(TAG, WARN) { "MonitorMode Flow failed:\n${it.asLog()}" }
}
@@ -492,6 +473,60 @@ internal data class MonitorModeState(
val hasAapSession: Boolean,
)
/**
* Decides whether a monitor session may keep running. In [MonitorMode.AUTOMATIC] with nothing
* connected the teardown runs [timeoutMillis] after the state was seen.
*
* [startSignal] is bumped by a start request that found a live session and short-circuited. The
* bump restarts this flow, arming a fresh window, and the pre-teardown re-check covers the case
* where the bump lands while the expired countdown is already unwinding.
*/
internal fun monitorModeFlow(
tag: String,
modeStates: Flow<MonitorModeState>,
startSignal: StateFlow<Long>,
timeoutMillis: Long = 15 * 1000,
onTeardown: () -> Unit,
): Flow<Unit> = combine(modeStates, startSignal) { state, signal ->
state to signal
}.flatMapLatest { (state, armedSignal) ->
log(tag) { "Monitor mode: ${state.mode}" }
log(tag) { "connectedAddresses: ${state.connectedAddresses}" }
log(tag) { "knownAddresses: ${state.knownAddresses}" }
when (state.mode) {
MonitorMode.MANUAL -> flow<Unit> {
onTeardown()
}
MonitorMode.ALWAYS -> emptyFlow()
MonitorMode.AUTOMATIC -> flow {
when {
!state.hasProfiles && state.connectedAddresses.isNotEmpty() -> {
log(tag, WARN) { "Main device address not set, staying alive while any is connected" }
}
state.knownAddresses.any { it in state.connectedAddresses } || state.hasAapSession -> {
log(tag) { "A device is connected, aborting any timeout." }
}
else -> {
log(tag) { "No known Pods are connected, stopping service soon." }
delay(timeoutMillis)
if (startSignal.value != armedSignal) {
log(tag) { "A start request arrived during the timeout, staying alive." }
return@flow
}
log(tag) { "Stopping service now, still no Pods connected." }
onTeardown()
}
}
}
}
}
internal fun buildMonitorModeState(
mode: MonitorMode,
profiles: List<DeviceProfile>,
@@ -15,7 +15,9 @@ enum class PodModel(
@DrawableRes val rightPodIconRes: Int? = null,
@DrawableRes val caseIconRes: Int? = null,
val batterySpec: BatterySpec? = null,
val caseSpec: CaseSpec? = null,
) {
/** Case charges: 5 h listening, more than 24 h with the case, noise control off. */
@SerialName("airpods.gen1")
AIRPODS_GEN1(
"AirPods (Gen 1)",
@@ -28,12 +30,14 @@ enum class PodModel(
hasEarDetectionToggle = true,
),
batterySpec = BatterySpec(listeningHoursAncOff = 5f, chargeFractionPerHour = 2.4f),
caseSpec = CaseSpec(fullPairRecharges = 3.8f, isLowerBound = true),
modelNumbers = setOf("A1523", "A1722"), // L/R earphones
leftPodIconRes = R.drawable.device_airpods_gen1_left,
rightPodIconRes = R.drawable.device_airpods_gen1_right,
caseIconRes = R.drawable.device_airpods_gen1_case,
),
/** Case charges: 5 h listening, more than 24 h with the case, noise control off (SP791). */
@SerialName("airpods.gen2")
AIRPODS_GEN2(
"AirPods (Gen 2)",
@@ -46,12 +50,14 @@ enum class PodModel(
hasEarDetectionToggle = true,
),
batterySpec = BatterySpec(listeningHoursAncOff = 5f, chargeFractionPerHour = 2.4f),
caseSpec = CaseSpec(fullPairRecharges = 3.8f, isLowerBound = true),
modelNumbers = setOf("A2031", "A2032"), // L/R earphones
leftPodIconRes = R.drawable.device_airpods_gen1_left,
rightPodIconRes = R.drawable.device_airpods_gen1_right,
caseIconRes = R.drawable.device_airpods_gen1_case,
),
/** Case charges: 6 h listening, 30 h with the case, noise control off. */
@SerialName("airpods.gen3")
AIRPODS_GEN3(
"AirPods (Gen 3)",
@@ -68,12 +74,14 @@ enum class PodModel(
hasEarDetectionToggle = true,
),
batterySpec = BatterySpec(listeningHoursAncOff = 6f, chargeFractionPerHour = 2.0f),
caseSpec = CaseSpec(fullPairRecharges = 4.0f),
modelNumbers = setOf("A2564", "A2565"), // L/R earphones
leftPodIconRes = R.drawable.device_airpods_gen3_left,
rightPodIconRes = R.drawable.device_airpods_gen3_right,
caseIconRes = R.drawable.device_airpods_gen3_case,
),
/** Case charges: 5 h listening, 30 h with the case, noise control off. */
@SerialName("airpods.gen4")
AIRPODS_GEN4(
"AirPods (Gen 4)",
@@ -91,12 +99,14 @@ enum class PodModel(
hasSleepDetection = true,
),
batterySpec = BatterySpec(listeningHoursAncOff = 5f, chargeFractionPerHour = 2.4f),
caseSpec = CaseSpec(fullPairRecharges = 5.0f),
modelNumbers = setOf("A3050", "A3053", "A3054"), // earphones
leftPodIconRes = R.drawable.device_airpods_gen3_left,
rightPodIconRes = R.drawable.device_airpods_gen3_right,
caseIconRes = R.drawable.device_airpods_gen3_case,
),
/** Case charges: 4 h listening, 20 h with the case, ANC on. */
@SerialName("airpods.gen4.anc")
AIRPODS_GEN4_ANC(
"AirPods (Gen 4 ANC)",
@@ -123,6 +133,7 @@ enum class PodModel(
hasSleepDetection = true,
),
batterySpec = BatterySpec(listeningHoursAncOn = 4f, listeningHoursAncOff = 5f, chargeFractionPerHour = 2.4f),
caseSpec = CaseSpec(fullPairRecharges = 4.0f),
modelNumbers = setOf("A3055", "A3056", "A3057"), // earphones
leftPodIconRes = R.drawable.device_airpods_gen4anc_left,
rightPodIconRes = R.drawable.device_airpods_gen4anc_right,
@@ -155,6 +166,7 @@ enum class PodModel(
caseIconRes = R.drawable.device_airpods_pro2_case,
),
/** Case charges: 6 h listening, 30 h with the case, ANC on. */
@SerialName("airpods.pro2")
AIRPODS_PRO2(
"AirPods Pro 2",
@@ -183,12 +195,14 @@ enum class PodModel(
hasSleepDetection = true,
),
batterySpec = BatterySpec(listeningHoursAncOn = 6f, chargeFractionPerHour = 2.0f),
caseSpec = CaseSpec(fullPairRecharges = 4.0f),
modelNumbers = setOf("A2698", "A2699", "A2931"), // earphones
leftPodIconRes = R.drawable.device_airpods_pro2_left,
rightPodIconRes = R.drawable.device_airpods_pro2_right,
caseIconRes = R.drawable.device_airpods_pro2_case,
),
/** Case charges: 6 h listening, 30 h with the case, ANC on. */
@SerialName("airpods.pro2.usbc")
AIRPODS_PRO2_USBC(
"AirPods Pro 2 USB-C",
@@ -217,12 +231,14 @@ enum class PodModel(
hasSleepDetection = true,
),
batterySpec = BatterySpec(listeningHoursAncOn = 6f, chargeFractionPerHour = 2.0f),
caseSpec = CaseSpec(fullPairRecharges = 4.0f),
modelNumbers = setOf("A3047", "A3048", "A3049"), // earphones
leftPodIconRes = R.drawable.device_airpods_pro2_left,
rightPodIconRes = R.drawable.device_airpods_pro2_right,
caseIconRes = R.drawable.device_airpods_pro2_case,
),
/** Case charges: 8 h listening, 24 h with the case, ANC on (support.apple.com/en-us/125135). */
@SerialName("airpods.pro3")
AIRPODS_PRO3(
"AirPods Pro 3",
@@ -252,6 +268,7 @@ enum class PodModel(
hasDynamicEndOfCharge = true,
),
batterySpec = BatterySpec(listeningHoursAncOn = 8f, chargeFractionPerHour = 1.5f),
caseSpec = CaseSpec(fullPairRecharges = 2.0f),
modelNumbers = setOf("A3063", "A3064", "A3065"), // earphones
leftPodIconRes = R.drawable.device_airpods_pro2_left,
rightPodIconRes = R.drawable.device_airpods_pro2_right,
@@ -616,4 +633,17 @@ enum class PodModel(
*/
val chargeFractionPerHour: Float? = null,
)
/**
* How many times a full case recharges BOTH earbuds, derived from Apple's published figures as
* `hoursWithCase / hoursListening - 1`. Both figures have to be read under the same noise
* control condition, which is why every entry records the pair it came from. Only a case that
* holds a battery carries a spec: [Features.hasCase] describes the physical case, and models
* such as Beats Solo Buds ship one that doesn't charge.
*/
data class CaseSpec(
val fullPairRecharges: Float,
/** True when the published total was "more than N hours" - the value is a lower bound. */
val isLowerBound: Boolean = false,
)
}
@@ -70,9 +70,12 @@ class AppleFactory @Inject constructor(
val factory = podFactories.firstOrNull { it.isResponsible(proximityMessage) } ?: unknownAppleFactory
val profiles = profilesRepo.currentProfiles().filterIsInstance<AppleDeviceProfile>()
var profile = profiles.firstOrNull {
it.identityKey != null && rpaChecker.verify(scanResult.address, it.identityKey)
val irkMatch = profiles.firstNotNullOfOrNull { candidate ->
val identityKey = candidate.identityKey ?: return@firstNotNullOfOrNull null
rpaChecker.resolve(scanResult.address, identityKey)?.let { candidate to it }
}
var profile = irkMatch?.first
val irkOrder = irkMatch?.second
val isIrkMatch = profile != null
if (isIrkMatch) {
@@ -115,7 +118,10 @@ class AppleFactory @Inject constructor(
.filter { it.identityKey != null && (it.model == PodModel.UNKNOWN || it.model == tempDevice.model) }
.firstOrNull { it.minimumSignalQuality <= tempDevice.signalQuality }
if (legacyCandidate != null) {
log(TAG, WARN) { "Keyed profile ${legacyCandidate.id} would match via old fallback (IRK failed) — stale key?" }
log(TAG, WARN) {
"Keyed profile ${legacyCandidate.id} would match via old fallback, " +
"its key resolved neither address order"
}
}
}
}
@@ -136,7 +142,8 @@ class AppleFactory @Inject constructor(
val publicHex = payload.public.data.joinToString(" ") { "%02X".format(it.toInt()) }
val privateHex = payload.private?.data?.joinToString(" ") { "%02X".format(it.toInt()) } ?: "-"
"Apple decoded: model=${device.model}, addr=${scanResult.address.redactedForLogs()}, " +
"irkMatch=$isIrkMatch, raw=[$rawHex], public=[$publicHex], private=[$privateHex]"
"irkMatch=$isIrkMatch, irkOrder=${irkOrder?.name ?: "-"}, " +
"raw=[$rawHex], public=[$publicHex], private=[$privateHex]"
}
device
@@ -21,6 +21,12 @@ import kotlin.math.roundToInt
const val BATTERY_UNKNOWN = -1f
/** Step size of a percent-granularity reading: AAP battery notifications and the encrypted payload. */
const val BATTERY_RESOLUTION_PERCENT = 0.01f
/** Step size of the public advertisement's battery nibble, which only carries deciles. */
const val BATTERY_RESOLUTION_DECILE = 0.10f
fun isKnownBattery(percent: Float): Boolean = percent.isFinite() && percent >= 0f
fun batteryProgress(percent: Float): Float =
@@ -4,6 +4,8 @@ import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.isBitSet
import eu.darken.capod.common.lowerNibble
import eu.darken.capod.common.upperNibble
import eu.darken.capod.pods.core.apple.ble.BATTERY_RESOLUTION_DECILE
import eu.darken.capod.pods.core.apple.ble.BATTERY_RESOLUTION_PERCENT
import eu.darken.capod.pods.core.apple.ble.DualBlePodSnapshot
import eu.darken.capod.pods.core.apple.ble.DualBlePodSnapshot.Pod
@@ -137,6 +139,13 @@ interface DualApplePods : ApplePods, HasChargeDetectionDual, DualBlePodSnapshot,
}
}
/** The encrypted payload carries whole percents; the public nibble only carries deciles. */
override val batteryCaseResolution: Float
get() = when {
payload.private?.asBatteryState(3) != null -> BATTERY_RESOLUTION_PERCENT
else -> BATTERY_RESOLUTION_DECILE
}
override val isCaseCharging: Boolean
get() {
payload.private?.asBatteryState(3)?.let { return it.isCharging }
@@ -2,14 +2,23 @@ package eu.darken.capod.pods.core.apple.ble.devices
import androidx.annotation.DrawableRes
import eu.darken.capod.R
import eu.darken.capod.pods.core.apple.ble.BATTERY_RESOLUTION_DECILE
interface HasCase {
val batteryCasePercent: Float?
/**
* Step size of [batteryCasePercent]. Deciles unless a decoder knows it read a finer source, so
* a caller reasoning about how much the true value may exceed the reading errs on the coarse
* side.
*/
val batteryCaseResolution: Float
get() = BATTERY_RESOLUTION_DECILE
val isCaseCharging: Boolean
@get:DrawableRes
val caseIcon: Int
get() = R.drawable.device_airpods_gen1_case
}
}
@@ -136,6 +136,7 @@ class PodHistoryRepo @Inject constructor(
if (profile != null) {
recognizedDevice = knownDevices.values
.filter { it.boundProfileId == null || it.boundProfileId == profile.id }
.firstOrNull { rpaChecker.verify(it.lastAddress, profile.identityKey!!) }
.also { log(TAG, VERBOSE) { "search1: Recovered via IRK: ${it?.logSummary()}" } }
}
@@ -2,30 +2,73 @@ package eu.darken.capod.pods.core.apple.ble.protocol
import android.annotation.SuppressLint
import eu.darken.capod.common.bluetooth.BluetoothAddress
import eu.darken.capod.common.bluetooth.redactedForLogs
import eu.darken.capod.common.debug.logging.Logging
import eu.darken.capod.common.debug.logging.asLog
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import okio.ByteString.Companion.toByteString
import javax.crypto.Cipher
import javax.crypto.spec.SecretKeySpec
import javax.inject.Inject
class RPAChecker @Inject constructor() {
enum class AddressOrder {
STANDARD,
REVERSED,
}
// Resolvable-Private-Address
fun verify(address: BluetoothAddress, irk: IdentityResolvingKey): Boolean = try {
val rpa = address.split(":").map { it.toInt(16).toByte() }.reversed().toByteArray()
val prand = rpa.copyOfRange(3, 6)
val hash = rpa.copyOfRange(0, 3)
val computedHash = ah(irk, prand)
hash.contentEquals(computedHash)
fun verify(address: BluetoothAddress, irk: IdentityResolvingKey): Boolean = resolve(address, irk) != null
/**
* Returns the octet order the [address] resolved in, or null if it resolves in neither.
*
* The reversed attempt is gated on the address being RPA-shaped in that order, the standard
* attempt is not: a device that resolves today must keep resolving byte-for-byte the same way.
*/
fun resolve(address: BluetoothAddress, irk: IdentityResolvingKey): AddressOrder? = try {
val octets = address.parseOctets()
when {
octets == null -> {
log(TAG, Logging.Priority.WARN) {
"Failed to resolve RPA, malformed address: ${address.redactedForLogs()}"
}
null
}
matchesHash(octets, irk) -> AddressOrder.STANDARD
else -> octets.reversedArray()
.takeIf { it.isRpaShaped() && matchesHash(it, irk) }
?.let { AddressOrder.REVERSED }
}
} catch (e: Exception) {
log(
TAG,
Logging.Priority.ERROR
) { "Failed to verify RPA\naddress=${address}\nIRK=${irk.toByteString()}\n${e.asLog()}" }
false
) { "Failed to resolve RPA\naddress=${address.redactedForLogs()}\nIRK=${irk.size}B\n${e.asLog()}" }
null
}
/** Six octets, most-significant first, as printed. Null if [this] isn't a well-formed address. */
private fun BluetoothAddress.parseOctets(): ByteArray? {
val parts = split(":")
if (parts.size != ADDRESS_OCTETS) return null
val octets = ByteArray(ADDRESS_OCTETS)
parts.forEachIndexed { index, part ->
val value = part.toIntOrNull(16) ?: return null
if (value !in 0..255) return null
octets[index] = value.toByte()
}
return octets
}
private fun ByteArray.isRpaShaped(): Boolean = (this[0].toInt() and 0xC0) == 0x40
private fun matchesHash(octets: ByteArray, irk: IdentityResolvingKey): Boolean {
val rpa = octets.reversedArray()
val prand = rpa.copyOfRange(3, 6)
val hash = rpa.copyOfRange(0, 3)
return hash.contentEquals(ah(irk, prand))
}
// E function (Encryption function):
@@ -47,6 +90,7 @@ class RPAChecker @Inject constructor() {
}
companion object {
private const val ADDRESS_OCTETS = 6
private val TAG = logTag("Monitor", "BlePodMonitor", "RPAChecker")
}
}
+1 -1
View File
@@ -283,7 +283,7 @@
<string name="last_seen_x">ለመጨረሻ ጊዜ የታየው: %s</string>
<string name="first_seen_x">ለመጀመሪያ ጊዜ የታየው: %s</string>
<string name="battery_cached_label">የመጨረሻ ታወቀ · %s</string>
<string name="battery_time_remaining_format_hm">%1$dሰ %2$ደ</string>
<string name="battery_time_remaining_format_hm">%1$dሰ %2$d</string>
<string name="battery_time_remaining_format_h">%1$dሰ</string>
<string name="battery_time_remaining_format_m">%1$dደ</string>
<string name="permission_post_notifications_label">ማሳወቂያዎችን አሳይ</string>
+27 -6
View File
@@ -129,11 +129,11 @@
<string name="settings_overview_hide_unmatched_label">Hide unmatched devices</string>
<string name="settings_overview_hide_unmatched_description">Don\'t show nearby devices that don\'t match any of your profiles in the overview, e.g. other people\'s AirPods.</string>
<string name="device_battery_estimate_toggle_label">Battery estimates</string>
<string name="device_battery_estimate_card_desc">Learn how this device charges and drains to estimate time remaining, time until charged, and battery health.</string>
<string name="device_battery_estimate_card_desc">Learn how this device charges and drains to estimate time remaining, time until charged, and how much of its rated listening time it still reaches.</string>
<string name="device_battery_estimate_reset_action">Reset learned data</string>
<string name="device_battery_estimate_reset_desc">Start over from the rated battery life.</string>
<string name="device_battery_estimate_reset_confirm_title">Reset learned data?</string>
<string name="device_battery_estimate_reset_confirm_message">CAPod will forget this device\'s measured drain, charging speed and battery health, and start over from the rated battery life.</string>
<string name="device_battery_estimate_reset_confirm_message">CAPod will forget this device\'s measured drain, charging speed and listening time, and start over from the rated battery life.</string>
<string name="settings_acknowledgements_label">Acknowledgements</string>
<string name="settings_debug_autoreports_label">Automatic bug reports</string>
@@ -274,6 +274,24 @@
<string name="pods_dual_right_short_label">R</string>
<string name="pods_case_label">Case</string>
<string name="battery_unavailable_label">Battery unavailable</string>
<string name="battery_state_low_cd">Battery low</string>
<string name="battery_state_critical_cd">Battery critically low</string>
<!-- Case charges: how often the case can still fully recharge BOTH earbuds. Not charge cycles, and not a statement that anything is charging right now. -->
<plurals name="battery_case_charges_approx">
<item quantity="one">~%d more full charge for both earbuds</item>
<item quantity="other">~%d more full charges for both earbuds</item>
</plurals>
<plurals name="battery_case_charges_at_least">
<item quantity="one">At least %d more full charge for both earbuds</item>
<item quantity="other">At least %d more full charges for both earbuds</item>
</plurals>
<string name="battery_case_charges_less_than_one">Less than one more full charge for both earbuds</string>
<string name="battery_case_charges_empty">Case is empty, no charges left for your earbuds</string>
<!-- Shown when the case reading is too coarse to name any number of charges. Must not mention a count. -->
<string name="battery_case_charges_uncertain">Not sure how many full charges are left for both earbuds</string>
<string name="battery_case_charges_state_enough_cd">Enough for a full charge</string>
<string name="battery_case_charges_state_not_enough_cd">Not enough for a full charge</string>
<string name="battery_case_charges_state_uncertain_cd">May not be enough for a full charge</string>
<string name="pods_case_status_open_label">Open</string>
<string name="pods_case_status_closed_label">Closed</string>
<string name="pods_connection_state_disconnected_label">Not connected to a device</string>
@@ -475,11 +493,14 @@
<string name="device_settings_info_right_serial_label">Right Pod Serial</string>
<string name="device_settings_info_left_bonded_label">Left Bonded</string>
<string name="device_settings_info_right_bonded_label">Right Bonded</string>
<string name="device_settings_info_battery_health_label">Battery Health (estimated)</string>
<string name="device_settings_info_battery_health_left_label">Left Battery Health (est.)</string>
<string name="device_settings_info_battery_health_right_label">Right Battery Health (est.)</string>
<string name="device_settings_info_battery_health_value">~%1$d%%</string>
<string name="device_settings_info_battery_health_label">Estimated listening time</string>
<string name="device_settings_info_battery_health_left_label">Left pod listening time</string>
<string name="device_settings_info_battery_health_right_label">Right pod listening time</string>
<string name="device_settings_info_battery_health_value">~%1$d%% of rated</string>
<string name="device_settings_info_battery_health_pending">Still determining, check back later</string>
<string name="device_settings_battery_runtime_warning_title">Listening time is much shorter than rated</string>
<string name="device_settings_battery_runtime_warning_description">%1$s: about %2$d%% of the rated listening time. Tap for details.</string>
<string name="device_settings_battery_runtime_warning_headset_label">These headphones</string>
<string name="device_settings_info_details_label">Device Details</string>
<string name="device_settings_info_details_action">Show device details</string>
<string name="device_settings_info_status_label">Status</string>
@@ -4,26 +4,17 @@ import android.content.res.Configuration
import androidx.compose.ui.tooling.preview.Preview
/**
* Multi-preview annotation generating one preview per Play Store-supported locale (light mode).
* Each [name] is the fastlane metadata directory name for direct use in the copy script.
* Placeholder locale set. `fastlane/generate_screenshots.sh` rewrites this file per batch and
* restores it afterwards, so the committed content only decides what a bare
* `./gradlew updateGplayDebugScreenshotTest` renders.
*
* [name] is the fastlane metadata directory the copy script sorts the output into.
*/
@Preview(locale = "en", name = "en-US", device = DS)
@Preview(locale = "af", name = "af", device = DS)
@Preview(locale = "am", name = "am", device = DS)
@Preview(locale = "ar", name = "ar", device = DS)
annotation class PlayStoreLocales
/**
* Same locales but with night mode enabled for dark theme screenshots.
*/
@Preview(locale = "en", name = "en-US", device = DS, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Preview(locale = "af", name = "af", device = DS, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Preview(locale = "am", name = "am", device = DS, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Preview(locale = "ar", name = "ar", device = DS, uiMode = Configuration.UI_MODE_NIGHT_YES)
annotation class PlayStoreLocalesDark
/**
* Smoke test subset for fast iteration (6 locales covering LTR, RTL, CJK).
*/
@Preview(locale = "en", name = "en-US", device = DS)
annotation class PlayStoreLocalesSmoke
@@ -0,0 +1,120 @@
package eu.darken.capod.common.bluetooth
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothManager
import android.content.Context
import io.kotest.matchers.shouldBe
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
import testhelpers.TestTimeSource
import testhelpers.coroutine.TestDispatcherProvider
import java.time.Duration
class BluetoothManager2Test : BaseTest() {
private val deviceA = mockk<BluetoothDevice>().apply {
every { address } returns ADDRESS_A
every { name } returns "Pods A"
}
private val deviceB = mockk<BluetoothDevice>().apply {
every { address } returns ADDRESS_B
every { name } returns "Pods B"
}
private val btAdapter = mockk<BluetoothAdapter>().apply {
every { bondedDevices } returns setOf(deviceA, deviceB)
}
private val btManager = mockk<BluetoothManager>().apply {
every { adapter } returns btAdapter
}
private val timeSource = TestTimeSource()
// Unconfined, so the appScope.launch bodies of the mark* methods complete in place.
private val appScope = CoroutineScope(UnconfinedTestDispatcher())
@AfterEach
fun teardown() {
appScope.cancel()
}
private fun create() = BluetoothManager2(
appScope = appScope,
dispatcherProvider = TestDispatcherProvider(),
context = mockk<Context>(),
manager = btManager,
timeSource = timeSource,
)
private suspend fun BluetoothManager2.bonded(address: BluetoothAddress) =
bondedDevices().first().single { it.address == address }
/**
* A bonded device is not necessarily a connected one, so querying bonded devices must not put a
* timestamp into the cache that [BluetoothManager2.connectedDevices] later reads as a connect
* time.
*/
@Test
fun `querying bonded devices does not cache a timestamp`() = runTest {
val manager = create()
manager.bonded(ADDRESS_A).seenFirstAt shouldBe timeSource.now()
timeSource.advanceBy(Duration.ofSeconds(60))
manager.bonded(ADDRESS_A).seenFirstAt shouldBe timeSource.now()
}
/**
* The ACL broadcast arrives whether or not anything is collecting the connected-devices flow,
* so the stamp it leaves has to survive until the device disconnects.
*/
@Test
fun `an ACL connect stamp survives the passage of time`() = runTest {
val manager = create()
val connectedAt = timeSource.now()
manager.markDeviceConnected(ADDRESS_A)
timeSource.advanceBy(Duration.ofSeconds(60))
manager.bonded(ADDRESS_A).seenFirstAt shouldBe connectedAt
}
@Test
fun `a repeated ACL connect keeps the first stamp`() = runTest {
val manager = create()
val connectedAt = timeSource.now()
manager.markDeviceConnected(ADDRESS_A)
timeSource.advanceBy(Duration.ofSeconds(60))
manager.markDeviceConnected(ADDRESS_A)
manager.bonded(ADDRESS_A).seenFirstAt shouldBe connectedAt
}
@Test
fun `an ACL disconnect drops the stamp`() = runTest {
val manager = create()
manager.markDeviceConnected(ADDRESS_A)
timeSource.advanceBy(Duration.ofSeconds(60))
manager.markDeviceDisconnected(ADDRESS_A)
manager.bonded(ADDRESS_A).seenFirstAt shouldBe timeSource.now()
}
companion object {
private const val ADDRESS_A = "AA:BB:CC:DD:EE:F1"
private const val ADDRESS_B = "AA:BB:CC:DD:EE:F2"
}
}
@@ -0,0 +1,121 @@
package eu.darken.capod.common.theming
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.unit.dp
import io.kotest.assertions.withClue
import io.kotest.matchers.doubles.shouldBeGreaterThanOrEqual
import io.kotest.matchers.shouldBe
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
/**
* The overview's battery gauges sit on a `Surface(tonalElevation = 4.dp)` inside an `ElevatedCard`,
* and a card without live data is drawn at `alpha(0.7f)`. Both variants are measured here: checking
* a token against the raw `surface` colour would pass values that are unreadable on screen.
*
* Material You is dynamic and can't be enumerated, so the tokens are fixed and verified against the
* full spread of the bundled palettes instead.
*/
class BatteryColorsContrastTest : BaseTest() {
private val gaugeElevation = 4.dp
private val notLiveAlpha = 0.7f
/** WCAG 2.x minimum for a graphical object such as a gauge fill. */
private val fillMinimum = 3.0
/** WCAG 2.x minimum for text. */
private val textMinimum = 4.5
private data class Palette(
val name: String,
val scheme: ColorScheme,
val tokens: BatteryColors,
) {
/** Container colour of the ElevatedCard the gauges are drawn in. */
val cardBackground: Color get() = scheme.surfaceContainerLow
}
private val palettes: List<Palette> = buildList {
ThemeColor.entries.forEach { color ->
ThemeStyle.entries.filter { it != ThemeStyle.MATERIAL_YOU }.forEach { style ->
add(
Palette(
name = "$color/$style/light",
scheme = ThemeColorProvider.getLightColorScheme(color, style),
tokens = BatteryColors.Light,
)
)
add(
Palette(
name = "$color/$style/dark",
scheme = ThemeColorProvider.getDarkColorScheme(color, style),
tokens = BatteryColors.Dark,
)
)
}
}
}
private fun contrast(a: Color, b: Color): Double {
val brighter = maxOf(a.luminance(), b.luminance()).toDouble()
val darker = minOf(a.luminance(), b.luminance()).toDouble()
return (brighter + 0.05) / (darker + 0.05)
}
private fun Color.dimmed(over: Color): Color = copy(alpha = notLiveAlpha).compositeOver(over)
private fun Palette.assertContrast(token: Color, against: Color, minimum: Double, what: String) {
withClue("$what, $name") {
contrast(token, against) shouldBeGreaterThanOrEqual minimum
}
withClue("$what, $name, card without live data") {
contrast(token.dimmed(cardBackground), against.dimmed(cardBackground)) shouldBeGreaterThanOrEqual minimum
}
}
@Test
fun `every bundled palette is covered`() {
palettes.size shouldBe 18
}
@Test
fun `the warn fill stands out from the gauge track`() {
palettes.forEach {
it.assertContrast(
token = it.tokens.warnFill,
against = it.scheme.surfaceVariant,
minimum = fillMinimum,
what = "warn fill",
)
}
}
@Test
fun `the warn text is readable on the gauge surface`() {
palettes.forEach {
it.assertContrast(
token = it.tokens.warnText,
against = it.scheme.surfaceColorAtElevation(gaugeElevation),
minimum = textMinimum,
what = "warn text",
)
}
}
@Test
fun `the positive text is readable on the gauge surface`() {
palettes.forEach {
it.assertContrast(
token = it.tokens.positiveText,
against = it.scheme.surfaceColorAtElevation(gaugeElevation),
minimum = textMinimum,
what = "positive text",
)
}
}
}
@@ -0,0 +1,88 @@
package eu.darken.capod.common.theming
import androidx.compose.material3.MaterialTheme
import androidx.compose.ui.graphics.Color
import eu.darken.capod.monitor.core.battery.BatteryTier
import io.kotest.matchers.nulls.shouldBeNull
import io.kotest.matchers.shouldBe
import org.junit.Test
import testhelpers.compose.BaseComposeRobolectricTest
/**
* Colour is not part of the semantics tree and `captureToImage()` does not work under Robolectric,
* so what a rendered card looks like is checked in two halves: this class pins the colour each tier
* resolves to, and the card tests pin which tier each slot reports.
*/
class BatteryColorsThemeTest : BaseComposeRobolectricTest() {
private class Resolved {
var warnFill: Color = Color.Unspecified
var criticalFill: Color = Color.Unspecified
var goodFill: Color = Color.Unspecified
var unknownFill: Color = Color.Unspecified
var warnText: Color? = null
var criticalText: Color? = null
var goodText: Color? = null
var unknownText: Color? = null
var error: Color = Color.Unspecified
var primary: Color = Color.Unspecified
var surfaceVariant: Color = Color.Unspecified
}
private fun resolve(mode: ThemeMode): Resolved {
val resolved = Resolved()
composeRule.setContent {
CapodTheme(state = ThemeState(mode = mode)) {
resolved.warnFill = BatteryTier.WARN.fillColor()
resolved.criticalFill = BatteryTier.CRITICAL.fillColor()
resolved.goodFill = BatteryTier.GOOD.fillColor()
resolved.unknownFill = BatteryTier.UNKNOWN.fillColor()
resolved.warnText = BatteryTier.WARN.textColorOrNull()
resolved.criticalText = BatteryTier.CRITICAL.textColorOrNull()
resolved.goodText = BatteryTier.GOOD.textColorOrNull()
resolved.unknownText = BatteryTier.UNKNOWN.textColorOrNull()
resolved.error = MaterialTheme.colorScheme.error
resolved.primary = MaterialTheme.colorScheme.primary
resolved.surfaceVariant = MaterialTheme.colorScheme.surfaceVariant
}
}
composeRule.waitForIdle()
return resolved
}
@Test
fun `the light theme uses the light tokens`() {
val resolved = resolve(ThemeMode.LIGHT)
resolved.warnFill shouldBe BatteryColors.Light.warnFill
resolved.warnText shouldBe BatteryColors.Light.warnText
}
@Test
fun `an in-app dark override uses the dark tokens`() {
// The host system is in light mode here, which is exactly the case a plain
// isSystemInDarkTheme() read inside the mapper would get wrong.
val resolved = resolve(ThemeMode.DARK)
resolved.warnFill shouldBe BatteryColors.Dark.warnFill
resolved.warnText shouldBe BatteryColors.Dark.warnText
}
@Test
fun `the other tiers stay on the palette`() {
val resolved = resolve(ThemeMode.LIGHT)
resolved.criticalFill shouldBe resolved.error
resolved.criticalText shouldBe resolved.error
resolved.goodFill shouldBe resolved.primary
resolved.unknownFill shouldBe resolved.surfaceVariant
}
@Test
fun `only a warning or worse claims the text colour`() {
val resolved = resolve(ThemeMode.LIGHT)
resolved.goodText.shouldBeNull()
resolved.unknownText.shouldBeNull()
}
}
@@ -576,7 +576,103 @@ class DeviceSettingsViewModelTest : BaseTest() {
val vm = createViewModel()
vm.initialize(testAddress)
vm.state.first().batteryHealth shouldBe BatteryHealth.PerPod(left = 50)
vm.state.first().batteryHealth shouldBe BatteryHealth.PerPod(
left = BatteryHealth.Reading(percent = 50, isPromotable = false),
)
}
@Test
fun `low runtime on recent evidence raises a warning`() = runVmTest {
val device = mockk<PodDevice>(relaxed = true).also {
every { it.profileId } returns testAddress
every { it.model } returns PodModel.AIRPODS_PRO2
}
devicesFlow.value = listOf(device)
drainProfilesFlow.value = mapOf(
testAddress to DrainProfile(
model = PodModel.AIRPODS_PRO2.name,
listeningRates = mapOf(
"UNKNOWN/LEFT" to DrainProfile.LearnedRate(
fractionPerHour = 1f / 3f,
sampleCount = 10,
updateCount = BatteryHealth.MIN_PROMOTE_UPDATE_COUNT,
updatedAt = timeSource.now(),
)
),
)
)
val vm = createViewModel()
vm.initialize(testAddress)
vm.state.first().batteryRuntimeWarning shouldBe BatteryHealth.SlotReading(
slot = BatteryHealth.Slot.LEFT,
reading = BatteryHealth.Reading(percent = 50, isPromotable = true),
)
}
@Test
fun `low runtime on thin evidence raises no warning`() = runVmTest {
val device = mockk<PodDevice>(relaxed = true).also {
every { it.profileId } returns testAddress
every { it.model } returns PodModel.AIRPODS_PRO2
}
devicesFlow.value = listOf(device)
drainProfilesFlow.value = mapOf(
testAddress to DrainProfile(
model = PodModel.AIRPODS_PRO2.name,
listeningRates = mapOf(
"UNKNOWN/LEFT" to DrainProfile.LearnedRate(
fractionPerHour = 1f / 3f,
sampleCount = 10,
updateCount = BatteryHealth.MIN_PROMOTE_UPDATE_COUNT - 1,
updatedAt = timeSource.now(),
)
),
)
)
val vm = createViewModel()
vm.initialize(testAddress)
val state = vm.state.first()
state.batteryHealth?.left?.percent shouldBe 50
state.batteryRuntimeWarning shouldBe null
}
@Test
fun `the warning follows the per-device estimate toggle`() = runVmTest {
val device = mockk<PodDevice>(relaxed = true).also {
every { it.profileId } returns testAddress
every { it.model } returns PodModel.AIRPODS_PRO2
}
devicesFlow.value = listOf(device)
drainProfilesFlow.value = mapOf(
testAddress to DrainProfile(
model = PodModel.AIRPODS_PRO2.name,
listeningRates = mapOf(
"UNKNOWN/LEFT" to DrainProfile.LearnedRate(
fractionPerHour = 1f / 3f,
sampleCount = 10,
updateCount = BatteryHealth.MIN_PROMOTE_UPDATE_COUNT,
updatedAt = timeSource.now(),
)
),
)
)
profilesFlow.value = listOf(
AppleDeviceProfile(
id = testAddress,
label = "Test",
address = testAddress,
batteryEstimateEnabled = false,
)
)
val vm = createViewModel()
vm.initialize(testAddress)
vm.state.first().batteryRuntimeWarning shouldBe null
}
@Test
@@ -0,0 +1,128 @@
package eu.darken.capod.main.ui.overview.cards
import android.content.Context
import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.test.SemanticsMatcher
import androidx.compose.ui.test.assert
import androidx.compose.ui.test.onNodeWithText
import androidx.test.core.app.ApplicationProvider
import eu.darken.capod.R
import eu.darken.capod.common.compose.PreviewWrapper
import eu.darken.capod.common.compose.preview.MOCK_NOW
import eu.darken.capod.common.compose.preview.MockPodDataProvider
import eu.darken.capod.monitor.core.PodDevice
import org.junit.Test
import testhelpers.compose.BaseComposeRobolectricTest
/**
* Every slot on the overview reports its level as a state description, which is both the screen
* reader's only access to the threshold and the observable half of the colour: the tier a slot
* announces here is the same tier it hands to the colour mapper covered by BatteryColorsThemeTest.
*/
class BatteryLevelStateTest : BaseComposeRobolectricTest() {
private val context: Context
get() = ApplicationProvider.getApplicationContext()
private val low: String
get() = context.getString(R.string.battery_state_low_cd)
private val critical: String
get() = context.getString(R.string.battery_state_critical_cd)
/** Left pod low, right pod critical, case low — three distinct percentages, three tiers. */
private fun mixedDualPods(): PodDevice = MockPodDataProvider.dualPodBatteries(
left = 0.22f,
right = 0.05f,
case = 0.20f,
)
private fun setDualPods(device: PodDevice, collapsed: Boolean) {
composeRule.setContent {
PreviewWrapper {
DualPodsCard(
device = device,
showDebug = false,
now = MOCK_NOW,
isCollapsed = collapsed,
onToggleCollapse = {},
)
}
}
}
private fun assertState(text: String, expected: String) {
composeRule.onNodeWithText(text)
.assert(SemanticsMatcher.expectValue(SemanticsProperties.StateDescription, expected))
}
@Test
fun `the expanded pod gauges report their level`() {
setDualPods(mixedDualPods(), collapsed = false)
assertState("22%", low)
assertState("5%", critical)
}
@Test
fun `the expanded case row reports its level`() {
setDualPods(mixedDualPods(), collapsed = false)
assertState("20%", low)
}
@Test
fun `the collapsed pod rings report their level`() {
setDualPods(mixedDualPods(), collapsed = true)
assertState("22%", low)
assertState("5%", critical)
}
@Test
fun `the collapsed case cluster reports its level`() {
setDualPods(mixedDualPods(), collapsed = true)
assertState("20%", low)
}
@Test
fun `the single pod gauge reports a low level`() {
composeRule.setContent {
PreviewWrapper {
SinglePodsCard(
device = MockPodDataProvider.singlePodBattery(percent = 0.20f),
showDebug = false,
now = MOCK_NOW,
)
}
}
assertState("20%", low)
}
@Test
fun `the single pod gauge reports a critical level`() {
composeRule.setContent {
PreviewWrapper {
SinglePodsCard(
device = MockPodDataProvider.singlePodBattery(percent = 0.08f),
showDebug = false,
now = MOCK_NOW,
)
}
}
assertState("8%", critical)
}
@Test
fun `a healthy level makes no claim`() {
setDualPods(MockPodDataProvider.dualPodBatteries(left = 0.80f, right = 0.45f, case = 0.60f), collapsed = false)
composeRule.onNodeWithText("80%")
.assert(SemanticsMatcher.keyIsDefined(SemanticsProperties.StateDescription).not())
composeRule.onNodeWithText("60%")
.assert(SemanticsMatcher.keyIsDefined(SemanticsProperties.StateDescription).not())
}
}
@@ -0,0 +1,170 @@
package eu.darken.capod.main.ui.overview.cards
import android.content.Context
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.test.SemanticsMatcher
import androidx.compose.ui.test.assert
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.test.core.app.ApplicationProvider
import eu.darken.capod.R
import eu.darken.capod.common.compose.PreviewWrapper
import eu.darken.capod.common.compose.preview.MOCK_NOW
import eu.darken.capod.common.compose.preview.MockPodDataProvider
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.pods.core.apple.PodModel
import org.junit.Test
import testhelpers.compose.BaseComposeRobolectricTest
/**
* The mock case battery resolves at decile granularity, so a 4.0-charge spec (AirPods Pro 2) puts
* 30% wholly above a full charge, 10% wholly below it, and 20% astride it.
*/
class CaseChargesLineTest : BaseComposeRobolectricTest() {
private val context: Context
get() = ApplicationProvider.getApplicationContext()
private fun charges(count: Int): String =
context.resources.getQuantityString(R.plurals.battery_case_charges_approx, count, count)
private fun device(case: Float, model: PodModel = PodModel.AIRPODS_PRO2): PodDevice =
MockPodDataProvider.dualPodBatteries(case = case, model = model)
private fun setCard(
device: PodDevice,
wrapper: @Composable (@Composable () -> Unit) -> Unit = { it() },
) {
composeRule.setContent {
PreviewWrapper {
wrapper {
DualPodsCard(
device = device,
showDebug = false,
now = MOCK_NOW,
)
}
}
}
}
private fun assertAdequacy(line: String, expected: Int) {
composeRule.onNodeWithText(line).assert(
SemanticsMatcher.expectValue(SemanticsProperties.StateDescription, context.getString(expected))
)
}
@Test
fun `a model with published case figures gets a line`() {
setCard(device(case = 0.60f))
// 4.0 x 0.60 = 2.4
composeRule.onNodeWithText(charges(2)).assertIsDisplayed()
}
@Test
fun `a model without published case figures gets no line`() {
setCard(device(case = 0.60f, model = PodModel.POWERBEATS_PRO))
composeRule.onAllNodesWithText(charges(2)).assertCountEquals(0)
composeRule.onAllNodesWithText(charges(1)).assertCountEquals(0)
composeRule.onAllNodesWithText(context.getString(R.string.battery_case_charges_less_than_one))
.assertCountEquals(0)
}
@Test
fun `a full charge in hand reads as enough`() {
setCard(device(case = 0.30f))
assertAdequacy(charges(1), R.string.battery_case_charges_state_enough_cd)
}
@Test
fun `a reading astride a full charge rounds to one`() {
setCard(device(case = 0.20f))
assertAdequacy(charges(1), R.string.battery_case_charges_state_uncertain_cd)
}
@Test
fun `an open ended spec below a full charge names no number`() {
// AirPods (Gen 1) publish a lower bound, so 3.8 x 0.10 has no upper end to round to
setCard(device(case = 0.10f, model = PodModel.AIRPODS_GEN1))
assertAdequacy(
context.getString(R.string.battery_case_charges_uncertain),
R.string.battery_case_charges_state_uncertain_cd,
)
}
@Test
fun `a reading below a full charge reads as not enough`() {
setCard(device(case = 0.10f))
assertAdequacy(
context.getString(R.string.battery_case_charges_less_than_one),
R.string.battery_case_charges_state_not_enough_cd,
)
}
@Test
fun `an empty case says so instead of counting`() {
setCard(device(case = 0f))
composeRule.onNodeWithText(context.getString(R.string.battery_case_charges_empty)).assertIsDisplayed()
composeRule.onAllNodesWithText(context.getString(R.string.battery_case_charges_less_than_one))
.assertCountEquals(0)
}
@Test
fun `an empty case on an open ended spec still reads as not enough`() {
setCard(device(case = 0f, model = PodModel.AIRPODS_GEN1))
assertAdequacy(
context.getString(R.string.battery_case_charges_empty),
R.string.battery_case_charges_state_not_enough_cd,
)
}
@Test
fun `the line survives a right-to-left layout`() {
setCard(device(case = 0.60f)) { card ->
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) { card() }
}
composeRule.onNodeWithText(charges(2)).assertIsDisplayed()
composeRule.onNodeWithText("60%").assertIsDisplayed()
}
@Test
fun `the line survives a narrow card`() {
setCard(device(case = 0.60f)) { card ->
Box(modifier = Modifier.width(240.dp)) { card() }
}
composeRule.onNodeWithText(charges(2)).assertIsDisplayed()
composeRule.onNodeWithText("60%").assertIsDisplayed()
}
@Test
fun `the line survives a large font scale`() {
setCard(device(case = 0.60f)) { card ->
CompositionLocalProvider(LocalDensity provides Density(density = 1f, fontScale = 2f)) { card() }
}
composeRule.onNodeWithText(charges(2)).assertIsDisplayed()
composeRule.onNodeWithText("60%").assertIsDisplayed()
}
}
@@ -1,13 +1,20 @@
package eu.darken.capod.monitor.core
import eu.darken.capod.common.debug.logging.Logging
import eu.darken.capod.common.fromHex
import eu.darken.capod.common.toHex
import eu.darken.capod.pods.core.apple.ble.protocol.RPAChecker
import io.kotest.matchers.nulls.shouldBeNull
import io.kotest.matchers.shouldBe
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
import testhelpers.logging.JUnitLogger
class RPACheckerTest : BaseTest() {
private val irkHex = "79-04-65-1E-E2-CC-D9-26-F2-6E-20-EE-3E-CC-DE-79"
private val resolvingAddress = "5A:16:2B:91:D1:CD"
@Test
fun `test check`() {
val checker = RPAChecker()
@@ -37,4 +44,150 @@ class RPACheckerTest : BaseTest() {
irk = "".fromHex(),
) shouldBe false
}
@Test
fun `resolve reports the standard octet order`() {
val checker = RPAChecker()
checker.resolve(
address = resolvingAddress,
irk = irkHex.fromHex(),
) shouldBe RPAChecker.AddressOrder.STANDARD
checker.verify(
address = resolvingAddress,
irk = irkHex.fromHex(),
) shouldBe true
}
@Test
fun `resolve reports the reversed octet order`() {
val checker = RPAChecker()
// The resolving address with its octets reversed, as a vendor stack delivering them backwards.
checker.resolve(
address = "CD:D1:91:2B:16:5A",
irk = irkHex.fromHex(),
) shouldBe RPAChecker.AddressOrder.REVERSED
checker.verify(
address = "CD:D1:91:2B:16:5A",
irk = irkHex.fromHex(),
) shouldBe true
}
@Test
fun `a reversed candidate still has to pass the hash comparison`() {
val checker = RPAChecker()
// Reversed form 5B:16:2B:91:D1:CD is RPA-shaped, so the marker gate passes and only the
// hash comparison can reject it.
checker.resolve(
address = "CD:D1:91:2B:16:5B",
irk = irkHex.fromHex(),
).shouldBeNull()
checker.verify(
address = "CD:D1:91:2B:16:5B",
irk = irkHex.fromHex(),
) shouldBe false
}
@Test
fun `a reversed candidate is gated on the RPA type marker`() {
val checker = RPAChecker()
// Reversed form 1A:16:2B:85:33:DC resolves cryptographically, but its type marker is 00,
// so only the gate rejects it.
checker.resolve(
address = "DC:33:85:2B:16:1A",
irk = irkHex.fromHex(),
).shouldBeNull()
checker.verify(
address = "DC:33:85:2B:16:1A",
irk = irkHex.fromHex(),
) shouldBe false
}
@Test
fun `malformed addresses are rejected instead of mis-sliced`() {
val checker = RPAChecker()
// Seven components whose tail is the resolving address — sliced rather than rejected, this
// resolves.
checker.resolve(
address = "00:$resolvingAddress",
irk = irkHex.fromHex(),
).shouldBeNull()
checker.verify(
address = "00:$resolvingAddress",
irk = irkHex.fromHex(),
) shouldBe false
// 0x15A truncates to the resolving octet 0x5A.
checker.resolve(
address = "15A:16:2B:91:D1:CD",
irk = irkHex.fromHex(),
).shouldBeNull()
checker.verify(
address = "15A:16:2B:91:D1:CD",
irk = irkHex.fromHex(),
) shouldBe false
checker.resolve(
address = "5A:16:2B:91:D1",
irk = irkHex.fromHex(),
).shouldBeNull()
}
@Test
fun `the failure log carries neither the identity key nor the full address`() {
val malformedIrk = "79-04-65-1E-E2-CC-D9-26-F2-6E-20-EE-3E-CC-DE".fromHex()
val captured = mutableListOf<Pair<Logging.Priority, String>>()
val capturingLogger = object : Logging.Logger {
override fun log(
priority: Logging.Priority,
tag: String,
message: String,
metaData: Map<String, Any>?,
) {
captured.add(priority to message)
}
}
Logging.clearAll()
Logging.install(capturingLogger)
try {
RPAChecker().verify(address = resolvingAddress, irk = malformedIrk) shouldBe false
} finally {
Logging.clearAll()
Logging.install(JUnitLogger())
}
captured.any { (priority, message) ->
priority == Logging.Priority.ERROR && message.contains("Failed to resolve RPA")
} shouldBe true
val keyHex = malformedIrk.toHex(separator = "")
captured.any { (_, message) -> message.contains(keyHex, ignoreCase = true) } shouldBe false
captured.any { (_, message) -> message.contains(resolvingAddress) } shouldBe false
}
@Test
fun `a malformed address is logged as a warning`() {
val captured = mutableListOf<Pair<Logging.Priority, String>>()
val capturingLogger = object : Logging.Logger {
override fun log(
priority: Logging.Priority,
tag: String,
message: String,
metaData: Map<String, Any>?,
) {
captured.add(priority to message)
}
}
Logging.clearAll()
Logging.install(capturingLogger)
try {
RPAChecker().verify(address = "123", irk = irkHex.fromHex()) shouldBe false
} finally {
Logging.clearAll()
Logging.install(JUnitLogger())
}
captured.any { (priority, message) ->
priority == Logging.Priority.WARN && message.contains("malformed")
} shouldBe true
}
}
@@ -0,0 +1,108 @@
package eu.darken.capod.monitor.core.aap
import eu.darken.capod.common.bluetooth.BluetoothAddress
import eu.darken.capod.common.fromHex
import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
import eu.darken.capod.pods.core.apple.aap.protocol.KeyExchangeResult
import eu.darken.capod.profiles.core.AppleDeviceProfile
import eu.darken.capod.profiles.core.DeviceProfile
import eu.darken.capod.profiles.core.DeviceProfilesRepo
import io.kotest.matchers.shouldBe
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.slot
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
/**
* The key fields on [AppleDeviceProfile] are ByteArrays, so a wrong null-handling or
* reference-comparison here either writes the profile on every connect or stops persisting keys
* entirely.
*/
class AapKeyPersisterTest : BaseTest() {
private val address: BluetoothAddress = "AA:BB:CC:DD:EE:FF"
private val irkHex = "79-04-65-1E-E2-CC-D9-26-F2-6E-20-EE-3E-CC-DE-79"
private val encHex = "11-22-33-44-55-66-77-88-99-AA-BB-CC-DD-EE-FF-00"
private val aapManager = mockk<AapConnectionManager>()
private val profilesRepo = mockk<DeviceProfilesRepo>()
private suspend fun runPersister(profile: AppleDeviceProfile, keys: KeyExchangeResult) {
val keysReceived = MutableSharedFlow<Pair<BluetoothAddress, KeyExchangeResult>>(replay = 1)
keysReceived.tryEmit(address to keys)
every { aapManager.keysReceived } returns keysReceived
every { profilesRepo.profiles } returns flowOf(listOf(profile))
coEvery { profilesRepo.updateProfile(any()) } returns Unit
AapKeyPersister(aapManager, profilesRepo).monitor().first()
}
private fun profile(
identityKey: ByteArray? = null,
encryptionKey: ByteArray? = null,
) = AppleDeviceProfile(
label = "Mine",
model = PodModel.AIRPODS_PRO2_USBC,
identityKey = identityKey,
encryptionKey = encryptionKey,
address = address,
)
private fun capturedProfile(): AppleDeviceProfile {
val captured = slot<DeviceProfile>()
coVerify(exactly = 1) { profilesRepo.updateProfile(capture(captured)) }
return captured.captured as AppleDeviceProfile
}
@Test
fun `a first key is persisted`() = runTest {
runPersister(
profile = profile(),
keys = KeyExchangeResult(irk = irkHex.fromHex(), encKey = null),
)
capturedProfile().identityKey.contentEquals(irkHex.fromHex()) shouldBe true
}
@Test
fun `a key equal by content is not persisted again`() = runTest {
runPersister(
profile = profile(identityKey = irkHex.fromHex(), encryptionKey = encHex.fromHex()),
keys = KeyExchangeResult(irk = irkHex.fromHex(), encKey = encHex.fromHex()),
)
coVerify(exactly = 0) { profilesRepo.updateProfile(any()) }
}
@Test
fun `a missing incoming key leaves the stored one alone`() = runTest {
runPersister(
profile = profile(identityKey = irkHex.fromHex(), encryptionKey = encHex.fromHex()),
keys = KeyExchangeResult(irk = null, encKey = encHex.fromHex()),
)
coVerify(exactly = 0) { profilesRepo.updateProfile(any()) }
}
@Test
fun `a changed encryption key is persisted without touching the identity key`() = runTest {
val newEnc = "00-11-22-33-44-55-66-77-88-99-AA-BB-CC-DD-EE-FF".fromHex()
runPersister(
profile = profile(identityKey = irkHex.fromHex(), encryptionKey = encHex.fromHex()),
keys = KeyExchangeResult(irk = irkHex.fromHex(), encKey = newEnc),
)
val written = capturedProfile()
written.encryptionKey.contentEquals(newEnc) shouldBe true
written.identityKey.contentEquals(irkHex.fromHex()) shouldBe true
}
}
@@ -6,27 +6,33 @@ import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.shouldBe
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
import java.time.Duration
import java.time.Instant
class BatteryHealthTest : BaseTest() {
private fun rate(fractionPerHour: Float, updateCount: Int = BatteryHealth.MIN_UPDATE_COUNT) =
DrainProfile.LearnedRate(
fractionPerHour = fractionPerHour,
sampleCount = 10,
updateCount = updateCount,
updatedAt = Instant.EPOCH,
)
private val now: Instant = Instant.parse("2026-01-01T00:00:00Z")
private fun rate(
fractionPerHour: Float,
updateCount: Int = BatteryHealth.MIN_UPDATE_COUNT,
updatedAt: Instant = now,
) = DrainProfile.LearnedRate(
fractionPerHour = fractionPerHour,
sampleCount = 10,
updateCount = updateCount,
updatedAt = updatedAt,
)
@Test
fun `health is the ratio of rated to learned drain`() {
fun `runtime is the ratio of rated to learned drain`() {
// Pro 2 is rated 6h (0.1667/hr); a pod that only manages 3h (0.3333/hr) is at ~50%.
val profile = DrainProfile(listeningRates = mapOf("UNKNOWN/LEFT" to rate(1f / 3f)))
BatteryHealth.estimate(profile, PodModel.AIRPODS_PRO2).shouldNotBeNull().left shouldBe 50
BatteryHealth.estimate(profile, PodModel.AIRPODS_PRO2, now).shouldNotBeNull().left?.percent shouldBe 50
}
@Test
fun `health is computed per pod`() {
fun `runtime is computed per pod`() {
// A replaced right earbud (or single-pod listening habits) makes the sides genuinely
// diverge — each pod gets its own figure instead of one masking the other.
val profile = DrainProfile(
@@ -35,21 +41,21 @@ class BatteryHealthTest : BaseTest() {
"UNKNOWN/RIGHT" to rate(1f / 6f), // full rated life -> 100%
)
)
val health = BatteryHealth.estimate(profile, PodModel.AIRPODS_PRO2).shouldNotBeNull()
health.left shouldBe 50
health.right shouldBe 100
val health = BatteryHealth.estimate(profile, PodModel.AIRPODS_PRO2, now).shouldNotBeNull()
health.left?.percent shouldBe 50
health.right?.percent shouldBe 100
health.headset shouldBe null
}
@Test
fun `health is capped at 100`() {
// Idle-heavy usage drains slower than the listening rating — never report over-health.
fun `runtime is capped at 100`() {
// Idle-heavy usage drains slower than the listening rating — never report over 100%.
val profile = DrainProfile(listeningRates = mapOf("UNKNOWN/LEFT" to rate(0.05f)))
BatteryHealth.estimate(profile, PodModel.AIRPODS_PRO2).shouldNotBeNull().left shouldBe 100
BatteryHealth.estimate(profile, PodModel.AIRPODS_PRO2, now).shouldNotBeNull().left?.percent shouldBe 100
}
@Test
fun `health uses the median across a pod's learned rates`() {
fun `runtime uses the median across a pod's learned rates`() {
// Three qualifying LEFT entries at 100% / 50% / 25% equivalent -> the median (50%) wins,
// so a single gentle idle session can't inflate the figure and one hard session can't
// tank it.
@@ -60,7 +66,7 @@ class BatteryHealthTest : BaseTest() {
"OFF/LEFT" to rate(1f / 1.5f),
)
)
BatteryHealth.estimate(profile, PodModel.AIRPODS_PRO2).shouldNotBeNull().left shouldBe 50
BatteryHealth.estimate(profile, PodModel.AIRPODS_PRO2, now).shouldNotBeNull().left?.percent shouldBe 50
}
@Test
@@ -68,19 +74,19 @@ class BatteryHealthTest : BaseTest() {
val profile = DrainProfile(
listeningRates = mapOf("UNKNOWN/LEFT" to rate(1f / 3f, updateCount = BatteryHealth.MIN_UPDATE_COUNT - 1))
)
BatteryHealth.estimate(profile, PodModel.AIRPODS_PRO2).shouldBeNull()
BatteryHealth.estimate(profile, PodModel.AIRPODS_PRO2, now).shouldBeNull()
}
@Test
fun `models without a rating have no health`() {
fun `models without a rating have no runtime figure`() {
val profile = DrainProfile(listeningRates = mapOf("UNKNOWN/LEFT" to rate(1f / 3f)))
BatteryHealth.estimate(profile, PodModel.UNKNOWN).shouldBeNull()
BatteryHealth.estimate(profile, PodModel.UNKNOWN, now).shouldBeNull()
}
@Test
fun `no profile or no qualifying rates yields null`() {
BatteryHealth.estimate(null, PodModel.AIRPODS_PRO2).shouldBeNull()
BatteryHealth.estimate(DrainProfile(), PodModel.AIRPODS_PRO2).shouldBeNull()
BatteryHealth.estimate(null, PodModel.AIRPODS_PRO2, now).shouldBeNull()
BatteryHealth.estimate(DrainProfile(), PodModel.AIRPODS_PRO2, now).shouldBeNull()
}
@Test
@@ -89,7 +95,7 @@ class BatteryHealthTest : BaseTest() {
model = PodModel.AIRPODS_PRO.name,
listeningRates = mapOf("UNKNOWN/LEFT" to rate(1f / 3f)),
)
BatteryHealth.estimate(profile, PodModel.AIRPODS_PRO2).shouldBeNull()
BatteryHealth.estimate(profile, PodModel.AIRPODS_PRO2, now).shouldBeNull()
}
@Test
@@ -105,7 +111,7 @@ class BatteryHealthTest : BaseTest() {
"UNKNOWN/RIGHT" to rate(Float.NaN), // non-finite rate
)
)
BatteryHealth.estimate(profile, PodModel.AIRPODS_PRO2).shouldBeNull()
BatteryHealth.estimate(profile, PodModel.AIRPODS_PRO2, now).shouldBeNull()
}
@Test
@@ -113,15 +119,145 @@ class BatteryHealthTest : BaseTest() {
// AirPods 4 ANC: 4h with ANC on, 5h off. A 2h runtime learned with ANC ON is 50% of the
// ON rating — not 40% of the OFF one.
val profile = DrainProfile(listeningRates = mapOf("ON/LEFT" to rate(0.5f)))
BatteryHealth.estimate(profile, PodModel.AIRPODS_GEN4_ANC).shouldNotBeNull().left shouldBe 50
BatteryHealth.estimate(profile, PodModel.AIRPODS_GEN4_ANC, now).shouldNotBeNull().left?.percent shouldBe 50
}
@Test
fun `headset slot yields a headset figure`() {
// AirPods Max rated 20h; managing only 10h -> 50%.
val profile = DrainProfile(listeningRates = mapOf("ON/HEADSET" to rate(0.1f)))
val health = BatteryHealth.estimate(profile, PodModel.AIRPODS_MAX).shouldNotBeNull()
health.headset shouldBe 50
val health = BatteryHealth.estimate(profile, PodModel.AIRPODS_MAX, now).shouldNotBeNull()
health.headset?.percent shouldBe 50
health.left shouldBe null
}
@Test
fun `a reading is promotable with enough recent sessions`() {
val profile = DrainProfile(
listeningRates = mapOf(
"UNKNOWN/LEFT" to rate(1f / 3f, updateCount = BatteryHealth.MIN_PROMOTE_UPDATE_COUNT),
)
)
BatteryHealth.estimate(profile, PodModel.AIRPODS_PRO2, now)
.shouldNotBeNull().left?.isPromotable shouldBe true
}
@Test
fun `sessions are summed across a pod's qualifying rates`() {
// Neither entry alone clears the promote floor, together they do.
val half = BatteryHealth.MIN_PROMOTE_UPDATE_COUNT / 2
val profile = DrainProfile(
listeningRates = mapOf(
"ON/LEFT" to rate(1f / 3f, updateCount = half),
"OFF/LEFT" to rate(1f / 3f, updateCount = BatteryHealth.MIN_PROMOTE_UPDATE_COUNT - half),
)
)
BatteryHealth.estimate(profile, PodModel.AIRPODS_PRO2, now)
.shouldNotBeNull().left?.isPromotable shouldBe true
}
@Test
fun `exactly the promote session floor is enough`() {
val atFloor = DrainProfile(
listeningRates = mapOf(
"UNKNOWN/LEFT" to rate(1f / 3f, updateCount = BatteryHealth.MIN_PROMOTE_UPDATE_COUNT),
)
)
BatteryHealth.estimate(atFloor, PodModel.AIRPODS_PRO2, now)
.shouldNotBeNull().left?.isPromotable shouldBe true
val belowFloor = DrainProfile(
listeningRates = mapOf(
"UNKNOWN/LEFT" to rate(1f / 3f, updateCount = BatteryHealth.MIN_PROMOTE_UPDATE_COUNT - 1),
)
)
BatteryHealth.estimate(belowFloor, PodModel.AIRPODS_PRO2, now)
.shouldNotBeNull().left?.isPromotable shouldBe false
}
@Test
fun `only the newest qualifying rate decides staleness`() {
val stale = now.minus(BatteryHealth.MAX_PROMOTE_AGE).minusSeconds(1)
val profile = DrainProfile(
listeningRates = mapOf(
"ON/LEFT" to rate(1f / 3f, updateCount = BatteryHealth.MIN_PROMOTE_UPDATE_COUNT, updatedAt = stale),
"OFF/LEFT" to rate(1f / 3f, updateCount = BatteryHealth.MIN_PROMOTE_UPDATE_COUNT, updatedAt = now),
)
)
BatteryHealth.estimate(profile, PodModel.AIRPODS_PRO2, now)
.shouldNotBeNull().left?.isPromotable shouldBe true
}
@Test
fun `a reading exactly at the age limit still promotes`() {
val atLimit = DrainProfile(
listeningRates = mapOf(
"UNKNOWN/LEFT" to rate(
1f / 3f,
updateCount = BatteryHealth.MIN_PROMOTE_UPDATE_COUNT,
updatedAt = now.minus(BatteryHealth.MAX_PROMOTE_AGE),
),
)
)
BatteryHealth.estimate(atLimit, PodModel.AIRPODS_PRO2, now)
.shouldNotBeNull().left?.isPromotable shouldBe true
val pastLimit = DrainProfile(
listeningRates = mapOf(
"UNKNOWN/LEFT" to rate(
1f / 3f,
updateCount = BatteryHealth.MIN_PROMOTE_UPDATE_COUNT,
updatedAt = now.minus(BatteryHealth.MAX_PROMOTE_AGE).minus(Duration.ofSeconds(1)),
),
)
)
BatteryHealth.estimate(pastLimit, PodModel.AIRPODS_PRO2, now)
.shouldNotBeNull().left?.isPromotable shouldBe false
}
@Test
fun `lowestPromotable picks the worst pod that has enough evidence`() {
val perPod = BatteryHealth.PerPod(
left = BatteryHealth.Reading(percent = 60, isPromotable = true),
right = BatteryHealth.Reading(percent = 40, isPromotable = true),
)
perPod.lowestPromotable shouldBe BatteryHealth.SlotReading(
slot = BatteryHealth.Slot.RIGHT,
reading = BatteryHealth.Reading(percent = 40, isPromotable = true),
)
}
@Test
fun `lowestPromotable ignores readings without enough evidence`() {
val perPod = BatteryHealth.PerPod(
left = BatteryHealth.Reading(percent = 20, isPromotable = false),
headset = BatteryHealth.Reading(percent = 70, isPromotable = true),
)
perPod.lowestPromotable shouldBe BatteryHealth.SlotReading(
slot = BatteryHealth.Slot.HEADSET,
reading = BatteryHealth.Reading(percent = 70, isPromotable = true),
)
}
@Test
fun `lowestPromotable is null when nothing qualifies`() {
BatteryHealth.PerPod(
left = BatteryHealth.Reading(percent = 20, isPromotable = false),
).lowestPromotable.shouldBeNull()
BatteryHealth.PerPod().lowestPromotable.shouldBeNull()
}
@Test
fun `exactly the low runtime threshold counts as low`() {
// Pro 2 rated 6h; 3h learned -> 50%, the threshold itself must still warn.
val profile = DrainProfile(
listeningRates = mapOf(
"UNKNOWN/LEFT" to rate(1f / 3f, updateCount = BatteryHealth.MIN_PROMOTE_UPDATE_COUNT),
)
)
val lowest = BatteryHealth.estimate(profile, PodModel.AIRPODS_PRO2, now)
.shouldNotBeNull().lowestPromotable.shouldNotBeNull()
lowest.reading.percent shouldBe BatteryHealth.LOW_RUNTIME_PERCENT
(lowest.reading.percent <= BatteryHealth.LOW_RUNTIME_PERCENT) shouldBe true
}
}
@@ -0,0 +1,50 @@
package eu.darken.capod.monitor.core.battery
import eu.darken.capod.pods.core.apple.ble.BATTERY_UNKNOWN
import io.kotest.matchers.shouldBe
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
class BatteryTierTest : BaseTest() {
@Test
fun `unknown battery has no tier`() {
batteryTier(BATTERY_UNKNOWN) shouldBe BatteryTier.UNKNOWN
batteryTier(-0.01f) shouldBe BatteryTier.UNKNOWN
batteryTier(-100f) shouldBe BatteryTier.UNKNOWN
}
@Test
fun `non-finite values have no tier`() {
batteryTier(Float.NaN) shouldBe BatteryTier.UNKNOWN
batteryTier(Float.POSITIVE_INFINITY) shouldBe BatteryTier.UNKNOWN
batteryTier(Float.NEGATIVE_INFINITY) shouldBe BatteryTier.UNKNOWN
}
@Test
fun `values at and above full are good`() {
batteryTier(1f) shouldBe BatteryTier.GOOD
batteryTier(1.5f) shouldBe BatteryTier.GOOD
batteryTier(Float.MAX_VALUE) shouldBe BatteryTier.GOOD
}
@Test
fun `the good boundary is exclusive`() {
batteryTier(0.30f) shouldBe BatteryTier.WARN
batteryTier(0.30f + 0.0001f) shouldBe BatteryTier.GOOD
batteryTier(0.30f - 0.0001f) shouldBe BatteryTier.WARN
}
@Test
fun `the warn boundary is inclusive`() {
batteryTier(0.15f) shouldBe BatteryTier.WARN
batteryTier(0.15f + 0.0001f) shouldBe BatteryTier.WARN
batteryTier(0.15f - 0.0001f) shouldBe BatteryTier.CRITICAL
}
@Test
fun `an empty battery is critical`() {
batteryTier(0f) shouldBe BatteryTier.CRITICAL
batteryTier(0.05f) shouldBe BatteryTier.CRITICAL
}
}
@@ -0,0 +1,151 @@
package eu.darken.capod.monitor.core.battery
import eu.darken.capod.monitor.core.BatteryReading
import eu.darken.capod.pods.core.apple.PodModel
import io.kotest.matchers.nulls.shouldBeNull
import io.kotest.matchers.shouldBe
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
class CaseChargesTest : BaseTest() {
private val exact = PodModel.CaseSpec(fullPairRecharges = 4.0f)
private val lowerBound = PodModel.CaseSpec(fullPairRecharges = 3.8f, isLowerBound = true)
private val decile = 0.10f
private val percent = 0.01f
private fun charges(spec: PodModel.CaseSpec?, percent: Float, resolution: Float) =
caseCharges(spec, BatteryReading(percent, resolution))
@Test
fun `no spec means no line`() {
caseCharges(null, BatteryReading(0.5f, percent)).shouldBeNull()
}
@Test
fun `no reading means no line`() {
caseCharges(exact, null).shouldBeNull()
}
@Test
fun `an unusable spec is treated as absent`() {
charges(PodModel.CaseSpec(fullPairRecharges = 0f), 0.5f, percent).shouldBeNull()
charges(PodModel.CaseSpec(fullPairRecharges = -1f), 0.5f, percent).shouldBeNull()
charges(PodModel.CaseSpec(fullPairRecharges = Float.NaN), 0.5f, percent).shouldBeNull()
}
@Test
fun `an unusable reading is treated as absent`() {
charges(exact, -1f, percent).shouldBeNull()
charges(exact, Float.NaN, percent).shouldBeNull()
charges(exact, Float.POSITIVE_INFINITY, percent).shouldBeNull()
}
@Test
fun `the count comes from the spec times the reading`() {
charges(exact, 1.0f, percent)!!.count shouldBe 4
charges(exact, 0.75f, percent)!!.count shouldBe 3
charges(exact, 0.50f, percent)!!.count shouldBe 2
charges(lowerBound, 1.0f, percent)!!.count shouldBe 3
}
@Test
fun `a whole interval above one charge is enough`() {
// 4.0 x 0.25 = 1.0 exactly, so even the low end clears a full charge
charges(exact, 0.25f, decile)!!.adequacy shouldBe CaseCharges.Adequacy.ENOUGH
charges(exact, 0.60f, percent)!!.adequacy shouldBe CaseCharges.Adequacy.ENOUGH
}
@Test
fun `a whole interval below one charge is not enough`() {
// 4.0 x (0.10 + 0.10) = 0.8, still short of a full charge at the top of the interval
charges(exact, 0.10f, decile)!!.adequacy shouldBe CaseCharges.Adequacy.NOT_ENOUGH
charges(exact, 0.20f, percent)!!.adequacy shouldBe CaseCharges.Adequacy.NOT_ENOUGH
}
@Test
fun `an interval ending exactly on one charge is not enough`() {
// the top of the interval is excluded, so 5.0 x (0.10 + 0.10) = 1.0 is still short
charges(PodModel.CaseSpec(fullPairRecharges = 5.0f), 0.10f, decile)!!
.adequacy shouldBe CaseCharges.Adequacy.NOT_ENOUGH
// 4.0 x (0.24 + 0.01) = 1.0
charges(exact, 0.24f, percent)!!.adequacy shouldBe CaseCharges.Adequacy.NOT_ENOUGH
}
@Test
fun `an interval straddling one charge claims nothing`() {
// 4.0 x 0.20 = 0.8, 4.0 x 0.30 = 1.2
charges(exact, 0.20f, decile)!!.adequacy shouldBe CaseCharges.Adequacy.UNCERTAIN
// the same reading at a finer resolution does resolve
charges(exact, 0.20f, percent)!!.adequacy shouldBe CaseCharges.Adequacy.NOT_ENOUGH
}
@Test
fun `a lower bound spec above empty never claims not enough`() {
// 3.8 x 0.25 = 0.95 — a confident "not enough" for a case that may well hold more
charges(lowerBound, 0.25f, percent)!!.adequacy shouldBe CaseCharges.Adequacy.UNCERTAIN
charges(lowerBound, 0.01f, percent)!!.adequacy shouldBe CaseCharges.Adequacy.UNCERTAIN
charges(lowerBound, 0.10f, decile)!!.adequacy shouldBe CaseCharges.Adequacy.UNCERTAIN
}
@Test
fun `a lower bound spec can still claim enough`() {
charges(lowerBound, 0.30f, percent)!!.adequacy shouldBe CaseCharges.Adequacy.ENOUGH
}
@Test
fun `an exact spec renders as an approximation`() {
charges(exact, 0.50f, percent)!!.display shouldBe CaseCharges.Display.APPROXIMATE
}
@Test
fun `a lower bound spec renders as a floor`() {
charges(lowerBound, 0.50f, percent)!!.display shouldBe CaseCharges.Display.AT_LEAST
}
@Test
fun `below a full charge renders as less than one`() {
charges(exact, 0.20f, percent)!!.display shouldBe CaseCharges.Display.LESS_THAN_ONE
charges(exact, 0.01f, percent)!!.display shouldBe CaseCharges.Display.LESS_THAN_ONE
}
@Test
fun `an interval straddling one charge rounds to one instead of claiming less`() {
// 4.0 x 0.20 = 0.8, 4.0 x 0.30 = 1.2 — the text must not deny what the interval allows
val straddle = charges(exact, 0.20f, decile)!!
straddle.display shouldBe CaseCharges.Display.APPROXIMATE
straddle.count shouldBe 1
}
@Test
fun `an uncertain lower bound spec names no number`() {
// 3.8 x 0.10 = 0.38 with no upper end, so neither "less than one" nor "~1" is supportable
val vague = charges(lowerBound, 0.10f, decile)!!
vague.adequacy shouldBe CaseCharges.Adequacy.UNCERTAIN
vague.display shouldBe CaseCharges.Display.UNCERTAIN
charges(lowerBound, 0.20f, percent)!!.display shouldBe CaseCharges.Display.UNCERTAIN
}
@Test
fun `a lower bound spec that reaches enough still counts`() {
val enough = charges(lowerBound, 0.50f, decile)!!
enough.adequacy shouldBe CaseCharges.Adequacy.ENOUGH
enough.display shouldBe CaseCharges.Display.AT_LEAST
// 3.8 x 0.50 = 1.9
enough.count shouldBe 1
}
@Test
fun `an empty case renders as empty and claims not enough`() {
val exactly = charges(exact, 0f, percent)!!
exactly.display shouldBe CaseCharges.Display.EMPTY
exactly.adequacy shouldBe CaseCharges.Adequacy.NOT_ENOUGH
// an empty case holds nothing for the open ended spec to undersell
val floored = charges(lowerBound, 0f, decile)!!
floored.display shouldBe CaseCharges.Display.EMPTY
floored.adequacy shouldBe CaseCharges.Adequacy.NOT_ENOUGH
}
}
@@ -0,0 +1,125 @@
package eu.darken.capod.monitor.core.worker
import eu.darken.capod.common.bluetooth.BluetoothAddress
import eu.darken.capod.main.core.MonitorMode
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
/**
* The AUTOMATIC teardown is only cancelled by a new state emission, but a start request that finds
* a live session short-circuits without producing one — the connection state that would abort the
* countdown lags the Bluetooth event by roughly a second. A request landing in the tail of the
* window used to be acknowledged and the session torn down anyway.
*/
class MonitorModeFlowTest : BaseTest() {
private fun state(
mode: MonitorMode = MonitorMode.AUTOMATIC,
hasProfiles: Boolean = true,
knownAddresses: Set<BluetoothAddress> = setOf(KNOWN_ADDRESS),
connectedAddresses: Set<BluetoothAddress> = emptySet(),
hasAapSession: Boolean = false,
) = MonitorModeState(
mode = mode,
hasProfiles = hasProfiles,
knownAddresses = knownAddresses,
connectedAddresses = connectedAddresses,
hasAapSession = hasAapSession,
)
@Test
fun `nothing connected tears the session down after the timeout`() = runTest {
var teardowns = 0
val job = monitorModeFlow(
tag = TAG,
modeStates = flowOf(state()),
startSignal = MutableStateFlow(0L),
onTeardown = { teardowns++ },
).launchIn(this)
advanceTimeBy(TIMEOUT)
teardowns shouldBe 0
runCurrent()
teardowns shouldBe 1
job.cancel()
}
@Test
fun `a start request during the window re-arms the countdown`() = runTest {
var teardowns = 0
val startSignal = MutableStateFlow(0L)
val job = monitorModeFlow(
tag = TAG,
modeStates = flowOf(state()),
startSignal = startSignal,
onTeardown = { teardowns++ },
).launchIn(this)
val bumpedAt = 14_750L
advanceTimeBy(bumpedAt)
runCurrent()
startSignal.value++
// The original window would have expired here.
advanceTimeBy(TIMEOUT - bumpedAt)
runCurrent()
teardowns shouldBe 0
// The re-armed window runs from the start request, not from the state emission.
advanceTimeBy(bumpedAt)
teardowns shouldBe 0
runCurrent()
teardowns shouldBe 1
job.cancel()
}
@Test
fun `a connected device aborts the countdown`() = runTest {
var teardowns = 0
val job = monitorModeFlow(
tag = TAG,
modeStates = flowOf(state(connectedAddresses = setOf(KNOWN_ADDRESS))),
startSignal = MutableStateFlow(0L),
onTeardown = { teardowns++ },
).launchIn(this)
advanceTimeBy(10 * TIMEOUT)
runCurrent()
teardowns shouldBe 0
job.cancel()
}
@Test
fun `manual mode tears the session down immediately`() = runTest {
var teardowns = 0
val job = monitorModeFlow(
tag = TAG,
modeStates = flowOf(state(mode = MonitorMode.MANUAL)),
startSignal = MutableStateFlow(0L),
onTeardown = { teardowns++ },
).launchIn(this)
runCurrent()
teardowns shouldBe 1
job.cancel()
}
companion object {
private const val TAG = "MonitorModeFlowTest"
private const val TIMEOUT = 15 * 1000L
private const val KNOWN_ADDRESS = "AA:BB:CC:DD:EE:FF"
}
}
@@ -13,6 +13,7 @@ import io.kotest.matchers.types.shouldNotBeSameInstanceAs
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.StateFlow
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.Robolectric
@@ -42,6 +43,8 @@ class MonitorServiceTest {
private fun MonitorService.getField(name: String): Any? =
MonitorService::class.java.getDeclaredField(name).apply { isAccessible = true }.get(this)
private fun MonitorService.startSignal(): Long = (getField("startSignal") as StateFlow<*>).value as Long
private fun notification(title: String): Notification =
NotificationCompat.Builder(context, MonitorNotifications.NOTIFICATION_CHANNEL_ID)
.setContentTitle(title)
@@ -204,6 +207,22 @@ class MonitorServiceTest {
service.getField("lastNotification").shouldBeNull()
}
/**
* A start request that finds a live session is acknowledged without touching it — including a
* teardown countdown that is already running. Bumping the signal is what re-arms that window.
*/
@Test
fun `a short-circuited start bumps the start signal`() {
val service = createService()
service.readyForMonitoring()
val before = service.startSignal()
service.onStartCommand(MonitorService.intent(context), 0, 1) shouldBe Service.START_STICKY
service.startSignal() shouldBe before + 1
}
@Test
fun `onDestroy skips notification cleanup when injection never completed`() {
val service = createService()
@@ -0,0 +1,49 @@
package eu.darken.capod.pods.core.apple
import io.kotest.matchers.collections.shouldBeEmpty
import io.kotest.matchers.floats.shouldBeGreaterThan
import io.kotest.matchers.shouldBe
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
class PodModelCaseSpecTest : BaseTest() {
@Test
fun `placeholder models carry no case spec`() {
PodModel.entries
.filter { it == PodModel.UNKNOWN || it.name.startsWith("FAKE_") }
.filter { it.caseSpec != null }
.shouldBeEmpty()
}
@Test
fun `a case spec implies a case`() {
PodModel.entries
.filter { it.caseSpec != null }
.filter { !it.features.hasCase }
.shouldBeEmpty()
}
@Test
fun `every case spec is usable`() {
PodModel.entries.mapNotNull { it.caseSpec }.forEach {
it.fullPairRecharges.isFinite() shouldBe true
it.fullPairRecharges shouldBeGreaterThan 0f
}
}
@Test
fun `the published figures divide out as recorded`() {
// hoursWithCase / hoursListening - 1
PodModel.AIRPODS_GEN1.caseSpec?.fullPairRecharges shouldBe 3.8f
PodModel.AIRPODS_GEN1.caseSpec?.isLowerBound shouldBe true
PodModel.AIRPODS_GEN2.caseSpec?.isLowerBound shouldBe true
PodModel.AIRPODS_GEN3.caseSpec?.fullPairRecharges shouldBe (30f / 6f - 1f)
PodModel.AIRPODS_GEN3.caseSpec?.isLowerBound shouldBe false
PodModel.AIRPODS_GEN4.caseSpec?.fullPairRecharges shouldBe (30f / 5f - 1f)
PodModel.AIRPODS_GEN4_ANC.caseSpec?.fullPairRecharges shouldBe (20f / 4f - 1f)
PodModel.AIRPODS_PRO2.caseSpec?.fullPairRecharges shouldBe (30f / 6f - 1f)
PodModel.AIRPODS_PRO2_USBC.caseSpec?.fullPairRecharges shouldBe (30f / 6f - 1f)
PodModel.AIRPODS_PRO3.caseSpec?.fullPairRecharges shouldBe (24f / 8f - 1f)
}
}
@@ -27,8 +27,11 @@ class PodHistoryFuzzyCollisionTest : BaseBlePodsTest() {
// IRK + a resolvable RPA pair lifted from RPACheckerTest.
private val irkHex = "79-04-65-1E-E2-CC-D9-26-F2-6E-20-EE-3E-CC-DE-79"
private val myRpa = "5A:16:2B:91:D1:CD" // resolves against irkHex
private val myRotatedRpa = "45:23:51:E3:40:6E" // also resolves against irkHex (computed)
private val foreignAddress = "77:49:4C:D8:25:0C" // does NOT resolve against irkHex
private fun String.reversedOctets(): String = split(":").reversed().joinToString(":")
// A valid AirPods Pro 2 (USB-C) proximity advertisement (model 0x2420), reused for both frames.
private val payload = "07 19 01 24 20 0B 99 8F 11 00 04 BD A7 3B FF 2D 8A 3C AF 9B 1A 7C 74 B7 A9 D1 C3"
@@ -75,7 +78,6 @@ class PodHistoryFuzzyCollisionTest : BaseBlePodsTest() {
address = "AA:BB:CC:DD:EE:FF",
)
)
val myRotatedRpa = "45:23:51:E3:40:6E" // also resolves against irkHex (computed)
lateinit var first: AirPodsPro2Usbc
create<AirPodsPro2Usbc>(payload, address = myRpa) { first = this }
@@ -87,4 +89,84 @@ class PodHistoryFuzzyCollisionTest : BaseBlePodsTest() {
// Same physical device across rotation -> same stable identity (recovered via IRK).
second.identifier shouldBe first.identifier
}
@Test
fun `a device whose address arrives octet-reversed keeps one identity across a rotation`() = runTest {
profileList.add(
AppleDeviceProfile(
label = "Mine",
model = PodModel.AIRPODS_PRO2_USBC,
identityKey = irkHex.fromHex(),
address = "AA:BB:CC:DD:EE:FF",
)
)
lateinit var first: AirPodsPro2Usbc
create<AirPodsPro2Usbc>(payload, address = myRpa.reversedOctets()) { first = this }
first.meta.isIRKMatch shouldBe true
first.meta.profile shouldNotBe null
lateinit var second: AirPodsPro2Usbc
create<AirPodsPro2Usbc>(payload, address = myRotatedRpa.reversedOctets()) { second = this }
second.meta.isIRKMatch shouldBe true
second.identifier shouldBe first.identifier
}
@Test
fun `a reversed foreign address must not inherit a keyed device's identity`() = runTest {
profileList.add(
AppleDeviceProfile(
label = "Mine",
model = PodModel.AIRPODS_PRO2_USBC,
identityKey = irkHex.fromHex(),
address = "AA:BB:CC:DD:EE:FF",
)
)
lateinit var mine: AirPodsPro2Usbc
create<AirPodsPro2Usbc>(payload, address = myRpa) { mine = this }
mine.meta.isIRKMatch shouldBe true
// The reversed foreign address is RPA-shaped in its reversed form, so the alternate-order
// path runs its hash comparison on it — and must still refuse to attribute it.
lateinit var foreign: AirPodsPro2Usbc
create<AirPodsPro2Usbc>(payload, address = foreignAddress.reversedOctets()) { foreign = this }
foreign.meta.isIRKMatch shouldBe false
foreign.meta.profile shouldBe null
foreign.identifier shouldNotBe mine.identifier
}
@Test
fun `a history bound to another profile is not claimed by identity recovery`() = runTest {
// Both profiles carry the SAME key: with two independent keys the candidate would already
// fail the key check and the binding check would never be reached.
val profileOne = AppleDeviceProfile(
label = "First",
model = PodModel.AIRPODS_PRO2_USBC,
identityKey = irkHex.fromHex(),
address = "AA:BB:CC:DD:EE:F1",
)
val profileTwo = AppleDeviceProfile(
label = "Second",
model = PodModel.AIRPODS_PRO2_USBC,
identityKey = irkHex.fromHex(),
address = "AA:BB:CC:DD:EE:F2",
)
profileList.add(profileOne)
profileList.add(profileTwo)
// The first resolving profile wins, so this frame binds the history to profileOne.
lateinit var first: AirPodsPro2Usbc
create<AirPodsPro2Usbc>(payload, address = myRpa) { first = this }
first.meta.profile?.id shouldBe profileOne.id
// profileOne is gone, so the next frame resolves to profileTwo — which must not be handed
// the history that is already bound to profileOne.
profileList.remove(profileOne)
lateinit var second: AirPodsPro2Usbc
create<AirPodsPro2Usbc>(payload, address = myRotatedRpa) { second = this }
second.meta.profile?.id shouldBe profileTwo.id
second.identifier shouldNotBe first.identifier
}
}
+9 -7
View File
@@ -122,6 +122,15 @@ echo "=== Localized Screenshot Generation ==="
echo "Locales: $TOTAL | Batch size: $BATCH_SIZE | Batches: $NUM_BATCHES"
echo ""
# A leftover .bak means a previous run died before its trap restored the file: the real source is
# in the .bak and copying over it here would destroy the only good copy.
if [[ -e "$LOCALES_FILE.bak" ]]; then
echo "ERROR: Stale backup found: $LOCALES_FILE.bak"
echo "A previous run was interrupted. Restore it first:"
echo " mv \"$LOCALES_FILE.bak\" \"$LOCALES_FILE\""
exit 1
fi
# Back up the original file
cp "$LOCALES_FILE" "$LOCALES_FILE.bak"
trap 'mv "$LOCALES_FILE.bak" "$LOCALES_FILE"; echo "Restored original PlayStoreLocales.kt"' EXIT
@@ -166,13 +175,6 @@ HEADER
done
echo "annotation class PlayStoreLocalesDark" >> "$file"
echo "" >> "$file"
# Smoke annotation (single entry placeholder)
echo "/**" >> "$file"
echo " * Smoke test subset for fast iteration (6 locales covering LTR, RTL, CJK)." >> "$file"
echo " */" >> "$file"
echo "@Preview(locale = \"en\", name = \"en-US\", device = DS)" >> "$file"
echo "annotation class PlayStoreLocalesSmoke" >> "$file"
}
for (( batch=0; batch < NUM_BATCHES; batch++ )); do
Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 209 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 206 KiB