From 4cce2b32fc2ff756b202192856fe95a0c5552e9c Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Thu, 11 Jun 2026 12:19:26 +0200 Subject: [PATCH 1/3] Move the WebSocket "leave room" signal to the start of leaveRoom() Move the WebSocket "leave room" signal to the start of leaveRoom() so the HPB stops considering the user "in" the room before the backend DELETE is even sent. This closes the window in which the HPB could trigger the server to delete a freshly-created notification. AI-assistant: Claude Code v2.1.142 (Claude Sonnet 4.6) Signed-off-by: Marcel Hibbe --- .../java/com/nextcloud/talk/chat/ChatActivity.kt | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) 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 4cf4bf3452..f2b7b8507e 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -1480,13 +1480,6 @@ class ChatActivity : getRoomInfoTimerHandler?.removeCallbacksAndMessages(null) } - if (webSocketInstance != null && currentConversation != null) { - webSocketInstance?.joinRoomWithRoomTokenAndSession( - "", - sessionIdAfterRoomJoined - ) - } - sessionIdAfterRoomJoined = "0" if (state.funToCallWhenLeaveSuccessful != null) { @@ -2845,6 +2838,13 @@ class ChatActivity : fun leaveRoom(funToCallWhenLeaveSuccessful: (() -> Unit)?) { logConversationInfos("leaveRoom") + // Send the HPB "leave room" immediately, before waiting for the backend DELETE to + // confirm. This minimises the window in which the HPB could still consider the user + // "in" the room and cause the server to delete a freshly-created notification. + if (webSocketInstance != null && currentConversation != null) { + webSocketInstance?.joinRoomWithRoomTokenAndSession("", sessionIdAfterRoomJoined) + } + var apiVersion = 1 // FIXME Fix API checking with guests? if (conversationUser != null) { From 5517a8afbb5b33f5fd0befbd3bfbde66746c4cb0 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Thu, 11 Jun 2026 13:01:08 +0200 Subject: [PATCH 2/3] move leaveRoom request to application CoroutineScope Replace the RxJava leaveRoom HTTP call with a coroutine launched on an application-scoped CoroutineScope, so the request survives onPause and ViewModel teardown and completes even after back-navigation. AI-assistant: Claude Code v2.1.142 (Claude Sonnet 4.6) Signed-off-by: Marcel Hibbe --- .../java/com/nextcloud/talk/api/NcApi.java | 3 -- .../com/nextcloud/talk/api/NcApiCoroutines.kt | 3 ++ .../data/network/ChatNetworkDataSource.kt | 2 +- .../chat/data/network/RetrofitChatNetwork.kt | 6 ++-- .../talk/chat/viewmodels/ChatViewModel.kt | 30 ++++++++----------- .../talk/dagger/modules/ApplicationScope.kt | 13 ++++++++ .../talk/dagger/modules/UtilsModule.kt | 9 ++++++ .../talk/utils/preview/ComposePreviewUtils.kt | 4 +++ 8 files changed, 44 insertions(+), 26 deletions(-) create mode 100644 app/src/main/java/com/nextcloud/talk/dagger/modules/ApplicationScope.kt diff --git a/app/src/main/java/com/nextcloud/talk/api/NcApi.java b/app/src/main/java/com/nextcloud/talk/api/NcApi.java index 8834665309..82b44342be 100644 --- a/app/src/main/java/com/nextcloud/talk/api/NcApi.java +++ b/app/src/main/java/com/nextcloud/talk/api/NcApi.java @@ -195,9 +195,6 @@ Observable joinRoom(@Nullable @Header("Authorization") String autho @Url String url, @Nullable @Field("password") String password); - @DELETE - Observable leaveRoom(@Nullable @Header("Authorization") String authorization, @Url String url); - /* Server URL is: baseUrl + ocsApiVersion + spreedApiVersion + /call/callToken */ diff --git a/app/src/main/java/com/nextcloud/talk/api/NcApiCoroutines.kt b/app/src/main/java/com/nextcloud/talk/api/NcApiCoroutines.kt index ee328c2664..56ab616d7d 100644 --- a/app/src/main/java/com/nextcloud/talk/api/NcApiCoroutines.kt +++ b/app/src/main/java/com/nextcloud/talk/api/NcApiCoroutines.kt @@ -312,6 +312,9 @@ interface NcApiCoroutines { @DELETE suspend fun unbindRoom(@Header("Authorization") authorization: String, @Url url: String): GenericOverall + @DELETE + suspend fun leaveRoom(@Header("Authorization") authorization: String, @Url url: String): GenericOverall + @GET suspend fun getThreads( @Header("Authorization") authorization: String, diff --git a/app/src/main/java/com/nextcloud/talk/chat/data/network/ChatNetworkDataSource.kt b/app/src/main/java/com/nextcloud/talk/chat/data/network/ChatNetworkDataSource.kt index 72c8c7cc30..2c380f336d 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/data/network/ChatNetworkDataSource.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/data/network/ChatNetworkDataSource.kt @@ -53,7 +53,7 @@ interface ChatNetworkDataSource { metadata: String ): Observable - fun leaveRoom(credentials: String, url: String): Observable + suspend fun leaveRoom(credentials: String, url: String): GenericOverall suspend fun sendChatMessage( credentials: String, url: String, diff --git a/app/src/main/java/com/nextcloud/talk/chat/data/network/RetrofitChatNetwork.kt b/app/src/main/java/com/nextcloud/talk/chat/data/network/RetrofitChatNetwork.kt index b36e8c554e..8a628c9f9f 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/data/network/RetrofitChatNetwork.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/data/network/RetrofitChatNetwork.kt @@ -134,10 +134,8 @@ class RetrofitChatNetwork(private val ncApi: NcApi, private val ncApiCoroutines: metadata: String ): Observable = ncApi.sendLocation(credentials, url, objectType, objectId, metadata).map { it } - override fun leaveRoom(credentials: String, url: String): Observable = - ncApi.leaveRoom(credentials, url).map { - it - } + override suspend fun leaveRoom(credentials: String, url: String): GenericOverall = + ncApiCoroutines.leaveRoom(credentials, url) override suspend fun sendChatMessage( credentials: String, diff --git a/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt b/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt index 0827e467e0..aeb01eb257 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt @@ -79,7 +79,9 @@ import io.reactivex.Observer import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.disposables.Disposable import io.reactivex.schedulers.Schedulers +import com.nextcloud.talk.dagger.modules.ApplicationScope import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job @@ -133,6 +135,7 @@ class ChatViewModel @AssistedInject constructor( private val mediaRecorderManager: MediaRecorderManager, private val audioFocusRequestManager: AudioFocusRequestManager, private val currentUserProvider: CurrentUserProvider, + @ApplicationScope private val appScope: CoroutineScope, @Assisted private val chatRoomToken: String, @Assisted private val conversationThreadId: Long? ) : ViewModel(), @@ -1539,27 +1542,18 @@ class ChatViewModel @AssistedInject constructor( fun leaveRoom(credentials: String, url: String, funToCallWhenLeaveSuccessful: (() -> Unit)?) { val startNanoTime = System.nanoTime() - chatNetworkDataSource.leaveRoom(credentials, url) - .subscribeOn(Schedulers.io()) - ?.observeOn(AndroidSchedulers.mainThread()) - ?.subscribe(object : Observer { - override fun onSubscribe(d: Disposable) { - disposableSet.add(d) - } - - override fun onError(e: Throwable) { - Log.e(TAG, "leaveRoom - leaveRoom - ERROR", e) - } - - override fun onComplete() { - Log.d(TAG, "leaveRoom - leaveRoom - completed: $startNanoTime") - } - - override fun onNext(t: GenericOverall) { + appScope.launch { + try { + chatNetworkDataSource.leaveRoom(credentials, url) + Log.d(TAG, "leaveRoom - completed: $startNanoTime") + withContext(Dispatchers.Main) { _leaveRoomViewState.value = LeaveRoomSuccessState(funToCallWhenLeaveSuccessful) _getCapabilitiesViewState.value = GetCapabilitiesStartState } - }) + } catch (e: Exception) { + Log.e(TAG, "leaveRoom - ERROR", e) + } + } } fun createRoom(credentials: String, url: String, queryMap: Map) { diff --git a/app/src/main/java/com/nextcloud/talk/dagger/modules/ApplicationScope.kt b/app/src/main/java/com/nextcloud/talk/dagger/modules/ApplicationScope.kt new file mode 100644 index 0000000000..67b6c6d004 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/dagger/modules/ApplicationScope.kt @@ -0,0 +1,13 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2025 Marcel Hibbe + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.dagger.modules + +import javax.inject.Qualifier + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class ApplicationScope diff --git a/app/src/main/java/com/nextcloud/talk/dagger/modules/UtilsModule.kt b/app/src/main/java/com/nextcloud/talk/dagger/modules/UtilsModule.kt index 91861a35b1..e00426f36a 100644 --- a/app/src/main/java/com/nextcloud/talk/dagger/modules/UtilsModule.kt +++ b/app/src/main/java/com/nextcloud/talk/dagger/modules/UtilsModule.kt @@ -15,6 +15,10 @@ import com.nextcloud.talk.utils.permissions.PlatformPermissionUtilImpl import dagger.Module import dagger.Provides import dagger.Reusable +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import javax.inject.Singleton @Module(includes = [ContextModule::class]) class UtilsModule { @@ -29,4 +33,9 @@ class UtilsModule { @Provides @Reusable fun provideMessageUtils(context: Context): MessageUtils = MessageUtils(context) + + @Provides + @Singleton + @ApplicationScope + fun provideApplicationScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) } diff --git a/app/src/main/java/com/nextcloud/talk/utils/preview/ComposePreviewUtils.kt b/app/src/main/java/com/nextcloud/talk/utils/preview/ComposePreviewUtils.kt index f84ad10c5e..8a9fd2abf2 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/preview/ComposePreviewUtils.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/preview/ComposePreviewUtils.kt @@ -58,7 +58,10 @@ import com.nextcloud.talk.utils.message.MessageUtils import com.nextcloud.talk.utils.preferences.AppPreferences import com.nextcloud.talk.utils.preferences.AppPreferencesImpl import io.reactivex.schedulers.Schedulers +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.SupervisorJob import okhttp3.OkHttpClient import retrofit2.Retrofit import retrofit2.adapter.rxjava2.RxJava2CallAdapterFactory @@ -194,6 +197,7 @@ class ComposePreviewUtils private constructor(context: Context) { mediaRecorderManager = mediaRecorderManager, audioFocusRequestManager = audioFocusRequestManager, currentUserProvider = currentUserProvider, + appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO), chatRoomToken = "", conversationThreadId = null ) From 46073db6fab817719970e14a20c960050f5558e0 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Thu, 11 Jun 2026 14:03:23 +0200 Subject: [PATCH 3/3] Fix leaveRoom callback and state handling Move sessionIdAfterRoomJoined reset to right after the WebSocket send in ChatActivity.leaveRoom() - the session ID is already consumed at that point so there's no reason to wait for the HTTP response. Remove the leaveRoomViewState observer from ChatActivity since it's no longer responsible for anything useful. The callback now fires directly from the coroutine via withContext(Main), which avoids the LiveData lifecycle gate that could silently drop it when the Activity is already stopping. AI-assistant: Claude Code v2.1.142 (Claude Sonnet 4.6) Signed-off-by: Marcel Hibbe --- .../com/nextcloud/talk/chat/ChatActivity.kt | 26 +++---------------- .../talk/chat/viewmodels/ChatViewModel.kt | 16 +++++++----- 2 files changed, 13 insertions(+), 29 deletions(-) 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 f2b7b8507e..6e7f980e12 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -1471,27 +1471,6 @@ class ChatActivity : } } - chatViewModel.leaveRoomViewState.observe(this) { state -> - when (state) { - is ChatViewModel.LeaveRoomSuccessState -> { - logConversationInfos("leaveRoom#onNext") - - if (getRoomInfoTimerHandler != null) { - getRoomInfoTimerHandler?.removeCallbacksAndMessages(null) - } - - sessionIdAfterRoomJoined = "0" - - if (state.funToCallWhenLeaveSuccessful != null) { - Log.d(TAG, "a callback action was set and is now executed because room was left successfully") - state.funToCallWhenLeaveSuccessful.invoke() - } - } - - else -> {} - } - } - messageInputViewModel.sendChatMessageViewState.observe(this) { state -> when (state) { is MessageInputViewModel.SendChatMessageSuccessState -> { @@ -2835,7 +2814,7 @@ class ChatActivity : } } - fun leaveRoom(funToCallWhenLeaveSuccessful: (() -> Unit)?) { + fun leaveRoom(functionToCallAfterLeave: (() -> Unit)?) { logConversationInfos("leaveRoom") // Send the HPB "leave room" immediately, before waiting for the backend DELETE to @@ -2844,6 +2823,7 @@ class ChatActivity : if (webSocketInstance != null && currentConversation != null) { webSocketInstance?.joinRoomWithRoomTokenAndSession("", sessionIdAfterRoomJoined) } + sessionIdAfterRoomJoined = "0" var apiVersion = 1 // FIXME Fix API checking with guests? @@ -2860,7 +2840,7 @@ class ChatActivity : conversationUser?.baseUrl!!, roomToken ), - funToCallWhenLeaveSuccessful + functionToCallAfterLeave ) } diff --git a/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt b/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt index aeb01eb257..bf1aac90ae 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt @@ -376,7 +376,7 @@ class ChatViewModel @AssistedInject constructor( get() = _joinRoomViewState object LeaveRoomStartState : ViewState - class LeaveRoomSuccessState(val funToCallWhenLeaveSuccessful: (() -> Unit)?) : ViewState + object LeaveRoomSuccessState : ViewState private val _leaveRoomViewState: MutableLiveData = MutableLiveData(LeaveRoomStartState) val leaveRoomViewState: LiveData @@ -1540,15 +1540,19 @@ class ChatViewModel @AssistedInject constructor( }) } - fun leaveRoom(credentials: String, url: String, funToCallWhenLeaveSuccessful: (() -> Unit)?) { - val startNanoTime = System.nanoTime() + fun leaveRoom(credentials: String, url: String, functionToCallAfterLeave: (() -> Unit)?) { appScope.launch { try { - chatNetworkDataSource.leaveRoom(credentials, url) - Log.d(TAG, "leaveRoom - completed: $startNanoTime") + val result = chatNetworkDataSource.leaveRoom(credentials, url) + if (result.ocs?.meta?.statusCode == HTTP_CODE_OK) { + Log.d(TAG, "leaveRoom completed") + } else { + Log.e(TAG, "leaveRoom failed with OCS status: ${result.ocs?.meta?.statusCode}") + } withContext(Dispatchers.Main) { - _leaveRoomViewState.value = LeaveRoomSuccessState(funToCallWhenLeaveSuccessful) + _leaveRoomViewState.value = LeaveRoomSuccessState _getCapabilitiesViewState.value = GetCapabilitiesStartState + functionToCallAfterLeave?.invoke() } } catch (e: Exception) { Log.e(TAG, "leaveRoom - ERROR", e)