diff --git a/app/src/main/java/im/vector/riotredesign/features/home/room/detail/RoomDetailActions.kt b/app/src/main/java/im/vector/riotredesign/features/home/room/detail/RoomDetailActions.kt index f3ca3b3d..47fd8ed2 100644 --- a/app/src/main/java/im/vector/riotredesign/features/home/room/detail/RoomDetailActions.kt +++ b/app/src/main/java/im/vector/riotredesign/features/home/room/detail/RoomDetailActions.kt @@ -22,6 +22,7 @@ sealed class RoomDetailActions { data class SendMessage(val text: String) : RoomDetailActions() object IsDisplayed : RoomDetailActions() - data class EventDisplayed(val event: TimelineEvent, val index: Int) : RoomDetailActions() + data class EventDisplayed(val event: TimelineEvent) : RoomDetailActions() + object LoadMore: RoomDetailActions() } \ No newline at end of file diff --git a/app/src/main/java/im/vector/riotredesign/features/home/room/detail/RoomDetailFragment.kt b/app/src/main/java/im/vector/riotredesign/features/home/room/detail/RoomDetailFragment.kt index 296918c7..ab128909 100644 --- a/app/src/main/java/im/vector/riotredesign/features/home/room/detail/RoomDetailFragment.kt +++ b/app/src/main/java/im/vector/riotredesign/features/home/room/detail/RoomDetailFragment.kt @@ -24,7 +24,6 @@ import android.view.ViewGroup import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.airbnb.epoxy.EpoxyVisibilityTracker -import com.airbnb.mvrx.Success import com.airbnb.mvrx.fragmentViewModel import im.vector.matrix.android.api.session.room.timeline.TimelineEvent import im.vector.riotredesign.R @@ -36,6 +35,7 @@ import im.vector.riotredesign.features.home.HomeModule import im.vector.riotredesign.features.home.HomePermalinkHandler import im.vector.riotredesign.features.home.room.detail.timeline.TimelineEventController import im.vector.riotredesign.features.home.room.detail.timeline.animation.TimelineItemAnimator +import im.vector.riotredesign.features.home.room.detail.timeline.helper.EndlessRecyclerViewScrollListener import im.vector.riotredesign.features.media.MediaContentRenderer import im.vector.riotredesign.features.media.MediaViewerActivity import kotlinx.android.parcel.Parcelize @@ -80,7 +80,6 @@ class RoomDetailFragment : RiotFragment(), TimelineEventController.Callback { setupRecyclerView() setupToolbar() setupSendButton() - timelineEventController.requestModelBuild() roomDetailViewModel.subscribe { renderState(it) } } @@ -111,6 +110,11 @@ class RoomDetailFragment : RiotFragment(), TimelineEventController.Callback { it.dispatchTo(stateRestorer) it.dispatchTo(scrollOnNewMessageCallback) } + recyclerView.addOnScrollListener(object : EndlessRecyclerViewScrollListener(layoutManager, EndlessRecyclerViewScrollListener.LoadOnScrollDirection.BOTTOM) { + override fun onLoadMore(page: Int, totalItemsCount: Int) { + roomDetailViewModel.process(RoomDetailActions.LoadMore) + } + }) recyclerView.setController(timelineEventController) timelineEventController.callback = this } @@ -119,29 +123,16 @@ class RoomDetailFragment : RiotFragment(), TimelineEventController.Callback { sendButton.setOnClickListener { val textMessage = composerEditText.text.toString() if (textMessage.isNotBlank()) { - composerEditText.text = null roomDetailViewModel.process(RoomDetailActions.SendMessage(textMessage)) + composerEditText.text = null } } } private fun renderState(state: RoomDetailViewState) { renderRoomSummary(state) - renderTimeline(state) - } - - private fun renderTimeline(state: RoomDetailViewState) { - when (state.asyncTimelineData) { - is Success -> { - val timelineData = state.asyncTimelineData() - val lockAutoScroll = timelineData?.let { - it.events == timelineEventController.currentList && it.isLoadingForward - } ?: true - - scrollOnNewMessageCallback.isLocked.set(lockAutoScroll) - timelineEventController.update(timelineData) - } - } + timelineEventController.setTimeline(state.timeline) + //renderTimeline(state) } private fun renderRoomSummary(state: RoomDetailViewState) { @@ -163,8 +154,8 @@ class RoomDetailFragment : RiotFragment(), TimelineEventController.Callback { homePermalinkHandler.launch(url) } - override fun onEventVisible(event: TimelineEvent, index: Int) { - roomDetailViewModel.process(RoomDetailActions.EventDisplayed(event, index)) + override fun onEventVisible(event: TimelineEvent) { + roomDetailViewModel.process(RoomDetailActions.EventDisplayed(event)) } override fun onMediaClicked(mediaData: MediaContentRenderer.Data, view: View) { diff --git a/app/src/main/java/im/vector/riotredesign/features/home/room/detail/RoomDetailViewModel.kt b/app/src/main/java/im/vector/riotredesign/features/home/room/detail/RoomDetailViewModel.kt index 4ca21b94..e1e80bcb 100644 --- a/app/src/main/java/im/vector/riotredesign/features/home/room/detail/RoomDetailViewModel.kt +++ b/app/src/main/java/im/vector/riotredesign/features/home/room/detail/RoomDetailViewModel.kt @@ -22,8 +22,8 @@ import com.jakewharton.rxrelay2.BehaviorRelay import im.vector.matrix.android.api.MatrixCallback import im.vector.matrix.android.api.session.Session import im.vector.matrix.android.api.session.events.model.Event +import im.vector.matrix.android.api.session.room.timeline.Timeline import im.vector.matrix.rx.rx -import im.vector.riotredesign.core.extensions.lastMinBy import im.vector.riotredesign.core.platform.RiotViewModel import im.vector.riotredesign.features.home.room.VisibleRoomStore import io.reactivex.rxkotlin.subscribeBy @@ -38,8 +38,8 @@ class RoomDetailViewModel(initialState: RoomDetailViewState, private val room = session.getRoom(initialState.roomId)!! private val roomId = initialState.roomId private val eventId = initialState.eventId - private val displayedEventsObservable = BehaviorRelay.create() + private val timeline = room.createTimeline(eventId) companion object : MvRxViewModelFactory { @@ -53,9 +53,10 @@ class RoomDetailViewModel(initialState: RoomDetailViewState, init { observeRoomSummary() - observeTimeline() observeDisplayedEvents() room.loadRoomMembersIfNeeded() + timeline.start() + setState { copy(timeline = this@RoomDetailViewModel.timeline) } } fun process(action: RoomDetailActions) { @@ -63,6 +64,7 @@ class RoomDetailViewModel(initialState: RoomDetailViewState, is RoomDetailActions.SendMessage -> handleSendMessage(action) is RoomDetailActions.IsDisplayed -> handleIsDisplayed() is RoomDetailActions.EventDisplayed -> handleEventDisplayed(action) + is RoomDetailActions.LoadMore -> timeline.paginate(Timeline.Direction.BACKWARDS, 50) } } @@ -83,11 +85,11 @@ class RoomDetailViewModel(initialState: RoomDetailViewState, private fun observeDisplayedEvents() { // We are buffering scroll events for one second // and keep the most recent one to set the read receipt on. - displayedEventsObservable.hide() + displayedEventsObservable .buffer(1, TimeUnit.SECONDS) .filter { it.isNotEmpty() } .subscribeBy(onNext = { actions -> - val mostRecentEvent = actions.lastMinBy { it.index } + val mostRecentEvent = actions.maxBy { it.event.displayIndex } mostRecentEvent?.event?.root?.eventId?.let { eventId -> room.setReadReceipt(eventId, callback = object : MatrixCallback {}) } @@ -102,12 +104,9 @@ class RoomDetailViewModel(initialState: RoomDetailViewState, } } - private fun observeTimeline() { - room.rx().timeline(eventId) - .execute { timelineData -> - copy(asyncTimelineData = timelineData) - } + override fun onCleared() { + super.onCleared() + timeline.dispose() } - } \ No newline at end of file diff --git a/app/src/main/java/im/vector/riotredesign/features/home/room/detail/RoomDetailViewState.kt b/app/src/main/java/im/vector/riotredesign/features/home/room/detail/RoomDetailViewState.kt index ca2f39b7..4df1551c 100644 --- a/app/src/main/java/im/vector/riotredesign/features/home/room/detail/RoomDetailViewState.kt +++ b/app/src/main/java/im/vector/riotredesign/features/home/room/detail/RoomDetailViewState.kt @@ -20,11 +20,13 @@ import com.airbnb.mvrx.Async import com.airbnb.mvrx.MvRxState import com.airbnb.mvrx.Uninitialized import im.vector.matrix.android.api.session.room.model.RoomSummary +import im.vector.matrix.android.api.session.room.timeline.Timeline import im.vector.matrix.android.api.session.room.timeline.TimelineData data class RoomDetailViewState( val roomId: String, val eventId: String?, + val timeline: Timeline? = null, val asyncRoomSummary: Async = Uninitialized, val asyncTimelineData: Async = Uninitialized ) : MvRxState { diff --git a/app/src/main/java/im/vector/riotredesign/features/home/room/detail/ScrollOnNewMessageCallback.kt b/app/src/main/java/im/vector/riotredesign/features/home/room/detail/ScrollOnNewMessageCallback.kt index 719a570a..6c93997b 100644 --- a/app/src/main/java/im/vector/riotredesign/features/home/room/detail/ScrollOnNewMessageCallback.kt +++ b/app/src/main/java/im/vector/riotredesign/features/home/room/detail/ScrollOnNewMessageCallback.kt @@ -25,7 +25,7 @@ class ScrollOnNewMessageCallback(private val layoutManager: LinearLayoutManager) var isLocked = AtomicBoolean(true) override fun onInserted(position: Int, count: Int) { - if (isLocked.compareAndSet(false, true) && position == 0 && layoutManager.findFirstVisibleItemPosition() == 0) { + if (position == 0 && layoutManager.findFirstVisibleItemPosition() == 0) { layoutManager.scrollToPosition(0) } } diff --git a/app/src/main/java/im/vector/riotredesign/features/home/room/detail/timeline/TimelineEventController.kt b/app/src/main/java/im/vector/riotredesign/features/home/room/detail/timeline/TimelineEventController.kt index d8558e6a..55bd6e0c 100644 --- a/app/src/main/java/im/vector/riotredesign/features/home/room/detail/timeline/TimelineEventController.kt +++ b/app/src/main/java/im/vector/riotredesign/features/home/room/detail/timeline/TimelineEventController.kt @@ -17,44 +17,79 @@ package im.vector.riotredesign.features.home.room.detail.timeline import android.view.View +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListUpdateCallback import androidx.recyclerview.widget.RecyclerView import com.airbnb.epoxy.EpoxyAsyncUtil +import com.airbnb.epoxy.EpoxyController import com.airbnb.epoxy.EpoxyModel import com.airbnb.epoxy.VisibilityState -import im.vector.matrix.android.api.session.events.model.EventType -import im.vector.matrix.android.api.session.room.timeline.TimelineData +import im.vector.matrix.android.api.session.room.timeline.Timeline import im.vector.matrix.android.api.session.room.timeline.TimelineEvent -import im.vector.riotredesign.core.epoxy.LoadingItemModel_ import im.vector.riotredesign.core.epoxy.RiotEpoxyModel import im.vector.riotredesign.core.extensions.localDateTime import im.vector.riotredesign.features.home.room.detail.timeline.factory.TimelineItemFactory import im.vector.riotredesign.features.home.room.detail.timeline.helper.TimelineDateFormatter +import im.vector.riotredesign.features.home.room.detail.timeline.helper.TimelineEventDiffUtilCallback import im.vector.riotredesign.features.home.room.detail.timeline.helper.TimelineMediaSizeProvider import im.vector.riotredesign.features.home.room.detail.timeline.item.DaySeparatorItem_ -import im.vector.riotredesign.features.home.room.detail.timeline.paging.PagedListEpoxyController import im.vector.riotredesign.features.media.MediaContentRenderer class TimelineEventController(private val dateFormatter: TimelineDateFormatter, private val timelineItemFactory: TimelineItemFactory, private val timelineMediaSizeProvider: TimelineMediaSizeProvider -) : PagedListEpoxyController( +) : EpoxyController( EpoxyAsyncUtil.getAsyncBackgroundHandler(), EpoxyAsyncUtil.getAsyncBackgroundHandler() -) { +), Timeline.Listener { + + private val modelCache = arrayListOf>>() + private var currentSnapshot: List = emptyList() + + override fun onUpdated(snapshot: List) { + submitSnapshot(snapshot) + } + + private val listUpdateCallback = object : ListUpdateCallback { + override fun onChanged(position: Int, count: Int, payload: Any?) { + (position until (position + count)).forEach { + modelCache[it] = emptyList() + } + requestModelBuild() + } + + override fun onMoved(fromPosition: Int, toPosition: Int) { + //no-op + } + + override fun onInserted(position: Int, count: Int) { + if (modelCache.isNotEmpty() && position == modelCache.size) { + modelCache[position - 1] = emptyList() + } + (0 until count).forEach { + modelCache.add(position, emptyList()) + } + requestModelBuild() + } + + override fun onRemoved(position: Int, count: Int) { + //no-op + } + + } private var isLoadingForward: Boolean = false private var isLoadingBackward: Boolean = false private var hasReachedEnd: Boolean = true + private var timeline: Timeline? = null var callback: Callback? = null - fun update(timelineData: TimelineData?) { - timelineData?.let { - isLoadingForward = it.isLoadingForward - isLoadingBackward = it.isLoadingBackward - hasReachedEnd = it.events.lastOrNull()?.root?.type == EventType.STATE_ROOM_CREATE - submitList(it.events) - requestModelBuild() + fun setTimeline(timeline: Timeline?) { + if (this.timeline != timeline) { + this.timeline = timeline + this.timeline?.listener = this + submitSnapshot(timeline?.snapshot() ?: emptyList()) } } @@ -63,12 +98,22 @@ class TimelineEventController(private val dateFormatter: TimelineDateFormatter, timelineMediaSizeProvider.recyclerView = recyclerView } - override fun buildItemModels(currentPosition: Int, items: List): List> { - if (items.isNullOrEmpty()) { - return emptyList() + override fun buildModels() { + add(getModels()) + } + + private fun getModels(): List> { + (0 until modelCache.size).forEach { position -> + if (modelCache[position].isEmpty()) { + modelCache[position] = buildItemModels(position, currentSnapshot) + } } + return modelCache.flatten() + } + + private fun buildItemModels(currentPosition: Int, items: List): List> { val epoxyModels = ArrayList>() - val event = items[currentPosition] ?: return emptyList() + val event = items[currentPosition] val nextEvent = if (currentPosition + 1 < items.size) items[currentPosition + 1] else null val date = event.root.localDateTime() @@ -77,7 +122,7 @@ class TimelineEventController(private val dateFormatter: TimelineDateFormatter, timelineItemFactory.create(event, nextEvent, callback).also { it.id(event.localId) - it.setOnVisibilityStateChanged(TimelineEventVisibilityStateChangedListener(callback, event, currentPosition)) + it.setOnVisibilityStateChanged(TimelineEventVisibilityStateChangedListener(callback, event)) epoxyModels.add(it) } if (addDaySeparator) { @@ -88,21 +133,17 @@ class TimelineEventController(private val dateFormatter: TimelineDateFormatter, return epoxyModels } - override fun addModels(models: List>) { - LoadingItemModel_() - .id("forward_loading_item") - .addIf(isLoadingForward, this) - - super.add(models) - - LoadingItemModel_() - .id("backward_loading_item") - .addIf(!hasReachedEnd, this) + private fun submitSnapshot(newSnapshot: List) { + EpoxyAsyncUtil.getAsyncBackgroundHandler().post { + val diffCallback = TimelineEventDiffUtilCallback(currentSnapshot, newSnapshot) + currentSnapshot = newSnapshot + val diffResult = DiffUtil.calculateDiff(diffCallback) + diffResult.dispatchUpdatesTo(listUpdateCallback) + } } - interface Callback { - fun onEventVisible(event: TimelineEvent, index: Int) + fun onEventVisible(event: TimelineEvent) fun onUrlClicked(url: String) fun onMediaClicked(mediaData: MediaContentRenderer.Data, view: View) } @@ -110,13 +151,12 @@ class TimelineEventController(private val dateFormatter: TimelineDateFormatter, } private class TimelineEventVisibilityStateChangedListener(private val callback: TimelineEventController.Callback?, - private val event: TimelineEvent, - private val currentPosition: Int) + private val event: TimelineEvent) : RiotEpoxyModel.OnVisibilityStateChangedListener { override fun onVisibilityStateChanged(visibilityState: Int) { if (visibilityState == VisibilityState.VISIBLE) { - callback?.onEventVisible(event, currentPosition) + callback?.onEventVisible(event) } } diff --git a/app/src/main/java/im/vector/riotredesign/features/home/room/detail/timeline/helper/EndlessRecyclerViewScrollListener.java b/app/src/main/java/im/vector/riotredesign/features/home/room/detail/timeline/helper/EndlessRecyclerViewScrollListener.java new file mode 100644 index 00000000..5c1c11b2 --- /dev/null +++ b/app/src/main/java/im/vector/riotredesign/features/home/room/detail/timeline/helper/EndlessRecyclerViewScrollListener.java @@ -0,0 +1,147 @@ +/* + * Copyright 2019 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 im.vector.riotredesign.features.home.room.detail.timeline.helper; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +// Todo rework that, it has been copy/paste at the moment +public abstract class EndlessRecyclerViewScrollListener extends RecyclerView.OnScrollListener { + // Sets the starting page index + private static final int startingPageIndex = 0; + // The minimum amount of items to have below your current scroll position + // before loading more. + private int visibleThreshold = 30; + // The current offset index of data you have loaded + private int currentPage = 0; + // The total number of items in the dataset after the last load + private int previousTotalItemCount = 0; + // True if we are still waiting for the last set of data to load. + private boolean loading = true; + private LinearLayoutManager mLayoutManager; + private LoadOnScrollDirection mDirection; + + public EndlessRecyclerViewScrollListener(LinearLayoutManager layoutManager, LoadOnScrollDirection direction) { + this.mLayoutManager = layoutManager; + mDirection = direction; + } + + + // This happens many times a second during a scroll, so be wary of the code you place here. + // 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. + @Override + public void onScrolled(@NonNull RecyclerView view, int dx, int dy) { + int lastVisibleItemPosition = 0; + int firstVisibleItemPosition = 0; + int totalItemCount = mLayoutManager.getItemCount(); + + lastVisibleItemPosition = mLayoutManager.findLastVisibleItemPosition(); + firstVisibleItemPosition = mLayoutManager.findFirstVisibleItemPosition(); + + switch (mDirection) { + case BOTTOM: + // 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 + if (totalItemCount < previousTotalItemCount) { + this.currentPage = startingPageIndex; + this.previousTotalItemCount = totalItemCount; + if (totalItemCount == 0) { + this.loading = true; + } + } + // If it’s still loading, we check to see if the dataset count has + // changed, if so we conclude it has finished loading and update the current page + // number and total item count. + if (loading && (totalItemCount > previousTotalItemCount)) { + loading = false; + previousTotalItemCount = totalItemCount; + } + + // If it isn’t currently loading, we check to see if we have breached + // the visibleThreshold and need to reload more 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 + if (!loading && (lastVisibleItemPosition + visibleThreshold) > totalItemCount) { + currentPage++; + onLoadMore(currentPage, totalItemCount); + loading = true; + } + break; + case TOP: + // 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 + if (totalItemCount < previousTotalItemCount) { + this.currentPage = startingPageIndex; + this.previousTotalItemCount = totalItemCount; + if (totalItemCount == 0) { + this.loading = true; + } + } + // If it’s still loading, we check to see if the dataset count has + // changed, if so we conclude it has finished loading and update the current page + // number and total item count. + if (loading && (totalItemCount > previousTotalItemCount)) { + loading = false; + previousTotalItemCount = totalItemCount; + } + + // If it isn’t currently loading, we check to see if we have breached + // the visibleThreshold and need to reload more 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 + if (!loading && firstVisibleItemPosition < visibleThreshold) { + currentPage++; + onLoadMore(currentPage, totalItemCount); + loading = true; + } + break; + } + } + + private int getLastVisibleItem(int[] lastVisibleItemPositions) { + int maxSize = 0; + for (int i = 0; i < lastVisibleItemPositions.length; i++) { + if (i == 0) { + maxSize = lastVisibleItemPositions[i]; + } else if (lastVisibleItemPositions[i] > maxSize) { + maxSize = lastVisibleItemPositions[i]; + } + } + return maxSize; + } + + private int getFirstVisibleItem(int[] firstVisibleItemPositions) { + int maxSize = 0; + for (int i = 0; i < firstVisibleItemPositions.length; i++) { + if (i == 0) { + maxSize = firstVisibleItemPositions[i]; + } else if (firstVisibleItemPositions[i] > maxSize) { + maxSize = firstVisibleItemPositions[i]; + } + } + return maxSize; + } + + // Defines the process for actually loading more data based on page + public abstract void onLoadMore(int page, int totalItemsCount); + + public enum LoadOnScrollDirection { + TOP, BOTTOM + } +} \ No newline at end of file diff --git a/app/src/main/java/im/vector/riotredesign/features/home/room/detail/timeline/helper/TimelineEventDiffUtilCallback.kt b/app/src/main/java/im/vector/riotredesign/features/home/room/detail/timeline/helper/TimelineEventDiffUtilCallback.kt new file mode 100644 index 00000000..cd62b511 --- /dev/null +++ b/app/src/main/java/im/vector/riotredesign/features/home/room/detail/timeline/helper/TimelineEventDiffUtilCallback.kt @@ -0,0 +1,44 @@ +/* + * Copyright 2019 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 im.vector.riotredesign.features.home.room.detail.timeline.helper + +import androidx.recyclerview.widget.DiffUtil +import im.vector.matrix.android.api.session.room.timeline.TimelineEvent + +class TimelineEventDiffUtilCallback(private val oldList: List, + private val newList: List) : DiffUtil.Callback() { + + override fun getOldListSize(): Int { + return oldList.size + } + + override fun getNewListSize(): Int { + return newList.size + } + + override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { + val oldItem = oldList[oldItemPosition] + val newItem = newList[newItemPosition] + return oldItem.localId == newItem.localId + } + + override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { + val oldItem = oldList[oldItemPosition] + val newItem = newList[newItemPosition] + return oldItem == newItem + } +} \ No newline at end of file diff --git a/app/src/main/java/im/vector/riotredesign/features/home/room/detail/timeline/paging/PagedListEpoxyController.kt b/app/src/main/java/im/vector/riotredesign/features/home/room/detail/timeline/paging/PagedListEpoxyController.kt deleted file mode 100644 index 30f11a8b..00000000 --- a/app/src/main/java/im/vector/riotredesign/features/home/room/detail/timeline/paging/PagedListEpoxyController.kt +++ /dev/null @@ -1,129 +0,0 @@ -/* - * Copyright 2019 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 im.vector.riotredesign.features.home.room.detail.timeline.paging - -import androidx.paging.PagedList -import android.os.Handler -import androidx.recyclerview.widget.DiffUtil -import com.airbnb.epoxy.EpoxyController -import com.airbnb.epoxy.EpoxyModel -import com.airbnb.epoxy.EpoxyViewHolder - -/** - * An [EpoxyController] that can work with a [PagedList]. - * - * Internally, it caches the model for each item in the [PagedList]. You should override - * [buildItemModel] method to build the model for the given item. Since [PagedList] might include - * `null` items if placeholders are enabled, this method needs to handle `null` values in the list. - * - * By default, the model for each item is added to the model list. To change this behavior (to - * filter items or inject extra items), you can override [addModels] function and manually add built - * models. - * - * @param T The type of the items in the [PagedList]. - */ -abstract class PagedListEpoxyController( - /** - * The handler to use for building models. By default this uses the main thread, but you can use - * [EpoxyAsyncUtil.getAsyncBackgroundHandler] to do model building in the background. - * - * The notify thread of your PagedList (from setNotifyExecutor in the PagedList Builder) must be - * the same as this thread. Otherwise Epoxy will crash. - */ - modelBuildingHandler: Handler = EpoxyController.defaultModelBuildingHandler, - /** - * The handler to use when calculating the diff between built model lists. - * By default this uses the main thread, but you can use - * [EpoxyAsyncUtil.getAsyncBackgroundHandler] to do diffing in the background. - */ - diffingHandler: Handler = EpoxyController.defaultDiffingHandler, - /** - * [PagedListEpoxyController] uses an [DiffUtil.ItemCallback] to detect changes between - * [PagedList]s. By default, it relies on simple object equality but you can provide a custom - * one if you don't use all fields in the object in your models. - */ - itemDiffCallback: DiffUtil.ItemCallback = DEFAULT_ITEM_DIFF_CALLBACK as DiffUtil.ItemCallback -) : EpoxyController(modelBuildingHandler, diffingHandler) { - // this is where we keep the already built models - protected val modelCache = PagedListModelCache( - modelBuilder = { pos, item -> - buildItemModels(pos, item) - }, - rebuildCallback = { - requestModelBuild() - }, - itemDiffCallback = itemDiffCallback, - modelBuildingHandler = modelBuildingHandler - ) - - var currentList: PagedList? = null - private set - - final override fun buildModels() { - addModels(modelCache.getModels()) - } - - override fun onModelBound( - holder: EpoxyViewHolder, - boundModel: EpoxyModel<*>, - position: Int, - previouslyBoundModel: EpoxyModel<*>? - ) { - modelCache.loadAround(boundModel) - } - - /** - * This function adds all built models to the adapter. You can override this method to add extra - * items into the model list or remove some. - */ - open fun addModels(models: List>) { - super.add(models) - } - - /** - * Builds the model for a given item. This must return a single model for each item. If you want - * to inject headers etc, you can override [addModels] function. - * - * If the `item` is `null`, you should provide the placeholder. If your [PagedList] is configured - * without placeholders, you don't need to handle the `null` case. - */ - abstract fun buildItemModels(currentPosition: Int, items: List): List> - - /** - * Submit a new paged list. - * - * A diff will be calculated between this list and the previous list so you may still get calls - * to [buildItemModel] with items from the previous list. - */ - fun submitList(newList: PagedList?) { - currentList = newList - modelCache.submitList(newList) - } - - companion object { - /** - * [PagedListEpoxyController] calculates a diff on top of the PagedList to check which - * models are invalidated. - * This is the default [DiffUtil.ItemCallback] which uses object equality. - */ - val DEFAULT_ITEM_DIFF_CALLBACK = object : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: Any, newItem: Any) = oldItem == newItem - - override fun areContentsTheSame(oldItem: Any, newItem: Any) = oldItem == newItem - } - } -} \ No newline at end of file diff --git a/app/src/main/java/im/vector/riotredesign/features/home/room/detail/timeline/paging/PagedListModelCache.kt b/app/src/main/java/im/vector/riotredesign/features/home/room/detail/timeline/paging/PagedListModelCache.kt deleted file mode 100644 index cbaf81a7..00000000 --- a/app/src/main/java/im/vector/riotredesign/features/home/room/detail/timeline/paging/PagedListModelCache.kt +++ /dev/null @@ -1,149 +0,0 @@ -/* - * Copyright 2019 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 im.vector.riotredesign.features.home.room.detail.timeline.paging - -import android.annotation.SuppressLint -import android.os.Handler -import androidx.paging.AsyncPagedListDiffer -import androidx.paging.PagedList -import androidx.recyclerview.widget.AsyncDifferConfig -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ListUpdateCallback -import com.airbnb.epoxy.EpoxyModel -import java.util.concurrent.Executor -import java.util.concurrent.atomic.AtomicBoolean - -/** - * A PagedList stream wrapper that caches models built for each item. It tracks changes in paged lists and caches - * models for each item when they are invalidated to avoid rebuilding models for the whole list when PagedList is - * updated. - */ -class PagedListModelCache( - private val modelBuilder: (itemIndex: Int, items: List) -> List>, - private val rebuildCallback: () -> Unit, - private val itemDiffCallback: DiffUtil.ItemCallback, - private val diffExecutor: Executor? = null, - private val modelBuildingHandler: Handler -) { - - - // Int is the index of the pagedList item - // We have to be able to find the pagedlist position coming from an epoxy model to trigger - // LoadAround with accuracy - private val modelCache = linkedMapOf, Int>() - private var isCacheStale = AtomicBoolean(true) - - /** - * Tracks the last accessed position so that we can report it back to the paged list when models are built. - */ - private var lastPosition: Int? = null - - /** - * Observer for the PagedList changes that invalidates the model cache when data is updated. - */ - private val updateCallback = object : ListUpdateCallback { - override fun onChanged(position: Int, count: Int, payload: Any?) { - invalidate() - rebuildCallback() - } - - override fun onMoved(fromPosition: Int, toPosition: Int) { - invalidate() - rebuildCallback() - } - - override fun onInserted(position: Int, count: Int) { - invalidate() - rebuildCallback() - } - - override fun onRemoved(position: Int, count: Int) { - invalidate() - rebuildCallback() - } - } - - @SuppressLint("RestrictedApi") - private val asyncDiffer = AsyncPagedListDiffer( - updateCallback, - AsyncDifferConfig.Builder( - itemDiffCallback - ).also { builder -> - if (diffExecutor != null) { - builder.setBackgroundThreadExecutor(diffExecutor) - } - // we have to reply on this private API, otherwise, paged list might be changed when models are being built, - // potentially creating concurrent modification problems. - builder.setMainThreadExecutor { runnable: Runnable -> - modelBuildingHandler.post(runnable) - } - }.build() - ) - - fun submitList(pagedList: PagedList?) { - asyncDiffer.submitList(pagedList) - } - - fun getModels(): List> { - if (isCacheStale.compareAndSet(true, false)) { - asyncDiffer.currentList?.forEachIndexed { position, _ -> - buildModel(position) - } - } - lastPosition?.let { - triggerLoadAround(it) - } - return modelCache.keys.toList() - } - - fun loadAround(model: EpoxyModel<*>) { - modelCache[model]?.let { itemPosition -> - triggerLoadAround(itemPosition) - lastPosition = itemPosition - } - } - - // PRIVATE METHODS ***************************************************************************** - - private fun invalidate() { - modelCache.clear() - isCacheStale.set(true) - } - - private fun cacheModelsAtPosition(itemPosition: Int, epoxyModels: Set>) { - epoxyModels.forEach { - modelCache[it] = itemPosition - } - } - - private fun buildModel(pos: Int) { - if (pos >= asyncDiffer.currentList?.size ?: 0) { - return - } - modelBuilder(pos, asyncDiffer.currentList as List).also { - cacheModelsAtPosition(pos, it.toSet()) - } - } - - private fun triggerLoadAround(position: Int) { - asyncDiffer.currentList?.let { - if (it.size > 0) { - it.loadAround(Math.min(position, it.size - 1)) - } - } - } -} diff --git a/matrix-sdk-android-rx/src/main/java/im/vector/matrix/rx/RxRoom.kt b/matrix-sdk-android-rx/src/main/java/im/vector/matrix/rx/RxRoom.kt index 054fb438..2fbed52b 100644 --- a/matrix-sdk-android-rx/src/main/java/im/vector/matrix/rx/RxRoom.kt +++ b/matrix-sdk-android-rx/src/main/java/im/vector/matrix/rx/RxRoom.kt @@ -18,7 +18,6 @@ package im.vector.matrix.rx import im.vector.matrix.android.api.session.room.Room import im.vector.matrix.android.api.session.room.model.RoomSummary -import im.vector.matrix.android.api.session.room.timeline.TimelineData import io.reactivex.Observable class RxRoom(private val room: Room) { @@ -27,10 +26,6 @@ class RxRoom(private val room: Room) { return room.roomSummary.asObservable() } - fun timeline(eventId: String? = null): Observable { - return room.timeline(eventId).asObservable() - } - } fun Room.rx(): RxRoom { diff --git a/matrix-sdk-android/src/androidTest/java/im/vector/matrix/android/session/room/timeline/TimelineHolderTest.kt b/matrix-sdk-android/src/androidTest/java/im/vector/matrix/android/session/room/timeline/TimelineHolderTest.kt index cf838528..917b955b 100644 --- a/matrix-sdk-android/src/androidTest/java/im/vector/matrix/android/session/room/timeline/TimelineHolderTest.kt +++ b/matrix-sdk-android/src/androidTest/java/im/vector/matrix/android/session/room/timeline/TimelineHolderTest.kt @@ -31,7 +31,6 @@ import im.vector.matrix.android.internal.util.PagingRequestHelper import im.vector.matrix.android.testCoroutineDispatchers import io.realm.Realm import io.realm.RealmConfiguration -import org.amshove.kluent.shouldEqual import org.junit.Before import org.junit.Rule import org.junit.Test diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/timeline/Timeline.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/timeline/Timeline.kt index 82601b50..9e30d452 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/timeline/Timeline.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/timeline/Timeline.kt @@ -20,13 +20,16 @@ package im.vector.matrix.android.api.session.room.timeline interface Timeline { + var listener: Timeline.Listener? + + fun size(): Int + fun snapshot(): List fun paginate(direction: Direction, count: Int) - fun addListener(listener: Listener) - fun removeListener(listener: Listener) - fun removeAllListeners() + fun start() + fun dispose() interface Listener { - + fun onUpdated(snapshot: List) } enum class Direction(val value: String) { diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/timeline/TimelineEvent.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/timeline/TimelineEvent.kt index 89e1724f..ffd7dbc6 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/timeline/TimelineEvent.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/timeline/TimelineEvent.kt @@ -27,6 +27,7 @@ import im.vector.matrix.android.api.session.room.model.RoomMember data class TimelineEvent( val root: Event, val localId: String, + val displayIndex: Int, val roomMember: RoomMember? ) { diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/timeline/TimelineService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/timeline/TimelineService.kt index 9e7edc61..28af1fe2 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/timeline/TimelineService.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/timeline/TimelineService.kt @@ -16,22 +16,11 @@ package im.vector.matrix.android.api.session.room.timeline -import androidx.lifecycle.LiveData - /** * This interface defines methods to interact with the timeline. It's implemented at the room level. */ interface TimelineService { - /** - * This is the main method of the service. It allows to listen for live [TimelineData]. - * It's automatically refreshed as soon as timeline data gets updated, through sync or pagination. - * - * @param eventId: an optional eventId to start loading timeline around. - * @return the [LiveData] of [TimelineData] - */ - fun timeline(eventId: String? = null): LiveData - fun createTimeline(eventId: String?): Timeline } \ No newline at end of file diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/RealmLiveEntityObserver.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/RealmLiveEntityObserver.kt index 27ff501f..490742ca 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/RealmLiveEntityObserver.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/RealmLiveEntityObserver.kt @@ -26,6 +26,7 @@ import java.util.concurrent.atomic.AtomicReference internal interface LiveEntityObserver { fun start() fun dispose() + fun isStarted(): Boolean } internal abstract class RealmLiveEntityObserver(protected val monarchy: Monarchy) @@ -56,6 +57,10 @@ internal abstract class RealmLiveEntityObserver(protected val m } } + override fun isStarted(): Boolean { + return isStarted.get() + } + protected open fun onChanged(realmResults: RealmResults, changeSet: OrderedCollectionChangeSet) { val insertionIndexes = changeSet.insertions val updateIndexes = changeSet.changes diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/EventEntity.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/EventEntity.kt index 0be7b106..5a7b04d8 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/EventEntity.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/EventEntity.kt @@ -18,12 +18,13 @@ package im.vector.matrix.android.internal.database.model import io.realm.RealmObject import io.realm.RealmResults +import io.realm.annotations.Index import io.realm.annotations.LinkingObjects import io.realm.annotations.PrimaryKey import java.util.* internal open class EventEntity(@PrimaryKey var localId: String = UUID.randomUUID().toString(), - var eventId: String = "", + @Index var eventId: String = "", var roomId: String = "", var type: String = "", var content: String? = null, diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomFactory.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomFactory.kt index 6b2c796c..6183be5c 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomFactory.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomFactory.kt @@ -28,7 +28,7 @@ import im.vector.matrix.android.internal.session.room.send.EventFactory import im.vector.matrix.android.internal.session.room.timeline.DefaultTimelineService import im.vector.matrix.android.internal.session.room.timeline.GetContextOfEventTask import im.vector.matrix.android.internal.session.room.timeline.PaginationTask -import im.vector.matrix.android.internal.session.room.timeline.TimelineBoundaryCallback +import im.vector.matrix.android.internal.session.room.timeline.TimelineEventFactory import im.vector.matrix.android.internal.task.TaskExecutor import im.vector.matrix.android.internal.util.PagingRequestHelper import java.util.concurrent.Executors @@ -44,9 +44,9 @@ internal class RoomFactory(private val loadRoomMembersTask: LoadRoomMembersTask, fun instantiate(roomId: String): Room { val helper = PagingRequestHelper(Executors.newSingleThreadExecutor()) - val timelineBoundaryCallback = TimelineBoundaryCallback(roomId, taskExecutor, paginationTask, monarchy, helper) val roomMemberExtractor = RoomMemberExtractor(monarchy, roomId) - val timelineService = DefaultTimelineService(roomId, monarchy, taskExecutor, timelineBoundaryCallback, contextOfEventTask, roomMemberExtractor) + val timelineEventFactory = TimelineEventFactory(roomMemberExtractor) + val timelineService = DefaultTimelineService(roomId, monarchy, taskExecutor, contextOfEventTask, timelineEventFactory, paginationTask, helper) val sendService = DefaultSendService(roomId, eventFactory, monarchy) val readService = DefaultReadService(roomId, monarchy, setReadMarkersTask, taskExecutor) return DefaultRoom( diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/members/LoadRoomMembersTask.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/members/LoadRoomMembersTask.kt index ac20e816..788bb1c1 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/members/LoadRoomMembersTask.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/members/LoadRoomMembersTask.kt @@ -27,6 +27,7 @@ import im.vector.matrix.android.internal.session.room.RoomAPI import im.vector.matrix.android.internal.session.sync.SyncTokenStore import im.vector.matrix.android.internal.task.Task import im.vector.matrix.android.internal.util.tryTransactionSync +import io.realm.kotlin.createObject internal interface LoadRoomMembersTask : Task { @@ -60,7 +61,7 @@ internal class DefaultLoadRoomMembersTask(private val roomAPI: RoomAPI, .tryTransactionSync { realm -> // We ignore all the already known members val roomEntity = RoomEntity.where(realm, roomId).findFirst() - ?: throw IllegalStateException("You shouldn't use this method without a room") + ?: realm.createObject(roomId) val roomMembers = RoomMembers(realm, roomId).getLoaded() val eventsToInsert = response.roomMemberEvents.filter { !roomMembers.containsKey(it.stateKey) } @@ -73,9 +74,9 @@ internal class DefaultLoadRoomMembersTask(private val roomAPI: RoomAPI, private fun areAllMembersAlreadyLoaded(roomId: String): Boolean { return monarchy - .fetchAllCopiedSync { RoomEntity.where(it, roomId) } - .firstOrNull() - ?.areAllMembersLoaded ?: false + .fetchAllCopiedSync { RoomEntity.where(it, roomId) } + .firstOrNull() + ?.areAllMembersLoaded ?: false } } \ No newline at end of file diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/DefaultTimeline.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/DefaultTimeline.kt index e08a9a65..eca061db 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/DefaultTimeline.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/DefaultTimeline.kt @@ -18,20 +18,26 @@ package im.vector.matrix.android.internal.session.room.timeline -import com.zhuinden.monarchy.Monarchy +import androidx.annotation.UiThread +import im.vector.matrix.android.api.MatrixCallback import im.vector.matrix.android.api.session.room.timeline.Timeline import im.vector.matrix.android.api.session.room.timeline.TimelineEvent -import im.vector.matrix.android.internal.database.RealmLiveEntityObserver -import im.vector.matrix.android.internal.database.mapper.asDomain import im.vector.matrix.android.internal.database.model.ChunkEntityFields import im.vector.matrix.android.internal.database.model.EventEntity import im.vector.matrix.android.internal.database.model.EventEntityFields import im.vector.matrix.android.internal.database.query.where -import im.vector.matrix.android.internal.session.room.members.RoomMemberExtractor import im.vector.matrix.android.internal.task.TaskExecutor +import im.vector.matrix.android.internal.task.configureWith +import im.vector.matrix.android.internal.util.PagingRequestHelper import io.realm.OrderedCollectionChangeSet +import io.realm.OrderedRealmCollectionChangeListener +import io.realm.Realm +import io.realm.RealmConfiguration +import io.realm.RealmQuery import io.realm.RealmResults import io.realm.Sort +import java.util.* +import kotlin.collections.ArrayList private const val INITIAL_LOAD_SIZE = 30 @@ -39,103 +45,136 @@ private const val INITIAL_LOAD_SIZE = 30 internal class DefaultTimeline( private val roomId: String, private val initialEventId: String? = null, - private val monarchy: Monarchy, + private val realmConfiguration: RealmConfiguration, private val taskExecutor: TaskExecutor, - private val boundaryCallback: TimelineBoundaryCallback, private val contextOfEventTask: GetContextOfEventTask, - private val roomMemberExtractor: RoomMemberExtractor + private val timelineEventFactory: TimelineEventFactory, + private val paginationTask: PaginationTask, + private val helper: PagingRequestHelper ) : Timeline { + override var listener: Timeline.Listener? = null + + private lateinit var realm: Realm + private lateinit var liveEvents: RealmResults private var prevDisplayIndex: Int = 0 private var nextDisplayIndex: Int = 0 private val isLive = initialEventId == null + private val builtEvents = Collections.synchronizedList(ArrayList()) - private val listeners = mutableListOf() - private val builtEvents = mutableListOf() - private lateinit var liveResults: RealmResults - - private val entityObserver = object : RealmLiveEntityObserver(monarchy) { - - override val query: Monarchy.Query - get() = buildQuery(initialEventId) - - override fun onChanged(realmResults: RealmResults, changeSet: OrderedCollectionChangeSet) { + private val changeListener = OrderedRealmCollectionChangeListener> { _, changeSet -> + if (changeSet.state == OrderedCollectionChangeSet.State.INITIAL) { + handleInitialLoad() + } else { changeSet.insertionRanges.forEach { - val (startIndex, direction) = if (it.startIndex == 0) { - Pair(realmResults[it.length]!!.displayIndex, Timeline.Direction.FORWARDS) + val (startDisplayIndex, direction) = if (it.startIndex == 0) { + Pair(liveEvents[it.length - 1]!!.displayIndex, Timeline.Direction.FORWARDS) } else { - Pair(realmResults[it.startIndex]!!.displayIndex, Timeline.Direction.FORWARDS) + Pair(liveEvents[it.startIndex]!!.displayIndex, Timeline.Direction.BACKWARDS) } - addFromLiveResults(startIndex, direction, it.length.toLong()) - } - } - - override fun processInitialResults(results: RealmResults) { - // Results are ordered DESCENDING, so first items is the most recent - liveResults = results - val initialDisplayIndex = if (isLive) { - results.first()?.displayIndex - } else { - results.where().equalTo(EventEntityFields.EVENT_ID, initialEventId).findFirst()?.displayIndex - } ?: 0 - prevDisplayIndex = initialDisplayIndex - nextDisplayIndex = initialDisplayIndex - val count = Math.min(INITIAL_LOAD_SIZE, results.size).toLong() - if (isLive) { - addFromLiveResults(initialDisplayIndex, Timeline.Direction.BACKWARDS, count) - } else { - val forwardCount = count / 2L - val backwardCount = count - forwardCount - addFromLiveResults(initialDisplayIndex, Timeline.Direction.BACKWARDS, backwardCount) - addFromLiveResults(initialDisplayIndex, Timeline.Direction.BACKWARDS, forwardCount) + insertFromLiveResults(startDisplayIndex, direction, it.length.toLong()) } } } + @UiThread override fun paginate(direction: Timeline.Direction, count: Int) { - monarchy.postToMonarchyThread { - val startDisplayIndex = if (direction == Timeline.Direction.BACKWARDS) prevDisplayIndex else nextDisplayIndex - val shouldHitNetwork = addFromLiveResults(startDisplayIndex, direction, count.toLong()).not() - if (shouldHitNetwork) { - if (direction == Timeline.Direction.BACKWARDS) { - val itemAtEnd = builtEvents.last() - boundaryCallback.onItemAtEndLoaded(itemAtEnd) - } else { - val itemAtFront = builtEvents.first() - boundaryCallback.onItemAtFrontLoaded(itemAtFront) - } + if (direction == Timeline.Direction.FORWARDS && isLive) { + return + } + val startDisplayIndex = if (direction == Timeline.Direction.BACKWARDS) prevDisplayIndex else nextDisplayIndex + val hasBuiltCountItems = insertFromLiveResults(startDisplayIndex, direction, count.toLong()) + if (hasBuiltCountItems.not()) { + val token = getToken(direction) ?: return + helper.runIfNotRunning(direction.toRequestType()) { + executePaginationTask(it, token, direction.toPaginationDirection(), 30) } } } - override fun addListener(listener: Timeline.Listener) { - if (listeners.isEmpty()) { - entityObserver.start() - } - listeners.add(listener) + @UiThread + override fun start() { + realm = Realm.getInstance(realmConfiguration) + liveEvents = buildQuery(initialEventId).findAllAsync() + liveEvents.addChangeListener(changeListener) } - override fun removeListener(listener: Timeline.Listener) { - listeners.remove(listener) - if (listeners.isEmpty()) { - entityObserver.dispose() + @UiThread + override fun dispose() { + liveEvents.removeAllChangeListeners() + realm.close() + } + + override fun snapshot(): List = synchronized(builtEvents) { + return builtEvents.toList() + } + + override fun size(): Int = synchronized(builtEvents) { + return builtEvents.size + } + + private fun handleInitialLoad() = synchronized(builtEvents) { + val initialDisplayIndex = if (isLive) { + liveEvents.firstOrNull()?.displayIndex + } else { + liveEvents.where().equalTo(EventEntityFields.EVENT_ID, initialEventId).findFirst()?.displayIndex + } ?: 0 + prevDisplayIndex = initialDisplayIndex + nextDisplayIndex = initialDisplayIndex + val count = Math.min(INITIAL_LOAD_SIZE, liveEvents.size).toLong() + if (count == 0L) { + return@synchronized + } + if (isLive) { + insertFromLiveResults(initialDisplayIndex, Timeline.Direction.BACKWARDS, count) + } else { + val forwardCount = count / 2L + val backwardCount = count - forwardCount + insertFromLiveResults(initialDisplayIndex, Timeline.Direction.BACKWARDS, backwardCount) + insertFromLiveResults(initialDisplayIndex, Timeline.Direction.BACKWARDS, forwardCount) } } - override fun removeAllListeners() { - listeners.clear() - if (listeners.isEmpty()) { - entityObserver.dispose() - } + private fun executePaginationTask(requestCallback: PagingRequestHelper.Request.Callback, + from: String, + direction: PaginationDirection, + limit: Int) { + + val params = PaginationTask.Params(roomId = roomId, + from = from, + direction = direction, + limit = limit) + + paginationTask.configureWith(params) + .enableRetry() + .dispatchTo(object : MatrixCallback { + override fun onSuccess(data: Boolean) { + requestCallback.recordSuccess() + } + + override fun onFailure(failure: Throwable) { + requestCallback.recordFailure(failure) + } + }) + .executeBy(taskExecutor) + } + + private fun getToken(direction: Timeline.Direction): String? { + val chunkEntity = liveEvents.firstOrNull()?.chunk?.firstOrNull() ?: return null + return if (direction == Timeline.Direction.BACKWARDS) chunkEntity.prevToken else chunkEntity.nextToken } /** + * This has to be called on MonarchyThread as it access realm live results * @return true if count items has been added */ - private fun addFromLiveResults(startDisplayIndex: Int, - direction: Timeline.Direction, - count: Long): Boolean { + private fun insertFromLiveResults(startDisplayIndex: Int, + direction: Timeline.Direction, + count: Long): Boolean = synchronized(builtEvents) { + if (count < 1) { + throw java.lang.IllegalStateException("You should provide a count superior to 0") + } val offsetResults = getOffsetResults(startDisplayIndex, direction, count) if (offsetResults.isEmpty()) { return false @@ -147,18 +186,18 @@ internal class DefaultTimeline( nextDisplayIndex = offsetIndex + 1 } offsetResults.forEach { eventEntity -> - val roomMember = roomMemberExtractor.extractFrom(eventEntity) - val timelineEvent = TimelineEvent(eventEntity.asDomain(), eventEntity.localId, roomMember) + val timelineEvent = timelineEventFactory.create(eventEntity) val position = if (direction == Timeline.Direction.FORWARDS) 0 else builtEvents.size builtEvents.add(position, timelineEvent) } + listener?.onUpdated(snapshot()) return offsetResults.size.toLong() == count } private fun getOffsetResults(startDisplayIndex: Int, direction: Timeline.Direction, count: Long): RealmResults { - val offsetQuery = liveResults.where() + val offsetQuery = liveEvents.where() if (direction == Timeline.Direction.BACKWARDS) { offsetQuery .sort(EventEntityFields.DISPLAY_INDEX, Sort.DESCENDING) @@ -171,19 +210,27 @@ internal class DefaultTimeline( return offsetQuery.limit(count).findAll() } - private fun buildQuery(eventId: String?): Monarchy.Query { - return Monarchy.Query { realm -> - val query = if (eventId == null) { - EventEntity - .where(realm, roomId = roomId, linkFilterMode = EventEntity.LinkFilterMode.LINKED_ONLY) - .equalTo("${EventEntityFields.CHUNK}.${ChunkEntityFields.IS_LAST}", true) - } else { - EventEntity - .where(realm, roomId = roomId, linkFilterMode = EventEntity.LinkFilterMode.BOTH) - .`in`("${EventEntityFields.CHUNK}.${ChunkEntityFields.EVENTS.EVENT_ID}", arrayOf(eventId)) - } - query.sort(EventEntityFields.DISPLAY_INDEX, Sort.DESCENDING) + private fun buildQuery(eventId: String?): RealmQuery { + val query = if (eventId == null) { + EventEntity + .where(realm, roomId = roomId, linkFilterMode = EventEntity.LinkFilterMode.LINKED_ONLY) + .equalTo("${EventEntityFields.CHUNK}.${ChunkEntityFields.IS_LAST}", true) + } else { + EventEntity + .where(realm, roomId = roomId, linkFilterMode = EventEntity.LinkFilterMode.BOTH) + .`in`("${EventEntityFields.CHUNK}.${ChunkEntityFields.EVENTS.EVENT_ID}", arrayOf(eventId)) } + query.sort(EventEntityFields.DISPLAY_INDEX, Sort.DESCENDING) + return query + } + private fun Timeline.Direction.toRequestType(): PagingRequestHelper.RequestType { + return if (this == Timeline.Direction.BACKWARDS) PagingRequestHelper.RequestType.BEFORE else PagingRequestHelper.RequestType.AFTER + } + + //Todo : remove that + private fun Timeline.Direction.toPaginationDirection(): PaginationDirection { + return if (this == Timeline.Direction.BACKWARDS) PaginationDirection.BACKWARDS else PaginationDirection.FORWARDS + } } \ No newline at end of file diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/DefaultTimelineService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/DefaultTimelineService.kt index 9cf5dd47..5fa891f4 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/DefaultTimelineService.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/DefaultTimelineService.kt @@ -16,20 +16,17 @@ package im.vector.matrix.android.internal.session.room.timeline -import androidx.lifecycle.LiveData -import androidx.paging.LivePagedListBuilder import androidx.paging.PagedList import com.zhuinden.monarchy.Monarchy -import im.vector.matrix.android.api.session.room.timeline.* -import im.vector.matrix.android.internal.database.mapper.asDomain +import im.vector.matrix.android.api.session.room.timeline.Timeline +import im.vector.matrix.android.api.session.room.timeline.TimelineEventInterceptor +import im.vector.matrix.android.api.session.room.timeline.TimelineService import im.vector.matrix.android.internal.database.model.ChunkEntityFields import im.vector.matrix.android.internal.database.model.EventEntity import im.vector.matrix.android.internal.database.model.EventEntityFields import im.vector.matrix.android.internal.database.query.where -import im.vector.matrix.android.internal.session.room.members.RoomMemberExtractor import im.vector.matrix.android.internal.task.TaskExecutor import im.vector.matrix.android.internal.task.configureWith -import im.vector.matrix.android.internal.util.LiveDataUtils import im.vector.matrix.android.internal.util.PagingRequestHelper import im.vector.matrix.android.internal.util.tryTransactionAsync import io.realm.Realm @@ -43,42 +40,16 @@ private const val EVENT_NOT_FOUND_INDEX = -1 internal class DefaultTimelineService(private val roomId: String, private val monarchy: Monarchy, private val taskExecutor: TaskExecutor, - private val boundaryCallback: TimelineBoundaryCallback, private val contextOfEventTask: GetContextOfEventTask, - private val roomMemberExtractor: RoomMemberExtractor + private val timelineEventFactory: TimelineEventFactory, + private val paginationTask: PaginationTask, + private val helper: PagingRequestHelper ) : TimelineService { private val eventInterceptors = ArrayList() - override fun timeline(eventId: String?): LiveData { - clearUnlinkedEvents() - val initialLoadKey = getInitialLoadKey(eventId) - val realmDataSourceFactory = monarchy.createDataSourceFactory { - buildDataSourceFactoryQuery(it, eventId) - } - val domainSourceFactory = realmDataSourceFactory - .map { eventEntity -> - val roomMember = roomMemberExtractor.extractFrom(eventEntity) - TimelineEvent(eventEntity.asDomain(), eventEntity.localId, roomMember) - } - - val pagedListConfig = buildPagedListConfig() - - val livePagedListBuilder = LivePagedListBuilder(domainSourceFactory, pagedListConfig) - .setBoundaryCallback(boundaryCallback) - .setInitialLoadKey(initialLoadKey) - - val eventsLiveData = monarchy.findAllPagedWithChanges(realmDataSourceFactory, livePagedListBuilder) - - return LiveDataUtils.combine(eventsLiveData, boundaryCallback.status) { events, status -> - val isLoadingForward = status.before == PagingRequestHelper.Status.RUNNING - val isLoadingBackward = status.after == PagingRequestHelper.Status.RUNNING - TimelineData(events, isLoadingForward, isLoadingBackward) - } - } - override fun createTimeline(eventId: String?): Timeline { - return DefaultTimeline(roomId, eventId, monarchy, taskExecutor, boundaryCallback, contextOfEventTask, roomMemberExtractor) + return DefaultTimeline(roomId, eventId, monarchy.realmConfiguration, taskExecutor, contextOfEventTask, timelineEventFactory, paginationTask, helper) } // PRIVATE FUNCTIONS *************************************************************************** @@ -124,7 +95,8 @@ internal class DefaultTimelineService(private val roomId: String, private fun indexOfEvent(eventId: String): Int { var displayIndex = EVENT_NOT_FOUND_INDEX monarchy.doWithRealm { - displayIndex = EventEntity.where(it, eventId = eventId).findFirst()?.displayIndex ?: EVENT_NOT_FOUND_INDEX + displayIndex = EventEntity.where(it, eventId = eventId).findFirst()?.displayIndex + ?: EVENT_NOT_FOUND_INDEX } return displayIndex } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/TimelineEventFactory.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/TimelineEventFactory.kt new file mode 100644 index 00000000..d748fe7f --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/TimelineEventFactory.kt @@ -0,0 +1,36 @@ +/* + * Copyright 2019 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 im.vector.matrix.android.internal.session.room.timeline + +import im.vector.matrix.android.api.session.room.timeline.TimelineEvent +import im.vector.matrix.android.internal.database.mapper.asDomain +import im.vector.matrix.android.internal.database.model.EventEntity +import im.vector.matrix.android.internal.session.room.members.RoomMemberExtractor + +internal class TimelineEventFactory(private val roomMemberExtractor: RoomMemberExtractor) { + + fun create(eventEntity: EventEntity): TimelineEvent { + val roomMember = roomMemberExtractor.extractFrom(eventEntity) + return TimelineEvent( + eventEntity.asDomain(), + eventEntity.localId, + eventEntity.displayIndex, + roomMember + ) + } + +} \ No newline at end of file diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/TokenChunkEventPersistor.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/TokenChunkEventPersistor.kt index bb74f2cc..e728a140 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/TokenChunkEventPersistor.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/TokenChunkEventPersistor.kt @@ -18,7 +18,12 @@ package im.vector.matrix.android.internal.session.room.timeline import arrow.core.Try import com.zhuinden.monarchy.Monarchy -import im.vector.matrix.android.internal.database.helper.* +import im.vector.matrix.android.internal.database.helper.addAll +import im.vector.matrix.android.internal.database.helper.addOrUpdate +import im.vector.matrix.android.internal.database.helper.addStateEvents +import im.vector.matrix.android.internal.database.helper.deleteOnCascade +import im.vector.matrix.android.internal.database.helper.isUnlinked +import im.vector.matrix.android.internal.database.helper.merge import im.vector.matrix.android.internal.database.model.ChunkEntity import im.vector.matrix.android.internal.database.model.RoomEntity import im.vector.matrix.android.internal.database.query.create @@ -26,6 +31,7 @@ import im.vector.matrix.android.internal.database.query.find import im.vector.matrix.android.internal.database.query.findAllIncludingEvents import im.vector.matrix.android.internal.database.query.where import im.vector.matrix.android.internal.util.tryTransactionSync +import io.realm.kotlin.createObject internal class TokenChunkEventPersistor(private val monarchy: Monarchy) { @@ -40,7 +46,7 @@ internal class TokenChunkEventPersistor(private val monarchy: Monarchy) { return monarchy .tryTransactionSync { realm -> val roomEntity = RoomEntity.where(realm, roomId).findFirst() - ?: throw IllegalStateException("You shouldn't use this method without a room") + ?: realm.createObject(roomId) val nextToken: String? val prevToken: String? @@ -60,10 +66,10 @@ internal class TokenChunkEventPersistor(private val monarchy: Monarchy) { var currentChunk = if (direction == PaginationDirection.FORWARDS) { prevChunk?.apply { this.nextToken = nextToken } - ?: ChunkEntity.create(realm, prevToken, nextToken) + ?: ChunkEntity.create(realm, prevToken, nextToken) } else { nextChunk?.apply { this.prevToken = prevToken } - ?: ChunkEntity.create(realm, prevToken, nextToken) + ?: ChunkEntity.create(realm, prevToken, nextToken) } currentChunk.addAll(roomId, receivedChunk.events, direction, isUnlinked = currentChunk.isUnlinked())