From ac21261d4da9d1204e59ee0a72a7984ca3b13418 Mon Sep 17 00:00:00 2001 From: darken Date: Mon, 30 Mar 2026 12:34:35 +0200 Subject: [PATCH] refactor: Migrate all consumers from PodDevice to MonitoredDevice Replace direct PodDevice/PodMonitor usage with MonitoredDevice/DeviceMonitor across ViewModels, UI screens, notifications, widgets, reactions, and service. All interface cast-based property access replaced with flat MonitoredDevice properties. Make L2capSocketFactory injectable. --- .../capod/debug/l2cap/L2capPocActivity.kt | 2 +- .../capod/screenshots/ScreenshotContent.kt | 8 +- app/src/main/java/eu/darken/capod/App.kt | 6 +- .../bluetooth/l2cap/L2capSocketFactory.kt | 9 +- .../compose/preview/MockPodDataProvider.kt | 23 +++ .../capod/common/dagger/AndroidModule.kt | 1 + .../capod/main/ui/overview/OverviewScreen.kt | 17 ++- .../main/ui/overview/OverviewViewModel.kt | 14 +- .../main/ui/overview/cards/DualPodsCard.kt | 132 +++++++----------- .../main/ui/overview/cards/SinglePodsCard.kt | 45 +++--- .../ui/overview/cards/UnknownPodDeviceCard.kt | 19 +-- .../main/ui/widget/BatteryGlanceWidget.kt | 16 +-- .../main/ui/widget/WidgetRenderStateMapper.kt | 45 +++--- .../capod/monitor/core/DeviceMonitor.kt | 14 ++ .../monitor/core/DeviceMonitorExtensions.kt | 49 +++++++ .../capod/monitor/core/MonitoredDevice.kt | 60 +++++++- .../monitor/core/worker/MonitorService.kt | 4 +- .../ui/MonitorNotificationViewFactory.kt | 105 +++++++------- .../capod/monitor/ui/MonitorNotifications.kt | 69 ++++----- .../reaction/core/autoconnect/AutoConnect.kt | 20 ++- .../reaction/core/playpause/PlayPause.kt | 39 +++--- .../reaction/core/popup/PopUpReaction.kt | 29 ++-- .../capod/reaction/ui/popup/PopUpContent.kt | 46 +++--- .../capod/reaction/ui/popup/PopUpWindow.kt | 8 +- .../ui/TroubleShooterViewModel.kt | 8 +- .../main/ui/overview/OverviewViewModelTest.kt | 33 +++-- .../capod/monitor/core/MonitoredDeviceTest.kt | 104 ++++++++++++++ .../core/autoconnect/AutoConnectLogicTest.kt | 2 +- .../core/playpause/PlayPauseLogicTest.kt | 2 +- .../core/popup/PopUpReactionLogicTest.kt | 2 +- 30 files changed, 563 insertions(+), 368 deletions(-) create mode 100644 app/src/main/java/eu/darken/capod/monitor/core/DeviceMonitorExtensions.kt diff --git a/app/src/debug/java/eu/darken/capod/debug/l2cap/L2capPocActivity.kt b/app/src/debug/java/eu/darken/capod/debug/l2cap/L2capPocActivity.kt index 9c2094b1..e98fce3d 100644 --- a/app/src/debug/java/eu/darken/capod/debug/l2cap/L2capPocActivity.kt +++ b/app/src/debug/java/eu/darken/capod/debug/l2cap/L2capPocActivity.kt @@ -187,7 +187,7 @@ class L2capPocActivity : ComponentActivity() { log("=== Connecting to ${dev.address} ===") scope.launch(Dispatchers.IO) { try { - val sock = L2capSocketFactory.createSocket(dev, PSM) + val sock = L2capSocketFactory().createSocket(dev, PSM) withContext(Dispatchers.Main) { log("Socket created, connecting...") } sock.connect() withContext(Dispatchers.Main) { diff --git a/app/src/debug/java/eu/darken/capod/screenshots/ScreenshotContent.kt b/app/src/debug/java/eu/darken/capod/screenshots/ScreenshotContent.kt index fa441129..2ad88e15 100644 --- a/app/src/debug/java/eu/darken/capod/screenshots/ScreenshotContent.kt +++ b/app/src/debug/java/eu/darken/capod/screenshots/ScreenshotContent.kt @@ -47,9 +47,9 @@ internal fun DashboardContent() = PreviewWrapper { now = MOCK_NOW, permissions = emptySet(), devices = listOf( - MockPodDataProvider.airPodsProMixed(), - MockPodDataProvider.airPodsMax(), - MockPodDataProvider.unknownDevice(), + MockPodDataProvider.dualPodMonitoredMixed(), + MockPodDataProvider.singlePodMonitored(), + MockPodDataProvider.unknownMonitored(), ), isDebugMode = false, isBluetoothEnabled = true, @@ -201,7 +201,7 @@ internal fun CasePopUpContent() { contentAlignment = Alignment.Center, ) { PopUpCard( - device = MockPodDataProvider.airPodsProMixed(), + device = MockPodDataProvider.dualPodMonitoredMixed(), onClose = {}, modifier = Modifier.padding(horizontal = 32.dp), ) diff --git a/app/src/main/java/eu/darken/capod/App.kt b/app/src/main/java/eu/darken/capod/App.kt index 5f96407e..92caef01 100644 --- a/app/src/main/java/eu/darken/capod/App.kt +++ b/app/src/main/java/eu/darken/capod/App.kt @@ -12,7 +12,7 @@ import eu.darken.capod.common.debug.logging.logTag import eu.darken.capod.common.flow.throttleLatest import eu.darken.capod.common.upgrade.UpgradeRepo import eu.darken.capod.main.ui.widget.WidgetManager -import eu.darken.capod.monitor.core.PodMonitor +import eu.darken.capod.monitor.core.DeviceMonitor import eu.darken.capod.monitor.core.devicesWithProfiles import kotlinx.coroutines.CoroutineScope @@ -27,7 +27,7 @@ import javax.inject.Inject open class App : Application() { @Inject lateinit var autoReporting: AutomaticBugReporter - @Inject lateinit var podMonitor: PodMonitor + @Inject lateinit var deviceMonitor: DeviceMonitor @Inject lateinit var widgetManager: WidgetManager @Inject lateinit var upgradeRepo: UpgradeRepo @Inject @AppScope lateinit var appScope: CoroutineScope @@ -42,7 +42,7 @@ open class App : Application() { appScope.launch { widgetManager.refreshWidgets() } - podMonitor.devicesWithProfiles() + deviceMonitor.devicesWithProfiles() .distinctUntilChanged() .throttleLatest(1000) .onEach { diff --git a/app/src/main/java/eu/darken/capod/common/bluetooth/l2cap/L2capSocketFactory.kt b/app/src/main/java/eu/darken/capod/common/bluetooth/l2cap/L2capSocketFactory.kt index 0209db55..b43d775c 100644 --- a/app/src/main/java/eu/darken/capod/common/bluetooth/l2cap/L2capSocketFactory.kt +++ b/app/src/main/java/eu/darken/capod/common/bluetooth/l2cap/L2capSocketFactory.kt @@ -5,6 +5,8 @@ import android.bluetooth.BluetoothDevice import android.bluetooth.BluetoothSocket import android.util.Log import java.io.IOException +import javax.inject.Inject +import javax.inject.Singleton /** * Creates BR/EDR L2CAP sockets for connecting to devices like AirPods. @@ -12,11 +14,12 @@ import java.io.IOException * Tries the public [android.bluetooth.BluetoothSocketSettings] API first (API 37+). * Falls back to the hidden `createInsecureL2capSocket` method via [HiddenApiBypass]. */ +@Singleton @SuppressLint("MissingPermission") -object L2capSocketFactory { +class L2capSocketFactory @Inject constructor() { - private const val TAG = "L2capSocketFactory" - private const val TYPE_L2CAP = 3 + private val TAG = "L2capSocketFactory" + private val TYPE_L2CAP = 3 /** * Creates an insecure BR/EDR L2CAP socket for the given [device] and [psm]. diff --git a/app/src/main/java/eu/darken/capod/common/compose/preview/MockPodDataProvider.kt b/app/src/main/java/eu/darken/capod/common/compose/preview/MockPodDataProvider.kt index 6c209675..4043db3f 100644 --- a/app/src/main/java/eu/darken/capod/common/compose/preview/MockPodDataProvider.kt +++ b/app/src/main/java/eu/darken/capod/common/compose/preview/MockPodDataProvider.kt @@ -4,6 +4,7 @@ import android.content.Context import eu.darken.capod.R import eu.darken.capod.common.bluetooth.BleScanResult import eu.darken.capod.common.upgrade.UpgradeRepo +import eu.darken.capod.monitor.core.MonitoredDevice import eu.darken.capod.pods.core.DualPodDevice import eu.darken.capod.pods.core.HasCase import eu.darken.capod.pods.core.HasChargeDetection @@ -144,6 +145,28 @@ object MockPodDataProvider { model = model, ) + // --- MonitoredDevice wrappers --- + + fun dualPodMonitored(): MonitoredDevice = MonitoredDevice( + ble = airPodsProFullCharge(), + aap = null, + ) + + fun dualPodMonitoredMixed(): MonitoredDevice = MonitoredDevice( + ble = airPodsProMixed(), + aap = null, + ) + + fun singlePodMonitored(): MonitoredDevice = MonitoredDevice( + ble = airPodsMax(), + aap = null, + ) + + fun unknownMonitored(): MonitoredDevice = MonitoredDevice( + ble = unknownDevice(), + aap = null, + ) + // --- UpgradeInfo --- fun fossInfo(isPro: Boolean = false): UpgradeRepo.Info = MockUpgradeInfo( diff --git a/app/src/main/java/eu/darken/capod/common/dagger/AndroidModule.kt b/app/src/main/java/eu/darken/capod/common/dagger/AndroidModule.kt index 32ddfdfd..1cf8de1c 100644 --- a/app/src/main/java/eu/darken/capod/common/dagger/AndroidModule.kt +++ b/app/src/main/java/eu/darken/capod/common/dagger/AndroidModule.kt @@ -33,4 +33,5 @@ class AndroidModule { @Singleton fun audioManager(context: Context): AudioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager + } diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewScreen.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewScreen.kt index ee73eb57..f3ffe811 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewScreen.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewScreen.kt @@ -54,9 +54,8 @@ import eu.darken.capod.main.ui.overview.cards.PermissionCard import eu.darken.capod.main.ui.overview.cards.SinglePodsCard import eu.darken.capod.main.ui.overview.cards.UnknownPodDeviceCard import eu.darken.capod.main.ui.overview.cards.UnmatchedDevicesCard -import eu.darken.capod.pods.core.DualPodDevice +import eu.darken.capod.monitor.core.MonitoredDevice import eu.darken.capod.pods.core.PodDevice -import eu.darken.capod.pods.core.SinglePodDevice import java.time.Instant @Composable @@ -259,10 +258,10 @@ fun OverviewScreen( } @Composable -private fun PodDeviceCard(device: PodDevice, showDebug: Boolean, now: Instant) { - when (device) { - is DualPodDevice -> DualPodsCard(device = device, showDebug = showDebug, now = now) - is SinglePodDevice -> SinglePodsCard(device = device, showDebug = showDebug, now = now) +private fun PodDeviceCard(device: MonitoredDevice, showDebug: Boolean, now: Instant) { + when { + device.hasDualPods -> DualPodsCard(device = device, showDebug = showDebug, now = now) + device.model != PodDevice.Model.UNKNOWN -> SinglePodsCard(device = device, showDebug = showDebug, now = now) else -> UnknownPodDeviceCard(device = device, showDebug = showDebug, now = now) } } @@ -275,9 +274,9 @@ private fun OverviewScreenWithDevicesPreview() = PreviewWrapper { now = Instant.now(), permissions = emptySet(), devices = listOf( - MockPodDataProvider.airPodsProMixed(), - MockPodDataProvider.airPodsMax(), - MockPodDataProvider.unknownDevice(), + MockPodDataProvider.dualPodMonitoredMixed(), + MockPodDataProvider.singlePodMonitored(), + MockPodDataProvider.unknownMonitored(), ), isDebugMode = false, isBluetoothEnabled = true, diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewViewModel.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewViewModel.kt index 1c4f91c8..67a4dd8e 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewViewModel.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewViewModel.kt @@ -17,9 +17,9 @@ import eu.darken.capod.common.upgrade.UpgradeRepo import eu.darken.capod.main.core.GeneralSettings import eu.darken.capod.main.core.MonitorMode import eu.darken.capod.main.core.PermissionTool -import eu.darken.capod.monitor.core.PodMonitor +import eu.darken.capod.monitor.core.DeviceMonitor +import eu.darken.capod.monitor.core.MonitoredDevice import eu.darken.capod.monitor.core.worker.MonitorControl -import eu.darken.capod.pods.core.PodDevice import eu.darken.capod.profiles.core.DeviceProfile import eu.darken.capod.profiles.core.DeviceProfilesRepo import java.time.Instant @@ -40,7 +40,7 @@ import eu.darken.capod.common.datastore.valueBlocking class OverviewViewModel @Inject constructor( dispatcherProvider: DispatcherProvider, private val monitorControl: MonitorControl, - private val podMonitor: PodMonitor, + private val deviceMonitor: DeviceMonitor, private val permissionTool: PermissionTool, private val generalSettings: GeneralSettings, debugSettings: DebugSettings, @@ -92,7 +92,7 @@ class OverviewViewModel @Inject constructor( if (permissions.isNotEmpty()) { return@flatMapLatest flowOf(emptyList()) } - podMonitor.devices + deviceMonitor.devices } .catch { errorEvents.emitBlocking(it) } .throttleLatest(1000) @@ -122,7 +122,7 @@ class OverviewViewModel @Inject constructor( data class State( val now: Instant, val permissions: Set, - val devices: List, + val devices: List, val isDebugMode: Boolean, val isBluetoothEnabled: Boolean, val profiles: List, @@ -130,8 +130,8 @@ class OverviewViewModel @Inject constructor( val showUnmatchedDevices: Boolean, ) { val isScanBlocked: Boolean get() = permissions.any { it.isScanBlocking } - val profiledDevices: List get() = devices.filter { it.meta.profile != null } - val unmatchedDevices: List get() = devices.filter { it.meta.profile == null } + val profiledDevices: List get() = devices.filter { it.meta?.profile != null } + val unmatchedDevices: List get() = devices.filter { it.meta?.profile == null } } fun onPermissionResult(@Suppress("UNUSED_PARAMETER") granted: Boolean) { diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/DualPodsCard.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/DualPodsCard.kt index eb55a42a..abe82d22 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/DualPodsCard.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/DualPodsCard.kt @@ -44,28 +44,24 @@ import eu.darken.capod.R import eu.darken.capod.common.compose.Preview2 import eu.darken.capod.common.compose.PreviewWrapper import eu.darken.capod.common.compose.preview.MockPodDataProvider -import eu.darken.capod.pods.core.DualPodDevice -import eu.darken.capod.pods.core.HasCase -import eu.darken.capod.pods.core.HasChargeDetectionDual -import eu.darken.capod.pods.core.HasDualMicrophone -import eu.darken.capod.pods.core.HasEarDetectionDual +import eu.darken.capod.monitor.core.MonitoredDevice +import eu.darken.capod.monitor.core.getSignalQuality +import eu.darken.capod.monitor.core.lastSeenFormatted +import eu.darken.capod.monitor.core.firstSeenFormatted import eu.darken.capod.pods.core.HasPodStyle import eu.darken.capod.pods.core.HasStateDetection import eu.darken.capod.pods.core.apple.ApplePods import eu.darken.capod.pods.core.apple.DualApplePods import eu.darken.capod.pods.core.apple.DualApplePods.LidState -import eu.darken.capod.pods.core.firstSeenFormatted import eu.darken.capod.pods.core.formatBatteryPercent import eu.darken.capod.pods.core.toBatteryFloat import eu.darken.capod.pods.core.toBatteryOrNull -import eu.darken.capod.pods.core.getSignalQuality -import eu.darken.capod.pods.core.lastSeenFormatted import java.time.Duration import java.time.Instant @Composable fun DualPodsCard( - device: DualPodDevice, + device: MonitoredDevice, showDebug: Boolean, now: Instant, ) { @@ -97,15 +93,16 @@ fun DualPodsCard( Column(modifier = Modifier.weight(1f)) { Row(verticalAlignment = Alignment.CenterVertically) { Text( - text = device.meta.profile?.label ?: "?", + text = device.meta?.profile?.label ?: "?", style = MaterialTheme.typography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis, ) - if (device is ApplePods && device.meta.isIRKMatch) { + val applePod = device.ble as? ApplePods + if (applePod != null && applePod.meta.isIRKMatch) { Spacer(modifier = Modifier.width(6.dp)) Icon( - imageVector = if (device.payload.private != null) Icons.TwoTone.Key else Icons.Outlined.Key, + imageVector = if (applePod.payload.private != null) Icons.TwoTone.Key else Icons.Outlined.Key, contentDescription = null, modifier = Modifier.size(14.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant, @@ -114,11 +111,13 @@ fun DualPodsCard( } val deviceLabel = buildString { append(device.getLabel(context)) - if (device is HasPodStyle && showDebug) { - append(" (${device.podStyle.getColor(context)})") + val podStyle = device.ble as? HasPodStyle + if (podStyle != null && showDebug) { + append(" (${podStyle.podStyle.getColor(context)})") } - if (device is DualApplePods && showDebug) { - append(" [${device.primaryPod.name}]") + val dualApple = device.ble as? DualApplePods + if (dualApple != null && showDebug) { + append(" [${dualApple.primaryPod.name}]") } } Text( @@ -141,7 +140,9 @@ fun DualPodsCard( style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) - if (Duration.between(device.seenFirstAt, device.seenLastAt).toMinutes() >= 1) { + val seenFirst = device.seenFirstAt + val seenLast = device.seenLastAt + if (seenFirst != null && seenLast != null && Duration.between(seenFirst, seenLast).toMinutes() >= 1) { Text( text = stringResource(R.string.first_seen_x, device.firstSeenFormatted(now)), style = MaterialTheme.typography.bodySmall, @@ -165,30 +166,30 @@ fun DualPodsCard( horizontalArrangement = Arrangement.SpaceEvenly, ) { PodGauge( - iconRes = device.leftPodIcon, - batteryPercent = device.batteryLeftPodPercent.toBatteryFloat(), - isCharging = (device as? HasChargeDetectionDual)?.isLeftPodCharging ?: false, - isInEar = (device as? HasEarDetectionDual)?.isLeftPodInEar ?: false, - showEarDetection = device is HasEarDetectionDual, - isMicrophone = (device as? HasDualMicrophone)?.isLeftPodMicrophone ?: false, - showMicrophone = device is HasDualMicrophone, + iconRes = device.leftPodIcon ?: R.drawable.device_airpods_gen1_left, + batteryPercent = device.batteryLeft.toBatteryFloat(), + isCharging = device.isLeftPodCharging ?: false, + isInEar = device.isLeftInEar ?: false, + showEarDetection = device.hasEarDetection && device.hasDualPods, + isMicrophone = device.isLeftPodMicrophone ?: false, + showMicrophone = device.hasDualMicrophone, modifier = Modifier.weight(1f), ) PodGauge( - iconRes = device.rightPodIcon, - batteryPercent = device.batteryRightPodPercent.toBatteryFloat(), - isCharging = (device as? HasChargeDetectionDual)?.isRightPodCharging ?: false, - isInEar = (device as? HasEarDetectionDual)?.isRightPodInEar ?: false, - showEarDetection = device is HasEarDetectionDual, - isMicrophone = (device as? HasDualMicrophone)?.isRightPodMicrophone ?: false, - showMicrophone = device is HasDualMicrophone, + iconRes = device.rightPodIcon ?: R.drawable.device_airpods_gen1_right, + batteryPercent = device.batteryRight.toBatteryFloat(), + isCharging = device.isRightPodCharging ?: false, + isInEar = device.isRightInEar ?: false, + showEarDetection = device.hasEarDetection && device.hasDualPods, + isMicrophone = device.isRightPodMicrophone ?: false, + showMicrophone = device.hasDualMicrophone, modifier = Modifier.weight(1f), ) } // Case row - if (device is HasCase) { + if (device.hasCase) { HorizontalDivider( modifier = Modifier.padding(vertical = 12.dp), color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f), @@ -200,10 +201,11 @@ fun DualPodsCard( } // Connection state - if (device is HasStateDetection) { + val stateDetection = device.ble as? HasStateDetection + if (stateDetection != null) { Spacer(modifier = Modifier.height(8.dp)) Text( - text = device.state.getLabel(context), + text = stateDetection.state.getLabel(context), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -314,7 +316,7 @@ private fun PodGauge( @OptIn(ExperimentalLayoutApi::class) @Composable private fun CaseRow( - device: HasCase, + device: MonitoredDevice, ) { val context = LocalContext.current @@ -323,7 +325,7 @@ private fun CaseRow( modifier = Modifier.fillMaxWidth(), ) { Image( - painter = painterResource(device.caseIcon), + painter = painterResource(device.caseIcon ?: R.drawable.device_airpods_gen1_case), contentDescription = null, modifier = Modifier.size(28.dp), ) @@ -331,13 +333,13 @@ private fun CaseRow( Spacer(modifier = Modifier.width(8.dp)) Text( - text = formatBatteryPercent(context, device.batteryCasePercent), + text = formatBatteryPercent(context, device.batteryCase), style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(end = 8.dp), ) BatteryCapsule( - percent = device.batteryCasePercent.toBatteryFloat(), + percent = device.batteryCase.toBatteryFloat(), modifier = Modifier .weight(1f) .height(8.dp), @@ -349,25 +351,23 @@ private fun CaseRow( horizontalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp), ) { - if (device.isCaseCharging) { + if (device.isCaseCharging == true) { StatusChip( icon = Icons.TwoTone.BatteryChargingFull, label = stringResource(R.string.pods_charging_label), ) } - if (device is DualApplePods) { - val lidState = device.caseLidState - if (lidState == LidState.OPEN || lidState == LidState.CLOSED) { - StatusChip( - icon = Icons.TwoTone.GridView, - label = when (lidState) { - LidState.OPEN -> stringResource(R.string.pods_case_status_open_label) - LidState.CLOSED -> stringResource(R.string.pods_case_status_closed_label) - else -> "" - }, - ) - } + val lidState = device.caseLidState + if (lidState == LidState.OPEN || lidState == LidState.CLOSED) { + StatusChip( + icon = Icons.TwoTone.GridView, + label = when (lidState) { + LidState.OPEN -> stringResource(R.string.pods_case_status_open_label) + LidState.CLOSED -> stringResource(R.string.pods_case_status_closed_label) + else -> "" + }, + ) } } } @@ -376,41 +376,17 @@ private fun CaseRow( @Preview2 @Composable private fun DualPodsCardFullChargePreview() = PreviewWrapper { - DualPodsCard(device = MockPodDataProvider.airPodsProFullCharge(), showDebug = false, now = Instant.now()) + DualPodsCard(device = MockPodDataProvider.dualPodMonitored(), showDebug = false, now = Instant.now()) } @Preview2 @Composable private fun DualPodsCardMixedBatteryPreview() = PreviewWrapper { - DualPodsCard(device = MockPodDataProvider.airPodsProMixed(), showDebug = false, now = Instant.now()) -} - -@Preview2 -@Composable -private fun DualPodsCardLowBatteryPreview() = PreviewWrapper { - DualPodsCard(device = MockPodDataProvider.airPodsProLowBattery(), showDebug = false, now = Instant.now()) -} - -@Preview2 -@Composable -private fun DualPodsCardInCasePreview() = PreviewWrapper { - DualPodsCard(device = MockPodDataProvider.airPodsProInCase(), showDebug = false, now = Instant.now()) + DualPodsCard(device = MockPodDataProvider.dualPodMonitoredMixed(), showDebug = false, now = Instant.now()) } @Preview2 @Composable private fun DualPodsCardDebugPreview() = PreviewWrapper { - DualPodsCard(device = MockPodDataProvider.airPodsProMixed(), showDebug = true, now = Instant.now()) -} - -@Preview2 -@Composable -private fun DualPodsCardNoCasePreview() = PreviewWrapper { - DualPodsCard(device = MockPodDataProvider.powerBeatsPro(), showDebug = false, now = Instant.now()) -} - -@Preview2 -@Composable -private fun DualPodsCardMicrophonePreview() = PreviewWrapper { - DualPodsCard(device = MockPodDataProvider.airPodsGen1Wearing(), showDebug = false, now = Instant.now()) + DualPodsCard(device = MockPodDataProvider.dualPodMonitoredMixed(), showDebug = true, now = Instant.now()) } diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/SinglePodsCard.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/SinglePodsCard.kt index f9f16ad7..c37aef33 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/SinglePodsCard.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/SinglePodsCard.kt @@ -44,27 +44,25 @@ import eu.darken.capod.R import eu.darken.capod.common.compose.Preview2 import eu.darken.capod.common.compose.PreviewWrapper import eu.darken.capod.common.compose.preview.MockPodDataProvider -import eu.darken.capod.pods.core.HasChargeDetection -import eu.darken.capod.pods.core.HasEarDetection -import eu.darken.capod.pods.core.SinglePodDevice +import eu.darken.capod.monitor.core.MonitoredDevice +import eu.darken.capod.monitor.core.getSignalQuality +import eu.darken.capod.monitor.core.lastSeenFormatted +import eu.darken.capod.monitor.core.firstSeenFormatted import eu.darken.capod.pods.core.apple.ApplePods -import eu.darken.capod.pods.core.firstSeenFormatted import eu.darken.capod.pods.core.formatBatteryPercent -import eu.darken.capod.pods.core.getSignalQuality -import eu.darken.capod.pods.core.lastSeenFormatted import java.time.Duration import java.time.Instant @OptIn(ExperimentalLayoutApi::class) @Composable fun SinglePodsCard( - device: SinglePodDevice, + device: MonitoredDevice, showDebug: Boolean, now: Instant, ) { val context = LocalContext.current - val clamped = device.batteryHeadsetPercent?.coerceIn(0f, 1f) + val clamped = device.batteryHeadset?.coerceIn(0f, 1f) val animatedProgress by animateFloatAsState( targetValue = clamped ?: 0f, animationSpec = tween(600, easing = FastOutSlowInEasing), @@ -104,15 +102,16 @@ fun SinglePodsCard( Column(modifier = Modifier.weight(1f)) { Row(verticalAlignment = Alignment.CenterVertically) { Text( - text = device.meta.profile?.label ?: "?", + text = device.meta?.profile?.label ?: "?", style = MaterialTheme.typography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis, ) - if (device is ApplePods && device.meta.isIRKMatch) { + val applePod = device.ble as? ApplePods + if (applePod != null && applePod.meta.isIRKMatch) { Spacer(modifier = Modifier.width(6.dp)) Icon( - imageVector = if (device.payload.private != null) Icons.TwoTone.Key else Icons.Outlined.Key, + imageVector = if (applePod.payload.private != null) Icons.TwoTone.Key else Icons.Outlined.Key, contentDescription = null, modifier = Modifier.size(14.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant, @@ -139,7 +138,9 @@ fun SinglePodsCard( style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) - if (Duration.between(device.seenFirstAt, device.seenLastAt).toMinutes() >= 1) { + val seenFirst = device.seenFirstAt + val seenLast = device.seenLastAt + if (seenFirst != null && seenLast != null && Duration.between(seenFirst, seenLast).toMinutes() >= 1) { Text( text = stringResource(R.string.first_seen_x, device.firstSeenFormatted(now)), style = MaterialTheme.typography.bodySmall, @@ -189,9 +190,9 @@ fun SinglePodsCard( // Battery text inside ring Text( - text = formatBatteryPercent(context, device.batteryHeadsetPercent), + text = formatBatteryPercent(context, device.batteryHeadset), style = MaterialTheme.typography.headlineMedium, - color = if (device.batteryHeadsetPercent != null) { + color = if (device.batteryHeadset != null) { MaterialTheme.colorScheme.onSurface } else { MaterialTheme.colorScheme.onSurfaceVariant @@ -207,13 +208,13 @@ fun SinglePodsCard( horizontalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp), ) { - if (device is HasChargeDetection && device.isHeadsetBeingCharged) { + if (device.isHeadsetBeingCharged == true) { StatusChip( icon = Icons.TwoTone.BatteryChargingFull, label = stringResource(R.string.pods_charging_label), ) } - if (device is HasEarDetection && device.isBeingWorn) { + if (device.isBeingWorn == true) { StatusChip( icon = Icons.TwoTone.Hearing, label = stringResource(R.string.pods_inear_label), @@ -233,18 +234,12 @@ fun SinglePodsCard( @Preview2 @Composable -private fun SinglePodsCardWearingPreview() = PreviewWrapper { - SinglePodsCard(device = MockPodDataProvider.airPodsMax(), showDebug = false, now = Instant.now()) -} - -@Preview2 -@Composable -private fun SinglePodsCardChargingPreview() = PreviewWrapper { - SinglePodsCard(device = MockPodDataProvider.airPodsMaxCharging(), showDebug = false, now = Instant.now()) +private fun SinglePodsCardPreview() = PreviewWrapper { + SinglePodsCard(device = MockPodDataProvider.singlePodMonitored(), showDebug = false, now = Instant.now()) } @Preview2 @Composable private fun SinglePodsCardDebugPreview() = PreviewWrapper { - SinglePodsCard(device = MockPodDataProvider.beatsSolo3(), showDebug = true, now = Instant.now()) + SinglePodsCard(device = MockPodDataProvider.singlePodMonitored(), showDebug = true, now = Instant.now()) } diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/UnknownPodDeviceCard.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/UnknownPodDeviceCard.kt index 86363d8e..bded58e4 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/UnknownPodDeviceCard.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/UnknownPodDeviceCard.kt @@ -21,10 +21,10 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import eu.darken.capod.R -import eu.darken.capod.pods.core.PodDevice +import eu.darken.capod.monitor.core.MonitoredDevice +import eu.darken.capod.monitor.core.getSignalQuality +import eu.darken.capod.monitor.core.lastSeenFormatted import eu.darken.capod.pods.core.apple.ApplePods -import eu.darken.capod.pods.core.getSignalQuality -import eu.darken.capod.pods.core.lastSeenFormatted import eu.darken.capod.common.compose.Preview2 import eu.darken.capod.common.compose.PreviewWrapper import eu.darken.capod.common.compose.preview.MockPodDataProvider @@ -32,7 +32,7 @@ import java.time.Instant @Composable fun UnknownPodDeviceCard( - device: PodDevice, + device: MonitoredDevice, showDebug: Boolean, now: Instant, ) { @@ -82,9 +82,10 @@ fun UnknownPodDeviceCard( Spacer(modifier = Modifier.height(8.dp)) Text( - text = when (device) { - is ApplePods -> stringResource(R.string.pods_unknown_contact_dev) - else -> stringResource(R.string.pods_unknown_label) + text = if (device.ble is ApplePods) { + stringResource(R.string.pods_unknown_contact_dev) + } else { + stringResource(R.string.pods_unknown_label) }, style = MaterialTheme.typography.bodyMedium, ) @@ -110,11 +111,11 @@ fun UnknownPodDeviceCard( @Preview2 @Composable private fun UnknownPodDeviceCardPreview() = PreviewWrapper { - UnknownPodDeviceCard(device = MockPodDataProvider.unknownDevice(), showDebug = false, now = Instant.now()) + UnknownPodDeviceCard(device = MockPodDataProvider.unknownMonitored(), showDebug = false, now = Instant.now()) } @Preview2 @Composable private fun UnknownPodDeviceCardDebugPreview() = PreviewWrapper { - UnknownPodDeviceCard(device = MockPodDataProvider.unknownDevice(), showDebug = true, now = Instant.now()) + UnknownPodDeviceCard(device = MockPodDataProvider.unknownMonitored(), showDebug = true, now = Instant.now()) } diff --git a/app/src/main/java/eu/darken/capod/main/ui/widget/BatteryGlanceWidget.kt b/app/src/main/java/eu/darken/capod/main/ui/widget/BatteryGlanceWidget.kt index dcba7348..d99850bd 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/widget/BatteryGlanceWidget.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/widget/BatteryGlanceWidget.kt @@ -22,8 +22,8 @@ import eu.darken.capod.common.debug.logging.log import eu.darken.capod.common.debug.logging.logTag import eu.darken.capod.common.upgrade.UpgradeRepo import eu.darken.capod.common.upgrade.isPro -import eu.darken.capod.monitor.core.PodMonitor -import eu.darken.capod.pods.core.PodDevice +import eu.darken.capod.monitor.core.DeviceMonitor +import eu.darken.capod.monitor.core.MonitoredDevice import eu.darken.capod.profiles.core.DeviceProfilesRepo class BatteryGlanceWidget : GlanceAppWidget() { @@ -34,7 +34,7 @@ class BatteryGlanceWidget : GlanceAppWidget() { @EntryPoint @InstallIn(SingletonComponent::class) interface WidgetEntryPoint { - fun podMonitor(): PodMonitor + fun deviceMonitor(): DeviceMonitor fun upgradeRepo(): UpgradeRepo fun widgetSettings(): WidgetSettings fun deviceProfilesRepo(): DeviceProfilesRepo @@ -45,7 +45,7 @@ class BatteryGlanceWidget : GlanceAppWidget() { val appWidgetId: Int val initialIsPro: Boolean val initialProfileId: String? - val cachedDevice: PodDevice? + val cachedDevice: MonitoredDevice? try { ep = EntryPointAccessors.fromApplication(context, WidgetEntryPoint::class.java) @@ -53,7 +53,7 @@ class BatteryGlanceWidget : GlanceAppWidget() { log(TAG, VERBOSE) { "provideGlance(appWidgetId=$appWidgetId)" } initialIsPro = ep.upgradeRepo().isPro() initialProfileId = ep.widgetSettings().getWidgetProfile(appWidgetId) - cachedDevice = initialProfileId?.let { ep.podMonitor().getDeviceForProfile(it) } + cachedDevice = initialProfileId?.let { ep.deviceMonitor().getDeviceForProfile(it) } } catch (e: Exception) { log(TAG, ERROR) { "provideGlance setup failed: ${e.asLog()}" } provideContent { @@ -73,7 +73,7 @@ class BatteryGlanceWidget : GlanceAppWidget() { provideContent { // Composable reads — must be outside try-catch - val devices by ep.podMonitor().devices.collectAsState(initial = emptyList()) + val devices by ep.deviceMonitor().devices.collectAsState(initial = emptyList()) val profiles by ep.deviceProfilesRepo().profiles.collectAsState(initial = emptyList()) val upgradeInfo by ep.upgradeRepo().upgradeInfo.collectAsState(initial = null) val widthDp = LocalSize.current.width @@ -86,8 +86,8 @@ class BatteryGlanceWidget : GlanceAppWidget() { val isPro = upgradeInfo?.isPro ?: initialIsPro - val liveDevice = devices.firstOrNull { it.meta.profile?.id == profileId } - val device = liveDevice ?: cachedDevice?.takeIf { it.meta.profile?.id == profileId } + val liveDevice = devices.firstOrNull { it.meta?.profile?.id == profileId } + val device = liveDevice ?: cachedDevice?.takeIf { it.meta?.profile?.id == profileId } val profileLabel = profileId?.let { pid -> profiles.firstOrNull { it.id == pid }?.label diff --git a/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetRenderStateMapper.kt b/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetRenderStateMapper.kt index b58cf041..40d79786 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetRenderStateMapper.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetRenderStateMapper.kt @@ -4,13 +4,8 @@ import android.content.Context import androidx.annotation.ColorInt import androidx.appcompat.view.ContextThemeWrapper import eu.darken.capod.R -import eu.darken.capod.pods.core.DualPodDevice -import eu.darken.capod.pods.core.HasCase -import eu.darken.capod.pods.core.HasChargeDetectionDual -import eu.darken.capod.pods.core.HasEarDetection -import eu.darken.capod.pods.core.HasEarDetectionDual +import eu.darken.capod.monitor.core.MonitoredDevice import eu.darken.capod.pods.core.PodDevice -import eu.darken.capod.pods.core.SinglePodDevice import eu.darken.capod.pods.core.getBatteryDrawable import eu.darken.capod.pods.core.toBatteryFloat @@ -18,7 +13,7 @@ object WidgetRenderStateMapper { fun map( context: Context, - device: PodDevice?, + device: MonitoredDevice?, theme: WidgetTheme, isPro: Boolean, hasConfiguredProfile: Boolean, @@ -39,40 +34,40 @@ object WidgetRenderStateMapper { secondaryText = context.getString(R.string.upgrade_capod_description), ) - device is DualPodDevice -> WidgetRenderState.DualPod( + device != null && device.hasDualPods -> WidgetRenderState.DualPod( theme = theme, resolvedBgColor = bgColor, resolvedTextColor = textColor, resolvedIconColor = iconColor, isWide = isWide, deviceLabel = profileLabel ?: device.getLabel(context), - leftIcon = device.leftPodIcon, - leftPercent = device.batteryLeftPodPercent.toBatteryFloat(), - leftCharging = device is HasChargeDetectionDual && device.isLeftPodCharging, - leftInEar = device is HasEarDetectionDual && device.isLeftPodInEar, - rightIcon = device.rightPodIcon, - rightPercent = device.batteryRightPodPercent.toBatteryFloat(), - rightCharging = device is HasChargeDetectionDual && device.isRightPodCharging, - rightInEar = device is HasEarDetectionDual && device.isRightPodInEar, - caseIcon = (device as? HasCase)?.caseIcon ?: R.drawable.device_airpods_gen1_case, - casePercent = (device as? HasCase)?.batteryCasePercent.toBatteryFloat(), - caseCharging = device is HasCase && device.isCaseCharging, + leftIcon = device.leftPodIcon ?: R.drawable.device_airpods_gen1_left, + leftPercent = device.batteryLeft.toBatteryFloat(), + leftCharging = device.isLeftPodCharging == true, + leftInEar = device.isLeftInEar == true, + rightIcon = device.rightPodIcon ?: R.drawable.device_airpods_gen1_right, + rightPercent = device.batteryRight.toBatteryFloat(), + rightCharging = device.isRightPodCharging == true, + rightInEar = device.isRightInEar == true, + caseIcon = device.caseIcon ?: R.drawable.device_airpods_gen1_case, + casePercent = device.batteryCase.toBatteryFloat(), + caseCharging = device.isCaseCharging == true, ) - device is SinglePodDevice -> WidgetRenderState.SinglePod( + device != null && device.model != PodDevice.Model.UNKNOWN -> WidgetRenderState.SinglePod( theme = theme, resolvedBgColor = bgColor, resolvedTextColor = textColor, resolvedIconColor = iconColor, deviceLabel = profileLabel ?: device.getLabel(context), headsetIcon = device.iconRes, - percent = device.batteryHeadsetPercent.toBatteryFloat(), - batteryIcon = getBatteryDrawable(device.batteryHeadsetPercent), - charging = device is HasChargeDetectionDual && device.isHeadsetBeingCharged, - worn = device is HasEarDetection && device.isBeingWorn, + percent = device.batteryHeadset.toBatteryFloat(), + batteryIcon = getBatteryDrawable(device.batteryHeadset), + charging = device.isHeadsetBeingCharged == true, + worn = device.isBeingWorn == true, ) - device is PodDevice -> WidgetRenderState.Message( + device != null -> WidgetRenderState.Message( theme = theme, resolvedBgColor = bgColor, resolvedTextColor = textColor, diff --git a/app/src/main/java/eu/darken/capod/monitor/core/DeviceMonitor.kt b/app/src/main/java/eu/darken/capod/monitor/core/DeviceMonitor.kt index 3421eb50..a7bc8a4a 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/DeviceMonitor.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/DeviceMonitor.kt @@ -1,8 +1,11 @@ package eu.darken.capod.monitor.core +import eu.darken.capod.common.debug.logging.log +import eu.darken.capod.common.debug.logging.logTag import eu.darken.capod.pods.core.apple.protocol.aap.AapConnectionManager import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.firstOrNull import javax.inject.Inject import javax.inject.Singleton @@ -26,4 +29,15 @@ class DeviceMonitor @Inject constructor( ) } } + + suspend fun getDeviceForProfile(profileId: String): MonitoredDevice? { + log(TAG) { "getDeviceForProfile(profileId=$profileId)" } + val bleDevice = blePodMonitor.getDeviceForProfile(profileId) ?: return null + val aapState = aapManager.allStates.firstOrNull()?.get(bleDevice.address) + return MonitoredDevice(ble = bleDevice, aap = aapState) + } + + companion object { + private val TAG = logTag("DeviceMonitor") + } } diff --git a/app/src/main/java/eu/darken/capod/monitor/core/DeviceMonitorExtensions.kt b/app/src/main/java/eu/darken/capod/monitor/core/DeviceMonitorExtensions.kt new file mode 100644 index 00000000..35a1bda3 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/monitor/core/DeviceMonitorExtensions.kt @@ -0,0 +1,49 @@ +package eu.darken.capod.monitor.core + +import android.content.Context +import android.icu.text.RelativeDateTimeFormatter +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import java.time.Duration +import java.time.Instant +import kotlin.math.roundToInt + +fun DeviceMonitor.devicesWithProfiles(): Flow> = devices + .map { devices -> devices.filter { it.meta?.profile != null } } + +fun DeviceMonitor.primaryDevice(): Flow = devicesWithProfiles().map { it.firstOrNull() } + +fun MonitoredDevice.getSignalQuality(context: Context): String { + val multiplier = 100 * signalQuality + return "${multiplier.roundToInt()}%" +} + +fun MonitoredDevice.lastSeenFormatted(now: Instant): String { + val lastAt = seenLastAt ?: return "" + val formatter = RelativeDateTimeFormatter.getInstance() + val duration = Duration.between(lastAt, now) + return if (duration > Duration.ofMinutes(1)) { + formatter.format( + duration.toMinutes().toDouble(), + RelativeDateTimeFormatter.Direction.LAST, + RelativeDateTimeFormatter.RelativeUnit.MINUTES + ) + } else { + formatter.format( + duration.seconds.toDouble(), + RelativeDateTimeFormatter.Direction.LAST, + RelativeDateTimeFormatter.RelativeUnit.SECONDS + ) + } +} + +fun MonitoredDevice.firstSeenFormatted(now: Instant): String { + val firstAt = seenFirstAt ?: return "" + val formatter = RelativeDateTimeFormatter.getInstance() + val duration = Duration.between(firstAt, now) + return formatter.format( + duration.toMinutes().toDouble(), + RelativeDateTimeFormatter.Direction.LAST, + RelativeDateTimeFormatter.RelativeUnit.MINUTES + ) +} diff --git a/app/src/main/java/eu/darken/capod/monitor/core/MonitoredDevice.kt b/app/src/main/java/eu/darken/capod/monitor/core/MonitoredDevice.kt index 908d6ca9..7d1d1107 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/MonitoredDevice.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/MonitoredDevice.kt @@ -1,15 +1,21 @@ package eu.darken.capod.monitor.core +import android.content.Context import eu.darken.capod.common.bluetooth.BluetoothAddress import eu.darken.capod.pods.core.BlePodSnapshot import eu.darken.capod.pods.core.DualPodDevice import eu.darken.capod.pods.core.HasCase +import eu.darken.capod.pods.core.HasChargeDetection +import eu.darken.capod.pods.core.HasChargeDetectionDual +import eu.darken.capod.pods.core.HasDualMicrophone +import eu.darken.capod.pods.core.HasEarDetection import eu.darken.capod.pods.core.HasEarDetectionDual import eu.darken.capod.pods.core.PodDevice import eu.darken.capod.pods.core.SinglePodDevice import eu.darken.capod.pods.core.apple.DualApplePods import eu.darken.capod.pods.core.apple.protocol.aap.AapPodState import eu.darken.capod.pods.core.apple.protocol.aap.AapSetting +import java.time.Instant /** * Unified device facade combining BLE scan data and AAP connection data. @@ -23,12 +29,21 @@ data class MonitoredDevice( // Identity val model: PodDevice.Model get() = ble?.model ?: PodDevice.Model.UNKNOWN val address: BluetoothAddress? get() = ble?.address + val identifier: PodDevice.Id? get() = ble?.identifier + val meta: PodDevice.Meta? get() = ble?.meta // Capabilities from Model.Features val hasCase: Boolean get() = model.features.hasCase val hasDualPods: Boolean get() = model.features.hasDualPods val hasEarDetection: Boolean get() = model.features.hasEarDetection val hasAncControl: Boolean get() = model.features.hasAncControl + val hasDualMicrophone: Boolean get() = ble is HasDualMicrophone + + // Signal / timing + val seenLastAt: Instant? get() = ble?.seenLastAt + val seenFirstAt: Instant? get() = ble?.seenFirstAt + val signalQuality: Float get() = ble?.signalQuality ?: 0f + val rssi: Int get() = ble?.rssi ?: 0 // Battery — best available source val batteryLeft: Float? @@ -43,16 +58,59 @@ data class MonitoredDevice( val batteryHeadset: Float? get() = (ble as? SinglePodDevice)?.batteryHeadsetPercent - // State + // Charging + val isLeftPodCharging: Boolean? + get() = (ble as? HasChargeDetectionDual)?.isLeftPodCharging + + val isRightPodCharging: Boolean? + get() = (ble as? HasChargeDetectionDual)?.isRightPodCharging + + val isCaseCharging: Boolean? + get() = (ble as? HasCase)?.isCaseCharging + + val isHeadsetBeingCharged: Boolean? + get() = (ble as? HasChargeDetection)?.isHeadsetBeingCharged + + // Ear detection val isLeftInEar: Boolean? get() = (ble as? HasEarDetectionDual)?.isLeftPodInEar val isRightInEar: Boolean? get() = (ble as? HasEarDetectionDual)?.isRightPodInEar + val isBeingWorn: Boolean? + get() = (ble as? HasEarDetection)?.isBeingWorn + + val isEitherPodInEar: Boolean? + get() = (ble as? HasEarDetectionDual)?.isEitherPodInEar + val caseLidState: DualApplePods.LidState? get() = (ble as? DualApplePods)?.caseLidState + // Microphone + val isLeftPodMicrophone: Boolean? + get() = (ble as? HasDualMicrophone)?.isLeftPodMicrophone + + val isRightPodMicrophone: Boolean? + get() = (ble as? HasDualMicrophone)?.isRightPodMicrophone + + // Icons / labels + val iconRes: Int get() = ble?.iconRes ?: model.iconRes + + val leftPodIcon: Int? + get() = (ble as? DualPodDevice)?.leftPodIcon + + val rightPodIcon: Int? + get() = (ble as? DualPodDevice)?.rightPodIcon + + val caseIcon: Int? + get() = (ble as? HasCase)?.caseIcon + + fun getLabel(context: Context): String = ble?.getLabel(context) ?: model.label + + // Debug + val rawDataHex: List get() = ble?.rawDataHex ?: emptyList() + // AAP controls val ancMode: AapSetting.AncMode? get() = aap?.setting() diff --git a/app/src/main/java/eu/darken/capod/monitor/core/worker/MonitorService.kt b/app/src/main/java/eu/darken/capod/monitor/core/worker/MonitorService.kt index 0439de8b..d9d5c462 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/worker/MonitorService.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/worker/MonitorService.kt @@ -26,6 +26,7 @@ import eu.darken.capod.common.hasApiLevel import eu.darken.capod.main.core.GeneralSettings import eu.darken.capod.main.core.MonitorMode import eu.darken.capod.main.core.PermissionTool +import eu.darken.capod.monitor.core.DeviceMonitor import eu.darken.capod.monitor.core.MonitorCoroutineScope import eu.darken.capod.monitor.core.PodMonitor import eu.darken.capod.monitor.core.primaryDevice @@ -64,6 +65,7 @@ class MonitorService : Service() { @Inject lateinit var generalSettings: GeneralSettings @Inject lateinit var permissionTool: PermissionTool @Inject lateinit var podMonitor: PodMonitor + @Inject lateinit var deviceMonitor: DeviceMonitor @Inject lateinit var bluetoothManager: BluetoothManager2 @Inject lateinit var playPause: PlayPause @Inject lateinit var autoConnect: AutoConnect @@ -176,7 +178,7 @@ class MonitorService : Service() { return } - val monitorJob = podMonitor.primaryDevice() + val monitorJob = deviceMonitor.primaryDevice() .setupCommonEventHandlers(TAG) { "PodMonitor" } .distinctUntilChanged() .throttleLatest(1000) diff --git a/app/src/main/java/eu/darken/capod/monitor/ui/MonitorNotificationViewFactory.kt b/app/src/main/java/eu/darken/capod/monitor/ui/MonitorNotificationViewFactory.kt index 66e2a226..ef38f420 100644 --- a/app/src/main/java/eu/darken/capod/monitor/ui/MonitorNotificationViewFactory.kt +++ b/app/src/main/java/eu/darken/capod/monitor/ui/MonitorNotificationViewFactory.kt @@ -5,13 +5,8 @@ import android.view.View import android.widget.RemoteViews import dagger.hilt.android.qualifiers.ApplicationContext import eu.darken.capod.R -import eu.darken.capod.pods.core.DualPodDevice -import eu.darken.capod.pods.core.HasCase -import eu.darken.capod.pods.core.HasChargeDetectionDual -import eu.darken.capod.pods.core.HasEarDetection -import eu.darken.capod.pods.core.HasEarDetectionDual +import eu.darken.capod.monitor.core.MonitoredDevice import eu.darken.capod.pods.core.PodDevice -import eu.darken.capod.pods.core.SinglePodDevice import eu.darken.capod.pods.core.formatBatteryPercent import eu.darken.capod.pods.core.getBatteryDrawable import javax.inject.Inject @@ -22,127 +17,127 @@ class MonitorNotificationViewFactory @Inject constructor( @ApplicationContext private val context: Context ) { - fun createContentView(device: PodDevice): RemoteViews = when (device) { - is DualPodDevice -> createDualPods(device) - is SinglePodDevice -> createSinglePod(device) + fun createContentView(device: MonitoredDevice): RemoteViews = when { + device.hasDualPods -> createDualPods(device) + device.model != PodDevice.Model.UNKNOWN -> createSinglePod(device) else -> createUnknownDevice(device) } - private fun createDualPods(device: DualPodDevice): RemoteViews = RemoteViews( + private fun createDualPods(device: MonitoredDevice): RemoteViews = RemoteViews( context.packageName, R.layout.monitor_notification_dual_pods_small ).apply { // Left - val leftPercent = device.batteryLeftPodPercent - setImageViewResource(R.id.pod_left_icon, device.leftPodIcon) + val leftPercent = device.batteryLeft + setImageViewResource(R.id.pod_left_icon, device.leftPodIcon ?: R.drawable.device_airpods_gen1_left) setTextViewText(R.id.pod_left_label, formatBatteryPercent(context, leftPercent)) - val isLeftPodCharging = (device as? HasChargeDetectionDual)?.isLeftPodCharging ?: false + val isLeftPodCharging = device.isLeftPodCharging ?: false setViewVisibility(R.id.pod_left_charging, if (isLeftPodCharging) View.VISIBLE else View.GONE) - val isLeftPodInEar = (device as? HasEarDetectionDual)?.isLeftPodInEar ?: false + val isLeftPodInEar = device.isLeftInEar ?: false setViewVisibility(R.id.pod_left_ear, if (isLeftPodInEar) View.VISIBLE else View.GONE) // Case - setViewVisibility(R.id.pod_case_charging, if (device is HasCase) View.VISIBLE else View.GONE) - (device as? HasCase)?.let { case -> - setImageViewResource(R.id.pod_case_icon, device.caseIcon) - val casePercent = case.batteryCasePercent + setViewVisibility(R.id.pod_case_charging, if (device.hasCase) View.VISIBLE else View.GONE) + if (device.hasCase) { + setImageViewResource(R.id.pod_case_icon, device.caseIcon ?: R.drawable.device_airpods_gen1_case) + val casePercent = device.batteryCase setTextViewText(R.id.pod_case_label, formatBatteryPercent(context, casePercent)) - setViewVisibility(R.id.pod_case_charging, if (case.isCaseCharging) View.VISIBLE else View.GONE) + setViewVisibility(R.id.pod_case_charging, if (device.isCaseCharging == true) View.VISIBLE else View.GONE) } // Right - val rightPercent = device.batteryRightPodPercent - setImageViewResource(R.id.pod_right_icon, device.rightPodIcon) + val rightPercent = device.batteryRight + setImageViewResource(R.id.pod_right_icon, device.rightPodIcon ?: R.drawable.device_airpods_gen1_right) setTextViewText(R.id.pod_right_label, formatBatteryPercent(context, rightPercent)) - val isRightPodCharging = (device as? HasChargeDetectionDual)?.isRightPodCharging ?: false + val isRightPodCharging = device.isRightPodCharging ?: false setViewVisibility(R.id.pod_right_charging, if (isRightPodCharging) View.VISIBLE else View.GONE) - val isRightPodInEar = (device as? HasEarDetectionDual)?.isRightPodInEar ?: false + val isRightPodInEar = device.isRightInEar ?: false setViewVisibility(R.id.pod_right_ear, if (isRightPodInEar) View.VISIBLE else View.GONE) } - private fun createSinglePod(device: SinglePodDevice): RemoteViews = RemoteViews( + private fun createSinglePod(device: MonitoredDevice): RemoteViews = RemoteViews( context.packageName, R.layout.monitor_notification_single_pods_small ).apply { - val headsetPercent = device.batteryHeadsetPercent + val headsetPercent = device.batteryHeadset setTextViewText(R.id.headphones_label, device.getLabel(context)) setImageViewResource(R.id.headphones_icon, device.iconRes) setImageViewResource(R.id.headphones_battery_icon, getBatteryDrawable(headsetPercent)) setTextViewText(R.id.headphones_battery_label, formatBatteryPercent(context, headsetPercent)) - if (device is HasEarDetection) { - setViewVisibility(R.id.headphones_worn, if (device.isBeingWorn) View.VISIBLE else View.GONE) + if (device.hasEarDetection) { + setViewVisibility(R.id.headphones_worn, if (device.isBeingWorn == true) View.VISIBLE else View.GONE) } - if (device is HasChargeDetectionDual) { - setViewVisibility(R.id.headphones_charging, if (device.isHeadsetBeingCharged) View.VISIBLE else View.GONE) + if (device.isHeadsetBeingCharged != null) { + setViewVisibility(R.id.headphones_charging, if (device.isHeadsetBeingCharged == true) View.VISIBLE else View.GONE) } } - private fun createUnknownDevice(device: PodDevice): RemoteViews = RemoteViews( + private fun createUnknownDevice(device: MonitoredDevice): RemoteViews = RemoteViews( context.packageName, R.layout.monitor_notification_unknown_device_small ).apply { setTextViewText(R.id.device, device.getLabel(context)) } - fun createBigContentView(device: PodDevice): RemoteViews = when (device) { - is DualPodDevice -> createDualPodsBig(device) - is SinglePodDevice -> createSinglePodBig(device) + fun createBigContentView(device: MonitoredDevice): RemoteViews = when { + device.hasDualPods -> createDualPodsBig(device) + device.model != PodDevice.Model.UNKNOWN -> createSinglePodBig(device) else -> createUnknownDeviceBig(device) } - private fun createDualPodsBig(device: DualPodDevice): RemoteViews = RemoteViews( + private fun createDualPodsBig(device: MonitoredDevice): RemoteViews = RemoteViews( context.packageName, R.layout.monitor_notification_dual_pods_big ).apply { // Left - val leftPercent = device.batteryLeftPodPercent - setImageViewResource(R.id.pod_left_icon, device.leftPodIcon) + val leftPercent = device.batteryLeft + setImageViewResource(R.id.pod_left_icon, device.leftPodIcon ?: R.drawable.device_airpods_gen1_left) setProgressBar(R.id.pod_left_progress, 100, percentToInt(leftPercent), false) setTextViewText(R.id.pod_left_label, formatBatteryPercent(context, leftPercent)) - val isLeftPodCharging = (device as? HasChargeDetectionDual)?.isLeftPodCharging ?: false + val isLeftPodCharging = device.isLeftPodCharging ?: false setViewVisibility(R.id.pod_left_charging, if (isLeftPodCharging) View.VISIBLE else View.GONE) - val isLeftPodInEar = (device as? HasEarDetectionDual)?.isLeftPodInEar ?: false + val isLeftPodInEar = device.isLeftInEar ?: false setViewVisibility(R.id.pod_left_ear, if (isLeftPodInEar) View.VISIBLE else View.GONE) // Case - setViewVisibility(R.id.pod_case_container, if (device is HasCase) View.VISIBLE else View.GONE) - (device as? HasCase)?.let { case -> - setImageViewResource(R.id.pod_case_icon, device.caseIcon) - val casePercent = case.batteryCasePercent + setViewVisibility(R.id.pod_case_container, if (device.hasCase) View.VISIBLE else View.GONE) + if (device.hasCase) { + setImageViewResource(R.id.pod_case_icon, device.caseIcon ?: R.drawable.device_airpods_gen1_case) + val casePercent = device.batteryCase setProgressBar(R.id.pod_case_progress, 100, percentToInt(casePercent), false) setTextViewText(R.id.pod_case_label, formatBatteryPercent(context, casePercent)) - setViewVisibility(R.id.pod_case_charging, if (case.isCaseCharging) View.VISIBLE else View.GONE) + setViewVisibility(R.id.pod_case_charging, if (device.isCaseCharging == true) View.VISIBLE else View.GONE) } // Right - val rightPercent = device.batteryRightPodPercent - setImageViewResource(R.id.pod_right_icon, device.rightPodIcon) + val rightPercent = device.batteryRight + setImageViewResource(R.id.pod_right_icon, device.rightPodIcon ?: R.drawable.device_airpods_gen1_right) setProgressBar(R.id.pod_right_progress, 100, percentToInt(rightPercent), false) setTextViewText(R.id.pod_right_label, formatBatteryPercent(context, rightPercent)) - val isRightPodCharging = (device as? HasChargeDetectionDual)?.isRightPodCharging ?: false + val isRightPodCharging = device.isRightPodCharging ?: false setViewVisibility(R.id.pod_right_charging, if (isRightPodCharging) View.VISIBLE else View.GONE) - val isRightPodInEar = (device as? HasEarDetectionDual)?.isRightPodInEar ?: false + val isRightPodInEar = device.isRightInEar ?: false setViewVisibility(R.id.pod_right_ear, if (isRightPodInEar) View.VISIBLE else View.GONE) } - private fun createSinglePodBig(device: SinglePodDevice): RemoteViews = RemoteViews( + private fun createSinglePodBig(device: MonitoredDevice): RemoteViews = RemoteViews( context.packageName, R.layout.monitor_notification_single_pods_big ).apply { - val headsetPercent = device.batteryHeadsetPercent + val headsetPercent = device.batteryHeadset setTextViewText(R.id.headphones_label, device.getLabel(context)) setImageViewResource(R.id.headphones_icon, device.iconRes) setProgressBar(R.id.headphones_battery_progress, 100, percentToInt(headsetPercent), false) setTextViewText(R.id.headphones_battery_label, formatBatteryPercent(context, headsetPercent)) - if (device is HasEarDetection) { - setViewVisibility(R.id.headphones_worn, if (device.isBeingWorn) View.VISIBLE else View.GONE) + if (device.hasEarDetection) { + setViewVisibility(R.id.headphones_worn, if (device.isBeingWorn == true) View.VISIBLE else View.GONE) } - if (device is HasChargeDetectionDual) { - setViewVisibility(R.id.headphones_charging, if (device.isHeadsetBeingCharged) View.VISIBLE else View.GONE) + if (device.isHeadsetBeingCharged != null) { + setViewVisibility(R.id.headphones_charging, if (device.isHeadsetBeingCharged == true) View.VISIBLE else View.GONE) } } - private fun createUnknownDeviceBig(device: PodDevice): RemoteViews = RemoteViews( + private fun createUnknownDeviceBig(device: MonitoredDevice): RemoteViews = RemoteViews( context.packageName, R.layout.monitor_notification_unknown_device_big ).apply { @@ -154,4 +149,4 @@ class MonitorNotificationViewFactory @Inject constructor( return (percent * 100).roundToInt().coerceIn(0, 100) } -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/darken/capod/monitor/ui/MonitorNotifications.kt b/app/src/main/java/eu/darken/capod/monitor/ui/MonitorNotifications.kt index 485cef4d..71889979 100644 --- a/app/src/main/java/eu/darken/capod/monitor/ui/MonitorNotifications.kt +++ b/app/src/main/java/eu/darken/capod/monitor/ui/MonitorNotifications.kt @@ -15,12 +15,8 @@ import eu.darken.capod.common.debug.logging.log import eu.darken.capod.common.debug.logging.logTag import eu.darken.capod.common.notifications.PendingIntentCompat import eu.darken.capod.main.ui.MainActivity -import eu.darken.capod.pods.core.DualPodDevice -import eu.darken.capod.pods.core.HasCase -import eu.darken.capod.pods.core.HasChargeDetection -import eu.darken.capod.pods.core.HasEarDetection +import eu.darken.capod.monitor.core.MonitoredDevice import eu.darken.capod.pods.core.PodDevice -import eu.darken.capod.pods.core.SinglePodDevice import eu.darken.capod.pods.core.formatBatteryPercent import javax.inject.Inject @@ -57,7 +53,7 @@ class MonitorNotifications @Inject constructor( setOngoing(true) } - private fun getBuilder(device: PodDevice?, channelId: String, showHint: Boolean = false): NotificationCompat.Builder { + private fun getBuilder(device: MonitoredDevice?, channelId: String, showHint: Boolean = false): NotificationCompat.Builder { if (device == null) { return baseBuilder(channelId).apply { if (showHint) { @@ -75,59 +71,42 @@ class MonitorNotifications @Inject constructor( return baseBuilder(channelId).apply { - // Options here should be mutually exclusive, and are prioritized by their order of importance - // Some options are omitted here, as they will conflict with other options - // TODO: Implement a settings pane to allow user to customize this val stateText = when { - // Pods charging state - // This goes first as pods should not be worn if it is still charging - device is HasChargeDetection && device.isHeadsetBeingCharged -> { + device.isHeadsetBeingCharged == true -> { context.getString(R.string.pods_charging_label) } - // Pods wear state - device is HasEarDetection -> { - if (device.isBeingWorn) context.getString(R.string.headset_being_worn_label) + device.hasEarDetection -> { + if (device.isBeingWorn == true) context.getString(R.string.headset_being_worn_label) else context.getString(R.string.headset_not_being_worn_label) } - // Case charge state - // This is under pods wear state as we don't want it conflicting with it - device is HasCase && device.isCaseCharging -> { + device.hasCase && device.isCaseCharging == true -> { context.getString(R.string.pods_charging_label) } else -> context.getString(R.string.pods_case_unknown_state) } - val batteryText = when (device) { - is DualPodDevice -> { - val leftPercent = device.batteryLeftPodPercent - val rightPercent = device.batteryRightPodPercent - val left = formatBatteryPercent(context, leftPercent) - val right = formatBatteryPercent(context, rightPercent) - when { - device is HasCase -> { - val casePercent = device.batteryCasePercent - val case = formatBatteryPercent(context, casePercent) - "$left $case $right" - } - - else -> "$left $right" + val batteryText = when { + device.hasDualPods -> { + val left = formatBatteryPercent(context, device.batteryLeft) + val right = formatBatteryPercent(context, device.batteryRight) + if (device.hasCase) { + val case = formatBatteryPercent(context, device.batteryCase) + "$left $case $right" + } else { + "$left $right" } } - is SinglePodDevice -> { - val headsetPercent = device.batteryHeadsetPercent - val headset = formatBatteryPercent(context, headsetPercent) - when { - device is HasCase -> { - val casePercent = device.batteryCasePercent - val case = formatBatteryPercent(context, casePercent) - "$headset $case" - } - - else -> headset + device.model != PodDevice.Model.UNKNOWN -> { + val headset = formatBatteryPercent(context, device.batteryHeadset) + if (device.hasCase) { + val case = formatBatteryPercent(context, device.batteryCase) + "$headset $case" + } else { + headset } } @@ -143,10 +122,10 @@ class MonitorNotifications @Inject constructor( } } - fun getNotification(podDevice: PodDevice?, showHint: Boolean = false): Notification = + fun getNotification(podDevice: MonitoredDevice?, showHint: Boolean = false): Notification = getBuilder(podDevice, NOTIFICATION_CHANNEL_ID, showHint).build() - fun getNotificationConnected(podDevice: PodDevice?): Notification = + fun getNotificationConnected(podDevice: MonitoredDevice?): Notification = getBuilder(podDevice, NOTIFICATION_CHANNEL_ID_CONNECTED).build() fun getStartupNotification(): Notification = diff --git a/app/src/main/java/eu/darken/capod/reaction/core/autoconnect/AutoConnect.kt b/app/src/main/java/eu/darken/capod/reaction/core/autoconnect/AutoConnect.kt index 501d46b7..53ca9f97 100644 --- a/app/src/main/java/eu/darken/capod/reaction/core/autoconnect/AutoConnect.kt +++ b/app/src/main/java/eu/darken/capod/reaction/core/autoconnect/AutoConnect.kt @@ -7,10 +7,8 @@ import eu.darken.capod.common.debug.logging.log import eu.darken.capod.common.debug.logging.logTag import eu.darken.capod.common.flow.setupCommonEventHandlers import eu.darken.capod.main.core.GeneralSettings -import eu.darken.capod.monitor.core.PodMonitor +import eu.darken.capod.monitor.core.DeviceMonitor import eu.darken.capod.monitor.core.primaryDevice -import eu.darken.capod.pods.core.HasEarDetection -import eu.darken.capod.pods.core.HasEarDetectionDual import eu.darken.capod.pods.core.apple.DualApplePods import eu.darken.capod.profiles.core.DeviceProfilesRepo import eu.darken.capod.reaction.core.ReactionSettings @@ -29,7 +27,7 @@ import eu.darken.capod.common.datastore.valueBlocking @Singleton class AutoConnect @Inject constructor( private val bluetoothManager: BluetoothManager2, - private val podMonitor: PodMonitor, + private val deviceMonitor: DeviceMonitor, private val generalSettings: GeneralSettings, private val reactionSettings: ReactionSettings, private val deviceProfilesRepo: DeviceProfilesRepo, @@ -40,7 +38,7 @@ class AutoConnect @Inject constructor( if (isAutoConnectEnabled) { combine( bluetoothManager.connectedDevices, - podMonitor.primaryDevice().filterNotNull().distinctUntilChangedBy { it.rawDataHex }, + deviceMonitor.primaryDevice().filterNotNull().distinctUntilChangedBy { it.rawDataHex }, ) { connectedDevices, mainDevice -> connectedDevices to mainDevice } @@ -51,7 +49,7 @@ class AutoConnect @Inject constructor( .map { (connectedDevices, mainDevice) -> log(TAG, VERBOSE) { "mainPodDevice is $mainDevice" } - val mainDeviceAddr = mainDevice.meta.profile?.address + val mainDeviceAddr = mainDevice.meta?.profile?.address if (mainDeviceAddr.isNullOrEmpty()) { log(TAG, WARN) { "mainDeviceAddress is null" } return@map @@ -78,9 +76,9 @@ class AutoConnect @Inject constructor( val condition = reactionSettings.autoConnectCondition.valueBlocking log(TAG) { "Checking condition $condition" } - val lidState = (mainDevice as? DualApplePods)?.caseLidState - val isBeingWorn = (mainDevice as? HasEarDetection)?.isBeingWorn ?: false - val isEitherPodInEar = (mainDevice as? HasEarDetectionDual)?.isEitherPodInEar ?: false + val lidState = mainDevice.caseLidState + val isBeingWorn = mainDevice.isBeingWorn ?: false + val isEitherPodInEar = mainDevice.isEitherPodInEar ?: false val onePodMode = reactionSettings.onePodMode.valueBlocking val decision = evaluateAutoConnect( @@ -92,7 +90,7 @@ class AutoConnect @Inject constructor( isBeingWorn = isBeingWorn, isEitherPodInEar = isEitherPodInEar, onePodMode = onePodMode, - supportsEarDetection = mainDevice is HasEarDetection, + supportsEarDetection = mainDevice.hasEarDetection, ) if (!decision.shouldConnect) { @@ -158,4 +156,4 @@ class AutoConnect @Inject constructor( companion object { private val TAG = logTag("Reaction", "AutoConnect") } -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/darken/capod/reaction/core/playpause/PlayPause.kt b/app/src/main/java/eu/darken/capod/reaction/core/playpause/PlayPause.kt index 74c2bfc7..66dd17e8 100644 --- a/app/src/main/java/eu/darken/capod/reaction/core/playpause/PlayPause.kt +++ b/app/src/main/java/eu/darken/capod/reaction/core/playpause/PlayPause.kt @@ -8,10 +8,9 @@ import eu.darken.capod.common.debug.logging.log import eu.darken.capod.common.debug.logging.logTag import eu.darken.capod.common.flow.setupCommonEventHandlers import eu.darken.capod.common.flow.withPrevious -import eu.darken.capod.monitor.core.PodMonitor +import eu.darken.capod.monitor.core.DeviceMonitor +import eu.darken.capod.monitor.core.MonitoredDevice import eu.darken.capod.monitor.core.primaryDevice -import eu.darken.capod.pods.core.HasEarDetection -import eu.darken.capod.pods.core.HasEarDetectionDual import eu.darken.capod.reaction.core.ReactionSettings import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged @@ -25,7 +24,7 @@ import eu.darken.capod.common.datastore.valueBlocking @Singleton class PlayPause @Inject constructor( - private val podMonitor: PodMonitor, + private val deviceMonitor: DeviceMonitor, private val bluetoothManager: BluetoothManager2, private val reactionSettings: ReactionSettings, private val mediaControl: MediaControl, @@ -43,7 +42,7 @@ class PlayPause @Inject constructor( emptyFlow() } else { log(TAG) { "Known devices connected: $it" } - podMonitor.primaryDevice() + deviceMonitor.primaryDevice() } } .distinctUntilChanged() @@ -63,20 +62,27 @@ class PlayPause @Inject constructor( val currState: EarDetectionState when { - previous is HasEarDetectionDual && current is HasEarDetectionDual -> { + previous!!.hasEarDetection && previous.hasDualPods && + current!!.hasEarDetection && current.hasDualPods -> { // Dual pod devices (AirPods, AirPods Pro, etc.) log(TAG, VERBOSE) { - "Dual-pod device: left=${current.isLeftPodInEar}, right=${current.isRightPodInEar}" + "Dual-pod device: left=${current.isLeftInEar}, right=${current.isRightInEar}" } - prevState = EarDetectionState.fromDualPod(previous) - currState = EarDetectionState.fromDualPod(current) + prevState = EarDetectionState.fromDualPod( + left = previous.isLeftInEar ?: false, + right = previous.isRightInEar ?: false, + ) + currState = EarDetectionState.fromDualPod( + left = current.isLeftInEar ?: false, + right = current.isRightInEar ?: false, + ) } - previous is HasEarDetection && current is HasEarDetection -> { + previous!!.hasEarDetection && current!!.hasEarDetection -> { // Single pod devices (AirPods Max, etc.) log(TAG, VERBOSE) { "Single-pod device: worn=${current.isBeingWorn}" } - prevState = EarDetectionState.fromSinglePod(previous) - currState = EarDetectionState.fromSinglePod(current) + prevState = EarDetectionState.fromSinglePod(worn = previous.isBeingWorn ?: false) + currState = EarDetectionState.fromSinglePod(worn = current.isBeingWorn ?: false) } else -> { @@ -209,19 +215,12 @@ class PlayPause @Inject constructor( } companion object { - fun fromDualPod(device: HasEarDetectionDual) = fromDualPod( - left = device.isLeftPodInEar, - right = device.isRightPodInEar, - ) - fun fromDualPod(left: Boolean, right: Boolean) = EarDetectionState( leftInEar = left, rightInEar = right, isWorn = left && right ) - fun fromSinglePod(device: HasEarDetection) = fromSinglePod(worn = device.isBeingWorn) - fun fromSinglePod(worn: Boolean) = EarDetectionState( leftInEar = null, rightInEar = null, @@ -239,4 +238,4 @@ class PlayPause @Inject constructor( companion object { private val TAG = logTag("Reaction", "PlayPause") } -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/darken/capod/reaction/core/popup/PopUpReaction.kt b/app/src/main/java/eu/darken/capod/reaction/core/popup/PopUpReaction.kt index 9dedb59e..6ec142dc 100644 --- a/app/src/main/java/eu/darken/capod/reaction/core/popup/PopUpReaction.kt +++ b/app/src/main/java/eu/darken/capod/reaction/core/popup/PopUpReaction.kt @@ -9,9 +9,9 @@ import eu.darken.capod.common.debug.logging.log import eu.darken.capod.common.debug.logging.logTag import eu.darken.capod.common.flow.setupCommonEventHandlers import eu.darken.capod.common.flow.withPrevious -import eu.darken.capod.monitor.core.PodMonitor +import eu.darken.capod.monitor.core.DeviceMonitor +import eu.darken.capod.monitor.core.MonitoredDevice import eu.darken.capod.monitor.core.primaryDevice -import eu.darken.capod.pods.core.PodDevice import eu.darken.capod.pods.core.apple.DualApplePods import eu.darken.capod.reaction.core.ReactionSettings import kotlinx.coroutines.flow.Flow @@ -28,7 +28,7 @@ import javax.inject.Singleton @Singleton class PopUpReaction @Inject constructor( - private val podMonitor: PodMonitor, + private val deviceMonitor: DeviceMonitor, private val reactionSettings: ReactionSettings, private val bluetoothManager: BluetoothManager2, ) { @@ -38,7 +38,7 @@ class PopUpReaction @Inject constructor( private fun monitorCase(): Flow = reactionSettings.showPopUpOnCaseOpen.flow .flatMapLatest { isEnabled -> if (isEnabled) { - podMonitor.primaryDevice().distinctUntilChangedBy { it?.rawDataHex } + deviceMonitor.primaryDevice().distinctUntilChangedBy { it?.rawDataHex } } else { emptyFlow() } @@ -46,18 +46,16 @@ class PopUpReaction @Inject constructor( .withPrevious() .setupCommonEventHandlers(TAG) { "popUpCase" } .mapNotNull { (previous, current) -> - if (previous !is DualApplePods? || current !is DualApplePods) { + if (current?.caseLidState == null) { return@mapNotNull null } log(TAG, VERBOSE) { - val prev = previous?.pubCaseLidState?.let { String.format("%02X", it.toByte()) } - val cur = current.pubCaseLidState.let { String.format("%02X", it.toByte()) } - "previous=$prev (${previous?.caseLidState}), current=$cur (${current.caseLidState})" + "previous=${previous?.caseLidState}, current=${current.caseLidState}" } log(TAG, VERBOSE) { "previous-id=${previous?.identifier}, current-id=${current.identifier}" } val isSameDeviceOrProfile = previous?.identifier == current.identifier || - (previous?.meta?.profile?.id != null && previous.meta.profile?.id == current.meta.profile?.id) + (previous?.meta?.profile?.id != null && previous.meta?.profile?.id == current.meta?.profile?.id) val isSameDeviceWithCaseNowOpen = isSameDeviceOrProfile && previous?.caseLidState != current.caseLidState val isNewDeviceWithJustOpenedCase = !isSameDeviceOrProfile && previous?.caseLidState != current.caseLidState @@ -69,8 +67,8 @@ class PopUpReaction @Inject constructor( throttleCasePopUps(current) } - private fun throttleCasePopUps(current: DualApplePods): Event? { - val cooldownKey = current.meta.profile?.id ?: current.identifier.toString() + private fun throttleCasePopUps(current: MonitoredDevice): Event? { + val cooldownKey = current.meta?.profile?.id ?: current.identifier.toString() val now = Instant.now() val lastShown = caseCoolDowns[cooldownKey] @@ -109,7 +107,7 @@ class PopUpReaction @Inject constructor( combine( bluetoothManager.connectedDevices, - podMonitor.primaryDevice().distinctUntilChangedBy { it?.rawDataHex }, + deviceMonitor.primaryDevice().distinctUntilChangedBy { it?.rawDataHex }, ) { devices, broadcast -> log(TAG) { "$broadcast $devices " } val primaryAddr = broadcast?.meta?.profile?.address @@ -143,7 +141,8 @@ class PopUpReaction @Inject constructor( return@mapNotNull null } - val deviceAge = Duration.between(currentBroadcasted.seenFirstAt, Instant.now()) + val deviceSeenFirst = currentBroadcasted.seenFirstAt ?: return@mapNotNull null + val deviceAge = Duration.between(deviceSeenFirst, Instant.now()) val connectionAge = Duration.between(currentConnected.seenFirstAt, Instant.now()) val decision = evaluateConnectionPopUp( @@ -170,7 +169,7 @@ class PopUpReaction @Inject constructor( sealed class Event { data class PopupShow( val eventAt: Instant = Instant.now(), - val device: PodDevice, + val device: MonitoredDevice, ) : Event() data class PopupHide( @@ -264,4 +263,4 @@ class PopUpReaction @Inject constructor( companion object { private val TAG = logTag("Reaction", "PopUp") } -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/darken/capod/reaction/ui/popup/PopUpContent.kt b/app/src/main/java/eu/darken/capod/reaction/ui/popup/PopUpContent.kt index e16c0fce..163ad8cc 100644 --- a/app/src/main/java/eu/darken/capod/reaction/ui/popup/PopUpContent.kt +++ b/app/src/main/java/eu/darken/capod/reaction/ui/popup/PopUpContent.kt @@ -34,19 +34,17 @@ import eu.darken.capod.R import eu.darken.capod.common.compose.Preview2 import eu.darken.capod.common.compose.PreviewWrapper import eu.darken.capod.common.compose.preview.MockPodDataProvider -import eu.darken.capod.pods.core.DualPodDevice -import eu.darken.capod.pods.core.HasCase +import eu.darken.capod.monitor.core.MonitoredDevice +import eu.darken.capod.monitor.core.getSignalQuality import eu.darken.capod.pods.core.PodDevice -import eu.darken.capod.pods.core.SinglePodDevice import eu.darken.capod.pods.core.formatBatteryPercent import eu.darken.capod.pods.core.getBatteryDrawable import eu.darken.capod.pods.core.toBatteryFloat import eu.darken.capod.pods.core.toBatteryOrNull -import eu.darken.capod.pods.core.getSignalQuality @Composable fun PopUpContent( - device: PodDevice, + device: MonitoredDevice, onClose: () -> Unit, modifier: Modifier = Modifier, ) { @@ -98,9 +96,9 @@ fun PopUpContent( Spacer(modifier = Modifier.height(16.dp)) // Device-specific content - when (device) { - is DualPodDevice -> DualPodContent(device) - is SinglePodDevice -> SinglePodContent(device) + when { + device.hasDualPods -> DualPodContent(device) + device.model != PodDevice.Model.UNKNOWN -> SinglePodContent(device) } Spacer(modifier = Modifier.height(20.dp)) @@ -119,43 +117,41 @@ fun PopUpContent( } @Composable -private fun DualPodContent(device: DualPodDevice) { - val hasCase = device as? HasCase - +private fun DualPodContent(device: MonitoredDevice) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly, ) { // Left pod BatteryColumn( - iconRes = device.leftPodIcon, - batteryPercent = device.batteryLeftPodPercent.toBatteryFloat(), + iconRes = device.leftPodIcon ?: R.drawable.device_airpods_gen1_left, + batteryPercent = device.batteryLeft.toBatteryFloat(), modifier = Modifier.weight(1f), ) // Case (only if device has one) - if (hasCase != null) { + if (device.hasCase) { BatteryColumn( - iconRes = hasCase.caseIcon, - batteryPercent = hasCase.batteryCasePercent.toBatteryFloat(), + iconRes = device.caseIcon ?: R.drawable.device_airpods_gen1_case, + batteryPercent = device.batteryCase.toBatteryFloat(), modifier = Modifier.weight(1f), ) } // Right pod BatteryColumn( - iconRes = device.rightPodIcon, - batteryPercent = device.batteryRightPodPercent.toBatteryFloat(), + iconRes = device.rightPodIcon ?: R.drawable.device_airpods_gen1_right, + batteryPercent = device.batteryRight.toBatteryFloat(), modifier = Modifier.weight(1f), ) } } @Composable -private fun SinglePodContent(device: SinglePodDevice) { +private fun SinglePodContent(device: MonitoredDevice) { BatteryColumn( iconRes = device.iconRes, - batteryPercent = device.batteryHeadsetPercent.toBatteryFloat(), + batteryPercent = device.batteryHeadset.toBatteryFloat(), ) } @@ -205,17 +201,11 @@ private fun BatteryColumn( @Preview2 @Composable private fun PopUpContentDualPodPreview() = PreviewWrapper { - PopUpContent(device = MockPodDataProvider.airPodsProMixed(), onClose = {}) -} - -@Preview2 -@Composable -private fun PopUpContentDualPodNoCasePreview() = PreviewWrapper { - PopUpContent(device = MockPodDataProvider.powerBeatsPro(), onClose = {}) + PopUpContent(device = MockPodDataProvider.dualPodMonitoredMixed(), onClose = {}) } @Preview2 @Composable private fun PopUpContentSinglePodPreview() = PreviewWrapper { - PopUpContent(device = MockPodDataProvider.airPodsMax(), onClose = {}) + PopUpContent(device = MockPodDataProvider.singlePodMonitored(), onClose = {}) } diff --git a/app/src/main/java/eu/darken/capod/reaction/ui/popup/PopUpWindow.kt b/app/src/main/java/eu/darken/capod/reaction/ui/popup/PopUpWindow.kt index 6e107027..a810cf0a 100644 --- a/app/src/main/java/eu/darken/capod/reaction/ui/popup/PopUpWindow.kt +++ b/app/src/main/java/eu/darken/capod/reaction/ui/popup/PopUpWindow.kt @@ -23,7 +23,7 @@ import eu.darken.capod.common.debug.logging.logTag import eu.darken.capod.common.theming.CapodTheme import eu.darken.capod.main.core.GeneralSettings import eu.darken.capod.main.core.currentThemeState -import eu.darken.capod.pods.core.PodDevice +import eu.darken.capod.monitor.core.MonitoredDevice import javax.inject.Inject import javax.inject.Singleton @@ -50,9 +50,9 @@ class PopUpWindow @Inject constructor( private var composeView: ComposeView? = null private var lifecycleOwner: OverlayLifecycleOwner? = null - private var deviceState: MutableState? = null + private var deviceState: MutableState? = null - fun show(device: PodDevice) { + fun show(device: MonitoredDevice) { try { log(TAG) { "open()" } @@ -64,7 +64,7 @@ class PopUpWindow @Inject constructor( teardown() - val state = mutableStateOf(device) + val state = mutableStateOf(device) deviceState = state val owner = OverlayLifecycleOwner() diff --git a/app/src/main/java/eu/darken/capod/troubleshooter/ui/TroubleShooterViewModel.kt b/app/src/main/java/eu/darken/capod/troubleshooter/ui/TroubleShooterViewModel.kt index a50f1082..ca46ed70 100644 --- a/app/src/main/java/eu/darken/capod/troubleshooter/ui/TroubleShooterViewModel.kt +++ b/app/src/main/java/eu/darken/capod/troubleshooter/ui/TroubleShooterViewModel.kt @@ -13,6 +13,7 @@ import eu.darken.capod.common.debug.logging.logTag import eu.darken.capod.common.uix.ViewModel4 import eu.darken.capod.main.core.GeneralSettings +import eu.darken.capod.monitor.core.DeviceMonitor import eu.darken.capod.monitor.core.PodMonitor import eu.darken.capod.monitor.core.primaryDevice import eu.darken.capod.pods.core.PodDevice @@ -39,6 +40,7 @@ class TroubleShooterViewModel @Inject constructor( private val generalSettings: GeneralSettings, private val profilesRepo: DeviceProfilesRepo, private val podMonitor: PodMonitor, + private val deviceMonitor: DeviceMonitor, private val debugSettings: DebugSettings, ) : ViewModel4(dispatcherProvider) { @@ -86,7 +88,7 @@ class TroubleShooterViewModel @Inject constructor( run { progress("Checking for headphones...") val mainDevice = withTimeoutOrNull(STEP_TIME) { - podMonitor.primaryDevice().filterNotNull().firstOrNull() + deviceMonitor.primaryDevice().filterNotNull().firstOrNull() } if (mainDevice != null) { success("Headphones found, nothing to troubleshoot.") @@ -179,7 +181,7 @@ class TroubleShooterViewModel @Inject constructor( run { progress("Checking for your headphones with new BLE settings...") val mainDevice = withTimeoutOrNull(STEP_TIME) { - podMonitor.primaryDevice().filterNotNull().firstOrNull() + deviceMonitor.primaryDevice().filterNotNull().firstOrNull() } if (mainDevice != null) { success("Found your headphones, new BLE settings worked :)!") @@ -227,7 +229,7 @@ class TroubleShooterViewModel @Inject constructor( ) val mainDevice = withTimeoutOrNull(STEP_TIME) { - podMonitor.primaryDevice().filterNotNull().firstOrNull() + deviceMonitor.primaryDevice().filterNotNull().firstOrNull() } generalSettings.scannerMode.valueBlocking = ScannerMode.BALANCED diff --git a/app/src/test/java/eu/darken/capod/main/ui/overview/OverviewViewModelTest.kt b/app/src/test/java/eu/darken/capod/main/ui/overview/OverviewViewModelTest.kt index 723567f3..11a34fb3 100644 --- a/app/src/test/java/eu/darken/capod/main/ui/overview/OverviewViewModelTest.kt +++ b/app/src/test/java/eu/darken/capod/main/ui/overview/OverviewViewModelTest.kt @@ -8,7 +8,8 @@ import eu.darken.capod.common.upgrade.UpgradeRepo import eu.darken.capod.main.core.GeneralSettings import eu.darken.capod.main.core.MonitorMode import eu.darken.capod.main.core.PermissionTool -import eu.darken.capod.monitor.core.PodMonitor +import eu.darken.capod.monitor.core.DeviceMonitor +import eu.darken.capod.monitor.core.MonitoredDevice import eu.darken.capod.monitor.core.worker.MonitorControl import eu.darken.capod.pods.core.PodDevice import eu.darken.capod.profiles.core.AppleDeviceProfile @@ -44,7 +45,7 @@ class OverviewViewModelTest : BaseTest() { private val testDispatcher = UnconfinedTestDispatcher() private lateinit var monitorControl: MonitorControl - private lateinit var podMonitor: PodMonitor + private lateinit var deviceMonitor: DeviceMonitor private lateinit var permissionTool: PermissionTool private lateinit var generalSettings: GeneralSettings private lateinit var debugSettings: DebugSettings @@ -53,7 +54,7 @@ class OverviewViewModelTest : BaseTest() { private lateinit var profilesRepo: DeviceProfilesRepo private lateinit var missingPermissionsFlow: MutableStateFlow> - private lateinit var devicesFlow: MutableStateFlow> + private lateinit var devicesFlow: MutableStateFlow> private lateinit var connectedDevicesFlow: MutableStateFlow> private lateinit var isBluetoothEnabledFlow: MutableStateFlow private lateinit var profilesFlow: MutableStateFlow> @@ -76,7 +77,7 @@ class OverviewViewModelTest : BaseTest() { monitorControl = mockk(relaxed = true) - podMonitor = mockk().also { + deviceMonitor = mockk().also { every { it.devices } returns devicesFlow } @@ -117,7 +118,7 @@ class OverviewViewModelTest : BaseTest() { private fun createViewModel() = OverviewViewModel( dispatcherProvider = TestDispatcherProvider(testDispatcher), monitorControl = monitorControl, - podMonitor = podMonitor, + deviceMonitor = deviceMonitor, permissionTool = permissionTool, generalSettings = generalSettings, debugSettings = debugSettings, @@ -153,7 +154,7 @@ class OverviewViewModelTest : BaseTest() { @Test fun `devices passed through when permissions granted`() = runTest(testDispatcher) { - val device = mockk(relaxed = true) + val device = MonitoredDevice(ble = mockk(relaxed = true), aap = null) devicesFlow.value = listOf(device) val vm = createViewModel() @@ -170,8 +171,14 @@ class OverviewViewModelTest : BaseTest() { val withoutProfile = object : PodDevice.Meta { override val profile: DeviceProfile? = null } - val profiled = mockk(relaxed = true) { every { meta } returns withProfile } - val unmatched = mockk(relaxed = true) { every { meta } returns withoutProfile } + val profiled = MonitoredDevice( + ble = mockk(relaxed = true) { every { meta } returns withProfile }, + aap = null, + ) + val unmatched = MonitoredDevice( + ble = mockk(relaxed = true) { every { meta } returns withoutProfile }, + aap = null, + ) val state = OverviewViewModel.State( now = java.time.Instant.now(), @@ -195,8 +202,14 @@ class OverviewViewModelTest : BaseTest() { val withoutProfile = object : PodDevice.Meta { override val profile: DeviceProfile? = null } - val profiled = mockk(relaxed = true) { every { meta } returns withProfile } - val unmatched = mockk(relaxed = true) { every { meta } returns withoutProfile } + val profiled = MonitoredDevice( + ble = mockk(relaxed = true) { every { meta } returns withProfile }, + aap = null, + ) + val unmatched = MonitoredDevice( + ble = mockk(relaxed = true) { every { meta } returns withoutProfile }, + aap = null, + ) val state = OverviewViewModel.State( now = java.time.Instant.now(), diff --git a/app/src/test/java/eu/darken/capod/monitor/core/MonitoredDeviceTest.kt b/app/src/test/java/eu/darken/capod/monitor/core/MonitoredDeviceTest.kt index 658081a1..7d838d8b 100644 --- a/app/src/test/java/eu/darken/capod/monitor/core/MonitoredDeviceTest.kt +++ b/app/src/test/java/eu/darken/capod/monitor/core/MonitoredDeviceTest.kt @@ -1,5 +1,10 @@ package eu.darken.capod.monitor.core +import eu.darken.capod.pods.core.HasCase +import eu.darken.capod.pods.core.HasChargeDetectionDual +import eu.darken.capod.pods.core.HasDualMicrophone +import eu.darken.capod.pods.core.HasEarDetection +import eu.darken.capod.pods.core.HasEarDetectionDual import eu.darken.capod.pods.core.PodDevice import eu.darken.capod.pods.core.apple.DualApplePods import eu.darken.capod.pods.core.apple.protocol.aap.AapConnectionState @@ -13,6 +18,7 @@ import io.mockk.every import io.mockk.mockk import org.junit.jupiter.api.Test import testhelpers.BaseTest +import java.time.Instant class MonitoredDeviceTest : BaseTest() { @@ -88,4 +94,102 @@ class MonitoredDeviceTest : BaseTest() { val device = MonitoredDevice(ble = null, aap = null) device.model shouldBe PodDevice.Model.UNKNOWN } + + @Test + fun `identity properties delegate to BLE`() { + val id = PodDevice.Id() + val meta = mockk(relaxed = true) + val device = MonitoredDevice( + ble = mockk(relaxed = true) { + every { identifier } returns id + every { this@mockk.meta } returns meta + }, + aap = null, + ) + device.identifier shouldBe id + device.meta shouldBe meta + } + + @Test + fun `identity properties null when BLE null`() { + val device = MonitoredDevice(ble = null, aap = null) + device.identifier.shouldBeNull() + device.meta.shouldBeNull() + } + + @Test + fun `signal timing properties delegate to BLE`() { + val now = Instant.now() + val earlier = now.minusSeconds(60) + val device = MonitoredDevice( + ble = mockk(relaxed = true) { + every { seenLastAt } returns now + every { seenFirstAt } returns earlier + every { signalQuality } returns 0.75f + every { rssi } returns -50 + }, + aap = null, + ) + device.seenLastAt shouldBe now + device.seenFirstAt shouldBe earlier + device.signalQuality shouldBe 0.75f + device.rssi shouldBe -50 + } + + @Test + fun `signal timing defaults when BLE null`() { + val device = MonitoredDevice(ble = null, aap = null) + device.seenLastAt.shouldBeNull() + device.seenFirstAt.shouldBeNull() + device.signalQuality shouldBe 0f + device.rssi shouldBe 0 + } + + @Test + fun `charging properties delegate to BLE interfaces`() { + val mock = mockk(relaxed = true) { + every { model } returns PodDevice.Model.AIRPODS_PRO3 + every { (this@mockk as HasChargeDetectionDual).isLeftPodCharging } returns true + every { (this@mockk as HasChargeDetectionDual).isRightPodCharging } returns false + every { (this@mockk as HasCase).isCaseCharging } returns true + } + val device = MonitoredDevice(ble = mock, aap = null) + device.isLeftPodCharging shouldBe true + device.isRightPodCharging shouldBe false + device.isCaseCharging shouldBe true + } + + @Test + fun `ear detection properties delegate to BLE interfaces`() { + val mock = mockk(relaxed = true) { + every { model } returns PodDevice.Model.AIRPODS_PRO3 + every { (this@mockk as HasEarDetectionDual).isLeftPodInEar } returns true + every { (this@mockk as HasEarDetectionDual).isRightPodInEar } returns false + every { (this@mockk as HasEarDetection).isBeingWorn } returns false + every { (this@mockk as HasEarDetectionDual).isEitherPodInEar } returns true + } + val device = MonitoredDevice(ble = mock, aap = null) + device.isLeftInEar shouldBe true + device.isRightInEar shouldBe false + device.isBeingWorn shouldBe false + device.isEitherPodInEar shouldBe true + } + + @Test + fun `icon and label properties delegate to BLE`() { + val device = MonitoredDevice( + ble = mockk(relaxed = true) { + every { model } returns PodDevice.Model.AIRPODS_PRO3 + every { iconRes } returns 42 + }, + aap = null, + ) + device.iconRes shouldBe 42 + } + + @Test + fun `rawDataHex empty when BLE null`() { + val device = MonitoredDevice(ble = null, aap = null) + device.rawDataHex shouldBe emptyList() + } } diff --git a/app/src/test/java/eu/darken/capod/reaction/core/autoconnect/AutoConnectLogicTest.kt b/app/src/test/java/eu/darken/capod/reaction/core/autoconnect/AutoConnectLogicTest.kt index 19b77156..6c5d7fa0 100644 --- a/app/src/test/java/eu/darken/capod/reaction/core/autoconnect/AutoConnectLogicTest.kt +++ b/app/src/test/java/eu/darken/capod/reaction/core/autoconnect/AutoConnectLogicTest.kt @@ -16,7 +16,7 @@ class AutoConnectLogicTest : BaseTest() { fun setup() { autoConnect = AutoConnect( bluetoothManager = mockk(relaxed = true), - podMonitor = mockk(relaxed = true), + deviceMonitor = mockk(relaxed = true), generalSettings = mockk(relaxed = true), reactionSettings = mockk(relaxed = true), deviceProfilesRepo = mockk(relaxed = true), diff --git a/app/src/test/java/eu/darken/capod/reaction/core/playpause/PlayPauseLogicTest.kt b/app/src/test/java/eu/darken/capod/reaction/core/playpause/PlayPauseLogicTest.kt index 53773699..0aecba39 100644 --- a/app/src/test/java/eu/darken/capod/reaction/core/playpause/PlayPauseLogicTest.kt +++ b/app/src/test/java/eu/darken/capod/reaction/core/playpause/PlayPauseLogicTest.kt @@ -16,7 +16,7 @@ class PlayPauseLogicTest : BaseTest() { fun setup() { // Create PlayPause instance with mocked dependencies (relaxed so we don't need to stub everything) playPause = PlayPause( - podMonitor = mockk(relaxed = true), + deviceMonitor = mockk(relaxed = true), bluetoothManager = mockk(relaxed = true), reactionSettings = mockk(relaxed = true), mediaControl = mockk(relaxed = true) diff --git a/app/src/test/java/eu/darken/capod/reaction/core/popup/PopUpReactionLogicTest.kt b/app/src/test/java/eu/darken/capod/reaction/core/popup/PopUpReactionLogicTest.kt index c5c2db03..1ffd3757 100644 --- a/app/src/test/java/eu/darken/capod/reaction/core/popup/PopUpReactionLogicTest.kt +++ b/app/src/test/java/eu/darken/capod/reaction/core/popup/PopUpReactionLogicTest.kt @@ -17,7 +17,7 @@ class PopUpReactionLogicTest : BaseTest() { @BeforeEach fun setup() { popUpReaction = PopUpReaction( - podMonitor = mockk(relaxed = true), + deviceMonitor = mockk(relaxed = true), reactionSettings = mockk(relaxed = true), bluetoothManager = mockk(relaxed = true), )