diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/HomeTimelineRemoteMediator.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/HomeTimelineRemoteMediator.kt index 206af51022..24297423cc 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/HomeTimelineRemoteMediator.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/HomeTimelineRemoteMediator.kt @@ -4,6 +4,7 @@ import androidx.paging.ExperimentalPagingApi import dev.dimension.flare.data.datasource.microblog.paging.CacheableRemoteLoader import dev.dimension.flare.data.datasource.microblog.paging.PagingRequest import dev.dimension.flare.data.datasource.microblog.paging.PagingResult +import dev.dimension.flare.data.datasource.microblog.paging.RefreshBehavior import dev.dimension.flare.data.network.mastodon.MastodonService import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.ui.model.UiTimelineV2 @@ -19,6 +20,9 @@ internal class HomeTimelineRemoteMediator( override val supportPrepend: Boolean get() = true + override val refreshBehavior: RefreshBehavior + get() = RefreshBehavior.MergeTop + override suspend fun load( pageSize: Int, request: PagingRequest, diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/ListTimelineRemoteMediator.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/ListTimelineRemoteMediator.kt index c15b69dc85..cf32891d56 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/ListTimelineRemoteMediator.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/ListTimelineRemoteMediator.kt @@ -4,6 +4,7 @@ import androidx.paging.ExperimentalPagingApi import dev.dimension.flare.data.datasource.microblog.paging.CacheableRemoteLoader import dev.dimension.flare.data.datasource.microblog.paging.PagingRequest import dev.dimension.flare.data.datasource.microblog.paging.PagingResult +import dev.dimension.flare.data.datasource.microblog.paging.RefreshBehavior import dev.dimension.flare.data.network.mastodon.MastodonService import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.ui.model.UiTimelineV2 @@ -20,6 +21,9 @@ internal class ListTimelineRemoteMediator( override val supportPrepend: Boolean get() = true + override val refreshBehavior: RefreshBehavior + get() = RefreshBehavior.MergeTop + override suspend fun load( pageSize: Int, request: PagingRequest, diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/MixedRemoteMediator.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/MixedRemoteMediator.kt index 8fdaf858f4..8758c7b179 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/MixedRemoteMediator.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/MixedRemoteMediator.kt @@ -6,6 +6,7 @@ import dev.dimension.flare.data.database.cache.connect import dev.dimension.flare.data.datasource.microblog.paging.CacheableRemoteLoader import dev.dimension.flare.data.datasource.microblog.paging.PagingRequest import dev.dimension.flare.data.datasource.microblog.paging.PagingResult +import dev.dimension.flare.data.datasource.microblog.paging.RefreshBehavior import dev.dimension.flare.data.datasource.microblog.paging.ReportableRemoteLoader import dev.dimension.flare.ui.model.UiTimelineV2 import kotlinx.coroutines.async @@ -52,7 +53,7 @@ internal class MixedRemoteMediator( reportError?.invoke(it) PagingResult(endOfPaginationReached = true) }.let { - SubResponse(subRequest.mediator, it) + SubResponse(subRequest.mediator, subRequest.request, it) } } }.awaitAll() @@ -72,7 +73,7 @@ internal class MixedRemoteMediator( database.connect { response.forEach { - saveSubResponse(request, it) + saveSubResponse(it) } } @@ -114,16 +115,23 @@ internal class MixedRemoteMediator( ?.prevKey ?.let(PagingRequest::Prepend) - is PagingRequest.Refresh -> PagingRequest.Refresh + is PagingRequest.Refresh -> + if (mediator.refreshBehavior == RefreshBehavior.MergeTop && mediator.supportPrepend) { + database + .pagingTimelineDao() + .getPagingKey(subKey(mediator)) + ?.prevKey + ?.let(PagingRequest::Prepend) + ?: PagingRequest.Refresh + } else { + PagingRequest.Refresh + } }?.let { SubRequest(mediator, it) } - private suspend fun saveSubResponse( - request: PagingRequest, - subResponse: SubResponse, - ) { - val (mediator, result) = subResponse + private suspend fun saveSubResponse(subResponse: SubResponse) { + val (mediator, request, result) = subResponse if (request is PagingRequest.Prepend && result.previousKey != null) { database.pagingTimelineDao().updatePagingKeyPrevKey( pagingKey = subKey(mediator), @@ -157,6 +165,7 @@ internal class MixedRemoteMediator( private data class SubResponse( val mediator: CacheableRemoteLoader, + val request: PagingRequest, val result: PagingResult, ) } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/paging/BasePagingRemoteMediator.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/paging/BasePagingRemoteMediator.kt index 513eac7e12..db8ea3f1c1 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/paging/BasePagingRemoteMediator.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/paging/BasePagingRemoteMediator.kt @@ -63,26 +63,7 @@ internal abstract class BasePagingRemoteMediator( request = request, ) database.connect { - if (loadType == LoadType.REFRESH) { - database.pagingTimelineDao().deletePagingKey(pagingKey) - database.pagingTimelineDao().insertPagingKey( - DbPagingKey( - pagingKey = pagingKey, - nextKey = result.nextKey, - prevKey = result.previousKey, - ), - ) - } else if (loadType == LoadType.PREPEND && result.previousKey != null) { - database.pagingTimelineDao().updatePagingKeyPrevKey( - pagingKey = pagingKey, - prevKey = result.previousKey, - ) - } else if (loadType == LoadType.APPEND && result.nextKey != null) { - database.pagingTimelineDao().updatePagingKeyNextKey( - pagingKey = pagingKey, - nextKey = result.nextKey, - ) - } + updatePagingKeys(loadType, request, result) onSaveCache(request, result.data) } return MediatorResult.Success( @@ -100,6 +81,33 @@ internal abstract class BasePagingRemoteMediator( request: PagingRequest, ): PagingResult + protected open suspend fun updatePagingKeys( + loadType: LoadType, + request: PagingRequest, + result: PagingResult, + ) { + if (loadType == LoadType.REFRESH) { + database.pagingTimelineDao().deletePagingKey(pagingKey) + database.pagingTimelineDao().insertPagingKey( + DbPagingKey( + pagingKey = pagingKey, + nextKey = result.nextKey, + prevKey = result.previousKey, + ), + ) + } else if (loadType == LoadType.PREPEND && result.previousKey != null) { + database.pagingTimelineDao().updatePagingKeyPrevKey( + pagingKey = pagingKey, + prevKey = result.previousKey, + ) + } else if (loadType == LoadType.APPEND && result.nextKey != null) { + database.pagingTimelineDao().updatePagingKeyNextKey( + pagingKey = pagingKey, + nextKey = result.nextKey, + ) + } + } + protected abstract suspend fun onSaveCache( request: PagingRequest, data: List, diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/paging/CacheableRemoteLoader.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/paging/CacheableRemoteLoader.kt index 84cc4caac8..5a6e9ebb0a 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/paging/CacheableRemoteLoader.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/paging/CacheableRemoteLoader.kt @@ -1,9 +1,16 @@ package dev.dimension.flare.data.datasource.microblog.paging +internal enum class RefreshBehavior { + Replace, + MergeTop, +} + internal interface CacheableRemoteLoader : RemoteLoader { val pagingKey: String val supportPrepend: Boolean get() = false + val refreshBehavior: RefreshBehavior + get() = RefreshBehavior.Replace } internal interface ReportableRemoteLoader { diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/paging/TimelineRemoteMediator.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/paging/TimelineRemoteMediator.kt index 1177ac6965..64021da656 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/paging/TimelineRemoteMediator.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/paging/TimelineRemoteMediator.kt @@ -1,9 +1,11 @@ package dev.dimension.flare.data.datasource.microblog.paging import androidx.paging.ExperimentalPagingApi +import androidx.paging.LoadType import dev.dimension.flare.common.SnowflakeIdGenerator import dev.dimension.flare.data.database.cache.CacheDatabase import dev.dimension.flare.data.database.cache.mapper.saveToDatabase +import dev.dimension.flare.data.database.cache.model.DbPagingKey import dev.dimension.flare.data.database.cache.model.DbPagingTimelineWithStatus import dev.dimension.flare.data.database.cache.model.DbStatusWithReference import dev.dimension.flare.data.translation.NoopPreTranslationService @@ -27,6 +29,14 @@ internal class TimelineRemoteMediator( override val pagingKey: String get() = loader.pagingKey + private var prependCapabilityState = + when { + !loader.supportPrepend -> PrependCapabilityState.Unsupported + loader.refreshBehavior == RefreshBehavior.MergeTop -> PrependCapabilityState.Unknown + else -> PrependCapabilityState.Supported + } + private var lastSaveMode: SaveMode = SaveMode.Replace + init { if (loader is ReportableRemoteLoader) { loader.reportError = notifyError @@ -53,10 +63,17 @@ internal class TimelineRemoteMediator( request: PagingRequest, ): PagingResult { val result = - timeline( - pageSize = pageSize, - request = request, - ) + when (request) { + PagingRequest.Refresh -> refreshTimeline(pageSize) + is PagingRequest.Prepend -> prependTimeline(pageSize, request.previousKey) + is PagingRequest.Append -> { + lastSaveMode = SaveMode.AppendBottom + timeline( + pageSize = pageSize, + request = request, + ) + } + } val data = result.data.map { TimelinePagingMapper.toDb( @@ -71,6 +88,36 @@ internal class TimelineRemoteMediator( ) } + override suspend fun updatePagingKeys( + loadType: LoadType, + request: PagingRequest, + result: PagingResult, + ) { + when { + loadType == LoadType.REFRESH && lastSaveMode == SaveMode.MergeTop -> { + val existing = database.pagingTimelineDao().getPagingKey(pagingKey) + if (existing == null) { + database.pagingTimelineDao().insertPagingKey( + DbPagingKey( + pagingKey = pagingKey, + nextKey = result.nextKey, + prevKey = result.previousKey, + ), + ) + } else { + result.previousKey?.let { + database.pagingTimelineDao().updatePagingKeyPrevKey( + pagingKey = pagingKey, + prevKey = it, + ) + } + } + } + + else -> super.updatePagingKeys(loadType, request, result) + } + } + suspend fun timeline( pageSize: Int, request: PagingRequest, @@ -89,14 +136,14 @@ internal class TimelineRemoteMediator( request: PagingRequest, data: List, ) { - if (request is PagingRequest.Refresh) { + if (lastSaveMode == SaveMode.Replace && request is PagingRequest.Refresh) { data.groupBy { it.timeline.pagingKey }.keys.forEach { key -> database .pagingTimelineDao() .delete(pagingKey = key) } } - if (request is PagingRequest.Prepend && loader.supportPrepend) { + if (lastSaveMode == SaveMode.MergeTop && data.isNotEmpty()) { // load current timeline caches val currentCaches = database @@ -121,8 +168,154 @@ internal class TimelineRemoteMediator( allowLongText = allowLongText, ) } + + private suspend fun refreshTimeline(pageSize: Int): PagingResult { + val existingPagingKey = database.pagingTimelineDao().getPagingKey(pagingKey) + if (loader.refreshBehavior != RefreshBehavior.MergeTop || existingPagingKey?.prevKey == null) { + lastSaveMode = SaveMode.Replace + return timeline( + pageSize = pageSize, + request = PagingRequest.Refresh, + ) + } + val mergeResult = runTopMerge(pageSize = pageSize, startKey = existingPagingKey.prevKey) + return when (mergeResult) { + is TopMergeResult.Success -> { + lastSaveMode = SaveMode.MergeTop + prependCapabilityState = PrependCapabilityState.Supported + mergeResult.result + } + + TopMergeResult.Failure -> { + prependCapabilityState = PrependCapabilityState.Unsupported + lastSaveMode = SaveMode.Replace + timeline( + pageSize = pageSize, + request = PagingRequest.Refresh, + ) + } + } + } + + private suspend fun prependTimeline( + pageSize: Int, + previousKey: String, + ): PagingResult { + if (!loader.supportPrepend || prependCapabilityState != PrependCapabilityState.Supported) { + lastSaveMode = SaveMode.MergeTop + return PagingResult(endOfPaginationReached = true) + } + return when (val mergeResult = runTopMerge(pageSize = pageSize, startKey = previousKey)) { + is TopMergeResult.Success -> { + lastSaveMode = SaveMode.MergeTop + mergeResult.result + } + + TopMergeResult.Failure -> { + prependCapabilityState = PrependCapabilityState.Unsupported + lastSaveMode = SaveMode.MergeTop + PagingResult(endOfPaginationReached = true) + } + } + } + + private suspend fun runTopMerge( + pageSize: Int, + startKey: String, + ): TopMergeResult { + val existingKeys = + database + .pagingTimelineDao() + .getByPagingKey(pagingKey) + .mapTo(mutableSetOf()) { it.statusKey.toString() } + val merged = mutableListOf() + val seenNewKeys = mutableSetOf() + var currentKey = startKey + var newTopKey: String? = null + val mergePageSize = pageSize.coerceAtLeast(MIN_TOP_MERGE_PAGE_SIZE).coerceAtMost(MAX_TOP_MERGE_PAGE_SIZE) + repeat(MAX_TOP_MERGE_PAGES) { + val response = + timeline( + pageSize = mergePageSize, + request = PagingRequest.Prepend(currentKey), + ) + if (!response.data.isSortedDescendingByCreatedAt()) { + return TopMergeResult.Failure + } + val filtered = + response.data.filterNot { item -> + val key = item.toStableTimelineKey() + item.statusKey.id == currentKey || item.statusKey.toString() in existingKeys || !seenNewKeys.add(key) + } + if (response.data.isNotEmpty() && filtered.isEmpty()) { + return TopMergeResult.Failure + } + if (newTopKey == null) { + newTopKey = filtered.firstOrNull()?.statusKey?.id + } + merged += filtered + if ( + response.previousKey == null || + response.previousKey == currentKey || + response.data.size < mergePageSize || + merged.size >= MAX_TOP_MERGE_ITEMS + ) { + return TopMergeResult.Success( + PagingResult( + data = merged, + nextKey = response.nextKey, + previousKey = newTopKey ?: startKey, + endOfPaginationReached = response.previousKey == null, + ), + ) + } + currentKey = response.previousKey + } + return if (merged.isEmpty()) { + TopMergeResult.Failure + } else { + TopMergeResult.Success( + PagingResult( + data = merged, + previousKey = newTopKey ?: startKey, + ), + ) + } + } +} + +private const val MIN_TOP_MERGE_PAGE_SIZE = 60 +private const val MAX_TOP_MERGE_PAGE_SIZE = 80 +private const val MAX_TOP_MERGE_PAGES = 5 +private const val MAX_TOP_MERGE_ITEMS = 200 + +private enum class SaveMode { + Replace, + MergeTop, + AppendBottom, +} + +private enum class PrependCapabilityState { + Unknown, + Supported, + Unsupported, +} + +private sealed interface TopMergeResult { + data class Success( + val result: PagingResult, + ) : TopMergeResult + + data object Failure : TopMergeResult } +private fun UiTimelineV2.toStableTimelineKey(): String = itemKey ?: "${accountType}_$statusKey" + +private fun List.isSortedDescendingByCreatedAt(): Boolean = + zipWithNext().all { (left, right) -> + left.createdAt.value >= right.createdAt.value + } + private fun List.collapseReplyChains(): List { val rootPosts = asSequence() diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/HomeTimelineRemoteMediator.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/HomeTimelineRemoteMediator.kt index 88d7ca207c..b83b2c062e 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/HomeTimelineRemoteMediator.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/HomeTimelineRemoteMediator.kt @@ -4,6 +4,7 @@ import androidx.paging.ExperimentalPagingApi import dev.dimension.flare.data.datasource.microblog.paging.CacheableRemoteLoader import dev.dimension.flare.data.datasource.microblog.paging.PagingRequest import dev.dimension.flare.data.datasource.microblog.paging.PagingResult +import dev.dimension.flare.data.datasource.microblog.paging.RefreshBehavior import dev.dimension.flare.data.network.misskey.MisskeyService import dev.dimension.flare.data.network.misskey.api.model.NotesHybridTimelineRequest import dev.dimension.flare.model.MicroBlogKey @@ -20,6 +21,9 @@ internal class HomeTimelineRemoteMediator( override val supportPrepend: Boolean get() = true + override val refreshBehavior: RefreshBehavior + get() = RefreshBehavior.MergeTop + override suspend fun load( pageSize: Int, request: PagingRequest, diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/ListTimelineRemoteMediator.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/ListTimelineRemoteMediator.kt index d74533c43b..e7e3ec14a7 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/ListTimelineRemoteMediator.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/ListTimelineRemoteMediator.kt @@ -4,6 +4,7 @@ import androidx.paging.ExperimentalPagingApi import dev.dimension.flare.data.datasource.microblog.paging.CacheableRemoteLoader import dev.dimension.flare.data.datasource.microblog.paging.PagingRequest import dev.dimension.flare.data.datasource.microblog.paging.PagingResult +import dev.dimension.flare.data.datasource.microblog.paging.RefreshBehavior import dev.dimension.flare.data.network.misskey.MisskeyService import dev.dimension.flare.data.network.misskey.api.model.NotesUserListTimelineRequest import dev.dimension.flare.model.MicroBlogKey @@ -21,6 +22,9 @@ internal class ListTimelineRemoteMediator( override val supportPrepend: Boolean get() = true + override val refreshBehavior: RefreshBehavior + get() = RefreshBehavior.MergeTop + override suspend fun load( pageSize: Int, request: PagingRequest, diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datastore/model/AiPromptDefaults.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datastore/model/AiPromptDefaults.kt index 2b6e3f4c59..cd728a7a1d 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datastore/model/AiPromptDefaults.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datastore/model/AiPromptDefaults.kt @@ -10,7 +10,8 @@ internal object AiPromptDefaults { "Inline markers like {{T0}} and {{L1}} are control markers.\n" + "Keep every control marker exactly unchanged and in the same order.\n" + "Translate every natural-language segment that appears after a {{Tn}} marker into natural {target_language}.\n" + - "Copying the original source text after a {{Tn}} marker is wrong unless that segment is already naturally written in {target_language}.\n" + + "Copying the original source text after a {{Tn}} marker is wrong " + + "unless that segment is already naturally written in {target_language}.\n" + "If you are unsure, still provide your best translation in {target_language} instead of leaving the source text unchanged.\n" + "Do not add any text after a {{Ln}} marker.\n" + "For item headers, use S only when the source text is already in {target_language}; otherwise keep C and translate.\n" + diff --git a/shared/src/commonTest/kotlin/dev/dimension/flare/data/datasource/microblog/MixedRemoteMediatorTest.kt b/shared/src/commonTest/kotlin/dev/dimension/flare/data/datasource/microblog/MixedRemoteMediatorTest.kt index b86dd43fa2..da548603cc 100644 --- a/shared/src/commonTest/kotlin/dev/dimension/flare/data/datasource/microblog/MixedRemoteMediatorTest.kt +++ b/shared/src/commonTest/kotlin/dev/dimension/flare/data/datasource/microblog/MixedRemoteMediatorTest.kt @@ -15,12 +15,14 @@ import dev.dimension.flare.common.decodeJson import dev.dimension.flare.common.encodeJson import dev.dimension.flare.createTestRootPath import dev.dimension.flare.data.database.cache.CacheDatabase +import dev.dimension.flare.data.database.cache.model.DbStatusWithReference import dev.dimension.flare.data.database.cache.model.TranslationDisplayOptions import dev.dimension.flare.data.database.cache.model.TranslationEntityType import dev.dimension.flare.data.database.cache.model.TranslationStatus import dev.dimension.flare.data.datasource.microblog.paging.CacheableRemoteLoader import dev.dimension.flare.data.datasource.microblog.paging.PagingRequest import dev.dimension.flare.data.datasource.microblog.paging.PagingResult +import dev.dimension.flare.data.datasource.microblog.paging.RefreshBehavior import dev.dimension.flare.data.datasource.microblog.paging.TimelinePagingMapper import dev.dimension.flare.data.datasource.microblog.paging.TimelineRemoteMediator import dev.dimension.flare.data.datastore.AppDataStore @@ -130,6 +132,97 @@ class MixedRemoteMediatorTest : RobolectricTest() { assertEquals(0, second.requests.size) } + @OptIn(ExperimentalPagingApi::class) + @Test + fun mergeTopRefreshUsesPrependAndKeepsExistingItems() = + runTest { + val loader = + FakeLoader( + pagingKey = "home", + supportPrepend = true, + refreshBehavior = RefreshBehavior.MergeTop, + ) { request -> + when (request) { + PagingRequest.Refresh -> + PagingResult( + data = listOf(feed("https://example.com/old", 1_000L)), + previousKey = "old", + ) + + is PagingRequest.Prepend -> { + assertEquals("old", request.previousKey) + PagingResult( + data = listOf(feed("https://example.com/new", 2_000L)), + previousKey = "new", + ) + } + + is PagingRequest.Append -> error("No append expected") + } + } + val mediator = TimelineRemoteMediator(loader = loader, database = db, allowLongText = false) + + mediator.load( + loadType = LoadType.REFRESH, + state = testPagingState(), + ) + mediator.load( + loadType = LoadType.REFRESH, + state = testPagingState(), + ) + + assertEquals( + listOf(PagingRequest.Refresh, PagingRequest.Prepend("old")), + loader.requests, + ) + assertEquals( + listOf( + feed("https://example.com/new", 2_000L).statusKey, + feed("https://example.com/old", 1_000L).statusKey, + ), + db.pagingTimelineDao().getByPagingKey("home").map { it.statusKey }, + ) + assertEquals(feed("https://example.com/new", 2_000L).statusKey.id, db.pagingTimelineDao().getPagingKey("home")?.prevKey) + } + + @OptIn(ExperimentalPagingApi::class) + @Test + fun invalidMergeTopResponseDisablesLaterPrependForCurrentMediator() = + runTest { + val loader = + FakeLoader( + pagingKey = "home", + supportPrepend = true, + refreshBehavior = RefreshBehavior.MergeTop, + ) { request -> + when (request) { + PagingRequest.Refresh -> + PagingResult( + data = listOf(feed("https://example.com/old", 1_000L)), + previousKey = "old", + ) + + is PagingRequest.Prepend -> + PagingResult( + data = listOf(feed("https://example.com/old", 1_000L)), + previousKey = "old", + ) + + is PagingRequest.Append -> error("No append expected") + } + } + val mediator = TimelineRemoteMediator(loader = loader, database = db, allowLongText = false) + + mediator.load(loadType = LoadType.REFRESH, state = testPagingState()) + mediator.load(loadType = LoadType.REFRESH, state = testPagingState()) + mediator.load(loadType = LoadType.PREPEND, state = testPagingState()) + + assertEquals( + listOf(PagingRequest.Refresh, PagingRequest.Prepend("old"), PagingRequest.Refresh), + loader.requests, + ) + } + @Test fun appendUsesRemainingMediatorsAndRefreshResetsMediatorSet() = runTest { @@ -218,6 +311,64 @@ class MixedRemoteMediatorTest : RobolectricTest() { assertNotNull(db.pagingTimelineDao().getPagingKey("mixed_healthy")) } + @Test + fun mixedRefreshUsesSubTimelinePrevKeyForMergeTopLoaders() = + runTest { + val mergeTop = + FakeLoader( + pagingKey = "merge_top", + supportPrepend = true, + refreshBehavior = RefreshBehavior.MergeTop, + ) { request -> + when (request) { + PagingRequest.Refresh -> + PagingResult( + data = listOf(feed("https://example.com/merge_refresh", 1_000L)), + previousKey = "merge_prev", + nextKey = "merge_next", + ) + + is PagingRequest.Prepend -> { + assertEquals("merge_prev", request.previousKey) + PagingResult( + data = listOf(feed("https://example.com/merge_prepend", 2_000L)), + previousKey = "merge_new_prev", + nextKey = "merge_next", + ) + } + + is PagingRequest.Append -> error("No append expected") + } + } + val replace = + FakeLoader("replace") { request -> + when (request) { + PagingRequest.Refresh -> + PagingResult( + data = listOf(feed("https://example.com/replace_refresh", 500L)), + nextKey = null, + ) + + is PagingRequest.Append -> error("No append expected") + is PagingRequest.Prepend -> error("Replace loader should not prepend on refresh") + } + } + val mediator = MixedRemoteMediator(db, listOf(mergeTop, replace)) + + mediator.load(pageSize = 20, request = PagingRequest.Refresh) + mediator.load(pageSize = 20, request = PagingRequest.Refresh) + + assertEquals( + listOf(PagingRequest.Refresh, PagingRequest.Prepend("merge_prev")), + mergeTop.requests, + ) + assertEquals( + listOf(PagingRequest.Refresh, PagingRequest.Refresh), + replace.requests, + ) + assertEquals("merge_new_prev", db.pagingTimelineDao().getPagingKey("mixed_merge_top")?.prevKey) + } + @OptIn(ExperimentalPagingApi::class) @Test fun refreshWithMultipleItemsPerSubPersistsSortedOrderInDatabase() = @@ -1184,6 +1335,8 @@ class MixedRemoteMediatorTest : RobolectricTest() { private class FakeLoader( override val pagingKey: String, + override val supportPrepend: Boolean = false, + override val refreshBehavior: RefreshBehavior = RefreshBehavior.Replace, private val onLoad: suspend (PagingRequest) -> PagingResult, ) : CacheableRemoteLoader { val requests = mutableListOf() @@ -1197,6 +1350,14 @@ class MixedRemoteMediatorTest : RobolectricTest() { } } + private fun testPagingState() = + PagingState( + pages = emptyList(), + anchorPosition = null, + config = PagingConfig(pageSize = 20), + leadingPlaceholderCount = 0, + ) + private fun feed( url: String, createdAtEpochMs: Long,