Compare commits

...
17 Commits
Author SHA1 Message Date
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
72 changed files with 1914 additions and 255 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/
@@ -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,10 @@ 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.TextOverflow
import androidx.compose.ui.unit.dp
import eu.darken.capod.R
@@ -49,6 +53,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 +64,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
@@ -313,22 +328,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 +358,7 @@ private fun PodGauge(
)
// Progress ring
if (clamped >= 0f) {
if (isKnown) {
CircularProgressIndicator(
progress = { animatedProgress },
modifier = Modifier.size(68.dp),
@@ -373,7 +384,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 +425,127 @@ 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,
// Aligned under the percentage, past the case icon and its spacer.
modifier = Modifier.padding(start = 36.dp, 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,
modifier = modifier.semantics { stateDescription = state },
)
}
@Preview2
@Composable
private fun DualPodsCardFullPreview() = PreviewWrapper {
@@ -525,6 +597,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
@@ -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
}
@@ -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)
}
@@ -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,
)
}
@@ -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
}
}
+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,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()
}
}
@@ -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,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)
}
}
+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