Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
a2ea897
refactor(chat): extract message sync into reusable component
AndyScherzinger Aug 2, 2026
fa06261
feat(chat): add room catch-up sync that creates missing chat blocks
AndyScherzinger Aug 2, 2026
00ab0ee
feat(chat): support fetching messages without clearing notifications
AndyScherzinger Aug 2, 2026
c1c7fe1
feat(conversations): prefetch unread messages during room list sync
AndyScherzinger Aug 2, 2026
0acb3bc
feat(conversations): guard message prefetch with caps and saver modes
AndyScherzinger Aug 2, 2026
46535aa
fix(chat): trust local cache on chat open and fetch only the delta
AndyScherzinger Aug 2, 2026
0bbd2c6
fix(chat): do not block initial message load on websocket connect
AndyScherzinger Aug 2, 2026
4910dca
feat(notifications): prefetch chat messages on push receipt
AndyScherzinger Aug 2, 2026
b877dc2
fix(conversations): guard conversation deletion against empty sync
AndyScherzinger Aug 2, 2026
196146b
fix(chat): reconcile chat blocks when expiring messages
AndyScherzinger Aug 2, 2026
0e8b820
fix(chat): derive insurance fetches from the chat blocks
AndyScherzinger Aug 2, 2026
41de36d
test(chat): cover message sync component
AndyScherzinger Aug 2, 2026
6cc0cb4
feat: Add extra logging to spot pre-fetching in debug mode
AndyScherzinger Aug 4, 2026
e9da372
refactor(chat): restructure catch-up guards to satisfy ReturnCount
AndyScherzinger Aug 4, 2026
9f974b9
feat(chat): coalesce bursts of background message catch-ups
AndyScherzinger Aug 6, 2026
d615de4
fix(chat): track insurance anchor from http syncs only
AndyScherzinger Aug 9, 2026
cef214d
fix(chat): loop delta fetches until the backlog is closed
AndyScherzinger Aug 9, 2026
8cb4e91
fix(notifications): catch up the pushed thread, not only the room
AndyScherzinger Aug 9, 2026
2b5d23d
chore: remove unrelated code change
AndyScherzinger Aug 9, 2026
70b2633
fix(chat): don't merge closeBacklog's fallback range with the backlog…
mahibi Aug 10, 2026
f307817
fix(chat): don't skip the initial backlog fetch on a stale lastMessage
mahibi Aug 11, 2026
e8734e2
refactor(chat): extract loadInitialMessages branches into named funct…
mahibi Aug 11, 2026
ac433db
refactor(chat): rename closeBacklog to tryCloseBacklog
mahibi Aug 11, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ interface ChatMessageRepository : LifecycleAwareManager {

fun updateConversation(conversationModel: ConversationModel)

suspend fun loadInitialMessages(withNetworkParams: Bundle, isChatRelaySupported: Boolean)
suspend fun loadInitialMessages(withNetworkParams: Bundle)

suspend fun startMessagePolling(hasHighPerformanceBackend: Boolean)

Expand Down

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -1277,20 +1277,20 @@ class ChatViewModel @AssistedInject constructor(

chatRepository.updateConversation(conversation)

val isChatRelaySupported = withTimeoutOrNull(WEBSOCKET_CONNECT_TIMEOUT_MS) {
awaitChatRelaySupport(user)
} ?: false
// The live-update mode (chat relay vs long polling) is decided in parallel: the
// initial load must never wait for the websocket to connect.
viewModelScope.launch {
val isChatRelaySupported = withTimeoutOrNull(WEBSOCKET_CONNECT_TIMEOUT_MS) {
awaitChatRelaySupport(user)
} ?: false
startMessagePolling(isChatRelaySupported)
}

loadInitialMessages(
withCredentials = credentials,
withUrl = url,
isChatRelaySupported = isChatRelaySupported
withUrl = url
)

viewModelScope.launch {
startMessagePolling(isChatRelaySupported)
}

getCapabilities(user, chatRoomToken, conversation)
}
.launchIn(viewModelScope)
Expand Down Expand Up @@ -1712,13 +1712,12 @@ class ChatViewModel @AssistedInject constructor(
}
}

suspend fun loadInitialMessages(withCredentials: String, withUrl: String, isChatRelaySupported: Boolean) {
suspend fun loadInitialMessages(withCredentials: String, withUrl: String) {
val bundle = Bundle()
bundle.putString(BundleKeys.KEY_CHAT_URL, withUrl)
bundle.putString(BundleKeys.KEY_CREDENTIALS, withCredentials)
chatRepository.loadInitialMessages(
withNetworkParams = bundle,
isChatRelaySupported = isChatRelaySupported
withNetworkParams = bundle
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@

package com.nextcloud.talk.conversationlist.data.network

import android.content.Context
import android.net.ConnectivityManager
import android.os.PowerManager
import android.util.Log
import com.nextcloud.talk.chat.data.network.ChatMessageSyncer
import com.nextcloud.talk.chat.data.network.ChatNetworkDataSource
import com.nextcloud.talk.conversationlist.data.OfflineConversationsRepository
import com.nextcloud.talk.data.database.dao.ConversationsDao
Expand All @@ -18,7 +22,9 @@ import com.nextcloud.talk.data.database.model.ConversationEntity
import com.nextcloud.talk.data.network.NetworkMonitor
import com.nextcloud.talk.data.user.model.User
import com.nextcloud.talk.models.domain.ConversationModel
import com.nextcloud.talk.utils.ApiUtils
import com.nextcloud.talk.utils.CapabilitiesUtil.isUserStatusAvailable
import com.nextcloud.talk.utils.SpreedFeatures
import io.reactivex.Observer
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.disposables.Disposable
Expand All @@ -30,16 +36,21 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import javax.inject.Inject
import kotlin.collections.map

class OfflineFirstConversationsRepository @Inject constructor(
private val dao: ConversationsDao,
private val network: ConversationsNetworkDataSource,
private val chatNetworkDataSource: ChatNetworkDataSource,
private val networkMonitor: NetworkMonitor
private val networkMonitor: NetworkMonitor,
private val chatMessageSyncer: ChatMessageSyncer,
private val context: Context
) : OfflineConversationsRepository {
override val roomListFlow: Flow<List<ConversationModel>>
get() = _roomListFlow
Expand Down Expand Up @@ -159,21 +170,133 @@ class OfflineFirstConversationsRepository @Inject constructor(
it.asEntity(user.id!!)
}

val previousConversations = dao.getConversationsForUser(user.id!!).first()
.associateBy { it.internalId }

deleteLeftConversations(
user,
conversationsFromSync
)
dao.upsertConversations(user.id!!, conversationsFromSync)

val roomsWithNewMessages = getRoomsWithNewMessages(conversationsFromSync, previousConversations)
scope.launch { catchUpRoomsWithNewMessages(user, roomsWithNewMessages) }
} catch (e: Exception) {
Log.e(TAG, "Something went wrong when fetching conversations", e)
}
return conversationsFromSync
}

/**
* Determines the rooms whose messages should be caught up in the background: rooms with
* activity newer than the last synced state (matching the iOS behavior) plus unread rooms that
* have no cached messages yet (never-opened rooms).
*/
private fun getRoomsWithNewMessages(
conversationsFromSync: List<ConversationEntity>,
previousConversations: Map<String, ConversationEntity>
): List<ConversationEntity> =
conversationsFromSync.filter { room ->
val previous = previousConversations[room.internalId]
val activityAdvanced = previous == null || room.lastActivity > previous.lastActivity
activityAdvanced ||
(room.unreadMessages > 0 && !chatMessageSyncer.hasLocalChatBlock(room.internalId, null))
}

/**
* 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 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>) {
if (rooms.isEmpty() || !isCatchUpAllowed(user)) {
return
}

val credentials = ApiUtils.getCredentials(user.username, user.token) ?: return

val cappedRooms = rooms
.sortedByDescending { it.lastActivity }
.take(MAX_ROOMS_TO_CATCH_UP)
if (cappedRooms.size < rooms.size) {
Log.w(TAG, "Capping message catch-up to ${cappedRooms.size} of ${rooms.size} rooms")
}

Log.d(TAG, "Catching up messages for ${cappedRooms.size} rooms")
coroutineScope {
val semaphore = Semaphore(MAX_CONCURRENT_CATCH_UPS)
cappedRooms.forEach { room ->
launch {
semaphore.withPermit {
val target = ChatMessageSyncer.SyncTarget(
user = user,
roomToken = room.token,
threadId = null,
credentials = credentials,
urlForChatting = ApiUtils.getUrlForChat(CHAT_API_VERSION, user.baseUrl!!, room.token)
)
runCatching { chatMessageSyncer.catchUpRoom(target) }
.onFailure { Log.e(TAG, "Message catch-up failed for room ${room.token}", it) }
}
}
}
}
}

private fun isCatchUpAllowed(user: User): Boolean =
when {
!user.hasSpreedFeatureCapability(SpreedFeatures.CHAT_KEEP_NOTIFICATIONS.value) -> {
Log.d(TAG, "Server lacks ${SpreedFeatures.CHAT_KEEP_NOTIFICATIONS.value}, skipping message catch-up")
false
}

isPowerSaveMode() -> {
Log.d(TAG, "Battery saver is active, skipping message catch-up")
false
}

isBackgroundDataRestricted() -> {
Log.d(TAG, "Background data is restricted on a metered network, skipping message catch-up")
false
}

else -> true
}

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

private fun isBackgroundDataRestricted(): Boolean {
val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
return connectivityManager.isActiveNetworkMetered &&
connectivityManager.restrictBackgroundStatus == ConnectivityManager.RESTRICT_BACKGROUND_STATUS_ENABLED
}

private suspend fun deleteLeftConversations(user: User, conversationsFromSync: List<ConversationEntity>) {
val conversationsFromSyncIds = conversationsFromSync.map { it.internalId }.toSet()
val oldConversationsFromDb = dao.getConversationsForUser(user.id!!).first()

if (conversationsFromSync.isEmpty() && oldConversationsFromDb.isNotEmpty()) {
// A sync that suddenly contains no conversations at all is most likely a broken or
// partial server response. Deleting the local conversations in that case would also
// wipe their cached chat messages and chat blocks via foreign key cascade, destroying
// the offline cache. Skip and let a later successful sync reconcile.
Log.w(
TAG,
"Sync returned no conversations while ${oldConversationsFromDb.size} exist locally, " +
"skipping deletion of left conversations"
)
return
}

val conversationsFromSyncIds = conversationsFromSync.map { it.internalId }.toSet()

val conversationIdsToDelete = oldConversationsFromDb
.map { it.internalId }
.filterNot { it in conversationsFromSyncIds }
Expand All @@ -193,5 +316,8 @@ class OfflineFirstConversationsRepository @Inject constructor(

companion object {
val TAG = OfflineFirstConversationsRepository::class.simpleName
private const val CHAT_API_VERSION = 1
private const val MAX_ROOMS_TO_CATCH_UP = 20
private const val MAX_CONCURRENT_CATCH_UPS = 3
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import com.nextcloud.talk.account.data.network.NetworkLoginDataSource
import com.nextcloud.talk.api.NcApi
import com.nextcloud.talk.api.NcApiCoroutines
import com.nextcloud.talk.chat.data.ChatMessageRepository
import com.nextcloud.talk.chat.data.network.ChatMessageSyncer
import com.nextcloud.talk.chat.data.network.ChatNetworkDataSource
import com.nextcloud.talk.chat.data.network.OfflineFirstChatRepository
import com.nextcloud.talk.logger.Logger
Expand Down Expand Up @@ -140,34 +141,57 @@ class RepositoryModule {
InvitationsRepositoryImpl(ncApi, ncApiCoroutines)

@Provides
@Singleton
fun provideChatMessageSyncer(
chatMessagesDao: ChatMessagesDao,
chatBlocksDao: ChatBlocksDao,
dataSource: ChatNetworkDataSource,
networkMonitor: NetworkMonitor
): ChatMessageSyncer =
ChatMessageSyncer(
chatMessagesDao,
chatBlocksDao,
dataSource,
networkMonitor
)

@Provides
@Suppress("LongParameterList")
fun provideOfflineFirstChatRepository(
logger: Logger,
chatMessagesDao: ChatMessagesDao,
chatBlocksDao: ChatBlocksDao,
dataSource: ChatNetworkDataSource,
networkMonitor: NetworkMonitor
networkMonitor: NetworkMonitor,
syncer: ChatMessageSyncer
): ChatMessageRepository =
OfflineFirstChatRepository(
logger,
chatMessagesDao,
chatBlocksDao,
dataSource,
networkMonitor
networkMonitor,
syncer
)

@Provides
@Singleton
@Suppress("LongParameterList")
fun provideOfflineFirstConversationsRepository(
dao: ConversationsDao,
dataSource: ConversationsNetworkDataSource,
chatNetworkDataSource: ChatNetworkDataSource,
networkMonitor: NetworkMonitor
networkMonitor: NetworkMonitor,
chatMessageSyncer: ChatMessageSyncer,
context: Context
): OfflineConversationsRepository =
OfflineFirstConversationsRepository(
dao,
dataSource,
chatNetworkDataSource,
networkMonitor
networkMonitor,
chatMessageSyncer,
context
)

@Provides
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,4 +121,14 @@ interface ChatBlocksDao {
"""
)
fun getLatestChatBlock(internalConversationId: String, threadId: Long?): Flow<ChatBlockEntity?>

@Query(
"""
SELECT *
FROM ChatBlocks
WHERE internalConversationId = :internalConversationId
ORDER BY newestMessageId ASC
"""
)
suspend fun getChatBlocksForConversation(internalConversationId: String): List<ChatBlockEntity>
}
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,40 @@ interface ChatMessagesDao {
)
suspend fun deleteExpiredMessages(internalConversationId: String, currentTimeSecs: Long): Int

@Query(
"""
SELECT MIN(id)
FROM ChatMessages
WHERE internalConversationId = :internalConversationId
AND isTemporary = 0
AND (:threadId IS NULL OR threadId = :threadId)
AND id BETWEEN :oldestMessageId AND :newestMessageId
"""
)
suspend fun getOldestMessageIdInRange(
internalConversationId: String,
threadId: Long?,
oldestMessageId: Long,
newestMessageId: Long
): Long?

@Query(
"""
SELECT MAX(id)
FROM ChatMessages
WHERE internalConversationId = :internalConversationId
AND isTemporary = 0
AND (:threadId IS NULL OR threadId = :threadId)
AND id BETWEEN :oldestMessageId AND :newestMessageId
"""
)
suspend fun getNewestMessageIdInRange(
internalConversationId: String,
threadId: Long?,
oldestMessageId: Long,
newestMessageId: Long
): Long?

@Query(
"""
DELETE FROM chatmessages
Expand Down
Loading
Loading