feat(android): wire up a RemoteMediator to cache newest posts

This commit is contained in:
Harsh Shandilya 2024-04-21 23:54:13 +05:30
parent e2af08aa12
commit ef0c0ed440
5 changed files with 195 additions and 3 deletions

View File

@ -0,0 +1,175 @@
/*
* 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
}
}

View File

@ -44,14 +44,15 @@ import kotlinx.coroutines.flow.MutableStateFlow
@Composable
fun NetworkPosts(
lazyPagingItems: LazyPagingItems<UIPost>,
refresh: () -> Unit,
listState: LazyListState,
postActions: PostActions,
modifier: Modifier = Modifier,
) {
ReportDrawnWhen { lazyPagingItems.itemCount > 0 }
val refreshLoadState = lazyPagingItems.loadState.refresh
val isRefreshing = refreshLoadState == LoadState.Loading && lazyPagingItems.itemCount == 0
val pullRefreshState = rememberPullRefreshState(isRefreshing, lazyPagingItems::refresh)
val isRefreshing = refreshLoadState == LoadState.Loading
val pullRefreshState = rememberPullRefreshState(isRefreshing, refresh)
Box(modifier = modifier.fillMaxSize().pullRefresh(pullRefreshState)) {
if (lazyPagingItems.itemCount == 0 && refreshLoadState is LoadState.Error) {
NetworkError(
@ -105,6 +106,7 @@ private fun ListPreview() {
LobstersTheme {
NetworkPosts(
lazyPagingItems = flow.collectAsLazyPagingItems(),
refresh = {},
listState = rememberLazyListState(),
postActions = TEST_POST_ACTIONS,
)

View File

@ -47,6 +47,7 @@ fun SearchList(
)
NetworkPosts(
lazyPagingItems = lazyPagingItems,
refresh = { lazyPagingItems.refresh() },
listState = listState,
postActions = postActions,
)

View File

@ -230,6 +230,7 @@ fun LobstersPostsScreen(
setWebUri("https://lobste.rs/")
NetworkPosts(
lazyPagingItems = hottestPosts,
refresh = { hottestPosts.refresh() },
listState = hottestListState,
postActions = postActions,
)
@ -238,6 +239,7 @@ fun LobstersPostsScreen(
setWebUri("https://lobste.rs/")
NetworkPosts(
lazyPagingItems = newestPosts,
refresh = { viewModel.refreshNewestPosts() },
listState = newestListState,
postActions = postActions,
)

View File

@ -16,6 +16,7 @@ import androidx.compose.ui.text.intl.Locale
import androidx.glance.appwidget.updateAll
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.ExperimentalPagingApi
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.cachedIn
@ -25,6 +26,7 @@ import com.slack.eithernet.ApiResult.Failure
import com.slack.eithernet.ApiResult.Success
import com.squareup.anvil.annotations.optional.ForScope
import dev.msfjarvis.claw.android.glance.SavedPostsWidget
import dev.msfjarvis.claw.android.paging.CachingRemoteMediator
import dev.msfjarvis.claw.android.paging.LobstersPagingSource
import dev.msfjarvis.claw.android.paging.LobstersPagingSource.Companion.PAGE_SIZE
import dev.msfjarvis.claw.android.paging.LobstersPagingSource.Companion.STARTING_PAGE_INDEX
@ -64,10 +66,12 @@ constructor(
private val dataTransferRepository: DataTransferRepository,
private val pagingSourceFactory: LobstersPagingSource.Factory,
private val searchPagingSourceFactory: SearchPagingSource.Factory,
cachingMediatorFactory: CachingRemoteMediator.Factory,
@IODispatcher private val ioDispatcher: CoroutineDispatcher,
@MainDispatcher private val mainDispatcher: CoroutineDispatcher,
@ForScope(ApplicationScope::class) context: Context,
) : AndroidViewModel(context as Application) {
private var cachingPagingSource: LobstersPagingSource? = null
val hottestPosts =
Pager(
config = PagingConfig(pageSize = PAGE_SIZE),
@ -76,11 +80,15 @@ constructor(
)
.flow
.cachedIn(viewModelScope)
@OptIn(ExperimentalPagingApi::class)
val newestPosts =
Pager(
config = PagingConfig(pageSize = PAGE_SIZE),
remoteMediator = cachingMediatorFactory.create(api::getNewestPosts),
initialKey = STARTING_PAGE_INDEX,
pagingSourceFactory = { pagingSourceFactory.create(api::getNewestPosts) },
pagingSourceFactory = {
pagingSourceFactory.create(api::getNewestPosts).also { cachingPagingSource = it }
},
)
.flow
.cachedIn(viewModelScope)
@ -126,6 +134,10 @@ constructor(
.toImmutableMap()
}
fun refreshNewestPosts() {
cachingPagingSource?.invalidate()
}
fun toggleSave(post: UIPost) {
viewModelScope.launch {
savedPostsRepository.toggleSave(post)