RoomList: use same logic than Timeline for caching built items. (#1013)
* RoomList: use same logic than Timeline for caching built items. Extract into reusable components. * RoomList: fix tests * Fix `DiffCacheUpdater` docs --------- Co-authored-by: ganfra <francoisg@element.io> Co-authored-by: Jorge Martín <jorgem@element.io>
This commit is contained in:
parent
89b1bba96e
commit
e453b984ef
11 changed files with 373 additions and 142 deletions
|
|
@ -52,7 +52,6 @@ dependencies {
|
||||||
implementation(libs.coil.compose)
|
implementation(libs.coil.compose)
|
||||||
implementation(libs.datetime)
|
implementation(libs.datetime)
|
||||||
implementation(libs.accompanist.flowlayout)
|
implementation(libs.accompanist.flowlayout)
|
||||||
implementation(libs.androidx.recyclerview)
|
|
||||||
implementation(libs.jsoup)
|
implementation(libs.jsoup)
|
||||||
implementation(libs.androidx.constraintlayout)
|
implementation(libs.androidx.constraintlayout)
|
||||||
implementation(libs.androidx.constraintlayout.compose)
|
implementation(libs.androidx.constraintlayout.compose)
|
||||||
|
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
/*
|
|
||||||
* Copyright (c) 2023 New Vector Ltd
|
|
||||||
*
|
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
* you may not use this file except in compliance with the License.
|
|
||||||
* You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing, software
|
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
* See the License for the specific language governing permissions and
|
|
||||||
* limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package io.element.android.features.messages.impl.timeline.diff
|
|
||||||
|
|
||||||
import androidx.recyclerview.widget.ListUpdateCallback
|
|
||||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
|
||||||
import io.element.android.features.messages.impl.timeline.util.invalidateLast
|
|
||||||
import timber.log.Timber
|
|
||||||
|
|
||||||
internal class CacheInvalidator(private val itemStatesCache: MutableList<TimelineItem?>) :
|
|
||||||
ListUpdateCallback {
|
|
||||||
|
|
||||||
override fun onChanged(position: Int, count: Int, payload: Any?) {
|
|
||||||
Timber.d("onChanged(position= $position, count= $count)")
|
|
||||||
(position until position + count).forEach {
|
|
||||||
// Invalidate cache
|
|
||||||
itemStatesCache[it] = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onMoved(fromPosition: Int, toPosition: Int) {
|
|
||||||
Timber.d("onMoved(fromPosition= $fromPosition, toPosition= $toPosition)")
|
|
||||||
val model = itemStatesCache.removeAt(fromPosition)
|
|
||||||
itemStatesCache.add(toPosition, model)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onInserted(position: Int, count: Int) {
|
|
||||||
Timber.d("onInserted(position= $position, count= $count)")
|
|
||||||
itemStatesCache.invalidateLast()
|
|
||||||
repeat(count) {
|
|
||||||
itemStatesCache.add(position, null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onRemoved(position: Int, count: Int) {
|
|
||||||
Timber.d("onRemoved(position= $position, count= $count)")
|
|
||||||
itemStatesCache.invalidateLast()
|
|
||||||
repeat(count) {
|
|
||||||
itemStatesCache.removeAt(position)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,63 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.messages.impl.timeline.diff
|
||||||
|
|
||||||
|
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||||
|
import io.element.android.libraries.androidutils.diff.DefaultDiffCacheInvalidator
|
||||||
|
import io.element.android.libraries.androidutils.diff.DiffCacheInvalidator
|
||||||
|
import io.element.android.libraries.androidutils.diff.MutableDiffCache
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [DiffCacheInvalidator] implementation for [TimelineItem].
|
||||||
|
* It uses [DefaultDiffCacheInvalidator] and invalidate the cache around the updated item so that those items are computed again.
|
||||||
|
* This is needed because a timeline item is computed based on the previous and next items.
|
||||||
|
*/
|
||||||
|
internal class TimelineItemsCacheInvalidator : DiffCacheInvalidator<TimelineItem> {
|
||||||
|
|
||||||
|
private val delegate = DefaultDiffCacheInvalidator<TimelineItem>()
|
||||||
|
|
||||||
|
override fun onChanged(position: Int, count: Int, cache: MutableDiffCache<TimelineItem>) {
|
||||||
|
delegate.onChanged(position, count, cache)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMoved(fromPosition: Int, toPosition: Int, cache: MutableDiffCache<TimelineItem>) {
|
||||||
|
delegate.onMoved(fromPosition, toPosition, cache)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onInserted(position: Int, count: Int, cache: MutableDiffCache<TimelineItem>) {
|
||||||
|
cache.invalidateAround(position)
|
||||||
|
delegate.onInserted(position, count, cache)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onRemoved(position: Int, count: Int, cache: MutableDiffCache<TimelineItem>) {
|
||||||
|
cache.invalidateAround(position)
|
||||||
|
delegate.onRemoved(position, count, cache)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invalidate the cache around the given position.
|
||||||
|
* It invalidates the previous and next items.
|
||||||
|
*/
|
||||||
|
private fun MutableDiffCache<*>.invalidateAround(position: Int) {
|
||||||
|
if (position > 0) {
|
||||||
|
set(position - 1, null)
|
||||||
|
}
|
||||||
|
if (position < indices().last) {
|
||||||
|
set(position + 1, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -19,13 +19,13 @@ package io.element.android.features.messages.impl.timeline.factories
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.State
|
import androidx.compose.runtime.State
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.recyclerview.widget.DiffUtil
|
import io.element.android.features.messages.impl.timeline.diff.TimelineItemsCacheInvalidator
|
||||||
import io.element.android.features.messages.impl.timeline.diff.CacheInvalidator
|
|
||||||
import io.element.android.features.messages.impl.timeline.diff.MatrixTimelineItemsDiffCallback
|
|
||||||
import io.element.android.features.messages.impl.timeline.factories.event.TimelineItemEventFactory
|
import io.element.android.features.messages.impl.timeline.factories.event.TimelineItemEventFactory
|
||||||
import io.element.android.features.messages.impl.timeline.factories.virtual.TimelineItemVirtualFactory
|
import io.element.android.features.messages.impl.timeline.factories.virtual.TimelineItemVirtualFactory
|
||||||
import io.element.android.features.messages.impl.timeline.groups.TimelineItemGrouper
|
import io.element.android.features.messages.impl.timeline.groups.TimelineItemGrouper
|
||||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||||
|
import io.element.android.libraries.androidutils.diff.DiffCacheUpdater
|
||||||
|
import io.element.android.libraries.androidutils.diff.MutableListDiffCache
|
||||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
|
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
|
@ -35,9 +35,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import timber.log.Timber
|
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlin.system.measureTimeMillis
|
|
||||||
|
|
||||||
class TimelineItemsFactory @Inject constructor(
|
class TimelineItemsFactory @Inject constructor(
|
||||||
private val dispatchers: CoroutineDispatchers,
|
private val dispatchers: CoroutineDispatchers,
|
||||||
|
|
@ -46,13 +44,20 @@ class TimelineItemsFactory @Inject constructor(
|
||||||
private val timelineItemGrouper: TimelineItemGrouper,
|
private val timelineItemGrouper: TimelineItemGrouper,
|
||||||
) {
|
) {
|
||||||
private val timelineItems = MutableStateFlow(persistentListOf<TimelineItem>())
|
private val timelineItems = MutableStateFlow(persistentListOf<TimelineItem>())
|
||||||
private val timelineItemsCache = arrayListOf<TimelineItem?>()
|
|
||||||
|
|
||||||
// Items from rust sdk, used for diffing
|
|
||||||
private var matrixTimelineItems: List<MatrixTimelineItem> = emptyList()
|
|
||||||
|
|
||||||
private val lock = Mutex()
|
private val lock = Mutex()
|
||||||
private val cacheInvalidator = CacheInvalidator(timelineItemsCache)
|
private val diffCache = MutableListDiffCache<TimelineItem>()
|
||||||
|
private val diffCacheUpdater = DiffCacheUpdater<MatrixTimelineItem, TimelineItem>(
|
||||||
|
diffCache = diffCache,
|
||||||
|
detectMoves = false,
|
||||||
|
cacheInvalidator = TimelineItemsCacheInvalidator()
|
||||||
|
) { old, new ->
|
||||||
|
if (old is MatrixTimelineItem.Event && new is MatrixTimelineItem.Event) {
|
||||||
|
old.uniqueId == new.uniqueId
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun collectItemsAsState(): State<ImmutableList<TimelineItem>> {
|
fun collectItemsAsState(): State<ImmutableList<TimelineItem>> {
|
||||||
|
|
@ -63,15 +68,15 @@ class TimelineItemsFactory @Inject constructor(
|
||||||
timelineItems: List<MatrixTimelineItem>,
|
timelineItems: List<MatrixTimelineItem>,
|
||||||
) = withContext(dispatchers.computation) {
|
) = withContext(dispatchers.computation) {
|
||||||
lock.withLock {
|
lock.withLock {
|
||||||
calculateAndApplyDiff(timelineItems)
|
diffCacheUpdater.updateWith(timelineItems)
|
||||||
buildAndEmitTimelineItemStates(timelineItems)
|
buildAndEmitTimelineItemStates(timelineItems)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun buildAndEmitTimelineItemStates(timelineItems: List<MatrixTimelineItem>) {
|
private suspend fun buildAndEmitTimelineItemStates(timelineItems: List<MatrixTimelineItem>) {
|
||||||
val newTimelineItemStates = ArrayList<TimelineItem>()
|
val newTimelineItemStates = ArrayList<TimelineItem>()
|
||||||
for (index in timelineItemsCache.indices.reversed()) {
|
for (index in diffCache.indices().reversed()) {
|
||||||
val cacheItem = timelineItemsCache[index]
|
val cacheItem = diffCache.get(index)
|
||||||
if (cacheItem == null) {
|
if (cacheItem == null) {
|
||||||
buildAndCacheItem(timelineItems, index)?.also { timelineItemState ->
|
buildAndCacheItem(timelineItems, index)?.also { timelineItemState ->
|
||||||
newTimelineItemStates.add(timelineItemState)
|
newTimelineItemStates.add(timelineItemState)
|
||||||
|
|
@ -84,20 +89,6 @@ class TimelineItemsFactory @Inject constructor(
|
||||||
this.timelineItems.emit(result)
|
this.timelineItems.emit(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun calculateAndApplyDiff(newTimelineItems: List<MatrixTimelineItem>) {
|
|
||||||
val timeToDiff = measureTimeMillis {
|
|
||||||
val diffCallback =
|
|
||||||
MatrixTimelineItemsDiffCallback(
|
|
||||||
oldList = matrixTimelineItems,
|
|
||||||
newList = newTimelineItems
|
|
||||||
)
|
|
||||||
val diffResult = DiffUtil.calculateDiff(diffCallback, false)
|
|
||||||
matrixTimelineItems = newTimelineItems
|
|
||||||
diffResult.dispatchUpdatesTo(cacheInvalidator)
|
|
||||||
}
|
|
||||||
Timber.v("Time to apply diff on new list of ${newTimelineItems.size} items: $timeToDiff ms")
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun buildAndCacheItem(
|
private fun buildAndCacheItem(
|
||||||
timelineItems: List<MatrixTimelineItem>,
|
timelineItems: List<MatrixTimelineItem>,
|
||||||
index: Int
|
index: Int
|
||||||
|
|
@ -108,7 +99,7 @@ class TimelineItemsFactory @Inject constructor(
|
||||||
is MatrixTimelineItem.Virtual -> virtualItemFactory.create(currentTimelineItem)
|
is MatrixTimelineItem.Virtual -> virtualItemFactory.create(currentTimelineItem)
|
||||||
MatrixTimelineItem.Other -> null
|
MatrixTimelineItem.Other -> null
|
||||||
}
|
}
|
||||||
timelineItemsCache[index] = timelineItemState
|
diffCache[index] = timelineItemState
|
||||||
return timelineItemState
|
return timelineItemState
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,8 @@ package io.element.android.features.roomlist.impl.datasource
|
||||||
|
|
||||||
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
|
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
|
||||||
import io.element.android.features.roomlist.impl.model.RoomListRoomSummaryPlaceholders
|
import io.element.android.features.roomlist.impl.model.RoomListRoomSummaryPlaceholders
|
||||||
|
import io.element.android.libraries.androidutils.diff.DiffCacheUpdater
|
||||||
|
import io.element.android.libraries.androidutils.diff.MutableListDiffCache
|
||||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||||
import io.element.android.libraries.core.extensions.orEmpty
|
import io.element.android.libraries.core.extensions.orEmpty
|
||||||
import io.element.android.libraries.dateformatter.api.LastMessageTimestampFormatter
|
import io.element.android.libraries.dateformatter.api.LastMessageTimestampFormatter
|
||||||
|
|
@ -36,6 +38,8 @@ import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.launchIn
|
import kotlinx.coroutines.flow.launchIn
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
|
@ -50,15 +54,17 @@ class RoomListDataSource @Inject constructor(
|
||||||
private val _allRooms = MutableStateFlow<ImmutableList<RoomListRoomSummary>>(persistentListOf())
|
private val _allRooms = MutableStateFlow<ImmutableList<RoomListRoomSummary>>(persistentListOf())
|
||||||
private val _filteredRooms = MutableStateFlow<ImmutableList<RoomListRoomSummary>>(persistentListOf())
|
private val _filteredRooms = MutableStateFlow<ImmutableList<RoomListRoomSummary>>(persistentListOf())
|
||||||
|
|
||||||
|
private val lock = Mutex()
|
||||||
|
private val diffCache = MutableListDiffCache<RoomListRoomSummary>()
|
||||||
|
private val diffCacheUpdater = DiffCacheUpdater<RoomSummary, RoomListRoomSummary>(diffCache = diffCache, detectMoves = true) { old, new ->
|
||||||
|
old?.identifier() == new?.identifier()
|
||||||
|
}
|
||||||
|
|
||||||
fun launchIn(coroutineScope: CoroutineScope) {
|
fun launchIn(coroutineScope: CoroutineScope) {
|
||||||
roomSummaryDataSource
|
roomSummaryDataSource
|
||||||
.allRooms()
|
.allRooms()
|
||||||
.onEach { roomSummaries ->
|
.onEach { roomSummaries ->
|
||||||
_allRooms.value = if (roomSummaries.isEmpty()) {
|
replaceWith(roomSummaries)
|
||||||
RoomListRoomSummaryPlaceholders.createFakeList(16)
|
|
||||||
} else {
|
|
||||||
mapRoomSummaries(roomSummaries)
|
|
||||||
}.toImmutableList()
|
|
||||||
}
|
}
|
||||||
.launchIn(coroutineScope)
|
.launchIn(coroutineScope)
|
||||||
|
|
||||||
|
|
@ -85,33 +91,63 @@ class RoomListDataSource @Inject constructor(
|
||||||
val allRooms: StateFlow<ImmutableList<RoomListRoomSummary>> = _allRooms
|
val allRooms: StateFlow<ImmutableList<RoomListRoomSummary>> = _allRooms
|
||||||
val filteredRooms: StateFlow<ImmutableList<RoomListRoomSummary>> = _filteredRooms
|
val filteredRooms: StateFlow<ImmutableList<RoomListRoomSummary>> = _filteredRooms
|
||||||
|
|
||||||
private suspend fun mapRoomSummaries(
|
private suspend fun replaceWith(roomSummaries: List<RoomSummary>) = withContext(coroutineDispatchers.computation) {
|
||||||
roomSummaries: List<RoomSummary>
|
lock.withLock {
|
||||||
): List<RoomListRoomSummary> = withContext(coroutineDispatchers.computation) {
|
diffCacheUpdater.updateWith(roomSummaries)
|
||||||
roomSummaries.map { roomSummary ->
|
buildAndEmitAllRooms(roomSummaries)
|
||||||
when (roomSummary) {
|
|
||||||
is RoomSummary.Empty -> RoomListRoomSummaryPlaceholders.create(roomSummary.identifier)
|
|
||||||
is RoomSummary.Filled -> {
|
|
||||||
val avatarData = AvatarData(
|
|
||||||
id = roomSummary.identifier(),
|
|
||||||
name = roomSummary.details.name,
|
|
||||||
url = roomSummary.details.avatarURLString,
|
|
||||||
size = AvatarSize.RoomListItem,
|
|
||||||
)
|
|
||||||
val roomIdentifier = roomSummary.identifier()
|
|
||||||
RoomListRoomSummary(
|
|
||||||
id = roomSummary.identifier(),
|
|
||||||
roomId = RoomId(roomIdentifier),
|
|
||||||
name = roomSummary.details.name,
|
|
||||||
hasUnread = roomSummary.details.unreadNotificationCount > 0,
|
|
||||||
timestamp = lastMessageTimestampFormatter.format(roomSummary.details.lastMessageTimestamp),
|
|
||||||
lastMessage = roomSummary.details.lastMessage?.let { message ->
|
|
||||||
roomLastMessageFormatter.format(message.event, roomSummary.details.isDirect)
|
|
||||||
}.orEmpty(),
|
|
||||||
avatarData = avatarData,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun buildAndEmitAllRooms(roomSummaries: List<RoomSummary>) {
|
||||||
|
if (diffCache.isEmpty()) {
|
||||||
|
_allRooms.emit(
|
||||||
|
RoomListRoomSummaryPlaceholders.createFakeList(16).toImmutableList()
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
val roomListRoomSummaries = ArrayList<RoomListRoomSummary>()
|
||||||
|
for (index in diffCache.indices()) {
|
||||||
|
val cacheItem = diffCache.get(index)
|
||||||
|
if (cacheItem == null) {
|
||||||
|
buildAndCacheItem(roomSummaries, index)?.also { timelineItemState ->
|
||||||
|
roomListRoomSummaries.add(timelineItemState)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
roomListRoomSummaries.add(cacheItem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_allRooms.emit(roomListRoomSummaries.toImmutableList())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildAndCacheItem(
|
||||||
|
roomSummaries: List<RoomSummary>,
|
||||||
|
index: Int
|
||||||
|
): RoomListRoomSummary? {
|
||||||
|
val roomListRoomSummary = when (val roomSummary = roomSummaries.getOrNull(index)) {
|
||||||
|
is RoomSummary.Empty -> RoomListRoomSummaryPlaceholders.create(roomSummary.identifier)
|
||||||
|
is RoomSummary.Filled -> {
|
||||||
|
val avatarData = AvatarData(
|
||||||
|
id = roomSummary.identifier(),
|
||||||
|
name = roomSummary.details.name,
|
||||||
|
url = roomSummary.details.avatarURLString,
|
||||||
|
size = AvatarSize.RoomListItem,
|
||||||
|
)
|
||||||
|
val roomIdentifier = roomSummary.identifier()
|
||||||
|
RoomListRoomSummary(
|
||||||
|
id = roomSummary.identifier(),
|
||||||
|
roomId = RoomId(roomIdentifier),
|
||||||
|
name = roomSummary.details.name,
|
||||||
|
hasUnread = roomSummary.details.unreadNotificationCount > 0,
|
||||||
|
timestamp = lastMessageTimestampFormatter.format(roomSummary.details.lastMessageTimestamp),
|
||||||
|
lastMessage = roomSummary.details.lastMessage?.let { message ->
|
||||||
|
roomLastMessageFormatter.format(message.event, roomSummary.details.isDirect)
|
||||||
|
}.orEmpty(),
|
||||||
|
avatarData = avatarData,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
null -> null
|
||||||
|
}
|
||||||
|
diffCache[index] = roomListRoomSummary
|
||||||
|
return roomListRoomSummary
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,7 @@ import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||||
import io.element.android.libraries.matrix.test.room.FakeRoomSummaryDataSource
|
import io.element.android.libraries.matrix.test.room.FakeRoomSummaryDataSource
|
||||||
import io.element.android.libraries.matrix.test.room.aRoomSummaryFilled
|
import io.element.android.libraries.matrix.test.room.aRoomSummaryFilled
|
||||||
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
|
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
|
||||||
|
import io.element.android.tests.testutils.consumeItemsUntilPredicate
|
||||||
import io.element.android.tests.testutils.testCoroutineDispatchers
|
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.test.TestScope
|
import kotlinx.coroutines.test.TestScope
|
||||||
|
|
@ -118,13 +119,12 @@ class RoomListPresenterTests {
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
skipItems(1)
|
val initialState = consumeItemsUntilPredicate { state -> state.roomList.size == 16 }.last()
|
||||||
val initialState = awaitItem()
|
|
||||||
// Room list is loaded with 16 placeholders
|
// Room list is loaded with 16 placeholders
|
||||||
Truth.assertThat(initialState.roomList.size).isEqualTo(16)
|
Truth.assertThat(initialState.roomList.size).isEqualTo(16)
|
||||||
Truth.assertThat(initialState.roomList.all { it.isPlaceholder }).isTrue()
|
Truth.assertThat(initialState.roomList.all { it.isPlaceholder }).isTrue()
|
||||||
roomSummaryDataSource.postAllRooms(listOf(aRoomSummaryFilled()))
|
roomSummaryDataSource.postAllRooms(listOf(aRoomSummaryFilled()))
|
||||||
val withRoomState = awaitItem()
|
val withRoomState = consumeItemsUntilPredicate { state -> state.roomList.size == 1 }.last()
|
||||||
Truth.assertThat(withRoomState.roomList.size).isEqualTo(1)
|
Truth.assertThat(withRoomState.roomList.size).isEqualTo(1)
|
||||||
Truth.assertThat(withRoomState.roomList.first())
|
Truth.assertThat(withRoomState.roomList.first())
|
||||||
.isEqualTo(aRoomListRoomSummary)
|
.isEqualTo(aRoomListRoomSummary)
|
||||||
|
|
@ -142,21 +142,19 @@ class RoomListPresenterTests {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
roomSummaryDataSource.postAllRooms(listOf(aRoomSummaryFilled()))
|
roomSummaryDataSource.postAllRooms(listOf(aRoomSummaryFilled()))
|
||||||
skipItems(1)
|
val loadedState = consumeItemsUntilPredicate { state -> state.roomList.size == 1 }.last()
|
||||||
val loadedState = awaitItem()
|
|
||||||
// Test filtering with result
|
// Test filtering with result
|
||||||
loadedState.eventSink.invoke(RoomListEvents.UpdateFilter(A_ROOM_NAME.substring(0, 3)))
|
loadedState.eventSink.invoke(RoomListEvents.UpdateFilter(A_ROOM_NAME.substring(0, 3)))
|
||||||
skipItems(1) // Filter update
|
val withFilteredRoomState = consumeItemsUntilPredicate { state -> state.filteredRoomList.size == 1 }.last()
|
||||||
val withNotFilteredRoomState = awaitItem()
|
Truth.assertThat(withFilteredRoomState.filter).isEqualTo(A_ROOM_NAME.substring(0, 3))
|
||||||
Truth.assertThat(withNotFilteredRoomState.filter).isEqualTo(A_ROOM_NAME.substring(0, 3))
|
Truth.assertThat(withFilteredRoomState.filteredRoomList.size).isEqualTo(1)
|
||||||
Truth.assertThat(withNotFilteredRoomState.filteredRoomList.size).isEqualTo(1)
|
Truth.assertThat(withFilteredRoomState.filteredRoomList.first())
|
||||||
Truth.assertThat(withNotFilteredRoomState.filteredRoomList.first())
|
|
||||||
.isEqualTo(aRoomListRoomSummary)
|
.isEqualTo(aRoomListRoomSummary)
|
||||||
// Test filtering without result
|
// Test filtering without result
|
||||||
withNotFilteredRoomState.eventSink.invoke(RoomListEvents.UpdateFilter("tada"))
|
withFilteredRoomState.eventSink.invoke(RoomListEvents.UpdateFilter("tada"))
|
||||||
skipItems(1) // Filter update
|
val withNotFilteredRoomState = consumeItemsUntilPredicate { state -> state.filteredRoomList.size == 0 }.last()
|
||||||
Truth.assertThat(awaitItem().filter).isEqualTo("tada")
|
Truth.assertThat(withNotFilteredRoomState.filter).isEqualTo("tada")
|
||||||
Truth.assertThat(awaitItem().filteredRoomList).isEmpty()
|
Truth.assertThat(withNotFilteredRoomState.filteredRoomList).isEmpty()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ dependencies {
|
||||||
implementation(libs.timber)
|
implementation(libs.timber)
|
||||||
implementation(libs.androidx.corektx)
|
implementation(libs.androidx.corektx)
|
||||||
implementation(libs.androidx.activity.activity)
|
implementation(libs.androidx.activity.activity)
|
||||||
|
implementation(libs.androidx.recyclerview)
|
||||||
implementation(libs.androidx.exifinterface)
|
implementation(libs.androidx.exifinterface)
|
||||||
implementation(libs.androidx.security.crypto)
|
implementation(libs.androidx.security.crypto)
|
||||||
implementation(libs.androidx.browser)
|
implementation(libs.androidx.browser)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
/*
|
/*
|
||||||
* Copyright (c) 2022 New Vector Ltd
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
*
|
*
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
* you may not use this file except in compliance with the License.
|
* you may not use this file except in compliance with the License.
|
||||||
|
|
@ -14,14 +14,17 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package io.element.android.features.messages.impl.timeline.diff
|
package io.element.android.libraries.androidutils.diff
|
||||||
|
|
||||||
import androidx.recyclerview.widget.DiffUtil
|
import androidx.recyclerview.widget.DiffUtil
|
||||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
|
|
||||||
|
|
||||||
internal class MatrixTimelineItemsDiffCallback(
|
/**
|
||||||
private val oldList: List<MatrixTimelineItem>,
|
* Default implementation of [DiffUtil.Callback] that uses [areItemsTheSame] to compare items.
|
||||||
private val newList: List<MatrixTimelineItem>
|
*/
|
||||||
|
internal class DefaultDiffCallback<T>(
|
||||||
|
private val oldList: List<T>,
|
||||||
|
private val newList: List<T>,
|
||||||
|
private val areItemsTheSame: (oldItem: T?, newItem: T?) -> Boolean,
|
||||||
) : DiffUtil.Callback() {
|
) : DiffUtil.Callback() {
|
||||||
|
|
||||||
override fun getOldListSize(): Int {
|
override fun getOldListSize(): Int {
|
||||||
|
|
@ -35,11 +38,7 @@ internal class MatrixTimelineItemsDiffCallback(
|
||||||
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
|
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
|
||||||
val oldItem = oldList.getOrNull(oldItemPosition)
|
val oldItem = oldList.getOrNull(oldItemPosition)
|
||||||
val newItem = newList.getOrNull(newItemPosition)
|
val newItem = newList.getOrNull(newItemPosition)
|
||||||
return if (oldItem is MatrixTimelineItem.Event && newItem is MatrixTimelineItem.Event) {
|
return areItemsTheSame(oldItem, newItem)
|
||||||
oldItem.uniqueId == newItem.uniqueId
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
|
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.androidutils.diff
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A cache that can be used to store some data that can be invalidated when a diff is applied.
|
||||||
|
* The cache is invalidated by the [DiffCacheInvalidator].
|
||||||
|
*/
|
||||||
|
interface DiffCache<E> {
|
||||||
|
fun get(index: Int): E?
|
||||||
|
fun indices(): IntRange
|
||||||
|
fun isEmpty(): Boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A [DiffCache] that can be mutated by adding, removing or updating elements.
|
||||||
|
*/
|
||||||
|
interface MutableDiffCache<E> : DiffCache<E> {
|
||||||
|
fun removeAt(index: Int): E?
|
||||||
|
fun add(index: Int, element: E?)
|
||||||
|
operator fun set(index: Int, element: E?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A [MutableDiffCache] backed by a [MutableList].
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
class MutableListDiffCache<E>(private val mutableList: MutableList<E?> = ArrayList()) : MutableDiffCache<E> {
|
||||||
|
|
||||||
|
override fun removeAt(index: Int): E? {
|
||||||
|
return mutableList.removeAt(index)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun get(index: Int): E? {
|
||||||
|
return mutableList.getOrNull(index)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun indices(): IntRange {
|
||||||
|
return mutableList.indices
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun isEmpty(): Boolean {
|
||||||
|
return mutableList.isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
override operator fun set(index: Int, element: E?) {
|
||||||
|
mutableList[index] = element
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun add(index: Int, element: E?) {
|
||||||
|
mutableList.add(index, element)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,63 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.androidutils.diff
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [DiffCacheInvalidator] is used to invalidate the cache when the list is updated.
|
||||||
|
* It is used by [DiffCacheUpdater].
|
||||||
|
* Check the default implementation [DefaultDiffCacheInvalidator].
|
||||||
|
*/
|
||||||
|
interface DiffCacheInvalidator<T> {
|
||||||
|
fun onChanged(position: Int, count: Int, cache: MutableDiffCache<T>)
|
||||||
|
|
||||||
|
fun onMoved(fromPosition: Int, toPosition: Int, cache: MutableDiffCache<T>)
|
||||||
|
|
||||||
|
fun onInserted(position: Int, count: Int, cache: MutableDiffCache<T>)
|
||||||
|
|
||||||
|
fun onRemoved(position: Int, count: Int, cache: MutableDiffCache<T>)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default implementation of [DiffCacheInvalidator].
|
||||||
|
* It invalidates the cache by setting values to null.
|
||||||
|
*/
|
||||||
|
class DefaultDiffCacheInvalidator<T> : DiffCacheInvalidator<T> {
|
||||||
|
|
||||||
|
override fun onChanged(position: Int, count: Int, cache: MutableDiffCache<T>) {
|
||||||
|
(position until position + count).forEach {
|
||||||
|
// Invalidate cache
|
||||||
|
cache[it] = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMoved(fromPosition: Int, toPosition: Int, cache: MutableDiffCache<T>) {
|
||||||
|
val model = cache.removeAt(fromPosition)
|
||||||
|
cache.add(toPosition, model)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onInserted(position: Int, count: Int, cache: MutableDiffCache<T>) {
|
||||||
|
repeat(count) {
|
||||||
|
cache.add(position, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onRemoved(position: Int, count: Int, cache: MutableDiffCache<T>) {
|
||||||
|
repeat(count) {
|
||||||
|
cache.removeAt(position)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.androidutils.diff
|
||||||
|
|
||||||
|
import androidx.recyclerview.widget.DiffUtil
|
||||||
|
import androidx.recyclerview.widget.ListUpdateCallback
|
||||||
|
import timber.log.Timber
|
||||||
|
import kotlin.system.measureTimeMillis
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class in charge of updating a [MutableDiffCache] according to the cache invalidation rules provided by the [DiffCacheInvalidator].
|
||||||
|
* @param ListItem the type of the items in the list
|
||||||
|
* @param CachedItem the type of the items in the cache
|
||||||
|
* @param diffCache the cache to update
|
||||||
|
* @param detectMoves true if DiffUtil should try to detect moved items, false otherwise
|
||||||
|
* @param cacheInvalidator the invalidator to use to update the cache
|
||||||
|
* @param areItemsTheSame the function to use to compare items
|
||||||
|
*/
|
||||||
|
class DiffCacheUpdater<ListItem, CachedItem>(
|
||||||
|
private val diffCache: MutableDiffCache<CachedItem>,
|
||||||
|
private val detectMoves: Boolean = false,
|
||||||
|
private val cacheInvalidator: DiffCacheInvalidator<CachedItem> = DefaultDiffCacheInvalidator(),
|
||||||
|
private val areItemsTheSame: (oldItem: ListItem?, newItem: ListItem?) -> Boolean,
|
||||||
|
) {
|
||||||
|
|
||||||
|
private val lock = Object()
|
||||||
|
private var prevOriginalList: List<ListItem> = emptyList()
|
||||||
|
|
||||||
|
private val listUpdateCallback = object : ListUpdateCallback {
|
||||||
|
override fun onInserted(position: Int, count: Int) {
|
||||||
|
cacheInvalidator.onInserted(position, count, diffCache)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onRemoved(position: Int, count: Int) {
|
||||||
|
cacheInvalidator.onRemoved(position, count, diffCache)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMoved(fromPosition: Int, toPosition: Int) {
|
||||||
|
cacheInvalidator.onMoved(fromPosition, toPosition, diffCache)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onChanged(position: Int, count: Int, payload: Any?) {
|
||||||
|
cacheInvalidator.onChanged(position, count, diffCache)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateWith(newOriginalList: List<ListItem>) = synchronized(lock) {
|
||||||
|
val timeToDiff = measureTimeMillis {
|
||||||
|
val diffCallback = DefaultDiffCallback(prevOriginalList, newOriginalList, areItemsTheSame)
|
||||||
|
val diffResult = DiffUtil.calculateDiff(diffCallback, detectMoves)
|
||||||
|
prevOriginalList = newOriginalList
|
||||||
|
diffResult.dispatchUpdatesTo(listUpdateCallback)
|
||||||
|
}
|
||||||
|
Timber.v("Time to apply diff on new list of ${newOriginalList.size} items: $timeToDiff ms")
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue