refactor: Remove duplicate strings from Filter contexts (#478)

A filter's context (previously referred to as its `kind`) controls where
the filter is applied.

This was implemented as an enum with a specific property to control how
it would serialise when @FormUrlEncoded, and with a @Json annotation for
Moshi.

In addition, the model objects kept the filter context in its string
form throughout Pachli, requiring periodic conversion to/from the enum
type, making the code more complicated.

Fix this, by:

1. Converting the incoming JSON value to the enum type immediately, so
the rest of the code uses the enum constants exclusively.

2. Implement a Retrofit converter that serialises the enum value when
@FormUrlEncoded to the same string used in JSON serialisation
This commit is contained in:
Nik Clayton 2024-02-28 19:09:54 +01:00 committed by GitHub
parent 2aa01fba8c
commit 9a23439d04
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 246 additions and 129 deletions

View File

@ -33,6 +33,7 @@ import app.pachli.core.navigation.StatusListActivityIntent
import app.pachli.core.network.ServerOperation.ORG_JOINMASTODON_FILTERS_CLIENT
import app.pachli.core.network.ServerOperation.ORG_JOINMASTODON_FILTERS_SERVER
import app.pachli.core.network.model.Filter
import app.pachli.core.network.model.FilterContext
import app.pachli.core.network.model.FilterV1
import app.pachli.core.network.model.TimelineKind
import app.pachli.databinding.ActivityStatuslistBinding
@ -243,7 +244,7 @@ class StatusListActivity : BottomSheetActivity(), AppBarLayoutHost, ActionButton
mastodonApi.getFilters().fold(
{ filters ->
mutedFilter = filters.firstOrNull { filter ->
filter.context.contains(Filter.Kind.HOME.kind) && filter.keywords.any {
filter.contexts.contains(FilterContext.HOME) && filter.keywords.any {
it.keyword == tagWithHash
}
}
@ -254,7 +255,7 @@ class StatusListActivity : BottomSheetActivity(), AppBarLayoutHost, ActionButton
mastodonApi.getFiltersV1().fold(
{ filters ->
mutedFilterV1 = filters.firstOrNull { filter ->
tagWithHash == filter.phrase && filter.context.contains(FilterV1.HOME)
tagWithHash == filter.phrase && filter.contexts.contains(FilterContext.HOME)
}
updateTagMuteState(mutedFilterV1 != null)
},
@ -288,7 +289,7 @@ class StatusListActivity : BottomSheetActivity(), AppBarLayoutHost, ActionButton
lifecycleScope.launch {
mastodonApi.createFilter(
title = tagWithHash,
context = listOf(FilterV1.HOME),
context = listOf(FilterContext.HOME),
filterAction = Filter.Action.WARN.action,
expiresInSeconds = null,
).fold(
@ -296,7 +297,7 @@ class StatusListActivity : BottomSheetActivity(), AppBarLayoutHost, ActionButton
if (mastodonApi.addFilterKeyword(filterId = filter.id, keyword = tagWithHash, wholeWord = true).isSuccess) {
mutedFilter = filter
updateTagMuteState(true)
eventHub.dispatch(FilterChangedEvent(Filter.Kind.from(filter.context[0])))
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()
@ -307,7 +308,7 @@ class StatusListActivity : BottomSheetActivity(), AppBarLayoutHost, ActionButton
if (throwable is HttpException && throwable.code() == 404) {
mastodonApi.createFilterV1(
tagWithHash,
listOf(FilterV1.HOME),
listOf(FilterContext.HOME),
irreversible = false,
wholeWord = true,
expiresInSeconds = null,
@ -315,7 +316,7 @@ class StatusListActivity : BottomSheetActivity(), AppBarLayoutHost, ActionButton
{ filter ->
mutedFilterV1 = filter
updateTagMuteState(true)
eventHub.dispatch(FilterChangedEvent(Filter.Kind.from(filter.context[0])))
eventHub.dispatch(FilterChangedEvent(filter.contexts[0]))
Snackbar.make(binding.root, getString(R.string.confirmation_hashtag_muted, hashtag), Snackbar.LENGTH_SHORT).show()
},
{ throwable ->
@ -340,23 +341,23 @@ class StatusListActivity : BottomSheetActivity(), AppBarLayoutHost, ActionButton
val result = if (mutedFilter != null) {
val filter = mutedFilter!!
if (filter.context.size > 1) {
if (filter.contexts.size > 1) {
// This filter exists in multiple contexts, just remove the home context
mastodonApi.updateFilter(
id = filter.id,
context = filter.context.filter { it != Filter.Kind.HOME.kind },
context = filter.contexts.filter { it != FilterContext.HOME },
)
} else {
mastodonApi.deleteFilter(filter.id)
}
} else if (mutedFilterV1 != null) {
mutedFilterV1?.let { filter ->
if (filter.context.size > 1) {
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.context.filter { it != FilterV1.HOME },
context = filter.contexts.filter { it != FilterContext.HOME },
irreversible = null,
wholeWord = null,
expiresInSeconds = null,
@ -373,7 +374,7 @@ class StatusListActivity : BottomSheetActivity(), AppBarLayoutHost, ActionButton
{
updateTagMuteState(false)
Snackbar.make(binding.root, getString(R.string.confirmation_hashtag_unmuted, hashtag), Snackbar.LENGTH_SHORT).show()
eventHub.dispatch(FilterChangedEvent(Filter.Kind.HOME))
eventHub.dispatch(FilterChangedEvent(FilterContext.HOME))
mutedFilterV1 = null
mutedFilter = null
},

View File

@ -2,7 +2,7 @@ package app.pachli.appstore
import app.pachli.core.database.model.TabData
import app.pachli.core.network.model.Account
import app.pachli.core.network.model.Filter
import app.pachli.core.network.model.FilterContext
import app.pachli.core.network.model.Poll
import app.pachli.core.network.model.Status
@ -21,7 +21,7 @@ data class StatusComposedEvent(val status: Status) : Event
data object StatusScheduledEvent : Event
data class StatusEditedEvent(val originalId: String, val status: Status) : Event
data class ProfileEditedEvent(val newProfileData: Account) : Event
data class FilterChangedEvent(val filterKind: Filter.Kind) : Event
data class FilterChangedEvent(val filterContext: FilterContext) : Event
data class MainTabsChangedEvent(val newTabs: List<TabData>) : Event
data class PollVoteEvent(val statusId: String, val poll: Poll) : Event
data class DomainMuteEvent(val instance: String) : Event

View File

@ -18,6 +18,7 @@ import app.pachli.core.common.extensions.viewBinding
import app.pachli.core.common.extensions.visible
import app.pachli.core.navigation.EditFilterActivityIntent
import app.pachli.core.network.model.Filter
import app.pachli.core.network.model.FilterContext
import app.pachli.core.network.model.FilterKeyword
import app.pachli.core.network.retrofit.MastodonApi
import app.pachli.databinding.ActivityEditFilterBinding
@ -48,20 +49,20 @@ class EditFilterActivity : BaseActivity() {
private lateinit var filter: Filter
private var originalFilter: Filter? = null
private lateinit var contextSwitches: Map<SwitchMaterial, Filter.Kind>
private lateinit var filterContextSwitches: Map<SwitchMaterial, FilterContext>
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
originalFilter = EditFilterActivityIntent.getFilter(intent)
filter = originalFilter ?: Filter("", "", listOf(), null, Filter.Action.WARN.action, listOf())
filter = originalFilter ?: Filter("", "", listOf(), null, Filter.Action.WARN, listOf())
binding.apply {
contextSwitches = mapOf(
filterContextHome to Filter.Kind.HOME,
filterContextNotifications to Filter.Kind.NOTIFICATIONS,
filterContextPublic to Filter.Kind.PUBLIC,
filterContextThread to Filter.Kind.THREAD,
filterContextAccount to Filter.Kind.ACCOUNT,
filterContextSwitches = mapOf(
filterContextHome to FilterContext.HOME,
filterContextNotifications to FilterContext.NOTIFICATIONS,
filterContextPublic to FilterContext.PUBLIC,
filterContextThread to FilterContext.THREAD,
filterContextAccount to FilterContext.ACCOUNT,
)
}
@ -90,9 +91,9 @@ class EditFilterActivity : BaseActivity() {
}
binding.filterDeleteButton.visible(originalFilter != null)
for (switch in contextSwitches.keys) {
for (switch in filterContextSwitches.keys) {
switch.setOnCheckedChangeListener { _, isChecked ->
val context = contextSwitches[switch]!!
val context = filterContextSwitches[switch]!!
if (isChecked) {
viewModel.addContext(context)
} else {
@ -156,7 +157,7 @@ class EditFilterActivity : BaseActivity() {
}
lifecycleScope.launch {
viewModel.contexts.collect { contexts ->
for ((key, value) in contextSwitches) {
for ((key, value) in filterContextSwitches) {
key.isChecked = contexts.contains(value)
}
}

View File

@ -6,6 +6,7 @@ import androidx.lifecycle.viewModelScope
import app.pachli.appstore.EventHub
import app.pachli.appstore.FilterChangedEvent
import app.pachli.core.network.model.Filter
import app.pachli.core.network.model.FilterContext
import app.pachli.core.network.model.FilterKeyword
import app.pachli.core.network.retrofit.MastodonApi
import at.connyduck.calladapter.networkresult.fold
@ -22,7 +23,7 @@ class EditFilterViewModel @Inject constructor(val api: MastodonApi, val eventHub
val keywords = MutableStateFlow(listOf<FilterKeyword>())
val action = MutableStateFlow(Filter.Action.WARN)
val duration = MutableStateFlow(0)
val contexts = MutableStateFlow(listOf<Filter.Kind>())
val contexts = MutableStateFlow(listOf<FilterContext>())
fun load(filter: Filter) {
originalFilter = filter
@ -34,7 +35,7 @@ class EditFilterViewModel @Inject constructor(val api: MastodonApi, val eventHub
} else {
-1
}
contexts.value = filter.kinds
contexts.value = filter.contexts
}
fun addKeyword(keyword: FilterKeyword) {
@ -66,14 +67,14 @@ class EditFilterViewModel @Inject constructor(val api: MastodonApi, val eventHub
this.action.value = action
}
fun addContext(context: Filter.Kind) {
if (!contexts.value.contains(context)) {
contexts.value += context
fun addContext(filterContext: FilterContext) {
if (!contexts.value.contains(filterContext)) {
contexts.value += filterContext
}
}
fun removeContext(context: Filter.Kind) {
contexts.value = contexts.value.filter { it != context }
fun removeContext(filterContext: FilterContext) {
contexts.value = contexts.value.filter { it != filterContext }
}
fun validate(): Boolean {
@ -83,7 +84,7 @@ class EditFilterViewModel @Inject constructor(val api: MastodonApi, val eventHub
}
suspend fun saveChanges(context: Context): Boolean {
val contexts = contexts.value.map { it.kind }
val contexts = contexts.value
val title = title.value
val durationIndex = duration.value
val action = action.value.action
@ -97,9 +98,9 @@ class EditFilterViewModel @Inject constructor(val api: MastodonApi, val eventHub
// e.g., removing a filter from "home" still notifies anything showing
// the home timeline, so the timeline can be refreshed.
if (success) {
val originalKinds = originalFilter?.context?.map { Filter.Kind.from(it) } ?: emptyList()
val newKinds = contexts.map { Filter.Kind.from(it) }
(originalKinds + newKinds).distinct().forEach {
val originalContexts = originalFilter?.contexts ?: emptyList()
val newFilterContexts = contexts
(originalContexts + newFilterContexts).distinct().forEach {
eventHub.dispatch(FilterChangedEvent(it))
}
}
@ -107,7 +108,7 @@ class EditFilterViewModel @Inject constructor(val api: MastodonApi, val eventHub
}
}
private suspend fun createFilter(title: String, contexts: List<String>, action: String, durationIndex: Int, context: Context): Boolean {
private suspend fun createFilter(title: String, contexts: List<FilterContext>, action: String, durationIndex: Int, context: Context): Boolean {
val expiresInSeconds = EditFilterActivity.getSecondsForDurationIndex(durationIndex, context)
api.createFilter(
title = title,
@ -131,7 +132,7 @@ class EditFilterViewModel @Inject constructor(val api: MastodonApi, val eventHub
)
}
private suspend fun updateFilter(originalFilter: Filter, title: String, contexts: List<String>, action: String, durationIndex: Int, context: Context): Boolean {
private suspend fun updateFilter(originalFilter: Filter, title: String, contexts: List<FilterContext>, action: String, durationIndex: Int, context: Context): Boolean {
val expiresInSeconds = EditFilterActivity.getSecondsForDurationIndex(durationIndex, context)
api.updateFilter(
id = originalFilter.id,
@ -167,18 +168,18 @@ class EditFilterViewModel @Inject constructor(val api: MastodonApi, val eventHub
)
}
private suspend fun createFilterV1(context: List<String>, expiresInSeconds: Int?): Boolean {
private suspend fun createFilterV1(contexts: List<FilterContext>, expiresInSeconds: Int?): Boolean {
return keywords.value.map { keyword ->
api.createFilterV1(keyword.keyword, context, false, keyword.wholeWord, expiresInSeconds)
api.createFilterV1(keyword.keyword, contexts, false, keyword.wholeWord, expiresInSeconds)
}.none { it.isFailure }
}
private suspend fun updateFilterV1(context: List<String>, expiresInSeconds: Int?): Boolean {
private suspend fun updateFilterV1(contexts: List<FilterContext>, expiresInSeconds: Int?): Boolean {
val results = keywords.value.map { keyword ->
if (originalFilter == null) {
api.createFilterV1(
phrase = keyword.keyword,
context = context,
context = contexts,
irreversible = false,
wholeWord = keyword.wholeWord,
expiresInSeconds = expiresInSeconds,
@ -187,7 +188,7 @@ class EditFilterViewModel @Inject constructor(val api: MastodonApi, val eventHub
api.updateFilterV1(
id = originalFilter!!.id,
phrase = keyword.keyword,
context = context,
context = contexts,
irreversible = false,
wholeWord = keyword.wholeWord,
expiresInSeconds = expiresInSeconds,

View File

@ -22,7 +22,7 @@ class FiltersAdapter(val listener: FiltersListener, val filters: List<Filter>) :
val binding = holder.binding
val resources = binding.root.resources
val actions = resources.getStringArray(R.array.filter_actions)
val contexts = resources.getStringArray(R.array.filter_contexts)
val filterContextNames = resources.getStringArray(R.array.filter_contexts)
val filter = filters[position]
val context = binding.root.context
@ -37,7 +37,7 @@ class FiltersAdapter(val listener: FiltersListener, val filters: List<Filter>) :
binding.textSecondary.text = context.getString(
R.string.filter_description_format,
actions.getOrNull(filter.action.ordinal - 1),
filter.context.map { contexts.getOrNull(Filter.Kind.from(it).ordinal) }.joinToString("/"),
filter.contexts.map { filterContextNames.getOrNull(it.ordinal) }.joinToString("/"),
)
binding.delete.setOnClickListener {

View File

@ -70,8 +70,8 @@ class FiltersViewModel @Inject constructor(
api.deleteFilter(filter.id).fold(
{
this@FiltersViewModel._state.value = State(this@FiltersViewModel._state.value.filters.filter { it.id != filter.id }, LoadingState.LOADED)
for (context in filter.context) {
eventHub.dispatch(FilterChangedEvent(Filter.Kind.from(context)))
for (context in filter.contexts) {
eventHub.dispatch(FilterChangedEvent(context))
}
},
{ throwable ->
@ -79,8 +79,8 @@ class FiltersViewModel @Inject constructor(
api.deleteFilterV1(filter.id).fold(
{
this@FiltersViewModel._state.value = State(this@FiltersViewModel._state.value.filters.filter { it.id != filter.id }, LoadingState.LOADED)
filter.context.forEach {
eventHub.dispatch(FilterChangedEvent(Filter.Kind.from(it)))
filter.contexts.forEach {
eventHub.dispatch(FilterChangedEvent(it))
}
},
{

View File

@ -35,6 +35,7 @@ import app.pachli.components.timeline.FiltersRepository
import app.pachli.components.timeline.util.ifExpected
import app.pachli.core.accounts.AccountManager
import app.pachli.core.network.model.Filter
import app.pachli.core.network.model.FilterContext
import app.pachli.core.network.model.Notification
import app.pachli.core.network.model.Poll
import app.pachli.core.preferences.PrefKeys
@ -472,7 +473,7 @@ class NotificationsViewModel @Inject constructor(
viewModelScope.launch {
eventHub.events
.filterIsInstance<FilterChangedEvent>()
.filter { it.filterKind == Filter.Kind.NOTIFICATIONS }
.filter { it.filterContext == FilterContext.NOTIFICATIONS }
.map {
getFilters()
repository.invalidate()
@ -538,8 +539,8 @@ class NotificationsViewModel @Inject constructor(
private fun getFilters() = viewModelScope.launch {
try {
filterModel = when (val filters = filtersRepository.getFilters()) {
is FilterKind.V1 -> FilterModel(Filter.Kind.NOTIFICATIONS, filters.filters)
is FilterKind.V2 -> FilterModel(Filter.Kind.NOTIFICATIONS)
is FilterKind.V1 -> FilterModel(FilterContext.NOTIFICATIONS, filters.filters)
is FilterKind.V2 -> FilterModel(FilterContext.NOTIFICATIONS)
}
} catch (throwable: Throwable) {
_uiErrorChannel.send(UiError.GetFilters(throwable))

View File

@ -46,6 +46,7 @@ import app.pachli.components.timeline.FiltersRepository
import app.pachli.components.timeline.util.ifExpected
import app.pachli.core.accounts.AccountManager
import app.pachli.core.network.model.Filter
import app.pachli.core.network.model.FilterContext
import app.pachli.core.network.model.Poll
import app.pachli.core.network.model.Status
import app.pachli.core.network.model.TimelineKind
@ -521,7 +522,7 @@ abstract class TimelineViewModel(
/** Updates the current set of filters if filter-related preferences change */
private fun updateFiltersFromPreferences() = eventHub.events
.filterIsInstance<FilterChangedEvent>()
.filter { filterContextMatchesKind(timelineKind, listOf(it.filterKind)) }
.filter { filterContextMatchesKind(timelineKind, listOf(it.filterContext)) }
.map {
getFilters()
Timber.d("Reload because FilterChangedEvent")
@ -534,10 +535,10 @@ abstract class TimelineViewModel(
viewModelScope.launch {
Timber.d("getFilters()")
try {
val filterKind = Filter.Kind.from(timelineKind)
val filterContext = FilterContext.from(timelineKind)
filterModel = when (val filters = filtersRepository.getFilters()) {
is FilterKind.V1 -> FilterModel(filterKind, filters.filters)
is FilterKind.V2 -> FilterModel(filterKind)
is FilterKind.V1 -> FilterModel(filterContext, filters.filters)
is FilterKind.V2 -> FilterModel(filterContext)
}
} catch (throwable: Throwable) {
Timber.d(throwable, "updateFilter(): Error fetching filters")
@ -635,9 +636,9 @@ abstract class TimelineViewModel(
fun filterContextMatchesKind(
timelineKind: TimelineKind,
filterContext: List<Filter.Kind>,
filterContext: List<FilterContext>,
): Boolean {
return filterContext.contains(Filter.Kind.from(timelineKind))
return filterContext.contains(FilterContext.from(timelineKind))
}
}
}

View File

@ -20,7 +20,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.pachli.appstore.EventHub
import app.pachli.appstore.FilterChangedEvent
import app.pachli.core.network.model.Filter
import app.pachli.core.network.model.FilterContext
import app.pachli.core.network.model.TrendingTag
import app.pachli.core.network.model.end
import app.pachli.core.network.model.start
@ -96,7 +96,7 @@ class TrendingTagsViewModel @Inject constructor(
TrendingTagsUiState(emptyList(), LoadingState.LOADED)
} else {
val homeFilters = deferredFilters.await().getOrNull()?.filter { filter ->
filter.context.contains(Filter.Kind.HOME.kind)
filter.contexts.contains(FilterContext.HOME)
}
val tags = tagResponse
.filter { tag ->

View File

@ -38,6 +38,7 @@ import app.pachli.core.database.model.AccountEntity
import app.pachli.core.database.model.TranslatedStatusEntity
import app.pachli.core.database.model.TranslationState
import app.pachli.core.network.model.Filter
import app.pachli.core.network.model.FilterContext
import app.pachli.core.network.model.Poll
import app.pachli.core.network.model.Status
import app.pachli.core.network.retrofit.MastodonApi
@ -112,7 +113,7 @@ class ViewThreadViewModel @Inject constructor(
is StatusDeletedEvent -> handleStatusDeletedEvent(event)
is StatusEditedEvent -> handleStatusEditedEvent(event)
is FilterChangedEvent -> {
if (event.filterKind == Filter.Kind.THREAD) {
if (event.filterContext == FilterContext.THREAD) {
loadFilters()
}
}
@ -527,8 +528,8 @@ class ViewThreadViewModel @Inject constructor(
viewModelScope.launch {
try {
filterModel = when (val filters = filtersRepository.getFilters()) {
is FilterKind.V1 -> FilterModel(Filter.Kind.THREAD, filters.filters)
is FilterKind.V2 -> FilterModel(Filter.Kind.THREAD)
is FilterKind.V1 -> FilterModel(FilterContext.THREAD, filters.filters)
is FilterKind.V2 -> FilterModel(FilterContext.THREAD)
}
updateStatuses()
} catch (_: Exception) {

View File

@ -1,6 +1,7 @@
package app.pachli.network
import app.pachli.core.network.model.Filter
import app.pachli.core.network.model.FilterContext
import app.pachli.core.network.model.FilterV1
import app.pachli.core.network.model.Status
import app.pachli.core.network.parseAsMastodonHtml
@ -10,16 +11,16 @@ import java.util.regex.Pattern
/**
* Filter statuses using V1 or V2 filters.
*
* Construct with [filterKind] 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.
*/
class FilterModel constructor(private val filterKind: Filter.Kind, v1filters: List<FilterV1>? = null) {
class FilterModel(private val filterContext: FilterContext, v1filters: List<FilterV1>? = null) {
/** Pattern to use when matching v1 filters against a status. Null if these are v2 filters */
private var pattern: Pattern? = null
init {
pattern = v1filters?.let { list ->
makeFilter(list.filter { it.context.contains(filterKind.kind) })
makeFilter(list.filter { it.contexts.contains(filterContext) })
}
}
@ -48,7 +49,7 @@ class FilterModel constructor(private val filterKind: Filter.Kind, v1filters: Li
}
val matchingKind = status.filtered?.filter { result ->
result.filter.kinds.contains(filterKind)
result.filter.contexts.contains(filterContext)
}
return if (matchingKind.isNullOrEmpty()) {

View File

@ -21,6 +21,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import app.pachli.components.filters.EditFilterActivity
import app.pachli.core.network.model.Attachment
import app.pachli.core.network.model.Filter
import app.pachli.core.network.model.FilterContext
import app.pachli.core.network.model.FilterV1
import app.pachli.core.network.model.Poll
import app.pachli.core.network.model.PollOption
@ -45,7 +46,7 @@ class FilterV1Test {
FilterV1(
id = "123",
phrase = "badWord",
context = listOf(FilterV1.HOME),
contexts = listOf(FilterContext.HOME),
expiresAt = null,
irreversible = false,
wholeWord = false,
@ -53,7 +54,7 @@ class FilterV1Test {
FilterV1(
id = "123",
phrase = "badWholeWord",
context = listOf(FilterV1.HOME, FilterV1.PUBLIC),
contexts = listOf(FilterContext.HOME, FilterContext.PUBLIC),
expiresAt = null,
irreversible = false,
wholeWord = true,
@ -61,7 +62,7 @@ class FilterV1Test {
FilterV1(
id = "123",
phrase = "@twitter.com",
context = listOf(FilterV1.HOME),
contexts = listOf(FilterContext.HOME),
expiresAt = null,
irreversible = false,
wholeWord = true,
@ -69,7 +70,7 @@ class FilterV1Test {
FilterV1(
id = "123",
phrase = "#hashtag",
context = listOf(FilterV1.HOME),
contexts = listOf(FilterContext.HOME),
expiresAt = null,
irreversible = false,
wholeWord = true,
@ -77,7 +78,7 @@ class FilterV1Test {
FilterV1(
id = "123",
phrase = "expired",
context = listOf(FilterV1.HOME),
contexts = listOf(FilterContext.HOME),
expiresAt = Date.from(Instant.now().minusSeconds(10)),
irreversible = false,
wholeWord = true,
@ -85,7 +86,7 @@ class FilterV1Test {
FilterV1(
id = "123",
phrase = "unexpired",
context = listOf(FilterV1.HOME),
contexts = listOf(FilterContext.HOME),
expiresAt = Date.from(Instant.now().plusSeconds(3600)),
irreversible = false,
wholeWord = true,
@ -93,14 +94,14 @@ class FilterV1Test {
FilterV1(
id = "123",
phrase = "href",
context = listOf(FilterV1.HOME),
contexts = listOf(FilterContext.HOME),
expiresAt = null,
irreversible = false,
wholeWord = false,
),
)
filterModel = FilterModel(Filter.Kind.HOME, filters)
filterModel = FilterModel(FilterContext.HOME, filters)
}
@Test

View File

@ -24,6 +24,7 @@ import app.pachli.core.mastodon.model.MediaUploadApi
import app.pachli.core.network.BuildConfig
import app.pachli.core.network.json.BooleanIfNull
import app.pachli.core.network.json.DefaultIfNull
import app.pachli.core.network.json.EnumConstantConverterFactory
import app.pachli.core.network.json.Guarded
import app.pachli.core.network.json.HasDefault
import app.pachli.core.network.retrofit.InstanceSwitchAuthInterceptor
@ -127,6 +128,7 @@ object NetworkModule {
): Retrofit {
return Retrofit.Builder().baseUrl("https://" + MastodonApi.PLACEHOLDER_DOMAIN)
.client(httpClient)
.addConverterFactory(EnumConstantConverterFactory)
.addConverterFactory(MoshiConverterFactory.create(moshi))
.addCallAdapterFactory(NetworkResultCallAdapterFactory.create())
.build()

View File

@ -0,0 +1,54 @@
/*
* 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.network.json
import com.squareup.moshi.Json
import java.lang.reflect.Type
import retrofit2.Converter
import retrofit2.Retrofit
/**
* Retrofit [Converter.Factory] that converts enum constants to strings using the
* value of any `@Json(name = ...)` annotation on the constant, falling back to
* the enum's name if the annotation is not present.
*
* This ensures that the same string is used for an enum constant's value whether
* it is sent/received as JSON or sent as a [retrofit2.http.FormUrlEncoded] value.
*
* To install in Retrofit call `.addConverterFactory(EnumConstantConverterFactory)`
* on the Retrofit builder.
*/
object EnumConstantConverterFactory : Converter.Factory() {
object EnumConstantConverter : Converter<Enum<*>, String> {
override fun convert(enum: Enum<*>): String {
return try {
enum.javaClass.getField(enum.name).getAnnotation(Json::class.java)?.name
} catch (_: Exception) {
null
} ?: enum.toString()
}
}
override fun stringConverter(
type: Type,
annotations: Array<out Annotation>,
retrofit: Retrofit,
): Converter<Enum<*>, String>? {
return if (type is Class<*> && type.isEnum) EnumConstantConverter else null
}
}

View File

@ -13,9 +13,9 @@ import kotlinx.parcelize.Parcelize
data class Filter(
val id: String,
val title: String,
val context: List<String>,
@Json(name = "context") val contexts: List<FilterContext>,
@Json(name = "expires_at") val expiresAt: Date?,
@Json(name = "filter_action") val filterAction: String,
@Json(name = "filter_action") val action: Action,
// This should not normally be empty. However, Mastodon does not include
// this in a status' `filtered.filter` property (it's not null or empty,
// it's missing) which breaks deserialisation. Patch this by ensuring it's
@ -26,48 +26,14 @@ data class Filter(
) : Parcelable {
@HasDefault
enum class Action(val action: String) {
@Json(name = "none")
NONE("none"),
@Json(name = "warn")
@Default
WARN("warn"),
@Json(name = "hide")
HIDE("hide"),
;
companion object {
fun from(action: String): Action = entries.firstOrNull { it.action == action } ?: WARN
}
}
@HasDefault
enum class Kind(val kind: String) {
HOME("home"),
NOTIFICATIONS("notifications"),
@Default
PUBLIC("public"),
THREAD("thread"),
ACCOUNT("account"),
;
companion object {
fun from(kind: String): Kind = entries.firstOrNull { it.kind == kind } ?: PUBLIC
fun from(kind: TimelineKind): Kind = when (kind) {
is TimelineKind.Home, is TimelineKind.UserList -> HOME
is TimelineKind.PublicFederated,
is TimelineKind.PublicLocal,
is TimelineKind.Tag,
is TimelineKind.Favourites,
-> PUBLIC
is TimelineKind.User -> ACCOUNT
else -> PUBLIC
}
}
}
val action: Action
get() = Action.from(filterAction)
val kinds: List<Kind>
get() = context.map { Kind.from(it) }
}

View File

@ -0,0 +1,68 @@
/*
* 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.network.model
import app.pachli.core.network.json.Default
import app.pachli.core.network.json.HasDefault
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/**
* The contexts in which a filter should be applied, for both a
* [v2](https://docs.joinmastodon.org/entities/Filter/#context) and
* [v1](https://docs.joinmastodon.org/entities/V1_Filter/#context) Mastodon
* filter. The API versions have identical contexts.
*/
@JsonClass(generateAdapter = false)
@HasDefault
enum class FilterContext {
/** Filter applies to home timeline and lists */
@Json(name = "home")
HOME,
/** Filter applies to notifications */
@Json(name = "notifications")
NOTIFICATIONS,
/** Filter applies to public timelines */
@Default
@Json(name = "public")
PUBLIC,
/** Filter applies to expanded thread */
@Json(name = "thread")
THREAD,
/** Filter applies when viewing a profile */
@Json(name = "account")
ACCOUNT,
;
companion object {
fun from(kind: TimelineKind): FilterContext = when (kind) {
is TimelineKind.Home, is TimelineKind.UserList -> HOME
is TimelineKind.PublicFederated,
is TimelineKind.PublicLocal,
is TimelineKind.Tag,
is TimelineKind.Favourites,
-> PUBLIC
is TimelineKind.User -> ACCOUNT
else -> PUBLIC
}
}
}

View File

@ -24,19 +24,11 @@ import java.util.Date
data class FilterV1(
val id: String,
val phrase: String,
val context: List<String>,
@Json(name = "context") val contexts: List<FilterContext>,
@Json(name = "expires_at") val expiresAt: Date?,
val irreversible: Boolean,
@Json(name = "whole_word") val wholeWord: Boolean,
) {
companion object {
const val HOME = "home"
const val NOTIFICATIONS = "notifications"
const val PUBLIC = "public"
const val THREAD = "thread"
const val ACCOUNT = "account"
}
override fun hashCode(): Int {
return id.hashCode()
}
@ -53,9 +45,9 @@ data class FilterV1(
return Filter(
id = id,
title = phrase,
context = context,
contexts = contexts,
expiresAt = expiresAt,
filterAction = Filter.Action.WARN.action,
action = Filter.Action.WARN,
keywords = listOf(
FilterKeyword(
id = id,

View File

@ -25,6 +25,7 @@ import app.pachli.core.network.model.Conversation
import app.pachli.core.network.model.DeletedStatus
import app.pachli.core.network.model.Emoji
import app.pachli.core.network.model.Filter
import app.pachli.core.network.model.FilterContext
import app.pachli.core.network.model.FilterKeyword
import app.pachli.core.network.model.FilterV1
import app.pachli.core.network.model.HashTag
@ -614,7 +615,7 @@ interface MastodonApi {
@POST("api/v1/filters")
suspend fun createFilterV1(
@Field("phrase") phrase: String,
@Field("context[]") context: List<String>,
@Field("context[]") context: List<FilterContext>,
@Field("irreversible") irreversible: Boolean?,
@Field("whole_word") wholeWord: Boolean?,
@Field("expires_in") expiresInSeconds: Int?,
@ -625,7 +626,7 @@ interface MastodonApi {
suspend fun updateFilterV1(
@Path("id") id: String,
@Field("phrase") phrase: String,
@Field("context[]") context: List<String>,
@Field("context[]") context: List<FilterContext>,
@Field("irreversible") irreversible: Boolean?,
@Field("whole_word") wholeWord: Boolean?,
@Field("expires_in") expiresInSeconds: Int?,
@ -640,7 +641,7 @@ interface MastodonApi {
@POST("api/v2/filters")
suspend fun createFilter(
@Field("title") title: String,
@Field("context[]") context: List<String>,
@Field("context[]") context: List<FilterContext>,
@Field("filter_action") filterAction: String,
@Field("expires_in") expiresInSeconds: Int?,
): NetworkResult<Filter>
@ -650,7 +651,7 @@ interface MastodonApi {
suspend fun updateFilter(
@Path("id") id: String,
@Field("title") title: String? = null,
@Field("context[]") context: List<String>? = null,
@Field("context[]") context: List<FilterContext>? = null,
@Field("filter_action") filterAction: String? = null,
@Field("expires_in") expiresInSeconds: Int? = null,
): NetworkResult<Filter>

View File

@ -0,0 +1,26 @@
package app.pachli.core.network.json
import com.google.common.truth.Truth.assertThat
import com.squareup.moshi.Json
import org.junit.Test
class EnumConstantConverterFactoryTest {
enum class Enum {
@Json(name = "one")
ONE,
TWO,
}
private val converter = EnumConstantConverterFactory.EnumConstantConverter
@Test
fun `Annotated enum constant uses annotation`() {
assertThat(converter.convert(Enum.ONE)).isEqualTo("one")
}
@Test
fun `Unannotated enum constant uses constant name`() {
assertThat(converter.convert(Enum.TWO)).isEqualTo("TWO")
}
}