feat: implement full-mesh WebRTC group calls with per-peer PeerConnections
Replaces the single-PeerConnection architecture with full mesh topology where each participant maintains one PeerConnection per peer, as specified by NIP-AC. WebRtcCallSession: Refactored to a pure PeerConnection wrapper that accepts a shared PeerConnectionFactory. No longer manages media sources, tracks, or camera — those are now shared across all peer sessions. CallController: Manages per-peer sessions via ConcurrentHashMap. Shared resources (PeerConnectionFactory, EglBase, audio/video sources, camera) are initialized once and reused. Each peer gets its own WebRtcCallSession with per-peer ICE candidate routing. Supports callee-to-callee mesh connections with pubkey-based tie-breaking to avoid ofer glare. CallManager: New methods for per-peer offer/answer publishing (publishOfferToPeer, publishAnswerToPeer, beginOffering). Forwards ALL answers to CallController (not just the first). New callbacks onNewPeerInGroupCall and onMidCallOfferReceived for mesh setup. Handles mid-call offers (same call-id) when already in Connecting/Connected state. AccountViewModel: Updated callback wiring to pass peer pubkey with answer events and register new group call callbacks. https://claude.ai/code/session_01J5fJx9YbSBx1BsBMctiAm8
This commit is contained in:
+463
-183
@@ -30,6 +30,7 @@ import com.vitorpamplona.amethyst.commons.call.CallManager
|
||||
import com.vitorpamplona.amethyst.commons.call.CallState
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
import com.vitorpamplona.quartz.nip19Bech32.toNpub
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
|
||||
@@ -46,17 +47,31 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.webrtc.AudioSource
|
||||
import org.webrtc.AudioTrack
|
||||
import org.webrtc.Camera2Enumerator
|
||||
import org.webrtc.CameraVideoCapturer
|
||||
import org.webrtc.DefaultVideoDecoderFactory
|
||||
import org.webrtc.DefaultVideoEncoderFactory
|
||||
import org.webrtc.EglBase
|
||||
import org.webrtc.IceCandidate
|
||||
import org.webrtc.MediaConstraints
|
||||
import org.webrtc.PeerConnection
|
||||
import org.webrtc.PeerConnectionFactory
|
||||
import org.webrtc.SessionDescription
|
||||
import org.webrtc.SurfaceTextureHelper
|
||||
import org.webrtc.VideoFrame
|
||||
import org.webrtc.VideoSink
|
||||
import org.webrtc.VideoSource
|
||||
import org.webrtc.VideoTrack
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
private const val TAG = "CallController"
|
||||
private const val VIDEO_MAX_BITRATE_BPS = 1_500_000
|
||||
|
||||
class CallController(
|
||||
private val context: Context,
|
||||
@@ -65,25 +80,45 @@ class CallController(
|
||||
private val publishWrap: suspend (GiftWrapEvent) -> Unit,
|
||||
private val signerProvider: suspend () -> com.vitorpamplona.quartz.nip01Core.signers.NostrSigner,
|
||||
) {
|
||||
private var webRtcSession: WebRtcCallSession? = null
|
||||
// ---- Per-peer session state ----
|
||||
|
||||
private class PeerSessionState(
|
||||
val session: WebRtcCallSession,
|
||||
val remoteDescriptionSet: AtomicBoolean = AtomicBoolean(false),
|
||||
val pendingIceCandidates: CopyOnWriteArrayList<IceCandidate> = CopyOnWriteArrayList(),
|
||||
)
|
||||
|
||||
private val peerSessions = ConcurrentHashMap<HexKey, PeerSessionState>()
|
||||
|
||||
// ---- Shared WebRTC resources ----
|
||||
|
||||
private var peerConnectionFactory: PeerConnectionFactory? = null
|
||||
private var sharedEglBase: EglBase? = null
|
||||
private var localAudioSource: AudioSource? = null
|
||||
private var localVideoSource: VideoSource? = null
|
||||
private var localAudioTrackInternal: AudioTrack? = null
|
||||
private var localVideoTrackInternal: VideoTrack? = null
|
||||
private var cameraCapturer: CameraVideoCapturer? = null
|
||||
|
||||
private val callFactory = WebRtcCallFactory()
|
||||
private val remoteDescriptionSet =
|
||||
java.util.concurrent.atomic
|
||||
.AtomicBoolean(false)
|
||||
private val pendingIceCandidates = CopyOnWriteArrayList<IceCandidate>()
|
||||
val audioManager = CallAudioManager(context)
|
||||
|
||||
// Video tracks exposed to UI
|
||||
private val _remoteVideoTrack = MutableStateFlow<VideoTrack?>(null)
|
||||
val remoteVideoTrack: StateFlow<VideoTrack?> = _remoteVideoTrack.asStateFlow()
|
||||
// ---- UI-exposed state ----
|
||||
|
||||
private val _localVideoTrack = MutableStateFlow<VideoTrack?>(null)
|
||||
val localVideoTrack: StateFlow<VideoTrack?> = _localVideoTrack.asStateFlow()
|
||||
|
||||
// Error state exposed to UI
|
||||
// Primary remote track (first connected peer) for backward-compat with P2P UI
|
||||
private val _remoteVideoTrack = MutableStateFlow<VideoTrack?>(null)
|
||||
val remoteVideoTrack: StateFlow<VideoTrack?> = _remoteVideoTrack.asStateFlow()
|
||||
|
||||
// All remote tracks keyed by peer pubkey (for group call UI)
|
||||
private val _remoteVideoTracks = MutableStateFlow<Map<HexKey, VideoTrack>>(emptyMap())
|
||||
val remoteVideoTracks: StateFlow<Map<HexKey, VideoTrack>> = _remoteVideoTracks.asStateFlow()
|
||||
|
||||
private val _errorMessage = MutableStateFlow<String?>(null)
|
||||
val errorMessage: StateFlow<String?> = _errorMessage.asStateFlow()
|
||||
|
||||
// Remote video frame monitoring — detects when peer stops sending video
|
||||
private val _isRemoteVideoActive = MutableStateFlow(false)
|
||||
val isRemoteVideoActive: StateFlow<Boolean> = _isRemoteVideoActive.asStateFlow()
|
||||
private val _remoteVideoAspectRatio = MutableStateFlow<Float?>(null)
|
||||
@@ -100,7 +135,6 @@ class CallController(
|
||||
}
|
||||
}
|
||||
|
||||
// Audio/video toggle state (UI concerns, not domain state)
|
||||
private val _isAudioMuted = MutableStateFlow(false)
|
||||
val isAudioMuted: StateFlow<Boolean> = _isAudioMuted.asStateFlow()
|
||||
private val _isVideoEnabled = MutableStateFlow(false)
|
||||
@@ -108,8 +142,10 @@ class CallController(
|
||||
val audioRoute: StateFlow<AudioRoute> = audioManager.audioRoute
|
||||
val isBluetoothAvailable: StateFlow<Boolean> = audioManager.isBluetoothAvailable
|
||||
|
||||
// Tracks whether video is paused because the phone is near the ear
|
||||
private var videoPausedByProximity = false
|
||||
private var foregroundServiceStarted = false
|
||||
|
||||
// ---- Initialization ----
|
||||
|
||||
init {
|
||||
callManager.onRenegotiationOfferReceived = { event -> onRenegotiationOfferReceived(event) }
|
||||
@@ -147,23 +183,24 @@ class CallController(
|
||||
}
|
||||
}
|
||||
|
||||
// Pause outgoing video when the phone is held near the ear
|
||||
scope.launch {
|
||||
audioManager.isNearEar.collect { nearEar ->
|
||||
val session = webRtcSession ?: return@collect
|
||||
val videoTrack = localVideoTrackInternal ?: return@collect
|
||||
if (nearEar && _isVideoEnabled.value && !videoPausedByProximity) {
|
||||
videoPausedByProximity = true
|
||||
session.setVideoEnabled(false)
|
||||
session.stopCamera()
|
||||
videoTrack.setEnabled(false)
|
||||
stopCamera()
|
||||
} else if (!nearEar && videoPausedByProximity) {
|
||||
videoPausedByProximity = false
|
||||
session.setVideoEnabled(true)
|
||||
session.startCamera()
|
||||
videoTrack.setEnabled(true)
|
||||
startCamera()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Call initiation (caller side) ----
|
||||
|
||||
fun initiateCall(
|
||||
peerPubKey: String,
|
||||
callType: CallType,
|
||||
@@ -178,6 +215,10 @@ class CallController(
|
||||
initiateCallInternal(peerPubKeys, callType)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a separate PeerConnection (and SDP offer) for each callee,
|
||||
* establishing full-mesh connectivity for group calls.
|
||||
*/
|
||||
private fun initiateCallInternal(
|
||||
peerPubKeys: Set<String>,
|
||||
callType: CallType,
|
||||
@@ -186,46 +227,40 @@ class CallController(
|
||||
scope.launch {
|
||||
val callId = UUID.randomUUID().toString()
|
||||
_errorMessage.value = null
|
||||
remoteDescriptionSet.set(false)
|
||||
pendingIceCandidates.clear()
|
||||
|
||||
try {
|
||||
withContext(Dispatchers.IO) { createWebRtcSession() }
|
||||
Log.d(TAG) { "initiateCall: WebRTC session created" }
|
||||
withContext(Dispatchers.IO) { initializeSharedResources(callType) }
|
||||
Log.d(TAG) { "initiateCall: shared resources initialized" }
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to create WebRTC session", e)
|
||||
Log.e(TAG, "Failed to initialize WebRTC", e)
|
||||
_errorMessage.value = "Failed to start call: ${e.message}"
|
||||
return@launch
|
||||
}
|
||||
|
||||
val session =
|
||||
webRtcSession ?: run {
|
||||
_errorMessage.value = "Failed to create WebRTC session"
|
||||
return@launch
|
||||
}
|
||||
// Set state to Offering before creating peer sessions
|
||||
callManager.beginOffering(callId, peerPubKeys, callType)
|
||||
|
||||
session.addAudioTrack()
|
||||
if (callType == CallType.VIDEO) {
|
||||
session.addVideoTrack()
|
||||
_localVideoTrack.value = session.getLocalVideoTrack()
|
||||
_isVideoEnabled.value = true
|
||||
}
|
||||
|
||||
Log.d(TAG) { "initiateCall: creating offer..." }
|
||||
session.createOffer { sdp ->
|
||||
Log.d(TAG) { "initiateCall: offer created, sdpLength=${sdp.description.length}, publishing..." }
|
||||
// Create a PeerConnection + offer for each callee
|
||||
for (peerPubKey in peerPubKeys) {
|
||||
try {
|
||||
val ps = withContext(Dispatchers.IO) { createPeerSession(peerPubKey) }
|
||||
Log.d(TAG) { "initiateCall: PeerConnection created for ${peerPubKey.take(8)}" }
|
||||
ps.session.createOffer { sdp ->
|
||||
Log.d(TAG) { "initiateCall: offer created for ${peerPubKey.take(8)}, sdpLength=${sdp.description.length}" }
|
||||
scope.launch {
|
||||
if (peerPubKeys.size == 1) {
|
||||
callManager.initiateCall(peerPubKeys.first(), callType, callId, sdp.description)
|
||||
} else {
|
||||
callManager.initiateGroupCall(peerPubKeys, callType, callId, sdp.description)
|
||||
callManager.publishOfferToPeer(peerPubKey, peerPubKeys, callType, callId, sdp.description)
|
||||
Log.d(TAG) { "initiateCall: offer published for ${peerPubKey.take(8)}" }
|
||||
}
|
||||
Log.d(TAG) { "initiateCall: offer published, callId=$callId, state=${callManager.state.value::class.simpleName}" }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to create PeerConnection for ${peerPubKey.take(8)}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Accept incoming call (callee side) ----
|
||||
|
||||
fun acceptIncomingCall(sdpOffer: String) {
|
||||
val state = callManager.state.value
|
||||
if (state !is CallState.IncomingCall) {
|
||||
@@ -233,45 +268,43 @@ class CallController(
|
||||
return
|
||||
}
|
||||
|
||||
Log.d(TAG) { "acceptIncomingCall: callId=${state.callId}, callType=${state.callType}, sdpOfferLength=${sdpOffer.length}, pendingICE=${pendingIceCandidates.size}" }
|
||||
val callerPubKey = state.callerPubKey
|
||||
Log.d(TAG) { "acceptIncomingCall: callId=${state.callId}, callType=${state.callType}, sdpOfferLength=${sdpOffer.length}" }
|
||||
|
||||
scope.launch {
|
||||
_errorMessage.value = null
|
||||
remoteDescriptionSet.set(false)
|
||||
// Don't clear pendingIceCandidates here — they were buffered while ringing
|
||||
|
||||
try {
|
||||
withContext(Dispatchers.IO) { createWebRtcSession() }
|
||||
Log.d(TAG) { "acceptIncomingCall: WebRTC session created" }
|
||||
withContext(Dispatchers.IO) { initializeSharedResources(state.callType) }
|
||||
Log.d(TAG) { "acceptIncomingCall: shared resources initialized" }
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to create WebRTC session", e)
|
||||
Log.e(TAG, "Failed to initialize WebRTC", e)
|
||||
_errorMessage.value = "Failed to accept call: ${e.message}"
|
||||
return@launch
|
||||
}
|
||||
|
||||
val session =
|
||||
webRtcSession ?: run {
|
||||
Log.e(TAG, "acceptIncomingCall: webRtcSession is null after creation")
|
||||
_errorMessage.value = "Failed to create WebRTC session"
|
||||
// Retrieve any ICE candidates buffered while ringing (before session existed)
|
||||
val globalPending = getGlobalPendingCandidates(callerPubKey)
|
||||
|
||||
val ps =
|
||||
try {
|
||||
withContext(Dispatchers.IO) { createPeerSession(callerPubKey) }
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to create PeerConnection", e)
|
||||
_errorMessage.value = "Failed to accept call: ${e.message}"
|
||||
return@launch
|
||||
}
|
||||
|
||||
session.addAudioTrack()
|
||||
Log.d(TAG) { "acceptIncomingCall: audio track added" }
|
||||
if (state.callType == CallType.VIDEO) {
|
||||
session.addVideoTrack()
|
||||
_localVideoTrack.value = session.getLocalVideoTrack()
|
||||
_isVideoEnabled.value = true
|
||||
Log.d(TAG) { "acceptIncomingCall: video track added" }
|
||||
}
|
||||
// Inject any globally-buffered ICE candidates for the caller
|
||||
globalPending.forEach { ps.pendingIceCandidates.add(it) }
|
||||
|
||||
Log.d(TAG) { "acceptIncomingCall: setting remote description (OFFER)..." }
|
||||
session.setRemoteDescription(SessionDescription(SessionDescription.Type.OFFER, sdpOffer))
|
||||
Log.d(TAG) { "acceptIncomingCall: flushing ${pendingIceCandidates.size} pending ICE candidates..." }
|
||||
flushPendingIceCandidates()
|
||||
ps.session.setRemoteDescription(SessionDescription(SessionDescription.Type.OFFER, sdpOffer))
|
||||
Log.d(TAG) { "acceptIncomingCall: flushing ${ps.pendingIceCandidates.size} pending ICE candidates..." }
|
||||
flushPendingIceCandidates(callerPubKey, ps)
|
||||
|
||||
Log.d(TAG) { "acceptIncomingCall: creating answer..." }
|
||||
session.createAnswer { sdp ->
|
||||
ps.session.createAnswer { sdp ->
|
||||
Log.d(TAG) { "acceptIncomingCall: answer created, sdpLength=${sdp.description.length}, publishing..." }
|
||||
scope.launch {
|
||||
callManager.acceptCall(sdp.description)
|
||||
@@ -281,82 +314,246 @@ class CallController(
|
||||
}
|
||||
}
|
||||
|
||||
fun onCallAnswerReceived(sdpAnswer: String) {
|
||||
val session = webRtcSession
|
||||
if (session == null) {
|
||||
Log.e(TAG, "Answer received but webRtcSession is NULL — cannot set remote description")
|
||||
return
|
||||
}
|
||||
val signalingState = session.getSignalingState()
|
||||
// ---- Answer routing ----
|
||||
|
||||
/**
|
||||
* Routes an answer to the correct per-peer PeerConnection.
|
||||
* For the caller: the answer is for a PeerConnection we already created.
|
||||
* For a callee seeing another callee's answer: we don't have a session
|
||||
* for them yet, so we trigger callee-to-callee connection.
|
||||
*/
|
||||
fun onCallAnswerReceived(
|
||||
peerPubKey: HexKey,
|
||||
sdpAnswer: String,
|
||||
) {
|
||||
val ps = peerSessions[peerPubKey]
|
||||
if (ps != null) {
|
||||
// We have a PeerConnection for this peer — set remote description
|
||||
val signalingState = ps.session.getSignalingState()
|
||||
Log.d(TAG) {
|
||||
"Answer received, SDP length=${sdpAnswer.length}, signalingState=$signalingState, " +
|
||||
"remoteDescriptionSet=${remoteDescriptionSet.get()}, pendingICE=${pendingIceCandidates.size}, " +
|
||||
"callState=${callManager.state.value::class.simpleName}"
|
||||
"Answer received from ${peerPubKey.take(8)}, sdpLength=${sdpAnswer.length}, " +
|
||||
"signalingState=$signalingState, remoteDescSet=${ps.remoteDescriptionSet.get()}"
|
||||
}
|
||||
|
||||
// An answer is only valid when we have a pending local offer.
|
||||
// Ignore stale answers (e.g. our own renegotiation answer echoed back by the relay).
|
||||
if (signalingState != PeerConnection.SignalingState.HAVE_LOCAL_OFFER) {
|
||||
Log.d(TAG) { "Ignoring answer in $signalingState state (no pending local offer)" }
|
||||
Log.d(TAG) { "Ignoring answer from ${peerPubKey.take(8)} in $signalingState (no pending local offer)" }
|
||||
return
|
||||
}
|
||||
|
||||
Log.d(TAG) { "Setting remote description (ANSWER)..." }
|
||||
session.setRemoteDescription(SessionDescription(SessionDescription.Type.ANSWER, sdpAnswer))
|
||||
Log.d(TAG) { "Flushing pending ICE candidates after answer..." }
|
||||
flushPendingIceCandidates()
|
||||
Log.d(TAG) { "Setting remote description (ANSWER) for ${peerPubKey.take(8)}..." }
|
||||
ps.session.setRemoteDescription(SessionDescription(SessionDescription.Type.ANSWER, sdpAnswer))
|
||||
flushPendingIceCandidates(peerPubKey, ps)
|
||||
} else {
|
||||
// No session for this peer — they are another callee who joined.
|
||||
// Initiate a callee-to-callee connection (with tie-breaking).
|
||||
Log.d(TAG) { "Answer from unknown peer ${peerPubKey.take(8)} — triggering callee-to-callee connection" }
|
||||
onNewPeerInGroupCall(peerPubKey)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- ICE candidate routing ----
|
||||
|
||||
/**
|
||||
* Routes an incoming ICE candidate to the correct per-peer session.
|
||||
* Before a session exists for a peer, candidates are buffered globally.
|
||||
*/
|
||||
fun onIceCandidateReceived(event: CallIceCandidateEvent) {
|
||||
try {
|
||||
val senderPubKey = event.pubKey
|
||||
val candidate = IceCandidate(event.sdpMid(), event.sdpMLineIndex(), event.candidateSdp())
|
||||
if (webRtcSession != null && remoteDescriptionSet.get()) {
|
||||
Log.d(TAG) { "Adding ICE candidate directly: ${candidate.sdp.take(50)}" }
|
||||
webRtcSession?.addIceCandidate(candidate)
|
||||
val ps = peerSessions[senderPubKey]
|
||||
if (ps != null && ps.remoteDescriptionSet.get()) {
|
||||
Log.d(TAG) { "Adding ICE candidate from ${senderPubKey.take(8)} directly" }
|
||||
ps.session.addIceCandidate(candidate)
|
||||
} else if (ps != null) {
|
||||
Log.d(TAG) { "Buffering ICE candidate from ${senderPubKey.take(8)} (remoteDesc not set)" }
|
||||
ps.pendingIceCandidates.add(candidate)
|
||||
} else {
|
||||
Log.d(TAG) { "Buffering ICE candidate (session=${webRtcSession != null}, remoteSet=${remoteDescriptionSet.get()})" }
|
||||
pendingIceCandidates.add(candidate)
|
||||
// No session yet — buffer globally (keyed by sender)
|
||||
Log.d(TAG) { "Buffering ICE candidate from ${senderPubKey.take(8)} (no session yet)" }
|
||||
globalPendingIce.getOrPut(senderPubKey) { CopyOnWriteArrayList() }.add(candidate)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to parse ICE candidate", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun flushPendingIceCandidates() {
|
||||
remoteDescriptionSet.set(true)
|
||||
val session = webRtcSession ?: return
|
||||
val candidates = pendingIceCandidates.toList()
|
||||
Log.d(TAG) { "Flushing ${candidates.size} buffered ICE candidates" }
|
||||
pendingIceCandidates.clear()
|
||||
candidates.forEach { session.addIceCandidate(it) }
|
||||
// Candidates received before a PeerSession exists for the sender
|
||||
private val globalPendingIce = ConcurrentHashMap<HexKey, CopyOnWriteArrayList<IceCandidate>>()
|
||||
|
||||
private fun getGlobalPendingCandidates(peerPubKey: HexKey): List<IceCandidate> = globalPendingIce.remove(peerPubKey)?.toList() ?: emptyList()
|
||||
|
||||
private fun flushPendingIceCandidates(
|
||||
peerPubKey: HexKey,
|
||||
ps: PeerSessionState,
|
||||
) {
|
||||
ps.remoteDescriptionSet.set(true)
|
||||
val candidates = ps.pendingIceCandidates.toList()
|
||||
Log.d(TAG) { "Flushing ${candidates.size} buffered ICE candidates for ${peerPubKey.take(8)}" }
|
||||
ps.pendingIceCandidates.clear()
|
||||
candidates.forEach { ps.session.addIceCandidate(it) }
|
||||
}
|
||||
|
||||
// UI toggle controls
|
||||
private fun onLocalIceCandidate(
|
||||
peerPubKey: HexKey,
|
||||
candidate: IceCandidate,
|
||||
) {
|
||||
Log.d(TAG) { "Local ICE candidate for ${peerPubKey.take(8)}: ${candidate.sdp.take(50)}" }
|
||||
val callId = callManager.currentCallId() ?: return
|
||||
val candidateJson = CallIceCandidateEvent.serializeCandidate(candidate.sdp, candidate.sdpMid, candidate.sdpMLineIndex)
|
||||
|
||||
scope.launch {
|
||||
val signer = signerProvider()
|
||||
val result = callFactory.createIceCandidate(candidateJson, peerPubKey, callId, signer)
|
||||
publishWrap(result.wrap)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Callee-to-callee mesh connections ----
|
||||
|
||||
/**
|
||||
* Another callee joined the group call. Establish a direct PeerConnection
|
||||
* to them. To avoid glare (both sides sending offers simultaneously), the
|
||||
* peer with the lexicographically lower pubkey initiates.
|
||||
*/
|
||||
fun onNewPeerInGroupCall(peerPubKey: HexKey) {
|
||||
if (peerSessions.containsKey(peerPubKey)) return // already connected/connecting
|
||||
|
||||
scope.launch {
|
||||
val myPubKey = signerProvider().pubKey
|
||||
if (myPubKey < peerPubKey) {
|
||||
Log.d(TAG) { "Initiating callee-to-callee connection to ${peerPubKey.take(8)} (I have lower pubkey)" }
|
||||
createAndOfferToPeer(peerPubKey)
|
||||
} else {
|
||||
Log.d(TAG) { "Waiting for callee-to-callee offer from ${peerPubKey.take(8)} (they have lower pubkey)" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun createAndOfferToPeer(peerPubKey: HexKey) {
|
||||
if (peerConnectionFactory == null) return
|
||||
val globalPending = getGlobalPendingCandidates(peerPubKey)
|
||||
|
||||
val ps =
|
||||
try {
|
||||
withContext(Dispatchers.IO) { createPeerSession(peerPubKey) }
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to create PeerConnection for ${peerPubKey.take(8)}", e)
|
||||
return
|
||||
}
|
||||
|
||||
globalPending.forEach { ps.pendingIceCandidates.add(it) }
|
||||
|
||||
ps.session.createOffer { sdp ->
|
||||
Log.d(TAG) { "Callee-to-callee offer created for ${peerPubKey.take(8)}, sdpLength=${sdp.description.length}" }
|
||||
scope.launch {
|
||||
callManager.publishOfferToPeer(peerPubKey, sdp.description)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A mid-call offer was received from another callee in the group.
|
||||
* Create a PeerSession, accept their offer, and send back an answer.
|
||||
*/
|
||||
fun onMidCallOfferReceived(
|
||||
peerPubKey: HexKey,
|
||||
sdpOffer: String,
|
||||
) {
|
||||
if (peerSessions.containsKey(peerPubKey)) {
|
||||
Log.d(TAG) { "Mid-call offer from ${peerPubKey.take(8)} but session already exists — ignoring" }
|
||||
return
|
||||
}
|
||||
|
||||
Log.d(TAG) { "Mid-call offer from ${peerPubKey.take(8)}, sdpLength=${sdpOffer.length}" }
|
||||
scope.launch {
|
||||
if (peerConnectionFactory == null) {
|
||||
Log.e(TAG, "Mid-call offer but factory not initialized")
|
||||
return@launch
|
||||
}
|
||||
|
||||
val globalPending = getGlobalPendingCandidates(peerPubKey)
|
||||
|
||||
val ps =
|
||||
try {
|
||||
withContext(Dispatchers.IO) { createPeerSession(peerPubKey) }
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to create PeerConnection for mid-call offer from ${peerPubKey.take(8)}", e)
|
||||
return@launch
|
||||
}
|
||||
|
||||
globalPending.forEach { ps.pendingIceCandidates.add(it) }
|
||||
|
||||
ps.session.setRemoteDescription(SessionDescription(SessionDescription.Type.OFFER, sdpOffer))
|
||||
flushPendingIceCandidates(peerPubKey, ps)
|
||||
|
||||
ps.session.createAnswer { sdp ->
|
||||
Log.d(TAG) { "Callee-to-callee answer for ${peerPubKey.take(8)}, sdpLength=${sdp.description.length}" }
|
||||
scope.launch {
|
||||
callManager.publishAnswerToPeer(peerPubKey, sdp.description)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Renegotiation ----
|
||||
|
||||
private fun onRenegotiationOfferReceived(event: CallRenegotiateEvent) {
|
||||
val peerPubKey = event.pubKey
|
||||
val ps = peerSessions[peerPubKey] ?: return
|
||||
val sdpOffer = event.sdpOffer()
|
||||
Log.d(TAG) { "Renegotiation offer from ${peerPubKey.take(8)}, sdpLength=${sdpOffer.length}" }
|
||||
|
||||
scope.launch {
|
||||
ps.session.setRemoteDescription(SessionDescription(SessionDescription.Type.OFFER, sdpOffer))
|
||||
ps.session.createAnswer { sdp ->
|
||||
scope.launch {
|
||||
callManager.sendRenegotiationAnswer(sdp.description, peerPubKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun performRenegotiation(peerPubKey: HexKey) {
|
||||
val ps = peerSessions[peerPubKey] ?: return
|
||||
val state = callManager.state.value
|
||||
if (state !is CallState.Connected && state !is CallState.Connecting) return
|
||||
|
||||
Log.d(TAG) { "Starting renegotiation with ${peerPubKey.take(8)}" }
|
||||
ps.session.createOffer { sdp ->
|
||||
scope.launch {
|
||||
callManager.sendRenegotiation(sdp.description, peerPubKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- UI toggle controls ----
|
||||
|
||||
fun toggleAudioMute() {
|
||||
val muted = !_isAudioMuted.value
|
||||
_isAudioMuted.value = muted
|
||||
webRtcSession?.setAudioEnabled(!muted)
|
||||
localAudioTrackInternal?.setEnabled(!muted)
|
||||
}
|
||||
|
||||
fun toggleVideo() {
|
||||
val session = webRtcSession ?: return
|
||||
val enabling = !_isVideoEnabled.value
|
||||
|
||||
if (enabling) {
|
||||
if (session.getLocalVideoTrack() == null) {
|
||||
// No video track yet — create one (voice → video upgrade)
|
||||
session.addVideoTrack()
|
||||
} else {
|
||||
// Track exists but camera was stopped — restart it
|
||||
session.setVideoEnabled(true)
|
||||
session.startCamera()
|
||||
if (localVideoTrackInternal == null) {
|
||||
// Voice → video upgrade: create video source/track and add to all sessions
|
||||
createVideoResources()
|
||||
peerSessions.values.forEach { ps ->
|
||||
localVideoTrackInternal?.let { ps.session.addTrack(it, VIDEO_MAX_BITRATE_BPS) }
|
||||
}
|
||||
_localVideoTrack.value = session.getLocalVideoTrack()
|
||||
} else {
|
||||
localVideoTrackInternal?.setEnabled(true)
|
||||
startCamera()
|
||||
}
|
||||
_localVideoTrack.value = localVideoTrackInternal
|
||||
_isVideoEnabled.value = true
|
||||
} else {
|
||||
// Disable video: stop camera and disable track
|
||||
session.setVideoEnabled(false)
|
||||
session.stopCamera()
|
||||
localVideoTrackInternal?.setEnabled(false)
|
||||
stopCamera()
|
||||
_isVideoEnabled.value = false
|
||||
}
|
||||
}
|
||||
@@ -365,23 +562,17 @@ class CallController(
|
||||
audioManager.cycleAudioRoute()
|
||||
}
|
||||
|
||||
fun getEglBase() = webRtcSession?.eglBase
|
||||
fun getEglBase(): EglBase? = sharedEglBase
|
||||
|
||||
fun invitePeer(peerPubKey: String) {
|
||||
scope.launch {
|
||||
val session = webRtcSession ?: return@launch
|
||||
session.createOffer { sdp ->
|
||||
scope.launch {
|
||||
callManager.invitePeer(peerPubKey, sdp.description)
|
||||
}
|
||||
}
|
||||
createAndOfferToPeer(peerPubKey)
|
||||
}
|
||||
}
|
||||
|
||||
fun hangup() {
|
||||
scope.launch {
|
||||
callManager.hangup()
|
||||
// cleanup is triggered by the state collector when Ended is reached
|
||||
}
|
||||
}
|
||||
|
||||
@@ -389,54 +580,182 @@ class CallController(
|
||||
_errorMessage.value = null
|
||||
}
|
||||
|
||||
private fun onRenegotiationOfferReceived(event: CallRenegotiateEvent) {
|
||||
val session = webRtcSession ?: return
|
||||
val sdpOffer = event.sdpOffer()
|
||||
val peerPubKey = event.pubKey
|
||||
Log.d(TAG) { "Renegotiation offer received, SDP length=${sdpOffer.length}" }
|
||||
// ---- Shared resource management ----
|
||||
|
||||
scope.launch {
|
||||
session.setRemoteDescription(SessionDescription(SessionDescription.Type.OFFER, sdpOffer))
|
||||
session.createAnswer { sdp ->
|
||||
scope.launch {
|
||||
callManager.sendRenegotiationAnswer(sdp.description, peerPubKey)
|
||||
}
|
||||
}
|
||||
private fun initializeSharedResources(callType: CallType) {
|
||||
if (peerConnectionFactory != null) return // already initialized
|
||||
|
||||
sharedEglBase = EglBase.create()
|
||||
|
||||
PeerConnectionFactory.initialize(
|
||||
PeerConnectionFactory
|
||||
.InitializationOptions
|
||||
.builder(context)
|
||||
.createInitializationOptions(),
|
||||
)
|
||||
|
||||
peerConnectionFactory =
|
||||
PeerConnectionFactory
|
||||
.builder()
|
||||
.setVideoDecoderFactory(DefaultVideoDecoderFactory(sharedEglBase!!.eglBaseContext))
|
||||
.setVideoEncoderFactory(DefaultVideoEncoderFactory(sharedEglBase!!.eglBaseContext, true, true))
|
||||
.createPeerConnectionFactory()
|
||||
|
||||
// Audio
|
||||
localAudioSource = peerConnectionFactory?.createAudioSource(MediaConstraints())
|
||||
localAudioTrackInternal = peerConnectionFactory?.createAudioTrack("audio0", localAudioSource)
|
||||
|
||||
// Video (if video call)
|
||||
if (callType == CallType.VIDEO) {
|
||||
createVideoResources()
|
||||
}
|
||||
}
|
||||
|
||||
private fun performRenegotiation() {
|
||||
val session = webRtcSession ?: return
|
||||
val state = callManager.state.value
|
||||
if (state !is CallState.Connected && state !is CallState.Connecting) return
|
||||
val peerPubKey = callManager.currentPeerPubKey() ?: return
|
||||
private fun createVideoResources() {
|
||||
if (localVideoSource != null) return
|
||||
val factory = peerConnectionFactory ?: return
|
||||
|
||||
Log.d(TAG) { "Starting renegotiation" }
|
||||
session.createOffer { sdp ->
|
||||
scope.launch {
|
||||
callManager.sendRenegotiation(sdp.description, peerPubKey)
|
||||
localVideoSource = factory.createVideoSource(false)
|
||||
localVideoTrackInternal = factory.createVideoTrack("video0", localVideoSource)
|
||||
_localVideoTrack.value = localVideoTrackInternal
|
||||
_isVideoEnabled.value = true
|
||||
startCamera()
|
||||
}
|
||||
|
||||
private fun startCamera() {
|
||||
if (cameraCapturer != null) return
|
||||
val source = localVideoSource ?: return
|
||||
val egl = sharedEglBase ?: return
|
||||
|
||||
val enumerator = Camera2Enumerator(context)
|
||||
val frontCamera = enumerator.deviceNames.firstOrNull { enumerator.isFrontFacing(it) }
|
||||
val camera = frontCamera ?: enumerator.deviceNames.firstOrNull() ?: return
|
||||
|
||||
cameraCapturer =
|
||||
enumerator.createCapturer(camera, null)?.also {
|
||||
it.initialize(
|
||||
SurfaceTextureHelper.create("CaptureThread", egl.eglBaseContext),
|
||||
context,
|
||||
source.capturerObserver,
|
||||
)
|
||||
it.startCapture(1280, 720, 30)
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopCamera() {
|
||||
cameraCapturer?.stopCapture()
|
||||
cameraCapturer?.dispose()
|
||||
cameraCapturer = null
|
||||
}
|
||||
|
||||
// ---- Per-peer PeerConnection creation ----
|
||||
|
||||
private fun createPeerSession(peerPubKey: HexKey): PeerSessionState {
|
||||
val factory = peerConnectionFactory ?: throw IllegalStateException("PeerConnectionFactory not initialized")
|
||||
|
||||
val session =
|
||||
WebRtcCallSession(
|
||||
peerConnectionFactory = factory,
|
||||
iceServers = IceServerConfig.buildIceServers(),
|
||||
onIceCandidate = { candidate -> onLocalIceCandidate(peerPubKey, candidate) },
|
||||
onPeerConnected = {
|
||||
Log.d(TAG) { "Peer ${peerPubKey.take(8)} connected!" }
|
||||
callManager.onPeerConnected()
|
||||
if (!foregroundServiceStarted) {
|
||||
foregroundServiceStarted = true
|
||||
startForegroundService()
|
||||
}
|
||||
},
|
||||
onRemoteVideoTrack = { track -> onRemoteVideoTrack(peerPubKey, track) },
|
||||
onDisconnected = { onPeerDisconnected(peerPubKey) },
|
||||
onError = { error -> _errorMessage.value = error },
|
||||
onRenegotiationNeeded = { performRenegotiation(peerPubKey) },
|
||||
)
|
||||
|
||||
try {
|
||||
session.createPeerConnection()
|
||||
} catch (e: Exception) {
|
||||
session.dispose()
|
||||
throw e
|
||||
}
|
||||
|
||||
// Add shared local tracks to this PeerConnection
|
||||
localAudioTrackInternal?.let { session.addTrack(it) }
|
||||
localVideoTrackInternal?.let { session.addTrack(it, VIDEO_MAX_BITRATE_BPS) }
|
||||
|
||||
val ps = PeerSessionState(session)
|
||||
peerSessions[peerPubKey] = ps
|
||||
return ps
|
||||
}
|
||||
|
||||
private fun onRemoteVideoTrack(
|
||||
peerPubKey: HexKey,
|
||||
track: VideoTrack,
|
||||
) {
|
||||
Log.d(TAG) { "Remote video track from ${peerPubKey.take(8)}" }
|
||||
_remoteVideoTracks.value = _remoteVideoTracks.value + (peerPubKey to track)
|
||||
// Backward-compat: set the first remote track as the primary
|
||||
if (_remoteVideoTrack.value == null) {
|
||||
_remoteVideoTrack.value = track
|
||||
startRemoteVideoMonitor(track)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onPeerDisconnected(peerPubKey: HexKey) {
|
||||
Log.d(TAG) { "Peer ${peerPubKey.take(8)} disconnected" }
|
||||
// If all peers disconnected, hang up
|
||||
val allDisconnected =
|
||||
peerSessions.keys.all { key ->
|
||||
key == peerPubKey || peerSessions[key]?.session?.getSignalingState() == PeerConnection.SignalingState.CLOSED
|
||||
}
|
||||
if (allDisconnected) {
|
||||
scope.launch { callManager.hangup() }
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Cleanup ----
|
||||
|
||||
fun cleanup() {
|
||||
audioManager.release()
|
||||
stopForegroundService()
|
||||
foregroundServiceStarted = false
|
||||
NotificationUtils.cancelCallNotification(context)
|
||||
stopRemoteVideoMonitor()
|
||||
|
||||
// Dispose all peer sessions
|
||||
peerSessions.values.forEach { it.session.dispose() }
|
||||
peerSessions.clear()
|
||||
|
||||
// Dispose shared resources
|
||||
stopCamera()
|
||||
localAudioTrackInternal?.dispose()
|
||||
localVideoTrackInternal?.dispose()
|
||||
localAudioSource?.dispose()
|
||||
localVideoSource?.dispose()
|
||||
peerConnectionFactory?.dispose()
|
||||
sharedEglBase?.release()
|
||||
|
||||
localAudioTrackInternal = null
|
||||
localVideoTrackInternal = null
|
||||
localAudioSource = null
|
||||
localVideoSource = null
|
||||
peerConnectionFactory = null
|
||||
sharedEglBase = null
|
||||
|
||||
globalPendingIce.clear()
|
||||
|
||||
_remoteVideoTrack.value = null
|
||||
_remoteVideoTracks.value = emptyMap()
|
||||
_localVideoTrack.value = null
|
||||
_isAudioMuted.value = false
|
||||
_isVideoEnabled.value = false
|
||||
_isRemoteVideoActive.value = false
|
||||
_remoteVideoAspectRatio.value = null
|
||||
videoPausedByProximity = false
|
||||
webRtcSession?.dispose()
|
||||
webRtcSession = null
|
||||
remoteDescriptionSet.set(false)
|
||||
pendingIceCandidates.clear()
|
||||
}
|
||||
|
||||
// ---- Remote video monitoring ----
|
||||
|
||||
private fun startRemoteVideoMonitor(track: VideoTrack) {
|
||||
stopRemoteVideoMonitor()
|
||||
lastRemoteFrameTimeMs.set(System.currentTimeMillis())
|
||||
@@ -460,48 +779,7 @@ class CallController(
|
||||
}
|
||||
}
|
||||
|
||||
private fun createWebRtcSession() {
|
||||
val session =
|
||||
WebRtcCallSession(
|
||||
context = context,
|
||||
iceServers = IceServerConfig.buildIceServers(),
|
||||
onIceCandidate = { candidate -> onLocalIceCandidate(candidate) },
|
||||
onPeerConnected = {
|
||||
callManager.onPeerConnected()
|
||||
startForegroundService()
|
||||
},
|
||||
onRemoteVideoTrack = { track ->
|
||||
_remoteVideoTrack.value = track
|
||||
startRemoteVideoMonitor(track)
|
||||
},
|
||||
onDisconnected = { scope.launch { callManager.hangup() } },
|
||||
onError = { error -> _errorMessage.value = error },
|
||||
onRenegotiationNeeded = { performRenegotiation() },
|
||||
)
|
||||
try {
|
||||
session.initialize()
|
||||
session.createPeerConnection()
|
||||
webRtcSession = session
|
||||
} catch (e: Exception) {
|
||||
session.dispose()
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
private fun onLocalIceCandidate(candidate: IceCandidate) {
|
||||
Log.d(TAG) { "Local ICE candidate: ${candidate.sdp.take(50)}" }
|
||||
val callId = callManager.currentCallId() ?: return
|
||||
val peerPubKeys = callManager.currentPeerPubKeys()?.takeIf { it.isNotEmpty() } ?: return
|
||||
val candidateJson = CallIceCandidateEvent.serializeCandidate(candidate.sdp, candidate.sdpMid, candidate.sdpMLineIndex)
|
||||
|
||||
scope.launch {
|
||||
val signer = signerProvider()
|
||||
for (peerPubKey in peerPubKeys) {
|
||||
val result = callFactory.createIceCandidate(candidateJson, peerPubKey, callId, signer)
|
||||
publishWrap(result.wrap)
|
||||
}
|
||||
}
|
||||
}
|
||||
// ---- Foreground service ----
|
||||
|
||||
private fun startForegroundService() {
|
||||
try {
|
||||
@@ -530,6 +808,8 @@ class CallController(
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Incoming call notification ----
|
||||
|
||||
private suspend fun showIncomingCallNotification(callerPubKey: String) {
|
||||
val callerUser = LocalCache.getUserIfExists(callerPubKey)
|
||||
val callerName = callerUser?.toBestDisplayName() ?: callerPubKey.take(8) + "..."
|
||||
|
||||
+27
-111
@@ -20,32 +20,32 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.call
|
||||
|
||||
import android.content.Context
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import org.webrtc.AudioSource
|
||||
import org.webrtc.AudioTrack
|
||||
import org.webrtc.Camera2Enumerator
|
||||
import org.webrtc.CameraVideoCapturer
|
||||
import org.webrtc.DataChannel
|
||||
import org.webrtc.DefaultVideoDecoderFactory
|
||||
import org.webrtc.DefaultVideoEncoderFactory
|
||||
import org.webrtc.EglBase
|
||||
import org.webrtc.IceCandidate
|
||||
import org.webrtc.MediaConstraints
|
||||
import org.webrtc.MediaStream
|
||||
import org.webrtc.MediaStreamTrack
|
||||
import org.webrtc.PeerConnection
|
||||
import org.webrtc.PeerConnectionFactory
|
||||
import org.webrtc.RtpReceiver
|
||||
import org.webrtc.RtpSender
|
||||
import org.webrtc.SdpObserver
|
||||
import org.webrtc.SessionDescription
|
||||
import org.webrtc.SurfaceTextureHelper
|
||||
import org.webrtc.VideoSource
|
||||
import org.webrtc.VideoTrack
|
||||
|
||||
private const val TAG = "WebRtcCallSession"
|
||||
|
||||
/**
|
||||
* Lightweight wrapper around a single [PeerConnection].
|
||||
*
|
||||
* The caller is responsible for creating and managing the shared
|
||||
* [PeerConnectionFactory], media sources, tracks, and camera. This class
|
||||
* only owns the [PeerConnection] itself and delegates lifecycle callbacks
|
||||
* through the constructor lambdas.
|
||||
*/
|
||||
class WebRtcCallSession(
|
||||
private val context: Context,
|
||||
private val peerConnectionFactory: PeerConnectionFactory,
|
||||
private val iceServers: List<PeerConnection.IceServer>,
|
||||
private val onIceCandidate: (IceCandidate) -> Unit,
|
||||
private val onPeerConnected: () -> Unit,
|
||||
@@ -54,31 +54,7 @@ class WebRtcCallSession(
|
||||
private val onError: (String) -> Unit = {},
|
||||
private val onRenegotiationNeeded: () -> Unit = {},
|
||||
) {
|
||||
private var peerConnectionFactory: PeerConnectionFactory? = null
|
||||
private var peerConnection: PeerConnection? = null
|
||||
private var localAudioTrack: AudioTrack? = null
|
||||
private var localVideoTrack: VideoTrack? = null
|
||||
private var audioSource: AudioSource? = null
|
||||
private var videoSource: VideoSource? = null
|
||||
private var cameraCapturer: CameraVideoCapturer? = null
|
||||
|
||||
val eglBase: EglBase = EglBase.create()
|
||||
|
||||
fun initialize() {
|
||||
PeerConnectionFactory.initialize(
|
||||
PeerConnectionFactory
|
||||
.InitializationOptions
|
||||
.builder(context)
|
||||
.createInitializationOptions(),
|
||||
)
|
||||
|
||||
peerConnectionFactory =
|
||||
PeerConnectionFactory
|
||||
.builder()
|
||||
.setVideoDecoderFactory(DefaultVideoDecoderFactory(eglBase.eglBaseContext))
|
||||
.setVideoEncoderFactory(DefaultVideoEncoderFactory(eglBase.eglBaseContext, true, true))
|
||||
.createPeerConnectionFactory()
|
||||
}
|
||||
|
||||
fun createPeerConnection() {
|
||||
val rtcConfig =
|
||||
@@ -88,7 +64,7 @@ class WebRtcCallSession(
|
||||
}
|
||||
|
||||
peerConnection =
|
||||
peerConnectionFactory?.createPeerConnection(
|
||||
peerConnectionFactory.createPeerConnection(
|
||||
rtcConfig,
|
||||
object : PeerConnection.Observer {
|
||||
override fun onIceCandidate(candidate: IceCandidate?) {
|
||||
@@ -128,9 +104,6 @@ class WebRtcCallSession(
|
||||
}
|
||||
|
||||
PeerConnection.IceConnectionState.DISCONNECTED -> {
|
||||
// DISCONNECTED is often transient (network switch, brief
|
||||
// packet loss). WebRTC will transition to FAILED if
|
||||
// recovery is impossible, so we only act on FAILED above.
|
||||
Log.d(TAG) { "ICE: DISCONNECTED (transient, waiting for recovery or FAILED)" }
|
||||
}
|
||||
|
||||
@@ -151,7 +124,6 @@ class WebRtcCallSession(
|
||||
}
|
||||
|
||||
override fun onAddStream(stream: MediaStream?) {
|
||||
// Fallback for Plan B SDP — Unified Plan uses onAddTrack
|
||||
stream?.videoTracks?.firstOrNull()?.let { onRemoteVideoTrack(it) }
|
||||
}
|
||||
|
||||
@@ -168,7 +140,6 @@ class WebRtcCallSession(
|
||||
receiver: RtpReceiver?,
|
||||
streams: Array<out MediaStream>?,
|
||||
) {
|
||||
// Unified Plan: extract video track from receiver
|
||||
val track = receiver?.track()
|
||||
if (track is VideoTrack) {
|
||||
onRemoteVideoTrack(track)
|
||||
@@ -178,59 +149,25 @@ class WebRtcCallSession(
|
||||
)
|
||||
}
|
||||
|
||||
fun addAudioTrack() {
|
||||
val constraints = MediaConstraints()
|
||||
audioSource = peerConnectionFactory?.createAudioSource(constraints)
|
||||
localAudioTrack =
|
||||
peerConnectionFactory?.createAudioTrack("audio0", audioSource).also {
|
||||
peerConnection?.addTrack(it)
|
||||
}
|
||||
}
|
||||
|
||||
fun addVideoTrack() {
|
||||
videoSource = peerConnectionFactory?.createVideoSource(false)
|
||||
localVideoTrack =
|
||||
peerConnectionFactory?.createVideoTrack("video0", videoSource).also {
|
||||
val sender = peerConnection?.addTrack(it)
|
||||
// Set max bitrate to 1.5 Mbps for good 720p quality
|
||||
sender?.let { s ->
|
||||
val params = s.parameters
|
||||
/**
|
||||
* Adds an existing track to this PeerConnection.
|
||||
*
|
||||
* @param maxBitrateBps optional per-sender bitrate cap (e.g. 1_500_000 for 720p video)
|
||||
*/
|
||||
fun addTrack(
|
||||
track: MediaStreamTrack,
|
||||
maxBitrateBps: Int? = null,
|
||||
): RtpSender? {
|
||||
val sender = peerConnection?.addTrack(track) ?: return null
|
||||
if (maxBitrateBps != null) {
|
||||
val params = sender.parameters
|
||||
params.encodings.forEach { encoding ->
|
||||
encoding.maxBitrateBps = 1_500_000
|
||||
encoding.maxBitrateBps = maxBitrateBps
|
||||
}
|
||||
s.parameters = params
|
||||
sender.parameters = params
|
||||
}
|
||||
return sender
|
||||
}
|
||||
startCamera()
|
||||
}
|
||||
|
||||
fun startCamera() {
|
||||
if (cameraCapturer != null) return // Already running
|
||||
val source = videoSource ?: return
|
||||
val enumerator = Camera2Enumerator(context)
|
||||
val frontCamera = enumerator.deviceNames.firstOrNull { enumerator.isFrontFacing(it) }
|
||||
val camera = frontCamera ?: enumerator.deviceNames.firstOrNull() ?: return
|
||||
|
||||
cameraCapturer =
|
||||
enumerator.createCapturer(camera, null)?.also {
|
||||
it.initialize(
|
||||
SurfaceTextureHelper.create("CaptureThread", eglBase.eglBaseContext),
|
||||
context,
|
||||
source.capturerObserver,
|
||||
)
|
||||
it.startCapture(1280, 720, 30)
|
||||
}
|
||||
}
|
||||
|
||||
fun stopCamera() {
|
||||
cameraCapturer?.stopCapture()
|
||||
cameraCapturer?.dispose()
|
||||
cameraCapturer = null
|
||||
}
|
||||
|
||||
fun getLocalVideoSource(): VideoSource? = videoSource
|
||||
|
||||
fun getLocalVideoTrack(): VideoTrack? = localVideoTrack
|
||||
|
||||
fun createOffer(onSdpCreated: (SessionDescription) -> Unit) {
|
||||
val constraints =
|
||||
@@ -320,31 +257,10 @@ class WebRtcCallSession(
|
||||
|
||||
fun getSignalingState(): PeerConnection.SignalingState? = peerConnection?.signalingState()
|
||||
|
||||
fun setAudioEnabled(enabled: Boolean) {
|
||||
localAudioTrack?.setEnabled(enabled)
|
||||
}
|
||||
|
||||
fun setVideoEnabled(enabled: Boolean) {
|
||||
localVideoTrack?.setEnabled(enabled)
|
||||
}
|
||||
|
||||
fun dispose() {
|
||||
stopCamera()
|
||||
localAudioTrack?.dispose()
|
||||
localVideoTrack?.dispose()
|
||||
audioSource?.dispose()
|
||||
videoSource?.dispose()
|
||||
peerConnection?.close()
|
||||
peerConnection?.dispose()
|
||||
peerConnectionFactory?.dispose()
|
||||
eglBase.release()
|
||||
|
||||
localAudioTrack = null
|
||||
localVideoTrack = null
|
||||
audioSource = null
|
||||
videoSource = null
|
||||
peerConnection = null
|
||||
peerConnectionFactory = null
|
||||
}
|
||||
|
||||
private fun loggingSdpObserver(label: String) =
|
||||
|
||||
+3
-1
@@ -221,8 +221,10 @@ class AccountViewModel(
|
||||
)
|
||||
|
||||
// Set callbacks before exposing controller to avoid timing races
|
||||
callManager.onAnswerReceived = { event -> controller.onCallAnswerReceived(event.sdpAnswer()) }
|
||||
callManager.onAnswerReceived = { event -> controller.onCallAnswerReceived(event.pubKey, event.sdpAnswer()) }
|
||||
callManager.onIceCandidateReceived = { event -> controller.onIceCandidateReceived(event) }
|
||||
callManager.onNewPeerInGroupCall = { peerPubKey -> controller.onNewPeerInGroupCall(peerPubKey) }
|
||||
callManager.onMidCallOfferReceived = { peerPubKey, sdpOffer -> controller.onMidCallOfferReceived(peerPubKey, sdpOffer) }
|
||||
callController = controller
|
||||
|
||||
// Populate ActiveCallHolder so CallActivity can launch even when the app
|
||||
|
||||
+108
-65
@@ -53,10 +53,17 @@ class CallManager(
|
||||
private val _state = MutableStateFlow<CallState>(CallState.Idle)
|
||||
val state: StateFlow<CallState> = _state.asStateFlow()
|
||||
|
||||
/** Called for every answer that should be applied to a PeerConnection (includes peer pubkey). */
|
||||
var onAnswerReceived: ((CallAnswerEvent) -> Unit)? = null
|
||||
var onIceCandidateReceived: ((CallIceCandidateEvent) -> Unit)? = null
|
||||
var onRenegotiationOfferReceived: ((CallRenegotiateEvent) -> Unit)? = null
|
||||
|
||||
/** Called when a new peer joins the group call (callee-to-callee mesh setup). */
|
||||
var onNewPeerInGroupCall: ((peerPubKey: HexKey) -> Unit)? = null
|
||||
|
||||
/** Called when a mid-call offer is received from another callee in a group call. */
|
||||
var onMidCallOfferReceived: ((peerPubKey: HexKey, sdpOffer: String) -> Unit)? = null
|
||||
|
||||
private var timeoutJob: Job? = null
|
||||
private var resetJob: Job? = null
|
||||
private val processedEventIds = mutableSetOf<String>()
|
||||
@@ -69,7 +76,68 @@ class CallManager(
|
||||
|
||||
private fun isEventTooOld(event: Event): Boolean = TimeUtils.now() - event.createdAt > MAX_EVENT_AGE_SECONDS
|
||||
|
||||
// ---- P2P call initiation ----
|
||||
// ---- Call initiation (state + publish) ----
|
||||
|
||||
/**
|
||||
* Sets state to Offering. Called by CallController before creating
|
||||
* per-peer offers in group calls.
|
||||
*/
|
||||
fun beginOffering(
|
||||
callId: String,
|
||||
calleePubKeys: Set<HexKey>,
|
||||
callType: CallType,
|
||||
) {
|
||||
_state.value = CallState.Offering(callId, calleePubKeys, callType)
|
||||
startTimeout(callId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes a per-peer call offer (group context: p-tags for all members).
|
||||
* Called once per callee so each gets their own SDP.
|
||||
*/
|
||||
suspend fun publishOfferToPeer(
|
||||
calleePubKey: HexKey,
|
||||
allCalleePubKeys: Set<HexKey>,
|
||||
callType: CallType,
|
||||
callId: String,
|
||||
sdpOffer: String,
|
||||
) {
|
||||
Log.d("CallManager") { "publishOfferToPeer: to=${calleePubKey.take(8)}, sdpLength=${sdpOffer.length}" }
|
||||
val result = factory.createCallOffer(sdpOffer, calleePubKey, allCalleePubKeys, callId, callType, signer)
|
||||
publishEvent(result.wrap)
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes a callee-to-callee offer within an existing call.
|
||||
* Uses the current call context for call-id, call-type, and group members.
|
||||
*/
|
||||
suspend fun publishOfferToPeer(
|
||||
peerPubKey: HexKey,
|
||||
sdpOffer: String,
|
||||
) {
|
||||
val callId = currentCallId() ?: return
|
||||
val callType = currentCallType() ?: return
|
||||
val allMembers = (currentPeerPubKeys() ?: emptySet()) + signer.pubKey
|
||||
Log.d("CallManager") { "publishOfferToPeer (mid-call): to=${peerPubKey.take(8)}, sdpLength=${sdpOffer.length}" }
|
||||
val result = factory.createCallOffer(sdpOffer, peerPubKey, allMembers, callId, callType, signer)
|
||||
publishEvent(result.wrap)
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes a callee-to-callee answer within an existing call.
|
||||
*/
|
||||
suspend fun publishAnswerToPeer(
|
||||
peerPubKey: HexKey,
|
||||
sdpAnswer: String,
|
||||
) {
|
||||
val callId = currentCallId() ?: return
|
||||
val allMembers = (currentPeerPubKeys() ?: emptySet()) + signer.pubKey
|
||||
Log.d("CallManager") { "publishAnswerToPeer: to=${peerPubKey.take(8)}, sdpLength=${sdpAnswer.length}" }
|
||||
val result = factory.createCallAnswer(sdpAnswer, peerPubKey, allMembers, callId, signer)
|
||||
publishEvent(result.wrap)
|
||||
}
|
||||
|
||||
// ---- P2P call initiation (convenience) ----
|
||||
|
||||
suspend fun initiateCall(
|
||||
calleePubKey: HexKey,
|
||||
@@ -85,24 +153,6 @@ class CallManager(
|
||||
Log.d("CallManager") { "initiateCall: offer published, timeout started" }
|
||||
}
|
||||
|
||||
// ---- Group call initiation ----
|
||||
|
||||
/**
|
||||
* Initiates a group call. A single [CallOfferEvent] is created with `p`
|
||||
* tags for every callee and then gift-wrapped individually to each one.
|
||||
*/
|
||||
suspend fun initiateGroupCall(
|
||||
calleePubKeys: Set<HexKey>,
|
||||
callType: CallType,
|
||||
callId: String,
|
||||
sdpOffer: String,
|
||||
) {
|
||||
val result = factory.createGroupCallOffer(sdpOffer, calleePubKeys, callId, callType, signer)
|
||||
_state.value = CallState.Offering(callId, calleePubKeys, callType)
|
||||
result.wraps.forEach { publishEvent(it) }
|
||||
startTimeout(callId)
|
||||
}
|
||||
|
||||
// ---- Incoming call handling ----
|
||||
|
||||
fun onIncomingCallEvent(event: CallOfferEvent) {
|
||||
@@ -117,9 +167,23 @@ class CallManager(
|
||||
return
|
||||
}
|
||||
|
||||
if (_state.value !is CallState.Idle) {
|
||||
// Already in a call — send a "busy" reject so the caller gets
|
||||
// immediate feedback instead of waiting for the 60s timeout.
|
||||
val currentState = _state.value
|
||||
|
||||
// Mid-call offer: same call-id, we're already in the call
|
||||
if (currentState is CallState.Connecting || currentState is CallState.Connected) {
|
||||
val currentCallId =
|
||||
when (currentState) {
|
||||
is CallState.Connecting -> currentState.callId
|
||||
is CallState.Connected -> currentState.callId
|
||||
}
|
||||
if (callId == currentCallId) {
|
||||
Log.d("CallManager") { "Mid-call offer from ${callerPubKey.take(8)} for current call — callee-to-callee" }
|
||||
onMidCallOfferReceived?.invoke(callerPubKey, event.sdpOffer())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (currentState !is CallState.Idle) {
|
||||
scope.launch {
|
||||
val result = factory.createReject(callerPubKey, callId, "busy", signer = signer)
|
||||
publishEvent(result.wrap)
|
||||
@@ -151,7 +215,6 @@ class CallManager(
|
||||
_state.value = CallState.Connecting(current.callId, current.peerPubKeys() - signer.pubKey, current.callType)
|
||||
cancelTimeout()
|
||||
|
||||
// Include all group members + self so other devices get notified too.
|
||||
val allRecipients = current.groupMembers + signer.pubKey
|
||||
Log.d("CallManager") { "acceptCall: publishing answer to ${allRecipients.size} recipients" }
|
||||
val result = factory.createGroupCallAnswer(sdpAnswer, allRecipients, current.callId, signer)
|
||||
@@ -165,7 +228,6 @@ class CallManager(
|
||||
|
||||
transitionToEnded(current.callId, current.peerPubKeys(), EndReason.REJECTED)
|
||||
|
||||
// Include all group members + self so other devices get notified too.
|
||||
val allRecipients = current.groupMembers + signer.pubKey
|
||||
val result = factory.createGroupReject(allRecipients, current.callId, signer = signer)
|
||||
result.wraps.forEach { publishEvent(it) }
|
||||
@@ -187,7 +249,6 @@ class CallManager(
|
||||
Log.d("CallManager") { "onCallAnswered: callId mismatch (got=$callId, expected=${current.callId})" }
|
||||
return
|
||||
}
|
||||
// First answer: start the call immediately. Remaining peers stay pending.
|
||||
val pending = current.peerPubKeys - answeringPeer
|
||||
_state.value =
|
||||
CallState.Connecting(
|
||||
@@ -197,12 +258,11 @@ class CallManager(
|
||||
pendingPeerPubKeys = pending,
|
||||
)
|
||||
cancelTimeout()
|
||||
Log.d("CallManager") { "onCallAnswered: Offering -> Connecting, invoking onAnswerReceived callback (isNull=${onAnswerReceived == null})" }
|
||||
Log.d("CallManager") { "onCallAnswered: Offering -> Connecting, forwarding answer to CallController" }
|
||||
onAnswerReceived?.invoke(event)
|
||||
}
|
||||
|
||||
is CallState.Connecting -> {
|
||||
// Another peer answered while we're still connecting with the first
|
||||
if (callId != current.callId) return
|
||||
if (answeringPeer in current.pendingPeerPubKeys) {
|
||||
_state.value =
|
||||
@@ -211,30 +271,36 @@ class CallManager(
|
||||
pendingPeerPubKeys = current.pendingPeerPubKeys - answeringPeer,
|
||||
)
|
||||
}
|
||||
// TODO: establish additional WebRTC peer connection for this peer
|
||||
// Forward to CallController — it routes to the correct PeerSession
|
||||
Log.d("CallManager") { "onCallAnswered: additional peer ${answeringPeer.take(8)} in Connecting, forwarding" }
|
||||
onAnswerReceived?.invoke(event)
|
||||
|
||||
// Notify callees about new peer for mesh setup
|
||||
onNewPeerInGroupCall?.invoke(answeringPeer)
|
||||
}
|
||||
|
||||
is CallState.IncomingCall -> {
|
||||
if (callId != current.callId) return
|
||||
if (answeringPeer == signer.pubKey) {
|
||||
// Another device of this user answered the call — stop ringing.
|
||||
transitionToEnded(current.callId, current.peerPubKeys(), EndReason.ANSWERED_ELSEWHERE)
|
||||
}
|
||||
// Otherwise another group member answered — we keep ringing.
|
||||
// Another group member answered — notify for future mesh connections
|
||||
// (we'll connect to them after we accept the call ourselves)
|
||||
}
|
||||
|
||||
is CallState.Connected -> {
|
||||
if (callId != current.callId) return
|
||||
if (answeringPeer in current.pendingPeerPubKeys) {
|
||||
// A pending peer just joined the group call
|
||||
_state.value =
|
||||
current.copy(
|
||||
peerPubKeys = current.peerPubKeys + answeringPeer,
|
||||
pendingPeerPubKeys = current.pendingPeerPubKeys - answeringPeer,
|
||||
)
|
||||
// TODO: establish additional WebRTC peer connection for this peer
|
||||
// Forward to CallController for routing
|
||||
onAnswerReceived?.invoke(event)
|
||||
onNewPeerInGroupCall?.invoke(answeringPeer)
|
||||
} else {
|
||||
// Renegotiation answer (e.g., peer accepted our video upgrade offer)
|
||||
// Renegotiation answer
|
||||
onAnswerReceived?.invoke(event)
|
||||
}
|
||||
}
|
||||
@@ -262,14 +328,12 @@ class CallManager(
|
||||
}
|
||||
|
||||
is CallState.Connecting -> {
|
||||
// A pending peer rejected while we're already connecting with another
|
||||
if (callId != current.callId) return
|
||||
_state.value =
|
||||
current.copy(pendingPeerPubKeys = current.pendingPeerPubKeys - rejectingPeer)
|
||||
}
|
||||
|
||||
is CallState.Connected -> {
|
||||
// A pending peer rejected while we're already in the call
|
||||
if (callId != current.callId) return
|
||||
_state.value =
|
||||
current.copy(pendingPeerPubKeys = current.pendingPeerPubKeys - rejectingPeer)
|
||||
@@ -278,10 +342,8 @@ class CallManager(
|
||||
is CallState.IncomingCall -> {
|
||||
if (callId != current.callId) return
|
||||
if (rejectingPeer == signer.pubKey) {
|
||||
// Another device of this user rejected the call — stop ringing.
|
||||
transitionToEnded(current.callId, current.peerPubKeys(), EndReason.REJECTED)
|
||||
}
|
||||
// Otherwise another group member rejected — we keep ringing.
|
||||
}
|
||||
|
||||
else -> {
|
||||
@@ -307,11 +369,6 @@ class CallManager(
|
||||
onRenegotiationOfferReceived?.invoke(event)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a renegotiation offer to a specific peer. SDP is per-PeerConnection
|
||||
* so this is always addressed to a single peer. In group calls the inner
|
||||
* event includes `p` tags for all members for group context.
|
||||
*/
|
||||
suspend fun sendRenegotiation(
|
||||
sdpOffer: String,
|
||||
peerPubKey: HexKey,
|
||||
@@ -322,11 +379,6 @@ class CallManager(
|
||||
publishEvent(result.wrap)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a renegotiation answer to a specific peer. SDP is per-PeerConnection
|
||||
* so this is always addressed to a single peer. The inner event includes
|
||||
* `p` tags for all members for group context.
|
||||
*/
|
||||
suspend fun sendRenegotiationAnswer(
|
||||
sdpAnswer: String,
|
||||
peerPubKey: HexKey,
|
||||
@@ -355,11 +407,6 @@ class CallManager(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Invites a new peer into the current call by sending them an offer.
|
||||
* The inner event includes `p` tags for all existing group members plus
|
||||
* the new invitee so they can see the full group composition.
|
||||
*/
|
||||
suspend fun invitePeer(
|
||||
peerPubKey: HexKey,
|
||||
sdpOffer: String,
|
||||
@@ -388,7 +435,6 @@ class CallManager(
|
||||
}
|
||||
}
|
||||
|
||||
// All group members: existing peers + the new invitee + ourselves
|
||||
val allMembers = existingMembers + peerPubKey + signer.pubKey
|
||||
val result = factory.createCallOffer(sdpOffer, peerPubKey, allMembers, callId, callType, signer)
|
||||
publishEvent(result.wrap)
|
||||
@@ -517,10 +563,8 @@ class CallManager(
|
||||
else -> null
|
||||
}
|
||||
|
||||
/** Returns the first peer pubkey (for P2P calls) or null. */
|
||||
fun currentPeerPubKey(): HexKey? = currentPeerPubKeys()?.firstOrNull()
|
||||
|
||||
/** Returns all peer pubkeys for the current call (connected + pending). */
|
||||
fun currentPeerPubKeys(): Set<HexKey>? =
|
||||
when (val s = _state.value) {
|
||||
is CallState.Offering -> s.peerPubKeys
|
||||
@@ -530,7 +574,15 @@ class CallManager(
|
||||
else -> null
|
||||
}
|
||||
|
||||
/** True when the current call has more than one peer. */
|
||||
fun currentCallType(): CallType? =
|
||||
when (val s = _state.value) {
|
||||
is CallState.Offering -> s.callType
|
||||
is CallState.IncomingCall -> s.callType
|
||||
is CallState.Connecting -> s.callType
|
||||
is CallState.Connected -> s.callType
|
||||
else -> null
|
||||
}
|
||||
|
||||
fun isGroupCall(): Boolean = (currentPeerPubKeys()?.size ?: 0) > 1
|
||||
|
||||
fun reset() {
|
||||
@@ -588,13 +640,4 @@ class CallManager(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience extension: the peers in an incoming call are all group members
|
||||
* except the local signer (i.e. ourselves) – but since we don't store the
|
||||
* local pubkey here we return all members except the caller's own pubkey
|
||||
* is already the callerPubKey field. In practice the set of "peer" keys
|
||||
* the UI should track is groupMembers minus self, which the controller
|
||||
* resolves. Here we simply exclude the caller from the recipients set
|
||||
* and add back the caller, resulting in the full group minus self.
|
||||
*/
|
||||
private fun CallState.IncomingCall.peerPubKeys(): Set<HexKey> = groupMembers
|
||||
|
||||
Reference in New Issue
Block a user