refactor: Make connectedDevices a hot StateFlow

Convert `connectedDevices()` from a function returning a cold Flow to a property that is a hot `StateFlow`. This simplifies call sites and improves efficiency by sharing the underlying subscription.
This commit is contained in:
darken
2025-10-29 15:51:48 +01:00
committed by Matthias Urhahn
parent 1cfc4611cb
commit b5436659b6
6 changed files with 49 additions and 31 deletions
@@ -12,8 +12,8 @@ import android.content.Intent
import android.content.IntentFilter
import android.os.Handler
import android.os.HandlerThread
import android.os.ParcelUuid
import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.common.coroutine.AppScope
import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.debug.Bugs
import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR
@@ -21,15 +21,23 @@ 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.pods.core.apple.protocol.ContinuityProtocol
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.plus
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.io.IOException
@@ -39,9 +47,10 @@ import javax.inject.Singleton
@Singleton
class BluetoothManager2 @Inject constructor(
private val manager: BluetoothManager,
@ApplicationContext private val context: Context,
@AppScope private val appScope: CoroutineScope,
private val dispatcherProvider: DispatcherProvider,
@ApplicationContext private val context: Context,
private val manager: BluetoothManager,
) {
val adapter: BluetoothAdapter?
@@ -103,11 +112,11 @@ class BluetoothManager2 @Inject constructor(
}
private fun monitorDevicesForProfile(
private fun monitorProfile(
profile: Int = BluetoothProfile.HEADSET
): Flow<Set<BluetoothDevice>> = getBluetoothProfile(profile).flatMapLatest { bluetoothProfile ->
callbackFlow {
log(TAG, VERBOSE) { "monitorDevices(): for profile=$profile starting" }
log(TAG, VERBOSE) { "monitorProfile(): for profile=$profile starting" }
trySend(bluetoothProfile.connectedDevices)
val filter = IntentFilter().apply {
@@ -121,21 +130,21 @@ class BluetoothManager2 @Inject constructor(
val receiver: BroadcastReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
log(TAG, VERBOSE) { "monitorDevices(): Bluetooth event (intent=$intent, extras=${intent.extras})" }
log(TAG, VERBOSE) { "monitorProfile(): Bluetooth event (intent=$intent, extras=${intent.extras})" }
val action = intent.action
if (action == null) {
log(TAG, ERROR) { "monitorDevices(): Bluetooth event without action?" }
log(TAG, ERROR) { "monitorProfile(): Bluetooth event without action?" }
return
}
val device = intent.getParcelableExtra<BluetoothDevice?>(BluetoothDevice.EXTRA_DEVICE)
if (device == null) {
log(TAG, ERROR) { "monitorDevices(): Event is missing EXTRA_DEVICE" }
log(TAG, ERROR) { "monitorProfile(): Event is missing EXTRA_DEVICE" }
return
}
this@callbackFlow.launch {
val currentDevices = bluetoothProfile.connectedDevices.toMutableSet()
log(TAG) { "monitorDevices(): currentDevices: $currentDevices" }
log(TAG) { "monitorProfile(): currentDevices: $currentDevices" }
if (action != BluetoothHeadset.ACTION_CONNECTION_STATE_CHANGED) {
log(TAG, WARN) { "Unknown action: $action" }
@@ -144,34 +153,34 @@ class BluetoothManager2 @Inject constructor(
// Profile connection changed - query actual state from proxy
val statePrevious = intent.getIntExtra(BluetoothProfile.EXTRA_PREVIOUS_STATE, -1)
log(TAG) { "monitorDevices(): HEADSET profile state changed for $device - previous: $statePrevious" }
log(TAG) { "monitorProfile(): HEADSET profile state changed for $device - previous: $statePrevious" }
val stateNow = intent.getIntExtra(BluetoothProfile.EXTRA_STATE, -1)
log(TAG) { "monitorDevices(): HEADSET profile state changed for $device - now: $stateNow" }
log(TAG) { "monitorProfile(): HEADSET profile state changed for $device - now: $stateNow" }
when (stateNow) {
BluetoothProfile.STATE_CONNECTING -> {
log(TAG) { "monitorDevices(): Currently connecting $device" }
log(TAG) { "monitorProfile(): Currently connecting $device" }
}
BluetoothProfile.STATE_CONNECTED -> {
log(TAG) { "monitorDevices(): Device has connected $device" }
log(TAG) { "monitorProfile(): Device has connected $device" }
if (!currentDevices.contains(device)) {
log(TAG, WARN) { "monitorDevices(): $device was not in $currentDevices" }
log(TAG, WARN) { "monitorProfile(): $device was not in $currentDevices" }
currentDevices.add(device)
}
trySend(currentDevices)
}
BluetoothProfile.STATE_DISCONNECTING -> {
log(TAG) { "monitorDevices(): Currently DISconnecting $device" }
log(TAG) { "monitorProfile(): Currently DISconnecting $device" }
}
BluetoothProfile.STATE_DISCONNECTED -> {
log(TAG) { "monitorDevices(): Device has disconnected $device" }
if (!currentDevices.contains(device)) {
log(TAG, WARN) { "monitorDevices(): $device WAS in $currentDevices" }
log(TAG) { "monitorProfile(): Device has disconnected $device" }
if (currentDevices.contains(device)) {
log(TAG, WARN) { "monitorProfile(): $device WAS in $currentDevices" }
currentDevices.remove(device)
}
trySend(currentDevices)
@@ -183,7 +192,7 @@ class BluetoothManager2 @Inject constructor(
context.registerReceiver(receiver, filter, null, handler)
awaitClose {
log(TAG, VERBOSE) { "connectedDevices(profile=$profile) closed." }
log(TAG, VERBOSE) { "monitorProfile(): profile=$profile closed." }
context.unregisterReceiver(receiver)
}
}
@@ -192,10 +201,8 @@ class BluetoothManager2 @Inject constructor(
private val seenDevicesLock = Mutex()
private val seenDevicesCache = mutableMapOf<String, Instant>()
fun connectedDevices(
featureFilter: Set<ParcelUuid> = ContinuityProtocol.BLE_FEATURE_UUIDS
): Flow<List<BluetoothDevice2>> = isBluetoothEnabled
.flatMapLatest { monitorDevicesForProfile(BluetoothProfile.HEADSET) }
val connectedDevices: Flow<List<BluetoothDevice2>> = isBluetoothEnabled
.flatMapLatest { monitorProfile(BluetoothProfile.HEADSET) }
.map { devices ->
val currentAddresses = devices.map { it.address }
@@ -206,7 +213,9 @@ class BluetoothManager2 @Inject constructor(
}
devices
.filter { device -> featureFilter.any { feature -> device.hasFeature(feature) } }
.filter { device ->
ContinuityProtocol.BLE_FEATURE_UUIDS.any { feature -> device.hasFeature(feature) }
}
.map { device ->
BluetoothDevice2(
internal = device,
@@ -218,6 +227,17 @@ class BluetoothManager2 @Inject constructor(
)
}
}
.distinctUntilChanged()
.setupCommonEventHandlers(TAG) { "connectedDevices" }
.stateIn(
scope = appScope + Dispatchers.IO,
started = SharingStarted.WhileSubscribed(
stopTimeoutMillis = 5_000L,
replayExpirationMillis = 0L,
),
initialValue = null
)
.filterNotNull()
fun bondedDevices(): Flow<Set<BluetoothDevice2>> = flow {
val rawDevices = adapter?.bondedDevices ?: throw IllegalStateException("Bluetooth adapter unavailable")
@@ -90,7 +90,7 @@ class OverviewFragmentVM @Inject constructor(
val shouldStartMonitor = when (generalSettings.monitorMode.value) {
MonitorMode.MANUAL -> false
MonitorMode.AUTOMATIC -> bluetoothManager.connectedDevices().first().isNotEmpty()
MonitorMode.AUTOMATIC -> bluetoothManager.connectedDevices.first().isNotEmpty()
MonitorMode.ALWAYS -> true
}
if (shouldStartMonitor) {
@@ -148,7 +148,7 @@ class MonitorWorker @AssistedInject constructor(
combine(
generalSettings.monitorMode.flow,
profilesRepo.profiles,
bluetoothManager.connectedDevices(),
bluetoothManager.connectedDevices,
) { monitorMode, profiles, connectedDevices ->
listOf(monitorMode, profiles, connectedDevices)
}
@@ -16,7 +16,6 @@ import eu.darken.capod.profiles.core.DeviceProfilesRepo
import eu.darken.capod.reaction.core.ReactionSettings
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.filterNotNull
@@ -39,7 +38,7 @@ class AutoConnect @Inject constructor(
.flatMapLatest { isAutoConnectEnabled ->
if (isAutoConnectEnabled) {
combine(
bluetoothManager.connectedDevices().distinctUntilChanged(),
bluetoothManager.connectedDevices,
podMonitor.primaryDevice().filterNotNull().distinctUntilChangedBy { it.rawDataHex },
) { connectedDevices, mainDevice ->
connectedDevices to mainDevice
@@ -35,7 +35,7 @@ class PlayPause @Inject constructor(
reactionSettings.autoPause.flow,
reactionSettings.onePodMode.flow,
) { play, pause, _ -> play || pause }
.flatMapLatest { if (it) bluetoothManager.connectedDevices() else emptyFlow() }
.flatMapLatest { if (it) bluetoothManager.connectedDevices else emptyFlow() }
.flatMapLatest {
if (it.isEmpty()) {
log(TAG) { "No known devices connected." }
@@ -16,7 +16,6 @@ import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.reaction.core.ReactionSettings
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flatMapLatest
@@ -116,7 +115,7 @@ class PopUpReaction @Inject constructor(
if (!isEnabled) return@flatMapLatest emptyFlow()
combine(
bluetoothManager.connectedDevices().distinctUntilChanged(),
bluetoothManager.connectedDevices,
podMonitor.primaryDevice().distinctUntilChangedBy { it?.rawDataHex },
) { devices, broadcast ->
log(TAG) { "$broadcast $devices " }