Merge pull request #2203 from vitorpamplona/claude/review-webrtc-calls-a7ZCq
Improve WebRTC call reliability and notification handling
This commit is contained in:
@@ -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) },
|
||||
)
|
||||
|
||||
+28
-2
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
+26
-13
@@ -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() {
|
||||
|
||||
+15
-9
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
+47
-3
@@ -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()
|
||||
},
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user