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:
@@ -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" />
|
||||
|
||||
+147
-10
@@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user