From 29ded409bfacec32bc086693826dd3c2d79808ed Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Sat, 11 Mar 2023 14:39:49 +0100 Subject: [PATCH 001/156] Remove NetworkTimelineRemoteMediator The layering of this code was weird. There's paging sources and remote mediators making calls "up" in to the viewmodel, so they had a cyclic dependency on each other. And there's a remote mediator when there's no local database to cache to, which made things more complicated. So: 1. Remove the remote mediator 2. Put the code to fetch statuses in to the paging source 3. Push the a variant of the "TimelineKind" type in to the paging source (probably not its final location), as this needs to be as low down the dependency tree as possible to avoid cycle. - Implement it as a sealed class instead of an enum, so that the subclasses can hold non-null Strings where necessary, instead of relying on `!!` 4. Comment out some code that either needs to be deleted or refactored, I'm not sure which yet. This builds and runs, and shows network timelines (local, federated, etc). Some functionality it lost (e.g., retaining state about whether or not the user has expanded a status) but that will be restored in future commits. --- .../viewmodel/CachedTimelineViewModel.kt | 2 +- .../viewmodel/NetworkTimelinePagingSource.kt | 218 +++++++++++++++- .../NetworkTimelineRemoteMediator.kt | 115 --------- .../viewmodel/NetworkTimelineViewModel.kt | 236 +++++++++--------- .../timeline/viewmodel/TimelineViewModel.kt | 25 +- 5 files changed, 348 insertions(+), 248 deletions(-) delete mode 100644 app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt index a8eaaf3269..03aa16d69d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt @@ -100,7 +100,7 @@ class CachedTimelineViewModel @Inject constructor( pagingData.map(Dispatchers.Default.asExecutor()) { timelineStatus -> timelineStatus.toViewData(gson) }.filter(Dispatchers.Default.asExecutor()) { statusViewData -> - !shouldFilterStatus(statusViewData) + !shouldFilterStatus(statusViewData.asStatusOrNull()?.status) } } .flowOn(Dispatchers.Default) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelinePagingSource.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelinePagingSource.kt index 56236ecf83..6529089ecc 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelinePagingSource.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelinePagingSource.kt @@ -15,23 +15,219 @@ package com.keylesspalace.tusky.components.timeline.viewmodel +import android.util.Log import androidx.paging.PagingSource import androidx.paging.PagingState -import com.keylesspalace.tusky.viewdata.StatusViewData +import com.keylesspalace.tusky.entity.Status +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.HttpException +import retrofit2.Response +import java.io.IOException +import javax.inject.Inject -class NetworkTimelinePagingSource( - private val viewModel: NetworkTimelineViewModel -) : PagingSource() { +// TODO(https://github.com/tuskyapp/Tusky/issues/3432) +// This is extremely similar to NotificationsPagingSource. Merging the code, or making it generic +// over the type of data returned (Notification, Status, etc) is probably warranted. - override fun getRefreshKey(state: PagingState): String? = null +/** Models next/prev links from the "Links" header in an API response */ +data class Links(val next: String?, val prev: String?) - override suspend fun load(params: LoadParams): LoadResult { +/** A timeline's type. Hold's data necessary to display that timeline. */ +sealed class TimelineKind { + object Home : TimelineKind() + object PublicFederated : TimelineKind() + object PublicLocal : TimelineKind() + data class Tag(val tags: List) : TimelineKind() + data class User(val id: String) : TimelineKind() + data class UserPinned(val id: String) : TimelineKind() + data class UserReplies(val id: String) : TimelineKind() + object Favourites : TimelineKind() + object Bookmarks : TimelineKind() + data class UserList(val id: String) : TimelineKind() +} + +/** [PagingSource] for Mastodon Status, identified by the Status ID */ +class NetworkTimelinePagingSource @Inject constructor( + private val api: MastodonApi, + private val kind: TimelineKind +) : 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 -> fetchStatusesForKind( + maxId = params.key, + limit = params.loadSize, + ) + is LoadParams.Prepend -> fetchStatusesForKind( + minId = params.key, + limit = params.loadSize, + ) + } + + if (!response.isSuccessful) { + return LoadResult.Error(Throwable(response.errorBody().toString())) + } - return if (params is LoadParams.Refresh) { - val list = viewModel.statusData.toList() - LoadResult.Page(list, null, viewModel.nextKey) - } else { - LoadResult.Page(emptyList(), null, null) + val links = getPageLinks(response.headers()["link"]) + return LoadResult.Page( + data = response.body()!!, + nextKey = links.next, + prevKey = links.prev + ) + } catch (e: Exception) { + return LoadResult.Error(e) } } + + @Throws(IOException::class, HttpException::class) + suspend fun fetchStatusesForKind( + maxId: String? = null, + minId: String? = null, + limit: Int + ): Response> { + return when (kind) { + is TimelineKind.Home -> api.homeTimeline(maxId = maxId, sinceId = minId, limit = limit) + is TimelineKind.PublicFederated -> api.publicTimeline(null, maxId, minId, limit) + is TimelineKind.PublicLocal -> api.publicTimeline(true, maxId, minId, limit) + is TimelineKind.Tag -> { + val firstHashtag = kind.tags.first() + val additionalHashtags = kind.tags.subList(1, kind.tags.size) + api.hashtagTimeline(firstHashtag, additionalHashtags, null, maxId, minId, limit) + } + is TimelineKind.User -> api.accountStatuses( + kind.id, + maxId, + minId, + limit, + excludeReplies = true, + onlyMedia = null, + pinned = null + ) + is TimelineKind.UserPinned -> api.accountStatuses( + kind.id, + maxId, + minId, + limit, + excludeReplies = null, + onlyMedia = null, + pinned = true + ) + is TimelineKind.UserReplies -> api.accountStatuses( + kind.id, + maxId, + minId, + limit, + excludeReplies = null, + onlyMedia = null, + pinned = null + ) + is TimelineKind.Favourites -> api.favourites(maxId, minId, limit) + is TimelineKind.Bookmarks -> api.bookmarks(maxId, minId, limit) + is TimelineKind.UserList -> api.listTimeline(kind.id, maxId, minId, limit) + } + } + + /** + * Fetch the initial page, using params.key as the ID of the initial item to fetch. + * + * - If there is no key the most recent page 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 there is no page of notifications immediately before then the page immediately after + * is returned + */ + // TODO: This is not directly usable from NotificationsPagingSource, as NotificationsPagingSource + // has to handle filtering results as well. + // + // In addition, the notification and status API calls return different types (statuses return + // NetworkResult, notifications returns Response + private suspend fun getInitialPage(params: LoadParams): Response> = coroutineScope { + // If the key is null this is straightforward, just return the most recent page + val key = params.key ?: return@coroutineScope fetchStatusesForKind(limit = params.loadSize) + + // 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 status itself, and the page of statuses immediately before + // it. This is so that a full page of results can be returned. Returning just the + // single status 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 deferredStatus = async { api.status(statusId = key) } + val deferredStatusPage = async { + fetchStatusesForKind(maxId = key, limit = params.loadSize) + } + + deferredStatus.await().getOrNull()?.let { + val statuses = mutableListOf(it) + + // The status() call returns a NetworkResult, the others return a Response (!) + // so convert between them. + deferredStatusPage.await().body()?.let { + statuses.addAll(it) + } + + // "statuses" now contains at least one status 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 status in the list. + val maxId = statuses.last().id + val headers = Headers.Builder() + .add("link: ; rel=\"next\", ; rel=\"prev\"") + .build() + + return@coroutineScope Response.success(statuses, headers) + + } + + // The user's last read status was missing or is filtered. Use the page of + // statuses chronologically older than their desired status. + deferredStatusPage.await().apply { + if (this.isSuccessful) return@coroutineScope this + } + + // There were no statuses older than the user's desired status. Return the page + // of statuses immediately newer than their desired status. + return@coroutineScope fetchStatusesForKind(minId = key, limit = params.loadSize) + } + + private fun getPageLinks(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" + ) + ) + } + + override fun getRefreshKey(state: PagingState): String? { + return state.anchorPosition?.let { anchorPosition -> + val anchorPage = state.closestPageToPosition(anchorPosition) + anchorPage?.prevKey ?: anchorPage?.nextKey + } + } + + companion object { + private const val TAG = "NetworkTimelinePagingSource" + } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt deleted file mode 100644 index 82cfd41d45..0000000000 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt +++ /dev/null @@ -1,115 +0,0 @@ -/* Copyright 2021 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.timeline.viewmodel - -import androidx.paging.ExperimentalPagingApi -import androidx.paging.LoadType -import androidx.paging.PagingState -import androidx.paging.RemoteMediator -import com.keylesspalace.tusky.components.timeline.util.ifExpected -import com.keylesspalace.tusky.db.AccountManager -import com.keylesspalace.tusky.util.HttpHeaderLink -import com.keylesspalace.tusky.util.toViewData -import com.keylesspalace.tusky.viewdata.StatusViewData -import retrofit2.HttpException - -@OptIn(ExperimentalPagingApi::class) -class NetworkTimelineRemoteMediator( - private val accountManager: AccountManager, - private val viewModel: NetworkTimelineViewModel -) : RemoteMediator() { - - override suspend fun load( - loadType: LoadType, - state: PagingState - ): MediatorResult { - - try { - val statusResponse = when (loadType) { - LoadType.REFRESH -> { - viewModel.fetchStatusesForKind(null, null, limit = state.config.pageSize) - } - LoadType.PREPEND -> { - return MediatorResult.Success(endOfPaginationReached = true) - } - LoadType.APPEND -> { - val maxId = viewModel.nextKey - if (maxId != null) { - viewModel.fetchStatusesForKind(maxId, null, limit = state.config.pageSize) - } else { - return MediatorResult.Success(endOfPaginationReached = true) - } - } - } - - val statuses = statusResponse.body() - if (!statusResponse.isSuccessful || statuses == null) { - return MediatorResult.Error(HttpException(statusResponse)) - } - - val activeAccount = accountManager.activeAccount!! - - val data = statuses.map { status -> - - val oldStatus = viewModel.statusData.find { s -> - s.asStatusOrNull()?.id == status.id - }?.asStatusOrNull() - - val contentShowing = oldStatus?.isShowingContent ?: activeAccount.alwaysShowSensitiveMedia || !status.actionableStatus.sensitive - val expanded = oldStatus?.isExpanded ?: activeAccount.alwaysOpenSpoiler - val contentCollapsed = oldStatus?.isCollapsed ?: true - - status.toViewData( - isShowingContent = contentShowing, - isExpanded = expanded, - isCollapsed = contentCollapsed - ) - } - - if (loadType == LoadType.REFRESH && viewModel.statusData.isNotEmpty()) { - - val insertPlaceholder = if (statuses.isNotEmpty()) { - !viewModel.statusData.removeAll { statusViewData -> - statuses.any { status -> status.id == statusViewData.asStatusOrNull()?.id } - } - } else { - false - } - - viewModel.statusData.addAll(0, data) - - if (insertPlaceholder) { - viewModel.statusData[statuses.size - 1] = StatusViewData.Placeholder(statuses.last().id, false) - } - } else { - val linkHeader = statusResponse.headers()["Link"] - val links = HttpHeaderLink.parse(linkHeader) - val nextId = HttpHeaderLink.findByRelationType(links, "next")?.uri?.getQueryParameter("max_id") - - viewModel.nextKey = nextId - - viewModel.statusData.addAll(data) - } - - viewModel.currentSource?.invalidate() - return MediatorResult.Success(endOfPaginationReached = statuses.isEmpty()) - } catch (e: Exception) { - return ifExpected(e) { - MediatorResult.Error(e) - } - } - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt index f569b57f00..8c03b8f57f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt @@ -16,19 +16,17 @@ package com.keylesspalace.tusky.components.timeline.viewmodel import android.content.SharedPreferences -import android.util.Log import androidx.lifecycle.viewModelScope -import androidx.paging.ExperimentalPagingApi import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.cachedIn import androidx.paging.filter +import androidx.paging.map import com.keylesspalace.tusky.appstore.BookmarkEvent import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.FavoriteEvent import com.keylesspalace.tusky.appstore.PinEvent import com.keylesspalace.tusky.appstore.ReblogEvent -import com.keylesspalace.tusky.components.timeline.util.ifExpected import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.entity.Status @@ -36,15 +34,12 @@ import com.keylesspalace.tusky.network.FilterModel import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.usecase.TimelineCases import com.keylesspalace.tusky.util.getDomain -import com.keylesspalace.tusky.util.isLessThan -import com.keylesspalace.tusky.util.isLessThanOrEqual import com.keylesspalace.tusky.util.toViewData import com.keylesspalace.tusky.viewdata.StatusViewData import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.asExecutor import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map -import kotlinx.coroutines.launch import retrofit2.HttpException import retrofit2.Response import java.io.IOException @@ -64,25 +59,36 @@ class NetworkTimelineViewModel @Inject constructor( var currentSource: NetworkTimelinePagingSource? = null - val statusData: MutableList = mutableListOf() + val statusData: MutableList = mutableListOf() var nextKey: String? = null - @OptIn(ExperimentalPagingApi::class) override val statuses = Pager( config = PagingConfig(pageSize = LOAD_AT_ONCE), pagingSourceFactory = { NetworkTimelinePagingSource( - viewModel = this + api, timelineKind ).also { source -> currentSource = source } }, - remoteMediator = NetworkTimelineRemoteMediator(accountManager, this) ).flow .map { pagingData -> - pagingData.filter(Dispatchers.Default.asExecutor()) { statusViewData -> - !shouldFilterStatus(statusViewData) + pagingData.filter(Dispatchers.Default.asExecutor()) { status -> + !shouldFilterStatus(status) + }.map { + // TODO: The previous code in RemoteMediator checked the states against the + // previous version of the status to make sure they were replicated. This will + // need to be reimplemented (probably as a map of StatusId -> ViewStates. + // For now, just use the user's preferences. + + val contentShowing = alwaysShowSensitiveMedia || !it.actionableStatus.sensitive + + // TODO: Have to use `as` here even though it's reported as redundant. If you don't + // the type is `StatusViewData.Concrete`, and `Flow>` + // is not assignable to a `Flow>`, which is the type of + // `status`. This'll be fixed later in the refactoring. + it.toViewData(contentShowing, alwaysOpenSpoilers, true) as StatusViewData } } .flowOn(Dispatchers.Default) @@ -113,110 +119,104 @@ class NetworkTimelineViewModel @Inject constructor( } override fun removeAllByAccountId(accountId: String) { - statusData.removeAll { vd -> - val status = vd.asStatusOrNull()?.status ?: return@removeAll false + statusData.removeAll { status -> status.account.id == accountId || status.actionableStatus.account.id == accountId } currentSource?.invalidate() } override fun removeAllByInstance(instance: String) { - statusData.removeAll { vd -> - val status = vd.asStatusOrNull()?.status ?: return@removeAll false + statusData.removeAll { status -> getDomain(status.account.url) == instance } currentSource?.invalidate() } override fun removeStatusWithId(id: String) { - statusData.removeAll { vd -> - val status = vd.asStatusOrNull()?.status ?: return@removeAll false + statusData.removeAll { status -> status.id == id || status.reblog?.id == id } currentSource?.invalidate() } override fun loadMore(placeholderId: String) { - viewModelScope.launch { - try { - val placeholderIndex = - statusData.indexOfFirst { it is StatusViewData.Placeholder && it.id == placeholderId } - statusData[placeholderIndex] = StatusViewData.Placeholder(placeholderId, isLoading = true) - - val idAbovePlaceholder = statusData.getOrNull(placeholderIndex - 1)?.id - - val statusResponse = fetchStatusesForKind( - fromId = idAbovePlaceholder, - uptoId = null, - limit = 20 - ) - - val statuses = statusResponse.body() - if (!statusResponse.isSuccessful || statuses == null) { - loadMoreFailed(placeholderId, HttpException(statusResponse)) - return@launch - } - - statusData.removeAt(placeholderIndex) - - val activeAccount = accountManager.activeAccount!! - val data: MutableList = statuses.map { status -> - status.toViewData( - isShowingContent = activeAccount.alwaysShowSensitiveMedia || !status.actionableStatus.sensitive, - isExpanded = activeAccount.alwaysOpenSpoiler, - isCollapsed = true - ) - }.toMutableList() - - if (statuses.isNotEmpty()) { - val firstId = statuses.first().id - val lastId = statuses.last().id - val overlappedFrom = statusData.indexOfFirst { it.asStatusOrNull()?.id?.isLessThanOrEqual(firstId) ?: false } - val overlappedTo = statusData.indexOfFirst { it.asStatusOrNull()?.id?.isLessThan(lastId) ?: false } - - if (overlappedFrom < overlappedTo) { - data.mapIndexed { i, status -> i to statusData.firstOrNull { it.asStatusOrNull()?.id == status.id }?.asStatusOrNull() } - .filter { (_, oldStatus) -> oldStatus != null } - .forEach { (i, oldStatus) -> - data[i] = data[i].asStatusOrNull()!! - .copy( - isShowingContent = oldStatus!!.isShowingContent, - isExpanded = oldStatus.isExpanded, - isCollapsed = oldStatus.isCollapsed, - ) - } - - statusData.removeAll { status -> - when (status) { - is StatusViewData.Placeholder -> lastId.isLessThan(status.id) && status.id.isLessThanOrEqual(firstId) - is StatusViewData.Concrete -> lastId.isLessThan(status.id) && status.id.isLessThanOrEqual(firstId) - } - } - } else { - data[data.size - 1] = StatusViewData.Placeholder(statuses.last().id, isLoading = false) - } - } - - statusData.addAll(placeholderIndex, data) - - currentSource?.invalidate() - } catch (e: Exception) { - ifExpected(e) { - loadMoreFailed(placeholderId, e) - } - } - } +// viewModelScope.launch { +// try { +// val placeholderIndex = +// statusData.indexOfFirst { it is StatusViewData.Placeholder && it.id == placeholderId } +// statusData[placeholderIndex] = StatusViewData.Placeholder(placeholderId, isLoading = true) +// +// val idAbovePlaceholder = statusData.getOrNull(placeholderIndex - 1)?.id +// +// val statusResponse = fetchStatusesForKind( +// fromId = idAbovePlaceholder, +// uptoId = null, +// limit = 20 +// ) +// +// val statuses = statusResponse.body() +// if (!statusResponse.isSuccessful || statuses == null) { +// loadMoreFailed(placeholderId, HttpException(statusResponse)) +// return@launch +// } +// +// statusData.removeAt(placeholderIndex) +// +// val activeAccount = accountManager.activeAccount!! +// val data: MutableList = statuses.map { status -> +// status.toViewData( +// isShowingContent = activeAccount.alwaysShowSensitiveMedia || !status.actionableStatus.sensitive, +// isExpanded = activeAccount.alwaysOpenSpoiler, +// isCollapsed = true +// ) +// }.toMutableList() +// +// if (statuses.isNotEmpty()) { +// val firstId = statuses.first().id +// val lastId = statuses.last().id +// val overlappedFrom = statusData.indexOfFirst { it.id.isLessThanOrEqual(firstId) ?: false } +// val overlappedTo = statusData.indexOfFirst { it.id.isLessThan(lastId) ?: false } +// +// if (overlappedFrom < overlappedTo) { +// data.mapIndexed { i, status -> i to statusData.firstOrNull { it.id == status.id }?.asStatusOrNull() } +// .filter { (_, oldStatus) -> oldStatus != null } +// .forEach { (i, oldStatus) -> +// data[i] = data[i].asStatusOrNull()!! +// .copy( +// isShowingContent = oldStatus!!.isShowingContent, +// isExpanded = oldStatus.isExpanded, +// isCollapsed = oldStatus.isCollapsed, +// ) +// } +// +// statusData.removeAll { status -> +// lastId.isLessThan(status.id) && status.id.isLessThanOrEqual(firstId) +// } +// } else { +// data[data.size - 1] = StatusViewData.Placeholder(statuses.last().id, isLoading = false) +// } +// } +// +// statusData.addAll(placeholderIndex, data) +// +// currentSource?.invalidate() +// } catch (e: Exception) { +// ifExpected(e) { +// loadMoreFailed(placeholderId, e) +// } +// } +// } } - private fun loadMoreFailed(placeholderId: String, e: Exception) { - Log.w("NetworkTimelineVM", "failed loading statuses", e) - - val index = - statusData.indexOfFirst { it is StatusViewData.Placeholder && it.id == placeholderId } - statusData[index] = StatusViewData.Placeholder(placeholderId, isLoading = false) - - currentSource?.invalidate() - } +// private fun loadMoreFailed(placeholderId: String, e: Exception) { +// Log.w("NetworkTimelineVM", "failed loading statuses", e) +// +// val index = +// statusData.indexOfFirst { it is StatusViewData.Placeholder && it.id == placeholderId } +// statusData[index] = StatusViewData.Placeholder(placeholderId, isLoading = false) +// +// currentSource?.invalidate() +// } override fun handleReblogEvent(reblogEvent: ReblogEvent) { updateStatusById(reblogEvent.statusId) { @@ -243,7 +243,7 @@ class NetworkTimelineViewModel @Inject constructor( } override fun fullReload() { - nextKey = statusData.firstOrNull { it is StatusViewData.Concrete }?.asStatusOrNull()?.id + nextKey = statusData.firstOrNull()?.id statusData.clear() currentSource?.invalidate() } @@ -301,41 +301,41 @@ class NetworkTimelineViewModel @Inject constructor( } private fun StatusViewData.Concrete.update() { - val position = statusData.indexOfFirst { viewData -> viewData.asStatusOrNull()?.id == this.id } - statusData[position] = this - currentSource?.invalidate() +// val position = statusData.indexOfFirst { viewData -> viewData.asStatusOrNull()?.id == this.id } +// statusData[position] = this +// currentSource?.invalidate() } private inline fun updateStatusById( id: String, updater: (StatusViewData.Concrete) -> StatusViewData.Concrete ) { - val pos = statusData.indexOfFirst { it.asStatusOrNull()?.id == id } + val pos = statusData.indexOfFirst { it.id == id } if (pos == -1) return - updateViewDataAt(pos, updater) +// updateViewDataAt(pos, updater) } private inline fun updateActionableStatusById( id: String, updater: (Status) -> Status ) { - val pos = statusData.indexOfFirst { it.asStatusOrNull()?.id == id } + val pos = statusData.indexOfFirst { it.id == id } if (pos == -1) return - updateViewDataAt(pos) { vd -> - if (vd.status.reblog != null) { - vd.copy(status = vd.status.copy(reblog = updater(vd.status.reblog))) - } else { - vd.copy(status = updater(vd.status)) - } - } +// updateViewDataAt(pos) { vd -> +// if (vd.status.reblog != null) { +// vd.copy(status = vd.status.copy(reblog = updater(vd.status.reblog))) +// } else { +// vd.copy(status = updater(vd.status)) +// } +// } } - private inline fun updateViewDataAt( - position: Int, - updater: (StatusViewData.Concrete) -> StatusViewData.Concrete - ) { - val status = statusData.getOrNull(position)?.asStatusOrNull() ?: return - statusData[position] = updater(status) - currentSource?.invalidate() - } +// private inline fun updateViewDataAt( +// position: Int, +// updater: (StatusViewData.Concrete) -> StatusViewData.Concrete +// ) { +// val status = statusData.getOrNull(position)?.asStatusOrNull() ?: return +// statusData[position] = updater(status) +// currentSource?.invalidate() +// } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt index 968b274384..557c77aa06 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt @@ -39,6 +39,7 @@ import com.keylesspalace.tusky.components.timeline.util.ifExpected import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.entity.Poll +import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.network.FilterModel import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.settings.PrefKeys @@ -59,6 +60,8 @@ abstract class TimelineViewModel( private val filterModel: FilterModel ) : ViewModel() { + // While refactoring the ViewData type (VDT) inside PagingData differs from subclass to + // subclass, so TimelineViewModel is generic over that. This is temporary. abstract val statuses: Flow> var kind: Kind = Kind.HOME @@ -68,8 +71,11 @@ abstract class TimelineViewModel( var tags: List = emptyList() private set + /** The [TimelineKind] equivalent of [kind] */ + lateinit var timelineKind: TimelineKind + protected var alwaysShowSensitiveMedia = false - private var alwaysOpenSpoilers = false + protected var alwaysOpenSpoilers = false private var filterRemoveReplies = false private var filterRemoveReblogs = false protected var readingOrder: ReadingOrder = ReadingOrder.OLDEST_FIRST @@ -83,6 +89,19 @@ abstract class TimelineViewModel( this.id = id this.tags = tags + timelineKind = when (kind) { + Kind.HOME -> TimelineKind.Home + Kind.PUBLIC_LOCAL -> TimelineKind.PublicLocal + Kind.PUBLIC_FEDERATED -> TimelineKind.PublicFederated + Kind.TAG -> TimelineKind.Tag(tags) + Kind.USER -> TimelineKind.User(id!!) + Kind.USER_PINNED -> TimelineKind.UserPinned(id!!) + Kind.USER_WITH_REPLIES -> TimelineKind.UserReplies(id!!) + Kind.FAVOURITES -> TimelineKind.Favourites + Kind.LIST -> TimelineKind.UserList(id!!) + Kind.BOOKMARKS -> TimelineKind.Bookmarks + } + if (kind == Kind.HOME) { // Note the variable is "true if filter" but the underlying preference/settings text is "true if show" filterRemoveReplies = @@ -181,8 +200,8 @@ abstract class TimelineViewModel( /** Triggered when currently displayed data must be reloaded. */ protected abstract suspend fun invalidate() - protected fun shouldFilterStatus(statusViewData: StatusViewData): Boolean { - val status = statusViewData.asStatusOrNull()?.status ?: return false + protected fun shouldFilterStatus(status: Status?): Boolean { + status ?: return false return status.inReplyToId != null && filterRemoveReplies || status.reblog != null && filterRemoveReblogs || filterModel.shouldFilterStatus(status.actionableStatus) From 2b0740618a065d33ee4eab8ce61f9aae081a78e1 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Sat, 11 Mar 2023 15:40:27 +0100 Subject: [PATCH 002/156] Introduce NetworkTimelineRepository, to mediate access to the timeline Use this in `NetworkTimelineViewModel`. --- .../timeline/NetworkTimelineRepository.kt | 65 ++++++++++++++++++ .../viewmodel/NetworkTimelineViewModel.kt | 68 +++++++++---------- .../timeline/viewmodel/TimelineViewModel.kt | 5 +- 3 files changed, 101 insertions(+), 37 deletions(-) create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRepository.kt diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRepository.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRepository.kt new file mode 100644 index 0000000000..13d60021ee --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRepository.kt @@ -0,0 +1,65 @@ +/* + * 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.timeline + +import android.util.Log +import androidx.paging.InvalidatingPagingSourceFactory +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.PagingData +import com.keylesspalace.tusky.components.timeline.viewmodel.NetworkTimelinePagingSource +import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineKind +import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.network.MastodonApi +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject + +class NetworkTimelineRepository @Inject constructor( + private val mastodonApi: MastodonApi +) { + private var factory: InvalidatingPagingSourceFactory? = null + + /** @return flow of Mastodon [Status], loaded in [pageSize] increments. */ + fun getStatusStream( + kind: TimelineKind, + pageSize: Int = PAGE_SIZE, + initialKey: String? = null + ): Flow> { + Log.d(TAG, "getStatusStream(): key: $initialKey") + + factory = InvalidatingPagingSourceFactory { + NetworkTimelinePagingSource(mastodonApi, kind) + } + + return Pager( + config = PagingConfig(pageSize = pageSize), + initialKey = initialKey, + pagingSourceFactory = factory!! + ).flow + } + + /** Invalidate the active paging source, see [PagingSource.invalidate] */ + fun invalidate() { + factory?.invalidate() + } + + companion object { + private const val TAG = "NetworkTimelineRepository" + private const val PAGE_SIZE = 30 + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt index db74b1a84b..2c433f5535 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt @@ -16,10 +16,7 @@ package com.keylesspalace.tusky.components.timeline.viewmodel import android.content.SharedPreferences -import androidx.lifecycle.viewModelScope -import androidx.paging.Pager -import androidx.paging.PagingConfig -import androidx.paging.cachedIn +import androidx.paging.PagingData import androidx.paging.filter import androidx.paging.map import com.keylesspalace.tusky.appstore.BookmarkEvent @@ -27,6 +24,7 @@ import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.FavoriteEvent import com.keylesspalace.tusky.appstore.PinEvent import com.keylesspalace.tusky.appstore.ReblogEvent +import com.keylesspalace.tusky.components.timeline.NetworkTimelineRepository import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.entity.Poll @@ -37,9 +35,7 @@ import com.keylesspalace.tusky.usecase.TimelineCases import com.keylesspalace.tusky.util.getDomain import com.keylesspalace.tusky.util.toViewData import com.keylesspalace.tusky.viewdata.StatusViewData -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.asExecutor -import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import retrofit2.HttpException import retrofit2.Response @@ -50,6 +46,7 @@ import javax.inject.Inject * TimelineViewModel that caches all statuses in an in-memory list */ class NetworkTimelineViewModel @Inject constructor( + private val repository: NetworkTimelineRepository, timelineCases: TimelineCases, private val api: MastodonApi, eventHub: EventHub, @@ -64,36 +61,39 @@ class NetworkTimelineViewModel @Inject constructor( var nextKey: String? = null - override val statuses = Pager( - config = PagingConfig(pageSize = LOAD_AT_ONCE), - pagingSourceFactory = { - NetworkTimelinePagingSource( - api, timelineKind - ).also { source -> - currentSource = source - } - }, - ).flow - .map { pagingData -> - pagingData.filter(Dispatchers.Default.asExecutor()) { status -> - shouldFilterStatus(status) != Filter.Action.HIDE - }.map { - // TODO: The previous code in RemoteMediator checked the states against the - // previous version of the status to make sure they were replicated. This will - // need to be reimplemented (probably as a map of StatusId -> ViewStates. - // For now, just use the user's preferences. + // TODO: This is janky because timelineKind isn't valid until init() is run, and is needed + // to know what timeline to get. Hence the lateinit in here and the need to override init() + // afterwards. + + override lateinit var statuses: Flow> - val contentShowing = alwaysShowSensitiveMedia || !it.actionableStatus.sensitive + override fun init(kind: Kind, id: String?, tags: List) { + super.init(kind, id, tags) + statuses = getStatuses(timelineKind) + } - // TODO: Have to use `as` here even though it's reported as redundant. If you don't - // the type is `StatusViewData.Concrete`, and `Flow>` - // is not assignable to a `Flow>`, which is the type of - // `status`. This'll be fixed later in the refactoring. - it.toViewData(contentShowing, alwaysOpenSpoilers, true) as StatusViewData + /** @return FLow of statuses that make up the timeline of [kind] */ + private fun getStatuses( + kind: TimelineKind, + initialKey: String? = null + ): Flow> { + return repository.getStatusStream(kind = kind, initialKey = initialKey) + .map { pagingData -> + pagingData.filter { + shouldFilterStatus(it) != Filter.Action.HIDE + }.map { + // TODO: The previous code in RemoteMediator checked the states against the + // previous version of the status to make sure they were replicated. This will + // need to be reimplemented (probably as a map of StatusId -> ViewStates. + // For now, just use the user's preferences. + it.toViewData( + isShowingContent = alwaysShowSensitiveMedia || !it.actionableStatus.sensitive, + isExpanded = alwaysOpenSpoilers, + isCollapsed = true + ) + } } - } - .flowOn(Dispatchers.Default) - .cachedIn(viewModelScope) + } override fun updatePoll(newPoll: Poll, status: StatusViewData.Concrete) { status.copy( diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt index e52c895338..836d56763c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt @@ -63,8 +63,6 @@ abstract class TimelineViewModel( private val filterModel: FilterModel ) : ViewModel() { - // While refactoring the ViewData type (VDT) inside PagingData differs from subclass to - // subclass, so TimelineViewModel is generic over that. This is temporary. abstract val statuses: Flow> var kind: Kind = Kind.HOME @@ -83,7 +81,8 @@ abstract class TimelineViewModel( private var filterRemoveReblogs = false protected var readingOrder: ReadingOrder = ReadingOrder.OLDEST_FIRST - fun init( + protected open fun init( + open fun init( kind: Kind, id: String?, tags: List From 464cd3e605cad3502a049ca4f186ac3f0232de3b Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Sat, 11 Mar 2023 21:14:49 +0100 Subject: [PATCH 003/156] Move StatusDisplayOptions responsibility Move it from TimelineFragment to TimelinewViewModel. Update StatusDisplayOptions.kt to handle SHOW_CARDS_IN_TIMELINES pref --- .../components/timeline/TimelineFragment.kt | 22 +------------------ .../timeline/viewmodel/TimelineViewModel.kt | 20 ++++++++++++++++- .../tusky/util/StatusDisplayOptions.kt | 9 +++++++- 3 files changed, 28 insertions(+), 23 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt index b0ae1cf778..4c6cdee383 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt @@ -62,9 +62,7 @@ import com.keylesspalace.tusky.interfaces.RefreshableFragment import com.keylesspalace.tusky.interfaces.ReselectableFragment import com.keylesspalace.tusky.interfaces.StatusActionListener import com.keylesspalace.tusky.settings.PrefKeys -import com.keylesspalace.tusky.util.CardViewMode import com.keylesspalace.tusky.util.ListStatusAccessibilityDelegate -import com.keylesspalace.tusky.util.StatusDisplayOptions import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.unsafeLazy @@ -178,26 +176,8 @@ class TimelineFragment : val preferences = PreferenceManager.getDefaultSharedPreferences(requireContext()) readingOrder = ReadingOrder.from(preferences.getString(PrefKeys.READING_ORDER, null)) - val statusDisplayOptions = StatusDisplayOptions( - animateAvatars = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false), - mediaPreviewEnabled = accountManager.activeAccount!!.mediaPreviewEnabled, - useAbsoluteTime = preferences.getBoolean(PrefKeys.ABSOLUTE_TIME_VIEW, false), - showBotOverlay = preferences.getBoolean(PrefKeys.SHOW_BOT_OVERLAY, true), - useBlurhash = preferences.getBoolean(PrefKeys.USE_BLURHASH, true), - cardViewMode = if (preferences.getBoolean( - PrefKeys.SHOW_CARDS_IN_TIMELINES, - false - ) - ) CardViewMode.INDENTED else CardViewMode.NONE, - confirmReblogs = preferences.getBoolean(PrefKeys.CONFIRM_REBLOGS, true), - confirmFavourites = preferences.getBoolean(PrefKeys.CONFIRM_FAVOURITES, false), - hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false), - animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false), - showSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia, - openSpoiler = accountManager.activeAccount!!.alwaysOpenSpoiler - ) adapter = TimelinePagingAdapter( - statusDisplayOptions, + viewModel.statusDisplayOptions.value, this ) } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt index 836d56763c..d881f47d8c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt @@ -46,9 +46,12 @@ import com.keylesspalace.tusky.network.FilterModel import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.usecase.TimelineCases +import com.keylesspalace.tusky.util.StatusDisplayOptions import com.keylesspalace.tusky.viewdata.StatusViewData import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.rx3.asFlow import kotlinx.coroutines.rx3.await @@ -65,6 +68,9 @@ abstract class TimelineViewModel( abstract val statuses: Flow> + /** Flow of changes to statusDisplayOptions, for use by the UI */ + val statusDisplayOptions: StateFlow + var kind: Kind = Kind.HOME private set var id: String? = null @@ -81,7 +87,19 @@ abstract class TimelineViewModel( private var filterRemoveReblogs = false protected var readingOrder: ReadingOrder = ReadingOrder.OLDEST_FIRST - protected open fun init( + init { + // 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( + sharedPreferences, + accountManager.activeAccount!! + ) + ) + } + open fun init( kind: Kind, id: String?, diff --git a/app/src/main/java/com/keylesspalace/tusky/util/StatusDisplayOptions.kt b/app/src/main/java/com/keylesspalace/tusky/util/StatusDisplayOptions.kt index 7767accd04..283524c6a1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/StatusDisplayOptions.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/StatusDisplayOptions.kt @@ -71,6 +71,9 @@ data class StatusDisplayOptions( PrefKeys.USE_BLURHASH -> copy( useBlurhash = preferences.getBoolean(key, true) ) + PrefKeys.SHOW_CARDS_IN_TIMELINES -> copy( + cardViewMode = if (preferences.getBoolean(key, false)) CardViewMode.INDENTED else CardViewMode.NONE + ) PrefKeys.CONFIRM_FAVOURITES -> copy( confirmFavourites = preferences.getBoolean(key, false) ) @@ -115,7 +118,11 @@ data class StatusDisplayOptions( useAbsoluteTime = preferences.getBoolean(PrefKeys.ABSOLUTE_TIME_VIEW, false), showBotOverlay = preferences.getBoolean(PrefKeys.SHOW_BOT_OVERLAY, true), useBlurhash = preferences.getBoolean(PrefKeys.USE_BLURHASH, true), - cardViewMode = CardViewMode.NONE, + cardViewMode = if (preferences.getBoolean(PrefKeys.SHOW_CARDS_IN_TIMELINES, false)) { + CardViewMode.INDENTED + } else { + CardViewMode.NONE + }, confirmReblogs = preferences.getBoolean(PrefKeys.CONFIRM_REBLOGS, true), confirmFavourites = preferences.getBoolean(PrefKeys.CONFIRM_FAVOURITES, false), hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false), From d184510815d8f5d44023a7c524f79d62bc912970 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Sun, 12 Mar 2023 14:19:42 +0100 Subject: [PATCH 004/156] Push preference handling down to the view model --- .../components/timeline/TimelineFragment.kt | 133 ++++++++++-------- .../timeline/viewmodel/TimelineViewModel.kt | 81 +++++++++++ 2 files changed, 153 insertions(+), 61 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt index 4c6cdee383..d1d2db9f50 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt @@ -29,8 +29,8 @@ import androidx.core.view.MenuProvider import androidx.lifecycle.Lifecycle import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import androidx.paging.LoadState -import androidx.preference.PreferenceManager import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView @@ -43,7 +43,6 @@ import com.keylesspalace.tusky.BaseActivity import com.keylesspalace.tusky.R import com.keylesspalace.tusky.adapter.StatusBaseViewHolder import com.keylesspalace.tusky.appstore.EventHub -import com.keylesspalace.tusky.appstore.PreferenceChangedEvent import com.keylesspalace.tusky.appstore.StatusComposedEvent import com.keylesspalace.tusky.appstore.StatusEditedEvent import com.keylesspalace.tusky.components.accountlist.AccountListActivity @@ -61,7 +60,6 @@ import com.keylesspalace.tusky.interfaces.ActionButtonActivity import com.keylesspalace.tusky.interfaces.RefreshableFragment import com.keylesspalace.tusky.interfaces.ReselectableFragment import com.keylesspalace.tusky.interfaces.StatusActionListener -import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.util.ListStatusAccessibilityDelegate import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.show @@ -74,11 +72,13 @@ import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial import com.mikepenz.iconics.utils.colorInt import com.mikepenz.iconics.utils.sizeDp import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers -import io.reactivex.rxjava3.core.Observable +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import java.io.IOException -import java.util.concurrent.TimeUnit import javax.inject.Inject class TimelineFragment : @@ -110,8 +110,9 @@ class TimelineFragment : private lateinit var adapter: TimelinePagingAdapter + private lateinit var layoutManager: LinearLayoutManager + private var isSwipeToRefreshEnabled = true - private var hideFab = false /** * Adapter position of the placeholder that was most recently clicked to "Load more". If null @@ -142,9 +143,6 @@ class TimelineFragment : // The user can then scroll up to read the new statuses. private var statusIdBelowLoadMore: String? = null - /** The user's preferred reading order */ - private lateinit var readingOrder: ReadingOrder - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -173,9 +171,6 @@ class TimelineFragment : isSwipeToRefreshEnabled = arguments.getBoolean(ARG_ENABLE_SWIPE_TO_REFRESH, true) - val preferences = PreferenceManager.getDefaultSharedPreferences(requireContext()) - readingOrder = ReadingOrder.from(preferences.getString(PrefKeys.READING_ORDER, null)) - adapter = TimelinePagingAdapter( viewModel.statusDisplayOptions.value, this @@ -193,6 +188,8 @@ class TimelineFragment : override fun onViewCreated(view: View, savedInstanceState: Bundle?) { requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED) + layoutManager = LinearLayoutManager(context) + setupSwipeRefreshLayout() setupRecyclerView() @@ -239,7 +236,7 @@ class TimelineFragment : } } } - if (readingOrder == ReadingOrder.OLDEST_FIRST) { + if (viewModel.uiState.value.readingOrder == ReadingOrder.OLDEST_FIRST) { updateReadingPositionForOldestFirst() } } @@ -252,13 +249,11 @@ class TimelineFragment : } if (actionButtonPresent()) { - val preferences = PreferenceManager.getDefaultSharedPreferences(requireContext()) - hideFab = preferences.getBoolean("fabHide", false) binding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { override fun onScrolled(view: RecyclerView, dx: Int, dy: Int) { val composeButton = (activity as ActionButtonActivity).actionButton if (composeButton != null) { - if (hideFab) { + if (!viewModel.uiState.value.showFabWhileScrolling) { if (dy > 0 && composeButton.isShown) { composeButton.hide() // hides the button if we're scrolling down } else if (dy < 0 && !composeButton.isShown) { @@ -272,14 +267,71 @@ class TimelineFragment : }) } + /** + * Collect this flow to notify the adapter that the timestamps of the visible items have + * changed + */ + // TODO: Copied from NotificationsFragment + val updateTimestampFlow = flow { + while (true) { delay(60000); emit(Unit) } + }.onEach { + layoutManager.findFirstVisibleItemPosition().let { first -> + first == RecyclerView.NO_POSITION && return@let + val count = layoutManager.findLastVisibleItemPosition() - first + adapter.notifyItemRangeChanged( + first, + count, + listOf(StatusBaseViewHolder.Key.KEY_CREATED) + ) + } + } + + viewLifecycleOwner.lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + launch { + viewModel.uiState.collectLatest { + // showMediaPreview changed? + val previousMediaPreview = adapter.mediaPreviewEnabled + if (previousMediaPreview != it.showMediaPreview) { + adapter.mediaPreviewEnabled = it.showMediaPreview + adapter.notifyItemRangeChanged(0, adapter.itemCount) + } + } + } + + // Update status display from statusDisplayOptions. If the new options request + // relative time display collect the flow to periodically re-bind the UI. + // TODO: Copied from NotificationsFragment + launch { + viewModel.statusDisplayOptions + .collectLatest { + // TODO: TimelinePagingAdapter doesn't handle statusDisplayOptions + // the same way NotificationsPagingAdapter does. Investigate bringing + // the two classes in to alignment. +// adapter.statusDisplayOptions = it +// layoutManager.findFirstVisibleItemPosition().let { first -> +// first == RecyclerView.NO_POSITION && return@let +// val count = layoutManager.findLastVisibleItemPosition() - first +// adapter.notifyItemRangeChanged( +// first, +// count, +// null +// ) +// } + + if (!it.useAbsoluteTime) { + updateTimestampFlow.collect() + } + } + } + } + } + eventHub.events .observeOn(AndroidSchedulers.mainThread()) .autoDispose(this, Lifecycle.Event.ON_DESTROY) .subscribe { event -> when (event) { - is PreferenceChangedEvent -> { - onPreferenceChanged(event.preferenceKey) - } is StatusComposedEvent -> { val status = event.status handleStatusComposeEvent(status) @@ -364,7 +416,7 @@ class TimelineFragment : } ) binding.recyclerView.setHasFixedSize(true) - binding.recyclerView.layoutManager = LinearLayoutManager(context) + binding.recyclerView.layoutManager = layoutManager val divider = DividerItemDecoration(context, RecyclerView.VERTICAL) binding.recyclerView.addItemDecoration(divider) @@ -491,28 +543,6 @@ class TimelineFragment : super.viewAccount(id) } - private fun onPreferenceChanged(key: String) { - val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(requireContext()) - when (key) { - PrefKeys.FAB_HIDE -> { - hideFab = sharedPreferences.getBoolean(PrefKeys.FAB_HIDE, false) - } - PrefKeys.MEDIA_PREVIEW_ENABLED -> { - val enabled = accountManager.activeAccount!!.mediaPreviewEnabled - val oldMediaPreviewEnabled = adapter.mediaPreviewEnabled - if (enabled != oldMediaPreviewEnabled) { - adapter.mediaPreviewEnabled = enabled - adapter.notifyItemRangeChanged(0, adapter.itemCount) - } - } - PrefKeys.READING_ORDER -> { - readingOrder = ReadingOrder.from( - sharedPreferences.getString(PrefKeys.READING_ORDER, null) - ) - } - } - } - private fun handleStatusComposeEvent(status: Status) { when (kind) { TimelineViewModel.Kind.HOME, @@ -555,25 +585,6 @@ class TimelineFragment : if (talkBackWasEnabled && !wasEnabled) { adapter.notifyItemRangeChanged(0, adapter.itemCount) } - startUpdateTimestamp() - } - - /** - * Start to update adapter every minute to refresh timestamp - * If setting absoluteTimeView is false - * Auto dispose observable on pause - */ - private fun startUpdateTimestamp() { - val preferences = PreferenceManager.getDefaultSharedPreferences(requireContext()) - val useAbsoluteTime = preferences.getBoolean(PrefKeys.ABSOLUTE_TIME_VIEW, false) - if (!useAbsoluteTime) { - Observable.interval(0, 1, TimeUnit.MINUTES) - .observeOn(AndroidSchedulers.mainThread()) - .autoDispose(this, Lifecycle.Event.ON_PAUSE) - .subscribe { - adapter.notifyItemRangeChanged(0, adapter.itemCount, listOf(StatusBaseViewHolder.Key.KEY_CREATED)) - } - } } override fun onReselect() { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt index d881f47d8c..fcfb86bdce 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt @@ -17,11 +17,13 @@ package com.keylesspalace.tusky.components.timeline.viewmodel 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 at.connyduck.calladapter.networkresult.fold import at.connyduck.calladapter.networkresult.getOrElse +import com.keylesspalace.tusky.R import com.keylesspalace.tusky.appstore.BlockEvent import com.keylesspalace.tusky.appstore.BookmarkEvent import com.keylesspalace.tusky.appstore.DomainMuteEvent @@ -50,13 +52,47 @@ import com.keylesspalace.tusky.util.StatusDisplayOptions import com.keylesspalace.tusky.viewdata.StatusViewData import kotlinx.coroutines.Job 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.filter +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.rx3.asFlow import kotlinx.coroutines.rx3.await import retrofit2.HttpException +data class UiState( + /** The user's preferred reading order */ + val readingOrder: ReadingOrder = ReadingOrder.NEWEST_FIRST, + + /** True if the FAB should be shown while scrolling */ + val showFabWhileScrolling: Boolean = true, + + /** True if media previews should be shown */ + val showMediaPreview: Boolean = true, +) + +/** Preferences the UI reacts to */ +data class UiPrefs( + val readingOrder: ReadingOrder, + val showFabWhileScrolling: Boolean, + val showMediaPreview: Boolean +) { + companion object { + /** Relevant preference keys. Changes to any of these trigger a display update */ + val prefKeys = setOf( + PrefKeys.FAB_HIDE, + PrefKeys.READING_ORDER, + PrefKeys.MEDIA_PREVIEW_ENABLED + ) + } +} + abstract class TimelineViewModel( private val timelineCases: TimelineCases, private val api: MastodonApi, @@ -65,6 +101,7 @@ abstract class TimelineViewModel( private val sharedPreferences: SharedPreferences, private val filterModel: FilterModel ) : ViewModel() { + val uiState: StateFlow abstract val statuses: Flow> @@ -98,8 +135,52 @@ abstract class TimelineViewModel( accountManager.activeAccount!! ) ) + + viewModelScope.launch { + eventHub.events.asFlow() + .filterIsInstance() + .filter { StatusDisplayOptions.prefKeys.contains(it.preferenceKey) } + .map { + statusDisplayOptions.value.make( + sharedPreferences, + it.preferenceKey, + accountManager.activeAccount!! + ) + } + .collect { + statusDisplayOptions.emit(it) + } + } + + uiState = getUiPrefs().map { prefs -> + UiState( + readingOrder = prefs.readingOrder, + showFabWhileScrolling = prefs.showFabWhileScrolling, + showMediaPreview = prefs.showMediaPreview + ) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000), + initialValue = UiState() + ) } + /** + * @return Flow of relevant preferences that change the UI + */ + // TODO: Preferences should be in a repository + private fun getUiPrefs() = eventHub.events.asFlow() + .filterIsInstance() + .filter { UiPrefs.prefKeys.contains(it.preferenceKey) } + .map { toPrefs() } + .onStart { emit(toPrefs()) } + + private fun toPrefs() = UiPrefs( + readingOrder = ReadingOrder.from(sharedPreferences.getString(PrefKeys.READING_ORDER, null)), + showFabWhileScrolling = !sharedPreferences.getBoolean(PrefKeys.FAB_HIDE, false), + showMediaPreview = accountManager.activeAccount!!.mediaPreviewEnabled + ) + open fun init( kind: Kind, id: String?, From a11bc64bae1517603171ac777ad807ccae019851 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Sun, 12 Mar 2023 16:04:21 +0100 Subject: [PATCH 005/156] Migrate reblog, favourite, bookmark, and vote --- .../notifications/NotificationsFragment.kt | 4 +- .../components/timeline/TimelineFragment.kt | 102 ++++++- .../viewmodel/NetworkTimelinePagingSource.kt | 1 + .../timeline/viewmodel/TimelineViewModel.kt | 257 ++++++++++++++---- .../tusky/viewdata/StatusViewData.kt | 2 +- 5 files changed, 306 insertions(+), 60 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt index be7ef91010..d99c61d990 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt @@ -279,10 +279,10 @@ class NotificationsFragment : // that the action succeeded. Since it hasn't, re-bind the view // to show the correct data. error.action?.let { action -> - action is StatusAction || return@let + if (action !is StatusAction) return@let val position = adapter.snapshot().indexOfFirst { - it?.statusViewData?.status?.id == (action as StatusAction).statusViewData.id + it?.statusViewData?.status?.id == action.statusViewData.id } if (position != RecyclerView.NO_POSITION) { adapter.notifyItemChanged(position) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt index d1d2db9f50..da2bef06e1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt @@ -39,6 +39,7 @@ import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener import at.connyduck.sparkbutton.helpers.Utils import autodispose2.androidx.lifecycle.autoDispose import com.google.android.material.color.MaterialColors +import com.google.android.material.snackbar.Snackbar import com.keylesspalace.tusky.BaseActivity import com.keylesspalace.tusky.R import com.keylesspalace.tusky.adapter.StatusBaseViewHolder @@ -47,9 +48,11 @@ import com.keylesspalace.tusky.appstore.StatusComposedEvent import com.keylesspalace.tusky.appstore.StatusEditedEvent import com.keylesspalace.tusky.components.accountlist.AccountListActivity import com.keylesspalace.tusky.components.accountlist.AccountListActivity.Companion.newIntent +import com.keylesspalace.tusky.components.notifications.StatusActionSuccess import com.keylesspalace.tusky.components.preference.PreferencesFragment.ReadingOrder import com.keylesspalace.tusky.components.timeline.viewmodel.CachedTimelineViewModel import com.keylesspalace.tusky.components.timeline.viewmodel.NetworkTimelineViewModel +import com.keylesspalace.tusky.components.timeline.viewmodel.StatusAction import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel import com.keylesspalace.tusky.databinding.FragmentTimelineBinding import com.keylesspalace.tusky.di.Injectable @@ -75,6 +78,7 @@ import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch @@ -289,6 +293,87 @@ class TimelineFragment : viewLifecycleOwner.lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { launch { + // Show errors from the view model as snack bars. + // + // Errors are shown: + // - Indefinitely, so the user has a chance to read and understand + // the message + // - With a max of 5 text lines, to allow space for longer errors. + // E.g., on a typical device, an error message like "Bookmarking + // post failed: Unable to resolve host 'mastodon.social': No + // address associated with hostname" is 3 lines. + // - With a "Retry" option if the error included a UiAction to retry. + // TODO: Very similar to same code in NotificationsFragment + launch { + viewModel.uiError.collect { error -> + Log.d(TAG, error.toString()) + val message = getString( + error.message, + error.exception.localizedMessage + ?: getString(R.string.ui_error_unknown) + ) + val snackbar = Snackbar.make( + // Without this the FAB will not move out of the way + (activity as ActionButtonActivity).actionButton ?: binding.root, + message, + Snackbar.LENGTH_INDEFINITE + ).setTextMaxLines(5) + error.action?.let { action -> + snackbar.setAction(R.string.action_retry) { + viewModel.accept(action) + } + } + snackbar.show() + + // The status view has pre-emptively updated its state to show + // that the action succeeded. Since it hasn't, re-bind the view + // to show the correct data. + error.action?.let { action -> + if (action !is StatusAction) return@let + + val position = adapter.snapshot().indexOfFirst { + it?.id == action.statusViewData.id + } + if (position != RecyclerView.NO_POSITION) { + adapter.notifyItemChanged(position) + } + } + } + } + + // Update adapter data when status actions are successful, and re-bind to update + // the UI. + launch { + viewModel.uiSuccess + .filterIsInstance() + .collect { + val indexedViewData = adapter.snapshot() + .withIndex() + .firstOrNull { indexed -> + indexed.value?.id == it.action.statusViewData.id + } ?: return@collect + + val statusViewData= + indexedViewData.value as? StatusViewData.Concrete ?: return@collect + + val status = when (it) { + is StatusActionSuccess.Bookmark -> + statusViewData.status.copy(bookmarked = it.action.state) + is StatusActionSuccess.Favourite -> + statusViewData.status.copy(favourited = it.action.state) + is StatusActionSuccess.Reblog -> + statusViewData.status.copy(reblogged = it.action.state) + is StatusActionSuccess.VoteInPoll -> + statusViewData.status.copy( + poll = it.action.poll.votedCopy(it.action.choices) + ) + } + (indexedViewData.value as StatusViewData.Concrete).status = status + + adapter.notifyItemChanged(indexedViewData.index) + } + } + viewModel.uiState.collectLatest { // showMediaPreview changed? val previousMediaPreview = adapter.mediaPreviewEnabled @@ -437,23 +522,24 @@ class TimelineFragment : } override fun onReblog(reblog: Boolean, position: Int) { - val status = adapter.peek(position)?.asStatusOrNull() ?: return - viewModel.reblog(reblog, status) + val statusViewData = adapter.peek(position) as? StatusViewData.Concrete ?: return + viewModel.accept(StatusAction.Reblog(reblog, statusViewData)) } override fun onFavourite(favourite: Boolean, position: Int) { - val status = adapter.peek(position)?.asStatusOrNull() ?: return - viewModel.favorite(favourite, status) + val statusViewData = adapter.peek(position) as? StatusViewData.Concrete ?: return + viewModel.accept(StatusAction.Favourite(favourite, statusViewData)) } override fun onBookmark(bookmark: Boolean, position: Int) { - val status = adapter.peek(position)?.asStatusOrNull() ?: return - viewModel.bookmark(bookmark, status) + val statusViewData = adapter.peek(position) as? StatusViewData.Concrete ?: return + viewModel.accept(StatusAction.Bookmark(bookmark, statusViewData)) } override fun onVoteInPoll(position: Int, choices: List) { - val status = adapter.peek(position)?.asStatusOrNull() ?: return - viewModel.voteInPoll(choices, status) + val statusViewData = adapter.peek(position) as? StatusViewData.Concrete ?: return + val poll = statusViewData.status.poll ?: return + viewModel.accept(StatusAction.VoteInPoll(poll, choices, statusViewData)) } override fun clearWarningAction(position: Int) { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelinePagingSource.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelinePagingSource.kt index 6529089ecc..a33c0da3ac 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelinePagingSource.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelinePagingSource.kt @@ -94,6 +94,7 @@ class NetworkTimelinePagingSource @Inject constructor( minId: String? = null, limit: Int ): Response> { + // TODO: These probably shouldn't be `sinceId` but `minId` in the API calls return when (kind) { is TimelineKind.Home -> api.homeTimeline(maxId = maxId, sinceId = minId, limit = limit) is TimelineKind.PublicFederated -> api.publicTimeline(null, maxId, minId, limit) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt index fcfb86bdce..95e1cf1a04 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt @@ -50,12 +50,13 @@ import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.usecase.TimelineCases import com.keylesspalace.tusky.util.StatusDisplayOptions import com.keylesspalace.tusky.viewdata.StatusViewData -import kotlinx.coroutines.Job +import kotlinx.coroutines.FlowPreview 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.debounce import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.map @@ -93,6 +94,153 @@ data class UiPrefs( } } +// TODO: Ui* classes are copied from NotificationsViewModel. Not yet sure whether these actions +// are "global" across all timelines (including notifications) or whether notifications are +// sufficiently different to warrant having a duplicate set. Keeping them duplicated for the +// moment. + +/** 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 */ +// 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() { + /** + * User is leaving the fragment, save the ID of the visible status. + * + * 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() +} + +/** 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 */ + object Block : UiSuccess() + + /** A user was muted */ + object Mute : UiSuccess() + + /** A conversation was muted */ + object MuteConversation : UiSuccess() +} + +/** 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) + } + } +} + +// TODO: Similar to UiError in NotificationsViewModel, but without ClearNotifications, +// AcceptFollowRequest, and RejectFollowRequest. +// +// Possibly indicates the need for UiStatusError and UiNotificationError subclasses so these +// can be shared. +// +// Need to think about how that would work with sealed classes. + +/** Errors from fallible view model actions that the UI will need to show */ +sealed class UiError( + /** The exception associated with the error */ + open val exception: Exception, + + /** 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 Bookmark( + override val exception: Exception, + override val action: StatusAction.Bookmark + ) : UiError(exception, R.string.ui_error_bookmark, action) + + data class Favourite( + override val exception: Exception, + override val action: StatusAction.Favourite + ) : UiError(exception, R.string.ui_error_favourite, action) + + data class Reblog( + override val exception: Exception, + override val action: StatusAction.Reblog + ) : UiError(exception, R.string.ui_error_reblog, action) + + data class VoteInPoll( + override val exception: Exception, + override val action: StatusAction.VoteInPoll + ) : UiError(exception, R.string.ui_error_vote, action) + + companion object { + fun make(exception: Exception, action: FallibleUiAction) = when (action) { + is StatusAction.Bookmark -> Bookmark(exception, action) + is StatusAction.Favourite -> Favourite(exception, action) + is StatusAction.Reblog -> Reblog(exception, action) + is StatusAction.VoteInPoll -> VoteInPoll(exception, action) + } + } +} + +@OptIn(FlowPreview::class) abstract class TimelineViewModel( private val timelineCases: TimelineCases, private val api: MastodonApi, @@ -108,6 +256,24 @@ abstract class TimelineViewModel( /** Flow of changes to statusDisplayOptions, for use by the UI */ val statusDisplayOptions: StateFlow + /** Flow of user actions received from the UI */ + private val uiAction = MutableSharedFlow() + + /** Flow of successful action results */ + // Note: These are a SharedFlow instead of a StateFlow because success or error 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 or + // error message, as it will be confusing to the user. + val uiSuccess = MutableSharedFlow() + + /** Flow of transient errors for the UI to present */ + val uiError = MutableSharedFlow() + + /** Accept UI actions in to actionStateFlow */ + val accept: (UiAction) -> Unit = { action -> + viewModelScope.launch { uiAction.emit(action) } + } + var kind: Kind = Kind.HOME private set var id: String? = null @@ -152,6 +318,46 @@ abstract class TimelineViewModel( } } + // Handle StatusAction.* + viewModelScope.launch { + uiAction.filterIsInstance() + // TODO: Not sure that debouncing is the right thing here, since that will wait + // DEBOUNCE_TIMEOUT_MS before acting. The right thing to do here (and in + // NotificationsFragment) is to take the first one, and ignore any others that + // arrive in the next N milliseconds). + .debounce(DEBOUNCE_TIMEOUT_MS) // avoid double-taps + .collect { action -> + try { + when (action) { + is StatusAction.Bookmark -> + timelineCases.bookmark( + action.statusViewData.actionableId, + action.state + ).await() + is StatusAction.Favourite -> + timelineCases.favourite( + action.statusViewData.actionableId, + action.state + ).await() + is StatusAction.Reblog -> + timelineCases.reblog( + action.statusViewData.actionableId, + action.state + ).await() + is StatusAction.VoteInPoll -> + timelineCases.voteInPoll( + action.statusViewData.actionableId, + action.poll.id, + action.choices + ).await() + } + uiSuccess.emit(StatusActionSuccess.from(action)) + } catch (e: Exception) { + ifExpected(e) { uiError.emit(UiError.make(e, action)) } + } + } + } + uiState = getUiPrefs().map { prefs -> UiState( readingOrder = prefs.readingOrder, @@ -225,54 +431,6 @@ abstract class TimelineViewModel( reloadFilters() } - fun reblog(reblog: Boolean, status: StatusViewData.Concrete): Job = viewModelScope.launch { - try { - timelineCases.reblog(status.actionableId, reblog).await() - } catch (t: Exception) { - ifExpected(t) { - Log.d(TAG, "Failed to reblog status " + status.actionableId, t) - } - } - } - - fun favorite(favorite: Boolean, status: StatusViewData.Concrete): Job = viewModelScope.launch { - try { - timelineCases.favourite(status.actionableId, favorite).await() - } catch (t: Exception) { - ifExpected(t) { - Log.d(TAG, "Failed to favourite status " + status.actionableId, t) - } - } - } - - fun bookmark(bookmark: Boolean, status: StatusViewData.Concrete): Job = viewModelScope.launch { - try { - timelineCases.bookmark(status.actionableId, bookmark).await() - } catch (t: Exception) { - ifExpected(t) { - Log.d(TAG, "Failed to bookmark status " + status.actionableId, t) - } - } - } - - fun voteInPoll(choices: List, status: StatusViewData.Concrete): Job = viewModelScope.launch { - val poll = status.status.actionableStatus.poll ?: run { - Log.w(TAG, "No poll on status ${status.id}") - return@launch - } - - val votedPoll = poll.votedCopy(choices) - updatePoll(votedPoll, status) - - try { - timelineCases.voteInPoll(status.actionableId, poll.id, choices).await() - } catch (t: Exception) { - ifExpected(t) { - Log.d(TAG, "Failed to vote in poll: " + status.actionableId, t) - } - } - } - abstract fun updatePoll(newPoll: Poll, status: StatusViewData.Concrete) abstract fun changeExpanded(expanded: Boolean, status: StatusViewData.Concrete) @@ -429,6 +587,7 @@ abstract class TimelineViewModel( companion object { private const val TAG = "TimelineVM" internal const val LOAD_AT_ONCE = 30 + private const val DEBOUNCE_TIMEOUT_MS = 500L fun filterContextMatchesKind( kind: Kind, diff --git a/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.kt b/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.kt index b4ce418574..315db6b3c0 100644 --- a/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.kt +++ b/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.kt @@ -33,7 +33,7 @@ sealed class StatusViewData { var filterAction: Filter.Action = Filter.Action.NONE data class Concrete( - val status: Status, + var status: Status, val isExpanded: Boolean, val isShowingContent: Boolean, /** From ea5b9781969a17c8d24f5de320a774e2006c50f9 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Mon, 13 Mar 2023 14:23:12 +0100 Subject: [PATCH 006/156] Migrate from TimelineViewModel.Kind to TimelineKind The old type was an enum, the new type is a hierarchy of sealed classes. This allows each class to carry with it the information necessary for that timeline. For example, the PublicFederated timeline needs no additional data. But a timeline for a hashtag does. This simplifies TimelineFragment.newInstance(). Before the called had to know which optional parameters to pass in, or whether they should actually call newHashtagInstance. Now they just construct a timeline of the appropriate kind, with the correct data, and pass that in. This also means that initialisation of TimelineFragment does not need to unpack data from multiple extras in the launch intent, everything is in the parcelized TimelineKind. --- .../keylesspalace/tusky/StatusListActivity.kt | 40 +++---- .../java/com/keylesspalace/tusky/TabData.kt | 13 +-- .../components/account/AccountPagerAdapter.kt | 8 +- .../NotificationsPagingSource.kt | 2 +- .../timeline/NetworkTimelineRepository.kt | 2 +- .../components/timeline/TimelineFragment.kt | 108 ++++++------------ .../viewmodel/NetworkTimelinePagingSource.kt | 29 +++-- .../viewmodel/NetworkTimelineViewModel.kt | 55 +-------- .../timeline/viewmodel/TimelineViewModel.kt | 83 +++----------- .../com/keylesspalace/tusky/entity/Filter.kt | 11 ++ .../tusky/util/StatusDisplayOptions.kt | 2 +- 11 files changed, 115 insertions(+), 238 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt b/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt index 2609243761..a73cf5d3a9 100644 --- a/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt @@ -28,7 +28,7 @@ import com.google.android.material.snackbar.Snackbar import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.PreferenceChangedEvent import com.keylesspalace.tusky.components.timeline.TimelineFragment -import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel.Kind +import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineKind import com.keylesspalace.tusky.databinding.ActivityStatuslistBinding import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.entity.FilterV1 @@ -48,7 +48,7 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector { lateinit var eventHub: EventHub private val binding: ActivityStatuslistBinding by viewBinding(ActivityStatuslistBinding::inflate) - private lateinit var kind: Kind + private lateinit var timelineKind: TimelineKind private var hashtag: String? = null private var followTagItem: MenuItem? = null private var unfollowTagItem: MenuItem? = null @@ -66,15 +66,14 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector { setSupportActionBar(binding.includedToolbar.toolbar) - kind = Kind.valueOf(intent.getStringExtra(EXTRA_KIND)!!) - val listId = intent.getStringExtra(EXTRA_LIST_ID) - hashtag = intent.getStringExtra(EXTRA_HASHTAG) + timelineKind = intent.getParcelableExtra(EXTRA_KIND)!! - val title = when (kind) { - Kind.FAVOURITES -> getString(R.string.title_favourites) - Kind.BOOKMARKS -> getString(R.string.title_bookmarks) - Kind.TAG -> getString(R.string.title_tag).format(hashtag) - else -> intent.getStringExtra(EXTRA_LIST_TITLE) + val title = when (timelineKind) { + is TimelineKind.Favourites -> getString(R.string.title_favourites) + is TimelineKind.Bookmarks -> getString(R.string.title_bookmarks) + is TimelineKind.Tag -> getString(R.string.title_tag).format((timelineKind as TimelineKind.Tag).tags.first()) + is TimelineKind.UserList -> (timelineKind as TimelineKind.UserList).title + else -> "Missing title!!!" } supportActionBar?.run { @@ -85,11 +84,7 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector { if (supportFragmentManager.findFragmentById(R.id.fragmentContainer) == null) { supportFragmentManager.commit { - val fragment = if (kind == Kind.TAG) { - TimelineFragment.newHashtagInstance(listOf(hashtag!!)) - } else { - TimelineFragment.newInstance(kind, listId) - } + val fragment = TimelineFragment.newInstance(timelineKind) replace(R.id.fragmentContainer, fragment) } } @@ -97,7 +92,7 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector { override fun onCreateOptionsMenu(menu: Menu): Boolean { val tag = hashtag - if (kind == Kind.TAG && tag != null) { + if (timelineKind is TimelineKind.Tag && tag != null) { lifecycleScope.launch { mastodonApi.tag(tag).fold( { tagEntity -> @@ -324,32 +319,27 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector { private const val EXTRA_KIND = "kind" private const val EXTRA_LIST_ID = "id" - private const val EXTRA_LIST_TITLE = "title" - private const val EXTRA_HASHTAG = "tag" const val TAG = "StatusListActivity" fun newFavouritesIntent(context: Context) = Intent(context, StatusListActivity::class.java).apply { - putExtra(EXTRA_KIND, Kind.FAVOURITES.name) + putExtra(EXTRA_KIND, TimelineKind.Favourites) } fun newBookmarksIntent(context: Context) = Intent(context, StatusListActivity::class.java).apply { - putExtra(EXTRA_KIND, Kind.BOOKMARKS.name) + putExtra(EXTRA_KIND, TimelineKind.Bookmarks) } fun newListIntent(context: Context, listId: String, listTitle: String) = Intent(context, StatusListActivity::class.java).apply { - putExtra(EXTRA_KIND, Kind.LIST.name) - putExtra(EXTRA_LIST_ID, listId) - putExtra(EXTRA_LIST_TITLE, listTitle) + putExtra(EXTRA_KIND, TimelineKind.UserList(listId, listTitle)) } @JvmStatic fun newHashtagIntent(context: Context, hashtag: String) = Intent(context, StatusListActivity::class.java).apply { - putExtra(EXTRA_KIND, Kind.TAG.name) - putExtra(EXTRA_HASHTAG, hashtag) + putExtra(EXTRA_KIND, TimelineKind.Tag(listOf(hashtag))) } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/TabData.kt b/app/src/main/java/com/keylesspalace/tusky/TabData.kt index 09d1f1cf42..f6b2925906 100644 --- a/app/src/main/java/com/keylesspalace/tusky/TabData.kt +++ b/app/src/main/java/com/keylesspalace/tusky/TabData.kt @@ -22,7 +22,7 @@ import androidx.fragment.app.Fragment import com.keylesspalace.tusky.components.conversation.ConversationsFragment import com.keylesspalace.tusky.components.notifications.NotificationsFragment import com.keylesspalace.tusky.components.timeline.TimelineFragment -import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel +import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineKind import com.keylesspalace.tusky.components.trending.TrendingFragment import java.util.Objects @@ -68,7 +68,7 @@ fun createTabDataFromId(id: String, arguments: List = emptyList()): TabD id = HOME, text = R.string.title_home, icon = R.drawable.ic_home_24dp, - fragment = { TimelineFragment.newInstance(TimelineViewModel.Kind.HOME) } + fragment = { TimelineFragment.newInstance(TimelineKind.Home) } ) NOTIFICATIONS -> TabData( id = NOTIFICATIONS, @@ -80,13 +80,13 @@ fun createTabDataFromId(id: String, arguments: List = emptyList()): TabD id = LOCAL, text = R.string.title_public_local, icon = R.drawable.ic_local_24dp, - fragment = { TimelineFragment.newInstance(TimelineViewModel.Kind.PUBLIC_LOCAL) } + fragment = { TimelineFragment.newInstance(TimelineKind.PublicLocal) } ) FEDERATED -> TabData( id = FEDERATED, text = R.string.title_public_federated, icon = R.drawable.ic_public_24dp, - fragment = { TimelineFragment.newInstance(TimelineViewModel.Kind.PUBLIC_FEDERATED) } + fragment = { TimelineFragment.newInstance(TimelineKind.PublicFederated) } ) DIRECT -> TabData( id = DIRECT, @@ -104,15 +104,14 @@ fun createTabDataFromId(id: String, arguments: List = emptyList()): TabD id = HASHTAG, text = R.string.hashtags, icon = R.drawable.ic_hashtag, - fragment = { args -> TimelineFragment.newHashtagInstance(args) }, - arguments = arguments, + fragment = { args -> TimelineFragment.newInstance(TimelineKind.Tag(args)) }, title = { context -> arguments.joinToString(separator = " ") { context.getString(R.string.title_tag, it) } } ) LIST -> TabData( id = LIST, text = R.string.list, icon = R.drawable.ic_list, - fragment = { args -> TimelineFragment.newInstance(TimelineViewModel.Kind.LIST, args.getOrNull(0).orEmpty()) }, + fragment = { args -> TimelineFragment.newInstance(TimelineKind.UserList(args.first(), args.last())) }, arguments = arguments, title = { arguments.getOrNull(1).orEmpty() } ) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/account/AccountPagerAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/account/AccountPagerAdapter.kt index baeeea43fd..ae3345bb2d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/account/AccountPagerAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/account/AccountPagerAdapter.kt @@ -19,7 +19,7 @@ import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentActivity import com.keylesspalace.tusky.components.account.media.AccountMediaFragment import com.keylesspalace.tusky.components.timeline.TimelineFragment -import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel +import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineKind import com.keylesspalace.tusky.interfaces.RefreshableFragment import com.keylesspalace.tusky.util.CustomFragmentStateAdapter @@ -32,9 +32,9 @@ class AccountPagerAdapter( override fun createFragment(position: Int): Fragment { return when (position) { - 0 -> TimelineFragment.newInstance(TimelineViewModel.Kind.USER, accountId, false) - 1 -> TimelineFragment.newInstance(TimelineViewModel.Kind.USER_WITH_REPLIES, accountId, false) - 2 -> TimelineFragment.newInstance(TimelineViewModel.Kind.USER_PINNED, accountId, false) + 0 -> TimelineFragment.newInstance(TimelineKind.User.Posts(accountId), false) + 1 -> TimelineFragment.newInstance(TimelineKind.User.Replies(accountId), false) + 2 -> TimelineFragment.newInstance(TimelineKind.User.Pinned(accountId), false) 3 -> AccountMediaFragment.newInstance(accountId) else -> throw AssertionError("Page $position is out of AccountPagerAdapter bounds") } 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 index 44db2308b7..90d24fed94 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsPagingSource.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsPagingSource.kt @@ -58,7 +58,7 @@ class NotificationsPagingSource @Inject constructor( } if (!response.isSuccessful) { - return LoadResult.Error(Throwable(response.errorBody().toString())) + return LoadResult.Error(Throwable(response.errorBody()?.string())) } val links = getPageLinks(response.headers()["link"]) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRepository.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRepository.kt index 13d60021ee..9ae0c94315 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRepository.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRepository.kt @@ -32,7 +32,7 @@ import javax.inject.Inject class NetworkTimelineRepository @Inject constructor( private val mastodonApi: MastodonApi ) { - private var factory: InvalidatingPagingSourceFactory? = null + private var factory: InvalidatingPagingSourceFactory? = null /** @return flow of Mastodon [Status], loaded in [pageSize] increments. */ fun getStatusStream( diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt index da2bef06e1..96fe1594d0 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt @@ -53,6 +53,7 @@ import com.keylesspalace.tusky.components.preference.PreferencesFragment.Reading import com.keylesspalace.tusky.components.timeline.viewmodel.CachedTimelineViewModel import com.keylesspalace.tusky.components.timeline.viewmodel.NetworkTimelineViewModel import com.keylesspalace.tusky.components.timeline.viewmodel.StatusAction +import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineKind import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel import com.keylesspalace.tusky.databinding.FragmentTimelineBinding import com.keylesspalace.tusky.di.Injectable @@ -101,7 +102,7 @@ class TimelineFragment : lateinit var eventHub: EventHub private val viewModel: TimelineViewModel by unsafeLazy { - if (kind == TimelineViewModel.Kind.HOME) { + if (timelineKind == TimelineKind.Home) { ViewModelProvider(this, viewModelFactory)[CachedTimelineViewModel::class.java] } else { ViewModelProvider(this, viewModelFactory)[NetworkTimelineViewModel::class.java] @@ -110,7 +111,7 @@ class TimelineFragment : private val binding by viewBinding(FragmentTimelineBinding::bind) - private lateinit var kind: TimelineViewModel.Kind + private lateinit var timelineKind: TimelineKind private lateinit var adapter: TimelinePagingAdapter @@ -151,34 +152,14 @@ class TimelineFragment : super.onCreate(savedInstanceState) val arguments = requireArguments() - kind = TimelineViewModel.Kind.valueOf(arguments.getString(KIND_ARG)!!) - val id: String? = if (kind == TimelineViewModel.Kind.USER || - kind == TimelineViewModel.Kind.USER_PINNED || - kind == TimelineViewModel.Kind.USER_WITH_REPLIES || - kind == TimelineViewModel.Kind.LIST - ) { - arguments.getString(ID_ARG)!! - } else { - null - } - val tags = if (kind == TimelineViewModel.Kind.TAG) { - arguments.getStringArrayList(HASHTAGS_ARG)!! - } else { - listOf() - } - viewModel.init( - kind, - id, - tags, - ) + timelineKind = arguments.getParcelable(KIND_ARG)!! + + viewModel.init(timelineKind) isSwipeToRefreshEnabled = arguments.getBoolean(ARG_ENABLE_SWIPE_TO_REFRESH, true) - adapter = TimelinePagingAdapter( - viewModel.statusDisplayOptions.value, - this - ) + adapter = TimelinePagingAdapter(viewModel.statusDisplayOptions.value, this) } override fun onCreateView( @@ -353,7 +334,7 @@ class TimelineFragment : indexed.value?.id == it.action.statusViewData.id } ?: return@collect - val statusViewData= + val statusViewData = indexedViewData.value as? StatusViewData.Concrete ?: return@collect val status = when (it) { @@ -606,43 +587,41 @@ class TimelineFragment : } override fun onViewTag(tag: String) { - if (viewModel.kind == TimelineViewModel.Kind.TAG && viewModel.tags.size == 1 && - viewModel.tags.contains(tag) - ) { - // If already viewing a tag page, then ignore any request to view that tag again. + val timelineKind = viewModel.timelineKind + + // If already viewing a tag page, then ignore any request to view that tag again. + if (timelineKind is TimelineKind.Tag && timelineKind.tags.contains(tag)) { return } + super.viewTag(tag) } override fun onViewAccount(id: String) { - if (( - viewModel.kind == TimelineViewModel.Kind.USER || - viewModel.kind == TimelineViewModel.Kind.USER_WITH_REPLIES - ) && - viewModel.id == id - ) { - /* If already viewing an account page, then any requests to view that account page - * should be ignored. */ + val timelineKind = viewModel.timelineKind + + // Ignore request to view the account page we're currently viewing + if (timelineKind is TimelineKind.User && timelineKind.id == id) { return } + super.viewAccount(id) } private fun handleStatusComposeEvent(status: Status) { - when (kind) { - TimelineViewModel.Kind.HOME, - TimelineViewModel.Kind.PUBLIC_FEDERATED, - TimelineViewModel.Kind.PUBLIC_LOCAL -> adapter.refresh() - TimelineViewModel.Kind.USER, - TimelineViewModel.Kind.USER_WITH_REPLIES -> if (status.account.id == viewModel.id) { + when (timelineKind) { + is TimelineKind.User.Pinned -> return + + is TimelineKind.Home, + is TimelineKind.PublicFederated, + is TimelineKind.PublicLocal -> adapter.refresh() + is TimelineKind.User -> if (status.account.id == (timelineKind as TimelineKind.User).id) { adapter.refresh() } - TimelineViewModel.Kind.TAG, - TimelineViewModel.Kind.FAVOURITES, - TimelineViewModel.Kind.LIST, - TimelineViewModel.Kind.BOOKMARKS, - TimelineViewModel.Kind.USER_PINNED -> return + is TimelineKind.Bookmarks, + is TimelineKind.Favourites, + is TimelineKind.Tag, + is TimelineKind.UserList -> return } } @@ -652,9 +631,9 @@ class TimelineFragment : } private fun actionButtonPresent(): Boolean { - return viewModel.kind != TimelineViewModel.Kind.TAG && - viewModel.kind != TimelineViewModel.Kind.FAVOURITES && - viewModel.kind != TimelineViewModel.Kind.BOOKMARKS && + return viewModel.timelineKind !is TimelineKind.Tag && + viewModel.timelineKind !is TimelineKind.Favourites && + viewModel.timelineKind !is TimelineKind.Bookmarks && activity is ActionButtonActivity } @@ -685,35 +664,20 @@ class TimelineFragment : } companion object { - private const val TAG = "TimelineF" // logging tag + private const val TAG = "TimelineFragment" // logging tag private const val KIND_ARG = "kind" - private const val ID_ARG = "id" - private const val HASHTAGS_ARG = "hashtags" private const val ARG_ENABLE_SWIPE_TO_REFRESH = "enableSwipeToRefresh" fun newInstance( - kind: TimelineViewModel.Kind, - hashtagOrId: String? = null, + timelineKind: TimelineKind, enableSwipeToRefresh: Boolean = true ): TimelineFragment { val fragment = TimelineFragment() - val arguments = Bundle(3) - arguments.putString(KIND_ARG, kind.name) - arguments.putString(ID_ARG, hashtagOrId) + val arguments = Bundle(2) + arguments.putParcelable(KIND_ARG, timelineKind) arguments.putBoolean(ARG_ENABLE_SWIPE_TO_REFRESH, enableSwipeToRefresh) fragment.arguments = arguments return fragment } - - @JvmStatic - fun newHashtagInstance(hashtags: List): TimelineFragment { - val fragment = TimelineFragment() - val arguments = Bundle(3) - arguments.putString(KIND_ARG, TimelineViewModel.Kind.TAG.name) - arguments.putStringArrayList(HASHTAGS_ARG, ArrayList(hashtags)) - arguments.putBoolean(ARG_ENABLE_SWIPE_TO_REFRESH, true) - fragment.arguments = arguments - return fragment - } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelinePagingSource.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelinePagingSource.kt index a33c0da3ac..c1dac5bc44 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelinePagingSource.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelinePagingSource.kt @@ -15,6 +15,7 @@ package com.keylesspalace.tusky.components.timeline.viewmodel +import android.os.Parcelable import android.util.Log import androidx.paging.PagingSource import androidx.paging.PagingState @@ -23,6 +24,7 @@ import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.util.HttpHeaderLink import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope +import kotlinx.parcelize.Parcelize import okhttp3.Headers import retrofit2.HttpException import retrofit2.Response @@ -37,17 +39,25 @@ import javax.inject.Inject data class Links(val next: String?, val prev: String?) /** A timeline's type. Hold's data necessary to display that timeline. */ -sealed class TimelineKind { +@Parcelize +sealed class TimelineKind : Parcelable { object Home : TimelineKind() object PublicFederated : TimelineKind() object PublicLocal : TimelineKind() data class Tag(val tags: List) : TimelineKind() - data class User(val id: String) : TimelineKind() - data class UserPinned(val id: String) : TimelineKind() - data class UserReplies(val id: String) : TimelineKind() + /** Any timeline showing statuses from a single user */ + @Parcelize + sealed class User(open val id: String) : TimelineKind() { + /** Timeline showing just the user's statuses (no replies) */ + data class Posts(override val id: String) : User(id) + /** Timeline showing the user's pinned statuses */ + data class Pinned(override val id: String) : User(id) + /** Timeline showing the user's top-level statuses and replies they have made */ + data class Replies(override val id: String) : User(id) + } object Favourites : TimelineKind() object Bookmarks : TimelineKind() - data class UserList(val id: String) : TimelineKind() + data class UserList(val id: String, val title: String) : TimelineKind() } /** [PagingSource] for Mastodon Status, identified by the Status ID */ @@ -74,7 +84,7 @@ class NetworkTimelinePagingSource @Inject constructor( } if (!response.isSuccessful) { - return LoadResult.Error(Throwable(response.errorBody().toString())) + return LoadResult.Error(Throwable(response.errorBody()?.string())) } val links = getPageLinks(response.headers()["link"]) @@ -104,7 +114,7 @@ class NetworkTimelinePagingSource @Inject constructor( val additionalHashtags = kind.tags.subList(1, kind.tags.size) api.hashtagTimeline(firstHashtag, additionalHashtags, null, maxId, minId, limit) } - is TimelineKind.User -> api.accountStatuses( + is TimelineKind.User.Posts -> api.accountStatuses( kind.id, maxId, minId, @@ -113,7 +123,7 @@ class NetworkTimelinePagingSource @Inject constructor( onlyMedia = null, pinned = null ) - is TimelineKind.UserPinned -> api.accountStatuses( + is TimelineKind.User.Pinned -> api.accountStatuses( kind.id, maxId, minId, @@ -122,7 +132,7 @@ class NetworkTimelinePagingSource @Inject constructor( onlyMedia = null, pinned = true ) - is TimelineKind.UserReplies -> api.accountStatuses( + is TimelineKind.User.Replies -> api.accountStatuses( kind.id, maxId, minId, @@ -195,7 +205,6 @@ class NetworkTimelinePagingSource @Inject constructor( .build() return@coroutineScope Response.success(statuses, headers) - } // The user's last read status was missing or is filtered. Use the page of diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt index 2c433f5535..3cdbea6fff 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt @@ -37,9 +37,6 @@ import com.keylesspalace.tusky.util.toViewData import com.keylesspalace.tusky.viewdata.StatusViewData import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map -import retrofit2.HttpException -import retrofit2.Response -import java.io.IOException import javax.inject.Inject /** @@ -67,8 +64,8 @@ class NetworkTimelineViewModel @Inject constructor( override lateinit var statuses: Flow> - override fun init(kind: Kind, id: String?, tags: List) { - super.init(kind, id, tags) + override fun init(timelineKind: TimelineKind) { + super.init(timelineKind) statuses = getStatuses(timelineKind) } @@ -259,54 +256,6 @@ class NetworkTimelineViewModel @Inject constructor( currentSource?.invalidate() } - @Throws(IOException::class, HttpException::class) - suspend fun fetchStatusesForKind( - fromId: String?, - uptoId: String?, - limit: Int - ): Response> { - return when (kind) { - Kind.HOME -> api.homeTimeline(maxId = fromId, sinceId = uptoId, limit = limit) - Kind.PUBLIC_FEDERATED -> api.publicTimeline(null, fromId, uptoId, limit) - Kind.PUBLIC_LOCAL -> api.publicTimeline(true, fromId, uptoId, limit) - Kind.TAG -> { - val firstHashtag = tags[0] - val additionalHashtags = tags.subList(1, tags.size) - api.hashtagTimeline(firstHashtag, additionalHashtags, null, fromId, uptoId, limit) - } - Kind.USER -> api.accountStatuses( - id!!, - fromId, - uptoId, - limit, - excludeReplies = true, - onlyMedia = null, - pinned = null - ) - Kind.USER_PINNED -> api.accountStatuses( - id!!, - fromId, - uptoId, - limit, - excludeReplies = null, - onlyMedia = null, - pinned = true - ) - Kind.USER_WITH_REPLIES -> api.accountStatuses( - id!!, - fromId, - uptoId, - limit, - excludeReplies = null, - onlyMedia = null, - pinned = null - ) - Kind.FAVOURITES -> api.favourites(fromId, uptoId, limit) - Kind.BOOKMARKS -> api.bookmarks(fromId, uptoId, limit) - Kind.LIST -> api.listTimeline(id!!, fromId, uptoId, limit) - } - } - private fun StatusViewData.Concrete.update() { // val position = statusData.indexOfFirst { viewData -> viewData.asStatusOrNull()?.id == this.id } // statusData[position] = this diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt index 95e1cf1a04..b83a9b0d33 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt @@ -122,13 +122,6 @@ sealed class InfallibleUiAction : UiAction() { data class SaveVisibleId(val visibleId: String) : 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, @@ -146,7 +139,7 @@ sealed class UiSuccess { } /** Actions the user can trigger on an individual status */ -sealed class StatusAction(open val statusViewData: StatusViewData.Concrete): FallibleUiAction() { +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) @@ -274,15 +267,8 @@ abstract class TimelineViewModel( viewModelScope.launch { uiAction.emit(action) } } - var kind: Kind = Kind.HOME - private set - var id: String? = null + var timelineKind: TimelineKind = TimelineKind.Home private set - var tags: List = emptyList() - private set - - /** The [TimelineKind] equivalent of [kind] */ - lateinit var timelineKind: TimelineKind protected var alwaysShowSensitiveMedia = false protected var alwaysOpenSpoilers = false @@ -387,30 +373,12 @@ abstract class TimelineViewModel( showMediaPreview = accountManager.activeAccount!!.mediaPreviewEnabled ) - open fun init( - kind: Kind, - id: String?, - tags: List - ) { - this.kind = kind - this.id = id - this.tags = tags - filterModel.kind = kind.toFilterKind() - - timelineKind = when (kind) { - Kind.HOME -> TimelineKind.Home - Kind.PUBLIC_LOCAL -> TimelineKind.PublicLocal - Kind.PUBLIC_FEDERATED -> TimelineKind.PublicFederated - Kind.TAG -> TimelineKind.Tag(tags) - Kind.USER -> TimelineKind.User(id!!) - Kind.USER_PINNED -> TimelineKind.UserPinned(id!!) - Kind.USER_WITH_REPLIES -> TimelineKind.UserReplies(id!!) - Kind.FAVOURITES -> TimelineKind.Favourites - Kind.LIST -> TimelineKind.UserList(id!!) - Kind.BOOKMARKS -> TimelineKind.Bookmarks - } + open fun init(timelineKind: TimelineKind) { + this.timelineKind = timelineKind - if (kind == Kind.HOME) { + 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" filterRemoveReplies = !sharedPreferences.getBoolean(PrefKeys.TAB_FILTER_HOME_REPLIES, true) @@ -482,7 +450,7 @@ abstract class TimelineViewModel( PrefKeys.TAB_FILTER_HOME_REPLIES -> { val filter = sharedPreferences.getBoolean(PrefKeys.TAB_FILTER_HOME_REPLIES, true) val oldRemoveReplies = filterRemoveReplies - filterRemoveReplies = kind == Kind.HOME && !filter + filterRemoveReplies = timelineKind is TimelineKind.Home && !filter if (oldRemoveReplies != filterRemoveReplies) { fullReload() } @@ -490,13 +458,13 @@ abstract class TimelineViewModel( PrefKeys.TAB_FILTER_HOME_BOOSTS -> { val filter = sharedPreferences.getBoolean(PrefKeys.TAB_FILTER_HOME_BOOSTS, true) val oldRemoveReblogs = filterRemoveReblogs - filterRemoveReblogs = kind == Kind.HOME && !filter + filterRemoveReblogs = timelineKind is TimelineKind.Home && !filter if (oldRemoveReblogs != filterRemoveReblogs) { fullReload() } } FilterV1.HOME, FilterV1.NOTIFICATIONS, FilterV1.THREAD, FilterV1.PUBLIC, FilterV1.ACCOUNT -> { - if (filterContextMatchesKind(kind, listOf(key))) { + if (filterContextMatchesKind(timelineKind, listOf(key))) { reloadFilters() } } @@ -519,31 +487,31 @@ abstract class TimelineViewModel( is PinEvent -> handlePinEvent(event) is MuteConversationEvent -> fullReload() is UnfollowEvent -> { - if (kind == Kind.HOME) { + if (timelineKind is TimelineKind.Home) { val id = event.accountId removeAllByAccountId(id) } } is BlockEvent -> { - if (kind != Kind.USER && kind != Kind.USER_WITH_REPLIES && kind != Kind.USER_PINNED) { + if (timelineKind !is TimelineKind.User) { val id = event.accountId removeAllByAccountId(id) } } is MuteEvent -> { - if (kind != Kind.USER && kind != Kind.USER_WITH_REPLIES && kind != Kind.USER_PINNED) { + if (timelineKind !is TimelineKind.User) { val id = event.accountId removeAllByAccountId(id) } } is DomainMuteEvent -> { - if (kind != Kind.USER && kind != Kind.USER_WITH_REPLIES && kind != Kind.USER_PINNED) { + if (timelineKind !is TimelineKind.User) { val instance = event.instance removeAllByInstance(instance) } } is StatusDeletedEvent -> { - if (kind != Kind.USER && kind != Kind.USER_WITH_REPLIES && kind != Kind.USER_PINNED) { + if (timelineKind !is TimelineKind.User) { removeStatusWithId(event.statusId) } } @@ -570,7 +538,7 @@ abstract class TimelineViewModel( } filterModel.initWithFilters( filters.filter { - filterContextMatchesKind(kind, it.context) + filterContextMatchesKind(timelineKind, it.context) } ) // After the filters are loaded we need to reload displayed content to apply them. @@ -585,28 +553,15 @@ abstract class TimelineViewModel( } companion object { - private const val TAG = "TimelineVM" + private const val TAG = "TimelineViewModel" internal const val LOAD_AT_ONCE = 30 private const val DEBOUNCE_TIMEOUT_MS = 500L fun filterContextMatchesKind( - kind: Kind, + timelineKind: TimelineKind, filterContext: List ): Boolean { - return filterContext.contains(kind.toFilterKind().kind) - } - } - - enum class Kind { - HOME, PUBLIC_LOCAL, PUBLIC_FEDERATED, TAG, USER, USER_PINNED, USER_WITH_REPLIES, FAVOURITES, LIST, BOOKMARKS; - - fun toFilterKind(): Filter.Kind { - return when (valueOf(name)) { - HOME, LIST -> Filter.Kind.HOME - PUBLIC_FEDERATED, PUBLIC_LOCAL, TAG, FAVOURITES -> Filter.Kind.PUBLIC - USER, USER_WITH_REPLIES, USER_PINNED -> Filter.Kind.ACCOUNT - else -> Filter.Kind.PUBLIC - } + return filterContext.contains(Filter.Kind.from(timelineKind).kind) } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Filter.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Filter.kt index 3e529bfe06..c199c72f3f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Filter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Filter.kt @@ -2,6 +2,7 @@ package com.keylesspalace.tusky.entity import android.os.Parcelable import com.google.gson.annotations.SerializedName +import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineKind import kotlinx.parcelize.Parcelize import java.util.Date @@ -33,6 +34,16 @@ data class Filter( companion object { fun from(kind: String): Kind = values().firstOrNull { it.kind == kind } ?: PUBLIC + + fun from(kind: TimelineKind): Kind = when (kind) { + is TimelineKind.Home, is TimelineKind.UserList -> HOME + is TimelineKind.PublicFederated, + is TimelineKind.PublicLocal, + is TimelineKind.Tag, + is TimelineKind.Favourites -> Filter.Kind.PUBLIC + is TimelineKind.User -> Filter.Kind.ACCOUNT + else -> Filter.Kind.PUBLIC + } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/util/StatusDisplayOptions.kt b/app/src/main/java/com/keylesspalace/tusky/util/StatusDisplayOptions.kt index 283524c6a1..86af65ecc6 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/StatusDisplayOptions.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/StatusDisplayOptions.kt @@ -120,7 +120,7 @@ data class StatusDisplayOptions( useBlurhash = preferences.getBoolean(PrefKeys.USE_BLURHASH, true), cardViewMode = if (preferences.getBoolean(PrefKeys.SHOW_CARDS_IN_TIMELINES, false)) { CardViewMode.INDENTED - } else { + } else { CardViewMode.NONE }, confirmReblogs = preferences.getBoolean(PrefKeys.CONFIRM_REBLOGS, true), From 60476d295484cdd8c23c1de171aaebc80b6aa361 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Mon, 13 Mar 2023 14:40:46 +0100 Subject: [PATCH 007/156] ktlint 11.3.1 and format --- .../com/keylesspalace/tusky/AboutActivity.kt | 1 - .../tusky/AccountsInListFragment.kt | 6 +- .../tusky/BottomSheetActivity.kt | 2 +- .../tusky/EditProfileActivity.kt | 1 - .../keylesspalace/tusky/LicenseActivity.kt | 1 - .../com/keylesspalace/tusky/ListsActivity.kt | 14 ++- .../com/keylesspalace/tusky/MainActivity.kt | 15 ++- .../keylesspalace/tusky/StatusListActivity.kt | 6 +- .../tusky/TabPreferenceActivity.kt | 3 +- .../keylesspalace/tusky/ViewMediaActivity.kt | 3 +- .../tusky/adapter/ListSelectionAdapter.kt | 1 - .../tusky/adapter/PollAdapter.kt | 1 - .../adapter/ReportNotificationViewHolder.kt | 2 +- .../tusky/adapter/TrendingDateViewHolder.kt | 2 +- .../tusky/adapter/TrendingTagViewHolder.kt | 2 +- .../components/account/AccountActivity.kt | 21 +++-- .../components/account/AccountViewModel.kt | 31 ++++--- .../account/list/ListsForAccountFragment.kt | 4 +- .../account/list/ListsForAccountViewModel.kt | 12 +-- .../account/media/AccountMediaPagingSource.kt | 1 - .../media/AccountMediaRemoteMediator.kt | 1 - .../accountlist/AccountListFragment.kt | 3 +- .../accountlist/adapter/AccountAdapter.kt | 2 +- .../accountlist/adapter/BlocksAdapter.kt | 2 +- .../adapter/FollowRequestsAdapter.kt | 4 +- .../announcements/AnnouncementsViewModel.kt | 3 +- .../components/compose/ComposeActivity.kt | 61 +++++++----- .../components/compose/ComposeViewModel.kt | 20 ++-- .../components/compose/ImageDownsizer.kt | 1 - .../components/compose/MediaPreviewAdapter.kt | 8 +- .../tusky/components/compose/MediaUploader.kt | 4 +- .../compose/dialog/AddPollDialog.kt | 1 - .../compose/dialog/CaptionDialog.kt | 11 ++- .../compose/view/FocusIndicatorView.kt | 12 ++- .../components/compose/view/TootButton.kt | 1 - .../conversation/ConversationEntity.kt | 10 +- .../conversation/ConversationViewData.kt | 2 +- .../ConversationsRemoteMediator.kt | 4 +- .../tusky/components/drafts/DraftHelper.kt | 4 +- .../components/drafts/DraftMediaAdapter.kt | 8 +- .../tusky/components/drafts/DraftsAdapter.kt | 1 - .../components/filters/EditFilterActivity.kt | 6 +- .../components/filters/EditFilterViewModel.kt | 6 +- .../components/filters/FiltersViewModel.kt | 2 +- .../followedtags/FollowedTagsAdapter.kt | 2 +- .../FollowedTagsRemoteMediator.kt | 2 +- .../adapter/DomainMutesAdapter.kt | 3 +- .../tusky/components/login/LoginActivity.kt | 19 +++- .../components/login/LoginWebViewActivity.kt | 2 +- .../notifications/FollowViewHolder.kt | 2 +- .../notifications/PushNotificationHelper.kt | 12 ++- .../preference/AccountPreferencesFragment.kt | 1 - .../report/adapter/StatusViewHolder.kt | 13 ++- .../report/adapter/StatusesAdapter.kt | 5 +- .../report/fragments/ReportNoteFragment.kt | 3 +- .../tusky/components/search/SearchActivity.kt | 2 +- .../search/adapter/SearchPagingSource.kt | 1 - .../fragments/SearchStatusesFragment.kt | 11 ++- .../components/timeline/TimelineFragment.kt | 4 +- .../timeline/TimelinePagingAdapter.kt | 4 +- .../timeline/TimelineTypeMappers.kt | 10 +- .../viewmodel/CachedTimelineRemoteMediator.kt | 1 - .../viewmodel/CachedTimelineViewModel.kt | 1 - .../viewmodel/NetworkTimelinePagingSource.kt | 7 +- .../timeline/viewmodel/TimelineViewModel.kt | 4 +- .../components/trending/TrendingAdapter.kt | 2 +- .../components/trending/TrendingFragment.kt | 9 +- .../components/viewthread/ThreadAdapter.kt | 4 +- .../viewthread/ViewThreadViewModel.kt | 2 +- .../keylesspalace/tusky/db/AccountEntity.kt | 2 +- .../keylesspalace/tusky/db/AccountManager.kt | 9 +- .../com/keylesspalace/tusky/db/DraftEntity.kt | 2 +- .../tusky/db/TimelineStatusEntity.kt | 2 +- .../com/keylesspalace/tusky/di/AppModule.kt | 2 +- .../com/keylesspalace/tusky/entity/Account.kt | 6 +- .../keylesspalace/tusky/entity/Attachment.kt | 6 +- .../tusky/entity/DeletedStatus.kt | 2 +- .../com/keylesspalace/tusky/entity/Filter.kt | 2 +- .../tusky/entity/FilterKeyword.kt | 2 +- .../tusky/entity/FilterResult.kt | 2 +- .../keylesspalace/tusky/entity/FilterV1.kt | 2 +- .../keylesspalace/tusky/entity/Instance.kt | 8 +- .../keylesspalace/tusky/entity/NewStatus.kt | 4 +- .../tusky/entity/Notification.kt | 11 ++- .../entity/NotificationSubscribeResult.kt | 2 +- .../com/keylesspalace/tusky/entity/Report.kt | 2 +- .../com/keylesspalace/tusky/entity/Status.kt | 8 +- .../tusky/entity/StatusSource.kt | 2 +- .../tusky/entity/TimelineAccount.kt | 6 +- .../tusky/entity/TrendingTagsResult.kt | 4 +- .../keylesspalace/tusky/fragment/SFragment.kt | 4 +- .../tusky/fragment/ViewImageFragment.kt | 6 +- .../tusky/fragment/ViewMediaFragment.kt | 1 + .../keylesspalace/tusky/json/Iso8601Utils.kt | 1 - .../tusky/network/FilterModel.kt | 3 +- .../network/InstanceSwitchAuthInterceptor.kt | 1 - .../tusky/network/MastodonApi.kt | 20 ++-- .../receiver/SendStatusBroadcastReceiver.kt | 2 +- .../tusky/service/SendStatusService.kt | 21 ++--- .../settings/AccountPreferenceHandler.kt | 2 +- .../tusky/settings/ProxyConfiguration.kt | 10 +- .../tusky/util/CustomEmojiHelper.kt | 4 +- .../tusky/util/HttpHeaderLink.kt | 4 +- .../com/keylesspalace/tusky/util/IOUtils.kt | 2 +- .../tusky/util/ImageLoadingHelper.kt | 1 - .../util/ListStatusAccessibilityDelegate.kt | 6 +- .../keylesspalace/tusky/util/LocaleManager.kt | 1 - .../keylesspalace/tusky/util/MediaUtils.kt | 10 +- .../tusky/util/NotificationTypeConverter.kt | 3 +- .../tusky/util/ShareShortcutHelper.kt | 3 - .../tusky/util/SmartLengthInputFilter.kt | 1 - .../com/keylesspalace/tusky/util/SpanUtils.kt | 2 +- .../tusky/util/StatusParsingHelper.kt | 1 + .../tusky/util/StatusViewHelper.kt | 23 +++-- .../keylesspalace/tusky/util/ViewDataUtils.kt | 4 +- .../com/keylesspalace/tusky/view/GraphView.kt | 72 ++++++++++---- .../keylesspalace/tusky/view/LicenseCard.kt | 7 +- .../tusky/view/MediaPreviewImageView.kt | 9 +- .../tusky/viewdata/PollViewData.kt | 2 +- .../tusky/viewdata/StatusViewData.kt | 2 +- .../tusky/viewdata/TrendingViewData.kt | 2 +- .../tusky/viewmodel/EditProfileViewModel.kt | 5 +- .../tusky/viewmodel/ListsViewModel.kt | 7 +- .../tusky/BottomSheetActivityTest.kt | 3 +- .../com/keylesspalace/tusky/FilterV1Test.kt | 14 ++- .../keylesspalace/tusky/FocalPointUtilTest.kt | 93 +++++++++++++------ .../keylesspalace/tusky/MainActivityTest.kt | 4 +- .../keylesspalace/tusky/StringUtilsTest.kt | 2 +- .../CachedTimelineRemoteMediatorTest.kt | 32 +++---- .../NetworkTimelineRemoteMediatorTest.kt | 38 ++++---- .../tusky/components/timeline/StatusMocker.kt | 4 +- .../viewthread/ViewThreadViewModelTest.kt | 18 ++-- .../keylesspalace/tusky/db/TimelineDaoTest.kt | 16 ++-- .../InstanceSwitchAuthInterceptorTest.kt | 1 - .../tusky/usecase/TimelineCasesTest.kt | 3 +- .../tusky/util/LinkHelperTest.kt | 16 ++-- .../tusky/util/LocaleUtilsTest.kt | 2 +- .../keylesspalace/tusky/util/RickRollTest.kt | 5 +- gradle/libs.versions.toml | 2 +- 139 files changed, 588 insertions(+), 425 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/AboutActivity.kt b/app/src/main/java/com/keylesspalace/tusky/AboutActivity.kt index fc8b3db2b6..7c2eedccdc 100644 --- a/app/src/main/java/com/keylesspalace/tusky/AboutActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/AboutActivity.kt @@ -51,7 +51,6 @@ class AboutActivity : BottomSheetActivity(), Injectable { } private fun TextView.setClickableTextWithoutUnderlines(@StringRes textId: Int) { - val text = SpannableString(context.getText(textId)) Linkify.addLinks(text, Linkify.WEB_URLS) diff --git a/app/src/main/java/com/keylesspalace/tusky/AccountsInListFragment.kt b/app/src/main/java/com/keylesspalace/tusky/AccountsInListFragment.kt index 2235958c72..7821630b58 100644 --- a/app/src/main/java/com/keylesspalace/tusky/AccountsInListFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/AccountsInListFragment.kt @@ -153,12 +153,14 @@ class AccountsInListFragment : DialogFragment(), Injectable { if (error is IOException) { binding.messageView.setup( R.drawable.elephant_offline, - R.string.error_network, retryAction + R.string.error_network, + retryAction ) } else { binding.messageView.setup( R.drawable.elephant_error, - R.string.error_generic, retryAction + R.string.error_generic, + retryAction ) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/BottomSheetActivity.kt b/app/src/main/java/com/keylesspalace/tusky/BottomSheetActivity.kt index 8df0c66171..d62b5c1d19 100644 --- a/app/src/main/java/com/keylesspalace/tusky/BottomSheetActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/BottomSheetActivity.kt @@ -177,5 +177,5 @@ abstract class BottomSheetActivity : BaseActivity() { enum class PostLookupFallbackBehavior { OPEN_IN_BROWSER, - DISPLAY_ERROR, + DISPLAY_ERROR } diff --git a/app/src/main/java/com/keylesspalace/tusky/EditProfileActivity.kt b/app/src/main/java/com/keylesspalace/tusky/EditProfileActivity.kt index 33d7a281a1..7a0c321d60 100644 --- a/app/src/main/java/com/keylesspalace/tusky/EditProfileActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/EditProfileActivity.kt @@ -131,7 +131,6 @@ class EditProfileActivity : BaseActivity(), Injectable { is Success -> { val me = profileRes.data if (me != null) { - binding.displayNameEditText.setText(me.displayName) binding.noteEditText.setText(me.source?.note) binding.lockedCheckBox.isChecked = me.locked diff --git a/app/src/main/java/com/keylesspalace/tusky/LicenseActivity.kt b/app/src/main/java/com/keylesspalace/tusky/LicenseActivity.kt index 3099bd0098..ca81d244a6 100644 --- a/app/src/main/java/com/keylesspalace/tusky/LicenseActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/LicenseActivity.kt @@ -44,7 +44,6 @@ class LicenseActivity : BaseActivity() { } private fun loadFileIntoTextView(@RawRes fileId: Int, textView: TextView) { - val sb = StringBuilder() val br = BufferedReader(InputStreamReader(resources.openRawResource(fileId))) diff --git a/app/src/main/java/com/keylesspalace/tusky/ListsActivity.kt b/app/src/main/java/com/keylesspalace/tusky/ListsActivity.kt index ec241c204f..e5c043d7ab 100644 --- a/app/src/main/java/com/keylesspalace/tusky/ListsActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/ListsActivity.kt @@ -134,8 +134,11 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector { val dialog = AlertDialog.Builder(this) .setView(layout) .setPositiveButton( - if (list == null) R.string.action_create_list - else R.string.action_rename_list + if (list == null) { + R.string.action_create_list + } else { + R.string.action_rename_list + } ) { _, _ -> onPickedDialogName(editText.text, list?.id) } @@ -181,7 +184,8 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector { if (state.lists.isEmpty()) { binding.messageView.show() binding.messageView.setup( - R.drawable.elephant_friend_empty, R.string.message_empty, + R.drawable.elephant_friend_empty, + R.string.message_empty, null ) } else { @@ -192,7 +196,9 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector { private fun showMessage(@StringRes messageId: Int) { Snackbar.make( - binding.listsRecycler, messageId, Snackbar.LENGTH_SHORT + binding.listsRecycler, + messageId, + Snackbar.LENGTH_SHORT ).show() } diff --git a/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt b/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt index 6b8978defd..dbf710a187 100644 --- a/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt @@ -215,7 +215,8 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje } else { // No account was provided, show the chooser showAccountChooserDialog( - getString(R.string.action_share_as), true, + getString(R.string.action_share_as), + true, object : AccountSelectionListener { override fun onAccountSelected(account: AccountEntity) { val requestedId = account.id @@ -295,7 +296,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje is MainTabsChangedEvent -> { refreshMainDrawerItems( addSearchButton = hideTopToolbar, - addTrendingButton = !event.newTabs.hasTab(TRENDING), + addTrendingButton = !event.newTabs.hasTab(TRENDING) ) setupTabs(false) @@ -407,7 +408,6 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje // FIXME: blackberry keyONE raises SHIFT key event even CTRL IS PRESSED when (keyCode) { KeyEvent.KEYCODE_N -> { - // open compose activity by pressing SHIFT + N (or CTRL + N) val composeIntent = Intent(applicationContext, ComposeActivity::class.java) startActivity(composeIntent) @@ -444,7 +444,6 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje addSearchButton: Boolean, addTrendingButton: Boolean ) { - val drawerOpenClickListener = View.OnClickListener { binding.mainDrawerLayout.open() } binding.mainToolbar.setNavigationOnClickListener(drawerOpenClickListener) @@ -708,7 +707,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje tabAdapter.notifyItemRangeChanged(0, tabs.size) tabLayoutMediator = TabLayoutMediator(activeTabLayout, binding.viewPager, true) { - tab: TabLayout.Tab, position: Int -> + tab: TabLayout.Tab, position: Int -> tab.icon = AppCompatResources.getDrawable(this@MainActivity, tabs[position].icon) tab.contentDescription = when (tabs[position].id) { LIST -> tabs[position].arguments[1] @@ -881,7 +880,6 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje } private fun loadDrawerAvatar(avatarUrl: String, showPlaceholder: Boolean) { - val hideTopToolbar = preferences.getBoolean(PrefKeys.HIDE_TOP_TOOLBAR, false) val animateAvatars = preferences.getBoolean("animateGifAvatars", false) @@ -909,7 +907,6 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje .into(avatarView) } } else { - binding.bottomNavAvatar.hide() binding.topNavAvatar.hide() @@ -1040,7 +1037,9 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje header.setActiveProfile(accountManager.activeAccount!!.id) binding.mainToolbar.subtitle = if (accountManager.shouldDisplaySelfUsername(this)) { accountManager.activeAccount!!.fullName - } else null + } else { + null + } } override fun getActionButton() = binding.composeButton diff --git a/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt b/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt index a73cf5d3a9..033ad7cb51 100644 --- a/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt @@ -220,7 +220,7 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector { title = "#$tag", context = listOf(FilterV1.HOME), filterAction = Filter.Action.WARN.action, - expiresInSeconds = null, + expiresInSeconds = null ).fold( { filter -> if (mastodonApi.addFilterKeyword(filterId = filter.id, keyword = tag, wholeWord = true).isSuccess) { @@ -271,7 +271,7 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector { // This filter exists in multiple contexts, just remove the home context mastodonApi.updateFilter( id = filter.id, - context = filter.context.filter { it != Filter.Kind.HOME.kind }, + context = filter.context.filter { it != Filter.Kind.HOME.kind } ) } else { mastodonApi.deleteFilter(filter.id) @@ -286,7 +286,7 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector { context = filter.context.filter { it != FilterV1.HOME }, irreversible = null, wholeWord = null, - expiresInSeconds = null, + expiresInSeconds = null ) } else { mastodonApi.deleteFilterV1(filter.id) diff --git a/app/src/main/java/com/keylesspalace/tusky/TabPreferenceActivity.kt b/app/src/main/java/com/keylesspalace/tusky/TabPreferenceActivity.kt index ad4ade7353..0bbeda5a60 100644 --- a/app/src/main/java/com/keylesspalace/tusky/TabPreferenceActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/TabPreferenceActivity.kt @@ -59,6 +59,7 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene @Inject lateinit var mastodonApi: MastodonApi + @Inject lateinit var eventHub: EventHub @@ -161,7 +162,6 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene } override fun onTabAdded(tab: TabData) { - if (currentTabs.size >= MAX_TAB_COUNT) { return } @@ -223,7 +223,6 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene } private fun showAddHashtagDialog(tab: TabData? = null, tabPosition: Int = 0) { - val frameLayout = FrameLayout(this) val padding = Utils.dpToPx(this, 8) frameLayout.updatePadding(left = padding, right = padding) diff --git a/app/src/main/java/com/keylesspalace/tusky/ViewMediaActivity.kt b/app/src/main/java/com/keylesspalace/tusky/ViewMediaActivity.kt index 8c7dff5948..7f81dd9c1c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/ViewMediaActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/ViewMediaActivity.kt @@ -306,8 +306,9 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener isCreating = false invalidateOptionsMenu() binding.progressBarShare.visibility = View.GONE - if (result) + if (result) { shareFile(file, "image/png") + } }, { error -> isCreating = false diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/ListSelectionAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/ListSelectionAdapter.kt index d647842742..7c2a16935c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/ListSelectionAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/ListSelectionAdapter.kt @@ -26,7 +26,6 @@ import com.keylesspalace.tusky.entity.MastoList class ListSelectionAdapter(context: Context) : ArrayAdapter(context, R.layout.item_picker_list) { override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { - val binding = if (convertView == null) { ItemPickerListBinding.inflate(LayoutInflater.from(context), parent, false) } else { diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/PollAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/PollAdapter.kt index 596c9432d5..3e4e1dad9c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/PollAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/PollAdapter.kt @@ -75,7 +75,6 @@ class PollAdapter : RecyclerView.Adapter>() { override fun getItemCount() = pollOptions.size override fun onBindViewHolder(holder: BindingHolder, position: Int) { - val option = pollOptions[position] val resultTextView = holder.binding.statusPollOptionResult diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/ReportNotificationViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/ReportNotificationViewHolder.kt index d4712159a9..7502c24e94 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/ReportNotificationViewHolder.kt +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/ReportNotificationViewHolder.kt @@ -35,7 +35,7 @@ import java.util.Date class ReportNotificationViewHolder( private val binding: ItemReportNotificationBinding, - private val notificationActionListener: NotificationActionListener, + private val notificationActionListener: NotificationActionListener ) : NotificationsPagingAdapter.ViewHolder, RecyclerView.ViewHolder(binding.root) { override fun bind( diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/TrendingDateViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/TrendingDateViewHolder.kt index 88ec17d1f2..481573068b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/TrendingDateViewHolder.kt +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/TrendingDateViewHolder.kt @@ -24,7 +24,7 @@ import java.util.Locale import java.util.TimeZone class TrendingDateViewHolder( - private val binding: ItemTrendingDateBinding, + private val binding: ItemTrendingDateBinding ) : RecyclerView.ViewHolder(binding.root) { private val dateFormat = SimpleDateFormat("EEE dd MMM yyyy", Locale.getDefault()).apply { diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/TrendingTagViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/TrendingTagViewHolder.kt index 270ede6c6d..f852c46b84 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/TrendingTagViewHolder.kt +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/TrendingTagViewHolder.kt @@ -32,7 +32,7 @@ class TrendingTagViewHolder( fun setup( tagViewData: TrendingViewData.Tag, maxTrendingValue: Long, - trendingListener: LinkListener, + trendingListener: LinkListener ) { val reversedHistory = tagViewData.tag.history.reversed() setGraph(reversedHistory, maxTrendingValue) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/account/AccountActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/account/AccountActivity.kt index 561e5fc0e0..ea90752103 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/account/AccountActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/account/AccountActivity.kt @@ -107,8 +107,10 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide @Inject lateinit var dispatchingAndroidInjector: DispatchingAndroidInjector + @Inject lateinit var viewModelFactory: ViewModelFactory + @Inject lateinit var draftsAlert: DraftsAlert @@ -134,14 +136,18 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide // fields for scroll animation private var hideFab: Boolean = false private var oldOffset: Int = 0 + @ColorInt private var toolbarColor: Int = 0 + @ColorInt private var statusBarColorTransparent: Int = 0 + @ColorInt private var statusBarColorOpaque: Int = 0 private var avatarSize: Float = 0f + @Px private var titleVisibleHeight: Int = 0 private lateinit var domain: String @@ -342,7 +348,6 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide binding.accountAppBarLayout.addOnOffsetChangedListener(object : AppBarLayout.OnOffsetChangedListener { override fun onOffsetChanged(appBarLayout: AppBarLayout, verticalOffset: Int) { - if (verticalOffset == oldOffset) { return } @@ -650,10 +655,11 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide binding.accountSubscribeButton.setOnClickListener { viewModel.changeSubscribingState() } - if (relation.notifying != null) + if (relation.notifying != null) { subscribing = relation.notifying - else if (relation.subscribing != null) + } else if (relation.subscribing != null) { subscribing = relation.subscribing + } } // remove the listener so it doesn't fire on non-user changes @@ -717,7 +723,6 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide invalidateOptionsMenu() if (loadedAccount?.moved == null) { - binding.accountFollowButton.show() updateFollowButton() updateSubscribeButton() @@ -750,7 +755,6 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide } if (!viewModel.isSelf) { - val block = menu.findItem(R.id.action_block) block.title = if (blocking) { getString(R.string.action_unblock) @@ -908,7 +912,8 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide R.id.action_open_as -> { loadedAccount?.let { loadedAccount -> showAccountChooserDialog( - item.title, false, + item.title, + false, object : AccountSelectionListener { override fun onAccountSelected(account: AccountEntity) { openAsAccount(loadedAccount.url, account) @@ -979,7 +984,9 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide override fun getActionButton(): FloatingActionButton? { return if (!blocking) { binding.accountFloatingActionButton - } else null + } else { + null + } } private fun getFullUsername(account: Account): String { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/account/AccountViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/account/AccountViewModel.kt index 1b4aa7f020..3b85915fa9 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/account/AccountViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/account/AccountViewModel.kt @@ -80,7 +80,6 @@ class AccountViewModel @Inject constructor( private fun obtainRelationship(reload: Boolean = false) { if (relationshipData.value == null || reload) { - relationshipData.postValue(Loading()) mastodonApi.relationships(listOf(accountId)) @@ -209,14 +208,18 @@ class AccountViewModel @Inject constructor( RelationShipAction.MUTE -> relation.copy(muting = true) RelationShipAction.UNMUTE -> relation.copy(muting = false) RelationShipAction.SUBSCRIBE -> { - if (isMastodon) + if (isMastodon) { relation.copy(notifying = true) - else relation.copy(subscribing = true) + } else { + relation.copy(subscribing = true) + } } RelationShipAction.UNSUBSCRIBE -> { - if (isMastodon) + if (isMastodon) { relation.copy(notifying = false) - else relation.copy(subscribing = false) + } else { + relation.copy(subscribing = false) + } } } relationshipData.postValue(Loading(newRelation)) @@ -238,14 +241,18 @@ class AccountViewModel @Inject constructor( ) RelationShipAction.UNMUTE -> mastodonApi.unmuteAccount(accountId) RelationShipAction.SUBSCRIBE -> { - if (isMastodon) + if (isMastodon) { mastodonApi.followAccount(accountId, notify = true) - else mastodonApi.subscribeAccount(accountId) + } else { + mastodonApi.subscribeAccount(accountId) + } } RelationShipAction.UNSUBSCRIBE -> { - if (isMastodon) + if (isMastodon) { mastodonApi.followAccount(accountId, notify = false) - else mastodonApi.unsubscribeAccount(accountId) + } else { + mastodonApi.unsubscribeAccount(accountId) + } } } @@ -294,12 +301,14 @@ class AccountViewModel @Inject constructor( } private fun reload(isReload: Boolean = false) { - if (isDataLoading) + if (isDataLoading) { return + } accountId.let { obtainAccount(isReload) - if (!isSelf) + if (!isSelf) { obtainRelationship(isReload) + } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/account/list/ListsForAccountFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/account/list/ListsForAccountFragment.kt index 874d4b9f20..d527f613da 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/account/list/ListsForAccountFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/account/list/ListsForAccountFragment.kt @@ -65,7 +65,7 @@ class ListsForAccountFragment : DialogFragment(), Injectable { dialog?.apply { window?.setLayout( LinearLayout.LayoutParams.MATCH_PARENT, - LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.MATCH_PARENT ) } } @@ -172,7 +172,7 @@ class ListsForAccountFragment : DialogFragment(), Injectable { ListAdapter>(Differ) { override fun onCreateViewHolder( parent: ViewGroup, - viewType: Int, + viewType: Int ): BindingHolder { val binding = ItemAddOrRemoveFromListBinding.inflate(LayoutInflater.from(parent.context), parent, false) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/account/list/ListsForAccountViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/account/list/ListsForAccountViewModel.kt index b571390e5f..110096966f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/account/list/ListsForAccountViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/account/list/ListsForAccountViewModel.kt @@ -35,23 +35,23 @@ import javax.inject.Inject data class AccountListState( val list: MastoList, - val includesAccount: Boolean, + val includesAccount: Boolean ) data class ActionError( val error: Throwable, val type: Type, - val listId: String, + val listId: String ) : Throwable(error) { enum class Type { ADD, - REMOVE, + REMOVE } } @OptIn(ExperimentalCoroutinesApi::class) class ListsForAccountViewModel @Inject constructor( - private val mastodonApi: MastodonApi, + private val mastodonApi: MastodonApi ) : ViewModel() { private lateinit var accountId: String @@ -75,14 +75,14 @@ class ListsForAccountViewModel @Inject constructor( runCatching { val (all, includes) = listOf( async { mastodonApi.getLists() }, - async { mastodonApi.getListsIncludesAccount(accountId) }, + async { mastodonApi.getListsIncludesAccount(accountId) } ).awaitAll() _states.emit( all.getOrThrow().map { list -> AccountListState( list = list, - includesAccount = includes.getOrThrow().any { it.id == list.id }, + includesAccount = includes.getOrThrow().any { it.id == list.id } ) } ) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaPagingSource.kt b/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaPagingSource.kt index 60c7674365..0ed67cf455 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaPagingSource.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaPagingSource.kt @@ -26,7 +26,6 @@ class AccountMediaPagingSource( override fun getRefreshKey(state: PagingState): String? = null override suspend fun load(params: LoadParams): LoadResult { - return if (params is LoadParams.Refresh) { val list = viewModel.attachmentData.toList() LoadResult.Page(list, null, list.lastOrNull()?.statusId) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaRemoteMediator.kt b/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaRemoteMediator.kt index 81865b0fef..315b0380cf 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaRemoteMediator.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaRemoteMediator.kt @@ -34,7 +34,6 @@ class AccountMediaRemoteMediator( loadType: LoadType, state: PagingState ): MediatorResult { - try { val statusResponse = when (loadType) { LoadType.REFRESH -> { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListFragment.kt index 2649cbfe61..eab95b13a5 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListFragment.kt @@ -63,6 +63,7 @@ class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountAct @Inject lateinit var api: MastodonApi + @Inject lateinit var accountManager: AccountManager @@ -83,7 +84,6 @@ class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountAct } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - binding.recyclerView.setHasFixedSize(true) val layoutManager = LinearLayoutManager(view.context) binding.recyclerView.layoutManager = layoutManager @@ -227,7 +227,6 @@ class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountAct accountId: String, position: Int ) { - if (accept) { api.authorizeFollowRequest(accountId) } else { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/AccountAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/AccountAdapter.kt index 55a37cfb45..7d050e7e4f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/AccountAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/AccountAdapter.kt @@ -61,7 +61,7 @@ abstract class AccountAdapter internal constructo } private fun createFooterViewHolder( - parent: ViewGroup, + parent: ViewGroup ): RecyclerView.ViewHolder { val binding = ItemFooterBinding.inflate(LayoutInflater.from(parent.context), parent, false) return BindingHolder(binding) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/BlocksAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/BlocksAdapter.kt index f769af344f..2ef520d5ed 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/BlocksAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/BlocksAdapter.kt @@ -30,7 +30,7 @@ class BlocksAdapter( accountActionListener: AccountActionListener, animateAvatar: Boolean, animateEmojis: Boolean, - showBotOverlay: Boolean, + showBotOverlay: Boolean ) : AccountAdapter>( accountActionListener = accountActionListener, animateAvatar = animateAvatar, diff --git a/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/FollowRequestsAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/FollowRequestsAdapter.kt index ab20d748a3..a6bde57b9b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/FollowRequestsAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/FollowRequestsAdapter.kt @@ -36,7 +36,9 @@ class FollowRequestsAdapter( override fun createAccountViewHolder(parent: ViewGroup): FollowRequestViewHolder { val binding = ItemFollowRequestBinding.inflate( - LayoutInflater.from(parent.context), parent, false + LayoutInflater.from(parent.context), + parent, + false ) return FollowRequestViewHolder( binding, diff --git a/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementsViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementsViewModel.kt index c7e6781a10..8abad91ac7 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementsViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementsViewModel.kt @@ -107,8 +107,7 @@ class AnnouncementsViewModel @Inject constructor( } else { listOf( *announcement.reactions.toTypedArray(), - emojis.value!!.find { emoji -> emoji.shortcode == name } - !!.run { + emojis.value!!.find { emoji -> emoji.shortcode == name }!!.run { Announcement.Reaction( name, 1, diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt index 55ea6b1550..08687e486b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt @@ -809,25 +809,26 @@ class ComposeActivity : } private fun onMediaPick() { - addMediaBehavior.addBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() { - override fun onStateChanged(bottomSheet: View, newState: Int) { - // Wait until bottom sheet is not collapsed and show next screen after - if (newState == BottomSheetBehavior.STATE_COLLAPSED) { - addMediaBehavior.removeBottomSheetCallback(this) - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q && ContextCompat.checkSelfPermission(this@ComposeActivity, Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) { - ActivityCompat.requestPermissions( - this@ComposeActivity, - arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE), - PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE - ) - } else { - pickMediaFile.launch(true) + addMediaBehavior.addBottomSheetCallback( + object : BottomSheetBehavior.BottomSheetCallback() { + override fun onStateChanged(bottomSheet: View, newState: Int) { + // Wait until bottom sheet is not collapsed and show next screen after + if (newState == BottomSheetBehavior.STATE_COLLAPSED) { + addMediaBehavior.removeBottomSheetCallback(this) + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q && ContextCompat.checkSelfPermission(this@ComposeActivity, Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) { + ActivityCompat.requestPermissions( + this@ComposeActivity, + arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE), + PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE + ) + } else { + pickMediaFile.launch(true) + } } } - } - override fun onSlide(bottomSheet: View, slideOffset: Float) {} - } + override fun onSlide(bottomSheet: View, slideOffset: Float) {} + } ) addMediaBehavior.state = BottomSheetBehavior.STATE_COLLAPSED } @@ -959,7 +960,6 @@ class ComposeActivity : binding.composeEditField.error = getString(R.string.error_empty) enableButtons(true, viewModel.editing) } else if (characterCount <= maximumTootCharacters) { - lifecycleScope.launch { viewModel.sendStatus(contentText, spoilerText) deleteDraftAndFinish() @@ -978,7 +978,8 @@ class ComposeActivity : pickMediaFile.launch(true) } else { Snackbar.make( - binding.activityCompose, R.string.error_media_upload_permission, + binding.activityCompose, + R.string.error_media_upload_permission, Snackbar.LENGTH_SHORT ).apply { setAction(R.string.action_retry) { onMediaPick() } @@ -1012,9 +1013,13 @@ class ComposeActivity : private fun enableButton(button: ImageButton, clickable: Boolean, colorActive: Boolean) { button.isEnabled = clickable setDrawableTint( - this, button.drawable, - if (colorActive) android.R.attr.textColorTertiary - else R.attr.textColorDisabled + this, + button.drawable, + if (colorActive) { + android.R.attr.textColorTertiary + } else { + R.attr.textColorDisabled + } ) } @@ -1022,8 +1027,11 @@ class ComposeActivity : binding.addPollTextActionTextView.isEnabled = enable val textColor = MaterialColors.getColor( binding.addPollTextActionTextView, - if (enable) android.R.attr.textColorTertiary - else R.attr.textColorDisabled + if (enable) { + android.R.attr.textColorTertiary + } else { + R.attr.textColorDisabled + } ) binding.addPollTextActionTextView.setTextColor(textColor) binding.addPollTextActionTextView.compoundDrawablesRelative[0].colorFilter = PorterDuffColorFilter(textColor, PorterDuff.Mode.SRC_IN) @@ -1199,8 +1207,11 @@ class ComposeActivity : lifecycleScope.launch { val dialog = if (viewModel.shouldShowSaveDraftDialog()) { ProgressDialog.show( - this@ComposeActivity, null, - getString(R.string.saving_draft), true, false + this@ComposeActivity, + null, + getString(R.string.saving_draft), + true, + false ) } else { null diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeViewModel.kt index 81304c4121..40b15bc787 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeViewModel.kt @@ -274,7 +274,7 @@ class ComposeViewModel @Inject constructor( failedToSendAlert = false, scheduledAt = scheduledAt.value, language = postLanguage, - statusId = originalStatusId, + statusId = originalStatusId ) } @@ -286,7 +286,6 @@ class ComposeViewModel @Inject constructor( content: String, spoilerText: String ) { - if (!scheduledTootId.isNullOrEmpty()) { api.deleteScheduledStatus(scheduledTootId!!) } @@ -405,7 +404,6 @@ class ComposeViewModel @Inject constructor( } fun setup(composeOptions: ComposeActivity.ComposeOptions?) { - if (setupComplete) { return } @@ -440,14 +438,16 @@ class ComposeViewModel @Inject constructor( pickMedia(attachment.uri, attachment.description, attachment.focus) } } - } else composeOptions?.mediaAttachments?.forEach { a -> - // when coming from redraft or ScheduledTootActivity - val mediaType = when (a.type) { - Attachment.Type.VIDEO, Attachment.Type.GIFV -> QueuedMedia.Type.VIDEO - Attachment.Type.UNKNOWN, Attachment.Type.IMAGE -> QueuedMedia.Type.IMAGE - Attachment.Type.AUDIO -> QueuedMedia.Type.AUDIO + } else { + composeOptions?.mediaAttachments?.forEach { a -> + // when coming from redraft or ScheduledTootActivity + val mediaType = when (a.type) { + Attachment.Type.VIDEO, Attachment.Type.GIFV -> QueuedMedia.Type.VIDEO + Attachment.Type.UNKNOWN, Attachment.Type.IMAGE -> QueuedMedia.Type.IMAGE + Attachment.Type.AUDIO -> QueuedMedia.Type.AUDIO + } + addUploadedMedia(a.id, mediaType, a.url.toUri(), a.description, a.meta?.focus) } - addUploadedMedia(a.id, mediaType, a.url.toUri(), a.description, a.meta?.focus) } draftId = composeOptions?.draftId ?: 0 diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/ImageDownsizer.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/ImageDownsizer.kt index 4976ae0c50..39b444688f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/ImageDownsizer.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/ImageDownsizer.kt @@ -41,7 +41,6 @@ fun downsizeImage( contentResolver: ContentResolver, tempFile: File ): Boolean { - val decodeBoundsInputStream = try { contentResolver.openInputStream(uri) } catch (e: FileNotFoundException) { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaPreviewAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaPreviewAdapter.kt index 16daa6dc4e..6cd590d7e0 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaPreviewAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaPreviewAdapter.kt @@ -90,10 +90,11 @@ class MediaPreviewAdapter( val imageView = holder.progressImageView val focus = item.focus - if (focus != null) + if (focus != null) { imageView.setFocalPoint(focus) - else + } else { imageView.removeFocalPoint() // Probably unnecessary since we have no UI for removal once added. + } var glide = Glide.with(holder.itemView.context) .load(item.uri) @@ -101,8 +102,9 @@ class MediaPreviewAdapter( .dontAnimate() .centerInside() - if (focus != null) + if (focus != null) { glide = glide.addListener(imageView) + } glide.into(imageView) } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaUploader.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaUploader.kt index eac6d93fa3..58c3cf2a05 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaUploader.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaUploader.kt @@ -159,7 +159,6 @@ class MediaUploader @Inject constructor( try { when (inUri.scheme) { ContentResolver.SCHEME_CONTENT -> { - mimeType = contentResolver.getType(uri) val suffix = "." + MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType ?: "tmp") @@ -278,7 +277,8 @@ class MediaUploader @Inject constructor( var lastProgress = -1 val fileBody = ProgressRequestBody( - stream!!, media.mediaSize, + stream!!, + media.mediaSize, mimeType.toMediaTypeOrNull()!! ) { percentage -> if (percentage != lastProgress) { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/AddPollDialog.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/AddPollDialog.kt index 4d7c0f818a..35dc7a192d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/AddPollDialog.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/AddPollDialog.kt @@ -35,7 +35,6 @@ fun showAddPollDialog( maxDuration: Int, onUpdatePoll: (NewPoll) -> Unit ) { - val binding = DialogAddPollBinding.inflate(LayoutInflater.from(context)) val dialog = AlertDialog.Builder(context) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/CaptionDialog.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/CaptionDialog.kt index d5ece95f4f..3234f4be3c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/CaptionDialog.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/CaptionDialog.kt @@ -67,7 +67,8 @@ class CaptionDialog : DialogFragment() { input = EditText(context) input.hint = resources.getQuantityString( R.plurals.hint_describe_for_visually_impaired, - MEDIA_DESCRIPTION_CHARACTER_LIMIT, MEDIA_DESCRIPTION_CHARACTER_LIMIT + MEDIA_DESCRIPTION_CHARACTER_LIMIT, + MEDIA_DESCRIPTION_CHARACTER_LIMIT ) dialogLayout.addView(input) (input.layoutParams as LinearLayout.LayoutParams).setMargins(margin, margin, margin, margin) @@ -105,7 +106,7 @@ class CaptionDialog : DialogFragment() { override fun onResourceReady( resource: Drawable, - transition: Transition?, + transition: Transition? ) { imageView.setImageDrawable(resource) } @@ -122,7 +123,7 @@ class CaptionDialog : DialogFragment() { override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle?, + savedInstanceState: Bundle? ): View? { savedInstanceState?.getString(DESCRIPTION_KEY)?.let { input.setText(it) @@ -143,12 +144,12 @@ class CaptionDialog : DialogFragment() { fun newInstance( localId: Int, existingDescription: String?, - previewUri: Uri, + previewUri: Uri ) = CaptionDialog().apply { arguments = bundleOf( LOCAL_ID_ARG to localId, EXISTING_DESCRIPTION_ARG to existingDescription, - PREVIEW_URI_ARG to previewUri, + PREVIEW_URI_ARG to previewUri ) } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/view/FocusIndicatorView.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/view/FocusIndicatorView.kt index 477c015c82..6e0b83dc59 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/view/FocusIndicatorView.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/view/FocusIndicatorView.kt @@ -27,14 +27,16 @@ class FocusIndicatorView fun setImageSize(width: Int, height: Int) { this.imageSize = Point(width, height) - if (focus != null) + if (focus != null) { invalidate() + } } fun setFocus(focus: Attachment.Focus) { this.focus = focus - if (imageSize != null) + if (imageSize != null) { invalidate() + } } // Assumes setFocus called first @@ -46,8 +48,9 @@ class FocusIndicatorView // so base it on the view width/height whenever the first access occurs. private fun getCircleRadius(): Float { val circleRadius = this.circleRadius - if (circleRadius != null) + if (circleRadius != null) { return circleRadius + } val newCircleRadius = min(this.width, this.height).toFloat() / 4.0f this.circleRadius = newCircleRadius return newCircleRadius @@ -67,8 +70,9 @@ class FocusIndicatorView @SuppressLint("ClickableViewAccessibility") // Android Studio wants us to implement PerformClick for accessibility, but that unfortunately cannot be made meaningful for this widget. override fun onTouchEvent(event: MotionEvent): Boolean { - if (event.actionMasked == MotionEvent.ACTION_CANCEL) + if (event.actionMasked == MotionEvent.ACTION_CANCEL) { return false + } val imageSize = this.imageSize ?: return false diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/view/TootButton.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/view/TootButton.kt index 24f4130b2a..43e8f6ef99 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/view/TootButton.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/view/TootButton.kt @@ -48,7 +48,6 @@ class TootButton fun setStatusVisibility(visibility: Status.Visibility) { if (!smallStyle) { - icon = when (visibility) { Status.Visibility.PUBLIC -> { setText(R.string.action_send_public) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationEntity.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationEntity.kt index 876a71ef25..dae0f1631e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationEntity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationEntity.kt @@ -66,7 +66,7 @@ data class ConversationAccountEntity( displayName = displayName, url = "", avatar = avatar, - emojis = emojis, + emojis = emojis ) } } @@ -96,7 +96,7 @@ data class ConversationStatusEntity( val collapsed: Boolean, val muted: Boolean, val poll: Poll?, - val language: String?, + val language: String? ) { fun toViewData(): StatusViewData.Concrete { @@ -130,11 +130,11 @@ data class ConversationStatusEntity( poll = poll, card = null, language = language, - filtered = null, + filtered = null ), isExpanded = expanded, isShowingContent = showingHiddenContent, - isCollapsed = collapsed, + isCollapsed = collapsed ) } } @@ -178,7 +178,7 @@ fun Status.toEntity( collapsed = contentCollapsed, muted = muted ?: false, poll = poll, - language = language, + language = language ) fun Conversation.toEntity( diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewData.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewData.kt index 04e69d4c40..b197084df7 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewData.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewData.kt @@ -87,6 +87,6 @@ fun StatusViewData.Concrete.toConversationStatusEntity( collapsed = collapsed, muted = muted, poll = poll, - language = status.language, + language = status.language ) } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsRemoteMediator.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsRemoteMediator.kt index 921d694b24..b00c99a95e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsRemoteMediator.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsRemoteMediator.kt @@ -15,7 +15,7 @@ import retrofit2.HttpException class ConversationsRemoteMediator( private val api: MastodonApi, private val db: AppDatabase, - accountManager: AccountManager, + accountManager: AccountManager ) : RemoteMediator() { private var nextKey: String? = null @@ -28,7 +28,6 @@ class ConversationsRemoteMediator( loadType: LoadType, state: PagingState ): MediatorResult { - if (loadType == LoadType.PREPEND) { return MediatorResult.Success(endOfPaginationReached = true) } @@ -47,7 +46,6 @@ class ConversationsRemoteMediator( } db.withTransaction { - if (loadType == LoadType.REFRESH) { db.conversationDao().deleteForAccount(activeAccount.id) } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftHelper.kt b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftHelper.kt index b9757a8015..2dc802e6a4 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftHelper.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftHelper.kt @@ -66,7 +66,7 @@ class DraftHelper @Inject constructor( failedToSendAlert: Boolean, scheduledAt: String?, language: String?, - statusId: String?, + statusId: String? ) = withContext(Dispatchers.IO) { val externalFilesDir = context.getExternalFilesDir("Tusky") @@ -127,7 +127,7 @@ class DraftHelper @Inject constructor( failedToSendNew = failedToSendAlert, scheduledAt = scheduledAt, language = language, - statusId = statusId, + statusId = statusId ) draftDao.insertOrReplace(draft) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftMediaAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftMediaAdapter.kt index 98a288b426..2165f7e0c1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftMediaAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftMediaAdapter.kt @@ -51,18 +51,20 @@ class DraftMediaAdapter( holder.imageView.clearFocus() holder.imageView.setImageResource(R.drawable.ic_music_box_preview_24dp) } else { - if (attachment.focus != null) + if (attachment.focus != null) { holder.imageView.setFocalPoint(attachment.focus) - else + } else { holder.imageView.clearFocus() + } var glide = Glide.with(holder.itemView.context) .load(attachment.uri) .diskCacheStrategy(DiskCacheStrategy.NONE) .dontAnimate() .centerInside() - if (attachment.focus != null) + if (attachment.focus != null) { glide = glide.addListener(holder.imageView) + } glide.into(holder.imageView) } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsAdapter.kt index 18621fd3f3..1edf354d6f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsAdapter.kt @@ -48,7 +48,6 @@ class DraftsAdapter( ) { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder { - val binding = ItemDraftBinding.inflate(LayoutInflater.from(parent.context), parent, false) val viewHolder = BindingHolder(binding) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/filters/EditFilterActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/filters/EditFilterActivity.kt index 41cd2fed4f..a7570bc0a7 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/filters/EditFilterActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/filters/EditFilterActivity.kt @@ -55,7 +55,7 @@ class EditFilterActivity : BaseActivity() { filterContextNotifications to Filter.Kind.NOTIFICATIONS, filterContextPublic to Filter.Kind.PUBLIC, filterContextThread to Filter.Kind.THREAD, - filterContextAccount to Filter.Kind.ACCOUNT, + filterContextAccount to Filter.Kind.ACCOUNT ) } @@ -213,7 +213,7 @@ class EditFilterActivity : BaseActivity() { FilterKeyword( "", binding.phraseEditText.text.toString(), - binding.phraseWholeWord.isChecked, + binding.phraseWholeWord.isChecked ) ) } @@ -234,7 +234,7 @@ class EditFilterActivity : BaseActivity() { keyword, keyword.copy( keyword = binding.phraseEditText.text.toString(), - wholeWord = binding.phraseWholeWord.isChecked, + wholeWord = binding.phraseWholeWord.isChecked ) ) } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/filters/EditFilterViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/filters/EditFilterViewModel.kt index 07e6d25c8d..d33031d658 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/filters/EditFilterViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/filters/EditFilterViewModel.kt @@ -98,7 +98,7 @@ class EditFilterViewModel @Inject constructor(val api: MastodonApi, val eventHub title = title, context = contexts, filterAction = action, - expiresInSeconds = expiresInSeconds, + expiresInSeconds = expiresInSeconds ).fold( { newFilter -> // This is _terrible_, but the all-in-one update filter api Just Doesn't Work @@ -123,7 +123,7 @@ class EditFilterViewModel @Inject constructor(val api: MastodonApi, val eventHub title = title, context = contexts, filterAction = action, - expiresInSeconds = expiresInSeconds, + expiresInSeconds = expiresInSeconds ).fold( { // This is _terrible_, but the all-in-one update filter api Just Doesn't Work @@ -175,7 +175,7 @@ class EditFilterViewModel @Inject constructor(val api: MastodonApi, val eventHub context = context, irreversible = false, wholeWord = keyword.wholeWord, - expiresInSeconds = expiresInSeconds, + expiresInSeconds = expiresInSeconds ) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/filters/FiltersViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/filters/FiltersViewModel.kt index 7fb07a0759..f4791d1452 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/filters/FiltersViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/filters/FiltersViewModel.kt @@ -62,7 +62,7 @@ class FiltersViewModel @Inject constructor( }, { Snackbar.make(parent, "Error deleting filter '${filter.title}'", Snackbar.LENGTH_SHORT).show() - }, + } ) } else { Snackbar.make(parent, "Error deleting filter '${filter.title}'", Snackbar.LENGTH_SHORT).show() diff --git a/app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsAdapter.kt index 682d39a8ad..4cdc9f97af 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsAdapter.kt @@ -13,7 +13,7 @@ import com.keylesspalace.tusky.util.BindingHolder class FollowedTagsAdapter( private val actionListener: HashtagActionListener, - private val viewModel: FollowedTagsViewModel, + private val viewModel: FollowedTagsViewModel ) : PagingDataAdapter>(STRING_COMPARATOR) { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder = BindingHolder(ItemFollowedHashtagBinding.inflate(LayoutInflater.from(parent.context), parent, false)) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsRemoteMediator.kt b/app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsRemoteMediator.kt index 649ca583ef..00239a75f2 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsRemoteMediator.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsRemoteMediator.kt @@ -13,7 +13,7 @@ import retrofit2.Response @OptIn(ExperimentalPagingApi::class) class FollowedTagsRemoteMediator( private val api: MastodonApi, - private val viewModel: FollowedTagsViewModel, + private val viewModel: FollowedTagsViewModel ) : RemoteMediator() { override suspend fun load( loadType: LoadType, diff --git a/app/src/main/java/com/keylesspalace/tusky/components/instancemute/adapter/DomainMutesAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/instancemute/adapter/DomainMutesAdapter.kt index 509c9561db..13d8f2d83e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/instancemute/adapter/DomainMutesAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/instancemute/adapter/DomainMutesAdapter.kt @@ -30,8 +30,9 @@ class DomainMutesAdapter( override fun getItemCount(): Int { var count = instances.size - if (bottomLoading) + if (bottomLoading) { ++count + } return count } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/login/LoginActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/login/LoginActivity.kt index 443395be65..7e04768967 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/login/LoginActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/login/LoginActivity.kt @@ -99,7 +99,8 @@ class LoginActivity : BaseActivity(), Injectable { } preferences = getSharedPreferences( - getString(R.string.preferences_file_key), Context.MODE_PRIVATE + getString(R.string.preferences_file_key), + Context.MODE_PRIVATE ) binding.loginButton.setOnClickListener { onLoginClick(true) } @@ -168,8 +169,11 @@ class LoginActivity : BaseActivity(), Injectable { lifecycleScope.launch { mastodonApi.authenticateApp( - domain, getString(R.string.app_name), oauthRedirectUri, - OAUTH_SCOPES, getString(R.string.tusky_website) + domain, + getString(R.string.app_name), + oauthRedirectUri, + OAUTH_SCOPES, + getString(R.string.tusky_website) ).fold( { credentials -> // Before we open browser page we save the data. @@ -273,7 +277,12 @@ class LoginActivity : BaseActivity(), Injectable { setLoading(true) mastodonApi.fetchOAuthToken( - domain, clientId, clientSecret, oauthRedirectUri, code, "authorization_code" + domain, + clientId, + clientSecret, + oauthRedirectUri, + code, + "authorization_code" ).fold( { accessToken -> fetchAccountDetails(accessToken, domain, clientId, clientSecret) @@ -293,7 +302,6 @@ class LoginActivity : BaseActivity(), Injectable { clientId: String, clientSecret: String ) { - mastodonApi.accountVerifyCredentials( domain = domain, auth = "Bearer ${accessToken.accessToken}" @@ -349,6 +357,7 @@ class LoginActivity : BaseActivity(), Injectable { const val MODE_DEFAULT = 0 const val MODE_ADDITIONAL_LOGIN = 1 + // "Migration" is used to update the OAuth scope granted to the client const val MODE_MIGRATION = 2 diff --git a/app/src/main/java/com/keylesspalace/tusky/components/login/LoginWebViewActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/login/LoginWebViewActivity.kt index b69f81e76c..367eab5b87 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/login/LoginWebViewActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/login/LoginWebViewActivity.kt @@ -85,7 +85,7 @@ class OauthLogin : ActivityResultContract() { data class LoginData( val domain: String, val url: Uri, - val oauthRedirectUrl: Uri, + val oauthRedirectUrl: Uri ) : Parcelable sealed class LoginResult : Parcelable { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/FollowViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/FollowViewHolder.kt index ca19455bac..80cfeded9e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/FollowViewHolder.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/FollowViewHolder.kt @@ -30,7 +30,7 @@ import com.keylesspalace.tusky.viewdata.NotificationViewData class FollowViewHolder( private val binding: ItemFollowBinding, - private val notificationActionListener: NotificationActionListener, + private val notificationActionListener: NotificationActionListener ) : NotificationsPagingAdapter.ViewHolder, RecyclerView.ViewHolder(binding.root) { private val avatarRadius42dp = itemView.context.resources.getDimensionPixelSize( R.dimen.avatar_radius_42dp diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/PushNotificationHelper.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/PushNotificationHelper.kt index 6745579ed3..c06d659d0f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/PushNotificationHelper.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/PushNotificationHelper.kt @@ -14,6 +14,7 @@ * see . */ @file:JvmName("PushNotificationHelper") + package com.keylesspalace.tusky.components.notifications import android.app.NotificationManager @@ -163,7 +164,6 @@ suspend fun registerUnifiedPushEndpoint( account: AccountEntity, endpoint: String ) = withContext(Dispatchers.IO) { - // Generate a prime256v1 key pair for WebPush // Decryption is unimplemented for now, since Mastodon uses an old WebPush // standard which does not send needed information for decryption in the payload @@ -173,8 +173,11 @@ suspend fun registerUnifiedPushEndpoint( val auth = CryptoUtil.secureRandomBytesEncoded(16) api.subscribePushNotifications( - "Bearer ${account.accessToken}", account.domain, - endpoint, keyPair.pubkey, auth, + "Bearer ${account.accessToken}", + account.domain, + endpoint, + keyPair.pubkey, + auth, buildSubscriptionData(context, account) ).onFailure { throwable -> Log.w(TAG, "Error setting push endpoint for account ${account.id}", throwable) @@ -195,7 +198,8 @@ suspend fun registerUnifiedPushEndpoint( suspend fun updateUnifiedPushSubscription(context: Context, api: MastodonApi, accountManager: AccountManager, account: AccountEntity) { withContext(Dispatchers.IO) { api.updatePushNotificationSubscription( - "Bearer ${account.accessToken}", account.domain, + "Bearer ${account.accessToken}", + account.domain, buildSubscriptionData(context, account) ).onSuccess { Log.d(TAG, "UnifiedPush subscription updated for account ${account.id}") diff --git a/app/src/main/java/com/keylesspalace/tusky/components/preference/AccountPreferencesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/preference/AccountPreferencesFragment.kt index fa1d7392f6..d776ec01f2 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/preference/AccountPreferencesFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/preference/AccountPreferencesFragment.kt @@ -300,7 +300,6 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable { override fun onResponse(call: Call, response: Response) { val account = response.body() if (response.isSuccessful && account != null) { - accountManager.activeAccount?.let { it.defaultPostPrivacy = account.source?.privacy ?: Status.Visibility.PUBLIC diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusViewHolder.kt index 4212046ad6..0ee7ec5db6 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusViewHolder.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusViewHolder.kt @@ -85,8 +85,11 @@ class StatusViewHolder( val sensitive = viewData.status.sensitive statusViewHelper.setMediasPreview( - statusDisplayOptions, viewData.status.attachments, - sensitive, previewListener, viewState.isMediaShow(viewData.id, viewData.status.sensitive), + statusDisplayOptions, + viewData.status.attachments, + sensitive, + previewListener, + viewState.isMediaShow(viewData.id, viewData.status.sensitive), mediaViewHeight ) @@ -97,8 +100,10 @@ class StatusViewHolder( private fun updateTextView() { viewdata()?.let { viewdata -> setupCollapsedState( - shouldTrimStatus(viewdata.content), viewState.isCollapsed(viewdata.id, true), - viewState.isContentShow(viewdata.id, viewdata.status.sensitive), viewdata.spoilerText + shouldTrimStatus(viewdata.content), + viewState.isCollapsed(viewdata.id, true), + viewState.isContentShow(viewdata.id, viewdata.status.sensitive), + viewdata.spoilerText ) if (viewdata.spoilerText.isBlank()) { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesAdapter.kt index 314513eb90..7e2c24174d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesAdapter.kt @@ -38,7 +38,10 @@ class StatusesAdapter( override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): StatusViewHolder { val binding = ItemReportStatusBinding.inflate(LayoutInflater.from(parent.context), parent, false) return StatusViewHolder( - binding, statusDisplayOptions, statusViewState, adapterHandler, + binding, + statusDisplayOptions, + statusViewState, + adapterHandler, statusForPosition ) } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportNoteFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportNoteFragment.kt index 74b40fb935..d77685e64f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportNoteFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportNoteFragment.kt @@ -72,8 +72,9 @@ class ReportNoteFragment : Fragment(R.layout.fragment_report_note), Injectable { binding.reportDescriptionRemoteInstance.hide() } - if (viewModel.isRemoteAccount) + if (viewModel.isRemoteAccount) { binding.checkIsNotifyRemote.text = getString(R.string.report_remote_instance, viewModel.remoteServer) + } binding.checkIsNotifyRemote.isChecked = viewModel.isRemoteNotify } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/SearchActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/SearchActivity.kt index ca60e95c4c..176fb8079d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/SearchActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/SearchActivity.kt @@ -75,7 +75,7 @@ class SearchActivity : BottomSheetActivity(), HasAndroidInjector, MenuProvider { binding.pages.isUserInputEnabled = enableSwipeForTabs TabLayoutMediator(binding.tabs, binding.pages) { - tab, position -> + tab, position -> tab.text = getPageTitle(position) }.attach() } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchPagingSource.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchPagingSource.kt index 5ced44037f..10339536a2 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchPagingSource.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchPagingSource.kt @@ -54,7 +54,6 @@ class SearchPagingSource( val currentKey = params.key ?: 0 try { - val data = mastodonApi.searchObservable( query = searchRequest, type = searchType.apiParameter, diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt index 23ebbef602..3ee1030555 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt @@ -133,7 +133,8 @@ class SearchStatusesFragment : SearchFragment(), Status Attachment.Type.GIFV, Attachment.Type.VIDEO, Attachment.Type.IMAGE, Attachment.Type.AUDIO -> { val attachments = AttachmentViewData.list(actionable) val intent = ViewMediaActivity.newIntent( - context, attachments, + context, + attachments, attachmentIndex ) if (view != null) { @@ -141,7 +142,8 @@ class SearchStatusesFragment : SearchFragment(), Status ViewCompat.setTransitionName(view, url) val options = ActivityOptionsCompat.makeSceneTransitionAnimation( requireActivity(), - view, url + view, + url ) startActivity(intent, options.toBundle()) } else { @@ -399,7 +401,8 @@ class SearchStatusesFragment : SearchFragment(), Status private fun showOpenAsDialog(statusUrl: String, dialogTitle: CharSequence?) { bottomSheetActivity?.showAccountChooserDialog( - dialogTitle, false, + dialogTitle, + false, object : AccountSelectionListener { override fun onAccountSelected(account: AccountEntity) { bottomSheetActivity?.openAsAccount(statusUrl, account) @@ -515,7 +518,7 @@ class SearchStatusesFragment : SearchFragment(), Status language = status.language, statusId = source.id, poll = status.poll?.toNewPoll(status.createdAt), - kind = ComposeActivity.ComposeKind.EDIT_POSTED, + kind = ComposeActivity.ComposeKind.EDIT_POSTED ) startActivity(ComposeActivity.startIntent(requireContext(), composeOptions)) }, diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt index 96fe1594d0..49db550819 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt @@ -217,7 +217,9 @@ class TimelineFragment : if (getView() != null) { if (isSwipeToRefreshEnabled) { binding.recyclerView.scrollBy(0, Utils.dpToPx(requireContext(), -30)) - } else binding.recyclerView.scrollToPosition(0) + } else { + binding.recyclerView.scrollToPosition(0) + } } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelinePagingAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelinePagingAdapter.kt index 09557a50ea..a232989b3a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelinePagingAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelinePagingAdapter.kt @@ -131,8 +131,10 @@ class TimelinePagingAdapter( return if (oldItem == newItem) { // If items are equal - update timestamp only listOf(StatusBaseViewHolder.Key.KEY_CREATED) - } else // If items are different - update the whole view holder + } else { + // If items are different - update the whole view holder null + } } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineTypeMappers.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineTypeMappers.kt index d154caaab5..04be236eed 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineTypeMappers.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineTypeMappers.kt @@ -105,7 +105,7 @@ fun Placeholder.toEntity(timelineUserId: Long): TimelineStatusEntity { card = null, repliesCount = 0, language = null, - filtered = null, + filtered = null ) } @@ -150,7 +150,7 @@ fun Status.toEntity( card = actionableStatus.card?.let(gson::toJson), repliesCount = actionableStatus.repliesCount, language = actionableStatus.language, - filtered = actionableStatus.filtered, + filtered = actionableStatus.filtered ) } @@ -198,7 +198,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson, isDetailed: Boolean = false card = card, repliesCount = status.repliesCount, language = status.language, - filtered = status.filtered, + filtered = status.filtered ) } val status = if (reblog != null) { @@ -231,7 +231,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson, isDetailed: Boolean = false card = null, repliesCount = status.repliesCount, language = status.language, - filtered = status.filtered, + filtered = status.filtered ) } else { Status( @@ -263,7 +263,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson, isDetailed: Boolean = false card = card, repliesCount = status.repliesCount, language = status.language, - filtered = status.filtered, + filtered = status.filtered ) } return StatusViewData.Concrete( diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt index a15577646b..89afefecda 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt @@ -49,7 +49,6 @@ class CachedTimelineRemoteMediator( loadType: LoadType, state: PagingState ): MediatorResult { - if (!activeAccount.isLoggedIn()) { return MediatorResult.Success(endOfPaginationReached = true) } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt index ebc00ffbd3..c0c792704d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt @@ -204,7 +204,6 @@ class CachedTimelineViewModel @Inject constructor( } db.withTransaction { - timelineDao.delete(activeAccount.id, placeholderId) val overlappedStatuses = if (statuses.isNotEmpty()) { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelinePagingSource.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelinePagingSource.kt index c1dac5bc44..a9c49f7a7f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelinePagingSource.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelinePagingSource.kt @@ -45,13 +45,16 @@ sealed class TimelineKind : Parcelable { object PublicFederated : TimelineKind() object PublicLocal : TimelineKind() data class Tag(val tags: List) : TimelineKind() + /** Any timeline showing statuses from a single user */ @Parcelize sealed class User(open val id: String) : TimelineKind() { /** Timeline showing just the user's statuses (no replies) */ data class Posts(override val id: String) : User(id) + /** Timeline showing the user's pinned statuses */ data class Pinned(override val id: String) : User(id) + /** Timeline showing the user's top-level statuses and replies they have made */ data class Replies(override val id: String) : User(id) } @@ -75,11 +78,11 @@ class NetworkTimelinePagingSource @Inject constructor( } is LoadParams.Append -> fetchStatusesForKind( maxId = params.key, - limit = params.loadSize, + limit = params.loadSize ) is LoadParams.Prepend -> fetchStatusesForKind( minId = params.key, - limit = params.loadSize, + limit = params.loadSize ) } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt index b83a9b0d33..bbbbda2f35 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt @@ -75,7 +75,7 @@ data class UiState( val showFabWhileScrolling: Boolean = true, /** True if media previews should be shown */ - val showMediaPreview: Boolean = true, + val showMediaPreview: Boolean = true ) /** Preferences the UI reacts to */ @@ -547,7 +547,7 @@ abstract class TimelineViewModel( } else { Log.e(TAG, "Error getting filters", throwable) } - }, + } ) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingAdapter.kt index 3e6f03416a..6f405e2f42 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingAdapter.kt @@ -28,7 +28,7 @@ import com.keylesspalace.tusky.interfaces.LinkListener import com.keylesspalace.tusky.viewdata.TrendingViewData class TrendingAdapter( - private val trendingListener: LinkListener, + private val trendingListener: LinkListener ) : ListAdapter(TrendingDifferCallback) { init { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingFragment.kt index 41e404a90d..4148a10869 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingFragment.kt @@ -95,7 +95,7 @@ class TrendingFragment : super.onCreate(savedInstanceState) adapter = TrendingAdapter( - this, + this ) } @@ -208,7 +208,8 @@ class TrendingFragment : binding.recyclerView.hide() binding.messageView.show() binding.messageView.setup( - R.drawable.elephant_friend_empty, R.string.message_empty, + R.drawable.elephant_friend_empty, + R.string.message_empty, null ) } else { @@ -242,7 +243,7 @@ class TrendingFragment : binding.swipeRefreshLayout.isRefreshing = false binding.messageView.setup( R.drawable.elephant_offline, - R.string.error_network, + R.string.error_network ) { refreshContent() } } @@ -254,7 +255,7 @@ class TrendingFragment : binding.swipeRefreshLayout.isRefreshing = false binding.messageView.setup( R.drawable.elephant_error, - R.string.error_generic, + R.string.error_generic ) { refreshContent() } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ThreadAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ThreadAdapter.kt index 3abd47d015..1edffcaf90 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ThreadAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ThreadAdapter.kt @@ -93,8 +93,10 @@ class ThreadAdapter( return if (oldItem == newItem) { // If items are equal - update timestamp only listOf(StatusBaseViewHolder.Key.KEY_CREATED) - } else // If items are different - update the whole view holder + } else { + // If items are different - update the whole view holder null + } } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadViewModel.kt index 3279310265..7a60c59f55 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadViewModel.kt @@ -167,7 +167,7 @@ class ViewThreadViewModel @Inject constructor( _uiState.value = ThreadUiState.Success( statusViewData = listOf(detailedStatus), detailedStatusPosition = 0, - revealButton = RevealButtonState.NO_BUTTON, + revealButton = RevealButtonState.NO_BUTTON ) }) } diff --git a/app/src/main/java/com/keylesspalace/tusky/db/AccountEntity.kt b/app/src/main/java/com/keylesspalace/tusky/db/AccountEntity.kt index 418c77e327..333920c6c2 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/AccountEntity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/AccountEntity.kt @@ -82,7 +82,7 @@ data class AccountEntity( var pushPubKey: String = "", var pushPrivKey: String = "", var pushAuth: String = "", - var pushServerKey: String = "", + var pushServerKey: String = "" ) { val identifier: String diff --git a/app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt b/app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt index eaa36bb724..46805861b1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt @@ -66,7 +66,6 @@ class AccountManager @Inject constructor(db: AppDatabase) { oauthScopes: String, newAccount: Account ) { - activeAccount?.let { it.isActive = false Log.d(TAG, "addAccount: saving account with id " + it.id) @@ -121,7 +120,6 @@ class AccountManager @Inject constructor(db: AppDatabase) { * @return the new active account, or null if no other account was found */ fun logActiveAccountOut(): AccountEntity? { - return activeAccount?.let { account -> account.logout() @@ -167,7 +165,6 @@ class AccountManager @Inject constructor(db: AppDatabase) { * @param accountId the database id of the new active account */ fun setActiveAccount(accountId: Long) { - val newActiveAccount = accounts.find { (id) -> id == accountId } ?: return // invalid accountId passed, do nothing @@ -237,10 +234,12 @@ class AccountManager @Inject constructor(db: AppDatabase) { fun shouldDisplaySelfUsername(context: Context): Boolean { val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context) val showUsernamePreference = sharedPreferences.getString(PrefKeys.SHOW_SELF_USERNAME, "disambiguate") - if (showUsernamePreference == "always") + if (showUsernamePreference == "always") { return true - if (showUsernamePreference == "never") + } + if (showUsernamePreference == "never") { return false + } return accounts.size > 1 // "disambiguate" } diff --git a/app/src/main/java/com/keylesspalace/tusky/db/DraftEntity.kt b/app/src/main/java/com/keylesspalace/tusky/db/DraftEntity.kt index 0b38385ae2..2a479ab34f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/DraftEntity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/DraftEntity.kt @@ -43,7 +43,7 @@ data class DraftEntity( val failedToSendNew: Boolean, val scheduledAt: String?, val language: String?, - val statusId: String?, + val statusId: String? ) /** diff --git a/app/src/main/java/com/keylesspalace/tusky/db/TimelineStatusEntity.kt b/app/src/main/java/com/keylesspalace/tusky/db/TimelineStatusEntity.kt index 0b5fcfb1ad..f0f7f98ed6 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/TimelineStatusEntity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/TimelineStatusEntity.kt @@ -85,7 +85,7 @@ data class TimelineStatusEntity( val pinned: Boolean, val card: String?, val language: String?, - val filtered: List?, + val filtered: List? ) { val isPlaceholder: Boolean get() = this.authorServerId == null diff --git a/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt index 758f8d24c4..bc2c7d7537 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt @@ -68,7 +68,7 @@ class AppModule { AppDatabase.MIGRATION_38_39, AppDatabase.MIGRATION_39_40, AppDatabase.MIGRATION_40_41, AppDatabase.MIGRATION_41_42, AppDatabase.MIGRATION_42_43, AppDatabase.MIGRATION_43_44, AppDatabase.MIGRATION_44_45, AppDatabase.MIGRATION_45_46, AppDatabase.MIGRATION_46_47, - AppDatabase.MIGRATION_47_48, + AppDatabase.MIGRATION_47_48 ) .build() } diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Account.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Account.kt index fdad077a03..43b9bd9c91 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Account.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Account.kt @@ -43,7 +43,9 @@ data class Account( val name: String get() = if (displayName.isNullOrEmpty()) { localUsername - } else displayName + } else { + displayName + } fun isRemote(): Boolean = this.username != this.localUsername } @@ -53,7 +55,7 @@ data class AccountSource( val sensitive: Boolean?, val note: String?, val fields: List?, - val language: String?, + val language: String? ) data class Field( diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Attachment.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Attachment.kt index fa0b978fca..0ebd9c370f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Attachment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Attachment.kt @@ -39,12 +39,16 @@ data class Attachment( enum class Type { @SerializedName("image") IMAGE, + @SerializedName("gifv") GIFV, + @SerializedName("video") VIDEO, + @SerializedName("audio") AUDIO, + @SerializedName("unknown") UNKNOWN } @@ -70,7 +74,7 @@ data class Attachment( val focus: Focus?, val duration: Float?, val original: Size?, - val small: Size?, + val small: Size? ) : Parcelable /** diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/DeletedStatus.kt b/app/src/main/java/com/keylesspalace/tusky/entity/DeletedStatus.kt index a653cc587d..872379138b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/DeletedStatus.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/DeletedStatus.kt @@ -28,7 +28,7 @@ data class DeletedStatus( @SerializedName("media_attachments") val attachments: ArrayList?, val poll: Poll?, @SerializedName("created_at") val createdAt: Date, - val language: String?, + val language: String? ) { fun isEmpty(): Boolean { return text == null && attachments == null diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Filter.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Filter.kt index c199c72f3f..239b7ae5e1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Filter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Filter.kt @@ -13,7 +13,7 @@ data class Filter( val context: List, @SerializedName("expires_at") val expiresAt: Date?, @SerializedName("filter_action") private val filterAction: String, - val keywords: List, + val keywords: List // val statuses: List, ) : Parcelable { enum class Action(val action: String) { diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/FilterKeyword.kt b/app/src/main/java/com/keylesspalace/tusky/entity/FilterKeyword.kt index 131540c146..c62ac40909 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/FilterKeyword.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/FilterKeyword.kt @@ -8,5 +8,5 @@ import kotlinx.parcelize.Parcelize data class FilterKeyword( val id: String, val keyword: String, - @SerializedName("whole_word") val wholeWord: Boolean, + @SerializedName("whole_word") val wholeWord: Boolean ) : Parcelable diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/FilterResult.kt b/app/src/main/java/com/keylesspalace/tusky/entity/FilterResult.kt index 79179bd055..fa2fa92b38 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/FilterResult.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/FilterResult.kt @@ -5,5 +5,5 @@ import com.google.gson.annotations.SerializedName data class FilterResult( val filter: Filter, @SerializedName("keyword_matches") val keywordMatches: List?, - @SerializedName("status_matches") val statusMatches: String?, + @SerializedName("status_matches") val statusMatches: String? ) diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/FilterV1.kt b/app/src/main/java/com/keylesspalace/tusky/entity/FilterV1.kt index c5b2d16958..a93ccff5ef 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/FilterV1.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/FilterV1.kt @@ -57,7 +57,7 @@ data class FilterV1( FilterKeyword( id = id, keyword = phrase, - wholeWord = wholeWord, + wholeWord = wholeWord ) ) ) diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Instance.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Instance.kt index 67ae891281..77864cfebb 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Instance.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Instance.kt @@ -54,19 +54,19 @@ data class PollConfiguration( @SerializedName("max_option_chars") val maxOptionChars: Int?, @SerializedName("max_characters_per_option") val maxCharactersPerOption: Int?, @SerializedName("min_expiration") val minExpiration: Int?, - @SerializedName("max_expiration") val maxExpiration: Int?, + @SerializedName("max_expiration") val maxExpiration: Int? ) data class InstanceConfiguration( val statuses: StatusConfiguration?, @SerializedName("media_attachments") val mediaAttachments: MediaAttachmentConfiguration?, - val polls: PollConfiguration?, + val polls: PollConfiguration? ) data class StatusConfiguration( @SerializedName("max_characters") val maxCharacters: Int?, @SerializedName("max_media_attachments") val maxMediaAttachments: Int?, - @SerializedName("characters_reserved_per_url") val charactersReservedPerUrl: Int?, + @SerializedName("characters_reserved_per_url") val charactersReservedPerUrl: Int? ) data class MediaAttachmentConfiguration( @@ -75,7 +75,7 @@ data class MediaAttachmentConfiguration( @SerializedName("image_matrix_limit") val imageMatrixLimit: Int?, @SerializedName("video_size_limit") val videoSizeLimit: Int?, @SerializedName("video_frame_rate_limit") val videoFrameRateLimit: Int?, - @SerializedName("video_matrix_limit") val videoMatrixLimit: Int?, + @SerializedName("video_matrix_limit") val videoMatrixLimit: Int? ) data class PleromaConfiguration( diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/NewStatus.kt b/app/src/main/java/com/keylesspalace/tusky/entity/NewStatus.kt index 9577caef10..1a353eadf5 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/NewStatus.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/NewStatus.kt @@ -29,7 +29,7 @@ data class NewStatus( @SerializedName("media_attributes") val mediaAttributes: List?, @SerializedName("scheduled_at") val scheduledAt: String?, val poll: NewPoll?, - val language: String?, + val language: String? ) @Parcelize @@ -46,5 +46,5 @@ data class MediaAttribute( val id: String, val description: String?, val focus: String?, - val thumbnail: String?, + val thumbnail: String? ) : Parcelable diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Notification.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Notification.kt index 1bad6697b1..a7007e57c4 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Notification.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Notification.kt @@ -28,7 +28,7 @@ data class Notification( val id: String, val account: TimelineAccount, val status: Status?, - val report: Report?, + val report: Report? ) { /** From https://docs.joinmastodon.org/entities/Notification/#type */ @@ -70,8 +70,9 @@ data class Notification( @JvmStatic fun byString(s: String): Type { values().forEach { - if (s == it.presentation) + if (s == it.presentation) { return it + } } return UNKNOWN } @@ -115,7 +116,11 @@ data class Notification( return if (status.mentions.any { it.id == accountId } - ) this else copy(type = Type.STATUS) + ) { + this + } else { + copy(type = Type.STATUS) + } } return this } diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/NotificationSubscribeResult.kt b/app/src/main/java/com/keylesspalace/tusky/entity/NotificationSubscribeResult.kt index c6eb09becf..6bdaa14388 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/NotificationSubscribeResult.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/NotificationSubscribeResult.kt @@ -20,5 +20,5 @@ import com.google.gson.annotations.SerializedName data class NotificationSubscribeResult( val id: Int, val endpoint: String, - @SerializedName("server_key") val serverKey: String, + @SerializedName("server_key") val serverKey: String ) diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Report.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Report.kt index 0330c102c4..8de7b957d6 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Report.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Report.kt @@ -8,5 +8,5 @@ data class Report( val category: String, val status_ids: List?, @SerializedName("created_at") val createdAt: Date, - @SerializedName("target_account") val targetAccount: TimelineAccount, + @SerializedName("target_account") val targetAccount: TimelineAccount ) diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt index b6c26f8916..f959974612 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt @@ -51,7 +51,7 @@ data class Status( val poll: Poll?, val card: Card?, val language: String?, - val filtered: List?, + val filtered: List? ) { val actionableId: String @@ -69,12 +69,16 @@ data class Status( enum class Visibility(val num: Int) { UNKNOWN(0), + @SerializedName("public") PUBLIC(1), + @SerializedName("unlisted") UNLISTED(2), + @SerializedName("private") PRIVATE(3), + @SerializedName("direct") DIRECT(4); @@ -134,7 +138,7 @@ data class Status( attachments = attachments, poll = poll, createdAt = createdAt, - language = language, + language = language ) } diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/StatusSource.kt b/app/src/main/java/com/keylesspalace/tusky/entity/StatusSource.kt index aea6bdd477..98a01d8b94 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/StatusSource.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/StatusSource.kt @@ -20,5 +20,5 @@ import com.google.gson.annotations.SerializedName data class StatusSource( val id: String, val text: String, - @SerializedName("spoiler_text") val spoilerText: String, + @SerializedName("spoiler_text") val spoilerText: String ) diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/TimelineAccount.kt b/app/src/main/java/com/keylesspalace/tusky/entity/TimelineAccount.kt index 224129feb8..ba57abb90f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/TimelineAccount.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/TimelineAccount.kt @@ -29,11 +29,13 @@ data class TimelineAccount( val url: String, val avatar: String, val bot: Boolean = false, - val emojis: List? = emptyList(), // nullable for backward compatibility + val emojis: List? = emptyList() // nullable for backward compatibility ) { val name: String get() = if (displayName.isNullOrEmpty()) { localUsername - } else displayName + } else { + displayName + } } diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/TrendingTagsResult.kt b/app/src/main/java/com/keylesspalace/tusky/entity/TrendingTagsResult.kt index 1c20a5e901..7baca6fe04 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/TrendingTagsResult.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/TrendingTagsResult.kt @@ -29,7 +29,7 @@ data class TrendingTag( val name: String, val url: String, val history: List, - val following: Boolean, + val following: Boolean ) /** @@ -42,7 +42,7 @@ data class TrendingTag( data class TrendingTagHistory( val day: String, val accounts: String, - val uses: String, + val uses: String ) fun TrendingTag.start() = Date(history.last().day.toLong() * 1000L) diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.kt b/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.kt index b4284b39f8..046b54c548 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.kt @@ -309,7 +309,6 @@ abstract class SFragment : Fragment(), Injectable { } private fun onMute(accountId: String, accountUsername: String) { - showMuteAccountDialog(this.requireActivity(), accountUsername) { notifications: Boolean?, duration: Int? -> lifecycleScope.launch { timelineCases.mute(accountId, notifications == true, duration) @@ -339,7 +338,8 @@ abstract class SFragment : Fragment(), Injectable { view.transitionName = url val options = ActivityOptionsCompat.makeSceneTransitionAnimation( requireActivity(), - view, url + view, + url ) startActivity(intent, options.toBundle()) } else { diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewImageFragment.kt b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewImageFragment.kt index 28cf64ca34..688ce7c585 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewImageFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewImageFragment.kt @@ -207,7 +207,7 @@ class ViewImageFragment : ViewMediaFragment() { .dontAnimate() .onlyRetrieveFromCache(true) .let { - if (previewUrl != null) + if (previewUrl != null) { it.thumbnail( glide .load(previewUrl) @@ -216,7 +216,9 @@ class ViewImageFragment : ViewMediaFragment() { .centerInside() .addListener(ImageRequestListener(true, isThumbnailRequest = true)) ) - else it + } else { + it + } } // Request image from the network on fail load image from cache .error( diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewMediaFragment.kt b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewMediaFragment.kt index 686e4fdfe6..2f8aaf1d9b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewMediaFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewMediaFragment.kt @@ -42,6 +42,7 @@ abstract class ViewMediaFragment : Fragment() { @JvmStatic protected val ARG_ATTACHMENT = "attach" + @JvmStatic protected val ARG_SINGLE_IMAGE_URL = "singleImageUrl" diff --git a/app/src/main/java/com/keylesspalace/tusky/json/Iso8601Utils.kt b/app/src/main/java/com/keylesspalace/tusky/json/Iso8601Utils.kt index bd8df6b5ed..983333a9c9 100644 --- a/app/src/main/java/com/keylesspalace/tusky/json/Iso8601Utils.kt +++ b/app/src/main/java/com/keylesspalace/tusky/json/Iso8601Utils.kt @@ -108,7 +108,6 @@ internal fun String.parseIsoDate(): Date { return GregorianCalendar(year, month - 1, day).time } if (hasT) { - // extract hours, minutes, seconds and milliseconds hour = parseInt(this, 1.let { offset += it; offset }, 2.let { offset += it; offset }) if (checkOffset(this, offset, ':')) { diff --git a/app/src/main/java/com/keylesspalace/tusky/network/FilterModel.kt b/app/src/main/java/com/keylesspalace/tusky/network/FilterModel.kt index 0d13373a80..26a7141ec0 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/FilterModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/network/FilterModel.kt @@ -29,8 +29,9 @@ class FilterModel @Inject constructor() { // Patterns are expensive and thread-safe, matchers are neither. val matcher = pattern?.matcher("") ?: return Filter.Action.NONE - if (status.poll?.options?.any { matcher.reset(it.title).find() } == true) + if (status.poll?.options?.any { matcher.reset(it.title).find() } == true) { return Filter.Action.HIDE + } val spoilerText = status.actionableStatus.spoilerText val attachmentsDescriptions = status.attachments.mapNotNull { it.description } diff --git a/app/src/main/java/com/keylesspalace/tusky/network/InstanceSwitchAuthInterceptor.kt b/app/src/main/java/com/keylesspalace/tusky/network/InstanceSwitchAuthInterceptor.kt index 3ca7a81165..6033165f97 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/InstanceSwitchAuthInterceptor.kt +++ b/app/src/main/java/com/keylesspalace/tusky/network/InstanceSwitchAuthInterceptor.kt @@ -34,7 +34,6 @@ class InstanceSwitchAuthInterceptor(private val accountManager: AccountManager) // only switch domains if the request comes from retrofit return if (originalRequest.url.host == MastodonApi.PLACEHOLDER_DOMAIN) { - val builder: Request.Builder = originalRequest.newBuilder() val instanceHeader = originalRequest.header(MastodonApi.DOMAIN_HEADER) diff --git a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt index 88f5480e44..62b52492f1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt +++ b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt @@ -194,7 +194,7 @@ interface MastodonApi { @Header("Authorization") auth: String, @Header(DOMAIN_HEADER) domain: String, @Header("Idempotency-Key") idempotencyKey: String, - @Body editedStatus: NewStatus, + @Body editedStatus: NewStatus ): NetworkResult @GET("api/v1/statuses/{id}") @@ -298,7 +298,7 @@ interface MastodonApi { @GET("api/v1/accounts/verify_credentials") suspend fun accountVerifyCredentials( @Header(DOMAIN_HEADER) domain: String? = null, - @Header("Authorization") auth: String? = null, + @Header("Authorization") auth: String? = null ): NetworkResult @FormUrlEncoded @@ -306,7 +306,7 @@ interface MastodonApi { fun accountUpdateSource( @Field("source[privacy]") privacy: String?, @Field("source[sensitive]") sensitive: Boolean?, - @Field("source[language]") language: String?, + @Field("source[language]") language: String? ): Call @Multipart @@ -607,7 +607,7 @@ interface MastodonApi { @Field("title") title: String, @Field("context[]") context: List, @Field("filter_action") filterAction: String, - @Field("expires_in") expiresInSeconds: Int?, + @Field("expires_in") expiresInSeconds: Int? ): NetworkResult @FormUrlEncoded @@ -617,7 +617,7 @@ interface MastodonApi { @Field("title") title: String? = null, @Field("context[]") context: List? = null, @Field("filter_action") filterAction: String? = null, - @Field("expires_in") expiresInSeconds: Int? = null, + @Field("expires_in") expiresInSeconds: Int? = null ): NetworkResult @DELETE("api/v2/filters/{id}") @@ -630,7 +630,7 @@ interface MastodonApi { suspend fun addFilterKeyword( @Path("filterId") filterId: String, @Field("keyword") keyword: String, - @Field("whole_word") wholeWord: Boolean, + @Field("whole_word") wholeWord: Boolean ): NetworkResult @FormUrlEncoded @@ -638,12 +638,12 @@ interface MastodonApi { suspend fun updateFilterKeyword( @Path("keywordId") keywordId: String, @Field("keyword") keyword: String, - @Field("whole_word") wholeWord: Boolean, + @Field("whole_word") wholeWord: Boolean ): NetworkResult @DELETE("api/v2/filters/keywords/{keywordId}") suspend fun deleteFilterKeyword( - @Path("keywordId") keywordId: String, + @Path("keywordId") keywordId: String ): NetworkResult @FormUrlEncoded @@ -751,7 +751,7 @@ interface MastodonApi { @DELETE("api/v1/push/subscription") suspend fun unsubscribePushNotifications( @Header("Authorization") auth: String, - @Header(DOMAIN_HEADER) domain: String, + @Header(DOMAIN_HEADER) domain: String ): NetworkResult @GET("api/v1/tags/{name}") @@ -762,7 +762,7 @@ interface MastodonApi { @Query("min_id") minId: String? = null, @Query("since_id") sinceId: String? = null, @Query("max_id") maxId: String? = null, - @Query("limit") limit: Int? = null, + @Query("limit") limit: Int? = null ): Response> @POST("api/v1/tags/{name}/follow") diff --git a/app/src/main/java/com/keylesspalace/tusky/receiver/SendStatusBroadcastReceiver.kt b/app/src/main/java/com/keylesspalace/tusky/receiver/SendStatusBroadcastReceiver.kt index 0c1d0e37c9..3ab941e9df 100644 --- a/app/src/main/java/com/keylesspalace/tusky/receiver/SendStatusBroadcastReceiver.kt +++ b/app/src/main/java/com/keylesspalace/tusky/receiver/SendStatusBroadcastReceiver.kt @@ -97,7 +97,7 @@ class SendStatusBroadcastReceiver : BroadcastReceiver() { idempotencyKey = randomAlphanumericString(16), retries = 0, language = null, - statusId = null, + statusId = null ) ) diff --git a/app/src/main/java/com/keylesspalace/tusky/service/SendStatusService.kt b/app/src/main/java/com/keylesspalace/tusky/service/SendStatusService.kt index fd7d32d01a..a7b893884d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/service/SendStatusService.kt +++ b/app/src/main/java/com/keylesspalace/tusky/service/SendStatusService.kt @@ -53,12 +53,16 @@ class SendStatusService : Service(), Injectable { @Inject lateinit var mastodonApi: MastodonApi + @Inject lateinit var accountManager: AccountManager + @Inject lateinit var eventHub: EventHub + @Inject lateinit var draftHelper: DraftHelper + @Inject lateinit var mediaUploader: MediaUploader @@ -111,7 +115,6 @@ class SendStatusService : Service(), Injectable { statusesToSend[sendingNotificationId] = statusToSend sendStatus(sendingNotificationId--) } else { - if (intent.hasExtra(KEY_CANCEL)) { cancelSending(intent.getIntExtra(KEY_CANCEL, 0)) } @@ -121,7 +124,6 @@ class SendStatusService : Service(), Injectable { } private fun sendStatus(statusId: Int) { - // when statusToSend == null, sending has been canceled val statusToSend = statusesToSend[statusId] ?: return @@ -138,7 +140,6 @@ class SendStatusService : Service(), Injectable { statusToSend.retries++ sendJobs[statusId] = serviceScope.launch { - // first, wait for media uploads to finish val media = statusToSend.media.map { mediaItem -> if (mediaItem.id == null) { @@ -174,7 +175,7 @@ class SendStatusService : Service(), Injectable { } } } - mediaCheckRetries ++ + mediaCheckRetries++ } } catch (e: Exception) { Log.w(TAG, "failed getting media status", e) @@ -198,9 +199,9 @@ class SendStatusService : Service(), Injectable { id = media.id!!, description = media.description, focus = media.focus?.toMastodonApiString(), - thumbnail = null, + thumbnail = null ) - }, + } ) val editing = (statusToSend.statusId != null) @@ -266,7 +267,6 @@ class SendStatusService : Service(), Injectable { } private fun stopSelfWhenDone() { - if (statusesToSend.isEmpty()) { ServiceCompat.stopForeground(this@SendStatusService, ServiceCompat.STOP_FOREGROUND_REMOVE) stopSelf() @@ -276,7 +276,6 @@ class SendStatusService : Service(), Injectable { private suspend fun failSending(statusId: Int) { val failedStatus = statusesToSend.remove(statusId) if (failedStatus != null) { - mediaUploader.cancelUploadScope(*failedStatus.media.map { it.localId }.toIntArray()) saveStatusToDrafts(failedStatus, failedToSendAlert = true) @@ -296,7 +295,6 @@ class SendStatusService : Service(), Injectable { private fun cancelSending(statusId: Int) = serviceScope.launch { val statusToCancel = statusesToSend.remove(statusId) if (statusToCancel != null) { - mediaUploader.cancelUploadScope(*statusToCancel.media.map { it.localId }.toIntArray()) val sendJob = sendJobs.remove(statusId) @@ -336,7 +334,7 @@ class SendStatusService : Service(), Injectable { failedToSendAlert = failedToSendAlert, scheduledAt = status.scheduledAt, language = status.language, - statusId = status.statusId, + statusId = status.statusId ) } @@ -357,7 +355,6 @@ class SendStatusService : Service(), Injectable { accountId: Long, statusId: Int ): Notification { - val intent = Intent(this, MainActivity::class.java) intent.putExtra(NotificationHelper.ACCOUNT_ID, accountId) intent.putExtra(MainActivity.OPEN_DRAFTS, true) @@ -442,7 +439,7 @@ data class StatusToSend( val idempotencyKey: String, var retries: Int, val language: String?, - val statusId: String?, + val statusId: String? ) : Parcelable @Parcelize diff --git a/app/src/main/java/com/keylesspalace/tusky/settings/AccountPreferenceHandler.kt b/app/src/main/java/com/keylesspalace/tusky/settings/AccountPreferenceHandler.kt index 3d6b0c15f2..802f0ba8f8 100644 --- a/app/src/main/java/com/keylesspalace/tusky/settings/AccountPreferenceHandler.kt +++ b/app/src/main/java/com/keylesspalace/tusky/settings/AccountPreferenceHandler.kt @@ -9,7 +9,7 @@ import com.keylesspalace.tusky.db.AccountManager class AccountPreferenceHandler( private val account: AccountEntity, private val accountManager: AccountManager, - private val eventHub: EventHub, + private val eventHub: EventHub ) : PreferenceDataStore() { override fun getBoolean(key: String, defValue: Boolean): Boolean { diff --git a/app/src/main/java/com/keylesspalace/tusky/settings/ProxyConfiguration.kt b/app/src/main/java/com/keylesspalace/tusky/settings/ProxyConfiguration.kt index f1e04bd56f..fbe8084bcd 100644 --- a/app/src/main/java/com/keylesspalace/tusky/settings/ProxyConfiguration.kt +++ b/app/src/main/java/com/keylesspalace/tusky/settings/ProxyConfiguration.kt @@ -14,9 +14,13 @@ class ProxyConfiguration private constructor( return null } fun isValidProxyPort(value: Any): Boolean = when (value) { - is String -> if (value == "") true else value.runCatching(String::toInt).map( - PROXY_RANGE::contains - ).getOrDefault(false) + is String -> if (value == "") { + true + } else { + value.runCatching(String::toInt).map( + PROXY_RANGE::contains + ).getOrDefault(false) + } is Int -> PROXY_RANGE.contains(value) else -> false } diff --git a/app/src/main/java/com/keylesspalace/tusky/util/CustomEmojiHelper.kt b/app/src/main/java/com/keylesspalace/tusky/util/CustomEmojiHelper.kt index b363290442..f53aae37ad 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/CustomEmojiHelper.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/CustomEmojiHelper.kt @@ -14,6 +14,7 @@ * see . */ @file:JvmName("CustomEmojiHelper") + package com.keylesspalace.tusky.util import android.graphics.Canvas @@ -39,8 +40,9 @@ import java.util.regex.Pattern * @return the text with the shortcodes replaced by EmojiSpans */ fun CharSequence.emojify(emojis: List?, view: View, animate: Boolean): CharSequence { - if (emojis.isNullOrEmpty()) + if (emojis.isNullOrEmpty()) { return this + } val builder = SpannableStringBuilder.valueOf(this) diff --git a/app/src/main/java/com/keylesspalace/tusky/util/HttpHeaderLink.kt b/app/src/main/java/com/keylesspalace/tusky/util/HttpHeaderLink.kt index 4f5e9920b2..a5ccd35be5 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/HttpHeaderLink.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/HttpHeaderLink.kt @@ -24,7 +24,9 @@ import androidx.core.net.toUri * * @see [RFC5988](https://tools.ietf.org/html/rfc5988) */ -class HttpHeaderLink @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) constructor( +class HttpHeaderLink +@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) +constructor( uri: String ) { data class Parameter(val name: String, val value: String?) diff --git a/app/src/main/java/com/keylesspalace/tusky/util/IOUtils.kt b/app/src/main/java/com/keylesspalace/tusky/util/IOUtils.kt index 005554bfa0..ece76bdfd4 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/IOUtils.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/IOUtils.kt @@ -36,7 +36,7 @@ fun Closeable?.closeQuietly() { fun Uri.copyToFile( contentResolver: ContentResolver, - file: File, + file: File ): Boolean { val from: InputStream? val to: FileOutputStream diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ImageLoadingHelper.kt b/app/src/main/java/com/keylesspalace/tusky/util/ImageLoadingHelper.kt index 1cd9b99acc..1430801c28 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/ImageLoadingHelper.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/ImageLoadingHelper.kt @@ -14,7 +14,6 @@ import com.keylesspalace.tusky.R private val centerCropTransformation = CenterCrop() fun loadAvatar(url: String?, imageView: ImageView, @Px radius: Int, animate: Boolean) { - if (url.isNullOrBlank()) { Glide.with(imageView) .load(R.drawable.avatar_default) diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ListStatusAccessibilityDelegate.kt b/app/src/main/java/com/keylesspalace/tusky/util/ListStatusAccessibilityDelegate.kt index 80deb344c0..9402edd037 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/ListStatusAccessibilityDelegate.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/ListStatusAccessibilityDelegate.kt @@ -196,7 +196,8 @@ class ListStatusAccessibilityDelegate( .setAdapter( ArrayAdapter( host.context, - android.R.layout.simple_list_item_1, stringMentions + android.R.layout.simple_list_item_1, + stringMentions ) ) { _, which -> statusActionListener.onViewAccount(mentions[which].id) @@ -213,7 +214,8 @@ class ListStatusAccessibilityDelegate( .setAdapter( ArrayAdapter( host.context, - android.R.layout.simple_list_item_1, tags + android.R.layout.simple_list_item_1, + tags ) ) { _, which -> statusActionListener.onViewTag(tags[which].toString()) diff --git a/app/src/main/java/com/keylesspalace/tusky/util/LocaleManager.kt b/app/src/main/java/com/keylesspalace/tusky/util/LocaleManager.kt index 6795317b24..8a5dc3d26e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/LocaleManager.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/LocaleManager.kt @@ -54,7 +54,6 @@ class LocaleManager @Inject constructor( } override fun putString(key: String?, value: String?) { - // if we are on Android < 13 we have to save the selected language so we can apply it at appstart // on Android 13+ the system handles it for us if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { diff --git a/app/src/main/java/com/keylesspalace/tusky/util/MediaUtils.kt b/app/src/main/java/com/keylesspalace/tusky/util/MediaUtils.kt index 408f64535d..b01200bb57 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/MediaUtils.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/MediaUtils.kt @@ -87,7 +87,6 @@ fun calculateInSampleSize(options: BitmapFactory.Options, reqWidth: Int, reqHeig var inSampleSize = 1 if (height > reqHeight || width > reqWidth) { - val halfHeight = height / 2 val halfWidth = width / 2 @@ -130,8 +129,13 @@ fun reorientBitmap(bitmap: Bitmap?, orientation: Int): Bitmap? { return try { val result = Bitmap.createBitmap( - bitmap, 0, 0, bitmap.width, - bitmap.height, matrix, true + bitmap, + 0, + 0, + bitmap.width, + bitmap.height, + matrix, + true ) if (!bitmap.sameAs(result)) { bitmap.recycle() diff --git a/app/src/main/java/com/keylesspalace/tusky/util/NotificationTypeConverter.kt b/app/src/main/java/com/keylesspalace/tusky/util/NotificationTypeConverter.kt index 34e8924f85..969deba47c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/NotificationTypeConverter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/NotificationTypeConverter.kt @@ -37,8 +37,9 @@ fun deserialize(data: String?): Set { for (i in 0 until array.length()) { val item = array.getString(i) val type = Notification.Type.byString(item) - if (type != Notification.Type.UNKNOWN) + if (type != Notification.Type.UNKNOWN) { ret.add(type) + } } } return ret diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ShareShortcutHelper.kt b/app/src/main/java/com/keylesspalace/tusky/util/ShareShortcutHelper.kt index ee68749647..9d8e4b2385 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/ShareShortcutHelper.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/ShareShortcutHelper.kt @@ -35,9 +35,7 @@ import io.reactivex.rxjava3.core.Single import io.reactivex.rxjava3.schedulers.Schedulers fun updateShortcut(context: Context, account: AccountEntity) { - Single.fromCallable { - val innerSize = context.resources.getDimensionPixelSize(R.dimen.adaptive_bitmap_inner_size) val outerSize = context.resources.getDimensionPixelSize(R.dimen.adaptive_bitmap_outer_size) @@ -94,6 +92,5 @@ fun updateShortcut(context: Context, account: AccountEntity) { } fun removeShortcut(context: Context, account: AccountEntity) { - ShortcutManagerCompat.removeDynamicShortcuts(context, listOf(account.id.toString())) } diff --git a/app/src/main/java/com/keylesspalace/tusky/util/SmartLengthInputFilter.kt b/app/src/main/java/com/keylesspalace/tusky/util/SmartLengthInputFilter.kt index 5e51003417..c7b583e571 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/SmartLengthInputFilter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/SmartLengthInputFilter.kt @@ -89,7 +89,6 @@ object SmartLengthInputFilter : InputFilter { keep = boundary } else { - // If no runway is allowed simply remove whitespace if present while (source[keep - 1].isWhitespace()) { --keep diff --git a/app/src/main/java/com/keylesspalace/tusky/util/SpanUtils.kt b/app/src/main/java/com/keylesspalace/tusky/util/SpanUtils.kt index 670b02edd5..96246650d8 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/SpanUtils.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/SpanUtils.kt @@ -43,7 +43,7 @@ private enum class FoundMatchType { HTTP_URL, HTTPS_URL, TAG, - MENTION, + MENTION } private class FindCharsResult { diff --git a/app/src/main/java/com/keylesspalace/tusky/util/StatusParsingHelper.kt b/app/src/main/java/com/keylesspalace/tusky/util/StatusParsingHelper.kt index 18c95c8ad6..b8c3c6a0d3 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/StatusParsingHelper.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/StatusParsingHelper.kt @@ -14,6 +14,7 @@ * see . */ @file:JvmName("StatusParsingHelper") + package com.keylesspalace.tusky.util import android.text.Html.TagHandler diff --git a/app/src/main/java/com/keylesspalace/tusky/util/StatusViewHelper.kt b/app/src/main/java/com/keylesspalace/tusky/util/StatusViewHelper.kt index 7594e8ed6f..2148a3b4c1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/StatusViewHelper.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/StatusViewHelper.kt @@ -53,7 +53,6 @@ class StatusViewHelper(private val itemView: View) { showingContent: Boolean, mediaPreviewHeight: Int ) { - val context = itemView.context val mediaPreviews = arrayOf( itemView.findViewById(R.id.status_media_preview_0), @@ -110,9 +109,11 @@ class StatusViewHelper(private val itemView: View) { .centerInside() .into(mediaPreviews[i]) } else { - val placeholder = if (attachment.blurhash != null) + val placeholder = if (attachment.blurhash != null) { decodeBlurHash(context, attachment.blurhash) - else mediaPreviewUnloaded + } else { + mediaPreviewUnloaded + } val meta = attachment.meta val focus = meta?.focus if (showingContent) { @@ -185,8 +186,12 @@ class StatusViewHelper(private val itemView: View) { v.visibility = View.GONE sensitiveMediaWarning.visibility = View.VISIBLE setMediasPreview( - statusDisplayOptions, attachments, sensitive, previewListener, - false, mediaPreviewHeight + statusDisplayOptions, + attachments, + sensitive, + previewListener, + false, + mediaPreviewHeight ) } sensitiveMediaWarning.setOnClickListener { v -> @@ -194,8 +199,12 @@ class StatusViewHelper(private val itemView: View) { v.visibility = View.GONE sensitiveMediaShow.visibility = View.VISIBLE setMediasPreview( - statusDisplayOptions, attachments, sensitive, previewListener, - true, mediaPreviewHeight + statusDisplayOptions, + attachments, + sensitive, + previewListener, + true, + mediaPreviewHeight ) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.kt b/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.kt index 37e0854be8..1d630f39bf 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.kt @@ -67,13 +67,13 @@ fun Notification.toViewData( this.id, this.account, this.status?.toViewData(isShowingContent, isExpanded, isCollapsed), - this.report, + this.report ) } @JvmName("tagToViewData") fun TrendingTag.toViewData(): TrendingViewData.Tag { return TrendingViewData.Tag( - tag = this, + tag = this ) } diff --git a/app/src/main/java/com/keylesspalace/tusky/view/GraphView.kt b/app/src/main/java/com/keylesspalace/tusky/view/GraphView.kt index cc779432d2..565d58a302 100644 --- a/app/src/main/java/com/keylesspalace/tusky/view/GraphView.kt +++ b/app/src/main/java/com/keylesspalace/tusky/view/GraphView.kt @@ -34,7 +34,7 @@ class GraphView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0, - defStyleRes: Int = 0, + defStyleRes: Int = 0 ) : AppCompatImageView(context, attrs, defStyleAttr) { @get:ColorInt @ColorInt @@ -69,22 +69,54 @@ class GraphView @JvmOverloads constructor( private var secondaryLinePath: Path = Path() var maxTrendingValue: Long = 300 - var primaryLineData: List = if (isInEditMode) listOf( - 30, 60, 70, 80, 130, 190, 80, - ) else listOf( - 1, 1, 1, 1, 1, 1, 1, - ) + var primaryLineData: List = if (isInEditMode) { + listOf( + 30, + 60, + 70, + 80, + 130, + 190, + 80 + ) + } else { + listOf( + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ) + } set(value) { field = value.map { max(1, it) } primaryLinePath.reset() invalidate() } - var secondaryLineData: List = if (isInEditMode) listOf( - 10, 20, 40, 60, 100, 132, 20, - ) else listOf( - 1, 1, 1, 1, 1, 1, 1, - ) + var secondaryLineData: List = if (isInEditMode) { + listOf( + 10, + 20, + 40, + 60, + 100, + 132, + 20 + ) + } else { + listOf( + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ) + } set(value) { field = value.map { max(1, it) } secondaryLinePath.reset() @@ -101,7 +133,7 @@ class GraphView @JvmOverloads constructor( context, a.getResourceId( R.styleable.GraphView_primaryLineColor, - R.color.tusky_blue, + R.color.tusky_blue ) ) @@ -109,7 +141,7 @@ class GraphView @JvmOverloads constructor( context, a.getResourceId( R.styleable.GraphView_secondaryLineColor, - R.color.tusky_red, + R.color.tusky_red ) ) @@ -122,7 +154,7 @@ class GraphView @JvmOverloads constructor( context, a.getResourceId( R.styleable.GraphView_graphColor, - R.color.colorBackground, + R.color.colorBackground ) ) @@ -130,13 +162,13 @@ class GraphView @JvmOverloads constructor( context, a.getResourceId( R.styleable.GraphView_metaColor, - R.color.dividerColor, + R.color.dividerColor ) ) proportionalTrending = a.getBoolean( R.styleable.GraphView_proportionalTrending, - proportionalTrending, + proportionalTrending ) } @@ -271,14 +303,14 @@ class GraphView @JvmOverloads constructor( linePath = secondaryLinePath, linePaint = secondaryLinePaint, circlePaint = secondaryCirclePaint, - lineThickness = lineWidth, + lineThickness = lineWidth ) drawLine( canvas = canvas, linePath = primaryLinePath, linePaint = primaryLinePaint, circlePaint = primaryCirclePaint, - lineThickness = lineWidth, + lineThickness = lineWidth ) } } @@ -288,12 +320,12 @@ class GraphView @JvmOverloads constructor( linePath: Path, linePaint: Paint, circlePaint: Paint, - lineThickness: Float, + lineThickness: Float ) { canvas.apply { drawPath( linePath, - linePaint, + linePaint ) val pm = PathMeasure(linePath, false) diff --git a/app/src/main/java/com/keylesspalace/tusky/view/LicenseCard.kt b/app/src/main/java/com/keylesspalace/tusky/view/LicenseCard.kt index 6d553a26c7..394cd36922 100644 --- a/app/src/main/java/com/keylesspalace/tusky/view/LicenseCard.kt +++ b/app/src/main/java/com/keylesspalace/tusky/view/LicenseCard.kt @@ -40,12 +40,15 @@ class LicenseCard setCardBackgroundColor(MaterialColors.getColor(context, com.google.android.material.R.attr.colorSurface, Color.BLACK)) val (name, license, link) = context.theme.obtainStyledAttributes( - attrs, R.styleable.LicenseCard, 0, 0 + attrs, + R.styleable.LicenseCard, + 0, + 0 ).use { a -> Triple( a.getString(R.styleable.LicenseCard_name), a.getString(R.styleable.LicenseCard_license), - a.getString(R.styleable.LicenseCard_link), + a.getString(R.styleable.LicenseCard_link) ) } diff --git a/app/src/main/java/com/keylesspalace/tusky/view/MediaPreviewImageView.kt b/app/src/main/java/com/keylesspalace/tusky/view/MediaPreviewImageView.kt index dc149e4bef..717bd14415 100644 --- a/app/src/main/java/com/keylesspalace/tusky/view/MediaPreviewImageView.kt +++ b/app/src/main/java/com/keylesspalace/tusky/view/MediaPreviewImageView.kt @@ -119,9 +119,12 @@ open class MediaPreviewImageView if (drawable != null && focus != null && focalMatrix != null) { scaleType = ScaleType.MATRIX FocalPointUtil.updateFocalPointMatrix( - width.toFloat(), height.toFloat(), - drawable.intrinsicWidth.toFloat(), drawable.intrinsicHeight.toFloat(), - focus as Attachment.Focus, focalMatrix as Matrix + width.toFloat(), + height.toFloat(), + drawable.intrinsicWidth.toFloat(), + drawable.intrinsicHeight.toFloat(), + focus as Attachment.Focus, + focalMatrix as Matrix ) imageMatrix = focalMatrix } diff --git a/app/src/main/java/com/keylesspalace/tusky/viewdata/PollViewData.kt b/app/src/main/java/com/keylesspalace/tusky/viewdata/PollViewData.kt index 3dc5ca10c0..7281bf060e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/viewdata/PollViewData.kt +++ b/app/src/main/java/com/keylesspalace/tusky/viewdata/PollViewData.kt @@ -72,7 +72,7 @@ fun Poll?.toViewData(): PollViewData? { votesCount = votesCount, votersCount = votersCount, options = options.mapIndexed { index, option -> option.toViewData(ownVotes?.contains(index) == true) }, - voted = voted, + voted = voted ) } diff --git a/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.kt b/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.kt index 315db6b3c0..7f99556f3b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.kt +++ b/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.kt @@ -43,7 +43,7 @@ sealed class StatusViewData { * @return Whether the post is collapsed or fully expanded. */ val isCollapsed: Boolean, - val isDetailed: Boolean = false, + val isDetailed: Boolean = false ) : StatusViewData() { override val id: String get() = status.id diff --git a/app/src/main/java/com/keylesspalace/tusky/viewdata/TrendingViewData.kt b/app/src/main/java/com/keylesspalace/tusky/viewdata/TrendingViewData.kt index 3c59651250..c018aebce4 100644 --- a/app/src/main/java/com/keylesspalace/tusky/viewdata/TrendingViewData.kt +++ b/app/src/main/java/com/keylesspalace/tusky/viewdata/TrendingViewData.kt @@ -25,7 +25,7 @@ sealed class TrendingViewData { data class Header( val start: Date, - val end: Date, + val end: Date ) : TrendingViewData() { override val id: String get() = start.toString() + end.toString() diff --git a/app/src/main/java/com/keylesspalace/tusky/viewmodel/EditProfileViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/viewmodel/EditProfileViewModel.kt index 887766065d..fe51103819 100644 --- a/app/src/main/java/com/keylesspalace/tusky/viewmodel/EditProfileViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/viewmodel/EditProfileViewModel.kt @@ -72,7 +72,6 @@ class EditProfileViewModel @Inject constructor( fun obtainProfile() = viewModelScope.launch { if (profileData.value == null || profileData.value is Error) { - profileData.postValue(Loading()) mastodonApi.accountVerifyCredentials().fold( @@ -100,7 +99,6 @@ class EditProfileViewModel @Inject constructor( } fun save(newDisplayName: String, newNote: String, newLocked: Boolean, newFields: List) { - if (saveData.value is Loading || profileData.value !is Success) { return } @@ -176,7 +174,8 @@ class EditProfileViewModel @Inject constructor( val newProfileSource = profileData.value?.data?.source?.copy(note = newNote, fields = newFields) val newProfile = profileData.value?.data?.copy( displayName = newDisplayName, - locked = newLocked, source = newProfileSource + locked = newLocked, + source = newProfileSource ) profileData.postValue(Success(newProfile)) diff --git a/app/src/main/java/com/keylesspalace/tusky/viewmodel/ListsViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/viewmodel/ListsViewModel.kt index 4c755f8682..f701847c6c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/viewmodel/ListsViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/viewmodel/ListsViewModel.kt @@ -72,8 +72,11 @@ internal class ListsViewModel @Inject constructor(private val api: MastodonApi) { err -> updateState { copy( - loadingState = if (err is IOException || err is ConnectException) - LoadingState.ERROR_NETWORK else LoadingState.ERROR_OTHER + loadingState = if (err is IOException || err is ConnectException) { + LoadingState.ERROR_NETWORK + } else { + LoadingState.ERROR_OTHER + } ) } } diff --git a/app/src/test/java/com/keylesspalace/tusky/BottomSheetActivityTest.kt b/app/src/test/java/com/keylesspalace/tusky/BottomSheetActivityTest.kt index 0c655262e3..bbc5c93052 100644 --- a/app/src/test/java/com/keylesspalace/tusky/BottomSheetActivityTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/BottomSheetActivityTest.kt @@ -90,13 +90,12 @@ class BottomSheetActivityTest { poll = null, card = null, language = null, - filtered = null, + filtered = null ) private val statusSingle = Single.just(SearchResult(emptyList(), listOf(status), emptyList())) @Before fun setup() { - RxJavaPlugins.setIoSchedulerHandler { testScheduler } RxAndroidPlugins.setMainThreadSchedulerHandler { testScheduler } diff --git a/app/src/test/java/com/keylesspalace/tusky/FilterV1Test.kt b/app/src/test/java/com/keylesspalace/tusky/FilterV1Test.kt index f51c9f0b68..a3e5c8979a 100644 --- a/app/src/test/java/com/keylesspalace/tusky/FilterV1Test.kt +++ b/app/src/test/java/com/keylesspalace/tusky/FilterV1Test.kt @@ -100,7 +100,7 @@ class FilterV1Test { expiresAt = null, irreversible = false, wholeWord = false - ), + ) ) filterModel.initWithFilters(filters) @@ -191,7 +191,7 @@ class FilterV1Test { mockStatus( content = "should not be filtered", spoilerText = "should not be filtered", - attachmentsDescriptions = listOf("should not be filtered", "badWord"), + attachmentsDescriptions = listOf("should not be filtered", "badWord") ) ) ) @@ -314,7 +314,9 @@ class FilterV1Test { ) } ) - } else arrayListOf(), + } else { + arrayListOf() + }, mentions = listOf(), tags = listOf(), application = null, @@ -334,10 +336,12 @@ class FilterV1Test { voted = false, ownVotes = null ) - } else null, + } else { + null + }, card = null, language = null, - filtered = null, + filtered = null ) } } diff --git a/app/src/test/java/com/keylesspalace/tusky/FocalPointUtilTest.kt b/app/src/test/java/com/keylesspalace/tusky/FocalPointUtilTest.kt index 444da0619f..89bcf7addd 100644 --- a/app/src/test/java/com/keylesspalace/tusky/FocalPointUtilTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/FocalPointUtilTest.kt @@ -29,14 +29,17 @@ class FocalPointUtilTest { fun positiveFocalXToCoordinateTest() { assertEquals(FocalPointUtil.focalXToCoordinate(0.4f), 0.7f, eps) } + @Test fun negativeFocalXToCoordinateTest() { assertEquals(FocalPointUtil.focalXToCoordinate(-0.8f), 0.1f, eps) } + @Test fun positiveFocalYToCoordinateTest() { assertEquals(FocalPointUtil.focalYToCoordinate(-0.2f), 0.6f, eps) } + @Test fun negativeFocalYToCoordinateTest() { assertEquals(FocalPointUtil.focalYToCoordinate(0.0f), 0.5f, eps) @@ -47,25 +50,33 @@ class FocalPointUtilTest { fun isVerticalCropTest() { assertTrue( FocalPointUtil.isVerticalCrop( - 2f, 1f, - 1f, 2f + 2f, + 1f, + 1f, + 2f ) ) } + @Test fun isHorizontalCropTest() { assertFalse( FocalPointUtil.isVerticalCrop( - 1f, 2f, - 2f, 1f + 1f, + 2f, + 2f, + 1f ) ) } + @Test fun isPerfectFitTest() { // Doesn't matter what it returns, just check it doesn't crash FocalPointUtil.isVerticalCrop( - 3f, 1f, - 6f, 2f + 3f, + 1f, + 6f, + 2f ) } @@ -74,60 +85,83 @@ class FocalPointUtilTest { fun perfectFitScaleDownTest() { assertEquals( FocalPointUtil.calculateScaling( - 2f, 5f, - 5f, 12.5f + 2f, + 5f, + 5f, + 12.5f ), - 0.4f, eps + 0.4f, + eps ) } + @Test fun perfectFitScaleUpTest() { assertEquals( FocalPointUtil.calculateScaling( - 2f, 5f, - 1f, 2.5f + 2f, + 5f, + 1f, + 2.5f ), - 2f, eps + 2f, + eps ) } + @Test fun verticalCropScaleUpTest() { assertEquals( FocalPointUtil.calculateScaling( - 2f, 1f, - 1f, 2f + 2f, + 1f, + 1f, + 2f ), - 2f, eps + 2f, + eps ) } + @Test fun verticalCropScaleDownTest() { assertEquals( FocalPointUtil.calculateScaling( - 4f, 3f, - 8f, 24f + 4f, + 3f, + 8f, + 24f ), - 0.5f, eps + 0.5f, + eps ) } + @Test fun horizontalCropScaleUpTest() { assertEquals( FocalPointUtil.calculateScaling( - 1f, 2f, - 2f, 1f + 1f, + 2f, + 2f, + 1f ), - 2f, eps + 2f, + eps ) } + @Test fun horizontalCropScaleDownTest() { assertEquals( FocalPointUtil.calculateScaling( - 3f, 4f, - 24f, 8f + 3f, + 4f, + 24f, + 8f ), - 0.5f, eps + 0.5f, + eps ) } @@ -136,21 +170,26 @@ class FocalPointUtilTest { fun toLowFocalOffsetTest() { assertEquals( FocalPointUtil.focalOffset(2f, 8f, 1f, 0.05f), - 0f, eps + 0f, + eps ) } + @Test fun toHighFocalOffsetTest() { assertEquals( FocalPointUtil.focalOffset(2f, 4f, 2f, 0.95f), - -6f, eps + -6f, + eps ) } + @Test fun possibleFocalOffsetTest() { assertEquals( FocalPointUtil.focalOffset(2f, 4f, 2f, 0.7f), - -4.6f, eps + -4.6f, + eps ) } } diff --git a/app/src/test/java/com/keylesspalace/tusky/MainActivityTest.kt b/app/src/test/java/com/keylesspalace/tusky/MainActivityTest.kt index 09927d209f..73ce0c1f5c 100644 --- a/app/src/test/java/com/keylesspalace/tusky/MainActivityTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/MainActivityTest.kt @@ -43,7 +43,7 @@ class MainActivityTest { note = "", url = "", avatar = "", - header = "", + header = "" ) private val accountEntity = AccountEntity( id = 1, @@ -104,7 +104,7 @@ class MainActivityTest { avatar = "https://mastodon.example/system/accounts/avatars/000/150/486/original/ab27d7ddd18a10ea.jpg" ), status = null, - report = null, + report = null ), accountEntity, true diff --git a/app/src/test/java/com/keylesspalace/tusky/StringUtilsTest.kt b/app/src/test/java/com/keylesspalace/tusky/StringUtilsTest.kt index 6910a365fe..518af95f64 100644 --- a/app/src/test/java/com/keylesspalace/tusky/StringUtilsTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/StringUtilsTest.kt @@ -29,7 +29,7 @@ class StringUtilsTest { "ab" to "abc", "cb" to "abc", "1" to "2", - "abc" to "abc", + "abc" to "abc" ) lessList.forEach { (l, r) -> assertTrue("$l < $r", l.isLessThanOrEqual(r)) } val notLessList = lessList.filterNot { (l, r) -> l == r }.map { (l, r) -> r to l } diff --git a/app/src/test/java/com/keylesspalace/tusky/components/timeline/CachedTimelineRemoteMediatorTest.kt b/app/src/test/java/com/keylesspalace/tusky/components/timeline/CachedTimelineRemoteMediatorTest.kt index 00802cabad..ad7bffd38a 100644 --- a/app/src/test/java/com/keylesspalace/tusky/components/timeline/CachedTimelineRemoteMediatorTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/components/timeline/CachedTimelineRemoteMediatorTest.kt @@ -74,7 +74,6 @@ class CachedTimelineRemoteMediatorTest { @Test @ExperimentalPagingApi fun `should return error when network call returns error code`() { - val remoteMediator = CachedTimelineRemoteMediator( accountManager = accountManager, api = mock { @@ -94,7 +93,6 @@ class CachedTimelineRemoteMediatorTest { @Test @ExperimentalPagingApi fun `should return error when network call fails`() { - val remoteMediator = CachedTimelineRemoteMediator( accountManager = accountManager, api = mock { @@ -113,7 +111,6 @@ class CachedTimelineRemoteMediatorTest { @Test @ExperimentalPagingApi fun `should not prepend statuses`() { - val remoteMediator = CachedTimelineRemoteMediator( accountManager = accountManager, api = mock(), @@ -142,11 +139,10 @@ class CachedTimelineRemoteMediatorTest { @Test @ExperimentalPagingApi fun `should refresh and insert placeholder when a whole page with no overlap to existing statuses is loaded`() { - val statusesAlreadyInDb = listOf( mockStatusEntityWithAccount("3"), mockStatusEntityWithAccount("2"), - mockStatusEntityWithAccount("1"), + mockStatusEntityWithAccount("1") ) db.insert(statusesAlreadyInDb) @@ -198,7 +194,7 @@ class CachedTimelineRemoteMediatorTest { ), mockStatusEntityWithAccount("3"), mockStatusEntityWithAccount("2"), - mockStatusEntityWithAccount("1"), + mockStatusEntityWithAccount("1") ) ) } @@ -206,11 +202,10 @@ class CachedTimelineRemoteMediatorTest { @Test @ExperimentalPagingApi fun `should refresh and not insert placeholder when less than a whole page is loaded`() { - val statusesAlreadyInDb = listOf( mockStatusEntityWithAccount("3"), mockStatusEntityWithAccount("2"), - mockStatusEntityWithAccount("1"), + mockStatusEntityWithAccount("1") ) db.insert(statusesAlreadyInDb) @@ -259,7 +254,7 @@ class CachedTimelineRemoteMediatorTest { mockStatusEntityWithAccount("5"), mockStatusEntityWithAccount("3"), mockStatusEntityWithAccount("2"), - mockStatusEntityWithAccount("1"), + mockStatusEntityWithAccount("1") ) ) } @@ -267,11 +262,10 @@ class CachedTimelineRemoteMediatorTest { @Test @ExperimentalPagingApi fun `should refresh and not insert placeholders when there is overlap with existing statuses`() { - val statusesAlreadyInDb = listOf( mockStatusEntityWithAccount("3"), mockStatusEntityWithAccount("2"), - mockStatusEntityWithAccount("1"), + mockStatusEntityWithAccount("1") ) db.insert(statusesAlreadyInDb) @@ -320,7 +314,7 @@ class CachedTimelineRemoteMediatorTest { mockStatusEntityWithAccount("4"), mockStatusEntityWithAccount("3"), mockStatusEntityWithAccount("2"), - mockStatusEntityWithAccount("1"), + mockStatusEntityWithAccount("1") ) ) } @@ -328,7 +322,6 @@ class CachedTimelineRemoteMediatorTest { @Test @ExperimentalPagingApi fun `should not try to refresh already cached statuses when db is empty`() { - val remoteMediator = CachedTimelineRemoteMediator( accountManager = accountManager, api = mock { @@ -371,11 +364,10 @@ class CachedTimelineRemoteMediatorTest { @Test @ExperimentalPagingApi fun `should remove deleted status from db and keep state of other cached statuses`() { - val statusesAlreadyInDb = listOf( mockStatusEntityWithAccount("3", expanded = true), mockStatusEntityWithAccount("2"), - mockStatusEntityWithAccount("1", expanded = false), + mockStatusEntityWithAccount("1", expanded = false) ) db.insert(statusesAlreadyInDb) @@ -422,12 +414,11 @@ class CachedTimelineRemoteMediatorTest { @Test @ExperimentalPagingApi fun `should not remove placeholder in timeline`() { - val statusesAlreadyInDb = listOf( mockStatusEntityWithAccount("8"), mockStatusEntityWithAccount("7"), mockPlaceholderEntityWithAccount("6"), - mockStatusEntityWithAccount("1"), + mockStatusEntityWithAccount("1") ) db.insert(statusesAlreadyInDb) @@ -474,7 +465,7 @@ class CachedTimelineRemoteMediatorTest { mockStatusEntityWithAccount("8"), mockStatusEntityWithAccount("7"), mockPlaceholderEntityWithAccount("6"), - mockStatusEntityWithAccount("1"), + mockStatusEntityWithAccount("1") ) ) } @@ -482,11 +473,10 @@ class CachedTimelineRemoteMediatorTest { @Test @ExperimentalPagingApi fun `should append statuses`() { - val statusesAlreadyInDb = listOf( mockStatusEntityWithAccount("8"), mockStatusEntityWithAccount("7"), - mockStatusEntityWithAccount("5"), + mockStatusEntityWithAccount("5") ) db.insert(statusesAlreadyInDb) @@ -527,7 +517,7 @@ class CachedTimelineRemoteMediatorTest { mockStatusEntityWithAccount("5"), mockStatusEntityWithAccount("3"), mockStatusEntityWithAccount("2"), - mockStatusEntityWithAccount("1"), + mockStatusEntityWithAccount("1") ) ) } diff --git a/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRemoteMediatorTest.kt b/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRemoteMediatorTest.kt index 808540dec2..9722fa5a8e 100644 --- a/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRemoteMediatorTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRemoteMediatorTest.kt @@ -47,7 +47,6 @@ class NetworkTimelineRemoteMediatorTest { @Test @ExperimentalPagingApi fun `should return error when network call returns error code`() { - val timelineViewModel: NetworkTimelineViewModel = mock { on { statusData } doReturn mutableListOf() onBlocking { fetchStatusesForKind(anyOrNull(), anyOrNull(), anyOrNull()) } doReturn Response.error(500, "".toResponseBody()) @@ -65,7 +64,6 @@ class NetworkTimelineRemoteMediatorTest { @Test @ExperimentalPagingApi fun `should return error when network call fails`() { - val timelineViewModel: NetworkTimelineViewModel = mock { on { statusData } doReturn mutableListOf() onBlocking { fetchStatusesForKind(anyOrNull(), anyOrNull(), anyOrNull()) } doThrow IOException() @@ -94,7 +92,8 @@ class NetworkTimelineRemoteMediatorTest { mockStatus("5") ), Headers.headersOf( - "Link", "; rel=\"next\", ; rel=\"prev\"" + "Link", + "; rel=\"next\", ; rel=\"prev\"" ) ) } @@ -116,7 +115,7 @@ class NetworkTimelineRemoteMediatorTest { val newStatusData = mutableListOf( mockStatusViewData("7"), mockStatusViewData("6"), - mockStatusViewData("5"), + mockStatusViewData("5") ) verify(timelineViewModel).nextKey = "4" @@ -131,7 +130,7 @@ class NetworkTimelineRemoteMediatorTest { val statuses: MutableList = mutableListOf( mockStatusViewData("3"), mockStatusViewData("2"), - mockStatusViewData("1"), + mockStatusViewData("1") ) val timelineViewModel: NetworkTimelineViewModel = mock { @@ -154,7 +153,7 @@ class NetworkTimelineRemoteMediatorTest { data = listOf( mockStatusViewData("3"), mockStatusViewData("2"), - mockStatusViewData("1"), + mockStatusViewData("1") ), prevKey = null, nextKey = "0" @@ -169,7 +168,7 @@ class NetworkTimelineRemoteMediatorTest { mockStatusViewData("4"), mockStatusViewData("3"), mockStatusViewData("2"), - mockStatusViewData("1"), + mockStatusViewData("1") ) assertTrue(result is RemoteMediator.MediatorResult.Success) @@ -183,7 +182,7 @@ class NetworkTimelineRemoteMediatorTest { val statuses: MutableList = mutableListOf( mockStatusViewData("3"), mockStatusViewData("2"), - mockStatusViewData("1"), + mockStatusViewData("1") ) val timelineViewModel: NetworkTimelineViewModel = mock { @@ -206,7 +205,7 @@ class NetworkTimelineRemoteMediatorTest { data = listOf( mockStatusViewData("3"), mockStatusViewData("2"), - mockStatusViewData("1"), + mockStatusViewData("1") ), prevKey = null, nextKey = "0" @@ -222,7 +221,7 @@ class NetworkTimelineRemoteMediatorTest { StatusViewData.Placeholder("7", false), mockStatusViewData("3"), mockStatusViewData("2"), - mockStatusViewData("1"), + mockStatusViewData("1") ) assertTrue(result is RemoteMediator.MediatorResult.Success) @@ -236,7 +235,7 @@ class NetworkTimelineRemoteMediatorTest { val statuses: MutableList = mutableListOf( mockStatusViewData("8"), mockStatusViewData("7"), - mockStatusViewData("5"), + mockStatusViewData("5") ) val timelineViewModel: NetworkTimelineViewModel = mock { @@ -259,7 +258,7 @@ class NetworkTimelineRemoteMediatorTest { data = listOf( mockStatusViewData("8"), mockStatusViewData("7"), - mockStatusViewData("5"), + mockStatusViewData("5") ), prevKey = null, nextKey = "3" @@ -275,7 +274,7 @@ class NetworkTimelineRemoteMediatorTest { mockStatusViewData("5"), mockStatusViewData("3"), mockStatusViewData("2"), - mockStatusViewData("1"), + mockStatusViewData("1") ) assertTrue(result is RemoteMediator.MediatorResult.Success) @@ -289,7 +288,7 @@ class NetworkTimelineRemoteMediatorTest { val statuses: MutableList = mutableListOf( mockStatusViewData("8"), mockStatusViewData("7"), - mockStatusViewData("5"), + mockStatusViewData("5") ) val timelineViewModel: NetworkTimelineViewModel = mock { @@ -302,7 +301,8 @@ class NetworkTimelineRemoteMediatorTest { mockStatus("1") ), Headers.headersOf( - "Link", "; rel=\"next\", ; rel=\"prev\"" + "Link", + "; rel=\"next\", ; rel=\"prev\"" ) ) } @@ -315,7 +315,7 @@ class NetworkTimelineRemoteMediatorTest { data = listOf( mockStatusViewData("8"), mockStatusViewData("7"), - mockStatusViewData("5"), + mockStatusViewData("5") ), prevKey = null, nextKey = "3" @@ -331,7 +331,7 @@ class NetworkTimelineRemoteMediatorTest { mockStatusViewData("5"), mockStatusViewData("3"), mockStatusViewData("2"), - mockStatusViewData("1"), + mockStatusViewData("1") ) verify(timelineViewModel).nextKey = "0" assertTrue(result is RemoteMediator.MediatorResult.Success) @@ -345,7 +345,7 @@ class NetworkTimelineRemoteMediatorTest { val statuses: MutableList = mutableListOf( mockStatusViewData("8"), mockStatusViewData("7"), - mockStatusViewData("5"), + mockStatusViewData("5") ) val timelineViewModel: NetworkTimelineViewModel = mock { @@ -361,7 +361,7 @@ class NetworkTimelineRemoteMediatorTest { data = listOf( mockStatusViewData("8"), mockStatusViewData("7"), - mockStatusViewData("5"), + mockStatusViewData("5") ), prevKey = null, nextKey = null diff --git a/app/src/test/java/com/keylesspalace/tusky/components/timeline/StatusMocker.kt b/app/src/test/java/com/keylesspalace/tusky/components/timeline/StatusMocker.kt index 47f4dbf004..282884976c 100644 --- a/app/src/test/java/com/keylesspalace/tusky/components/timeline/StatusMocker.kt +++ b/app/src/test/java/com/keylesspalace/tusky/components/timeline/StatusMocker.kt @@ -54,7 +54,7 @@ fun mockStatus( poll = null, card = null, language = null, - filtered = null, + filtered = null ) fun mockStatusViewData( @@ -110,7 +110,7 @@ fun mockStatusEntityWithAccount( fun mockPlaceholderEntityWithAccount( id: String, - userId: Long = 1, + userId: Long = 1 ): TimelineStatusWithAccount { return TimelineStatusWithAccount( status = Placeholder(id, false).toEntity(userId) diff --git a/app/src/test/java/com/keylesspalace/tusky/components/viewthread/ViewThreadViewModelTest.kt b/app/src/test/java/com/keylesspalace/tusky/components/viewthread/ViewThreadViewModelTest.kt index d48322b2c0..fcc49c7cb4 100644 --- a/app/src/test/java/com/keylesspalace/tusky/components/viewthread/ViewThreadViewModelTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/components/viewthread/ViewThreadViewModelTest.kt @@ -146,7 +146,7 @@ class ViewThreadViewModelTest { mockStatusViewData(id = "2", inReplyToId = "1", inReplyToAccountId = "1", isDetailed = true) ), detailedStatusPosition = 0, - revealButton = RevealButtonState.NO_BUTTON, + revealButton = RevealButtonState.NO_BUTTON ), viewModel.uiState.first() ) @@ -208,7 +208,7 @@ class ViewThreadViewModelTest { mockStatusViewData(id = "3", inReplyToId = "2", inReplyToAccountId = "1", spoilerText = "Test", isExpanded = true) ), detailedStatusPosition = 1, - revealButton = RevealButtonState.HIDE, + revealButton = RevealButtonState.HIDE ), viewModel.uiState.first() ) @@ -232,7 +232,7 @@ class ViewThreadViewModelTest { mockStatusViewData(id = "3", inReplyToId = "2", inReplyToAccountId = "1", spoilerText = "Test") ), detailedStatusPosition = 1, - revealButton = RevealButtonState.REVEAL, + revealButton = RevealButtonState.REVEAL ), viewModel.uiState.first() ) @@ -256,7 +256,7 @@ class ViewThreadViewModelTest { mockStatusViewData(id = "3", inReplyToId = "2", inReplyToAccountId = "1", spoilerText = "Test") ), detailedStatusPosition = 1, - revealButton = RevealButtonState.REVEAL, + revealButton = RevealButtonState.REVEAL ), viewModel.uiState.first() ) @@ -280,7 +280,7 @@ class ViewThreadViewModelTest { mockStatusViewData(id = "3", inReplyToId = "2", inReplyToAccountId = "1", spoilerText = "Test", bookmarked = false) ), detailedStatusPosition = 1, - revealButton = RevealButtonState.REVEAL, + revealButton = RevealButtonState.REVEAL ), viewModel.uiState.first() ) @@ -303,7 +303,7 @@ class ViewThreadViewModelTest { mockStatusViewData(id = "2", inReplyToId = "1", inReplyToAccountId = "1", isDetailed = true, spoilerText = "Test") ), detailedStatusPosition = 1, - revealButton = RevealButtonState.REVEAL, + revealButton = RevealButtonState.REVEAL ), viewModel.uiState.first() ) @@ -330,7 +330,7 @@ class ViewThreadViewModelTest { mockStatusViewData(id = "3", inReplyToId = "2", inReplyToAccountId = "1", spoilerText = "Test") ), detailedStatusPosition = 1, - revealButton = RevealButtonState.REVEAL, + revealButton = RevealButtonState.REVEAL ), viewModel.uiState.first() ) @@ -357,7 +357,7 @@ class ViewThreadViewModelTest { mockStatusViewData(id = "3", inReplyToId = "2", inReplyToAccountId = "1", spoilerText = "Test") ), detailedStatusPosition = 1, - revealButton = RevealButtonState.REVEAL, + revealButton = RevealButtonState.REVEAL ), viewModel.uiState.first() ) @@ -384,7 +384,7 @@ class ViewThreadViewModelTest { mockStatusViewData(id = "3", inReplyToId = "2", inReplyToAccountId = "1", spoilerText = "Test") ), detailedStatusPosition = 1, - revealButton = RevealButtonState.REVEAL, + revealButton = RevealButtonState.REVEAL ), viewModel.uiState.first() ) diff --git a/app/src/test/java/com/keylesspalace/tusky/db/TimelineDaoTest.kt b/app/src/test/java/com/keylesspalace/tusky/db/TimelineDaoTest.kt index 4332361a09..ca3bcfa4ee 100644 --- a/app/src/test/java/com/keylesspalace/tusky/db/TimelineDaoTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/db/TimelineDaoTest.kt @@ -65,7 +65,6 @@ class TimelineDaoTest { @Test fun cleanup() = runBlocking { - val statusesBeforeCleanup = listOf( makeStatus(statusId = 100), makeStatus(statusId = 10, authorServerId = "3"), @@ -80,7 +79,7 @@ class TimelineDaoTest { makeStatus(statusId = 100), makeStatus(statusId = 10, authorServerId = "3"), makeStatus(statusId = 8, reblog = true, authorServerId = "10"), - makeStatus(statusId = 2, accountId = 2, authorServerId = "5"), + makeStatus(statusId = 2, accountId = 2, authorServerId = "5") ) for ((status, author, reblogAuthor) in statusesBeforeCleanup) { @@ -123,7 +122,6 @@ class TimelineDaoTest { @Test fun overwriteDeletedStatus() = runBlocking { - val oldStatuses = listOf( makeStatus(statusId = 3), makeStatus(statusId = 2), @@ -217,7 +215,6 @@ class TimelineDaoTest { @Test fun deleteAllForInstance() = runBlocking { - val statusWithRedDomain1 = makeStatus( statusId = 15, accountId = 1, @@ -283,7 +280,6 @@ class TimelineDaoTest { @Test fun `should return correct topId`() = runBlocking { - val statusData = listOf( makeStatus( statusId = 4, @@ -318,7 +314,6 @@ class TimelineDaoTest { @Test fun `should return correct placeholderId after other ids`() = runBlocking { - val statusData = listOf( makeStatus(statusId = 1000), makePlaceholder(id = 99), @@ -345,7 +340,6 @@ class TimelineDaoTest { @Test fun `should return correct top placeholderId`() = runBlocking { - val statusData = listOf( makeStatus(statusId = 1000), makePlaceholder(id = 99), @@ -398,7 +392,7 @@ class TimelineDaoTest { createdAt: Long = statusId, authorServerId: String = "20", domain: String = "mastodon.example", - cardUrl: String? = null, + cardUrl: String? = null ): Triple { val author = TimelineAccountEntity( serverId = authorServerId, @@ -424,7 +418,9 @@ class TimelineDaoTest { emojis = "[]", bot = false ) - } else null + } else { + null + } val card = when (cardUrl) { null -> null @@ -465,7 +461,7 @@ class TimelineDaoTest { pinned = false, card = card, language = null, - filtered = null, + filtered = null ) return Triple(status, author, reblogAuthor) } diff --git a/app/src/test/java/com/keylesspalace/tusky/network/InstanceSwitchAuthInterceptorTest.kt b/app/src/test/java/com/keylesspalace/tusky/network/InstanceSwitchAuthInterceptorTest.kt index aa070489db..9a4f737d18 100644 --- a/app/src/test/java/com/keylesspalace/tusky/network/InstanceSwitchAuthInterceptorTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/network/InstanceSwitchAuthInterceptorTest.kt @@ -30,7 +30,6 @@ class InstanceSwitchAuthInterceptorTest { @Test fun `should make regular request when requested`() { - mockWebServer.enqueue(MockResponse()) val accountManager: AccountManager = mock { diff --git a/app/src/test/java/com/keylesspalace/tusky/usecase/TimelineCasesTest.kt b/app/src/test/java/com/keylesspalace/tusky/usecase/TimelineCasesTest.kt index ae62aedccc..e87e2d5f25 100644 --- a/app/src/test/java/com/keylesspalace/tusky/usecase/TimelineCasesTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/usecase/TimelineCasesTest.kt @@ -30,7 +30,6 @@ class TimelineCasesTest { @Before fun setup() { - api = mock() eventHub = EventHub() timelineCases = TimelineCases(api, eventHub) @@ -97,7 +96,7 @@ class TimelineCasesTest { poll = null, card = null, language = null, - filtered = null, + filtered = null ) } } diff --git a/app/src/test/java/com/keylesspalace/tusky/util/LinkHelperTest.kt b/app/src/test/java/com/keylesspalace/tusky/util/LinkHelperTest.kt index bfc4e965cf..3b053d0177 100644 --- a/app/src/test/java/com/keylesspalace/tusky/util/LinkHelperTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/util/LinkHelperTest.kt @@ -26,11 +26,11 @@ class LinkHelperTest { private val mentions = listOf( Status.Mention("1", "https://example.com/@user", "user", "user"), - Status.Mention("2", "https://example.com/@anotherUser", "anotherUser", "anotherUser"), + Status.Mention("2", "https://example.com/@anotherUser", "anotherUser", "anotherUser") ) private val tags = listOf( HashTag("Tusky", "https://example.com/Tags/Tusky"), - HashTag("mastodev", "https://example.com/Tags/mastodev"), + HashTag("mastodev", "https://example.com/Tags/mastodev") ) private val context: Context @@ -118,7 +118,7 @@ class LinkHelperTest { null, "foo bar baz", "http:/foo.bar", - "c:/foo/bar", + "c:/foo/bar" ).forEach { Assert.assertEquals("", getDomain(it)) } @@ -130,7 +130,7 @@ class LinkHelperTest { "example.com", "localhost", "sub.domain.com", - "10.45.0.123", + "10.45.0.123" ).forEach { domain -> listOf( "https://$domain", @@ -140,7 +140,7 @@ class LinkHelperTest { "https://$domain/foo/bar.html#", "https://$domain/foo/bar.html#anchor", "https://$domain/foo/bar.html?argument=value", - "https://$domain/foo/bar.html?argument=value&otherArgument=otherValue", + "https://$domain/foo/bar.html?argument=value&otherArgument=otherValue" ).forEach { url -> Assert.assertEquals(domain, getDomain(url)) } @@ -153,7 +153,7 @@ class LinkHelperTest { "https://www.example.com/foo/bar" to "example.com", "https://awww.example.com/foo/bar" to "awww.example.com", "http://www.localhost" to "localhost", - "https://wwwexample.com/" to "wwwexample.com", + "https://wwwexample.com/" to "wwwexample.com" ).forEach { (url, domain) -> Assert.assertEquals(domain, getDomain(url)) } @@ -247,7 +247,7 @@ class LinkHelperTest { "Another Place: another.place/", "Another Place - https://another.place", "Another Place | https://another.place/", - "Another Place https://another.place/path", + "Another Place https://another.place/path" ) asserts.forEach { Assert.assertTrue(markedUpContent.contains(context.getString(R.string.url_domain_notifier, it, "some.place"))) @@ -367,7 +367,7 @@ class LinkHelperTest { arrayOf("https://pixelfed.social/connyduck", true), arrayOf("https://gts.foo.bar/@goblin/statuses/01GH9XANCJ0TA8Y95VE9H3Y0Q2", true), arrayOf("https://gts.foo.bar/@goblin", true), - arrayOf("https://foo.microblog.pub/o/5b64045effd24f48a27d7059f6cb38f5", true), + arrayOf("https://foo.microblog.pub/o/5b64045effd24f48a27d7059f6cb38f5", true) ) } } diff --git a/app/src/test/java/com/keylesspalace/tusky/util/LocaleUtilsTest.kt b/app/src/test/java/com/keylesspalace/tusky/util/LocaleUtilsTest.kt index da4c48d551..3622770a7d 100644 --- a/app/src/test/java/com/keylesspalace/tusky/util/LocaleUtilsTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/util/LocaleUtilsTest.kt @@ -73,7 +73,7 @@ class LocaleUtilsTest { clientId = null, clientSecret = null, isActive = true, - defaultPostLanguage = configuredLanguages[1].orEmpty(), + defaultPostLanguage = configuredLanguages[1].orEmpty() ) ) } diff --git a/app/src/test/java/com/keylesspalace/tusky/util/RickRollTest.kt b/app/src/test/java/com/keylesspalace/tusky/util/RickRollTest.kt index c5bfad4261..9a5e8f819c 100644 --- a/app/src/test/java/com/keylesspalace/tusky/util/RickRollTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/util/RickRollTest.kt @@ -14,6 +14,7 @@ import org.robolectric.annotation.Config @RunWith(AndroidJUnit4::class) class RickRollTest { private lateinit var activity: Activity + @Before fun setupActivity() { val controller = Robolectric.buildActivity(Activity::class.java) @@ -23,12 +24,12 @@ class RickRollTest { @Test fun testShouldRickRoll() { listOf("gab.Com", "social.gab.ai", "whatever.GAB.com").forEach { - rollableDomain -> + rollableDomain -> assertTrue(shouldRickRoll(activity, rollableDomain)) } listOf("chaos.social", "notgab.com").forEach { - notRollableDomain -> + notRollableDomain -> assertFalse(shouldRickRoll(activity, notRollableDomain)) } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 72bb066780..5c316c77d7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -58,7 +58,7 @@ android-application = { id = "com.android.application", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-kapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlin" } kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" } -ktlint = "org.jlleitschuh.gradle.ktlint:11.0.0" +ktlint = "org.jlleitschuh.gradle.ktlint:11.3.1" [libraries] android-material = { module = "com.google.android.material:material", version.ref = "material" } From f435045b2e9b4e0b6a5285e1d0aaea8106069604 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Mon, 10 Apr 2023 14:29:56 +0200 Subject: [PATCH 008/156] Lint --- .../timeline/viewmodel/TimelineViewModel.kt | 26 +++++++++++++++++-- .../tusky/util/StatusDisplayOptions.kt | 2 +- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt index ad8704b6bd..9bdbf2b4a1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt @@ -24,7 +24,19 @@ import androidx.paging.PagingData import at.connyduck.calladapter.networkresult.fold import at.connyduck.calladapter.networkresult.getOrElse import com.keylesspalace.tusky.R -import com.keylesspalace.tusky.appstore.* +import com.keylesspalace.tusky.appstore.BlockEvent +import com.keylesspalace.tusky.appstore.BookmarkEvent +import com.keylesspalace.tusky.appstore.DomainMuteEvent +import com.keylesspalace.tusky.appstore.Event +import com.keylesspalace.tusky.appstore.EventHub +import com.keylesspalace.tusky.appstore.FavoriteEvent +import com.keylesspalace.tusky.appstore.MuteConversationEvent +import com.keylesspalace.tusky.appstore.MuteEvent +import com.keylesspalace.tusky.appstore.PinEvent +import com.keylesspalace.tusky.appstore.PreferenceChangedEvent +import com.keylesspalace.tusky.appstore.ReblogEvent +import com.keylesspalace.tusky.appstore.StatusDeletedEvent +import com.keylesspalace.tusky.appstore.UnfollowEvent import com.keylesspalace.tusky.components.preference.PreferencesFragment.ReadingOrder import com.keylesspalace.tusky.components.timeline.util.ifExpected import com.keylesspalace.tusky.db.AccountManager @@ -39,7 +51,17 @@ import com.keylesspalace.tusky.usecase.TimelineCases import com.keylesspalace.tusky.util.StatusDisplayOptions import com.keylesspalace.tusky.viewdata.StatusViewData import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.flow.* +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.debounce +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import retrofit2.HttpException diff --git a/app/src/main/java/com/keylesspalace/tusky/util/StatusDisplayOptions.kt b/app/src/main/java/com/keylesspalace/tusky/util/StatusDisplayOptions.kt index 675bd3d99c..481c1bd0b8 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/StatusDisplayOptions.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/StatusDisplayOptions.kt @@ -47,7 +47,7 @@ data class StatusDisplayOptions( @get:JvmName("showSensitiveMedia") val showSensitiveMedia: Boolean, @get:JvmName("openSpoiler") - val openSpoiler: Boolean, + val openSpoiler: Boolean ) { /** From 83ac6bade15f3a609e3061dae7acbee8b0188de8 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Sat, 15 Apr 2023 13:41:25 +0200 Subject: [PATCH 009/156] First pass at migrating CachedTimeline* to the new architecture - Add CachedTimelineRepository between CachedTimelineViewModel and CachedTimelineRemoteMediator - Update CachedTimelineViewModel to use the repository - Modify TImelineFragment to be closer to NotificationsFragment (easier to figure out what code can be reused) - Add DeveloperTools to clear the DB, and remove the most recent 40 statuses - Add many TODOs marking code to refactor or remove --- .../com/keylesspalace/tusky/MainActivity.kt | 19 +- .../timeline/CachedTimelineRepository.kt | 86 +++++ .../components/timeline/TimelineFragment.kt | 158 ++++++---- .../viewmodel/CachedTimelineRemoteMediator.kt | 96 +++--- .../viewmodel/CachedTimelineViewModel.kt | 298 +++++++++--------- .../timeline/viewmodel/TimelineViewModel.kt | 5 +- .../com/keylesspalace/tusky/db/TimelineDao.kt | 4 + .../tusky/usecase/DeveloperToolsUseCase.kt | 17 + 8 files changed, 416 insertions(+), 267 deletions(-) create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/timeline/CachedTimelineRepository.kt diff --git a/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt b/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt index eeda8cb240..734fa79f9e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt @@ -657,17 +657,26 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje return AlertDialog.Builder(this) .setTitle("Developer Tools") .setItems( - arrayOf("Create \"Load more\" gap") + arrayOf( + "Clear home timeline cache", + "Remove first 40 statuses" + ), ) { _, which -> Log.d(TAG, "Developer tools: $which") when (which) { 0 -> { - Log.d(TAG, "Creating \"Load more\" gap") + Log.d(TAG, "Clearing home timeline cache") lifecycleScope.launch { accountManager.activeAccount?.let { - developerToolsUseCase.createLoadMoreGap( - it.id - ) + developerToolsUseCase.clearHomeTimelineCache(it.id) + } + } + } + 1 -> { + Log.d(TAG, "Removing most recent 40 statuses") + lifecycleScope.launch { + accountManager.activeAccount?.let { + developerToolsUseCase.deleteFirstKStatuses(it.id, 40) } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/CachedTimelineRepository.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/CachedTimelineRepository.kt new file mode 100644 index 0000000000..5c08b05e20 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/CachedTimelineRepository.kt @@ -0,0 +1,86 @@ +/* + * 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.timeline + +import android.util.Log +import androidx.paging.ExperimentalPagingApi +import androidx.paging.InvalidatingPagingSourceFactory +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.PagingData +import com.google.gson.Gson +import com.keylesspalace.tusky.components.timeline.viewmodel.CachedTimelineRemoteMediator +import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineKind +import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.db.AppDatabase +import com.keylesspalace.tusky.db.TimelineStatusWithAccount +import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.util.EmptyPagingSource +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject + +// TODO: This is very similar to NetworkTimelineRepository. They could be merged (and the use +// of the cache be made a parameter to getStatusStream), except that they return Pagers of +// different generic types. +// +// NetworkTimelineRepository factory is , this is +// +// Re-writing the caching so that they can use the same types is the TODO. + +class CachedTimelineRepository @Inject constructor( + private val mastodonApi: MastodonApi, + private val accountManager: AccountManager, + private val appDatabase: AppDatabase, + private val gson: Gson +) { + private var factory: InvalidatingPagingSourceFactory? = null + + /** @return flow of Mastodon [TimelineStatusWithAccount], loaded in [pageSize] increments */ + @OptIn(ExperimentalPagingApi::class) + fun getStatusStream( + kind: TimelineKind, + pageSize: Int = PAGE_SIZE, + initialKey: String? = null + ): Flow> { + Log.d(TAG, "getStatusStream(): key: $initialKey") + + factory = InvalidatingPagingSourceFactory { + val activeAccount = accountManager.activeAccount + if (activeAccount == null) { + EmptyPagingSource() + } else { + appDatabase.timelineDao().getStatuses(activeAccount.id) + } + } + + return Pager( + config = PagingConfig(pageSize = PAGE_SIZE), + remoteMediator = CachedTimelineRemoteMediator(accountManager, mastodonApi, appDatabase, gson), + pagingSourceFactory = factory!! + ).flow + } + + fun invalidate() { + factory?.invalidate() + } + + companion object { + private const val TAG = "CachedTimelineRepository" + private const val PAGE_SIZE = 30 + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt index 218fe065f4..34dfea3cb0 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt @@ -17,7 +17,12 @@ package com.keylesspalace.tusky.components.timeline import android.os.Bundle import android.util.Log -import android.view.* +import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup import android.view.accessibility.AccessibilityManager import androidx.core.content.ContextCompat import androidx.core.view.MenuProvider @@ -44,7 +49,11 @@ import com.keylesspalace.tusky.components.accountlist.AccountListActivity import com.keylesspalace.tusky.components.accountlist.AccountListActivity.Companion.newIntent import com.keylesspalace.tusky.components.notifications.StatusActionSuccess import com.keylesspalace.tusky.components.preference.PreferencesFragment.ReadingOrder -import com.keylesspalace.tusky.components.timeline.viewmodel.* +import com.keylesspalace.tusky.components.timeline.viewmodel.CachedTimelineViewModel +import com.keylesspalace.tusky.components.timeline.viewmodel.NetworkTimelineViewModel +import com.keylesspalace.tusky.components.timeline.viewmodel.StatusAction +import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineKind +import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel import com.keylesspalace.tusky.databinding.FragmentTimelineBinding import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.di.ViewModelFactory @@ -54,7 +63,11 @@ import com.keylesspalace.tusky.interfaces.ActionButtonActivity import com.keylesspalace.tusky.interfaces.RefreshableFragment import com.keylesspalace.tusky.interfaces.ReselectableFragment import com.keylesspalace.tusky.interfaces.StatusActionListener -import com.keylesspalace.tusky.util.* +import com.keylesspalace.tusky.util.ListStatusAccessibilityDelegate +import com.keylesspalace.tusky.util.hide +import com.keylesspalace.tusky.util.show +import com.keylesspalace.tusky.util.unsafeLazy +import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.viewdata.AttachmentViewData import com.keylesspalace.tusky.viewdata.StatusViewData import com.mikepenz.iconics.IconicsDrawable @@ -62,7 +75,12 @@ import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial import com.mikepenz.iconics.utils.colorInt import com.mikepenz.iconics.utils.sizeDp import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.* +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChangedBy +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import java.io.IOException import javax.inject.Inject @@ -159,45 +177,6 @@ class TimelineFragment : setupSwipeRefreshLayout() setupRecyclerView() - adapter.addLoadStateListener { loadState -> - if (loadState.refresh != LoadState.Loading && loadState.source.refresh != LoadState.Loading) { - binding.swipeRefreshLayout.isRefreshing = false - } - - binding.statusView.hide() - binding.progressBar.hide() - - if (adapter.itemCount == 0) { - when (loadState.refresh) { - is LoadState.NotLoading -> { - if (loadState.append is LoadState.NotLoading && loadState.source.refresh is LoadState.NotLoading) { - binding.statusView.show() - binding.statusView.setup(R.drawable.elephant_friend_empty, R.string.message_empty) - if (timelineKind == TimelineKind.Home) { - binding.statusView.showHelp(R.string.help_empty_home) - } - } - } - is LoadState.Error -> { - binding.statusView.show() - - if ((loadState.refresh as LoadState.Error).error is IOException) { - binding.statusView.setup(R.drawable.elephant_offline, R.string.error_network) { - onRefresh() - } - } else { - binding.statusView.setup(R.drawable.elephant_error, R.string.error_generic) { - onRefresh() - } - } - } - is LoadState.Loading -> { - binding.progressBar.show() - } - } - } - } - adapter.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() { override fun onItemRangeInserted(positionStart: Int, itemCount: Int) { if (positionStart == 0 && adapter.itemCount != itemCount) { @@ -211,18 +190,13 @@ class TimelineFragment : } } } + // TODO: probably not necessary without placeholders/reading order. if (viewModel.uiState.value.readingOrder == ReadingOrder.OLDEST_FIRST) { updateReadingPositionForOldestFirst() } } }) - viewLifecycleOwner.lifecycleScope.launch { - viewModel.statuses.collectLatest { pagingData -> - adapter.submitData(pagingData) - } - } - if (actionButtonPresent()) { binding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { override fun onScrolled(view: RecyclerView, dx: Int, dy: Int) { @@ -264,6 +238,12 @@ class TimelineFragment : viewLifecycleOwner.lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { launch { + viewLifecycleOwner.lifecycleScope.launch { + viewModel.statuses.collectLatest { pagingData -> + adapter.submitData(pagingData) + } + } + // Show errors from the view model as snack bars. // // Errors are shown: @@ -345,6 +325,19 @@ class TimelineFragment : } } + // Refresh adapter on mutes and blocks + // TODO: Check that this makes sense and implement UiSuccess.* classes. +// launch { +// viewModel.uiSuccess.collectLatest { +// when (it) { +// is UiSuccess.Block, is UiSuccess.Mute, is UiSuccess.MuteConversation -> +// adapter.refresh() +// else -> { /* nothing to do */ +// } +// } +// } +// } + viewModel.uiState.collectLatest { // showMediaPreview changed? val previousMediaPreview = adapter.mediaPreviewEnabled @@ -380,20 +373,65 @@ class TimelineFragment : } } } - } - } - viewLifecycleOwner.lifecycleScope.launch { - eventHub.events.collect { event -> - when (event) { - is StatusComposedEvent -> { - val status = event.status - handleStatusComposeEvent(status) - } - is StatusEditedEvent -> { - handleStatusComposeEvent(event.status) + launch { + eventHub.events.collect { event -> + when (event) { + is StatusComposedEvent -> { + val status = event.status + handleStatusComposeEvent(status) + } + is StatusEditedEvent -> { + handleStatusComposeEvent(event.status) + } + } } } + + // Update the UI from the combined load state + adapter.loadStateFlow + .distinctUntilChangedBy { it.refresh } + .collect { loadState -> + Log.d(TAG, "loadState: $loadState") + Log.d(TAG, " adapter.itemCount: ${adapter.itemCount}") + Log.d(TAG, " refresh?: ${loadState.refresh}") + if (loadState.refresh != LoadState.Loading && loadState.source.refresh != LoadState.Loading) { + binding.swipeRefreshLayout.isRefreshing = false + } + + binding.statusView.hide() + binding.progressBar.hide() + + if (adapter.itemCount == 0) { + when (loadState.refresh) { + is LoadState.NotLoading -> { + if (loadState.append is LoadState.NotLoading && loadState.source.refresh is LoadState.NotLoading) { + binding.statusView.show() + binding.statusView.setup(R.drawable.elephant_friend_empty, R.string.message_empty) + if (timelineKind == TimelineKind.Home) { + binding.statusView.showHelp(R.string.help_empty_home) + } + } + } + is LoadState.Error -> { + binding.statusView.show() + + if ((loadState.refresh as LoadState.Error).error is IOException) { + binding.statusView.setup(R.drawable.elephant_offline, R.string.error_network) { + onRefresh() + } + } else { + binding.statusView.setup(R.drawable.elephant_error, R.string.error_generic) { + onRefresh() + } + } + } + is LoadState.Loading -> { + binding.progressBar.show() + } + } + } + } } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt index 89afefecda..1f912ad893 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt @@ -11,19 +11,19 @@ * Public License for more details. * * You should have received a copy of the GNU General Public License along with Tusky; if not, - * see . */ + * see . + */ package com.keylesspalace.tusky.components.timeline.viewmodel +import android.util.Log import androidx.paging.ExperimentalPagingApi import androidx.paging.LoadType import androidx.paging.PagingState import androidx.paging.RemoteMediator import androidx.room.withTransaction import com.google.gson.Gson -import com.keylesspalace.tusky.components.timeline.Placeholder import com.keylesspalace.tusky.components.timeline.toEntity -import com.keylesspalace.tusky.components.timeline.util.ifExpected import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.db.TimelineStatusEntity @@ -31,6 +31,7 @@ import com.keylesspalace.tusky.db.TimelineStatusWithAccount import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.network.MastodonApi import retrofit2.HttpException +import java.io.IOException @OptIn(ExperimentalPagingApi::class) class CachedTimelineRemoteMediator( @@ -40,8 +41,6 @@ class CachedTimelineRemoteMediator( private val gson: Gson ) : RemoteMediator() { - private var initialRefresh = false - private val timelineDao = db.timelineDao() private val activeAccount = accountManager.activeAccount!! @@ -53,72 +52,47 @@ class CachedTimelineRemoteMediator( return MediatorResult.Success(endOfPaginationReached = true) } - try { - var dbEmpty = false + Log.d(TAG, "load(), LoadType = $loadType") - val topPlaceholderId = if (loadType == LoadType.REFRESH) { - timelineDao.getTopPlaceholderId(activeAccount.id) - } else { - null // don't execute the query if it is not needed - } - - if (!initialRefresh && loadType == LoadType.REFRESH) { - val topId = timelineDao.getTopId(activeAccount.id) - topId?.let { cachedTopId -> - val statusResponse = api.homeTimeline( - maxId = cachedTopId, - sinceId = topPlaceholderId, // so already existing placeholders don't get accidentally overwritten - limit = state.config.pageSize - ) - - val statuses = statusResponse.body() - if (statusResponse.isSuccessful && statuses != null) { - db.withTransaction { - replaceStatusRange(statuses, state) - } - } - } - initialRefresh = true - dbEmpty = topId == null - } - - val statusResponse = when (loadType) { + return try { + val response = when (loadType) { LoadType.REFRESH -> { - api.homeTimeline(sinceId = topPlaceholderId, limit = state.config.pageSize) - } - LoadType.PREPEND -> { - return MediatorResult.Success(endOfPaginationReached = true) + api.homeTimeline(minId = null, limit = state.config.pageSize) } LoadType.APPEND -> { - val maxId = state.pages.findLast { it.data.isNotEmpty() }?.data?.lastOrNull()?.status?.serverId - api.homeTimeline(maxId = maxId, limit = state.config.pageSize) + val bottomId = timelineDao.getBottomId(activeAccount.id) + ?: return MediatorResult.Success(endOfPaginationReached = true) + Log.d(TAG, "Loading from bottomId: $bottomId") + api.homeTimeline(maxId = bottomId, limit = state.config.pageSize) + } + LoadType.PREPEND -> { + val topId = timelineDao.getTopId(activeAccount.id) + ?: return MediatorResult.Success(endOfPaginationReached = true) + Log.d(TAG, "Loading from topId: $topId") + api.homeTimeline(minId = topId, limit = state.config.pageSize) } } - val statuses = statusResponse.body() - if (!statusResponse.isSuccessful || statuses == null) { - return MediatorResult.Error(HttpException(statusResponse)) + val statuses = response.body() + if (!response.isSuccessful || statuses == null) { + return MediatorResult.Error(HttpException(response)) + } + + Log.d(TAG, "${statuses.size} - # statuses loaded") + if (statuses.isNotEmpty()) { + Log.d(TAG, "${statuses.first().id} - first ID") + Log.d(TAG, "${statuses.last().id} - last ID") } db.withTransaction { - val overlappedStatuses = replaceStatusRange(statuses, state) - - /* In case we loaded a whole page and there was no overlap with existing statuses, - we insert a placeholder because there might be even more unknown statuses */ - if (loadType == LoadType.REFRESH && overlappedStatuses == 0 && statuses.size == state.config.pageSize && !dbEmpty) { - /* This overrides the last of the newly loaded statuses with a placeholder - to guarantee the placeholder has an id that exists on the server as not all - servers handle client generated ids as expected */ - timelineDao.insertStatus( - Placeholder(statuses.last().id, loading = false).toEntity(activeAccount.id) - ) - } + replaceStatusRange(statuses, state) } + return MediatorResult.Success(endOfPaginationReached = statuses.isEmpty()) - } catch (e: Exception) { - return ifExpected(e) { - MediatorResult.Error(e) - } + } catch (e: IOException) { + MediatorResult.Error(e) + } catch (e: HttpException) { + MediatorResult.Error(e) } } @@ -175,4 +149,8 @@ class CachedTimelineRemoteMediator( } return overlappedStatuses } + + companion object { + private const val TAG = "CachedTimelineRemoteMediator" + } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt index c0c792704d..ca295c27f7 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt @@ -16,28 +16,20 @@ package com.keylesspalace.tusky.components.timeline.viewmodel import android.content.SharedPreferences -import android.util.Log import androidx.lifecycle.viewModelScope -import androidx.paging.ExperimentalPagingApi -import androidx.paging.Pager -import androidx.paging.PagingConfig +import androidx.paging.PagingData import androidx.paging.PagingSource import androidx.paging.cachedIn import androidx.paging.filter import androidx.paging.map -import androidx.room.withTransaction import com.google.gson.Gson import com.keylesspalace.tusky.appstore.BookmarkEvent import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.FavoriteEvent import com.keylesspalace.tusky.appstore.PinEvent import com.keylesspalace.tusky.appstore.ReblogEvent -import com.keylesspalace.tusky.components.preference.PreferencesFragment.ReadingOrder.NEWEST_FIRST -import com.keylesspalace.tusky.components.preference.PreferencesFragment.ReadingOrder.OLDEST_FIRST -import com.keylesspalace.tusky.components.timeline.Placeholder -import com.keylesspalace.tusky.components.timeline.toEntity +import com.keylesspalace.tusky.components.timeline.CachedTimelineRepository import com.keylesspalace.tusky.components.timeline.toViewData -import com.keylesspalace.tusky.components.timeline.util.ifExpected import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.db.TimelineStatusWithAccount @@ -46,15 +38,13 @@ import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.network.FilterModel import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.usecase.TimelineCases -import com.keylesspalace.tusky.util.EmptyPagingSource import com.keylesspalace.tusky.viewdata.StatusViewData -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.asExecutor +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch -import retrofit2.HttpException import javax.inject.Inject import kotlin.time.DurationUnit import kotlin.time.toDuration @@ -63,11 +53,12 @@ import kotlin.time.toDuration * TimelineViewModel that caches all statuses in a local database */ class CachedTimelineViewModel @Inject constructor( + private val repository: CachedTimelineRepository, timelineCases: TimelineCases, private val api: MastodonApi, - eventHub: EventHub, + private val eventHub: EventHub, accountManager: AccountManager, - sharedPreferences: SharedPreferences, + private val preferences: SharedPreferences, filterModel: FilterModel, private val db: AppDatabase, private val gson: Gson @@ -76,38 +67,53 @@ class CachedTimelineViewModel @Inject constructor( api, eventHub, accountManager, - sharedPreferences, + preferences, filterModel ) { private var currentPagingSource: PagingSource? = null - @OptIn(ExperimentalPagingApi::class) - override val statuses = Pager( - config = PagingConfig(pageSize = LOAD_AT_ONCE), - remoteMediator = CachedTimelineRemoteMediator(accountManager, api, db, gson), - pagingSourceFactory = { - val activeAccount = accountManager.activeAccount - if (activeAccount == null) { - EmptyPagingSource() - } else { - db.timelineDao().getStatuses(activeAccount.id) - }.also { newPagingSource -> - this.currentPagingSource = newPagingSource - } - } - ).flow - .map { pagingData -> - pagingData.map(Dispatchers.Default.asExecutor()) { timelineStatus -> - timelineStatus.toViewData(gson) - }.filter(Dispatchers.Default.asExecutor()) { statusViewData -> - shouldFilterStatus(statusViewData.asStatusOrNull()?.status) != Filter.Action.HIDE + override lateinit var statuses: Flow> + + @OptIn(ExperimentalCoroutinesApi::class) + override fun init(timelineKind: TimelineKind) { + super.init(timelineKind) + // TODO: Got here -- need to make sure that the boosts/replies pref changes don't + // blow away the database. + statuses = getUiPrefs() + .flatMapLatest { + getStatuses(timelineKind) + }.cachedIn(viewModelScope) + } + + /** @return Flow of statuses that make up the timeline of [kind] */ + private fun getStatuses( + kind: TimelineKind, + initialKey: String? = null + ): Flow> { + return repository.getStatusStream(kind = kind, initialKey = initialKey) + .map { pagingData -> + pagingData.map { + it.toViewData(gson) + } + }.map { + // TODO: These operations happen in a sub-optimal order. Ideally we could do + // any filtering of the statuses before the cost of converting them to viewdata. + // However, TimelineStatusWithAccount does not provide access to the `Status` + // type that is needed to do the filtering, so it has to be converted to a + // `StatusViewData` first. + it.filter { + shouldFilterStatus(it.asStatusOrNull()?.status) != Filter.Action.HIDE + } } - } - .flowOn(Dispatchers.Default) - .cachedIn(viewModelScope) + + // TODO: + // - Does the above need a .flowOn(Dispatches.Default) and a .cachedIn(viewModelScope)? + // - Ditto for the same code in NetworkTimelineViewModel (check NotificationsViewModel) + } init { + // TODO: This probably shouldn't be done here, but be a WorkManager job viewModelScope.launch { delay(5.toDuration(DurationUnit.SECONDS)) // delay so the db is not locked during initial ui refresh accountManager.activeAccount?.id?.let { accountId -> @@ -122,12 +128,14 @@ class CachedTimelineViewModel @Inject constructor( } override fun changeExpanded(expanded: Boolean, status: StatusViewData.Concrete) { + // TODO: Don't touch the db directly, go through the repository viewModelScope.launch { db.timelineDao().setExpanded(accountManager.activeAccount!!.id, status.id, expanded) } } override fun changeContentShowing(isShowing: Boolean, status: StatusViewData.Concrete) { + // TODO: Don't touch the db directly, go through the repository viewModelScope.launch { db.timelineDao() .setContentShowing(accountManager.activeAccount!!.id, status.id, isShowing) @@ -135,6 +143,7 @@ class CachedTimelineViewModel @Inject constructor( } override fun changeContentCollapsed(isCollapsed: Boolean, status: StatusViewData.Concrete) { + // TODO: Don't touch the db directly, go through the repository viewModelScope.launch { db.timelineDao() .setContentCollapsed(accountManager.activeAccount!!.id, status.id, isCollapsed) @@ -142,18 +151,21 @@ class CachedTimelineViewModel @Inject constructor( } override fun removeAllByAccountId(accountId: String) { + // TODO: Don't touch the db directly, go through the repository viewModelScope.launch { db.timelineDao().removeAllByUser(accountManager.activeAccount!!.id, accountId) } } override fun removeAllByInstance(instance: String) { + // TODO: Don't touch the db directly, go through the repository viewModelScope.launch { db.timelineDao().deleteAllFromInstance(accountManager.activeAccount!!.id, instance) } } override fun clearWarning(status: StatusViewData.Concrete) { + // TODO: Don't touch the db directly, go through the repository viewModelScope.launch { db.timelineDao().clearWarning(accountManager.activeAccount!!.id, status.actionableId) } @@ -164,106 +176,106 @@ class CachedTimelineViewModel @Inject constructor( } override fun loadMore(placeholderId: String) { - viewModelScope.launch { - try { - val timelineDao = db.timelineDao() - - val activeAccount = accountManager.activeAccount!! - - timelineDao.insertStatus( - Placeholder(placeholderId, loading = true).toEntity( - activeAccount.id - ) - ) - - val response = db.withTransaction { - val idAbovePlaceholder = timelineDao.getIdAbove(activeAccount.id, placeholderId) - val idBelowPlaceholder = timelineDao.getIdBelow(activeAccount.id, placeholderId) - when (readingOrder) { - // Using minId, loads up to LOAD_AT_ONCE statuses with IDs immediately - // after minId and no larger than maxId - OLDEST_FIRST -> api.homeTimeline( - maxId = idAbovePlaceholder, - minId = idBelowPlaceholder, - limit = LOAD_AT_ONCE - ) - // Using sinceId, loads up to LOAD_AT_ONCE statuses immediately before - // maxId, and no smaller than minId. - NEWEST_FIRST -> api.homeTimeline( - maxId = idAbovePlaceholder, - sinceId = idBelowPlaceholder, - limit = LOAD_AT_ONCE - ) - } - } - - val statuses = response.body() - if (!response.isSuccessful || statuses == null) { - loadMoreFailed(placeholderId, HttpException(response)) - return@launch - } - - db.withTransaction { - timelineDao.delete(activeAccount.id, placeholderId) - - val overlappedStatuses = if (statuses.isNotEmpty()) { - timelineDao.deleteRange( - activeAccount.id, - statuses.last().id, - statuses.first().id - ) - } else { - 0 - } - - for (status in statuses) { - timelineDao.insertAccount(status.account.toEntity(activeAccount.id, gson)) - status.reblog?.account?.toEntity(activeAccount.id, gson) - ?.let { rebloggedAccount -> - timelineDao.insertAccount(rebloggedAccount) - } - timelineDao.insertStatus( - status.toEntity( - timelineUserId = activeAccount.id, - gson = gson, - expanded = activeAccount.alwaysOpenSpoiler, - contentShowing = activeAccount.alwaysShowSensitiveMedia || !status.actionableStatus.sensitive, - contentCollapsed = true - ) - ) - } - - /* In case we loaded a whole page and there was no overlap with existing statuses, - we insert a placeholder because there might be even more unknown statuses */ - if (overlappedStatuses == 0 && statuses.size == LOAD_AT_ONCE) { - /* This overrides the first/last of the newly loaded statuses with a placeholder - to guarantee the placeholder has an id that exists on the server as not all - servers handle client generated ids as expected */ - val idToConvert = when (readingOrder) { - OLDEST_FIRST -> statuses.first().id - NEWEST_FIRST -> statuses.last().id - } - timelineDao.insertStatus( - Placeholder( - idToConvert, - loading = false - ).toEntity(activeAccount.id) - ) - } - } - } catch (e: Exception) { - ifExpected(e) { - loadMoreFailed(placeholderId, e) - } - } - } +// viewModelScope.launch { +// try { +// val timelineDao = db.timelineDao() +// +// val activeAccount = accountManager.activeAccount!! +// +// timelineDao.insertStatus( +// Placeholder(placeholderId, loading = true).toEntity( +// activeAccount.id +// ) +// ) +// +// val response = db.withTransaction { +// val idAbovePlaceholder = timelineDao.getIdAbove(activeAccount.id, placeholderId) +// val idBelowPlaceholder = timelineDao.getIdBelow(activeAccount.id, placeholderId) +// when (readingOrder) { +// // Using minId, loads up to LOAD_AT_ONCE statuses with IDs immediately +// // after minId and no larger than maxId +// OLDEST_FIRST -> api.homeTimeline( +// maxId = idAbovePlaceholder, +// minId = idBelowPlaceholder, +// limit = LOAD_AT_ONCE +// ) +// // Using sinceId, loads up to LOAD_AT_ONCE statuses immediately before +// // maxId, and no smaller than minId. +// NEWEST_FIRST -> api.homeTimeline( +// maxId = idAbovePlaceholder, +// sinceId = idBelowPlaceholder, +// limit = LOAD_AT_ONCE +// ) +// } +// } +// +// val statuses = response.body() +// if (!response.isSuccessful || statuses == null) { +// loadMoreFailed(placeholderId, HttpException(response)) +// return@launch +// } +// +// db.withTransaction { +// timelineDao.delete(activeAccount.id, placeholderId) +// +// val overlappedStatuses = if (statuses.isNotEmpty()) { +// timelineDao.deleteRange( +// activeAccount.id, +// statuses.last().id, +// statuses.first().id +// ) +// } else { +// 0 +// } +// +// for (status in statuses) { +// timelineDao.insertAccount(status.account.toEntity(activeAccount.id, gson)) +// status.reblog?.account?.toEntity(activeAccount.id, gson) +// ?.let { rebloggedAccount -> +// timelineDao.insertAccount(rebloggedAccount) +// } +// timelineDao.insertStatus( +// status.toEntity( +// timelineUserId = activeAccount.id, +// gson = gson, +// expanded = activeAccount.alwaysOpenSpoiler, +// contentShowing = activeAccount.alwaysShowSensitiveMedia || !status.actionableStatus.sensitive, +// contentCollapsed = true +// ) +// ) +// } +// +// /* In case we loaded a whole page and there was no overlap with existing statuses, +// we insert a placeholder because there might be even more unknown statuses */ +// if (overlappedStatuses == 0 && statuses.size == LOAD_AT_ONCE) { +// /* This overrides the first/last of the newly loaded statuses with a placeholder +// to guarantee the placeholder has an id that exists on the server as not all +// servers handle client generated ids as expected */ +// val idToConvert = when (readingOrder) { +// OLDEST_FIRST -> statuses.first().id +// NEWEST_FIRST -> statuses.last().id +// } +// timelineDao.insertStatus( +// Placeholder( +// idToConvert, +// loading = false +// ).toEntity(activeAccount.id) +// ) +// } +// } +// } catch (e: Exception) { +// ifExpected(e) { +// loadMoreFailed(placeholderId, e) +// } +// } +// } } private suspend fun loadMoreFailed(placeholderId: String, e: Exception) { - Log.w("CachedTimelineVM", "failed loading statuses", e) - val activeAccount = accountManager.activeAccount!! - db.timelineDao() - .insertStatus(Placeholder(placeholderId, loading = false).toEntity(activeAccount.id)) +// Log.w("CachedTimelineVM", "failed loading statuses", e) +// val activeAccount = accountManager.activeAccount!! +// db.timelineDao() +// .insertStatus(Placeholder(placeholderId, loading = false).toEntity(activeAccount.id)) } override fun handleReblogEvent(reblogEvent: ReblogEvent) { @@ -283,16 +295,20 @@ class CachedTimelineViewModel @Inject constructor( } override fun fullReload() { + // TODO: Don't touch the db directly, go through the repository +// viewModelScope.launch { +// val activeAccount = accountManager.activeAccount!! +// db.timelineDao().removeAll(activeAccount.id) +// } viewModelScope.launch { - val activeAccount = accountManager.activeAccount!! - db.timelineDao().removeAll(activeAccount.id) + invalidate() } } override suspend fun invalidate() { // invalidating when we don't have statuses yet can cause empty timelines because it cancels the network load if (db.timelineDao().getStatusCount(accountManager.activeAccount!!.id) > 0) { - currentPagingSource?.invalidate() + repository.invalidate() } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt index 9bdbf2b4a1..4907351616 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt @@ -359,13 +359,13 @@ abstract class TimelineViewModel( * @return Flow of relevant preferences that change the UI */ // TODO: Preferences should be in a repository - private fun getUiPrefs() = eventHub.events + protected fun getUiPrefs() = eventHub.events .filterIsInstance() .filter { UiPrefs.prefKeys.contains(it.preferenceKey) } .map { toPrefs() } .onStart { emit(toPrefs()) } - private fun toPrefs() = UiPrefs( + protected fun toPrefs() = UiPrefs( readingOrder = ReadingOrder.from(sharedPreferences.getString(PrefKeys.READING_ORDER, null)), showFabWhileScrolling = !sharedPreferences.getBoolean(PrefKeys.FAB_HIDE, false), showMediaPreview = accountManager.activeAccount!!.mediaPreviewEnabled @@ -442,6 +442,7 @@ abstract class TimelineViewModel( } } + // TODO: Update this so that the list of UIPrefs is correct private fun onPreferenceChanged(key: String) { when (key) { PrefKeys.TAB_FILTER_HOME_REPLIES -> { diff --git a/app/src/main/java/com/keylesspalace/tusky/db/TimelineDao.kt b/app/src/main/java/com/keylesspalace/tusky/db/TimelineDao.kt index 0bf1267fa6..7753c33bac 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/TimelineDao.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/TimelineDao.kt @@ -209,6 +209,10 @@ AND timelineUserId = :accountId @Query("SELECT serverId FROM TimelineStatusEntity WHERE timelineUserId = :accountId ORDER BY LENGTH(serverId) DESC, serverId DESC LIMIT 1") abstract suspend fun getTopId(accountId: Long): String? + @Query("SELECT serverId FROM TimelineStatusEntity WHERE timelineUserId = :accountId ORDER BY LENGTH(serverId) DESC, serverId LIMIT 1") + abstract suspend fun getBottomId(accountId: Long): String? + + // TODO: Part of placeholder code deletion @Query("SELECT serverId FROM TimelineStatusEntity WHERE timelineUserId = :accountId AND authorServerId IS NULL ORDER BY LENGTH(serverId) DESC, serverId DESC LIMIT 1") abstract suspend fun getTopPlaceholderId(accountId: Long): String? diff --git a/app/src/main/java/com/keylesspalace/tusky/usecase/DeveloperToolsUseCase.kt b/app/src/main/java/com/keylesspalace/tusky/usecase/DeveloperToolsUseCase.kt index 8724718a8d..6660f9f27c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/usecase/DeveloperToolsUseCase.kt +++ b/app/src/main/java/com/keylesspalace/tusky/usecase/DeveloperToolsUseCase.kt @@ -40,6 +40,23 @@ class DeveloperToolsUseCase @Inject constructor( } } + /** + * Clear the home timeline cache. + */ + suspend fun clearHomeTimelineCache(accountId: Long) { + timelineDao.removeAllStatuses(accountId) + } + + /** + * Delete first K statuses + */ + suspend fun deleteFirstKStatuses(accountId: Long, k: Int) { + db.withTransaction { + val ids = timelineDao.getMostRecentNStatusIds(accountId, 40) + timelineDao.deleteRange(accountId, ids.last(), ids.first()) + } + } + companion object { const val TAG = "DeveloperToolsUseCase" } From 728e6f15219001e60e75877c561701d88f08dc1c Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Sat, 15 Apr 2023 13:49:06 +0200 Subject: [PATCH 010/156] Fix typo --- .../components/timeline/viewmodel/NetworkTimelineViewModel.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt index 3cdbea6fff..66a2d7d2b0 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt @@ -69,7 +69,7 @@ class NetworkTimelineViewModel @Inject constructor( statuses = getStatuses(timelineKind) } - /** @return FLow of statuses that make up the timeline of [kind] */ + /** @return Flow of statuses that make up the timeline of [kind] */ private fun getStatuses( kind: TimelineKind, initialKey: String? = null From 4e965e256646b996bce2c7b6df5364eaa6e3cfb6 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Sat, 15 Apr 2023 13:49:18 +0200 Subject: [PATCH 011/156] Remove period --- .../tusky/components/timeline/NetworkTimelineRepository.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRepository.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRepository.kt index 9ae0c94315..bd35feaf38 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRepository.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRepository.kt @@ -34,7 +34,7 @@ class NetworkTimelineRepository @Inject constructor( ) { private var factory: InvalidatingPagingSourceFactory? = null - /** @return flow of Mastodon [Status], loaded in [pageSize] increments. */ + /** @return flow of Mastodon [Status], loaded in [pageSize] increments */ fun getStatusStream( kind: TimelineKind, pageSize: Int = PAGE_SIZE, From c633d997c9c4a95b6e57279df74767e163e86f3c Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Sat, 15 Apr 2023 14:29:29 +0200 Subject: [PATCH 012/156] Remove placeholder types - Remove StatusViewData.Placeholder - Move StatusViewData.Concrete into StatusViewData --- app/lint-baseline.xml | 18 +-- .../tusky/adapter/StatusBaseViewHolder.java | 26 ++-- .../adapter/StatusDetailedViewHolder.java | 6 +- .../tusky/adapter/StatusViewHolder.java | 6 +- .../conversation/ConversationEntity.kt | 4 +- .../conversation/ConversationViewData.kt | 4 +- .../conversation/ConversationViewHolder.java | 2 +- .../notifications/NotificationsViewModel.kt | 10 +- .../StatusNotificationViewHolder.kt | 2 +- .../components/report/ReportViewModel.kt | 2 +- .../report/adapter/StatusViewHolder.kt | 4 +- .../report/adapter/StatusesAdapter.kt | 10 +- .../components/search/SearchViewModel.kt | 22 ++-- .../search/adapter/SearchStatusesAdapter.kt | 8 +- .../fragments/SearchStatusesFragment.kt | 8 +- .../components/timeline/TimelineFragment.kt | 44 +++---- .../timeline/TimelinePagingAdapter.kt | 25 ++-- .../timeline/TimelineTypeMappers.kt | 8 +- .../viewmodel/CachedTimelineViewModel.kt | 12 +- .../viewmodel/NetworkTimelineViewModel.kt | 16 +-- .../timeline/viewmodel/TimelineViewModel.kt | 20 +-- .../components/viewthread/ThreadAdapter.kt | 16 +-- .../viewthread/ViewThreadFragment.kt | 2 +- .../viewthread/ViewThreadViewModel.kt | 36 ++--- .../tusky/db/TimelineStatusEntity.kt | 2 +- .../util/ListStatusAccessibilityDelegate.kt | 14 +- .../keylesspalace/tusky/util/ViewDataUtils.kt | 4 +- .../tusky/viewdata/NotificationViewData.kt | 2 +- .../tusky/viewdata/StatusViewData.kt | 124 ++++++++---------- .../tusky/StatusComparisonTest.kt | 12 +- .../NotificationsViewModelTestStatusAction.kt | 2 +- .../tusky/components/timeline/StatusMocker.kt | 2 +- doc/ViewModelInterface.md | 8 +- 33 files changed, 225 insertions(+), 256 deletions(-) diff --git a/app/lint-baseline.xml b/app/lint-baseline.xml index 034347521e..07a4c3ce9c 100644 --- a/app/lint-baseline.xml +++ b/app/lint-baseline.xml @@ -8553,7 +8553,7 @@ attachments) return true; } - private void setDescriptionForStatus(@NonNull StatusViewData.Concrete status, + private void setDescriptionForStatus(@NonNull StatusViewData status, StatusDisplayOptions statusDisplayOptions) { Context context = itemView.getContext(); Status actionable = status.getActionable(); @@ -876,7 +876,7 @@ private void setDescriptionForStatus(@NonNull StatusViewData.Concrete status, } private static CharSequence getReblogDescription(Context context, - @NonNull StatusViewData.Concrete status) { + @NonNull StatusViewData status) { Status reblog = status.getRebloggingStatus(); if (reblog != null) { return context @@ -887,7 +887,7 @@ private static CharSequence getReblogDescription(Context context, } private static CharSequence getMediaDescription(Context context, - @NonNull StatusViewData.Concrete status) { + @NonNull StatusViewData status) { if (status.getActionable().getAttachments().isEmpty()) { return ""; } @@ -908,7 +908,7 @@ private static CharSequence getMediaDescription(Context context, } private static CharSequence getContentWarningDescription(Context context, - @NonNull StatusViewData.Concrete status) { + @NonNull StatusViewData status) { if (!TextUtils.isEmpty(status.getSpoilerText())) { return context.getString(R.string.description_post_cw, status.getSpoilerText()); } else { @@ -942,7 +942,7 @@ protected static CharSequence getVisibilityDescription(Context context, Status.V return context.getString(resource); } - private CharSequence getPollDescription(@NonNull StatusViewData.Concrete status, + private CharSequence getPollDescription(@NonNull StatusViewData status, Context context, StatusDisplayOptions statusDisplayOptions) { PollViewData poll = PollViewDataKt.toViewData(status.getActionable().getPoll()); @@ -1075,7 +1075,7 @@ private CharSequence getPollInfoText(long timestamp, PollViewData poll, } protected void setupCard( - final StatusViewData.Concrete status, + final StatusViewData status, boolean expanded, final CardViewMode cardViewMode, final StatusDisplayOptions statusDisplayOptions, diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java index 76eda11074..66730786f1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java @@ -43,7 +43,7 @@ public StatusDetailedViewHolder(View view) { } @Override - protected void setMetaData(StatusViewData.Concrete statusViewData, StatusDisplayOptions statusDisplayOptions, StatusActionListener listener) { + protected void setMetaData(StatusViewData statusViewData, StatusDisplayOptions statusDisplayOptions, StatusActionListener listener) { Status status = statusViewData.getActionable(); @@ -149,12 +149,12 @@ private void setReblogAndFavCount(int reblogCount, int favCount, StatusActionLis } @Override - public void setupWithStatus(@NonNull final StatusViewData.Concrete status, + public void setupWithStatus(@NonNull final StatusViewData status, @NonNull final StatusActionListener listener, @NonNull StatusDisplayOptions statusDisplayOptions, @Nullable Object payloads) { // We never collapse statuses in the detail view - StatusViewData.Concrete uncollapsedStatus = (status.isCollapsible() && status.isCollapsed()) ? + StatusViewData uncollapsedStatus = (status.isCollapsible() && status.isCollapsed()) ? status.copyWithCollapsed(false) : status; diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java index 0763b96ae0..2f1a56bf01 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java @@ -60,7 +60,7 @@ public StatusViewHolder(View itemView) { } @Override - public void setupWithStatus(@NonNull StatusViewData.Concrete status, + public void setupWithStatus(@NonNull StatusViewData status, @NonNull final StatusActionListener listener, @NonNull StatusDisplayOptions statusDisplayOptions, @Nullable Object payloads) { @@ -127,7 +127,7 @@ protected void hideStatusInfo() { private void setupCollapsedState(boolean sensitive, boolean expanded, - final StatusViewData.Concrete status, + final StatusViewData status, final StatusActionListener listener) { /* input filter for TextViews have to be set before text */ if (status.isCollapsible() && (!sensitive || expanded)) { @@ -159,7 +159,7 @@ public void showStatusContent(boolean show) { @Override protected void toggleExpandedState(boolean sensitive, boolean expanded, - @NonNull StatusViewData.Concrete status, + @NonNull StatusViewData status, @NonNull StatusDisplayOptions statusDisplayOptions, @NonNull final StatusActionListener listener) { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationEntity.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationEntity.kt index dae0f1631e..2669c0260f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationEntity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationEntity.kt @@ -99,8 +99,8 @@ data class ConversationStatusEntity( val language: String? ) { - fun toViewData(): StatusViewData.Concrete { - return StatusViewData.Concrete( + fun toViewData(): StatusViewData { + return StatusViewData( status = Status( id = id, url = url, diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewData.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewData.kt index b197084df7..ceae7c6f7b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewData.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewData.kt @@ -23,7 +23,7 @@ data class ConversationViewData( val order: Int, val accounts: List, val unread: Boolean, - val lastStatus: StatusViewData.Concrete + val lastStatus: StatusViewData ) { fun toEntity( accountId: Long, @@ -54,7 +54,7 @@ data class ConversationViewData( } } -fun StatusViewData.Concrete.toConversationStatusEntity( +fun StatusViewData.toConversationStatusEntity( favourited: Boolean = status.favourited, bookmarked: Boolean = status.bookmarked, muted: Boolean = status.muted ?: false, diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewHolder.java index 722a9f3c52..20d95d159a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewHolder.java @@ -72,7 +72,7 @@ void setupWithConversation( @Nullable Object payloads ) { - StatusViewData.Concrete statusViewData = conversation.getLastStatus(); + StatusViewData statusViewData = conversation.getLastStatus(); Status status = statusViewData.getStatus(); if (payloads == null) { 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 index 329bce64a8..99a01fa73c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModel.kt @@ -169,25 +169,25 @@ sealed class NotificationActionSuccess( /** Actions the user can trigger on an individual status */ sealed class StatusAction( - open val statusViewData: StatusViewData.Concrete + open val statusViewData: StatusViewData ) : FallibleUiAction() { /** Set the bookmark state for a status */ - data class Bookmark(val state: Boolean, override val statusViewData: StatusViewData.Concrete) : + data class Bookmark(val state: Boolean, override val statusViewData: StatusViewData) : StatusAction(statusViewData) /** Set the favourite state for a status */ - data class Favourite(val state: Boolean, override val statusViewData: StatusViewData.Concrete) : + data class Favourite(val state: Boolean, override val statusViewData: StatusViewData) : StatusAction(statusViewData) /** Set the reblog state for a status */ - data class Reblog(val state: Boolean, override val statusViewData: StatusViewData.Concrete) : + data class Reblog(val state: Boolean, override val statusViewData: StatusViewData) : StatusAction(statusViewData) /** Vote in a poll */ data class VoteInPoll( val poll: Poll, val choices: List, - override val statusViewData: StatusViewData.Concrete + override val statusViewData: StatusViewData ) : StatusAction(statusViewData) } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/StatusNotificationViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/StatusNotificationViewHolder.kt index 0b1d8dca44..27c20ef273 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/StatusNotificationViewHolder.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/StatusNotificationViewHolder.kt @@ -321,7 +321,7 @@ internal class StatusNotificationViewHolder( private fun setupContentAndSpoiler( listener: LinkListener, - statusViewData: StatusViewData.Concrete, + statusViewData: StatusViewData, animateEmojis: Boolean ) { val shouldShowContentIfSpoiler = statusViewData.isExpanded diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/ReportViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/ReportViewModel.kt index f30726a23c..c56be7d30d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/report/ReportViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/report/ReportViewModel.kt @@ -77,7 +77,7 @@ class ReportViewModel @Inject constructor( ).flow } .map { pagingData -> - /* TODO: refactor reports to use the isShowingContent / isExpanded / isCollapsed attributes from StatusViewData.Concrete + /* TODO: refactor reports to use the isShowingContent / isExpanded / isCollapsed attributes from StatusViewData instead of StatusViewState */ pagingData.map { status -> status.toViewData(false, false, false) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusViewHolder.kt index 0ee7ec5db6..ba076b77eb 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusViewHolder.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusViewHolder.kt @@ -47,7 +47,7 @@ class StatusViewHolder( private val statusDisplayOptions: StatusDisplayOptions, private val viewState: StatusViewState, private val adapterHandler: AdapterHandler, - private val getStatusForPosition: (Int) -> StatusViewData.Concrete? + private val getStatusForPosition: (Int) -> StatusViewData? ) : RecyclerView.ViewHolder(binding.root) { private val mediaViewHeight = itemView.context.resources.getDimensionPixelSize(R.dimen.status_media_preview_height) @@ -77,7 +77,7 @@ class StatusViewHolder( binding.statusMediaPreviewContainer.clipToOutline = true } - fun bind(viewData: StatusViewData.Concrete) { + fun bind(viewData: StatusViewData) { binding.statusSelection.isChecked = adapterHandler.isStatusChecked(viewData.id) updateTextView() diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesAdapter.kt index 7e2c24174d..dab371ccf6 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesAdapter.kt @@ -29,9 +29,9 @@ class StatusesAdapter( private val statusDisplayOptions: StatusDisplayOptions, private val statusViewState: StatusViewState, private val adapterHandler: AdapterHandler -) : PagingDataAdapter(STATUS_COMPARATOR) { +) : PagingDataAdapter(STATUS_COMPARATOR) { - private val statusForPosition: (Int) -> StatusViewData.Concrete? = { position: Int -> + private val statusForPosition: (Int) -> StatusViewData? = { position: Int -> if (position != RecyclerView.NO_POSITION) getItem(position) else null } @@ -53,11 +53,11 @@ class StatusesAdapter( } companion object { - val STATUS_COMPARATOR = object : DiffUtil.ItemCallback() { - override fun areContentsTheSame(oldItem: StatusViewData.Concrete, newItem: StatusViewData.Concrete): Boolean = + val STATUS_COMPARATOR = object : DiffUtil.ItemCallback() { + override fun areContentsTheSame(oldItem: StatusViewData, newItem: StatusViewData): Boolean = oldItem == newItem - override fun areItemsTheSame(oldItem: StatusViewData.Concrete, newItem: StatusViewData.Concrete): Boolean = + override fun areItemsTheSame(oldItem: StatusViewData, newItem: StatusViewData): Boolean = oldItem.id == newItem.id } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/SearchViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/SearchViewModel.kt index 05c6832295..8762daf71b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/SearchViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/SearchViewModel.kt @@ -56,7 +56,7 @@ class SearchViewModel @Inject constructor( val alwaysShowSensitiveMedia = activeAccount?.alwaysShowSensitiveMedia ?: false val alwaysOpenSpoiler = activeAccount?.alwaysOpenSpoiler ?: false - private val loadedStatuses: MutableList = mutableListOf() + private val loadedStatuses: MutableList = mutableListOf() private val statusesPagingSourceFactory = SearchPagingSourceFactory(mastodonApi, SearchType.Status, loadedStatuses) { it.statuses.map { status -> @@ -101,7 +101,7 @@ class SearchViewModel @Inject constructor( hashtagsPagingSourceFactory.newSearch(query) } - fun removeItem(statusViewData: StatusViewData.Concrete) { + fun removeItem(statusViewData: StatusViewData) { viewModelScope.launch { if (timelineCases.delete(statusViewData.id).isSuccess) { if (loadedStatuses.remove(statusViewData)) { @@ -111,11 +111,11 @@ class SearchViewModel @Inject constructor( } } - fun expandedChange(statusViewData: StatusViewData.Concrete, expanded: Boolean) { + fun expandedChange(statusViewData: StatusViewData, expanded: Boolean) { updateStatusViewData(statusViewData.copy(isExpanded = expanded)) } - fun reblog(statusViewData: StatusViewData.Concrete, reblog: Boolean) { + fun reblog(statusViewData: StatusViewData, reblog: Boolean) { viewModelScope.launch { timelineCases.reblog(statusViewData.id, reblog).fold({ updateStatus( @@ -130,15 +130,15 @@ class SearchViewModel @Inject constructor( } } - fun contentHiddenChange(statusViewData: StatusViewData.Concrete, isShowing: Boolean) { + fun contentHiddenChange(statusViewData: StatusViewData, isShowing: Boolean) { updateStatusViewData(statusViewData.copy(isShowingContent = isShowing)) } - fun collapsedChange(statusViewData: StatusViewData.Concrete, collapsed: Boolean) { + fun collapsedChange(statusViewData: StatusViewData, collapsed: Boolean) { updateStatusViewData(statusViewData.copy(isCollapsed = collapsed)) } - fun voteInPoll(statusViewData: StatusViewData.Concrete, choices: MutableList) { + fun voteInPoll(statusViewData: StatusViewData, choices: MutableList) { val votedPoll = statusViewData.status.actionableStatus.poll!!.votedCopy(choices) updateStatus(statusViewData.status.copy(poll = votedPoll)) viewModelScope.launch { @@ -147,14 +147,14 @@ class SearchViewModel @Inject constructor( } } - fun favorite(statusViewData: StatusViewData.Concrete, isFavorited: Boolean) { + fun favorite(statusViewData: StatusViewData, isFavorited: Boolean) { updateStatus(statusViewData.status.copy(favourited = isFavorited)) viewModelScope.launch { timelineCases.favourite(statusViewData.id, isFavorited) } } - fun bookmark(statusViewData: StatusViewData.Concrete, isBookmarked: Boolean) { + fun bookmark(statusViewData: StatusViewData, isBookmarked: Boolean) { updateStatus(statusViewData.status.copy(bookmarked = isBookmarked)) viewModelScope.launch { timelineCases.bookmark(statusViewData.id, isBookmarked) @@ -185,14 +185,14 @@ class SearchViewModel @Inject constructor( } } - fun muteConversation(statusViewData: StatusViewData.Concrete, mute: Boolean) { + fun muteConversation(statusViewData: StatusViewData, mute: Boolean) { updateStatus(statusViewData.status.copy(muted = mute)) viewModelScope.launch { timelineCases.muteConversation(statusViewData.id, mute) } } - private fun updateStatusViewData(newStatusViewData: StatusViewData.Concrete) { + private fun updateStatusViewData(newStatusViewData: StatusViewData) { val idx = loadedStatuses.indexOfFirst { it.id == newStatusViewData.id } if (idx >= 0) { loadedStatuses[idx] = newStatusViewData diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchStatusesAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchStatusesAdapter.kt index a7c4c90ebf..6517308bc9 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchStatusesAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchStatusesAdapter.kt @@ -28,7 +28,7 @@ import com.keylesspalace.tusky.viewdata.StatusViewData class SearchStatusesAdapter( private val statusDisplayOptions: StatusDisplayOptions, private val statusListener: StatusActionListener -) : PagingDataAdapter(STATUS_COMPARATOR) { +) : PagingDataAdapter(STATUS_COMPARATOR) { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): StatusViewHolder { val view = LayoutInflater.from(parent.context) @@ -44,11 +44,11 @@ class SearchStatusesAdapter( companion object { - val STATUS_COMPARATOR = object : DiffUtil.ItemCallback() { - override fun areContentsTheSame(oldItem: StatusViewData.Concrete, newItem: StatusViewData.Concrete): Boolean = + val STATUS_COMPARATOR = object : DiffUtil.ItemCallback() { + override fun areContentsTheSame(oldItem: StatusViewData, newItem: StatusViewData): Boolean = oldItem == newItem - override fun areItemsTheSame(oldItem: StatusViewData.Concrete, newItem: StatusViewData.Concrete): Boolean = + override fun areItemsTheSame(oldItem: StatusViewData, newItem: StatusViewData): Boolean = oldItem.id == newItem.id } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt index 1144a06dcd..94259eb827 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt @@ -65,17 +65,17 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.launch import javax.inject.Inject -class SearchStatusesFragment : SearchFragment(), StatusActionListener { +class SearchStatusesFragment : SearchFragment(), StatusActionListener { @Inject lateinit var accountManager: AccountManager - override val data: Flow> + override val data: Flow> get() = viewModel.statusesFlow private val searchAdapter get() = super.adapter as SearchStatusesAdapter - override fun createAdapter(): PagingDataAdapter { + override fun createAdapter(): PagingDataAdapter { val preferences = PreferenceManager.getDefaultSharedPreferences(binding.searchRecyclerView.context) val statusDisplayOptions = StatusDisplayOptions( animateAvatars = preferences.getBoolean("animateGifAvatars", false), @@ -211,7 +211,7 @@ class SearchStatusesFragment : SearchFragment(), Status fun newInstance() = SearchStatusesFragment() } - private fun reply(status: StatusViewData.Concrete) { + private fun reply(status: StatusViewData) { val actionableStatus = status.actionable val mentionedUsernames = actionableStatus.mentions.map { it.username } .toMutableSet() diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt index 34dfea3cb0..2ab5bbf03f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt @@ -305,7 +305,7 @@ class TimelineFragment : } ?: return@collect val statusViewData = - indexedViewData.value as? StatusViewData.Concrete ?: return@collect + indexedViewData.value ?: return@collect val status = when (it) { is StatusActionSuccess.Bookmark -> @@ -319,7 +319,7 @@ class TimelineFragment : poll = it.action.poll.votedCopy(it.action.choices) ) } - (indexedViewData.value as StatusViewData.Concrete).status = status + (indexedViewData.value as StatusViewData).status = status adapter.notifyItemChanged(indexedViewData.index) } @@ -525,82 +525,82 @@ class TimelineFragment : } override fun onReply(position: Int) { - val status = adapter.peek(position)?.asStatusOrNull() ?: return + val status = adapter.peek(position) ?: return super.reply(status.status) } override fun onReblog(reblog: Boolean, position: Int) { - val statusViewData = adapter.peek(position) as? StatusViewData.Concrete ?: return + val statusViewData = adapter.peek(position) ?: return viewModel.accept(StatusAction.Reblog(reblog, statusViewData)) } override fun onFavourite(favourite: Boolean, position: Int) { - val statusViewData = adapter.peek(position) as? StatusViewData.Concrete ?: return + val statusViewData = adapter.peek(position) ?: return viewModel.accept(StatusAction.Favourite(favourite, statusViewData)) } override fun onBookmark(bookmark: Boolean, position: Int) { - val statusViewData = adapter.peek(position) as? StatusViewData.Concrete ?: return + val statusViewData = adapter.peek(position) ?: return viewModel.accept(StatusAction.Bookmark(bookmark, statusViewData)) } override fun onVoteInPoll(position: Int, choices: List) { - val statusViewData = adapter.peek(position) as? StatusViewData.Concrete ?: return + val statusViewData = adapter.peek(position) ?: return val poll = statusViewData.status.poll ?: return viewModel.accept(StatusAction.VoteInPoll(poll, choices, statusViewData)) } override fun clearWarningAction(position: Int) { - val status = adapter.peek(position)?.asStatusOrNull() ?: return + val status = adapter.peek(position) ?: return viewModel.clearWarning(status) } override fun onMore(view: View, position: Int) { - val status = adapter.peek(position)?.asStatusOrNull() ?: return + val status = adapter.peek(position) ?: return super.more(status.status, view, position) } override fun onOpenReblog(position: Int) { - val status = adapter.peek(position)?.asStatusOrNull() ?: return + val status = adapter.peek(position) ?: return super.openReblog(status.status) } override fun onExpandedChange(expanded: Boolean, position: Int) { - val status = adapter.peek(position)?.asStatusOrNull() ?: return + val status = adapter.peek(position) ?: return viewModel.changeExpanded(expanded, status) } override fun onContentHiddenChange(isShowing: Boolean, position: Int) { - val status = adapter.peek(position)?.asStatusOrNull() ?: return + val status = adapter.peek(position) ?: return viewModel.changeContentShowing(isShowing, status) } override fun onShowReblogs(position: Int) { - val statusId = adapter.peek(position)?.asStatusOrNull()?.id ?: return + val statusId = adapter.peek(position)?.id ?: return val intent = newIntent(requireContext(), AccountListActivity.Type.REBLOGGED, statusId) (activity as BaseActivity).startActivityWithSlideInAnimation(intent) } override fun onShowFavs(position: Int) { - val statusId = adapter.peek(position)?.asStatusOrNull()?.id ?: return + val statusId = adapter.peek(position)?.id ?: return val intent = newIntent(requireContext(), AccountListActivity.Type.FAVOURITED, statusId) (activity as BaseActivity).startActivityWithSlideInAnimation(intent) } override fun onLoadMore(position: Int) { - val placeholder = adapter.peek(position)?.asPlaceholderOrNull() ?: return - loadMorePosition = position - statusIdBelowLoadMore = adapter.peek(position + 1)?.id - viewModel.loadMore(placeholder.id) +// val placeholder = adapter.peek(position)?.asPlaceholderOrNull() ?: return +// loadMorePosition = position +// statusIdBelowLoadMore = adapter.peek(position + 1)?.id +// viewModel.loadMore(placeholder.id) } override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) { - val status = adapter.peek(position)?.asStatusOrNull() ?: return + val status = adapter.peek(position) ?: return viewModel.changeContentCollapsed(isCollapsed, status) } override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) { - val status = adapter.peek(position)?.asStatusOrNull() ?: return + val status = adapter.peek(position) ?: return super.viewMedia( attachmentIndex, AttachmentViewData.list(status.actionable), @@ -609,7 +609,7 @@ class TimelineFragment : } override fun onViewThread(position: Int) { - val status = adapter.peek(position)?.asStatusOrNull() ?: return + val status = adapter.peek(position) ?: return super.viewThread(status.actionable.id, status.actionable.url) } @@ -653,7 +653,7 @@ class TimelineFragment : } public override fun removeItem(position: Int) { - val status = adapter.peek(position)?.asStatusOrNull() ?: return + val status = adapter.peek(position) ?: return viewModel.removeStatusWithId(status.id) } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelinePagingAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelinePagingAdapter.kt index a232989b3a..9c492dcb14 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelinePagingAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelinePagingAdapter.kt @@ -78,26 +78,19 @@ class TimelinePagingAdapter( position: Int, payloads: List<*>? ) { - val status = getItem(position) - if (status is StatusViewData.Placeholder) { - val holder = viewHolder as PlaceholderViewHolder - holder.setup(statusListener, status.isLoading) - } else if (status is StatusViewData.Concrete) { - val holder = viewHolder as StatusViewHolder - holder.setupWithStatus( - status, - statusListener, - statusDisplayOptions, - if (payloads != null && payloads.isNotEmpty()) payloads[0] else null - ) - } + val status = getItem(position) ?: return + val holder = viewHolder as StatusViewHolder + holder.setupWithStatus( + status, + statusListener, + statusDisplayOptions, + if (payloads != null && payloads.isNotEmpty()) payloads[0] else null + ) } override fun getItemViewType(position: Int): Int { val viewData = getItem(position) - return if (viewData is StatusViewData.Placeholder) { - VIEW_TYPE_PLACEHOLDER - } else if (viewData?.filterAction == Filter.Action.WARN) { + return if (viewData?.filterAction == Filter.Action.WARN) { VIEW_TYPE_STATUS_FILTERED } else { VIEW_TYPE_STATUS diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineTypeMappers.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineTypeMappers.kt index 04be236eed..3478c404c7 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineTypeMappers.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineTypeMappers.kt @@ -15,7 +15,6 @@ package com.keylesspalace.tusky.components.timeline -import android.util.Log import com.google.gson.Gson import com.google.gson.reflect.TypeToken import com.keylesspalace.tusky.db.TimelineAccountEntity @@ -155,11 +154,6 @@ fun Status.toEntity( } fun TimelineStatusWithAccount.toViewData(gson: Gson, isDetailed: Boolean = false): StatusViewData { - if (this.account == null) { - Log.d(TAG, "Constructing Placeholder(${this.status.serverId}, ${this.status.expanded})") - return StatusViewData.Placeholder(this.status.serverId, this.status.expanded) - } - val attachments: ArrayList = gson.fromJson(status.attachments, attachmentArrayListType) ?: arrayListOf() val mentions: List = gson.fromJson(status.mentions, mentionListType) ?: emptyList() val tags: List? = gson.fromJson(status.tags, tagListType) @@ -266,7 +260,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson, isDetailed: Boolean = false filtered = status.filtered ) } - return StatusViewData.Concrete( + return StatusViewData( status = status, isExpanded = this.status.expanded, isShowingContent = this.status.contentShowing, diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt index ca295c27f7..5f25a04666 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt @@ -103,7 +103,7 @@ class CachedTimelineViewModel @Inject constructor( // type that is needed to do the filtering, so it has to be converted to a // `StatusViewData` first. it.filter { - shouldFilterStatus(it.asStatusOrNull()?.status) != Filter.Action.HIDE + shouldFilterStatus(it?.status) != Filter.Action.HIDE } } @@ -123,18 +123,18 @@ class CachedTimelineViewModel @Inject constructor( } } - override fun updatePoll(newPoll: Poll, status: StatusViewData.Concrete) { + override fun updatePoll(newPoll: Poll, status: StatusViewData) { // handled by CacheUpdater } - override fun changeExpanded(expanded: Boolean, status: StatusViewData.Concrete) { + override fun changeExpanded(expanded: Boolean, status: StatusViewData) { // TODO: Don't touch the db directly, go through the repository viewModelScope.launch { db.timelineDao().setExpanded(accountManager.activeAccount!!.id, status.id, expanded) } } - override fun changeContentShowing(isShowing: Boolean, status: StatusViewData.Concrete) { + override fun changeContentShowing(isShowing: Boolean, status: StatusViewData) { // TODO: Don't touch the db directly, go through the repository viewModelScope.launch { db.timelineDao() @@ -142,7 +142,7 @@ class CachedTimelineViewModel @Inject constructor( } } - override fun changeContentCollapsed(isCollapsed: Boolean, status: StatusViewData.Concrete) { + override fun changeContentCollapsed(isCollapsed: Boolean, status: StatusViewData) { // TODO: Don't touch the db directly, go through the repository viewModelScope.launch { db.timelineDao() @@ -164,7 +164,7 @@ class CachedTimelineViewModel @Inject constructor( } } - override fun clearWarning(status: StatusViewData.Concrete) { + override fun clearWarning(status: StatusViewData) { // TODO: Don't touch the db directly, go through the repository viewModelScope.launch { db.timelineDao().clearWarning(accountManager.activeAccount!!.id, status.actionableId) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt index 66a2d7d2b0..88f5e21b70 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt @@ -92,25 +92,25 @@ class NetworkTimelineViewModel @Inject constructor( } } - override fun updatePoll(newPoll: Poll, status: StatusViewData.Concrete) { + override fun updatePoll(newPoll: Poll, status: StatusViewData) { status.copy( status = status.status.copy(poll = newPoll) ).update() } - override fun changeExpanded(expanded: Boolean, status: StatusViewData.Concrete) { + override fun changeExpanded(expanded: Boolean, status: StatusViewData) { status.copy( isExpanded = expanded ).update() } - override fun changeContentShowing(isShowing: Boolean, status: StatusViewData.Concrete) { + override fun changeContentShowing(isShowing: Boolean, status: StatusViewData) { status.copy( isShowingContent = isShowing ).update() } - override fun changeContentCollapsed(isCollapsed: Boolean, status: StatusViewData.Concrete) { + override fun changeContentCollapsed(isCollapsed: Boolean, status: StatusViewData) { status.copy( isCollapsed = isCollapsed ).update() @@ -246,7 +246,7 @@ class NetworkTimelineViewModel @Inject constructor( currentSource?.invalidate() } - override fun clearWarning(status: StatusViewData.Concrete) { + override fun clearWarning(status: StatusViewData) { updateActionableStatusById(status.actionableId) { it.copy(filtered = null) } @@ -256,7 +256,7 @@ class NetworkTimelineViewModel @Inject constructor( currentSource?.invalidate() } - private fun StatusViewData.Concrete.update() { + private fun StatusViewData.update() { // val position = statusData.indexOfFirst { viewData -> viewData.asStatusOrNull()?.id == this.id } // statusData[position] = this // currentSource?.invalidate() @@ -264,7 +264,7 @@ class NetworkTimelineViewModel @Inject constructor( private inline fun updateStatusById( id: String, - updater: (StatusViewData.Concrete) -> StatusViewData.Concrete + updater: (StatusViewData) -> StatusViewData ) { val pos = statusData.indexOfFirst { it.id == id } if (pos == -1) return @@ -288,7 +288,7 @@ class NetworkTimelineViewModel @Inject constructor( // private inline fun updateViewDataAt( // position: Int, -// updater: (StatusViewData.Concrete) -> StatusViewData.Concrete +// updater: (StatusViewData) -> StatusViewData // ) { // val status = statusData.getOrNull(position)?.asStatusOrNull() ?: return // statusData[position] = updater(status) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt index 4907351616..aeee8bec36 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt @@ -137,24 +137,24 @@ sealed class UiSuccess { } /** Actions the user can trigger on an individual status */ -sealed class StatusAction(open val statusViewData: StatusViewData.Concrete) : FallibleUiAction() { +sealed class StatusAction(open val statusViewData: StatusViewData) : FallibleUiAction() { /** Set the bookmark state for a status */ - data class Bookmark(val state: Boolean, override val statusViewData: StatusViewData.Concrete) : + data class Bookmark(val state: Boolean, override val statusViewData: StatusViewData) : StatusAction(statusViewData) /** Set the favourite state for a status */ - data class Favourite(val state: Boolean, override val statusViewData: StatusViewData.Concrete) : + data class Favourite(val state: Boolean, override val statusViewData: StatusViewData) : StatusAction(statusViewData) /** Set the reblog state for a status */ - data class Reblog(val state: Boolean, override val statusViewData: StatusViewData.Concrete) : + data class Reblog(val state: Boolean, override val statusViewData: StatusViewData) : StatusAction(statusViewData) /** Vote in a poll */ data class VoteInPoll( val poll: Poll, val choices: List, - override val statusViewData: StatusViewData.Concrete + override val statusViewData: StatusViewData ) : StatusAction(statusViewData) } @@ -396,13 +396,13 @@ abstract class TimelineViewModel( reloadFilters() } - abstract fun updatePoll(newPoll: Poll, status: StatusViewData.Concrete) + abstract fun updatePoll(newPoll: Poll, status: StatusViewData) - abstract fun changeExpanded(expanded: Boolean, status: StatusViewData.Concrete) + abstract fun changeExpanded(expanded: Boolean, status: StatusViewData) - abstract fun changeContentShowing(isShowing: Boolean, status: StatusViewData.Concrete) + abstract fun changeContentShowing(isShowing: Boolean, status: StatusViewData) - abstract fun changeContentCollapsed(isCollapsed: Boolean, status: StatusViewData.Concrete) + abstract fun changeContentCollapsed(isCollapsed: Boolean, status: StatusViewData) abstract fun removeAllByAccountId(accountId: String) @@ -422,7 +422,7 @@ abstract class TimelineViewModel( abstract fun fullReload() - abstract fun clearWarning(status: StatusViewData.Concrete) + abstract fun clearWarning(status: StatusViewData) /** Triggered when currently displayed data must be reloaded. */ protected abstract suspend fun invalidate() diff --git a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ThreadAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ThreadAdapter.kt index 1edffcaf90..7a13d47a3e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ThreadAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ThreadAdapter.kt @@ -31,7 +31,7 @@ import com.keylesspalace.tusky.viewdata.StatusViewData class ThreadAdapter( private val statusDisplayOptions: StatusDisplayOptions, private val statusActionListener: StatusActionListener -) : ListAdapter(ThreadDifferCallback) { +) : ListAdapter(ThreadDifferCallback) { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): StatusBaseViewHolder { val inflater = LayoutInflater.from(parent.context) @@ -71,24 +71,24 @@ class ThreadAdapter( private const val VIEW_TYPE_STATUS_DETAILED = 1 private const val VIEW_TYPE_STATUS_FILTERED = 2 - val ThreadDifferCallback = object : DiffUtil.ItemCallback() { + val ThreadDifferCallback = object : DiffUtil.ItemCallback() { override fun areItemsTheSame( - oldItem: StatusViewData.Concrete, - newItem: StatusViewData.Concrete + oldItem: StatusViewData, + newItem: StatusViewData ): Boolean { return oldItem.id == newItem.id } override fun areContentsTheSame( - oldItem: StatusViewData.Concrete, - newItem: StatusViewData.Concrete + oldItem: StatusViewData, + newItem: StatusViewData ): Boolean { return false // Items are different always. It allows to refresh timestamp on every view holder update } override fun getChangePayload( - oldItem: StatusViewData.Concrete, - newItem: StatusViewData.Concrete + oldItem: StatusViewData, + newItem: StatusViewData ): Any? { return if (oldItem == newItem) { // If items are equal - update timestamp only diff --git a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadFragment.kt index cbcedd968b..a40ef8bf5c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadFragment.kt @@ -364,7 +364,7 @@ class ViewThreadFragment : } override fun onViewUrl(url: String) { - val status: StatusViewData.Concrete? = viewModel.detailedStatus() + val status: StatusViewData? = viewModel.detailedStatus() if (status != null && status.status.url == url) { // already viewing the status with this url // probably just a preview federated and the user is clicking again to view more -> open the browser diff --git a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadViewModel.kt index f4b94400b0..86bd360d14 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadViewModel.kt @@ -114,7 +114,7 @@ class ViewThreadViewModel @Inject constructor( val viewData = timelineStatus.toViewData( gson, isDetailed = true - ) as StatusViewData.Concrete + ) // Return the correct status, depending on which one matched. If you do not do // this the status IDs will be different between the status that's displayed with @@ -181,7 +181,7 @@ class ViewThreadViewModel @Inject constructor( loadThread(id) } - fun detailedStatus(): StatusViewData.Concrete? { + fun detailedStatus(): StatusViewData? { return when (val uiState = _uiState.value) { is ThreadUiState.Success -> uiState.statusViewData.find { status -> status.isDetailed @@ -191,7 +191,7 @@ class ViewThreadViewModel @Inject constructor( } } - fun reblog(reblog: Boolean, status: StatusViewData.Concrete): Job = viewModelScope.launch { + fun reblog(reblog: Boolean, status: StatusViewData): Job = viewModelScope.launch { try { timelineCases.reblog(status.actionableId, reblog).getOrThrow() } catch (t: Exception) { @@ -201,7 +201,7 @@ class ViewThreadViewModel @Inject constructor( } } - fun favorite(favorite: Boolean, status: StatusViewData.Concrete): Job = viewModelScope.launch { + fun favorite(favorite: Boolean, status: StatusViewData): Job = viewModelScope.launch { try { timelineCases.favourite(status.actionableId, favorite).getOrThrow() } catch (t: Exception) { @@ -211,7 +211,7 @@ class ViewThreadViewModel @Inject constructor( } } - fun bookmark(bookmark: Boolean, status: StatusViewData.Concrete): Job = viewModelScope.launch { + fun bookmark(bookmark: Boolean, status: StatusViewData): Job = viewModelScope.launch { try { timelineCases.bookmark(status.actionableId, bookmark).getOrThrow() } catch (t: Exception) { @@ -221,7 +221,7 @@ class ViewThreadViewModel @Inject constructor( } } - fun voteInPoll(choices: List, status: StatusViewData.Concrete): Job = viewModelScope.launch { + fun voteInPoll(choices: List, status: StatusViewData): Job = viewModelScope.launch { val poll = status.status.actionableStatus.poll ?: run { Log.w(TAG, "No poll on status ${status.id}") return@launch @@ -241,7 +241,7 @@ class ViewThreadViewModel @Inject constructor( } } - fun removeStatus(statusToRemove: StatusViewData.Concrete) { + fun removeStatus(statusToRemove: StatusViewData) { updateSuccess { uiState -> uiState.copy( statusViewData = uiState.statusViewData.filterNot { status -> status == statusToRemove } @@ -249,7 +249,7 @@ class ViewThreadViewModel @Inject constructor( } } - fun changeExpanded(expanded: Boolean, status: StatusViewData.Concrete) { + fun changeExpanded(expanded: Boolean, status: StatusViewData) { updateSuccess { uiState -> val statuses = uiState.statusViewData.map { viewData -> if (viewData.id == status.id) { @@ -265,13 +265,13 @@ class ViewThreadViewModel @Inject constructor( } } - fun changeContentShowing(isShowing: Boolean, status: StatusViewData.Concrete) { + fun changeContentShowing(isShowing: Boolean, status: StatusViewData) { updateStatusViewData(status.id) { viewData -> viewData.copy(isShowingContent = isShowing) } } - fun changeContentCollapsed(isCollapsed: Boolean, status: StatusViewData.Concrete) { + fun changeContentCollapsed(isCollapsed: Boolean, status: StatusViewData) { updateStatusViewData(status.id) { viewData -> viewData.copy(isCollapsed = isCollapsed) } @@ -373,7 +373,7 @@ class ViewThreadViewModel @Inject constructor( } } - private fun StatusViewData.Concrete.getRevealButtonState(): RevealButtonState { + private fun StatusViewData.getRevealButtonState(): RevealButtonState { val hasWarnings = status.spoilerText.isNotEmpty() return if (hasWarnings) { @@ -394,7 +394,7 @@ class ViewThreadViewModel @Inject constructor( * - If no status sets it to REVEAL, but at least one uses HIDE, use HIDE * - Otherwise use NO_BUTTON */ - private fun List.getRevealButtonState(): RevealButtonState { + private fun List.getRevealButtonState(): RevealButtonState { var seenHide = false forEach { @@ -448,7 +448,7 @@ class ViewThreadViewModel @Inject constructor( } } - private fun List.filter(): List { + private fun List.filter(): List { return filter { status -> if (status.isDetailed) { true @@ -461,7 +461,7 @@ class ViewThreadViewModel @Inject constructor( private fun Status.toViewData( isDetailed: Boolean = false - ): StatusViewData.Concrete { + ): StatusViewData { val oldStatus = (_uiState.value as? ThreadUiState.Success)?.statusViewData?.find { it.id == this.id } return toViewData( isShowingContent = oldStatus?.isShowingContent ?: (alwaysShowSensitiveMedia || !actionableStatus.sensitive), @@ -481,7 +481,7 @@ class ViewThreadViewModel @Inject constructor( } } - private fun updateStatusViewData(statusId: String, updater: (StatusViewData.Concrete) -> StatusViewData.Concrete) { + private fun updateStatusViewData(statusId: String, updater: (StatusViewData) -> StatusViewData) { updateSuccess { uiState -> uiState.copy( statusViewData = uiState.statusViewData.map { viewData -> @@ -503,7 +503,7 @@ class ViewThreadViewModel @Inject constructor( } } - fun clearWarning(viewData: StatusViewData.Concrete) { + fun clearWarning(viewData: StatusViewData) { updateStatus(viewData.id) { status -> status.copy(filtered = null) } @@ -520,7 +520,7 @@ sealed interface ThreadUiState { /** Loading the detailed status has completed, now loading ancestors/descendants */ data class LoadingThread( - val statusViewDatum: StatusViewData.Concrete?, + val statusViewDatum: StatusViewData?, val revealButton: RevealButtonState ) : ThreadUiState @@ -529,7 +529,7 @@ sealed interface ThreadUiState { /** Successfully loaded the full thread */ data class Success( - val statusViewData: List, + val statusViewData: List, val revealButton: RevealButtonState, val detailedStatusPosition: Int ) : ThreadUiState diff --git a/app/src/main/java/com/keylesspalace/tusky/db/TimelineStatusEntity.kt b/app/src/main/java/com/keylesspalace/tusky/db/TimelineStatusEntity.kt index f0f7f98ed6..de7b2df717 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/TimelineStatusEntity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/TimelineStatusEntity.kt @@ -110,7 +110,7 @@ data class TimelineStatusWithAccount( @Embedded val status: TimelineStatusEntity, @Embedded(prefix = "a_") - val account: TimelineAccountEntity? = null, // null when placeholder + val account: TimelineAccountEntity, @Embedded(prefix = "rb_") val reblogAccount: TimelineAccountEntity? = null // null when no reblog ) diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ListStatusAccessibilityDelegate.kt b/app/src/main/java/com/keylesspalace/tusky/util/ListStatusAccessibilityDelegate.kt index 9402edd037..da057cbec2 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/ListStatusAccessibilityDelegate.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/ListStatusAccessibilityDelegate.kt @@ -47,7 +47,7 @@ class ListStatusAccessibilityDelegate( val pos = recyclerView.getChildAdapterPosition(host) val status = statusProvider.getStatus(pos) ?: return - if (status is StatusViewData.Concrete) { + if (status is StatusViewData) { if (status.spoilerText.isNotEmpty()) { info.addAction(if (status.isExpanded) collapseCwAction else expandCwAction) } @@ -114,7 +114,7 @@ class ListStatusAccessibilityDelegate( R.id.action_open_profile -> { interrupt() statusActionListener.onViewAccount( - (statusProvider.getStatus(pos) as StatusViewData.Concrete).actionable.account.id + (statusProvider.getStatus(pos) as StatusViewData).actionable.account.id ) } R.id.action_open_media_1 -> { @@ -171,7 +171,7 @@ class ListStatusAccessibilityDelegate( } private fun showLinksDialog(host: View) { - val status = getStatus(host) as? StatusViewData.Concrete ?: return + val status = getStatus(host) as? StatusViewData ?: return val links = getLinks(status).toList() val textLinks = links.map { item -> item.link } AlertDialog.Builder(host.context) @@ -188,7 +188,7 @@ class ListStatusAccessibilityDelegate( } private fun showMentionsDialog(host: View) { - val status = getStatus(host) as? StatusViewData.Concrete ?: return + val status = getStatus(host) as? StatusViewData ?: return val mentions = status.actionable.mentions val stringMentions = mentions.map { it.username } AlertDialog.Builder(host.context) @@ -207,7 +207,7 @@ class ListStatusAccessibilityDelegate( } private fun showHashtagsDialog(host: View) { - val status = getStatus(host) as? StatusViewData.Concrete ?: return + val status = getStatus(host) as? StatusViewData ?: return val tags = getHashtags(status).map { it.subSequence(1, it.length) }.toList() AlertDialog.Builder(host.context) .setTitle(R.string.title_hashtags_dialog) @@ -229,7 +229,7 @@ class ListStatusAccessibilityDelegate( } } - private fun getLinks(status: StatusViewData.Concrete): Sequence { + private fun getLinks(status: StatusViewData): Sequence { val content = status.content return if (content is Spannable) { content.getSpans(0, content.length, URLSpan::class.java) @@ -247,7 +247,7 @@ class ListStatusAccessibilityDelegate( } } - private fun getHashtags(status: StatusViewData.Concrete): Sequence { + private fun getHashtags(status: StatusViewData): Sequence { val content = status.content return content.getSpans(0, content.length, Object::class.java) .asSequence() diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.kt b/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.kt index 1d630f39bf..664b53fc62 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.kt @@ -46,8 +46,8 @@ fun Status.toViewData( isExpanded: Boolean, isCollapsed: Boolean, isDetailed: Boolean = false -): StatusViewData.Concrete { - return StatusViewData.Concrete( +): StatusViewData { + return StatusViewData( status = this, isShowingContent = isShowingContent, isCollapsed = isCollapsed, diff --git a/app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.kt b/app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.kt index 759d633e28..8a5fa3fe31 100644 --- a/app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.kt +++ b/app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.kt @@ -38,6 +38,6 @@ data class NotificationViewData( val type: Notification.Type, val id: String, val account: TimelineAccount, - var statusViewData: StatusViewData.Concrete?, + var statusViewData: StatusViewData?, val report: Report? ) diff --git a/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.kt b/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.kt index 7f99556f3b..b77c61e01d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.kt +++ b/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.kt @@ -23,87 +23,69 @@ import com.keylesspalace.tusky.util.replaceCrashingCharacters import com.keylesspalace.tusky.util.shouldTrimStatus /** - * Created by charlag on 11/07/2017. - * - * Class to represent data required to display either a notification or a placeholder. - * It is either a [StatusViewData.Concrete] or a [StatusViewData.Placeholder]. + * Data required to display a status. */ -sealed class StatusViewData { - abstract val id: String +data class StatusViewData( + var status: Status, + val isExpanded: Boolean, + val isShowingContent: Boolean, + /** + * Specifies whether the content of this post is currently limited in visibility to the first + * 500 characters or not. + * + * @return Whether the post is collapsed or fully expanded. + */ + val isCollapsed: Boolean, + val isDetailed: Boolean = false +) { var filterAction: Filter.Action = Filter.Action.NONE + val id: String + get() = status.id - data class Concrete( - var status: Status, - val isExpanded: Boolean, - val isShowingContent: Boolean, - /** - * Specifies whether the content of this post is currently limited in visibility to the first - * 500 characters or not. - * - * @return Whether the post is collapsed or fully expanded. - */ - val isCollapsed: Boolean, - val isDetailed: Boolean = false - ) : StatusViewData() { - override val id: String - get() = status.id - - /** - * Specifies whether the content of this post is long enough to be automatically - * collapsed or if it should show all content regardless. - * - * @return Whether the post is collapsible or never collapsed. - */ - val isCollapsible: Boolean - - val content: Spanned - val spoilerText: String - val username: String - - val actionable: Status - get() = status.actionableStatus + /** + * Specifies whether the content of this post is long enough to be automatically + * collapsed or if it should show all content regardless. + * + * @return Whether the post is collapsible or never collapsed. + */ + val isCollapsible: Boolean - val actionableId: String - get() = status.actionableStatus.id + val content: Spanned + val spoilerText: String + val username: String - val rebloggedAvatar: String? - get() = if (status.reblog != null) { - status.account.avatar - } else { - null - } + val actionable: Status + get() = status.actionableStatus - val rebloggingStatus: Status? - get() = if (status.reblog != null) status else null + val actionableId: String + get() = status.actionableStatus.id - init { - if (Build.VERSION.SDK_INT == 23) { - // https://github.com/tuskyapp/Tusky/issues/563 - this.content = replaceCrashingCharacters(status.actionableStatus.content.parseAsMastodonHtml()) - this.spoilerText = - replaceCrashingCharacters(status.actionableStatus.spoilerText).toString() - this.username = - replaceCrashingCharacters(status.actionableStatus.account.username).toString() - } else { - this.content = status.actionableStatus.content.parseAsMastodonHtml() - this.spoilerText = status.actionableStatus.spoilerText - this.username = status.actionableStatus.account.username - } - this.isCollapsible = shouldTrimStatus(this.content) + val rebloggedAvatar: String? + get() = if (status.reblog != null) { + status.account.avatar + } else { + null } - /** Helper for Java */ - fun copyWithCollapsed(isCollapsed: Boolean): Concrete { - return copy(isCollapsed = isCollapsed) + val rebloggingStatus: Status? + get() = if (status.reblog != null) status else null + + init { + if (Build.VERSION.SDK_INT == 23) { + // https://github.com/tuskyapp/Tusky/issues/563 + this.content = replaceCrashingCharacters(status.actionableStatus.content.parseAsMastodonHtml()) + this.spoilerText = + replaceCrashingCharacters(status.actionableStatus.spoilerText).toString() + this.username = + replaceCrashingCharacters(status.actionableStatus.account.username).toString() + } else { + this.content = status.actionableStatus.content.parseAsMastodonHtml() + this.spoilerText = status.actionableStatus.spoilerText + this.username = status.actionableStatus.account.username } + this.isCollapsible = shouldTrimStatus(this.content) } - data class Placeholder( - override val id: String, - val isLoading: Boolean - ) : StatusViewData() - - fun asStatusOrNull() = this as? Concrete - - fun asPlaceholderOrNull() = this as? Placeholder + /** Helper for Java */ + fun copyWithCollapsed(isCollapsed: Boolean) = copy(isCollapsed = isCollapsed) } diff --git a/app/src/test/java/com/keylesspalace/tusky/StatusComparisonTest.kt b/app/src/test/java/com/keylesspalace/tusky/StatusComparisonTest.kt index 3086036a02..79136c7a4d 100644 --- a/app/src/test/java/com/keylesspalace/tusky/StatusComparisonTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/StatusComparisonTest.kt @@ -41,13 +41,13 @@ class StatusComparisonTest { @Test fun `two equal status view data - should be equal`() { - val viewdata1 = StatusViewData.Concrete( + val viewdata1 = StatusViewData( status = createStatus(), isExpanded = false, isShowingContent = false, isCollapsed = false ) - val viewdata2 = StatusViewData.Concrete( + val viewdata2 = StatusViewData( status = createStatus(), isExpanded = false, isShowingContent = false, @@ -58,13 +58,13 @@ class StatusComparisonTest { @Test fun `status view data with different isExpanded - should not be equal`() { - val viewdata1 = StatusViewData.Concrete( + val viewdata1 = StatusViewData( status = createStatus(), isExpanded = true, isShowingContent = false, isCollapsed = false ) - val viewdata2 = StatusViewData.Concrete( + val viewdata2 = StatusViewData( status = createStatus(), isExpanded = false, isShowingContent = false, @@ -75,13 +75,13 @@ class StatusComparisonTest { @Test fun `status view data with different statuses- should not be equal`() { - val viewdata1 = StatusViewData.Concrete( + val viewdata1 = StatusViewData( status = createStatus(content = "whatever"), isExpanded = true, isShowingContent = false, isCollapsed = false ) - val viewdata2 = StatusViewData.Concrete( + val viewdata2 = StatusViewData( status = createStatus(), isExpanded = false, isShowingContent = false, 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 index 3a102c12a0..6c53b5d7ce 100644 --- a/app/src/test/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModelTestStatusAction.kt +++ b/app/src/test/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModelTestStatusAction.kt @@ -43,7 +43,7 @@ import org.mockito.kotlin.verify @OptIn(ExperimentalCoroutinesApi::class) class NotificationsViewModelTestStatusAction : NotificationsViewModelTestBase() { private val status = mockStatus(pollOptions = listOf("Choice 1", "Choice 2", "Choice 3")) - private val statusViewData = StatusViewData.Concrete( + private val statusViewData = StatusViewData( status = status, isExpanded = true, isShowingContent = false, diff --git a/app/src/test/java/com/keylesspalace/tusky/components/timeline/StatusMocker.kt b/app/src/test/java/com/keylesspalace/tusky/components/timeline/StatusMocker.kt index 282884976c..211b1ad2d5 100644 --- a/app/src/test/java/com/keylesspalace/tusky/components/timeline/StatusMocker.kt +++ b/app/src/test/java/com/keylesspalace/tusky/components/timeline/StatusMocker.kt @@ -69,7 +69,7 @@ fun mockStatusViewData( reblogged: Boolean = false, favourited: Boolean = true, bookmarked: Boolean = true -) = StatusViewData.Concrete( +) = StatusViewData( status = mockStatus( id = id, inReplyToId = inReplyToId, diff --git a/doc/ViewModelInterface.md b/doc/ViewModelInterface.md index 0d1dac489d..8f308b19b2 100644 --- a/doc/ViewModelInterface.md +++ b/doc/ViewModelInterface.md @@ -293,7 +293,7 @@ sealed class NotificationAction : FallibleUiAction() { } sealed class StatusAction( - open val statusViewData: StatusViewData.Concrete + open val statusViewData: StatusViewData ) : FallibleUiAction() { // subclasses here } @@ -429,13 +429,13 @@ looks like this: ```kotlin // In the View Model sealed class StatusAction( - open val statusViewData: StatusViewData.Concrete + open val statusViewData: StatusViewData ) : FallibleUiAction() { data class Bookmark( val state: Boolean, - override val statusViewData: StatusViewData.Concrete + override val statusViewData: StatusViewData ) : StatusAction(statusViewData) - + // ... other actions here } From d2c4de2367758891b0f6044287199900b82ee56b Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Sat, 15 Apr 2023 14:29:45 +0200 Subject: [PATCH 013/156] Lint --- .../java/com/keylesspalace/tusky/MainActivity.kt | 2 +- .../timeline/viewmodel/CachedTimelineViewModel.kt | 12 ++++-------- .../timeline/viewmodel/NetworkTimelineViewModel.kt | 4 ++-- .../timeline/CachedTimelineRemoteMediatorTest.kt | 2 +- 4 files changed, 8 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt b/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt index 734fa79f9e..3ddd37e517 100644 --- a/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt @@ -660,7 +660,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje arrayOf( "Clear home timeline cache", "Remove first 40 statuses" - ), + ) ) { _, which -> Log.d(TAG, "Developer tools: $which") when (which) { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt index 5f25a04666..99e05debbe 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt @@ -18,7 +18,6 @@ package com.keylesspalace.tusky.components.timeline.viewmodel import android.content.SharedPreferences import androidx.lifecycle.viewModelScope import androidx.paging.PagingData -import androidx.paging.PagingSource import androidx.paging.cachedIn import androidx.paging.filter import androidx.paging.map @@ -32,7 +31,6 @@ import com.keylesspalace.tusky.components.timeline.CachedTimelineRepository import com.keylesspalace.tusky.components.timeline.toViewData import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AppDatabase -import com.keylesspalace.tusky.db.TimelineStatusWithAccount import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.network.FilterModel @@ -55,10 +53,10 @@ import kotlin.time.toDuration class CachedTimelineViewModel @Inject constructor( private val repository: CachedTimelineRepository, timelineCases: TimelineCases, - private val api: MastodonApi, - private val eventHub: EventHub, + api: MastodonApi, + eventHub: EventHub, accountManager: AccountManager, - private val preferences: SharedPreferences, + preferences: SharedPreferences, filterModel: FilterModel, private val db: AppDatabase, private val gson: Gson @@ -71,8 +69,6 @@ class CachedTimelineViewModel @Inject constructor( filterModel ) { - private var currentPagingSource: PagingSource? = null - override lateinit var statuses: Flow> @OptIn(ExperimentalCoroutinesApi::class) @@ -103,7 +99,7 @@ class CachedTimelineViewModel @Inject constructor( // type that is needed to do the filtering, so it has to be converted to a // `StatusViewData` first. it.filter { - shouldFilterStatus(it?.status) != Filter.Action.HIDE + shouldFilterStatus(it.status) != Filter.Action.HIDE } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt index 88f5e21b70..5151cc78be 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt @@ -45,14 +45,14 @@ import javax.inject.Inject class NetworkTimelineViewModel @Inject constructor( private val repository: NetworkTimelineRepository, timelineCases: TimelineCases, - private val api: MastodonApi, + api: MastodonApi, eventHub: EventHub, accountManager: AccountManager, sharedPreferences: SharedPreferences, filterModel: FilterModel ) : TimelineViewModel(timelineCases, api, eventHub, accountManager, sharedPreferences, filterModel) { - var currentSource: NetworkTimelinePagingSource? = null + private var currentSource: NetworkTimelinePagingSource? = null val statusData: MutableList = mutableListOf() diff --git a/app/src/test/java/com/keylesspalace/tusky/components/timeline/CachedTimelineRemoteMediatorTest.kt b/app/src/test/java/com/keylesspalace/tusky/components/timeline/CachedTimelineRemoteMediatorTest.kt index ad7bffd38a..7215041716 100644 --- a/app/src/test/java/com/keylesspalace/tusky/components/timeline/CachedTimelineRemoteMediatorTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/components/timeline/CachedTimelineRemoteMediatorTest.kt @@ -537,7 +537,7 @@ class CachedTimelineRemoteMediatorTest { private fun AppDatabase.insert(statuses: List) { runBlocking { statuses.forEach { statusWithAccount -> - statusWithAccount.account?.let { account -> + statusWithAccount.account.let { account -> timelineDao().insertAccount(account) } statusWithAccount.reblogAccount?.let { account -> From a326f31102098cdaa383eed09c2c4d65f95be9c3 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Sat, 15 Apr 2023 14:34:08 +0200 Subject: [PATCH 014/156] Remove "Load more" handling from Timeline view models --- .../viewmodel/CachedTimelineViewModel.kt | 103 ------------------ .../viewmodel/NetworkTimelineViewModel.kt | 79 -------------- .../timeline/viewmodel/TimelineViewModel.kt | 2 - 3 files changed, 184 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt index 99e05debbe..4e88dd24ea 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt @@ -171,109 +171,6 @@ class CachedTimelineViewModel @Inject constructor( // handled by CacheUpdater } - override fun loadMore(placeholderId: String) { -// viewModelScope.launch { -// try { -// val timelineDao = db.timelineDao() -// -// val activeAccount = accountManager.activeAccount!! -// -// timelineDao.insertStatus( -// Placeholder(placeholderId, loading = true).toEntity( -// activeAccount.id -// ) -// ) -// -// val response = db.withTransaction { -// val idAbovePlaceholder = timelineDao.getIdAbove(activeAccount.id, placeholderId) -// val idBelowPlaceholder = timelineDao.getIdBelow(activeAccount.id, placeholderId) -// when (readingOrder) { -// // Using minId, loads up to LOAD_AT_ONCE statuses with IDs immediately -// // after minId and no larger than maxId -// OLDEST_FIRST -> api.homeTimeline( -// maxId = idAbovePlaceholder, -// minId = idBelowPlaceholder, -// limit = LOAD_AT_ONCE -// ) -// // Using sinceId, loads up to LOAD_AT_ONCE statuses immediately before -// // maxId, and no smaller than minId. -// NEWEST_FIRST -> api.homeTimeline( -// maxId = idAbovePlaceholder, -// sinceId = idBelowPlaceholder, -// limit = LOAD_AT_ONCE -// ) -// } -// } -// -// val statuses = response.body() -// if (!response.isSuccessful || statuses == null) { -// loadMoreFailed(placeholderId, HttpException(response)) -// return@launch -// } -// -// db.withTransaction { -// timelineDao.delete(activeAccount.id, placeholderId) -// -// val overlappedStatuses = if (statuses.isNotEmpty()) { -// timelineDao.deleteRange( -// activeAccount.id, -// statuses.last().id, -// statuses.first().id -// ) -// } else { -// 0 -// } -// -// for (status in statuses) { -// timelineDao.insertAccount(status.account.toEntity(activeAccount.id, gson)) -// status.reblog?.account?.toEntity(activeAccount.id, gson) -// ?.let { rebloggedAccount -> -// timelineDao.insertAccount(rebloggedAccount) -// } -// timelineDao.insertStatus( -// status.toEntity( -// timelineUserId = activeAccount.id, -// gson = gson, -// expanded = activeAccount.alwaysOpenSpoiler, -// contentShowing = activeAccount.alwaysShowSensitiveMedia || !status.actionableStatus.sensitive, -// contentCollapsed = true -// ) -// ) -// } -// -// /* In case we loaded a whole page and there was no overlap with existing statuses, -// we insert a placeholder because there might be even more unknown statuses */ -// if (overlappedStatuses == 0 && statuses.size == LOAD_AT_ONCE) { -// /* This overrides the first/last of the newly loaded statuses with a placeholder -// to guarantee the placeholder has an id that exists on the server as not all -// servers handle client generated ids as expected */ -// val idToConvert = when (readingOrder) { -// OLDEST_FIRST -> statuses.first().id -// NEWEST_FIRST -> statuses.last().id -// } -// timelineDao.insertStatus( -// Placeholder( -// idToConvert, -// loading = false -// ).toEntity(activeAccount.id) -// ) -// } -// } -// } catch (e: Exception) { -// ifExpected(e) { -// loadMoreFailed(placeholderId, e) -// } -// } -// } - } - - private suspend fun loadMoreFailed(placeholderId: String, e: Exception) { -// Log.w("CachedTimelineVM", "failed loading statuses", e) -// val activeAccount = accountManager.activeAccount!! -// db.timelineDao() -// .insertStatus(Placeholder(placeholderId, loading = false).toEntity(activeAccount.id)) - } - override fun handleReblogEvent(reblogEvent: ReblogEvent) { // handled by CacheUpdater } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt index 5151cc78be..c3f686762f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt @@ -137,85 +137,6 @@ class NetworkTimelineViewModel @Inject constructor( currentSource?.invalidate() } - override fun loadMore(placeholderId: String) { -// viewModelScope.launch { -// try { -// val placeholderIndex = -// statusData.indexOfFirst { it is StatusViewData.Placeholder && it.id == placeholderId } -// statusData[placeholderIndex] = StatusViewData.Placeholder(placeholderId, isLoading = true) -// -// val idAbovePlaceholder = statusData.getOrNull(placeholderIndex - 1)?.id -// -// val statusResponse = fetchStatusesForKind( -// fromId = idAbovePlaceholder, -// uptoId = null, -// limit = 20 -// ) -// -// val statuses = statusResponse.body() -// if (!statusResponse.isSuccessful || statuses == null) { -// loadMoreFailed(placeholderId, HttpException(statusResponse)) -// return@launch -// } -// -// statusData.removeAt(placeholderIndex) -// -// val activeAccount = accountManager.activeAccount!! -// val data: MutableList = statuses.map { status -> -// status.toViewData( -// isShowingContent = activeAccount.alwaysShowSensitiveMedia || !status.actionableStatus.sensitive, -// isExpanded = activeAccount.alwaysOpenSpoiler, -// isCollapsed = true -// ) -// }.toMutableList() -// -// if (statuses.isNotEmpty()) { -// val firstId = statuses.first().id -// val lastId = statuses.last().id -// val overlappedFrom = statusData.indexOfFirst { it.id.isLessThanOrEqual(firstId) ?: false } -// val overlappedTo = statusData.indexOfFirst { it.id.isLessThan(lastId) ?: false } -// -// if (overlappedFrom < overlappedTo) { -// data.mapIndexed { i, status -> i to statusData.firstOrNull { it.id == status.id }?.asStatusOrNull() } -// .filter { (_, oldStatus) -> oldStatus != null } -// .forEach { (i, oldStatus) -> -// data[i] = data[i].asStatusOrNull()!! -// .copy( -// isShowingContent = oldStatus!!.isShowingContent, -// isExpanded = oldStatus.isExpanded, -// isCollapsed = oldStatus.isCollapsed, -// ) -// } -// -// statusData.removeAll { status -> -// lastId.isLessThan(status.id) && status.id.isLessThanOrEqual(firstId) -// } -// } else { -// data[data.size - 1] = StatusViewData.Placeholder(statuses.last().id, isLoading = false) -// } -// } -// -// statusData.addAll(placeholderIndex, data) -// -// currentSource?.invalidate() -// } catch (e: Exception) { -// ifExpected(e) { -// loadMoreFailed(placeholderId, e) -// } -// } -// } - } - -// private fun loadMoreFailed(placeholderId: String, e: Exception) { -// Log.w("NetworkTimelineVM", "failed loading statuses", e) -// -// val index = -// statusData.indexOfFirst { it is StatusViewData.Placeholder && it.id == placeholderId } -// statusData[index] = StatusViewData.Placeholder(placeholderId, isLoading = false) -// -// currentSource?.invalidate() -// } - override fun handleReblogEvent(reblogEvent: ReblogEvent) { updateStatusById(reblogEvent.statusId) { it.copy(status = it.status.copy(reblogged = reblogEvent.reblog)) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt index aeee8bec36..e489a9c552 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt @@ -410,8 +410,6 @@ abstract class TimelineViewModel( abstract fun removeStatusWithId(id: String) - abstract fun loadMore(placeholderId: String) - abstract fun handleReblogEvent(reblogEvent: ReblogEvent) abstract fun handleFavEvent(favEvent: FavoriteEvent) From 26673adc18ab24ab420a88f03463fdcd96da4840 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Sat, 15 Apr 2023 18:29:22 +0200 Subject: [PATCH 015/156] Remove PlaceholderViewHolder - Delete the class - Update instantiation locations - Remove obsolete string resource --- .../tusky/adapter/PlaceholderViewHolder.kt | 67 ------------------- .../timeline/TimelinePagingAdapter.kt | 5 -- .../res/layout/item_status_placeholder.xml | 51 -------------- app/src/main/res/values-ar/strings.xml | 3 +- app/src/main/res/values-be/strings.xml | 3 +- app/src/main/res/values-bg/strings.xml | 3 +- app/src/main/res/values-bn-rBD/strings.xml | 3 +- app/src/main/res/values-bn-rIN/strings.xml | 3 +- app/src/main/res/values-ca/strings.xml | 3 +- app/src/main/res/values-ckb/strings.xml | 3 +- app/src/main/res/values-cs/strings.xml | 3 +- app/src/main/res/values-cy/strings.xml | 3 +- app/src/main/res/values-de/strings.xml | 3 +- app/src/main/res/values-eo/strings.xml | 3 +- app/src/main/res/values-es/strings.xml | 1 - app/src/main/res/values-eu/strings.xml | 3 +- app/src/main/res/values-fa/strings.xml | 3 +- app/src/main/res/values-fi/strings.xml | 3 +- app/src/main/res/values-fr/strings.xml | 3 +- app/src/main/res/values-fy/strings.xml | 3 +- app/src/main/res/values-ga/strings.xml | 3 +- app/src/main/res/values-gd/strings.xml | 3 +- app/src/main/res/values-gl/strings.xml | 3 +- app/src/main/res/values-hi/strings.xml | 3 +- app/src/main/res/values-hu/strings.xml | 3 +- app/src/main/res/values-is/strings.xml | 3 +- app/src/main/res/values-it/strings.xml | 3 +- app/src/main/res/values-ja/strings.xml | 3 +- app/src/main/res/values-kab/strings.xml | 3 +- app/src/main/res/values-ko/strings.xml | 3 +- app/src/main/res/values-lv/strings.xml | 3 +- app/src/main/res/values-ml/strings.xml | 3 +- app/src/main/res/values-nb-rNO/strings.xml | 3 +- app/src/main/res/values-nl/strings.xml | 3 +- app/src/main/res/values-oc/strings.xml | 3 +- app/src/main/res/values-pl/strings.xml | 3 +- app/src/main/res/values-pt-rBR/strings.xml | 3 +- app/src/main/res/values-pt-rPT/strings.xml | 3 +- app/src/main/res/values-ru/strings.xml | 3 +- app/src/main/res/values-sa/strings.xml | 3 +- app/src/main/res/values-si/strings.xml | 3 +- app/src/main/res/values-sl/strings.xml | 3 +- app/src/main/res/values-sv/strings.xml | 3 +- app/src/main/res/values-ta/strings.xml | 3 +- app/src/main/res/values-th/strings.xml | 3 +- app/src/main/res/values-tr/strings.xml | 3 +- app/src/main/res/values-uk/strings.xml | 3 +- app/src/main/res/values-vi/strings.xml | 3 +- app/src/main/res/values-zh-rCN/strings.xml | 3 +- app/src/main/res/values-zh-rHK/strings.xml | 3 +- app/src/main/res/values-zh-rMO/strings.xml | 3 +- app/src/main/res/values-zh-rSG/strings.xml | 3 +- app/src/main/res/values-zh-rTW/strings.xml | 3 +- app/src/main/res/values/strings.xml | 1 - 54 files changed, 49 insertions(+), 223 deletions(-) delete mode 100644 app/src/main/java/com/keylesspalace/tusky/adapter/PlaceholderViewHolder.kt delete mode 100644 app/src/main/res/layout/item_status_placeholder.xml diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/PlaceholderViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/PlaceholderViewHolder.kt deleted file mode 100644 index c277ea385e..0000000000 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/PlaceholderViewHolder.kt +++ /dev/null @@ -1,67 +0,0 @@ -/* Copyright 2021 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.adapter - -import android.view.View -import androidx.recyclerview.widget.RecyclerView -import com.google.android.material.button.MaterialButton -import com.google.android.material.progressindicator.CircularProgressIndicatorSpec -import com.google.android.material.progressindicator.IndeterminateDrawable -import com.keylesspalace.tusky.R -import com.keylesspalace.tusky.interfaces.StatusActionListener - -/** - * Placeholder for different timelines. - * - * Displays a "Load more" button for a particular status ID, or a - * circular progress wheel if the status' page is being loaded. - * - * The user can only have one "Load more" operation in progress at - * a time (determined by the adapter), so the contents of the view - * and the enabled state is driven by that. - */ -class PlaceholderViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { - private val loadMoreButton: MaterialButton = itemView.findViewById(R.id.button_load_more) - private val drawable = IndeterminateDrawable.createCircularDrawable( - itemView.context, - CircularProgressIndicatorSpec(itemView.context, null) - ) - - fun setup(listener: StatusActionListener, loading: Boolean) { - itemView.isEnabled = !loading - loadMoreButton.isEnabled = !loading - - if (loading) { - loadMoreButton.text = "" - loadMoreButton.icon = drawable - return - } - - loadMoreButton.text = itemView.context.getString(R.string.load_more_placeholder_text) - loadMoreButton.icon = null - - // To allow the user to click anywhere in the layout to load more content set the click - // listener on the parent layout instead of loadMoreButton. - // - // See the comments in item_status_placeholder.xml for more details. - itemView.setOnClickListener { - itemView.isEnabled = false - loadMoreButton.isEnabled = false - loadMoreButton.icon = drawable - loadMoreButton.text = "" - listener.onLoadMore(bindingAdapterPosition) - } - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelinePagingAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelinePagingAdapter.kt index 9c492dcb14..b30d38f6c3 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelinePagingAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelinePagingAdapter.kt @@ -21,7 +21,6 @@ import androidx.paging.PagingDataAdapter import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView import com.keylesspalace.tusky.R -import com.keylesspalace.tusky.adapter.PlaceholderViewHolder import com.keylesspalace.tusky.adapter.StatusBaseViewHolder import com.keylesspalace.tusky.adapter.StatusViewHolder import com.keylesspalace.tusky.entity.Filter @@ -52,9 +51,6 @@ class TimelinePagingAdapter( VIEW_TYPE_STATUS_FILTERED -> { StatusViewHolder(inflater.inflate(R.layout.item_status_wrapper, viewGroup, false)) } - VIEW_TYPE_PLACEHOLDER -> { - PlaceholderViewHolder(inflater.inflate(R.layout.item_status_placeholder, viewGroup, false)) - } else -> { StatusViewHolder(inflater.inflate(R.layout.item_status, viewGroup, false)) } @@ -100,7 +96,6 @@ class TimelinePagingAdapter( companion object { private const val VIEW_TYPE_STATUS = 0 private const val VIEW_TYPE_STATUS_FILTERED = 1 - private const val VIEW_TYPE_PLACEHOLDER = 2 val TimelineDifferCallback = object : DiffUtil.ItemCallback() { override fun areItemsTheSame( diff --git a/app/src/main/res/layout/item_status_placeholder.xml b/app/src/main/res/layout/item_status_placeholder.xml deleted file mode 100644 index 660c99eaa4..0000000000 --- a/app/src/main/res/layout/item_status_placeholder.xml +++ /dev/null @@ -1,51 +0,0 @@ - - - - - - - - diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index e699bddc43..a86e292a13 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -255,7 +255,6 @@ أظهر دائما المحتوى الحساس الوسائط ردًا على @%s - حمِّل المزيد الخطوط الزمنية العمومية المحادثات إضافة عامل تصفية @@ -671,4 +670,4 @@ سبب مجهول طال الاتصال بخادمك كثيرًا إضافة - \ No newline at end of file + diff --git a/app/src/main/res/values-be/strings.xml b/app/src/main/res/values-be/strings.xml index bc786fa6b9..e22b275e18 100644 --- a/app/src/main/res/values-be/strings.xml +++ b/app/src/main/res/values-be/strings.xml @@ -338,7 +338,6 @@ Адказ для @%s Публічныя стужкі Размовы - загрузіць больш Tusky — свабодная праграма з адкрытым зыходным кодам. Зроблена пад GNU General Public License Version 3. Вы можаце паглядзець ліцэнзію тут: https://www.gnu.org/licenses/gpl-3.0.en.html Справаздачы аб памылках і пажаданні: \n https://github.com/tuskyapp/Tusky/issues @@ -648,4 +647,4 @@ Адфільтрована: %s Профілі Папулярныя хэштэгі - \ No newline at end of file + diff --git a/app/src/main/res/values-bg/strings.xml b/app/src/main/res/values-bg/strings.xml index 81b65fe538..44a7bbc116 100644 --- a/app/src/main/res/values-bg/strings.xml +++ b/app/src/main/res/values-bg/strings.xml @@ -164,7 +164,6 @@ Добавяне на филтър Разговори Публични емисии - зареждане на още Отговаряне на @%s Мултимедия Винаги разгъване на публикации, маркирани с предупреждения за съдържание @@ -503,4 +502,4 @@ Показване на споделяния Видео и аудио файловете не може да превишават %s МБ в размер. Тази снимка не може да абъде редактирана. - \ No newline at end of file + diff --git a/app/src/main/res/values-bn-rBD/strings.xml b/app/src/main/res/values-bn-rBD/strings.xml index fb7c571232..8d43acae8b 100644 --- a/app/src/main/res/values-bn-rBD/strings.xml +++ b/app/src/main/res/values-bn-rBD/strings.xml @@ -86,7 +86,6 @@ ফিল্টার যোগ করুন কথাবার্তা পাবলিক টাইমলাইন - আরো লোড কর \'@%s কে উত্তর দিচ্ছে\' মিডিয়া সর্বদা সংবেদনশীল কন্টেন্ট প্রদর্শন করুন @@ -510,4 +509,4 @@ %1$s আর %2$s %1$s, %2$s, আর %3$s %s তোমাকে উল্লেখ করেছে - \ No newline at end of file + diff --git a/app/src/main/res/values-bn-rIN/strings.xml b/app/src/main/res/values-bn-rIN/strings.xml index 35c255a3f2..53f60bd41c 100644 --- a/app/src/main/res/values-bn-rIN/strings.xml +++ b/app/src/main/res/values-bn-rIN/strings.xml @@ -261,7 +261,6 @@ সর্বদা সংবেদনশীল কন্টেন্ট প্রদর্শন করুন মিডিয়া \@%s কে উত্তর দিচ্ছে - আরো লোড কর পাবলিক টাইমলাইন কথাবার্তা ফিল্টার যোগ করুন @@ -469,4 +468,4 @@ তোমার কোনো খসড়া নেই। মাস্টোডনের সর্বনিম্ন ৫ মিনিটের সময়সূচীর বিরতি আছে। শীর্ষস্থানীয় সরঞ্জামদণ্ডের শিরোনামটি লুকান - \ No newline at end of file + diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index 509dd37533..9d01719c4c 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -191,7 +191,6 @@ Mostra el contingut no apte (NSFW) Multimèdia En resposta a @%s - carrega\'n més Vota S\'ha produït un error en publicar. Pestanyes @@ -638,4 +637,4 @@ Mostra de totes maneres El contacte amb el teu servidor ha trigat massa motiu desconegut - \ No newline at end of file + diff --git a/app/src/main/res/values-ckb/strings.xml b/app/src/main/res/values-ckb/strings.xml index 5f0b624dc1..7db50b24f6 100644 --- a/app/src/main/res/values-ckb/strings.xml +++ b/app/src/main/res/values-ckb/strings.xml @@ -410,7 +410,6 @@ زیادکردنی فلتەر گفتوگۆکان هێڵی کاتی گشتی - بارکردنی زیاتر وەڵام دانەوە بۆ @%s میدیا هەمیشە ئەو توتانەی کە بە ئاگادارکردنەوەکانی ناوەڕۆکەوە نیشانەکراون فراوان بکە @@ -477,4 +476,4 @@ سەرکەوتوو نەبوو لە هاودەمکردنی ڕێکبەندەکان بڵاوکردنەوە (هاوکاتکراوە لەگەڵ سێرڤەر) هەمیشە میدیا وەک هەستیار نیشان بکە - \ No newline at end of file + diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index b46645e998..cd824f7d40 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -262,7 +262,6 @@ Vždy zobrazovat citlivý obsah Média Odpověď uživateli @%s - načíst více Veřejné časové osy Konverzace Přidat filtr @@ -583,4 +582,4 @@ Smazat tuto konverzaci\? Požádáno o sledování Animovat vlastní emotikony - \ No newline at end of file + diff --git a/app/src/main/res/values-cy/strings.xml b/app/src/main/res/values-cy/strings.xml index 0d1ea41998..73b1294bd2 100644 --- a/app/src/main/res/values-cy/strings.xml +++ b/app/src/main/res/values-cy/strings.xml @@ -228,7 +228,6 @@ Dangos cynnwys sensitif bob tro Cyfryngau Yn ymateb i @%s - llwytho rhagor Ychwanegu Cyfrif Ychwanegu Cyfrif Mastodon newydd Rhestrau @@ -711,4 +710,4 @@ Ychwanegu allweddair Golygu allweddair %s: %s - \ No newline at end of file + diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 1457cfda42..bdd5f0e39e 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -245,7 +245,6 @@ Mediendateien mit Inhaltswarnung immer anzeigen Medien Antworten an @%s - mehr laden Unterhaltungen Filter hinzufügen Filter bearbeiten @@ -667,4 +666,4 @@ \nDamit du andere Konten entdeckst, kannst du entweder andere Timelines lesen – z. B. die Lokale Timeline deiner Instanz [iconics gmd_group] – oder du suchst nach Namen [iconics gmd_search] – z. B. Tusky, um unser Mastodon-Konto zu finden. Bild Beitragsstatistiken in der Timeline anzeigen - \ No newline at end of file + diff --git a/app/src/main/res/values-eo/strings.xml b/app/src/main/res/values-eo/strings.xml index e3cea19eec..dcaf23cacc 100644 --- a/app/src/main/res/values-eo/strings.xml +++ b/app/src/main/res/values-eo/strings.xml @@ -257,7 +257,6 @@ Ĉiam montri tiklan enhavon Aŭdovidaĵoj Respondo al @%s - ŝarĝi pli Publikaj tempolinioj Konversacioj Aldoni filtrilon @@ -565,4 +564,4 @@ Por ricevi sciigoj per UnifiedPush, Tusky bezonas taŭgan permeson el Mastodon-servilo. Tio postulas re-ensaluton por ŝanĝi OAuth-rajtoj donitaj al Tusky. Se vi uzas la opcion re-ensaluti ĉi tie aŭ en la agordoj de la konto, viaj malnetoj kaj kaŝmemoroj estos konservitaj. Vi re-ensalutis en tiu konto por doni sciigo-permeson al Tusky. Vi havas tamen aliajn kontojn, ĉe kiuj vi devas re-sensaluti. Iru al ili, kaj re-ensalutu por ebligi ricevon de sciigoj per UnifiedPush. %s (🔗 %s) - \ No newline at end of file + diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 1dd99ccf5b..dd6fbc7a85 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -246,7 +246,6 @@ Mostrar contenido NSFW Multimedia Respondiendo a @%s - cargar más Añadir cuenta Añadir cuenta de Mastodon Listas diff --git a/app/src/main/res/values-eu/strings.xml b/app/src/main/res/values-eu/strings.xml index 3cde4a4c8a..5ede6ac7db 100644 --- a/app/src/main/res/values-eu/strings.xml +++ b/app/src/main/res/values-eu/strings.xml @@ -228,7 +228,6 @@ Eduki mingarria erakutsi Multimedia \@%s-(r)i erantzuten - Gehiago erakutsi Gehitu kontua Mastodon kontua gehitu Zerrendak @@ -545,4 +544,4 @@ %s editatua %s-(e)k izena eman du Akatsa zerbitzaritik egoeraren iturria kargatzean. - \ No newline at end of file + diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index 3281f676f6..ce1e2f9416 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -222,7 +222,6 @@ نمایش همیشگی محتوای حساس رسانه در حال پاسخ به @%s - بار کردن بیش‌تر افزودن حساب افزودن حساب ماستودون جدید فهرست‌ها @@ -666,4 +665,4 @@ هنوز هیچ سیاهه‌ای ندارید مدیریت سیاهه‌ها خطا در بار کردن سیاهه‌ها - \ No newline at end of file + diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml index fcacca2726..8704e9a168 100644 --- a/app/src/main/res/values-fi/strings.xml +++ b/app/src/main/res/values-fi/strings.xml @@ -299,7 +299,6 @@ Jaa linkki postaukseen Liitteet Media - lataa lisää Julkiset aikajanat Keskustelut Lisää suodatin @@ -309,4 +308,4 @@ Käynnistä uudelleen Tunnistautuminen valitsemasi instanssin kanssa epäonnistui. Estä @%s\? - \ No newline at end of file + diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 390739eb74..0b680cd87b 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -262,7 +262,6 @@ Toujours afficher les contenus sensibles Média Réponse à @%s - en charger plus Fils publics Conversations Ajouter un filtre @@ -570,4 +569,4 @@ ajouter une réaction %s règles Chargement du fil - \ No newline at end of file + diff --git a/app/src/main/res/values-fy/strings.xml b/app/src/main/res/values-fy/strings.xml index 23fe4caf12..a92f1e5181 100644 --- a/app/src/main/res/values-fy/strings.xml +++ b/app/src/main/res/values-fy/strings.xml @@ -31,7 +31,6 @@ Filter oanpasse Filter tafoegje Petearen - mear lade Oan it reagearren op @%s Media Altyd gefoeliche ynhâld sjen litte @@ -260,4 +259,4 @@ Boost %s hat dyn toot boost Oankundigingen - \ No newline at end of file + diff --git a/app/src/main/res/values-ga/strings.xml b/app/src/main/res/values-ga/strings.xml index 1ea0d9aaa4..3743d2a8c0 100644 --- a/app/src/main/res/values-ga/strings.xml +++ b/app/src/main/res/values-ga/strings.xml @@ -276,7 +276,6 @@ Leathnaigh i gcónaí postálacha atá marcáilte le rabhaidh ábhair Meáin Ag freagairt do @%s - Lódáil a thuilleadh Comhráite Cuir scagaire leis Cuir scagaire in eagar @@ -506,4 +505,4 @@ Dún Sonraí Scrios an comhrá seo\? - \ No newline at end of file + diff --git a/app/src/main/res/values-gd/strings.xml b/app/src/main/res/values-gd/strings.xml index 27011ebf45..ccb5f6de71 100644 --- a/app/src/main/res/values-gd/strings.xml +++ b/app/src/main/res/values-gd/strings.xml @@ -310,7 +310,6 @@ Cuir criathrag ris Còmhraidhean Loidhnichean-ama poblach - luchdaich barrachd dheth A’ freagairt gu @%s Meadhanan Seall susbaint fhrionasach an-còmhnaidh @@ -642,4 +641,4 @@ Co-roinn ainm-cleachdaiche a’ chunntais Co-roinn URL a’ chunntais le… Co-roinn ainm-cleachdaiche a’ chunntais le… - \ No newline at end of file + diff --git a/app/src/main/res/values-gl/strings.xml b/app/src/main/res/values-gl/strings.xml index bc1ba09679..c64e927b4c 100644 --- a/app/src/main/res/values-gl/strings.xml +++ b/app/src/main/res/values-gl/strings.xml @@ -283,7 +283,6 @@ Engadir filtro Conversas Cronoloxías públicas - cargar máis Respondendo a @%s Multimedia Despregar sempre publicacións marcadas con avisos sobre o contido @@ -659,4 +658,4 @@ Aínda non tes listas Xestionar listas Erro ao cargar as listas - \ No newline at end of file + diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index 1079993032..2190c30221 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -250,7 +250,6 @@ फ़िल्टर संपादित करें फिल्टर लगाएं सार्वजनिक टाइमलाइन - और लोड करें टाइमलाइन में लिंक प्रीव्यू दिखाएं मास्टोडन का न्यूनतम शेड्यूलिंग अंतराल 5 मिनट है। आपके पास कोई ड्राफ्ट नहीं है। @@ -402,4 +401,4 @@ पोस्ट बहुत लंबा है! उस सर्वर से प्रमाणित करने में विफल। - \ No newline at end of file + diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index 2281e7405e..733b199404 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -231,7 +231,6 @@ Követ téged Mindig mutassa a kényes tartalmat Média - több betöltése Fiók hozzáadása Új Mastodon-fiók hozzáadása Listák @@ -661,4 +660,4 @@ Kulcsszó hozzáadása Kulcsszó szerkesztése %s: %s - \ No newline at end of file + diff --git a/app/src/main/res/values-is/strings.xml b/app/src/main/res/values-is/strings.xml index 335172e43d..07c3c9089b 100644 --- a/app/src/main/res/values-is/strings.xml +++ b/app/src/main/res/values-is/strings.xml @@ -274,7 +274,6 @@ Alltaf fletta út færslum sem eru með aðvörun vegna efnis Gagnaskrár Svar til @%s - hlaða inn fleiru Opinberar tímalínur Samtöl Bæta við síu @@ -653,4 +652,4 @@ Bæta við stikkorði Breyta stikkorði %s: %s - \ No newline at end of file + diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index e93222afc2..4458223e0c 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -256,7 +256,6 @@ Mostra sempre i contenuti sensibili Media Rispondendo a @%s - carica altro Timeline pubbliche Conversazioni Aggiungi filtro @@ -647,4 +646,4 @@ Account totali Aggiorna Hashtag di tendenza - \ No newline at end of file + diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 8af7dbdeab..366c300eda 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -247,7 +247,6 @@ 閲覧注意のメディアを常に表示 メディア \@%sに返信 - さらに読み込む フィルターを追加 フィルターを編集 アカウントを追加 @@ -614,4 +613,4 @@ アップロードに失敗した投稿は下書きに保存されました。 \n \nサーバーと接続できなかったか、投稿が拒否されました。 - \ No newline at end of file + diff --git a/app/src/main/res/values-kab/strings.xml b/app/src/main/res/values-kab/strings.xml index bd93ba9481..00409e034f 100644 --- a/app/src/main/res/values-kab/strings.xml +++ b/app/src/main/res/values-kab/strings.xml @@ -157,7 +157,6 @@ %dis aya %dus aya Tettaraḍ-as i @%s - awid ugar Idewenniyen Rgel amiḍan Yettnadi… @@ -274,4 +273,4 @@ Sken-d beṭuyat Ihacṭagen Isuturen n teḍfeṛt - \ No newline at end of file + diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 1b49407e07..8782cabe6d 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -266,7 +266,6 @@ 민감한 컨텐츠 항상 보이기 미디어 \@%s에게 답장 - 더 불러오기 공개 타임라인 대화 필터 추가 @@ -425,4 +424,4 @@ 수정 해시태그 팔로우 요청 - \ No newline at end of file + diff --git a/app/src/main/res/values-lv/strings.xml b/app/src/main/res/values-lv/strings.xml index ef9ed0877c..f4e0211949 100644 --- a/app/src/main/res/values-lv/strings.xml +++ b/app/src/main/res/values-lv/strings.xml @@ -231,7 +231,6 @@ Sekošanas pieprasījumi Jauni ieraksti Seko tev - ielādēt vairāk Saglabāt melnrakstu\? Lejupielāde neizdevās 14 dienas @@ -593,4 +592,4 @@ Tendenču tēmturi Pievienot %s: %s - \ No newline at end of file + diff --git a/app/src/main/res/values-ml/strings.xml b/app/src/main/res/values-ml/strings.xml index 6cfd04054e..31ea52d970 100644 --- a/app/src/main/res/values-ml/strings.xml +++ b/app/src/main/res/values-ml/strings.xml @@ -129,7 +129,6 @@ ഫോട്ടോ എടുക്കുക തിരയുക… ചിത്രങ്ങൾ - കൂടുതൽ ലഭ്യമാക്കുക സൂചനകൾ ബയോ %1$s @@ -159,4 +158,4 @@ വിഡിയോ സൂചിപ്പിക്കുക നീക്കം ചെയ്യുക - \ No newline at end of file + diff --git a/app/src/main/res/values-nb-rNO/strings.xml b/app/src/main/res/values-nb-rNO/strings.xml index f5700239b1..646e3d5e37 100644 --- a/app/src/main/res/values-nb-rNO/strings.xml +++ b/app/src/main/res/values-nb-rNO/strings.xml @@ -228,7 +228,6 @@ Vis alltid sensitivt innhold Media Svarer til @%s - last mer Offentlige tidslinjer Samtaler Legg til filter @@ -579,4 +578,4 @@ Annet Slutte å følge #%s\? Sluttet å følge #%s - \ No newline at end of file + diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index a130a11b57..5494395d31 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -252,7 +252,6 @@ Altijd gevoelige inhoud (nsfw) tonen Media Aan het reageren op @%s - meer laden Account toevoegen Een nieuw Mastodonaccount toevoegen Lijsten @@ -592,4 +591,4 @@ Gebruikersnaam gekopieerd #%s ontvolgd Gevolgde hashtags - \ No newline at end of file + diff --git a/app/src/main/res/values-oc/strings.xml b/app/src/main/res/values-oc/strings.xml index 2dae727b8d..def22f6f4e 100644 --- a/app/src/main/res/values-oc/strings.xml +++ b/app/src/main/res/values-oc/strings.xml @@ -219,7 +219,6 @@ Mostrar lo contengut sensible (NSFW) Mèdia En responsa a @%s - cargar mai Apondre un compte Apondre un nòu compte Mastodon Listas @@ -658,4 +657,4 @@ Mots clau o frasas de filtrar Descripcion Imatge - \ No newline at end of file + diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 5362ead9f5..14454d6710 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -223,7 +223,6 @@ Zawsze wyświetlaj wrażliwą zawartość Zawartość multimedialna Odpowiadasz na wpis autorstwa @%s - załaduj więcej Dodaj konto Dodaj nowe Konto Mastodon Listy @@ -650,4 +649,4 @@ Odrzuć zmiany Udostępnij nazwę użytkownika Udostępnij link do konta - \ No newline at end of file + diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 938488f3bf..7fe844c62c 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -240,7 +240,6 @@ Sempre mostrar mídia sensível Mídia Respondendo @%s - carregar mais Adicionar conta Adicionar nova conta Mastodon Listas @@ -610,4 +609,4 @@ Salvando rascunho… Entrar %s regras - \ No newline at end of file + diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index 9919ef7435..cd36855be6 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -308,7 +308,6 @@ Expandir sempre toots com Aviso de Conteúdo Palavra completa Conteúdo Multimédia - carregar mais Timelines públicas Conversas Criar filtro @@ -565,4 +564,4 @@ Erro ao seguir #%s Erro ao deixar de seguir #%s adicionar reação - \ No newline at end of file + diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 5b09e2ba29..bdff252c04 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -282,7 +282,6 @@ Всегда показывать чувствительный контент Медиа Ответить @%s - показать ещё Публичные ленты Разговоры Добавить фильтр @@ -545,4 +544,4 @@ %1$s создали %2$s Войти Вход через Браузер - \ No newline at end of file + diff --git a/app/src/main/res/values-sa/strings.xml b/app/src/main/res/values-sa/strings.xml index 8ce11fd30b..9d67737bd6 100644 --- a/app/src/main/res/values-sa/strings.xml +++ b/app/src/main/res/values-sa/strings.xml @@ -210,7 +210,6 @@ शोधकं युज्यताम् आलापाः सार्वजनिकतालिकाः - अधिकमारोप्यताम् \@%s मित्रायोत्तरम् सामग्र्यः सर्वदा विषयसतर्कतयाऽङ्कितं दौत्यं विस्तार्यताम् @@ -568,4 +567,4 @@ लेखायाः उपभोक्तृनाम्नः संविभागं कुरुताम् अस्मै… #%s अनुसरणम् अपाकृतम् लेखायाः निरपेक्ष-सार्वत्रिक-वस्तुसङ्केतस्य संविभागं कुरुताम् अस्मै… - \ No newline at end of file + diff --git a/app/src/main/res/values-si/strings.xml b/app/src/main/res/values-si/strings.xml index 2f1a461b98..4b832e733b 100644 --- a/app/src/main/res/values-si/strings.xml +++ b/app/src/main/res/values-si/strings.xml @@ -110,7 +110,6 @@ සෘජු පණිවිඩ මාධ්‍ය බාගත වෙමින් මාධ්‍ය පෙරදසුන් බාගන්න - තව පූරණය බාගන්න සඳැහුම් පිළිතුර @@ -268,4 +267,4 @@ අතිරික්සුව තත්. %d %s (🔗 %s) - \ No newline at end of file + diff --git a/app/src/main/res/values-sl/strings.xml b/app/src/main/res/values-sl/strings.xml index d882ebc001..720dec662d 100644 --- a/app/src/main/res/values-sl/strings.xml +++ b/app/src/main/res/values-sl/strings.xml @@ -233,7 +233,6 @@ Vedno prikaži občutljivo vsebino Mediji Odgovori @%s - naloži več Javne časovnice Pogovori Dodaj filter @@ -444,4 +443,4 @@ %1$s Priljubljene %1$s Priljubljenih - \ No newline at end of file + diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 6ff3deefaa..03ecdc771d 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -256,7 +256,6 @@ Visa alltid allt innehåll (inkl. känsligt) Media Svarar till @%s - ladda mer Offentliga tidslinjer Konversationer Lägg till filter @@ -628,4 +627,4 @@ \nAntingen kunde inte servern nås eller så har uppladdningen nekats. Stäng Fungerar i de flesta fallen. Ingen information läcker till andra applikationer. - \ No newline at end of file + diff --git a/app/src/main/res/values-ta/strings.xml b/app/src/main/res/values-ta/strings.xml index 5d7c79fff0..0643d1df3e 100644 --- a/app/src/main/res/values-ta/strings.xml +++ b/app/src/main/res/values-ta/strings.xml @@ -210,7 +210,6 @@ எல்லா nsfw(உணர்ச்சிகரமான) உள்ளடக்கத்தையும் எப்போதும் காண்பி ஊடகம் \@%s -க்கு பதிலளி - மேலும் காட்டு… கணக்கை சேர்க்க புதிய Mastodon கணக்கைச் சேர்க்க பட்டியல்கள் @@ -277,4 +276,4 @@ பட்டியலிடப்படாதவர்களுக்கு அனைவருக்கும் எழுது - \ No newline at end of file + diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index 4380f5c0be..83e7247dfc 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -155,7 +155,6 @@ เพิ่มตัวคัดกรอง การสนทนา ไทม์ไลน์สาธารณะ - โหลดเพิ่ม ตอบกลับไป @%s สื่อ ขยาย Toot ที่มีเครื่องหมายเนื้อหาอ่อนไหวเสมอ @@ -487,4 +486,4 @@ %s เพิ่งโพสต์ ประกาศ ลบการสนทนา - \ No newline at end of file + diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 34cc9b9423..e7e936d273 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -237,7 +237,6 @@ Seni takip ediyor Her zaman hassas içerikleri göster Medya - daha fazla yükle Hesap Ekle Yeni Mastodon hesabı ekle Listeler @@ -592,4 +591,4 @@ Sunucudan kaynak durumu yüklenemedi. Bağlantı noktası %d ile %d arasında olmalıdır Alternatif metin - \ No newline at end of file + diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index fc6cf32f41..e8ba0cefd5 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -295,7 +295,6 @@ Додати фільтр Розмови Загальнодоступні стрічки - завантажити ще Відповідь для @%s Завжди розгортати допис, з попередженнями про вміст Підписники @@ -683,4 +682,4 @@ Помилка завантаження списків У вас ще немає списків Керувати списками - \ No newline at end of file + diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index 3827027c7e..1d38e8fd43 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -286,7 +286,6 @@ Thêm bộ lọc Thảo luận Liên hợp - hiện những tút chưa đọc Trả lời @%s Media Hiện nội dung ẩn @@ -648,4 +647,4 @@ Quản lý danh sách Xảy ra lỗi khi tải danh sách Bạn chưa có danh sách - \ No newline at end of file + diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 754cd87597..42b4b0a549 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -263,7 +263,6 @@ 总是显示所有敏感媒体内容 媒体 回复 @%s - 加载更多 公共时间轴 对话 添加新的过滤器 @@ -666,4 +665,4 @@ 管理列表 加载列表出错 你还没有列表 - \ No newline at end of file + diff --git a/app/src/main/res/values-zh-rHK/strings.xml b/app/src/main/res/values-zh-rHK/strings.xml index b40c4ca77d..93c19d2f6a 100644 --- a/app/src/main/res/values-zh-rHK/strings.xml +++ b/app/src/main/res/values-zh-rHK/strings.xml @@ -262,7 +262,6 @@ 總是顯示所有敏感媒體內容 媒體 回覆 @%s - 載入更多 公共時間軸 對話 添加新的過濾器 @@ -503,4 +502,4 @@ 底端 頂端 主要導覽列的位置 - \ No newline at end of file + diff --git a/app/src/main/res/values-zh-rMO/strings.xml b/app/src/main/res/values-zh-rMO/strings.xml index b5b54eab51..f5683b2e2a 100644 --- a/app/src/main/res/values-zh-rMO/strings.xml +++ b/app/src/main/res/values-zh-rMO/strings.xml @@ -256,7 +256,6 @@ 總是顯示所有敏感媒體內容 媒體 回覆 @%s - 載入更多 公共時間軸 對話 添加新的過濾器 @@ -402,4 +401,4 @@ 話題 編輯 編輯 - \ No newline at end of file + diff --git a/app/src/main/res/values-zh-rSG/strings.xml b/app/src/main/res/values-zh-rSG/strings.xml index 8eb92b7411..fb44030c3e 100644 --- a/app/src/main/res/values-zh-rSG/strings.xml +++ b/app/src/main/res/values-zh-rSG/strings.xml @@ -261,7 +261,6 @@ 总是显示所有敏感媒体内容 媒体 回复 @%s - 加载更多 公共时间轴 对话 添加新的过滤器 @@ -405,4 +404,4 @@ 编辑 编辑 关注请求 - \ No newline at end of file + diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 584303bbb8..4c03f2d0d0 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -262,7 +262,6 @@ 總是顯示所有敏感媒體內容 媒體 回覆 @%s - 載入更多 公共時間軸 對話 添加新的過濾器 @@ -575,4 +574,4 @@ %s 已註冊 %s 編輯了他們的嘟文 這張圖片不能編輯。 - \ No newline at end of file + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8aa90ccaf9..63aea1e1aa 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -436,7 +436,6 @@ Always expand posts marked with content warnings Media Replying to @%s - load more Public timelines Conversations From 98dba8dc07d7614155a9e081cd2f8dbdfc6d850a Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Sun, 16 Apr 2023 11:39:43 +0200 Subject: [PATCH 016/156] Stop using the ReadingOrder preference --- .../components/timeline/TimelineFragment.kt | 61 ------------------- .../timeline/viewmodel/TimelineViewModel.kt | 5 -- 2 files changed, 66 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt index 2ab5bbf03f..1dbe2c51d0 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt @@ -48,7 +48,6 @@ import com.keylesspalace.tusky.appstore.StatusEditedEvent import com.keylesspalace.tusky.components.accountlist.AccountListActivity import com.keylesspalace.tusky.components.accountlist.AccountListActivity.Companion.newIntent import com.keylesspalace.tusky.components.notifications.StatusActionSuccess -import com.keylesspalace.tusky.components.preference.PreferencesFragment.ReadingOrder import com.keylesspalace.tusky.components.timeline.viewmodel.CachedTimelineViewModel import com.keylesspalace.tusky.components.timeline.viewmodel.NetworkTimelineViewModel import com.keylesspalace.tusky.components.timeline.viewmodel.StatusAction @@ -118,35 +117,6 @@ class TimelineFragment : private var isSwipeToRefreshEnabled = true - /** - * Adapter position of the placeholder that was most recently clicked to "Load more". If null - * then there is no active "Load more" operation - */ - private var loadMorePosition: Int? = null - - /** ID of the status immediately below the most recent "Load more" placeholder click */ - // The Paging library assumes that the user will be scrolling down a list of items, - // and if new items are loaded but not visible then it's reasonable to scroll to the top - // of the inserted items. It does not seem to be possible to disable that behaviour. - // - // That behaviour should depend on the user's preferred reading order. If they prefer to - // read oldest first then the list should be scrolled to the bottom of the freshly - // inserted statuses. - // - // To do this: - // - // 1. When "Load more" is clicked (onLoadMore()): - // a. Remember the adapter position of the "Load more" item in loadMorePosition - // b. Remember the ID of the status immediately below the "Load more" item in - // statusIdBelowLoadMore - // 2. After the new items have been inserted, search the adapter for the position of the - // status with id == statusIdBelowLoadMore. - // 3. If this position is still visible on screen then do nothing, otherwise, scroll the view - // so that the status is visible. - // - // The user can then scroll up to read the new statuses. - private var statusIdBelowLoadMore: String? = null - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -190,10 +160,6 @@ class TimelineFragment : } } } - // TODO: probably not necessary without placeholders/reading order. - if (viewModel.uiState.value.readingOrder == ReadingOrder.OLDEST_FIRST) { - updateReadingPositionForOldestFirst() - } } }) @@ -465,33 +431,6 @@ class TimelineFragment : } } - /** - * Set the correct reading position in the timeline after the user clicked "Load more", - * assuming the reading position should be below the freshly-loaded statuses. - */ - // Note: The positionStart parameter to onItemRangeInserted() does not always - // match the adapter position where data was inserted (which is why loadMorePosition - // is tracked manually, see this bug report for another example: - // https://github.com/android/architecture-components-samples/issues/726). - private fun updateReadingPositionForOldestFirst() { - var position = loadMorePosition ?: return - val statusIdBelowLoadMore = statusIdBelowLoadMore ?: return - - var status: StatusViewData? - while (adapter.peek(position).let { status = it; it != null }) { - if (status?.id == statusIdBelowLoadMore) { - val lastVisiblePosition = - (binding.recyclerView.layoutManager as LinearLayoutManager).findLastVisibleItemPosition() - if (position > lastVisiblePosition) { - binding.recyclerView.scrollToPosition(position) - } - break - } - position++ - } - loadMorePosition = null - } - private fun setupSwipeRefreshLayout() { binding.swipeRefreshLayout.isEnabled = isSwipeToRefreshEnabled binding.swipeRefreshLayout.setOnRefreshListener(this) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt index e489a9c552..97c6c830d7 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt @@ -272,7 +272,6 @@ abstract class TimelineViewModel( protected var alwaysOpenSpoilers = false private var filterRemoveReplies = false private var filterRemoveReblogs = false - protected var readingOrder: ReadingOrder = ReadingOrder.OLDEST_FIRST init { // Set initial status display options from the user's preferences. @@ -383,7 +382,6 @@ abstract class TimelineViewModel( filterRemoveReblogs = !sharedPreferences.getBoolean(PrefKeys.TAB_FILTER_HOME_BOOSTS, true) } - readingOrder = ReadingOrder.from(sharedPreferences.getString(PrefKeys.READING_ORDER, null)) this.alwaysShowSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia this.alwaysOpenSpoilers = accountManager.activeAccount!!.alwaysOpenSpoiler @@ -469,9 +467,6 @@ abstract class TimelineViewModel( alwaysShowSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia } - PrefKeys.READING_ORDER -> { - readingOrder = ReadingOrder.from(sharedPreferences.getString(PrefKeys.READING_ORDER, null)) - } } } From f29c35de36ea6dd9508822b65eb7296c188ebc99 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Sun, 16 Apr 2023 11:40:43 +0200 Subject: [PATCH 017/156] Move functions that modify statuses in the DB to the repository --- .../timeline/CachedTimelineRepository.kt | 43 ++++++++++++++++++- .../viewmodel/CachedTimelineViewModel.kt | 29 ++++--------- 2 files changed, 50 insertions(+), 22 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/CachedTimelineRepository.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/CachedTimelineRepository.kt index 5c08b05e20..589f798770 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/CachedTimelineRepository.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/CachedTimelineRepository.kt @@ -42,6 +42,9 @@ import javax.inject.Inject // // Re-writing the caching so that they can use the same types is the TODO. +// TODO: Follow lifecycle recommendations in https://developer.android.com/topic/architecture/data-layer#make_an_operation_live_longer_than_the_screen +// (ditto for the other repositories). + class CachedTimelineRepository @Inject constructor( private val mastodonApi: MastodonApi, private val accountManager: AccountManager, @@ -75,10 +78,48 @@ class CachedTimelineRepository @Inject constructor( ).flow } - fun invalidate() { + suspend fun invalidate() { + // Invalidating when no statuses have been loaded can cause empty timelines because it + // cancels the network load. + if (appDatabase.timelineDao().getStatusCount(accountManager.activeAccount!!.id) < 1) { + return + } + factory?.invalidate() } + /** Set and store the "expanded" state of the given status, for the active account */ + suspend fun setExpanded(expanded: Boolean, statusId: String) { + appDatabase.timelineDao().setExpanded(accountManager.activeAccount!!.id, statusId, expanded) + } + + /** Set and store the "content showing" state of the given status, for the active account */ + suspend fun setContentShowing(showing: Boolean, statusId: String) { + appDatabase.timelineDao() + .setContentShowing(accountManager.activeAccount!!.id, statusId, showing) + } + + /** Set and store the "content collapsed" ("Show more") state of the given status, for the active account */ + suspend fun setContentCollapsed(collapsed: Boolean, statusId: String) { + appDatabase.timelineDao() + .setContentCollapsed(accountManager.activeAccount!!.id, statusId, collapsed) + } + + /** Remove all statuses authored/boosted by the given account, for the active account */ + suspend fun removeAllByAccountId(accountId: String) { + appDatabase.timelineDao().removeAllByUser(accountManager.activeAccount!!.id, accountId) + } + + /** Remove all statuses from the given instance, for the active account */ + suspend fun removeAllByInstance(instance: String) { + appDatabase.timelineDao().deleteAllFromInstance(accountManager.activeAccount!!.id, instance) + } + + /** Clear the warning (remove the "filtered" setting) for the given status, for the active account */ + suspend fun clearStatusWarning(statusId: String) { + appDatabase.timelineDao().clearWarning(accountManager.activeAccount!!.id, statusId) + } + companion object { private const val TAG = "CachedTimelineRepository" private const val PAGE_SIZE = 30 diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt index 4e88dd24ea..9371985be1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt @@ -74,8 +74,6 @@ class CachedTimelineViewModel @Inject constructor( @OptIn(ExperimentalCoroutinesApi::class) override fun init(timelineKind: TimelineKind) { super.init(timelineKind) - // TODO: Got here -- need to make sure that the boosts/replies pref changes don't - // blow away the database. statuses = getUiPrefs() .flatMapLatest { getStatuses(timelineKind) @@ -104,7 +102,7 @@ class CachedTimelineViewModel @Inject constructor( } // TODO: - // - Does the above need a .flowOn(Dispatches.Default) and a .cachedIn(viewModelScope)? + // - Does the above need a .flowOn(Dispatches.Default) // - Ditto for the same code in NetworkTimelineViewModel (check NotificationsViewModel) } @@ -124,46 +122,38 @@ class CachedTimelineViewModel @Inject constructor( } override fun changeExpanded(expanded: Boolean, status: StatusViewData) { - // TODO: Don't touch the db directly, go through the repository viewModelScope.launch { - db.timelineDao().setExpanded(accountManager.activeAccount!!.id, status.id, expanded) + repository.setExpanded(expanded, status.id) } } override fun changeContentShowing(isShowing: Boolean, status: StatusViewData) { - // TODO: Don't touch the db directly, go through the repository viewModelScope.launch { - db.timelineDao() - .setContentShowing(accountManager.activeAccount!!.id, status.id, isShowing) + repository.setContentShowing(isShowing, status.id) } } override fun changeContentCollapsed(isCollapsed: Boolean, status: StatusViewData) { - // TODO: Don't touch the db directly, go through the repository viewModelScope.launch { - db.timelineDao() - .setContentCollapsed(accountManager.activeAccount!!.id, status.id, isCollapsed) + repository.setContentCollapsed(isCollapsed, status.id) } } override fun removeAllByAccountId(accountId: String) { - // TODO: Don't touch the db directly, go through the repository viewModelScope.launch { - db.timelineDao().removeAllByUser(accountManager.activeAccount!!.id, accountId) + repository.removeAllByAccountId(accountId) } } override fun removeAllByInstance(instance: String) { - // TODO: Don't touch the db directly, go through the repository viewModelScope.launch { - db.timelineDao().deleteAllFromInstance(accountManager.activeAccount!!.id, instance) + repository.removeAllByInstance(instance) } } override fun clearWarning(status: StatusViewData) { - // TODO: Don't touch the db directly, go through the repository viewModelScope.launch { - db.timelineDao().clearWarning(accountManager.activeAccount!!.id, status.actionableId) + repository.clearStatusWarning(status.actionableId) } } @@ -199,10 +189,7 @@ class CachedTimelineViewModel @Inject constructor( } override suspend fun invalidate() { - // invalidating when we don't have statuses yet can cause empty timelines because it cancels the network load - if (db.timelineDao().getStatusCount(accountManager.activeAccount!!.id) > 0) { - repository.invalidate() - } + repository.invalidate() } companion object { From 02cdbba2186227851b8478fb0a5610e3fe1f6c1f Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Sun, 16 Apr 2023 11:40:54 +0200 Subject: [PATCH 018/156] Remove unnecessary constant --- .../tusky/components/timeline/viewmodel/TimelineViewModel.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt index 97c6c830d7..1461c3afaf 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt @@ -545,7 +545,6 @@ abstract class TimelineViewModel( companion object { private const val TAG = "TimelineViewModel" - internal const val LOAD_AT_ONCE = 30 private const val DEBOUNCE_TIMEOUT_MS = 500L fun filterContextMatchesKind( From 257074d44133106e1b6da45e3f37778e4809e1f2 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Sun, 16 Apr 2023 15:32:10 +0200 Subject: [PATCH 019/156] Move cache pruning to a WorkManager worker CachedTimelineViewModel no longer needs an injected AppDatabase. To do this: - Extend what was `NotificationWorkerFactory` to `WorkerFactory`. This can construct arbitrary Workers as long as they provide their own Factory for construction. The per-Worker factory contains any injected components just for that worker type, keeping `WorkerFactory` clean. - Move `NotificationWorkerFactory` to the new model. - Implement `PruneCacheWorker`, and remove the code from `CachedTimelineViewModel`. - Create the periodic worker in `TuskyApplication`, ensuring that the database is only pruned when the device is idle. --- .../keylesspalace/tusky/TuskyApplication.kt | 20 ++++++- .../com/keylesspalace/tusky/WorkerFactory.kt | 56 ++++++++++++++++++ .../notifications/NotificationWorker.kt | 27 +++------ .../components/timeline/PruneCacheWorker.kt | 59 +++++++++++++++++++ .../viewmodel/CachedTimelineViewModel.kt | 16 ----- .../keylesspalace/tusky/di/AppComponent.kt | 3 +- .../keylesspalace/tusky/di/WorkerModule.kt | 45 ++++++++++++++ gradle/libs.versions.toml | 3 +- 8 files changed, 190 insertions(+), 39 deletions(-) create mode 100644 app/src/main/java/com/keylesspalace/tusky/WorkerFactory.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/timeline/PruneCacheWorker.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/di/WorkerModule.kt diff --git a/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt b/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt index ef5b8cab44..ea3b6ad6d5 100644 --- a/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt +++ b/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt @@ -18,9 +18,12 @@ package com.keylesspalace.tusky import android.app.Application import android.content.SharedPreferences import android.util.Log +import androidx.work.Constraints +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkManager import autodispose2.AutoDisposePlugins -import com.keylesspalace.tusky.components.notifications.NotificationWorkerFactory +import com.keylesspalace.tusky.components.timeline.PruneCacheWorker import com.keylesspalace.tusky.di.AppInjector import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.settings.SCHEMA_VERSION @@ -35,6 +38,7 @@ import de.c1710.filemojicompat_ui.helpers.EmojiPreference import io.reactivex.rxjava3.plugins.RxJavaPlugins import org.conscrypt.Conscrypt import java.security.Security +import java.util.concurrent.TimeUnit import javax.inject.Inject class TuskyApplication : Application(), HasAndroidInjector { @@ -42,7 +46,7 @@ class TuskyApplication : Application(), HasAndroidInjector { lateinit var androidInjector: DispatchingAndroidInjector @Inject - lateinit var notificationWorkerFactory: NotificationWorkerFactory + lateinit var workerFactory: WorkerFactory @Inject lateinit var localeManager: LocaleManager @@ -93,9 +97,19 @@ class TuskyApplication : Application(), HasAndroidInjector { WorkManager.initialize( this, androidx.work.Configuration.Builder() - .setWorkerFactory(notificationWorkerFactory) + .setWorkerFactory(workerFactory) .build() ) + + // Prune the database every ~ 12 hours when the device is idle. + val pruneCacheWorker = PeriodicWorkRequestBuilder(12, TimeUnit.HOURS) + .setConstraints(Constraints.Builder().setRequiresDeviceIdle(true).build()) + .build() + WorkManager.getInstance(this).enqueueUniquePeriodicWork( + PruneCacheWorker.PERIODIC_WORK_TAG, + ExistingPeriodicWorkPolicy.KEEP, + pruneCacheWorker + ) } override fun androidInjector() = androidInjector diff --git a/app/src/main/java/com/keylesspalace/tusky/WorkerFactory.kt b/app/src/main/java/com/keylesspalace/tusky/WorkerFactory.kt new file mode 100644 index 0000000000..5b31bd1f0f --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/WorkerFactory.kt @@ -0,0 +1,56 @@ +/* + * 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 + +import android.content.Context +import androidx.work.ListenableWorker +import androidx.work.WorkerFactory +import androidx.work.WorkerParameters +import javax.inject.Inject +import javax.inject.Provider + +/** + * Workers implement this and are added to the map in [com.keylesspalace.tusky.di.WorkerModule] + * so they can be created by [WorkerFactory.createWorker]. + */ +interface ChildWorkerFactory { + /** Create a new instance of the given worker. */ + fun createWorker(appContext: Context, params: WorkerParameters): ListenableWorker +} + +/** + * Creates workers, delegating to each worker's [ChildWorkerFactory.createWorker] to do the + * creation. + * + * @see [com.keylesspalace.tusky.components.notifications.NotificationWorker] + */ +class WorkerFactory @Inject constructor( + val workerFactories: Map, @JvmSuppressWildcards Provider> +) : WorkerFactory() { + override fun createWorker( + appContext: Context, + workerClassName: String, + workerParameters: WorkerParameters + ): ListenableWorker? { + val key = Class.forName(workerClassName) + workerFactories[key]?.let { + return it.get().createWorker(appContext, workerParameters) + } + return null + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationWorker.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationWorker.kt index 42b9c869e1..b71dd0dad5 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationWorker.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationWorker.kt @@ -16,36 +16,27 @@ package com.keylesspalace.tusky.components.notifications import android.content.Context -import androidx.work.ListenableWorker import androidx.work.Worker -import androidx.work.WorkerFactory import androidx.work.WorkerParameters +import com.keylesspalace.tusky.ChildWorkerFactory import javax.inject.Inject +/** Fetch and show new notifications. */ class NotificationWorker( - context: Context, + appContext: Context, params: WorkerParameters, private val notificationsFetcher: NotificationFetcher -) : Worker(context, params) { - +) : Worker(appContext, params) { override fun doWork(): Result { notificationsFetcher.fetchAndShow() return Result.success() } -} - -class NotificationWorkerFactory @Inject constructor( - private val notificationsFetcher: NotificationFetcher -) : WorkerFactory() { - override fun createWorker( - appContext: Context, - workerClassName: String, - workerParameters: WorkerParameters - ): ListenableWorker? { - if (workerClassName == NotificationWorker::class.java.name) { - return NotificationWorker(appContext, workerParameters, notificationsFetcher) + class Factory @Inject constructor( + private val notificationsFetcher: NotificationFetcher + ) : ChildWorkerFactory { + override fun createWorker(appContext: Context, params: WorkerParameters): Worker { + return NotificationWorker(appContext, params, notificationsFetcher) } - return null } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/PruneCacheWorker.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/PruneCacheWorker.kt new file mode 100644 index 0000000000..0913e71d87 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/PruneCacheWorker.kt @@ -0,0 +1,59 @@ +/* + * 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.timeline + +import android.content.Context +import android.util.Log +import androidx.work.CoroutineWorker +import androidx.work.ListenableWorker +import androidx.work.WorkerParameters +import com.keylesspalace.tusky.ChildWorkerFactory +import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.db.AppDatabase +import javax.inject.Inject + +/** Prune the database cache of old statuses. */ +class PruneCacheWorker( + appContext: Context, + workerParams: WorkerParameters, + private val appDatabase: AppDatabase, + private val accountManager: AccountManager +) : CoroutineWorker(appContext, workerParams) { + override suspend fun doWork(): Result { + accountManager.activeAccount?.id?.let { accountId -> + Log.d(TAG, "Pruning database using account ID: $accountId") + appDatabase.timelineDao().cleanup(accountId, MAX_STATUSES_IN_CACHE) + } + return Result.success() + } + + companion object { + private const val TAG = "PruneCacheWorker" + private const val MAX_STATUSES_IN_CACHE = 1000 + const val PERIODIC_WORK_TAG = "PruneCacheWorker_periodic" + } + + class Factory @Inject constructor( + private val appDatabase: AppDatabase, + private val accountManager: AccountManager + ) : ChildWorkerFactory { + override fun createWorker(appContext: Context, params: WorkerParameters): ListenableWorker { + return PruneCacheWorker(appContext, params, appDatabase, accountManager) + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt index 9371985be1..eb4b45bb2b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt @@ -30,7 +30,6 @@ import com.keylesspalace.tusky.appstore.ReblogEvent import com.keylesspalace.tusky.components.timeline.CachedTimelineRepository import com.keylesspalace.tusky.components.timeline.toViewData import com.keylesspalace.tusky.db.AccountManager -import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.network.FilterModel @@ -38,14 +37,11 @@ import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.usecase.TimelineCases import com.keylesspalace.tusky.viewdata.StatusViewData import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import javax.inject.Inject -import kotlin.time.DurationUnit -import kotlin.time.toDuration /** * TimelineViewModel that caches all statuses in a local database @@ -58,7 +54,6 @@ class CachedTimelineViewModel @Inject constructor( accountManager: AccountManager, preferences: SharedPreferences, filterModel: FilterModel, - private val db: AppDatabase, private val gson: Gson ) : TimelineViewModel( timelineCases, @@ -106,17 +101,6 @@ class CachedTimelineViewModel @Inject constructor( // - Ditto for the same code in NetworkTimelineViewModel (check NotificationsViewModel) } - init { - // TODO: This probably shouldn't be done here, but be a WorkManager job - viewModelScope.launch { - delay(5.toDuration(DurationUnit.SECONDS)) // delay so the db is not locked during initial ui refresh - accountManager.activeAccount?.id?.let { accountId -> - db.timelineDao().cleanup(accountId, MAX_STATUSES_IN_CACHE) - db.timelineDao().cleanupAccounts(accountId) - } - } - } - override fun updatePoll(newPoll: Poll, status: StatusViewData) { // handled by CacheUpdater } diff --git a/app/src/main/java/com/keylesspalace/tusky/di/AppComponent.kt b/app/src/main/java/com/keylesspalace/tusky/di/AppComponent.kt index 2cf480469b..73aceeab2d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/AppComponent.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/AppComponent.kt @@ -34,7 +34,8 @@ import javax.inject.Singleton ActivitiesModule::class, ServicesModule::class, BroadcastReceiverModule::class, - ViewModelModule::class + ViewModelModule::class, + WorkerModule::class ] ) interface AppComponent { diff --git a/app/src/main/java/com/keylesspalace/tusky/di/WorkerModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/WorkerModule.kt new file mode 100644 index 0000000000..f1929a616d --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/di/WorkerModule.kt @@ -0,0 +1,45 @@ +/* + * 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.di + +import androidx.work.ListenableWorker +import com.keylesspalace.tusky.ChildWorkerFactory +import com.keylesspalace.tusky.components.notifications.NotificationWorker +import com.keylesspalace.tusky.components.timeline.PruneCacheWorker +import dagger.Binds +import dagger.MapKey +import dagger.Module +import dagger.multibindings.IntoMap +import kotlin.reflect.KClass + +@Retention(AnnotationRetention.RUNTIME) +@MapKey +annotation class WorkerKey(val value: KClass) + +@Module +abstract class WorkerModule { + @Binds + @IntoMap + @WorkerKey(NotificationWorker::class) + internal abstract fun bindNotificationWorkerFactory(worker: NotificationWorker.Factory): ChildWorkerFactory + + @Binds + @IntoMap + @WorkerKey(PruneCacheWorker::class) + internal abstract fun bindPruneCacheWorkerFactory(worker: PruneCacheWorker.Factory): ChildWorkerFactory +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7a307816a3..8e492f57c8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -91,6 +91,7 @@ androidx-swiperefreshlayout = { module = "androidx.swiperefreshlayout:swiperefre androidx-test-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-junit" } androidx-viewpager2 = { module = "androidx.viewpager2:viewpager2", version.ref = "androidx-viewpager2" } androidx-work-runtime = { module = "androidx.work:work-runtime", version.ref = "androidx-work" } +androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "androidx-work" } androidx-work-testing = { module = "androidx.work:work-testing", version.ref = "androidx-work" } autodispose-android-lifecycle = { module = "com.uber.autodispose2:autodispose-androidx-lifecycle", version.ref = "autodispose" } autodispose-core = { module = "com.uber.autodispose2:autodispose", version.ref = "autodispose" } @@ -145,7 +146,7 @@ androidx = ["androidx-core-ktx", "androidx-appcompat", "androidx-fragment-ktx", "androidx-emoji2-core", "androidx-emoji2-views-core", "androidx-emoji2-view-helper", "androidx-lifecycle-viewmodel-ktx", "androidx-lifecycle-livedata-ktx", "androidx-lifecycle-common-java8", "androidx-lifecycle-reactivestreams-ktx", "androidx-constraintlayout", "androidx-paging-runtime-ktx", "androidx-viewpager2", "androidx-work-runtime", - "androidx-core-splashscreen", "androidx-activity"] + "androidx-work-runtime-ktx", "androidx-core-splashscreen", "androidx-activity"] autodispose = ["autodispose-core", "autodispose-android-lifecycle"] dagger = ["dagger-core", "dagger-android-core", "dagger-android-support"] dagger-processors = ["dagger-compiler", "dagger-android-processor"] From 322d2df1f7d117ab9203e58709d7c817c3606713 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Sun, 16 Apr 2023 18:53:51 +0200 Subject: [PATCH 020/156] Update timeline on mutes and blocks. --- .../components/timeline/TimelineFragment.kt | 22 +++++++++---------- .../timeline/viewmodel/TimelineViewModel.kt | 12 ++++++++++ 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt index 1dbe2c51d0..d713e25a5c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt @@ -53,6 +53,7 @@ import com.keylesspalace.tusky.components.timeline.viewmodel.NetworkTimelineView import com.keylesspalace.tusky.components.timeline.viewmodel.StatusAction import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineKind import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel +import com.keylesspalace.tusky.components.timeline.viewmodel.UiSuccess import com.keylesspalace.tusky.databinding.FragmentTimelineBinding import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.di.ViewModelFactory @@ -292,17 +293,16 @@ class TimelineFragment : } // Refresh adapter on mutes and blocks - // TODO: Check that this makes sense and implement UiSuccess.* classes. -// launch { -// viewModel.uiSuccess.collectLatest { -// when (it) { -// is UiSuccess.Block, is UiSuccess.Mute, is UiSuccess.MuteConversation -> -// adapter.refresh() -// else -> { /* nothing to do */ -// } -// } -// } -// } + launch { + viewModel.uiSuccess.collectLatest { + when (it) { + is UiSuccess.Block, is UiSuccess.Mute, is UiSuccess.MuteConversation -> + adapter.refresh() + else -> { /* nothing to do */ + } + } + } + } viewModel.uiState.collectLatest { // showMediaPreview changed? diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt index 1461c3afaf..1ca4fae8f0 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt @@ -56,6 +56,7 @@ 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.debounce import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterIsInstance @@ -341,6 +342,17 @@ abstract class TimelineViewModel( } } + // 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) + } + } + } + uiState = getUiPrefs().map { prefs -> UiState( readingOrder = prefs.readingOrder, From e4c15848923518614d37486ba92a8e56841c2aa8 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Sun, 16 Apr 2023 18:54:40 +0200 Subject: [PATCH 021/156] AGP 8 / Java 17 (for Android Studio Flamingo) --- app/build.gradle | 4 ++-- build.gradle | 2 +- gradle/libs.versions.toml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index a1f4e2bff1..2af003d316 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -102,8 +102,8 @@ android { // Can remove this once https://issuetracker.google.com/issues/260059413 is fixed. // https://kotlinlang.org/docs/gradle-configure-project.html#gradle-java-toolchains-support compileOptions { - sourceCompatibility JavaVersion.VERSION_11 - targetCompatibility JavaVersion.VERSION_11 + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 } applicationVariants.configureEach { variant -> variant.outputs.configureEach { diff --git a/build.gradle b/build.gradle index 14527214e6..090c9733f1 100644 --- a/build.gradle +++ b/build.gradle @@ -11,7 +11,7 @@ allprojects { plugins.withType(JavaBasePlugin).configureEach { java { - toolchain.languageVersion = JavaLanguageVersion.of(11) + toolchain.languageVersion = JavaLanguageVersion.of(17) } } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8e492f57c8..6f8e4c0be2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -agp = "7.4.2" +agp = "8.0.0" androidx-activity = "1.7.0" androidx-appcompat = "1.6.1" androidx-browser = "1.5.0" From c91a9311f9eee19ffa003c231ff33621b080febe Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Sun, 16 Apr 2023 18:58:07 +0200 Subject: [PATCH 022/156] Remove obsolete onLoadMore method from interface and implementations --- .../tusky/components/conversation/ConversationsFragment.kt | 4 ---- .../components/notifications/NotificationsFragment.kt | 4 ---- .../components/search/fragments/SearchStatusesFragment.kt | 4 ---- .../tusky/components/timeline/TimelineFragment.kt | 7 ------- .../tusky/components/viewthread/ViewThreadFragment.kt | 4 ---- .../tusky/interfaces/StatusActionListener.java | 7 +++---- 6 files changed, 3 insertions(+), 27 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt index 303ec6b030..ce0b3bddab 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt @@ -325,10 +325,6 @@ class ConversationsFragment : } } - override fun onLoadMore(position: Int) { - // not using the old way of pagination - } - override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) { adapter.peek(position)?.let { conversation -> viewModel.collapseLongStatus(isCollapsed, conversation) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt index 8bd73d221a..07e10a1ecb 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt @@ -526,10 +526,6 @@ class NotificationsFragment : adapter.notifyItemChanged(position) } - override fun onLoadMore(position: Int) { - // Empty -- this fragment doesn't show placeholders - } - override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) { val notificationViewData = adapter.snapshot()[position] ?: return notificationViewData.statusViewData = notificationViewData.statusViewData?.copy( diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt index 94259eb827..0cbe300479 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt @@ -177,10 +177,6 @@ class SearchStatusesFragment : SearchFragment(), StatusActionLis } } - override fun onLoadMore(position: Int) { - // Not possible here - } - override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) { searchAdapter.peek(position)?.let { viewModel.collapsedChange(it, isCollapsed) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt index d713e25a5c..1e2bb5653e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt @@ -526,13 +526,6 @@ class TimelineFragment : (activity as BaseActivity).startActivityWithSlideInAnimation(intent) } - override fun onLoadMore(position: Int) { -// val placeholder = adapter.peek(position)?.asPlaceholderOrNull() ?: return -// loadMorePosition = position -// statusIdBelowLoadMore = adapter.peek(position + 1)?.id -// viewModel.loadMore(placeholder.id) - } - override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) { val status = adapter.peek(position) ?: return viewModel.changeContentCollapsed(isCollapsed, status) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadFragment.kt index a40ef8bf5c..ded572e20a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadFragment.kt @@ -387,10 +387,6 @@ class ViewThreadFragment : viewModel.changeContentShowing(isShowing, adapter.currentList[position]) } - override fun onLoadMore(position: Int) { - // only used in timelines - } - override fun onShowReblogs(position: Int) { val statusId = adapter.currentList[position].id val intent = newIntent(requireContext(), AccountListActivity.Type.REBLOGGED, statusId) diff --git a/app/src/main/java/com/keylesspalace/tusky/interfaces/StatusActionListener.java b/app/src/main/java/com/keylesspalace/tusky/interfaces/StatusActionListener.java index e142683a16..88225b9e88 100644 --- a/app/src/main/java/com/keylesspalace/tusky/interfaces/StatusActionListener.java +++ b/app/src/main/java/com/keylesspalace/tusky/interfaces/StatusActionListener.java @@ -17,11 +17,11 @@ import android.view.View; -import java.util.List; - import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import java.util.List; + public interface StatusActionListener extends LinkListener { void onReply(int position); void onReblog(final boolean reblog, final int position); @@ -38,7 +38,6 @@ public interface StatusActionListener extends LinkListener { void onOpenReblog(int position); void onExpandedChange(boolean expanded, int position); void onContentHiddenChange(boolean isShowing, int position); - void onLoadMore(int position); /** * Called when the status {@link android.widget.ToggleButton} responsible for collapsing long @@ -64,7 +63,7 @@ default void onShowFavs(int position) {} void onVoteInPoll(int position, @NonNull List choices); default void onShowEdits(int position) {} - + void clearWarningAction(int position); } From 00a8fc5d509228682c11a48190d1b86ac9091ac5 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Sun, 16 Apr 2023 19:25:31 +0200 Subject: [PATCH 023/156] Emit UiSuccess events when a status is composed or edited This pushes the last of the EventHub responsibility out of TimelineFragment in to TimelineViewModel --- .../keylesspalace/tusky/appstore/Events.kt | 2 + .../components/timeline/TimelineFragment.kt | 42 ++++++++----------- .../timeline/viewmodel/TimelineViewModel.kt | 10 +++++ 3 files changed, 30 insertions(+), 24 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/appstore/Events.kt b/app/src/main/java/com/keylesspalace/tusky/appstore/Events.kt index 494d67974c..c2d9a850fb 100644 --- a/app/src/main/java/com/keylesspalace/tusky/appstore/Events.kt +++ b/app/src/main/java/com/keylesspalace/tusky/appstore/Events.kt @@ -13,6 +13,8 @@ data class UnfollowEvent(val accountId: String) : Event data class BlockEvent(val accountId: String) : Event data class MuteEvent(val accountId: String) : Event data class StatusDeletedEvent(val statusId: String) : Event +/** A status the user wrote was successfully sent */ +// TODO: Rename, calling it "Composed" does imply anything about the sent state data class StatusComposedEvent(val status: Status) : Event data class StatusScheduledEvent(val status: Status) : Event data class StatusEditedEvent(val originalId: String, val status: Status) : Event diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt index 1e2bb5653e..2b18c59a19 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt @@ -42,9 +42,6 @@ import com.google.android.material.snackbar.Snackbar import com.keylesspalace.tusky.BaseActivity import com.keylesspalace.tusky.R import com.keylesspalace.tusky.adapter.StatusBaseViewHolder -import com.keylesspalace.tusky.appstore.EventHub -import com.keylesspalace.tusky.appstore.StatusComposedEvent -import com.keylesspalace.tusky.appstore.StatusEditedEvent import com.keylesspalace.tusky.components.accountlist.AccountListActivity import com.keylesspalace.tusky.components.accountlist.AccountListActivity.Companion.newIntent import com.keylesspalace.tusky.components.notifications.StatusActionSuccess @@ -97,9 +94,6 @@ class TimelineFragment : @Inject lateinit var viewModelFactory: ViewModelFactory - @Inject - lateinit var eventHub: EventHub - private val viewModel: TimelineViewModel by unsafeLazy { if (timelineKind == TimelineKind.Home) { ViewModelProvider(this, viewModelFactory)[CachedTimelineViewModel::class.java] @@ -296,10 +290,15 @@ class TimelineFragment : launch { viewModel.uiSuccess.collectLatest { when (it) { - is UiSuccess.Block, is UiSuccess.Mute, is UiSuccess.MuteConversation -> + is UiSuccess.Block, + is UiSuccess.Mute, + is UiSuccess.MuteConversation -> adapter.refresh() - else -> { /* nothing to do */ - } + + is UiSuccess.StatusSent -> handleStatusSentOrEdit(it.status) + is UiSuccess.StatusEdited -> handleStatusSentOrEdit(it.status) + + else -> { /* nothing to do */ } } } } @@ -340,20 +339,6 @@ class TimelineFragment : } } - launch { - eventHub.events.collect { event -> - when (event) { - is StatusComposedEvent -> { - val status = event.status - handleStatusComposeEvent(status) - } - is StatusEditedEvent -> { - handleStatusComposeEvent(event.status) - } - } - } - } - // Update the UI from the combined load state adapter.loadStateFlow .distinctUntilChangedBy { it.refresh } @@ -567,7 +552,16 @@ class TimelineFragment : super.viewAccount(id) } - private fun handleStatusComposeEvent(status: Status) { + /** + * A status the user has written has either: + * + * - Been successfully posted + * - Been edited by the user + * + * Depending on the timeline kind it may need refreshing to show the new status or the changes + * that have been made to it. + */ + private fun handleStatusSentOrEdit(status: Status) { when (timelineKind) { is TimelineKind.User.Pinned -> return diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt index 1ca4fae8f0..5ef2fbda2e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt @@ -35,7 +35,9 @@ import com.keylesspalace.tusky.appstore.MuteEvent import com.keylesspalace.tusky.appstore.PinEvent import com.keylesspalace.tusky.appstore.PreferenceChangedEvent import com.keylesspalace.tusky.appstore.ReblogEvent +import com.keylesspalace.tusky.appstore.StatusComposedEvent import com.keylesspalace.tusky.appstore.StatusDeletedEvent +import com.keylesspalace.tusky.appstore.StatusEditedEvent import com.keylesspalace.tusky.appstore.UnfollowEvent import com.keylesspalace.tusky.components.preference.PreferencesFragment.ReadingOrder import com.keylesspalace.tusky.components.timeline.util.ifExpected @@ -135,6 +137,12 @@ sealed class UiSuccess { /** A conversation was muted */ object MuteConversation : UiSuccess() + + /** A status the user wrote was successfully posted */ + data class StatusSent(val status: Status) : UiSuccess() + + /** A status the user wrote was successfully edited */ + data class StatusEdited(val status: Status) : UiSuccess() } /** Actions the user can trigger on an individual status */ @@ -349,6 +357,8 @@ abstract class TimelineViewModel( is BlockEvent -> uiSuccess.emit(UiSuccess.Block) is MuteEvent -> uiSuccess.emit(UiSuccess.Mute) is MuteConversationEvent -> uiSuccess.emit(UiSuccess.MuteConversation) + is StatusComposedEvent -> uiSuccess.emit(UiSuccess.StatusSent(it.status)) + is StatusEditedEvent -> uiSuccess.emit(UiSuccess.StatusEdited(it.status)) } } } From 56ea51f880ef3c88a4666d4f3f8c59875fa68ccc Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Sun, 16 Apr 2023 19:31:48 +0200 Subject: [PATCH 024/156] Move TimelineKind definition to a separate file --- .../keylesspalace/tusky/StatusListActivity.kt | 2 +- .../java/com/keylesspalace/tusky/TabData.kt | 2 +- .../components/account/AccountPagerAdapter.kt | 2 +- .../timeline/CachedTimelineRepository.kt | 1 - .../timeline/NetworkTimelineRepository.kt | 1 - .../components/timeline/TimelineFragment.kt | 1 - .../tusky/components/timeline/TimelineKind.kt | 46 +++++++++++++++++++ .../viewmodel/CachedTimelineViewModel.kt | 1 + .../viewmodel/NetworkTimelinePagingSource.kt | 28 +---------- .../viewmodel/NetworkTimelineViewModel.kt | 1 + .../timeline/viewmodel/TimelineViewModel.kt | 1 + .../com/keylesspalace/tusky/entity/Filter.kt | 2 +- 12 files changed, 54 insertions(+), 34 deletions(-) create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineKind.kt diff --git a/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt b/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt index 033ad7cb51..df52b0e1b5 100644 --- a/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt @@ -28,7 +28,7 @@ import com.google.android.material.snackbar.Snackbar import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.PreferenceChangedEvent import com.keylesspalace.tusky.components.timeline.TimelineFragment -import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineKind +import com.keylesspalace.tusky.components.timeline.TimelineKind import com.keylesspalace.tusky.databinding.ActivityStatuslistBinding import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.entity.FilterV1 diff --git a/app/src/main/java/com/keylesspalace/tusky/TabData.kt b/app/src/main/java/com/keylesspalace/tusky/TabData.kt index f6b2925906..015bdc04cd 100644 --- a/app/src/main/java/com/keylesspalace/tusky/TabData.kt +++ b/app/src/main/java/com/keylesspalace/tusky/TabData.kt @@ -22,7 +22,7 @@ import androidx.fragment.app.Fragment import com.keylesspalace.tusky.components.conversation.ConversationsFragment import com.keylesspalace.tusky.components.notifications.NotificationsFragment import com.keylesspalace.tusky.components.timeline.TimelineFragment -import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineKind +import com.keylesspalace.tusky.components.timeline.TimelineKind import com.keylesspalace.tusky.components.trending.TrendingFragment import java.util.Objects diff --git a/app/src/main/java/com/keylesspalace/tusky/components/account/AccountPagerAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/account/AccountPagerAdapter.kt index ae3345bb2d..5649c28b4f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/account/AccountPagerAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/account/AccountPagerAdapter.kt @@ -19,7 +19,7 @@ import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentActivity import com.keylesspalace.tusky.components.account.media.AccountMediaFragment import com.keylesspalace.tusky.components.timeline.TimelineFragment -import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineKind +import com.keylesspalace.tusky.components.timeline.TimelineKind import com.keylesspalace.tusky.interfaces.RefreshableFragment import com.keylesspalace.tusky.util.CustomFragmentStateAdapter diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/CachedTimelineRepository.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/CachedTimelineRepository.kt index 589f798770..e7e8391d3c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/CachedTimelineRepository.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/CachedTimelineRepository.kt @@ -25,7 +25,6 @@ import androidx.paging.PagingConfig import androidx.paging.PagingData import com.google.gson.Gson import com.keylesspalace.tusky.components.timeline.viewmodel.CachedTimelineRemoteMediator -import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineKind import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.db.TimelineStatusWithAccount diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRepository.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRepository.kt index bd35feaf38..fef2d685c6 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRepository.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRepository.kt @@ -23,7 +23,6 @@ import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.PagingData import com.keylesspalace.tusky.components.timeline.viewmodel.NetworkTimelinePagingSource -import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineKind import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.network.MastodonApi import kotlinx.coroutines.flow.Flow diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt index 2b18c59a19..c6f2ecac96 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt @@ -48,7 +48,6 @@ import com.keylesspalace.tusky.components.notifications.StatusActionSuccess import com.keylesspalace.tusky.components.timeline.viewmodel.CachedTimelineViewModel import com.keylesspalace.tusky.components.timeline.viewmodel.NetworkTimelineViewModel import com.keylesspalace.tusky.components.timeline.viewmodel.StatusAction -import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineKind import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel import com.keylesspalace.tusky.components.timeline.viewmodel.UiSuccess import com.keylesspalace.tusky.databinding.FragmentTimelineBinding diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineKind.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineKind.kt new file mode 100644 index 0000000000..0bae170736 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineKind.kt @@ -0,0 +1,46 @@ +/* + * 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.timeline + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +/** A timeline's type. Hold's data necessary to display that timeline. */ +@Parcelize +sealed class TimelineKind : Parcelable { + object Home : TimelineKind() + object PublicFederated : TimelineKind() + object PublicLocal : TimelineKind() + data class Tag(val tags: List) : TimelineKind() + + /** Any timeline showing statuses from a single user */ + @Parcelize + sealed class User(open val id: String) : TimelineKind() { + /** Timeline showing just the user's statuses (no replies) */ + data class Posts(override val id: String) : User(id) + + /** Timeline showing the user's pinned statuses */ + data class Pinned(override val id: String) : User(id) + + /** Timeline showing the user's top-level statuses and replies they have made */ + data class Replies(override val id: String) : User(id) + } + object Favourites : TimelineKind() + object Bookmarks : TimelineKind() + data class UserList(val id: String, val title: String) : TimelineKind() +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt index eb4b45bb2b..18834e76ee 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt @@ -28,6 +28,7 @@ import com.keylesspalace.tusky.appstore.FavoriteEvent import com.keylesspalace.tusky.appstore.PinEvent import com.keylesspalace.tusky.appstore.ReblogEvent import com.keylesspalace.tusky.components.timeline.CachedTimelineRepository +import com.keylesspalace.tusky.components.timeline.TimelineKind import com.keylesspalace.tusky.components.timeline.toViewData import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.entity.Filter diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelinePagingSource.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelinePagingSource.kt index a9c49f7a7f..4b06da5d4f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelinePagingSource.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelinePagingSource.kt @@ -15,16 +15,15 @@ package com.keylesspalace.tusky.components.timeline.viewmodel -import android.os.Parcelable import android.util.Log import androidx.paging.PagingSource import androidx.paging.PagingState +import com.keylesspalace.tusky.components.timeline.TimelineKind import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.util.HttpHeaderLink import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope -import kotlinx.parcelize.Parcelize import okhttp3.Headers import retrofit2.HttpException import retrofit2.Response @@ -38,31 +37,6 @@ 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?) -/** A timeline's type. Hold's data necessary to display that timeline. */ -@Parcelize -sealed class TimelineKind : Parcelable { - object Home : TimelineKind() - object PublicFederated : TimelineKind() - object PublicLocal : TimelineKind() - data class Tag(val tags: List) : TimelineKind() - - /** Any timeline showing statuses from a single user */ - @Parcelize - sealed class User(open val id: String) : TimelineKind() { - /** Timeline showing just the user's statuses (no replies) */ - data class Posts(override val id: String) : User(id) - - /** Timeline showing the user's pinned statuses */ - data class Pinned(override val id: String) : User(id) - - /** Timeline showing the user's top-level statuses and replies they have made */ - data class Replies(override val id: String) : User(id) - } - object Favourites : TimelineKind() - object Bookmarks : TimelineKind() - data class UserList(val id: String, val title: String) : TimelineKind() -} - /** [PagingSource] for Mastodon Status, identified by the Status ID */ class NetworkTimelinePagingSource @Inject constructor( private val api: MastodonApi, diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt index c3f686762f..926876a8dd 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt @@ -25,6 +25,7 @@ import com.keylesspalace.tusky.appstore.FavoriteEvent import com.keylesspalace.tusky.appstore.PinEvent import com.keylesspalace.tusky.appstore.ReblogEvent import com.keylesspalace.tusky.components.timeline.NetworkTimelineRepository +import com.keylesspalace.tusky.components.timeline.TimelineKind import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.entity.Poll diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt index 5ef2fbda2e..9fb442bcff 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt @@ -40,6 +40,7 @@ import com.keylesspalace.tusky.appstore.StatusDeletedEvent import com.keylesspalace.tusky.appstore.StatusEditedEvent import com.keylesspalace.tusky.appstore.UnfollowEvent import com.keylesspalace.tusky.components.preference.PreferencesFragment.ReadingOrder +import com.keylesspalace.tusky.components.timeline.TimelineKind import com.keylesspalace.tusky.components.timeline.util.ifExpected import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.entity.Filter diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Filter.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Filter.kt index 239b7ae5e1..dbc5378280 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Filter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Filter.kt @@ -2,7 +2,7 @@ package com.keylesspalace.tusky.entity import android.os.Parcelable import com.google.gson.annotations.SerializedName -import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineKind +import com.keylesspalace.tusky.components.timeline.TimelineKind import kotlinx.parcelize.Parcelize import java.util.Date From e7849497f4c45d398f0b469ecdbb5e92d00e0118 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Mon, 17 Apr 2023 19:42:22 +0200 Subject: [PATCH 025/156] Skeleton code to save the user's reading position Doesn't actually save it (is consistent with current behaviour) but provides the hook from which to hang the future code. --- .../tusky/components/timeline/TimelineFragment.kt | 15 ++++++++++++++- .../timeline/viewmodel/TimelineViewModel.kt | 15 +++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt index c6f2ecac96..587dd313af 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt @@ -44,10 +44,11 @@ import com.keylesspalace.tusky.R import com.keylesspalace.tusky.adapter.StatusBaseViewHolder import com.keylesspalace.tusky.components.accountlist.AccountListActivity import com.keylesspalace.tusky.components.accountlist.AccountListActivity.Companion.newIntent -import com.keylesspalace.tusky.components.notifications.StatusActionSuccess import com.keylesspalace.tusky.components.timeline.viewmodel.CachedTimelineViewModel +import com.keylesspalace.tusky.components.timeline.viewmodel.InfallibleUiAction import com.keylesspalace.tusky.components.timeline.viewmodel.NetworkTimelineViewModel import com.keylesspalace.tusky.components.timeline.viewmodel.StatusAction +import com.keylesspalace.tusky.components.timeline.viewmodel.StatusActionSuccess import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel import com.keylesspalace.tusky.components.timeline.viewmodel.UiSuccess import com.keylesspalace.tusky.databinding.FragmentTimelineBinding @@ -604,6 +605,18 @@ class TimelineFragment : } } + override fun onPause() { + super.onPause() + + // Save the ID of the first visible status in the list + val position = layoutManager.findLastVisibleItemPosition() + if (position != RecyclerView.NO_POSITION) { + adapter.snapshot()[position]?.id?.let { statusId -> + viewModel.accept(InfallibleUiAction.SaveVisibleId(visibleId = statusId)) + } + } + } + override fun onReselect() { if (isAdded) { binding.recyclerView.layoutManager?.scrollToPosition(0) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt index 9fb442bcff..954eb394d5 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt @@ -61,6 +61,7 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.map @@ -284,6 +285,20 @@ abstract class TimelineViewModel( private var filterRemoveReblogs = false init { + // Save the visible status ID + // TODO: Implement following https://github.com/tuskyapp/Tusky/pull/3271 + viewModelScope.launch { + uiAction + .filterIsInstance() + .distinctUntilChanged() + .collectLatest { action -> + Log.d(TAG, "Would save visible ID: ${action.visibleId}") + accountManager.activeAccount?.let { account -> + // TODO: Save the user's position + } + } + } + // Set initial status display options from the user's preferences. // // Then collect future preference changes and emit new values in to From 579ff399e0f9f12a6dcb501ca2e1c7043f71eab3 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Mon, 17 Apr 2023 19:43:23 +0200 Subject: [PATCH 026/156] Lint --- app/src/main/java/com/keylesspalace/tusky/appstore/Events.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/java/com/keylesspalace/tusky/appstore/Events.kt b/app/src/main/java/com/keylesspalace/tusky/appstore/Events.kt index c2d9a850fb..930dc0c35b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/appstore/Events.kt +++ b/app/src/main/java/com/keylesspalace/tusky/appstore/Events.kt @@ -13,6 +13,7 @@ data class UnfollowEvent(val accountId: String) : Event data class BlockEvent(val accountId: String) : Event data class MuteEvent(val accountId: String) : Event data class StatusDeletedEvent(val statusId: String) : Event + /** A status the user wrote was successfully sent */ // TODO: Rename, calling it "Composed" does imply anything about the sent state data class StatusComposedEvent(val status: Status) : Event From cfc9dcc5eee19aa237455c152e3885a83831ac80 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Tue, 18 Apr 2023 19:25:21 +0200 Subject: [PATCH 027/156] Handle filters with a repository and flows - Create a FiltersRepository - Move code to fetch filters in to the new repository - Fetch filters on startup, and when relevant preferences change - Expose errors that occur when loading filters in the UI This removes the API dependency from the timeline view models. --- .../components/filters/FiltersViewModel.kt | 1 + .../components/timeline/FiltersRepository.kt | 63 +++++++ .../components/timeline/TimelineFragment.kt | 2 +- .../viewmodel/CachedTimelineViewModel.kt | 21 +-- .../viewmodel/NetworkTimelineViewModel.kt | 19 +- .../timeline/viewmodel/TimelineViewModel.kt | 177 +++++++++++------- app/src/main/res/values/strings.xml | 1 + 7 files changed, 191 insertions(+), 93 deletions(-) create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/timeline/FiltersRepository.kt diff --git a/app/src/main/java/com/keylesspalace/tusky/components/filters/FiltersViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/filters/FiltersViewModel.kt index e28d251b87..fc7ce62b63 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/filters/FiltersViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/filters/FiltersViewModel.kt @@ -29,6 +29,7 @@ class FiltersViewModel @Inject constructor( val state: Flow get() = _state private val _state = MutableStateFlow(State(emptyList(), LoadingState.INITIAL)) + // TODO: Now that FilterRepository exists this code should be updated to use that. fun load() { this@FiltersViewModel._state.value = _state.value.copy(loadingState = LoadingState.LOADING) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/FiltersRepository.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/FiltersRepository.kt new file mode 100644 index 0000000000..50c874314c --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/FiltersRepository.kt @@ -0,0 +1,63 @@ +/* + * 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.timeline + +import at.connyduck.calladapter.networkresult.fold +import at.connyduck.calladapter.networkresult.getOrThrow +import com.keylesspalace.tusky.entity.Filter +import com.keylesspalace.tusky.entity.FilterV1 +import com.keylesspalace.tusky.network.MastodonApi +import retrofit2.HttpException +import javax.inject.Inject + +sealed class FilterKind { + /** API v1 filter, filtering happens client side */ + data class V1(val filters: List) : FilterKind() + + /** API v2 filter, filtering happens server side */ + data class V2(val filters: List) : FilterKind() +} + +/** Repository for filter information */ +class FiltersRepository @Inject constructor( + private val mastodonApi: MastodonApi +) { + /** + * Get the current set of filters. + * + * Checks for server-side (v2) filters first. If that fails then fetches filters to + * apply client-side. + * + * @throws HttpException if the requests fail + */ + suspend fun getFilters(): FilterKind = mastodonApi.getFilters().fold( + { filters -> FilterKind.V2(filters) }, + { throwable -> + if (throwable is HttpException && throwable.code() == 404) { + val filters = mastodonApi.getFiltersV1().getOrThrow() + FilterKind.V1(filters) + } else { + throw throwable + } + } + ) + + companion object { + private const val TAG = "FiltersRepository" + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt index 587dd313af..17b84cc7f7 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt @@ -221,7 +221,7 @@ class TimelineFragment : Log.d(TAG, error.toString()) val message = getString( error.message, - error.exception.localizedMessage + error.throwable.localizedMessage ?: getString(R.string.ui_error_unknown) ) val snackbar = Snackbar.make( diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt index 18834e76ee..e356f07b62 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt @@ -28,13 +28,13 @@ import com.keylesspalace.tusky.appstore.FavoriteEvent import com.keylesspalace.tusky.appstore.PinEvent import com.keylesspalace.tusky.appstore.ReblogEvent import com.keylesspalace.tusky.components.timeline.CachedTimelineRepository +import com.keylesspalace.tusky.components.timeline.FiltersRepository import com.keylesspalace.tusky.components.timeline.TimelineKind import com.keylesspalace.tusky.components.timeline.toViewData import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.network.FilterModel -import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.usecase.TimelineCases import com.keylesspalace.tusky.viewdata.StatusViewData import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -50,16 +50,16 @@ import javax.inject.Inject class CachedTimelineViewModel @Inject constructor( private val repository: CachedTimelineRepository, timelineCases: TimelineCases, - api: MastodonApi, eventHub: EventHub, + filtersRepository: FiltersRepository, accountManager: AccountManager, preferences: SharedPreferences, filterModel: FilterModel, private val gson: Gson ) : TimelineViewModel( timelineCases, - api, eventHub, + filtersRepository, accountManager, preferences, filterModel @@ -83,18 +83,9 @@ class CachedTimelineViewModel @Inject constructor( ): Flow> { return repository.getStatusStream(kind = kind, initialKey = initialKey) .map { pagingData -> - pagingData.map { - it.toViewData(gson) - } - }.map { - // TODO: These operations happen in a sub-optimal order. Ideally we could do - // any filtering of the statuses before the cost of converting them to viewdata. - // However, TimelineStatusWithAccount does not provide access to the `Status` - // type that is needed to do the filtering, so it has to be converted to a - // `StatusViewData` first. - it.filter { - shouldFilterStatus(it.status) != Filter.Action.HIDE - } + pagingData + .map { it.toViewData(gson) } + .filter { shouldFilterStatus(it) != Filter.Action.HIDE } } // TODO: diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt index 926876a8dd..fd5a23e10a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt @@ -24,6 +24,7 @@ import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.FavoriteEvent import com.keylesspalace.tusky.appstore.PinEvent import com.keylesspalace.tusky.appstore.ReblogEvent +import com.keylesspalace.tusky.components.timeline.FiltersRepository import com.keylesspalace.tusky.components.timeline.NetworkTimelineRepository import com.keylesspalace.tusky.components.timeline.TimelineKind import com.keylesspalace.tusky.db.AccountManager @@ -31,7 +32,6 @@ import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.network.FilterModel -import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.usecase.TimelineCases import com.keylesspalace.tusky.util.getDomain import com.keylesspalace.tusky.util.toViewData @@ -46,12 +46,19 @@ import javax.inject.Inject class NetworkTimelineViewModel @Inject constructor( private val repository: NetworkTimelineRepository, timelineCases: TimelineCases, - api: MastodonApi, eventHub: EventHub, + filtersRepository: FiltersRepository, accountManager: AccountManager, sharedPreferences: SharedPreferences, filterModel: FilterModel -) : TimelineViewModel(timelineCases, api, eventHub, accountManager, sharedPreferences, filterModel) { +) : TimelineViewModel( + timelineCases, + eventHub, + filtersRepository, + accountManager, + sharedPreferences, + filterModel +) { private var currentSource: NetworkTimelinePagingSource? = null @@ -77,9 +84,7 @@ class NetworkTimelineViewModel @Inject constructor( ): Flow> { return repository.getStatusStream(kind = kind, initialKey = initialKey) .map { pagingData -> - pagingData.filter { - shouldFilterStatus(it) != Filter.Action.HIDE - }.map { + pagingData.map { // TODO: The previous code in RemoteMediator checked the states against the // previous version of the status to make sure they were replicated. This will // need to be reimplemented (probably as a map of StatusId -> ViewStates. @@ -89,6 +94,8 @@ class NetworkTimelineViewModel @Inject constructor( isExpanded = alwaysOpenSpoilers, isCollapsed = true ) + }.filter { + shouldFilterStatus(it) != Filter.Action.HIDE } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt index 954eb394d5..c0ab54ada0 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt @@ -21,8 +21,6 @@ import androidx.annotation.StringRes import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.paging.PagingData -import at.connyduck.calladapter.networkresult.fold -import at.connyduck.calladapter.networkresult.getOrElse import com.keylesspalace.tusky.R import com.keylesspalace.tusky.appstore.BlockEvent import com.keylesspalace.tusky.appstore.BookmarkEvent @@ -40,6 +38,8 @@ import com.keylesspalace.tusky.appstore.StatusDeletedEvent import com.keylesspalace.tusky.appstore.StatusEditedEvent import com.keylesspalace.tusky.appstore.UnfollowEvent import com.keylesspalace.tusky.components.preference.PreferencesFragment.ReadingOrder +import com.keylesspalace.tusky.components.timeline.FilterKind +import com.keylesspalace.tusky.components.timeline.FiltersRepository import com.keylesspalace.tusky.components.timeline.TimelineKind import com.keylesspalace.tusky.components.timeline.util.ifExpected import com.keylesspalace.tusky.db.AccountManager @@ -48,12 +48,12 @@ import com.keylesspalace.tusky.entity.FilterV1 import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.network.FilterModel -import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.usecase.TimelineCases import com.keylesspalace.tusky.util.StatusDisplayOptions import com.keylesspalace.tusky.viewdata.StatusViewData import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -66,9 +66,9 @@ import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import retrofit2.HttpException data class UiState( /** The user's preferred reading order */ @@ -203,8 +203,8 @@ sealed class StatusActionSuccess(open val action: StatusAction) : UiSuccess() { /** Errors from fallible view model actions that the UI will need to show */ sealed class UiError( - /** The exception associated with the error */ - open val exception: Exception, + /** The throwable associated with the error */ + open val throwable: Throwable, /** String resource with an error message to show the user */ @StringRes val message: Int, @@ -213,31 +213,35 @@ sealed class UiError( open val action: UiAction? = null ) { data class Bookmark( - override val exception: Exception, + override val throwable: Throwable, override val action: StatusAction.Bookmark - ) : UiError(exception, R.string.ui_error_bookmark, action) + ) : UiError(throwable, R.string.ui_error_bookmark, action) data class Favourite( - override val exception: Exception, + override val throwable: Throwable, override val action: StatusAction.Favourite - ) : UiError(exception, R.string.ui_error_favourite, action) + ) : UiError(throwable, R.string.ui_error_favourite, action) data class Reblog( - override val exception: Exception, + override val throwable: Throwable, override val action: StatusAction.Reblog - ) : UiError(exception, R.string.ui_error_reblog, action) + ) : UiError(throwable, R.string.ui_error_reblog, action) data class VoteInPoll( - override val exception: Exception, + override val throwable: Throwable, override val action: StatusAction.VoteInPoll - ) : UiError(exception, R.string.ui_error_vote, action) + ) : UiError(throwable, R.string.ui_error_vote, action) + + data class GetFilters( + override val throwable: Throwable, + ) : UiError(throwable, R.string.ui_error_filter_v1_load, null) companion object { - fun make(exception: Exception, action: FallibleUiAction) = when (action) { - is StatusAction.Bookmark -> Bookmark(exception, action) - is StatusAction.Favourite -> Favourite(exception, action) - is StatusAction.Reblog -> Reblog(exception, action) - is StatusAction.VoteInPoll -> VoteInPoll(exception, action) + 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) } } } @@ -245,8 +249,8 @@ sealed class UiError( @OptIn(FlowPreview::class) abstract class TimelineViewModel( private val timelineCases: TimelineCases, - private val api: MastodonApi, private val eventHub: EventHub, + private val filtersRepository: FiltersRepository, protected val accountManager: AccountManager, private val sharedPreferences: SharedPreferences, private val filterModel: FilterModel @@ -262,14 +266,21 @@ abstract class TimelineViewModel( private val uiAction = MutableSharedFlow() /** Flow of successful action results */ - // Note: These are a SharedFlow instead of a StateFlow because success or error 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 or - // error message, as it will be confusing to the user. + // Note: Thisis 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() - /** Flow of transient errors for the UI to present */ - val uiError = 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 -> @@ -285,6 +296,12 @@ abstract class TimelineViewModel( private var filterRemoveReblogs = false init { + viewModelScope.launch { + updateFiltersFromPreferences().collectLatest { + Log.d(TAG, "Filters updated") + } + } + // Save the visible status ID // TODO: Implement following https://github.com/tuskyapp/Tusky/pull/3271 viewModelScope.launch { @@ -361,7 +378,7 @@ abstract class TimelineViewModel( } uiSuccess.emit(StatusActionSuccess.from(action)) } catch (e: Exception) { - ifExpected(e) { uiError.emit(UiError.make(e, action)) } + ifExpected(e) { _uiErrorChannel.send(UiError.make(e, action)) } } } } @@ -426,10 +443,17 @@ abstract class TimelineViewModel( viewModelScope.launch { eventHub.events - .collect { event -> handleEvent(event) } + .filterIsInstance() + .filter { FilterPrefs.contains(it.preferenceKey) } + .distinctUntilChanged() + .map { getFilters() } + .onStart { getFilters() } } - reloadFilters() + viewModelScope.launch { + eventHub.events + .collect { event -> handleEvent(event) } + } } abstract fun updatePoll(newPoll: Poll, status: StatusViewData) @@ -461,18 +485,65 @@ abstract class TimelineViewModel( /** Triggered when currently displayed data must be reloaded. */ protected abstract suspend fun invalidate() - protected fun shouldFilterStatus(status: Status?): Filter.Action { - status ?: return Filter.Action.NONE + protected fun shouldFilterStatus(statusViewData: StatusViewData): Filter.Action { + val status = statusViewData.status return if ( (status.inReplyToId != null && filterRemoveReplies) || (status.reblog != null && filterRemoveReblogs) ) { return Filter.Action.HIDE } else { - // TODO: Check why this was saving the filter action choice in the viewdata, and migrate -// statusViewData.filterAction = filterModel.shouldFilterStatus(status.actionableStatus) -// statusViewData.filterAction - filterModel.shouldFilterStatus(status.actionableStatus) + statusViewData.filterAction = filterModel.shouldFilterStatus(status.actionableStatus) + statusViewData.filterAction + } + } + + private val FilterPrefs = setOf( + FilterV1.HOME, + FilterV1.NOTIFICATIONS, + FilterV1.THREAD, + FilterV1.PUBLIC, + FilterV1.ACCOUNT + ) + + /** Updates the current set of filters if filter-related preferences change */ + // TODO: https://github.com/tuskyapp/Tusky/issues/3546, and update if a v2 filter is + // updated as well. + private fun updateFiltersFromPreferences() = eventHub.events + .filterIsInstance() + .filter { FilterPrefs.contains(it.preferenceKey) } + .filter { filterContextMatchesKind(timelineKind, listOf(it.preferenceKey)) } + .distinctUntilChanged() + .map { getFilters() } + .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. + */ + private fun getFilters() { + viewModelScope.launch { + Log.d(TAG, "getFilters()") + try { + when (val filters = filtersRepository.getFilters()) { + is FilterKind.V1 -> { + filterModel.initWithFilters( + filters.filters.filter { + filterContextMatchesKind(timelineKind, it.context) + } + ) + invalidate() + } + + is FilterKind.V2 -> invalidate() + } + } catch (throwable: Throwable) { + Log.d(TAG, "updateFilter(): Error fetching filters: ${throwable.message}") + _uiErrorChannel.send(UiError.GetFilters(throwable)) + } } } @@ -495,11 +566,6 @@ abstract class TimelineViewModel( fullReload() } } - FilterV1.HOME, FilterV1.NOTIFICATIONS, FilterV1.THREAD, FilterV1.PUBLIC, FilterV1.ACCOUNT -> { - if (filterContextMatchesKind(timelineKind, listOf(key))) { - reloadFilters() - } - } PrefKeys.ALWAYS_SHOW_SENSITIVE_MEDIA -> { // it is ok if only newly loaded statuses are affected, no need to fully refresh alwaysShowSensitiveMedia = @@ -550,37 +616,6 @@ abstract class TimelineViewModel( } } - private fun reloadFilters() { - viewModelScope.launch { - api.getFilters().fold( - { - // After the filters are loaded we need to reload displayed content to apply them. - // It can happen during the usage or at startup, when we get statuses before filters. - invalidate() - }, - { throwable -> - if (throwable is HttpException && throwable.code() == 404) { - // Fallback to client-side filter code - val filters = api.getFiltersV1().getOrElse { - Log.e(TAG, "Failed to fetch filters", it) - return@launch - } - filterModel.initWithFilters( - filters.filter { - filterContextMatchesKind(timelineKind, it.context) - } - ) - // After the filters are loaded we need to reload displayed content to apply them. - // It can happen during the usage or at startup, when we get statuses before filters. - invalidate() - } else { - Log.e(TAG, "Error getting filters", throwable) - } - } - ) - } - } - companion object { private const val TAG = "TimelineViewModel" private const val DEBOUNCE_TIMEOUT_MS = 500L diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 63aea1e1aa..53d63b3af1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -787,6 +787,7 @@ Voting in poll failed: %s Accepting follow request failed: %s Rejecting follow request failed: %s + Loading filters failed: %s Follow request accepted From 4522054e825f2504c16f54395a050313d6a1db4a Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Tue, 18 Apr 2023 21:18:42 +0200 Subject: [PATCH 028/156] Use a channel for errors, update docs --- .../notifications/NotificationsViewModel.kt | 29 ++-- doc/ViewModelInterface.md | 148 ++++++++++++++---- 2 files changed, 134 insertions(+), 43 deletions(-) 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 index 99a01fa73c..4dc402e62d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModel.kt @@ -45,6 +45,7 @@ import com.keylesspalace.tusky.viewdata.NotificationViewData import com.keylesspalace.tusky.viewdata.StatusViewData import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -60,6 +61,7 @@ import kotlinx.coroutines.flow.flatMapLatest 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 @@ -294,14 +296,21 @@ class NotificationsViewModel @Inject constructor( private val uiAction = MutableSharedFlow() /** Flow of successful action results */ - // Note: These are a SharedFlow instead of a StateFlow because success or error 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 or - // error message, as it will be confusing to the user. + // Note: Thisis 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() - /** Flow of transient errors for the UI to present */ - val uiError = 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 -> @@ -380,11 +389,11 @@ class NotificationsViewModel @Inject constructor( if (this.isSuccessful) { repository.invalidate() } else { - uiError.emit(UiError.make(HttpException(this), it)) + _uiErrorChannel.send(UiError.make(HttpException(this), it)) } } } catch (e: Exception) { - ifExpected(e) { uiError.emit(UiError.make(e, it)) } + ifExpected(e) { _uiErrorChannel.send(UiError.make(e, it)) } } } } @@ -403,7 +412,7 @@ class NotificationsViewModel @Inject constructor( } uiSuccess.emit(NotificationActionSuccess.from(action)) } catch (e: Exception) { - ifExpected(e) { uiError.emit(UiError.make(e, action)) } + ifExpected(e) { _uiErrorChannel.send(UiError.make(e, action)) } } } } @@ -439,7 +448,7 @@ class NotificationsViewModel @Inject constructor( } uiSuccess.emit(StatusActionSuccess.from(action)) } catch (e: Exception) { - ifExpected(e) { uiError.emit(UiError.make(e, action)) } + ifExpected(e) { _uiErrorChannel.send(UiError.make(e, action)) } } } } diff --git a/doc/ViewModelInterface.md b/doc/ViewModelInterface.md index 8f308b19b2..2a1ff95eda 100644 --- a/doc/ViewModelInterface.md +++ b/doc/ViewModelInterface.md @@ -16,7 +16,7 @@ After reading this document you should understand: Before reading this document you should: - Understand Kotlin flows -- Read [Guide to app architecture / UI layer](https://developer.android.com/topic/architecture/ui-layer) +- Read [Guide to app architecture / UI layer](https://developer.android.com/topic/architecture/ui-layer) ## Action and UiState flows @@ -43,7 +43,7 @@ something. The view model does **not** tell the fragment to do something. State always flows from right to left. The view model tells the fragment -"Here's the new state, it up to you how to display it." +"Here's the new state, it's up to you how to display it." Not shown on this diagram, but implicit, is these actions are asynchronous, and the view model may be making one or more requests to other components to @@ -108,7 +108,7 @@ fun onViewCreated(...) { } ``` -This is a good start, but it can be me significantly improved. +This is a good start, but it can be improved. ### Model actions with sealed classes @@ -138,7 +138,7 @@ the UI are: > NOTE: The user can also interact with items in the list of the > notifications. -> +> > That is handled a little differently because of how code outside > `NotificationsFragment` is currently written. It will be adjusted at > a later time. @@ -156,7 +156,7 @@ sealed class UiAction { This has multiple benefits: -- The actions the view model can act on are defined in a single place +- The actions the view model can perform are defined in a single place - Each action clearly describes the information it carries with it - Each action is strongly typed; it is impossible to create an action of the wrong type @@ -173,11 +173,11 @@ val actionFlow = MutableSharedFlow() // As before init { // ... - + handleApplyFilter() handleClearNotifications() handleSaveVisibleId() - + // ... } @@ -194,7 +194,7 @@ fun handleClearNotifications() = viewModelScope.launch { actionFlow .filterIsInstance() .distinctUntilChanged() - .collect { action -> + .collect { action -> // Clear notifications, update state } } @@ -236,7 +236,8 @@ val accept: (UiAction) -> Unit = { action -> } ``` -When the Fragment wants to send a `UiAction` to the view model it: +When the Fragment wants to send a `UiAction` to the view model it can call this +function. ```kotlin // In the Fragment @@ -337,7 +338,7 @@ classDiagram StatusAction <|-- Favourite StatusAction <|-- Reblog StatusAction <|-- VoteInPoll - + ``` ### Multiple output flows @@ -378,6 +379,7 @@ to process them. - `UiSuccess` show a brief snackbar without disturbing the rest of the UI - `UiError` show a fixed snackbar with a "Retry" option + (if the operation can be retried) They also have different statefulness requirements, which makes separating them in to different flows a sensible approach. @@ -385,11 +387,44 @@ them in to different flows a sensible approach. `PagingData`, `UiState`, and `StatusDisplayOptions` are stateful -- if the Fragment disconnects from the flow and then reconnects (e.g., because of a configuration change) the Fragment should receive the most recent state of -each of these. +each of these. So they are modeled as a `StateFlow`. + +`UiSuccess` is not stateful. The success messages are transient; if one has +been shown, and there is a subsequent configuration change the user should +not see the success message again. So this is modeled as a `SharedFlow`. + +`UiError` is not stateful, but it must be hot, otherwise error messages can +be lost, so it is implemented as a channel and exposed as a flow. + +It may be easier to explain what happens if you do not do this. + +Suppose `UiError` is a `SharedFlow`. The view model initialises and performs +some fallible operations, such as loading data from a repository that involves +a network request. -`UiSuccess` and `UiError` are not stateful. The success and error messages are -transient; if one has been shown, and there is a subsequent configuration -change the user should not see the success or error message again. +This operation fails, so an error is sent to `uiError`. However, the +fragment or activity has not yet started collecting from that flow. So as a +`SharedFlow` the error is lost and not displayed to the user. + +If `UiError` was implemented as a `StateFlow` this problem would not occur. +However, the flow would need an initial value (an empty `StateFlow` is not +possible) and there would need to be a mechanism to dismiss errors from the +state. + +Instead, the error flow is backed by a private channel and exposed as a flow. +If an error is sent by the view model before the UI has collected from the +flow the error will be persisted. And once the error has been collected it +will not persist in the channel. + +The implementation looks like this: + +```kotlin +private val _uiErrorChannel = Channel() +val uiError = _uiErrorChannel.receiveAsFlow() + +// Later, to send an error +_uiErrorChannel.send(UiError.SomeError(/* ... */)) +``` ### Modelling success and failure for fallible actions @@ -413,12 +448,56 @@ Fragment saying "Here is the action I want to be performed" and the `action` in `UiSuccess` is the View Model saying "Here is the action that was carried out." +Including the original action in the successful response allows the UI to be +updated in response to the success. + Unsurprisingly, this is modelled with a `UiSuccess` class, and per-action subclasses. +This shows typical code for a success class, in this case, bookmarking a +status has succeeded. + +```kotlin +sealed class StatusActionSuccess(open val action: StatusAction) : UiSuccess () { + data class Bookmark(override val action: StatusAction.Bookmark) : + StatusActionSuccess(action) + + // ... other action successes here + + companion object { + fun from (action: StatusAction) = when (action) { + is StatusAction.Bookmark -> Bookmark(action) + // ... other actions here + } + } +} +``` + Failures are modelled similarly, with a `UiError` class. However, details about the error are included, as well as the original action. +```kotlin +sealed class UiError( + open val throwable: Throwable, + @StringRes val message: Int, + open val action: UiAction? = null +) { + data class Bookmark( + override val throwable: Throwable, + override val action: StatusAction.Bookmark + ) : UiError(throwable, R.string.ui_error_bookmark, action) + + // ... other action errors here + + companion object { + fun make(throwable: Throwable, action: FallibleUiAction) = when (action) { + is StatusAction.Bookmark -> Bookmark(throwable, action) + // other actions here + } + } +} +``` + So each fallible action has three associated classes; one for the action, one to represent the action succeeding, and one to represent the action failing. @@ -442,9 +521,9 @@ sealed class StatusAction( sealed class StatusActionSuccess(open val action: StatusAction) : UiSuccess () { data class Bookmark(override val action: StatusAction.Bookmark) : StatusActionSuccess(action) - + // ... other action successes here - + companion object { fun from (action: StatusAction) = when (action) { is StatusAction.Bookmark -> Bookmark(action) @@ -454,22 +533,22 @@ sealed class StatusActionSuccess(open val action: StatusAction) : UiSuccess () { } sealed class UiError( - open val exception: Exception, + open val throwable: Throwable, @StringRes val message: Int, open val action: UiAction? = null ) { data class Bookmark( - override val exception: Exception, - override val action: StatusAction.Bookmark - ) : UiError(exception, R.string.ui_error_bookmark, action) - + override val throwable: Throwable, + override val action: StatusAction.Bookmark + ) : UiError(throwable, R.string.ui_error_bookmark, action) + // ... other action errors here companion object { - fun make(exception: Exception, action: FallibleUiAction) = when (action) { - is StatusAction.Bookmark -> Bookmark(exception, action) + fun make(throwable: Throwable, action: FallibleUiAction) = when (action) { + is StatusAction.Bookmark -> Bookmark(throwable, action) // other actions here - } + } } } ``` @@ -536,10 +615,11 @@ lifecycleScope.launch { ### Supporting "retry" semantics -This approach has an extremely helpful benefit. By including the original -action in the `UiError` response, implementing a "retry" function is as -simple as re-sending the original action (included in the error) back to -the view model. +Including the original action in the `UiError` response means implementing a +"retry" function is as simple as re-sending the original action (included in +the error) back to the view model. + +The previous code can be amended like so: ```kotlin lifecycleScope.launch { @@ -552,7 +632,9 @@ lifecycleScope.launch { getString(error.message), LENGTH_LONG ) - error.action?.let { action -> + // New code here -- add a button to the snackbar to retry + // the operation by resending the action that failed. + error.action?.let { action -> snackbar.setAction("Retry") { viewModel.accept(action) } } snackbar.show() @@ -579,7 +661,7 @@ sequenceDiagram activate ui ui->>ui: collect UiState, update UI deactivate ui - + else Update StatusDisplayOptions? vm->>vm: emit(StatusDisplayOptions(...)) vm-->>ui: StatusDisplayOption(...) @@ -607,9 +689,9 @@ sequenceDiagram deactivate ui activate vm vm->>vm: Perform action, emit response... - deactivate vm + deactivate vm end note over ui,vm: Type of UI change depends on type of object emitted
UiState, StatusDisplayOptions, UiSuccess, UiError - + ui-->>user: Observes changes -``` \ No newline at end of file +``` From 4fd7a7076bde6ac27305bf8d8b56fc88c541bea3 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Wed, 19 Apr 2023 08:32:20 +0200 Subject: [PATCH 029/156] Implement load state adapter for timelines This is identical to the existing adapter for notifications, so rename that one and use it for both. --- .../notifications/NotificationsFragment.kt | 5 +++-- .../tusky/components/timeline/TimelineFragment.kt | 6 +++++- .../TimelineLoadStateAdapter.kt} | 14 +++++++------- .../TimelineLoadStateViewHolder.kt} | 14 +++++++------- ...er_view.xml => item_load_state_footer_view.xml} | 0 5 files changed, 22 insertions(+), 17 deletions(-) rename app/src/main/java/com/keylesspalace/tusky/components/{notifications/NotificationsLoadStateAdapter.kt => timeline/TimelineLoadStateAdapter.kt} (69%) rename app/src/main/java/com/keylesspalace/tusky/components/{notifications/NotificationsLoadStateViewHolder.kt => timeline/TimelineLoadStateViewHolder.kt} (82%) rename app/src/main/res/layout/{item_notifications_load_state_footer_view.xml => item_load_state_footer_view.xml} (100%) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt index 07e10a1ecb..a046826900 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt @@ -49,6 +49,7 @@ import com.google.android.material.color.MaterialColors import com.google.android.material.snackbar.Snackbar import com.keylesspalace.tusky.R import com.keylesspalace.tusky.adapter.StatusBaseViewHolder +import com.keylesspalace.tusky.components.timeline.TimelineLoadStateAdapter import com.keylesspalace.tusky.databinding.FragmentTimelineNotificationsBinding import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.di.ViewModelFactory @@ -196,8 +197,8 @@ class NotificationsFragment : }) binding.recyclerView.adapter = adapter.withLoadStateHeaderAndFooter( - header = NotificationsLoadStateAdapter { adapter.retry() }, - footer = NotificationsLoadStateAdapter { adapter.retry() } + header = TimelineLoadStateAdapter { adapter.retry() }, + footer = TimelineLoadStateAdapter { adapter.retry() } ) binding.buttonClear.setOnClickListener { confirmClearNotifications() } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt index 17b84cc7f7..251df039e3 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt @@ -439,7 +439,11 @@ class TimelineFragment : // CWs are expanded without animation, buttons animate itself, we don't need it basically (binding.recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false - binding.recyclerView.adapter = adapter + + binding.recyclerView.adapter = adapter.withLoadStateHeaderAndFooter( + header = TimelineLoadStateAdapter { adapter.retry() }, + footer = TimelineLoadStateAdapter { adapter.retry() } + ) } override fun onRefresh() { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsLoadStateAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineLoadStateAdapter.kt similarity index 69% rename from app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsLoadStateAdapter.kt rename to app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineLoadStateAdapter.kt index 0a281ccd9c..d7adf23d0c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsLoadStateAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineLoadStateAdapter.kt @@ -15,24 +15,24 @@ * see . */ -package com.keylesspalace.tusky.components.notifications +package com.keylesspalace.tusky.components.timeline import android.view.ViewGroup import androidx.paging.LoadState import androidx.paging.LoadStateAdapter -/** Show load state and retry options when loading notifications */ -class NotificationsLoadStateAdapter( +/** Show load state and retry options when loading timelines */ +class TimelineLoadStateAdapter( private val retry: () -> Unit -) : LoadStateAdapter() { +) : LoadStateAdapter() { override fun onCreateViewHolder( parent: ViewGroup, loadState: LoadState - ): NotificationsLoadStateViewHolder { - return NotificationsLoadStateViewHolder.create(parent, retry) + ): TimelineLoadStateViewHolder { + return TimelineLoadStateViewHolder.create(parent, retry) } - override fun onBindViewHolder(holder: NotificationsLoadStateViewHolder, loadState: LoadState) { + override fun onBindViewHolder(holder: TimelineLoadStateViewHolder, 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/timeline/TimelineLoadStateViewHolder.kt similarity index 82% rename from app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsLoadStateViewHolder.kt rename to app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineLoadStateViewHolder.kt index f3c006d329..3b7dcc34a2 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsLoadStateViewHolder.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineLoadStateViewHolder.kt @@ -15,7 +15,7 @@ * see . */ -package com.keylesspalace.tusky.components.notifications +package com.keylesspalace.tusky.components.timeline import android.view.LayoutInflater import android.view.ViewGroup @@ -23,7 +23,7 @@ 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 com.keylesspalace.tusky.databinding.ItemLoadStateFooterViewBinding import java.net.SocketTimeoutException /** @@ -36,8 +36,8 @@ import java.net.SocketTimeoutException * * @param retry function to invoke if the user clicks the "retry" button */ -class NotificationsLoadStateViewHolder( - private val binding: ItemNotificationsLoadStateFooterViewBinding, +class TimelineLoadStateViewHolder( + private val binding: ItemLoadStateFooterViewBinding, retry: () -> Unit ) : RecyclerView.ViewHolder(binding.root) { init { @@ -61,13 +61,13 @@ class NotificationsLoadStateViewHolder( } companion object { - fun create(parent: ViewGroup, retry: () -> Unit): NotificationsLoadStateViewHolder { - val binding = ItemNotificationsLoadStateFooterViewBinding.inflate( + fun create(parent: ViewGroup, retry: () -> Unit): TimelineLoadStateViewHolder { + val binding = ItemLoadStateFooterViewBinding.inflate( LayoutInflater.from(parent.context), parent, false ) - return NotificationsLoadStateViewHolder(binding, retry) + return TimelineLoadStateViewHolder(binding, retry) } } } diff --git a/app/src/main/res/layout/item_notifications_load_state_footer_view.xml b/app/src/main/res/layout/item_load_state_footer_view.xml similarity index 100% rename from app/src/main/res/layout/item_notifications_load_state_footer_view.xml rename to app/src/main/res/layout/item_load_state_footer_view.xml From 6c2147edb1d8dcd40aa871546fb14889baf542b9 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Wed, 19 Apr 2023 09:59:11 +0200 Subject: [PATCH 030/156] Re-implement clear and reload for CachedTimelineViewModel --- .../components/timeline/CachedTimelineRepository.kt | 6 ++++++ .../timeline/viewmodel/CachedTimelineViewModel.kt | 11 +---------- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/CachedTimelineRepository.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/CachedTimelineRepository.kt index e7e8391d3c..b4d3ce0f43 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/CachedTimelineRepository.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/CachedTimelineRepository.kt @@ -119,6 +119,12 @@ class CachedTimelineRepository @Inject constructor( appDatabase.timelineDao().clearWarning(accountManager.activeAccount!!.id, statusId) } + /** Remove all statuses and invalidate the pager, for the active account */ + suspend fun clearAndReload() { + appDatabase.timelineDao().removeAll(accountManager.activeAccount!!.id) + factory?.invalidate() + } + companion object { private const val TAG = "CachedTimelineRepository" private const val PAGE_SIZE = 30 diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt index e356f07b62..49c357b0b0 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt @@ -154,21 +154,12 @@ class CachedTimelineViewModel @Inject constructor( } override fun fullReload() { - // TODO: Don't touch the db directly, go through the repository -// viewModelScope.launch { -// val activeAccount = accountManager.activeAccount!! -// db.timelineDao().removeAll(activeAccount.id) -// } viewModelScope.launch { - invalidate() + repository.clearAndReload() } } override suspend fun invalidate() { repository.invalidate() } - - companion object { - private const val MAX_STATUSES_IN_CACHE = 1000 - } } From 4a080fa7b682c5a4ef280e7813953f76fd23be81 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Thu, 20 Apr 2023 16:20:19 +0200 Subject: [PATCH 031/156] Fetch network pages using min_id not since_id --- .../viewmodel/CachedTimelineRemoteMediator.kt | 2 +- .../viewmodel/NetworkTimelinePagingSource.kt | 86 ++++++++++--------- .../tusky/network/MastodonApi.kt | 26 +++--- 3 files changed, 61 insertions(+), 53 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt index 1f912ad893..fd776477b0 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt @@ -57,7 +57,7 @@ class CachedTimelineRemoteMediator( return try { val response = when (loadType) { LoadType.REFRESH -> { - api.homeTimeline(minId = null, limit = state.config.pageSize) + api.homeTimeline(limit = state.config.pageSize) } LoadType.APPEND -> { val bottomId = timelineDao.getBottomId(activeAccount.id) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelinePagingSource.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelinePagingSource.kt index 4b06da5d4f..212788c854 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelinePagingSource.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelinePagingSource.kt @@ -47,17 +47,8 @@ class NetworkTimelinePagingSource @Inject constructor( try { val response = when (params) { - is LoadParams.Refresh -> { - getInitialPage(params) - } - is LoadParams.Append -> fetchStatusesForKind( - maxId = params.key, - limit = params.loadSize - ) - is LoadParams.Prepend -> fetchStatusesForKind( - minId = params.key, - limit = params.loadSize - ) + is LoadParams.Refresh -> getInitialPage(params) + else -> fetchStatusPageByKind(params) } if (!response.isSuccessful) { @@ -76,51 +67,52 @@ class NetworkTimelinePagingSource @Inject constructor( } @Throws(IOException::class, HttpException::class) - suspend fun fetchStatusesForKind( - maxId: String? = null, - minId: String? = null, - limit: Int - ): Response> { - // TODO: These probably shouldn't be `sinceId` but `minId` in the API calls + private suspend fun fetchStatusPageByKind(params: LoadParams): Response> { + val (maxId, minId) = when (params) { + is LoadParams.Refresh -> Pair(null, null) + is LoadParams.Append -> Pair(params.key, null) + is LoadParams.Prepend -> Pair(null, params.key) + } + return when (kind) { - is TimelineKind.Home -> api.homeTimeline(maxId = maxId, sinceId = minId, limit = limit) - is TimelineKind.PublicFederated -> api.publicTimeline(null, maxId, minId, limit) - is TimelineKind.PublicLocal -> api.publicTimeline(true, maxId, minId, limit) + TimelineKind.Bookmarks -> api.bookmarks(maxId = maxId, minId = minId, limit = params.loadSize) + TimelineKind.Favourites -> api.favourites(maxId = maxId, minId = minId, limit = params.loadSize) + TimelineKind.Home -> api.homeTimeline(maxId = maxId, minId = minId, limit = params.loadSize) + TimelineKind.PublicFederated -> api.publicTimeline(local = false, maxId = maxId, minId = minId, limit = params.loadSize) + TimelineKind.PublicLocal -> api.publicTimeline(local = true, maxId = maxId, minId = minId, limit = params.loadSize) is TimelineKind.Tag -> { val firstHashtag = kind.tags.first() val additionalHashtags = kind.tags.subList(1, kind.tags.size) - api.hashtagTimeline(firstHashtag, additionalHashtags, null, maxId, minId, limit) + api.hashtagTimeline(firstHashtag, additionalHashtags, null, maxId = maxId, minId = minId, limit = params.loadSize) } - is TimelineKind.User.Posts -> api.accountStatuses( - kind.id, - maxId, - minId, - limit, - excludeReplies = true, - onlyMedia = null, - pinned = null - ) is TimelineKind.User.Pinned -> api.accountStatuses( kind.id, - maxId, - minId, - limit, + maxId = maxId, + minId = minId, + limit = params.loadSize, excludeReplies = null, onlyMedia = null, pinned = true ) + is TimelineKind.User.Posts -> api.accountStatuses( + kind.id, + maxId = maxId, + minId = minId, + limit = params.loadSize, + excludeReplies = true, + onlyMedia = null, + pinned = null + ) is TimelineKind.User.Replies -> api.accountStatuses( kind.id, - maxId, - minId, - limit, + maxId = maxId, + minId = minId, + limit = params.loadSize, excludeReplies = null, onlyMedia = null, pinned = null ) - is TimelineKind.Favourites -> api.favourites(maxId, minId, limit) - is TimelineKind.Bookmarks -> api.bookmarks(maxId, minId, limit) - is TimelineKind.UserList -> api.listTimeline(kind.id, maxId, minId, limit) + is TimelineKind.UserList -> api.listTimeline(kind.id, maxId = maxId, minId = minId, limit = params.loadSize) } } @@ -141,7 +133,7 @@ class NetworkTimelinePagingSource @Inject constructor( // NetworkResult, notifications returns Response private suspend fun getInitialPage(params: LoadParams): Response> = coroutineScope { // If the key is null this is straightforward, just return the most recent page - val key = params.key ?: return@coroutineScope fetchStatusesForKind(limit = params.loadSize) + val key = params.key ?: return@coroutineScope fetchStatusPageByKind(params) // 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. @@ -158,7 +150,12 @@ class NetworkTimelinePagingSource @Inject constructor( // Make both requests, and wait for the first to complete. val deferredStatus = async { api.status(statusId = key) } val deferredStatusPage = async { - fetchStatusesForKind(maxId = key, limit = params.loadSize) + fetchStatusPageByKind(LoadParams.Append( + key = key, + loadSize = params.loadSize, + placeholdersEnabled = params.placeholdersEnabled + )) + //fetchStatusesForKind(maxId = key, limit = params.loadSize) } deferredStatus.await().getOrNull()?.let { @@ -192,7 +189,12 @@ class NetworkTimelinePagingSource @Inject constructor( // There were no statuses older than the user's desired status. Return the page // of statuses immediately newer than their desired status. - return@coroutineScope fetchStatusesForKind(minId = key, limit = params.loadSize) + //return@coroutineScope fetchStatusesForKind(minId = key, limit = params.loadSize) + return@coroutineScope fetchStatusPageByKind(LoadParams.Prepend( + key = key, + loadSize = params.loadSize, + placeholdersEnabled = params.placeholdersEnabled + )) } private fun getPageLinks(linkHeader: String?): Links { diff --git a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt index ae74ceecce..55416b676d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt +++ b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt @@ -106,6 +106,7 @@ interface MastodonApi { @Query("local") local: Boolean? = null, @Query("max_id") maxId: String? = null, @Query("since_id") sinceId: String? = null, + @Query("min_id") minId: String? = null, @Query("limit") limit: Int? = null ): Response> @@ -114,17 +115,19 @@ interface MastodonApi { @Path("hashtag") hashtag: String, @Query("any[]") any: List?, @Query("local") local: Boolean?, - @Query("max_id") maxId: String?, - @Query("since_id") sinceId: String?, - @Query("limit") limit: Int? + @Query("max_id") maxId: String? = null, + @Query("since_id") sinceId: String? = null, + @Query("min_id") minId: String? = null, + @Query("limit") limit: Int? = null, ): Response> @GET("api/v1/timelines/list/{listId}") suspend fun listTimeline( @Path("listId") listId: String, - @Query("max_id") maxId: String?, - @Query("since_id") sinceId: String?, - @Query("limit") limit: Int? + @Query("max_id") maxId: String? = null, + @Query("since_id") sinceId: String? = null, + @Query("min_id") minId: String? = null, + @Query("limit") limit: Int? = null, ): Response> @GET("api/v1/notifications") @@ -362,6 +365,7 @@ interface MastodonApi { @Path("id") accountId: String, @Query("max_id") maxId: String? = null, @Query("since_id") sinceId: String? = null, + @Query("min_id") minId: String? = null, @Query("limit") limit: Int? = null, @Query("exclude_replies") excludeReplies: Boolean? = null, @Query("only_media") onlyMedia: Boolean? = null, @@ -461,15 +465,17 @@ interface MastodonApi { @GET("api/v1/favourites") suspend fun favourites( - @Query("max_id") maxId: String?, - @Query("since_id") sinceId: String?, + @Query("max_id") maxId: String? = null, + @Query("since_id") sinceId: String? = null, + @Query("min_id") minId: String? = null, @Query("limit") limit: Int? ): Response> @GET("api/v1/bookmarks") suspend fun bookmarks( - @Query("max_id") maxId: String?, - @Query("since_id") sinceId: String?, + @Query("max_id") maxId: String? = null, + @Query("since_id") sinceId: String? = null, + @Query("min_id") minId: String? = null, @Query("limit") limit: Int? ): Response> From 6f9291eb8143f0b53b3b8ce25a7169f79f332caf Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Thu, 20 Apr 2023 16:22:17 +0200 Subject: [PATCH 032/156] Additional logging --- .../components/notifications/NotificationsViewModel.kt | 1 + .../timeline/viewmodel/CachedTimelineViewModel.kt | 6 ++++++ .../timeline/viewmodel/NetworkTimelinePagingSource.kt | 2 ++ .../timeline/viewmodel/NetworkTimelineViewModel.kt | 6 ++++++ 4 files changed, 15 insertions(+) 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 index 4dc402e62d..560a2ef7e0 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModel.kt @@ -495,6 +495,7 @@ class NotificationsViewModel @Inject constructor( filters: Set, initialKey: String? = null ): Flow> { + Log.d(TAG, "getNotifications: $initialKey") return repository.getNotificationsStream(filter = filters, initialKey = initialKey) .map { pagingData -> pagingData.map { notification -> diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt index 49c357b0b0..e6aa959201 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt @@ -16,6 +16,7 @@ package com.keylesspalace.tusky.components.timeline.viewmodel import android.content.SharedPreferences +import android.util.Log import androidx.lifecycle.viewModelScope import androidx.paging.PagingData import androidx.paging.cachedIn @@ -81,6 +82,7 @@ class CachedTimelineViewModel @Inject constructor( kind: TimelineKind, initialKey: String? = null ): Flow> { + Log.d(TAG, "getStatuses: kind: $kind, initialKey: $initialKey") return repository.getStatusStream(kind = kind, initialKey = initialKey) .map { pagingData -> pagingData @@ -162,4 +164,8 @@ class CachedTimelineViewModel @Inject constructor( override suspend fun invalidate() { repository.invalidate() } + + companion object { + private const val TAG = "CachedTimelineViewModel" + } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelinePagingSource.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelinePagingSource.kt index 212788c854..079cd1627f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelinePagingSource.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelinePagingSource.kt @@ -70,7 +70,9 @@ class NetworkTimelinePagingSource @Inject constructor( private suspend fun fetchStatusPageByKind(params: LoadParams): Response> { val (maxId, minId) = when (params) { is LoadParams.Refresh -> Pair(null, null) + // When appending fetch a page of statuses that are immediately *older* than the key is LoadParams.Append -> Pair(params.key, null) + // When prepending fetch a page of statuses that are immediately *newer* than the key is LoadParams.Prepend -> Pair(null, params.key) } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt index fd5a23e10a..ac13385a94 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt @@ -16,6 +16,7 @@ package com.keylesspalace.tusky.components.timeline.viewmodel import android.content.SharedPreferences +import android.util.Log import androidx.paging.PagingData import androidx.paging.filter import androidx.paging.map @@ -82,6 +83,7 @@ class NetworkTimelineViewModel @Inject constructor( kind: TimelineKind, initialKey: String? = null ): Flow> { + Log.d(TAG, "getStatuses: kind: $kind, initialKey: $initialKey") return repository.getStatusStream(kind = kind, initialKey = initialKey) .map { pagingData -> pagingData.map { @@ -223,4 +225,8 @@ class NetworkTimelineViewModel @Inject constructor( // statusData[position] = updater(status) // currentSource?.invalidate() // } + + companion object { + private const val TAG = "NetworkTimelineViewModel" + } } From 1cf6781d8bfd056ef70ee17aea8e0fa0cc5275b7 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Thu, 20 Apr 2023 19:40:07 +0200 Subject: [PATCH 033/156] Cache statuses from the network in the viewmodel --- .../components/timeline/viewmodel/NetworkTimelineViewModel.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt index ac13385a94..aa08f79e9e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt @@ -17,7 +17,9 @@ package com.keylesspalace.tusky.components.timeline.viewmodel import android.content.SharedPreferences import android.util.Log +import androidx.lifecycle.viewModelScope import androidx.paging.PagingData +import androidx.paging.cachedIn import androidx.paging.filter import androidx.paging.map import com.keylesspalace.tusky.appstore.BookmarkEvent @@ -75,7 +77,7 @@ class NetworkTimelineViewModel @Inject constructor( override fun init(timelineKind: TimelineKind) { super.init(timelineKind) - statuses = getStatuses(timelineKind) + statuses = getStatuses(timelineKind).cachedIn(viewModelScope) } /** @return Flow of statuses that make up the timeline of [kind] */ From 285d3a2eb3a09fbb78446e87d348828a1f1f67b2 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Thu, 20 Apr 2023 19:49:11 +0200 Subject: [PATCH 034/156] Remove "flowOn" comment Retrofit and Room both run suspend functions on the appropriate dispatcher already --- .../components/timeline/viewmodel/CachedTimelineViewModel.kt | 4 ---- 1 file changed, 4 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt index e6aa959201..6523ad045b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt @@ -89,10 +89,6 @@ class CachedTimelineViewModel @Inject constructor( .map { it.toViewData(gson) } .filter { shouldFilterStatus(it) != Filter.Action.HIDE } } - - // TODO: - // - Does the above need a .flowOn(Dispatches.Default) - // - Ditto for the same code in NetworkTimelineViewModel (check NotificationsViewModel) } override fun updatePoll(newPoll: Poll, status: StatusViewData) { From bf19e7ff591f0afc15a4121fb1ce8f4f7a463d3a Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Fri, 21 Apr 2023 11:17:16 +0200 Subject: [PATCH 035/156] Additional documentation for StatusViewData fields --- .../tusky/viewdata/StatusViewData.kt | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.kt b/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.kt index b77c61e01d..3156f8347b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.kt +++ b/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.kt @@ -27,13 +27,20 @@ import com.keylesspalace.tusky.util.shouldTrimStatus */ data class StatusViewData( var status: Status, + /** + * If the status includes a non-empty content warning ([spoilerText]), specifies whether + * just the content warning is showing (false), or the whole status content is showing (true). + * + * Ignored if there is no content warning. + */ val isExpanded: Boolean, val isShowingContent: Boolean, + /** - * Specifies whether the content of this post is currently limited in visibility to the first + * Specifies whether the content of this status is currently limited in visibility to the first * 500 characters or not. * - * @return Whether the post is collapsed or fully expanded. + * @return Whether the status is collapsed or fully expanded. */ val isCollapsed: Boolean, val isDetailed: Boolean = false @@ -43,14 +50,16 @@ data class StatusViewData( get() = status.id /** - * Specifies whether the content of this post is long enough to be automatically + * Specifies whether the content of this status is long enough to be automatically * collapsed or if it should show all content regardless. * - * @return Whether the post is collapsible or never collapsed. + * @return Whether the status is collapsible or never collapsed. */ val isCollapsible: Boolean val content: Spanned + + /** The content warning, may be the empty string */ val spoilerText: String val username: String From 52ea238a9572bedab8aa785d9475da7978f81260 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Sat, 22 Apr 2023 18:56:34 +0200 Subject: [PATCH 036/156] Fix typo --- .../tusky/components/notifications/NotificationsViewModel.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 560a2ef7e0..5af5b1181b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModel.kt @@ -296,7 +296,7 @@ class NotificationsViewModel @Inject constructor( private val uiAction = MutableSharedFlow() /** Flow of successful action results */ - // Note: Thisis a SharedFlow instead of a StateFlow because success state does not need to be + // 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. From 7146e4d61c306806d7049dca1786bdbf241e54ba Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Sat, 22 Apr 2023 18:56:13 +0200 Subject: [PATCH 037/156] Initial in-memory cache for timeline data --- .../NotificationsPagingSource.kt | 19 +- .../timeline/CachedTimelineRepository.kt | 1 + .../timeline/NetworkTimelineRepository.kt | 40 +++- .../components/timeline/TimelineFragment.kt | 41 ++-- .../viewmodel/NetworkTimelinePagingSource.kt | 213 ++++-------------- .../NetworkTimelineRemoteMediator.kt | 173 ++++++++++++++ .../viewmodel/NetworkTimelineViewModel.kt | 41 ++-- .../tusky/network/MastodonApi.kt | 18 ++ .../NetworkTimelinePagingSourceTest.kt | 6 +- .../NetworkTimelineRemoteMediatorTest.kt | 64 +++++- 10 files changed, 372 insertions(+), 244 deletions(-) create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt 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 index 5f7eafb0ef..79e488d298 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsPagingSource.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsPagingSource.kt @@ -22,17 +22,14 @@ import androidx.paging.PagingSource import androidx.paging.PagingState import com.google.gson.Gson import com.keylesspalace.tusky.entity.Notification +import com.keylesspalace.tusky.network.Links 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?) - /** [PagingSource] for Mastodon Notifications, identified by the Notification ID */ class NotificationsPagingSource @Inject constructor( private val mastodonApi: MastodonApi, @@ -79,7 +76,7 @@ class NotificationsPagingSource @Inject constructor( return LoadResult.Error(Throwable("HTTP $code: $msg")) } - val links = getPageLinks(response.headers()["link"]) + val links = Links.from(response.headers()["link"]) return LoadResult.Page( data = response.body()!!, nextKey = links.next, @@ -177,18 +174,6 @@ class NotificationsPagingSource @Inject constructor( ) } - private fun getPageLinks(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" - ) - ) - } - override fun getRefreshKey(state: PagingState): String? { return state.anchorPosition?.let { anchorPosition -> val anchorPage = state.closestPageToPosition(anchorPosition) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/CachedTimelineRepository.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/CachedTimelineRepository.kt index b4d3ce0f43..e5fb571219 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/CachedTimelineRepository.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/CachedTimelineRepository.kt @@ -77,6 +77,7 @@ class CachedTimelineRepository @Inject constructor( ).flow } + /** Invalidate the active paging source, see [PagingSource.invalidate] */ suspend fun invalidate() { // Invalidating when no statuses have been loaded can cause empty timelines because it // cancels the network load. diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRepository.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRepository.kt index fef2d685c6..629e14b31d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRepository.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRepository.kt @@ -18,22 +18,48 @@ package com.keylesspalace.tusky.components.timeline import android.util.Log +import androidx.paging.ExperimentalPagingApi import androidx.paging.InvalidatingPagingSourceFactory import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.PagingData +import androidx.paging.PagingSource import com.keylesspalace.tusky.components.timeline.viewmodel.NetworkTimelinePagingSource +import com.keylesspalace.tusky.components.timeline.viewmodel.NetworkTimelineRemoteMediator +import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.network.MastodonApi import kotlinx.coroutines.flow.Flow +import java.util.TreeMap import javax.inject.Inject +/** Timeline repository where the timeline information is backed by an in-memory cache. */ class NetworkTimelineRepository @Inject constructor( - private val mastodonApi: MastodonApi -) { + private val mastodonApi: MastodonApi, + // TODO: This needs to be recreated if the active account changes + private val accountManager: AccountManager, + ) { + + /** + * Pages of statuses. + * + * Each page is keyed by the ID of the first status in that page, and stores the tokens + * use as `max_id` and `min_id` parameters in API calls to fetch pages before/after this + * one. + * + * In Pager3 parlance, an "append" operation is fetching a chronologically *older* page of + * statuses, a "prepend" operation is fetching a chronologically *newer* page of statuses. + */ + // Storing the next/prev tokens in this structure is important, as you can't derive them from + // status IDs (e.g., the next/prev keys returned by the "favourites" API call *do not match* + // status IDs elsewhere). The tokens are discovered by the RemoteMediator but are used by the + // PagingSource, so they need to be available somewhere both components can access them. + private val pages = TreeMap>() + private var factory: InvalidatingPagingSourceFactory? = null /** @return flow of Mastodon [Status], loaded in [pageSize] increments */ + @OptIn(ExperimentalPagingApi::class) fun getStatusStream( kind: TimelineKind, pageSize: Int = PAGE_SIZE, @@ -42,12 +68,18 @@ class NetworkTimelineRepository @Inject constructor( Log.d(TAG, "getStatusStream(): key: $initialKey") factory = InvalidatingPagingSourceFactory { - NetworkTimelinePagingSource(mastodonApi, kind) + NetworkTimelinePagingSource(pages) } return Pager( config = PagingConfig(pageSize = pageSize), - initialKey = initialKey, + remoteMediator = NetworkTimelineRemoteMediator( + mastodonApi, + accountManager, + factory!!, + pages, + kind + ), pagingSourceFactory = factory!! ).flow } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt index 251df039e3..e08b07103c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt @@ -36,7 +36,6 @@ import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.SimpleItemAnimator import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener -import at.connyduck.sparkbutton.helpers.Utils import com.google.android.material.color.MaterialColors import com.google.android.material.snackbar.Snackbar import com.keylesspalace.tusky.BaseActivity @@ -142,21 +141,29 @@ class TimelineFragment : setupSwipeRefreshLayout() setupRecyclerView() - adapter.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() { - override fun onItemRangeInserted(positionStart: Int, itemCount: Int) { - if (positionStart == 0 && adapter.itemCount != itemCount) { - binding.recyclerView.post { - if (getView() != null) { - if (isSwipeToRefreshEnabled) { - binding.recyclerView.scrollBy(0, Utils.dpToPx(requireContext(), -30)) - } else { - binding.recyclerView.scrollToPosition(0) - } - } - } - } - } - }) + // TODO: This is the wrong place to do this, since it bumps the list down by 30px + // every time a PREPEND call completes. + // + // The right thing to do is to use the loadstate flow (already used later in the code) + // and if the refresh has finished, and not at position 0, then bump the list down by + // the 30px. + // + // Temporarily disable while investigating other list behaviour. +// adapter.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() { +// override fun onItemRangeInserted(positionStart: Int, itemCount: Int) { +// if (positionStart == 0 && adapter.itemCount != itemCount) { +// binding.recyclerView.post { +// if (getView() != null) { +// if (isSwipeToRefreshEnabled) { +// binding.recyclerView.scrollBy(0, Utils.dpToPx(requireContext(), -30)) +// } else { +// binding.recyclerView.scrollToPosition(0) +// } +// } +// } +// } +// } +// }) if (actionButtonPresent()) { binding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { @@ -615,7 +622,7 @@ class TimelineFragment : // Save the ID of the first visible status in the list val position = layoutManager.findLastVisibleItemPosition() if (position != RecyclerView.NO_POSITION) { - adapter.snapshot()[position]?.id?.let { statusId -> + adapter.snapshot().getOrNull(position)?.id?.let { statusId -> viewModel.accept(InfallibleUiAction.SaveVisibleId(visibleId = statusId)) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelinePagingSource.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelinePagingSource.kt index 079cd1627f..e88b0576c6 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelinePagingSource.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelinePagingSource.kt @@ -18,204 +18,67 @@ package com.keylesspalace.tusky.components.timeline.viewmodel import android.util.Log import androidx.paging.PagingSource import androidx.paging.PagingState -import com.keylesspalace.tusky.components.timeline.TimelineKind import com.keylesspalace.tusky.entity.Status -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.HttpException -import retrofit2.Response -import java.io.IOException +import java.util.TreeMap import javax.inject.Inject -// TODO(https://github.com/tuskyapp/Tusky/issues/3432) -// This is extremely similar to NotificationsPagingSource. Merging the code, or making it generic -// over the type of data returned (Notification, Status, etc) is probably warranted. - -/** Models next/prev links from the "Links" header in an API response */ -data class Links(val next: String?, val prev: String?) - /** [PagingSource] for Mastodon Status, identified by the Status ID */ class NetworkTimelinePagingSource @Inject constructor( - private val api: MastodonApi, - private val kind: TimelineKind + private val pages: TreeMap> ) : 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) - else -> fetchStatusPageByKind(params) + synchronized(pages) { + Log.d(TAG, "Pages state:") + if (pages.isEmpty()) { + Log.d(TAG, " **empty**") + } else { + pages.onEachIndexed { i, entry -> + Log.d(TAG, " $i: k: ${entry.key}, prev: ${entry.value.prevKey}, next: ${entry.value.nextKey}") + } } - - if (!response.isSuccessful) { - return LoadResult.Error(Throwable(response.errorBody()?.string())) - } - - val links = getPageLinks(response.headers()["link"]) - return LoadResult.Page( - data = response.body()!!, - nextKey = links.next, - prevKey = links.prev - ) - } catch (e: Exception) { - return LoadResult.Error(e) - } - } - - @Throws(IOException::class, HttpException::class) - private suspend fun fetchStatusPageByKind(params: LoadParams): Response> { - val (maxId, minId) = when (params) { - is LoadParams.Refresh -> Pair(null, null) - // When appending fetch a page of statuses that are immediately *older* than the key - is LoadParams.Append -> Pair(params.key, null) - // When prepending fetch a page of statuses that are immediately *newer* than the key - is LoadParams.Prepend -> Pair(null, params.key) } - return when (kind) { - TimelineKind.Bookmarks -> api.bookmarks(maxId = maxId, minId = minId, limit = params.loadSize) - TimelineKind.Favourites -> api.favourites(maxId = maxId, minId = minId, limit = params.loadSize) - TimelineKind.Home -> api.homeTimeline(maxId = maxId, minId = minId, limit = params.loadSize) - TimelineKind.PublicFederated -> api.publicTimeline(local = false, maxId = maxId, minId = minId, limit = params.loadSize) - TimelineKind.PublicLocal -> api.publicTimeline(local = true, maxId = maxId, minId = minId, limit = params.loadSize) - is TimelineKind.Tag -> { - val firstHashtag = kind.tags.first() - val additionalHashtags = kind.tags.subList(1, kind.tags.size) - api.hashtagTimeline(firstHashtag, additionalHashtags, null, maxId = maxId, minId = minId, limit = params.loadSize) + val page = synchronized(pages) { + if (pages.isEmpty()) { + return@synchronized null } - is TimelineKind.User.Pinned -> api.accountStatuses( - kind.id, - maxId = maxId, - minId = minId, - limit = params.loadSize, - excludeReplies = null, - onlyMedia = null, - pinned = true - ) - is TimelineKind.User.Posts -> api.accountStatuses( - kind.id, - maxId = maxId, - minId = minId, - limit = params.loadSize, - excludeReplies = true, - onlyMedia = null, - pinned = null - ) - is TimelineKind.User.Replies -> api.accountStatuses( - kind.id, - maxId = maxId, - minId = minId, - limit = params.loadSize, - excludeReplies = null, - onlyMedia = null, - pinned = null - ) - is TimelineKind.UserList -> api.listTimeline(kind.id, maxId = maxId, minId = minId, limit = params.loadSize) - } - } - /** - * Fetch the initial page, using params.key as the ID of the initial item to fetch. - * - * - If there is no key the most recent page 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 there is no page of notifications immediately before then the page immediately after - * is returned - */ - // TODO: This is not directly usable from NotificationsPagingSource, as NotificationsPagingSource - // has to handle filtering results as well. - // - // In addition, the notification and status API calls return different types (statuses return - // NetworkResult, notifications returns Response - private suspend fun getInitialPage(params: LoadParams): Response> = coroutineScope { - // If the key is null this is straightforward, just return the most recent page - val key = params.key ?: return@coroutineScope fetchStatusPageByKind(params) - - // 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 status itself, and the page of statuses immediately before - // it. This is so that a full page of results can be returned. Returning just the - // single status 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 deferredStatus = async { api.status(statusId = key) } - val deferredStatusPage = async { - fetchStatusPageByKind(LoadParams.Append( - key = key, - loadSize = params.loadSize, - placeholdersEnabled = params.placeholdersEnabled - )) - //fetchStatusesForKind(maxId = key, limit = params.loadSize) - } - - deferredStatus.await().getOrNull()?.let { - val statuses = mutableListOf(it) - - // The status() call returns a NetworkResult, the others return a Response (!) - // so convert between them. - deferredStatusPage.await().body()?.let { - statuses.addAll(it) + return@synchronized when (params) { + is LoadParams.Refresh -> { + // If no key then return the latest page. Otherwise return the request page. + if (params.key == null) { + pages.lastEntry()?.value + } else { + pages[params.key] + } + } + // Load the page immediately after the key + is LoadParams.Append -> pages.lowerEntry(params.key)?.value + // Load the page immediately before the key + is LoadParams.Prepend -> pages.higherEntry(params.key)?.value } - - // "statuses" now contains at least one status 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 status in the list. - val maxId = statuses.last().id - val headers = Headers.Builder() - .add("link: ; rel=\"next\", ; rel=\"prev\"") - .build() - - return@coroutineScope Response.success(statuses, headers) } - // The user's last read status was missing or is filtered. Use the page of - // statuses chronologically older than their desired status. - deferredStatusPage.await().apply { - if (this.isSuccessful) return@coroutineScope this + if (page == null) { + Log.d(TAG, " Returning empty page") + } else { + Log.d(TAG, " Returning full page:") + Log.d(TAG, " k: ${page.data.first().id}, prev: ${page.prevKey}, next: ${page.nextKey}") } - - // There were no statuses older than the user's desired status. Return the page - // of statuses immediately newer than their desired status. - //return@coroutineScope fetchStatusesForKind(minId = key, limit = params.loadSize) - return@coroutineScope fetchStatusPageByKind(LoadParams.Prepend( - key = key, - loadSize = params.loadSize, - placeholdersEnabled = params.placeholdersEnabled - )) - } - - private fun getPageLinks(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" - ) - ) + return LoadResult.Page(page?.data ?: emptyList(), nextKey = page?.nextKey, prevKey = page?.prevKey) } override fun getRefreshKey(state: PagingState): String? { - return state.anchorPosition?.let { anchorPosition -> + Log.d(TAG, "getRefreshKey(): anchorPosition: ${state.anchorPosition}") + val refreshKey = state.anchorPosition?.let { anchorPosition -> val anchorPage = state.closestPageToPosition(anchorPosition) - anchorPage?.prevKey ?: anchorPage?.nextKey + anchorPage?.data?.first()?.id } + Log.d(TAG, " refreshKey = $refreshKey") + return refreshKey } companion object { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt new file mode 100644 index 0000000000..deb93b857e --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt @@ -0,0 +1,173 @@ +/* + * 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.timeline.viewmodel + +import android.util.Log +import androidx.paging.ExperimentalPagingApi +import androidx.paging.InvalidatingPagingSourceFactory +import androidx.paging.LoadType +import androidx.paging.PagingSource +import androidx.paging.PagingState +import androidx.paging.RemoteMediator +import com.keylesspalace.tusky.components.timeline.TimelineKind +import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.network.Links +import com.keylesspalace.tusky.network.MastodonApi +import retrofit2.HttpException +import retrofit2.Response +import java.io.IOException +import java.util.TreeMap + +/** Remote mediator for accessing timelines that are not backed by the database. */ +@OptIn(ExperimentalPagingApi::class) +class NetworkTimelineRemoteMediator( + private val api: MastodonApi, + accountManager: AccountManager, + private val factory: InvalidatingPagingSourceFactory, + private val pages: TreeMap>, + private val timelineKind: TimelineKind +) : RemoteMediator() { + + private val activeAccount = accountManager.activeAccount!! + + override suspend fun load( + loadType: LoadType, + state: PagingState + ): MediatorResult { + if (!activeAccount.isLoggedIn()) { + return MediatorResult.Success(endOfPaginationReached = true) + } + + Log.d(TAG, "load(), LoadType = $loadType") + + return try { + val key = when (loadType) { + LoadType.REFRESH -> + // Refresh from the page immediately after the newest page + pages.lastEntry()?.value?.prevKey + LoadType.APPEND -> { + Log.d(TAG, " firstEntry: ${pages.firstEntry()?.key}") + pages.firstEntry()?.value?.nextKey + ?: return MediatorResult.Success(endOfPaginationReached = true) + } + LoadType.PREPEND -> { + Log.d(TAG, " lastEntry: ${pages.lastEntry()?.key}") + pages.lastEntry()?.value?.prevKey + ?: return MediatorResult.Success(endOfPaginationReached = true) + } + } + + Log.d(TAG, " from key: $key") + val response = fetchStatusPageByKind(loadType, key, state.config.initialLoadSize) + val statuses = response.body() + if (!response.isSuccessful || statuses == null) { + return MediatorResult.Error(HttpException(response)) + } + + Log.d(TAG, " link: " + response.headers()["link"]) + val links = Links.from(response.headers()["link"]) + + Log.d(TAG, " ${statuses.size} - # statuses loaded") + + if (statuses.isEmpty()) { + return MediatorResult.Success(endOfPaginationReached = true) + } + + synchronized(pages) { + Log.d(TAG, "Inserting new page:") + Log.d(TAG, " k: ${statuses.first().id}, prev: ${links.prev}, next: ${links.next}") + + pages[statuses.first().id] = PagingSource.LoadResult.Page( + data = statuses, + nextKey = links.next, + prevKey = links.prev + ) + Log.d(TAG, " Page $loadType complete for $timelineKind, now got ${pages.size} pages") + } + Log.d(TAG, " Invalidating source") + factory.invalidate() + + return MediatorResult.Success(endOfPaginationReached = false) + } catch (e: IOException) { + MediatorResult.Error(e) + } catch (e: HttpException) { + MediatorResult.Error(e) + } + } + + @Throws(IOException::class, HttpException::class) + private suspend fun fetchStatusPageByKind(loadType: LoadType, key: String?, loadSize: Int): Response> { + val (maxId, minId) = when (loadType) { + // When refreshing fetch a page of statuses that are immediately *newer* than the key + // This is so that that user's reading position is not lost. + LoadType.REFRESH -> Pair(null, key) + // When appending fetch a page of statuses that are immediately *older* than the key + LoadType.APPEND -> Pair(key, null) + // When prepending fetch a page of statuses that are immediately *newer* than the key + LoadType.PREPEND -> Pair(null, key) + } + + return when (timelineKind) { + TimelineKind.Bookmarks -> api.bookmarks(maxId = maxId, minId = minId, limit = loadSize) + TimelineKind.Favourites -> api.favourites(maxId = maxId, minId = minId, limit = loadSize) + TimelineKind.Home -> api.homeTimeline(maxId = maxId, minId = minId, limit = loadSize) + TimelineKind.PublicFederated -> api.publicTimeline(local = false, maxId = maxId, minId = minId, limit = loadSize) + TimelineKind.PublicLocal -> api.publicTimeline(local = true, maxId = maxId, minId = minId, limit = loadSize) + is TimelineKind.Tag -> { + val firstHashtag = timelineKind.tags.first() + val additionalHashtags = timelineKind.tags.subList(1, timelineKind.tags.size) + api.hashtagTimeline(firstHashtag, additionalHashtags, null, maxId = maxId, minId = minId, limit = loadSize) + } + is TimelineKind.User.Pinned -> api.accountStatuses( + timelineKind.id, + maxId = maxId, + minId = minId, + limit = loadSize, + excludeReplies = null, + onlyMedia = null, + pinned = true + ) + is TimelineKind.User.Posts -> api.accountStatuses( + timelineKind.id, + maxId = maxId, + minId = minId, + limit = loadSize, + excludeReplies = true, + onlyMedia = null, + pinned = null + ) + is TimelineKind.User.Replies -> api.accountStatuses( + timelineKind.id, + maxId = maxId, + minId = minId, + limit = loadSize, + excludeReplies = null, + onlyMedia = null, + pinned = null + ) + is TimelineKind.UserList -> api.listTimeline( + timelineKind.id, maxId = maxId, minId = minId, limit = loadSize + ) + } + } + + companion object { + private const val TAG = "NetworkTimelineRemoteMediator" + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt index aa08f79e9e..a035b70098 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt @@ -62,16 +62,11 @@ class NetworkTimelineViewModel @Inject constructor( sharedPreferences, filterModel ) { - - private var currentSource: NetworkTimelinePagingSource? = null - val statusData: MutableList = mutableListOf() var nextKey: String? = null - // TODO: This is janky because timelineKind isn't valid until init() is run, and is needed - // to know what timeline to get. Hence the lateinit in here and the need to override init() - // afterwards. + private val modifiedViewData = mutableMapOf() override lateinit var statuses: Flow> @@ -93,7 +88,7 @@ class NetworkTimelineViewModel @Inject constructor( // previous version of the status to make sure they were replicated. This will // need to be reimplemented (probably as a map of StatusId -> ViewStates. // For now, just use the user's preferences. - it.toViewData( + modifiedViewData[it.id] ?: it.toViewData( isShowingContent = alwaysShowSensitiveMedia || !it.actionableStatus.sensitive, isExpanded = alwaysOpenSpoilers, isCollapsed = true @@ -105,48 +100,54 @@ class NetworkTimelineViewModel @Inject constructor( } override fun updatePoll(newPoll: Poll, status: StatusViewData) { - status.copy( + modifiedViewData[status.id] = status.copy( status = status.status.copy(poll = newPoll) - ).update() + ) + repository.invalidate() } override fun changeExpanded(expanded: Boolean, status: StatusViewData) { - status.copy( + modifiedViewData[status.id] = status.copy( isExpanded = expanded - ).update() + ) + repository.invalidate() } override fun changeContentShowing(isShowing: Boolean, status: StatusViewData) { - status.copy( + modifiedViewData[status.id] = status.copy( isShowingContent = isShowing - ).update() + ) + repository.invalidate() } override fun changeContentCollapsed(isCollapsed: Boolean, status: StatusViewData) { - status.copy( + Log.d(TAG, "changeContentCollapsed: $isCollapsed") + Log.d(TAG, " " + status.content) + modifiedViewData[status.id] = status.copy( isCollapsed = isCollapsed - ).update() + ) + repository.invalidate() } override fun removeAllByAccountId(accountId: String) { statusData.removeAll { status -> status.account.id == accountId || status.actionableStatus.account.id == accountId } - currentSource?.invalidate() + repository.invalidate() } override fun removeAllByInstance(instance: String) { statusData.removeAll { status -> getDomain(status.account.url) == instance } - currentSource?.invalidate() + repository.invalidate() } override fun removeStatusWithId(id: String) { statusData.removeAll { status -> status.id == id || status.reblog?.id == id } - currentSource?.invalidate() + repository.invalidate() } override fun handleReblogEvent(reblogEvent: ReblogEvent) { @@ -176,7 +177,7 @@ class NetworkTimelineViewModel @Inject constructor( override fun fullReload() { nextKey = statusData.firstOrNull()?.id statusData.clear() - currentSource?.invalidate() + repository.invalidate() } override fun clearWarning(status: StatusViewData) { @@ -186,7 +187,7 @@ class NetworkTimelineViewModel @Inject constructor( } override suspend fun invalidate() { - currentSource?.invalidate() + repository.invalidate() } private fun StatusViewData.update() { diff --git a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt index 55416b676d..f9f1390241 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt +++ b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt @@ -45,6 +45,7 @@ import com.keylesspalace.tusky.entity.StatusEdit import com.keylesspalace.tusky.entity.StatusSource import com.keylesspalace.tusky.entity.TimelineAccount import com.keylesspalace.tusky.entity.TrendingTag +import com.keylesspalace.tusky.util.HttpHeaderLink import io.reactivex.rxjava3.core.Single import okhttp3.MultipartBody import okhttp3.RequestBody @@ -71,6 +72,23 @@ import retrofit2.http.Query * for documentation of the Mastodon REST API see https://docs.joinmastodon.org/api/ */ +/** 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" + ) + ) + } + } +} + @JvmSuppressWildcards interface MastodonApi { diff --git a/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelinePagingSourceTest.kt b/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelinePagingSourceTest.kt index 33215e6757..7a30c23099 100644 --- a/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelinePagingSourceTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelinePagingSourceTest.kt @@ -24,7 +24,7 @@ class NetworkTimelinePagingSourceTest { @Test fun `should return empty list when params are Append`() { - val pagingSource = NetworkTimelinePagingSource(timelineViewModel) + val pagingSource = NetworkTimelinePagingSource(statusData) val params = PagingSource.LoadParams.Append("132", 20, false) @@ -37,7 +37,7 @@ class NetworkTimelinePagingSourceTest { @Test fun `should return empty list when params are Prepend`() { - val pagingSource = NetworkTimelinePagingSource(timelineViewModel) + val pagingSource = NetworkTimelinePagingSource(statusData) val params = PagingSource.LoadParams.Prepend("132", 20, false) @@ -50,7 +50,7 @@ class NetworkTimelinePagingSourceTest { @Test fun `should return full list when params are Refresh`() { - val pagingSource = NetworkTimelinePagingSource(timelineViewModel) + val pagingSource = NetworkTimelinePagingSource(statusData) val params = PagingSource.LoadParams.Refresh(null, 20, false) diff --git a/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRemoteMediatorTest.kt b/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRemoteMediatorTest.kt index 9722fa5a8e..c8a56fe37b 100644 --- a/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRemoteMediatorTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRemoteMediatorTest.kt @@ -52,7 +52,13 @@ class NetworkTimelineRemoteMediatorTest { onBlocking { fetchStatusesForKind(anyOrNull(), anyOrNull(), anyOrNull()) } doReturn Response.error(500, "".toResponseBody()) } - val remoteMediator = NetworkTimelineRemoteMediator(accountManager, timelineViewModel) + val remoteMediator = NetworkTimelineRemoteMediator( + accountManager, + timelineViewModel, + factory!!, + statusData, + kind + ) val result = runBlocking { remoteMediator.load(LoadType.REFRESH, state()) } @@ -69,7 +75,13 @@ class NetworkTimelineRemoteMediatorTest { onBlocking { fetchStatusesForKind(anyOrNull(), anyOrNull(), anyOrNull()) } doThrow IOException() } - val remoteMediator = NetworkTimelineRemoteMediator(accountManager, timelineViewModel) + val remoteMediator = NetworkTimelineRemoteMediator( + accountManager, + timelineViewModel, + factory!!, + statusData, + kind + ) val result = runBlocking { remoteMediator.load(LoadType.REFRESH, state()) } @@ -98,7 +110,13 @@ class NetworkTimelineRemoteMediatorTest { ) } - val remoteMediator = NetworkTimelineRemoteMediator(accountManager, timelineViewModel) + val remoteMediator = NetworkTimelineRemoteMediator( + accountManager, + timelineViewModel, + factory!!, + statusData, + kind + ) val state = state( listOf( @@ -145,7 +163,13 @@ class NetworkTimelineRemoteMediatorTest { ) } - val remoteMediator = NetworkTimelineRemoteMediator(accountManager, timelineViewModel) + val remoteMediator = NetworkTimelineRemoteMediator( + accountManager, + timelineViewModel, + factory!!, + statusData, + kind + ) val state = state( listOf( @@ -197,7 +221,13 @@ class NetworkTimelineRemoteMediatorTest { ) } - val remoteMediator = NetworkTimelineRemoteMediator(accountManager, timelineViewModel) + val remoteMediator = NetworkTimelineRemoteMediator( + accountManager, + timelineViewModel, + factory!!, + statusData, + kind + ) val state = state( listOf( @@ -250,7 +280,13 @@ class NetworkTimelineRemoteMediatorTest { ) } - val remoteMediator = NetworkTimelineRemoteMediator(accountManager, timelineViewModel) + val remoteMediator = NetworkTimelineRemoteMediator( + accountManager, + timelineViewModel, + factory!!, + statusData, + kind + ) val state = state( listOf( @@ -307,7 +343,13 @@ class NetworkTimelineRemoteMediatorTest { ) } - val remoteMediator = NetworkTimelineRemoteMediator(accountManager, timelineViewModel) + val remoteMediator = NetworkTimelineRemoteMediator( + accountManager, + timelineViewModel, + factory!!, + statusData, + kind + ) val state = state( listOf( @@ -353,7 +395,13 @@ class NetworkTimelineRemoteMediatorTest { on { nextKey } doReturn null } - val remoteMediator = NetworkTimelineRemoteMediator(accountManager, timelineViewModel) + val remoteMediator = NetworkTimelineRemoteMediator( + accountManager, + timelineViewModel, + factory!!, + statusData, + kind + ) val state = state( listOf( From 70d71eb6ce40002eb1fc3f767e7add968a2b93b6 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Sun, 23 Apr 2023 18:26:32 +0200 Subject: [PATCH 038/156] Update network status cache in response to events --- .../timeline/NetworkTimelineRepository.kt | 84 ++++++++++++++- .../viewmodel/NetworkTimelinePagingSource.kt | 3 +- .../NetworkTimelineRemoteMediator.kt | 8 +- .../viewmodel/NetworkTimelineViewModel.kt | 100 ++++++------------ 4 files changed, 121 insertions(+), 74 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRepository.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRepository.kt index 629e14b31d..f704db2324 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRepository.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRepository.kt @@ -29,10 +29,25 @@ import com.keylesspalace.tusky.components.timeline.viewmodel.NetworkTimelineRemo import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.util.getDomain import kotlinx.coroutines.flow.Flow import java.util.TreeMap import javax.inject.Inject +data class Page constructor( + /** Loaded data */ + val data: MutableList, + /** + * [Key] for previous page if more data can be loaded in that direction, `null` + * otherwise. + */ + val prevKey: Key?, + /** + * [Key] for next page if more data can be loaded in that direction, `null` otherwise. + */ + val nextKey: Key?, +) + /** Timeline repository where the timeline information is backed by an in-memory cache. */ class NetworkTimelineRepository @Inject constructor( private val mastodonApi: MastodonApi, @@ -48,13 +63,14 @@ class NetworkTimelineRepository @Inject constructor( * one. * * In Pager3 parlance, an "append" operation is fetching a chronologically *older* page of - * statuses, a "prepend" operation is fetching a chronologically *newer* page of statuses. + * statuses using `nextKey`, a "prepend" operation is fetching a chronologically *newer* + * page of statuses using `prevKey`. */ // Storing the next/prev tokens in this structure is important, as you can't derive them from // status IDs (e.g., the next/prev keys returned by the "favourites" API call *do not match* // status IDs elsewhere). The tokens are discovered by the RemoteMediator but are used by the // PagingSource, so they need to be available somewhere both components can access them. - private val pages = TreeMap>() + private val pages = TreeMap>() private var factory: InvalidatingPagingSourceFactory? = null @@ -89,6 +105,70 @@ class NetworkTimelineRepository @Inject constructor( factory?.invalidate() } + fun removeAllByAccountId(accountId: String) { + synchronized(pages) { + for (page in pages.values) { + page.data.removeAll { status -> + status.account.id == accountId || status.actionableStatus.account.id == accountId + } + } + } + invalidate() + } + + fun removeAllByInstance(instance: String) { + synchronized(pages) { + for (page in pages.values) { + page.data.removeAll { status -> getDomain(status.account.url) == instance } + } + } + invalidate() + } + + fun removeStatusWithId(statusId: String) { + synchronized(pages) { + pages.floorEntry(statusId)?.value?.data?.removeAll { status -> + status.id == statusId || status.reblog?.id == statusId + } + } + invalidate() + } + + fun updateStatusById(statusId: String, updater: (Status) -> Status) { + synchronized(pages) { + pages.floorEntry(statusId)?.value?.let { page -> + val index = page.data.indexOfFirst { it.id == statusId } + if (index != -1) { + page.data[index] = updater(page.data[index]) + } + } + } + invalidate() + } + + fun updateActionableStatusById(statusId: String, updater: (Status) -> Status) { + synchronized(pages) { + pages.floorEntry(statusId)?.value?.let { page -> + val index = page.data.indexOfFirst { it.id == statusId } + if (index != -1) { + val status = page.data[index] + if (status.reblog != null) { + page.data[index] = status.copy(reblog = updater(status.reblog)) + } else { + page.data[index] = updater(status) + } + } + } + } + } + + fun reload() { + synchronized(pages) { + pages.clear() + } + invalidate() + } + companion object { private const val TAG = "NetworkTimelineRepository" private const val PAGE_SIZE = 30 diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelinePagingSource.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelinePagingSource.kt index e88b0576c6..bfb9a3f093 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelinePagingSource.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelinePagingSource.kt @@ -18,13 +18,14 @@ package com.keylesspalace.tusky.components.timeline.viewmodel import android.util.Log import androidx.paging.PagingSource import androidx.paging.PagingState +import com.keylesspalace.tusky.components.timeline.Page import com.keylesspalace.tusky.entity.Status import java.util.TreeMap import javax.inject.Inject /** [PagingSource] for Mastodon Status, identified by the Status ID */ class NetworkTimelinePagingSource @Inject constructor( - private val pages: TreeMap> + private val pages: TreeMap> ) : PagingSource() { override suspend fun load(params: LoadParams): LoadResult { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt index deb93b857e..ead686d811 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt @@ -21,9 +21,9 @@ import android.util.Log import androidx.paging.ExperimentalPagingApi import androidx.paging.InvalidatingPagingSourceFactory import androidx.paging.LoadType -import androidx.paging.PagingSource import androidx.paging.PagingState import androidx.paging.RemoteMediator +import com.keylesspalace.tusky.components.timeline.Page import com.keylesspalace.tusky.components.timeline.TimelineKind import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.entity.Status @@ -40,7 +40,7 @@ class NetworkTimelineRemoteMediator( private val api: MastodonApi, accountManager: AccountManager, private val factory: InvalidatingPagingSourceFactory, - private val pages: TreeMap>, + private val pages: TreeMap>, private val timelineKind: TimelineKind ) : RemoteMediator() { @@ -93,8 +93,8 @@ class NetworkTimelineRemoteMediator( Log.d(TAG, "Inserting new page:") Log.d(TAG, " k: ${statuses.first().id}, prev: ${links.prev}, next: ${links.next}") - pages[statuses.first().id] = PagingSource.LoadResult.Page( - data = statuses, + pages[statuses.first().id] = Page( + data = statuses.toMutableList(), nextKey = links.next, prevKey = links.prev ) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt index a035b70098..7ce198ab6e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt @@ -33,14 +33,13 @@ import com.keylesspalace.tusky.components.timeline.TimelineKind import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.entity.Poll -import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.network.FilterModel import com.keylesspalace.tusky.usecase.TimelineCases -import com.keylesspalace.tusky.util.getDomain import com.keylesspalace.tusky.util.toViewData import com.keylesspalace.tusky.viewdata.StatusViewData import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch import javax.inject.Inject /** @@ -62,10 +61,6 @@ class NetworkTimelineViewModel @Inject constructor( sharedPreferences, filterModel ) { - val statusData: MutableList = mutableListOf() - - var nextKey: String? = null - private val modifiedViewData = mutableMapOf() override lateinit var statuses: Flow> @@ -130,59 +125,69 @@ class NetworkTimelineViewModel @Inject constructor( } override fun removeAllByAccountId(accountId: String) { - statusData.removeAll { status -> - status.account.id == accountId || status.actionableStatus.account.id == accountId + viewModelScope.launch { + repository.removeAllByAccountId(accountId) } - repository.invalidate() } override fun removeAllByInstance(instance: String) { - statusData.removeAll { status -> - getDomain(status.account.url) == instance + viewModelScope.launch { + repository.removeAllByInstance(instance) } - repository.invalidate() } override fun removeStatusWithId(id: String) { - statusData.removeAll { status -> - status.id == id || status.reblog?.id == id + viewModelScope.launch { + repository.removeStatusWithId(id) } - repository.invalidate() } override fun handleReblogEvent(reblogEvent: ReblogEvent) { - updateStatusById(reblogEvent.statusId) { - it.copy(status = it.status.copy(reblogged = reblogEvent.reblog)) + viewModelScope.launch { + repository.updateStatusById(reblogEvent.statusId) { + it.copy(reblogged = reblogEvent.reblog) + } } } override fun handleFavEvent(favEvent: FavoriteEvent) { - updateActionableStatusById(favEvent.statusId) { - it.copy(favourited = favEvent.favourite) + viewModelScope.launch { + repository.updateActionableStatusById(favEvent.statusId) { + it.copy(favourited = favEvent.favourite) + } } + repository.invalidate() } override fun handleBookmarkEvent(bookmarkEvent: BookmarkEvent) { - updateActionableStatusById(bookmarkEvent.statusId) { - it.copy(bookmarked = bookmarkEvent.bookmark) + viewModelScope.launch { + repository.updateActionableStatusById(bookmarkEvent.statusId) { + it.copy(bookmarked = bookmarkEvent.bookmark) + } } + repository.invalidate() } override fun handlePinEvent(pinEvent: PinEvent) { - updateActionableStatusById(pinEvent.statusId) { - it.copy(pinned = pinEvent.pinned) + viewModelScope.launch { + repository.updateActionableStatusById(pinEvent.statusId) { + it.copy(pinned = pinEvent.pinned) + } } + repository.invalidate() } override fun fullReload() { - nextKey = statusData.firstOrNull()?.id - statusData.clear() - repository.invalidate() + viewModelScope.launch { + repository.reload() + } } override fun clearWarning(status: StatusViewData) { - updateActionableStatusById(status.actionableId) { - it.copy(filtered = null) + viewModelScope.launch { + repository.updateActionableStatusById(status.actionableId) { + it.copy(filtered = null) + } } } @@ -190,45 +195,6 @@ class NetworkTimelineViewModel @Inject constructor( repository.invalidate() } - private fun StatusViewData.update() { -// val position = statusData.indexOfFirst { viewData -> viewData.asStatusOrNull()?.id == this.id } -// statusData[position] = this -// currentSource?.invalidate() - } - - private inline fun updateStatusById( - id: String, - updater: (StatusViewData) -> StatusViewData - ) { - val pos = statusData.indexOfFirst { it.id == id } - if (pos == -1) return -// updateViewDataAt(pos, updater) - } - - private inline fun updateActionableStatusById( - id: String, - updater: (Status) -> Status - ) { - val pos = statusData.indexOfFirst { it.id == id } - if (pos == -1) return -// updateViewDataAt(pos) { vd -> -// if (vd.status.reblog != null) { -// vd.copy(status = vd.status.copy(reblog = updater(vd.status.reblog))) -// } else { -// vd.copy(status = updater(vd.status)) -// } -// } - } - -// private inline fun updateViewDataAt( -// position: Int, -// updater: (StatusViewData) -> StatusViewData -// ) { -// val status = statusData.getOrNull(position)?.asStatusOrNull() ?: return -// statusData[position] = updater(status) -// currentSource?.invalidate() -// } - companion object { private const val TAG = "NetworkTimelineViewModel" } From c168cf2d7fbb12317736148a05fdd9e70aee96d8 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Sun, 23 Apr 2023 18:32:29 +0200 Subject: [PATCH 039/156] Remove more code that deals with placeholders --- .../timeline/TimelineTypeMappers.kt | 44 ------------------- .../viewmodel/CachedTimelineRemoteMediator.kt | 9 +--- .../com/keylesspalace/tusky/db/TimelineDao.kt | 14 ------ .../tusky/db/TimelineStatusEntity.kt | 6 +-- .../tusky/usecase/DeveloperToolsUseCase.kt | 25 ----------- 5 files changed, 2 insertions(+), 96 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineTypeMappers.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineTypeMappers.kt index 3478c404c7..a9938b62cf 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineTypeMappers.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineTypeMappers.kt @@ -32,11 +32,6 @@ import java.util.Date private const val TAG = "TimelineTypeMappers" -data class Placeholder( - val id: String, - val loading: Boolean -) - private val attachmentArrayListType = object : TypeToken>() {}.type private val emojisListType = object : TypeToken>() {}.type private val mentionListType = object : TypeToken>() {}.type @@ -69,45 +64,6 @@ fun TimelineAccountEntity.toAccount(gson: Gson): TimelineAccount { ) } -fun Placeholder.toEntity(timelineUserId: Long): TimelineStatusEntity { - return TimelineStatusEntity( - serverId = this.id, - url = null, - timelineUserId = timelineUserId, - authorServerId = null, - inReplyToId = null, - inReplyToAccountId = null, - content = null, - createdAt = 0L, - editedAt = 0L, - emojis = null, - reblogsCount = 0, - favouritesCount = 0, - reblogged = false, - favourited = false, - bookmarked = false, - sensitive = false, - spoilerText = "", - visibility = Status.Visibility.UNKNOWN, - attachments = null, - mentions = null, - tags = null, - application = null, - reblogServerId = null, - reblogAccountId = null, - poll = null, - muted = false, - expanded = loading, - contentCollapsed = false, - contentShowing = false, - pinned = false, - card = null, - repliesCount = 0, - language = null, - filtered = null - ) -} - fun Status.toEntity( timelineUserId: Long, gson: Gson, diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt index fd776477b0..87c6bbc552 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt @@ -126,14 +126,7 @@ class CachedTimelineRemoteMediator( if (oldStatus != null) break } - // The "expanded" property for Placeholders determines whether or not they are - // in the "loading" state, and should not be affected by the account's - // "alwaysOpenSpoiler" preference - val expanded = if (oldStatus?.isPlaceholder == true) { - oldStatus.expanded - } else { - oldStatus?.expanded ?: activeAccount.alwaysOpenSpoiler - } + val expanded = oldStatus?.expanded ?: activeAccount.alwaysOpenSpoiler val contentShowing = oldStatus?.contentShowing ?: activeAccount.alwaysShowSensitiveMedia || !status.actionableStatus.sensitive val contentCollapsed = oldStatus?.contentCollapsed ?: true diff --git a/app/src/main/java/com/keylesspalace/tusky/db/TimelineDao.kt b/app/src/main/java/com/keylesspalace/tusky/db/TimelineDao.kt index 7753c33bac..a4d4e8ce23 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/TimelineDao.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/TimelineDao.kt @@ -212,10 +212,6 @@ AND timelineUserId = :accountId @Query("SELECT serverId FROM TimelineStatusEntity WHERE timelineUserId = :accountId ORDER BY LENGTH(serverId) DESC, serverId LIMIT 1") abstract suspend fun getBottomId(accountId: Long): String? - // TODO: Part of placeholder code deletion - @Query("SELECT serverId FROM TimelineStatusEntity WHERE timelineUserId = :accountId AND authorServerId IS NULL ORDER BY LENGTH(serverId) DESC, serverId DESC LIMIT 1") - abstract suspend fun getTopPlaceholderId(accountId: Long): String? - /** * Returns the id directly above [serverId], or null if [serverId] is the id of the top status */ @@ -229,20 +225,10 @@ AND timelineUserId = :accountId @Query("SELECT serverId FROM TimelineStatusEntity WHERE timelineUserId = :accountId AND (LENGTH(:serverId) > LENGTH(serverId) OR (LENGTH(:serverId) = LENGTH(serverId) AND :serverId > serverId)) ORDER BY LENGTH(serverId) DESC, serverId DESC LIMIT 1") abstract suspend fun getIdBelow(accountId: Long, serverId: String): String? - /** - * Returns the id of the next placeholder after [serverId] - */ - @Query("SELECT serverId FROM TimelineStatusEntity WHERE timelineUserId = :accountId AND authorServerId IS NULL AND (LENGTH(:serverId) > LENGTH(serverId) OR (LENGTH(:serverId) = LENGTH(serverId) AND :serverId > serverId)) ORDER BY LENGTH(serverId) DESC, serverId DESC LIMIT 1") - abstract suspend fun getNextPlaceholderIdAfter(accountId: Long, serverId: String): String? - @Query("SELECT COUNT(*) FROM TimelineStatusEntity WHERE timelineUserId = :accountId") abstract suspend fun getStatusCount(accountId: Long): Int /** Developer tools: Find N most recent status IDs */ @Query("SELECT serverId FROM TimelineStatusEntity WHERE timelineUserId = :accountId ORDER BY LENGTH(serverId) DESC, serverId DESC LIMIT :count") abstract suspend fun getMostRecentNStatusIds(accountId: Long, count: Int): List - - /** Developer tools: Convert a status to a placeholder */ - @Query("UPDATE TimelineStatusEntity SET authorServerId = NULL WHERE serverId = :serverId") - abstract suspend fun convertStatustoPlaceholder(serverId: String) } diff --git a/app/src/main/java/com/keylesspalace/tusky/db/TimelineStatusEntity.kt b/app/src/main/java/com/keylesspalace/tusky/db/TimelineStatusEntity.kt index de7b2df717..38d7b20fe2 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/TimelineStatusEntity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/TimelineStatusEntity.kt @@ -78,7 +78,6 @@ data class TimelineStatusEntity( val reblogAccountId: String?, val poll: String?, val muted: Boolean?, - /** Also used as the "loading" attribute when this TimelineStatusEntity is a placeholder */ val expanded: Boolean, val contentCollapsed: Boolean, val contentShowing: Boolean, @@ -86,10 +85,7 @@ data class TimelineStatusEntity( val card: String?, val language: String?, val filtered: List? -) { - val isPlaceholder: Boolean - get() = this.authorServerId == null -} +) @Entity( primaryKeys = ["serverId", "timelineUserId"] diff --git a/app/src/main/java/com/keylesspalace/tusky/usecase/DeveloperToolsUseCase.kt b/app/src/main/java/com/keylesspalace/tusky/usecase/DeveloperToolsUseCase.kt index 6660f9f27c..cd98ed6c45 100644 --- a/app/src/main/java/com/keylesspalace/tusky/usecase/DeveloperToolsUseCase.kt +++ b/app/src/main/java/com/keylesspalace/tusky/usecase/DeveloperToolsUseCase.kt @@ -1,6 +1,5 @@ package com.keylesspalace.tusky.usecase -import android.util.Log import androidx.room.withTransaction import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.db.TimelineDao @@ -16,30 +15,6 @@ class DeveloperToolsUseCase @Inject constructor( private var timelineDao: TimelineDao = db.timelineDao() - /** - * Create a gap in the home timeline to make it easier to interactively experiment with - * different "Load more" behaviours. - * - * Do this by taking the 10 most recent statuses, keeping the first 2, deleting the next 7, - * and replacing the last one with a placeholder. - */ - suspend fun createLoadMoreGap(accountId: Long) { - db.withTransaction { - val ids = timelineDao.getMostRecentNStatusIds(accountId, 10) - val maxId = ids[2] - val minId = ids[8] - val placeHolderId = ids[9] - - Log.d( - "TAG", - "createLoadMoreGap: creating gap between $minId .. $maxId (new placeholder: $placeHolderId" - ) - - timelineDao.deleteRange(accountId, minId, maxId) - timelineDao.convertStatustoPlaceholder(placeHolderId) - } - } - /** * Clear the home timeline cache. */ From 28f46e6a34425fe2e32abc810a18ea4a547b7fcd Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Sun, 23 Apr 2023 18:34:05 +0200 Subject: [PATCH 040/156] Lint --- .../tusky/components/timeline/NetworkTimelineRepository.kt | 6 +++--- .../timeline/viewmodel/NetworkTimelineRemoteMediator.kt | 5 ++++- .../components/timeline/viewmodel/TimelineViewModel.kt | 2 +- .../java/com/keylesspalace/tusky/network/MastodonApi.kt | 4 ++-- 4 files changed, 10 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRepository.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRepository.kt index f704db2324..0a6f4fc80a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRepository.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRepository.kt @@ -45,15 +45,15 @@ data class Page constructor( /** * [Key] for next page if more data can be loaded in that direction, `null` otherwise. */ - val nextKey: Key?, + val nextKey: Key? ) /** Timeline repository where the timeline information is backed by an in-memory cache. */ class NetworkTimelineRepository @Inject constructor( private val mastodonApi: MastodonApi, // TODO: This needs to be recreated if the active account changes - private val accountManager: AccountManager, - ) { + private val accountManager: AccountManager +) { /** * Pages of statuses. diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt index ead686d811..fed8408bfd 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt @@ -162,7 +162,10 @@ class NetworkTimelineRemoteMediator( pinned = null ) is TimelineKind.UserList -> api.listTimeline( - timelineKind.id, maxId = maxId, minId = minId, limit = loadSize + timelineKind.id, + maxId = maxId, + minId = minId, + limit = loadSize ) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt index c0ab54ada0..ce02e05f3a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt @@ -233,7 +233,7 @@ sealed class UiError( ) : UiError(throwable, R.string.ui_error_vote, action) data class GetFilters( - override val throwable: Throwable, + override val throwable: Throwable ) : UiError(throwable, R.string.ui_error_filter_v1_load, null) companion object { diff --git a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt index f9f1390241..c9b4cd728b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt +++ b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt @@ -136,7 +136,7 @@ interface MastodonApi { @Query("max_id") maxId: String? = null, @Query("since_id") sinceId: String? = null, @Query("min_id") minId: String? = null, - @Query("limit") limit: Int? = null, + @Query("limit") limit: Int? = null ): Response> @GET("api/v1/timelines/list/{listId}") @@ -145,7 +145,7 @@ interface MastodonApi { @Query("max_id") maxId: String? = null, @Query("since_id") sinceId: String? = null, @Query("min_id") minId: String? = null, - @Query("limit") limit: Int? = null, + @Query("limit") limit: Int? = null ): Response> @GET("api/v1/notifications") From 0f0739dd9cee974169863afdc557c554cd69a15f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C4=81rti=C5=86=C5=A1=20Bru=C5=86enieks?= Date: Sun, 23 Apr 2023 16:24:47 +0000 Subject: [PATCH 041/156] Translated using Weblate (Latvian) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Currently translated at 90.8% (548 of 603 strings) Translated using Weblate (Latvian) Currently translated at 90.5% (546 of 603 strings) Translated using Weblate (Latvian) Currently translated at 90.3% (545 of 603 strings) Co-authored-by: Mārtiņš Bruņenieks Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/lv/ Translation: Tusky/Tusky --- app/src/main/res/values-lv/strings.xml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/src/main/res/values-lv/strings.xml b/app/src/main/res/values-lv/strings.xml index ef9ed0877c..bed6a86331 100644 --- a/app/src/main/res/values-lv/strings.xml +++ b/app/src/main/res/values-lv/strings.xml @@ -593,4 +593,11 @@ Tendenču tēmturi Pievienot %s: %s + Filtra konteksts + Filtra darbība + Mans filtrs + Nosaukums + Apraksts + Sekošanas pieprasījums pieņemts + Sekošanas pieprasījums bloķēts \ No newline at end of file From 97d50e3d421ffe274dab26fb5a4c2aac6c62d9b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=E1=BB=93=20Nh=E1=BA=A5t=20Duy?= Date: Sun, 23 Apr 2023 16:24:48 +0000 Subject: [PATCH 042/156] Translated using Weblate (Vietnamese) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Currently translated at 100.0% (603 of 603 strings) Translated using Weblate (Vietnamese) Currently translated at 100.0% (603 of 603 strings) Co-authored-by: Hồ Nhất Duy Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/vi/ Translation: Tusky/Tusky --- app/src/main/res/values-vi/strings.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index 3827027c7e..042fe65788 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -187,7 +187,7 @@ Người theo dõi Theo dõi Ghim - Trả lời + Lượt trả lời Tút Nội dung tút Xếp tab @@ -423,7 +423,7 @@ Yêu cầu khởi động lại ứng dụng Đọc tút Mở rộng/Thu gọn toàn bộ tút - Đang tìm kiếm… + Đang tra cứu… Bạn cần tải về bộ emoji này trước Mặc định của thiết bị Emoji From 1d37442b98250bc884ddf00c90104704caa9c54a Mon Sep 17 00:00:00 2001 From: Rhoslyn Prys Date: Sun, 23 Apr 2023 16:24:48 +0000 Subject: [PATCH 043/156] Translated using Weblate (Welsh) Currently translated at 100.0% (603 of 603 strings) Co-authored-by: Rhoslyn Prys Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/cy/ Translation: Tusky/Tusky --- app/src/main/res/values-cy/strings.xml | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/app/src/main/res/values-cy/strings.xml b/app/src/main/res/values-cy/strings.xml index 0d1ea41998..cbb48ecdab 100644 --- a/app/src/main/res/values-cy/strings.xml +++ b/app/src/main/res/values-cy/strings.xml @@ -481,7 +481,7 @@ %1$s • %2$s Dylai fod gan y cyfryngau ddisgrifiad. Rhybudd cynnwys: %s - Dangos deialog cadarnhau cyn rhoi hwb + Dangos cadarnhâd cyn hybu Adolygu hysbysiadau Adroddiad newydd ar %s Adroddodd %s %s @@ -501,7 +501,7 @@ Cyfyngu ar hysbysiadau ffrydiau Gwall wrth chwilio am y neges %s Dangos enw defnyddiwr mewn bariau offer - Dangos deilaog cadarnhau cyn ffefrynnu + Dangos cadarnhâd cyn ffefrynnu Cuddio teitl y bar offer uchaf Rheolau %s %s (%s) @@ -630,17 +630,17 @@ Nid oes modd i chi lanlwytho mwy na %1$d atodiad cyfryngau. - Disgrifiwch ar gyfer pobl â nam ar eu golwg + Disgrifiwch y cynnwys ar gyfer pobl â nam ar eu golwg \n(terfyn nodau o %d) - Disgrifiwch ar gyfer pobl â nam ar eu golwg + Disgrifiwch y cynnwys ar gyfer pobl â nam ar eu golwg \n(terfyn nodau o %d) - Disgrifiwch ar gyfer pobl â nam ar eu golwg + Disgrifiwch y cynnwys ar gyfer pobl â nam ar eu golwg \n(terfyn nodau o %d) - Disgrifiwch ar gyfer pobl â nam ar eu golwg + Disgrifiwch y cynnwys ar gyfer pobl â nam ar eu golwg \n(terfyn nodau o %d) - Disgrifiwch ar gyfer pobl â nam ar eu golwg + Disgrifiwch y cynnwys ar gyfer pobl â nam ar eu golwg \n(terfyn nodau o %d) - Disgrifiwch ar gyfer pobl â nam ar eu golwg + Disgrifiwch y cynnwys ar gyfer pobl â nam ar eu golwg \n(terfyn nodau o %d) Parhau i olygu @@ -711,4 +711,13 @@ Ychwanegu allweddair Golygu allweddair %s: %s + Disgrifiad + Delwedd + Nid oes gennych retrau, eto + Rheoli rhestrau + Gwall wrth lwytho rhestrau + Hwn yw eich llinell amser cartref. Mae’n dangos postiadau diweddar y cyfrifon rydych yn eu dilyn. +\n +\nI archwilio cyfrifon gallwch un ai eu darganfod o fewn un o\'r llinellau amser eraill. Er enghraifft, mae llinell amser eich enghraifft chi [iconics gmd_group]. Neu gallwch eu chwilio yn ôl eu henw [iconics gmd_search]; er enghraifft, chwilio am Tusky i ganfod ein cyfrif Mastodon. + Dangos ystadegau postiadau mewn llinell amser \ No newline at end of file From 20c03f4763228205a3c0ecef04f3ec8153513914 Mon Sep 17 00:00:00 2001 From: XoseM Date: Sun, 23 Apr 2023 16:24:48 +0000 Subject: [PATCH 044/156] Translated using Weblate (Galician) Currently translated at 100.0% (603 of 603 strings) Co-authored-by: XoseM Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/gl/ Translation: Tusky/Tusky --- app/src/main/res/values-gl/strings.xml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/src/main/res/values-gl/strings.xml b/app/src/main/res/values-gl/strings.xml index bc1ba09679..efa4653be9 100644 --- a/app/src/main/res/values-gl/strings.xml +++ b/app/src/main/res/values-gl/strings.xml @@ -111,9 +111,9 @@ Non se puido abrir o ficheiro. Non pode subirse ese tipo de ficheiro. A publicación é demasiado longa! - Fallou a obtención do token de acceso. Se persiste, inténtao desde Acceder no Navegador. + Non se obtivo o token de acceso. Se non o consigues, inténtao desde Acceder no Navegador. A autorización foi rexeitada. Se tes a certeza de que as credenciais son correctas, inténtao desde Acceder no Navegador no menú. - Aconteceu un erro non identificado de autorización. Se persiste, inténtao desde Acceder no Navedor. + Aconteceu un erro non identificado na autorización. Se persiste, inténtao desde Acceder no Navedor. Non se atopou un navegador para utilizar. Fallou a autenticación nesta instancia. Se persiste, inténtao desde Acceder no Navegador no menú. O dominio escrito non é válido @@ -522,7 +522,7 @@ Volve a acceder con tódalas contas para activar as notificacións push. Acceder Notificacións cando son editadas publicacións coas que interactuaches - Para poder usar as notificacións push vía UnifiedPush, Tusky require o permiso para subscribirse ás notificacións do teu servidor Mastodon. É necesario volver a acceder para cambiar os ámbitos OAuth concedidos a Tusky. Usando aquí ou nas preferencias da Conta a opción de volver a acceder conservarás os borradores locais e caché. + Para poder usar as notificacións push vía UnifiedPush, Tusky require o permiso para subscribirse ás notificacións do teu servidor Mastodon. É necesario volver a acceder para cambiar os ámbitos OAuth concedidos a Tusky. Usando aquí, ou nas preferencias da Conta, a opción de volver a acceder conservarás os borradores locais e caché. Volveches a acceder para obter as notificacións push en Tusky. Aínda así tes algunha outra conta que non foi migrada a este modo. Cambia a esas contas e volve a conectar unha a unha para activar o soporte para notificacións de UnifiedPush. Volve a acceder para ter notificacións push %s editou a publicación @@ -573,7 +573,7 @@ Nova denuncia en %s %s denunciou a %s %s · %d publicacións fixadas - Xa non segues #%s + Xa non segues a #%s hai unha nova denuncia O multimedia debería ter unha descrición. O porto debe estar entre %d e %d From b2b6be2a76d3c8a5459d6d1bd7e90faf3c9f8e98 Mon Sep 17 00:00:00 2001 From: Deleted User Date: Sun, 23 Apr 2023 16:24:48 +0000 Subject: [PATCH 045/156] Translated using Weblate (German) Currently translated at 100.0% (603 of 603 strings) Co-authored-by: Deleted User Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/de/ Translation: Tusky/Tusky --- app/src/main/res/values-de/strings.xml | 71 +++++++++++++------------- 1 file changed, 35 insertions(+), 36 deletions(-) diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 1457cfda42..f1e9133465 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -47,7 +47,7 @@ Ausklappen Einklappen Hier ist nichts. - Noch keine Beiträge. Ziehe nach unten, um zu aktualisieren! + Noch keine Beiträge. Ziehe zum Aktualisieren nach unten! %s teilte deinen Beitrag %s favorisierte deinen Beitrag %s folgt dir @@ -92,7 +92,7 @@ Stummschaltung aufheben Erwähnen Mediendateien ausblenden - Drawer öffnen + Menü öffnen Speichern Profil bearbeiten Bearbeiten @@ -147,7 +147,7 @@ \nEine Instanz ist ein einzelner Ort, an dem dein Konto gehostet ist, aber du kannst dennoch mit anderen Leuten interagieren, als wärt ihr alle auf derselben Website. \n \nWeitere Informationen gibt es auf joinmastodon.org. - Stelle Dateiupload fertig + Hochladen der Datei wird abgeschlossen Wird hochgeladen … Herunterladen Folgeanfrage zurückziehen? @@ -161,12 +161,12 @@ Benachrichtigungen Benachrichtigungen Mit einem Ton benachrichtigen - Benachrichtige mit Vibration - Benachrichtige mit Licht - Benachrichtigen wenn + Mit Vibration benachrichtigen + Mit Licht benachrichtigen + Mich benachrichtigen, wenn Ich erwähnt werde Mir jemand folgt - Jemand meine Beiträge teilt + meine Beiträge geteilt werden meine Beiträge favorisiert werden Erscheinungsbild App-Design @@ -191,7 +191,7 @@ HTTP-Proxy aktivieren HTTP-Proxy-Server HTTP-Proxy-Port - Beitragssichtbarkeit + Standard Beitragssichtbarkeit Mediendateien immer mit einer Inhaltswarnung versehen Beiträge Fehler beim Synchronisieren @@ -231,10 +231,9 @@ --> Website des Projekts: \n https://tusky.app - Fehlermeldungen & Verbesserungsvorschläge:\n - https://github.com/tuskyapp/Tusky/issues - - Tuskys Profil + Fehlermeldungen und Verbesserungsvorschläge: +\n https://github.com/tuskyapp/Tusky/issues + Profil von Tusky Inhalt des Beitrags teilen Link zum Beitrag teilen Bilder @@ -244,7 +243,7 @@ Folgt dir Mediendateien mit Inhaltswarnung immer anzeigen Medien - Antworten an @%s + Antwort an @%s mehr laden Unterhaltungen Filter hinzufügen @@ -287,7 +286,7 @@ App-Neustart erforderlich Du musst Tusky neu starten, damit die Änderungen übernommen werden Später - Neustarten + Neu starten Die Standard-Emojis deines Geräts Die Blob–Emojis aus Android 4.4–7.1 Die Standard-Emojis von Mastodon @@ -296,7 +295,7 @@ Bot %1$s ist umgezogen auf: An ursprüngliches Publikum teilen - nicht mehr teilen + Nicht mehr teilen Tusky enthält Code und Inhalte von den folgenden Open-Source-Projekten: Lizenziert unter der Apache-Lizenz (s. u.) CC-BY 4.0 @@ -329,10 +328,10 @@ Liste konnte nicht erstellt werden Liste konnte nicht umbenannt werden Liste konnte nicht gelöscht werden - Suche nach Leuten denen du folgst + Suche nach Leuten, denen du folgst Von der Liste entfernen Hashtag ohne # - Boost Autor öffnen + Autor*in des geteilten Beitrags öffnen Öffentliche Timelines <b>%1$s</b> Favorit @@ -353,7 +352,7 @@ Anwenden Beitrag erstellen Beitrag erstellen - Bot-Hinweis anzeigen + Hinweis für Bots anzeigen Bist du dir sicher, dass du alle deine Benachrichtigungen dauerhaft löschen möchtest\? %1$s • %2$s @@ -394,7 +393,7 @@ Zurück Fertig \@%s wurde erfolgreich gemeldet - Zusätzliche Kommentare + Zusätzliche Anmerkungen An %s weiterleiten Melden fehlgeschlagen Die Meldung wird an die Moderator*innen deines Servers geschickt. Du kannst hier eine Erklärung angeben, warum du dieses Konto meldest: @@ -409,12 +408,12 @@ 3 Tage 7 Tage Bearbeiten - test %s + Tusky %s Umfrage hinzufügen Beiträge mit Inhaltswarnungen immer ausklappen Umfrage mit den Möglichkeiten: %1$s, %2$s, %3$s, %4$s; %5$s Aktionen für Bild %s - Fehler beim Holen der Beiträge + Beiträge konnten nicht abgerufen werden Konten Fehler beim Suchen Auswahlmöglichkeit hinzufügen @@ -423,16 +422,16 @@ Geplante Beiträge Bearbeiten Geplante Beiträge - Plane Beitrag + Beitrag planen Zurücksetzen Lesezeichen Lesezeichen Lesezeichen - Angetrieben durch Tusky + Unterstützt von Tusky Als Lesezeichen gespeichert Liste auswählen Du hast noch keine Listen - Listen + Listen verwalten Fehler beim Laden der Listen Liste Fehler beim Nachschlagen von %s @@ -467,10 +466,10 @@ Position der Hauptnavigation Benachrichtigungen ausblenden %s nicht mehr stummschalten - %d Sek. - %d St. + %d Sek. + %d Std. in %d T. - %d J. + %d J. Gespeichert! Private Notiz über dieses Konto Titel der Hauptnavigation ausblenden @@ -479,7 +478,7 @@ Der Beitrag, auf den du antworten wolltest, wurde gelöscht Entwurf gelöscht Dieser Beitrag konnte nicht gesendet werden! - Willst du die Liste %s wirklich löschen\? + Möchtest du die Liste %s wirklich löschen\? Du kannst nicht mehr als %1$d Anhang hochladen. Du kannst nicht mehr als %1$d Anhänge hochladen. @@ -494,7 +493,7 @@ GIF-Emojis animieren jemand, den ich abonniert habe, etwas Neues veröffentlicht %s hat gerade etwas veröffentlicht - %d Min. + %d Min. Benachrichtigungen überprüfen Informationen, die dein geistiges Wohlbefinden beeinflussen könnten, werden ausgeblendet. Dazu gehören: \n @@ -509,10 +508,10 @@ Timeline-Benachrichtigungen einschränken Abonnieren Deabonnieren - in %d M. + in %d Min. in %d Std. Informationen zur Antwort konnten nicht geladen werden - %d T. + %d T. in %d J. in %d Sek. Lesezeichen entfernen @@ -527,7 +526,7 @@ 180 Tage Beitrag erstellen %s hat den Beitrag bearbeitet - Ein Beitrag, mit dem ich interagiert habe, wurde bearbeitet + Ein Beitrag, mit dem ich interagiert habe, bearbeitet wurde Registrierungen Benachrichtigungen über neue Profile %s hat sich registriert @@ -573,7 +572,7 @@ Das Konto konnte nicht aus der Liste entfernt werden Zur Liste hinzufügen / aus Liste entfernen Das Konto konnte nicht zur Liste hinzugefügt werden - Du hast gar keine Listen. + Du hast keine Listen. %s · %d Beiträge angehängt %s hat %s gemeldet #%s entfolgt @@ -599,17 +598,17 @@ Port sollte zwischen %d und %d liegen Fehler beim Stummschalten von #%s Hochladen fehlgeschlagen - Dein Beitrag konnte nicht gepostet werden. + Dein Beitrag konnte nicht gepostet werden und wurde als Entwurf gespeichert. \n \nEntweder ist die Verbindung zum Server fehlgeschlagen oder der Server hat den Beitrag abgelehnt. - Dein Beiträge konnte nicht gepostet werden. + Deine Beiträge konnte nicht gepostet werden und wurde als Entwürfe gespeichert. \n \nEntweder ist die Verbindung zum Server fehlgeschlagen oder der Server hat die Beiträge abgelehnt. Entwürfe anzeigen Abbrechen Bearbeitet %s Änderungen verwerfen - Bearbeitung fortsetzen + Bearbeiten fortsetzen Mit Browser anmelden Name des Profils teilen Link zum Profil teilen From 3b9f62c975a806f647d3d6e4a82e741bd774feb3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Quent=C3=AD?= Date: Sun, 23 Apr 2023 16:24:48 +0000 Subject: [PATCH 046/156] Translated using Weblate (Occitan) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Currently translated at 99.6% (601 of 603 strings) Co-authored-by: Quentí Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/oc/ Translation: Tusky/Tusky --- app/src/main/res/values-oc/strings.xml | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/app/src/main/res/values-oc/strings.xml b/app/src/main/res/values-oc/strings.xml index 2dae727b8d..58dcd2af6d 100644 --- a/app/src/main/res/values-oc/strings.xml +++ b/app/src/main/res/values-oc/strings.xml @@ -321,10 +321,8 @@ Ajustar un compte a la lista Suprimir aqueste compte de la lista - Descriure pels mal vesents -\n(%d caractèr maximum) - Descriure pels mal vesents -\n(%d caractèrs maximum) + Descriure lo contengut pels mal vesents (%d caractèr maximum) + "Descriure los contenguts pels mal vesents (%d caractèrs maximum)" CC-BY 4.0 CC-BY-SA 4.0 @@ -658,4 +656,13 @@ Mots clau o frasas de filtrar Descripcion Imatge + Avètz pas encara de lista + Gerir las listas + Error en cargant las litas + Fracàs de la mes en favorit : %s + Fracàs en partejant : %s + Fracàs del vòt : %s + Fracàs de l’acceptacion de la demanda : %s + Fracàs del refús de la demanda : %s + Mostrar las estatisticas dins la cronologia \ No newline at end of file From ef2bdaf6aad135cd0d0a379c8e747cac9a7dd0ed Mon Sep 17 00:00:00 2001 From: Luna Jernberg Date: Sun, 23 Apr 2023 16:24:48 +0000 Subject: [PATCH 047/156] Translated using Weblate (Swedish) Currently translated at 97.8% (590 of 603 strings) Co-authored-by: Luna Jernberg Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/sv/ Translation: Tusky/Tusky --- app/src/main/res/values-sv/strings.xml | 48 ++++++++++++++++++++++---- 1 file changed, 41 insertions(+), 7 deletions(-) diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 6ff3deefaa..3bdaabc175 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -8,7 +8,7 @@ Det gick inte att hitta en webbläsare. Ett oidentifierat behörighetsfel inträffade. Om problemet kvarstår, försök att logga in via webbläsare från menyn. Ingen behörighet. Om du är säker på att användarnamn och lösenord är korrekt, försök att logga in via webbläsare från menyn. - Misslyckades med att få en inloggnings-token. + Misslyckades med att få en inloggnings-token.Om detta kvarstår kan du prova att logga in i webbläsaren från menyn. Inlägget är för långt! Den typen av fil kan inte laddas upp. Den filen kunde inte öppnas. @@ -203,7 +203,7 @@ Mellan Stor Största - Nya omnämnanden + Nya omnämningar Aviseringar om nya omnämnanden Nya följare Aviseringar på nya följare @@ -281,10 +281,10 @@ Publicerar som %1$s Misslyckades med att ange bildtext - Beskriv för synskadade -\n(max %d tecken) - Beskriv för synskadade -\n(max %d tecken) + Beskriv innehåll för synskadade +\n (max %d tecken) + Beskriv innehåll för synskadade +\n (max %d tecken) Ange bildtext Ta bort @@ -534,7 +534,7 @@ 90 dagar (Ingen ändring) Visa användarnamn i verktygsrader - Visa bekräftelsedialog före favoritmarkering + Visa bekräftelse före favoritmarkering Vill du verkligen radera listan %s\? Det här inlägget kunde inte skickas! Kunde inte ladda information om svar @@ -628,4 +628,38 @@ \nAntingen kunde inte servern nås eller så har uppladdningen nekats. Stäng Fungerar i de flesta fallen. Ingen information läcker till andra applikationer. + Äldsta först + Nyaste först + Följ hashtagg + #hashtagg + %1$d personer pratar om hashtagg %2$s + Total användning + Totala konton + Beskrivning + Bild + Kan stödja ytterligare autentiseringsmetoder, men kräver en webbläsare som stöds. + %s: %s + Lägg till nyckelord + Redigera nyckelord + Okänd + Att kontakta din server tog för lång tid + okänd anledning + Följ begäran accepterad + Följ begäran blockerad + Hantera listor + Du har inga listor, än + Visa allafall + Filtrerad: %s + Profiler + Filteråtgärd + Nyckelord eller fraser att filtrera + Visa post statistik i tidslinje + Mitt filter + Titel + Varna + Dölj + Dölj med en varning + Dölj helt + Lägg till + %s (helt ord) \ No newline at end of file From 1f3169735afccf5cdf1911b97251ec932c736d37 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Mon, 24 Apr 2023 16:36:13 +0200 Subject: [PATCH 048/156] Revert "AGP 8 / Java 17 (for Android Studio Flamingo)" This reverts commit e4c15848923518614d37486ba92a8e56841c2aa8. --- app/build.gradle | 4 ++-- build.gradle | 2 +- gradle/libs.versions.toml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 2af003d316..a1f4e2bff1 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -102,8 +102,8 @@ android { // Can remove this once https://issuetracker.google.com/issues/260059413 is fixed. // https://kotlinlang.org/docs/gradle-configure-project.html#gradle-java-toolchains-support compileOptions { - sourceCompatibility JavaVersion.VERSION_17 - targetCompatibility JavaVersion.VERSION_17 + sourceCompatibility JavaVersion.VERSION_11 + targetCompatibility JavaVersion.VERSION_11 } applicationVariants.configureEach { variant -> variant.outputs.configureEach { diff --git a/build.gradle b/build.gradle index 090c9733f1..14527214e6 100644 --- a/build.gradle +++ b/build.gradle @@ -11,7 +11,7 @@ allprojects { plugins.withType(JavaBasePlugin).configureEach { java { - toolchain.languageVersion = JavaLanguageVersion.of(17) + toolchain.languageVersion = JavaLanguageVersion.of(11) } } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6f8e4c0be2..8e492f57c8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -agp = "8.0.0" +agp = "7.4.2" androidx-activity = "1.7.0" androidx-appcompat = "1.6.1" androidx-browser = "1.5.0" From 234a5dc404d0f1db8cc04c459c669d8f7ffea6e5 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Wed, 26 Apr 2023 11:50:43 +0200 Subject: [PATCH 049/156] Handle all preferences that update statusDisplayOptions --- .../components/timeline/TimelineFragment.kt | 35 ++++++------------- .../timeline/TimelinePagingAdapter.kt | 26 ++++---------- .../timeline/viewmodel/TimelineViewModel.kt | 4 --- 3 files changed, 18 insertions(+), 47 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt index e08b07103c..19664d85a3 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt @@ -122,7 +122,7 @@ class TimelineFragment : isSwipeToRefreshEnabled = arguments.getBoolean(ARG_ENABLE_SWIPE_TO_REFRESH, true) - adapter = TimelinePagingAdapter(viewModel.statusDisplayOptions.value, this) + adapter = TimelinePagingAdapter(this, viewModel.statusDisplayOptions.value) } override fun onCreateView( @@ -309,36 +309,23 @@ class TimelineFragment : } } } - - viewModel.uiState.collectLatest { - // showMediaPreview changed? - val previousMediaPreview = adapter.mediaPreviewEnabled - if (previousMediaPreview != it.showMediaPreview) { - adapter.mediaPreviewEnabled = it.showMediaPreview - adapter.notifyItemRangeChanged(0, adapter.itemCount) - } - } } // Update status display from statusDisplayOptions. If the new options request // relative time display collect the flow to periodically re-bind the UI. - // TODO: Copied from NotificationsFragment launch { viewModel.statusDisplayOptions .collectLatest { - // TODO: TimelinePagingAdapter doesn't handle statusDisplayOptions - // the same way NotificationsPagingAdapter does. Investigate bringing - // the two classes in to alignment. -// adapter.statusDisplayOptions = it -// layoutManager.findFirstVisibleItemPosition().let { first -> -// first == RecyclerView.NO_POSITION && return@let -// val count = layoutManager.findLastVisibleItemPosition() - first -// adapter.notifyItemRangeChanged( -// first, -// count, -// null -// ) -// } + adapter.statusDisplayOptions = it + layoutManager.findFirstVisibleItemPosition().let { first -> + first == RecyclerView.NO_POSITION && return@let + val count = layoutManager.findLastVisibleItemPosition() - first + adapter.notifyItemRangeChanged( + first, + count, + null + ) + } if (!it.useAbsoluteTime) { updateTimestampFlow.collect() diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelinePagingAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelinePagingAdapter.kt index b30d38f6c3..e0ef76efa5 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelinePagingAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelinePagingAdapter.kt @@ -29,18 +29,9 @@ import com.keylesspalace.tusky.util.StatusDisplayOptions import com.keylesspalace.tusky.viewdata.StatusViewData class TimelinePagingAdapter( - private var statusDisplayOptions: StatusDisplayOptions, - private val statusListener: StatusActionListener + private val statusListener: StatusActionListener, + var statusDisplayOptions: StatusDisplayOptions ) : PagingDataAdapter(TimelineDifferCallback) { - - var mediaPreviewEnabled: Boolean - get() = statusDisplayOptions.mediaPreviewEnabled - set(mediaPreviewEnabled) { - statusDisplayOptions = statusDisplayOptions.copy( - mediaPreviewEnabled = mediaPreviewEnabled - ) - } - init { stateRestorationPolicy = StateRestorationPolicy.PREVENT_WHEN_EMPTY } @@ -74,14 +65,11 @@ class TimelinePagingAdapter( position: Int, payloads: List<*>? ) { - val status = getItem(position) ?: return - val holder = viewHolder as StatusViewHolder - holder.setupWithStatus( - status, - statusListener, - statusDisplayOptions, - if (payloads != null && payloads.isNotEmpty()) payloads[0] else null - ) + getItem(position)?.let { + (viewHolder as StatusViewHolder).setupWithStatus( + it, statusListener, statusDisplayOptions, payloads?.getOrNull(0) + ) + } } override fun getItemViewType(position: Int): Int { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt index ce02e05f3a..a4a4cafba7 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt @@ -85,14 +85,12 @@ data class UiState( data class UiPrefs( val readingOrder: ReadingOrder, val showFabWhileScrolling: Boolean, - val showMediaPreview: Boolean ) { companion object { /** Relevant preference keys. Changes to any of these trigger a display update */ val prefKeys = setOf( PrefKeys.FAB_HIDE, PrefKeys.READING_ORDER, - PrefKeys.MEDIA_PREVIEW_ENABLED ) } } @@ -400,7 +398,6 @@ abstract class TimelineViewModel( UiState( readingOrder = prefs.readingOrder, showFabWhileScrolling = prefs.showFabWhileScrolling, - showMediaPreview = prefs.showMediaPreview ) }.stateIn( scope = viewModelScope, @@ -422,7 +419,6 @@ abstract class TimelineViewModel( protected fun toPrefs() = UiPrefs( readingOrder = ReadingOrder.from(sharedPreferences.getString(PrefKeys.READING_ORDER, null)), showFabWhileScrolling = !sharedPreferences.getBoolean(PrefKeys.FAB_HIDE, false), - showMediaPreview = accountManager.activeAccount!!.mediaPreviewEnabled ) open fun init(timelineKind: TimelineKind) { From cca30220938ac3c85384797157213780795a7824 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Wed, 26 Apr 2023 11:54:37 +0200 Subject: [PATCH 050/156] Lint --- .../tusky/components/timeline/TimelinePagingAdapter.kt | 5 ++++- .../components/timeline/viewmodel/TimelineViewModel.kt | 8 ++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelinePagingAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelinePagingAdapter.kt index e0ef76efa5..5d76f63eee 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelinePagingAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelinePagingAdapter.kt @@ -67,7 +67,10 @@ class TimelinePagingAdapter( ) { getItem(position)?.let { (viewHolder as StatusViewHolder).setupWithStatus( - it, statusListener, statusDisplayOptions, payloads?.getOrNull(0) + it, + statusListener, + statusDisplayOptions, + payloads?.getOrNull(0) ) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt index a4a4cafba7..24c083bf0c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt @@ -84,13 +84,13 @@ data class UiState( /** Preferences the UI reacts to */ data class UiPrefs( val readingOrder: ReadingOrder, - val showFabWhileScrolling: Boolean, + val showFabWhileScrolling: Boolean ) { companion object { /** Relevant preference keys. Changes to any of these trigger a display update */ val prefKeys = setOf( PrefKeys.FAB_HIDE, - PrefKeys.READING_ORDER, + PrefKeys.READING_ORDER ) } } @@ -397,7 +397,7 @@ abstract class TimelineViewModel( uiState = getUiPrefs().map { prefs -> UiState( readingOrder = prefs.readingOrder, - showFabWhileScrolling = prefs.showFabWhileScrolling, + showFabWhileScrolling = prefs.showFabWhileScrolling ) }.stateIn( scope = viewModelScope, @@ -418,7 +418,7 @@ abstract class TimelineViewModel( protected fun toPrefs() = UiPrefs( readingOrder = ReadingOrder.from(sharedPreferences.getString(PrefKeys.READING_ORDER, null)), - showFabWhileScrolling = !sharedPreferences.getBoolean(PrefKeys.FAB_HIDE, false), + showFabWhileScrolling = !sharedPreferences.getBoolean(PrefKeys.FAB_HIDE, false) ) open fun init(timelineKind: TimelineKind) { From b8a04aa63e0d48bb73518f4cd9a879ee1e6cd232 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Wed, 26 Apr 2023 16:04:01 +0200 Subject: [PATCH 051/156] Derive alwaysShowSensitiveMedia and alwaysOpenSpoiler from StatusDisplayOptions --- .../timeline/viewmodel/NetworkTimelineViewModel.kt | 8 ++------ .../timeline/viewmodel/TimelineViewModel.kt | 14 -------------- 2 files changed, 2 insertions(+), 20 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt index 7ce198ab6e..52e9e39444 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt @@ -79,13 +79,9 @@ class NetworkTimelineViewModel @Inject constructor( return repository.getStatusStream(kind = kind, initialKey = initialKey) .map { pagingData -> pagingData.map { - // TODO: The previous code in RemoteMediator checked the states against the - // previous version of the status to make sure they were replicated. This will - // need to be reimplemented (probably as a map of StatusId -> ViewStates. - // For now, just use the user's preferences. modifiedViewData[it.id] ?: it.toViewData( - isShowingContent = alwaysShowSensitiveMedia || !it.actionableStatus.sensitive, - isExpanded = alwaysOpenSpoilers, + isShowingContent = statusDisplayOptions.value.showSensitiveMedia || !it.actionableStatus.sensitive, + isExpanded = statusDisplayOptions.value.openSpoiler, isCollapsed = true ) }.filter { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt index 24c083bf0c..2755b4ab47 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt @@ -83,14 +83,12 @@ data class UiState( /** Preferences the UI reacts to */ data class UiPrefs( - val readingOrder: ReadingOrder, val showFabWhileScrolling: Boolean ) { companion object { /** Relevant preference keys. Changes to any of these trigger a display update */ val prefKeys = setOf( PrefKeys.FAB_HIDE, - PrefKeys.READING_ORDER ) } } @@ -288,8 +286,6 @@ abstract class TimelineViewModel( var timelineKind: TimelineKind = TimelineKind.Home private set - protected var alwaysShowSensitiveMedia = false - protected var alwaysOpenSpoilers = false private var filterRemoveReplies = false private var filterRemoveReblogs = false @@ -396,7 +392,6 @@ abstract class TimelineViewModel( uiState = getUiPrefs().map { prefs -> UiState( - readingOrder = prefs.readingOrder, showFabWhileScrolling = prefs.showFabWhileScrolling ) }.stateIn( @@ -417,7 +412,6 @@ abstract class TimelineViewModel( .onStart { emit(toPrefs()) } protected fun toPrefs() = UiPrefs( - readingOrder = ReadingOrder.from(sharedPreferences.getString(PrefKeys.READING_ORDER, null)), showFabWhileScrolling = !sharedPreferences.getBoolean(PrefKeys.FAB_HIDE, false) ) @@ -434,9 +428,6 @@ abstract class TimelineViewModel( !sharedPreferences.getBoolean(PrefKeys.TAB_FILTER_HOME_BOOSTS, true) } - this.alwaysShowSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia - this.alwaysOpenSpoilers = accountManager.activeAccount!!.alwaysOpenSpoiler - viewModelScope.launch { eventHub.events .filterIsInstance() @@ -562,11 +553,6 @@ abstract class TimelineViewModel( fullReload() } } - PrefKeys.ALWAYS_SHOW_SENSITIVE_MEDIA -> { - // it is ok if only newly loaded statuses are affected, no need to fully refresh - alwaysShowSensitiveMedia = - accountManager.activeAccount!!.alwaysShowSensitiveMedia - } } } From 2fdeccad0c5a1180666a2face807027129921d52 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Wed, 26 Apr 2023 16:27:15 +0200 Subject: [PATCH 052/156] Throttle clicks, instead of debouncing them This ensures that the click is acted on immediately, instead of waiting 500ms and then acting. --- .../notifications/NotificationsViewModel.kt | 8 +-- .../timeline/viewmodel/TimelineViewModel.kt | 12 ++-- .../tusky/util/FlowExtensions.kt | 59 +++++++++++++++++++ 3 files changed, 67 insertions(+), 12 deletions(-) create mode 100644 app/src/main/java/com/keylesspalace/tusky/util/FlowExtensions.kt 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 index 5af5b1181b..9a89f488cc 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModel.kt @@ -40,6 +40,7 @@ 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 @@ -53,7 +54,6 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterIsInstance @@ -401,7 +401,7 @@ class NotificationsViewModel @Inject constructor( // Handle NotificationAction.* viewModelScope.launch { uiAction.filterIsInstance() - .debounce(DEBOUNCE_TIMEOUT_MS) + .throttleFirst(THROTTLE_TIMEOUT_MS) .collect { action -> try { when (action) { @@ -420,7 +420,7 @@ class NotificationsViewModel @Inject constructor( // Handle StatusAction.* viewModelScope.launch { uiAction.filterIsInstance() - .debounce(DEBOUNCE_TIMEOUT_MS) // avoid double-taps + .throttleFirst(THROTTLE_TIMEOUT_MS) // avoid double-taps .collect { action -> try { when (action) { @@ -526,6 +526,6 @@ class NotificationsViewModel @Inject constructor( companion object { private const val TAG = "NotificationsViewModel" - private const val DEBOUNCE_TIMEOUT_MS = 500L + private const val THROTTLE_TIMEOUT_MS = 500L } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt index 2755b4ab47..9bfe50d4c1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt @@ -51,6 +51,7 @@ import com.keylesspalace.tusky.network.FilterModel import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.usecase.TimelineCases import com.keylesspalace.tusky.util.StatusDisplayOptions +import com.keylesspalace.tusky.util.throttleFirst import com.keylesspalace.tusky.viewdata.StatusViewData import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.channels.Channel @@ -60,7 +61,6 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterIsInstance @@ -88,7 +88,7 @@ data class UiPrefs( companion object { /** Relevant preference keys. Changes to any of these trigger a display update */ val prefKeys = setOf( - PrefKeys.FAB_HIDE, + PrefKeys.FAB_HIDE ) } } @@ -340,11 +340,7 @@ abstract class TimelineViewModel( // Handle StatusAction.* viewModelScope.launch { uiAction.filterIsInstance() - // TODO: Not sure that debouncing is the right thing here, since that will wait - // DEBOUNCE_TIMEOUT_MS before acting. The right thing to do here (and in - // NotificationsFragment) is to take the first one, and ignore any others that - // arrive in the next N milliseconds). - .debounce(DEBOUNCE_TIMEOUT_MS) // avoid double-taps + .throttleFirst(THROTTLE_TIMEOUT_MS) // avoid double-taps .collect { action -> try { when (action) { @@ -600,7 +596,7 @@ abstract class TimelineViewModel( companion object { private const val TAG = "TimelineViewModel" - private const val DEBOUNCE_TIMEOUT_MS = 500L + private const val THROTTLE_TIMEOUT_MS = 500L fun filterContextMatchesKind( timelineKind: TimelineKind, diff --git a/app/src/main/java/com/keylesspalace/tusky/util/FlowExtensions.kt b/app/src/main/java/com/keylesspalace/tusky/util/FlowExtensions.kt new file mode 100644 index 0000000000..931d23c3e5 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/FlowExtensions.kt @@ -0,0 +1,59 @@ +/* + * 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.util + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow + +/** + * Returns a flow that mirrors the original flow, but filters out values that occur within + * [timeoutMillis] of the previously emitted value. The first value is always emitted. + * + * Example: + * + * ```kotlin + * flow { + * emit(1) + * delay(90) + * emit(2) + * delay(90) + * emit(3) + * delay(1010) + * emit(4) + * delay(1010) + * emit(5) + * }.throttleFirst(1000) + * ``` + * + * produces the following emissions. + * + * ```text + * 1, 4, 5 + * ``` + */ +fun Flow.throttleFirst(timeoutMillis: Long): Flow = flow { + var lastEmitTime = 0L + collect { + val currentTime = System.currentTimeMillis() + val emit = currentTime - lastEmitTime > timeoutMillis + if (emit) { + lastEmitTime = currentTime + emit(it) + } + } +} From a3155667e4548a40bc1875adffaf37f955f433bc Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Wed, 26 Apr 2023 19:19:08 +0200 Subject: [PATCH 053/156] Remove "Reading order" preference --- .../keylesspalace/tusky/TuskyApplication.kt | 6 ++++ .../preference/PreferencesFragment.kt | 30 ------------------- .../timeline/viewmodel/TimelineViewModel.kt | 4 --- .../tusky/settings/SettingsConstants.kt | 6 ++-- app/src/main/res/values-ar/strings.xml | 3 -- app/src/main/res/values-be/strings.xml | 3 -- app/src/main/res/values-ca/strings.xml | 3 -- app/src/main/res/values-cy/strings.xml | 3 -- app/src/main/res/values-de/strings.xml | 3 -- app/src/main/res/values-es/strings.xml | 3 -- app/src/main/res/values-fa/strings.xml | 3 -- app/src/main/res/values-gd/strings.xml | 3 -- app/src/main/res/values-gl/strings.xml | 3 -- app/src/main/res/values-hu/strings.xml | 3 -- app/src/main/res/values-is/strings.xml | 3 -- app/src/main/res/values-it/strings.xml | 3 -- app/src/main/res/values-ja/strings.xml | 3 -- app/src/main/res/values-lv/strings.xml | 3 -- app/src/main/res/values-nl/strings.xml | 3 -- app/src/main/res/values-oc/strings.xml | 3 -- app/src/main/res/values-pl/strings.xml | 3 -- app/src/main/res/values-pt-rBR/strings.xml | 3 -- app/src/main/res/values-ru/strings.xml | 1 - app/src/main/res/values-sa/strings.xml | 3 -- app/src/main/res/values-sv/strings.xml | 6 ++-- app/src/main/res/values-uk/strings.xml | 3 -- app/src/main/res/values-vi/strings.xml | 3 -- app/src/main/res/values-zh-rCN/strings.xml | 3 -- app/src/main/res/values/donottranslate.xml | 10 ------- app/src/main/res/values/strings.xml | 3 -- 30 files changed, 12 insertions(+), 120 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt b/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt index ea3b6ad6d5..6e66d91619 100644 --- a/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt +++ b/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt @@ -126,6 +126,12 @@ class TuskyApplication : Application(), HasAndroidInjector { editor.remove(PrefKeys.MEDIA_PREVIEW_ENABLED) } + if (oldVersion < 2023042601) { + // Deleting the "Reading order" preference, as the need to "Load more" has been + // removed. + editor.remove(PrefKeys.DEPRECATED_READING_ORDER) + } + editor.putInt(PrefKeys.SCHEMA_VERSION, newVersion) editor.apply() } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesFragment.kt index 84ba4c0c20..7dd8cd2875 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesFragment.kt @@ -50,26 +50,6 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable { private val iconSize by unsafeLazy { resources.getDimensionPixelSize(R.dimen.preference_icon_size) } - enum class ReadingOrder { - /** User scrolls up, reading statuses oldest to newest */ - OLDEST_FIRST, - - /** User scrolls down, reading statuses newest to oldest. Default behaviour. */ - NEWEST_FIRST; - - companion object { - fun from(s: String?): ReadingOrder { - s ?: return NEWEST_FIRST - - return try { - valueOf(s.uppercase()) - } catch (_: Throwable) { - NEWEST_FIRST - } - } - } - } - override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { makePreferenceScreen { preferenceCategory(R.string.pref_title_appearance_settings) { @@ -109,16 +89,6 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable { icon = makeIcon(GoogleMaterial.Icon.gmd_format_size) } - listPreference { - setDefaultValue(ReadingOrder.NEWEST_FIRST.name) - setEntries(R.array.reading_order_names) - setEntryValues(R.array.reading_order_values) - key = PrefKeys.READING_ORDER - setSummaryProvider { entry } - setTitle(R.string.pref_title_reading_order) - icon = makeIcon(GoogleMaterial.Icon.gmd_sort) - } - listPreference { setDefaultValue("top") setEntries(R.array.pref_main_nav_position_options) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt index 9bfe50d4c1..6c17aab888 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt @@ -37,7 +37,6 @@ import com.keylesspalace.tusky.appstore.StatusComposedEvent import com.keylesspalace.tusky.appstore.StatusDeletedEvent import com.keylesspalace.tusky.appstore.StatusEditedEvent import com.keylesspalace.tusky.appstore.UnfollowEvent -import com.keylesspalace.tusky.components.preference.PreferencesFragment.ReadingOrder import com.keylesspalace.tusky.components.timeline.FilterKind import com.keylesspalace.tusky.components.timeline.FiltersRepository import com.keylesspalace.tusky.components.timeline.TimelineKind @@ -71,9 +70,6 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch data class UiState( - /** The user's preferred reading order */ - val readingOrder: ReadingOrder = ReadingOrder.NEWEST_FIRST, - /** True if the FAB should be shown while scrolling */ val showFabWhileScrolling: Boolean = true, diff --git a/app/src/main/java/com/keylesspalace/tusky/settings/SettingsConstants.kt b/app/src/main/java/com/keylesspalace/tusky/settings/SettingsConstants.kt index a20fb4af0b..717cec84b8 100644 --- a/app/src/main/java/com/keylesspalace/tusky/settings/SettingsConstants.kt +++ b/app/src/main/java/com/keylesspalace/tusky/settings/SettingsConstants.kt @@ -41,7 +41,7 @@ enum class AppTheme(val value: String) { * * - Adding a new preference that does not change the interpretation of an existing preference */ -const val SCHEMA_VERSION = 2023022701 +const val SCHEMA_VERSION = 2023042601 object PrefKeys { // Note: not all of these keys are actually used as SharedPreferences keys but we must give @@ -53,7 +53,9 @@ object PrefKeys { const val FAB_HIDE = "fabHide" const val LANGUAGE = "language" const val STATUS_TEXT_SIZE = "statusTextSize" - const val READING_ORDER = "readingOrder" + + /** Deprecated reading order preference key */ + const val DEPRECATED_READING_ORDER = "readingOrder" const val MAIN_NAV_POSITION = "mainNavPosition" const val HIDE_TOP_TOOLBAR = "hideTopToolbar" const val ABSOLUTE_TIME_VIEW = "absoluteTimeView" diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index ad88456ae9..d4fd1535d7 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -657,10 +657,7 @@ فشل التحميل إظهار المسودات تخطي - ترتيب القراءة مُعطَّل - الأقدم أولاً - الأحدث أولاً الولوج باستخدام متصفح الوسوم المتداولة هناك %1$d أشخاص يتحدثون عن الوسم %2$s diff --git a/app/src/main/res/values-be/strings.xml b/app/src/main/res/values-be/strings.xml index 463058672c..20d3f5ef35 100644 --- a/app/src/main/res/values-be/strings.xml +++ b/app/src/main/res/values-be/strings.xml @@ -621,12 +621,9 @@ Увайдзіце зноў на ўсіх уліковых запісах, каб push-апавяшчэнні запрацавалі. Бязгучныя апавяшчэнні Найменшы час планавання ў Mastodon складае 5 хвілін. - Спачатку старэйшыя - Кірунак чытання Адключана <нядзейсны> <не задана> - Спачатку навейшыя Падпісацца на хэштэг #hashtag Не атрымалася запампаваць diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index 9f0ed7a604..51273b7d54 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -579,9 +579,6 @@ 14 dies Tot i que el vostre compte no està bloquejat, el personal de %1$s va pensar que potser voldreu revisar les sol·licituds de seguiment d\'aquests comptes manualment. Altres - Ordre de lectura - El més vell primer - El més nou primer 30 dies No s\'ha pogut eliminar el compte de la llista 60 dies diff --git a/app/src/main/res/values-cy/strings.xml b/app/src/main/res/values-cy/strings.xml index 12ca47b199..2d32e61bc6 100644 --- a/app/src/main/res/values-cy/strings.xml +++ b/app/src/main/res/values-cy/strings.xml @@ -654,9 +654,6 @@ Rhannu URL cyfrif i… Rhannu enw denyddiwr cyfrif i… Enw defnyddiwr wedi\'i gopïo - Hynaf yn gyntaf - Diweddaraf yn gyntaf - Trefn darllen Analluogwyd <heb ei osod> <annilys> diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index dcbdce5acd..7b2968eda5 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -584,9 +584,6 @@ Bearbeitet Thread wird geladen Benachrichtigungen stummschalten - Neueste zuerst - Älteste zuerst - Lesereihenfolge Bearbeitungen Deaktiviert <nicht gesetzt> diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 69c53ff06b..207a657892 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -617,9 +617,6 @@ Iniciar sesión con el navegador Cargando hilo Silenciar notificaciones - Orden de lectura - Más antiguas primero - Más recientes primero %1$s ha editado %2$s %1$s ha editado %2$s Desactivado diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index 21301d55f2..738fe98d8c 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -592,8 +592,6 @@ <تنظیم نشده> از کار افتاده <نامعتبر> - ترتیب خواندن - نخست جدیدترین %1$s %2$s را ویراست %1$s %2$s را ایجاد کرد بارگذاری شکست خورد @@ -606,7 +604,6 @@ \n \nارتباط با کارساز برقرار نشد یا فرسته‌ها را رد کرد. در بیش‌تر حالت‌ها کار می‌کند. هیچ داده‌ای به دیگر کاره‌ها نشت نمی‌کند. - نخست قدیمی‌ترین ممکن است از روش‌های تأیید خویت اضافی پشتیبانی کند؛ ولی نیازمند مرورگری پشتیبانی شده است. دور انداختن تغییرات ادامهٔ ویرایش diff --git a/app/src/main/res/values-gd/strings.xml b/app/src/main/res/values-gd/strings.xml index 8031e540ff..642225af8b 100644 --- a/app/src/main/res/values-gd/strings.xml +++ b/app/src/main/res/values-gd/strings.xml @@ -581,9 +581,6 @@ Dh’fhàillig leis an dì-phrìneachadh A bheil thu airson a shàbhaladh ’na dhreachd\? (Thèid na ceanglachain a luchdadh suas a-rithist nuair a dh’aisigeas tu an dreuchd.) A’ luchdadh an t-snàithlein - Òrdugh an leughaidh - As sine an toiseach - As ùire an toiseach A bheil thu airson sgur de #%s a leantainn\? Mùch na brathan Bu chòir do thuairisgeul a bhith aig a’ mheadhan. diff --git a/app/src/main/res/values-gl/strings.xml b/app/src/main/res/values-gl/strings.xml index e6da29a650..c5a6231eaa 100644 --- a/app/src/main/res/values-gl/strings.xml +++ b/app/src/main/res/values-gl/strings.xml @@ -593,9 +593,6 @@ Compartir URL da conta en… Compartir identificador da conta en… Identificador copiado - Orde de lectura - Antigo primeiro - Novidades primeiro <non establecido> Desactivado <non válido> diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index caed3e136d..7b4c1f36a2 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -590,9 +590,6 @@ Nem sikerült letölteni a kiszolgálóról az állapotforrást. Követett hashtagek Szál betöltése - Olvasási sorrend - Régebbi először - Újabb először Értesítések némítása Letiltva <nincs beállítva> diff --git a/app/src/main/res/values-is/strings.xml b/app/src/main/res/values-is/strings.xml index eb754067f7..d93760cb39 100644 --- a/app/src/main/res/values-is/strings.xml +++ b/app/src/main/res/values-is/strings.xml @@ -582,9 +582,6 @@ Gáttin ætti að vera á milli %d og %d Mistókst að hlaða inn uppruna stöðufærslu af netþjóninum. Hleð inn þræði - Lestrarröð - Elsta fyrst - Nýjasta fyrst Þú ert með óvistaðar breytingar. Óvirkt <ekki stillt> diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 2ebc1d91b2..c1725fa9f0 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -619,7 +619,6 @@ Disattivato <non impostato> <non valido> - Più nuovi prima Non è stato possibile caricare il tuo post ed è stato salvato nelle bozze. \n \nNon è stato possibile contattare il server oppure il server ha rifiutato il post. @@ -629,10 +628,8 @@ Mostra bozze Chiudi Condividi nome utente dell\'account a… - Ordine di lettura Funziona nella maggior parte dei casi. Nessun dato è trasferito ad altre app. Potrebbe supportare differenti metodi di autenticazione, ma richiede un browser supportato. - Più vecchi prima Condividi link dell\'account Condividi il nome utente dell\'account Condividi URL account a… diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 48f2ecbb3e..a6d410b5b5 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -580,9 +580,6 @@ アカウントのユーザー名を共有… ユーザー名がコピーされました スレッドの読み込み中 - 新しい順 - 読む順番 - 古い順 サムネイル画像で常に表示される中心点を設定するには、円をタップまたはドラッグして中してくだだい。 通知のミュート %1$s に参加 diff --git a/app/src/main/res/values-lv/strings.xml b/app/src/main/res/values-lv/strings.xml index 96728f8c54..369c0f9b0f 100644 --- a/app/src/main/res/values-lv/strings.xml +++ b/app/src/main/res/values-lv/strings.xml @@ -469,7 +469,6 @@ pārtraukta sekošana #%s %s atcelta slēpšana Nevarēja izveidot sarakstu - Lasīšanas secība Filtrējamā frāze Atspējots <nav iestatīts> @@ -511,8 +510,6 @@ %s balss %s balsis - Vecākos vispirms - Jaunākos vispirms %1$s izveidoja %2$s %s personas diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index d96b7f4a1b..c991a0e974 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -554,9 +554,6 @@ Fout tijdens het volgen van #%s Fout tijdens het ontvolgen van #%s Dit ingeplande bericht verwijderen\? - Leesvolgorde - Oudste eerst - Nieuwste eerst Account toevoegen aan de lijst is mislukt Toevoegen of verwijderen van lijst Kan niet vastmaken diff --git a/app/src/main/res/values-oc/strings.xml b/app/src/main/res/values-oc/strings.xml index 732eb23e69..32b59b3cbd 100644 --- a/app/src/main/res/values-oc/strings.xml +++ b/app/src/main/res/values-oc/strings.xml @@ -597,9 +597,6 @@ Téner de modificar Avètz de modificacions pas salvadas. Cargament del fil - Òrdre de lectura - Mai ancians en primièr - Mai recents primièr Desactivat <pas definit> <invalid> diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index fdb8774c27..1e4dd33b09 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -597,8 +597,6 @@ Obserwowane hashtagi Ładowanie wątku Logując się akceptujesz regulamin %s. - Najpierw najstarsze - Najpierw najnowsze Nie masz żadnych list. Wycisz powiadomienia %1$s edytował %2$s @@ -620,7 +618,6 @@ Inne Edytowano %s Usunąć ten zaplanowany wpis\? - Kolejność czytania <nieustawiony> <niepoprawny> Port powinien być pomiędzy %d a %d diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index cff3d4ef1d..6775a66c89 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -551,7 +551,6 @@ Falha ao carregar detalhes da conta Carregando fio Ao entrar, você concorda com as regras de %s. - Ordem de leitura Toque ou arraste o círculo para escolher o ponto focal que estará sempre visível nas miniaturas. %s (%s) Adicionar ou remover da lista @@ -578,10 +577,8 @@ agora Falha ao enviar Mostrar rascunhos - Mais recentes primeiro Entrou em %1$s Silenciar notificações - Mais antigos primeiro Falha ao adicionar a conta à lista Funciona na maioria dos casos. Nenhum dado é vazado para outros aplicativos. Pode oferecer suporte a métodos de autenticação adicionais, mas requer um navegador compatível. diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index ea124525d5..ca0b1fe20c 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -537,7 +537,6 @@ Запрашивать подтверждение перед добавлением в избранное Убрать из закладок Загрузка ветки - Сначала новые Правки %1$s отредактировали %2$s %1$s создали %2$s diff --git a/app/src/main/res/values-sa/strings.xml b/app/src/main/res/values-sa/strings.xml index 960c443c33..42a32c5fc1 100644 --- a/app/src/main/res/values-sa/strings.xml +++ b/app/src/main/res/values-sa/strings.xml @@ -464,9 +464,6 @@ सकललेखाः दौत्यमाला दृश्यते %s नियमाः - पाठनक्रमः - पुरातनं प्रथमम् - नूतनं प्रथमम् अपरिमितम् केन्द्रबिन्दुं स्थाप्यताम् सूचनाः निशब्दाः करोतु diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 96bf5dbe6d..b80d581d40 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -139,7 +139,7 @@ Vad är en instans? Ansluter… Adressen eller domänen för varje instans kan anges - här, till exempel mastodon.social, icosahedron.website, social.tchncs.de och + här, till exempel mastodon.social, icosahedron.website, social.tchncs.de och mer! \n\nOm du inte har något konto kan du ange namnet på instansen du vill ansluta till och skapa ett konto där. \n\nEn instans är en plats där ditt konto finns, men du kan enkelt kommunicera med och följa andra personer på andra instanser, @@ -281,7 +281,7 @@ Beskriv innehåll för synskadade \n (max %d tecken) - Beskriv innehåll för synskadade + Beskriv innehåll för synskadade \n (max %d tecken) Ange bildtext @@ -625,8 +625,6 @@ \nAntingen kunde inte servern nås eller så har uppladdningen nekats. Stäng Fungerar i de flesta fallen. Ingen information läcker till andra applikationer. - Äldsta först - Nyaste först Följ hashtagg #hashtagg %1$d personer pratar om hashtagg %2$s diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 723f839905..d04e4b8ec5 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -617,9 +617,6 @@ Поділитися URL облікового запису через… Поділитися іменем користувача облікового запису через… Ім\'я користувача скопійовано - Спочатку новіші - Порядок читання - Спочатку давніші Вимкнено <не налаштовано> <недійсний> diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index 1e5be6c851..565cb94a28 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -582,9 +582,6 @@ Chia sẻ tên người này Chia sẻ tên người này… Đã sao chép tên người này - Thứ tự đọc - Cũ nhất trước - Mới nhất trước Tắt <không đặt> <không hợp lệ> diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index d3f47b050c..a41ca27cc7 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -600,12 +600,9 @@ 分享账户链接到… 分享账户用户名到… 已复制用户名 - 阅读顺序 已禁用 <未设置> <无效> - 从新到旧 - 从旧到新 用浏览器登录 多数情况下有效。没有数据泄露给其他应用。 可能支持额外的验证方法但需要受支持的浏览器。 diff --git a/app/src/main/res/values/donottranslate.xml b/app/src/main/res/values/donottranslate.xml index 644bea9e15..3ff808727f 100644 --- a/app/src/main/res/values/donottranslate.xml +++ b/app/src/main/res/values/donottranslate.xml @@ -247,16 +247,6 @@ 604800 - - @string/pref_reading_order_oldest_first - @string/pref_reading_order_newest_first - - - - OLDEST_FIRST - NEWEST_FIRST - - warn hide diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9943d8aa8c..e259325481 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -758,9 +758,6 @@ Mute notifications - Reading order - Oldest first - Newest first %1$s edited %2$s From 4ec02a364dfc3ec3ae71396a2a608887eb097b09 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Fri, 28 Apr 2023 09:37:16 +0200 Subject: [PATCH 054/156] Clear local caches on upgrade --- .../keylesspalace/tusky/TuskyApplication.kt | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt b/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt index 6e66d91619..c7f78836c1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt +++ b/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt @@ -24,6 +24,7 @@ import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkManager import autodispose2.AutoDisposePlugins import com.keylesspalace.tusky.components.timeline.PruneCacheWorker +import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.di.AppInjector import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.settings.SCHEMA_VERSION @@ -36,6 +37,8 @@ import de.c1710.filemojicompat_defaults.DefaultEmojiPackList import de.c1710.filemojicompat_ui.helpers.EmojiPackHelper import de.c1710.filemojicompat_ui.helpers.EmojiPreference import io.reactivex.rxjava3.plugins.RxJavaPlugins +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.launch import org.conscrypt.Conscrypt import java.security.Security import java.util.concurrent.TimeUnit @@ -54,6 +57,9 @@ class TuskyApplication : Application(), HasAndroidInjector { @Inject lateinit var sharedPreferences: SharedPreferences + @Inject + lateinit var db: AppDatabase + override fun onCreate() { // Uncomment me to get StrictMode violation logs // if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) { @@ -130,6 +136,20 @@ class TuskyApplication : Application(), HasAndroidInjector { // Deleting the "Reading order" preference, as the need to "Load more" has been // removed. editor.remove(PrefKeys.DEPRECATED_READING_ORDER) + + // The status and account entity caches may be broken. + // + // 1. In some cases Tusky was missing statuses which couldn't be backfilled + // 2. Old account entities + // + // Remove them. Do this directly, instead of with DAO methods, this functionality + // shouldn't be part of the normal DAO. + db.runInTransaction { + MainScope().launch { + db.query("DELETE FROM TimelineStatusEntity", null) + db.query("DELETE FROM TimelineAccountEntity", null) + } + } } editor.putInt(PrefKeys.SCHEMA_VERSION, newVersion) From deab885d12a76d128a8ca7cbb49a72f78bb91287 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Fri, 28 Apr 2023 10:02:22 +0200 Subject: [PATCH 055/156] Clean up all accounts, not just the active one. --- .../tusky/components/timeline/PruneCacheWorker.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/PruneCacheWorker.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/PruneCacheWorker.kt index 0913e71d87..cac0b63322 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/PruneCacheWorker.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/PruneCacheWorker.kt @@ -35,9 +35,9 @@ class PruneCacheWorker( private val accountManager: AccountManager ) : CoroutineWorker(appContext, workerParams) { override suspend fun doWork(): Result { - accountManager.activeAccount?.id?.let { accountId -> - Log.d(TAG, "Pruning database using account ID: $accountId") - appDatabase.timelineDao().cleanup(accountId, MAX_STATUSES_IN_CACHE) + for (account in accountManager.accounts) { + Log.d(TAG, "Pruning database using account ID: ${account.id}") + appDatabase.timelineDao().cleanup(account.id, MAX_STATUSES_IN_CACHE) } return Result.success() } From b79422938144d93dc5ebc41fc1857ceb3df6ae58 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Thu, 11 May 2023 20:20:43 +0200 Subject: [PATCH 056/156] Scroll the list down a bit of refreshing found new content --- .../components/timeline/TimelineFragment.kt | 57 +++++++++++-------- .../tusky/util/BooleanExtensions.kt | 19 +++++++ 2 files changed, 52 insertions(+), 24 deletions(-) create mode 100644 app/src/main/java/com/keylesspalace/tusky/util/BooleanExtensions.kt diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt index e9dc064915..fef2757aaa 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt @@ -26,6 +26,7 @@ import android.view.ViewGroup import android.view.accessibility.AccessibilityManager import androidx.core.content.ContextCompat import androidx.core.view.MenuProvider +import androidx.core.view.postDelayed import androidx.lifecycle.Lifecycle import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.lifecycleScope @@ -36,6 +37,7 @@ import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.SimpleItemAnimator import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener +import at.connyduck.sparkbutton.helpers.Utils import com.google.android.material.color.MaterialColors import com.google.android.material.snackbar.Snackbar import com.keylesspalace.tusky.BaseActivity @@ -70,10 +72,13 @@ import com.mikepenz.iconics.IconicsDrawable import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial import com.mikepenz.iconics.utils.colorInt import com.mikepenz.iconics.utils.sizeDp +import isTrue import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChangedBy +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.onEach @@ -141,30 +146,6 @@ class TimelineFragment : setupSwipeRefreshLayout() setupRecyclerView() - // TODO: This is the wrong place to do this, since it bumps the list down by 30px - // every time a PREPEND call completes. - // - // The right thing to do is to use the loadstate flow (already used later in the code) - // and if the refresh has finished, and not at position 0, then bump the list down by - // the 30px. - // - // Temporarily disable while investigating other list behaviour. -// adapter.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() { -// override fun onItemRangeInserted(positionStart: Int, itemCount: Int) { -// if (positionStart == 0 && adapter.itemCount != itemCount) { -// binding.recyclerView.post { -// if (getView() != null) { -// if (isSwipeToRefreshEnabled) { -// binding.recyclerView.scrollBy(0, Utils.dpToPx(requireContext(), -30)) -// } else { -// binding.recyclerView.scrollToPosition(0) -// } -// } -// } -// } -// } -// }) - if (actionButtonPresent()) { binding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { override fun onScrolled(view: RecyclerView, dx: Int, dy: Int) { @@ -333,6 +314,34 @@ class TimelineFragment : } } + // Scroll the list down if a refresh has completely finished. A refresh is + // finished when both the initial refresh is complete and any prepends have + // finished (so that DiffUtil has had a chance to process the data). See + // https://github.com/googlecodelabs/android-paging/issues/149 + launch { + if (isSwipeToRefreshEnabled) { + adapter.loadStateFlow + .distinctUntilChanged { old, new -> + old.mediator?.prepend?.endOfPaginationReached.isTrue() && + new.mediator?.prepend?.endOfPaginationReached.isTrue() + } + .filter { + it.refresh is LoadState.NotLoading && it.prepend.endOfPaginationReached && !it.append.endOfPaginationReached + } + .collect { + // This works without the delay if you are repeatedly refreshing a + // single timeline. But if you refresh a timeline (e.g., Local), + // then go to another timeline (e.g., Home), then go back to the + // first timeline and refresh that, it jumps to the top. Adding the + // delay fixes that -- I have no idea why... + binding.recyclerView.postDelayed(300) { + getView() ?: return@postDelayed + binding.recyclerView.scrollBy(0, Utils.dpToPx(requireContext(), -30)) + } + } + } + } + // Update the UI from the combined load state adapter.loadStateFlow .distinctUntilChangedBy { it.refresh } diff --git a/app/src/main/java/com/keylesspalace/tusky/util/BooleanExtensions.kt b/app/src/main/java/com/keylesspalace/tusky/util/BooleanExtensions.kt new file mode 100644 index 0000000000..3ce757e58a --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/BooleanExtensions.kt @@ -0,0 +1,19 @@ +/* + * 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 . + */ + +/** True if the Boolean? is true, false if it is null or false */ +fun Boolean?.isTrue() = this != null && this From 075d3fc5fb81c24a0f558f558d20fa91109b89fa Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Thu, 11 May 2023 22:18:12 +0200 Subject: [PATCH 057/156] Remove tests that expect placeholders to be created or removed --- .../CachedTimelineRemoteMediatorTest.kt | 129 +----------------- .../NetworkTimelineRemoteMediatorTest.kt | 59 -------- .../tusky/components/timeline/StatusMocker.kt | 9 -- .../keylesspalace/tusky/db/TimelineDaoTest.kt | 61 --------- 4 files changed, 2 insertions(+), 256 deletions(-) diff --git a/app/src/test/java/com/keylesspalace/tusky/components/timeline/CachedTimelineRemoteMediatorTest.kt b/app/src/test/java/com/keylesspalace/tusky/components/timeline/CachedTimelineRemoteMediatorTest.kt index 7215041716..19ceb79a0e 100644 --- a/app/src/test/java/com/keylesspalace/tusky/components/timeline/CachedTimelineRemoteMediatorTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/components/timeline/CachedTimelineRemoteMediatorTest.kt @@ -22,7 +22,6 @@ import kotlinx.coroutines.runBlocking import okhttp3.ResponseBody.Companion.toResponseBody import org.junit.After import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @@ -136,69 +135,6 @@ class CachedTimelineRemoteMediatorTest { assertTrue((result as RemoteMediator.MediatorResult.Success).endOfPaginationReached) } - @Test - @ExperimentalPagingApi - fun `should refresh and insert placeholder when a whole page with no overlap to existing statuses is loaded`() { - val statusesAlreadyInDb = listOf( - mockStatusEntityWithAccount("3"), - mockStatusEntityWithAccount("2"), - mockStatusEntityWithAccount("1") - ) - - db.insert(statusesAlreadyInDb) - - val remoteMediator = CachedTimelineRemoteMediator( - accountManager = accountManager, - api = mock { - onBlocking { homeTimeline(limit = 3) } doReturn Response.success( - listOf( - mockStatus("8"), - mockStatus("7"), - mockStatus("5") - ) - ) - onBlocking { homeTimeline(maxId = "3", limit = 3) } doReturn Response.success( - listOf( - mockStatus("3"), - mockStatus("2"), - mockStatus("1") - ) - ) - }, - db = db, - gson = Gson() - ) - - val state = state( - pages = listOf( - PagingSource.LoadResult.Page( - data = statusesAlreadyInDb, - prevKey = null, - nextKey = 0 - ) - ), - pageSize = 3 - ) - - val result = runBlocking { remoteMediator.load(LoadType.REFRESH, state) } - - assertTrue(result is RemoteMediator.MediatorResult.Success) - assertEquals(false, (result as RemoteMediator.MediatorResult.Success).endOfPaginationReached) - - db.assertStatuses( - listOf( - mockStatusEntityWithAccount("8"), - mockStatusEntityWithAccount("7"), - TimelineStatusWithAccount( - status = Placeholder("5", loading = false).toEntity(1) - ), - mockStatusEntityWithAccount("3"), - mockStatusEntityWithAccount("2"), - mockStatusEntityWithAccount("1") - ) - ) - } - @Test @ExperimentalPagingApi fun `should refresh and not insert placeholder when less than a whole page is loaded`() { @@ -411,65 +347,6 @@ class CachedTimelineRemoteMediatorTest { ) } - @Test - @ExperimentalPagingApi - fun `should not remove placeholder in timeline`() { - val statusesAlreadyInDb = listOf( - mockStatusEntityWithAccount("8"), - mockStatusEntityWithAccount("7"), - mockPlaceholderEntityWithAccount("6"), - mockStatusEntityWithAccount("1") - ) - - db.insert(statusesAlreadyInDb) - - val remoteMediator = CachedTimelineRemoteMediator( - accountManager = accountManager, - api = mock { - onBlocking { homeTimeline(sinceId = "6", limit = 20) } doReturn Response.success( - listOf( - mockStatus("9"), - mockStatus("8"), - mockStatus("7") - ) - ) - onBlocking { homeTimeline(maxId = "8", sinceId = "6", limit = 20) } doReturn Response.success( - listOf( - mockStatus("8"), - mockStatus("7") - ) - ) - }, - db = db, - gson = Gson() - ) - - val state = state( - listOf( - PagingSource.LoadResult.Page( - data = statusesAlreadyInDb, - prevKey = null, - nextKey = 0 - ) - ) - ) - - val result = runBlocking { remoteMediator.load(LoadType.REFRESH, state) } - - assertTrue(result is RemoteMediator.MediatorResult.Success) - assertFalse((result as RemoteMediator.MediatorResult.Success).endOfPaginationReached) - - db.assertStatuses( - listOf( - mockStatusEntityWithAccount("9"), - mockStatusEntityWithAccount("8"), - mockStatusEntityWithAccount("7"), - mockPlaceholderEntityWithAccount("6"), - mockStatusEntityWithAccount("1") - ) - ) - } - @Test @ExperimentalPagingApi fun `should append statuses`() { @@ -564,10 +441,8 @@ class CachedTimelineRemoteMediatorTest { for ((exp, prov) in expected.zip(loadedStatuses)) { assertEquals(exp.status, prov.status) - if (!exp.status.isPlaceholder) { - assertEquals(exp.account, prov.account) - assertEquals(exp.reblogAccount, prov.reblogAccount) - } + assertEquals(exp.account, prov.account) + assertEquals(exp.reblogAccount, prov.reblogAccount) } } } diff --git a/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRemoteMediatorTest.kt b/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRemoteMediatorTest.kt index c8a56fe37b..e8d39951cc 100644 --- a/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRemoteMediatorTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRemoteMediatorTest.kt @@ -200,65 +200,6 @@ class NetworkTimelineRemoteMediatorTest { assertEquals(newStatusData, statuses) } - @Test - @ExperimentalPagingApi - fun `should refresh and insert placeholder`() { - val statuses: MutableList = mutableListOf( - mockStatusViewData("3"), - mockStatusViewData("2"), - mockStatusViewData("1") - ) - - val timelineViewModel: NetworkTimelineViewModel = mock { - on { statusData } doReturn statuses - on { nextKey } doReturn "0" - onBlocking { fetchStatusesForKind(null, null, 20) } doReturn Response.success( - listOf( - mockStatus("10"), - mockStatus("9"), - mockStatus("7") - ) - ) - } - - val remoteMediator = NetworkTimelineRemoteMediator( - accountManager, - timelineViewModel, - factory!!, - statusData, - kind - ) - - val state = state( - listOf( - PagingSource.LoadResult.Page( - data = listOf( - mockStatusViewData("3"), - mockStatusViewData("2"), - mockStatusViewData("1") - ), - prevKey = null, - nextKey = "0" - ) - ) - ) - - val result = runBlocking { remoteMediator.load(LoadType.REFRESH, state) } - - val newStatusData = mutableListOf( - mockStatusViewData("10"), - mockStatusViewData("9"), - StatusViewData.Placeholder("7", false), - mockStatusViewData("3"), - mockStatusViewData("2"), - mockStatusViewData("1") - ) - - assertTrue(result is RemoteMediator.MediatorResult.Success) - assertEquals(false, (result as RemoteMediator.MediatorResult.Success).endOfPaginationReached) - assertEquals(newStatusData, statuses) - } - @Test @ExperimentalPagingApi fun `should refresh and not insert placeholders`() { diff --git a/app/src/test/java/com/keylesspalace/tusky/components/timeline/StatusMocker.kt b/app/src/test/java/com/keylesspalace/tusky/components/timeline/StatusMocker.kt index 4b9d497bd2..133e895f7f 100644 --- a/app/src/test/java/com/keylesspalace/tusky/components/timeline/StatusMocker.kt +++ b/app/src/test/java/com/keylesspalace/tusky/components/timeline/StatusMocker.kt @@ -107,12 +107,3 @@ fun mockStatusEntityWithAccount( ) ) } - -fun mockPlaceholderEntityWithAccount( - id: String, - userId: Long = 1 -): TimelineStatusWithAccount { - return TimelineStatusWithAccount( - status = Placeholder(id, false).toEntity(userId) - ) -} diff --git a/app/src/test/java/com/keylesspalace/tusky/db/TimelineDaoTest.kt b/app/src/test/java/com/keylesspalace/tusky/db/TimelineDaoTest.kt index ca3bcfa4ee..71d5cc9e49 100644 --- a/app/src/test/java/com/keylesspalace/tusky/db/TimelineDaoTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/db/TimelineDaoTest.kt @@ -5,8 +5,6 @@ import androidx.room.Room import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import com.google.gson.Gson -import com.keylesspalace.tusky.components.timeline.Placeholder -import com.keylesspalace.tusky.components.timeline.toEntity import com.keylesspalace.tusky.entity.Status import kotlinx.coroutines.runBlocking import org.junit.After @@ -312,57 +310,6 @@ class TimelineDaoTest { assertEquals("33", timelineDao.getTopId(1)) } - @Test - fun `should return correct placeholderId after other ids`() = runBlocking { - val statusData = listOf( - makeStatus(statusId = 1000), - makePlaceholder(id = 99), - makeStatus(statusId = 97), - makeStatus(statusId = 95), - makePlaceholder(id = 94), - makeStatus(statusId = 90) - ) - - for ((status, author, reblogAuthor) in statusData) { - author?.let { - timelineDao.insertAccount(it) - } - reblogAuthor?.let { - timelineDao.insertAccount(it) - } - timelineDao.insertStatus(status) - } - - assertEquals("99", timelineDao.getNextPlaceholderIdAfter(1, "1000")) - assertEquals("94", timelineDao.getNextPlaceholderIdAfter(1, "99")) - assertNull(timelineDao.getNextPlaceholderIdAfter(1, "90")) - } - - @Test - fun `should return correct top placeholderId`() = runBlocking { - val statusData = listOf( - makeStatus(statusId = 1000), - makePlaceholder(id = 99), - makeStatus(statusId = 97), - makePlaceholder(id = 96), - makeStatus(statusId = 90), - makePlaceholder(id = 80), - makeStatus(statusId = 77) - ) - - for ((status, author, reblogAuthor) in statusData) { - author?.let { - timelineDao.insertAccount(it) - } - reblogAuthor?.let { - timelineDao.insertAccount(it) - } - timelineDao.insertStatus(status) - } - - assertEquals("99", timelineDao.getTopPlaceholderId(1)) - } - @Test fun `preview card survives roundtrip`() = runBlocking { val setOne = makeStatus(statusId = 3, cardUrl = "https://foo.bar") @@ -466,14 +413,6 @@ class TimelineDaoTest { return Triple(status, author, reblogAuthor) } - private fun makePlaceholder( - accountId: Long = 1, - id: Long - ): Triple { - val placeholder = Placeholder(id.toString(), false).toEntity(accountId) - return Triple(placeholder, null, null) - } - private fun assertStatuses( expected: List>, provided: List From 13215355e80181da670a02f0103bece17bb56b45 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Thu, 11 May 2023 23:14:09 +0200 Subject: [PATCH 058/156] Wrap a debug block in BuildConfig.DEBUG check --- .../viewmodel/NetworkTimelinePagingSource.kt | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelinePagingSource.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelinePagingSource.kt index bfb9a3f093..fb9b594567 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelinePagingSource.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelinePagingSource.kt @@ -18,6 +18,7 @@ package com.keylesspalace.tusky.components.timeline.viewmodel import android.util.Log import androidx.paging.PagingSource import androidx.paging.PagingState +import com.keylesspalace.tusky.BuildConfig import com.keylesspalace.tusky.components.timeline.Page import com.keylesspalace.tusky.entity.Status import java.util.TreeMap @@ -31,13 +32,18 @@ class NetworkTimelinePagingSource @Inject constructor( override suspend fun load(params: LoadParams): LoadResult { Log.d(TAG, "load() with ${params.javaClass.simpleName} for key: ${params.key}") - synchronized(pages) { - Log.d(TAG, "Pages state:") - if (pages.isEmpty()) { - Log.d(TAG, " **empty**") - } else { - pages.onEachIndexed { i, entry -> - Log.d(TAG, " $i: k: ${entry.key}, prev: ${entry.value.prevKey}, next: ${entry.value.nextKey}") + if (BuildConfig.DEBUG) { + synchronized(pages) { + Log.d(TAG, "Pages state:") + if (pages.isEmpty()) { + Log.d(TAG, " **empty**") + } else { + pages.onEachIndexed { i, entry -> + Log.d( + TAG, + " $i: k: ${entry.key}, prev: ${entry.value.prevKey}, next: ${entry.value.nextKey}" + ) + } } } } From b714aa4107c29b6cd37b8ccf118cdc12298c6aa2 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Thu, 11 May 2023 23:19:11 +0200 Subject: [PATCH 059/156] Update tests for NetworkTimelinePagingSource --- .../timeline/NetworkTimelineRepository.kt | 7 +- .../NetworkTimelinePagingSourceTest.kt | 134 ++++++++++++------ 2 files changed, 97 insertions(+), 44 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRepository.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRepository.kt index 0a6f4fc80a..d86514c18a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRepository.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRepository.kt @@ -34,6 +34,7 @@ import kotlinx.coroutines.flow.Flow import java.util.TreeMap import javax.inject.Inject +/** A page of data from the Mastodon API */ data class Page constructor( /** Loaded data */ val data: MutableList, @@ -41,11 +42,11 @@ data class Page constructor( * [Key] for previous page if more data can be loaded in that direction, `null` * otherwise. */ - val prevKey: Key?, + val prevKey: Key? = null, /** * [Key] for next page if more data can be loaded in that direction, `null` otherwise. */ - val nextKey: Key? + val nextKey: Key? = null, ) /** Timeline repository where the timeline information is backed by an in-memory cache. */ @@ -56,7 +57,7 @@ class NetworkTimelineRepository @Inject constructor( ) { /** - * Pages of statuses. + * Cached pages of statuses. * * Each page is keyed by the ID of the first status in that page, and stores the tokens * use as `max_id` and `min_id` parameters in API calls to fetch pages before/after this diff --git a/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelinePagingSourceTest.kt b/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelinePagingSourceTest.kt index 7a30c23099..f97185f699 100644 --- a/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelinePagingSourceTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelinePagingSourceTest.kt @@ -1,64 +1,116 @@ package com.keylesspalace.tusky.components.timeline import androidx.paging.PagingSource +import androidx.paging.PagingSource.LoadResult import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat import com.keylesspalace.tusky.components.timeline.viewmodel.NetworkTimelinePagingSource -import com.keylesspalace.tusky.components.timeline.viewmodel.NetworkTimelineViewModel -import kotlinx.coroutines.runBlocking -import org.junit.Assert.assertEquals +import com.keylesspalace.tusky.entity.Status +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest import org.junit.Test import org.junit.runner.RunWith -import org.mockito.kotlin.doReturn -import org.mockito.kotlin.mock import org.robolectric.annotation.Config +import java.util.TreeMap @Config(sdk = [28]) @RunWith(AndroidJUnit4::class) +@OptIn(ExperimentalCoroutinesApi::class) class NetworkTimelinePagingSourceTest { - - private val status = mockStatusViewData() - - private val timelineViewModel: NetworkTimelineViewModel = mock { - on { statusData } doReturn mutableListOf(status) + @Test + fun `load() with empty pages returns empty list`() = runTest { + // Given + val pages = TreeMap>() + val pagingSource = NetworkTimelinePagingSource(pages) + val loadingParams = PagingSource.LoadParams.Refresh("0", 2, false) + + // When + val loadResult = pagingSource.load(loadingParams) + + // Then + assertThat(loadResult).isInstanceOf(LoadResult.Page::class.java) + assertThat((loadResult as? LoadResult.Page)) + .isEqualTo( + LoadResult.Page( + data = emptyList(), + prevKey = null, + nextKey = null + ) + ) } @Test - fun `should return empty list when params are Append`() { - val pagingSource = NetworkTimelinePagingSource(statusData) - - val params = PagingSource.LoadParams.Append("132", 20, false) - - val expectedResult = PagingSource.LoadResult.Page(emptyList(), null, null) - - runBlocking { - assertEquals(expectedResult, pagingSource.load(params)) - } + fun `load() for an item in a page returns the page containing that item and next, prev keys`() = runTest { + // Given + val pages = TreeMap>() + pages["2"] = Page(data = mutableListOf(mockStatus(id = "2")), nextKey = "1") + pages["1"] = Page(data = mutableListOf(mockStatus(id = "1")), nextKey = "0", prevKey = "2") + pages["0"] = Page(data = mutableListOf(mockStatus(id = "0")), prevKey = "1") + val pagingSource = NetworkTimelinePagingSource(pages) + val loadingParams = PagingSource.LoadParams.Refresh("1", 2, false) + + // When + val loadResult = pagingSource.load(loadingParams) + + // Then + assertThat(loadResult).isInstanceOf(LoadResult.Page::class.java) + assertThat((loadResult as? LoadResult.Page)) + .isEqualTo( + LoadResult.Page( + data = listOf(mockStatus(id = "1")), + prevKey = "2", + nextKey = "0" + ) + ) } @Test - fun `should return empty list when params are Prepend`() { - val pagingSource = NetworkTimelinePagingSource(statusData) - - val params = PagingSource.LoadParams.Prepend("132", 20, false) - - val expectedResult = PagingSource.LoadResult.Page(emptyList(), null, null) - - runBlocking { - assertEquals(expectedResult, pagingSource.load(params)) - } + fun `LoadParams Append returns the page after`() = runTest { + // Given + val pages = TreeMap>() + pages["2"] = Page(data = mutableListOf(mockStatus(id = "2")), nextKey = "1") + pages["1"] = Page(data = mutableListOf(mockStatus(id = "1")), nextKey = "0", prevKey = "2") + pages["0"] = Page(data = mutableListOf(mockStatus(id = "0")), prevKey = "1") + val pagingSource = NetworkTimelinePagingSource(pages) + val loadingParams = PagingSource.LoadParams.Append("1", 2, false) + + // When + val loadResult = pagingSource.load(loadingParams) + + // Then + assertThat(loadResult).isInstanceOf(LoadResult.Page::class.java) + assertThat((loadResult as? LoadResult.Page)) + .isEqualTo( + LoadResult.Page( + data = listOf(mockStatus(id = "0")), + prevKey = "1", + nextKey = null + ) + ) } @Test - fun `should return full list when params are Refresh`() { - val pagingSource = NetworkTimelinePagingSource(statusData) - - val params = PagingSource.LoadParams.Refresh(null, 20, false) - - val expectedResult = PagingSource.LoadResult.Page(listOf(status), null, null) - - runBlocking { - val result = pagingSource.load(params) - assertEquals(expectedResult, result) - } + fun `LoadParams Prepend returns the page before`() = runTest { + // Given + val pages = TreeMap>() + pages["2"] = Page(data = mutableListOf(mockStatus(id = "2")), nextKey = "1") + pages["1"] = Page(data = mutableListOf(mockStatus(id = "1")), nextKey = "0", prevKey = "2") + pages["0"] = Page(data = mutableListOf(mockStatus(id = "0")), prevKey = "1") + val pagingSource = NetworkTimelinePagingSource(pages) + val loadingParams = PagingSource.LoadParams.Prepend("1", 2, false) + + // When + val loadResult = pagingSource.load(loadingParams) + + // Then + assertThat(loadResult).isInstanceOf(LoadResult.Page::class.java) + assertThat((loadResult as? LoadResult.Page)) + .isEqualTo( + LoadResult.Page( + data = listOf(mockStatus(id = "2")), + prevKey = null, + nextKey = "1" + ) + ) } } From e1e8fdcbfb8218290f67830369159c3b43f94d19 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Fri, 12 May 2023 12:28:29 +0200 Subject: [PATCH 060/156] Add a comparator to the page cache - Ensures that status IDs (used as keys) are sorted correctly. - Update tests to use it - Add tests for gaps in the page cache - Add tests for fetching stuff outside the page cache --- .../timeline/NetworkTimelineRepository.kt | 12 +- .../NetworkTimelinePagingSourceTest.kt | 148 ++++++++++++++++-- 2 files changed, 144 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRepository.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRepository.kt index d86514c18a..41e8020f91 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRepository.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRepository.kt @@ -18,6 +18,7 @@ package com.keylesspalace.tusky.components.timeline import android.util.Log +import androidx.annotation.VisibleForTesting import androidx.paging.ExperimentalPagingApi import androidx.paging.InvalidatingPagingSourceFactory import androidx.paging.Pager @@ -71,7 +72,7 @@ class NetworkTimelineRepository @Inject constructor( // status IDs (e.g., the next/prev keys returned by the "favourites" API call *do not match* // status IDs elsewhere). The tokens are discovered by the RemoteMediator but are used by the // PagingSource, so they need to be available somewhere both components can access them. - private val pages = TreeMap>() + private val pages = makeEmptyPageCache() private var factory: InvalidatingPagingSourceFactory? = null @@ -173,5 +174,14 @@ class NetworkTimelineRepository @Inject constructor( companion object { private const val TAG = "NetworkTimelineRepository" private const val PAGE_SIZE = 30 + + /** + * Creates an empty page cache with a comparator that ensures keys are compared first + * by length, then by natural order. + * + * The map key is the ID of the oldest status in the page. + */ + @VisibleForTesting + fun makeEmptyPageCache() = TreeMap>(compareBy({ it.length }, { it })) } } diff --git a/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelinePagingSourceTest.kt b/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelinePagingSourceTest.kt index f97185f699..34a294b87f 100644 --- a/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelinePagingSourceTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelinePagingSourceTest.kt @@ -4,6 +4,7 @@ import androidx.paging.PagingSource import androidx.paging.PagingSource.LoadResult import androidx.test.ext.junit.runners.AndroidJUnit4 import com.google.common.truth.Truth.assertThat +import com.keylesspalace.tusky.components.timeline.NetworkTimelineRepository.Companion.makeEmptyPageCache import com.keylesspalace.tusky.components.timeline.viewmodel.NetworkTimelinePagingSource import com.keylesspalace.tusky.entity.Status import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -11,7 +12,6 @@ import kotlinx.coroutines.test.runTest import org.junit.Test import org.junit.runner.RunWith import org.robolectric.annotation.Config -import java.util.TreeMap @Config(sdk = [28]) @RunWith(AndroidJUnit4::class) @@ -20,12 +20,11 @@ class NetworkTimelinePagingSourceTest { @Test fun `load() with empty pages returns empty list`() = runTest { // Given - val pages = TreeMap>() + val pages = makeEmptyPageCache() val pagingSource = NetworkTimelinePagingSource(pages) - val loadingParams = PagingSource.LoadParams.Refresh("0", 2, false) // When - val loadResult = pagingSource.load(loadingParams) + val loadResult = pagingSource.load(PagingSource.LoadParams.Refresh("0", 2, false)) // Then assertThat(loadResult).isInstanceOf(LoadResult.Page::class.java) @@ -42,15 +41,14 @@ class NetworkTimelinePagingSourceTest { @Test fun `load() for an item in a page returns the page containing that item and next, prev keys`() = runTest { // Given - val pages = TreeMap>() + val pages = makeEmptyPageCache() pages["2"] = Page(data = mutableListOf(mockStatus(id = "2")), nextKey = "1") pages["1"] = Page(data = mutableListOf(mockStatus(id = "1")), nextKey = "0", prevKey = "2") pages["0"] = Page(data = mutableListOf(mockStatus(id = "0")), prevKey = "1") val pagingSource = NetworkTimelinePagingSource(pages) - val loadingParams = PagingSource.LoadParams.Refresh("1", 2, false) // When - val loadResult = pagingSource.load(loadingParams) + val loadResult = pagingSource.load(PagingSource.LoadParams.Refresh("1", 2, false)) // Then assertThat(loadResult).isInstanceOf(LoadResult.Page::class.java) @@ -65,17 +63,16 @@ class NetworkTimelinePagingSourceTest { } @Test - fun `LoadParams Append returns the page after`() = runTest { + fun `Append returns the page after`() = runTest { // Given - val pages = TreeMap>() + val pages = makeEmptyPageCache() pages["2"] = Page(data = mutableListOf(mockStatus(id = "2")), nextKey = "1") pages["1"] = Page(data = mutableListOf(mockStatus(id = "1")), nextKey = "0", prevKey = "2") pages["0"] = Page(data = mutableListOf(mockStatus(id = "0")), prevKey = "1") val pagingSource = NetworkTimelinePagingSource(pages) - val loadingParams = PagingSource.LoadParams.Append("1", 2, false) // When - val loadResult = pagingSource.load(loadingParams) + val loadResult = pagingSource.load(PagingSource.LoadParams.Append("1", 2, false)) // Then assertThat(loadResult).isInstanceOf(LoadResult.Page::class.java) @@ -90,17 +87,16 @@ class NetworkTimelinePagingSourceTest { } @Test - fun `LoadParams Prepend returns the page before`() = runTest { + fun `Prepend returns the page before`() = runTest { // Given - val pages = TreeMap>() + val pages = makeEmptyPageCache() pages["2"] = Page(data = mutableListOf(mockStatus(id = "2")), nextKey = "1") pages["1"] = Page(data = mutableListOf(mockStatus(id = "1")), nextKey = "0", prevKey = "2") pages["0"] = Page(data = mutableListOf(mockStatus(id = "0")), prevKey = "1") val pagingSource = NetworkTimelinePagingSource(pages) - val loadingParams = PagingSource.LoadParams.Prepend("1", 2, false) // When - val loadResult = pagingSource.load(loadingParams) + val loadResult = pagingSource.load(PagingSource.LoadParams.Prepend("1", 2, false)) // Then assertThat(loadResult).isInstanceOf(LoadResult.Page::class.java) @@ -113,4 +109,126 @@ class NetworkTimelinePagingSourceTest { ) ) } + + @Test + fun `Refresh with null key returns the latest page`() = runTest { + // Given + val pages = makeEmptyPageCache() + pages["2"] = Page(data = mutableListOf(mockStatus(id = "2")), nextKey = "1") + pages["1"] = Page(data = mutableListOf(mockStatus(id = "1")), nextKey = "0", prevKey = "2") + pages["0"] = Page(data = mutableListOf(mockStatus(id = "0")), prevKey = "1") + val pagingSource = NetworkTimelinePagingSource(pages) + + // When + val loadResult = pagingSource.load(PagingSource.LoadParams.Refresh(null, 2, false)) + + // Then + assertThat(loadResult).isInstanceOf(LoadResult.Page::class.java) + assertThat((loadResult as? LoadResult.Page)) + .isEqualTo( + LoadResult.Page( + data = listOf(mockStatus(id = "2")), + prevKey = null, + nextKey = "1" + ) + ) + } + + @Test + fun `Append with a gap returns the page after`() = runTest { + // Given + val pages = makeEmptyPageCache() + pages["20"] = Page(data = mutableListOf(mockStatus(id = "20")), nextKey = "10") + pages["10"] = Page(data = mutableListOf(mockStatus(id = "10")), nextKey = "0", prevKey = "20") + pages["0"] = Page(data = mutableListOf(mockStatus(id = "0")), prevKey = "10") + val pagingSource = NetworkTimelinePagingSource(pages) + + // When + val loadResult = pagingSource.load(PagingSource.LoadParams.Append("9", 2, false)) + + // Then + assertThat(loadResult).isInstanceOf(LoadResult.Page::class.java) + assertThat((loadResult as? LoadResult.Page)) + .isEqualTo( + LoadResult.Page( + // key="9" should return the statuses in page 0 + data = listOf(mockStatus(id = "0")), + prevKey = "10", + nextKey = null + ) + ) + } + + @Test + fun `Append with a too-old key returns empty list`() = runTest { + // Given + val pages = makeEmptyPageCache() + pages["20"] = Page(data = mutableListOf(mockStatus(id = "20")), nextKey = "10") + pages["10"] = Page(data = mutableListOf(mockStatus(id = "10")), prevKey = "20") + val pagingSource = NetworkTimelinePagingSource(pages) + + // When + val loadResult = pagingSource.load(PagingSource.LoadParams.Append("9", 2, false)) + + // Then + assertThat(loadResult).isInstanceOf(LoadResult.Page::class.java) + assertThat((loadResult as? LoadResult.Page)) + .isEqualTo( + LoadResult.Page( + // No page contains key="9" (oldest is key="10"), so empty list + data = emptyList(), + prevKey = null, + nextKey = null + ) + ) + } + + @Test + fun `Prepend with a gap returns the page after`() = runTest { + // Given + val pages = makeEmptyPageCache() + pages["20"] = Page(data = mutableListOf(mockStatus(id = "20")), nextKey = "10") + pages["10"] = Page(data = mutableListOf(mockStatus(id = "10")), nextKey = "0", prevKey = "20") + pages["0"] = Page(data = mutableListOf(mockStatus(id = "0")), prevKey = "10") + val pagingSource = NetworkTimelinePagingSource(pages) + + // When + val loadResult = pagingSource.load(PagingSource.LoadParams.Prepend("11", 2, false)) + + // Then + assertThat(loadResult).isInstanceOf(LoadResult.Page::class.java) + assertThat((loadResult as? LoadResult.Page)) + .isEqualTo( + LoadResult.Page( + // key="9" should return the statuses in page 0 + data = listOf(mockStatus(id = "20")), + prevKey = null, + nextKey = "10" + ) + ) + } + + @Test + fun `Prepend with a too-new key returns empty list`() = runTest { + // Given + val pages = makeEmptyPageCache() + pages["20"] = Page(data = mutableListOf(mockStatus(id = "20")), nextKey = "10") + pages["10"] = Page(data = mutableListOf(mockStatus(id = "10")), prevKey = "20") + val pagingSource = NetworkTimelinePagingSource(pages) + + // When + val loadResult = pagingSource.load(PagingSource.LoadParams.Prepend("20", 2, false)) + + // Then + assertThat(loadResult).isInstanceOf(LoadResult.Page::class.java) + assertThat((loadResult as? LoadResult.Page)) + .isEqualTo( + LoadResult.Page( + // No page contains key="9" (oldest is key="10"), so empty list + data = emptyList(), + prevKey = null, + nextKey = null + ) + ) + } } From 6a94592aab885e96a8ebf0c82e0d53202544959b Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Fri, 12 May 2023 12:49:50 +0200 Subject: [PATCH 061/156] Update CachedTimelineRemoteMediatorTest - Use runTest instead of runBlocking - Account for different API calls in new remote mediator code - Remove bogus "endOfPaginationReached" check --- .../CachedTimelineRemoteMediatorTest.kt | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/app/src/test/java/com/keylesspalace/tusky/components/timeline/CachedTimelineRemoteMediatorTest.kt b/app/src/test/java/com/keylesspalace/tusky/components/timeline/CachedTimelineRemoteMediatorTest.kt index 19ceb79a0e..03bcbea786 100644 --- a/app/src/test/java/com/keylesspalace/tusky/components/timeline/CachedTimelineRemoteMediatorTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/components/timeline/CachedTimelineRemoteMediatorTest.kt @@ -10,6 +10,7 @@ import androidx.paging.RemoteMediator import androidx.room.Room import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry +import com.google.common.truth.Truth.assertThat import com.google.gson.Gson import com.keylesspalace.tusky.components.timeline.viewmodel.CachedTimelineRemoteMediator import com.keylesspalace.tusky.db.AccountEntity @@ -19,6 +20,7 @@ import com.keylesspalace.tusky.db.Converters import com.keylesspalace.tusky.db.TimelineStatusWithAccount import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest import okhttp3.ResponseBody.Companion.toResponseBody import org.junit.After import org.junit.Assert.assertEquals @@ -297,9 +299,11 @@ class CachedTimelineRemoteMediatorTest { ) } + @OptIn(ExperimentalCoroutinesApi::class) @Test @ExperimentalPagingApi - fun `should remove deleted status from db and keep state of other cached statuses`() { + fun `should remove deleted status from db and keep state of other cached statuses`() = runTest { + // Given val statusesAlreadyInDb = listOf( mockStatusEntityWithAccount("3", expanded = true), mockStatusEntityWithAccount("2"), @@ -311,9 +315,7 @@ class CachedTimelineRemoteMediatorTest { val remoteMediator = CachedTimelineRemoteMediator( accountManager = accountManager, api = mock { - onBlocking { homeTimeline(limit = 20) } doReturn Response.success(emptyList()) - - onBlocking { homeTimeline(maxId = "3", limit = 20) } doReturn Response.success( + onBlocking { homeTimeline(limit = 20) } doReturn Response.success( listOf( mockStatus("3"), mockStatus("1") @@ -334,13 +336,16 @@ class CachedTimelineRemoteMediatorTest { ) ) - val result = runBlocking { remoteMediator.load(LoadType.REFRESH, state) } + // When + val result = remoteMediator.load(LoadType.REFRESH, state) - assertTrue(result is RemoteMediator.MediatorResult.Success) - assertTrue((result as RemoteMediator.MediatorResult.Success).endOfPaginationReached) + // Then + assertThat(result).isInstanceOf(RemoteMediator.MediatorResult.Success::class.java) db.assertStatuses( listOf( + // id="2" was in the database initially, but not in the results returned + // from the API, so it should have been deleted here. mockStatusEntityWithAccount("3", expanded = true), mockStatusEntityWithAccount("1", expanded = false) ) From a56520ebab257d6f041a82dc383080c582088fdd Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Fri, 12 May 2023 15:53:23 +0200 Subject: [PATCH 062/156] Update NetworkTimelineRemoteMediator tests --- .../timeline/NetworkTimelineRepository.kt | 2 +- .../NetworkTimelineRemoteMediator.kt | 2 +- .../NetworkTimelineRemoteMediatorTest.kt | 429 +++++++----------- 3 files changed, 169 insertions(+), 264 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRepository.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRepository.kt index 41e8020f91..37fc05a603 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRepository.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRepository.kt @@ -179,7 +179,7 @@ class NetworkTimelineRepository @Inject constructor( * Creates an empty page cache with a comparator that ensures keys are compared first * by length, then by natural order. * - * The map key is the ID of the oldest status in the page. + * The map key is the ID of the newest status in the page it maps to. */ @VisibleForTesting fun makeEmptyPageCache() = TreeMap>(compareBy({ it.length }, { it })) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt index fed8408bfd..b7e7f3e17a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt @@ -115,7 +115,7 @@ class NetworkTimelineRemoteMediator( private suspend fun fetchStatusPageByKind(loadType: LoadType, key: String?, loadSize: Int): Response> { val (maxId, minId) = when (loadType) { // When refreshing fetch a page of statuses that are immediately *newer* than the key - // This is so that that user's reading position is not lost. + // This is so that the user's reading position is not lost. LoadType.REFRESH -> Pair(null, key) // When appending fetch a page of statuses that are immediately *older* than the key LoadType.APPEND -> Pair(key, null) diff --git a/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRemoteMediatorTest.kt b/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRemoteMediatorTest.kt index e8d39951cc..65f969da8f 100644 --- a/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRemoteMediatorTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRemoteMediatorTest.kt @@ -1,38 +1,39 @@ package com.keylesspalace.tusky.components.timeline import androidx.paging.ExperimentalPagingApi +import androidx.paging.InvalidatingPagingSourceFactory import androidx.paging.LoadType import androidx.paging.PagingConfig import androidx.paging.PagingSource import androidx.paging.PagingState import androidx.paging.RemoteMediator import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import com.keylesspalace.tusky.components.timeline.NetworkTimelineRepository.Companion.makeEmptyPageCache import com.keylesspalace.tusky.components.timeline.viewmodel.NetworkTimelineRemoteMediator -import com.keylesspalace.tusky.components.timeline.viewmodel.NetworkTimelineViewModel import com.keylesspalace.tusky.db.AccountEntity import com.keylesspalace.tusky.db.AccountManager -import com.keylesspalace.tusky.viewdata.StatusViewData -import kotlinx.coroutines.runBlocking +import com.keylesspalace.tusky.entity.Status +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest import okhttp3.Headers import okhttp3.ResponseBody.Companion.toResponseBody -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue +import okio.IOException +import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.doReturn -import org.mockito.kotlin.doThrow import org.mockito.kotlin.mock import org.mockito.kotlin.verify import org.robolectric.annotation.Config import retrofit2.HttpException import retrofit2.Response -import java.io.IOException @Config(sdk = [29]) @RunWith(AndroidJUnit4::class) +@OptIn(ExperimentalCoroutinesApi::class) class NetworkTimelineRemoteMediatorTest { - private val accountManager: AccountManager = mock { on { activeAccount } doReturn AccountEntity( id = 1, @@ -44,78 +45,74 @@ class NetworkTimelineRemoteMediatorTest { ) } + private lateinit var pagingSourceFactory: InvalidatingPagingSourceFactory + + @Before + fun setup() { + pagingSourceFactory = mock() + } + @Test @ExperimentalPagingApi - fun `should return error when network call returns error code`() { - val timelineViewModel: NetworkTimelineViewModel = mock { - on { statusData } doReturn mutableListOf() - onBlocking { fetchStatusesForKind(anyOrNull(), anyOrNull(), anyOrNull()) } doReturn Response.error(500, "".toResponseBody()) - } - + fun `should return error when network call returns error code`() = runTest { + // Given val remoteMediator = NetworkTimelineRemoteMediator( - accountManager, - timelineViewModel, - factory!!, - statusData, - kind + api = mock(defaultAnswer = { Response.error(500, "".toResponseBody()) }), + accountManager = accountManager, + factory = pagingSourceFactory, + pages = makeEmptyPageCache(), + timelineKind = TimelineKind.Home ) - val result = runBlocking { remoteMediator.load(LoadType.REFRESH, state()) } + // When + val result = remoteMediator.load(LoadType.REFRESH, state()) - assertTrue(result is RemoteMediator.MediatorResult.Error) - assertTrue((result as RemoteMediator.MediatorResult.Error).throwable is HttpException) - assertEquals(500, (result.throwable as HttpException).code()) + // Then + assertThat(result).isInstanceOf(RemoteMediator.MediatorResult.Error::class.java) + assertThat((result as RemoteMediator.MediatorResult.Error).throwable).isInstanceOf(HttpException::class.java) + assertThat((result.throwable as HttpException).code()).isEqualTo(500) } @Test @ExperimentalPagingApi - fun `should return error when network call fails`() { - val timelineViewModel: NetworkTimelineViewModel = mock { - on { statusData } doReturn mutableListOf() - onBlocking { fetchStatusesForKind(anyOrNull(), anyOrNull(), anyOrNull()) } doThrow IOException() - } - + fun `should return error when network call fails`() = runTest { + // Given val remoteMediator = NetworkTimelineRemoteMediator( + api = mock(defaultAnswer = { throw IOException() } ), accountManager, - timelineViewModel, - factory!!, - statusData, - kind + factory = pagingSourceFactory, + pages = makeEmptyPageCache(), + timelineKind = TimelineKind.Home ) - val result = runBlocking { remoteMediator.load(LoadType.REFRESH, state()) } + // When + val result = remoteMediator.load(LoadType.REFRESH, state()) - assertTrue(result is RemoteMediator.MediatorResult.Error) - assertTrue((result as RemoteMediator.MediatorResult.Error).throwable is IOException) + // Then + assertThat(result).isInstanceOf(RemoteMediator.MediatorResult.Error::class.java) + assertThat((result as RemoteMediator.MediatorResult.Error).throwable).isInstanceOf(IOException::class.java) } + @Test @ExperimentalPagingApi - fun `should do initial loading`() { - val statuses: MutableList = mutableListOf() - - val timelineViewModel: NetworkTimelineViewModel = mock { - on { statusData } doReturn statuses - on { nextKey } doReturn null - onBlocking { fetchStatusesForKind(null, null, 20) } doReturn Response.success( - listOf( - mockStatus("7"), - mockStatus("6"), - mockStatus("5") - ), - Headers.headersOf( - "Link", - "; rel=\"next\", ; rel=\"prev\"" - ) - ) - } - + fun `should do initial loading`() = runTest { + // Given + val pages = makeEmptyPageCache() val remoteMediator = NetworkTimelineRemoteMediator( - accountManager, - timelineViewModel, - factory!!, - statusData, - kind + api = mock { + onBlocking { homeTimeline(maxId = anyOrNull(), minId = anyOrNull(), limit = anyOrNull(), sinceId = anyOrNull()) } doReturn Response.success( + listOf(mockStatus("7"), mockStatus("6"), mockStatus("5")), + Headers.headersOf( + "Link", + "; rel=\"next\", ; rel=\"prev\"" + ) + ) + }, + accountManager = accountManager, + factory = pagingSourceFactory, + pages = pages, + timelineKind = TimelineKind.Home ) val state = state( @@ -128,255 +125,163 @@ class NetworkTimelineRemoteMediatorTest { ) ) - val result = runBlocking { remoteMediator.load(LoadType.REFRESH, state) } + // When + val result = remoteMediator.load(LoadType.REFRESH, state) - val newStatusData = mutableListOf( - mockStatusViewData("7"), - mockStatusViewData("6"), - mockStatusViewData("5") + // Then + val expectedPages = makeEmptyPageCache() + expectedPages["7"] = Page( + data = mutableListOf(mockStatus("7"), mockStatus("6"), mockStatus("5")), + prevKey = "7", + nextKey = "5" ) - verify(timelineViewModel).nextKey = "4" - assertTrue(result is RemoteMediator.MediatorResult.Success) - assertEquals(false, (result as RemoteMediator.MediatorResult.Success).endOfPaginationReached) - assertEquals(newStatusData, statuses) + assertThat(result).isInstanceOf(RemoteMediator.MediatorResult.Success::class.java) + assertThat((result as RemoteMediator.MediatorResult.Success).endOfPaginationReached).isFalse() + assertThat(pages).containsExactlyEntriesIn(expectedPages) + + // Page cache was modified, so the pager should have been invalidated + verify(pagingSourceFactory).invalidate() } @Test @ExperimentalPagingApi - fun `should not prepend statuses`() { - val statuses: MutableList = mutableListOf( - mockStatusViewData("3"), - mockStatusViewData("2"), - mockStatusViewData("1") - ) - - val timelineViewModel: NetworkTimelineViewModel = mock { - on { statusData } doReturn statuses - on { nextKey } doReturn "0" - onBlocking { fetchStatusesForKind(null, null, 20) } doReturn Response.success( - listOf( - mockStatus("5"), - mockStatus("4"), - mockStatus("3") - ) + fun `should prepend statuses`() = runTest { + // Given + val pages = makeEmptyPageCache().apply { + this["7"] = Page( + data = mutableListOf(mockStatus("7"), mockStatus("6"), mockStatus("5")), + prevKey = "7", + nextKey = "5" ) } val remoteMediator = NetworkTimelineRemoteMediator( - accountManager, - timelineViewModel, - factory!!, - statusData, - kind + api = mock { + onBlocking { homeTimeline(maxId = anyOrNull(), minId = anyOrNull(), limit = anyOrNull(), sinceId = anyOrNull()) } doReturn Response.success( + listOf(mockStatus("10"), mockStatus("9"), mockStatus("8")), + Headers.headersOf( + "Link", + "; rel=\"next\", ; rel=\"prev\"" + ) + ) + }, + accountManager = accountManager, + factory = pagingSourceFactory, + pages = pages, + timelineKind = TimelineKind.Home ) val state = state( listOf( PagingSource.LoadResult.Page( - data = listOf( - mockStatusViewData("3"), - mockStatusViewData("2"), - mockStatusViewData("1") - ), - prevKey = null, - nextKey = "0" + data = listOf(mockStatus("7"), mockStatus("6"), mockStatus("5")), + prevKey = "7", + nextKey = "5" ) ) ) - val result = runBlocking { remoteMediator.load(LoadType.REFRESH, state) } - - val newStatusData = mutableListOf( - mockStatusViewData("5"), - mockStatusViewData("4"), - mockStatusViewData("3"), - mockStatusViewData("2"), - mockStatusViewData("1") - ) - - assertTrue(result is RemoteMediator.MediatorResult.Success) - assertEquals(false, (result as RemoteMediator.MediatorResult.Success).endOfPaginationReached) - assertEquals(newStatusData, statuses) - } - - @Test - @ExperimentalPagingApi - fun `should refresh and not insert placeholders`() { - val statuses: MutableList = mutableListOf( - mockStatusViewData("8"), - mockStatusViewData("7"), - mockStatusViewData("5") - ) + // When + val result = remoteMediator.load(LoadType.PREPEND, state) - val timelineViewModel: NetworkTimelineViewModel = mock { - on { statusData } doReturn statuses - on { nextKey } doReturn "3" - onBlocking { fetchStatusesForKind("3", null, 20) } doReturn Response.success( - listOf( - mockStatus("3"), - mockStatus("2"), - mockStatus("1") - ) + // Then + val expectedPages = makeEmptyPageCache().apply { + this["7"] = Page( + data = mutableListOf(mockStatus("7"), mockStatus("6"), mockStatus("5")), + prevKey = "7", + nextKey = "5" ) - } - - val remoteMediator = NetworkTimelineRemoteMediator( - accountManager, - timelineViewModel, - factory!!, - statusData, - kind - ) - - val state = state( - listOf( - PagingSource.LoadResult.Page( - data = listOf( - mockStatusViewData("8"), - mockStatusViewData("7"), - mockStatusViewData("5") - ), - prevKey = null, - nextKey = "3" - ) + this["10"] = Page( + data = mutableListOf(mockStatus("10"), mockStatus("9"), mockStatus("8")), + prevKey = "10", + nextKey = "8" ) - ) - - val result = runBlocking { remoteMediator.load(LoadType.APPEND, state) } + } - val newStatusData = mutableListOf( - mockStatusViewData("8"), - mockStatusViewData("7"), - mockStatusViewData("5"), - mockStatusViewData("3"), - mockStatusViewData("2"), - mockStatusViewData("1") - ) + assertThat(result).isInstanceOf(RemoteMediator.MediatorResult.Success::class.java) + assertThat((result as RemoteMediator.MediatorResult.Success).endOfPaginationReached).isFalse() + assertThat(pages).containsExactlyEntriesIn(expectedPages) - assertTrue(result is RemoteMediator.MediatorResult.Success) - assertEquals(false, (result as RemoteMediator.MediatorResult.Success).endOfPaginationReached) - assertEquals(newStatusData, statuses) + // Page cache was modified, so the pager should have been invalidated + verify(pagingSourceFactory).invalidate() } @Test @ExperimentalPagingApi - fun `should append statuses`() { - val statuses: MutableList = mutableListOf( - mockStatusViewData("8"), - mockStatusViewData("7"), - mockStatusViewData("5") - ) - - val timelineViewModel: NetworkTimelineViewModel = mock { - on { statusData } doReturn statuses - on { nextKey } doReturn "3" - onBlocking { fetchStatusesForKind("3", null, 20) } doReturn Response.success( - listOf( - mockStatus("3"), - mockStatus("2"), - mockStatus("1") - ), - Headers.headersOf( - "Link", - "; rel=\"next\", ; rel=\"prev\"" - ) + fun `should append statuses`() = runTest { + // Given + val pages = makeEmptyPageCache().apply { + this["7"] = Page( + data = mutableListOf(mockStatus("7"), mockStatus("6"), mockStatus("5")), + prevKey = "7", + nextKey = "5" ) } val remoteMediator = NetworkTimelineRemoteMediator( - accountManager, - timelineViewModel, - factory!!, - statusData, - kind + api = mock { + onBlocking { homeTimeline(maxId = anyOrNull(), minId = anyOrNull(), limit = anyOrNull(), sinceId = anyOrNull()) } doReturn Response.success( + listOf(mockStatus("4"), mockStatus("3"), mockStatus("2")), + Headers.headersOf( + "Link", + "; rel=\"next\", ; rel=\"prev\"" + ) + ) + }, + accountManager = accountManager, + factory = pagingSourceFactory, + pages = pages, + timelineKind = TimelineKind.Home ) val state = state( listOf( PagingSource.LoadResult.Page( - data = listOf( - mockStatusViewData("8"), - mockStatusViewData("7"), - mockStatusViewData("5") - ), - prevKey = null, - nextKey = "3" + data = listOf(mockStatus("7"), mockStatus("6"), mockStatus("5")), + prevKey = "7", + nextKey = "5" ) ) ) - val result = runBlocking { remoteMediator.load(LoadType.APPEND, state) } - - val newStatusData = mutableListOf( - mockStatusViewData("8"), - mockStatusViewData("7"), - mockStatusViewData("5"), - mockStatusViewData("3"), - mockStatusViewData("2"), - mockStatusViewData("1") - ) - verify(timelineViewModel).nextKey = "0" - assertTrue(result is RemoteMediator.MediatorResult.Success) - assertEquals(false, (result as RemoteMediator.MediatorResult.Success).endOfPaginationReached) - assertEquals(newStatusData, statuses) - } - - @Test - @ExperimentalPagingApi - fun `should not append statuses when pagination end has been reached`() { - val statuses: MutableList = mutableListOf( - mockStatusViewData("8"), - mockStatusViewData("7"), - mockStatusViewData("5") - ) + // When + val result = remoteMediator.load(LoadType.APPEND, state) - val timelineViewModel: NetworkTimelineViewModel = mock { - on { statusData } doReturn statuses - on { nextKey } doReturn null + // Then + val expectedPages = makeEmptyPageCache().apply { + this["7"] = Page( + data = mutableListOf(mockStatus("7"), mockStatus("6"), mockStatus("5")), + prevKey = "7", + nextKey = "5" + ) + this["4"] = Page( + data = mutableListOf(mockStatus("4"), mockStatus("3"), mockStatus("2")), + prevKey = "4", + nextKey = "2" + ) } - val remoteMediator = NetworkTimelineRemoteMediator( - accountManager, - timelineViewModel, - factory!!, - statusData, - kind - ) - - val state = state( - listOf( - PagingSource.LoadResult.Page( - data = listOf( - mockStatusViewData("8"), - mockStatusViewData("7"), - mockStatusViewData("5") - ), - prevKey = null, - nextKey = null - ) - ) - ) + assertThat(result).isInstanceOf(RemoteMediator.MediatorResult.Success::class.java) + assertThat((result as RemoteMediator.MediatorResult.Success).endOfPaginationReached).isFalse() + assertThat(pages).containsExactlyEntriesIn(expectedPages) - val result = runBlocking { remoteMediator.load(LoadType.APPEND, state) } + // Page cache was modified, so the pager should have been invalidated + verify(pagingSourceFactory).invalidate() + } - val newStatusData = mutableListOf( - mockStatusViewData("8"), - mockStatusViewData("7"), - mockStatusViewData("5") - ) + companion object { + private const val PAGE_SIZE = 20 - assertTrue(result is RemoteMediator.MediatorResult.Success) - assertTrue((result as RemoteMediator.MediatorResult.Success).endOfPaginationReached) - assertEquals(newStatusData, statuses) + private fun state(pages: List> = emptyList()) = + PagingState( + pages = pages, + anchorPosition = null, + config = PagingConfig( + pageSize = PAGE_SIZE, + initialLoadSize = PAGE_SIZE + ), + leadingPlaceholderCount = 0 + ) } - - private fun state(pages: List> = emptyList()) = PagingState( - pages = pages, - anchorPosition = null, - config = PagingConfig( - pageSize = 20 - ), - leadingPlaceholderCount = 0 - ) } From 7f36e9300a8915f62965cbdb8136b3cca308373c Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Fri, 12 May 2023 15:59:03 +0200 Subject: [PATCH 063/156] Lint --- .../NetworkTimelineRemoteMediatorTest.kt | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRemoteMediatorTest.kt b/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRemoteMediatorTest.kt index 65f969da8f..7d52cfdfa2 100644 --- a/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRemoteMediatorTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRemoteMediatorTest.kt @@ -78,7 +78,7 @@ class NetworkTimelineRemoteMediatorTest { fun `should return error when network call fails`() = runTest { // Given val remoteMediator = NetworkTimelineRemoteMediator( - api = mock(defaultAnswer = { throw IOException() } ), + api = mock(defaultAnswer = { throw IOException() }), accountManager, factory = pagingSourceFactory, pages = makeEmptyPageCache(), @@ -93,7 +93,6 @@ class NetworkTimelineRemoteMediatorTest { assertThat((result as RemoteMediator.MediatorResult.Error).throwable).isInstanceOf(IOException::class.java) } - @Test @ExperimentalPagingApi fun `should do initial loading`() = runTest { @@ -101,13 +100,13 @@ class NetworkTimelineRemoteMediatorTest { val pages = makeEmptyPageCache() val remoteMediator = NetworkTimelineRemoteMediator( api = mock { - onBlocking { homeTimeline(maxId = anyOrNull(), minId = anyOrNull(), limit = anyOrNull(), sinceId = anyOrNull()) } doReturn Response.success( - listOf(mockStatus("7"), mockStatus("6"), mockStatus("5")), - Headers.headersOf( - "Link", - "; rel=\"next\", ; rel=\"prev\"" - ) - ) + onBlocking { homeTimeline(maxId = anyOrNull(), minId = anyOrNull(), limit = anyOrNull(), sinceId = anyOrNull()) } doReturn Response.success( + listOf(mockStatus("7"), mockStatus("6"), mockStatus("5")), + Headers.headersOf( + "Link", + "; rel=\"next\", ; rel=\"prev\"" + ) + ) }, accountManager = accountManager, factory = pagingSourceFactory, From 4b35516a46a9cdc3f79fc8d169a35bc304698399 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Fri, 12 May 2023 17:21:11 +0200 Subject: [PATCH 064/156] Perform long running operations with an external scope Using the view model's scope for long running operations is a problem. If the user navigates away and the view model is killed the operation will be cancelled. This is OK when fetching data. But sending data using the API (e.g., bookmarking a status) should continue in the background after the user navigates away. - Provide an ApplicationScope injectable - Inject it to the repositories that need it - Use the scope when performing potentially long-running operations --- .../notifications/NotificationsRepository.kt | 12 +++-- .../timeline/CachedTimelineRepository.kt | 43 +++++++++-------- .../keylesspalace/tusky/di/AppComponent.kt | 1 + .../tusky/di/CoroutineScopeModule.kt | 46 +++++++++++++++++++ 4 files changed, 78 insertions(+), 24 deletions(-) create mode 100644 app/src/main/java/com/keylesspalace/tusky/di/CoroutineScopeModule.kt 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 index 4bec1aa328..080aeb3778 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsRepository.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsRepository.kt @@ -24,8 +24,11 @@ import androidx.paging.PagingConfig import androidx.paging.PagingData import androidx.paging.PagingSource import com.google.gson.Gson +import com.keylesspalace.tusky.di.ApplicationScope import com.keylesspalace.tusky.entity.Notification import com.keylesspalace.tusky.network.MastodonApi +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.async import kotlinx.coroutines.flow.Flow import okhttp3.ResponseBody import retrofit2.Response @@ -33,7 +36,8 @@ import javax.inject.Inject class NotificationsRepository @Inject constructor( private val mastodonApi: MastodonApi, - private val gson: Gson + private val gson: Gson, + @ApplicationScope private val externalScope: CoroutineScope ) { private var factory: InvalidatingPagingSourceFactory? = null @@ -65,9 +69,9 @@ class NotificationsRepository @Inject constructor( } /** Clear notifications */ - suspend fun clearNotifications(): Response { - return mastodonApi.clearNotifications() - } + suspend fun clearNotifications(): Response = externalScope.async { + return@async mastodonApi.clearNotifications() + }.await() companion object { private const val TAG = "NotificationsRepository" diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/CachedTimelineRepository.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/CachedTimelineRepository.kt index e5fb571219..c651ecf803 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/CachedTimelineRepository.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/CachedTimelineRepository.kt @@ -28,9 +28,12 @@ import com.keylesspalace.tusky.components.timeline.viewmodel.CachedTimelineRemot import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.db.TimelineStatusWithAccount +import com.keylesspalace.tusky.di.ApplicationScope import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.util.EmptyPagingSource +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch import javax.inject.Inject // TODO: This is very similar to NetworkTimelineRepository. They could be merged (and the use @@ -41,14 +44,12 @@ import javax.inject.Inject // // Re-writing the caching so that they can use the same types is the TODO. -// TODO: Follow lifecycle recommendations in https://developer.android.com/topic/architecture/data-layer#make_an_operation_live_longer_than_the_screen -// (ditto for the other repositories). - class CachedTimelineRepository @Inject constructor( private val mastodonApi: MastodonApi, private val accountManager: AccountManager, private val appDatabase: AppDatabase, - private val gson: Gson + private val gson: Gson, + @ApplicationScope private val externalScope: CoroutineScope ) { private var factory: InvalidatingPagingSourceFactory? = null @@ -89,42 +90,44 @@ class CachedTimelineRepository @Inject constructor( } /** Set and store the "expanded" state of the given status, for the active account */ - suspend fun setExpanded(expanded: Boolean, statusId: String) { - appDatabase.timelineDao().setExpanded(accountManager.activeAccount!!.id, statusId, expanded) - } + suspend fun setExpanded(expanded: Boolean, statusId: String) = externalScope.launch { + appDatabase.timelineDao() + .setExpanded(accountManager.activeAccount!!.id, statusId, expanded) + }.join() /** Set and store the "content showing" state of the given status, for the active account */ - suspend fun setContentShowing(showing: Boolean, statusId: String) { + suspend fun setContentShowing(showing: Boolean, statusId: String) = externalScope.launch { appDatabase.timelineDao() .setContentShowing(accountManager.activeAccount!!.id, statusId, showing) - } + }.join() /** Set and store the "content collapsed" ("Show more") state of the given status, for the active account */ - suspend fun setContentCollapsed(collapsed: Boolean, statusId: String) { + suspend fun setContentCollapsed(collapsed: Boolean, statusId: String) = externalScope.launch { appDatabase.timelineDao() .setContentCollapsed(accountManager.activeAccount!!.id, statusId, collapsed) - } + }.join() /** Remove all statuses authored/boosted by the given account, for the active account */ - suspend fun removeAllByAccountId(accountId: String) { + suspend fun removeAllByAccountId(accountId: String) = externalScope.launch { appDatabase.timelineDao().removeAllByUser(accountManager.activeAccount!!.id, accountId) - } + }.join() /** Remove all statuses from the given instance, for the active account */ - suspend fun removeAllByInstance(instance: String) { - appDatabase.timelineDao().deleteAllFromInstance(accountManager.activeAccount!!.id, instance) - } + suspend fun removeAllByInstance(instance: String) = externalScope.launch { + appDatabase.timelineDao() + .deleteAllFromInstance(accountManager.activeAccount!!.id, instance) + }.join() /** Clear the warning (remove the "filtered" setting) for the given status, for the active account */ - suspend fun clearStatusWarning(statusId: String) { + suspend fun clearStatusWarning(statusId: String) = externalScope.launch { appDatabase.timelineDao().clearWarning(accountManager.activeAccount!!.id, statusId) - } + }.join() /** Remove all statuses and invalidate the pager, for the active account */ - suspend fun clearAndReload() { + suspend fun clearAndReload() = externalScope.launch { appDatabase.timelineDao().removeAll(accountManager.activeAccount!!.id) factory?.invalidate() - } + }.join() companion object { private const val TAG = "CachedTimelineRepository" diff --git a/app/src/main/java/com/keylesspalace/tusky/di/AppComponent.kt b/app/src/main/java/com/keylesspalace/tusky/di/AppComponent.kt index 73aceeab2d..d922ab3700 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/AppComponent.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/AppComponent.kt @@ -29,6 +29,7 @@ import javax.inject.Singleton @Component( modules = [ AppModule::class, + CoroutineScopeModule::class, NetworkModule::class, AndroidSupportInjectionModule::class, ActivitiesModule::class, diff --git a/app/src/main/java/com/keylesspalace/tusky/di/CoroutineScopeModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/CoroutineScopeModule.kt new file mode 100644 index 0000000000..623711cc65 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/di/CoroutineScopeModule.kt @@ -0,0 +1,46 @@ +/* + * 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.di + +import dagger.Module +import dagger.Provides +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import javax.inject.Qualifier + +/** + * Scope for potentially long-running tasks that should outlive the viewmodel that + * started them. For example, if the API call to bookmark a status is taking a long + * time, that call should not be cancelled because the user has navigated away from + * the viewmodel that made the call. + * + * @see https://developer.android.com/topic/architecture/data-layer#make_an_operation_live_longer_than_the_screen + */ +@Retention(AnnotationRetention.BINARY) +@Qualifier +annotation class ApplicationScope + +@Module +class CoroutineScopeModule { + @ApplicationScope + @Provides + fun providesApplicationScope() = CoroutineScope(SupervisorJob() + Dispatchers.Default) +} + + From 856b8f9060c3cbdd033680bb52d7bc203ec88bc0 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Fri, 12 May 2023 17:26:52 +0200 Subject: [PATCH 065/156] Remove obsolete TODO --- .../tusky/components/timeline/NetworkTimelineRepository.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRepository.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRepository.kt index 37fc05a603..1060e30cfb 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRepository.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRepository.kt @@ -53,7 +53,6 @@ data class Page constructor( /** Timeline repository where the timeline information is backed by an in-memory cache. */ class NetworkTimelineRepository @Inject constructor( private val mastodonApi: MastodonApi, - // TODO: This needs to be recreated if the active account changes private val accountManager: AccountManager ) { From 730c790b872ef363de3eb233065ab29ca3412769 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Fri, 12 May 2023 17:55:17 +0200 Subject: [PATCH 066/156] Lint --- .../tusky/components/timeline/NetworkTimelineRepository.kt | 2 +- .../java/com/keylesspalace/tusky/di/CoroutineScopeModule.kt | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRepository.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRepository.kt index 1060e30cfb..7abf51e63b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRepository.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRepository.kt @@ -47,7 +47,7 @@ data class Page constructor( /** * [Key] for next page if more data can be loaded in that direction, `null` otherwise. */ - val nextKey: Key? = null, + val nextKey: Key? = null ) /** Timeline repository where the timeline information is backed by an in-memory cache. */ diff --git a/app/src/main/java/com/keylesspalace/tusky/di/CoroutineScopeModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/CoroutineScopeModule.kt index 623711cc65..bee62f7ec7 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/CoroutineScopeModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/CoroutineScopeModule.kt @@ -42,5 +42,3 @@ class CoroutineScopeModule { @Provides fun providesApplicationScope() = CoroutineScope(SupervisorJob() + Dispatchers.Default) } - - From 807b06e2dc3c6e84e04d070638dd8598afed775f Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Sat, 13 May 2023 11:39:41 +0200 Subject: [PATCH 067/156] Initial network timeline tests Similar to the ones for notifications. Refactored account preference handling so that they can be tested: - Renamed AccountPreferenceHandler to AccountPreferenceDataStore - Injected it in to components that need it - Mock it in tests Tests showed that PrefKEys.MEDIA_PREVIEW_ENABLED wasn't being handled, so update code to do that properly. --- .../preference/AccountPreferencesFragment.kt | 24 +- .../viewmodel/CachedTimelineViewModel.kt | 3 + .../viewmodel/NetworkTimelineViewModel.kt | 3 + .../timeline/viewmodel/TimelineViewModel.kt | 21 +- ...ndler.kt => AccountPreferenceDataStore.kt} | 16 +- .../NetworkTimelineViewModelTestBase.kt | 171 +++++++++++++ ...etworkTimelineViewModelTestStatusAction.kt | 232 ++++++++++++++++++ ...melineViewModelTestStatusDisplayOptions.kt | 105 ++++++++ .../NetworkTimelineViewModelTestUiState.kt | 86 +++++++ 9 files changed, 633 insertions(+), 28 deletions(-) rename app/src/main/java/com/keylesspalace/tusky/settings/{AccountPreferenceHandler.kt => AccountPreferenceDataStore.kt} (67%) create mode 100644 app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineViewModelTestBase.kt create mode 100644 app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineViewModelTestStatusAction.kt create mode 100644 app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineViewModelTestStatusDisplayOptions.kt create mode 100644 app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineViewModelTestUiState.kt diff --git a/app/src/main/java/com/keylesspalace/tusky/components/preference/AccountPreferencesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/preference/AccountPreferencesFragment.kt index 49107339c7..326909e1d5 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/preference/AccountPreferencesFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/preference/AccountPreferencesFragment.kt @@ -21,7 +21,6 @@ import android.os.Build import android.os.Bundle import android.util.Log import androidx.annotation.DrawableRes -import androidx.lifecycle.lifecycleScope import androidx.preference.PreferenceFragmentCompat import com.google.android.material.color.MaterialColors import com.google.android.material.snackbar.Snackbar @@ -30,7 +29,6 @@ import com.keylesspalace.tusky.BuildConfig import com.keylesspalace.tusky.R import com.keylesspalace.tusky.TabPreferenceActivity import com.keylesspalace.tusky.appstore.EventHub -import com.keylesspalace.tusky.appstore.PreferenceChangedEvent import com.keylesspalace.tusky.components.accountlist.AccountListActivity import com.keylesspalace.tusky.components.filters.FiltersActivity import com.keylesspalace.tusky.components.followedtags.FollowedTagsActivity @@ -42,7 +40,7 @@ import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.network.MastodonApi -import com.keylesspalace.tusky.settings.AccountPreferenceHandler +import com.keylesspalace.tusky.settings.AccountPreferenceDataStore import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.settings.listPreference import com.keylesspalace.tusky.settings.makePreferenceScreen @@ -58,7 +56,6 @@ import com.mikepenz.iconics.IconicsDrawable import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial import com.mikepenz.iconics.utils.colorInt import com.mikepenz.iconics.utils.sizeRes -import kotlinx.coroutines.launch import retrofit2.Call import retrofit2.Callback import retrofit2.Response @@ -74,6 +71,9 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable { @Inject lateinit var eventHub: EventHub + @Inject + lateinit var accountPreferenceDataStore: AccountPreferenceDataStore + private val iconSize by unsafeLazy { resources.getDimensionPixelSize(R.dimen.preference_icon_size) } override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { @@ -243,29 +243,25 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable { } preferenceCategory(R.string.pref_title_timelines) { - // TODO having no activeAccount in this fragment does not really make sense, enforce it? - // All other locations here make it optional, however. - val accountPreferenceHandler = AccountPreferenceHandler(accountManager.activeAccount!!, accountManager, ::dispatchEvent) - switchPreference { key = PrefKeys.MEDIA_PREVIEW_ENABLED setTitle(R.string.pref_title_show_media_preview) isSingleLineTitle = false - preferenceDataStore = accountPreferenceHandler + preferenceDataStore = accountPreferenceDataStore } switchPreference { key = PrefKeys.ALWAYS_SHOW_SENSITIVE_MEDIA setTitle(R.string.pref_title_alway_show_sensitive_media) isSingleLineTitle = false - preferenceDataStore = accountPreferenceHandler + preferenceDataStore = accountPreferenceDataStore } switchPreference { key = PrefKeys.ALWAYS_OPEN_SPOILER setTitle(R.string.pref_title_alway_open_spoiler) isSingleLineTitle = false - preferenceDataStore = accountPreferenceHandler + preferenceDataStore = accountPreferenceDataStore } } } @@ -353,12 +349,6 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable { activity?.overridePendingTransition(R.anim.slide_from_right, R.anim.slide_to_left) } - private fun dispatchEvent(event: PreferenceChangedEvent) { - lifecycleScope.launch { - eventHub.dispatch(event) - } - } - companion object { fun newInstance() = AccountPreferencesFragment() } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt index 6523ad045b..4d7a2e4b9c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt @@ -36,6 +36,7 @@ import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.network.FilterModel +import com.keylesspalace.tusky.settings.AccountPreferenceDataStore import com.keylesspalace.tusky.usecase.TimelineCases import com.keylesspalace.tusky.viewdata.StatusViewData import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -55,6 +56,7 @@ class CachedTimelineViewModel @Inject constructor( filtersRepository: FiltersRepository, accountManager: AccountManager, preferences: SharedPreferences, + accountPreferenceDataStore: AccountPreferenceDataStore, filterModel: FilterModel, private val gson: Gson ) : TimelineViewModel( @@ -63,6 +65,7 @@ class CachedTimelineViewModel @Inject constructor( filtersRepository, accountManager, preferences, + accountPreferenceDataStore, filterModel ) { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt index 52e9e39444..963b0996aa 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt @@ -34,6 +34,7 @@ import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.network.FilterModel +import com.keylesspalace.tusky.settings.AccountPreferenceDataStore import com.keylesspalace.tusky.usecase.TimelineCases import com.keylesspalace.tusky.util.toViewData import com.keylesspalace.tusky.viewdata.StatusViewData @@ -52,6 +53,7 @@ class NetworkTimelineViewModel @Inject constructor( filtersRepository: FiltersRepository, accountManager: AccountManager, sharedPreferences: SharedPreferences, + accountPreferenceDataStore: AccountPreferenceDataStore, filterModel: FilterModel ) : TimelineViewModel( timelineCases, @@ -59,6 +61,7 @@ class NetworkTimelineViewModel @Inject constructor( filtersRepository, accountManager, sharedPreferences, + accountPreferenceDataStore, filterModel ) { private val modifiedViewData = mutableMapOf() diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt index b6e87ca946..9b98194c4f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt @@ -47,6 +47,7 @@ import com.keylesspalace.tusky.entity.FilterV1 import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.network.FilterModel +import com.keylesspalace.tusky.settings.AccountPreferenceDataStore import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.usecase.TimelineCases import com.keylesspalace.tusky.util.StatusDisplayOptions @@ -64,6 +65,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterIsInstance 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 @@ -79,12 +81,14 @@ data class UiState( /** Preferences the UI reacts to */ data class UiPrefs( - val showFabWhileScrolling: Boolean + val showFabWhileScrolling: Boolean, + val showMediaPreview: Boolean, ) { companion object { /** Relevant preference keys. Changes to any of these trigger a display update */ val prefKeys = setOf( - PrefKeys.FAB_HIDE + PrefKeys.FAB_HIDE, + PrefKeys.MEDIA_PREVIEW_ENABLED ) } } @@ -99,8 +103,7 @@ sealed class UiAction /** Actions the user can trigger from the UI. These actions may fail. */ sealed class FallibleUiAction : UiAction() { - /** Clear all notifications */ -// object ClearNotifications : FallibleUiAction() + /* none at the moment */ } /** @@ -245,6 +248,7 @@ abstract class TimelineViewModel( private val filtersRepository: FiltersRepository, protected val accountManager: AccountManager, private val sharedPreferences: SharedPreferences, + private val accountPreferenceDataStore: AccountPreferenceDataStore, private val filterModel: FilterModel ) : ViewModel() { val uiState: StateFlow @@ -386,7 +390,8 @@ abstract class TimelineViewModel( uiState = getUiPrefs().map { prefs -> UiState( - showFabWhileScrolling = prefs.showFabWhileScrolling + showFabWhileScrolling = prefs.showFabWhileScrolling, + showMediaPreview = prefs.showMediaPreview ) }.stateIn( scope = viewModelScope, @@ -401,12 +406,14 @@ abstract class TimelineViewModel( // TODO: Preferences should be in a repository protected fun getUiPrefs() = eventHub.events .filterIsInstance() + .onEach { println("PreferenceChangedEvent: $it") } .filter { UiPrefs.prefKeys.contains(it.preferenceKey) } .map { toPrefs() } .onStart { emit(toPrefs()) } - protected fun toPrefs() = UiPrefs( - showFabWhileScrolling = !sharedPreferences.getBoolean(PrefKeys.FAB_HIDE, false) + private fun toPrefs() = UiPrefs( + showFabWhileScrolling = !sharedPreferences.getBoolean(PrefKeys.FAB_HIDE, false), + showMediaPreview = accountPreferenceDataStore.getBoolean(PrefKeys.MEDIA_PREVIEW_ENABLED, true) ) open fun init(timelineKind: TimelineKind) { diff --git a/app/src/main/java/com/keylesspalace/tusky/settings/AccountPreferenceHandler.kt b/app/src/main/java/com/keylesspalace/tusky/settings/AccountPreferenceDataStore.kt similarity index 67% rename from app/src/main/java/com/keylesspalace/tusky/settings/AccountPreferenceHandler.kt rename to app/src/main/java/com/keylesspalace/tusky/settings/AccountPreferenceDataStore.kt index cfdc27b44c..21144672d3 100644 --- a/app/src/main/java/com/keylesspalace/tusky/settings/AccountPreferenceHandler.kt +++ b/app/src/main/java/com/keylesspalace/tusky/settings/AccountPreferenceDataStore.kt @@ -1,15 +1,21 @@ package com.keylesspalace.tusky.settings import androidx.preference.PreferenceDataStore +import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.PreferenceChangedEvent import com.keylesspalace.tusky.db.AccountEntity import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.di.ApplicationScope +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import javax.inject.Inject -class AccountPreferenceHandler( - private val account: AccountEntity, +class AccountPreferenceDataStore @Inject constructor( private val accountManager: AccountManager, - private val dispatchEvent: (PreferenceChangedEvent) -> Unit + private val eventHub: EventHub, + @ApplicationScope val externalScope: CoroutineScope ) : PreferenceDataStore() { + private val account: AccountEntity = accountManager.activeAccount!! override fun getBoolean(key: String, defValue: Boolean): Boolean { return when (key) { @@ -29,6 +35,8 @@ class AccountPreferenceHandler( accountManager.saveAccount(account) - dispatchEvent(PreferenceChangedEvent(key)) + externalScope.launch { + eventHub.dispatch(PreferenceChangedEvent(key)) + } } } diff --git a/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineViewModelTestBase.kt b/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineViewModelTestBase.kt new file mode 100644 index 0000000000..a0ff9d07fa --- /dev/null +++ b/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineViewModelTestBase.kt @@ -0,0 +1,171 @@ +/* + * 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.timeline + +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.appstore.PreferenceChangedEvent +import com.keylesspalace.tusky.components.timeline.viewmodel.NetworkTimelineViewModel +import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel +import com.keylesspalace.tusky.db.AccountEntity +import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.network.FilterModel +import com.keylesspalace.tusky.settings.AccountPreferenceDataStore +import com.keylesspalace.tusky.settings.PrefKeys +import com.keylesspalace.tusky.usecase.TimelineCases +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.runBlocking +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.ArgumentMatchers.anyBoolean +import org.mockito.ArgumentMatchers.anyString +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 constructor(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) +@OptIn(ExperimentalCoroutinesApi::class) +abstract class NetworkTimelineViewModelTestBase { + protected lateinit var networkTimelineRepository: NetworkTimelineRepository + protected lateinit var sharedPreferencesMap: MutableMap + protected lateinit var sharedPreferences: SharedPreferences + protected lateinit var accountPreferencesMap: MutableMap + protected lateinit var accountPreferenceDataStore: AccountPreferenceDataStore + protected lateinit var accountManager: AccountManager + protected lateinit var timelineCases: TimelineCases + protected lateinit var eventHub: EventHub + protected lateinit var filtersRepository: FiltersRepository + protected lateinit var filterModel: FilterModel + protected lateinit var viewModel: TimelineViewModel + + /** Empty success response, for API calls that return one */ + protected var emptySuccess = 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() + + networkTimelineRepository = 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.SHOW_NOTIFICATIONS_FILTER to true, + 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]] } + } + + // Backing store for account preferences, to allow mutation in tests + accountPreferencesMap = mutableMapOf( + PrefKeys.ALWAYS_SHOW_SENSITIVE_MEDIA to false, + PrefKeys.ALWAYS_OPEN_SPOILER to false, + PrefKeys.MEDIA_PREVIEW_ENABLED to true + ) + + // Any getBoolean() call looks for the result in accountPreferencesMap. + // Any putBoolean() call updates the map and dispatches an event + accountPreferenceDataStore = mock { + on { getBoolean(any(), any()) } doAnswer { accountPreferencesMap[it.arguments[0]] } + on { putBoolean(anyString(), anyBoolean()) } doAnswer { + accountPreferencesMap[it.arguments[0] as String] = it.arguments[1] as Boolean + runBlocking { eventHub.dispatch(PreferenceChangedEvent(it.arguments[0] as String)) } + } + } + + 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() + filtersRepository = mock() + filterModel = mock() + + viewModel = NetworkTimelineViewModel( + networkTimelineRepository, + timelineCases, + eventHub, + filtersRepository, + accountManager, + sharedPreferences, + accountPreferenceDataStore, + filterModel + ) + } +} diff --git a/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineViewModelTestStatusAction.kt b/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineViewModelTestStatusAction.kt new file mode 100644 index 0000000000..91150bbcd9 --- /dev/null +++ b/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineViewModelTestStatusAction.kt @@ -0,0 +1,232 @@ +/* + * 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.timeline + +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.components.timeline.viewmodel.StatusAction +import com.keylesspalace.tusky.components.timeline.viewmodel.StatusActionSuccess +import com.keylesspalace.tusky.components.timeline.viewmodel.UiError +import com.keylesspalace.tusky.viewdata.StatusViewData +import kotlinx.coroutines.ExperimentalCoroutinesApi +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. + */ +// TODO: With the exception of the types, this is identical to +// NotificationsViewModelTestStatusAction. +@OptIn(ExperimentalCoroutinesApi::class) +class NetworkTimelineViewModelTestStatusAction : NetworkTimelineViewModelTestBase() { + private val status = mockStatus(pollOptions = listOf("Choice 1", "Choice 2", "Choice 3")) + private val statusViewData = StatusViewData( + 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/timeline/NetworkTimelineViewModelTestStatusDisplayOptions.kt b/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineViewModelTestStatusDisplayOptions.kt new file mode 100644 index 0000000000..bbdc7ed5f4 --- /dev/null +++ b/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineViewModelTestStatusDisplayOptions.kt @@ -0,0 +1,105 @@ +/* + * 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.timeline + +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.ExperimentalCoroutinesApi +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? + */ +// TODO: With the exception of the types, this is identical to +// NotificationsViewModelTestStatusDisplayOptions +@OptIn(ExperimentalCoroutinesApi::class) +class NetworkTimelineViewModelTestStatusDisplayOptions : NetworkTimelineViewModelTestBase() { + + 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/timeline/NetworkTimelineViewModelTestUiState.kt b/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineViewModelTestUiState.kt new file mode 100644 index 0000000000..a11385825a --- /dev/null +++ b/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineViewModelTestUiState.kt @@ -0,0 +1,86 @@ +/* + * 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.timeline + +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import com.keylesspalace.tusky.appstore.PreferenceChangedEvent +import com.keylesspalace.tusky.components.timeline.viewmodel.UiState +import com.keylesspalace.tusky.settings.PrefKeys +import kotlinx.coroutines.ExperimentalCoroutinesApi +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? + */ +@OptIn(ExperimentalCoroutinesApi::class) +class NetworkTimelineViewModelTestUiState : NetworkTimelineViewModelTestBase() { + + private val initialUiState = UiState( + showFabWhileScrolling = true, + showMediaPreview = 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() + } + } + + @Test + fun `showMediaPreview depends on MEDIA_PREVIEW_ENABLED preference`() = runTest { + //Prior + viewModel.uiState.test { + assertThat(expectMostRecentItem().showMediaPreview).isTrue() + } + + // Given (nothing to do here, set up is in base class) + + // When + accountPreferenceDataStore.putBoolean(PrefKeys.MEDIA_PREVIEW_ENABLED, false) + + // Then + viewModel.uiState.test { + assertThat(expectMostRecentItem().showMediaPreview).isFalse() + } + } +} From ce76ce160142d6d0c41fddfc9bc0c305a1767dd6 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Sat, 13 May 2023 20:00:25 +0200 Subject: [PATCH 068/156] `authorServerId` can be non-null Requires a database migration --- .../51.json | 995 ++++++++++++++++++ .../keylesspalace/tusky/TuskyApplication.kt | 16 - .../keylesspalace/tusky/db/AppDatabase.java | 65 +- .../tusky/db/TimelineStatusEntity.kt | 2 +- .../com/keylesspalace/tusky/di/AppModule.kt | 3 +- 5 files changed, 1062 insertions(+), 19 deletions(-) create mode 100644 app/schemas/com.keylesspalace.tusky.db.AppDatabase/51.json diff --git a/app/schemas/com.keylesspalace.tusky.db.AppDatabase/51.json b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/51.json new file mode 100644 index 0000000000..ba48dd3fa2 --- /dev/null +++ b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/51.json @@ -0,0 +1,995 @@ +{ + "formatVersion": 1, + "database": { + "version": 51, + "identityHash": "783599e5496c6de78d6b47bfebd7ca13", + "entities": [ + { + "tableName": "DraftEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `accountId` INTEGER NOT NULL, `inReplyToId` TEXT, `content` TEXT, `contentWarning` TEXT, `sensitive` INTEGER NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT NOT NULL, `poll` TEXT, `failedToSend` INTEGER NOT NULL, `failedToSendNew` INTEGER NOT NULL, `scheduledAt` TEXT, `language` TEXT, `statusId` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "contentWarning", + "columnName": "contentWarning", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sensitive", + "columnName": "sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "failedToSend", + "columnName": "failedToSend", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "failedToSendNew", + "columnName": "failedToSendNew", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "scheduledAt", + "columnName": "scheduledAt", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "statusId", + "columnName": "statusId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "AccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `domain` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `clientId` TEXT, `clientSecret` TEXT, `isActive` INTEGER NOT NULL, `accountId` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `profilePictureUrl` TEXT NOT NULL, `notificationsEnabled` INTEGER NOT NULL, `notificationsMentioned` INTEGER NOT NULL, `notificationsFollowed` INTEGER NOT NULL, `notificationsFollowRequested` INTEGER NOT NULL, `notificationsReblogged` INTEGER NOT NULL, `notificationsFavorited` INTEGER NOT NULL, `notificationsPolls` INTEGER NOT NULL, `notificationsSubscriptions` INTEGER NOT NULL, `notificationsSignUps` INTEGER NOT NULL, `notificationsUpdates` INTEGER NOT NULL, `notificationsReports` INTEGER NOT NULL, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `defaultPostLanguage` TEXT NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `alwaysOpenSpoiler` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `emojis` TEXT NOT NULL, `tabPreferences` TEXT NOT NULL, `notificationsFilter` TEXT NOT NULL, `oauthScopes` TEXT NOT NULL, `unifiedPushUrl` TEXT NOT NULL, `pushPubKey` TEXT NOT NULL, `pushPrivKey` TEXT NOT NULL, `pushAuth` TEXT NOT NULL, `pushServerKey` TEXT NOT NULL, `lastVisibleHomeTimelineStatusId` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clientId", + "columnName": "clientId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "clientSecret", + "columnName": "clientSecret", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profilePictureUrl", + "columnName": "profilePictureUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationsEnabled", + "columnName": "notificationsEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsMentioned", + "columnName": "notificationsMentioned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFollowed", + "columnName": "notificationsFollowed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFollowRequested", + "columnName": "notificationsFollowRequested", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsReblogged", + "columnName": "notificationsReblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFavorited", + "columnName": "notificationsFavorited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsPolls", + "columnName": "notificationsPolls", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsSubscriptions", + "columnName": "notificationsSubscriptions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsSignUps", + "columnName": "notificationsSignUps", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsUpdates", + "columnName": "notificationsUpdates", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsReports", + "columnName": "notificationsReports", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationSound", + "columnName": "notificationSound", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationVibration", + "columnName": "notificationVibration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationLight", + "columnName": "notificationLight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultPostPrivacy", + "columnName": "defaultPostPrivacy", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultMediaSensitivity", + "columnName": "defaultMediaSensitivity", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultPostLanguage", + "columnName": "defaultPostLanguage", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "alwaysShowSensitiveMedia", + "columnName": "alwaysShowSensitiveMedia", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "alwaysOpenSpoiler", + "columnName": "alwaysOpenSpoiler", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaPreviewEnabled", + "columnName": "mediaPreviewEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastNotificationId", + "columnName": "lastNotificationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tabPreferences", + "columnName": "tabPreferences", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationsFilter", + "columnName": "notificationsFilter", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "oauthScopes", + "columnName": "oauthScopes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unifiedPushUrl", + "columnName": "unifiedPushUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushPubKey", + "columnName": "pushPubKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushPrivKey", + "columnName": "pushPrivKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushAuth", + "columnName": "pushAuth", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushServerKey", + "columnName": "pushServerKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastVisibleHomeTimelineStatusId", + "columnName": "lastVisibleHomeTimelineStatusId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_AccountEntity_domain_accountId", + "unique": true, + "columnNames": [ + "domain", + "accountId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_AccountEntity_domain_accountId` ON `${TABLE_NAME}` (`domain`, `accountId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "InstanceEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`instance` TEXT NOT NULL, `emojiList` TEXT, `maximumTootCharacters` INTEGER, `maxPollOptions` INTEGER, `maxPollOptionLength` INTEGER, `minPollDuration` INTEGER, `maxPollDuration` INTEGER, `charactersReservedPerUrl` INTEGER, `version` TEXT, `videoSizeLimit` INTEGER, `imageSizeLimit` INTEGER, `imageMatrixLimit` INTEGER, `maxMediaAttachments` INTEGER, `maxFields` INTEGER, `maxFieldNameLength` INTEGER, `maxFieldValueLength` INTEGER, PRIMARY KEY(`instance`))", + "fields": [ + { + "fieldPath": "instance", + "columnName": "instance", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojiList", + "columnName": "emojiList", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maximumTootCharacters", + "columnName": "maximumTootCharacters", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollOptions", + "columnName": "maxPollOptions", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollOptionLength", + "columnName": "maxPollOptionLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "minPollDuration", + "columnName": "minPollDuration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollDuration", + "columnName": "maxPollDuration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "charactersReservedPerUrl", + "columnName": "charactersReservedPerUrl", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "videoSizeLimit", + "columnName": "videoSizeLimit", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "imageSizeLimit", + "columnName": "imageSizeLimit", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "imageMatrixLimit", + "columnName": "imageMatrixLimit", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxMediaAttachments", + "columnName": "maxMediaAttachments", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxFields", + "columnName": "maxFields", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxFieldNameLength", + "columnName": "maxFieldNameLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxFieldValueLength", + "columnName": "maxFieldValueLength", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "instance" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "TimelineStatusEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `url` TEXT, `timelineUserId` INTEGER NOT NULL, `authorServerId` TEXT NOT NULL, `inReplyToId` TEXT, `inReplyToAccountId` TEXT, `content` TEXT, `createdAt` INTEGER NOT NULL, `editedAt` INTEGER, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` INTEGER NOT NULL, `repliesCount` INTEGER NOT NULL, `reblogged` INTEGER NOT NULL, `bookmarked` INTEGER NOT NULL, `favourited` INTEGER NOT NULL, `sensitive` INTEGER NOT NULL, `spoilerText` TEXT NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT, `mentions` TEXT, `tags` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` TEXT, `poll` TEXT, `muted` INTEGER, `expanded` INTEGER NOT NULL, `contentCollapsed` INTEGER NOT NULL, `contentShowing` INTEGER NOT NULL, `pinned` INTEGER NOT NULL, `card` TEXT, `language` TEXT, `filtered` TEXT, PRIMARY KEY(`serverId`, `timelineUserId`), FOREIGN KEY(`authorServerId`, `timelineUserId`) REFERENCES `TimelineAccountEntity`(`serverId`, `timelineUserId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorServerId", + "columnName": "authorServerId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToAccountId", + "columnName": "inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "editedAt", + "columnName": "editedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogsCount", + "columnName": "reblogsCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favouritesCount", + "columnName": "favouritesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "repliesCount", + "columnName": "repliesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reblogged", + "columnName": "reblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookmarked", + "columnName": "bookmarked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favourited", + "columnName": "favourited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sensitive", + "columnName": "sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "spoilerText", + "columnName": "spoilerText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "mentions", + "columnName": "mentions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "application", + "columnName": "application", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogServerId", + "columnName": "reblogServerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogAccountId", + "columnName": "reblogAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "muted", + "columnName": "muted", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "expanded", + "columnName": "expanded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentCollapsed", + "columnName": "contentCollapsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentShowing", + "columnName": "contentShowing", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pinned", + "columnName": "pinned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "card", + "columnName": "card", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "filtered", + "columnName": "filtered", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "timelineUserId" + ] + }, + "indices": [ + { + "name": "index_TimelineStatusEntity_authorServerId_timelineUserId", + "unique": false, + "columnNames": [ + "authorServerId", + "timelineUserId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TimelineStatusEntity_authorServerId_timelineUserId` ON `${TABLE_NAME}` (`authorServerId`, `timelineUserId`)" + } + ], + "foreignKeys": [ + { + "table": "TimelineAccountEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "authorServerId", + "timelineUserId" + ], + "referencedColumns": [ + "serverId", + "timelineUserId" + ] + } + ] + }, + { + "tableName": "TimelineAccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `timelineUserId` INTEGER NOT NULL, `localUsername` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `url` TEXT NOT NULL, `avatar` TEXT NOT NULL, `emojis` TEXT NOT NULL, `bot` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `timelineUserId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localUsername", + "columnName": "localUsername", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "avatar", + "columnName": "avatar", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bot", + "columnName": "bot", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "timelineUserId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ConversationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT NOT NULL, `order` INTEGER NOT NULL, `accounts` TEXT NOT NULL, `unread` INTEGER NOT NULL, `s_id` TEXT NOT NULL, `s_url` TEXT, `s_inReplyToId` TEXT, `s_inReplyToAccountId` TEXT, `s_account` TEXT NOT NULL, `s_content` TEXT NOT NULL, `s_createdAt` INTEGER NOT NULL, `s_editedAt` INTEGER, `s_emojis` TEXT NOT NULL, `s_favouritesCount` INTEGER NOT NULL, `s_repliesCount` INTEGER NOT NULL, `s_favourited` INTEGER NOT NULL, `s_bookmarked` INTEGER NOT NULL, `s_sensitive` INTEGER NOT NULL, `s_spoilerText` TEXT NOT NULL, `s_attachments` TEXT NOT NULL, `s_mentions` TEXT NOT NULL, `s_tags` TEXT, `s_showingHiddenContent` INTEGER NOT NULL, `s_expanded` INTEGER NOT NULL, `s_collapsed` INTEGER NOT NULL, `s_muted` INTEGER NOT NULL, `s_poll` TEXT, `s_language` TEXT, PRIMARY KEY(`id`, `accountId`))", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accounts", + "columnName": "accounts", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unread", + "columnName": "unread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.id", + "columnName": "s_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.url", + "columnName": "s_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.inReplyToId", + "columnName": "s_inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.inReplyToAccountId", + "columnName": "s_inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.account", + "columnName": "s_account", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.content", + "columnName": "s_content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.createdAt", + "columnName": "s_createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.editedAt", + "columnName": "s_editedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastStatus.emojis", + "columnName": "s_emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.favouritesCount", + "columnName": "s_favouritesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.repliesCount", + "columnName": "s_repliesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.favourited", + "columnName": "s_favourited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.bookmarked", + "columnName": "s_bookmarked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.sensitive", + "columnName": "s_sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.spoilerText", + "columnName": "s_spoilerText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.attachments", + "columnName": "s_attachments", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.mentions", + "columnName": "s_mentions", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.tags", + "columnName": "s_tags", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.showingHiddenContent", + "columnName": "s_showingHiddenContent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.expanded", + "columnName": "s_expanded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.collapsed", + "columnName": "s_collapsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.muted", + "columnName": "s_muted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.poll", + "columnName": "s_poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.language", + "columnName": "s_language", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id", + "accountId" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '783599e5496c6de78d6b47bfebd7ca13')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt b/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt index c7f78836c1..dcbf3d64c5 100644 --- a/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt +++ b/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt @@ -37,8 +37,6 @@ import de.c1710.filemojicompat_defaults.DefaultEmojiPackList import de.c1710.filemojicompat_ui.helpers.EmojiPackHelper import de.c1710.filemojicompat_ui.helpers.EmojiPreference import io.reactivex.rxjava3.plugins.RxJavaPlugins -import kotlinx.coroutines.MainScope -import kotlinx.coroutines.launch import org.conscrypt.Conscrypt import java.security.Security import java.util.concurrent.TimeUnit @@ -136,20 +134,6 @@ class TuskyApplication : Application(), HasAndroidInjector { // Deleting the "Reading order" preference, as the need to "Load more" has been // removed. editor.remove(PrefKeys.DEPRECATED_READING_ORDER) - - // The status and account entity caches may be broken. - // - // 1. In some cases Tusky was missing statuses which couldn't be backfilled - // 2. Old account entities - // - // Remove them. Do this directly, instead of with DAO methods, this functionality - // shouldn't be part of the normal DAO. - db.runInTransaction { - MainScope().launch { - db.query("DELETE FROM TimelineStatusEntity", null) - db.query("DELETE FROM TimelineAccountEntity", null) - } - } } editor.putInt(PrefKeys.SCHEMA_VERSION, newVersion) diff --git a/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java b/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java index b9a0151e55..7901528158 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java +++ b/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java @@ -41,7 +41,7 @@ TimelineAccountEntity.class, ConversationEntity.class }, - version = 50, + version = 51, autoMigrations = { @AutoMigration(from = 48, to = 49), @AutoMigration(from = 49, to = 50, spec = AppDatabase.MIGRATION_49_50.class) @@ -671,4 +671,67 @@ public void migrate(@NonNull SupportSQLiteDatabase database) { @DeleteColumn(tableName = "AccountEntity", columnName = "activeNotifications") static class MIGRATION_49_50 implements AutoMigrationSpec { } + + public static final Migration MIGRATION_50_51 = new Migration(50, 51) { + @Override + public void migrate(@NonNull SupportSQLiteDatabase database) { + database.execSQL("DROP TABLE IF EXISTS `TimelineAccountEntity`"); + database.execSQL("DROP TABLE IF EXISTS `TimelineStatusEntity`"); + + database.execSQL("CREATE TABLE IF NOT EXISTS `TimelineAccountEntity` (" + + "`serverId` TEXT NOT NULL," + + "`timelineUserId` INTEGER NOT NULL," + + "`localUsername` TEXT NOT NULL," + + "`username` TEXT NOT NULL," + + "`displayName` TEXT NOT NULL," + + "`url` TEXT NOT NULL," + + "`avatar` TEXT NOT NULL," + + "`emojis` TEXT NOT NULL," + + "`bot` INTEGER NOT NULL," + + "PRIMARY KEY(`serverId`, `timelineUserId`) )"); + + // Changing `authorServerId` from `TEXT` to `TEXT NOT NULL` + database.execSQL("CREATE TABLE IF NOT EXISTS `TimelineStatusEntity` (" + + "`serverId` TEXT NOT NULL," + + "`url` TEXT," + + "`timelineUserId` INTEGER NOT NULL," + + "`authorServerId` TEXT NOT NULL," + + "`inReplyToId` TEXT," + + "`inReplyToAccountId` TEXT," + + "`content` TEXT," + + "`createdAt` INTEGER NOT NULL," + + "`emojis` TEXT," + + "`reblogsCount` INTEGER NOT NULL," + + "`favouritesCount` INTEGER NOT NULL," + + "`reblogged` INTEGER NOT NULL," + + "`bookmarked` INTEGER NOT NULL," + + "`favourited` INTEGER NOT NULL," + + "`sensitive` INTEGER NOT NULL," + + "`spoilerText` TEXT NOT NULL," + + "`visibility` INTEGER NOT NULL," + + "`attachments` TEXT," + + "`mentions` TEXT," + + "`application` TEXT," + + "`reblogServerId` TEXT," + + "`reblogAccountId` TEXT," + + "`poll` TEXT," + + "`muted` INTEGER," + + "`expanded` INTEGER NOT NULL," + + "`contentCollapsed` INTEGER NOT NULL," + + "`contentShowing` INTEGER NOT NULL," + + "`pinned` INTEGER NOT NULL," + + "`tags` TEXT," + + "`card` TEXT," + + "`repliesCount` INTEGER NOT NULL DEFAULT 0," + + "`language` TEXT," + + "`editedAt` INTEGER," + + "`filtered` TEXT," + + "PRIMARY KEY(`serverId`, `timelineUserId`)," + + "FOREIGN KEY(`authorServerId`, `timelineUserId`) REFERENCES `TimelineAccountEntity`(`serverId`, `timelineUserId`)" + + "ON UPDATE NO ACTION ON DELETE NO ACTION )"); + + database.execSQL("CREATE INDEX IF NOT EXISTS `index_TimelineStatusEntity_authorServerId_timelineUserId`" + + "ON `TimelineStatusEntity` (`authorServerId`, `timelineUserId`)"); + } + }; } diff --git a/app/src/main/java/com/keylesspalace/tusky/db/TimelineStatusEntity.kt b/app/src/main/java/com/keylesspalace/tusky/db/TimelineStatusEntity.kt index 38d7b20fe2..cf8a3c26ac 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/TimelineStatusEntity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/TimelineStatusEntity.kt @@ -54,7 +54,7 @@ data class TimelineStatusEntity( val url: String?, // our local id for the logged in user in case there are multiple accounts per instance val timelineUserId: Long, - val authorServerId: String?, + val authorServerId: String, val inReplyToId: String?, val inReplyToAccountId: String?, val content: String?, diff --git a/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt index bc2c7d7537..47a189df8e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt @@ -68,7 +68,8 @@ class AppModule { AppDatabase.MIGRATION_38_39, AppDatabase.MIGRATION_39_40, AppDatabase.MIGRATION_40_41, AppDatabase.MIGRATION_41_42, AppDatabase.MIGRATION_42_43, AppDatabase.MIGRATION_43_44, AppDatabase.MIGRATION_44_45, AppDatabase.MIGRATION_45_46, AppDatabase.MIGRATION_46_47, - AppDatabase.MIGRATION_47_48 + AppDatabase.MIGRATION_47_48, /* 48 -> 49, auto */ /* 49 -> 50, auto */ + AppDatabase.MIGRATION_50_51 ) .build() } From 60fbf62f1159e8038dce62967de61d24d9c12c16 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Sat, 13 May 2023 21:32:26 +0200 Subject: [PATCH 069/156] Save visible status ID only after knowing what the timeline kind is - Fixes a bug where the reading position could get reset - Confirm it stays fixed with tests --- .../components/timeline/TimelineFragment.kt | 10 +--- .../timeline/viewmodel/TimelineViewModel.kt | 32 ++++++------ .../NetworkTimelineViewModelTestBase.kt | 4 ++ .../NetworkTimelineViewModelTestVisibleId.kt | 51 +++++++++++++++++++ 4 files changed, 73 insertions(+), 24 deletions(-) create mode 100644 app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineViewModelTestVisibleId.kt diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt index fef2757aaa..13dbeb11b0 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt @@ -26,7 +26,6 @@ import android.view.ViewGroup import android.view.accessibility.AccessibilityManager import androidx.core.content.ContextCompat import androidx.core.view.MenuProvider -import androidx.core.view.postDelayed import androidx.lifecycle.Lifecycle import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.lifecycleScope @@ -329,13 +328,8 @@ class TimelineFragment : it.refresh is LoadState.NotLoading && it.prepend.endOfPaginationReached && !it.append.endOfPaginationReached } .collect { - // This works without the delay if you are repeatedly refreshing a - // single timeline. But if you refresh a timeline (e.g., Local), - // then go to another timeline (e.g., Home), then go back to the - // first timeline and refresh that, it jumps to the top. Adding the - // delay fixes that -- I have no idea why... - binding.recyclerView.postDelayed(300) { - getView() ?: return@postDelayed + binding.recyclerView.post { + getView() ?: return@post binding.recyclerView.scrollBy(0, Utils.dpToPx(requireContext(), -30)) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt index 9b98194c4f..6973ee5d36 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt @@ -296,22 +296,6 @@ abstract class TimelineViewModel( } } - // Save the visible status ID (if it's the home timeline) - if (timelineKind == TimelineKind.Home) { - viewModelScope.launch { - uiAction - .filterIsInstance() - .distinctUntilChanged() - .collectLatest { action -> - accountManager.activeAccount?.let { account -> - Log.d(TAG, "Saving Home timeline position at: ${action.visibleId}") - account.lastVisibleHomeTimelineStatusId = 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 @@ -429,6 +413,22 @@ abstract class TimelineViewModel( !sharedPreferences.getBoolean(PrefKeys.TAB_FILTER_HOME_BOOSTS, true) } + // Save the visible status ID (if it's the home timeline) + if (timelineKind == TimelineKind.Home) { + viewModelScope.launch { + uiAction + .filterIsInstance() + .distinctUntilChanged() + .collectLatest { action -> + accountManager.activeAccount?.let { account -> + Log.d(TAG, "Saving Home timeline position at: ${action.visibleId}") + account.lastVisibleHomeTimelineStatusId = action.visibleId + accountManager.saveAccount(account) + } + } + } + } + viewModelScope.launch { eventHub.events .filterIsInstance() diff --git a/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineViewModelTestBase.kt b/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineViewModelTestBase.kt index a0ff9d07fa..596c971abd 100644 --- a/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineViewModelTestBase.kt +++ b/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineViewModelTestBase.kt @@ -146,6 +146,7 @@ abstract class NetworkTimelineViewModelTestBase { clientId = "fakeId", clientSecret = "fakeSecret", isActive = true, + lastVisibleHomeTimelineStatusId = null, notificationsFilter = "['follow']", mediaPreviewEnabled = true, alwaysShowSensitiveMedia = true, @@ -167,5 +168,8 @@ abstract class NetworkTimelineViewModelTestBase { accountPreferenceDataStore, filterModel ) + // Initialisation with any timeline kind, as long as it's not Home + // (Home uses CachedTimelineViewModel) + viewModel.init(TimelineKind.Bookmarks) } } diff --git a/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineViewModelTestVisibleId.kt b/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineViewModelTestVisibleId.kt new file mode 100644 index 0000000000..cd31d49a44 --- /dev/null +++ b/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineViewModelTestVisibleId.kt @@ -0,0 +1,51 @@ +/* + * 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.timeline + +import com.google.common.truth.Truth.assertThat +import com.keylesspalace.tusky.components.timeline.viewmodel.InfallibleUiAction +import com.keylesspalace.tusky.db.AccountEntity +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.never +import org.mockito.kotlin.verify + +@OptIn(ExperimentalCoroutinesApi::class) +class NetworkTimelineViewModelTestVisibleId : NetworkTimelineViewModelTestBase() { + + @Test + fun `should not save status ID to active account`() = runTest { + argumentCaptor().apply { + // Given + assertThat(accountManager.activeAccount?.lastVisibleHomeTimelineStatusId) + .isNull() + assertThat(viewModel.timelineKind) + .isNotEqualTo(TimelineKind.Home) + + // When + viewModel.accept(InfallibleUiAction.SaveVisibleId("1234")) + + // Then + // As a non-Home timline this should *not* save the account, and + // the last visible property should *not* have changed. + verify(accountManager, never()).saveAccount(capture()) + } + } +} From 638118fff677b62e4e89f26b8bf7ef89f613947e Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Sat, 13 May 2023 21:33:08 +0200 Subject: [PATCH 070/156] Lint --- .../components/timeline/NetworkTimelineViewModelTestUiState.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineViewModelTestUiState.kt b/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineViewModelTestUiState.kt index a11385825a..a4c49b155f 100644 --- a/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineViewModelTestUiState.kt +++ b/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineViewModelTestUiState.kt @@ -68,7 +68,7 @@ class NetworkTimelineViewModelTestUiState : NetworkTimelineViewModelTestBase() { @Test fun `showMediaPreview depends on MEDIA_PREVIEW_ENABLED preference`() = runTest { - //Prior + // Prior viewModel.uiState.test { assertThat(expectMostRecentItem().showMediaPreview).isTrue() } From 94d34985157f79102ba7c4312b50ae943842830a Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Sat, 13 May 2023 21:53:26 +0200 Subject: [PATCH 071/156] Add equivalent tests for the CachedTimelineViewModel --- .../CachedTimelineViewModelTestBase.kt | 157 ++++++++++++ ...CachedTimelineViewModelTestStatusAction.kt | 232 ++++++++++++++++++ ...melineViewModelTestStatusDisplayOptions.kt | 105 ++++++++ .../CachedTimelineViewModelTestUiState.kt | 86 +++++++ .../CachedTimelineViewModelTestVisibleId.kt | 52 ++++ .../components/timeline/MainCoroutineRule.kt | 40 +++ .../NetworkTimelineViewModelTestBase.kt | 20 -- .../NetworkTimelineViewModelTestUiState.kt | 2 +- 8 files changed, 673 insertions(+), 21 deletions(-) create mode 100644 app/src/test/java/com/keylesspalace/tusky/components/timeline/CachedTimelineViewModelTestBase.kt create mode 100644 app/src/test/java/com/keylesspalace/tusky/components/timeline/CachedTimelineViewModelTestStatusAction.kt create mode 100644 app/src/test/java/com/keylesspalace/tusky/components/timeline/CachedTimelineViewModelTestStatusDisplayOptions.kt create mode 100644 app/src/test/java/com/keylesspalace/tusky/components/timeline/CachedTimelineViewModelTestUiState.kt create mode 100644 app/src/test/java/com/keylesspalace/tusky/components/timeline/CachedTimelineViewModelTestVisibleId.kt create mode 100644 app/src/test/java/com/keylesspalace/tusky/components/timeline/MainCoroutineRule.kt diff --git a/app/src/test/java/com/keylesspalace/tusky/components/timeline/CachedTimelineViewModelTestBase.kt b/app/src/test/java/com/keylesspalace/tusky/components/timeline/CachedTimelineViewModelTestBase.kt new file mode 100644 index 0000000000..b8da9d6cb4 --- /dev/null +++ b/app/src/test/java/com/keylesspalace/tusky/components/timeline/CachedTimelineViewModelTestBase.kt @@ -0,0 +1,157 @@ +/* + * 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.timeline + +import android.content.SharedPreferences +import android.os.Looper +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.gson.Gson +import com.keylesspalace.tusky.appstore.EventHub +import com.keylesspalace.tusky.appstore.PreferenceChangedEvent +import com.keylesspalace.tusky.components.timeline.viewmodel.CachedTimelineViewModel +import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel +import com.keylesspalace.tusky.db.AccountEntity +import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.network.FilterModel +import com.keylesspalace.tusky.settings.AccountPreferenceDataStore +import com.keylesspalace.tusky.settings.PrefKeys +import com.keylesspalace.tusky.usecase.TimelineCases +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.runBlocking +import okhttp3.ResponseBody +import okhttp3.ResponseBody.Companion.toResponseBody +import org.junit.Before +import org.junit.Rule +import org.junit.runner.RunWith +import org.mockito.ArgumentMatchers.anyBoolean +import org.mockito.ArgumentMatchers.anyString +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 + +@Config(sdk = [28]) +@RunWith(AndroidJUnit4::class) +@OptIn(ExperimentalCoroutinesApi::class) +abstract class CachedTimelineViewModelTestBase { + protected lateinit var cachedTimelineRepository: CachedTimelineRepository + protected lateinit var sharedPreferencesMap: MutableMap + protected lateinit var sharedPreferences: SharedPreferences + protected lateinit var accountPreferencesMap: MutableMap + protected lateinit var accountPreferenceDataStore: AccountPreferenceDataStore + protected lateinit var accountManager: AccountManager + protected lateinit var timelineCases: TimelineCases + protected lateinit var eventHub: EventHub + protected lateinit var filtersRepository: FiltersRepository + protected lateinit var filterModel: FilterModel + protected lateinit var viewModel: TimelineViewModel + + /** Empty success response, for API calls that return one */ + protected var emptySuccess = 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() + + cachedTimelineRepository = 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.SHOW_NOTIFICATIONS_FILTER to true, + 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]] } + } + + // Backing store for account preferences, to allow mutation in tests + accountPreferencesMap = mutableMapOf( + PrefKeys.ALWAYS_SHOW_SENSITIVE_MEDIA to false, + PrefKeys.ALWAYS_OPEN_SPOILER to false, + PrefKeys.MEDIA_PREVIEW_ENABLED to true + ) + + // Any getBoolean() call looks for the result in accountPreferencesMap. + // Any putBoolean() call updates the map and dispatches an event + accountPreferenceDataStore = mock { + on { getBoolean(any(), any()) } doAnswer { accountPreferencesMap[it.arguments[0]] } + on { putBoolean(anyString(), anyBoolean()) } doAnswer { + accountPreferencesMap[it.arguments[0] as String] = it.arguments[1] as Boolean + runBlocking { eventHub.dispatch(PreferenceChangedEvent(it.arguments[0] as String)) } + } + } + + accountManager = mock { + on { activeAccount } doReturn AccountEntity( + id = 1, + domain = "mastodon.test", + accessToken = "fakeToken", + clientId = "fakeId", + clientSecret = "fakeSecret", + isActive = true, + lastVisibleHomeTimelineStatusId = null, + notificationsFilter = "['follow']", + mediaPreviewEnabled = true, + alwaysShowSensitiveMedia = true, + alwaysOpenSpoiler = true + ) + } + eventHub = EventHub() + timelineCases = mock() + filtersRepository = mock() + filterModel = mock() + + viewModel = CachedTimelineViewModel( + cachedTimelineRepository, + timelineCases, + eventHub, + filtersRepository, + accountManager, + sharedPreferences, + accountPreferenceDataStore, + filterModel, + Gson() + ) + + // Initialisation with the Home timeline + viewModel.init(TimelineKind.Home) + } +} diff --git a/app/src/test/java/com/keylesspalace/tusky/components/timeline/CachedTimelineViewModelTestStatusAction.kt b/app/src/test/java/com/keylesspalace/tusky/components/timeline/CachedTimelineViewModelTestStatusAction.kt new file mode 100644 index 0000000000..b39e07e5f7 --- /dev/null +++ b/app/src/test/java/com/keylesspalace/tusky/components/timeline/CachedTimelineViewModelTestStatusAction.kt @@ -0,0 +1,232 @@ +/* + * 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.timeline + +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.components.timeline.viewmodel.StatusAction +import com.keylesspalace.tusky.components.timeline.viewmodel.StatusActionSuccess +import com.keylesspalace.tusky.components.timeline.viewmodel.UiError +import com.keylesspalace.tusky.viewdata.StatusViewData +import kotlinx.coroutines.ExperimentalCoroutinesApi +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. + */ +// TODO: With the exception of the types, this is identical to +// NotificationsViewModelTestStatusAction. +@OptIn(ExperimentalCoroutinesApi::class) +class CachedTimelineViewModelTestStatusAction : CachedTimelineViewModelTestBase() { + private val status = mockStatus(pollOptions = listOf("Choice 1", "Choice 2", "Choice 3")) + private val statusViewData = StatusViewData( + 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/timeline/CachedTimelineViewModelTestStatusDisplayOptions.kt b/app/src/test/java/com/keylesspalace/tusky/components/timeline/CachedTimelineViewModelTestStatusDisplayOptions.kt new file mode 100644 index 0000000000..b43eccfd99 --- /dev/null +++ b/app/src/test/java/com/keylesspalace/tusky/components/timeline/CachedTimelineViewModelTestStatusDisplayOptions.kt @@ -0,0 +1,105 @@ +/* + * 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.timeline + +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.ExperimentalCoroutinesApi +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? + */ +// TODO: With the exception of the types, this is identical to +// NotificationsViewModelTestStatusDisplayOptions +@OptIn(ExperimentalCoroutinesApi::class) +class CachedTimelineViewModelTestStatusDisplayOptions : CachedTimelineViewModelTestBase() { + + 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/timeline/CachedTimelineViewModelTestUiState.kt b/app/src/test/java/com/keylesspalace/tusky/components/timeline/CachedTimelineViewModelTestUiState.kt new file mode 100644 index 0000000000..47f67c7937 --- /dev/null +++ b/app/src/test/java/com/keylesspalace/tusky/components/timeline/CachedTimelineViewModelTestUiState.kt @@ -0,0 +1,86 @@ +/* + * 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.timeline + +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import com.keylesspalace.tusky.appstore.PreferenceChangedEvent +import com.keylesspalace.tusky.components.timeline.viewmodel.UiState +import com.keylesspalace.tusky.settings.PrefKeys +import kotlinx.coroutines.ExperimentalCoroutinesApi +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? + */ +@OptIn(ExperimentalCoroutinesApi::class) +class CachedTimelineViewModelTestUiState : CachedTimelineViewModelTestBase() { + + private val initialUiState = UiState( + showFabWhileScrolling = true, + showMediaPreview = true + ) + + @Test + fun `should load initial UI state`() = 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() + } + } + + @Test + fun `showMediaPreview depends on MEDIA_PREVIEW_ENABLED preference`() = runTest { + // Prior + viewModel.uiState.test { + assertThat(expectMostRecentItem().showMediaPreview).isTrue() + } + + // Given (nothing to do here, set up is in base class) + + // When + accountPreferenceDataStore.putBoolean(PrefKeys.MEDIA_PREVIEW_ENABLED, false) + + // Then + viewModel.uiState.test { + assertThat(expectMostRecentItem().showMediaPreview).isFalse() + } + } +} diff --git a/app/src/test/java/com/keylesspalace/tusky/components/timeline/CachedTimelineViewModelTestVisibleId.kt b/app/src/test/java/com/keylesspalace/tusky/components/timeline/CachedTimelineViewModelTestVisibleId.kt new file mode 100644 index 0000000000..8e67487c63 --- /dev/null +++ b/app/src/test/java/com/keylesspalace/tusky/components/timeline/CachedTimelineViewModelTestVisibleId.kt @@ -0,0 +1,52 @@ +/* + * 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.timeline + +import com.google.common.truth.Truth.assertThat +import com.keylesspalace.tusky.components.timeline.viewmodel.InfallibleUiAction +import com.keylesspalace.tusky.db.AccountEntity +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.verify + +@OptIn(ExperimentalCoroutinesApi::class) +class CachedTimelineViewModelTestVisibleId : CachedTimelineViewModelTestBase() { + + @Test + fun `should save status ID to active account`() = runTest { + argumentCaptor().apply { + // Given + assertThat(accountManager.activeAccount?.lastVisibleHomeTimelineStatusId) + .isNull() + assertThat(viewModel.timelineKind) + .isEqualTo(TimelineKind.Home) + + // When + viewModel.accept(InfallibleUiAction.SaveVisibleId("1234")) + + // Then + // As a non-Home timline this should *not* save the account, and + // the last visible property should *not* have changed. + verify(accountManager).saveAccount(capture()) + assertThat(this.lastValue.lastVisibleHomeTimelineStatusId) + .isEqualTo("1234") + } + } +} diff --git a/app/src/test/java/com/keylesspalace/tusky/components/timeline/MainCoroutineRule.kt b/app/src/test/java/com/keylesspalace/tusky/components/timeline/MainCoroutineRule.kt new file mode 100644 index 0000000000..1bda27ff8d --- /dev/null +++ b/app/src/test/java/com/keylesspalace/tusky/components/timeline/MainCoroutineRule.kt @@ -0,0 +1,40 @@ +/* + * 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.timeline + +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 org.junit.rules.TestWatcher +import org.junit.runner.Description + +@OptIn(ExperimentalCoroutinesApi::class) +class MainCoroutineRule constructor(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() + } +} diff --git a/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineViewModelTestBase.kt b/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineViewModelTestBase.kt index 596c971abd..1f073e0c97 100644 --- a/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineViewModelTestBase.kt +++ b/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineViewModelTestBase.kt @@ -30,19 +30,12 @@ import com.keylesspalace.tusky.network.FilterModel import com.keylesspalace.tusky.settings.AccountPreferenceDataStore import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.usecase.TimelineCases -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.runBlocking -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.ArgumentMatchers.anyBoolean import org.mockito.ArgumentMatchers.anyString @@ -55,19 +48,6 @@ import org.robolectric.annotation.Config import retrofit2.HttpException import retrofit2.Response -@OptIn(ExperimentalCoroutinesApi::class) -class MainCoroutineRule constructor(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) @OptIn(ExperimentalCoroutinesApi::class) diff --git a/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineViewModelTestUiState.kt b/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineViewModelTestUiState.kt index a4c49b155f..1c11ab9cd8 100644 --- a/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineViewModelTestUiState.kt +++ b/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineViewModelTestUiState.kt @@ -41,7 +41,7 @@ class NetworkTimelineViewModelTestUiState : NetworkTimelineViewModelTestBase() { ) @Test - fun `should load initial filter from active account`() = runTest { + fun `should load initial UI state`() = runTest { viewModel.uiState.test { assertThat(expectMostRecentItem()).isEqualTo(initialUiState) } From 10d1aef55fc502fc1a450e72abf404ad93d72445 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Sat, 13 May 2023 23:06:41 +0200 Subject: [PATCH 072/156] Delete obsolete translations --- app/src/main/res/values-fr/strings.xml | 5 +---- app/src/main/res/values-tr/strings.xml | 5 +---- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 78b99c1d48..8f80487926 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -620,9 +620,6 @@ maintenant Les détails du compte n\'ont pas pu être chargés En vous connectant vous acceptez de vous tenir aux règles de %s. - Ordre de lecture - Les plus vieux en premier - Les plus récents en premier Arrêter de suivre #%s \? Échec de l\'épinglage Modification de %1$s le %2$s @@ -683,4 +680,4 @@ Ajouter un mot-clé Modifier mot-clé %s : %s - \ No newline at end of file + diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 49a0530cd0..bf68e8ad94 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -591,9 +591,6 @@ Bu senin ana zaman tünelin. Takip ettiğin hesapların son gönderileri burada yer alacak. \n \nTakip edebileceğin hesapları diğer zaman tünellerinden keşfedebilirsin, örneğin kendi sunucunun yerel zaman tünelinden [iconics gmd_group]. Veya hesapları adlarıyla arayabilirsin [iconics gmd_search], örneğin Mastodon hesabımızı bulmak için Tusky adıyla araman yeterli. - Okuma sırası - Önce en eski - Önce en yeni <ayarlanmadı> <geçersiz> Etkisizleştirildi @@ -663,4 +660,4 @@ Toplam kullanım Toplam hesap %1$d kişi %2$s etiketi hakkında konuşuyor - \ No newline at end of file + From 628f9b3292be4d65c2b11d325987faee877986ee Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Sun, 14 May 2023 15:42:14 +0200 Subject: [PATCH 073/156] Note the desired visibility for makeEmptyPageCache() --- .../tusky/components/timeline/NetworkTimelineRepository.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRepository.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRepository.kt index 7abf51e63b..60482ad3ca 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRepository.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRepository.kt @@ -180,7 +180,7 @@ class NetworkTimelineRepository @Inject constructor( * * The map key is the ID of the newest status in the page it maps to. */ - @VisibleForTesting + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) fun makeEmptyPageCache() = TreeMap>(compareBy({ it.length }, { it })) } } From 41f729eb62650c73e61300392cfe521bf95abe4d Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Sun, 14 May 2023 15:52:42 +0200 Subject: [PATCH 074/156] Lint --- .../tusky/components/timeline/viewmodel/TimelineViewModel.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt index 6973ee5d36..7893ec6614 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt @@ -82,7 +82,7 @@ data class UiState( /** Preferences the UI reacts to */ data class UiPrefs( val showFabWhileScrolling: Boolean, - val showMediaPreview: Boolean, + val showMediaPreview: Boolean ) { companion object { /** Relevant preference keys. Changes to any of these trigger a display update */ From 018ef0f9143828037a88c326c66bb4eaff02ad8e Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Mon, 12 Jun 2023 13:12:18 +0200 Subject: [PATCH 075/156] Make workerfactories private --- .../main/java/com/keylesspalace/tusky/worker/WorkerFactory.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/worker/WorkerFactory.kt b/app/src/main/java/com/keylesspalace/tusky/worker/WorkerFactory.kt index 73c87b2e5b..d41717c725 100644 --- a/app/src/main/java/com/keylesspalace/tusky/worker/WorkerFactory.kt +++ b/app/src/main/java/com/keylesspalace/tusky/worker/WorkerFactory.kt @@ -40,7 +40,7 @@ interface ChildWorkerFactory { * @see [com.keylesspalace.tusky.components.notifications.NotificationWorker] */ class WorkerFactory @Inject constructor( - val workerFactories: Map, @JvmSuppressWildcards Provider> + private val workerFactories: Map, @JvmSuppressWildcards Provider> ) : WorkerFactory() { override fun createWorker( appContext: Context, From 2cedf787928579435215857fd573fb0e2c069120 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Thu, 15 Jun 2023 19:02:53 +0200 Subject: [PATCH 076/156] Set initialLoadSize to match the page size --- .../tusky/components/timeline/NetworkTimelineRepository.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRepository.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRepository.kt index 60482ad3ca..ade5041ed6 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRepository.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRepository.kt @@ -89,7 +89,7 @@ class NetworkTimelineRepository @Inject constructor( } return Pager( - config = PagingConfig(pageSize = pageSize), + config = PagingConfig(pageSize = pageSize, initialLoadSize = PAGE_SIZE), remoteMediator = NetworkTimelineRemoteMediator( mastodonApi, accountManager, From f5a9836ab2d7868f79359c24a752cb893c951737 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Thu, 15 Jun 2023 19:33:23 +0200 Subject: [PATCH 077/156] Only use the active account at the time the view model was created --- .../timeline/viewmodel/TimelineViewModel.kt | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt index 5d1d7a4fca..f9c2f1a00d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt @@ -291,6 +291,8 @@ abstract class TimelineViewModel( private var filterRemoveReplies = false private var filterRemoveReblogs = false + val activeAccount = accountManager.activeAccount!! + init { viewModelScope.launch { updateFiltersFromPreferences().collectLatest { @@ -305,7 +307,7 @@ abstract class TimelineViewModel( statusDisplayOptions = MutableStateFlow( StatusDisplayOptions.from( sharedPreferences, - accountManager.activeAccount!! + activeAccount ) ) @@ -317,7 +319,7 @@ abstract class TimelineViewModel( statusDisplayOptions.value.make( sharedPreferences, it.preferenceKey, - accountManager.activeAccount!! + activeAccount ) } .collect { @@ -422,11 +424,9 @@ abstract class TimelineViewModel( .filterIsInstance() .distinctUntilChanged() .collectLatest { action -> - accountManager.activeAccount?.let { account -> - Log.d(TAG, "Saving Home timeline position at: ${action.visibleId}") - account.lastVisibleHomeTimelineStatusId = action.visibleId - accountManager.saveAccount(account) - } + Log.d(TAG, "Saving Home timeline position at: ${action.visibleId}") + activeAccount.lastVisibleHomeTimelineStatusId = action.visibleId + accountManager.saveAccount(activeAccount) } } } From cb4abe0c75e1c4c9d4225df43f4a6c33761b4622 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Thu, 15 Jun 2023 19:33:55 +0200 Subject: [PATCH 078/156] Save the reading position more often, use it when resuming --- .../tusky/components/timeline/TimelineFragment.kt | 13 +++++++++++++ .../timeline/viewmodel/CachedTimelineViewModel.kt | 2 +- .../timeline/viewmodel/NetworkTimelineViewModel.kt | 2 +- .../timeline/viewmodel/TimelineViewModel.kt | 8 ++++++++ 4 files changed, 23 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt index 13dbeb11b0..f3a0b38bea 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt @@ -34,6 +34,7 @@ import androidx.paging.LoadState import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.RecyclerView.SCROLL_STATE_IDLE import androidx.recyclerview.widget.SimpleItemAnimator import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener import at.connyduck.sparkbutton.helpers.Utils @@ -161,6 +162,18 @@ class TimelineFragment : } } } + + override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { + newState != SCROLL_STATE_IDLE && return + + // Save the ID of the first notification visible in the list, so the user's + // reading position is always restorable. + layoutManager.findFirstVisibleItemPosition().takeIf { it >= 0 }?.let { position -> + adapter.snapshot().getOrNull(position)?.id?.let { id -> + viewModel.accept(InfallibleUiAction.SaveVisibleId(visibleId = id)) + } + } + } }) } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt index 4d7a2e4b9c..b0aac00a50 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt @@ -76,7 +76,7 @@ class CachedTimelineViewModel @Inject constructor( super.init(timelineKind) statuses = getUiPrefs() .flatMapLatest { - getStatuses(timelineKind) + getStatuses(timelineKind, initialKey = getInitialKey()) }.cachedIn(viewModelScope) } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt index 963b0996aa..dde52e9396 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt @@ -70,7 +70,7 @@ class NetworkTimelineViewModel @Inject constructor( override fun init(timelineKind: TimelineKind) { super.init(timelineKind) - statuses = getStatuses(timelineKind).cachedIn(viewModelScope) + statuses = getStatuses(timelineKind, initialKey = getInitialKey()).cachedIn(viewModelScope) } /** @return Flow of statuses that make up the timeline of [kind] */ diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt index f9c2f1a00d..26f592b886 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt @@ -446,6 +446,14 @@ abstract class TimelineViewModel( } } + fun getInitialKey(): String? { + if (timelineKind != TimelineKind.Home) { + return null + } + + return activeAccount.lastVisibleHomeTimelineStatusId + } + abstract fun updatePoll(newPoll: Poll, status: StatusViewData) abstract fun changeExpanded(expanded: Boolean, status: StatusViewData) From 0931594f476dbeb76d0505ba68d333b6f5e85b39 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Thu, 15 Jun 2023 19:54:10 +0200 Subject: [PATCH 079/156] Use the `pageSize` parameter --- .../tusky/components/timeline/CachedTimelineRepository.kt | 2 +- .../tusky/components/timeline/NetworkTimelineRepository.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/CachedTimelineRepository.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/CachedTimelineRepository.kt index c651ecf803..09043f6490 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/CachedTimelineRepository.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/CachedTimelineRepository.kt @@ -72,7 +72,7 @@ class CachedTimelineRepository @Inject constructor( } return Pager( - config = PagingConfig(pageSize = PAGE_SIZE), + config = PagingConfig(pageSize = pageSize), remoteMediator = CachedTimelineRemoteMediator(accountManager, mastodonApi, appDatabase, gson), pagingSourceFactory = factory!! ).flow diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRepository.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRepository.kt index ade5041ed6..4b7e8cf40b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRepository.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRepository.kt @@ -89,7 +89,7 @@ class NetworkTimelineRepository @Inject constructor( } return Pager( - config = PagingConfig(pageSize = pageSize, initialLoadSize = PAGE_SIZE), + config = PagingConfig(pageSize = pageSize, initialLoadSize = pageSize), remoteMediator = NetworkTimelineRemoteMediator( mastodonApi, accountManager, From fc8fb775f91b0eb2a2f75e20b1ccd14dc86e669d Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Thu, 15 Jun 2023 19:55:21 +0200 Subject: [PATCH 080/156] Move save visible ID functionality in to a function --- .../components/timeline/TimelineFragment.kt | 29 +++++++++---------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt index f3a0b38bea..e04ce82ff5 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt @@ -165,14 +165,7 @@ class TimelineFragment : override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { newState != SCROLL_STATE_IDLE && return - - // Save the ID of the first notification visible in the list, so the user's - // reading position is always restorable. - layoutManager.findFirstVisibleItemPosition().takeIf { it >= 0 }?.let { position -> - adapter.snapshot().getOrNull(position)?.id?.let { id -> - viewModel.accept(InfallibleUiAction.SaveVisibleId(visibleId = id)) - } - } + saveVisibleId() } }) } @@ -426,6 +419,18 @@ class TimelineFragment : } } + /** + * Save the ID of the last visible status in the list + */ + private fun saveVisibleId() = layoutManager + .findLastCompletelyVisibleItemPosition() + .takeIf { it != RecyclerView.NO_POSITION } + ?.let { position -> + adapter.snapshot().getOrNull(position)?.id?.let { statusId -> + viewModel.accept(InfallibleUiAction.SaveVisibleId(visibleId = statusId)) + } + } + private fun setupSwipeRefreshLayout() { binding.swipeRefreshLayout.isEnabled = isSwipeToRefreshEnabled binding.swipeRefreshLayout.setOnRefreshListener(this) @@ -622,13 +627,7 @@ class TimelineFragment : override fun onPause() { super.onPause() - // Save the ID of the first visible status in the list - val position = layoutManager.findLastCompletelyVisibleItemPosition() - if (position != RecyclerView.NO_POSITION) { - adapter.snapshot().getOrNull(position)?.id?.let { statusId -> - viewModel.accept(InfallibleUiAction.SaveVisibleId(visibleId = statusId)) - } - } + saveVisibleId() } override fun onReselect() { From fbba2594ac99d8f0580a1d3dc6c2df7b193e3928 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Thu, 15 Jun 2023 20:13:34 +0200 Subject: [PATCH 081/156] Explicitly throw and catch timeline case errors --- .../tusky/components/timeline/viewmodel/TimelineViewModel.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt index 26f592b886..c9a1da6d69 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt @@ -21,6 +21,7 @@ import androidx.annotation.StringRes import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.paging.PagingData +import at.connyduck.calladapter.networkresult.getOrThrow import com.keylesspalace.tusky.R import com.keylesspalace.tusky.appstore.BlockEvent import com.keylesspalace.tusky.appstore.BookmarkEvent @@ -355,7 +356,7 @@ abstract class TimelineViewModel( action.poll.id, action.choices ) - } + }.getOrThrow() uiSuccess.emit(StatusActionSuccess.from(action)) } catch (e: Exception) { ifExpected(e) { _uiErrorChannel.send(UiError.make(e, action)) } From f380053877b6df9c4c0c85a03f4ac9b239b3a390 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Fri, 16 Jun 2023 10:29:31 +0200 Subject: [PATCH 082/156] Don't set a state restoration policy explicitly, the base adapter already does that. --- .../tusky/components/timeline/TimelinePagingAdapter.kt | 4 ---- 1 file changed, 4 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelinePagingAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelinePagingAdapter.kt index 5d76f63eee..9b60dc9c4c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelinePagingAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelinePagingAdapter.kt @@ -32,10 +32,6 @@ class TimelinePagingAdapter( private val statusListener: StatusActionListener, var statusDisplayOptions: StatusDisplayOptions ) : PagingDataAdapter(TimelineDifferCallback) { - init { - stateRestorationPolicy = StateRestorationPolicy.PREVENT_WHEN_EMPTY - } - override fun onCreateViewHolder(viewGroup: ViewGroup, viewType: Int): RecyclerView.ViewHolder { val inflater = LayoutInflater.from(viewGroup.context) return when (viewType) { From 8a6c2ccb949d1157630932fcc5e6df82582888bb Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Fri, 16 Jun 2023 12:14:41 +0200 Subject: [PATCH 083/156] Initial "Load newest" implementation --- .../components/timeline/TimelineFragment.kt | 5 ++++ .../viewmodel/CachedTimelineViewModel.kt | 3 ++- .../viewmodel/NetworkTimelineViewModel.kt | 8 ++++++- .../timeline/viewmodel/TimelineViewModel.kt | 24 +++++++++++++++++++ app/src/main/res/menu/fragment_timeline.xml | 5 ++++ app/src/main/res/values/strings.xml | 1 + 6 files changed, 44 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt index e04ce82ff5..92ec8fb059 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt @@ -415,6 +415,11 @@ class TimelineFragment : false } } + R.id.action_load_newest -> { + viewModel.accept(InfallibleUiAction.LoadNewest) + adapter.refresh() + true + } else -> false } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt index b0aac00a50..7672fc4c8d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt @@ -43,6 +43,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.merge import kotlinx.coroutines.launch import javax.inject.Inject @@ -74,7 +75,7 @@ class CachedTimelineViewModel @Inject constructor( @OptIn(ExperimentalCoroutinesApi::class) override fun init(timelineKind: TimelineKind) { super.init(timelineKind) - statuses = getUiPrefs() + statuses = merge(getUiPrefs(), reload) .flatMapLatest { getStatuses(timelineKind, initialKey = getInitialKey()) }.cachedIn(viewModelScope) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt index dde52e9396..794938d72f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt @@ -38,7 +38,9 @@ import com.keylesspalace.tusky.settings.AccountPreferenceDataStore import com.keylesspalace.tusky.usecase.TimelineCases import com.keylesspalace.tusky.util.toViewData import com.keylesspalace.tusky.viewdata.StatusViewData +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import javax.inject.Inject @@ -68,9 +70,13 @@ class NetworkTimelineViewModel @Inject constructor( override lateinit var statuses: Flow> + @OptIn(ExperimentalCoroutinesApi::class) override fun init(timelineKind: TimelineKind) { super.init(timelineKind) - statuses = getStatuses(timelineKind, initialKey = getInitialKey()).cachedIn(viewModelScope) + statuses = reload + .flatMapLatest { + getStatuses(timelineKind, initialKey = getInitialKey()) + }.cachedIn(viewModelScope) } /** @return Flow of statuses that make up the timeline of [kind] */ diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt index c9a1da6d69..4ca431922c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt @@ -65,6 +65,7 @@ import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.getAndUpdate import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onStart @@ -121,6 +122,12 @@ sealed class InfallibleUiAction : UiAction() { * 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 reading position, 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. + object LoadNewest : InfallibleUiAction() } sealed class UiSuccess { @@ -264,6 +271,9 @@ abstract class TimelineViewModel( /** Flow of user actions received from the UI */ private val uiAction = MutableSharedFlow() + /** Flow that can be used to trigger a full reload */ + protected val reload = MutableStateFlow(0) + /** Flow of successful action results */ // Note: Thisis 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 @@ -432,6 +442,20 @@ abstract class TimelineViewModel( } } + // Increment `reload` to trigger creation of a new PagingSource. + // Optionally save the home timeline's visible ID. + viewModelScope.launch { + uiAction + .filterIsInstance() + .collectLatest { + if (timelineKind == TimelineKind.Home) { + activeAccount.lastVisibleHomeTimelineStatusId = null + accountManager.saveAccount(activeAccount) + } + reload.getAndUpdate { it + 1 } + } + } + viewModelScope.launch { eventHub.events .filterIsInstance() diff --git a/app/src/main/res/menu/fragment_timeline.xml b/app/src/main/res/menu/fragment_timeline.xml index bf722917fe..fe8eec1f19 100644 --- a/app/src/main/res/menu/fragment_timeline.xml +++ b/app/src/main/res/menu/fragment_timeline.xml @@ -5,4 +5,9 @@ android:id="@+id/action_refresh" android:title="@string/action_refresh" app:showAsAction="never" /> + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d0b842bff9..e95bf44f3a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -809,4 +809,5 @@ For example the local timeline of your instance [iconics gmd_group]. Or you can search them by name [iconics gmd_search]; for example search for Tusky to find our Mastodon account. Load newest notifications + Load newest posts From b69962435c0759554c523dd168fb7d051bc9859b Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Sun, 18 Jun 2023 16:05:44 +0200 Subject: [PATCH 084/156] Use show/hide instead of toggling isVisible --- .../notifications/NotificationsFragment.kt | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt index fd9da4c02c..b41f6b3f7c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt @@ -63,7 +63,9 @@ import com.keylesspalace.tusky.interfaces.ActionButtonActivity import com.keylesspalace.tusky.interfaces.ReselectableFragment import com.keylesspalace.tusky.interfaces.StatusActionListener import com.keylesspalace.tusky.util.ListStatusAccessibilityDelegate +import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.openLink +import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.viewdata.AttachmentViewData.Companion.list import com.keylesspalace.tusky.viewdata.NotificationViewData @@ -395,23 +397,23 @@ class NotificationsFragment : adapter.loadStateFlow .distinctUntilChangedBy { it.refresh } .collect { loadState -> - binding.recyclerView.isVisible = true + binding.recyclerView.show() binding.progressBar.isVisible = loadState.refresh is LoadState.Loading && !binding.swipeRefreshLayout.isRefreshing binding.swipeRefreshLayout.isRefreshing = loadState.refresh is LoadState.Loading && !binding.progressBar.isVisible - binding.statusView.isVisible = false + binding.statusView.hide() if (loadState.refresh is LoadState.NotLoading) { if (adapter.itemCount == 0) { binding.statusView.setup( R.drawable.elephant_friend_empty, R.string.message_empty ) - binding.recyclerView.isVisible = false - binding.statusView.isVisible = true + binding.recyclerView.hide() + binding.statusView.show() } else { - binding.statusView.isVisible = false + binding.statusView.hide() } } @@ -430,8 +432,8 @@ class NotificationsFragment : ) { adapter.retry() } } } - binding.recyclerView.isVisible = false - binding.statusView.isVisible = true + binding.recyclerView.hide() + binding.statusView.show() } } } From f14149ba633ff528d567dc64485fd9b60d37f349 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Sun, 18 Jun 2023 16:06:24 +0200 Subject: [PATCH 085/156] WIP: Failure to load --- .../components/timeline/TimelineFragment.kt | 64 +++++++++++++++---- 1 file changed, 50 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt index 92ec8fb059..6373bed648 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt @@ -26,6 +26,7 @@ import android.view.ViewGroup import android.view.accessibility.AccessibilityManager import androidx.core.content.ContextCompat import androidx.core.view.MenuProvider +import androidx.core.view.isVisible import androidx.lifecycle.Lifecycle import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.lifecycleScope @@ -349,40 +350,75 @@ class TimelineFragment : Log.d(TAG, "loadState: $loadState") Log.d(TAG, " adapter.itemCount: ${adapter.itemCount}") Log.d(TAG, " refresh?: ${loadState.refresh}") - if (loadState.refresh != LoadState.Loading && loadState.source.refresh != LoadState.Loading) { - binding.swipeRefreshLayout.isRefreshing = false - } + Log.d(TAG, " source.refresh?: ${loadState.source.refresh}") + Log.d(TAG, " mediator.refresh?: ${loadState.mediator?.refresh}") + + binding.progressBar.isVisible = loadState.refresh is LoadState.Loading && + !binding.swipeRefreshLayout.isRefreshing + binding.swipeRefreshLayout.isRefreshing = + loadState.refresh is LoadState.Loading && !binding.progressBar.isVisible binding.statusView.hide() binding.progressBar.hide() - if (adapter.itemCount == 0) { - when (loadState.refresh) { - is LoadState.NotLoading -> { + when (loadState.refresh) { + is LoadState.NotLoading -> { + if (adapter.itemCount == 0) { if (loadState.append is LoadState.NotLoading && loadState.source.refresh is LoadState.NotLoading) { binding.statusView.show() - binding.statusView.setup(R.drawable.elephant_friend_empty, R.string.message_empty) + binding.statusView.setup( + R.drawable.elephant_friend_empty, + R.string.message_empty + ) if (timelineKind == TimelineKind.Home) { binding.statusView.showHelp(R.string.help_empty_home) } } + } else { + binding.statusView.hide() + } + } + is LoadState.Error -> { + val message = when ((loadState.refresh as LoadState.Error).error) { + is IOException -> R.string.error_network + else -> R.string.error_generic } - is LoadState.Error -> { - binding.statusView.show() + if (adapter.itemCount == 0) { if ((loadState.refresh as LoadState.Error).error is IOException) { - binding.statusView.setup(R.drawable.elephant_offline, R.string.error_network) { + binding.statusView.setup( + R.drawable.elephant_offline, + message + ) { onRefresh() } } else { - binding.statusView.setup(R.drawable.elephant_error, R.string.error_generic) { + binding.statusView.setup( + R.drawable.elephant_error, + message + ) { onRefresh() } } + binding.recyclerView.hide() + binding.statusView.show() + } else { + Snackbar.make( + (activity as ActionButtonActivity).actionButton ?: binding.root, + getString(message), + Snackbar.LENGTH_INDEFINITE + ) + .setTextMaxLines(5) + .setAction(R.string.action_retry) { + onRefresh() + } + .show() + binding.recyclerView.show() + binding.statusView.hide() } - is LoadState.Loading -> { - binding.progressBar.show() - } + } + is LoadState.Loading -> { + binding.progressBar.show() } } } From f3a8834eed106740ea1fd2ac7c1aee63acb7b377 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Mon, 19 Jun 2023 18:33:53 +0200 Subject: [PATCH 086/156] Simplify repeated code that shows errors Instead of repeating the same if/else check on the error type when setting up the background message, move this in to BackgroundMessageView. Provide different `setup()` variants, including one that just takes a throwable and a handler, and figures out the correct drawables and error message. Update and simplify call sites. --- .../tusky/AccountsInListFragment.kt | 16 +--------------- .../account/list/ListsForAccountFragment.kt | 12 +----------- .../account/media/AccountMediaFragment.kt | 8 +------- .../accountlist/AccountListFragment.kt | 13 +++---------- .../conversation/ConversationsFragment.kt | 12 +----------- .../followedtags/FollowedTagsActivity.kt | 7 +------ .../fragment/InstanceListFragment.kt | 14 +++----------- .../notifications/NotificationsFragment.kt | 16 +--------------- .../scheduled/ScheduledStatusActivity.kt | 11 +---------- .../components/viewthread/ViewThreadFragment.kt | 17 +---------------- .../viewthread/edits/ViewEditsFragment.kt | 14 +------------- .../tusky/util/ThrowableExtensions.kt | 16 ++++++++++++++++ .../tusky/view/BackgroundMessageView.kt | 16 ++++++++++++++-- app/src/main/res/values/strings.xml | 2 +- 14 files changed, 46 insertions(+), 128 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/AccountsInListFragment.kt b/app/src/main/java/com/keylesspalace/tusky/AccountsInListFragment.kt index 7821630b58..a20526e959 100644 --- a/app/src/main/java/com/keylesspalace/tusky/AccountsInListFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/AccountsInListFragment.kt @@ -46,7 +46,6 @@ import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.viewmodel.AccountsInListViewModel import com.keylesspalace.tusky.viewmodel.State import kotlinx.coroutines.launch -import java.io.IOException import javax.inject.Inject private typealias AccountInfo = Pair @@ -146,23 +145,10 @@ class AccountsInListFragment : DialogFragment(), Injectable { private fun handleError(error: Throwable) { binding.messageView.show() - val retryAction = { _: View -> + binding.messageView.setup(error) { _: View -> binding.messageView.hide() viewModel.load(listId) } - if (error is IOException) { - binding.messageView.setup( - R.drawable.elephant_offline, - R.string.error_network, - retryAction - ) - } else { - binding.messageView.setup( - R.drawable.elephant_error, - R.string.error_generic, - retryAction - ) - } } private fun onRemoveFromList(accountId: String) { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/account/list/ListsForAccountFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/account/list/ListsForAccountFragment.kt index d527f613da..08c93756b9 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/account/list/ListsForAccountFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/account/list/ListsForAccountFragment.kt @@ -40,7 +40,6 @@ import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.visible import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch -import java.io.IOException import javax.inject.Inject class ListsForAccountFragment : DialogFragment(), Injectable { @@ -103,16 +102,7 @@ class ListsForAccountFragment : DialogFragment(), Injectable { binding.listsView.hide() binding.messageView.apply { show() - - if (error is IOException) { - setup(R.drawable.elephant_offline, R.string.error_network) { - load() - } - } else { - setup(R.drawable.elephant_error, R.string.error_generic) { - load() - } - } + setup(error) { load() } } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaFragment.kt index df87372eb7..b39a8b5b48 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaFragment.kt @@ -51,7 +51,6 @@ import com.mikepenz.iconics.utils.colorInt import com.mikepenz.iconics.utils.sizeDp import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch -import java.io.IOException import javax.inject.Inject /** @@ -133,12 +132,7 @@ class AccountMediaFragment : } is LoadState.Error -> { binding.statusView.show() - - if ((loadState.refresh as LoadState.Error).error is IOException) { - binding.statusView.setup(R.drawable.elephant_offline, R.string.error_network, null) - } else { - binding.statusView.setup(R.drawable.elephant_error, R.string.error_generic, null) - } + binding.statusView.setup((loadState.refresh as LoadState.Error).error) } is LoadState.Loading -> { binding.progressBar.show() diff --git a/app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListFragment.kt index dfa1726a85..68eba3c76c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListFragment.kt @@ -393,16 +393,9 @@ class AccountListFragment : if (adapter.itemCount == 0) { binding.messageView.show() - if (throwable is IOException) { - binding.messageView.setup(R.drawable.elephant_offline, R.string.error_network) { - binding.messageView.hide() - this.fetchAccounts(null) - } - } else { - binding.messageView.setup(R.drawable.elephant_error, R.string.error_generic) { - binding.messageView.hide() - this.fetchAccounts(null) - } + binding.messageView.setup(throwable) { + binding.messageView.hide() + this.fetchAccounts(null) } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt index ce0b3bddab..462e3808ab 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt @@ -64,7 +64,6 @@ import com.mikepenz.iconics.utils.sizeDp import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch -import java.io.IOException import javax.inject.Inject import kotlin.time.DurationUnit import kotlin.time.toDuration @@ -139,16 +138,7 @@ class ConversationsFragment : } is LoadState.Error -> { binding.statusView.show() - - if ((loadState.refresh as LoadState.Error).error is IOException) { - binding.statusView.setup(R.drawable.elephant_offline, R.string.error_network) { - refreshContent() - } - } else { - binding.statusView.setup(R.drawable.elephant_error, R.string.error_generic) { - refreshContent() - } - } + binding.statusView.setup((loadState.refresh as LoadState.Error).error) { refreshContent() } } is LoadState.Loading -> { binding.progressBar.show() diff --git a/app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsActivity.kt index 94a0b47efa..b6b56d4a36 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsActivity.kt @@ -31,7 +31,6 @@ import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.visible import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch -import java.io.IOException import javax.inject.Inject class FollowedTagsActivity : @@ -108,11 +107,7 @@ class FollowedTagsActivity : binding.followedTagsView.hide() binding.followedTagsMessageView.show() val errorState = loadState.refresh as LoadState.Error - if (errorState.error is IOException) { - binding.followedTagsMessageView.setup(R.drawable.elephant_offline, R.string.error_network) { retry() } - } else { - binding.followedTagsMessageView.setup(R.drawable.elephant_error, R.string.error_generic) { retry() } - } + binding.followedTagsMessageView.setup(errorState.error) { retry() } Log.w(TAG, "error loading followed hashtags", errorState.error) } else { binding.followedTagsView.show() diff --git a/app/src/main/java/com/keylesspalace/tusky/components/instancemute/fragment/InstanceListFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/instancemute/fragment/InstanceListFragment.kt index 1e4925a518..1da0a2b7d8 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/instancemute/fragment/InstanceListFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/instancemute/fragment/InstanceListFragment.kt @@ -26,7 +26,6 @@ import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.view.EndlessOnScrollListener import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import kotlinx.coroutines.launch -import java.io.IOException import javax.inject.Inject class InstanceListFragment : Fragment(R.layout.fragment_instance_list), Injectable, InstanceActionListener { @@ -146,16 +145,9 @@ class InstanceListFragment : Fragment(R.layout.fragment_instance_list), Injectab if (adapter.itemCount == 0) { binding.messageView.show() - if (throwable is IOException) { - binding.messageView.setup(R.drawable.elephant_offline, R.string.error_network) { - binding.messageView.hide() - this.fetchInstances(null) - } - } else { - binding.messageView.setup(R.drawable.elephant_error, R.string.error_generic) { - binding.messageView.hide() - this.fetchInstances(null) - } + binding.messageView.setup(throwable) { + binding.messageView.hide() + this.fetchInstances(null) } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt index b41f6b3f7c..a1b2143b78 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt @@ -81,7 +81,6 @@ import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch -import java.io.IOException import javax.inject.Inject class NotificationsFragment : @@ -418,20 +417,7 @@ class NotificationsFragment : } if (loadState.refresh is LoadState.Error) { - when ((loadState.refresh as LoadState.Error).error) { - is IOException -> { - binding.statusView.setup( - R.drawable.elephant_offline, - R.string.error_network - ) { adapter.retry() } - } - else -> { - binding.statusView.setup( - R.drawable.elephant_error, - R.string.error_generic - ) { adapter.retry() } - } - } + binding.statusView.setup((loadState.refresh as LoadState.Error).error) { adapter.retry() } binding.recyclerView.hide() binding.statusView.show() } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledStatusActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledStatusActivity.kt index 102e67be5a..a53c08928c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledStatusActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledStatusActivity.kt @@ -47,7 +47,6 @@ import com.mikepenz.iconics.utils.colorInt import com.mikepenz.iconics.utils.sizeDp import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch -import java.io.IOException import javax.inject.Inject class ScheduledStatusActivity : @@ -102,15 +101,7 @@ class ScheduledStatusActivity : binding.errorMessageView.show() val errorState = loadState.refresh as LoadState.Error - if (errorState.error is IOException) { - binding.errorMessageView.setup(R.drawable.elephant_offline, R.string.error_network) { - refreshStatuses() - } - } else { - binding.errorMessageView.setup(R.drawable.elephant_error, R.string.error_generic) { - refreshStatuses() - } - } + binding.errorMessageView.setup(errorState.error) { refreshStatuses() } } if (loadState.refresh != LoadState.Loading) { binding.swipeRefreshLayout.isRefreshing = false diff --git a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadFragment.kt index 860acac923..2c6e918c60 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadFragment.kt @@ -60,7 +60,6 @@ import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import java.io.IOException import javax.inject.Inject class ViewThreadFragment : @@ -201,21 +200,7 @@ class ViewThreadFragment : binding.recyclerView.hide() binding.statusView.show() - if (uiState.throwable is IOException) { - binding.statusView.setup( - R.drawable.elephant_offline, - R.string.error_network - ) { - viewModel.retry(thisThreadsStatusId) - } - } else { - binding.statusView.setup( - R.drawable.elephant_error, - R.string.error_generic - ) { - viewModel.retry(thisThreadsStatusId) - } - } + binding.statusView.setup(uiState.throwable) { viewModel.retry(thisThreadsStatusId) } } is ThreadUiState.Success -> { if (uiState.statusViewData.none { viewData -> viewData.isDetailed }) { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsFragment.kt index 9fa5a30fff..95a0b96d13 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsFragment.kt @@ -53,7 +53,6 @@ import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial import com.mikepenz.iconics.utils.colorInt import com.mikepenz.iconics.utils.sizeDp import kotlinx.coroutines.launch -import java.io.IOException import javax.inject.Inject class ViewEditsFragment : @@ -112,14 +111,6 @@ class ViewEditsFragment : binding.initialProgressBar.hide() when (uiState.throwable) { - is IOException -> { - binding.statusView.setup( - R.drawable.elephant_offline, - R.string.error_network - ) { - viewModel.loadEdits(statusId, force = true) - } - } is ViewEditsViewModel.MissingEditsException -> { binding.statusView.setup( R.drawable.elephant_friend_empty, @@ -127,10 +118,7 @@ class ViewEditsFragment : ) } else -> { - binding.statusView.setup( - R.drawable.elephant_error, - R.string.error_generic - ) { + binding.statusView.setup(uiState.throwable) { viewModel.loadEdits(statusId, force = true) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ThrowableExtensions.kt b/app/src/main/java/com/keylesspalace/tusky/util/ThrowableExtensions.kt index 26f962554b..a3811a358f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/ThrowableExtensions.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/ThrowableExtensions.kt @@ -1,8 +1,11 @@ package com.keylesspalace.tusky.util +import android.content.Context +import com.keylesspalace.tusky.R import org.json.JSONException import org.json.JSONObject import retrofit2.HttpException +import java.io.IOException /** * checks if this throwable indicates an error causes by a 4xx/5xx server response and @@ -24,3 +27,16 @@ fun Throwable.getServerErrorMessage(): String? { } return null } + +/** @return A drawable resource to accompany the error message for this throwable */ +fun Throwable.getDrawableRes(): Int = when (this) { + is IOException -> R.drawable.elephant_offline + is HttpException -> R.drawable.elephant_offline + else -> R.drawable.elephant_error +} + +/** @return A string error message for this throwable */ +fun Throwable.getErrorString(context: Context): String = getServerErrorMessage() ?: when (this) { + is IOException -> context.getString(R.string.error_network) + else -> context.getString(R.string.error_generic) +} diff --git a/app/src/main/java/com/keylesspalace/tusky/view/BackgroundMessageView.kt b/app/src/main/java/com/keylesspalace/tusky/view/BackgroundMessageView.kt index 97078d5023..650d92ccbe 100644 --- a/app/src/main/java/com/keylesspalace/tusky/view/BackgroundMessageView.kt +++ b/app/src/main/java/com/keylesspalace/tusky/view/BackgroundMessageView.kt @@ -13,6 +13,8 @@ import androidx.annotation.StringRes import com.keylesspalace.tusky.R import com.keylesspalace.tusky.databinding.ViewBackgroundMessageBinding import com.keylesspalace.tusky.util.addDrawables +import com.keylesspalace.tusky.util.getDrawableRes +import com.keylesspalace.tusky.util.getErrorString import com.keylesspalace.tusky.util.visible /** @@ -35,16 +37,26 @@ class BackgroundMessageView @JvmOverloads constructor( } } + fun setup(throwable: Throwable, listener: ((v: View) -> Unit)? = null) { + setup(throwable.getDrawableRes(), throwable.getErrorString(context), listener) + } + + fun setup( + @DrawableRes imageRes: Int, + @StringRes messageRes: Int, + clickListener: ((v: View) -> Unit)? = null + ) = setup(imageRes, context.getString(messageRes), clickListener) + /** * Setup image, message and button. * If [clickListener] is `null` then the button will be hidden. */ fun setup( @DrawableRes imageRes: Int, - @StringRes messageRes: Int, + message: String, clickListener: ((v: View) -> Unit)? = null ) { - binding.messageTextView.setText(messageRes) + binding.messageTextView.text = message binding.messageTextView.movementMethod = LinkMovementMethod.getInstance() binding.imageView.setImageResource(imageRes) binding.button.setOnClickListener(clickListener) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7c3da39111..d9d43cdacf 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -18,7 +18,7 @@ An error occurred. - A network error occurred! Please check your connection and try again! + A network error occurred. Please check your connection and try again. This cannot be empty. Invalid domain entered Failed authenticating with that instance. If this persists, try "Login in Browser" from the menu. From 61ab11a2ac643d623bde36f481594123b16a197b Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Mon, 19 Jun 2023 18:34:56 +0200 Subject: [PATCH 087/156] Show errors as snackbar is the list has contents This allows the user to still view their content even if there are temporary network issues. --- .../components/timeline/TimelineFragment.kt | 113 ++++++++---------- 1 file changed, 50 insertions(+), 63 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt index 6373bed648..b1d8336b6e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt @@ -63,6 +63,8 @@ import com.keylesspalace.tusky.interfaces.RefreshableFragment import com.keylesspalace.tusky.interfaces.ReselectableFragment import com.keylesspalace.tusky.interfaces.StatusActionListener import com.keylesspalace.tusky.util.ListStatusAccessibilityDelegate +import com.keylesspalace.tusky.util.getDrawableRes +import com.keylesspalace.tusky.util.getErrorString import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.unsafeLazy @@ -84,7 +86,6 @@ import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch -import java.io.IOException import javax.inject.Inject class TimelineFragment : @@ -115,6 +116,14 @@ class TimelineFragment : private lateinit var layoutManager: LinearLayoutManager + /** The active snackbar, if any */ + // TODO: This shouldn't be necessary, the snackbar should dismiss itself if the layout + // changes. It doesn't, because the CoordinatorLayout is in the activity, not the fragment. + // I think the correct fix is to include the FAB in each fragment layout that needs it, + // ensuring that the outermost fragment view is a CoordinatorLayout. That will auto-dismiss + // the snackbar when the fragment is paused. + private var snackbar: Snackbar? = null + private var isSwipeToRefreshEnabled = true override fun onCreate(savedInstanceState: Bundle?) { @@ -218,18 +227,18 @@ class TimelineFragment : error.throwable.localizedMessage ?: getString(R.string.ui_error_unknown) ) - val snackbar = Snackbar.make( + snackbar = Snackbar.make( // Without this the FAB will not move out of the way (activity as ActionButtonActivity).actionButton ?: binding.root, message, Snackbar.LENGTH_INDEFINITE ).setTextMaxLines(5) error.action?.let { action -> - snackbar.setAction(R.string.action_retry) { + snackbar!!.setAction(R.string.action_retry) { viewModel.accept(action) } } - snackbar.show() + snackbar!!.show() // The status view has pre-emptively updated its state to show // that the action succeeded. Since it hasn't, re-bind the view @@ -353,72 +362,49 @@ class TimelineFragment : Log.d(TAG, " source.refresh?: ${loadState.source.refresh}") Log.d(TAG, " mediator.refresh?: ${loadState.mediator?.refresh}") + val listIsEmpty = loadState.refresh is LoadState.NotLoading && adapter.itemCount == 0 + binding.progressBar.isVisible = loadState.refresh is LoadState.Loading && !binding.swipeRefreshLayout.isRefreshing binding.swipeRefreshLayout.isRefreshing = loadState.refresh is LoadState.Loading && !binding.progressBar.isVisible + binding.recyclerView.show() binding.statusView.hide() - binding.progressBar.hide() - - when (loadState.refresh) { - is LoadState.NotLoading -> { - if (adapter.itemCount == 0) { - if (loadState.append is LoadState.NotLoading && loadState.source.refresh is LoadState.NotLoading) { - binding.statusView.show() - binding.statusView.setup( - R.drawable.elephant_friend_empty, - R.string.message_empty - ) - if (timelineKind == TimelineKind.Home) { - binding.statusView.showHelp(R.string.help_empty_home) - } - } - } else { - binding.statusView.hide() - } - } - is LoadState.Error -> { - val message = when ((loadState.refresh as LoadState.Error).error) { - is IOException -> R.string.error_network - else -> R.string.error_generic - } - if (adapter.itemCount == 0) { - if ((loadState.refresh as LoadState.Error).error is IOException) { - binding.statusView.setup( - R.drawable.elephant_offline, - message - ) { - onRefresh() - } - } else { - binding.statusView.setup( - R.drawable.elephant_error, - message - ) { - onRefresh() - } - } - binding.recyclerView.hide() - binding.statusView.show() - } else { - Snackbar.make( - (activity as ActionButtonActivity).actionButton ?: binding.root, - getString(message), - Snackbar.LENGTH_INDEFINITE - ) - .setTextMaxLines(5) - .setAction(R.string.action_retry) { - onRefresh() - } - .show() - binding.recyclerView.show() - binding.statusView.hide() - } + if (listIsEmpty) { + binding.statusView.setup( + R.drawable.elephant_friend_empty, + R.string.message_empty + ) + if (timelineKind == TimelineKind.Home) { + binding.statusView.showHelp(R.string.help_empty_home) } - is LoadState.Loading -> { - binding.progressBar.show() + binding.recyclerView.hide() + binding.statusView.show() + return@collect + } + + if (loadState.refresh is LoadState.Error) { + val message = (loadState.refresh as LoadState.Error).error.getErrorString(requireContext()) + + // Show errors as a snackbar if there is existing content to show + // (either cached, or in the adapter), or as a full screen error + // otherwise. + if (viewModel is CachedTimelineViewModel || adapter.itemCount > 0) { + snackbar = Snackbar.make( + (activity as ActionButtonActivity).actionButton ?: binding.root, + message, + Snackbar.LENGTH_INDEFINITE + ) + .setTextMaxLines(5) + .setAction(R.string.action_retry) { onRefresh() } + snackbar!!.show() + } else { + binding.recyclerView.hide() + val drawableRes = (loadState.refresh as LoadState.Error).error.getDrawableRes() + binding.statusView.setup(drawableRes, message) { onRefresh() } + binding.statusView.show() } } } @@ -463,7 +449,7 @@ class TimelineFragment : /** * Save the ID of the last visible status in the list */ - private fun saveVisibleId() = layoutManager + fun saveVisibleId() = layoutManager .findLastCompletelyVisibleItemPosition() .takeIf { it != RecyclerView.NO_POSITION } ?.let { position -> @@ -669,6 +655,7 @@ class TimelineFragment : super.onPause() saveVisibleId() + snackbar?.dismiss() } override fun onReselect() { From 05748ad83d0253a51af0488a94d47a34629378ee Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Tue, 20 Jun 2023 00:25:01 +0200 Subject: [PATCH 088/156] Remove unnecessary distinctUntilChanged The very first time data is fetched it transitions NotLoading -> NotLoading, but the first time adapter.itemCount = 0. If you have the distinctUntilChanged the second NotLoading state is ignored, so the initial "welcome view" remains displayed. --- .../tusky/components/notifications/NotificationsFragment.kt | 2 -- .../keylesspalace/tusky/components/timeline/TimelineFragment.kt | 2 -- 2 files changed, 4 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt index a1b2143b78..73f76ad710 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt @@ -76,7 +76,6 @@ import com.mikepenz.iconics.utils.sizeDp import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.onEach @@ -394,7 +393,6 @@ class NotificationsFragment : // Update the UI from the loadState adapter.loadStateFlow - .distinctUntilChangedBy { it.refresh } .collect { loadState -> binding.recyclerView.show() binding.progressBar.isVisible = loadState.refresh is LoadState.Loading && diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt index b1d8336b6e..6b72766d6d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt @@ -80,7 +80,6 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.flow @@ -354,7 +353,6 @@ class TimelineFragment : // Update the UI from the combined load state adapter.loadStateFlow - .distinctUntilChangedBy { it.refresh } .collect { loadState -> Log.d(TAG, "loadState: $loadState") Log.d(TAG, " adapter.itemCount: ${adapter.itemCount}") From 95b6120f3851293cbd0ca4f2fa706c15b3cb7467 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Tue, 20 Jun 2023 00:26:48 +0200 Subject: [PATCH 089/156] Retry the adapter operation Retrying causes one Error -> Loading -> Error transition. Refreshing causes two, which meant the snackbar was dismissed, then shown, then shown again (which dismissed the first one), causing flickering. --- .../tusky/components/timeline/TimelineFragment.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt index 6b72766d6d..7b5e53cbf2 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt @@ -396,12 +396,12 @@ class TimelineFragment : Snackbar.LENGTH_INDEFINITE ) .setTextMaxLines(5) - .setAction(R.string.action_retry) { onRefresh() } + .setAction(R.string.action_retry) { adapter.retry() } snackbar!!.show() } else { binding.recyclerView.hide() val drawableRes = (loadState.refresh as LoadState.Error).error.getDrawableRes() - binding.statusView.setup(drawableRes, message) { onRefresh() } + binding.statusView.setup(drawableRes, message) { adapter.retry() } binding.statusView.show() } } From aa84ad4563e146de73cbbe52c65dc3b634147b85 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Wed, 21 Jun 2023 00:47:34 +0200 Subject: [PATCH 090/156] Fix bookmarks and other timelines with weird paging rules --- .../timeline/NetworkTimelineRepository.kt | 7 ++- .../viewmodel/NetworkTimelinePagingSource.kt | 61 +++++++++++++++++-- 2 files changed, 60 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRepository.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRepository.kt index 4b7e8cf40b..79efbd4e1c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRepository.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRepository.kt @@ -40,12 +40,13 @@ data class Page constructor( /** Loaded data */ val data: MutableList, /** - * [Key] for previous page if more data can be loaded in that direction, `null` - * otherwise. + * [Key] for previous page (newer results, PREPEND operation) if more data can be loaded in + * that direction, `null` otherwise. */ val prevKey: Key? = null, /** - * [Key] for next page if more data can be loaded in that direction, `null` otherwise. + * [Key] for next page (older results, APPEND operation) if more data can be loaded in that + * direction, `null` otherwise. */ val nextKey: Key? = null ) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelinePagingSource.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelinePagingSource.kt index fb9b594567..085652c6c5 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelinePagingSource.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelinePagingSource.kt @@ -55,17 +55,68 @@ class NetworkTimelinePagingSource @Inject constructor( return@synchronized when (params) { is LoadParams.Refresh -> { - // If no key then return the latest page. Otherwise return the request page. + // If no key then return the latest page. Otherwise return the requested page. if (params.key == null) { pages.lastEntry()?.value } else { pages[params.key] } } - // Load the page immediately after the key - is LoadParams.Append -> pages.lowerEntry(params.key)?.value - // Load the page immediately before the key - is LoadParams.Prepend -> pages.higherEntry(params.key)?.value + + // Loading previous / next pages (`Prepend` or `Append`) is a little complicated. + // + // `pages` is keyed by the ID of the first item in the list of data for that page. + // This is so that `Refresh` (above) is straightforward. + // + // Append and Prepend requests have a `params.key` that corresponds to the previous + // or next page. For some timeline types those keys are identical to the item + // keys and match the IDs of the first and last items in the data for that page. + // + // But for some timeline types they are completely different. + // + // For example, bookmarks might have item keys that look like 110542553707722778 + // but prevKey / nextKey values that look like 1480606 / 1229303. + // + // There's no guarantee that the `nextKey` value for one page matches the `prevKey` + // value of the page immediately before it. + // + // E.g., suppose `pages` has the following entries (older entries have lower page + // indices). + // + // .--- page index + // | .-- ID of first item (key in `pages`) + // v V + // 0: k: 109934818460629189, prevKey: 995916, nextKey: 941865 + // 1: k: 110033940961955385, prevKey: 1073324, nextKey: 997376 + // 2: k: 110239564017438509, prevKey: 1224838, nextKey: 1073352 + // 3: k: 110542553707722778, prevKey: 1480606, nextKey: 1229303 + // + // And the request is `LoadParams.Append` with `params.key` == 1073352. This means + // "fetch the page *before* the page that has `nextKey` == 1073352". + // + // The desired page has index 1. But that can't be found directly, because although + // the page after it (index 2) points back to it with the `nextKey` value, the page + // at index 1 **does not** have a `prevKey` value of 1073352. There can be gaps in + // the `prevKey` / `nextKey` chain -- I assume this is a Mastodon implementation + // detail. + // + // Further, we can't assume anything about the structure of the keys. + // + // To find the correct page for Append we must: + // + // 1. Find the page that has a `nextKey` value that matches `params.key` (page 2) + // 2. Get that page's key ("110239564017438509") + // 3. Return the page with the key that is immediately lower than the key from step 2 + // + // The approach for Prepend is the same, except it is `prevKey` that is checked. + is LoadParams.Append -> { + pages.firstNotNullOfOrNull { entry -> entry.takeIf { it.value.nextKey == params.key }?.value } + ?.let { page -> pages.lowerEntry(page.data.first().id)?.value } + } + is LoadParams.Prepend -> { + pages.firstNotNullOfOrNull { entry -> entry.takeIf { it.value.prevKey == params.key }?.value } + ?.let { page -> pages.higherEntry(page.data.first().id)?.value } + } } } From 9c4aadb0efcdcc1b48669d0bc298fa56da45c09c Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Wed, 21 Jun 2023 09:48:53 +0200 Subject: [PATCH 091/156] Update tests to be correct --- .../NetworkTimelinePagingSourceTest.kt | 64 ++----------------- 1 file changed, 7 insertions(+), 57 deletions(-) diff --git a/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelinePagingSourceTest.kt b/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelinePagingSourceTest.kt index 34a294b87f..ea8d7e9468 100644 --- a/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelinePagingSourceTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelinePagingSourceTest.kt @@ -79,9 +79,9 @@ class NetworkTimelinePagingSourceTest { assertThat((loadResult as? LoadResult.Page)) .isEqualTo( LoadResult.Page( - data = listOf(mockStatus(id = "0")), - prevKey = "1", - nextKey = null + data = listOf(mockStatus(id = "1")), + prevKey = "2", + nextKey = "0" ) ) } @@ -103,9 +103,9 @@ class NetworkTimelinePagingSourceTest { assertThat((loadResult as? LoadResult.Page)) .isEqualTo( LoadResult.Page( - data = listOf(mockStatus(id = "2")), - prevKey = null, - nextKey = "1" + data = listOf(mockStatus(id = "1")), + prevKey = "2", + nextKey = "0" ) ) } @@ -134,31 +134,6 @@ class NetworkTimelinePagingSourceTest { ) } - @Test - fun `Append with a gap returns the page after`() = runTest { - // Given - val pages = makeEmptyPageCache() - pages["20"] = Page(data = mutableListOf(mockStatus(id = "20")), nextKey = "10") - pages["10"] = Page(data = mutableListOf(mockStatus(id = "10")), nextKey = "0", prevKey = "20") - pages["0"] = Page(data = mutableListOf(mockStatus(id = "0")), prevKey = "10") - val pagingSource = NetworkTimelinePagingSource(pages) - - // When - val loadResult = pagingSource.load(PagingSource.LoadParams.Append("9", 2, false)) - - // Then - assertThat(loadResult).isInstanceOf(LoadResult.Page::class.java) - assertThat((loadResult as? LoadResult.Page)) - .isEqualTo( - LoadResult.Page( - // key="9" should return the statuses in page 0 - data = listOf(mockStatus(id = "0")), - prevKey = "10", - nextKey = null - ) - ) - } - @Test fun `Append with a too-old key returns empty list`() = runTest { // Given @@ -183,31 +158,6 @@ class NetworkTimelinePagingSourceTest { ) } - @Test - fun `Prepend with a gap returns the page after`() = runTest { - // Given - val pages = makeEmptyPageCache() - pages["20"] = Page(data = mutableListOf(mockStatus(id = "20")), nextKey = "10") - pages["10"] = Page(data = mutableListOf(mockStatus(id = "10")), nextKey = "0", prevKey = "20") - pages["0"] = Page(data = mutableListOf(mockStatus(id = "0")), prevKey = "10") - val pagingSource = NetworkTimelinePagingSource(pages) - - // When - val loadResult = pagingSource.load(PagingSource.LoadParams.Prepend("11", 2, false)) - - // Then - assertThat(loadResult).isInstanceOf(LoadResult.Page::class.java) - assertThat((loadResult as? LoadResult.Page)) - .isEqualTo( - LoadResult.Page( - // key="9" should return the statuses in page 0 - data = listOf(mockStatus(id = "20")), - prevKey = null, - nextKey = "10" - ) - ) - } - @Test fun `Prepend with a too-new key returns empty list`() = runTest { // Given @@ -217,7 +167,7 @@ class NetworkTimelinePagingSourceTest { val pagingSource = NetworkTimelinePagingSource(pages) // When - val loadResult = pagingSource.load(PagingSource.LoadParams.Prepend("20", 2, false)) + val loadResult = pagingSource.load(PagingSource.LoadParams.Prepend("10", 2, false)) // Then assertThat(loadResult).isInstanceOf(LoadResult.Page::class.java) From 4ef41ab9331037f5316231dcaa14cb515a0a90d3 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Wed, 21 Jun 2023 23:45:23 +0200 Subject: [PATCH 092/156] Fetch the bigger than normal initial load to cover animations --- .../tusky/components/timeline/NetworkTimelineRepository.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRepository.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRepository.kt index 79efbd4e1c..07ac0c39f0 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRepository.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRepository.kt @@ -90,7 +90,7 @@ class NetworkTimelineRepository @Inject constructor( } return Pager( - config = PagingConfig(pageSize = pageSize, initialLoadSize = pageSize), + config = PagingConfig(pageSize = pageSize), remoteMediator = NetworkTimelineRemoteMediator( mastodonApi, accountManager, From d7a2e29d305f9d2a8b9ab01a5b5fa41c65c46cc7 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Thu, 22 Jun 2023 17:04:04 +0200 Subject: [PATCH 093/156] Peeking to show new content is not dependent on the Append state --- .../keylesspalace/tusky/components/timeline/TimelineFragment.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt index 7b5e53cbf2..bb63d83080 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt @@ -340,7 +340,7 @@ class TimelineFragment : new.mediator?.prepend?.endOfPaginationReached.isTrue() } .filter { - it.refresh is LoadState.NotLoading && it.prepend.endOfPaginationReached && !it.append.endOfPaginationReached + it.refresh is LoadState.NotLoading && it.prepend.endOfPaginationReached } .collect { binding.recyclerView.post { From e5287b8dffb1d14f1b846e63a43b6bed42900b53 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Thu, 22 Jun 2023 17:04:34 +0200 Subject: [PATCH 094/156] Set isRefreshing in refreshContent() --- .../tusky/components/timeline/TimelineFragment.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt index bb63d83080..4b5eab6466 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt @@ -427,8 +427,6 @@ class TimelineFragment : return when (menuItem.itemId) { R.id.action_refresh -> { if (isSwipeToRefreshEnabled) { - binding.swipeRefreshLayout.isRefreshing = true - refreshContent() true } else { @@ -664,6 +662,7 @@ class TimelineFragment : } override fun refreshContent() { + binding.swipeRefreshLayout.isRefreshing = true onRefresh() } From 9602d59c628870a0bb522fa4c3ada6e47f81c899 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Thu, 22 Jun 2023 17:05:07 +0200 Subject: [PATCH 095/156] Simplify the code to show/hide different views --- .../tusky/components/timeline/TimelineFragment.kt | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt index 4b5eab6466..a35bc43139 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt @@ -362,13 +362,10 @@ class TimelineFragment : val listIsEmpty = loadState.refresh is LoadState.NotLoading && adapter.itemCount == 0 - binding.progressBar.isVisible = loadState.refresh is LoadState.Loading && - !binding.swipeRefreshLayout.isRefreshing - binding.swipeRefreshLayout.isRefreshing = - loadState.refresh is LoadState.Loading && !binding.progressBar.isVisible - - binding.recyclerView.show() - binding.statusView.hide() + binding.statusView.isVisible = listIsEmpty + binding.recyclerView.isVisible = adapter.itemCount != 0 || loadState.source.refresh is LoadState.NotLoading || loadState.mediator?.refresh is LoadState.NotLoading + binding.progressBar.isVisible = loadState.mediator?.refresh is LoadState.Loading && listIsEmpty + binding.swipeRefreshLayout.isRefreshing = loadState.mediator?.refresh is LoadState.Loading if (listIsEmpty) { binding.statusView.setup( @@ -378,8 +375,6 @@ class TimelineFragment : if (timelineKind == TimelineKind.Home) { binding.statusView.showHelp(R.string.help_empty_home) } - binding.recyclerView.hide() - binding.statusView.show() return@collect } @@ -399,7 +394,6 @@ class TimelineFragment : .setAction(R.string.action_retry) { adapter.retry() } snackbar!!.show() } else { - binding.recyclerView.hide() val drawableRes = (loadState.refresh as LoadState.Error).error.getDrawableRes() binding.statusView.setup(drawableRes, message) { adapter.retry() } binding.statusView.show() From bba7c5d6b4445498aab0f62acbf0d2d1f17db5a4 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Thu, 22 Jun 2023 17:05:30 +0200 Subject: [PATCH 096/156] Show error based on the mediator error state --- .../tusky/components/timeline/TimelineFragment.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt index a35bc43139..32c280ac6c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt @@ -378,8 +378,8 @@ class TimelineFragment : return@collect } - if (loadState.refresh is LoadState.Error) { - val message = (loadState.refresh as LoadState.Error).error.getErrorString(requireContext()) + if (loadState.mediator?.refresh is LoadState.Error) { + val message = (loadState.mediator?.refresh as LoadState.Error).error.getErrorString(requireContext()) // Show errors as a snackbar if there is existing content to show // (either cached, or in the adapter), or as a full screen error From 431a7ea7bad8f5ce299f09e37f7a99a2af595694 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Thu, 22 Jun 2023 17:05:45 +0200 Subject: [PATCH 097/156] Correctly use refreshContent() --- .../keylesspalace/tusky/components/timeline/TimelineFragment.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt index 32c280ac6c..42da6238ad 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt @@ -429,7 +429,7 @@ class TimelineFragment : } R.id.action_load_newest -> { viewModel.accept(InfallibleUiAction.LoadNewest) - adapter.refresh() + refreshContent() true } else -> false From e913fcb6ffb3d3ef38d97c430d148e71ea78980f Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Fri, 23 Jun 2023 16:04:56 +0200 Subject: [PATCH 098/156] Index pages by prev --- .../timeline/NetworkTimelineRepository.kt | 9 ++- .../viewmodel/NetworkTimelinePagingSource.kt | 60 +++---------------- .../NetworkTimelineRemoteMediator.kt | 10 +++- 3 files changed, 19 insertions(+), 60 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRepository.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRepository.kt index 07ac0c39f0..5ea0742098 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRepository.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRepository.kt @@ -60,9 +60,10 @@ class NetworkTimelineRepository @Inject constructor( /** * Cached pages of statuses. * - * Each page is keyed by the ID of the first status in that page, and stores the tokens - * use as `max_id` and `min_id` parameters in API calls to fetch pages before/after this - * one. + * Each page is (generally) keyed by value of the `prev` key in the `Link` header for this page, + * as making the request .../max_id={prev} should also fetch this page. In the case of API + * responses that are not paginated (so don't have a `Link` header and consist of a single + * page) the key is the ID of the first (newest) entry in the data. * * In Pager3 parlance, an "append" operation is fetching a chronologically *older* page of * statuses using `nextKey`, a "prepend" operation is fetching a chronologically *newer* @@ -178,8 +179,6 @@ class NetworkTimelineRepository @Inject constructor( /** * Creates an empty page cache with a comparator that ensures keys are compared first * by length, then by natural order. - * - * The map key is the ID of the newest status in the page it maps to. */ @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) fun makeEmptyPageCache() = TreeMap>(compareBy({ it.length }, { it })) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelinePagingSource.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelinePagingSource.kt index 085652c6c5..42f65eebec 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelinePagingSource.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelinePagingSource.kt @@ -62,60 +62,11 @@ class NetworkTimelinePagingSource @Inject constructor( pages[params.key] } } - - // Loading previous / next pages (`Prepend` or `Append`) is a little complicated. - // - // `pages` is keyed by the ID of the first item in the list of data for that page. - // This is so that `Refresh` (above) is straightforward. - // - // Append and Prepend requests have a `params.key` that corresponds to the previous - // or next page. For some timeline types those keys are identical to the item - // keys and match the IDs of the first and last items in the data for that page. - // - // But for some timeline types they are completely different. - // - // For example, bookmarks might have item keys that look like 110542553707722778 - // but prevKey / nextKey values that look like 1480606 / 1229303. - // - // There's no guarantee that the `nextKey` value for one page matches the `prevKey` - // value of the page immediately before it. - // - // E.g., suppose `pages` has the following entries (older entries have lower page - // indices). - // - // .--- page index - // | .-- ID of first item (key in `pages`) - // v V - // 0: k: 109934818460629189, prevKey: 995916, nextKey: 941865 - // 1: k: 110033940961955385, prevKey: 1073324, nextKey: 997376 - // 2: k: 110239564017438509, prevKey: 1224838, nextKey: 1073352 - // 3: k: 110542553707722778, prevKey: 1480606, nextKey: 1229303 - // - // And the request is `LoadParams.Append` with `params.key` == 1073352. This means - // "fetch the page *before* the page that has `nextKey` == 1073352". - // - // The desired page has index 1. But that can't be found directly, because although - // the page after it (index 2) points back to it with the `nextKey` value, the page - // at index 1 **does not** have a `prevKey` value of 1073352. There can be gaps in - // the `prevKey` / `nextKey` chain -- I assume this is a Mastodon implementation - // detail. - // - // Further, we can't assume anything about the structure of the keys. - // - // To find the correct page for Append we must: - // - // 1. Find the page that has a `nextKey` value that matches `params.key` (page 2) - // 2. Get that page's key ("110239564017438509") - // 3. Return the page with the key that is immediately lower than the key from step 2 - // - // The approach for Prepend is the same, except it is `prevKey` that is checked. is LoadParams.Append -> { - pages.firstNotNullOfOrNull { entry -> entry.takeIf { it.value.nextKey == params.key }?.value } - ?.let { page -> pages.lowerEntry(page.data.first().id)?.value } + pages.lowerEntry(params.key)?.value } is LoadParams.Prepend -> { - pages.firstNotNullOfOrNull { entry -> entry.takeIf { it.value.prevKey == params.key }?.value } - ?.let { page -> pages.higherEntry(page.data.first().id)?.value } + pages.higherEntry(params.key)?.value } } } @@ -124,8 +75,11 @@ class NetworkTimelinePagingSource @Inject constructor( Log.d(TAG, " Returning empty page") } else { Log.d(TAG, " Returning full page:") - Log.d(TAG, " k: ${page.data.first().id}, prev: ${page.prevKey}, next: ${page.nextKey}") + Log.d(TAG, " k: ${page.prevKey}, prev: ${page.prevKey}, next: ${page.nextKey}") + } + val result = LoadResult.Page(page?.data ?: emptyList(), nextKey = page?.nextKey, prevKey = page?.prevKey) + Log.d(TAG, " result: $result") return LoadResult.Page(page?.data ?: emptyList(), nextKey = page?.nextKey, prevKey = page?.prevKey) } @@ -133,7 +87,7 @@ class NetworkTimelinePagingSource @Inject constructor( Log.d(TAG, "getRefreshKey(): anchorPosition: ${state.anchorPosition}") val refreshKey = state.anchorPosition?.let { anchorPosition -> val anchorPage = state.closestPageToPosition(anchorPosition) - anchorPage?.data?.first()?.id + anchorPage?.prevKey } Log.d(TAG, " refreshKey = $refreshKey") return refreshKey diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt index b7e7f3e17a..f98c5edb91 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt @@ -91,9 +91,15 @@ class NetworkTimelineRemoteMediator( synchronized(pages) { Log.d(TAG, "Inserting new page:") - Log.d(TAG, " k: ${statuses.first().id}, prev: ${links.prev}, next: ${links.next}") + Log.d(TAG, " k: ${links.prev}, prev: ${links.prev}, next: ${links.next}") - pages[statuses.first().id] = Page( + // Some API endpoints may not return pagination links (at the time of writing + // at least fetching an account's pinned statuses does not, see this bug report: + // https://github.com/mastodon/mastodon/issues/25555). If that happens fall back + // to the ID of the first item in the list. + val k = links.prev ?: statuses.first().id + + pages[k] = Page( data = statuses.toMutableList(), nextKey = links.next, prevKey = links.prev From 3cc000b716076913eff740490e83d3a5cecc09a1 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Mon, 26 Jun 2023 16:56:12 +0200 Subject: [PATCH 099/156] Use the last ID as the key not the first ID --- .../timeline/NetworkTimelineRepository.kt | 2 +- .../timeline/TimelinePagingAdapter.kt | 2 +- .../viewmodel/NetworkTimelinePagingSource.kt | 73 ++++++++++-- .../NetworkTimelineRemoteMediator.kt | 105 ++++++++++++------ .../NetworkTimelinePagingSourceTest.kt | 2 +- .../NetworkTimelineRemoteMediatorTest.kt | 14 +-- 6 files changed, 146 insertions(+), 52 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRepository.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRepository.kt index 5ea0742098..405149c759 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRepository.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRepository.kt @@ -91,7 +91,7 @@ class NetworkTimelineRepository @Inject constructor( } return Pager( - config = PagingConfig(pageSize = pageSize), + config = PagingConfig(pageSize = pageSize, initialLoadSize = pageSize), remoteMediator = NetworkTimelineRemoteMediator( mastodonApi, accountManager, diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelinePagingAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelinePagingAdapter.kt index 9b60dc9c4c..e4c5c34e95 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelinePagingAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelinePagingAdapter.kt @@ -96,7 +96,7 @@ class TimelinePagingAdapter( oldItem: StatusViewData, newItem: StatusViewData ): Boolean { - return false // Items are different always. It allows to refresh timestamp on every view holder update + return oldItem == newItem } override fun getChangePayload( diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelinePagingSource.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelinePagingSource.kt index 42f65eebec..10070dcbc5 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelinePagingSource.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelinePagingSource.kt @@ -41,7 +41,7 @@ class NetworkTimelinePagingSource @Inject constructor( pages.onEachIndexed { i, entry -> Log.d( TAG, - " $i: k: ${entry.key}, prev: ${entry.value.prevKey}, next: ${entry.value.nextKey}" + " $i: k: ${entry.key}, prev: ${entry.value.prevKey}, next: ${entry.value.nextKey}, size: ${entry.value.data.size}" ) } } @@ -59,14 +59,71 @@ class NetworkTimelinePagingSource @Inject constructor( if (params.key == null) { pages.lastEntry()?.value } else { - pages[params.key] + pages[params.key] ?: pages.lowerEntry(params.key)?.value } } + // Loading previous / next pages (`Prepend` or `Append`) is a little complicated. + // + // `pages` is keyed by the ID of the last (oldest) item in the list of data for + // that page. This is so that `Refresh` (above) is straightforward. + // + // It's the last item, and not the first because a page may be incomplete. E.g,. + // a prepend operation completes, and instead of loading pageSize items it loads + // (pageSize - 10) items, because only (pageSize - 10) items were available at the + // time of the API call. + // + // If the page was subsequently refreshed, *and* the ID of the first (newest) item + // was used as the key then you might have two pages that contain overlapping + // items. + // + // Append and Prepend requests have a `params.key` that corresponds to the previous + // or next page. For some timeline types those keys have the same form as the + // item keys, and are valid item keys. + // + // But for some timeline types they are completely different. + // + // For example, bookmarks might have item keys that look like 110542553707722778 + // but prevKey / nextKey values that look like 1480606 / 1229303. + // + // There's no guarantee that the `nextKey` value for one page matches the `prevKey` + // value of the page immediately before it. + // + // E.g., suppose `pages` has the following entries (older entries have lower page + // indices). + // + // .--- page index + // | .-- ID of first item (key in `pages`) + // v V + // 0: k: 109934818460629189, prevKey: 995916, nextKey: 941865 + // 1: k: 110033940961955385, prevKey: 1073324, nextKey: 997376 + // 2: k: 110239564017438509, prevKey: 1224838, nextKey: 1073352 + // 3: k: 110542553707722778, prevKey: 1480606, nextKey: 1229303 + // + // And the request is `LoadParams.Append` with `params.key` == 1073352. This means + // "fetch the page *before* the page that has `nextKey` == 1073352". + // + // The desired page has index 1. But that can't be found directly, because although + // the page after it (index 2) points back to it with the `nextKey` value, the page + // at index 1 **does not** have a `prevKey` value of 1073352. There can be gaps in + // the `prevKey` / `nextKey` chain -- I assume this is a Mastodon implementation + // detail. + // + // Further, we can't assume anything about the structure of the keys. + // + // To find the correct page for Append we must: + // + // 1. Find the page that has a `nextKey` value that matches `params.key` (page 2) + // 2. Get that page's key ("110239564017438509") + // 3. Return the page with the key that is immediately lower than the key from step 2 + // + // The approach for Prepend is the same, except it is `prevKey` that is checked. is LoadParams.Append -> { - pages.lowerEntry(params.key)?.value + pages.firstNotNullOfOrNull { entry -> entry.takeIf { it.value.nextKey == params.key }?.value } + ?.let { page -> pages.lowerEntry(page.data.last().id)?.value } } is LoadParams.Prepend -> { - pages.higherEntry(params.key)?.value + pages.firstNotNullOfOrNull { entry -> entry.takeIf { it.value.prevKey == params.key }?.value } + ?.let { page -> pages.higherEntry(page.data.last().id)?.value } } } } @@ -75,8 +132,7 @@ class NetworkTimelinePagingSource @Inject constructor( Log.d(TAG, " Returning empty page") } else { Log.d(TAG, " Returning full page:") - Log.d(TAG, " k: ${page.prevKey}, prev: ${page.prevKey}, next: ${page.nextKey}") - + Log.d(TAG, " k: ${page.data.last().id}, prev: ${page.prevKey}, next: ${page.nextKey}") } val result = LoadResult.Page(page?.data ?: emptyList(), nextKey = page?.nextKey, prevKey = page?.prevKey) Log.d(TAG, " result: $result") @@ -86,8 +142,9 @@ class NetworkTimelinePagingSource @Inject constructor( override fun getRefreshKey(state: PagingState): String? { Log.d(TAG, "getRefreshKey(): anchorPosition: ${state.anchorPosition}") val refreshKey = state.anchorPosition?.let { anchorPosition -> - val anchorPage = state.closestPageToPosition(anchorPosition) - anchorPage?.prevKey + // TODO: Test if closestPage or closestItem is better here +// state.closestPageToPosition(anchorPosition)?.data?.last()?.id + state.closestItemToPosition(anchorPosition)?.id } Log.d(TAG, " refreshKey = $refreshKey") return refreshKey diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt index f98c5edb91..9f6df03259 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt @@ -23,6 +23,7 @@ import androidx.paging.InvalidatingPagingSourceFactory import androidx.paging.LoadType import androidx.paging.PagingState import androidx.paging.RemoteMediator +import com.keylesspalace.tusky.BuildConfig import com.keylesspalace.tusky.components.timeline.Page import com.keylesspalace.tusky.components.timeline.TimelineKind import com.keylesspalace.tusky.db.AccountManager @@ -46,10 +47,7 @@ class NetworkTimelineRemoteMediator( private val activeAccount = accountManager.activeAccount!! - override suspend fun load( - loadType: LoadType, - state: PagingState - ): MediatorResult { + override suspend fun load(loadType: LoadType, state: PagingState): MediatorResult { if (!activeAccount.isLoggedIn()) { return MediatorResult.Success(endOfPaginationReached = true) } @@ -58,18 +56,20 @@ class NetworkTimelineRemoteMediator( return try { val key = when (loadType) { - LoadType.REFRESH -> - // Refresh from the page immediately after the newest page - pages.lastEntry()?.value?.prevKey + LoadType.REFRESH -> { + Log.d(TAG, " anchorPosition: ${state.anchorPosition}") + // Find the closest page to the current position + val key = state.anchorPosition?.let { state.closestPageToPosition(it) }?.data?.last()?.id + Log.d(TAG, " Refreshing page with last id: $key") + + // Use its prevKey to refresh this page + key?.let { pages.lowerEntry(it)?.value?.prevKey } + } LoadType.APPEND -> { - Log.d(TAG, " firstEntry: ${pages.firstEntry()?.key}") - pages.firstEntry()?.value?.nextKey - ?: return MediatorResult.Success(endOfPaginationReached = true) + pages.firstEntry()?.value?.nextKey ?: return MediatorResult.Success(endOfPaginationReached = true) } LoadType.PREPEND -> { - Log.d(TAG, " lastEntry: ${pages.lastEntry()?.key}") - pages.lastEntry()?.value?.prevKey - ?: return MediatorResult.Success(endOfPaginationReached = true) + pages.lastEntry()?.value?.prevKey ?: return MediatorResult.Success(endOfPaginationReached = true) } } @@ -85,31 +85,68 @@ class NetworkTimelineRemoteMediator( Log.d(TAG, " ${statuses.size} - # statuses loaded") - if (statuses.isEmpty()) { - return MediatorResult.Success(endOfPaginationReached = true) + if (BuildConfig.DEBUG && loadType == LoadType.REFRESH) { + state.anchorPosition?.let { state.closestPageToPosition(it) }?.data?.last()?.id?.let { expectedPage -> + if (pages[expectedPage]?.data?.size == statuses.size) { + Log.d(TAG, " Refresh has same data size") + if (pages[expectedPage]?.data?.last()?.id == statuses.last().id) { + Log.d(TAG, " ... and they have the same last ID") + } else { + throw IllegalStateException(" Last IDs do not match, got: ${statuses.last().id}, want: $expectedPage") + } + } else { + Log.d(TAG, " Refresh has different data size!") + } + statuses.lastOrNull { it.id == expectedPage } + ?: throw IllegalStateException(" Refreshed page does not contain $expectedPage") + Log.d(TAG, " Page contains $expectedPage") + } } - synchronized(pages) { - Log.d(TAG, "Inserting new page:") - Log.d(TAG, " k: ${links.prev}, prev: ${links.prev}, next: ${links.next}") - - // Some API endpoints may not return pagination links (at the time of writing - // at least fetching an account's pinned statuses does not, see this bug report: - // https://github.com/mastodon/mastodon/issues/25555). If that happens fall back - // to the ID of the first item in the list. - val k = links.prev ?: statuses.first().id - - pages[k] = Page( - data = statuses.toMutableList(), - nextKey = links.next, - prevKey = links.prev - ) - Log.d(TAG, " Page $loadType complete for $timelineKind, now got ${pages.size} pages") + if (statuses.isNotEmpty()) { + synchronized(pages) { + if (loadType == LoadType.REFRESH) { + pages.clear() + } + + val k = statuses.last().id + + Log.d(TAG, "Inserting new page:") + Log.d(TAG, " k: $k, prev: ${links.prev}, next: ${links.next}, size: ${statuses.size}") + + pages[k] = Page( + data = statuses.toMutableList(), + nextKey = links.next, + prevKey = links.prev + ) + Log.d( + TAG, + " Page $loadType complete for $timelineKind, now got ${pages.size} pages" + ) + pages.onEachIndexed { i, entry -> + Log.d( + TAG, + " $i: k: ${entry.key}, prev: ${entry.value.prevKey}, next: ${entry.value.nextKey}, size: ${entry.value.data.size}, range: ${entry.value.data.first().id}..${entry.value.data.last().id}" + ) + } + + // There should never be duplicate items across all the pages. Enforce this + // in debug mode. + if (BuildConfig.DEBUG) { + val ids = buildList { + this.addAll(pages.map { entry -> entry.value.data.map { it.id } }.flatten()) + } + val groups = ids.groupingBy { it }.eachCount().filter { it.value > 1 } + if (groups.isNotEmpty()) { + throw IllegalStateException("Duplicate item IDs in results!: $groups") + } + } + } + Log.d(TAG, " Invalidating source") + factory.invalidate() } - Log.d(TAG, " Invalidating source") - factory.invalidate() - return MediatorResult.Success(endOfPaginationReached = false) + return MediatorResult.Success(endOfPaginationReached = statuses.isEmpty()) } catch (e: IOException) { MediatorResult.Error(e) } catch (e: HttpException) { diff --git a/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelinePagingSourceTest.kt b/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelinePagingSourceTest.kt index ea8d7e9468..e3b7174b3f 100644 --- a/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelinePagingSourceTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelinePagingSourceTest.kt @@ -167,7 +167,7 @@ class NetworkTimelinePagingSourceTest { val pagingSource = NetworkTimelinePagingSource(pages) // When - val loadResult = pagingSource.load(PagingSource.LoadParams.Prepend("10", 2, false)) + val loadResult = pagingSource.load(PagingSource.LoadParams.Prepend("21", 2, false)) // Then assertThat(loadResult).isInstanceOf(LoadResult.Page::class.java) diff --git a/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRemoteMediatorTest.kt b/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRemoteMediatorTest.kt index 7d52cfdfa2..48ed7c33c7 100644 --- a/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRemoteMediatorTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRemoteMediatorTest.kt @@ -129,7 +129,7 @@ class NetworkTimelineRemoteMediatorTest { // Then val expectedPages = makeEmptyPageCache() - expectedPages["7"] = Page( + expectedPages["5"] = Page( data = mutableListOf(mockStatus("7"), mockStatus("6"), mockStatus("5")), prevKey = "7", nextKey = "5" @@ -148,7 +148,7 @@ class NetworkTimelineRemoteMediatorTest { fun `should prepend statuses`() = runTest { // Given val pages = makeEmptyPageCache().apply { - this["7"] = Page( + this["5"] = Page( data = mutableListOf(mockStatus("7"), mockStatus("6"), mockStatus("5")), prevKey = "7", nextKey = "5" @@ -186,12 +186,12 @@ class NetworkTimelineRemoteMediatorTest { // Then val expectedPages = makeEmptyPageCache().apply { - this["7"] = Page( + this["5"] = Page( data = mutableListOf(mockStatus("7"), mockStatus("6"), mockStatus("5")), prevKey = "7", nextKey = "5" ) - this["10"] = Page( + this["8"] = Page( data = mutableListOf(mockStatus("10"), mockStatus("9"), mockStatus("8")), prevKey = "10", nextKey = "8" @@ -211,7 +211,7 @@ class NetworkTimelineRemoteMediatorTest { fun `should append statuses`() = runTest { // Given val pages = makeEmptyPageCache().apply { - this["7"] = Page( + this["5"] = Page( data = mutableListOf(mockStatus("7"), mockStatus("6"), mockStatus("5")), prevKey = "7", nextKey = "5" @@ -249,12 +249,12 @@ class NetworkTimelineRemoteMediatorTest { // Then val expectedPages = makeEmptyPageCache().apply { - this["7"] = Page( + this["5"] = Page( data = mutableListOf(mockStatus("7"), mockStatus("6"), mockStatus("5")), prevKey = "7", nextKey = "5" ) - this["4"] = Page( + this["2"] = Page( data = mutableListOf(mockStatus("4"), mockStatus("3"), mockStatus("2")), prevKey = "4", nextKey = "2" From 269d8f1609e0f406e34404de8222e7006158b77e Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Wed, 28 Jun 2023 08:01:22 +0200 Subject: [PATCH 100/156] notifyItemRangeChanged for all items --- .../tusky/components/timeline/TimelineFragment.kt | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt index 42da6238ad..15edfce4e3 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt @@ -86,6 +86,7 @@ import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import javax.inject.Inject +import kotlin.time.Duration.Companion.seconds class TimelineFragment : SFragment(), @@ -185,17 +186,9 @@ class TimelineFragment : */ // TODO: Copied from NotificationsFragment val updateTimestampFlow = flow { - while (true) { delay(60000); emit(Unit) } + while (true) { delay(60.seconds); emit(Unit) } }.onEach { - layoutManager.findFirstVisibleItemPosition().let { first -> - first == RecyclerView.NO_POSITION && return@let - val count = layoutManager.findLastVisibleItemPosition() - first - adapter.notifyItemRangeChanged( - first, - count, - listOf(StatusBaseViewHolder.Key.KEY_CREATED) - ) - } + adapter.notifyItemRangeChanged(0, adapter.itemCount, listOf(StatusBaseViewHolder.Key.KEY_CREATED)) } viewLifecycleOwner.lifecycleScope.launch { From 1ae50b6df5c626aa846f2e25c679a5ba77bb2efa Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Wed, 28 Jun 2023 09:28:07 +0200 Subject: [PATCH 101/156] Move the page cache to its own class --- .../timeline/NetworkTimelineRepository.kt | 73 +++------- .../viewmodel/NetworkTimelinePagingSource.kt | 50 ++----- .../NetworkTimelineRemoteMediator.kt | 69 +++------- .../timeline/viewmodel/PageCache.kt | 128 ++++++++++++++++++ .../NetworkTimelinePagingSourceTest.kt | 55 ++++---- .../NetworkTimelineRemoteMediatorTest.kt | 98 ++++++++------ 6 files changed, 260 insertions(+), 213 deletions(-) create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/PageCache.kt diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRepository.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRepository.kt index 405149c759..799cd15384 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRepository.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRepository.kt @@ -18,7 +18,6 @@ package com.keylesspalace.tusky.components.timeline import android.util.Log -import androidx.annotation.VisibleForTesting import androidx.paging.ExperimentalPagingApi import androidx.paging.InvalidatingPagingSourceFactory import androidx.paging.Pager @@ -27,53 +26,20 @@ import androidx.paging.PagingData import androidx.paging.PagingSource import com.keylesspalace.tusky.components.timeline.viewmodel.NetworkTimelinePagingSource import com.keylesspalace.tusky.components.timeline.viewmodel.NetworkTimelineRemoteMediator +import com.keylesspalace.tusky.components.timeline.viewmodel.PageCache import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.util.getDomain import kotlinx.coroutines.flow.Flow -import java.util.TreeMap import javax.inject.Inject -/** A page of data from the Mastodon API */ -data class Page constructor( - /** Loaded data */ - val data: MutableList, - /** - * [Key] for previous page (newer results, PREPEND operation) if more data can be loaded in - * that direction, `null` otherwise. - */ - val prevKey: Key? = null, - /** - * [Key] for next page (older results, APPEND operation) if more data can be loaded in that - * direction, `null` otherwise. - */ - val nextKey: Key? = null -) - /** Timeline repository where the timeline information is backed by an in-memory cache. */ class NetworkTimelineRepository @Inject constructor( private val mastodonApi: MastodonApi, private val accountManager: AccountManager ) { - - /** - * Cached pages of statuses. - * - * Each page is (generally) keyed by value of the `prev` key in the `Link` header for this page, - * as making the request .../max_id={prev} should also fetch this page. In the case of API - * responses that are not paginated (so don't have a `Link` header and consist of a single - * page) the key is the ID of the first (newest) entry in the data. - * - * In Pager3 parlance, an "append" operation is fetching a chronologically *older* page of - * statuses using `nextKey`, a "prepend" operation is fetching a chronologically *newer* - * page of statuses using `prevKey`. - */ - // Storing the next/prev tokens in this structure is important, as you can't derive them from - // status IDs (e.g., the next/prev keys returned by the "favourites" API call *do not match* - // status IDs elsewhere). The tokens are discovered by the RemoteMediator but are used by the - // PagingSource, so they need to be available somewhere both components can access them. - private val pages = makeEmptyPageCache() + private val pageCache = PageCache() private var factory: InvalidatingPagingSourceFactory? = null @@ -87,7 +53,7 @@ class NetworkTimelineRepository @Inject constructor( Log.d(TAG, "getStatusStream(): key: $initialKey") factory = InvalidatingPagingSourceFactory { - NetworkTimelinePagingSource(pages) + NetworkTimelinePagingSource(pageCache) } return Pager( @@ -96,7 +62,7 @@ class NetworkTimelineRepository @Inject constructor( mastodonApi, accountManager, factory!!, - pages, + pageCache, kind ), pagingSourceFactory = factory!! @@ -109,8 +75,8 @@ class NetworkTimelineRepository @Inject constructor( } fun removeAllByAccountId(accountId: String) { - synchronized(pages) { - for (page in pages.values) { + synchronized(pageCache) { + for (page in pageCache.values) { page.data.removeAll { status -> status.account.id == accountId || status.actionableStatus.account.id == accountId } @@ -120,8 +86,8 @@ class NetworkTimelineRepository @Inject constructor( } fun removeAllByInstance(instance: String) { - synchronized(pages) { - for (page in pages.values) { + synchronized(pageCache) { + for (page in pageCache.values) { page.data.removeAll { status -> getDomain(status.account.url) == instance } } } @@ -129,8 +95,8 @@ class NetworkTimelineRepository @Inject constructor( } fun removeStatusWithId(statusId: String) { - synchronized(pages) { - pages.floorEntry(statusId)?.value?.data?.removeAll { status -> + synchronized(pageCache) { + pageCache.floorEntry(statusId)?.value?.data?.removeAll { status -> status.id == statusId || status.reblog?.id == statusId } } @@ -138,8 +104,8 @@ class NetworkTimelineRepository @Inject constructor( } fun updateStatusById(statusId: String, updater: (Status) -> Status) { - synchronized(pages) { - pages.floorEntry(statusId)?.value?.let { page -> + synchronized(pageCache) { + pageCache.floorEntry(statusId)?.value?.let { page -> val index = page.data.indexOfFirst { it.id == statusId } if (index != -1) { page.data[index] = updater(page.data[index]) @@ -150,8 +116,8 @@ class NetworkTimelineRepository @Inject constructor( } fun updateActionableStatusById(statusId: String, updater: (Status) -> Status) { - synchronized(pages) { - pages.floorEntry(statusId)?.value?.let { page -> + synchronized(pageCache) { + pageCache.floorEntry(statusId)?.value?.let { page -> val index = page.data.indexOfFirst { it.id == statusId } if (index != -1) { val status = page.data[index] @@ -166,8 +132,8 @@ class NetworkTimelineRepository @Inject constructor( } fun reload() { - synchronized(pages) { - pages.clear() + synchronized(pageCache) { + pageCache.clear() } invalidate() } @@ -175,12 +141,5 @@ class NetworkTimelineRepository @Inject constructor( companion object { private const val TAG = "NetworkTimelineRepository" private const val PAGE_SIZE = 30 - - /** - * Creates an empty page cache with a comparator that ensures keys are compared first - * by length, then by natural order. - */ - @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) - fun makeEmptyPageCache() = TreeMap>(compareBy({ it.length }, { it })) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelinePagingSource.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelinePagingSource.kt index 10070dcbc5..3628d21e2d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelinePagingSource.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelinePagingSource.kt @@ -19,37 +19,21 @@ import android.util.Log import androidx.paging.PagingSource import androidx.paging.PagingState import com.keylesspalace.tusky.BuildConfig -import com.keylesspalace.tusky.components.timeline.Page import com.keylesspalace.tusky.entity.Status -import java.util.TreeMap import javax.inject.Inject /** [PagingSource] for Mastodon Status, identified by the Status ID */ class NetworkTimelinePagingSource @Inject constructor( - private val pages: TreeMap> + private val pageCache: PageCache ) : PagingSource() { override suspend fun load(params: LoadParams): LoadResult { Log.d(TAG, "load() with ${params.javaClass.simpleName} for key: ${params.key}") - if (BuildConfig.DEBUG) { - synchronized(pages) { - Log.d(TAG, "Pages state:") - if (pages.isEmpty()) { - Log.d(TAG, " **empty**") - } else { - pages.onEachIndexed { i, entry -> - Log.d( - TAG, - " $i: k: ${entry.key}, prev: ${entry.value.prevKey}, next: ${entry.value.nextKey}, size: ${entry.value.data.size}" - ) - } - } - } - } + if (BuildConfig.DEBUG) { pageCache.debug() } - val page = synchronized(pages) { - if (pages.isEmpty()) { + val page = synchronized(pageCache) { + if (pageCache.isEmpty()) { return@synchronized null } @@ -57,25 +41,13 @@ class NetworkTimelinePagingSource @Inject constructor( is LoadParams.Refresh -> { // If no key then return the latest page. Otherwise return the requested page. if (params.key == null) { - pages.lastEntry()?.value + pageCache.lastEntry()?.value } else { - pages[params.key] ?: pages.lowerEntry(params.key)?.value + pageCache[params.key] ?: pageCache.lowerEntry(params.key)?.value } } // Loading previous / next pages (`Prepend` or `Append`) is a little complicated. // - // `pages` is keyed by the ID of the last (oldest) item in the list of data for - // that page. This is so that `Refresh` (above) is straightforward. - // - // It's the last item, and not the first because a page may be incomplete. E.g,. - // a prepend operation completes, and instead of loading pageSize items it loads - // (pageSize - 10) items, because only (pageSize - 10) items were available at the - // time of the API call. - // - // If the page was subsequently refreshed, *and* the ID of the first (newest) item - // was used as the key then you might have two pages that contain overlapping - // items. - // // Append and Prepend requests have a `params.key` that corresponds to the previous // or next page. For some timeline types those keys have the same form as the // item keys, and are valid item keys. @@ -118,12 +90,12 @@ class NetworkTimelinePagingSource @Inject constructor( // // The approach for Prepend is the same, except it is `prevKey` that is checked. is LoadParams.Append -> { - pages.firstNotNullOfOrNull { entry -> entry.takeIf { it.value.nextKey == params.key }?.value } - ?.let { page -> pages.lowerEntry(page.data.last().id)?.value } + pageCache.firstNotNullOfOrNull { entry -> entry.takeIf { it.value.nextKey == params.key }?.value } + ?.let { page -> pageCache.lowerEntry(page.data.last().id)?.value } } is LoadParams.Prepend -> { - pages.firstNotNullOfOrNull { entry -> entry.takeIf { it.value.prevKey == params.key }?.value } - ?.let { page -> pages.higherEntry(page.data.last().id)?.value } + pageCache.firstNotNullOfOrNull { entry -> entry.takeIf { it.value.prevKey == params.key }?.value } + ?.let { page -> pageCache.higherEntry(page.data.last().id)?.value } } } } @@ -132,7 +104,7 @@ class NetworkTimelinePagingSource @Inject constructor( Log.d(TAG, " Returning empty page") } else { Log.d(TAG, " Returning full page:") - Log.d(TAG, " k: ${page.data.last().id}, prev: ${page.prevKey}, next: ${page.nextKey}") + Log.d(TAG, " $page") } val result = LoadResult.Page(page?.data ?: emptyList(), nextKey = page?.nextKey, prevKey = page?.prevKey) Log.d(TAG, " result: $result") diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt index 9f6df03259..2f7c538461 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt @@ -24,16 +24,13 @@ import androidx.paging.LoadType import androidx.paging.PagingState import androidx.paging.RemoteMediator import com.keylesspalace.tusky.BuildConfig -import com.keylesspalace.tusky.components.timeline.Page import com.keylesspalace.tusky.components.timeline.TimelineKind import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.entity.Status -import com.keylesspalace.tusky.network.Links import com.keylesspalace.tusky.network.MastodonApi import retrofit2.HttpException import retrofit2.Response import java.io.IOException -import java.util.TreeMap /** Remote mediator for accessing timelines that are not backed by the database. */ @OptIn(ExperimentalPagingApi::class) @@ -41,7 +38,7 @@ class NetworkTimelineRemoteMediator( private val api: MastodonApi, accountManager: AccountManager, private val factory: InvalidatingPagingSourceFactory, - private val pages: TreeMap>, + private val pageCache: PageCache, private val timelineKind: TimelineKind ) : RemoteMediator() { @@ -63,90 +60,58 @@ class NetworkTimelineRemoteMediator( Log.d(TAG, " Refreshing page with last id: $key") // Use its prevKey to refresh this page - key?.let { pages.lowerEntry(it)?.value?.prevKey } + key?.let { pageCache.lowerEntry(it)?.value?.prevKey } } LoadType.APPEND -> { - pages.firstEntry()?.value?.nextKey ?: return MediatorResult.Success(endOfPaginationReached = true) + pageCache.firstEntry()?.value?.nextKey ?: return MediatorResult.Success(endOfPaginationReached = true) } LoadType.PREPEND -> { - pages.lastEntry()?.value?.prevKey ?: return MediatorResult.Success(endOfPaginationReached = true) + pageCache.lastEntry()?.value?.prevKey ?: return MediatorResult.Success(endOfPaginationReached = true) } } Log.d(TAG, " from key: $key") val response = fetchStatusPageByKind(loadType, key, state.config.initialLoadSize) - val statuses = response.body() - if (!response.isSuccessful || statuses == null) { - return MediatorResult.Error(HttpException(response)) - } - - Log.d(TAG, " link: " + response.headers()["link"]) - val links = Links.from(response.headers()["link"]) - Log.d(TAG, " ${statuses.size} - # statuses loaded") + val page = Page.tryFrom(response).getOrElse { return MediatorResult.Error(it) } if (BuildConfig.DEBUG && loadType == LoadType.REFRESH) { state.anchorPosition?.let { state.closestPageToPosition(it) }?.data?.last()?.id?.let { expectedPage -> - if (pages[expectedPage]?.data?.size == statuses.size) { + if (pageCache[expectedPage]?.data?.size == page.data.size) { Log.d(TAG, " Refresh has same data size") - if (pages[expectedPage]?.data?.last()?.id == statuses.last().id) { + if (pageCache[expectedPage]?.data?.last()?.id == page.data.last().id) { Log.d(TAG, " ... and they have the same last ID") } else { - throw IllegalStateException(" Last IDs do not match, got: ${statuses.last().id}, want: $expectedPage") + throw IllegalStateException(" Last IDs do not match, got: ${page.data.last().id}, want: $expectedPage") } } else { Log.d(TAG, " Refresh has different data size!") } - statuses.lastOrNull { it.id == expectedPage } + page.data.lastOrNull { it.id == expectedPage } ?: throw IllegalStateException(" Refreshed page does not contain $expectedPage") Log.d(TAG, " Page contains $expectedPage") } } - if (statuses.isNotEmpty()) { - synchronized(pages) { + val endOfPaginationReached = page.data.isEmpty() + if (!endOfPaginationReached) { + synchronized(pageCache) { if (loadType == LoadType.REFRESH) { - pages.clear() + pageCache.clear() } - val k = statuses.last().id - - Log.d(TAG, "Inserting new page:") - Log.d(TAG, " k: $k, prev: ${links.prev}, next: ${links.next}, size: ${statuses.size}") - - pages[k] = Page( - data = statuses.toMutableList(), - nextKey = links.next, - prevKey = links.prev - ) + pageCache.upsert(page) Log.d( TAG, - " Page $loadType complete for $timelineKind, now got ${pages.size} pages" + " Page $loadType complete for $timelineKind, now got ${pageCache.size} pages" ) - pages.onEachIndexed { i, entry -> - Log.d( - TAG, - " $i: k: ${entry.key}, prev: ${entry.value.prevKey}, next: ${entry.value.nextKey}, size: ${entry.value.data.size}, range: ${entry.value.data.first().id}..${entry.value.data.last().id}" - ) - } - - // There should never be duplicate items across all the pages. Enforce this - // in debug mode. - if (BuildConfig.DEBUG) { - val ids = buildList { - this.addAll(pages.map { entry -> entry.value.data.map { it.id } }.flatten()) - } - val groups = ids.groupingBy { it }.eachCount().filter { it.value > 1 } - if (groups.isNotEmpty()) { - throw IllegalStateException("Duplicate item IDs in results!: $groups") - } - } + pageCache.debug() } Log.d(TAG, " Invalidating source") factory.invalidate() } - return MediatorResult.Success(endOfPaginationReached = statuses.isEmpty()) + return MediatorResult.Success(endOfPaginationReached = endOfPaginationReached) } catch (e: IOException) { MediatorResult.Error(e) } catch (e: HttpException) { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/PageCache.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/PageCache.kt new file mode 100644 index 0000000000..08f094181d --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/PageCache.kt @@ -0,0 +1,128 @@ +/* + * 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.timeline.viewmodel + +import android.util.Log +import com.keylesspalace.tusky.BuildConfig +import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.network.Links +import retrofit2.HttpException +import retrofit2.Response +import java.util.TreeMap +import kotlin.Result.Companion.failure +import kotlin.Result.Companion.success + +/** A page of data from the Mastodon API */ +data class Page constructor( + /** Loaded data */ + val data: MutableList, + /** + * Key for previous page (newer results, PREPEND operation) if more data can be loaded in + * that direction, `null` otherwise. + */ + val prevKey: String? = null, + /** + * Key for next page (older results, APPEND operation) if more data can be loaded in that + * direction, `null` otherwise. + */ + val nextKey: String? = null +) { + override fun toString() = "k: ${data.last().id}, prev: $prevKey, next: $nextKey, size: ${data.size}, range: ${data.first().id}..${data.last().id}" + + companion object { + private const val TAG = "Page" + + fun tryFrom(response: Response>): Result { + val statuses = response.body() + if (!response.isSuccessful || statuses == null) { + return failure(HttpException(response)) + } + + val links = Links.from(response.headers()["link"]) + Log.d(TAG, " link: " + response.headers()["link"]) + Log.d(TAG, " ${statuses.size} - # statuses loaded") + + return success( + Page( + data = statuses.toMutableList(), + nextKey = links.next, + prevKey = links.prev + ) + ) + } + } +} + +/** + * Cache of pages from Mastodon API calls. + * + * Cache pages are identified by the ID of the **last** (smallest, oldest) key in the page. + * + * It's the last item, and not the first because a page may be incomplete. E.g,. + * a prepend operation completes, and instead of loading pageSize items it loads + * (pageSize - 10) items, because only (pageSize - 10) items were available at the + * time of the API call. + * + * If the page was subsequently refreshed, *and* the ID of the first (newest) item + * was used as the key then you might have two pages that contain overlapping + * items. + */ +class PageCache : TreeMap(compareBy({ it.length }, { it })) { + /** + * Adds a new page to the cache or updates the existing page with the given key + */ + fun upsert(page: Page) { + val key = page.data.last().id + + Log.d(TAG, "Inserting new page:") + Log.d(TAG, " $page") + + this[key] = page + + // There should never be duplicate items across all the pages. Enforce this in debug mode. + if (BuildConfig.DEBUG) { + val ids = buildList { + this.addAll(this@PageCache.map { entry -> entry.value.data.map { it.id } }.flatten()) + } + val groups = ids.groupingBy { it }.eachCount().filter { it.value > 1 } + if (groups.isNotEmpty()) { + throw IllegalStateException("Duplicate item IDs in results!: $groups") + } + } + } + + /** + * Logs the current state of the cache + */ + fun debug() { + Log.d(TAG, "Page cache state:") + synchronized(this) { + if (this.isEmpty()) { + Log.d(TAG, " ** empty **") + } else { + this.onEachIndexed { index, entry -> + Log.d(TAG, " $index: ${entry.value}") + } + } + } + } + + companion object { + private const val TAG = "PageCache" + } +} diff --git a/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelinePagingSourceTest.kt b/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelinePagingSourceTest.kt index e3b7174b3f..bc3462ca04 100644 --- a/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelinePagingSourceTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelinePagingSourceTest.kt @@ -4,8 +4,9 @@ import androidx.paging.PagingSource import androidx.paging.PagingSource.LoadResult import androidx.test.ext.junit.runners.AndroidJUnit4 import com.google.common.truth.Truth.assertThat -import com.keylesspalace.tusky.components.timeline.NetworkTimelineRepository.Companion.makeEmptyPageCache import com.keylesspalace.tusky.components.timeline.viewmodel.NetworkTimelinePagingSource +import com.keylesspalace.tusky.components.timeline.viewmodel.Page +import com.keylesspalace.tusky.components.timeline.viewmodel.PageCache import com.keylesspalace.tusky.entity.Status import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest @@ -20,7 +21,7 @@ class NetworkTimelinePagingSourceTest { @Test fun `load() with empty pages returns empty list`() = runTest { // Given - val pages = makeEmptyPageCache() + val pages = PageCache() val pagingSource = NetworkTimelinePagingSource(pages) // When @@ -41,10 +42,11 @@ class NetworkTimelinePagingSourceTest { @Test fun `load() for an item in a page returns the page containing that item and next, prev keys`() = runTest { // Given - val pages = makeEmptyPageCache() - pages["2"] = Page(data = mutableListOf(mockStatus(id = "2")), nextKey = "1") - pages["1"] = Page(data = mutableListOf(mockStatus(id = "1")), nextKey = "0", prevKey = "2") - pages["0"] = Page(data = mutableListOf(mockStatus(id = "0")), prevKey = "1") + val pages = PageCache().apply { + upsert(Page(data = mutableListOf(mockStatus(id = "2")), nextKey = "1")) + upsert(Page(data = mutableListOf(mockStatus(id = "1")), nextKey = "0", prevKey = "2")) + upsert(Page(data = mutableListOf(mockStatus(id = "0")), prevKey = "1")) + } val pagingSource = NetworkTimelinePagingSource(pages) // When @@ -65,10 +67,11 @@ class NetworkTimelinePagingSourceTest { @Test fun `Append returns the page after`() = runTest { // Given - val pages = makeEmptyPageCache() - pages["2"] = Page(data = mutableListOf(mockStatus(id = "2")), nextKey = "1") - pages["1"] = Page(data = mutableListOf(mockStatus(id = "1")), nextKey = "0", prevKey = "2") - pages["0"] = Page(data = mutableListOf(mockStatus(id = "0")), prevKey = "1") + val pages = PageCache().apply { + upsert(Page(data = mutableListOf(mockStatus(id = "2")), nextKey = "1")) + upsert(Page(data = mutableListOf(mockStatus(id = "1")), nextKey = "0", prevKey = "2")) + upsert(Page(data = mutableListOf(mockStatus(id = "0")), prevKey = "1")) + } val pagingSource = NetworkTimelinePagingSource(pages) // When @@ -89,10 +92,11 @@ class NetworkTimelinePagingSourceTest { @Test fun `Prepend returns the page before`() = runTest { // Given - val pages = makeEmptyPageCache() - pages["2"] = Page(data = mutableListOf(mockStatus(id = "2")), nextKey = "1") - pages["1"] = Page(data = mutableListOf(mockStatus(id = "1")), nextKey = "0", prevKey = "2") - pages["0"] = Page(data = mutableListOf(mockStatus(id = "0")), prevKey = "1") + val pages = PageCache().apply { + upsert(Page(data = mutableListOf(mockStatus(id = "2")), nextKey = "1")) + upsert(Page(data = mutableListOf(mockStatus(id = "1")), nextKey = "0", prevKey = "2")) + upsert(Page(data = mutableListOf(mockStatus(id = "0")), prevKey = "1")) + } val pagingSource = NetworkTimelinePagingSource(pages) // When @@ -113,10 +117,11 @@ class NetworkTimelinePagingSourceTest { @Test fun `Refresh with null key returns the latest page`() = runTest { // Given - val pages = makeEmptyPageCache() - pages["2"] = Page(data = mutableListOf(mockStatus(id = "2")), nextKey = "1") - pages["1"] = Page(data = mutableListOf(mockStatus(id = "1")), nextKey = "0", prevKey = "2") - pages["0"] = Page(data = mutableListOf(mockStatus(id = "0")), prevKey = "1") + val pages = PageCache().apply { + upsert(Page(data = mutableListOf(mockStatus(id = "2")), nextKey = "1")) + upsert(Page(data = mutableListOf(mockStatus(id = "1")), nextKey = "0", prevKey = "2")) + upsert(Page(data = mutableListOf(mockStatus(id = "0")), prevKey = "1")) + } val pagingSource = NetworkTimelinePagingSource(pages) // When @@ -137,9 +142,10 @@ class NetworkTimelinePagingSourceTest { @Test fun `Append with a too-old key returns empty list`() = runTest { // Given - val pages = makeEmptyPageCache() - pages["20"] = Page(data = mutableListOf(mockStatus(id = "20")), nextKey = "10") - pages["10"] = Page(data = mutableListOf(mockStatus(id = "10")), prevKey = "20") + val pages = PageCache().apply { + upsert(Page(data = mutableListOf(mockStatus(id = "20")), nextKey = "10")) + upsert(Page(data = mutableListOf(mockStatus(id = "10")), prevKey = "20")) + } val pagingSource = NetworkTimelinePagingSource(pages) // When @@ -161,9 +167,10 @@ class NetworkTimelinePagingSourceTest { @Test fun `Prepend with a too-new key returns empty list`() = runTest { // Given - val pages = makeEmptyPageCache() - pages["20"] = Page(data = mutableListOf(mockStatus(id = "20")), nextKey = "10") - pages["10"] = Page(data = mutableListOf(mockStatus(id = "10")), prevKey = "20") + val pages = PageCache().apply { + upsert(Page(data = mutableListOf(mockStatus(id = "20")), nextKey = "10")) + upsert(Page(data = mutableListOf(mockStatus(id = "10")), prevKey = "20")) + } val pagingSource = NetworkTimelinePagingSource(pages) // When diff --git a/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRemoteMediatorTest.kt b/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRemoteMediatorTest.kt index 48ed7c33c7..9f898c12af 100644 --- a/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRemoteMediatorTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRemoteMediatorTest.kt @@ -9,8 +9,9 @@ import androidx.paging.PagingState import androidx.paging.RemoteMediator import androidx.test.ext.junit.runners.AndroidJUnit4 import com.google.common.truth.Truth.assertThat -import com.keylesspalace.tusky.components.timeline.NetworkTimelineRepository.Companion.makeEmptyPageCache import com.keylesspalace.tusky.components.timeline.viewmodel.NetworkTimelineRemoteMediator +import com.keylesspalace.tusky.components.timeline.viewmodel.Page +import com.keylesspalace.tusky.components.timeline.viewmodel.PageCache import com.keylesspalace.tusky.db.AccountEntity import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.entity.Status @@ -60,7 +61,7 @@ class NetworkTimelineRemoteMediatorTest { api = mock(defaultAnswer = { Response.error(500, "".toResponseBody()) }), accountManager = accountManager, factory = pagingSourceFactory, - pages = makeEmptyPageCache(), + pageCache = PageCache(), timelineKind = TimelineKind.Home ) @@ -81,7 +82,7 @@ class NetworkTimelineRemoteMediatorTest { api = mock(defaultAnswer = { throw IOException() }), accountManager, factory = pagingSourceFactory, - pages = makeEmptyPageCache(), + pageCache = PageCache(), timelineKind = TimelineKind.Home ) @@ -97,7 +98,7 @@ class NetworkTimelineRemoteMediatorTest { @ExperimentalPagingApi fun `should do initial loading`() = runTest { // Given - val pages = makeEmptyPageCache() + val pages = PageCache() val remoteMediator = NetworkTimelineRemoteMediator( api = mock { onBlocking { homeTimeline(maxId = anyOrNull(), minId = anyOrNull(), limit = anyOrNull(), sinceId = anyOrNull()) } doReturn Response.success( @@ -110,7 +111,7 @@ class NetworkTimelineRemoteMediatorTest { }, accountManager = accountManager, factory = pagingSourceFactory, - pages = pages, + pageCache = pages, timelineKind = TimelineKind.Home ) @@ -128,12 +129,15 @@ class NetworkTimelineRemoteMediatorTest { val result = remoteMediator.load(LoadType.REFRESH, state) // Then - val expectedPages = makeEmptyPageCache() - expectedPages["5"] = Page( - data = mutableListOf(mockStatus("7"), mockStatus("6"), mockStatus("5")), - prevKey = "7", - nextKey = "5" - ) + val expectedPages = PageCache().apply { + upsert( + Page( + data = mutableListOf(mockStatus("7"), mockStatus("6"), mockStatus("5")), + prevKey = "7", + nextKey = "5" + ) + ) + } assertThat(result).isInstanceOf(RemoteMediator.MediatorResult.Success::class.java) assertThat((result as RemoteMediator.MediatorResult.Success).endOfPaginationReached).isFalse() @@ -147,11 +151,13 @@ class NetworkTimelineRemoteMediatorTest { @ExperimentalPagingApi fun `should prepend statuses`() = runTest { // Given - val pages = makeEmptyPageCache().apply { - this["5"] = Page( - data = mutableListOf(mockStatus("7"), mockStatus("6"), mockStatus("5")), - prevKey = "7", - nextKey = "5" + val pages = PageCache().apply { + upsert( + Page( + data = mutableListOf(mockStatus("7"), mockStatus("6"), mockStatus("5")), + prevKey = "7", + nextKey = "5" + ) ) } @@ -167,7 +173,7 @@ class NetworkTimelineRemoteMediatorTest { }, accountManager = accountManager, factory = pagingSourceFactory, - pages = pages, + pageCache = pages, timelineKind = TimelineKind.Home ) @@ -185,16 +191,20 @@ class NetworkTimelineRemoteMediatorTest { val result = remoteMediator.load(LoadType.PREPEND, state) // Then - val expectedPages = makeEmptyPageCache().apply { - this["5"] = Page( - data = mutableListOf(mockStatus("7"), mockStatus("6"), mockStatus("5")), - prevKey = "7", - nextKey = "5" + val expectedPages = PageCache().apply { + upsert( + Page( + data = mutableListOf(mockStatus("7"), mockStatus("6"), mockStatus("5")), + prevKey = "7", + nextKey = "5" + ) ) - this["8"] = Page( - data = mutableListOf(mockStatus("10"), mockStatus("9"), mockStatus("8")), - prevKey = "10", - nextKey = "8" + upsert( + Page( + data = mutableListOf(mockStatus("10"), mockStatus("9"), mockStatus("8")), + prevKey = "10", + nextKey = "8" + ) ) } @@ -210,11 +220,13 @@ class NetworkTimelineRemoteMediatorTest { @ExperimentalPagingApi fun `should append statuses`() = runTest { // Given - val pages = makeEmptyPageCache().apply { - this["5"] = Page( - data = mutableListOf(mockStatus("7"), mockStatus("6"), mockStatus("5")), - prevKey = "7", - nextKey = "5" + val pages = PageCache().apply { + upsert( + Page( + data = mutableListOf(mockStatus("7"), mockStatus("6"), mockStatus("5")), + prevKey = "7", + nextKey = "5" + ) ) } @@ -230,7 +242,7 @@ class NetworkTimelineRemoteMediatorTest { }, accountManager = accountManager, factory = pagingSourceFactory, - pages = pages, + pageCache = pages, timelineKind = TimelineKind.Home ) @@ -248,16 +260,20 @@ class NetworkTimelineRemoteMediatorTest { val result = remoteMediator.load(LoadType.APPEND, state) // Then - val expectedPages = makeEmptyPageCache().apply { - this["5"] = Page( - data = mutableListOf(mockStatus("7"), mockStatus("6"), mockStatus("5")), - prevKey = "7", - nextKey = "5" + val expectedPages = PageCache().apply { + upsert( + Page( + data = mutableListOf(mockStatus("7"), mockStatus("6"), mockStatus("5")), + prevKey = "7", + nextKey = "5" + ) ) - this["2"] = Page( - data = mutableListOf(mockStatus("4"), mockStatus("3"), mockStatus("2")), - prevKey = "4", - nextKey = "2" + upsert( + Page( + data = mutableListOf(mockStatus("4"), mockStatus("3"), mockStatus("2")), + prevKey = "4", + nextKey = "2" + ) ) } From e7c233555c2275ac5ecf43efcecfac74e48ff170 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Wed, 28 Jun 2023 16:01:41 +0200 Subject: [PATCH 102/156] Remove debug code --- .../timeline/viewmodel/NetworkTimelinePagingSource.kt | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelinePagingSource.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelinePagingSource.kt index 3628d21e2d..9286bc7a1d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelinePagingSource.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelinePagingSource.kt @@ -106,8 +106,6 @@ class NetworkTimelinePagingSource @Inject constructor( Log.d(TAG, " Returning full page:") Log.d(TAG, " $page") } - val result = LoadResult.Page(page?.data ?: emptyList(), nextKey = page?.nextKey, prevKey = page?.prevKey) - Log.d(TAG, " result: $result") return LoadResult.Page(page?.data ?: emptyList(), nextKey = page?.nextKey, prevKey = page?.prevKey) } From e676bc83632da1a81cab68f0b101814002ed56ca Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Wed, 28 Jun 2023 16:20:17 +0200 Subject: [PATCH 103/156] No need to synchronize --- .../tusky/components/timeline/viewmodel/PageCache.kt | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/PageCache.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/PageCache.kt index 08f094181d..d8f99fe553 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/PageCache.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/PageCache.kt @@ -111,13 +111,11 @@ class PageCache : TreeMap(compareBy({ it.length }, { it })) { */ fun debug() { Log.d(TAG, "Page cache state:") - synchronized(this) { - if (this.isEmpty()) { - Log.d(TAG, " ** empty **") - } else { - this.onEachIndexed { index, entry -> - Log.d(TAG, " $index: ${entry.value}") - } + if (this.isEmpty()) { + Log.d(TAG, " ** empty **") + } else { + this.onEachIndexed { index, entry -> + Log.d(TAG, " $index: ${entry.value}") } } } From 56bb11f4bc3e33042d6f9fbfb2068d25e4634260 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Wed, 28 Jun 2023 16:58:55 +0200 Subject: [PATCH 104/156] WIP: Working on full refresh --- .../tusky/adapter/StatusBaseViewHolder.java | 3 +- .../components/timeline/TimelineFragment.kt | 76 +++++++++++++++---- .../viewmodel/NetworkTimelinePagingSource.kt | 14 ++-- 3 files changed, 71 insertions(+), 22 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java index 5be36bec08..74064ace0e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java @@ -761,7 +761,8 @@ public void setupWithStatus(@NonNull StatusViewData status, @Nullable Object payloads) { if (payloads == null) { Status actionable = status.getActionable(); - setDisplayName(actionable.getAccount().getName(), actionable.getAccount().getEmojis(), statusDisplayOptions); +// setDisplayName(actionable.getAccount().getName(), actionable.getAccount().getEmojis(), statusDisplayOptions); + setDisplayName(actionable.getId(), actionable.getAccount().getEmojis(), statusDisplayOptions); setUsername(status.getUsername()); setMetaData(status, statusDisplayOptions, listener); setIsReply(actionable.getInReplyToId() != null); diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt index 15edfce4e3..020eef458c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt @@ -31,6 +31,7 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle +import androidx.paging.CombinedLoadStates import androidx.paging.LoadState import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager @@ -75,12 +76,9 @@ import com.mikepenz.iconics.IconicsDrawable import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial import com.mikepenz.iconics.utils.colorInt import com.mikepenz.iconics.utils.sizeDp -import isTrue import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.onEach @@ -326,20 +324,70 @@ class TimelineFragment : // finished (so that DiffUtil has had a chance to process the data). See // https://github.com/googlecodelabs/android-paging/issues/149 launch { + var old: CombinedLoadStates? = null + var refreshComplete = false + if (isSwipeToRefreshEnabled) { adapter.loadStateFlow - .distinctUntilChanged { old, new -> - old.mediator?.prepend?.endOfPaginationReached.isTrue() && - new.mediator?.prepend?.endOfPaginationReached.isTrue() - } - .filter { - it.refresh is LoadState.NotLoading && it.prepend.endOfPaginationReached - } .collect { - binding.recyclerView.post { - getView() ?: return@post - binding.recyclerView.scrollBy(0, Utils.dpToPx(requireContext(), -30)) + if (old == null) { + Log.d("loadState", "No previous loadState") + old = it + return@collect + } + + if (old?.refresh != it.refresh) { + Log.d("loadState", "refresh: ${old?.refresh} -> ${it.refresh}") + } + + if (old?.prepend != it.prepend) { + Log.d("loadState", "prepend: ${old?.prepend} -> ${it.prepend}") + } + + if (old?.append != it.append) { + Log.d("loadState", "append: ${old?.append} -> ${it.append}") + } + + if (old?.source?.refresh != it.source.refresh) { + Log.d("loadState", " source.refresh: ${old?.source?.refresh} -> ${it.source.refresh}") + } + + if (old?.source?.prepend != it.source.prepend) { + Log.d("loadState", " source.prepend: ${old?.source?.prepend} -> ${it.source.prepend}") + } + + if (old?.source?.append != it.source.append) { + Log.d("loadState", " source.append: ${old?.source?.append} -> ${it.source.append}") + } + + if (old?.mediator?.refresh != it.mediator?.refresh) { + Log.d("loadState", " mediator.refresh: ${old?.mediator?.refresh} -> ${it.mediator?.refresh}") + } + + if (old?.mediator?.prepend != it.mediator?.prepend) { + Log.d("loadState", " mediator.prepend: ${old?.mediator?.prepend} -> ${it.mediator?.prepend}") + } + + if (old?.mediator?.append != it.mediator?.append) { + Log.d("loadState", " mediator.append: ${old?.mediator?.append} -> ${it.mediator?.append}") + } + + if (!refreshComplete) { + refreshComplete = + old?.refresh is LoadState.Loading && it.refresh is LoadState.NotLoading + } + + if (refreshComplete) { + if (old?.prepend is LoadState.Loading && it.prepend is LoadState.NotLoading) { + refreshComplete = false + Log.d("loadState", "mediator.prepend=NotLoading, scrolling to peek") + binding.recyclerView.post { + getView() ?: return@post + binding.recyclerView.scrollBy(0, Utils.dpToPx(requireContext(), -30)) + } + } } + old = it } } } @@ -347,7 +395,7 @@ class TimelineFragment : // Update the UI from the combined load state adapter.loadStateFlow .collect { loadState -> - Log.d(TAG, "loadState: $loadState") +// Log.d(TAG, "loadState: $loadState") Log.d(TAG, " adapter.itemCount: ${adapter.itemCount}") Log.d(TAG, " refresh?: ${loadState.refresh}") Log.d(TAG, " source.refresh?: ${loadState.source.refresh}") diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelinePagingSource.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelinePagingSource.kt index 9286bc7a1d..f71ba8ac35 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelinePagingSource.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelinePagingSource.kt @@ -37,14 +37,14 @@ class NetworkTimelinePagingSource @Inject constructor( return@synchronized null } - return@synchronized when (params) { + when (params) { is LoadParams.Refresh -> { - // If no key then return the latest page. Otherwise return the requested page. - if (params.key == null) { - pageCache.lastEntry()?.value - } else { - pageCache[params.key] ?: pageCache.lowerEntry(params.key)?.value - } + // If no key then return the latest page + params.key ?: return@synchronized pageCache.lastEntry()?.value + + // Return the page after. If you don't do this (i.e., return pageCache[params.key]) + // the page above animates down in a distracting manner. + return@synchronized pageCache.lowerEntry(params.key)?.value } // Loading previous / next pages (`Prepend` or `Append`) is a little complicated. // From a4ef7c7b0d53e69f8399d014e1b7f5608d0c0309 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Thu, 29 Jun 2023 10:21:48 +0200 Subject: [PATCH 105/156] Migrate "room" from "kapt" to "ksp" - Add ksp plugin - Switch room to use ksp instead of kapt - `ArrayList` -> `List` in a few places to fix errors about unbound generics --- app/build.gradle | 11 +++++------ .../components/conversation/ConversationEntity.kt | 2 +- .../java/com/keylesspalace/tusky/db/Converters.kt | 4 ++-- .../com/keylesspalace/tusky/entity/DeletedStatus.kt | 3 +-- .../java/com/keylesspalace/tusky/entity/Status.kt | 3 +-- build.gradle | 1 + gradle/libs.versions.toml | 1 + 7 files changed, 12 insertions(+), 13 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 297ce1da8f..7d46301c06 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,5 +1,6 @@ plugins { alias(libs.plugins.android.application) + alias(libs.plugins.google.ksp) alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.kapt) alias(libs.plugins.kotlin.parcelize) @@ -113,11 +114,9 @@ android { } } -kapt { - arguments { - arg("room.schemaLocation", "$projectDir/schemas") - arg("room.incremental", "true") - } +ksp { + arg("room.schemaLocation", "$projectDir/schemas") + arg("room.incremental", "true") } configurations { @@ -134,7 +133,7 @@ dependencies { implementation libs.bundles.androidx implementation libs.bundles.room - kapt libs.androidx.room.compiler + ksp libs.androidx.room.compiler implementation libs.android.material diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationEntity.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationEntity.kt index 9c5631916a..f830b5bd98 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationEntity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationEntity.kt @@ -89,7 +89,7 @@ data class ConversationStatusEntity( val bookmarked: Boolean, val sensitive: Boolean, val spoilerText: String, - val attachments: ArrayList, + val attachments: List, val mentions: List, val tags: List?, val showingHiddenContent: Boolean, diff --git a/app/src/main/java/com/keylesspalace/tusky/db/Converters.kt b/app/src/main/java/com/keylesspalace/tusky/db/Converters.kt index 6ef9425452..491cd53d84 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/Converters.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/Converters.kt @@ -102,8 +102,8 @@ class Converters @Inject constructor( } @TypeConverter - fun jsonToAttachmentList(attachmentListJson: String?): ArrayList? { - return gson.fromJson(attachmentListJson, object : TypeToken>() {}.type) + fun jsonToAttachmentList(attachmentListJson: String?): List? { + return gson.fromJson(attachmentListJson, object : TypeToken>() {}.type) } @TypeConverter diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/DeletedStatus.kt b/app/src/main/java/com/keylesspalace/tusky/entity/DeletedStatus.kt index 872379138b..c400a1af60 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/DeletedStatus.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/DeletedStatus.kt @@ -16,7 +16,6 @@ package com.keylesspalace.tusky.entity import com.google.gson.annotations.SerializedName -import java.util.ArrayList import java.util.Date data class DeletedStatus( @@ -25,7 +24,7 @@ data class DeletedStatus( @SerializedName("spoiler_text") val spoilerText: String, val visibility: Status.Visibility, val sensitive: Boolean, - @SerializedName("media_attachments") val attachments: ArrayList?, + @SerializedName("media_attachments") val attachments: List?, val poll: Poll?, @SerializedName("created_at") val createdAt: Date, val language: String? diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt index f959974612..0b9a0796d5 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt @@ -19,7 +19,6 @@ import android.text.SpannableStringBuilder import android.text.style.URLSpan import com.google.gson.annotations.SerializedName import com.keylesspalace.tusky.util.parseAsMastodonHtml -import java.util.ArrayList import java.util.Date data class Status( @@ -42,7 +41,7 @@ data class Status( val sensitive: Boolean, @SerializedName("spoiler_text") val spoilerText: String, val visibility: Visibility, - @SerializedName("media_attachments") val attachments: ArrayList, + @SerializedName("media_attachments") val attachments: List, val mentions: List, val tags: List?, val application: Application?, diff --git a/build.gradle b/build.gradle index 14527214e6..7689e2fb69 100644 --- a/build.gradle +++ b/build.gradle @@ -1,5 +1,6 @@ plugins { alias(libs.plugins.android.application) apply false + alias(libs.plugins.google.ksp) apply false alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.kotlin.kapt) apply false alias(libs.plugins.kotlin.parcelize) apply false diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b3128ba327..09e50769f1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -57,6 +57,7 @@ xmlwriter = "1.0.4" [plugins] android-application = { id = "com.android.application", version.ref = "agp" } +google-ksp = "com.google.devtools.ksp:1.8.22-1.0.11" kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-kapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlin" } kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" } From 31ae4aac9f3bbc067c0ba8542a60b04d72e464ed Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Sat, 1 Jul 2023 22:02:08 +0200 Subject: [PATCH 106/156] Load a huge page on refresh Tries to prevent jumping around during a refresh. More documentation --- .../timeline/NetworkTimelineRepository.kt | 24 +++++++ .../viewmodel/NetworkTimelinePagingSource.kt | 29 ++++---- .../NetworkTimelineRemoteMediator.kt | 67 ++++++++++++------- .../timeline/viewmodel/PageCache.kt | 48 ++++++++++++- 4 files changed, 123 insertions(+), 45 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRepository.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRepository.kt index 799cd15384..42dc09951b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRepository.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRepository.kt @@ -34,6 +34,30 @@ import com.keylesspalace.tusky.util.getDomain import kotlinx.coroutines.flow.Flow import javax.inject.Inject +// Things that make this more difficult than it should be: +// +// - Mastodon API doesn't support "Fetch page that contains item X", you have to rely on having +// the page that contains item X, and the previous or next page, so you can use the prev/next +// link values from the next or previous page to step forwards or backwards to the page you +// actually want. +// - Not all Mastodon APIs that paginate support a "Fetch me just the item X". E.g., getting a +// list of bookmarks (https://docs.joinmastodon.org/methods/bookmarks/#get) paginates, but does +// not support a "Get a single bookmark" call. Ditto for favourites. So even though some API +// methods do support that they can't be used here, because this has to work for all paging APIs. +// - Values of next/prev in the Link header do not have to match any of the item keys (or be taken +// from the same namespace). +// - Two pages that are consecutive in the result set may not have next/prev values that point +// back to each other. I.e., this is a valid set of two pages from an API call: +// +// .--- page index +// | .-- ID of last item (key in `pageCache`) +// v V +// 0: k: 109934818460629189, prevKey: 995916, nextKey: 941865 +// 1: k: 110033940961955385, prevKey: 1073324, nextKey: 997376 +// +// They are consecutive in the result set, but pageCache[0].prevKey != pageCache[1].nextKey. So +// there's no benefit to using the nextKey/prevKey tokens as the keys in PageCache. + /** Timeline repository where the timeline information is backed by an in-memory cache. */ class NetworkTimelineRepository @Inject constructor( private val mastodonApi: MastodonApi, diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelinePagingSource.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelinePagingSource.kt index f71ba8ac35..bfc2b9290a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelinePagingSource.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelinePagingSource.kt @@ -28,8 +28,7 @@ class NetworkTimelinePagingSource @Inject constructor( ) : PagingSource() { override suspend fun load(params: LoadParams): LoadResult { - Log.d(TAG, "load() with ${params.javaClass.simpleName} for key: ${params.key}") - + Log.d(TAG, "- load(), type = ${params.javaClass.simpleName}, key = ${params.key}") if (BuildConfig.DEBUG) { pageCache.debug() } val page = synchronized(pageCache) { @@ -39,12 +38,11 @@ class NetworkTimelinePagingSource @Inject constructor( when (params) { is LoadParams.Refresh -> { - // If no key then return the latest page - params.key ?: return@synchronized pageCache.lastEntry()?.value - - // Return the page after. If you don't do this (i.e., return pageCache[params.key]) - // the page above animates down in a distracting manner. - return@synchronized pageCache.lowerEntry(params.key)?.value + // Return the page that contains the given key, or the most recent page if + // the key isn't in the cache. + params.key?.let { key -> + pageCache.floorEntry(key)?.value + } ?: pageCache.lastEntry()?.value } // Loading previous / next pages (`Prepend` or `Append`) is a little complicated. // @@ -64,7 +62,7 @@ class NetworkTimelinePagingSource @Inject constructor( // indices). // // .--- page index - // | .-- ID of first item (key in `pages`) + // | .-- ID of last item (key in `pageCache`) // v V // 0: k: 109934818460629189, prevKey: 995916, nextKey: 941865 // 1: k: 110033940961955385, prevKey: 1073324, nextKey: 997376 @@ -104,20 +102,17 @@ class NetworkTimelinePagingSource @Inject constructor( Log.d(TAG, " Returning empty page") } else { Log.d(TAG, " Returning full page:") - Log.d(TAG, " $page") + Log.d(TAG, " $page") } return LoadResult.Page(page?.data ?: emptyList(), nextKey = page?.nextKey, prevKey = page?.prevKey) } override fun getRefreshKey(state: PagingState): String? { - Log.d(TAG, "getRefreshKey(): anchorPosition: ${state.anchorPosition}") - val refreshKey = state.anchorPosition?.let { anchorPosition -> - // TODO: Test if closestPage or closestItem is better here -// state.closestPageToPosition(anchorPosition)?.data?.last()?.id - state.closestItemToPosition(anchorPosition)?.id + return state.anchorPosition?.let { anchorPosition -> + val refreshKey = state.closestItemToPosition(anchorPosition)?.id + Log.d(TAG, "- getRefreshKey(), state.anchorPosition = $anchorPosition, return $refreshKey") + refreshKey } - Log.d(TAG, " refreshKey = $refreshKey") - return refreshKey } companion object { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt index 2f7c538461..6334921389 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt @@ -49,18 +49,24 @@ class NetworkTimelineRemoteMediator( return MediatorResult.Success(endOfPaginationReached = true) } - Log.d(TAG, "load(), LoadType = $loadType") - return try { val key = when (loadType) { LoadType.REFRESH -> { - Log.d(TAG, " anchorPosition: ${state.anchorPosition}") // Find the closest page to the current position - val key = state.anchorPosition?.let { state.closestPageToPosition(it) }?.data?.last()?.id - Log.d(TAG, " Refreshing page with last id: $key") + val itemKey = state.anchorPosition?.let { state.closestItemToPosition(it) }?.id + itemKey?.let { ik -> + val pageContainingItem = pageCache.floorEntry(ik) + ?: throw java.lang.IllegalStateException("$itemKey not found in the pageCache page") + + // Double check the item appears in the page + if (BuildConfig.DEBUG) { + pageContainingItem.value.data.find { it.id == itemKey } + ?: throw java.lang.IllegalStateException("$itemKey not found in returned page") + } - // Use its prevKey to refresh this page - key?.let { pageCache.lowerEntry(it)?.value?.prevKey } + // The desired key is the prevKey of the page immediately before this one + pageCache.lowerEntry(pageContainingItem.value.data.last().id)?.value?.prevKey + } } LoadType.APPEND -> { pageCache.firstEntry()?.value?.nextKey ?: return MediatorResult.Success(endOfPaginationReached = true) @@ -70,26 +76,35 @@ class NetworkTimelineRemoteMediator( } } - Log.d(TAG, " from key: $key") - val response = fetchStatusPageByKind(loadType, key, state.config.initialLoadSize) + Log.d(TAG, "- load(), type = $loadType, key = $key") - val page = Page.tryFrom(response).getOrElse { return MediatorResult.Error(it) } + val response = fetchStatusPageByKind(loadType, key, state.config.initialLoadSize) + var page = Page.tryFrom(response).getOrElse { return MediatorResult.Error(it) } + + // If doing a refresh with a known key Paging3 wants you to load "around" the requested + // key, so that it can show the item with the key in the view as well as context before + // and after it. If you don't do this state.anchorPosition can get in to a weird state + // where it starts picking anchorPositions in freshly loaded pages, and the list + // repeatedly jumps up as new content is loaded with the prepend operations that occur + // after a refresh. + // + // To ensure that the first page loaded after a refresh is big enough that this can't + // happen load the page immediately before and the page immediately after as well, + // and merge the three of them in to one large page. + if (loadType == LoadType.REFRESH && key != null) { + Log.d(TAG, " Refresh with non-null key, creating huge page") + val prevPage = Page.tryFrom(fetchStatusPageByKind(LoadType.PREPEND, page.prevKey, state.config.initialLoadSize)) + .getOrElse { return MediatorResult.Error(it) } + val nextPage = Page.tryFrom(fetchStatusPageByKind(LoadType.APPEND, page.nextKey, state.config.initialLoadSize)) + .getOrElse { return MediatorResult.Error(it) } + page = page.merge(prevPage, nextPage) + } if (BuildConfig.DEBUG && loadType == LoadType.REFRESH) { - state.anchorPosition?.let { state.closestPageToPosition(it) }?.data?.last()?.id?.let { expectedPage -> - if (pageCache[expectedPage]?.data?.size == page.data.size) { - Log.d(TAG, " Refresh has same data size") - if (pageCache[expectedPage]?.data?.last()?.id == page.data.last().id) { - Log.d(TAG, " ... and they have the same last ID") - } else { - throw IllegalStateException(" Last IDs do not match, got: ${page.data.last().id}, want: $expectedPage") - } - } else { - Log.d(TAG, " Refresh has different data size!") - } - page.data.lastOrNull { it.id == expectedPage } - ?: throw IllegalStateException(" Refreshed page does not contain $expectedPage") - Log.d(TAG, " Page contains $expectedPage") + // Verify page contains the expected key + state.anchorPosition?.let { state.closestItemToPosition(it) }?.id?.let { itemId -> + page.data.find { it.id == itemId } + ?: throw IllegalStateException("Fetched page with $key, it does not contain $itemId") } } @@ -103,11 +118,11 @@ class NetworkTimelineRemoteMediator( pageCache.upsert(page) Log.d( TAG, - " Page $loadType complete for $timelineKind, now got ${pageCache.size} pages" + " Page $loadType complete for $timelineKind, now got ${pageCache.size} pages, endOfPaginationReached = $endOfPaginationReached" ) pageCache.debug() } - Log.d(TAG, " Invalidating source") + Log.d(TAG, " Invalidating paging source") factory.invalidate() } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/PageCache.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/PageCache.kt index d8f99fe553..4ccf96f4a2 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/PageCache.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/PageCache.kt @@ -21,6 +21,7 @@ import android.util.Log import com.keylesspalace.tusky.BuildConfig import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.network.Links +import com.keylesspalace.tusky.util.isLessThan import retrofit2.HttpException import retrofit2.Response import java.util.TreeMap @@ -28,7 +29,7 @@ import kotlin.Result.Companion.failure import kotlin.Result.Companion.success /** A page of data from the Mastodon API */ -data class Page constructor( +data class Page( /** Loaded data */ val data: MutableList, /** @@ -42,7 +43,50 @@ data class Page constructor( */ val nextKey: String? = null ) { - override fun toString() = "k: ${data.last().id}, prev: $prevKey, next: $nextKey, size: ${data.size}, range: ${data.first().id}..${data.last().id}" + override fun toString() = "k: ${data.lastOrNull()?.id}, prev: $prevKey, next: $nextKey, size: ${"%2d".format(data.size)}, range: ${data.firstOrNull()?.id}..${data.lastOrNull()?.id}" + + fun merge(vararg pages: Page?): Page { + val d = data + var next = nextKey + var prev = prevKey + + pages.filterNotNull().forEach { + d.addAll(it.data) + if (next != null) { + if (it.nextKey == null || it.nextKey.isLessThan(next!!)) next = it.nextKey + } + if (prev != null) { + if (prev!!.isLessThan(it.prevKey ?: "")) prev = it.prevKey + } + } + + d.sortWith(compareBy({ it.id.length }, { it.id })) + d.reverse() + + if (nextKey?.isLessThan(next ?: "") == true) throw java.lang.IllegalStateException("New next $next is greater than old nextKey $nextKey") + if (prev?.isLessThan(prevKey ?: "") == true) throw java.lang.IllegalStateException("New prev $prev is less than old $prevKey") + + // Debug assertions + if (BuildConfig.DEBUG) { + // There should never be duplicate items across all the pages. + val ids = d.map { it.id } + val groups = ids.groupingBy { it }.eachCount().filter { it.value > 1 } + if (groups.isNotEmpty()) { + throw IllegalStateException("Duplicate item IDs in results!: $groups") + } + + // Data should always be sorted newest first + if (d.first().id.isLessThan(d.last().id)) { + throw IllegalStateException("Items in data are *not* sorted newest first") + } + } + + return Page( + data = d, + nextKey = next, + prevKey = prev + ) + } companion object { private const val TAG = "Page" From 8b4d84b5547ed735694e10ff75cd69a2e6673044 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Sun, 2 Jul 2023 21:34:49 +0200 Subject: [PATCH 107/156] Return LoadResult.Invalid if the paging source has been invalidated --- .../notifications/NotificationsPagingSource.kt | 12 ++++++++++++ .../viewmodel/NetworkTimelinePagingSource.kt | 12 ++++++++++++ 2 files changed, 24 insertions(+) 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 index 5202a36fed..f483610470 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsPagingSource.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsPagingSource.kt @@ -19,6 +19,7 @@ package com.keylesspalace.tusky.components.notifications import android.util.Log import androidx.paging.PagingSource +import androidx.paging.PagingSource.LoadResult import androidx.paging.PagingState import com.google.gson.Gson import com.keylesspalace.tusky.entity.Notification @@ -30,6 +31,8 @@ import okhttp3.Headers import retrofit2.Response import javax.inject.Inject +private val INVALID = LoadResult.Invalid() + /** [PagingSource] for Mastodon Notifications, identified by the Notification ID */ class NotificationsPagingSource @Inject constructor( private val mastodonApi: MastodonApi, @@ -77,6 +80,15 @@ class NotificationsPagingSource @Inject constructor( } val links = Links.from(response.headers()["link"]) + + // Bail if this paging source has already been invalidated. If you do not do this there + // is a lot of spurious animation, especially during the initial load, as multiple pages + // are loaded and the paging source is repeatedly invalidated. + if (invalid) { + Log.d(TAG, "Invalidated, returning LoadResult.Invalid") + return INVALID + } + return LoadResult.Page( data = response.body()!!, nextKey = links.next, diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelinePagingSource.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelinePagingSource.kt index bfc2b9290a..71169a0e30 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelinePagingSource.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelinePagingSource.kt @@ -17,11 +17,14 @@ package com.keylesspalace.tusky.components.timeline.viewmodel import android.util.Log import androidx.paging.PagingSource +import androidx.paging.PagingSource.LoadResult import androidx.paging.PagingState import com.keylesspalace.tusky.BuildConfig import com.keylesspalace.tusky.entity.Status import javax.inject.Inject +private val INVALID = LoadResult.Invalid() + /** [PagingSource] for Mastodon Status, identified by the Status ID */ class NetworkTimelinePagingSource @Inject constructor( private val pageCache: PageCache @@ -104,6 +107,15 @@ class NetworkTimelinePagingSource @Inject constructor( Log.d(TAG, " Returning full page:") Log.d(TAG, " $page") } + + // Bail if this paging source has already been invalidated. If you do not do this there + // is a lot of spurious animation, especially during the initial load, as multiple pages + // are loaded and the paging source is repeatedly invalidated. + if (invalid) { + Log.d(TAG, "Invalidated, returning LoadResult.Invalid") + return INVALID + } + return LoadResult.Page(page?.data ?: emptyList(), nextKey = page?.nextKey, prevKey = page?.prevKey) } From 770d23c398a3d09dc9ce67fd3bd1098eb4d33297 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Mon, 3 Jul 2023 12:31:31 +0200 Subject: [PATCH 108/156] Load huge pages asynchronously --- .../timeline/NetworkTimelineRepository.kt | 5 ++++- .../viewmodel/NetworkTimelineRemoteMediator.kt | 17 ++++++++++++----- .../viewmodel/NetworkTimelineViewModel.kt | 2 +- 3 files changed, 17 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRepository.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRepository.kt index 42dc09951b..4cc5fc2e18 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRepository.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRepository.kt @@ -31,6 +31,7 @@ import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.util.getDomain +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import javax.inject.Inject @@ -70,9 +71,10 @@ class NetworkTimelineRepository @Inject constructor( /** @return flow of Mastodon [Status], loaded in [pageSize] increments */ @OptIn(ExperimentalPagingApi::class) fun getStatusStream( + viewModelScope: CoroutineScope, kind: TimelineKind, pageSize: Int = PAGE_SIZE, - initialKey: String? = null + initialKey: String? = null, ): Flow> { Log.d(TAG, "getStatusStream(): key: $initialKey") @@ -83,6 +85,7 @@ class NetworkTimelineRepository @Inject constructor( return Pager( config = PagingConfig(pageSize = pageSize, initialLoadSize = pageSize), remoteMediator = NetworkTimelineRemoteMediator( + viewModelScope, mastodonApi, accountManager, factory!!, diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt index 6334921389..209ab8217e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt @@ -28,6 +28,8 @@ import com.keylesspalace.tusky.components.timeline.TimelineKind import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.network.MastodonApi +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.async import retrofit2.HttpException import retrofit2.Response import java.io.IOException @@ -35,6 +37,7 @@ import java.io.IOException /** Remote mediator for accessing timelines that are not backed by the database. */ @OptIn(ExperimentalPagingApi::class) class NetworkTimelineRemoteMediator( + private val viewModelScope: CoroutineScope, private val api: MastodonApi, accountManager: AccountManager, private val factory: InvalidatingPagingSourceFactory, @@ -93,10 +96,14 @@ class NetworkTimelineRemoteMediator( // and merge the three of them in to one large page. if (loadType == LoadType.REFRESH && key != null) { Log.d(TAG, " Refresh with non-null key, creating huge page") - val prevPage = Page.tryFrom(fetchStatusPageByKind(LoadType.PREPEND, page.prevKey, state.config.initialLoadSize)) - .getOrElse { return MediatorResult.Error(it) } - val nextPage = Page.tryFrom(fetchStatusPageByKind(LoadType.APPEND, page.nextKey, state.config.initialLoadSize)) - .getOrElse { return MediatorResult.Error(it) } + val prevPageJob = viewModelScope.async { + fetchStatusPageByKind(LoadType.PREPEND, page.prevKey, state.config.initialLoadSize) + } + val nextPageJob = viewModelScope.async { + fetchStatusPageByKind(LoadType.APPEND, page.nextKey, state.config.initialLoadSize) + } + val prevPage = Page.tryFrom(prevPageJob.await()).getOrElse { return MediatorResult.Error(it) } + val nextPage = Page.tryFrom(nextPageJob.await()).getOrElse { return MediatorResult.Error(it) } page = page.merge(prevPage, nextPage) } @@ -118,7 +125,7 @@ class NetworkTimelineRemoteMediator( pageCache.upsert(page) Log.d( TAG, - " Page $loadType complete for $timelineKind, now got ${pageCache.size} pages, endOfPaginationReached = $endOfPaginationReached" + " Page $loadType complete for $timelineKind, now got ${pageCache.size} pages" ) pageCache.debug() } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt index 794938d72f..f87c3f380b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt @@ -85,7 +85,7 @@ class NetworkTimelineViewModel @Inject constructor( initialKey: String? = null ): Flow> { Log.d(TAG, "getStatuses: kind: $kind, initialKey: $initialKey") - return repository.getStatusStream(kind = kind, initialKey = initialKey) + return repository.getStatusStream(viewModelScope, kind = kind, initialKey = initialKey) .map { pagingData -> pagingData.map { modifiedViewData[it.id] ?: it.toViewData( From 8bece6fa0fecee2c81a0776acf4b03e7fb7359e4 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Tue, 4 Jul 2023 17:48:23 +0200 Subject: [PATCH 109/156] Check for null when creating the huge page --- .../viewmodel/NetworkTimelineRemoteMediator.kt | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt index 209ab8217e..9582b33508 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt @@ -97,13 +97,22 @@ class NetworkTimelineRemoteMediator( if (loadType == LoadType.REFRESH && key != null) { Log.d(TAG, " Refresh with non-null key, creating huge page") val prevPageJob = viewModelScope.async { - fetchStatusPageByKind(LoadType.PREPEND, page.prevKey, state.config.initialLoadSize) + page.prevKey?.let { key -> + fetchStatusPageByKind(LoadType.PREPEND, key, state.config.initialLoadSize) + } } val nextPageJob = viewModelScope.async { - fetchStatusPageByKind(LoadType.APPEND, page.nextKey, state.config.initialLoadSize) + page.nextKey?.let { key -> + fetchStatusPageByKind(LoadType.APPEND, key, state.config.initialLoadSize) + } } - val prevPage = Page.tryFrom(prevPageJob.await()).getOrElse { return MediatorResult.Error(it) } - val nextPage = Page.tryFrom(nextPageJob.await()).getOrElse { return MediatorResult.Error(it) } + val prevPage = prevPageJob.await() + ?.let { Page.tryFrom(it).getOrElse { return MediatorResult.Error(it) } } + val nextPage = nextPageJob.await() + ?.let { Page.tryFrom(it).getOrElse { return MediatorResult.Error(it) } } + Log.d(TAG, " prevPage: $prevPage") + Log.d(TAG, " midPage: $page") + Log.d(TAG, " nextPage: $nextPage") page = page.merge(prevPage, nextPage) } From 76bc564af220053caf90c9037da73d724af7e499 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Tue, 4 Jul 2023 17:48:40 +0200 Subject: [PATCH 110/156] Link to relevant bugs --- .../components/timeline/NetworkTimelineRepository.kt | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRepository.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRepository.kt index 4cc5fc2e18..964c889f9d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRepository.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRepository.kt @@ -41,12 +41,15 @@ import javax.inject.Inject // the page that contains item X, and the previous or next page, so you can use the prev/next // link values from the next or previous page to step forwards or backwards to the page you // actually want. +// // - Not all Mastodon APIs that paginate support a "Fetch me just the item X". E.g., getting a // list of bookmarks (https://docs.joinmastodon.org/methods/bookmarks/#get) paginates, but does // not support a "Get a single bookmark" call. Ditto for favourites. So even though some API // methods do support that they can't be used here, because this has to work for all paging APIs. +// // - Values of next/prev in the Link header do not have to match any of the item keys (or be taken // from the same namespace). +// // - Two pages that are consecutive in the result set may not have next/prev values that point // back to each other. I.e., this is a valid set of two pages from an API call: // @@ -58,6 +61,11 @@ import javax.inject.Inject // // They are consecutive in the result set, but pageCache[0].prevKey != pageCache[1].nextKey. So // there's no benefit to using the nextKey/prevKey tokens as the keys in PageCache. +// +// - Bugs in the Paging library mean that on initial load (especially of rapidly changing timelines +// like Federated) the user's initial position can jump around a lot. See: +// - https://issuetracker.google.com/issues/235319241 +// - https://issuetracker.google.com/issues/289824257 /** Timeline repository where the timeline information is backed by an in-memory cache. */ class NetworkTimelineRepository @Inject constructor( From 4ec61c7e0919debdc13ed4eb6d9a18ee2627322d Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Tue, 4 Jul 2023 20:07:55 +0200 Subject: [PATCH 111/156] Lint --- .../tusky/components/timeline/NetworkTimelineRepository.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRepository.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRepository.kt index 964c889f9d..45d6e97780 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRepository.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRepository.kt @@ -82,7 +82,7 @@ class NetworkTimelineRepository @Inject constructor( viewModelScope: CoroutineScope, kind: TimelineKind, pageSize: Int = PAGE_SIZE, - initialKey: String? = null, + initialKey: String? = null ): Flow> { Log.d(TAG, "getStatusStream(): key: $initialKey") From af99ef43b79c2706c668757b21803a579eb6bbfb Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Wed, 5 Jul 2023 10:11:21 +0200 Subject: [PATCH 112/156] Correct off-by-two error in state.anchorPosition --- .../viewmodel/NetworkTimelinePagingSource.kt | 21 +++++++++++++------ .../NetworkTimelineRemoteMediator.kt | 11 ++++------ 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelinePagingSource.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelinePagingSource.kt index 71169a0e30..ee5ff5e6f5 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelinePagingSource.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelinePagingSource.kt @@ -19,7 +19,6 @@ import android.util.Log import androidx.paging.PagingSource import androidx.paging.PagingSource.LoadResult import androidx.paging.PagingState -import com.keylesspalace.tusky.BuildConfig import com.keylesspalace.tusky.entity.Status import javax.inject.Inject @@ -32,7 +31,7 @@ class NetworkTimelinePagingSource @Inject constructor( override suspend fun load(params: LoadParams): LoadResult { Log.d(TAG, "- load(), type = ${params.javaClass.simpleName}, key = ${params.key}") - if (BuildConfig.DEBUG) { pageCache.debug() } + pageCache.debug() val page = synchronized(pageCache) { if (pageCache.isEmpty()) { @@ -120,11 +119,21 @@ class NetworkTimelinePagingSource @Inject constructor( } override fun getRefreshKey(state: PagingState): String? { - return state.anchorPosition?.let { anchorPosition -> - val refreshKey = state.closestItemToPosition(anchorPosition)?.id - Log.d(TAG, "- getRefreshKey(), state.anchorPosition = $anchorPosition, return $refreshKey") - refreshKey + val refreshKey = if (state.anchorPosition != null) { + // In testing, state.anchorPosition always seems to be off by 2. I suspect that might + // be because of the load state header and footer that are on the list. If this is + // not corrected here then the user's reading position is *not* maintained as they + // scroll over a page boundary, and the list jumps up by two posts. Adding 2 here + // corrects for this. + state.closestItemToPosition(state.anchorPosition!! + 2)?.id + } else { + pageCache.firstEntry()?.value?.data?.let { data -> + data.getOrNull(data.size / 2)?.id + } } + + Log.d(TAG, "- getRefreshKey(), state.anchorPosition = ${state.anchorPosition}, return $refreshKey") + return refreshKey } companion object { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt index 9582b33508..f75abfb641 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt @@ -86,14 +86,11 @@ class NetworkTimelineRemoteMediator( // If doing a refresh with a known key Paging3 wants you to load "around" the requested // key, so that it can show the item with the key in the view as well as context before - // and after it. If you don't do this state.anchorPosition can get in to a weird state - // where it starts picking anchorPositions in freshly loaded pages, and the list - // repeatedly jumps up as new content is loaded with the prepend operations that occur - // after a refresh. + // and after it. // - // To ensure that the first page loaded after a refresh is big enough that this can't - // happen load the page immediately before and the page immediately after as well, - // and merge the three of them in to one large page. + // To ensure that the first page loaded after a refresh is big enough load the page + // immediately before and the page immediately after as well, and merge the three of + // them in to one large page. if (loadType == LoadType.REFRESH && key != null) { Log.d(TAG, " Refresh with non-null key, creating huge page") val prevPageJob = viewModelScope.async { From d22658a09af1fe8a4536df7d600eefeaacd6b963 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Wed, 5 Jul 2023 10:23:11 +0200 Subject: [PATCH 113/156] Add scope to remote mediator tests --- .../components/timeline/NetworkTimelineRemoteMediatorTest.kt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRemoteMediatorTest.kt b/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRemoteMediatorTest.kt index 9f898c12af..258c85ae23 100644 --- a/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRemoteMediatorTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRemoteMediatorTest.kt @@ -58,6 +58,7 @@ class NetworkTimelineRemoteMediatorTest { fun `should return error when network call returns error code`() = runTest { // Given val remoteMediator = NetworkTimelineRemoteMediator( + viewModelScope = this, api = mock(defaultAnswer = { Response.error(500, "".toResponseBody()) }), accountManager = accountManager, factory = pagingSourceFactory, @@ -79,6 +80,7 @@ class NetworkTimelineRemoteMediatorTest { fun `should return error when network call fails`() = runTest { // Given val remoteMediator = NetworkTimelineRemoteMediator( + viewModelScope = this, api = mock(defaultAnswer = { throw IOException() }), accountManager, factory = pagingSourceFactory, @@ -100,6 +102,7 @@ class NetworkTimelineRemoteMediatorTest { // Given val pages = PageCache() val remoteMediator = NetworkTimelineRemoteMediator( + viewModelScope = this, api = mock { onBlocking { homeTimeline(maxId = anyOrNull(), minId = anyOrNull(), limit = anyOrNull(), sinceId = anyOrNull()) } doReturn Response.success( listOf(mockStatus("7"), mockStatus("6"), mockStatus("5")), @@ -162,6 +165,7 @@ class NetworkTimelineRemoteMediatorTest { } val remoteMediator = NetworkTimelineRemoteMediator( + viewModelScope = this, api = mock { onBlocking { homeTimeline(maxId = anyOrNull(), minId = anyOrNull(), limit = anyOrNull(), sinceId = anyOrNull()) } doReturn Response.success( listOf(mockStatus("10"), mockStatus("9"), mockStatus("8")), @@ -231,6 +235,7 @@ class NetworkTimelineRemoteMediatorTest { } val remoteMediator = NetworkTimelineRemoteMediator( + viewModelScope = this, api = mock { onBlocking { homeTimeline(maxId = anyOrNull(), minId = anyOrNull(), limit = anyOrNull(), sinceId = anyOrNull()) } doReturn Response.success( listOf(mockStatus("4"), mockStatus("3"), mockStatus("2")), From bdeb6817ebe1dd8378ac0a557edea51d39686e3e Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Wed, 5 Jul 2023 11:52:24 +0200 Subject: [PATCH 114/156] Show the display name again --- .../com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java index 74064ace0e..5be36bec08 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java @@ -761,8 +761,7 @@ public void setupWithStatus(@NonNull StatusViewData status, @Nullable Object payloads) { if (payloads == null) { Status actionable = status.getActionable(); -// setDisplayName(actionable.getAccount().getName(), actionable.getAccount().getEmojis(), statusDisplayOptions); - setDisplayName(actionable.getId(), actionable.getAccount().getEmojis(), statusDisplayOptions); + setDisplayName(actionable.getAccount().getName(), actionable.getAccount().getEmojis(), statusDisplayOptions); setUsername(status.getUsername()); setMetaData(status, statusDisplayOptions, listener); setIsReply(actionable.getInReplyToId() != null); From 324220d82c4c1f49714cc9d66cf690cf19d49a1a Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Wed, 5 Jul 2023 19:14:12 +0200 Subject: [PATCH 115/156] Comments --- .../tusky/components/timeline/viewmodel/PageCache.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/PageCache.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/PageCache.kt index 4ccf96f4a2..f618defa62 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/PageCache.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/PageCache.kt @@ -45,6 +45,9 @@ data class Page( ) { override fun toString() = "k: ${data.lastOrNull()?.id}, prev: $prevKey, next: $nextKey, size: ${"%2d".format(data.size)}, range: ${data.firstOrNull()?.id}..${data.lastOrNull()?.id}" + /** + * Return a new page consisting of this page, plus the data from [pages]. + */ fun merge(vararg pages: Page?): Page { val d = data var next = nextKey From 4c44481bd0872b0c215be717a7b814bbeb22617a Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Wed, 5 Jul 2023 19:14:39 +0200 Subject: [PATCH 116/156] RemoteKeys implementation for cached timeline --- .../52.json | 44 +++++++++++- .../viewmodel/CachedTimelineRemoteMediator.kt | 68 ++++++++++++++++--- .../keylesspalace/tusky/db/AccountManager.kt | 3 +- .../keylesspalace/tusky/db/AppDatabase.java | 4 +- .../keylesspalace/tusky/db/RemoteKeyDao.kt | 35 ++++++++++ .../keylesspalace/tusky/db/RemoteKeyEntity.kt | 55 +++++++++++++++ .../com/keylesspalace/tusky/db/TimelineDao.kt | 6 -- 7 files changed, 197 insertions(+), 18 deletions(-) create mode 100644 app/src/main/java/com/keylesspalace/tusky/db/RemoteKeyDao.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/db/RemoteKeyEntity.kt diff --git a/app/schemas/com.keylesspalace.tusky.db.AppDatabase/52.json b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/52.json index c1b6af44d5..2986dea9ab 100644 --- a/app/schemas/com.keylesspalace.tusky.db.AppDatabase/52.json +++ b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/52.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 52, - "identityHash": "edce0f04c8586e8f43455f052d6258cf", + "identityHash": "99e79139435c7255795ec8c89831a8dd", "entities": [ { "tableName": "DraftEntity", @@ -991,12 +991,52 @@ }, "indices": [], "foreignKeys": [] + }, + { + "tableName": "RemoteKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `timelineId` TEXT NOT NULL, `kind` TEXT NOT NULL, `key` TEXT, PRIMARY KEY(`accountId`, `timelineId`, `kind`))", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timelineId", + "columnName": "timelineId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "accountId", + "timelineId", + "kind" + ] + }, + "indices": [], + "foreignKeys": [] } ], "views": [], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'edce0f04c8586e8f43455f052d6258cf')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '99e79139435c7255795ec8c89831a8dd')" ] } } \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt index 87c6bbc552..f194b746f9 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt @@ -26,9 +26,12 @@ import com.google.gson.Gson import com.keylesspalace.tusky.components.timeline.toEntity import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AppDatabase +import com.keylesspalace.tusky.db.RemoteKeyEntity +import com.keylesspalace.tusky.db.RemoteKeyKind import com.keylesspalace.tusky.db.TimelineStatusEntity import com.keylesspalace.tusky.db.TimelineStatusWithAccount import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.network.Links import com.keylesspalace.tusky.network.MastodonApi import retrofit2.HttpException import java.io.IOException @@ -42,6 +45,7 @@ class CachedTimelineRemoteMediator( ) : RemoteMediator() { private val timelineDao = db.timelineDao() + private val remoteKeyDao = db.remoteKeyDao() private val activeAccount = accountManager.activeAccount!! override suspend fun load( @@ -60,16 +64,26 @@ class CachedTimelineRemoteMediator( api.homeTimeline(limit = state.config.pageSize) } LoadType.APPEND -> { - val bottomId = timelineDao.getBottomId(activeAccount.id) - ?: return MediatorResult.Success(endOfPaginationReached = true) - Log.d(TAG, "Loading from bottomId: $bottomId") - api.homeTimeline(maxId = bottomId, limit = state.config.pageSize) + val rke = db.withTransaction { + remoteKeyDao.remoteKeyForKind( + activeAccount.id, + TIMELINE_ID, + RemoteKeyKind.NEXT + ) + } ?: return MediatorResult.Success(endOfPaginationReached = true) + Log.d(TAG, "Loading from remoteKey: $rke") + api.homeTimeline(maxId = rke.key, limit = state.config.pageSize) } LoadType.PREPEND -> { - val topId = timelineDao.getTopId(activeAccount.id) - ?: return MediatorResult.Success(endOfPaginationReached = true) - Log.d(TAG, "Loading from topId: $topId") - api.homeTimeline(minId = topId, limit = state.config.pageSize) + val rke = db.withTransaction { + remoteKeyDao.remoteKeyForKind( + activeAccount.id, + TIMELINE_ID, + RemoteKeyKind.PREV + ) + }?: return MediatorResult.Success(endOfPaginationReached = true) + Log.d(TAG, "Loading from remoteKey: $rke") + api.homeTimeline(minId = rke.key, limit = state.config.pageSize) } } @@ -85,6 +99,43 @@ class CachedTimelineRemoteMediator( } db.withTransaction { + val links = Links.from(response.headers()["link"]) + when (loadType) { + LoadType.REFRESH -> { + remoteKeyDao.upsert( + RemoteKeyEntity( + activeAccount.id, + TIMELINE_ID, + RemoteKeyKind.NEXT, + links.next + ) + ) + remoteKeyDao.upsert( + RemoteKeyEntity( + activeAccount.id, + TIMELINE_ID, + RemoteKeyKind.PREV, + links.prev + ) + ) + } + LoadType.PREPEND -> remoteKeyDao.upsert( + RemoteKeyEntity( + activeAccount.id, + TIMELINE_ID, + RemoteKeyKind.PREV, + links.prev + ) + ) + LoadType.APPEND -> remoteKeyDao.upsert( + RemoteKeyEntity( + activeAccount.id, + TIMELINE_ID, + RemoteKeyKind.NEXT, + links.next + ) + ) + } replaceStatusRange(statuses, state) } @@ -145,5 +196,6 @@ class CachedTimelineRemoteMediator( companion object { private const val TAG = "CachedTimelineRemoteMediator" + private const val TIMELINE_ID = "HOME" } } diff --git a/app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt b/app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt index 61ce076a54..8c125b7378 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt @@ -33,7 +33,7 @@ import javax.inject.Singleton private const val TAG = "AccountManager" @Singleton -class AccountManager @Inject constructor(db: AppDatabase) { +class AccountManager @Inject constructor(val db: AppDatabase) { @Volatile var activeAccount: AccountEntity? = null @@ -128,6 +128,7 @@ class AccountManager @Inject constructor(db: AppDatabase) { accounts.remove(account) accountDao.delete(account) + db.remoteKeyDao().delete(account.id) if (accounts.size > 0) { accounts[0].isActive = true diff --git a/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java b/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java index a4e9e25351..d057ab4d1c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java +++ b/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java @@ -39,7 +39,8 @@ InstanceEntity.class, TimelineStatusEntity.class, TimelineAccountEntity.class, - ConversationEntity.class + ConversationEntity.class, + RemoteKeyEntity.class }, version = 52, autoMigrations = { @@ -55,6 +56,7 @@ public abstract class AppDatabase extends RoomDatabase { public abstract ConversationsDao conversationDao(); public abstract TimelineDao timelineDao(); public abstract DraftDao draftDao(); + public abstract RemoteKeyDao remoteKeyDao(); public static final Migration MIGRATION_2_3 = new Migration(2, 3) { @Override diff --git a/app/src/main/java/com/keylesspalace/tusky/db/RemoteKeyDao.kt b/app/src/main/java/com/keylesspalace/tusky/db/RemoteKeyDao.kt new file mode 100644 index 0000000000..394e53c280 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/db/RemoteKeyDao.kt @@ -0,0 +1,35 @@ +/* + * 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.db + +import androidx.room.Dao +import androidx.room.Query +import androidx.room.Upsert + +@Dao +interface RemoteKeyDao { + @Upsert + suspend fun upsert(remoteKey: RemoteKeyEntity) + + @Query("SELECT * FROM RemoteKeyEntity WHERE accountId = :accountId AND timelineId = :timelineId AND kind = :kind") + suspend fun remoteKeyForKind(accountId: Long, timelineId: String, kind: RemoteKeyKind): RemoteKeyEntity? + + // TODO: This can be marked suspend when AccountManager.logActiveAccountOut() has a coroutine scope + @Query("DELETE FROM RemoteKeyEntity WHERE accountId = :accountId") + fun delete(accountId: Long) +} diff --git a/app/src/main/java/com/keylesspalace/tusky/db/RemoteKeyEntity.kt b/app/src/main/java/com/keylesspalace/tusky/db/RemoteKeyEntity.kt new file mode 100644 index 0000000000..f84ea8b022 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/db/RemoteKeyEntity.kt @@ -0,0 +1,55 @@ +/* + * 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.db + +import androidx.room.Entity + +enum class RemoteKeyKind { + /** Key to load the next (chronologically oldest) page of data for this timeline */ + NEXT, + + /** Key to load the previous (chronologically newer) page of data for this timeline */ + PREV +} + +/** + * The next and previous keys for the given timeline. + */ +@Entity( + primaryKeys = ["accountId", "timelineId", "kind"] +) +data class RemoteKeyEntity( + /** User account these keys relate to. */ + val accountId: Long, + /** + * Identifier for the timeline these keys relate to. + * + * At the moment there is only one valid value here, "home", as that + * is the only timeline that is cached. As more timelines become cacheable + * this will need to be expanded. + * + * This also needs to be extensible in the future to cover the case where + * the user might have multiple timelines from the same base timeline, but + * with different configurations. E.g., two home timelines, one with boosts + * and replies turned off, and one with boosts and replies turned on. + */ + val timelineId: String, + val kind: RemoteKeyKind, + val key: String? = null, +) + diff --git a/app/src/main/java/com/keylesspalace/tusky/db/TimelineDao.kt b/app/src/main/java/com/keylesspalace/tusky/db/TimelineDao.kt index a4d4e8ce23..376acbcba0 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/TimelineDao.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/TimelineDao.kt @@ -206,12 +206,6 @@ AND timelineUserId = :accountId @Query("UPDATE TimelineStatusEntity SET filtered = NULL WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId = :statusId)") abstract suspend fun clearWarning(accountId: Long, statusId: String): Int - @Query("SELECT serverId FROM TimelineStatusEntity WHERE timelineUserId = :accountId ORDER BY LENGTH(serverId) DESC, serverId DESC LIMIT 1") - abstract suspend fun getTopId(accountId: Long): String? - - @Query("SELECT serverId FROM TimelineStatusEntity WHERE timelineUserId = :accountId ORDER BY LENGTH(serverId) DESC, serverId LIMIT 1") - abstract suspend fun getBottomId(accountId: Long): String? - /** * Returns the id directly above [serverId], or null if [serverId] is the id of the top status */ From 54c5058c6a6e7bb7421ad81587880f9bf6762405 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Wed, 5 Jul 2023 23:46:04 +0200 Subject: [PATCH 117/156] Lint --- .../tusky/adapter/StatusBaseViewHolder.java | 3 ++- .../timeline/CachedTimelineRepository.kt | 15 +++++++++++++++ .../viewmodel/CachedTimelineRemoteMediator.kt | 14 ++++++++++---- .../com/keylesspalace/tusky/db/RemoteKeyEntity.kt | 3 +-- .../com/keylesspalace/tusky/db/TimelineDao.kt | 15 +++++++++++++++ 5 files changed, 43 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java index 5be36bec08..724ba735c0 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java @@ -761,7 +761,8 @@ public void setupWithStatus(@NonNull StatusViewData status, @Nullable Object payloads) { if (payloads == null) { Status actionable = status.getActionable(); - setDisplayName(actionable.getAccount().getName(), actionable.getAccount().getEmojis(), statusDisplayOptions); + //setDisplayName(actionable.getAccount().getName(), actionable.getAccount().getEmojis(), statusDisplayOptions); + setDisplayName(status.getId(), actionable.getAccount().getEmojis(), statusDisplayOptions); setUsername(status.getUsername()); setMetaData(status, statusDisplayOptions, listener); setIsReply(actionable.getInReplyToId() != null); diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/CachedTimelineRepository.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/CachedTimelineRepository.kt index 09043f6490..1e8c639ae8 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/CachedTimelineRepository.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/CachedTimelineRepository.kt @@ -71,8 +71,23 @@ class CachedTimelineRepository @Inject constructor( } } + val row = initialKey?.let { key -> + // Room is row-keyed (by Int), not item-keyed, so the status ID string that was + // passed as `initialKey` won't work. + // + // Instead, get all the status IDs for this account, in timeline order, and find the + // row index that contains the status. The row index is the correct initialKey. + accountManager.activeAccount?.let { account -> + appDatabase.timelineDao().getStatusRowNumber(account.id) + .indexOfFirst { it == key }.takeIf { it != -1 } + } + } + + Log.d(TAG, "initialKey: $initialKey is row: $row") + return Pager( config = PagingConfig(pageSize = pageSize), + initialKey = row, remoteMediator = CachedTimelineRemoteMediator(accountManager, mastodonApi, appDatabase, gson), pagingSourceFactory = factory!! ).flow diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt index f194b746f9..1152d745f8 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt @@ -61,7 +61,14 @@ class CachedTimelineRemoteMediator( return try { val response = when (loadType) { LoadType.REFRESH -> { - api.homeTimeline(limit = state.config.pageSize) + val rke = db.withTransaction { + remoteKeyDao.remoteKeyForKind( + activeAccount.id, + TIMELINE_ID, + RemoteKeyKind.PREV + ) + } + api.homeTimeline(minId = rke?.key, limit = state.config.pageSize) } LoadType.APPEND -> { val rke = db.withTransaction { @@ -81,7 +88,7 @@ class CachedTimelineRemoteMediator( TIMELINE_ID, RemoteKeyKind.PREV ) - }?: return MediatorResult.Success(endOfPaginationReached = true) + } ?: return MediatorResult.Success(endOfPaginationReached = true) Log.d(TAG, "Loading from remoteKey: $rke") api.homeTimeline(minId = rke.key, limit = state.config.pageSize) } @@ -94,8 +101,7 @@ class CachedTimelineRemoteMediator( Log.d(TAG, "${statuses.size} - # statuses loaded") if (statuses.isNotEmpty()) { - Log.d(TAG, "${statuses.first().id} - first ID") - Log.d(TAG, "${statuses.last().id} - last ID") + Log.d(TAG, " ${statuses.first().id}..${statuses.last().id}") } db.withTransaction { diff --git a/app/src/main/java/com/keylesspalace/tusky/db/RemoteKeyEntity.kt b/app/src/main/java/com/keylesspalace/tusky/db/RemoteKeyEntity.kt index f84ea8b022..2c35d13390 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/RemoteKeyEntity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/RemoteKeyEntity.kt @@ -50,6 +50,5 @@ data class RemoteKeyEntity( */ val timelineId: String, val kind: RemoteKeyKind, - val key: String? = null, + val key: String? = null ) - diff --git a/app/src/main/java/com/keylesspalace/tusky/db/TimelineDao.kt b/app/src/main/java/com/keylesspalace/tusky/db/TimelineDao.kt index 376acbcba0..80d8ffd30c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/TimelineDao.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/TimelineDao.kt @@ -53,6 +53,21 @@ ORDER BY LENGTH(s.serverId) DESC, s.serverId DESC""" ) abstract fun getStatuses(account: Long): PagingSource + /** + * All statuses for [account] in timeline ID. Used to find the correct initialKey to restore + * the user's reading position. + * + * @see [com.keylesspalace.tusky.components.timeline.viewmodel.CachedTimelineViewModel.statuses] + */ + @Query( + """ +SELECT serverId + FROM TimelineStatusEntity + WHERE timelineUserId = :account + ORDER BY LENGTH(serverId) DESC, serverId DESC""" + ) + abstract fun getStatusRowNumber(account: Long): List + @Query( """ SELECT s.serverId, s.url, s.timelineUserId, From 1a86e41672c41318d0c65f090eccb95edf98aba3 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Wed, 5 Jul 2023 23:46:30 +0200 Subject: [PATCH 118/156] Restore the reading position for the cached home timeline --- .../components/timeline/TimelineFragment.kt | 27 +++++++++++++++++++ .../viewmodel/CachedTimelineViewModel.kt | 4 +++ .../timeline/viewmodel/TimelineViewModel.kt | 8 +++++- 3 files changed, 38 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt index 020eef458c..26bfbe6f80 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt @@ -82,6 +82,7 @@ import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.take import kotlinx.coroutines.launch import javax.inject.Inject import kotlin.time.Duration.Companion.seconds @@ -124,6 +125,9 @@ class TimelineFragment : private var isSwipeToRefreshEnabled = true + /** True if the reading position should be restored when new data is submitted to the adapter */ + private var shouldRestoreReadingPosition = false + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -131,6 +135,8 @@ class TimelineFragment : timelineKind = arguments.getParcelable(KIND_ARG)!! + shouldRestoreReadingPosition = timelineKind == TimelineKind.Home + viewModel.init(timelineKind) isSwipeToRefreshEnabled = arguments.getBoolean(ARG_ENABLE_SWIPE_TO_REFRESH, true) @@ -319,6 +325,27 @@ class TimelineFragment : } } + // Restore the user's reading position, if appropriate. + // Collect the first page submitted to the adapter, which will be the Refresh. + // Refresh. This should contain a status with an ID that matches the reading + // position. Find that status and scroll to it. + launch { + if (shouldRestoreReadingPosition) { + adapter.onPagesUpdatedFlow.take(1).collect() + Log.d(TAG, "Page updated, should restore reading position") + adapter.snapshot() + .indexOfFirst { it?.id == viewModel.readingPosition } + .takeIf { it != -1 } + ?.let { pos -> + binding.recyclerView.post { + getView() ?: return@post + binding.recyclerView.scrollToPosition(pos) + } + } + shouldRestoreReadingPosition = false + } + } + // Scroll the list down if a refresh has completely finished. A refresh is // finished when both the initial refresh is complete and any prepends have // finished (so that DiffUtil has had a chance to process the data). See diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt index 7672fc4c8d..57353a4783 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt @@ -72,6 +72,10 @@ class CachedTimelineViewModel @Inject constructor( override lateinit var statuses: Flow> + init { + readingPosition = activeAccount.lastVisibleHomeTimelineStatusId + } + @OptIn(ExperimentalCoroutinesApi::class) override fun init(timelineKind: TimelineKind) { super.init(timelineKind) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt index 4ca431922c..b79801235d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt @@ -302,7 +302,13 @@ abstract class TimelineViewModel( private var filterRemoveReplies = false private var filterRemoveReblogs = false - val activeAccount = accountManager.activeAccount!! + protected val activeAccount = accountManager.activeAccount!! + + /** The ID of the status to which the user's reading position should be restored */ + // Not part of the UiState as it's only used once in the lifespan of the fragment. + // Subclasses should set this if they support restoring the reading position. + open var readingPosition: String? = null + protected set init { viewModelScope.launch { From 5c07689af241b0c02b0469ac7689438916c24d27 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Thu, 6 Jul 2023 15:52:10 +0200 Subject: [PATCH 119/156] Don't overwrite next/prev keys with null if an empty page is returned --- .../viewmodel/CachedTimelineRemoteMediator.kt | 36 +++++++++++-------- 1 file changed, 22 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt index 1152d745f8..d5977f79e7 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt @@ -125,22 +125,30 @@ class CachedTimelineRemoteMediator( ) ) } - LoadType.PREPEND -> remoteKeyDao.upsert( - RemoteKeyEntity( - activeAccount.id, - TIMELINE_ID, - RemoteKeyKind.PREV, - links.prev + // May be null if there are no statuses, only set if non-null, + // https://github.com/mastodon/mastodon/issues/25760 + LoadType.PREPEND -> links.prev?.let { prev -> + remoteKeyDao.upsert( + RemoteKeyEntity( + activeAccount.id, + TIMELINE_ID, + RemoteKeyKind.PREV, + prev + ) ) - ) - LoadType.APPEND -> remoteKeyDao.upsert( - RemoteKeyEntity( - activeAccount.id, - TIMELINE_ID, - RemoteKeyKind.NEXT, - links.next + } + // May be null if there are no statuses, only set if non-null, + // https://github.com/mastodon/mastodon/issues/25760 + LoadType.APPEND -> links.next?.let { next -> + remoteKeyDao.upsert( + RemoteKeyEntity( + activeAccount.id, + TIMELINE_ID, + RemoteKeyKind.NEXT, + next + ) ) - ) + } } replaceStatusRange(statuses, state) } From 86698e8a5869700c559bbdafcd06374a2a60e2cc Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Thu, 6 Jul 2023 16:29:56 +0200 Subject: [PATCH 120/156] Update tests --- .../viewmodel/CachedTimelineRemoteMediator.kt | 5 ++- .../keylesspalace/tusky/db/RemoteKeyDao.kt | 6 ++- .../CachedTimelineRemoteMediatorTest.kt | 13 +++++- .../keylesspalace/tusky/db/TimelineDaoTest.kt | 40 ------------------- 4 files changed, 19 insertions(+), 45 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt index d5977f79e7..05cb690523 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt @@ -17,6 +17,7 @@ package com.keylesspalace.tusky.components.timeline.viewmodel import android.util.Log +import androidx.annotation.VisibleForTesting import androidx.paging.ExperimentalPagingApi import androidx.paging.LoadType import androidx.paging.PagingState @@ -210,6 +211,8 @@ class CachedTimelineRemoteMediator( companion object { private const val TAG = "CachedTimelineRemoteMediator" - private const val TIMELINE_ID = "HOME" + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + const val TIMELINE_ID = "HOME" } } diff --git a/app/src/main/java/com/keylesspalace/tusky/db/RemoteKeyDao.kt b/app/src/main/java/com/keylesspalace/tusky/db/RemoteKeyDao.kt index 394e53c280..95578c62f9 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/RemoteKeyDao.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/RemoteKeyDao.kt @@ -18,12 +18,14 @@ package com.keylesspalace.tusky.db import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy import androidx.room.Query -import androidx.room.Upsert @Dao interface RemoteKeyDao { - @Upsert + // TODO(https://issuetracker.google.com/issues/243039555), switch to @Upsert + @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsert(remoteKey: RemoteKeyEntity) @Query("SELECT * FROM RemoteKeyEntity WHERE accountId = :accountId AND timelineId = :timelineId AND kind = :kind") diff --git a/app/src/test/java/com/keylesspalace/tusky/components/timeline/CachedTimelineRemoteMediatorTest.kt b/app/src/test/java/com/keylesspalace/tusky/components/timeline/CachedTimelineRemoteMediatorTest.kt index 03bcbea786..06ed320857 100644 --- a/app/src/test/java/com/keylesspalace/tusky/components/timeline/CachedTimelineRemoteMediatorTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/components/timeline/CachedTimelineRemoteMediatorTest.kt @@ -13,14 +13,18 @@ import androidx.test.platform.app.InstrumentationRegistry import com.google.common.truth.Truth.assertThat import com.google.gson.Gson import com.keylesspalace.tusky.components.timeline.viewmodel.CachedTimelineRemoteMediator +import com.keylesspalace.tusky.components.timeline.viewmodel.CachedTimelineRemoteMediator.Companion.TIMELINE_ID import com.keylesspalace.tusky.db.AccountEntity import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.db.Converters +import com.keylesspalace.tusky.db.RemoteKeyEntity +import com.keylesspalace.tusky.db.RemoteKeyKind import com.keylesspalace.tusky.db.TimelineStatusWithAccount import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest +import okhttp3.Headers import okhttp3.ResponseBody.Companion.toResponseBody import org.junit.After import org.junit.Assert.assertEquals @@ -354,7 +358,7 @@ class CachedTimelineRemoteMediatorTest { @Test @ExperimentalPagingApi - fun `should append statuses`() { + fun `should append statuses`() = runTest { val statusesAlreadyInDb = listOf( mockStatusEntityWithAccount("8"), mockStatusEntityWithAccount("7"), @@ -362,6 +366,8 @@ class CachedTimelineRemoteMediatorTest { ) db.insert(statusesAlreadyInDb) + db.remoteKeyDao().upsert(RemoteKeyEntity(1, TIMELINE_ID, RemoteKeyKind.PREV, "8")) + db.remoteKeyDao().upsert(RemoteKeyEntity(1, TIMELINE_ID, RemoteKeyKind.NEXT, "5")) val remoteMediator = CachedTimelineRemoteMediator( accountManager = accountManager, @@ -371,7 +377,10 @@ class CachedTimelineRemoteMediatorTest { mockStatus("3"), mockStatus("2"), mockStatus("1") - ) + ), + Headers.Builder().add( + "Link: ; rel=\"prev\", ; rel=\"next\"" + ).build() ) }, db = db, diff --git a/app/src/test/java/com/keylesspalace/tusky/db/TimelineDaoTest.kt b/app/src/test/java/com/keylesspalace/tusky/db/TimelineDaoTest.kt index 71d5cc9e49..6975a30a0f 100644 --- a/app/src/test/java/com/keylesspalace/tusky/db/TimelineDaoTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/db/TimelineDaoTest.kt @@ -9,7 +9,6 @@ import com.keylesspalace.tusky.entity.Status import kotlinx.coroutines.runBlocking import org.junit.After import org.junit.Assert.assertEquals -import org.junit.Assert.assertNull import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -271,45 +270,6 @@ class TimelineDaoTest { assertStatuses(listOf(statusWithRedDomainOtherAccount, statusWithBlueDomainOtherAccount), statusesAccount2) } - @Test - fun `should return null as topId when db is empty`() = runBlocking { - assertNull(timelineDao.getTopId(1)) - } - - @Test - fun `should return correct topId`() = runBlocking { - val statusData = listOf( - makeStatus( - statusId = 4, - accountId = 1, - domain = "mastodon.test", - authorServerId = "1" - ), - makeStatus( - statusId = 33, - accountId = 1, - domain = "mastodon.test", - authorServerId = "2" - ), - makeStatus( - statusId = 22, - accountId = 1, - domain = "mastodon.test", - authorServerId = "2" - ) - ) - - for ((status, author, reblogAuthor) in statusData) { - timelineDao.insertAccount(author) - reblogAuthor?.let { - timelineDao.insertAccount(it) - } - timelineDao.insertStatus(status) - } - - assertEquals("33", timelineDao.getTopId(1)) - } - @Test fun `preview card survives roundtrip`() = runBlocking { val setOne = makeStatus(statusId = 3, cardUrl = "https://foo.bar") From d662dc7b99ad76172329a9439f8d45699e4f9173 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Thu, 6 Jul 2023 19:25:32 +0200 Subject: [PATCH 121/156] Fix lint issues in AppDatabase.java --- app/lint-baseline.xml | 278 +++++------------- .../keylesspalace/tusky/db/AppDatabase.java | 13 +- 2 files changed, 80 insertions(+), 211 deletions(-) diff --git a/app/lint-baseline.xml b/app/lint-baseline.xml index bf4a0466a7..056acea995 100644 --- a/app/lint-baseline.xml +++ b/app/lint-baseline.xml @@ -839,7 +839,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -850,7 +850,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -861,7 +861,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -2031,17 +2031,6 @@ column="5"/> - - - - - - - - - - - - - - - - - - - - - - - - @@ -2500,7 +2434,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -2511,7 +2445,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -2522,7 +2456,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -2533,7 +2467,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~"> @@ -2544,7 +2478,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -2555,7 +2489,7 @@ errorLine2=" ~~~~~~~~~~~~"> @@ -2566,7 +2500,7 @@ errorLine2=" ~~~~~~~~~~~~~~"> @@ -2577,7 +2511,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -2588,7 +2522,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~"> @@ -2599,7 +2533,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~"> @@ -2610,7 +2544,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -2621,7 +2555,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~"> @@ -2632,7 +2566,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -2643,7 +2577,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -2654,7 +2588,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~"> @@ -2665,7 +2599,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -2676,7 +2610,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -2687,7 +2621,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -2698,7 +2632,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -2709,7 +2643,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -2720,7 +2654,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -2731,7 +2665,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -2742,7 +2676,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -2962,7 +2896,7 @@ errorLine2=" ~~~~~~~~~"> @@ -2973,7 +2907,7 @@ errorLine2=" ~~~~~~~~~"> @@ -2984,7 +2918,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~"> @@ -2995,7 +2929,7 @@ errorLine2=" ~~~~~~~~~~~~~"> @@ -3006,7 +2940,7 @@ errorLine2=" ~~~~~~~~~~~~~"> @@ -3017,7 +2951,7 @@ errorLine2=" ~~~~~~~~~~~~~"> @@ -3028,7 +2962,7 @@ errorLine2=" ~~~~~~"> @@ -3039,7 +2973,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~"> @@ -3050,7 +2984,7 @@ errorLine2=" ~~~~~~~~~~~"> @@ -3061,7 +2995,7 @@ errorLine2=" ~~~~~~~~~~~~~"> @@ -3072,7 +3006,7 @@ errorLine2=" ~~~~~~~~~~~~~"> @@ -3083,7 +3017,7 @@ errorLine2=" ~~~~~~~~~~~~~"> @@ -3094,7 +3028,7 @@ errorLine2=" ~~~~~~"> @@ -3204,7 +3138,7 @@ errorLine2=" ~~~~~~~"> @@ -3215,7 +3149,7 @@ errorLine2=" ~~~~~~~"> @@ -3270,7 +3204,7 @@ errorLine2=" ~~~~~~~"> @@ -3281,7 +3215,7 @@ errorLine2=" ~~~~~~~"> @@ -3292,7 +3226,7 @@ errorLine2=" ~~~~~~~"> @@ -3303,7 +3237,7 @@ errorLine2=" ~~~~~~~"> @@ -3314,7 +3248,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -3325,7 +3259,7 @@ errorLine2=" ~~~~~~~~~~~~~~"> @@ -3589,7 +3523,7 @@ errorLine2=" ~~~~~~~~~"> @@ -3600,7 +3534,7 @@ errorLine2=" ~~~~~~~~~"> @@ -4491,7 +4425,7 @@ errorLine2=" ~~~~~~~~~"> @@ -4502,7 +4436,7 @@ errorLine2=" ~~~~~~~~"> @@ -4513,7 +4447,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -4524,7 +4458,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -4535,7 +4469,7 @@ errorLine2=" ~~~~~~~"> @@ -4546,7 +4480,7 @@ errorLine2=" ~~~~~~~"> @@ -4557,7 +4491,7 @@ errorLine2=" ~~~~~~~"> @@ -4568,7 +4502,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -5096,7 +5030,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -5620,7 +5554,7 @@ errorLine2=" ~~~~~~~~"> @@ -5631,7 +5565,7 @@ errorLine2=" ~~~~~~~~"> @@ -6137,7 +6071,7 @@ errorLine2=" ~~~~~~~~"> @@ -6445,7 +6379,7 @@ errorLine2=" ~~~~~~~~~"> @@ -6500,7 +6434,7 @@ errorLine2=" ~~~~~~~~~"> @@ -6746,72 +6680,6 @@ column="63"/> - - - - - - - - - - - - - - - - - - - - - - - - @@ -6830,7 +6698,7 @@ errorLine2=" ~~~~~~"> @@ -6841,7 +6709,7 @@ errorLine2=" ~~~~~~~~"> @@ -6852,7 +6720,7 @@ errorLine2=" ~~~~"> @@ -6863,7 +6731,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -6874,7 +6742,7 @@ errorLine2=" ~~~~~~~~~~~~"> @@ -6885,7 +6753,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -6896,7 +6764,7 @@ errorLine2=" ~~~~~~~~"> @@ -6907,7 +6775,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~"> diff --git a/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java b/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java index 86ea7664b7..a512735450 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java +++ b/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java @@ -16,6 +16,7 @@ package com.keylesspalace.tusky.db; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.room.AutoMigration; import androidx.room.Database; import androidx.room.DeleteColumn; @@ -50,11 +51,11 @@ ) public abstract class AppDatabase extends RoomDatabase { - public abstract AccountDao accountDao(); - public abstract InstanceDao instanceDao(); - public abstract ConversationsDao conversationDao(); - public abstract TimelineDao timelineDao(); - public abstract DraftDao draftDao(); + @NonNull public abstract AccountDao accountDao(); + @NonNull public abstract InstanceDao instanceDao(); + @NonNull public abstract ConversationsDao conversationDao(); + @NonNull public abstract TimelineDao timelineDao(); + @NonNull public abstract DraftDao draftDao(); public static final Migration MIGRATION_2_3 = new Migration(2, 3) { @Override @@ -386,7 +387,7 @@ public static class Migration25_26 extends Migration { private final File oldDraftDirectory; - public Migration25_26(File oldDraftDirectory) { + public Migration25_26(@Nullable File oldDraftDirectory) { super(25, 26); this.oldDraftDirectory = oldDraftDirectory; } From 6f8183080cd35a7680831dda244b266798c73692 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Sun, 9 Jul 2023 13:17:18 +0200 Subject: [PATCH 122/156] Rename readingPosition to readingPositionId --- .../tusky/components/timeline/TimelineFragment.kt | 6 +++--- .../timeline/viewmodel/CachedTimelineViewModel.kt | 2 +- .../components/timeline/viewmodel/TimelineViewModel.kt | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt index 26bfbe6f80..12734fe376 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt @@ -327,14 +327,14 @@ class TimelineFragment : // Restore the user's reading position, if appropriate. // Collect the first page submitted to the adapter, which will be the Refresh. - // Refresh. This should contain a status with an ID that matches the reading - // position. Find that status and scroll to it. + // This should contain a status with an ID that matches the reading position. + // Find that status and scroll to it. launch { if (shouldRestoreReadingPosition) { adapter.onPagesUpdatedFlow.take(1).collect() Log.d(TAG, "Page updated, should restore reading position") adapter.snapshot() - .indexOfFirst { it?.id == viewModel.readingPosition } + .indexOfFirst { it?.id == viewModel.readingPositionId } .takeIf { it != -1 } ?.let { pos -> binding.recyclerView.post { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt index 57353a4783..f603316346 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt @@ -73,7 +73,7 @@ class CachedTimelineViewModel @Inject constructor( override lateinit var statuses: Flow> init { - readingPosition = activeAccount.lastVisibleHomeTimelineStatusId + readingPositionId = activeAccount.lastVisibleHomeTimelineStatusId } @OptIn(ExperimentalCoroutinesApi::class) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt index b79801235d..d0e253348b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt @@ -307,7 +307,7 @@ abstract class TimelineViewModel( /** The ID of the status to which the user's reading position should be restored */ // Not part of the UiState as it's only used once in the lifespan of the fragment. // Subclasses should set this if they support restoring the reading position. - open var readingPosition: String? = null + open var readingPositionId: String? = null protected set init { From ef92c1d9fcc66281e62c28066756a2859d45e7d8 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Sun, 9 Jul 2023 13:18:07 +0200 Subject: [PATCH 123/156] Work with a fixed active account, prevent possible data corruption --- .../timeline/CachedTimelineRepository.kt | 27 +++++++++---------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/CachedTimelineRepository.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/CachedTimelineRepository.kt index 1e8c639ae8..6a834ec4f9 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/CachedTimelineRepository.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/CachedTimelineRepository.kt @@ -53,6 +53,8 @@ class CachedTimelineRepository @Inject constructor( ) { private var factory: InvalidatingPagingSourceFactory? = null + private val activeAccount = accountManager.activeAccount + /** @return flow of Mastodon [TimelineStatusWithAccount], loaded in [pageSize] increments */ @OptIn(ExperimentalPagingApi::class) fun getStatusStream( @@ -63,12 +65,7 @@ class CachedTimelineRepository @Inject constructor( Log.d(TAG, "getStatusStream(): key: $initialKey") factory = InvalidatingPagingSourceFactory { - val activeAccount = accountManager.activeAccount - if (activeAccount == null) { - EmptyPagingSource() - } else { - appDatabase.timelineDao().getStatuses(activeAccount.id) - } + activeAccount?.let { appDatabase.timelineDao().getStatuses(it.id) } ?: EmptyPagingSource() } val row = initialKey?.let { key -> @@ -77,7 +74,7 @@ class CachedTimelineRepository @Inject constructor( // // Instead, get all the status IDs for this account, in timeline order, and find the // row index that contains the status. The row index is the correct initialKey. - accountManager.activeAccount?.let { account -> + activeAccount?.let { account -> appDatabase.timelineDao().getStatusRowNumber(account.id) .indexOfFirst { it == key }.takeIf { it != -1 } } @@ -97,7 +94,7 @@ class CachedTimelineRepository @Inject constructor( suspend fun invalidate() { // Invalidating when no statuses have been loaded can cause empty timelines because it // cancels the network load. - if (appDatabase.timelineDao().getStatusCount(accountManager.activeAccount!!.id) < 1) { + if (appDatabase.timelineDao().getStatusCount(activeAccount!!.id) < 1) { return } @@ -107,40 +104,40 @@ class CachedTimelineRepository @Inject constructor( /** Set and store the "expanded" state of the given status, for the active account */ suspend fun setExpanded(expanded: Boolean, statusId: String) = externalScope.launch { appDatabase.timelineDao() - .setExpanded(accountManager.activeAccount!!.id, statusId, expanded) + .setExpanded(activeAccount!!.id, statusId, expanded) }.join() /** Set and store the "content showing" state of the given status, for the active account */ suspend fun setContentShowing(showing: Boolean, statusId: String) = externalScope.launch { appDatabase.timelineDao() - .setContentShowing(accountManager.activeAccount!!.id, statusId, showing) + .setContentShowing(activeAccount!!.id, statusId, showing) }.join() /** Set and store the "content collapsed" ("Show more") state of the given status, for the active account */ suspend fun setContentCollapsed(collapsed: Boolean, statusId: String) = externalScope.launch { appDatabase.timelineDao() - .setContentCollapsed(accountManager.activeAccount!!.id, statusId, collapsed) + .setContentCollapsed(activeAccount!!.id, statusId, collapsed) }.join() /** Remove all statuses authored/boosted by the given account, for the active account */ suspend fun removeAllByAccountId(accountId: String) = externalScope.launch { - appDatabase.timelineDao().removeAllByUser(accountManager.activeAccount!!.id, accountId) + appDatabase.timelineDao().removeAllByUser(activeAccount!!.id, accountId) }.join() /** Remove all statuses from the given instance, for the active account */ suspend fun removeAllByInstance(instance: String) = externalScope.launch { appDatabase.timelineDao() - .deleteAllFromInstance(accountManager.activeAccount!!.id, instance) + .deleteAllFromInstance(activeAccount!!.id, instance) }.join() /** Clear the warning (remove the "filtered" setting) for the given status, for the active account */ suspend fun clearStatusWarning(statusId: String) = externalScope.launch { - appDatabase.timelineDao().clearWarning(accountManager.activeAccount!!.id, statusId) + appDatabase.timelineDao().clearWarning(activeAccount!!.id, statusId) }.join() /** Remove all statuses and invalidate the pager, for the active account */ suspend fun clearAndReload() = externalScope.launch { - appDatabase.timelineDao().removeAll(accountManager.activeAccount!!.id) + appDatabase.timelineDao().removeAll(activeAccount!!.id) factory?.invalidate() }.join() From 50c484ff0b805df9aead1b3f339b7ca03a543f48 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Mon, 10 Jul 2023 15:48:34 +0200 Subject: [PATCH 124/156] Improve reload from newest in cached timelines, and other clean ups - Add presentation state - Dismiss snackbars when refreshing or retrying errors - Save the visible ID when tapping on the tab icon --- .../timeline/CachedTimelineRepository.kt | 14 ++- .../components/timeline/TimelineFragment.kt | 75 ++++++++++------ .../viewmodel/CachedTimelineRemoteMediator.kt | 23 +++-- .../viewmodel/CachedTimelineViewModel.kt | 10 ++- .../NetworkTimelineRemoteMediator.kt | 12 +-- .../viewmodel/NetworkTimelineViewModel.kt | 8 +- .../timeline/viewmodel/TimelineViewModel.kt | 33 +++++-- .../util/CombinedLoadStatesExtensions.kt | 89 +++++++++++++++++++ app/src/main/res/values-nb-rNO/strings.xml | 9 +- app/src/main/res/values-sv/strings.xml | 3 +- .../CachedTimelineRemoteMediatorTest.kt | 13 +++ 11 files changed, 230 insertions(+), 59 deletions(-) create mode 100644 app/src/main/java/com/keylesspalace/tusky/util/CombinedLoadStatesExtensions.kt diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/CachedTimelineRepository.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/CachedTimelineRepository.kt index 6a834ec4f9..9a751d332d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/CachedTimelineRepository.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/CachedTimelineRepository.kt @@ -85,7 +85,13 @@ class CachedTimelineRepository @Inject constructor( return Pager( config = PagingConfig(pageSize = pageSize), initialKey = row, - remoteMediator = CachedTimelineRemoteMediator(accountManager, mastodonApi, appDatabase, gson), + remoteMediator = CachedTimelineRemoteMediator( + mastodonApi, + accountManager, + factory!!, + appDatabase, + gson + ), pagingSourceFactory = factory!! ).flow } @@ -141,6 +147,12 @@ class CachedTimelineRepository @Inject constructor( factory?.invalidate() }.join() + suspend fun clearAndReloadFromNewest() = externalScope.launch { + appDatabase.timelineDao().removeAll(activeAccount!!.id) + appDatabase.remoteKeyDao().delete(activeAccount.id) + invalidate() + } + companion object { private const val TAG = "CachedTimelineRepository" private const val PAGE_SIZE = 30 diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt index 12734fe376..8936d60fbe 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt @@ -64,12 +64,14 @@ import com.keylesspalace.tusky.interfaces.RefreshableFragment import com.keylesspalace.tusky.interfaces.ReselectableFragment import com.keylesspalace.tusky.interfaces.StatusActionListener import com.keylesspalace.tusky.util.ListStatusAccessibilityDelegate +import com.keylesspalace.tusky.util.PresentationState import com.keylesspalace.tusky.util.getDrawableRes import com.keylesspalace.tusky.util.getErrorString import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.unsafeLazy import com.keylesspalace.tusky.util.viewBinding +import com.keylesspalace.tusky.util.withPresentationState import com.keylesspalace.tusky.viewdata.AttachmentViewData import com.keylesspalace.tusky.viewdata.StatusViewData import com.mikepenz.iconics.IconicsDrawable @@ -242,12 +244,10 @@ class TimelineFragment : error.action?.let { action -> if (action !is StatusAction) return@let - val position = adapter.snapshot().indexOfFirst { - it?.id == action.statusViewData.id - } - if (position != RecyclerView.NO_POSITION) { - adapter.notifyItemChanged(position) - } + adapter.snapshot() + .indexOfFirst { it?.id == action.statusViewData.id } + .takeIf { it != RecyclerView.NO_POSITION } + ?.let { adapter.notifyItemChanged(it) } } } } @@ -421,21 +421,28 @@ class TimelineFragment : // Update the UI from the combined load state adapter.loadStateFlow - .collect { loadState -> -// Log.d(TAG, "loadState: $loadState") + .withPresentationState() + .collect { (loadState, presentationState) -> + Log.d(TAG, "loadState: $loadState") + Log.d(TAG, "presentationState: $presentationState") Log.d(TAG, " adapter.itemCount: ${adapter.itemCount}") - Log.d(TAG, " refresh?: ${loadState.refresh}") - Log.d(TAG, " source.refresh?: ${loadState.source.refresh}") - Log.d(TAG, " mediator.refresh?: ${loadState.mediator?.refresh}") val listIsEmpty = loadState.refresh is LoadState.NotLoading && adapter.itemCount == 0 + val initialLoadOrRefresh = presentationState == PresentationState.REMOTE_LOADING || presentationState == PresentationState.SOURCE_LOADING + + binding.progressBar.hide() + binding.statusView.hide() + binding.recyclerView.isVisible = !listIsEmpty - binding.statusView.isVisible = listIsEmpty - binding.recyclerView.isVisible = adapter.itemCount != 0 || loadState.source.refresh is LoadState.NotLoading || loadState.mediator?.refresh is LoadState.NotLoading - binding.progressBar.isVisible = loadState.mediator?.refresh is LoadState.Loading && listIsEmpty - binding.swipeRefreshLayout.isRefreshing = loadState.mediator?.refresh is LoadState.Loading + Log.d(TAG, " initialLoadOrRefresh: $initialLoadOrRefresh") - if (listIsEmpty) { + binding.progressBar.isVisible = presentationState != PresentationState.PRESENTED && listIsEmpty + if (binding.swipeRefreshLayout.isRefreshing && (presentationState == PresentationState.PRESENTED || loadState.refresh is LoadState.Error)) { + binding.swipeRefreshLayout.isRefreshing = false + } + + if (listIsEmpty && presentationState == PresentationState.PRESENTED) { + Log.d(TAG, "Showing empty state") binding.statusView.setup( R.drawable.elephant_friend_empty, R.string.message_empty @@ -444,15 +451,17 @@ class TimelineFragment : binding.statusView.showHelp(R.string.help_empty_home) } return@collect + } else { + Log.d(TAG, "Not showing empty state") } - if (loadState.mediator?.refresh is LoadState.Error) { - val message = (loadState.mediator?.refresh as LoadState.Error).error.getErrorString(requireContext()) + if (loadState.refresh is LoadState.Error) { + val message = (loadState.refresh as LoadState.Error).error.getErrorString(requireContext()) // Show errors as a snackbar if there is existing content to show // (either cached, or in the adapter), or as a full screen error // otherwise. - if (viewModel is CachedTimelineViewModel || adapter.itemCount > 0) { + if (adapter.itemCount > 0) { snackbar = Snackbar.make( (activity as ActionButtonActivity).actionButton ?: binding.root, message, @@ -463,7 +472,10 @@ class TimelineFragment : snackbar!!.show() } else { val drawableRes = (loadState.refresh as LoadState.Error).error.getDrawableRes() - binding.statusView.setup(drawableRes, message) { adapter.retry() } + binding.statusView.setup(drawableRes, message) { + snackbar?.dismiss() + adapter.retry() + } binding.statusView.show() } } @@ -505,16 +517,21 @@ class TimelineFragment : } /** - * Save the ID of the last visible status in the list + * Save the ID of status at [position] in the adapter. If [position] is + * [RecyclerView.NO_POSITION] then the ID of the last completely visible status is used. */ - fun saveVisibleId() = layoutManager - .findLastCompletelyVisibleItemPosition() - .takeIf { it != RecyclerView.NO_POSITION } - ?.let { position -> - adapter.snapshot().getOrNull(position)?.id?.let { statusId -> - viewModel.accept(InfallibleUiAction.SaveVisibleId(visibleId = statusId)) + fun saveVisibleId(position: Int = RecyclerView.NO_POSITION) { + if (position == RecyclerView.NO_POSITION) { + layoutManager.findLastCompletelyVisibleItemPosition() + } else { + position + }.takeIf { it != RecyclerView.NO_POSITION } + ?.let { pos -> + adapter.snapshot().getOrNull(pos)?.id?.let { statusId -> + viewModel.accept(InfallibleUiAction.SaveVisibleId(visibleId = statusId)) + } } - } + } private fun setupSwipeRefreshLayout() { binding.swipeRefreshLayout.isEnabled = isSwipeToRefreshEnabled @@ -548,6 +565,7 @@ class TimelineFragment : override fun onRefresh() { binding.statusView.hide() + snackbar?.dismiss() adapter.refresh() } @@ -720,6 +738,7 @@ class TimelineFragment : if (isAdded) { binding.recyclerView.layoutManager?.scrollToPosition(0) binding.recyclerView.stopScroll() + saveVisibleId(0) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt index 05cb690523..26504e8bc7 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt @@ -19,6 +19,7 @@ package com.keylesspalace.tusky.components.timeline.viewmodel import android.util.Log import androidx.annotation.VisibleForTesting import androidx.paging.ExperimentalPagingApi +import androidx.paging.InvalidatingPagingSourceFactory import androidx.paging.LoadType import androidx.paging.PagingState import androidx.paging.RemoteMediator @@ -39,8 +40,9 @@ import java.io.IOException @OptIn(ExperimentalPagingApi::class) class CachedTimelineRemoteMediator( - accountManager: AccountManager, private val api: MastodonApi, + accountManager: AccountManager, + private val factory: InvalidatingPagingSourceFactory, private val db: AppDatabase, private val gson: Gson ) : RemoteMediator() { @@ -69,6 +71,7 @@ class CachedTimelineRemoteMediator( RemoteKeyKind.PREV ) } + Log.d(TAG, "Loading from remoteKey: $rke") api.homeTimeline(minId = rke?.key, limit = state.config.pageSize) } LoadType.APPEND -> { @@ -101,12 +104,18 @@ class CachedTimelineRemoteMediator( } Log.d(TAG, "${statuses.size} - # statuses loaded") - if (statuses.isNotEmpty()) { - Log.d(TAG, " ${statuses.first().id}..${statuses.last().id}") + + // This request succeeded with no new data, and pagination ends (unless this is a + // REFRESH, which must always set endOfPaginationReached to false). + if (statuses.isEmpty()) { + factory.invalidate() + return MediatorResult.Success(endOfPaginationReached = loadType != LoadType.REFRESH) } + Log.d(TAG, " ${statuses.first().id}..${statuses.last().id}") + + val links = Links.from(response.headers()["link"]) db.withTransaction { - val links = Links.from(response.headers()["link"]) when (loadType) { LoadType.REFRESH -> { remoteKeyDao.upsert( @@ -126,7 +135,7 @@ class CachedTimelineRemoteMediator( ) ) } - // May be null if there are no statuses, only set if non-null, + // links.prev may be null if there are no statuses, only set if non-null, // https://github.com/mastodon/mastodon/issues/25760 LoadType.PREPEND -> links.prev?.let { prev -> remoteKeyDao.upsert( @@ -138,7 +147,7 @@ class CachedTimelineRemoteMediator( ) ) } - // May be null if there are no statuses, only set if non-null, + // links.next may be null if there are no statuses, only set if non-null, // https://github.com/mastodon/mastodon/issues/25760 LoadType.APPEND -> links.next?.let { next -> remoteKeyDao.upsert( @@ -154,7 +163,7 @@ class CachedTimelineRemoteMediator( replaceStatusRange(statuses, state) } - return MediatorResult.Success(endOfPaginationReached = statuses.isEmpty()) + return MediatorResult.Success(endOfPaginationReached = false) } catch (e: IOException) { MediatorResult.Error(e) } catch (e: HttpException) { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt index f603316346..1957a27962 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt @@ -159,12 +159,20 @@ class CachedTimelineViewModel @Inject constructor( // handled by CacheUpdater } - override fun fullReload() { + override fun reloadKeepingReadingPosition() { + super.reloadKeepingReadingPosition() viewModelScope.launch { repository.clearAndReload() } } + override fun reloadFromNewest() { + super.reloadFromNewest() + viewModelScope.launch { + repository.clearAndReloadFromNewest() + } + } + override suspend fun invalidate() { repository.invalidate() } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt index f75abfb641..b1d6889587 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt @@ -111,13 +111,13 @@ class NetworkTimelineRemoteMediator( Log.d(TAG, " midPage: $page") Log.d(TAG, " nextPage: $nextPage") page = page.merge(prevPage, nextPage) - } - if (BuildConfig.DEBUG && loadType == LoadType.REFRESH) { - // Verify page contains the expected key - state.anchorPosition?.let { state.closestItemToPosition(it) }?.id?.let { itemId -> - page.data.find { it.id == itemId } - ?: throw IllegalStateException("Fetched page with $key, it does not contain $itemId") + if (BuildConfig.DEBUG) { + // Verify page contains the expected key + state.anchorPosition?.let { state.closestItemToPosition(it) }?.id?.let { itemId -> + page.data.find { it.id == itemId } + ?: throw IllegalStateException("Fetched page with $key, it does not contain $itemId") + } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt index f87c3f380b..0323202462 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt @@ -182,12 +182,18 @@ class NetworkTimelineViewModel @Inject constructor( repository.invalidate() } - override fun fullReload() { + override fun reloadKeepingReadingPosition() { + super.reloadKeepingReadingPosition() viewModelScope.launch { repository.reload() } } + override fun reloadFromNewest() { + super.reloadFromNewest() + reloadKeepingReadingPosition() + } + override fun clearWarning(status: StatusViewData) { viewModelScope.launch { repository.updateActionableStatusById(status.actionableId) { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt index d0e253348b..df6496c7f9 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt @@ -17,6 +17,7 @@ package com.keylesspalace.tusky.components.timeline.viewmodel import android.content.SharedPreferences import android.util.Log +import androidx.annotation.CallSuper import androidx.annotation.StringRes import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -421,6 +422,7 @@ abstract class TimelineViewModel( showMediaPreview = accountPreferenceDataStore.getBoolean(PrefKeys.MEDIA_PREVIEW_ENABLED, true) ) + @CallSuper open fun init(timelineKind: TimelineKind) { this.timelineKind = timelineKind @@ -448,8 +450,7 @@ abstract class TimelineViewModel( } } - // Increment `reload` to trigger creation of a new PagingSource. - // Optionally save the home timeline's visible ID. + // Clear the saved visible ID (if necessary), and reload from the newest status. viewModelScope.launch { uiAction .filterIsInstance() @@ -458,7 +459,7 @@ abstract class TimelineViewModel( activeAccount.lastVisibleHomeTimelineStatusId = null accountManager.saveAccount(activeAccount) } - reload.getAndUpdate { it + 1 } + reloadFromNewest() } } @@ -507,7 +508,25 @@ abstract class TimelineViewModel( abstract fun handlePinEvent(pinEvent: PinEvent) - abstract fun fullReload() + /** + * Reload data for this timeline while preserving the user's reading position. + * + * Subclasses should call this, then start loading data. + */ + @CallSuper + open fun reloadKeepingReadingPosition() { + reload.getAndUpdate { it + 1 } + } + + /** + * Load the most recent data for this timeline, ignoring the user's reading position. + * + * Subclasses should call this, then start loading data. + */ + @CallSuper + open fun reloadFromNewest() { + reload.getAndUpdate { it + 1 } + } abstract fun clearWarning(status: StatusViewData) @@ -584,7 +603,7 @@ abstract class TimelineViewModel( val oldRemoveReplies = filterRemoveReplies filterRemoveReplies = timelineKind is TimelineKind.Home && !filter if (oldRemoveReplies != filterRemoveReplies) { - fullReload() + reloadKeepingReadingPosition() } } PrefKeys.TAB_FILTER_HOME_BOOSTS -> { @@ -592,7 +611,7 @@ abstract class TimelineViewModel( val oldRemoveReblogs = filterRemoveReblogs filterRemoveReblogs = timelineKind is TimelineKind.Home && !filter if (oldRemoveReblogs != filterRemoveReblogs) { - fullReload() + reloadKeepingReadingPosition() } } } @@ -604,7 +623,7 @@ abstract class TimelineViewModel( is ReblogEvent -> handleReblogEvent(event) is BookmarkEvent -> handleBookmarkEvent(event) is PinEvent -> handlePinEvent(event) - is MuteConversationEvent -> fullReload() + is MuteConversationEvent -> reloadKeepingReadingPosition() is UnfollowEvent -> { if (timelineKind is TimelineKind.Home) { val id = event.accountId diff --git a/app/src/main/java/com/keylesspalace/tusky/util/CombinedLoadStatesExtensions.kt b/app/src/main/java/com/keylesspalace/tusky/util/CombinedLoadStatesExtensions.kt new file mode 100644 index 0000000000..0a296a17a0 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/CombinedLoadStatesExtensions.kt @@ -0,0 +1,89 @@ +/* + * 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.util + +import android.util.Log +import androidx.paging.CombinedLoadStates +import androidx.paging.LoadState +import com.keylesspalace.tusky.util.PresentationState.INITIAL +import com.keylesspalace.tusky.util.PresentationState.PRESENTED +import com.keylesspalace.tusky.util.PresentationState.REMOTE_LOADING +import com.keylesspalace.tusky.util.PresentationState.SOURCE_LOADING +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.scan + +enum class PresentationState { + /** Initial state, nothing is known about the load state */ + INITIAL, + + /** RemoteMediator is loading the first requested page of results */ + REMOTE_LOADING, + + /** PagingSource is loading the first requested page of results */ + SOURCE_LOADING, + + /** The first request page of results is visible via the adapter */ + PRESENTED +} + +/** + * [CombinedLoadStates] are stateful -- you can't fully interpret the meaning of the state unless + * previous states have been observed. This tracks those states and provides a [PresentationState] + * that describes whether the most refresh has presented the data via the associated adapter. + * + * @return Flow that combines the load state with its associated presentation state + */ +@OptIn(ExperimentalCoroutinesApi::class) +fun Flow.withPresentationState(): Flow> { + val TAG = "WithPresentationState" + + val presentationStateFlow = scan(INITIAL) { state, loadState -> + Log.d(TAG, "state: $state") + Log.d(TAG, "loadState.mediator.refresh: ${loadState.mediator?.refresh}") + Log.d(TAG, "loadState.source.refresh: ${loadState.source.refresh}") + when (state) { + PRESENTED -> when (loadState.mediator?.refresh) { + is LoadState.Loading -> REMOTE_LOADING + else -> state + } + + INITIAL -> when (loadState.mediator?.refresh) { + is LoadState.Loading -> REMOTE_LOADING + else -> state + } + + REMOTE_LOADING -> when (loadState.source.refresh) { + is LoadState.Loading -> SOURCE_LOADING + else -> state + } + + SOURCE_LOADING -> when (loadState.source.refresh) { + is LoadState.NotLoading -> PRESENTED + else -> state + } + } + } + .distinctUntilChanged() + + return this.combine(presentationStateFlow) { loadState, presentationState -> + Pair(loadState, presentationState) + } +} diff --git a/app/src/main/res/values-nb-rNO/strings.xml b/app/src/main/res/values-nb-rNO/strings.xml index c20512abf6..bb0d7d87a6 100644 --- a/app/src/main/res/values-nb-rNO/strings.xml +++ b/app/src/main/res/values-nb-rNO/strings.xml @@ -577,13 +577,10 @@ Slutte å følge #%s\? Sluttet å følge #%s Laster tråd - Dette er Hjemmetidslinjendin. Den viser de nyeste innleggene av kontoer du følger. + Dette er Hjemmetidslinjendin. Den viser de nyeste innleggene av kontoer du følger. \n \nFor å utforske kontoer kan du enten oppdage dem i en av tidslinjene dine, for eksempel den lokale tidslinjen for instansen din [iconics gmd_group], eller du kan søke etter navnet deres [iconics gmd_search]; søk for eksempel etter Tusky før å finne Mastodonkonten vår. - Leserekkefølge - Eldste først Skriftstørrelse for brukergrensesnitt - Nyeste først Følg emneknagger #emneknagg %1$d folk snakker om emneknaggen %2$s @@ -665,7 +662,7 @@ Vis poststatistikk i tidslinjen Last de nyeste varslene Del utkast\? - Serveren din vet at denne posten ble endret, men har ingen kopi av entringene så de kann ikke vises. + Serveren din vet at denne posten ble endret, men har ingen kopi av entringene så de kann ikke vises. \n \nDette er Mastodon issue #25398. - \ No newline at end of file + diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 7d2ee31d13..3b1b3bba90 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -667,7 +667,6 @@ Att knuffa inlägg misslyckades: %s Rösta i omröstning misslyckades: %s Acceptera följarförfrågan misslyckades: %s - Läsordning Att bokmärka inlägg misslyckades: %s Rensing av aviseringar misslyckades: %s Att favoritmarkera inlägg misslyckades: %s @@ -680,4 +679,4 @@ Din server vet att det här inlägget har redigerats, men har ingen kopia av ändringarna, så de kan inte visas för dig. \n \nDet är Mastodon problem #25398. - \ No newline at end of file + diff --git a/app/src/test/java/com/keylesspalace/tusky/components/timeline/CachedTimelineRemoteMediatorTest.kt b/app/src/test/java/com/keylesspalace/tusky/components/timeline/CachedTimelineRemoteMediatorTest.kt index 06ed320857..51013104c1 100644 --- a/app/src/test/java/com/keylesspalace/tusky/components/timeline/CachedTimelineRemoteMediatorTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/components/timeline/CachedTimelineRemoteMediatorTest.kt @@ -2,6 +2,7 @@ package com.keylesspalace.tusky.components.timeline import android.os.Looper.getMainLooper import androidx.paging.ExperimentalPagingApi +import androidx.paging.InvalidatingPagingSourceFactory import androidx.paging.LoadType import androidx.paging.PagingConfig import androidx.paging.PagingSource @@ -59,6 +60,8 @@ class CachedTimelineRemoteMediatorTest { private lateinit var db: AppDatabase + private lateinit var pagingSourceFactory: InvalidatingPagingSourceFactory + @Before @ExperimentalCoroutinesApi fun setup() { @@ -68,6 +71,8 @@ class CachedTimelineRemoteMediatorTest { db = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java) .addTypeConverter(Converters(Gson())) .build() + + pagingSourceFactory = mock() } @After @@ -84,6 +89,7 @@ class CachedTimelineRemoteMediatorTest { api = mock { onBlocking { homeTimeline(anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) } doReturn Response.error(500, "".toResponseBody()) }, + factory = pagingSourceFactory, db = db, gson = Gson() ) @@ -103,6 +109,7 @@ class CachedTimelineRemoteMediatorTest { api = mock { onBlocking { homeTimeline(anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) } doThrow IOException() }, + factory = pagingSourceFactory, db = db, gson = Gson() ) @@ -119,6 +126,7 @@ class CachedTimelineRemoteMediatorTest { val remoteMediator = CachedTimelineRemoteMediator( accountManager = accountManager, api = mock(), + factory = pagingSourceFactory, db = db, gson = Gson() ) @@ -170,6 +178,7 @@ class CachedTimelineRemoteMediatorTest { ) ) }, + factory = pagingSourceFactory, db = db, gson = Gson() ) @@ -230,6 +239,7 @@ class CachedTimelineRemoteMediatorTest { ) ) }, + factory = pagingSourceFactory, db = db, gson = Gson() ) @@ -275,6 +285,7 @@ class CachedTimelineRemoteMediatorTest { ) ) }, + factory = pagingSourceFactory, db = db, gson = Gson() ) @@ -326,6 +337,7 @@ class CachedTimelineRemoteMediatorTest { ) ) }, + factory = pagingSourceFactory, db = db, gson = Gson() ) @@ -383,6 +395,7 @@ class CachedTimelineRemoteMediatorTest { ).build() ) }, + factory = pagingSourceFactory, db = db, gson = Gson() ) From 7b22d3c0d1029105edbfb5b29eaab553895e51b9 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Mon, 10 Jul 2023 18:32:41 +0200 Subject: [PATCH 125/156] ItemSnapshotList's first item may not be at position 0 If it's not then the previous code wouldn't save the visible ID. --- .../components/timeline/TimelineFragment.kt | 25 ++++++++++--------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt index 8936d60fbe..ebab928660 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt @@ -517,19 +517,19 @@ class TimelineFragment : } /** - * Save the ID of status at [position] in the adapter. If [position] is - * [RecyclerView.NO_POSITION] then the ID of the last completely visible status is used. + * Save [statusId] as the reading position. If null then the ID of the last completely visible + * status is used. */ - fun saveVisibleId(position: Int = RecyclerView.NO_POSITION) { - if (position == RecyclerView.NO_POSITION) { - layoutManager.findLastCompletelyVisibleItemPosition() - } else { - position - }.takeIf { it != RecyclerView.NO_POSITION } + fun saveVisibleId(statusId: String? = null) { + statusId ?: layoutManager.findLastCompletelyVisibleItemPosition() + .takeIf { it != RecyclerView.NO_POSITION } ?.let { pos -> - adapter.snapshot().getOrNull(pos)?.id?.let { statusId -> - viewModel.accept(InfallibleUiAction.SaveVisibleId(visibleId = statusId)) - } + val snapshot = adapter.snapshot() + adapter.snapshot().getOrNull(pos)?.id + } + ?.let { + Log.d(TAG, "Saving ID: $it") + viewModel.accept(InfallibleUiAction.SaveVisibleId(visibleId = it)) } } @@ -738,7 +738,8 @@ class TimelineFragment : if (isAdded) { binding.recyclerView.layoutManager?.scrollToPosition(0) binding.recyclerView.stopScroll() - saveVisibleId(0) + // The first item in an ItemSnapshotList may not be at index 0, hence firstOrNull() + saveVisibleId(adapter.snapshot().firstOrNull()?.id) } } From 0af9f1df6f8f3d718c5336465e00bd1a80276cd2 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Mon, 10 Jul 2023 19:57:15 +0200 Subject: [PATCH 126/156] Rejig presentation states --- .../components/timeline/TimelineFragment.kt | 16 +++--- .../util/CombinedLoadStatesExtensions.kt | 52 ++++++++++--------- 2 files changed, 36 insertions(+), 32 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt index ebab928660..ad5ca8ef0b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt @@ -428,19 +428,18 @@ class TimelineFragment : Log.d(TAG, " adapter.itemCount: ${adapter.itemCount}") val listIsEmpty = loadState.refresh is LoadState.NotLoading && adapter.itemCount == 0 - val initialLoadOrRefresh = presentationState == PresentationState.REMOTE_LOADING || presentationState == PresentationState.SOURCE_LOADING - binding.progressBar.hide() - binding.statusView.hide() - binding.recyclerView.isVisible = !listIsEmpty + binding.progressBar.isVisible = presentationState != PresentationState.PRESENTED && binding.swipeRefreshLayout.isRefreshing == false - Log.d(TAG, " initialLoadOrRefresh: $initialLoadOrRefresh") - - binding.progressBar.isVisible = presentationState != PresentationState.PRESENTED && listIsEmpty if (binding.swipeRefreshLayout.isRefreshing && (presentationState == PresentationState.PRESENTED || loadState.refresh is LoadState.Error)) { binding.swipeRefreshLayout.isRefreshing = false } + if (!listIsEmpty && presentationState == PresentationState.PRESENTED) { + binding.recyclerView.show() + binding.statusView.hide() + } + if (listIsEmpty && presentationState == PresentationState.PRESENTED) { Log.d(TAG, "Showing empty state") binding.statusView.setup( @@ -450,6 +449,8 @@ class TimelineFragment : if (timelineKind == TimelineKind.Home) { binding.statusView.showHelp(R.string.help_empty_home) } + binding.statusView.show() + binding.recyclerView.hide() return@collect } else { Log.d(TAG, "Not showing empty state") @@ -477,6 +478,7 @@ class TimelineFragment : adapter.retry() } binding.statusView.show() + binding.recyclerView.hide() } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/util/CombinedLoadStatesExtensions.kt b/app/src/main/java/com/keylesspalace/tusky/util/CombinedLoadStatesExtensions.kt index 0a296a17a0..3ea72f7965 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/CombinedLoadStatesExtensions.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/CombinedLoadStatesExtensions.kt @@ -21,9 +21,6 @@ import android.util.Log import androidx.paging.CombinedLoadStates import androidx.paging.LoadState import com.keylesspalace.tusky.util.PresentationState.INITIAL -import com.keylesspalace.tusky.util.PresentationState.PRESENTED -import com.keylesspalace.tusky.util.PresentationState.REMOTE_LOADING -import com.keylesspalace.tusky.util.PresentationState.SOURCE_LOADING import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine @@ -41,46 +38,51 @@ enum class PresentationState { SOURCE_LOADING, /** The first request page of results is visible via the adapter */ - PRESENTED -} + PRESENTED; -/** - * [CombinedLoadStates] are stateful -- you can't fully interpret the meaning of the state unless - * previous states have been observed. This tracks those states and provides a [PresentationState] - * that describes whether the most refresh has presented the data via the associated adapter. - * - * @return Flow that combines the load state with its associated presentation state - */ -@OptIn(ExperimentalCoroutinesApi::class) -fun Flow.withPresentationState(): Flow> { - val TAG = "WithPresentationState" - - val presentationStateFlow = scan(INITIAL) { state, loadState -> - Log.d(TAG, "state: $state") - Log.d(TAG, "loadState.mediator.refresh: ${loadState.mediator?.refresh}") - Log.d(TAG, "loadState.source.refresh: ${loadState.source.refresh}") - when (state) { + fun next(loadState: CombinedLoadStates): PresentationState { + return when (this) { PRESENTED -> when (loadState.mediator?.refresh) { is LoadState.Loading -> REMOTE_LOADING - else -> state + else -> this } INITIAL -> when (loadState.mediator?.refresh) { is LoadState.Loading -> REMOTE_LOADING - else -> state + is LoadState.NotLoading -> SOURCE_LOADING.next(loadState) + else -> this } REMOTE_LOADING -> when (loadState.source.refresh) { is LoadState.Loading -> SOURCE_LOADING - else -> state + else -> this } SOURCE_LOADING -> when (loadState.source.refresh) { is LoadState.NotLoading -> PRESENTED - else -> state + else -> this } } } +} + +/** + * [CombinedLoadStates] are stateful -- you can't fully interpret the meaning of the state unless + * previous states have been observed. This tracks those states and provides a [PresentationState] + * that describes whether the most refresh has presented the data via the associated adapter. + * + * @return Flow that combines the load state with its associated presentation state + */ +@OptIn(ExperimentalCoroutinesApi::class) +fun Flow.withPresentationState(): Flow> { + val TAG = "WithPresentationState" + + val presentationStateFlow = scan(INITIAL) { state, loadState -> + Log.d(TAG, "state: $state") + Log.d(TAG, "loadState.mediator.refresh: ${loadState.mediator?.refresh}") + Log.d(TAG, "loadState.source.refresh: ${loadState.source.refresh}") + state.next(loadState) + } .distinctUntilChanged() return this.combine(presentationStateFlow) { loadState, presentationState -> From a4b6138dbdcb31b9040f987dd16fc2d7fd072880 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Tue, 11 Jul 2023 15:12:28 +0200 Subject: [PATCH 127/156] Move FilterPrefs to the companion object. --- .../timeline/viewmodel/TimelineViewModel.kt | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt index df6496c7f9..909cf554db 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt @@ -466,7 +466,7 @@ abstract class TimelineViewModel( viewModelScope.launch { eventHub.events .filterIsInstance() - .filter { FilterPrefs.contains(it.preferenceKey) } + .filter { FILTER_PREF_KEYS.contains(it.preferenceKey) } .distinctUntilChanged() .map { getFilters() } .onStart { getFilters() } @@ -546,20 +546,12 @@ abstract class TimelineViewModel( } } - private val FilterPrefs = setOf( - FilterV1.HOME, - FilterV1.NOTIFICATIONS, - FilterV1.THREAD, - FilterV1.PUBLIC, - FilterV1.ACCOUNT - ) - /** Updates the current set of filters if filter-related preferences change */ // TODO: https://github.com/tuskyapp/Tusky/issues/3546, and update if a v2 filter is // updated as well. private fun updateFiltersFromPreferences() = eventHub.events .filterIsInstance() - .filter { FilterPrefs.contains(it.preferenceKey) } + .filter { FILTER_PREF_KEYS.contains(it.preferenceKey) } .filter { filterContextMatchesKind(timelineKind, listOf(it.preferenceKey)) } .distinctUntilChanged() .map { getFilters() } @@ -663,6 +655,15 @@ abstract class TimelineViewModel( private const val TAG = "TimelineViewModel" private val THROTTLE_TIMEOUT = 500.milliseconds + /** Preference keys that, if changed, indicate that a filter preference has changed */ + private val FILTER_PREF_KEYS = setOf( + FilterV1.HOME, + FilterV1.NOTIFICATIONS, + FilterV1.THREAD, + FilterV1.PUBLIC, + FilterV1.ACCOUNT + ) + fun filterContextMatchesKind( timelineKind: TimelineKind, filterContext: List From de1b5c8f60d823bdf87f11bf94e99f415d5e46e5 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Tue, 11 Jul 2023 22:30:26 +0200 Subject: [PATCH 128/156] Include the exception message in the UI --- .../java/com/keylesspalace/tusky/util/ThrowableExtensions.kt | 4 ++-- app/src/main/res/values/strings.xml | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ThrowableExtensions.kt b/app/src/main/java/com/keylesspalace/tusky/util/ThrowableExtensions.kt index a3811a358f..75f9f08aaa 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/ThrowableExtensions.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/ThrowableExtensions.kt @@ -37,6 +37,6 @@ fun Throwable.getDrawableRes(): Int = when (this) { /** @return A string error message for this throwable */ fun Throwable.getErrorString(context: Context): String = getServerErrorMessage() ?: when (this) { - is IOException -> context.getString(R.string.error_network) - else -> context.getString(R.string.error_generic) + is IOException -> context.getString(R.string.error_network_fmt, this.message) + else -> context.getString(R.string.error_generic_fmt, this.message) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3c8a79ab12..ec5a79398c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -18,7 +18,9 @@ An error occurred. + An error occurred: %s A network error occurred. Please check your connection and try again. + A network error occurred: %s This cannot be empty. Invalid domain entered Failed authenticating with that instance. If this persists, try "Login in Browser" from the menu. From 94944a9b47651af3826ccd2344f27de93aab7aa9 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Tue, 11 Jul 2023 22:33:04 +0200 Subject: [PATCH 129/156] Add an error case to PresentationState --- .../components/timeline/TimelineFragment.kt | 14 ++++++++++- .../util/CombinedLoadStatesExtensions.kt | 23 +++++++++++++------ 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt index ad5ca8ef0b..bf6f8c7f0e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt @@ -429,7 +429,19 @@ class TimelineFragment : val listIsEmpty = loadState.refresh is LoadState.NotLoading && adapter.itemCount == 0 - binding.progressBar.isVisible = presentationState != PresentationState.PRESENTED && binding.swipeRefreshLayout.isRefreshing == false + // Only show the progress bar if: + // + // - The load hasn't errored + // - The list hasn't been presented + // - The swipe-refresh progress spinner isn't showing + // - The adapter count is 0 + // + // The last one is because there appears to be a race condition between the + // presentation state changing and isRefreshing changing to true. + binding.progressBar.isVisible = presentationState != PresentationState.ERROR + && presentationState != PresentationState.PRESENTED + && binding.swipeRefreshLayout.isRefreshing == false + && adapter.itemCount == 0 if (binding.swipeRefreshLayout.isRefreshing && (presentationState == PresentationState.PRESENTED || loadState.refresh is LoadState.Error)) { binding.swipeRefreshLayout.isRefreshing = false diff --git a/app/src/main/java/com/keylesspalace/tusky/util/CombinedLoadStatesExtensions.kt b/app/src/main/java/com/keylesspalace/tusky/util/CombinedLoadStatesExtensions.kt index 3ea72f7965..05e784ce1e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/CombinedLoadStatesExtensions.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/CombinedLoadStatesExtensions.kt @@ -21,7 +21,6 @@ import android.util.Log import androidx.paging.CombinedLoadStates import androidx.paging.LoadState import com.keylesspalace.tusky.util.PresentationState.INITIAL -import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged @@ -37,16 +36,20 @@ enum class PresentationState { /** PagingSource is loading the first requested page of results */ SOURCE_LOADING, + /** There was an error loading the first page of results */ + ERROR, + /** The first request page of results is visible via the adapter */ PRESENTED; + /** + * Take the next step in the PresentationState state machine, given [loadState] + */ fun next(loadState: CombinedLoadStates): PresentationState { - return when (this) { - PRESENTED -> when (loadState.mediator?.refresh) { - is LoadState.Loading -> REMOTE_LOADING - else -> this - } + if (loadState.mediator?.refresh is LoadState.Error) return ERROR + if (loadState.source.refresh is LoadState.Error) return ERROR + return when (this) { INITIAL -> when (loadState.mediator?.refresh) { is LoadState.Loading -> REMOTE_LOADING is LoadState.NotLoading -> SOURCE_LOADING.next(loadState) @@ -62,6 +65,13 @@ enum class PresentationState { is LoadState.NotLoading -> PRESENTED else -> this } + + ERROR -> INITIAL.next(loadState) + + PRESENTED -> when (loadState.mediator?.refresh) { + is LoadState.Loading -> REMOTE_LOADING + else -> this + } } } } @@ -73,7 +83,6 @@ enum class PresentationState { * * @return Flow that combines the load state with its associated presentation state */ -@OptIn(ExperimentalCoroutinesApi::class) fun Flow.withPresentationState(): Flow> { val TAG = "WithPresentationState" From 92ac56fc26f3b103c590e1e543056f682169f336 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Wed, 12 Jul 2023 19:31:57 +0200 Subject: [PATCH 130/156] Lint --- .../tusky/components/timeline/TimelineFragment.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt index bf6f8c7f0e..e9d38a8df2 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt @@ -438,10 +438,10 @@ class TimelineFragment : // // The last one is because there appears to be a race condition between the // presentation state changing and isRefreshing changing to true. - binding.progressBar.isVisible = presentationState != PresentationState.ERROR - && presentationState != PresentationState.PRESENTED - && binding.swipeRefreshLayout.isRefreshing == false - && adapter.itemCount == 0 + binding.progressBar.isVisible = presentationState != PresentationState.ERROR && + presentationState != PresentationState.PRESENTED && + binding.swipeRefreshLayout.isRefreshing == false && + adapter.itemCount == 0 if (binding.swipeRefreshLayout.isRefreshing && (presentationState == PresentationState.PRESENTED || loadState.refresh is LoadState.Error)) { binding.swipeRefreshLayout.isRefreshing = false From 15482189ba73cd4c8bdf0b9c1c51a5f6b3532465 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Thu, 13 Jul 2023 16:05:52 +0200 Subject: [PATCH 131/156] Handle placeholder items in a timeline list This should not happen, as the lists do not use placeholders. Nevertheless, in use I've observed `getItem(position)` returning null. The documentation says this can happen if the item at that position is a placeholder. Without this change this manifests as a completely empty status, but with buttons for voting, an empty media preview, etc. With this change it's an effectively invisible view which is eventually replaced with the real one. --- .../timeline/TimelinePagingAdapter.kt | 10 +++++--- app/src/main/res/layout/item_placeholder.xml | 24 +++++++++++++++++++ 2 files changed, 31 insertions(+), 3 deletions(-) create mode 100644 app/src/main/res/layout/item_placeholder.xml diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelinePagingAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelinePagingAdapter.kt index e4c5c34e95..60df0e8580 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelinePagingAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelinePagingAdapter.kt @@ -38,9 +38,10 @@ class TimelinePagingAdapter( VIEW_TYPE_STATUS_FILTERED -> { StatusViewHolder(inflater.inflate(R.layout.item_status_wrapper, viewGroup, false)) } - else -> { + VIEW_TYPE_STATUS -> { StatusViewHolder(inflater.inflate(R.layout.item_status, viewGroup, false)) } + else -> return object : RecyclerView.ViewHolder(inflater.inflate(R.layout.item_placeholder, viewGroup, false)) {} } } @@ -72,8 +73,8 @@ class TimelinePagingAdapter( } override fun getItemViewType(position: Int): Int { - val viewData = getItem(position) - return if (viewData?.filterAction == Filter.Action.WARN) { + val viewData = getItem(position) ?: return VIEW_TYPE_PLACEHOLDER + return if (viewData.filterAction == Filter.Action.WARN) { VIEW_TYPE_STATUS_FILTERED } else { VIEW_TYPE_STATUS @@ -81,8 +82,11 @@ class TimelinePagingAdapter( } companion object { + @Suppress("unused") + private const val TAG = "TimelinePagingAdapter" private const val VIEW_TYPE_STATUS = 0 private const val VIEW_TYPE_STATUS_FILTERED = 1 + private const val VIEW_TYPE_PLACEHOLDER = -1 val TimelineDifferCallback = object : DiffUtil.ItemCallback() { override fun areItemsTheSame( diff --git a/app/src/main/res/layout/item_placeholder.xml b/app/src/main/res/layout/item_placeholder.xml new file mode 100644 index 0000000000..a01ff9b828 --- /dev/null +++ b/app/src/main/res/layout/item_placeholder.xml @@ -0,0 +1,24 @@ + + + + + + From 95fe4bbe1b051b2532b2411169cc0647d2cd5b80 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Thu, 13 Jul 2023 18:16:57 +0200 Subject: [PATCH 132/156] Use withPresentationState for peeking behaviour --- .../components/timeline/TimelineFragment.kt | 61 ++++--------------- 1 file changed, 12 insertions(+), 49 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt index e9d38a8df2..4eec4e847e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt @@ -351,70 +351,33 @@ class TimelineFragment : // finished (so that DiffUtil has had a chance to process the data). See // https://github.com/googlecodelabs/android-paging/issues/149 launch { - var old: CombinedLoadStates? = null - var refreshComplete = false + var previousLoadState: CombinedLoadStates? = null + var activeRefresh = false if (isSwipeToRefreshEnabled) { adapter.loadStateFlow - .collect { - if (old == null) { - Log.d("loadState", "No previous loadState") - old = it + .withPresentationState() + .collect { (loadState, presentationState) -> + if (previousLoadState == null) { + previousLoadState = loadState return@collect } - if (old?.refresh != it.refresh) { - Log.d("loadState", "refresh: ${old?.refresh} -> ${it.refresh}") - } - - if (old?.prepend != it.prepend) { - Log.d("loadState", "prepend: ${old?.prepend} -> ${it.prepend}") - } - - if (old?.append != it.append) { - Log.d("loadState", "append: ${old?.append} -> ${it.append}") - } - - if (old?.source?.refresh != it.source.refresh) { - Log.d("loadState", " source.refresh: ${old?.source?.refresh} -> ${it.source.refresh}") - } - - if (old?.source?.prepend != it.source.prepend) { - Log.d("loadState", " source.prepend: ${old?.source?.prepend} -> ${it.source.prepend}") - } - - if (old?.source?.append != it.source.append) { - Log.d("loadState", " source.append: ${old?.source?.append} -> ${it.source.append}") - } - - if (old?.mediator?.refresh != it.mediator?.refresh) { - Log.d("loadState", " mediator.refresh: ${old?.mediator?.refresh} -> ${it.mediator?.refresh}") - } - - if (old?.mediator?.prepend != it.mediator?.prepend) { - Log.d("loadState", " mediator.prepend: ${old?.mediator?.prepend} -> ${it.mediator?.prepend}") - } - - if (old?.mediator?.append != it.mediator?.append) { - Log.d("loadState", " mediator.append: ${old?.mediator?.append} -> ${it.mediator?.append}") - } - - if (!refreshComplete) { - refreshComplete = - old?.refresh is LoadState.Loading && it.refresh is LoadState.NotLoading + if (presentationState != PresentationState.PRESENTED) { + activeRefresh = true } - if (refreshComplete) { - if (old?.prepend is LoadState.Loading && it.prepend is LoadState.NotLoading) { - refreshComplete = false + if (presentationState == PresentationState.PRESENTED && activeRefresh) { + if (previousLoadState?.prepend is LoadState.Loading && loadState.prepend is LoadState.NotLoading) { Log.d("loadState", "mediator.prepend=NotLoading, scrolling to peek") binding.recyclerView.post { getView() ?: return@post binding.recyclerView.scrollBy(0, Utils.dpToPx(requireContext(), -30)) } + activeRefresh = false } } - old = it + previousLoadState = loadState } } } From 431877080b57afa3b298b471dd4f822d8a5c1b52 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Thu, 13 Jul 2023 19:30:28 +0200 Subject: [PATCH 133/156] Ditch listIsEmpty --- .../tusky/components/timeline/TimelineFragment.kt | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt index 4eec4e847e..19899768e0 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt @@ -390,8 +390,6 @@ class TimelineFragment : Log.d(TAG, "presentationState: $presentationState") Log.d(TAG, " adapter.itemCount: ${adapter.itemCount}") - val listIsEmpty = loadState.refresh is LoadState.NotLoading && adapter.itemCount == 0 - // Only show the progress bar if: // // - The load hasn't errored @@ -410,12 +408,12 @@ class TimelineFragment : binding.swipeRefreshLayout.isRefreshing = false } - if (!listIsEmpty && presentationState == PresentationState.PRESENTED) { + if (adapter.itemCount != 0 && presentationState == PresentationState.PRESENTED) { binding.recyclerView.show() binding.statusView.hide() } - if (listIsEmpty && presentationState == PresentationState.PRESENTED) { + if (adapter.itemCount == 0 && presentationState == PresentationState.PRESENTED) { Log.d(TAG, "Showing empty state") binding.statusView.setup( R.drawable.elephant_friend_empty, @@ -426,7 +424,6 @@ class TimelineFragment : } binding.statusView.show() binding.recyclerView.hide() - return@collect } else { Log.d(TAG, "Not showing empty state") } From 75f9f7e6fb6977c173991e60ae72a7d7c51dc94f Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Mon, 24 Jul 2023 15:34:26 +0200 Subject: [PATCH 134/156] Add snackbars to show why voting didn't happen --- .../tusky/adapter/StatusBaseViewHolder.java | 18 +++++++++++++----- .../components/timeline/TimelineFragment.kt | 18 ++++++++++++++++-- 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java index 724ba735c0..5cc48b08e9 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java @@ -37,6 +37,7 @@ import com.google.android.material.imageview.ShapeableImageView; import com.google.android.material.shape.CornerFamily; import com.google.android.material.shape.ShapeAppearanceModel; +import com.google.android.material.snackbar.Snackbar; import com.keylesspalace.tusky.R; import com.keylesspalace.tusky.ViewMediaActivity; import com.keylesspalace.tusky.entity.Attachment; @@ -1031,18 +1032,25 @@ private void setupPoll(PollViewData poll, List emojis, pollButton.setVisibility(View.VISIBLE); pollButton.setOnClickListener(v -> { - int position = getBindingAdapterPosition(); - if (position != RecyclerView.NO_POSITION) { - List pollResult = pollAdapter.getSelected(); - if (!pollResult.isEmpty()) { listener.onVoteInPoll(position, pollResult); + } else { + Snackbar.make( + itemView, + "pollAdapter.getSelected() returned an empty list", + Snackbar.LENGTH_INDEFINITE + ).show(); } + } else { + Snackbar.make( + itemView, + "getBindingAdapterPosition() returned NO_POSITION", + Snackbar.LENGTH_INDEFINITE + ).show(); } - }); } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt index 19899768e0..29dae93205 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt @@ -565,8 +565,22 @@ class TimelineFragment : } override fun onVoteInPoll(position: Int, choices: List) { - val statusViewData = adapter.peek(position) ?: return - val poll = statusViewData.status.poll ?: return + val statusViewData = adapter.peek(position) ?: run { + Snackbar.make( + binding.root, + "null at adapter.peek($position)", + Snackbar.LENGTH_INDEFINITE + ).show() + null + } ?: return + val poll = statusViewData.status.poll ?: run { + Snackbar.make( + binding.root, + "statusViewData had null poll", + Snackbar.LENGTH_INDEFINITE + ).show() + null + } ?: return viewModel.accept(StatusAction.VoteInPoll(poll, choices, statusViewData)) } From d6f9c760926b916cd8e19ae7302a7372b98ec5c1 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Thu, 27 Jul 2023 13:40:25 +0200 Subject: [PATCH 135/156] Initialise the hashtag variable from the timeline kind Ensures that the mute/unmute follow/unfollow menu options are shown --- .../java/com/keylesspalace/tusky/StatusListActivity.kt | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt b/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt index df52b0e1b5..f8f3b930c1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt @@ -71,7 +71,10 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector { val title = when (timelineKind) { is TimelineKind.Favourites -> getString(R.string.title_favourites) is TimelineKind.Bookmarks -> getString(R.string.title_bookmarks) - is TimelineKind.Tag -> getString(R.string.title_tag).format((timelineKind as TimelineKind.Tag).tags.first()) + is TimelineKind.Tag -> { + hashtag = (timelineKind as TimelineKind.Tag).tags.first() + getString(R.string.title_tag).format(hashtag) + } is TimelineKind.UserList -> (timelineKind as TimelineKind.UserList).title else -> "Missing title!!!" } @@ -316,10 +319,8 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector { override fun androidInjector() = dispatchingAndroidInjector companion object { - private const val EXTRA_KIND = "kind" - private const val EXTRA_LIST_ID = "id" - const val TAG = "StatusListActivity" + private const val TAG = "StatusListActivity" fun newFavouritesIntent(context: Context) = Intent(context, StatusListActivity::class.java).apply { From 56ae1d32a8087eb14e943d8dbb89e078dd9a50bb Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Sun, 30 Jul 2023 19:46:51 +0200 Subject: [PATCH 136/156] INITIAL can only transition to REMOTE_LOADING --- .../keylesspalace/tusky/util/CombinedLoadStatesExtensions.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/util/CombinedLoadStatesExtensions.kt b/app/src/main/java/com/keylesspalace/tusky/util/CombinedLoadStatesExtensions.kt index 05e784ce1e..64b00e679b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/CombinedLoadStatesExtensions.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/CombinedLoadStatesExtensions.kt @@ -52,7 +52,6 @@ enum class PresentationState { return when (this) { INITIAL -> when (loadState.mediator?.refresh) { is LoadState.Loading -> REMOTE_LOADING - is LoadState.NotLoading -> SOURCE_LOADING.next(loadState) else -> this } @@ -79,7 +78,7 @@ enum class PresentationState { /** * [CombinedLoadStates] are stateful -- you can't fully interpret the meaning of the state unless * previous states have been observed. This tracks those states and provides a [PresentationState] - * that describes whether the most refresh has presented the data via the associated adapter. + * that describes whether the most recent refresh has presented the data via the associated adapter. * * @return Flow that combines the load state with its associated presentation state */ From 6e94bce960688632c1cbe080e16b9c2821e9d5b9 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Sun, 30 Jul 2023 19:50:02 +0200 Subject: [PATCH 137/156] Display the username again --- .../com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java index 5cc48b08e9..295a0ac776 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java @@ -762,8 +762,7 @@ public void setupWithStatus(@NonNull StatusViewData status, @Nullable Object payloads) { if (payloads == null) { Status actionable = status.getActionable(); - //setDisplayName(actionable.getAccount().getName(), actionable.getAccount().getEmojis(), statusDisplayOptions); - setDisplayName(status.getId(), actionable.getAccount().getEmojis(), statusDisplayOptions); + setDisplayName(actionable.getAccount().getName(), actionable.getAccount().getEmojis(), statusDisplayOptions); setUsername(status.getUsername()); setMetaData(status, statusDisplayOptions, listener); setIsReply(actionable.getInReplyToId() != null); From 33092a2c66173f288e05151b0947b70492b32ed9 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Sat, 5 Aug 2023 13:23:04 +0200 Subject: [PATCH 138/156] Remove kapt flags --- app/build.gradle | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 1e67b551be..70c8687ec1 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -186,20 +186,3 @@ dependencies { androidTestImplementation libs.androidx.room.testing androidTestImplementation libs.androidx.test.junit } - -// Work around warnings of: -// WARNING: Illegal reflective access by org.jetbrains.kotlin.kapt3.util.ModuleManipulationUtilsKt (file:/C:/Users/Andi/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlin/kotlin-annotation-processing-gradle/1.8.22/28dab7e0ee9ce62c03bf97de3543c911dc653700/kotlin-annotation-processing-gradle-1.8.22.jar) to constructor com.sun.tools.javac.util.Context() -// See https://youtrack.jetbrains.com/issue/KT-30589/Kapt-An-illegal-reflective-access-operation-has-occurred -tasks.withType(org.jetbrains.kotlin.gradle.internal.KaptWithoutKotlincTask) { - kaptProcessJvmArgs.addAll([ - "--add-opens", "jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED", - "--add-opens", "jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED", - "--add-opens", "jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED", - "--add-opens", "jdk.compiler/com.sun.tools.javac.jvm=ALL-UNNAMED", - "--add-opens", "jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED", - "--add-opens", "jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED", - "--add-opens", "jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED", - "--add-opens", "jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED", - "--add-opens", "jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED", - "--add-opens", "jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED"]) -} From f9e0e850725cd28e665b2fae073b7b8af54a9780 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Mon, 7 Aug 2023 12:19:24 +0200 Subject: [PATCH 139/156] Transition to PRESENTED when refresh has stopped Seen loadstate.source.refresh transition to NotLoading while loadstate.mediator.refresh was Loading --- .../tusky/components/timeline/TimelineFragment.kt | 5 ++++- .../keylesspalace/tusky/util/CombinedLoadStatesExtensions.kt | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt index 29dae93205..ac35019cab 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt @@ -389,6 +389,7 @@ class TimelineFragment : Log.d(TAG, "loadState: $loadState") Log.d(TAG, "presentationState: $presentationState") Log.d(TAG, " adapter.itemCount: ${adapter.itemCount}") + Log.d(TAG, " isRefreshing: ${binding.swipeRefreshLayout.isRefreshing}") // Only show the progress bar if: // @@ -403,6 +404,7 @@ class TimelineFragment : presentationState != PresentationState.PRESENTED && binding.swipeRefreshLayout.isRefreshing == false && adapter.itemCount == 0 + Log.d(TAG, "progressBar.isVisible: ${binding.progressBar.isVisible}") if (binding.swipeRefreshLayout.isRefreshing && (presentationState == PresentationState.PRESENTED || loadState.refresh is LoadState.Error)) { binding.swipeRefreshLayout.isRefreshing = false @@ -426,9 +428,10 @@ class TimelineFragment : binding.recyclerView.hide() } else { Log.d(TAG, "Not showing empty state") + binding.statusView.hide() } - if (loadState.refresh is LoadState.Error) { + if (presentationState == PresentationState.ERROR) { val message = (loadState.refresh as LoadState.Error).error.getErrorString(requireContext()) // Show errors as a snackbar if there is existing content to show diff --git a/app/src/main/java/com/keylesspalace/tusky/util/CombinedLoadStatesExtensions.kt b/app/src/main/java/com/keylesspalace/tusky/util/CombinedLoadStatesExtensions.kt index 64b00e679b..5edcef7ed3 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/CombinedLoadStatesExtensions.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/CombinedLoadStatesExtensions.kt @@ -60,7 +60,7 @@ enum class PresentationState { else -> this } - SOURCE_LOADING -> when (loadState.source.refresh) { + SOURCE_LOADING -> when (loadState.refresh) { is LoadState.NotLoading -> PRESENTED else -> this } From 91ba338debd8dd6b7435cfbd4121e15909377bb4 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Mon, 7 Aug 2023 12:19:53 +0200 Subject: [PATCH 140/156] Use when with PresentationState instead of if chains --- .../components/timeline/TimelineFragment.kt | 118 ++++++++---------- 1 file changed, 51 insertions(+), 67 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt index ac35019cab..3494666996 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt @@ -26,7 +26,6 @@ import android.view.ViewGroup import android.view.accessibility.AccessibilityManager import androidx.core.content.ContextCompat import androidx.core.view.MenuProvider -import androidx.core.view.isVisible import androidx.lifecycle.Lifecycle import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.lifecycleScope @@ -386,74 +385,59 @@ class TimelineFragment : adapter.loadStateFlow .withPresentationState() .collect { (loadState, presentationState) -> - Log.d(TAG, "loadState: $loadState") - Log.d(TAG, "presentationState: $presentationState") - Log.d(TAG, " adapter.itemCount: ${adapter.itemCount}") - Log.d(TAG, " isRefreshing: ${binding.swipeRefreshLayout.isRefreshing}") - - // Only show the progress bar if: - // - // - The load hasn't errored - // - The list hasn't been presented - // - The swipe-refresh progress spinner isn't showing - // - The adapter count is 0 - // - // The last one is because there appears to be a race condition between the - // presentation state changing and isRefreshing changing to true. - binding.progressBar.isVisible = presentationState != PresentationState.ERROR && - presentationState != PresentationState.PRESENTED && - binding.swipeRefreshLayout.isRefreshing == false && - adapter.itemCount == 0 - Log.d(TAG, "progressBar.isVisible: ${binding.progressBar.isVisible}") - - if (binding.swipeRefreshLayout.isRefreshing && (presentationState == PresentationState.PRESENTED || loadState.refresh is LoadState.Error)) { - binding.swipeRefreshLayout.isRefreshing = false - } - - if (adapter.itemCount != 0 && presentationState == PresentationState.PRESENTED) { - binding.recyclerView.show() - binding.statusView.hide() - } - - if (adapter.itemCount == 0 && presentationState == PresentationState.PRESENTED) { - Log.d(TAG, "Showing empty state") - binding.statusView.setup( - R.drawable.elephant_friend_empty, - R.string.message_empty - ) - if (timelineKind == TimelineKind.Home) { - binding.statusView.showHelp(R.string.help_empty_home) + when (presentationState) { + PresentationState.INITIAL -> { } + PresentationState.REMOTE_LOADING -> { + if (adapter.itemCount == 0 && !binding.swipeRefreshLayout.isRefreshing) { + binding.progressBar.show() + } } - binding.statusView.show() - binding.recyclerView.hide() - } else { - Log.d(TAG, "Not showing empty state") - binding.statusView.hide() - } - - if (presentationState == PresentationState.ERROR) { - val message = (loadState.refresh as LoadState.Error).error.getErrorString(requireContext()) - - // Show errors as a snackbar if there is existing content to show - // (either cached, or in the adapter), or as a full screen error - // otherwise. - if (adapter.itemCount > 0) { - snackbar = Snackbar.make( - (activity as ActionButtonActivity).actionButton ?: binding.root, - message, - Snackbar.LENGTH_INDEFINITE - ) - .setTextMaxLines(5) - .setAction(R.string.action_retry) { adapter.retry() } - snackbar!!.show() - } else { - val drawableRes = (loadState.refresh as LoadState.Error).error.getDrawableRes() - binding.statusView.setup(drawableRes, message) { - snackbar?.dismiss() - adapter.retry() + PresentationState.SOURCE_LOADING -> { } + PresentationState.ERROR -> { + binding.progressBar.hide() + binding.swipeRefreshLayout.isRefreshing = false + val message = (loadState.refresh as LoadState.Error).error.getErrorString(requireContext()) + + // Show errors as a snackbar if there is existing content to show + // (either cached, or in the adapter), or as a full screen error + // otherwise. + if (adapter.itemCount > 0) { + snackbar = Snackbar.make( + (activity as ActionButtonActivity).actionButton ?: binding.root, + message, + Snackbar.LENGTH_INDEFINITE + ) + .setTextMaxLines(5) + .setAction(R.string.action_retry) { adapter.retry() } + snackbar!!.show() + } else { + val drawableRes = (loadState.refresh as LoadState.Error).error.getDrawableRes() + binding.statusView.setup(drawableRes, message) { + snackbar?.dismiss() + adapter.retry() + } + binding.statusView.show() + binding.recyclerView.hide() + } + } + PresentationState.PRESENTED -> { + binding.progressBar.hide() + binding.swipeRefreshLayout.isRefreshing = false + + if (adapter.itemCount == 0) { + binding.statusView.setup( + R.drawable.elephant_friend_empty, + R.string.message_empty + ) + if (timelineKind == TimelineKind.Home) { + binding.statusView.showHelp(R.string.help_empty_home) + } + binding.statusView.show() + binding.recyclerView.hide() + } else { + binding.recyclerView.show() + binding.statusView.hide() } - binding.statusView.show() - binding.recyclerView.hide() } } } From 245ea37aa3ea184ea13fc689b428c428588c699e Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Mon, 7 Aug 2023 15:27:26 +0200 Subject: [PATCH 141/156] Remove duplicate file (PruneCacheWorker) --- .../components/timeline/PruneCacheWorker.kt | 59 ------------------- 1 file changed, 59 deletions(-) delete mode 100644 app/src/main/java/com/keylesspalace/tusky/components/timeline/PruneCacheWorker.kt diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/PruneCacheWorker.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/PruneCacheWorker.kt deleted file mode 100644 index cac0b63322..0000000000 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/PruneCacheWorker.kt +++ /dev/null @@ -1,59 +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.timeline - -import android.content.Context -import android.util.Log -import androidx.work.CoroutineWorker -import androidx.work.ListenableWorker -import androidx.work.WorkerParameters -import com.keylesspalace.tusky.ChildWorkerFactory -import com.keylesspalace.tusky.db.AccountManager -import com.keylesspalace.tusky.db.AppDatabase -import javax.inject.Inject - -/** Prune the database cache of old statuses. */ -class PruneCacheWorker( - appContext: Context, - workerParams: WorkerParameters, - private val appDatabase: AppDatabase, - private val accountManager: AccountManager -) : CoroutineWorker(appContext, workerParams) { - override suspend fun doWork(): Result { - for (account in accountManager.accounts) { - Log.d(TAG, "Pruning database using account ID: ${account.id}") - appDatabase.timelineDao().cleanup(account.id, MAX_STATUSES_IN_CACHE) - } - return Result.success() - } - - companion object { - private const val TAG = "PruneCacheWorker" - private const val MAX_STATUSES_IN_CACHE = 1000 - const val PERIODIC_WORK_TAG = "PruneCacheWorker_periodic" - } - - class Factory @Inject constructor( - private val appDatabase: AppDatabase, - private val accountManager: AccountManager - ) : ChildWorkerFactory { - override fun createWorker(appContext: Context, params: WorkerParameters): ListenableWorker { - return PruneCacheWorker(appContext, params, appDatabase, accountManager) - } - } -} From f893dc2236a1405c991f7cf89bf65e124fe0d1f1 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Mon, 7 Aug 2023 19:47:49 +0200 Subject: [PATCH 142/156] Update app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt --- .../components/timeline/viewmodel/TimelineViewModel.kt | 9 --------- 1 file changed, 9 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt index 909cf554db..8cad624cf0 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt @@ -463,15 +463,6 @@ abstract class TimelineViewModel( } } - viewModelScope.launch { - eventHub.events - .filterIsInstance() - .filter { FILTER_PREF_KEYS.contains(it.preferenceKey) } - .distinctUntilChanged() - .map { getFilters() } - .onStart { getFilters() } - } - viewModelScope.launch { eventHub.events .collect { event -> handleEvent(event) } From c25e9c33b83ebf3c5c099de6f93506d741400d7f Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Tue, 8 Aug 2023 21:33:19 +0200 Subject: [PATCH 143/156] Get the poll from the "actionable" status, in case this was a poll in a reblogged status --- .../keylesspalace/tusky/components/timeline/TimelineFragment.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt index 3494666996..2328da0ace 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt @@ -560,7 +560,7 @@ class TimelineFragment : ).show() null } ?: return - val poll = statusViewData.status.poll ?: run { + val poll = statusViewData.actionable.poll ?: run { Snackbar.make( binding.root, "statusViewData had null poll", From e5c5c73d19c411708996d9786bb34fe9c0c74900 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Tue, 8 Aug 2023 21:34:33 +0200 Subject: [PATCH 144/156] Fix poll bug in NotificationsFragment too --- .../tusky/components/notifications/NotificationsFragment.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt index 260824ac66..6f866c6456 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt @@ -494,7 +494,7 @@ class NotificationsFragment : override fun onVoteInPoll(position: Int, choices: List) { val statusViewData = adapter.peek(position)?.statusViewData ?: return - val poll = statusViewData.status.poll ?: return + val poll = statusViewData.actionable.poll ?: return viewModel.accept(StatusAction.VoteInPoll(poll, choices, statusViewData)) } From 211caddd86ef549a6e31252f7c73c7bf9042b5cd Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Fri, 11 Aug 2023 22:33:00 +0200 Subject: [PATCH 145/156] Correct anchorPosition, peeking, and progress indicators - Calculate itemsBefore and itemsAfter, get correct anchorPosition - Updating peeking and "show / hide the progress indicators" algorithm --- .../components/timeline/TimelineFragment.kt | 180 ++++++++++-------- .../viewmodel/NetworkTimelinePagingSource.kt | 36 ++-- .../util/CombinedLoadStatesExtensions.kt | 124 ++++++++++-- 3 files changed, 241 insertions(+), 99 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt index 2328da0ace..be25d6ab83 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt @@ -30,7 +30,6 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle -import androidx.paging.CombinedLoadStates import androidx.paging.LoadState import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager @@ -64,6 +63,8 @@ import com.keylesspalace.tusky.interfaces.ReselectableFragment import com.keylesspalace.tusky.interfaces.StatusActionListener import com.keylesspalace.tusky.util.ListStatusAccessibilityDelegate import com.keylesspalace.tusky.util.PresentationState +import com.keylesspalace.tusky.util.RefreshState +import com.keylesspalace.tusky.util.asRefreshState import com.keylesspalace.tusky.util.getDrawableRes import com.keylesspalace.tusky.util.getErrorString import com.keylesspalace.tusky.util.hide @@ -345,102 +346,129 @@ class TimelineFragment : } } - // Scroll the list down if a refresh has completely finished. A refresh is + /** Flow of RefreshState, derived from the adapter's CombinedLoadState */ + val refreshState = adapter.loadStateFlow.asRefreshState() + + // Scroll the list down (peek) if a refresh has completely finished. A refresh is // finished when both the initial refresh is complete and any prepends have - // finished (so that DiffUtil has had a chance to process the data). See - // https://github.com/googlecodelabs/android-paging/issues/149 + // finished (so that DiffUtil has had a chance to process the data). launch { - var previousLoadState: CombinedLoadStates? = null - var activeRefresh = false - - if (isSwipeToRefreshEnabled) { - adapter.loadStateFlow - .withPresentationState() - .collect { (loadState, presentationState) -> - if (previousLoadState == null) { - previousLoadState = loadState - return@collect - } + if (!isSwipeToRefreshEnabled) return@launch - if (presentationState != PresentationState.PRESENTED) { - activeRefresh = true - } + /** True if the previous prepend resulted in a peek, false otherwise */ + var peeked = false + + /** ID of the item that was first in the adapter before the refresh */ + var previousFirstId: String? = null + + refreshState.collect { + when (it) { + // Refresh has started, reset peeked, and save the ID of the first item + // in the adapter + RefreshState.ACTIVE_REFRESH -> { + peeked = false + if (adapter.itemCount != 0) previousFirstId = adapter.peek(0)?.id + } + + // Refresh has finished, pages are being prepended. + RefreshState.PREPEND_COMPLETE -> { + // There might be multiple prepends after a refresh, only continue + // if one them has not already caused a peek. + if (peeked) return@collect + + // Compare the ID of the current first item with the previous first + // item. If they're the same then this prepend did not add any new + // items, and can be ignored. + val firstId = if (adapter.itemCount != 0) adapter.peek(0)?.id else null + if (previousFirstId == firstId) return@collect - if (presentationState == PresentationState.PRESENTED && activeRefresh) { - if (previousLoadState?.prepend is LoadState.Loading && loadState.prepend is LoadState.NotLoading) { - Log.d("loadState", "mediator.prepend=NotLoading, scrolling to peek") - binding.recyclerView.post { - getView() ?: return@post - binding.recyclerView.scrollBy(0, Utils.dpToPx(requireContext(), -30)) - } - activeRefresh = false - } + // New items were added and haven't peeked for this refresh. Schedule + // a scroll to disclose that new items are available. + binding.recyclerView.post { + getView() ?: return@post + binding.recyclerView.smoothScrollBy( + 0, + Utils.dpToPx(requireContext(), -30) + ) } - previousLoadState = loadState + peeked = true } + else -> { /* nothing to do */ } + } } } - // Update the UI from the combined load state - adapter.loadStateFlow - .withPresentationState() - .collect { (loadState, presentationState) -> - when (presentationState) { - PresentationState.INITIAL -> { } - PresentationState.REMOTE_LOADING -> { + // Manage the display of progress bars. Rather than hide them as soon as the + // Refresh portion completes, hide them when then first Prepend completes. This + // is a better signal to the user that it is now possible to scroll up and see + // new content. + launch { + refreshState.collect { + when (it) { + RefreshState.ACTIVE_REFRESH -> { if (adapter.itemCount == 0 && !binding.swipeRefreshLayout.isRefreshing) { binding.progressBar.show() } } - PresentationState.SOURCE_LOADING -> { } - PresentationState.ERROR -> { + RefreshState.PREPEND_COMPLETE, RefreshState.ERROR -> { binding.progressBar.hide() binding.swipeRefreshLayout.isRefreshing = false - val message = (loadState.refresh as LoadState.Error).error.getErrorString(requireContext()) - - // Show errors as a snackbar if there is existing content to show - // (either cached, or in the adapter), or as a full screen error - // otherwise. - if (adapter.itemCount > 0) { - snackbar = Snackbar.make( - (activity as ActionButtonActivity).actionButton ?: binding.root, - message, - Snackbar.LENGTH_INDEFINITE - ) - .setTextMaxLines(5) - .setAction(R.string.action_retry) { adapter.retry() } - snackbar!!.show() - } else { - val drawableRes = (loadState.refresh as LoadState.Error).error.getDrawableRes() - binding.statusView.setup(drawableRes, message) { - snackbar?.dismiss() - adapter.retry() - } - binding.statusView.show() - binding.recyclerView.hide() - } } - PresentationState.PRESENTED -> { - binding.progressBar.hide() - binding.swipeRefreshLayout.isRefreshing = false + else -> { /* nothing to do */ } + } + } + } - if (adapter.itemCount == 0) { - binding.statusView.setup( - R.drawable.elephant_friend_empty, - R.string.message_empty - ) - if (timelineKind == TimelineKind.Home) { - binding.statusView.showHelp(R.string.help_empty_home) - } - binding.statusView.show() - binding.recyclerView.hide() - } else { - binding.recyclerView.show() - binding.statusView.hide() + // Update the UI from the combined load state + adapter.loadStateFlow.withPresentationState().collect { (loadState, presentationState) -> + when (presentationState) { + PresentationState.ERROR -> { + val message = (loadState.refresh as LoadState.Error).error.getErrorString(requireContext()) + + // Show errors as a snackbar if there is existing content to show + // (either cached, or in the adapter), or as a full screen error + // otherwise. + if (adapter.itemCount > 0) { + snackbar = Snackbar.make( + (activity as ActionButtonActivity).actionButton ?: binding.root, + message, + Snackbar.LENGTH_INDEFINITE + ) + .setTextMaxLines(5) + .setAction(R.string.action_retry) { adapter.retry() } + snackbar!!.show() + } else { + val drawableRes = (loadState.refresh as LoadState.Error).error.getDrawableRes() + binding.statusView.setup(drawableRes, message) { + snackbar?.dismiss() + adapter.retry() } + binding.statusView.show() + binding.recyclerView.hide() } } + PresentationState.PRESENTED -> { + if (adapter.itemCount == 0) { + binding.statusView.setup( + R.drawable.elephant_friend_empty, + R.string.message_empty + ) + if (timelineKind == TimelineKind.Home) { + binding.statusView.showHelp(R.string.help_empty_home) + } + binding.statusView.show() + binding.recyclerView.hide() + } else { + binding.recyclerView.show() + binding.statusView.hide() + } + } + else -> { + // Nothing to do -- show/hiding the progress bars in non-error states + // is handled via refreshState. + } } + } } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelinePagingSource.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelinePagingSource.kt index ee5ff5e6f5..e46f2189ed 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelinePagingSource.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelinePagingSource.kt @@ -115,21 +115,33 @@ class NetworkTimelinePagingSource @Inject constructor( return INVALID } - return LoadResult.Page(page?.data ?: emptyList(), nextKey = page?.nextKey, prevKey = page?.prevKey) + // Calculate itemsBefore and itemsAfter values to include in the returned Page. + // If you do not do this (and this is not documented anywhere) then the anchorPosition + // in the PagingState (used in getRefreshKey) is bogus, and refreshing the list can + // result in large jumps in the user's position. + // + // The items are calculated relative to the local cache, not the remote data source. + val itemsBefore = page?.let { + pageCache.tailMap(it.data.first().id).values.fold(0) { sum, p -> sum + p.data.size } + } ?: 0 + val itemsAfter = page?.let { + pageCache.headMap(it.data.first().id).values.fold(0) { sum, p -> sum + p.data.size } + } ?: 0 + + return LoadResult.Page( + page?.data ?: emptyList(), + nextKey = page?.nextKey, + prevKey = page?.prevKey, + itemsAfter = itemsAfter, + itemsBefore = itemsBefore + ) } override fun getRefreshKey(state: PagingState): String? { - val refreshKey = if (state.anchorPosition != null) { - // In testing, state.anchorPosition always seems to be off by 2. I suspect that might - // be because of the load state header and footer that are on the list. If this is - // not corrected here then the user's reading position is *not* maintained as they - // scroll over a page boundary, and the list jumps up by two posts. Adding 2 here - // corrects for this. - state.closestItemToPosition(state.anchorPosition!! + 2)?.id - } else { - pageCache.firstEntry()?.value?.data?.let { data -> - data.getOrNull(data.size / 2)?.id - } + val refreshKey = state.anchorPosition?.let { + state.closestItemToPosition(it)?.id + } ?: pageCache.firstEntry()?.value?.data?.let { + it.getOrNull(it.size / 2)?.id } Log.d(TAG, "- getRefreshKey(), state.anchorPosition = ${state.anchorPosition}, return $refreshKey") diff --git a/app/src/main/java/com/keylesspalace/tusky/util/CombinedLoadStatesExtensions.kt b/app/src/main/java/com/keylesspalace/tusky/util/CombinedLoadStatesExtensions.kt index 5edcef7ed3..a98affe569 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/CombinedLoadStatesExtensions.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/CombinedLoadStatesExtensions.kt @@ -17,7 +17,6 @@ package com.keylesspalace.tusky.util -import android.util.Log import androidx.paging.CombinedLoadStates import androidx.paging.LoadState import com.keylesspalace.tusky.util.PresentationState.INITIAL @@ -26,6 +25,13 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.scan +/** + * Each [CombinedLoadStates] state does not contain enough information to understand the actual + * state unless previous states have been observed. + * + * This tracks those states and provides a [PresentationState] that describes whether the most + * recent refresh has presented the data via the associated adapter. + */ enum class PresentationState { /** Initial state, nothing is known about the load state */ INITIAL, @@ -76,19 +82,10 @@ enum class PresentationState { } /** - * [CombinedLoadStates] are stateful -- you can't fully interpret the meaning of the state unless - * previous states have been observed. This tracks those states and provides a [PresentationState] - * that describes whether the most recent refresh has presented the data via the associated adapter. - * - * @return Flow that combines the load state with its associated presentation state + * @return Flow that combines the [CombinedLoadStates] with its associated [PresentationState]. */ fun Flow.withPresentationState(): Flow> { - val TAG = "WithPresentationState" - val presentationStateFlow = scan(INITIAL) { state, loadState -> - Log.d(TAG, "state: $state") - Log.d(TAG, "loadState.mediator.refresh: ${loadState.mediator?.refresh}") - Log.d(TAG, "loadState.source.refresh: ${loadState.source.refresh}") state.next(loadState) } .distinctUntilChanged() @@ -97,3 +94,108 @@ fun Flow.withPresentationState(): Flow when (loadState.refresh) { + is LoadState.Loading -> ACTIVE_REFRESH + else -> this + } + ACTIVE_REFRESH -> when (loadState.refresh) { + is LoadState.NotLoading -> when (loadState.prepend) { + is LoadState.NotLoading -> PREPEND_COMPLETE + is LoadState.Loading -> REFRESH_COMPLETE + else -> this + } + else -> this + } + REFRESH_COMPLETE -> when (loadState.prepend) { + is LoadState.NotLoading -> PREPEND_COMPLETE + else -> this + } + PREPEND_COMPLETE -> when (loadState.refresh) { + is LoadState.Loading -> ACTIVE_REFRESH + else -> this + } + ERROR -> WAITING_FOR_REFRESH.next(loadState) + } + } +} + +fun Flow.asRefreshState(): Flow { + return scan(RefreshState.WAITING_FOR_REFRESH) { state, loadState -> + state.next(loadState) + } + .distinctUntilChanged() +} + +/** + * Debug helper that generates a string showing the effective difference between two [CombinedLoadStates]. + * + * @param prev the value to compare against + * @return A (possibly multi-line) string showing the fields that differed + */ +fun CombinedLoadStates.diff(prev: CombinedLoadStates?): String { + prev ?: return "" + + val result = mutableListOf() + + if (prev.refresh != refresh) { + result.add(".refresh ${prev.refresh} -> $refresh") + } + if (prev.source.refresh != source.refresh) { + result.add("\n .source.refresh ${prev.source.refresh} -> ${source.refresh}") + } + if (prev.mediator?.refresh != mediator?.refresh) { + result.add("\n .mediator.refresh ${prev.mediator?.refresh} -> ${mediator?.refresh}") + } + + if (prev.prepend != prepend) { + result.add(".prepend ${prev.prepend} -> $prepend") + } + if (prev.source.prepend != source.prepend) { + result.add("\n .source.prepend ${prev.source.prepend} -> ${source.prepend}") + } + if (prev.mediator?.prepend != mediator?.prepend) { + result.add("\n .mediator.prepend ${prev.mediator?.prepend} -> ${mediator?.prepend}") + } + + if (prev.append != append) { + result.add(".append ${prev.append} -> $append") + } + if (prev.source.append != source.append) { + result.add("\n .source.append ${prev.source.append} -> ${source.append}") + } + if (prev.mediator?.append != mediator?.append) { + result.add("\n .mediator.append ${prev.mediator?.append} -> ${mediator?.append}") + } + + return result.joinToString("\n") +} From a3567a2a55217d480d76a33cf776beefe30f6a5c Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Sun, 13 Aug 2023 20:33:52 +0200 Subject: [PATCH 146/156] Correctly pass `arguments` when creating a hashtag tab --- app/src/main/java/com/keylesspalace/tusky/TabData.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/java/com/keylesspalace/tusky/TabData.kt b/app/src/main/java/com/keylesspalace/tusky/TabData.kt index f9559ccd26..7df99da33c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/TabData.kt +++ b/app/src/main/java/com/keylesspalace/tusky/TabData.kt @@ -103,6 +103,7 @@ fun createTabDataFromId(id: String, arguments: List = emptyList()): TabD text = R.string.hashtags, icon = R.drawable.ic_hashtag, fragment = { args -> TimelineFragment.newInstance(TimelineKind.Tag(args)) }, + arguments = arguments, title = { context -> arguments.joinToString(separator = " ") { context.getString(R.string.title_tag, it) } } ) LIST -> TabData( From 5f8e8a9caa07be3a2ea8e0a85cbdb924f132f8d2 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Tue, 15 Aug 2023 12:30:39 +0200 Subject: [PATCH 147/156] Don't compare against the previous top ID, it might not be loaded in to the adapter at the time of the check --- .../tusky/components/timeline/TimelineFragment.kt | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt index be25d6ab83..32a189ac54 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt @@ -358,17 +358,11 @@ class TimelineFragment : /** True if the previous prepend resulted in a peek, false otherwise */ var peeked = false - /** ID of the item that was first in the adapter before the refresh */ - var previousFirstId: String? = null - refreshState.collect { when (it) { // Refresh has started, reset peeked, and save the ID of the first item // in the adapter - RefreshState.ACTIVE_REFRESH -> { - peeked = false - if (adapter.itemCount != 0) previousFirstId = adapter.peek(0)?.id - } + RefreshState.ACTIVE_REFRESH -> peeked = false // Refresh has finished, pages are being prepended. RefreshState.PREPEND_COMPLETE -> { @@ -376,12 +370,6 @@ class TimelineFragment : // if one them has not already caused a peek. if (peeked) return@collect - // Compare the ID of the current first item with the previous first - // item. If they're the same then this prepend did not add any new - // items, and can be ignored. - val firstId = if (adapter.itemCount != 0) adapter.peek(0)?.id else null - if (previousFirstId == firstId) return@collect - // New items were added and haven't peeked for this refresh. Schedule // a scroll to disclose that new items are available. binding.recyclerView.post { From d854f7007991360498455faaaf7fef1d54f81de0 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Tue, 15 Aug 2023 12:32:00 +0200 Subject: [PATCH 148/156] Track the first prepend more accurately --- .../tusky/util/CombinedLoadStatesExtensions.kt | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/util/CombinedLoadStatesExtensions.kt b/app/src/main/java/com/keylesspalace/tusky/util/CombinedLoadStatesExtensions.kt index a98affe569..1ab8728285 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/CombinedLoadStatesExtensions.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/CombinedLoadStatesExtensions.kt @@ -113,6 +113,9 @@ enum class RefreshState { /** A refresh has completed, there may be a followup prepend operations */ REFRESH_COMPLETE, + /** The first prepend is underway */ + ACTIVE_PREPEND, + /** The first prepend after a refresh has completed. There may be followup prepend operations */ PREPEND_COMPLETE, @@ -130,13 +133,23 @@ enum class RefreshState { } ACTIVE_REFRESH -> when (loadState.refresh) { is LoadState.NotLoading -> when (loadState.prepend) { - is LoadState.NotLoading -> PREPEND_COMPLETE is LoadState.Loading -> REFRESH_COMPLETE + // If prepend.endOfPaginationReached then the prepend is complete too. + // Otherwise, wait for the prepend to finish. + is LoadState.NotLoading -> if (loadState.prepend.endOfPaginationReached) { + PREPEND_COMPLETE + } else { + REFRESH_COMPLETE + } else -> this } else -> this } REFRESH_COMPLETE -> when (loadState.prepend) { + is LoadState.Loading -> ACTIVE_PREPEND + else -> this + } + ACTIVE_PREPEND -> when (loadState.prepend) { is LoadState.NotLoading -> PREPEND_COMPLETE else -> this } From 2a5d858a24f9696fd0e0adebefb45548c7192e5e Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Tue, 15 Aug 2023 14:21:46 +0200 Subject: [PATCH 149/156] Remove unnecessary \n --- .../tusky/util/CombinedLoadStatesExtensions.kt | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/util/CombinedLoadStatesExtensions.kt b/app/src/main/java/com/keylesspalace/tusky/util/CombinedLoadStatesExtensions.kt index 1ab8728285..a4a9ee54e3 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/CombinedLoadStatesExtensions.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/CombinedLoadStatesExtensions.kt @@ -184,30 +184,30 @@ fun CombinedLoadStates.diff(prev: CombinedLoadStates?): String { result.add(".refresh ${prev.refresh} -> $refresh") } if (prev.source.refresh != source.refresh) { - result.add("\n .source.refresh ${prev.source.refresh} -> ${source.refresh}") + result.add(" .source.refresh ${prev.source.refresh} -> ${source.refresh}") } if (prev.mediator?.refresh != mediator?.refresh) { - result.add("\n .mediator.refresh ${prev.mediator?.refresh} -> ${mediator?.refresh}") + result.add(" .mediator.refresh ${prev.mediator?.refresh} -> ${mediator?.refresh}") } if (prev.prepend != prepend) { result.add(".prepend ${prev.prepend} -> $prepend") } if (prev.source.prepend != source.prepend) { - result.add("\n .source.prepend ${prev.source.prepend} -> ${source.prepend}") + result.add(" .source.prepend ${prev.source.prepend} -> ${source.prepend}") } if (prev.mediator?.prepend != mediator?.prepend) { - result.add("\n .mediator.prepend ${prev.mediator?.prepend} -> ${mediator?.prepend}") + result.add(" .mediator.prepend ${prev.mediator?.prepend} -> ${mediator?.prepend}") } if (prev.append != append) { result.add(".append ${prev.append} -> $append") } if (prev.source.append != source.append) { - result.add("\n .source.append ${prev.source.append} -> ${source.append}") + result.add(" .source.append ${prev.source.append} -> ${source.append}") } if (prev.mediator?.append != mediator?.append) { - result.add("\n .mediator.append ${prev.mediator?.append} -> ${mediator?.append}") + result.add(" .mediator.append ${prev.mediator?.append} -> ${mediator?.append}") } return result.joinToString("\n") From 2a42b7a84b6d2df14821a00f90829d431c09939b Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Tue, 15 Aug 2023 18:53:00 +0200 Subject: [PATCH 150/156] Track source and mediator prepends --- .../components/timeline/TimelineFragment.kt | 18 +- .../util/CombinedLoadStatesExtensions.kt | 161 ++++++++++++------ 2 files changed, 122 insertions(+), 57 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt index 32a189ac54..c40ecbe635 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt @@ -63,7 +63,7 @@ import com.keylesspalace.tusky.interfaces.ReselectableFragment import com.keylesspalace.tusky.interfaces.StatusActionListener import com.keylesspalace.tusky.util.ListStatusAccessibilityDelegate import com.keylesspalace.tusky.util.PresentationState -import com.keylesspalace.tusky.util.RefreshState +import com.keylesspalace.tusky.util.UserRefreshState import com.keylesspalace.tusky.util.asRefreshState import com.keylesspalace.tusky.util.getDrawableRes import com.keylesspalace.tusky.util.getErrorString @@ -84,6 +84,7 @@ import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.take import kotlinx.coroutines.launch import javax.inject.Inject @@ -346,8 +347,10 @@ class TimelineFragment : } } - /** Flow of RefreshState, derived from the adapter's CombinedLoadState */ - val refreshState = adapter.loadStateFlow.asRefreshState() + /** + * StateFlow (to allow multiple consumers) of RefreshState, derived from the adapter's CombinedLoadState. + */ + val refreshState = adapter.loadStateFlow.asRefreshState().stateIn(lifecycleScope) // Scroll the list down (peek) if a refresh has completely finished. A refresh is // finished when both the initial refresh is complete and any prepends have @@ -362,10 +365,10 @@ class TimelineFragment : when (it) { // Refresh has started, reset peeked, and save the ID of the first item // in the adapter - RefreshState.ACTIVE_REFRESH -> peeked = false + UserRefreshState.ACTIVE -> peeked = false // Refresh has finished, pages are being prepended. - RefreshState.PREPEND_COMPLETE -> { + UserRefreshState.COMPLETE -> { // There might be multiple prepends after a refresh, only continue // if one them has not already caused a peek. if (peeked) return@collect @@ -374,6 +377,7 @@ class TimelineFragment : // a scroll to disclose that new items are available. binding.recyclerView.post { getView() ?: return@post + Log.d("RefreshState", "***peeking**") binding.recyclerView.smoothScrollBy( 0, Utils.dpToPx(requireContext(), -30) @@ -393,12 +397,12 @@ class TimelineFragment : launch { refreshState.collect { when (it) { - RefreshState.ACTIVE_REFRESH -> { + UserRefreshState.ACTIVE -> { if (adapter.itemCount == 0 && !binding.swipeRefreshLayout.isRefreshing) { binding.progressBar.show() } } - RefreshState.PREPEND_COMPLETE, RefreshState.ERROR -> { + UserRefreshState.COMPLETE, UserRefreshState.ERROR -> { binding.progressBar.hide() binding.swipeRefreshLayout.isRefreshing = false } diff --git a/app/src/main/java/com/keylesspalace/tusky/util/CombinedLoadStatesExtensions.kt b/app/src/main/java/com/keylesspalace/tusky/util/CombinedLoadStatesExtensions.kt index a4a9ee54e3..82c27b339c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/CombinedLoadStatesExtensions.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/CombinedLoadStatesExtensions.kt @@ -17,12 +17,15 @@ package com.keylesspalace.tusky.util +import android.util.Log import androidx.paging.CombinedLoadStates import androidx.paging.LoadState +import com.keylesspalace.tusky.BuildConfig import com.keylesspalace.tusky.util.PresentationState.INITIAL import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.scan /** @@ -95,76 +98,134 @@ fun Flow.withPresentationState(): Flow.asRefreshState(): Flow { + // Can't use CombinedLoadStates.refresh and .prepend on their own. In testing I've observed + // situations where: + // + // - the refresh completes before the prepend starts + // - the prepend starts before the refresh completes + // - the prepend *ends* before the refresh completes (but after the refresh starts) + // + // So you need to track the state of both the refresh and the prepend actions, and merge them + // in to a single state that answers the question "Has the refresh, and the first prepend + // started by that refresh, finished?" + // + // In addition, a prepend operation might involve both the mediator and the source, or only + // one of them. Since loadState.prepend tracks the mediator property this means a prepend that + // only modifies loadState.source will not be reflected in loadState.prepend. + // + // So the code also has to track whether the prepend transition was initiated by the mediator + // or the source property, and look for the end of the transition on the same property. - /** A refresh is underway */ - ACTIVE_REFRESH, + /** The state of the "refresh" portion of the user refresh */ + var refresh = UserRefreshState.WAITING - /** A refresh has completed, there may be a followup prepend operations */ - REFRESH_COMPLETE, + /** The state of the "prepend" portion of the user refresh */ + var prepend = UserRefreshState.WAITING - /** The first prepend is underway */ - ACTIVE_PREPEND, + /** True if the state of the prepend portion is derived from the mediator property */ + var usePrependMediator = false - /** The first prepend after a refresh has completed. There may be followup prepend operations */ - PREPEND_COMPLETE, + var previousLoadState: CombinedLoadStates? = null - /** A refresh or prepend operation was [LoadState.Error] */ - ERROR; + return map { loadState -> + // Debug helper, show the differences between successive load states. + if (BuildConfig.DEBUG) { + previousLoadState?.let { + val loadStateDiff = loadState.diff(previousLoadState) + Log.d("RefreshState", "Current state: $refresh $prepend") + if (loadStateDiff.isNotEmpty()) Log.d("RefreshState", loadStateDiff) + } + previousLoadState = loadState + } - fun next(loadState: CombinedLoadStates): RefreshState { - if (loadState.refresh is LoadState.Error) return ERROR - if (loadState.prepend is LoadState.Error) return ERROR + // Bail early on errors + if (loadState.refresh is LoadState.Error || loadState.prepend is LoadState.Error) { + refresh = UserRefreshState.WAITING + prepend = UserRefreshState.WAITING + return@map UserRefreshState.ERROR + } - return when (this) { - WAITING_FOR_REFRESH -> when (loadState.refresh) { - is LoadState.Loading -> ACTIVE_REFRESH - else -> this + // Handling loadState.refresh is straightforward + refresh = when (loadState.refresh) { + is LoadState.Loading -> if (refresh == UserRefreshState.WAITING) UserRefreshState.ACTIVE else refresh + is LoadState.NotLoading -> if (refresh == UserRefreshState.ACTIVE) UserRefreshState.COMPLETE else refresh + else -> { throw IllegalStateException("can't happen, LoadState.Error is already handled") } + } + + // Prepend can only transition to active if there is an active or complete refresh + // (i.e., the refresh is not WAITING). + if (refresh != UserRefreshState.WAITING && prepend == UserRefreshState.WAITING) { + if (loadState.mediator?.prepend is LoadState.Loading) { + usePrependMediator = true + prepend = UserRefreshState.ACTIVE } - ACTIVE_REFRESH -> when (loadState.refresh) { - is LoadState.NotLoading -> when (loadState.prepend) { - is LoadState.Loading -> REFRESH_COMPLETE - // If prepend.endOfPaginationReached then the prepend is complete too. - // Otherwise, wait for the prepend to finish. - is LoadState.NotLoading -> if (loadState.prepend.endOfPaginationReached) { - PREPEND_COMPLETE - } else { - REFRESH_COMPLETE - } - else -> this - } - else -> this + if (loadState.source.prepend is LoadState.Loading) { + usePrependMediator = false + prepend = UserRefreshState.ACTIVE } - REFRESH_COMPLETE -> when (loadState.prepend) { - is LoadState.Loading -> ACTIVE_PREPEND - else -> this + } + + if (prepend == UserRefreshState.ACTIVE) { + if (usePrependMediator && loadState.mediator?.prepend is LoadState.NotLoading) { + prepend = UserRefreshState.COMPLETE } - ACTIVE_PREPEND -> when (loadState.prepend) { - is LoadState.NotLoading -> PREPEND_COMPLETE - else -> this + if (!usePrependMediator && loadState.source.prepend is LoadState.NotLoading) { + prepend = UserRefreshState.COMPLETE } - PREPEND_COMPLETE -> when (loadState.refresh) { - is LoadState.Loading -> ACTIVE_REFRESH - else -> this - } - ERROR -> WAITING_FOR_REFRESH.next(loadState) } - } -} -fun Flow.asRefreshState(): Flow { - return scan(RefreshState.WAITING_FOR_REFRESH) { state, loadState -> - state.next(loadState) + // Determine the new user refresh state by combining the refresh and prepend states + // + // - If refresh and prepend are identical use the refresh value + // - If refresh is WAITING then the state is WAITING (waiting for a refresh implies waiting + // for a prepend too) + // - Otherwise, one of them is active (doesn't matter which), so the state is ACTIVE + val newUserRefreshState = when (refresh) { + prepend -> refresh + UserRefreshState.WAITING -> UserRefreshState.WAITING + else -> UserRefreshState.ACTIVE + } + + // If the new state is COMPLETE reset the individual states back to WAITING, ready for + // the next user refresh. + if (newUserRefreshState == UserRefreshState.COMPLETE) { + refresh = UserRefreshState.WAITING + prepend = UserRefreshState.WAITING + } + + return@map newUserRefreshState } .distinctUntilChanged() } From f325a8318ac5dde3de1db099fddd7441c565fa39 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Wed, 16 Aug 2023 19:34:03 +0200 Subject: [PATCH 151/156] Revert "Don't compare against the previous top ID, it might not be loaded in to the adapter at the time of the check" This reverts commit 5f8e8a9caa07be3a2ea8e0a85cbdb924f132f8d2. This seems to work better with the new prepend/refresh code --- .../tusky/components/timeline/TimelineFragment.kt | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt index c40ecbe635..2feabae166 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt @@ -361,11 +361,17 @@ class TimelineFragment : /** True if the previous prepend resulted in a peek, false otherwise */ var peeked = false + /** ID of the item that was first in the adapter before the refresh */ + var previousFirstId: String? = null + refreshState.collect { when (it) { // Refresh has started, reset peeked, and save the ID of the first item // in the adapter - UserRefreshState.ACTIVE -> peeked = false + UserRefreshState.ACTIVE -> { + peeked = false + if (adapter.itemCount != 0) previousFirstId = adapter.peek(0)?.id + } // Refresh has finished, pages are being prepended. UserRefreshState.COMPLETE -> { @@ -373,6 +379,12 @@ class TimelineFragment : // if one them has not already caused a peek. if (peeked) return@collect + // Compare the ID of the current first item with the previous first + // item. If they're the same then this prepend did not add any new + // items, and can be ignored. + val firstId = if (adapter.itemCount != 0) adapter.peek(0)?.id else null + if (previousFirstId == firstId) return@collect + // New items were added and haven't peeked for this refresh. Schedule // a scroll to disclose that new items are available. binding.recyclerView.post { From 452468120a7f7ed1b36fffcc429b6e21b37a0985 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Wed, 16 Aug 2023 23:55:35 +0200 Subject: [PATCH 152/156] Remove "huge page" support now that prepend isn't jumping all over the place --- .../timeline/NetworkTimelineRepository.kt | 2 +- .../NetworkTimelineRemoteMediator.kt | 40 +------------------ 2 files changed, 2 insertions(+), 40 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRepository.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRepository.kt index 45d6e97780..04226bd34f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRepository.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRepository.kt @@ -91,7 +91,7 @@ class NetworkTimelineRepository @Inject constructor( } return Pager( - config = PagingConfig(pageSize = pageSize, initialLoadSize = pageSize), + config = PagingConfig(pageSize = pageSize), remoteMediator = NetworkTimelineRemoteMediator( viewModelScope, mastodonApi, diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt index b1d6889587..75e9f28390 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt @@ -29,7 +29,6 @@ import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.network.MastodonApi import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.async import retrofit2.HttpException import retrofit2.Response import java.io.IOException @@ -82,44 +81,7 @@ class NetworkTimelineRemoteMediator( Log.d(TAG, "- load(), type = $loadType, key = $key") val response = fetchStatusPageByKind(loadType, key, state.config.initialLoadSize) - var page = Page.tryFrom(response).getOrElse { return MediatorResult.Error(it) } - - // If doing a refresh with a known key Paging3 wants you to load "around" the requested - // key, so that it can show the item with the key in the view as well as context before - // and after it. - // - // To ensure that the first page loaded after a refresh is big enough load the page - // immediately before and the page immediately after as well, and merge the three of - // them in to one large page. - if (loadType == LoadType.REFRESH && key != null) { - Log.d(TAG, " Refresh with non-null key, creating huge page") - val prevPageJob = viewModelScope.async { - page.prevKey?.let { key -> - fetchStatusPageByKind(LoadType.PREPEND, key, state.config.initialLoadSize) - } - } - val nextPageJob = viewModelScope.async { - page.nextKey?.let { key -> - fetchStatusPageByKind(LoadType.APPEND, key, state.config.initialLoadSize) - } - } - val prevPage = prevPageJob.await() - ?.let { Page.tryFrom(it).getOrElse { return MediatorResult.Error(it) } } - val nextPage = nextPageJob.await() - ?.let { Page.tryFrom(it).getOrElse { return MediatorResult.Error(it) } } - Log.d(TAG, " prevPage: $prevPage") - Log.d(TAG, " midPage: $page") - Log.d(TAG, " nextPage: $nextPage") - page = page.merge(prevPage, nextPage) - - if (BuildConfig.DEBUG) { - // Verify page contains the expected key - state.anchorPosition?.let { state.closestItemToPosition(it) }?.id?.let { itemId -> - page.data.find { it.id == itemId } - ?: throw IllegalStateException("Fetched page with $key, it does not contain $itemId") - } - } - } + val page = Page.tryFrom(response).getOrElse { return MediatorResult.Error(it) } val endOfPaginationReached = page.data.isEmpty() if (!endOfPaginationReached) { From 9998621732641df18fe6bffb836c8aae2cfd70dd Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Thu, 17 Aug 2023 17:48:44 +0200 Subject: [PATCH 153/156] Make PageCache.debug() easier to optimise out --- .../components/timeline/viewmodel/PageCache.kt | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/PageCache.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/PageCache.kt index f618defa62..e39e45e85c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/PageCache.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/PageCache.kt @@ -157,12 +157,14 @@ class PageCache : TreeMap(compareBy({ it.length }, { it })) { * Logs the current state of the cache */ fun debug() { - Log.d(TAG, "Page cache state:") - if (this.isEmpty()) { - Log.d(TAG, " ** empty **") - } else { - this.onEachIndexed { index, entry -> - Log.d(TAG, " $index: ${entry.value}") + if (BuildConfig.DEBUG) { // Makes it easier for Proguard to optimise this out + Log.d(TAG, "Page cache state:") + if (this.isEmpty()) { + Log.d(TAG, " ** empty **") + } else { + this.onEachIndexed { index, entry -> + Log.d(TAG, " $index: ${entry.value}") + } } } } From e0b38e698ec1d1e2d987d21a2481c28bbb3f9e7c Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Thu, 17 Aug 2023 18:00:43 +0200 Subject: [PATCH 154/156] Refresh cached timeline from close to the current key, not the top --- .../viewmodel/CachedTimelineRemoteMediator.kt | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt index 26504e8bc7..a6e1697eb2 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt @@ -64,15 +64,9 @@ class CachedTimelineRemoteMediator( return try { val response = when (loadType) { LoadType.REFRESH -> { - val rke = db.withTransaction { - remoteKeyDao.remoteKeyForKind( - activeAccount.id, - TIMELINE_ID, - RemoteKeyKind.PREV - ) - } - Log.d(TAG, "Loading from remoteKey: $rke") - api.homeTimeline(minId = rke?.key, limit = state.config.pageSize) + val key = state.anchorPosition?.let { state.closestItemToPosition(it) }?.status?.serverId + Log.d(TAG, "Loading from item close to current position: $key") + api.homeTimeline(minId = key, limit = state.config.pageSize) } LoadType.APPEND -> { val rke = db.withTransaction { From 5d0b3015fcc2fc208745ce16f084c71bd30d2530 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Thu, 17 Aug 2023 22:36:32 +0200 Subject: [PATCH 155/156] Start/stop UI coroutines on the viewlifecycle Previous code started/stopped on the fragment lifecycle. So if you left the fragment the UI code would continue running (collecting from flows, ticking to update an invisible UI, etc). --- .../notifications/NotificationsFragment.kt | 2 +- .../components/timeline/TimelineFragment.kt | 276 +++++++++--------- 2 files changed, 143 insertions(+), 135 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt index 433e4286f5..bd65709907 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt @@ -246,7 +246,7 @@ class NotificationsFragment : } viewLifecycleOwner.lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.STARTED) { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { launch { viewModel.pagingData.collectLatest { pagingData -> Log.d(TAG, "Submitting data to adapter") diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt index 2feabae166..0df7cdd2e9 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt @@ -199,107 +199,105 @@ class TimelineFragment : } viewLifecycleOwner.lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.STARTED) { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { launch { - viewLifecycleOwner.lifecycleScope.launch { - viewModel.statuses.collectLatest { pagingData -> - adapter.submitData(pagingData) - } + viewModel.statuses.collectLatest { pagingData -> + adapter.submitData(pagingData) } + } - // Show errors from the view model as snack bars. - // - // Errors are shown: - // - Indefinitely, so the user has a chance to read and understand - // the message - // - With a max of 5 text lines, to allow space for longer errors. - // E.g., on a typical device, an error message like "Bookmarking - // post failed: Unable to resolve host 'mastodon.social': No - // address associated with hostname" is 3 lines. - // - With a "Retry" option if the error included a UiAction to retry. - // TODO: Very similar to same code in NotificationsFragment - launch { - viewModel.uiError.collect { error -> - Log.d(TAG, error.toString()) - val message = getString( - error.message, - error.throwable.localizedMessage - ?: getString(R.string.ui_error_unknown) - ) - snackbar = Snackbar.make( - // Without this the FAB will not move out of the way - (activity as ActionButtonActivity).actionButton ?: binding.root, - message, - Snackbar.LENGTH_INDEFINITE - ).setTextMaxLines(5) - error.action?.let { action -> - snackbar!!.setAction(R.string.action_retry) { - viewModel.accept(action) - } - } - snackbar!!.show() - - // The status view has pre-emptively updated its state to show - // that the action succeeded. Since it hasn't, re-bind the view - // to show the correct data. - error.action?.let { action -> - if (action !is StatusAction) return@let - - adapter.snapshot() - .indexOfFirst { it?.id == action.statusViewData.id } - .takeIf { it != RecyclerView.NO_POSITION } - ?.let { adapter.notifyItemChanged(it) } + // Show errors from the view model as snack bars. + // + // Errors are shown: + // - Indefinitely, so the user has a chance to read and understand + // the message + // - With a max of 5 text lines, to allow space for longer errors. + // E.g., on a typical device, an error message like "Bookmarking + // post failed: Unable to resolve host 'mastodon.social': No + // address associated with hostname" is 3 lines. + // - With a "Retry" option if the error included a UiAction to retry. + // TODO: Very similar to same code in NotificationsFragment + launch { + viewModel.uiError.collect { error -> + Log.d(TAG, error.toString()) + val message = getString( + error.message, + error.throwable.localizedMessage + ?: getString(R.string.ui_error_unknown) + ) + snackbar = Snackbar.make( + // Without this the FAB will not move out of the way + (activity as ActionButtonActivity).actionButton ?: binding.root, + message, + Snackbar.LENGTH_INDEFINITE + ).setTextMaxLines(5) + error.action?.let { action -> + snackbar!!.setAction(R.string.action_retry) { + viewModel.accept(action) } } + snackbar!!.show() + + // The status view has pre-emptively updated its state to show + // that the action succeeded. Since it hasn't, re-bind the view + // to show the correct data. + error.action?.let { action -> + if (action !is StatusAction) return@let + + adapter.snapshot() + .indexOfFirst { it?.id == action.statusViewData.id } + .takeIf { it != RecyclerView.NO_POSITION } + ?.let { adapter.notifyItemChanged(it) } + } } + } - // Update adapter data when status actions are successful, and re-bind to update - // the UI. - launch { - viewModel.uiSuccess - .filterIsInstance() - .collect { - val indexedViewData = adapter.snapshot() - .withIndex() - .firstOrNull { indexed -> - indexed.value?.id == it.action.statusViewData.id - } ?: return@collect - - val statusViewData = - indexedViewData.value ?: return@collect - - val status = when (it) { - is StatusActionSuccess.Bookmark -> - statusViewData.status.copy(bookmarked = it.action.state) - is StatusActionSuccess.Favourite -> - statusViewData.status.copy(favourited = it.action.state) - is StatusActionSuccess.Reblog -> - statusViewData.status.copy(reblogged = it.action.state) - is StatusActionSuccess.VoteInPoll -> - statusViewData.status.copy( - poll = it.action.poll.votedCopy(it.action.choices) - ) - } - (indexedViewData.value as StatusViewData).status = status - - adapter.notifyItemChanged(indexedViewData.index) + // Update adapter data when status actions are successful, and re-bind to update + // the UI. + launch { + viewModel.uiSuccess + .filterIsInstance() + .collect { + val indexedViewData = adapter.snapshot() + .withIndex() + .firstOrNull { indexed -> + indexed.value?.id == it.action.statusViewData.id + } ?: return@collect + + val statusViewData = + indexedViewData.value ?: return@collect + + val status = when (it) { + is StatusActionSuccess.Bookmark -> + statusViewData.status.copy(bookmarked = it.action.state) + is StatusActionSuccess.Favourite -> + statusViewData.status.copy(favourited = it.action.state) + is StatusActionSuccess.Reblog -> + statusViewData.status.copy(reblogged = it.action.state) + is StatusActionSuccess.VoteInPoll -> + statusViewData.status.copy( + poll = it.action.poll.votedCopy(it.action.choices) + ) } - } + (indexedViewData.value as StatusViewData).status = status - // Refresh adapter on mutes and blocks - launch { - viewModel.uiSuccess.collectLatest { - when (it) { - is UiSuccess.Block, - is UiSuccess.Mute, - is UiSuccess.MuteConversation -> - adapter.refresh() + adapter.notifyItemChanged(indexedViewData.index) + } + } + + // Refresh adapter on mutes and blocks + launch { + viewModel.uiSuccess.collectLatest { + when (it) { + is UiSuccess.Block, + is UiSuccess.Mute, + is UiSuccess.MuteConversation -> + adapter.refresh() - is UiSuccess.StatusSent -> handleStatusSentOrEdit(it.status) - is UiSuccess.StatusEdited -> handleStatusSentOrEdit(it.status) + is UiSuccess.StatusSent -> handleStatusSentOrEdit(it.status) + is UiSuccess.StatusEdited -> handleStatusSentOrEdit(it.status) - else -> { /* nothing to do */ } - } + else -> { /* nothing to do */ } } } } @@ -424,54 +422,64 @@ class TimelineFragment : } // Update the UI from the combined load state - adapter.loadStateFlow.withPresentationState().collect { (loadState, presentationState) -> - when (presentationState) { - PresentationState.ERROR -> { - val message = (loadState.refresh as LoadState.Error).error.getErrorString(requireContext()) - - // Show errors as a snackbar if there is existing content to show - // (either cached, or in the adapter), or as a full screen error - // otherwise. - if (adapter.itemCount > 0) { - snackbar = Snackbar.make( - (activity as ActionButtonActivity).actionButton ?: binding.root, - message, - Snackbar.LENGTH_INDEFINITE - ) - .setTextMaxLines(5) - .setAction(R.string.action_retry) { adapter.retry() } - snackbar!!.show() - } else { - val drawableRes = (loadState.refresh as LoadState.Error).error.getDrawableRes() - binding.statusView.setup(drawableRes, message) { - snackbar?.dismiss() - adapter.retry() + launch { + adapter.loadStateFlow.withPresentationState() + .collect { (loadState, presentationState) -> + when (presentationState) { + PresentationState.ERROR -> { + val message = + (loadState.refresh as LoadState.Error).error.getErrorString( + requireContext() + ) + + // Show errors as a snackbar if there is existing content to show + // (either cached, or in the adapter), or as a full screen error + // otherwise. + if (adapter.itemCount > 0) { + snackbar = Snackbar.make( + (activity as ActionButtonActivity).actionButton + ?: binding.root, + message, + Snackbar.LENGTH_INDEFINITE + ) + .setTextMaxLines(5) + .setAction(R.string.action_retry) { adapter.retry() } + snackbar!!.show() + } else { + val drawableRes = + (loadState.refresh as LoadState.Error).error.getDrawableRes() + binding.statusView.setup(drawableRes, message) { + snackbar?.dismiss() + adapter.retry() + } + binding.statusView.show() + binding.recyclerView.hide() + } } - binding.statusView.show() - binding.recyclerView.hide() - } - } - PresentationState.PRESENTED -> { - if (adapter.itemCount == 0) { - binding.statusView.setup( - R.drawable.elephant_friend_empty, - R.string.message_empty - ) - if (timelineKind == TimelineKind.Home) { - binding.statusView.showHelp(R.string.help_empty_home) + + PresentationState.PRESENTED -> { + if (adapter.itemCount == 0) { + binding.statusView.setup( + R.drawable.elephant_friend_empty, + R.string.message_empty + ) + if (timelineKind == TimelineKind.Home) { + binding.statusView.showHelp(R.string.help_empty_home) + } + binding.statusView.show() + binding.recyclerView.hide() + } else { + binding.recyclerView.show() + binding.statusView.hide() + } + } + + else -> { + // Nothing to do -- show/hiding the progress bars in non-error states + // is handled via refreshState. } - binding.statusView.show() - binding.recyclerView.hide() - } else { - binding.recyclerView.show() - binding.statusView.hide() } } - else -> { - // Nothing to do -- show/hiding the progress bars in non-error states - // is handled via refreshState. - } - } } } } From cc65333d3308d5e2a2461bf22c14c7d8dd7d1387 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Thu, 17 Aug 2023 22:46:35 +0200 Subject: [PATCH 156/156] Copy the peeking and progress bar behaviour to NotificationsFragment --- .../notifications/NotificationsFragment.kt | 96 +++++++++++++++---- .../components/timeline/TimelineFragment.kt | 5 +- 2 files changed, 76 insertions(+), 25 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt index bd65709907..9b61031048 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt @@ -63,6 +63,8 @@ import com.keylesspalace.tusky.interfaces.ActionButtonActivity import com.keylesspalace.tusky.interfaces.ReselectableFragment import com.keylesspalace.tusky.interfaces.StatusActionListener import com.keylesspalace.tusky.util.ListStatusAccessibilityDelegate +import com.keylesspalace.tusky.util.UserRefreshState +import com.keylesspalace.tusky.util.asRefreshState import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.openLink import com.keylesspalace.tusky.util.show @@ -79,6 +81,7 @@ import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject @@ -221,21 +224,7 @@ class NotificationsFragment : (binding.recyclerView.itemAnimator as SimpleItemAnimator?)!!.supportsChangeAnimations = false - // Signal the user that a refresh has loaded new items above their current position - // by scrolling up slightly to disclose the new content - adapter.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() { - override fun onItemRangeInserted(positionStart: Int, itemCount: Int) { - if (positionStart == 0 && adapter.itemCount != itemCount) { - binding.recyclerView.post { - if (getView() != null) { - binding.recyclerView.scrollBy(0, Utils.dpToPx(requireContext(), -30)) - } - } - } - } - }) - - // update post timestamps + // Update post timestamps val updateTimestampFlow = flow { while (true) { delay(60000) @@ -391,15 +380,80 @@ class NotificationsFragment : } } + /** StateFlow (to allow multiple consumers) of UserRefreshState */ + val refreshState = adapter.loadStateFlow.asRefreshState().stateIn(lifecycleScope) + + // Scroll the list down (peek) if a refresh has completely finished. A refresh is + // finished when both the initial refresh is complete and any prepends have + // finished (so that DiffUtil has had a chance to process the data). + launch { + /** True if the previous prepend resulted in a peek, false otherwise */ + var peeked = false + + /** ID of the item that was first in the adapter before the refresh */ + var previousFirstId: String? = null + + refreshState.collect { + when (it) { + // Refresh has started, reset peeked, and save the ID of the first item + // in the adapter + UserRefreshState.ACTIVE -> { + peeked = false + if (adapter.itemCount != 0) previousFirstId = adapter.peek(0)?.id + } + + // Refresh has finished, pages are being prepended. + UserRefreshState.COMPLETE -> { + // There might be multiple prepends after a refresh, only continue + // if one them has not already caused a peek. + if (peeked) return@collect + + // Compare the ID of the current first item with the previous first + // item. If they're the same then this prepend did not add any new + // items, and can be ignored. + val firstId = if (adapter.itemCount != 0) adapter.peek(0)?.id else null + if (previousFirstId == firstId) return@collect + + // New items were added and haven't peeked for this refresh. Schedule + // a scroll to disclose that new items are available. + binding.recyclerView.post { + getView() ?: return@post + binding.recyclerView.smoothScrollBy( + 0, + Utils.dpToPx(requireContext(), -30) + ) + } + peeked = true + } + else -> { /* nothing to do */ } + } + } + } + + // Manage the display of progress bars. Rather than hide them as soon as the + // Refresh portion completes, hide them when then first Prepend completes. This + // is a better signal to the user that it is now possible to scroll up and see + // new content. + launch { + refreshState.collect { + when (it) { + UserRefreshState.ACTIVE -> { + if (adapter.itemCount == 0 && !binding.swipeRefreshLayout.isRefreshing) { + binding.progressBar.show() + } + } + UserRefreshState.COMPLETE, UserRefreshState.ERROR -> { + binding.progressBar.hide() + binding.swipeRefreshLayout.isRefreshing = false + } + else -> { /* nothing to do */ } + } + } + } + // Update the UI from the loadState adapter.loadStateFlow .collect { loadState -> - binding.recyclerView.show() - binding.progressBar.isVisible = loadState.refresh is LoadState.Loading && - !binding.swipeRefreshLayout.isRefreshing - binding.swipeRefreshLayout.isRefreshing = - loadState.refresh is LoadState.Loading && !binding.progressBar.isVisible - binding.statusView.hide() if (loadState.refresh is LoadState.NotLoading) { if (adapter.itemCount == 0) { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt index 0df7cdd2e9..b050ba92d7 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt @@ -345,9 +345,7 @@ class TimelineFragment : } } - /** - * StateFlow (to allow multiple consumers) of RefreshState, derived from the adapter's CombinedLoadState. - */ + /** StateFlow (to allow multiple consumers) of UserRefreshState */ val refreshState = adapter.loadStateFlow.asRefreshState().stateIn(lifecycleScope) // Scroll the list down (peek) if a refresh has completely finished. A refresh is @@ -387,7 +385,6 @@ class TimelineFragment : // a scroll to disclose that new items are available. binding.recyclerView.post { getView() ?: return@post - Log.d("RefreshState", "***peeking**") binding.recyclerView.smoothScrollBy( 0, Utils.dpToPx(requireContext(), -30)