mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
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:
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user