Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
53 commits
Select commit Hold shift + click to select a range
cedab07
remove ParticipantDisplayItemNotifier
mahibi Sep 24, 2025
6425a74
Rename .java to .kt
mahibi Sep 24, 2025
efdeabf
WIP. replace observer patterns with flows
mahibi Sep 24, 2025
4d5c799
simplify architecture for call participant handling
mahibi Oct 16, 2025
442a4b1
add share screen icon and logic
mahibi Oct 17, 2025
4c95fb9
remove animations for call controls + remove fullscreen grid
mahibi Oct 17, 2025
8e74ffa
remove margin
mahibi Oct 17, 2025
9b036bc
fix self video zorder
mahibi Oct 17, 2025
575eadb
change top bar
mahibi Oct 17, 2025
11f234d
open/close screenshare of participants by click on monitor/close button
mahibi Oct 27, 2025
1afbdb4
make screen share fullscreen
mahibi Oct 29, 2025
c2cb899
hide self video in screenshare view mode
mahibi Oct 29, 2025
b668e18
do not cut off stream in fullscreen
mahibi Oct 29, 2025
a2378fb
implement zooming and panning
mahibi Oct 29, 2025
5451cf6
automatically show/unshow screenshare
mahibi Oct 29, 2025
3a8ae2c
fade in/out screenshare controls by single click
mahibi Oct 29, 2025
b1c8a1e
refactor structure
mahibi Oct 29, 2025
00a4b46
rearrange top bar
mahibi Oct 30, 2025
4753c4b
solve codacy warnings
mahibi Oct 30, 2025
02595a5
migrate self video to compose
mahibi Oct 30, 2025
a9d337c
remove drag and drop for selfvideo for now
mahibi Oct 30, 2025
3ad9d9a
fix screenshare for peer connection
mahibi Oct 30, 2025
91cdea8
solve warnings
mahibi Oct 31, 2025
979edd2
fix test LocalStateBroadcasterTest
mahibi Oct 31, 2025
99c5658
fix participant avatar url
mahibi Oct 31, 2025
49b24f9
remove CallParticipantModelTest
mahibi Oct 31, 2025
b9460a2
add Tests for CallViewModel
mahibi Oct 31, 2025
57489cf
fix LocalStateBroadcasterMcuTest (1 failing test for now)
mahibi Nov 2, 2025
f51ec99
fix to not show loading spinner after screen share ended
mahibi Nov 4, 2025
5ff8e4a
use ConcurrentHashMap to avoid ConcurrentModificationException
mahibi Nov 4, 2025
a7cdc0c
add ParticipantHandlerTest.kt
mahibi Nov 5, 2025
54a744c
temporarily comment out some tests
mahibi Nov 5, 2025
48491b9
fix avatars (also for guests with names)
mahibi Nov 6, 2025
65da036
fix federated avatar darkMode + make avatar handling even more reactive
mahibi Nov 6, 2025
bb991d7
remove usage of userId for calls (deprecated)
mahibi Nov 6, 2025
19a7b32
move hand to bottom left
mahibi Nov 6, 2025
d15d71d
enable to receive screenshare in audiocalls
mahibi Nov 6, 2025
9c72c6f
simplify ParticipantTile
mahibi Nov 6, 2025
feab800
request big size for avatars in call
mahibi Nov 6, 2025
dd8d9b1
set OfferToReceiveVideo only for the peer that shares the screen (ove…
mahibi Nov 7, 2025
0a49aab
remove unnecessary checks for doesParticipantExist
mahibi Nov 7, 2025
48f5a49
adapt tests (1 still failing)
mahibi Nov 7, 2025
867ec0f
support high resolution screenshares by using software encoder/decoder
mahibi Nov 7, 2025
34e90b0
adapt tests
mahibi Nov 7, 2025
2f6b3ea
format code
mahibi Nov 10, 2025
7352736
adapt tests
mahibi Nov 10, 2025
ffb87cf
add logging
mahibi Nov 12, 2025
276436f
resolve warnings
mahibi Nov 13, 2025
f5de89d
fix loading spinner for participants without peerConnection
mahibi Nov 17, 2025
b2768ad
fallback to "guest" if participant name not known
mahibi Nov 18, 2025
952d41e
set raise hand to upper right corner again
mahibi Nov 18, 2025
6c7f054
better fallback to "guest" if participant name not known
mahibi Nov 18, 2025
8ab836d
sort participants in call
mahibi Nov 18, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
622 changes: 187 additions & 435 deletions app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt

Large diffs are not rendered by default.

100 changes: 100 additions & 0 deletions app/src/main/java/com/nextcloud/talk/activities/CallViewModel.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
/*
* Nextcloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2025 Marcel Hibbe <[email protected]>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.nextcloud.talk.activities

import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.nextcloud.talk.signaling.SignalingMessageReceiver
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject

class CallViewModel @Inject constructor() : ViewModel() {

private val participantHandlers: MutableMap<String, ParticipantHandler> = ConcurrentHashMap()

private val _participants = MutableStateFlow<List<ParticipantUiState>>(emptyList())
val participants: StateFlow<List<ParticipantUiState>> = _participants.asStateFlow()

private val _activeScreenShareSession = MutableStateFlow<ParticipantUiState?>(null)
val activeScreenShareSession: StateFlow<ParticipantUiState?> = _activeScreenShareSession.asStateFlow()

fun getParticipant(sessionId: String?): ParticipantHandler? {
if (sessionId == null) {
Log.w(TAG, "Attempted to get participant with null sessionId.")
return null
}
return participantHandlers[sessionId]
}

fun doesParticipantExist(sessionId: String?): Boolean = (participantHandlers.containsKey(sessionId))

fun addParticipant(
baseUrl: String,
roomToken: String,
sessionId: String,
signalingMessageReceiver: SignalingMessageReceiver
) {
if (participantHandlers.containsKey(sessionId)) return

val participantHandler = ParticipantHandler(
sessionId,
baseUrl,
roomToken,
signalingMessageReceiver,
onParticipantShareScreen = {
onShareScreen(it)
},
onParticipantUnshareScreen = {
onUnshareScreen(it)
}
)
participantHandlers[sessionId] = participantHandler

viewModelScope.launch {
participantHandler.uiState.collect {
_participants.value = participantHandlers.values.map { it.uiState.value }
}
}
}

fun onShareScreen(sessionId: String?) {
setActiveScreenShareSession(sessionId)
}

fun onUnshareScreen(sessionId: String?) {
if (_activeScreenShareSession.value?.sessionKey.equals(sessionId)) {
setActiveScreenShareSession(null)
}
}

fun removeParticipant(sessionId: String) {
participantHandlers[sessionId]?.destroy()
participantHandlers.remove(sessionId)
_participants.value = participantHandlers.values.map { it.uiState.value }
}

fun setActiveScreenShareSession(session: String?) {
_activeScreenShareSession.value = session?.let {
participantHandlers[it]?.uiState?.value
}
}

public override fun onCleared() {
participantHandlers.values.forEach { it.destroy() }
participantHandlers.clear()
_participants.value = emptyList()
}

companion object {
private val TAG = CallViewModel::class.java.simpleName
}
}
235 changes: 235 additions & 0 deletions app/src/main/java/com/nextcloud/talk/activities/ParticipantHandler.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
/*
* Nextcloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2025 Marcel Hibbe <[email protected]>
* SPDX-License-Identifier: GPL-3.0-or-later
*/

package com.nextcloud.talk.activities

import android.util.Log
import com.nextcloud.talk.models.json.participants.Participant
import com.nextcloud.talk.signaling.SignalingMessageReceiver
import com.nextcloud.talk.webrtc.PeerConnectionWrapper
import com.nextcloud.talk.webrtc.PeerConnectionWrapper.DataChannelMessageListener
import com.nextcloud.talk.webrtc.PeerConnectionWrapper.PeerConnectionObserver
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import org.webrtc.MediaStream
import org.webrtc.PeerConnection.IceConnectionState

class ParticipantHandler(
private val sessionId: String,
val baseUrl: String,
val roomToken: String,
private val signalingMessageReceiver: SignalingMessageReceiver,
onParticipantShareScreen: ((String?) -> Unit?),
onParticipantUnshareScreen: ((String?) -> Unit?)
) {
private val _uiState = MutableStateFlow(
ParticipantUiState(
sessionKey = sessionId,
baseUrl = baseUrl,
roomToken = roomToken,
nick = "Guest",
isConnected = true,
isAudioEnabled = false,
isStreamEnabled = false,
isScreenStreamEnabled = false,
raisedHand = false,
isInternal = false
)
)
val uiState: StateFlow<ParticipantUiState> = _uiState.asStateFlow()

private var peerConnection: PeerConnectionWrapper? = null
private var screenPeerConnection: PeerConnectionWrapper? = null

private val peerConnectionObserver: PeerConnectionObserver = object : PeerConnectionObserver {
override fun onStreamAdded(mediaStream: MediaStream?) {
handleStreamChange(mediaStream)
}

override fun onStreamRemoved(mediaStream: MediaStream?) {
handleStreamChange(mediaStream)
}

override fun onIceConnectionStateChanged(iceConnectionState: IceConnectionState?) {
Log.d(TAG, "onIceConnectionStateChanged " + _uiState.value.nick + " " + iceConnectionState)
handleIceConnectionStateChange(iceConnectionState)
}
}

private val screenPeerConnectionObserver: PeerConnectionObserver = object : PeerConnectionObserver {
override fun onStreamAdded(mediaStream: MediaStream?) {
handleScreenStreamChange(mediaStream)
onParticipantShareScreen.invoke(_uiState.value.sessionKey)
}

override fun onStreamRemoved(mediaStream: MediaStream?) {
handleScreenStreamChange(mediaStream)
}

override fun onIceConnectionStateChanged(iceConnectionState: IceConnectionState?) {
// do nothing
}
}

private fun handleStreamChange(mediaStream: MediaStream?) {
val hasAtLeastOneVideoStream = mediaStream?.videoTracks?.isNotEmpty() == true

_uiState.update {
it.copy(
mediaStream = mediaStream,
isStreamEnabled = hasAtLeastOneVideoStream
)
}
}

private fun handleScreenStreamChange(mediaStream: MediaStream?) {
val hasAtLeastOneVideoStream = mediaStream?.videoTracks?.isNotEmpty() == true

_uiState.update {
it.copy(
screenMediaStream = mediaStream,
isScreenStreamEnabled = hasAtLeastOneVideoStream
)
}
}

private fun handleIceConnectionStateChange(iceConnectionState: IceConnectionState?) {
Log.d(TAG, "handleIceConnectionStateChange " + _uiState.value.nick + " " + iceConnectionState)

if (iceConnectionState == IceConnectionState.NEW ||
iceConnectionState == IceConnectionState.CHECKING
) {
_uiState.update { it.copy(isAudioEnabled = false) }
_uiState.update { it.copy(isStreamEnabled = false) }
}

_uiState.update { it.copy(isConnected = isConnected(iceConnectionState)) }
}

private val dataChannelMessageListener: DataChannelMessageListener = object : DataChannelMessageListener {
override fun onAudioOn() {
_uiState.update { it.copy(isAudioEnabled = true) }
}

override fun onAudioOff() {
_uiState.update { it.copy(isAudioEnabled = false) }
}

override fun onVideoOn() {
_uiState.update { it.copy(isStreamEnabled = true) }
}

override fun onVideoOff() {
_uiState.update { it.copy(isStreamEnabled = false) }
}

override fun onNickChanged(nick: String?) {
_uiState.update { it.copy(nick = nick) }
}
}

private val listener = object : SignalingMessageReceiver.CallParticipantMessageListener {
override fun onRaiseHand(state: Boolean, timestamp: Long) {
_uiState.update { it.copy(raisedHand = state) }
}

override fun onReaction(reaction: String?) {
Log.d(TAG, "onReaction")
}

override fun onUnshareScreen() {
handleScreenStreamChange(null)
onParticipantUnshareScreen.invoke(_uiState.value.sessionKey)
}
}

init {
signalingMessageReceiver.addListener(listener, sessionId)
}

fun setPeerConnection(peerConnection: PeerConnectionWrapper?) {
this.peerConnection?.let {
it.removeObserver(peerConnectionObserver)
it.removeListener(dataChannelMessageListener)
}

this.peerConnection = peerConnection

if (this.peerConnection == null) {
// special case when participant has no permission. -> no streams are transmitted but he must be shown as
// connected
_uiState.update { it.copy(mediaStream = null) }
_uiState.update { it.copy(isAudioEnabled = false) }
_uiState.update { it.copy(isStreamEnabled = false) }
_uiState.update { it.copy(isConnected = true) }
return
}

Log.d(
TAG,
"setPeerConnection " + _uiState.value.nick + " " +
this.peerConnection?.peerConnection?.iceConnectionState()
)

handleIceConnectionStateChange(this.peerConnection?.peerConnection?.iceConnectionState())
handleStreamChange(this.peerConnection?.stream)

this.peerConnection?.addObserver(peerConnectionObserver)
this.peerConnection?.addListener(dataChannelMessageListener)
}

fun setScreenPeerConnection(screenPeerConnectionWrapper: PeerConnectionWrapper?) {
this.screenPeerConnection?.removeObserver(screenPeerConnectionObserver)

this.screenPeerConnection = screenPeerConnectionWrapper

if (this.screenPeerConnection == null) {
_uiState.update { it.copy(screenMediaStream = null) }
return
}

_uiState.update { it.copy(screenMediaStream = screenPeerConnection?.stream) }

this.screenPeerConnection?.addObserver(screenPeerConnectionObserver)
}

fun isConnected(iceConnectionState: IceConnectionState?): Boolean =
iceConnectionState == IceConnectionState.CONNECTED ||
iceConnectionState == IceConnectionState.COMPLETED ||
// If there is no connection state that means that no connection is needed,
// so it is a special case that is also seen as "connected".
iceConnectionState == null

fun updateNick(nick: String?) = _uiState.update { it.copy(nick = nick ?: "Guest") }

fun updateIsInternal(isInternal: Boolean) = _uiState.update { it.copy(isInternal = isInternal) }

fun updateActor(actorType: Participant.ActorType?, actorId: String?) {
_uiState.update { it.copy(actorType = actorType, actorId = actorId) }
}

fun destroy() {
signalingMessageReceiver.removeListener(listener)

if (peerConnection != null) {
peerConnection!!.removeObserver(peerConnectionObserver)
peerConnection!!.removeListener(dataChannelMessageListener)
}
if (screenPeerConnection != null) {
screenPeerConnection!!.removeObserver(screenPeerConnectionObserver)
}

peerConnection = null
screenPeerConnection = null
}

companion object {
private val TAG = ParticipantHandler::class.java.simpleName
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/*
* Nextcloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2025 Marcel Hibbe <[email protected]>
* SPDX-License-Identifier: GPL-3.0-or-later
*/

package com.nextcloud.talk.activities

import com.nextcloud.talk.models.json.participants.Participant
import org.webrtc.MediaStream

data class ParticipantUiState(
val sessionKey: String?,
val baseUrl: String,
val roomToken: String,
val nick: String?,
val isConnected: Boolean,
val isAudioEnabled: Boolean,
val isStreamEnabled: Boolean,
val mediaStream: MediaStream? = null,
val isScreenStreamEnabled: Boolean,
val screenMediaStream: MediaStream? = null,
val raisedHand: Boolean,
val actorType: Participant.ActorType? = null,
val actorId: String? = null,
val isInternal: Boolean
)
Loading
Loading