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,