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.
This commit is contained in:
Matthias Urhahn
2026-05-04 21:30:25 +02:00
committed by Matthias Urhahn
parent ac0c4693dc
commit 8a81d354e6
25 changed files with 64 additions and 27 deletions
+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,
@@ -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) {
@@ -12,7 +12,7 @@ fun DashboardLight() = DashboardContent()
@PreviewTest
@PlayStoreLocalesDark
@Composable
fun DashboardDark() = DashboardContent()
fun DashboardDark() = DashboardContent(showAap = true)
@PreviewTest
@PlayStoreLocales
Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 127 KiB

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 125 KiB

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 127 KiB

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 147 KiB

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 127 KiB

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 129 KiB

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 178 KiB

After

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 127 KiB

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 127 KiB

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 150 KiB