Skip to content

Commit 46c0a5f

Browse files
feat: Move to coroutines
AI-assistant: Copilot 1.7.1-243 (Claude Sonnet 4.6) Signed-off-by: Andy Scherzinger <info@andy-scherzinger.de>
1 parent 0321dac commit 46c0a5f

5 files changed

Lines changed: 195 additions & 58 deletions

File tree

app/src/main/java/com/nextcloud/talk/api/NcApiCoroutines.kt

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -447,4 +447,17 @@ interface NcApiCoroutines {
447447
@Url url: String,
448448
@Query("reaction") reaction: String?
449449
): ReactionsOverall
450+
451+
// Url is: /api/{apiVersion}/chat/{token}/read
452+
@FormUrlEncoded
453+
@POST
454+
suspend fun setChatReadMarker(
455+
@Header("Authorization") authorization: String,
456+
@Url url: String,
457+
@Field("lastReadMessage") lastReadMessage: Int?
458+
): GenericOverall
459+
460+
// Url is: /api/{apiVersion}/chat/{token}/read
461+
@DELETE
462+
suspend fun markRoomAsUnread(@Header("Authorization") authorization: String, @Url url: String): GenericOverall
450463
}

app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt

Lines changed: 20 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,6 @@ import com.nextcloud.talk.account.ServerSelectionActivity
3838
import com.nextcloud.talk.activities.BaseActivity
3939
import com.nextcloud.talk.activities.CallActivity
4040
import com.nextcloud.talk.activities.MainActivity
41-
import com.nextcloud.talk.api.NcApi
4241
import com.nextcloud.talk.api.NcApiCoroutines
4342
import com.nextcloud.talk.application.NextcloudTalkApplication
4443
import com.nextcloud.talk.conversation.RenameConversationDialogFragment
@@ -63,7 +62,6 @@ import com.nextcloud.talk.jobs.LeaveConversationWorker
6362
import com.nextcloud.talk.jobs.UploadAndShareFilesWorker
6463
import com.nextcloud.talk.models.domain.ConversationModel
6564
import com.nextcloud.talk.models.domain.SearchMessageEntry
66-
import com.nextcloud.talk.models.json.generic.GenericOverall
6765
import com.nextcloud.talk.models.json.conversations.ConversationEnums
6866
import com.nextcloud.talk.settings.SettingsActivity
6967
import com.nextcloud.talk.threadsoverview.ThreadsOverviewActivity
@@ -106,10 +104,6 @@ import kotlinx.coroutines.flow.collect
106104
import kotlinx.coroutines.flow.onEach
107105
import kotlinx.coroutines.launch
108106
import kotlinx.coroutines.withContext
109-
import io.reactivex.Observer
110-
import io.reactivex.android.schedulers.AndroidSchedulers
111-
import io.reactivex.disposables.Disposable
112-
import io.reactivex.schedulers.Schedulers
113107
import org.greenrobot.eventbus.Subscribe
114108
import org.greenrobot.eventbus.ThreadMode
115109
import retrofit2.HttpException
@@ -124,9 +118,6 @@ class ConversationsListActivity : BaseActivity() {
124118
@Inject
125119
lateinit var userManager: UserManager
126120

127-
@Inject
128-
lateinit var ncApi: NcApi
129-
130121
@Inject
131122
lateinit var ncApiCoroutines: NcApiCoroutines
132123

@@ -424,6 +415,24 @@ class ConversationsListActivity : BaseActivity() {
424415
if (filterState[ARCHIVE] == true) showUnreadBubbleState.value = false
425416
}
426417
}
418+
419+
lifecycleScope.launch {
420+
conversationsListViewModel.readUnreadState.collect { state ->
421+
when (state) {
422+
is ConversationsListViewModel.ConversationReadUnreadUiState.Success -> {
423+
fetchRooms()
424+
val resId = if (state.isMarkedRead) R.string.marked_as_read else R.string.marked_as_unread
425+
showSnackbar(String.format(resources.getString(resId), state.conversationDisplayName))
426+
conversationsListViewModel.resetReadUnreadState()
427+
}
428+
is ConversationsListViewModel.ConversationReadUnreadUiState.Error -> {
429+
showSnackbar(resources.getString(R.string.nc_common_error_sorry))
430+
conversationsListViewModel.resetReadUnreadState()
431+
}
432+
ConversationsListViewModel.ConversationReadUnreadUiState.None -> { /* no-op */ }
433+
}
434+
}
435+
}
427436
}
428437

429438
private fun handleNoteToSelfShortcut(noteToSelfAvailable: Boolean, noteToSelfToken: String) {
@@ -1000,57 +1009,11 @@ class ConversationsListActivity : BaseActivity() {
10001009
}
10011010

10021011
private fun markConversationAsUnread(conversation: ConversationModel) {
1003-
val apiVersion = ApiUtils.getChatApiVersion(
1004-
currentUser?.capabilities!!.spreedCapability!!,
1005-
intArrayOf(ApiUtils.API_V1)
1006-
)
1007-
ncApi.markRoomAsUnread(
1008-
credentials!!,
1009-
ApiUtils.getUrlForChatReadMarker(apiVersion, currentUser?.baseUrl!!, conversation.token!!)
1010-
)
1011-
.subscribeOn(Schedulers.io())
1012-
.observeOn(AndroidSchedulers.mainThread())
1013-
.retry(1)
1014-
.subscribe(object : Observer<GenericOverall> {
1015-
override fun onSubscribe(d: Disposable) { /* unused */ }
1016-
override fun onNext(t: GenericOverall) {
1017-
fetchRooms()
1018-
showSnackbar(
1019-
String.format(resources.getString(R.string.marked_as_unread), conversation.displayName)
1020-
)
1021-
}
1022-
override fun onError(e: Throwable) {
1023-
showSnackbar(resources.getString(R.string.nc_common_error_sorry))
1024-
}
1025-
override fun onComplete() { /* unused */ }
1026-
})
1012+
conversationsListViewModel.markConversationAsUnread(conversation)
10271013
}
10281014

10291015
private fun markConversationAsRead(conversation: ConversationModel) {
1030-
val messageId = if (conversation.remoteServer.isNullOrEmpty()) conversation.lastMessage?.id else null
1031-
val apiVersion = ApiUtils.getChatApiVersion(
1032-
currentUser?.capabilities!!.spreedCapability!!,
1033-
intArrayOf(ApiUtils.API_V1)
1034-
)
1035-
ncApi.setChatReadMarker(
1036-
credentials!!,
1037-
ApiUtils.getUrlForChatReadMarker(apiVersion, currentUser?.baseUrl!!, conversation.token!!),
1038-
messageId?.toInt()
1039-
)
1040-
.subscribeOn(Schedulers.io())
1041-
.observeOn(AndroidSchedulers.mainThread())
1042-
.retry(1)
1043-
.subscribe(object : Observer<GenericOverall> {
1044-
override fun onSubscribe(d: Disposable) { /* unused */ }
1045-
override fun onNext(t: GenericOverall) {
1046-
fetchRooms()
1047-
showSnackbar(String.format(resources.getString(R.string.marked_as_read), conversation.displayName))
1048-
}
1049-
override fun onError(e: Throwable) {
1050-
showSnackbar(resources.getString(R.string.nc_common_error_sorry))
1051-
}
1052-
override fun onComplete() { /* unused */ }
1053-
})
1016+
conversationsListViewModel.markConversationAsRead(conversation)
10541017
}
10551018

10561019
private fun renameConversation(conversation: ConversationModel) {

app/src/main/java/com/nextcloud/talk/conversationlist/viewmodels/ConversationsListViewModel.kt

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import androidx.lifecycle.MutableLiveData
1313
import androidx.lifecycle.ViewModel
1414
import androidx.lifecycle.viewModelScope
1515
import com.nextcloud.talk.R
16+
import com.nextcloud.talk.api.NcApiCoroutines
1617
import com.nextcloud.talk.arbitrarystorage.ArbitraryStorageManager
1718
import com.nextcloud.talk.contacts.ContactsRepository
1819
import com.nextcloud.talk.conversationlist.data.OfflineConversationsRepository
@@ -40,6 +41,7 @@ import com.nextcloud.talk.utils.CapabilitiesUtil.hasSpreedFeatureCapability
4041
import com.nextcloud.talk.utils.SpreedFeatures
4142
import com.nextcloud.talk.utils.UserIdUtils
4243
import com.nextcloud.talk.utils.database.user.CurrentUserProviderOld
44+
import com.nextcloud.talk.utils.withRetry
4345
import io.reactivex.Observer
4446
import io.reactivex.android.schedulers.AndroidSchedulers
4547
import io.reactivex.disposables.Disposable
@@ -73,7 +75,8 @@ class ConversationsListViewModel @Inject constructor(
7375
private val unifiedSearchRepository: UnifiedSearchRepository,
7476
private val invitationsRepository: InvitationsRepository,
7577
private val arbitraryStorageManager: ArbitraryStorageManager,
76-
var userManager: UserManager
78+
var userManager: UserManager,
79+
private val ncApiCoroutines: NcApiCoroutines
7780
) : ViewModel() {
7881

7982
private val _currentUser = currentUserProvider.currentUser.blockingGet()
@@ -102,6 +105,16 @@ class ConversationsListViewModel @Inject constructor(
102105
private val _openConversationsState = MutableStateFlow<OpenConversationsUiState>(OpenConversationsUiState.None)
103106
val openConversationsState: StateFlow<OpenConversationsUiState> = _openConversationsState
104107

108+
sealed class ConversationReadUnreadUiState {
109+
data object None : ConversationReadUnreadUiState()
110+
data class Success(val conversationDisplayName: String, val isMarkedRead: Boolean) :
111+
ConversationReadUnreadUiState()
112+
data object Error : ConversationReadUnreadUiState()
113+
}
114+
115+
private val _readUnreadState = MutableStateFlow<ConversationReadUnreadUiState>(ConversationReadUnreadUiState.None)
116+
val readUnreadState: StateFlow<ConversationReadUnreadUiState> = _readUnreadState.asStateFlow()
117+
105118
object GetRoomsStartState : ViewState
106119
class GetRoomsErrorState(val throwable: Throwable) : ViewState
107120
open class GetRoomsSuccessState(val listIsNotEmpty: Boolean) : ViewState
@@ -556,6 +569,49 @@ class ConversationsListViewModel @Inject constructor(
556569
(eventTimeStart - currentTimeStampInSeconds) > SIXTEEN_HOURS_IN_SECONDS
557570
}
558571

572+
fun resetReadUnreadState() {
573+
_readUnreadState.value = ConversationReadUnreadUiState.None
574+
}
575+
576+
@Suppress("Detekt.TooGenericExceptionCaught")
577+
fun markConversationAsRead(conversation: ConversationModel) {
578+
val messageId = if (conversation.remoteServer.isNullOrEmpty()) conversation.lastMessage?.id?.toInt() else null
579+
val apiVersion = ApiUtils.getChatApiVersion(
580+
currentUser.capabilities?.spreedCapability!!,
581+
intArrayOf(ApiUtils.API_V1)
582+
)
583+
val url = ApiUtils.getUrlForChatReadMarker(apiVersion, currentUser.baseUrl, conversation.token)
584+
viewModelScope.launch {
585+
try {
586+
withContext(Dispatchers.IO) {
587+
withRetry(1) { ncApiCoroutines.setChatReadMarker(credentials, url, messageId) }
588+
}
589+
_readUnreadState.value = ConversationReadUnreadUiState.Success(conversation.displayName, true)
590+
} catch (e: Exception) {
591+
_readUnreadState.value = ConversationReadUnreadUiState.Error
592+
}
593+
}
594+
}
595+
596+
@Suppress("Detekt.TooGenericExceptionCaught")
597+
fun markConversationAsUnread(conversation: ConversationModel) {
598+
val apiVersion = ApiUtils.getChatApiVersion(
599+
currentUser.capabilities?.spreedCapability!!,
600+
intArrayOf(ApiUtils.API_V1)
601+
)
602+
val url = ApiUtils.getUrlForChatReadMarker(apiVersion, currentUser.baseUrl, conversation.token)
603+
viewModelScope.launch {
604+
try {
605+
withContext(Dispatchers.IO) {
606+
withRetry(1) { ncApiCoroutines.markRoomAsUnread(credentials, url) }
607+
}
608+
_readUnreadState.value = ConversationReadUnreadUiState.Success(conversation.displayName, false)
609+
} catch (e: Exception) {
610+
_readUnreadState.value = ConversationReadUnreadUiState.Error
611+
}
612+
}
613+
}
614+
559615
inner class FederatedInvitationsObserver : Observer<InvitationsModel> {
560616
override fun onSubscribe(d: Disposable) {
561617
// unused atm
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
/*
2+
* Nextcloud Talk - Android Client
3+
*
4+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
5+
* SPDX-License-Identifier: GPL-3.0-or-later
6+
*/
7+
8+
package com.nextcloud.talk.utils
9+
10+
/**
11+
* Executes [block] and, if it throws, retries up to [retries] additional times.
12+
* Equivalent to RxJava's `.retry(retries)`.
13+
* The last exception is rethrown if all attempts fail.
14+
*/
15+
@Suppress("TooGenericExceptionCaught")
16+
suspend fun <T> withRetry(retries: Int = 1, block: suspend () -> T): T {
17+
var attempt = 0
18+
while (true) {
19+
try {
20+
return block()
21+
} catch (e: Exception) {
22+
if (attempt >= retries) throw e
23+
attempt++
24+
}
25+
}
26+
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
/*
2+
* Nextcloud Talk - Android Client
3+
*
4+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
5+
* SPDX-License-Identifier: GPL-3.0-or-later
6+
*/
7+
8+
package com.nextcloud.talk.utils
9+
10+
import kotlinx.coroutines.test.runTest
11+
import org.junit.Assert.assertEquals
12+
import org.junit.Assert.assertSame
13+
import org.junit.Test
14+
15+
@Suppress("TooGenericExceptionThrown")
16+
class CoroutineUtilsTest {
17+
18+
@Test
19+
fun `withRetry returns result on first success`() =
20+
runTest {
21+
var callCount = 0
22+
val result = withRetry(retries = 1) {
23+
callCount++
24+
"success"
25+
}
26+
assertEquals("success", result)
27+
assertEquals(1, callCount)
28+
}
29+
30+
@Test
31+
fun `withRetry retries once and returns result on second attempt`() =
32+
runTest {
33+
var callCount = 0
34+
val result = withRetry(retries = 1) {
35+
callCount++
36+
if (callCount < 2) throw RuntimeException("transient error")
37+
"success after retry"
38+
}
39+
assertEquals("success after retry", result)
40+
assertEquals(2, callCount)
41+
}
42+
43+
@Test(expected = RuntimeException::class)
44+
fun `withRetry rethrows exception when all attempts fail`() =
45+
runTest {
46+
withRetry(retries = 1) {
47+
throw RuntimeException("permanent error")
48+
}
49+
}
50+
51+
@Test
52+
fun `withRetry makes exactly retries plus one attempts before failing`() =
53+
runTest {
54+
var callCount = 0
55+
val expectedException = RuntimeException("permanent")
56+
val thrownException = runCatching {
57+
withRetry(retries = 2) {
58+
callCount++
59+
throw expectedException
60+
}
61+
}.exceptionOrNull()
62+
63+
assertEquals(3, callCount)
64+
assertSame(expectedException, thrownException)
65+
}
66+
67+
@Test
68+
fun `withRetry with zero retries does not retry`() =
69+
runTest {
70+
var callCount = 0
71+
runCatching {
72+
withRetry(retries = 0) {
73+
callCount++
74+
throw RuntimeException("error")
75+
}
76+
}
77+
assertEquals(1, callCount)
78+
}
79+
}

0 commit comments

Comments
 (0)