diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationFetcher.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationFetcher.kt index 633ca08f7..5083ea9b1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationFetcher.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationFetcher.kt @@ -10,12 +10,30 @@ import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.entity.Marker import com.keylesspalace.tusky.entity.Notification import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.util.HttpHeaderLink import com.keylesspalace.tusky.util.isLessThan import kotlinx.coroutines.delay import javax.inject.Inject import kotlin.math.min import kotlin.time.Duration.Companion.milliseconds +/** Models next/prev links from the "Links" header in an API response */ +data class Links(val next: String?, val prev: String?) { + companion object { + fun from(linkHeader: String?): Links { + val links = HttpHeaderLink.parse(linkHeader) + return Links( + next = HttpHeaderLink.findByRelationType(links, "next")?.uri?.getQueryParameter( + "max_id" + ), + prev = HttpHeaderLink.findByRelationType(links, "prev")?.uri?.getQueryParameter( + "min_id" + ) + ) + } + } +} + /** * Fetch Mastodon notifications and show Android notifications, with summaries, for them. * diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsLoadStateAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsLoadStateAdapter.kt deleted file mode 100644 index 0a281ccd9..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsLoadStateAdapter.kt +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Copyright 2023 Tusky Contributors - * - * This file is a part of Tusky. - * - * This program is free software; you can redistribute it and/or modify it under the terms of the - * GNU General Public License as published by the Free Software Foundation; either version 3 of the - * License, or (at your option) any later version. - * - * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even - * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General - * Public License for more details. - * - * You should have received a copy of the GNU General Public License along with Tusky; if not, - * see . - */ - -package com.keylesspalace.tusky.components.notifications - -import android.view.ViewGroup -import androidx.paging.LoadState -import androidx.paging.LoadStateAdapter - -/** Show load state and retry options when loading notifications */ -class NotificationsLoadStateAdapter( - private val retry: () -> Unit -) : LoadStateAdapter() { - override fun onCreateViewHolder( - parent: ViewGroup, - loadState: LoadState - ): NotificationsLoadStateViewHolder { - return NotificationsLoadStateViewHolder.create(parent, retry) - } - - override fun onBindViewHolder(holder: NotificationsLoadStateViewHolder, loadState: LoadState) { - holder.bind(loadState) - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsLoadStateViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsLoadStateViewHolder.kt deleted file mode 100644 index f3c006d32..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsLoadStateViewHolder.kt +++ /dev/null @@ -1,73 +0,0 @@ -/* - * Copyright 2023 Tusky Contributors - * - * This file is a part of Tusky. - * - * This program is free software; you can redistribute it and/or modify it under the terms of the - * GNU General Public License as published by the Free Software Foundation; either version 3 of the - * License, or (at your option) any later version. - * - * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even - * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General - * Public License for more details. - * - * You should have received a copy of the GNU General Public License along with Tusky; if not, - * see . - */ - -package com.keylesspalace.tusky.components.notifications - -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.core.view.isVisible -import androidx.paging.LoadState -import androidx.recyclerview.widget.RecyclerView -import com.keylesspalace.tusky.R -import com.keylesspalace.tusky.databinding.ItemNotificationsLoadStateFooterViewBinding -import java.net.SocketTimeoutException - -/** - * Display the header/footer loading state to the user. - * - * Either: - * - * 1. A page is being loaded, display a progress view, or - * 2. An error occurred, display an error message with a "retry" button - * - * @param retry function to invoke if the user clicks the "retry" button - */ -class NotificationsLoadStateViewHolder( - private val binding: ItemNotificationsLoadStateFooterViewBinding, - retry: () -> Unit -) : RecyclerView.ViewHolder(binding.root) { - init { - binding.retryButton.setOnClickListener { retry.invoke() } - } - - fun bind(loadState: LoadState) { - if (loadState is LoadState.Error) { - val ctx = binding.root.context - binding.errorMsg.text = when (loadState.error) { - is SocketTimeoutException -> ctx.getString(R.string.socket_timeout_exception) - // Other exceptions to consider: - // - UnknownHostException, default text is: - // Unable to resolve "%s": No address associated with hostname - else -> loadState.error.localizedMessage - } - } - binding.progressBar.isVisible = loadState is LoadState.Loading - binding.retryButton.isVisible = loadState is LoadState.Error - binding.errorMsg.isVisible = loadState is LoadState.Error - } - - companion object { - fun create(parent: ViewGroup, retry: () -> Unit): NotificationsLoadStateViewHolder { - val binding = ItemNotificationsLoadStateFooterViewBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false - ) - return NotificationsLoadStateViewHolder(binding, retry) - } - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsPagingSource.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsPagingSource.kt deleted file mode 100644 index b754989d0..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsPagingSource.kt +++ /dev/null @@ -1,216 +0,0 @@ -/* - * Copyright 2023 Tusky Contributors - * - * This file is a part of Tusky. - * - * This program is free software; you can redistribute it and/or modify it under the terms of the - * GNU General Public License as published by the Free Software Foundation; either version 3 of the - * License, or (at your option) any later version. - * - * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even - * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General - * Public License for more details. - * - * You should have received a copy of the GNU General Public License along with Tusky; if not, - * see . - */ - -package com.keylesspalace.tusky.components.notifications - -import android.util.Log -import androidx.paging.PagingSource -import androidx.paging.PagingState -import com.google.gson.Gson -import com.keylesspalace.tusky.entity.Notification -import com.keylesspalace.tusky.network.MastodonApi -import com.keylesspalace.tusky.util.HttpHeaderLink -import kotlinx.coroutines.async -import kotlinx.coroutines.coroutineScope -import okhttp3.Headers -import retrofit2.Response -import javax.inject.Inject - -/** Models next/prev links from the "Links" header in an API response */ -data class Links(val next: String?, val prev: String?) { - companion object { - fun from(linkHeader: String?): Links { - val links = HttpHeaderLink.parse(linkHeader) - return Links( - next = HttpHeaderLink.findByRelationType(links, "next")?.uri?.getQueryParameter( - "max_id" - ), - prev = HttpHeaderLink.findByRelationType(links, "prev")?.uri?.getQueryParameter( - "min_id" - ) - ) - } - } -} - -/** [PagingSource] for Mastodon Notifications, identified by the Notification ID */ -class NotificationsPagingSource @Inject constructor( - private val mastodonApi: MastodonApi, - private val gson: Gson, - private val notificationFilter: Set -) : PagingSource() { - override suspend fun load(params: LoadParams): LoadResult { - Log.d(TAG, "load() with ${params.javaClass.simpleName} for key: ${params.key}") - - try { - val response = when (params) { - is LoadParams.Refresh -> { - getInitialPage(params) - } - is LoadParams.Append -> mastodonApi.notifications( - maxId = params.key, - limit = params.loadSize, - excludes = notificationFilter - ) - is LoadParams.Prepend -> mastodonApi.notifications( - minId = params.key, - limit = params.loadSize, - excludes = notificationFilter - ) - } - - if (!response.isSuccessful) { - val code = response.code() - - val msg = response.errorBody()?.string()?.let { errorBody -> - if (errorBody.isBlank()) return@let "no reason given" - - val error = try { - gson.fromJson(errorBody, com.keylesspalace.tusky.entity.Error::class.java) - } catch (e: Exception) { - return@let "$errorBody ($e)" - } - - when (val desc = error.error_description) { - null -> error.error - else -> "${error.error}: $desc" - } - } ?: "no reason given" - return LoadResult.Error(Throwable("HTTP $code: $msg")) - } - - val links = Links.from(response.headers()["link"]) - return LoadResult.Page( - data = response.body()!!, - nextKey = links.next, - prevKey = links.prev - ) - } catch (e: Exception) { - return LoadResult.Error(e) - } - } - - /** - * Fetch the initial page of notifications, using params.key as the ID of the initial - * notification to fetch. - * - * - If there is no key, a page of the most recent notifications is returned - * - If the notification exists, and is not filtered, a page of notifications is returned - * - If the notification does not exist, or is filtered, the page of notifications immediately - * before is returned (if non-empty) - * - If there is no page of notifications immediately before then the page immediately after - * is returned (if non-empty) - * - Finally, fall back to the most recent notifications - */ - private suspend fun getInitialPage(params: LoadParams): Response> = coroutineScope { - // If the key is null this is straightforward, just return the most recent notifications. - val key = params.key - ?: return@coroutineScope mastodonApi.notifications( - limit = params.loadSize, - excludes = notificationFilter - ) - - // It's important to return *something* from this state. If an empty page is returned - // (even with next/prev links) Pager3 assumes there is no more data to load and stops. - // - // In addition, the Mastodon API does not let you fetch a page that contains a given key. - // You can fetch the page immediately before the key, or the page immediately after, but - // you can not fetch the page itself. - - // First, try and get the notification itself, and the notifications immediately before - // it. This is so that a full page of results can be returned. Returning just the - // single notification means the displayed list can jump around a bit as more data is - // loaded. - // - // Make both requests, and wait for the first to complete. - val deferredNotification = async { mastodonApi.notification(id = key) } - val deferredNotificationPage = async { - mastodonApi.notifications(maxId = key, limit = params.loadSize, excludes = notificationFilter) - } - - val notification = deferredNotification.await() - if (notification.isSuccessful) { - // If this was successful we must still check that the user is not filtering this type - // of notification, as fetching a single notification ignores filters. Returning this - // notification if the user is filtering the type is wrong. - notification.body()?.let { body -> - if (!notificationFilter.contains(body.type)) { - // Notification is *not* filtered. We can return this, but need the next page of - // notifications as well - - // Collect all notifications in to this list - val notifications = mutableListOf(body) - val notificationPage = deferredNotificationPage.await() - if (notificationPage.isSuccessful) { - notificationPage.body()?.let { - notifications.addAll(it) - } - } - - // "notifications" now contains at least one notification we can return, and - // hopefully a full page. - - // Build correct max_id and min_id links for the response. The "min_id" to use - // when fetching the next page is the same as "key". The "max_id" is the ID of - // the oldest notification in the list. - val maxId = notifications.last().id - val headers = Headers.Builder() - .add("link: ; rel=\"next\", ; rel=\"prev\"") - .build() - - return@coroutineScope Response.success(notifications, headers) - } - } - } - - // The user's last read notification was missing or is filtered. Use the page of - // notifications chronologically older than their desired notification. This page must - // *not* be empty (as noted earlier, if it is, paging stops). - deferredNotificationPage.await().let { response -> - if (response.isSuccessful) { - if (!response.body().isNullOrEmpty()) return@coroutineScope response - } - } - - // There were no notifications older than the user's desired notification. Return the page - // of notifications immediately newer than their desired notification. This page must - // *not* be empty (as noted earlier, if it is, paging stops). - mastodonApi.notifications(minId = key, limit = params.loadSize, excludes = notificationFilter).let { response -> - if (response.isSuccessful) { - if (!response.body().isNullOrEmpty()) return@coroutineScope response - } - } - - // Everything failed -- fallback to fetching the most recent notifications - return@coroutineScope mastodonApi.notifications( - limit = params.loadSize, - excludes = notificationFilter - ) - } - - override fun getRefreshKey(state: PagingState): String? { - return state.anchorPosition?.let { anchorPosition -> - val id = state.closestItemToPosition(anchorPosition)?.id - Log.d(TAG, " getRefreshKey returning $id") - return id - } - } - - companion object { - private const val TAG = "NotificationsPagingSource" - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsRepository.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsRepository.kt deleted file mode 100644 index 4bec1aa32..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsRepository.kt +++ /dev/null @@ -1,76 +0,0 @@ -/* - * Copyright 2023 Tusky Contributors - * - * This file is a part of Tusky. - * - * This program is free software; you can redistribute it and/or modify it under the terms of the - * GNU General Public License as published by the Free Software Foundation; either version 3 of the - * License, or (at your option) any later version. - * - * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even - * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General - * Public License for more details. - * - * You should have received a copy of the GNU General Public License along with Tusky; if not, - * see . - */ - -package com.keylesspalace.tusky.components.notifications - -import android.util.Log -import androidx.paging.InvalidatingPagingSourceFactory -import androidx.paging.Pager -import androidx.paging.PagingConfig -import androidx.paging.PagingData -import androidx.paging.PagingSource -import com.google.gson.Gson -import com.keylesspalace.tusky.entity.Notification -import com.keylesspalace.tusky.network.MastodonApi -import kotlinx.coroutines.flow.Flow -import okhttp3.ResponseBody -import retrofit2.Response -import javax.inject.Inject - -class NotificationsRepository @Inject constructor( - private val mastodonApi: MastodonApi, - private val gson: Gson -) { - private var factory: InvalidatingPagingSourceFactory? = null - - /** - * @return flow of Mastodon [Notification], excluding all types in [filter]. - * Notifications are loaded in [pageSize] increments. - */ - fun getNotificationsStream( - filter: Set, - pageSize: Int = PAGE_SIZE, - initialKey: String? = null - ): Flow> { - Log.d(TAG, "getNotificationsStream(), filtering: $filter") - - factory = InvalidatingPagingSourceFactory { - NotificationsPagingSource(mastodonApi, gson, filter) - } - - return Pager( - config = PagingConfig(pageSize = pageSize, initialLoadSize = pageSize), - initialKey = initialKey, - pagingSourceFactory = factory!! - ).flow - } - - /** Invalidate the active paging source, see [PagingSource.invalidate] */ - fun invalidate() { - factory?.invalidate() - } - - /** Clear notifications */ - suspend fun clearNotifications(): Response { - return mastodonApi.clearNotifications() - } - - companion object { - private const val TAG = "NotificationsRepository" - private const val PAGE_SIZE = 30 - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModel.kt deleted file mode 100644 index d06a8bbcf..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModel.kt +++ /dev/null @@ -1,548 +0,0 @@ -/* - * Copyright 2023 Tusky Contributors - * - * This file is a part of Tusky. - * - * This program is free software; you can redistribute it and/or modify it under the terms of the - * GNU General Public License as published by the Free Software Foundation; either version 3 of the - * License, or (at your option) any later version. - * - * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even - * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General - * Public License for more details. - * - * You should have received a copy of the GNU General Public License along with Tusky; if not, - * see . - */ - -package com.keylesspalace.tusky.components.notifications - -import android.content.SharedPreferences -import android.util.Log -import androidx.annotation.StringRes -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import androidx.paging.PagingData -import androidx.paging.cachedIn -import androidx.paging.map -import at.connyduck.calladapter.networkresult.getOrThrow -import com.keylesspalace.tusky.R -import com.keylesspalace.tusky.appstore.BlockEvent -import com.keylesspalace.tusky.appstore.EventHub -import com.keylesspalace.tusky.appstore.MuteConversationEvent -import com.keylesspalace.tusky.appstore.MuteEvent -import com.keylesspalace.tusky.appstore.PreferenceChangedEvent -import com.keylesspalace.tusky.components.timeline.util.ifExpected -import com.keylesspalace.tusky.db.AccountManager -import com.keylesspalace.tusky.entity.Notification -import com.keylesspalace.tusky.entity.Poll -import com.keylesspalace.tusky.settings.PrefKeys -import com.keylesspalace.tusky.usecase.TimelineCases -import com.keylesspalace.tusky.util.StatusDisplayOptions -import com.keylesspalace.tusky.util.deserialize -import com.keylesspalace.tusky.util.serialize -import com.keylesspalace.tusky.util.throttleFirst -import com.keylesspalace.tusky.util.toViewData -import com.keylesspalace.tusky.viewdata.NotificationViewData -import com.keylesspalace.tusky.viewdata.StatusViewData -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.filterIsInstance -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.getAndUpdate -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.onStart -import kotlinx.coroutines.flow.receiveAsFlow -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.launch -import kotlinx.coroutines.rx3.await -import retrofit2.HttpException -import javax.inject.Inject -import kotlin.time.Duration.Companion.milliseconds - -data class UiState( - /** Filtered notification types */ - val activeFilter: Set = emptySet(), - - /** True if the FAB should be shown while scrolling */ - val showFabWhileScrolling: Boolean = true -) - -/** Preferences the UI reacts to */ -data class UiPrefs( - val showFabWhileScrolling: Boolean -) { - companion object { - /** Relevant preference keys. Changes to any of these trigger a display update */ - val prefKeys = setOf( - PrefKeys.FAB_HIDE - ) - } -} - -/** Parent class for all UI actions, fallible or infallible. */ -sealed class UiAction - -/** Actions the user can trigger from the UI. These actions may fail. */ -sealed class FallibleUiAction : UiAction() { - /** Clear all notifications */ - data object ClearNotifications : FallibleUiAction() -} - -/** - * Actions the user can trigger from the UI that either cannot fail, or if they do fail, - * do not show an error. - */ -sealed class InfallibleUiAction : UiAction() { - /** Apply a new filter to the notification list */ - // This saves the list to the local database, which triggers a refresh of the data. - // Saving the data can't fail, which is why this is infallible. Refreshing the - // data may fail, but that's handled by the paging system / adapter refresh logic. - data class ApplyFilter(val filter: Set) : InfallibleUiAction() - - /** - * User is leaving the fragment, save the ID of the visible notification. - * - * Infallible because if it fails there's nowhere to show the error, and nothing the user - * can do. - */ - data class SaveVisibleId(val visibleId: String) : InfallibleUiAction() - - /** Ignore the saved reading position, load the page with the newest items */ - // Resets the account's `lastNotificationId`, which can't fail, which is why this is - // infallible. Reloading the data may fail, but that's handled by the paging system / - // adapter refresh logic. - data object LoadNewest : InfallibleUiAction() -} - -/** Actions the user can trigger on an individual notification. These may fail. */ -sealed class NotificationAction : FallibleUiAction() { - data class AcceptFollowRequest(val accountId: String) : NotificationAction() - - data class RejectFollowRequest(val accountId: String) : NotificationAction() -} - -sealed class UiSuccess { - // These three are from menu items on the status. Currently they don't come to the - // viewModel as actions, they're noticed when events are posted. That will change, - // but for the moment we can still report them to the UI. Typically, receiving any - // of these three should trigger the UI to refresh. - - /** A user was blocked */ - data object Block : UiSuccess() - - /** A user was muted */ - data object Mute : UiSuccess() - - /** A conversation was muted */ - data object MuteConversation : UiSuccess() -} - -/** The result of a successful action on a notification */ -sealed class NotificationActionSuccess( - /** String resource with an error message to show the user */ - @StringRes val msg: Int, - - /** - * The original action, in case additional information is required from it to display the - * message. - */ - open val action: NotificationAction -) : UiSuccess() { - data class AcceptFollowRequest(override val action: NotificationAction) : - NotificationActionSuccess(R.string.ui_success_accepted_follow_request, action) - data class RejectFollowRequest(override val action: NotificationAction) : - NotificationActionSuccess(R.string.ui_success_rejected_follow_request, action) - - companion object { - fun from(action: NotificationAction) = when (action) { - is NotificationAction.AcceptFollowRequest -> AcceptFollowRequest(action) - is NotificationAction.RejectFollowRequest -> RejectFollowRequest(action) - } - } -} - -/** Actions the user can trigger on an individual status */ -sealed class StatusAction( - open val statusViewData: StatusViewData.Concrete -) : FallibleUiAction() { - /** Set the bookmark state for a status */ - data class Bookmark(val state: Boolean, override val statusViewData: StatusViewData.Concrete) : - StatusAction(statusViewData) - - /** Set the favourite state for a status */ - data class Favourite(val state: Boolean, override val statusViewData: StatusViewData.Concrete) : - StatusAction(statusViewData) - - /** Set the reblog state for a status */ - data class Reblog(val state: Boolean, override val statusViewData: StatusViewData.Concrete) : - StatusAction(statusViewData) - - /** Vote in a poll */ - data class VoteInPoll( - val poll: Poll, - val choices: List, - override val statusViewData: StatusViewData.Concrete - ) : StatusAction(statusViewData) -} - -/** Changes to a status' visible state after API calls */ -sealed class StatusActionSuccess(open val action: StatusAction) : UiSuccess() { - data class Bookmark(override val action: StatusAction.Bookmark) : - StatusActionSuccess(action) - - data class Favourite(override val action: StatusAction.Favourite) : - StatusActionSuccess(action) - - data class Reblog(override val action: StatusAction.Reblog) : - StatusActionSuccess(action) - - data class VoteInPoll(override val action: StatusAction.VoteInPoll) : - StatusActionSuccess(action) - - companion object { - fun from(action: StatusAction) = when (action) { - is StatusAction.Bookmark -> Bookmark(action) - is StatusAction.Favourite -> Favourite(action) - is StatusAction.Reblog -> Reblog(action) - is StatusAction.VoteInPoll -> VoteInPoll(action) - } - } -} - -/** Errors from fallible view model actions that the UI will need to show */ -sealed class UiError( - /** The exception associated with the error */ - open val throwable: Throwable, - - /** String resource with an error message to show the user */ - @StringRes val message: Int, - - /** The action that failed. Can be resent to retry the action */ - open val action: UiAction? = null -) { - data class ClearNotifications(override val throwable: Throwable) : UiError( - throwable, - R.string.ui_error_clear_notifications - ) - - data class Bookmark( - override val throwable: Throwable, - override val action: StatusAction.Bookmark - ) : UiError(throwable, R.string.ui_error_bookmark, action) - - data class Favourite( - override val throwable: Throwable, - override val action: StatusAction.Favourite - ) : UiError(throwable, R.string.ui_error_favourite, action) - - data class Reblog( - override val throwable: Throwable, - override val action: StatusAction.Reblog - ) : UiError(throwable, R.string.ui_error_reblog, action) - - data class VoteInPoll( - override val throwable: Throwable, - override val action: StatusAction.VoteInPoll - ) : UiError(throwable, R.string.ui_error_vote, action) - - data class AcceptFollowRequest( - override val throwable: Throwable, - override val action: NotificationAction.AcceptFollowRequest - ) : UiError(throwable, R.string.ui_error_accept_follow_request, action) - - data class RejectFollowRequest( - override val throwable: Throwable, - override val action: NotificationAction.RejectFollowRequest - ) : UiError(throwable, R.string.ui_error_reject_follow_request, action) - - companion object { - fun make(throwable: Throwable, action: FallibleUiAction) = when (action) { - is StatusAction.Bookmark -> Bookmark(throwable, action) - is StatusAction.Favourite -> Favourite(throwable, action) - is StatusAction.Reblog -> Reblog(throwable, action) - is StatusAction.VoteInPoll -> VoteInPoll(throwable, action) - is NotificationAction.AcceptFollowRequest -> AcceptFollowRequest(throwable, action) - is NotificationAction.RejectFollowRequest -> RejectFollowRequest(throwable, action) - FallibleUiAction.ClearNotifications -> ClearNotifications(throwable) - } - } -} - -@OptIn(ExperimentalCoroutinesApi::class) -class NotificationsViewModel @Inject constructor( - private val repository: NotificationsRepository, - private val preferences: SharedPreferences, - private val accountManager: AccountManager, - private val timelineCases: TimelineCases, - private val eventHub: EventHub -) : ViewModel() { - /** The account to display notifications for */ - val account = accountManager.activeAccount!! - - val uiState: StateFlow - - /** Flow of changes to statusDisplayOptions, for use by the UI */ - val statusDisplayOptions: StateFlow - - val pagingData: Flow> - - /** Flow of user actions received from the UI */ - private val uiAction = MutableSharedFlow() - - /** Flow that can be used to trigger a full reload */ - private val reload = MutableStateFlow(0) - - /** Flow of successful action results */ - // Note: This is a SharedFlow instead of a StateFlow because success state does not need to be - // retained. A message is shown once to a user and then dismissed. Re-collecting the flow - // (e.g., after a device orientation change) should not re-show the most recent success - // message, as it will be confusing to the user. - val uiSuccess = MutableSharedFlow() - - /** Channel for error results */ - // Errors are sent to a channel to ensure that any errors that occur *before* there are any - // subscribers are retained. If this was a SharedFlow any errors would be dropped, and if it - // was a StateFlow any errors would be retained, and there would need to be an explicit - // mechanism to dismiss them. - private val _uiErrorChannel = Channel() - - /** Expose UI errors as a flow */ - val uiError = _uiErrorChannel.receiveAsFlow() - - /** Accept UI actions in to actionStateFlow */ - val accept: (UiAction) -> Unit = { action -> - viewModelScope.launch { uiAction.emit(action) } - } - - init { - // Handle changes to notification filters - val notificationFilter = uiAction - .filterIsInstance() - .distinctUntilChanged() - // Save each change back to the active account - .onEach { action -> - Log.d(TAG, "notificationFilter: $action") - account.notificationsFilter = serialize(action.filter) - accountManager.saveAccount(account) - } - // Load the initial filter from the active account - .onStart { - emit( - InfallibleUiAction.ApplyFilter( - filter = deserialize(account.notificationsFilter) - ) - ) - } - - // Reset the last notification ID to "0" to fetch the newest notifications, and - // increment `reload` to trigger creation of a new PagingSource. - viewModelScope.launch { - uiAction - .filterIsInstance() - .collectLatest { - account.lastNotificationId = "0" - accountManager.saveAccount(account) - reload.getAndUpdate { it + 1 } - } - } - - // Save the visible notification ID - viewModelScope.launch { - uiAction - .filterIsInstance() - .distinctUntilChanged() - .collectLatest { action -> - Log.d(TAG, "Saving visible ID: ${action.visibleId}, active account = ${account.id}") - account.lastNotificationId = action.visibleId - accountManager.saveAccount(account) - } - } - - // Set initial status display options from the user's preferences. - // - // Then collect future preference changes and emit new values in to - // statusDisplayOptions if necessary. - statusDisplayOptions = MutableStateFlow( - StatusDisplayOptions.from( - preferences, - account - ) - ) - - viewModelScope.launch { - eventHub.events - .filterIsInstance() - .filter { StatusDisplayOptions.prefKeys.contains(it.preferenceKey) } - .map { - statusDisplayOptions.value.make( - preferences, - it.preferenceKey, - account - ) - } - .collect { - statusDisplayOptions.emit(it) - } - } - - // Handle UiAction.ClearNotifications - viewModelScope.launch { - uiAction.filterIsInstance() - .collectLatest { - try { - repository.clearNotifications().apply { - if (this.isSuccessful) { - repository.invalidate() - } else { - _uiErrorChannel.send(UiError.make(HttpException(this), it)) - } - } - } catch (e: Exception) { - ifExpected(e) { _uiErrorChannel.send(UiError.make(e, it)) } - } - } - } - - // Handle NotificationAction.* - viewModelScope.launch { - uiAction.filterIsInstance() - .throttleFirst(THROTTLE_TIMEOUT) - .collect { action -> - try { - when (action) { - is NotificationAction.AcceptFollowRequest -> - timelineCases.acceptFollowRequest(action.accountId).await() - is NotificationAction.RejectFollowRequest -> - timelineCases.rejectFollowRequest(action.accountId).await() - } - uiSuccess.emit(NotificationActionSuccess.from(action)) - } catch (e: Exception) { - ifExpected(e) { _uiErrorChannel.send(UiError.make(e, action)) } - } - } - } - - // Handle StatusAction.* - viewModelScope.launch { - uiAction.filterIsInstance() - .throttleFirst(THROTTLE_TIMEOUT) // avoid double-taps - .collect { action -> - try { - when (action) { - is StatusAction.Bookmark -> - timelineCases.bookmark( - action.statusViewData.actionableId, - action.state - ) - is StatusAction.Favourite -> - timelineCases.favourite( - action.statusViewData.actionableId, - action.state - ) - is StatusAction.Reblog -> - timelineCases.reblog( - action.statusViewData.actionableId, - action.state - ) - is StatusAction.VoteInPoll -> - timelineCases.voteInPoll( - action.statusViewData.actionableId, - action.poll.id, - action.choices - ) - }.getOrThrow() - uiSuccess.emit(StatusActionSuccess.from(action)) - } catch (t: Throwable) { - _uiErrorChannel.send(UiError.make(t, action)) - } - } - } - - // Handle events that should refresh the list - viewModelScope.launch { - eventHub.events.collectLatest { - when (it) { - is BlockEvent -> uiSuccess.emit(UiSuccess.Block) - is MuteEvent -> uiSuccess.emit(UiSuccess.Mute) - is MuteConversationEvent -> uiSuccess.emit(UiSuccess.MuteConversation) - } - } - } - - // Re-fetch notifications if either of `notificationFilter` or `reload` flows have - // new items. - pagingData = combine(notificationFilter, reload) { action, _ -> action } - .flatMapLatest { action -> - getNotifications(filters = action.filter, initialKey = getInitialKey()) - }.cachedIn(viewModelScope) - - uiState = combine(notificationFilter, getUiPrefs()) { filter, prefs -> - UiState( - activeFilter = filter.filter, - showFabWhileScrolling = prefs.showFabWhileScrolling - ) - }.stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000), - initialValue = UiState() - ) - } - - private fun getNotifications( - filters: Set, - initialKey: String? = null - ): Flow> { - return repository.getNotificationsStream(filter = filters, initialKey = initialKey) - .map { pagingData -> - pagingData.map { notification -> - notification.toViewData( - isShowingContent = statusDisplayOptions.value.showSensitiveMedia || - !(notification.status?.actionableStatus?.sensitive ?: false), - isExpanded = statusDisplayOptions.value.openSpoiler, - isCollapsed = true - ) - } - } - } - - // The database stores "0" as the last notification ID if notifications have not been - // fetched. Convert to null to ensure a full fetch in this case - private fun getInitialKey(): String? { - val initialKey = when (val id = account.lastNotificationId) { - "0" -> null - else -> id - } - Log.d(TAG, "Restoring at $initialKey") - return initialKey - } - - /** - * @return Flow of relevant preferences that change the UI - */ - // TODO: Preferences should be in a repository - private fun getUiPrefs() = eventHub.events - .filterIsInstance() - .filter { UiPrefs.prefKeys.contains(it.preferenceKey) } - .map { toPrefs() } - .onStart { emit(toPrefs()) } - - private fun toPrefs() = UiPrefs( - showFabWhileScrolling = !preferences.getBoolean(PrefKeys.FAB_HIDE, false) - ) - - companion object { - private const val TAG = "NotificationsViewModel" - private val THROTTLE_TIMEOUT = 500.milliseconds - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt b/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt index 0ac5ae54e..dc4d03449 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt @@ -33,7 +33,6 @@ import com.keylesspalace.tusky.components.filters.EditFilterViewModel import com.keylesspalace.tusky.components.filters.FiltersViewModel import com.keylesspalace.tusky.components.followedtags.FollowedTagsViewModel import com.keylesspalace.tusky.components.login.LoginWebViewViewModel -import com.keylesspalace.tusky.components.notifications.NotificationsViewModel import com.keylesspalace.tusky.components.report.ReportViewModel import com.keylesspalace.tusky.components.scheduled.ScheduledStatusViewModel import com.keylesspalace.tusky.components.search.SearchViewModel @@ -166,11 +165,6 @@ abstract class ViewModelModule { @ViewModelKey(ListsForAccountViewModel::class) internal abstract fun listsForAccountViewModel(viewModel: ListsForAccountViewModel): ViewModel - @Binds - @IntoMap - @ViewModelKey(NotificationsViewModel::class) - internal abstract fun notificationsViewModel(viewModel: NotificationsViewModel): ViewModel - @Binds @IntoMap @ViewModelKey(TrendingTagsViewModel::class) diff --git a/app/src/test/java/com/keylesspalace/tusky/components/notifications/NotificationsPagingSourceTest.kt b/app/src/test/java/com/keylesspalace/tusky/components/notifications/NotificationsPagingSourceTest.kt deleted file mode 100644 index b91e07161..000000000 --- a/app/src/test/java/com/keylesspalace/tusky/components/notifications/NotificationsPagingSourceTest.kt +++ /dev/null @@ -1,143 +0,0 @@ -/* - * Copyright 2023 Tusky Contributors - * - * This file is a part of Tusky. - * - * This program is free software; you can redistribute it and/or modify it under the terms of the - * GNU General Public License as published by the Free Software Foundation; either version 3 of the - * License, or (at your option) any later version. - * - * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even - * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General - * Public License for more details. - * - * You should have received a copy of the GNU General Public License along with Tusky; if not, - * see . - */ - -package com.keylesspalace.tusky.components.notifications - -import androidx.paging.PagingSource -import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.google.gson.Gson -import com.keylesspalace.tusky.entity.Notification -import com.keylesspalace.tusky.network.MastodonApi -import kotlinx.coroutines.test.runTest -import okhttp3.ResponseBody.Companion.toResponseBody -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Test -import org.junit.runner.RunWith -import org.mockito.kotlin.any -import org.mockito.kotlin.anyOrNull -import org.mockito.kotlin.doReturn -import org.mockito.kotlin.mock -import org.robolectric.annotation.Config -import retrofit2.Response - -@Config(sdk = [28]) -@RunWith(AndroidJUnit4::class) -class NotificationsPagingSourceTest { - @Test - fun `load() returns error message on HTTP error`() = runTest { - // Given - val jsonError = "{error: 'This is an error'}".toResponseBody() - val mockApi: MastodonApi = mock { - onBlocking { notifications(anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) } doReturn Response.error(429, jsonError) - onBlocking { notification(any()) } doReturn Response.error(429, jsonError) - } - - val filter = emptySet() - val gson = Gson() - val pagingSource = NotificationsPagingSource(mockApi, gson, filter) - val loadingParams = PagingSource.LoadParams.Refresh("0", 5, false) - - // When - val loadResult = pagingSource.load(loadingParams) - - // Then - assertTrue(loadResult is PagingSource.LoadResult.Error) - assertEquals( - "HTTP 429: This is an error", - (loadResult as PagingSource.LoadResult.Error).throwable.message - ) - } - - // As previous, but with `error_description` field as well. - @Test - fun `load() returns extended error message on HTTP error`() = runTest { - // Given - val jsonError = "{error: 'This is an error', error_description: 'Description of the error'}".toResponseBody() - val mockApi: MastodonApi = mock { - onBlocking { notifications(anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) } doReturn Response.error(429, jsonError) - onBlocking { notification(any()) } doReturn Response.error(429, jsonError) - } - - val filter = emptySet() - val gson = Gson() - val pagingSource = NotificationsPagingSource(mockApi, gson, filter) - val loadingParams = PagingSource.LoadParams.Refresh("0", 5, false) - - // When - val loadResult = pagingSource.load(loadingParams) - - // Then - assertTrue(loadResult is PagingSource.LoadResult.Error) - assertEquals( - "HTTP 429: This is an error: Description of the error", - (loadResult as PagingSource.LoadResult.Error).throwable.message - ) - } - - // As previous, but no error JSON, so expect default response - @Test - fun `load() returns default error message on empty HTTP error`() = runTest { - // Given - val jsonError = "{}".toResponseBody() - val mockApi: MastodonApi = mock { - onBlocking { notifications(anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) } doReturn Response.error(429, jsonError) - onBlocking { notification(any()) } doReturn Response.error(429, jsonError) - } - - val filter = emptySet() - val gson = Gson() - val pagingSource = NotificationsPagingSource(mockApi, gson, filter) - val loadingParams = PagingSource.LoadParams.Refresh("0", 5, false) - - // When - val loadResult = pagingSource.load(loadingParams) - - // Then - assertTrue(loadResult is PagingSource.LoadResult.Error) - assertEquals( - "HTTP 429: no reason given", - (loadResult as PagingSource.LoadResult.Error).throwable.message - ) - } - - // As previous, but malformed JSON, so expect response with enough information to troubleshoot - @Test - fun `load() returns useful error message on malformed HTTP error`() = runTest { - // Given - val jsonError = "{'malformedjson}".toResponseBody() - val mockApi: MastodonApi = mock { - onBlocking { notifications(anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) } doReturn Response.error(429, jsonError) - onBlocking { notification(any()) } doReturn Response.error(429, jsonError) - } - - val filter = emptySet() - val gson = Gson() - val pagingSource = NotificationsPagingSource(mockApi, gson, filter) - val loadingParams = PagingSource.LoadParams.Refresh("0", 5, false) - - // When - val loadResult = pagingSource.load(loadingParams) - - // Then - assertTrue(loadResult is PagingSource.LoadResult.Error) - assertEquals( - "HTTP 429: {'malformedjson} (com.google.gson.JsonSyntaxException: com.google.gson.stream.MalformedJsonException: Unterminated string at line 1 column 17 path \$.)", - (loadResult as PagingSource.LoadResult.Error).throwable.message - ) - } -} diff --git a/app/src/test/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModelTestBase.kt b/app/src/test/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModelTestBase.kt deleted file mode 100644 index b99f64f64..000000000 --- a/app/src/test/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModelTestBase.kt +++ /dev/null @@ -1,135 +0,0 @@ -/* - * Copyright 2023 Tusky Contributors - * - * This file is a part of Tusky. - * - * This program is free software; you can redistribute it and/or modify it under the terms of the - * GNU General Public License as published by the Free Software Foundation; either version 3 of the - * License, or (at your option) any later version. - * - * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even - * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General - * Public License for more details. - * - * You should have received a copy of the GNU General Public License along with Tusky; if not, - * see . - */ - -package com.keylesspalace.tusky.components.notifications - -import android.content.SharedPreferences -import android.os.Looper -import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.keylesspalace.tusky.appstore.EventHub -import com.keylesspalace.tusky.db.AccountEntity -import com.keylesspalace.tusky.db.AccountManager -import com.keylesspalace.tusky.settings.PrefKeys -import com.keylesspalace.tusky.usecase.TimelineCases -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.TestDispatcher -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.resetMain -import kotlinx.coroutines.test.setMain -import okhttp3.ResponseBody -import okhttp3.ResponseBody.Companion.toResponseBody -import org.junit.Before -import org.junit.Rule -import org.junit.rules.TestWatcher -import org.junit.runner.Description -import org.junit.runner.RunWith -import org.mockito.kotlin.any -import org.mockito.kotlin.doAnswer -import org.mockito.kotlin.doReturn -import org.mockito.kotlin.mock -import org.robolectric.Shadows.shadowOf -import org.robolectric.annotation.Config -import retrofit2.HttpException -import retrofit2.Response - -@OptIn(ExperimentalCoroutinesApi::class) -class MainCoroutineRule(private val dispatcher: TestDispatcher = UnconfinedTestDispatcher()) : TestWatcher() { - override fun starting(description: Description) { - super.starting(description) - Dispatchers.setMain(dispatcher) - } - - override fun finished(description: Description) { - super.finished(description) - Dispatchers.resetMain() - } -} - -@Config(sdk = [28]) -@RunWith(AndroidJUnit4::class) -abstract class NotificationsViewModelTestBase { - protected lateinit var notificationsRepository: NotificationsRepository - protected lateinit var sharedPreferencesMap: MutableMap - protected lateinit var sharedPreferences: SharedPreferences - protected lateinit var accountManager: AccountManager - protected lateinit var timelineCases: TimelineCases - protected lateinit var eventHub: EventHub - protected lateinit var viewModel: NotificationsViewModel - - /** Empty success response, for API calls that return one */ - protected var emptySuccess: Response = Response.success("".toResponseBody()) - - /** Empty error response, for API calls that return one */ - protected var emptyError: Response = Response.error(404, "".toResponseBody()) - - /** Exception to throw when testing errors */ - protected val httpException = HttpException(emptyError) - - @get:Rule - val mainCoroutineRule = MainCoroutineRule() - - @Before - fun setup() { - shadowOf(Looper.getMainLooper()).idle() - - notificationsRepository = mock() - - // Backing store for sharedPreferences, to allow mutation in tests - sharedPreferencesMap = mutableMapOf( - PrefKeys.ANIMATE_GIF_AVATARS to false, - PrefKeys.ANIMATE_CUSTOM_EMOJIS to false, - PrefKeys.ABSOLUTE_TIME_VIEW to false, - PrefKeys.SHOW_BOT_OVERLAY to true, - PrefKeys.USE_BLURHASH to true, - PrefKeys.CONFIRM_REBLOGS to true, - PrefKeys.CONFIRM_FAVOURITES to false, - PrefKeys.WELLBEING_HIDE_STATS_POSTS to false, - PrefKeys.FAB_HIDE to false - ) - - // Any getBoolean() call looks for the result in sharedPreferencesMap - sharedPreferences = mock { - on { getBoolean(any(), any()) } doAnswer { sharedPreferencesMap[it.arguments[0]] } - } - - accountManager = mock { - on { activeAccount } doReturn AccountEntity( - id = 1, - domain = "mastodon.test", - accessToken = "fakeToken", - clientId = "fakeId", - clientSecret = "fakeSecret", - isActive = true, - notificationsFilter = "['follow']", - mediaPreviewEnabled = true, - alwaysShowSensitiveMedia = true, - alwaysOpenSpoiler = true - ) - } - eventHub = EventHub() - timelineCases = mock() - - viewModel = NotificationsViewModel( - notificationsRepository, - sharedPreferences, - accountManager, - timelineCases, - eventHub - ) - } -} diff --git a/app/src/test/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModelTestClearNotifications.kt b/app/src/test/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModelTestClearNotifications.kt deleted file mode 100644 index e6b765be4..000000000 --- a/app/src/test/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModelTestClearNotifications.kt +++ /dev/null @@ -1,63 +0,0 @@ -/* - * Copyright 2023 Tusky Contributors - * - * This file is a part of Tusky. - * - * This program is free software; you can redistribute it and/or modify it under the terms of the - * GNU General Public License as published by the Free Software Foundation; either version 3 of the - * License, or (at your option) any later version. - * - * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even - * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General - * Public License for more details. - * - * You should have received a copy of the GNU General Public License along with Tusky; if not, - * see . - */ - -package com.keylesspalace.tusky.components.notifications - -import app.cash.turbine.test -import com.google.common.truth.Truth.assertThat -import kotlinx.coroutines.test.runTest -import org.junit.Test -import org.mockito.kotlin.doReturn -import org.mockito.kotlin.stub -import org.mockito.kotlin.verify - -/** - * Verify that [ClearNotifications] is handled correctly on receipt: - * - * - Is the correct [UiSuccess] or [UiError] value emitted? - * - Are the correct [NotificationsRepository] functions called, with the correct arguments? - * This is only tested in the success case; if it passed there it must also - * have passed in the error case. - */ -class NotificationsViewModelTestClearNotifications : NotificationsViewModelTestBase() { - @Test - fun `clearing notifications succeeds && invalidate the repository`() = runTest { - // Given - notificationsRepository.stub { onBlocking { clearNotifications() } doReturn emptySuccess } - - // When - viewModel.accept(FallibleUiAction.ClearNotifications) - - // Then - verify(notificationsRepository).clearNotifications() - verify(notificationsRepository).invalidate() - } - - @Test - fun `clearing notifications fails && emits UiError`() = runTest { - // Given - notificationsRepository.stub { onBlocking { clearNotifications() } doReturn emptyError } - - viewModel.uiError.test { - // When - viewModel.accept(FallibleUiAction.ClearNotifications) - - // Then - assertThat(awaitItem()).isInstanceOf(UiError::class.java) - } - } -} diff --git a/app/src/test/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModelTestFilter.kt b/app/src/test/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModelTestFilter.kt deleted file mode 100644 index 98737e431..000000000 --- a/app/src/test/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModelTestFilter.kt +++ /dev/null @@ -1,64 +0,0 @@ -/* - * Copyright 2023 Tusky Contributors - * - * This file is a part of Tusky. - * - * This program is free software; you can redistribute it and/or modify it under the terms of the - * GNU General Public License as published by the Free Software Foundation; either version 3 of the - * License, or (at your option) any later version. - * - * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even - * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General - * Public License for more details. - * - * You should have received a copy of the GNU General Public License along with Tusky; if not, - * see . - */ - -package com.keylesspalace.tusky.components.notifications - -import app.cash.turbine.test -import com.google.common.truth.Truth.assertThat -import com.keylesspalace.tusky.db.AccountEntity -import com.keylesspalace.tusky.entity.Notification -import kotlinx.coroutines.test.runTest -import org.junit.Test -import org.mockito.kotlin.argumentCaptor -import org.mockito.kotlin.verify - -/** - * Verify that [ApplyFilter] is handled correctly on receipt: - * - * - Is the [UiState] updated correctly? - * - Are the correct [AccountManager] functions called, with the correct arguments? - */ -class NotificationsViewModelTestFilter : NotificationsViewModelTestBase() { - - @Test - fun `should load initial filter from active account`() = runTest { - viewModel.uiState.test { - assertThat(awaitItem().activeFilter) - .containsExactlyElementsIn(setOf(Notification.Type.FOLLOW)) - } - } - - @Test - fun `should save filter to active account && update state`() = runTest { - viewModel.uiState.test { - // When - viewModel.accept(InfallibleUiAction.ApplyFilter(setOf(Notification.Type.REBLOG))) - - // Then - // - filter saved to active account - argumentCaptor().apply { - verify(accountManager).saveAccount(capture()) - assertThat(this.lastValue.notificationsFilter) - .isEqualTo("[\"reblog\"]") - } - - // - filter updated in uiState - assertThat(expectMostRecentItem().activeFilter) - .containsExactlyElementsIn(setOf(Notification.Type.REBLOG)) - } - } -} diff --git a/app/src/test/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModelTestNotificationAction.kt b/app/src/test/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModelTestNotificationAction.kt deleted file mode 100644 index 3c48dd2b1..000000000 --- a/app/src/test/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModelTestNotificationAction.kt +++ /dev/null @@ -1,142 +0,0 @@ -/* - * Copyright 2023 Tusky Contributors - * - * This file is a part of Tusky. - * - * This program is free software; you can redistribute it and/or modify it under the terms of the - * GNU General Public License as published by the Free Software Foundation; either version 3 of the - * License, or (at your option) any later version. - * - * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even - * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General - * Public License for more details. - * - * You should have received a copy of the GNU General Public License along with Tusky; if not, - * see . - */ - -package com.keylesspalace.tusky.components.notifications - -import app.cash.turbine.test -import com.google.common.truth.Truth.assertThat -import com.keylesspalace.tusky.entity.Relationship -import io.reactivex.rxjava3.core.Single -import kotlinx.coroutines.test.runTest -import org.junit.Test -import org.mockito.kotlin.any -import org.mockito.kotlin.argumentCaptor -import org.mockito.kotlin.doReturn -import org.mockito.kotlin.doThrow -import org.mockito.kotlin.stub -import org.mockito.kotlin.verify - -/** - * Verify that [NotificationAction] are handled correctly on receipt: - * - * - Is the correct [UiSuccess] or [UiError] value emitted? - * - Is the correct [TimelineCases] function called, with the correct arguments? - * This is only tested in the success case; if it passed there it must also - * have passed in the error case. - */ -class NotificationsViewModelTestNotificationAction : NotificationsViewModelTestBase() { - /** Dummy relationship */ - private val relationship = Relationship( - // Nothing special about these values, it's just to have something to return - "1234", - following = true, - followedBy = true, - blocking = false, - muting = false, - mutingNotifications = false, - requested = false, - showingReblogs = false, - subscribing = null, - blockingDomain = false, - note = null, - notifying = null - ) - - /** Action to accept a follow request */ - private val acceptAction = NotificationAction.AcceptFollowRequest("1234") - - /** Action to reject a follow request */ - private val rejectAction = NotificationAction.RejectFollowRequest("1234") - - @Test - fun `accepting follow request succeeds && emits UiSuccess`() = runTest { - // Given - timelineCases.stub { - onBlocking { acceptFollowRequest(any()) } doReturn Single.just(relationship) - } - - viewModel.uiSuccess.test { - // When - viewModel.accept(acceptAction) - - // Then - val item = awaitItem() - assertThat(item).isInstanceOf(NotificationActionSuccess::class.java) - assertThat((item as NotificationActionSuccess).action).isEqualTo(acceptAction) - } - - // Then - argumentCaptor().apply { - verify(timelineCases).acceptFollowRequest(capture()) - assertThat(this.lastValue).isEqualTo("1234") - } - } - - @Test - fun `accepting follow request fails && emits UiError`() = runTest { - // Given - timelineCases.stub { onBlocking { acceptFollowRequest(any()) } doThrow httpException } - - viewModel.uiError.test { - // When - viewModel.accept(acceptAction) - - // Then - val item = awaitItem() - assertThat(item).isInstanceOf(UiError.AcceptFollowRequest::class.java) - assertThat(item.action).isEqualTo(acceptAction) - } - } - - @Test - fun `rejecting follow request succeeds && emits UiSuccess`() = runTest { - // Given - timelineCases.stub { onBlocking { rejectFollowRequest(any()) } doReturn Single.just(relationship) } - - viewModel.uiSuccess.test { - // When - viewModel.accept(rejectAction) - - // Then - val item = awaitItem() - assertThat(item).isInstanceOf(NotificationActionSuccess::class.java) - assertThat((item as NotificationActionSuccess).action).isEqualTo(rejectAction) - } - - // Then - argumentCaptor().apply { - verify(timelineCases).rejectFollowRequest(capture()) - assertThat(this.lastValue).isEqualTo("1234") - } - } - - @Test - fun `rejecting follow request fails && emits UiError`() = runTest { - // Given - timelineCases.stub { onBlocking { rejectFollowRequest(any()) } doThrow httpException } - - viewModel.uiError.test { - // When - viewModel.accept(rejectAction) - - // Then - val item = awaitItem() - assertThat(item).isInstanceOf(UiError.RejectFollowRequest::class.java) - assertThat(item.action).isEqualTo(rejectAction) - } - } -} diff --git a/app/src/test/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModelTestStatusAction.kt b/app/src/test/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModelTestStatusAction.kt deleted file mode 100644 index cf7ce2dc1..000000000 --- a/app/src/test/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModelTestStatusAction.kt +++ /dev/null @@ -1,225 +0,0 @@ -/* - * Copyright 2023 Tusky Contributors - * - * This file is a part of Tusky. - * - * This program is free software; you can redistribute it and/or modify it under the terms of the - * GNU General Public License as published by the Free Software Foundation; either version 3 of the - * License, or (at your option) any later version. - * - * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even - * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General - * Public License for more details. - * - * You should have received a copy of the GNU General Public License along with Tusky; if not, - * see . - */ - -package com.keylesspalace.tusky.components.notifications - -import app.cash.turbine.test -import at.connyduck.calladapter.networkresult.NetworkResult -import com.google.common.truth.Truth.assertThat -import com.keylesspalace.tusky.FilterV1Test.Companion.mockStatus -import com.keylesspalace.tusky.viewdata.StatusViewData -import kotlinx.coroutines.test.runTest -import org.junit.Test -import org.mockito.kotlin.any -import org.mockito.kotlin.argumentCaptor -import org.mockito.kotlin.doReturn -import org.mockito.kotlin.doThrow -import org.mockito.kotlin.stub -import org.mockito.kotlin.verify - -/** - * Verify that [StatusAction] are handled correctly on receipt: - * - * - Is the correct [UiSuccess] or [UiError] value emitted? - * - Is the correct [TimelineCases] function called, with the correct arguments? - * This is only tested in the success case; if it passed there it must also - * have passed in the error case. - */ -class NotificationsViewModelTestStatusAction : NotificationsViewModelTestBase() { - private val status = mockStatus(pollOptions = listOf("Choice 1", "Choice 2", "Choice 3")) - private val statusViewData = StatusViewData.Concrete( - status = status, - isExpanded = true, - isShowingContent = false, - isCollapsed = false - ) - - /** Action to bookmark a status */ - private val bookmarkAction = StatusAction.Bookmark(true, statusViewData) - - /** Action to favourite a status */ - private val favouriteAction = StatusAction.Favourite(true, statusViewData) - - /** Action to reblog a status */ - private val reblogAction = StatusAction.Reblog(true, statusViewData) - - /** Action to vote in a poll */ - private val voteInPollAction = StatusAction.VoteInPoll( - poll = status.poll!!, - choices = listOf(1, 0, 0), - statusViewData - ) - - /** Captors for status ID and state arguments */ - private val id = argumentCaptor() - private val state = argumentCaptor() - - @Test - fun `bookmark succeeds && emits UiSuccess`() = runTest { - // Given - timelineCases.stub { onBlocking { bookmark(any(), any()) } doReturn NetworkResult.success(status) } - - viewModel.uiSuccess.test { - // When - viewModel.accept(bookmarkAction) - - // Then - val item = awaitItem() - assertThat(item).isInstanceOf(StatusActionSuccess.Bookmark::class.java) - assertThat((item as StatusActionSuccess).action).isEqualTo(bookmarkAction) - } - - // Then - verify(timelineCases).bookmark(id.capture(), state.capture()) - assertThat(id.firstValue).isEqualTo(statusViewData.status.id) - assertThat(state.firstValue).isEqualTo(true) - } - - @Test - fun `bookmark fails && emits UiError`() = runTest { - // Given - timelineCases.stub { onBlocking { bookmark(any(), any()) } doThrow httpException } - - viewModel.uiError.test { - // When - viewModel.accept(bookmarkAction) - - // Then - val item = awaitItem() - assertThat(item).isInstanceOf(UiError.Bookmark::class.java) - assertThat(item.action).isEqualTo(bookmarkAction) - } - } - - @Test - fun `favourite succeeds && emits UiSuccess`() = runTest { - // Given - timelineCases.stub { - onBlocking { favourite(any(), any()) } doReturn NetworkResult.success(status) - } - - viewModel.uiSuccess.test { - // When - viewModel.accept(favouriteAction) - - // Then - val item = awaitItem() - assertThat(item).isInstanceOf(StatusActionSuccess.Favourite::class.java) - assertThat((item as StatusActionSuccess).action).isEqualTo(favouriteAction) - } - - // Then - verify(timelineCases).favourite(id.capture(), state.capture()) - assertThat(id.firstValue).isEqualTo(statusViewData.status.id) - assertThat(state.firstValue).isEqualTo(true) - } - - @Test - fun `favourite fails && emits UiError`() = runTest { - // Given - timelineCases.stub { onBlocking { favourite(any(), any()) } doThrow httpException } - - viewModel.uiError.test { - // When - viewModel.accept(favouriteAction) - - // Then - val item = awaitItem() - assertThat(item).isInstanceOf(UiError.Favourite::class.java) - assertThat(item.action).isEqualTo(favouriteAction) - } - } - - @Test - fun `reblog succeeds && emits UiSuccess`() = runTest { - // Given - timelineCases.stub { onBlocking { reblog(any(), any()) } doReturn NetworkResult.success(status) } - - viewModel.uiSuccess.test { - // When - viewModel.accept(reblogAction) - - // Then - val item = awaitItem() - assertThat(item).isInstanceOf(StatusActionSuccess.Reblog::class.java) - assertThat((item as StatusActionSuccess).action).isEqualTo(reblogAction) - } - - // Then - verify(timelineCases).reblog(id.capture(), state.capture()) - assertThat(id.firstValue).isEqualTo(statusViewData.status.id) - assertThat(state.firstValue).isEqualTo(true) - } - - @Test - fun `reblog fails && emits UiError`() = runTest { - // Given - timelineCases.stub { onBlocking { reblog(any(), any()) } doThrow httpException } - - viewModel.uiError.test { - // When - viewModel.accept(reblogAction) - - // Then - val item = awaitItem() - assertThat(item).isInstanceOf(UiError.Reblog::class.java) - assertThat(item.action).isEqualTo(reblogAction) - } - } - - @Test - fun `voteinpoll succeeds && emits UiSuccess`() = runTest { - // Given - timelineCases.stub { - onBlocking { voteInPoll(any(), any(), any()) } doReturn NetworkResult.success(status.poll!!) - } - - viewModel.uiSuccess.test { - // When - viewModel.accept(voteInPollAction) - - // Then - val item = awaitItem() - assertThat(item).isInstanceOf(StatusActionSuccess.VoteInPoll::class.java) - assertThat((item as StatusActionSuccess).action).isEqualTo(voteInPollAction) - } - - // Then - val pollId = argumentCaptor() - val choices = argumentCaptor>() - verify(timelineCases).voteInPoll(id.capture(), pollId.capture(), choices.capture()) - assertThat(id.firstValue).isEqualTo(statusViewData.status.id) - assertThat(pollId.firstValue).isEqualTo(status.poll!!.id) - assertThat(choices.firstValue).isEqualTo(voteInPollAction.choices) - } - - @Test - fun `voteinpoll fails && emits UiError`() = runTest { - // Given - timelineCases.stub { onBlocking { voteInPoll(any(), any(), any()) } doThrow httpException } - - viewModel.uiError.test { - // When - viewModel.accept(voteInPollAction) - - // Then - val item = awaitItem() - assertThat(item).isInstanceOf(UiError.VoteInPoll::class.java) - assertThat(item.action).isEqualTo(voteInPollAction) - } - } -} diff --git a/app/src/test/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModelTestStatusDisplayOptions.kt b/app/src/test/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModelTestStatusDisplayOptions.kt deleted file mode 100644 index f3dd3c478..000000000 --- a/app/src/test/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModelTestStatusDisplayOptions.kt +++ /dev/null @@ -1,101 +0,0 @@ -/* - * Copyright 2023 Tusky Contributors - * - * This file is a part of Tusky. - * - * This program is free software; you can redistribute it and/or modify it under the terms of the - * GNU General Public License as published by the Free Software Foundation; either version 3 of the - * License, or (at your option) any later version. - * - * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even - * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General - * Public License for more details. - * - * You should have received a copy of the GNU General Public License along with Tusky; if not, - * see . - */ - -package com.keylesspalace.tusky.components.notifications - -import app.cash.turbine.test -import com.google.common.truth.Truth.assertThat -import com.keylesspalace.tusky.appstore.PreferenceChangedEvent -import com.keylesspalace.tusky.settings.PrefKeys -import com.keylesspalace.tusky.util.CardViewMode -import com.keylesspalace.tusky.util.StatusDisplayOptions -import kotlinx.coroutines.test.runTest -import org.junit.Test - -/** - * Verify that [StatusDisplayOptions] are handled correctly. - * - * - Is the initial value taken from values in sharedPreferences and account? - * - Does the make() function correctly use an updated preference? - * - Is the correct update emitted when a relevant preference changes? - */ -class NotificationsViewModelTestStatusDisplayOptions : NotificationsViewModelTestBase() { - - private val defaultStatusDisplayOptions = StatusDisplayOptions( - animateAvatars = false, - mediaPreviewEnabled = true, // setting in NotificationsViewModelTestBase - useAbsoluteTime = false, - showBotOverlay = true, - useBlurhash = true, - cardViewMode = CardViewMode.NONE, - confirmReblogs = true, - confirmFavourites = false, - hideStats = false, - animateEmojis = false, - showStatsInline = false, - showSensitiveMedia = true, // setting in NotificationsViewModelTestBase - openSpoiler = true // setting in NotificationsViewModelTestBase - ) - - @Test - fun `initial settings are from sharedPreferences and activeAccount`() = runTest { - viewModel.statusDisplayOptions.test { - val item = awaitItem() - assertThat(item).isEqualTo(defaultStatusDisplayOptions) - } - } - - @Test - fun `make() uses updated preference`() = runTest { - // Prior, should be false - assertThat(defaultStatusDisplayOptions.animateAvatars).isFalse() - - // Given; just a change to one preferences - sharedPreferencesMap[PrefKeys.ANIMATE_GIF_AVATARS] = true - - // When - val updatedOptions = defaultStatusDisplayOptions.make( - sharedPreferences, - PrefKeys.ANIMATE_GIF_AVATARS, - accountManager.activeAccount!! - ) - - // Then, should be true - assertThat(updatedOptions.animateAvatars).isTrue() - } - - @Test - fun `PreferenceChangedEvent emits new StatusDisplayOptions`() = runTest { - // Prior, should be false - viewModel.statusDisplayOptions.test { - val item = expectMostRecentItem() - assertThat(item.animateAvatars).isFalse() - } - - // Given - sharedPreferencesMap[PrefKeys.ANIMATE_GIF_AVATARS] = true - - // When - eventHub.dispatch(PreferenceChangedEvent(PrefKeys.ANIMATE_GIF_AVATARS)) - - // Then, should be true - viewModel.statusDisplayOptions.test { - val item = expectMostRecentItem() - assertThat(item.animateAvatars).isTrue() - } - } -} diff --git a/app/src/test/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModelTestUiState.kt b/app/src/test/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModelTestUiState.kt deleted file mode 100644 index 942fe717b..000000000 --- a/app/src/test/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModelTestUiState.kt +++ /dev/null @@ -1,66 +0,0 @@ -/* - * Copyright 2023 Tusky Contributors - * - * This file is a part of Tusky. - * - * This program is free software; you can redistribute it and/or modify it under the terms of the - * GNU General Public License as published by the Free Software Foundation; either version 3 of the - * License, or (at your option) any later version. - * - * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even - * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General - * Public License for more details. - * - * You should have received a copy of the GNU General Public License along with Tusky; if not, - * see . - */ - -package com.keylesspalace.tusky.components.notifications - -import app.cash.turbine.test -import com.google.common.truth.Truth.assertThat -import com.keylesspalace.tusky.appstore.PreferenceChangedEvent -import com.keylesspalace.tusky.entity.Notification -import com.keylesspalace.tusky.settings.PrefKeys -import kotlinx.coroutines.test.runTest -import org.junit.Test - -/** - * Verify that [UiState] is handled correctly. - * - * - Is the initial value taken from values in sharedPreferences and account? - * - Is the correct update emitted when a relevant preference changes? - */ -class NotificationsViewModelTestUiState : NotificationsViewModelTestBase() { - - private val initialUiState = UiState( - activeFilter = setOf(Notification.Type.FOLLOW), - showFabWhileScrolling = true - ) - - @Test - fun `should load initial filter from active account`() = runTest { - viewModel.uiState.test { - assertThat(expectMostRecentItem()).isEqualTo(initialUiState) - } - } - - @Test - fun `showFabWhileScrolling depends on FAB_HIDE preference`() = runTest { - // Prior - viewModel.uiState.test { - assertThat(expectMostRecentItem().showFabWhileScrolling).isTrue() - } - - // Given - sharedPreferencesMap[PrefKeys.FAB_HIDE] = true - - // When - eventHub.dispatch(PreferenceChangedEvent(PrefKeys.FAB_HIDE)) - - // Then - viewModel.uiState.test { - assertThat(expectMostRecentItem().showFabWhileScrolling).isFalse() - } - } -} diff --git a/app/src/test/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModelTestVisibleId.kt b/app/src/test/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModelTestVisibleId.kt deleted file mode 100644 index f6b7360e6..000000000 --- a/app/src/test/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModelTestVisibleId.kt +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Copyright 2023 Tusky Contributors - * - * This file is a part of Tusky. - * - * This program is free software; you can redistribute it and/or modify it under the terms of the - * GNU General Public License as published by the Free Software Foundation; either version 3 of the - * License, or (at your option) any later version. - * - * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even - * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General - * Public License for more details. - * - * You should have received a copy of the GNU General Public License along with Tusky; if not, - * see . - */ - -package com.keylesspalace.tusky.components.notifications - -import com.google.common.truth.Truth.assertThat -import com.keylesspalace.tusky.db.AccountEntity -import kotlinx.coroutines.test.runTest -import org.junit.Test -import org.mockito.kotlin.argumentCaptor -import org.mockito.kotlin.verify - -class NotificationsViewModelTestVisibleId : NotificationsViewModelTestBase() { - - @Test - fun `should save notification ID to active account`() = runTest { - argumentCaptor().apply { - // When - viewModel.accept(InfallibleUiAction.SaveVisibleId("1234")) - - // Then - verify(accountManager).saveAccount(capture()) - assertThat(this.lastValue.lastNotificationId) - .isEqualTo("1234") - } - } -}