Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
60 changes: 40 additions & 20 deletions app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -556,38 +556,58 @@ class ChatActivity :

private val conversationMessageListener = object : SignalingMessageReceiver.ConversationMessageListener {
override fun onStartTyping(userId: String?, session: String?) {
if (session == null) {
return
}
val userIdOrGuestSession = userId ?: session

if (isTypingStatusEnabled() && conversationUser.userId != userIdOrGuestSession) {
var displayName = webSocketInstance?.getDisplayNameForSession(session)
if (isTypingStatusEnabled() &&
conversationUser.userId != userIdOrGuestSession &&
webSocketInstance?.isSessionInCurrentRoom(session) == true
) {
var displayName = webSocketInstance?.getDisplayNameForSession(session).orEmpty()
if (displayName == "") {
displayName = context.resources?.getString(R.string.nc_guest)!!
}

if (displayName != null && !typingParticipants.contains(userIdOrGuestSession)) {
if (displayName == "") {
displayName = context.resources?.getString(R.string.nc_guest)!!
runOnUiThread {
val typingParticipant = typingParticipants[userIdOrGuestSession]
if (typingParticipant != null) {
typingParticipant.addSession(session)
} else {
typingParticipants[userIdOrGuestSession] =
TypingParticipant(userIdOrGuestSession, displayName, session) { participant ->
typingParticipants.remove(userIdOrGuestSession, participant)
updateTypingIndicator()
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
updateTypingIndicator()
}
}
}
}

runOnUiThread {
val typingParticipant = TypingParticipant(userIdOrGuestSession!!, displayName) {
typingParticipants.remove(userIdOrGuestSession)
updateTypingIndicator()
}
override fun onStopTyping(userId: String?, session: String?) {
if (session == null) {
return
}

typingParticipants[userIdOrGuestSession] = typingParticipant
if (isTypingStatusEnabled() &&
conversationUser.userId != userId &&
webSocketInstance?.isSessionInCurrentRoom(session) == true
) {
runOnUiThread {
if (TypingParticipant.stopTyping(typingParticipants, userId ?: session, session)) {
updateTypingIndicator()
}
} else if (typingParticipants.contains(userIdOrGuestSession)) {
typingParticipants[userIdOrGuestSession]?.restartTimer()
}
}
}

override fun onStopTyping(userId: String?, session: String?) {
val userIdOrGuestSession = userId ?: session

if (isTypingStatusEnabled() && conversationUser.userId != userId) {
typingParticipants[userIdOrGuestSession]?.cancelTimer()
typingParticipants.remove(userIdOrGuestSession)
updateTypingIndicator()
override fun onSessionsLeft(sessions: List<String>) {
runOnUiThread {
if (TypingParticipant.removeSessions(typingParticipants, sessions)) {
updateTypingIndicator()
}
}
}

Expand Down
26 changes: 10 additions & 16 deletions app/src/main/java/com/nextcloud/talk/chat/MessageInputFragment.kt
Original file line number Diff line number Diff line change
Expand Up @@ -979,14 +979,11 @@ class MessageInputFragment : Fragment() {

fun updateOwnTypingStatus(typedText: CharSequence) {
fun sendStartTypingSignalingMessage() {
val concurrentSafeHashMap = chatActivity.webSocketInstance?.getUserMap()
if (concurrentSafeHashMap != null) {
for ((sessionId, _) in concurrentSafeHashMap) {
val ncSignalingMessage = NCSignalingMessageDto()
ncSignalingMessage.to = sessionId
ncSignalingMessage.type = TYPING_STARTED_SIGNALING_MESSAGE_TYPE
chatActivity.signalingMessageSender!!.send(ncSignalingMessage)
}
chatActivity.webSocketInstance?.getRoomSessionIds()?.forEach { sessionId ->
val ncSignalingMessage = NCSignalingMessageDto()
ncSignalingMessage.to = sessionId
ncSignalingMessage.type = TYPING_STARTED_SIGNALING_MESSAGE_TYPE
chatActivity.signalingMessageSender!!.send(ncSignalingMessage)
}
}

Expand Down Expand Up @@ -1026,14 +1023,11 @@ class MessageInputFragment : Fragment() {
typingTimer = null
typedWhileTypingTimerIsRunning = false

val concurrentSafeHashMap = chatActivity.webSocketInstance?.getUserMap()
if (concurrentSafeHashMap != null) {
for ((sessionId, _) in concurrentSafeHashMap) {
val ncSignalingMessage = NCSignalingMessageDto()
ncSignalingMessage.to = sessionId
ncSignalingMessage.type = TYPING_STOPPED_SIGNALING_MESSAGE_TYPE
chatActivity.signalingMessageSender?.send(ncSignalingMessage)
}
chatActivity.webSocketInstance?.getRoomSessionIds()?.forEach { sessionId ->
val ncSignalingMessage = NCSignalingMessageDto()
ncSignalingMessage.to = sessionId
ncSignalingMessage.type = TYPING_STOPPED_SIGNALING_MESSAGE_TYPE
chatActivity.signalingMessageSender?.send(ncSignalingMessage)
}
}
}
Expand Down
64 changes: 51 additions & 13 deletions app/src/main/java/com/nextcloud/talk/chat/TypingParticipant.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,26 @@ package com.nextcloud.talk.chat

import android.os.CountDownTimer

class TypingParticipant(val userId: String, val name: String, val funToCallWhenTimeIsUp: (userId: String) -> Unit) {
var timer: CountDownTimer? = null
class TypingParticipant(
val userId: String,
val name: String,
session: String,
val funToCallWhenTimeIsUp: (participant: TypingParticipant) -> Unit
) {
// one timer per signaling session of the user (e.g. a device), the keys are the sessions which type
private val timers = mutableMapOf<String, CountDownTimer>()

init {
startTimer()
addSession(session)
}

private fun startTimer() {
timer = object : CountDownTimer(
/**
* A session of the user types in the room. Starts the 15 s timer of the session or restarts it when the session
* types already. Each session expires on its own, the participant expires with its last session.
*/
fun addSession(session: String) {
timers.remove(session)?.cancel()
timers[session] = object : CountDownTimer(
TYPING_DURATION_TO_HIDE_TYPING_MESSAGE,
TYPING_DURATION_TO_HIDE_TYPING_MESSAGE
) {
Expand All @@ -25,21 +36,48 @@ class TypingParticipant(val userId: String, val name: String, val funToCallWhenT
}

override fun onFinish() {
funToCallWhenTimeIsUp(userId)
if (timers.remove(session, this) && timers.isEmpty()) {
funToCallWhenTimeIsUp(this@TypingParticipant)
}
}
}.start()
}

fun restartTimer() {
timer?.cancel()
timer?.start()
}

fun cancelTimer() {
timer?.cancel()
/** Forgets the [left] sessions and cancels their timers. Returns whether the participant had any of them. */
private fun forgetSessions(left: Collection<String>): Boolean {
val known = left.filter { timers.containsKey(it) }
known.forEach { timers.remove(it)?.cancel() }
return known.isNotEmpty()
}

companion object {
/**
* A [session] of the participant [key] stopped typing: only this session is forgotten, the participant is
* removed with its last session. Returns whether the participant was removed.
*/
fun stopTyping(participants: MutableMap<String, TypingParticipant>, key: String, session: String): Boolean {
val participant = participants[key]
val removed = participant != null &&
participant.forgetSessions(listOf(session)) &&
participant.timers.isEmpty()
if (removed) {
participants.remove(key)
}
return removed
}

/**
* Forgets the [sessions] which left the room and removes the typing participants which have no session
* left (a user may type with several sessions). Returns whether any participant was removed.
*/
fun removeSessions(participants: MutableMap<String, TypingParticipant>, sessions: Collection<String>): Boolean {
val left = participants.filterValues { participant ->
participant.forgetSessions(sessions) && participant.timers.isEmpty()
}
left.keys.forEach { participants.remove(it) }
return left.isNotEmpty()
}

private const val TYPING_DURATION_TO_HIDE_TYPING_MESSAGE = 15000L
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,11 @@ internal class ConversationMessageNotifier {
listener.onStopTyping(userId, sessionId)
}
}

@Synchronized
fun notifySessionsLeft(sessions: List<String>) {
for (listener in ArrayList(conversationMessageListeners)) {
listener.onSessionsLeft(sessions)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,13 @@ abstract class SignalingMessageReceiver {
interface ConversationMessageListener {
fun onStartTyping(userId: String?, session: String?)
fun onStopTyping(userId: String?, session: String?)

/**
* Sessions which left the room. Their typing indicator is not going to be stopped by them any more.
*/
fun onSessionsLeft(sessions: List<String>) {
// optional
}
fun onChatMessagesReceived(chatMessages: List<ChatMessageDto>)
}

Expand Down Expand Up @@ -306,6 +313,13 @@ abstract class SignalingMessageReceiver {

return
}

if ("room" == eventMap?.get("target") && "leave" == eventMap["type"]) {
val leftSessions = (eventMap["leave"] as? List<*>)?.filterIsInstance<String>()
if (!leftSessions.isNullOrEmpty()) {
conversationMessageNotifier.notifySessionsLeft(leftSessions)
}
}
}

private fun processSwitchToEvent(eventMap: Map<String, Any>?) {
Expand Down
96 changes: 96 additions & 0 deletions app/src/main/java/com/nextcloud/talk/webrtc/RoomSessionRegistry.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
/*
* Nextcloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.nextcloud.talk.webrtc

import com.nextcloud.talk.models.json.participants.ParticipantDto
import com.nextcloud.talk.models.json.participants.ParticipantDto.ActorType
import java.util.concurrent.ConcurrentHashMap

/**
* The signaling sessions of the room the external signaling connection is currently in.
*
* One [WebSocketInstance] lives per account and is shared by all conversations opened with that account, but the
* signaling server only reports the sessions of the room the connection joined. So the set must hold exactly the
* sessions of the current room: sessions of a previous room (or of an expired connection) must not stay in it,
* otherwise typing messages are sent to sessions of other rooms and typing messages of such sessions are accepted
* (the server delivers messages addressed to a session no matter which room that session is in).
*
* Written from the OkHttp thread and from the UI thread ([reset] on leaving a room), read from the UI thread.
* The changes of the room and of the sessions ([onRoomResponse], [reset], [onJoin]) share one lock, so a join cannot
* store a session after a [reset] cleared the registry.
*/
class RoomSessionRegistry {
private val sessions = ConcurrentHashMap<String, ParticipantDto>()

@Volatile
private var roomToken: String? = null

/**
* The server answers every room join/leave with a "room" message. It is sent before the list of the sessions
* of the new room, so clearing here cannot drop sessions of the new room. The same room (re-sent for an
* unchanged join) keeps its sessions. After [reset] the room is unknown, so the answer to a leave (empty room)
* clears as well: it drops what late events of the left room added in between.
*/
@Synchronized
fun onRoomResponse(newRoomToken: String?) {
val token = newRoomToken ?: ""
if (token != roomToken) {
sessions.clear()
roomToken = token
}
}

/**
* The connection leaves the room, got a new signaling session (hello without resume) or lost it
* (no_such_session, no_such_room). Nothing is known about the room any more.
*/
@Synchronized
fun reset() {
sessions.clear()
roomToken = null
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/**
* Processes the "join" list of a room event: maps with "sessionid", optional "userid" and optional "user"
* (with "displayname"). A guest joins without "userid" and stays in the registry.
*
* Ignored while no room is known (after [reset] or the answer to a leave): the server sends the answer to a join
* before the list of the sessions, so a join arriving without a room is a late event of a room which was left.
*/
@Synchronized
fun onJoin(joinEvents: List<Map<String, Any>>?) {
if (roomToken.isNullOrEmpty()) return
Comment thread
ToteMeiSter marked this conversation as resolved.

joinEvents?.forEach { event ->
val sessionId = event["sessionid"] as? String ?: return@forEach
val participant = ParticipantDto()
val userId = event["userid"] as? String
if (userId != null) {
participant.actorType = ActorType.USERS
participant.actorId = userId
} else {
participant.actorType = ActorType.GUESTS
}
// There is no "user" attribute for guest participants.
val userMap = event["user"] as? Map<*, *>
participant.displayName = userMap?.get("displayname") as? String
sessions[sessionId] = participant
}
}

fun onLeave(sessionIds: List<String>?) {
sessionIds?.forEach { sessions.remove(it) }
}

fun contains(sessionId: String?): Boolean = sessionId != null && sessions.containsKey(sessionId)

/** Display name of the session, or "" if the session is unknown or has no name (e.g. a guest). */
fun displayNameFor(sessionId: String?): String = sessionId?.let { sessions[it]?.displayName } ?: ""

/** Snapshot of the sessions of the current room, without the own session. */
fun sessionIdsExcept(ownSessionId: String?): List<String> = sessions.keys.filter { it != ownSessionId }
}
Loading
Loading