diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/ReportActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/ReportActivity.kt index 57c9214c..4c53588a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/report/ReportActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/report/ReportActivity.kt @@ -51,7 +51,6 @@ class ReportActivity : BottomSheetActivity(), HasAndroidInjector { viewModel.init(accountId, accountUserName, intent?.getStringExtra(STATUS_ID)) - setContentView(binding.root) setSupportActionBar(binding.includedToolbar.toolbar) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/ReportViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/ReportViewModel.kt index e5691473..7b51e91c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/report/ReportViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/report/ReportViewModel.kt @@ -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() val navigation: LiveData = navigationMutable @@ -52,11 +62,19 @@ class ReportViewModel @Inject constructor( private val checkUrlMutable = MutableLiveData() val checkUrl: LiveData = checkUrlMutable - private val repoResult = MutableLiveData>() - val statuses: LiveData> = Transformations.switchMap(repoResult) { it.pagedList } - val networkStateAfter: LiveData = Transformations.switchMap(repoResult) { it.networkStateAfter } - val networkStateBefore: LiveData = Transformations.switchMap(repoResult) { it.networkStateBefore } - val networkStateRefresh: LiveData = Transformations.switchMap(repoResult) { it.refreshState } + private val accountIdFlow = MutableSharedFlow( + 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() 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) } - -} \ No newline at end of file +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesAdapter.kt index b66ac4f3..d472995d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesAdapter.kt @@ -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_COMPARATOR) { +) : PagingDataAdapter(STATUS_COMPARATOR) { private val statusForPosition: (Int) -> Status? = { position: Int -> if (position != RecyclerView.NO_POSITION) getItem(position) else null diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesDataSource.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesDataSource.kt deleted file mode 100644 index 9566214c..00000000 --- a/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesDataSource.kt +++ /dev/null @@ -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 . */ - -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() { - - val networkStateAfter = MutableLiveData() - val networkStateBefore = MutableLiveData() - - private var retryAfter: (() -> Any)? = null - private var retryBefore: (() -> Any)? = null - private var retryInitial: (() -> Any)? = null - - val initialLoad = MutableLiveData() - 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, callback: LoadInitialCallback) { - 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 -> - val ret = ArrayList() - 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, callback: LoadCallback) { - 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, callback: LoadCallback) { - 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 -} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesDataSourceFactory.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesDataSourceFactory.kt deleted file mode 100644 index 1afdc3c0..00000000 --- a/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesDataSourceFactory.kt +++ /dev/null @@ -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 . */ - -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() { - val sourceLiveData = MutableLiveData() - override fun create(): DataSource { - val source = StatusesDataSource(accountId, mastodonApi, disposables, retryExecutor) - sourceLiveData.postValue(source) - return source - } -} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesPagingSource.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesPagingSource.kt new file mode 100644 index 00000000..964e23e2 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesPagingSource.kt @@ -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 . */ + +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() { + + override fun getRefreshKey(state: PagingState): String? { + return state.anchorPosition?.let { anchorPosition -> + state.closestItemToPosition(anchorPosition)?.id + } + } + + override suspend fun load(params: LoadParams): LoadResult { + 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 { + return mastodonApi.accountStatusesObservable( + accountId = accountId, + maxId = maxId, + sinceId = null, + minId = minId, + limit = limit, + excludeReblogs = true + ).await() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesRepository.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesRepository.kt deleted file mode 100644 index eb7866ac..00000000 --- a/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesRepository.kt +++ /dev/null @@ -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 . */ - -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 { - 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 - } - - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportNoteFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportNoteFragment.kt index b47b586a..aa355935 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportNoteFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportNoteFragment.kt @@ -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() { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportStatusesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportStatusesFragment.kt index 01a12c23..33cd2ece 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportStatusesFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportStatusesFragment.kt @@ -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) diff --git a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt index 6aadc9b7..96f05349 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt +++ b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt @@ -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> diff --git a/app/src/main/java/com/keylesspalace/tusky/util/BiListing.kt b/app/src/main/java/com/keylesspalace/tusky/util/BiListing.kt deleted file mode 100644 index 268631cb..00000000 --- a/app/src/main/java/com/keylesspalace/tusky/util/BiListing.kt +++ /dev/null @@ -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( - // the LiveData of paged lists for the UI to observe - val pagedList: LiveData>, - // represents the network request status for load data before first to show to the user - val networkStateBefore: LiveData, - // represents the network request status for load data after last to show to the user - val networkStateAfter: LiveData, - // represents the refresh status to show to the user. Separate from networkState, this - // value is importantly only when refresh is requested. - val refreshState: LiveData, - // refreshes the whole data and fetches it from scratch. - val refresh: () -> Unit, - // retries any failed requests. - val retry: () -> Unit) \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/util/PagingRequestHelper.java b/app/src/main/java/com/keylesspalace/tusky/util/PagingRequestHelper.java deleted file mode 100644 index 4f7d3eff..00000000 --- a/app/src/main/java/com/keylesspalace/tusky/util/PagingRequestHelper.java +++ /dev/null @@ -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. - *

- * 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)}. - *

- * It tracks a {@link Status} and an {@code error} for each {@link RequestType}. - *

- * A sample usage of this class to limit requests looks like this: - *

- * class PagingBoundaryCallback extends PagedList.BoundaryCallback<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<ApiResponse>() {
- *                             {@literal @}Override
- *                             public void onResponse(Call<ApiResponse> call,
- *                                     Response<ApiResponse> response) {
- *                                 // TODO insert new records into database
- *                                 helperCallback.recordSuccess();
- *                             }
- *
- *                             {@literal @}Override
- *                             public void onFailure(Call<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<ApiResponse>() {
- *                             {@literal @}Override
- *                             public void onResponse(Call<ApiResponse> call,
- *                                     Response<ApiResponse> response) {
- *                                 // TODO insert new records into database
- *                                 helperCallback.recordSuccess();
- *                             }
- *
- *                             {@literal @}Override
- *                             public void onFailure(Call<ApiResponse> call, Throwable t) {
- *                                 helperCallback.recordFailure(t);
- *                             }
- *                         }));
- *     }
- * }
- * 
- *

- * The helper provides an API to observe combined request status, which can be reported back to the - * application based on your business rules. - *

- * MutableLiveData<PagingRequestHelper.Status> combined = new MutableLiveData<>();
- * 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);
- *     }
- * });
- * 
- */ -// 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 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. - *

- * 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}. - *

- * 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; - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/util/getErrorMessage.kt b/app/src/main/java/com/keylesspalace/tusky/util/getErrorMessage.kt deleted file mode 100644 index b003cb2d..00000000 --- a/app/src/main/java/com/keylesspalace/tusky/util/getErrorMessage.kt +++ /dev/null @@ -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 { - val liveData = MutableLiveData() - addListener { report -> - when { - report.hasRunning() -> liveData.postValue(NetworkState.LOADING) - report.hasError() -> liveData.postValue( - NetworkState.error(getErrorMessage(report))) - else -> liveData.postValue(NetworkState.LOADED) - } - } - return liveData -} \ No newline at end of file