diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt index e06f6cd34f..daee010852 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt @@ -427,13 +427,13 @@ private fun ConnectedCallUI( verticalArrangement = Arrangement.Center, ) { GroupCallPictures( - peerPubKeys = state.peerPubKeys, + peerPubKeys = state.allPeerPubKeys, size = 120.dp, accountViewModel = accountViewModel, ) Spacer(modifier = Modifier.height(16.dp)) GroupCallNames( - peerPubKeys = state.peerPubKeys, + peerPubKeys = state.allPeerPubKeys, accountViewModel = accountViewModel, textColor = Color.White, ) @@ -443,6 +443,14 @@ private fun ConnectedCallUI( color = Color.White.copy(alpha = 0.7f), fontSize = 16.sp, ) + if (state.pendingPeerPubKeys.isNotEmpty()) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringRes(R.string.call_waiting_for_others), + color = Color.White.copy(alpha = 0.5f), + fontSize = 13.sp, + ) + } } } else { // Timer overlay @@ -648,7 +656,7 @@ private fun PipConnectedCallUI( verticalArrangement = Arrangement.Center, ) { GroupCallPictures( - peerPubKeys = state.peerPubKeys, + peerPubKeys = state.allPeerPubKeys, size = 48.dp, accountViewModel = accountViewModel, ) diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index b2c1dba13b..49801f5cbe 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -796,6 +796,7 @@ Bluetooth Voice call Video call + Waiting for others to join\u2026 Failed to start call Failed to accept call Failed to create call session diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt index b025cdc94c..496b08d5b3 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt @@ -155,15 +155,37 @@ class CallManager( fun onCallAnswered(event: CallAnswerEvent) { val current = _state.value val callId = event.callId() + val answeringPeer = event.pubKey when (current) { is CallState.Offering -> { if (callId != current.callId) return - _state.value = CallState.Connecting(current.callId, current.peerPubKeys, current.callType) + // First answer: start the call immediately. Remaining peers stay pending. + val pending = current.peerPubKeys - answeringPeer + _state.value = + CallState.Connecting( + current.callId, + setOf(answeringPeer), + current.callType, + pendingPeerPubKeys = pending, + ) cancelTimeout() 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 = + current.copy( + peerPubKeys = current.peerPubKeys + answeringPeer, + pendingPeerPubKeys = current.pendingPeerPubKeys - answeringPeer, + ) + } + // TODO: establish additional WebRTC peer connection for this peer + } + is CallState.IncomingCall -> { // Another device of this user answered the call — stop ringing. if (callId != current.callId) return @@ -171,9 +193,19 @@ class CallManager( } is CallState.Connected -> { - // Renegotiation answer (e.g., peer accepted our video upgrade offer) if (callId != current.callId) return - onAnswerReceived?.invoke(event) + 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 + } else { + // Renegotiation answer (e.g., peer accepted our video upgrade offer) + onAnswerReceived?.invoke(event) + } } else -> { @@ -198,6 +230,20 @@ 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) + } + is CallState.IncomingCall -> { // Another device of this user rejected the call — stop ringing. if (callId != current.callId) return @@ -251,6 +297,7 @@ class CallManager( peerPubKeys = current.peerPubKeys, callType = current.callType, startedAtEpoch = TimeUtils.now(), + pendingPeerPubKeys = current.pendingPeerPubKeys, ) } @@ -296,21 +343,31 @@ class CallManager( when (current) { is CallState.Connected -> { if (callId != current.callId) return - val remaining = current.peerPubKeys - leavingPeer - if (remaining.isEmpty()) { - transitionToEnded(callId, current.peerPubKeys, EndReason.PEER_HANGUP) + val connectedRemaining = current.peerPubKeys - leavingPeer + val pendingRemaining = current.pendingPeerPubKeys - leavingPeer + if (connectedRemaining.isEmpty() && pendingRemaining.isEmpty()) { + transitionToEnded(callId, current.allPeerPubKeys, EndReason.PEER_HANGUP) } else { - _state.value = current.copy(peerPubKeys = remaining) + _state.value = + current.copy( + peerPubKeys = connectedRemaining, + pendingPeerPubKeys = pendingRemaining, + ) } } is CallState.Connecting -> { if (callId != current.callId) return - val remaining = current.peerPubKeys - leavingPeer - if (remaining.isEmpty()) { - transitionToEnded(callId, current.peerPubKeys, EndReason.PEER_HANGUP) + val connectedRemaining = current.peerPubKeys - leavingPeer + val pendingRemaining = current.pendingPeerPubKeys - leavingPeer + if (connectedRemaining.isEmpty() && pendingRemaining.isEmpty()) { + transitionToEnded(callId, current.peerPubKeys + current.pendingPeerPubKeys, EndReason.PEER_HANGUP) } else { - _state.value = current.copy(peerPubKeys = remaining) + _state.value = + current.copy( + peerPubKeys = connectedRemaining, + pendingPeerPubKeys = pendingRemaining, + ) } } @@ -326,7 +383,6 @@ class CallManager( is CallState.IncomingCall -> { if (callId != current.callId) return - // If the caller hung up, end the incoming call if (leavingPeer == current.callerPubKey) { transitionToEnded(callId, current.groupMembers, EndReason.PEER_HANGUP) } else { @@ -376,13 +432,13 @@ class CallManager( /** Returns the first peer pubkey (for P2P calls) or null. */ fun currentPeerPubKey(): HexKey? = currentPeerPubKeys()?.firstOrNull() - /** Returns all peer pubkeys for the current call. */ + /** Returns all peer pubkeys for the current call (connected + pending). */ fun currentPeerPubKeys(): Set? = when (val s = _state.value) { is CallState.Offering -> s.peerPubKeys is CallState.IncomingCall -> s.peerPubKeys() - is CallState.Connecting -> s.peerPubKeys - is CallState.Connected -> s.peerPubKeys + is CallState.Connecting -> s.peerPubKeys + s.pendingPeerPubKeys + is CallState.Connected -> s.allPeerPubKeys else -> null } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallState.kt index 940eefdaa8..001ebcd87d 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallState.kt @@ -46,6 +46,7 @@ sealed interface CallState { val callId: String, val peerPubKeys: Set, val callType: CallType, + val pendingPeerPubKeys: Set = emptySet(), ) : CallState data class Connected( @@ -53,7 +54,11 @@ sealed interface CallState { val peerPubKeys: Set, val callType: CallType, val startedAtEpoch: Long, - ) : CallState + val pendingPeerPubKeys: Set = emptySet(), + ) : CallState { + /** All peers: connected + still pending. */ + val allPeerPubKeys: Set get() = peerPubKeys + pendingPeerPubKeys + } data class Ended( val callId: String,