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