Thanks to visit codestin.com
Credit goes to github.com

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
143 changes: 105 additions & 38 deletions app/src/main/java/eu/darken/capod/common/bluetooth/BluetoothManager2.kt
Original file line number Diff line number Diff line change
Expand Up @@ -12,36 +12,47 @@ 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
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.channels.awaitClose
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.catch
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.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.retryWhen
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
import java.time.Instant
import javax.inject.Inject
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?
Expand Down Expand Up @@ -89,7 +100,7 @@ class BluetoothManager2 @Inject constructor(

override fun onServiceDisconnected(profile: Int) {
log(TAG, WARN) { "onServiceDisconnected(profile=$profile)" }
close(IOException("BluetoothProfile service disconnected (profile=$profile)"))
close() // Close gracefully without exception to prevent crash
}

}, profile)
Expand All @@ -103,75 +114,91 @@ 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" }
trySend(bluetoothProfile.connectedDevices)
log(TAG, VERBOSE) { "monitorProfile(): for profile=$profile starting" }

try {
trySend(bluetoothProfile.connectedDevices)
} catch (e: Exception) {
log(TAG, ERROR) { "monitorProfile(): Error querying initial connected devices: $e" }
close(e)
return@callbackFlow
}

val filter = IntentFilter().apply {
addAction(BluetoothHeadset.ACTION_CONNECTION_STATE_CHANGED)
}

val handlerThread = HandlerThread("BluetoothEventReceiver").apply {
start()
}
val handlerThread = HandlerThread("BluetoothEventReceiver").apply { start() }
val handler = Handler(handlerThread.looper)

val receiver: BroadcastReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
log(TAG, VERBOSE) { "monitorDevices(): Bluetooth event (intent=$intent, extras=${intent.extras})" }
val action = intent.action
if (action == null) {
log(TAG, ERROR) { "monitorDevices(): Bluetooth event without action?" }
log(TAG, VERBOSE) { "monitorProfile(): Bluetooth event (intent=$intent, extras=${intent.extras})" }

if (intent.action == null) {
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
}

[email protected] {
val currentDevices = bluetoothProfile.connectedDevices.toMutableSet()
log(TAG) { "monitorDevices(): currentDevices: $currentDevices" }

if (action != BluetoothHeadset.ACTION_CONNECTION_STATE_CHANGED) {
log(TAG, WARN) { "Unknown action: $action" }
if (intent.action != BluetoothHeadset.ACTION_CONNECTION_STATE_CHANGED) {
log(TAG, WARN) { "Unknown action: ${intent.action}" }
return@launch
}

// 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" }

val currentDevices = try {
bluetoothProfile.connectedDevices
} catch (e: Exception) {
log(TAG, ERROR) { "monitorProfile(): Error handling profile event: $e" }
// Log but continue - don't kill the whole Flow for one bad event
emptySet()
}.toMutableSet()
log(TAG) { "monitorProfile(): currentDevices: $currentDevices" }

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,
VERBOSE
) { "monitorProfile(): $device not in proxy yet, adding manually" }
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,
VERBOSE
) { "monitorProfile(): $device still in proxy, removing manually" }
currentDevices.remove(device)
}
trySend(currentDevices)
Expand All @@ -180,22 +207,36 @@ class BluetoothManager2 @Inject constructor(
}
}
}
context.registerReceiver(receiver, filter, null, handler)

try {
context.registerReceiver(receiver, filter, null, handler)
} catch (e: Exception) {
log(TAG, ERROR) { "monitorProfile(): Failed to register receiver: $e" }
close(e)
return@callbackFlow
}

awaitClose {
log(TAG, VERBOSE) { "connectedDevices(profile=$profile) closed." }
context.unregisterReceiver(receiver)
log(TAG, VERBOSE) { "monitorProfile(): profile=$profile closed." }
try {
context.unregisterReceiver(receiver)
} catch (e: Exception) {
log(TAG, ERROR) { "monitorProfile(): Error unregistering receiver: $e" }
} finally {
handlerThread.quitSafely()
}
}
}
}

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 { enabled ->
if (enabled) monitorProfile(BluetoothProfile.HEADSET)
else flowOf(emptySet()) // Return empty when Bluetooth is off
}
.map { devices ->
val currentAddresses = devices.map { it.address }

Expand All @@ -206,7 +247,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,
Expand All @@ -218,6 +261,30 @@ class BluetoothManager2 @Inject constructor(
)
}
}
.retryWhen { cause, attempt ->
log(TAG, WARN) { "connectedDevices Flow failed (attempt ${attempt + 1}): $cause" }
if (attempt < 3) {
delay(1000 * (attempt + 1)) // 1s, 2s, 3s exponential backoff
true // Retry
} else {
false // Give up after 3 attempts
}
}
.catch { e ->
log(TAG, ERROR) { "connectedDevices Flow failed after retries: $e" }
emit(emptyList()) // Emit empty list and complete gracefully
}
.distinctUntilChanged()
.setupCommonEventHandlers(TAG) { "connectedDevices" }
.stateIn(
scope = appScope + dispatcherProvider.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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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." }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 " }
Expand Down