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:
Nik Clayton 2024-07-14 15:36:52 +02:00 committed by GitHub
parent 1177948c9b
commit 00a2cd32d3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
49 changed files with 2613 additions and 951 deletions

View File

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

View File

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

View File

@ -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
}
}

View File

@ -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 {

View File

@ -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
}

View File

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

View File

@ -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
}

View File

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

View File

@ -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()
}
},
)
}
}
}

View File

@ -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? {

View File

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

View File

@ -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
}
},
)
}
}

View File

@ -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,

View File

@ -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,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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"

View File

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

View File

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

View File

@ -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"),
),

View File

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

View File

@ -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,

View File

@ -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,

View File

@ -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,

View File

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

View File

@ -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&lt;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
}
}

View File

@ -18,6 +18,7 @@
plugins {
alias(libs.plugins.pachli.android.library)
alias(libs.plugins.pachli.android.hilt)
alias(libs.plugins.kotlin.parcelize)
}
android {

View File

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

View File

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

View File

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

View File

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

View File

@ -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)),
),
)
}
}

View File

@ -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()
}
}
}

View File

@ -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()
}
}
}

View File

@ -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"))
}
}
}

View File

@ -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()
}
}
}

View File

@ -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()
}
}
}

View File

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

View File

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

View File

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

View File

@ -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,
)
}
}

View File

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

View File

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

View File

@ -30,6 +30,7 @@ android {
dependencies {
implementation(projects.core.common)
implementation(projects.core.network)
api(libs.kotlinx.coroutines.test)
api(libs.androidx.test.junit)

View File

@ -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&lt;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&lt;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"
}

View File

@ -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&lt;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
}
}