/* 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 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 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.timeline.Placeholder import com.keylesspalace.tusky.components.timeline.toEntity 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.entity.Poll import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.network.FilterModel import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.TimelineCases import com.keylesspalace.tusky.viewdata.StatusViewData import kotlinx.coroutines.delay import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.coroutines.rx3.await import net.accelf.yuito.streaming.StreamingManager import retrofit2.HttpException import javax.inject.Inject import kotlin.time.DurationUnit import kotlin.time.toDuration /** * TimelineViewModel that caches all statuses in a local database */ class CachedTimelineViewModel @Inject constructor( timelineCases: TimelineCases, private val api: MastodonApi, eventHub: EventHub, accountManager: AccountManager, sharedPreferences: SharedPreferences, filterModel: FilterModel, private val db: AppDatabase, private val gson: Gson, streamingManager: StreamingManager, ) : TimelineViewModel(timelineCases, api, eventHub, accountManager, sharedPreferences, filterModel, streamingManager) { @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) { EmptyTimelinePagingSource() } else { db.timelineDao().getStatuses(activeAccount.id) } } ).flow .map { pagingData -> pagingData.map { timelineStatus -> timelineStatus.toViewData(gson) } } .map { pagingData -> pagingData.filter { statusViewData -> !shouldFilterStatus(statusViewData) } } .cachedIn(viewModelScope) init { 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.Concrete) { // handled by CacheUpdater } override fun changeExpanded(expanded: Boolean, status: StatusViewData.Concrete) { viewModelScope.launch { db.timelineDao().setExpanded(accountManager.activeAccount!!.id, status.id, expanded) } } override fun changeContentShowing(isShowing: Boolean, status: StatusViewData.Concrete) { viewModelScope.launch { db.timelineDao().setContentShowing(accountManager.activeAccount!!.id, status.id, isShowing) } } override fun changeContentCollapsed(isCollapsed: Boolean, status: StatusViewData.Concrete) { viewModelScope.launch { db.timelineDao().setContentCollapsed(accountManager.activeAccount!!.id, status.id, isCollapsed) } } override fun removeAllByAccountId(accountId: String) { viewModelScope.launch { db.timelineDao().removeAllByUser(accountManager.activeAccount!!.id, accountId) } } override fun removeAllByInstance(instance: String) { viewModelScope.launch { db.timelineDao().deleteAllFromInstance(accountManager.activeAccount!!.id, instance) } } override fun removeStatusWithId(id: String) { // 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 nextPlaceholderId = timelineDao.getNextPlaceholderIdAfter(activeAccount.id, placeholderId) api.homeTimeline(maxId = idAbovePlaceholder, sinceId = nextPlaceholderId, limit = LOAD_AT_ONCE) }.await() 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 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) ) } } } 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 } override fun handleFavEvent(favEvent: FavoriteEvent) { // handled by CacheUpdater } override fun handleBookmarkEvent(bookmarkEvent: BookmarkEvent) { // handled by CacheUpdater } override fun handlePinEvent(pinEvent: PinEvent) { // handled by CacheUpdater } override fun handleStreamUpdateEvent(status: Status) { viewModelScope.launch { val timelineDao = db.timelineDao() val activeAccount = accountManager.activeAccount!! db.withTransaction { if (isFirstOfStreaming) { timelineDao.insertStatus(Placeholder(status.id, loading = false).toEntity(activeAccount.id)) isFirstOfStreaming = false return@withTransaction } timelineDao.insertAccount(status.account.toEntity(activeAccount.id, gson)) status.reblog?.account?.toEntity(activeAccount.id, gson)?.let { rebloggedAccount -> timelineDao.insertAccount(rebloggedAccount) } timelineDao.insertStatus( status.toEntity( activeAccount.id, gson, expanded = activeAccount.alwaysOpenSpoiler, contentShowing = activeAccount.alwaysShowSensitiveMedia || !status.actionableStatus.sensitive, contentCollapsed = true ) ) } } } override fun fullReload() { viewModelScope.launch { val activeAccount = accountManager.activeAccount!! db.timelineDao().removeAll(activeAccount.id) } } companion object { private const val MAX_STATUSES_IN_CACHE = 1000 } }