From 2a85b591a50c6bed9dc53f212383db194d03aae7 Mon Sep 17 00:00:00 2001 From: Krainov Gleb Date: Fri, 9 Oct 2026 12:08:02 +0000 Subject: [PATCH 1/2] fix(chat): skip insurance requests while the chat is paused With a high performance backend the insurance loop sent a chat GET every 2 minutes for as long as ChatActivity stayed in the back stack, in the background and overnight. Long polling already honours itIsPaused; the insurance loop did not. Skip the tick while paused. Returning to the chat is covered by ChatViewModel.onResume, which fetches immediately. Signed-off-by: Krainov Gleb Assisted-by: Claude-Code:claude-opus-5-5 --- .../network/OfflineFirstChatRepository.kt | 5 +++ .../network/OfflineFirstChatRepositoryTest.kt | 41 ++++++++++++++++++- 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/nextcloud/talk/chat/data/network/OfflineFirstChatRepository.kt b/app/src/main/java/com/nextcloud/talk/chat/data/network/OfflineFirstChatRepository.kt index b72ff8968a1..9569d65d49e 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/data/network/OfflineFirstChatRepository.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/data/network/OfflineFirstChatRepository.kt @@ -367,6 +367,11 @@ class OfflineFirstChatRepository @Inject constructor( while (true) { delay(INSURANCE_REQUEST_DELAY) + if (itIsPaused) { + // ChatViewModel.onResume fetches right away when the chat comes back to the foreground + Log.d(TAG, "skip insurance request while paused") + continue + } Log.d(TAG, "execute insurance request") fetchNewMessages() diff --git a/app/src/test/java/com/nextcloud/talk/chat/data/network/OfflineFirstChatRepositoryTest.kt b/app/src/test/java/com/nextcloud/talk/chat/data/network/OfflineFirstChatRepositoryTest.kt index 1496841e6a3..aeac296b607 100644 --- a/app/src/test/java/com/nextcloud/talk/chat/data/network/OfflineFirstChatRepositoryTest.kt +++ b/app/src/test/java/com/nextcloud/talk/chat/data/network/OfflineFirstChatRepositoryTest.kt @@ -43,6 +43,8 @@ import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import kotlinx.coroutines.withTimeout import okhttp3.MediaType.Companion.toMediaType @@ -76,7 +78,7 @@ import java.io.IOException * last read message, otherwise the initial window is re-fetched — anchored at the boundary when * the unread backlog calls for it. */ -@Suppress("TooManyFunctions") +@Suppress("TooManyFunctions", "LargeClass") class OfflineFirstChatRepositoryTest { private val logger: Logger = mock() @@ -245,6 +247,42 @@ class OfflineFirstChatRepositoryTest { assertFalse(fieldMap.containsKey("lastKnownMessageId")) } + @Test + fun `insurance requests are skipped while the chat is paused`() = + runTest { + repository.handleOnPause() + val polling = launch { repository.initInsuranceRequests() } + + advanceTimeBy(INSURANCE_DELAY_MS * 5 + 1) + runCurrent() + + // fetchNewMessages always starts by purging expired messages, so this counts its calls + verifyBlocking(chatDao, never()) { deleteExpiredMessages(eq(INTERNAL_CONVERSATION_ID), any()) } + polling.cancelAndJoin() + } + + @Test + fun `insurance requests resume with the next tick after the chat is resumed`() = + runTest { + repository.handleOnPause() + val polling = launch { repository.initInsuranceRequests() } + advanceTimeBy(INSURANCE_DELAY_MS * 3 + 1) + runCurrent() + verifyBlocking(chatDao, never()) { deleteExpiredMessages(eq(INTERNAL_CONVERSATION_ID), any()) } + + repository.handleOnResume() + // the loop keeps its normal step: nothing fires before the next tick ... + advanceTimeBy(INSURANCE_DELAY_MS / 2) + runCurrent() + verifyBlocking(chatDao, never()) { deleteExpiredMessages(eq(INTERNAL_CONVERSATION_ID), any()) } + + // ... and the request is sent on it + advanceTimeBy(INSURANCE_DELAY_MS / 2) + runCurrent() + verifyBlocking(chatDao, times(1)) { deleteExpiredMessages(eq(INTERNAL_CONVERSATION_ID), any()) } + polling.cancelAndJoin() + } + @Test fun `fetchNewMessages skips the request when no HTTP-synced anchor exists yet`() = runTest { @@ -862,6 +900,7 @@ class OfflineFirstChatRepositoryTest { private const val LONG_POLLING_OBSERVATION_MILLIS = 200L private const val AWAIT_TIMEOUT_MILLIS = 2000L private const val CHAT_URL = "https://server.example.com/ocs/v2.php/apps/spreed/api/v1/chat/$ROOM_TOKEN" + private const val INSURANCE_DELAY_MS = 2 * 60 * 1000L private const val MESSAGE_ID = 42L private const val SYSTEM_MESSAGE_ID = 43L private const val MESSAGE_URL = "$CHAT_URL/42" From bf3af05fab67f08fb1d6a94f33187c97ab626d35 Mon Sep 17 00:00:00 2001 From: Krainov Gleb Date: Fri, 9 Oct 2026 12:40:20 +0000 Subject: [PATCH 2/2] test(chat): verify the insurance poll sends no chat request while paused The pause tests counted deleteExpiredMessages calls, which only show that fetchNewMessages ran: without an HTTP-synced anchor it returns before any network request. Record an anchor through the initial load and verify pullChatMessages instead: no call while paused, one call on the first tick after the chat is resumed. Signed-off-by: Krainov Gleb Assisted-by: Claude-Code:claude-opus-5-5 --- .../network/OfflineFirstChatRepositoryTest.kt | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/app/src/test/java/com/nextcloud/talk/chat/data/network/OfflineFirstChatRepositoryTest.kt b/app/src/test/java/com/nextcloud/talk/chat/data/network/OfflineFirstChatRepositoryTest.kt index aeac296b607..e9078eb57e5 100644 --- a/app/src/test/java/com/nextcloud/talk/chat/data/network/OfflineFirstChatRepositoryTest.kt +++ b/app/src/test/java/com/nextcloud/talk/chat/data/network/OfflineFirstChatRepositoryTest.kt @@ -58,6 +58,7 @@ import org.junit.Test import org.mockito.kotlin.any import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.atLeastOnce +import org.mockito.kotlin.clearInvocations import org.mockito.kotlin.eq import org.mockito.kotlin.doSuspendableAnswer import org.mockito.kotlin.mock @@ -250,36 +251,39 @@ class OfflineFirstChatRepositoryTest { @Test fun `insurance requests are skipped while the chat is paused`() = runTest { + givenHttpSyncedAnchor() + clearInvocations(network) repository.handleOnPause() val polling = launch { repository.initInsuranceRequests() } advanceTimeBy(INSURANCE_DELAY_MS * 5 + 1) runCurrent() - // fetchNewMessages always starts by purging expired messages, so this counts its calls - verifyBlocking(chatDao, never()) { deleteExpiredMessages(eq(INTERNAL_CONVERSATION_ID), any()) } + verifyBlocking(network, never()) { pullChatMessages(any(), any(), any()) } polling.cancelAndJoin() } @Test fun `insurance requests resume with the next tick after the chat is resumed`() = runTest { + givenHttpSyncedAnchor() + clearInvocations(network) repository.handleOnPause() val polling = launch { repository.initInsuranceRequests() } advanceTimeBy(INSURANCE_DELAY_MS * 3 + 1) runCurrent() - verifyBlocking(chatDao, never()) { deleteExpiredMessages(eq(INTERNAL_CONVERSATION_ID), any()) } + verifyBlocking(network, never()) { pullChatMessages(any(), any(), any()) } repository.handleOnResume() - // the loop keeps its normal step: nothing fires before the next tick ... + // the loop keeps its normal step: nothing is sent before the next tick ... advanceTimeBy(INSURANCE_DELAY_MS / 2) runCurrent() - verifyBlocking(chatDao, never()) { deleteExpiredMessages(eq(INTERNAL_CONVERSATION_ID), any()) } + verifyBlocking(network, never()) { pullChatMessages(any(), any(), any()) } // ... and the request is sent on it advanceTimeBy(INSURANCE_DELAY_MS / 2) runCurrent() - verifyBlocking(chatDao, times(1)) { deleteExpiredMessages(eq(INTERNAL_CONVERSATION_ID), any()) } + verifyBlocking(network, times(1)) { pullChatMessages(eq(CREDENTIALS), eq(CHAT_URL), any()) } polling.cancelAndJoin() }