Load contents by chunks instead of loading all of them at once

This commit is contained in:
Sylvain Berfini 2025-09-16 17:15:56 +02:00
parent 31e15ddfca
commit ae7a3c5bce
7 changed files with 200 additions and 41 deletions

View file

@ -33,6 +33,7 @@ Group changes to describe their impact on the project, as follows:
### Changed ### Changed
- Hide SIP address/phone number picker dialog if contact has exactly one SIP address matching both the app default domain & the currently selected account domain - Hide SIP address/phone number picker dialog if contact has exactly one SIP address matching both the app default domain & the currently selected account domain
- Improved UI on tablets with screen sw600dp and higher, will look more like our desktop app - Improved UI on tablets with screen sw600dp and higher, will look more like our desktop app
- Now loading media/documents contents in conversation by chunks (instead of all of them at once)
- Simplified audio device name in settings - Simplified audio device name in settings
- Reworked some settings (moved calls related ones from advanced settings to advanced calls settings) - Reworked some settings (moved calls related ones from advanced settings to advanced calls settings)
- Increased shared media preview size in chat - Increased shared media preview size in chat

View file

@ -1,5 +1,5 @@
/* /*
* Copyright (c) 2010-2020 Belledonne Communications SARL. * Copyright (c) 2010-2025 Belledonne Communications SARL.
* *
* This file is part of linphone-android * This file is part of linphone-android
* (see https://www.linphone.org). * (see https://www.linphone.org).
@ -21,13 +21,12 @@ package org.linphone.ui.main.chat
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import org.linphone.core.tools.Log
internal abstract class ConversationScrollListener(private val mLayoutManager: LinearLayoutManager) : internal abstract class RecyclerViewScrollListener(private val layoutManager: LinearLayoutManager, private val visibleThreshold: Int, private val scrollingTopToBottom: Boolean) :
RecyclerView.OnScrollListener() { RecyclerView.OnScrollListener() {
companion object { companion object {
// The minimum amount of items to have below your current scroll position private const val TAG = "[RecyclerView Scroll Listener]"
// before loading more.
private const val VISIBLE_THRESHOLD = 5
} }
// The total number of items in the data set after the last load // The total number of items in the data set after the last load
@ -40,9 +39,9 @@ internal abstract class ConversationScrollListener(private val mLayoutManager: L
// We are given a few useful parameters to help us work out if we need to load some more data, // We are given a few useful parameters to help us work out if we need to load some more data,
// but first we check if we are waiting for the previous load to finish. // but first we check if we are waiting for the previous load to finish.
override fun onScrolled(view: RecyclerView, dx: Int, dy: Int) { override fun onScrolled(view: RecyclerView, dx: Int, dy: Int) {
val totalItemCount = mLayoutManager.itemCount val totalItemCount = layoutManager.itemCount
val firstVisibleItemPosition: Int = mLayoutManager.findFirstVisibleItemPosition() val firstVisibleItemPosition: Int = layoutManager.findFirstVisibleItemPosition()
val lastVisibleItemPosition: Int = mLayoutManager.findLastVisibleItemPosition() val lastVisibleItemPosition: Int = layoutManager.findLastVisibleItemPosition()
// If the total item count is zero and the previous isn't, assume the // If the total item count is zero and the previous isn't, assume the
// list is invalidated and should be reset back to initial state // list is invalidated and should be reset back to initial state
@ -64,21 +63,34 @@ internal abstract class ConversationScrollListener(private val mLayoutManager: L
val userHasScrolledUp = lastVisibleItemPosition != totalItemCount - 1 val userHasScrolledUp = lastVisibleItemPosition != totalItemCount - 1
if (userHasScrolledUp) { if (userHasScrolledUp) {
onScrolledUp() onScrolledUp()
Log.d("$TAG Scrolled up")
} else { } else {
onScrolledToEnd() onScrolledToEnd()
Log.d("$TAG Scrolled to end")
} }
// If it isnt currently loading, we check to see if we have breached // If it isnt currently loading, we check to see if we have breached
// the mVisibleThreshold and need to reload more data. // the visibleThreshold and need to reload more data.
// If we do need to reload some more data, we execute onLoadMore to fetch the data. // If we do need to reload some more data, we execute onLoadMore to fetch the data.
// threshold should reflect how many total columns there are too // threshold should reflect how many total columns there are too
if (!loading && if (!loading) {
firstVisibleItemPosition < VISIBLE_THRESHOLD && if (scrollingTopToBottom) {
firstVisibleItemPosition >= 0 && if (lastVisibleItemPosition >= totalItemCount - visibleThreshold) {
lastVisibleItemPosition < totalItemCount - VISIBLE_THRESHOLD Log.d(
) { "$TAG Last visible item position [$lastVisibleItemPosition] reached [${totalItemCount - visibleThreshold}], loading more (current total items is [$totalItemCount])"
onLoadMore(totalItemCount) )
loading = true loading = true
onLoadMore(totalItemCount)
}
} else {
if (firstVisibleItemPosition < visibleThreshold) {
Log.d(
"$TAG First visible item position [$firstVisibleItemPosition] < visibleThreshold [$visibleThreshold], loading more (current total items is [$totalItemCount])"
)
loading = true
onLoadMore(totalItemCount)
}
}
} }
} }

View file

@ -33,6 +33,7 @@ import androidx.navigation.fragment.navArgs
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import org.linphone.core.tools.Log import org.linphone.core.tools.Log
import org.linphone.databinding.ChatDocumentsFragmentBinding import org.linphone.databinding.ChatDocumentsFragmentBinding
import org.linphone.ui.main.chat.RecyclerViewScrollListener
import org.linphone.ui.main.chat.adapter.ConversationsFilesAdapter import org.linphone.ui.main.chat.adapter.ConversationsFilesAdapter
import org.linphone.ui.main.chat.model.FileModel import org.linphone.ui.main.chat.model.FileModel
import org.linphone.ui.main.chat.viewmodel.ConversationDocumentsListViewModel import org.linphone.ui.main.chat.viewmodel.ConversationDocumentsListViewModel
@ -57,6 +58,8 @@ class ConversationDocumentsListFragment : SlidingPaneChildFragment() {
private val args: ConversationMediaListFragmentArgs by navArgs() private val args: ConversationMediaListFragmentArgs by navArgs()
private lateinit var scrollListener: RecyclerViewScrollListener
override fun goBack(): Boolean { override fun goBack(): Boolean {
try { try {
return findNavController().popBackStack() return findNavController().popBackStack()
@ -130,6 +133,40 @@ class ConversationDocumentsListFragment : SlidingPaneChildFragment() {
goToFileViewer(model) goToFileViewer(model)
} }
} }
scrollListener = object : RecyclerViewScrollListener(layoutManager, 4, true) {
@UiThread
override fun onLoadMore(totalItemsCount: Int) {
Log.i("$TAG Asking for more data to display, currently displayed items count is [$totalItemsCount]")
viewModel.loadMoreData(totalItemsCount)
}
@UiThread
override fun onScrolledUp() {
}
@UiThread
override fun onScrolledToEnd() {
}
}
}
override fun onResume() {
super.onResume()
if (::scrollListener.isInitialized) {
binding.documentsList.addOnScrollListener(scrollListener)
}
}
override fun onPause() {
super.onPause()
if (::scrollListener.isInitialized) {
binding.documentsList.removeOnScrollListener(scrollListener)
}
} }
private fun goToFileViewer(fileModel: FileModel) { private fun goToFileViewer(fileModel: FileModel) {

View file

@ -69,7 +69,7 @@ import org.linphone.core.tools.Log
import org.linphone.databinding.ChatConversationFragmentBinding import org.linphone.databinding.ChatConversationFragmentBinding
import org.linphone.databinding.ChatConversationPopupMenuBinding import org.linphone.databinding.ChatConversationPopupMenuBinding
import org.linphone.ui.GenericActivity import org.linphone.ui.GenericActivity
import org.linphone.ui.main.chat.ConversationScrollListener import org.linphone.ui.main.chat.RecyclerViewScrollListener
import org.linphone.ui.main.chat.adapter.ConversationEventAdapter import org.linphone.ui.main.chat.adapter.ConversationEventAdapter
import org.linphone.ui.main.chat.adapter.MessageBottomSheetAdapter import org.linphone.ui.main.chat.adapter.MessageBottomSheetAdapter
import org.linphone.ui.main.chat.model.FileModel import org.linphone.ui.main.chat.model.FileModel
@ -298,7 +298,7 @@ open class ConversationFragment : SlidingPaneChildFragment() {
} }
} }
private lateinit var scrollListener: ConversationScrollListener private lateinit var scrollListener: RecyclerViewScrollListener
private lateinit var headerItemDecoration: RecyclerViewHeaderDecoration private lateinit var headerItemDecoration: RecyclerViewHeaderDecoration
@ -1005,7 +1005,7 @@ open class ConversationFragment : SlidingPaneChildFragment() {
binding.sendArea.messageToSend.addTextChangedListener(textObserver) binding.sendArea.messageToSend.addTextChangedListener(textObserver)
scrollListener = object : ConversationScrollListener(layoutManager) { scrollListener = object : RecyclerViewScrollListener(layoutManager, 5, false) {
@UiThread @UiThread
override fun onLoadMore(totalItemsCount: Int) { override fun onLoadMore(totalItemsCount: Int) {
if (viewModel.searchInProgress.value == false) { if (viewModel.searchInProgress.value == false) {

View file

@ -36,6 +36,7 @@ import org.linphone.R
import org.linphone.core.tools.Log import org.linphone.core.tools.Log
import org.linphone.databinding.ChatMediaFragmentBinding import org.linphone.databinding.ChatMediaFragmentBinding
import org.linphone.ui.GenericActivity import org.linphone.ui.GenericActivity
import org.linphone.ui.main.chat.RecyclerViewScrollListener
import org.linphone.ui.main.chat.adapter.ConversationsFilesAdapter import org.linphone.ui.main.chat.adapter.ConversationsFilesAdapter
import org.linphone.ui.main.chat.model.FileModel import org.linphone.ui.main.chat.model.FileModel
import org.linphone.ui.main.chat.viewmodel.ConversationMediaListViewModel import org.linphone.ui.main.chat.viewmodel.ConversationMediaListViewModel
@ -58,6 +59,8 @@ class ConversationMediaListFragment : SlidingPaneChildFragment() {
private val args: ConversationMediaListFragmentArgs by navArgs() private val args: ConversationMediaListFragmentArgs by navArgs()
private lateinit var scrollListener: RecyclerViewScrollListener
override fun goBack(): Boolean { override fun goBack(): Boolean {
try { try {
return findNavController().popBackStack() return findNavController().popBackStack()
@ -159,6 +162,40 @@ class ConversationMediaListFragment : SlidingPaneChildFragment() {
goToFileViewer(model) goToFileViewer(model)
} }
} }
scrollListener = object : RecyclerViewScrollListener(layoutManager, spanCount, true) {
@UiThread
override fun onLoadMore(totalItemsCount: Int) {
Log.i("$TAG Asking for more data to display, currently displayed items count is [$totalItemsCount]")
viewModel.loadMoreData(totalItemsCount)
}
@UiThread
override fun onScrolledUp() {
}
@UiThread
override fun onScrolledToEnd() {
}
}
}
override fun onResume() {
super.onResume()
if (::scrollListener.isInitialized) {
binding.mediaList.addOnScrollListener(scrollListener)
}
}
override fun onPause() {
super.onPause()
if (::scrollListener.isInitialized) {
binding.mediaList.removeOnScrollListener(scrollListener)
}
} }
private fun goToFileViewer(fileModel: FileModel) { private fun goToFileViewer(fileModel: FileModel) {

View file

@ -22,16 +22,21 @@ package org.linphone.ui.main.chat.viewmodel
import androidx.annotation.UiThread import androidx.annotation.UiThread
import androidx.annotation.WorkerThread import androidx.annotation.WorkerThread
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import org.linphone.LinphoneApplication.Companion.coreContext
import org.linphone.core.Content
import org.linphone.core.tools.Log import org.linphone.core.tools.Log
import org.linphone.ui.main.chat.model.FileModel import org.linphone.ui.main.chat.model.FileModel
import org.linphone.utils.Event import org.linphone.utils.Event
import org.linphone.utils.LinphoneUtils import org.linphone.utils.LinphoneUtils
import kotlin.math.min
class ConversationDocumentsListViewModel class ConversationDocumentsListViewModel
@UiThread @UiThread
constructor() : AbstractConversationViewModel() { constructor() : AbstractConversationViewModel() {
companion object { companion object {
private const val TAG = "[Conversation Documents List ViewModel]" private const val TAG = "[Conversation Documents List ViewModel]"
private const val CONTENTS_PER_PAGE = 20
} }
val documentsList = MutableLiveData<List<FileModel>>() val documentsList = MutableLiveData<List<FileModel>>()
@ -42,6 +47,8 @@ class ConversationDocumentsListViewModel
MutableLiveData<Event<FileModel>>() MutableLiveData<Event<FileModel>>()
} }
private var totalDocumentsCount: Int = -1
@WorkerThread @WorkerThread
override fun afterNotifyingChatRoomFound(sameOne: Boolean) { override fun afterNotifyingChatRoomFound(sameOne: Boolean) {
loadDocumentsList() loadDocumentsList()
@ -56,16 +63,48 @@ class ConversationDocumentsListViewModel
@WorkerThread @WorkerThread
private fun loadDocumentsList() { private fun loadDocumentsList() {
operationInProgress.postValue(true) operationInProgress.postValue(true)
val list = arrayListOf<FileModel>()
Log.i( Log.i(
"$TAG Loading document contents for conversation [${LinphoneUtils.getConversationId( "$TAG Loading document contents for conversation [${LinphoneUtils.getConversationId(
chatRoom chatRoom
)}]" )}]"
) )
val documents = chatRoom.documentContents
Log.i("$TAG [${documents.size}] documents have been fetched") totalDocumentsCount = chatRoom.documentContentsSize
for (documentContent in documents) { Log.i("$TAG Document contents size is [$totalDocumentsCount]")
val contentsToLoad = min(totalDocumentsCount, CONTENTS_PER_PAGE)
val contents = chatRoom.getDocumentContentsRange(0, contentsToLoad)
Log.i("$TAG [${contents.size}] documents have been fetched")
documentsList.postValue(getFileModelsListFromContents(contents))
operationInProgress.postValue(false)
}
@UiThread
fun loadMoreData(totalItemsCount: Int) {
coreContext.postOnCoreThread {
Log.i("$TAG Loading more data, current total is $totalItemsCount, max size is $totalDocumentsCount")
if (totalItemsCount < totalDocumentsCount) {
var upperBound: Int = totalItemsCount + CONTENTS_PER_PAGE
if (upperBound > totalDocumentsCount) {
upperBound = totalDocumentsCount
}
val contents = chatRoom.getDocumentContentsRange(totalItemsCount, upperBound)
Log.i("$TAG [${contents.size}] contents loaded, adding them to list")
val list = arrayListOf<FileModel>()
list.addAll(documentsList.value.orEmpty())
list.addAll(getFileModelsListFromContents(contents))
documentsList.postValue(list)
}
}
}
@WorkerThread
private fun getFileModelsListFromContents(contents: Array<Content>): ArrayList<FileModel> {
val list = arrayListOf<FileModel>()
for (documentContent in contents) {
val isEncrypted = documentContent.isFileEncrypted val isEncrypted = documentContent.isFileEncrypted
val originalPath = documentContent.filePath.orEmpty() val originalPath = documentContent.filePath.orEmpty()
val path = if (isEncrypted) { val path = if (isEncrypted) {
@ -94,14 +133,11 @@ class ConversationDocumentsListViewModel
val model = val model =
FileModel(path, name, size, timestamp, isEncrypted, originalPath, ephemeral) { FileModel(path, name, size, timestamp, isEncrypted, originalPath, ephemeral) {
openDocumentEvent.postValue(Event(it)) openDocumentEvent.postValue(Event(it))
} }
list.add(model) list.add(model)
} }
} }
return list
Log.i("$TAG [${documents.size}] documents have been processed")
documentsList.postValue(list)
operationInProgress.postValue(false)
} }
} }

View file

@ -22,16 +22,21 @@ package org.linphone.ui.main.chat.viewmodel
import androidx.annotation.UiThread import androidx.annotation.UiThread
import androidx.annotation.WorkerThread import androidx.annotation.WorkerThread
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import org.linphone.LinphoneApplication.Companion.coreContext
import org.linphone.core.Content
import org.linphone.core.tools.Log import org.linphone.core.tools.Log
import org.linphone.ui.main.chat.model.FileModel import org.linphone.ui.main.chat.model.FileModel
import org.linphone.utils.Event import org.linphone.utils.Event
import org.linphone.utils.LinphoneUtils import org.linphone.utils.LinphoneUtils
import kotlin.math.min
class ConversationMediaListViewModel class ConversationMediaListViewModel
@UiThread @UiThread
constructor() : AbstractConversationViewModel() { constructor() : AbstractConversationViewModel() {
companion object { companion object {
private const val TAG = "[Conversation Media List ViewModel]" private const val TAG = "[Conversation Media List ViewModel]"
private const val CONTENTS_PER_PAGE = 50
} }
val mediaList = MutableLiveData<List<FileModel>>() val mediaList = MutableLiveData<List<FileModel>>()
@ -42,6 +47,8 @@ class ConversationMediaListViewModel
MutableLiveData<Event<FileModel>>() MutableLiveData<Event<FileModel>>()
} }
private var totalMediaCount: Int = -1
@WorkerThread @WorkerThread
override fun afterNotifyingChatRoomFound(sameOne: Boolean) { override fun afterNotifyingChatRoomFound(sameOne: Boolean) {
loadMediaList() loadMediaList()
@ -56,16 +63,48 @@ class ConversationMediaListViewModel
@WorkerThread @WorkerThread
private fun loadMediaList() { private fun loadMediaList() {
operationInProgress.postValue(true) operationInProgress.postValue(true)
val list = arrayListOf<FileModel>()
Log.i( Log.i(
"$TAG Loading media contents for conversation [${LinphoneUtils.getConversationId( "$TAG Loading media contents for conversation [${LinphoneUtils.getConversationId(
chatRoom chatRoom
)}]" )}]"
) )
val media = chatRoom.mediaContents
Log.i("$TAG [${media.size}] media have been fetched") totalMediaCount = chatRoom.mediaContentsSize
for (mediaContent in media) { Log.i("$TAG Media contents size is [$totalMediaCount]")
val contentsToLoad = min(totalMediaCount, CONTENTS_PER_PAGE)
val contents = chatRoom.getMediaContentsRange(0, contentsToLoad)
Log.i("$TAG [${contents.size}] media have been fetched")
mediaList.postValue(getFileModelsListFromContents(contents))
operationInProgress.postValue(false)
}
@UiThread
fun loadMoreData(totalItemsCount: Int) {
coreContext.postOnCoreThread {
Log.i("$TAG Loading more data, current total is $totalItemsCount, max size is $totalMediaCount")
if (totalItemsCount < totalMediaCount) {
var upperBound: Int = totalItemsCount + CONTENTS_PER_PAGE
if (upperBound > totalMediaCount) {
upperBound = totalMediaCount
}
val contents = chatRoom.getMediaContentsRange(totalItemsCount, upperBound)
Log.i("$TAG [${contents.size}] contents loaded, adding them to list")
val list = arrayListOf<FileModel>()
list.addAll(mediaList.value.orEmpty())
list.addAll(getFileModelsListFromContents(contents))
mediaList.postValue(list)
}
}
}
@WorkerThread
private fun getFileModelsListFromContents(contents: Array<Content>): ArrayList<FileModel> {
val list = arrayListOf<FileModel>()
for (mediaContent in contents) {
// Do not display voice recordings here, even if they are media file // Do not display voice recordings here, even if they are media file
if (mediaContent.isVoiceRecording) continue if (mediaContent.isVoiceRecording) continue
@ -85,14 +124,11 @@ class ConversationMediaListViewModel
if (path.isNotEmpty() && name.isNotEmpty()) { if (path.isNotEmpty() && name.isNotEmpty()) {
val model = val model =
FileModel(path, name, size, timestamp, isEncrypted, originalPath, chatRoom.isEphemeralEnabled) { FileModel(path, name, size, timestamp, isEncrypted, originalPath, chatRoom.isEphemeralEnabled) {
openMediaEvent.postValue(Event(it)) openMediaEvent.postValue(Event(it))
} }
list.add(model) list.add(model)
} }
} }
return list
Log.i("$TAG [${media.size}] media have been processed")
mediaList.postValue(list)
operationInProgress.postValue(false)
} }
} }