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