refactor: Rename PodDevice to BlePodSnapshot, MonitoredDevice to PodDevice, extract PodModel

Complete type rename chain: PodDevice (interface) becomes BlePodSnapshot, MonitoredDevice (facade) claims PodDevice name, Model enum extracted to top-level PodModel. Delete redundant type alias files. Fix stale comments and log tags.
This commit is contained in:
darken
2026-03-31 19:17:09 +02:00
committed by Matthias Urhahn
parent e84f8762b4
commit 3fa01bb510
68 changed files with 631 additions and 647 deletions
@@ -4,20 +4,20 @@ 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.monitor.core.PodDevice
import eu.darken.capod.pods.core.BlePodSnapshot
import eu.darken.capod.pods.core.DualBlePodSnapshot
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.PodModel
import eu.darken.capod.pods.core.SinglePodDevice
import eu.darken.capod.pods.core.SingleBlePodSnapshot
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
import eu.darken.capod.pods.core.unknown.UnknownDevice
import eu.darken.capod.pods.core.unknown.UnknownSnapshotBle
import eu.darken.capod.profiles.core.AppleDeviceProfile
import eu.darken.capod.profiles.core.DeviceProfile
import java.time.Instant
@@ -39,7 +39,7 @@ object MockPodDataProvider {
// --- Dual pod scenarios ---
fun airPodsProFullCharge(): DualPodDevice = MockDualPodDevice(
fun airPodsProFullCharge(): DualBlePodSnapshot = MockDualBlePodSnapshot(
_model = PodModel.AIRPODS_PRO2,
_label = "Work AirPods",
batteryLeftPodPercent = 1.0f,
@@ -50,7 +50,7 @@ object MockPodDataProvider {
_caseIcon = R.drawable.device_airpods_pro2_case,
)
fun airPodsProMixed(): DualPodDevice = MockDualPodDevice(
fun airPodsProMixed(): DualBlePodSnapshot = MockDualBlePodSnapshot(
_model = PodModel.AIRPODS_PRO2,
_label = "My AirPods Pro",
batteryLeftPodPercent = 0.80f,
@@ -62,7 +62,7 @@ object MockPodDataProvider {
_caseIcon = R.drawable.device_airpods_pro2_case,
)
fun airPodsProLowBattery(): DualPodDevice = MockDualPodDevice(
fun airPodsProLowBattery(): DualBlePodSnapshot = MockDualBlePodSnapshot(
_model = PodModel.AIRPODS_PRO2,
_label = "My AirPods Pro",
batteryLeftPodPercent = 0.10f,
@@ -75,7 +75,7 @@ object MockPodDataProvider {
_caseIcon = R.drawable.device_airpods_pro2_case,
)
fun airPodsProInCase(): DualPodDevice = MockDualPodDevice(
fun airPodsProInCase(): DualBlePodSnapshot = MockDualBlePodSnapshot(
_model = PodModel.AIRPODS_PRO2,
_label = "My AirPods Pro",
batteryLeftPodPercent = null,
@@ -86,7 +86,7 @@ object MockPodDataProvider {
_caseIcon = R.drawable.device_airpods_pro2_case,
)
fun airPodsGen1Wearing(): DualPodDevice = MockDualPodDevice(
fun airPodsGen1Wearing(): DualBlePodSnapshot = MockDualBlePodSnapshot(
_model = PodModel.AIRPODS_GEN1,
_label = "Old AirPods",
batteryLeftPodPercent = 0.70f,
@@ -100,7 +100,7 @@ object MockPodDataProvider {
_caseIcon = R.drawable.device_airpods_gen1_case,
)
fun powerBeatsPro(): DualPodDevice = MockDualPodDeviceNoCase(
fun powerBeatsPro(): DualBlePodSnapshot = MockDualBlePodSnapshotNoCase(
_model = PodModel.POWERBEATS_PRO,
_label = "PowerBeats Pro",
batteryLeftPodPercent = 0.55f,
@@ -111,14 +111,14 @@ object MockPodDataProvider {
// --- Single pod scenarios ---
fun airPodsMax(): SinglePodDevice = MockSinglePodDevice(
fun airPodsMax(): SingleBlePodSnapshot = MockSingleBlePodSnapshot(
_model = PodModel.AIRPODS_MAX,
_label = "AirPods Max",
batteryHeadsetPercent = 0.85f,
_isBeingWorn = true,
)
fun airPodsMaxCharging(): SinglePodDevice = MockSingleApplePodDevice(
fun airPodsMaxCharging(): SingleBlePodSnapshot = MockSingleAppleBlePodSnapshot(
_model = PodModel.AIRPODS_MAX,
_label = "AirPods Max",
batteryHeadsetPercent = 0.40f,
@@ -127,7 +127,7 @@ object MockPodDataProvider {
_hasPrivatePayload = true,
)
fun beatsSolo3(): SinglePodDevice = MockSinglePodDevice(
fun beatsSolo3(): SingleBlePodSnapshot = MockSingleBlePodSnapshot(
_model = PodModel.BEATS_SOLO_3,
_label = "Beats Solo 3",
batteryHeadsetPercent = 0.70f,
@@ -135,7 +135,7 @@ object MockPodDataProvider {
// --- Unknown device ---
fun unknownDevice(): PodDevice = UnknownDevice(
fun unknownDevice(): BlePodSnapshot = UnknownSnapshotBle(
scanResult = dummyScanResult(rssi = -70),
)
@@ -146,24 +146,24 @@ object MockPodDataProvider {
model = model,
)
// --- MonitoredDevice wrappers ---
// --- PodDevice wrappers ---
fun dualPodMonitored(): MonitoredDevice = MonitoredDevice(
fun dualPodMonitored(): PodDevice = PodDevice(
ble = airPodsProFullCharge(),
aap = null,
)
fun dualPodMonitoredMixed(): MonitoredDevice = MonitoredDevice(
fun dualPodMonitoredMixed(): PodDevice = PodDevice(
ble = airPodsProMixed(),
aap = null,
)
fun singlePodMonitored(): MonitoredDevice = MonitoredDevice(
fun singlePodMonitored(): PodDevice = PodDevice(
ble = airPodsMax(),
aap = null,
)
fun unknownMonitored(): MonitoredDevice = MonitoredDevice(
fun unknownMonitored(): PodDevice = PodDevice(
ble = unknownDevice(),
aap = null,
)
@@ -188,7 +188,7 @@ private data class MockUpgradeInfo(
override val error: Throwable? = null,
) : UpgradeRepo.Info
private class MockDualPodDevice(
private class MockDualBlePodSnapshot(
private val _model: PodModel,
private val _label: String,
override val batteryLeftPodPercent: Float?,
@@ -205,8 +205,8 @@ private class MockDualPodDevice(
override val rightPodIcon: Int = R.drawable.device_airpods_gen1_right,
private val _caseIcon: Int = R.drawable.device_airpods_gen1_case,
rssi: Int = -50,
) : DualPodDevice, HasCase, HasChargeDetectionDual, HasEarDetectionDual, HasDualMicrophone {
override val identifier: PodDevice.Id = PodDevice.Id()
) : DualBlePodSnapshot, HasCase, HasChargeDetectionDual, HasEarDetectionDual, HasDualMicrophone {
override val identifier: BlePodSnapshot.Id = BlePodSnapshot.Id()
override val model: PodModel = _model
override val seenLastAt: Instant = MOCK_NOW
override val seenFirstAt: Instant = MOCK_NOW
@@ -215,7 +215,7 @@ private class MockDualPodDevice(
override val reliability: Float = 1.0f
override val signalQuality: Float = 0.75f
override val iconRes: Int = _model.iconRes
override val meta: PodDevice.Meta = object : PodDevice.Meta {
override val meta: BlePodSnapshot.Meta = object : BlePodSnapshot.Meta {
override val profile: DeviceProfile = AppleDeviceProfile(label = _label, model = _model)
}
@@ -239,7 +239,7 @@ private class MockDualPodDevice(
override val isRightPodMicrophone: Boolean = _isRightPodMicrophone
}
private class MockDualPodDeviceNoCase(
private class MockDualBlePodSnapshotNoCase(
private val _model: PodModel,
private val _label: String,
override val batteryLeftPodPercent: Float?,
@@ -247,8 +247,8 @@ private class MockDualPodDeviceNoCase(
override val leftPodIcon: Int = R.drawable.device_airpods_gen1_left,
override val rightPodIcon: Int = R.drawable.device_airpods_gen1_right,
rssi: Int = -50,
) : DualPodDevice, HasChargeDetectionDual, HasEarDetectionDual {
override val identifier: PodDevice.Id = PodDevice.Id()
) : DualBlePodSnapshot, HasChargeDetectionDual, HasEarDetectionDual {
override val identifier: BlePodSnapshot.Id = BlePodSnapshot.Id()
override val model: PodModel = _model
override val seenLastAt: Instant = MOCK_NOW
override val seenFirstAt: Instant = MOCK_NOW
@@ -257,7 +257,7 @@ private class MockDualPodDeviceNoCase(
override val reliability: Float = 1.0f
override val signalQuality: Float = 0.70f
override val iconRes: Int = _model.iconRes
override val meta: PodDevice.Meta = object : PodDevice.Meta {
override val meta: BlePodSnapshot.Meta = object : BlePodSnapshot.Meta {
override val profile: DeviceProfile = AppleDeviceProfile(label = _label, model = _model)
}
@@ -272,15 +272,15 @@ private class MockDualPodDeviceNoCase(
override val isRightPodInEar: Boolean = false
}
private class MockSinglePodDevice(
private class MockSingleBlePodSnapshot(
private val _model: PodModel,
private val _label: String,
override val batteryHeadsetPercent: Float?,
private val _isHeadsetBeingCharged: Boolean = false,
private val _isBeingWorn: Boolean = false,
rssi: Int = -50,
) : SinglePodDevice, HasChargeDetection, HasEarDetection {
override val identifier: PodDevice.Id = PodDevice.Id()
) : SingleBlePodSnapshot, HasChargeDetection, HasEarDetection {
override val identifier: BlePodSnapshot.Id = BlePodSnapshot.Id()
override val model: PodModel = _model
override val seenLastAt: Instant = MOCK_NOW
override val seenFirstAt: Instant = MOCK_NOW
@@ -289,7 +289,7 @@ private class MockSinglePodDevice(
override val reliability: Float = 1.0f
override val signalQuality: Float = 0.80f
override val iconRes: Int = _model.iconRes
override val meta: PodDevice.Meta = object : PodDevice.Meta {
override val meta: BlePodSnapshot.Meta = object : BlePodSnapshot.Meta {
override val profile: DeviceProfile = AppleDeviceProfile(label = _label, model = _model)
}
@@ -303,7 +303,7 @@ private class MockSinglePodDevice(
}
@Suppress("PropertyName")
private class MockSingleApplePodDevice(
private class MockSingleAppleBlePodSnapshot(
private val _model: PodModel,
private val _label: String,
override val batteryHeadsetPercent: Float?,
@@ -312,8 +312,8 @@ private class MockSingleApplePodDevice(
private val _isIRKMatch: Boolean = false,
private val _hasPrivatePayload: Boolean = false,
rssi: Int = -50,
) : SinglePodDevice, ApplePods, HasChargeDetection, HasEarDetection {
override val identifier: PodDevice.Id = PodDevice.Id()
) : SingleBlePodSnapshot, ApplePods, HasChargeDetection, HasEarDetection {
override val identifier: BlePodSnapshot.Id = BlePodSnapshot.Id()
override val model: PodModel = _model
override val seenLastAt: Instant = MOCK_NOW
override val seenFirstAt: Instant = MOCK_NOW
@@ -53,7 +53,7 @@ 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.monitor.core.MonitoredDevice
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.pods.core.PodModel
import java.time.Instant
@@ -257,7 +257,7 @@ fun OverviewScreen(
}
@Composable
private fun PodDeviceCard(device: MonitoredDevice, showDebug: Boolean, now: Instant) {
private fun PodDeviceCard(device: PodDevice, showDebug: Boolean, now: Instant) {
when {
device.hasDualPods -> DualPodsCard(device = device, showDebug = showDebug, now = now)
device.model != PodModel.UNKNOWN -> SinglePodsCard(device = device, showDebug = showDebug, now = now)
@@ -3,12 +3,12 @@ package eu.darken.capod.main.ui.overview
import dagger.hilt.android.lifecycle.HiltViewModel
import eu.darken.capod.common.bluetooth.BluetoothManager2
import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.datastore.valueBlocking
import eu.darken.capod.common.debug.DebugSettings
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.flow.SingleEventFlow
import eu.darken.capod.common.flow.combine
import eu.darken.capod.common.flow.throttleLatest
import eu.darken.capod.common.navigation.Nav
import eu.darken.capod.common.permissions.Permission
@@ -18,11 +18,10 @@ 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.MonitoredDevice
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.monitor.core.worker.MonitorControl
import eu.darken.capod.profiles.core.DeviceProfile
import eu.darken.capod.profiles.core.DeviceProfilesRepo
import java.time.Instant
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.catch
@@ -33,8 +32,8 @@ import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.isActive
import kotlinx.coroutines.withTimeoutOrNull
import java.time.Instant
import javax.inject.Inject
import eu.darken.capod.common.datastore.valueBlocking
@HiltViewModel
class OverviewViewModel @Inject constructor(
@@ -71,6 +70,7 @@ class OverviewViewModel @Inject constructor(
false
}
}
MonitorMode.ALWAYS -> true
}
if (shouldStart) {
@@ -122,7 +122,7 @@ class OverviewViewModel @Inject constructor(
data class State(
val now: Instant,
val permissions: Set<Permission>,
val devices: List<MonitoredDevice>,
val devices: List<PodDevice>,
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<MonitoredDevice> get() = devices.filter { it.meta?.profile != null }
val unmatchedDevices: List<MonitoredDevice> get() = devices.filter { it.meta?.profile == null }
val profiledDevices: List<PodDevice> get() = devices.filter { it.meta?.profile != null }
val unmatchedDevices: List<PodDevice> get() = devices.filter { it.meta?.profile == null }
}
fun onPermissionResult(@Suppress("UNUSED_PARAMETER") granted: Boolean) {
@@ -44,10 +44,10 @@ 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.monitor.core.MonitoredDevice
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.monitor.core.firstSeenFormatted
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
@@ -61,7 +61,7 @@ import java.time.Instant
@Composable
fun DualPodsCard(
device: MonitoredDevice,
device: PodDevice,
showDebug: Boolean,
now: Instant,
) {
@@ -316,7 +316,7 @@ private fun PodGauge(
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun CaseRow(
device: MonitoredDevice,
device: PodDevice,
) {
val context = LocalContext.current
@@ -24,9 +24,9 @@ import androidx.compose.material.icons.twotone.BatteryChargingFull
import androidx.compose.material.icons.twotone.Hearing
import androidx.compose.material.icons.twotone.Key
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
@@ -44,10 +44,10 @@ 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.monitor.core.MonitoredDevice
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.monitor.core.firstSeenFormatted
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.formatBatteryPercent
import java.time.Duration
@@ -56,7 +56,7 @@ import java.time.Instant
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun SinglePodsCard(
device: MonitoredDevice,
device: PodDevice,
showDebug: Boolean,
now: Instant,
) {
@@ -21,18 +21,18 @@ 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.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.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper
import eu.darken.capod.common.compose.preview.MockPodDataProvider
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.monitor.core.getSignalQuality
import eu.darken.capod.monitor.core.lastSeenFormatted
import eu.darken.capod.pods.core.apple.ApplePods
import java.time.Instant
@Composable
fun UnknownPodDeviceCard(
device: MonitoredDevice,
device: PodDevice,
showDebug: Boolean,
now: Instant,
) {
@@ -2,6 +2,7 @@ package eu.darken.capod.main.ui.widget
import android.appwidget.AppWidgetManager
import android.content.Context
import androidx.annotation.Keep
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.glance.GlanceId
@@ -10,7 +11,6 @@ import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.GlanceAppWidgetManager
import androidx.glance.appwidget.SizeMode
import androidx.glance.appwidget.provideContent
import androidx.annotation.Keep
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
@@ -23,7 +23,7 @@ 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.DeviceMonitor
import eu.darken.capod.monitor.core.MonitoredDevice
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.profiles.core.DeviceProfilesRepo
class BatteryGlanceWidget : GlanceAppWidget() {
@@ -45,7 +45,7 @@ class BatteryGlanceWidget : GlanceAppWidget() {
val appWidgetId: Int
val initialIsPro: Boolean
val initialProfileId: String?
val cachedDevice: MonitoredDevice?
val cachedDevice: PodDevice?
try {
ep = EntryPointAccessors.fromApplication(context, WidgetEntryPoint::class.java)
@@ -139,7 +139,7 @@ class BatteryGlanceWidget : GlanceAppWidget() {
var n = 2
while (70 * n - 30 < size) {
++n
}
}
return n - 1
}
@@ -4,7 +4,7 @@ import android.content.Context
import androidx.annotation.ColorInt
import androidx.appcompat.view.ContextThemeWrapper
import eu.darken.capod.R
import eu.darken.capod.monitor.core.MonitoredDevice
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.pods.core.PodModel
import eu.darken.capod.pods.core.getBatteryDrawable
import eu.darken.capod.pods.core.toBatteryFloat
@@ -13,7 +13,7 @@ object WidgetRenderStateMapper {
fun map(
context: Context,
device: MonitoredDevice?,
device: PodDevice?,
theme: WidgetTheme,
isPro: Boolean,
hasConfiguredProfile: Boolean,
@@ -1,12 +1,217 @@
package eu.darken.capod.monitor.core
/**
* Type alias for the BLE scan pipeline.
*
* The [PodMonitor] class handles BLE advertisement scanning and device detection.
* New code should use [BlePodMonitor] to clarify that this is the BLE-only monitor,
* not the unified merge point ([DeviceMonitor]).
*
* Full rename deferred to IDE refactoring pass.
*/
typealias BlePodMonitor = PodMonitor
import android.bluetooth.le.ScanFilter
import eu.darken.capod.common.bluetooth.BleScanner
import eu.darken.capod.common.bluetooth.BluetoothManager2
import eu.darken.capod.common.bluetooth.ScannerMode
import eu.darken.capod.common.bluetooth.onlyNewAndUnique
import eu.darken.capod.common.coroutine.AppScope
import eu.darken.capod.common.debug.DebugSettings
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
import eu.darken.capod.common.debug.logging.asLog
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.flow.replayingShare
import eu.darken.capod.common.flow.throttleLatest
import eu.darken.capod.main.core.GeneralSettings
import eu.darken.capod.main.core.PermissionTool
import eu.darken.capod.pods.core.BlePodSnapshot
import eu.darken.capod.pods.core.PodFactory
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import eu.darken.capod.profiles.core.DeviceProfilesRepo
import eu.darken.capod.profiles.core.currentProfiles
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.retryWhen
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.time.Duration
import java.time.Instant
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class BlePodMonitor @Inject constructor(
@AppScope private val appScope: CoroutineScope,
private val bleScanner: BleScanner,
private val podFactory: PodFactory,
private val generalSettings: GeneralSettings,
bluetoothManager: BluetoothManager2,
private val debugSettings: DebugSettings,
private val podDeviceCache: PodDeviceCache,
permissionTool: PermissionTool,
private val profilesRepo: DeviceProfilesRepo,
) {
private val deviceCache = mutableMapOf<BlePodSnapshot.Id, BlePodSnapshot>()
private val cacheLock = Mutex()
val devices: Flow<List<BlePodSnapshot>> = combine(
permissionTool.missingScanPermissions,
bluetoothManager.isBluetoothEnabled
) { missingScanPermissions, isBluetoothEnabled ->
log(TAG) { "devices: missingScanPermissions=$missingScanPermissions, isBluetoothEnabled=$isBluetoothEnabled" }
missingScanPermissions.isEmpty() && isBluetoothEnabled
}
.flatMapLatest { isReady ->
if (!isReady) {
log(TAG, WARN) { "Bluetooth is not ready" }
flowOf(null)
} else {
createBleScanner()
}
}
.map { results -> results?.mapNotNull { podFactory.createPod(it) } }
.map { processWithCache(it).values }
.flatMapLatest { devices ->
flowOf(sortPodsToInterest(devices))
}
.retryWhen { cause, attempt ->
if (cause is SecurityException) {
log(TAG, WARN) { "PodMonitor failed due to missing permission, not retrying: ${cause.asLog()}" }
false
} else {
log(TAG, WARN) { "PodMonitor failed (attempt=$attempt), will retry: ${cause.asLog()}" }
delay(3000)
true
}
}
.onStart { emit(emptyList()) }
.replayingShare(appScope)
private suspend fun sortPodsToInterest(devices: Collection<BlePodSnapshot>): List<BlePodSnapshot> {
val now = Instant.now()
val profiles = profilesRepo.currentProfiles() ?: emptyList()
return devices.sortedWith(
compareBy<BlePodSnapshot> { device ->
// Use profile position in list as priority (0 = highest priority)
device.meta.profile?.let { profile ->
profiles.indexOfFirst { it.id == profile.id }.takeIf { it >= 0 } ?: Int.MAX_VALUE
} ?: Int.MAX_VALUE
}
.thenBy {
val age = Duration.between(it.seenLastAt, now).seconds
if (age < 5) 0L else (age / 3L)
}
.thenByDescending { it.signalQuality }
.thenByDescending { (it.seenCounter / 10) }
)
}
private data class ScannerOptions(
val scannerMode: ScannerMode,
val showUnfiltered: Boolean,
val offloadedFilteringDisabled: Boolean,
val offloadedBatchingDisabled: Boolean,
val disableDirectCallback: Boolean,
)
private fun createBleScanner() = combine(
generalSettings.scannerMode.flow,
debugSettings.showUnfiltered.flow,
generalSettings.isOffloadedBatchingDisabled.flow,
generalSettings.isOffloadedFilteringDisabled.flow,
generalSettings.useIndirectScanResultCallback.flow,
) {
scannermode,
showUnfiltered,
isOffloadedBatchingDisabled,
isOffloadedFilteringDisabled,
useIndirectScanResultCallback,
->
ScannerOptions(
scannerMode = scannermode,
showUnfiltered = showUnfiltered,
offloadedFilteringDisabled = isOffloadedFilteringDisabled,
offloadedBatchingDisabled = isOffloadedBatchingDisabled,
disableDirectCallback = useIndirectScanResultCallback,
)
}
.throttleLatest(1000)
.flatMapLatest { options ->
val filters = when {
options.showUnfiltered -> {
log(TAG, WARN) { "Using unfiltered scan mode" }
setOf(ScanFilter.Builder().build())
}
else -> ProximityPairing.getBleScanFilter()
}
bleScanner.scan(
filters = filters,
scannerMode = options.scannerMode,
disableOffloadFiltering = options.offloadedFilteringDisabled,
disableOffloadBatching = options.offloadedBatchingDisabled,
disableDirectScanCallback = options.disableDirectCallback,
)
}
.map { it.onlyNewAndUnique() }
private suspend fun processWithCache(
newPods: List<PodFactory.Result>?
): Map<BlePodSnapshot.Id, BlePodSnapshot> = cacheLock.withLock {
if (newPods == null) {
log(TAG) { "Null result, Bluetooth is disabled." }
deviceCache.clear()
return emptyMap()
}
val now = Instant.now()
deviceCache.toList().forEach { (key, value) ->
if (Duration.between(value.seenLastAt, now) > Duration.ofSeconds(20)) {
log(TAG, VERBOSE) { "Removing stale device from cache: $value" }
deviceCache.remove(key)
}
}
val pods = mutableMapOf<BlePodSnapshot.Id, BlePodSnapshot>()
pods.putAll(deviceCache)
newPods.map { it.device }.forEach {
deviceCache[it.identifier] = it
pods[it.identifier] = it
}
newPods
.mapNotNull {
val profileId = it.device.meta.profile?.id ?: return@mapNotNull null
profileId to it.device.scanResult
}
.toMap()
.run { podDeviceCache.saveAll(this) }
return pods
}
suspend fun getDeviceForProfile(profileId: String): BlePodSnapshot? {
log(TAG) { "getDeviceForProfile(profileId=$profileId)" }
val liveDevice = devices.firstOrNull()?.firstOrNull { device ->
device.meta.profile?.id == profileId
}
if (liveDevice != null) {
log(TAG) { "Found live device for profile $profileId: $liveDevice" }
return liveDevice
}
val cachedDevice = podDeviceCache.load(profileId)?.let {
podFactory.createPod(it)?.device
}
log(TAG) { "Cached device for profile $profileId: $cachedDevice" }
return cachedDevice
}
companion object {
private val TAG = logTag("Monitor", "PodMonitor")
}
}
@@ -11,7 +11,7 @@ import javax.inject.Singleton
/**
* Single merge point: combines BLE scan data ([BlePodMonitor]) with AAP connection data
* ([AapConnectionManager]) into unified [MonitoredDevice] objects.
* ([AapConnectionManager]) into unified [PodDevice] objects.
*
* ViewModels should observe [devices] instead of accessing BlePodMonitor directly.
*/
@@ -20,21 +20,21 @@ class DeviceMonitor @Inject constructor(
private val blePodMonitor: BlePodMonitor,
private val aapManager: AapConnectionManager,
) {
val devices: Flow<List<MonitoredDevice>> = blePodMonitor.devices
val devices: Flow<List<PodDevice>> = blePodMonitor.devices
.combine(aapManager.allStates) { pods, aapStates ->
pods.map { pod ->
MonitoredDevice(
PodDevice(
ble = pod,
aap = aapStates[pod.address],
)
}
}
suspend fun getDeviceForProfile(profileId: String): MonitoredDevice? {
suspend fun getDeviceForProfile(profileId: String): PodDevice? {
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)
return PodDevice(ble = bleDevice, aap = aapState)
}
companion object {
@@ -8,17 +8,17 @@ import java.time.Duration
import java.time.Instant
import kotlin.math.roundToInt
fun DeviceMonitor.devicesWithProfiles(): Flow<List<MonitoredDevice>> = devices
fun DeviceMonitor.devicesWithProfiles(): Flow<List<PodDevice>> = devices
.map { devices -> devices.filter { it.meta?.profile != null } }
fun DeviceMonitor.primaryDevice(): Flow<MonitoredDevice?> = devicesWithProfiles().map { it.firstOrNull() }
fun DeviceMonitor.primaryDevice(): Flow<PodDevice?> = devicesWithProfiles().map { it.firstOrNull() }
fun MonitoredDevice.getSignalQuality(context: Context): String {
fun PodDevice.getSignalQuality(context: Context): String {
val multiplier = 100 * signalQuality
return "${multiplier.roundToInt()}%"
}
fun MonitoredDevice.lastSeenFormatted(now: Instant): String {
fun PodDevice.lastSeenFormatted(now: Instant): String {
val lastAt = seenLastAt ?: return ""
val formatter = RelativeDateTimeFormatter.getInstance()
val duration = Duration.between(lastAt, now)
@@ -37,7 +37,7 @@ fun MonitoredDevice.lastSeenFormatted(now: Instant): String {
}
}
fun MonitoredDevice.firstSeenFormatted(now: Instant): String {
fun PodDevice.firstSeenFormatted(now: Instant): String {
val firstAt = seenFirstAt ?: return ""
val formatter = RelativeDateTimeFormatter.getInstance()
val duration = Duration.between(firstAt, now)
@@ -3,16 +3,15 @@ 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.DualBlePodSnapshot
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.PodModel
import eu.darken.capod.pods.core.SinglePodDevice
import eu.darken.capod.pods.core.SingleBlePodSnapshot
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
@@ -23,15 +22,15 @@ import java.time.Instant
* All properties are dynamically resolved from the best available source.
* Consumers don't need to know whether data came from BLE or AAP.
*/
data class MonitoredDevice(
data class PodDevice(
internal val ble: BlePodSnapshot?,
internal val aap: AapPodState?,
) {
// Identity
val model: PodModel get() = ble?.model ?: PodModel.UNKNOWN
val address: BluetoothAddress? get() = ble?.address
val identifier: PodDevice.Id? get() = ble?.identifier
val meta: PodDevice.Meta? get() = ble?.meta
val identifier: BlePodSnapshot.Id? get() = ble?.identifier
val meta: BlePodSnapshot.Meta? get() = ble?.meta
// Capabilities from Model.Features
val hasCase: Boolean get() = model.features.hasCase
@@ -48,16 +47,16 @@ data class MonitoredDevice(
// Battery — best available source
val batteryLeft: Float?
get() = (ble as? DualPodDevice)?.batteryLeftPodPercent
get() = (ble as? DualBlePodSnapshot)?.batteryLeftPodPercent
val batteryRight: Float?
get() = (ble as? DualPodDevice)?.batteryRightPodPercent
get() = (ble as? DualBlePodSnapshot)?.batteryRightPodPercent
val batteryCase: Float?
get() = (ble as? HasCase)?.batteryCasePercent
val batteryHeadset: Float?
get() = (ble as? SinglePodDevice)?.batteryHeadsetPercent
get() = (ble as? SingleBlePodSnapshot)?.batteryHeadsetPercent
// Charging
val isLeftPodCharging: Boolean?
@@ -99,10 +98,10 @@ data class MonitoredDevice(
val iconRes: Int get() = ble?.iconRes ?: model.iconRes
val leftPodIcon: Int?
get() = (ble as? DualPodDevice)?.leftPodIcon
get() = (ble as? DualBlePodSnapshot)?.leftPodIcon
val rightPodIcon: Int?
get() = (ble as? DualPodDevice)?.rightPodIcon
get() = (ble as? DualBlePodSnapshot)?.rightPodIcon
val caseIcon: Int?
get() = (ble as? HasCase)?.caseIcon
@@ -78,7 +78,7 @@ class PodDeviceCache @Inject constructor(
}
companion object {
private val TAG = logTag("Monitor", "PodMonitor", "Cache")
private val TAG = logTag("Monitor", "BlePodMonitor", "Cache")
}
}
@@ -1,217 +0,0 @@
package eu.darken.capod.monitor.core
import android.bluetooth.le.ScanFilter
import eu.darken.capod.common.bluetooth.BleScanner
import eu.darken.capod.common.bluetooth.BluetoothManager2
import eu.darken.capod.common.bluetooth.ScannerMode
import eu.darken.capod.common.bluetooth.onlyNewAndUnique
import eu.darken.capod.common.coroutine.AppScope
import eu.darken.capod.common.debug.DebugSettings
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
import eu.darken.capod.common.debug.logging.asLog
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.flow.replayingShare
import eu.darken.capod.common.flow.throttleLatest
import eu.darken.capod.main.core.GeneralSettings
import eu.darken.capod.main.core.PermissionTool
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.PodFactory
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import eu.darken.capod.profiles.core.DeviceProfilesRepo
import eu.darken.capod.profiles.core.currentProfiles
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.retryWhen
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.time.Duration
import java.time.Instant
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class PodMonitor @Inject constructor(
@AppScope private val appScope: CoroutineScope,
private val bleScanner: BleScanner,
private val podFactory: PodFactory,
private val generalSettings: GeneralSettings,
bluetoothManager: BluetoothManager2,
private val debugSettings: DebugSettings,
private val podDeviceCache: PodDeviceCache,
permissionTool: PermissionTool,
private val profilesRepo: DeviceProfilesRepo,
) {
private val deviceCache = mutableMapOf<PodDevice.Id, PodDevice>()
private val cacheLock = Mutex()
val devices: Flow<List<PodDevice>> = combine(
permissionTool.missingScanPermissions,
bluetoothManager.isBluetoothEnabled
) { missingScanPermissions, isBluetoothEnabled ->
log(TAG) { "devices: missingScanPermissions=$missingScanPermissions, isBluetoothEnabled=$isBluetoothEnabled" }
missingScanPermissions.isEmpty() && isBluetoothEnabled
}
.flatMapLatest { isReady ->
if (!isReady) {
log(TAG, WARN) { "Bluetooth is not ready" }
flowOf(null)
} else {
createBleScanner()
}
}
.map { results -> results?.mapNotNull { podFactory.createPod(it) } }
.map { processWithCache(it).values }
.flatMapLatest { devices ->
flowOf(sortPodsToInterest(devices))
}
.retryWhen { cause, attempt ->
if (cause is SecurityException) {
log(TAG, WARN) { "PodMonitor failed due to missing permission, not retrying: ${cause.asLog()}" }
false
} else {
log(TAG, WARN) { "PodMonitor failed (attempt=$attempt), will retry: ${cause.asLog()}" }
delay(3000)
true
}
}
.onStart { emit(emptyList()) }
.replayingShare(appScope)
private suspend fun sortPodsToInterest(devices: Collection<PodDevice>): List<PodDevice> {
val now = Instant.now()
val profiles = profilesRepo.currentProfiles() ?: emptyList()
return devices.sortedWith(
compareBy<PodDevice> { device ->
// Use profile position in list as priority (0 = highest priority)
device.meta.profile?.let { profile ->
profiles.indexOfFirst { it.id == profile.id }.takeIf { it >= 0 } ?: Int.MAX_VALUE
} ?: Int.MAX_VALUE
}
.thenBy {
val age = Duration.between(it.seenLastAt, now).seconds
if (age < 5) 0L else (age / 3L)
}
.thenByDescending { it.signalQuality }
.thenByDescending { (it.seenCounter / 10) }
)
}
private data class ScannerOptions(
val scannerMode: ScannerMode,
val showUnfiltered: Boolean,
val offloadedFilteringDisabled: Boolean,
val offloadedBatchingDisabled: Boolean,
val disableDirectCallback: Boolean,
)
private fun createBleScanner() = combine(
generalSettings.scannerMode.flow,
debugSettings.showUnfiltered.flow,
generalSettings.isOffloadedBatchingDisabled.flow,
generalSettings.isOffloadedFilteringDisabled.flow,
generalSettings.useIndirectScanResultCallback.flow,
) {
scannermode,
showUnfiltered,
isOffloadedBatchingDisabled,
isOffloadedFilteringDisabled,
useIndirectScanResultCallback,
->
ScannerOptions(
scannerMode = scannermode,
showUnfiltered = showUnfiltered,
offloadedFilteringDisabled = isOffloadedFilteringDisabled,
offloadedBatchingDisabled = isOffloadedBatchingDisabled,
disableDirectCallback = useIndirectScanResultCallback,
)
}
.throttleLatest(1000)
.flatMapLatest { options ->
val filters = when {
options.showUnfiltered -> {
log(TAG, WARN) { "Using unfiltered scan mode" }
setOf(ScanFilter.Builder().build())
}
else -> ProximityPairing.getBleScanFilter()
}
bleScanner.scan(
filters = filters,
scannerMode = options.scannerMode,
disableOffloadFiltering = options.offloadedFilteringDisabled,
disableOffloadBatching = options.offloadedBatchingDisabled,
disableDirectScanCallback = options.disableDirectCallback,
)
}
.map { it.onlyNewAndUnique() }
private suspend fun processWithCache(
newPods: List<PodFactory.Result>?
): Map<PodDevice.Id, PodDevice> = cacheLock.withLock {
if (newPods == null) {
log(TAG) { "Null result, Bluetooth is disabled." }
deviceCache.clear()
return emptyMap()
}
val now = Instant.now()
deviceCache.toList().forEach { (key, value) ->
if (Duration.between(value.seenLastAt, now) > Duration.ofSeconds(20)) {
log(TAG, VERBOSE) { "Removing stale device from cache: $value" }
deviceCache.remove(key)
}
}
val pods = mutableMapOf<PodDevice.Id, PodDevice>()
pods.putAll(deviceCache)
newPods.map { it.device }.forEach {
deviceCache[it.identifier] = it
pods[it.identifier] = it
}
newPods
.mapNotNull {
val profileId = it.device.meta.profile?.id ?: return@mapNotNull null
profileId to it.device.scanResult
}
.toMap()
.run { podDeviceCache.saveAll(this) }
return pods
}
suspend fun getDeviceForProfile(profileId: String): PodDevice? {
log(TAG) { "getDeviceForProfile(profileId=$profileId)" }
val liveDevice = devices.firstOrNull()?.firstOrNull { device ->
device.meta.profile?.id == profileId
}
if (liveDevice != null) {
log(TAG) { "Found live device for profile $profileId: $liveDevice" }
return liveDevice
}
val cachedDevice = podDeviceCache.load(profileId)?.let {
podFactory.createPod(it)?.device
}
log(TAG) { "Cached device for profile $profileId: $cachedDevice" }
return cachedDevice
}
companion object {
private val TAG = logTag("Monitor", "PodMonitor")
}
}
@@ -1,11 +1,10 @@
package eu.darken.capod.monitor.core
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.BlePodSnapshot
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlin.collections.firstOrNull
fun PodMonitor.devicesWithProfiles(): Flow<List<PodDevice>> = devices
fun BlePodMonitor.devicesWithProfiles(): Flow<List<BlePodSnapshot>> = devices
.map { devices -> devices.filter { it.meta.profile != null } }
fun PodMonitor.primaryDevice(): Flow<PodDevice?> = devicesWithProfiles().map { it.firstOrNull() }
fun BlePodMonitor.primaryDevice(): Flow<BlePodSnapshot?> = devicesWithProfiles().map { it.firstOrNull() }
@@ -14,6 +14,7 @@ import dagger.hilt.android.AndroidEntryPoint
import eu.darken.capod.common.bluetooth.BluetoothDevice2
import eu.darken.capod.common.bluetooth.BluetoothManager2
import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.datastore.valueBlocking
import eu.darken.capod.common.debug.Bugs
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
@@ -26,9 +27,9 @@ 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.BlePodMonitor
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
import eu.darken.capod.monitor.ui.MonitorNotifications
import eu.darken.capod.profiles.core.DeviceProfile
@@ -54,7 +55,6 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import javax.inject.Inject
import eu.darken.capod.common.datastore.valueBlocking
@AndroidEntryPoint
class MonitorService : Service() {
@@ -64,7 +64,7 @@ class MonitorService : Service() {
@Inject lateinit var notificationManager: NotificationManager
@Inject lateinit var generalSettings: GeneralSettings
@Inject lateinit var permissionTool: PermissionTool
@Inject lateinit var podMonitor: PodMonitor
@Inject lateinit var blePodMonitor: BlePodMonitor
@Inject lateinit var deviceMonitor: DeviceMonitor
@Inject lateinit var bluetoothManager: BluetoothManager2
@Inject lateinit var playPause: PlayPause
@@ -179,7 +179,7 @@ class MonitorService : Service() {
}
val monitorJob = deviceMonitor.primaryDevice()
.setupCommonEventHandlers(TAG) { "PodMonitor" }
.setupCommonEventHandlers(TAG) { "BlePodMonitor" }
.distinctUntilChanged()
.throttleLatest(1000)
.onEach { currentDevice ->
@@ -222,6 +222,7 @@ class MonitorService : Service() {
@Suppress("UNCHECKED_CAST")
val profiles = arguments[1] as List<DeviceProfile>
@Suppress("UNCHECKED_CAST")
val devices = arguments[2] as Collection<BluetoothDevice2>
@@ -5,7 +5,7 @@ import android.view.View
import android.widget.RemoteViews
import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.R
import eu.darken.capod.monitor.core.MonitoredDevice
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.pods.core.PodModel
import eu.darken.capod.pods.core.formatBatteryPercent
import eu.darken.capod.pods.core.getBatteryDrawable
@@ -17,13 +17,13 @@ class MonitorNotificationViewFactory @Inject constructor(
@ApplicationContext private val context: Context
) {
fun createContentView(device: MonitoredDevice): RemoteViews = when {
fun createContentView(device: PodDevice): RemoteViews = when {
device.hasDualPods -> createDualPods(device)
device.model != PodModel.UNKNOWN -> createSinglePod(device)
else -> createUnknownDevice(device)
}
private fun createDualPods(device: MonitoredDevice): RemoteViews = RemoteViews(
private fun createDualPods(device: PodDevice): RemoteViews = RemoteViews(
context.packageName,
R.layout.monitor_notification_dual_pods_small
).apply {
@@ -55,7 +55,7 @@ class MonitorNotificationViewFactory @Inject constructor(
setViewVisibility(R.id.pod_right_ear, if (isRightPodInEar) View.VISIBLE else View.GONE)
}
private fun createSinglePod(device: MonitoredDevice): RemoteViews = RemoteViews(
private fun createSinglePod(device: PodDevice): RemoteViews = RemoteViews(
context.packageName,
R.layout.monitor_notification_single_pods_small
).apply {
@@ -68,24 +68,27 @@ class MonitorNotificationViewFactory @Inject constructor(
setViewVisibility(R.id.headphones_worn, if (device.isBeingWorn == true) View.VISIBLE else View.GONE)
}
if (device.isHeadsetBeingCharged != null) {
setViewVisibility(R.id.headphones_charging, if (device.isHeadsetBeingCharged == true) View.VISIBLE else View.GONE)
setViewVisibility(
R.id.headphones_charging,
if (device.isHeadsetBeingCharged == true) View.VISIBLE else View.GONE
)
}
}
private fun createUnknownDevice(device: MonitoredDevice): RemoteViews = RemoteViews(
private fun createUnknownDevice(device: PodDevice): RemoteViews = RemoteViews(
context.packageName,
R.layout.monitor_notification_unknown_device_small
).apply {
setTextViewText(R.id.device, device.getLabel(context))
}
fun createBigContentView(device: MonitoredDevice): RemoteViews = when {
fun createBigContentView(device: PodDevice): RemoteViews = when {
device.hasDualPods -> createDualPodsBig(device)
device.model != PodModel.UNKNOWN -> createSinglePodBig(device)
else -> createUnknownDeviceBig(device)
}
private fun createDualPodsBig(device: MonitoredDevice): RemoteViews = RemoteViews(
private fun createDualPodsBig(device: PodDevice): RemoteViews = RemoteViews(
context.packageName,
R.layout.monitor_notification_dual_pods_big
).apply {
@@ -120,7 +123,7 @@ class MonitorNotificationViewFactory @Inject constructor(
setViewVisibility(R.id.pod_right_ear, if (isRightPodInEar) View.VISIBLE else View.GONE)
}
private fun createSinglePodBig(device: MonitoredDevice): RemoteViews = RemoteViews(
private fun createSinglePodBig(device: PodDevice): RemoteViews = RemoteViews(
context.packageName,
R.layout.monitor_notification_single_pods_big
).apply {
@@ -133,11 +136,14 @@ class MonitorNotificationViewFactory @Inject constructor(
setViewVisibility(R.id.headphones_worn, if (device.isBeingWorn == true) View.VISIBLE else View.GONE)
}
if (device.isHeadsetBeingCharged != null) {
setViewVisibility(R.id.headphones_charging, if (device.isHeadsetBeingCharged == true) View.VISIBLE else View.GONE)
setViewVisibility(
R.id.headphones_charging,
if (device.isHeadsetBeingCharged == true) View.VISIBLE else View.GONE
)
}
}
private fun createUnknownDeviceBig(device: MonitoredDevice): RemoteViews = RemoteViews(
private fun createUnknownDeviceBig(device: PodDevice): RemoteViews = RemoteViews(
context.packageName,
R.layout.monitor_notification_unknown_device_big
).apply {
@@ -15,7 +15,7 @@ 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.monitor.core.MonitoredDevice
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.pods.core.PodModel
import eu.darken.capod.pods.core.formatBatteryPercent
import javax.inject.Inject
@@ -53,7 +53,11 @@ class MonitorNotifications @Inject constructor(
setOngoing(true)
}
private fun getBuilder(device: MonitoredDevice?, channelId: String, showHint: Boolean = false): NotificationCompat.Builder {
private fun getBuilder(
device: PodDevice?,
channelId: String,
showHint: Boolean = false
): NotificationCompat.Builder {
if (device == null) {
return baseBuilder(channelId).apply {
if (showHint) {
@@ -122,10 +126,10 @@ class MonitorNotifications @Inject constructor(
}
}
fun getNotification(podDevice: MonitoredDevice?, showHint: Boolean = false): Notification =
fun getNotification(podDevice: PodDevice?, showHint: Boolean = false): Notification =
getBuilder(podDevice, NOTIFICATION_CHANNEL_ID, showHint).build()
fun getNotificationConnected(podDevice: MonitoredDevice?): Notification =
fun getNotificationConnected(podDevice: PodDevice?): Notification =
getBuilder(podDevice, NOTIFICATION_CHANNEL_ID_CONNECTED).build()
fun getStartupNotification(): Notification =
@@ -1,15 +1,77 @@
package eu.darken.capod.pods.core
/**
* Type alias for the BLE-sourced device snapshot.
*
* The [PodDevice] interface represents BLE scan data (battery, ear detection, case state)
* extracted from Apple Continuity Protocol advertisements.
*
* New code should use [BlePodSnapshot] to clarify that this is the BLE data source,
* not the unified facade ([eu.darken.capod.monitor.core.MonitoredDevice]).
*
* Full rename of [PodDevice] → [BlePodSnapshot] across the codebase is deferred to
* an IDE refactoring pass (103 files, 470 occurrences).
*/
typealias BlePodSnapshot = PodDevice
import android.content.Context
import androidx.annotation.DrawableRes
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.bluetooth.BluetoothAddress
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.profiles.core.DeviceProfile
import java.time.Duration
import java.time.Instant
import java.util.UUID
import kotlin.math.abs
import kotlin.math.max
interface BlePodSnapshot {
val identifier: Id
val model: PodModel
val address: BluetoothAddress
get() = scanResult.address
val seenLastAt: Instant
val seenFirstAt: Instant
val seenCounter: Int
val scanResult: BleScanResult
val rssi: Int
get() = scanResult.rssi
val reliability: Float
val signalQuality: Float
get() {
/**
* This is not correct but it works ¯\_(ツ)_/¯
* The range of the RSSI is device specific (ROMs).
*/
val sqRssi = ((100 - abs(rssi)) / 100f)
val sqReliability = max(BASE_CONFIDENCE, reliability)
val sqAge = (Duration.between(seenFirstAt, Instant.now()).toMinutes().coerceAtMost(60) / 60f) * 0.25f
log(VERBOSE) { "Signal Quality ($address): rssi=$sqRssi, reliability=$reliability, age=$sqAge" }
return (sqRssi + sqReliability + sqAge) / 2f
}
val rawData: Map<Int, ByteArray>
get() = scanResult.manufacturerSpecificData
val rawDataHex: List<String>
get() = rawData.entries.map { entry ->
"${entry.key}: ${entry.value.joinToString(separator = " ") { String.format("%02X", it) }}"
}
interface Meta {
val profile: DeviceProfile?
}
val meta: Meta
fun getLabel(context: Context): String = model.label
@get:DrawableRes
val iconRes: Int
get() = model.iconRes
@JvmInline
value class Id(private val id: UUID = UUID.randomUUID())
companion object {
const val BASE_CONFIDENCE = 0.0f
}
}
@@ -3,7 +3,7 @@ package eu.darken.capod.pods.core
import androidx.annotation.DrawableRes
import eu.darken.capod.R
interface DualPodDevice : PodDevice {
interface DualBlePodSnapshot : BlePodSnapshot {
enum class Pod {
LEFT,
@@ -1,77 +0,0 @@
package eu.darken.capod.pods.core
import android.content.Context
import androidx.annotation.DrawableRes
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.bluetooth.BluetoothAddress
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.profiles.core.DeviceProfile
import java.time.Duration
import java.time.Instant
import java.util.UUID
import kotlin.math.abs
import kotlin.math.max
interface PodDevice {
val identifier: Id
val model: PodModel
val address: BluetoothAddress
get() = scanResult.address
val seenLastAt: Instant
val seenFirstAt: Instant
val seenCounter: Int
val scanResult: BleScanResult
val rssi: Int
get() = scanResult.rssi
val reliability: Float
val signalQuality: Float
get() {
/**
* This is not correct but it works ¯\_(ツ)_/¯
* The range of the RSSI is device specific (ROMs).
*/
val sqRssi = ((100 - abs(rssi)) / 100f)
val sqReliability = max(BASE_CONFIDENCE, reliability)
val sqAge = (Duration.between(seenFirstAt, Instant.now()).toMinutes().coerceAtMost(60) / 60f) * 0.25f
log(VERBOSE) { "Signal Quality ($address): rssi=$sqRssi, reliability=$reliability, age=$sqAge" }
return (sqRssi + sqReliability + sqAge) / 2f
}
val rawData: Map<Int, ByteArray>
get() = scanResult.manufacturerSpecificData
val rawDataHex: List<String>
get() = rawData.entries.map { entry ->
"${entry.key}: ${entry.value.joinToString(separator = " ") { String.format("%02X", it) }}"
}
interface Meta {
val profile: DeviceProfile?
}
val meta: Meta
fun getLabel(context: Context): String = model.label
@get:DrawableRes
val iconRes: Int
get() = model.iconRes
@JvmInline
value class Id(private val id: UUID = UUID.randomUUID())
companion object {
const val BASE_CONFIDENCE = 0.0f
}
}
@@ -18,7 +18,7 @@ fun formatBatteryPercent(context: Context, percent: Float?): String =
percent?.let { "${(it * 100).roundToInt()}%" }
?: context.getString(R.string.general_value_not_available_label)
fun PodDevice.getSignalQuality(context: Context): String {
fun BlePodSnapshot.getSignalQuality(context: Context): String {
val multiplier = 100 * signalQuality
return "${multiplier.roundToInt()}%"
}
@@ -36,7 +36,7 @@ fun getBatteryDrawable(percent: Float?): Int = when {
else -> R.drawable.ic_baseline_battery_0_bar_24
}
fun PodDevice.lastSeenFormatted(now: Instant): String {
fun BlePodSnapshot.lastSeenFormatted(now: Instant): String {
val formatter = RelativeDateTimeFormatter.getInstance()
val duration = Duration.between(seenLastAt, now)
return if (duration > Duration.ofMinutes(1)) {
@@ -54,7 +54,7 @@ fun PodDevice.lastSeenFormatted(now: Instant): String {
}
}
fun PodDevice.firstSeenFormatted(now: Instant): String {
fun BlePodSnapshot.firstSeenFormatted(now: Instant): String {
val formatter = RelativeDateTimeFormatter.getInstance()
val duration = Duration.between(seenFirstAt, now)
return formatter.format(
@@ -36,7 +36,7 @@ class PodFactory @Inject constructor(
data class Result(
val scanResult: BleScanResult,
val device: PodDevice
val device: BlePodSnapshot
)
companion object {
@@ -1,6 +1,6 @@
package eu.darken.capod.pods.core
interface SinglePodDevice : PodDevice {
interface SingleBlePodSnapshot : BlePodSnapshot {
val batteryHeadsetPercent: Float?
}
@@ -6,9 +6,9 @@ import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
import eu.darken.capod.common.debug.logging.asLog
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.BlePodSnapshot
import eu.darken.capod.pods.core.PodModel
import eu.darken.capod.pods.core.apple.misc.UnknownAppleDevice
import eu.darken.capod.pods.core.apple.misc.UnknownAppleSnapshotBle
import eu.darken.capod.pods.core.apple.protocol.ContinuityProtocol
import eu.darken.capod.pods.core.apple.protocol.ProximityMessage
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
@@ -28,7 +28,7 @@ class AppleFactory @Inject constructor(
private val proximityPairingDecoder: ProximityPairing.Decoder,
private val proximityMessageDecrypter: ProximityMessage.Decrypter,
private val podFactories: @JvmSuppressWildcards Set<ApplePodsFactory>,
private val unknownAppleFactory: UnknownAppleDevice.Factory,
private val unknownAppleFactory: UnknownAppleSnapshotBle.Factory,
private val rpaChecker: RPAChecker,
private val profilesRepo: DeviceProfilesRepo,
) {
@@ -60,7 +60,7 @@ class AppleFactory @Inject constructor(
return proximityMessage
}
suspend fun create(scanResult: BleScanResult): PodDevice? = lock.withLock {
suspend fun create(scanResult: BleScanResult): BlePodSnapshot? = lock.withLock {
val proximityMessage = getMessage(scanResult) ?: return@withLock null
val factory = podFactories.firstOrNull { it.isResponsible(proximityMessage) } ?: unknownAppleFactory
@@ -2,11 +2,11 @@ package eu.darken.capod.pods.core.apple
import eu.darken.capod.common.lowerNibble
import eu.darken.capod.common.upperNibble
import eu.darken.capod.profiles.core.AppleDeviceProfile
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.BlePodSnapshot
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
import eu.darken.capod.profiles.core.AppleDeviceProfile
interface ApplePods : PodDevice {
interface ApplePods : BlePodSnapshot {
val payload: ProximityPayload
@@ -59,7 +59,7 @@ interface ApplePods : PodDevice {
data class AppleMeta(
val isIRKMatch: Boolean = false,
override val profile: AppleDeviceProfile? = null,
) : PodDevice.Meta
) : BlePodSnapshot.Meta
override val meta: AppleMeta
}
@@ -4,14 +4,14 @@ import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.isBitSet
import eu.darken.capod.common.lowerNibble
import eu.darken.capod.common.upperNibble
import eu.darken.capod.pods.core.DualPodDevice
import eu.darken.capod.pods.core.DualPodDevice.Pod
import eu.darken.capod.pods.core.DualBlePodSnapshot
import eu.darken.capod.pods.core.DualBlePodSnapshot.Pod
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
interface DualApplePods : ApplePods, HasChargeDetectionDual, DualPodDevice, HasEarDetectionDual, HasCase,
interface DualApplePods : ApplePods, HasChargeDetectionDual, DualBlePodSnapshot, HasEarDetectionDual, HasCase,
HasDualMicrophone, HasAppleColor {
val primaryPod: Pod
@@ -2,12 +2,12 @@ package eu.darken.capod.pods.core.apple
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.lowerNibble
import eu.darken.capod.pods.core.SinglePodDevice
import eu.darken.capod.pods.core.SingleBlePodSnapshot
/**
* Devices that only present a single charge level, e.g. most Beats devices
*/
interface SingleApplePods : ApplePods, SinglePodDevice, HasAppleColor {
interface SingleApplePods : ApplePods, SingleBlePodSnapshot, HasAppleColor {
override val batteryHeadsetPercent: Float?
get() {
@@ -2,7 +2,7 @@ package eu.darken.capod.pods.core.apple.airpods
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.BlePodSnapshot
import eu.darken.capod.pods.core.PodModel
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.ApplePodsFactory
@@ -15,14 +15,14 @@ import java.time.Instant
import javax.inject.Inject
data class AirPodsGen1(
override val identifier: PodDevice.Id = PodDevice.Id(),
override val identifier: BlePodSnapshot.Id = BlePodSnapshot.Id(),
override val seenLastAt: Instant = Instant.now(),
override val seenFirstAt: Instant = Instant.now(),
override val seenCounter: Int = 1,
override val scanResult: BleScanResult,
override val payload: ProximityPayload,
override val meta: ApplePods.AppleMeta,
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
override val reliability: Float = BlePodSnapshot.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null,
private val cachedCaseState: DualApplePods.LidState? = null
@@ -2,7 +2,7 @@ package eu.darken.capod.pods.core.apple.airpods
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.BlePodSnapshot
import eu.darken.capod.pods.core.PodModel
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.ApplePodsFactory
@@ -15,14 +15,14 @@ import java.time.Instant
import javax.inject.Inject
data class AirPodsGen2(
override val identifier: PodDevice.Id = PodDevice.Id(),
override val identifier: BlePodSnapshot.Id = BlePodSnapshot.Id(),
override val seenLastAt: Instant = Instant.now(),
override val seenFirstAt: Instant = Instant.now(),
override val seenCounter: Int = 1,
override val scanResult: BleScanResult,
override val payload: ProximityPayload,
override val meta: ApplePods.AppleMeta,
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
override val reliability: Float = BlePodSnapshot.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null,
private val cachedCaseState: DualApplePods.LidState? = null
@@ -4,7 +4,7 @@ import androidx.annotation.DrawableRes
import eu.darken.capod.R
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.BlePodSnapshot
import eu.darken.capod.pods.core.PodModel
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.ApplePodsFactory
@@ -17,14 +17,14 @@ import java.time.Instant
import javax.inject.Inject
data class AirPodsGen3(
override val identifier: PodDevice.Id = PodDevice.Id(),
override val identifier: BlePodSnapshot.Id = BlePodSnapshot.Id(),
override val seenLastAt: Instant = Instant.now(),
override val seenFirstAt: Instant = Instant.now(),
override val seenCounter: Int = 1,
override val scanResult: BleScanResult,
override val payload: ProximityPayload,
override val meta: ApplePods.AppleMeta,
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
override val reliability: Float = BlePodSnapshot.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null,
private val cachedCaseState: DualApplePods.LidState? = null
@@ -4,7 +4,7 @@ import androidx.annotation.DrawableRes
import eu.darken.capod.R
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.BlePodSnapshot
import eu.darken.capod.pods.core.PodModel
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.ApplePodsFactory
@@ -17,14 +17,14 @@ import java.time.Instant
import javax.inject.Inject
data class AirPodsGen4(
override val identifier: PodDevice.Id = PodDevice.Id(),
override val identifier: BlePodSnapshot.Id = BlePodSnapshot.Id(),
override val seenLastAt: Instant = Instant.now(),
override val seenFirstAt: Instant = Instant.now(),
override val seenCounter: Int = 1,
override val scanResult: BleScanResult,
override val payload: ProximityPayload,
override val meta: ApplePods.AppleMeta,
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
override val reliability: Float = BlePodSnapshot.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null,
private val cachedCaseState: DualApplePods.LidState? = null
@@ -4,7 +4,7 @@ import androidx.annotation.DrawableRes
import eu.darken.capod.R
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.BlePodSnapshot
import eu.darken.capod.pods.core.PodModel
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.ApplePodsFactory
@@ -17,14 +17,14 @@ import java.time.Instant
import javax.inject.Inject
data class AirPodsGen4Anc(
override val identifier: PodDevice.Id = PodDevice.Id(),
override val identifier: BlePodSnapshot.Id = BlePodSnapshot.Id(),
override val seenLastAt: Instant = Instant.now(),
override val seenFirstAt: Instant = Instant.now(),
override val seenCounter: Int = 1,
override val scanResult: BleScanResult,
override val payload: ProximityPayload,
override val meta: ApplePods.AppleMeta,
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
override val reliability: Float = BlePodSnapshot.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null,
private val cachedCaseState: DualApplePods.LidState? = null
@@ -3,9 +3,9 @@ package eu.darken.capod.pods.core.apple.airpods
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.isBitSet
import eu.darken.capod.pods.core.BlePodSnapshot
import eu.darken.capod.pods.core.HasChargeDetection
import eu.darken.capod.pods.core.HasEarDetection
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.PodModel
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.ApplePodsFactory
@@ -19,14 +19,14 @@ import java.time.Instant
import javax.inject.Inject
data class AirPodsMax(
override val identifier: PodDevice.Id = PodDevice.Id(),
override val identifier: BlePodSnapshot.Id = BlePodSnapshot.Id(),
override val seenLastAt: Instant = Instant.now(),
override val seenFirstAt: Instant = Instant.now(),
override val seenCounter: Int = 1,
override val scanResult: BleScanResult,
override val payload: ProximityPayload,
override val meta: ApplePods.AppleMeta,
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
override val reliability: Float = BlePodSnapshot.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
) : SingleApplePods, HasEarDetection, HasChargeDetection, HasAppleColor {
@@ -3,9 +3,9 @@ package eu.darken.capod.pods.core.apple.airpods
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.isBitSet
import eu.darken.capod.pods.core.BlePodSnapshot
import eu.darken.capod.pods.core.HasChargeDetection
import eu.darken.capod.pods.core.HasEarDetection
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.PodModel
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.ApplePodsFactory
@@ -19,14 +19,14 @@ import java.time.Instant
import javax.inject.Inject
data class AirPodsMaxUsbc(
override val identifier: PodDevice.Id = PodDevice.Id(),
override val identifier: BlePodSnapshot.Id = BlePodSnapshot.Id(),
override val seenLastAt: Instant = Instant.now(),
override val seenFirstAt: Instant = Instant.now(),
override val seenCounter: Int = 1,
override val scanResult: BleScanResult,
override val payload: ProximityPayload,
override val meta: ApplePods.AppleMeta,
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
override val reliability: Float = BlePodSnapshot.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
) : SingleApplePods, HasEarDetection, HasChargeDetection, HasAppleColor {
@@ -4,7 +4,7 @@ import androidx.annotation.DrawableRes
import eu.darken.capod.R
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.BlePodSnapshot
import eu.darken.capod.pods.core.PodModel
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.ApplePodsFactory
@@ -18,14 +18,14 @@ import java.time.Instant
import javax.inject.Inject
data class AirPodsPro(
override val identifier: PodDevice.Id = PodDevice.Id(),
override val identifier: BlePodSnapshot.Id = BlePodSnapshot.Id(),
override val seenLastAt: Instant = Instant.now(),
override val seenFirstAt: Instant = Instant.now(),
override val seenCounter: Int = 1,
override val scanResult: BleScanResult,
override val payload: ProximityPayload,
override val meta: ApplePods.AppleMeta,
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
override val reliability: Float = BlePodSnapshot.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null,
private val cachedCaseState: LidState? = null
@@ -4,7 +4,7 @@ import androidx.annotation.DrawableRes
import eu.darken.capod.R
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.BlePodSnapshot
import eu.darken.capod.pods.core.PodModel
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.ApplePodsFactory
@@ -18,14 +18,14 @@ import java.time.Instant
import javax.inject.Inject
data class AirPodsPro2(
override val identifier: PodDevice.Id = PodDevice.Id(),
override val identifier: BlePodSnapshot.Id = BlePodSnapshot.Id(),
override val seenLastAt: Instant = Instant.now(),
override val seenFirstAt: Instant = Instant.now(),
override val seenCounter: Int = 1,
override val scanResult: BleScanResult,
override val payload: ProximityPayload,
override val meta: ApplePods.AppleMeta,
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
override val reliability: Float = BlePodSnapshot.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null,
private val cachedCaseState: LidState? = null
@@ -4,7 +4,7 @@ import androidx.annotation.DrawableRes
import eu.darken.capod.R
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.BlePodSnapshot
import eu.darken.capod.pods.core.PodModel
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.ApplePodsFactory
@@ -18,14 +18,14 @@ import java.time.Instant
import javax.inject.Inject
data class AirPodsPro2Usbc(
override val identifier: PodDevice.Id = PodDevice.Id(),
override val identifier: BlePodSnapshot.Id = BlePodSnapshot.Id(),
override val seenLastAt: Instant = Instant.now(),
override val seenFirstAt: Instant = Instant.now(),
override val seenCounter: Int = 1,
override val scanResult: BleScanResult,
override val payload: ProximityPayload,
override val meta: ApplePods.AppleMeta,
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
override val reliability: Float = BlePodSnapshot.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null,
private val cachedCaseState: LidState? = null
@@ -4,7 +4,7 @@ import androidx.annotation.DrawableRes
import eu.darken.capod.R
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.BlePodSnapshot
import eu.darken.capod.pods.core.PodModel
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.ApplePodsFactory
@@ -18,13 +18,13 @@ import java.time.Instant
import javax.inject.Inject
data class AirPodsPro3(
override val identifier: PodDevice.Id = PodDevice.Id(),
override val identifier: BlePodSnapshot.Id = BlePodSnapshot.Id(),
override val seenLastAt: Instant = Instant.now(),
override val seenFirstAt: Instant = Instant.now(),
override val seenCounter: Int = 1,
override val scanResult: BleScanResult,
override val payload: ProximityPayload,
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
override val reliability: Float = BlePodSnapshot.BASE_CONFIDENCE,
override val meta: ApplePods.AppleMeta,
private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null,
@@ -4,7 +4,7 @@ import androidx.annotation.DrawableRes
import eu.darken.capod.R
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.BlePodSnapshot
import eu.darken.capod.pods.core.PodModel
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.ApplePodsFactory
@@ -17,14 +17,14 @@ import java.time.Instant
import javax.inject.Inject
data class BeatsFitPro(
override val identifier: PodDevice.Id = PodDevice.Id(),
override val identifier: BlePodSnapshot.Id = BlePodSnapshot.Id(),
override val seenLastAt: Instant = Instant.now(),
override val seenFirstAt: Instant = Instant.now(),
override val seenCounter: Int = 1,
override val scanResult: BleScanResult,
override val payload: ProximityPayload,
override val meta: ApplePods.AppleMeta,
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
override val reliability: Float = BlePodSnapshot.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null,
private val cachedCaseState: DualApplePods.LidState? = null
@@ -2,7 +2,7 @@ package eu.darken.capod.pods.core.apple.beats
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.BlePodSnapshot
import eu.darken.capod.pods.core.PodModel
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.ApplePodsFactory
@@ -15,14 +15,14 @@ import java.time.Instant
import javax.inject.Inject
data class BeatsFlex(
override val identifier: PodDevice.Id = PodDevice.Id(),
override val identifier: BlePodSnapshot.Id = BlePodSnapshot.Id(),
override val seenLastAt: Instant = Instant.now(),
override val seenFirstAt: Instant = Instant.now(),
override val seenCounter: Int = 1,
override val scanResult: BleScanResult,
override val payload: ProximityPayload,
override val meta: ApplePods.AppleMeta,
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
override val reliability: Float = BlePodSnapshot.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
) : SingleApplePods {
@@ -2,7 +2,7 @@ package eu.darken.capod.pods.core.apple.beats
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.BlePodSnapshot
import eu.darken.capod.pods.core.PodModel
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.ApplePodsFactory
@@ -15,14 +15,14 @@ import java.time.Instant
import javax.inject.Inject
data class BeatsSolo3(
override val identifier: PodDevice.Id = PodDevice.Id(),
override val identifier: BlePodSnapshot.Id = BlePodSnapshot.Id(),
override val seenLastAt: Instant = Instant.now(),
override val seenFirstAt: Instant = Instant.now(),
override val seenCounter: Int = 1,
override val scanResult: BleScanResult,
override val payload: ProximityPayload,
override val meta: ApplePods.AppleMeta,
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
override val reliability: Float = BlePodSnapshot.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
) : SingleApplePods {
@@ -2,7 +2,7 @@ package eu.darken.capod.pods.core.apple.beats
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.BlePodSnapshot
import eu.darken.capod.pods.core.PodModel
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.ApplePodsFactory
@@ -15,14 +15,14 @@ import java.time.Instant
import javax.inject.Inject
data class BeatsStudio3(
override val identifier: PodDevice.Id = PodDevice.Id(),
override val identifier: BlePodSnapshot.Id = BlePodSnapshot.Id(),
override val seenLastAt: Instant = Instant.now(),
override val seenFirstAt: Instant = Instant.now(),
override val seenCounter: Int = 1,
override val scanResult: BleScanResult,
override val payload: ProximityPayload,
override val meta: ApplePods.AppleMeta,
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
override val reliability: Float = BlePodSnapshot.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
) : SingleApplePods {
@@ -2,7 +2,7 @@ package eu.darken.capod.pods.core.apple.beats
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.BlePodSnapshot
import eu.darken.capod.pods.core.PodModel
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.ApplePodsFactory
@@ -15,14 +15,14 @@ import java.time.Instant
import javax.inject.Inject
data class BeatsX(
override val identifier: PodDevice.Id = PodDevice.Id(),
override val identifier: BlePodSnapshot.Id = BlePodSnapshot.Id(),
override val seenLastAt: Instant = Instant.now(),
override val seenFirstAt: Instant = Instant.now(),
override val seenCounter: Int = 1,
override val scanResult: BleScanResult,
override val payload: ProximityPayload,
override val meta: ApplePods.AppleMeta,
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
override val reliability: Float = BlePodSnapshot.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
) : SingleApplePods {
@@ -2,7 +2,7 @@ package eu.darken.capod.pods.core.apple.beats
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.BlePodSnapshot
import eu.darken.capod.pods.core.PodModel
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.ApplePodsFactory
@@ -15,14 +15,14 @@ import java.time.Instant
import javax.inject.Inject
data class PowerBeats3(
override val identifier: PodDevice.Id = PodDevice.Id(),
override val identifier: BlePodSnapshot.Id = BlePodSnapshot.Id(),
override val seenLastAt: Instant = Instant.now(),
override val seenFirstAt: Instant = Instant.now(),
override val seenCounter: Int = 1,
override val scanResult: BleScanResult,
override val payload: ProximityPayload,
override val meta: ApplePods.AppleMeta,
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
override val reliability: Float = BlePodSnapshot.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
) : SingleApplePods {
@@ -2,7 +2,7 @@ package eu.darken.capod.pods.core.apple.beats
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.BlePodSnapshot
import eu.darken.capod.pods.core.PodModel
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.ApplePodsFactory
@@ -16,14 +16,14 @@ import java.time.Instant
import javax.inject.Inject
data class PowerBeats4(
override val identifier: PodDevice.Id = PodDevice.Id(),
override val identifier: BlePodSnapshot.Id = BlePodSnapshot.Id(),
override val seenLastAt: Instant = Instant.now(),
override val seenFirstAt: Instant = Instant.now(),
override val seenCounter: Int = 1,
override val scanResult: BleScanResult,
override val payload: ProximityPayload,
override val meta: ApplePods.AppleMeta,
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
override val reliability: Float = BlePodSnapshot.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
) : SingleApplePods, HasStateDetectionAirPods {
@@ -4,7 +4,7 @@ import androidx.annotation.DrawableRes
import eu.darken.capod.R
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.BlePodSnapshot
import eu.darken.capod.pods.core.PodModel
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.ApplePodsFactory
@@ -17,14 +17,14 @@ import java.time.Instant
import javax.inject.Inject
data class PowerBeatsPro(
override val identifier: PodDevice.Id = PodDevice.Id(),
override val identifier: BlePodSnapshot.Id = BlePodSnapshot.Id(),
override val seenLastAt: Instant = Instant.now(),
override val seenFirstAt: Instant = Instant.now(),
override val seenCounter: Int = 1,
override val scanResult: BleScanResult,
override val payload: ProximityPayload,
override val meta: ApplePods.AppleMeta,
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
override val reliability: Float = BlePodSnapshot.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null,
private val cachedCaseState: DualApplePods.LidState? = null
@@ -4,7 +4,7 @@ import androidx.annotation.DrawableRes
import eu.darken.capod.R
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.BlePodSnapshot
import eu.darken.capod.pods.core.PodModel
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.ApplePodsFactory
@@ -17,14 +17,14 @@ import java.time.Instant
import javax.inject.Inject
data class PowerBeatsPro2(
override val identifier: PodDevice.Id = PodDevice.Id(),
override val identifier: BlePodSnapshot.Id = BlePodSnapshot.Id(),
override val seenLastAt: Instant = Instant.now(),
override val seenFirstAt: Instant = Instant.now(),
override val seenCounter: Int = 1,
override val scanResult: BleScanResult,
override val payload: ProximityPayload,
override val meta: ApplePods.AppleMeta,
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
override val reliability: Float = BlePodSnapshot.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null,
private val cachedCaseState: DualApplePods.LidState? = null
@@ -2,7 +2,7 @@ package eu.darken.capod.pods.core.apple.history
import eu.darken.capod.common.bluetooth.BluetoothAddress
import eu.darken.capod.common.collections.median
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.BlePodSnapshot
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
import java.time.Duration
@@ -10,7 +10,7 @@ import java.time.Instant
import kotlin.math.max
data class KnownDevice(
val id: PodDevice.Id,
val id: BlePodSnapshot.Id,
val seenFirstAt: Instant,
val seenCounter: Int,
val history: List<ApplePods>,
@@ -7,8 +7,8 @@ import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.lowerNibble
import eu.darken.capod.common.upperNibble
import eu.darken.capod.pods.core.BlePodSnapshot
import eu.darken.capod.pods.core.HasCase
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.PodModel
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.DualApplePods
@@ -27,7 +27,7 @@ class PodHistoryRepo @Inject constructor(
private val rpaChecker: RPAChecker,
) {
private val knownDevices = mutableMapOf<PodDevice.Id, KnownDevice>()
private val knownDevices = mutableMapOf<BlePodSnapshot.Id, KnownDevice>()
data class Identifier(
val device: UShort,
@@ -2,10 +2,10 @@ package eu.darken.capod.pods.core.apple.misc
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.BlePodSnapshot
import eu.darken.capod.pods.core.HasCase
import eu.darken.capod.pods.core.HasChargeDetectionDual
import eu.darken.capod.pods.core.HasEarDetectionDual
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.PodModel
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.ApplePodsFactory
@@ -22,14 +22,14 @@ import javax.inject.Inject
* Marketed as TWS i99999
*/
data class FakeAirPodsGen1(
override val identifier: PodDevice.Id = PodDevice.Id(),
override val identifier: BlePodSnapshot.Id = BlePodSnapshot.Id(),
override val seenLastAt: Instant = Instant.now(),
override val seenFirstAt: Instant = Instant.now(),
override val seenCounter: Int = 1,
override val scanResult: BleScanResult,
override val payload: ProximityPayload,
override val meta: ApplePods.AppleMeta,
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
override val reliability: Float = BlePodSnapshot.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null,
) : DualApplePods, HasEarDetectionDual, HasChargeDetectionDual, HasCase {
@@ -2,10 +2,10 @@ package eu.darken.capod.pods.core.apple.misc
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.BlePodSnapshot
import eu.darken.capod.pods.core.HasCase
import eu.darken.capod.pods.core.HasChargeDetectionDual
import eu.darken.capod.pods.core.HasEarDetectionDual
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.PodModel
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.ApplePodsFactory
@@ -20,14 +20,14 @@ import javax.inject.Inject
* Similar data structure but a lot of placeholder values or hardcoded values
*/
data class FakeAirPodsGen2(
override val identifier: PodDevice.Id = PodDevice.Id(),
override val identifier: BlePodSnapshot.Id = BlePodSnapshot.Id(),
override val seenLastAt: Instant = Instant.now(),
override val seenFirstAt: Instant = Instant.now(),
override val seenCounter: Int = 1,
override val scanResult: BleScanResult,
override val payload: ProximityPayload,
override val meta: ApplePods.AppleMeta,
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
override val reliability: Float = BlePodSnapshot.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null,
) : DualApplePods, HasEarDetectionDual, HasChargeDetectionDual, HasCase {
@@ -4,10 +4,10 @@ import androidx.annotation.DrawableRes
import eu.darken.capod.R
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.BlePodSnapshot
import eu.darken.capod.pods.core.HasCase
import eu.darken.capod.pods.core.HasChargeDetectionDual
import eu.darken.capod.pods.core.HasEarDetectionDual
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.PodModel
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.ApplePodsFactory
@@ -24,14 +24,14 @@ import javax.inject.Inject
* https://discord.com/channels/548521543039189022/927235844127993866/1054404630118924308
*/
data class FakeAirPodsGen3(
override val identifier: PodDevice.Id = PodDevice.Id(),
override val identifier: BlePodSnapshot.Id = BlePodSnapshot.Id(),
override val seenLastAt: Instant = Instant.now(),
override val seenFirstAt: Instant = Instant.now(),
override val seenCounter: Int = 1,
override val scanResult: BleScanResult,
override val payload: ProximityPayload,
override val meta: ApplePods.AppleMeta,
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
override val reliability: Float = BlePodSnapshot.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null,
) : DualApplePods, HasEarDetectionDual, HasChargeDetectionDual, HasCase {
@@ -4,10 +4,10 @@ import androidx.annotation.DrawableRes
import eu.darken.capod.R
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.BlePodSnapshot
import eu.darken.capod.pods.core.HasCase
import eu.darken.capod.pods.core.HasChargeDetectionDual
import eu.darken.capod.pods.core.HasEarDetectionDual
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.PodModel
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.ApplePodsFactory
@@ -24,14 +24,14 @@ import javax.inject.Inject
* https://discord.com/channels/548521543039189022/927235844127993866/962068944196358234
*/
data class FakeAirPodsPro(
override val identifier: PodDevice.Id = PodDevice.Id(),
override val identifier: BlePodSnapshot.Id = BlePodSnapshot.Id(),
override val seenLastAt: Instant = Instant.now(),
override val seenFirstAt: Instant = Instant.now(),
override val seenCounter: Int = 1,
override val scanResult: BleScanResult,
override val payload: ProximityPayload,
override val meta: ApplePods.AppleMeta,
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
override val reliability: Float = BlePodSnapshot.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null,
) : DualApplePods, HasChargeDetectionDual, HasEarDetectionDual, HasCase {
@@ -4,11 +4,11 @@ import androidx.annotation.DrawableRes
import eu.darken.capod.R
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.DualPodDevice
import eu.darken.capod.pods.core.BlePodSnapshot
import eu.darken.capod.pods.core.DualBlePodSnapshot
import eu.darken.capod.pods.core.HasCase
import eu.darken.capod.pods.core.HasChargeDetectionDual
import eu.darken.capod.pods.core.HasEarDetectionDual
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.PodModel
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.ApplePodsFactory
@@ -25,18 +25,18 @@ import javax.inject.Inject
* https://discord.com/channels/548521543039189022/927235844127993866/1060911213539774504
*/
data class FakeAirPodsPro2(
override val identifier: PodDevice.Id = PodDevice.Id(),
override val identifier: BlePodSnapshot.Id = BlePodSnapshot.Id(),
override val seenLastAt: Instant = Instant.now(),
override val seenFirstAt: Instant = Instant.now(),
override val seenCounter: Int = 1,
override val scanResult: BleScanResult,
override val payload: ProximityPayload,
override val meta: ApplePods.AppleMeta,
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
override val reliability: Float = BlePodSnapshot.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null,
private val cachedCaseState: LidState? = null,
) : DualApplePods, HasChargeDetectionDual, DualPodDevice, HasEarDetectionDual, HasCase {
) : DualApplePods, HasChargeDetectionDual, DualBlePodSnapshot, HasEarDetectionDual, HasCase {
override val model: PodModel = PodModel.FAKE_AIRPODS_PRO2
@@ -4,7 +4,7 @@ import android.content.Context
import eu.darken.capod.R
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.BlePodSnapshot
import eu.darken.capod.pods.core.PodModel
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.ApplePodsFactory
@@ -14,8 +14,8 @@ import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
import java.time.Instant
import javax.inject.Inject
data class UnknownAppleDevice(
override val identifier: PodDevice.Id = PodDevice.Id(),
data class UnknownAppleSnapshotBle(
override val identifier: BlePodSnapshot.Id = BlePodSnapshot.Id(),
override val seenLastAt: Instant = Instant.now(),
override val seenFirstAt: Instant = Instant.now(),
override val seenCounter: Int = 1,
@@ -43,7 +43,7 @@ data class UnknownAppleDevice(
payload: ProximityPayload,
meta: ApplePods.AppleMeta,
): ApplePods {
var basic = UnknownAppleDevice(scanResult = scanResult, payload = payload, meta = meta)
var basic = UnknownAppleSnapshotBle(scanResult = scanResult, payload = payload, meta = meta)
val result = repo.search(basic)
if (result != null) basic = basic.copy(identifier = result.id)
@@ -47,6 +47,6 @@ class RPAChecker @Inject constructor() {
}
companion object {
private val TAG = logTag("Monitor", "PodMonitor", "RPAChecker")
private val TAG = logTag("Monitor", "BlePodMonitor", "RPAChecker")
}
}
@@ -4,7 +4,7 @@ import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.BlePodSnapshot
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.time.Duration
@@ -17,9 +17,9 @@ class UnknownDeviceFactory @Inject constructor() {
private val lock = Mutex()
suspend fun create(scanResult: BleScanResult): PodDevice? = lock.withLock {
var basic = UnknownDevice(
identifier = PodDevice.Id(),
suspend fun create(scanResult: BleScanResult): BlePodSnapshot = lock.withLock {
var basic = UnknownSnapshotBle(
identifier = BlePodSnapshot.Id(),
scanResult = scanResult,
)
val result = searchHistory(basic)
@@ -40,10 +40,10 @@ class UnknownDeviceFactory @Inject constructor() {
}
data class KnownDevice(
val id: PodDevice.Id,
val id: BlePodSnapshot.Id,
val seenFirstAt: Instant,
val seenCounter: Int,
val history: List<PodDevice>
val history: List<BlePodSnapshot>
) {
val lastAddress: String
@@ -74,9 +74,9 @@ class UnknownDeviceFactory @Inject constructor() {
}
}
private val knownDevices = mutableMapOf<PodDevice.Id, KnownDevice>()
private val knownDevices = mutableMapOf<BlePodSnapshot.Id, KnownDevice>()
private fun searchHistory(current: PodDevice): KnownDevice? {
private fun searchHistory(current: BlePodSnapshot): KnownDevice? {
val scanResult = current.scanResult
knownDevices.values.toList().forEach { knownDevice ->
@@ -104,7 +104,7 @@ class UnknownDeviceFactory @Inject constructor() {
return recognizedDevice
}
private fun updateHistory(device: PodDevice) {
private fun updateHistory(device: BlePodSnapshot) {
val existing = knownDevices[device.identifier]
knownDevices[device.identifier] = when {
@@ -114,6 +114,7 @@ class UnknownDeviceFactory @Inject constructor() {
history = existing.history.plus(device)
)
}
else -> {
log(TAG) { "searchHistory1: Creating new history for $device" }
KnownDevice(
@@ -4,13 +4,13 @@ import android.content.Context
import eu.darken.capod.R
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.BlePodSnapshot
import eu.darken.capod.pods.core.PodModel
import eu.darken.capod.profiles.core.DeviceProfile
import java.time.Instant
data class UnknownDevice(
override val identifier: PodDevice.Id = PodDevice.Id(),
data class UnknownSnapshotBle(
override val identifier: BlePodSnapshot.Id = BlePodSnapshot.Id(),
override val seenLastAt: Instant = Instant.now(),
override val seenFirstAt: Instant = Instant.now(),
override val seenCounter: Int = 1,
@@ -18,7 +18,7 @@ data class UnknownDevice(
override val reliability: Float = 0f,
override val meta: Meta = Meta(),
private val rssiAverage: Int? = null,
) : PodDevice {
) : BlePodSnapshot {
override val model: PodModel = PodModel.UNKNOWN
@@ -29,7 +29,7 @@ data class UnknownDevice(
data class Meta(
override val profile: DeviceProfile? = null
) : PodDevice.Meta
) : BlePodSnapshot.Meta
companion object {
private val TAG = logTag("PodDevice", "Unknown")
@@ -2,6 +2,7 @@ package eu.darken.capod.reaction.core.playpause
import eu.darken.capod.common.MediaControl
import eu.darken.capod.common.bluetooth.BluetoothManager2
import eu.darken.capod.common.datastore.valueBlocking
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
import eu.darken.capod.common.debug.logging.log
@@ -9,7 +10,6 @@ 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.DeviceMonitor
import eu.darken.capod.monitor.core.MonitoredDevice
import eu.darken.capod.monitor.core.primaryDevice
import eu.darken.capod.reaction.core.ReactionSettings
import kotlinx.coroutines.flow.combine
@@ -20,7 +20,6 @@ import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.onEach
import javax.inject.Inject
import javax.inject.Singleton
import eu.darken.capod.common.datastore.valueBlocking
@Singleton
class PlayPause @Inject constructor(
@@ -63,7 +62,7 @@ class PlayPause @Inject constructor(
when {
previous!!.hasEarDetection && previous.hasDualPods &&
current!!.hasEarDetection && current.hasDualPods -> {
current!!.hasEarDetection && current.hasDualPods -> {
// Dual pod devices (AirPods, AirPods Pro, etc.)
log(TAG, VERBOSE) {
"Dual-pod device: left=${current.isLeftInEar}, right=${current.isRightInEar}"
@@ -2,15 +2,13 @@ package eu.darken.capod.reaction.core.popup
import eu.darken.capod.common.bluetooth.BluetoothAddress
import eu.darken.capod.common.bluetooth.BluetoothManager2
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
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.DeviceMonitor
import eu.darken.capod.monitor.core.MonitoredDevice
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.monitor.core.primaryDevice
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.reaction.core.ReactionSettings
@@ -55,7 +53,7 @@ class PopUpReaction @Inject constructor(
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
@@ -67,7 +65,7 @@ class PopUpReaction @Inject constructor(
throttleCasePopUps(current)
}
private fun throttleCasePopUps(current: MonitoredDevice): Event? {
private fun throttleCasePopUps(current: PodDevice): Event? {
val cooldownKey = current.meta?.profile?.id ?: current.identifier.toString()
val now = Instant.now()
val lastShown = caseCoolDowns[cooldownKey]
@@ -89,12 +87,14 @@ class PopUpReaction @Inject constructor(
caseCoolDowns[cooldownKey] = now
Event.PopupShow(device = current)
}
decision.shouldHide -> {
if (!decision.shouldResetCooldown) {
caseCoolDowns[cooldownKey] = now
}
Event.PopupHide()
}
else -> null
}
}
@@ -169,7 +169,7 @@ class PopUpReaction @Inject constructor(
sealed class Event {
data class PopupShow(
val eventAt: Instant = Instant.now(),
val device: MonitoredDevice,
val device: PodDevice,
) : Event()
data class PopupHide(
@@ -210,12 +210,14 @@ class PopUpReaction @Inject constructor(
)
}
}
DualApplePods.LidState.CLOSED -> CasePopUpDecision(
shouldShow = false,
shouldHide = true,
shouldResetCooldown = true,
reason = "Lid CLOSED, resetting cooldown",
)
else -> CasePopUpDecision(
shouldShow = false,
shouldHide = true,
@@ -34,7 +34,7 @@ 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.monitor.core.MonitoredDevice
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.monitor.core.getSignalQuality
import eu.darken.capod.pods.core.PodModel
import eu.darken.capod.pods.core.formatBatteryPercent
@@ -44,7 +44,7 @@ import eu.darken.capod.pods.core.toBatteryOrNull
@Composable
fun PopUpContent(
device: MonitoredDevice,
device: PodDevice,
onClose: () -> Unit,
modifier: Modifier = Modifier,
) {
@@ -117,7 +117,7 @@ fun PopUpContent(
}
@Composable
private fun DualPodContent(device: MonitoredDevice) {
private fun DualPodContent(device: PodDevice) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
@@ -148,7 +148,7 @@ private fun DualPodContent(device: MonitoredDevice) {
}
@Composable
private fun SinglePodContent(device: MonitoredDevice) {
private fun SinglePodContent(device: PodDevice) {
BatteryColumn(
iconRes = device.iconRes,
batteryPercent = device.batteryHeadset.toBatteryFloat(),
@@ -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.monitor.core.MonitoredDevice
import eu.darken.capod.monitor.core.PodDevice
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<MonitoredDevice?>? = null
private var deviceState: MutableState<PodDevice?>? = null
fun show(device: MonitoredDevice) {
fun show(device: PodDevice) {
try {
log(TAG) { "open()" }
@@ -64,7 +64,7 @@ class PopUpWindow @Inject constructor(
teardown()
val state = mutableStateOf<MonitoredDevice?>(device)
val state = mutableStateOf<PodDevice?>(device)
deviceState = state
val owner = OverlayLifecycleOwner()
@@ -6,18 +6,18 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.R
import eu.darken.capod.common.bluetooth.ScannerMode
import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.datastore.valueBlocking
import eu.darken.capod.common.debug.DebugSettings
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
import eu.darken.capod.common.debug.logging.log
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.BlePodMonitor
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
import eu.darken.capod.pods.core.unknown.UnknownDevice
import eu.darken.capod.pods.core.BlePodSnapshot
import eu.darken.capod.pods.core.unknown.UnknownSnapshotBle
import eu.darken.capod.profiles.core.AppleDeviceProfile
import eu.darken.capod.profiles.core.DeviceProfilesRepo
import kotlinx.coroutines.flow.MutableStateFlow
@@ -31,7 +31,6 @@ import kotlinx.coroutines.flow.takeWhile
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.withTimeoutOrNull
import javax.inject.Inject
import eu.darken.capod.common.datastore.valueBlocking
@HiltViewModel
class TroubleShooterViewModel @Inject constructor(
@@ -39,7 +38,7 @@ class TroubleShooterViewModel @Inject constructor(
private val dispatcherProvider: DispatcherProvider,
private val generalSettings: GeneralSettings,
private val profilesRepo: DeviceProfilesRepo,
private val podMonitor: PodMonitor,
private val blePodMonitor: BlePodMonitor,
private val deviceMonitor: DeviceMonitor,
private val debugSettings: DebugSettings,
) : ViewModel4(dispatcherProvider) {
@@ -98,39 +97,40 @@ class TroubleShooterViewModel @Inject constructor(
}
}
val doScan: suspend (Boolean, Boolean, Boolean, Boolean) -> Collection<PodDevice> = { hardwareFilteringDisabled,
hardwareBatchingDisabled,
indirectCallback,
unfiltered ->
val sb = StringBuilder("SCAN - Settings: ")
sb.append("hardwareFilteringDisabled=$hardwareFilteringDisabled, ")
sb.append("hardwareBatchingDisabled=$hardwareBatchingDisabled, ")
sb.append("indirectCallback=$indirectCallback, ")
sb.append("unfiltered=$unfiltered")
progress(sb.toString())
generalSettings.isOffloadedFilteringDisabled.valueBlocking = hardwareFilteringDisabled
generalSettings.isOffloadedBatchingDisabled.valueBlocking = hardwareBatchingDisabled
generalSettings.useIndirectScanResultCallback.valueBlocking = indirectCallback
debugSettings.showUnfiltered.valueBlocking = unfiltered
val doScan: suspend (Boolean, Boolean, Boolean, Boolean) -> Collection<BlePodSnapshot> =
{ hardwareFilteringDisabled,
hardwareBatchingDisabled,
indirectCallback,
unfiltered ->
val sb = StringBuilder("SCAN - Settings: ")
sb.append("hardwareFilteringDisabled=$hardwareFilteringDisabled, ")
sb.append("hardwareBatchingDisabled=$hardwareBatchingDisabled, ")
sb.append("indirectCallback=$indirectCallback, ")
sb.append("unfiltered=$unfiltered")
progress(sb.toString())
generalSettings.isOffloadedFilteringDisabled.valueBlocking = hardwareFilteringDisabled
generalSettings.isOffloadedBatchingDisabled.valueBlocking = hardwareBatchingDisabled
generalSettings.useIndirectScanResultCallback.valueBlocking = indirectCallback
debugSettings.showUnfiltered.valueBlocking = unfiltered
val start = System.currentTimeMillis()
val devices = withTimeoutOrNull(STEP_TIME) {
podMonitor.devices
.take(10)
.takeWhile { System.currentTimeMillis() - start < STEP_TIME - 1000 }
.toList()
.flatten()
.distinctBy { it.address }
} ?: emptyList()
log(TAG) { "SCAN: BLE Devices: $devices" }
if (devices.isNotEmpty()) {
progress("SCAN: Received data from ${devices.size} BLE devices")
devices
} else {
progress("SCAN: No data received")
devices
val start = System.currentTimeMillis()
val devices = withTimeoutOrNull(STEP_TIME) {
blePodMonitor.devices
.take(10)
.takeWhile { System.currentTimeMillis() - start < STEP_TIME - 1000 }
.toList()
.flatten()
.distinctBy { it.address }
} ?: emptyList()
log(TAG) { "SCAN: BLE Devices: $devices" }
if (devices.isNotEmpty()) {
progress("SCAN: Received data from ${devices.size} BLE devices")
devices
} else {
progress("SCAN: No data received")
devices
}
}
}
run {
progress("Checking if we can receive BLE data at all.")
@@ -158,14 +158,14 @@ class TroubleShooterViewModel @Inject constructor(
run {
progress("Checking for supported headphones.")
if (doScan(false, false, false, false).any { it !is UnknownDevice }) return@run
if (doScan(false, false, true, false).any { it !is UnknownDevice }) return@run
if (doScan(true, true, true, false).any { it !is UnknownDevice }) return@run
if (doScan(true, true, false, false).any { it !is UnknownDevice }) return@run
if (doScan(true, false, true, false).any { it !is UnknownDevice }) return@run
if (doScan(true, false, false, false).any { it !is UnknownDevice }) return@run
if (doScan(false, true, true, false).any { it !is UnknownDevice }) return@run
if (doScan(false, true, false, false).any { it !is UnknownDevice }) return@run
if (doScan(false, false, false, false).any { it !is UnknownSnapshotBle }) return@run
if (doScan(false, false, true, false).any { it !is UnknownSnapshotBle }) return@run
if (doScan(true, true, true, false).any { it !is UnknownSnapshotBle }) return@run
if (doScan(true, true, false, false).any { it !is UnknownSnapshotBle }) return@run
if (doScan(true, false, true, false).any { it !is UnknownSnapshotBle }) return@run
if (doScan(true, false, false, false).any { it !is UnknownSnapshotBle }) return@run
if (doScan(false, true, true, false).any { it !is UnknownSnapshotBle }) return@run
if (doScan(false, true, false, false).any { it !is UnknownSnapshotBle }) return@run
failure("No compatible headphones found", BleState.Result.Failure.Type.HEADPHONES)
@@ -196,7 +196,7 @@ class TroubleShooterViewModel @Inject constructor(
val otherDevices = withTimeoutOrNull(STEP_TIME) {
val start = System.currentTimeMillis()
podMonitor.devices
blePodMonitor.devices
.take(10)
.takeWhile { System.currentTimeMillis() - start < STEP_TIME - 1000 }
.toList()
@@ -215,7 +215,7 @@ class TroubleShooterViewModel @Inject constructor(
progress("Creating profile for closest headphones.")
val candidate = otherDevices
.filter { it !is UnknownDevice }
.filter { it !is UnknownSnapshotBle }
.maxBy { it.signalQuality }
log(TAG, INFO) { "Candidate is $candidate" }
@@ -9,9 +9,9 @@ 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.MonitoredDevice
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.monitor.core.worker.MonitorControl
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.BlePodSnapshot
import eu.darken.capod.profiles.core.AppleDeviceProfile
import eu.darken.capod.profiles.core.DeviceProfile
import eu.darken.capod.profiles.core.DeviceProfilesRepo
@@ -32,11 +32,11 @@ import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import testhelpers.BaseTest
import testhelpers.coroutine.TestDispatcherProvider
import testhelpers.datastore.FakeDataStoreValue
import testhelpers.livedata.InstantExecutorExtension
import org.junit.jupiter.api.extension.ExtendWith
@OptIn(ExperimentalCoroutinesApi::class)
@ExtendWith(InstantExecutorExtension::class)
@@ -54,7 +54,7 @@ class OverviewViewModelTest : BaseTest() {
private lateinit var profilesRepo: DeviceProfilesRepo
private lateinit var missingPermissionsFlow: MutableStateFlow<Set<Permission>>
private lateinit var devicesFlow: MutableStateFlow<List<MonitoredDevice>>
private lateinit var devicesFlow: MutableStateFlow<List<PodDevice>>
private lateinit var connectedDevicesFlow: MutableStateFlow<List<BluetoothDevice2>>
private lateinit var isBluetoothEnabledFlow: MutableStateFlow<Boolean>
private lateinit var profilesFlow: MutableStateFlow<List<DeviceProfile>>
@@ -154,7 +154,7 @@ class OverviewViewModelTest : BaseTest() {
@Test
fun `devices passed through when permissions granted`() = runTest(testDispatcher) {
val device = MonitoredDevice(ble = mockk(relaxed = true), aap = null)
val device = PodDevice(ble = mockk(relaxed = true), aap = null)
devicesFlow.value = listOf(device)
val vm = createViewModel()
@@ -165,17 +165,17 @@ class OverviewViewModelTest : BaseTest() {
@Test
fun `profiledDevices returns only devices with non-null profile`() {
val withProfile = object : PodDevice.Meta {
val withProfile = object : BlePodSnapshot.Meta {
override val profile: DeviceProfile = AppleDeviceProfile(label = "Test")
}
val withoutProfile = object : PodDevice.Meta {
val withoutProfile = object : BlePodSnapshot.Meta {
override val profile: DeviceProfile? = null
}
val profiled = MonitoredDevice(
val profiled = PodDevice(
ble = mockk(relaxed = true) { every { meta } returns withProfile },
aap = null,
)
val unmatched = MonitoredDevice(
val unmatched = PodDevice(
ble = mockk(relaxed = true) { every { meta } returns withoutProfile },
aap = null,
)
@@ -196,17 +196,17 @@ class OverviewViewModelTest : BaseTest() {
@Test
fun `unmatchedDevices returns only devices with null profile`() {
val withProfile = object : PodDevice.Meta {
val withProfile = object : BlePodSnapshot.Meta {
override val profile: DeviceProfile = AppleDeviceProfile(label = "Test")
}
val withoutProfile = object : PodDevice.Meta {
val withoutProfile = object : BlePodSnapshot.Meta {
override val profile: DeviceProfile? = null
}
val profiled = MonitoredDevice(
val profiled = PodDevice(
ble = mockk(relaxed = true) { every { meta } returns withProfile },
aap = null,
)
val unmatched = MonitoredDevice(
val unmatched = PodDevice(
ble = mockk(relaxed = true) { every { meta } returns withoutProfile },
aap = null,
)
@@ -1,10 +1,10 @@
package eu.darken.capod.monitor.core
import eu.darken.capod.pods.core.BlePodSnapshot
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.pods.core.PodDevice
import eu.darken.capod.pods.core.PodModel
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.protocol.aap.AapConnectionState
@@ -20,15 +20,15 @@ import org.junit.jupiter.api.Test
import testhelpers.BaseTest
import java.time.Instant
class MonitoredDeviceTest : BaseTest() {
class PodDeviceTest : BaseTest() {
private fun mockDualPod(
model: PodModel = PodModel.AIRPODS_PRO3,
leftBattery: Float? = 0.8f,
rightBattery: Float? = 0.9f,
caseBattery: Float? = 0.5f,
): PodDevice {
// DualApplePods implements DualPodDevice + HasCase + other interfaces
): BlePodSnapshot {
// DualApplePods implements DualBlePodSnapshot + HasCase + other interfaces
return mockk<DualApplePods>(relaxed = true) {
every { this@mockk.model } returns model
every { batteryLeftPodPercent } returns leftBattery
@@ -39,14 +39,14 @@ class MonitoredDeviceTest : BaseTest() {
@Test
fun `BLE-only device exposes battery from BLE`() {
val device = MonitoredDevice(ble = mockDualPod(leftBattery = 0.8f), aap = null)
val device = PodDevice(ble = mockDualPod(leftBattery = 0.8f), aap = null)
device.batteryLeft shouldBe 0.8f
device.isAapConnected shouldBe false
}
@Test
fun `capabilities come from model features`() {
val device = MonitoredDevice(
val device = PodDevice(
ble = mockDualPod(model = PodModel.AIRPODS_PRO3),
aap = null,
)
@@ -58,7 +58,7 @@ class MonitoredDeviceTest : BaseTest() {
@Test
fun `Beats Solo 3 has no dual pods or case`() {
val device = MonitoredDevice(
val device = PodDevice(
ble = mockk(relaxed = true) { every { model } returns PodModel.BEATS_SOLO_3 },
aap = null,
)
@@ -77,7 +77,7 @@ class MonitoredDeviceTest : BaseTest() {
),
),
)
val device = MonitoredDevice(ble = mockDualPod(), aap = aap)
val device = PodDevice(ble = mockDualPod(), aap = aap)
device.isAapConnected shouldBe true
device.ancMode.shouldNotBeNull()
device.ancMode!!.current shouldBe AncModeValue.TRANSPARENCY
@@ -85,21 +85,21 @@ class MonitoredDeviceTest : BaseTest() {
@Test
fun `ANC mode is null when not AAP connected`() {
val device = MonitoredDevice(ble = mockDualPod(), aap = null)
val device = PodDevice(ble = mockDualPod(), aap = null)
device.ancMode.shouldBeNull()
}
@Test
fun `null BLE gives UNKNOWN model`() {
val device = MonitoredDevice(ble = null, aap = null)
val device = PodDevice(ble = null, aap = null)
device.model shouldBe PodModel.UNKNOWN
}
@Test
fun `identity properties delegate to BLE`() {
val id = PodDevice.Id()
val meta = mockk<PodDevice.Meta>(relaxed = true)
val device = MonitoredDevice(
val id = BlePodSnapshot.Id()
val meta = mockk<BlePodSnapshot.Meta>(relaxed = true)
val device = PodDevice(
ble = mockk(relaxed = true) {
every { identifier } returns id
every { this@mockk.meta } returns meta
@@ -112,7 +112,7 @@ class MonitoredDeviceTest : BaseTest() {
@Test
fun `identity properties null when BLE null`() {
val device = MonitoredDevice(ble = null, aap = null)
val device = PodDevice(ble = null, aap = null)
device.identifier.shouldBeNull()
device.meta.shouldBeNull()
}
@@ -121,7 +121,7 @@ class MonitoredDeviceTest : BaseTest() {
fun `signal timing properties delegate to BLE`() {
val now = Instant.now()
val earlier = now.minusSeconds(60)
val device = MonitoredDevice(
val device = PodDevice(
ble = mockk(relaxed = true) {
every { seenLastAt } returns now
every { seenFirstAt } returns earlier
@@ -138,7 +138,7 @@ class MonitoredDeviceTest : BaseTest() {
@Test
fun `signal timing defaults when BLE null`() {
val device = MonitoredDevice(ble = null, aap = null)
val device = PodDevice(ble = null, aap = null)
device.seenLastAt.shouldBeNull()
device.seenFirstAt.shouldBeNull()
device.signalQuality shouldBe 0f
@@ -153,7 +153,7 @@ class MonitoredDeviceTest : BaseTest() {
every { (this@mockk as HasChargeDetectionDual).isRightPodCharging } returns false
every { (this@mockk as HasCase).isCaseCharging } returns true
}
val device = MonitoredDevice(ble = mock, aap = null)
val device = PodDevice(ble = mock, aap = null)
device.isLeftPodCharging shouldBe true
device.isRightPodCharging shouldBe false
device.isCaseCharging shouldBe true
@@ -168,7 +168,7 @@ class MonitoredDeviceTest : BaseTest() {
every { (this@mockk as HasEarDetection).isBeingWorn } returns false
every { (this@mockk as HasEarDetectionDual).isEitherPodInEar } returns true
}
val device = MonitoredDevice(ble = mock, aap = null)
val device = PodDevice(ble = mock, aap = null)
device.isLeftInEar shouldBe true
device.isRightInEar shouldBe false
device.isBeingWorn shouldBe false
@@ -177,7 +177,7 @@ class MonitoredDeviceTest : BaseTest() {
@Test
fun `icon and label properties delegate to BLE`() {
val device = MonitoredDevice(
val device = PodDevice(
ble = mockk(relaxed = true) {
every { model } returns PodModel.AIRPODS_PRO3
every { iconRes } returns 42
@@ -189,7 +189,7 @@ class MonitoredDeviceTest : BaseTest() {
@Test
fun `rawDataHex empty when BLE null`() {
val device = MonitoredDevice(ble = null, aap = null)
val device = PodDevice(ble = null, aap = null)
device.rawDataHex shouldBe emptyList()
}
}
@@ -1,9 +1,9 @@
package eu.darken.capod.pods.core.apple
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.BlePodSnapshot
import eu.darken.capod.pods.core.apple.airpods.AirPodsGen1
import eu.darken.capod.pods.core.apple.airpods.AirPodsPro
import eu.darken.capod.pods.core.apple.misc.UnknownAppleDevice
import eu.darken.capod.pods.core.apple.misc.UnknownAppleSnapshotBle
import io.kotest.matchers.shouldBe
import io.kotest.matchers.types.instanceOf
import kotlinx.coroutines.test.runTest
@@ -28,13 +28,13 @@ class AirPodsFactoryTest : BaseAirPodsTest() {
@Test
fun `unknown AppleDevice`() = runTest {
create<ApplePods>("07 19 01 FF FF 2B 99 8F 01 00 >09< 10 30 EE F3 41 B5 D8 9F A3 B0 B4 17 9F 85 97 5F") {
this shouldBe instanceOf<UnknownAppleDevice>()
this shouldBe instanceOf<UnknownAppleSnapshotBle>()
}
}
@Test
fun `invalid data`() = runTest {
create<PodDevice?>("abcd") {
create<BlePodSnapshot?>("abcd") {
this shouldBe null
}
}
@@ -6,7 +6,7 @@ import eu.darken.capod.common.SystemClockWrap
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.fromHex
import eu.darken.capod.common.serialization.SerializationModule
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.BlePodSnapshot
import eu.darken.capod.pods.core.apple.protocol.ContinuityProtocol
import eu.darken.capod.profiles.core.DeviceProfile
import eu.darken.capod.profiles.core.DeviceProfilesRepo
@@ -60,7 +60,7 @@ abstract class BaseAirPodsTest : BaseTest() {
every { SystemClockWrap.elapsedRealtimeNanos } returns 1000L
}
internal suspend inline fun <reified T : PodDevice?> create(
internal suspend inline fun <reified T : BlePodSnapshot?> create(
hex: String,
address: String = "77:49:4C:D8:25:0C",
block: T.() -> Unit