feat(android): wire up a RemoteMediator to cache newest posts
This commit is contained in:
parent
e2af08aa12
commit
ef0c0ed440
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
)
|
||||
|
|
|
@ -47,6 +47,7 @@ fun SearchList(
|
|||
)
|
||||
NetworkPosts(
|
||||
lazyPagingItems = lazyPagingItems,
|
||||
refresh = { lazyPagingItems.refresh() },
|
||||
listState = listState,
|
||||
postActions = postActions,
|
||||
)
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
|
|
|
@ -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)
|
||||
|
|
Loading…
Reference in New Issue