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
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.graphics.drawable.IconCompat
import androidx.core.net.toUri
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.repeatOnLifecycle
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.work.Data
import androidx.work.OneTimeWorkRequest
Expand Down Expand Up @@ -114,9 +116,11 @@ import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_SHARED_TEXT
import com.nextcloud.talk.utils.permissions.PlatformPermissionUtil
import com.nextcloud.talk.utils.power.PowerManagerUtils
import com.nextcloud.talk.utils.singletons.ApplicationWideCurrentRoomHolder
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import org.greenrobot.eventbus.Subscribe
Expand Down Expand Up @@ -207,6 +211,7 @@ class ConversationsListActivity : BaseActivity() {

conversationsListViewModel = ViewModelProvider(this, viewModelFactory)[ConversationsListViewModel::class.java]
conversationTagsViewModel = ViewModelProvider(this, viewModelFactory)[ConversationTagsViewModel::class.java]
startForegroundRefreshLoop()

setSupportActionBar(null)
forwardMessageState.value = intent.getBooleanExtra(KEY_FORWARD_MSG_FLAG, false)
Expand Down Expand Up @@ -670,6 +675,22 @@ class ConversationsListActivity : BaseActivity() {
lifecycleScope.launch { snackbarHostState.showSnackbar(text) }
}

/**
* Starts a loop that refreshes the conversation list every
* [FOREGROUND_REFRESH_INTERVAL_MILLIS] milliseconds while this screen is resumed, waiting one
* interval before the first refresh.
*/
private fun startForegroundRefreshLoop() {
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.RESUMED) {
while (isActive) {
delay(FOREGROUND_REFRESH_INTERVAL_MILLIS)
currentUser?.let { conversationsListViewModel.refreshRoomsIfIdle(it) }
}
}
}
}

fun fetchRooms(forceFullSync: Boolean = false) {
conversationsListViewModel.getRooms(currentUser!!, forceFullSync)
}
Expand Down Expand Up @@ -1574,6 +1595,8 @@ class ConversationsListActivity : BaseActivity() {

companion object {
private val TAG = ConversationsListActivity::class.java.simpleName

private const val FOREGROUND_REFRESH_INTERVAL_MILLIS = 30_000L
const val BOTTOM_SHEET_DELAY: Long = 2500
const val SEARCH_DEBOUNCE_INTERVAL_MS = 300
const val HTTP_UNAUTHORIZED = 401
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,26 +42,30 @@ interface OfflineConversationsRepository {
* the server (when online). The synced changes surface through [roomListFlow], which
* observes the database.
*
* The sync asks the server only for what changed since the last one where it can. Set
* [forceFullSync] for the cases where that is not good enough and the whole list is wanted
* back - a pull to refresh, where a conversation the user left elsewhere should be gone by the
* time the indicator stops spinning, rather than within the next five minutes.
* The sync asks the server only for the conversations changed since the last one where it
* can. [forceFullSync] makes it fetch the whole list instead.
*/
@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.
* Synchronizes [user]'s conversations with the server, suspending until that sync and the
* message catch-up it triggers have finished, and returns whether the sync succeeded.
*
* [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.
* Unlike [getRooms] this neither returns early nor changes which account [roomListFlow]
* observes.
*/
suspend fun syncRooms(user: User, forceFullSync: Boolean = false): Boolean

/**
* Whether a sync for [user] right now would either ask only for the conversations that changed,
* or be the full refresh that falls due every five minutes.
*
* False means the next sync would fetch the whole conversation list without the cadence calling
* for it.
*/
suspend fun isPeriodicSyncDue(user: User): 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 @@ -11,7 +11,6 @@ package com.nextcloud.talk.conversationlist.data.network
import android.content.Context
import android.database.sqlite.SQLiteConstraintException
import android.net.ConnectivityManager
import android.os.PowerManager
import android.util.Log
import com.nextcloud.talk.arbitrarystorage.ArbitraryStorageManager
import com.nextcloud.talk.chat.data.network.ChatMessageSyncer
Expand All @@ -23,6 +22,7 @@ import com.nextcloud.talk.data.database.mappers.toDomainModel
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.extensions.isPowerSaveMode
import com.nextcloud.talk.logger.Logger
import com.nextcloud.talk.models.domain.ConversationModel
import com.nextcloud.talk.utils.ApiUtils
Expand All @@ -48,6 +48,7 @@ import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext
import javax.inject.Inject
import kotlin.collections.map

Expand Down Expand Up @@ -242,31 +243,38 @@ class OfflineFirstConversationsRepository @Inject constructor(
}

/**
* The value to send as `modifiedSince`, or null when this sync has to be a full one.
* The stored timestamp to send as `modifiedSince`, or null when the sync has to fetch the whole
* list.
*
* A filtered response cannot express a removal, so the server asks clients to refresh in full
* regularly (`docs/conversation.md`): at least every five minutes, and always when the internal
* signaling backend is in use, since there is no signaling server to announce a change out of
* band. On top of that a caller can demand one, for a pull to refresh or an account switch.
* Null is returned when [forceFullSync] is set, when the account uses the internal signaling
* backend, when the last full sync is older than [FULL_SYNC_INTERVAL_MILLIS], and when no
* timestamp is stored.
*/
private fun modifiedSinceFor(user: User, forceFullSync: Boolean): Long? {
val accountId = user.id!!
val lastFullSyncAt = readTimestamp(accountId, KEY_LAST_FULL_SYNC_AT)
val fullSyncIsRecent = lastFullSyncAt != null &&
System.currentTimeMillis() - lastFullSyncAt in 0 until FULL_SYNC_INTERVAL_MILLIS
val usesExternalSignaling = !user.externalSignalingServer?.externalSignalingServer.isNullOrEmpty()

return if (!forceFullSync && usesExternalSignaling && fullSyncIsRecent) {
return if (!forceFullSync && usesExternalSignaling && fullSyncIsRecent(accountId)) {
readTimestamp(accountId, KEY_MODIFIED_SINCE)
} else {
null
}
}

private fun fullSyncIsRecent(accountId: Long): Boolean {
val lastFullSyncAt = readTimestamp(accountId, KEY_LAST_FULL_SYNC_AT) ?: return false
return System.currentTimeMillis() - lastFullSyncAt in 0 until FULL_SYNC_INTERVAL_MILLIS
}

override suspend fun isPeriodicSyncDue(user: User): Boolean =
withContext(Dispatchers.IO) {
modifiedSinceFor(user, forceFullSync = false) != null || !fullSyncIsRecent(user.id!!)
}

/**
* Stores what the next sync needs, and only once the response is safely in the database: a
* timestamp kept ahead of a write that then failed would permanently skip the conversations
* that write was carrying.
* Stores the timestamp [roomList] reported for the next request, and, when it was a full
* response, the time of this full sync. Called after the response has been written to the
* database.
*/
private fun rememberSyncedState(accountId: Long, roomList: RoomListResult) {
storeTimestamp(accountId, KEY_MODIFIED_SINCE, roomList.modifiedBefore)
Expand All @@ -275,7 +283,7 @@ class OfflineFirstConversationsRepository @Inject constructor(
}
}

/** Null for anything that is not a plausible timestamp, so a bad value asks for a full sync. */
/** The timestamp stored under [key] for [accountId], or null when none is stored or it is not a positive number. */
private fun readTimestamp(accountId: Long, key: String): Long? =
arbitraryStorageManager.getStorageSetting(accountId, key, "")
.blockingGet()
Expand Down Expand Up @@ -364,7 +372,7 @@ class OfflineFirstConversationsRepository @Inject constructor(
false
}

isPowerSaveMode() -> {
context.isPowerSaveMode() -> {
Log.d(TAG, "Battery saver is active, skipping message catch-up")
false
}
Expand All @@ -377,11 +385,6 @@ class OfflineFirstConversationsRepository @Inject constructor(
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 &&
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -563,6 +563,25 @@ class ConversationsListViewModel @Inject constructor(
}
}

/**
* Refreshes the conversation list, unless a sync is already running, a search is open, or the
* sync would fetch the whole list without the refresh cadence calling for it.
*/
suspend fun refreshRoomsIfIdle(user: User) {
when {
_isLoadingRooms.value ->
Log.d(TAG, "Foreground refresh skipped: a sync is already in flight")

_isSearchActiveFlow.value ->
Log.d(TAG, "Foreground refresh skipped: a search is active")

!repository.isPeriodicSyncDue(user) ->
Log.d(TAG, "Foreground refresh skipped: no delta available and no full sync due")

else -> getRooms(user)
}
}

fun getRooms(user: User, forceFullSync: Boolean = false) {
val startNanoTime = System.nanoTime()
Log.d(TAG, "fetchData - getRooms - calling: $startNanoTime")
Expand Down
Loading