compose-lobsters/android/src/main/kotlin/dev/msfjarvis/claw/android/paging/CachingRemoteMediator.kt

176 lines
6.8 KiB
Kotlin

/*
* Copyright © 2024 Harsh Shandilya.
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE file or at
* https://opensource.org/licenses/MIT.
*/
package dev.msfjarvis.claw.android.paging
import androidx.paging.ExperimentalPagingApi
import androidx.paging.LoadType
import androidx.paging.PagingState
import androidx.paging.RemoteMediator
import com.slack.eithernet.ApiResult.Failure.ApiFailure
import com.slack.eithernet.ApiResult.Failure.HttpFailure
import com.slack.eithernet.ApiResult.Failure.NetworkFailure
import com.slack.eithernet.ApiResult.Failure.UnknownFailure
import com.slack.eithernet.ApiResult.Success
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dev.msfjarvis.claw.android.paging.LobstersPagingSource.Companion.STARTING_PAGE_INDEX
import dev.msfjarvis.claw.android.viewmodel.ReadPostsRepository
import dev.msfjarvis.claw.android.viewmodel.SavedPostsRepository
import dev.msfjarvis.claw.core.injection.DatabaseDispatcher
import dev.msfjarvis.claw.core.injection.IODispatcher
import dev.msfjarvis.claw.database.local.NewestPosts
import dev.msfjarvis.claw.database.local.NewestPostsKeys
import dev.msfjarvis.claw.database.local.NewestPostsKeysQueries
import dev.msfjarvis.claw.database.local.NewestPostsQueries
import dev.msfjarvis.claw.database.local.SavedPost
import dev.msfjarvis.claw.model.LobstersPost
import dev.msfjarvis.claw.model.UIPost
import java.io.IOException
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
@OptIn(ExperimentalPagingApi::class)
class CachingRemoteMediator
@AssistedInject
constructor(
@Assisted private val remoteFetcher: RemoteFetcher<LobstersPost>,
@IODispatcher private val ioDispatcher: CoroutineDispatcher,
@DatabaseDispatcher private val databaseDispatcher: CoroutineDispatcher,
private val savedPostsRepository: SavedPostsRepository,
private val readPostsRepository: ReadPostsRepository,
private val postsDatabase: NewestPostsQueries,
private val postKeysDatabase: NewestPostsKeysQueries,
) : RemoteMediator<Int, UIPost>() {
private val cacheTimeout = 10.seconds
private lateinit var savedPosts: List<String>
private lateinit var readPosts: List<String>
override suspend fun initialize(): InitializeAction {
savedPosts = savedPostsRepository.savedPosts.first().map(SavedPost::shortId)
readPosts = readPostsRepository.readPosts.first()
val lastAddition =
postsDatabase.getTimeOfLastAddition().executeAsOneOrNull()?.toLongOrNull()?.milliseconds
val currentDuration = System.currentTimeMillis().milliseconds
return if (lastAddition != null && currentDuration - lastAddition < cacheTimeout) {
InitializeAction.SKIP_INITIAL_REFRESH
} else {
InitializeAction.LAUNCH_INITIAL_REFRESH
}
}
override suspend fun load(loadType: LoadType, state: PagingState<Int, UIPost>): MediatorResult {
val page =
when (loadType) {
LoadType.REFRESH -> {
val remoteKeys = getRemoteKeyClosestToCurrentPosition(state)
remoteKeys?.nextKey?.minus(1) ?: STARTING_PAGE_INDEX
}
LoadType.PREPEND -> {
val remoteKeys = getRemoteKeyForFirstItem(state)
val prevKey =
remoteKeys?.prevKey
?: return MediatorResult.Success(endOfPaginationReached = remoteKeys != null)
prevKey
}
LoadType.APPEND -> {
val remoteKeys = getRemoteKeyForLastItem(state)
val nextKey =
remoteKeys?.nextKey
?: return MediatorResult.Success(endOfPaginationReached = remoteKeys != null)
nextKey
}
}
return when (val result = withContext(ioDispatcher) { remoteFetcher.getItemsAtPage(page) }) {
is Success -> {
val items = result.value
val endOfPaginationReached = items.isEmpty()
withContext(databaseDispatcher) {
if (loadType == LoadType.REFRESH) {
postsDatabase.clearCache()
postKeysDatabase.clearRemoteKeys()
}
val prevKey = if (page == STARTING_PAGE_INDEX) null else page - 1
val nextKey = if (endOfPaginationReached) null else page + 1
val posts =
items.map { item ->
NewestPosts(
shortId = item.shortId,
createdAt = item.createdAt,
title = item.title,
url = item.url,
commentCount = item.commentCount,
description = item.description,
commentsUrl = item.commentsUrl,
submitter = item.submitter,
tags = item.tags,
isRead = readPosts.contains(item.shortId),
isSaved = savedPosts.contains(item.shortId),
insertTimestamp = null,
)
}
val keys =
items.map { item ->
NewestPostsKeys(shortId = item.shortId, prevKey = prevKey, nextKey = nextKey)
}
postsDatabase.transaction { posts.forEach(postsDatabase::addCachedPost) }
postKeysDatabase.transaction { keys.forEach(postKeysDatabase::addRemoteKey) }
}
MediatorResult.Success(endOfPaginationReached = endOfPaginationReached)
}
is NetworkFailure -> MediatorResult.Error(result.error)
is UnknownFailure -> MediatorResult.Error(result.error)
is ApiFailure,
is HttpFailure -> MediatorResult.Error(IOException("API returned an invalid response"))
}
}
private suspend fun getRemoteKeyForFirstItem(state: PagingState<Int, UIPost>): NewestPostsKeys? {
return state.pages
.firstOrNull { it.data.isNotEmpty() }
?.data
?.firstOrNull()
?.let { post ->
withContext(databaseDispatcher) {
postKeysDatabase.getRemoteKey(post.shortId).executeAsOneOrNull()
}
}
}
private suspend fun getRemoteKeyClosestToCurrentPosition(
state: PagingState<Int, UIPost>
): NewestPostsKeys? {
return state.anchorPosition?.let { position ->
state.closestItemToPosition(position)?.shortId?.let { shortId ->
withContext(databaseDispatcher) {
postKeysDatabase.getRemoteKey(shortId).executeAsOneOrNull()
}
}
}
}
private suspend fun getRemoteKeyForLastItem(state: PagingState<Int, UIPost>): NewestPostsKeys? {
return state.pages
.lastOrNull { it.data.isNotEmpty() }
?.data
?.lastOrNull()
?.let { post ->
withContext(databaseDispatcher) {
postKeysDatabase.getRemoteKey(post.shortId).executeAsOneOrNull()
}
}
}
@AssistedFactory
interface Factory {
fun create(remoteFetcher: RemoteFetcher<LobstersPost>): CachingRemoteMediator
}
}