migrate reporting to paging 3 (#2205)

* migrate reporting to paging 3

* apply PR feedback
This commit is contained in:
Konrad Pozniak 2021-06-20 10:58:19 +02:00 committed by GitHub
parent 920c71560b
commit 554820de5f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 162 additions and 874 deletions

View File

@ -51,7 +51,6 @@ class ReportActivity : BottomSheetActivity(), HasAndroidInjector {
viewModel.init(accountId, accountUserName, intent?.getStringExtra(STATUS_ID))
setContentView(binding.root)
setSupportActionBar(binding.includedToolbar.toolbar)

View File

@ -17,25 +17,35 @@ package com.keylesspalace.tusky.components.report
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Transformations
import androidx.paging.PagedList
import androidx.lifecycle.viewModelScope
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.cachedIn
import com.keylesspalace.tusky.appstore.BlockEvent
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.MuteEvent
import com.keylesspalace.tusky.components.report.adapter.StatusesRepository
import com.keylesspalace.tusky.components.report.adapter.StatusesPagingSource
import com.keylesspalace.tusky.components.report.model.StatusViewState
import com.keylesspalace.tusky.entity.Relationship
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.*
import com.keylesspalace.tusky.util.Error
import com.keylesspalace.tusky.util.Loading
import com.keylesspalace.tusky.util.Resource
import com.keylesspalace.tusky.util.RxAwareViewModel
import com.keylesspalace.tusky.util.Success
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.schedulers.Schedulers
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.launch
import javax.inject.Inject
class ReportViewModel @Inject constructor(
private val mastodonApi: MastodonApi,
private val eventHub: EventHub,
private val statusesRepository: StatusesRepository) : RxAwareViewModel() {
private val eventHub: EventHub
) : RxAwareViewModel() {
private val navigationMutable = MutableLiveData<Screen?>()
val navigation: LiveData<Screen?> = navigationMutable
@ -52,11 +62,19 @@ class ReportViewModel @Inject constructor(
private val checkUrlMutable = MutableLiveData<String?>()
val checkUrl: LiveData<String?> = checkUrlMutable
private val repoResult = MutableLiveData<BiListing<Status>>()
val statuses: LiveData<PagedList<Status>> = Transformations.switchMap(repoResult) { it.pagedList }
val networkStateAfter: LiveData<NetworkState> = Transformations.switchMap(repoResult) { it.networkStateAfter }
val networkStateBefore: LiveData<NetworkState> = Transformations.switchMap(repoResult) { it.networkStateBefore }
val networkStateRefresh: LiveData<NetworkState> = Transformations.switchMap(repoResult) { it.refreshState }
private val accountIdFlow = MutableSharedFlow<String>(
replay = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
val statusesFlow = accountIdFlow.flatMapLatest { accountId ->
Pager(
initialKey = statusId,
config = PagingConfig(pageSize = 20, initialLoadSize = 20),
pagingSourceFactory = { StatusesPagingSource(accountId, mastodonApi) }
).flow
}
.cachedIn(viewModelScope)
private val selectedIds = HashSet<String>()
val statusViewState = StatusViewState()
@ -84,7 +102,10 @@ class ReportViewModel @Inject constructor(
}
obtainRelationship()
repoResult.value = statusesRepository.getStatuses(accountId, statusId, disposables)
viewModelScope.launch {
accountIdFlow.emit(accountId)
}
}
fun navigateTo(screen: Screen) {
@ -95,7 +116,6 @@ class ReportViewModel @Inject constructor(
navigationMutable.value = null
}
private fun obtainRelationship() {
val ids = listOf(accountId)
muteStateMutable.value = Loading()
@ -115,7 +135,6 @@ class ReportViewModel @Inject constructor(
.autoDispose()
}
private fun updateRelationship(relationship: Relationship?) {
if (relationship != null) {
muteStateMutable.value = Success(relationship.muting)
@ -194,14 +213,6 @@ class ReportViewModel @Inject constructor(
}
fun retryStatusLoad() {
repoResult.value?.retry?.invoke()
}
fun refreshStatuses() {
repoResult.value?.refresh?.invoke()
}
fun checkClickedUrl(url: String?) {
checkUrlMutable.value = url
}
@ -221,5 +232,4 @@ class ReportViewModel @Inject constructor(
fun isStatusChecked(id: String): Boolean {
return selectedIds.contains(id)
}
}
}

View File

@ -17,7 +17,7 @@ package com.keylesspalace.tusky.components.report.adapter
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.paging.PagedListAdapter
import androidx.paging.PagingDataAdapter
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.components.report.model.StatusViewState
@ -29,7 +29,7 @@ class StatusesAdapter(
private val statusDisplayOptions: StatusDisplayOptions,
private val statusViewState: StatusViewState,
private val adapterHandler: AdapterHandler
) : PagedListAdapter<Status, StatusViewHolder>(STATUS_COMPARATOR) {
) : PagingDataAdapter<Status, StatusViewHolder>(STATUS_COMPARATOR) {
private val statusForPosition: (Int) -> Status? = { position: Int ->
if (position != RecyclerView.NO_POSITION) getItem(position) else null

View File

@ -1,150 +0,0 @@
/* Copyright 2019 Joel Pyska
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.components.report.adapter
import android.annotation.SuppressLint
import androidx.lifecycle.MutableLiveData
import androidx.paging.ItemKeyedDataSource
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.NetworkState
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.functions.BiFunction
import java.util.concurrent.Executor
class StatusesDataSource(private val accountId: String,
private val mastodonApi: MastodonApi,
private val disposables: CompositeDisposable,
private val retryExecutor: Executor) : ItemKeyedDataSource<String, Status>() {
val networkStateAfter = MutableLiveData<NetworkState>()
val networkStateBefore = MutableLiveData<NetworkState>()
private var retryAfter: (() -> Any)? = null
private var retryBefore: (() -> Any)? = null
private var retryInitial: (() -> Any)? = null
val initialLoad = MutableLiveData<NetworkState>()
fun retryAllFailed() {
var prevRetry = retryInitial
retryInitial = null
prevRetry?.let {
retryExecutor.execute {
it.invoke()
}
}
prevRetry = retryAfter
retryAfter = null
prevRetry?.let {
retryExecutor.execute {
it.invoke()
}
}
prevRetry = retryBefore
retryBefore = null
prevRetry?.let {
retryExecutor.execute {
it.invoke()
}
}
}
@SuppressLint("CheckResult")
override fun loadInitial(params: LoadInitialParams<String>, callback: LoadInitialCallback<Status>) {
networkStateAfter.postValue(NetworkState.LOADED)
networkStateBefore.postValue(NetworkState.LOADED)
retryAfter = null
retryBefore = null
retryInitial = null
initialLoad.postValue(NetworkState.LOADING)
val initialKey = params.requestedInitialKey
if (initialKey == null) {
mastodonApi.accountStatusesObservable(accountId, null, null, params.requestedLoadSize, true)
} else {
mastodonApi.statusObservable(initialKey).zipWith(
mastodonApi.accountStatusesObservable(accountId, params.requestedInitialKey, null, params.requestedLoadSize - 1, true),
BiFunction { status: Status, list: List<Status> ->
val ret = ArrayList<Status>()
ret.add(status)
ret.addAll(list)
return@BiFunction ret
})
}
.doOnSubscribe {
disposables.add(it)
}
.subscribe(
{
callback.onResult(it)
initialLoad.postValue(NetworkState.LOADED)
},
{
retryInitial = {
loadInitial(params, callback)
}
initialLoad.postValue(NetworkState.error(it.message))
}
)
}
@SuppressLint("CheckResult")
override fun loadAfter(params: LoadParams<String>, callback: LoadCallback<Status>) {
networkStateAfter.postValue(NetworkState.LOADING)
retryAfter = null
mastodonApi.accountStatusesObservable(accountId, params.key, null, params.requestedLoadSize, true)
.doOnSubscribe {
disposables.add(it)
}
.subscribe(
{
callback.onResult(it)
networkStateAfter.postValue(NetworkState.LOADED)
},
{
retryAfter = {
loadAfter(params, callback)
}
networkStateAfter.postValue(NetworkState.error(it.message))
}
)
}
@SuppressLint("CheckResult")
override fun loadBefore(params: LoadParams<String>, callback: LoadCallback<Status>) {
networkStateBefore.postValue(NetworkState.LOADING)
retryBefore = null
mastodonApi.accountStatusesObservable(accountId, null, params.key, params.requestedLoadSize, true)
.doOnSubscribe {
disposables.add(it)
}
.subscribe(
{
callback.onResult(it)
networkStateBefore.postValue(NetworkState.LOADED)
},
{
retryBefore = {
loadBefore(params, callback)
}
networkStateBefore.postValue(NetworkState.error(it.message))
}
)
}
override fun getKey(item: Status): String = item.id
}

View File

@ -1,36 +0,0 @@
/* Copyright 2019 Joel Pyska
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.components.report.adapter
import androidx.lifecycle.MutableLiveData
import androidx.paging.DataSource
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.network.MastodonApi
import io.reactivex.rxjava3.disposables.CompositeDisposable
import java.util.concurrent.Executor
class StatusesDataSourceFactory(
private val accountId: String,
private val mastodonApi: MastodonApi,
private val disposables: CompositeDisposable,
private val retryExecutor: Executor) : DataSource.Factory<String, Status>() {
val sourceLiveData = MutableLiveData<StatusesDataSource>()
override fun create(): DataSource<String, Status> {
val source = StatusesDataSource(accountId, mastodonApi, disposables, retryExecutor)
sourceLiveData.postValue(source)
return source
}
}

View File

@ -0,0 +1,89 @@
/* Copyright 2021 Tusky Contributors
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.components.report.adapter
import android.util.Log
import androidx.paging.PagingSource
import androidx.paging.PagingState
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.network.MastodonApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.rx3.await
import kotlinx.coroutines.withContext
class StatusesPagingSource(
private val accountId: String,
private val mastodonApi: MastodonApi
) : PagingSource<String, Status>() {
override fun getRefreshKey(state: PagingState<String, Status>): String? {
return state.anchorPosition?.let { anchorPosition ->
state.closestItemToPosition(anchorPosition)?.id
}
}
override suspend fun load(params: LoadParams<String>): LoadResult<String, Status> {
val key = params.key
try {
val result = if (params is LoadParams.Refresh && key != null) {
withContext(Dispatchers.IO) {
val initialStatus = async { getSingleStatus(key) }
val additionalStatuses = async { getStatusList(maxId = key, limit = params.loadSize - 1) }
listOf(initialStatus.await()) + additionalStatuses.await()
}
} else {
val maxId = if (params is LoadParams.Refresh || params is LoadParams.Append) {
params.key
} else {
null
}
val minId = if (params is LoadParams.Prepend) {
params.key
} else {
null
}
getStatusList(minId = minId, maxId = maxId, limit = params.loadSize)
}
return LoadResult.Page(
data = result,
prevKey = result.firstOrNull()?.id,
nextKey = result.lastOrNull()?.id
)
} catch (e: Exception) {
Log.w("StatusesPagingSource", "failed to load statuses", e)
return LoadResult.Error(e)
}
}
private suspend fun getSingleStatus(statusId: String): Status {
return mastodonApi.statusObservable(statusId).await()
}
private suspend fun getStatusList(minId: String? = null, maxId: String? = null, limit: Int): List<Status> {
return mastodonApi.accountStatusesObservable(
accountId = accountId,
maxId = maxId,
sinceId = null,
minId = minId,
limit = limit,
excludeReblogs = true
).await()
}
}

View File

@ -1,60 +0,0 @@
/* Copyright 2019 Joel Pyska
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.components.report.adapter
import androidx.lifecycle.Transformations
import androidx.paging.Config
import androidx.paging.toLiveData
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.BiListing
import io.reactivex.rxjava3.disposables.CompositeDisposable
import java.util.concurrent.Executors
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class StatusesRepository @Inject constructor(private val mastodonApi: MastodonApi) {
private val executor = Executors.newSingleThreadExecutor()
fun getStatuses(accountId: String, initialStatus: String?, disposables: CompositeDisposable, pageSize: Int = 20): BiListing<Status> {
val sourceFactory = StatusesDataSourceFactory(accountId, mastodonApi, disposables, executor)
val livePagedList = sourceFactory.toLiveData(
config = Config(pageSize = pageSize, enablePlaceholders = false, initialLoadSizeHint = pageSize * 2),
fetchExecutor = executor, initialLoadKey = initialStatus
)
return BiListing(
pagedList = livePagedList,
networkStateBefore = Transformations.switchMap(sourceFactory.sourceLiveData) {
it.networkStateBefore
},
networkStateAfter = Transformations.switchMap(sourceFactory.sourceLiveData) {
it.networkStateAfter
},
retry = {
sourceFactory.sourceLiveData.value?.retryAllFailed()
},
refresh = {
sourceFactory.sourceLiveData.value?.invalidate()
},
refreshState = Transformations.switchMap(sourceFactory.sourceLiveData) {
it.initialLoad
}
)
}
}

View File

@ -27,7 +27,12 @@ import com.keylesspalace.tusky.components.report.Screen
import com.keylesspalace.tusky.databinding.FragmentReportNoteBinding
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.util.*
import com.keylesspalace.tusky.util.Error
import com.keylesspalace.tusky.util.Loading
import com.keylesspalace.tusky.util.Success
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.viewBinding
import java.io.IOException
import javax.inject.Inject
@ -92,12 +97,10 @@ class ReportNoteFragment : Fragment(R.layout.fragment_report_note), Injectable {
binding.progressBar.hide()
Snackbar.make(binding.buttonBack, if (error is IOException) R.string.error_network else R.string.error_generic, Snackbar.LENGTH_LONG)
.apply {
setAction(R.string.action_retry) {
sendReport()
}
}
.show()
.setAction(R.string.action_retry) {
sendReport()
}
.show()
}
private fun sendReport() {

View File

@ -21,6 +21,8 @@ import androidx.core.app.ActivityOptionsCompat
import androidx.core.view.ViewCompat
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.lifecycleScope
import androidx.paging.LoadState
import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
@ -43,10 +45,11 @@ import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.util.CardViewMode
import com.keylesspalace.tusky.util.StatusDisplayOptions
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.util.visible
import com.keylesspalace.tusky.viewdata.AttachmentViewData
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import javax.inject.Inject
class ReportStatusesFragment : Fragment(R.layout.fragment_report_statuses), Injectable, AdapterHandler {
@ -70,13 +73,11 @@ class ReportStatusesFragment : Fragment(R.layout.fragment_report_statuses), Inje
when (actionable.attachments[idx].type) {
Attachment.Type.GIFV, Attachment.Type.VIDEO, Attachment.Type.IMAGE, Attachment.Type.AUDIO -> {
val attachments = AttachmentViewData.list(actionable)
val intent = ViewMediaActivity.newIntent(context, attachments,
idx)
val intent = ViewMediaActivity.newIntent(context, attachments, idx)
if (v != null) {
val url = actionable.attachments[idx].url
ViewCompat.setTransitionName(v, url)
val options = ActivityOptionsCompat.makeSceneTransitionAnimation(requireActivity(),
v, url)
val options = ActivityOptionsCompat.makeSceneTransitionAnimation(requireActivity(), v, url)
startActivity(intent, options.toBundle())
} else {
startActivity(intent)
@ -85,7 +86,6 @@ class ReportStatusesFragment : Fragment(R.layout.fragment_report_statuses), Inje
Attachment.Type.UNKNOWN -> {
}
}
}
}
@ -100,7 +100,7 @@ class ReportStatusesFragment : Fragment(R.layout.fragment_report_statuses), Inje
binding.swipeRefreshLayout.setOnRefreshListener {
snackbarErrorRetry?.dismiss()
viewModel.refreshStatuses()
adapter.refresh()
}
}
@ -118,62 +118,46 @@ class ReportStatusesFragment : Fragment(R.layout.fragment_report_statuses), Inje
animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false)
)
adapter = StatusesAdapter(statusDisplayOptions,
viewModel.statusViewState, this)
adapter = StatusesAdapter(statusDisplayOptions, viewModel.statusViewState, this)
binding.recyclerView.addItemDecoration(DividerItemDecoration(requireContext(), DividerItemDecoration.VERTICAL))
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
binding.recyclerView.adapter = adapter
(binding.recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false
viewModel.statuses.observe(viewLifecycleOwner) {
adapter.submitList(it)
lifecycleScope.launch {
viewModel.statusesFlow.collectLatest { pagingData ->
adapter.submitData(pagingData)
}
}
viewModel.networkStateAfter.observe(viewLifecycleOwner) {
if (it?.status == com.keylesspalace.tusky.util.Status.RUNNING)
binding.progressBarBottom.show()
else
binding.progressBarBottom.hide()
adapter.addLoadStateListener { loadState ->
if (loadState.refresh is LoadState.Error
|| loadState.append is LoadState.Error
|| loadState.prepend is LoadState.Error) {
showError()
}
if (it?.status == com.keylesspalace.tusky.util.Status.FAILED)
showError(it.msg)
}
binding.progressBarBottom.visible(loadState.append == LoadState.Loading)
binding.progressBarTop.visible(loadState.prepend == LoadState.Loading)
binding.progressBarLoading.visible(loadState.refresh == LoadState.Loading && !binding.swipeRefreshLayout.isRefreshing)
viewModel.networkStateBefore.observe(viewLifecycleOwner) {
if (it?.status == com.keylesspalace.tusky.util.Status.RUNNING)
binding.progressBarTop.show()
else
binding.progressBarTop.hide()
if (it?.status == com.keylesspalace.tusky.util.Status.FAILED)
showError(it.msg)
}
viewModel.networkStateRefresh.observe(viewLifecycleOwner) {
if (it?.status == com.keylesspalace.tusky.util.Status.RUNNING && !binding.swipeRefreshLayout.isRefreshing)
binding.progressBarLoading.show()
else
binding.progressBarLoading.hide()
if (it?.status != com.keylesspalace.tusky.util.Status.RUNNING)
if (loadState.refresh != LoadState.Loading) {
binding.swipeRefreshLayout.isRefreshing = false
if (it?.status == com.keylesspalace.tusky.util.Status.FAILED)
showError(it.msg)
}
}
}
private fun showError(@Suppress("UNUSED_PARAMETER") msg: String?) {
private fun showError() {
if (snackbarErrorRetry?.isShown != true) {
snackbarErrorRetry = Snackbar.make(binding.swipeRefreshLayout, R.string.failed_fetch_statuses, Snackbar.LENGTH_INDEFINITE)
snackbarErrorRetry?.setAction(R.string.action_retry) {
viewModel.retryStatusLoad()
adapter.retry()
}
snackbarErrorRetry?.show()
}
}
private fun handleClicks() {
binding.buttonCancel.setOnClickListener {
viewModel.navigateTo(Screen.Back)

View File

@ -577,6 +577,7 @@ interface MastodonApi {
@Path("id") accountId: String,
@Query("max_id") maxId: String?,
@Query("since_id") sinceId: String?,
@Query("min_id") minId: String?,
@Query("limit") limit: Int?,
@Query("exclude_reblogs") excludeReblogs: Boolean?
): Single<List<Status>>

View File

@ -1,38 +0,0 @@
/*
* Copyright (C) 2017 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.keylesspalace.tusky.util
import androidx.lifecycle.LiveData
import androidx.paging.PagedList
/**
* Data class that is necessary for a UI to show a listing and interact w/ the rest of the system
*/
data class BiListing<T: Any>(
// the LiveData of paged lists for the UI to observe
val pagedList: LiveData<PagedList<T>>,
// represents the network request status for load data before first to show to the user
val networkStateBefore: LiveData<NetworkState>,
// represents the network request status for load data after last to show to the user
val networkStateAfter: LiveData<NetworkState>,
// represents the refresh status to show to the user. Separate from networkState, this
// value is importantly only when refresh is requested.
val refreshState: LiveData<NetworkState>,
// refreshes the whole data and fetches it from scratch.
val refresh: () -> Unit,
// retries any failed requests.
val retry: () -> Unit)

View File

@ -1,491 +0,0 @@
/*
* Copyright 2017 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.keylesspalace.tusky.util;
import androidx.annotation.AnyThread;
import androidx.annotation.GuardedBy;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import java.util.Arrays;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.Executor;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* A helper class for {@link androidx.paging.PagedList.BoundaryCallback BoundaryCallback}s and
* {@link androidx.paging.DataSource}s to help with tracking network requests.
* <p>
* It is designed to support 3 types of requests, {@link RequestType#INITIAL INITIAL},
* {@link RequestType#BEFORE BEFORE} and {@link RequestType#AFTER AFTER} and runs only 1 request
* for each of them via {@link #runIfNotRunning(RequestType, Request)}.
* <p>
* It tracks a {@link Status} and an {@code error} for each {@link RequestType}.
* <p>
* A sample usage of this class to limit requests looks like this:
* <pre>
* class PagingBoundaryCallback extends PagedList.BoundaryCallback&lt;MyItem> {
* // TODO replace with an executor from your application
* Executor executor = Executors.newSingleThreadExecutor();
* PagingRequestHelper helper = new PagingRequestHelper(executor);
* // imaginary API service, using Retrofit
* MyApi api;
*
* {@literal @}Override
* public void onItemAtFrontLoaded({@literal @}NonNull MyItem itemAtFront) {
* helper.runIfNotRunning(PagingRequestHelper.RequestType.BEFORE,
* helperCallback -> api.getTopBefore(itemAtFront.getName(), 10).enqueue(
* new Callback&lt;ApiResponse>() {
* {@literal @}Override
* public void onResponse(Call&lt;ApiResponse> call,
* Response&lt;ApiResponse> response) {
* // TODO insert new records into database
* helperCallback.recordSuccess();
* }
*
* {@literal @}Override
* public void onFailure(Call&lt;ApiResponse> call, Throwable t) {
* helperCallback.recordFailure(t);
* }
* }));
* }
*
* {@literal @}Override
* public void onItemAtEndLoaded({@literal @}NonNull MyItem itemAtEnd) {
* helper.runIfNotRunning(PagingRequestHelper.RequestType.AFTER,
* helperCallback -> api.getTopBefore(itemAtEnd.getName(), 10).enqueue(
* new Callback&lt;ApiResponse>() {
* {@literal @}Override
* public void onResponse(Call&lt;ApiResponse> call,
* Response&lt;ApiResponse> response) {
* // TODO insert new records into database
* helperCallback.recordSuccess();
* }
*
* {@literal @}Override
* public void onFailure(Call&lt;ApiResponse> call, Throwable t) {
* helperCallback.recordFailure(t);
* }
* }));
* }
* }
* </pre>
* <p>
* The helper provides an API to observe combined request status, which can be reported back to the
* application based on your business rules.
* <pre>
* MutableLiveData&lt;PagingRequestHelper.Status> combined = new MutableLiveData&lt;>();
* helper.addListener(status -> {
* // merge multiple states per request type into one, or dispatch separately depending on
* // your application logic.
* if (status.hasRunning()) {
* combined.postValue(PagingRequestHelper.Status.RUNNING);
* } else if (status.hasError()) {
* // can also obtain the error via {@link StatusReport#getErrorFor(RequestType)}
* combined.postValue(PagingRequestHelper.Status.FAILED);
* } else {
* combined.postValue(PagingRequestHelper.Status.SUCCESS);
* }
* });
* </pre>
*/
// THIS class is likely to be moved into the library in a future release. Feel free to copy it
// from this sample.
public class PagingRequestHelper {
private final Object mLock = new Object();
private final Executor mRetryService;
@GuardedBy("mLock")
private final RequestQueue[] mRequestQueues = new RequestQueue[]
{new RequestQueue(RequestType.INITIAL),
new RequestQueue(RequestType.BEFORE),
new RequestQueue(RequestType.AFTER)};
@NonNull
final CopyOnWriteArrayList<Listener> mListeners = new CopyOnWriteArrayList<>();
/**
* Creates a new PagingRequestHelper with the given {@link Executor} which is used to run
* retry actions.
*
* @param retryService The {@link Executor} that can run the retry actions.
*/
public PagingRequestHelper(@NonNull Executor retryService) {
mRetryService = retryService;
}
/**
* Adds a new listener that will be notified when any request changes {@link Status state}.
*
* @param listener The listener that will be notified each time a request's status changes.
* @return True if it is added, false otherwise (e.g. it already exists in the list).
*/
@AnyThread
public boolean addListener(@NonNull Listener listener) {
return mListeners.add(listener);
}
/**
* Removes the given listener from the listeners list.
*
* @param listener The listener that will be removed.
* @return True if the listener is removed, false otherwise (e.g. it never existed)
*/
public boolean removeListener(@NonNull Listener listener) {
return mListeners.remove(listener);
}
/**
* Runs the given {@link Request} if no other requests in the given request type is already
* running.
* <p>
* If run, the request will be run in the current thread.
*
* @param type The type of the request.
* @param request The request to run.
* @return True if the request is run, false otherwise.
*/
@SuppressWarnings("WeakerAccess")
@AnyThread
public boolean runIfNotRunning(@NonNull RequestType type, @NonNull Request request) {
boolean hasListeners = !mListeners.isEmpty();
StatusReport report = null;
synchronized (mLock) {
RequestQueue queue = mRequestQueues[type.ordinal()];
if (queue.mRunning != null) {
return false;
}
queue.mRunning = request;
queue.mStatus = Status.RUNNING;
queue.mFailed = null;
queue.mLastError = null;
if (hasListeners) {
report = prepareStatusReportLocked();
}
}
if (report != null) {
dispatchReport(report);
}
final RequestWrapper wrapper = new RequestWrapper(request, this, type);
wrapper.run();
return true;
}
@GuardedBy("mLock")
private StatusReport prepareStatusReportLocked() {
Throwable[] errors = new Throwable[]{
mRequestQueues[0].mLastError,
mRequestQueues[1].mLastError,
mRequestQueues[2].mLastError
};
return new StatusReport(
getStatusForLocked(RequestType.INITIAL),
getStatusForLocked(RequestType.BEFORE),
getStatusForLocked(RequestType.AFTER),
errors
);
}
@GuardedBy("mLock")
private Status getStatusForLocked(RequestType type) {
return mRequestQueues[type.ordinal()].mStatus;
}
@AnyThread
@VisibleForTesting
void recordResult(@NonNull RequestWrapper wrapper, @Nullable Throwable throwable) {
StatusReport report = null;
final boolean success = throwable == null;
boolean hasListeners = !mListeners.isEmpty();
synchronized (mLock) {
RequestQueue queue = mRequestQueues[wrapper.mType.ordinal()];
queue.mRunning = null;
queue.mLastError = throwable;
if (success) {
queue.mFailed = null;
queue.mStatus = Status.SUCCESS;
} else {
queue.mFailed = wrapper;
queue.mStatus = Status.FAILED;
}
if (hasListeners) {
report = prepareStatusReportLocked();
}
}
if (report != null) {
dispatchReport(report);
}
}
private void dispatchReport(StatusReport report) {
for (Listener listener : mListeners) {
listener.onStatusChange(report);
}
}
/**
* Retries all failed requests.
*
* @return True if any request is retried, false otherwise.
*/
public boolean retryAllFailed() {
final RequestWrapper[] toBeRetried = new RequestWrapper[RequestType.values().length];
boolean retried = false;
synchronized (mLock) {
for (int i = 0; i < RequestType.values().length; i++) {
toBeRetried[i] = mRequestQueues[i].mFailed;
mRequestQueues[i].mFailed = null;
}
}
for (RequestWrapper failed : toBeRetried) {
if (failed != null) {
failed.retry(mRetryService);
retried = true;
}
}
return retried;
}
static class RequestWrapper implements Runnable {
@NonNull
final Request mRequest;
@NonNull
final PagingRequestHelper mHelper;
@NonNull
final RequestType mType;
RequestWrapper(@NonNull Request request, @NonNull PagingRequestHelper helper,
@NonNull RequestType type) {
mRequest = request;
mHelper = helper;
mType = type;
}
@Override
public void run() {
mRequest.run(new Request.Callback(this, mHelper));
}
void retry(Executor service) {
service.execute(new Runnable() {
@Override
public void run() {
mHelper.runIfNotRunning(mType, mRequest);
}
});
}
}
/**
* Runner class that runs a request tracked by the {@link PagingRequestHelper}.
* <p>
* When a request is invoked, it must call one of {@link Callback#recordFailure(Throwable)}
* or {@link Callback#recordSuccess()} once and only once. This call
* can be made any time. Until that method call is made, {@link PagingRequestHelper} will
* consider the request is running.
*/
@FunctionalInterface
public interface Request {
/**
* Should run the request and call the given {@link Callback} with the result of the
* request.
*
* @param callback The callback that should be invoked with the result.
*/
void run(Callback callback);
/**
* Callback class provided to the {@link #run(Callback)} method to report the result.
*/
class Callback {
private final AtomicBoolean mCalled = new AtomicBoolean();
private final RequestWrapper mWrapper;
private final PagingRequestHelper mHelper;
Callback(RequestWrapper wrapper, PagingRequestHelper helper) {
mWrapper = wrapper;
mHelper = helper;
}
/**
* Call this method when the request succeeds and new data is fetched.
*/
@SuppressWarnings("unused")
public final void recordSuccess() {
if (mCalled.compareAndSet(false, true)) {
mHelper.recordResult(mWrapper, null);
} else {
throw new IllegalStateException(
"already called recordSuccess or recordFailure");
}
}
/**
* Call this method with the failure message and the request can be retried via
* {@link #retryAllFailed()}.
*
* @param throwable The error that occured while carrying out the request.
*/
@SuppressWarnings("unused")
public final void recordFailure(@NonNull Throwable throwable) {
//noinspection ConstantConditions
if (throwable == null) {
throw new IllegalArgumentException("You must provide a throwable describing"
+ " the error to record the failure");
}
if (mCalled.compareAndSet(false, true)) {
mHelper.recordResult(mWrapper, throwable);
} else {
throw new IllegalStateException(
"already called recordSuccess or recordFailure");
}
}
}
}
/**
* Data class that holds the information about the current status of the ongoing requests
* using this helper.
*/
public static final class StatusReport {
/**
* Status of the latest request that were submitted with {@link RequestType#INITIAL}.
*/
@NonNull
public final Status initial;
/**
* Status of the latest request that were submitted with {@link RequestType#BEFORE}.
*/
@NonNull
public final Status before;
/**
* Status of the latest request that were submitted with {@link RequestType#AFTER}.
*/
@NonNull
public final Status after;
@NonNull
private final Throwable[] mErrors;
StatusReport(@NonNull Status initial, @NonNull Status before, @NonNull Status after,
@NonNull Throwable[] errors) {
this.initial = initial;
this.before = before;
this.after = after;
this.mErrors = errors;
}
/**
* Convenience method to check if there are any running requests.
*
* @return True if there are any running requests, false otherwise.
*/
public boolean hasRunning() {
return initial == Status.RUNNING
|| before == Status.RUNNING
|| after == Status.RUNNING;
}
/**
* Convenience method to check if there are any requests that resulted in an error.
*
* @return True if there are any requests that finished with error, false otherwise.
*/
public boolean hasError() {
return initial == Status.FAILED
|| before == Status.FAILED
|| after == Status.FAILED;
}
/**
* Returns the error for the given request type.
*
* @param type The request type for which the error should be returned.
* @return The {@link Throwable} returned by the failing request with the given type or
* {@code null} if the request for the given type did not fail.
*/
@Nullable
public Throwable getErrorFor(@NonNull RequestType type) {
return mErrors[type.ordinal()];
}
@Override
public String toString() {
return "StatusReport{"
+ "initial=" + initial
+ ", before=" + before
+ ", after=" + after
+ ", mErrors=" + Arrays.toString(mErrors)
+ '}';
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
StatusReport that = (StatusReport) o;
if (initial != that.initial) return false;
if (before != that.before) return false;
if (after != that.after) return false;
// Probably incorrect - comparing Object[] arrays with Arrays.equals
return Arrays.equals(mErrors, that.mErrors);
}
@Override
public int hashCode() {
int result = initial.hashCode();
result = 31 * result + before.hashCode();
result = 31 * result + after.hashCode();
result = 31 * result + Arrays.hashCode(mErrors);
return result;
}
}
/**
* Listener interface to get notified by request status changes.
*/
public interface Listener {
/**
* Called when the status for any of the requests has changed.
*
* @param report The current status report that has all the information about the requests.
*/
void onStatusChange(@NonNull StatusReport report);
}
/**
* Represents the status of a Request for each {@link RequestType}.
*/
public enum Status {
/**
* There is current a running request.
*/
RUNNING,
/**
* The last request has succeeded or no such requests have ever been run.
*/
SUCCESS,
/**
* The last request has failed.
*/
FAILED
}
/**
* Available request types.
*/
public enum RequestType {
/**
* Corresponds to an initial request made to a {@link androidx.paging.DataSource} or the empty state for
* a {@link androidx.paging.PagedList.BoundaryCallback BoundaryCallback}.
*/
INITIAL,
/**
* Corresponds to the {@code loadBefore} calls in {@link androidx.paging.DataSource} or
* {@code onItemAtFrontLoaded} in
* {@link androidx.paging.PagedList.BoundaryCallback BoundaryCallback}.
*/
BEFORE,
/**
* Corresponds to the {@code loadAfter} calls in {@link androidx.paging.DataSource} or
* {@code onItemAtEndLoaded} in
* {@link androidx.paging.PagedList.BoundaryCallback BoundaryCallback}.
*/
AFTER
}
class RequestQueue {
@NonNull
final RequestType mRequestType;
@Nullable
RequestWrapper mFailed;
@Nullable
Request mRunning;
@Nullable
Throwable mLastError;
@NonNull
Status mStatus = Status.SUCCESS;
RequestQueue(@NonNull RequestType requestType) {
mRequestType = requestType;
}
}
}

View File

@ -1,23 +0,0 @@
package com.keylesspalace.tusky.util
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
private fun getErrorMessage(report: PagingRequestHelper.StatusReport): String {
return PagingRequestHelper.RequestType.values().mapNotNull {
report.getErrorFor(it)?.message
}.first()
}
fun PagingRequestHelper.createStatusLiveData(): LiveData<NetworkState> {
val liveData = MutableLiveData<NetworkState>()
addListener { report ->
when {
report.hasRunning() -> liveData.postValue(NetworkState.LOADING)
report.hasError() -> liveData.postValue(
NetworkState.error(getErrorMessage(report)))
else -> liveData.postValue(NetworkState.LOADED)
}
}
return liveData
}