change: Implement more of FiltersRepository (#816)
The previous code had a number of problems, including: - Calls to the filters API were scattered through UI and viewmodel code. - Repeated places where the differences between the v1 and v2 Mastodon filters API had to be handled. - UI and viewmodel code using the network filter classes, which tied them to the API implementation. - Error handling was inconsistent. Fix this. ## FiltersRepository - All filter management now goes through `FiltersRepository`. - `FiltersRepository` exposes the current set of filters as a `StateFlow`, and automatically updates it when the current server changes or any changes to filters are made. This makes `FilterChangeEvent` obsolete. - Other operations on filters are exposed through `FiltersRepository` as functions for viewmodels to call. - Within the bulk of the app a new `Filter` class is used to represent a filter; handling the differences between the v1 and v2 APIs is encapsulated in `FiltersRepository`. - Represent errors when handling filters as subclasses of `PachliError`, and use `Result<V, E>` throughout, including using `ApiResult` for all filter API results. - Provide different types to distinguish between new-and-unsaved filters, new-and-unsaved keywords, and in-progress edits to filters. ## Editing filters - Accept an optional complete filter, or filter ID, as parameters in the intent that launches `EditFilterActivity`. Pass those to the viewmodel using assisted injection so the viewmodel has the info immediately. - In the viewmodel use a new `FilterViewData` type to model the data used to display and edit the filter. - Start using the UiSuccess/UiError model. Refrain from cutting over to full the action implementation as that would be a much larger change. - Use `FiltersRepository` instead of making any API calls directly. ## Listing filters - Use `FiltersRepository` instead of making any API calls directly. ## EventHub - Remove `FilterChangedEvent`. Update everywhere that used it to use the flow from `FiltersRepository`.
This commit is contained in:
parent
1177948c9b
commit
00a2cd32d3
|
@ -26,33 +26,31 @@ import androidx.core.view.MenuProvider
|
|||
import androidx.fragment.app.commit
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import app.pachli.appstore.EventHub
|
||||
import app.pachli.appstore.FilterChangedEvent
|
||||
import app.pachli.appstore.MainTabsChangedEvent
|
||||
import app.pachli.core.activity.BottomSheetActivity
|
||||
import app.pachli.core.common.extensions.viewBinding
|
||||
import app.pachli.core.common.util.unsafeLazy
|
||||
import app.pachli.core.data.repository.ServerRepository
|
||||
import app.pachli.core.data.model.Filter
|
||||
import app.pachli.core.data.model.NewFilterKeyword
|
||||
import app.pachli.core.data.repository.FilterEdit
|
||||
import app.pachli.core.data.repository.FiltersRepository
|
||||
import app.pachli.core.data.repository.NewFilter
|
||||
import app.pachli.core.model.Timeline
|
||||
import app.pachli.core.navigation.TimelineActivityIntent
|
||||
import app.pachli.core.network.ServerOperation.ORG_JOINMASTODON_FILTERS_CLIENT
|
||||
import app.pachli.core.network.ServerOperation.ORG_JOINMASTODON_FILTERS_SERVER
|
||||
import app.pachli.core.network.model.Filter
|
||||
import app.pachli.core.network.model.FilterContext
|
||||
import app.pachli.core.network.model.FilterV1
|
||||
import app.pachli.databinding.ActivityTimelineBinding
|
||||
import app.pachli.interfaces.ActionButtonActivity
|
||||
import app.pachli.interfaces.AppBarLayoutHost
|
||||
import at.connyduck.calladapter.networkresult.fold
|
||||
import com.github.michaelbull.result.getOrElse
|
||||
import com.github.michaelbull.result.onFailure
|
||||
import com.github.michaelbull.result.onSuccess
|
||||
import com.google.android.material.appbar.AppBarLayout
|
||||
import com.google.android.material.floatingactionbutton.FloatingActionButton
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import io.github.z4kn4fein.semver.constraints.toConstraint
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import retrofit2.HttpException
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
|
@ -64,7 +62,7 @@ class TimelineActivity : BottomSheetActivity(), AppBarLayoutHost, ActionButtonAc
|
|||
lateinit var eventHub: EventHub
|
||||
|
||||
@Inject
|
||||
lateinit var serverRepository: ServerRepository
|
||||
lateinit var filtersRepository: FiltersRepository
|
||||
|
||||
private val binding: ActivityTimelineBinding by viewBinding(ActivityTimelineBinding::inflate)
|
||||
private lateinit var timeline: Timeline
|
||||
|
@ -85,7 +83,6 @@ class TimelineActivity : BottomSheetActivity(), AppBarLayoutHost, ActionButtonAc
|
|||
private var unmuteTagItem: MenuItem? = null
|
||||
|
||||
/** The filter muting hashtag, null if unknown or hashtag is not filtered */
|
||||
private var mutedFilterV1: FilterV1? = null
|
||||
private var mutedFilter: Filter? = null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
|
@ -238,14 +235,9 @@ class TimelineActivity : BottomSheetActivity(), AppBarLayoutHost, ActionButtonAc
|
|||
private fun updateMuteTagMenuItems() {
|
||||
val tagWithHash = hashtag?.let { "#$it" } ?: return
|
||||
|
||||
// If there's no server info, or the server can't filter then it's impossible
|
||||
// to mute hashtags, so disable the functionality.
|
||||
val server = serverRepository.flow.value.getOrElse { null }
|
||||
if (server == null || (
|
||||
!server.can(ORG_JOINMASTODON_FILTERS_CLIENT, ">=1.0.0".toConstraint()) &&
|
||||
!server.can(ORG_JOINMASTODON_FILTERS_SERVER, ">=1.0.0".toConstraint())
|
||||
)
|
||||
) {
|
||||
// If the server can't filter then it's impossible to mute hashtags, so disable
|
||||
// the functionality.
|
||||
if (!filtersRepository.canFilter()) {
|
||||
muteTagItem?.isVisible = false
|
||||
unmuteTagItem?.isVisible = false
|
||||
return
|
||||
|
@ -256,33 +248,15 @@ class TimelineActivity : BottomSheetActivity(), AppBarLayoutHost, ActionButtonAc
|
|||
unmuteTagItem?.isVisible = false
|
||||
|
||||
lifecycleScope.launch {
|
||||
mastodonApi.getFilters().fold(
|
||||
{ filters ->
|
||||
mutedFilter = filters.firstOrNull { filter ->
|
||||
filter.contexts.contains(FilterContext.HOME) && filter.keywords.any {
|
||||
it.keyword == tagWithHash
|
||||
}
|
||||
filtersRepository.filters.collect { result ->
|
||||
result.onSuccess { filters ->
|
||||
mutedFilter = filters?.filters?.firstOrNull { filter ->
|
||||
filter.contexts.contains(FilterContext.HOME) &&
|
||||
filter.keywords.any { it.keyword == tagWithHash }
|
||||
}
|
||||
updateTagMuteState(mutedFilter != null)
|
||||
},
|
||||
{ throwable ->
|
||||
if (throwable is HttpException && throwable.code() == 404) {
|
||||
mastodonApi.getFiltersV1().fold(
|
||||
{ filters ->
|
||||
mutedFilterV1 = filters.firstOrNull { filter ->
|
||||
tagWithHash == filter.phrase && filter.contexts.contains(FilterContext.HOME)
|
||||
}
|
||||
updateTagMuteState(mutedFilterV1 != null)
|
||||
},
|
||||
{ throwable ->
|
||||
Timber.e(throwable, "Error getting filters")
|
||||
},
|
||||
)
|
||||
} else {
|
||||
Timber.e(throwable, "Error getting filters")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -298,108 +272,57 @@ class TimelineActivity : BottomSheetActivity(), AppBarLayoutHost, ActionButtonAc
|
|||
}
|
||||
}
|
||||
|
||||
private fun muteTag(): Boolean {
|
||||
val tagWithHash = hashtag?.let { "#$it" } ?: return true
|
||||
private fun muteTag() {
|
||||
val tagWithHash = hashtag?.let { "#$it" } ?: return
|
||||
|
||||
lifecycleScope.launch {
|
||||
mastodonApi.createFilter(
|
||||
val newFilter = NewFilter(
|
||||
title = tagWithHash,
|
||||
context = listOf(FilterContext.HOME),
|
||||
filterAction = Filter.Action.WARN,
|
||||
expiresInSeconds = null,
|
||||
).fold(
|
||||
{ filter ->
|
||||
if (mastodonApi.addFilterKeyword(filterId = filter.id, keyword = tagWithHash, wholeWord = true).isSuccess) {
|
||||
mutedFilter = filter
|
||||
updateTagMuteState(true)
|
||||
eventHub.dispatch(FilterChangedEvent(filter.contexts[0]))
|
||||
Snackbar.make(binding.root, getString(R.string.confirmation_hashtag_muted, hashtag), Snackbar.LENGTH_SHORT).show()
|
||||
} else {
|
||||
Snackbar.make(binding.root, getString(R.string.error_muting_hashtag_format, hashtag), Snackbar.LENGTH_SHORT).show()
|
||||
Timber.e("Failed to mute %s", tagWithHash)
|
||||
}
|
||||
},
|
||||
{ throwable ->
|
||||
if (throwable is HttpException && throwable.code() == 404) {
|
||||
mastodonApi.createFilterV1(
|
||||
tagWithHash,
|
||||
listOf(FilterContext.HOME),
|
||||
irreversible = false,
|
||||
contexts = setOf(FilterContext.HOME),
|
||||
action = app.pachli.core.network.model.Filter.Action.WARN,
|
||||
expiresIn = 0,
|
||||
keywords = listOf(
|
||||
NewFilterKeyword(
|
||||
keyword = tagWithHash,
|
||||
wholeWord = true,
|
||||
expiresInSeconds = null,
|
||||
).fold(
|
||||
{ filter ->
|
||||
mutedFilterV1 = filter
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
filtersRepository.createFilter(newFilter)
|
||||
.onSuccess {
|
||||
mutedFilter = it
|
||||
updateTagMuteState(true)
|
||||
eventHub.dispatch(FilterChangedEvent(filter.contexts[0]))
|
||||
Snackbar.make(binding.root, getString(R.string.confirmation_hashtag_muted, hashtag), Snackbar.LENGTH_SHORT).show()
|
||||
},
|
||||
{ throwable ->
|
||||
Snackbar.make(binding.root, getString(R.string.error_muting_hashtag_format, hashtag), Snackbar.LENGTH_SHORT).show()
|
||||
Timber.e(throwable, "Failed to mute %s", tagWithHash)
|
||||
},
|
||||
)
|
||||
} else {
|
||||
Snackbar.make(binding.root, getString(R.string.error_muting_hashtag_format, hashtag), Snackbar.LENGTH_SHORT).show()
|
||||
Timber.e(throwable, "Failed to mute %s", tagWithHash)
|
||||
}
|
||||
},
|
||||
)
|
||||
.onFailure {
|
||||
Snackbar.make(binding.root, getString(R.string.error_muting_hashtag_format, hashtag), Snackbar.LENGTH_SHORT).show()
|
||||
Timber.e("Failed to mute %s: %s", tagWithHash, it.fmt(this@TimelineActivity))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private fun unmuteTag(): Boolean {
|
||||
private fun unmuteTag() {
|
||||
lifecycleScope.launch {
|
||||
val tagWithHash = hashtag?.let { "#$it" } ?: return@launch
|
||||
|
||||
val result = if (mutedFilter != null) {
|
||||
val filter = mutedFilter!!
|
||||
if (filter.contexts.size > 1) {
|
||||
// This filter exists in multiple contexts, just remove the home context
|
||||
mastodonApi.updateFilter(
|
||||
id = filter.id,
|
||||
context = filter.contexts.filter { it != FilterContext.HOME },
|
||||
)
|
||||
val result = mutedFilter?.let { filter ->
|
||||
val newContexts = filter.contexts.filter { it != FilterContext.HOME }
|
||||
if (newContexts.isEmpty()) {
|
||||
filtersRepository.deleteFilter(filter.id)
|
||||
} else {
|
||||
mastodonApi.deleteFilter(filter.id)
|
||||
filtersRepository.updateFilter(filter, FilterEdit(filter.id, contexts = newContexts))
|
||||
}
|
||||
} else if (mutedFilterV1 != null) {
|
||||
mutedFilterV1?.let { filter ->
|
||||
if (filter.contexts.size > 1) {
|
||||
// This filter exists in multiple contexts, just remove the home context
|
||||
mastodonApi.updateFilterV1(
|
||||
id = filter.id,
|
||||
phrase = filter.phrase,
|
||||
context = filter.contexts.filter { it != FilterContext.HOME },
|
||||
irreversible = null,
|
||||
wholeWord = null,
|
||||
expiresInSeconds = null,
|
||||
)
|
||||
} else {
|
||||
mastodonApi.deleteFilterV1(filter.id)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
result?.fold(
|
||||
{
|
||||
result?.onSuccess {
|
||||
updateTagMuteState(false)
|
||||
Snackbar.make(binding.root, getString(R.string.confirmation_hashtag_unmuted, hashtag), Snackbar.LENGTH_SHORT).show()
|
||||
eventHub.dispatch(FilterChangedEvent(FilterContext.HOME))
|
||||
mutedFilterV1 = null
|
||||
mutedFilter = null
|
||||
},
|
||||
{ throwable ->
|
||||
}?.onFailure { e ->
|
||||
Snackbar.make(binding.root, getString(R.string.error_unmuting_hashtag_format, hashtag), Snackbar.LENGTH_SHORT).show()
|
||||
Timber.e(throwable, "Failed to unmute %s", tagWithHash)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return true
|
||||
Timber.e("Failed to unmute %s: %s", tagWithHash, e.fmt(this@TimelineActivity))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,7 +2,6 @@ package app.pachli.appstore
|
|||
|
||||
import app.pachli.core.model.Timeline
|
||||
import app.pachli.core.network.model.Account
|
||||
import app.pachli.core.network.model.FilterContext
|
||||
import app.pachli.core.network.model.Poll
|
||||
import app.pachli.core.network.model.Status
|
||||
|
||||
|
@ -21,7 +20,6 @@ data class StatusComposedEvent(val status: Status) : Event
|
|||
data object StatusScheduledEvent : Event
|
||||
data class StatusEditedEvent(val originalId: String, val status: Status) : Event
|
||||
data class ProfileEditedEvent(val newProfileData: Account) : Event
|
||||
data class FilterChangedEvent(val filterContext: FilterContext) : Event
|
||||
data class MainTabsChangedEvent(val newTabs: List<Timeline>) : Event
|
||||
data class PollVoteEvent(val statusId: String, val poll: Poll) : Event
|
||||
data class DomainMuteEvent(val instance: String) : Event
|
||||
|
|
|
@ -1,59 +1,75 @@
|
|||
package app.pachli.components.filters
|
||||
|
||||
import android.content.Context
|
||||
import android.content.DialogInterface.BUTTON_NEGATIVE
|
||||
import android.content.DialogInterface.BUTTON_POSITIVE
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.AdapterView
|
||||
import android.widget.ArrayAdapter
|
||||
import android.widget.TextView
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.activity.viewModels
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.size
|
||||
import androidx.core.widget.doAfterTextChanged
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import app.pachli.R
|
||||
import app.pachli.appstore.EventHub
|
||||
import app.pachli.core.activity.BaseActivity
|
||||
import app.pachli.core.common.extensions.viewBinding
|
||||
import app.pachli.core.common.extensions.visible
|
||||
import app.pachli.core.data.model.FilterValidationError
|
||||
import app.pachli.core.navigation.EditFilterActivityIntent
|
||||
import app.pachli.core.network.model.Filter
|
||||
import app.pachli.core.network.model.FilterContext
|
||||
import app.pachli.core.network.model.FilterKeyword
|
||||
import app.pachli.core.network.retrofit.MastodonApi
|
||||
import app.pachli.core.ui.extensions.await
|
||||
import app.pachli.databinding.ActivityEditFilterBinding
|
||||
import app.pachli.databinding.DialogFilterBinding
|
||||
import at.connyduck.calladapter.networkresult.fold
|
||||
import com.github.michaelbull.result.Result
|
||||
import com.github.michaelbull.result.get
|
||||
import com.github.michaelbull.result.onFailure
|
||||
import com.github.michaelbull.result.onSuccess
|
||||
import com.google.android.material.chip.Chip
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.google.android.material.switchmaterial.SwitchMaterial
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import javax.inject.Inject
|
||||
import dagger.hilt.android.lifecycle.withCreationCallback
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.launch
|
||||
import retrofit2.HttpException
|
||||
|
||||
/**
|
||||
* Edit a single server-side filter.
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class EditFilterActivity : BaseActivity() {
|
||||
@Inject
|
||||
lateinit var api: MastodonApi
|
||||
|
||||
@Inject
|
||||
lateinit var eventHub: EventHub
|
||||
|
||||
private val binding by viewBinding(ActivityEditFilterBinding::inflate)
|
||||
private val viewModel: EditFilterViewModel by viewModels()
|
||||
|
||||
private lateinit var filter: Filter
|
||||
private var originalFilter: Filter? = null
|
||||
// Pass the optional filter and filterId values from the intent to
|
||||
// EditFilterViewModel.
|
||||
private val viewModel: EditFilterViewModel by viewModels(
|
||||
extrasProducer = {
|
||||
defaultViewModelCreationExtras.withCreationCallback<EditFilterViewModel.Factory> { factory ->
|
||||
factory.create(
|
||||
EditFilterActivityIntent.getFilter(intent),
|
||||
EditFilterActivityIntent.getFilterId(intent),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
private lateinit var filterDurationAdapter: FilterDurationAdapter
|
||||
|
||||
private lateinit var filterContextSwitches: Map<SwitchMaterial, FilterContext>
|
||||
|
||||
/** The active snackbar */
|
||||
private var snackbar: Snackbar? = null
|
||||
|
||||
private val onBackPressedCallback = object : OnBackPressedCallback(true) {
|
||||
override fun handleOnBackPressed() {
|
||||
lifecycleScope.launch {
|
||||
|
@ -66,8 +82,6 @@ class EditFilterActivity : BaseActivity() {
|
|||
super.onCreate(savedInstanceState)
|
||||
onBackPressedDispatcher.addCallback(onBackPressedCallback)
|
||||
|
||||
originalFilter = EditFilterActivityIntent.getFilter(intent)
|
||||
filter = originalFilter ?: Filter()
|
||||
binding.apply {
|
||||
filterContextSwitches = mapOf(
|
||||
filterContextHome to FilterContext.HOME,
|
||||
|
@ -86,21 +100,35 @@ class EditFilterActivity : BaseActivity() {
|
|||
}
|
||||
|
||||
setTitle(
|
||||
if (originalFilter == null) {
|
||||
R.string.filter_addition_title
|
||||
} else {
|
||||
R.string.filter_edit_title
|
||||
when (viewModel.uiMode) {
|
||||
UiMode.CREATE -> R.string.filter_addition_title
|
||||
UiMode.EDIT -> R.string.filter_edit_title
|
||||
},
|
||||
)
|
||||
|
||||
binding.actionChip.setOnClickListener { showAddKeywordDialog() }
|
||||
|
||||
filterDurationAdapter = FilterDurationAdapter(this, viewModel.uiMode)
|
||||
binding.filterDurationSpinner.adapter = filterDurationAdapter
|
||||
binding.filterDurationSpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
|
||||
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
|
||||
viewModel.setExpiresIn(filterDurationAdapter.getItem(position)!!.duration)
|
||||
}
|
||||
|
||||
override fun onNothingSelected(parent: AdapterView<*>?) {
|
||||
viewModel.setExpiresIn(0)
|
||||
}
|
||||
}
|
||||
|
||||
binding.filterSaveButton.setOnClickListener { saveChanges() }
|
||||
binding.filterDeleteButton.setOnClickListener {
|
||||
lifecycleScope.launch {
|
||||
if (showDeleteFilterDialog(filter.title) == BUTTON_POSITIVE) deleteFilter()
|
||||
viewModel.filterViewData.value.get()?.let {
|
||||
if (showDeleteFilterDialog(it.title) == BUTTON_POSITIVE) deleteFilter()
|
||||
}
|
||||
}
|
||||
binding.filterDeleteButton.visible(originalFilter != null)
|
||||
}
|
||||
binding.filterDeleteButton.visible(viewModel.uiMode == UiMode.EDIT)
|
||||
|
||||
for (switch in filterContextSwitches.keys) {
|
||||
switch.setOnCheckedChangeListener { _, isChecked ->
|
||||
|
@ -108,7 +136,7 @@ class EditFilterActivity : BaseActivity() {
|
|||
if (isChecked) {
|
||||
viewModel.addContext(context)
|
||||
} else {
|
||||
viewModel.removeContext(context)
|
||||
viewModel.deleteContext(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -116,73 +144,23 @@ class EditFilterActivity : BaseActivity() {
|
|||
viewModel.setTitle(editable.toString())
|
||||
}
|
||||
binding.filterActionWarn.setOnCheckedChangeListener { _, checked ->
|
||||
viewModel.setAction(
|
||||
if (checked) {
|
||||
Filter.Action.WARN
|
||||
} else {
|
||||
Filter.Action.HIDE
|
||||
},
|
||||
)
|
||||
}
|
||||
binding.filterDurationSpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
|
||||
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
|
||||
viewModel.setDuration(
|
||||
if (originalFilter?.expiresAt == null) {
|
||||
position
|
||||
} else {
|
||||
position - 1
|
||||
},
|
||||
)
|
||||
viewModel.setAction(if (checked) Filter.Action.WARN else Filter.Action.HIDE)
|
||||
}
|
||||
|
||||
override fun onNothingSelected(parent: AdapterView<*>?) {
|
||||
viewModel.setDuration(0)
|
||||
}
|
||||
bind()
|
||||
}
|
||||
|
||||
loadFilter()
|
||||
observeModel()
|
||||
}
|
||||
private fun bind() {
|
||||
lifecycleScope.launch {
|
||||
repeatOnLifecycle(Lifecycle.State.RESUMED) {
|
||||
launch { viewModel.uiResult.collect(::bindUiResult) }
|
||||
|
||||
private fun observeModel() {
|
||||
lifecycleScope.launch {
|
||||
viewModel.title.collect { title ->
|
||||
if (title != binding.filterTitle.text.toString()) {
|
||||
// We also get this callback when typing in the field,
|
||||
// which messes with the cursor focus
|
||||
binding.filterTitle.setText(title)
|
||||
}
|
||||
}
|
||||
}
|
||||
lifecycleScope.launch {
|
||||
viewModel.keywords.collect { keywords ->
|
||||
updateKeywords(keywords)
|
||||
}
|
||||
}
|
||||
lifecycleScope.launch {
|
||||
viewModel.contexts.collect { contexts ->
|
||||
for ((key, value) in filterContextSwitches) {
|
||||
key.isChecked = contexts.contains(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
lifecycleScope.launch {
|
||||
viewModel.action.collect { action ->
|
||||
when (action) {
|
||||
Filter.Action.HIDE -> binding.filterActionHide.isChecked = true
|
||||
else -> binding.filterActionWarn.isChecked = true
|
||||
}
|
||||
}
|
||||
}
|
||||
launch { viewModel.filterViewData.collect(::bindFilter) }
|
||||
|
||||
lifecycleScope.launch {
|
||||
viewModel.isDirty.collectLatest { onBackPressedCallback.isEnabled = it }
|
||||
}
|
||||
launch { viewModel.isDirty.collectLatest { onBackPressedCallback.isEnabled = it } }
|
||||
|
||||
lifecycleScope.launch {
|
||||
launch {
|
||||
viewModel.validationErrors.collectLatest { errors ->
|
||||
binding.filterSaveButton.isEnabled = errors.isEmpty()
|
||||
|
||||
binding.filterTitleWrapper.error = if (errors.contains(FilterValidationError.NO_TITLE)) {
|
||||
getString(R.string.error_filter_missing_title)
|
||||
} else {
|
||||
|
@ -193,18 +171,89 @@ class EditFilterActivity : BaseActivity() {
|
|||
binding.filterContextError.isVisible = errors.contains(FilterValidationError.NO_CONTEXT)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Populate the UI from the filter's members
|
||||
private fun loadFilter() {
|
||||
viewModel.load(filter)
|
||||
if (filter.expiresAt != null) {
|
||||
val durationNames = listOf(getString(R.string.duration_no_change)) + resources.getStringArray(R.array.filter_duration_names)
|
||||
binding.filterDurationSpinner.adapter = ArrayAdapter(this, android.R.layout.simple_list_item_1, durationNames)
|
||||
launch {
|
||||
viewModel.isDirty.combine(viewModel.validationErrors) { dirty, errors ->
|
||||
dirty && errors.isEmpty()
|
||||
}.collectLatest { binding.filterSaveButton.isEnabled = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateKeywords(newKeywords: List<FilterKeyword>) {
|
||||
/** Act on the result of UI actions */
|
||||
private fun bindUiResult(uiResult: Result<UiSuccess, UiError>) {
|
||||
uiResult.onFailure(::bindUiError)
|
||||
uiResult.onSuccess { uiSuccess ->
|
||||
when (uiSuccess) {
|
||||
UiSuccess.SaveFilter -> finish()
|
||||
UiSuccess.DeleteFilter -> finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun bindUiError(uiError: UiError) {
|
||||
val message = uiError.fmt(this)
|
||||
snackbar?.dismiss()
|
||||
try {
|
||||
Snackbar.make(binding.root, message, Snackbar.LENGTH_INDEFINITE).apply {
|
||||
setAction(app.pachli.core.ui.R.string.action_retry) {
|
||||
when (uiError) {
|
||||
is UiError.DeleteFilterError -> viewModel.deleteFilter()
|
||||
is UiError.GetFilterError -> viewModel.reload()
|
||||
is UiError.SaveFilterError -> viewModel.saveChanges()
|
||||
}
|
||||
}
|
||||
show()
|
||||
snackbar = this
|
||||
}
|
||||
} catch (_: IllegalArgumentException) {
|
||||
// On rare occasions this code is running before the fragment's
|
||||
// view is connected to the parent. This causes Snackbar.make()
|
||||
// to crash. See https://issuetracker.google.com/issues/228215869.
|
||||
// For now, swallow the exception.
|
||||
}
|
||||
}
|
||||
|
||||
private fun bindFilter(result: Result<FilterViewData?, UiError.GetFilterError>) {
|
||||
result.onFailure(::bindUiError)
|
||||
|
||||
result.onSuccess { filterViewData ->
|
||||
filterViewData ?: return
|
||||
|
||||
when (val expiresIn = filterViewData.expiresIn) {
|
||||
-1 -> binding.filterDurationSpinner.setSelection(0)
|
||||
else -> {
|
||||
filterDurationAdapter.items.indexOfFirst { it.duration == expiresIn }.let {
|
||||
if (it == -1) {
|
||||
binding.filterDurationSpinner.setSelection(0)
|
||||
} else {
|
||||
binding.filterDurationSpinner.setSelection(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (filterViewData.title != binding.filterTitle.text.toString()) {
|
||||
// We also get this callback when typing in the field,
|
||||
// which messes with the cursor focus
|
||||
binding.filterTitle.setText(filterViewData.title)
|
||||
}
|
||||
|
||||
bindKeywords(filterViewData.keywords)
|
||||
|
||||
for ((key, value) in filterContextSwitches) {
|
||||
key.isChecked = filterViewData.contexts.contains(value)
|
||||
}
|
||||
|
||||
when (filterViewData.action) {
|
||||
Filter.Action.HIDE -> binding.filterActionHide.isChecked = true
|
||||
else -> binding.filterActionWarn.isChecked = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun bindKeywords(newKeywords: List<FilterKeyword>) {
|
||||
newKeywords.forEachIndexed { index, filterKeyword ->
|
||||
val chip = binding.keywordChips.getChildAt(index).takeUnless {
|
||||
it.id == R.id.actionChip
|
||||
|
@ -234,8 +283,6 @@ class EditFilterActivity : BaseActivity() {
|
|||
while (binding.keywordChips.size - 1 > newKeywords.size) {
|
||||
binding.keywordChips.removeViewAt(newKeywords.size)
|
||||
}
|
||||
|
||||
filter = filter.copy(keywords = newKeywords)
|
||||
}
|
||||
|
||||
private fun showAddKeywordDialog() {
|
||||
|
@ -266,7 +313,7 @@ class EditFilterActivity : BaseActivity() {
|
|||
.setTitle(R.string.filter_edit_keyword_title)
|
||||
.setView(binding.root)
|
||||
.setPositiveButton(R.string.filter_dialog_update_button) { _, _ ->
|
||||
viewModel.modifyKeyword(
|
||||
viewModel.updateKeyword(
|
||||
keyword,
|
||||
keyword.copy(
|
||||
keyword = binding.phraseEditText.text.toString(),
|
||||
|
@ -291,41 +338,57 @@ class EditFilterActivity : BaseActivity() {
|
|||
.create()
|
||||
.await(R.string.action_continue_edit, R.string.action_discard)
|
||||
|
||||
private fun saveChanges() {
|
||||
// TODO use a progress bar here (see EditProfileActivity/activity_edit_profile.xml for example)?
|
||||
private fun saveChanges() = viewModel.saveChanges()
|
||||
|
||||
lifecycleScope.launch {
|
||||
if (viewModel.saveChanges(this@EditFilterActivity)) {
|
||||
finish()
|
||||
} else {
|
||||
Snackbar.make(binding.root, "Error saving filter '${viewModel.title.value}'", Snackbar.LENGTH_SHORT).show()
|
||||
private fun deleteFilter() = viewModel.deleteFilter()
|
||||
}
|
||||
|
||||
data class FilterDuration(
|
||||
/** Filter duration, in seconds. -1 means no change, 0 means indefinite. */
|
||||
val duration: Int,
|
||||
/** Label to use for this duration. */
|
||||
val label: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* Displays [FilterDuration] derived from R.array.filter_duration_values and
|
||||
* R.array.filter_duration_labels.
|
||||
*
|
||||
* In addition, if [uiMode] is [UiMode.EDIT] an extra duration corresponding to
|
||||
* "no change" is included in the list of possible values.
|
||||
*/
|
||||
class FilterDurationAdapter(context: Context, uiMode: UiMode) : ArrayAdapter<FilterDuration>(
|
||||
context,
|
||||
android.R.layout.simple_list_item_1,
|
||||
) {
|
||||
val items = buildList {
|
||||
if (uiMode == UiMode.EDIT) {
|
||||
add(FilterDuration(-1, context.getString(R.string.duration_no_change)))
|
||||
}
|
||||
|
||||
val values = context.resources.getIntArray(R.array.filter_duration_values)
|
||||
val labels = context.resources.getStringArray(R.array.filter_duration_labels)
|
||||
assert(values.size == labels.size)
|
||||
|
||||
values.zip(labels) { value, label ->
|
||||
add(FilterDuration(duration = value, label = label))
|
||||
}
|
||||
}
|
||||
|
||||
private fun deleteFilter() {
|
||||
originalFilter?.let { filter ->
|
||||
lifecycleScope.launch {
|
||||
api.deleteFilter(filter.id).fold(
|
||||
{
|
||||
finish()
|
||||
},
|
||||
{ throwable ->
|
||||
if (throwable is HttpException && throwable.code() == 404) {
|
||||
api.deleteFilterV1(filter.id).fold(
|
||||
{
|
||||
finish()
|
||||
},
|
||||
{
|
||||
Snackbar.make(binding.root, "Error deleting filter '${filter.title}'", Snackbar.LENGTH_SHORT).show()
|
||||
},
|
||||
)
|
||||
} else {
|
||||
Snackbar.make(binding.root, "Error deleting filter '${filter.title}'", Snackbar.LENGTH_SHORT).show()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
init {
|
||||
addAll(items)
|
||||
}
|
||||
|
||||
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
|
||||
val view = super.getView(position, convertView, parent)
|
||||
getItem(position)?.let { item -> (view as? TextView)?.text = item.label }
|
||||
return view
|
||||
}
|
||||
|
||||
override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup): View {
|
||||
val view = super.getDropDownView(position, convertView, parent)
|
||||
getItem(position)?.let { item -> (view as? TextView)?.text = item.label }
|
||||
return view
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,257 +1,421 @@
|
|||
/*
|
||||
* Copyright 2024 Pachli Association
|
||||
*
|
||||
* This file is a part of Pachli.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* Pachli 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 Pachli; if not,
|
||||
* see <http://www.gnu.org/licenses>.
|
||||
*/
|
||||
|
||||
package app.pachli.components.filters
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import app.pachli.R
|
||||
import app.pachli.appstore.EventHub
|
||||
import app.pachli.appstore.FilterChangedEvent
|
||||
import app.pachli.core.network.model.Filter
|
||||
import app.pachli.components.filters.UiError.DeleteFilterError
|
||||
import app.pachli.components.filters.UiError.SaveFilterError
|
||||
import app.pachli.core.common.PachliError
|
||||
import app.pachli.core.common.extensions.mapIfNotNull
|
||||
import app.pachli.core.data.model.Filter
|
||||
import app.pachli.core.data.model.FilterValidationError
|
||||
import app.pachli.core.data.model.NewFilterKeyword
|
||||
import app.pachli.core.data.repository.FilterEdit
|
||||
import app.pachli.core.data.repository.FiltersRepository
|
||||
import app.pachli.core.data.repository.NewFilter
|
||||
import app.pachli.core.network.model.Filter as NetworkFilter
|
||||
import app.pachli.core.network.model.FilterContext
|
||||
import app.pachli.core.network.model.FilterKeyword
|
||||
import app.pachli.core.network.retrofit.MastodonApi
|
||||
import at.connyduck.calladapter.networkresult.fold
|
||||
import com.github.michaelbull.result.Err
|
||||
import com.github.michaelbull.result.Ok
|
||||
import com.github.michaelbull.result.Result
|
||||
import com.github.michaelbull.result.get
|
||||
import com.github.michaelbull.result.map
|
||||
import com.github.michaelbull.result.mapEither
|
||||
import com.github.michaelbull.result.mapError
|
||||
import com.github.michaelbull.result.onFailure
|
||||
import com.github.michaelbull.result.onSuccess
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import java.util.Date
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.withContext
|
||||
import retrofit2.HttpException
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.onSubscription
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@HiltViewModel
|
||||
class EditFilterViewModel @Inject constructor(val api: MastodonApi, val eventHub: EventHub) : ViewModel() {
|
||||
private lateinit var originalFilter: Filter
|
||||
val title = MutableStateFlow("")
|
||||
val keywords = MutableStateFlow(listOf<FilterKeyword>())
|
||||
val action = MutableStateFlow(Filter.Action.WARN)
|
||||
val duration = MutableStateFlow(0)
|
||||
val contexts = MutableStateFlow(listOf<FilterContext>())
|
||||
|
||||
/** Track whether the duration has been modified, for use in [onChange] */
|
||||
// TODO: Rethink how duration is shown in the UI.
|
||||
// Could show the actual end time with the date/time widget to set the duration,
|
||||
// along with dropdown for quick settings (1h, etc).
|
||||
private var durationIsDirty = false
|
||||
|
||||
private val _isDirty = MutableStateFlow(false)
|
||||
|
||||
/** True if the user has made unsaved changes to the filter */
|
||||
val isDirty = _isDirty.asStateFlow()
|
||||
|
||||
private val _validationErrors = MutableStateFlow(emptySet<FilterValidationError>())
|
||||
|
||||
/** True if the filter is valid and can be saved */
|
||||
val validationErrors = _validationErrors.asStateFlow()
|
||||
|
||||
fun load(filter: Filter) {
|
||||
originalFilter = filter
|
||||
title.value = filter.title
|
||||
keywords.value = filter.keywords
|
||||
action.value = filter.action
|
||||
duration.value = if (filter.expiresAt == null) {
|
||||
0
|
||||
} else {
|
||||
-1
|
||||
}
|
||||
contexts.value = filter.contexts
|
||||
}
|
||||
|
||||
fun addKeyword(keyword: FilterKeyword) {
|
||||
keywords.value += keyword
|
||||
onChange()
|
||||
}
|
||||
|
||||
fun deleteKeyword(keyword: FilterKeyword) {
|
||||
keywords.value = keywords.value.filterNot { it == keyword }
|
||||
onChange()
|
||||
}
|
||||
|
||||
fun modifyKeyword(original: FilterKeyword, updated: FilterKeyword) {
|
||||
val index = keywords.value.indexOf(original)
|
||||
if (index >= 0) {
|
||||
keywords.value = keywords.value.toMutableList().apply {
|
||||
set(index, updated)
|
||||
}
|
||||
onChange()
|
||||
}
|
||||
}
|
||||
|
||||
fun setTitle(title: String) {
|
||||
this.title.value = title
|
||||
onChange()
|
||||
}
|
||||
|
||||
fun setDuration(index: Int) {
|
||||
if (!durationIsDirty && duration.value != index) durationIsDirty = true
|
||||
|
||||
duration.value = index
|
||||
onChange()
|
||||
}
|
||||
|
||||
fun setAction(action: Filter.Action) {
|
||||
this.action.value = action
|
||||
onChange()
|
||||
}
|
||||
|
||||
fun addContext(filterContext: FilterContext) {
|
||||
if (!contexts.value.contains(filterContext)) {
|
||||
contexts.value += filterContext
|
||||
onChange()
|
||||
}
|
||||
}
|
||||
|
||||
fun removeContext(filterContext: FilterContext) {
|
||||
contexts.value = contexts.value.filter { it != filterContext }
|
||||
onChange()
|
||||
}
|
||||
|
||||
private fun validate() {
|
||||
_validationErrors.value = buildSet {
|
||||
if (title.value.isBlank()) add(FilterValidationError.NO_TITLE)
|
||||
if (keywords.value.isEmpty()) add(FilterValidationError.NO_KEYWORDS)
|
||||
if (contexts.value.isEmpty()) add(FilterValidationError.NO_CONTEXT)
|
||||
}
|
||||
/**
|
||||
* Data to show the filter in the UI.
|
||||
*/
|
||||
data class FilterViewData(
|
||||
/** Filter's ID. Null if this is a new, un-saved filter. */
|
||||
val id: String? = null,
|
||||
val title: String = "",
|
||||
val contexts: Set<FilterContext> = emptySet(),
|
||||
/**
|
||||
* The number of seconds in the future the filter should expire.
|
||||
* "-1" means "use the filter's current value".
|
||||
* "0" means "filter never expires".
|
||||
*/
|
||||
val expiresIn: Int = 0,
|
||||
val action: NetworkFilter.Action = NetworkFilter.Action.WARN,
|
||||
val keywords: List<FilterKeyword> = emptyList(),
|
||||
) {
|
||||
/**
|
||||
* @return Set of [FilterValidationError] given the current state of the
|
||||
* filter. Empty if there are no validation errors.
|
||||
*/
|
||||
fun validate() = buildSet {
|
||||
if (title.isBlank()) add(FilterValidationError.NO_TITLE)
|
||||
if (keywords.isEmpty()) add(FilterValidationError.NO_KEYWORDS)
|
||||
if (contexts.isEmpty()) add(FilterValidationError.NO_CONTEXT)
|
||||
}
|
||||
|
||||
/**
|
||||
* Call when the contents of the filter change; recalculates validity
|
||||
* and dirty state.
|
||||
* Calculates the difference between [filter] and `this`, returning an
|
||||
* [FilterEdit] that representes the differences.
|
||||
*/
|
||||
private fun onChange() {
|
||||
validate()
|
||||
fun diff(filter: Filter): FilterEdit {
|
||||
val title: String? = if (title != filter.title) title else null
|
||||
val contexts = if (contexts != filter.contexts) contexts else null
|
||||
val action = if (action != filter.action) action else null
|
||||
|
||||
if (durationIsDirty) {
|
||||
// Keywords to delete
|
||||
val (keywordsToAdd, existingKeywords) = keywords.partition { it.id == "" }
|
||||
val existingKeywordsMap = existingKeywords.associateBy { it.id }
|
||||
|
||||
// Delete any keywords that are in the original list but are not in the existing
|
||||
// keywords here.
|
||||
val keywordsToDelete = filter.keywords.filter { !existingKeywordsMap.contains(it.id) }
|
||||
|
||||
// Any keywords that are in the original filter and this one, but have different
|
||||
// values need to be modified.
|
||||
val keywordsToModify = buildList {
|
||||
val originalKeywords = filter.keywords.associateBy { it.id }
|
||||
originalKeywords.forEach {
|
||||
val originalKeyword = it.value
|
||||
|
||||
existingKeywordsMap[originalKeyword.id]?.let { existingKeyword ->
|
||||
if (existingKeyword != originalKeyword) add(existingKeyword)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return FilterEdit(
|
||||
id = filter.id,
|
||||
title = title,
|
||||
contexts = contexts,
|
||||
expiresIn = this.expiresIn,
|
||||
action = action,
|
||||
keywordsToDelete = keywordsToDelete.ifEmpty { null },
|
||||
keywordsToModify = keywordsToModify.ifEmpty { null },
|
||||
keywordsToAdd = keywordsToAdd.ifEmpty { null },
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun from(filter: Filter) = FilterViewData(
|
||||
id = filter.id,
|
||||
title = filter.title,
|
||||
contexts = filter.contexts,
|
||||
expiresIn = -1,
|
||||
action = filter.action,
|
||||
keywords = filter.keywords,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun NewFilter.Companion.from(filterViewData: FilterViewData) = NewFilter(
|
||||
title = filterViewData.title,
|
||||
contexts = filterViewData.contexts,
|
||||
expiresIn = filterViewData.expiresIn,
|
||||
action = filterViewData.action,
|
||||
keywords = filterViewData.keywords.map {
|
||||
NewFilterKeyword(
|
||||
keyword = it.keyword,
|
||||
wholeWord = it.wholeWord,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
/** Successful UI operations. */
|
||||
sealed interface UiSuccess {
|
||||
/** Filter was saved. */
|
||||
data object SaveFilter : UiSuccess
|
||||
|
||||
/** Filter was deleted. */
|
||||
data object DeleteFilter : UiSuccess
|
||||
}
|
||||
|
||||
/** Errors that can occur from actions the user takes in the UI. */
|
||||
sealed class UiError(
|
||||
@StringRes override val resourceId: Int,
|
||||
override val formatArgs: Array<out Any>? = null,
|
||||
) : PachliError {
|
||||
/**
|
||||
* Filter could not be loaded.
|
||||
*
|
||||
* @param filterId ID of the filter that could not be loaded.
|
||||
*/
|
||||
data class GetFilterError(val filterId: String, override val cause: PachliError) :
|
||||
UiError(R.string.error_load_filter_failed_fmt)
|
||||
|
||||
/** Filter could not be saved. */
|
||||
data class SaveFilterError(override val cause: PachliError) :
|
||||
UiError(R.string.error_save_filter_failed_fmt)
|
||||
|
||||
/** Filter could not be deleted. */
|
||||
data class DeleteFilterError(override val cause: PachliError) :
|
||||
UiError(R.string.error_delete_filter_failed_fmt)
|
||||
}
|
||||
|
||||
/** Mode the UI should operate in. */
|
||||
enum class UiMode {
|
||||
/** A new filter is being created. */
|
||||
CREATE,
|
||||
|
||||
/** An existing filter is being edited. */
|
||||
EDIT,
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or edit filters.
|
||||
*
|
||||
* If [filter] is non-null it is used to initialise the view model data,
|
||||
* [filterId] is ignored, and [uiMode] is [UiMode.EDIT].
|
||||
*
|
||||
* If [filterId] is non-null is is fetched from the repository, used to
|
||||
* initialise the view model, and [uiMode] is [UiMode.EDIT].
|
||||
*
|
||||
* If both [filter] and [filterId] are null an empty [FilterViewData]
|
||||
* is initialised, and [uiMode] is [UiMode.CREATE].
|
||||
*
|
||||
* @param filtersRepository
|
||||
* @param filter Filter to show
|
||||
* @param filterId ID of filter to fetch and show
|
||||
*/
|
||||
@HiltViewModel(assistedFactory = EditFilterViewModel.Factory::class)
|
||||
class EditFilterViewModel @AssistedInject constructor(
|
||||
val filtersRepository: FiltersRepository,
|
||||
@Assisted val filter: Filter?,
|
||||
@Assisted val filterId: String?,
|
||||
) : ViewModel() {
|
||||
/** The original filter before any edits (if provided via [filter] or [filterId]. */
|
||||
private var originalFilter: Filter? = null
|
||||
|
||||
/** User interface mode. */
|
||||
val uiMode = if (filter == null && filterId == null) UiMode.CREATE else UiMode.EDIT
|
||||
|
||||
/** True if the user has made unsaved changes to the filter */
|
||||
private val _isDirty = MutableStateFlow(false)
|
||||
val isDirty = _isDirty.asStateFlow()
|
||||
|
||||
/** True if the filter is valid and can be saved */
|
||||
private val _validationErrors = MutableStateFlow(emptySet<FilterValidationError>())
|
||||
val validationErrors = _validationErrors.asStateFlow()
|
||||
|
||||
private val _uiResult = Channel<Result<UiSuccess, UiError>>()
|
||||
val uiResult = _uiResult.receiveAsFlow()
|
||||
|
||||
private var _filterViewData = MutableSharedFlow<Result<FilterViewData?, UiError.GetFilterError>>()
|
||||
val filterViewData = _filterViewData
|
||||
.onSubscription {
|
||||
filter?.let {
|
||||
originalFilter = it
|
||||
emit(Ok(FilterViewData.from(it)))
|
||||
return@onSubscription
|
||||
}
|
||||
|
||||
emit(
|
||||
filterId?.let {
|
||||
filtersRepository.getFilter(filterId)
|
||||
.onSuccess {
|
||||
originalFilter = it
|
||||
}.mapEither(
|
||||
{ FilterViewData.from(it) },
|
||||
{ UiError.GetFilterError(filterId, it) },
|
||||
)
|
||||
} ?: Ok(FilterViewData()),
|
||||
)
|
||||
}.onEach { it.onSuccess { it?.let { onChange(it) } } }
|
||||
.stateIn(
|
||||
viewModelScope,
|
||||
SharingStarted.WhileSubscribed(5000),
|
||||
initialValue = Ok(null),
|
||||
)
|
||||
|
||||
/** Reload the filter, if [filterId] is non-null. */
|
||||
fun reload() = viewModelScope.launch {
|
||||
filterId ?: return@launch _filterViewData.emit(Ok(FilterViewData()))
|
||||
|
||||
_filterViewData.emit(
|
||||
filtersRepository.getFilter(filterId)
|
||||
.onSuccess { originalFilter = it }
|
||||
.mapEither(
|
||||
{ FilterViewData.from(it) },
|
||||
{ UiError.GetFilterError(filterId, it) },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** Adds [keyword] to [filterViewData]. */
|
||||
fun addKeyword(keyword: FilterKeyword) = viewModelScope.launch {
|
||||
_filterViewData.emit(
|
||||
filterViewData.value.mapIfNotNull {
|
||||
it.copy(keywords = it.keywords + keyword)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** Deletes [keyword] from [filterViewData]. */
|
||||
fun deleteKeyword(keyword: FilterKeyword) = viewModelScope.launch {
|
||||
_filterViewData.emit(
|
||||
filterViewData.value.mapIfNotNull {
|
||||
it.copy(keywords = it.keywords.filterNot { it == keyword })
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** Replaces [original] keyword in [filterViewData] with [newKeyword]. */
|
||||
fun updateKeyword(original: FilterKeyword, newKeyword: FilterKeyword) = viewModelScope.launch {
|
||||
_filterViewData.emit(
|
||||
filterViewData.value.mapIfNotNull {
|
||||
it.copy(
|
||||
keywords = it.keywords.map {
|
||||
if (it == original) newKeyword else it
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** Replaces [filterViewData]'s [title][FilterViewData.title] with [title]. */
|
||||
fun setTitle(title: String) = viewModelScope.launch {
|
||||
_filterViewData.emit(
|
||||
filterViewData.value.mapIfNotNull {
|
||||
it.copy(title = title)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** Replaces [filterViewData]'s [expiresIn][FilterViewData.expiresIn] with [expiresIn]. */
|
||||
fun setExpiresIn(expiresIn: Int) = viewModelScope.launch {
|
||||
_filterViewData.emit(
|
||||
filterViewData.value.mapIfNotNull {
|
||||
it.copy(expiresIn = expiresIn)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** Replaces [filterViewData]'s [action][FilterViewData.action] with [action]. */
|
||||
fun setAction(action: NetworkFilter.Action) = viewModelScope.launch {
|
||||
_filterViewData.emit(
|
||||
filterViewData.value.mapIfNotNull {
|
||||
it.copy(action = action)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** Adds [filterContext] to [filterViewData]'s [contexts][FilterViewData.contexts]. */
|
||||
fun addContext(filterContext: FilterContext) = viewModelScope.launch {
|
||||
filterViewData.value.get()?.let { filter ->
|
||||
if (filter.contexts.contains(filterContext)) return@launch
|
||||
|
||||
_filterViewData.emit(Ok(filter.copy(contexts = filter.contexts + filterContext)))
|
||||
}
|
||||
}
|
||||
|
||||
/** Deletes [filterContext] from [filterViewData]'s [contexts][FilterViewData.contexts]. */
|
||||
fun deleteContext(filterContext: FilterContext) = viewModelScope.launch {
|
||||
filterViewData.value.get()?.let { filter ->
|
||||
if (!filter.contexts.contains(filterContext)) return@launch
|
||||
|
||||
_filterViewData.emit(Ok(filter.copy(contexts = filter.contexts - filterContext)))
|
||||
}
|
||||
}
|
||||
|
||||
/** Recalculates validity and dirty state. */
|
||||
private fun onChange(filterViewData: FilterViewData) {
|
||||
_validationErrors.update { filterViewData.validate() }
|
||||
|
||||
if (filterViewData.expiresIn != -1) {
|
||||
_isDirty.value = true
|
||||
return
|
||||
}
|
||||
|
||||
_isDirty.value = when {
|
||||
originalFilter.title != title.value -> true
|
||||
originalFilter.contexts != contexts.value -> true
|
||||
originalFilter.action != action.value -> true
|
||||
originalFilter.keywords.toSet() != keywords.value.toSet() -> true
|
||||
originalFilter?.title != filterViewData.title -> true
|
||||
originalFilter?.contexts != filterViewData.contexts -> true
|
||||
originalFilter?.action != filterViewData.action -> true
|
||||
originalFilter?.keywords?.toSet() != filterViewData.keywords.toSet() -> true
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun saveChanges(context: Context): Boolean {
|
||||
val contexts = contexts.value
|
||||
val title = title.value
|
||||
val durationIndex = duration.value
|
||||
val action = action.value
|
||||
/**
|
||||
* Saves [filterViewData], either by creating a new filter or updating the
|
||||
* existing filter.
|
||||
*/
|
||||
fun saveChanges() = viewModelScope.launch {
|
||||
val filterViewData = filterViewData.value.get() ?: return@launch
|
||||
|
||||
return withContext(viewModelScope.coroutineContext) {
|
||||
val success = if (originalFilter.id == "") {
|
||||
createFilter(title, contexts, action, durationIndex, context)
|
||||
} else {
|
||||
updateFilter(originalFilter, title, contexts, action, durationIndex, context)
|
||||
_uiResult.send(
|
||||
when (uiMode) {
|
||||
UiMode.CREATE -> createFilter(filterViewData)
|
||||
UiMode.EDIT -> updateFilter(filterViewData)
|
||||
}
|
||||
|
||||
// Send FilterChangedEvent for old and new contexts, to ensure that
|
||||
// e.g., removing a filter from "home" still notifies anything showing
|
||||
// the home timeline, so the timeline can be refreshed.
|
||||
if (success) {
|
||||
val originalContexts = originalFilter.contexts
|
||||
val newFilterContexts = contexts
|
||||
(originalContexts + newFilterContexts).distinct().forEach {
|
||||
eventHub.dispatch(FilterChangedEvent(it))
|
||||
}
|
||||
}
|
||||
return@withContext success
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun createFilter(title: String, contexts: List<FilterContext>, action: Filter.Action, durationIndex: Int, context: Context): Boolean {
|
||||
val expiresInSeconds = getSecondsForDurationIndex(durationIndex, context)
|
||||
api.createFilter(
|
||||
title = title,
|
||||
context = contexts,
|
||||
filterAction = action,
|
||||
expiresInSeconds = expiresInSeconds,
|
||||
).fold(
|
||||
{ newFilter ->
|
||||
// This is _terrible_, but the all-in-one update filter api Just Doesn't Work
|
||||
return keywords.value.map { keyword ->
|
||||
api.addFilterKeyword(filterId = newFilter.id, keyword = keyword.keyword, wholeWord = keyword.wholeWord)
|
||||
}.none { it.isFailure }
|
||||
},
|
||||
{ throwable ->
|
||||
return (
|
||||
throwable is HttpException && throwable.code() == 404 &&
|
||||
// Endpoint not found, fall back to v1 api
|
||||
createFilterV1(contexts, expiresInSeconds)
|
||||
)
|
||||
},
|
||||
.map { UiSuccess.SaveFilter },
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun updateFilter(originalFilter: Filter, title: String, contexts: List<FilterContext>, action: Filter.Action, durationIndex: Int, context: Context): Boolean {
|
||||
val expiresInSeconds = getSecondsForDurationIndex(durationIndex, context)
|
||||
api.updateFilter(
|
||||
id = originalFilter.id,
|
||||
title = title,
|
||||
context = contexts,
|
||||
filterAction = action,
|
||||
expiresInSeconds = expiresInSeconds,
|
||||
).fold(
|
||||
{
|
||||
// This is _terrible_, but the all-in-one update filter api Just Doesn't Work
|
||||
val results = keywords.value.map { keyword ->
|
||||
if (keyword.id.isEmpty()) {
|
||||
api.addFilterKeyword(filterId = originalFilter.id, keyword = keyword.keyword, wholeWord = keyword.wholeWord)
|
||||
} else {
|
||||
api.updateFilterKeyword(keywordId = keyword.id, keyword = keyword.keyword, wholeWord = keyword.wholeWord)
|
||||
}
|
||||
} + originalFilter.keywords.filter { keyword ->
|
||||
// Deleted keywords
|
||||
keywords.value.none { it.id == keyword.id }
|
||||
}.map { api.deleteFilterKeyword(it.id) }
|
||||
|
||||
return results.none { it.isFailure }
|
||||
},
|
||||
{ throwable ->
|
||||
if (throwable is HttpException && throwable.code() == 404) {
|
||||
// Endpoint not found, fall back to v1 api
|
||||
if (updateFilterV1(contexts, expiresInSeconds)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
},
|
||||
)
|
||||
/** Create a new filter from [filterViewData]. */
|
||||
private suspend fun createFilter(filterViewData: FilterViewData): Result<Filter, UiError> {
|
||||
return filtersRepository.createFilter(NewFilter.from(filterViewData))
|
||||
.mapError { SaveFilterError(it) }
|
||||
}
|
||||
|
||||
private suspend fun createFilterV1(contexts: List<FilterContext>, expiresInSeconds: String?): Boolean {
|
||||
return keywords.value.map { keyword ->
|
||||
api.createFilterV1(keyword.keyword, contexts, false, keyword.wholeWord, expiresInSeconds)
|
||||
}.none { it.isFailure }
|
||||
/** Persists the changes to [filterViewData]. */
|
||||
private suspend fun updateFilter(filterViewData: FilterViewData): Result<Filter, UiError> {
|
||||
return filtersRepository.updateFilter(originalFilter!!, filterViewData.diff(originalFilter!!))
|
||||
.mapError { SaveFilterError(it) }
|
||||
}
|
||||
|
||||
private suspend fun updateFilterV1(contexts: List<FilterContext>, expiresInSeconds: String?): Boolean {
|
||||
val results = keywords.value.map { keyword ->
|
||||
if (originalFilter.id == "") {
|
||||
api.createFilterV1(
|
||||
phrase = keyword.keyword,
|
||||
context = contexts,
|
||||
irreversible = false,
|
||||
wholeWord = keyword.wholeWord,
|
||||
expiresInSeconds = expiresInSeconds,
|
||||
)
|
||||
} else {
|
||||
api.updateFilterV1(
|
||||
id = originalFilter.id,
|
||||
phrase = keyword.keyword,
|
||||
context = contexts,
|
||||
irreversible = false,
|
||||
wholeWord = keyword.wholeWord,
|
||||
expiresInSeconds = expiresInSeconds,
|
||||
)
|
||||
}
|
||||
}
|
||||
// Don't handle deleted keywords here because there's only one keyword per v1 filter anyway
|
||||
/** Delete [filterViewData]. */
|
||||
fun deleteFilter() = viewModelScope.launch {
|
||||
val filterViewData = filterViewData.value.get() ?: return@launch
|
||||
|
||||
return results.none { it.isFailure }
|
||||
// TODO: Check for non-null, or have a type that makes this impossible.
|
||||
filtersRepository.deleteFilter(filterViewData.id!!)
|
||||
.onSuccess { _uiResult.send(Ok(UiSuccess.DeleteFilter)) }
|
||||
.onFailure { _uiResult.send(Err(DeleteFilterError(it))) }
|
||||
}
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
/**
|
||||
* Creates [EditFilterViewModel], passing optional [filter] and
|
||||
* [filterId] parameters.
|
||||
*
|
||||
* @see EditFilterViewModel
|
||||
*/
|
||||
fun create(filter: Filter?, filterId: String?): EditFilterViewModel
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
|
|
@ -18,10 +18,8 @@
|
|||
package app.pachli.components.filters
|
||||
|
||||
import android.app.Activity
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import app.pachli.R
|
||||
import app.pachli.core.network.model.Filter
|
||||
import app.pachli.core.ui.extensions.await
|
||||
|
||||
internal suspend fun Activity.showDeleteFilterDialog(filterTitle: String) = AlertDialog.Builder(this)
|
||||
|
@ -29,36 +27,3 @@ internal suspend fun Activity.showDeleteFilterDialog(filterTitle: String) = Aler
|
|||
.setCancelable(true)
|
||||
.create()
|
||||
.await(R.string.dialog_delete_filter_positive_action, android.R.string.cancel)
|
||||
|
||||
/** Reasons why a filter might be invalid */
|
||||
enum class FilterValidationError {
|
||||
/** Filter title is empty or blank */
|
||||
NO_TITLE,
|
||||
|
||||
/** Filter has no keywords */
|
||||
NO_KEYWORDS,
|
||||
|
||||
/** Filter has no contexts */
|
||||
NO_CONTEXT,
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Set of validation errors for this filter, empty set if there
|
||||
* are no errors.
|
||||
*/
|
||||
fun Filter.validate() = buildSet {
|
||||
if (title.isBlank()) add(FilterValidationError.NO_TITLE)
|
||||
if (keywords.isEmpty()) add(FilterValidationError.NO_KEYWORDS)
|
||||
if (contexts.isEmpty()) add(FilterValidationError.NO_CONTEXT)
|
||||
}
|
||||
|
||||
/**
|
||||
* @return String resource containing an error message for this
|
||||
* validation error.
|
||||
*/
|
||||
@StringRes
|
||||
fun FilterValidationError.stringResource() = when (this) {
|
||||
FilterValidationError.NO_TITLE -> R.string.error_filter_missing_title
|
||||
FilterValidationError.NO_KEYWORDS -> R.string.error_filter_missing_keyword
|
||||
FilterValidationError.NO_CONTEXT -> R.string.error_filter_missing_context
|
||||
}
|
||||
|
|
|
@ -12,8 +12,8 @@ import app.pachli.core.common.extensions.hide
|
|||
import app.pachli.core.common.extensions.show
|
||||
import app.pachli.core.common.extensions.viewBinding
|
||||
import app.pachli.core.common.extensions.visible
|
||||
import app.pachli.core.data.model.Filter
|
||||
import app.pachli.core.navigation.EditFilterActivityIntent
|
||||
import app.pachli.core.network.model.Filter
|
||||
import app.pachli.core.ui.BackgroundMessage
|
||||
import app.pachli.databinding.ActivityFiltersBinding
|
||||
import com.google.android.material.color.MaterialColors
|
||||
|
@ -94,7 +94,9 @@ class FiltersActivity : BaseActivity(), FiltersListener {
|
|||
}
|
||||
|
||||
private fun launchEditFilterActivity(filter: Filter? = null) {
|
||||
val intent = EditFilterActivityIntent(this, filter)
|
||||
val intent = filter?.let {
|
||||
EditFilterActivityIntent.edit(this, filter)
|
||||
} ?: EditFilterActivityIntent(this)
|
||||
startActivityWithTransition(intent, TransitionKind.SLIDE_FROM_END)
|
||||
}
|
||||
|
||||
|
|
|
@ -2,9 +2,11 @@ package app.pachli.components.filters
|
|||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import app.pachli.R
|
||||
import app.pachli.core.network.model.Filter
|
||||
import app.pachli.core.data.model.Filter
|
||||
import app.pachli.core.data.model.FilterValidationError
|
||||
import app.pachli.core.ui.BindingHolder
|
||||
import app.pachli.databinding.ItemRemovableBinding
|
||||
import app.pachli.util.getRelativeTimeSpanString
|
||||
|
@ -64,3 +66,14 @@ class FiltersAdapter(val listener: FiltersListener, val filters: List<Filter>) :
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return String resource containing an error message for this
|
||||
* validation error.
|
||||
*/
|
||||
@StringRes
|
||||
fun FilterValidationError.stringResource() = when (this) {
|
||||
FilterValidationError.NO_TITLE -> R.string.error_filter_missing_title
|
||||
FilterValidationError.NO_KEYWORDS -> R.string.error_filter_missing_keyword
|
||||
FilterValidationError.NO_CONTEXT -> R.string.error_filter_missing_context
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
package app.pachli.components.filters
|
||||
|
||||
import app.pachli.core.network.model.Filter
|
||||
import app.pachli.core.data.model.Filter
|
||||
|
||||
interface FiltersListener {
|
||||
fun deleteFilter(filter: Filter)
|
||||
|
|
|
@ -3,23 +3,21 @@ package app.pachli.components.filters
|
|||
import android.view.View
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import app.pachli.appstore.EventHub
|
||||
import app.pachli.appstore.FilterChangedEvent
|
||||
import app.pachli.core.network.model.Filter
|
||||
import app.pachli.core.network.retrofit.MastodonApi
|
||||
import at.connyduck.calladapter.networkresult.fold
|
||||
import app.pachli.core.data.model.Filter
|
||||
import app.pachli.core.data.repository.FiltersRepository
|
||||
import com.github.michaelbull.result.onFailure
|
||||
import com.github.michaelbull.result.onSuccess
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import retrofit2.HttpException
|
||||
|
||||
@HiltViewModel
|
||||
class FiltersViewModel @Inject constructor(
|
||||
private val api: MastodonApi,
|
||||
private val eventHub: EventHub,
|
||||
private val filtersRepository: FiltersRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
enum class LoadingState {
|
||||
|
@ -35,63 +33,34 @@ class FiltersViewModel @Inject constructor(
|
|||
val state: Flow<State> get() = _state
|
||||
private val _state = MutableStateFlow(State(emptyList(), LoadingState.INITIAL))
|
||||
|
||||
// TODO: Now that FilterRepository exists this code should be updated to use that.
|
||||
fun load() {
|
||||
this@FiltersViewModel._state.value = _state.value.copy(loadingState = LoadingState.LOADING)
|
||||
|
||||
viewModelScope.launch {
|
||||
api.getFilters().fold(
|
||||
{ filters ->
|
||||
this@FiltersViewModel._state.value = State(filters, LoadingState.LOADED)
|
||||
},
|
||||
{ throwable ->
|
||||
if (throwable is HttpException && throwable.code() == 404) {
|
||||
api.getFiltersV1().fold(
|
||||
{ filters ->
|
||||
this@FiltersViewModel._state.value = State(filters.map { it.toFilter() }, LoadingState.LOADED)
|
||||
},
|
||||
{ throwable ->
|
||||
// TODO log errors (also below)
|
||||
|
||||
this@FiltersViewModel._state.value = _state.value.copy(loadingState = LoadingState.ERROR_OTHER)
|
||||
},
|
||||
)
|
||||
this@FiltersViewModel._state.value = _state.value.copy(loadingState = LoadingState.ERROR_OTHER)
|
||||
} else {
|
||||
this@FiltersViewModel._state.value = _state.value.copy(loadingState = LoadingState.ERROR_NETWORK)
|
||||
filtersRepository.filters.collect { result ->
|
||||
result.onSuccess { filters ->
|
||||
this@FiltersViewModel._state.update { State(filters?.filters.orEmpty(), LoadingState.LOADED) }
|
||||
}
|
||||
.onFailure {
|
||||
// TODO: There's an ERROR_NETWORK state to maybe consider here. Or get rid of
|
||||
// that and do proper error handling.
|
||||
this@FiltersViewModel._state.update {
|
||||
it.copy(loadingState = LoadingState.ERROR_OTHER)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteFilter(filter: Filter, parent: View) {
|
||||
viewModelScope.launch {
|
||||
api.deleteFilter(filter.id).fold(
|
||||
{
|
||||
filtersRepository.deleteFilter(filter.id)
|
||||
.onSuccess {
|
||||
this@FiltersViewModel._state.value = State(this@FiltersViewModel._state.value.filters.filter { it.id != filter.id }, LoadingState.LOADED)
|
||||
for (context in filter.contexts) {
|
||||
eventHub.dispatch(FilterChangedEvent(context))
|
||||
}
|
||||
},
|
||||
{ throwable ->
|
||||
if (throwable is HttpException && throwable.code() == 404) {
|
||||
api.deleteFilterV1(filter.id).fold(
|
||||
{
|
||||
this@FiltersViewModel._state.value = State(this@FiltersViewModel._state.value.filters.filter { it.id != filter.id }, LoadingState.LOADED)
|
||||
filter.contexts.forEach {
|
||||
eventHub.dispatch(FilterChangedEvent(it))
|
||||
}
|
||||
},
|
||||
{
|
||||
Snackbar.make(parent, "Error deleting filter '${filter.title}'", Snackbar.LENGTH_SHORT).show()
|
||||
},
|
||||
)
|
||||
} else {
|
||||
.onFailure {
|
||||
Snackbar.make(parent, "Error deleting filter '${filter.title}'", Snackbar.LENGTH_SHORT).show()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -17,6 +17,7 @@
|
|||
|
||||
package app.pachli.components.notifications
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
|
@ -27,13 +28,12 @@ import androidx.paging.map
|
|||
import app.pachli.R
|
||||
import app.pachli.appstore.BlockEvent
|
||||
import app.pachli.appstore.EventHub
|
||||
import app.pachli.appstore.FilterChangedEvent
|
||||
import app.pachli.appstore.MuteConversationEvent
|
||||
import app.pachli.appstore.MuteEvent
|
||||
import app.pachli.components.timeline.FilterKind
|
||||
import app.pachli.components.timeline.FiltersRepository
|
||||
import app.pachli.core.accounts.AccountManager
|
||||
import app.pachli.core.common.extensions.throttleFirst
|
||||
import app.pachli.core.data.repository.FilterVersion
|
||||
import app.pachli.core.data.repository.FiltersRepository
|
||||
import app.pachli.core.data.repository.StatusDisplayOptionsRepository
|
||||
import app.pachli.core.network.model.Filter
|
||||
import app.pachli.core.network.model.FilterContext
|
||||
|
@ -48,7 +48,10 @@ import app.pachli.util.serialize
|
|||
import app.pachli.viewdata.NotificationViewData
|
||||
import app.pachli.viewdata.StatusViewData
|
||||
import at.connyduck.calladapter.networkresult.getOrThrow
|
||||
import com.github.michaelbull.result.onFailure
|
||||
import com.github.michaelbull.result.onSuccess
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
|
@ -57,7 +60,6 @@ import kotlinx.coroutines.flow.MutableSharedFlow
|
|||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
|
@ -301,6 +303,9 @@ sealed interface UiError {
|
|||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@HiltViewModel
|
||||
class NotificationsViewModel @Inject constructor(
|
||||
// TODO: Context is required because handling filter errors needs to
|
||||
// format a resource string. As soon as that is removed this can be removed.
|
||||
@ApplicationContext private val context: Context,
|
||||
private val repository: NotificationsRepository,
|
||||
private val accountManager: AccountManager,
|
||||
private val timelineCases: TimelineCases,
|
||||
|
@ -469,15 +474,18 @@ class NotificationsViewModel @Inject constructor(
|
|||
|
||||
// Fetch the status filters
|
||||
viewModelScope.launch {
|
||||
eventHub.events
|
||||
.filterIsInstance<FilterChangedEvent>()
|
||||
.filter { it.filterContext == FilterContext.NOTIFICATIONS }
|
||||
.map {
|
||||
getFilters()
|
||||
repository.invalidate()
|
||||
filtersRepository.filters.collect { filters ->
|
||||
filters.onSuccess {
|
||||
filterModel = when (it?.version) {
|
||||
FilterVersion.V2 -> FilterModel(FilterContext.NOTIFICATIONS)
|
||||
FilterVersion.V1 -> FilterModel(FilterContext.NOTIFICATIONS, it.filters)
|
||||
else -> null
|
||||
}
|
||||
reload.getAndUpdate { it + 1 }
|
||||
}.onFailure {
|
||||
_uiErrorChannel.send(UiError.GetFilters(RuntimeException(it.fmt(context))))
|
||||
}
|
||||
}
|
||||
.onStart { getFilters() }
|
||||
.collect()
|
||||
}
|
||||
|
||||
// Handle events that should refresh the list
|
||||
|
@ -533,18 +541,6 @@ class NotificationsViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
/** Gets the current filters from the repository. */
|
||||
private fun getFilters() = viewModelScope.launch {
|
||||
try {
|
||||
filterModel = when (val filters = filtersRepository.getFilters()) {
|
||||
is FilterKind.V1 -> FilterModel(FilterContext.NOTIFICATIONS, filters.filters)
|
||||
is FilterKind.V2 -> FilterModel(FilterContext.NOTIFICATIONS)
|
||||
}
|
||||
} catch (throwable: Throwable) {
|
||||
_uiErrorChannel.send(UiError.GetFilters(throwable))
|
||||
}
|
||||
}
|
||||
|
||||
// The database stores "0" as the last notification ID if notifications have not been
|
||||
// fetched. Convert to null to ensure a full fetch in this case
|
||||
private fun getInitialKey(): String? {
|
||||
|
|
|
@ -30,7 +30,7 @@ import app.pachli.core.activity.extensions.TransitionKind
|
|||
import app.pachli.core.activity.extensions.startActivityWithTransition
|
||||
import app.pachli.core.common.util.unsafeLazy
|
||||
import app.pachli.core.data.repository.AccountPreferenceDataStore
|
||||
import app.pachli.core.data.repository.ServerRepository
|
||||
import app.pachli.core.data.repository.FiltersRepository
|
||||
import app.pachli.core.designsystem.R as DR
|
||||
import app.pachli.core.navigation.AccountListActivityIntent
|
||||
import app.pachli.core.navigation.FiltersActivityIntent
|
||||
|
@ -41,8 +41,6 @@ import app.pachli.core.navigation.LoginActivityIntent.LoginMode
|
|||
import app.pachli.core.navigation.PreferencesActivityIntent
|
||||
import app.pachli.core.navigation.PreferencesActivityIntent.PreferenceScreen
|
||||
import app.pachli.core.navigation.TabPreferenceActivityIntent
|
||||
import app.pachli.core.network.ServerOperation.ORG_JOINMASTODON_FILTERS_CLIENT
|
||||
import app.pachli.core.network.ServerOperation.ORG_JOINMASTODON_FILTERS_SERVER
|
||||
import app.pachli.core.network.model.Account
|
||||
import app.pachli.core.network.model.Status
|
||||
import app.pachli.core.network.retrofit.MastodonApi
|
||||
|
@ -57,12 +55,10 @@ import app.pachli.util.getInitialLanguages
|
|||
import app.pachli.util.getLocaleList
|
||||
import app.pachli.util.getPachliDisplayName
|
||||
import app.pachli.util.iconRes
|
||||
import com.github.michaelbull.result.getOrElse
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.mikepenz.iconics.IconicsDrawable
|
||||
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import io.github.z4kn4fein.semver.constraints.toConstraint
|
||||
import javax.inject.Inject
|
||||
import retrofit2.Call
|
||||
import retrofit2.Callback
|
||||
|
@ -78,7 +74,7 @@ class AccountPreferencesFragment : PreferenceFragmentCompat() {
|
|||
lateinit var mastodonApi: MastodonApi
|
||||
|
||||
@Inject
|
||||
lateinit var serverRepository: ServerRepository
|
||||
lateinit var filtersRepository: FiltersRepository
|
||||
|
||||
@Inject
|
||||
lateinit var eventHub: EventHub
|
||||
|
@ -170,16 +166,7 @@ class AccountPreferencesFragment : PreferenceFragmentCompat() {
|
|||
activity?.startActivityWithTransition(intent, TransitionKind.SLIDE_FROM_END)
|
||||
true
|
||||
}
|
||||
val server = serverRepository.flow.value.getOrElse { null }
|
||||
isEnabled = server?.let {
|
||||
it.can(
|
||||
ORG_JOINMASTODON_FILTERS_CLIENT,
|
||||
">1.0.0".toConstraint(),
|
||||
) || it.can(
|
||||
ORG_JOINMASTODON_FILTERS_SERVER,
|
||||
">1.0.0".toConstraint(),
|
||||
)
|
||||
} ?: false
|
||||
isEnabled = filtersRepository.canFilter()
|
||||
if (!isEnabled) summary = context.getString(R.string.pref_summary_timeline_filters)
|
||||
}
|
||||
|
||||
|
|
|
@ -1,79 +0,0 @@
|
|||
/*
|
||||
* Copyright 2023 Pachli Association
|
||||
*
|
||||
* This file is a part of Pachli.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* Pachli 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 Pachli; if not,
|
||||
* see <http://www.gnu.org/licenses>.
|
||||
*/
|
||||
|
||||
package app.pachli.components.timeline
|
||||
|
||||
import app.pachli.core.data.repository.ServerRepository
|
||||
import app.pachli.core.network.ServerOperation.ORG_JOINMASTODON_FILTERS_CLIENT
|
||||
import app.pachli.core.network.ServerOperation.ORG_JOINMASTODON_FILTERS_SERVER
|
||||
import app.pachli.core.network.model.Filter
|
||||
import app.pachli.core.network.model.FilterV1
|
||||
import app.pachli.core.network.retrofit.MastodonApi
|
||||
import at.connyduck.calladapter.networkresult.fold
|
||||
import at.connyduck.calladapter.networkresult.getOrThrow
|
||||
import com.github.michaelbull.result.getOrElse
|
||||
import io.github.z4kn4fein.semver.constraints.toConstraint
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import retrofit2.HttpException
|
||||
|
||||
sealed interface FilterKind {
|
||||
/** API v1 filter, filtering happens client side */
|
||||
data class V1(val filters: List<FilterV1>) : FilterKind
|
||||
|
||||
/** API v2 filter, filtering happens server side */
|
||||
data class V2(val filters: List<Filter>) : FilterKind
|
||||
}
|
||||
|
||||
/** Repository for filter information */
|
||||
@Singleton
|
||||
class FiltersRepository @Inject constructor(
|
||||
private val mastodonApi: MastodonApi,
|
||||
private val serverRepository: ServerRepository,
|
||||
) {
|
||||
/**
|
||||
* Get the current set of filters.
|
||||
*
|
||||
* Checks for server-side (v2) filters first. If that fails then fetches filters to
|
||||
* apply client-side.
|
||||
*
|
||||
* @throws HttpException if the requests fail
|
||||
*/
|
||||
suspend fun getFilters(): FilterKind {
|
||||
// If fetching capabilities failed then assume no filtering
|
||||
val server = serverRepository.flow.value.getOrElse { null } ?: return FilterKind.V2(emptyList())
|
||||
|
||||
// If the server doesn't support filtering then return an empty list of filters
|
||||
if (!server.can(ORG_JOINMASTODON_FILTERS_CLIENT, ">=1.0.0".toConstraint()) &&
|
||||
!server.can(ORG_JOINMASTODON_FILTERS_SERVER, ">=1.0.0".toConstraint())
|
||||
) {
|
||||
return FilterKind.V2(emptyList())
|
||||
}
|
||||
|
||||
return mastodonApi.getFilters().fold(
|
||||
{ filters -> FilterKind.V2(filters) },
|
||||
{ throwable ->
|
||||
if (throwable is HttpException && throwable.code() == 404) {
|
||||
val filters = mastodonApi.getFiltersV1().getOrThrow()
|
||||
FilterKind.V1(filters)
|
||||
} else {
|
||||
throw throwable
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
|
@ -17,6 +17,7 @@
|
|||
|
||||
package app.pachli.components.timeline.viewmodel
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.paging.PagingData
|
||||
|
@ -29,8 +30,8 @@ import app.pachli.appstore.FavoriteEvent
|
|||
import app.pachli.appstore.PinEvent
|
||||
import app.pachli.appstore.ReblogEvent
|
||||
import app.pachli.components.timeline.CachedTimelineRepository
|
||||
import app.pachli.components.timeline.FiltersRepository
|
||||
import app.pachli.core.accounts.AccountManager
|
||||
import app.pachli.core.data.repository.FiltersRepository
|
||||
import app.pachli.core.data.repository.StatusDisplayOptionsRepository
|
||||
import app.pachli.core.network.model.Filter
|
||||
import app.pachli.core.network.model.Poll
|
||||
|
@ -39,6 +40,7 @@ import app.pachli.usecase.TimelineCases
|
|||
import app.pachli.viewdata.StatusViewData
|
||||
import com.squareup.moshi.Moshi
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
@ -53,6 +55,7 @@ import timber.log.Timber
|
|||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@HiltViewModel
|
||||
class CachedTimelineViewModel @Inject constructor(
|
||||
@ApplicationContext context: Context,
|
||||
savedStateHandle: SavedStateHandle,
|
||||
private val repository: CachedTimelineRepository,
|
||||
timelineCases: TimelineCases,
|
||||
|
@ -63,6 +66,7 @@ class CachedTimelineViewModel @Inject constructor(
|
|||
sharedPreferencesRepository: SharedPreferencesRepository,
|
||||
private val moshi: Moshi,
|
||||
) : TimelineViewModel(
|
||||
context,
|
||||
savedStateHandle,
|
||||
timelineCases,
|
||||
eventHub,
|
||||
|
|
|
@ -17,6 +17,7 @@
|
|||
|
||||
package app.pachli.components.timeline.viewmodel
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.paging.PagingData
|
||||
|
@ -28,9 +29,9 @@ import app.pachli.appstore.EventHub
|
|||
import app.pachli.appstore.FavoriteEvent
|
||||
import app.pachli.appstore.PinEvent
|
||||
import app.pachli.appstore.ReblogEvent
|
||||
import app.pachli.components.timeline.FiltersRepository
|
||||
import app.pachli.components.timeline.NetworkTimelineRepository
|
||||
import app.pachli.core.accounts.AccountManager
|
||||
import app.pachli.core.data.repository.FiltersRepository
|
||||
import app.pachli.core.data.repository.StatusDisplayOptionsRepository
|
||||
import app.pachli.core.network.model.Filter
|
||||
import app.pachli.core.network.model.Poll
|
||||
|
@ -38,6 +39,7 @@ import app.pachli.core.preferences.SharedPreferencesRepository
|
|||
import app.pachli.usecase.TimelineCases
|
||||
import app.pachli.viewdata.StatusViewData
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
@ -52,6 +54,7 @@ import timber.log.Timber
|
|||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@HiltViewModel
|
||||
class NetworkTimelineViewModel @Inject constructor(
|
||||
@ApplicationContext context: Context,
|
||||
savedStateHandle: SavedStateHandle,
|
||||
private val repository: NetworkTimelineRepository,
|
||||
timelineCases: TimelineCases,
|
||||
|
@ -61,6 +64,7 @@ class NetworkTimelineViewModel @Inject constructor(
|
|||
statusDisplayOptionsRepository: StatusDisplayOptionsRepository,
|
||||
sharedPreferencesRepository: SharedPreferencesRepository,
|
||||
) : TimelineViewModel(
|
||||
context,
|
||||
savedStateHandle,
|
||||
timelineCases,
|
||||
eventHub,
|
||||
|
|
|
@ -17,6 +17,7 @@
|
|||
|
||||
package app.pachli.components.timeline.viewmodel
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.CallSuper
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.annotation.VisibleForTesting
|
||||
|
@ -32,7 +33,6 @@ import app.pachli.appstore.DomainMuteEvent
|
|||
import app.pachli.appstore.Event
|
||||
import app.pachli.appstore.EventHub
|
||||
import app.pachli.appstore.FavoriteEvent
|
||||
import app.pachli.appstore.FilterChangedEvent
|
||||
import app.pachli.appstore.MuteConversationEvent
|
||||
import app.pachli.appstore.MuteEvent
|
||||
import app.pachli.appstore.PinEvent
|
||||
|
@ -41,10 +41,10 @@ import app.pachli.appstore.StatusComposedEvent
|
|||
import app.pachli.appstore.StatusDeletedEvent
|
||||
import app.pachli.appstore.StatusEditedEvent
|
||||
import app.pachli.appstore.UnfollowEvent
|
||||
import app.pachli.components.timeline.FilterKind
|
||||
import app.pachli.components.timeline.FiltersRepository
|
||||
import app.pachli.core.accounts.AccountManager
|
||||
import app.pachli.core.common.extensions.throttleFirst
|
||||
import app.pachli.core.data.repository.FilterVersion
|
||||
import app.pachli.core.data.repository.FiltersRepository
|
||||
import app.pachli.core.data.repository.StatusDisplayOptionsRepository
|
||||
import app.pachli.core.model.Timeline
|
||||
import app.pachli.core.network.model.Filter
|
||||
|
@ -57,6 +57,9 @@ import app.pachli.network.FilterModel
|
|||
import app.pachli.usecase.TimelineCases
|
||||
import app.pachli.viewdata.StatusViewData
|
||||
import at.connyduck.calladapter.networkresult.getOrThrow
|
||||
import com.github.michaelbull.result.onFailure
|
||||
import com.github.michaelbull.result.onSuccess
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
|
@ -68,9 +71,9 @@ import kotlinx.coroutines.flow.distinctUntilChanged
|
|||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.filterIsInstance
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.fold
|
||||
import kotlinx.coroutines.flow.getAndUpdate
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
|
@ -258,6 +261,9 @@ sealed interface UiError {
|
|||
}
|
||||
|
||||
abstract class TimelineViewModel(
|
||||
// TODO: Context is required because handling filter errors needs to
|
||||
// format a resource string. As soon as that is removed this can be removed.
|
||||
@ApplicationContext private val context: Context,
|
||||
savedStateHandle: SavedStateHandle,
|
||||
private val timelineCases: TimelineCases,
|
||||
private val eventHub: EventHub,
|
||||
|
@ -320,8 +326,22 @@ abstract class TimelineViewModel(
|
|||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
updateFiltersFromPreferences().collectLatest {
|
||||
Timber.d("Filters updated")
|
||||
FilterContext.from(timeline)?.let { filterContext ->
|
||||
filtersRepository.filters.fold(false) { reload, filters ->
|
||||
filters.onSuccess {
|
||||
filterModel = when (it?.version) {
|
||||
FilterVersion.V2 -> FilterModel(filterContext)
|
||||
FilterVersion.V1 -> FilterModel(filterContext, it.filters)
|
||||
else -> null
|
||||
}
|
||||
if (reload) {
|
||||
reloadKeepingReadingPosition()
|
||||
}
|
||||
}.onFailure {
|
||||
_uiErrorChannel.send(UiError.GetFilters(RuntimeException(it.fmt(context))))
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -517,35 +537,6 @@ abstract class TimelineViewModel(
|
|||
}
|
||||
}
|
||||
|
||||
/** Updates the current set of filters if filter-related preferences change */
|
||||
private fun updateFiltersFromPreferences() = eventHub.events
|
||||
.filterIsInstance<FilterChangedEvent>()
|
||||
.filter { filterContextMatchesKind(timeline, listOf(it.filterContext)) }
|
||||
.map {
|
||||
getFilters()
|
||||
Timber.d("Reload because FilterChangedEvent")
|
||||
reloadKeepingReadingPosition()
|
||||
}
|
||||
.onStart { getFilters() }
|
||||
|
||||
/** Gets the current filters from the repository. */
|
||||
private fun getFilters() {
|
||||
viewModelScope.launch {
|
||||
Timber.d("getFilters()")
|
||||
try {
|
||||
FilterContext.from(timeline)?.let { filterContext ->
|
||||
filterModel = when (val filters = filtersRepository.getFilters()) {
|
||||
is FilterKind.V1 -> FilterModel(filterContext, filters.filters)
|
||||
is FilterKind.V2 -> FilterModel(filterContext)
|
||||
}
|
||||
}
|
||||
} catch (throwable: Throwable) {
|
||||
Timber.d(throwable, "updateFilter(): Error fetching filters")
|
||||
_uiErrorChannel.send(UiError.GetFilters(throwable))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Update this so that the list of UIPrefs is correct
|
||||
private fun onPreferenceChanged(key: String) {
|
||||
when (key) {
|
||||
|
|
|
@ -18,8 +18,7 @@ package app.pachli.components.trending.viewmodel
|
|||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import app.pachli.appstore.EventHub
|
||||
import app.pachli.appstore.FilterChangedEvent
|
||||
import app.pachli.core.data.repository.FiltersRepository
|
||||
import app.pachli.core.network.model.FilterContext
|
||||
import app.pachli.core.network.model.TrendingTag
|
||||
import app.pachli.core.network.model.end
|
||||
|
@ -27,20 +26,19 @@ import app.pachli.core.network.model.start
|
|||
import app.pachli.core.network.retrofit.MastodonApi
|
||||
import app.pachli.viewdata.TrendingViewData
|
||||
import at.connyduck.calladapter.networkresult.fold
|
||||
import com.github.michaelbull.result.get
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import java.io.IOException
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.filterIsInstance
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
|
||||
@HiltViewModel
|
||||
class TrendingTagsViewModel @Inject constructor(
|
||||
private val mastodonApi: MastodonApi,
|
||||
private val eventHub: EventHub,
|
||||
private val filtersRepository: FiltersRepository,
|
||||
) : ViewModel() {
|
||||
enum class LoadingState {
|
||||
INITIAL,
|
||||
|
@ -61,17 +59,7 @@ class TrendingTagsViewModel @Inject constructor(
|
|||
|
||||
init {
|
||||
invalidate()
|
||||
|
||||
// Collect FilterChangedEvent, FiltersActivity creates them when a filter is created
|
||||
// or deleted. Unfortunately, there's nothing in the event to determine if it's a filter
|
||||
// that was modified, so refresh on every preference change.
|
||||
viewModelScope.launch {
|
||||
eventHub.events
|
||||
.filterIsInstance<FilterChangedEvent>()
|
||||
.collect {
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
viewModelScope.launch { filtersRepository.filters.collect { invalidate() } }
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -86,8 +74,6 @@ class TrendingTagsViewModel @Inject constructor(
|
|||
_uiState.value = TrendingTagsUiState(emptyList(), LoadingState.LOADING)
|
||||
}
|
||||
|
||||
val deferredFilters = async { mastodonApi.getFilters() }
|
||||
|
||||
mastodonApi.trendingTags(limit = LIMIT_TRENDING_HASHTAGS).fold(
|
||||
{ tagResponse ->
|
||||
|
||||
|
@ -95,7 +81,7 @@ class TrendingTagsViewModel @Inject constructor(
|
|||
_uiState.value = if (firstTag == null) {
|
||||
TrendingTagsUiState(emptyList(), LoadingState.LOADED)
|
||||
} else {
|
||||
val homeFilters = deferredFilters.await().getOrNull()?.filter { filter ->
|
||||
val homeFilters = filtersRepository.filters.value.get()?.filters?.filter { filter ->
|
||||
filter.contexts.contains(FilterContext.HOME)
|
||||
}
|
||||
val tags = tagResponse
|
||||
|
|
|
@ -22,17 +22,16 @@ import app.pachli.appstore.BlockEvent
|
|||
import app.pachli.appstore.BookmarkEvent
|
||||
import app.pachli.appstore.EventHub
|
||||
import app.pachli.appstore.FavoriteEvent
|
||||
import app.pachli.appstore.FilterChangedEvent
|
||||
import app.pachli.appstore.PinEvent
|
||||
import app.pachli.appstore.ReblogEvent
|
||||
import app.pachli.appstore.StatusComposedEvent
|
||||
import app.pachli.appstore.StatusDeletedEvent
|
||||
import app.pachli.appstore.StatusEditedEvent
|
||||
import app.pachli.components.timeline.CachedTimelineRepository
|
||||
import app.pachli.components.timeline.FilterKind
|
||||
import app.pachli.components.timeline.FiltersRepository
|
||||
import app.pachli.components.timeline.util.ifExpected
|
||||
import app.pachli.core.accounts.AccountManager
|
||||
import app.pachli.core.data.repository.FilterVersion
|
||||
import app.pachli.core.data.repository.FiltersRepository
|
||||
import app.pachli.core.data.repository.StatusDisplayOptionsRepository
|
||||
import app.pachli.core.database.dao.TimelineDao
|
||||
import app.pachli.core.database.model.AccountEntity
|
||||
|
@ -49,6 +48,8 @@ import app.pachli.viewdata.StatusViewData
|
|||
import at.connyduck.calladapter.networkresult.fold
|
||||
import at.connyduck.calladapter.networkresult.getOrElse
|
||||
import at.connyduck.calladapter.networkresult.getOrThrow
|
||||
import com.github.michaelbull.result.onFailure
|
||||
import com.github.michaelbull.result.onSuccess
|
||||
import com.squareup.moshi.Moshi
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import javax.inject.Inject
|
||||
|
@ -107,16 +108,27 @@ class ViewThreadViewModel @Inject constructor(
|
|||
is StatusComposedEvent -> handleStatusComposedEvent(event)
|
||||
is StatusDeletedEvent -> handleStatusDeletedEvent(event)
|
||||
is StatusEditedEvent -> handleStatusEditedEvent(event)
|
||||
is FilterChangedEvent -> {
|
||||
if (event.filterContext == FilterContext.THREAD) {
|
||||
loadFilters()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadFilters()
|
||||
viewModelScope.launch {
|
||||
filtersRepository.filters.collect { filters ->
|
||||
filters.onSuccess {
|
||||
filterModel = when (it?.version) {
|
||||
FilterVersion.V2 -> FilterModel(FilterContext.THREAD)
|
||||
FilterVersion.V1 -> FilterModel(FilterContext.THREAD, it.filters)
|
||||
else -> null
|
||||
}
|
||||
updateStatuses()
|
||||
}
|
||||
.onFailure {
|
||||
// TODO: Deliberately don't emit to _errors here -- at the moment
|
||||
// ViewThreadFragment shows a generic error to the user, and that
|
||||
// would confuse them when the rest of the thread is loading OK.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadThread(id: String) {
|
||||
|
@ -205,8 +217,7 @@ class ViewThreadViewModel @Inject constructor(
|
|||
translation = cachedTranslations[status.id],
|
||||
)
|
||||
}.filterByFilterAction()
|
||||
val descendants = statusContext.descendants.map {
|
||||
status ->
|
||||
val descendants = statusContext.descendants.map { status ->
|
||||
val svd = cachedViewData[status.id]
|
||||
StatusViewData.from(
|
||||
status,
|
||||
|
@ -519,22 +530,6 @@ class ViewThreadViewModel @Inject constructor(
|
|||
return RevealButtonState.NO_BUTTON
|
||||
}
|
||||
|
||||
private fun loadFilters() {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
filterModel = when (val filters = filtersRepository.getFilters()) {
|
||||
is FilterKind.V1 -> FilterModel(FilterContext.THREAD, filters.filters)
|
||||
is FilterKind.V2 -> FilterModel(FilterContext.THREAD)
|
||||
}
|
||||
updateStatuses()
|
||||
} catch (_: Exception) {
|
||||
// TODO: Deliberately don't emit to _errors here -- at the moment
|
||||
// ViewThreadFragment shows a generic error to the user, and that
|
||||
// would confuse them when the rest of the thread is loading OK.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateStatuses() {
|
||||
updateSuccess { uiState ->
|
||||
val statuses = uiState.statusViewData.filterByFilterAction()
|
||||
|
|
|
@ -134,7 +134,7 @@ abstract class SFragment<T : IStatusViewData> : Fragment(), StatusActionListener
|
|||
Timber.e(msg)
|
||||
try {
|
||||
Snackbar.make(requireView(), msg, Snackbar.LENGTH_INDEFINITE)
|
||||
.setAction(app.pachli.core.ui.R.string.action_retry) { serverRepository.retry() }
|
||||
.setAction(app.pachli.core.ui.R.string.action_retry) { serverRepository.reload() }
|
||||
.show()
|
||||
} catch (e: IllegalArgumentException) {
|
||||
// On rare occasions this code is running before the fragment's
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
package app.pachli.network
|
||||
|
||||
import app.pachli.core.network.model.Filter
|
||||
import app.pachli.core.data.model.Filter
|
||||
import app.pachli.core.network.model.Filter as NetworkFilter
|
||||
import app.pachli.core.network.model.FilterContext
|
||||
import app.pachli.core.network.model.FilterV1
|
||||
import app.pachli.core.network.model.Status
|
||||
import app.pachli.core.network.parseAsMastodonHtml
|
||||
import java.util.Date
|
||||
|
@ -14,7 +14,7 @@ import java.util.regex.Pattern
|
|||
* Construct with [filterContext] that corresponds to the kind of timeline, and optionally the set
|
||||
* of v1 filters that should be applied.
|
||||
*/
|
||||
class FilterModel(private val filterContext: FilterContext, v1filters: List<FilterV1>? = null) {
|
||||
class FilterModel(private val filterContext: FilterContext, v1filters: List<Filter>? = null) {
|
||||
/** Pattern to use when matching v1 filters against a status. Null if these are v2 filters */
|
||||
private var pattern: Pattern? = null
|
||||
|
||||
|
@ -25,13 +25,13 @@ class FilterModel(private val filterContext: FilterContext, v1filters: List<Filt
|
|||
}
|
||||
|
||||
/** @return the [Filter.Action] that should be applied to this status */
|
||||
fun filterActionFor(status: Status): Filter.Action {
|
||||
fun filterActionFor(status: Status): NetworkFilter.Action {
|
||||
pattern?.let { pat ->
|
||||
// Patterns are expensive and thread-safe, matchers are neither.
|
||||
val matcher = pat.matcher("") ?: return Filter.Action.NONE
|
||||
val matcher = pat.matcher("") ?: return NetworkFilter.Action.NONE
|
||||
|
||||
if (status.poll?.options?.any { matcher.reset(it.title).find() } == true) {
|
||||
return Filter.Action.HIDE
|
||||
return NetworkFilter.Action.HIDE
|
||||
}
|
||||
|
||||
val spoilerText = status.actionableStatus.spoilerText
|
||||
|
@ -42,9 +42,9 @@ class FilterModel(private val filterContext: FilterContext, v1filters: List<Filt
|
|||
(spoilerText.isNotEmpty() && matcher.reset(spoilerText).find()) ||
|
||||
(attachmentsDescriptions.isNotEmpty() && matcher.reset(attachmentsDescriptions.joinToString("\n")).find())
|
||||
) {
|
||||
Filter.Action.HIDE
|
||||
NetworkFilter.Action.HIDE
|
||||
} else {
|
||||
Filter.Action.NONE
|
||||
NetworkFilter.Action.NONE
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -53,23 +53,24 @@ class FilterModel(private val filterContext: FilterContext, v1filters: List<Filt
|
|||
}
|
||||
|
||||
return if (matchingKind.isNullOrEmpty()) {
|
||||
Filter.Action.NONE
|
||||
NetworkFilter.Action.NONE
|
||||
} else {
|
||||
matchingKind.maxOf { it.filter.action }
|
||||
}
|
||||
}
|
||||
|
||||
private fun filterToRegexToken(filter: FilterV1): String? {
|
||||
val phrase = filter.phrase
|
||||
private fun filterToRegexToken(filter: Filter): String? {
|
||||
val keyword = filter.keywords.first()
|
||||
val phrase = keyword.keyword
|
||||
val quotedPhrase = Pattern.quote(phrase)
|
||||
return if (filter.wholeWord && ALPHANUMERIC.matcher(phrase).matches()) {
|
||||
return if (keyword.wholeWord && ALPHANUMERIC.matcher(phrase).matches()) {
|
||||
"(^|\\W)$quotedPhrase($|\\W)"
|
||||
} else {
|
||||
quotedPhrase
|
||||
}
|
||||
}
|
||||
|
||||
private fun makeFilter(filters: List<FilterV1>): Pattern? {
|
||||
private fun makeFilter(filters: List<Filter>): Pattern? {
|
||||
val now = Date()
|
||||
val nonExpiredFilters = filters.filter { it.expiresAt?.before(now) != true }
|
||||
if (nonExpiredFilters.isEmpty()) return null
|
||||
|
|
|
@ -110,7 +110,7 @@
|
|||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:minHeight="48dp"
|
||||
android:entries="@array/filter_duration_names" />
|
||||
android:entries="@array/filter_duration_labels" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
|
|
|
@ -237,7 +237,8 @@
|
|||
<item>@string/duration_7_days</item>
|
||||
</string-array>
|
||||
|
||||
<integer-array name="mute_duration_values"> <!-- values in seconds, corresponding to mute_duration_names -->
|
||||
<!-- values in seconds, corresponding to mute_duration_names -->
|
||||
<integer-array name="mute_duration_values">
|
||||
<item>0</item>
|
||||
<item>300</item>
|
||||
<item>1800</item>
|
||||
|
@ -248,7 +249,7 @@
|
|||
<item>604800</item>
|
||||
</integer-array>
|
||||
|
||||
<string-array name="filter_duration_names">
|
||||
<string-array name="filter_duration_labels">
|
||||
<item>@string/duration_indefinite</item>
|
||||
<item>@string/duration_5_min</item>
|
||||
<item>@string/duration_30_min</item>
|
||||
|
@ -259,7 +260,8 @@
|
|||
<item>@string/duration_7_days</item>
|
||||
</string-array>
|
||||
|
||||
<string-array name="filter_duration_values"> <!-- values in seconds, corresponding to filter_duration_names -->
|
||||
<!-- values in seconds, corresponding to filter_duration_names -->
|
||||
<integer-array name="filter_duration_values">
|
||||
<item>0</item>
|
||||
<item>300</item>
|
||||
<item>1800</item>
|
||||
|
@ -268,7 +270,7 @@
|
|||
<item>86400</item>
|
||||
<item>259200</item>
|
||||
<item>604800</item>
|
||||
</string-array>
|
||||
</integer-array>
|
||||
|
||||
<string-array name="filter_action_values">
|
||||
<item>warn</item>
|
||||
|
|
|
@ -712,4 +712,8 @@
|
|||
<string name="preview_card_byline_fmt">See more from %1$s</string>
|
||||
<string name="action_open_byline_account">Show article author\'s profile</string>
|
||||
<string name="action_open_link">Open link</string>
|
||||
|
||||
<string name="error_load_filter_failed_fmt">Loading filter failed: %1$s</string>
|
||||
<string name="error_save_filter_failed_fmt">Saving filter failed: %1$s</string>
|
||||
<string name="error_delete_filter_failed_fmt">Deleting filter failed: %1$s</string>
|
||||
</resources>
|
||||
|
|
|
@ -19,8 +19,9 @@ package app.pachli
|
|||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import app.pachli.components.filters.EditFilterViewModel.Companion.getSecondsForDurationIndex
|
||||
import app.pachli.core.data.model.Filter
|
||||
import app.pachli.core.network.model.Attachment
|
||||
import app.pachli.core.network.model.Filter
|
||||
import app.pachli.core.network.model.Filter as NetworkFilter
|
||||
import app.pachli.core.network.model.FilterContext
|
||||
import app.pachli.core.network.model.FilterV1
|
||||
import app.pachli.core.network.model.Poll
|
||||
|
@ -46,7 +47,7 @@ class FilterV1Test {
|
|||
FilterV1(
|
||||
id = "123",
|
||||
phrase = "badWord",
|
||||
contexts = listOf(FilterContext.HOME),
|
||||
contexts = setOf(FilterContext.HOME),
|
||||
expiresAt = null,
|
||||
irreversible = false,
|
||||
wholeWord = false,
|
||||
|
@ -54,7 +55,7 @@ class FilterV1Test {
|
|||
FilterV1(
|
||||
id = "123",
|
||||
phrase = "badWholeWord",
|
||||
contexts = listOf(FilterContext.HOME, FilterContext.PUBLIC),
|
||||
contexts = setOf(FilterContext.HOME, FilterContext.PUBLIC),
|
||||
expiresAt = null,
|
||||
irreversible = false,
|
||||
wholeWord = true,
|
||||
|
@ -62,7 +63,7 @@ class FilterV1Test {
|
|||
FilterV1(
|
||||
id = "123",
|
||||
phrase = "@twitter.com",
|
||||
contexts = listOf(FilterContext.HOME),
|
||||
contexts = setOf(FilterContext.HOME),
|
||||
expiresAt = null,
|
||||
irreversible = false,
|
||||
wholeWord = true,
|
||||
|
@ -70,7 +71,7 @@ class FilterV1Test {
|
|||
FilterV1(
|
||||
id = "123",
|
||||
phrase = "#hashtag",
|
||||
contexts = listOf(FilterContext.HOME),
|
||||
contexts = setOf(FilterContext.HOME),
|
||||
expiresAt = null,
|
||||
irreversible = false,
|
||||
wholeWord = true,
|
||||
|
@ -78,7 +79,7 @@ class FilterV1Test {
|
|||
FilterV1(
|
||||
id = "123",
|
||||
phrase = "expired",
|
||||
contexts = listOf(FilterContext.HOME),
|
||||
contexts = setOf(FilterContext.HOME),
|
||||
expiresAt = Date.from(Instant.now().minusSeconds(10)),
|
||||
irreversible = false,
|
||||
wholeWord = true,
|
||||
|
@ -86,7 +87,7 @@ class FilterV1Test {
|
|||
FilterV1(
|
||||
id = "123",
|
||||
phrase = "unexpired",
|
||||
contexts = listOf(FilterContext.HOME),
|
||||
contexts = setOf(FilterContext.HOME),
|
||||
expiresAt = Date.from(Instant.now().plusSeconds(3600)),
|
||||
irreversible = false,
|
||||
wholeWord = true,
|
||||
|
@ -94,12 +95,12 @@ class FilterV1Test {
|
|||
FilterV1(
|
||||
id = "123",
|
||||
phrase = "href",
|
||||
contexts = listOf(FilterContext.HOME),
|
||||
contexts = setOf(FilterContext.HOME),
|
||||
expiresAt = null,
|
||||
irreversible = false,
|
||||
wholeWord = false,
|
||||
),
|
||||
)
|
||||
).map { Filter.from(it) }
|
||||
|
||||
filterModel = FilterModel(FilterContext.HOME, filters)
|
||||
}
|
||||
|
@ -107,7 +108,7 @@ class FilterV1Test {
|
|||
@Test
|
||||
fun shouldNotFilter() {
|
||||
assertEquals(
|
||||
Filter.Action.NONE,
|
||||
NetworkFilter.Action.NONE,
|
||||
filterModel.filterActionFor(
|
||||
mockStatus(content = "should not be filtered"),
|
||||
),
|
||||
|
@ -117,7 +118,7 @@ class FilterV1Test {
|
|||
@Test
|
||||
fun shouldFilter_whenContentMatchesBadWord() {
|
||||
assertEquals(
|
||||
Filter.Action.HIDE,
|
||||
NetworkFilter.Action.HIDE,
|
||||
filterModel.filterActionFor(
|
||||
mockStatus(content = "one two badWord three"),
|
||||
),
|
||||
|
@ -127,7 +128,7 @@ class FilterV1Test {
|
|||
@Test
|
||||
fun shouldFilter_whenContentMatchesBadWordPart() {
|
||||
assertEquals(
|
||||
Filter.Action.HIDE,
|
||||
NetworkFilter.Action.HIDE,
|
||||
filterModel.filterActionFor(
|
||||
mockStatus(content = "one two badWordPart three"),
|
||||
),
|
||||
|
@ -137,7 +138,7 @@ class FilterV1Test {
|
|||
@Test
|
||||
fun shouldFilter_whenContentMatchesBadWholeWord() {
|
||||
assertEquals(
|
||||
Filter.Action.HIDE,
|
||||
NetworkFilter.Action.HIDE,
|
||||
filterModel.filterActionFor(
|
||||
mockStatus(content = "one two badWholeWord three"),
|
||||
),
|
||||
|
@ -147,7 +148,7 @@ class FilterV1Test {
|
|||
@Test
|
||||
fun shouldNotFilter_whenContentDoesNotMatchWholeWord() {
|
||||
assertEquals(
|
||||
Filter.Action.NONE,
|
||||
NetworkFilter.Action.NONE,
|
||||
filterModel.filterActionFor(
|
||||
mockStatus(content = "one two badWholeWordTest three"),
|
||||
),
|
||||
|
@ -157,7 +158,7 @@ class FilterV1Test {
|
|||
@Test
|
||||
fun shouldFilter_whenSpoilerTextDoesMatch() {
|
||||
assertEquals(
|
||||
Filter.Action.HIDE,
|
||||
NetworkFilter.Action.HIDE,
|
||||
filterModel.filterActionFor(
|
||||
mockStatus(
|
||||
content = "should not be filtered",
|
||||
|
@ -170,7 +171,7 @@ class FilterV1Test {
|
|||
@Test
|
||||
fun shouldFilter_whenPollTextDoesMatch() {
|
||||
assertEquals(
|
||||
Filter.Action.HIDE,
|
||||
NetworkFilter.Action.HIDE,
|
||||
filterModel.filterActionFor(
|
||||
mockStatus(
|
||||
content = "should not be filtered",
|
||||
|
@ -184,7 +185,7 @@ class FilterV1Test {
|
|||
@Test
|
||||
fun shouldFilter_whenMediaDescriptionDoesMatch() {
|
||||
assertEquals(
|
||||
Filter.Action.HIDE,
|
||||
NetworkFilter.Action.HIDE,
|
||||
filterModel.filterActionFor(
|
||||
mockStatus(
|
||||
content = "should not be filtered",
|
||||
|
@ -198,7 +199,7 @@ class FilterV1Test {
|
|||
@Test
|
||||
fun shouldFilterPartialWord_whenWholeWordFilterContainsNonAlphanumericCharacters() {
|
||||
assertEquals(
|
||||
Filter.Action.HIDE,
|
||||
NetworkFilter.Action.HIDE,
|
||||
filterModel.filterActionFor(
|
||||
mockStatus(content = "one two someone@twitter.com three"),
|
||||
),
|
||||
|
@ -208,7 +209,7 @@ class FilterV1Test {
|
|||
@Test
|
||||
fun shouldFilterHashtags() {
|
||||
assertEquals(
|
||||
Filter.Action.HIDE,
|
||||
NetworkFilter.Action.HIDE,
|
||||
filterModel.filterActionFor(
|
||||
mockStatus(content = "#hashtag one two three"),
|
||||
),
|
||||
|
@ -218,7 +219,7 @@ class FilterV1Test {
|
|||
@Test
|
||||
fun shouldFilterHashtags_whenContentIsMarkedUp() {
|
||||
assertEquals(
|
||||
Filter.Action.HIDE,
|
||||
NetworkFilter.Action.HIDE,
|
||||
filterModel.filterActionFor(
|
||||
mockStatus(content = "<p><a href=\"https://foo.bar/tags/hashtag\" class=\"mention hashtag\" rel=\"nofollow noopener noreferrer\" target=\"_blank\">#<span>hashtag</span></a>one two three</p>"),
|
||||
),
|
||||
|
@ -228,7 +229,7 @@ class FilterV1Test {
|
|||
@Test
|
||||
fun shouldNotFilterHtmlAttributes() {
|
||||
assertEquals(
|
||||
Filter.Action.NONE,
|
||||
NetworkFilter.Action.NONE,
|
||||
filterModel.filterActionFor(
|
||||
mockStatus(content = "<p><a href=\"https://foo.bar/\">https://foo.bar/</a> one two three</p>"),
|
||||
),
|
||||
|
@ -238,7 +239,7 @@ class FilterV1Test {
|
|||
@Test
|
||||
fun shouldNotFilter_whenFilterIsExpired() {
|
||||
assertEquals(
|
||||
Filter.Action.NONE,
|
||||
NetworkFilter.Action.NONE,
|
||||
filterModel.filterActionFor(
|
||||
mockStatus(content = "content matching expired filter should not be filtered"),
|
||||
),
|
||||
|
@ -248,7 +249,7 @@ class FilterV1Test {
|
|||
@Test
|
||||
fun shouldFilter_whenFilterIsUnexpired() {
|
||||
assertEquals(
|
||||
Filter.Action.HIDE,
|
||||
NetworkFilter.Action.HIDE,
|
||||
filterModel.filterActionFor(
|
||||
mockStatus(content = "content matching unexpired filter should be filtered"),
|
||||
),
|
||||
|
|
|
@ -0,0 +1,170 @@
|
|||
/*
|
||||
* Copyright 2024 Pachli Association
|
||||
*
|
||||
* This file is a part of Pachli.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* Pachli 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 Pachli; if not,
|
||||
* see <http://www.gnu.org/licenses>.
|
||||
*/
|
||||
|
||||
package app.pachli.components.filters
|
||||
|
||||
import app.pachli.core.data.model.Filter
|
||||
import app.pachli.core.data.repository.FilterEdit
|
||||
import app.pachli.core.network.model.Filter.Action
|
||||
import app.pachli.core.network.model.FilterContext
|
||||
import app.pachli.core.network.model.FilterKeyword
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.Test
|
||||
|
||||
class FilterViewDataTest {
|
||||
private val originalFilter = Filter(
|
||||
id = "1",
|
||||
title = "original filter",
|
||||
contexts = setOf(FilterContext.HOME),
|
||||
expiresAt = null,
|
||||
action = Action.WARN,
|
||||
keywords = listOf(
|
||||
FilterKeyword(id = "1", keyword = "first", wholeWord = false),
|
||||
FilterKeyword(id = "2", keyword = "second", wholeWord = true),
|
||||
FilterKeyword(id = "3", keyword = "three", wholeWord = true),
|
||||
FilterKeyword(id = "4", keyword = "four", wholeWord = true),
|
||||
),
|
||||
)
|
||||
|
||||
private val originalFilterViewData = FilterViewData.from(originalFilter)
|
||||
|
||||
@Test
|
||||
fun `diff title only affects title`() {
|
||||
val newTitle = "new title"
|
||||
|
||||
val update = originalFilterViewData
|
||||
.copy(title = newTitle)
|
||||
.diff(originalFilter)
|
||||
|
||||
val expectedUpdate = FilterEdit(id = originalFilter.id, title = newTitle)
|
||||
|
||||
assertThat(update).isEqualTo(expectedUpdate)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `diff contexts only affects contexts`() {
|
||||
val newContexts = setOf(FilterContext.HOME, FilterContext.THREAD)
|
||||
|
||||
val update = originalFilterViewData
|
||||
.copy(contexts = newContexts)
|
||||
.diff(originalFilter)
|
||||
|
||||
val expectedUpdate = FilterEdit(id = originalFilter.id, contexts = newContexts)
|
||||
|
||||
assertThat(update).isEqualTo(expectedUpdate)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `diff expiresIn only affects expiresIn`() {
|
||||
val newExpiresIn = 300
|
||||
|
||||
val update = originalFilterViewData
|
||||
.copy(expiresIn = newExpiresIn)
|
||||
.diff(originalFilter)
|
||||
|
||||
val expectedUpdate = FilterEdit(id = originalFilter.id, expiresIn = newExpiresIn)
|
||||
|
||||
assertThat(update).isEqualTo(expectedUpdate)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `diff action only affects action`() {
|
||||
val newAction = Action.HIDE
|
||||
|
||||
val update = originalFilterViewData
|
||||
.copy(action = newAction)
|
||||
.diff(originalFilter)
|
||||
|
||||
val expectedUpdate = FilterEdit(id = originalFilter.id, action = newAction)
|
||||
|
||||
assertThat(update).isEqualTo(expectedUpdate)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `adding a keyword updates keywordsToAdd`() {
|
||||
val newKeyword = FilterKeyword(id = "", keyword = "new keyword", wholeWord = false)
|
||||
|
||||
val update = originalFilterViewData
|
||||
.copy(keywords = originalFilterViewData.keywords + newKeyword)
|
||||
.diff(originalFilter)
|
||||
|
||||
val expectedUpdate = FilterEdit(id = originalFilter.id, keywordsToAdd = listOf(newKeyword))
|
||||
|
||||
assertThat(update).isEqualTo(expectedUpdate)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deleting a keyword updates keywordsToDelete`() {
|
||||
val (keywordsToDelete, updatedKeywords) = originalFilterViewData.keywords.partition {
|
||||
it.id == "2"
|
||||
}
|
||||
|
||||
val update = originalFilterViewData
|
||||
.copy(keywords = updatedKeywords)
|
||||
.diff(originalFilter)
|
||||
|
||||
val expectedUpdate = FilterEdit(id = originalFilter.id, keywordsToDelete = keywordsToDelete)
|
||||
|
||||
assertThat(update).isEqualTo(expectedUpdate)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `modifying a keyword updates keywordsToModify`() {
|
||||
val modifiedKeyword = originalFilter.keywords[1].copy(keyword = "modified keyword")
|
||||
|
||||
val newKeywords = originalFilter.keywords.map {
|
||||
if (it.id == modifiedKeyword.id) modifiedKeyword else it
|
||||
}
|
||||
|
||||
val update = originalFilterViewData
|
||||
.copy(keywords = newKeywords)
|
||||
.diff(originalFilter)
|
||||
|
||||
// The fact the keywords are in a different order now should have no effect.
|
||||
// Only the change to the key
|
||||
val expectedUpdate = FilterEdit(id = originalFilter.id, keywordsToModify = listOf(modifiedKeyword))
|
||||
|
||||
assertThat(update).isEqualTo(expectedUpdate)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `adding, modifying, and deleting keywords together works`() {
|
||||
// Add a new keyword, delete keyword with id == "2", and modify the keyword with
|
||||
// id == "3".
|
||||
val keywordToAdd = FilterKeyword(id = "", keyword = "new keyword", wholeWord = false)
|
||||
val keywordToDelete = originalFilter.keywords.find { it.id == "2" }!!
|
||||
val modifiedKeyword = originalFilter.keywords.find { it.id == "3" }?.copy(keyword = "modified keyword")!!
|
||||
|
||||
val newKeywords = originalFilter.keywords
|
||||
.filterNot { it.id == keywordToDelete.id }
|
||||
.map { if (it.id == modifiedKeyword.id) modifiedKeyword else it }
|
||||
.plus(keywordToAdd)
|
||||
|
||||
val update = originalFilterViewData
|
||||
.copy(keywords = newKeywords)
|
||||
.diff(originalFilter)
|
||||
|
||||
val expectedUpdate = FilterEdit(
|
||||
id = originalFilter.id,
|
||||
keywordsToAdd = listOf(keywordToAdd),
|
||||
keywordsToDelete = listOf(keywordToDelete),
|
||||
keywordsToModify = listOf(modifiedKeyword),
|
||||
)
|
||||
|
||||
assertThat(update).isEqualTo(expectedUpdate)
|
||||
}
|
||||
}
|
|
@ -18,11 +18,13 @@
|
|||
package app.pachli.components.notifications
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import app.pachli.appstore.EventHub
|
||||
import app.pachli.components.timeline.FilterKind
|
||||
import app.pachli.components.timeline.FiltersRepository
|
||||
import app.pachli.core.accounts.AccountManager
|
||||
import app.pachli.core.data.repository.AccountPreferenceDataStore
|
||||
import app.pachli.core.data.repository.Filters
|
||||
import app.pachli.core.data.repository.FiltersError
|
||||
import app.pachli.core.data.repository.FiltersRepository
|
||||
import app.pachli.core.data.repository.ServerRepository
|
||||
import app.pachli.core.data.repository.StatusDisplayOptionsRepository
|
||||
import app.pachli.core.database.model.AccountEntity
|
||||
|
@ -35,6 +37,8 @@ import app.pachli.core.testing.fakes.InMemorySharedPreferences
|
|||
import app.pachli.core.testing.rules.MainCoroutineRule
|
||||
import app.pachli.usecase.TimelineCases
|
||||
import at.connyduck.calladapter.networkresult.NetworkResult
|
||||
import com.github.michaelbull.result.Ok
|
||||
import com.github.michaelbull.result.Result
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import okhttp3.ResponseBody
|
||||
|
@ -103,8 +107,9 @@ abstract class NotificationsViewModelTestBase {
|
|||
)
|
||||
|
||||
timelineCases = mock()
|
||||
|
||||
filtersRepository = mock {
|
||||
onBlocking { getFilters() } doReturn FilterKind.V2(emptyList())
|
||||
whenever(it.filters).thenReturn(MutableStateFlow<Result<Filters?, FiltersError.GetFiltersError>>(Ok(null)))
|
||||
}
|
||||
|
||||
sharedPreferencesRepository = SharedPreferencesRepository(
|
||||
|
@ -149,6 +154,7 @@ abstract class NotificationsViewModelTestBase {
|
|||
)
|
||||
|
||||
viewModel = NotificationsViewModel(
|
||||
InstrumentationRegistry.getInstrumentation().targetContext,
|
||||
notificationsRepository,
|
||||
accountManager,
|
||||
timelineCases,
|
||||
|
|
|
@ -19,11 +19,13 @@ package app.pachli.components.timeline
|
|||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import app.pachli.PachliApplication
|
||||
import app.pachli.appstore.EventHub
|
||||
import app.pachli.components.timeline.viewmodel.CachedTimelineViewModel
|
||||
import app.pachli.components.timeline.viewmodel.TimelineViewModel
|
||||
import app.pachli.core.accounts.AccountManager
|
||||
import app.pachli.core.data.repository.FiltersRepository
|
||||
import app.pachli.core.data.repository.StatusDisplayOptionsRepository
|
||||
import app.pachli.core.model.Timeline
|
||||
import app.pachli.core.network.model.Account
|
||||
|
@ -33,6 +35,7 @@ import app.pachli.core.network.retrofit.MastodonApi
|
|||
import app.pachli.core.network.retrofit.NodeInfoApi
|
||||
import app.pachli.core.preferences.SharedPreferencesRepository
|
||||
import app.pachli.core.testing.rules.MainCoroutineRule
|
||||
import app.pachli.core.testing.success
|
||||
import app.pachli.usecase.TimelineCases
|
||||
import at.connyduck.calladapter.networkresult.NetworkResult
|
||||
import com.squareup.moshi.Moshi
|
||||
|
@ -113,7 +116,7 @@ abstract class CachedTimelineViewModelTestBase {
|
|||
reset(mastodonApi)
|
||||
mastodonApi.stub {
|
||||
onBlocking { getCustomEmojis() } doReturn NetworkResult.failure(Exception())
|
||||
onBlocking { getFilters() } doReturn NetworkResult.success(emptyList())
|
||||
onBlocking { getFilters() } doReturn success(emptyList())
|
||||
}
|
||||
|
||||
reset(nodeInfoApi)
|
||||
|
@ -155,6 +158,7 @@ abstract class CachedTimelineViewModelTestBase {
|
|||
timelineCases = mock()
|
||||
|
||||
viewModel = CachedTimelineViewModel(
|
||||
InstrumentationRegistry.getInstrumentation().targetContext,
|
||||
SavedStateHandle(mapOf(TimelineViewModel.TIMELINE_TAG to Timeline.Home)),
|
||||
cachedTimelineRepository,
|
||||
timelineCases,
|
||||
|
|
|
@ -19,10 +19,12 @@ package app.pachli.components.timeline
|
|||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import app.pachli.appstore.EventHub
|
||||
import app.pachli.components.timeline.viewmodel.NetworkTimelineViewModel
|
||||
import app.pachli.components.timeline.viewmodel.TimelineViewModel
|
||||
import app.pachli.core.accounts.AccountManager
|
||||
import app.pachli.core.data.repository.FiltersRepository
|
||||
import app.pachli.core.data.repository.StatusDisplayOptionsRepository
|
||||
import app.pachli.core.model.Timeline
|
||||
import app.pachli.core.network.model.Account
|
||||
|
@ -32,6 +34,7 @@ import app.pachli.core.network.retrofit.MastodonApi
|
|||
import app.pachli.core.network.retrofit.NodeInfoApi
|
||||
import app.pachli.core.preferences.SharedPreferencesRepository
|
||||
import app.pachli.core.testing.rules.MainCoroutineRule
|
||||
import app.pachli.core.testing.success
|
||||
import app.pachli.usecase.TimelineCases
|
||||
import app.pachli.util.HiltTestApplication_Application
|
||||
import at.connyduck.calladapter.networkresult.NetworkResult
|
||||
|
@ -103,7 +106,7 @@ abstract class NetworkTimelineViewModelTestBase {
|
|||
reset(mastodonApi)
|
||||
mastodonApi.stub {
|
||||
onBlocking { getCustomEmojis() } doReturn NetworkResult.failure(Exception())
|
||||
onBlocking { getFilters() } doReturn NetworkResult.success(emptyList())
|
||||
onBlocking { getFilters() } doReturn success(emptyList())
|
||||
}
|
||||
|
||||
reset(nodeInfoApi)
|
||||
|
@ -145,6 +148,7 @@ abstract class NetworkTimelineViewModelTestBase {
|
|||
timelineCases = mock()
|
||||
|
||||
viewModel = NetworkTimelineViewModel(
|
||||
InstrumentationRegistry.getInstrumentation().targetContext,
|
||||
SavedStateHandle(mapOf(TimelineViewModel.TIMELINE_TAG to Timeline.Bookmarks)),
|
||||
networkTimelineRepository,
|
||||
timelineCases,
|
||||
|
|
|
@ -9,11 +9,12 @@ import app.pachli.appstore.FavoriteEvent
|
|||
import app.pachli.appstore.ReblogEvent
|
||||
import app.pachli.components.compose.HiltTestApplication_Application
|
||||
import app.pachli.components.timeline.CachedTimelineRepository
|
||||
import app.pachli.components.timeline.FilterKind
|
||||
import app.pachli.components.timeline.FiltersRepository
|
||||
import app.pachli.components.timeline.mockStatus
|
||||
import app.pachli.components.timeline.mockStatusViewData
|
||||
import app.pachli.core.accounts.AccountManager
|
||||
import app.pachli.core.data.repository.Filters
|
||||
import app.pachli.core.data.repository.FiltersError
|
||||
import app.pachli.core.data.repository.FiltersRepository
|
||||
import app.pachli.core.data.repository.StatusDisplayOptionsRepository
|
||||
import app.pachli.core.database.dao.TimelineDao
|
||||
import app.pachli.core.database.model.AccountEntity
|
||||
|
@ -26,6 +27,8 @@ import app.pachli.core.network.retrofit.NodeInfoApi
|
|||
import app.pachli.core.preferences.SharedPreferencesRepository
|
||||
import app.pachli.usecase.TimelineCases
|
||||
import at.connyduck.calladapter.networkresult.NetworkResult
|
||||
import com.github.michaelbull.result.Ok
|
||||
import com.github.michaelbull.result.Result
|
||||
import com.squareup.moshi.Moshi
|
||||
import dagger.hilt.android.testing.BindValue
|
||||
import dagger.hilt.android.testing.CustomTestApplication
|
||||
|
@ -35,6 +38,7 @@ import java.io.IOException
|
|||
import java.time.Instant
|
||||
import java.util.Date
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert.assertEquals
|
||||
|
@ -47,6 +51,7 @@ import org.mockito.kotlin.doReturn
|
|||
import org.mockito.kotlin.mock
|
||||
import org.mockito.kotlin.reset
|
||||
import org.mockito.kotlin.stub
|
||||
import org.mockito.kotlin.whenever
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
open class PachliHiltApplication : PachliApplication()
|
||||
|
@ -129,7 +134,7 @@ class ViewThreadViewModelTest {
|
|||
|
||||
reset(filtersRepository)
|
||||
filtersRepository.stub {
|
||||
onBlocking { getFilters() } doReturn FilterKind.V2(emptyList())
|
||||
whenever(it.filters).thenReturn(MutableStateFlow<Result<Filters?, FiltersError.GetFiltersError>>(Ok(null)))
|
||||
}
|
||||
|
||||
reset(nodeInfoApi)
|
||||
|
|
|
@ -0,0 +1,59 @@
|
|||
/*
|
||||
* Copyright 2024 Pachli Association
|
||||
*
|
||||
* This file is a part of Pachli.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* Pachli 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 Pachli; if not,
|
||||
* see <http://www.gnu.org/licenses>.
|
||||
*/
|
||||
|
||||
package app.pachli.core.common.extensions
|
||||
|
||||
import com.github.michaelbull.result.Err
|
||||
import com.github.michaelbull.result.Ok
|
||||
import com.github.michaelbull.result.Result
|
||||
import kotlin.contracts.ExperimentalContracts
|
||||
import kotlin.contracts.InvocationKind
|
||||
import kotlin.contracts.contract
|
||||
|
||||
/**
|
||||
* Maps this [Result<V, E>][Result] to [Result<V, E>][Result] by either applying the [transform]
|
||||
* function to the [value][Ok.value] if this [Result] is [Ok<T>][Ok], or returning the result
|
||||
* unchanged.
|
||||
*/
|
||||
@OptIn(ExperimentalContracts::class)
|
||||
inline infix fun <V, E, reified T : V> Result<V, E>.mapIfInstance(transform: (T) -> V): Result<V, E> {
|
||||
contract {
|
||||
callsInPlace(transform, InvocationKind.AT_MOST_ONCE)
|
||||
}
|
||||
|
||||
return when (this) {
|
||||
is Ok -> (value as? T)?.let { Ok(transform(it)) } ?: this
|
||||
is Err -> this
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps this [Result<V?, E>][Result] to [Result<V?, E>][Result] by either applying the [transform]
|
||||
* function to the [value][Ok.value] if this [Result] is [Ok] and non-null, or returning the
|
||||
* result unchanged.
|
||||
*/
|
||||
@OptIn(ExperimentalContracts::class)
|
||||
inline infix fun <V, E> Result<V?, E>.mapIfNotNull(transform: (V) -> V): Result<V?, E> {
|
||||
contract {
|
||||
callsInPlace(transform, InvocationKind.AT_MOST_ONCE)
|
||||
}
|
||||
|
||||
return when (this) {
|
||||
is Ok -> value?.let { Ok(transform(it)) } ?: this
|
||||
is Err -> this
|
||||
}
|
||||
}
|
|
@ -18,6 +18,7 @@
|
|||
plugins {
|
||||
alias(libs.plugins.pachli.android.library)
|
||||
alias(libs.plugins.pachli.android.hilt)
|
||||
alias(libs.plugins.kotlin.parcelize)
|
||||
}
|
||||
|
||||
android {
|
||||
|
|
|
@ -0,0 +1,111 @@
|
|||
/*
|
||||
* Copyright 2024 Pachli Association
|
||||
*
|
||||
* This file is a part of Pachli.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* Pachli 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 Pachli; if not,
|
||||
* see <http://www.gnu.org/licenses>.
|
||||
*/
|
||||
|
||||
package app.pachli.core.data.model
|
||||
|
||||
import android.os.Parcelable
|
||||
import app.pachli.core.network.model.Filter.Action
|
||||
import app.pachli.core.network.model.FilterContext
|
||||
import app.pachli.core.network.model.FilterKeyword
|
||||
import java.util.Date
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
/** Reasons why a filter might be invalid */
|
||||
enum class FilterValidationError {
|
||||
/** Filter title is empty or blank */
|
||||
NO_TITLE,
|
||||
|
||||
/** Filter has no keywords */
|
||||
NO_KEYWORDS,
|
||||
|
||||
/** Filter has no contexts */
|
||||
NO_CONTEXT,
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal representation of a Mastodon filter, whether v1 or v2.
|
||||
*
|
||||
* @param id The server's ID for this filter
|
||||
* @param title Filter's title (label to use in the UI)
|
||||
* @param contexts One or more [FilterContext] the filter is applied to
|
||||
* @param expiresAt Date the filter expires, null if the filter does not expire
|
||||
* @param action Action to take if the filter matches a status
|
||||
* @param keywords One or more [FilterKeyword] the filter matches against a status
|
||||
*/
|
||||
@Parcelize
|
||||
data class Filter(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val contexts: Set<FilterContext> = emptySet(),
|
||||
val expiresAt: Date? = null,
|
||||
val action: Action,
|
||||
val keywords: List<FilterKeyword> = emptyList(),
|
||||
) : Parcelable {
|
||||
/**
|
||||
* @return Set of [FilterValidationError] given the current state of the
|
||||
* filter. Empty if there are no validation errors.
|
||||
*/
|
||||
fun validate() = buildSet {
|
||||
if (title.isBlank()) add(FilterValidationError.NO_TITLE)
|
||||
if (keywords.isEmpty()) add(FilterValidationError.NO_KEYWORDS)
|
||||
if (contexts.isEmpty()) add(FilterValidationError.NO_CONTEXT)
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Returns a [Filter] from a
|
||||
* [v2 Mastodon filter][app.pachli.core.network.model.Filter].
|
||||
*/
|
||||
fun from(filter: app.pachli.core.network.model.Filter) = Filter(
|
||||
id = filter.id,
|
||||
title = filter.title,
|
||||
contexts = filter.contexts,
|
||||
expiresAt = filter.expiresAt,
|
||||
action = filter.action,
|
||||
keywords = filter.keywords,
|
||||
)
|
||||
|
||||
/**
|
||||
* Returns a [Filter] from a
|
||||
* [v1 Mastodon filter][app.pachli.core.network.model.Filter].
|
||||
*
|
||||
* There are some restrictions imposed by the v1 filter;
|
||||
* - it can only have a single entry in the [keywords] list
|
||||
* - the [title] is identical to the keyword
|
||||
*/
|
||||
fun from(filter: app.pachli.core.network.model.FilterV1) = Filter(
|
||||
id = filter.id,
|
||||
title = filter.phrase,
|
||||
contexts = filter.contexts,
|
||||
expiresAt = filter.expiresAt,
|
||||
action = Action.WARN,
|
||||
keywords = listOf(
|
||||
FilterKeyword(
|
||||
id = filter.id,
|
||||
keyword = filter.phrase,
|
||||
wholeWord = filter.wholeWord,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** A new filter keyword; has no ID as it has not been saved to the server. */
|
||||
data class NewFilterKeyword(
|
||||
val keyword: String,
|
||||
val wholeWord: Boolean,
|
||||
)
|
|
@ -0,0 +1,392 @@
|
|||
/*
|
||||
* Copyright 2024 Pachli Association
|
||||
*
|
||||
* This file is a part of Pachli.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* Pachli 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 Pachli; if not,
|
||||
* see <http://www.gnu.org/licenses>.
|
||||
*/
|
||||
|
||||
package app.pachli.core.data.repository
|
||||
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import app.pachli.core.common.PachliError
|
||||
import app.pachli.core.common.di.ApplicationScope
|
||||
import app.pachli.core.data.R
|
||||
import app.pachli.core.data.model.Filter
|
||||
import app.pachli.core.data.model.NewFilterKeyword
|
||||
import app.pachli.core.data.repository.FiltersError.DeleteFilterError
|
||||
import app.pachli.core.data.repository.FiltersError.GetFiltersError
|
||||
import app.pachli.core.data.repository.FiltersError.ServerDoesNotFilter
|
||||
import app.pachli.core.data.repository.FiltersError.ServerRepositoryError
|
||||
import app.pachli.core.network.Server
|
||||
import app.pachli.core.network.ServerOperation.ORG_JOINMASTODON_FILTERS_CLIENT
|
||||
import app.pachli.core.network.ServerOperation.ORG_JOINMASTODON_FILTERS_SERVER
|
||||
import app.pachli.core.network.model.Filter as NetworkFilter
|
||||
import app.pachli.core.network.model.FilterContext
|
||||
import app.pachli.core.network.model.FilterKeyword
|
||||
import app.pachli.core.network.model.NewFilterV1
|
||||
import app.pachli.core.network.retrofit.MastodonApi
|
||||
import com.github.michaelbull.result.Err
|
||||
import com.github.michaelbull.result.Ok
|
||||
import com.github.michaelbull.result.Result
|
||||
import com.github.michaelbull.result.andThen
|
||||
import com.github.michaelbull.result.coroutines.binding.binding
|
||||
import com.github.michaelbull.result.get
|
||||
import com.github.michaelbull.result.map
|
||||
import com.github.michaelbull.result.mapError
|
||||
import com.github.michaelbull.result.mapResult
|
||||
import io.github.z4kn4fein.semver.constraints.toConstraint
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
|
||||
/**
|
||||
* A filter to be created or updated.
|
||||
*
|
||||
* Same as [Filter] except a [NewFilter] does not have an [id][Filter.id], as it
|
||||
* has not been created on the server.
|
||||
*/
|
||||
data class NewFilter(
|
||||
val title: String,
|
||||
val contexts: Set<FilterContext>,
|
||||
val expiresIn: Int,
|
||||
val action: app.pachli.core.network.model.Filter.Action,
|
||||
val keywords: List<NewFilterKeyword>,
|
||||
) {
|
||||
fun toNewFilterV1() = this.keywords.map { keyword ->
|
||||
NewFilterV1(
|
||||
phrase = keyword.keyword,
|
||||
contexts = this.contexts,
|
||||
expiresIn = this.expiresIn,
|
||||
irreversible = false,
|
||||
wholeWord = keyword.wholeWord,
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun from(filter: Filter) = NewFilter(
|
||||
title = filter.title,
|
||||
contexts = filter.contexts,
|
||||
expiresIn = -1,
|
||||
action = filter.action,
|
||||
keywords = filter.keywords.map {
|
||||
NewFilterKeyword(
|
||||
keyword = it.keyword,
|
||||
wholeWord = it.wholeWord,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a collection of edits to make to an existing filter.
|
||||
*
|
||||
* @param id ID of the filter to be changed
|
||||
* @param title New title, null if the title should not be changed
|
||||
* @param contexts New contexts, null if the contexts should not be changed
|
||||
* @param expiresIn New expiresIn, -1 if the expiry time should not be changed
|
||||
* @param action New action, null if the action should not be changed
|
||||
* @param keywordsToAdd One or more keywords to add to the filter, null if none to add
|
||||
* @param keywordsToDelete One or more keywords to delete from the filter, null if none to delete
|
||||
* @param keywordsToModify One or more keywords to modify in the filter, null if none to modify
|
||||
*/
|
||||
data class FilterEdit(
|
||||
val id: String,
|
||||
val title: String? = null,
|
||||
val contexts: Collection<FilterContext>? = null,
|
||||
val expiresIn: Int = -1,
|
||||
val action: NetworkFilter.Action? = null,
|
||||
val keywordsToAdd: List<FilterKeyword>? = null,
|
||||
val keywordsToDelete: List<FilterKeyword>? = null,
|
||||
val keywordsToModify: List<FilterKeyword>? = null,
|
||||
)
|
||||
|
||||
/** Errors that can be returned from this repository. */
|
||||
sealed interface FiltersError : PachliError {
|
||||
/** Wraps errors from actions on the [ServerRepository]. */
|
||||
@JvmInline
|
||||
value class ServerRepositoryError(private val error: ServerRepository.Error) :
|
||||
FiltersError, PachliError by error
|
||||
|
||||
/** The user's server does not support filters. */
|
||||
data object ServerDoesNotFilter : FiltersError {
|
||||
override val resourceId: Int = R.string.error_filter_server_does_not_filter
|
||||
override val formatArgs: Array<out Any>? = null
|
||||
override val cause: PachliError? = null
|
||||
}
|
||||
|
||||
/** API error fetching a filter by ID. */
|
||||
@JvmInline
|
||||
value class GetFilterError(private val error: PachliError) : FiltersError, PachliError by error
|
||||
|
||||
/** API error fetching all filters. */
|
||||
@JvmInline
|
||||
value class GetFiltersError(@get:VisibleForTesting val error: PachliError) : FiltersError, PachliError by error
|
||||
|
||||
/** API error creating a filter. */
|
||||
@JvmInline
|
||||
value class CreateFilterError(private val error: PachliError) : FiltersError, PachliError by error
|
||||
|
||||
/** API error updating a filter. */
|
||||
@JvmInline
|
||||
value class UpdateFilterError(private val error: PachliError) : FiltersError, PachliError by error
|
||||
|
||||
/** API error deleting a filter. */
|
||||
@JvmInline
|
||||
value class DeleteFilterError(private val error: PachliError) : FiltersError, PachliError by error
|
||||
}
|
||||
|
||||
enum class FilterVersion {
|
||||
V1,
|
||||
V2,
|
||||
}
|
||||
|
||||
// Hack, so that FilterModel can know whether this is V1 or V2 filters.
|
||||
// See usage in:
|
||||
// - TimelineViewModel.getFilters()
|
||||
// - NotificationsViewModel.getFilters()
|
||||
// Need to think about a better way to do this.
|
||||
data class Filters(
|
||||
val filters: List<Filter>,
|
||||
val version: FilterVersion,
|
||||
)
|
||||
|
||||
/** Repository for filter information */
|
||||
@Singleton
|
||||
class FiltersRepository @Inject constructor(
|
||||
@ApplicationScope private val externalScope: CoroutineScope,
|
||||
private val mastodonApi: MastodonApi,
|
||||
private val serverRepository: ServerRepository,
|
||||
) {
|
||||
/** Flow where emissions trigger fresh loads from the server. */
|
||||
private val reload = MutableSharedFlow<Unit>(replay = 1).apply { tryEmit(Unit) }
|
||||
|
||||
private lateinit var server: Result<Server, ServerRepositoryError>
|
||||
|
||||
/**
|
||||
* Flow of filters from the server. Updates when:
|
||||
*
|
||||
* - A new value is emitted to [reload]
|
||||
* - The active server changes
|
||||
*
|
||||
* The [Ok] value is either `null` if the filters have not yet been loaded, or
|
||||
* the most recent loaded filters.
|
||||
*/
|
||||
val filters = reload.combine(serverRepository.flow) { _, server ->
|
||||
this.server = server.mapError { ServerRepositoryError(it) }
|
||||
server
|
||||
.mapError { GetFiltersError(it) }
|
||||
.andThen { getFilters(it) }
|
||||
}
|
||||
.stateIn(externalScope, SharingStarted.Lazily, Ok(null))
|
||||
|
||||
suspend fun reload() = reload.emit(Unit)
|
||||
|
||||
/** @return True if the user's server can filter, false otherwise. */
|
||||
fun canFilter() = server.get()?.let { it.canFilterV1() || it.canFilterV2() } ?: false
|
||||
|
||||
/** Get a specific filter from the server, by [filterId]. */
|
||||
suspend fun getFilter(filterId: String): Result<Filter, FiltersError> = binding {
|
||||
val server = server.bind()
|
||||
|
||||
when {
|
||||
server.canFilterV2() -> mastodonApi.getFilter(filterId).map { Filter.from(it.body) }
|
||||
server.canFilterV1() -> mastodonApi.getFilterV1(filterId).map { Filter.from(it.body) }
|
||||
else -> Err(ServerDoesNotFilter)
|
||||
}.mapError { FiltersError.GetFilterError(it) }.bind()
|
||||
}
|
||||
|
||||
/** Get the current set of filters. */
|
||||
private suspend fun getFilters(server: Server): Result<Filters, FiltersError> = binding {
|
||||
when {
|
||||
server.canFilterV2() -> mastodonApi.getFilters().map {
|
||||
Filters(
|
||||
filters = it.body.map { Filter.from(it) },
|
||||
version = FilterVersion.V2,
|
||||
)
|
||||
}
|
||||
server.canFilterV1() -> mastodonApi.getFiltersV1().map {
|
||||
Filters(
|
||||
filters = it.body.map { Filter.from(it) },
|
||||
version = FilterVersion.V1,
|
||||
)
|
||||
}
|
||||
else -> Err(ServerDoesNotFilter)
|
||||
}.mapError { GetFiltersError(it) }.bind()
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the filter in [filter].
|
||||
*
|
||||
* Reloads filters whether or not an error occured.
|
||||
*
|
||||
* @return The newly created [Filter], or a [FiltersError].
|
||||
*/
|
||||
suspend fun createFilter(filter: NewFilter): Result<Filter, FiltersError> = binding {
|
||||
val server = server.bind()
|
||||
|
||||
val expiresInSeconds = when (val expiresIn = filter.expiresIn) {
|
||||
0 -> ""
|
||||
else -> expiresIn.toString()
|
||||
}
|
||||
|
||||
externalScope.async {
|
||||
when {
|
||||
server.canFilterV2() -> {
|
||||
mastodonApi.createFilter(
|
||||
title = filter.title,
|
||||
contexts = filter.contexts,
|
||||
filterAction = filter.action,
|
||||
expiresInSeconds = expiresInSeconds,
|
||||
).andThen { response ->
|
||||
val filterId = response.body.id
|
||||
filter.keywords.mapResult {
|
||||
mastodonApi.addFilterKeyword(
|
||||
filterId,
|
||||
keyword = it.keyword,
|
||||
wholeWord = it.wholeWord,
|
||||
)
|
||||
}.map { Filter.from(response.body) }
|
||||
}
|
||||
}
|
||||
|
||||
server.canFilterV1() -> {
|
||||
filter.toNewFilterV1().mapResult {
|
||||
mastodonApi.createFilterV1(
|
||||
phrase = it.phrase,
|
||||
context = it.contexts,
|
||||
irreversible = it.irreversible,
|
||||
wholeWord = it.wholeWord,
|
||||
expiresInSeconds = expiresInSeconds,
|
||||
)
|
||||
}.map {
|
||||
Filter.from(it.last().body)
|
||||
}
|
||||
}
|
||||
|
||||
else -> Err(ServerDoesNotFilter)
|
||||
}.mapError { FiltersError.CreateFilterError(it) }
|
||||
.also { reload.emit(Unit) }
|
||||
}.await().bind()
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates [originalFilter] on the server by applying the changes in
|
||||
* [filterEdit].
|
||||
*
|
||||
* Reloads filters whether or not an error occured.*
|
||||
*/
|
||||
suspend fun updateFilter(originalFilter: Filter, filterEdit: FilterEdit): Result<Filter, FiltersError> = binding {
|
||||
val server = server.bind()
|
||||
|
||||
// Modify
|
||||
val expiresInSeconds = when (val expiresIn = filterEdit.expiresIn) {
|
||||
-1 -> null
|
||||
0 -> ""
|
||||
else -> expiresIn.toString()
|
||||
}
|
||||
|
||||
externalScope.async {
|
||||
when {
|
||||
server.canFilterV2() -> {
|
||||
// Retrofit can't send a form where there are multiple parameters
|
||||
// with the same ID (https://github.com/square/retrofit/issues/1324)
|
||||
// so it's not possible to update keywords
|
||||
|
||||
if (filterEdit.title != null ||
|
||||
filterEdit.contexts != null ||
|
||||
filterEdit.action != null ||
|
||||
expiresInSeconds != null
|
||||
) {
|
||||
mastodonApi.updateFilter(
|
||||
id = filterEdit.id,
|
||||
title = filterEdit.title,
|
||||
contexts = filterEdit.contexts,
|
||||
filterAction = filterEdit.action,
|
||||
expiresInSeconds = expiresInSeconds,
|
||||
)
|
||||
} else {
|
||||
Ok(originalFilter)
|
||||
}
|
||||
.andThen {
|
||||
filterEdit.keywordsToDelete.orEmpty().mapResult {
|
||||
mastodonApi.deleteFilterKeyword(it.id)
|
||||
}
|
||||
}
|
||||
.andThen {
|
||||
filterEdit.keywordsToModify.orEmpty().mapResult {
|
||||
mastodonApi.updateFilterKeyword(
|
||||
it.id,
|
||||
it.keyword,
|
||||
it.wholeWord,
|
||||
)
|
||||
}
|
||||
}
|
||||
.andThen {
|
||||
filterEdit.keywordsToAdd.orEmpty().mapResult {
|
||||
mastodonApi.addFilterKeyword(
|
||||
filterEdit.id,
|
||||
it.keyword,
|
||||
it.wholeWord,
|
||||
)
|
||||
}
|
||||
}
|
||||
.andThen {
|
||||
mastodonApi.getFilter(originalFilter.id)
|
||||
}
|
||||
.map { Filter.from(it.body) }
|
||||
}
|
||||
server.canFilterV1() -> {
|
||||
mastodonApi.updateFilterV1(
|
||||
id = filterEdit.id,
|
||||
phrase = filterEdit.keywordsToModify?.firstOrNull()?.keyword ?: originalFilter.keywords.first().keyword,
|
||||
wholeWord = filterEdit.keywordsToModify?.firstOrNull()?.wholeWord,
|
||||
contexts = filterEdit.contexts ?: originalFilter.contexts,
|
||||
irreversible = false,
|
||||
expiresInSeconds = expiresInSeconds,
|
||||
).map { Filter.from(it.body) }
|
||||
}
|
||||
else -> {
|
||||
Err(ServerDoesNotFilter)
|
||||
}
|
||||
}.mapError { FiltersError.UpdateFilterError(it) }
|
||||
.also { reload() }
|
||||
}.await().bind()
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the filter identified by [filterId] from the server.
|
||||
*
|
||||
* Reloads filters whether or not an error occured.
|
||||
*/
|
||||
suspend fun deleteFilter(filterId: String): Result<Unit, FiltersError> = binding {
|
||||
val server = server.bind()
|
||||
|
||||
externalScope.async {
|
||||
when {
|
||||
server.canFilterV2() -> mastodonApi.deleteFilter(filterId)
|
||||
server.canFilterV1() -> mastodonApi.deleteFilterV1(filterId)
|
||||
else -> Err(ServerDoesNotFilter)
|
||||
}.mapError { DeleteFilterError(it) }
|
||||
.also { reload() }
|
||||
}.await().bind()
|
||||
}
|
||||
}
|
||||
|
||||
private fun Server.canFilterV1() = this.can(ORG_JOINMASTODON_FILTERS_CLIENT, ">=1.0.0".toConstraint())
|
||||
private fun Server.canFilterV2() = this.can(ORG_JOINMASTODON_FILTERS_SERVER, ">=1.0.0".toConstraint())
|
|
@ -41,8 +41,10 @@ import com.github.michaelbull.result.mapError
|
|||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.shareIn
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
|
||||
|
@ -65,18 +67,14 @@ class ServerRepository @Inject constructor(
|
|||
private val accountManager: AccountManager,
|
||||
@ApplicationScope private val externalScope: CoroutineScope,
|
||||
) {
|
||||
private val _flow = MutableStateFlow<Result<Server?, Error>>(Ok(null))
|
||||
val flow = _flow.asStateFlow()
|
||||
private val reload = MutableSharedFlow<Unit>(replay = 1).apply { tryEmit(Unit) }
|
||||
|
||||
init {
|
||||
externalScope.launch {
|
||||
accountManager.activeAccountFlow.collect { _flow.emit(getServer()) }
|
||||
}
|
||||
}
|
||||
// SharedFlow, **not** StateFlow, to ensure a new value is emitted even if the
|
||||
// user switches between accounts that are on the same server.
|
||||
val flow = reload.combine(accountManager.activeAccountFlow) { _, _ -> getServer() }
|
||||
.shareIn(externalScope, SharingStarted.Lazily, replay = 1)
|
||||
|
||||
fun retry() = externalScope.launch {
|
||||
_flow.emit(getServer())
|
||||
}
|
||||
fun reload() = externalScope.launch { reload.emit(Unit) }
|
||||
|
||||
/**
|
||||
* @return the server info or a [Server.Error] if the server info can not
|
||||
|
|
|
@ -6,4 +6,5 @@
|
|||
<string name="server_repository_error_validate_node_info">validating nodeinfo %1$s failed: %2$s</string>
|
||||
<string name="server_repository_error_get_instance_info">fetching /api/v1/instance failed: %1$s</string>
|
||||
<string name="server_repository_error_capabilities">parsing server capabilities failed: %1$s</string>
|
||||
<string name="error_filter_server_does_not_filter">Server does not support filters</string>
|
||||
</resources>
|
||||
|
|
|
@ -0,0 +1,102 @@
|
|||
/*
|
||||
* Copyright 2024 Pachli Association
|
||||
*
|
||||
* This file is a part of Pachli.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* Pachli 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 Pachli; if not,
|
||||
* see <http://www.gnu.org/licenses>.
|
||||
*/
|
||||
|
||||
package app.pachli.core.data.repository.filtersRepository
|
||||
|
||||
import android.app.Application
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import app.pachli.core.data.repository.FiltersRepository
|
||||
import app.pachli.core.data.repository.HiltTestApplication_Application
|
||||
import app.pachli.core.data.repository.ServerRepository
|
||||
import app.pachli.core.network.Server
|
||||
import app.pachli.core.network.ServerKind
|
||||
import app.pachli.core.network.ServerOperation
|
||||
import app.pachli.core.network.retrofit.MastodonApi
|
||||
import app.pachli.core.testing.rules.MainCoroutineRule
|
||||
import com.github.michaelbull.result.Ok
|
||||
import dagger.hilt.android.testing.CustomTestApplication
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import io.github.z4kn4fein.semver.Version
|
||||
import io.github.z4kn4fein.semver.toVersion
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.runner.RunWith
|
||||
import org.mockito.kotlin.mock
|
||||
import org.mockito.kotlin.reset
|
||||
import org.mockito.kotlin.whenever
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
open class PachliHiltApplication : Application()
|
||||
|
||||
@CustomTestApplication(PachliHiltApplication::class)
|
||||
interface HiltTestApplication
|
||||
|
||||
@HiltAndroidTest
|
||||
@Config(application = HiltTestApplication_Application::class)
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
abstract class BaseFiltersRepositoryTest {
|
||||
@get:Rule(order = 0)
|
||||
var hilt = HiltAndroidRule(this)
|
||||
|
||||
@get:Rule(order = 1)
|
||||
val mainCoroutineRule = MainCoroutineRule()
|
||||
|
||||
@Inject
|
||||
lateinit var mastodonApi: MastodonApi
|
||||
|
||||
protected lateinit var filtersRepository: FiltersRepository
|
||||
|
||||
val serverFlow = MutableStateFlow(Ok(SERVER_V2))
|
||||
|
||||
private val serverRepository: ServerRepository = mock {
|
||||
whenever(it.flow).thenReturn(serverFlow)
|
||||
}
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
hilt.inject()
|
||||
|
||||
reset(mastodonApi)
|
||||
|
||||
filtersRepository = FiltersRepository(
|
||||
TestScope(),
|
||||
mastodonApi,
|
||||
serverRepository,
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
val SERVER_V2 = Server(
|
||||
kind = ServerKind.MASTODON,
|
||||
version = Version(4, 2, 0),
|
||||
capabilities = mapOf(
|
||||
Pair(ServerOperation.ORG_JOINMASTODON_FILTERS_SERVER, "1.0.0".toVersion(true)),
|
||||
),
|
||||
)
|
||||
val SERVER_V1 = Server(
|
||||
kind = ServerKind.MASTODON,
|
||||
version = Version(4, 2, 0),
|
||||
capabilities = mapOf(
|
||||
Pair(ServerOperation.ORG_JOINMASTODON_FILTERS_CLIENT, "1.1.0".toVersion(true)),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,201 @@
|
|||
/*
|
||||
* Copyright 2024 Pachli Association
|
||||
*
|
||||
* This file is a part of Pachli.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* Pachli 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 Pachli; if not,
|
||||
* see <http://www.gnu.org/licenses>.
|
||||
*/
|
||||
|
||||
package app.pachli.core.data.repository.filtersRepository
|
||||
|
||||
import app.cash.turbine.test
|
||||
import app.pachli.core.data.model.NewFilterKeyword
|
||||
import app.pachli.core.data.repository.NewFilter
|
||||
import app.pachli.core.network.model.Filter as NetworkFilter
|
||||
import app.pachli.core.network.model.Filter.Action
|
||||
import app.pachli.core.network.model.FilterContext
|
||||
import app.pachli.core.network.model.FilterKeyword
|
||||
import app.pachli.core.network.model.FilterV1 as NetworkFilterV1
|
||||
import app.pachli.core.testing.success
|
||||
import com.github.michaelbull.result.Ok
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import java.util.Date
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
import org.mockito.kotlin.any
|
||||
import org.mockito.kotlin.doAnswer
|
||||
import org.mockito.kotlin.doReturn
|
||||
import org.mockito.kotlin.stub
|
||||
import org.mockito.kotlin.times
|
||||
import org.mockito.kotlin.verify
|
||||
|
||||
@HiltAndroidTest
|
||||
class FiltersRepositoryTestCreate : BaseFiltersRepositoryTest() {
|
||||
private val filterWithTwoKeywords = NewFilter(
|
||||
title = "new filter",
|
||||
contexts = setOf(FilterContext.HOME),
|
||||
expiresIn = 300,
|
||||
action = Action.WARN,
|
||||
keywords = listOf(
|
||||
NewFilterKeyword(keyword = "first", wholeWord = false),
|
||||
NewFilterKeyword(keyword = "second", wholeWord = true),
|
||||
),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `creating v2 filter should send correct requests`() = runTest {
|
||||
mastodonApi.stub {
|
||||
onBlocking { getFilters() } doReturn success(emptyList())
|
||||
onBlocking { createFilter(any(), any(), any(), any()) } doAnswer { call ->
|
||||
success(
|
||||
NetworkFilter(
|
||||
id = "1",
|
||||
title = call.getArgument(0),
|
||||
contexts = call.getArgument(1),
|
||||
action = call.getArgument(2),
|
||||
expiresAt = Date(System.currentTimeMillis() + (call.getArgument<String>(3).toInt() * 1000)),
|
||||
keywords = emptyList(),
|
||||
),
|
||||
)
|
||||
}
|
||||
onBlocking { addFilterKeyword(any(), any(), any()) } doAnswer { call ->
|
||||
success(
|
||||
FilterKeyword(
|
||||
id = "1",
|
||||
keyword = call.getArgument(1),
|
||||
wholeWord = call.getArgument(2),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
filtersRepository.filters.test {
|
||||
advanceUntilIdle()
|
||||
|
||||
filtersRepository.createFilter(filterWithTwoKeywords)
|
||||
advanceUntilIdle()
|
||||
|
||||
// createFilter should have been called once, with the correct arguments.
|
||||
verify(mastodonApi, times(1)).createFilter(
|
||||
title = filterWithTwoKeywords.title,
|
||||
contexts = filterWithTwoKeywords.contexts,
|
||||
filterAction = filterWithTwoKeywords.action,
|
||||
expiresInSeconds = filterWithTwoKeywords.expiresIn.toString(),
|
||||
)
|
||||
|
||||
// To create the keywords addFilterKeyword should have been called twice.
|
||||
verify(mastodonApi, times(1)).addFilterKeyword("1", "first", false)
|
||||
verify(mastodonApi, times(1)).addFilterKeyword("1", "second", true)
|
||||
|
||||
// Filters should have been refreshed
|
||||
verify(mastodonApi, times(2)).getFilters()
|
||||
|
||||
cancelAndConsumeRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
// Test that "expiresIn = 0" in newFilter is converted to "".
|
||||
@Test
|
||||
fun `expiresIn of 0 is converted to empty string`() = runTest {
|
||||
mastodonApi.stub {
|
||||
onBlocking { getFilters() } doReturn success(emptyList())
|
||||
onBlocking { createFilter(any(), any(), any(), any()) } doAnswer { call ->
|
||||
success(
|
||||
NetworkFilter(
|
||||
id = "1",
|
||||
title = call.getArgument(0),
|
||||
contexts = call.getArgument(1),
|
||||
action = call.getArgument(2),
|
||||
expiresAt = null,
|
||||
keywords = emptyList(),
|
||||
),
|
||||
)
|
||||
}
|
||||
onBlocking { addFilterKeyword(any(), any(), any()) } doAnswer { call ->
|
||||
success(
|
||||
FilterKeyword(
|
||||
id = "1",
|
||||
keyword = call.getArgument(1),
|
||||
wholeWord = call.getArgument(2),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// The v2 filter creation test covers most things, this just verifies that
|
||||
// createFilter converts a "0" expiresIn to the empty string.
|
||||
filtersRepository.filters.test {
|
||||
advanceUntilIdle()
|
||||
verify(mastodonApi, times(1)).getFilters()
|
||||
|
||||
val filterWithZeroExpiry = filterWithTwoKeywords.copy(expiresIn = 0)
|
||||
filtersRepository.createFilter(filterWithZeroExpiry)
|
||||
advanceUntilIdle()
|
||||
|
||||
verify(mastodonApi, times(1)).createFilter(
|
||||
title = filterWithZeroExpiry.title,
|
||||
contexts = filterWithZeroExpiry.contexts,
|
||||
filterAction = filterWithZeroExpiry.action,
|
||||
expiresInSeconds = "",
|
||||
)
|
||||
|
||||
cancelAndConsumeRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `creating v1 filter should create one filter per keyword`() = runTest {
|
||||
mastodonApi.stub {
|
||||
onBlocking { getFiltersV1() } doReturn success(emptyList())
|
||||
onBlocking { createFilterV1(any(), any(), any(), any(), any()) } doAnswer { call ->
|
||||
success(
|
||||
NetworkFilterV1(
|
||||
id = "1",
|
||||
phrase = call.getArgument(0),
|
||||
contexts = call.getArgument(1),
|
||||
irreversible = call.getArgument(2),
|
||||
wholeWord = call.getArgument(3),
|
||||
expiresAt = Date(System.currentTimeMillis() + (call.getArgument<String>(4).toInt() * 1000)),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
serverFlow.update { Ok(SERVER_V1) }
|
||||
|
||||
filtersRepository.filters.test {
|
||||
advanceUntilIdle()
|
||||
verify(mastodonApi, times(1)).getFiltersV1()
|
||||
|
||||
filtersRepository.createFilter(filterWithTwoKeywords)
|
||||
advanceUntilIdle()
|
||||
|
||||
// createFilterV1 should have been called twice, once for each keyword
|
||||
filterWithTwoKeywords.keywords.forEach { keyword ->
|
||||
verify(mastodonApi, times(1)).createFilterV1(
|
||||
phrase = keyword.keyword,
|
||||
context = filterWithTwoKeywords.contexts,
|
||||
irreversible = false,
|
||||
wholeWord = keyword.wholeWord,
|
||||
expiresInSeconds = filterWithTwoKeywords.expiresIn.toString(),
|
||||
)
|
||||
}
|
||||
|
||||
// Filters should have been refreshed
|
||||
verify(mastodonApi, times(2)).getFiltersV1()
|
||||
|
||||
cancelAndConsumeRemainingEvents()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,79 @@
|
|||
/*
|
||||
* Copyright 2024 Pachli Association
|
||||
*
|
||||
* This file is a part of Pachli.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* Pachli 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 Pachli; if not,
|
||||
* see <http://www.gnu.org/licenses>.
|
||||
*/
|
||||
|
||||
package app.pachli.core.data.repository.filtersRepository
|
||||
|
||||
import app.cash.turbine.test
|
||||
import app.pachli.core.testing.success
|
||||
import com.github.michaelbull.result.Ok
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
import org.mockito.kotlin.any
|
||||
import org.mockito.kotlin.doReturn
|
||||
import org.mockito.kotlin.stub
|
||||
import org.mockito.kotlin.times
|
||||
import org.mockito.kotlin.verify
|
||||
|
||||
@HiltAndroidTest
|
||||
class FiltersRepositoryTestDelete : BaseFiltersRepositoryTest() {
|
||||
@Test
|
||||
fun `delete on v2 server should call delete and refresh`() = runTest {
|
||||
mastodonApi.stub {
|
||||
onBlocking { getFilters() } doReturn success(emptyList())
|
||||
onBlocking { deleteFilter(any()) } doReturn success(Unit)
|
||||
}
|
||||
|
||||
filtersRepository.filters.test {
|
||||
advanceUntilIdle()
|
||||
verify(mastodonApi).getFilters()
|
||||
|
||||
filtersRepository.deleteFilter("1")
|
||||
advanceUntilIdle()
|
||||
|
||||
verify(mastodonApi, times(1)).deleteFilter("1")
|
||||
verify(mastodonApi, times(2)).getFilters()
|
||||
|
||||
cancelAndConsumeRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `delete on v1 server should call delete and refresh`() = runTest {
|
||||
mastodonApi.stub {
|
||||
onBlocking { getFiltersV1() } doReturn success(emptyList())
|
||||
onBlocking { deleteFilterV1(any()) } doReturn success(Unit)
|
||||
}
|
||||
|
||||
serverFlow.update { Ok(SERVER_V1) }
|
||||
|
||||
filtersRepository.filters.test {
|
||||
advanceUntilIdle()
|
||||
verify(mastodonApi).getFiltersV1()
|
||||
|
||||
filtersRepository.deleteFilter("1")
|
||||
advanceUntilIdle()
|
||||
|
||||
verify(mastodonApi, times(1)).deleteFilterV1("1")
|
||||
verify(mastodonApi, times(2)).getFiltersV1()
|
||||
|
||||
cancelAndConsumeRemainingEvents()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,201 @@
|
|||
/*
|
||||
* Copyright 2024 Pachli Association
|
||||
*
|
||||
* This file is a part of Pachli.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* Pachli 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 Pachli; if not,
|
||||
* see <http://www.gnu.org/licenses>.
|
||||
*/
|
||||
|
||||
package app.pachli.core.data.repository.filtersRepository
|
||||
|
||||
import app.cash.turbine.test
|
||||
import app.pachli.core.data.model.Filter
|
||||
import app.pachli.core.data.repository.FilterVersion.V1
|
||||
import app.pachli.core.data.repository.FilterVersion.V2
|
||||
import app.pachli.core.data.repository.Filters
|
||||
import app.pachli.core.data.repository.FiltersError
|
||||
import app.pachli.core.network.model.Filter as NetworkFilter
|
||||
import app.pachli.core.network.model.Filter.Action
|
||||
import app.pachli.core.network.model.FilterContext
|
||||
import app.pachli.core.network.model.FilterKeyword
|
||||
import app.pachli.core.network.model.FilterV1 as NetworkFilterV1
|
||||
import app.pachli.core.network.retrofit.apiresult.ClientError
|
||||
import app.pachli.core.testing.failure
|
||||
import app.pachli.core.testing.success
|
||||
import com.github.michaelbull.result.Ok
|
||||
import com.github.michaelbull.result.get
|
||||
import com.github.michaelbull.result.getError
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import java.util.Date
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
import org.mockito.kotlin.doReturn
|
||||
import org.mockito.kotlin.stub
|
||||
|
||||
@HiltAndroidTest
|
||||
class FiltersRepositoryTestFlow : BaseFiltersRepositoryTest() {
|
||||
@Test
|
||||
fun `filters flow returns empty list when there are no v2 filters`() = runTest {
|
||||
mastodonApi.stub {
|
||||
onBlocking { getFiltersV1() } doReturn failure(body = "v1 should not be called")
|
||||
onBlocking { getFilters() } doReturn success(emptyList())
|
||||
}
|
||||
|
||||
filtersRepository.filters.test {
|
||||
advanceUntilIdle()
|
||||
val item = expectMostRecentItem()
|
||||
val filters = item.get()
|
||||
assertThat(filters).isEqualTo(Filters(version = V2, filters = emptyList()))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `filters flow contains initial set of v2 filters`() = runTest {
|
||||
val expiresAt = Date()
|
||||
|
||||
mastodonApi.stub {
|
||||
onBlocking { getFiltersV1() } doReturn failure(body = "v1 should not be called")
|
||||
onBlocking { getFilters() } doReturn success(
|
||||
listOf(
|
||||
NetworkFilter(
|
||||
id = "1",
|
||||
title = "test filter",
|
||||
contexts = setOf(FilterContext.HOME),
|
||||
action = Action.WARN,
|
||||
expiresAt = expiresAt,
|
||||
keywords = listOf(FilterKeyword(id = "1", keyword = "foo", wholeWord = true)),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
filtersRepository.filters.test {
|
||||
advanceUntilIdle()
|
||||
val item = expectMostRecentItem()
|
||||
val filters = item.get()
|
||||
assertThat(filters).isEqualTo(
|
||||
Filters(
|
||||
version = V2,
|
||||
filters = listOf(
|
||||
Filter(
|
||||
id = "1",
|
||||
title = "test filter",
|
||||
contexts = setOf(FilterContext.HOME),
|
||||
action = Action.WARN,
|
||||
expiresAt = expiresAt,
|
||||
keywords = listOf(
|
||||
FilterKeyword(id = "1", keyword = "foo", wholeWord = true),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `filters flow returns empty list when there are no v1 filters`() = runTest {
|
||||
mastodonApi.stub {
|
||||
onBlocking { getFilters() } doReturn failure(body = "v2 should not be called")
|
||||
onBlocking { getFiltersV1() } doReturn success(emptyList())
|
||||
}
|
||||
serverFlow.update { Ok(SERVER_V1) }
|
||||
|
||||
filtersRepository.filters.test {
|
||||
advanceUntilIdle()
|
||||
val item = expectMostRecentItem()
|
||||
val filters = item.get()
|
||||
assertThat(filters).isEqualTo(Filters(version = V1, filters = emptyList()))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `filters flow contains initial set of v1 filters`() = runTest {
|
||||
val expiresAt = Date()
|
||||
|
||||
mastodonApi.stub {
|
||||
onBlocking { getFilters() } doReturn failure(body = "v2 should not be called")
|
||||
onBlocking { getFiltersV1() } doReturn success(
|
||||
listOf(
|
||||
NetworkFilterV1(
|
||||
id = "1",
|
||||
phrase = "some_phrase",
|
||||
contexts = setOf(FilterContext.HOME),
|
||||
expiresAt = expiresAt,
|
||||
irreversible = true,
|
||||
wholeWord = true,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
serverFlow.update { Ok(SERVER_V1) }
|
||||
|
||||
filtersRepository.filters.test {
|
||||
advanceUntilIdle()
|
||||
val item = expectMostRecentItem()
|
||||
val filters = item.get()
|
||||
assertThat(filters).isEqualTo(
|
||||
Filters(
|
||||
version = V1,
|
||||
filters = listOf(
|
||||
Filter(
|
||||
id = "1",
|
||||
title = "some_phrase",
|
||||
contexts = setOf(FilterContext.HOME),
|
||||
action = Action.WARN,
|
||||
expiresAt = expiresAt,
|
||||
keywords = listOf(
|
||||
FilterKeyword(id = "1", keyword = "some_phrase", wholeWord = true),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `HTTP 404 for v2 filters returns correct error type`() = runTest {
|
||||
mastodonApi.stub {
|
||||
onBlocking { getFilters() } doReturn failure(body = "{\"error\": \"error message\"}")
|
||||
}
|
||||
|
||||
filtersRepository.filters.test {
|
||||
advanceUntilIdle()
|
||||
val item = expectMostRecentItem()
|
||||
val error = item.getError() as? FiltersError.GetFiltersError
|
||||
assertThat(error?.error).isInstanceOf(ClientError.NotFound::class.java)
|
||||
assertThat(error?.error?.formatArgs).isEqualTo(arrayOf("error message"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `HTTP 404 for v1 filters returns correct error type`() = runTest {
|
||||
mastodonApi.stub {
|
||||
onBlocking { getFiltersV1() } doReturn failure(body = "{\"error\": \"error message\"}")
|
||||
}
|
||||
|
||||
serverFlow.update { Ok(SERVER_V1) }
|
||||
|
||||
filtersRepository.filters.test {
|
||||
advanceUntilIdle()
|
||||
val item = expectMostRecentItem()
|
||||
val error = item.getError() as? FiltersError.GetFiltersError
|
||||
assertThat(error?.error).isInstanceOf(ClientError.NotFound::class.java)
|
||||
assertThat(error?.error?.formatArgs).isEqualTo(arrayOf("error message"))
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,79 @@
|
|||
/*
|
||||
* Copyright 2024 Pachli Association
|
||||
*
|
||||
* This file is a part of Pachli.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* Pachli 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 Pachli; if not,
|
||||
* see <http://www.gnu.org/licenses>.
|
||||
*/
|
||||
|
||||
package app.pachli.core.data.repository.filtersRepository
|
||||
|
||||
import app.cash.turbine.test
|
||||
import app.pachli.core.testing.failure
|
||||
import app.pachli.core.testing.success
|
||||
import com.github.michaelbull.result.Ok
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
import org.mockito.kotlin.doReturn
|
||||
import org.mockito.kotlin.never
|
||||
import org.mockito.kotlin.stub
|
||||
import org.mockito.kotlin.times
|
||||
import org.mockito.kotlin.verify
|
||||
|
||||
@HiltAndroidTest
|
||||
class FiltersRepositoryTestReload : BaseFiltersRepositoryTest() {
|
||||
@Test
|
||||
fun `reload should trigger a network request`() = runTest {
|
||||
mastodonApi.stub {
|
||||
onBlocking { getFiltersV1() } doReturn failure(body = "v1 should not be called")
|
||||
onBlocking { getFilters() } doReturn success(emptyList())
|
||||
}
|
||||
|
||||
filtersRepository.filters.test {
|
||||
advanceUntilIdle()
|
||||
verify(mastodonApi).getFilters()
|
||||
|
||||
filtersRepository.reload()
|
||||
advanceUntilIdle()
|
||||
|
||||
verify(mastodonApi, times(2)).getFilters()
|
||||
verify(mastodonApi, never()).getFiltersV1()
|
||||
|
||||
cancelAndConsumeRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `changing server should trigger a network request`() = runTest {
|
||||
mastodonApi.stub {
|
||||
onBlocking { getFiltersV1() } doReturn success(emptyList())
|
||||
onBlocking { getFilters() } doReturn success(emptyList())
|
||||
}
|
||||
|
||||
filtersRepository.filters.test {
|
||||
advanceUntilIdle()
|
||||
verify(mastodonApi, times(1)).getFilters()
|
||||
verify(mastodonApi, never()).getFiltersV1()
|
||||
|
||||
serverFlow.update { Ok(SERVER_V1) }
|
||||
advanceUntilIdle()
|
||||
|
||||
verify(mastodonApi, times(1)).getFilters()
|
||||
verify(mastodonApi, times(1)).getFiltersV1()
|
||||
|
||||
cancelAndConsumeRemainingEvents()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,167 @@
|
|||
/*
|
||||
* Copyright 2024 Pachli Association
|
||||
*
|
||||
* This file is a part of Pachli.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* Pachli 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 Pachli; if not,
|
||||
* see <http://www.gnu.org/licenses>.
|
||||
*/
|
||||
|
||||
package app.pachli.core.data.repository.filtersRepository
|
||||
|
||||
import app.cash.turbine.test
|
||||
import app.pachli.core.data.model.Filter
|
||||
import app.pachli.core.data.repository.FilterEdit
|
||||
import app.pachli.core.network.model.Filter as NetworkFilter
|
||||
import app.pachli.core.network.model.Filter.Action
|
||||
import app.pachli.core.network.model.FilterContext
|
||||
import app.pachli.core.network.model.FilterKeyword
|
||||
import app.pachli.core.testing.success
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import java.util.Date
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
import org.mockito.kotlin.any
|
||||
import org.mockito.kotlin.anyOrNull
|
||||
import org.mockito.kotlin.doAnswer
|
||||
import org.mockito.kotlin.doReturn
|
||||
import org.mockito.kotlin.never
|
||||
import org.mockito.kotlin.stub
|
||||
import org.mockito.kotlin.times
|
||||
import org.mockito.kotlin.verify
|
||||
|
||||
/**
|
||||
* Test that ensures the correct API calls are made given an [FilterEdit]. The correct
|
||||
* creation of the [FilterEdit] is tested in FilterViewDataTest.kt
|
||||
*/
|
||||
@HiltAndroidTest
|
||||
class FiltersRepositoryTestUpdate : BaseFiltersRepositoryTest() {
|
||||
private val originalNetworkFilter = NetworkFilter(
|
||||
id = "1",
|
||||
title = "original filter",
|
||||
contexts = setOf(FilterContext.HOME),
|
||||
expiresAt = null,
|
||||
action = Action.WARN,
|
||||
keywords = listOf(
|
||||
FilterKeyword(id = "1", keyword = "first", wholeWord = false),
|
||||
FilterKeyword(id = "2", keyword = "second", wholeWord = true),
|
||||
FilterKeyword(id = "3", keyword = "three", wholeWord = true),
|
||||
FilterKeyword(id = "4", keyword = "four", wholeWord = true),
|
||||
),
|
||||
)
|
||||
|
||||
private val originalFilter = Filter.from(originalNetworkFilter)
|
||||
|
||||
@Test
|
||||
fun `v2 update with no keyword changes should only call updateFilter once`() = runTest {
|
||||
mastodonApi.stub {
|
||||
onBlocking { getFilters() } doReturn success(emptyList())
|
||||
onBlocking { updateFilter(any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) } doAnswer { call ->
|
||||
success(
|
||||
originalNetworkFilter.copy(
|
||||
title = call.getArgument(1) ?: originalFilter.title,
|
||||
contexts = call.getArgument(2) ?: originalFilter.contexts,
|
||||
action = call.getArgument(3) ?: originalFilter.action,
|
||||
expiresAt = call.getArgument<String?>(4)?.let {
|
||||
when (it) {
|
||||
"" -> null
|
||||
else -> Date(System.currentTimeMillis() + (it.toInt() * 1000))
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
onBlocking { getFilter(originalNetworkFilter.id) } doReturn success(originalNetworkFilter)
|
||||
}
|
||||
|
||||
val update = FilterEdit(id = originalFilter.id, title = "new title")
|
||||
|
||||
filtersRepository.filters.test {
|
||||
advanceUntilIdle()
|
||||
verify(mastodonApi, times(1)).getFilters()
|
||||
|
||||
filtersRepository.updateFilter(originalFilter, update)
|
||||
advanceUntilIdle()
|
||||
|
||||
verify(mastodonApi, times(1)).updateFilter(
|
||||
id = update.id,
|
||||
title = update.title,
|
||||
contexts = update.contexts,
|
||||
filterAction = update.action,
|
||||
expiresInSeconds = null,
|
||||
)
|
||||
|
||||
verify(mastodonApi, times(1)).getFilter(originalFilter.id)
|
||||
verify(mastodonApi, times(2)).getFilters()
|
||||
verify(mastodonApi, never()).getFiltersV1()
|
||||
|
||||
cancelAndConsumeRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `v2 update with keyword changes should call updateFilter and the keyword methods`() = runTest {
|
||||
mastodonApi.stub {
|
||||
onBlocking { getFilters() } doReturn success(emptyList())
|
||||
onBlocking { deleteFilterKeyword(any()) } doReturn success(Unit)
|
||||
onBlocking { updateFilterKeyword(any(), any(), any()) } doAnswer { call ->
|
||||
success(FilterKeyword(call.getArgument(0), call.getArgument(1), call.getArgument(2)))
|
||||
}
|
||||
onBlocking { addFilterKeyword(any(), any(), any()) } doAnswer { call ->
|
||||
success(FilterKeyword("x", call.getArgument(1), call.getArgument(2)))
|
||||
}
|
||||
onBlocking { getFilter(any()) } doReturn success(originalNetworkFilter)
|
||||
}
|
||||
|
||||
val keywordToAdd = FilterKeyword(id = "", keyword = "new keyword", wholeWord = false)
|
||||
val keywordToDelete = originalFilter.keywords[1]
|
||||
val keywordToModify = originalFilter.keywords[0].copy(keyword = "new keyword")
|
||||
|
||||
val update = FilterEdit(
|
||||
id = originalFilter.id,
|
||||
keywordsToAdd = listOf(keywordToAdd),
|
||||
keywordsToDelete = listOf(keywordToDelete),
|
||||
keywordsToModify = listOf(keywordToModify),
|
||||
)
|
||||
|
||||
filtersRepository.filters.test {
|
||||
advanceUntilIdle()
|
||||
verify(mastodonApi, times(1)).getFilters()
|
||||
|
||||
filtersRepository.updateFilter(originalFilter, update)
|
||||
advanceUntilIdle()
|
||||
|
||||
// updateFilter() call should be skipped, as only the keywords have changed.
|
||||
verify(mastodonApi, never()).updateFilter(any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull())
|
||||
|
||||
verify(mastodonApi, times(1)).addFilterKeyword(
|
||||
originalFilter.id,
|
||||
keywordToAdd.keyword,
|
||||
keywordToAdd.wholeWord,
|
||||
)
|
||||
|
||||
verify(mastodonApi, times(1)).deleteFilterKeyword(keywordToDelete.id)
|
||||
|
||||
verify(mastodonApi, times(1)).updateFilterKeyword(
|
||||
keywordToModify.id,
|
||||
keywordToModify.keyword,
|
||||
keywordToModify.wholeWord,
|
||||
)
|
||||
|
||||
verify(mastodonApi, times(1)).getFilter(originalFilter.id)
|
||||
verify(mastodonApi, times(2)).getFilters()
|
||||
verify(mastodonApi, never()).getFiltersV1()
|
||||
|
||||
cancelAndConsumeRemainingEvents()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -30,6 +30,7 @@ android {
|
|||
}
|
||||
|
||||
dependencies {
|
||||
implementation(projects.core.data)
|
||||
implementation(projects.core.database) // For DraftAttachment, used in ComposeOptions
|
||||
implementation(projects.core.model)
|
||||
implementation(projects.core.network) // For Attachment, used in AttachmentViewData
|
||||
|
|
|
@ -21,6 +21,7 @@ import android.content.Context
|
|||
import android.content.Intent
|
||||
import android.os.Parcelable
|
||||
import androidx.core.content.IntentCompat
|
||||
import app.pachli.core.data.model.Filter
|
||||
import app.pachli.core.database.model.DraftAttachment
|
||||
import app.pachli.core.model.Timeline
|
||||
import app.pachli.core.navigation.LoginActivityIntent.LoginMode
|
||||
|
@ -32,7 +33,6 @@ import app.pachli.core.navigation.TimelineActivityIntent.Companion.list
|
|||
import app.pachli.core.navigation.TimelineActivityIntent.Companion.publicFederated
|
||||
import app.pachli.core.navigation.TimelineActivityIntent.Companion.publicLocal
|
||||
import app.pachli.core.network.model.Attachment
|
||||
import app.pachli.core.network.model.Filter
|
||||
import app.pachli.core.network.model.NewPoll
|
||||
import app.pachli.core.network.model.Notification
|
||||
import app.pachli.core.network.model.Status
|
||||
|
@ -190,23 +190,47 @@ class ComposeActivityIntent(context: Context) : Intent() {
|
|||
}
|
||||
|
||||
/**
|
||||
* Launch with an empty filter to edit.
|
||||
*
|
||||
* @param context
|
||||
* @param filter Optional filter to edit. If null an empty filter is created.
|
||||
* @see [app.pachli.components.filters.EditFilterActivity]
|
||||
*/
|
||||
class EditFilterActivityIntent(context: Context, filter: Filter? = null) : Intent() {
|
||||
class EditFilterActivityIntent(context: Context) : Intent() {
|
||||
init {
|
||||
setClassName(context, QuadrantConstants.EDIT_FILTER_ACTIVITY)
|
||||
filter?.let {
|
||||
putExtra(EXTRA_FILTER_TO_EDIT, it)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val EXTRA_FILTER_TO_EDIT = "filterToEdit"
|
||||
private const val EXTRA_FILTER_TO_EDIT = "filterToEdit"
|
||||
private const val EXTRA_FILTER_ID_TO_LOAD = "filterIdToLoad"
|
||||
|
||||
/**
|
||||
* Launch with [filter] displayed, ready to edit.
|
||||
*
|
||||
* @param context
|
||||
* @param filter Filter to edit
|
||||
* @see [app.pachli.components.filters.EditFilterActivity]
|
||||
*/
|
||||
fun edit(context: Context, filter: Filter) = EditFilterActivityIntent(context).apply {
|
||||
putExtra(EXTRA_FILTER_TO_EDIT, filter)
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch and load [filterId], display it ready to edit.
|
||||
*
|
||||
* @param context
|
||||
* @param filterId ID of the filter to load
|
||||
* @see [app.pachli.components.filters.EditFilterActivity]
|
||||
*/
|
||||
fun edit(context: Context, filterId: String) = EditFilterActivityIntent(context).apply {
|
||||
putExtra(EXTRA_FILTER_ID_TO_LOAD, filterId)
|
||||
}
|
||||
|
||||
/** @return the [Filter] passed in this intent, or null */
|
||||
fun getFilter(intent: Intent) = IntentCompat.getParcelableExtra(intent, EXTRA_FILTER_TO_EDIT, Filter::class.java)
|
||||
|
||||
/** @return the filter ID passed in this intent, or null */
|
||||
fun getFilterId(intent: Intent) = intent.getStringExtra(EXTRA_FILTER_ID_TO_LOAD)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -13,7 +13,7 @@ import kotlinx.parcelize.Parcelize
|
|||
data class Filter(
|
||||
val id: String = "",
|
||||
val title: String = "",
|
||||
@Json(name = "context") val contexts: List<FilterContext> = emptyList(),
|
||||
@Json(name = "context") val contexts: Set<FilterContext> = emptySet(),
|
||||
@Json(name = "expires_at") val expiresAt: Date? = null,
|
||||
@Json(name = "filter_action") val action: Action = Action.WARN,
|
||||
// This should not normally be empty. However, Mastodon does not include
|
||||
|
@ -29,8 +29,8 @@ data class Filter(
|
|||
@Json(name = "none")
|
||||
NONE,
|
||||
|
||||
@Json(name = "warn")
|
||||
@Default
|
||||
@Json(name = "warn")
|
||||
WARN,
|
||||
|
||||
@Json(name = "hide")
|
||||
|
|
|
@ -24,7 +24,7 @@ import java.util.Date
|
|||
data class FilterV1(
|
||||
val id: String,
|
||||
val phrase: String,
|
||||
@Json(name = "context") val contexts: List<FilterContext>,
|
||||
@Json(name = "context") val contexts: Set<FilterContext>,
|
||||
@Json(name = "expires_at") val expiresAt: Date?,
|
||||
val irreversible: Boolean,
|
||||
@Json(name = "whole_word") val wholeWord: Boolean,
|
||||
|
@ -40,21 +40,12 @@ data class FilterV1(
|
|||
val filter = other as FilterV1?
|
||||
return filter?.id.equals(id)
|
||||
}
|
||||
}
|
||||
|
||||
fun toFilter(): Filter {
|
||||
return Filter(
|
||||
id = id,
|
||||
title = phrase,
|
||||
contexts = contexts,
|
||||
expiresAt = expiresAt,
|
||||
action = Filter.Action.WARN,
|
||||
keywords = listOf(
|
||||
FilterKeyword(
|
||||
id = id,
|
||||
keyword = phrase,
|
||||
wholeWord = wholeWord,
|
||||
),
|
||||
),
|
||||
data class NewFilterV1(
|
||||
val phrase: String,
|
||||
val contexts: Set<FilterContext>,
|
||||
val expiresIn: Int,
|
||||
val irreversible: Boolean,
|
||||
val wholeWord: Boolean,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -97,10 +97,20 @@ interface MastodonApi {
|
|||
suspend fun getInstanceV2(@Header(DOMAIN_HEADER) domain: String? = null): NetworkResult<InstanceV2>
|
||||
|
||||
@GET("api/v1/filters")
|
||||
suspend fun getFiltersV1(): NetworkResult<List<FilterV1>>
|
||||
suspend fun getFiltersV1(): ApiResult<List<FilterV1>>
|
||||
|
||||
@GET("api/v2/filters")
|
||||
suspend fun getFilters(): NetworkResult<List<Filter>>
|
||||
suspend fun getFilters(): ApiResult<List<Filter>>
|
||||
|
||||
@GET("api/v2/filters/{id}")
|
||||
suspend fun getFilter(
|
||||
@Path("id") filterId: String,
|
||||
): ApiResult<Filter>
|
||||
|
||||
@GET("api/v1/filters/{id}")
|
||||
suspend fun getFilterV1(
|
||||
@Path("id") filterId: String,
|
||||
): ApiResult<FilterV1>
|
||||
|
||||
@GET("api/v1/timelines/home")
|
||||
@Throws(Exception::class)
|
||||
|
@ -612,59 +622,59 @@ interface MastodonApi {
|
|||
@POST("api/v1/filters")
|
||||
suspend fun createFilterV1(
|
||||
@Field("phrase") phrase: String,
|
||||
@Field("context[]") context: List<FilterContext>,
|
||||
@Field("context[]") context: Set<FilterContext>,
|
||||
@Field("irreversible") irreversible: Boolean?,
|
||||
@Field("whole_word") wholeWord: Boolean?,
|
||||
// String not Int because the empty string is used to represent "indefinite",
|
||||
// see https://github.com/mastodon/documentation/issues/1216#issuecomment-2030222940
|
||||
@Field("expires_in") expiresInSeconds: String?,
|
||||
): NetworkResult<FilterV1>
|
||||
): ApiResult<FilterV1>
|
||||
|
||||
@FormUrlEncoded
|
||||
@PUT("api/v1/filters/{id}")
|
||||
suspend fun updateFilterV1(
|
||||
@Path("id") id: String,
|
||||
@Field("phrase") phrase: String,
|
||||
@Field("context[]") context: List<FilterContext>,
|
||||
@Field("context[]") contexts: Collection<FilterContext>,
|
||||
@Field("irreversible") irreversible: Boolean?,
|
||||
@Field("whole_word") wholeWord: Boolean?,
|
||||
// String not Int because the empty string is used to represent "indefinite",
|
||||
// see https://github.com/mastodon/documentation/issues/1216#issuecomment-2030222940
|
||||
@Field("expires_in") expiresInSeconds: String?,
|
||||
): NetworkResult<FilterV1>
|
||||
): ApiResult<FilterV1>
|
||||
|
||||
@DELETE("api/v1/filters/{id}")
|
||||
suspend fun deleteFilterV1(
|
||||
@Path("id") id: String,
|
||||
): NetworkResult<ResponseBody>
|
||||
): ApiResult<Unit>
|
||||
|
||||
@FormUrlEncoded
|
||||
@POST("api/v2/filters")
|
||||
suspend fun createFilter(
|
||||
@Field("title") title: String,
|
||||
@Field("context[]") context: List<FilterContext>,
|
||||
@Field("context[]") contexts: Set<FilterContext>,
|
||||
@Field("filter_action") filterAction: Filter.Action,
|
||||
// String not Int because the empty string is used to represent "indefinite",
|
||||
// see https://github.com/mastodon/documentation/issues/1216#issuecomment-2030222940
|
||||
@Field("expires_in") expiresInSeconds: String?,
|
||||
): NetworkResult<Filter>
|
||||
): ApiResult<Filter>
|
||||
|
||||
@FormUrlEncoded
|
||||
@PUT("api/v2/filters/{id}")
|
||||
suspend fun updateFilter(
|
||||
@Path("id") id: String,
|
||||
@Field("title") title: String? = null,
|
||||
@Field("context[]") context: List<FilterContext>? = null,
|
||||
@Field("context[]") contexts: Collection<FilterContext>? = null,
|
||||
@Field("filter_action") filterAction: Filter.Action? = null,
|
||||
// String not Int because the empty string is used to represent "indefinite",
|
||||
// see https://github.com/mastodon/documentation/issues/1216#issuecomment-2030222940
|
||||
@Field("expires_in") expiresInSeconds: String? = null,
|
||||
): NetworkResult<Filter>
|
||||
): ApiResult<Filter>
|
||||
|
||||
@DELETE("api/v2/filters/{id}")
|
||||
suspend fun deleteFilter(
|
||||
@Path("id") id: String,
|
||||
): NetworkResult<ResponseBody>
|
||||
): ApiResult<Unit>
|
||||
|
||||
@FormUrlEncoded
|
||||
@POST("api/v2/filters/{filterId}/keywords")
|
||||
|
@ -672,7 +682,7 @@ interface MastodonApi {
|
|||
@Path("filterId") filterId: String,
|
||||
@Field("keyword") keyword: String,
|
||||
@Field("whole_word") wholeWord: Boolean,
|
||||
): NetworkResult<FilterKeyword>
|
||||
): ApiResult<FilterKeyword>
|
||||
|
||||
@FormUrlEncoded
|
||||
@PUT("api/v2/filters/keywords/{keywordId}")
|
||||
|
@ -680,12 +690,12 @@ interface MastodonApi {
|
|||
@Path("keywordId") keywordId: String,
|
||||
@Field("keyword") keyword: String,
|
||||
@Field("whole_word") wholeWord: Boolean,
|
||||
): NetworkResult<FilterKeyword>
|
||||
): ApiResult<FilterKeyword>
|
||||
|
||||
@DELETE("api/v2/filters/keywords/{keywordId}")
|
||||
suspend fun deleteFilterKeyword(
|
||||
@Path("keywordId") keywordId: String,
|
||||
): NetworkResult<ResponseBody>
|
||||
): ApiResult<Unit>
|
||||
|
||||
@FormUrlEncoded
|
||||
@POST("api/v1/polls/{id}/votes")
|
||||
|
|
|
@ -17,13 +17,12 @@
|
|||
|
||||
package app.pachli.core.network.retrofit.apiresult
|
||||
|
||||
import app.pachli.core.testing.jsonError
|
||||
import com.github.michaelbull.result.Err
|
||||
import com.github.michaelbull.result.unwrapError
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import java.io.IOException
|
||||
import okhttp3.Headers
|
||||
import okhttp3.Protocol
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
import org.junit.Assert.assertThrows
|
||||
import org.junit.Test
|
||||
import retrofit2.Call
|
||||
|
@ -124,9 +123,9 @@ class ApiResultCallTest {
|
|||
networkApiResultCall.enqueue(
|
||||
object : Callback<ApiResult<String>> {
|
||||
override fun onResponse(call: Call<ApiResult<String>>, response: Response<ApiResult<String>>) {
|
||||
val error = response.body()?.unwrapError() as? WrongContentType
|
||||
val error = response.body()?.unwrapError()
|
||||
assertThat(error).isInstanceOf(WrongContentType::class.java)
|
||||
assertThat(error?.contentType).isEqualTo("text/html")
|
||||
assertThat((error as WrongContentType).contentType).isEqualTo("text/html")
|
||||
assertThat(response.isSuccessful).isTrue()
|
||||
}
|
||||
|
||||
|
@ -142,29 +141,18 @@ class ApiResultCallTest {
|
|||
// properties then the error message should fall back to the HTTP error message.
|
||||
@Test
|
||||
fun `should parse call with 404 error code as ApiResult-failure (no JSON)`() {
|
||||
val errorMsg = "dummy error message"
|
||||
val errorResponse = Response.error<String>(
|
||||
"".toResponseBody(),
|
||||
okhttp3.Response.Builder()
|
||||
.request(okhttp3.Request.Builder().url("http://localhost/").build())
|
||||
.protocol(Protocol.HTTP_1_1)
|
||||
.addHeader("content-type", "application/json")
|
||||
.code(404)
|
||||
.message(errorMsg)
|
||||
.build(),
|
||||
|
||||
)
|
||||
val errorResponse = jsonError(404, "")
|
||||
|
||||
networkApiResultCall.enqueue(
|
||||
object : Callback<ApiResult<String>> {
|
||||
override fun onResponse(call: Call<ApiResult<String>>, response: Response<ApiResult<String>>) {
|
||||
val error = response.body()?.unwrapError() as? ClientError.NotFound
|
||||
val error = response.body()?.unwrapError()
|
||||
assertThat(error).isInstanceOf(ClientError.NotFound::class.java)
|
||||
|
||||
val exception = error?.exception
|
||||
val exception = (error as ClientError.NotFound).exception
|
||||
assertThat(exception).isInstanceOf(HttpException::class.java)
|
||||
assertThat(exception?.code()).isEqualTo(404)
|
||||
assertThat(error?.formatArgs).isEqualTo(arrayOf("HTTP 404 $errorMsg"))
|
||||
assertThat(exception.code()).isEqualTo(404)
|
||||
assertThat(error.formatArgs).isEqualTo(arrayOf("HTTP 404 Not Found"))
|
||||
}
|
||||
|
||||
override fun onFailure(call: Call<ApiResult<String>>, t: Throwable) {
|
||||
|
@ -181,28 +169,18 @@ class ApiResultCallTest {
|
|||
@Test
|
||||
fun `should parse call with 404 error code as ApiResult-failure (JSON error message)`() {
|
||||
val errorMsg = "JSON error message"
|
||||
val errorResponse = Response.error<String>(
|
||||
"{\"error\": \"$errorMsg\"}".toResponseBody(),
|
||||
okhttp3.Response.Builder()
|
||||
.request(okhttp3.Request.Builder().url("http://localhost/").build())
|
||||
.protocol(Protocol.HTTP_1_1)
|
||||
.addHeader("content-type", "application/json")
|
||||
.code(404)
|
||||
.message("")
|
||||
.build(),
|
||||
|
||||
)
|
||||
val errorResponse = jsonError(404, "{\"error\": \"$errorMsg\"}")
|
||||
|
||||
networkApiResultCall.enqueue(
|
||||
object : Callback<ApiResult<String>> {
|
||||
override fun onResponse(call: Call<ApiResult<String>>, response: Response<ApiResult<String>>) {
|
||||
val error = response.body()?.unwrapError() as? ClientError.NotFound
|
||||
val error = response.body()?.unwrapError()
|
||||
assertThat(error).isInstanceOf(ClientError.NotFound::class.java)
|
||||
|
||||
val exception = error?.exception
|
||||
val exception = (error as ClientError.NotFound).exception
|
||||
assertThat(exception).isInstanceOf(HttpException::class.java)
|
||||
assertThat(exception?.code()).isEqualTo(404)
|
||||
assertThat(error?.formatArgs).isEqualTo(arrayOf(errorMsg))
|
||||
assertThat(exception.code()).isEqualTo(404)
|
||||
assertThat(error.formatArgs).isEqualTo(arrayOf(errorMsg))
|
||||
}
|
||||
|
||||
override fun onFailure(call: Call<ApiResult<String>>, t: Throwable) {
|
||||
|
@ -220,28 +198,18 @@ class ApiResultCallTest {
|
|||
fun `should parse call with 404 error code as ApiResult-failure (JSON error and description message)`() {
|
||||
val errorMsg = "JSON error message"
|
||||
val descriptionMsg = "JSON error description"
|
||||
val errorResponse = Response.error<String>(
|
||||
"{\"error\": \"$errorMsg\", \"description\": \"$descriptionMsg\"}".toResponseBody(),
|
||||
okhttp3.Response.Builder()
|
||||
.request(okhttp3.Request.Builder().url("http://localhost/").build())
|
||||
.protocol(Protocol.HTTP_1_1)
|
||||
.addHeader("content-type", "application/json")
|
||||
.code(404)
|
||||
.message("")
|
||||
.build(),
|
||||
|
||||
)
|
||||
val errorResponse = jsonError(404, "{\"error\": \"$errorMsg\", \"description\": \"$descriptionMsg\"}")
|
||||
|
||||
networkApiResultCall.enqueue(
|
||||
object : Callback<ApiResult<String>> {
|
||||
override fun onResponse(call: Call<ApiResult<String>>, response: Response<ApiResult<String>>) {
|
||||
val error = response.body()?.unwrapError() as? ClientError.NotFound
|
||||
val error = response.body()?.unwrapError()
|
||||
assertThat(error).isInstanceOf(ClientError.NotFound::class.java)
|
||||
|
||||
val exception = error?.exception
|
||||
val exception = (error as ClientError.NotFound).exception
|
||||
assertThat(exception).isInstanceOf(HttpException::class.java)
|
||||
assertThat(exception?.code()).isEqualTo(404)
|
||||
assertThat(error?.formatArgs).isEqualTo(arrayOf("$errorMsg: $descriptionMsg"))
|
||||
assertThat(exception.code()).isEqualTo(404)
|
||||
assertThat(error.formatArgs).isEqualTo(arrayOf("$errorMsg: $descriptionMsg"))
|
||||
}
|
||||
|
||||
override fun onFailure(call: Call<ApiResult<String>>, t: Throwable) {
|
||||
|
|
|
@ -30,6 +30,7 @@ android {
|
|||
|
||||
dependencies {
|
||||
implementation(projects.core.common)
|
||||
implementation(projects.core.network)
|
||||
|
||||
api(libs.kotlinx.coroutines.test)
|
||||
api(libs.androidx.test.junit)
|
||||
|
|
|
@ -0,0 +1,118 @@
|
|||
/*
|
||||
* Copyright 2024 Pachli Association
|
||||
*
|
||||
* This file is a part of Pachli.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* Pachli 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 Pachli; if not,
|
||||
* see <http://www.gnu.org/licenses>.
|
||||
*/
|
||||
|
||||
package app.pachli.core.testing
|
||||
|
||||
import app.pachli.core.network.retrofit.apiresult.ApiError
|
||||
import app.pachli.core.network.retrofit.apiresult.ApiResponse
|
||||
import app.pachli.core.network.retrofit.apiresult.ApiResult
|
||||
import com.github.michaelbull.result.Err
|
||||
import com.github.michaelbull.result.Ok
|
||||
import okhttp3.Headers
|
||||
import okhttp3.Protocol
|
||||
import okhttp3.Request
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
import retrofit2.HttpException
|
||||
import retrofit2.Response
|
||||
|
||||
/**
|
||||
* Returns an [Ok][Ok] [ApiResult<T>][ApiResult] wrapping [data].
|
||||
*
|
||||
* @param data Data to wrap in the result.
|
||||
* @param code HTTP response code.
|
||||
* @param headers Optional additional headers to include in the response. See
|
||||
* [Headers.headersOf].
|
||||
*/
|
||||
fun <T> success(data: T, code: Int = 200, vararg headers: String): ApiResult<T> =
|
||||
Ok(ApiResponse(Headers.headersOf(*headers), data, code))
|
||||
|
||||
/**
|
||||
* Returns an [Err][Err] [ApiResult<T>][ApiResult] representing an HTTP request
|
||||
* failure.
|
||||
*
|
||||
* @param code HTTP failure code.
|
||||
* @param body Data to use as the HTTP response body.
|
||||
* @param message (optional) String to use as the HTTP status message.
|
||||
*/
|
||||
fun <T> failure(code: Int = 404, body: String = "", message: String = code.httpStatusMessage()): ApiResult<T> =
|
||||
Err(ApiError.from(HttpException(jsonError(code, body, message))))
|
||||
|
||||
/**
|
||||
* Equivalent to [Response.error] with the content-type set to `application/json`.
|
||||
*
|
||||
* @param code HTTP failure code.
|
||||
* @param body Data to use as the HTTP response body. Should be JSON (unless you are
|
||||
* testing the ability to handle invalid JSON).
|
||||
* @param message (optional) String to use as the HTTP status message.
|
||||
*/
|
||||
fun jsonError(code: Int, body: String, message: String = code.httpStatusMessage()): Response<String> = Response.error(
|
||||
body.toResponseBody(),
|
||||
okhttp3.Response.Builder()
|
||||
.request(Request.Builder().url("http://localhost/").build())
|
||||
.protocol(Protocol.HTTP_1_1)
|
||||
.addHeader("content-type", "application/json")
|
||||
.code(code)
|
||||
.message(message)
|
||||
.build(),
|
||||
)
|
||||
|
||||
/** Default HTTP status messages for different response codes. */
|
||||
private fun Int.httpStatusMessage() = when (this) {
|
||||
100 -> "Continue"
|
||||
101 -> "Switching Protocols"
|
||||
103 -> "Early Hints"
|
||||
200 -> "OK"
|
||||
201 -> "Created"
|
||||
202 -> "Accepted"
|
||||
203 -> "Non-Authoritative Information"
|
||||
204 -> "No Content"
|
||||
205 -> "Reset Content"
|
||||
206 -> "Partial Content"
|
||||
300 -> "Multiple Choices"
|
||||
301 -> "Moved Permanently"
|
||||
302 -> "Found"
|
||||
303 -> "See Other"
|
||||
304 -> "Not Modified"
|
||||
307 -> "Temporary Redirect"
|
||||
308 -> "Permanent Redirect"
|
||||
400 -> "Bad Request"
|
||||
401 -> "Unauthorized"
|
||||
402 -> "Payment Required"
|
||||
403 -> "Forbidden"
|
||||
404 -> "Not Found"
|
||||
405 -> "Method Not Allowed"
|
||||
406 -> "Not Acceptable"
|
||||
407 -> "Proxy Authentication Required"
|
||||
408 -> "Request Timeout"
|
||||
409 -> "Conflict"
|
||||
410 -> "Gone"
|
||||
411 -> "Length Required"
|
||||
412 -> "Precondition Failed"
|
||||
413 -> "Request Too Large"
|
||||
414 -> "Request-URI Too Long"
|
||||
415 -> "Unsupported Media Type"
|
||||
416 -> "Range Not Satisfiable"
|
||||
417 -> "Expectation Failed"
|
||||
500 -> "Internal Server Error"
|
||||
501 -> "Not Implemented"
|
||||
502 -> "Bad Gateway"
|
||||
503 -> "Service Unavailable"
|
||||
504 -> "Gateway Timeout"
|
||||
505 -> "HTTP Version Not Supported"
|
||||
511 -> "Network Authentication Required"
|
||||
else -> "Unknown"
|
||||
}
|
|
@ -19,6 +19,7 @@ package app.pachli.feature.suggestions
|
|||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import app.pachli.core.common.extensions.mapIfInstance
|
||||
import app.pachli.core.common.extensions.stateFlow
|
||||
import app.pachli.core.data.model.StatusDisplayOptions
|
||||
import app.pachli.core.data.model.Suggestion
|
||||
|
@ -30,15 +31,11 @@ import app.pachli.feature.suggestions.UiAction.GetSuggestions
|
|||
import app.pachli.feature.suggestions.UiAction.SuggestionAction
|
||||
import app.pachli.feature.suggestions.UiAction.SuggestionAction.AcceptSuggestion
|
||||
import app.pachli.feature.suggestions.UiAction.SuggestionAction.DeleteSuggestion
|
||||
import com.github.michaelbull.result.Err
|
||||
import com.github.michaelbull.result.Ok
|
||||
import com.github.michaelbull.result.Result
|
||||
import com.github.michaelbull.result.mapEither
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import javax.inject.Inject
|
||||
import kotlin.contracts.ExperimentalContracts
|
||||
import kotlin.contracts.InvocationKind
|
||||
import kotlin.contracts.contract
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
|
@ -256,20 +253,3 @@ internal class SuggestionsViewModel @Inject constructor(
|
|||
return result
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps this [Result<V, E>][Result] to [Result<V, E>][Result] by either applying the [transform]
|
||||
* function to the [value][Ok.value] if this [Result] is [Ok<T>][Ok], or returning the result
|
||||
* unchanged.
|
||||
*/
|
||||
@OptIn(ExperimentalContracts::class)
|
||||
inline infix fun <V, E, reified T : V> Result<V, E>.mapIfInstance(transform: (T) -> V): Result<V, E> {
|
||||
contract {
|
||||
callsInPlace(transform, InvocationKind.AT_MOST_ONCE)
|
||||
}
|
||||
|
||||
return when (this) {
|
||||
is Ok -> (value as? T)?.let { Ok(transform(it)) } ?: this
|
||||
is Err -> this
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue