Skip to content

Commit 2e29d5a

Browse files
feat(conversations): sync conversations periodically in background
A backgrounded app learns nothing about its conversations until a push arrives. Where push is disabled, unavailable or simply does not turn up, that means the list, the unread counts and the cached messages stay as they were until the user opens the app. Add a periodic worker that runs the conversation list sync and its message catch-up for every configured account. Accounts are synced one after another rather than at once, because each fans out into its own bounded set of message requests and running them together multiplies that into a burst on a single wake-up. It stands down in battery saver and while the app is in the foreground, where the list refreshes itself, and leaves reachability to the work request's network constraint - the connectivity flow is frozen in a process with no UI collector, and pre-checking it there is what silently disabled the message prefetch before. WorkManager's period floor is 15 minutes and Doze defers it further, so this bounds staleness rather than delivering immediacy. Scheduling keeps an existing run instead of replacing it, so an app opened often still reaches one. Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Andy Scherzinger <info@andy-scherzinger.de>
1 parent bc8be8b commit 2e29d5a

2 files changed

Lines changed: 153 additions & 0 deletions

File tree

‎app/src/main/java/com/nextcloud/talk/application/NextcloudTalkApplication.kt‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ import com.nextcloud.talk.dagger.modules.UtilsModule
5050
import com.nextcloud.talk.dagger.modules.ViewModelModule
5151
import com.nextcloud.talk.filebrowser.webdav.DavUtils
5252
import com.nextcloud.talk.jobs.AccountRemovalWorker
53+
import com.nextcloud.talk.jobs.ConversationsSyncWorker
5354
import com.nextcloud.talk.jobs.CapabilitiesSyncWorker
5455
import com.nextcloud.talk.jobs.SignalingSettingsWorker
5556
import com.nextcloud.talk.jobs.WebsocketConnectionsWorker
@@ -262,6 +263,8 @@ class NextcloudTalkApplication :
262263
ExistingPeriodicWorkPolicy.REPLACE,
263264
periodicCapabilitiesUpdateWork
264265
)
266+
267+
ConversationsSyncWorker.schedule(applicationContext)
265268
}
266269

267270
override fun onTerminate() {
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
/*
2+
* Nextcloud Talk - Android Client
3+
*
4+
* SPDX-FileCopyrightText: 2026 Andy Scherzinger <andy.scherzinger@nextcloud.com>
5+
* SPDX-License-Identifier: GPL-3.0-or-later
6+
*/
7+
package com.nextcloud.talk.jobs
8+
9+
import android.content.Context
10+
import android.os.PowerManager
11+
import android.util.Log
12+
import androidx.lifecycle.Lifecycle
13+
import androidx.lifecycle.ProcessLifecycleOwner
14+
import androidx.work.Constraints
15+
import androidx.work.CoroutineWorker
16+
import androidx.work.ExistingPeriodicWorkPolicy
17+
import androidx.work.NetworkType
18+
import androidx.work.PeriodicWorkRequest
19+
import androidx.work.WorkManager
20+
import androidx.work.WorkerParameters
21+
import autodagger.AutoInjector
22+
import com.nextcloud.talk.application.NextcloudTalkApplication
23+
import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedApplication
24+
import com.nextcloud.talk.conversationlist.data.OfflineConversationsRepository
25+
import com.nextcloud.talk.data.user.model.User
26+
import com.nextcloud.talk.users.UserManager
27+
import kotlinx.coroutines.Dispatchers
28+
import kotlinx.coroutines.withContext
29+
import java.util.concurrent.TimeUnit
30+
import javax.inject.Inject
31+
32+
/**
33+
* Syncs the conversation list of every account while the app is in the background, so local state
34+
* does not go stale between push notifications - a device with push disabled, or one whose push
35+
* never arrives, otherwise shows whatever it last saw until the user opens the app.
36+
*
37+
* This bounds staleness rather than delivering immediacy. WorkManager's period floor is 15 minutes
38+
* and Doze defers it further, so it is coarser than the iOS background refresh it mirrors.
39+
* Immediacy is push, which is already handled, and the foreground refresh while the list is open.
40+
*
41+
* The sync asks the server only for what changed where it can, which is what makes running it on a
42+
* timer affordable in the first place; the five-minute rule inside the repository still forces a
43+
* full list often enough for removals to be noticed.
44+
*
45+
* Reachability is left to the request's [NetworkType.CONNECTED] constraint rather than a pre-check:
46+
* the connectivity flow is frozen in a process with no UI collector, and pre-checking it there
47+
* silently disabled the message prefetch for exactly that reason.
48+
*/
49+
@AutoInjector(NextcloudTalkApplication::class)
50+
class ConversationsSyncWorker(context: Context, workerParams: WorkerParameters) :
51+
CoroutineWorker(context, workerParams) {
52+
53+
@Inject
54+
lateinit var userManager: UserManager
55+
56+
@Inject
57+
lateinit var conversationsRepository: OfflineConversationsRepository
58+
59+
override suspend fun doWork(): Result {
60+
sharedApplication!!.componentApplication.inject(this)
61+
62+
return when {
63+
isPowerSaveMode() -> {
64+
Log.d(TAG, "Battery saver is active, skipping the background conversation sync")
65+
Result.success()
66+
}
67+
68+
isAppInForeground() -> {
69+
// the conversation list refreshes itself while it is on screen
70+
Log.d(TAG, "App is in the foreground, skipping the background conversation sync")
71+
Result.success()
72+
}
73+
74+
else -> syncAccounts()
75+
}
76+
}
77+
78+
private suspend fun syncAccounts(): Result {
79+
// ponytail: every account in one wake-up, sequentially. N accounts cost N room list
80+
// requests plus their message catch-ups; cap the run and take the stalest accounts first if
81+
// that ever shows up in request volume.
82+
val accounts = runCatching { userManager.users.blockingGet() }.getOrElse { throwable ->
83+
Log.e(TAG, "Could not read the accounts to sync", throwable)
84+
emptyList()
85+
}
86+
87+
if (accounts.isEmpty()) {
88+
Log.d(TAG, "No account to sync")
89+
return Result.success()
90+
}
91+
92+
val failed = accounts.count { !syncAccount(it) }
93+
94+
return if (failed == 0) {
95+
Result.success()
96+
} else {
97+
Log.w(TAG, "$failed of ${accounts.size} accounts did not sync (attempt ${runAttemptCount + 1})")
98+
retryOrFail()
99+
}
100+
}
101+
102+
/**
103+
* Never in parallel: each account fans out into its own bounded set of message requests, and
104+
* running the accounts at the same time multiplies that into a burst on one wake-up.
105+
*/
106+
private suspend fun syncAccount(user: User): Boolean =
107+
runCatching { conversationsRepository.syncRooms(user) }.getOrElse { throwable ->
108+
Log.e(TAG, "Background conversation sync failed for account ${user.id}", throwable)
109+
false
110+
}
111+
112+
private fun retryOrFail(): Result = if (runAttemptCount < MAX_RUN_ATTEMPTS - 1) Result.retry() else Result.failure()
113+
114+
private fun isPowerSaveMode(): Boolean {
115+
val powerManager = applicationContext.getSystemService(Context.POWER_SERVICE) as PowerManager
116+
return powerManager.isPowerSaveMode
117+
}
118+
119+
private suspend fun isAppInForeground(): Boolean =
120+
withContext(Dispatchers.Main.immediate) {
121+
ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)
122+
}
123+
124+
companion object {
125+
private val TAG: String = ConversationsSyncWorker::class.java.simpleName
126+
private const val MAX_RUN_ATTEMPTS = 3
127+
private const val REPEAT_INTERVAL_MINUTES = 15L
128+
const val UNIQUE_WORK_NAME = "PeriodicConversationsSync"
129+
130+
/**
131+
* Keeps an already scheduled run rather than replacing it: replacing restarts the period on
132+
* every cold start, so an app that is opened often would never reach a run.
133+
*/
134+
fun schedule(context: Context) {
135+
val work = PeriodicWorkRequest.Builder(
136+
ConversationsSyncWorker::class.java,
137+
REPEAT_INTERVAL_MINUTES,
138+
TimeUnit.MINUTES
139+
).setConstraints(
140+
Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()
141+
).build()
142+
143+
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
144+
UNIQUE_WORK_NAME,
145+
ExistingPeriodicWorkPolicy.KEEP,
146+
work
147+
)
148+
}
149+
}
150+
}

0 commit comments

Comments
 (0)