Skip to content
Draft
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 @@ -372,6 +372,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 @@ -50,6 +50,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 @@ -262,6 +263,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 @@ -50,6 +50,18 @@ interface OfflineConversationsRepository {
@Deprecated("use observeConversation")
fun getRooms(user: User, forceFullSync: Boolean = false): Job

/**
* Synchronizes [user]'s conversations with the server and returns once that sync and the
* message catch-up it triggers are done, reporting whether it worked.
*
* [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.
*/
suspend fun syncRooms(user: User, forceFullSync: Boolean = false): Boolean

/**
* Called once onStart to emit a conversation to [conversationFlow]
* to be handled asynchronously.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,9 @@ class OfflineFirstConversationsRepository @Inject constructor(
}
}

override suspend fun syncRooms(user: User, forceFullSync: Boolean): Boolean =
getRoomsFromServer(user, forceFullSync = forceFullSync, awaitCatchUp = true) != null

@Suppress("Detekt.TooGenericExceptionCaught")
override fun getRoom(user: User, roomToken: String): Job =
scope.launch {
Expand Down Expand Up @@ -169,7 +172,11 @@ class OfflineFirstConversationsRepository @Inject constructor(
}

@Suppress("Detekt.TooGenericExceptionCaught")
private suspend fun getRoomsFromServer(user: User, forceFullSync: Boolean = false): List<ConversationEntity>? {
private suspend fun getRoomsFromServer(
user: User,
forceFullSync: Boolean = false,
awaitCatchUp: Boolean = false
): List<ConversationEntity>? {
var conversationsFromSync: List<ConversationEntity>? = null

if (!networkMonitor.isOnline.value) {
Expand Down Expand Up @@ -224,7 +231,13 @@ class OfflineFirstConversationsRepository @Inject constructor(
rememberSyncedState(accountId, roomList)

val roomsWithNewMessages = getRoomsWithNewMessages(conversationsFromSync, previousConversations)
scope.launch { catchUpRoomsWithNewMessages(user, roomsWithNewMessages) }
if (awaitCatchUp) {
// A worker that returns before the catch-up is done reports success for messages it
// never fetched, and is torn down mid-request.
catchUpRoomsWithNewMessages(user, roomsWithNewMessages)
} else {
scope.launch { catchUpRoomsWithNewMessages(user, roomsWithNewMessages) }
}
} catch (e: Exception) {
Log.e(TAG, "Something went wrong when fetching conversations", e)
// A delta anchored on a sync that did not land is worse than fetching everything again:
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 <andy.scherzinger@nextcloud.com>
* 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
142 changes: 142 additions & 0 deletions app/src/main/java/com/nextcloud/talk/jobs/ConversationsSyncWorker.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
/*
* Nextcloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2026 Andy Scherzinger <andy.scherzinger@nextcloud.com>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.nextcloud.talk.jobs

import android.content.Context
import android.util.Log
import androidx.annotation.VisibleForTesting
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.work.Constraints
import androidx.work.CoroutineWorker
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.NetworkType
import androidx.work.PeriodicWorkRequest
import androidx.work.WorkManager
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.conversationlist.data.OfflineConversationsRepository
import com.nextcloud.talk.data.user.model.User
import com.nextcloud.talk.extensions.isPowerSaveMode
import com.nextcloud.talk.users.UserManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.util.concurrent.TimeUnit
import javax.inject.Inject

/**
* Periodic worker that syncs the conversation list, and the messages that sync prefetches, for
* every configured account.
*
* Accounts are synced one after another. The run is skipped in battery saver mode and while the app
* is in the foreground, and a run in which any account failed is retried up to [MAX_RUN_ATTEMPTS]
* times. Network availability is enforced by the [NetworkType.CONNECTED] constraint on the request
* rather than checked here.
*/
@AutoInjector(NextcloudTalkApplication::class)
class ConversationsSyncWorker(context: Context, workerParams: WorkerParameters) :
CoroutineWorker(context, workerParams) {

@Inject
lateinit var userManager: UserManager

@Inject
lateinit var conversationsRepository: OfflineConversationsRepository

override suspend fun doWork(): Result {
sharedApplication!!.componentApplication.inject(this)
return sync()
}

/** Runs the sync, or reports success without syncing when a guard stands the run down. */
@VisibleForTesting
internal suspend fun sync(): Result =
when {
applicationContext.isPowerSaveMode() -> {
Log.d(TAG, "Battery saver is active, skipping the background conversation sync")
Result.success()
}

isAppInForeground() -> {
// ponytail: this stands down for any foreground screen, not only the conversation
// list that refreshes itself. Sitting in a chat for an hour therefore refreshes
// nothing until the list is opened again, which syncs on resume anyway. Narrow it
// to the list being resumed if that ever costs someone a stale unread count.
Log.d(TAG, "App is in the foreground, skipping the background conversation sync")
Result.success()
}

else -> syncAccounts()
}

private suspend fun syncAccounts(): Result {
// ponytail: every account in one wake-up, sequentially. N accounts cost N room list
// requests plus their message catch-ups; cap the run and take the stalest accounts first if
// that ever shows up in request volume.
val accounts = runCatching { userManager.users.blockingGet() }.getOrElse { throwable ->
Log.e(TAG, "Could not read the accounts to sync", throwable)
emptyList()
}

if (accounts.isEmpty()) {
Log.d(TAG, "No account to sync")
return Result.success()
}

val failed = accounts.count { !syncAccount(it) }

return if (failed == 0) {
Result.success()
} else {
Log.w(TAG, "$failed of ${accounts.size} accounts did not sync (attempt ${runAttemptCount + 1})")
retryOrFail()
}
}

/** Syncs a single account, returning whether it succeeded. Failures are logged, never thrown. */
private suspend fun syncAccount(user: User): Boolean =
runCatching { conversationsRepository.syncRooms(user) }.getOrElse { throwable ->
Log.e(TAG, "Background conversation sync failed for account ${user.id}", throwable)
false
}

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

private suspend fun isAppInForeground(): Boolean =
withContext(Dispatchers.Main.immediate) {
ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)
}

companion object {
private val TAG: String = ConversationsSyncWorker::class.java.simpleName
private const val MAX_RUN_ATTEMPTS = 3
private const val REPEAT_INTERVAL_MINUTES = 15L
const val UNIQUE_WORK_NAME = "PeriodicConversationsSync"

/**
* Schedules the worker to run every [REPEAT_INTERVAL_MINUTES] minutes while a network is
* available, leaving an already scheduled run in place.
*/
fun schedule(context: Context) {
val work = PeriodicWorkRequest.Builder(
ConversationsSyncWorker::class.java,
REPEAT_INTERVAL_MINUTES,
TimeUnit.MINUTES
).setConstraints(
Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()
).build()

WorkManager.getInstance(context).enqueueUniquePeriodicWork(
UNIQUE_WORK_NAME,
ExistingPeriodicWorkPolicy.KEEP,
work
)
}
}
}
Loading