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,
"amethyst:call_proximity",
)
proximityWakeLock?.acquire(60 * 60 * 1000L)
proximityWakeLock?.acquire(10 * 60 * 1000L)
registerProximitySensor()
}
@@ -320,10 +320,11 @@ class CallAudioManager(
}
}
}
@Suppress("DEPRECATION")
context.registerReceiver(
ContextCompat.registerReceiver(
context,
scoReceiver,
IntentFilter(AudioManager.ACTION_SCO_AUDIO_STATE_UPDATED),
ContextCompat.RECEIVER_NOT_EXPORTED,
)
}
@@ -417,14 +417,16 @@ class CallController(
onIceCandidate = { candidate -> onLocalIceCandidate(peerPubKey, candidate) },
onPeerConnected = {
Log.d(TAG) { "Peer ${peerPubKey.take(8)} connected!" }
scope.launch { callManager.onPeerConnected() }
if (!foregroundServiceStarted) {
foregroundServiceStarted = true
startForegroundService()
scope.launch {
callManager.onPeerConnected()
if (!foregroundServiceStarted) {
foregroundServiceStarted = true
startForegroundService()
}
}
},
onRemoteVideoTrack = { track -> videoMonitor.onRemoteVideoTrack(peerPubKey, track) },
onDisconnected = { onPeerDisconnected(peerPubKey) },
onDisconnected = { scope.launch { onPeerDisconnected(peerPubKey) } },
onError = { error -> _errorMessage.value = error },
onRenegotiationNeeded = { performRenegotiation(peerPubKey) },
)
@@ -24,6 +24,7 @@ import android.Manifest
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Intent
import android.content.pm.PackageManager
@@ -34,6 +35,7 @@ import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.call.CallActivity
import com.vitorpamplona.quartz.utils.Log
private const val TAG = "CallForegroundService"
@@ -46,6 +48,7 @@ class CallForegroundService : Service() {
const val ACTION_STOP = "com.vitorpamplona.amethyst.CALL_STOP"
const val EXTRA_PEER_NAME = "peer_name"
const val EXTRA_IS_VIDEO = "is_video"
private const val HANGUP_REQUEST_CODE = 0x70001
}
override fun onBind(intent: Intent?): IBinder? = null
@@ -118,12 +121,35 @@ class CallForegroundService : Service() {
notificationManager.createNotificationChannel(channel)
}
private fun buildNotification(peerName: String): Notification =
NotificationCompat
private fun buildNotification(peerName: String): Notification {
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)
.setContentTitle(getString(R.string.app_name))
.setContentText(getString(R.string.call_with, peerName))
.setSmallIcon(R.drawable.amethyst)
.setOngoing(true)
.setContentIntent(openCallIntent)
.addAction(R.drawable.ic_call_end, getString(R.string.call_hangup), hangupIntent)
.build()
}
}
@@ -77,21 +77,33 @@ class CallMediaManager(
fun initialize(callType: CallType) {
if (peerConnectionFactory != null) return
sharedEglBase = EglBase.create()
var egl: EglBase? = null
try {
egl = EglBase.create()
PeerConnectionFactory.initialize(
PeerConnectionFactory
.InitializationOptions
.builder(context)
.createInitializationOptions(),
)
PeerConnectionFactory.initialize(
PeerConnectionFactory
.InitializationOptions
.builder(context)
.createInitializationOptions(),
)
peerConnectionFactory =
PeerConnectionFactory
.builder()
.setVideoDecoderFactory(DefaultVideoDecoderFactory(sharedEglBase!!.eglBaseContext))
.setVideoEncoderFactory(DefaultVideoEncoderFactory(sharedEglBase!!.eglBaseContext, true, true))
.createPeerConnectionFactory()
peerConnectionFactory =
PeerConnectionFactory
.builder()
.setVideoDecoderFactory(DefaultVideoDecoderFactory(egl.eglBaseContext))
.setVideoEncoderFactory(DefaultVideoEncoderFactory(egl.eglBaseContext, true, true))
.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())
localAudioTrack = peerConnectionFactory?.createAudioTrack("audio0", localAudioSource)
@@ -145,6 +157,7 @@ class CallMediaManager(
localVideoTrack?.setEnabled(true)
_isVideoEnabled.value = true
_localVideoTrackFlow.value = localVideoTrack
startCamera()
}
fun disableVideo() {
@@ -24,8 +24,6 @@ import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
/**
@@ -36,24 +34,32 @@ import kotlinx.coroutines.launch
* notification trampoline restrictions.
*/
class CallNotificationReceiver : BroadcastReceiver() {
@OptIn(DelicateCoroutinesApi::class)
override fun onReceive(
context: Context,
intent: Intent,
) {
when (intent.action) {
ACTION_REJECT_CALL -> {
NotificationUtils.cancelCallNotification(context)
val callManager = CallSessionBridge.callManager ?: return
val pendingResult = goAsync()
kotlinx.coroutines.MainScope().launch {
try {
when (intent.action) {
ACTION_REJECT_CALL -> {
NotificationUtils.cancelCallNotification(context)
callManager.rejectCall()
}
val callManager = CallSessionBridge.callManager ?: return
GlobalScope.launch {
callManager.rejectCall()
ACTION_HANGUP_CALL -> {
callManager.hangup()
}
}
} finally {
pendingResult.finish()
}
}
}
companion object {
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 var peerConnection: PeerConnection? = null
private var iceRestartAttempted = false
fun createPeerConnection() {
val rtcConfig =
@@ -90,6 +91,7 @@ class WebRtcCallSession(
PeerConnection.IceConnectionState.CONNECTED -> {
Log.d(TAG) { "ICE: CONNECTED - peer connection established!" }
iceRestartAttempted = false
onPeerConnected()
}
@@ -98,9 +100,15 @@ class WebRtcCallSession(
}
PeerConnection.IceConnectionState.FAILED -> {
Log.e(TAG, "ICE: FAILED - could not establish connection")
onError("Connection failed - check network")
onDisconnected()
if (!iceRestartAttempted) {
iceRestartAttempted = true
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 -> {
@@ -255,6 +263,42 @@ class WebRtcCallSession(
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()
/**
@@ -46,9 +46,9 @@ import com.vitorpamplona.amethyst.ui.StringResSetup
import com.vitorpamplona.amethyst.ui.screen.ManageRelayServices
import com.vitorpamplona.amethyst.ui.screen.ManageWebOkHttp
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class CallActivity : AppCompatActivity() {
val isInPipMode = mutableStateOf(false)
@@ -69,14 +69,13 @@ class CallActivity : AppCompatActivity() {
private val pipActionReceiver =
object : BroadcastReceiver() {
@OptIn(DelicateCoroutinesApi::class)
override fun onReceive(
context: Context,
intent: Intent,
) {
when (intent.action) {
ACTION_PIP_HANGUP -> {
GlobalScope.launch { CallSessionBridge.callManager?.hangup() }
lifecycleScope.launch { CallSessionBridge.callManager?.hangup() }
}
ACTION_PIP_TOGGLE_MUTE -> {
@@ -191,7 +190,6 @@ class CallActivity : AppCompatActivity() {
}
}
@OptIn(DelicateCoroutinesApi::class)
override fun onStop() {
super.onStop()
// Only hang up if the user dismissed PiP (swiped it away).
@@ -202,29 +200,36 @@ class CallActivity : AppCompatActivity() {
if (wasInPipMode && !isInPictureInPictureMode) {
val state = CallSessionBridge.callManager?.state?.value
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()
}
}
@OptIn(DelicateCoroutinesApi::class)
override fun onDestroy() {
unregisterPipReceiver()
// Safety net: if the Activity is destroyed while a call is still
// 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
when (manager?.state?.value) {
is CallState.IncomingCall -> {
GlobalScope.launch { manager.rejectCall() }
lifecycleScope.launch {
withContext(NonCancellable) { manager.rejectCall() }
}
}
is CallState.Offering,
is CallState.Connecting,
is CallState.Connected,
-> {
GlobalScope.launch { manager.hangup() }
lifecycleScope.launch {
withContext(NonCancellable) { manager.hangup() }
}
}
else -> {}
@@ -36,6 +36,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -63,6 +64,10 @@ fun VideoRenderer(
modifier: Modifier = Modifier,
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(
modifier = modifier,
factory = { ctx ->
@@ -73,8 +78,21 @@ fun VideoRenderer(
videoTrack.addSink(this)
}
},
update = { renderer ->
if (currentTrack.value !== videoTrack) {
try {
currentTrack.value.removeSink(renderer)
} catch (_: Exception) {
}
videoTrack.addSink(renderer)
currentTrack.value = videoTrack
}
},
onRelease = { renderer ->
videoTrack.removeSink(renderer)
try {
currentTrack.value.removeSink(renderer)
} catch (_: Exception) {
}
renderer.release()
},
)