Skip to content
Merged
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
2015cc9
remove ParticipantDisplayItemNotifier
mahibi Sep 24, 2025
263aca0
Rename .java to .kt
mahibi Sep 24, 2025
c521018
WIP. replace observer patterns with flows
mahibi Sep 24, 2025
2b0f371
simplify architecture for call participant handling
mahibi Oct 16, 2025
12c349b
add share screen icon and logic
mahibi Oct 17, 2025
c62b635
remove animations for call controls + remove fullscreen grid
mahibi Oct 17, 2025
01c5729
remove margin
mahibi Oct 17, 2025
d6824ff
fix self video zorder
mahibi Oct 17, 2025
c834c8d
change top bar
mahibi Oct 17, 2025
8dc3855
open/close screenshare of participants by click on monitor/close button
mahibi Oct 27, 2025
3142d0b
make screen share fullscreen
mahibi Oct 29, 2025
9cf4546
hide self video in screenshare view mode
mahibi Oct 29, 2025
eb44576
do not cut off stream in fullscreen
mahibi Oct 29, 2025
fcd2708
implement zooming and panning
mahibi Oct 29, 2025
8f93ee9
automatically show/unshow screenshare
mahibi Oct 29, 2025
ed18d2b
fade in/out screenshare controls by single click
mahibi Oct 29, 2025
770b965
refactor structure
mahibi Oct 29, 2025
e2a9a1c
rearrange top bar
mahibi Oct 30, 2025
367e49f
solve codacy warnings
mahibi Oct 30, 2025
fdf30bf
migrate self video to compose
mahibi Oct 30, 2025
7f767d1
remove drag and drop for selfvideo for now
mahibi Oct 30, 2025
247d0b7
fix screenshare for peer connection
mahibi Oct 30, 2025
8b09e7d
solve warnings
mahibi Oct 31, 2025
5bf86ff
fix test LocalStateBroadcasterTest
mahibi Oct 31, 2025
d985417
fix participant avatar url
mahibi Oct 31, 2025
531e283
remove CallParticipantModelTest
mahibi Oct 31, 2025
039e460
add Tests for CallViewModel
mahibi Oct 31, 2025
87b2ea0
fix LocalStateBroadcasterMcuTest (1 failing test for now)
mahibi Nov 2, 2025
2e73f26
fix to not show loading spinner after screen share ended
mahibi Nov 4, 2025
05a615b
use ConcurrentHashMap to avoid ConcurrentModificationException
mahibi Nov 4, 2025
fabc038
add ParticipantHandlerTest.kt
mahibi Nov 5, 2025
c44bc2a
temporarily comment out some tests
mahibi Nov 5, 2025
bb74ebe
fix avatars (also for guests with names)
mahibi Nov 6, 2025
7438333
fix federated avatar darkMode + make avatar handling even more reactive
mahibi Nov 6, 2025
ae85e03
remove usage of userId for calls (deprecated)
mahibi Nov 6, 2025
205099a
move hand to bottom left
mahibi Nov 6, 2025
04d3ac6
enable to receive screenshare in audiocalls
mahibi Nov 6, 2025
23baa46
simplify ParticipantTile
mahibi Nov 6, 2025
8eed7b3
request big size for avatars in call
mahibi Nov 6, 2025
66eb102
set OfferToReceiveVideo only for the peer that shares the screen (ove…
mahibi Nov 7, 2025
f0b2e87
remove unnecessary checks for doesParticipantExist
mahibi Nov 7, 2025
de190d4
adapt tests (1 still failing)
mahibi Nov 7, 2025
f4237af
support high resolution screenshares by using software encoder/decoder
mahibi Nov 7, 2025
5386963
adapt tests
mahibi Nov 7, 2025
1a8c6b1
format code
mahibi Nov 10, 2025
9597da0
adapt tests
mahibi Nov 10, 2025
6b530a4
add logging
mahibi Nov 12, 2025
d76a76c
resolve warnings
mahibi Nov 13, 2025
ca0bb57
fix loading spinner for participants without peerConnection
mahibi Nov 17, 2025
8d99a37
fallback to "guest" if participant name not known
mahibi Nov 18, 2025
3f66f1e
set raise hand to upper right corner again
mahibi Nov 18, 2025
ba94ed1
better fallback to "guest" if participant name not known
mahibi Nov 18, 2025
ee79d67
ignore test and added comment
mahibi Nov 27, 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 <dev@mhibbe.de>
* 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 <dev@mhibbe.de>
* 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 <dev@mhibbe.de>
* 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