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.fragment.app.commit
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import app.pachli.appstore.EventHub import app.pachli.appstore.EventHub
import app.pachli.appstore.FilterChangedEvent
import app.pachli.appstore.MainTabsChangedEvent import app.pachli.appstore.MainTabsChangedEvent
import app.pachli.core.activity.BottomSheetActivity import app.pachli.core.activity.BottomSheetActivity
import app.pachli.core.common.extensions.viewBinding import app.pachli.core.common.extensions.viewBinding
import app.pachli.core.common.util.unsafeLazy 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.model.Timeline
import app.pachli.core.navigation.TimelineActivityIntent 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.FilterContext
import app.pachli.core.network.model.FilterV1
import app.pachli.databinding.ActivityTimelineBinding import app.pachli.databinding.ActivityTimelineBinding
import app.pachli.interfaces.ActionButtonActivity import app.pachli.interfaces.ActionButtonActivity
import app.pachli.interfaces.AppBarLayoutHost import app.pachli.interfaces.AppBarLayoutHost
import at.connyduck.calladapter.networkresult.fold 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.appbar.AppBarLayout
import com.google.android.material.floatingactionbutton.FloatingActionButton import com.google.android.material.floatingactionbutton.FloatingActionButton
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import io.github.z4kn4fein.semver.constraints.toConstraint
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import retrofit2.HttpException
import timber.log.Timber import timber.log.Timber
/** /**
@ -64,7 +62,7 @@ class TimelineActivity : BottomSheetActivity(), AppBarLayoutHost, ActionButtonAc
lateinit var eventHub: EventHub lateinit var eventHub: EventHub
@Inject @Inject
lateinit var serverRepository: ServerRepository lateinit var filtersRepository: FiltersRepository
private val binding: ActivityTimelineBinding by viewBinding(ActivityTimelineBinding::inflate) private val binding: ActivityTimelineBinding by viewBinding(ActivityTimelineBinding::inflate)
private lateinit var timeline: Timeline private lateinit var timeline: Timeline
@ -85,7 +83,6 @@ class TimelineActivity : BottomSheetActivity(), AppBarLayoutHost, ActionButtonAc
private var unmuteTagItem: MenuItem? = null private var unmuteTagItem: MenuItem? = null
/** The filter muting hashtag, null if unknown or hashtag is not filtered */ /** The filter muting hashtag, null if unknown or hashtag is not filtered */
private var mutedFilterV1: FilterV1? = null
private var mutedFilter: Filter? = null private var mutedFilter: Filter? = null
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
@ -238,14 +235,9 @@ class TimelineActivity : BottomSheetActivity(), AppBarLayoutHost, ActionButtonAc
private fun updateMuteTagMenuItems() { private fun updateMuteTagMenuItems() {
val tagWithHash = hashtag?.let { "#$it" } ?: return val tagWithHash = hashtag?.let { "#$it" } ?: return
// If there's no server info, or the server can't filter then it's impossible // If the server can't filter then it's impossible to mute hashtags, so disable
// to mute hashtags, so disable the functionality. // the functionality.
val server = serverRepository.flow.value.getOrElse { null } if (!filtersRepository.canFilter()) {
if (server == null || (
!server.can(ORG_JOINMASTODON_FILTERS_CLIENT, ">=1.0.0".toConstraint()) &&
!server.can(ORG_JOINMASTODON_FILTERS_SERVER, ">=1.0.0".toConstraint())
)
) {
muteTagItem?.isVisible = false muteTagItem?.isVisible = false
unmuteTagItem?.isVisible = false unmuteTagItem?.isVisible = false
return return
@ -256,33 +248,15 @@ class TimelineActivity : BottomSheetActivity(), AppBarLayoutHost, ActionButtonAc
unmuteTagItem?.isVisible = false unmuteTagItem?.isVisible = false
lifecycleScope.launch { lifecycleScope.launch {
mastodonApi.getFilters().fold( filtersRepository.filters.collect { result ->
{ filters -> result.onSuccess { filters ->
mutedFilter = filters.firstOrNull { filter -> mutedFilter = filters?.filters?.firstOrNull { filter ->
filter.contexts.contains(FilterContext.HOME) && filter.keywords.any { filter.contexts.contains(FilterContext.HOME) &&
it.keyword == tagWithHash filter.keywords.any { it.keyword == tagWithHash }
}
} }
updateTagMuteState(mutedFilter != null) 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 { private fun muteTag() {
val tagWithHash = hashtag?.let { "#$it" } ?: return true val tagWithHash = hashtag?.let { "#$it" } ?: return
lifecycleScope.launch { lifecycleScope.launch {
mastodonApi.createFilter( val newFilter = NewFilter(
title = tagWithHash, title = tagWithHash,
context = listOf(FilterContext.HOME), contexts = setOf(FilterContext.HOME),
filterAction = Filter.Action.WARN, action = app.pachli.core.network.model.Filter.Action.WARN,
expiresInSeconds = null, expiresIn = 0,
).fold( keywords = listOf(
{ filter -> NewFilterKeyword(
if (mastodonApi.addFilterKeyword(filterId = filter.id, keyword = tagWithHash, wholeWord = true).isSuccess) { keyword = tagWithHash,
mutedFilter = filter wholeWord = true,
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,
wholeWord = true,
expiresInSeconds = null,
).fold(
{ filter ->
mutedFilterV1 = filter
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)
}
},
) )
}
return true filtersRepository.createFilter(newFilter)
.onSuccess {
mutedFilter = it
updateTagMuteState(true)
Snackbar.make(binding.root, getString(R.string.confirmation_hashtag_muted, hashtag), Snackbar.LENGTH_SHORT).show()
}
.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))
}
}
} }
private fun unmuteTag(): Boolean { private fun unmuteTag() {
lifecycleScope.launch { lifecycleScope.launch {
val tagWithHash = hashtag?.let { "#$it" } ?: return@launch val tagWithHash = hashtag?.let { "#$it" } ?: return@launch
val result = if (mutedFilter != null) { val result = mutedFilter?.let { filter ->
val filter = mutedFilter!! val newContexts = filter.contexts.filter { it != FilterContext.HOME }
if (filter.contexts.size > 1) { if (newContexts.isEmpty()) {
// This filter exists in multiple contexts, just remove the home context filtersRepository.deleteFilter(filter.id)
mastodonApi.updateFilter(
id = filter.id,
context = filter.contexts.filter { it != FilterContext.HOME },
)
} else { } 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)
updateTagMuteState(false) Snackbar.make(binding.root, getString(R.string.confirmation_hashtag_unmuted, hashtag), Snackbar.LENGTH_SHORT).show()
Snackbar.make(binding.root, getString(R.string.confirmation_hashtag_unmuted, hashtag), Snackbar.LENGTH_SHORT).show() mutedFilter = null
eventHub.dispatch(FilterChangedEvent(FilterContext.HOME)) }?.onFailure { e ->
mutedFilterV1 = null Snackbar.make(binding.root, getString(R.string.error_unmuting_hashtag_format, hashtag), Snackbar.LENGTH_SHORT).show()
mutedFilter = null Timber.e("Failed to unmute %s: %s", tagWithHash, e.fmt(this@TimelineActivity))
}, }
{ throwable ->
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
} }
} }

View File

@ -2,7 +2,6 @@ package app.pachli.appstore
import app.pachli.core.model.Timeline import app.pachli.core.model.Timeline
import app.pachli.core.network.model.Account 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.Poll
import app.pachli.core.network.model.Status import app.pachli.core.network.model.Status
@ -21,7 +20,6 @@ data class StatusComposedEvent(val status: Status) : Event
data object StatusScheduledEvent : Event data object StatusScheduledEvent : Event
data class StatusEditedEvent(val originalId: String, val status: Status) : Event data class StatusEditedEvent(val originalId: String, val status: Status) : Event
data class ProfileEditedEvent(val newProfileData: Account) : 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 MainTabsChangedEvent(val newTabs: List<Timeline>) : Event
data class PollVoteEvent(val statusId: String, val poll: Poll) : Event data class PollVoteEvent(val statusId: String, val poll: Poll) : Event
data class DomainMuteEvent(val instance: String) : Event data class DomainMuteEvent(val instance: String) : Event

View File

@ -1,59 +1,75 @@
package app.pachli.components.filters package app.pachli.components.filters
import android.content.Context
import android.content.DialogInterface.BUTTON_NEGATIVE import android.content.DialogInterface.BUTTON_NEGATIVE
import android.content.DialogInterface.BUTTON_POSITIVE import android.content.DialogInterface.BUTTON_POSITIVE
import android.os.Bundle import android.os.Bundle
import android.view.View import android.view.View
import android.view.ViewGroup
import android.widget.AdapterView import android.widget.AdapterView
import android.widget.ArrayAdapter import android.widget.ArrayAdapter
import android.widget.TextView
import androidx.activity.OnBackPressedCallback import androidx.activity.OnBackPressedCallback
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.view.size import androidx.core.view.size
import androidx.core.widget.doAfterTextChanged import androidx.core.widget.doAfterTextChanged
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import app.pachli.R import app.pachli.R
import app.pachli.appstore.EventHub
import app.pachli.core.activity.BaseActivity import app.pachli.core.activity.BaseActivity
import app.pachli.core.common.extensions.viewBinding import app.pachli.core.common.extensions.viewBinding
import app.pachli.core.common.extensions.visible import app.pachli.core.common.extensions.visible
import app.pachli.core.data.model.FilterValidationError
import app.pachli.core.navigation.EditFilterActivityIntent import app.pachli.core.navigation.EditFilterActivityIntent
import app.pachli.core.network.model.Filter import app.pachli.core.network.model.Filter
import app.pachli.core.network.model.FilterContext import app.pachli.core.network.model.FilterContext
import app.pachli.core.network.model.FilterKeyword import app.pachli.core.network.model.FilterKeyword
import app.pachli.core.network.retrofit.MastodonApi
import app.pachli.core.ui.extensions.await import app.pachli.core.ui.extensions.await
import app.pachli.databinding.ActivityEditFilterBinding import app.pachli.databinding.ActivityEditFilterBinding
import app.pachli.databinding.DialogFilterBinding 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.chip.Chip
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import com.google.android.material.switchmaterial.SwitchMaterial import com.google.android.material.switchmaterial.SwitchMaterial
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject import dagger.hilt.android.lifecycle.withCreationCallback
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import retrofit2.HttpException
/** /**
* Edit a single server-side filter. * Edit a single server-side filter.
*/ */
@AndroidEntryPoint @AndroidEntryPoint
class EditFilterActivity : BaseActivity() { class EditFilterActivity : BaseActivity() {
@Inject
lateinit var api: MastodonApi
@Inject
lateinit var eventHub: EventHub
private val binding by viewBinding(ActivityEditFilterBinding::inflate) private val binding by viewBinding(ActivityEditFilterBinding::inflate)
private val viewModel: EditFilterViewModel by viewModels()
private lateinit var filter: Filter // Pass the optional filter and filterId values from the intent to
private var originalFilter: Filter? = null // 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> private lateinit var filterContextSwitches: Map<SwitchMaterial, FilterContext>
/** The active snackbar */
private var snackbar: Snackbar? = null
private val onBackPressedCallback = object : OnBackPressedCallback(true) { private val onBackPressedCallback = object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() { override fun handleOnBackPressed() {
lifecycleScope.launch { lifecycleScope.launch {
@ -66,8 +82,6 @@ class EditFilterActivity : BaseActivity() {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
onBackPressedDispatcher.addCallback(onBackPressedCallback) onBackPressedDispatcher.addCallback(onBackPressedCallback)
originalFilter = EditFilterActivityIntent.getFilter(intent)
filter = originalFilter ?: Filter()
binding.apply { binding.apply {
filterContextSwitches = mapOf( filterContextSwitches = mapOf(
filterContextHome to FilterContext.HOME, filterContextHome to FilterContext.HOME,
@ -86,21 +100,35 @@ class EditFilterActivity : BaseActivity() {
} }
setTitle( setTitle(
if (originalFilter == null) { when (viewModel.uiMode) {
R.string.filter_addition_title UiMode.CREATE -> R.string.filter_addition_title
} else { UiMode.EDIT -> R.string.filter_edit_title
R.string.filter_edit_title
}, },
) )
binding.actionChip.setOnClickListener { showAddKeywordDialog() } 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.filterSaveButton.setOnClickListener { saveChanges() }
binding.filterDeleteButton.setOnClickListener { binding.filterDeleteButton.setOnClickListener {
lifecycleScope.launch { 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) { for (switch in filterContextSwitches.keys) {
switch.setOnCheckedChangeListener { _, isChecked -> switch.setOnCheckedChangeListener { _, isChecked ->
@ -108,7 +136,7 @@ class EditFilterActivity : BaseActivity() {
if (isChecked) { if (isChecked) {
viewModel.addContext(context) viewModel.addContext(context)
} else { } else {
viewModel.removeContext(context) viewModel.deleteContext(context)
} }
} }
} }
@ -116,95 +144,116 @@ class EditFilterActivity : BaseActivity() {
viewModel.setTitle(editable.toString()) viewModel.setTitle(editable.toString())
} }
binding.filterActionWarn.setOnCheckedChangeListener { _, checked -> binding.filterActionWarn.setOnCheckedChangeListener { _, checked ->
viewModel.setAction( viewModel.setAction(if (checked) Filter.Action.WARN else Filter.Action.HIDE)
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
},
)
}
override fun onNothingSelected(parent: AdapterView<*>?) {
viewModel.setDuration(0)
}
} }
loadFilter() bind()
observeModel()
} }
private fun observeModel() { private fun bind() {
lifecycleScope.launch { lifecycleScope.launch {
viewModel.title.collect { title -> repeatOnLifecycle(Lifecycle.State.RESUMED) {
if (title != binding.filterTitle.text.toString()) { launch { viewModel.uiResult.collect(::bindUiResult) }
// 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
}
}
}
lifecycleScope.launch { launch { viewModel.filterViewData.collect(::bindFilter) }
viewModel.isDirty.collectLatest { onBackPressedCallback.isEnabled = it }
}
lifecycleScope.launch { launch { viewModel.isDirty.collectLatest { onBackPressedCallback.isEnabled = it } }
viewModel.validationErrors.collectLatest { errors ->
binding.filterSaveButton.isEnabled = errors.isEmpty()
binding.filterTitleWrapper.error = if (errors.contains(FilterValidationError.NO_TITLE)) { launch {
getString(R.string.error_filter_missing_title) viewModel.validationErrors.collectLatest { errors ->
} else { binding.filterTitleWrapper.error = if (errors.contains(FilterValidationError.NO_TITLE)) {
null getString(R.string.error_filter_missing_title)
} else {
null
}
binding.keywordChipsError.isVisible = errors.contains(FilterValidationError.NO_KEYWORDS)
binding.filterContextError.isVisible = errors.contains(FilterValidationError.NO_CONTEXT)
}
} }
binding.keywordChipsError.isVisible = errors.contains(FilterValidationError.NO_KEYWORDS) launch {
binding.filterContextError.isVisible = errors.contains(FilterValidationError.NO_CONTEXT) viewModel.isDirty.combine(viewModel.validationErrors) { dirty, errors ->
dirty && errors.isEmpty()
}.collectLatest { binding.filterSaveButton.isEnabled = it }
}
} }
} }
} }
// Populate the UI from the filter's members /** Act on the result of UI actions */
private fun loadFilter() { private fun bindUiResult(uiResult: Result<UiSuccess, UiError>) {
viewModel.load(filter) uiResult.onFailure(::bindUiError)
if (filter.expiresAt != null) { uiResult.onSuccess { uiSuccess ->
val durationNames = listOf(getString(R.string.duration_no_change)) + resources.getStringArray(R.array.filter_duration_names) when (uiSuccess) {
binding.filterDurationSpinner.adapter = ArrayAdapter(this, android.R.layout.simple_list_item_1, durationNames) UiSuccess.SaveFilter -> finish()
UiSuccess.DeleteFilter -> finish()
}
} }
} }
private fun updateKeywords(newKeywords: List<FilterKeyword>) { 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 -> newKeywords.forEachIndexed { index, filterKeyword ->
val chip = binding.keywordChips.getChildAt(index).takeUnless { val chip = binding.keywordChips.getChildAt(index).takeUnless {
it.id == R.id.actionChip it.id == R.id.actionChip
@ -234,8 +283,6 @@ class EditFilterActivity : BaseActivity() {
while (binding.keywordChips.size - 1 > newKeywords.size) { while (binding.keywordChips.size - 1 > newKeywords.size) {
binding.keywordChips.removeViewAt(newKeywords.size) binding.keywordChips.removeViewAt(newKeywords.size)
} }
filter = filter.copy(keywords = newKeywords)
} }
private fun showAddKeywordDialog() { private fun showAddKeywordDialog() {
@ -266,7 +313,7 @@ class EditFilterActivity : BaseActivity() {
.setTitle(R.string.filter_edit_keyword_title) .setTitle(R.string.filter_edit_keyword_title)
.setView(binding.root) .setView(binding.root)
.setPositiveButton(R.string.filter_dialog_update_button) { _, _ -> .setPositiveButton(R.string.filter_dialog_update_button) { _, _ ->
viewModel.modifyKeyword( viewModel.updateKeyword(
keyword, keyword,
keyword.copy( keyword.copy(
keyword = binding.phraseEditText.text.toString(), keyword = binding.phraseEditText.text.toString(),
@ -291,41 +338,57 @@ class EditFilterActivity : BaseActivity() {
.create() .create()
.await(R.string.action_continue_edit, R.string.action_discard) .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)?
// TODO use a progress bar here (see EditProfileActivity/activity_edit_profile.xml for example)? private fun saveChanges() = viewModel.saveChanges()
lifecycleScope.launch { private fun deleteFilter() = viewModel.deleteFilter()
if (viewModel.saveChanges(this@EditFilterActivity)) { }
finish()
} else { data class FilterDuration(
Snackbar.make(binding.root, "Error saving filter '${viewModel.title.value}'", Snackbar.LENGTH_SHORT).show() /** 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() { init {
originalFilter?.let { filter -> addAll(items)
lifecycleScope.launch { }
api.deleteFilter(filter.id).fold(
{ override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
finish() val view = super.getView(position, convertView, parent)
}, getItem(position)?.let { item -> (view as? TextView)?.text = item.label }
{ throwable -> return view
if (throwable is HttpException && throwable.code() == 404) { }
api.deleteFilterV1(filter.id).fold(
{ override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup): View {
finish() val view = super.getDropDownView(position, convertView, parent)
}, getItem(position)?.let { item -> (view as? TextView)?.text = item.label }
{ return view
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()
}
},
)
}
}
} }
} }

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 package app.pachli.components.filters
import android.content.Context import android.content.Context
import androidx.annotation.StringRes
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import app.pachli.R import app.pachli.R
import app.pachli.appstore.EventHub import app.pachli.components.filters.UiError.DeleteFilterError
import app.pachli.appstore.FilterChangedEvent import app.pachli.components.filters.UiError.SaveFilterError
import app.pachli.core.network.model.Filter 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.FilterContext
import app.pachli.core.network.model.FilterKeyword import app.pachli.core.network.model.FilterKeyword
import app.pachli.core.network.retrofit.MastodonApi import com.github.michaelbull.result.Err
import at.connyduck.calladapter.networkresult.fold 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 dagger.hilt.android.lifecycle.HiltViewModel
import java.util.Date 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.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.withContext import kotlinx.coroutines.flow.onEach
import retrofit2.HttpException 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() { * Data to show the filter in the UI.
private lateinit var originalFilter: Filter */
val title = MutableStateFlow("") data class FilterViewData(
val keywords = MutableStateFlow(listOf<FilterKeyword>()) /** Filter's ID. Null if this is a new, un-saved filter. */
val action = MutableStateFlow(Filter.Action.WARN) val id: String? = null,
val duration = MutableStateFlow(0) val title: String = "",
val contexts = MutableStateFlow(listOf<FilterContext>()) val contexts: Set<FilterContext> = emptySet(),
/**
/** Track whether the duration has been modified, for use in [onChange] */ * The number of seconds in the future the filter should expire.
// TODO: Rethink how duration is shown in the UI. * "-1" means "use the filter's current value".
// Could show the actual end time with the date/time widget to set the duration, * "0" means "filter never expires".
// along with dropdown for quick settings (1h, etc). */
private var durationIsDirty = false val expiresIn: Int = 0,
val action: NetworkFilter.Action = NetworkFilter.Action.WARN,
private val _isDirty = MutableStateFlow(false) val keywords: List<FilterKeyword> = emptyList(),
) {
/** True if the user has made unsaved changes to the filter */ /**
val isDirty = _isDirty.asStateFlow() * @return Set of [FilterValidationError] given the current state of the
* filter. Empty if there are no validation errors.
private val _validationErrors = MutableStateFlow(emptySet<FilterValidationError>()) */
fun validate() = buildSet {
/** True if the filter is valid and can be saved */ if (title.isBlank()) add(FilterValidationError.NO_TITLE)
val validationErrors = _validationErrors.asStateFlow() if (keywords.isEmpty()) add(FilterValidationError.NO_KEYWORDS)
if (contexts.isEmpty()) add(FilterValidationError.NO_CONTEXT)
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)
}
} }
/** /**
* Call when the contents of the filter change; recalculates validity * Calculates the difference between [filter] and `this`, returning an
* and dirty state. * [FilterEdit] that representes the differences.
*/ */
private fun onChange() { fun diff(filter: Filter): FilterEdit {
validate() 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 _isDirty.value = true
return return
} }
_isDirty.value = when { _isDirty.value = when {
originalFilter.title != title.value -> true originalFilter?.title != filterViewData.title -> true
originalFilter.contexts != contexts.value -> true originalFilter?.contexts != filterViewData.contexts -> true
originalFilter.action != action.value -> true originalFilter?.action != filterViewData.action -> true
originalFilter.keywords.toSet() != keywords.value.toSet() -> true originalFilter?.keywords?.toSet() != filterViewData.keywords.toSet() -> true
else -> false else -> false
} }
} }
suspend fun saveChanges(context: Context): Boolean { /**
val contexts = contexts.value * Saves [filterViewData], either by creating a new filter or updating the
val title = title.value * existing filter.
val durationIndex = duration.value */
val action = action.value fun saveChanges() = viewModelScope.launch {
val filterViewData = filterViewData.value.get() ?: return@launch
return withContext(viewModelScope.coroutineContext) { _uiResult.send(
val success = if (originalFilter.id == "") { when (uiMode) {
createFilter(title, contexts, action, durationIndex, context) UiMode.CREATE -> createFilter(filterViewData)
} else { UiMode.EDIT -> updateFilter(filterViewData)
updateFilter(originalFilter, title, contexts, action, durationIndex, context)
} }
.map { UiSuccess.SaveFilter },
// 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)
)
},
) )
} }
private suspend fun updateFilter(originalFilter: Filter, title: String, contexts: List<FilterContext>, action: Filter.Action, durationIndex: Int, context: Context): Boolean { /** Create a new filter from [filterViewData]. */
val expiresInSeconds = getSecondsForDurationIndex(durationIndex, context) private suspend fun createFilter(filterViewData: FilterViewData): Result<Filter, UiError> {
api.updateFilter( return filtersRepository.createFilter(NewFilter.from(filterViewData))
id = originalFilter.id, .mapError { SaveFilterError(it) }
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
},
)
} }
private suspend fun createFilterV1(contexts: List<FilterContext>, expiresInSeconds: String?): Boolean { /** Persists the changes to [filterViewData]. */
return keywords.value.map { keyword -> private suspend fun updateFilter(filterViewData: FilterViewData): Result<Filter, UiError> {
api.createFilterV1(keyword.keyword, contexts, false, keyword.wholeWord, expiresInSeconds) return filtersRepository.updateFilter(originalFilter!!, filterViewData.diff(originalFilter!!))
}.none { it.isFailure } .mapError { SaveFilterError(it) }
} }
private suspend fun updateFilterV1(contexts: List<FilterContext>, expiresInSeconds: String?): Boolean { /** Delete [filterViewData]. */
val results = keywords.value.map { keyword -> fun deleteFilter() = viewModelScope.launch {
if (originalFilter.id == "") { val filterViewData = filterViewData.value.get() ?: return@launch
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
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 { companion object {

View File

@ -18,10 +18,8 @@
package app.pachli.components.filters package app.pachli.components.filters
import android.app.Activity import android.app.Activity
import androidx.annotation.StringRes
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import app.pachli.R import app.pachli.R
import app.pachli.core.network.model.Filter
import app.pachli.core.ui.extensions.await import app.pachli.core.ui.extensions.await
internal suspend fun Activity.showDeleteFilterDialog(filterTitle: String) = AlertDialog.Builder(this) internal suspend fun Activity.showDeleteFilterDialog(filterTitle: String) = AlertDialog.Builder(this)
@ -29,36 +27,3 @@ internal suspend fun Activity.showDeleteFilterDialog(filterTitle: String) = Aler
.setCancelable(true) .setCancelable(true)
.create() .create()
.await(R.string.dialog_delete_filter_positive_action, android.R.string.cancel) .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.show
import app.pachli.core.common.extensions.viewBinding import app.pachli.core.common.extensions.viewBinding
import app.pachli.core.common.extensions.visible import app.pachli.core.common.extensions.visible
import app.pachli.core.data.model.Filter
import app.pachli.core.navigation.EditFilterActivityIntent import app.pachli.core.navigation.EditFilterActivityIntent
import app.pachli.core.network.model.Filter
import app.pachli.core.ui.BackgroundMessage import app.pachli.core.ui.BackgroundMessage
import app.pachli.databinding.ActivityFiltersBinding import app.pachli.databinding.ActivityFiltersBinding
import com.google.android.material.color.MaterialColors import com.google.android.material.color.MaterialColors
@ -94,7 +94,9 @@ class FiltersActivity : BaseActivity(), FiltersListener {
} }
private fun launchEditFilterActivity(filter: Filter? = null) { 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) startActivityWithTransition(intent, TransitionKind.SLIDE_FROM_END)
} }

View File

@ -2,9 +2,11 @@ package app.pachli.components.filters
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.ViewGroup import android.view.ViewGroup
import androidx.annotation.StringRes
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import app.pachli.R 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.core.ui.BindingHolder
import app.pachli.databinding.ItemRemovableBinding import app.pachli.databinding.ItemRemovableBinding
import app.pachli.util.getRelativeTimeSpanString 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 package app.pachli.components.filters
import app.pachli.core.network.model.Filter import app.pachli.core.data.model.Filter
interface FiltersListener { interface FiltersListener {
fun deleteFilter(filter: Filter) fun deleteFilter(filter: Filter)

View File

@ -3,23 +3,21 @@ package app.pachli.components.filters
import android.view.View import android.view.View
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import app.pachli.appstore.EventHub import app.pachli.core.data.model.Filter
import app.pachli.appstore.FilterChangedEvent import app.pachli.core.data.repository.FiltersRepository
import app.pachli.core.network.model.Filter import com.github.michaelbull.result.onFailure
import app.pachli.core.network.retrofit.MastodonApi import com.github.michaelbull.result.onSuccess
import at.connyduck.calladapter.networkresult.fold
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import retrofit2.HttpException
@HiltViewModel @HiltViewModel
class FiltersViewModel @Inject constructor( class FiltersViewModel @Inject constructor(
private val api: MastodonApi, private val filtersRepository: FiltersRepository,
private val eventHub: EventHub,
) : ViewModel() { ) : ViewModel() {
enum class LoadingState { enum class LoadingState {
@ -35,63 +33,34 @@ class FiltersViewModel @Inject constructor(
val state: Flow<State> get() = _state val state: Flow<State> get() = _state
private val _state = MutableStateFlow(State(emptyList(), LoadingState.INITIAL)) private val _state = MutableStateFlow(State(emptyList(), LoadingState.INITIAL))
// TODO: Now that FilterRepository exists this code should be updated to use that.
fun load() { fun load() {
this@FiltersViewModel._state.value = _state.value.copy(loadingState = LoadingState.LOADING) this@FiltersViewModel._state.value = _state.value.copy(loadingState = LoadingState.LOADING)
viewModelScope.launch { viewModelScope.launch {
api.getFilters().fold( filtersRepository.filters.collect { result ->
{ filters -> result.onSuccess { filters ->
this@FiltersViewModel._state.value = State(filters, LoadingState.LOADED) this@FiltersViewModel._state.update { State(filters?.filters.orEmpty(), LoadingState.LOADED) }
}, }
{ throwable -> .onFailure {
if (throwable is HttpException && throwable.code() == 404) { // TODO: There's an ERROR_NETWORK state to maybe consider here. Or get rid of
api.getFiltersV1().fold( // that and do proper error handling.
{ filters -> this@FiltersViewModel._state.update {
this@FiltersViewModel._state.value = State(filters.map { it.toFilter() }, LoadingState.LOADED) it.copy(loadingState = LoadingState.ERROR_OTHER)
}, }
{ 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)
} }
}, }
)
} }
} }
fun deleteFilter(filter: Filter, parent: View) { fun deleteFilter(filter: Filter, parent: View) {
viewModelScope.launch { 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) 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)) .onFailure {
} Snackbar.make(parent, "Error deleting filter '${filter.title}'", Snackbar.LENGTH_SHORT).show()
}, }
{ 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 {
Snackbar.make(parent, "Error deleting filter '${filter.title}'", Snackbar.LENGTH_SHORT).show()
}
},
)
} }
} }
} }

View File

@ -17,6 +17,7 @@
package app.pachli.components.notifications package app.pachli.components.notifications
import android.content.Context
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
@ -27,13 +28,12 @@ import androidx.paging.map
import app.pachli.R import app.pachli.R
import app.pachli.appstore.BlockEvent import app.pachli.appstore.BlockEvent
import app.pachli.appstore.EventHub import app.pachli.appstore.EventHub
import app.pachli.appstore.FilterChangedEvent
import app.pachli.appstore.MuteConversationEvent import app.pachli.appstore.MuteConversationEvent
import app.pachli.appstore.MuteEvent 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.accounts.AccountManager
import app.pachli.core.common.extensions.throttleFirst 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.data.repository.StatusDisplayOptionsRepository
import app.pachli.core.network.model.Filter import app.pachli.core.network.model.Filter
import app.pachli.core.network.model.FilterContext 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.NotificationViewData
import app.pachli.viewdata.StatusViewData import app.pachli.viewdata.StatusViewData
import at.connyduck.calladapter.networkresult.getOrThrow 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.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
@ -57,7 +60,6 @@ import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
@ -301,6 +303,9 @@ sealed interface UiError {
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel @HiltViewModel
class NotificationsViewModel @Inject constructor( 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 repository: NotificationsRepository,
private val accountManager: AccountManager, private val accountManager: AccountManager,
private val timelineCases: TimelineCases, private val timelineCases: TimelineCases,
@ -469,15 +474,18 @@ class NotificationsViewModel @Inject constructor(
// Fetch the status filters // Fetch the status filters
viewModelScope.launch { viewModelScope.launch {
eventHub.events filtersRepository.filters.collect { filters ->
.filterIsInstance<FilterChangedEvent>() filters.onSuccess {
.filter { it.filterContext == FilterContext.NOTIFICATIONS } filterModel = when (it?.version) {
.map { FilterVersion.V2 -> FilterModel(FilterContext.NOTIFICATIONS)
getFilters() FilterVersion.V1 -> FilterModel(FilterContext.NOTIFICATIONS, it.filters)
repository.invalidate() 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 // 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 // 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 // fetched. Convert to null to ensure a full fetch in this case
private fun getInitialKey(): String? { 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.activity.extensions.startActivityWithTransition
import app.pachli.core.common.util.unsafeLazy import app.pachli.core.common.util.unsafeLazy
import app.pachli.core.data.repository.AccountPreferenceDataStore 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.designsystem.R as DR
import app.pachli.core.navigation.AccountListActivityIntent import app.pachli.core.navigation.AccountListActivityIntent
import app.pachli.core.navigation.FiltersActivityIntent 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
import app.pachli.core.navigation.PreferencesActivityIntent.PreferenceScreen import app.pachli.core.navigation.PreferencesActivityIntent.PreferenceScreen
import app.pachli.core.navigation.TabPreferenceActivityIntent 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.Account
import app.pachli.core.network.model.Status import app.pachli.core.network.model.Status
import app.pachli.core.network.retrofit.MastodonApi 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.getLocaleList
import app.pachli.util.getPachliDisplayName import app.pachli.util.getPachliDisplayName
import app.pachli.util.iconRes import app.pachli.util.iconRes
import com.github.michaelbull.result.getOrElse
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import com.mikepenz.iconics.IconicsDrawable import com.mikepenz.iconics.IconicsDrawable
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import io.github.z4kn4fein.semver.constraints.toConstraint
import javax.inject.Inject import javax.inject.Inject
import retrofit2.Call import retrofit2.Call
import retrofit2.Callback import retrofit2.Callback
@ -78,7 +74,7 @@ class AccountPreferencesFragment : PreferenceFragmentCompat() {
lateinit var mastodonApi: MastodonApi lateinit var mastodonApi: MastodonApi
@Inject @Inject
lateinit var serverRepository: ServerRepository lateinit var filtersRepository: FiltersRepository
@Inject @Inject
lateinit var eventHub: EventHub lateinit var eventHub: EventHub
@ -170,16 +166,7 @@ class AccountPreferencesFragment : PreferenceFragmentCompat() {
activity?.startActivityWithTransition(intent, TransitionKind.SLIDE_FROM_END) activity?.startActivityWithTransition(intent, TransitionKind.SLIDE_FROM_END)
true true
} }
val server = serverRepository.flow.value.getOrElse { null } isEnabled = filtersRepository.canFilter()
isEnabled = server?.let {
it.can(
ORG_JOINMASTODON_FILTERS_CLIENT,
">1.0.0".toConstraint(),
) || it.can(
ORG_JOINMASTODON_FILTERS_SERVER,
">1.0.0".toConstraint(),
)
} ?: false
if (!isEnabled) summary = context.getString(R.string.pref_summary_timeline_filters) 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 package app.pachli.components.timeline.viewmodel
import android.content.Context
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.paging.PagingData import androidx.paging.PagingData
@ -29,8 +30,8 @@ import app.pachli.appstore.FavoriteEvent
import app.pachli.appstore.PinEvent import app.pachli.appstore.PinEvent
import app.pachli.appstore.ReblogEvent import app.pachli.appstore.ReblogEvent
import app.pachli.components.timeline.CachedTimelineRepository import app.pachli.components.timeline.CachedTimelineRepository
import app.pachli.components.timeline.FiltersRepository
import app.pachli.core.accounts.AccountManager import app.pachli.core.accounts.AccountManager
import app.pachli.core.data.repository.FiltersRepository
import app.pachli.core.data.repository.StatusDisplayOptionsRepository import app.pachli.core.data.repository.StatusDisplayOptionsRepository
import app.pachli.core.network.model.Filter import app.pachli.core.network.model.Filter
import app.pachli.core.network.model.Poll import app.pachli.core.network.model.Poll
@ -39,6 +40,7 @@ import app.pachli.usecase.TimelineCases
import app.pachli.viewdata.StatusViewData import app.pachli.viewdata.StatusViewData
import com.squareup.moshi.Moshi import com.squareup.moshi.Moshi
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@ -53,6 +55,7 @@ import timber.log.Timber
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel @HiltViewModel
class CachedTimelineViewModel @Inject constructor( class CachedTimelineViewModel @Inject constructor(
@ApplicationContext context: Context,
savedStateHandle: SavedStateHandle, savedStateHandle: SavedStateHandle,
private val repository: CachedTimelineRepository, private val repository: CachedTimelineRepository,
timelineCases: TimelineCases, timelineCases: TimelineCases,
@ -63,6 +66,7 @@ class CachedTimelineViewModel @Inject constructor(
sharedPreferencesRepository: SharedPreferencesRepository, sharedPreferencesRepository: SharedPreferencesRepository,
private val moshi: Moshi, private val moshi: Moshi,
) : TimelineViewModel( ) : TimelineViewModel(
context,
savedStateHandle, savedStateHandle,
timelineCases, timelineCases,
eventHub, eventHub,

View File

@ -17,6 +17,7 @@
package app.pachli.components.timeline.viewmodel package app.pachli.components.timeline.viewmodel
import android.content.Context
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.paging.PagingData import androidx.paging.PagingData
@ -28,9 +29,9 @@ import app.pachli.appstore.EventHub
import app.pachli.appstore.FavoriteEvent import app.pachli.appstore.FavoriteEvent
import app.pachli.appstore.PinEvent import app.pachli.appstore.PinEvent
import app.pachli.appstore.ReblogEvent import app.pachli.appstore.ReblogEvent
import app.pachli.components.timeline.FiltersRepository
import app.pachli.components.timeline.NetworkTimelineRepository import app.pachli.components.timeline.NetworkTimelineRepository
import app.pachli.core.accounts.AccountManager import app.pachli.core.accounts.AccountManager
import app.pachli.core.data.repository.FiltersRepository
import app.pachli.core.data.repository.StatusDisplayOptionsRepository import app.pachli.core.data.repository.StatusDisplayOptionsRepository
import app.pachli.core.network.model.Filter import app.pachli.core.network.model.Filter
import app.pachli.core.network.model.Poll 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.usecase.TimelineCases
import app.pachli.viewdata.StatusViewData import app.pachli.viewdata.StatusViewData
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@ -52,6 +54,7 @@ import timber.log.Timber
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel @HiltViewModel
class NetworkTimelineViewModel @Inject constructor( class NetworkTimelineViewModel @Inject constructor(
@ApplicationContext context: Context,
savedStateHandle: SavedStateHandle, savedStateHandle: SavedStateHandle,
private val repository: NetworkTimelineRepository, private val repository: NetworkTimelineRepository,
timelineCases: TimelineCases, timelineCases: TimelineCases,
@ -61,6 +64,7 @@ class NetworkTimelineViewModel @Inject constructor(
statusDisplayOptionsRepository: StatusDisplayOptionsRepository, statusDisplayOptionsRepository: StatusDisplayOptionsRepository,
sharedPreferencesRepository: SharedPreferencesRepository, sharedPreferencesRepository: SharedPreferencesRepository,
) : TimelineViewModel( ) : TimelineViewModel(
context,
savedStateHandle, savedStateHandle,
timelineCases, timelineCases,
eventHub, eventHub,

View File

@ -17,6 +17,7 @@
package app.pachli.components.timeline.viewmodel package app.pachli.components.timeline.viewmodel
import android.content.Context
import androidx.annotation.CallSuper import androidx.annotation.CallSuper
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.annotation.VisibleForTesting import androidx.annotation.VisibleForTesting
@ -32,7 +33,6 @@ import app.pachli.appstore.DomainMuteEvent
import app.pachli.appstore.Event import app.pachli.appstore.Event
import app.pachli.appstore.EventHub import app.pachli.appstore.EventHub
import app.pachli.appstore.FavoriteEvent import app.pachli.appstore.FavoriteEvent
import app.pachli.appstore.FilterChangedEvent
import app.pachli.appstore.MuteConversationEvent import app.pachli.appstore.MuteConversationEvent
import app.pachli.appstore.MuteEvent import app.pachli.appstore.MuteEvent
import app.pachli.appstore.PinEvent import app.pachli.appstore.PinEvent
@ -41,10 +41,10 @@ import app.pachli.appstore.StatusComposedEvent
import app.pachli.appstore.StatusDeletedEvent import app.pachli.appstore.StatusDeletedEvent
import app.pachli.appstore.StatusEditedEvent import app.pachli.appstore.StatusEditedEvent
import app.pachli.appstore.UnfollowEvent 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.accounts.AccountManager
import app.pachli.core.common.extensions.throttleFirst 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.data.repository.StatusDisplayOptionsRepository
import app.pachli.core.model.Timeline import app.pachli.core.model.Timeline
import app.pachli.core.network.model.Filter import app.pachli.core.network.model.Filter
@ -57,6 +57,9 @@ import app.pachli.network.FilterModel
import app.pachli.usecase.TimelineCases import app.pachli.usecase.TimelineCases
import app.pachli.viewdata.StatusViewData import app.pachli.viewdata.StatusViewData
import at.connyduck.calladapter.networkresult.getOrThrow 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.channels.Channel
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
@ -68,9 +71,9 @@ import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.fold
import kotlinx.coroutines.flow.getAndUpdate import kotlinx.coroutines.flow.getAndUpdate
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -258,6 +261,9 @@ sealed interface UiError {
} }
abstract class TimelineViewModel( 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, savedStateHandle: SavedStateHandle,
private val timelineCases: TimelineCases, private val timelineCases: TimelineCases,
private val eventHub: EventHub, private val eventHub: EventHub,
@ -320,8 +326,22 @@ abstract class TimelineViewModel(
init { init {
viewModelScope.launch { viewModelScope.launch {
updateFiltersFromPreferences().collectLatest { FilterContext.from(timeline)?.let { filterContext ->
Timber.d("Filters updated") 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 // TODO: Update this so that the list of UIPrefs is correct
private fun onPreferenceChanged(key: String) { private fun onPreferenceChanged(key: String) {
when (key) { when (key) {

View File

@ -18,8 +18,7 @@ package app.pachli.components.trending.viewmodel
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import app.pachli.appstore.EventHub import app.pachli.core.data.repository.FiltersRepository
import app.pachli.appstore.FilterChangedEvent
import app.pachli.core.network.model.FilterContext import app.pachli.core.network.model.FilterContext
import app.pachli.core.network.model.TrendingTag import app.pachli.core.network.model.TrendingTag
import app.pachli.core.network.model.end 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.core.network.retrofit.MastodonApi
import app.pachli.viewdata.TrendingViewData import app.pachli.viewdata.TrendingViewData
import at.connyduck.calladapter.networkresult.fold import at.connyduck.calladapter.networkresult.fold
import com.github.michaelbull.result.get
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import java.io.IOException import java.io.IOException
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
@HiltViewModel @HiltViewModel
class TrendingTagsViewModel @Inject constructor( class TrendingTagsViewModel @Inject constructor(
private val mastodonApi: MastodonApi, private val mastodonApi: MastodonApi,
private val eventHub: EventHub, private val filtersRepository: FiltersRepository,
) : ViewModel() { ) : ViewModel() {
enum class LoadingState { enum class LoadingState {
INITIAL, INITIAL,
@ -61,17 +59,7 @@ class TrendingTagsViewModel @Inject constructor(
init { init {
invalidate() invalidate()
viewModelScope.launch { filtersRepository.filters.collect { 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()
}
}
} }
/** /**
@ -86,8 +74,6 @@ class TrendingTagsViewModel @Inject constructor(
_uiState.value = TrendingTagsUiState(emptyList(), LoadingState.LOADING) _uiState.value = TrendingTagsUiState(emptyList(), LoadingState.LOADING)
} }
val deferredFilters = async { mastodonApi.getFilters() }
mastodonApi.trendingTags(limit = LIMIT_TRENDING_HASHTAGS).fold( mastodonApi.trendingTags(limit = LIMIT_TRENDING_HASHTAGS).fold(
{ tagResponse -> { tagResponse ->
@ -95,7 +81,7 @@ class TrendingTagsViewModel @Inject constructor(
_uiState.value = if (firstTag == null) { _uiState.value = if (firstTag == null) {
TrendingTagsUiState(emptyList(), LoadingState.LOADED) TrendingTagsUiState(emptyList(), LoadingState.LOADED)
} else { } else {
val homeFilters = deferredFilters.await().getOrNull()?.filter { filter -> val homeFilters = filtersRepository.filters.value.get()?.filters?.filter { filter ->
filter.contexts.contains(FilterContext.HOME) filter.contexts.contains(FilterContext.HOME)
} }
val tags = tagResponse val tags = tagResponse

View File

@ -22,17 +22,16 @@ import app.pachli.appstore.BlockEvent
import app.pachli.appstore.BookmarkEvent import app.pachli.appstore.BookmarkEvent
import app.pachli.appstore.EventHub import app.pachli.appstore.EventHub
import app.pachli.appstore.FavoriteEvent import app.pachli.appstore.FavoriteEvent
import app.pachli.appstore.FilterChangedEvent
import app.pachli.appstore.PinEvent import app.pachli.appstore.PinEvent
import app.pachli.appstore.ReblogEvent import app.pachli.appstore.ReblogEvent
import app.pachli.appstore.StatusComposedEvent import app.pachli.appstore.StatusComposedEvent
import app.pachli.appstore.StatusDeletedEvent import app.pachli.appstore.StatusDeletedEvent
import app.pachli.appstore.StatusEditedEvent import app.pachli.appstore.StatusEditedEvent
import app.pachli.components.timeline.CachedTimelineRepository 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.components.timeline.util.ifExpected
import app.pachli.core.accounts.AccountManager 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.data.repository.StatusDisplayOptionsRepository
import app.pachli.core.database.dao.TimelineDao import app.pachli.core.database.dao.TimelineDao
import app.pachli.core.database.model.AccountEntity 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.fold
import at.connyduck.calladapter.networkresult.getOrElse import at.connyduck.calladapter.networkresult.getOrElse
import at.connyduck.calladapter.networkresult.getOrThrow import at.connyduck.calladapter.networkresult.getOrThrow
import com.github.michaelbull.result.onFailure
import com.github.michaelbull.result.onSuccess
import com.squareup.moshi.Moshi import com.squareup.moshi.Moshi
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject import javax.inject.Inject
@ -107,16 +108,27 @@ class ViewThreadViewModel @Inject constructor(
is StatusComposedEvent -> handleStatusComposedEvent(event) is StatusComposedEvent -> handleStatusComposedEvent(event)
is StatusDeletedEvent -> handleStatusDeletedEvent(event) is StatusDeletedEvent -> handleStatusDeletedEvent(event)
is StatusEditedEvent -> handleStatusEditedEvent(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) { fun loadThread(id: String) {
@ -205,8 +217,7 @@ class ViewThreadViewModel @Inject constructor(
translation = cachedTranslations[status.id], translation = cachedTranslations[status.id],
) )
}.filterByFilterAction() }.filterByFilterAction()
val descendants = statusContext.descendants.map { val descendants = statusContext.descendants.map { status ->
status ->
val svd = cachedViewData[status.id] val svd = cachedViewData[status.id]
StatusViewData.from( StatusViewData.from(
status, status,
@ -519,22 +530,6 @@ class ViewThreadViewModel @Inject constructor(
return RevealButtonState.NO_BUTTON 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() { private fun updateStatuses() {
updateSuccess { uiState -> updateSuccess { uiState ->
val statuses = uiState.statusViewData.filterByFilterAction() val statuses = uiState.statusViewData.filterByFilterAction()

View File

@ -134,7 +134,7 @@ abstract class SFragment<T : IStatusViewData> : Fragment(), StatusActionListener
Timber.e(msg) Timber.e(msg)
try { try {
Snackbar.make(requireView(), msg, Snackbar.LENGTH_INDEFINITE) 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() .show()
} catch (e: IllegalArgumentException) { } catch (e: IllegalArgumentException) {
// On rare occasions this code is running before the fragment's // On rare occasions this code is running before the fragment's

View File

@ -1,8 +1,8 @@
package app.pachli.network 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.FilterContext
import app.pachli.core.network.model.FilterV1
import app.pachli.core.network.model.Status import app.pachli.core.network.model.Status
import app.pachli.core.network.parseAsMastodonHtml import app.pachli.core.network.parseAsMastodonHtml
import java.util.Date 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 * Construct with [filterContext] that corresponds to the kind of timeline, and optionally the set
* of v1 filters that should be applied. * 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 */ /** Pattern to use when matching v1 filters against a status. Null if these are v2 filters */
private var pattern: Pattern? = null 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 */ /** @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 -> pattern?.let { pat ->
// Patterns are expensive and thread-safe, matchers are neither. // 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) { if (status.poll?.options?.any { matcher.reset(it.title).find() } == true) {
return Filter.Action.HIDE return NetworkFilter.Action.HIDE
} }
val spoilerText = status.actionableStatus.spoilerText val spoilerText = status.actionableStatus.spoilerText
@ -42,9 +42,9 @@ class FilterModel(private val filterContext: FilterContext, v1filters: List<Filt
(spoilerText.isNotEmpty() && matcher.reset(spoilerText).find()) || (spoilerText.isNotEmpty() && matcher.reset(spoilerText).find()) ||
(attachmentsDescriptions.isNotEmpty() && matcher.reset(attachmentsDescriptions.joinToString("\n")).find()) (attachmentsDescriptions.isNotEmpty() && matcher.reset(attachmentsDescriptions.joinToString("\n")).find())
) { ) {
Filter.Action.HIDE NetworkFilter.Action.HIDE
} else { } else {
Filter.Action.NONE NetworkFilter.Action.NONE
} }
} }
@ -53,23 +53,24 @@ class FilterModel(private val filterContext: FilterContext, v1filters: List<Filt
} }
return if (matchingKind.isNullOrEmpty()) { return if (matchingKind.isNullOrEmpty()) {
Filter.Action.NONE NetworkFilter.Action.NONE
} else { } else {
matchingKind.maxOf { it.filter.action } matchingKind.maxOf { it.filter.action }
} }
} }
private fun filterToRegexToken(filter: FilterV1): String? { private fun filterToRegexToken(filter: Filter): String? {
val phrase = filter.phrase val keyword = filter.keywords.first()
val phrase = keyword.keyword
val quotedPhrase = Pattern.quote(phrase) val quotedPhrase = Pattern.quote(phrase)
return if (filter.wholeWord && ALPHANUMERIC.matcher(phrase).matches()) { return if (keyword.wholeWord && ALPHANUMERIC.matcher(phrase).matches()) {
"(^|\\W)$quotedPhrase($|\\W)" "(^|\\W)$quotedPhrase($|\\W)"
} else { } else {
quotedPhrase quotedPhrase
} }
} }
private fun makeFilter(filters: List<FilterV1>): Pattern? { private fun makeFilter(filters: List<Filter>): Pattern? {
val now = Date() val now = Date()
val nonExpiredFilters = filters.filter { it.expiresAt?.before(now) != true } val nonExpiredFilters = filters.filter { it.expiresAt?.before(now) != true }
if (nonExpiredFilters.isEmpty()) return null if (nonExpiredFilters.isEmpty()) return null

View File

@ -110,7 +110,7 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:minHeight="48dp" android:minHeight="48dp"
android:entries="@array/filter_duration_names" /> android:entries="@array/filter_duration_labels" />
<TextView <TextView
android:layout_width="match_parent" android:layout_width="match_parent"

View File

@ -237,7 +237,8 @@
<item>@string/duration_7_days</item> <item>@string/duration_7_days</item>
</string-array> </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>0</item>
<item>300</item> <item>300</item>
<item>1800</item> <item>1800</item>
@ -248,7 +249,7 @@
<item>604800</item> <item>604800</item>
</integer-array> </integer-array>
<string-array name="filter_duration_names"> <string-array name="filter_duration_labels">
<item>@string/duration_indefinite</item> <item>@string/duration_indefinite</item>
<item>@string/duration_5_min</item> <item>@string/duration_5_min</item>
<item>@string/duration_30_min</item> <item>@string/duration_30_min</item>
@ -259,7 +260,8 @@
<item>@string/duration_7_days</item> <item>@string/duration_7_days</item>
</string-array> </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>0</item>
<item>300</item> <item>300</item>
<item>1800</item> <item>1800</item>
@ -268,7 +270,7 @@
<item>86400</item> <item>86400</item>
<item>259200</item> <item>259200</item>
<item>604800</item> <item>604800</item>
</string-array> </integer-array>
<string-array name="filter_action_values"> <string-array name="filter_action_values">
<item>warn</item> <item>warn</item>

View File

@ -712,4 +712,8 @@
<string name="preview_card_byline_fmt">See more from %1$s</string> <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_byline_account">Show article author\'s profile</string>
<string name="action_open_link">Open link</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> </resources>

View File

@ -19,8 +19,9 @@ package app.pachli
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import app.pachli.components.filters.EditFilterViewModel.Companion.getSecondsForDurationIndex 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.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.FilterContext
import app.pachli.core.network.model.FilterV1 import app.pachli.core.network.model.FilterV1
import app.pachli.core.network.model.Poll import app.pachli.core.network.model.Poll
@ -46,7 +47,7 @@ class FilterV1Test {
FilterV1( FilterV1(
id = "123", id = "123",
phrase = "badWord", phrase = "badWord",
contexts = listOf(FilterContext.HOME), contexts = setOf(FilterContext.HOME),
expiresAt = null, expiresAt = null,
irreversible = false, irreversible = false,
wholeWord = false, wholeWord = false,
@ -54,7 +55,7 @@ class FilterV1Test {
FilterV1( FilterV1(
id = "123", id = "123",
phrase = "badWholeWord", phrase = "badWholeWord",
contexts = listOf(FilterContext.HOME, FilterContext.PUBLIC), contexts = setOf(FilterContext.HOME, FilterContext.PUBLIC),
expiresAt = null, expiresAt = null,
irreversible = false, irreversible = false,
wholeWord = true, wholeWord = true,
@ -62,7 +63,7 @@ class FilterV1Test {
FilterV1( FilterV1(
id = "123", id = "123",
phrase = "@twitter.com", phrase = "@twitter.com",
contexts = listOf(FilterContext.HOME), contexts = setOf(FilterContext.HOME),
expiresAt = null, expiresAt = null,
irreversible = false, irreversible = false,
wholeWord = true, wholeWord = true,
@ -70,7 +71,7 @@ class FilterV1Test {
FilterV1( FilterV1(
id = "123", id = "123",
phrase = "#hashtag", phrase = "#hashtag",
contexts = listOf(FilterContext.HOME), contexts = setOf(FilterContext.HOME),
expiresAt = null, expiresAt = null,
irreversible = false, irreversible = false,
wholeWord = true, wholeWord = true,
@ -78,7 +79,7 @@ class FilterV1Test {
FilterV1( FilterV1(
id = "123", id = "123",
phrase = "expired", phrase = "expired",
contexts = listOf(FilterContext.HOME), contexts = setOf(FilterContext.HOME),
expiresAt = Date.from(Instant.now().minusSeconds(10)), expiresAt = Date.from(Instant.now().minusSeconds(10)),
irreversible = false, irreversible = false,
wholeWord = true, wholeWord = true,
@ -86,7 +87,7 @@ class FilterV1Test {
FilterV1( FilterV1(
id = "123", id = "123",
phrase = "unexpired", phrase = "unexpired",
contexts = listOf(FilterContext.HOME), contexts = setOf(FilterContext.HOME),
expiresAt = Date.from(Instant.now().plusSeconds(3600)), expiresAt = Date.from(Instant.now().plusSeconds(3600)),
irreversible = false, irreversible = false,
wholeWord = true, wholeWord = true,
@ -94,12 +95,12 @@ class FilterV1Test {
FilterV1( FilterV1(
id = "123", id = "123",
phrase = "href", phrase = "href",
contexts = listOf(FilterContext.HOME), contexts = setOf(FilterContext.HOME),
expiresAt = null, expiresAt = null,
irreversible = false, irreversible = false,
wholeWord = false, wholeWord = false,
), ),
) ).map { Filter.from(it) }
filterModel = FilterModel(FilterContext.HOME, filters) filterModel = FilterModel(FilterContext.HOME, filters)
} }
@ -107,7 +108,7 @@ class FilterV1Test {
@Test @Test
fun shouldNotFilter() { fun shouldNotFilter() {
assertEquals( assertEquals(
Filter.Action.NONE, NetworkFilter.Action.NONE,
filterModel.filterActionFor( filterModel.filterActionFor(
mockStatus(content = "should not be filtered"), mockStatus(content = "should not be filtered"),
), ),
@ -117,7 +118,7 @@ class FilterV1Test {
@Test @Test
fun shouldFilter_whenContentMatchesBadWord() { fun shouldFilter_whenContentMatchesBadWord() {
assertEquals( assertEquals(
Filter.Action.HIDE, NetworkFilter.Action.HIDE,
filterModel.filterActionFor( filterModel.filterActionFor(
mockStatus(content = "one two badWord three"), mockStatus(content = "one two badWord three"),
), ),
@ -127,7 +128,7 @@ class FilterV1Test {
@Test @Test
fun shouldFilter_whenContentMatchesBadWordPart() { fun shouldFilter_whenContentMatchesBadWordPart() {
assertEquals( assertEquals(
Filter.Action.HIDE, NetworkFilter.Action.HIDE,
filterModel.filterActionFor( filterModel.filterActionFor(
mockStatus(content = "one two badWordPart three"), mockStatus(content = "one two badWordPart three"),
), ),
@ -137,7 +138,7 @@ class FilterV1Test {
@Test @Test
fun shouldFilter_whenContentMatchesBadWholeWord() { fun shouldFilter_whenContentMatchesBadWholeWord() {
assertEquals( assertEquals(
Filter.Action.HIDE, NetworkFilter.Action.HIDE,
filterModel.filterActionFor( filterModel.filterActionFor(
mockStatus(content = "one two badWholeWord three"), mockStatus(content = "one two badWholeWord three"),
), ),
@ -147,7 +148,7 @@ class FilterV1Test {
@Test @Test
fun shouldNotFilter_whenContentDoesNotMatchWholeWord() { fun shouldNotFilter_whenContentDoesNotMatchWholeWord() {
assertEquals( assertEquals(
Filter.Action.NONE, NetworkFilter.Action.NONE,
filterModel.filterActionFor( filterModel.filterActionFor(
mockStatus(content = "one two badWholeWordTest three"), mockStatus(content = "one two badWholeWordTest three"),
), ),
@ -157,7 +158,7 @@ class FilterV1Test {
@Test @Test
fun shouldFilter_whenSpoilerTextDoesMatch() { fun shouldFilter_whenSpoilerTextDoesMatch() {
assertEquals( assertEquals(
Filter.Action.HIDE, NetworkFilter.Action.HIDE,
filterModel.filterActionFor( filterModel.filterActionFor(
mockStatus( mockStatus(
content = "should not be filtered", content = "should not be filtered",
@ -170,7 +171,7 @@ class FilterV1Test {
@Test @Test
fun shouldFilter_whenPollTextDoesMatch() { fun shouldFilter_whenPollTextDoesMatch() {
assertEquals( assertEquals(
Filter.Action.HIDE, NetworkFilter.Action.HIDE,
filterModel.filterActionFor( filterModel.filterActionFor(
mockStatus( mockStatus(
content = "should not be filtered", content = "should not be filtered",
@ -184,7 +185,7 @@ class FilterV1Test {
@Test @Test
fun shouldFilter_whenMediaDescriptionDoesMatch() { fun shouldFilter_whenMediaDescriptionDoesMatch() {
assertEquals( assertEquals(
Filter.Action.HIDE, NetworkFilter.Action.HIDE,
filterModel.filterActionFor( filterModel.filterActionFor(
mockStatus( mockStatus(
content = "should not be filtered", content = "should not be filtered",
@ -198,7 +199,7 @@ class FilterV1Test {
@Test @Test
fun shouldFilterPartialWord_whenWholeWordFilterContainsNonAlphanumericCharacters() { fun shouldFilterPartialWord_whenWholeWordFilterContainsNonAlphanumericCharacters() {
assertEquals( assertEquals(
Filter.Action.HIDE, NetworkFilter.Action.HIDE,
filterModel.filterActionFor( filterModel.filterActionFor(
mockStatus(content = "one two someone@twitter.com three"), mockStatus(content = "one two someone@twitter.com three"),
), ),
@ -208,7 +209,7 @@ class FilterV1Test {
@Test @Test
fun shouldFilterHashtags() { fun shouldFilterHashtags() {
assertEquals( assertEquals(
Filter.Action.HIDE, NetworkFilter.Action.HIDE,
filterModel.filterActionFor( filterModel.filterActionFor(
mockStatus(content = "#hashtag one two three"), mockStatus(content = "#hashtag one two three"),
), ),
@ -218,7 +219,7 @@ class FilterV1Test {
@Test @Test
fun shouldFilterHashtags_whenContentIsMarkedUp() { fun shouldFilterHashtags_whenContentIsMarkedUp() {
assertEquals( assertEquals(
Filter.Action.HIDE, NetworkFilter.Action.HIDE,
filterModel.filterActionFor( 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>"), 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 @Test
fun shouldNotFilterHtmlAttributes() { fun shouldNotFilterHtmlAttributes() {
assertEquals( assertEquals(
Filter.Action.NONE, NetworkFilter.Action.NONE,
filterModel.filterActionFor( filterModel.filterActionFor(
mockStatus(content = "<p><a href=\"https://foo.bar/\">https://foo.bar/</a> one two three</p>"), mockStatus(content = "<p><a href=\"https://foo.bar/\">https://foo.bar/</a> one two three</p>"),
), ),
@ -238,7 +239,7 @@ class FilterV1Test {
@Test @Test
fun shouldNotFilter_whenFilterIsExpired() { fun shouldNotFilter_whenFilterIsExpired() {
assertEquals( assertEquals(
Filter.Action.NONE, NetworkFilter.Action.NONE,
filterModel.filterActionFor( filterModel.filterActionFor(
mockStatus(content = "content matching expired filter should not be filtered"), mockStatus(content = "content matching expired filter should not be filtered"),
), ),
@ -248,7 +249,7 @@ class FilterV1Test {
@Test @Test
fun shouldFilter_whenFilterIsUnexpired() { fun shouldFilter_whenFilterIsUnexpired() {
assertEquals( assertEquals(
Filter.Action.HIDE, NetworkFilter.Action.HIDE,
filterModel.filterActionFor( filterModel.filterActionFor(
mockStatus(content = "content matching unexpired filter should be filtered"), 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 package app.pachli.components.notifications
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import app.pachli.appstore.EventHub 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.accounts.AccountManager
import app.pachli.core.data.repository.AccountPreferenceDataStore 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.ServerRepository
import app.pachli.core.data.repository.StatusDisplayOptionsRepository import app.pachli.core.data.repository.StatusDisplayOptionsRepository
import app.pachli.core.database.model.AccountEntity 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.core.testing.rules.MainCoroutineRule
import app.pachli.usecase.TimelineCases import app.pachli.usecase.TimelineCases
import at.connyduck.calladapter.networkresult.NetworkResult 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.flow.MutableStateFlow
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope
import okhttp3.ResponseBody import okhttp3.ResponseBody
@ -103,8 +107,9 @@ abstract class NotificationsViewModelTestBase {
) )
timelineCases = mock() timelineCases = mock()
filtersRepository = mock { filtersRepository = mock {
onBlocking { getFilters() } doReturn FilterKind.V2(emptyList()) whenever(it.filters).thenReturn(MutableStateFlow<Result<Filters?, FiltersError.GetFiltersError>>(Ok(null)))
} }
sharedPreferencesRepository = SharedPreferencesRepository( sharedPreferencesRepository = SharedPreferencesRepository(
@ -149,6 +154,7 @@ abstract class NotificationsViewModelTestBase {
) )
viewModel = NotificationsViewModel( viewModel = NotificationsViewModel(
InstrumentationRegistry.getInstrumentation().targetContext,
notificationsRepository, notificationsRepository,
accountManager, accountManager,
timelineCases, timelineCases,

View File

@ -19,11 +19,13 @@ package app.pachli.components.timeline
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import app.pachli.PachliApplication import app.pachli.PachliApplication
import app.pachli.appstore.EventHub import app.pachli.appstore.EventHub
import app.pachli.components.timeline.viewmodel.CachedTimelineViewModel import app.pachli.components.timeline.viewmodel.CachedTimelineViewModel
import app.pachli.components.timeline.viewmodel.TimelineViewModel import app.pachli.components.timeline.viewmodel.TimelineViewModel
import app.pachli.core.accounts.AccountManager import app.pachli.core.accounts.AccountManager
import app.pachli.core.data.repository.FiltersRepository
import app.pachli.core.data.repository.StatusDisplayOptionsRepository import app.pachli.core.data.repository.StatusDisplayOptionsRepository
import app.pachli.core.model.Timeline import app.pachli.core.model.Timeline
import app.pachli.core.network.model.Account 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.network.retrofit.NodeInfoApi
import app.pachli.core.preferences.SharedPreferencesRepository import app.pachli.core.preferences.SharedPreferencesRepository
import app.pachli.core.testing.rules.MainCoroutineRule import app.pachli.core.testing.rules.MainCoroutineRule
import app.pachli.core.testing.success
import app.pachli.usecase.TimelineCases import app.pachli.usecase.TimelineCases
import at.connyduck.calladapter.networkresult.NetworkResult import at.connyduck.calladapter.networkresult.NetworkResult
import com.squareup.moshi.Moshi import com.squareup.moshi.Moshi
@ -113,7 +116,7 @@ abstract class CachedTimelineViewModelTestBase {
reset(mastodonApi) reset(mastodonApi)
mastodonApi.stub { mastodonApi.stub {
onBlocking { getCustomEmojis() } doReturn NetworkResult.failure(Exception()) onBlocking { getCustomEmojis() } doReturn NetworkResult.failure(Exception())
onBlocking { getFilters() } doReturn NetworkResult.success(emptyList()) onBlocking { getFilters() } doReturn success(emptyList())
} }
reset(nodeInfoApi) reset(nodeInfoApi)
@ -155,6 +158,7 @@ abstract class CachedTimelineViewModelTestBase {
timelineCases = mock() timelineCases = mock()
viewModel = CachedTimelineViewModel( viewModel = CachedTimelineViewModel(
InstrumentationRegistry.getInstrumentation().targetContext,
SavedStateHandle(mapOf(TimelineViewModel.TIMELINE_TAG to Timeline.Home)), SavedStateHandle(mapOf(TimelineViewModel.TIMELINE_TAG to Timeline.Home)),
cachedTimelineRepository, cachedTimelineRepository,
timelineCases, timelineCases,

View File

@ -19,10 +19,12 @@ package app.pachli.components.timeline
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import app.pachli.appstore.EventHub import app.pachli.appstore.EventHub
import app.pachli.components.timeline.viewmodel.NetworkTimelineViewModel import app.pachli.components.timeline.viewmodel.NetworkTimelineViewModel
import app.pachli.components.timeline.viewmodel.TimelineViewModel import app.pachli.components.timeline.viewmodel.TimelineViewModel
import app.pachli.core.accounts.AccountManager import app.pachli.core.accounts.AccountManager
import app.pachli.core.data.repository.FiltersRepository
import app.pachli.core.data.repository.StatusDisplayOptionsRepository import app.pachli.core.data.repository.StatusDisplayOptionsRepository
import app.pachli.core.model.Timeline import app.pachli.core.model.Timeline
import app.pachli.core.network.model.Account 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.network.retrofit.NodeInfoApi
import app.pachli.core.preferences.SharedPreferencesRepository import app.pachli.core.preferences.SharedPreferencesRepository
import app.pachli.core.testing.rules.MainCoroutineRule import app.pachli.core.testing.rules.MainCoroutineRule
import app.pachli.core.testing.success
import app.pachli.usecase.TimelineCases import app.pachli.usecase.TimelineCases
import app.pachli.util.HiltTestApplication_Application import app.pachli.util.HiltTestApplication_Application
import at.connyduck.calladapter.networkresult.NetworkResult import at.connyduck.calladapter.networkresult.NetworkResult
@ -103,7 +106,7 @@ abstract class NetworkTimelineViewModelTestBase {
reset(mastodonApi) reset(mastodonApi)
mastodonApi.stub { mastodonApi.stub {
onBlocking { getCustomEmojis() } doReturn NetworkResult.failure(Exception()) onBlocking { getCustomEmojis() } doReturn NetworkResult.failure(Exception())
onBlocking { getFilters() } doReturn NetworkResult.success(emptyList()) onBlocking { getFilters() } doReturn success(emptyList())
} }
reset(nodeInfoApi) reset(nodeInfoApi)
@ -145,6 +148,7 @@ abstract class NetworkTimelineViewModelTestBase {
timelineCases = mock() timelineCases = mock()
viewModel = NetworkTimelineViewModel( viewModel = NetworkTimelineViewModel(
InstrumentationRegistry.getInstrumentation().targetContext,
SavedStateHandle(mapOf(TimelineViewModel.TIMELINE_TAG to Timeline.Bookmarks)), SavedStateHandle(mapOf(TimelineViewModel.TIMELINE_TAG to Timeline.Bookmarks)),
networkTimelineRepository, networkTimelineRepository,
timelineCases, timelineCases,

View File

@ -9,11 +9,12 @@ import app.pachli.appstore.FavoriteEvent
import app.pachli.appstore.ReblogEvent import app.pachli.appstore.ReblogEvent
import app.pachli.components.compose.HiltTestApplication_Application import app.pachli.components.compose.HiltTestApplication_Application
import app.pachli.components.timeline.CachedTimelineRepository 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.mockStatus
import app.pachli.components.timeline.mockStatusViewData import app.pachli.components.timeline.mockStatusViewData
import app.pachli.core.accounts.AccountManager 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.data.repository.StatusDisplayOptionsRepository
import app.pachli.core.database.dao.TimelineDao import app.pachli.core.database.dao.TimelineDao
import app.pachli.core.database.model.AccountEntity 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.core.preferences.SharedPreferencesRepository
import app.pachli.usecase.TimelineCases import app.pachli.usecase.TimelineCases
import at.connyduck.calladapter.networkresult.NetworkResult import at.connyduck.calladapter.networkresult.NetworkResult
import com.github.michaelbull.result.Ok
import com.github.michaelbull.result.Result
import com.squareup.moshi.Moshi import com.squareup.moshi.Moshi
import dagger.hilt.android.testing.BindValue import dagger.hilt.android.testing.BindValue
import dagger.hilt.android.testing.CustomTestApplication import dagger.hilt.android.testing.CustomTestApplication
@ -35,6 +38,7 @@ import java.io.IOException
import java.time.Instant import java.time.Instant
import java.util.Date import java.util.Date
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
@ -47,6 +51,7 @@ import org.mockito.kotlin.doReturn
import org.mockito.kotlin.mock import org.mockito.kotlin.mock
import org.mockito.kotlin.reset import org.mockito.kotlin.reset
import org.mockito.kotlin.stub import org.mockito.kotlin.stub
import org.mockito.kotlin.whenever
import org.robolectric.annotation.Config import org.robolectric.annotation.Config
open class PachliHiltApplication : PachliApplication() open class PachliHiltApplication : PachliApplication()
@ -129,7 +134,7 @@ class ViewThreadViewModelTest {
reset(filtersRepository) reset(filtersRepository)
filtersRepository.stub { filtersRepository.stub {
onBlocking { getFilters() } doReturn FilterKind.V2(emptyList()) whenever(it.filters).thenReturn(MutableStateFlow<Result<Filters?, FiltersError.GetFiltersError>>(Ok(null)))
} }
reset(nodeInfoApi) 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 { plugins {
alias(libs.plugins.pachli.android.library) alias(libs.plugins.pachli.android.library)
alias(libs.plugins.pachli.android.hilt) alias(libs.plugins.pachli.android.hilt)
alias(libs.plugins.kotlin.parcelize)
} }
android { 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.Inject
import javax.inject.Singleton import javax.inject.Singleton
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
@ -65,18 +67,14 @@ class ServerRepository @Inject constructor(
private val accountManager: AccountManager, private val accountManager: AccountManager,
@ApplicationScope private val externalScope: CoroutineScope, @ApplicationScope private val externalScope: CoroutineScope,
) { ) {
private val _flow = MutableStateFlow<Result<Server?, Error>>(Ok(null)) private val reload = MutableSharedFlow<Unit>(replay = 1).apply { tryEmit(Unit) }
val flow = _flow.asStateFlow()
init { // SharedFlow, **not** StateFlow, to ensure a new value is emitted even if the
externalScope.launch { // user switches between accounts that are on the same server.
accountManager.activeAccountFlow.collect { _flow.emit(getServer()) } val flow = reload.combine(accountManager.activeAccountFlow) { _, _ -> getServer() }
} .shareIn(externalScope, SharingStarted.Lazily, replay = 1)
}
fun retry() = externalScope.launch { fun reload() = externalScope.launch { reload.emit(Unit) }
_flow.emit(getServer())
}
/** /**
* @return the server info or a [Server.Error] if the server info can not * @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_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_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="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> </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 { dependencies {
implementation(projects.core.data)
implementation(projects.core.database) // For DraftAttachment, used in ComposeOptions implementation(projects.core.database) // For DraftAttachment, used in ComposeOptions
implementation(projects.core.model) implementation(projects.core.model)
implementation(projects.core.network) // For Attachment, used in AttachmentViewData 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.content.Intent
import android.os.Parcelable import android.os.Parcelable
import androidx.core.content.IntentCompat import androidx.core.content.IntentCompat
import app.pachli.core.data.model.Filter
import app.pachli.core.database.model.DraftAttachment import app.pachli.core.database.model.DraftAttachment
import app.pachli.core.model.Timeline import app.pachli.core.model.Timeline
import app.pachli.core.navigation.LoginActivityIntent.LoginMode 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.publicFederated
import app.pachli.core.navigation.TimelineActivityIntent.Companion.publicLocal import app.pachli.core.navigation.TimelineActivityIntent.Companion.publicLocal
import app.pachli.core.network.model.Attachment 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.NewPoll
import app.pachli.core.network.model.Notification import app.pachli.core.network.model.Notification
import app.pachli.core.network.model.Status 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 context
* @param filter Optional filter to edit. If null an empty filter is created.
* @see [app.pachli.components.filters.EditFilterActivity] * @see [app.pachli.components.filters.EditFilterActivity]
*/ */
class EditFilterActivityIntent(context: Context, filter: Filter? = null) : Intent() { class EditFilterActivityIntent(context: Context) : Intent() {
init { init {
setClassName(context, QuadrantConstants.EDIT_FILTER_ACTIVITY) setClassName(context, QuadrantConstants.EDIT_FILTER_ACTIVITY)
filter?.let {
putExtra(EXTRA_FILTER_TO_EDIT, it)
}
} }
companion object { 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 */ /** @return the [Filter] passed in this intent, or null */
fun getFilter(intent: Intent) = IntentCompat.getParcelableExtra(intent, EXTRA_FILTER_TO_EDIT, Filter::class.java) 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( data class Filter(
val id: String = "", val id: String = "",
val title: 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 = "expires_at") val expiresAt: Date? = null,
@Json(name = "filter_action") val action: Action = Action.WARN, @Json(name = "filter_action") val action: Action = Action.WARN,
// This should not normally be empty. However, Mastodon does not include // This should not normally be empty. However, Mastodon does not include
@ -29,8 +29,8 @@ data class Filter(
@Json(name = "none") @Json(name = "none")
NONE, NONE,
@Json(name = "warn")
@Default @Default
@Json(name = "warn")
WARN, WARN,
@Json(name = "hide") @Json(name = "hide")

View File

@ -24,7 +24,7 @@ import java.util.Date
data class FilterV1( data class FilterV1(
val id: String, val id: String,
val phrase: 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?, @Json(name = "expires_at") val expiresAt: Date?,
val irreversible: Boolean, val irreversible: Boolean,
@Json(name = "whole_word") val wholeWord: Boolean, @Json(name = "whole_word") val wholeWord: Boolean,
@ -40,21 +40,12 @@ data class FilterV1(
val filter = other as FilterV1? val filter = other as FilterV1?
return filter?.id.equals(id) 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> suspend fun getInstanceV2(@Header(DOMAIN_HEADER) domain: String? = null): NetworkResult<InstanceV2>
@GET("api/v1/filters") @GET("api/v1/filters")
suspend fun getFiltersV1(): NetworkResult<List<FilterV1>> suspend fun getFiltersV1(): ApiResult<List<FilterV1>>
@GET("api/v2/filters") @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") @GET("api/v1/timelines/home")
@Throws(Exception::class) @Throws(Exception::class)
@ -612,59 +622,59 @@ interface MastodonApi {
@POST("api/v1/filters") @POST("api/v1/filters")
suspend fun createFilterV1( suspend fun createFilterV1(
@Field("phrase") phrase: String, @Field("phrase") phrase: String,
@Field("context[]") context: List<FilterContext>, @Field("context[]") context: Set<FilterContext>,
@Field("irreversible") irreversible: Boolean?, @Field("irreversible") irreversible: Boolean?,
@Field("whole_word") wholeWord: Boolean?, @Field("whole_word") wholeWord: Boolean?,
// String not Int because the empty string is used to represent "indefinite", // String not Int because the empty string is used to represent "indefinite",
// see https://github.com/mastodon/documentation/issues/1216#issuecomment-2030222940 // see https://github.com/mastodon/documentation/issues/1216#issuecomment-2030222940
@Field("expires_in") expiresInSeconds: String?, @Field("expires_in") expiresInSeconds: String?,
): NetworkResult<FilterV1> ): ApiResult<FilterV1>
@FormUrlEncoded @FormUrlEncoded
@PUT("api/v1/filters/{id}") @PUT("api/v1/filters/{id}")
suspend fun updateFilterV1( suspend fun updateFilterV1(
@Path("id") id: String, @Path("id") id: String,
@Field("phrase") phrase: String, @Field("phrase") phrase: String,
@Field("context[]") context: List<FilterContext>, @Field("context[]") contexts: Collection<FilterContext>,
@Field("irreversible") irreversible: Boolean?, @Field("irreversible") irreversible: Boolean?,
@Field("whole_word") wholeWord: Boolean?, @Field("whole_word") wholeWord: Boolean?,
// String not Int because the empty string is used to represent "indefinite", // String not Int because the empty string is used to represent "indefinite",
// see https://github.com/mastodon/documentation/issues/1216#issuecomment-2030222940 // see https://github.com/mastodon/documentation/issues/1216#issuecomment-2030222940
@Field("expires_in") expiresInSeconds: String?, @Field("expires_in") expiresInSeconds: String?,
): NetworkResult<FilterV1> ): ApiResult<FilterV1>
@DELETE("api/v1/filters/{id}") @DELETE("api/v1/filters/{id}")
suspend fun deleteFilterV1( suspend fun deleteFilterV1(
@Path("id") id: String, @Path("id") id: String,
): NetworkResult<ResponseBody> ): ApiResult<Unit>
@FormUrlEncoded @FormUrlEncoded
@POST("api/v2/filters") @POST("api/v2/filters")
suspend fun createFilter( suspend fun createFilter(
@Field("title") title: String, @Field("title") title: String,
@Field("context[]") context: List<FilterContext>, @Field("context[]") contexts: Set<FilterContext>,
@Field("filter_action") filterAction: Filter.Action, @Field("filter_action") filterAction: Filter.Action,
// String not Int because the empty string is used to represent "indefinite", // String not Int because the empty string is used to represent "indefinite",
// see https://github.com/mastodon/documentation/issues/1216#issuecomment-2030222940 // see https://github.com/mastodon/documentation/issues/1216#issuecomment-2030222940
@Field("expires_in") expiresInSeconds: String?, @Field("expires_in") expiresInSeconds: String?,
): NetworkResult<Filter> ): ApiResult<Filter>
@FormUrlEncoded @FormUrlEncoded
@PUT("api/v2/filters/{id}") @PUT("api/v2/filters/{id}")
suspend fun updateFilter( suspend fun updateFilter(
@Path("id") id: String, @Path("id") id: String,
@Field("title") title: String? = null, @Field("title") title: String? = null,
@Field("context[]") context: List<FilterContext>? = null, @Field("context[]") contexts: Collection<FilterContext>? = null,
@Field("filter_action") filterAction: Filter.Action? = null, @Field("filter_action") filterAction: Filter.Action? = null,
// String not Int because the empty string is used to represent "indefinite", // String not Int because the empty string is used to represent "indefinite",
// see https://github.com/mastodon/documentation/issues/1216#issuecomment-2030222940 // see https://github.com/mastodon/documentation/issues/1216#issuecomment-2030222940
@Field("expires_in") expiresInSeconds: String? = null, @Field("expires_in") expiresInSeconds: String? = null,
): NetworkResult<Filter> ): ApiResult<Filter>
@DELETE("api/v2/filters/{id}") @DELETE("api/v2/filters/{id}")
suspend fun deleteFilter( suspend fun deleteFilter(
@Path("id") id: String, @Path("id") id: String,
): NetworkResult<ResponseBody> ): ApiResult<Unit>
@FormUrlEncoded @FormUrlEncoded
@POST("api/v2/filters/{filterId}/keywords") @POST("api/v2/filters/{filterId}/keywords")
@ -672,7 +682,7 @@ interface MastodonApi {
@Path("filterId") filterId: String, @Path("filterId") filterId: String,
@Field("keyword") keyword: String, @Field("keyword") keyword: String,
@Field("whole_word") wholeWord: Boolean, @Field("whole_word") wholeWord: Boolean,
): NetworkResult<FilterKeyword> ): ApiResult<FilterKeyword>
@FormUrlEncoded @FormUrlEncoded
@PUT("api/v2/filters/keywords/{keywordId}") @PUT("api/v2/filters/keywords/{keywordId}")
@ -680,12 +690,12 @@ interface MastodonApi {
@Path("keywordId") keywordId: String, @Path("keywordId") keywordId: String,
@Field("keyword") keyword: String, @Field("keyword") keyword: String,
@Field("whole_word") wholeWord: Boolean, @Field("whole_word") wholeWord: Boolean,
): NetworkResult<FilterKeyword> ): ApiResult<FilterKeyword>
@DELETE("api/v2/filters/keywords/{keywordId}") @DELETE("api/v2/filters/keywords/{keywordId}")
suspend fun deleteFilterKeyword( suspend fun deleteFilterKeyword(
@Path("keywordId") keywordId: String, @Path("keywordId") keywordId: String,
): NetworkResult<ResponseBody> ): ApiResult<Unit>
@FormUrlEncoded @FormUrlEncoded
@POST("api/v1/polls/{id}/votes") @POST("api/v1/polls/{id}/votes")

View File

@ -17,13 +17,12 @@
package app.pachli.core.network.retrofit.apiresult package app.pachli.core.network.retrofit.apiresult
import app.pachli.core.testing.jsonError
import com.github.michaelbull.result.Err import com.github.michaelbull.result.Err
import com.github.michaelbull.result.unwrapError import com.github.michaelbull.result.unwrapError
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import java.io.IOException import java.io.IOException
import okhttp3.Headers import okhttp3.Headers
import okhttp3.Protocol
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.Assert.assertThrows import org.junit.Assert.assertThrows
import org.junit.Test import org.junit.Test
import retrofit2.Call import retrofit2.Call
@ -124,9 +123,9 @@ class ApiResultCallTest {
networkApiResultCall.enqueue( networkApiResultCall.enqueue(
object : Callback<ApiResult<String>> { object : Callback<ApiResult<String>> {
override fun onResponse(call: Call<ApiResult<String>>, response: Response<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).isInstanceOf(WrongContentType::class.java)
assertThat(error?.contentType).isEqualTo("text/html") assertThat((error as WrongContentType).contentType).isEqualTo("text/html")
assertThat(response.isSuccessful).isTrue() assertThat(response.isSuccessful).isTrue()
} }
@ -142,29 +141,18 @@ class ApiResultCallTest {
// properties then the error message should fall back to the HTTP error message. // properties then the error message should fall back to the HTTP error message.
@Test @Test
fun `should parse call with 404 error code as ApiResult-failure (no JSON)`() { fun `should parse call with 404 error code as ApiResult-failure (no JSON)`() {
val errorMsg = "dummy error message" val errorResponse = jsonError(404, "")
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(),
)
networkApiResultCall.enqueue( networkApiResultCall.enqueue(
object : Callback<ApiResult<String>> { object : Callback<ApiResult<String>> {
override fun onResponse(call: Call<ApiResult<String>>, response: Response<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) 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).isInstanceOf(HttpException::class.java)
assertThat(exception?.code()).isEqualTo(404) assertThat(exception.code()).isEqualTo(404)
assertThat(error?.formatArgs).isEqualTo(arrayOf("HTTP 404 $errorMsg")) assertThat(error.formatArgs).isEqualTo(arrayOf("HTTP 404 Not Found"))
} }
override fun onFailure(call: Call<ApiResult<String>>, t: Throwable) { override fun onFailure(call: Call<ApiResult<String>>, t: Throwable) {
@ -181,28 +169,18 @@ class ApiResultCallTest {
@Test @Test
fun `should parse call with 404 error code as ApiResult-failure (JSON error message)`() { fun `should parse call with 404 error code as ApiResult-failure (JSON error message)`() {
val errorMsg = "JSON error message" val errorMsg = "JSON error message"
val errorResponse = Response.error<String>( val errorResponse = jsonError(404, "{\"error\": \"$errorMsg\"}")
"{\"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(),
)
networkApiResultCall.enqueue( networkApiResultCall.enqueue(
object : Callback<ApiResult<String>> { object : Callback<ApiResult<String>> {
override fun onResponse(call: Call<ApiResult<String>>, response: Response<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) 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).isInstanceOf(HttpException::class.java)
assertThat(exception?.code()).isEqualTo(404) assertThat(exception.code()).isEqualTo(404)
assertThat(error?.formatArgs).isEqualTo(arrayOf(errorMsg)) assertThat(error.formatArgs).isEqualTo(arrayOf(errorMsg))
} }
override fun onFailure(call: Call<ApiResult<String>>, t: Throwable) { 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)`() { fun `should parse call with 404 error code as ApiResult-failure (JSON error and description message)`() {
val errorMsg = "JSON error message" val errorMsg = "JSON error message"
val descriptionMsg = "JSON error description" val descriptionMsg = "JSON error description"
val errorResponse = Response.error<String>( val errorResponse = jsonError(404, "{\"error\": \"$errorMsg\", \"description\": \"$descriptionMsg\"}")
"{\"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(),
)
networkApiResultCall.enqueue( networkApiResultCall.enqueue(
object : Callback<ApiResult<String>> { object : Callback<ApiResult<String>> {
override fun onResponse(call: Call<ApiResult<String>>, response: Response<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) 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).isInstanceOf(HttpException::class.java)
assertThat(exception?.code()).isEqualTo(404) assertThat(exception.code()).isEqualTo(404)
assertThat(error?.formatArgs).isEqualTo(arrayOf("$errorMsg: $descriptionMsg")) assertThat(error.formatArgs).isEqualTo(arrayOf("$errorMsg: $descriptionMsg"))
} }
override fun onFailure(call: Call<ApiResult<String>>, t: Throwable) { override fun onFailure(call: Call<ApiResult<String>>, t: Throwable) {

View File

@ -30,6 +30,7 @@ android {
dependencies { dependencies {
implementation(projects.core.common) implementation(projects.core.common)
implementation(projects.core.network)
api(libs.kotlinx.coroutines.test) api(libs.kotlinx.coroutines.test)
api(libs.androidx.test.junit) 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.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import app.pachli.core.common.extensions.mapIfInstance
import app.pachli.core.common.extensions.stateFlow import app.pachli.core.common.extensions.stateFlow
import app.pachli.core.data.model.StatusDisplayOptions import app.pachli.core.data.model.StatusDisplayOptions
import app.pachli.core.data.model.Suggestion 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
import app.pachli.feature.suggestions.UiAction.SuggestionAction.AcceptSuggestion import app.pachli.feature.suggestions.UiAction.SuggestionAction.AcceptSuggestion
import app.pachli.feature.suggestions.UiAction.SuggestionAction.DeleteSuggestion 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.Ok
import com.github.michaelbull.result.Result import com.github.michaelbull.result.Result
import com.github.michaelbull.result.mapEither import com.github.michaelbull.result.mapEither
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject import javax.inject.Inject
import kotlin.contracts.ExperimentalContracts
import kotlin.contracts.InvocationKind
import kotlin.contracts.contract
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
@ -256,20 +253,3 @@ internal class SuggestionsViewModel @Inject constructor(
return result 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
}
}