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
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)