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/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 | | `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 ## Commands
```bash ```bash
# Full run — iterates over ALL_LOCALES in batches of BATCH_SIZE. # Default — smoke set (6 locales × 7 screens, ~42 PNGs, single batch).
# The script prints "Locales: N | Batch size: B | Batches: ceil(N/B)" at startup. # Use this for local iteration and PRs that touch screenshot content.
./fastlane/generate_screenshots.sh
# Smoke test — 6 locales (en, de, ja, ar, zh-CN, pt-BR), single batch
./fastlane/generate_screenshots.sh --smoke ./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) # Copy into fastlane directories (run after generate)
./fastlane/copy_screenshots.sh ./fastlane/copy_screenshots.sh
@@ -48,23 +55,36 @@ ScreenshotContent.kt (mock data + composables)
2. Add a `@PreviewTest` function in `PlayStoreScreenshots.kt` that calls it 2. Add a `@PreviewTest` function in `PlayStoreScreenshots.kt` that calls it
3. Add the function name → filename mapping in `copy_screenshots.sh` `SCREEN_MAP` 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) 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 ## Removing or Renaming a Screenshot
1. Remove the `@PreviewTest` entry and its `SCREEN_MAP` mapping 1. Remove the `@PreviewTest` entry and its `SCREEN_MAP` mapping
2. Run `generate_screenshots.sh` 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/*/images/phoneScreenshots/` and get uploaded to Play Store 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 ## 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 ```bash
./fastlane/generate_screenshots.sh ./fastlane/generate_screenshots.sh --smoke
./fastlane/copy_screenshots.sh --clean ./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 ## 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) - 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 .kotlin
# Screenshot test reference images (ephemeral, regenerated on demand) # Screenshot test reference images (ephemeral, regenerated on demand)
app/src/screenshotTest*/reference/ 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 .codex
protocol-research/ protocol-research/
_site/ _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 //noinspection WrongGradleMethod
tasks.withType<Test> { tasks.withType<Test> {
useJUnitPlatform() useJUnitPlatform()
maxHeapSize = "4g"
} }
} }
} }
@@ -14,6 +14,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp 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.PreviewWrapper
import eu.darken.capod.common.compose.preview.MOCK_NOW import eu.darken.capod.common.compose.preview.MOCK_NOW
import eu.darken.capod.common.compose.preview.MockPodDataProvider 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" internal const val DS = "spec:width=1080px,height=2400px,dpi=428"
@Composable @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( OverviewScreen(
state = OverviewViewModel.State( state = OverviewViewModel.State(
now = MOCK_NOW, now = MOCK_NOW,
permissions = emptySet(), permissions = emptySet(),
devices = listOf( devices = devices,
MockPodDataProvider.dualPodMonitoredMixed(),
MockPodDataProvider.singlePodMonitored(),
MockPodDataProvider.unknownMonitored(),
),
isDebugMode = false, isDebugMode = false,
isBluetoothEnabled = true, isBluetoothEnabled = true,
profiles = listOf( profiles = listOf(
@@ -83,19 +93,38 @@ internal fun DeviceProfilesContent() = PreviewWrapper {
@Composable @Composable
internal fun AddProfileContent() = PreviewWrapper { 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( DeviceProfileCreationScreen(
state = DeviceProfileCreationViewModel.State( state = DeviceProfileCreationViewModel.State(
isEditMode = false, isEditMode = false,
name = "", name = "My AirPods Pro",
nameError = null, nameError = null,
selectedModel = null, selectedModel = PodModel.AIRPODS_PRO2,
availableModels = PodModel.entries.filter { it != PodModel.UNKNOWN }, availableModels = PodModel.entries.filter { it != PodModel.UNKNOWN },
identityKey = null, identityKey = null,
encryptionKey = null, encryptionKey = null,
selectedDevice = null, selectedDevice = pairedAirPods,
bondedDeviceItems = emptyList(), bondedDeviceItems = bondedItems,
minimumSignalQuality = 0.15f, minimumSignalQuality = 0.15f,
canSave = false, canSave = true,
), ),
onBack = {}, onBack = {},
onSave = {}, 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) @Preview(name = "2 - Dashboard Dark", locale = "en", device = DS, uiMode = Configuration.UI_MODE_NIGHT_YES, showSystemUi = true)
@Composable @Composable
private fun PreviewDashboardDark() = DashboardContent() private fun PreviewDashboardDark() = DashboardContent(showAap = true)
@Preview(name = "3 - Case Pop-up", device = DS, showSystemUi = true) @Preview(name = "3 - Case Pop-up", device = DS, showSystemUi = true)
@Composable @Composable
@@ -4,12 +4,8 @@ import android.bluetooth.BluetoothDevice
import java.time.Instant import java.time.Instant
data class BluetoothDevice2( data class BluetoothDevice2(
internal val internal: BluetoothDevice, val address: BluetoothAddress,
val name: String?,
val seenFirstAt: Instant, val seenFirstAt: Instant,
) { internal val internal: BluetoothDevice? = null,
val address: BluetoothAddress )
get() = internal.address
val name: String?
get() = internal.name
}
@@ -255,6 +255,8 @@ class BluetoothManager2 @Inject constructor(
} }
.map { device -> .map { device ->
BluetoothDevice2( BluetoothDevice2(
address = device.address,
name = device.name,
internal = device, internal = device,
seenFirstAt = seenDevicesLock.withLock { seenFirstAt = seenDevicesLock.withLock {
seenDevicesCache[device.address] ?: run { seenDevicesCache[device.address] ?: run {
@@ -304,6 +306,8 @@ class BluetoothManager2 @Inject constructor(
val wrappedDevices = rawDevices.map { device -> val wrappedDevices = rawDevices.map { device ->
BluetoothDevice2( BluetoothDevice2(
address = device.address,
name = device.name,
internal = device, internal = device,
seenFirstAt = seenDevicesLock.withLock { seenFirstAt = seenDevicesLock.withLock {
seenDevicesCache[device.address] ?: run { seenDevicesCache[device.address] ?: run {
@@ -408,9 +412,10 @@ class BluetoothManager2 @Inject constructor(
@android.annotation.SuppressLint("MissingPermission") @android.annotation.SuppressLint("MissingPermission")
fun setDeviceAlias(device: BluetoothDevice2, alias: String): Boolean { fun setDeviceAlias(device: BluetoothDevice2, alias: String): Boolean {
return try { return try {
val target = device.internal ?: return false
val method = BluetoothDevice::class.java.getDeclaredMethod("setAlias", String::class.java) val method = BluetoothDevice::class.java.getDeclaredMethod("setAlias", String::class.java)
.apply { isAccessible = true } .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" } log(TAG) { "setDeviceAlias(${device.address}, $alias) -> $result" }
result result
} catch (e: Exception) { } catch (e: Exception) {
@@ -424,11 +429,12 @@ class BluetoothManager2 @Inject constructor(
try { try {
log(TAG) { "Nudging Android connection to $device" } log(TAG) { "Nudging Android connection to $device" }
val target = device.internal ?: return@map false
val connectMethod = BluetoothHeadset::class.java.getDeclaredMethod( val connectMethod = BluetoothHeadset::class.java.getDeclaredMethod(
"connect", BluetoothDevice::class.java "connect", BluetoothDevice::class.java
).apply { isAccessible = true } ).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" } log(TAG) { "Nudged connection to $device — accepted=$accepted" }
accepted accepted
} catch (e: Exception) { } catch (e: Exception) {
@@ -180,18 +180,21 @@ object MockPodDataProvider {
fun dualPodMonitoredMixed(): PodDevice = PodDevice( fun dualPodMonitoredMixed(): PodDevice = PodDevice(
profileId = "preview-dual-mixed", profileId = "preview-dual-mixed",
label = "My AirPods Pro",
ble = airPodsProMixed(), ble = airPodsProMixed(),
aap = null, aap = null,
) )
fun dualPodMonitoredWithKeys(): PodDevice = PodDevice( fun dualPodMonitoredWithKeys(): PodDevice = PodDevice(
profileId = "preview-dual-keys", profileId = "preview-dual-keys",
label = "My AirPods Pro",
ble = airPodsProWithKeys(), ble = airPodsProWithKeys(),
aap = null, aap = null,
) )
fun dualPodMonitoredWithAap(): PodDevice = PodDevice( fun dualPodMonitoredWithAap(): PodDevice = PodDevice(
profileId = "preview-dual-aap", profileId = "preview-dual-aap",
label = "My AirPods Pro",
ble = airPodsProWithKeys(), ble = airPodsProWithKeys(),
aap = AapPodState( aap = AapPodState(
connectionState = AapPodState.ConnectionState.READY, connectionState = AapPodState.ConnectionState.READY,
@@ -227,12 +230,14 @@ object MockPodDataProvider {
fun singlePodMonitored(): PodDevice = PodDevice( fun singlePodMonitored(): PodDevice = PodDevice(
profileId = "preview-single", profileId = "preview-single",
label = "AirPods Max",
ble = airPodsMax(), ble = airPodsMax(),
aap = null, aap = null,
) )
fun singlePodMonitoredWithAap(): PodDevice = PodDevice( fun singlePodMonitoredWithAap(): PodDevice = PodDevice(
profileId = "preview-single-aap", profileId = "preview-single-aap",
label = "AirPods Max",
ble = airPodsMaxCharging(), ble = airPodsMaxCharging(),
aap = AapPodState( aap = AapPodState(
connectionState = AapPodState.ConnectionState.READY, connectionState = AapPodState.ConnectionState.READY,
@@ -24,6 +24,7 @@ import androidx.lifecycle.lifecycleScope
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import eu.darken.capod.common.debug.logging.log import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag 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.LocalNavigationController
import eu.darken.capod.common.navigation.Nav import eu.darken.capod.common.navigation.Nav
import eu.darken.capod.common.navigation.NavigationController import eu.darken.capod.common.navigation.NavigationController
@@ -50,6 +51,11 @@ class MainActivity : Activity2() {
@Inject lateinit var popUpWindow: PopUpWindow @Inject lateinit var popUpWindow: PopUpWindow
@Inject lateinit var upgradeRepo: UpgradeRepo @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?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
installSplashScreen() installSplashScreen()
@@ -80,7 +86,11 @@ class MainActivity : Activity2() {
navCtrl.setup(backStack) navCtrl.setup(backStack)
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
// Cold-start intent (the activity's launching intent).
consumeUpgradeExtra(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) { CapodTheme(state = themeState) {
@@ -132,7 +142,9 @@ class MainActivity : Activity2() {
override fun onNewIntent(intent: Intent) { override fun onNewIntent(intent: Intent) {
super.onNewIntent(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?) { 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.DualApplePods.LidState
import eu.darken.capod.pods.core.apple.ble.devices.HasPodStyle 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.formatBatteryPercent
import eu.darken.capod.pods.core.apple.ble.toBatteryFloat
import eu.darken.capod.pods.core.apple.ble.toBatteryOrNull
import java.time.Instant import java.time.Instant
@Composable @Composable
@@ -227,7 +225,7 @@ private fun ColumnScope.DualPodsCardExpanded(
) { ) {
PodGauge( PodGauge(
iconRes = device.leftPodIcon, iconRes = device.leftPodIcon,
batteryPercent = device.batteryLeft.toBatteryFloat(), batteryPercent = device.batteryLeft,
chargingState = device.leftPodChargingState chargingState = device.leftPodChargingState
?: device.isLeftPodCharging?.let { if (it) AapPodState.ChargingState.CHARGING else null }, ?: device.isLeftPodCharging?.let { if (it) AapPodState.ChargingState.CHARGING else null },
isInEar = device.isLeftInEar ?: false, isInEar = device.isLeftInEar ?: false,
@@ -239,7 +237,7 @@ private fun ColumnScope.DualPodsCardExpanded(
PodGauge( PodGauge(
iconRes = device.rightPodIcon, iconRes = device.rightPodIcon,
batteryPercent = device.batteryRight.toBatteryFloat(), batteryPercent = device.batteryRight,
chargingState = device.rightPodChargingState chargingState = device.rightPodChargingState
?: device.isRightPodCharging?.let { if (it) AapPodState.ChargingState.CHARGING else null }, ?: device.isRightPodCharging?.let { if (it) AapPodState.ChargingState.CHARGING else null },
isInEar = device.isRightInEar ?: false, isInEar = device.isRightInEar ?: false,
@@ -360,7 +358,7 @@ private fun PodGauge(
// Battery percentage // Battery percentage
Text( Text(
text = formatBatteryPercent(context, batteryPercent.toBatteryOrNull()), text = formatBatteryPercent(context, batteryPercent),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
color = if (batteryPercent >= 0f) { color = if (batteryPercent >= 0f) {
MaterialTheme.colorScheme.onSurface MaterialTheme.colorScheme.onSurface
@@ -412,7 +410,7 @@ private fun CaseRow(
) )
BatteryCapsule( BatteryCapsule(
percent = device.batteryCase.toBatteryFloat(), percent = device.batteryCase,
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.height(8.dp), .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.monitor.core.cachedBatteryFormatted
import eu.darken.capod.pods.core.apple.aap.AapPodState 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.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.formatBatteryPercent
import eu.darken.capod.pods.core.apple.ble.isKnownBattery
import java.time.Instant import java.time.Instant
@OptIn(ExperimentalLayoutApi::class) @OptIn(ExperimentalLayoutApi::class)
@@ -192,17 +194,18 @@ private fun ColumnScope.SinglePodsCardExpanded(
) { ) {
val context = LocalContext.current val context = LocalContext.current
val clamped = device.batteryHeadset?.coerceIn(0f, 1f) val percent = device.batteryHeadset
val isKnown = isKnownBattery(percent)
val animatedProgress by animateFloatAsState( val animatedProgress by animateFloatAsState(
targetValue = clamped ?: 0f, targetValue = batteryProgress(percent),
animationSpec = tween(600, easing = FastOutSlowInEasing), animationSpec = tween(600, easing = FastOutSlowInEasing),
label = "gaugeProgress", label = "gaugeProgress",
) )
val ringColor = when { val ringColor = when {
clamped == null -> MaterialTheme.colorScheme.surfaceVariant !isKnown -> MaterialTheme.colorScheme.surfaceVariant
clamped > 0.30f -> MaterialTheme.colorScheme.primary percent > 0.30f -> MaterialTheme.colorScheme.primary
clamped >= 0.15f -> MaterialTheme.colorScheme.tertiary percent >= 0.15f -> MaterialTheme.colorScheme.tertiary
else -> MaterialTheme.colorScheme.error else -> MaterialTheme.colorScheme.error
} }
@@ -237,7 +240,7 @@ private fun ColumnScope.SinglePodsCardExpanded(
) )
// Progress ring // Progress ring
if (clamped != null) { if (isKnown) {
CircularProgressIndicator( CircularProgressIndicator(
progress = { animatedProgress }, progress = { animatedProgress },
modifier = Modifier.size(88.dp), modifier = Modifier.size(88.dp),
@@ -250,9 +253,9 @@ private fun ColumnScope.SinglePodsCardExpanded(
// Battery text inside ring // Battery text inside ring
Text( Text(
text = formatBatteryPercent(context, device.batteryHeadset), text = formatBatteryPercent(context, percent),
style = MaterialTheme.typography.headlineSmall, style = MaterialTheme.typography.headlineSmall,
color = if (device.batteryHeadset != null) { color = if (isKnown) {
MaterialTheme.colorScheme.onSurface MaterialTheme.colorScheme.onSurface
} else { } else {
MaterialTheme.colorScheme.onSurfaceVariant MaterialTheme.colorScheme.onSurfaceVariant
@@ -33,17 +33,19 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import eu.darken.capod.R import eu.darken.capod.R
import eu.darken.capod.monitor.core.PodDevice 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.formatBatteryPercent
import eu.darken.capod.pods.core.apple.ble.isKnownBattery
@Composable @Composable
fun CompactBatterySummary( fun CompactBatterySummary(
device: PodDevice, device: PodDevice,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val hasAnyBattery = device.batteryLeft != null val hasAnyBattery = isKnownBattery(device.batteryLeft)
|| device.batteryRight != null || isKnownBattery(device.batteryRight)
|| device.batteryHeadset != null || isKnownBattery(device.batteryHeadset)
|| device.batteryCase != null || isKnownBattery(device.batteryCase)
Surface( Surface(
modifier = modifier modifier = modifier
@@ -80,7 +82,7 @@ private fun RowScope.DualPodsRow(device: PodDevice) {
percent = device.batteryRight, percent = device.batteryRight,
) )
if (device.hasCase && device.batteryCase != null) { if (device.hasCase && isKnownBattery(device.batteryCase)) {
Spacer(modifier = Modifier.weight(1f)) Spacer(modifier = Modifier.weight(1f))
MiniCaseCluster(device = device) MiniCaseCluster(device = device)
} }
@@ -93,7 +95,7 @@ private fun RowScope.SinglePodRow(device: PodDevice) {
iconRes = null, iconRes = null,
percent = device.batteryHeadset, percent = device.batteryHeadset,
) )
if (device.hasCase && device.batteryCase != null) { if (device.hasCase && isKnownBattery(device.batteryCase)) {
Spacer(modifier = Modifier.weight(1f)) Spacer(modifier = Modifier.weight(1f))
MiniCaseCluster(device = device) MiniCaseCluster(device = device)
} }
@@ -121,21 +123,21 @@ private fun RowScope.EmptyBatteryRow() {
@Composable @Composable
private fun MiniPodRing( private fun MiniPodRing(
iconRes: Int?, iconRes: Int?,
percent: Float?, percent: Float,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val clamped = percent?.coerceIn(0f, 1f) val isKnown = isKnownBattery(percent)
val animatedProgress by animateFloatAsState( val animatedProgress by animateFloatAsState(
targetValue = clamped ?: 0f, targetValue = batteryProgress(percent),
animationSpec = tween(600, easing = FastOutSlowInEasing), animationSpec = tween(600, easing = FastOutSlowInEasing),
label = "miniGaugeProgress", label = "miniGaugeProgress",
) )
val ringColor = when { val ringColor = when {
clamped == null -> MaterialTheme.colorScheme.surfaceVariant !isKnown -> MaterialTheme.colorScheme.surfaceVariant
clamped > 0.30f -> MaterialTheme.colorScheme.primary percent > 0.30f -> MaterialTheme.colorScheme.primary
clamped >= 0.15f -> MaterialTheme.colorScheme.tertiary percent >= 0.15f -> MaterialTheme.colorScheme.tertiary
else -> MaterialTheme.colorScheme.error else -> MaterialTheme.colorScheme.error
} }
@@ -155,7 +157,7 @@ private fun MiniPodRing(
trackColor = MaterialTheme.colorScheme.surfaceVariant, trackColor = MaterialTheme.colorScheme.surfaceVariant,
strokeCap = StrokeCap.Round, strokeCap = StrokeCap.Round,
) )
if (clamped != null) { if (isKnown) {
CircularProgressIndicator( CircularProgressIndicator(
progress = { animatedProgress }, progress = { animatedProgress },
modifier = Modifier.size(28.dp), modifier = Modifier.size(28.dp),
@@ -177,7 +179,7 @@ private fun MiniPodRing(
Text( Text(
text = formatBatteryPercent(context, percent), text = formatBatteryPercent(context, percent),
style = MaterialTheme.typography.titleSmall, style = MaterialTheme.typography.titleSmall,
color = if (percent != null) { color = if (isKnown) {
MaterialTheme.colorScheme.onSurface MaterialTheme.colorScheme.onSurface
} else { } else {
MaterialTheme.colorScheme.onSurfaceVariant MaterialTheme.colorScheme.onSurfaceVariant
@@ -204,7 +206,7 @@ private fun MiniCaseCluster(
) )
Spacer(modifier = Modifier.width(6.dp)) Spacer(modifier = Modifier.width(6.dp))
BatteryCapsule( BatteryCapsule(
percent = device.batteryCase ?: -1f, percent = device.batteryCase,
modifier = Modifier modifier = Modifier
.width(36.dp) .width(36.dp)
.height(6.dp), .height(6.dp),
@@ -58,13 +58,21 @@ object AncWidgetRenderStateMapper {
primaryText = context.getString(R.string.anc_widget_no_anc_support_label), primaryText = context.getString(R.string.anc_widget_no_anc_support_label),
) )
!device.isAapConnected -> AncWidgetRenderState.Message( !device.isAapConnected -> {
theme = theme, val secondaryRes = if (device.ble != null) {
resolvedBgColor = bgColor, R.string.anc_widget_aap_not_connected_nearby_description
resolvedTextColor = textColor, } else {
resolvedIconColor = iconColor, R.string.anc_widget_aap_not_connected_not_nearby_description
primaryText = context.getString(R.string.anc_widget_aap_not_connected_label), }
) AncWidgetRenderState.Message(
theme = theme,
resolvedBgColor = bgColor,
resolvedTextColor = textColor,
resolvedIconColor = iconColor,
primaryText = profileLabel ?: device.getLabel(context),
secondaryText = context.getString(secondaryRes),
)
}
!device.isAapReady -> AncWidgetRenderState.Message( !device.isAapReady -> AncWidgetRenderState.Message(
theme = theme, theme = theme,
@@ -31,7 +31,7 @@ import androidx.compose.ui.unit.sp
import eu.darken.capod.R import eu.darken.capod.R
import eu.darken.capod.common.compose.Preview2 import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper 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 import kotlin.math.roundToInt
@Composable @Composable
@@ -160,7 +160,7 @@ private fun SinglePodPreview(
colorFilter = iconTint, colorFilter = iconTint,
) )
Text( Text(
text = formatPercent(state.percent.toBatteryOrNull()), text = formatPercent(state.percent),
fontSize = 12.sp, fontSize = 12.sp,
color = textColor, color = textColor,
modifier = Modifier.padding(horizontal = 8.dp), modifier = Modifier.padding(horizontal = 8.dp),
@@ -311,7 +311,7 @@ private fun PodItemRow(
colorFilter = iconTint, colorFilter = iconTint,
) )
Text( Text(
text = formatPercent(percent.toBatteryOrNull()), text = formatPercent(percent),
fontSize = 12.sp, fontSize = 12.sp,
color = textColor, color = textColor,
modifier = Modifier.padding(horizontal = 4.dp), modifier = Modifier.padding(horizontal = 4.dp),
@@ -354,7 +354,7 @@ private fun TinyPodItem(
colorFilter = iconTint, colorFilter = iconTint,
) )
Text( Text(
text = formatPercent(percent.toBatteryOrNull()), text = formatPercent(percent),
fontSize = 12.sp, fontSize = 12.sp,
color = textColor, color = textColor,
maxLines = 1, maxLines = 1,
@@ -382,9 +382,8 @@ private fun DeviceLabel(
} }
} }
private fun formatPercent(percent: Float?): String { private fun formatPercent(percent: Float): String =
return percent?.let { "${(it * 100).roundToInt()}%" } ?: "" if (isKnownBattery(percent)) "${(percent * 100).roundToInt()}%" else ""
}
@Preview2 @Preview2
@Composable @Composable
@@ -27,7 +27,7 @@ import androidx.glance.text.TextAlign
import androidx.glance.text.TextStyle import androidx.glance.text.TextStyle
import eu.darken.capod.R import eu.darken.capod.R
import eu.darken.capod.main.ui.MainActivity 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 import kotlin.math.roundToInt
@Composable @Composable
@@ -122,7 +122,7 @@ private fun GlanceSinglePod(
colorFilter = iconTint, colorFilter = iconTint,
) )
Text( Text(
text = formatGlancePercent(state.percent.toBatteryOrNull()), text = formatGlancePercent(state.percent),
style = textStyle, style = textStyle,
modifier = GlanceModifier.padding(horizontal = 8.dp), modifier = GlanceModifier.padding(horizontal = 8.dp),
) )
@@ -270,7 +270,7 @@ private fun GlancePodItem(
colorFilter = iconTint, colorFilter = iconTint,
) )
Text( Text(
text = formatGlancePercent(percent.toBatteryOrNull()), text = formatGlancePercent(percent),
style = textStyle, style = textStyle,
modifier = GlanceModifier.padding(horizontal = 4.dp), modifier = GlanceModifier.padding(horizontal = 4.dp),
) )
@@ -312,7 +312,7 @@ private fun GlanceTinyPodItem(
colorFilter = iconTint, colorFilter = iconTint,
) )
Text( Text(
text = formatGlancePercent(percent.toBatteryOrNull()), text = formatGlancePercent(percent),
style = textStyle, style = textStyle,
maxLines = 1, maxLines = 1,
modifier = GlanceModifier.padding(start = 4.dp), modifier = GlanceModifier.padding(start = 4.dp),
@@ -341,6 +341,5 @@ private fun GlanceDeviceLabel(
private fun fixedColor(argb: Int): ColorProvider = ColorProvider(Color(argb)) private fun fixedColor(argb: Int): ColorProvider = ColorProvider(Color(argb))
private fun formatGlancePercent(percent: Float?): String { private fun formatGlancePercent(percent: Float): String =
return percent?.let { "${(it * 100).roundToInt()}%" } ?: "" if (isKnownBattery(percent)) "${(percent * 100).roundToInt()}%" else ""
}
@@ -14,10 +14,10 @@ internal data class WidgetDeviceKey(
val profileId: String?, val profileId: String?,
val profileLabel: String?, val profileLabel: String?,
val model: PodModel, val model: PodModel,
val batteryLeft: Float?, val batteryLeft: Float,
val batteryRight: Float?, val batteryRight: Float,
val batteryCase: Float?, val batteryCase: Float,
val batteryHeadset: Float?, val batteryHeadset: Float,
val isLeftPodCharging: Boolean?, val isLeftPodCharging: Boolean?,
val isRightPodCharging: Boolean?, val isRightPodCharging: Boolean?,
val isCaseCharging: Boolean?, val isCaseCharging: Boolean?,
@@ -27,6 +27,7 @@ internal data class WidgetDeviceKey(
val isBeingWorn: Boolean?, val isBeingWorn: Boolean?,
val isAapConnected: Boolean, val isAapConnected: Boolean,
val isAapReady: Boolean, val isAapReady: Boolean,
val hasBleAdvertisement: Boolean,
val ancMode: AapSetting.AncMode.Value?, val ancMode: AapSetting.AncMode.Value?,
val pendingAncMode: AapSetting.AncMode.Value?, val pendingAncMode: AapSetting.AncMode.Value?,
val visibleAncModes: List<AapSetting.AncMode.Value>, val visibleAncModes: List<AapSetting.AncMode.Value>,
@@ -49,6 +50,7 @@ internal fun PodDevice.toWidgetKey(): WidgetDeviceKey = WidgetDeviceKey(
isBeingWorn = isBeingWorn, isBeingWorn = isBeingWorn,
isAapConnected = isAapConnected, isAapConnected = isAapConnected,
isAapReady = isAapReady, isAapReady = isAapReady,
hasBleAdvertisement = ble != null,
ancMode = ancMode?.current, ancMode = ancMode?.current,
pendingAncMode = pendingAncMode, pendingAncMode = pendingAncMode,
visibleAncModes = visibleAncModes, visibleAncModes = visibleAncModes,
@@ -7,7 +7,6 @@ import eu.darken.capod.R
import eu.darken.capod.monitor.core.PodDevice import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.pods.core.apple.PodModel 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.getBatteryDrawable
import eu.darken.capod.pods.core.apple.ble.toBatteryFloat
object WidgetRenderStateMapper { object WidgetRenderStateMapper {
@@ -43,15 +42,15 @@ object WidgetRenderStateMapper {
layout = layout, layout = layout,
deviceLabel = profileLabel ?: device.getLabel(context), deviceLabel = profileLabel ?: device.getLabel(context),
leftIcon = device.leftPodIcon, leftIcon = device.leftPodIcon,
leftPercent = device.batteryLeft.toBatteryFloat(), leftPercent = device.batteryLeft,
leftCharging = device.isLeftPodCharging == true, leftCharging = device.isLeftPodCharging == true,
leftInEar = device.isLeftInEar == true, leftInEar = device.isLeftInEar == true,
rightIcon = device.rightPodIcon, rightIcon = device.rightPodIcon,
rightPercent = device.batteryRight.toBatteryFloat(), rightPercent = device.batteryRight,
rightCharging = device.isRightPodCharging == true, rightCharging = device.isRightPodCharging == true,
rightInEar = device.isRightInEar == true, rightInEar = device.isRightInEar == true,
caseIcon = device.caseIcon, caseIcon = device.caseIcon,
casePercent = device.batteryCase.toBatteryFloat(), casePercent = device.batteryCase,
caseCharging = device.isCaseCharging == true, caseCharging = device.isCaseCharging == true,
) )
@@ -63,7 +62,7 @@ object WidgetRenderStateMapper {
layout = layout, layout = layout,
deviceLabel = profileLabel ?: device.getLabel(context), deviceLabel = profileLabel ?: device.getLabel(context),
headsetIcon = device.iconRes, headsetIcon = device.iconRes,
percent = device.batteryHeadset.toBatteryFloat(), percent = device.batteryHeadset,
batteryIcon = getBatteryDrawable(device.batteryHeadset), batteryIcon = getBatteryDrawable(device.batteryHeadset),
charging = device.isHeadsetBeingCharged == true, charging = device.isHeadsetBeingCharged == true,
worn = device.isBeingWorn == 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.AapPodState
import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceInfo 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.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.BlePodSnapshot
import eu.darken.capod.pods.core.apple.ble.DualBlePodSnapshot 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.SingleBlePodSnapshot
@@ -138,18 +139,24 @@ data class PodDevice(
} }
} }
// Battery — AAP preferred, BLE fallback, then cached // Battery — AAP preferred, BLE fallback, then cached. Returns BATTERY_UNKNOWN (-1f)
val batteryLeft: Float? // for unknown to keep the type primitive; this is the boundary that previously emitted
get() = aap?.batteryLeft ?: (ble as? DualBlePodSnapshot)?.batteryLeftPodPercent ?: cached?.left?.percent // 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? val batteryRight: Float
get() = aap?.batteryRight ?: (ble as? DualBlePodSnapshot)?.batteryRightPodPercent ?: cached?.right?.percent get() = aap?.batteryRight ?: (ble as? DualBlePodSnapshot)?.batteryRightPodPercent
?: cached?.right?.percent ?: BATTERY_UNKNOWN
val batteryCase: Float? val batteryCase: Float
get() = aap?.batteryCase ?: (ble as? HasCase)?.batteryCasePercent ?: cached?.case?.percent get() = aap?.batteryCase ?: (ble as? HasCase)?.batteryCasePercent
?: cached?.case?.percent ?: BATTERY_UNKNOWN
val batteryHeadset: Float? val batteryHeadset: Float
get() = aap?.batteryHeadset ?: (ble as? SingleBlePodSnapshot)?.batteryHeadsetPercent ?: cached?.headset?.percent 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). */ /** True when at least one displayed battery value was filled from cache (not live). */
val isBatteryCached: Boolean val isBatteryCached: Boolean
@@ -76,7 +76,7 @@ class AapAutoConnect @Inject constructor(
log(TAG) { "AAP connecting to $address (${profile.label})" } log(TAG) { "AAP connecting to $address (${profile.label})" }
try { try {
aapManager.connect(address, bonded.internal, profile.model) aapManager.connect(address, bonded.internal!!, profile.model)
log(TAG) { "AAP connected to $address" } log(TAG) { "AAP connected to $address" }
} catch (e: Exception) { } catch (e: Exception) {
log(TAG, WARN) { "AAP initial connect failed for $address: ${e.message}" } log(TAG, WARN) { "AAP initial connect failed for $address: ${e.message}" }
@@ -99,7 +99,7 @@ class AapAutoConnect @Inject constructor(
try { try {
log(TAG) { "AAP initial retry ${attempt + 1} for $address after ${delayMs}ms" } 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}" } log(TAG) { "AAP connected to $address on retry ${attempt + 1}" }
break break
} catch (retryException: Exception) { } catch (retryException: Exception) {
@@ -159,7 +159,7 @@ class AapAutoConnect @Inject constructor(
try { try {
log(TAG) { "AAP reconnect attempt ${attempt + 1} for $address in ${delayMs}ms" } 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" } log(TAG) { "AAP reconnected to $address" }
break break
} catch (e: Exception) { } catch (e: Exception) {
@@ -211,7 +211,7 @@ class AapAutoConnect @Inject constructor(
val bonded = bluetoothManager.bondedDevices().first() val bonded = bluetoothManager.bondedDevices().first()
.firstOrNull { it.address == address } .firstOrNull { it.address == address }
if (bonded != null) { if (bonded != null) {
aapManager.connect(address, bonded.internal, detectedModel) aapManager.connect(address, bonded.internal!!, detectedModel)
log(TAG) { "AAP reconnected $address with corrected model $detectedModel" } log(TAG) { "AAP reconnected $address with corrected model $detectedModel" }
} }
} catch (e: Exception) { } catch (e: Exception) {
@@ -3,9 +3,11 @@ package eu.darken.capod.monitor.core.cache
import eu.darken.capod.common.SystemTimeSource import eu.darken.capod.common.SystemTimeSource
import eu.darken.capod.monitor.core.PodDevice import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.monitor.core.cache.CachedDeviceState.CachedBatterySlot 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.DualBlePodSnapshot
import eu.darken.capod.pods.core.apple.ble.SingleBlePodSnapshot 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.devices.HasCase
import eu.darken.capod.pods.core.apple.ble.isKnownBattery
import java.time.Duration import java.time.Duration
import java.time.Instant import java.time.Instant
@@ -24,15 +26,17 @@ fun PodDevice.toCachedState(
if (!isLive) return null if (!isLive) return null
val pid = profileId ?: return null val pid = profileId ?: return null
val liveLeft = aap?.batteryLeft ?: (ble as? DualBlePodSnapshot)?.batteryLeftPodPercent // RAW LIVE EXTRACTION — must NOT use device.batteryLeft etc. (which fall back to cache).
val liveRight = aap?.batteryRight ?: (ble as? DualBlePodSnapshot)?.batteryRightPodPercent // Reading the unified getter would re-stamp stale cached readings as fresh live data.
val liveCase = aap?.batteryCase ?: (ble as? HasCase)?.batteryCasePercent val liveLeft = aap?.batteryLeft ?: (ble as? DualBlePodSnapshot)?.batteryLeftPodPercent ?: BATTERY_UNKNOWN
val liveHeadset = aap?.batteryHeadset ?: (ble as? SingleBlePodSnapshot)?.batteryHeadsetPercent 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 val liveDeviceInfo = aap?.deviceInfo
if (liveLeft == null && liveRight == null && liveCase == null && liveHeadset == null && liveDeviceInfo == null) { if (!isKnownBattery(liveLeft) && !isKnownBattery(liveRight) &&
return null !isKnownBattery(liveCase) && !isKnownBattery(liveHeadset) && liveDeviceInfo == null
} ) return null
val newState = CachedDeviceState( val newState = CachedDeviceState(
profileId = pid, profileId = pid,
@@ -61,15 +65,14 @@ fun PodDevice.toCachedState(
} }
private fun mergeBatterySlot( private fun mergeBatterySlot(
livePercent: Float?, livePercent: Float,
existing: CachedBatterySlot?, existing: CachedBatterySlot?,
now: Instant, now: Instant,
): CachedBatterySlot? { ): CachedBatterySlot? {
val live: Float = livePercent ?: return existing if (!isKnownBattery(livePercent)) return existing
val current: CachedBatterySlot = existing ?: return CachedBatterySlot(live, now) val current = existing ?: return CachedBatterySlot(livePercent, now)
val isStale = Duration.between(current.updatedAt, now).abs() > Duration.ofMinutes(1) 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 { 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.PodModel
import eu.darken.capod.pods.core.apple.ble.formatBatteryPercent 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.getBatteryDrawable
import eu.darken.capod.pods.core.apple.ble.isKnownBattery
import javax.inject.Inject import javax.inject.Inject
import kotlin.math.roundToInt import kotlin.math.roundToInt
@@ -150,9 +151,7 @@ class MonitorNotificationViewFactory @Inject constructor(
setTextViewText(R.id.device, device.getLabel(context)) setTextViewText(R.id.device, device.getLabel(context))
} }
private fun percentToInt(percent: Float?): Int { private fun percentToInt(percent: Float): Int =
if (percent == null) return 0 if (isKnownBattery(percent)) (percent * 100).roundToInt().coerceIn(0, 100) else 0
return (percent * 100).roundToInt().coerceIn(0, 100)
}
} }
@@ -21,17 +21,18 @@ import kotlin.math.roundToInt
const val BATTERY_UNKNOWN = -1f 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 = fun formatBatteryPercent(context: Context, percent: Float): String =
percent?.let { "${(it * 100).roundToInt()}%" } if (isKnownBattery(percent)) "${(percent * 100).roundToInt()}%"
?: context.getString(R.string.general_value_not_available_label) else context.getString(R.string.general_value_not_available_label)
@DrawableRes @DrawableRes
fun getBatteryDrawable(percent: Float?): Int = when { fun getBatteryDrawable(percent: Float): Int = when {
percent == null -> R.drawable.ic_baseline_battery_unknown_24 !isKnownBattery(percent) -> R.drawable.ic_baseline_battery_unknown_24
percent > 0.95f -> R.drawable.ic_baseline_battery_full_24 percent > 0.95f -> R.drawable.ic_baseline_battery_full_24
percent > 0.80f -> R.drawable.ic_baseline_battery_6_bar_24 percent > 0.80f -> R.drawable.ic_baseline_battery_6_bar_24
percent > 0.65f -> R.drawable.ic_baseline_battery_5_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 else -> R.drawable.ic_baseline_battery_0_bar_24
} }
fun getBatteryIcon(percent: Float?): ImageVector = when { fun getBatteryIcon(percent: Float): ImageVector = when {
percent == null -> Icons.AutoMirrored.TwoTone.BatteryUnknown !isKnownBattery(percent) -> Icons.AutoMirrored.TwoTone.BatteryUnknown
percent > 0.95f -> Icons.TwoTone.BatteryFull percent > 0.95f -> Icons.TwoTone.BatteryFull
percent > 0.80f -> Icons.TwoTone.Battery6Bar percent > 0.80f -> Icons.TwoTone.Battery6Bar
percent > 0.65f -> Icons.TwoTone.Battery5Bar 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.PodModel
import eu.darken.capod.pods.core.apple.ble.formatBatteryPercent 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.getBatteryIcon
import eu.darken.capod.pods.core.apple.ble.toBatteryFloat
import eu.darken.capod.pods.core.apple.ble.toBatteryOrNull
@Composable @Composable
fun PopUpContent( fun PopUpContent(
@@ -115,7 +113,7 @@ private fun DualPodContent(device: PodDevice) {
// Left pod // Left pod
BatteryColumn( BatteryColumn(
iconRes = device.leftPodIcon, iconRes = device.leftPodIcon,
batteryPercent = device.batteryLeft.toBatteryFloat(), batteryPercent = device.batteryLeft,
isCharging = device.isLeftPodCharging ?: false, isCharging = device.isLeftPodCharging ?: false,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
@@ -124,7 +122,7 @@ private fun DualPodContent(device: PodDevice) {
if (device.hasCase) { if (device.hasCase) {
BatteryColumn( BatteryColumn(
iconRes = device.caseIcon, iconRes = device.caseIcon,
batteryPercent = device.batteryCase.toBatteryFloat(), batteryPercent = device.batteryCase,
isCharging = device.isCaseCharging ?: false, isCharging = device.isCaseCharging ?: false,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
@@ -133,7 +131,7 @@ private fun DualPodContent(device: PodDevice) {
// Right pod // Right pod
BatteryColumn( BatteryColumn(
iconRes = device.rightPodIcon, iconRes = device.rightPodIcon,
batteryPercent = device.batteryRight.toBatteryFloat(), batteryPercent = device.batteryRight,
isCharging = device.isRightPodCharging ?: false, isCharging = device.isRightPodCharging ?: false,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
@@ -144,7 +142,7 @@ private fun DualPodContent(device: PodDevice) {
private fun SinglePodContent(device: PodDevice) { private fun SinglePodContent(device: PodDevice) {
BatteryColumn( BatteryColumn(
iconRes = device.iconRes, iconRes = device.iconRes,
batteryPercent = device.batteryHeadset.toBatteryFloat(), batteryPercent = device.batteryHeadset,
isCharging = device.isHeadsetBeingCharged ?: false, isCharging = device.isHeadsetBeingCharged ?: false,
) )
} }
@@ -157,7 +155,6 @@ private fun BatteryColumn(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val nullablePercent = batteryPercent.toBatteryOrNull()
Column( Column(
modifier = modifier, modifier = modifier,
@@ -184,14 +181,14 @@ private fun BatteryColumn(
) )
} else { } else {
Icon( Icon(
imageVector = getBatteryIcon(nullablePercent), imageVector = getBatteryIcon(batteryPercent),
contentDescription = null, contentDescription = null,
modifier = Modifier.size(16.dp), modifier = Modifier.size(16.dp),
) )
} }
Spacer(modifier = Modifier.width(2.dp)) Spacer(modifier = Modifier.width(2.dp))
Text( Text(
text = formatBatteryPercent(context, nullablePercent), text = formatBatteryPercent(context, batteryPercent),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, 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_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_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_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="anc_widget_aap_connecting_label">Connecting…</string>
<string name="tile_anc_label">Noise Control</string> <string name="tile_anc_label">Noise Control</string>
<string name="tile_anc_subtitle_permission_required">Permission required</string> <string name="tile_anc_subtitle_permission_required">Permission required</string>
@@ -12,7 +12,7 @@ fun DashboardLight() = DashboardContent()
@PreviewTest @PreviewTest
@PlayStoreLocalesDark @PlayStoreLocalesDark
@Composable @Composable
fun DashboardDark() = DashboardContent() fun DashboardDark() = DashboardContent(showAap = true)
@PreviewTest @PreviewTest
@PlayStoreLocales @PlayStoreLocales
@@ -3,6 +3,7 @@ package eu.darken.capod.monitor.core
import eu.darken.capod.monitor.core.cache.CachedDeviceState import eu.darken.capod.monitor.core.cache.CachedDeviceState
import eu.darken.capod.pods.core.apple.PodModel 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.AapPodState
import eu.darken.capod.pods.core.apple.ble.BATTERY_UNKNOWN
import eu.darken.capod.pods.core.apple.ble.devices.DualApplePods import eu.darken.capod.pods.core.apple.ble.devices.DualApplePods
import io.kotest.matchers.nulls.shouldBeNull import io.kotest.matchers.nulls.shouldBeNull
import io.kotest.matchers.nulls.shouldNotBeNull import io.kotest.matchers.nulls.shouldNotBeNull
@@ -57,7 +58,7 @@ class PodDeviceCacheTest : BaseTest() {
device.batteryLeft shouldBe 0.8f device.batteryLeft shouldBe 0.8f
device.batteryRight shouldBe 0.7f device.batteryRight shouldBe 0.7f
device.batteryCase shouldBe 0.5f device.batteryCase shouldBe 0.5f
device.batteryHeadset.shouldBeNull() device.batteryHeadset shouldBe BATTERY_UNKNOWN
} }
@Test @Test
@@ -252,6 +252,37 @@ class ToCachedStateTest : BaseTest() {
result.rightEarbudSerial shouldBe "R-1" result.rightEarbudSerial shouldBe "R-1"
result.marketingVersion shouldBe "9999" 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( private fun deviceInfo(
+14
View File
@@ -62,6 +62,20 @@ platform :android do
) )
end 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| after_all do |lane|
# This block is called, only if the executed lane was successful # This block is called, only if the executed lane was successful
+23 -25
View File
@@ -3,13 +3,13 @@ GEM
specs: specs:
CFPropertyList (3.0.8) CFPropertyList (3.0.8)
abbrev (0.1.2) abbrev (0.1.2)
addressable (2.8.8) addressable (2.9.0)
public_suffix (>= 2.0.2, < 8.0) public_suffix (>= 2.0.2, < 8.0)
artifactory (3.0.17) artifactory (3.0.17)
atomos (0.1.3) atomos (0.1.3)
aws-eventstream (1.4.0) aws-eventstream (1.4.0)
aws-partitions (1.1216.0) aws-partitions (1.1244.0)
aws-sdk-core (3.242.0) aws-sdk-core (3.246.0)
aws-eventstream (~> 1, >= 1.3.0) aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0) aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9) aws-sigv4 (~> 1.9)
@@ -17,11 +17,11 @@ GEM
bigdecimal bigdecimal
jmespath (~> 1, >= 1.6.1) jmespath (~> 1, >= 1.6.1)
logger logger
aws-sdk-kms (1.122.0) aws-sdk-kms (1.124.0)
aws-sdk-core (~> 3, >= 3.241.4) aws-sdk-core (~> 3, >= 3.244.0)
aws-sigv4 (~> 1.5) aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.213.0) aws-sdk-s3 (1.220.0)
aws-sdk-core (~> 3, >= 3.241.4) aws-sdk-core (~> 3, >= 3.244.0)
aws-sdk-kms (~> 1) aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5) aws-sigv4 (~> 1.5)
aws-sigv4 (1.12.1) aws-sigv4 (1.12.1)
@@ -29,7 +29,7 @@ GEM
babosa (1.0.4) babosa (1.0.4)
base64 (0.2.0) base64 (0.2.0)
benchmark (0.5.0) benchmark (0.5.0)
bigdecimal (4.0.1) bigdecimal (4.1.2)
claide (1.1.0) claide (1.1.0)
colored (1.2) colored (1.2)
colored2 (3.1.2) colored2 (3.1.2)
@@ -68,11 +68,11 @@ GEM
faraday-net_http_persistent (1.2.0) faraday-net_http_persistent (1.2.0)
faraday-patron (1.0.0) faraday-patron (1.0.0)
faraday-rack (1.0.0) faraday-rack (1.0.0)
faraday-retry (1.0.3) faraday-retry (1.0.4)
faraday_middleware (1.2.1) faraday_middleware (1.2.1)
faraday (~> 1.0) faraday (~> 1.0)
fastimage (2.4.0) fastimage (2.4.1)
fastlane (2.232.1) fastlane (2.233.1)
CFPropertyList (>= 2.3, < 4.0.0) CFPropertyList (>= 2.3, < 4.0.0)
abbrev (~> 0.1.2) abbrev (~> 0.1.2)
addressable (>= 2.8, < 3.0.0) addressable (>= 2.8, < 3.0.0)
@@ -92,7 +92,7 @@ GEM
faraday-cookie_jar (~> 0.0.6) faraday-cookie_jar (~> 0.0.6)
faraday_middleware (~> 1.0) faraday_middleware (~> 1.0)
fastimage (>= 2.1.0, < 3.0.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) gh_inspector (>= 1.1.2, < 2.0.0)
google-apis-androidpublisher_v3 (~> 0.3) google-apis-androidpublisher_v3 (~> 0.3)
google-apis-playcustomapp_v1 (~> 0.1) google-apis-playcustomapp_v1 (~> 0.1)
@@ -122,10 +122,9 @@ GEM
xcodeproj (>= 1.13.0, < 2.0.0) xcodeproj (>= 1.13.0, < 2.0.0)
xcpretty (~> 0.4.1) xcpretty (~> 0.4.1)
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0) xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
fastlane-sirp (1.0.0) fastlane-sirp (1.1.0)
sysrandom (~> 1.0)
gh_inspector (1.1.3) 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.15.0, < 2.a)
google-apis-core (0.18.0) google-apis-core (0.18.0)
addressable (~> 2.5, >= 2.5.1) addressable (~> 2.5, >= 2.5.1)
@@ -135,19 +134,19 @@ GEM
mutex_m mutex_m
representable (~> 3.0) representable (~> 3.0)
retriable (>= 2.0, < 4.a) 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-core (>= 0.15.0, < 2.a)
google-apis-playcustomapp_v1 (0.17.0) google-apis-playcustomapp_v1 (0.17.0)
google-apis-core (>= 0.15.0, < 2.a) 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-apis-core (>= 0.15.0, < 2.a)
google-cloud-core (1.8.0) google-cloud-core (1.8.0)
google-cloud-env (>= 1.0, < 3.a) google-cloud-env (>= 1.0, < 3.a)
google-cloud-errors (~> 1.0) google-cloud-errors (~> 1.0)
google-cloud-env (2.1.1) google-cloud-env (2.1.1)
faraday (>= 1.0, < 3.a) faraday (>= 1.0, < 3.a)
google-cloud-errors (1.5.0) google-cloud-errors (1.6.0)
google-cloud-storage (1.58.0) google-cloud-storage (1.59.0)
addressable (~> 2.8) addressable (~> 2.8)
digest-crc (~> 0.4) digest-crc (~> 0.4)
google-apis-core (>= 0.18, < 2) google-apis-core (>= 0.18, < 2)
@@ -169,13 +168,13 @@ GEM
httpclient (2.9.0) httpclient (2.9.0)
mutex_m mutex_m
jmespath (1.6.2) jmespath (1.6.2)
json (2.18.1) json (2.19.5)
jwt (2.10.2) jwt (2.10.2)
base64 base64
logger (1.7.0) logger (1.7.0)
mini_magick (4.13.2) mini_magick (4.13.2)
mini_mime (1.1.5) mini_mime (1.1.5)
multi_json (1.19.1) multi_json (1.21.0)
multipart-post (2.4.1) multipart-post (2.4.1)
mutex_m (0.3.0) mutex_m (0.3.0)
nanaimo (0.4.0) nanaimo (0.4.0)
@@ -185,13 +184,13 @@ GEM
os (1.1.4) os (1.1.4)
ostruct (0.6.3) ostruct (0.6.3)
plist (3.7.2) plist (3.7.2)
public_suffix (7.0.2) public_suffix (7.0.5)
rake (13.3.1) rake (13.4.2)
representable (3.2.0) representable (3.2.0)
declarative (< 0.1.0) declarative (< 0.1.0)
trailblazer-option (>= 0.1.1, < 0.2.0) trailblazer-option (>= 0.1.1, < 0.2.0)
uber (< 0.2.0) uber (< 0.2.0)
retriable (3.2.0) retriable (3.4.1)
rexml (3.4.4) rexml (3.4.4)
rouge (3.28.0) rouge (3.28.0)
ruby2_keywords (0.0.5) ruby2_keywords (0.0.5)
@@ -205,7 +204,6 @@ GEM
simctl (1.6.10) simctl (1.6.10)
CFPropertyList CFPropertyList
naturally naturally
sysrandom (1.0.5)
terminal-notifier (2.0.0) terminal-notifier (2.0.0)
terminal-table (3.0.2) terminal-table (3.0.2)
unicode-display_width (>= 1.1.1, < 3) 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