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 --> <!-- Phone calls -->
<uses-permission android:name="android.permission.VIBRATE"/> <uses-permission android:name="android.permission.VIBRATE"/>
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<!-- Keeps screen on while playing videos --> <!-- Keeps screen on while playing videos -->
<uses-permission android:name="android.permission.WAKE_LOCK" /> <uses-permission android:name="android.permission.WAKE_LOCK" />
@@ -20,8 +20,12 @@
*/ */
package com.vitorpamplona.amethyst.service.call package com.vitorpamplona.amethyst.service.call
import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.media.AudioAttributes import android.media.AudioAttributes
import android.media.AudioDeviceInfo
import android.media.AudioManager import android.media.AudioManager
import android.media.Ringtone import android.media.Ringtone
import android.media.RingtoneManager import android.media.RingtoneManager
@@ -31,6 +35,15 @@ import android.os.PowerManager
import android.os.VibrationEffect import android.os.VibrationEffect
import android.os.Vibrator import android.os.Vibrator
import android.os.VibratorManager 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( class CallAudioManager(
private val context: Context, private val context: Context,
@@ -41,6 +54,13 @@ class CallAudioManager(
private var ringbackTone: ToneGenerator? = null private var ringbackTone: ToneGenerator? = null
private val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager private val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
private var previousAudioMode: Int = AudioManager.MODE_NORMAL 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() { fun startRinging() {
startRingtone() startRingtone()
@@ -59,7 +79,6 @@ class CallAudioManager(
it.startTone(ToneGenerator.TONE_SUP_RINGTONE) it.startTone(ToneGenerator.TONE_SUP_RINGTONE)
} }
} catch (_: Exception) { } catch (_: Exception) {
// ToneGenerator may not be available
} }
} }
@@ -72,14 +91,43 @@ class CallAudioManager(
fun switchToCallAudioMode() { fun switchToCallAudioMode() {
previousAudioMode = audioManager.mode previousAudioMode = audioManager.mode
audioManager.mode = AudioManager.MODE_IN_COMMUNICATION audioManager.mode = AudioManager.MODE_IN_COMMUNICATION
audioManager.isSpeakerphoneOn = false
_isBluetoothAvailable.value = hasBluetoothDevice()
if (_isBluetoothAvailable.value) {
startBluetoothSco()
} else {
routeToEarpiece()
}
registerBluetoothScoReceiver()
} }
fun restoreAudioMode() { fun restoreAudioMode() {
stopBluetoothSco()
unregisterBluetoothScoReceiver()
audioManager.mode = previousAudioMode audioManager.mode = previousAudioMode
audioManager.isSpeakerphoneOn = false 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() { fun acquireProximityWakeLock() {
if (proximityWakeLock != null) return if (proximityWakeLock != null) return
val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager
@@ -88,13 +136,11 @@ class CallAudioManager(
PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK, PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK,
"amethyst:call_proximity", "amethyst:call_proximity",
) )
proximityWakeLock?.acquire(60 * 60 * 1000L) // 1 hour max proximityWakeLock?.acquire(60 * 60 * 1000L)
} }
fun releaseProximityWakeLock() { fun releaseProximityWakeLock() {
proximityWakeLock?.let { proximityWakeLock?.let { if (it.isHeld) it.release() }
if (it.isHeld) it.release()
}
proximityWakeLock = null proximityWakeLock = null
} }
@@ -105,6 +151,100 @@ class CallAudioManager(
releaseProximityWakeLock() 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() { private fun startRingtone() {
try { try {
val ringtoneUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_RINGTONE) val ringtoneUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_RINGTONE)
@@ -120,7 +260,6 @@ class CallAudioManager(
play() play()
} }
} catch (_: Exception) { } catch (_: Exception) {
// Ringtone may not be available
} }
} }
@@ -139,11 +278,9 @@ class CallAudioManager(
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
context.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator context.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
} }
val pattern = longArrayOf(0, 1000, 1000)
val pattern = longArrayOf(0, 1000, 1000) // vibrate 1s, pause 1s, repeat
vibrator?.vibrate(VibrationEffect.createWaveform(pattern, 0)) vibrator?.vibrate(VibrationEffect.createWaveform(pattern, 0))
} catch (_: Exception) { } catch (_: Exception) {
// Vibrator may not be available
} }
} }
@@ -22,7 +22,6 @@ package com.vitorpamplona.amethyst.service.call
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.media.AudioManager
import com.vitorpamplona.amethyst.commons.call.CallManager import com.vitorpamplona.amethyst.commons.call.CallManager
import com.vitorpamplona.amethyst.commons.call.CallState import com.vitorpamplona.amethyst.commons.call.CallState
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils import com.vitorpamplona.amethyst.service.notifications.NotificationUtils
@@ -75,8 +74,8 @@ class CallController(
val isAudioMuted: StateFlow<Boolean> = _isAudioMuted.asStateFlow() val isAudioMuted: StateFlow<Boolean> = _isAudioMuted.asStateFlow()
private val _isVideoEnabled = MutableStateFlow(true) private val _isVideoEnabled = MutableStateFlow(true)
val isVideoEnabled: StateFlow<Boolean> = _isVideoEnabled.asStateFlow() val isVideoEnabled: StateFlow<Boolean> = _isVideoEnabled.asStateFlow()
private val _isSpeakerOn = MutableStateFlow(false) val audioRoute: StateFlow<AudioRoute> = audioManager.audioRoute
val isSpeakerOn: StateFlow<Boolean> = _isSpeakerOn.asStateFlow() val isBluetoothAvailable: StateFlow<Boolean> = audioManager.isBluetoothAvailable
init { init {
scope.launch { scope.launch {
@@ -232,11 +231,8 @@ class CallController(
webRtcSession?.setVideoEnabled(enabled) webRtcSession?.setVideoEnabled(enabled)
} }
fun toggleSpeaker() { fun cycleAudioRoute() {
val on = !_isSpeakerOn.value audioManager.cycleAudioRoute()
_isSpeakerOn.value = on
val am = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
am.isSpeakerphoneOn = on
} }
fun getEglBase() = webRtcSession?.eglBase fun getEglBase() = webRtcSession?.eglBase
@@ -258,7 +254,6 @@ class CallController(
_localVideoTrack.value = null _localVideoTrack.value = null
_isAudioMuted.value = false _isAudioMuted.value = false
_isVideoEnabled.value = true _isVideoEnabled.value = true
_isSpeakerOn.value = false
webRtcSession?.dispose() webRtcSession?.dispose()
webRtcSession = null webRtcSession = null
remoteDescriptionSet = false remoteDescriptionSet = false
@@ -35,6 +35,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons 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.Call
import androidx.compose.material.icons.filled.CallEnd import androidx.compose.material.icons.filled.CallEnd
import androidx.compose.material.icons.filled.Mic 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.R
import com.vitorpamplona.amethyst.commons.call.CallManager import com.vitorpamplona.amethyst.commons.call.CallManager
import com.vitorpamplona.amethyst.commons.call.CallState 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.service.call.CallController
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
@@ -148,7 +150,7 @@ fun CallScreen(
onHangup = { scope.launch { callManager.hangup() } }, onHangup = { scope.launch { callManager.hangup() } },
onToggleMute = { callController?.toggleAudioMute() }, onToggleMute = { callController?.toggleAudioMute() },
onToggleVideo = { callController?.toggleVideo() }, onToggleVideo = { callController?.toggleVideo() },
onToggleSpeaker = { callController?.toggleSpeaker() }, onCycleAudioRoute = { callController?.cycleAudioRoute() },
) )
} }
@@ -327,7 +329,7 @@ private fun ConnectedCallUI(
onHangup: () -> Unit, onHangup: () -> Unit,
onToggleMute: () -> Unit, onToggleMute: () -> Unit,
onToggleVideo: () -> Unit, onToggleVideo: () -> Unit,
onToggleSpeaker: () -> Unit, onCycleAudioRoute: () -> Unit,
) { ) {
var elapsed by remember { mutableLongStateOf(0L) } var elapsed by remember { mutableLongStateOf(0L) }
@@ -343,9 +345,10 @@ private fun ConnectedCallUI(
val localVideoTrack by (callController?.localVideoTrack ?: emptyVideoFlow).collectAsState() val localVideoTrack by (callController?.localVideoTrack ?: emptyVideoFlow).collectAsState()
val defaultFalse = remember { kotlinx.coroutines.flow.MutableStateFlow(false) } val defaultFalse = remember { kotlinx.coroutines.flow.MutableStateFlow(false) }
val defaultTrue = remember { kotlinx.coroutines.flow.MutableStateFlow(true) } 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 isAudioMuted by (callController?.isAudioMuted ?: defaultFalse).collectAsState()
val isVideoEnabled by (callController?.isVideoEnabled ?: defaultTrue).collectAsState() val isVideoEnabled by (callController?.isVideoEnabled ?: defaultTrue).collectAsState()
val isSpeakerOn by (callController?.isSpeakerOn ?: defaultFalse).collectAsState() val currentAudioRoute by (callController?.audioRoute ?: defaultRoute).collectAsState()
Box( Box(
modifier = modifier =
@@ -455,13 +458,30 @@ private fun ConnectedCallUI(
) )
} }
IconButton( IconButton(
onClick = onToggleSpeaker, onClick = onCycleAudioRoute,
modifier = Modifier.size(56.dp), modifier = Modifier.size(56.dp),
) { ) {
Icon( Icon(
imageVector = if (isSpeakerOn) Icons.Default.VolumeUp else Icons.Default.VolumeOff, imageVector =
contentDescription = stringRes(if (isSpeakerOn) R.string.call_earpiece else R.string.call_speaker), when (currentAudioRoute) {
tint = if (isSpeakerOn) Color.Cyan else Color.White, 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), modifier = Modifier.size(28.dp),
) )
} }
+1
View File
@@ -793,6 +793,7 @@
<string name="call_camera_off">Camera off</string> <string name="call_camera_off">Camera off</string>
<string name="call_speaker">Speaker</string> <string name="call_speaker">Speaker</string>
<string name="call_earpiece">Earpiece</string> <string name="call_earpiece">Earpiece</string>
<string name="call_bluetooth">Bluetooth</string>
<string name="call_voice">Voice call</string> <string name="call_voice">Voice call</string>
<string name="call_video">Video call</string> <string name="call_video">Video call</string>
<string name="call_failed_start">Failed to start call</string> <string name="call_failed_start">Failed to start call</string>