only check once for filters v2 availability (#4539)

Instead of calling the endpoint every time filters are needed, it will
be called only once and the result cached. This will result in quite
some requests less on instances supporting v2.

I also tested v1 filters and made some small improvements. We should
[remove filters v1
support](https://github.com/tuskyapp/Tusky/issues/4538) some time in the
future though.
This commit is contained in:
Konrad Pozniak 2024-07-03 21:18:09 +02:00 committed by GitHub
parent 859ffd121e
commit 8a57bcc3f4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 1470 additions and 127 deletions

File diff suppressed because it is too large Load Diff

View File

@ -212,7 +212,7 @@ class StatusListActivity : BottomSheetActivity() {
mastodonApi.getFiltersV1().fold(
{ filters ->
mutedFilterV1 = filters.firstOrNull { filter ->
hashedTag == filter.phrase && filter.context.contains(FilterV1.HOME)
hashedTag == filter.phrase && filter.context.contains(Filter.Kind.HOME.kind)
}
updateTagMuteState(mutedFilterV1 != null)
},
@ -249,7 +249,7 @@ class StatusListActivity : BottomSheetActivity() {
mastodonApi.createFilter(
title = "#$tag",
context = listOf(FilterV1.HOME),
context = listOf(Filter.Kind.HOME.kind),
filterAction = Filter.Action.WARN.action,
expiresInSeconds = null
).fold(
@ -278,7 +278,7 @@ class StatusListActivity : BottomSheetActivity() {
if (throwable.isHttpNotFound()) {
mastodonApi.createFilterV1(
hashedTag,
listOf(FilterV1.HOME),
listOf(Filter.Kind.HOME.kind),
irreversible = false,
wholeWord = true,
expiresInSeconds = null
@ -355,7 +355,7 @@ class StatusListActivity : BottomSheetActivity() {
mastodonApi.updateFilterV1(
id = filter.id,
phrase = filter.phrase,
context = filter.context.filter { it != FilterV1.HOME },
context = filter.context.filter { it != Filter.Kind.HOME.kind },
irreversible = null,
wholeWord = null,
expiresInSeconds = null

View File

@ -107,6 +107,10 @@ class InstanceInfoRepository @Inject constructor(
}
}.toInfoOrDefault()
suspend fun saveFilterV2Support(filterV2Supported: Boolean) = dao.setFilterV2Support(instanceName, filterV2Supported)
suspend fun isFilterV2Supported(): Boolean = dao.getFilterV2Support(instanceName)
private suspend fun InstanceInfoRepository.fetchAndPersistInstanceInfo(): NetworkResult<InstanceInfoEntity> =
fetchRemoteInstanceInfo()
.onSuccess { instanceInfoEntity ->

View File

@ -127,11 +127,17 @@ class NotificationsViewModel @Inject constructor(
onPreferenceChanged(event.preferenceKey)
}
if (event is FilterUpdatedEvent && event.filterContext.contains(Filter.Kind.NOTIFICATIONS.kind)) {
filterModel.init(Filter.Kind.NOTIFICATIONS)
refreshTrigger.value += 1
}
}
}
filterModel.kind = Filter.Kind.NOTIFICATIONS
viewModelScope.launch {
val needsRefresh = filterModel.init(Filter.Kind.NOTIFICATIONS)
if (needsRefresh) {
refreshTrigger.value++
}
}
}
fun updateNotificationFilters(newFilters: Set<Notification.Type>) {

View File

@ -21,10 +21,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.PagingData
import at.connyduck.calladapter.networkresult.NetworkResult
import at.connyduck.calladapter.networkresult.fold
import at.connyduck.calladapter.networkresult.getOrElse
import at.connyduck.calladapter.networkresult.getOrThrow
import com.keylesspalace.tusky.appstore.Event
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.FilterUpdatedEvent
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
@ -32,13 +29,11 @@ import com.keylesspalace.tusky.components.preference.PreferencesFragment.Reading
import com.keylesspalace.tusky.components.timeline.util.ifExpected
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.entity.Filter
import com.keylesspalace.tusky.entity.FilterV1
import com.keylesspalace.tusky.entity.Poll
import com.keylesspalace.tusky.network.FilterModel
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.usecase.TimelineCases
import com.keylesspalace.tusky.util.isHttpNotFound
import com.keylesspalace.tusky.viewdata.StatusViewData
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
@ -73,7 +68,6 @@ abstract class TimelineViewModel(
this.kind = kind
this.id = id
this.tags = tags
filterModel.kind = kind.toFilterKind()
if (kind == Kind.HOME) {
// Note the variable is "true if filter" but the underlying preference/settings text is "true if show"
@ -91,10 +85,27 @@ abstract class TimelineViewModel(
viewModelScope.launch {
eventHub.events
.collect { event -> handleEvent(event) }
.collect { event ->
when (event) {
is PreferenceChangedEvent -> {
onPreferenceChanged(event.preferenceKey)
}
is FilterUpdatedEvent -> {
if (filterContextMatchesKind(this@TimelineViewModel.kind, event.filterContext)) {
filterModel.init(kind.toFilterKind())
fullReload()
}
}
}
}
}
reloadFilters()
viewModelScope.launch {
val needsRefresh = filterModel.init(kind.toFilterKind())
if (needsRefresh) {
fullReload()
}
}
}
fun reblog(reblog: Boolean, status: StatusViewData.Concrete): Job = viewModelScope.launch {
@ -208,11 +219,6 @@ abstract class TimelineViewModel(
fullReload()
}
}
FilterV1.HOME, FilterV1.NOTIFICATIONS, FilterV1.THREAD, FilterV1.PUBLIC, FilterV1.ACCOUNT -> {
if (filterContextMatchesKind(kind, listOf(key))) {
reloadFilters()
}
}
PrefKeys.ALWAYS_SHOW_SENSITIVE_MEDIA -> {
// it is ok if only newly loaded statuses are affected, no need to fully refresh
alwaysShowSensitiveMedia =
@ -224,50 +230,6 @@ abstract class TimelineViewModel(
}
}
private fun handleEvent(event: Event) {
when (event) {
is PreferenceChangedEvent -> {
onPreferenceChanged(event.preferenceKey)
}
is FilterUpdatedEvent -> {
if (filterContextMatchesKind(kind, event.filterContext)) {
fullReload()
}
}
}
}
private fun reloadFilters() {
viewModelScope.launch {
api.getFilters().fold(
{
// After the filters are loaded we need to reload displayed content to apply them.
// It can happen during the usage or at startup, when we get statuses before filters.
invalidate()
},
{ throwable ->
if (throwable.isHttpNotFound()) {
// Fallback to client-side filter code
val filters = api.getFiltersV1().getOrElse {
Log.e(TAG, "Failed to fetch filters", it)
return@launch
}
filterModel.initWithFilters(
filters.filter {
filterContextMatchesKind(kind, it.context)
}
)
// After the filters are loaded we need to reload displayed content to apply them.
// It can happen during the usage or at startup, when we get statuses before filters.
invalidate()
} else {
Log.e(TAG, "Error getting filters", throwable)
}
}
)
}
}
abstract suspend fun translate(status: StatusViewData.Concrete): NetworkResult<Unit>
abstract fun untranslate(status: StatusViewData.Concrete)

View File

@ -35,12 +35,10 @@ import com.keylesspalace.tusky.components.timeline.util.ifExpected
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.entity.Filter
import com.keylesspalace.tusky.entity.FilterV1
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.network.FilterModel
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.usecase.TimelineCases
import com.keylesspalace.tusky.util.isHttpNotFound
import com.keylesspalace.tusky.util.toViewData
import com.keylesspalace.tusky.viewdata.StatusViewData
import com.keylesspalace.tusky.viewdata.TranslationViewData
@ -101,8 +99,6 @@ class ViewThreadViewModel @Inject constructor(
}
}
}
loadFilters()
}
fun loadThread(id: String) {
@ -110,6 +106,8 @@ class ViewThreadViewModel @Inject constructor(
viewModelScope.launch {
Log.d(TAG, "Finding status with: $id")
val filterCall = async { filterModel.init(Filter.Kind.THREAD) }
val contextCall = async { api.statusContext(id) }
val statusAndAccount = db.timelineStatusDao().getStatusWithAccount(accountManager.activeAccount!!.id, id)
@ -152,6 +150,7 @@ class ViewThreadViewModel @Inject constructor(
}
}
filterCall.await() // make sure FilterModel is initialized before using it
val contextResult = contextCall.await()
contextResult.fold({ statusContext ->
@ -420,42 +419,6 @@ class ViewThreadViewModel @Inject constructor(
return RevealButtonState.NO_BUTTON
}
private fun loadFilters() {
viewModelScope.launch {
api.getFilters().fold(
{
filterModel.kind = Filter.Kind.THREAD
updateStatuses()
},
{ throwable ->
if (throwable.isHttpNotFound()) {
val filters = api.getFiltersV1().getOrElse {
Log.w(TAG, "Failed to fetch filters", it)
return@launch
}
filterModel.initWithFilters(
filters.filter { filter -> filter.context.contains(FilterV1.THREAD) }
)
updateStatuses()
} else {
Log.e(TAG, "Error getting filters", throwable)
}
}
)
}
}
private fun updateStatuses() {
updateSuccess { uiState ->
val statuses = uiState.statusViewData.filter()
uiState.copy(
statusViewData = statuses,
revealButton = statuses.getRevealButtonState()
)
}
}
private fun List<StatusViewData.Concrete>.filter(): List<StatusViewData.Concrete> {
return filter { status ->
if (status.isDetailed) {

View File

@ -62,14 +62,15 @@ import java.io.File;
},
// Note: Starting with version 54, database versions in Tusky are always even.
// This is to reserve odd version numbers for use by forks.
version = 62,
version = 64,
autoMigrations = {
@AutoMigration(from = 48, to = 49),
@AutoMigration(from = 49, to = 50, spec = AppDatabase.MIGRATION_49_50.class),
@AutoMigration(from = 50, to = 51),
@AutoMigration(from = 51, to = 52),
@AutoMigration(from = 53, to = 54), // hasDirectMessageBadge in AccountEntity
@AutoMigration(from = 56, to = 58) // translationEnabled in InstanceEntity/InstanceInfoEntity
@AutoMigration(from = 56, to = 58), // translationEnabled in InstanceEntity/InstanceInfoEntity
@AutoMigration(from = 62, to = 64) // filterV2Available in InstanceEntity
}
)
public abstract class AppDatabase extends RoomDatabase {

View File

@ -39,4 +39,10 @@ interface InstanceDao {
@RewriteQueriesToDropUnusedColumns
@Query("SELECT * FROM InstanceEntity WHERE instance = :instance LIMIT 1")
suspend fun getEmojiInfo(instance: String): EmojisEntity?
@Query("UPDATE InstanceEntity SET filterV2Supported = :filterV2Support WHERE instance = :instance")
suspend fun setFilterV2Support(instance: String, filterV2Support: Boolean)
@Query("SELECT filterV2Supported FROM InstanceEntity WHERE instance = :instance LIMIT 1")
suspend fun getFilterV2Support(instance: String): Boolean
}

View File

@ -15,6 +15,7 @@
package com.keylesspalace.tusky.db.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
import androidx.room.TypeConverters
@ -41,6 +42,8 @@ data class InstanceEntity(
val maxFieldNameLength: Int?,
val maxFieldValueLength: Int?,
val translationEnabled: Boolean?,
// ToDo: Remove this again when filter v1 support is dropped
@ColumnInfo(defaultValue = "false") val filterV2Supported: Boolean = false
)
@TypeConverters(Converters::class)

View File

@ -28,13 +28,6 @@ data class FilterV1(
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()

View File

@ -1,8 +1,13 @@
package com.keylesspalace.tusky.network
import android.util.Log
import at.connyduck.calladapter.networkresult.fold
import at.connyduck.calladapter.networkresult.getOrElse
import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository
import com.keylesspalace.tusky.entity.Filter
import com.keylesspalace.tusky.entity.FilterV1
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.util.isHttpNotFound
import com.keylesspalace.tusky.util.parseAsMastodonHtml
import java.util.Date
import java.util.regex.Pattern
@ -11,17 +16,54 @@ import javax.inject.Inject
/**
* One-stop for status filtering logic using Mastodon's filters.
*
* 1. You init with [initWithFilters], this compiles regex pattern.
* 1. You init with [init], this checks which filter version to use and compiles regex pattern if needed.
* 2. You call [shouldFilterStatus] to figure out what to display when you load statuses.
*/
class FilterModel @Inject constructor() {
class FilterModel @Inject constructor(
private val instanceInfoRepo: InstanceInfoRepository,
private val api: MastodonApi
) {
private var pattern: Pattern? = null
private var v1 = false
lateinit var kind: Filter.Kind
private lateinit var kind: Filter.Kind
fun initWithFilters(filters: List<FilterV1>) {
v1 = true
this.pattern = makeFilter(filters)
/**
* @param kind the [Filter.Kind] that should be filtered
* @return true when filters v1 have been loaded successfully and the currently shown posts may need to be filtered
*/
suspend fun init(kind: Filter.Kind): Boolean {
this.kind = kind
if (instanceInfoRepo.isFilterV2Supported()) {
// nothing to do - Instance supports V2 so posts are filtered by the server
return false
}
api.getFilters().fold(
{
instanceInfoRepo.saveFilterV2Support(true)
return false
},
{ throwable ->
if (throwable.isHttpNotFound()) {
val filters = api.getFiltersV1().getOrElse {
Log.w(TAG, "Failed to fetch filters", it)
return false
}
this.v1 = true
val activeFilters = filters.filter { filter -> filter.context.contains(kind.kind) }
this.pattern = makeFilter(activeFilters)
return activeFilters.isNotEmpty()
} else {
Log.e(TAG, "Error getting filters", throwable)
return false
}
}
)
}
fun shouldFilterStatus(status: Status): Filter.Action {
@ -81,6 +123,7 @@ class FilterModel @Inject constructor() {
}
companion object {
private const val TAG = "FilterModel"
private val ALPHANUMERIC = Pattern.compile("^\\w+$")
}
}

View File

@ -18,7 +18,9 @@
package com.keylesspalace.tusky
import androidx.test.ext.junit.runners.AndroidJUnit4
import at.connyduck.calladapter.networkresult.NetworkResult
import com.keylesspalace.tusky.components.filters.EditFilterActivity
import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository
import com.keylesspalace.tusky.entity.Attachment
import com.keylesspalace.tusky.entity.Filter
import com.keylesspalace.tusky.entity.FilterV1
@ -26,14 +28,20 @@ import com.keylesspalace.tusky.entity.Poll
import com.keylesspalace.tusky.entity.PollOption
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.network.FilterModel
import com.keylesspalace.tusky.network.MastodonApi
import java.time.Instant
import java.util.Date
import kotlinx.coroutines.runBlocking
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.kotlin.doReturn
import org.mockito.kotlin.mock
import org.robolectric.annotation.Config
import retrofit2.HttpException
import retrofit2.Response
@Config(sdk = [28])
@RunWith(AndroidJUnit4::class)
@ -43,12 +51,11 @@ class FilterV1Test {
@Before
fun setup() {
filterModel = FilterModel()
val filters = listOf(
FilterV1(
id = "123",
phrase = "badWord",
context = listOf(FilterV1.HOME),
context = listOf(Filter.Kind.HOME.kind),
expiresAt = null,
irreversible = false,
wholeWord = false
@ -56,7 +63,7 @@ class FilterV1Test {
FilterV1(
id = "123",
phrase = "badWholeWord",
context = listOf(FilterV1.HOME, FilterV1.PUBLIC),
context = listOf(Filter.Kind.HOME.kind, Filter.Kind.PUBLIC.kind),
expiresAt = null,
irreversible = false,
wholeWord = true
@ -64,7 +71,7 @@ class FilterV1Test {
FilterV1(
id = "123",
phrase = "@twitter.com",
context = listOf(FilterV1.HOME),
context = listOf(Filter.Kind.HOME.kind),
expiresAt = null,
irreversible = false,
wholeWord = true
@ -72,7 +79,7 @@ class FilterV1Test {
FilterV1(
id = "123",
phrase = "#hashtag",
context = listOf(FilterV1.HOME),
context = listOf(Filter.Kind.HOME.kind),
expiresAt = null,
irreversible = false,
wholeWord = true
@ -80,7 +87,7 @@ class FilterV1Test {
FilterV1(
id = "123",
phrase = "expired",
context = listOf(FilterV1.HOME),
context = listOf(Filter.Kind.HOME.kind),
expiresAt = Date.from(Instant.now().minusSeconds(10)),
irreversible = false,
wholeWord = true
@ -88,7 +95,7 @@ class FilterV1Test {
FilterV1(
id = "123",
phrase = "unexpired",
context = listOf(FilterV1.HOME),
context = listOf(Filter.Kind.HOME.kind),
expiresAt = Date.from(Instant.now().plusSeconds(3600)),
irreversible = false,
wholeWord = true
@ -96,14 +103,27 @@ class FilterV1Test {
FilterV1(
id = "123",
phrase = "href",
context = listOf(FilterV1.HOME),
context = listOf(Filter.Kind.HOME.kind),
expiresAt = null,
irreversible = false,
wholeWord = false
)
)
filterModel.initWithFilters(filters)
val api: MastodonApi = mock {
onBlocking { getFiltersV1() } doReturn NetworkResult.success(filters)
onBlocking { getFilters() } doReturn NetworkResult.failure(
HttpException(Response.error<Any>(404, "".toResponseBody()))
)
}
val instanceInfoRepo: InstanceInfoRepository = mock {
onBlocking { isFilterV2Supported() } doReturn false
}
filterModel = FilterModel(instanceInfoRepo, api)
runBlocking {
filterModel.init(Filter.Kind.HOME)
}
}
@Test

View File

@ -8,6 +8,7 @@ import androidx.test.platform.app.InstrumentationRegistry
import at.connyduck.calladapter.networkresult.NetworkResult
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.StatusChangedEvent
import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository
import com.keylesspalace.tusky.components.timeline.fakeStatus
import com.keylesspalace.tusky.components.timeline.fakeStatusViewData
import com.keylesspalace.tusky.db.AccountManager
@ -81,8 +82,11 @@ class ViewThreadViewModelTest {
api = mock {
onBlocking { getFilters() } doReturn NetworkResult.success(emptyList())
}
val instanceInfoRepo: InstanceInfoRepository = mock {
onBlocking { isFilterV2Supported() } doReturn false
}
eventHub = EventHub()
val filterModel = FilterModel()
val filterModel = FilterModel(instanceInfoRepo, api)
val timelineCases = TimelineCases(api, eventHub)
val accountManager: AccountManager = mock {
on { activeAccount } doReturn AccountEntity(