From 7f51d45e9a042b9d6d6cd8ab877cba1a0d0234c6 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Thu, 1 Jun 2023 13:21:11 +0200 Subject: [PATCH] Align typing indicator to new concept # Send start/stop typing Send "Typing" every 10 sec when there was a change Send stop typing: - when input is deleted - when there was no input during the 10s timer - when on leaving room # Receive start/stop typing Clear typing for participant after 15s if no start typing-message was received. Use userId instead sessionId to manage typing participants. This ensures participants are not shown multiple times when using multiple devices with the same user (multisession). To get the userId via websocket, SignalingMessageReceiver and WebSocketInstance had to be modified to pass the CallWebSocketMessage in case the signalingMessage.type is related to typing. Not sure if this is the best solution but didn't find any other way. Typing is not handled when the userId is of the own user (this could happen when using multiple devices) In case userId is null (which happens for guests), their sessionId is used as key for the typingParticipants map. # Other Disable setting for typing indicator when no HPB is used + Avoid crash in chat when no HPB is used. Signed-off-by: Marcel Hibbe --- .../com/nextcloud/talk/chat/ChatActivity.kt | 107 ++++++++++++------ .../nextcloud/talk/chat/TypingParticipant.kt | 59 ++++++++++ .../talk/settings/SettingsActivity.kt | 31 ++++- .../signaling/ConversationMessageNotifier.kt | 8 +- .../signaling/SignalingMessageReceiver.java | 33 ++++-- .../talk/webrtc/WebSocketInstance.kt | 18 ++- app/src/main/res/layout/activity_settings.xml | 10 ++ app/src/main/res/values/strings.xml | 2 + 8 files changed, 208 insertions(+), 60 deletions(-) create mode 100644 app/src/main/java/com/nextcloud/talk/chat/TypingParticipant.kt diff --git a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt index 5f7c67410..49ba7cf37 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -159,8 +159,8 @@ import com.nextcloud.talk.remotefilebrowser.activities.RemoteFileBrowserActivity import com.nextcloud.talk.repositories.reactions.ReactionsRepository import com.nextcloud.talk.shareditems.activities.SharedItemsActivity import com.nextcloud.talk.signaling.SignalingMessageReceiver -import com.nextcloud.talk.translate.ui.TranslateActivity import com.nextcloud.talk.signaling.SignalingMessageSender +import com.nextcloud.talk.translate.ui.TranslateActivity import com.nextcloud.talk.ui.bottom.sheet.ProfileBottomSheet import com.nextcloud.talk.ui.dialog.AttachmentDialog import com.nextcloud.talk.ui.dialog.MessageActionsDialog @@ -319,7 +319,8 @@ class ChatActivity : } var typingTimer: CountDownTimer? = null - val typingParticipants = HashMap() + var typedWhileTypingTimerIsRunning: Boolean = false + val typingParticipants = HashMap() private val localParticipantMessageListener = object : SignalingMessageReceiver.LocalParticipantMessageListener { override fun onSwitchTo(token: String?) { @@ -334,23 +335,38 @@ class ChatActivity : } private val conversationMessageListener = object : SignalingMessageReceiver.ConversationMessageListener { - override fun onStartTyping(session: String) { - if (!CapabilitiesUtilNew.isTypingStatusPrivate(conversationUser!!)) { - var name = webSocketInstance?.getDisplayNameForSession(session) + override fun onStartTyping(userId: String?, session: String?) { + val userIdOrGuestSession = userId ?: session - if (name != null && !typingParticipants.contains(session)) { - if (name == "") { - name = context.resources?.getString(R.string.nc_guest)!! + if (isTypingStatusEnabled() && conversationUser?.userId != userIdOrGuestSession) { + var displayName = webSocketInstance?.getDisplayNameForSession(session) + + if (displayName != null && !typingParticipants.contains(userIdOrGuestSession)) { + if (displayName == "") { + displayName = context.resources?.getString(R.string.nc_guest)!! } - typingParticipants[session] = name - updateTypingIndicator() + + runOnUiThread { + val typingParticipant = TypingParticipant(userIdOrGuestSession!!, displayName) { + typingParticipants.remove(userIdOrGuestSession) + updateTypingIndicator() + } + + typingParticipants[userIdOrGuestSession] = typingParticipant + updateTypingIndicator() + } + } else if (typingParticipants.contains(userIdOrGuestSession)) { + typingParticipants[userIdOrGuestSession]?.restartTimer() } } } - override fun onStopTyping(session: String) { - if (!CapabilitiesUtilNew.isTypingStatusPrivate(conversationUser!!)) { - typingParticipants.remove(session) + override fun onStopTyping(userId: String?, session: String?) { + val userIdOrGuestSession = userId ?: session + + if (isTypingStatusEnabled() && conversationUser?.userId != userId) { + typingParticipants[userIdOrGuestSession]?.cancelTimer() + typingParticipants.remove(userIdOrGuestSession) updateTypingIndicator() } } @@ -544,7 +560,7 @@ class ChatActivity : @Suppress("Detekt.TooGenericExceptionCaught") override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) { - sendStartTypingMessage() + updateOwnTypingStatus(s) if (s.length >= lengthFilter) { binding?.messageInputView?.inputEditText?.error = String.format( @@ -922,7 +938,11 @@ class ChatActivity : return DisplayUtils.ellipsize(text, TYPING_INDICATOR_MAX_NAME_LENGTH) } - val participantNames = ArrayList(typingParticipants.values) + val participantNames = ArrayList() + + for (typingParticipant in typingParticipants.values) { + participantNames.add(typingParticipant.name) + } val typingString: SpannableStringBuilder when (typingParticipants.size) { @@ -998,42 +1018,51 @@ class ChatActivity : } } - fun sendStartTypingMessage() { - if (webSocketInstance == null) { - return + fun updateOwnTypingStatus(typedText: CharSequence) { + fun sendStartTypingSignalingMessage() { + for ((sessionId, participant) in webSocketInstance?.getUserMap()!!) { + val ncSignalingMessage = NCSignalingMessage() + ncSignalingMessage.to = sessionId + ncSignalingMessage.type = TYPING_STARTED_SIGNALING_MESSAGE_TYPE + signalingMessageSender!!.send(ncSignalingMessage) + } } - if (!CapabilitiesUtilNew.isTypingStatusPrivate(conversationUser!!)) { - if (typingTimer == null) { - for ((sessionId, participant) in webSocketInstance?.getUserMap()!!) { - val ncSignalingMessage = NCSignalingMessage() - ncSignalingMessage.to = sessionId - ncSignalingMessage.type = TYPING_STARTED_SIGNALING_MESSAGE_TYPE - signalingMessageSender!!.send(ncSignalingMessage) - } + if (isTypingStatusEnabled()) { + if (typedText.isEmpty()) { + sendStopTypingMessage() + } else if (typingTimer == null) { + sendStartTypingSignalingMessage() typingTimer = object : CountDownTimer( - TYPING_DURATION_BEFORE_SENDING_STOP, - TYPING_DURATION_BEFORE_SENDING_STOP + TYPING_DURATION_TO_SEND_NEXT_TYPING_MESSAGE, + TYPING_INTERVAL_TO_SEND_NEXT_TYPING_MESSAGE ) { override fun onTick(millisUntilFinished: Long) { - // unused atm + // unused } override fun onFinish() { - sendStopTypingMessage() + if (typedWhileTypingTimerIsRunning) { + sendStartTypingSignalingMessage() + cancel() + start() + typedWhileTypingTimerIsRunning = false + } else { + sendStopTypingMessage() + } } }.start() } else { - typingTimer?.cancel() - typingTimer?.start() + typedWhileTypingTimerIsRunning = true } } } - fun sendStopTypingMessage() { - if (!CapabilitiesUtilNew.isTypingStatusPrivate(conversationUser!!)) { + private fun sendStopTypingMessage() { + if (isTypingStatusEnabled()) { typingTimer = null + typedWhileTypingTimerIsRunning = false for ((sessionId, participant) in webSocketInstance?.getUserMap()!!) { val ncSignalingMessage = NCSignalingMessage() @@ -1044,6 +1073,11 @@ class ChatActivity : } } + private fun isTypingStatusEnabled(): Boolean { + return webSocketInstance != null && + !CapabilitiesUtilNew.isTypingStatusPrivate(conversationUser!!) + } + private fun getRoomInfo() { logConversationInfos("getRoomInfo") @@ -2347,6 +2381,8 @@ class ChatActivity : Log.d(TAG, "leaveRoom - leaveRoom - got response: $startNanoTime") logConversationInfos("leaveRoom#onNext") + sendStopTypingMessage() + checkingLobbyStatus = false if (getRoomInfoTimerHandler != null) { @@ -3810,7 +3846,8 @@ class ChatActivity : private const val COMMA = ", " private const val TYPING_INDICATOR_ANIMATION_DURATION = 200L private const val TYPING_INDICATOR_MAX_NAME_LENGTH = 14 - private const val TYPING_DURATION_BEFORE_SENDING_STOP = 4000L + private const val TYPING_DURATION_TO_SEND_NEXT_TYPING_MESSAGE = 10000L + private const val TYPING_INTERVAL_TO_SEND_NEXT_TYPING_MESSAGE = 1000L private const val TYPING_STARTED_SIGNALING_MESSAGE_TYPE = "startedTyping" private const val TYPING_STOPPED_SIGNALING_MESSAGE_TYPE = "stoppedTyping" } diff --git a/app/src/main/java/com/nextcloud/talk/chat/TypingParticipant.kt b/app/src/main/java/com/nextcloud/talk/chat/TypingParticipant.kt new file mode 100644 index 000000000..a29a13fdc --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/chat/TypingParticipant.kt @@ -0,0 +1,59 @@ +/* + * Nextcloud Talk application + * + * @author Marcel Hibbe + * Copyright (C) 2021-2022 Marcel Hibbe + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +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 + + init { + startTimer() + } + + private fun startTimer() { + timer = object : CountDownTimer( + TYPING_DURATION_TO_HIDE_TYPING_MESSAGE, + TYPING_DURATION_TO_HIDE_TYPING_MESSAGE + ) { + override fun onTick(millisUntilFinished: Long) { + // unused + } + + override fun onFinish() { + funToCallWhenTimeIsUp(userId) + } + }.start() + } + + fun restartTimer() { + timer?.cancel() + timer?.start() + } + + fun cancelTimer() { + timer?.cancel() + } + + companion object { + private const val TYPING_DURATION_TO_HIDE_TYPING_MESSAGE = 15000L + } +} diff --git a/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt b/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt index 55fd82bbf..23f0ddb40 100644 --- a/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt @@ -629,6 +629,7 @@ class SettingsActivity : BaseActivity() { PorterDuff.Mode.SRC_IN ) } + CapabilitiesUtilNew.isServerAlmostEOL(currentUser!!) -> { binding.serverAgeWarningText.setTextColor( ContextCompat.getColor((context), R.color.nc_darkYellow) @@ -639,6 +640,7 @@ class SettingsActivity : BaseActivity() { PorterDuff.Mode.SRC_IN ) } + else -> { binding.serverAgeWarningTextCard.visibility = View.GONE } @@ -664,17 +666,31 @@ class SettingsActivity : BaseActivity() { binding.settingsReadPrivacy.visibility = View.GONE } - if (CapabilitiesUtilNew.isTypingStatusAvailable(currentUser!!)) { - (binding.settingsTypingStatus.findViewById(R.id.mp_checkable) as Checkable).isChecked = - !CapabilitiesUtilNew.isTypingStatusPrivate(currentUser!!) - } else { - binding.settingsTypingStatus.visibility = View.GONE - } + setupTypingStatusSetting() (binding.settingsPhoneBookIntegration.findViewById(R.id.mp_checkable) as Checkable).isChecked = appPreferences.isPhoneBookIntegrationEnabled } + private fun setupTypingStatusSetting() { + if (currentUser!!.externalSignalingServer?.externalSignalingServer?.isNotEmpty() == true) { + binding.settingsTypingStatusOnlyWithHpb.visibility = View.GONE + + if (CapabilitiesUtilNew.isTypingStatusAvailable(currentUser!!)) { + (binding.settingsTypingStatus.findViewById(R.id.mp_checkable) as Checkable).isChecked = + !CapabilitiesUtilNew.isTypingStatusPrivate(currentUser!!) + } else { + binding.settingsTypingStatus.visibility = View.GONE + } + } else { + (binding.settingsTypingStatus.findViewById(R.id.mp_checkable) as Checkable).isChecked = false + binding.settingsTypingStatusOnlyWithHpb.visibility = View.VISIBLE + binding.settingsTypingStatus.isEnabled = false + binding.settingsTypingStatusOnlyWithHpb.alpha = DISABLED_ALPHA + binding.settingsTypingStatus.alpha = DISABLED_ALPHA + } + } + private fun setupScreenLockSetting() { val keyguardManager = context.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager if (keyguardManager.isKeyguardSecure) { @@ -846,10 +862,13 @@ class SettingsActivity : BaseActivity() { when (newValue) { "HTTP" -> binding.settingsProxyPortEdit.value = "3128" + "DIRECT" -> binding.settingsProxyPortEdit.value = "8080" + "SOCKS" -> binding.settingsProxyPortEdit.value = "1080" + else -> { } } diff --git a/app/src/main/java/com/nextcloud/talk/signaling/ConversationMessageNotifier.kt b/app/src/main/java/com/nextcloud/talk/signaling/ConversationMessageNotifier.kt index 6b8fac543..e1207f52e 100644 --- a/app/src/main/java/com/nextcloud/talk/signaling/ConversationMessageNotifier.kt +++ b/app/src/main/java/com/nextcloud/talk/signaling/ConversationMessageNotifier.kt @@ -36,15 +36,15 @@ internal class ConversationMessageNotifier { } @Synchronized - fun notifyStartTyping(sessionId: String?) { + fun notifyStartTyping(userId: String?, sessionId: String?) { for (listener in ArrayList(conversationMessageListeners)) { - listener.onStartTyping(sessionId) + listener.onStartTyping(userId, sessionId) } } - fun notifyStopTyping(sessionId: String?) { + fun notifyStopTyping(userId: String?, sessionId: String?) { for (listener in ArrayList(conversationMessageListeners)) { - listener.onStopTyping(sessionId) + listener.onStopTyping(userId, sessionId) } } } diff --git a/app/src/main/java/com/nextcloud/talk/signaling/SignalingMessageReceiver.java b/app/src/main/java/com/nextcloud/talk/signaling/SignalingMessageReceiver.java index 1455b1ec5..5a2ffe4bc 100644 --- a/app/src/main/java/com/nextcloud/talk/signaling/SignalingMessageReceiver.java +++ b/app/src/main/java/com/nextcloud/talk/signaling/SignalingMessageReceiver.java @@ -24,6 +24,7 @@ import com.nextcloud.talk.models.json.participants.Participant; import com.nextcloud.talk.models.json.signaling.NCIceCandidate; import com.nextcloud.talk.models.json.signaling.NCMessagePayload; import com.nextcloud.talk.models.json.signaling.NCSignalingMessage; +import com.nextcloud.talk.models.json.websocket.CallWebSocketMessage; import java.util.ArrayList; import java.util.List; @@ -169,8 +170,8 @@ public abstract class SignalingMessageReceiver { * Listener for conversation messages. */ public interface ConversationMessageListener { - void onStartTyping(String session); - void onStopTyping(String session); + void onStartTyping(String userId, String session); + void onStopTyping(String userId,String session); } /** @@ -515,6 +516,26 @@ public abstract class SignalingMessageReceiver { return participant; } + protected void processCallWebSocketMessage(CallWebSocketMessage callWebSocketMessage) { + + NCSignalingMessage signalingMessage = callWebSocketMessage.getNcSignalingMessage(); + + if (callWebSocketMessage.getSenderWebSocketMessage() != null && signalingMessage != null) { + String type = signalingMessage.getType(); + + String userId = callWebSocketMessage.getSenderWebSocketMessage().getUserid(); + String sessionId = signalingMessage.getFrom(); + + if ("startedTyping".equals(type)) { + conversationMessageNotifier.notifyStartTyping(userId, sessionId); + } + + if ("stoppedTyping".equals(type)) { + conversationMessageNotifier.notifyStopTyping(userId, sessionId); + } + } + } + protected void processSignalingMessage(NCSignalingMessage signalingMessage) { // Note that in the internal signaling server message "data" is the String representation of a JSON // object, although it is already decoded when used here. @@ -581,14 +602,6 @@ public abstract class SignalingMessageReceiver { return; } - if ("startedTyping".equals(type)) { - conversationMessageNotifier.notifyStartTyping(sessionId); - } - - if ("stoppedTyping".equals(type)) { - conversationMessageNotifier.notifyStopTyping(sessionId); - } - if ("reaction".equals(type)) { // Message schema (external signaling server): // { diff --git a/app/src/main/java/com/nextcloud/talk/webrtc/WebSocketInstance.kt b/app/src/main/java/com/nextcloud/talk/webrtc/WebSocketInstance.kt index b057a638a..b50859f7c 100644 --- a/app/src/main/java/com/nextcloud/talk/webrtc/WebSocketInstance.kt +++ b/app/src/main/java/com/nextcloud/talk/webrtc/WebSocketInstance.kt @@ -35,6 +35,7 @@ import com.nextcloud.talk.models.json.signaling.NCSignalingMessage import com.nextcloud.talk.models.json.websocket.BaseWebSocketMessage import com.nextcloud.talk.models.json.websocket.ByeWebSocketMessage import com.nextcloud.talk.models.json.websocket.CallOverallWebSocketMessage +import com.nextcloud.talk.models.json.websocket.CallWebSocketMessage import com.nextcloud.talk.models.json.websocket.ErrorOverallWebSocketMessage import com.nextcloud.talk.models.json.websocket.EventOverallWebSocketMessage import com.nextcloud.talk.models.json.websocket.HelloResponseOverallWebSocketMessage @@ -182,15 +183,16 @@ class WebSocketInstance internal constructor( private fun processMessage(text: String) { val (_, callWebSocketMessage) = LoganSquare.parse(text, CallOverallWebSocketMessage::class.java) if (callWebSocketMessage != null) { - val ncSignalingMessage = callWebSocketMessage - .ncSignalingMessage + val ncSignalingMessage = callWebSocketMessage.ncSignalingMessage + if (ncSignalingMessage != null && TextUtils.isEmpty(ncSignalingMessage.from) && callWebSocketMessage.senderWebSocketMessage != null ) { ncSignalingMessage.from = callWebSocketMessage.senderWebSocketMessage!!.sessionId } - signalingMessageReceiver.process(ncSignalingMessage) + + signalingMessageReceiver.process(callWebSocketMessage) } } @@ -453,8 +455,14 @@ class WebSocketInstance internal constructor( processEvent(eventMap) } - fun process(message: NCSignalingMessage?) { - processSignalingMessage(message) + fun process(message: CallWebSocketMessage?) { + if (message?.ncSignalingMessage?.type == "startedTyping" || + message?.ncSignalingMessage?.type == "stoppedTyping" + ) { + processCallWebSocketMessage(message) + } else { + processSignalingMessage(message?.ncSignalingMessage) + } } } diff --git a/app/src/main/res/layout/activity_settings.xml b/app/src/main/res/layout/activity_settings.xml index 7c5c4890a..953a97166 100644 --- a/app/src/main/res/layout/activity_settings.xml +++ b/app/src/main/res/layout/activity_settings.xml @@ -272,6 +272,16 @@ apc:mp_key="@string/nc_settings_read_privacy_key" apc:mp_summary="@string/nc_settings_typing_status_desc" apc:mp_title="@string/nc_settings_typing_status_title" /> + + + Read status Share my typing-status and show the typing-status of others Typing status + Typing status is only available when using a high + performance backend (HPB) 30 seconds 1 minute