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