From 0e31afbb25d4dc922446989430e1fb8521c07c15 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Apr 2026 03:15:22 +0000 Subject: [PATCH] feat: add Bluetooth headset audio routing for calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audio route cycling: the speaker button now cycles through Earpiece → Bluetooth → Speaker (or Earpiece → Speaker if no Bluetooth device is connected). CallAudioManager: - Detects Bluetooth SCO/A2DP/BLE headset devices via AudioManager - Manages Bluetooth SCO connection (startBluetoothSco/stopBluetoothSco) - BroadcastReceiver monitors SCO state changes — auto-falls back to earpiece if Bluetooth disconnects during call - Exposes audioRoute and isBluetoothAvailable as StateFlows CallController: - Replaced toggleSpeaker() with cycleAudioRoute() which delegates to CallAudioManager.cycleAudioRoute() - Exposes audioRoute and isBluetoothAvailable flows to UI CallScreen: - Audio route button shows context-aware icon: VolumeOff (earpiece), VolumeUp (speaker), BluetoothAudio - Blue tint for Bluetooth, cyan for speaker, white for earpiece Manifest: BLUETOOTH_CONNECT permission added. Strings: call_bluetooth string resource added. https://claude.ai/code/session_017hZm7yu7CzmcQgZGSaqSXS --- amethyst/src/main/AndroidManifest.xml | 1 + .../amethyst/service/call/CallAudioManager.kt | 157 ++++++++++++++++-- .../amethyst/service/call/CallController.kt | 13 +- .../amethyst/ui/call/CallScreen.kt | 34 +++- amethyst/src/main/res/values/strings.xml | 1 + 5 files changed, 180 insertions(+), 26 deletions(-) diff --git a/amethyst/src/main/AndroidManifest.xml b/amethyst/src/main/AndroidManifest.xml index 1009e17b9..f5e39a388 100644 --- a/amethyst/src/main/AndroidManifest.xml +++ b/amethyst/src/main/AndroidManifest.xml @@ -44,6 +44,7 @@ + diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallAudioManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallAudioManager.kt index d5168878b..d703c22d8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallAudioManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallAudioManager.kt @@ -20,8 +20,12 @@ */ package com.vitorpamplona.amethyst.service.call +import android.content.BroadcastReceiver import android.content.Context +import android.content.Intent +import android.content.IntentFilter import android.media.AudioAttributes +import android.media.AudioDeviceInfo import android.media.AudioManager import android.media.Ringtone import android.media.RingtoneManager @@ -31,6 +35,15 @@ import android.os.PowerManager import android.os.VibrationEffect import android.os.Vibrator import android.os.VibratorManager +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +enum class AudioRoute { + EARPIECE, + SPEAKER, + BLUETOOTH, +} class CallAudioManager( private val context: Context, @@ -41,6 +54,13 @@ class CallAudioManager( private var ringbackTone: ToneGenerator? = null private val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager private var previousAudioMode: Int = AudioManager.MODE_NORMAL + private var scoReceiver: BroadcastReceiver? = null + + private val _audioRoute = MutableStateFlow(AudioRoute.EARPIECE) + val audioRoute: StateFlow = _audioRoute.asStateFlow() + + private val _isBluetoothAvailable = MutableStateFlow(false) + val isBluetoothAvailable: StateFlow = _isBluetoothAvailable.asStateFlow() fun startRinging() { startRingtone() @@ -59,7 +79,6 @@ class CallAudioManager( it.startTone(ToneGenerator.TONE_SUP_RINGTONE) } } catch (_: Exception) { - // ToneGenerator may not be available } } @@ -72,14 +91,43 @@ class CallAudioManager( fun switchToCallAudioMode() { previousAudioMode = audioManager.mode audioManager.mode = AudioManager.MODE_IN_COMMUNICATION - audioManager.isSpeakerphoneOn = false + + _isBluetoothAvailable.value = hasBluetoothDevice() + if (_isBluetoothAvailable.value) { + startBluetoothSco() + } else { + routeToEarpiece() + } + registerBluetoothScoReceiver() } fun restoreAudioMode() { + stopBluetoothSco() + unregisterBluetoothScoReceiver() audioManager.mode = previousAudioMode audioManager.isSpeakerphoneOn = false } + fun cycleAudioRoute() { + val hasBt = _isBluetoothAvailable.value + val next = + when (_audioRoute.value) { + AudioRoute.EARPIECE -> if (hasBt) AudioRoute.BLUETOOTH else AudioRoute.SPEAKER + AudioRoute.BLUETOOTH -> AudioRoute.SPEAKER + AudioRoute.SPEAKER -> AudioRoute.EARPIECE + } + setAudioRoute(next) + } + + fun setAudioRoute(route: AudioRoute) { + _audioRoute.value = route + when (route) { + AudioRoute.EARPIECE -> routeToEarpiece() + AudioRoute.SPEAKER -> routeToSpeaker() + AudioRoute.BLUETOOTH -> routeToBluetooth() + } + } + fun acquireProximityWakeLock() { if (proximityWakeLock != null) return val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager @@ -88,13 +136,11 @@ class CallAudioManager( PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK, "amethyst:call_proximity", ) - proximityWakeLock?.acquire(60 * 60 * 1000L) // 1 hour max + proximityWakeLock?.acquire(60 * 60 * 1000L) } fun releaseProximityWakeLock() { - proximityWakeLock?.let { - if (it.isHeld) it.release() - } + proximityWakeLock?.let { if (it.isHeld) it.release() } proximityWakeLock = null } @@ -105,6 +151,100 @@ class CallAudioManager( releaseProximityWakeLock() } + private fun routeToEarpiece() { + stopBluetoothSco() + audioManager.isSpeakerphoneOn = false + } + + private fun routeToSpeaker() { + stopBluetoothSco() + audioManager.isSpeakerphoneOn = true + } + + private fun routeToBluetooth() { + audioManager.isSpeakerphoneOn = false + startBluetoothSco() + } + + private fun hasBluetoothDevice(): Boolean = + try { + audioManager + .getDevices(AudioManager.GET_DEVICES_OUTPUTS) + .any { + it.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO || + it.type == AudioDeviceInfo.TYPE_BLUETOOTH_A2DP || + it.type == AudioDeviceInfo.TYPE_BLE_HEADSET + } + } catch (_: Exception) { + false + } + + @Suppress("DEPRECATION") + private fun startBluetoothSco() { + try { + if (!audioManager.isBluetoothScoOn) { + audioManager.startBluetoothSco() + audioManager.isBluetoothScoOn = true + } + } catch (_: Exception) { + } + } + + @Suppress("DEPRECATION") + private fun stopBluetoothSco() { + try { + if (audioManager.isBluetoothScoOn) { + audioManager.isBluetoothScoOn = false + audioManager.stopBluetoothSco() + } + } catch (_: Exception) { + } + } + + private fun registerBluetoothScoReceiver() { + if (scoReceiver != null) return + scoReceiver = + object : BroadcastReceiver() { + override fun onReceive( + ctx: Context?, + intent: Intent?, + ) { + val state = intent?.getIntExtra(AudioManager.EXTRA_SCO_AUDIO_STATE, -1) + when (state) { + AudioManager.SCO_AUDIO_STATE_CONNECTED -> { + _isBluetoothAvailable.value = true + if (_audioRoute.value == AudioRoute.BLUETOOTH) { + audioManager.isBluetoothScoOn = true + } + } + + AudioManager.SCO_AUDIO_STATE_DISCONNECTED -> { + _isBluetoothAvailable.value = hasBluetoothDevice() + if (_audioRoute.value == AudioRoute.BLUETOOTH) { + _audioRoute.value = AudioRoute.EARPIECE + routeToEarpiece() + } + } + } + } + } + @Suppress("DEPRECATION") + context.registerReceiver( + scoReceiver, + IntentFilter(AudioManager.ACTION_SCO_AUDIO_STATE_UPDATED), + ) + } + + private fun unregisterBluetoothScoReceiver() { + scoReceiver?.let { + try { + context.unregisterReceiver(it) + } catch (_: Exception) { + } + } + scoReceiver = null + } + private fun startRingtone() { try { val ringtoneUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_RINGTONE) @@ -120,7 +260,6 @@ class CallAudioManager( play() } } catch (_: Exception) { - // Ringtone may not be available } } @@ -139,11 +278,9 @@ class CallAudioManager( @Suppress("DEPRECATION") context.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator } - - val pattern = longArrayOf(0, 1000, 1000) // vibrate 1s, pause 1s, repeat + val pattern = longArrayOf(0, 1000, 1000) vibrator?.vibrate(VibrationEffect.createWaveform(pattern, 0)) } catch (_: Exception) { - // Vibrator may not be available } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt index 12b5f4236..7ba71f51f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt @@ -22,7 +22,6 @@ package com.vitorpamplona.amethyst.service.call import android.content.Context import android.content.Intent -import android.media.AudioManager import com.vitorpamplona.amethyst.commons.call.CallManager import com.vitorpamplona.amethyst.commons.call.CallState import com.vitorpamplona.amethyst.service.notifications.NotificationUtils @@ -75,8 +74,8 @@ class CallController( val isAudioMuted: StateFlow = _isAudioMuted.asStateFlow() private val _isVideoEnabled = MutableStateFlow(true) val isVideoEnabled: StateFlow = _isVideoEnabled.asStateFlow() - private val _isSpeakerOn = MutableStateFlow(false) - val isSpeakerOn: StateFlow = _isSpeakerOn.asStateFlow() + val audioRoute: StateFlow = audioManager.audioRoute + val isBluetoothAvailable: StateFlow = audioManager.isBluetoothAvailable init { scope.launch { @@ -232,11 +231,8 @@ class CallController( webRtcSession?.setVideoEnabled(enabled) } - fun toggleSpeaker() { - val on = !_isSpeakerOn.value - _isSpeakerOn.value = on - val am = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager - am.isSpeakerphoneOn = on + fun cycleAudioRoute() { + audioManager.cycleAudioRoute() } fun getEglBase() = webRtcSession?.eglBase @@ -258,7 +254,6 @@ class CallController( _localVideoTrack.value = null _isAudioMuted.value = false _isVideoEnabled.value = true - _isSpeakerOn.value = false webRtcSession?.dispose() webRtcSession = null remoteDescriptionSet = false diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt index db5642629..b6385b91a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt @@ -35,6 +35,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.BluetoothAudio import androidx.compose.material.icons.filled.Call import androidx.compose.material.icons.filled.CallEnd import androidx.compose.material.icons.filled.Mic @@ -69,6 +70,7 @@ import androidx.compose.ui.viewinterop.AndroidView import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.call.CallManager import com.vitorpamplona.amethyst.commons.call.CallState +import com.vitorpamplona.amethyst.service.call.AudioRoute import com.vitorpamplona.amethyst.service.call.CallController import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture import com.vitorpamplona.amethyst.ui.note.UsernameDisplay @@ -148,7 +150,7 @@ fun CallScreen( onHangup = { scope.launch { callManager.hangup() } }, onToggleMute = { callController?.toggleAudioMute() }, onToggleVideo = { callController?.toggleVideo() }, - onToggleSpeaker = { callController?.toggleSpeaker() }, + onCycleAudioRoute = { callController?.cycleAudioRoute() }, ) } @@ -327,7 +329,7 @@ private fun ConnectedCallUI( onHangup: () -> Unit, onToggleMute: () -> Unit, onToggleVideo: () -> Unit, - onToggleSpeaker: () -> Unit, + onCycleAudioRoute: () -> Unit, ) { var elapsed by remember { mutableLongStateOf(0L) } @@ -343,9 +345,10 @@ private fun ConnectedCallUI( val localVideoTrack by (callController?.localVideoTrack ?: emptyVideoFlow).collectAsState() val defaultFalse = remember { kotlinx.coroutines.flow.MutableStateFlow(false) } val defaultTrue = remember { kotlinx.coroutines.flow.MutableStateFlow(true) } + val defaultRoute = remember { kotlinx.coroutines.flow.MutableStateFlow(AudioRoute.EARPIECE) } val isAudioMuted by (callController?.isAudioMuted ?: defaultFalse).collectAsState() val isVideoEnabled by (callController?.isVideoEnabled ?: defaultTrue).collectAsState() - val isSpeakerOn by (callController?.isSpeakerOn ?: defaultFalse).collectAsState() + val currentAudioRoute by (callController?.audioRoute ?: defaultRoute).collectAsState() Box( modifier = @@ -455,13 +458,30 @@ private fun ConnectedCallUI( ) } IconButton( - onClick = onToggleSpeaker, + onClick = onCycleAudioRoute, modifier = Modifier.size(56.dp), ) { Icon( - imageVector = if (isSpeakerOn) Icons.Default.VolumeUp else Icons.Default.VolumeOff, - contentDescription = stringRes(if (isSpeakerOn) R.string.call_earpiece else R.string.call_speaker), - tint = if (isSpeakerOn) Color.Cyan else Color.White, + imageVector = + when (currentAudioRoute) { + AudioRoute.EARPIECE -> Icons.Default.VolumeOff + AudioRoute.SPEAKER -> Icons.Default.VolumeUp + AudioRoute.BLUETOOTH -> Icons.Default.BluetoothAudio + }, + contentDescription = + stringRes( + when (currentAudioRoute) { + AudioRoute.EARPIECE -> R.string.call_earpiece + AudioRoute.SPEAKER -> R.string.call_speaker + AudioRoute.BLUETOOTH -> R.string.call_bluetooth + }, + ), + tint = + when (currentAudioRoute) { + AudioRoute.EARPIECE -> Color.White + AudioRoute.SPEAKER -> Color.Cyan + AudioRoute.BLUETOOTH -> Color(0xFF2196F3) + }, modifier = Modifier.size(28.dp), ) } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 0e84ddb15..13ae35a7f 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -793,6 +793,7 @@ Camera off Speaker Earpiece + Bluetooth Voice call Video call Failed to start call