diff --git a/core/pagination/.gitignore b/core/pagination/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/core/pagination/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/core/pagination/build.gradle.kts b/core/pagination/build.gradle.kts new file mode 100644 index 0000000000..c7b5d3d97a --- /dev/null +++ b/core/pagination/build.gradle.kts @@ -0,0 +1,10 @@ +plugins { + alias(deps.plugins.kotlin.jvm) + id("configuration") +} + +dependencies { + // region Coroutines + implementation(deps.kotlin.coroutines) + // endregion +} \ No newline at end of file diff --git a/core/pagination/src/main/java/com/tangem/pagination/Batch.kt b/core/pagination/src/main/java/com/tangem/pagination/Batch.kt new file mode 100644 index 0000000000..0e262776a2 --- /dev/null +++ b/core/pagination/src/main/java/com/tangem/pagination/Batch.kt @@ -0,0 +1,13 @@ +package com.tangem.pagination + +/** + * Represents a batch of data with a key. + * Used in [BatchListState]. + * + * @param TKey type of the key. + * @param TData type of the data. + */ +data class Batch( + val key: TKey, + val data: TData, +) \ No newline at end of file diff --git a/core/pagination/src/main/java/com/tangem/pagination/BatchAction.kt b/core/pagination/src/main/java/com/tangem/pagination/BatchAction.kt new file mode 100644 index 0000000000..691941752a --- /dev/null +++ b/core/pagination/src/main/java/com/tangem/pagination/BatchAction.kt @@ -0,0 +1,61 @@ +package com.tangem.pagination + +/** + * Action that can be dispatched to [BatchListSource]. + * + * @param TRequestParams type of the request params to load batches. + * @param TKey type of the key of the batch. + * @param TUpdate type of the update request. + */ +sealed class BatchAction { + + /** + * Action to load the first batch. + * + * @param requestParams request params to load the first batch. + */ + data class Reload( + val requestParams: TRequestParams, + ) : BatchAction() + + /** + * Action to load the next batch. + * + * @param requestParams request params to load the next batch with new request. + * If null, the last request will be used. + * Will be saved in the state and used for future LoadMore actions with request = null. + */ + data class LoadMore( + val requestParams: TRequestParams? = null, + ) : BatchAction() + + /** + * Action to update the batch. + * + * @param keys keys of the batches to update. + * @param updateRequest request to update the batches. + */ + class UpdateBatches( + val keys: Set, + val updateRequest: TUpdate, + ) : BatchAction() + + /** + * Action to cancel the current batch loading. + */ + data object CancelBatchLoading : BatchAction() + + /** + * Action to cancel all update requests. + */ + data object CancelAllUpdates : BatchAction() + + /** + * Action to cancel update requests that satisfy the predicate. + * + * @param predicate predicate to check if the update request should be cancelled. + */ + class CancelUpdates( + val predicate: (UpdateBatches) -> Boolean, + ) : BatchAction() +} \ No newline at end of file diff --git a/core/pagination/src/main/java/com/tangem/pagination/BatchFetchResult.kt b/core/pagination/src/main/java/com/tangem/pagination/BatchFetchResult.kt new file mode 100644 index 0000000000..a247310b9c --- /dev/null +++ b/core/pagination/src/main/java/com/tangem/pagination/BatchFetchResult.kt @@ -0,0 +1,30 @@ +package com.tangem.pagination + +/** + * Represents a result of a batch fetch request. + * Used in [BatchListState]. + * + * @param TData type of the data. + */ +sealed class BatchFetchResult { + + /** + * Represents a successful result of a batch fetch request. + * + * @param data fetched data. + * @param last indicates if this is the last batch for the request. + */ + data class Success( + val data: TData, + val last: Boolean = false, + ) : BatchFetchResult() + + /** + * Represents an error result of a batch fetch request. + * Also used for unexpected exceptions that occurred in fetch method in BatchFetcher. + * + * @param throwable throwable that occurred during the request. + * @see com.tangem.pagination.fetcher.BatchFetcher + */ + class Error(val throwable: Throwable) : BatchFetchResult() +} \ No newline at end of file diff --git a/core/pagination/src/main/java/com/tangem/pagination/BatchListSource.kt b/core/pagination/src/main/java/com/tangem/pagination/BatchListSource.kt new file mode 100644 index 0000000000..fc1d0bd1c2 --- /dev/null +++ b/core/pagination/src/main/java/com/tangem/pagination/BatchListSource.kt @@ -0,0 +1,328 @@ +package com.tangem.pagination + +import com.tangem.pagination.fetcher.BatchFetcher +import kotlinx.coroutines.* +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.* + +/** + * Source for paginated data. The starting point for pagination. + * + * @param TKey Type of the key that identifies a batch. + * @param TData Type of the data in the batch. Generally, it' a list of items. + * @param TUpdate Type of the update request. + * @property state State of the paginated data. + * @property updateResults Flow of results of update requests. + */ +interface BatchListSource { + val state: StateFlow> + val updateResults: SharedFlow>> +} + +/** + * Creates a new [BatchListSource] with the provided configuration. + * + * @param fetchDispatcher Dispatcher for fetch operations. + * @param context Context for batching. + * @param generateNewKey Function to generate a new key for a batch. + * @param batchFetcher Function to fetch a batch of data. + * + * @return New instance of [BatchListSource]. + */ +@Suppress("FunctionNaming") +fun BatchListSource( + fetchDispatcher: CoroutineDispatcher = Dispatchers.IO, + context: BatchingContext, + generateNewKey: suspend (List) -> TKey, + batchFetcher: BatchFetcher, +): BatchListSource = + DefaultBatchListSource(fetchDispatcher, context, generateNewKey, batchFetcher, null) + +/** + * Creates a new [BatchListSource] with the provided configuration. + * + * @param fetchDispatcher Dispatcher for fetch operations. + * @param context Context for batching. + * @param generateNewKey Function to generate a new key for a batch. + * @param batchFetcher Function to fetch a batch of data. + * @param updateFetcher Function to fetch updates for batches. + * + * @return New instance of [BatchListSource]. + */ +@Suppress("FunctionNaming") +fun BatchListSource( + fetchDispatcher: CoroutineDispatcher = Dispatchers.IO, + context: BatchingContext, + generateNewKey: suspend (List) -> TKey, + batchFetcher: BatchFetcher, + updateFetcher: BatchUpdateFetcher, +): BatchListSource = + DefaultBatchListSource(fetchDispatcher, context, generateNewKey, batchFetcher, updateFetcher) + +private class DefaultBatchListSource( + private val fetchDispatcher: CoroutineDispatcher, + private val context: BatchingContext, + private val generateNewKey: suspend (List) -> TKey, + private val batchFetcher: BatchFetcher, + private val updateFetcher: BatchUpdateFetcher? = null, +) : BatchListSource { + + override val state = MutableStateFlow(BatchListState(emptyList(), PaginationStatus.None)) + override val updateResults = MutableSharedFlow>>( + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + + private val scope = context.coroutineScope + private val updateJobs = MutableStateFlow, Job>>>(emptyList()) + private val waitingUpdateJobs = + MutableStateFlow, Job>>>(emptyList()) + + private val lastRequestResult = MutableStateFlow?>(null) + private var reloadActionJob: Job? = null + private var loadMoreActionJob: Job? = null + + init { + scope.launch { + try { + awaitCancellation() + } finally { + withContext(NonCancellable) { + loadMoreActionJob = null + loadMoreActionJob = null + lastRequestResult.value = null + stopAllUpdates() + state.value = BatchListState(emptyList(), PaginationStatus.None) + } + } + } + + scope.launch { + context.actionsFlow.collect { action -> + collectActions(action) + } + } + } + + private fun collectActions(action: BatchAction) { + when (action) { + is BatchAction.Reload -> { + // Stop all tasks + loadMoreActionJob?.cancel() + reloadActionJob?.cancel() + stopAllUpdates() + reloadActionJob = scope.launch(fetchDispatcher) { + reloadTask(action) + } + } + is BatchAction.LoadMore -> { + if (loadMoreActionJob?.isActive == true) { + return + } + + loadMoreActionJob = scope.launch(fetchDispatcher) { + reloadActionJob?.join() + loadMoreTask(action) + } + } + is BatchAction.UpdateBatches -> { + if (updateFetcher == null) return + + scope.launch(fetchDispatcher) { + // Lazily start a job so we can avoid batch update collisions + // by waiting for other tasks with the same keys to complete + val job = launch(start = CoroutineStart.LAZY) { + updateBatchesTask(action) + } + + val actionJob = action to job + + waitingUpdateJobs.update { it + actionJob } + + // Wait for other update tasks that mutate batches with the same keys + updateJobs.first { workingJobs -> + action.keys.intersect(workingJobs.map { it.first.keys }.flatten().toSet()).isEmpty() + } + + waitingUpdateJobs.update { it - actionJob } + + // No other task are mutating batches with the same keys, so we can start a job + val started = job.start() + + if (started) { + updateJobs.update { it + actionJob } + + job.invokeOnCompletion { cause -> + // If the job was cancelled it is up to a canceller to remove job from the updateJobs list + if (cause !is CancellationException) { + updateJobs.update { it - actionJob } + } + } + } + } + } + BatchAction.CancelAllUpdates -> { + if (updateFetcher == null) return + stopAllUpdates() + } + is BatchAction.CancelUpdates -> { + if (updateFetcher == null) return + stopUpdates(action.predicate) + } + BatchAction.CancelBatchLoading -> { + loadMoreActionJob?.cancel() + reloadActionJob?.cancel() + } + } + } + + private suspend fun reloadTask(action: BatchAction.Reload) { + state.value = BatchListState( + data = emptyList(), + status = PaginationStatus.InitialLoading, + ) + + val res = runCatching { + batchFetcher.fetchFirst(action.requestParams) + }.getOrElse { BatchFetchResult.Error(it) } + + state.value = when (res) { + is BatchFetchResult.Success -> { + val key = generateNewKey(listOf()) + val batch = Batch( + key = key, + data = res.data, + ) + BatchListState( + data = listOf(batch), + status = if (res.last) { + PaginationStatus.EndOfPagination + } else { + PaginationStatus.Paginating(res) + }, + ) + } + is BatchFetchResult.Error -> { + BatchListState( + data = emptyList(), + status = PaginationStatus.InitialLoadingError( + throwable = res.throwable, + ), + ) + } + } + + lastRequestResult.value = res + } + + private suspend fun loadMoreTask(action: BatchAction.LoadMore) { + val status = state.value.status + + // Skip the action if the state is not ready to continue pagination. + // Two options are acceptable: + // 1. The Source is ready to load next page with the same or different request params. + // 2. The Source has reached the end of pagination, but there is another request + // that can possibly load the next page and continue the pagination + + if (status !is PaginationStatus.Paginating && status !is PaginationStatus.EndOfPagination) return + if (status is PaginationStatus.EndOfPagination && action.requestParams == null) return + + val lastResult = lastRequestResult.value ?: return + + state.update { it.copy(status = PaginationStatus.NextBatchLoading) } + + val res = runCatching { + batchFetcher.fetchNext(action.requestParams, lastResult) + }.getOrElse { BatchFetchResult.Error(it) } + + lastRequestResult.value = lastResult + + state.update { currentState -> + when (res) { + is BatchFetchResult.Success -> { + val newBatch = Batch( + key = generateNewKey(currentState.data.map { it.key }), + data = res.data, + ) + + currentState.copy( + data = currentState.data + newBatch, + status = if (res.last) { + PaginationStatus.EndOfPagination + } else { + PaginationStatus.Paginating(res) + }, + ) + } + is BatchFetchResult.Error -> { + currentState.copy( + status = PaginationStatus.Paginating(res), + ) + } + } + } + } + + private suspend fun updateBatchesTask(action: BatchAction.UpdateBatches) { + if (updateFetcher == null) return + + val batches = state.value.data + val batchesToUpdate = batches.filter { action.keys.contains(it.key) } + + val result = updateFetcher.fetchUpdate( + toUpdate = batchesToUpdate, + updateRequest = action.updateRequest, + ) + + if (result is BatchUpdateResult.Success) { + state.update { currentState -> + val resMap = result.data.associateBy { it.key } + currentState.copy( + data = currentState.data.map { + resMap[it.key] ?: it + }, + ) + } + } + + updateResults.emit(action.updateRequest to result) + } + + private fun stopAllUpdates() { + updateJobs.update { actionJobs -> + waitingUpdateJobs.update { waitingActionJobs -> + waitingActionJobs.forEach { + it.second.cancel() + } + emptyList() + } + actionJobs.forEach { + it.second.cancel() + } + emptyList() + } + } + + private fun stopUpdates(predicate: (BatchAction.UpdateBatches) -> Boolean) { + updateJobs.update { actionJobs -> + waitingUpdateJobs.update { waitingActionJobs -> + waitingActionJobs.mapNotNull { + if (predicate(it.first)) { + it.second.cancel() + null + } else { + it + } + } + } + actionJobs.mapNotNull { + if (predicate(it.first)) { + it.second.cancel() + null + } else { + it + } + } + } + } +} \ No newline at end of file diff --git a/core/pagination/src/main/java/com/tangem/pagination/BatchListState.kt b/core/pagination/src/main/java/com/tangem/pagination/BatchListState.kt new file mode 100644 index 0000000000..b53ba23dc0 --- /dev/null +++ b/core/pagination/src/main/java/com/tangem/pagination/BatchListState.kt @@ -0,0 +1,17 @@ +package com.tangem.pagination + +/** + * State that is used for listening the current state of a pagination. + * + * @param TKey type of the key of the batch. + * @param TData type of the data. + * + * @property data list of loaded batches. + * @property status current status of the pagination. + * + * @see BatchListSource + */ +data class BatchListState( + val data: List>, + val status: PaginationStatus, +) \ No newline at end of file diff --git a/core/pagination/src/main/java/com/tangem/pagination/BatchUpdateFetcher.kt b/core/pagination/src/main/java/com/tangem/pagination/BatchUpdateFetcher.kt new file mode 100644 index 0000000000..704c416316 --- /dev/null +++ b/core/pagination/src/main/java/com/tangem/pagination/BatchUpdateFetcher.kt @@ -0,0 +1,23 @@ +package com.tangem.pagination + +/** + * Interface for fetching updates for a batch of data. + * Used in [BatchListState]. + * + * @param TKey type of the key. + * @param TData type of the data. + * @param TUpdate type of the update request. + */ +fun interface BatchUpdateFetcher { + + /** + * Fetches updates for a batch of data. + * Note that the result batch key as a result of executing the method must be presented in the [toUpdate] list, + * otherwise, updates will not be performed + * + * @param toUpdate list of batches to update. + * @param updateRequest request to update the data. + * @return result of the update operation. + */ + suspend fun fetchUpdate(toUpdate: List>, updateRequest: TUpdate): BatchUpdateResult +} \ No newline at end of file diff --git a/core/pagination/src/main/java/com/tangem/pagination/BatchUpdateResult.kt b/core/pagination/src/main/java/com/tangem/pagination/BatchUpdateResult.kt new file mode 100644 index 0000000000..c2e00fad9c --- /dev/null +++ b/core/pagination/src/main/java/com/tangem/pagination/BatchUpdateResult.kt @@ -0,0 +1,27 @@ +package com.tangem.pagination + +/** + * Represents the result of a batch fetch operation. + * Used in [BatchListState] and [BatchUpdateFetcher]. + * + * @param TKey type of the key. + * @param TData type of the data. + */ +sealed class BatchUpdateResult { + + /** + * Represents a successful result of a batch update operation. + * + * @param data fetched data. + */ + data class Success( + val data: List>, + ) : BatchUpdateResult() + + /** + * Represents an error result of a batch update operation. + * + * @param error error that occurred during the operation. + */ + class Error(val throwable: Throwable) : BatchUpdateResult() +} \ No newline at end of file diff --git a/core/pagination/src/main/java/com/tangem/pagination/BatchingContext.kt b/core/pagination/src/main/java/com/tangem/pagination/BatchingContext.kt new file mode 100644 index 0000000000..252d6f1680 --- /dev/null +++ b/core/pagination/src/main/java/com/tangem/pagination/BatchingContext.kt @@ -0,0 +1,22 @@ +package com.tangem.pagination + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow + +/** + * Context for working with [BatchListSource]. + * + * @param TRequestParams type of the request. + * @param TKey type of the key. + * @param TUpdate type of the update request. + * + * @property actionsFlow flow of [BatchAction]s that would be dispatched to [BatchListSource]. + * @property coroutineScope scope for the [BatchListSource] to launch coroutines. When it is cancelled, + * all the operations and requests launched in the [BatchListSource] would be cancelled and all data would be cleared. + * + * @see BatchListSource + */ +class BatchingContext( + val actionsFlow: Flow>, + val coroutineScope: CoroutineScope, +) \ No newline at end of file diff --git a/core/pagination/src/main/java/com/tangem/pagination/PaginationStatus.kt b/core/pagination/src/main/java/com/tangem/pagination/PaginationStatus.kt new file mode 100644 index 0000000000..5e30875c6a --- /dev/null +++ b/core/pagination/src/main/java/com/tangem/pagination/PaginationStatus.kt @@ -0,0 +1,55 @@ +package com.tangem.pagination + +/** + * Status of the pagination. + * + * @param TData type of the data. + * + * @see BatchListState + */ +sealed class PaginationStatus { + + /** + * Represents that there is no data. Used when the list of batches is empty. + * The initial state of the pagination. + */ + data object None : PaginationStatus() + + /** + * Represents that the batch is loading for the first time. + * Used when the pagination is empty and the first batch is being loaded. + */ + data object InitialLoading : PaginationStatus() + + /** + * Represents that the first batch was loaded with an error. + * + * @param error error that occurred during the initial loading. + */ + data class InitialLoadingError( + val throwable: Throwable, + ) : PaginationStatus() + + /** + * Represents that the last batch was loaded and + * the source is ready to load the next one or reload previous if [lastResult] is an error. + * For the first batch, [lastResult] is always [BatchFetchResult.Success] + * + * @param lastResult result of the last batch fetch. + */ + data class Paginating( + val lastResult: BatchFetchResult, + ) : PaginationStatus() + + /** + * Represents that the next batch is loading. + * Used when the next batch is being loaded. + */ + data object NextBatchLoading : PaginationStatus() + + /** + * Represents that the source has no more batches to load. + * The next [BatchAction.LoadMore] with [BatchAction.LoadMore.requestParams] = null will be ignored. + */ + data object EndOfPagination : PaginationStatus() +} \ No newline at end of file diff --git a/core/pagination/src/main/java/com/tangem/pagination/exception/EndOfPaginationException.kt b/core/pagination/src/main/java/com/tangem/pagination/exception/EndOfPaginationException.kt new file mode 100644 index 0000000000..f2cc69767a --- /dev/null +++ b/core/pagination/src/main/java/com/tangem/pagination/exception/EndOfPaginationException.kt @@ -0,0 +1,6 @@ +package com.tangem.pagination.exception + +/** + * Exception that is thrown when there are no more items to fetch. + */ +class EndOfPaginationException : IllegalStateException() \ No newline at end of file diff --git a/core/pagination/src/main/java/com/tangem/pagination/fetcher/BatchFetcher.kt b/core/pagination/src/main/java/com/tangem/pagination/fetcher/BatchFetcher.kt new file mode 100644 index 0000000000..818620dab1 --- /dev/null +++ b/core/pagination/src/main/java/com/tangem/pagination/fetcher/BatchFetcher.kt @@ -0,0 +1,36 @@ +package com.tangem.pagination.fetcher + +import com.tangem.pagination.BatchFetchResult +import com.tangem.pagination.BatchListState + +/** + * Interface for fetching a batch of data. Used in [BatchListState]. + * + * @param TRequestParams type of the request. + * @param TData type of the data. + * + * @see BatchListState + */ +interface BatchFetcher { + + /** + * Fetches the first batch of data. + * + * @param requestParams initial request params. Will be saved to be used in [fetchNext] requests. + * @return result of the fetch operation. + */ + suspend fun fetchFirst(requestParams: TRequestParams): BatchFetchResult + + /** + * Fetches the next batch of data. + * + * @param overrideRequestParams overrides current remembered request, even if that fetch fails. + * If null, the last request should be used. + * @param lastResult result of the last fetch operation. + * @return result of the fetch operation. + */ + suspend fun fetchNext( + overrideRequestParams: TRequestParams?, + lastResult: BatchFetchResult, + ): BatchFetchResult +} \ No newline at end of file diff --git a/core/pagination/src/main/java/com/tangem/pagination/fetcher/LimitOffsetBatchFetcher.kt b/core/pagination/src/main/java/com/tangem/pagination/fetcher/LimitOffsetBatchFetcher.kt new file mode 100644 index 0000000000..01059c4676 --- /dev/null +++ b/core/pagination/src/main/java/com/tangem/pagination/fetcher/LimitOffsetBatchFetcher.kt @@ -0,0 +1,68 @@ +package com.tangem.pagination.fetcher + +import com.tangem.pagination.BatchFetchResult +import com.tangem.pagination.exception.EndOfPaginationException +import kotlinx.coroutines.flow.MutableStateFlow + +/** + * Fetcher that uses limit and offset to fetch data. + * + * @param TRequestParams type of the request params. + * @param TData type of the data. + * + * @property prefetchDistance number of items to fetch for the first batch. + * @property batchSize size of the batch. + * @property fetch function that fetches the data. + */ +class LimitOffsetBatchFetcher( + private val prefetchDistance: Int, + private val batchSize: Int, + private val fetch: suspend (request: Request) -> BatchFetchResult, +) : BatchFetcher { + + data class Request( + val limit: Int, + val offset: Int, + val request: TRequest, + ) + + private val lastRequest = MutableStateFlow?>(null) + + override suspend fun fetchFirst(requestParams: TRequestParams): BatchFetchResult { + val req = Request( + offset = 0, + limit = prefetchDistance, + request = requestParams, + ) + + val res = fetch(req) + lastRequest.value = req + return res + } + + override suspend fun fetchNext( + overrideRequestParams: TRequestParams?, + lastResult: BatchFetchResult, + ): BatchFetchResult { + val last = lastRequest.value + requireNotNull(last) + + val req = if (lastResult is BatchFetchResult.Success) { + if (lastResult.last && overrideRequestParams == null) { + return BatchFetchResult.Error(EndOfPaginationException()) + } + + Request( + offset = last.offset + last.limit, + limit = batchSize, + request = overrideRequestParams ?: last.request, + ) + } else { + last + } + + val res = fetch(req) + lastRequest.value = req + return res + } +} \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 3637dd3cc9..6d1f411939 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -127,6 +127,7 @@ include(":core:utils") include(":core:deep-links") include(":core:deep-links:global") include(":core:decompose") +include(":core:pagination") // endregion Core modules // region Common modules