From 4154df7c21773d05770a905758a51acd672cce66 Mon Sep 17 00:00:00 2001 From: ganfra Date: Thu, 21 Mar 2019 20:21:45 +0100 Subject: [PATCH] Timeline : stabilize the pagedList replacement. Seems ok for phase0 --- .../riotredesign/features/home/HomeModule.kt | 29 +- .../features/home/HomeNavigator.kt | 3 +- .../home/room/detail/RoomDetailFragment.kt | 18 +- .../home/room/detail/RoomDetailViewModel.kt | 12 +- .../room/detail/ScrollOnNewMessageCallback.kt | 3 - .../timeline/TimelineEventController.kt | 92 +-- .../animation/TimelineItemAnimator.kt | 6 +- .../timeline/factory/MessageItemFactory.kt | 9 +- .../EndlessRecyclerViewScrollListener.java | 114 ---- .../EndlessRecyclerViewScrollListener.kt | 73 +++ .../timeline/helper/TimelineAsyncHelper.kt | 19 +- .../helper/TimelineDisplayableEvents.kt | 57 ++ app/src/main/res/layout/item_empty.xml | 2 +- build.gradle | 2 +- .../room/timeline/TimelineHolderTest.kt | 7 +- .../api/session/room/timeline/Timeline.kt | 60 +- .../session/room/timeline/TimelineService.kt | 9 +- .../internal/database/RealmLiveData.kt | 49 ++ .../database/helper/ChunkEntityHelper.kt | 19 +- .../internal/database/model/ChunkEntity.kt | 9 +- .../internal/database/model/EventEntity.kt | 12 +- .../database/query/EventEntityQueries.kt | 2 +- .../internal/session/room/DefaultRoom.kt | 19 +- .../session/room/RoomAvatarResolver.kt | 4 +- .../internal/session/room/RoomFactory.kt | 7 +- .../session/room/RoomSummaryUpdater.kt | 4 +- .../room/members/RoomDisplayNameResolver.kt | 18 +- .../room/members/RoomMemberExtractor.kt | 26 +- .../session/room/timeline/DefaultTimeline.kt | 337 +++++++---- .../room/timeline/DefaultTimelineService.kt | 56 +- .../room/timeline/TimelineBoundaryCallback.kt | 110 ---- .../room/timeline/TokenChunkEventPersistor.kt | 19 +- .../internal/util/PagingRequestHelper.java | 530 ------------------ 33 files changed, 611 insertions(+), 1125 deletions(-) delete mode 100644 app/src/main/java/im/vector/riotredesign/features/home/room/detail/timeline/helper/EndlessRecyclerViewScrollListener.java create mode 100644 app/src/main/java/im/vector/riotredesign/features/home/room/detail/timeline/helper/EndlessRecyclerViewScrollListener.kt create mode 100644 app/src/main/java/im/vector/riotredesign/features/home/room/detail/timeline/helper/TimelineDisplayableEvents.kt create mode 100644 matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/RealmLiveData.kt delete mode 100644 matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/TimelineBoundaryCallback.kt delete mode 100644 matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/util/PagingRequestHelper.java diff --git a/app/src/main/java/im/vector/riotredesign/features/home/HomeModule.kt b/app/src/main/java/im/vector/riotredesign/features/home/HomeModule.kt index cb299d45..a013a0dc 100644 --- a/app/src/main/java/im/vector/riotredesign/features/home/HomeModule.kt +++ b/app/src/main/java/im/vector/riotredesign/features/home/HomeModule.kt @@ -19,16 +19,9 @@ package im.vector.riotredesign.features.home import androidx.fragment.app.Fragment import im.vector.riotredesign.core.glide.GlideApp import im.vector.riotredesign.features.home.group.GroupSummaryController -import im.vector.riotredesign.features.home.room.detail.timeline.factory.CallItemFactory -import im.vector.riotredesign.features.home.room.detail.timeline.factory.DefaultItemFactory -import im.vector.riotredesign.features.home.room.detail.timeline.factory.MessageItemFactory -import im.vector.riotredesign.features.home.room.detail.timeline.factory.RoomHistoryVisibilityItemFactory -import im.vector.riotredesign.features.home.room.detail.timeline.factory.RoomMemberItemFactory -import im.vector.riotredesign.features.home.room.detail.timeline.factory.RoomNameItemFactory -import im.vector.riotredesign.features.home.room.detail.timeline.factory.RoomTopicItemFactory -import im.vector.riotredesign.features.home.room.detail.timeline.helper.TimelineDateFormatter import im.vector.riotredesign.features.home.room.detail.timeline.TimelineEventController -import im.vector.riotredesign.features.home.room.detail.timeline.factory.TimelineItemFactory +import im.vector.riotredesign.features.home.room.detail.timeline.factory.* +import im.vector.riotredesign.features.home.room.detail.timeline.helper.TimelineDateFormatter import im.vector.riotredesign.features.home.room.detail.timeline.helper.TimelineMediaSizeProvider import im.vector.riotredesign.features.home.room.list.RoomSummaryController import im.vector.riotredesign.features.html.EventHtmlRenderer @@ -57,28 +50,28 @@ class HomeModule { // Fragment scopes - scope(ROOM_DETAIL_SCOPE) { (fragment: Fragment) -> + factory { (fragment: Fragment) -> val eventHtmlRenderer = EventHtmlRenderer(GlideApp.with(fragment), fragment.requireContext(), get()) val timelineDateFormatter = TimelineDateFormatter(get()) val timelineMediaSizeProvider = TimelineMediaSizeProvider() val messageItemFactory = MessageItemFactory(get(), timelineMediaSizeProvider, timelineDateFormatter, eventHtmlRenderer) val timelineItemFactory = TimelineItemFactory(messageItemFactory = messageItemFactory, - roomNameItemFactory = RoomNameItemFactory(get()), - roomTopicItemFactory = RoomTopicItemFactory(get()), - roomMemberItemFactory = RoomMemberItemFactory(get()), - roomHistoryVisibilityItemFactory = RoomHistoryVisibilityItemFactory(get()), - callItemFactory = CallItemFactory(get()), - defaultItemFactory = DefaultItemFactory() + roomNameItemFactory = RoomNameItemFactory(get()), + roomTopicItemFactory = RoomTopicItemFactory(get()), + roomMemberItemFactory = RoomMemberItemFactory(get()), + roomHistoryVisibilityItemFactory = RoomHistoryVisibilityItemFactory(get()), + callItemFactory = CallItemFactory(get()), + defaultItemFactory = DefaultItemFactory() ) TimelineEventController(timelineDateFormatter, timelineItemFactory, timelineMediaSizeProvider) } - scope(ROOM_LIST_SCOPE) { + factory { RoomSummaryController(get()) } - scope(GROUP_LIST_SCOPE) { + factory { GroupSummaryController() } diff --git a/app/src/main/java/im/vector/riotredesign/features/home/HomeNavigator.kt b/app/src/main/java/im/vector/riotredesign/features/home/HomeNavigator.kt index f6583b97..b0d5748a 100644 --- a/app/src/main/java/im/vector/riotredesign/features/home/HomeNavigator.kt +++ b/app/src/main/java/im/vector/riotredesign/features/home/HomeNavigator.kt @@ -37,7 +37,8 @@ class HomeNavigator { addToBackstack: Boolean = false) { Timber.v("Open room detail $roomId - $eventId - $addToBackstack") activity?.let { - val args = RoomDetailArgs(roomId, eventId) + //TODO enable eventId permalink. It doesn't work enough at the moment. + val args = RoomDetailArgs(roomId) val roomDetailFragment = RoomDetailFragment.newInstance(args) it.drawerLayout?.closeDrawer(Gravity.LEFT) if (addToBackstack) { 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 b8d1a5dc..8f9d494e 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 @@ -25,7 +25,6 @@ import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.airbnb.epoxy.EpoxyVisibilityTracker import com.airbnb.mvrx.fragmentViewModel -import im.vector.matrix.android.api.session.room.timeline.Timeline import im.vector.matrix.android.api.session.room.timeline.TimelineEvent import im.vector.riotredesign.R import im.vector.riotredesign.core.epoxy.LayoutManagerStateRestorer @@ -111,16 +110,11 @@ class RoomDetailFragment : RiotFragment(), TimelineEventController.Callback { it.dispatchTo(stateRestorer) it.dispatchTo(scrollOnNewMessageCallback) } - recyclerView.addOnScrollListener(object : EndlessRecyclerViewScrollListener(layoutManager, Timeline.Direction.BACKWARDS) { - override fun onLoadMore() { - roomDetailViewModel.process(RoomDetailActions.LoadMore(Timeline.Direction.BACKWARDS)) - } - }) - recyclerView.addOnScrollListener(object : EndlessRecyclerViewScrollListener(layoutManager, Timeline.Direction.FORWARDS) { - override fun onLoadMore() { - roomDetailViewModel.process(RoomDetailActions.LoadMore(Timeline.Direction.FORWARDS)) - } - }) + + recyclerView.addOnScrollListener( + EndlessRecyclerViewScrollListener(layoutManager, RoomDetailViewModel.PAGINATION_COUNT) { direction -> + roomDetailViewModel.process(RoomDetailActions.LoadMore(direction)) + }) recyclerView.setController(timelineEventController) timelineEventController.callback = this } @@ -153,7 +147,7 @@ class RoomDetailFragment : RiotFragment(), TimelineEventController.Callback { } } - // TimelineEventController.Callback ************************************************************ +// TimelineEventController.Callback ************************************************************ override fun onUrlClicked(url: String) { homePermalinkHandler.launch(url) 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 e2e394d2..4ebf4984 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 @@ -25,6 +25,7 @@ import im.vector.matrix.android.api.session.events.model.Event import im.vector.matrix.rx.rx import im.vector.riotredesign.core.platform.RiotViewModel import im.vector.riotredesign.features.home.room.VisibleRoomStore +import im.vector.riotredesign.features.home.room.detail.timeline.helper.TimelineDisplayableEvents import io.reactivex.rxkotlin.subscribeBy import org.koin.android.ext.android.get import java.util.concurrent.TimeUnit @@ -38,10 +39,12 @@ class RoomDetailViewModel(initialState: RoomDetailViewState, private val roomId = initialState.roomId private val eventId = initialState.eventId private val displayedEventsObservable = BehaviorRelay.create() - private val timeline = room.createTimeline(eventId) + private val timeline = room.createTimeline(eventId, TimelineDisplayableEvents.DISPLAYABLE_TYPES) companion object : MvRxViewModelFactory { + const val PAGINATION_COUNT = 50 + @JvmStatic override fun create(viewModelContext: ViewModelContext, state: RoomDetailViewState): RoomDetailViewModel? { val currentSession = viewModelContext.activity.get() @@ -52,7 +55,7 @@ class RoomDetailViewModel(initialState: RoomDetailViewState, init { observeRoomSummary() - observeDisplayedEvents() + observeEventDisplayedActions() room.loadRoomMembersIfNeeded() timeline.start() setState { copy(timeline = this@RoomDetailViewModel.timeline) } @@ -82,10 +85,10 @@ class RoomDetailViewModel(initialState: RoomDetailViewState, } private fun handleLoadMore(action: RoomDetailActions.LoadMore) { - timeline.paginate(action.direction, 50) + timeline.paginate(action.direction, PAGINATION_COUNT) } - private fun observeDisplayedEvents() { + private fun observeEventDisplayedActions() { // We are buffering scroll events for one second // and keep the most recent one to set the read receipt on. displayedEventsObservable @@ -111,5 +114,4 @@ class RoomDetailViewModel(initialState: RoomDetailViewState, timeline.dispose() super.onCleared() } - } \ No newline at end of file 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 6c93997b..0888f672 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 @@ -18,12 +18,9 @@ package im.vector.riotredesign.features.home.room.detail import androidx.recyclerview.widget.LinearLayoutManager import im.vector.riotredesign.core.platform.DefaultListUpdateCallback -import java.util.concurrent.atomic.AtomicBoolean class ScrollOnNewMessageCallback(private val layoutManager: LinearLayoutManager) : DefaultListUpdateCallback { - var isLocked = AtomicBoolean(true) - override fun onInserted(position: Int, count: Int) { 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 c16b9c94..0c6b28e1 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,6 +17,7 @@ package im.vector.riotredesign.features.home.room.detail.timeline import android.os.Handler +import android.os.Looper import android.view.View import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListUpdateCallback @@ -30,10 +31,7 @@ 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.TimelineAsyncHelper -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.helper.* import im.vector.riotredesign.features.home.room.detail.timeline.item.DaySeparatorItem_ import im.vector.riotredesign.features.media.MediaContentRenderer @@ -43,25 +41,41 @@ class TimelineEventController(private val dateFormatter: TimelineDateFormatter, private val backgroundHandler: Handler = TimelineAsyncHelper.getBackgroundHandler() ) : EpoxyController(backgroundHandler, backgroundHandler), Timeline.Listener { + interface Callback { + fun onEventVisible(event: TimelineEvent) + fun onUrlClicked(url: String) + fun onMediaClicked(mediaData: MediaContentRenderer.Data, view: View) + } + private val modelCache = arrayListOf>>() private var currentSnapshot: List = emptyList() + private var inSubmitList: Boolean = false + private var timeline: Timeline? = null + + var callback: Callback? = null private val listUpdateCallback = object : ListUpdateCallback { @Synchronized override fun onChanged(position: Int, count: Int, payload: Any?) { + assertUpdateCallbacksAllowed() (position until (position + count)).forEach { modelCache[it] = emptyList() } requestModelBuild() } + @Synchronized override fun onMoved(fromPosition: Int, toPosition: Int) { - //no-op + assertUpdateCallbacksAllowed() + val model = modelCache.removeAt(fromPosition) + modelCache.add(toPosition, model) + requestModelBuild() } @Synchronized - override fun onInserted(position: Int, count: Int) = synchronized(modelCache) { + override fun onInserted(position: Int, count: Int) { + assertUpdateCallbacksAllowed() if (modelCache.isNotEmpty() && position == modelCache.size) { modelCache[position - 1] = emptyList() } @@ -71,15 +85,16 @@ class TimelineEventController(private val dateFormatter: TimelineDateFormatter, requestModelBuild() } + @Synchronized override fun onRemoved(position: Int, count: Int) { - //no-op + assertUpdateCallbacksAllowed() + (0 until count).forEach { + modelCache.removeAt(position) + } + requestModelBuild() } - } - private var timeline: Timeline? = null - var callback: Callback? = null - init { requestModelBuild() } @@ -101,18 +116,34 @@ class TimelineEventController(private val dateFormatter: TimelineDateFormatter, .id("forward_loading_item") .addWhen(Timeline.Direction.FORWARDS) - add(getModels()) + + val timelineModels = getModels() + add(timelineModels) LoadingItemModel_() .id("backward_loading_item") .addWhen(Timeline.Direction.BACKWARDS) } - private fun LoadingItemModel_.addWhen(direction: Timeline.Direction) { - val shouldAdd = timeline?.let { - it.hasMoreToLoad(direction) || !it.hasReachedEnd(direction) - } ?: false - addIf(shouldAdd, this@TimelineEventController) + // Timeline.LISTENER *************************************************************************** + + override fun onUpdated(snapshot: List) { + submitSnapshot(snapshot) + } + + private fun submitSnapshot(newSnapshot: List) { + backgroundHandler.post { + inSubmitList = true + val diffCallback = TimelineEventDiffUtilCallback(currentSnapshot, newSnapshot) + currentSnapshot = newSnapshot + val diffResult = DiffUtil.calculateDiff(diffCallback) + diffResult.dispatchUpdatesTo(listUpdateCallback) + inSubmitList = false + } + } + + private fun assertUpdateCallbacksAllowed() { + require(inSubmitList || Looper.myLooper() == backgroundHandler.looper) } @Synchronized @@ -128,7 +159,7 @@ class TimelineEventController(private val dateFormatter: TimelineDateFormatter, private fun buildItemModels(currentPosition: Int, items: List): List> { val epoxyModels = ArrayList>() val event = items[currentPosition] - val nextEvent = if (currentPosition + 1 < items.size) items[currentPosition + 1] else null + val nextEvent = items.nextDisplayableEvent(currentPosition) val date = event.root.localDateTime() val nextDate = nextEvent?.root?.localDateTime() @@ -147,26 +178,11 @@ class TimelineEventController(private val dateFormatter: TimelineDateFormatter, return epoxyModels } - // Timeline.LISTENER *************************************************************************** - - override fun onUpdated(snapshot: List) { - submitSnapshot(snapshot) - } - - private fun submitSnapshot(newSnapshot: List) { - backgroundHandler.post { - val diffCallback = TimelineEventDiffUtilCallback(currentSnapshot, newSnapshot) - currentSnapshot = newSnapshot - val diffResult = DiffUtil.calculateDiff(diffCallback) - diffResult.dispatchUpdatesTo(listUpdateCallback) - } - } - - - interface Callback { - fun onEventVisible(event: TimelineEvent) - fun onUrlClicked(url: String) - fun onMediaClicked(mediaData: MediaContentRenderer.Data, view: View) + private fun LoadingItemModel_.addWhen(direction: Timeline.Direction) { + val shouldAdd = timeline?.let { + it.hasMoreToLoad(direction) + } ?: false + addIf(shouldAdd, this@TimelineEventController) } } diff --git a/app/src/main/java/im/vector/riotredesign/features/home/room/detail/timeline/animation/TimelineItemAnimator.kt b/app/src/main/java/im/vector/riotredesign/features/home/room/detail/timeline/animation/TimelineItemAnimator.kt index 8a9615db..94fe199c 100644 --- a/app/src/main/java/im/vector/riotredesign/features/home/room/detail/timeline/animation/TimelineItemAnimator.kt +++ b/app/src/main/java/im/vector/riotredesign/features/home/room/detail/timeline/animation/TimelineItemAnimator.kt @@ -24,9 +24,9 @@ class TimelineItemAnimator : DefaultItemAnimator() { init { addDuration = ANIM_DURATION_IN_MILLIS - removeDuration = ANIM_DURATION_IN_MILLIS - moveDuration = ANIM_DURATION_IN_MILLIS - changeDuration = ANIM_DURATION_IN_MILLIS + removeDuration = 0 + moveDuration = 0 + changeDuration = 0 } } \ No newline at end of file diff --git a/app/src/main/java/im/vector/riotredesign/features/home/room/detail/timeline/factory/MessageItemFactory.kt b/app/src/main/java/im/vector/riotredesign/features/home/room/detail/timeline/factory/MessageItemFactory.kt index a490d8fb..8d03e42b 100644 --- a/app/src/main/java/im/vector/riotredesign/features/home/room/detail/timeline/factory/MessageItemFactory.kt +++ b/app/src/main/java/im/vector/riotredesign/features/home/room/detail/timeline/factory/MessageItemFactory.kt @@ -42,8 +42,6 @@ class MessageItemFactory(private val colorProvider: ColorProvider, private val timelineDateFormatter: TimelineDateFormatter, private val htmlRenderer: EventHtmlRenderer) { - private val messagesDisplayedWithInformation = HashSet() - fun create(event: TimelineEvent, nextEvent: TimelineEvent?, callback: TimelineEventController.Callback? @@ -58,15 +56,12 @@ class MessageItemFactory(private val colorProvider: ColorProvider, val isNextMessageReceivedMoreThanOneHourAgo = nextDate?.isBefore(date.minusMinutes(60)) ?: false - if (addDaySeparator + val showInformation = addDaySeparator || nextRoomMember != roomMember || nextEvent?.root?.type != EventType.MESSAGE - || isNextMessageReceivedMoreThanOneHourAgo) { - messagesDisplayedWithInformation.add(event.root.eventId) - } + || isNextMessageReceivedMoreThanOneHourAgo val messageContent: MessageContent = event.root.content.toModel() ?: return null - val showInformation = messagesDisplayedWithInformation.contains(event.root.eventId) val time = timelineDateFormatter.formatMessageHour(date) val avatarUrl = roomMember?.avatarUrl val memberName = roomMember?.displayName ?: event.root.sender 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 deleted file mode 100644 index e77ed44f..00000000 --- a/app/src/main/java/im/vector/riotredesign/features/home/room/detail/timeline/helper/EndlessRecyclerViewScrollListener.java +++ /dev/null @@ -1,114 +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.helper; - -import androidx.annotation.NonNull; -import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.RecyclerView; -import im.vector.matrix.android.api.session.room.timeline.Timeline; - -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 = 50; - // 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 Timeline.Direction mDirection; - - public EndlessRecyclerViewScrollListener(LinearLayoutManager layoutManager, Timeline.Direction direction) { - this.mLayoutManager = layoutManager; - this.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 BACKWARDS: - // 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.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) { - onLoadMore(); - loading = true; - } - break; - case FORWARDS: - // 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.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) { - onLoadMore(); - loading = true; - } - break; - } - } - - // Defines the process for actually loading more data based on page - public abstract void onLoadMore(); - -} \ No newline at end of file diff --git a/app/src/main/java/im/vector/riotredesign/features/home/room/detail/timeline/helper/EndlessRecyclerViewScrollListener.kt b/app/src/main/java/im/vector/riotredesign/features/home/room/detail/timeline/helper/EndlessRecyclerViewScrollListener.kt new file mode 100644 index 00000000..425dd094 --- /dev/null +++ b/app/src/main/java/im/vector/riotredesign/features/home/room/detail/timeline/helper/EndlessRecyclerViewScrollListener.kt @@ -0,0 +1,73 @@ +/* + * 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.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import im.vector.matrix.android.api.session.room.timeline.Timeline + +class EndlessRecyclerViewScrollListener(private val layoutManager: LinearLayoutManager, + private val visibleThreshold: Int, + private val onLoadMore: (Timeline.Direction) -> Unit +) : RecyclerView.OnScrollListener() { + + // The total number of items in the dataset after the last load + private var previousTotalItemCount = 0 + // True if we are still waiting for the last set of data to load. + private var loadingBackwards = true + private var loadingForwards = true + + // 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 fun onScrolled(view: RecyclerView, dx: Int, dy: Int) { + val lastVisibleItemPosition = layoutManager.findLastVisibleItemPosition() + val firstVisibleItemPosition = layoutManager.findFirstVisibleItemPosition() + val totalItemCount = layoutManager.itemCount + + // The minimum amount of items to have below your current scroll position + // before loading more. + // 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) { + previousTotalItemCount = totalItemCount + if (totalItemCount == 0) { + loadingForwards = true + loadingBackwards = true + } + } + // If it’s still loading, we check to see if the dataset count has + // changed, if so we conclude it has finished loading + if (totalItemCount > previousTotalItemCount) { + loadingBackwards = false + loadingForwards = 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 (!loadingBackwards && lastVisibleItemPosition + visibleThreshold > totalItemCount) { + loadingBackwards = true + onLoadMore(Timeline.Direction.BACKWARDS) + } + if (!loadingForwards && firstVisibleItemPosition < visibleThreshold) { + loadingForwards = true + onLoadMore(Timeline.Direction.FORWARDS) + } + } + + +} \ No newline at end of file diff --git a/app/src/main/java/im/vector/riotredesign/features/home/room/detail/timeline/helper/TimelineAsyncHelper.kt b/app/src/main/java/im/vector/riotredesign/features/home/room/detail/timeline/helper/TimelineAsyncHelper.kt index f721413b..a54d5f83 100644 --- a/app/src/main/java/im/vector/riotredesign/features/home/room/detail/timeline/helper/TimelineAsyncHelper.kt +++ b/app/src/main/java/im/vector/riotredesign/features/home/room/detail/timeline/helper/TimelineAsyncHelper.kt @@ -25,23 +25,16 @@ private const val THREAD_NAME = "Timeline_Building_Thread" object TimelineAsyncHelper { - private var backgroundHandlerThread: HandlerThread? = null private var backgroundHandler: Handler? = null fun getBackgroundHandler(): Handler { - if (backgroundHandler != null) { - backgroundHandler?.removeCallbacksAndMessages(null) - } - if (backgroundHandlerThread != null) { - backgroundHandlerThread?.quit() - } + return backgroundHandler ?: createBackgroundHandler().also { backgroundHandler = it } + } + + private fun createBackgroundHandler(): Handler { val handlerThread = HandlerThread(THREAD_NAME) - .also { - backgroundHandlerThread = it - it.start() - } - val looper = handlerThread.looper - return Handler(looper).also { backgroundHandler = it } + handlerThread.start() + return Handler(handlerThread.looper) } } \ No newline at end of file diff --git a/app/src/main/java/im/vector/riotredesign/features/home/room/detail/timeline/helper/TimelineDisplayableEvents.kt b/app/src/main/java/im/vector/riotredesign/features/home/room/detail/timeline/helper/TimelineDisplayableEvents.kt new file mode 100644 index 00000000..1d3a8bf7 --- /dev/null +++ b/app/src/main/java/im/vector/riotredesign/features/home/room/detail/timeline/helper/TimelineDisplayableEvents.kt @@ -0,0 +1,57 @@ +/* + * 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 im.vector.matrix.android.api.session.events.model.EventType +import im.vector.matrix.android.api.session.room.timeline.TimelineEvent + +object TimelineDisplayableEvents { + + val DISPLAYABLE_TYPES = listOf( + EventType.MESSAGE, + EventType.STATE_ROOM_NAME, + EventType.STATE_ROOM_TOPIC, + EventType.STATE_ROOM_MEMBER, + EventType.STATE_HISTORY_VISIBILITY, + EventType.CALL_INVITE, + EventType.CALL_HANGUP, + EventType.CALL_ANSWER, + EventType.ENCRYPTED, + EventType.ENCRYPTION, + EventType.STATE_ROOM_THIRD_PARTY_INVITE, + EventType.STICKER, + EventType.STATE_ROOM_CREATE + ) +} + +fun TimelineEvent.isDisplayable(): Boolean { + return TimelineDisplayableEvents.DISPLAYABLE_TYPES.contains(root.type) +} + +fun List.filterDisplayableEvents(): List { + return this.filter { + it.isDisplayable() + } +} + +fun List.nextDisplayableEvent(index: Int): TimelineEvent? { + return if (index == size - 1) { + null + } else { + subList(index + 1, this.size).firstOrNull { it.isDisplayable() } + } +} \ No newline at end of file diff --git a/app/src/main/res/layout/item_empty.xml b/app/src/main/res/layout/item_empty.xml index f7afb775..c8dee60c 100644 --- a/app/src/main/res/layout/item_empty.xml +++ b/app/src/main/res/layout/item_empty.xml @@ -1,4 +1,4 @@ \ No newline at end of file + android:layout_height="0dp" /> \ No newline at end of file diff --git a/build.gradle b/build.gradle index 226ca00e..92421278 100644 --- a/build.gradle +++ b/build.gradle @@ -8,7 +8,7 @@ buildscript { jcenter() } dependencies { - classpath 'com.android.tools.build:gradle:3.3.0' + classpath 'com.android.tools.build:gradle:3.3.2' classpath "com.airbnb.okreplay:gradle-plugin:1.4.0" classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" 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 917b955b..2063a618 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 @@ -21,13 +21,10 @@ import androidx.test.annotation.UiThreadTest import com.zhuinden.monarchy.Monarchy import im.vector.matrix.android.InstrumentedTest import im.vector.matrix.android.LiveDataTestObserver -import im.vector.matrix.android.MainThreadExecutor import im.vector.matrix.android.internal.session.room.members.RoomMemberExtractor import im.vector.matrix.android.internal.session.room.timeline.DefaultTimelineService -import im.vector.matrix.android.internal.session.room.timeline.TimelineBoundaryCallback import im.vector.matrix.android.internal.session.room.timeline.TokenChunkEventPersistor import im.vector.matrix.android.internal.task.TaskExecutor -import im.vector.matrix.android.internal.util.PagingRequestHelper import im.vector.matrix.android.testCoroutineDispatchers import io.realm.Realm import io.realm.RealmConfiguration @@ -56,10 +53,8 @@ internal class TimelineHolderTest : InstrumentedTest { val tokenChunkEventPersistor = TokenChunkEventPersistor(monarchy) val paginationTask = FakePaginationTask(tokenChunkEventPersistor) val getContextOfEventTask = FakeGetContextOfEventTask(tokenChunkEventPersistor) - val boundaryCallback = TimelineBoundaryCallback(roomId, taskExecutor, paginationTask, monarchy, PagingRequestHelper(MainThreadExecutor())) - RoomDataHelper.fakeInitialSync(monarchy, roomId) - val timelineHolder = DefaultTimelineService(roomId, monarchy, taskExecutor, boundaryCallback, getContextOfEventTask, RoomMemberExtractor(monarchy, roomId)) + val timelineHolder = DefaultTimelineService(roomId, monarchy, taskExecutor, getContextOfEventTask, RoomMemberExtractor(roomId)) val timelineObserver = LiveDataTestObserver.test(timelineHolder.timeline()) timelineObserver.awaitNextValue().assertHasValue() var timelineData = timelineObserver.value() 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 1a09c591..2c2530bb 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 @@ -18,35 +18,67 @@ package im.vector.matrix.android.api.session.room.timeline +/** + * A Timeline instance represents a contiguous sequence of events in a room. + *

+ * There are two kinds of timeline: + *

+ * - live timelines: they process live events from the sync. You can paginate + * backwards but not forwards. + *

+ * - past timelines: they start in the past from an `initialEventId`. You can paginate + * backwards and forwards. + * + */ interface Timeline { var listener: Timeline.Listener? - fun hasMoreToLoad(direction: Direction): Boolean - fun hasReachedEnd(direction: Direction): Boolean - fun size(): Int - fun snapshot(): List - fun paginate(direction: Direction, count: Int) + /** + * This should be called before any other method after creating the timeline. It ensures the underlying database is open + */ fun start() + + /** + * This should be called when you don't need the timeline. It ensures the underlying database get closed. + */ fun dispose() + /** + * Check if the timeline can be enriched by paginating. + * @param the direction to check in + * @return true if timeline can be enriched + */ + fun hasMoreToLoad(direction: Direction): Boolean + + /** + * This is the main method to enrich the timeline with new data. + * It will call the onUpdated method from [Listener] when the data will be processed. + * It also ensures only one pagination by direction is launched at a time, so you can safely call this multiple time in a row. + */ + fun paginate(direction: Direction, count: Int) + + interface Listener { + /** + * Call when the timeline has been updated through pagination or sync. + * @param snapshot the most uptodate snapshot + */ fun onUpdated(snapshot: List) } - enum class Direction(val value: String) { + /** + * This is used to paginate in one or another direction. + */ + enum class Direction { /** - * Forwards when the event is added to the end of the timeline. - * These events come from the /sync stream or from forwards pagination. + * It represents future events. */ - FORWARDS("f"), - + FORWARDS, /** - * Backwards when the event is added to the start of the timeline. - * These events come from a back pagination. + * It represents past events. */ - BACKWARDS("b"); + BACKWARDS } - } \ No newline at end of file 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 28af1fe2..0980cfc5 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 @@ -21,6 +21,13 @@ package im.vector.matrix.android.api.session.room.timeline */ interface TimelineService { - fun createTimeline(eventId: String?): Timeline + /** + * Instantiate a [Timeline] with an optional initial eventId, to be used with permalink. + * You can filter the type you want to grab with the allowedTypes param. + * @param eventId the optional initial eventId. + * @param allowedTypes the optional filter types + * @return the instantiated timeline + */ + fun createTimeline(eventId: String?, allowedTypes: List? = null): Timeline } \ No newline at end of file diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/RealmLiveData.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/RealmLiveData.kt new file mode 100644 index 00000000..6002d4d4 --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/RealmLiveData.kt @@ -0,0 +1,49 @@ +/* + * + * * 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.database + +import androidx.lifecycle.LiveData +import io.realm.* + +class RealmLiveData(private val realmConfiguration: RealmConfiguration, + private val query: (Realm) -> RealmQuery) : LiveData>() { + + private val listener = RealmChangeListener> { results -> + value = results + } + + private var realm: Realm? = null + private var results: RealmResults? = null + + override fun onActive() { + val realm = Realm.getInstance(realmConfiguration) + val results = query.invoke(realm).findAll() + value = results + results.addChangeListener(listener) + this.realm = realm + this.results = results + } + + override fun onInactive() { + results?.removeChangeListener(listener) + results = null + realm?.close() + realm = null + } +} \ No newline at end of file diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/helper/ChunkEntityHelper.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/helper/ChunkEntityHelper.kt index ed80750d..e352e847 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/helper/ChunkEntityHelper.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/helper/ChunkEntityHelper.kt @@ -54,11 +54,11 @@ internal fun ChunkEntity.merge(roomId: String, if (direction == PaginationDirection.FORWARDS) { this.nextToken = chunkToMerge.nextToken this.isLastForward = chunkToMerge.isLastForward - eventsToMerge = chunkToMerge.events.reversed() + eventsToMerge = chunkToMerge.events.sort(EventEntityFields.DISPLAY_INDEX, Sort.ASCENDING) } else { this.prevToken = chunkToMerge.prevToken this.isLastBackward = chunkToMerge.isLastBackward - eventsToMerge = chunkToMerge.events + eventsToMerge = chunkToMerge.events.sort(EventEntityFields.DISPLAY_INDEX, Sort.DESCENDING) } eventsToMerge.forEach { add(roomId, it.asDomain(), direction, isUnlinked = isUnlinked) @@ -107,7 +107,8 @@ internal fun ChunkEntity.add(roomId: String, this.displayIndex = currentDisplayIndex } // We are not using the order of the list, but will be sorting with displayIndex field - events.add(eventEntity) + val position = if (direction == PaginationDirection.FORWARDS) 0 else this.events.size + events.add(position, eventEntity) } private fun ChunkEntity.assertIsManaged() { @@ -118,14 +119,14 @@ private fun ChunkEntity.assertIsManaged() { internal fun ChunkEntity.lastDisplayIndex(direction: PaginationDirection, defaultValue: Int = 0): Int { return when (direction) { - PaginationDirection.FORWARDS -> events.where().sort(EventEntityFields.DISPLAY_INDEX, Sort.DESCENDING).findFirst()?.displayIndex - PaginationDirection.BACKWARDS -> events.where().sort(EventEntityFields.DISPLAY_INDEX, Sort.ASCENDING).findFirst()?.displayIndex - } ?: defaultValue + PaginationDirection.FORWARDS -> events.where().sort(EventEntityFields.DISPLAY_INDEX, Sort.DESCENDING).findFirst()?.displayIndex + PaginationDirection.BACKWARDS -> events.where().sort(EventEntityFields.DISPLAY_INDEX, Sort.ASCENDING).findFirst()?.displayIndex + } ?: defaultValue } internal fun ChunkEntity.lastStateIndex(direction: PaginationDirection, defaultValue: Int = 0): Int { return when (direction) { - PaginationDirection.FORWARDS -> events.where().sort(EventEntityFields.STATE_INDEX, Sort.DESCENDING).findFirst()?.stateIndex - PaginationDirection.BACKWARDS -> events.where().sort(EventEntityFields.STATE_INDEX, Sort.ASCENDING).findFirst()?.stateIndex - } ?: defaultValue + PaginationDirection.FORWARDS -> events.where().sort(EventEntityFields.STATE_INDEX, Sort.DESCENDING).findFirst()?.stateIndex + PaginationDirection.BACKWARDS -> events.where().sort(EventEntityFields.STATE_INDEX, Sort.ASCENDING).findFirst()?.stateIndex + } ?: defaultValue } \ No newline at end of file diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/ChunkEntity.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/ChunkEntity.kt index 1c7c755a..dd3520e9 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/ChunkEntity.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/ChunkEntity.kt @@ -19,13 +19,14 @@ package im.vector.matrix.android.internal.database.model import io.realm.RealmList import io.realm.RealmObject import io.realm.RealmResults +import io.realm.annotations.Index import io.realm.annotations.LinkingObjects -internal open class ChunkEntity(var prevToken: String? = null, - var nextToken: String? = null, +internal open class ChunkEntity(@Index var prevToken: String? = null, + @Index var nextToken: String? = null, var events: RealmList = RealmList(), - var isLastForward: Boolean = false, - var isLastBackward: Boolean = false + @Index var isLastForward: Boolean = false, + @Index var isLastBackward: Boolean = false ) : RealmObject() { @LinkingObjects("chunks") 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 5a7b04d8..c688a11c 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 @@ -26,17 +26,17 @@ import java.util.* internal open class EventEntity(@PrimaryKey var localId: String = UUID.randomUUID().toString(), @Index var eventId: String = "", var roomId: String = "", - var type: String = "", + @Index var type: String = "", var content: String? = null, var prevContent: String? = null, - var stateKey: String? = null, + @Index var stateKey: String? = null, var originServerTs: Long? = null, - var sender: String? = null, + @Index var sender: String? = null, var age: Long? = 0, var redacts: String? = null, - var stateIndex: Int = 0, - var displayIndex: Int = 0, - var isUnlinked: Boolean = false + @Index var stateIndex: Int = 0, + @Index var displayIndex: Int = 0, + @Index var isUnlinked: Boolean = false ) : RealmObject() { enum class LinkFilterMode { diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/query/EventEntityQueries.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/query/EventEntityQueries.kt index a86ec2c0..c500a880 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/query/EventEntityQueries.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/query/EventEntityQueries.kt @@ -77,7 +77,7 @@ internal fun RealmQuery.next(from: Int? = null, strict: Boolean = t .findFirst() } -internal fun RealmQuery.last(since: Int? = null, strict: Boolean = false): EventEntity? { +internal fun RealmQuery.prev(since: Int? = null, strict: Boolean = false): EventEntity? { if (since != null) { if (strict) { this.lessThan(EventEntityFields.STATE_INDEX, since) diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/DefaultRoom.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/DefaultRoom.kt index de3de801..ae6255a1 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/DefaultRoom.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/DefaultRoom.kt @@ -26,6 +26,7 @@ import im.vector.matrix.android.api.session.room.read.ReadService import im.vector.matrix.android.api.session.room.send.SendService import im.vector.matrix.android.api.session.room.timeline.TimelineService import im.vector.matrix.android.api.util.Cancelable +import im.vector.matrix.android.internal.database.RealmLiveData import im.vector.matrix.android.internal.database.mapper.asDomain import im.vector.matrix.android.internal.database.model.RoomSummaryEntity import im.vector.matrix.android.internal.database.model.RoomSummaryEntityFields @@ -45,18 +46,16 @@ internal class DefaultRoom( ) : Room, - TimelineService by timelineService, - SendService by sendService, - ReadService by readService { + TimelineService by timelineService, + SendService by sendService, + ReadService by readService { override val roomSummary: LiveData by lazy { - val liveData = monarchy - .findAllMappedWithChanges( - { realm -> RoomSummaryEntity.where(realm, roomId).isNotEmpty(RoomSummaryEntityFields.DISPLAY_NAME) }, - { from -> from.asDomain() }) - - Transformations.map(liveData) { - it.first() + val liveRealmData = RealmLiveData(monarchy.realmConfiguration) { realm -> + RoomSummaryEntity.where(realm, roomId).isNotEmpty(RoomSummaryEntityFields.DISPLAY_NAME) + } + Transformations.map(liveRealmData) { results -> + results.map { it.asDomain() }.first() } } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomAvatarResolver.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomAvatarResolver.kt index 235d18e7..c6786508 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomAvatarResolver.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomAvatarResolver.kt @@ -25,7 +25,7 @@ import im.vector.matrix.android.api.session.room.model.RoomAvatarContent import im.vector.matrix.android.internal.database.mapper.asDomain import im.vector.matrix.android.internal.database.model.EventEntity import im.vector.matrix.android.internal.database.model.RoomEntity -import im.vector.matrix.android.internal.database.query.last +import im.vector.matrix.android.internal.database.query.prev import im.vector.matrix.android.internal.database.query.where import im.vector.matrix.android.internal.session.room.members.RoomMembers @@ -41,7 +41,7 @@ internal class RoomAvatarResolver(private val monarchy: Monarchy, var res: String? = null monarchy.doWithRealm { realm -> val roomEntity = RoomEntity.where(realm, roomId).findFirst() - val roomName = EventEntity.where(realm, roomId, EventType.STATE_ROOM_AVATAR).last()?.asDomain() + val roomName = EventEntity.where(realm, roomId, EventType.STATE_ROOM_AVATAR).prev()?.asDomain() res = roomName?.content.toModel()?.avatarUrl if (!res.isNullOrEmpty()) { return@doWithRealm 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 6183be5c..b30ba3ee 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 @@ -30,8 +30,6 @@ import im.vector.matrix.android.internal.session.room.timeline.GetContextOfEvent import im.vector.matrix.android.internal.session.room.timeline.PaginationTask 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 internal class RoomFactory(private val loadRoomMembersTask: LoadRoomMembersTask, private val monarchy: Monarchy, @@ -43,10 +41,9 @@ internal class RoomFactory(private val loadRoomMembersTask: LoadRoomMembersTask, private val taskExecutor: TaskExecutor) { fun instantiate(roomId: String): Room { - val helper = PagingRequestHelper(Executors.newSingleThreadExecutor()) - val roomMemberExtractor = RoomMemberExtractor(monarchy, roomId) + val roomMemberExtractor = RoomMemberExtractor(roomId) val timelineEventFactory = TimelineEventFactory(roomMemberExtractor) - val timelineService = DefaultTimelineService(roomId, monarchy, taskExecutor, contextOfEventTask, timelineEventFactory, paginationTask, helper) + val timelineService = DefaultTimelineService(roomId, monarchy, taskExecutor, contextOfEventTask, timelineEventFactory, paginationTask) 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/RoomSummaryUpdater.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomSummaryUpdater.kt index 590d6ce8..f847237a 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomSummaryUpdater.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomSummaryUpdater.kt @@ -27,8 +27,8 @@ import im.vector.matrix.android.internal.database.mapper.asDomain import im.vector.matrix.android.internal.database.model.EventEntity import im.vector.matrix.android.internal.database.model.RoomEntity import im.vector.matrix.android.internal.database.model.RoomSummaryEntity -import im.vector.matrix.android.internal.database.query.last import im.vector.matrix.android.internal.database.query.latestEvent +import im.vector.matrix.android.internal.database.query.prev import im.vector.matrix.android.internal.database.query.where import im.vector.matrix.android.internal.session.room.members.RoomDisplayNameResolver import im.vector.matrix.android.internal.session.room.members.RoomMembers @@ -59,7 +59,7 @@ internal class RoomSummaryUpdater(monarchy: Monarchy, ?: realm.createObject(roomId) val lastEvent = EventEntity.latestEvent(realm, roomId, includedTypes = listOf(EventType.MESSAGE)) - val lastTopicEvent = EventEntity.where(realm, roomId, EventType.STATE_ROOM_TOPIC).last()?.asDomain() + val lastTopicEvent = EventEntity.where(realm, roomId, EventType.STATE_ROOM_TOPIC).prev()?.asDomain() val otherRoomMembers = RoomMembers(realm, roomId).getLoaded().filterKeys { it != credentials.userId } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/members/RoomDisplayNameResolver.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/members/RoomDisplayNameResolver.kt index 7eb0f85f..f39dd300 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/members/RoomDisplayNameResolver.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/members/RoomDisplayNameResolver.kt @@ -30,7 +30,7 @@ import im.vector.matrix.android.internal.database.mapper.asDomain import im.vector.matrix.android.internal.database.model.EventEntity import im.vector.matrix.android.internal.database.model.RoomEntity import im.vector.matrix.android.internal.database.model.RoomSummaryEntity -import im.vector.matrix.android.internal.database.query.last +import im.vector.matrix.android.internal.database.query.prev import im.vector.matrix.android.internal.database.query.where /** @@ -58,19 +58,19 @@ internal class RoomDisplayNameResolver(private val monarchy: Monarchy, var name: CharSequence? = null monarchy.doWithRealm { realm -> val roomEntity = RoomEntity.where(realm, roomId = roomId).findFirst() - val roomName = EventEntity.where(realm, roomId, EventType.STATE_ROOM_NAME).last()?.asDomain() + val roomName = EventEntity.where(realm, roomId, EventType.STATE_ROOM_NAME).prev()?.asDomain() name = roomName?.content.toModel()?.name if (!name.isNullOrEmpty()) { return@doWithRealm } - val canonicalAlias = EventEntity.where(realm, roomId, EventType.STATE_CANONICAL_ALIAS).last()?.asDomain() + val canonicalAlias = EventEntity.where(realm, roomId, EventType.STATE_CANONICAL_ALIAS).prev()?.asDomain() name = canonicalAlias?.content.toModel()?.canonicalAlias if (!name.isNullOrEmpty()) { return@doWithRealm } - val aliases = EventEntity.where(realm, roomId, EventType.STATE_ROOM_ALIASES).last()?.asDomain() + val aliases = EventEntity.where(realm, roomId, EventType.STATE_ROOM_ALIASES).prev()?.asDomain() name = aliases?.content.toModel()?.aliases?.firstOrNull() if (!name.isNullOrEmpty()) { return@doWithRealm @@ -111,16 +111,16 @@ internal class RoomDisplayNameResolver(private val monarchy: Monarchy, val member1 = memberIds[0] val member2 = memberIds[1] name = context.getString(R.string.room_displayname_two_members, - roomMemberDisplayNameResolver.resolve(member1, otherRoomMembers), - roomMemberDisplayNameResolver.resolve(member2, otherRoomMembers) + roomMemberDisplayNameResolver.resolve(member1, otherRoomMembers), + roomMemberDisplayNameResolver.resolve(member2, otherRoomMembers) ) } else -> { val member = memberIds[0] name = context.resources.getQuantityString(R.plurals.room_displayname_three_and_more_members, - roomMembers.getNumberOfJoinedMembers() - 1, - roomMemberDisplayNameResolver.resolve(member, otherRoomMembers), - roomMembers.getNumberOfJoinedMembers() - 1) + roomMembers.getNumberOfJoinedMembers() - 1, + roomMemberDisplayNameResolver.resolve(member, otherRoomMembers), + roomMembers.getNumberOfJoinedMembers() - 1) } } } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/members/RoomMemberExtractor.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/members/RoomMemberExtractor.kt index 79d4a176..f2ec6a05 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/members/RoomMemberExtractor.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/members/RoomMemberExtractor.kt @@ -16,20 +16,19 @@ package im.vector.matrix.android.internal.session.room.members -import com.zhuinden.monarchy.Monarchy import im.vector.matrix.android.api.session.events.model.EventType import im.vector.matrix.android.api.session.events.model.toModel import im.vector.matrix.android.api.session.room.model.RoomMember import im.vector.matrix.android.internal.database.mapper.ContentMapper 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.last import im.vector.matrix.android.internal.database.query.next +import im.vector.matrix.android.internal.database.query.prev import im.vector.matrix.android.internal.database.query.where +import io.realm.Realm import io.realm.RealmQuery -internal class RoomMemberExtractor(private val monarchy: Monarchy, - private val roomId: String) { +internal class RoomMemberExtractor(private val roomId: String) { private val cached = HashMap() @@ -44,30 +43,25 @@ internal class RoomMemberExtractor(private val monarchy: Monarchy, // When stateIndex is negative, we try to get the next stateEvent prevContent() // If prevContent is null we fallback to the Int.MIN state events content() val content = if (event.stateIndex <= 0) { - baseQuery(monarchy, roomId, sender, unlinked).next(from = event.stateIndex)?.prevContent - ?: baseQuery(monarchy, roomId, sender, unlinked).last(since = event.stateIndex)?.content + baseQuery(event.realm, roomId, sender, unlinked).next(from = event.stateIndex)?.prevContent + ?: baseQuery(event.realm, roomId, sender, unlinked).prev(since = event.stateIndex)?.content } else { - baseQuery(monarchy, roomId, sender, unlinked).last(since = event.stateIndex)?.content + baseQuery(event.realm, roomId, sender, unlinked).prev(since = event.stateIndex)?.content } val roomMember: RoomMember? = ContentMapper.map(content).toModel() cached[cacheKey] = roomMember return roomMember } - private fun baseQuery(monarchy: Monarchy, + private fun baseQuery(realm: Realm, roomId: String, sender: String, isUnlinked: Boolean): RealmQuery { - lateinit var query: RealmQuery val filterMode = if (isUnlinked) EventEntity.LinkFilterMode.UNLINKED_ONLY else EventEntity.LinkFilterMode.LINKED_ONLY - monarchy.doWithRealm { realm -> - query = EventEntity - .where(realm, roomId = roomId, type = EventType.STATE_ROOM_MEMBER, linkFilterMode = filterMode) - .equalTo(EventEntityFields.STATE_KEY, sender) - } - return query + return EventEntity + .where(realm, roomId = roomId, type = EventType.STATE_ROOM_MEMBER, linkFilterMode = filterMode) + .equalTo(EventEntityFields.STATE_KEY, sender) } - } \ 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 b85d8280..ef037ac5 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 @@ -20,6 +20,7 @@ package im.vector.matrix.android.internal.session.room.timeline import android.os.Handler import android.os.HandlerThread +import android.os.Looper import im.vector.matrix.android.api.MatrixCallback import im.vector.matrix.android.api.session.events.model.EventType import im.vector.matrix.android.api.session.room.timeline.Timeline @@ -30,19 +31,21 @@ import im.vector.matrix.android.internal.database.model.ChunkEntity 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.findIncludingEvent +import im.vector.matrix.android.internal.database.query.findLastLiveChunkFromRoom import im.vector.matrix.android.internal.database.query.where 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.* +import timber.log.Timber import java.util.* -import java.util.concurrent.CountDownLatch import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicReference -import kotlin.collections.ArrayList private const val INITIAL_LOAD_SIZE = 20 +private const val MIN_FETCHING_COUNT = 30 +private const val DISPLAY_INDEX_UNKNOWN = Int.MIN_VALUE private const val THREAD_NAME = "TIMELINE_DB_THREAD" internal class DefaultTimeline( @@ -53,153 +56,237 @@ internal class DefaultTimeline( private val contextOfEventTask: GetContextOfEventTask, private val timelineEventFactory: TimelineEventFactory, private val paginationTask: PaginationTask, - private val helper: PagingRequestHelper + private val allowedTypes: List? ) : Timeline { override var listener: Timeline.Listener? = null set(value) { field = value - listener?.onUpdated(snapshot()) + backgroundHandler.get()?.post { + val snapshot = snapshot() + mainHandler.post { listener?.onUpdated(snapshot) } + } } private val isStarted = AtomicBoolean(false) - private val handlerThread = AtomicReference() - private val handler = AtomicReference() - private val realm = AtomicReference() - + private val isReady = AtomicBoolean(false) + private val backgroundHandlerThread = AtomicReference() + private val backgroundHandler = AtomicReference() + private val mainHandler = Handler(Looper.getMainLooper()) + private val backgroundRealm = AtomicReference() private val cancelableBag = CancelableBag() + private lateinit var liveEvents: RealmResults - private var prevDisplayIndex: Int = 0 - private var nextDisplayIndex: Int = 0 + + private var prevDisplayIndex: Int = DISPLAY_INDEX_UNKNOWN + private var nextDisplayIndex: Int = DISPLAY_INDEX_UNKNOWN private val isLive = initialEventId == null private val builtEvents = Collections.synchronizedList(ArrayList()) + private val backwardsPaginationState = AtomicReference(PaginationState()) + private val forwardsPaginationState = AtomicReference(PaginationState()) + private val eventsChangeListener = OrderedRealmCollectionChangeListener> { _, changeSet -> - if (changeSet.state == OrderedCollectionChangeSet.State.INITIAL) { - handleInitialLoad() - } else { - changeSet.insertionRanges.forEach { - val (startDisplayIndex, direction) = if (it.startIndex == 0) { - Pair(liveEvents[it.length - 1]!!.displayIndex, Timeline.Direction.FORWARDS) - } else { - Pair(liveEvents[it.startIndex]!!.displayIndex, Timeline.Direction.BACKWARDS) - } - insertFromLiveResults(startDisplayIndex, direction, it.length.toLong()) + // TODO HANDLE CHANGES + changeSet.insertionRanges.forEach { range -> + val (startDisplayIndex, direction) = if (range.startIndex == 0) { + Pair(liveEvents[range.length - 1]!!.displayIndex, Timeline.Direction.FORWARDS) + } else { + Pair(liveEvents[range.startIndex]!!.displayIndex, Timeline.Direction.BACKWARDS) + } + val state = getPaginationState(direction) + if (state.isPaginating) { + // We are getting new items from pagination + paginateInternal(startDisplayIndex, direction, state.requestedCount) + } else { + // We are getting new items from sync + buildTimelineEvents(startDisplayIndex, direction, range.length.toLong()) } } } + // Public methods ****************************************************************************** + override fun paginate(direction: Timeline.Direction, count: Int) { - handler.get()?.post { - if (!hasMoreToLoadLive(direction) && hasReachedEndLive(direction)) { + backgroundHandler.get()?.post { + if (!canPaginate(direction)) { return@post } + Timber.v("Paginate $direction of $count items") val startDisplayIndex = if (direction == Timeline.Direction.BACKWARDS) prevDisplayIndex else nextDisplayIndex - val builtCountItems = insertFromLiveResults(startDisplayIndex, direction, count.toLong()) - if (builtCountItems < count) { - val limit = count - builtCountItems - val token = getTokenLive(direction) ?: return@post - helper.runIfNotRunning(direction.toRequestType()) { executePaginationTask(it, token, direction, limit) } - } + paginateInternal(startDisplayIndex, direction, count) } } override fun start() { if (isStarted.compareAndSet(false, true)) { - val handlerThread = HandlerThread(THREAD_NAME) + Timber.v("Start timeline for roomId: $roomId and eventId: $initialEventId") + val handlerThread = HandlerThread(THREAD_NAME + hashCode()) handlerThread.start() val handler = Handler(handlerThread.looper) - this.handlerThread.set(handlerThread) - this.handler.set(handler) + this.backgroundHandlerThread.set(handlerThread) + this.backgroundHandler.set(handler) handler.post { val realm = Realm.getInstance(realmConfiguration) - this.realm.set(realm) - liveEvents = buildEventQuery(realm).findAllAsync() - liveEvents.addChangeListener(eventsChangeListener) + backgroundRealm.set(realm) + clearUnlinkedEvents(realm) + isReady.set(true) + liveEvents = buildEventQuery(realm) + .sort(EventEntityFields.DISPLAY_INDEX, Sort.DESCENDING) + .findAll() + .also { it.addChangeListener(eventsChangeListener) } + handleInitialLoad() } } - } override fun dispose() { if (isStarted.compareAndSet(true, false)) { - handler.get()?.post { + Timber.v("Dispose timeline for roomId: $roomId and eventId: $initialEventId") + backgroundHandler.get()?.post { cancelableBag.cancel() liveEvents.removeAllChangeListeners() - realm.getAndSet(null)?.close() - handler.set(null) - handlerThread.getAndSet(null)?.quit() + backgroundRealm.getAndSet(null).also { + it.close() + } + backgroundHandler.set(null) + backgroundHandlerThread.getAndSet(null)?.quit() } } } - override fun snapshot(): List = synchronized(builtEvents) { + override fun hasMoreToLoad(direction: Timeline.Direction): Boolean { + return hasMoreInCache(direction) || !hasReachedEnd(direction) + } + + // Private methods ***************************************************************************** + + private fun hasMoreInCache(direction: Timeline.Direction): Boolean { + val localRealm = Realm.getInstance(realmConfiguration) + val eventEntity = buildEventQuery(localRealm).findFirst(direction) ?: return false + val hasMoreInCache = if (direction == Timeline.Direction.FORWARDS) { + val firstEvent = builtEvents.firstOrNull() ?: return true + firstEvent.displayIndex < eventEntity.displayIndex + } else { + val lastEvent = builtEvents.lastOrNull() ?: return true + lastEvent.displayIndex > eventEntity.displayIndex + } + localRealm.close() + return hasMoreInCache + } + + private fun hasReachedEnd(direction: Timeline.Direction): Boolean { + val localRealm = Realm.getInstance(realmConfiguration) + val currentChunk = findCurrentChunk(localRealm) ?: return false + val hasReachedEnd = if (direction == Timeline.Direction.FORWARDS) { + currentChunk.isLastForward + } else { + val eventEntity = buildEventQuery(localRealm).findFirst(direction) + currentChunk.isLastBackward || eventEntity?.type == EventType.STATE_ROOM_CREATE + } + localRealm.close() + return hasReachedEnd + } + + + /** + * This has to be called on TimelineThread as it access realm live results + */ + private fun paginateInternal(startDisplayIndex: Int, + direction: Timeline.Direction, + count: Int) { + updatePaginationState(direction) { it.copy(requestedCount = count, isPaginating = true) } + val builtCount = buildTimelineEvents(startDisplayIndex, direction, count.toLong()) + if (builtCount < count && !hasReachedEnd(direction)) { + val newRequestedCount = count - builtCount + updatePaginationState(direction) { it.copy(requestedCount = newRequestedCount) } + val fetchingCount = Math.max(MIN_FETCHING_COUNT, newRequestedCount) + executePaginationTask(direction, fetchingCount) + } else { + updatePaginationState(direction) { it.copy(isPaginating = false, requestedCount = 0) } + } + } + + private fun snapshot(): List { return builtEvents.toList() } - override fun size(): Int = synchronized(builtEvents) { - return builtEvents.size + private fun canPaginate(direction: Timeline.Direction): Boolean { + return isReady.get() && !getPaginationState(direction).isPaginating && hasMoreToLoad(direction) } - override fun hasReachedEnd(direction: Timeline.Direction): Boolean { - return handler.get()?.postAndWait { - hasReachedEndLive(direction) - } ?: false + private fun getPaginationState(direction: Timeline.Direction): PaginationState { + return when (direction) { + Timeline.Direction.FORWARDS -> forwardsPaginationState.get() + Timeline.Direction.BACKWARDS -> backwardsPaginationState.get() + } } - override fun hasMoreToLoad(direction: Timeline.Direction): Boolean { - return handler.get()?.postAndWait { - hasMoreToLoadLive(direction) - } ?: false + private fun updatePaginationState(direction: Timeline.Direction, update: (PaginationState) -> PaginationState) { + val stateReference = when (direction) { + Timeline.Direction.FORWARDS -> forwardsPaginationState + Timeline.Direction.BACKWARDS -> backwardsPaginationState + } + val currentValue = stateReference.get() + val newValue = update(currentValue) + stateReference.set(newValue) } /** * This has to be called on TimelineThread as it access realm live results */ - private fun handleInitialLoad() = synchronized(builtEvents) { + private fun handleInitialLoad() { + var shouldFetchInitialEvent = false val initialDisplayIndex = if (isLive) { liveEvents.firstOrNull()?.displayIndex } else { - liveEvents.where().equalTo(EventEntityFields.EVENT_ID, initialEventId).findFirst()?.displayIndex - } ?: 0 + val initialEvent = liveEvents.where().equalTo(EventEntityFields.EVENT_ID, initialEventId).findFirst() + shouldFetchInitialEvent = initialEvent == null + initialEvent?.displayIndex + } ?: DISPLAY_INDEX_UNKNOWN + 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) + if (initialEventId != null && shouldFetchInitialEvent) { + fetchEvent(initialEventId) } else { - val forwardCount = count / 2L - val backwardCount = count - forwardCount - insertFromLiveResults(initialDisplayIndex, Timeline.Direction.BACKWARDS, backwardCount) - insertFromLiveResults(initialDisplayIndex, Timeline.Direction.BACKWARDS, forwardCount) + val count = Math.min(INITIAL_LOAD_SIZE, liveEvents.size) + if (isLive) { + paginate(Timeline.Direction.BACKWARDS, count) + } else { + paginate(Timeline.Direction.FORWARDS, count / 2) + paginate(Timeline.Direction.BACKWARDS, count / 2) + } } } - private fun executePaginationTask(requestCallback: PagingRequestHelper.Request.Callback, - from: String, - direction: Timeline.Direction, - limit: Int) { - + /** + * This has to be called on TimelineThread as it access realm live results + */ + private fun executePaginationTask(direction: Timeline.Direction, limit: Int) { + val token = getTokenLive(direction) ?: return val params = PaginationTask.Params(roomId = roomId, - from = from, + from = token, direction = direction.toPaginationDirection(), limit = limit) + Timber.v("Should fetch $limit items $direction") paginationTask.configureWith(params) .enableRetry() .dispatchTo(object : MatrixCallback { override fun onSuccess(data: TokenChunkEventPersistor.Result) { - requestCallback.recordSuccess() - if (data == TokenChunkEventPersistor.Result.SHOULD_FETCH_MORE) { - paginate(direction, limit) + if (data == TokenChunkEventPersistor.Result.SUCCESS) { + Timber.v("Success fetching $limit items $direction from pagination request") + } else { + // Database won't be updated, so we force pagination request + backgroundHandler.get()?.post { + executePaginationTask(direction, limit) + } } } override fun onFailure(failure: Throwable) { - requestCallback.recordFailure(failure) + Timber.v("Failure fetching $limit items $direction from pagination request") } }) .executeBy(taskExecutor) @@ -209,36 +296,12 @@ internal class DefaultTimeline( /** * This has to be called on TimelineThread as it access realm live results */ + private fun getTokenLive(direction: Timeline.Direction): String? { val chunkEntity = getLiveChunk() ?: return null return if (direction == Timeline.Direction.BACKWARDS) chunkEntity.prevToken else chunkEntity.nextToken } - /** - * This has to be called on TimelineThread as it access realm live results - */ - private fun hasReachedEndLive(direction: Timeline.Direction): Boolean { - val liveChunk = getLiveChunk() ?: return false - return if (direction == Timeline.Direction.FORWARDS) { - liveChunk.isLastForward - } else { - liveChunk.isLastBackward || liveEvents.lastOrNull()?.type == EventType.STATE_ROOM_CREATE - } - } - - /** - * This has to be called on TimelineThread as it access realm live results - */ - private fun hasMoreToLoadLive(direction: Timeline.Direction): Boolean { - if (liveEvents.isEmpty()) { - return true - } - return if (direction == Timeline.Direction.FORWARDS) { - builtEvents.firstOrNull()?.displayIndex != liveEvents.firstOrNull()?.displayIndex - } else { - builtEvents.lastOrNull()?.displayIndex != liveEvents.lastOrNull()?.displayIndex - } - } /** * This has to be called on TimelineThread as it access realm live results @@ -251,11 +314,11 @@ internal class DefaultTimeline( * This has to be called on TimelineThread as it access realm live results * @return number of items who have been added */ - private fun insertFromLiveResults(startDisplayIndex: Int, - direction: Timeline.Direction, - count: Long): Int = synchronized(builtEvents) { + private fun buildTimelineEvents(startDisplayIndex: Int, + direction: Timeline.Direction, + count: Long): Int { if (count < 1) { - throw java.lang.IllegalStateException("You should provide a count superior to 0") + return 0 } val offsetResults = getOffsetResults(startDisplayIndex, direction, count) if (offsetResults.isEmpty()) { @@ -272,7 +335,9 @@ internal class DefaultTimeline( val position = if (direction == Timeline.Direction.FORWARDS) 0 else builtEvents.size builtEvents.add(position, timelineEvent) } - listener?.onUpdated(snapshot()) + Timber.v("Built ${offsetResults.size} items from db") + val snapshot = snapshot() + mainHandler.post { listener?.onUpdated(snapshot) } return offsetResults.size } @@ -292,11 +357,15 @@ internal class DefaultTimeline( .sort(EventEntityFields.DISPLAY_INDEX, Sort.ASCENDING) .greaterThanOrEqualTo(EventEntityFields.DISPLAY_INDEX, startDisplayIndex) } - return offsetQuery.limit(count).findAll() + return offsetQuery + .filterAllowedTypes() + .limit(count) + .findAll() } + private fun buildEventQuery(realm: Realm): RealmQuery { - val query = if (initialEventId == null) { + return if (initialEventId == null) { EventEntity .where(realm, roomId = roomId, linkFilterMode = EventEntity.LinkFilterMode.LINKED_ONLY) .equalTo("${EventEntityFields.CHUNK}.${ChunkEntityFields.IS_LAST_FORWARD}", true) @@ -305,30 +374,56 @@ internal class DefaultTimeline( .where(realm, roomId = roomId, linkFilterMode = EventEntity.LinkFilterMode.BOTH) .`in`("${EventEntityFields.CHUNK}.${ChunkEntityFields.EVENTS.EVENT_ID}", arrayOf(initialEventId)) } - query.sort(EventEntityFields.DISPLAY_INDEX, Sort.DESCENDING) - return query - } - private fun Handler.postAndWait(runnable: () -> T): T { - val lock = CountDownLatch(1) - val atomicReference = AtomicReference() - post { - val result = runnable() - atomicReference.set(result) - lock.countDown() + private fun findCurrentChunk(realm: Realm): ChunkEntity? { + return if (initialEventId == null) { + ChunkEntity.findLastLiveChunkFromRoom(realm, roomId) + } else { + ChunkEntity.findIncludingEvent(realm, initialEventId) } - lock.await() - return atomicReference.get() } - private fun Timeline.Direction.toRequestType(): PagingRequestHelper.RequestType { - return if (this == Timeline.Direction.BACKWARDS) PagingRequestHelper.RequestType.BEFORE else PagingRequestHelper.RequestType.AFTER + private fun clearUnlinkedEvents(realm: Realm) { + realm.executeTransaction { + val unlinkedChunks = ChunkEntity + .where(it, roomId = roomId) + .equalTo(ChunkEntityFields.EVENTS.IS_UNLINKED, true) + .findAll() + unlinkedChunks.deleteAllFromRealm() + } } - //Todo : remove that + private fun fetchEvent(eventId: String) { + val params = GetContextOfEventTask.Params(roomId, eventId) + contextOfEventTask.configureWith(params).executeBy(taskExecutor) + } + +// Extension methods *************************************************************************** + private fun Timeline.Direction.toPaginationDirection(): PaginationDirection { return if (this == Timeline.Direction.BACKWARDS) PaginationDirection.BACKWARDS else PaginationDirection.FORWARDS } + private fun RealmQuery.findFirst(direction: Timeline.Direction): EventEntity? { + return if (direction == Timeline.Direction.FORWARDS) { + sort(EventEntityFields.DISPLAY_INDEX, Sort.DESCENDING) + } else { + sort(EventEntityFields.DISPLAY_INDEX, Sort.ASCENDING) + } + .filterAllowedTypes() + .findFirst() + } + + private fun RealmQuery.filterAllowedTypes(): RealmQuery { + if (allowedTypes != null) { + `in`(EventEntityFields.TYPE, allowedTypes.toTypedArray()) + } + return this + } } + +private data class PaginationState( + val isPaginating: Boolean = false, + val requestedCount: Int = 0 +) 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 f112f078..9700d712 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 @@ -19,68 +19,18 @@ package im.vector.matrix.android.internal.session.room.timeline import com.zhuinden.monarchy.Monarchy import im.vector.matrix.android.api.session.room.timeline.Timeline import im.vector.matrix.android.api.session.room.timeline.TimelineService -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.task.TaskExecutor -import im.vector.matrix.android.internal.task.configureWith -import im.vector.matrix.android.internal.util.PagingRequestHelper -import im.vector.matrix.android.internal.util.tryTransactionAsync - -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 contextOfEventTask: GetContextOfEventTask, private val timelineEventFactory: TimelineEventFactory, - private val paginationTask: PaginationTask, - private val helper: PagingRequestHelper + private val paginationTask: PaginationTask ) : TimelineService { - override fun createTimeline(eventId: String?): Timeline { - return DefaultTimeline(roomId, eventId, monarchy.realmConfiguration, taskExecutor, contextOfEventTask, timelineEventFactory, paginationTask, helper) + override fun createTimeline(eventId: String?, allowedTypes: List?): Timeline { + return DefaultTimeline(roomId, eventId, monarchy.realmConfiguration, taskExecutor, contextOfEventTask, timelineEventFactory, paginationTask, allowedTypes) } - // PRIVATE FUNCTIONS *************************************************************************** - - private fun getInitialLoadKey(eventId: String?): Int { - var initialLoadKey = 0 - if (eventId != null) { - val indexOfEvent = indexOfEvent(eventId) - if (indexOfEvent == EVENT_NOT_FOUND_INDEX) { - fetchEvent(eventId) - } else { - initialLoadKey = indexOfEvent - } - } - return initialLoadKey - } - - - private fun fetchEvent(eventId: String) { - val params = GetContextOfEventTask.Params(roomId, eventId) - contextOfEventTask.configureWith(params).executeBy(taskExecutor) - } - - private fun clearUnlinkedEvents() { - monarchy.tryTransactionAsync { realm -> - val unlinkedEvents = EventEntity - .where(realm, roomId = roomId) - .equalTo(EventEntityFields.IS_UNLINKED, true) - .findAll() - unlinkedEvents.deleteAllFromRealm() - } - } - - 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 - } - return displayIndex - } - - } \ No newline at end of file diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/TimelineBoundaryCallback.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/TimelineBoundaryCallback.kt deleted file mode 100644 index aeec3a3f..00000000 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/TimelineBoundaryCallback.kt +++ /dev/null @@ -1,110 +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.matrix.android.internal.session.room.timeline - -import androidx.lifecycle.LiveData -import androidx.paging.PagedList -import com.zhuinden.monarchy.Monarchy -import im.vector.matrix.android.api.MatrixCallback -import im.vector.matrix.android.api.session.room.timeline.TimelineEvent -import im.vector.matrix.android.internal.database.model.ChunkEntity -import im.vector.matrix.android.internal.database.query.findIncludingEvent -import im.vector.matrix.android.internal.task.TaskExecutor -import im.vector.matrix.android.internal.task.configureWith -import im.vector.matrix.android.internal.util.PagingRequestHelper - -internal class TimelineBoundaryCallback(private val roomId: String, - private val taskExecutor: TaskExecutor, - private val paginationTask: PaginationTask, - private val monarchy: Monarchy, - private val helper: PagingRequestHelper -) : PagedList.BoundaryCallback() { - - var limit = 30 - - val status = object : LiveData() { - - init { - value = PagingRequestHelper.StatusReport.createDefault() - } - - val listener = PagingRequestHelper.Listener { postValue(it) } - - override fun onActive() { - helper.addListener(listener) - } - - override fun onInactive() { - helper.removeListener(listener) - } - } - - override fun onZeroItemsLoaded() { - // actually, it's not possible - } - - override fun onItemAtEndLoaded(itemAtEnd: TimelineEvent) { - val token = itemAtEnd.root.eventId?.let { getToken(it, PaginationDirection.BACKWARDS) } - ?: return - - helper.runIfNotRunning(PagingRequestHelper.RequestType.AFTER) { - executePaginationTask(it, token, PaginationDirection.BACKWARDS) - } - } - - override fun onItemAtFrontLoaded(itemAtFront: TimelineEvent) { - val token = itemAtFront.root.eventId?.let { getToken(it, PaginationDirection.FORWARDS) } - ?: return - - helper.runIfNotRunning(PagingRequestHelper.RequestType.BEFORE) { - executePaginationTask(it, token, PaginationDirection.FORWARDS) - } - } - - private fun getToken(eventId: String, direction: PaginationDirection): String? { - var token: String? = null - monarchy.doWithRealm { realm -> - val chunkEntity = ChunkEntity.findIncludingEvent(realm, eventId) - token = if (direction == PaginationDirection.FORWARDS) chunkEntity?.nextToken else chunkEntity?.prevToken - } - return token - } - - private fun executePaginationTask(requestCallback: PagingRequestHelper.Request.Callback, - from: String, - direction: PaginationDirection) { - - val params = PaginationTask.Params(roomId = roomId, - from = from, - direction = direction, - limit = limit) - - paginationTask.configureWith(params) - .enableRetry() - .dispatchTo(object : MatrixCallback { - override fun onSuccess(data: TokenChunkEventPersistor.Result) { - requestCallback.recordSuccess() - } - - override fun onFailure(failure: Throwable) { - requestCallback.recordFailure(failure) - } - }) - .executeBy(taskExecutor) - } - -} 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 187833ef..3ac88211 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,12 +18,7 @@ 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.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.helper.* 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 @@ -32,6 +27,7 @@ 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 +import timber.log.Timber internal class TokenChunkEventPersistor(private val monarchy: Monarchy) { @@ -47,8 +43,10 @@ internal class TokenChunkEventPersistor(private val monarchy: Monarchy) { return monarchy .tryTransactionSync { realm -> + Timber.v("Start persisting ${receivedChunk.events.size} events in $roomId towards $direction") + val roomEntity = RoomEntity.where(realm, roomId).findFirst() - ?: realm.createObject(roomId) + ?: realm.createObject(roomId) val nextToken: String? val prevToken: String? @@ -68,16 +66,16 @@ 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) } if (receivedChunk.events.isEmpty() && receivedChunk.end == receivedChunk.start) { currentChunk.isLastBackward = true } else { + Timber.v("Add ${receivedChunk.events.size} events in chunk(${currentChunk.nextToken}-${currentChunk.prevToken}") currentChunk.addAll(roomId, receivedChunk.events, direction, isUnlinked = currentChunk.isUnlinked()) - // Then we merge chunks if needed if (currentChunk != prevChunk && prevChunk != null) { currentChunk = handleMerge(roomEntity, direction, currentChunk, prevChunk) @@ -111,6 +109,7 @@ internal class TokenChunkEventPersistor(private val monarchy: Monarchy) { otherChunk: ChunkEntity): ChunkEntity { // We always merge the bottom chunk into top chunk, so we are always merging backwards + Timber.v("Merge ${currentChunk.prevToken}-${currentChunk.nextToken} with ${otherChunk.prevToken}-${otherChunk.nextToken}") return if (direction == PaginationDirection.BACKWARDS) { currentChunk.merge(roomEntity.roomId, otherChunk, PaginationDirection.BACKWARDS) roomEntity.deleteOnCascade(otherChunk) diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/util/PagingRequestHelper.java b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/util/PagingRequestHelper.java deleted file mode 100644 index 45066740..00000000 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/util/PagingRequestHelper.java +++ /dev/null @@ -1,530 +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.matrix.android.internal.util; - -import androidx.annotation.AnyThread; -import androidx.annotation.GuardedBy; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.VisibleForTesting; - -import java.util.Arrays; -import java.util.concurrent.CopyOnWriteArrayList; -import java.util.concurrent.Executor; -import java.util.concurrent.atomic.AtomicBoolean; - -/** - * A helper class for {@link androidx.paging.PagedList.BoundaryCallback BoundaryCallback}s and - * {@link androidx.paging.DataSource}s to help with tracking network requests. - *

- * It is designed to support 3 types of requests, {@link RequestType#INITIAL INITIAL}, - * {@link RequestType#BEFORE BEFORE} and {@link RequestType#AFTER AFTER} and runs only 1 request - * for each of them via {@link #runIfNotRunning(RequestType, Request)}. - *

- * It tracks a {@link Status} and an {@code error} for each {@link RequestType}. - *

- * A sample usage of this class to limit requests looks like this: - *

- * class PagingBoundaryCallback extends PagedList.BoundaryCallback<MyItem> {
- *     // TODO replace with an executor from your application
- *     Executor executor = Executors.newSingleThreadExecutor();
- *     PagingRequestHelper helper = new PagingRequestHelper(executor);
- *     // imaginary API service, using Retrofit
- *     MyApi api;
- *
- *     {@literal @}Override
- *     public void onItemAtFrontLoaded({@literal @}NonNull MyItem itemAtFront) {
- *         helper.runIfNotRunning(PagingRequestHelper.RequestType.BEFORE,
- *                 helperCallback -> api.getTopBefore(itemAtFront.getName(), 10).enqueue(
- *                         new Callback<ApiResponse>() {
- *                             {@literal @}Override
- *                             public void onResponse(Call<ApiResponse> call,
- *                                     Response<ApiResponse> response) {
- *                                 // TODO insert new records into database
- *                                 helperCallback.recordSuccess();
- *                             }
- *
- *                             {@literal @}Override
- *                             public void onFailure(Call<ApiResponse> call, Throwable t) {
- *                                 helperCallback.recordFailure(t);
- *                             }
- *                         }));
- *     }
- *
- *     {@literal @}Override
- *     public void onItemAtEndLoaded({@literal @}NonNull MyItem itemAtEnd) {
- *         helper.runIfNotRunning(PagingRequestHelper.RequestType.AFTER,
- *                 helperCallback -> api.getTopBefore(itemAtEnd.getName(), 10).enqueue(
- *                         new Callback<ApiResponse>() {
- *                             {@literal @}Override
- *                             public void onResponse(Call<ApiResponse> call,
- *                                     Response<ApiResponse> response) {
- *                                 // TODO insert new records into database
- *                                 helperCallback.recordSuccess();
- *                             }
- *
- *                             {@literal @}Override
- *                             public void onFailure(Call<ApiResponse> call, Throwable t) {
- *                                 helperCallback.recordFailure(t);
- *                             }
- *                         }));
- *     }
- * }
- * 
- *

- * The helper provides an API to observe combined request status, which can be reported back to the - * application based on your business rules. - *

- * MutableLiveData<PagingRequestHelper.Status> combined = new MutableLiveData<>();
- * helper.addListener(status -> {
- *     // merge multiple states per request type into one, or dispatch separately depending on
- *     // your application logic.
- *     if (status.hasRunning()) {
- *         combined.postValue(PagingRequestHelper.Status.RUNNING);
- *     } else if (status.hasError()) {
- *         // can also obtain the error via {@link StatusReport#getErrorFor(RequestType)}
- *         combined.postValue(PagingRequestHelper.Status.FAILED);
- *     } else {
- *         combined.postValue(PagingRequestHelper.Status.SUCCESS);
- *     }
- * });
- * 
- */ -// THIS class is likely to be moved into the library in a future release. Feel free to copy it -// from this sample. -public class PagingRequestHelper { - private final Object mLock = new Object(); - private final Executor mRetryService; - @GuardedBy("mLock") - private final RequestQueue[] mRequestQueues = new RequestQueue[] - {new RequestQueue(RequestType.INITIAL), - new RequestQueue(RequestType.BEFORE), - new RequestQueue(RequestType.AFTER)}; - @NonNull - final CopyOnWriteArrayList mListeners = new CopyOnWriteArrayList<>(); - - /** - * Creates a new PagingRequestHelper with the given {@link Executor} which is used to run - * retry actions. - * - * @param retryService The {@link Executor} that can run the retry actions. - */ - public PagingRequestHelper(@NonNull Executor retryService) { - mRetryService = retryService; - } - - /** - * Adds a new listener that will be notified when any request changes {@link Status state}. - * - * @param listener The listener that will be notified each time a request's status changes. - * @return True if it is added, false otherwise (e.g. it already exists in the list). - */ - @AnyThread - public boolean addListener(@NonNull Listener listener) { - return mListeners.add(listener); - } - - /** - * Removes the given listener from the listeners list. - * - * @param listener The listener that will be removed. - * @return True if the listener is removed, false otherwise (e.g. it never existed) - */ - public boolean removeListener(@NonNull Listener listener) { - return mListeners.remove(listener); - } - - /** - * Runs the given {@link Request} if no other requests in the given request type is already - * running. - *

- * If run, the request will be run in the current thread. - * - * @param type The type of the request. - * @param request The request to run. - * @return True if the request is run, false otherwise. - */ - @SuppressWarnings("WeakerAccess") - @AnyThread - public boolean runIfNotRunning(@NonNull RequestType type, @NonNull Request request) { - boolean hasListeners = !mListeners.isEmpty(); - StatusReport report = null; - synchronized (mLock) { - RequestQueue queue = mRequestQueues[type.ordinal()]; - if (queue.mRunning != null) { - return false; - } - queue.mRunning = request; - queue.mStatus = Status.RUNNING; - queue.mFailed = null; - queue.mLastError = null; - if (hasListeners) { - report = prepareStatusReportLocked(); - } - } - if (report != null) { - dispatchReport(report); - } - final RequestWrapper wrapper = new RequestWrapper(request, this, type); - wrapper.run(); - return true; - } - - @GuardedBy("mLock") - private StatusReport prepareStatusReportLocked() { - Throwable[] errors = new Throwable[]{ - mRequestQueues[0].mLastError, - mRequestQueues[1].mLastError, - mRequestQueues[2].mLastError - }; - return new StatusReport( - getStatusForLocked(RequestType.INITIAL), - getStatusForLocked(RequestType.BEFORE), - getStatusForLocked(RequestType.AFTER), - errors - ); - } - - @GuardedBy("mLock") - private Status getStatusForLocked(RequestType type) { - return mRequestQueues[type.ordinal()].mStatus; - } - - @AnyThread - @VisibleForTesting - void recordResult(@NonNull RequestWrapper wrapper, @Nullable Throwable throwable) { - StatusReport report = null; - final boolean success = throwable == null; - boolean hasListeners = !mListeners.isEmpty(); - synchronized (mLock) { - RequestQueue queue = mRequestQueues[wrapper.mType.ordinal()]; - queue.mRunning = null; - queue.mLastError = throwable; - if (success) { - queue.mFailed = null; - queue.mStatus = Status.SUCCESS; - } else { - queue.mFailed = wrapper; - queue.mStatus = Status.FAILED; - } - if (hasListeners) { - report = prepareStatusReportLocked(); - } - } - if (report != null) { - dispatchReport(report); - } - } - - private void dispatchReport(StatusReport report) { - for (Listener listener : mListeners) { - listener.onStatusChange(report); - } - } - - /** - * Retries all failed requests. - * - * @return True if any request is retried, false otherwise. - */ - public boolean retryAllFailed() { - final RequestWrapper[] toBeRetried = new RequestWrapper[RequestType.values().length]; - boolean retried = false; - synchronized (mLock) { - for (int i = 0; i < RequestType.values().length; i++) { - toBeRetried[i] = mRequestQueues[i].mFailed; - mRequestQueues[i].mFailed = null; - } - } - for (RequestWrapper failed : toBeRetried) { - if (failed != null) { - failed.retry(mRetryService); - retried = true; - } - } - return retried; - } - - static class RequestWrapper implements Runnable { - @NonNull - final Request mRequest; - @NonNull - final PagingRequestHelper mHelper; - @NonNull - final RequestType mType; - - RequestWrapper(@NonNull Request request, @NonNull PagingRequestHelper helper, - @NonNull RequestType type) { - mRequest = request; - mHelper = helper; - mType = type; - } - - @Override - public void run() { - mRequest.run(new Request.Callback(this, mHelper)); - } - - void retry(Executor service) { - service.execute(new Runnable() { - @Override - public void run() { - mHelper.runIfNotRunning(mType, mRequest); - } - }); - } - } - - /** - * Runner class that runs a request tracked by the {@link PagingRequestHelper}. - *

- * When a request is invoked, it must call one of {@link Callback#recordFailure(Throwable)} - * or {@link Callback#recordSuccess()} once and only once. This call - * can be made any time. Until that method call is made, {@link PagingRequestHelper} will - * consider the request is running. - */ - @FunctionalInterface - public interface Request { - /** - * Should run the request and call the given {@link Callback} with the result of the - * request. - * - * @param callback The callback that should be invoked with the result. - */ - void run(Callback callback); - - /** - * Callback class provided to the {@link #run(Callback)} method to report the result. - */ - class Callback { - private final AtomicBoolean mCalled = new AtomicBoolean(); - private final RequestWrapper mWrapper; - private final PagingRequestHelper mHelper; - - Callback(RequestWrapper wrapper, PagingRequestHelper helper) { - mWrapper = wrapper; - mHelper = helper; - } - - /** - * Call this method when the request succeeds and new data is fetched. - */ - @SuppressWarnings("unused") - public final void recordSuccess() { - if (mCalled.compareAndSet(false, true)) { - mHelper.recordResult(mWrapper, null); - } else { - throw new IllegalStateException( - "already called recordSuccess or recordFailure"); - } - } - - /** - * Call this method with the failure message and the request can be retried via - * {@link #retryAllFailed()}. - * - * @param throwable The error that occured while carrying out the request. - */ - @SuppressWarnings("unused") - public final void recordFailure(@NonNull Throwable throwable) { - //noinspection ConstantConditions - if (throwable == null) { - throw new IllegalArgumentException("You must provide a throwable describing" - + " the error to record the failure"); - } - if (mCalled.compareAndSet(false, true)) { - mHelper.recordResult(mWrapper, throwable); - } else { - throw new IllegalStateException( - "already called recordSuccess or recordFailure"); - } - } - } - } - - /** - * Data class that holds the information about the current status of the ongoing requests - * using this helper. - */ - public static final class StatusReport { - /** - * Status of the latest request that were submitted with {@link RequestType#INITIAL}. - */ - @NonNull - public final Status initial; - /** - * Status of the latest request that were submitted with {@link RequestType#BEFORE}. - */ - @NonNull - public final Status before; - /** - * Status of the latest request that were submitted with {@link RequestType#AFTER}. - */ - @NonNull - public final Status after; - @NonNull - private final Throwable[] mErrors; - - public static StatusReport createDefault() { - final Throwable[] errors = {}; - return new StatusReport(Status.SUCCESS, Status.SUCCESS, Status.SUCCESS, errors); - } - - StatusReport(@NonNull Status initial, @NonNull Status before, @NonNull Status after, - @NonNull Throwable[] errors) { - this.initial = initial; - this.before = before; - this.after = after; - this.mErrors = errors; - } - - /** - * Convenience method to check if there are any running requests. - * - * @return True if there are any running requests, false otherwise. - */ - public boolean hasRunning() { - return initial == Status.RUNNING - || before == Status.RUNNING - || after == Status.RUNNING; - } - - /** - * Convenience method to check if there are any requests that resulted in an error. - * - * @return True if there are any requests that finished with error, false otherwise. - */ - public boolean hasError() { - return initial == Status.FAILED - || before == Status.FAILED - || after == Status.FAILED; - } - - /** - * Returns the error for the given request type. - * - * @param type The request type for which the error should be returned. - * @return The {@link Throwable} returned by the failing request with the given type or - * {@code null} if the request for the given type did not fail. - */ - @Nullable - public Throwable getErrorFor(@NonNull RequestType type) { - return mErrors[type.ordinal()]; - } - - @Override - public String toString() { - return "StatusReport{" - + "initial=" + initial - + ", before=" + before - + ", after=" + after - + ", mErrors=" + Arrays.toString(mErrors) - + '}'; - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - StatusReport that = (StatusReport) o; - if (initial != that.initial) return false; - if (before != that.before) return false; - if (after != that.after) return false; - // Probably incorrect - comparing Object[] arrays with Arrays.equals - return Arrays.equals(mErrors, that.mErrors); - } - - @Override - public int hashCode() { - int result = initial.hashCode(); - result = 31 * result + before.hashCode(); - result = 31 * result + after.hashCode(); - result = 31 * result + Arrays.hashCode(mErrors); - return result; - } - } - - /** - * Listener interface to get notified by request status changes. - */ - public interface Listener { - /** - * Called when the status for any of the requests has changed. - * - * @param report The current status report that has all the information about the requests. - */ - void onStatusChange(@NonNull StatusReport report); - } - - /** - * Represents the status of a Request for each {@link RequestType}. - */ - public enum Status { - /** - * There is current a running request. - */ - RUNNING, - /** - * The last request has succeeded or no such requests have ever been run. - */ - SUCCESS, - /** - * The last request has failed. - */ - FAILED - } - - /** - * Available request types. - */ - public enum RequestType { - /** - * Corresponds to an initial request made to a {@link androidx.paging.DataSource} or the empty state for - * a {@link androidx.paging.PagedList.BoundaryCallback BoundaryCallback}. - */ - INITIAL, - /** - * Corresponds to the {@code loadBefore} calls in {@link androidx.paging.DataSource} or - * {@code onItemAtFrontLoaded} in - * {@link androidx.paging.PagedList.BoundaryCallback BoundaryCallback}. - */ - BEFORE, - /** - * Corresponds to the {@code loadAfter} calls in {@link androidx.paging.DataSource} or - * {@code onItemAtEndLoaded} in - * {@link androidx.paging.PagedList.BoundaryCallback BoundaryCallback}. - */ - AFTER - } - - class RequestQueue { - @NonNull - final RequestType mRequestType; - @Nullable - RequestWrapper mFailed; - @Nullable - Request mRunning; - @Nullable - Throwable mLastError; - @NonNull - Status mStatus = Status.SUCCESS; - - RequestQueue(@NonNull RequestType requestType) { - mRequestType = requestType; - } - } -} \ No newline at end of file