diff --git a/app/lint-baseline.xml b/app/lint-baseline.xml index 984a41bb8..05fe63164 100644 --- a/app/lint-baseline.xml +++ b/app/lint-baseline.xml @@ -894,7 +894,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -905,7 +905,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~"> @@ -916,7 +916,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~"> @@ -927,7 +927,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~"> @@ -938,7 +938,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~"> @@ -949,7 +949,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~"> @@ -960,7 +960,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~"> @@ -971,7 +971,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~"> @@ -982,7 +982,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~"> @@ -993,7 +993,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~"> @@ -1004,7 +1004,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~"> @@ -1015,7 +1015,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~"> @@ -1026,7 +1026,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -2159,7 +2159,7 @@ errorLine2=" ~~~~~~~~~~"> @@ -2170,7 +2170,7 @@ errorLine2=" ~~~~~~~~~~"> @@ -2179,17 +2179,6 @@ message="Access to `private` method `isSameDate` of class `Companion` requires synthetic accessor" errorLine1=" isSameDate(time, now, tz) -> sameDaySdf.format(time)" errorLine2=" ~~~~~~~~~~"> - - - - @@ -2214,7 +2203,18 @@ errorLine2=" ~~~~~~~~~~"> + + + + @@ -2225,7 +2225,7 @@ errorLine2=" ~~~~~~~"> @@ -2236,7 +2236,7 @@ errorLine2=" ~~~~~~~"> @@ -2247,7 +2247,7 @@ errorLine2=" ~~~~~~~"> @@ -2258,7 +2258,7 @@ errorLine2=" ~~~~~~~"> @@ -2269,7 +2269,7 @@ errorLine2=" ~~~~~~~"> @@ -2280,7 +2280,7 @@ errorLine2=" ~~~~~~~"> @@ -2291,7 +2291,7 @@ errorLine2=" ~~~~~~~~~~~~~"> @@ -2302,7 +2302,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~"> @@ -2313,7 +2313,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~"> @@ -2467,7 +2467,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~"> @@ -2478,7 +2478,7 @@ errorLine2=" ~~~~~~~~~"> @@ -2489,7 +2489,7 @@ errorLine2=" ~~~~~~~~~"> @@ -2500,7 +2500,7 @@ errorLine2=" ~~~~~~~~~~~~~~"> @@ -2511,7 +2511,7 @@ errorLine2=" ~~~~~~~~~~~~~"> @@ -2522,7 +2522,7 @@ errorLine2=" ~~~~~~~~~~~"> @@ -2533,7 +2533,7 @@ errorLine2=" ~~~~~~~~~~~~~"> @@ -2544,7 +2544,7 @@ errorLine2=" ~~~~~~~~~~~~~"> @@ -2555,7 +2555,7 @@ errorLine2=" ~~~~~~~"> @@ -2566,7 +2566,7 @@ errorLine2=" ~~~~~~~"> @@ -2654,7 +2654,7 @@ errorLine2=" ~~~~~~~~"> @@ -2665,7 +2665,7 @@ errorLine2=" ~~~~~~~~"> @@ -2929,7 +2929,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~"> @@ -2940,7 +2940,7 @@ errorLine2=" ~~~~~~~"> @@ -2951,7 +2951,7 @@ errorLine2=" ~~~~~~~"> @@ -2962,7 +2962,7 @@ errorLine2=" ~~~~~~~~~~"> @@ -2973,7 +2973,7 @@ errorLine2=" ~~~~~~~~~~"> @@ -2984,7 +2984,7 @@ errorLine2=" ~~~~~~~~~~"> @@ -2995,7 +2995,7 @@ errorLine2=" ~~~~~~~~~~"> @@ -3006,7 +3006,7 @@ errorLine2=" ~~~~~~~~~~"> @@ -3017,7 +3017,7 @@ errorLine2=" ~~~~~~~~~~"> @@ -3457,7 +3457,7 @@ errorLine2=" ~~~~~~~~~~~~"> @@ -3688,7 +3688,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~"> @@ -3732,7 +3732,7 @@ errorLine2=" ~~~~~~~~"> @@ -3743,7 +3743,7 @@ errorLine2=" ~~~~~~~~"> @@ -3798,7 +3798,7 @@ errorLine2=" ~~~~~~~~~"> @@ -3809,7 +3809,7 @@ errorLine2=" ~~~~~~~"> @@ -3820,7 +3820,7 @@ errorLine2=" ~~~~~~~"> @@ -3831,7 +3831,7 @@ errorLine2=" ~~~~~~~"> @@ -3842,7 +3842,7 @@ errorLine2=" ~~~~~~~"> @@ -3908,7 +3908,7 @@ errorLine2=" ~~~~~~~"> @@ -3919,7 +3919,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -5213,7 +5213,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -5224,7 +5224,7 @@ errorLine2=" ^"> @@ -5235,7 +5235,7 @@ errorLine2=" ^"> diff --git a/app/src/main/java/app/pachli/components/filters/EditFilterViewModel.kt b/app/src/main/java/app/pachli/components/filters/EditFilterViewModel.kt index 041dd14f7..ca8af2c4a 100644 --- a/app/src/main/java/app/pachli/components/filters/EditFilterViewModel.kt +++ b/app/src/main/java/app/pachli/components/filters/EditFilterViewModel.kt @@ -4,6 +4,7 @@ import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import app.pachli.appstore.EventHub +import app.pachli.appstore.FilterChangedEvent import app.pachli.entity.Filter import app.pachli.entity.FilterKeyword import app.pachli.network.MastodonApi @@ -88,9 +89,21 @@ class EditFilterViewModel @Inject constructor(val api: MastodonApi, val eventHub val action = action.value.action return withContext(viewModelScope.coroutineContext) { - originalFilter?.let { filter -> + val success = originalFilter?.let { filter -> updateFilter(filter, title, contexts, action, durationIndex, context) } ?: createFilter(title, contexts, action, durationIndex, context) + + // 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 originalKinds = originalFilter?.context?.map { Filter.Kind.from(it) } ?: emptyList() + val newKinds = contexts.map { Filter.Kind.from(it) } + (originalKinds + newKinds).distinct().forEach { + eventHub.dispatch(FilterChangedEvent(it)) + } + } + return@withContext success } } diff --git a/app/src/main/java/app/pachli/components/filters/FiltersViewModel.kt b/app/src/main/java/app/pachli/components/filters/FiltersViewModel.kt index be89b5eb1..3c8200917 100644 --- a/app/src/main/java/app/pachli/components/filters/FiltersViewModel.kt +++ b/app/src/main/java/app/pachli/components/filters/FiltersViewModel.kt @@ -75,6 +75,9 @@ 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))) + } }, { Snackbar.make(parent, "Error deleting filter '${filter.title}'", Snackbar.LENGTH_SHORT).show() diff --git a/app/src/main/java/app/pachli/components/notifications/NotificationsViewModel.kt b/app/src/main/java/app/pachli/components/notifications/NotificationsViewModel.kt index 5ea882279..f489e939d 100644 --- a/app/src/main/java/app/pachli/components/notifications/NotificationsViewModel.kt +++ b/app/src/main/java/app/pachli/components/notifications/NotificationsViewModel.kt @@ -307,7 +307,6 @@ class NotificationsViewModel @Inject constructor( private val timelineCases: TimelineCases, private val eventHub: EventHub, private val filtersRepository: FiltersRepository, - private val filterModel: FilterModel, statusDisplayOptionsRepository: StatusDisplayOptionsRepository, private val sharedPreferencesRepository: SharedPreferencesRepository, ) : ViewModel() { @@ -349,9 +348,9 @@ class NotificationsViewModel @Inject constructor( viewModelScope.launch { uiAction.emit(action) } } - init { - filterModel.kind = Filter.Kind.NOTIFICATIONS + private var filterModel: FilterModel? = null + init { // Handle changes to notification filters val notificationFilter = uiAction .filterIsInstance() @@ -472,8 +471,11 @@ class NotificationsViewModel @Inject constructor( viewModelScope.launch { eventHub.events .filterIsInstance() - .distinctUntilChanged() - .map { getFilters() } + .filter { it.filterKind == Filter.Kind.NOTIFICATIONS } + .map { + getFilters() + repository.invalidate() + } .onStart { getFilters() } .collect() } @@ -516,7 +518,7 @@ class NotificationsViewModel @Inject constructor( return repository.getNotificationsStream(filter = filters, initialKey = initialKey) .map { pagingData -> pagingData.map { notification -> - val filterAction = notification.status?.actionableStatus?.let { filterModel.shouldFilterStatus(it) } ?: Filter.Action.NONE + val filterAction = notification.status?.actionableStatus?.let { filterModel?.filterActionFor(it) } ?: Filter.Action.NONE NotificationViewData.from( notification, isShowingContent = statusDisplayOptions.value.showSensitiveMedia || @@ -531,25 +533,12 @@ class NotificationsViewModel @Inject constructor( } } - /** - * Gets the current filters from the repository. Applies them locally if they are - * v1 filters. - * - * Whatever the filter kind, the current timeline is invalidated, so it updates with the - * most recent filters. - */ + /** Gets the current filters from the repository. */ private fun getFilters() = viewModelScope.launch { try { - when (val filters = filtersRepository.getFilters()) { - is FilterKind.V1 -> { - filterModel.initWithFilters( - filters.filters.filter { - it.context.contains("notifications") - }, - ) - repository.invalidate() - } - is FilterKind.V2 -> repository.invalidate() + filterModel = when (val filters = filtersRepository.getFilters()) { + is FilterKind.V1 -> FilterModel(Filter.Kind.NOTIFICATIONS, filters.filters) + is FilterKind.V2 -> FilterModel(Filter.Kind.NOTIFICATIONS) } } catch (throwable: Throwable) { _uiErrorChannel.send(UiError.GetFilters(throwable)) diff --git a/app/src/main/java/app/pachli/components/timeline/TimelineFragment.kt b/app/src/main/java/app/pachli/components/timeline/TimelineFragment.kt index f85b99dc0..be8074b76 100644 --- a/app/src/main/java/app/pachli/components/timeline/TimelineFragment.kt +++ b/app/src/main/java/app/pachli/components/timeline/TimelineFragment.kt @@ -29,9 +29,11 @@ import android.view.accessibility.AccessibilityManager import androidx.core.content.ContextCompat import androidx.core.view.MenuProvider import androidx.fragment.app.viewModels +import androidx.lifecycle.DEFAULT_ARGS_KEY import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle +import androidx.lifecycle.viewmodel.MutableCreationExtras import androidx.paging.LoadState import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView @@ -99,11 +101,30 @@ class TimelineFragment : RefreshableFragment, MenuProvider { + // Create the correct view model. Do this lazily because it depends on the value of + // `timelineKind`, which won't be known until part way through `onCreate`. Pass this in + // the "extras" to the view model, which are populated in to the `SavedStateHandle` it + // takes as a parameter. + // + // If the navigation library was being used this would happen automatically, so this + // workaround can be removed when that change happens. private val viewModel: TimelineViewModel by lazy { if (timelineKind == TimelineKind.Home) { - viewModels().value + viewModels( + extrasProducer = { + MutableCreationExtras(defaultViewModelCreationExtras).apply { + set(DEFAULT_ARGS_KEY, TimelineViewModel.creationExtras(timelineKind)) + } + }, + ).value } else { - viewModels().value + viewModels( + extrasProducer = { + MutableCreationExtras(defaultViewModelCreationExtras).apply { + set(DEFAULT_ARGS_KEY, TimelineViewModel.creationExtras(timelineKind)) + } + }, + ).value } } @@ -132,8 +153,6 @@ class TimelineFragment : timelineKind = arguments.getParcelable(KIND_ARG)!! - viewModel.init(timelineKind) - isSwipeToRefreshEnabled = arguments.getBoolean(ARG_ENABLE_SWIPE_TO_REFRESH, true) adapter = TimelinePagingAdapter(this, viewModel.statusDisplayOptions.value) diff --git a/app/src/main/java/app/pachli/components/timeline/viewmodel/CachedTimelineViewModel.kt b/app/src/main/java/app/pachli/components/timeline/viewmodel/CachedTimelineViewModel.kt index 2541d68ea..49bb86b14 100644 --- a/app/src/main/java/app/pachli/components/timeline/viewmodel/CachedTimelineViewModel.kt +++ b/app/src/main/java/app/pachli/components/timeline/viewmodel/CachedTimelineViewModel.kt @@ -18,6 +18,7 @@ package app.pachli.components.timeline.viewmodel import android.util.Log +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import androidx.paging.PagingData import androidx.paging.cachedIn @@ -30,11 +31,9 @@ import app.pachli.appstore.PinEvent import app.pachli.appstore.ReblogEvent import app.pachli.components.timeline.CachedTimelineRepository import app.pachli.components.timeline.FiltersRepository -import app.pachli.components.timeline.TimelineKind import app.pachli.db.AccountManager import app.pachli.entity.Filter import app.pachli.entity.Poll -import app.pachli.network.FilterModel import app.pachli.usecase.TimelineCases import app.pachli.util.SharedPreferencesRepository import app.pachli.util.StatusDisplayOptionsRepository @@ -51,8 +50,10 @@ import javax.inject.Inject /** * TimelineViewModel that caches all statuses in a local database */ +@OptIn(ExperimentalCoroutinesApi::class) @HiltViewModel class CachedTimelineViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, private val repository: CachedTimelineRepository, timelineCases: TimelineCases, eventHub: EventHub, @@ -60,39 +61,33 @@ class CachedTimelineViewModel @Inject constructor( accountManager: AccountManager, statusDisplayOptionsRepository: StatusDisplayOptionsRepository, sharedPreferencesRepository: SharedPreferencesRepository, - filterModel: FilterModel, private val gson: Gson, ) : TimelineViewModel( + savedStateHandle, timelineCases, eventHub, filtersRepository, accountManager, - filterModel, statusDisplayOptionsRepository, sharedPreferencesRepository, ) { - override lateinit var statuses: Flow> + override var statuses: Flow> init { readingPositionId = activeAccount.lastVisibleHomeTimelineStatusId - } - @OptIn(ExperimentalCoroutinesApi::class) - override fun init(timelineKind: TimelineKind) { - super.init(timelineKind) statuses = reload.flatMapLatest { - getStatuses(timelineKind, initialKey = getInitialKey()) + getStatuses(initialKey = getInitialKey()) }.cachedIn(viewModelScope) } - /** @return Flow of statuses that make up the timeline of [kind] */ + /** @return Flow of statuses that make up the timeline of [timelineKind] */ private fun getStatuses( - kind: TimelineKind, initialKey: String? = null, ): Flow> { - Log.d(TAG, "getStatuses: kind: $kind, initialKey: $initialKey") - return repository.getStatusStream(kind = kind, initialKey = initialKey) + Log.d(TAG, "getStatuses: kind: $timelineKind, initialKey: $initialKey") + return repository.getStatusStream(kind = timelineKind, initialKey = initialKey) .map { pagingData -> pagingData .map { diff --git a/app/src/main/java/app/pachli/components/timeline/viewmodel/NetworkTimelineViewModel.kt b/app/src/main/java/app/pachli/components/timeline/viewmodel/NetworkTimelineViewModel.kt index 2da3c8ab2..c12588bc0 100644 --- a/app/src/main/java/app/pachli/components/timeline/viewmodel/NetworkTimelineViewModel.kt +++ b/app/src/main/java/app/pachli/components/timeline/viewmodel/NetworkTimelineViewModel.kt @@ -18,6 +18,7 @@ package app.pachli.components.timeline.viewmodel import android.util.Log +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import androidx.paging.PagingData import androidx.paging.cachedIn @@ -30,11 +31,9 @@ import app.pachli.appstore.PinEvent import app.pachli.appstore.ReblogEvent import app.pachli.components.timeline.FiltersRepository import app.pachli.components.timeline.NetworkTimelineRepository -import app.pachli.components.timeline.TimelineKind import app.pachli.db.AccountManager import app.pachli.entity.Filter import app.pachli.entity.Poll -import app.pachli.network.FilterModel import app.pachli.usecase.TimelineCases import app.pachli.util.SharedPreferencesRepository import app.pachli.util.StatusDisplayOptionsRepository @@ -50,8 +49,10 @@ import javax.inject.Inject /** * TimelineViewModel that caches all statuses in an in-memory list */ +@OptIn(ExperimentalCoroutinesApi::class) @HiltViewModel class NetworkTimelineViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, private val repository: NetworkTimelineRepository, timelineCases: TimelineCases, eventHub: EventHub, @@ -59,36 +60,32 @@ class NetworkTimelineViewModel @Inject constructor( accountManager: AccountManager, statusDisplayOptionsRepository: StatusDisplayOptionsRepository, sharedPreferencesRepository: SharedPreferencesRepository, - filterModel: FilterModel, ) : TimelineViewModel( + savedStateHandle, timelineCases, eventHub, filtersRepository, accountManager, - filterModel, statusDisplayOptionsRepository, sharedPreferencesRepository, ) { private val modifiedViewData = mutableMapOf() - override lateinit var statuses: Flow> + override var statuses: Flow> - @OptIn(ExperimentalCoroutinesApi::class) - override fun init(timelineKind: TimelineKind) { - super.init(timelineKind) + init { statuses = reload .flatMapLatest { - getStatuses(timelineKind, initialKey = getInitialKey()) + getStatuses(initialKey = getInitialKey()) }.cachedIn(viewModelScope) } - /** @return Flow of statuses that make up the timeline of [kind] */ + /** @return Flow of statuses that make up the timeline of [timelineKind] */ private fun getStatuses( - kind: TimelineKind, initialKey: String? = null, ): Flow> { - Log.d(TAG, "getStatuses: kind: $kind, initialKey: $initialKey") - return repository.getStatusStream(viewModelScope, kind = kind, initialKey = initialKey) + Log.d(TAG, "getStatuses: kind: $timelineKind, initialKey: $initialKey") + return repository.getStatusStream(viewModelScope, kind = timelineKind, initialKey = initialKey) .map { pagingData -> pagingData.map { modifiedViewData[it.id] ?: StatusViewData.from( diff --git a/app/src/main/java/app/pachli/components/timeline/viewmodel/TimelineViewModel.kt b/app/src/main/java/app/pachli/components/timeline/viewmodel/TimelineViewModel.kt index 1105aff58..fe30d5993 100644 --- a/app/src/main/java/app/pachli/components/timeline/viewmodel/TimelineViewModel.kt +++ b/app/src/main/java/app/pachli/components/timeline/viewmodel/TimelineViewModel.kt @@ -20,6 +20,9 @@ package app.pachli.components.timeline.viewmodel import android.util.Log import androidx.annotation.CallSuper import androidx.annotation.StringRes +import androidx.annotation.VisibleForTesting +import androidx.core.os.bundleOf +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.paging.PagingData @@ -253,11 +256,11 @@ sealed interface UiError { } abstract class TimelineViewModel( + savedStateHandle: SavedStateHandle, private val timelineCases: TimelineCases, private val eventHub: EventHub, private val filtersRepository: FiltersRepository, protected val accountManager: AccountManager, - private val filterModel: FilterModel, statusDisplayOptionsRepository: StatusDisplayOptionsRepository, private val sharedPreferencesRepository: SharedPreferencesRepository, ) : ViewModel() { @@ -296,8 +299,7 @@ abstract class TimelineViewModel( viewModelScope.launch { uiAction.emit(action) } } - var timelineKind: TimelineKind = TimelineKind.Home - private set + val timelineKind: TimelineKind = savedStateHandle.get(TIMELINE_KIND_TAG)!! private var filterRemoveReplies = false private var filterRemoveReblogs = false @@ -311,6 +313,8 @@ abstract class TimelineViewModel( open var readingPositionId: String? = null protected set + private var filterModel: FilterModel? = null + init { viewModelScope.launch { updateFiltersFromPreferences().collectLatest { @@ -384,13 +388,6 @@ abstract class TimelineViewModel( started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000), initialValue = UiState(showFabWhileScrolling = true), ) - } - - @CallSuper - open fun init(timelineKind: TimelineKind) { - this.timelineKind = timelineKind - - filterModel.kind = Filter.Kind.from(timelineKind) if (timelineKind is TimelineKind.Home) { // Note the variable is "true if filter" but the underlying preference/settings text is "true if show" @@ -501,43 +498,30 @@ abstract class TimelineViewModel( ) { return Filter.Action.HIDE } else { - statusViewData.filterAction = filterModel.shouldFilterStatus(status.actionableStatus) + statusViewData.filterAction = filterModel?.filterActionFor(status.actionableStatus) ?: Filter.Action.NONE statusViewData.filterAction } } /** Updates the current set of filters if filter-related preferences change */ - // TODO: https://github.com/tuskyapp/Tusky/issues/3546, and update if a v2 filter is - // updated as well. private fun updateFiltersFromPreferences() = eventHub.events .filterIsInstance() .filter { filterContextMatchesKind(timelineKind, listOf(it.filterKind)) } - .distinctUntilChanged() - .map { getFilters() } + .map { + getFilters() + reloadKeepingReadingPosition() + } .onStart { getFilters() } - /** - * Gets the current filters from the repository. Applies them locally if they are - * v1 filters. - * - * Whatever the filter kind, the current timeline is invalidated, so it updates with the - * most recent filters. - */ + /** Gets the current filters from the repository. */ private fun getFilters() { viewModelScope.launch { Log.d(TAG, "getFilters()") try { - when (val filters = filtersRepository.getFilters()) { - is FilterKind.V1 -> { - filterModel.initWithFilters( - filters.filters.filter { - filterContextMatchesString(timelineKind, it.context) - }, - ) - invalidate() - } - - is FilterKind.V2 -> invalidate() + val filterKind = Filter.Kind.from(timelineKind) + filterModel = when (val filters = filtersRepository.getFilters()) { + is FilterKind.V1 -> FilterModel(filterKind, filters.filters) + is FilterKind.V2 -> FilterModel(filterKind) } } catch (throwable: Throwable) { Log.d(TAG, "updateFilter(): Error fetching filters: ${throwable.message}") @@ -619,12 +603,14 @@ abstract class TimelineViewModel( private const val TAG = "TimelineViewModel" private val THROTTLE_TIMEOUT = 500.milliseconds - fun filterContextMatchesString( - timelineKind: TimelineKind, - filterContext: List, - ): Boolean { - return filterContext.contains(Filter.Kind.from(timelineKind).kind) - } + /** Tag for the timelineKind in `savedStateHandle` */ + @VisibleForTesting(VisibleForTesting.PRIVATE) + const val TIMELINE_KIND_TAG = "timelineKind" + + /** Create extras for this view model */ + fun creationExtras(timelineKind: TimelineKind) = bundleOf( + TIMELINE_KIND_TAG to timelineKind, + ) fun filterContextMatchesKind( timelineKind: TimelineKind, diff --git a/app/src/main/java/app/pachli/components/viewthread/ViewThreadViewModel.kt b/app/src/main/java/app/pachli/components/viewthread/ViewThreadViewModel.kt index 1af3c1887..5745ff734 100644 --- a/app/src/main/java/app/pachli/components/viewthread/ViewThreadViewModel.kt +++ b/app/src/main/java/app/pachli/components/viewthread/ViewThreadViewModel.kt @@ -23,6 +23,7 @@ import app.pachli.appstore.BlockEvent import app.pachli.appstore.BookmarkEvent import app.pachli.appstore.EventHub import app.pachli.appstore.FavoriteEvent +import app.pachli.appstore.FilterChangedEvent import app.pachli.appstore.PinEvent import app.pachli.appstore.ReblogEvent import app.pachli.appstore.StatusComposedEvent @@ -36,7 +37,6 @@ import app.pachli.db.AccountEntity import app.pachli.db.AccountManager import app.pachli.db.TimelineDao import app.pachli.entity.Filter -import app.pachli.entity.FilterV1 import app.pachli.entity.Status import app.pachli.network.FilterModel import app.pachli.network.MastodonApi @@ -61,7 +61,6 @@ import javax.inject.Inject @HiltViewModel class ViewThreadViewModel @Inject constructor( private val api: MastodonApi, - private val filterModel: FilterModel, private val timelineCases: TimelineCases, eventHub: EventHub, accountManager: AccountManager, @@ -89,6 +88,8 @@ class ViewThreadViewModel @Inject constructor( val activeAccount: AccountEntity + private var filterModel: FilterModel? = null + init { activeAccount = accountManager.activeAccount!! alwaysShowSensitiveMedia = activeAccount.alwaysShowSensitiveMedia @@ -106,6 +107,11 @@ class ViewThreadViewModel @Inject constructor( is StatusComposedEvent -> handleStatusComposedEvent(event) is StatusDeletedEvent -> handleStatusDeletedEvent(event) is StatusEditedEvent -> handleStatusEditedEvent(event) + is FilterChangedEvent -> { + if (event.filterKind == Filter.Kind.THREAD) { + loadFilters() + } + } } } } @@ -474,16 +480,9 @@ class ViewThreadViewModel @Inject constructor( private fun loadFilters() { viewModelScope.launch { try { - when (val filters = filtersRepository.getFilters()) { - is FilterKind.V1 -> { - filterModel.initWithFilters( - filters.filters.filter { filter -> - filter.context.contains(FilterV1.THREAD) - }, - ) - } - - is FilterKind.V2 -> filterModel.kind = Filter.Kind.THREAD + filterModel = when (val filters = filtersRepository.getFilters()) { + is FilterKind.V1 -> FilterModel(Filter.Kind.THREAD, filters.filters) + is FilterKind.V2 -> FilterModel(Filter.Kind.THREAD) } updateStatuses() } catch (_: Exception) { @@ -509,7 +508,7 @@ class ViewThreadViewModel @Inject constructor( if (status.isDetailed) { true } else { - status.filterAction = filterModel.shouldFilterStatus(status.status) + status.filterAction = filterModel?.filterActionFor(status.status) ?: Filter.Action.NONE status.filterAction != Filter.Action.HIDE } } diff --git a/app/src/main/java/app/pachli/network/FilterModel.kt b/app/src/main/java/app/pachli/network/FilterModel.kt index ea9ab443c..e459c979f 100644 --- a/app/src/main/java/app/pachli/network/FilterModel.kt +++ b/app/src/main/java/app/pachli/network/FilterModel.kt @@ -6,28 +6,28 @@ import app.pachli.entity.Status import app.pachli.util.parseAsMastodonHtml import java.util.Date import java.util.regex.Pattern -import javax.inject.Inject /** - * One-stop for status filtering logic using Mastodon's filters. + * Filter statuses using V1 or V2 filters. * - * 1. You init with [initWithFilters], this compiles regex pattern. - * 2. You call [shouldFilterStatus] to figure out what to display when you load statuses. + * Construct with [filterKind] that corresponds to the kind of timeline, and optionally the set + * of v1 filters that should be applied. */ -class FilterModel @Inject constructor() { +class FilterModel constructor(private val filterKind: Filter.Kind, v1filters: List? = null) { + /** Pattern to use when matching v1 filters against a status. Null if these are v2 filters */ private var pattern: Pattern? = null - private var v1 = false - lateinit var kind: Filter.Kind - fun initWithFilters(filters: List) { - v1 = true - this.pattern = makeFilter(filters) + init { + pattern = v1filters?.let { list -> + makeFilter(list.filter { it.context.contains(filterKind.kind) }) + } } - fun shouldFilterStatus(status: Status): Filter.Action { - if (v1) { + /** @return the [Filter.Action] that should be applied to this status */ + fun filterActionFor(status: Status): Filter.Action { + pattern?.let { pat -> // Patterns are expensive and thread-safe, matchers are neither. - val matcher = pattern?.matcher("") ?: return Filter.Action.NONE + val matcher = pat.matcher("") ?: return Filter.Action.NONE if (status.poll?.options?.any { matcher.reset(it.title).find() } == true) { return Filter.Action.HIDE @@ -48,7 +48,7 @@ class FilterModel @Inject constructor() { } val matchingKind = status.filtered?.filter { result -> - result.filter.kinds.contains(kind) + result.filter.kinds.contains(filterKind) } return if (matchingKind.isNullOrEmpty()) { diff --git a/app/src/test/java/app/pachli/FilterV1Test.kt b/app/src/test/java/app/pachli/FilterV1Test.kt index 0cc72f001..47317fcd3 100644 --- a/app/src/test/java/app/pachli/FilterV1Test.kt +++ b/app/src/test/java/app/pachli/FilterV1Test.kt @@ -41,7 +41,6 @@ class FilterV1Test { @Before fun setup() { - filterModel = FilterModel() val filters = listOf( FilterV1( id = "123", @@ -101,14 +100,14 @@ class FilterV1Test { ), ) - filterModel.initWithFilters(filters) + filterModel = FilterModel(Filter.Kind.HOME, filters) } @Test fun shouldNotFilter() { assertEquals( Filter.Action.NONE, - filterModel.shouldFilterStatus( + filterModel.filterActionFor( mockStatus(content = "should not be filtered"), ), ) @@ -118,7 +117,7 @@ class FilterV1Test { fun shouldFilter_whenContentMatchesBadWord() { assertEquals( Filter.Action.HIDE, - filterModel.shouldFilterStatus( + filterModel.filterActionFor( mockStatus(content = "one two badWord three"), ), ) @@ -128,7 +127,7 @@ class FilterV1Test { fun shouldFilter_whenContentMatchesBadWordPart() { assertEquals( Filter.Action.HIDE, - filterModel.shouldFilterStatus( + filterModel.filterActionFor( mockStatus(content = "one two badWordPart three"), ), ) @@ -138,7 +137,7 @@ class FilterV1Test { fun shouldFilter_whenContentMatchesBadWholeWord() { assertEquals( Filter.Action.HIDE, - filterModel.shouldFilterStatus( + filterModel.filterActionFor( mockStatus(content = "one two badWholeWord three"), ), ) @@ -148,7 +147,7 @@ class FilterV1Test { fun shouldNotFilter_whenContentDoesNotMatchWholeWord() { assertEquals( Filter.Action.NONE, - filterModel.shouldFilterStatus( + filterModel.filterActionFor( mockStatus(content = "one two badWholeWordTest three"), ), ) @@ -158,7 +157,7 @@ class FilterV1Test { fun shouldFilter_whenSpoilerTextDoesMatch() { assertEquals( Filter.Action.HIDE, - filterModel.shouldFilterStatus( + filterModel.filterActionFor( mockStatus( content = "should not be filtered", spoilerText = "badWord should be filtered", @@ -171,7 +170,7 @@ class FilterV1Test { fun shouldFilter_whenPollTextDoesMatch() { assertEquals( Filter.Action.HIDE, - filterModel.shouldFilterStatus( + filterModel.filterActionFor( mockStatus( content = "should not be filtered", spoilerText = "should not be filtered", @@ -185,7 +184,7 @@ class FilterV1Test { fun shouldFilter_whenMediaDescriptionDoesMatch() { assertEquals( Filter.Action.HIDE, - filterModel.shouldFilterStatus( + filterModel.filterActionFor( mockStatus( content = "should not be filtered", spoilerText = "should not be filtered", @@ -199,7 +198,7 @@ class FilterV1Test { fun shouldFilterPartialWord_whenWholeWordFilterContainsNonAlphanumericCharacters() { assertEquals( Filter.Action.HIDE, - filterModel.shouldFilterStatus( + filterModel.filterActionFor( mockStatus(content = "one two someone@twitter.com three"), ), ) @@ -209,7 +208,7 @@ class FilterV1Test { fun shouldFilterHashtags() { assertEquals( Filter.Action.HIDE, - filterModel.shouldFilterStatus( + filterModel.filterActionFor( mockStatus(content = "#hashtag one two three"), ), ) @@ -219,7 +218,7 @@ class FilterV1Test { fun shouldFilterHashtags_whenContentIsMarkedUp() { assertEquals( Filter.Action.HIDE, - filterModel.shouldFilterStatus( + filterModel.filterActionFor( mockStatus(content = "

#hashtagone two three

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

https://foo.bar/ one two three

"), ), ) @@ -239,7 +238,7 @@ class FilterV1Test { fun shouldNotFilter_whenFilterIsExpired() { assertEquals( Filter.Action.NONE, - filterModel.shouldFilterStatus( + filterModel.filterActionFor( mockStatus(content = "content matching expired filter should not be filtered"), ), ) @@ -249,7 +248,7 @@ class FilterV1Test { fun shouldFilter_whenFilterIsUnexpired() { assertEquals( Filter.Action.HIDE, - filterModel.shouldFilterStatus( + filterModel.filterActionFor( mockStatus(content = "content matching unexpired filter should be filtered"), ), ) diff --git a/app/src/test/java/app/pachli/components/notifications/NotificationsViewModelTestBase.kt b/app/src/test/java/app/pachli/components/notifications/NotificationsViewModelTestBase.kt index cf67757de..cee902a18 100644 --- a/app/src/test/java/app/pachli/components/notifications/NotificationsViewModelTestBase.kt +++ b/app/src/test/java/app/pachli/components/notifications/NotificationsViewModelTestBase.kt @@ -19,12 +19,12 @@ package app.pachli.components.notifications import androidx.test.ext.junit.runners.AndroidJUnit4 import app.pachli.appstore.EventHub +import app.pachli.components.timeline.FilterKind import app.pachli.components.timeline.FiltersRepository import app.pachli.components.timeline.MainCoroutineRule import app.pachli.db.AccountEntity import app.pachli.db.AccountManager import app.pachli.fakes.InMemorySharedPreferences -import app.pachli.network.FilterModel import app.pachli.settings.AccountPreferenceDataStore import app.pachli.usecase.TimelineCases import app.pachli.util.SharedPreferencesRepository @@ -51,7 +51,6 @@ abstract class NotificationsViewModelTestBase { protected lateinit var viewModel: NotificationsViewModel private lateinit var statusDisplayOptionsRepository: StatusDisplayOptionsRepository private lateinit var filtersRepository: FiltersRepository - private lateinit var filterModel: FilterModel private val eventHub = EventHub() @@ -96,8 +95,9 @@ abstract class NotificationsViewModelTestBase { ) timelineCases = mock() - filtersRepository = mock() - filterModel = mock() + filtersRepository = mock { + onBlocking { getFilters() } doReturn FilterKind.V2(emptyList()) + } sharedPreferencesRepository = SharedPreferencesRepository( InMemorySharedPreferences(), @@ -117,7 +117,6 @@ abstract class NotificationsViewModelTestBase { timelineCases, eventHub, filtersRepository, - filterModel, statusDisplayOptionsRepository, sharedPreferencesRepository, ) diff --git a/app/src/test/java/app/pachli/components/timeline/CachedTimelineViewModelTestBase.kt b/app/src/test/java/app/pachli/components/timeline/CachedTimelineViewModelTestBase.kt index b4a62a23e..965809ea0 100644 --- a/app/src/test/java/app/pachli/components/timeline/CachedTimelineViewModelTestBase.kt +++ b/app/src/test/java/app/pachli/components/timeline/CachedTimelineViewModelTestBase.kt @@ -17,6 +17,7 @@ package app.pachli.components.timeline +import androidx.lifecycle.SavedStateHandle import androidx.test.ext.junit.runners.AndroidJUnit4 import app.pachli.PachliApplication import app.pachli.appstore.EventHub @@ -24,7 +25,6 @@ import app.pachli.components.timeline.viewmodel.CachedTimelineViewModel import app.pachli.components.timeline.viewmodel.TimelineViewModel import app.pachli.db.AccountManager import app.pachli.entity.Account -import app.pachli.network.FilterModel import app.pachli.network.MastodonApi import app.pachli.settings.AccountPreferenceDataStore import app.pachli.usecase.TimelineCases @@ -76,19 +76,19 @@ abstract class CachedTimelineViewModelTestBase { @Inject lateinit var sharedPreferencesRepository: SharedPreferencesRepository - private lateinit var cachedTimelineRepository: CachedTimelineRepository + @Inject + lateinit var filtersRepository: FiltersRepository + + @Inject + lateinit var cachedTimelineRepository: CachedTimelineRepository + private lateinit var accountPreferenceDataStore: AccountPreferenceDataStore protected lateinit var timelineCases: TimelineCases private lateinit var statusDisplayOptionsRepository: StatusDisplayOptionsRepository - private lateinit var filtersRepository: FiltersRepository - private lateinit var filterModel: FilterModel protected lateinit var viewModel: TimelineViewModel private val eventHub = EventHub() - /** Empty success response, for API calls that return one */ - protected var emptySuccess = Response.success("".toResponseBody()) - /** Empty error response, for API calls that return one */ private var emptyError: Response = Response.error(404, "".toResponseBody()) @@ -102,6 +102,7 @@ abstract class CachedTimelineViewModelTestBase { reset(mastodonApi) mastodonApi.stub { onBlocking { getCustomEmojis() } doReturn NetworkResult.failure(Exception()) + onBlocking { getFilters() } doReturn NetworkResult.success(emptyList()) } accountManager.addAccount( @@ -123,16 +124,12 @@ abstract class CachedTimelineViewModelTestBase { ), ) - cachedTimelineRepository = mock() - accountPreferenceDataStore = AccountPreferenceDataStore( accountManager, TestScope(), ) timelineCases = mock() - filtersRepository = mock() - filterModel = mock() statusDisplayOptionsRepository = StatusDisplayOptionsRepository( sharedPreferencesRepository, @@ -142,6 +139,7 @@ abstract class CachedTimelineViewModelTestBase { ) viewModel = CachedTimelineViewModel( + SavedStateHandle(mapOf(TimelineViewModel.TIMELINE_KIND_TAG to TimelineKind.Home)), cachedTimelineRepository, timelineCases, eventHub, @@ -149,11 +147,7 @@ abstract class CachedTimelineViewModelTestBase { accountManager, statusDisplayOptionsRepository, sharedPreferencesRepository, - filterModel, Gson(), ) - - // Initialisation with the Home timeline - viewModel.init(TimelineKind.Home) } } diff --git a/app/src/test/java/app/pachli/components/timeline/NetworkTimelineViewModelTestBase.kt b/app/src/test/java/app/pachli/components/timeline/NetworkTimelineViewModelTestBase.kt index 7d0671577..713810ccf 100644 --- a/app/src/test/java/app/pachli/components/timeline/NetworkTimelineViewModelTestBase.kt +++ b/app/src/test/java/app/pachli/components/timeline/NetworkTimelineViewModelTestBase.kt @@ -17,17 +17,19 @@ package app.pachli.components.timeline +import androidx.lifecycle.SavedStateHandle import androidx.test.ext.junit.runners.AndroidJUnit4 import app.pachli.appstore.EventHub import app.pachli.components.timeline.viewmodel.NetworkTimelineViewModel import app.pachli.components.timeline.viewmodel.TimelineViewModel import app.pachli.db.AccountManager import app.pachli.entity.Account -import app.pachli.network.FilterModel +import app.pachli.network.MastodonApi import app.pachli.settings.AccountPreferenceDataStore import app.pachli.usecase.TimelineCases import app.pachli.util.SharedPreferencesRepository import app.pachli.util.StatusDisplayOptionsRepository +import at.connyduck.calladapter.networkresult.NetworkResult import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest import kotlinx.coroutines.test.TestScope @@ -36,7 +38,10 @@ import okhttp3.ResponseBody.Companion.toResponseBody import org.junit.Before import org.junit.Rule import org.junit.runner.RunWith +import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock +import org.mockito.kotlin.reset +import org.mockito.kotlin.stub import org.robolectric.annotation.Config import retrofit2.HttpException import retrofit2.Response @@ -57,22 +62,25 @@ abstract class NetworkTimelineViewModelTestBase { @Inject lateinit var accountManager: AccountManager + @Inject + lateinit var mastodonApi: MastodonApi + @Inject lateinit var sharedPreferencesRepository: SharedPreferencesRepository - private lateinit var networkTimelineRepository: NetworkTimelineRepository + @Inject + lateinit var filtersRepository: FiltersRepository + + @Inject + lateinit var networkTimelineRepository: NetworkTimelineRepository + private lateinit var accountPreferenceDataStore: AccountPreferenceDataStore protected lateinit var timelineCases: TimelineCases - private lateinit var filtersRepository: FiltersRepository private lateinit var statusDisplayOptionsRepository: StatusDisplayOptionsRepository - private lateinit var filterModel: FilterModel protected lateinit var viewModel: TimelineViewModel private val eventHub = EventHub() - /** Empty success response, for API calls that return one */ - protected var emptySuccess = Response.success("".toResponseBody()) - /** Empty error response, for API calls that return one */ private var emptyError: Response = Response.error(404, "".toResponseBody()) @@ -83,6 +91,12 @@ abstract class NetworkTimelineViewModelTestBase { fun setup() { hilt.inject() + reset(mastodonApi) + mastodonApi.stub { + onBlocking { getCustomEmojis() } doReturn NetworkResult.failure(Exception()) + onBlocking { getFilters() } doReturn NetworkResult.success(emptyList()) + } + accountManager.addAccount( accessToken = "token", domain = "domain.example", @@ -102,16 +116,12 @@ abstract class NetworkTimelineViewModelTestBase { ), ) - networkTimelineRepository = mock() - accountPreferenceDataStore = AccountPreferenceDataStore( accountManager, TestScope(), ) timelineCases = mock() - filtersRepository = mock() - filterModel = mock() statusDisplayOptionsRepository = StatusDisplayOptionsRepository( sharedPreferencesRepository, @@ -121,6 +131,7 @@ abstract class NetworkTimelineViewModelTestBase { ) viewModel = NetworkTimelineViewModel( + SavedStateHandle(mapOf(TimelineViewModel.TIMELINE_KIND_TAG to TimelineKind.Bookmarks)), networkTimelineRepository, timelineCases, eventHub, @@ -128,11 +139,6 @@ abstract class NetworkTimelineViewModelTestBase { accountManager, statusDisplayOptionsRepository, sharedPreferencesRepository, - filterModel, ) - - // Initialisation with any timeline kind, as long as it's not Home - // (Home uses CachedTimelineViewModel) - viewModel.init(TimelineKind.Bookmarks) } } diff --git a/app/src/test/java/app/pachli/components/viewthread/ViewThreadViewModelTest.kt b/app/src/test/java/app/pachli/components/viewthread/ViewThreadViewModelTest.kt index d42afb47e..86ca8a0e1 100644 --- a/app/src/test/java/app/pachli/components/viewthread/ViewThreadViewModelTest.kt +++ b/app/src/test/java/app/pachli/components/viewthread/ViewThreadViewModelTest.kt @@ -18,7 +18,6 @@ import app.pachli.db.AccountManager import app.pachli.db.TimelineDao import app.pachli.entity.Account import app.pachli.entity.StatusContext -import app.pachli.network.FilterModel import app.pachli.network.MastodonApi import app.pachli.settings.AccountPreferenceDataStore import app.pachli.usecase.TimelineCases @@ -130,8 +129,6 @@ class ViewThreadViewModelTest { onBlocking { getFilters() } doReturn FilterKind.V2(emptyList()) } - val filterModel = FilterModel() - val defaultAccount = AccountEntity( id = 1, domain = "mastodon.test", @@ -178,7 +175,6 @@ class ViewThreadViewModelTest { viewModel = ViewThreadViewModel( mastodonApi, - filterModel, timelineCases, eventHub, accountManager,