From e4c8efb0468e9c0f39978988082e7d27c0d376e5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Apr 2026 03:07:10 +0000 Subject: [PATCH 1/2] fix: address WebRTC call implementation bugs and hardening - Fix enableVideo() not restarting camera after disable/re-enable - Add hangup action to foreground service notification with tap-to-open - Register BluetoothSco receiver with RECEIVER_NOT_EXPORTED flag - Replace GlobalScope with lifecycleScope+NonCancellable in CallActivity - Replace GlobalScope with goAsync()+MainScope in CallNotificationReceiver - Reduce proximity WakeLock timeout from 1 hour to 10 minutes - Add try-catch to CallMediaManager.initialize() to prevent EglBase leak - Add ICE restart attempt before giving up on FAILED state - Add VideoRenderer update block to handle track reference changes https://claude.ai/code/session_01DE9BUAuLJSwT3jq7S53NJ6 --- .../amethyst/service/call/CallAudioManager.kt | 7 +-- .../service/call/CallForegroundService.kt | 30 ++++++++++- .../amethyst/service/call/CallMediaManager.kt | 39 ++++++++++----- .../service/call/CallNotificationReceiver.kt | 24 +++++---- .../service/call/WebRtcCallSession.kt | 50 +++++++++++++++++-- .../amethyst/ui/call/CallActivity.kt | 23 +++++---- .../amethyst/ui/call/CallWidgets.kt | 20 +++++++- 7 files changed, 153 insertions(+), 40 deletions(-) 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 8048c298c..75c02967d 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 @@ -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, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallForegroundService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallForegroundService.kt index 1b0a00a49..d539cf7d2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallForegroundService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallForegroundService.kt @@ -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() + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallMediaManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallMediaManager.kt index d489943ba..70477bfba 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallMediaManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallMediaManager.kt @@ -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() { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallNotificationReceiver.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallNotificationReceiver.kt index dc255b901..1add3bce0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallNotificationReceiver.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallNotificationReceiver.kt @@ -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" } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/WebRtcCallSession.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/WebRtcCallSession.kt index 3379e2160..9385b6aa2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/WebRtcCallSession.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/WebRtcCallSession.kt @@ -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() /** diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallActivity.kt index 83788d2de..152aa6c8e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallActivity.kt @@ -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 -> {} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallWidgets.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallWidgets.kt index 91444ae05..e6248d2c9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallWidgets.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallWidgets.kt @@ -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() }, ) From b036880947787480167b03a1dea820c25565f1f2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Apr 2026 07:22:40 +0000 Subject: [PATCH 2/2] fix: marshal WebRTC callbacks to main thread in CallController Move foregroundServiceStarted flag check and onPeerDisconnected() inside scope.launch to avoid accessing main-thread-only state from WebRTC's internal observer thread. https://claude.ai/code/session_01DE9BUAuLJSwT3jq7S53NJ6 --- .../amethyst/service/call/CallController.kt | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) 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 f432a92b4..a2c711098 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 @@ -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) }, )