fix: Prevent potential crash when filters are slow to load (#205)

This previous code could crash if `filterModel.kind` (marked `lateinit`)
had not been set before the filters are loaded. This could happen in
rare cases.

Fix this by rewriting `FilterModel`. Instead of creating a half-empty
object that still needs further initialisation, delay the creation until
all the necessary information is available, and pass it in the
`FilterModel` constructor.

This also forces code that uses `FilterModel` to properly handle the
case where it might be null at the point where filtering decisions have
to be made.

This means that `TimelineViewModel` (and subclasses) no longer need the
`init()` function to complete their construction, which was another
significant code smell. Pass the `TimelineKind` to the view models via
their `SavedStateHandle`.

This showed that changing filters wasn't causing the timelines to update
without a manual refresh, so fix that too. Editing filters sends change
events for the old and new contexts (in case a context is removed from a
filter), and deleting a filter sends a change event too.
This commit is contained in:
Nik Clayton 2023-10-28 19:54:46 +02:00 committed by GitHub
parent 34e37f9ebb
commit 523efa705c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 244 additions and 249 deletions

View File

@ -894,7 +894,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/app/pachli/components/account/AccountActivity.kt"
line="469"
line="470"
column="9"/>
</issue>
@ -905,7 +905,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/app/pachli/components/accountlist/adapter/AccountAdapter.kt"
line="80"
line="81"
column="9"/>
</issue>
@ -916,7 +916,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/app/pachli/adapter/AccountFieldEditAdapter.kt"
line="43"
line="44"
column="9"/>
</issue>
@ -927,7 +927,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/app/pachli/adapter/AccountFieldEditAdapter.kt"
line="49"
line="50"
column="9"/>
</issue>
@ -938,7 +938,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/app/pachli/components/announcements/AnnouncementAdapter.kt"
line="162"
line="163"
column="9"/>
</issue>
@ -949,7 +949,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/app/pachli/components/accountlist/adapter/MutesAdapter.kt"
line="107"
line="108"
column="9"/>
</issue>
@ -960,7 +960,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/app/pachli/adapter/PollAdapter.kt"
line="67"
line="68"
column="9"/>
</issue>
@ -971,7 +971,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/app/pachli/adapter/PollAdapter.kt"
line="67"
line="68"
column="9"/>
</issue>
@ -982,7 +982,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/app/pachli/adapter/PollAdapter.kt"
line="67"
line="68"
column="9"/>
</issue>
@ -993,7 +993,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/app/pachli/adapter/PreviewPollOptionsAdapter.kt"
line="35"
line="36"
column="9"/>
</issue>
@ -1004,7 +1004,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/app/pachli/adapter/TabAdapter.kt"
line="54"
line="55"
column="9"/>
</issue>
@ -1015,7 +1015,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/app/pachli/adapter/TabAdapter.kt"
line="157"
line="158"
column="13"/>
</issue>
@ -1026,7 +1026,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/app/pachli/components/notifications/NotificationHelper.java"
line="865"
line="866"
column="57"/>
</issue>
@ -2159,7 +2159,7 @@
errorLine2=" ~~~~~~~~~~">
<location
file="src/main/java/app/pachli/util/AbsoluteTimeFormatter.kt"
line="34"
line="35"
column="13"/>
</issue>
@ -2170,7 +2170,7 @@
errorLine2=" ~~~~~~~~~~">
<location
file="src/main/java/app/pachli/util/AbsoluteTimeFormatter.kt"
line="34"
line="35"
column="13"/>
</issue>
@ -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=" ~~~~~~~~~~">
<location
file="src/main/java/app/pachli/util/AbsoluteTimeFormatter.kt"
line="34"
column="13"/>
</issue>
<issue
id="SyntheticAccessor"
message="Access to `private` method `isSameYear` of class `Companion` requires synthetic accessor"
errorLine1=" isSameYear(time, now, tz) -> sameYearSdf.format(time)"
errorLine2=" ~~~~~~~~~~">
<location
file="src/main/java/app/pachli/util/AbsoluteTimeFormatter.kt"
line="35"
@ -2203,7 +2192,7 @@
errorLine2=" ~~~~~~~~~~">
<location
file="src/main/java/app/pachli/util/AbsoluteTimeFormatter.kt"
line="35"
line="36"
column="13"/>
</issue>
@ -2214,7 +2203,18 @@
errorLine2=" ~~~~~~~~~~">
<location
file="src/main/java/app/pachli/util/AbsoluteTimeFormatter.kt"
line="35"
line="36"
column="13"/>
</issue>
<issue
id="SyntheticAccessor"
message="Access to `private` method `isSameYear` of class `Companion` requires synthetic accessor"
errorLine1=" isSameYear(time, now, tz) -> sameYearSdf.format(time)"
errorLine2=" ~~~~~~~~~~">
<location
file="src/main/java/app/pachli/util/AbsoluteTimeFormatter.kt"
line="36"
column="13"/>
</issue>
@ -2225,7 +2225,7 @@
errorLine2=" ~~~~~~~">
<location
file="src/main/java/app/pachli/components/account/AccountActivity.kt"
line="357"
line="358"
column="29"/>
</issue>
@ -2236,7 +2236,7 @@
errorLine2=" ~~~~~~~">
<location
file="src/main/java/app/pachli/components/account/AccountActivity.kt"
line="360"
line="361"
column="29"/>
</issue>
@ -2247,7 +2247,7 @@
errorLine2=" ~~~~~~~">
<location
file="src/main/java/app/pachli/components/account/AccountActivity.kt"
line="366"
line="367"
column="21"/>
</issue>
@ -2258,7 +2258,7 @@
errorLine2=" ~~~~~~~">
<location
file="src/main/java/app/pachli/components/account/AccountActivity.kt"
line="367"
line="368"
column="21"/>
</issue>
@ -2269,7 +2269,7 @@
errorLine2=" ~~~~~~~">
<location
file="src/main/java/app/pachli/components/account/AccountActivity.kt"
line="369"
line="370"
column="21"/>
</issue>
@ -2280,7 +2280,7 @@
errorLine2=" ~~~~~~~">
<location
file="src/main/java/app/pachli/components/account/AccountActivity.kt"
line="379"
line="380"
column="21"/>
</issue>
@ -2291,7 +2291,7 @@
errorLine2=" ~~~~~~~~~~~~~">
<location
file="src/main/java/app/pachli/components/accountlist/AccountListFragment.kt"
line="137"
line="138"
column="17"/>
</issue>
@ -2302,7 +2302,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/app/pachli/components/preference/AccountPreferencesFragment.kt"
line="307"
line="308"
column="29"/>
</issue>
@ -2313,7 +2313,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/app/pachli/components/preference/AccountPreferencesFragment.kt"
line="313"
line="314"
column="25"/>
</issue>
@ -2467,7 +2467,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/app/pachli/components/compose/ComposeActivity.kt"
line="536"
line="537"
column="21"/>
</issue>
@ -2478,7 +2478,7 @@
errorLine2=" ~~~~~~~~~">
<location
file="src/main/java/app/pachli/components/compose/ComposeActivity.kt"
line="545"
line="546"
column="17"/>
</issue>
@ -2489,7 +2489,7 @@
errorLine2=" ~~~~~~~~~">
<location
file="src/main/java/app/pachli/components/compose/ComposeActivity.kt"
line="545"
line="546"
column="17"/>
</issue>
@ -2500,7 +2500,7 @@
errorLine2=" ~~~~~~~~~~~~~~">
<location
file="src/main/java/app/pachli/components/compose/ComposeAutoCompleteAdapter.kt"
line="60"
line="61"
column="60"/>
</issue>
@ -2511,7 +2511,7 @@
errorLine2=" ~~~~~~~~~~~~~">
<location
file="src/main/java/app/pachli/components/compose/ComposeAutoCompleteAdapter.kt"
line="61"
line="62"
column="60"/>
</issue>
@ -2522,7 +2522,7 @@
errorLine2=" ~~~~~~~~~~~">
<location
file="src/main/java/app/pachli/components/compose/ComposeAutoCompleteAdapter.kt"
line="62"
line="63"
column="58"/>
</issue>
@ -2533,7 +2533,7 @@
errorLine2=" ~~~~~~~~~~~~~">
<location
file="src/main/java/app/pachli/components/compose/ComposeAutoCompleteAdapter.kt"
line="122"
line="123"
column="37"/>
</issue>
@ -2544,7 +2544,7 @@
errorLine2=" ~~~~~~~~~~~~~">
<location
file="src/main/java/app/pachli/components/compose/ComposeAutoCompleteAdapter.kt"
line="122"
line="123"
column="37"/>
</issue>
@ -2555,7 +2555,7 @@
errorLine2=" ~~~~~~~">
<location
file="src/main/java/app/pachli/components/conversation/ConversationsFragment.kt"
line="147"
line="148"
column="29"/>
</issue>
@ -2566,7 +2566,7 @@
errorLine2=" ~~~~~~~">
<location
file="src/main/java/app/pachli/components/conversation/ConversationsFragment.kt"
line="149"
line="150"
column="37"/>
</issue>
@ -2654,7 +2654,7 @@
errorLine2=" ~~~~~~~~">
<location
file="src/main/java/app/pachli/network/InstanceSwitchAuthInterceptor.kt"
line="42"
line="43"
column="29"/>
</issue>
@ -2665,7 +2665,7 @@
errorLine2=" ~~~~~~~~">
<location
file="src/main/java/app/pachli/network/InstanceSwitchAuthInterceptor.kt"
line="51"
line="52"
column="37"/>
</issue>
@ -2929,7 +2929,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/app/pachli/components/login/LoginActivity.kt"
line="154"
line="155"
column="22"/>
</issue>
@ -2940,7 +2940,7 @@
errorLine2=" ~~~~~~~">
<location
file="src/main/java/app/pachli/components/login/LoginWebViewActivity.kt"
line="142"
line="143"
column="17"/>
</issue>
@ -2951,7 +2951,7 @@
errorLine2=" ~~~~~~~">
<location
file="src/main/java/app/pachli/components/login/LoginWebViewActivity.kt"
line="142"
line="143"
column="17"/>
</issue>
@ -2962,7 +2962,7 @@
errorLine2=" ~~~~~~~~~~">
<location
file="src/main/java/app/pachli/components/login/LoginWebViewActivity.kt"
line="151"
line="152"
column="17"/>
</issue>
@ -2973,7 +2973,7 @@
errorLine2=" ~~~~~~~~~~">
<location
file="src/main/java/app/pachli/components/login/LoginWebViewActivity.kt"
line="151"
line="152"
column="17"/>
</issue>
@ -2984,7 +2984,7 @@
errorLine2=" ~~~~~~~~~~">
<location
file="src/main/java/app/pachli/components/login/LoginWebViewActivity.kt"
line="172"
line="173"
column="25"/>
</issue>
@ -2995,7 +2995,7 @@
errorLine2=" ~~~~~~~~~~">
<location
file="src/main/java/app/pachli/components/login/LoginWebViewActivity.kt"
line="172"
line="173"
column="25"/>
</issue>
@ -3006,7 +3006,7 @@
errorLine2=" ~~~~~~~~~~">
<location
file="src/main/java/app/pachli/components/login/LoginWebViewActivity.kt"
line="175"
line="176"
column="25"/>
</issue>
@ -3017,7 +3017,7 @@
errorLine2=" ~~~~~~~~~~">
<location
file="src/main/java/app/pachli/components/login/LoginWebViewActivity.kt"
line="175"
line="176"
column="25"/>
</issue>
@ -3457,7 +3457,7 @@
errorLine2=" ~~~~~~~~~~~~">
<location
file="src/main/java/app/pachli/components/compose/MediaPreviewAdapter.kt"
line="138"
line="139"
column="17"/>
</issue>
@ -3688,7 +3688,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/app/pachli/fragment/SFragment.kt"
line="182"
line="183"
column="48"/>
</issue>
@ -3732,7 +3732,7 @@
errorLine2=" ~~~~~~~~">
<location
file="src/main/java/app/pachli/components/report/adapter/StatusViewHolder.kt"
line="59"
line="60"
column="13"/>
</issue>
@ -3743,7 +3743,7 @@
errorLine2=" ~~~~~~~~">
<location
file="src/main/java/app/pachli/components/report/adapter/StatusViewHolder.kt"
line="65"
line="66"
column="13"/>
</issue>
@ -3798,7 +3798,7 @@
errorLine2=" ~~~~~~~~~">
<location
file="src/main/java/app/pachli/components/timeline/TimelineFragment.kt"
line="174"
line="193"
column="47"/>
</issue>
@ -3809,7 +3809,7 @@
errorLine2=" ~~~~~~~">
<location
file="src/main/java/app/pachli/fragment/ViewImageFragment.kt"
line="217"
line="218"
column="25"/>
</issue>
@ -3820,7 +3820,7 @@
errorLine2=" ~~~~~~~">
<location
file="src/main/java/app/pachli/fragment/ViewImageFragment.kt"
line="305"
line="306"
column="34"/>
</issue>
@ -3831,7 +3831,7 @@
errorLine2=" ~~~~~~~">
<location
file="src/main/java/app/pachli/fragment/ViewImageFragment.kt"
line="318"
line="319"
column="13"/>
</issue>
@ -3842,7 +3842,7 @@
errorLine2=" ~~~~~~~">
<location
file="src/main/java/app/pachli/fragment/ViewImageFragment.kt"
line="325"
line="326"
column="17"/>
</issue>
@ -3908,7 +3908,7 @@
errorLine2=" ~~~~~~~">
<location
file="src/main/java/app/pachli/fragment/ViewVideoFragment.kt"
line="152"
line="153"
column="32"/>
</issue>
@ -3919,7 +3919,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/app/pachli/fragment/ViewVideoFragment.kt"
line="232"
line="233"
column="21"/>
</issue>
@ -5213,7 +5213,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/app/pachli/util/ShareShortcutHelper.kt"
line="86"
line="87"
column="9"/>
</issue>
@ -5224,7 +5224,7 @@
errorLine2=" ^">
<location
file="src/main/java/app/pachli/adapter/TabAdapter.kt"
line="91"
line="92"
column="13"/>
</issue>
@ -5235,7 +5235,7 @@
errorLine2=" ^">
<location
file="src/main/java/app/pachli/adapter/TabAdapter.kt"
line="91"
line="92"
column="50"/>
</issue>

View File

@ -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
}
}

View File

@ -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()

View File

@ -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<InfallibleUiAction.ApplyFilter>()
@ -472,8 +471,11 @@ class NotificationsViewModel @Inject constructor(
viewModelScope.launch {
eventHub.events
.filterIsInstance<FilterChangedEvent>()
.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))

View File

@ -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<CachedTimelineViewModel>().value
viewModels<CachedTimelineViewModel>(
extrasProducer = {
MutableCreationExtras(defaultViewModelCreationExtras).apply {
set(DEFAULT_ARGS_KEY, TimelineViewModel.creationExtras(timelineKind))
}
},
).value
} else {
viewModels<NetworkTimelineViewModel>().value
viewModels<NetworkTimelineViewModel>(
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)

View File

@ -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<PagingData<StatusViewData>>
override var statuses: Flow<PagingData<StatusViewData>>
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<PagingData<StatusViewData>> {
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 {

View File

@ -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<String, StatusViewData>()
override lateinit var statuses: Flow<PagingData<StatusViewData>>
override var statuses: Flow<PagingData<StatusViewData>>
@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<PagingData<StatusViewData>> {
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(

View File

@ -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<TimelineKind>(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<FilterChangedEvent>()
.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<String>,
): 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,

View File

@ -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
}
}

View File

@ -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<FilterV1>? = 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<FilterV1>) {
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()) {

View File

@ -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 = "<p><a href=\"https://foo.bar/tags/hashtag\" class=\"mention hashtag\" rel=\"nofollow noopener noreferrer\" target=\"_blank\">#<span>hashtag</span></a>one two three</p>"),
),
)
@ -229,7 +228,7 @@ class FilterV1Test {
fun shouldNotFilterHtmlAttributes() {
assertEquals(
Filter.Action.NONE,
filterModel.shouldFilterStatus(
filterModel.filterActionFor(
mockStatus(content = "<p><a href=\"https://foo.bar/\">https://foo.bar/</a> one two three</p>"),
),
)
@ -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"),
),
)

View File

@ -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,
)

View File

@ -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<ResponseBody> = 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)
}
}

View File

@ -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<ResponseBody> = 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)
}
}

View File

@ -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,