Merge pull request #2203 from vitorpamplona/claude/review-webrtc-calls-a7ZCq

Improve WebRTC call reliability and notification handling
This commit is contained in:
Vitor Pamplona
2026-04-10 08:51:21 -04:00
committed by GitHub
8 changed files with 160 additions and 45 deletions
@@ -184,7 +184,7 @@ 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) proximityWakeLock?.acquire(10 * 60 * 1000L)
registerProximitySensor() registerProximitySensor()
} }
@@ -320,10 +320,11 @@ class CallAudioManager(
} }
} }
} }
@Suppress("DEPRECATION") ContextCompat.registerReceiver(
context.registerReceiver( context,
scoReceiver, scoReceiver,
IntentFilter(AudioManager.ACTION_SCO_AUDIO_STATE_UPDATED), IntentFilter(AudioManager.ACTION_SCO_AUDIO_STATE_UPDATED),
ContextCompat.RECEIVER_NOT_EXPORTED,
) )
} }
@@ -417,14 +417,16 @@ class CallController(
onIceCandidate = { candidate -> onLocalIceCandidate(peerPubKey, candidate) }, onIceCandidate = { candidate -> onLocalIceCandidate(peerPubKey, candidate) },
onPeerConnected = { onPeerConnected = {
Log.d(TAG) { "Peer ${peerPubKey.take(8)} connected!" } Log.d(TAG) { "Peer ${peerPubKey.take(8)} connected!" }
scope.launch { callManager.onPeerConnected() } scope.launch {
if (!foregroundServiceStarted) { callManager.onPeerConnected()
foregroundServiceStarted = true if (!foregroundServiceStarted) {
startForegroundService() foregroundServiceStarted = true
startForegroundService()
}
} }
}, },
onRemoteVideoTrack = { track -> videoMonitor.onRemoteVideoTrack(peerPubKey, track) }, onRemoteVideoTrack = { track -> videoMonitor.onRemoteVideoTrack(peerPubKey, track) },
onDisconnected = { onPeerDisconnected(peerPubKey) }, onDisconnected = { scope.launch { onPeerDisconnected(peerPubKey) } },
onError = { error -> _errorMessage.value = error }, onError = { error -> _errorMessage.value = error },
onRenegotiationNeeded = { performRenegotiation(peerPubKey) }, onRenegotiationNeeded = { performRenegotiation(peerPubKey) },
) )
@@ -24,6 +24,7 @@ import android.Manifest
import android.app.Notification import android.app.Notification
import android.app.NotificationChannel import android.app.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service import android.app.Service
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
@@ -34,6 +35,7 @@ import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.call.CallActivity
import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.Log
private const val TAG = "CallForegroundService" private const val TAG = "CallForegroundService"
@@ -46,6 +48,7 @@ class CallForegroundService : Service() {
const val ACTION_STOP = "com.vitorpamplona.amethyst.CALL_STOP" const val ACTION_STOP = "com.vitorpamplona.amethyst.CALL_STOP"
const val EXTRA_PEER_NAME = "peer_name" const val EXTRA_PEER_NAME = "peer_name"
const val EXTRA_IS_VIDEO = "is_video" const val EXTRA_IS_VIDEO = "is_video"
private const val HANGUP_REQUEST_CODE = 0x70001
} }
override fun onBind(intent: Intent?): IBinder? = null override fun onBind(intent: Intent?): IBinder? = null
@@ -118,12 +121,35 @@ class CallForegroundService : Service() {
notificationManager.createNotificationChannel(channel) notificationManager.createNotificationChannel(channel)
} }
private fun buildNotification(peerName: String): Notification = private fun buildNotification(peerName: String): Notification {
NotificationCompat val openCallIntent =
PendingIntent.getActivity(
this,
0,
Intent(this, CallActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
},
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
val hangupIntent =
PendingIntent.getBroadcast(
this,
HANGUP_REQUEST_CODE,
Intent(this, CallNotificationReceiver::class.java).apply {
action = CallNotificationReceiver.ACTION_HANGUP_CALL
},
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
return NotificationCompat
.Builder(this, CHANNEL_ID) .Builder(this, CHANNEL_ID)
.setContentTitle(getString(R.string.app_name)) .setContentTitle(getString(R.string.app_name))
.setContentText(getString(R.string.call_with, peerName)) .setContentText(getString(R.string.call_with, peerName))
.setSmallIcon(R.drawable.amethyst) .setSmallIcon(R.drawable.amethyst)
.setOngoing(true) .setOngoing(true)
.setContentIntent(openCallIntent)
.addAction(R.drawable.ic_call_end, getString(R.string.call_hangup), hangupIntent)
.build() .build()
}
} }
@@ -77,21 +77,33 @@ class CallMediaManager(
fun initialize(callType: CallType) { fun initialize(callType: CallType) {
if (peerConnectionFactory != null) return if (peerConnectionFactory != null) return
sharedEglBase = EglBase.create() var egl: EglBase? = null
try {
egl = EglBase.create()
PeerConnectionFactory.initialize( PeerConnectionFactory.initialize(
PeerConnectionFactory PeerConnectionFactory
.InitializationOptions .InitializationOptions
.builder(context) .builder(context)
.createInitializationOptions(), .createInitializationOptions(),
) )
peerConnectionFactory = peerConnectionFactory =
PeerConnectionFactory PeerConnectionFactory
.builder() .builder()
.setVideoDecoderFactory(DefaultVideoDecoderFactory(sharedEglBase!!.eglBaseContext)) .setVideoDecoderFactory(DefaultVideoDecoderFactory(egl.eglBaseContext))
.setVideoEncoderFactory(DefaultVideoEncoderFactory(sharedEglBase!!.eglBaseContext, true, true)) .setVideoEncoderFactory(DefaultVideoEncoderFactory(egl.eglBaseContext, true, true))
.createPeerConnectionFactory() .createPeerConnectionFactory()
sharedEglBase = egl
} catch (e: Exception) {
// Clean up partially-created resources to avoid leaking EglBase
egl?.release()
peerConnectionFactory?.dispose()
peerConnectionFactory = null
sharedEglBase = null
throw e
}
localAudioSource = peerConnectionFactory?.createAudioSource(MediaConstraints()) localAudioSource = peerConnectionFactory?.createAudioSource(MediaConstraints())
localAudioTrack = peerConnectionFactory?.createAudioTrack("audio0", localAudioSource) localAudioTrack = peerConnectionFactory?.createAudioTrack("audio0", localAudioSource)
@@ -145,6 +157,7 @@ class CallMediaManager(
localVideoTrack?.setEnabled(true) localVideoTrack?.setEnabled(true)
_isVideoEnabled.value = true _isVideoEnabled.value = true
_localVideoTrackFlow.value = localVideoTrack _localVideoTrackFlow.value = localVideoTrack
startCamera()
} }
fun disableVideo() { fun disableVideo() {
@@ -24,8 +24,6 @@ import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils import com.vitorpamplona.amethyst.service.notifications.NotificationUtils
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
/** /**
@@ -36,24 +34,32 @@ import kotlinx.coroutines.launch
* notification trampoline restrictions. * notification trampoline restrictions.
*/ */
class CallNotificationReceiver : BroadcastReceiver() { class CallNotificationReceiver : BroadcastReceiver() {
@OptIn(DelicateCoroutinesApi::class)
override fun onReceive( override fun onReceive(
context: Context, context: Context,
intent: Intent, intent: Intent,
) { ) {
when (intent.action) { val callManager = CallSessionBridge.callManager ?: return
ACTION_REJECT_CALL -> { val pendingResult = goAsync()
NotificationUtils.cancelCallNotification(context) kotlinx.coroutines.MainScope().launch {
try {
when (intent.action) {
ACTION_REJECT_CALL -> {
NotificationUtils.cancelCallNotification(context)
callManager.rejectCall()
}
val callManager = CallSessionBridge.callManager ?: return ACTION_HANGUP_CALL -> {
GlobalScope.launch { callManager.hangup()
callManager.rejectCall() }
} }
} finally {
pendingResult.finish()
} }
} }
} }
companion object { companion object {
const val ACTION_REJECT_CALL = "com.vitorpamplona.amethyst.REJECT_CALL" const val ACTION_REJECT_CALL = "com.vitorpamplona.amethyst.REJECT_CALL"
const val ACTION_HANGUP_CALL = "com.vitorpamplona.amethyst.HANGUP_CALL"
} }
} }
@@ -55,6 +55,7 @@ class WebRtcCallSession(
private val onRenegotiationNeeded: () -> Unit = {}, private val onRenegotiationNeeded: () -> Unit = {},
) { ) {
private var peerConnection: PeerConnection? = null private var peerConnection: PeerConnection? = null
private var iceRestartAttempted = false
fun createPeerConnection() { fun createPeerConnection() {
val rtcConfig = val rtcConfig =
@@ -90,6 +91,7 @@ class WebRtcCallSession(
PeerConnection.IceConnectionState.CONNECTED -> { PeerConnection.IceConnectionState.CONNECTED -> {
Log.d(TAG) { "ICE: CONNECTED - peer connection established!" } Log.d(TAG) { "ICE: CONNECTED - peer connection established!" }
iceRestartAttempted = false
onPeerConnected() onPeerConnected()
} }
@@ -98,9 +100,15 @@ class WebRtcCallSession(
} }
PeerConnection.IceConnectionState.FAILED -> { PeerConnection.IceConnectionState.FAILED -> {
Log.e(TAG, "ICE: FAILED - could not establish connection") if (!iceRestartAttempted) {
onError("Connection failed - check network") iceRestartAttempted = true
onDisconnected() Log.d(TAG) { "ICE: FAILED - attempting ICE restart" }
restartIce()
} else {
Log.e(TAG, "ICE: FAILED after restart - giving up")
onError("Connection failed - check network")
onDisconnected()
}
} }
PeerConnection.IceConnectionState.DISCONNECTED -> { PeerConnection.IceConnectionState.DISCONNECTED -> {
@@ -255,6 +263,42 @@ class WebRtcCallSession(
Log.d(TAG) { "addIceCandidate result=$added sdpMid=${candidate.sdpMid} sdp=${candidate.sdp.take(60)}" } Log.d(TAG) { "addIceCandidate result=$added sdpMid=${candidate.sdpMid} sdp=${candidate.sdp.take(60)}" }
} }
/**
* Triggers an ICE restart by creating a new offer with iceRestart=true.
* This re-gathers candidates and attempts to establish connectivity
* without tearing down the PeerConnection.
*/
private fun restartIce() {
val constraints =
MediaConstraints().apply {
mandatory.add(MediaConstraints.KeyValuePair("IceRestart", "true"))
mandatory.add(MediaConstraints.KeyValuePair("OfferToReceiveAudio", "true"))
mandatory.add(MediaConstraints.KeyValuePair("OfferToReceiveVideo", "true"))
}
peerConnection?.createOffer(
object : SdpObserver {
override fun onCreateSuccess(sdp: SessionDescription?) {
sdp?.let {
Log.d(TAG) { "ICE restart offer created, setting local description" }
peerConnection?.setLocalDescription(loggingSdpObserver("setLocalDescription(ICE_RESTART)"), it)
onRenegotiationNeeded()
}
}
override fun onCreateFailure(error: String?) {
Log.e(TAG, "ICE restart offer creation failed: $error")
onError("Connection failed - check network")
onDisconnected()
}
override fun onSetSuccess() {}
override fun onSetFailure(error: String?) {}
},
constraints,
)
}
fun getSignalingState(): PeerConnection.SignalingState? = peerConnection?.signalingState() fun getSignalingState(): PeerConnection.SignalingState? = peerConnection?.signalingState()
/** /**
@@ -46,9 +46,9 @@ import com.vitorpamplona.amethyst.ui.StringResSetup
import com.vitorpamplona.amethyst.ui.screen.ManageRelayServices import com.vitorpamplona.amethyst.ui.screen.ManageRelayServices
import com.vitorpamplona.amethyst.ui.screen.ManageWebOkHttp import com.vitorpamplona.amethyst.ui.screen.ManageWebOkHttp
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class CallActivity : AppCompatActivity() { class CallActivity : AppCompatActivity() {
val isInPipMode = mutableStateOf(false) val isInPipMode = mutableStateOf(false)
@@ -69,14 +69,13 @@ class CallActivity : AppCompatActivity() {
private val pipActionReceiver = private val pipActionReceiver =
object : BroadcastReceiver() { object : BroadcastReceiver() {
@OptIn(DelicateCoroutinesApi::class)
override fun onReceive( override fun onReceive(
context: Context, context: Context,
intent: Intent, intent: Intent,
) { ) {
when (intent.action) { when (intent.action) {
ACTION_PIP_HANGUP -> { ACTION_PIP_HANGUP -> {
GlobalScope.launch { CallSessionBridge.callManager?.hangup() } lifecycleScope.launch { CallSessionBridge.callManager?.hangup() }
} }
ACTION_PIP_TOGGLE_MUTE -> { ACTION_PIP_TOGGLE_MUTE -> {
@@ -191,7 +190,6 @@ class CallActivity : AppCompatActivity() {
} }
} }
@OptIn(DelicateCoroutinesApi::class)
override fun onStop() { override fun onStop() {
super.onStop() super.onStop()
// Only hang up if the user dismissed PiP (swiped it away). // Only hang up if the user dismissed PiP (swiped it away).
@@ -202,29 +200,36 @@ class CallActivity : AppCompatActivity() {
if (wasInPipMode && !isInPictureInPictureMode) { if (wasInPipMode && !isInPictureInPictureMode) {
val state = CallSessionBridge.callManager?.state?.value val state = CallSessionBridge.callManager?.state?.value
if (state is CallState.Connected || state is CallState.Connecting || state is CallState.Offering) { if (state is CallState.Connected || state is CallState.Connecting || state is CallState.Offering) {
GlobalScope.launch { CallSessionBridge.callManager?.hangup() } lifecycleScope.launch {
withContext(NonCancellable) { CallSessionBridge.callManager?.hangup() }
}
} }
finishAndRemoveTask() finishAndRemoveTask()
} }
} }
@OptIn(DelicateCoroutinesApi::class)
override fun onDestroy() { override fun onDestroy() {
unregisterPipReceiver() unregisterPipReceiver()
// Safety net: if the Activity is destroyed while a call is still // Safety net: if the Activity is destroyed while a call is still
// ringing/offering, ensure the call is hung up so audio stops. // ringing/offering, ensure the call is hung up so audio stops.
// Use NonCancellable so the signaling event is published even
// though the lifecycle scope is being cancelled.
val manager = CallSessionBridge.callManager val manager = CallSessionBridge.callManager
when (manager?.state?.value) { when (manager?.state?.value) {
is CallState.IncomingCall -> { is CallState.IncomingCall -> {
GlobalScope.launch { manager.rejectCall() } lifecycleScope.launch {
withContext(NonCancellable) { manager.rejectCall() }
}
} }
is CallState.Offering, is CallState.Offering,
is CallState.Connecting, is CallState.Connecting,
is CallState.Connected, is CallState.Connected,
-> { -> {
GlobalScope.launch { manager.hangup() } lifecycleScope.launch {
withContext(NonCancellable) { manager.hangup() }
}
} }
else -> {} else -> {}
@@ -36,6 +36,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -63,6 +64,10 @@ fun VideoRenderer(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
mirror: Boolean = false, mirror: Boolean = false,
) { ) {
// Track the current track so the update block can swap sinks when the
// track reference changes (e.g. after renegotiation).
val currentTrack = remember { mutableStateOf(videoTrack) }
AndroidView( AndroidView(
modifier = modifier, modifier = modifier,
factory = { ctx -> factory = { ctx ->
@@ -73,8 +78,21 @@ fun VideoRenderer(
videoTrack.addSink(this) videoTrack.addSink(this)
} }
}, },
update = { renderer ->
if (currentTrack.value !== videoTrack) {
try {
currentTrack.value.removeSink(renderer)
} catch (_: Exception) {
}
videoTrack.addSink(renderer)
currentTrack.value = videoTrack
}
},
onRelease = { renderer -> onRelease = { renderer ->
videoTrack.removeSink(renderer) try {
currentTrack.value.removeSink(renderer)
} catch (_: Exception) {
}
renderer.release() renderer.release()
}, },
) )