From b5436659b61d3010571044b3ccd4a13e66cffccd Mon Sep 17 00:00:00 2001 From: darken Date: Wed, 29 Oct 2025 15:17:30 +0100 Subject: [PATCH] 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. --- .../common/bluetooth/BluetoothManager2.kt | 68 ++++++++++++------- .../main/ui/overview/OverviewFragmentVM.kt | 2 +- .../monitor/core/worker/MonitorWorker.kt | 2 +- .../reaction/core/autoconnect/AutoConnect.kt | 3 +- .../reaction/core/playpause/PlayPause.kt | 2 +- .../reaction/core/popup/PopUpReaction.kt | 3 +- 6 files changed, 49 insertions(+), 31 deletions(-) diff --git a/app/src/main/java/eu/darken/capod/common/bluetooth/BluetoothManager2.kt b/app/src/main/java/eu/darken/capod/common/bluetooth/BluetoothManager2.kt index 853e328a..057e3a32 100644 --- a/app/src/main/java/eu/darken/capod/common/bluetooth/BluetoothManager2.kt +++ b/app/src/main/java/eu/darken/capod/common/bluetooth/BluetoothManager2.kt @@ -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> = 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.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() - fun connectedDevices( - featureFilter: Set = ContinuityProtocol.BLE_FEATURE_UUIDS - ): Flow> = isBluetoothEnabled - .flatMapLatest { monitorDevicesForProfile(BluetoothProfile.HEADSET) } + val connectedDevices: Flow> = 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> = flow { val rawDevices = adapter?.bondedDevices ?: throw IllegalStateException("Bluetooth adapter unavailable") diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewFragmentVM.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewFragmentVM.kt index 7f4ae3b1..03eb6a42 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewFragmentVM.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewFragmentVM.kt @@ -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) { diff --git a/app/src/main/java/eu/darken/capod/monitor/core/worker/MonitorWorker.kt b/app/src/main/java/eu/darken/capod/monitor/core/worker/MonitorWorker.kt index 448f351a..aee08c35 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/worker/MonitorWorker.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/worker/MonitorWorker.kt @@ -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) } diff --git a/app/src/main/java/eu/darken/capod/reaction/core/autoconnect/AutoConnect.kt b/app/src/main/java/eu/darken/capod/reaction/core/autoconnect/AutoConnect.kt index d4585c45..75b38b4c 100644 --- a/app/src/main/java/eu/darken/capod/reaction/core/autoconnect/AutoConnect.kt +++ b/app/src/main/java/eu/darken/capod/reaction/core/autoconnect/AutoConnect.kt @@ -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 diff --git a/app/src/main/java/eu/darken/capod/reaction/core/playpause/PlayPause.kt b/app/src/main/java/eu/darken/capod/reaction/core/playpause/PlayPause.kt index c351eea7..e000debc 100644 --- a/app/src/main/java/eu/darken/capod/reaction/core/playpause/PlayPause.kt +++ b/app/src/main/java/eu/darken/capod/reaction/core/playpause/PlayPause.kt @@ -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." } diff --git a/app/src/main/java/eu/darken/capod/reaction/core/popup/PopUpReaction.kt b/app/src/main/java/eu/darken/capod/reaction/core/popup/PopUpReaction.kt index b4580360..88d4a024 100644 --- a/app/src/main/java/eu/darken/capod/reaction/core/popup/PopUpReaction.kt +++ b/app/src/main/java/eu/darken/capod/reaction/core/popup/PopUpReaction.kt @@ -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 " }