Skip to content
Merged
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
1 change: 1 addition & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,7 @@ dependencies {
androidTestImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.11.0")
androidTestImplementation("androidx.test:core-ktx:1.7.0")
androidTestImplementation("org.mockito:mockito-android:5.22.0")
testImplementation("androidx.work:work-testing:$workVersion")
androidTestImplementation("androidx.work:work-testing:$workVersion")
androidTestImplementation("androidx.test.espresso:espresso-core:$espressoVersion") {
exclude(group = "com.android.support", module = "support-annotations")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import com.nextcloud.talk.dagger.modules.UtilsModule
import com.nextcloud.talk.dagger.modules.ViewModelModule
import com.nextcloud.talk.filebrowser.webdav.DavUtils
import com.nextcloud.talk.jobs.AccountRemovalWorker
import com.nextcloud.talk.jobs.ConversationsSyncWorker
import com.nextcloud.talk.jobs.CapabilitiesSyncWorker
import com.nextcloud.talk.jobs.SignalingSettingsWorker
import com.nextcloud.talk.jobs.WebsocketConnectionsWorker
Expand Down Expand Up @@ -271,6 +272,8 @@ class NextcloudTalkApplication :
ExistingPeriodicWorkPolicy.REPLACE,
periodicCapabilitiesUpdateWork
)

ConversationsSyncWorker.schedule(applicationContext)
}

override fun onTerminate() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
package com.nextcloud.talk.conversationlist.data

import com.nextcloud.talk.conversationlist.data.network.OfflineFirstConversationsRepository.ConversationResult
import com.nextcloud.talk.data.database.model.ConversationEntity
import com.nextcloud.talk.data.user.model.User
import com.nextcloud.talk.models.domain.ConversationModel
import kotlinx.coroutines.Job
Expand Down Expand Up @@ -50,6 +51,41 @@ interface OfflineConversationsRepository {
@Deprecated("use observeConversation")
fun getRooms(user: User, forceFullSync: Boolean = false): Job

/**
* Synchronizes [user]'s conversations with the server and returns once they are stored,
* handing back the rooms whose messages should be caught up, or null when the sync failed.
* The catch-up itself is left to the caller, through [catchUpRooms].
*
* [getRooms] launches into the repository's own scope and returns immediately, which is what
* the conversation list wants and what a background worker cannot use: WorkManager tears the
* process down once the worker returns, mid-request. This does not select the observed account
* either - that is what the conversation list screen shows, and a worker walking several
* accounts must not move it. Leaving the catch-up to the caller lets a worker sync the room
* lists of all accounts before it spends time on any account's messages.
*
* With [roomListTimeoutMillis] set, the sync is given at most that long and reports failure
* when it runs out.
*
* Connectivity is not pre-checked: the request is sent and a failure is reported like any
* other, so a network that is connected but not yet validated still gets its chance.
*
* A failure is reported only through the return value, never on [syncErrorFlow]: that flow
* feeds the conversation list, which must not show an error of an account it does not show.
*
* Safe to call from any thread: the blocking work runs on the IO dispatcher.
*/
suspend fun syncRooms(
user: User,
forceFullSync: Boolean = false,
roomListTimeoutMillis: Long? = null
): List<ConversationEntity>?

/**
* Prefetches the messages of [rooms], as returned by [syncRooms], into the local database and
* returns once that is done. Failures for single rooms are logged, not thrown.
*/
suspend fun catchUpRooms(user: User, rooms: List<ConversationEntity>)

/**
* Called once onStart to emit a conversation to [conversationFlow]
* to be handled asynchronously.
Expand All @@ -76,5 +112,11 @@ interface OfflineConversationsRepository {
*/
fun requireFullSync(accountId: Long)

/**
* When the conversation list of the account with the internal id [accountId] was last fetched
* in full, or null when it never was. Safe to call from any thread.
*/
suspend fun lastFullSyncAt(accountId: Long): Long?

fun observeConversation(accountId: Long, roomToken: String): Flow<ConversationResult>
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.distinctUntilChanged
Expand All @@ -44,10 +45,13 @@ import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import javax.inject.Inject
import kotlin.collections.map
import kotlin.time.Duration.Companion.milliseconds

@Suppress("LongParameterList")
@Suppress("LongParameterList", "TooManyFunctions")
class OfflineFirstConversationsRepository @Inject constructor(
private val dao: ConversationsDao,
private val network: ConversationsNetworkDataSource,
Expand Down Expand Up @@ -101,10 +105,31 @@ class OfflineFirstConversationsRepository @Inject constructor(
override fun getRooms(user: User, forceFullSync: Boolean): Job =
scope.launch {
if (networkMonitor.isOnline.value) {
getRoomsFromServer(user, forceFullSync = forceFullSync)
val roomsWithNewMessages = getRoomsFromServer(
user,
forceFullSync = forceFullSync,
reportSyncError = true
) ?: return@launch
// a launch of its own, so the returned job completes without waiting for the catch-up
scope.launch { catchUpRooms(user, roomsWithNewMessages) }
}
}

override suspend fun syncRooms(
user: User,
forceFullSync: Boolean,
roomListTimeoutMillis: Long?
): List<ConversationEntity>? =
// the sync blocks its thread on the request and on stored state, so it must not run on the caller's
withContext(Dispatchers.IO) {
getRoomsFromServer(
user,
forceFullSync = forceFullSync,
roomListTimeoutMillis = roomListTimeoutMillis,
reportSyncError = false
)
}

@Suppress("Detekt.TooGenericExceptionCaught")
override fun getRoom(user: User, roomToken: String): Job =
scope.launch {
Expand Down Expand Up @@ -151,79 +176,103 @@ class OfflineFirstConversationsRepository @Inject constructor(
return getConversation(id, roomToken)
}

/**
* Syncs [user]'s room list, returning the rooms whose messages should be caught up, or null
* when the sync failed.
*
* [reportSyncError] lets a failure reach [syncErrorFlow]. That flow does not say which account
* failed and the conversation list shows whatever arrives there, so only a sync of the account
* the list shows may report to it.
*/
@Suppress("Detekt.TooGenericExceptionCaught")
private suspend fun getRoomsFromServer(user: User, forceFullSync: Boolean = false): List<ConversationEntity>? {
var conversationsFromSync: List<ConversationEntity>? = null

if (!networkMonitor.isOnline.value) {
Log.d(TAG, "Device is offline, can't load conversations from server")
return null
}

private suspend fun getRoomsFromServer(
user: User,
forceFullSync: Boolean = false,
roomListTimeoutMillis: Long? = null,
reportSyncError: Boolean
): List<ConversationEntity>? {
val accountId = user.id!!
val modifiedSince = modifiedSinceFor(user, forceFullSync)

val includeStatus = modifiedSince == null && isUserStatusAvailable(user)

try {
val roomList = withRetry(
retries = NETWORK_FETCH_RETRIES,
initialDelayMillis = NETWORK_FETCH_RETRY_INITIAL_DELAY_MS,
maxDelayMillis = NETWORK_FETCH_RETRY_MAX_DELAY_MS
) {
network.getRooms(user, user.baseUrl!!, includeStatus, modifiedSince)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.blockingSingle()
}

conversationsFromSync = roomList.conversations.map {
it.asEntity(accountId)
}

val previousConversations = dao.getConversationsForUser(accountId).first()
.associateBy { it.internalId }

val serverItems = if (includeStatus) {
conversationsFromSync
return try {
if (roomListTimeoutMillis == null) {
syncRoomList(user, modifiedSince)
} else {
keepCachedStatus(conversationsFromSync, previousConversations)
withTimeout(roomListTimeoutMillis.milliseconds) { syncRoomList(user, modifiedSince) }
}

dao.syncConversationsForUser(
accountId = accountId,
serverItems = conversationListUpdater.preservePendingLocalState(
previousConversations,
serverItems
),
conversationIdsToDelete = if (roomList.wasDelta) {
emptyList()
} else {
determineLeftConversationIds(previousConversations, conversationsFromSync)
}
)

rememberSyncedState(accountId, roomList)

val roomsWithNewMessages = getRoomsWithNewMessages(conversationsFromSync, previousConversations)
scope.launch { catchUpRoomsWithNewMessages(user, roomsWithNewMessages) }
} catch (e: TimeoutCancellationException) {
// the caller's run goes on, so this is a failed sync rather than a cancellation
Log.w(TAG, "Room list sync for account $accountId ran out of its time budget")
null
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.e(TAG, "Something went wrong when fetching conversations", e)
storeTimestamp(accountId, KEY_MODIFIED_SINCE, null)
val hasCachedConversations = dao.getConversationsForUser(accountId).first().isNotEmpty()
if (!hasCachedConversations) {
if (reportSyncError && dao.getConversationsForUser(accountId).first().isEmpty()) {
_syncErrorFlow.emit(e)
}
null
}
}

/**
* Fetches [user]'s room list, filtered by [modifiedSince] when set, and stores it, returning the
* rooms whose messages should be caught up.
*/
private suspend fun syncRoomList(user: User, modifiedSince: Long?): List<ConversationEntity> {
val accountId = user.id!!
val includeStatus = modifiedSince == null && isUserStatusAvailable(user)

val roomList = withRetry(
retries = NETWORK_FETCH_RETRIES,
initialDelayMillis = NETWORK_FETCH_RETRY_INITIAL_DELAY_MS,
maxDelayMillis = NETWORK_FETCH_RETRY_MAX_DELAY_MS
) {
network.getRooms(user, user.baseUrl!!, includeStatus, modifiedSince)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.blockingSingle()
}

val conversationsFromSync = roomList.conversations.map {
it.asEntity(accountId)
}
return conversationsFromSync

val previousConversations = dao.getConversationsForUser(accountId).first()
.associateBy { it.internalId }

val serverItems = if (includeStatus) {
conversationsFromSync
} else {
keepCachedStatus(conversationsFromSync, previousConversations)
}

dao.syncConversationsForUser(
accountId = accountId,
serverItems = conversationListUpdater.preservePendingLocalState(
previousConversations,
serverItems
),
conversationIdsToDelete = if (roomList.wasDelta) {
emptyList()
} else {
determineLeftConversationIds(previousConversations, conversationsFromSync)
}
)

rememberSyncedState(accountId, roomList)

return getRoomsWithNewMessages(conversationsFromSync, previousConversations)
}

override fun requireFullSync(accountId: Long) {
storeTimestamp(accountId, KEY_LAST_FULL_SYNC_AT, null)
}

override suspend fun lastFullSyncAt(accountId: Long): Long? =
withContext(Dispatchers.IO) { readTimestamp(accountId, KEY_LAST_FULL_SYNC_AT) }

/**
* The value to send as `modifiedSince`, or null when this sync has to be a full one.
*
Expand Down Expand Up @@ -289,14 +338,14 @@ class OfflineFirstConversationsRepository @Inject constructor(
/**
* Prefetches the messages of [rooms] into the local database so they are instantly visible
* when a chat is opened. Runs after the room list sync; failures are logged and never affect
* the conversation list itself.
* the conversation list itself, and cancellation propagates.
*
* The catch-up is skipped in battery saver mode and when background data is restricted on a
* metered network (mirroring the Low Power Mode guard on iOS), and is bounded to the
* [MAX_ROOMS_TO_CATCH_UP] most recently active rooms with [MAX_CONCURRENT_CATCH_UPS] parallel
* requests, so a fresh install with many rooms cannot cause an unbounded request burst.
*/
private suspend fun catchUpRoomsWithNewMessages(user: User, rooms: List<ConversationEntity>) {
override suspend fun catchUpRooms(user: User, rooms: List<ConversationEntity>) {
if (rooms.isEmpty() || !isCatchUpAllowed(user)) {
return
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/*
* Nextcloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2026 Andy Scherzinger <info@andy-scherzinger.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.nextcloud.talk.extensions

import android.content.Context
import android.os.PowerManager

/** Whether the device is currently in battery saver mode. */
fun Context.isPowerSaveMode(): Boolean = (getSystemService(Context.POWER_SERVICE) as PowerManager).isPowerSaveMode
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
package com.nextcloud.talk.jobs

import android.content.Context
import android.os.PowerManager
import android.util.Log
import androidx.work.BackoffPolicy
import androidx.work.Constraints
Expand All @@ -22,6 +21,7 @@ import androidx.work.WorkerParameters
import autodagger.AutoInjector
import com.nextcloud.talk.application.NextcloudTalkApplication
import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedApplication
import com.nextcloud.talk.extensions.isPowerSaveMode
import com.nextcloud.talk.chat.data.network.ChatMessageSyncer
import com.nextcloud.talk.data.database.dao.ConversationsDao
import com.nextcloud.talk.users.UserManager
Expand Down Expand Up @@ -71,7 +71,7 @@ class ChatMessageCatchUpWorker(context: Context, workerParams: WorkerParameters)
Result.failure()
}

isPowerSaveMode() -> {
applicationContext.isPowerSaveMode() -> {
Log.d(TAG, "Battery saver is active, skipping message catch-up for room $roomToken")
Result.success()
}
Expand Down Expand Up @@ -127,11 +127,6 @@ class ChatMessageCatchUpWorker(context: Context, workerParams: WorkerParameters)

private fun retryOrFail(): Result = if (runAttemptCount < MAX_RUN_ATTEMPTS - 1) Result.retry() else Result.failure()

private fun isPowerSaveMode(): Boolean {
val powerManager = applicationContext.getSystemService(Context.POWER_SERVICE) as PowerManager
return powerManager.isPowerSaveMode
}

companion object {
private val TAG: String = ChatMessageCatchUpWorker::class.java.simpleName
private const val CHAT_API_VERSION = 1
Expand Down
Loading
Loading