Merge feature/replace_paged_list into develop

This commit is contained in:
ganfra 2019-04-01 17:33:53 +02:00
commit d110dac0a6
73 changed files with 1486 additions and 1542 deletions

View File

@ -144,7 +144,7 @@ android {


dependencies { dependencies {


def epoxy_version = "3.0.0" def epoxy_version = "3.3.0"
def arrow_version = "0.8.2" def arrow_version = "0.8.2"
def coroutines_version = "1.0.1" def coroutines_version = "1.0.1"
def markwon_version = '3.0.0-SNAPSHOT' def markwon_version = '3.0.0-SNAPSHOT'
@ -159,13 +159,10 @@ dependencies {
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines_version" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines_version"


implementation 'androidx.appcompat:appcompat:1.1.0-alpha01' implementation 'androidx.appcompat:appcompat:1.1.0-alpha03'
implementation 'androidx.constraintlayout:constraintlayout:1.1.3' implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
implementation 'androidx.core:core-ktx:1.0.1' implementation 'androidx.core:core-ktx:1.0.1'


// Paging
implementation 'androidx.paging:paging-runtime:2.0.0'

implementation 'com.jakewharton.threetenabp:threetenabp:1.1.1' implementation 'com.jakewharton.threetenabp:threetenabp:1.1.1'


// Log // Log
@ -192,7 +189,7 @@ dependencies {


// UI // UI
implementation 'com.amulyakhare:com.amulyakhare.textdrawable:1.0.1' implementation 'com.amulyakhare:com.amulyakhare.textdrawable:1.0.1'
implementation 'com.google.android.material:material:1.1.0-alpha02' implementation 'com.google.android.material:material:1.1.0-alpha04'
implementation 'me.gujun.android:span:1.7' implementation 'me.gujun.android:span:1.7'
implementation "ru.noties.markwon:core:$markwon_version" implementation "ru.noties.markwon:core:$markwon_version"
implementation "ru.noties.markwon:html:$markwon_version" implementation "ru.noties.markwon:html:$markwon_version"

View File

@ -19,6 +19,8 @@ package im.vector.riotredesign
import android.app.Application import android.app.Application
import android.content.Context import android.content.Context
import androidx.multidex.MultiDex import androidx.multidex.MultiDex
import com.airbnb.epoxy.EpoxyAsyncUtil
import com.airbnb.epoxy.EpoxyController
import com.facebook.stetho.Stetho import com.facebook.stetho.Stetho
import com.github.piasy.biv.BigImageViewer import com.github.piasy.biv.BigImageViewer
import com.github.piasy.biv.loader.glide.GlideImageLoader import com.github.piasy.biv.loader.glide.GlideImageLoader
@ -50,6 +52,8 @@ class Riot : Application() {


AndroidThreeTen.init(this) AndroidThreeTen.init(this)
BigImageViewer.initialize(GlideImageLoader.with(applicationContext)) BigImageViewer.initialize(GlideImageLoader.with(applicationContext))
EpoxyController.defaultDiffingHandler = EpoxyAsyncUtil.getAsyncBackgroundHandler()
EpoxyController.defaultModelBuildingHandler = EpoxyAsyncUtil.getAsyncBackgroundHandler()
val appModule = AppModule(applicationContext).definition val appModule = AppModule(applicationContext).definition
val homeModule = HomeModule().definition val homeModule = HomeModule().definition
startKoin(listOf(appModule, homeModule), logger = EmptyLogger()) startKoin(listOf(appModule, homeModule), logger = EmptyLogger())

View File

@ -16,8 +16,19 @@


package im.vector.riotredesign.core.glide; package im.vector.riotredesign.core.glide;


import android.content.Context;
import android.util.Log;

import com.bumptech.glide.GlideBuilder;
import com.bumptech.glide.annotation.GlideModule; import com.bumptech.glide.annotation.GlideModule;
import com.bumptech.glide.module.AppGlideModule; import com.bumptech.glide.module.AppGlideModule;


@GlideModule @GlideModule
public final class MyAppGlideModule extends AppGlideModule {} public final class MyAppGlideModule extends AppGlideModule {

@Override
public void applyOptions(Context context, GlideBuilder builder) {
builder.setLogLevel(Log.ERROR);
}

}

View File

@ -19,11 +19,10 @@ package im.vector.riotredesign.features.home
import android.content.Context import android.content.Context
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import android.widget.ImageView import android.widget.ImageView
import androidx.annotation.AnyThread
import androidx.annotation.UiThread import androidx.annotation.UiThread
import androidx.annotation.WorkerThread
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import com.amulyakhare.textdrawable.TextDrawable import com.amulyakhare.textdrawable.TextDrawable
import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.request.RequestOptions import com.bumptech.glide.request.RequestOptions
import com.bumptech.glide.request.target.DrawableImageViewTarget import com.bumptech.glide.request.target.DrawableImageViewTarget
import com.bumptech.glide.request.target.Target import com.bumptech.glide.request.target.Target
@ -40,8 +39,11 @@ import im.vector.riotredesign.core.glide.GlideRequests
/** /**
* This helper centralise ways to retrieve avatar into ImageView or even generic Target<Drawable> * This helper centralise ways to retrieve avatar into ImageView or even generic Target<Drawable>
*/ */

object AvatarRenderer { object AvatarRenderer {


private const val THUMBNAIL_SIZE = 250

@UiThread @UiThread
fun render(roomMember: RoomMember, imageView: ImageView) { fun render(roomMember: RoomMember, imageView: ImageView) {
render(roomMember.avatarUrl, roomMember.displayName, imageView) render(roomMember.avatarUrl, roomMember.displayName, imageView)
@ -54,7 +56,7 @@ object AvatarRenderer {


@UiThread @UiThread
fun render(avatarUrl: String?, name: String?, imageView: ImageView) { fun render(avatarUrl: String?, name: String?, imageView: ImageView) {
render(imageView.context, GlideApp.with(imageView), avatarUrl, name, imageView.height, DrawableImageViewTarget(imageView)) render(imageView.context, GlideApp.with(imageView), avatarUrl, name, DrawableImageViewTarget(imageView))
} }


@UiThread @UiThread
@ -62,45 +64,18 @@ object AvatarRenderer {
glideRequest: GlideRequests, glideRequest: GlideRequests,
avatarUrl: String?, avatarUrl: String?,
name: String?, name: String?,
size: Int,
target: Target<Drawable>) { target: Target<Drawable>) {
if (name.isNullOrEmpty()) { if (name.isNullOrEmpty()) {
return return
} }
val placeholder = buildPlaceholderDrawable(context, name) val placeholder = getPlaceholderDrawable(context, name)
buildGlideRequest(glideRequest, avatarUrl, size) buildGlideRequest(glideRequest, avatarUrl)
.placeholder(placeholder) .placeholder(placeholder)
.into(target) .into(target)
} }


@WorkerThread @AnyThread
fun getCachedOrPlaceholder(context: Context, fun getPlaceholderDrawable(context: Context, text: String): Drawable {
glideRequest: GlideRequests,
avatarUrl: String?,
text: String,
size: Int): Drawable {
val future = buildGlideRequest(glideRequest, avatarUrl, size).onlyRetrieveFromCache(true).submit()
return try {
future.get()
} catch (exception: Exception) {
buildPlaceholderDrawable(context, text)
}
}

// PRIVATE API *********************************************************************************

private fun buildGlideRequest(glideRequest: GlideRequests, avatarUrl: String?, size: Int): GlideRequest<Drawable> {
val resolvedUrl = Matrix.getInstance().currentSession!!
.contentUrlResolver()
.resolveThumbnail(avatarUrl, size, size, ContentUrlResolver.ThumbnailMethod.SCALE)

return glideRequest
.load(resolvedUrl)
.apply(RequestOptions.circleCropTransform())
.diskCacheStrategy(DiskCacheStrategy.DATA)
}

private fun buildPlaceholderDrawable(context: Context, text: String): Drawable {
val avatarColor = ContextCompat.getColor(context, R.color.pale_teal) val avatarColor = ContextCompat.getColor(context, R.color.pale_teal)
return if (text.isEmpty()) { return if (text.isEmpty()) {
TextDrawable.builder().buildRound("", avatarColor) TextDrawable.builder().buildRound("", avatarColor)
@ -110,7 +85,18 @@ object AvatarRenderer {
val firstLetter = text[firstLetterIndex].toString().toUpperCase() val firstLetter = text[firstLetterIndex].toString().toUpperCase()
TextDrawable.builder().buildRound(firstLetter, avatarColor) TextDrawable.builder().buildRound(firstLetter, avatarColor)
} }
}



// PRIVATE API *********************************************************************************

private fun buildGlideRequest(glideRequest: GlideRequests, avatarUrl: String?): GlideRequest<Drawable> {
val resolvedUrl = Matrix.getInstance().currentSession!!.contentUrlResolver()
.resolveThumbnail(avatarUrl, THUMBNAIL_SIZE, THUMBNAIL_SIZE, ContentUrlResolver.ThumbnailMethod.SCALE)

return glideRequest
.load(resolvedUrl)
.apply(RequestOptions.circleCropTransform())
} }


} }

View File

@ -19,16 +19,9 @@ package im.vector.riotredesign.features.home
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import im.vector.riotredesign.core.glide.GlideApp import im.vector.riotredesign.core.glide.GlideApp
import im.vector.riotredesign.features.home.group.GroupSummaryController 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.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.detail.timeline.helper.TimelineMediaSizeProvider
import im.vector.riotredesign.features.home.room.list.RoomSummaryController import im.vector.riotredesign.features.home.room.list.RoomSummaryController
import im.vector.riotredesign.features.html.EventHtmlRenderer import im.vector.riotredesign.features.html.EventHtmlRenderer
@ -57,28 +50,28 @@ class HomeModule {


// Fragment scopes // Fragment scopes


scope(ROOM_DETAIL_SCOPE) { (fragment: Fragment) -> factory { (fragment: Fragment) ->
val eventHtmlRenderer = EventHtmlRenderer(GlideApp.with(fragment), fragment.requireContext(), get()) val eventHtmlRenderer = EventHtmlRenderer(GlideApp.with(fragment), fragment.requireContext(), get())
val timelineDateFormatter = TimelineDateFormatter(get()) val timelineDateFormatter = TimelineDateFormatter(get())
val timelineMediaSizeProvider = TimelineMediaSizeProvider() val timelineMediaSizeProvider = TimelineMediaSizeProvider()
val messageItemFactory = MessageItemFactory(get(), timelineMediaSizeProvider, timelineDateFormatter, eventHtmlRenderer) val messageItemFactory = MessageItemFactory(get(), timelineMediaSizeProvider, timelineDateFormatter, eventHtmlRenderer)


val timelineItemFactory = TimelineItemFactory(messageItemFactory = messageItemFactory, val timelineItemFactory = TimelineItemFactory(messageItemFactory = messageItemFactory,
roomNameItemFactory = RoomNameItemFactory(get()), roomNameItemFactory = RoomNameItemFactory(get()),
roomTopicItemFactory = RoomTopicItemFactory(get()), roomTopicItemFactory = RoomTopicItemFactory(get()),
roomMemberItemFactory = RoomMemberItemFactory(get()), roomMemberItemFactory = RoomMemberItemFactory(get()),
roomHistoryVisibilityItemFactory = RoomHistoryVisibilityItemFactory(get()), roomHistoryVisibilityItemFactory = RoomHistoryVisibilityItemFactory(get()),
callItemFactory = CallItemFactory(get()), callItemFactory = CallItemFactory(get()),
defaultItemFactory = DefaultItemFactory() defaultItemFactory = DefaultItemFactory()
) )
TimelineEventController(timelineDateFormatter, timelineItemFactory, timelineMediaSizeProvider) TimelineEventController(timelineDateFormatter, timelineItemFactory, timelineMediaSizeProvider)
} }


scope(ROOM_LIST_SCOPE) { factory {
RoomSummaryController(get()) RoomSummaryController(get())
} }


scope(GROUP_LIST_SCOPE) { factory {
GroupSummaryController() GroupSummaryController()
} }



View File

@ -37,7 +37,8 @@ class HomeNavigator {
addToBackstack: Boolean = false) { addToBackstack: Boolean = false) {
Timber.v("Open room detail $roomId - $eventId - $addToBackstack") Timber.v("Open room detail $roomId - $eventId - $addToBackstack")
activity?.let { 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) val roomDetailFragment = RoomDetailFragment.newInstance(args)
it.drawerLayout?.closeDrawer(GravityCompat.START) it.drawerLayout?.closeDrawer(GravityCompat.START)
if (addToBackstack) { if (addToBackstack) {

View File

@ -16,12 +16,14 @@


package im.vector.riotredesign.features.home.room.detail package im.vector.riotredesign.features.home.room.detail


import im.vector.matrix.android.api.session.room.timeline.Timeline
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent import im.vector.matrix.android.api.session.room.timeline.TimelineEvent


sealed class RoomDetailActions { sealed class RoomDetailActions {


data class SendMessage(val text: String) : RoomDetailActions() data class SendMessage(val text: String) : RoomDetailActions()
object IsDisplayed : RoomDetailActions() object IsDisplayed : RoomDetailActions()
data class EventDisplayed(val event: TimelineEvent, val index: Int) : RoomDetailActions() data class EventDisplayed(val event: TimelineEvent) : RoomDetailActions()
data class LoadMore(val direction: Timeline.Direction) : RoomDetailActions()


} }

View File

@ -24,7 +24,6 @@ import android.view.ViewGroup
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.airbnb.epoxy.EpoxyVisibilityTracker import com.airbnb.epoxy.EpoxyVisibilityTracker
import com.airbnb.mvrx.Success
import com.airbnb.mvrx.fragmentViewModel import com.airbnb.mvrx.fragmentViewModel
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
import im.vector.riotredesign.R import im.vector.riotredesign.R
@ -35,7 +34,7 @@ import im.vector.riotredesign.features.home.AvatarRenderer
import im.vector.riotredesign.features.home.HomeModule import im.vector.riotredesign.features.home.HomeModule
import im.vector.riotredesign.features.home.HomePermalinkHandler import im.vector.riotredesign.features.home.HomePermalinkHandler
import im.vector.riotredesign.features.home.room.detail.timeline.TimelineEventController import im.vector.riotredesign.features.home.room.detail.timeline.TimelineEventController
import im.vector.riotredesign.features.home.room.detail.timeline.animation.TimelineItemAnimator import im.vector.riotredesign.features.home.room.detail.timeline.helper.EndlessRecyclerViewScrollListener
import im.vector.riotredesign.features.media.MediaContentRenderer import im.vector.riotredesign.features.media.MediaContentRenderer
import im.vector.riotredesign.features.media.MediaViewerActivity import im.vector.riotredesign.features.media.MediaViewerActivity
import kotlinx.android.parcel.Parcelize import kotlinx.android.parcel.Parcelize
@ -80,7 +79,6 @@ class RoomDetailFragment : RiotFragment(), TimelineEventController.Callback {
setupRecyclerView() setupRecyclerView()
setupToolbar() setupToolbar()
setupSendButton() setupSendButton()
timelineEventController.requestModelBuild()
roomDetailViewModel.subscribe { renderState(it) } roomDetailViewModel.subscribe { renderState(it) }
} }


@ -105,12 +103,17 @@ class RoomDetailFragment : RiotFragment(), TimelineEventController.Callback {
val stateRestorer = LayoutManagerStateRestorer(layoutManager).register() val stateRestorer = LayoutManagerStateRestorer(layoutManager).register()
scrollOnNewMessageCallback = ScrollOnNewMessageCallback(layoutManager) scrollOnNewMessageCallback = ScrollOnNewMessageCallback(layoutManager)
recyclerView.layoutManager = layoutManager recyclerView.layoutManager = layoutManager
recyclerView.itemAnimator = TimelineItemAnimator() recyclerView.itemAnimator = null
recyclerView.setHasFixedSize(true) recyclerView.setHasFixedSize(true)
timelineEventController.addModelBuildListener { timelineEventController.addModelBuildListener {
it.dispatchTo(stateRestorer) it.dispatchTo(stateRestorer)
it.dispatchTo(scrollOnNewMessageCallback) it.dispatchTo(scrollOnNewMessageCallback)
} }

recyclerView.addOnScrollListener(
EndlessRecyclerViewScrollListener(layoutManager, RoomDetailViewModel.PAGINATION_COUNT) { direction ->
roomDetailViewModel.process(RoomDetailActions.LoadMore(direction))
})
recyclerView.setController(timelineEventController) recyclerView.setController(timelineEventController)
timelineEventController.callback = this timelineEventController.callback = this
} }
@ -119,29 +122,15 @@ class RoomDetailFragment : RiotFragment(), TimelineEventController.Callback {
sendButton.setOnClickListener { sendButton.setOnClickListener {
val textMessage = composerEditText.text.toString() val textMessage = composerEditText.text.toString()
if (textMessage.isNotBlank()) { if (textMessage.isNotBlank()) {
composerEditText.text = null
roomDetailViewModel.process(RoomDetailActions.SendMessage(textMessage)) roomDetailViewModel.process(RoomDetailActions.SendMessage(textMessage))
composerEditText.text = null
} }
} }
} }


private fun renderState(state: RoomDetailViewState) { private fun renderState(state: RoomDetailViewState) {
renderRoomSummary(state) renderRoomSummary(state)
renderTimeline(state) timelineEventController.setTimeline(state.timeline)
}

private fun renderTimeline(state: RoomDetailViewState) {
when (state.asyncTimelineData) {
is Success -> {
val timelineData = state.asyncTimelineData()
val lockAutoScroll = timelineData?.let {
it.events == timelineEventController.currentList && it.isLoadingForward
} ?: true

scrollOnNewMessageCallback.isLocked.set(lockAutoScroll)
timelineEventController.update(timelineData)
}
}
} }


private fun renderRoomSummary(state: RoomDetailViewState) { private fun renderRoomSummary(state: RoomDetailViewState) {
@ -157,14 +146,14 @@ class RoomDetailFragment : RiotFragment(), TimelineEventController.Callback {
} }
} }


// TimelineEventController.Callback ************************************************************ // TimelineEventController.Callback ************************************************************


override fun onUrlClicked(url: String) { override fun onUrlClicked(url: String) {
homePermalinkHandler.launch(url) homePermalinkHandler.launch(url)
} }


override fun onEventVisible(event: TimelineEvent, index: Int) { override fun onEventVisible(event: TimelineEvent) {
roomDetailViewModel.process(RoomDetailActions.EventDisplayed(event, index)) roomDetailViewModel.process(RoomDetailActions.EventDisplayed(event))
} }


override fun onMediaClicked(mediaData: MediaContentRenderer.Data, view: View) { override fun onMediaClicked(mediaData: MediaContentRenderer.Data, view: View) {

View File

@ -23,9 +23,9 @@ import im.vector.matrix.android.api.MatrixCallback
import im.vector.matrix.android.api.session.Session import im.vector.matrix.android.api.session.Session
import im.vector.matrix.android.api.session.events.model.Event import im.vector.matrix.android.api.session.events.model.Event
import im.vector.matrix.rx.rx import im.vector.matrix.rx.rx
import im.vector.riotredesign.core.extensions.lastMinBy
import im.vector.riotredesign.core.platform.RiotViewModel import im.vector.riotredesign.core.platform.RiotViewModel
import im.vector.riotredesign.features.home.room.VisibleRoomStore 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 io.reactivex.rxkotlin.subscribeBy
import org.koin.android.ext.android.get import org.koin.android.ext.android.get
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
@ -38,11 +38,13 @@ class RoomDetailViewModel(initialState: RoomDetailViewState,
private val room = session.getRoom(initialState.roomId)!! private val room = session.getRoom(initialState.roomId)!!
private val roomId = initialState.roomId private val roomId = initialState.roomId
private val eventId = initialState.eventId private val eventId = initialState.eventId

private val displayedEventsObservable = BehaviorRelay.create<RoomDetailActions.EventDisplayed>() private val displayedEventsObservable = BehaviorRelay.create<RoomDetailActions.EventDisplayed>()
private val timeline = room.createTimeline(eventId, TimelineDisplayableEvents.DISPLAYABLE_TYPES)


companion object : MvRxViewModelFactory<RoomDetailViewModel, RoomDetailViewState> { companion object : MvRxViewModelFactory<RoomDetailViewModel, RoomDetailViewState> {


const val PAGINATION_COUNT = 50

@JvmStatic @JvmStatic
override fun create(viewModelContext: ViewModelContext, state: RoomDetailViewState): RoomDetailViewModel? { override fun create(viewModelContext: ViewModelContext, state: RoomDetailViewState): RoomDetailViewModel? {
val currentSession = viewModelContext.activity.get<Session>() val currentSession = viewModelContext.activity.get<Session>()
@ -53,9 +55,10 @@ class RoomDetailViewModel(initialState: RoomDetailViewState,


init { init {
observeRoomSummary() observeRoomSummary()
observeTimeline() observeEventDisplayedActions()
observeDisplayedEvents()
room.loadRoomMembersIfNeeded() room.loadRoomMembersIfNeeded()
timeline.start()
setState { copy(timeline = this@RoomDetailViewModel.timeline) }
} }


fun process(action: RoomDetailActions) { fun process(action: RoomDetailActions) {
@ -63,6 +66,7 @@ class RoomDetailViewModel(initialState: RoomDetailViewState,
is RoomDetailActions.SendMessage -> handleSendMessage(action) is RoomDetailActions.SendMessage -> handleSendMessage(action)
is RoomDetailActions.IsDisplayed -> handleIsDisplayed() is RoomDetailActions.IsDisplayed -> handleIsDisplayed()
is RoomDetailActions.EventDisplayed -> handleEventDisplayed(action) is RoomDetailActions.EventDisplayed -> handleEventDisplayed(action)
is RoomDetailActions.LoadMore -> handleLoadMore(action)
} }
} }


@ -80,14 +84,18 @@ class RoomDetailViewModel(initialState: RoomDetailViewState,
visibleRoomHolder.post(roomId) visibleRoomHolder.post(roomId)
} }


private fun observeDisplayedEvents() { private fun handleLoadMore(action: RoomDetailActions.LoadMore) {
timeline.paginate(action.direction, PAGINATION_COUNT)
}

private fun observeEventDisplayedActions() {
// We are buffering scroll events for one second // We are buffering scroll events for one second
// and keep the most recent one to set the read receipt on. // and keep the most recent one to set the read receipt on.
displayedEventsObservable.hide() displayedEventsObservable
.buffer(1, TimeUnit.SECONDS) .buffer(1, TimeUnit.SECONDS)
.filter { it.isNotEmpty() } .filter { it.isNotEmpty() }
.subscribeBy(onNext = { actions -> .subscribeBy(onNext = { actions ->
val mostRecentEvent = actions.lastMinBy { it.index } val mostRecentEvent = actions.maxBy { it.event.displayIndex }
mostRecentEvent?.event?.root?.eventId?.let { eventId -> mostRecentEvent?.event?.root?.eventId?.let { eventId ->
room.setReadReceipt(eventId, callback = object : MatrixCallback<Void> {}) room.setReadReceipt(eventId, callback = object : MatrixCallback<Void> {})
} }
@ -102,12 +110,8 @@ class RoomDetailViewModel(initialState: RoomDetailViewState,
} }
} }


private fun observeTimeline() { override fun onCleared() {
room.rx().timeline(eventId) timeline.dispose()
.execute { timelineData -> super.onCleared()
copy(asyncTimelineData = timelineData)
}
} }


} }

View File

@ -20,11 +20,13 @@ import com.airbnb.mvrx.Async
import com.airbnb.mvrx.MvRxState import com.airbnb.mvrx.MvRxState
import com.airbnb.mvrx.Uninitialized import com.airbnb.mvrx.Uninitialized
import im.vector.matrix.android.api.session.room.model.RoomSummary import im.vector.matrix.android.api.session.room.model.RoomSummary
import im.vector.matrix.android.api.session.room.timeline.Timeline
import im.vector.matrix.android.api.session.room.timeline.TimelineData import im.vector.matrix.android.api.session.room.timeline.TimelineData


data class RoomDetailViewState( data class RoomDetailViewState(
val roomId: String, val roomId: String,
val eventId: String?, val eventId: String?,
val timeline: Timeline? = null,
val asyncRoomSummary: Async<RoomSummary> = Uninitialized, val asyncRoomSummary: Async<RoomSummary> = Uninitialized,
val asyncTimelineData: Async<TimelineData> = Uninitialized val asyncTimelineData: Async<TimelineData> = Uninitialized
) : MvRxState { ) : MvRxState {

View File

@ -18,14 +18,11 @@ package im.vector.riotredesign.features.home.room.detail


import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import im.vector.riotredesign.core.platform.DefaultListUpdateCallback import im.vector.riotredesign.core.platform.DefaultListUpdateCallback
import java.util.concurrent.atomic.AtomicBoolean


class ScrollOnNewMessageCallback(private val layoutManager: LinearLayoutManager) : DefaultListUpdateCallback { class ScrollOnNewMessageCallback(private val layoutManager: LinearLayoutManager) : DefaultListUpdateCallback {


var isLocked = AtomicBoolean(true)

override fun onInserted(position: Int, count: Int) { override fun onInserted(position: Int, count: Int) {
if (isLocked.compareAndSet(false, true) && position == 0 && layoutManager.findFirstVisibleItemPosition() == 0) { if (position == 0 && layoutManager.findFirstVisibleItemPosition() == 0) {
layoutManager.scrollToPosition(0) layoutManager.scrollToPosition(0)
} }
} }

View File

@ -16,46 +16,94 @@


package im.vector.riotredesign.features.home.room.detail.timeline package im.vector.riotredesign.features.home.room.detail.timeline


import android.os.Handler
import android.os.Looper
import android.view.View import android.view.View
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListUpdateCallback
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.airbnb.epoxy.EpoxyAsyncUtil import com.airbnb.epoxy.EpoxyController
import com.airbnb.epoxy.EpoxyModel import com.airbnb.epoxy.EpoxyModel
import com.airbnb.epoxy.VisibilityState import com.airbnb.epoxy.VisibilityState
import im.vector.matrix.android.api.session.events.model.EventType import im.vector.matrix.android.api.session.room.timeline.Timeline
import im.vector.matrix.android.api.session.room.timeline.TimelineData
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
import im.vector.riotredesign.core.epoxy.LoadingItemModel_ import im.vector.riotredesign.core.epoxy.LoadingItemModel_
import im.vector.riotredesign.core.epoxy.RiotEpoxyModel import im.vector.riotredesign.core.epoxy.RiotEpoxyModel
import im.vector.riotredesign.core.extensions.localDateTime 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.factory.TimelineItemFactory
import im.vector.riotredesign.features.home.room.detail.timeline.helper.TimelineDateFormatter import im.vector.riotredesign.features.home.room.detail.timeline.helper.*
import im.vector.riotredesign.features.home.room.detail.timeline.helper.TimelineMediaSizeProvider
import im.vector.riotredesign.features.home.room.detail.timeline.item.DaySeparatorItem_ import im.vector.riotredesign.features.home.room.detail.timeline.item.DaySeparatorItem_
import im.vector.riotredesign.features.home.room.detail.timeline.paging.PagedListEpoxyController
import im.vector.riotredesign.features.media.MediaContentRenderer import im.vector.riotredesign.features.media.MediaContentRenderer


class TimelineEventController(private val dateFormatter: TimelineDateFormatter, class TimelineEventController(private val dateFormatter: TimelineDateFormatter,
private val timelineItemFactory: TimelineItemFactory, private val timelineItemFactory: TimelineItemFactory,
private val timelineMediaSizeProvider: TimelineMediaSizeProvider private val timelineMediaSizeProvider: TimelineMediaSizeProvider,
) : PagedListEpoxyController<TimelineEvent>( private val backgroundHandler: Handler = TimelineAsyncHelper.getBackgroundHandler()
EpoxyAsyncUtil.getAsyncBackgroundHandler(), ) : EpoxyController(backgroundHandler, backgroundHandler), Timeline.Listener {
EpoxyAsyncUtil.getAsyncBackgroundHandler()
) {


private var isLoadingForward: Boolean = false interface Callback {
private var isLoadingBackward: Boolean = false fun onEventVisible(event: TimelineEvent)
private var hasReachedEnd: Boolean = true fun onUrlClicked(url: String)
fun onMediaClicked(mediaData: MediaContentRenderer.Data, view: View)
}

private val modelCache = arrayListOf<List<EpoxyModel<*>>>()
private var currentSnapshot: List<TimelineEvent> = emptyList()
private var inSubmitList: Boolean = false
private var timeline: Timeline? = null


var callback: Callback? = null var callback: Callback? = null


fun update(timelineData: TimelineData?) { private val listUpdateCallback = object : ListUpdateCallback {
timelineData?.let {
isLoadingForward = it.isLoadingForward @Synchronized
isLoadingBackward = it.isLoadingBackward override fun onChanged(position: Int, count: Int, payload: Any?) {
hasReachedEnd = it.events.lastOrNull()?.root?.type == EventType.STATE_ROOM_CREATE assertUpdateCallbacksAllowed()
submitList(it.events) (position until (position + count)).forEach {
modelCache[it] = emptyList()
}
requestModelBuild() requestModelBuild()
} }

@Synchronized
override fun onMoved(fromPosition: Int, toPosition: Int) {
assertUpdateCallbacksAllowed()
val model = modelCache.removeAt(fromPosition)
modelCache.add(toPosition, model)
requestModelBuild()
}

@Synchronized
override fun onInserted(position: Int, count: Int) {
assertUpdateCallbacksAllowed()
if (modelCache.isNotEmpty() && position == modelCache.size) {
modelCache[position - 1] = emptyList()
}
(0 until count).forEach {
modelCache.add(position, emptyList())
}
requestModelBuild()
}

@Synchronized
override fun onRemoved(position: Int, count: Int) {
assertUpdateCallbacksAllowed()
(0 until count).forEach {
modelCache.removeAt(position)
}
requestModelBuild()
}
}

init {
requestModelBuild()
}

fun setTimeline(timeline: Timeline?) {
if (this.timeline != timeline) {
this.timeline = timeline
this.timeline?.listener = this
}
} }


override fun onAttachedToRecyclerView(recyclerView: RecyclerView) { override fun onAttachedToRecyclerView(recyclerView: RecyclerView) {
@ -63,13 +111,55 @@ class TimelineEventController(private val dateFormatter: TimelineDateFormatter,
timelineMediaSizeProvider.recyclerView = recyclerView timelineMediaSizeProvider.recyclerView = recyclerView
} }


override fun buildItemModels(currentPosition: Int, items: List<TimelineEvent?>): List<EpoxyModel<*>> { override fun buildModels() {
if (items.isNullOrEmpty()) { LoadingItemModel_()
return emptyList() .id("forward_loading_item")
.addWhen(Timeline.Direction.FORWARDS)


val timelineModels = getModels()
add(timelineModels)

LoadingItemModel_()
.id("backward_loading_item")
.addWhen(Timeline.Direction.BACKWARDS)
}

// Timeline.LISTENER ***************************************************************************

override fun onUpdated(snapshot: List<TimelineEvent>) {
submitSnapshot(snapshot)
}

private fun submitSnapshot(newSnapshot: List<TimelineEvent>) {
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
private fun getModels(): List<EpoxyModel<*>> {
(0 until modelCache.size).forEach { position ->
if (modelCache[position].isEmpty()) {
modelCache[position] = buildItemModels(position, currentSnapshot)
}
}
return modelCache.flatten()
}

private fun buildItemModels(currentPosition: Int, items: List<TimelineEvent>): List<EpoxyModel<*>> {
val epoxyModels = ArrayList<EpoxyModel<*>>() val epoxyModels = ArrayList<EpoxyModel<*>>()
val event = items[currentPosition] ?: return emptyList() 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 date = event.root.localDateTime()
val nextDate = nextEvent?.root?.localDateTime() val nextDate = nextEvent?.root?.localDateTime()
@ -77,7 +167,7 @@ class TimelineEventController(private val dateFormatter: TimelineDateFormatter,


timelineItemFactory.create(event, nextEvent, callback).also { timelineItemFactory.create(event, nextEvent, callback).also {
it.id(event.localId) it.id(event.localId)
it.setOnVisibilityStateChanged(TimelineEventVisibilityStateChangedListener(callback, event, currentPosition)) it.setOnVisibilityStateChanged(TimelineEventVisibilityStateChangedListener(callback, event))
epoxyModels.add(it) epoxyModels.add(it)
} }
if (addDaySeparator) { if (addDaySeparator) {
@ -88,35 +178,22 @@ class TimelineEventController(private val dateFormatter: TimelineDateFormatter,
return epoxyModels return epoxyModels
} }


override fun addModels(models: List<EpoxyModel<*>>) { private fun LoadingItemModel_.addWhen(direction: Timeline.Direction) {
LoadingItemModel_() val shouldAdd = timeline?.let {
.id("forward_loading_item") it.hasMoreToLoad(direction)
.addIf(isLoadingForward, this) } ?: false

addIf(shouldAdd, this@TimelineEventController)
super.add(models)

LoadingItemModel_()
.id("backward_loading_item")
.addIf(!hasReachedEnd, this)
}


interface Callback {
fun onEventVisible(event: TimelineEvent, index: Int)
fun onUrlClicked(url: String)
fun onMediaClicked(mediaData: MediaContentRenderer.Data, view: View)
} }


} }


private class TimelineEventVisibilityStateChangedListener(private val callback: TimelineEventController.Callback?, private class TimelineEventVisibilityStateChangedListener(private val callback: TimelineEventController.Callback?,
private val event: TimelineEvent, private val event: TimelineEvent)
private val currentPosition: Int)
: RiotEpoxyModel.OnVisibilityStateChangedListener { : RiotEpoxyModel.OnVisibilityStateChangedListener {


override fun onVisibilityStateChanged(visibilityState: Int) { override fun onVisibilityStateChanged(visibilityState: Int) {
if (visibilityState == VisibilityState.VISIBLE) { if (visibilityState == VisibilityState.VISIBLE) {
callback?.onEventVisible(event, currentPosition) callback?.onEventVisible(event)
} }
} }



View File

@ -24,9 +24,9 @@ class TimelineItemAnimator : DefaultItemAnimator() {


init { init {
addDuration = ANIM_DURATION_IN_MILLIS addDuration = ANIM_DURATION_IN_MILLIS
removeDuration = ANIM_DURATION_IN_MILLIS removeDuration = 0
moveDuration = ANIM_DURATION_IN_MILLIS moveDuration = 0
changeDuration = ANIM_DURATION_IN_MILLIS changeDuration = 0
} }


} }

View File

@ -42,8 +42,6 @@ class MessageItemFactory(private val colorProvider: ColorProvider,
private val timelineDateFormatter: TimelineDateFormatter, private val timelineDateFormatter: TimelineDateFormatter,
private val htmlRenderer: EventHtmlRenderer) { private val htmlRenderer: EventHtmlRenderer) {


private val messagesDisplayedWithInformation = HashSet<String?>()

fun create(event: TimelineEvent, fun create(event: TimelineEvent,
nextEvent: TimelineEvent?, nextEvent: TimelineEvent?,
callback: TimelineEventController.Callback? callback: TimelineEventController.Callback?
@ -58,15 +56,12 @@ class MessageItemFactory(private val colorProvider: ColorProvider,
val isNextMessageReceivedMoreThanOneHourAgo = nextDate?.isBefore(date.minusMinutes(60)) val isNextMessageReceivedMoreThanOneHourAgo = nextDate?.isBefore(date.minusMinutes(60))
?: false ?: false


if (addDaySeparator val showInformation = addDaySeparator
|| nextRoomMember != roomMember || nextRoomMember != roomMember
|| nextEvent?.root?.type != EventType.MESSAGE || nextEvent?.root?.type != EventType.MESSAGE
|| isNextMessageReceivedMoreThanOneHourAgo) { || isNextMessageReceivedMoreThanOneHourAgo
messagesDisplayedWithInformation.add(event.root.eventId)
}


val messageContent: MessageContent = event.root.content.toModel() ?: return null val messageContent: MessageContent = event.root.content.toModel() ?: return null
val showInformation = messagesDisplayedWithInformation.contains(event.root.eventId)
val time = timelineDateFormatter.formatMessageHour(date) val time = timelineDateFormatter.formatMessageHour(date)
val avatarUrl = roomMember?.avatarUrl val avatarUrl = roomMember?.avatarUrl
val memberName = roomMember?.displayName ?: event.root.sender val memberName = roomMember?.displayName ?: event.root.sender

View File

@ -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 its 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 isnt currently loading, we check to see if we have reached
// 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)
}
}


}

View File

@ -0,0 +1,40 @@
/*
*
* * 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 android.os.Handler
import android.os.HandlerThread

private const val THREAD_NAME = "Timeline_Building_Thread"

object TimelineAsyncHelper {

private var backgroundHandler: Handler? = null

fun getBackgroundHandler(): Handler {
return backgroundHandler ?: createBackgroundHandler().also { backgroundHandler = it }
}

private fun createBackgroundHandler(): Handler {
val handlerThread = HandlerThread(THREAD_NAME)
handlerThread.start()
return Handler(handlerThread.looper)
}

}

View File

@ -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) && !root.content.isNullOrEmpty()
}

fun List<TimelineEvent>.filterDisplayableEvents(): List<TimelineEvent> {
return this.filter {
it.isDisplayable()
}
}

fun List<TimelineEvent>.nextDisplayableEvent(index: Int): TimelineEvent? {
return if (index == size - 1) {
null
} else {
subList(index + 1, this.size).firstOrNull { it.isDisplayable() }
}
}

View File

@ -0,0 +1,44 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package im.vector.riotredesign.features.home.room.detail.timeline.helper

import androidx.recyclerview.widget.DiffUtil
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent

class TimelineEventDiffUtilCallback(private val oldList: List<TimelineEvent>,
private val newList: List<TimelineEvent>) : DiffUtil.Callback() {

override fun getOldListSize(): Int {
return oldList.size
}

override fun getNewListSize(): Int {
return newList.size
}

override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
val oldItem = oldList[oldItemPosition]
val newItem = newList[newItemPosition]
return oldItem.localId == newItem.localId
}

override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
val oldItem = oldList[oldItemPosition]
val newItem = newList[newItemPosition]
return oldItem == newItem
}
}

View File

@ -1,129 +0,0 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package im.vector.riotredesign.features.home.room.detail.timeline.paging

import androidx.paging.PagedList
import android.os.Handler
import androidx.recyclerview.widget.DiffUtil
import com.airbnb.epoxy.EpoxyController
import com.airbnb.epoxy.EpoxyModel
import com.airbnb.epoxy.EpoxyViewHolder

/**
* An [EpoxyController] that can work with a [PagedList].
*
* Internally, it caches the model for each item in the [PagedList]. You should override
* [buildItemModel] method to build the model for the given item. Since [PagedList] might include
* `null` items if placeholders are enabled, this method needs to handle `null` values in the list.
*
* By default, the model for each item is added to the model list. To change this behavior (to
* filter items or inject extra items), you can override [addModels] function and manually add built
* models.
*
* @param T The type of the items in the [PagedList].
*/
abstract class PagedListEpoxyController<T>(
/**
* The handler to use for building models. By default this uses the main thread, but you can use
* [EpoxyAsyncUtil.getAsyncBackgroundHandler] to do model building in the background.
*
* The notify thread of your PagedList (from setNotifyExecutor in the PagedList Builder) must be
* the same as this thread. Otherwise Epoxy will crash.
*/
modelBuildingHandler: Handler = EpoxyController.defaultModelBuildingHandler,
/**
* The handler to use when calculating the diff between built model lists.
* By default this uses the main thread, but you can use
* [EpoxyAsyncUtil.getAsyncBackgroundHandler] to do diffing in the background.
*/
diffingHandler: Handler = EpoxyController.defaultDiffingHandler,
/**
* [PagedListEpoxyController] uses an [DiffUtil.ItemCallback] to detect changes between
* [PagedList]s. By default, it relies on simple object equality but you can provide a custom
* one if you don't use all fields in the object in your models.
*/
itemDiffCallback: DiffUtil.ItemCallback<T> = DEFAULT_ITEM_DIFF_CALLBACK as DiffUtil.ItemCallback<T>
) : EpoxyController(modelBuildingHandler, diffingHandler) {
// this is where we keep the already built models
protected val modelCache = PagedListModelCache(
modelBuilder = { pos, item ->
buildItemModels(pos, item)
},
rebuildCallback = {
requestModelBuild()
},
itemDiffCallback = itemDiffCallback,
modelBuildingHandler = modelBuildingHandler
)

var currentList: PagedList<T>? = null
private set

final override fun buildModels() {
addModels(modelCache.getModels())
}

override fun onModelBound(
holder: EpoxyViewHolder,
boundModel: EpoxyModel<*>,
position: Int,
previouslyBoundModel: EpoxyModel<*>?
) {
modelCache.loadAround(boundModel)
}

/**
* This function adds all built models to the adapter. You can override this method to add extra
* items into the model list or remove some.
*/
open fun addModels(models: List<EpoxyModel<*>>) {
super.add(models)
}

/**
* Builds the model for a given item. This must return a single model for each item. If you want
* to inject headers etc, you can override [addModels] function.
*
* If the `item` is `null`, you should provide the placeholder. If your [PagedList] is configured
* without placeholders, you don't need to handle the `null` case.
*/
abstract fun buildItemModels(currentPosition: Int, items: List<T?>): List<EpoxyModel<*>>

/**
* Submit a new paged list.
*
* A diff will be calculated between this list and the previous list so you may still get calls
* to [buildItemModel] with items from the previous list.
*/
fun submitList(newList: PagedList<T>?) {
currentList = newList
modelCache.submitList(newList)
}

companion object {
/**
* [PagedListEpoxyController] calculates a diff on top of the PagedList to check which
* models are invalidated.
* This is the default [DiffUtil.ItemCallback] which uses object equality.
*/
val DEFAULT_ITEM_DIFF_CALLBACK = object : DiffUtil.ItemCallback<Any>() {
override fun areItemsTheSame(oldItem: Any, newItem: Any) = oldItem == newItem

override fun areContentsTheSame(oldItem: Any, newItem: Any) = oldItem == newItem
}
}
}

View File

@ -1,149 +0,0 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package im.vector.riotredesign.features.home.room.detail.timeline.paging

import android.annotation.SuppressLint
import android.os.Handler
import androidx.paging.AsyncPagedListDiffer
import androidx.paging.PagedList
import androidx.recyclerview.widget.AsyncDifferConfig
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListUpdateCallback
import com.airbnb.epoxy.EpoxyModel
import java.util.concurrent.Executor
import java.util.concurrent.atomic.AtomicBoolean

/**
* A PagedList stream wrapper that caches models built for each item. It tracks changes in paged lists and caches
* models for each item when they are invalidated to avoid rebuilding models for the whole list when PagedList is
* updated.
*/
class PagedListModelCache<T>(
private val modelBuilder: (itemIndex: Int, items: List<T>) -> List<EpoxyModel<*>>,
private val rebuildCallback: () -> Unit,
private val itemDiffCallback: DiffUtil.ItemCallback<T>,
private val diffExecutor: Executor? = null,
private val modelBuildingHandler: Handler
) {


// Int is the index of the pagedList item
// We have to be able to find the pagedlist position coming from an epoxy model to trigger
// LoadAround with accuracy
private val modelCache = linkedMapOf<EpoxyModel<*>, Int>()
private var isCacheStale = AtomicBoolean(true)

/**
* Tracks the last accessed position so that we can report it back to the paged list when models are built.
*/
private var lastPosition: Int? = null

/**
* Observer for the PagedList changes that invalidates the model cache when data is updated.
*/
private val updateCallback = object : ListUpdateCallback {
override fun onChanged(position: Int, count: Int, payload: Any?) {
invalidate()
rebuildCallback()
}

override fun onMoved(fromPosition: Int, toPosition: Int) {
invalidate()
rebuildCallback()
}

override fun onInserted(position: Int, count: Int) {
invalidate()
rebuildCallback()
}

override fun onRemoved(position: Int, count: Int) {
invalidate()
rebuildCallback()
}
}

@SuppressLint("RestrictedApi")
private val asyncDiffer = AsyncPagedListDiffer<T>(
updateCallback,
AsyncDifferConfig.Builder<T>(
itemDiffCallback
).also { builder ->
if (diffExecutor != null) {
builder.setBackgroundThreadExecutor(diffExecutor)
}
// we have to reply on this private API, otherwise, paged list might be changed when models are being built,
// potentially creating concurrent modification problems.
builder.setMainThreadExecutor { runnable: Runnable ->
modelBuildingHandler.post(runnable)
}
}.build()
)

fun submitList(pagedList: PagedList<T>?) {
asyncDiffer.submitList(pagedList)
}

fun getModels(): List<EpoxyModel<*>> {
if (isCacheStale.compareAndSet(true, false)) {
asyncDiffer.currentList?.forEachIndexed { position, _ ->
buildModel(position)
}
}
lastPosition?.let {
triggerLoadAround(it)
}
return modelCache.keys.toList()
}

fun loadAround(model: EpoxyModel<*>) {
modelCache[model]?.let { itemPosition ->
triggerLoadAround(itemPosition)
lastPosition = itemPosition
}
}

// PRIVATE METHODS *****************************************************************************

private fun invalidate() {
modelCache.clear()
isCacheStale.set(true)
}

private fun cacheModelsAtPosition(itemPosition: Int, epoxyModels: Set<EpoxyModel<*>>) {
epoxyModels.forEach {
modelCache[it] = itemPosition
}
}

private fun buildModel(pos: Int) {
if (pos >= asyncDiffer.currentList?.size ?: 0) {
return
}
modelBuilder(pos, asyncDiffer.currentList as List<T>).also {
cacheModelsAtPosition(pos, it.toSet())
}
}

private fun triggerLoadAround(position: Int) {
asyncDiffer.currentList?.let {
if (it.size > 0) {
it.loadAround(Math.min(position, it.size - 1))
}
}
}
}

View File

@ -37,8 +37,6 @@ import java.lang.ref.WeakReference
* It's needed to call [bind] method to start requesting avatar, otherwise only the placeholder icon will be displayed if not already cached. * It's needed to call [bind] method to start requesting avatar, otherwise only the placeholder icon will be displayed if not already cached.
*/ */


private const val PILL_AVATAR_SIZE = 80

class PillImageSpan(private val glideRequests: GlideRequests, class PillImageSpan(private val glideRequests: GlideRequests,
private val context: Context, private val context: Context,
private val userId: String, private val userId: String,
@ -55,7 +53,7 @@ class PillImageSpan(private val glideRequests: GlideRequests,
@UiThread @UiThread
fun bind(textView: TextView) { fun bind(textView: TextView) {
tv = WeakReference(textView) tv = WeakReference(textView)
AvatarRenderer.render(context, glideRequests, user?.avatarUrl, displayName, PILL_AVATAR_SIZE, target) AvatarRenderer.render(context, glideRequests, user?.avatarUrl, displayName, target)
} }


// ReplacementSpan ***************************************************************************** // ReplacementSpan *****************************************************************************
@ -108,7 +106,7 @@ class PillImageSpan(private val glideRequests: GlideRequests,
textStartPadding = textPadding textStartPadding = textPadding
setChipMinHeightResource(R.dimen.pill_min_height) setChipMinHeightResource(R.dimen.pill_min_height)
setChipIconSizeResource(R.dimen.pill_avatar_size) setChipIconSizeResource(R.dimen.pill_avatar_size)
chipIcon = AvatarRenderer.getCachedOrPlaceholder(context, glideRequests, user?.avatarUrl, displayName, PILL_AVATAR_SIZE) chipIcon = AvatarRenderer.getPlaceholderDrawable(context, displayName)
setBounds(0, 0, intrinsicWidth, intrinsicHeight) setBounds(0, 0, intrinsicWidth, intrinsicHeight)
} }
} }

View File

@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<View xmlns:android="http://schemas.android.com/apk/res/android" <View xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="1dp"/> android:layout_height="0dp" />

View File

@ -3,7 +3,7 @@


<!-- DARK THEME COLORS --> <!-- DARK THEME COLORS -->


<style name="AppTheme.Base.Dark" parent="Theme.AppCompat.NoActionBar"> <style name="AppTheme.Base.Dark" parent="Theme.MaterialComponents.NoActionBar.Bridge">
<item name="colorPrimaryDark">@color/primary_color_dark_dark</item> <item name="colorPrimaryDark">@color/primary_color_dark_dark</item>
<item name="colorPrimary">@color/primary_color_dark</item> <item name="colorPrimary">@color/primary_color_dark</item>
<item name="colorAccent">@color/accent_color_dark</item> <item name="colorAccent">@color/accent_color_dark</item>

View File

@ -3,7 +3,7 @@


<!-- LIGHT THEME COLORS --> <!-- LIGHT THEME COLORS -->


<style name="AppTheme.Base.Light" parent="Theme.AppCompat.Light.NoActionBar"> <style name="AppTheme.Base.Light" parent="Theme.MaterialComponents.Light.NoActionBar.Bridge">
<item name="colorPrimaryDark">@color/primary_color_dark_light</item> <item name="colorPrimaryDark">@color/primary_color_dark_light</item>
<item name="colorPrimary">@color/primary_color_light</item> <item name="colorPrimary">@color/primary_color_light</item>
<item name="colorAccent">@color/accent_color_light</item> <item name="colorAccent">@color/accent_color_light</item>

View File

@ -3,7 +3,7 @@


<!-- STATUS.IM THEME COLORS --> <!-- STATUS.IM THEME COLORS -->


<style name="AppTheme.Base.Status" parent="Theme.AppCompat.Light.NoActionBar"> <style name="AppTheme.Base.Status" parent="Theme.MaterialComponents.Light.NoActionBar.Bridge">
<item name="colorPrimaryDark">@color/primary_color_dark_status</item> <item name="colorPrimaryDark">@color/primary_color_dark_status</item>
<item name="colorPrimary">@color/primary_color_status</item> <item name="colorPrimary">@color/primary_color_status</item>
<item name="colorAccent">@color/accent_color_status</item> <item name="colorAccent">@color/accent_color_status</item>
@ -21,7 +21,8 @@


<!-- default background color --> <!-- default background color -->
<item name="android:colorBackground">@color/riot_primary_background_color_status</item> <item name="android:colorBackground">@color/riot_primary_background_color_status</item>
<item name="vctr_bottom_nav_background_color">@color/riot_primary_background_color_status</item> <item name="vctr_bottom_nav_background_color">@color/riot_primary_background_color_status
</item>


<!-- waiting view background --> <!-- waiting view background -->
<item name="vctr_waiting_background_color">#AAAAAAAA</item> <item name="vctr_waiting_background_color">#AAAAAAAA</item>
@ -108,7 +109,8 @@
<item name="vctr_tabbar_background">@drawable/vector_tabbar_background_status</item> <item name="vctr_tabbar_background">@drawable/vector_tabbar_background_status</item>


<item name="vctr_pill_background_user_id">@drawable/pill_background_user_id_status</item> <item name="vctr_pill_background_user_id">@drawable/pill_background_user_id_status</item>
<item name="vctr_pill_background_room_alias">@drawable/pill_background_room_alias_status</item> <item name="vctr_pill_background_room_alias">@drawable/pill_background_room_alias_status
</item>


<item name="vctr_pill_text_color_user_id">@color/riot_primary_text_color_status</item> <item name="vctr_pill_text_color_user_id">@color/riot_primary_text_color_status</item>
<item name="vctr_pill_text_color_room_alias">@android:color/white</item> <item name="vctr_pill_text_color_room_alias">@android:color/white</item>

View File

@ -1,7 +1,7 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules. // Top-level build file where you can add configuration options common to all sub-projects/modules.


buildscript { buildscript {
ext.kotlin_version = '1.3.20' ext.kotlin_version = '1.3.21'
ext.koin_version = '1.0.2' ext.koin_version = '1.0.2'
repositories { repositories {
google() google()
@ -10,7 +10,7 @@ buildscript {
url "https://plugins.gradle.org/m2/" url "https://plugins.gradle.org/m2/"
} } } }
dependencies { dependencies {
classpath 'com.android.tools.build:gradle:3.3.1' classpath 'com.android.tools.build:gradle:3.3.2'
classpath "com.airbnb.okreplay:gradle-plugin:1.4.0" classpath "com.airbnb.okreplay:gradle-plugin:1.4.0"
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
classpath 'org.sonarsource.scanner.gradle:sonarqube-gradle-plugin:2.6.2' classpath 'org.sonarsource.scanner.gradle:sonarqube-gradle-plugin:2.6.2'

View File

@ -18,7 +18,6 @@ package im.vector.matrix.rx


import im.vector.matrix.android.api.session.room.Room import im.vector.matrix.android.api.session.room.Room
import im.vector.matrix.android.api.session.room.model.RoomSummary import im.vector.matrix.android.api.session.room.model.RoomSummary
import im.vector.matrix.android.api.session.room.timeline.TimelineData
import io.reactivex.Observable import io.reactivex.Observable


class RxRoom(private val room: Room) { class RxRoom(private val room: Room) {
@ -27,10 +26,6 @@ class RxRoom(private val room: Room) {
return room.roomSummary.asObservable() return room.roomSummary.asObservable()
} }


fun timeline(eventId: String? = null): Observable<TimelineData> {
return room.timeline(eventId).asObservable()
}

} }


fun Room.rx(): RxRoom { fun Room.rx(): RxRoom {

View File

@ -28,6 +28,7 @@ android {
targetSdkVersion 28 targetSdkVersion 28
versionCode 1 versionCode 1
versionName "1.0" versionName "1.0"
multiDexEnabled true
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"


resValue "string", "git_sdk_revision", "\"${gitRevision()}\"" resValue "string", "git_sdk_revision", "\"${gitRevision()}\""
@ -77,7 +78,7 @@ static def gitRevisionDate() {
dependencies { dependencies {


def arrow_version = "0.8.0" def arrow_version = "0.8.0"
def support_version = '1.1.0-alpha01' def support_version = '1.1.0-alpha03'
def moshi_version = '1.8.0' def moshi_version = '1.8.0'
def lifecycle_version = '2.0.0' def lifecycle_version = '2.0.0'
def coroutines_version = "1.0.1" def coroutines_version = "1.0.1"
@ -96,7 +97,7 @@ dependencies {
// Network // Network
implementation 'com.squareup.retrofit2:retrofit:2.4.0' implementation 'com.squareup.retrofit2:retrofit:2.4.0'
implementation 'com.squareup.retrofit2:converter-moshi:2.4.0' implementation 'com.squareup.retrofit2:converter-moshi:2.4.0'
implementation 'com.squareup.okhttp3:okhttp:3.10.0' implementation 'com.squareup.okhttp3:okhttp:3.11.0'
implementation 'com.squareup.okhttp3:logging-interceptor:3.10.0' implementation 'com.squareup.okhttp3:logging-interceptor:3.10.0'
implementation 'com.novoda:merlin:1.1.6' implementation 'com.novoda:merlin:1.1.6'
implementation "com.squareup.moshi:moshi-adapters:$moshi_version" implementation "com.squareup.moshi:moshi-adapters:$moshi_version"
@ -106,11 +107,8 @@ dependencies {
implementation 'com.github.Zhuinden:realm-monarchy:0.5.1' implementation 'com.github.Zhuinden:realm-monarchy:0.5.1'
kapt 'dk.ilios:realmfieldnameshelper:1.1.1' kapt 'dk.ilios:realmfieldnameshelper:1.1.1'


// Paging
implementation 'androidx.paging:paging-runtime:2.0.0'

// Work // Work
implementation "android.arch.work:work-runtime-ktx:1.0.0-beta02" implementation "android.arch.work:work-runtime-ktx:1.0.0"


// FP // FP
implementation "io.arrow-kt:arrow-core:$arrow_version" implementation "io.arrow-kt:arrow-core:$arrow_version"
@ -137,17 +135,17 @@ dependencies {
//testImplementation 'org.robolectric:shadows-support-v4:3.0' //testImplementation 'org.robolectric:shadows-support-v4:3.0'
testImplementation "io.mockk:mockk:1.8.13.kotlin13" testImplementation "io.mockk:mockk:1.8.13.kotlin13"
testImplementation 'org.amshove.kluent:kluent-android:1.44' testImplementation 'org.amshove.kluent:kluent-android:1.44'
testImplementation "androidx.arch.core:core-testing:$lifecycle_version"
testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines_version" testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines_version"


androidTestImplementation "org.koin:koin-test:$koin_version" androidTestImplementation "org.koin:koin-test:$koin_version"
androidTestImplementation 'androidx.test:core:1.1.0'
androidTestImplementation 'androidx.test:runner:1.1.1' androidTestImplementation 'androidx.test:runner:1.1.1'
androidTestImplementation 'androidx.test:rules:1.1.1' androidTestImplementation 'androidx.test:rules:1.1.1'
androidTestImplementation 'androidx.test.ext:junit:1.1.0'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.1.1' androidTestImplementation 'androidx.test.espresso:espresso-core:3.1.1'
androidTestImplementation 'org.amshove.kluent:kluent-android:1.44' androidTestImplementation 'org.amshove.kluent:kluent-android:1.44'
androidTestImplementation "io.mockk:mockk-android:1.8.13.kotlin13" androidTestImplementation "io.mockk:mockk-android:1.8.13.kotlin13"
androidTestImplementation "androidx.arch.core:core-testing:$lifecycle_version" androidTestImplementation "androidx.arch.core:core-testing:$lifecycle_version"
androidTestImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines_version" androidTestImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines_version"



} }

View File

@ -16,10 +16,9 @@


package im.vector.matrix.android.session.room.timeline package im.vector.matrix.android.session.room.timeline


import androidx.test.ext.junit.runners.AndroidJUnit4
import com.zhuinden.monarchy.Monarchy import com.zhuinden.monarchy.Monarchy
import im.vector.matrix.android.InstrumentedTest import im.vector.matrix.android.InstrumentedTest
import im.vector.matrix.android.api.session.events.model.Event
import im.vector.matrix.android.api.session.events.model.EventType
import im.vector.matrix.android.internal.database.helper.add import im.vector.matrix.android.internal.database.helper.add
import im.vector.matrix.android.internal.database.helper.addAll import im.vector.matrix.android.internal.database.helper.addAll
import im.vector.matrix.android.internal.database.helper.isUnlinked import im.vector.matrix.android.internal.database.helper.isUnlinked
@ -27,6 +26,9 @@ import im.vector.matrix.android.internal.database.helper.lastStateIndex
import im.vector.matrix.android.internal.database.helper.merge import im.vector.matrix.android.internal.database.helper.merge
import im.vector.matrix.android.internal.database.model.ChunkEntity import im.vector.matrix.android.internal.database.model.ChunkEntity
import im.vector.matrix.android.internal.session.room.timeline.PaginationDirection import im.vector.matrix.android.internal.session.room.timeline.PaginationDirection
import im.vector.matrix.android.session.room.timeline.RoomDataHelper.createFakeListOfEvents
import im.vector.matrix.android.session.room.timeline.RoomDataHelper.createFakeMessageEvent
import im.vector.matrix.android.session.room.timeline.RoomDataHelper.createFakeRoomMemberEvent
import io.realm.Realm import io.realm.Realm
import io.realm.RealmConfiguration import io.realm.RealmConfiguration
import io.realm.kotlin.createObject import io.realm.kotlin.createObject
@ -35,9 +37,10 @@ import org.amshove.kluent.shouldBeTrue
import org.amshove.kluent.shouldEqual import org.amshove.kluent.shouldEqual
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
import kotlin.random.Random import org.junit.runner.RunWith




@RunWith(AndroidJUnit4::class)
internal class ChunkEntityTest : InstrumentedTest { internal class ChunkEntityTest : InstrumentedTest {


private lateinit var monarchy: Monarchy private lateinit var monarchy: Monarchy
@ -54,7 +57,7 @@ internal class ChunkEntityTest : InstrumentedTest {
fun add_shouldAdd_whenNotAlreadyIncluded() { fun add_shouldAdd_whenNotAlreadyIncluded() {
monarchy.runTransactionSync { realm -> monarchy.runTransactionSync { realm ->
val chunk: ChunkEntity = realm.createObject() val chunk: ChunkEntity = realm.createObject()
val fakeEvent = createFakeEvent(false) val fakeEvent = createFakeMessageEvent()
chunk.add("roomId", fakeEvent, PaginationDirection.FORWARDS) chunk.add("roomId", fakeEvent, PaginationDirection.FORWARDS)
chunk.events.size shouldEqual 1 chunk.events.size shouldEqual 1
} }
@ -64,7 +67,7 @@ internal class ChunkEntityTest : InstrumentedTest {
fun add_shouldNotAdd_whenAlreadyIncluded() { fun add_shouldNotAdd_whenAlreadyIncluded() {
monarchy.runTransactionSync { realm -> monarchy.runTransactionSync { realm ->
val chunk: ChunkEntity = realm.createObject() val chunk: ChunkEntity = realm.createObject()
val fakeEvent = createFakeEvent(false) val fakeEvent = createFakeMessageEvent()
chunk.add("roomId", fakeEvent, PaginationDirection.FORWARDS) chunk.add("roomId", fakeEvent, PaginationDirection.FORWARDS)
chunk.add("roomId", fakeEvent, PaginationDirection.FORWARDS) chunk.add("roomId", fakeEvent, PaginationDirection.FORWARDS)
chunk.events.size shouldEqual 1 chunk.events.size shouldEqual 1
@ -75,7 +78,7 @@ internal class ChunkEntityTest : InstrumentedTest {
fun add_shouldStateIndexIncremented_whenStateEventIsAddedForward() { fun add_shouldStateIndexIncremented_whenStateEventIsAddedForward() {
monarchy.runTransactionSync { realm -> monarchy.runTransactionSync { realm ->
val chunk: ChunkEntity = realm.createObject() val chunk: ChunkEntity = realm.createObject()
val fakeEvent = createFakeEvent(true) val fakeEvent = createFakeRoomMemberEvent()
chunk.add("roomId", fakeEvent, PaginationDirection.FORWARDS) chunk.add("roomId", fakeEvent, PaginationDirection.FORWARDS)
chunk.lastStateIndex(PaginationDirection.FORWARDS) shouldEqual 1 chunk.lastStateIndex(PaginationDirection.FORWARDS) shouldEqual 1
} }
@ -85,7 +88,7 @@ internal class ChunkEntityTest : InstrumentedTest {
fun add_shouldStateIndexNotIncremented_whenNoStateEventIsAdded() { fun add_shouldStateIndexNotIncremented_whenNoStateEventIsAdded() {
monarchy.runTransactionSync { realm -> monarchy.runTransactionSync { realm ->
val chunk: ChunkEntity = realm.createObject() val chunk: ChunkEntity = realm.createObject()
val fakeEvent = createFakeEvent(false) val fakeEvent = createFakeMessageEvent()
chunk.add("roomId", fakeEvent, PaginationDirection.FORWARDS) chunk.add("roomId", fakeEvent, PaginationDirection.FORWARDS)
chunk.lastStateIndex(PaginationDirection.FORWARDS) shouldEqual 0 chunk.lastStateIndex(PaginationDirection.FORWARDS) shouldEqual 0
} }
@ -134,13 +137,13 @@ internal class ChunkEntityTest : InstrumentedTest {
val chunk2: ChunkEntity = realm.createObject() val chunk2: ChunkEntity = realm.createObject()
val eventsForChunk1 = createFakeListOfEvents(30) val eventsForChunk1 = createFakeListOfEvents(30)
val eventsForChunk2 = eventsForChunk1 + createFakeListOfEvents(10) val eventsForChunk2 = eventsForChunk1 + createFakeListOfEvents(10)
chunk1.isLast = true chunk1.isLastForward = true
chunk2.isLast = false chunk2.isLastForward = false
chunk1.addAll("roomId", eventsForChunk1, PaginationDirection.FORWARDS) chunk1.addAll("roomId", eventsForChunk1, PaginationDirection.FORWARDS)
chunk2.addAll("roomId", eventsForChunk2, PaginationDirection.BACKWARDS) chunk2.addAll("roomId", eventsForChunk2, PaginationDirection.BACKWARDS)
chunk1.merge("roomId", chunk2, PaginationDirection.BACKWARDS) chunk1.merge("roomId", chunk2, PaginationDirection.BACKWARDS)
chunk1.events.size shouldEqual 40 chunk1.events.size shouldEqual 40
chunk1.isLast.shouldBeTrue() chunk1.isLastForward.shouldBeTrue()
} }
} }


@ -196,15 +199,4 @@ internal class ChunkEntityTest : InstrumentedTest {
} }
} }



private fun createFakeListOfEvents(size: Int = 10): List<Event> {
return (0 until size).map { createFakeEvent(Random.nextBoolean()) }
}

private fun createFakeEvent(asStateEvent: Boolean = false): Event {
val eventId = Random.nextLong(System.currentTimeMillis()).toString()
val type = if (asStateEvent) EventType.STATE_ROOM_NAME else EventType.MESSAGE
return Event(type, eventId)
}

} }

View File

@ -25,7 +25,7 @@ import kotlin.random.Random


internal class FakeGetContextOfEventTask(private val tokenChunkEventPersistor: TokenChunkEventPersistor) : GetContextOfEventTask { internal class FakeGetContextOfEventTask(private val tokenChunkEventPersistor: TokenChunkEventPersistor) : GetContextOfEventTask {


override fun execute(params: GetContextOfEventTask.Params): Try<TokenChunkEvent> { override fun execute(params: GetContextOfEventTask.Params): Try<TokenChunkEventPersistor.Result> {
val fakeEvents = RoomDataHelper.createFakeListOfEvents(30) val fakeEvents = RoomDataHelper.createFakeListOfEvents(30)
val tokenChunkEvent = FakeTokenChunkEvent( val tokenChunkEvent = FakeTokenChunkEvent(
Random.nextLong(System.currentTimeMillis()).toString(), Random.nextLong(System.currentTimeMillis()).toString(),
@ -33,7 +33,6 @@ internal class FakeGetContextOfEventTask(private val tokenChunkEventPersistor: T
fakeEvents fakeEvents
) )
return tokenChunkEventPersistor.insertInDb(tokenChunkEvent, params.roomId, PaginationDirection.BACKWARDS) return tokenChunkEventPersistor.insertInDb(tokenChunkEvent, params.roomId, PaginationDirection.BACKWARDS)
.map { tokenChunkEvent }
} }





View File

@ -23,7 +23,7 @@ import kotlin.random.Random


internal class FakePaginationTask(private val tokenChunkEventPersistor: TokenChunkEventPersistor) : PaginationTask { internal class FakePaginationTask(private val tokenChunkEventPersistor: TokenChunkEventPersistor) : PaginationTask {


override fun execute(params: PaginationTask.Params): Try<Boolean> { override fun execute(params: PaginationTask.Params): Try<TokenChunkEventPersistor.Result> {
val fakeEvents = RoomDataHelper.createFakeListOfEvents(30) val fakeEvents = RoomDataHelper.createFakeListOfEvents(30)
val tokenChunkEvent = FakeTokenChunkEvent(params.from, Random.nextLong(System.currentTimeMillis()).toString(), fakeEvents) val tokenChunkEvent = FakeTokenChunkEvent(params.from, Random.nextLong(System.currentTimeMillis()).toString(), fakeEvents)
return tokenChunkEventPersistor.insertInDb(tokenChunkEvent, params.roomId, params.direction) return tokenChunkEventPersistor.insertInDb(tokenChunkEvent, params.roomId, params.direction)

View File

@ -17,9 +17,15 @@
package im.vector.matrix.android.session.room.timeline package im.vector.matrix.android.session.room.timeline


import com.zhuinden.monarchy.Monarchy import com.zhuinden.monarchy.Monarchy
import im.vector.matrix.android.api.session.events.model.Content
import im.vector.matrix.android.api.session.events.model.Event import im.vector.matrix.android.api.session.events.model.Event
import im.vector.matrix.android.api.session.events.model.EventType import im.vector.matrix.android.api.session.events.model.EventType
import im.vector.matrix.android.api.session.events.model.toContent
import im.vector.matrix.android.api.session.room.model.Membership
import im.vector.matrix.android.api.session.room.model.MyMembership import im.vector.matrix.android.api.session.room.model.MyMembership
import im.vector.matrix.android.api.session.room.model.RoomMember
import im.vector.matrix.android.api.session.room.model.message.MessageTextContent
import im.vector.matrix.android.api.session.room.model.message.MessageType
import im.vector.matrix.android.internal.database.helper.addAll 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.addOrUpdate
import im.vector.matrix.android.internal.database.model.ChunkEntity import im.vector.matrix.android.internal.database.model.ChunkEntity
@ -30,27 +36,56 @@ import kotlin.random.Random


object RoomDataHelper { object RoomDataHelper {


private const val FAKE_TEST_SENDER = "@sender:test.org"
private val EVENT_FACTORIES = hashMapOf(
0 to { createFakeMessageEvent() },
1 to { createFakeRoomMemberEvent() }
)

fun createFakeListOfEvents(size: Int = 10): List<Event> { fun createFakeListOfEvents(size: Int = 10): List<Event> {
return (0 until size).map { createFakeEvent(Random.nextBoolean()) } return (0 until size).mapNotNull {
val nextInt = Random.nextInt(EVENT_FACTORIES.size)
EVENT_FACTORIES[nextInt]?.invoke()
}
} }


fun createFakeEvent(asStateEvent: Boolean = false): Event { fun createFakeEvent(type: String,
val eventId = Random.nextLong(System.currentTimeMillis()).toString() content: Content? = null,
val type = if (asStateEvent) EventType.STATE_ROOM_NAME else EventType.MESSAGE prevContent: Content? = null,
return Event(type, eventId) sender: String = FAKE_TEST_SENDER,
stateKey: String = FAKE_TEST_SENDER
): Event {
return Event(
type = type,
eventId = Random.nextLong().toString(),
content = content,
prevContent = prevContent,
sender = sender,
stateKey = stateKey
)
}

fun createFakeMessageEvent(): Event {
val message = MessageTextContent(MessageType.MSGTYPE_TEXT, "Fake message #${Random.nextLong()}").toContent()
return createFakeEvent(EventType.MESSAGE, message)
}

fun createFakeRoomMemberEvent(): Event {
val roomMember = RoomMember(Membership.JOIN, "Fake name #${Random.nextLong()}").toContent()
return createFakeEvent(EventType.STATE_ROOM_MEMBER, roomMember)
} }


fun fakeInitialSync(monarchy: Monarchy, roomId: String) { fun fakeInitialSync(monarchy: Monarchy, roomId: String) {
monarchy.runTransactionSync { realm -> monarchy.runTransactionSync { realm ->
val roomEntity = realm.createObject<RoomEntity>(roomId) val roomEntity = realm.createObject<RoomEntity>(roomId)
roomEntity.membership = MyMembership.JOINED roomEntity.membership = MyMembership.JOINED
val eventList = createFakeListOfEvents(30) val eventList = createFakeListOfEvents(10)
val chunkEntity = realm.createObject<ChunkEntity>().apply { val chunkEntity = realm.createObject<ChunkEntity>().apply {
nextToken = null nextToken = null
prevToken = Random.nextLong(System.currentTimeMillis()).toString() prevToken = Random.nextLong(System.currentTimeMillis()).toString()
isLast = true isLastForward = true
} }
chunkEntity.addAll("roomId", eventList, PaginationDirection.FORWARDS) chunkEntity.addAll(roomId, eventList, PaginationDirection.FORWARDS)
roomEntity.addOrUpdate(chunkEntity) roomEntity.addOrUpdate(chunkEntity)
} }
} }

View File

@ -16,61 +16,75 @@


package im.vector.matrix.android.session.room.timeline package im.vector.matrix.android.session.room.timeline


import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import androidx.test.annotation.UiThreadTest
import com.zhuinden.monarchy.Monarchy import com.zhuinden.monarchy.Monarchy
import im.vector.matrix.android.InstrumentedTest import im.vector.matrix.android.InstrumentedTest
import im.vector.matrix.android.LiveDataTestObserver import im.vector.matrix.android.api.session.room.timeline.Timeline
import im.vector.matrix.android.MainThreadExecutor import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
import im.vector.matrix.android.internal.session.room.members.RoomMemberExtractor 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.DefaultTimeline
import im.vector.matrix.android.internal.session.room.timeline.TimelineBoundaryCallback import im.vector.matrix.android.internal.session.room.timeline.TimelineEventFactory
import im.vector.matrix.android.internal.session.room.timeline.TokenChunkEventPersistor import im.vector.matrix.android.internal.session.room.timeline.TokenChunkEventPersistor
import im.vector.matrix.android.internal.task.TaskExecutor import im.vector.matrix.android.internal.task.TaskExecutor
import im.vector.matrix.android.internal.util.PagingRequestHelper
import im.vector.matrix.android.testCoroutineDispatchers import im.vector.matrix.android.testCoroutineDispatchers
import io.realm.Realm import io.realm.Realm
import io.realm.RealmConfiguration import io.realm.RealmConfiguration
import org.amshove.kluent.shouldEqual import org.amshove.kluent.shouldEqual
import org.junit.Before import org.junit.Before
import org.junit.Rule
import org.junit.Test import org.junit.Test
import timber.log.Timber
import java.util.concurrent.CountDownLatch


internal class TimelineHolderTest : InstrumentedTest { internal class TimelineTest : InstrumentedTest {

companion object {
private const val ROOM_ID = "roomId"
}


@get:Rule val testRule = InstantTaskExecutorRule()
private lateinit var monarchy: Monarchy private lateinit var monarchy: Monarchy


@Before @Before
fun setup() { fun setup() {
Timber.plant(Timber.DebugTree())
Realm.init(context()) Realm.init(context())
val testConfiguration = RealmConfiguration.Builder().name("test-realm").build() val testConfiguration = RealmConfiguration.Builder().name("test-realm").build()
Realm.deleteRealm(testConfiguration) Realm.deleteRealm(testConfiguration)
monarchy = Monarchy.Builder().setRealmConfiguration(testConfiguration).build() monarchy = Monarchy.Builder().setRealmConfiguration(testConfiguration).build()
RoomDataHelper.fakeInitialSync(monarchy, ROOM_ID)
} }


@Test private fun createTimeline(initialEventId: String? = null): Timeline {
@UiThreadTest
fun backPaginate_shouldLoadMoreEvents_whenLoadAroundIsCalled() {
val roomId = "roomId"
val taskExecutor = TaskExecutor(testCoroutineDispatchers) val taskExecutor = TaskExecutor(testCoroutineDispatchers)
val tokenChunkEventPersistor = TokenChunkEventPersistor(monarchy) val tokenChunkEventPersistor = TokenChunkEventPersistor(monarchy)
val paginationTask = FakePaginationTask(tokenChunkEventPersistor) val paginationTask = FakePaginationTask(tokenChunkEventPersistor)
val getContextOfEventTask = FakeGetContextOfEventTask(tokenChunkEventPersistor) val getContextOfEventTask = FakeGetContextOfEventTask(tokenChunkEventPersistor)
val boundaryCallback = TimelineBoundaryCallback(roomId, taskExecutor, paginationTask, monarchy, PagingRequestHelper(MainThreadExecutor())) val roomMemberExtractor = RoomMemberExtractor(ROOM_ID)
val timelineEventFactory = TimelineEventFactory(roomMemberExtractor)
return DefaultTimeline(ROOM_ID, initialEventId, monarchy.realmConfiguration, taskExecutor, getContextOfEventTask, timelineEventFactory, paginationTask, null)
}


RoomDataHelper.fakeInitialSync(monarchy, roomId) @Test
val timelineHolder = DefaultTimelineService(roomId, monarchy, taskExecutor, boundaryCallback, getContextOfEventTask, RoomMemberExtractor(monarchy, roomId)) fun backPaginate_shouldLoadMoreEvents_whenPaginateIsCalled() {
val timelineObserver = LiveDataTestObserver.test(timelineHolder.timeline()) val timeline = createTimeline()
timelineObserver.awaitNextValue().assertHasValue() timeline.start()
var timelineData = timelineObserver.value() val paginationCount = 30
timelineData.events.size shouldEqual 30 var initialLoad = 0
(0 until timelineData.events.size).map { val latch = CountDownLatch(2)
timelineData.events.loadAround(it) var timelineEvents: List<TimelineEvent> = emptyList()
timeline.listener = object : Timeline.Listener {
override fun onUpdated(snapshot: List<TimelineEvent>) {
if (snapshot.isNotEmpty()) {
if (initialLoad == 0) {
initialLoad = snapshot.size
}
timelineEvents = snapshot
latch.countDown()
timeline.paginate(Timeline.Direction.BACKWARDS, paginationCount)
}
}
} }
timelineObserver.awaitNextValue().assertHasValue() latch.await()
timelineData = timelineObserver.value() timelineEvents.size shouldEqual initialLoad + paginationCount
timelineData.events.size shouldEqual 60 timeline.dispose()
} }




View File

@ -50,11 +50,9 @@ class Matrix private constructor(context: Context) : MatrixKoinComponent {
val authModule = AuthModule().definition val authModule = AuthModule().definition
MatrixKoinHolder.instance.loadModules(listOf(matrixModule, networkModule, authModule)) MatrixKoinHolder.instance.loadModules(listOf(matrixModule, networkModule, authModule))
ProcessLifecycleOwner.get().lifecycle.addObserver(backgroundDetectionObserver) ProcessLifecycleOwner.get().lifecycle.addObserver(backgroundDetectionObserver)
val lastActiveSession = authenticator.getLastActiveSession() authenticator.getLastActiveSession()?.also {
if (lastActiveSession != null) { currentSession = it
currentSession = lastActiveSession.apply { it.open()
open()
}
} }
} }



View File

@ -35,6 +35,18 @@ inline fun <reified T> Content?.toModel(): T? {
} }
} }


/**
* This methods is a facility method to map a model to a json Content
*/
@Suppress("UNCHECKED_CAST")
inline fun <reified T> T?.toContent(): Content? {
return this?.let {
val moshi = MoshiProvider.providesMoshi()
val moshiAdapter = moshi.adapter(T::class.java)
return moshiAdapter.toJsonValue(it) as Content
}
}

/** /**
* Generic event class with all possible fields for events. * Generic event class with all possible fields for events.
* The content and prevContent json fields can easily be mapped to a model with [toModel] method. * The content and prevContent json fields can easily be mapped to a model with [toModel] method.

View File

@ -0,0 +1,84 @@
/*
*
* * 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.api.session.room.timeline

/**
* A Timeline instance represents a contiguous sequence of events in a room.
* <p>
* There are two kinds of timeline:
* <p>
* - live timelines: they process live events from the sync. You can paginate
* backwards but not forwards.
* <p>
* - past timelines: they start in the past from an `initialEventId`. You can paginate
* backwards and forwards.
*
*/
interface Timeline {

var listener: Timeline.Listener?

/**
* 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<TimelineEvent>)
}

/**
* This is used to paginate in one or another direction.
*/
enum class Direction {
/**
* It represents future events.
*/
FORWARDS,
/**
* It represents past events.
*/
BACKWARDS
}

}

View File

@ -27,6 +27,7 @@ import im.vector.matrix.android.api.session.room.model.RoomMember
data class TimelineEvent( data class TimelineEvent(
val root: Event, val root: Event,
val localId: String, val localId: String,
val displayIndex: Int,
val roomMember: RoomMember? val roomMember: RoomMember?
) { ) {



View File

@ -16,20 +16,18 @@


package im.vector.matrix.android.api.session.room.timeline package im.vector.matrix.android.api.session.room.timeline


import androidx.lifecycle.LiveData

/** /**
* This interface defines methods to interact with the timeline. It's implemented at the room level. * This interface defines methods to interact with the timeline. It's implemented at the room level.
*/ */
interface TimelineService { interface TimelineService {


/** /**
* This is the main method of the service. It allows to listen for live [TimelineData]. * Instantiate a [Timeline] with an optional initial eventId, to be used with permalink.
* It's automatically refreshed as soon as timeline data gets updated, through sync or pagination. * You can filter the type you want to grab with the allowedTypes param.
* * @param eventId the optional initial eventId.
* @param eventId: an optional eventId to start loading timeline around. * @param allowedTypes the optional filter types
* @return the [LiveData] of [TimelineData] * @return the instantiated timeline
*/ */
fun timeline(eventId: String? = null): LiveData<TimelineData> fun createTimeline(eventId: String?, allowedTypes: List<String>? = null): Timeline


} }

View File

@ -0,0 +1,35 @@
/*
* 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.api.util

class CancelableBag : Cancelable {

private val cancelableList = ArrayList<Cancelable>()

fun add(cancelable: Cancelable) {
cancelableList.add(cancelable)
}

override fun cancel() {
cancelableList.forEach { it.cancel() }
}

}

fun Cancelable.addTo(cancelables: CancelableBag) {
cancelables.add(this)
}

View File

@ -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<T : RealmModel>(private val realmConfiguration: RealmConfiguration,
private val query: (Realm) -> RealmQuery<T>) : LiveData<RealmResults<T>>() {

private val listener = RealmChangeListener<RealmResults<T>> { results ->
value = results
}

private var realm: Realm? = null
private var results: RealmResults<T>? = 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
}
}

View File

@ -26,6 +26,7 @@ import java.util.concurrent.atomic.AtomicReference
internal interface LiveEntityObserver { internal interface LiveEntityObserver {
fun start() fun start()
fun dispose() fun dispose()
fun isStarted(): Boolean
} }


internal abstract class RealmLiveEntityObserver<T : RealmObject>(protected val monarchy: Monarchy) internal abstract class RealmLiveEntityObserver<T : RealmObject>(protected val monarchy: Monarchy)
@ -55,7 +56,9 @@ internal abstract class RealmLiveEntityObserver<T : RealmObject>(protected val m
} }
} }


// PRIVATE override fun isStarted(): Boolean {
return isStarted.get()
}


private fun onChanged(realmResults: RealmResults<T>, changeSet: OrderedCollectionChangeSet) { private fun onChanged(realmResults: RealmResults<T>, changeSet: OrderedCollectionChangeSet) {
val insertionIndexes = changeSet.insertions val insertionIndexes = changeSet.insertions
@ -64,9 +67,9 @@ internal abstract class RealmLiveEntityObserver<T : RealmObject>(protected val m
val inserted = realmResults.filterIndexed { index, _ -> insertionIndexes.contains(index) } val inserted = realmResults.filterIndexed { index, _ -> insertionIndexes.contains(index) }
val updated = realmResults.filterIndexed { index, _ -> updateIndexes.contains(index) } val updated = realmResults.filterIndexed { index, _ -> updateIndexes.contains(index) }
val deleted = realmResults.filterIndexed { index, _ -> deletionIndexes.contains(index) } val deleted = realmResults.filterIndexed { index, _ -> deletionIndexes.contains(index) }
process(inserted, updated, deleted) processChanges(inserted, updated, deleted)
} }


abstract fun process(inserted: List<T>, updated: List<T>, deleted: List<T>) protected abstract fun processChanges(inserted: List<T>, updated: List<T>, deleted: List<T>)


} }

View File

@ -20,7 +20,6 @@ import im.vector.matrix.android.api.session.events.model.Event
import im.vector.matrix.android.api.session.events.model.EventType import im.vector.matrix.android.api.session.events.model.EventType
import im.vector.matrix.android.internal.database.mapper.asDomain import im.vector.matrix.android.internal.database.mapper.asDomain
import im.vector.matrix.android.internal.database.mapper.toEntity import im.vector.matrix.android.internal.database.mapper.toEntity
import im.vector.matrix.android.internal.database.mapper.updateWith
import im.vector.matrix.android.internal.database.model.ChunkEntity import im.vector.matrix.android.internal.database.model.ChunkEntity
import im.vector.matrix.android.internal.database.model.EventEntity 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.model.EventEntityFields
@ -29,18 +28,18 @@ import im.vector.matrix.android.internal.extensions.assertIsManaged
import im.vector.matrix.android.internal.session.room.timeline.PaginationDirection import im.vector.matrix.android.internal.session.room.timeline.PaginationDirection
import io.realm.Sort import io.realm.Sort


internal fun ChunkEntity.deleteOnCascade() {
assertIsManaged()
this.events.deleteAllFromRealm()
this.deleteFromRealm()
}

// By default if a chunk is empty we consider it unlinked // By default if a chunk is empty we consider it unlinked
internal fun ChunkEntity.isUnlinked(): Boolean { internal fun ChunkEntity.isUnlinked(): Boolean {
assertIsManaged() assertIsManaged()
return events.where().equalTo(EventEntityFields.IS_UNLINKED, false).findAll().isEmpty() return events.where().equalTo(EventEntityFields.IS_UNLINKED, false).findAll().isEmpty()
} }


internal fun ChunkEntity.deleteOnCascade() {
assertIsManaged()
this.events.deleteAllFromRealm()
this.deleteFromRealm()
}

internal fun ChunkEntity.merge(roomId: String, internal fun ChunkEntity.merge(roomId: String,
chunkToMerge: ChunkEntity, chunkToMerge: ChunkEntity,
direction: PaginationDirection) { direction: PaginationDirection) {
@ -55,11 +54,16 @@ internal fun ChunkEntity.merge(roomId: String,
val eventsToMerge: List<EventEntity> val eventsToMerge: List<EventEntity>
if (direction == PaginationDirection.FORWARDS) { if (direction == PaginationDirection.FORWARDS) {
this.nextToken = chunkToMerge.nextToken this.nextToken = chunkToMerge.nextToken
this.isLast = chunkToMerge.isLast this.isLastForward = chunkToMerge.isLastForward
eventsToMerge = chunkToMerge.events.reversed() this.forwardsStateIndex = chunkToMerge.forwardsStateIndex
this.forwardsDisplayIndex = chunkToMerge.forwardsDisplayIndex
eventsToMerge = chunkToMerge.events.sort(EventEntityFields.DISPLAY_INDEX, Sort.ASCENDING)
} else { } else {
this.prevToken = chunkToMerge.prevToken this.prevToken = chunkToMerge.prevToken
eventsToMerge = chunkToMerge.events this.isLastBackward = chunkToMerge.isLastBackward
this.backwardsStateIndex = chunkToMerge.backwardsStateIndex
this.backwardsDisplayIndex = chunkToMerge.backwardsDisplayIndex
eventsToMerge = chunkToMerge.events.sort(EventEntityFields.DISPLAY_INDEX, Sort.DESCENDING)
} }
eventsToMerge.forEach { eventsToMerge.forEach {
add(roomId, it.asDomain(), direction, isUnlinked = isUnlinked) add(roomId, it.asDomain(), direction, isUnlinked = isUnlinked)
@ -70,7 +74,7 @@ internal fun ChunkEntity.addAll(roomId: String,
events: List<Event>, events: List<Event>,
direction: PaginationDirection, direction: PaginationDirection,
stateIndexOffset: Int = 0, stateIndexOffset: Int = 0,
// Set to true for Event retrieved from a Permalink (i.e. not linked to live Chunk) // Set to true for Event retrieved from a Permalink (i.e. not linked to live Chunk)
isUnlinked: Boolean = false) { isUnlinked: Boolean = false) {
assertIsManaged() assertIsManaged()
events.forEach { event -> events.forEach { event ->
@ -78,10 +82,6 @@ internal fun ChunkEntity.addAll(roomId: String,
} }
} }


internal fun ChunkEntity.updateDisplayIndexes() {
events.forEachIndexed { index, eventEntity -> eventEntity.displayIndex = index }
}

internal fun ChunkEntity.add(roomId: String, internal fun ChunkEntity.add(roomId: String,
event: Event, event: Event,
direction: PaginationDirection, direction: PaginationDirection,
@ -92,24 +92,44 @@ internal fun ChunkEntity.add(roomId: String,
if (event.eventId.isNullOrEmpty() || events.fastContains(event.eventId)) { if (event.eventId.isNullOrEmpty() || events.fastContains(event.eventId)) {
return return
} }
var currentDisplayIndex = lastDisplayIndex(direction, 0)
if (direction == PaginationDirection.FORWARDS) {
currentDisplayIndex += 1
forwardsDisplayIndex = currentDisplayIndex
} else {
currentDisplayIndex -= 1
backwardsDisplayIndex = currentDisplayIndex
}
var currentStateIndex = lastStateIndex(direction, defaultValue = stateIndexOffset) var currentStateIndex = lastStateIndex(direction, defaultValue = stateIndexOffset)
if (direction == PaginationDirection.FORWARDS && EventType.isStateEvent(event.type)) { if (direction == PaginationDirection.FORWARDS && EventType.isStateEvent(event.type)) {
currentStateIndex += 1 currentStateIndex += 1
forwardsStateIndex = currentStateIndex
} else if (direction == PaginationDirection.BACKWARDS && events.isNotEmpty()) { } else if (direction == PaginationDirection.BACKWARDS && events.isNotEmpty()) {
val lastEventType = events.last()?.type ?: "" val lastEventType = events.last()?.type ?: ""
if (EventType.isStateEvent(lastEventType)) { if (EventType.isStateEvent(lastEventType)) {
currentStateIndex -= 1 currentStateIndex -= 1
backwardsStateIndex = currentStateIndex
} }
} }
val eventEntity = event.toEntity(roomId) val eventEntity = event.toEntity(roomId).apply {
eventEntity.updateWith(currentStateIndex, isUnlinked) this.stateIndex = currentStateIndex
val position = if (direction == PaginationDirection.FORWARDS) 0 else this.events.size this.isUnlinked = isUnlinked
events.add(position, eventEntity) this.displayIndex = currentDisplayIndex
}
// We are not using the order of the list, but will be sorting with displayIndex field
events.add(eventEntity)
}

internal fun ChunkEntity.lastDisplayIndex(direction: PaginationDirection, defaultValue: Int = 0): Int {
return when (direction) {
PaginationDirection.FORWARDS -> forwardsDisplayIndex
PaginationDirection.BACKWARDS -> backwardsDisplayIndex
} ?: defaultValue
} }


internal fun ChunkEntity.lastStateIndex(direction: PaginationDirection, defaultValue: Int = 0): Int { internal fun ChunkEntity.lastStateIndex(direction: PaginationDirection, defaultValue: Int = 0): Int {
return when (direction) { return when (direction) {
PaginationDirection.FORWARDS -> events.where().sort(EventEntityFields.STATE_INDEX, Sort.DESCENDING).findFirst()?.stateIndex PaginationDirection.FORWARDS -> forwardsStateIndex
PaginationDirection.BACKWARDS -> events.where().sort(EventEntityFields.STATE_INDEX, Sort.ASCENDING).findFirst()?.stateIndex PaginationDirection.BACKWARDS -> backwardsStateIndex
} ?: defaultValue } ?: defaultValue
} }

View File

@ -18,7 +18,6 @@ package im.vector.matrix.android.internal.database.helper


import im.vector.matrix.android.api.session.events.model.Event import im.vector.matrix.android.api.session.events.model.Event
import im.vector.matrix.android.internal.database.mapper.toEntity import im.vector.matrix.android.internal.database.mapper.toEntity
import im.vector.matrix.android.internal.database.mapper.updateWith
import im.vector.matrix.android.internal.database.model.ChunkEntity 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.model.RoomEntity
import im.vector.matrix.android.internal.database.query.fastContains import im.vector.matrix.android.internal.database.query.fastContains
@ -31,7 +30,6 @@ internal fun RoomEntity.deleteOnCascade(chunkEntity: ChunkEntity) {
} }


internal fun RoomEntity.addOrUpdate(chunkEntity: ChunkEntity) { internal fun RoomEntity.addOrUpdate(chunkEntity: ChunkEntity) {
chunkEntity.updateDisplayIndexes()
if (!chunks.contains(chunkEntity)) { if (!chunks.contains(chunkEntity)) {
chunks.add(chunkEntity) chunks.add(chunkEntity)
} }
@ -47,8 +45,10 @@ internal fun RoomEntity.addStateEvents(stateEvents: List<Event>,
if (event.eventId == null || (filterDuplicates && fastContains(event.eventId))) { if (event.eventId == null || (filterDuplicates && fastContains(event.eventId))) {
return@forEach return@forEach
} }
val eventEntity = event.toEntity(roomId) val eventEntity = event.toEntity(roomId).apply {
eventEntity.updateWith(stateIndex, isUnlinked) this.stateIndex = stateIndex
this.isUnlinked = isUnlinked
}
untimelinedStateEvents.add(eventEntity) untimelinedStateEvents.add(eventEntity)
} }
} }

View File

@ -57,11 +57,6 @@ internal object EventMapper {


} }


internal fun EventEntity.updateWith(stateIndex: Int, isUnlinked: Boolean) {
this.stateIndex = stateIndex
this.isUnlinked = isUnlinked
}

internal fun EventEntity.asDomain(): Event { internal fun EventEntity.asDomain(): Event {
return EventMapper.map(this) return EventMapper.map(this)
} }

View File

@ -19,12 +19,18 @@ package im.vector.matrix.android.internal.database.model
import io.realm.RealmList import io.realm.RealmList
import io.realm.RealmObject import io.realm.RealmObject
import io.realm.RealmResults import io.realm.RealmResults
import io.realm.annotations.Index
import io.realm.annotations.LinkingObjects import io.realm.annotations.LinkingObjects


internal open class ChunkEntity(var prevToken: String? = null, internal open class ChunkEntity(@Index var prevToken: String? = null,
var nextToken: String? = null, @Index var nextToken: String? = null,
var isLast: Boolean = false, var events: RealmList<EventEntity> = RealmList(),
var events: RealmList<EventEntity> = RealmList() @Index var isLastForward: Boolean = false,
@Index var isLastBackward: Boolean = false,
var backwardsDisplayIndex: Int? = null,
var forwardsDisplayIndex: Int? = null,
var backwardsStateIndex: Int? = null,
var forwardsStateIndex: Int? = null
) : RealmObject() { ) : RealmObject() {


@LinkingObjects("chunks") @LinkingObjects("chunks")

View File

@ -18,24 +18,25 @@ package im.vector.matrix.android.internal.database.model


import io.realm.RealmObject import io.realm.RealmObject
import io.realm.RealmResults import io.realm.RealmResults
import io.realm.annotations.Index
import io.realm.annotations.LinkingObjects import io.realm.annotations.LinkingObjects
import io.realm.annotations.PrimaryKey import io.realm.annotations.PrimaryKey
import java.util.* import java.util.*


internal open class EventEntity(@PrimaryKey var localId: String = UUID.randomUUID().toString(), internal open class EventEntity(@PrimaryKey var localId: String = UUID.randomUUID().toString(),
var eventId: String = "", @Index var eventId: String = "",
var roomId: String = "", var roomId: String = "",
var type: String = "", @Index var type: String = "",
var content: String? = null, var content: String? = null,
var prevContent: String? = null, var prevContent: String? = null,
var stateKey: String? = null, @Index var stateKey: String? = null,
var originServerTs: Long? = null, var originServerTs: Long? = null,
var sender: String? = null, @Index var sender: String? = null,
var age: Long? = 0, var age: Long? = 0,
var redacts: String? = null, var redacts: String? = null,
var stateIndex: Int = 0, @Index var stateIndex: Int = 0,
var displayIndex: Int = 0, @Index var displayIndex: Int = 0,
var isUnlinked: Boolean = false @Index var isUnlinked: Boolean = false
) : RealmObject() { ) : RealmObject() {


enum class LinkFilterMode { enum class LinkFilterMode {

View File

@ -43,7 +43,7 @@ internal fun ChunkEntity.Companion.find(realm: Realm, roomId: String, prevToken:


internal fun ChunkEntity.Companion.findLastLiveChunkFromRoom(realm: Realm, roomId: String): ChunkEntity? { internal fun ChunkEntity.Companion.findLastLiveChunkFromRoom(realm: Realm, roomId: String): ChunkEntity? {
return where(realm, roomId) return where(realm, roomId)
.equalTo(ChunkEntityFields.IS_LAST, true) .equalTo(ChunkEntityFields.IS_LAST_FORWARD, true)
.findFirst() .findFirst()
} }



View File

@ -59,7 +59,7 @@ internal fun EventEntity.Companion.latestEvent(realm: Realm,
query?.not()?.`in`(EventEntityFields.TYPE, excludedTypes.toTypedArray()) query?.not()?.`in`(EventEntityFields.TYPE, excludedTypes.toTypedArray())
} }
return query return query
?.sort(EventEntityFields.DISPLAY_INDEX) ?.sort(EventEntityFields.DISPLAY_INDEX, Sort.DESCENDING)
?.findFirst() ?.findFirst()
} }


@ -77,7 +77,7 @@ internal fun RealmQuery<EventEntity>.next(from: Int? = null, strict: Boolean = t
.findFirst() .findFirst()
} }


internal fun RealmQuery<EventEntity>.last(since: Int? = null, strict: Boolean = false): EventEntity? { internal fun RealmQuery<EventEntity>.prev(since: Int? = null, strict: Boolean = false): EventEntity? {
if (since != null) { if (since != null) {
if (strict) { if (strict) {
this.lessThan(EventEntityFields.STATE_INDEX, since) this.lessThan(EventEntityFields.STATE_INDEX, since)

View File

@ -35,7 +35,6 @@ import im.vector.matrix.android.internal.session.room.members.RoomMemberDisplayN
import im.vector.matrix.android.internal.session.room.prune.EventsPruner import im.vector.matrix.android.internal.session.room.prune.EventsPruner
import im.vector.matrix.android.internal.session.user.DefaultUserService import im.vector.matrix.android.internal.session.user.DefaultUserService
import im.vector.matrix.android.internal.session.user.UserEntityUpdater import im.vector.matrix.android.internal.session.user.UserEntityUpdater
import im.vector.matrix.android.internal.session.user.UserModule
import im.vector.matrix.android.internal.util.md5 import im.vector.matrix.android.internal.util.md5
import io.realm.RealmConfiguration import io.realm.RealmConfiguration
import org.koin.dsl.module.module import org.koin.dsl.module.module
@ -84,13 +83,17 @@ internal class SessionModule(private val sessionParams: SessionParams) {
} }


scope(DefaultSession.SCOPE) { scope(DefaultSession.SCOPE) {
RoomDisplayNameResolver(get(), get(), sessionParams.credentials) RoomDisplayNameResolver(get(), get(), get(), sessionParams.credentials)
} }


scope(DefaultSession.SCOPE) { scope(DefaultSession.SCOPE) {
RoomAvatarResolver(get(), sessionParams.credentials) RoomAvatarResolver(get(), sessionParams.credentials)
} }


scope(DefaultSession.SCOPE) {
RoomSummaryUpdater(get(), get(), get())
}

scope(DefaultSession.SCOPE) { scope(DefaultSession.SCOPE) {
DefaultRoomService(get(), get()) as RoomService DefaultRoomService(get(), get()) as RoomService
} }
@ -112,11 +115,10 @@ internal class SessionModule(private val sessionParams: SessionParams) {
} }


scope(DefaultSession.SCOPE) { scope(DefaultSession.SCOPE) {
val roomSummaryUpdater = RoomSummaryUpdater(get(), get(), get(), get(), sessionParams.credentials)
val groupSummaryUpdater = GroupSummaryUpdater(get()) val groupSummaryUpdater = GroupSummaryUpdater(get())
val eventsPruner = EventsPruner(get()) val eventsPruner = EventsPruner(get())
val userEntityUpdater = UserEntityUpdater(get(), get(), get()) val userEntityUpdater = UserEntityUpdater(get(), get(), get())
listOf<LiveEntityObserver>(roomSummaryUpdater, groupSummaryUpdater, eventsPruner, userEntityUpdater) listOf<LiveEntityObserver>(groupSummaryUpdater, eventsPruner, userEntityUpdater)
} }





View File

@ -23,6 +23,7 @@ import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager import androidx.work.WorkManager
import com.zhuinden.monarchy.Monarchy import com.zhuinden.monarchy.Monarchy
import im.vector.matrix.android.internal.database.RealmLiveEntityObserver import im.vector.matrix.android.internal.database.RealmLiveEntityObserver
import im.vector.matrix.android.internal.database.model.EventEntity
import im.vector.matrix.android.internal.database.model.GroupEntity import im.vector.matrix.android.internal.database.model.GroupEntity
import im.vector.matrix.android.internal.database.query.where import im.vector.matrix.android.internal.database.query.where
import im.vector.matrix.android.internal.util.WorkerParamsFactory import im.vector.matrix.android.internal.util.WorkerParamsFactory
@ -38,7 +39,7 @@ internal class GroupSummaryUpdater(monarchy: Monarchy
.setRequiredNetworkType(NetworkType.CONNECTED) .setRequiredNetworkType(NetworkType.CONNECTED)
.build() .build()


override fun process(inserted: List<GroupEntity>, updated: List<GroupEntity>, deleted: List<GroupEntity>) { override fun processChanges(inserted: List<GroupEntity>, updated: List<GroupEntity>, deleted: List<GroupEntity>) {
val newGroupIds = inserted.map { it.groupId } val newGroupIds = inserted.map { it.groupId }
val getGroupDataWorkerParams = GetGroupDataWorker.Params(newGroupIds) val getGroupDataWorkerParams = GetGroupDataWorker.Params(newGroupIds)
val workData = WorkerParamsFactory.toData(getGroupDataWorkerParams) val workData = WorkerParamsFactory.toData(getGroupDataWorkerParams)

View File

@ -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.send.SendService
import im.vector.matrix.android.api.session.room.timeline.TimelineService import im.vector.matrix.android.api.session.room.timeline.TimelineService
import im.vector.matrix.android.api.util.Cancelable 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.mapper.asDomain
import im.vector.matrix.android.internal.database.model.RoomSummaryEntity import im.vector.matrix.android.internal.database.model.RoomSummaryEntity
import im.vector.matrix.android.internal.database.model.RoomSummaryEntityFields import im.vector.matrix.android.internal.database.model.RoomSummaryEntityFields
@ -45,18 +46,16 @@ internal class DefaultRoom(




) : Room, ) : Room,
TimelineService by timelineService, TimelineService by timelineService,
SendService by sendService, SendService by sendService,
ReadService by readService { ReadService by readService {


override val roomSummary: LiveData<RoomSummary> by lazy { override val roomSummary: LiveData<RoomSummary> by lazy {
val liveData = monarchy val liveRealmData = RealmLiveData<RoomSummaryEntity>(monarchy.realmConfiguration) { realm ->
.findAllMappedWithChanges( RoomSummaryEntity.where(realm, roomId).isNotEmpty(RoomSummaryEntityFields.DISPLAY_NAME)
{ realm -> RoomSummaryEntity.where(realm, roomId).isNotEmpty(RoomSummaryEntityFields.DISPLAY_NAME) }, }
{ from -> from.asDomain() }) Transformations.map(liveRealmData) { results ->

results.map { it.asDomain() }.first()
Transformations.map(liveData) {
it.first()
} }
} }



View File

@ -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.mapper.asDomain
import im.vector.matrix.android.internal.database.model.EventEntity 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.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.database.query.where
import im.vector.matrix.android.internal.session.room.members.RoomMembers 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 var res: String? = null
monarchy.doWithRealm { realm -> monarchy.doWithRealm { realm ->
val roomEntity = RoomEntity.where(realm, roomId).findFirst() 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<RoomAvatarContent>()?.avatarUrl res = roomName?.content.toModel<RoomAvatarContent>()?.avatarUrl
if (!res.isNullOrEmpty()) { if (!res.isNullOrEmpty()) {
return@doWithRealm return@doWithRealm

View File

@ -28,10 +28,8 @@ import im.vector.matrix.android.internal.session.room.send.EventFactory
import im.vector.matrix.android.internal.session.room.timeline.DefaultTimelineService import im.vector.matrix.android.internal.session.room.timeline.DefaultTimelineService
import im.vector.matrix.android.internal.session.room.timeline.GetContextOfEventTask import im.vector.matrix.android.internal.session.room.timeline.GetContextOfEventTask
import im.vector.matrix.android.internal.session.room.timeline.PaginationTask import im.vector.matrix.android.internal.session.room.timeline.PaginationTask
import im.vector.matrix.android.internal.session.room.timeline.TimelineBoundaryCallback import im.vector.matrix.android.internal.session.room.timeline.TimelineEventFactory
import im.vector.matrix.android.internal.task.TaskExecutor import im.vector.matrix.android.internal.task.TaskExecutor
import im.vector.matrix.android.internal.util.PagingRequestHelper
import java.util.concurrent.Executors


internal class RoomFactory(private val loadRoomMembersTask: LoadRoomMembersTask, internal class RoomFactory(private val loadRoomMembersTask: LoadRoomMembersTask,
private val monarchy: Monarchy, private val monarchy: Monarchy,
@ -43,10 +41,9 @@ internal class RoomFactory(private val loadRoomMembersTask: LoadRoomMembersTask,
private val taskExecutor: TaskExecutor) { private val taskExecutor: TaskExecutor) {


fun instantiate(roomId: String): Room { fun instantiate(roomId: String): Room {
val helper = PagingRequestHelper(Executors.newSingleThreadExecutor()) val roomMemberExtractor = RoomMemberExtractor(roomId)
val timelineBoundaryCallback = TimelineBoundaryCallback(roomId, taskExecutor, paginationTask, monarchy, helper) val timelineEventFactory = TimelineEventFactory(roomMemberExtractor)
val roomMemberExtractor = RoomMemberExtractor(monarchy, roomId) val timelineService = DefaultTimelineService(roomId, monarchy, taskExecutor, contextOfEventTask, timelineEventFactory, paginationTask)
val timelineService = DefaultTimelineService(roomId, monarchy, taskExecutor, timelineBoundaryCallback, contextOfEventTask, roomMemberExtractor)
val sendService = DefaultSendService(roomId, eventFactory, monarchy) val sendService = DefaultSendService(roomId, eventFactory, monarchy)
val readService = DefaultReadService(roomId, monarchy, setReadMarkersTask, taskExecutor) val readService = DefaultReadService(roomId, monarchy, setReadMarkersTask, taskExecutor)
return DefaultRoom( return DefaultRoom(

View File

@ -22,11 +22,7 @@ import im.vector.matrix.android.internal.session.room.members.LoadRoomMembersTas
import im.vector.matrix.android.internal.session.room.read.DefaultSetReadMarkersTask import im.vector.matrix.android.internal.session.room.read.DefaultSetReadMarkersTask
import im.vector.matrix.android.internal.session.room.read.SetReadMarkersTask import im.vector.matrix.android.internal.session.room.read.SetReadMarkersTask
import im.vector.matrix.android.internal.session.room.send.EventFactory import im.vector.matrix.android.internal.session.room.send.EventFactory
import im.vector.matrix.android.internal.session.room.timeline.DefaultGetContextOfEventTask import im.vector.matrix.android.internal.session.room.timeline.*
import im.vector.matrix.android.internal.session.room.timeline.DefaultPaginationTask
import im.vector.matrix.android.internal.session.room.timeline.GetContextOfEventTask
import im.vector.matrix.android.internal.session.room.timeline.PaginationTask
import im.vector.matrix.android.internal.session.room.timeline.TokenChunkEventPersistor
import org.koin.dsl.module.module import org.koin.dsl.module.module
import retrofit2.Retrofit import retrofit2.Retrofit


@ -41,7 +37,7 @@ class RoomModule {
} }


scope(DefaultSession.SCOPE) { scope(DefaultSession.SCOPE) {
DefaultLoadRoomMembersTask(get(), get(), get()) as LoadRoomMembersTask DefaultLoadRoomMembersTask(get(), get(), get(), get()) as LoadRoomMembersTask
} }


scope(DefaultSession.SCOPE) { scope(DefaultSession.SCOPE) {
@ -57,7 +53,7 @@ class RoomModule {
} }


scope(DefaultSession.SCOPE) { scope(DefaultSession.SCOPE) {
DefaultSetReadMarkersTask(get(), get(),get()) as SetReadMarkersTask DefaultSetReadMarkersTask(get(), get(), get()) as SetReadMarkersTask
} }


scope(DefaultSession.SCOPE) { scope(DefaultSession.SCOPE) {

View File

@ -1,74 +1,79 @@
/* /*
* Copyright 2019 New Vector Ltd
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * * Copyright 2019 New Vector Ltd
* you may not use this file except in compliance with the License. * *
* You may obtain a copy of the License at * * 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.
* *
* 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 package im.vector.matrix.android.internal.session.room


import android.content.Context
import com.zhuinden.monarchy.Monarchy
import im.vector.matrix.android.api.auth.data.Credentials import im.vector.matrix.android.api.auth.data.Credentials
import im.vector.matrix.android.api.session.events.model.EventType 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.events.model.toModel
import im.vector.matrix.android.api.session.room.model.RoomTopicContent import im.vector.matrix.android.api.session.room.model.RoomTopicContent
import im.vector.matrix.android.internal.database.RealmLiveEntityObserver
import im.vector.matrix.android.internal.database.mapper.asDomain 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.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.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.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.database.query.where
import im.vector.matrix.android.internal.session.room.members.RoomDisplayNameResolver import im.vector.matrix.android.internal.session.room.members.RoomDisplayNameResolver
import im.vector.matrix.android.internal.session.room.members.RoomMembers import im.vector.matrix.android.internal.session.room.members.RoomMembers
import im.vector.matrix.android.internal.session.sync.model.RoomSyncSummary
import im.vector.matrix.android.internal.session.sync.model.RoomSyncUnreadNotifications
import io.realm.Realm import io.realm.Realm
import io.realm.kotlin.createObject import io.realm.kotlin.createObject


internal class RoomSummaryUpdater(monarchy: Monarchy, internal class RoomSummaryUpdater(private val credentials: Credentials,
private val roomDisplayNameResolver: RoomDisplayNameResolver, private val roomDisplayNameResolver: RoomDisplayNameResolver,
private val roomAvatarResolver: RoomAvatarResolver, private val roomAvatarResolver: RoomAvatarResolver) {
private val context: Context,
private val credentials: Credentials
) : RealmLiveEntityObserver<RoomEntity>(monarchy) {


override val query = Monarchy.Query<RoomEntity> { RoomEntity.where(it) } fun update(realm: Realm,
roomId: String,
roomSummary: RoomSyncSummary? = null,
unreadNotifications: RoomSyncUnreadNotifications? = null) {


override fun process(inserted: List<RoomEntity>, updated: List<RoomEntity>, deleted: List<RoomEntity>) { val roomSummaryEntity = RoomSummaryEntity.where(realm, roomId).findFirst()
val rooms = (inserted + updated).map { it.roomId } ?: realm.createObject(roomId)
monarchy.writeAsync { realm ->
rooms.forEach { updateRoom(realm, it) } if (roomSummary != null) {
if (roomSummary.heroes.isNotEmpty()) {
roomSummaryEntity.heroes.clear()
roomSummaryEntity.heroes.addAll(roomSummary.heroes)
}
if (roomSummary.invitedMembersCount != null) {
roomSummaryEntity.invitedMembersCount = roomSummary.invitedMembersCount
}
if (roomSummary.joinedMembersCount != null) {
roomSummaryEntity.joinedMembersCount = roomSummary.joinedMembersCount
}
} }
} if (unreadNotifications?.highlightCount != null) {

roomSummaryEntity.highlightCount = unreadNotifications.highlightCount
private fun updateRoom(realm: Realm, roomId: String?) { }
if (roomId == null) { if (unreadNotifications?.notificationCount != null) {
return roomSummaryEntity.notificationCount = unreadNotifications.notificationCount
} }
val roomSummary = RoomSummaryEntity.where(realm, roomId).findFirst()
?: realm.createObject(roomId)


val lastEvent = EventEntity.latestEvent(realm, roomId, includedTypes = listOf(EventType.MESSAGE)) 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 } val otherRoomMembers = RoomMembers(realm, roomId).getLoaded().filterKeys { it != credentials.userId }

roomSummaryEntity.displayName = roomDisplayNameResolver.resolve(roomId).toString()
roomSummary.displayName = roomDisplayNameResolver.resolve(context, roomId).toString() roomSummaryEntity.avatarUrl = roomAvatarResolver.resolve(roomId)
roomSummary.avatarUrl = roomAvatarResolver.resolve(roomId) roomSummaryEntity.topic = lastTopicEvent?.content.toModel<RoomTopicContent>()?.topic
roomSummary.topic = lastTopicEvent?.content.toModel<RoomTopicContent>()?.topic roomSummaryEntity.lastMessage = lastEvent
roomSummary.lastMessage = lastEvent roomSummaryEntity.otherMemberIds.clear()
roomSummary.otherMemberIds.clear() roomSummaryEntity.otherMemberIds.addAll(otherRoomMembers.keys)
roomSummary.otherMemberIds.addAll(otherRoomMembers.keys)
} }

} }

View File

@ -24,9 +24,11 @@ import im.vector.matrix.android.internal.database.model.RoomEntity
import im.vector.matrix.android.internal.database.query.where import im.vector.matrix.android.internal.database.query.where
import im.vector.matrix.android.internal.network.executeRequest import im.vector.matrix.android.internal.network.executeRequest
import im.vector.matrix.android.internal.session.room.RoomAPI import im.vector.matrix.android.internal.session.room.RoomAPI
import im.vector.matrix.android.internal.session.room.RoomSummaryUpdater
import im.vector.matrix.android.internal.session.sync.SyncTokenStore import im.vector.matrix.android.internal.session.sync.SyncTokenStore
import im.vector.matrix.android.internal.task.Task import im.vector.matrix.android.internal.task.Task
import im.vector.matrix.android.internal.util.tryTransactionSync import im.vector.matrix.android.internal.util.tryTransactionSync
import io.realm.kotlin.createObject


internal interface LoadRoomMembersTask : Task<LoadRoomMembersTask.Params, Boolean> { internal interface LoadRoomMembersTask : Task<LoadRoomMembersTask.Params, Boolean> {


@ -38,17 +40,17 @@ internal interface LoadRoomMembersTask : Task<LoadRoomMembersTask.Params, Boolea


internal class DefaultLoadRoomMembersTask(private val roomAPI: RoomAPI, internal class DefaultLoadRoomMembersTask(private val roomAPI: RoomAPI,
private val monarchy: Monarchy, private val monarchy: Monarchy,
private val syncTokenStore: SyncTokenStore private val syncTokenStore: SyncTokenStore,
private val roomSummaryUpdater: RoomSummaryUpdater
) : LoadRoomMembersTask { ) : LoadRoomMembersTask {


override fun execute(params: LoadRoomMembersTask.Params): Try<Boolean> { override fun execute(params: LoadRoomMembersTask.Params): Try<Boolean> {
return if (areAllMembersAlreadyLoaded(params.roomId)) { return if (areAllMembersAlreadyLoaded(params.roomId)) {
Try.just(true) Try.just(true)
} else { } else {
//TODO use this token
val lastToken = syncTokenStore.getLastToken() val lastToken = syncTokenStore.getLastToken()
executeRequest<RoomMembersResponse> { executeRequest<RoomMembersResponse> {
apiCall = roomAPI.getMembers(params.roomId, null, null, params.excludeMembership?.value) apiCall = roomAPI.getMembers(params.roomId, lastToken, null, params.excludeMembership?.value)
}.flatMap { response -> }.flatMap { response ->
insertInDb(response, params.roomId) insertInDb(response, params.roomId)
}.map { true } }.map { true }
@ -60,22 +62,24 @@ internal class DefaultLoadRoomMembersTask(private val roomAPI: RoomAPI,
.tryTransactionSync { realm -> .tryTransactionSync { realm ->
// We ignore all the already known members // We ignore all the already known members
val roomEntity = RoomEntity.where(realm, roomId).findFirst() val roomEntity = RoomEntity.where(realm, roomId).findFirst()
?: throw IllegalStateException("You shouldn't use this method without a room") ?: realm.createObject(roomId)


val roomMembers = RoomMembers(realm, roomId).getLoaded() val roomMembers = RoomMembers(realm, roomId).getLoaded()
val eventsToInsert = response.roomMemberEvents.filter { !roomMembers.containsKey(it.stateKey) } val eventsToInsert = response.roomMemberEvents.filter { !roomMembers.containsKey(it.stateKey) }


roomEntity.addStateEvents(eventsToInsert) roomEntity.addStateEvents(eventsToInsert)
roomEntity.areAllMembersLoaded = true roomEntity.areAllMembersLoaded = true

roomSummaryUpdater.update(realm, roomId)
} }
.map { response } .map { response }
} }


private fun areAllMembersAlreadyLoaded(roomId: String): Boolean { private fun areAllMembersAlreadyLoaded(roomId: String): Boolean {
return monarchy return monarchy
.fetchAllCopiedSync { RoomEntity.where(it, roomId) } .fetchAllCopiedSync { RoomEntity.where(it, roomId) }
.firstOrNull() .firstOrNull()
?.areAllMembersLoaded ?: false ?.areAllMembersLoaded ?: false
} }


} }

View File

@ -30,13 +30,14 @@ 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.EventEntity
import im.vector.matrix.android.internal.database.model.RoomEntity 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.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 import im.vector.matrix.android.internal.database.query.where


/** /**
* This class computes room display name * This class computes room display name
*/ */
internal class RoomDisplayNameResolver(private val monarchy: Monarchy, internal class RoomDisplayNameResolver(private val context: Context,
private val monarchy: Monarchy,
private val roomMemberDisplayNameResolver: RoomMemberDisplayNameResolver, private val roomMemberDisplayNameResolver: RoomMemberDisplayNameResolver,
private val credentials: Credentials private val credentials: Credentials
) { ) {
@ -44,11 +45,10 @@ internal class RoomDisplayNameResolver(private val monarchy: Monarchy,
/** /**
* Compute the room display name * Compute the room display name
* *
* @param context
* @param roomId: the roomId to resolve the name of. * @param roomId: the roomId to resolve the name of.
* @return the room display name * @return the room display name
*/ */
fun resolve(context: Context, roomId: String): CharSequence { fun resolve(roomId: String): CharSequence {
// this algorithm is the one defined in // this algorithm is the one defined in
// https://github.com/matrix-org/matrix-js-sdk/blob/develop/lib/models/room.js#L617 // https://github.com/matrix-org/matrix-js-sdk/blob/develop/lib/models/room.js#L617
// calculateRoomName(room, userId) // calculateRoomName(room, userId)
@ -58,19 +58,19 @@ internal class RoomDisplayNameResolver(private val monarchy: Monarchy,
var name: CharSequence? = null var name: CharSequence? = null
monarchy.doWithRealm { realm -> monarchy.doWithRealm { realm ->
val roomEntity = RoomEntity.where(realm, roomId = roomId).findFirst() 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<RoomNameContent>()?.name name = roomName?.content.toModel<RoomNameContent>()?.name
if (!name.isNullOrEmpty()) { if (!name.isNullOrEmpty()) {
return@doWithRealm 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<RoomCanonicalAliasContent>()?.canonicalAlias name = canonicalAlias?.content.toModel<RoomCanonicalAliasContent>()?.canonicalAlias
if (!name.isNullOrEmpty()) { if (!name.isNullOrEmpty()) {
return@doWithRealm 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<RoomAliasesContent>()?.aliases?.firstOrNull() name = aliases?.content.toModel<RoomAliasesContent>()?.aliases?.firstOrNull()
if (!name.isNullOrEmpty()) { if (!name.isNullOrEmpty()) {
return@doWithRealm return@doWithRealm

View File

@ -16,20 +16,19 @@


package im.vector.matrix.android.internal.session.room.members 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.EventType
import im.vector.matrix.android.api.session.events.model.toModel 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.api.session.room.model.RoomMember
import im.vector.matrix.android.internal.database.mapper.ContentMapper 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.EventEntity
import im.vector.matrix.android.internal.database.model.EventEntityFields 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.next
import im.vector.matrix.android.internal.database.query.prev
import im.vector.matrix.android.internal.database.query.where import im.vector.matrix.android.internal.database.query.where
import io.realm.Realm
import io.realm.RealmQuery import io.realm.RealmQuery


internal class RoomMemberExtractor(private val monarchy: Monarchy, internal class RoomMemberExtractor(private val roomId: String) {
private val roomId: String) {


private val cached = HashMap<String, RoomMember?>() private val cached = HashMap<String, RoomMember?>()


@ -44,30 +43,25 @@ internal class RoomMemberExtractor(private val monarchy: Monarchy,
// When stateIndex is negative, we try to get the next stateEvent prevContent() // 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() // If prevContent is null we fallback to the Int.MIN state events content()
val content = if (event.stateIndex <= 0) { val content = if (event.stateIndex <= 0) {
baseQuery(monarchy, roomId, sender, unlinked).next(from = event.stateIndex)?.prevContent baseQuery(event.realm, roomId, sender, unlinked).next(from = event.stateIndex)?.prevContent
?: baseQuery(monarchy, roomId, sender, unlinked).last(since = event.stateIndex)?.content ?: baseQuery(event.realm, roomId, sender, unlinked).prev(since = event.stateIndex)?.content
} else { } 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() val roomMember: RoomMember? = ContentMapper.map(content).toModel()
cached[cacheKey] = roomMember cached[cacheKey] = roomMember
return roomMember return roomMember
} }


private fun baseQuery(monarchy: Monarchy, private fun baseQuery(realm: Realm,
roomId: String, roomId: String,
sender: String, sender: String,
isUnlinked: Boolean): RealmQuery<EventEntity> { isUnlinked: Boolean): RealmQuery<EventEntity> {


lateinit var query: RealmQuery<EventEntity>
val filterMode = if (isUnlinked) EventEntity.LinkFilterMode.UNLINKED_ONLY else EventEntity.LinkFilterMode.LINKED_ONLY val filterMode = if (isUnlinked) EventEntity.LinkFilterMode.UNLINKED_ONLY else EventEntity.LinkFilterMode.LINKED_ONLY
monarchy.doWithRealm { realm -> return EventEntity
query = EventEntity .where(realm, roomId = roomId, type = EventType.STATE_ROOM_MEMBER, linkFilterMode = filterMode)
.where(realm, roomId = roomId, type = EventType.STATE_ROOM_MEMBER, linkFilterMode = filterMode) .equalTo(EventEntityFields.STATE_KEY, sender)
.equalTo(EventEntityFields.STATE_KEY, sender)
}
return query
} }



} }

View File

@ -34,7 +34,7 @@ internal class EventsPruner(monarchy: Monarchy) :


override val query = Monarchy.Query<EventEntity> { EventEntity.where(it, type = EventType.REDACTION) } override val query = Monarchy.Query<EventEntity> { EventEntity.where(it, type = EventType.REDACTION) }


override fun process(inserted: List<EventEntity>, updated: List<EventEntity>, deleted: List<EventEntity>) { override fun processChanges(inserted: List<EventEntity>, updated: List<EventEntity>, deleted: List<EventEntity>) {
val redactionEvents = inserted.map { it.asDomain() } val redactionEvents = inserted.map { it.asDomain() }
val pruneEventWorkerParams = PruneEventWorker.Params(redactionEvents) val pruneEventWorkerParams = PruneEventWorker.Params(redactionEvents)
val workData = WorkerParamsFactory.toData(pruneEventWorkerParams) val workData = WorkerParamsFactory.toData(pruneEventWorkerParams)

View File

@ -56,8 +56,8 @@ internal class DefaultSetReadMarkersTask(private val roomAPI: RoomAPI,
markers[READ_MARKER] = params.fullyReadEventId markers[READ_MARKER] = params.fullyReadEventId
} }
if (params.readReceiptEventId != null if (params.readReceiptEventId != null
&& MatrixPatterns.isEventId(params.readReceiptEventId) && MatrixPatterns.isEventId(params.readReceiptEventId)
&& !isEventRead(params.roomId, params.readReceiptEventId)) { && !isEventRead(params.roomId, params.readReceiptEventId)) {


updateNotificationCountIfNecessary(params.roomId, params.readReceiptEventId) updateNotificationCountIfNecessary(params.roomId, params.readReceiptEventId)
markers[READ_RECEIPT] = params.readReceiptEventId markers[READ_RECEIPT] = params.readReceiptEventId
@ -76,7 +76,7 @@ internal class DefaultSetReadMarkersTask(private val roomAPI: RoomAPI,
val isLatestReceived = EventEntity.latestEvent(realm, roomId)?.eventId == eventId val isLatestReceived = EventEntity.latestEvent(realm, roomId)?.eventId == eventId
if (isLatestReceived) { if (isLatestReceived) {
val roomSummary = RoomSummaryEntity.where(realm, roomId).findFirst() val roomSummary = RoomSummaryEntity.where(realm, roomId).findFirst()
?: return@tryTransactionAsync ?: return@tryTransactionAsync
roomSummary.notificationCount = 0 roomSummary.notificationCount = 0
roomSummary.highlightCount = 0 roomSummary.highlightCount = 0
} }
@ -87,13 +87,14 @@ internal class DefaultSetReadMarkersTask(private val roomAPI: RoomAPI,
var isEventRead = false var isEventRead = false
monarchy.doWithRealm { monarchy.doWithRealm {
val readReceipt = ReadReceiptEntity.where(it, roomId, credentials.userId).findFirst() val readReceipt = ReadReceiptEntity.where(it, roomId, credentials.userId).findFirst()
?: return@doWithRealm ?: return@doWithRealm
val liveChunk = ChunkEntity.findLastLiveChunkFromRoom(it, roomId) val liveChunk = ChunkEntity.findLastLiveChunkFromRoom(it, roomId)
?: return@doWithRealm ?: return@doWithRealm
val readReceiptIndex = liveChunk.events.find(readReceipt.eventId)?.displayIndex val readReceiptIndex = liveChunk.events.find(readReceipt.eventId)?.displayIndex
?: Int.MAX_VALUE ?: Int.MIN_VALUE
val eventToCheckIndex = liveChunk.events.find(eventId)?.displayIndex ?: -1 val eventToCheckIndex = liveChunk.events.find(eventId)?.displayIndex
isEventRead = eventToCheckIndex >= readReceiptIndex ?: Int.MAX_VALUE
isEventRead = eventToCheckIndex <= readReceiptIndex
} }
return isEventRead return isEventRead
} }

View File

@ -23,7 +23,6 @@ import im.vector.matrix.android.api.session.events.model.Event
import im.vector.matrix.android.api.session.room.send.SendService import im.vector.matrix.android.api.session.room.send.SendService
import im.vector.matrix.android.api.util.Cancelable import im.vector.matrix.android.api.util.Cancelable
import im.vector.matrix.android.internal.database.helper.add import im.vector.matrix.android.internal.database.helper.add
import im.vector.matrix.android.internal.database.helper.updateDisplayIndexes
import im.vector.matrix.android.internal.database.model.ChunkEntity import im.vector.matrix.android.internal.database.model.ChunkEntity
import im.vector.matrix.android.internal.database.query.findLastLiveChunkFromRoom import im.vector.matrix.android.internal.database.query.findLastLiveChunkFromRoom
import im.vector.matrix.android.internal.session.room.timeline.PaginationDirection import im.vector.matrix.android.internal.session.room.timeline.PaginationDirection
@ -49,7 +48,6 @@ internal class DefaultSendService(private val roomId: String,
val chunkEntity = ChunkEntity.findLastLiveChunkFromRoom(realm, roomId) val chunkEntity = ChunkEntity.findLastLiveChunkFromRoom(realm, roomId)
?: return@tryTransactionAsync ?: return@tryTransactionAsync
chunkEntity.add(roomId, event, PaginationDirection.FORWARDS) chunkEntity.add(roomId, event, PaginationDirection.FORWARDS)
chunkEntity.updateDisplayIndexes()
} }


val sendContentWorkerParams = SendEventWorker.Params(roomId, event) val sendContentWorkerParams = SendEventWorker.Params(roomId, event)

View File

@ -17,12 +17,12 @@
package im.vector.matrix.android.internal.session.room.timeline package im.vector.matrix.android.internal.session.room.timeline


import arrow.core.Try import arrow.core.Try
import im.vector.matrix.android.internal.task.Task
import im.vector.matrix.android.internal.network.executeRequest import im.vector.matrix.android.internal.network.executeRequest
import im.vector.matrix.android.internal.session.room.RoomAPI import im.vector.matrix.android.internal.session.room.RoomAPI
import im.vector.matrix.android.internal.task.Task
import im.vector.matrix.android.internal.util.FilterUtil import im.vector.matrix.android.internal.util.FilterUtil


internal interface GetContextOfEventTask : Task<GetContextOfEventTask.Params, TokenChunkEvent> { internal interface GetContextOfEventTask : Task<GetContextOfEventTask.Params, TokenChunkEventPersistor.Result> {


data class Params( data class Params(
val roomId: String, val roomId: String,
@ -35,12 +35,12 @@ internal class DefaultGetContextOfEventTask(private val roomAPI: RoomAPI,
private val tokenChunkEventPersistor: TokenChunkEventPersistor private val tokenChunkEventPersistor: TokenChunkEventPersistor
) : GetContextOfEventTask { ) : GetContextOfEventTask {


override fun execute(params: GetContextOfEventTask.Params): Try<EventContextResponse> { override fun execute(params: GetContextOfEventTask.Params): Try<TokenChunkEventPersistor.Result> {
val filter = FilterUtil.createRoomEventFilter(true)?.toJSONString() val filter = FilterUtil.createRoomEventFilter(true)?.toJSONString()
return executeRequest<EventContextResponse> { return executeRequest<EventContextResponse> {
apiCall = roomAPI.getContextOfEvent(params.roomId, params.eventId, 0, filter) apiCall = roomAPI.getContextOfEvent(params.roomId, params.eventId, 0, filter)
}.flatMap { response -> }.flatMap { response ->
tokenChunkEventPersistor.insertInDb(response, params.roomId, PaginationDirection.BACKWARDS).map { response } tokenChunkEventPersistor.insertInDb(response, params.roomId, PaginationDirection.BACKWARDS)
} }
} }



View File

@ -23,7 +23,7 @@ import im.vector.matrix.android.internal.task.Task
import im.vector.matrix.android.internal.util.FilterUtil import im.vector.matrix.android.internal.util.FilterUtil




internal interface PaginationTask : Task<PaginationTask.Params, Boolean> { internal interface PaginationTask : Task<PaginationTask.Params, TokenChunkEventPersistor.Result> {


data class Params( data class Params(
val roomId: String, val roomId: String,
@ -38,7 +38,7 @@ internal class DefaultPaginationTask(private val roomAPI: RoomAPI,
private val tokenChunkEventPersistor: TokenChunkEventPersistor private val tokenChunkEventPersistor: TokenChunkEventPersistor
) : PaginationTask { ) : PaginationTask {


override fun execute(params: PaginationTask.Params): Try<Boolean> { override fun execute(params: PaginationTask.Params): Try<TokenChunkEventPersistor.Result> {
val filter = FilterUtil.createRoomEventFilter(true)?.toJSONString() val filter = FilterUtil.createRoomEventFilter(true)?.toJSONString()
return executeRequest<PaginationResponse> { return executeRequest<PaginationResponse> {
apiCall = roomAPI.getRoomMessagesFrom(params.roomId, params.from, params.direction.value, params.limit, filter) apiCall = roomAPI.getRoomMessagesFrom(params.roomId, params.from, params.direction.value, params.limit, filter)

View File

@ -0,0 +1,448 @@
/*
*
* * 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 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
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
import im.vector.matrix.android.api.util.CancelableBag
import im.vector.matrix.android.api.util.addTo
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 io.realm.OrderedRealmCollectionChangeListener
import io.realm.Realm
import io.realm.RealmConfiguration
import io.realm.RealmQuery
import io.realm.RealmResults
import io.realm.Sort
import timber.log.Timber
import java.util.*
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicReference


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(
private val roomId: String,
private val initialEventId: String? = null,
private val realmConfiguration: RealmConfiguration,
private val taskExecutor: TaskExecutor,
private val contextOfEventTask: GetContextOfEventTask,
private val timelineEventFactory: TimelineEventFactory,
private val paginationTask: PaginationTask,
private val allowedTypes: List<String>?
) : Timeline {

override var listener: Timeline.Listener? = null
set(value) {
field = value
backgroundHandler.get()?.post {
val snapshot = snapshot()
mainHandler.post { listener?.onUpdated(snapshot) }
}
}

private val isStarted = AtomicBoolean(false)
private val isReady = AtomicBoolean(false)
private val backgroundHandlerThread = AtomicReference<HandlerThread>()
private val backgroundHandler = AtomicReference<Handler>()
private val mainHandler = Handler(Looper.getMainLooper())
private val backgroundRealm = AtomicReference<Realm>()
private val cancelableBag = CancelableBag()

private lateinit var liveEvents: RealmResults<EventEntity>

private var prevDisplayIndex: Int = DISPLAY_INDEX_UNKNOWN
private var nextDisplayIndex: Int = DISPLAY_INDEX_UNKNOWN
private val isLive = initialEventId == null
private val builtEvents = Collections.synchronizedList<TimelineEvent>(ArrayList())

private val backwardsPaginationState = AtomicReference(PaginationState())
private val forwardsPaginationState = AtomicReference(PaginationState())

private val eventsChangeListener = OrderedRealmCollectionChangeListener<RealmResults<EventEntity>> { _, changeSet ->
// 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
val shouldPostSnapshot = paginateInternal(startDisplayIndex, direction, state.requestedCount)
if (shouldPostSnapshot) {
postSnapshot()
}
} else {
// We are getting new items from sync
buildTimelineEvents(startDisplayIndex, direction, range.length.toLong())
postSnapshot()
}
}
}

// Public methods ******************************************************************************

override fun paginate(direction: Timeline.Direction, count: Int) {
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 shouldPostSnapshot = paginateInternal(startDisplayIndex, direction, count)
if (shouldPostSnapshot) {
postSnapshot()
}
}
}

override fun start() {
if (isStarted.compareAndSet(false, true)) {
Timber.v("Start timeline for roomId: $roomId and eventId: $initialEventId")
val handlerThread = HandlerThread(THREAD_NAME + hashCode())
handlerThread.start()
val handler = Handler(handlerThread.looper)
this.backgroundHandlerThread.set(handlerThread)
this.backgroundHandler.set(handler)
handler.post {
val realm = Realm.getInstance(realmConfiguration)
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)) {
Timber.v("Dispose timeline for roomId: $roomId and eventId: $initialEventId")
backgroundHandler.get()?.post {
cancelableBag.cancel()
liveEvents.removeAllChangeListeners()
backgroundRealm.getAndSet(null).also {
it.close()
}
backgroundHandler.set(null)
backgroundHandlerThread.getAndSet(null)?.quit()
}
}
}

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
* @return true if snapshot should be posted
*/
private fun paginateInternal(startDisplayIndex: Int,
direction: Timeline.Direction,
count: Int): Boolean {
updatePaginationState(direction) { it.copy(requestedCount = count, isPaginating = true) }
val builtCount = buildTimelineEvents(startDisplayIndex, direction, count.toLong())
val shouldFetchMore = builtCount < count && !hasReachedEnd(direction)
if (shouldFetchMore) {
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) }
}
return !shouldFetchMore
}

private fun snapshot(): List<TimelineEvent> {
return builtEvents.toList()
}

private fun canPaginate(direction: Timeline.Direction): Boolean {
return isReady.get() && !getPaginationState(direction).isPaginating && hasMoreToLoad(direction)
}

private fun getPaginationState(direction: Timeline.Direction): PaginationState {
return when (direction) {
Timeline.Direction.FORWARDS -> forwardsPaginationState.get()
Timeline.Direction.BACKWARDS -> backwardsPaginationState.get()
}
}

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() {
var shouldFetchInitialEvent = false
val initialDisplayIndex = if (isLive) {
liveEvents.firstOrNull()?.displayIndex
} else {
val initialEvent = liveEvents.where().equalTo(EventEntityFields.EVENT_ID, initialEventId).findFirst()
shouldFetchInitialEvent = initialEvent == null
initialEvent?.displayIndex
} ?: DISPLAY_INDEX_UNKNOWN

prevDisplayIndex = initialDisplayIndex
nextDisplayIndex = initialDisplayIndex
if (initialEventId != null && shouldFetchInitialEvent) {
fetchEvent(initialEventId)
} else {
val count = Math.min(INITIAL_LOAD_SIZE, liveEvents.size)
if (isLive) {
paginateInternal(initialDisplayIndex, Timeline.Direction.BACKWARDS, count)
} else {
paginateInternal(initialDisplayIndex, Timeline.Direction.FORWARDS, count / 2)
paginateInternal(initialDisplayIndex, Timeline.Direction.BACKWARDS, count / 2)
}
}
postSnapshot()
}

/**
* 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 = token,
direction = direction.toPaginationDirection(),
limit = limit)

Timber.v("Should fetch $limit items $direction")
paginationTask.configureWith(params)
.enableRetry()
.dispatchTo(object : MatrixCallback<TokenChunkEventPersistor.Result> {
override fun onSuccess(data: TokenChunkEventPersistor.Result) {
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) {
Timber.v("Failure fetching $limit items $direction from pagination request")
}
})
.executeBy(taskExecutor)
.addTo(cancelableBag)
}

/**
* 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 getLiveChunk(): ChunkEntity? {
return liveEvents.firstOrNull()?.chunk?.firstOrNull()
}

/**
* This has to be called on TimelineThread as it access realm live results
* @return number of items who have been added
*/
private fun buildTimelineEvents(startDisplayIndex: Int,
direction: Timeline.Direction,
count: Long): Int {
if (count < 1) {
return 0
}
val offsetResults = getOffsetResults(startDisplayIndex, direction, count)
if (offsetResults.isEmpty()) {
return 0
}
val offsetIndex = offsetResults.last()!!.displayIndex
if (direction == Timeline.Direction.BACKWARDS) {
prevDisplayIndex = offsetIndex - 1
} else {
nextDisplayIndex = offsetIndex + 1
}
offsetResults.forEach { eventEntity ->
val timelineEvent = timelineEventFactory.create(eventEntity)
val position = if (direction == Timeline.Direction.FORWARDS) 0 else builtEvents.size
builtEvents.add(position, timelineEvent)
}
Timber.v("Built ${offsetResults.size} items from db")
return offsetResults.size
}

/**
* This has to be called on TimelineThread as it access realm live results
*/
private fun getOffsetResults(startDisplayIndex: Int,
direction: Timeline.Direction,
count: Long): RealmResults<EventEntity> {
val offsetQuery = liveEvents.where()
if (direction == Timeline.Direction.BACKWARDS) {
offsetQuery
.sort(EventEntityFields.DISPLAY_INDEX, Sort.DESCENDING)
.lessThanOrEqualTo(EventEntityFields.DISPLAY_INDEX, startDisplayIndex)
} else {
offsetQuery
.sort(EventEntityFields.DISPLAY_INDEX, Sort.ASCENDING)
.greaterThanOrEqualTo(EventEntityFields.DISPLAY_INDEX, startDisplayIndex)
}
return offsetQuery
.filterAllowedTypes()
.limit(count)
.findAll()
}


private fun buildEventQuery(realm: Realm): RealmQuery<EventEntity> {
return if (initialEventId == null) {
EventEntity
.where(realm, roomId = roomId, linkFilterMode = EventEntity.LinkFilterMode.LINKED_ONLY)
.equalTo("${EventEntityFields.CHUNK}.${ChunkEntityFields.IS_LAST_FORWARD}", true)
} else {
EventEntity
.where(realm, roomId = roomId, linkFilterMode = EventEntity.LinkFilterMode.BOTH)
.`in`("${EventEntityFields.CHUNK}.${ChunkEntityFields.EVENTS.EVENT_ID}", arrayOf(initialEventId))
}
}

private fun findCurrentChunk(realm: Realm): ChunkEntity? {
return if (initialEventId == null) {
ChunkEntity.findLastLiveChunkFromRoom(realm, roomId)
} else {
ChunkEntity.findIncludingEvent(realm, initialEventId)
}
}

private fun clearUnlinkedEvents(realm: Realm) {
realm.executeTransaction {
val unlinkedChunks = ChunkEntity
.where(it, roomId = roomId)
.equalTo(ChunkEntityFields.EVENTS.IS_UNLINKED, true)
.findAll()
unlinkedChunks.deleteAllFromRealm()
}
}

private fun fetchEvent(eventId: String) {
val params = GetContextOfEventTask.Params(roomId, eventId)
contextOfEventTask.configureWith(params).executeBy(taskExecutor)
}

private fun postSnapshot() {
val snapshot = snapshot()
mainHandler.post { listener?.onUpdated(snapshot) }
}

// Extension methods ***************************************************************************

private fun Timeline.Direction.toPaginationDirection(): PaginationDirection {
return if (this == Timeline.Direction.BACKWARDS) PaginationDirection.BACKWARDS else PaginationDirection.FORWARDS
}

private fun RealmQuery<EventEntity>.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<EventEntity>.filterAllowedTypes(): RealmQuery<EventEntity> {
if (allowedTypes != null) {
`in`(EventEntityFields.TYPE, allowedTypes.toTypedArray())
}
return this
}
}

private data class PaginationState(
val isPaginating: Boolean = false,
val requestedCount: Int = 0
)

View File

@ -16,129 +16,21 @@


package im.vector.matrix.android.internal.session.room.timeline package im.vector.matrix.android.internal.session.room.timeline


import androidx.lifecycle.LiveData
import androidx.paging.LivePagedListBuilder
import androidx.paging.PagedList
import com.zhuinden.monarchy.Monarchy import com.zhuinden.monarchy.Monarchy
import im.vector.matrix.android.api.session.room.timeline.TimelineEventInterceptor import im.vector.matrix.android.api.session.room.timeline.Timeline
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
import im.vector.matrix.android.api.session.room.timeline.TimelineData
import im.vector.matrix.android.api.session.room.timeline.TimelineService import im.vector.matrix.android.api.session.room.timeline.TimelineService
import im.vector.matrix.android.internal.database.mapper.asDomain
import im.vector.matrix.android.internal.database.model.ChunkEntityFields
import im.vector.matrix.android.internal.database.model.EventEntity
import im.vector.matrix.android.internal.database.model.EventEntityFields
import im.vector.matrix.android.internal.database.query.where
import im.vector.matrix.android.internal.session.room.members.RoomMemberExtractor
import im.vector.matrix.android.internal.task.TaskExecutor import im.vector.matrix.android.internal.task.TaskExecutor
import im.vector.matrix.android.internal.task.configureWith
import im.vector.matrix.android.internal.util.LiveDataUtils
import im.vector.matrix.android.internal.util.PagingRequestHelper
import im.vector.matrix.android.internal.util.tryTransactionAsync
import io.realm.Realm
import io.realm.RealmQuery

private const val PAGE_SIZE = 100
private const val PREFETCH_DISTANCE = 30
private const val EVENT_NOT_FOUND_INDEX = -1


internal class DefaultTimelineService(private val roomId: String, internal class DefaultTimelineService(private val roomId: String,
private val monarchy: Monarchy, private val monarchy: Monarchy,
private val taskExecutor: TaskExecutor, private val taskExecutor: TaskExecutor,
private val boundaryCallback: TimelineBoundaryCallback,
private val contextOfEventTask: GetContextOfEventTask, private val contextOfEventTask: GetContextOfEventTask,
private val roomMemberExtractor: RoomMemberExtractor private val timelineEventFactory: TimelineEventFactory,
private val paginationTask: PaginationTask
) : TimelineService { ) : TimelineService {


private val eventInterceptors = ArrayList<TimelineEventInterceptor>() override fun createTimeline(eventId: String?, allowedTypes: List<String>?): Timeline {

return DefaultTimeline(roomId, eventId, monarchy.realmConfiguration, taskExecutor, contextOfEventTask, timelineEventFactory, paginationTask, allowedTypes)
override fun timeline(eventId: String?): LiveData<TimelineData> {
clearUnlinkedEvents()
val initialLoadKey = getInitialLoadKey(eventId)
val realmDataSourceFactory = monarchy.createDataSourceFactory {
buildDataSourceFactoryQuery(it, eventId)
}
val domainSourceFactory = realmDataSourceFactory
.map { eventEntity ->
val roomMember = roomMemberExtractor.extractFrom(eventEntity)
TimelineEvent(eventEntity.asDomain(), eventEntity.localId, roomMember)
}

val pagedListConfig = buildPagedListConfig()

val livePagedListBuilder = LivePagedListBuilder(domainSourceFactory, pagedListConfig)
.setBoundaryCallback(boundaryCallback)
.setInitialLoadKey(initialLoadKey)

val eventsLiveData = monarchy.findAllPagedWithChanges(realmDataSourceFactory, livePagedListBuilder)

return LiveDataUtils.combine(eventsLiveData, boundaryCallback.status) { events, status ->
val isLoadingForward = status.before == PagingRequestHelper.Status.RUNNING
val isLoadingBackward = status.after == PagingRequestHelper.Status.RUNNING
TimelineData(events, isLoadingForward, isLoadingBackward)
}
} }


// 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 buildPagedListConfig(): PagedList.Config {
return PagedList.Config.Builder()
.setEnablePlaceholders(false)
.setPageSize(PAGE_SIZE)
.setInitialLoadSizeHint(2 * PAGE_SIZE)
.setPrefetchDistance(PREFETCH_DISTANCE)
.build()
}

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
}

private fun buildDataSourceFactoryQuery(realm: Realm, eventId: String?): RealmQuery<EventEntity> {
val query = if (eventId == null) {
EventEntity
.where(realm, roomId = roomId, linkFilterMode = EventEntity.LinkFilterMode.LINKED_ONLY)
.equalTo("${EventEntityFields.CHUNK}.${ChunkEntityFields.IS_LAST}", true)
} else {
EventEntity
.where(realm, roomId = roomId, linkFilterMode = EventEntity.LinkFilterMode.BOTH)
.`in`("${EventEntityFields.CHUNK}.${ChunkEntityFields.EVENTS.EVENT_ID}", arrayOf(eventId))
}
return query.sort(EventEntityFields.DISPLAY_INDEX)
}


} }

View File

@ -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<TimelineEvent>() {

var limit = 30

val status = object : LiveData<PagingRequestHelper.StatusReport>() {

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<Boolean> {
override fun onSuccess(data: Boolean) {
requestCallback.recordSuccess()
}

override fun onFailure(failure: Throwable) {
requestCallback.recordFailure(failure)
}
})
.executeBy(taskExecutor)
}

}

View File

@ -0,0 +1,36 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package im.vector.matrix.android.internal.session.room.timeline

import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
import im.vector.matrix.android.internal.database.mapper.asDomain
import im.vector.matrix.android.internal.database.model.EventEntity
import im.vector.matrix.android.internal.session.room.members.RoomMemberExtractor

internal class TimelineEventFactory(private val roomMemberExtractor: RoomMemberExtractor) {

fun create(eventEntity: EventEntity): TimelineEvent {
val roomMember = roomMemberExtractor.extractFrom(eventEntity)
return TimelineEvent(
eventEntity.asDomain(),
eventEntity.localId,
eventEntity.displayIndex,
roomMember
)
}

}

View File

@ -18,7 +18,12 @@ package im.vector.matrix.android.internal.session.room.timeline


import arrow.core.Try import arrow.core.Try
import com.zhuinden.monarchy.Monarchy import com.zhuinden.monarchy.Monarchy
import im.vector.matrix.android.internal.database.helper.* import im.vector.matrix.android.internal.database.helper.addAll
import im.vector.matrix.android.internal.database.helper.addOrUpdate
import im.vector.matrix.android.internal.database.helper.addStateEvents
import im.vector.matrix.android.internal.database.helper.deleteOnCascade
import im.vector.matrix.android.internal.database.helper.isUnlinked
import im.vector.matrix.android.internal.database.helper.merge
import im.vector.matrix.android.internal.database.model.ChunkEntity import im.vector.matrix.android.internal.database.model.ChunkEntity
import im.vector.matrix.android.internal.database.model.RoomEntity import im.vector.matrix.android.internal.database.model.RoomEntity
import im.vector.matrix.android.internal.database.query.create import im.vector.matrix.android.internal.database.query.create
@ -26,6 +31,8 @@ import im.vector.matrix.android.internal.database.query.find
import im.vector.matrix.android.internal.database.query.findAllIncludingEvents import im.vector.matrix.android.internal.database.query.findAllIncludingEvents
import im.vector.matrix.android.internal.database.query.where import im.vector.matrix.android.internal.database.query.where
import im.vector.matrix.android.internal.util.tryTransactionSync import im.vector.matrix.android.internal.util.tryTransactionSync
import io.realm.kotlin.createObject
import timber.log.Timber


/** /**
* Insert Chunk in DB, and eventually merge with existing chunk event * Insert Chunk in DB, and eventually merge with existing chunk event
@ -94,17 +101,22 @@ internal class TokenChunkEventPersistor(private val monarchy: Monarchy) {
* ======================================================================================================== * ========================================================================================================
* </pre> * </pre>
*/ */

enum class Result {
SHOULD_FETCH_MORE,
SUCCESS
}

fun insertInDb(receivedChunk: TokenChunkEvent, fun insertInDb(receivedChunk: TokenChunkEvent,
roomId: String, roomId: String,
direction: PaginationDirection): Try<Boolean> { direction: PaginationDirection): Try<Result> {


if (receivedChunk.events.isEmpty() && receivedChunk.stateEvents.isEmpty()) {
return Try.just(false)
}
return monarchy return monarchy
.tryTransactionSync { realm -> .tryTransactionSync { realm ->
Timber.v("Start persisting ${receivedChunk.events.size} events in $roomId towards $direction")

val roomEntity = RoomEntity.where(realm, roomId).findFirst() val roomEntity = RoomEntity.where(realm, roomId).findFirst()
?: throw IllegalStateException("You shouldn't use this method without a room") ?: realm.createObject(roomId)


val nextToken: String? val nextToken: String?
val prevToken: String? val prevToken: String?
@ -118,7 +130,7 @@ internal class TokenChunkEventPersistor(private val monarchy: Monarchy) {
val prevChunk = ChunkEntity.find(realm, roomId, nextToken = prevToken) val prevChunk = ChunkEntity.find(realm, roomId, nextToken = prevToken)
val nextChunk = ChunkEntity.find(realm, roomId, prevToken = nextToken) val nextChunk = ChunkEntity.find(realm, roomId, prevToken = nextToken)


// The current chunk is the one we will keep all along the merge process. // The current chunk is the one we will keep all along the merge processChanges.
// We try to look for a chunk next to the token, // We try to look for a chunk next to the token,
// otherwise we create a whole new one // otherwise we create a whole new one


@ -127,28 +139,39 @@ internal class TokenChunkEventPersistor(private val monarchy: Monarchy) {
} else { } else {
nextChunk?.apply { this.prevToken = prevToken } nextChunk?.apply { this.prevToken = prevToken }
} }
?: ChunkEntity.create(realm, prevToken, nextToken) ?: ChunkEntity.create(realm, prevToken, nextToken)


currentChunk.addAll(roomId, receivedChunk.events, direction, isUnlinked = currentChunk.isUnlinked()) if (receivedChunk.events.isEmpty() && receivedChunk.end == receivedChunk.start) {

Timber.v("Reach end of $roomId")
// Then we merge chunks if needed currentChunk.isLastBackward = true
if (currentChunk != prevChunk && prevChunk != null) {
currentChunk = handleMerge(roomEntity, direction, currentChunk, prevChunk)
} else if (currentChunk != nextChunk && nextChunk != null) {
currentChunk = handleMerge(roomEntity, direction, currentChunk, nextChunk)
} else { } else {
val newEventIds = receivedChunk.events.mapNotNull { it.eventId } Timber.v("Add ${receivedChunk.events.size} events in chunk(${currentChunk.nextToken} | ${currentChunk.prevToken}")
ChunkEntity currentChunk.addAll(roomId, receivedChunk.events, direction, isUnlinked = currentChunk.isUnlinked())
.findAllIncludingEvents(realm, newEventIds) // Then we merge chunks if needed
.filter { it != currentChunk } if (currentChunk != prevChunk && prevChunk != null) {
.forEach { overlapped -> currentChunk = handleMerge(roomEntity, direction, currentChunk, prevChunk)
currentChunk = handleMerge(roomEntity, direction, currentChunk, overlapped) } else if (currentChunk != nextChunk && nextChunk != null) {
} currentChunk = handleMerge(roomEntity, direction, currentChunk, nextChunk)
} else {
val newEventIds = receivedChunk.events.mapNotNull { it.eventId }
ChunkEntity
.findAllIncludingEvents(realm, newEventIds)
.filter { it != currentChunk }
.forEach { overlapped ->
currentChunk = handleMerge(roomEntity, direction, currentChunk, overlapped)
}
}
roomEntity.addOrUpdate(currentChunk)
roomEntity.addStateEvents(receivedChunk.stateEvents, isUnlinked = currentChunk.isUnlinked())
}
}
.map {
if (receivedChunk.events.isEmpty() && receivedChunk.stateEvents.isEmpty() && receivedChunk.start != receivedChunk.end) {
Result.SHOULD_FETCH_MORE
} else {
Result.SUCCESS
} }
roomEntity.addOrUpdate(currentChunk)
roomEntity.addStateEvents(receivedChunk.stateEvents, isUnlinked = currentChunk.isUnlinked())
} }
.map { true }
} }


private fun handleMerge(roomEntity: RoomEntity, private fun handleMerge(roomEntity: RoomEntity,
@ -157,6 +180,7 @@ internal class TokenChunkEventPersistor(private val monarchy: Monarchy) {
otherChunk: ChunkEntity): ChunkEntity { otherChunk: ChunkEntity): ChunkEntity {


// We always merge the bottom chunk into top chunk, so we are always merging backwards // 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) { return if (direction == PaginationDirection.BACKWARDS) {
currentChunk.merge(roomEntity.roomId, otherChunk, PaginationDirection.BACKWARDS) currentChunk.merge(roomEntity.roomId, otherChunk, PaginationDirection.BACKWARDS)
roomEntity.deleteOnCascade(otherChunk) roomEntity.deleteOnCascade(otherChunk)

View File

@ -28,22 +28,17 @@ import im.vector.matrix.android.internal.database.helper.addStateEvents
import im.vector.matrix.android.internal.database.helper.lastStateIndex import im.vector.matrix.android.internal.database.helper.lastStateIndex
import im.vector.matrix.android.internal.database.model.ChunkEntity 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.model.RoomEntity
import im.vector.matrix.android.internal.database.model.RoomSummaryEntity
import im.vector.matrix.android.internal.database.query.findLastLiveChunkFromRoom import im.vector.matrix.android.internal.database.query.findLastLiveChunkFromRoom
import im.vector.matrix.android.internal.database.query.where import im.vector.matrix.android.internal.database.query.where
import im.vector.matrix.android.internal.session.room.RoomSummaryUpdater
import im.vector.matrix.android.internal.session.room.timeline.PaginationDirection import im.vector.matrix.android.internal.session.room.timeline.PaginationDirection
import im.vector.matrix.android.internal.session.sync.model.InvitedRoomSync import im.vector.matrix.android.internal.session.sync.model.*
import im.vector.matrix.android.internal.session.sync.model.RoomSync
import im.vector.matrix.android.internal.session.sync.model.RoomSyncAccountData
import im.vector.matrix.android.internal.session.sync.model.RoomSyncEphemeral
import im.vector.matrix.android.internal.session.sync.model.RoomSyncSummary
import im.vector.matrix.android.internal.session.sync.model.RoomSyncUnreadNotifications
import im.vector.matrix.android.internal.session.sync.model.RoomsSyncResponse
import io.realm.Realm import io.realm.Realm
import io.realm.kotlin.createObject import io.realm.kotlin.createObject


internal class RoomSyncHandler(private val monarchy: Monarchy, internal class RoomSyncHandler(private val monarchy: Monarchy,
private val readReceiptHandler: ReadReceiptHandler, private val readReceiptHandler: ReadReceiptHandler,
private val roomSummaryUpdater: RoomSummaryUpdater,
private val roomTagHandler: RoomTagHandler) { private val roomTagHandler: RoomTagHandler) {


sealed class HandlingStrategy { sealed class HandlingStrategy {
@ -76,7 +71,7 @@ internal class RoomSyncHandler(private val monarchy: Monarchy,
roomSync: RoomSync): RoomEntity { roomSync: RoomSync): RoomEntity {


val roomEntity = RoomEntity.where(realm, roomId).findFirst() val roomEntity = RoomEntity.where(realm, roomId).findFirst()
?: realm.createObject(roomId) ?: realm.createObject(roomId)


if (roomEntity.membership == MyMembership.INVITED) { if (roomEntity.membership == MyMembership.INVITED) {
roomEntity.chunks.deleteAllFromRealm() roomEntity.chunks.deleteAllFromRealm()
@ -107,14 +102,7 @@ internal class RoomSyncHandler(private val monarchy: Monarchy,
) )
roomEntity.addOrUpdate(chunkEntity) roomEntity.addOrUpdate(chunkEntity)
} }

roomSummaryUpdater.update(realm, roomId, roomSync.summary, roomSync.unreadNotifications)
if (roomSync.summary != null) {
handleRoomSummary(realm, roomId, roomSync.summary)
}

if (roomSync.unreadNotifications != null) {
handleUnreadNotifications(realm, roomId, roomSync.unreadNotifications)
}


if (roomSync.ephemeral != null && roomSync.ephemeral.events.isNotEmpty()) { if (roomSync.ephemeral != null && roomSync.ephemeral.events.isNotEmpty()) {
handleEphemeral(realm, roomId, roomSync.ephemeral) handleEphemeral(realm, roomId, roomSync.ephemeral)
@ -164,32 +152,12 @@ internal class RoomSyncHandler(private val monarchy: Monarchy,
realm.createObject<ChunkEntity>().apply { this.prevToken = prevToken } realm.createObject<ChunkEntity>().apply { this.prevToken = prevToken }
} }


lastChunk?.isLast = false lastChunk?.isLastForward = false
chunkEntity.isLast = true chunkEntity.isLastForward = true
chunkEntity.addAll(roomId, eventList, PaginationDirection.FORWARDS, stateIndexOffset) chunkEntity.addAll(roomId, eventList, PaginationDirection.FORWARDS, stateIndexOffset)
return chunkEntity return chunkEntity
} }


private fun handleRoomSummary(realm: Realm,
roomId: String,
roomSummary: RoomSyncSummary) {

val roomSummaryEntity = RoomSummaryEntity.where(realm, roomId).findFirst()
?: RoomSummaryEntity(roomId)

if (roomSummary.heroes.isNotEmpty()) {
roomSummaryEntity.heroes.clear()
roomSummaryEntity.heroes.addAll(roomSummary.heroes)
}
if (roomSummary.invitedMembersCount != null) {
roomSummaryEntity.invitedMembersCount = roomSummary.invitedMembersCount
}
if (roomSummary.joinedMembersCount != null) {
roomSummaryEntity.joinedMembersCount = roomSummary.joinedMembersCount
}
realm.insertOrUpdate(roomSummaryEntity)
}

private fun handleEphemeral(realm: Realm, private fun handleEphemeral(realm: Realm,
roomId: String, roomId: String,
ephemeral: RoomSyncEphemeral) { ephemeral: RoomSyncEphemeral) {
@ -199,20 +167,6 @@ internal class RoomSyncHandler(private val monarchy: Monarchy,
.flatMap { readReceiptHandler.handle(realm, roomId, it) } .flatMap { readReceiptHandler.handle(realm, roomId, it) }
} }


private fun handleUnreadNotifications(realm: Realm, roomId: String, unreadNotifications: RoomSyncUnreadNotifications) {
val roomSummaryEntity = RoomSummaryEntity.where(realm, roomId).findFirst()
?: RoomSummaryEntity(roomId)

if (unreadNotifications.highlightCount != null) {
roomSummaryEntity.highlightCount = unreadNotifications.highlightCount
}
if (unreadNotifications.notificationCount != null) {
roomSummaryEntity.notificationCount = unreadNotifications.notificationCount
}
realm.insertOrUpdate(roomSummaryEntity)

}

private fun handleRoomAccountDataEvents(realm: Realm, roomId: String, accountData: RoomSyncAccountData) { private fun handleRoomAccountDataEvents(realm: Realm, roomId: String, accountData: RoomSyncAccountData) {
accountData.events accountData.events
.filter { it.type == EventType.TAG } .filter { it.type == EventType.TAG }

View File

@ -40,7 +40,7 @@ internal class SyncModule {
} }


scope(DefaultSession.SCOPE) { scope(DefaultSession.SCOPE) {
RoomSyncHandler(get(), get(), get()) RoomSyncHandler(get(), get(), get(), get())
} }


scope(DefaultSession.SCOPE) { scope(DefaultSession.SCOPE) {

View File

@ -19,6 +19,7 @@ package im.vector.matrix.android.internal.session.sync
import arrow.core.Try import arrow.core.Try
import im.vector.matrix.android.internal.session.sync.model.SyncResponse import im.vector.matrix.android.internal.session.sync.model.SyncResponse
import timber.log.Timber import timber.log.Timber
import kotlin.system.measureTimeMillis


internal class SyncResponseHandler(private val roomSyncHandler: RoomSyncHandler, internal class SyncResponseHandler(private val roomSyncHandler: RoomSyncHandler,
private val userAccountDataSyncHandler: UserAccountDataSyncHandler, private val userAccountDataSyncHandler: UserAccountDataSyncHandler,
@ -26,16 +27,19 @@ internal class SyncResponseHandler(private val roomSyncHandler: RoomSyncHandler,


fun handleResponse(syncResponse: SyncResponse, fromToken: String?, isCatchingUp: Boolean): Try<SyncResponse> { fun handleResponse(syncResponse: SyncResponse, fromToken: String?, isCatchingUp: Boolean): Try<SyncResponse> {
return Try { return Try {
Timber.v("Handle sync response") Timber.v("Start handling sync")
if (syncResponse.rooms != null) { val measure = measureTimeMillis {
roomSyncHandler.handle(syncResponse.rooms) if (syncResponse.rooms != null) {
} roomSyncHandler.handle(syncResponse.rooms)
if (syncResponse.groups != null) { }
groupSyncHandler.handle(syncResponse.groups) if (syncResponse.groups != null) {
} groupSyncHandler.handle(syncResponse.groups)
if (syncResponse.accountData != null) { }
userAccountDataSyncHandler.handle(syncResponse.accountData) if (syncResponse.accountData != null) {
userAccountDataSyncHandler.handle(syncResponse.accountData)
}
} }
Timber.v("Finish handling sync in $measure ms")
syncResponse syncResponse
} }
} }

View File

@ -42,7 +42,7 @@ internal class UserEntityUpdater(monarchy: Monarchy,


} }


override fun process(inserted: List<EventEntity>, updated: List<EventEntity>, deleted: List<EventEntity>) { override fun processChanges(inserted: List<EventEntity>, updated: List<EventEntity>, deleted: List<EventEntity>) {
val roomMembersEvents = inserted.map { it.eventId } val roomMembersEvents = inserted.map { it.eventId }
val taskParams = UpdateUserTask.Params(roomMembersEvents) val taskParams = UpdateUserTask.Params(roomMembersEvents)
updateUserTask updateUserTask

View File

@ -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.
* <p>
* 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)}.
* <p>
* It tracks a {@link Status} and an {@code error} for each {@link RequestType}.
* <p>
* A sample usage of this class to limit requests looks like this:
* <pre>
* class PagingBoundaryCallback extends PagedList.BoundaryCallback&lt;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&lt;ApiResponse>() {
* {@literal @}Override
* public void onResponse(Call&lt;ApiResponse> call,
* Response&lt;ApiResponse> response) {
* // TODO insert new records into database
* helperCallback.recordSuccess();
* }
*
* {@literal @}Override
* public void onFailure(Call&lt;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&lt;ApiResponse>() {
* {@literal @}Override
* public void onResponse(Call&lt;ApiResponse> call,
* Response&lt;ApiResponse> response) {
* // TODO insert new records into database
* helperCallback.recordSuccess();
* }
*
* {@literal @}Override
* public void onFailure(Call&lt;ApiResponse> call, Throwable t) {
* helperCallback.recordFailure(t);
* }
* }));
* }
* }
* </pre>
* <p>
* The helper provides an API to observe combined request status, which can be reported back to the
* application based on your business rules.
* <pre>
* MutableLiveData&lt;PagingRequestHelper.Status> combined = new MutableLiveData&lt;>();
* 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);
* }
* });
* </pre>
*/
// 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<Listener> 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.
* <p>
* 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}.
* <p>
* 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;
}
}
}