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.
This commit is contained in:
darken
2026-03-31 19:17:09 +02:00
committed by Matthias Urhahn
parent c19ee88ae6
commit ac21261d4d
30 changed files with 563 additions and 368 deletions
@@ -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) {
@@ -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),
)
+3 -3
View File
@@ -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 {
@@ -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].
@@ -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(
@@ -33,4 +33,5 @@ class AndroidModule {
@Singleton
fun audioManager(context: Context): AudioManager =
context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
}
@@ -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,
@@ -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<Permission>,
val devices: List<PodDevice>,
val devices: List<MonitoredDevice>,
val isDebugMode: Boolean,
val isBluetoothEnabled: Boolean,
val profiles: List<DeviceProfile>,
@@ -130,8 +130,8 @@ class OverviewViewModel @Inject constructor(
val showUnmatchedDevices: Boolean,
) {
val isScanBlocked: Boolean get() = permissions.any { it.isScanBlocking }
val profiledDevices: List<PodDevice> get() = devices.filter { it.meta.profile != null }
val unmatchedDevices: List<PodDevice> get() = devices.filter { it.meta.profile == null }
val profiledDevices: List<MonitoredDevice> get() = devices.filter { it.meta?.profile != null }
val unmatchedDevices: List<MonitoredDevice> get() = devices.filter { it.meta?.profile == null }
}
fun onPermissionResult(@Suppress("UNUSED_PARAMETER") granted: Boolean) {
@@ -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())
}
@@ -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())
}
@@ -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())
}
@@ -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
@@ -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,
@@ -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")
}
}
@@ -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<List<MonitoredDevice>> = devices
.map { devices -> devices.filter { it.meta?.profile != null } }
fun DeviceMonitor.primaryDevice(): Flow<MonitoredDevice?> = 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
)
}
@@ -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<String> get() = ble?.rawDataHex ?: emptyList()
// AAP controls
val ancMode: AapSetting.AncMode?
get() = aap?.setting()
@@ -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)
@@ -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)
}
}
}
@@ -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 =
@@ -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")
}
}
}
@@ -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")
}
}
}
@@ -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<Event> = 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")
}
}
}
@@ -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 = {})
}
@@ -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<PodDevice?>? = null
private var deviceState: MutableState<MonitoredDevice?>? = 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<PodDevice?>(device)
val state = mutableStateOf<MonitoredDevice?>(device)
deviceState = state
val owner = OverlayLifecycleOwner()
@@ -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
@@ -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<Set<Permission>>
private lateinit var devicesFlow: MutableStateFlow<List<PodDevice>>
private lateinit var devicesFlow: MutableStateFlow<List<MonitoredDevice>>
private lateinit var connectedDevicesFlow: MutableStateFlow<List<BluetoothDevice2>>
private lateinit var isBluetoothEnabledFlow: MutableStateFlow<Boolean>
private lateinit var profilesFlow: MutableStateFlow<List<DeviceProfile>>
@@ -76,7 +77,7 @@ class OverviewViewModelTest : BaseTest() {
monitorControl = mockk(relaxed = true)
podMonitor = mockk<PodMonitor>().also {
deviceMonitor = mockk<DeviceMonitor>().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<PodDevice>(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<PodDevice>(relaxed = true) { every { meta } returns withProfile }
val unmatched = mockk<PodDevice>(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<PodDevice>(relaxed = true) { every { meta } returns withProfile }
val unmatched = mockk<PodDevice>(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(),
@@ -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<PodDevice.Meta>(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<DualApplePods>(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<DualApplePods>(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()
}
}
@@ -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),
@@ -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)
@@ -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),
)