diff --git a/app/src/main/java/app/pachli/TimelineActivity.kt b/app/src/main/java/app/pachli/TimelineActivity.kt index 309adf6d0..ad778354b 100644 --- a/app/src/main/java/app/pachli/TimelineActivity.kt +++ b/app/src/main/java/app/pachli/TimelineActivity.kt @@ -26,33 +26,31 @@ import androidx.core.view.MenuProvider import androidx.fragment.app.commit import androidx.lifecycle.lifecycleScope import app.pachli.appstore.EventHub -import app.pachli.appstore.FilterChangedEvent import app.pachli.appstore.MainTabsChangedEvent import app.pachli.core.activity.BottomSheetActivity import app.pachli.core.common.extensions.viewBinding import app.pachli.core.common.util.unsafeLazy -import app.pachli.core.data.repository.ServerRepository +import app.pachli.core.data.model.Filter +import app.pachli.core.data.model.NewFilterKeyword +import app.pachli.core.data.repository.FilterEdit +import app.pachli.core.data.repository.FiltersRepository +import app.pachli.core.data.repository.NewFilter import app.pachli.core.model.Timeline import app.pachli.core.navigation.TimelineActivityIntent -import app.pachli.core.network.ServerOperation.ORG_JOINMASTODON_FILTERS_CLIENT -import app.pachli.core.network.ServerOperation.ORG_JOINMASTODON_FILTERS_SERVER -import app.pachli.core.network.model.Filter import app.pachli.core.network.model.FilterContext -import app.pachli.core.network.model.FilterV1 import app.pachli.databinding.ActivityTimelineBinding import app.pachli.interfaces.ActionButtonActivity import app.pachli.interfaces.AppBarLayoutHost import at.connyduck.calladapter.networkresult.fold -import com.github.michaelbull.result.getOrElse +import com.github.michaelbull.result.onFailure +import com.github.michaelbull.result.onSuccess import com.google.android.material.appbar.AppBarLayout import com.google.android.material.floatingactionbutton.FloatingActionButton import com.google.android.material.snackbar.Snackbar import dagger.hilt.android.AndroidEntryPoint -import io.github.z4kn4fein.semver.constraints.toConstraint import javax.inject.Inject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import retrofit2.HttpException import timber.log.Timber /** @@ -64,7 +62,7 @@ class TimelineActivity : BottomSheetActivity(), AppBarLayoutHost, ActionButtonAc lateinit var eventHub: EventHub @Inject - lateinit var serverRepository: ServerRepository + lateinit var filtersRepository: FiltersRepository private val binding: ActivityTimelineBinding by viewBinding(ActivityTimelineBinding::inflate) private lateinit var timeline: Timeline @@ -85,7 +83,6 @@ class TimelineActivity : BottomSheetActivity(), AppBarLayoutHost, ActionButtonAc private var unmuteTagItem: MenuItem? = null /** The filter muting hashtag, null if unknown or hashtag is not filtered */ - private var mutedFilterV1: FilterV1? = null private var mutedFilter: Filter? = null override fun onCreate(savedInstanceState: Bundle?) { @@ -238,14 +235,9 @@ class TimelineActivity : BottomSheetActivity(), AppBarLayoutHost, ActionButtonAc private fun updateMuteTagMenuItems() { val tagWithHash = hashtag?.let { "#$it" } ?: return - // If there's no server info, or the server can't filter then it's impossible - // to mute hashtags, so disable the functionality. - val server = serverRepository.flow.value.getOrElse { null } - if (server == null || ( - !server.can(ORG_JOINMASTODON_FILTERS_CLIENT, ">=1.0.0".toConstraint()) && - !server.can(ORG_JOINMASTODON_FILTERS_SERVER, ">=1.0.0".toConstraint()) - ) - ) { + // If the server can't filter then it's impossible to mute hashtags, so disable + // the functionality. + if (!filtersRepository.canFilter()) { muteTagItem?.isVisible = false unmuteTagItem?.isVisible = false return @@ -256,33 +248,15 @@ class TimelineActivity : BottomSheetActivity(), AppBarLayoutHost, ActionButtonAc unmuteTagItem?.isVisible = false lifecycleScope.launch { - mastodonApi.getFilters().fold( - { filters -> - mutedFilter = filters.firstOrNull { filter -> - filter.contexts.contains(FilterContext.HOME) && filter.keywords.any { - it.keyword == tagWithHash - } + filtersRepository.filters.collect { result -> + result.onSuccess { filters -> + mutedFilter = filters?.filters?.firstOrNull { filter -> + filter.contexts.contains(FilterContext.HOME) && + filter.keywords.any { it.keyword == tagWithHash } } updateTagMuteState(mutedFilter != null) - }, - { throwable -> - if (throwable is HttpException && throwable.code() == 404) { - mastodonApi.getFiltersV1().fold( - { filters -> - mutedFilterV1 = filters.firstOrNull { filter -> - tagWithHash == filter.phrase && filter.contexts.contains(FilterContext.HOME) - } - updateTagMuteState(mutedFilterV1 != null) - }, - { throwable -> - Timber.e(throwable, "Error getting filters") - }, - ) - } else { - Timber.e(throwable, "Error getting filters") - } - }, - ) + } + } } } @@ -298,108 +272,57 @@ class TimelineActivity : BottomSheetActivity(), AppBarLayoutHost, ActionButtonAc } } - private fun muteTag(): Boolean { - val tagWithHash = hashtag?.let { "#$it" } ?: return true + private fun muteTag() { + val tagWithHash = hashtag?.let { "#$it" } ?: return lifecycleScope.launch { - mastodonApi.createFilter( + val newFilter = NewFilter( title = tagWithHash, - context = listOf(FilterContext.HOME), - filterAction = Filter.Action.WARN, - expiresInSeconds = null, - ).fold( - { filter -> - if (mastodonApi.addFilterKeyword(filterId = filter.id, keyword = tagWithHash, wholeWord = true).isSuccess) { - mutedFilter = filter - updateTagMuteState(true) - eventHub.dispatch(FilterChangedEvent(filter.contexts[0])) - Snackbar.make(binding.root, getString(R.string.confirmation_hashtag_muted, hashtag), Snackbar.LENGTH_SHORT).show() - } else { - Snackbar.make(binding.root, getString(R.string.error_muting_hashtag_format, hashtag), Snackbar.LENGTH_SHORT).show() - Timber.e("Failed to mute %s", tagWithHash) - } - }, - { throwable -> - if (throwable is HttpException && throwable.code() == 404) { - mastodonApi.createFilterV1( - tagWithHash, - listOf(FilterContext.HOME), - irreversible = false, - 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) - } - }, + contexts = setOf(FilterContext.HOME), + action = app.pachli.core.network.model.Filter.Action.WARN, + expiresIn = 0, + keywords = listOf( + NewFilterKeyword( + keyword = tagWithHash, + wholeWord = true, + ), + ), ) - } - 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 { val tagWithHash = hashtag?.let { "#$it" } ?: return@launch - val result = if (mutedFilter != null) { - val filter = mutedFilter!! - if (filter.contexts.size > 1) { - // This filter exists in multiple contexts, just remove the home context - mastodonApi.updateFilter( - id = filter.id, - context = filter.contexts.filter { it != FilterContext.HOME }, - ) + val result = mutedFilter?.let { filter -> + val newContexts = filter.contexts.filter { it != FilterContext.HOME } + if (newContexts.isEmpty()) { + filtersRepository.deleteFilter(filter.id) } else { - mastodonApi.deleteFilter(filter.id) + filtersRepository.updateFilter(filter, FilterEdit(filter.id, contexts = newContexts)) } - } else if (mutedFilterV1 != null) { - mutedFilterV1?.let { filter -> - if (filter.contexts.size > 1) { - // This filter exists in multiple contexts, just remove the home context - mastodonApi.updateFilterV1( - id = filter.id, - phrase = filter.phrase, - context = filter.contexts.filter { it != FilterContext.HOME }, - irreversible = null, - wholeWord = null, - expiresInSeconds = null, - ) - } else { - mastodonApi.deleteFilterV1(filter.id) - } - } - } else { - null } - result?.fold( - { - updateTagMuteState(false) - Snackbar.make(binding.root, getString(R.string.confirmation_hashtag_unmuted, hashtag), Snackbar.LENGTH_SHORT).show() - eventHub.dispatch(FilterChangedEvent(FilterContext.HOME)) - mutedFilterV1 = null - mutedFilter = null - }, - { throwable -> - Snackbar.make(binding.root, getString(R.string.error_unmuting_hashtag_format, hashtag), Snackbar.LENGTH_SHORT).show() - Timber.e(throwable, "Failed to unmute %s", tagWithHash) - }, - ) + result?.onSuccess { + updateTagMuteState(false) + Snackbar.make(binding.root, getString(R.string.confirmation_hashtag_unmuted, hashtag), Snackbar.LENGTH_SHORT).show() + mutedFilter = null + }?.onFailure { e -> + Snackbar.make(binding.root, getString(R.string.error_unmuting_hashtag_format, hashtag), Snackbar.LENGTH_SHORT).show() + Timber.e("Failed to unmute %s: %s", tagWithHash, e.fmt(this@TimelineActivity)) + } } - - return true } } diff --git a/app/src/main/java/app/pachli/appstore/Events.kt b/app/src/main/java/app/pachli/appstore/Events.kt index 05e5823ee..3568f6af0 100644 --- a/app/src/main/java/app/pachli/appstore/Events.kt +++ b/app/src/main/java/app/pachli/appstore/Events.kt @@ -2,7 +2,6 @@ package app.pachli.appstore import app.pachli.core.model.Timeline import app.pachli.core.network.model.Account -import app.pachli.core.network.model.FilterContext import app.pachli.core.network.model.Poll import app.pachli.core.network.model.Status @@ -21,7 +20,6 @@ data class StatusComposedEvent(val status: Status) : Event data object StatusScheduledEvent : Event data class StatusEditedEvent(val originalId: String, val status: Status) : Event data class ProfileEditedEvent(val newProfileData: Account) : Event -data class FilterChangedEvent(val filterContext: FilterContext) : Event data class MainTabsChangedEvent(val newTabs: List) : Event data class PollVoteEvent(val statusId: String, val poll: Poll) : Event data class DomainMuteEvent(val instance: String) : Event diff --git a/app/src/main/java/app/pachli/components/filters/EditFilterActivity.kt b/app/src/main/java/app/pachli/components/filters/EditFilterActivity.kt index a885205c7..844735adf 100644 --- a/app/src/main/java/app/pachli/components/filters/EditFilterActivity.kt +++ b/app/src/main/java/app/pachli/components/filters/EditFilterActivity.kt @@ -1,59 +1,75 @@ package app.pachli.components.filters +import android.content.Context import android.content.DialogInterface.BUTTON_NEGATIVE import android.content.DialogInterface.BUTTON_POSITIVE import android.os.Bundle import android.view.View +import android.view.ViewGroup import android.widget.AdapterView import android.widget.ArrayAdapter +import android.widget.TextView import androidx.activity.OnBackPressedCallback import androidx.activity.viewModels import androidx.appcompat.app.AlertDialog import androidx.core.view.isVisible import androidx.core.view.size import androidx.core.widget.doAfterTextChanged +import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import app.pachli.R -import app.pachli.appstore.EventHub import app.pachli.core.activity.BaseActivity import app.pachli.core.common.extensions.viewBinding import app.pachli.core.common.extensions.visible +import app.pachli.core.data.model.FilterValidationError import app.pachli.core.navigation.EditFilterActivityIntent import app.pachli.core.network.model.Filter import app.pachli.core.network.model.FilterContext import app.pachli.core.network.model.FilterKeyword -import app.pachli.core.network.retrofit.MastodonApi import app.pachli.core.ui.extensions.await import app.pachli.databinding.ActivityEditFilterBinding import app.pachli.databinding.DialogFilterBinding -import at.connyduck.calladapter.networkresult.fold +import com.github.michaelbull.result.Result +import com.github.michaelbull.result.get +import com.github.michaelbull.result.onFailure +import com.github.michaelbull.result.onSuccess import com.google.android.material.chip.Chip import com.google.android.material.snackbar.Snackbar import com.google.android.material.switchmaterial.SwitchMaterial import dagger.hilt.android.AndroidEntryPoint -import javax.inject.Inject +import dagger.hilt.android.lifecycle.withCreationCallback import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.launch -import retrofit2.HttpException /** * Edit a single server-side filter. */ @AndroidEntryPoint class EditFilterActivity : BaseActivity() { - @Inject - lateinit var api: MastodonApi - - @Inject - lateinit var eventHub: EventHub - private val binding by viewBinding(ActivityEditFilterBinding::inflate) - private val viewModel: EditFilterViewModel by viewModels() - private lateinit var filter: Filter - private var originalFilter: Filter? = null + // Pass the optional filter and filterId values from the intent to + // EditFilterViewModel. + private val viewModel: EditFilterViewModel by viewModels( + extrasProducer = { + defaultViewModelCreationExtras.withCreationCallback { factory -> + factory.create( + EditFilterActivityIntent.getFilter(intent), + EditFilterActivityIntent.getFilterId(intent), + ) + } + }, + ) + + private lateinit var filterDurationAdapter: FilterDurationAdapter + private lateinit var filterContextSwitches: Map + /** The active snackbar */ + private var snackbar: Snackbar? = null + private val onBackPressedCallback = object : OnBackPressedCallback(true) { override fun handleOnBackPressed() { lifecycleScope.launch { @@ -66,8 +82,6 @@ class EditFilterActivity : BaseActivity() { super.onCreate(savedInstanceState) onBackPressedDispatcher.addCallback(onBackPressedCallback) - originalFilter = EditFilterActivityIntent.getFilter(intent) - filter = originalFilter ?: Filter() binding.apply { filterContextSwitches = mapOf( filterContextHome to FilterContext.HOME, @@ -86,21 +100,35 @@ class EditFilterActivity : BaseActivity() { } setTitle( - if (originalFilter == null) { - R.string.filter_addition_title - } else { - R.string.filter_edit_title + when (viewModel.uiMode) { + UiMode.CREATE -> R.string.filter_addition_title + UiMode.EDIT -> R.string.filter_edit_title }, ) binding.actionChip.setOnClickListener { showAddKeywordDialog() } + + filterDurationAdapter = FilterDurationAdapter(this, viewModel.uiMode) + binding.filterDurationSpinner.adapter = filterDurationAdapter + binding.filterDurationSpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { + override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) { + viewModel.setExpiresIn(filterDurationAdapter.getItem(position)!!.duration) + } + + override fun onNothingSelected(parent: AdapterView<*>?) { + viewModel.setExpiresIn(0) + } + } + binding.filterSaveButton.setOnClickListener { saveChanges() } binding.filterDeleteButton.setOnClickListener { lifecycleScope.launch { - if (showDeleteFilterDialog(filter.title) == BUTTON_POSITIVE) deleteFilter() + viewModel.filterViewData.value.get()?.let { + if (showDeleteFilterDialog(it.title) == BUTTON_POSITIVE) deleteFilter() + } } } - binding.filterDeleteButton.visible(originalFilter != null) + binding.filterDeleteButton.visible(viewModel.uiMode == UiMode.EDIT) for (switch in filterContextSwitches.keys) { switch.setOnCheckedChangeListener { _, isChecked -> @@ -108,7 +136,7 @@ class EditFilterActivity : BaseActivity() { if (isChecked) { viewModel.addContext(context) } else { - viewModel.removeContext(context) + viewModel.deleteContext(context) } } } @@ -116,95 +144,116 @@ class EditFilterActivity : BaseActivity() { viewModel.setTitle(editable.toString()) } binding.filterActionWarn.setOnCheckedChangeListener { _, checked -> - viewModel.setAction( - if (checked) { - Filter.Action.WARN - } else { - Filter.Action.HIDE - }, - ) - } - binding.filterDurationSpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { - override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) { - viewModel.setDuration( - if (originalFilter?.expiresAt == null) { - position - } else { - position - 1 - }, - ) - } - - override fun onNothingSelected(parent: AdapterView<*>?) { - viewModel.setDuration(0) - } + viewModel.setAction(if (checked) Filter.Action.WARN else Filter.Action.HIDE) } - loadFilter() - observeModel() + bind() } - private fun observeModel() { + private fun bind() { lifecycleScope.launch { - viewModel.title.collect { title -> - if (title != binding.filterTitle.text.toString()) { - // We also get this callback when typing in the field, - // which messes with the cursor focus - binding.filterTitle.setText(title) - } - } - } - lifecycleScope.launch { - viewModel.keywords.collect { keywords -> - updateKeywords(keywords) - } - } - lifecycleScope.launch { - viewModel.contexts.collect { contexts -> - for ((key, value) in filterContextSwitches) { - key.isChecked = contexts.contains(value) - } - } - } - lifecycleScope.launch { - viewModel.action.collect { action -> - when (action) { - Filter.Action.HIDE -> binding.filterActionHide.isChecked = true - else -> binding.filterActionWarn.isChecked = true - } - } - } + repeatOnLifecycle(Lifecycle.State.RESUMED) { + launch { viewModel.uiResult.collect(::bindUiResult) } - lifecycleScope.launch { - viewModel.isDirty.collectLatest { onBackPressedCallback.isEnabled = it } - } + launch { viewModel.filterViewData.collect(::bindFilter) } - lifecycleScope.launch { - viewModel.validationErrors.collectLatest { errors -> - binding.filterSaveButton.isEnabled = errors.isEmpty() + launch { viewModel.isDirty.collectLatest { onBackPressedCallback.isEnabled = it } } - binding.filterTitleWrapper.error = if (errors.contains(FilterValidationError.NO_TITLE)) { - getString(R.string.error_filter_missing_title) - } else { - null + launch { + viewModel.validationErrors.collectLatest { errors -> + binding.filterTitleWrapper.error = if (errors.contains(FilterValidationError.NO_TITLE)) { + 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) - binding.filterContextError.isVisible = errors.contains(FilterValidationError.NO_CONTEXT) + launch { + viewModel.isDirty.combine(viewModel.validationErrors) { dirty, errors -> + dirty && errors.isEmpty() + }.collectLatest { binding.filterSaveButton.isEnabled = it } + } } } } - // Populate the UI from the filter's members - private fun loadFilter() { - viewModel.load(filter) - if (filter.expiresAt != null) { - val durationNames = listOf(getString(R.string.duration_no_change)) + resources.getStringArray(R.array.filter_duration_names) - binding.filterDurationSpinner.adapter = ArrayAdapter(this, android.R.layout.simple_list_item_1, durationNames) + /** Act on the result of UI actions */ + private fun bindUiResult(uiResult: Result) { + uiResult.onFailure(::bindUiError) + uiResult.onSuccess { uiSuccess -> + when (uiSuccess) { + UiSuccess.SaveFilter -> finish() + UiSuccess.DeleteFilter -> finish() + } } } - private fun updateKeywords(newKeywords: List) { + 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) { + 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) { newKeywords.forEachIndexed { index, filterKeyword -> val chip = binding.keywordChips.getChildAt(index).takeUnless { it.id == R.id.actionChip @@ -234,8 +283,6 @@ class EditFilterActivity : BaseActivity() { while (binding.keywordChips.size - 1 > newKeywords.size) { binding.keywordChips.removeViewAt(newKeywords.size) } - - filter = filter.copy(keywords = newKeywords) } private fun showAddKeywordDialog() { @@ -266,7 +313,7 @@ class EditFilterActivity : BaseActivity() { .setTitle(R.string.filter_edit_keyword_title) .setView(binding.root) .setPositiveButton(R.string.filter_dialog_update_button) { _, _ -> - viewModel.modifyKeyword( + viewModel.updateKeyword( keyword, keyword.copy( keyword = binding.phraseEditText.text.toString(), @@ -291,41 +338,57 @@ class EditFilterActivity : BaseActivity() { .create() .await(R.string.action_continue_edit, R.string.action_discard) - private fun saveChanges() { - // TODO use a progress bar here (see EditProfileActivity/activity_edit_profile.xml for example)? + // TODO use a progress bar here (see EditProfileActivity/activity_edit_profile.xml for example)? + private fun saveChanges() = viewModel.saveChanges() - lifecycleScope.launch { - if (viewModel.saveChanges(this@EditFilterActivity)) { - finish() - } else { - Snackbar.make(binding.root, "Error saving filter '${viewModel.title.value}'", Snackbar.LENGTH_SHORT).show() - } + private fun deleteFilter() = viewModel.deleteFilter() +} + +data class FilterDuration( + /** Filter duration, in seconds. -1 means no change, 0 means indefinite. */ + val duration: Int, + /** Label to use for this duration. */ + val label: String, +) + +/** + * Displays [FilterDuration] derived from R.array.filter_duration_values and + * R.array.filter_duration_labels. + * + * In addition, if [uiMode] is [UiMode.EDIT] an extra duration corresponding to + * "no change" is included in the list of possible values. + */ +class FilterDurationAdapter(context: Context, uiMode: UiMode) : ArrayAdapter( + context, + android.R.layout.simple_list_item_1, +) { + val items = buildList { + if (uiMode == UiMode.EDIT) { + add(FilterDuration(-1, context.getString(R.string.duration_no_change))) + } + + val values = context.resources.getIntArray(R.array.filter_duration_values) + val labels = context.resources.getStringArray(R.array.filter_duration_labels) + assert(values.size == labels.size) + + values.zip(labels) { value, label -> + add(FilterDuration(duration = value, label = label)) } } - private fun deleteFilter() { - originalFilter?.let { filter -> - lifecycleScope.launch { - api.deleteFilter(filter.id).fold( - { - finish() - }, - { throwable -> - if (throwable is HttpException && throwable.code() == 404) { - api.deleteFilterV1(filter.id).fold( - { - finish() - }, - { - Snackbar.make(binding.root, "Error deleting filter '${filter.title}'", Snackbar.LENGTH_SHORT).show() - }, - ) - } else { - Snackbar.make(binding.root, "Error deleting filter '${filter.title}'", Snackbar.LENGTH_SHORT).show() - } - }, - ) - } - } + init { + addAll(items) + } + + override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { + val view = super.getView(position, convertView, parent) + getItem(position)?.let { item -> (view as? TextView)?.text = item.label } + return view + } + + override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup): View { + val view = super.getDropDownView(position, convertView, parent) + getItem(position)?.let { item -> (view as? TextView)?.text = item.label } + return view } } diff --git a/app/src/main/java/app/pachli/components/filters/EditFilterViewModel.kt b/app/src/main/java/app/pachli/components/filters/EditFilterViewModel.kt index 28a11c649..d8c587e9f 100644 --- a/app/src/main/java/app/pachli/components/filters/EditFilterViewModel.kt +++ b/app/src/main/java/app/pachli/components/filters/EditFilterViewModel.kt @@ -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 . + */ + package app.pachli.components.filters import android.content.Context +import androidx.annotation.StringRes import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import app.pachli.R -import app.pachli.appstore.EventHub -import app.pachli.appstore.FilterChangedEvent -import app.pachli.core.network.model.Filter +import app.pachli.components.filters.UiError.DeleteFilterError +import app.pachli.components.filters.UiError.SaveFilterError +import app.pachli.core.common.PachliError +import app.pachli.core.common.extensions.mapIfNotNull +import app.pachli.core.data.model.Filter +import app.pachli.core.data.model.FilterValidationError +import app.pachli.core.data.model.NewFilterKeyword +import app.pachli.core.data.repository.FilterEdit +import app.pachli.core.data.repository.FiltersRepository +import app.pachli.core.data.repository.NewFilter +import app.pachli.core.network.model.Filter as NetworkFilter import app.pachli.core.network.model.FilterContext import app.pachli.core.network.model.FilterKeyword -import app.pachli.core.network.retrofit.MastodonApi -import at.connyduck.calladapter.networkresult.fold +import com.github.michaelbull.result.Err +import com.github.michaelbull.result.Ok +import com.github.michaelbull.result.Result +import com.github.michaelbull.result.get +import com.github.michaelbull.result.map +import com.github.michaelbull.result.mapEither +import com.github.michaelbull.result.mapError +import com.github.michaelbull.result.onFailure +import com.github.michaelbull.result.onSuccess +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import java.util.Date -import javax.inject.Inject +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.withContext -import retrofit2.HttpException +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.onSubscription +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch -@HiltViewModel -class EditFilterViewModel @Inject constructor(val api: MastodonApi, val eventHub: EventHub) : ViewModel() { - private lateinit var originalFilter: Filter - val title = MutableStateFlow("") - val keywords = MutableStateFlow(listOf()) - val action = MutableStateFlow(Filter.Action.WARN) - val duration = MutableStateFlow(0) - val contexts = MutableStateFlow(listOf()) - - /** Track whether the duration has been modified, for use in [onChange] */ - // TODO: Rethink how duration is shown in the UI. - // Could show the actual end time with the date/time widget to set the duration, - // along with dropdown for quick settings (1h, etc). - private var durationIsDirty = false - - private val _isDirty = MutableStateFlow(false) - - /** True if the user has made unsaved changes to the filter */ - val isDirty = _isDirty.asStateFlow() - - private val _validationErrors = MutableStateFlow(emptySet()) - - /** True if the filter is valid and can be saved */ - val validationErrors = _validationErrors.asStateFlow() - - fun load(filter: Filter) { - originalFilter = filter - title.value = filter.title - keywords.value = filter.keywords - action.value = filter.action - duration.value = if (filter.expiresAt == null) { - 0 - } else { - -1 - } - contexts.value = filter.contexts - } - - fun addKeyword(keyword: FilterKeyword) { - keywords.value += keyword - onChange() - } - - fun deleteKeyword(keyword: FilterKeyword) { - keywords.value = keywords.value.filterNot { it == keyword } - onChange() - } - - fun modifyKeyword(original: FilterKeyword, updated: FilterKeyword) { - val index = keywords.value.indexOf(original) - if (index >= 0) { - keywords.value = keywords.value.toMutableList().apply { - set(index, updated) - } - onChange() - } - } - - fun setTitle(title: String) { - this.title.value = title - onChange() - } - - fun setDuration(index: Int) { - if (!durationIsDirty && duration.value != index) durationIsDirty = true - - duration.value = index - onChange() - } - - fun setAction(action: Filter.Action) { - this.action.value = action - onChange() - } - - fun addContext(filterContext: FilterContext) { - if (!contexts.value.contains(filterContext)) { - contexts.value += filterContext - onChange() - } - } - - fun removeContext(filterContext: FilterContext) { - contexts.value = contexts.value.filter { it != filterContext } - onChange() - } - - private fun validate() { - _validationErrors.value = buildSet { - if (title.value.isBlank()) add(FilterValidationError.NO_TITLE) - if (keywords.value.isEmpty()) add(FilterValidationError.NO_KEYWORDS) - if (contexts.value.isEmpty()) add(FilterValidationError.NO_CONTEXT) - } +/** + * Data to show the filter in the UI. + */ +data class FilterViewData( + /** Filter's ID. Null if this is a new, un-saved filter. */ + val id: String? = null, + val title: String = "", + val contexts: Set = emptySet(), + /** + * The number of seconds in the future the filter should expire. + * "-1" means "use the filter's current value". + * "0" means "filter never expires". + */ + val expiresIn: Int = 0, + val action: NetworkFilter.Action = NetworkFilter.Action.WARN, + val keywords: List = emptyList(), +) { + /** + * @return Set of [FilterValidationError] given the current state of the + * filter. Empty if there are no validation errors. + */ + fun validate() = buildSet { + if (title.isBlank()) add(FilterValidationError.NO_TITLE) + if (keywords.isEmpty()) add(FilterValidationError.NO_KEYWORDS) + if (contexts.isEmpty()) add(FilterValidationError.NO_CONTEXT) } /** - * Call when the contents of the filter change; recalculates validity - * and dirty state. + * Calculates the difference between [filter] and `this`, returning an + * [FilterEdit] that representes the differences. */ - private fun onChange() { - validate() + fun diff(filter: Filter): FilterEdit { + val title: String? = if (title != filter.title) title else null + val contexts = if (contexts != filter.contexts) contexts else null + val action = if (action != filter.action) action else null - if (durationIsDirty) { + // Keywords to delete + val (keywordsToAdd, existingKeywords) = keywords.partition { it.id == "" } + val existingKeywordsMap = existingKeywords.associateBy { it.id } + + // Delete any keywords that are in the original list but are not in the existing + // keywords here. + val keywordsToDelete = filter.keywords.filter { !existingKeywordsMap.contains(it.id) } + + // Any keywords that are in the original filter and this one, but have different + // values need to be modified. + val keywordsToModify = buildList { + val originalKeywords = filter.keywords.associateBy { it.id } + originalKeywords.forEach { + val originalKeyword = it.value + + existingKeywordsMap[originalKeyword.id]?.let { existingKeyword -> + if (existingKeyword != originalKeyword) add(existingKeyword) + } + } + } + + return FilterEdit( + id = filter.id, + title = title, + contexts = contexts, + expiresIn = this.expiresIn, + action = action, + keywordsToDelete = keywordsToDelete.ifEmpty { null }, + keywordsToModify = keywordsToModify.ifEmpty { null }, + keywordsToAdd = keywordsToAdd.ifEmpty { null }, + ) + } + + companion object { + fun from(filter: Filter) = FilterViewData( + id = filter.id, + title = filter.title, + contexts = filter.contexts, + expiresIn = -1, + action = filter.action, + keywords = filter.keywords, + ) + } +} + +fun NewFilter.Companion.from(filterViewData: FilterViewData) = NewFilter( + title = filterViewData.title, + contexts = filterViewData.contexts, + expiresIn = filterViewData.expiresIn, + action = filterViewData.action, + keywords = filterViewData.keywords.map { + NewFilterKeyword( + keyword = it.keyword, + wholeWord = it.wholeWord, + ) + }, +) + +/** Successful UI operations. */ +sealed interface UiSuccess { + /** Filter was saved. */ + data object SaveFilter : UiSuccess + + /** Filter was deleted. */ + data object DeleteFilter : UiSuccess +} + +/** Errors that can occur from actions the user takes in the UI. */ +sealed class UiError( + @StringRes override val resourceId: Int, + override val formatArgs: Array? = 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()) + val validationErrors = _validationErrors.asStateFlow() + + private val _uiResult = Channel>() + val uiResult = _uiResult.receiveAsFlow() + + private var _filterViewData = MutableSharedFlow>() + val filterViewData = _filterViewData + .onSubscription { + filter?.let { + originalFilter = it + emit(Ok(FilterViewData.from(it))) + return@onSubscription + } + + emit( + filterId?.let { + filtersRepository.getFilter(filterId) + .onSuccess { + originalFilter = it + }.mapEither( + { FilterViewData.from(it) }, + { UiError.GetFilterError(filterId, it) }, + ) + } ?: Ok(FilterViewData()), + ) + }.onEach { it.onSuccess { it?.let { onChange(it) } } } + .stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5000), + initialValue = Ok(null), + ) + + /** Reload the filter, if [filterId] is non-null. */ + fun reload() = viewModelScope.launch { + filterId ?: return@launch _filterViewData.emit(Ok(FilterViewData())) + + _filterViewData.emit( + filtersRepository.getFilter(filterId) + .onSuccess { originalFilter = it } + .mapEither( + { FilterViewData.from(it) }, + { UiError.GetFilterError(filterId, it) }, + ), + ) + } + + /** Adds [keyword] to [filterViewData]. */ + fun addKeyword(keyword: FilterKeyword) = viewModelScope.launch { + _filterViewData.emit( + filterViewData.value.mapIfNotNull { + it.copy(keywords = it.keywords + keyword) + }, + ) + } + + /** Deletes [keyword] from [filterViewData]. */ + fun deleteKeyword(keyword: FilterKeyword) = viewModelScope.launch { + _filterViewData.emit( + filterViewData.value.mapIfNotNull { + it.copy(keywords = it.keywords.filterNot { it == keyword }) + }, + ) + } + + /** Replaces [original] keyword in [filterViewData] with [newKeyword]. */ + fun updateKeyword(original: FilterKeyword, newKeyword: FilterKeyword) = viewModelScope.launch { + _filterViewData.emit( + filterViewData.value.mapIfNotNull { + it.copy( + keywords = it.keywords.map { + if (it == original) newKeyword else it + }, + ) + }, + ) + } + + /** Replaces [filterViewData]'s [title][FilterViewData.title] with [title]. */ + fun setTitle(title: String) = viewModelScope.launch { + _filterViewData.emit( + filterViewData.value.mapIfNotNull { + it.copy(title = title) + }, + ) + } + + /** Replaces [filterViewData]'s [expiresIn][FilterViewData.expiresIn] with [expiresIn]. */ + fun setExpiresIn(expiresIn: Int) = viewModelScope.launch { + _filterViewData.emit( + filterViewData.value.mapIfNotNull { + it.copy(expiresIn = expiresIn) + }, + ) + } + + /** Replaces [filterViewData]'s [action][FilterViewData.action] with [action]. */ + fun setAction(action: NetworkFilter.Action) = viewModelScope.launch { + _filterViewData.emit( + filterViewData.value.mapIfNotNull { + it.copy(action = action) + }, + ) + } + + /** Adds [filterContext] to [filterViewData]'s [contexts][FilterViewData.contexts]. */ + fun addContext(filterContext: FilterContext) = viewModelScope.launch { + filterViewData.value.get()?.let { filter -> + if (filter.contexts.contains(filterContext)) return@launch + + _filterViewData.emit(Ok(filter.copy(contexts = filter.contexts + filterContext))) + } + } + + /** Deletes [filterContext] from [filterViewData]'s [contexts][FilterViewData.contexts]. */ + fun deleteContext(filterContext: FilterContext) = viewModelScope.launch { + filterViewData.value.get()?.let { filter -> + if (!filter.contexts.contains(filterContext)) return@launch + + _filterViewData.emit(Ok(filter.copy(contexts = filter.contexts - filterContext))) + } + } + + /** Recalculates validity and dirty state. */ + private fun onChange(filterViewData: FilterViewData) { + _validationErrors.update { filterViewData.validate() } + + if (filterViewData.expiresIn != -1) { _isDirty.value = true return } _isDirty.value = when { - originalFilter.title != title.value -> true - originalFilter.contexts != contexts.value -> true - originalFilter.action != action.value -> true - originalFilter.keywords.toSet() != keywords.value.toSet() -> true + originalFilter?.title != filterViewData.title -> true + originalFilter?.contexts != filterViewData.contexts -> true + originalFilter?.action != filterViewData.action -> true + originalFilter?.keywords?.toSet() != filterViewData.keywords.toSet() -> true else -> false } } - suspend fun saveChanges(context: Context): Boolean { - val contexts = contexts.value - val title = title.value - val durationIndex = duration.value - val action = action.value + /** + * Saves [filterViewData], either by creating a new filter or updating the + * existing filter. + */ + fun saveChanges() = viewModelScope.launch { + val filterViewData = filterViewData.value.get() ?: return@launch - return withContext(viewModelScope.coroutineContext) { - val success = if (originalFilter.id == "") { - createFilter(title, contexts, action, durationIndex, context) - } else { - updateFilter(originalFilter, title, contexts, action, durationIndex, context) + _uiResult.send( + when (uiMode) { + UiMode.CREATE -> createFilter(filterViewData) + UiMode.EDIT -> updateFilter(filterViewData) } - - // Send FilterChangedEvent for old and new contexts, to ensure that - // e.g., removing a filter from "home" still notifies anything showing - // the home timeline, so the timeline can be refreshed. - if (success) { - val originalContexts = originalFilter.contexts - val newFilterContexts = contexts - (originalContexts + newFilterContexts).distinct().forEach { - eventHub.dispatch(FilterChangedEvent(it)) - } - } - return@withContext success - } - } - - private suspend fun createFilter(title: String, contexts: List, action: Filter.Action, durationIndex: Int, context: Context): Boolean { - val expiresInSeconds = getSecondsForDurationIndex(durationIndex, context) - api.createFilter( - title = title, - context = contexts, - filterAction = action, - expiresInSeconds = expiresInSeconds, - ).fold( - { newFilter -> - // This is _terrible_, but the all-in-one update filter api Just Doesn't Work - return keywords.value.map { keyword -> - api.addFilterKeyword(filterId = newFilter.id, keyword = keyword.keyword, wholeWord = keyword.wholeWord) - }.none { it.isFailure } - }, - { throwable -> - return ( - throwable is HttpException && throwable.code() == 404 && - // Endpoint not found, fall back to v1 api - createFilterV1(contexts, expiresInSeconds) - ) - }, + .map { UiSuccess.SaveFilter }, ) } - private suspend fun updateFilter(originalFilter: Filter, title: String, contexts: List, action: Filter.Action, durationIndex: Int, context: Context): Boolean { - val expiresInSeconds = getSecondsForDurationIndex(durationIndex, context) - api.updateFilter( - id = originalFilter.id, - title = title, - context = contexts, - filterAction = action, - expiresInSeconds = expiresInSeconds, - ).fold( - { - // This is _terrible_, but the all-in-one update filter api Just Doesn't Work - val results = keywords.value.map { keyword -> - if (keyword.id.isEmpty()) { - api.addFilterKeyword(filterId = originalFilter.id, keyword = keyword.keyword, wholeWord = keyword.wholeWord) - } else { - api.updateFilterKeyword(keywordId = keyword.id, keyword = keyword.keyword, wholeWord = keyword.wholeWord) - } - } + originalFilter.keywords.filter { keyword -> - // Deleted keywords - keywords.value.none { it.id == keyword.id } - }.map { api.deleteFilterKeyword(it.id) } - - return results.none { it.isFailure } - }, - { throwable -> - if (throwable is HttpException && throwable.code() == 404) { - // Endpoint not found, fall back to v1 api - if (updateFilterV1(contexts, expiresInSeconds)) { - return true - } - } - return false - }, - ) + /** Create a new filter from [filterViewData]. */ + private suspend fun createFilter(filterViewData: FilterViewData): Result { + return filtersRepository.createFilter(NewFilter.from(filterViewData)) + .mapError { SaveFilterError(it) } } - private suspend fun createFilterV1(contexts: List, expiresInSeconds: String?): Boolean { - return keywords.value.map { keyword -> - api.createFilterV1(keyword.keyword, contexts, false, keyword.wholeWord, expiresInSeconds) - }.none { it.isFailure } + /** Persists the changes to [filterViewData]. */ + private suspend fun updateFilter(filterViewData: FilterViewData): Result { + return filtersRepository.updateFilter(originalFilter!!, filterViewData.diff(originalFilter!!)) + .mapError { SaveFilterError(it) } } - private suspend fun updateFilterV1(contexts: List, expiresInSeconds: String?): Boolean { - val results = keywords.value.map { keyword -> - if (originalFilter.id == "") { - api.createFilterV1( - phrase = keyword.keyword, - context = contexts, - irreversible = false, - wholeWord = keyword.wholeWord, - expiresInSeconds = expiresInSeconds, - ) - } else { - api.updateFilterV1( - id = originalFilter.id, - phrase = keyword.keyword, - context = contexts, - irreversible = false, - wholeWord = keyword.wholeWord, - expiresInSeconds = expiresInSeconds, - ) - } - } - // Don't handle deleted keywords here because there's only one keyword per v1 filter anyway + /** Delete [filterViewData]. */ + fun deleteFilter() = viewModelScope.launch { + val filterViewData = filterViewData.value.get() ?: return@launch - return results.none { it.isFailure } + // TODO: Check for non-null, or have a type that makes this impossible. + filtersRepository.deleteFilter(filterViewData.id!!) + .onSuccess { _uiResult.send(Ok(UiSuccess.DeleteFilter)) } + .onFailure { _uiResult.send(Err(DeleteFilterError(it))) } + } + + @AssistedFactory + interface Factory { + /** + * Creates [EditFilterViewModel], passing optional [filter] and + * [filterId] parameters. + * + * @see EditFilterViewModel + */ + fun create(filter: Filter?, filterId: String?): EditFilterViewModel } companion object { diff --git a/app/src/main/java/app/pachli/components/filters/FilterExtensions.kt b/app/src/main/java/app/pachli/components/filters/FilterExtensions.kt index 6fea2f4d4..0fa5c7415 100644 --- a/app/src/main/java/app/pachli/components/filters/FilterExtensions.kt +++ b/app/src/main/java/app/pachli/components/filters/FilterExtensions.kt @@ -18,10 +18,8 @@ package app.pachli.components.filters import android.app.Activity -import androidx.annotation.StringRes import androidx.appcompat.app.AlertDialog import app.pachli.R -import app.pachli.core.network.model.Filter import app.pachli.core.ui.extensions.await internal suspend fun Activity.showDeleteFilterDialog(filterTitle: String) = AlertDialog.Builder(this) @@ -29,36 +27,3 @@ internal suspend fun Activity.showDeleteFilterDialog(filterTitle: String) = Aler .setCancelable(true) .create() .await(R.string.dialog_delete_filter_positive_action, android.R.string.cancel) - -/** Reasons why a filter might be invalid */ -enum class FilterValidationError { - /** Filter title is empty or blank */ - NO_TITLE, - - /** Filter has no keywords */ - NO_KEYWORDS, - - /** Filter has no contexts */ - NO_CONTEXT, -} - -/** - * @return Set of validation errors for this filter, empty set if there - * are no errors. - */ -fun Filter.validate() = buildSet { - if (title.isBlank()) add(FilterValidationError.NO_TITLE) - if (keywords.isEmpty()) add(FilterValidationError.NO_KEYWORDS) - if (contexts.isEmpty()) add(FilterValidationError.NO_CONTEXT) -} - -/** - * @return String resource containing an error message for this - * validation error. - */ -@StringRes -fun FilterValidationError.stringResource() = when (this) { - FilterValidationError.NO_TITLE -> R.string.error_filter_missing_title - FilterValidationError.NO_KEYWORDS -> R.string.error_filter_missing_keyword - FilterValidationError.NO_CONTEXT -> R.string.error_filter_missing_context -} diff --git a/app/src/main/java/app/pachli/components/filters/FiltersActivity.kt b/app/src/main/java/app/pachli/components/filters/FiltersActivity.kt index 500bd879a..7e0512280 100644 --- a/app/src/main/java/app/pachli/components/filters/FiltersActivity.kt +++ b/app/src/main/java/app/pachli/components/filters/FiltersActivity.kt @@ -12,8 +12,8 @@ import app.pachli.core.common.extensions.hide import app.pachli.core.common.extensions.show import app.pachli.core.common.extensions.viewBinding import app.pachli.core.common.extensions.visible +import app.pachli.core.data.model.Filter import app.pachli.core.navigation.EditFilterActivityIntent -import app.pachli.core.network.model.Filter import app.pachli.core.ui.BackgroundMessage import app.pachli.databinding.ActivityFiltersBinding import com.google.android.material.color.MaterialColors @@ -94,7 +94,9 @@ class FiltersActivity : BaseActivity(), FiltersListener { } private fun launchEditFilterActivity(filter: Filter? = null) { - val intent = EditFilterActivityIntent(this, filter) + val intent = filter?.let { + EditFilterActivityIntent.edit(this, filter) + } ?: EditFilterActivityIntent(this) startActivityWithTransition(intent, TransitionKind.SLIDE_FROM_END) } diff --git a/app/src/main/java/app/pachli/components/filters/FiltersAdapter.kt b/app/src/main/java/app/pachli/components/filters/FiltersAdapter.kt index 99f6a5b75..0dd147944 100644 --- a/app/src/main/java/app/pachli/components/filters/FiltersAdapter.kt +++ b/app/src/main/java/app/pachli/components/filters/FiltersAdapter.kt @@ -2,9 +2,11 @@ package app.pachli.components.filters import android.view.LayoutInflater import android.view.ViewGroup +import androidx.annotation.StringRes import androidx.recyclerview.widget.RecyclerView import app.pachli.R -import app.pachli.core.network.model.Filter +import app.pachli.core.data.model.Filter +import app.pachli.core.data.model.FilterValidationError import app.pachli.core.ui.BindingHolder import app.pachli.databinding.ItemRemovableBinding import app.pachli.util.getRelativeTimeSpanString @@ -64,3 +66,14 @@ class FiltersAdapter(val listener: FiltersListener, val filters: List) : } } } + +/** + * @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 +} diff --git a/app/src/main/java/app/pachli/components/filters/FiltersListener.kt b/app/src/main/java/app/pachli/components/filters/FiltersListener.kt index 3eb6953a1..49f6c7827 100644 --- a/app/src/main/java/app/pachli/components/filters/FiltersListener.kt +++ b/app/src/main/java/app/pachli/components/filters/FiltersListener.kt @@ -1,6 +1,6 @@ package app.pachli.components.filters -import app.pachli.core.network.model.Filter +import app.pachli.core.data.model.Filter interface FiltersListener { fun deleteFilter(filter: Filter) diff --git a/app/src/main/java/app/pachli/components/filters/FiltersViewModel.kt b/app/src/main/java/app/pachli/components/filters/FiltersViewModel.kt index 800fcd69c..18ade0154 100644 --- a/app/src/main/java/app/pachli/components/filters/FiltersViewModel.kt +++ b/app/src/main/java/app/pachli/components/filters/FiltersViewModel.kt @@ -3,23 +3,21 @@ package app.pachli.components.filters import android.view.View import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import app.pachli.appstore.EventHub -import app.pachli.appstore.FilterChangedEvent -import app.pachli.core.network.model.Filter -import app.pachli.core.network.retrofit.MastodonApi -import at.connyduck.calladapter.networkresult.fold +import app.pachli.core.data.model.Filter +import app.pachli.core.data.repository.FiltersRepository +import com.github.michaelbull.result.onFailure +import com.github.michaelbull.result.onSuccess import com.google.android.material.snackbar.Snackbar import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import retrofit2.HttpException @HiltViewModel class FiltersViewModel @Inject constructor( - private val api: MastodonApi, - private val eventHub: EventHub, + private val filtersRepository: FiltersRepository, ) : ViewModel() { enum class LoadingState { @@ -35,63 +33,34 @@ class FiltersViewModel @Inject constructor( val state: Flow get() = _state private val _state = MutableStateFlow(State(emptyList(), LoadingState.INITIAL)) - // TODO: Now that FilterRepository exists this code should be updated to use that. fun load() { this@FiltersViewModel._state.value = _state.value.copy(loadingState = LoadingState.LOADING) viewModelScope.launch { - api.getFilters().fold( - { filters -> - this@FiltersViewModel._state.value = State(filters, LoadingState.LOADED) - }, - { throwable -> - if (throwable is HttpException && throwable.code() == 404) { - api.getFiltersV1().fold( - { filters -> - this@FiltersViewModel._state.value = State(filters.map { it.toFilter() }, LoadingState.LOADED) - }, - { throwable -> - // TODO log errors (also below) - - this@FiltersViewModel._state.value = _state.value.copy(loadingState = LoadingState.ERROR_OTHER) - }, - ) - this@FiltersViewModel._state.value = _state.value.copy(loadingState = LoadingState.ERROR_OTHER) - } else { - this@FiltersViewModel._state.value = _state.value.copy(loadingState = LoadingState.ERROR_NETWORK) + filtersRepository.filters.collect { result -> + result.onSuccess { filters -> + this@FiltersViewModel._state.update { State(filters?.filters.orEmpty(), LoadingState.LOADED) } + } + .onFailure { + // TODO: There's an ERROR_NETWORK state to maybe consider here. Or get rid of + // that and do proper error handling. + this@FiltersViewModel._state.update { + it.copy(loadingState = LoadingState.ERROR_OTHER) + } } - }, - ) + } } } fun deleteFilter(filter: Filter, parent: View) { viewModelScope.launch { - api.deleteFilter(filter.id).fold( - { + filtersRepository.deleteFilter(filter.id) + .onSuccess { this@FiltersViewModel._state.value = State(this@FiltersViewModel._state.value.filters.filter { it.id != filter.id }, LoadingState.LOADED) - for (context in filter.contexts) { - eventHub.dispatch(FilterChangedEvent(context)) - } - }, - { throwable -> - if (throwable is HttpException && throwable.code() == 404) { - api.deleteFilterV1(filter.id).fold( - { - this@FiltersViewModel._state.value = State(this@FiltersViewModel._state.value.filters.filter { it.id != filter.id }, LoadingState.LOADED) - filter.contexts.forEach { - eventHub.dispatch(FilterChangedEvent(it)) - } - }, - { - Snackbar.make(parent, "Error deleting filter '${filter.title}'", Snackbar.LENGTH_SHORT).show() - }, - ) - } else { - Snackbar.make(parent, "Error deleting filter '${filter.title}'", Snackbar.LENGTH_SHORT).show() - } - }, - ) + } + .onFailure { + Snackbar.make(parent, "Error deleting filter '${filter.title}'", Snackbar.LENGTH_SHORT).show() + } } } } diff --git a/app/src/main/java/app/pachli/components/notifications/NotificationsViewModel.kt b/app/src/main/java/app/pachli/components/notifications/NotificationsViewModel.kt index fcdab59b2..0af09a6c4 100644 --- a/app/src/main/java/app/pachli/components/notifications/NotificationsViewModel.kt +++ b/app/src/main/java/app/pachli/components/notifications/NotificationsViewModel.kt @@ -17,6 +17,7 @@ package app.pachli.components.notifications +import android.content.Context import androidx.annotation.StringRes import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -27,13 +28,12 @@ import androidx.paging.map import app.pachli.R import app.pachli.appstore.BlockEvent import app.pachli.appstore.EventHub -import app.pachli.appstore.FilterChangedEvent import app.pachli.appstore.MuteConversationEvent import app.pachli.appstore.MuteEvent -import app.pachli.components.timeline.FilterKind -import app.pachli.components.timeline.FiltersRepository import app.pachli.core.accounts.AccountManager import app.pachli.core.common.extensions.throttleFirst +import app.pachli.core.data.repository.FilterVersion +import app.pachli.core.data.repository.FiltersRepository import app.pachli.core.data.repository.StatusDisplayOptionsRepository import app.pachli.core.network.model.Filter import app.pachli.core.network.model.FilterContext @@ -48,7 +48,10 @@ import app.pachli.util.serialize import app.pachli.viewdata.NotificationViewData import app.pachli.viewdata.StatusViewData import at.connyduck.calladapter.networkresult.getOrThrow +import com.github.michaelbull.result.onFailure +import com.github.michaelbull.result.onSuccess import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.channels.Channel @@ -57,7 +60,6 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged @@ -301,6 +303,9 @@ sealed interface UiError { @OptIn(ExperimentalCoroutinesApi::class) @HiltViewModel class NotificationsViewModel @Inject constructor( + // TODO: Context is required because handling filter errors needs to + // format a resource string. As soon as that is removed this can be removed. + @ApplicationContext private val context: Context, private val repository: NotificationsRepository, private val accountManager: AccountManager, private val timelineCases: TimelineCases, @@ -469,15 +474,18 @@ class NotificationsViewModel @Inject constructor( // Fetch the status filters viewModelScope.launch { - eventHub.events - .filterIsInstance() - .filter { it.filterContext == FilterContext.NOTIFICATIONS } - .map { - getFilters() - repository.invalidate() + filtersRepository.filters.collect { filters -> + filters.onSuccess { + filterModel = when (it?.version) { + FilterVersion.V2 -> FilterModel(FilterContext.NOTIFICATIONS) + FilterVersion.V1 -> FilterModel(FilterContext.NOTIFICATIONS, it.filters) + else -> null + } + reload.getAndUpdate { it + 1 } + }.onFailure { + _uiErrorChannel.send(UiError.GetFilters(RuntimeException(it.fmt(context)))) } - .onStart { getFilters() } - .collect() + } } // Handle events that should refresh the list @@ -533,18 +541,6 @@ class NotificationsViewModel @Inject constructor( } } - /** Gets the current filters from the repository. */ - private fun getFilters() = viewModelScope.launch { - try { - filterModel = when (val filters = filtersRepository.getFilters()) { - is FilterKind.V1 -> FilterModel(FilterContext.NOTIFICATIONS, filters.filters) - is FilterKind.V2 -> FilterModel(FilterContext.NOTIFICATIONS) - } - } catch (throwable: Throwable) { - _uiErrorChannel.send(UiError.GetFilters(throwable)) - } - } - // The database stores "0" as the last notification ID if notifications have not been // fetched. Convert to null to ensure a full fetch in this case private fun getInitialKey(): String? { diff --git a/app/src/main/java/app/pachli/components/preference/AccountPreferencesFragment.kt b/app/src/main/java/app/pachli/components/preference/AccountPreferencesFragment.kt index f71520a17..8f433d6c9 100644 --- a/app/src/main/java/app/pachli/components/preference/AccountPreferencesFragment.kt +++ b/app/src/main/java/app/pachli/components/preference/AccountPreferencesFragment.kt @@ -30,7 +30,7 @@ import app.pachli.core.activity.extensions.TransitionKind import app.pachli.core.activity.extensions.startActivityWithTransition import app.pachli.core.common.util.unsafeLazy import app.pachli.core.data.repository.AccountPreferenceDataStore -import app.pachli.core.data.repository.ServerRepository +import app.pachli.core.data.repository.FiltersRepository import app.pachli.core.designsystem.R as DR import app.pachli.core.navigation.AccountListActivityIntent import app.pachli.core.navigation.FiltersActivityIntent @@ -41,8 +41,6 @@ import app.pachli.core.navigation.LoginActivityIntent.LoginMode import app.pachli.core.navigation.PreferencesActivityIntent import app.pachli.core.navigation.PreferencesActivityIntent.PreferenceScreen import app.pachli.core.navigation.TabPreferenceActivityIntent -import app.pachli.core.network.ServerOperation.ORG_JOINMASTODON_FILTERS_CLIENT -import app.pachli.core.network.ServerOperation.ORG_JOINMASTODON_FILTERS_SERVER import app.pachli.core.network.model.Account import app.pachli.core.network.model.Status import app.pachli.core.network.retrofit.MastodonApi @@ -57,12 +55,10 @@ import app.pachli.util.getInitialLanguages import app.pachli.util.getLocaleList import app.pachli.util.getPachliDisplayName import app.pachli.util.iconRes -import com.github.michaelbull.result.getOrElse import com.google.android.material.snackbar.Snackbar import com.mikepenz.iconics.IconicsDrawable import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial import dagger.hilt.android.AndroidEntryPoint -import io.github.z4kn4fein.semver.constraints.toConstraint import javax.inject.Inject import retrofit2.Call import retrofit2.Callback @@ -78,7 +74,7 @@ class AccountPreferencesFragment : PreferenceFragmentCompat() { lateinit var mastodonApi: MastodonApi @Inject - lateinit var serverRepository: ServerRepository + lateinit var filtersRepository: FiltersRepository @Inject lateinit var eventHub: EventHub @@ -170,16 +166,7 @@ class AccountPreferencesFragment : PreferenceFragmentCompat() { activity?.startActivityWithTransition(intent, TransitionKind.SLIDE_FROM_END) true } - val server = serverRepository.flow.value.getOrElse { null } - isEnabled = server?.let { - it.can( - ORG_JOINMASTODON_FILTERS_CLIENT, - ">1.0.0".toConstraint(), - ) || it.can( - ORG_JOINMASTODON_FILTERS_SERVER, - ">1.0.0".toConstraint(), - ) - } ?: false + isEnabled = filtersRepository.canFilter() if (!isEnabled) summary = context.getString(R.string.pref_summary_timeline_filters) } diff --git a/app/src/main/java/app/pachli/components/timeline/FiltersRepository.kt b/app/src/main/java/app/pachli/components/timeline/FiltersRepository.kt deleted file mode 100644 index 9c539e756..000000000 --- a/app/src/main/java/app/pachli/components/timeline/FiltersRepository.kt +++ /dev/null @@ -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 . - */ - -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) : FilterKind - - /** API v2 filter, filtering happens server side */ - data class V2(val filters: List) : 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 - } - }, - ) - } -} diff --git a/app/src/main/java/app/pachli/components/timeline/viewmodel/CachedTimelineViewModel.kt b/app/src/main/java/app/pachli/components/timeline/viewmodel/CachedTimelineViewModel.kt index b23fb90c5..47de640e3 100644 --- a/app/src/main/java/app/pachli/components/timeline/viewmodel/CachedTimelineViewModel.kt +++ b/app/src/main/java/app/pachli/components/timeline/viewmodel/CachedTimelineViewModel.kt @@ -17,6 +17,7 @@ package app.pachli.components.timeline.viewmodel +import android.content.Context import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import androidx.paging.PagingData @@ -29,8 +30,8 @@ import app.pachli.appstore.FavoriteEvent import app.pachli.appstore.PinEvent import app.pachli.appstore.ReblogEvent import app.pachli.components.timeline.CachedTimelineRepository -import app.pachli.components.timeline.FiltersRepository import app.pachli.core.accounts.AccountManager +import app.pachli.core.data.repository.FiltersRepository import app.pachli.core.data.repository.StatusDisplayOptionsRepository import app.pachli.core.network.model.Filter import app.pachli.core.network.model.Poll @@ -39,6 +40,7 @@ import app.pachli.usecase.TimelineCases import app.pachli.viewdata.StatusViewData import com.squareup.moshi.Moshi import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow @@ -53,6 +55,7 @@ import timber.log.Timber @OptIn(ExperimentalCoroutinesApi::class) @HiltViewModel class CachedTimelineViewModel @Inject constructor( + @ApplicationContext context: Context, savedStateHandle: SavedStateHandle, private val repository: CachedTimelineRepository, timelineCases: TimelineCases, @@ -63,6 +66,7 @@ class CachedTimelineViewModel @Inject constructor( sharedPreferencesRepository: SharedPreferencesRepository, private val moshi: Moshi, ) : TimelineViewModel( + context, savedStateHandle, timelineCases, eventHub, diff --git a/app/src/main/java/app/pachli/components/timeline/viewmodel/NetworkTimelineViewModel.kt b/app/src/main/java/app/pachli/components/timeline/viewmodel/NetworkTimelineViewModel.kt index 4539382d3..bc4e9450b 100644 --- a/app/src/main/java/app/pachli/components/timeline/viewmodel/NetworkTimelineViewModel.kt +++ b/app/src/main/java/app/pachli/components/timeline/viewmodel/NetworkTimelineViewModel.kt @@ -17,6 +17,7 @@ package app.pachli.components.timeline.viewmodel +import android.content.Context import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import androidx.paging.PagingData @@ -28,9 +29,9 @@ import app.pachli.appstore.EventHub import app.pachli.appstore.FavoriteEvent import app.pachli.appstore.PinEvent import app.pachli.appstore.ReblogEvent -import app.pachli.components.timeline.FiltersRepository import app.pachli.components.timeline.NetworkTimelineRepository import app.pachli.core.accounts.AccountManager +import app.pachli.core.data.repository.FiltersRepository import app.pachli.core.data.repository.StatusDisplayOptionsRepository import app.pachli.core.network.model.Filter import app.pachli.core.network.model.Poll @@ -38,6 +39,7 @@ import app.pachli.core.preferences.SharedPreferencesRepository import app.pachli.usecase.TimelineCases import app.pachli.viewdata.StatusViewData import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow @@ -52,6 +54,7 @@ import timber.log.Timber @OptIn(ExperimentalCoroutinesApi::class) @HiltViewModel class NetworkTimelineViewModel @Inject constructor( + @ApplicationContext context: Context, savedStateHandle: SavedStateHandle, private val repository: NetworkTimelineRepository, timelineCases: TimelineCases, @@ -61,6 +64,7 @@ class NetworkTimelineViewModel @Inject constructor( statusDisplayOptionsRepository: StatusDisplayOptionsRepository, sharedPreferencesRepository: SharedPreferencesRepository, ) : TimelineViewModel( + context, savedStateHandle, timelineCases, eventHub, diff --git a/app/src/main/java/app/pachli/components/timeline/viewmodel/TimelineViewModel.kt b/app/src/main/java/app/pachli/components/timeline/viewmodel/TimelineViewModel.kt index 24c900477..fd36b5e50 100644 --- a/app/src/main/java/app/pachli/components/timeline/viewmodel/TimelineViewModel.kt +++ b/app/src/main/java/app/pachli/components/timeline/viewmodel/TimelineViewModel.kt @@ -17,6 +17,7 @@ package app.pachli.components.timeline.viewmodel +import android.content.Context import androidx.annotation.CallSuper import androidx.annotation.StringRes import androidx.annotation.VisibleForTesting @@ -32,7 +33,6 @@ import app.pachli.appstore.DomainMuteEvent import app.pachli.appstore.Event import app.pachli.appstore.EventHub import app.pachli.appstore.FavoriteEvent -import app.pachli.appstore.FilterChangedEvent import app.pachli.appstore.MuteConversationEvent import app.pachli.appstore.MuteEvent import app.pachli.appstore.PinEvent @@ -41,10 +41,10 @@ import app.pachli.appstore.StatusComposedEvent import app.pachli.appstore.StatusDeletedEvent import app.pachli.appstore.StatusEditedEvent import app.pachli.appstore.UnfollowEvent -import app.pachli.components.timeline.FilterKind -import app.pachli.components.timeline.FiltersRepository import app.pachli.core.accounts.AccountManager import app.pachli.core.common.extensions.throttleFirst +import app.pachli.core.data.repository.FilterVersion +import app.pachli.core.data.repository.FiltersRepository import app.pachli.core.data.repository.StatusDisplayOptionsRepository import app.pachli.core.model.Timeline import app.pachli.core.network.model.Filter @@ -57,6 +57,9 @@ import app.pachli.network.FilterModel import app.pachli.usecase.TimelineCases import app.pachli.viewdata.StatusViewData import at.connyduck.calladapter.networkresult.getOrThrow +import com.github.michaelbull.result.onFailure +import com.github.michaelbull.result.onSuccess +import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow @@ -68,9 +71,9 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.fold import kotlinx.coroutines.flow.getAndUpdate import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch @@ -258,6 +261,9 @@ sealed interface UiError { } abstract class TimelineViewModel( + // TODO: Context is required because handling filter errors needs to + // format a resource string. As soon as that is removed this can be removed. + @ApplicationContext private val context: Context, savedStateHandle: SavedStateHandle, private val timelineCases: TimelineCases, private val eventHub: EventHub, @@ -320,8 +326,22 @@ abstract class TimelineViewModel( init { viewModelScope.launch { - updateFiltersFromPreferences().collectLatest { - Timber.d("Filters updated") + FilterContext.from(timeline)?.let { filterContext -> + filtersRepository.filters.fold(false) { reload, filters -> + filters.onSuccess { + filterModel = when (it?.version) { + FilterVersion.V2 -> FilterModel(filterContext) + FilterVersion.V1 -> FilterModel(filterContext, it.filters) + else -> null + } + if (reload) { + reloadKeepingReadingPosition() + } + }.onFailure { + _uiErrorChannel.send(UiError.GetFilters(RuntimeException(it.fmt(context)))) + } + true + } } } @@ -517,35 +537,6 @@ abstract class TimelineViewModel( } } - /** Updates the current set of filters if filter-related preferences change */ - private fun updateFiltersFromPreferences() = eventHub.events - .filterIsInstance() - .filter { filterContextMatchesKind(timeline, listOf(it.filterContext)) } - .map { - getFilters() - Timber.d("Reload because FilterChangedEvent") - reloadKeepingReadingPosition() - } - .onStart { getFilters() } - - /** Gets the current filters from the repository. */ - private fun getFilters() { - viewModelScope.launch { - Timber.d("getFilters()") - try { - FilterContext.from(timeline)?.let { filterContext -> - filterModel = when (val filters = filtersRepository.getFilters()) { - is FilterKind.V1 -> FilterModel(filterContext, filters.filters) - is FilterKind.V2 -> FilterModel(filterContext) - } - } - } catch (throwable: Throwable) { - Timber.d(throwable, "updateFilter(): Error fetching filters") - _uiErrorChannel.send(UiError.GetFilters(throwable)) - } - } - } - // TODO: Update this so that the list of UIPrefs is correct private fun onPreferenceChanged(key: String) { when (key) { diff --git a/app/src/main/java/app/pachli/components/trending/viewmodel/TrendingTagsViewModel.kt b/app/src/main/java/app/pachli/components/trending/viewmodel/TrendingTagsViewModel.kt index 5a8545f99..c62568f02 100644 --- a/app/src/main/java/app/pachli/components/trending/viewmodel/TrendingTagsViewModel.kt +++ b/app/src/main/java/app/pachli/components/trending/viewmodel/TrendingTagsViewModel.kt @@ -18,8 +18,7 @@ package app.pachli.components.trending.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import app.pachli.appstore.EventHub -import app.pachli.appstore.FilterChangedEvent +import app.pachli.core.data.repository.FiltersRepository import app.pachli.core.network.model.FilterContext import app.pachli.core.network.model.TrendingTag import app.pachli.core.network.model.end @@ -27,20 +26,19 @@ import app.pachli.core.network.model.start import app.pachli.core.network.retrofit.MastodonApi import app.pachli.viewdata.TrendingViewData import at.connyduck.calladapter.networkresult.fold +import com.github.michaelbull.result.get import dagger.hilt.android.lifecycle.HiltViewModel import java.io.IOException import javax.inject.Inject -import kotlinx.coroutines.async import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.launch import timber.log.Timber @HiltViewModel class TrendingTagsViewModel @Inject constructor( private val mastodonApi: MastodonApi, - private val eventHub: EventHub, + private val filtersRepository: FiltersRepository, ) : ViewModel() { enum class LoadingState { INITIAL, @@ -61,17 +59,7 @@ class TrendingTagsViewModel @Inject constructor( init { invalidate() - - // Collect FilterChangedEvent, FiltersActivity creates them when a filter is created - // or deleted. Unfortunately, there's nothing in the event to determine if it's a filter - // that was modified, so refresh on every preference change. - viewModelScope.launch { - eventHub.events - .filterIsInstance() - .collect { - invalidate() - } - } + viewModelScope.launch { filtersRepository.filters.collect { invalidate() } } } /** @@ -86,8 +74,6 @@ class TrendingTagsViewModel @Inject constructor( _uiState.value = TrendingTagsUiState(emptyList(), LoadingState.LOADING) } - val deferredFilters = async { mastodonApi.getFilters() } - mastodonApi.trendingTags(limit = LIMIT_TRENDING_HASHTAGS).fold( { tagResponse -> @@ -95,7 +81,7 @@ class TrendingTagsViewModel @Inject constructor( _uiState.value = if (firstTag == null) { TrendingTagsUiState(emptyList(), LoadingState.LOADED) } else { - val homeFilters = deferredFilters.await().getOrNull()?.filter { filter -> + val homeFilters = filtersRepository.filters.value.get()?.filters?.filter { filter -> filter.contexts.contains(FilterContext.HOME) } val tags = tagResponse diff --git a/app/src/main/java/app/pachli/components/viewthread/ViewThreadViewModel.kt b/app/src/main/java/app/pachli/components/viewthread/ViewThreadViewModel.kt index 35b1ac8ce..8eb183e50 100644 --- a/app/src/main/java/app/pachli/components/viewthread/ViewThreadViewModel.kt +++ b/app/src/main/java/app/pachli/components/viewthread/ViewThreadViewModel.kt @@ -22,17 +22,16 @@ import app.pachli.appstore.BlockEvent import app.pachli.appstore.BookmarkEvent import app.pachli.appstore.EventHub import app.pachli.appstore.FavoriteEvent -import app.pachli.appstore.FilterChangedEvent import app.pachli.appstore.PinEvent import app.pachli.appstore.ReblogEvent import app.pachli.appstore.StatusComposedEvent import app.pachli.appstore.StatusDeletedEvent import app.pachli.appstore.StatusEditedEvent import app.pachli.components.timeline.CachedTimelineRepository -import app.pachli.components.timeline.FilterKind -import app.pachli.components.timeline.FiltersRepository import app.pachli.components.timeline.util.ifExpected import app.pachli.core.accounts.AccountManager +import app.pachli.core.data.repository.FilterVersion +import app.pachli.core.data.repository.FiltersRepository import app.pachli.core.data.repository.StatusDisplayOptionsRepository import app.pachli.core.database.dao.TimelineDao import app.pachli.core.database.model.AccountEntity @@ -49,6 +48,8 @@ import app.pachli.viewdata.StatusViewData import at.connyduck.calladapter.networkresult.fold import at.connyduck.calladapter.networkresult.getOrElse import at.connyduck.calladapter.networkresult.getOrThrow +import com.github.michaelbull.result.onFailure +import com.github.michaelbull.result.onSuccess import com.squareup.moshi.Moshi import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject @@ -107,16 +108,27 @@ class ViewThreadViewModel @Inject constructor( is StatusComposedEvent -> handleStatusComposedEvent(event) is StatusDeletedEvent -> handleStatusDeletedEvent(event) is StatusEditedEvent -> handleStatusEditedEvent(event) - is FilterChangedEvent -> { - if (event.filterContext == FilterContext.THREAD) { - loadFilters() - } - } } } } - loadFilters() + viewModelScope.launch { + filtersRepository.filters.collect { filters -> + filters.onSuccess { + filterModel = when (it?.version) { + FilterVersion.V2 -> FilterModel(FilterContext.THREAD) + FilterVersion.V1 -> FilterModel(FilterContext.THREAD, it.filters) + else -> null + } + updateStatuses() + } + .onFailure { + // TODO: Deliberately don't emit to _errors here -- at the moment + // ViewThreadFragment shows a generic error to the user, and that + // would confuse them when the rest of the thread is loading OK. + } + } + } } fun loadThread(id: String) { @@ -205,8 +217,7 @@ class ViewThreadViewModel @Inject constructor( translation = cachedTranslations[status.id], ) }.filterByFilterAction() - val descendants = statusContext.descendants.map { - status -> + val descendants = statusContext.descendants.map { status -> val svd = cachedViewData[status.id] StatusViewData.from( status, @@ -519,22 +530,6 @@ class ViewThreadViewModel @Inject constructor( return RevealButtonState.NO_BUTTON } - private fun loadFilters() { - viewModelScope.launch { - try { - filterModel = when (val filters = filtersRepository.getFilters()) { - is FilterKind.V1 -> FilterModel(FilterContext.THREAD, filters.filters) - is FilterKind.V2 -> FilterModel(FilterContext.THREAD) - } - updateStatuses() - } catch (_: Exception) { - // TODO: Deliberately don't emit to _errors here -- at the moment - // ViewThreadFragment shows a generic error to the user, and that - // would confuse them when the rest of the thread is loading OK. - } - } - } - private fun updateStatuses() { updateSuccess { uiState -> val statuses = uiState.statusViewData.filterByFilterAction() diff --git a/app/src/main/java/app/pachli/fragment/SFragment.kt b/app/src/main/java/app/pachli/fragment/SFragment.kt index fe77e650b..3e60e14f6 100644 --- a/app/src/main/java/app/pachli/fragment/SFragment.kt +++ b/app/src/main/java/app/pachli/fragment/SFragment.kt @@ -134,7 +134,7 @@ abstract class SFragment : Fragment(), StatusActionListener Timber.e(msg) try { Snackbar.make(requireView(), msg, Snackbar.LENGTH_INDEFINITE) - .setAction(app.pachli.core.ui.R.string.action_retry) { serverRepository.retry() } + .setAction(app.pachli.core.ui.R.string.action_retry) { serverRepository.reload() } .show() } catch (e: IllegalArgumentException) { // On rare occasions this code is running before the fragment's diff --git a/app/src/main/java/app/pachli/network/FilterModel.kt b/app/src/main/java/app/pachli/network/FilterModel.kt index cf7aa1cd4..d74bf2231 100644 --- a/app/src/main/java/app/pachli/network/FilterModel.kt +++ b/app/src/main/java/app/pachli/network/FilterModel.kt @@ -1,8 +1,8 @@ package app.pachli.network -import app.pachli.core.network.model.Filter +import app.pachli.core.data.model.Filter +import app.pachli.core.network.model.Filter as NetworkFilter import app.pachli.core.network.model.FilterContext -import app.pachli.core.network.model.FilterV1 import app.pachli.core.network.model.Status import app.pachli.core.network.parseAsMastodonHtml import java.util.Date @@ -14,7 +14,7 @@ import java.util.regex.Pattern * Construct with [filterContext] that corresponds to the kind of timeline, and optionally the set * of v1 filters that should be applied. */ -class FilterModel(private val filterContext: FilterContext, v1filters: List? = null) { +class FilterModel(private val filterContext: FilterContext, v1filters: List? = null) { /** Pattern to use when matching v1 filters against a status. Null if these are v2 filters */ private var pattern: Pattern? = null @@ -25,13 +25,13 @@ class FilterModel(private val filterContext: FilterContext, v1filters: List // Patterns are expensive and thread-safe, matchers are neither. - val matcher = pat.matcher("") ?: return Filter.Action.NONE + val matcher = pat.matcher("") ?: return NetworkFilter.Action.NONE if (status.poll?.options?.any { matcher.reset(it.title).find() } == true) { - return Filter.Action.HIDE + return NetworkFilter.Action.HIDE } val spoilerText = status.actionableStatus.spoilerText @@ -42,9 +42,9 @@ class FilterModel(private val filterContext: FilterContext, v1filters: List): Pattern? { + private fun makeFilter(filters: List): Pattern? { val now = Date() val nonExpiredFilters = filters.filter { it.expiresAt?.before(now) != true } if (nonExpiredFilters.isEmpty()) return null diff --git a/app/src/main/res/layout/activity_edit_filter.xml b/app/src/main/res/layout/activity_edit_filter.xml index 77bfd1979..a5e32d9f1 100644 --- a/app/src/main/res/layout/activity_edit_filter.xml +++ b/app/src/main/res/layout/activity_edit_filter.xml @@ -110,7 +110,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="48dp" - android:entries="@array/filter_duration_names" /> + android:entries="@array/filter_duration_labels" /> @string/duration_7_days - + + 0 300 1800 @@ -248,7 +249,7 @@ 604800 - + @string/duration_indefinite @string/duration_5_min @string/duration_30_min @@ -259,7 +260,8 @@ @string/duration_7_days - + + 0 300 1800 @@ -268,7 +270,7 @@ 86400 259200 604800 - + warn diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4cbed09c0..0883ef594 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -712,4 +712,8 @@ See more from %1$s Show article author\'s profile Open link + + Loading filter failed: %1$s + Saving filter failed: %1$s + Deleting filter failed: %1$s diff --git a/app/src/test/java/app/pachli/FilterV1Test.kt b/app/src/test/java/app/pachli/FilterV1Test.kt index aae4cc108..3312f8a63 100644 --- a/app/src/test/java/app/pachli/FilterV1Test.kt +++ b/app/src/test/java/app/pachli/FilterV1Test.kt @@ -19,8 +19,9 @@ package app.pachli import androidx.test.ext.junit.runners.AndroidJUnit4 import app.pachli.components.filters.EditFilterViewModel.Companion.getSecondsForDurationIndex +import app.pachli.core.data.model.Filter import app.pachli.core.network.model.Attachment -import app.pachli.core.network.model.Filter +import app.pachli.core.network.model.Filter as NetworkFilter import app.pachli.core.network.model.FilterContext import app.pachli.core.network.model.FilterV1 import app.pachli.core.network.model.Poll @@ -46,7 +47,7 @@ class FilterV1Test { FilterV1( id = "123", phrase = "badWord", - contexts = listOf(FilterContext.HOME), + contexts = setOf(FilterContext.HOME), expiresAt = null, irreversible = false, wholeWord = false, @@ -54,7 +55,7 @@ class FilterV1Test { FilterV1( id = "123", phrase = "badWholeWord", - contexts = listOf(FilterContext.HOME, FilterContext.PUBLIC), + contexts = setOf(FilterContext.HOME, FilterContext.PUBLIC), expiresAt = null, irreversible = false, wholeWord = true, @@ -62,7 +63,7 @@ class FilterV1Test { FilterV1( id = "123", phrase = "@twitter.com", - contexts = listOf(FilterContext.HOME), + contexts = setOf(FilterContext.HOME), expiresAt = null, irreversible = false, wholeWord = true, @@ -70,7 +71,7 @@ class FilterV1Test { FilterV1( id = "123", phrase = "#hashtag", - contexts = listOf(FilterContext.HOME), + contexts = setOf(FilterContext.HOME), expiresAt = null, irreversible = false, wholeWord = true, @@ -78,7 +79,7 @@ class FilterV1Test { FilterV1( id = "123", phrase = "expired", - contexts = listOf(FilterContext.HOME), + contexts = setOf(FilterContext.HOME), expiresAt = Date.from(Instant.now().minusSeconds(10)), irreversible = false, wholeWord = true, @@ -86,7 +87,7 @@ class FilterV1Test { FilterV1( id = "123", phrase = "unexpired", - contexts = listOf(FilterContext.HOME), + contexts = setOf(FilterContext.HOME), expiresAt = Date.from(Instant.now().plusSeconds(3600)), irreversible = false, wholeWord = true, @@ -94,12 +95,12 @@ class FilterV1Test { FilterV1( id = "123", phrase = "href", - contexts = listOf(FilterContext.HOME), + contexts = setOf(FilterContext.HOME), expiresAt = null, irreversible = false, wholeWord = false, ), - ) + ).map { Filter.from(it) } filterModel = FilterModel(FilterContext.HOME, filters) } @@ -107,7 +108,7 @@ class FilterV1Test { @Test fun shouldNotFilter() { assertEquals( - Filter.Action.NONE, + NetworkFilter.Action.NONE, filterModel.filterActionFor( mockStatus(content = "should not be filtered"), ), @@ -117,7 +118,7 @@ class FilterV1Test { @Test fun shouldFilter_whenContentMatchesBadWord() { assertEquals( - Filter.Action.HIDE, + NetworkFilter.Action.HIDE, filterModel.filterActionFor( mockStatus(content = "one two badWord three"), ), @@ -127,7 +128,7 @@ class FilterV1Test { @Test fun shouldFilter_whenContentMatchesBadWordPart() { assertEquals( - Filter.Action.HIDE, + NetworkFilter.Action.HIDE, filterModel.filterActionFor( mockStatus(content = "one two badWordPart three"), ), @@ -137,7 +138,7 @@ class FilterV1Test { @Test fun shouldFilter_whenContentMatchesBadWholeWord() { assertEquals( - Filter.Action.HIDE, + NetworkFilter.Action.HIDE, filterModel.filterActionFor( mockStatus(content = "one two badWholeWord three"), ), @@ -147,7 +148,7 @@ class FilterV1Test { @Test fun shouldNotFilter_whenContentDoesNotMatchWholeWord() { assertEquals( - Filter.Action.NONE, + NetworkFilter.Action.NONE, filterModel.filterActionFor( mockStatus(content = "one two badWholeWordTest three"), ), @@ -157,7 +158,7 @@ class FilterV1Test { @Test fun shouldFilter_whenSpoilerTextDoesMatch() { assertEquals( - Filter.Action.HIDE, + NetworkFilter.Action.HIDE, filterModel.filterActionFor( mockStatus( content = "should not be filtered", @@ -170,7 +171,7 @@ class FilterV1Test { @Test fun shouldFilter_whenPollTextDoesMatch() { assertEquals( - Filter.Action.HIDE, + NetworkFilter.Action.HIDE, filterModel.filterActionFor( mockStatus( content = "should not be filtered", @@ -184,7 +185,7 @@ class FilterV1Test { @Test fun shouldFilter_whenMediaDescriptionDoesMatch() { assertEquals( - Filter.Action.HIDE, + NetworkFilter.Action.HIDE, filterModel.filterActionFor( mockStatus( content = "should not be filtered", @@ -198,7 +199,7 @@ class FilterV1Test { @Test fun shouldFilterPartialWord_whenWholeWordFilterContainsNonAlphanumericCharacters() { assertEquals( - Filter.Action.HIDE, + NetworkFilter.Action.HIDE, filterModel.filterActionFor( mockStatus(content = "one two someone@twitter.com three"), ), @@ -208,7 +209,7 @@ class FilterV1Test { @Test fun shouldFilterHashtags() { assertEquals( - Filter.Action.HIDE, + NetworkFilter.Action.HIDE, filterModel.filterActionFor( mockStatus(content = "#hashtag one two three"), ), @@ -218,7 +219,7 @@ class FilterV1Test { @Test fun shouldFilterHashtags_whenContentIsMarkedUp() { assertEquals( - Filter.Action.HIDE, + NetworkFilter.Action.HIDE, filterModel.filterActionFor( mockStatus(content = "

#hashtagone two three

"), ), @@ -228,7 +229,7 @@ class FilterV1Test { @Test fun shouldNotFilterHtmlAttributes() { assertEquals( - Filter.Action.NONE, + NetworkFilter.Action.NONE, filterModel.filterActionFor( mockStatus(content = "

https://foo.bar/ one two three

"), ), @@ -238,7 +239,7 @@ class FilterV1Test { @Test fun shouldNotFilter_whenFilterIsExpired() { assertEquals( - Filter.Action.NONE, + NetworkFilter.Action.NONE, filterModel.filterActionFor( mockStatus(content = "content matching expired filter should not be filtered"), ), @@ -248,7 +249,7 @@ class FilterV1Test { @Test fun shouldFilter_whenFilterIsUnexpired() { assertEquals( - Filter.Action.HIDE, + NetworkFilter.Action.HIDE, filterModel.filterActionFor( mockStatus(content = "content matching unexpired filter should be filtered"), ), diff --git a/app/src/test/java/app/pachli/components/filters/FilterViewDataTest.kt b/app/src/test/java/app/pachli/components/filters/FilterViewDataTest.kt new file mode 100644 index 000000000..70b8ad65c --- /dev/null +++ b/app/src/test/java/app/pachli/components/filters/FilterViewDataTest.kt @@ -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 . + */ + +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) + } +} diff --git a/app/src/test/java/app/pachli/components/notifications/NotificationsViewModelTestBase.kt b/app/src/test/java/app/pachli/components/notifications/NotificationsViewModelTestBase.kt index 175f4676f..84b046538 100644 --- a/app/src/test/java/app/pachli/components/notifications/NotificationsViewModelTestBase.kt +++ b/app/src/test/java/app/pachli/components/notifications/NotificationsViewModelTestBase.kt @@ -18,11 +18,13 @@ package app.pachli.components.notifications import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry import app.pachli.appstore.EventHub -import app.pachli.components.timeline.FilterKind -import app.pachli.components.timeline.FiltersRepository import app.pachli.core.accounts.AccountManager import app.pachli.core.data.repository.AccountPreferenceDataStore +import app.pachli.core.data.repository.Filters +import app.pachli.core.data.repository.FiltersError +import app.pachli.core.data.repository.FiltersRepository import app.pachli.core.data.repository.ServerRepository import app.pachli.core.data.repository.StatusDisplayOptionsRepository import app.pachli.core.database.model.AccountEntity @@ -35,6 +37,8 @@ import app.pachli.core.testing.fakes.InMemorySharedPreferences import app.pachli.core.testing.rules.MainCoroutineRule import app.pachli.usecase.TimelineCases import at.connyduck.calladapter.networkresult.NetworkResult +import com.github.michaelbull.result.Ok +import com.github.michaelbull.result.Result import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.TestScope import okhttp3.ResponseBody @@ -103,8 +107,9 @@ abstract class NotificationsViewModelTestBase { ) timelineCases = mock() + filtersRepository = mock { - onBlocking { getFilters() } doReturn FilterKind.V2(emptyList()) + whenever(it.filters).thenReturn(MutableStateFlow>(Ok(null))) } sharedPreferencesRepository = SharedPreferencesRepository( @@ -149,6 +154,7 @@ abstract class NotificationsViewModelTestBase { ) viewModel = NotificationsViewModel( + InstrumentationRegistry.getInstrumentation().targetContext, notificationsRepository, accountManager, timelineCases, diff --git a/app/src/test/java/app/pachli/components/timeline/CachedTimelineViewModelTestBase.kt b/app/src/test/java/app/pachli/components/timeline/CachedTimelineViewModelTestBase.kt index 42afccc71..9f67fe1aa 100644 --- a/app/src/test/java/app/pachli/components/timeline/CachedTimelineViewModelTestBase.kt +++ b/app/src/test/java/app/pachli/components/timeline/CachedTimelineViewModelTestBase.kt @@ -19,11 +19,13 @@ package app.pachli.components.timeline import androidx.lifecycle.SavedStateHandle import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry import app.pachli.PachliApplication import app.pachli.appstore.EventHub import app.pachli.components.timeline.viewmodel.CachedTimelineViewModel import app.pachli.components.timeline.viewmodel.TimelineViewModel import app.pachli.core.accounts.AccountManager +import app.pachli.core.data.repository.FiltersRepository import app.pachli.core.data.repository.StatusDisplayOptionsRepository import app.pachli.core.model.Timeline import app.pachli.core.network.model.Account @@ -33,6 +35,7 @@ import app.pachli.core.network.retrofit.MastodonApi import app.pachli.core.network.retrofit.NodeInfoApi import app.pachli.core.preferences.SharedPreferencesRepository import app.pachli.core.testing.rules.MainCoroutineRule +import app.pachli.core.testing.success import app.pachli.usecase.TimelineCases import at.connyduck.calladapter.networkresult.NetworkResult import com.squareup.moshi.Moshi @@ -113,7 +116,7 @@ abstract class CachedTimelineViewModelTestBase { reset(mastodonApi) mastodonApi.stub { onBlocking { getCustomEmojis() } doReturn NetworkResult.failure(Exception()) - onBlocking { getFilters() } doReturn NetworkResult.success(emptyList()) + onBlocking { getFilters() } doReturn success(emptyList()) } reset(nodeInfoApi) @@ -155,6 +158,7 @@ abstract class CachedTimelineViewModelTestBase { timelineCases = mock() viewModel = CachedTimelineViewModel( + InstrumentationRegistry.getInstrumentation().targetContext, SavedStateHandle(mapOf(TimelineViewModel.TIMELINE_TAG to Timeline.Home)), cachedTimelineRepository, timelineCases, diff --git a/app/src/test/java/app/pachli/components/timeline/NetworkTimelineViewModelTestBase.kt b/app/src/test/java/app/pachli/components/timeline/NetworkTimelineViewModelTestBase.kt index 65ca7b735..cf9906806 100644 --- a/app/src/test/java/app/pachli/components/timeline/NetworkTimelineViewModelTestBase.kt +++ b/app/src/test/java/app/pachli/components/timeline/NetworkTimelineViewModelTestBase.kt @@ -19,10 +19,12 @@ package app.pachli.components.timeline import androidx.lifecycle.SavedStateHandle import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry import app.pachli.appstore.EventHub import app.pachli.components.timeline.viewmodel.NetworkTimelineViewModel import app.pachli.components.timeline.viewmodel.TimelineViewModel import app.pachli.core.accounts.AccountManager +import app.pachli.core.data.repository.FiltersRepository import app.pachli.core.data.repository.StatusDisplayOptionsRepository import app.pachli.core.model.Timeline import app.pachli.core.network.model.Account @@ -32,6 +34,7 @@ import app.pachli.core.network.retrofit.MastodonApi import app.pachli.core.network.retrofit.NodeInfoApi import app.pachli.core.preferences.SharedPreferencesRepository import app.pachli.core.testing.rules.MainCoroutineRule +import app.pachli.core.testing.success import app.pachli.usecase.TimelineCases import app.pachli.util.HiltTestApplication_Application import at.connyduck.calladapter.networkresult.NetworkResult @@ -103,7 +106,7 @@ abstract class NetworkTimelineViewModelTestBase { reset(mastodonApi) mastodonApi.stub { onBlocking { getCustomEmojis() } doReturn NetworkResult.failure(Exception()) - onBlocking { getFilters() } doReturn NetworkResult.success(emptyList()) + onBlocking { getFilters() } doReturn success(emptyList()) } reset(nodeInfoApi) @@ -145,6 +148,7 @@ abstract class NetworkTimelineViewModelTestBase { timelineCases = mock() viewModel = NetworkTimelineViewModel( + InstrumentationRegistry.getInstrumentation().targetContext, SavedStateHandle(mapOf(TimelineViewModel.TIMELINE_TAG to Timeline.Bookmarks)), networkTimelineRepository, timelineCases, diff --git a/app/src/test/java/app/pachli/components/viewthread/ViewThreadViewModelTest.kt b/app/src/test/java/app/pachli/components/viewthread/ViewThreadViewModelTest.kt index 0cdc7ac41..116bb36ab 100644 --- a/app/src/test/java/app/pachli/components/viewthread/ViewThreadViewModelTest.kt +++ b/app/src/test/java/app/pachli/components/viewthread/ViewThreadViewModelTest.kt @@ -9,11 +9,12 @@ import app.pachli.appstore.FavoriteEvent import app.pachli.appstore.ReblogEvent import app.pachli.components.compose.HiltTestApplication_Application import app.pachli.components.timeline.CachedTimelineRepository -import app.pachli.components.timeline.FilterKind -import app.pachli.components.timeline.FiltersRepository import app.pachli.components.timeline.mockStatus import app.pachli.components.timeline.mockStatusViewData import app.pachli.core.accounts.AccountManager +import app.pachli.core.data.repository.Filters +import app.pachli.core.data.repository.FiltersError +import app.pachli.core.data.repository.FiltersRepository import app.pachli.core.data.repository.StatusDisplayOptionsRepository import app.pachli.core.database.dao.TimelineDao import app.pachli.core.database.model.AccountEntity @@ -26,6 +27,8 @@ import app.pachli.core.network.retrofit.NodeInfoApi import app.pachli.core.preferences.SharedPreferencesRepository import app.pachli.usecase.TimelineCases import at.connyduck.calladapter.networkresult.NetworkResult +import com.github.michaelbull.result.Ok +import com.github.michaelbull.result.Result import com.squareup.moshi.Moshi import dagger.hilt.android.testing.BindValue import dagger.hilt.android.testing.CustomTestApplication @@ -35,6 +38,7 @@ import java.io.IOException import java.time.Instant import java.util.Date import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals @@ -47,6 +51,7 @@ import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock import org.mockito.kotlin.reset import org.mockito.kotlin.stub +import org.mockito.kotlin.whenever import org.robolectric.annotation.Config open class PachliHiltApplication : PachliApplication() @@ -129,7 +134,7 @@ class ViewThreadViewModelTest { reset(filtersRepository) filtersRepository.stub { - onBlocking { getFilters() } doReturn FilterKind.V2(emptyList()) + whenever(it.filters).thenReturn(MutableStateFlow>(Ok(null))) } reset(nodeInfoApi) diff --git a/core/common/src/main/kotlin/app/pachli/core/common/extensions/ResultExtensions.kt b/core/common/src/main/kotlin/app/pachli/core/common/extensions/ResultExtensions.kt new file mode 100644 index 000000000..d0475b529 --- /dev/null +++ b/core/common/src/main/kotlin/app/pachli/core/common/extensions/ResultExtensions.kt @@ -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 . + */ + +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][Result] to [Result][Result] by either applying the [transform] + * function to the [value][Ok.value] if this [Result] is [Ok<T>][Ok], or returning the result + * unchanged. + */ +@OptIn(ExperimentalContracts::class) +inline infix fun Result.mapIfInstance(transform: (T) -> V): Result { + 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][Result] to [Result][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 Result.mapIfNotNull(transform: (V) -> V): Result { + contract { + callsInPlace(transform, InvocationKind.AT_MOST_ONCE) + } + + return when (this) { + is Ok -> value?.let { Ok(transform(it)) } ?: this + is Err -> this + } +} diff --git a/core/data/build.gradle.kts b/core/data/build.gradle.kts index edbd34fc8..2097e7d85 100644 --- a/core/data/build.gradle.kts +++ b/core/data/build.gradle.kts @@ -18,6 +18,7 @@ plugins { alias(libs.plugins.pachli.android.library) alias(libs.plugins.pachli.android.hilt) + alias(libs.plugins.kotlin.parcelize) } android { diff --git a/core/data/src/main/kotlin/app/pachli/core/data/model/Filter.kt b/core/data/src/main/kotlin/app/pachli/core/data/model/Filter.kt new file mode 100644 index 000000000..936e7040f --- /dev/null +++ b/core/data/src/main/kotlin/app/pachli/core/data/model/Filter.kt @@ -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 . + */ + +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 = emptySet(), + val expiresAt: Date? = null, + val action: Action, + val keywords: List = 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, +) diff --git a/core/data/src/main/kotlin/app/pachli/core/data/repository/FiltersRepository.kt b/core/data/src/main/kotlin/app/pachli/core/data/repository/FiltersRepository.kt new file mode 100644 index 000000000..220de0b79 --- /dev/null +++ b/core/data/src/main/kotlin/app/pachli/core/data/repository/FiltersRepository.kt @@ -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 . + */ + +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, + val expiresIn: Int, + val action: app.pachli.core.network.model.Filter.Action, + val keywords: List, +) { + 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? = null, + val expiresIn: Int = -1, + val action: NetworkFilter.Action? = null, + val keywordsToAdd: List? = null, + val keywordsToDelete: List? = null, + val keywordsToModify: List? = 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? = 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, + 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(replay = 1).apply { tryEmit(Unit) } + + private lateinit var server: Result + + /** + * 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 = 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 = 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 = 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 = 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 = 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()) diff --git a/core/data/src/main/kotlin/app/pachli/core/data/repository/ServerRepository.kt b/core/data/src/main/kotlin/app/pachli/core/data/repository/ServerRepository.kt index 77f2575ac..e7dd8b1bb 100644 --- a/core/data/src/main/kotlin/app/pachli/core/data/repository/ServerRepository.kt +++ b/core/data/src/main/kotlin/app/pachli/core/data/repository/ServerRepository.kt @@ -41,8 +41,10 @@ import com.github.michaelbull.result.mapError import javax.inject.Inject import javax.inject.Singleton import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.launch import timber.log.Timber @@ -65,18 +67,14 @@ class ServerRepository @Inject constructor( private val accountManager: AccountManager, @ApplicationScope private val externalScope: CoroutineScope, ) { - private val _flow = MutableStateFlow>(Ok(null)) - val flow = _flow.asStateFlow() + private val reload = MutableSharedFlow(replay = 1).apply { tryEmit(Unit) } - init { - externalScope.launch { - accountManager.activeAccountFlow.collect { _flow.emit(getServer()) } - } - } + // SharedFlow, **not** StateFlow, to ensure a new value is emitted even if the + // user switches between accounts that are on the same server. + val flow = reload.combine(accountManager.activeAccountFlow) { _, _ -> getServer() } + .shareIn(externalScope, SharingStarted.Lazily, replay = 1) - fun retry() = externalScope.launch { - _flow.emit(getServer()) - } + fun reload() = externalScope.launch { reload.emit(Unit) } /** * @return the server info or a [Server.Error] if the server info can not diff --git a/core/data/src/main/res/values/strings.xml b/core/data/src/main/res/values/strings.xml index 46487c927..5446da936 100644 --- a/core/data/src/main/res/values/strings.xml +++ b/core/data/src/main/res/values/strings.xml @@ -6,4 +6,5 @@ validating nodeinfo %1$s failed: %2$s fetching /api/v1/instance failed: %1$s parsing server capabilities failed: %1$s + Server does not support filters diff --git a/core/data/src/test/kotlin/app/pachli/core/data/repository/filtersRepository/BaseFiltersRepositoryTest.kt b/core/data/src/test/kotlin/app/pachli/core/data/repository/filtersRepository/BaseFiltersRepositoryTest.kt new file mode 100644 index 000000000..6f3e70009 --- /dev/null +++ b/core/data/src/test/kotlin/app/pachli/core/data/repository/filtersRepository/BaseFiltersRepositoryTest.kt @@ -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 . + */ + +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)), + ), + ) + } +} diff --git a/core/data/src/test/kotlin/app/pachli/core/data/repository/filtersRepository/FiltersRepositoryTestCreate.kt b/core/data/src/test/kotlin/app/pachli/core/data/repository/filtersRepository/FiltersRepositoryTestCreate.kt new file mode 100644 index 000000000..80c32098b --- /dev/null +++ b/core/data/src/test/kotlin/app/pachli/core/data/repository/filtersRepository/FiltersRepositoryTestCreate.kt @@ -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 . + */ + +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(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(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() + } + } +} diff --git a/core/data/src/test/kotlin/app/pachli/core/data/repository/filtersRepository/FiltersRepositoryTestDelete.kt b/core/data/src/test/kotlin/app/pachli/core/data/repository/filtersRepository/FiltersRepositoryTestDelete.kt new file mode 100644 index 000000000..1c588bda8 --- /dev/null +++ b/core/data/src/test/kotlin/app/pachli/core/data/repository/filtersRepository/FiltersRepositoryTestDelete.kt @@ -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 . + */ + +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() + } + } +} diff --git a/core/data/src/test/kotlin/app/pachli/core/data/repository/filtersRepository/FiltersRepositoryTestFlow.kt b/core/data/src/test/kotlin/app/pachli/core/data/repository/filtersRepository/FiltersRepositoryTestFlow.kt new file mode 100644 index 000000000..1f0a40ec5 --- /dev/null +++ b/core/data/src/test/kotlin/app/pachli/core/data/repository/filtersRepository/FiltersRepositoryTestFlow.kt @@ -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 . + */ + +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")) + } + } +} diff --git a/core/data/src/test/kotlin/app/pachli/core/data/repository/filtersRepository/FiltersRepositoryTestReload.kt b/core/data/src/test/kotlin/app/pachli/core/data/repository/filtersRepository/FiltersRepositoryTestReload.kt new file mode 100644 index 000000000..e9393057f --- /dev/null +++ b/core/data/src/test/kotlin/app/pachli/core/data/repository/filtersRepository/FiltersRepositoryTestReload.kt @@ -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 . + */ + +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() + } + } +} diff --git a/core/data/src/test/kotlin/app/pachli/core/data/repository/filtersRepository/FiltersRepositoryTestUpdate.kt b/core/data/src/test/kotlin/app/pachli/core/data/repository/filtersRepository/FiltersRepositoryTestUpdate.kt new file mode 100644 index 000000000..5e9d01056 --- /dev/null +++ b/core/data/src/test/kotlin/app/pachli/core/data/repository/filtersRepository/FiltersRepositoryTestUpdate.kt @@ -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 . + */ + +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(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() + } + } +} diff --git a/core/navigation/build.gradle.kts b/core/navigation/build.gradle.kts index 8a3d27529..41bd377e6 100644 --- a/core/navigation/build.gradle.kts +++ b/core/navigation/build.gradle.kts @@ -30,6 +30,7 @@ android { } dependencies { + implementation(projects.core.data) implementation(projects.core.database) // For DraftAttachment, used in ComposeOptions implementation(projects.core.model) implementation(projects.core.network) // For Attachment, used in AttachmentViewData diff --git a/core/navigation/src/main/kotlin/app/pachli/core/navigation/Navigation.kt b/core/navigation/src/main/kotlin/app/pachli/core/navigation/Navigation.kt index 61b201187..bd41ac13f 100644 --- a/core/navigation/src/main/kotlin/app/pachli/core/navigation/Navigation.kt +++ b/core/navigation/src/main/kotlin/app/pachli/core/navigation/Navigation.kt @@ -21,6 +21,7 @@ import android.content.Context import android.content.Intent import android.os.Parcelable import androidx.core.content.IntentCompat +import app.pachli.core.data.model.Filter import app.pachli.core.database.model.DraftAttachment import app.pachli.core.model.Timeline import app.pachli.core.navigation.LoginActivityIntent.LoginMode @@ -32,7 +33,6 @@ import app.pachli.core.navigation.TimelineActivityIntent.Companion.list import app.pachli.core.navigation.TimelineActivityIntent.Companion.publicFederated import app.pachli.core.navigation.TimelineActivityIntent.Companion.publicLocal import app.pachli.core.network.model.Attachment -import app.pachli.core.network.model.Filter import app.pachli.core.network.model.NewPoll import app.pachli.core.network.model.Notification import app.pachli.core.network.model.Status @@ -190,23 +190,47 @@ class ComposeActivityIntent(context: Context) : Intent() { } /** + * Launch with an empty filter to edit. + * * @param context - * @param filter Optional filter to edit. If null an empty filter is created. * @see [app.pachli.components.filters.EditFilterActivity] */ -class EditFilterActivityIntent(context: Context, filter: Filter? = null) : Intent() { +class EditFilterActivityIntent(context: Context) : Intent() { init { setClassName(context, QuadrantConstants.EDIT_FILTER_ACTIVITY) - filter?.let { - putExtra(EXTRA_FILTER_TO_EDIT, it) - } } companion object { - const val EXTRA_FILTER_TO_EDIT = "filterToEdit" + private const val EXTRA_FILTER_TO_EDIT = "filterToEdit" + private const val EXTRA_FILTER_ID_TO_LOAD = "filterIdToLoad" + + /** + * Launch with [filter] displayed, ready to edit. + * + * @param context + * @param filter Filter to edit + * @see [app.pachli.components.filters.EditFilterActivity] + */ + fun edit(context: Context, filter: Filter) = EditFilterActivityIntent(context).apply { + putExtra(EXTRA_FILTER_TO_EDIT, filter) + } + + /** + * Launch and load [filterId], display it ready to edit. + * + * @param context + * @param filterId ID of the filter to load + * @see [app.pachli.components.filters.EditFilterActivity] + */ + fun edit(context: Context, filterId: String) = EditFilterActivityIntent(context).apply { + putExtra(EXTRA_FILTER_ID_TO_LOAD, filterId) + } /** @return the [Filter] passed in this intent, or null */ fun getFilter(intent: Intent) = IntentCompat.getParcelableExtra(intent, EXTRA_FILTER_TO_EDIT, Filter::class.java) + + /** @return the filter ID passed in this intent, or null */ + fun getFilterId(intent: Intent) = intent.getStringExtra(EXTRA_FILTER_ID_TO_LOAD) } } diff --git a/core/network/src/main/kotlin/app/pachli/core/network/model/Filter.kt b/core/network/src/main/kotlin/app/pachli/core/network/model/Filter.kt index dfe79568c..8965d141c 100644 --- a/core/network/src/main/kotlin/app/pachli/core/network/model/Filter.kt +++ b/core/network/src/main/kotlin/app/pachli/core/network/model/Filter.kt @@ -13,7 +13,7 @@ import kotlinx.parcelize.Parcelize data class Filter( val id: String = "", val title: String = "", - @Json(name = "context") val contexts: List = emptyList(), + @Json(name = "context") val contexts: Set = emptySet(), @Json(name = "expires_at") val expiresAt: Date? = null, @Json(name = "filter_action") val action: Action = Action.WARN, // This should not normally be empty. However, Mastodon does not include @@ -29,8 +29,8 @@ data class Filter( @Json(name = "none") NONE, - @Json(name = "warn") @Default + @Json(name = "warn") WARN, @Json(name = "hide") diff --git a/core/network/src/main/kotlin/app/pachli/core/network/model/FilterV1.kt b/core/network/src/main/kotlin/app/pachli/core/network/model/FilterV1.kt index c6ffb8e1b..56bf7c51d 100644 --- a/core/network/src/main/kotlin/app/pachli/core/network/model/FilterV1.kt +++ b/core/network/src/main/kotlin/app/pachli/core/network/model/FilterV1.kt @@ -24,7 +24,7 @@ import java.util.Date data class FilterV1( val id: String, val phrase: String, - @Json(name = "context") val contexts: List, + @Json(name = "context") val contexts: Set, @Json(name = "expires_at") val expiresAt: Date?, val irreversible: Boolean, @Json(name = "whole_word") val wholeWord: Boolean, @@ -40,21 +40,12 @@ data class FilterV1( val filter = other as FilterV1? return filter?.id.equals(id) } - - fun toFilter(): Filter { - return Filter( - id = id, - title = phrase, - contexts = contexts, - expiresAt = expiresAt, - action = Filter.Action.WARN, - keywords = listOf( - FilterKeyword( - id = id, - keyword = phrase, - wholeWord = wholeWord, - ), - ), - ) - } } + +data class NewFilterV1( + val phrase: String, + val contexts: Set, + val expiresIn: Int, + val irreversible: Boolean, + val wholeWord: Boolean, +) diff --git a/core/network/src/main/kotlin/app/pachli/core/network/retrofit/MastodonApi.kt b/core/network/src/main/kotlin/app/pachli/core/network/retrofit/MastodonApi.kt index eacd867f1..2da9cfc16 100644 --- a/core/network/src/main/kotlin/app/pachli/core/network/retrofit/MastodonApi.kt +++ b/core/network/src/main/kotlin/app/pachli/core/network/retrofit/MastodonApi.kt @@ -97,10 +97,20 @@ interface MastodonApi { suspend fun getInstanceV2(@Header(DOMAIN_HEADER) domain: String? = null): NetworkResult @GET("api/v1/filters") - suspend fun getFiltersV1(): NetworkResult> + suspend fun getFiltersV1(): ApiResult> @GET("api/v2/filters") - suspend fun getFilters(): NetworkResult> + suspend fun getFilters(): ApiResult> + + @GET("api/v2/filters/{id}") + suspend fun getFilter( + @Path("id") filterId: String, + ): ApiResult + + @GET("api/v1/filters/{id}") + suspend fun getFilterV1( + @Path("id") filterId: String, + ): ApiResult @GET("api/v1/timelines/home") @Throws(Exception::class) @@ -612,59 +622,59 @@ interface MastodonApi { @POST("api/v1/filters") suspend fun createFilterV1( @Field("phrase") phrase: String, - @Field("context[]") context: List, + @Field("context[]") context: Set, @Field("irreversible") irreversible: Boolean?, @Field("whole_word") wholeWord: Boolean?, // String not Int because the empty string is used to represent "indefinite", // see https://github.com/mastodon/documentation/issues/1216#issuecomment-2030222940 @Field("expires_in") expiresInSeconds: String?, - ): NetworkResult + ): ApiResult @FormUrlEncoded @PUT("api/v1/filters/{id}") suspend fun updateFilterV1( @Path("id") id: String, @Field("phrase") phrase: String, - @Field("context[]") context: List, + @Field("context[]") contexts: Collection, @Field("irreversible") irreversible: Boolean?, @Field("whole_word") wholeWord: Boolean?, // String not Int because the empty string is used to represent "indefinite", // see https://github.com/mastodon/documentation/issues/1216#issuecomment-2030222940 @Field("expires_in") expiresInSeconds: String?, - ): NetworkResult + ): ApiResult @DELETE("api/v1/filters/{id}") suspend fun deleteFilterV1( @Path("id") id: String, - ): NetworkResult + ): ApiResult @FormUrlEncoded @POST("api/v2/filters") suspend fun createFilter( @Field("title") title: String, - @Field("context[]") context: List, + @Field("context[]") contexts: Set, @Field("filter_action") filterAction: Filter.Action, // String not Int because the empty string is used to represent "indefinite", // see https://github.com/mastodon/documentation/issues/1216#issuecomment-2030222940 @Field("expires_in") expiresInSeconds: String?, - ): NetworkResult + ): ApiResult @FormUrlEncoded @PUT("api/v2/filters/{id}") suspend fun updateFilter( @Path("id") id: String, @Field("title") title: String? = null, - @Field("context[]") context: List? = null, + @Field("context[]") contexts: Collection? = null, @Field("filter_action") filterAction: Filter.Action? = null, // String not Int because the empty string is used to represent "indefinite", // see https://github.com/mastodon/documentation/issues/1216#issuecomment-2030222940 @Field("expires_in") expiresInSeconds: String? = null, - ): NetworkResult + ): ApiResult @DELETE("api/v2/filters/{id}") suspend fun deleteFilter( @Path("id") id: String, - ): NetworkResult + ): ApiResult @FormUrlEncoded @POST("api/v2/filters/{filterId}/keywords") @@ -672,7 +682,7 @@ interface MastodonApi { @Path("filterId") filterId: String, @Field("keyword") keyword: String, @Field("whole_word") wholeWord: Boolean, - ): NetworkResult + ): ApiResult @FormUrlEncoded @PUT("api/v2/filters/keywords/{keywordId}") @@ -680,12 +690,12 @@ interface MastodonApi { @Path("keywordId") keywordId: String, @Field("keyword") keyword: String, @Field("whole_word") wholeWord: Boolean, - ): NetworkResult + ): ApiResult @DELETE("api/v2/filters/keywords/{keywordId}") suspend fun deleteFilterKeyword( @Path("keywordId") keywordId: String, - ): NetworkResult + ): ApiResult @FormUrlEncoded @POST("api/v1/polls/{id}/votes") diff --git a/core/network/src/test/kotlin/app/pachli/core/network/retrofit/apiresult/ApiResultCallTest.kt b/core/network/src/test/kotlin/app/pachli/core/network/retrofit/apiresult/ApiResultCallTest.kt index c4bd84c70..24bc792ac 100644 --- a/core/network/src/test/kotlin/app/pachli/core/network/retrofit/apiresult/ApiResultCallTest.kt +++ b/core/network/src/test/kotlin/app/pachli/core/network/retrofit/apiresult/ApiResultCallTest.kt @@ -17,13 +17,12 @@ package app.pachli.core.network.retrofit.apiresult +import app.pachli.core.testing.jsonError import com.github.michaelbull.result.Err import com.github.michaelbull.result.unwrapError import com.google.common.truth.Truth.assertThat import java.io.IOException import okhttp3.Headers -import okhttp3.Protocol -import okhttp3.ResponseBody.Companion.toResponseBody import org.junit.Assert.assertThrows import org.junit.Test import retrofit2.Call @@ -124,9 +123,9 @@ class ApiResultCallTest { networkApiResultCall.enqueue( object : Callback> { override fun onResponse(call: Call>, response: Response>) { - val error = response.body()?.unwrapError() as? WrongContentType + val error = response.body()?.unwrapError() assertThat(error).isInstanceOf(WrongContentType::class.java) - assertThat(error?.contentType).isEqualTo("text/html") + assertThat((error as WrongContentType).contentType).isEqualTo("text/html") assertThat(response.isSuccessful).isTrue() } @@ -142,29 +141,18 @@ class ApiResultCallTest { // properties then the error message should fall back to the HTTP error message. @Test fun `should parse call with 404 error code as ApiResult-failure (no JSON)`() { - val errorMsg = "dummy error message" - val errorResponse = Response.error( - "".toResponseBody(), - okhttp3.Response.Builder() - .request(okhttp3.Request.Builder().url("http://localhost/").build()) - .protocol(Protocol.HTTP_1_1) - .addHeader("content-type", "application/json") - .code(404) - .message(errorMsg) - .build(), - - ) + val errorResponse = jsonError(404, "") networkApiResultCall.enqueue( object : Callback> { override fun onResponse(call: Call>, response: Response>) { - val error = response.body()?.unwrapError() as? ClientError.NotFound + val error = response.body()?.unwrapError() assertThat(error).isInstanceOf(ClientError.NotFound::class.java) - val exception = error?.exception + val exception = (error as ClientError.NotFound).exception assertThat(exception).isInstanceOf(HttpException::class.java) - assertThat(exception?.code()).isEqualTo(404) - assertThat(error?.formatArgs).isEqualTo(arrayOf("HTTP 404 $errorMsg")) + assertThat(exception.code()).isEqualTo(404) + assertThat(error.formatArgs).isEqualTo(arrayOf("HTTP 404 Not Found")) } override fun onFailure(call: Call>, t: Throwable) { @@ -181,28 +169,18 @@ class ApiResultCallTest { @Test fun `should parse call with 404 error code as ApiResult-failure (JSON error message)`() { val errorMsg = "JSON error message" - val errorResponse = Response.error( - "{\"error\": \"$errorMsg\"}".toResponseBody(), - okhttp3.Response.Builder() - .request(okhttp3.Request.Builder().url("http://localhost/").build()) - .protocol(Protocol.HTTP_1_1) - .addHeader("content-type", "application/json") - .code(404) - .message("") - .build(), - - ) + val errorResponse = jsonError(404, "{\"error\": \"$errorMsg\"}") networkApiResultCall.enqueue( object : Callback> { override fun onResponse(call: Call>, response: Response>) { - val error = response.body()?.unwrapError() as? ClientError.NotFound + val error = response.body()?.unwrapError() assertThat(error).isInstanceOf(ClientError.NotFound::class.java) - val exception = error?.exception + val exception = (error as ClientError.NotFound).exception assertThat(exception).isInstanceOf(HttpException::class.java) - assertThat(exception?.code()).isEqualTo(404) - assertThat(error?.formatArgs).isEqualTo(arrayOf(errorMsg)) + assertThat(exception.code()).isEqualTo(404) + assertThat(error.formatArgs).isEqualTo(arrayOf(errorMsg)) } override fun onFailure(call: Call>, t: Throwable) { @@ -220,28 +198,18 @@ class ApiResultCallTest { fun `should parse call with 404 error code as ApiResult-failure (JSON error and description message)`() { val errorMsg = "JSON error message" val descriptionMsg = "JSON error description" - val errorResponse = Response.error( - "{\"error\": \"$errorMsg\", \"description\": \"$descriptionMsg\"}".toResponseBody(), - okhttp3.Response.Builder() - .request(okhttp3.Request.Builder().url("http://localhost/").build()) - .protocol(Protocol.HTTP_1_1) - .addHeader("content-type", "application/json") - .code(404) - .message("") - .build(), - - ) + val errorResponse = jsonError(404, "{\"error\": \"$errorMsg\", \"description\": \"$descriptionMsg\"}") networkApiResultCall.enqueue( object : Callback> { override fun onResponse(call: Call>, response: Response>) { - val error = response.body()?.unwrapError() as? ClientError.NotFound + val error = response.body()?.unwrapError() assertThat(error).isInstanceOf(ClientError.NotFound::class.java) - val exception = error?.exception + val exception = (error as ClientError.NotFound).exception assertThat(exception).isInstanceOf(HttpException::class.java) - assertThat(exception?.code()).isEqualTo(404) - assertThat(error?.formatArgs).isEqualTo(arrayOf("$errorMsg: $descriptionMsg")) + assertThat(exception.code()).isEqualTo(404) + assertThat(error.formatArgs).isEqualTo(arrayOf("$errorMsg: $descriptionMsg")) } override fun onFailure(call: Call>, t: Throwable) { diff --git a/core/testing/build.gradle.kts b/core/testing/build.gradle.kts index cad010c87..3baee5baa 100644 --- a/core/testing/build.gradle.kts +++ b/core/testing/build.gradle.kts @@ -30,6 +30,7 @@ android { dependencies { implementation(projects.core.common) + implementation(projects.core.network) api(libs.kotlinx.coroutines.test) api(libs.androidx.test.junit) diff --git a/core/testing/src/main/kotlin/app/pachli/core/testing/ApiResult.kt b/core/testing/src/main/kotlin/app/pachli/core/testing/ApiResult.kt new file mode 100644 index 000000000..fd0797122 --- /dev/null +++ b/core/testing/src/main/kotlin/app/pachli/core/testing/ApiResult.kt @@ -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 . + */ + +package app.pachli.core.testing + +import app.pachli.core.network.retrofit.apiresult.ApiError +import app.pachli.core.network.retrofit.apiresult.ApiResponse +import app.pachli.core.network.retrofit.apiresult.ApiResult +import com.github.michaelbull.result.Err +import com.github.michaelbull.result.Ok +import okhttp3.Headers +import okhttp3.Protocol +import okhttp3.Request +import okhttp3.ResponseBody.Companion.toResponseBody +import retrofit2.HttpException +import retrofit2.Response + +/** + * Returns an [Ok][Ok] [ApiResult<T>][ApiResult] wrapping [data]. + * + * @param data Data to wrap in the result. + * @param code HTTP response code. + * @param headers Optional additional headers to include in the response. See + * [Headers.headersOf]. + */ +fun success(data: T, code: Int = 200, vararg headers: String): ApiResult = + Ok(ApiResponse(Headers.headersOf(*headers), data, code)) + +/** + * Returns an [Err][Err] [ApiResult<T>][ApiResult] representing an HTTP request + * failure. + * + * @param code HTTP failure code. + * @param body Data to use as the HTTP response body. + * @param message (optional) String to use as the HTTP status message. + */ +fun failure(code: Int = 404, body: String = "", message: String = code.httpStatusMessage()): ApiResult = + 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 = 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" +} diff --git a/feature/suggestions/src/main/kotlin/app/pachli/feature/suggestions/SuggestionsViewModel.kt b/feature/suggestions/src/main/kotlin/app/pachli/feature/suggestions/SuggestionsViewModel.kt index fc39217f1..e2fc2c6f9 100644 --- a/feature/suggestions/src/main/kotlin/app/pachli/feature/suggestions/SuggestionsViewModel.kt +++ b/feature/suggestions/src/main/kotlin/app/pachli/feature/suggestions/SuggestionsViewModel.kt @@ -19,6 +19,7 @@ package app.pachli.feature.suggestions import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import app.pachli.core.common.extensions.mapIfInstance import app.pachli.core.common.extensions.stateFlow import app.pachli.core.data.model.StatusDisplayOptions import app.pachli.core.data.model.Suggestion @@ -30,15 +31,11 @@ import app.pachli.feature.suggestions.UiAction.GetSuggestions import app.pachli.feature.suggestions.UiAction.SuggestionAction import app.pachli.feature.suggestions.UiAction.SuggestionAction.AcceptSuggestion import app.pachli.feature.suggestions.UiAction.SuggestionAction.DeleteSuggestion -import com.github.michaelbull.result.Err import com.github.michaelbull.result.Ok import com.github.michaelbull.result.Result import com.github.michaelbull.result.mapEither import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject -import kotlin.contracts.ExperimentalContracts -import kotlin.contracts.InvocationKind -import kotlin.contracts.contract import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow @@ -256,20 +253,3 @@ internal class SuggestionsViewModel @Inject constructor( return result } } - -/** - * Maps this [Result][Result] to [Result][Result] by either applying the [transform] - * function to the [value][Ok.value] if this [Result] is [Ok<T>][Ok], or returning the result - * unchanged. - */ -@OptIn(ExperimentalContracts::class) -inline infix fun Result.mapIfInstance(transform: (T) -> V): Result { - contract { - callsInPlace(transform, InvocationKind.AT_MOST_ONCE) - } - - return when (this) { - is Ok -> (value as? T)?.let { Ok(transform(it)) } ?: this - is Err -> this - } -}