feat: add Bluetooth headset audio routing for calls

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
This commit is contained in:
Claude
2026-04-02 03:15:22 +00:00
parent 6e92e8153d
commit 0e31afbb25
5 changed files with 180 additions and 26 deletions
+1
View File
@@ -44,6 +44,7 @@
<!-- Phone calls -->
<uses-permission android:name="android.permission.VIBRATE"/>
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<!-- Keeps screen on while playing videos -->
<uses-permission android:name="android.permission.WAKE_LOCK" />
@@ -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> = _audioRoute.asStateFlow()
private val _isBluetoothAvailable = MutableStateFlow(false)
val isBluetoothAvailable: StateFlow<Boolean> = _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
}
}
@@ -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<Boolean> = _isAudioMuted.asStateFlow()
private val _isVideoEnabled = MutableStateFlow(true)
val isVideoEnabled: StateFlow<Boolean> = _isVideoEnabled.asStateFlow()
private val _isSpeakerOn = MutableStateFlow(false)
val isSpeakerOn: StateFlow<Boolean> = _isSpeakerOn.asStateFlow()
val audioRoute: StateFlow<AudioRoute> = audioManager.audioRoute
val isBluetoothAvailable: StateFlow<Boolean> = 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
@@ -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),
)
}
+1
View File
@@ -793,6 +793,7 @@
<string name="call_camera_off">Camera off</string>
<string name="call_speaker">Speaker</string>
<string name="call_earpiece">Earpiece</string>
<string name="call_bluetooth">Bluetooth</string>
<string name="call_voice">Voice call</string>
<string name="call_video">Video call</string>
<string name="call_failed_start">Failed to start call</string>