Skip to content

Commit 02d5796

Browse files
committed
simplify architecture for call participant handling
Signed-off-by: Marcel Hibbe <[email protected]>
1 parent afa6b3d commit 02d5796

14 files changed

+422
-549
lines changed

app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt

Lines changed: 88 additions & 147 deletions
Large diffs are not rendered by default.
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/*
2+
* Nextcloud Talk - Android Client
3+
*
4+
* SPDX-FileCopyrightText: 2025 Marcel Hibbe <[email protected]>
5+
* SPDX-License-Identifier: GPL-3.0-or-later
6+
*/
7+
package com.nextcloud.talk.activities
8+
9+
import androidx.lifecycle.ViewModel
10+
import androidx.lifecycle.viewModelScope
11+
import com.nextcloud.talk.signaling.SignalingMessageReceiver
12+
import kotlinx.coroutines.flow.MutableStateFlow
13+
import kotlinx.coroutines.flow.StateFlow
14+
import kotlinx.coroutines.flow.asStateFlow
15+
import kotlinx.coroutines.launch
16+
import javax.inject.Inject
17+
18+
class CallViewModel @Inject constructor() : ViewModel() {
19+
20+
private val participantHandlers = mutableMapOf<String, ParticipantHandler>()
21+
22+
private val _participants = MutableStateFlow<List<ParticipantUiState>>(emptyList())
23+
val participants: StateFlow<List<ParticipantUiState>> = _participants.asStateFlow()
24+
25+
fun getParticipant(sessionId: String?): ParticipantHandler? = participantHandlers[sessionId]
26+
27+
fun doesParticipantExist(sessionId: String?): Boolean = (participantHandlers.containsKey(sessionId))
28+
29+
fun addParticipant(sessionId: String, signalingMessageReceiver: SignalingMessageReceiver) {
30+
if (participantHandlers.containsKey(sessionId)) return
31+
32+
val participantHandler = ParticipantHandler(sessionId, signalingMessageReceiver)
33+
participantHandlers[sessionId] = participantHandler
34+
35+
viewModelScope.launch {
36+
participantHandler.uiState.collect {
37+
_participants.value = participantHandlers.values.map { it.uiState.value }
38+
}
39+
}
40+
}
41+
42+
fun removeParticipant(sessionId: String) {
43+
participantHandlers[sessionId]?.destroy()
44+
participantHandlers.remove(sessionId)
45+
_participants.value = participantHandlers.values.map { it.uiState.value }
46+
}
47+
48+
override fun onCleared() {
49+
participantHandlers.values.forEach { it.destroy() }
50+
}
51+
}
Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
/*
2+
* Nextcloud Talk - Android Client
3+
*
4+
* SPDX-FileCopyrightText: 2025 Marcel Hibbe <[email protected]>
5+
* SPDX-License-Identifier: GPL-3.0-or-later
6+
*/
7+
8+
package com.nextcloud.talk.activities
9+
10+
import com.nextcloud.talk.models.json.participants.Participant
11+
import com.nextcloud.talk.signaling.SignalingMessageReceiver
12+
import com.nextcloud.talk.webrtc.PeerConnectionWrapper
13+
import com.nextcloud.talk.webrtc.PeerConnectionWrapper.DataChannelMessageListener
14+
import com.nextcloud.talk.webrtc.PeerConnectionWrapper.PeerConnectionObserver
15+
import kotlinx.coroutines.flow.MutableStateFlow
16+
import kotlinx.coroutines.flow.StateFlow
17+
import kotlinx.coroutines.flow.asStateFlow
18+
import kotlinx.coroutines.flow.update
19+
import org.webrtc.MediaStream
20+
import org.webrtc.PeerConnection
21+
import org.webrtc.PeerConnection.IceConnectionState
22+
23+
class ParticipantHandler(
24+
private val sessionId: String,
25+
private val signalingMessageReceiver: SignalingMessageReceiver
26+
) {
27+
private val _uiState = MutableStateFlow(
28+
ParticipantUiState(
29+
sessionKey = sessionId,
30+
nick = "Guest",
31+
isConnected = false,
32+
isAudioEnabled = false,
33+
isStreamEnabled = false,
34+
raisedHand = false,
35+
isInternal = false
36+
)
37+
)
38+
val uiState: StateFlow<ParticipantUiState> = _uiState.asStateFlow()
39+
40+
private var peerConnection: PeerConnectionWrapper? = null
41+
private var screenPeerConnection: PeerConnectionWrapper? = null
42+
43+
private val peerConnectionObserver: PeerConnectionObserver = object : PeerConnectionObserver {
44+
override fun onStreamAdded(mediaStream: MediaStream?) {
45+
handleStreamChange(mediaStream)
46+
}
47+
48+
override fun onStreamRemoved(mediaStream: MediaStream?) {
49+
handleStreamChange(mediaStream)
50+
}
51+
52+
override fun onIceConnectionStateChanged(iceConnectionState: IceConnectionState?) {
53+
handleIceConnectionStateChange(iceConnectionState)
54+
}
55+
}
56+
57+
private val screenPeerConnectionObserver: PeerConnectionObserver = object : PeerConnectionObserver {
58+
override fun onStreamAdded(mediaStream: MediaStream?) {
59+
_uiState.update { it.copy(screenMediaStream = mediaStream) }
60+
}
61+
62+
override fun onStreamRemoved(mediaStream: MediaStream?) {
63+
_uiState.update { it.copy(screenMediaStream = null) }
64+
}
65+
66+
override fun onIceConnectionStateChanged(iceConnectionState: IceConnectionState?) {
67+
// callParticipantModel.setScreenIceConnectionState(iceConnectionState)
68+
}
69+
}
70+
71+
private fun handleStreamChange(mediaStream: MediaStream?) {
72+
if (mediaStream == null) {
73+
_uiState.update { it.copy(mediaStream = null) }
74+
_uiState.update { it.copy(isStreamEnabled = false) }
75+
return
76+
}
77+
78+
val hasAtLeastOneVideoStream = mediaStream.videoTracks != null && !mediaStream.videoTracks.isEmpty()
79+
80+
_uiState.update { it.copy(mediaStream = mediaStream) }
81+
_uiState.update { it.copy(isStreamEnabled = hasAtLeastOneVideoStream) }
82+
}
83+
84+
private fun handleIceConnectionStateChange(iceConnectionState: IceConnectionState?) {
85+
if (iceConnectionState == IceConnectionState.NEW ||
86+
iceConnectionState == IceConnectionState.CHECKING
87+
) {
88+
_uiState.update { it.copy(isAudioEnabled = false) }
89+
_uiState.update { it.copy(isStreamEnabled = false) }
90+
}
91+
92+
val isConnected = iceConnectionState == IceConnectionState.CONNECTED ||
93+
iceConnectionState == IceConnectionState.COMPLETED ||
94+
iceConnectionState == null
95+
_uiState.update { it.copy(isConnected = isConnected) }
96+
}
97+
98+
private val dataChannelMessageListener: DataChannelMessageListener = object : DataChannelMessageListener {
99+
override fun onAudioOn() {
100+
_uiState.update { it.copy(isAudioEnabled = true) }
101+
}
102+
103+
override fun onAudioOff() {
104+
_uiState.update { it.copy(isAudioEnabled = false) }
105+
}
106+
107+
override fun onVideoOn() {
108+
_uiState.update { it.copy(isStreamEnabled = true) }
109+
}
110+
111+
override fun onVideoOff() {
112+
_uiState.update { it.copy(isStreamEnabled = false) }
113+
}
114+
115+
override fun onNickChanged(nick: String?) {
116+
_uiState.update { it.copy(nick = nick) }
117+
}
118+
}
119+
120+
// --- Signaling listeners ---
121+
private val listener = object : SignalingMessageReceiver.CallParticipantMessageListener {
122+
override fun onRaiseHand(state: Boolean, timestamp: Long) {
123+
_uiState.update { it.copy(raisedHand = state) }
124+
}
125+
126+
override fun onReaction(reaction: String?) {
127+
// TODO: handle reactions
128+
}
129+
130+
override fun onUnshareScreen() {
131+
updateMedia(null, null)
132+
}
133+
}
134+
135+
init {
136+
signalingMessageReceiver.addListener(listener, sessionId)
137+
}
138+
139+
// --- WebRTC updates ---
140+
fun updateMedia(mediaStream: MediaStream?, iceState: PeerConnection.IceConnectionState?) {
141+
_uiState.update {
142+
it.copy(
143+
mediaStream = mediaStream,
144+
isConnected =
145+
iceState == PeerConnection.IceConnectionState.CONNECTED ||
146+
iceState == PeerConnection.IceConnectionState.COMPLETED,
147+
isStreamEnabled = mediaStream?.videoTracks?.isNotEmpty() == true
148+
)
149+
}
150+
}
151+
152+
fun setPeerConnection(peerConnection: PeerConnectionWrapper?) {
153+
this.peerConnection?.let {
154+
it.removeObserver(peerConnectionObserver)
155+
it.removeListener(dataChannelMessageListener)
156+
}
157+
158+
this.peerConnection = peerConnection
159+
160+
if (this.peerConnection == null) {
161+
_uiState.update { it.copy(mediaStream = null) }
162+
_uiState.update { it.copy(isAudioEnabled = false) }
163+
_uiState.update { it.copy(isStreamEnabled = false) }
164+
165+
return
166+
}
167+
168+
handleIceConnectionStateChange(this.peerConnection?.peerConnection?.iceConnectionState())
169+
handleStreamChange(this.peerConnection?.stream)
170+
171+
this.peerConnection?.addObserver(peerConnectionObserver)
172+
this.peerConnection?.addListener(dataChannelMessageListener)
173+
}
174+
175+
fun setScreenPeerConnection(screenPeerConnectionWrapper: PeerConnectionWrapper?) {
176+
this.screenPeerConnection?.removeObserver(screenPeerConnectionObserver)
177+
178+
this.screenPeerConnection = screenPeerConnectionWrapper
179+
180+
if (this.screenPeerConnection == null) {
181+
// callParticipantModel.setScreenIceConnectionState(null)
182+
_uiState.update { it.copy(screenMediaStream = null) }
183+
184+
return
185+
}
186+
187+
_uiState.update { it.copy(screenMediaStream = screenPeerConnection?.stream) }
188+
189+
this.screenPeerConnection?.addObserver(screenPeerConnectionObserver)
190+
}
191+
192+
fun updateAudio(enabled: Boolean?) = _uiState.update { it.copy(isAudioEnabled = enabled ?: it.isAudioEnabled) }
193+
194+
fun updateVideo(enabled: Boolean?) = _uiState.update { it.copy(isStreamEnabled = enabled ?: it.isStreamEnabled) }
195+
196+
fun updateNick(nick: String?) = _uiState.update { it.copy(nick = nick ?: "Guest") }
197+
198+
fun updateUserId(userId: String?) = _uiState.update { it.copy(userId = userId) }
199+
200+
fun updateIsInternal(isInternal: Boolean) = _uiState.update { it.copy(isInternal = isInternal) }
201+
202+
fun updateActor(actorType: Participant.ActorType?, actorId: String?) =
203+
_uiState.update { it.copy(actorType = actorType, actorId = actorId) }
204+
205+
fun destroy() {
206+
signalingMessageReceiver.removeListener(listener)
207+
208+
if (peerConnection != null) {
209+
peerConnection!!.removeObserver(peerConnectionObserver)
210+
peerConnection!!.removeListener(dataChannelMessageListener)
211+
}
212+
if (screenPeerConnection != null) {
213+
screenPeerConnection!!.removeObserver(screenPeerConnectionObserver)
214+
}
215+
216+
peerConnection = null
217+
screenPeerConnection = null
218+
}
219+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
/*
2+
* Nextcloud Talk - Android Client
3+
*
4+
* SPDX-FileCopyrightText: 2025 Marcel Hibbe <[email protected]>
5+
* SPDX-License-Identifier: GPL-3.0-or-later
6+
*/
7+
8+
package com.nextcloud.talk.activities
9+
10+
import com.nextcloud.talk.models.json.participants.Participant
11+
import org.webrtc.MediaStream
12+
13+
data class ParticipantUiState(
14+
val sessionKey: String?,
15+
val nick: String?,
16+
val isConnected: Boolean,
17+
val isAudioEnabled: Boolean,
18+
val isStreamEnabled: Boolean,
19+
val raisedHand: Boolean,
20+
val avatarUrl: String? = null,
21+
val mediaStream: MediaStream? = null,
22+
val screenMediaStream: MediaStream? = null,
23+
val actorType: Participant.ActorType? = null,
24+
val actorId: String? = null,
25+
val userId: String? = null,
26+
val isInternal: Boolean
27+
)

0 commit comments

Comments
 (0)