Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ internal fun DashboardContent() = PreviewWrapper {
showUnmatchedDevices = false,
),
onRequestPermission = {},
onBluetoothSettings = {},
onManageDevices = {},
onSettings = {},
onUpgrade = {},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,14 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.twotone.Bluetooth
import androidx.compose.material.icons.twotone.BluetoothConnected
import androidx.compose.material.icons.twotone.DevicesOther
import androidx.compose.material.icons.twotone.Settings
import androidx.compose.material.icons.twotone.Stars
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
Expand Down Expand Up @@ -126,6 +129,13 @@ fun OverviewScreenHost(vm: OverviewViewModel = hiltViewModel()) {
OverviewScreen(
state = currentState,
onRequestPermission = { vm.requestPermission(it) },
onBluetoothSettings = {
try {
context.startActivity(Intent(Settings.ACTION_BLUETOOTH_SETTINGS))
} catch (_: SecurityException) {
// Some devices require BT permission to open BT settings
}
},
onManageDevices = { vm.goToDeviceManager() },
onSettings = { vm.goToSettings() },
onUpgrade = { vm.onUpgrade() },
Expand All @@ -140,6 +150,7 @@ fun OverviewScreenHost(vm: OverviewViewModel = hiltViewModel()) {
fun OverviewScreen(
state: OverviewViewModel.State,
onRequestPermission: (Permission) -> Unit,
onBluetoothSettings: () -> Unit,
onManageDevices: () -> Unit,
onSettings: () -> Unit,
onUpgrade: () -> Unit,
Expand All @@ -155,6 +166,31 @@ fun OverviewScreen(
ToolbarTitle(upgradeInfo = state.upgradeInfo)
},
actions = {
// Bluetooth status icon
val btState = state.bluetoothIconState
if (btState != OverviewViewModel.BluetoothIconState.HIDDEN) {
IconButton(onClick = onBluetoothSettings) {
Icon(
imageVector = when (btState) {
OverviewViewModel.BluetoothIconState.CONNECTED -> Icons.TwoTone.BluetoothConnected
else -> Icons.TwoTone.Bluetooth
},
contentDescription = stringResource(
when (btState) {
OverviewViewModel.BluetoothIconState.DISABLED -> R.string.overview_bluetooth_disabled_icon_cd
OverviewViewModel.BluetoothIconState.CONNECTED -> R.string.overview_bluetooth_connected_icon_cd
else -> R.string.overview_bluetooth_nearby_icon_cd
}
),
tint = when (btState) {
OverviewViewModel.BluetoothIconState.DISABLED -> MaterialTheme.colorScheme.error
OverviewViewModel.BluetoothIconState.CONNECTED -> MaterialTheme.colorScheme.primary
else -> MaterialTheme.colorScheme.onSurface
},
)
}
}

// Upgrade/donate button based on type and pro status
val info = state.upgradeInfo
when {
Expand Down Expand Up @@ -345,6 +381,7 @@ private fun OverviewScreenWithDevicesPreview() = PreviewWrapper {
showUnmatchedDevices = false,
),
onRequestPermission = {},
onBluetoothSettings = {},
onManageDevices = {},
onSettings = {},
onUpgrade = {},
Expand All @@ -367,6 +404,7 @@ private fun OverviewScreenEmptyPreview() = PreviewWrapper {
showUnmatchedDevices = false,
),
onRequestPermission = {},
onBluetoothSettings = {},
onManageDevices = {},
onSettings = {},
onUpgrade = {},
Expand All @@ -389,6 +427,7 @@ private fun OverviewScreenNoProfilesPreview() = PreviewWrapper {
showUnmatchedDevices = false,
),
onRequestPermission = {},
onBluetoothSettings = {},
onManageDevices = {},
onSettings = {},
onUpgrade = {},
Expand All @@ -411,6 +450,7 @@ private fun OverviewScreenBluetoothOffPreview() = PreviewWrapper {
showUnmatchedDevices = false,
),
onRequestPermission = {},
onBluetoothSettings = {},
onManageDevices = {},
onSettings = {},
onUpgrade = {},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,8 @@ class OverviewViewModel @Inject constructor(
)
}.asLiveState()

enum class BluetoothIconState { HIDDEN, DISABLED, NEARBY, CONNECTED }

data class State(
val now: Instant,
val permissions: Set<Permission>,
Expand All @@ -143,6 +145,16 @@ class OverviewViewModel @Inject constructor(
get() = if (upgradeInfo.isPro) profiledDevices else profiledDevices.take(FREE_DEVICE_LIMIT)
val hiddenProfiledDeviceCount: Int get() = profiledDevices.size - visibleProfiledDevices.size
val unmatchedDevices: List<PodDevice> get() = devices.filter { it.profileId == null }

val bluetoothIconState: BluetoothIconState
get() = when {
isScanBlocked -> BluetoothIconState.HIDDEN
profiles.isEmpty() -> BluetoothIconState.HIDDEN
!isBluetoothEnabled -> BluetoothIconState.DISABLED
profiledDevices.any { it.isSystemConnected } -> BluetoothIconState.CONNECTED
profiledDevices.any { it.isLive } -> BluetoothIconState.NEARBY
else -> BluetoothIconState.HIDDEN
}
}

fun onPermissionResult() {
Expand Down
12 changes: 11 additions & 1 deletion app/src/main/java/eu/darken/capod/monitor/core/DeviceMonitor.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package eu.darken.capod.monitor.core

import eu.darken.capod.common.bluetooth.BluetoothAddress
import eu.darken.capod.common.bluetooth.BluetoothManager2
import eu.darken.capod.common.TimeSource
import eu.darken.capod.common.coroutine.AppScope
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
Expand All @@ -24,6 +25,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import javax.inject.Inject
import javax.inject.Singleton

Expand All @@ -40,6 +42,7 @@ class DeviceMonitor @Inject constructor(
@AppScope private val appScope: CoroutineScope,
private val blePodMonitor: BlePodMonitor,
private val aapManager: AapConnectionManager,
private val bluetoothManager: BluetoothManager2,
private val deviceStateCache: DeviceStateCache,
private val profilesRepo: DeviceProfilesRepo,
private val aapLifecycleManager: AapLifecycleManager,
Expand All @@ -52,9 +55,11 @@ class DeviceMonitor @Inject constructor(
private val liveState: Flow<LiveMergeState> = combine(
blePodMonitor.devices,
aapManager.allStates,
bluetoothManager.connectedDevices.onStart { emit(emptyList()) },
profilesRepo.profiles,
) { pods, aapStates, profiles ->
) { pods, aapStates, connectedBtDevices, profiles ->
val profilesById = profiles.associateBy { it.id }
val connectedAddresses = connectedBtDevices.mapTo(mutableSetOf()) { it.address }

// Live devices — BLE + AAP. Cache is merged later so cache writes don't feed back here.
val liveDevices = pods.map { pod ->
Expand All @@ -68,13 +73,15 @@ class DeviceMonitor @Inject constructor(
profileModel = profile?.model,
profileKeyState = profile.toBleKeyState(),
reactions = profile.toReactionConfig(),
isSystemConnected = profile?.address in connectedAddresses,
)
}

LiveMergeState(
liveDevices = liveDevices,
profiles = profiles,
aapStates = aapStates,
connectedAddresses = connectedAddresses,
)
}.onEach { liveState ->
persistLiveDevices(liveState.liveDevices)
Expand All @@ -89,6 +96,7 @@ class DeviceMonitor @Inject constructor(
}
val profiles = liveState.profiles
val aapStates = liveState.aapStates
val connectedAddresses = liveState.connectedAddresses

// Collapse live duplicates sharing an identity-backed profile — e.g. when the legacy
// signal-quality fallback misattributed ambient strangers to our profile.
Expand Down Expand Up @@ -138,6 +146,7 @@ class DeviceMonitor @Inject constructor(
profileModel = profile.model,
profileKeyState = profile.toBleKeyState(),
reactions = profile.toReactionConfig(),
isSystemConnected = profile.address in connectedAddresses,
)
}

Expand Down Expand Up @@ -183,6 +192,7 @@ class DeviceMonitor @Inject constructor(
val liveDevices: List<PodDevice>,
val profiles: List<DeviceProfile>,
val aapStates: Map<BluetoothAddress, AapPodState>,
val connectedAddresses: Set<BluetoothAddress>,
)

suspend fun getDeviceForProfile(profileId: String): PodDevice? {
Expand Down
2 changes: 2 additions & 0 deletions app/src/main/java/eu/darken/capod/monitor/core/PodDevice.kt
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ data class PodDevice(
internal val profileKeyState: BleKeyState = BleKeyState.NONE,
/** Reaction toggle snapshot from the profile. Defaults to all-off when no profile is matched. */
val reactions: ReactionConfig = ReactionConfig(),
/** True when the profile's BR/EDR address is in the system's connected Bluetooth devices. */
val isSystemConnected: Boolean = false,
) {
val model: PodModel get() = ble?.model ?: profileModel ?: cached?.model ?: PodModel.UNKNOWN
/** Bonded BR/EDR address (from profile). Used for AAP commands. */
Expand Down
3 changes: 3 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,9 @@

<string name="overview_nomaindevice_label">No device configured</string>
<string name="overview_nomaindevice_description">Configure your device to start monitoring battery levels and enable additional features.</string>
<string name="overview_bluetooth_disabled_icon_cd">Bluetooth disabled, open settings</string>
<string name="overview_bluetooth_nearby_icon_cd">Device nearby, open Bluetooth settings</string>
<string name="overview_bluetooth_connected_icon_cd">Device connected, open Bluetooth settings</string>
<string name="overview_bluetooth_disabled_label">Bluetooth is disabled</string>
<string name="overview_bluetooth_disabled_description">Bluetooth is disabled, enable it ;)</string>
<string name="overview_monitoring_active_label">Monitoring for devices</string>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package eu.darken.capod.monitor.core

import eu.darken.capod.common.TimeSource
import eu.darken.capod.common.bluetooth.BluetoothAddress
import eu.darken.capod.common.bluetooth.BluetoothManager2
import eu.darken.capod.monitor.core.aap.AapLifecycleManager
import eu.darken.capod.monitor.core.ble.BlePodMonitor
import eu.darken.capod.monitor.core.cache.CachedDeviceState
Expand Down Expand Up @@ -154,11 +155,15 @@ class DeviceMonitorTest : BaseTest() {
every { this@mockk.profiles } returns MutableStateFlow(profiles)
}
val aapLifecycleManager: AapLifecycleManager = mockk(relaxed = true)
val bluetoothManager: BluetoothManager2 = mockk {
every { connectedDevices } returns MutableStateFlow(emptyList())
}

return DeviceMonitor(
appScope = scope,
blePodMonitor = blePodMonitor,
aapManager = aapManager,
bluetoothManager = bluetoothManager,
deviceStateCache = deviceStateCache,
profilesRepo = profilesRepo,
aapLifecycleManager = aapLifecycleManager,
Expand Down Expand Up @@ -560,11 +565,15 @@ class DeviceMonitorTest : BaseTest() {
every { profiles } returns profilesFlow
}
val aapLifecycleManager: AapLifecycleManager = mockk(relaxed = true)
val bluetoothManager: BluetoothManager2 = mockk {
every { connectedDevices } returns MutableStateFlow(emptyList())
}

val monitor = DeviceMonitor(
appScope = backgroundScope,
blePodMonitor = blePodMonitor,
aapManager = aapManager,
bluetoothManager = bluetoothManager,
deviceStateCache = deviceStateCache,
profilesRepo = profilesRepo,
aapLifecycleManager = aapLifecycleManager,
Expand Down