Compare commits

..
Author SHA1 Message Date
d4rken-org-releaser[bot] 9d41979499 Release: 5.1.5-rc0 2026-05-06 10:16:00 +00:00
darken 985b73754e fix: Eliminate Float? boxing from battery display and cache merge
Two SIGSEGV native crashes recurred on Android 10 in 5.1.4-rc0 inside JIT-cached code at toBatteryFloat+4 (popup) and mergeBatterySlot+40 (cache merge). Both functions had a boxed Float? unbox at function entry that R8 horizontally merged into stdlib host classes, where Android 10 ART JIT miscompiled the unbox.

Convert PodDevice battery getters to non-null Float with BATTERY_UNKNOWN sentinel and propagate primitive Float through every display/persistence consumer. mergeBatterySlot now takes primitive Float; toBatteryFloat and toBatteryOrNull are deleted. Add isKnownBattery and batteryProgress helpers used everywhere instead of scattered nullable checks. Raw live extraction in toCachedState avoids touching the unified getter so cached values aren't refreshed as live.
2026-05-06 11:54:25 +02:00
Matthias Urhahn e4e4190523 General: Update zh-CN phone screenshots 2026-05-04 22:10:14 +02:00
Matthias Urhahn ebfb034fdd fix(navigation): Defer onNewIntent navCtrl call until Compose attaches
Move consumeUpgradeExtra() out of MainActivity.onNewIntent and into a Compose-level LaunchedEffect that collects a new MainActivity.warmIntents SingleEventFlow.

onNewIntent could fire before the setContent{} lambda registered the back stack with NavigationController, leaving navCtrl.goTo(Nav.Main.Upgrade) to throw IllegalStateException("NavigationController not initialized"). Same race shipped a fix for in octi (d4rken-org/octi#284).
2026-05-04 21:42:53 +02:00
Matthias Urhahn 8a81d354e6 chore(screenshots): Improve dashboard and add-profile previews
Use AAP-connected mocks for the dark dashboard to showcase full connectivity badges (BLE + IRK + encrypted + AAP) and the ANC mode selector. Populate the add-profile preview with a demo name, model, and paired device. Add user-facing labels to the dashboard mock devices.

Refactor BluetoothDevice2 so name/address are primary constructor fields, letting previews construct one without a real Android BluetoothDevice. Bump the screenshot test JVM heap to 4g — the smoke batch (42 renders) was hitting the test executor's default ceiling.
2026-05-04 21:30:25 +02:00
Matthias Urhahn ac0c4693dc chore(screenshots): Switch to smoke-only Play Store screenshot commits
Mirrors permission-pilot's policy: only the 6 smoke locales (en-US, de-DE, ja-JP, ar, zh-CN, pt-BR) check phoneScreenshots PNGs into the repo. Non-smoke locales are excluded via .gitignore. Drops fastlane/metadata/android/ from ~67 MB to ~7 MB and prevents future bloat from full regens. Play Store's supply retains previously-uploaded screenshots for locales not pushed, so full localization is maintained by occasional manual regen + screenshots_only upload.
2026-05-04 19:27:57 +02:00
Matthias Urhahn 4fd7a41c53 chore(screenshots): Regenerate Play Store screenshots
Replaces stale 7_settings.png and 8_reaction_settings.png with the current 7_device_settings_reactions.png across all 68 locales.
2026-05-04 18:50:30 +02:00
Matthias Urhahn cc1caf47f0 chore(fastlane): Add screenshots_only supply lane 2026-05-04 18:32:00 +02:00
darken 8b9f953818 feat(widget): Show profile name and nearby state when ANC widget is disconnected 2026-05-04 13:03:27 +02:00
580 changed files with 310 additions and 168 deletions
+30 -10
View File
@@ -25,16 +25,23 @@ ScreenshotContent.kt (mock data + composables)
| `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).
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.
## Commands
```bash
# Full run — iterates over ALL_LOCALES in batches of BATCH_SIZE.
# The script prints "Locales: N | Batch size: B | Batches: ceil(N/B)" at startup.
./fastlane/generate_screenshots.sh
# Smoke test — 6 locales (en, de, ja, ar, zh-CN, pt-BR), single batch
# Default — smoke set (6 locales × 7 screens, ~42 PNGs, single batch).
# Use this for local iteration and PRs that touch screenshot content.
./fastlane/generate_screenshots.sh --smoke
# Full run — all 68 locales. Use only when intending to upload to Play Store
# (the non-smoke output is .gitignored and should not be committed).
./fastlane/generate_screenshots.sh
# Copy into fastlane directories (run after generate)
./fastlane/copy_screenshots.sh
@@ -48,23 +55,36 @@ ScreenshotContent.kt (mock data + composables)
2. Add a `@PreviewTest` function in `PlayStoreScreenshots.kt` that calls it
3. Add the function name → filename mapping in `copy_screenshots.sh` `SCREEN_MAP`
4. Update the expected count in `generate_screenshots.sh` (composables per locale)
5. Run the full pipeline: `generate_screenshots.sh` then `copy_screenshots.sh`
5. Run the smoke pipeline: `generate_screenshots.sh --smoke` then `copy_screenshots.sh --clean`
## Removing or Renaming a Screenshot
1. Remove the `@PreviewTest` entry and its `SCREEN_MAP` mapping
2. Run `generate_screenshots.sh`
3. Run `copy_screenshots.sh --clean`**`--clean` is required** here; without it, old files (e.g. a renamed `8_reaction_settings.png`) stay in `fastlane/metadata/android/*/images/phoneScreenshots/` and get uploaded to Play Store
2. Run `generate_screenshots.sh --smoke`
3. Run `copy_screenshots.sh --clean`**`--clean` is required** here; without it, old files (e.g. a renamed `8_reaction_settings.png`) stay in `fastlane/metadata/android/<smoke locale>/images/phoneScreenshots/` and get uploaded to Play Store
## After UI Changes
When modifying a screen that appears in screenshots (check `ScreenshotContent.kt`), regenerate:
When modifying a screen that appears in screenshots (check `ScreenshotContent.kt`), regenerate the smoke set:
```bash
./fastlane/generate_screenshots.sh
./fastlane/generate_screenshots.sh --smoke
./fastlane/copy_screenshots.sh --clean
```
## Refreshing all locales on Play Store
Periodic, manual operation — not per-PR:
```bash
./fastlane/generate_screenshots.sh # full, ~30 min, 477 PNGs
./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)
```
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.
## Technical Notes
- Batch size defaults to 2 locales; renders per batch = `BATCH_SIZE × screen count` (currently 2 × 7 = 14). Small batches avoid layoutlib memory leaks (~10MB/image)
+10
View File
@@ -17,6 +17,16 @@
.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).
# Full localization is uploaded by occasional manual regen + screenshots_only;
# Play Store retains previously-uploaded screenshots for locales not pushed.
fastlane/metadata/android/*/images/phoneScreenshots/*.png
!fastlane/metadata/android/en-US/images/phoneScreenshots/*.png
!fastlane/metadata/android/de-DE/images/phoneScreenshots/*.png
!fastlane/metadata/android/ja-JP/images/phoneScreenshots/*.png
!fastlane/metadata/android/ar/images/phoneScreenshots/*.png
!fastlane/metadata/android/zh-CN/images/phoneScreenshots/*.png
!fastlane/metadata/android/pt-BR/images/phoneScreenshots/*.png
.codex
protocol-research/
_site/
+1 -1
View File
@@ -1 +1 @@
5.1.4-rc0 50104000
5.1.5-rc0 50105000
+1
View File
@@ -130,6 +130,7 @@ android {
//noinspection WrongGradleMethod
tasks.withType<Test> {
useJUnitPlatform()
maxHeapSize = "4g"
}
}
}
@@ -14,6 +14,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import eu.darken.capod.common.bluetooth.BluetoothDevice2
import eu.darken.capod.common.compose.PreviewWrapper
import eu.darken.capod.common.compose.preview.MOCK_NOW
import eu.darken.capod.common.compose.preview.MockPodDataProvider
@@ -37,16 +38,25 @@ import eu.darken.capod.reaction.ui.popup.PopUpContent as PopUpCard
internal const val DS = "spec:width=1080px,height=2400px,dpi=428"
@Composable
internal fun DashboardContent() = PreviewWrapper {
internal fun DashboardContent(showAap: Boolean = false) = PreviewWrapper {
val devices = if (showAap) {
listOf(
MockPodDataProvider.dualPodMonitoredWithAap(),
MockPodDataProvider.singlePodMonitoredWithAap(),
MockPodDataProvider.unknownMonitored(),
)
} else {
listOf(
MockPodDataProvider.dualPodMonitoredMixed(),
MockPodDataProvider.singlePodMonitored(),
MockPodDataProvider.unknownMonitored(),
)
}
OverviewScreen(
state = OverviewViewModel.State(
now = MOCK_NOW,
permissions = emptySet(),
devices = listOf(
MockPodDataProvider.dualPodMonitoredMixed(),
MockPodDataProvider.singlePodMonitored(),
MockPodDataProvider.unknownMonitored(),
),
devices = devices,
isDebugMode = false,
isBluetoothEnabled = true,
profiles = listOf(
@@ -83,19 +93,38 @@ internal fun DeviceProfilesContent() = PreviewWrapper {
@Composable
internal fun AddProfileContent() = PreviewWrapper {
val pairedAirPods = BluetoothDevice2(
address = "AA:BB:CC:DD:EE:FF",
name = "AirPods Pro",
seenFirstAt = MOCK_NOW,
)
val bondedItems = listOf(
DeviceProfileCreationViewModel.BondedDeviceItem(
device = pairedAirPods,
claimedByProfile = null,
),
DeviceProfileCreationViewModel.BondedDeviceItem(
device = BluetoothDevice2(
address = "11:22:33:44:55:66",
name = "Living Room TV",
seenFirstAt = MOCK_NOW,
),
claimedByProfile = null,
),
)
DeviceProfileCreationScreen(
state = DeviceProfileCreationViewModel.State(
isEditMode = false,
name = "",
name = "My AirPods Pro",
nameError = null,
selectedModel = null,
selectedModel = PodModel.AIRPODS_PRO2,
availableModels = PodModel.entries.filter { it != PodModel.UNKNOWN },
identityKey = null,
encryptionKey = null,
selectedDevice = null,
bondedDeviceItems = emptyList(),
selectedDevice = pairedAirPods,
bondedDeviceItems = bondedItems,
minimumSignalQuality = 0.15f,
canSave = false,
canSave = true,
),
onBack = {},
onSave = {},
@@ -212,7 +241,7 @@ private fun PreviewDashboardLight() = DashboardContent()
@Preview(name = "2 - Dashboard Dark", locale = "en", device = DS, uiMode = Configuration.UI_MODE_NIGHT_YES, showSystemUi = true)
@Composable
private fun PreviewDashboardDark() = DashboardContent()
private fun PreviewDashboardDark() = DashboardContent(showAap = true)
@Preview(name = "3 - Case Pop-up", device = DS, showSystemUi = true)
@Composable
@@ -4,12 +4,8 @@ import android.bluetooth.BluetoothDevice
import java.time.Instant
data class BluetoothDevice2(
internal val internal: BluetoothDevice,
val address: BluetoothAddress,
val name: String?,
val seenFirstAt: Instant,
) {
val address: BluetoothAddress
get() = internal.address
val name: String?
get() = internal.name
}
internal val internal: BluetoothDevice? = null,
)
@@ -255,6 +255,8 @@ class BluetoothManager2 @Inject constructor(
}
.map { device ->
BluetoothDevice2(
address = device.address,
name = device.name,
internal = device,
seenFirstAt = seenDevicesLock.withLock {
seenDevicesCache[device.address] ?: run {
@@ -304,6 +306,8 @@ class BluetoothManager2 @Inject constructor(
val wrappedDevices = rawDevices.map { device ->
BluetoothDevice2(
address = device.address,
name = device.name,
internal = device,
seenFirstAt = seenDevicesLock.withLock {
seenDevicesCache[device.address] ?: run {
@@ -408,9 +412,10 @@ class BluetoothManager2 @Inject constructor(
@android.annotation.SuppressLint("MissingPermission")
fun setDeviceAlias(device: BluetoothDevice2, alias: String): Boolean {
return try {
val target = device.internal ?: return false
val method = BluetoothDevice::class.java.getDeclaredMethod("setAlias", String::class.java)
.apply { isAccessible = true }
val result = method.invoke(device.internal, alias) as? Boolean ?: false
val result = method.invoke(target, alias) as? Boolean ?: false
log(TAG) { "setDeviceAlias(${device.address}, $alias) -> $result" }
result
} catch (e: Exception) {
@@ -424,11 +429,12 @@ class BluetoothManager2 @Inject constructor(
try {
log(TAG) { "Nudging Android connection to $device" }
val target = device.internal ?: return@map false
val connectMethod = BluetoothHeadset::class.java.getDeclaredMethod(
"connect", BluetoothDevice::class.java
).apply { isAccessible = true }
val accepted = connectMethod.invoke(bluetoothProfile.proxy, device.internal) as? Boolean ?: false
val accepted = connectMethod.invoke(bluetoothProfile.proxy, target) as? Boolean ?: false
log(TAG) { "Nudged connection to $device — accepted=$accepted" }
accepted
} catch (e: Exception) {
@@ -180,18 +180,21 @@ object MockPodDataProvider {
fun dualPodMonitoredMixed(): PodDevice = PodDevice(
profileId = "preview-dual-mixed",
label = "My AirPods Pro",
ble = airPodsProMixed(),
aap = null,
)
fun dualPodMonitoredWithKeys(): PodDevice = PodDevice(
profileId = "preview-dual-keys",
label = "My AirPods Pro",
ble = airPodsProWithKeys(),
aap = null,
)
fun dualPodMonitoredWithAap(): PodDevice = PodDevice(
profileId = "preview-dual-aap",
label = "My AirPods Pro",
ble = airPodsProWithKeys(),
aap = AapPodState(
connectionState = AapPodState.ConnectionState.READY,
@@ -227,12 +230,14 @@ object MockPodDataProvider {
fun singlePodMonitored(): PodDevice = PodDevice(
profileId = "preview-single",
label = "AirPods Max",
ble = airPodsMax(),
aap = null,
)
fun singlePodMonitoredWithAap(): PodDevice = PodDevice(
profileId = "preview-single-aap",
label = "AirPods Max",
ble = airPodsMaxCharging(),
aap = AapPodState(
connectionState = AapPodState.ConnectionState.READY,
@@ -24,6 +24,7 @@ import androidx.lifecycle.lifecycleScope
import dagger.hilt.android.AndroidEntryPoint
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.flow.SingleEventFlow
import eu.darken.capod.common.navigation.LocalNavigationController
import eu.darken.capod.common.navigation.Nav
import eu.darken.capod.common.navigation.NavigationController
@@ -50,6 +51,11 @@ class MainActivity : Activity2() {
@Inject lateinit var popUpWindow: PopUpWindow
@Inject lateinit var upgradeRepo: UpgradeRepo
// Buffers warm-start intents (onNewIntent) so they are consumed from inside the Compose tree,
// after navCtrl.setup(backStack) has run. Calling navCtrl directly from onNewIntent races with
// the asynchronous Compose lambda and crashes if the back stack is not yet registered.
private val warmIntents = SingleEventFlow<Intent>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
installSplashScreen()
@@ -80,7 +86,11 @@ class MainActivity : Activity2() {
navCtrl.setup(backStack)
LaunchedEffect(Unit) {
// Cold-start intent (the activity's launching intent).
consumeUpgradeExtra(intent)
// Warm-start intents delivered via onNewIntent. Consuming them here (instead of
// directly from onNewIntent) guarantees navCtrl.setup() has run.
warmIntents.collect { newIntent -> consumeUpgradeExtra(newIntent) }
}
CapodTheme(state = themeState) {
@@ -132,7 +142,9 @@ class MainActivity : Activity2() {
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
consumeUpgradeExtra(intent)
setIntent(intent)
// Defer to the Compose-side collector — navCtrl may not yet be set up here.
warmIntents.tryEmit(intent)
}
private fun consumeUpgradeExtra(intent: Intent?) {
@@ -67,8 +67,6 @@ 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
import eu.darken.capod.pods.core.apple.ble.formatBatteryPercent
import eu.darken.capod.pods.core.apple.ble.toBatteryFloat
import eu.darken.capod.pods.core.apple.ble.toBatteryOrNull
import java.time.Instant
@Composable
@@ -227,7 +225,7 @@ private fun ColumnScope.DualPodsCardExpanded(
) {
PodGauge(
iconRes = device.leftPodIcon,
batteryPercent = device.batteryLeft.toBatteryFloat(),
batteryPercent = device.batteryLeft,
chargingState = device.leftPodChargingState
?: device.isLeftPodCharging?.let { if (it) AapPodState.ChargingState.CHARGING else null },
isInEar = device.isLeftInEar ?: false,
@@ -239,7 +237,7 @@ private fun ColumnScope.DualPodsCardExpanded(
PodGauge(
iconRes = device.rightPodIcon,
batteryPercent = device.batteryRight.toBatteryFloat(),
batteryPercent = device.batteryRight,
chargingState = device.rightPodChargingState
?: device.isRightPodCharging?.let { if (it) AapPodState.ChargingState.CHARGING else null },
isInEar = device.isRightInEar ?: false,
@@ -360,7 +358,7 @@ private fun PodGauge(
// Battery percentage
Text(
text = formatBatteryPercent(context, batteryPercent.toBatteryOrNull()),
text = formatBatteryPercent(context, batteryPercent),
style = MaterialTheme.typography.titleMedium,
color = if (batteryPercent >= 0f) {
MaterialTheme.colorScheme.onSurface
@@ -412,7 +410,7 @@ private fun CaseRow(
)
BatteryCapsule(
percent = device.batteryCase.toBatteryFloat(),
percent = device.batteryCase,
modifier = Modifier
.weight(1f)
.height(8.dp),
@@ -60,7 +60,9 @@ import eu.darken.capod.monitor.core.PodDevice
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.formatBatteryPercent
import eu.darken.capod.pods.core.apple.ble.isKnownBattery
import java.time.Instant
@OptIn(ExperimentalLayoutApi::class)
@@ -192,17 +194,18 @@ private fun ColumnScope.SinglePodsCardExpanded(
) {
val context = LocalContext.current
val clamped = device.batteryHeadset?.coerceIn(0f, 1f)
val percent = device.batteryHeadset
val isKnown = isKnownBattery(percent)
val animatedProgress by animateFloatAsState(
targetValue = clamped ?: 0f,
targetValue = batteryProgress(percent),
animationSpec = tween(600, easing = FastOutSlowInEasing),
label = "gaugeProgress",
)
val ringColor = when {
clamped == null -> MaterialTheme.colorScheme.surfaceVariant
clamped > 0.30f -> MaterialTheme.colorScheme.primary
clamped >= 0.15f -> MaterialTheme.colorScheme.tertiary
!isKnown -> MaterialTheme.colorScheme.surfaceVariant
percent > 0.30f -> MaterialTheme.colorScheme.primary
percent >= 0.15f -> MaterialTheme.colorScheme.tertiary
else -> MaterialTheme.colorScheme.error
}
@@ -237,7 +240,7 @@ private fun ColumnScope.SinglePodsCardExpanded(
)
// Progress ring
if (clamped != null) {
if (isKnown) {
CircularProgressIndicator(
progress = { animatedProgress },
modifier = Modifier.size(88.dp),
@@ -250,9 +253,9 @@ private fun ColumnScope.SinglePodsCardExpanded(
// Battery text inside ring
Text(
text = formatBatteryPercent(context, device.batteryHeadset),
text = formatBatteryPercent(context, percent),
style = MaterialTheme.typography.headlineSmall,
color = if (device.batteryHeadset != null) {
color = if (isKnown) {
MaterialTheme.colorScheme.onSurface
} else {
MaterialTheme.colorScheme.onSurfaceVariant
@@ -33,17 +33,19 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import eu.darken.capod.R
import eu.darken.capod.monitor.core.PodDevice
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
@Composable
fun CompactBatterySummary(
device: PodDevice,
modifier: Modifier = Modifier,
) {
val hasAnyBattery = device.batteryLeft != null
|| device.batteryRight != null
|| device.batteryHeadset != null
|| device.batteryCase != null
val hasAnyBattery = isKnownBattery(device.batteryLeft)
|| isKnownBattery(device.batteryRight)
|| isKnownBattery(device.batteryHeadset)
|| isKnownBattery(device.batteryCase)
Surface(
modifier = modifier
@@ -80,7 +82,7 @@ private fun RowScope.DualPodsRow(device: PodDevice) {
percent = device.batteryRight,
)
if (device.hasCase && device.batteryCase != null) {
if (device.hasCase && isKnownBattery(device.batteryCase)) {
Spacer(modifier = Modifier.weight(1f))
MiniCaseCluster(device = device)
}
@@ -93,7 +95,7 @@ private fun RowScope.SinglePodRow(device: PodDevice) {
iconRes = null,
percent = device.batteryHeadset,
)
if (device.hasCase && device.batteryCase != null) {
if (device.hasCase && isKnownBattery(device.batteryCase)) {
Spacer(modifier = Modifier.weight(1f))
MiniCaseCluster(device = device)
}
@@ -121,21 +123,21 @@ private fun RowScope.EmptyBatteryRow() {
@Composable
private fun MiniPodRing(
iconRes: Int?,
percent: Float?,
percent: Float,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val clamped = percent?.coerceIn(0f, 1f)
val isKnown = isKnownBattery(percent)
val animatedProgress by animateFloatAsState(
targetValue = clamped ?: 0f,
targetValue = batteryProgress(percent),
animationSpec = tween(600, easing = FastOutSlowInEasing),
label = "miniGaugeProgress",
)
val ringColor = when {
clamped == null -> MaterialTheme.colorScheme.surfaceVariant
clamped > 0.30f -> MaterialTheme.colorScheme.primary
clamped >= 0.15f -> MaterialTheme.colorScheme.tertiary
!isKnown -> MaterialTheme.colorScheme.surfaceVariant
percent > 0.30f -> MaterialTheme.colorScheme.primary
percent >= 0.15f -> MaterialTheme.colorScheme.tertiary
else -> MaterialTheme.colorScheme.error
}
@@ -155,7 +157,7 @@ private fun MiniPodRing(
trackColor = MaterialTheme.colorScheme.surfaceVariant,
strokeCap = StrokeCap.Round,
)
if (clamped != null) {
if (isKnown) {
CircularProgressIndicator(
progress = { animatedProgress },
modifier = Modifier.size(28.dp),
@@ -177,7 +179,7 @@ private fun MiniPodRing(
Text(
text = formatBatteryPercent(context, percent),
style = MaterialTheme.typography.titleSmall,
color = if (percent != null) {
color = if (isKnown) {
MaterialTheme.colorScheme.onSurface
} else {
MaterialTheme.colorScheme.onSurfaceVariant
@@ -204,7 +206,7 @@ private fun MiniCaseCluster(
)
Spacer(modifier = Modifier.width(6.dp))
BatteryCapsule(
percent = device.batteryCase ?: -1f,
percent = device.batteryCase,
modifier = Modifier
.width(36.dp)
.height(6.dp),
@@ -58,13 +58,21 @@ object AncWidgetRenderStateMapper {
primaryText = context.getString(R.string.anc_widget_no_anc_support_label),
)
!device.isAapConnected -> AncWidgetRenderState.Message(
theme = theme,
resolvedBgColor = bgColor,
resolvedTextColor = textColor,
resolvedIconColor = iconColor,
primaryText = context.getString(R.string.anc_widget_aap_not_connected_label),
)
!device.isAapConnected -> {
val secondaryRes = if (device.ble != null) {
R.string.anc_widget_aap_not_connected_nearby_description
} else {
R.string.anc_widget_aap_not_connected_not_nearby_description
}
AncWidgetRenderState.Message(
theme = theme,
resolvedBgColor = bgColor,
resolvedTextColor = textColor,
resolvedIconColor = iconColor,
primaryText = profileLabel ?: device.getLabel(context),
secondaryText = context.getString(secondaryRes),
)
}
!device.isAapReady -> AncWidgetRenderState.Message(
theme = theme,
@@ -31,7 +31,7 @@ import androidx.compose.ui.unit.sp
import eu.darken.capod.R
import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper
import eu.darken.capod.pods.core.apple.ble.toBatteryOrNull
import eu.darken.capod.pods.core.apple.ble.isKnownBattery
import kotlin.math.roundToInt
@Composable
@@ -160,7 +160,7 @@ private fun SinglePodPreview(
colorFilter = iconTint,
)
Text(
text = formatPercent(state.percent.toBatteryOrNull()),
text = formatPercent(state.percent),
fontSize = 12.sp,
color = textColor,
modifier = Modifier.padding(horizontal = 8.dp),
@@ -311,7 +311,7 @@ private fun PodItemRow(
colorFilter = iconTint,
)
Text(
text = formatPercent(percent.toBatteryOrNull()),
text = formatPercent(percent),
fontSize = 12.sp,
color = textColor,
modifier = Modifier.padding(horizontal = 4.dp),
@@ -354,7 +354,7 @@ private fun TinyPodItem(
colorFilter = iconTint,
)
Text(
text = formatPercent(percent.toBatteryOrNull()),
text = formatPercent(percent),
fontSize = 12.sp,
color = textColor,
maxLines = 1,
@@ -382,9 +382,8 @@ private fun DeviceLabel(
}
}
private fun formatPercent(percent: Float?): String {
return percent?.let { "${(it * 100).roundToInt()}%" } ?: ""
}
private fun formatPercent(percent: Float): String =
if (isKnownBattery(percent)) "${(percent * 100).roundToInt()}%" else ""
@Preview2
@Composable
@@ -27,7 +27,7 @@ import androidx.glance.text.TextAlign
import androidx.glance.text.TextStyle
import eu.darken.capod.R
import eu.darken.capod.main.ui.MainActivity
import eu.darken.capod.pods.core.apple.ble.toBatteryOrNull
import eu.darken.capod.pods.core.apple.ble.isKnownBattery
import kotlin.math.roundToInt
@Composable
@@ -122,7 +122,7 @@ private fun GlanceSinglePod(
colorFilter = iconTint,
)
Text(
text = formatGlancePercent(state.percent.toBatteryOrNull()),
text = formatGlancePercent(state.percent),
style = textStyle,
modifier = GlanceModifier.padding(horizontal = 8.dp),
)
@@ -270,7 +270,7 @@ private fun GlancePodItem(
colorFilter = iconTint,
)
Text(
text = formatGlancePercent(percent.toBatteryOrNull()),
text = formatGlancePercent(percent),
style = textStyle,
modifier = GlanceModifier.padding(horizontal = 4.dp),
)
@@ -312,7 +312,7 @@ private fun GlanceTinyPodItem(
colorFilter = iconTint,
)
Text(
text = formatGlancePercent(percent.toBatteryOrNull()),
text = formatGlancePercent(percent),
style = textStyle,
maxLines = 1,
modifier = GlanceModifier.padding(start = 4.dp),
@@ -341,6 +341,5 @@ private fun GlanceDeviceLabel(
private fun fixedColor(argb: Int): ColorProvider = ColorProvider(Color(argb))
private fun formatGlancePercent(percent: Float?): String {
return percent?.let { "${(it * 100).roundToInt()}%" } ?: ""
}
private fun formatGlancePercent(percent: Float): String =
if (isKnownBattery(percent)) "${(percent * 100).roundToInt()}%" else ""
@@ -14,10 +14,10 @@ internal data class WidgetDeviceKey(
val profileId: String?,
val profileLabel: String?,
val model: PodModel,
val batteryLeft: Float?,
val batteryRight: Float?,
val batteryCase: Float?,
val batteryHeadset: Float?,
val batteryLeft: Float,
val batteryRight: Float,
val batteryCase: Float,
val batteryHeadset: Float,
val isLeftPodCharging: Boolean?,
val isRightPodCharging: Boolean?,
val isCaseCharging: Boolean?,
@@ -27,6 +27,7 @@ internal data class WidgetDeviceKey(
val isBeingWorn: Boolean?,
val isAapConnected: Boolean,
val isAapReady: Boolean,
val hasBleAdvertisement: Boolean,
val ancMode: AapSetting.AncMode.Value?,
val pendingAncMode: AapSetting.AncMode.Value?,
val visibleAncModes: List<AapSetting.AncMode.Value>,
@@ -49,6 +50,7 @@ internal fun PodDevice.toWidgetKey(): WidgetDeviceKey = WidgetDeviceKey(
isBeingWorn = isBeingWorn,
isAapConnected = isAapConnected,
isAapReady = isAapReady,
hasBleAdvertisement = ble != null,
ancMode = ancMode?.current,
pendingAncMode = pendingAncMode,
visibleAncModes = visibleAncModes,
@@ -7,7 +7,6 @@ import eu.darken.capod.R
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.pods.core.apple.ble.getBatteryDrawable
import eu.darken.capod.pods.core.apple.ble.toBatteryFloat
object WidgetRenderStateMapper {
@@ -43,15 +42,15 @@ object WidgetRenderStateMapper {
layout = layout,
deviceLabel = profileLabel ?: device.getLabel(context),
leftIcon = device.leftPodIcon,
leftPercent = device.batteryLeft.toBatteryFloat(),
leftPercent = device.batteryLeft,
leftCharging = device.isLeftPodCharging == true,
leftInEar = device.isLeftInEar == true,
rightIcon = device.rightPodIcon,
rightPercent = device.batteryRight.toBatteryFloat(),
rightPercent = device.batteryRight,
rightCharging = device.isRightPodCharging == true,
rightInEar = device.isRightInEar == true,
caseIcon = device.caseIcon,
casePercent = device.batteryCase.toBatteryFloat(),
casePercent = device.batteryCase,
caseCharging = device.isCaseCharging == true,
)
@@ -63,7 +62,7 @@ object WidgetRenderStateMapper {
layout = layout,
deviceLabel = profileLabel ?: device.getLabel(context),
headsetIcon = device.iconRes,
percent = device.batteryHeadset.toBatteryFloat(),
percent = device.batteryHeadset,
batteryIcon = getBatteryDrawable(device.batteryHeadset),
charging = device.isHeadsetBeingCharged == true,
worn = device.isBeingWorn == true,
@@ -10,6 +10,7 @@ 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.AapDeviceInfo
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import eu.darken.capod.pods.core.apple.ble.BATTERY_UNKNOWN
import eu.darken.capod.pods.core.apple.ble.BlePodSnapshot
import eu.darken.capod.pods.core.apple.ble.DualBlePodSnapshot
import eu.darken.capod.pods.core.apple.ble.SingleBlePodSnapshot
@@ -138,18 +139,24 @@ data class PodDevice(
}
}
// Battery — AAP preferred, BLE fallback, then cached
val batteryLeft: Float?
get() = aap?.batteryLeft ?: (ble as? DualBlePodSnapshot)?.batteryLeftPodPercent ?: cached?.left?.percent
// Battery — AAP preferred, BLE fallback, then cached. Returns BATTERY_UNKNOWN (-1f)
// for unknown to keep the type primitive; this is the boundary that previously emitted
// Float? and triggered Android 10 ART JIT crashes via R8-merged unbox call sites.
val batteryLeft: Float
get() = aap?.batteryLeft ?: (ble as? DualBlePodSnapshot)?.batteryLeftPodPercent
?: cached?.left?.percent ?: BATTERY_UNKNOWN
val batteryRight: Float?
get() = aap?.batteryRight ?: (ble as? DualBlePodSnapshot)?.batteryRightPodPercent ?: cached?.right?.percent
val batteryRight: Float
get() = aap?.batteryRight ?: (ble as? DualBlePodSnapshot)?.batteryRightPodPercent
?: cached?.right?.percent ?: BATTERY_UNKNOWN
val batteryCase: Float?
get() = aap?.batteryCase ?: (ble as? HasCase)?.batteryCasePercent ?: cached?.case?.percent
val batteryCase: Float
get() = aap?.batteryCase ?: (ble as? HasCase)?.batteryCasePercent
?: cached?.case?.percent ?: BATTERY_UNKNOWN
val batteryHeadset: Float?
get() = aap?.batteryHeadset ?: (ble as? SingleBlePodSnapshot)?.batteryHeadsetPercent ?: cached?.headset?.percent
val batteryHeadset: Float
get() = aap?.batteryHeadset ?: (ble as? SingleBlePodSnapshot)?.batteryHeadsetPercent
?: cached?.headset?.percent ?: BATTERY_UNKNOWN
/** True when at least one displayed battery value was filled from cache (not live). */
val isBatteryCached: Boolean
@@ -76,7 +76,7 @@ class AapAutoConnect @Inject constructor(
log(TAG) { "AAP connecting to $address (${profile.label})" }
try {
aapManager.connect(address, bonded.internal, profile.model)
aapManager.connect(address, bonded.internal!!, profile.model)
log(TAG) { "AAP connected to $address" }
} catch (e: Exception) {
log(TAG, WARN) { "AAP initial connect failed for $address: ${e.message}" }
@@ -99,7 +99,7 @@ class AapAutoConnect @Inject constructor(
try {
log(TAG) { "AAP initial retry ${attempt + 1} for $address after ${delayMs}ms" }
aapManager.connect(address, bonded.internal, profile.model)
aapManager.connect(address, bonded.internal!!, profile.model)
log(TAG) { "AAP connected to $address on retry ${attempt + 1}" }
break
} catch (retryException: Exception) {
@@ -159,7 +159,7 @@ class AapAutoConnect @Inject constructor(
try {
log(TAG) { "AAP reconnect attempt ${attempt + 1} for $address in ${delayMs}ms" }
aapManager.connect(address, bonded.internal, profile.model)
aapManager.connect(address, bonded.internal!!, profile.model)
log(TAG) { "AAP reconnected to $address" }
break
} catch (e: Exception) {
@@ -211,7 +211,7 @@ class AapAutoConnect @Inject constructor(
val bonded = bluetoothManager.bondedDevices().first()
.firstOrNull { it.address == address }
if (bonded != null) {
aapManager.connect(address, bonded.internal, detectedModel)
aapManager.connect(address, bonded.internal!!, detectedModel)
log(TAG) { "AAP reconnected $address with corrected model $detectedModel" }
}
} catch (e: Exception) {
@@ -3,9 +3,11 @@ package eu.darken.capod.monitor.core.cache
import eu.darken.capod.common.SystemTimeSource
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.monitor.core.cache.CachedDeviceState.CachedBatterySlot
import eu.darken.capod.pods.core.apple.ble.BATTERY_UNKNOWN
import eu.darken.capod.pods.core.apple.ble.DualBlePodSnapshot
import eu.darken.capod.pods.core.apple.ble.SingleBlePodSnapshot
import eu.darken.capod.pods.core.apple.ble.devices.HasCase
import eu.darken.capod.pods.core.apple.ble.isKnownBattery
import java.time.Duration
import java.time.Instant
@@ -24,15 +26,17 @@ fun PodDevice.toCachedState(
if (!isLive) return null
val pid = profileId ?: return null
val liveLeft = aap?.batteryLeft ?: (ble as? DualBlePodSnapshot)?.batteryLeftPodPercent
val liveRight = aap?.batteryRight ?: (ble as? DualBlePodSnapshot)?.batteryRightPodPercent
val liveCase = aap?.batteryCase ?: (ble as? HasCase)?.batteryCasePercent
val liveHeadset = aap?.batteryHeadset ?: (ble as? SingleBlePodSnapshot)?.batteryHeadsetPercent
// RAW LIVE EXTRACTION — must NOT use device.batteryLeft etc. (which fall back to cache).
// Reading the unified getter would re-stamp stale cached readings as fresh live data.
val liveLeft = aap?.batteryLeft ?: (ble as? DualBlePodSnapshot)?.batteryLeftPodPercent ?: BATTERY_UNKNOWN
val liveRight = aap?.batteryRight ?: (ble as? DualBlePodSnapshot)?.batteryRightPodPercent ?: BATTERY_UNKNOWN
val liveCase = aap?.batteryCase ?: (ble as? HasCase)?.batteryCasePercent ?: BATTERY_UNKNOWN
val liveHeadset = aap?.batteryHeadset ?: (ble as? SingleBlePodSnapshot)?.batteryHeadsetPercent ?: BATTERY_UNKNOWN
val liveDeviceInfo = aap?.deviceInfo
if (liveLeft == null && liveRight == null && liveCase == null && liveHeadset == null && liveDeviceInfo == null) {
return null
}
if (!isKnownBattery(liveLeft) && !isKnownBattery(liveRight) &&
!isKnownBattery(liveCase) && !isKnownBattery(liveHeadset) && liveDeviceInfo == null
) return null
val newState = CachedDeviceState(
profileId = pid,
@@ -61,15 +65,14 @@ fun PodDevice.toCachedState(
}
private fun mergeBatterySlot(
livePercent: Float?,
livePercent: Float,
existing: CachedBatterySlot?,
now: Instant,
): CachedBatterySlot? {
val live: Float = livePercent ?: return existing
val current: CachedBatterySlot = existing ?: return CachedBatterySlot(live, now)
if (!isKnownBattery(livePercent)) return existing
val current = existing ?: return CachedBatterySlot(livePercent, now)
val isStale = Duration.between(current.updatedAt, now).abs() > Duration.ofMinutes(1)
return if (current.percent == live && !isStale) current else CachedBatterySlot(live, now)
return if (current.percent == livePercent && !isStale) current else CachedBatterySlot(livePercent, now)
}
private fun hasStateChanged(old: CachedDeviceState, new: CachedDeviceState): Boolean {
@@ -9,6 +9,7 @@ import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.pods.core.apple.ble.formatBatteryPercent
import eu.darken.capod.pods.core.apple.ble.getBatteryDrawable
import eu.darken.capod.pods.core.apple.ble.isKnownBattery
import javax.inject.Inject
import kotlin.math.roundToInt
@@ -150,9 +151,7 @@ class MonitorNotificationViewFactory @Inject constructor(
setTextViewText(R.id.device, device.getLabel(context))
}
private fun percentToInt(percent: Float?): Int {
if (percent == null) return 0
return (percent * 100).roundToInt().coerceIn(0, 100)
}
private fun percentToInt(percent: Float): Int =
if (isKnownBattery(percent)) (percent * 100).roundToInt().coerceIn(0, 100) else 0
}
@@ -21,17 +21,18 @@ import kotlin.math.roundToInt
const val BATTERY_UNKNOWN = -1f
fun Float?.toBatteryFloat(): Float = this ?: BATTERY_UNKNOWN
fun isKnownBattery(percent: Float): Boolean = percent.isFinite() && percent >= 0f
fun Float.toBatteryOrNull(): Float? = takeIf { it >= 0f }
fun batteryProgress(percent: Float): Float =
if (isKnownBattery(percent)) percent.coerceIn(0f, 1f) else 0f
fun formatBatteryPercent(context: Context, percent: Float?): String =
percent?.let { "${(it * 100).roundToInt()}%" }
?: context.getString(R.string.general_value_not_available_label)
fun formatBatteryPercent(context: Context, percent: Float): String =
if (isKnownBattery(percent)) "${(percent * 100).roundToInt()}%"
else context.getString(R.string.general_value_not_available_label)
@DrawableRes
fun getBatteryDrawable(percent: Float?): Int = when {
percent == null -> R.drawable.ic_baseline_battery_unknown_24
fun getBatteryDrawable(percent: Float): Int = when {
!isKnownBattery(percent) -> R.drawable.ic_baseline_battery_unknown_24
percent > 0.95f -> R.drawable.ic_baseline_battery_full_24
percent > 0.80f -> R.drawable.ic_baseline_battery_6_bar_24
percent > 0.65f -> R.drawable.ic_baseline_battery_5_bar_24
@@ -42,8 +43,8 @@ fun getBatteryDrawable(percent: Float?): Int = when {
else -> R.drawable.ic_baseline_battery_0_bar_24
}
fun getBatteryIcon(percent: Float?): ImageVector = when {
percent == null -> Icons.AutoMirrored.TwoTone.BatteryUnknown
fun getBatteryIcon(percent: Float): ImageVector = when {
!isKnownBattery(percent) -> Icons.AutoMirrored.TwoTone.BatteryUnknown
percent > 0.95f -> Icons.TwoTone.BatteryFull
percent > 0.80f -> Icons.TwoTone.Battery6Bar
percent > 0.65f -> Icons.TwoTone.Battery5Bar
@@ -39,8 +39,6 @@ import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.pods.core.apple.ble.formatBatteryPercent
import eu.darken.capod.pods.core.apple.ble.getBatteryIcon
import eu.darken.capod.pods.core.apple.ble.toBatteryFloat
import eu.darken.capod.pods.core.apple.ble.toBatteryOrNull
@Composable
fun PopUpContent(
@@ -115,7 +113,7 @@ private fun DualPodContent(device: PodDevice) {
// Left pod
BatteryColumn(
iconRes = device.leftPodIcon,
batteryPercent = device.batteryLeft.toBatteryFloat(),
batteryPercent = device.batteryLeft,
isCharging = device.isLeftPodCharging ?: false,
modifier = Modifier.weight(1f),
)
@@ -124,7 +122,7 @@ private fun DualPodContent(device: PodDevice) {
if (device.hasCase) {
BatteryColumn(
iconRes = device.caseIcon,
batteryPercent = device.batteryCase.toBatteryFloat(),
batteryPercent = device.batteryCase,
isCharging = device.isCaseCharging ?: false,
modifier = Modifier.weight(1f),
)
@@ -133,7 +131,7 @@ private fun DualPodContent(device: PodDevice) {
// Right pod
BatteryColumn(
iconRes = device.rightPodIcon,
batteryPercent = device.batteryRight.toBatteryFloat(),
batteryPercent = device.batteryRight,
isCharging = device.isRightPodCharging ?: false,
modifier = Modifier.weight(1f),
)
@@ -144,7 +142,7 @@ private fun DualPodContent(device: PodDevice) {
private fun SinglePodContent(device: PodDevice) {
BatteryColumn(
iconRes = device.iconRes,
batteryPercent = device.batteryHeadset.toBatteryFloat(),
batteryPercent = device.batteryHeadset,
isCharging = device.isHeadsetBeingCharged ?: false,
)
}
@@ -157,7 +155,6 @@ private fun BatteryColumn(
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val nullablePercent = batteryPercent.toBatteryOrNull()
Column(
modifier = modifier,
@@ -184,14 +181,14 @@ private fun BatteryColumn(
)
} else {
Icon(
imageVector = getBatteryIcon(nullablePercent),
imageVector = getBatteryIcon(batteryPercent),
contentDescription = null,
modifier = Modifier.size(16.dp),
)
}
Spacer(modifier = Modifier.width(2.dp))
Text(
text = formatBatteryPercent(context, nullablePercent),
text = formatBatteryPercent(context, batteryPercent),
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
+2
View File
@@ -157,6 +157,8 @@
<string name="anc_widget_config_aap_required_hint">This widget requires a direct connection (AAP) to your device. Make sure your device is paired and connected in the app before using this widget.</string>
<string name="anc_widget_no_anc_support_label">This device does not support noise control</string>
<string name="anc_widget_aap_not_connected_label">Not connected</string>
<string name="anc_widget_aap_not_connected_nearby_description">Nearby but not connected</string>
<string name="anc_widget_aap_not_connected_not_nearby_description">Not nearby</string>
<string name="anc_widget_aap_connecting_label">Connecting…</string>
<string name="tile_anc_label">Noise Control</string>
<string name="tile_anc_subtitle_permission_required">Permission required</string>
@@ -12,7 +12,7 @@ fun DashboardLight() = DashboardContent()
@PreviewTest
@PlayStoreLocalesDark
@Composable
fun DashboardDark() = DashboardContent()
fun DashboardDark() = DashboardContent(showAap = true)
@PreviewTest
@PlayStoreLocales
@@ -3,6 +3,7 @@ package eu.darken.capod.monitor.core
import eu.darken.capod.monitor.core.cache.CachedDeviceState
import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.pods.core.apple.aap.AapPodState
import eu.darken.capod.pods.core.apple.ble.BATTERY_UNKNOWN
import eu.darken.capod.pods.core.apple.ble.devices.DualApplePods
import io.kotest.matchers.nulls.shouldBeNull
import io.kotest.matchers.nulls.shouldNotBeNull
@@ -57,7 +58,7 @@ class PodDeviceCacheTest : BaseTest() {
device.batteryLeft shouldBe 0.8f
device.batteryRight shouldBe 0.7f
device.batteryCase shouldBe 0.5f
device.batteryHeadset.shouldBeNull()
device.batteryHeadset shouldBe BATTERY_UNKNOWN
}
@Test
@@ -252,6 +252,37 @@ class ToCachedStateTest : BaseTest() {
result.rightEarbudSerial shouldBe "R-1"
result.marketingVersion shouldBe "9999"
}
@Test
fun `DeviceInfo-only update preserves existing slot timestamps (no live refresh)`() {
// Regression: if toCachedState consults the unified PodDevice.batteryX getter (which
// falls back to cache), an AAP-only DeviceInfo update would re-stamp every slot with
// `now`, refreshing stale cached readings indefinitely. Raw live extraction prevents
// that.
val oldStamp = now.minusSeconds(3600)
val existing = CachedDeviceState(
profileId = "test-profile",
model = PodModel.AIRPODS_PRO3,
left = CachedDeviceState.CachedBatterySlot(0.8f, oldStamp),
right = CachedDeviceState.CachedBatterySlot(0.7f, oldStamp),
case = CachedDeviceState.CachedBatterySlot(0.5f, oldStamp),
marketingVersion = "OLD",
lastSeenAt = oldStamp,
)
val device = PodDevice(
profileId = "test-profile",
ble = null,
aap = AapPodState(deviceInfo = deviceInfo(marketingVersion = "NEW")),
profileModel = PodModel.AIRPODS_PRO3,
)
val result = device.toCachedState(existing, now).shouldNotBeNull()
result.marketingVersion shouldBe "NEW"
result.left?.updatedAt shouldBe oldStamp
result.right?.updatedAt shouldBe oldStamp
result.case?.updatedAt shouldBe oldStamp
result.left?.percent shouldBe 0.8f
}
}
private fun deviceInfo(
+14
View File
@@ -62,6 +62,20 @@ platform :android do
)
end
lane :screenshots_only do
sh "bash ./remove_unsupported_languages.sh"
supply(
track: 'production',
package_name: 'eu.darken.capod',
skip_upload_apk: 'true',
skip_upload_aab: 'true',
skip_upload_metadata: 'true',
skip_upload_changelogs: 'true',
skip_upload_images: 'true',
skip_upload_screenshots: 'false',
)
end
after_all do |lane|
# This block is called, only if the executed lane was successful
+23 -25
View File
@@ -3,13 +3,13 @@ GEM
specs:
CFPropertyList (3.0.8)
abbrev (0.1.2)
addressable (2.8.8)
addressable (2.9.0)
public_suffix (>= 2.0.2, < 8.0)
artifactory (3.0.17)
atomos (0.1.3)
aws-eventstream (1.4.0)
aws-partitions (1.1216.0)
aws-sdk-core (3.242.0)
aws-partitions (1.1244.0)
aws-sdk-core (3.246.0)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
@@ -17,11 +17,11 @@ GEM
bigdecimal
jmespath (~> 1, >= 1.6.1)
logger
aws-sdk-kms (1.122.0)
aws-sdk-core (~> 3, >= 3.241.4)
aws-sdk-kms (1.124.0)
aws-sdk-core (~> 3, >= 3.244.0)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.213.0)
aws-sdk-core (~> 3, >= 3.241.4)
aws-sdk-s3 (1.220.0)
aws-sdk-core (~> 3, >= 3.244.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.12.1)
@@ -29,7 +29,7 @@ GEM
babosa (1.0.4)
base64 (0.2.0)
benchmark (0.5.0)
bigdecimal (4.0.1)
bigdecimal (4.1.2)
claide (1.1.0)
colored (1.2)
colored2 (3.1.2)
@@ -68,11 +68,11 @@ GEM
faraday-net_http_persistent (1.2.0)
faraday-patron (1.0.0)
faraday-rack (1.0.0)
faraday-retry (1.0.3)
faraday-retry (1.0.4)
faraday_middleware (1.2.1)
faraday (~> 1.0)
fastimage (2.4.0)
fastlane (2.232.1)
fastimage (2.4.1)
fastlane (2.233.1)
CFPropertyList (>= 2.3, < 4.0.0)
abbrev (~> 0.1.2)
addressable (>= 2.8, < 3.0.0)
@@ -92,7 +92,7 @@ GEM
faraday-cookie_jar (~> 0.0.6)
faraday_middleware (~> 1.0)
fastimage (>= 2.1.0, < 3.0.0)
fastlane-sirp (>= 1.0.0)
fastlane-sirp (>= 1.1.0)
gh_inspector (>= 1.1.2, < 2.0.0)
google-apis-androidpublisher_v3 (~> 0.3)
google-apis-playcustomapp_v1 (~> 0.1)
@@ -122,10 +122,9 @@ GEM
xcodeproj (>= 1.13.0, < 2.0.0)
xcpretty (~> 0.4.1)
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
fastlane-sirp (1.0.0)
sysrandom (~> 1.0)
fastlane-sirp (1.1.0)
gh_inspector (1.1.3)
google-apis-androidpublisher_v3 (0.96.0)
google-apis-androidpublisher_v3 (0.99.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-core (0.18.0)
addressable (~> 2.5, >= 2.5.1)
@@ -135,19 +134,19 @@ GEM
mutex_m
representable (~> 3.0)
retriable (>= 2.0, < 4.a)
google-apis-iamcredentials_v1 (0.26.0)
google-apis-iamcredentials_v1 (0.27.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-playcustomapp_v1 (0.17.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-storage_v1 (0.60.0)
google-apis-storage_v1 (0.62.0)
google-apis-core (>= 0.15.0, < 2.a)
google-cloud-core (1.8.0)
google-cloud-env (>= 1.0, < 3.a)
google-cloud-errors (~> 1.0)
google-cloud-env (2.1.1)
faraday (>= 1.0, < 3.a)
google-cloud-errors (1.5.0)
google-cloud-storage (1.58.0)
google-cloud-errors (1.6.0)
google-cloud-storage (1.59.0)
addressable (~> 2.8)
digest-crc (~> 0.4)
google-apis-core (>= 0.18, < 2)
@@ -169,13 +168,13 @@ GEM
httpclient (2.9.0)
mutex_m
jmespath (1.6.2)
json (2.18.1)
json (2.19.5)
jwt (2.10.2)
base64
logger (1.7.0)
mini_magick (4.13.2)
mini_mime (1.1.5)
multi_json (1.19.1)
multi_json (1.21.0)
multipart-post (2.4.1)
mutex_m (0.3.0)
nanaimo (0.4.0)
@@ -185,13 +184,13 @@ GEM
os (1.1.4)
ostruct (0.6.3)
plist (3.7.2)
public_suffix (7.0.2)
rake (13.3.1)
public_suffix (7.0.5)
rake (13.4.2)
representable (3.2.0)
declarative (< 0.1.0)
trailblazer-option (>= 0.1.1, < 0.2.0)
uber (< 0.2.0)
retriable (3.2.0)
retriable (3.4.1)
rexml (3.4.4)
rouge (3.28.0)
ruby2_keywords (0.0.5)
@@ -205,7 +204,6 @@ GEM
simctl (1.6.10)
CFPropertyList
naturally
sysrandom (1.0.5)
terminal-notifier (2.0.0)
terminal-table (3.0.2)
unicode-display_width (>= 1.1.1, < 3)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 156 KiB

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 145 KiB

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 147 KiB

Some files were not shown because too many files have changed in this diff Show More