diff --git a/app/build.gradle b/app/build.gradle index bd59a39e0..4e98fe819 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -175,4 +175,6 @@ dependencies { androidTestImplementation "androidx.room:room-testing:$roomVersion" androidTestImplementation "androidx.test.ext:junit:1.1.2" testImplementation "androidx.arch.core:core-testing:2.1.0" + + testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.5.0' } diff --git a/app/schemas/com.keylesspalace.tusky.db.AppDatabase/28.json b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/28.json new file mode 100644 index 000000000..c9e9b3702 --- /dev/null +++ b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/28.json @@ -0,0 +1,777 @@ +{ + "formatVersion": 1, + "database": { + "version": 28, + "identityHash": "867026e095d84652026e902709389c00", + "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)", + "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 + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "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, `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, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `alwaysOpenSpoiler` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `activeNotifications` TEXT NOT NULL, `emojis` TEXT NOT NULL, `tabPreferences` TEXT NOT NULL, `notificationsFilter` TEXT NOT NULL)", + "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": "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": "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": "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": "activeNotifications", + "columnName": "activeNotifications", + "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 + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_AccountEntity_domain_accountId", + "unique": true, + "columnNames": [ + "domain", + "accountId" + ], + "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, `version` TEXT, 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": "version", + "columnName": "version", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "instance" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "TimelineStatusEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `url` TEXT, `timelineUserId` INTEGER NOT NULL, `authorServerId` TEXT, `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, 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": false + }, + { + "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": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogsCount", + "columnName": "reblogsCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favouritesCount", + "columnName": "favouritesCount", + "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": "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 + } + ], + "primaryKey": { + "columnNames": [ + "serverId", + "timelineUserId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_TimelineStatusEntity_authorServerId_timelineUserId", + "unique": false, + "columnNames": [ + "authorServerId", + "timelineUserId" + ], + "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": { + "columnNames": [ + "serverId", + "timelineUserId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ConversationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT 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_emojis` TEXT NOT NULL, `s_favouritesCount` 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_showingHiddenContent` INTEGER NOT NULL, `s_expanded` INTEGER NOT NULL, `s_collapsible` INTEGER NOT NULL, `s_collapsed` INTEGER NOT NULL, `s_muted` INTEGER NOT NULL, `s_poll` TEXT, PRIMARY KEY(`id`, `accountId`))", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "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.emojis", + "columnName": "s_emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.favouritesCount", + "columnName": "s_favouritesCount", + "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.showingHiddenContent", + "columnName": "s_showingHiddenContent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.expanded", + "columnName": "s_expanded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.collapsible", + "columnName": "s_collapsible", + "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 + } + ], + "primaryKey": { + "columnNames": [ + "id", + "accountId" + ], + "autoGenerate": false + }, + "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, '867026e095d84652026e902709389c00')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/keylesspalace/tusky/TimelineDAOTest.kt b/app/src/androidTest/java/com/keylesspalace/tusky/TimelineDAOTest.kt deleted file mode 100644 index c4959b3ab..000000000 --- a/app/src/androidTest/java/com/keylesspalace/tusky/TimelineDAOTest.kt +++ /dev/null @@ -1,253 +0,0 @@ -package com.keylesspalace.tusky - -import androidx.room.Room -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.platform.app.InstrumentationRegistry -import com.keylesspalace.tusky.components.timeline.TimelineRepository -import com.keylesspalace.tusky.db.AppDatabase -import com.keylesspalace.tusky.db.TimelineAccountEntity -import com.keylesspalace.tusky.db.TimelineDao -import com.keylesspalace.tusky.db.TimelineStatusEntity -import com.keylesspalace.tusky.db.TimelineStatusWithAccount -import com.keylesspalace.tusky.entity.Status -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 - -@RunWith(AndroidJUnit4::class) -class TimelineDAOTest { - private lateinit var timelineDao: TimelineDao - private lateinit var db: AppDatabase - - @Before - fun createDb() { - val context = InstrumentationRegistry.getInstrumentation().targetContext - db = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java).build() - timelineDao = db.timelineDao() - } - - @After - fun closeDb() { - db.close() - } - - @Test - fun insertGetStatus() { - val setOne = makeStatus(statusId = 3) - val setTwo = makeStatus(statusId = 20, reblog = true) - val ignoredOne = makeStatus(statusId = 1) - val ignoredTwo = makeStatus(accountId = 2) - - for ((status, author, reblogger) in listOf(setOne, setTwo, ignoredOne, ignoredTwo)) { - timelineDao.insertInTransaction(status, author, reblogger) - } - - val resultsFromDb = timelineDao.getStatusesForAccount( - setOne.first.timelineUserId, - maxId = "21", sinceId = ignoredOne.first.serverId, limit = 10 - ) - .blockingGet() - - assertEquals(2, resultsFromDb.size) - for ((set, fromDb) in listOf(setTwo, setOne).zip(resultsFromDb)) { - val (status, author, reblogger) = set - assertEquals(status, fromDb.status) - assertEquals(author, fromDb.account) - assertEquals(reblogger, fromDb.reblogAccount) - } - } - - @Test - fun doNotOverwrite() { - val (status, author) = makeStatus() - timelineDao.insertInTransaction(status, author, null) - - val placeholder = createPlaceholder(status.serverId, status.timelineUserId) - - timelineDao.insertStatusIfNotThere(placeholder) - - val fromDb = timelineDao.getStatusesForAccount(status.timelineUserId, null, null, 10) - .blockingGet() - val result = fromDb.first() - - assertEquals(1, fromDb.size) - assertEquals(author, result.account) - assertEquals(status, result.status) - assertNull(result.reblogAccount) - } - - @Test - fun cleanup() { - val now = System.currentTimeMillis() - val oldDate = now - TimelineRepository.CLEANUP_INTERVAL - 20_000 - val oldThisAccount = makeStatus( - statusId = 5, - createdAt = oldDate - ) - val oldAnotherAccount = makeStatus( - statusId = 10, - createdAt = oldDate, - accountId = 2 - ) - val recentThisAccount = makeStatus( - statusId = 30, - createdAt = System.currentTimeMillis() - ) - val recentAnotherAccount = makeStatus( - statusId = 60, - createdAt = System.currentTimeMillis(), - accountId = 2 - ) - - for ((status, author, reblogAuthor) in listOf(oldThisAccount, oldAnotherAccount, recentThisAccount, recentAnotherAccount)) { - timelineDao.insertInTransaction(status, author, reblogAuthor) - } - - timelineDao.cleanup(now - TimelineRepository.CLEANUP_INTERVAL) - - assertEquals( - listOf(recentThisAccount), - timelineDao.getStatusesForAccount(1, null, null, 100).blockingGet() - .map { it.toTriple() } - ) - - assertEquals( - listOf(recentAnotherAccount), - timelineDao.getStatusesForAccount(2, null, null, 100).blockingGet() - .map { it.toTriple() } - ) - } - - @Test - fun overwriteDeletedStatus() { - - val oldStatuses = listOf( - makeStatus(statusId = 3), - makeStatus(statusId = 2), - makeStatus(statusId = 1) - ) - - timelineDao.deleteRange(1, oldStatuses.last().first.serverId, oldStatuses.first().first.serverId) - - for ((status, author, reblogAuthor) in oldStatuses) { - timelineDao.insertInTransaction(status, author, reblogAuthor) - } - - // status 2 gets deleted, newly loaded status contain only 1 + 3 - val newStatuses = listOf( - makeStatus(statusId = 3), - makeStatus(statusId = 1) - ) - - timelineDao.deleteRange(1, newStatuses.last().first.serverId, newStatuses.first().first.serverId) - - for ((status, author, reblogAuthor) in newStatuses) { - timelineDao.insertInTransaction(status, author, reblogAuthor) - } - - // make sure status 2 is no longer in db - - assertEquals( - newStatuses, - timelineDao.getStatusesForAccount(1, null, null, 100).blockingGet() - .map { it.toTriple() } - ) - } - - private fun makeStatus( - accountId: Long = 1, - statusId: Long = 10, - reblog: Boolean = false, - createdAt: Long = statusId, - authorServerId: String = "20" - ): Triple { - val author = TimelineAccountEntity( - authorServerId, - accountId, - "localUsername", - "username", - "displayName", - "blah", - "avatar", - "[\"tusky\": \"http://tusky.cool/emoji.jpg\"]", - false - ) - - val reblogAuthor = if (reblog) { - TimelineAccountEntity( - "R$authorServerId", - accountId, - "RlocalUsername", - "Rusername", - "RdisplayName", - "Rblah", - "Ravatar", - "[]", - false - ) - } else null - - val even = accountId % 2 == 0L - val status = TimelineStatusEntity( - serverId = statusId.toString(), - url = "url$statusId", - timelineUserId = accountId, - authorServerId = authorServerId, - inReplyToId = "inReplyToId$statusId", - inReplyToAccountId = "inReplyToAccountId$statusId", - content = "Content!$statusId", - createdAt = createdAt, - emojis = "emojis$statusId", - reblogsCount = 1 * statusId.toInt(), - favouritesCount = 2 * statusId.toInt(), - reblogged = even, - favourited = !even, - bookmarked = false, - sensitive = even, - spoilerText = "spoier$statusId", - visibility = Status.Visibility.PRIVATE, - attachments = "attachments$accountId", - mentions = "mentions$accountId", - application = "application$accountId", - reblogServerId = if (reblog) (statusId * 100).toString() else null, - reblogAccountId = reblogAuthor?.serverId, - poll = null, - muted = false - ) - return Triple(status, author, reblogAuthor) - } - - private fun createPlaceholder(serverId: String, timelineUserId: Long): TimelineStatusEntity { - return TimelineStatusEntity( - serverId = serverId, - url = null, - timelineUserId = timelineUserId, - authorServerId = null, - inReplyToId = null, - inReplyToAccountId = null, - content = null, - createdAt = 0L, - emojis = null, - reblogsCount = 0, - favouritesCount = 0, - reblogged = false, - favourited = false, - bookmarked = false, - sensitive = false, - spoilerText = null, - visibility = null, - attachments = null, - mentions = null, - application = null, - reblogServerId = null, - reblogAccountId = null, - poll = null, - muted = false - ) - } - - private fun TimelineStatusWithAccount.toTriple() = Triple(status, account, reblogAccount) -} diff --git a/app/src/main/java/com/keylesspalace/tusky/ListsActivity.kt b/app/src/main/java/com/keylesspalace/tusky/ListsActivity.kt index e816a0a55..3bd0d4981 100644 --- a/app/src/main/java/com/keylesspalace/tusky/ListsActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/ListsActivity.kt @@ -40,7 +40,7 @@ import at.connyduck.sparkbutton.helpers.Utils import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from import autodispose2.autoDispose import com.google.android.material.snackbar.Snackbar -import com.keylesspalace.tusky.components.timeline.TimelineViewModel +import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel import com.keylesspalace.tusky.databinding.ActivityListsBinding import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.di.ViewModelFactory diff --git a/app/src/main/java/com/keylesspalace/tusky/ModalTimelineActivity.kt b/app/src/main/java/com/keylesspalace/tusky/ModalTimelineActivity.kt index dbcde89dc..044349b9d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/ModalTimelineActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/ModalTimelineActivity.kt @@ -5,7 +5,7 @@ import android.content.Intent import android.os.Bundle import com.google.android.material.floatingactionbutton.FloatingActionButton import com.keylesspalace.tusky.components.timeline.TimelineFragment -import com.keylesspalace.tusky.components.timeline.TimelineViewModel +import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel import com.keylesspalace.tusky.databinding.ActivityModalTimelineBinding import com.keylesspalace.tusky.interfaces.ActionButtonActivity import dagger.android.DispatchingAndroidInjector diff --git a/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt b/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt index ac9dba6bd..ebe6c63e0 100644 --- a/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt @@ -20,7 +20,7 @@ import android.content.Intent import android.os.Bundle import androidx.fragment.app.commit import com.keylesspalace.tusky.components.timeline.TimelineFragment -import com.keylesspalace.tusky.components.timeline.TimelineViewModel.Kind +import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel.Kind import com.keylesspalace.tusky.databinding.ActivityStatuslistBinding import dagger.android.DispatchingAndroidInjector import dagger.android.HasAndroidInjector diff --git a/app/src/main/java/com/keylesspalace/tusky/TabData.kt b/app/src/main/java/com/keylesspalace/tusky/TabData.kt index fa1876ce3..de75b7c73 100644 --- a/app/src/main/java/com/keylesspalace/tusky/TabData.kt +++ b/app/src/main/java/com/keylesspalace/tusky/TabData.kt @@ -21,7 +21,7 @@ import androidx.annotation.StringRes import androidx.fragment.app.Fragment import com.keylesspalace.tusky.components.conversation.ConversationsFragment import com.keylesspalace.tusky.components.timeline.TimelineFragment -import com.keylesspalace.tusky.components.timeline.TimelineViewModel +import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel import com.keylesspalace.tusky.fragment.NotificationsFragment /** this would be a good case for a sealed class, but that does not work nice with Room */ diff --git a/app/src/main/java/com/keylesspalace/tusky/appstore/CacheUpdater.kt b/app/src/main/java/com/keylesspalace/tusky/appstore/CacheUpdater.kt index 7b5ecf77a..cb837fbc0 100644 --- a/app/src/main/java/com/keylesspalace/tusky/appstore/CacheUpdater.kt +++ b/app/src/main/java/com/keylesspalace/tusky/appstore/CacheUpdater.kt @@ -6,6 +6,7 @@ import com.keylesspalace.tusky.db.AppDatabase import io.reactivex.rxjava3.core.Single import io.reactivex.rxjava3.disposables.Disposable import io.reactivex.rxjava3.schedulers.Schedulers +import java.util.concurrent.TimeUnit import javax.inject.Inject class CacheUpdater @Inject constructor( @@ -19,6 +20,12 @@ class CacheUpdater @Inject constructor( init { val timelineDao = appDatabase.timelineDao() + + Schedulers.io().scheduleDirect { + val olderThan = System.currentTimeMillis() - CLEANUP_INTERVAL + appDatabase.timelineDao().cleanup(olderThan) + } + disposable = eventHub.events.subscribe { event -> val accountId = accountManager.activeAccount?.id ?: return@subscribe when (event) { @@ -36,6 +43,8 @@ class CacheUpdater @Inject constructor( val pollString = gson.toJson(event.poll) timelineDao.setVoted(accountId, event.statusId, pollString) } + is PinEvent -> + timelineDao.setPinned(accountId, event.statusId, event.pinned) } } } @@ -52,4 +61,8 @@ class CacheUpdater @Inject constructor( .subscribeOn(Schedulers.io()) .subscribe() } + + companion object { + val CLEANUP_INTERVAL = TimeUnit.DAYS.toMillis(14) + } } 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 8682b5a27..c6c17d139 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( private val loadedStatuses: MutableList> = mutableListOf() private val statusesPagingSourceFactory = SearchPagingSourceFactory(mastodonApi, SearchType.Status, loadedStatuses) { - it.statuses.map { status -> Pair(status, status.toViewData(alwaysShowSensitiveMedia, alwaysOpenSpoiler)) } + it.statuses.map { status -> Pair(status, status.toViewData(alwaysShowSensitiveMedia, alwaysOpenSpoiler, true)) } .apply { loadedStatuses.addAll(this) } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineAdapter.java b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineAdapter.java deleted file mode 100644 index a58627dbe..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineAdapter.java +++ /dev/null @@ -1,138 +0,0 @@ -/* Copyright 2017 Andrew Dawson - * - * 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.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.recyclerview.widget.RecyclerView; - -import com.keylesspalace.tusky.R; -import com.keylesspalace.tusky.adapter.PlaceholderViewHolder; -import com.keylesspalace.tusky.adapter.StatusViewHolder; -import com.keylesspalace.tusky.interfaces.StatusActionListener; -import com.keylesspalace.tusky.util.StatusDisplayOptions; -import com.keylesspalace.tusky.viewdata.StatusViewData; - -import java.util.List; - -public final class TimelineAdapter extends RecyclerView.Adapter { - - public interface AdapterDataSource { - int getItemCount(); - - T getItemAt(int pos); - } - - private static final int VIEW_TYPE_STATUS = 0; - private static final int VIEW_TYPE_PLACEHOLDER = 2; - - private final AdapterDataSource dataSource; - private StatusDisplayOptions statusDisplayOptions; - private final StatusActionListener statusListener; - - public TimelineAdapter(AdapterDataSource dataSource, - StatusDisplayOptions statusDisplayOptions, - StatusActionListener statusListener) { - this.dataSource = dataSource; - this.statusDisplayOptions = statusDisplayOptions; - this.statusListener = statusListener; - } - - public boolean getMediaPreviewEnabled() { - return statusDisplayOptions.mediaPreviewEnabled(); - } - - public void setMediaPreviewEnabled(boolean mediaPreviewEnabled) { - this.statusDisplayOptions = statusDisplayOptions.copy( - statusDisplayOptions.animateAvatars(), - mediaPreviewEnabled, - statusDisplayOptions.useAbsoluteTime(), - statusDisplayOptions.showBotOverlay(), - statusDisplayOptions.useBlurhash(), - statusDisplayOptions.cardViewMode(), - statusDisplayOptions.confirmReblogs(), - statusDisplayOptions.confirmFavourites(), - statusDisplayOptions.hideStats(), - statusDisplayOptions.animateEmojis() - ); - } - - @NonNull - @Override - public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup viewGroup, int viewType) { - switch (viewType) { - default: - case VIEW_TYPE_STATUS: { - View view = LayoutInflater.from(viewGroup.getContext()) - .inflate(R.layout.item_status, viewGroup, false); - return new StatusViewHolder(view); - } - case VIEW_TYPE_PLACEHOLDER: { - View view = LayoutInflater.from(viewGroup.getContext()) - .inflate(R.layout.item_status_placeholder, viewGroup, false); - return new PlaceholderViewHolder(view); - } - } - } - - @Override - public void onBindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position) { - bindViewHolder(viewHolder, position, null); - } - - - @Override - public void onBindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position, @NonNull List payloads) { - bindViewHolder(viewHolder, position, payloads); - } - - private void bindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position, @Nullable List payloads) { - StatusViewData status = dataSource.getItemAt(position); - if (status instanceof StatusViewData.Placeholder) { - PlaceholderViewHolder holder = (PlaceholderViewHolder) viewHolder; - holder.setup(statusListener, ((StatusViewData.Placeholder) status).isLoading()); - } else if (status instanceof StatusViewData.Concrete) { - StatusViewHolder holder = (StatusViewHolder) viewHolder; - holder.setupWithStatus((StatusViewData.Concrete) status, - statusListener, - statusDisplayOptions, - payloads != null && !payloads.isEmpty() ? payloads.get(0) : null); - } - } - - @Override - public int getItemCount() { - return dataSource.getItemCount(); - } - - @Override - public int getItemViewType(int position) { - if (dataSource.getItemAt(position) instanceof StatusViewData.Placeholder) { - return VIEW_TYPE_PLACEHOLDER; - } else { - return VIEW_TYPE_STATUS; - } - } - - @Override - public long getItemId(int position) { - return dataSource.getItemAt(position).getViewDataId(); - } -} 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 2568371ca..e10f21466 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 @@ -22,15 +22,13 @@ import android.view.View import android.view.ViewGroup import android.view.accessibility.AccessibilityManager import androidx.core.content.ContextCompat -import androidx.fragment.app.viewModels import androidx.lifecycle.Lifecycle +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.lifecycleScope +import androidx.paging.LoadState import androidx.preference.PreferenceManager -import androidx.recyclerview.widget.AsyncDifferConfig -import androidx.recyclerview.widget.AsyncListDiffer -import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.ListUpdateCallback import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.SimpleItemAnimator import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener @@ -40,13 +38,17 @@ import com.keylesspalace.tusky.AccountListActivity import com.keylesspalace.tusky.AccountListActivity.Companion.newIntent 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.components.timeline.viewmodel.CachedTimelineViewModel +import com.keylesspalace.tusky.components.timeline.viewmodel.NetworkTimelineViewModel +import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel import com.keylesspalace.tusky.databinding.FragmentTimelineBinding import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.di.ViewModelFactory +import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.fragment.SFragment import com.keylesspalace.tusky.interfaces.ActionButtonActivity import com.keylesspalace.tusky.interfaces.RefreshableFragment @@ -59,12 +61,12 @@ import com.keylesspalace.tusky.util.StatusDisplayOptions import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.viewBinding -import com.keylesspalace.tusky.util.visible -import com.keylesspalace.tusky.view.EndlessOnScrollListener import com.keylesspalace.tusky.viewdata.AttachmentViewData -import com.keylesspalace.tusky.viewdata.StatusViewData import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.core.Observable +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import java.io.IOException import java.util.concurrent.TimeUnit import javax.inject.Inject @@ -85,25 +87,33 @@ class TimelineFragment : @Inject lateinit var accountManager: AccountManager - private val viewModel: TimelineViewModel by viewModels { viewModelFactory } + private val viewModel: TimelineViewModel by lazy { + if (kind == TimelineViewModel.Kind.HOME) { + ViewModelProvider(this, viewModelFactory).get(CachedTimelineViewModel::class.java) + } else { + ViewModelProvider(this, viewModelFactory).get(NetworkTimelineViewModel::class.java) + } + } private val binding by viewBinding(FragmentTimelineBinding::bind) - private lateinit var adapter: TimelineAdapter + private lateinit var kind: TimelineViewModel.Kind + + private lateinit var adapter: TimelinePagingAdapter private var isSwipeToRefreshEnabled = true private var eventRegistered = false private var layoutManager: LinearLayoutManager? = null - private var scrollListener: EndlessOnScrollListener? = null + private var scrollListener: RecyclerView.OnScrollListener? = null private var hideFab = false override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val arguments = requireArguments() - val kind = TimelineViewModel.Kind.valueOf(arguments.getString(KIND_ARG)!!) + 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 || @@ -125,11 +135,6 @@ class TimelineFragment : tags, ) - viewModel.viewUpdates - .observeOn(AndroidSchedulers.mainThread()) - .autoDispose(this) - .subscribe { this.updateViews() } - isSwipeToRefreshEnabled = arguments.getBoolean(ARG_ENABLE_SWIPE_TO_REFRESH, true) val preferences = PreferenceManager.getDefaultSharedPreferences(activity) @@ -149,8 +154,7 @@ class TimelineFragment : hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false), animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) ) - adapter = TimelineAdapter( - dataSource, + adapter = TimelinePagingAdapter( statusDisplayOptions, this ) @@ -167,8 +171,56 @@ class TimelineFragment : override fun onViewCreated(view: View, savedInstanceState: Bundle?) { setupSwipeRefreshLayout() setupRecyclerView() - updateViews() - viewModel.loadInitial() + + adapter.addLoadStateListener { loadState -> + if (loadState.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) { + binding.statusView.show() + binding.statusView.setup(R.drawable.elephant_friend_empty, R.string.message_empty, null) + } + } + 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) + } + } + is LoadState.Loading -> { + binding.progressBar.show() + } + } + } + } + + adapter.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() { + override fun onItemRangeInserted(positionStart: Int, itemCount: Int) { + if (positionStart == 0 && adapter.itemCount != itemCount) { + binding.recyclerView.post { + if (isSwipeToRefreshEnabled) { + binding.recyclerView.scrollBy(0, Utils.dpToPx(requireContext(), -30)) + } else binding.recyclerView.scrollToPosition(0) + } + } + } + }) + + lifecycleScope.launch { + viewModel.statuses.collectLatest { pagingData -> + adapter.submitData(pagingData) + } + } } private fun setupSwipeRefreshLayout() { @@ -179,7 +231,9 @@ class TimelineFragment : private fun setupRecyclerView() { binding.recyclerView.setAccessibilityDelegateCompat( - ListStatusAccessibilityDelegate(binding.recyclerView, this) { pos -> viewModel.statuses.getOrNull(pos) } + ListStatusAccessibilityDelegate(binding.recyclerView, this) { pos -> + adapter.peek(pos) + } ) binding.recyclerView.setHasFixedSize(true) layoutManager = LinearLayoutManager(context) @@ -192,24 +246,16 @@ class TimelineFragment : binding.recyclerView.adapter = adapter } - private fun showEmptyView() { - binding.statusView.show() - binding.statusView.setup(R.drawable.elephant_friend_empty, R.string.message_empty, null) - } - override fun onActivityCreated(savedInstanceState: Bundle?) { super.onActivityCreated(savedInstanceState) /* This is delayed until onActivityCreated solely because MainActivity.composeButton isn't * guaranteed to be set until then. */ - scrollListener = if (actionButtonPresent()) { - /* Use a modified scroll listener that both loads more statuses as it goes, and hides - * the follow button on down-scroll. */ + if (actionButtonPresent()) { val preferences = PreferenceManager.getDefaultSharedPreferences(context) hideFab = preferences.getBoolean("fabHide", false) - object : EndlessOnScrollListener(layoutManager) { + scrollListener = object : RecyclerView.OnScrollListener() { override fun onScrolled(view: RecyclerView, dx: Int, dy: Int) { - super.onScrolled(view, dx, dy) val composeButton = (activity as ActionButtonActivity).actionButton if (composeButton != null) { if (hideFab) { @@ -223,20 +269,9 @@ class TimelineFragment : } } } - - override fun onLoadMore(totalItemsCount: Int, view: RecyclerView) { - this@TimelineFragment.onLoadMore() - } + }.also { + binding.recyclerView.addOnScrollListener(it) } - } else { - // Just use the basic scroll listener to load more statuses. - object : EndlessOnScrollListener(layoutManager) { - override fun onLoadMore(totalItemsCount: Int, view: RecyclerView) { - this@TimelineFragment.onLoadMore() - } - } - }.also { - binding.recyclerView.addOnScrollListener(it) } if (!eventRegistered) { @@ -248,6 +283,10 @@ class TimelineFragment : is PreferenceChangedEvent -> { onPreferenceChanged(event.preferenceKey) } + is StatusComposedEvent -> { + val status = event.status + handleStatusComposeEvent(status) + } } } eventRegistered = true @@ -255,75 +294,80 @@ class TimelineFragment : } override fun onRefresh() { - binding.swipeRefreshLayout.isEnabled = isSwipeToRefreshEnabled binding.statusView.hide() - viewModel.refresh() + adapter.refresh() } override fun onReply(position: Int) { - val status = viewModel.statuses[position].asStatusOrNull() ?: return + val status = adapter.peek(position)?.asStatusOrNull() ?: return super.reply(status.status) } override fun onReblog(reblog: Boolean, position: Int) { - viewModel.reblog(reblog, position) + val status = adapter.peek(position)?.asStatusOrNull() ?: return + viewModel.reblog(reblog, status) } override fun onFavourite(favourite: Boolean, position: Int) { - viewModel.favorite(favourite, position) + val status = adapter.peek(position)?.asStatusOrNull() ?: return + viewModel.favorite(favourite, status) } override fun onBookmark(bookmark: Boolean, position: Int) { - viewModel.bookmark(bookmark, position) + val status = adapter.peek(position)?.asStatusOrNull() ?: return + viewModel.bookmark(bookmark, status) } override fun onVoteInPoll(position: Int, choices: List) { - viewModel.voteInPoll(position, choices) + val status = adapter.peek(position)?.asStatusOrNull() ?: return + viewModel.voteInPoll(choices, status) } override fun onMore(view: View, position: Int) { - val status = viewModel.statuses[position].asStatusOrNull()?.status ?: return - super.more(status, view, position) + val status = adapter.peek(position)?.asStatusOrNull() ?: return + super.more(status.status, view, position) } override fun onOpenReblog(position: Int) { - val status = viewModel.statuses[position].asStatusOrNull()?.status ?: return - super.openReblog(status) + val status = adapter.peek(position)?.asStatusOrNull() ?: return + super.openReblog(status.status) } override fun onExpandedChange(expanded: Boolean, position: Int) { - viewModel.changeExpanded(expanded, position) - updateViews() + val status = adapter.peek(position)?.asStatusOrNull() ?: return + viewModel.changeExpanded(expanded, status) } override fun onContentHiddenChange(isShowing: Boolean, position: Int) { - viewModel.changeContentHidden(isShowing, position) - updateViews() + val status = adapter.peek(position)?.asStatusOrNull() ?: return + viewModel.changeContentShowing(isShowing, status) } override fun onShowReblogs(position: Int) { - val statusId = viewModel.statuses[position].asStatusOrNull()?.id ?: return + val statusId = adapter.peek(position)?.asStatusOrNull()?.id ?: return val intent = newIntent(requireContext(), AccountListActivity.Type.REBLOGGED, statusId) (activity as BaseActivity).startActivityWithSlideInAnimation(intent) } override fun onShowFavs(position: Int) { - val statusId = viewModel.statuses[position].asStatusOrNull()?.id ?: return + val statusId = adapter.peek(position)?.asStatusOrNull()?.id ?: return val intent = newIntent(requireContext(), AccountListActivity.Type.FAVOURITED, statusId) (activity as BaseActivity).startActivityWithSlideInAnimation(intent) } override fun onLoadMore(position: Int) { - viewModel.loadGap(position) + val placeholder = adapter.peek(position)?.asPlaceholderOrNull() ?: return + viewModel.loadMore(placeholder.id) } override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) { - viewModel.changeContentCollapsed(isCollapsed, position) + val status = adapter.peek(position)?.asStatusOrNull() ?: return + viewModel.changeContentCollapsed(isCollapsed, status) } override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) { - val status = viewModel.statuses[position].asStatusOrNull() ?: return + val status = adapter.peek(position)?.asStatusOrNull() ?: return super.viewMedia( attachmentIndex, AttachmentViewData.list(status.actionable), @@ -332,7 +376,7 @@ class TimelineFragment : } override fun onViewThread(position: Int) { - val status = viewModel.statuses[position].asStatusOrNull() ?: return + val status = adapter.peek(position)?.asStatusOrNull() ?: return super.viewThread(status.actionable.id, status.actionable.url) } @@ -371,19 +415,32 @@ class TimelineFragment : val oldMediaPreviewEnabled = adapter.mediaPreviewEnabled if (enabled != oldMediaPreviewEnabled) { adapter.mediaPreviewEnabled = enabled - updateViews() + adapter.notifyDataSetChanged() } } } } - public override fun removeItem(position: Int) { - viewModel.statuses.removeAt(position) - updateViews() + 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) { + adapter.refresh() + } + TimelineViewModel.Kind.TAG, + TimelineViewModel.Kind.FAVOURITES, + TimelineViewModel.Kind.LIST, + TimelineViewModel.Kind.BOOKMARKS, + TimelineViewModel.Kind.USER_PINNED -> return + } } - private fun onLoadMore() { - viewModel.loadMore() + public override fun removeItem(position: Int) { + val status = adapter.peek(position)?.asStatusOrNull() ?: return + viewModel.removeStatusWithId(status.id) } private fun actionButtonPresent(): Boolean { @@ -393,86 +450,6 @@ class TimelineFragment : activity is ActionButtonActivity } - private fun updateViews() { - differ.submitList(viewModel.statuses.toList()) - binding.swipeRefreshLayout.isEnabled = viewModel.failure == null - - if (isAdded) { - binding.swipeRefreshLayout.isRefreshing = viewModel.isRefreshing - binding.progressBar.visible(viewModel.isLoadingInitially) - if (viewModel.failure == null && viewModel.statuses.isEmpty() && !viewModel.isLoadingInitially) { - showEmptyView() - } else { - when (viewModel.failure) { - TimelineViewModel.FailureReason.NETWORK -> { - binding.statusView.show() - binding.statusView.setup( - R.drawable.elephant_offline, - R.string.error_network - ) { - binding.statusView.hide() - viewModel.loadInitial() - } - } - TimelineViewModel.FailureReason.OTHER -> { - binding.statusView.show() - binding.statusView.setup( - R.drawable.elephant_error, - R.string.error_generic - ) { - binding.statusView.hide() - viewModel.loadInitial() - } - } - null -> binding.statusView.hide() - } - } - } - } - - private val listUpdateCallback: ListUpdateCallback = object : ListUpdateCallback { - override fun onInserted(position: Int, count: Int) { - if (isAdded) { - adapter.notifyItemRangeInserted(position, count) - val context = context - // scroll up when new items at the top are loaded while being in the first position - // https://github.com/tuskyapp/Tusky/pull/1905#issuecomment-677819724 - if (position == 0 && context != null && adapter.itemCount != count) { - if (isSwipeToRefreshEnabled) { - binding.recyclerView.scrollBy(0, Utils.dpToPx(context, -30)) - } else binding.recyclerView.scrollToPosition(0) - } - } - } - - override fun onRemoved(position: Int, count: Int) { - adapter.notifyItemRangeRemoved(position, count) - } - - override fun onMoved(fromPosition: Int, toPosition: Int) { - adapter.notifyItemMoved(fromPosition, toPosition) - } - - override fun onChanged(position: Int, count: Int, payload: Any?) { - adapter.notifyItemRangeChanged(position, count, payload) - } - } - private val differ = AsyncListDiffer( - listUpdateCallback, - AsyncDifferConfig.Builder(diffCallback).build() - ) - - private val dataSource: TimelineAdapter.AdapterDataSource = - object : TimelineAdapter.AdapterDataSource { - override fun getItemCount(): Int { - return differ.currentList.size - } - - override fun getItemAt(pos: Int): StatusViewData { - return differ.currentList[pos] - } - } - private var talkBackWasEnabled = false override fun onResume() { @@ -501,7 +478,9 @@ class TimelineFragment : Observable.interval(1, TimeUnit.MINUTES) .observeOn(AndroidSchedulers.mainThread()) .autoDispose(this, Lifecycle.Event.ON_PAUSE) - .subscribe { updateViews() } + .subscribe { + adapter.notifyDataSetChanged() + } } } @@ -509,7 +488,6 @@ class TimelineFragment : if (isAdded) { layoutManager!!.scrollToPosition(0) binding.recyclerView.stopScroll() - scrollListener!!.reset() } } @@ -548,33 +526,5 @@ class TimelineFragment : fragment.arguments = arguments return fragment } - - private val diffCallback: DiffUtil.ItemCallback = - object : DiffUtil.ItemCallback() { - override fun areItemsTheSame( - oldItem: StatusViewData, - newItem: StatusViewData - ): Boolean { - return oldItem.viewDataId == newItem.viewDataId - } - - override fun areContentsTheSame( - 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, - newItem: StatusViewData - ): Any? { - 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 - null - } - } } } 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 new file mode 100644 index 000000000..e8b23c61b --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelinePagingAdapter.kt @@ -0,0 +1,135 @@ +/* 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 + +import android.view.LayoutInflater +import android.view.ViewGroup +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.interfaces.StatusActionListener +import com.keylesspalace.tusky.util.StatusDisplayOptions +import com.keylesspalace.tusky.viewdata.StatusViewData + +class TimelinePagingAdapter( + private var statusDisplayOptions: StatusDisplayOptions, + private val statusListener: StatusActionListener +) : PagingDataAdapter(TimelineDifferCallback) { + + var mediaPreviewEnabled: Boolean + get() = statusDisplayOptions.mediaPreviewEnabled + set(mediaPreviewEnabled) { + statusDisplayOptions = statusDisplayOptions.copy( + mediaPreviewEnabled = mediaPreviewEnabled + ) + } + + override fun onCreateViewHolder(viewGroup: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + return when (viewType) { + VIEW_TYPE_STATUS -> { + val view = LayoutInflater.from(viewGroup.context) + .inflate(R.layout.item_status, viewGroup, false) + StatusViewHolder(view) + } + VIEW_TYPE_PLACEHOLDER -> { + val view = LayoutInflater.from(viewGroup.context) + .inflate(R.layout.item_status_placeholder, viewGroup, false) + PlaceholderViewHolder(view) + } + else -> { + val view = LayoutInflater.from(viewGroup.context) + .inflate(R.layout.item_status, viewGroup, false) + StatusViewHolder(view) + } + } + } + + override fun onBindViewHolder(viewHolder: RecyclerView.ViewHolder, position: Int) { + bindViewHolder(viewHolder, position, null) + } + + override fun onBindViewHolder( + viewHolder: RecyclerView.ViewHolder, + position: Int, + payloads: List<*> + ) { + bindViewHolder(viewHolder, position, payloads) + } + + private fun bindViewHolder( + viewHolder: RecyclerView.ViewHolder, + 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 + ) + } + } + + override fun getItemViewType(position: Int): Int { + return if (getItem(position) is StatusViewData.Placeholder) { + VIEW_TYPE_PLACEHOLDER + } else { + VIEW_TYPE_STATUS + } + } + + companion object { + private const val VIEW_TYPE_STATUS = 0 + private const val VIEW_TYPE_PLACEHOLDER = 2 + + val TimelineDifferCallback = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame( + oldItem: StatusViewData, + newItem: StatusViewData + ): Boolean { + return oldItem.viewDataId == newItem.viewDataId + } + + override fun areContentsTheSame( + 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, + newItem: StatusViewData + ): Any? { + 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 + null + } + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineRepository.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineRepository.kt deleted file mode 100644 index 1f7d32e74..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineRepository.kt +++ /dev/null @@ -1,435 +0,0 @@ -package com.keylesspalace.tusky.components.timeline - -import android.text.SpannedString -import androidx.core.text.parseAsHtml -import androidx.core.text.toHtml -import com.google.gson.Gson -import com.google.gson.reflect.TypeToken -import com.keylesspalace.tusky.components.timeline.TimelineRequestMode.DISK -import com.keylesspalace.tusky.components.timeline.TimelineRequestMode.NETWORK -import com.keylesspalace.tusky.db.AccountManager -import com.keylesspalace.tusky.db.TimelineAccountEntity -import com.keylesspalace.tusky.db.TimelineDao -import com.keylesspalace.tusky.db.TimelineStatusEntity -import com.keylesspalace.tusky.db.TimelineStatusWithAccount -import com.keylesspalace.tusky.entity.Account -import com.keylesspalace.tusky.entity.Attachment -import com.keylesspalace.tusky.entity.Emoji -import com.keylesspalace.tusky.entity.Poll -import com.keylesspalace.tusky.entity.Status -import com.keylesspalace.tusky.network.MastodonApi -import com.keylesspalace.tusky.util.Either -import com.keylesspalace.tusky.util.dec -import com.keylesspalace.tusky.util.inc -import com.keylesspalace.tusky.util.trimTrailingWhitespace -import io.reactivex.rxjava3.core.Single -import io.reactivex.rxjava3.schedulers.Schedulers -import java.io.IOException -import java.util.Date -import java.util.concurrent.TimeUnit - -data class Placeholder(val id: String) - -typealias TimelineStatus = Either - -enum class TimelineRequestMode { - DISK, NETWORK, ANY -} - -interface TimelineRepository { - fun getStatuses( - maxId: String?, - sinceId: String?, - sincedIdMinusOne: String?, - limit: Int, - requestMode: TimelineRequestMode - ): Single> - - companion object { - val CLEANUP_INTERVAL = TimeUnit.DAYS.toMillis(14) - } -} - -class TimelineRepositoryImpl( - private val timelineDao: TimelineDao, - private val mastodonApi: MastodonApi, - private val accountManager: AccountManager, - private val gson: Gson -) : TimelineRepository { - - init { - this.cleanup() - } - - override fun getStatuses( - maxId: String?, - sinceId: String?, - sincedIdMinusOne: String?, - limit: Int, - requestMode: TimelineRequestMode - ): Single> { - val acc = accountManager.activeAccount ?: throw IllegalStateException() - val accountId = acc.id - - return if (requestMode == DISK) { - this.getStatusesFromDb(accountId, maxId, sinceId, limit) - } else { - getStatusesFromNetwork(maxId, sinceId, sincedIdMinusOne, limit, accountId, requestMode) - } - } - - private fun getStatusesFromNetwork( - maxId: String?, - sinceId: String?, - sinceIdMinusOne: String?, - limit: Int, - accountId: Long, - requestMode: TimelineRequestMode - ): Single> { - return mastodonApi.homeTimeline(maxId, sinceIdMinusOne, limit + 1) - .map { response -> - this.saveStatusesToDb(accountId, response.body().orEmpty(), maxId, sinceId) - } - .flatMap { statuses -> - this.addFromDbIfNeeded(accountId, statuses, maxId, sinceId, limit, requestMode) - } - .onErrorResumeNext { error -> - if (error is IOException && requestMode != NETWORK) { - this.getStatusesFromDb(accountId, maxId, sinceId, limit) - } else { - Single.error(error) - } - } - } - - private fun addFromDbIfNeeded( - accountId: Long, - statuses: List>, - maxId: String?, - sinceId: String?, - limit: Int, - requestMode: TimelineRequestMode - ): Single> { - return if (requestMode != NETWORK && statuses.size < 2) { - val newMaxID = if (statuses.isEmpty()) { - maxId - } else { - statuses.last { it.isRight() }.asRight().id - } - this.getStatusesFromDb(accountId, newMaxID, sinceId, limit) - .map { fromDb -> - // If it's just placeholders and less than limit (so we exhausted both - // db and server at this point) - if (fromDb.size < limit && fromDb.all { !it.isRight() }) { - statuses - } else { - statuses + fromDb - } - } - } else { - Single.just(statuses) - } - } - - private fun getStatusesFromDb( - accountId: Long, - maxId: String?, - sinceId: String?, - limit: Int - ): Single> { - return timelineDao.getStatusesForAccount(accountId, maxId, sinceId, limit) - .subscribeOn(Schedulers.io()) - .map { statuses -> - statuses.map { it.toStatus() } - } - } - - private fun saveStatusesToDb( - accountId: Long, - statuses: List, - maxId: String?, - sinceId: String? - ): List> { - var placeholderToInsert: Placeholder? = null - - // Look for overlap - val resultStatuses = if (statuses.isNotEmpty() && sinceId != null) { - val indexOfSince = statuses.indexOfLast { it.id == sinceId } - if (indexOfSince == -1) { - // We didn't find the status which must be there. Add a placeholder - placeholderToInsert = Placeholder(sinceId.inc()) - statuses.mapTo(mutableListOf(), Status::lift) - .apply { - add(Either.Left(placeholderToInsert)) - } - } else { - // There was an overlap. Remove all overlapped statuses. No need for a placeholder. - statuses.mapTo(mutableListOf(), Status::lift) - .apply { - subList(indexOfSince, size).clear() - } - } - } else { - // Just a normal case. - statuses.map(Status::lift) - } - - Single.fromCallable { - - if (statuses.isNotEmpty()) { - timelineDao.deleteRange(accountId, statuses.last().id, statuses.first().id) - } - - for (status in statuses) { - timelineDao.insertInTransaction( - status.toEntity(accountId, gson), - status.account.toEntity(accountId, gson), - status.reblog?.account?.toEntity(accountId, gson) - ) - } - - placeholderToInsert?.let { - timelineDao.insertStatusIfNotThere(placeholderToInsert.toEntity(accountId)) - } - - // If we're loading in the bottom insert placeholder after every load - // (for requests on next launches) but not return it. - if (sinceId == null && statuses.isNotEmpty()) { - timelineDao.insertStatusIfNotThere( - Placeholder(statuses.last().id.dec()).toEntity(accountId) - ) - } - - // There may be placeholders which we thought could be from our TL but they are not - if (statuses.size > 2) { - timelineDao.removeAllPlaceholdersBetween( - accountId, statuses.first().id, - statuses.last().id - ) - } else if (placeholderToInsert == null && maxId != null && sinceId != null) { - timelineDao.removeAllPlaceholdersBetween(accountId, maxId, sinceId) - } - } - .subscribeOn(Schedulers.io()) - .subscribe() - - return resultStatuses - } - - private fun cleanup() { - Schedulers.io().scheduleDirect { - val olderThan = System.currentTimeMillis() - TimelineRepository.CLEANUP_INTERVAL - timelineDao.cleanup(olderThan) - } - } - - private fun TimelineStatusWithAccount.toStatus(): TimelineStatus { - if (this.status.authorServerId == null) { - return Either.Left(Placeholder(this.status.serverId)) - } - - val attachments: ArrayList = gson.fromJson( - status.attachments, - object : TypeToken>() {}.type - ) ?: ArrayList() - val mentions: List = gson.fromJson( - status.mentions, - object : TypeToken>() {}.type - ) ?: listOf() - val application = gson.fromJson(status.application, Status.Application::class.java) - val emojis: List = gson.fromJson( - status.emojis, - object : TypeToken>() {}.type - ) ?: listOf() - val poll: Poll? = gson.fromJson(status.poll, Poll::class.java) - - val reblog = status.reblogServerId?.let { id -> - Status( - id = id, - url = status.url, - account = account.toAccount(gson), - inReplyToId = status.inReplyToId, - inReplyToAccountId = status.inReplyToAccountId, - reblog = null, - content = status.content?.parseAsHtml()?.trimTrailingWhitespace() - ?: SpannedString(""), - createdAt = Date(status.createdAt), - emojis = emojis, - reblogsCount = status.reblogsCount, - favouritesCount = status.favouritesCount, - reblogged = status.reblogged, - favourited = status.favourited, - bookmarked = status.bookmarked, - sensitive = status.sensitive, - spoilerText = status.spoilerText!!, - visibility = status.visibility!!, - attachments = attachments, - mentions = mentions, - application = application, - pinned = false, - muted = status.muted, - poll = poll, - card = null - ) - } - val status = if (reblog != null) { - Status( - id = status.serverId, - url = null, // no url for reblogs - account = this.reblogAccount!!.toAccount(gson), - inReplyToId = null, - inReplyToAccountId = null, - reblog = reblog, - content = SpannedString(""), - createdAt = Date(status.createdAt), // lie but whatever? - emojis = listOf(), - reblogsCount = 0, - favouritesCount = 0, - reblogged = false, - favourited = false, - bookmarked = false, - sensitive = false, - spoilerText = "", - visibility = status.visibility!!, - attachments = ArrayList(), - mentions = listOf(), - application = null, - pinned = false, - muted = status.muted, - poll = null, - card = null - ) - } else { - Status( - id = status.serverId, - url = status.url, - account = account.toAccount(gson), - inReplyToId = status.inReplyToId, - inReplyToAccountId = status.inReplyToAccountId, - reblog = null, - content = status.content?.parseAsHtml()?.trimTrailingWhitespace() - ?: SpannedString(""), - createdAt = Date(status.createdAt), - emojis = emojis, - reblogsCount = status.reblogsCount, - favouritesCount = status.favouritesCount, - reblogged = status.reblogged, - favourited = status.favourited, - bookmarked = status.bookmarked, - sensitive = status.sensitive, - spoilerText = status.spoilerText!!, - visibility = status.visibility!!, - attachments = attachments, - mentions = mentions, - application = application, - pinned = false, - muted = status.muted, - poll = poll, - card = null - ) - } - return Either.Right(status) - } -} - -private val emojisListTypeToken = object : TypeToken>() {} - -fun Account.toEntity(accountId: Long, gson: Gson): TimelineAccountEntity { - return TimelineAccountEntity( - serverId = id, - timelineUserId = accountId, - localUsername = localUsername, - username = username, - displayName = name, - url = url, - avatar = avatar, - emojis = gson.toJson(emojis), - bot = bot - ) -} - -fun TimelineAccountEntity.toAccount(gson: Gson): Account { - return Account( - id = serverId, - localUsername = localUsername, - username = username, - displayName = displayName, - note = SpannedString(""), - url = url, - avatar = avatar, - header = "", - locked = false, - followingCount = 0, - followersCount = 0, - statusesCount = 0, - source = null, - bot = bot, - emojis = gson.fromJson(this.emojis, emojisListTypeToken.type), - fields = null, - moved = null - ) -} - -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, - emojis = null, - reblogsCount = 0, - favouritesCount = 0, - reblogged = false, - favourited = false, - bookmarked = false, - sensitive = false, - spoilerText = null, - visibility = null, - attachments = null, - mentions = null, - application = null, - reblogServerId = null, - reblogAccountId = null, - poll = null, - muted = false - ) -} - -fun Status.toEntity( - timelineUserId: Long, - gson: Gson -): TimelineStatusEntity { - val actionable = actionableStatus - return TimelineStatusEntity( - serverId = this.id, - url = actionable.url!!, - timelineUserId = timelineUserId, - authorServerId = actionable.account.id, - inReplyToId = actionable.inReplyToId, - inReplyToAccountId = actionable.inReplyToAccountId, - content = actionable.content.toHtml(), - createdAt = actionable.createdAt.time, - emojis = actionable.emojis.let(gson::toJson), - reblogsCount = actionable.reblogsCount, - favouritesCount = actionable.favouritesCount, - reblogged = actionable.reblogged, - favourited = actionable.favourited, - bookmarked = actionable.bookmarked, - sensitive = actionable.sensitive, - spoilerText = actionable.spoilerText, - visibility = actionable.visibility, - attachments = actionable.attachments.let(gson::toJson), - mentions = actionable.mentions.let(gson::toJson), - application = actionable.application.let(gson::toJson), - reblogServerId = reblog?.id, - reblogAccountId = reblog?.let { this.account.id }, - poll = actionable.poll.let(gson::toJson), - muted = actionable.muted - ) -} - -fun Status.lift(): Either = Either.Right(this) 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 new file mode 100644 index 000000000..1f3810f9b --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineTypeMappers.kt @@ -0,0 +1,256 @@ +/* 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 + +import android.text.SpannedString +import androidx.core.text.parseAsHtml +import androidx.core.text.toHtml +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import com.keylesspalace.tusky.db.TimelineAccountEntity +import com.keylesspalace.tusky.db.TimelineStatusEntity +import com.keylesspalace.tusky.db.TimelineStatusWithAccount +import com.keylesspalace.tusky.entity.Account +import com.keylesspalace.tusky.entity.Attachment +import com.keylesspalace.tusky.entity.Emoji +import com.keylesspalace.tusky.entity.Poll +import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.util.shouldTrimStatus +import com.keylesspalace.tusky.util.trimTrailingWhitespace +import com.keylesspalace.tusky.viewdata.StatusViewData +import java.util.Date + +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 + +fun Account.toEntity(accountId: Long, gson: Gson): TimelineAccountEntity { + return TimelineAccountEntity( + serverId = id, + timelineUserId = accountId, + localUsername = localUsername, + username = username, + displayName = name, + url = url, + avatar = avatar, + emojis = gson.toJson(emojis), + bot = bot + ) +} + +fun TimelineAccountEntity.toAccount(gson: Gson): Account { + return Account( + id = serverId, + localUsername = localUsername, + username = username, + displayName = displayName, + note = SpannedString(""), + url = url, + avatar = avatar, + header = "", + locked = false, + followingCount = 0, + followersCount = 0, + statusesCount = 0, + source = null, + bot = bot, + emojis = gson.fromJson(emojis, emojisListType), + fields = null, + moved = null + ) +} + +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, + emojis = null, + reblogsCount = 0, + favouritesCount = 0, + reblogged = false, + favourited = false, + bookmarked = false, + sensitive = false, + spoilerText = "", + visibility = Status.Visibility.UNKNOWN, + attachments = null, + mentions = null, + application = null, + reblogServerId = null, + reblogAccountId = null, + poll = null, + muted = false, + expanded = loading, + contentCollapsed = false, + contentShowing = false, + pinned = false + ) +} + +fun Status.toEntity( + timelineUserId: Long, + gson: Gson, + expanded: Boolean, + contentShowing: Boolean, + contentCollapsed: Boolean +): TimelineStatusEntity { + return TimelineStatusEntity( + serverId = this.id, + url = actionableStatus.url, + timelineUserId = timelineUserId, + authorServerId = actionableStatus.account.id, + inReplyToId = actionableStatus.inReplyToId, + inReplyToAccountId = actionableStatus.inReplyToAccountId, + content = actionableStatus.content.toHtml(), + createdAt = actionableStatus.createdAt.time, + emojis = actionableStatus.emojis.let(gson::toJson), + reblogsCount = actionableStatus.reblogsCount, + favouritesCount = actionableStatus.favouritesCount, + reblogged = actionableStatus.reblogged, + favourited = actionableStatus.favourited, + bookmarked = actionableStatus.bookmarked, + sensitive = actionableStatus.sensitive, + spoilerText = actionableStatus.spoilerText, + visibility = actionableStatus.visibility, + attachments = actionableStatus.attachments.let(gson::toJson), + mentions = actionableStatus.mentions.let(gson::toJson), + application = actionableStatus.application.let(gson::toJson), + reblogServerId = reblog?.id, + reblogAccountId = reblog?.let { this.account.id }, + poll = actionableStatus.poll.let(gson::toJson), + muted = actionableStatus.muted, + expanded = expanded, + contentShowing = contentShowing, + contentCollapsed = contentCollapsed, + pinned = actionableStatus.pinned == true + ) +} + +fun TimelineStatusWithAccount.toViewData(gson: Gson): StatusViewData { + if (this.status.authorServerId == null) { + 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 application = gson.fromJson(status.application, Status.Application::class.java) + val emojis: List = gson.fromJson(status.emojis, emojisListType) ?: emptyList() + val poll: Poll? = gson.fromJson(status.poll, Poll::class.java) + + val reblog = status.reblogServerId?.let { id -> + Status( + id = id, + url = status.url, + account = account.toAccount(gson), + inReplyToId = status.inReplyToId, + inReplyToAccountId = status.inReplyToAccountId, + reblog = null, + content = status.content?.parseAsHtml()?.trimTrailingWhitespace() + ?: SpannedString(""), + createdAt = Date(status.createdAt), + emojis = emojis, + reblogsCount = status.reblogsCount, + favouritesCount = status.favouritesCount, + reblogged = status.reblogged, + favourited = status.favourited, + bookmarked = status.bookmarked, + sensitive = status.sensitive, + spoilerText = status.spoilerText, + visibility = status.visibility, + attachments = attachments, + mentions = mentions, + application = application, + pinned = false, + muted = status.muted, + poll = poll, + card = null + ) + } + val status = if (reblog != null) { + Status( + id = status.serverId, + url = null, // no url for reblogs + account = this.reblogAccount!!.toAccount(gson), + inReplyToId = null, + inReplyToAccountId = null, + reblog = reblog, + content = SpannedString(""), + createdAt = Date(status.createdAt), // lie but whatever? + emojis = listOf(), + reblogsCount = 0, + favouritesCount = 0, + reblogged = false, + favourited = false, + bookmarked = false, + sensitive = false, + spoilerText = "", + visibility = status.visibility, + attachments = ArrayList(), + mentions = listOf(), + application = null, + pinned = status.pinned, + muted = status.muted, + poll = null, + card = null + ) + } else { + Status( + id = status.serverId, + url = status.url, + account = account.toAccount(gson), + inReplyToId = status.inReplyToId, + inReplyToAccountId = status.inReplyToAccountId, + reblog = null, + content = status.content?.parseAsHtml()?.trimTrailingWhitespace() + ?: SpannedString(""), + createdAt = Date(status.createdAt), + emojis = emojis, + reblogsCount = status.reblogsCount, + favouritesCount = status.favouritesCount, + reblogged = status.reblogged, + favourited = status.favourited, + bookmarked = status.bookmarked, + sensitive = status.sensitive, + spoilerText = status.spoilerText, + visibility = status.visibility, + attachments = attachments, + mentions = mentions, + application = application, + pinned = status.pinned, + muted = status.muted, + poll = poll, + card = null + ) + } + return StatusViewData.Concrete( + status = status, + isExpanded = this.status.expanded, + isShowingContent = this.status.contentShowing, + isCollapsible = shouldTrimStatus(status.content), + isCollapsed = this.status.contentCollapsed + ) +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineViewModel.kt deleted file mode 100644 index 1cc43d02e..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineViewModel.kt +++ /dev/null @@ -1,940 +0,0 @@ -package com.keylesspalace.tusky.components.timeline - -import android.content.SharedPreferences -import android.util.Log -import androidx.lifecycle.viewModelScope -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.StatusComposedEvent -import com.keylesspalace.tusky.appstore.StatusDeletedEvent -import com.keylesspalace.tusky.appstore.UnfollowEvent -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.network.TimelineCases -import com.keylesspalace.tusky.settings.PrefKeys -import com.keylesspalace.tusky.util.Either -import com.keylesspalace.tusky.util.HttpHeaderLink -import com.keylesspalace.tusky.util.LinkHelper -import com.keylesspalace.tusky.util.RxAwareViewModel -import com.keylesspalace.tusky.util.dec -import com.keylesspalace.tusky.util.firstIsInstanceOrNull -import com.keylesspalace.tusky.util.inc -import com.keylesspalace.tusky.util.isLessThan -import com.keylesspalace.tusky.util.toViewData -import com.keylesspalace.tusky.viewdata.StatusViewData -import io.reactivex.rxjava3.core.Observable -import io.reactivex.rxjava3.core.Single -import io.reactivex.rxjava3.subjects.PublishSubject -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.collect -import kotlinx.coroutines.launch -import kotlinx.coroutines.rx3.asFlow -import kotlinx.coroutines.rx3.await -import retrofit2.HttpException -import retrofit2.Response -import java.io.IOException -import javax.inject.Inject - -class TimelineViewModel @Inject constructor( - private val timelineRepo: TimelineRepository, - private val timelineCases: TimelineCases, - private val api: MastodonApi, - private val eventHub: EventHub, - private val accountManager: AccountManager, - private val sharedPreferences: SharedPreferences, - private val filterModel: FilterModel, -) : RxAwareViewModel() { - - enum class FailureReason { - NETWORK, - OTHER, - } - - val viewUpdates: Observable - get() = updateViewSubject - - var kind: Kind = Kind.HOME - private set - - var isLoadingInitially = false - private set - var isRefreshing = false - private set - var bottomLoading = false - private set - var initialUpdateFailed = false - private set - var failure: FailureReason? = null - private set - var id: String? = null - private set - var tags: List = emptyList() - private set - - private var alwaysShowSensitiveMedia = false - private var alwaysOpenSpoilers = false - private var filterRemoveReplies = false - private var filterRemoveReblogs = false - private var didLoadEverythingBottom = false - - private var updateViewSubject = PublishSubject.create() - - /** - * For some timeline kinds we must use LINK headers and not just status ids. - */ - private var nextId: String? = null - - val statuses = mutableListOf() - - fun init( - kind: Kind, - id: String?, - tags: List - ) { - this.kind = kind - this.id = id - this.tags = tags - - if (kind == Kind.HOME) { - filterRemoveReplies = - !sharedPreferences.getBoolean(PrefKeys.TAB_FILTER_HOME_REPLIES, true) - filterRemoveReblogs = - !sharedPreferences.getBoolean(PrefKeys.TAB_FILTER_HOME_BOOSTS, true) - } - this.alwaysShowSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia - this.alwaysOpenSpoilers = accountManager.activeAccount!!.alwaysOpenSpoiler - - viewModelScope.launch { - eventHub.events - .asFlow() - .collect { event -> handleEvent(event) } - } - - reloadFilters() - } - - private suspend fun updateCurrent() { - val topId = statuses.firstIsInstanceOrNull()?.id ?: return - // Request statuses including current top to refresh all of them - val topIdMinusOne = topId.inc() - val statuses = try { - loadStatuses( - maxId = topIdMinusOne, - sinceId = null, - sinceIdMinusOne = null, - TimelineRequestMode.NETWORK, - ) - } catch (t: Exception) { - initialUpdateFailed = true - if (isExpectedRequestException(t)) { - Log.d(TAG, "Failed updating timeline", t) - triggerViewUpdate() - return - } else { - throw t - } - } - - initialUpdateFailed = false - - // When cached timeline is too old, we would replace it with nothing - if (statuses.isNotEmpty()) { - val mutableStatuses = statuses.toMutableList() - filterStatuses(mutableStatuses) - this.statuses.removeAll { item -> - val id = when (item) { - is StatusViewData.Concrete -> item.id - is StatusViewData.Placeholder -> item.id - } - - id == topId || id.isLessThan(topId) - } - this.statuses.addAll(mutableStatuses.toViewData()) - } - triggerViewUpdate() - } - - private fun isExpectedRequestException(t: Exception) = t is IOException || t is HttpException - - fun refresh(): Job { - return viewModelScope.launch { - isRefreshing = true - failure = null - triggerViewUpdate() - - try { - if (initialUpdateFailed) updateCurrent() - loadAbove() - } catch (e: Exception) { - if (isExpectedRequestException(e)) { - Log.e(TAG, "Failed to refresh", e) - } else { - throw e - } - } finally { - isRefreshing = false - triggerViewUpdate() - } - } - } - - /** When reaching the end of list. WIll optionally show spinner in the end of list. */ - fun loadMore(): Job { - return viewModelScope.launch { - if (didLoadEverythingBottom || bottomLoading) { - return@launch - } - if (statuses.isEmpty()) { - loadInitial().join() - return@launch - } - setLoadingPlaceholderBelow() - - val bottomId: String? = - if (kind == Kind.FAVOURITES || kind == Kind.BOOKMARKS) { - nextId - } else { - statuses.lastOrNull { it is StatusViewData.Concrete } - ?.let { (it as StatusViewData.Concrete).id } - } - try { - loadBelow(bottomId) - } catch (e: Exception) { - if (isExpectedRequestException(e)) { - if (statuses.lastOrNull() is StatusViewData.Placeholder) { - statuses.removeAt(statuses.lastIndex) - } - } else { - throw e - } - } finally { - triggerViewUpdate() - } - } - } - - /** Load and insert statuses below the [bottomId]. Does not indicate progress. */ - private suspend fun loadBelow(bottomId: String?) { - this.bottomLoading = true - try { - val statuses = loadStatuses( - bottomId, - null, - null, - TimelineRequestMode.ANY - ) - addStatusesBelow(statuses.toMutableList()) - } finally { - this.bottomLoading = false - } - } - - private fun setLoadingPlaceholderBelow() { - val last = statuses.last() - val placeholder: StatusViewData.Placeholder - if (last is StatusViewData.Concrete) { - val placeholderId = last.id.dec() - placeholder = StatusViewData.Placeholder(placeholderId, true) - statuses.add(placeholder) - } else { - placeholder = last as StatusViewData.Placeholder - } - statuses[statuses.lastIndex] = placeholder - triggerViewUpdate() - } - - private fun addStatusesBelow(statuses: MutableList>) { - val fullFetch = isFullFetch(statuses) - // Remove placeholder in the bottom if it's there - if (this.statuses.isNotEmpty() && - this.statuses.last() !is StatusViewData.Concrete - ) { - this.statuses.removeAt(this.statuses.lastIndex) - } - - // Removing placeholder if it's the last one from the cache - if (statuses.isNotEmpty() && !statuses[statuses.size - 1].isRight()) { - statuses.removeAt(statuses.size - 1) - } - - val oldSize = this.statuses.size - if (this.statuses.isNotEmpty()) { - addItems(statuses) - } else { - updateStatuses(statuses, fullFetch) - } - if (this.statuses.size == oldSize) { - // This may be a brittle check but seems like it works - // Can we check it using headers somehow? Do all server support them? - didLoadEverythingBottom = true - } - } - - fun loadGap(position: Int): Job { - return viewModelScope.launch { - // check bounds before accessing list, - if (statuses.size < position || position <= 0) { - Log.e(TAG, "Wrong gap position: $position") - return@launch - } - - val fromStatus = statuses[position - 1].asStatusOrNull() - val toStatus = statuses[position + 1].asStatusOrNull() - val toMinusOne = statuses.getOrNull(position + 2)?.asStatusOrNull()?.id - if (fromStatus == null || toStatus == null) { - Log.e(TAG, "Failed to load more at $position, wrong placeholder position") - return@launch - } - val placeholder = statuses[position].asPlaceholderOrNull() ?: run { - Log.e(TAG, "Not a placeholder at $position") - return@launch - } - - val newViewData: StatusViewData = StatusViewData.Placeholder(placeholder.id, true) - statuses[position] = newViewData - triggerViewUpdate() - - try { - val statuses = loadStatuses( - fromStatus.id, - toStatus.id, - toMinusOne, - TimelineRequestMode.NETWORK - ) - replacePlaceholderWithStatuses( - statuses.toMutableList(), - isFullFetch(statuses), - position - ) - } catch (t: Exception) { - if (isExpectedRequestException(t)) { - Log.e(TAG, "Failed to load gap", t) - if (statuses[position] is StatusViewData.Placeholder) { - statuses[position] = StatusViewData.Placeholder(placeholder.id, false) - } - } else { - throw t - } - } - } - } - - fun reblog(reblog: Boolean, position: Int): Job = viewModelScope.launch { - val status = statuses[position].asStatusOrNull() ?: return@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, position: Int): Job = viewModelScope.launch { - val status = statuses[position].asStatusOrNull() ?: return@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, position: Int): Job = viewModelScope.launch { - val status = statuses[position].asStatusOrNull() ?: return@launch - try { - timelineCases.bookmark(status.actionableId, bookmark).await() - } catch (t: Exception) { - ifExpected(t) { - Log.d(TAG, "Failed to favourite status " + status.actionableId, t) - } - } - } - - fun voteInPoll(position: Int, choices: List): Job = viewModelScope.launch { - val status = statuses[position].asStatusOrNull() ?: return@launch - - val poll = status.status.poll ?: run { - Log.w(TAG, "No poll on status ${status.id}") - return@launch - } - - val votedPoll = poll.votedCopy(choices) - updatePoll(status, votedPoll) - - 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) - } - } - } - - private fun updatePoll( - status: StatusViewData.Concrete, - newPoll: Poll - ) { - updateStatusById(status.id) { - it.copy(status = it.status.copy(poll = newPoll)) - } - } - - fun changeExpanded(expanded: Boolean, position: Int) { - updateStatusAt(position) { it.copy(isExpanded = expanded) } - triggerViewUpdate() - } - - fun changeContentHidden(isShowing: Boolean, position: Int) { - updateStatusAt(position) { it.copy(isShowingContent = isShowing) } - triggerViewUpdate() - } - - fun changeContentCollapsed(isCollapsed: Boolean, position: Int) { - updateStatusAt(position) { it.copy(isCollapsed = isCollapsed) } - triggerViewUpdate() - } - - private fun removeAllByAccountId(accountId: String) { - statuses.removeAll { vm -> - val status = vm.asStatusOrNull()?.status ?: return@removeAll false - status.account.id == accountId || status.actionableStatus.account.id == accountId - } - } - - private fun removeAllByInstance(instance: String) { - statuses.removeAll { vd -> - val status = vd.asStatusOrNull()?.status ?: return@removeAll false - LinkHelper.getDomain(status.account.url) == instance - } - } - - private fun triggerViewUpdate() { - this.updateViewSubject.onNext(Unit) - } - - private suspend fun loadStatuses( - maxId: String?, - sinceId: String?, - sinceIdMinusOne: String?, - homeMode: TimelineRequestMode, - ): List { - val statuses = if (kind == Kind.HOME) { - timelineRepo.getStatuses(maxId, sinceId, sinceIdMinusOne, LOAD_AT_ONCE, homeMode) - .await() - } else { - val response = fetchStatusesForKind(maxId, sinceId, LOAD_AT_ONCE).await() - if (response.isSuccessful) { - val newNextId = extractNextId(response) - if (newNextId != null) { - // when we reach the bottom of the list, we won't have a new link. If - // we blindly write `null` here we will start loading from the top - // again. - nextId = newNextId - } - response.body()?.map { Either.Right(it) } ?: listOf() - } else { - throw HttpException(response) - } - }.toMutableList() - - filterStatuses(statuses) - - return statuses - } - - private fun updateStatuses( - newStatuses: MutableList>, - fullFetch: Boolean - ) { - if (statuses.isEmpty()) { - statuses.addAll(newStatuses.toViewData()) - } else { - val lastOfNew = newStatuses.lastOrNull() - val index = if (lastOfNew == null) -1 - else statuses.indexOfLast { it.asStatusOrNull()?.id === lastOfNew.asRightOrNull()?.id } - if (index >= 0) { - statuses.subList(0, index).clear() - } - - val newIndex = - newStatuses.indexOfFirst { - it.isRight() && it.asRight().id == (statuses[0] as? StatusViewData.Concrete)?.id - } - if (newIndex == -1) { - if (index == -1 && fullFetch) { - val placeholderId = - newStatuses.last { status -> status.isRight() }.asRight().id.inc() - newStatuses.add(Either.Left(Placeholder(placeholderId))) - } - statuses.addAll(0, newStatuses.toViewData()) - } else { - statuses.addAll(0, newStatuses.subList(0, newIndex).toViewData()) - } - } - // Remove all consecutive placeholders - removeConsecutivePlaceholders() - this.triggerViewUpdate() - } - - private fun filterViewData(viewData: MutableList) { - viewData.removeAll { vd -> - vd.asStatusOrNull()?.status?.let { shouldFilterStatus(it) } ?: false - } - } - - private fun filterStatuses(statuses: MutableList>) { - statuses.removeAll { status -> - status.asRightOrNull()?.let { shouldFilterStatus(it) } ?: false - } - } - - private fun shouldFilterStatus(status: Status): Boolean { - return status.inReplyToId != null && filterRemoveReplies || - status.reblog != null && filterRemoveReblogs || - filterModel.shouldFilterStatus(status.actionableStatus) - } - - private fun extractNextId(response: Response<*>): String? { - val linkHeader = response.headers()["Link"] ?: return null - val links = HttpHeaderLink.parse(linkHeader) - val nextHeader = HttpHeaderLink.findByRelationType(links, "next") ?: return null - val nextLink = nextHeader.uri ?: return null - return nextLink.getQueryParameter("max_id") - } - - private suspend fun tryCache() { - // Request timeline from disk to make it quick, then replace it with timeline from - // the server to update it - val statuses = - timelineRepo.getStatuses(null, null, null, LOAD_AT_ONCE, TimelineRequestMode.DISK) - .await() - - val mutableStatusResponse = statuses.toMutableList() - filterStatuses(mutableStatusResponse) - if (statuses.size > 1) { - clearPlaceholdersForResponse(mutableStatusResponse) - this.statuses.clear() - this.statuses.addAll(mutableStatusResponse.toViewData()) - } - } - - fun loadInitial(): Job { - return viewModelScope.launch { - if (statuses.isNotEmpty() || initialUpdateFailed || isLoadingInitially) { - return@launch - } - isLoadingInitially = true - failure = null - triggerViewUpdate() - - if (kind == Kind.HOME) { - tryCache() - isLoadingInitially = statuses.isEmpty() - updateCurrent() - try { - loadAbove() - } catch (e: Exception) { - Log.e(TAG, "Loading above failed", e) - if (!isExpectedRequestException(e)) { - throw e - } else if (statuses.isEmpty()) { - failure = - if (e is IOException) FailureReason.NETWORK - else FailureReason.OTHER - } - } finally { - isLoadingInitially = false - triggerViewUpdate() - } - } else { - try { - loadBelow(null) - } catch (e: IOException) { - failure = FailureReason.NETWORK - } catch (e: HttpException) { - failure = FailureReason.OTHER - } finally { - isLoadingInitially = false - triggerViewUpdate() - } - } - } - } - - private suspend fun loadAbove() { - var firstOrNull: String? = null - var secondOrNull: String? = null - for (i in statuses.indices) { - val status = statuses[i].asStatusOrNull() ?: continue - firstOrNull = status.id - secondOrNull = statuses.getOrNull(i + 1)?.asStatusOrNull()?.id - break - } - - try { - if (firstOrNull != null) { - triggerViewUpdate() - - val statuses = loadStatuses( - maxId = null, - sinceId = firstOrNull, - sinceIdMinusOne = secondOrNull, - homeMode = TimelineRequestMode.NETWORK - ) - - val fullFetch = isFullFetch(statuses) - updateStatuses(statuses.toMutableList(), fullFetch) - } else { - loadBelow(null) - } - } finally { - triggerViewUpdate() - } - } - - private fun isFullFetch(statuses: List) = statuses.size >= LOAD_AT_ONCE - - private fun fullyRefresh(): Job { - this.statuses.clear() - return loadInitial() - } - - private fun fetchStatusesForKind( - fromId: String?, - uptoId: String?, - limit: Int - ): Single>> { - return when (kind) { - Kind.HOME -> api.homeTimeline(fromId, uptoId, 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 replacePlaceholderWithStatuses( - newStatuses: MutableList>, - fullFetch: Boolean, - pos: Int - ) { - val placeholder = statuses[pos] - if (placeholder is StatusViewData.Placeholder) { - statuses.removeAt(pos) - } - if (newStatuses.isEmpty()) { - return - } - val newViewData = newStatuses - .toViewData() - .toMutableList() - - if (fullFetch) { - newViewData.add(placeholder) - } - statuses.addAll(pos, newViewData) - removeConsecutivePlaceholders() - triggerViewUpdate() - } - - private fun removeConsecutivePlaceholders() { - for (i in 0 until statuses.size - 1) { - if (statuses[i] is StatusViewData.Placeholder && - statuses[i + 1] is StatusViewData.Placeholder - ) { - statuses.removeAt(i) - } - } - } - - private fun addItems(newStatuses: List>) { - if (newStatuses.isEmpty()) { - return - } - statuses.addAll(newStatuses.toViewData()) - removeConsecutivePlaceholders() - } - - /** - * For certain requests we don't want to see placeholders, they will be removed some other way - */ - private fun clearPlaceholdersForResponse(statuses: MutableList>) { - statuses.removeAll { status -> status.isLeft() } - } - - private fun handleReblogEvent(reblogEvent: ReblogEvent) { - updateStatusById(reblogEvent.statusId) { - it.copy(status = it.status.copy(reblogged = reblogEvent.reblog)) - } - } - - private fun handleFavEvent(favEvent: FavoriteEvent) { - updateActionableStatusById(favEvent.statusId) { - it.copy(favourited = favEvent.favourite) - } - } - - private fun handleBookmarkEvent(bookmarkEvent: BookmarkEvent) { - updateActionableStatusById(bookmarkEvent.statusId) { - it.copy(bookmarked = bookmarkEvent.bookmark) - } - } - - private fun handlePinEvent(pinEvent: PinEvent) { - updateActionableStatusById(pinEvent.statusId) { - it.copy(pinned = pinEvent.pinned) - } - } - - private fun handleStatusComposeEvent(status: Status) { - when (kind) { - Kind.HOME, Kind.PUBLIC_FEDERATED, Kind.PUBLIC_LOCAL -> refresh() - Kind.USER, Kind.USER_WITH_REPLIES -> if (status.account.id == id) { - refresh() - } else { - return - } - Kind.TAG, Kind.FAVOURITES, Kind.LIST, Kind.BOOKMARKS, Kind.USER_PINNED -> return - } - } - - private fun deleteStatusById(id: String) { - for (i in statuses.indices) { - val either = statuses[i] - if (either.asStatusOrNull()?.id == id) { - statuses.removeAt(i) - break - } - } - } - - private fun onPreferenceChanged(key: String) { - when (key) { - PrefKeys.TAB_FILTER_HOME_REPLIES -> { - val filter = sharedPreferences.getBoolean(PrefKeys.TAB_FILTER_HOME_REPLIES, true) - val oldRemoveReplies = filterRemoveReplies - filterRemoveReplies = kind == Kind.HOME && !filter - if (statuses.isNotEmpty() && oldRemoveReplies != filterRemoveReplies) { - fullyRefresh() - } - } - PrefKeys.TAB_FILTER_HOME_BOOSTS -> { - val filter = sharedPreferences.getBoolean(PrefKeys.TAB_FILTER_HOME_BOOSTS, true) - val oldRemoveReblogs = filterRemoveReblogs - filterRemoveReblogs = kind == Kind.HOME && !filter - if (statuses.isNotEmpty() && oldRemoveReblogs != filterRemoveReblogs) { - fullyRefresh() - } - } - Filter.HOME, Filter.NOTIFICATIONS, Filter.THREAD, Filter.PUBLIC, Filter.ACCOUNT -> { - if (filterContextMatchesKind(kind, listOf(key))) { - reloadFilters() - } - } - PrefKeys.ALWAYS_SHOW_SENSITIVE_MEDIA -> { - // it is ok if only newly loaded statuses are affected, no need to fully refresh - alwaysShowSensitiveMedia = - accountManager.activeAccount!!.alwaysShowSensitiveMedia - } - } - } - - // public for now - fun filterContextMatchesKind( - kind: Kind, - filterContext: List - ): Boolean { - // home, notifications, public, thread - return when (kind) { - Kind.HOME, Kind.LIST -> filterContext.contains( - Filter.HOME - ) - Kind.PUBLIC_FEDERATED, Kind.PUBLIC_LOCAL, Kind.TAG -> filterContext.contains( - Filter.PUBLIC - ) - Kind.FAVOURITES -> filterContext.contains(Filter.PUBLIC) || filterContext.contains( - Filter.NOTIFICATIONS - ) - Kind.USER, Kind.USER_WITH_REPLIES, Kind.USER_PINNED -> filterContext.contains( - Filter.ACCOUNT - ) - else -> false - } - } - - private fun handleEvent(event: Event) { - when (event) { - is FavoriteEvent -> handleFavEvent(event) - is ReblogEvent -> handleReblogEvent(event) - is BookmarkEvent -> handleBookmarkEvent(event) - is PinEvent -> handlePinEvent(event) - is MuteConversationEvent -> fullyRefresh() - is UnfollowEvent -> { - if (kind == Kind.HOME) { - val id = event.accountId - removeAllByAccountId(id) - } - } - is BlockEvent -> { - if (kind != Kind.USER && kind != Kind.USER_WITH_REPLIES && kind != Kind.USER_PINNED) { - val id = event.accountId - removeAllByAccountId(id) - } - } - is MuteEvent -> { - if (kind != Kind.USER && kind != Kind.USER_WITH_REPLIES && kind != Kind.USER_PINNED) { - val id = event.accountId - removeAllByAccountId(id) - } - } - is DomainMuteEvent -> { - if (kind != Kind.USER && kind != Kind.USER_WITH_REPLIES && kind != Kind.USER_PINNED) { - val instance = event.instance - removeAllByInstance(instance) - } - } - is StatusDeletedEvent -> { - if (kind != Kind.USER && kind != Kind.USER_WITH_REPLIES && kind != Kind.USER_PINNED) { - val id = event.statusId - deleteStatusById(id) - } - } - is StatusComposedEvent -> { - val status = event.status - handleStatusComposeEvent(status) - } - is PreferenceChangedEvent -> { - onPreferenceChanged(event.preferenceKey) - } - } - } - - private inline fun updateActionableStatusById( - id: String, - updater: (Status) -> Status - ) { - val pos = statuses.indexOfFirst { it.asStatusOrNull()?.id == id } - if (pos == -1) return - updateStatusAt(pos) { - if (it.status.reblog != null) { - it.copy(status = it.status.copy(reblog = updater(it.status.reblog))) - } else { - it.copy(status = updater(it.status)) - } - } - } - - private inline fun updateStatusById( - id: String, - updater: (StatusViewData.Concrete) -> StatusViewData.Concrete - ) { - val pos = statuses.indexOfFirst { it.asStatusOrNull()?.id == id } - if (pos == -1) return - updateStatusAt(pos, updater) - } - - private inline fun updateStatusAt( - position: Int, - updater: (StatusViewData.Concrete) -> StatusViewData.Concrete - ) { - val status = statuses.getOrNull(position)?.asStatusOrNull() ?: return - statuses[position] = updater(status) - triggerViewUpdate() - } - - private fun List.toViewData(): List = this.map { - when (it) { - is Either.Right -> it.value.toViewData( - alwaysShowSensitiveMedia, - alwaysOpenSpoilers - ) - is Either.Left -> StatusViewData.Placeholder(it.value.id, false) - } - } - - private fun reloadFilters() { - viewModelScope.launch { - val filters = try { - api.getFilters().await() - } catch (t: Exception) { - Log.e(TAG, "Failed to fetch filters", t) - return@launch - } - filterModel.initWithFilters( - filters.filter { - filterContextMatchesKind(kind, it.context) - } - ) - filterViewData(this@TimelineViewModel.statuses) - } - } - - private inline fun ifExpected( - t: Exception, - cb: () -> Unit - ) { - if (isExpectedRequestException(t)) { - cb() - } else { - throw t - } - } - - companion object { - private const val TAG = "TimelineVM" - internal const val LOAD_AT_ONCE = 30 - } - - enum class Kind { - HOME, PUBLIC_LOCAL, PUBLIC_FEDERATED, TAG, USER, USER_PINNED, USER_WITH_REPLIES, FAVOURITES, LIST, BOOKMARKS - } -} 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 new file mode 100644 index 000000000..1d23ff210 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt @@ -0,0 +1,154 @@ +/* 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 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.db.AccountManager +import com.keylesspalace.tusky.db.AppDatabase +import com.keylesspalace.tusky.db.TimelineStatusEntity +import com.keylesspalace.tusky.db.TimelineStatusWithAccount +import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.util.dec +import kotlinx.coroutines.rx3.await +import retrofit2.HttpException + +@ExperimentalPagingApi +class CachedTimelineRemoteMediator( + accountManager: AccountManager, + private val api: MastodonApi, + private val db: AppDatabase, + private val gson: Gson +) : RemoteMediator() { + + private var initialRefresh = false + + private val timelineDao = db.timelineDao() + private val activeAccount = accountManager.activeAccount!! + + override suspend fun load( + loadType: LoadType, + state: PagingState + ): MediatorResult { + + try { + var dbEmpty = false + if (!initialRefresh && loadType == LoadType.REFRESH) { + val topId = timelineDao.getTopId(activeAccount.id) + topId?.let { cachedTopId -> + val statusResponse = api.homeTimeline( + maxId = cachedTopId, + limit = state.config.pageSize + ).await() + + val statuses = statusResponse.body() + if (statusResponse.isSuccessful && statuses != null) { + db.withTransaction { + replaceStatusRange(statuses, state) + } + } + } + initialRefresh = true + dbEmpty = topId == null + } + + val statusResponse = when (loadType) { + LoadType.REFRESH -> { + api.homeTimeline(limit = state.config.pageSize).await() + } + LoadType.PREPEND -> { + return MediatorResult.Success(endOfPaginationReached = true) + } + LoadType.APPEND -> { + val maxId = state.pages.findLast { it.data.isNotEmpty() }?.data?.lastOrNull()?.status?.serverId + api.homeTimeline(maxId = maxId, limit = state.config.pageSize).await() + } + } + + val statuses = statusResponse.body() + if (!statusResponse.isSuccessful || statuses == null) { + return MediatorResult.Error(HttpException(statusResponse)) + } + + db.withTransaction { + val overlappedStatuses = replaceStatusRange(statuses, state) + + if (loadType == LoadType.REFRESH && overlappedStatuses == 0 && statuses.isNotEmpty() && !dbEmpty) { + timelineDao.insertStatus( + Placeholder(statuses.last().id.dec(), loading = false).toEntity(activeAccount.id) + ) + } + } + return MediatorResult.Success(endOfPaginationReached = statuses.isEmpty()) + } catch (e: Exception) { + return MediatorResult.Error(e) + } + } + + /** + * Deletes all statuses in a given range and inserts new statuses. + * This is necessary so statuses that have been deleted on the server are cleaned up. + * Should be run in a transaction as it executes multiple db updates + * @param statuses the new statuses + * @return the number of old statuses that have been cleared from the database + */ + private suspend fun replaceStatusRange(statuses: List, state: PagingState): Int { + 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) + } + + // check if we already have one of the newly loaded statuses cached locally + // in case we do, copy the local state (expanded, contentShowing, contentCollapsed) over so it doesn't get lost + var oldStatus: TimelineStatusEntity? = null + for (page in state.pages) { + oldStatus = page.data.find { s -> + s.status.serverId == status.id + }?.status + if (oldStatus != null) break + } + + val expanded = oldStatus?.expanded ?: activeAccount.alwaysOpenSpoiler + val contentShowing = oldStatus?.contentShowing ?: activeAccount.alwaysShowSensitiveMedia || !status.actionableStatus.sensitive + val contentCollapsed = oldStatus?.contentCollapsed ?: true + + timelineDao.insertStatus( + status.toEntity( + timelineUserId = activeAccount.id, + gson = gson, + expanded = expanded, + contentShowing = contentShowing, + contentCollapsed = contentCollapsed + ) + ) + } + return overlappedStatuses + } +} 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 new file mode 100644 index 000000000..066949d02 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt @@ -0,0 +1,208 @@ +/* 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.db.AccountManager +import com.keylesspalace.tusky.db.AppDatabase +import com.keylesspalace.tusky.entity.Poll +import com.keylesspalace.tusky.network.FilterModel +import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.network.TimelineCases +import com.keylesspalace.tusky.util.dec +import com.keylesspalace.tusky.util.inc +import com.keylesspalace.tusky.viewdata.StatusViewData +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.coroutines.rx3.await +import retrofit2.HttpException +import javax.inject.Inject + +/** + * 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 +) : TimelineViewModel(timelineCases, api, eventHub, accountManager, sharedPreferences, filterModel) { + + @ExperimentalPagingApi + override val statuses = Pager( + config = PagingConfig(pageSize = LOAD_AT_ONCE), + remoteMediator = CachedTimelineRemoteMediator(accountManager, api, db, gson), + pagingSourceFactory = { db.timelineDao().getStatusesForAccount(accountManager.activeAccount!!.id) } + ).flow + .map { pagingData -> + pagingData.map { timelineStatus -> + timelineStatus.toViewData(gson) + } + } + .map { pagingData -> + pagingData.filter { statusViewData -> + !shouldFilterStatus(statusViewData) + } + } + .cachedIn(viewModelScope) + + 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 = api.homeTimeline(maxId = placeholderId.inc(), limit = 20).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 + ) + ) + } + + if (overlappedStatuses == 0) { + timelineDao.insertStatus( + Placeholder(statuses.last().id.dec(), loading = false).toEntity(activeAccount.id) + ) + } + } + } catch (e: java.lang.Exception) { + 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 fullReload() { + viewModelScope.launch { + val activeAccount = accountManager.activeAccount!! + db.runInTransaction { + db.timelineDao().removeAllForAccount(activeAccount.id) + db.timelineDao().removeAllUsersForAccount(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 new file mode 100644 index 000000000..56236ecf8 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelinePagingSource.kt @@ -0,0 +1,37 @@ +/* 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.PagingSource +import androidx.paging.PagingState +import com.keylesspalace.tusky.viewdata.StatusViewData + +class NetworkTimelinePagingSource( + private val viewModel: NetworkTimelineViewModel +) : PagingSource() { + + override fun getRefreshKey(state: PagingState): String? = null + + override suspend fun load(params: LoadParams): LoadResult { + + return if (params is LoadParams.Refresh) { + val list = viewModel.statusData.toList() + LoadResult.Page(list, null, viewModel.nextKey) + } else { + LoadResult.Page(emptyList(), null, null) + } + } +} 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 000000000..55a187670 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt @@ -0,0 +1,109 @@ +/* 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.db.AccountManager +import com.keylesspalace.tusky.util.HttpHeaderLink +import com.keylesspalace.tusky.util.dec +import com.keylesspalace.tusky.util.toViewData +import com.keylesspalace.tusky.viewdata.StatusViewData +import retrofit2.HttpException + +@ExperimentalPagingApi +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 + viewModel.fetchStatusesForKind(maxId, null, limit = state.config.pageSize) + } + } + + 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.add(statuses.size, StatusViewData.Placeholder(statuses.last().id.dec(), 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 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 new file mode 100644 index 000000000..b0bdfbcba --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt @@ -0,0 +1,302 @@ +/* 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 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.db.AccountManager +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.util.LinkHelper +import com.keylesspalace.tusky.util.inc +import com.keylesspalace.tusky.util.toViewData +import com.keylesspalace.tusky.viewdata.StatusViewData +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.coroutines.rx3.await +import retrofit2.HttpException +import retrofit2.Response +import javax.inject.Inject + +/** + * TimelineViewModel that caches all statuses in an in-memory list + */ +class NetworkTimelineViewModel @Inject constructor( + timelineCases: TimelineCases, + private val api: MastodonApi, + eventHub: EventHub, + accountManager: AccountManager, + sharedPreferences: SharedPreferences, + filterModel: FilterModel +) : TimelineViewModel(timelineCases, api, eventHub, accountManager, sharedPreferences, filterModel) { + + var currentSource: NetworkTimelinePagingSource? = null + + val statusData: MutableList = mutableListOf() + + var nextKey: String? = null + + @ExperimentalPagingApi + override val statuses = Pager( + config = PagingConfig(pageSize = LOAD_AT_ONCE), + pagingSourceFactory = { + NetworkTimelinePagingSource( + viewModel = this + ).also { source -> + currentSource = source + } + }, + remoteMediator = NetworkTimelineRemoteMediator(accountManager, this) + ).flow + .map { pagingData -> + pagingData.filter { statusViewData -> + !shouldFilterStatus(statusViewData) + } + } + .cachedIn(viewModelScope) + + override fun updatePoll(newPoll: Poll, status: StatusViewData.Concrete) { + status.copy( + status = status.status.copy(poll = newPoll) + ).update() + } + + override fun changeExpanded(expanded: Boolean, status: StatusViewData.Concrete) { + status.copy( + isExpanded = expanded + ).update() + } + + override fun changeContentShowing(isShowing: Boolean, status: StatusViewData.Concrete) { + status.copy( + isShowingContent = isShowing + ).update() + } + + override fun changeContentCollapsed(isCollapsed: Boolean, status: StatusViewData.Concrete) { + status.copy( + isCollapsed = isCollapsed + ).update() + } + + override fun removeAllByAccountId(accountId: String) { + statusData.removeAll { vd -> + val status = vd.asStatusOrNull()?.status ?: return@removeAll false + 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 + LinkHelper.getDomain(status.account.url) == instance + } + currentSource?.invalidate() + } + + override fun removeStatusWithId(id: String) { + statusData.removeAll { vd -> + val status = vd.asStatusOrNull()?.status ?: return@removeAll false + status.id == id || status.reblog?.id == id + } + currentSource?.invalidate() + } + + override fun loadMore(placeholderId: String) { + viewModelScope.launch { + try { + val statusResponse = fetchStatusesForKind( + fromId = placeholderId.inc(), + uptoId = null, + limit = 20 + ) + + val statuses = statusResponse.body() + if (!statusResponse.isSuccessful || statuses == null) { + loadMoreFailed(placeholderId, HttpException(statusResponse)) + return@launch + } + + val activeAccount = accountManager.activeAccount!! + + val data = statuses.map { status -> + val oldStatus = 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 + ) + } + + val index = + statusData.indexOfFirst { it is StatusViewData.Placeholder && it.id == placeholderId } + statusData.removeAt(index) + statusData.addAll(index, data) + + currentSource?.invalidate() + } catch (e: Exception) { + 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)) + } + } + + override fun handleFavEvent(favEvent: FavoriteEvent) { + updateActionableStatusById(favEvent.statusId) { + it.copy(favourited = favEvent.favourite) + } + } + + override fun handleBookmarkEvent(bookmarkEvent: BookmarkEvent) { + updateActionableStatusById(bookmarkEvent.statusId) { + it.copy(bookmarked = bookmarkEvent.bookmark) + } + } + + override fun handlePinEvent(pinEvent: PinEvent) { + updateActionableStatusById(pinEvent.statusId) { + it.copy(pinned = pinEvent.pinned) + } + } + + override fun fullReload() { + statusData.clear() + currentSource?.invalidate() + } + + suspend fun fetchStatusesForKind( + fromId: String?, + uptoId: String?, + limit: Int + ): Response> { + return when (kind) { + Kind.HOME -> api.homeTimeline(fromId, uptoId, 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) + }.await() + } + + private fun StatusViewData.Concrete.update() { + 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 } + if (pos == -1) return + updateViewDataAt(pos, updater) + } + + private inline fun updateActionableStatusById( + id: String, + updater: (Status) -> Status + ) { + val pos = statusData.indexOfFirst { it.asStatusOrNull()?.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.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 new file mode 100644 index 000000000..95a31dd11 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt @@ -0,0 +1,316 @@ +/* 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.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.paging.PagingData +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.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.network.TimelineCases +import com.keylesspalace.tusky.settings.PrefKeys +import com.keylesspalace.tusky.viewdata.StatusViewData +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch +import kotlinx.coroutines.rx3.asFlow +import kotlinx.coroutines.rx3.await +import retrofit2.HttpException +import java.io.IOException + +abstract class TimelineViewModel( + private val timelineCases: TimelineCases, + private val api: MastodonApi, + private val eventHub: EventHub, + protected val accountManager: AccountManager, + private val sharedPreferences: SharedPreferences, + private val filterModel: FilterModel +) : ViewModel() { + + abstract val statuses: Flow> + + var kind: Kind = Kind.HOME + private set + var id: String? = null + private set + var tags: List = emptyList() + private set + + protected var alwaysShowSensitiveMedia = false + protected var alwaysOpenSpoilers = false + private var filterRemoveReplies = false + private var filterRemoveReblogs = false + + fun init( + kind: Kind, + id: String?, + tags: List + ) { + this.kind = kind + this.id = id + this.tags = tags + + if (kind == Kind.HOME) { + filterRemoveReplies = + !sharedPreferences.getBoolean(PrefKeys.TAB_FILTER_HOME_REPLIES, true) + filterRemoveReblogs = + !sharedPreferences.getBoolean(PrefKeys.TAB_FILTER_HOME_BOOSTS, true) + } + this.alwaysShowSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia + this.alwaysOpenSpoilers = accountManager.activeAccount!!.alwaysOpenSpoiler + + viewModelScope.launch { + eventHub.events + .asFlow() + .collect { event -> handleEvent(event) } + } + + 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 favourite 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) + + abstract fun changeContentShowing(isShowing: Boolean, status: StatusViewData.Concrete) + + abstract fun changeContentCollapsed(isCollapsed: Boolean, status: StatusViewData.Concrete) + + abstract fun removeAllByAccountId(accountId: String) + + abstract fun removeAllByInstance(instance: String) + + abstract fun removeStatusWithId(id: String) + + abstract fun loadMore(placeholderId: String) + + abstract fun handleReblogEvent(reblogEvent: ReblogEvent) + + abstract fun handleFavEvent(favEvent: FavoriteEvent) + + abstract fun handleBookmarkEvent(bookmarkEvent: BookmarkEvent) + + abstract fun handlePinEvent(pinEvent: PinEvent) + + abstract fun fullReload() + + protected fun shouldFilterStatus(statusViewData: StatusViewData): Boolean { + val status = statusViewData.asStatusOrNull()?.status ?: return false + return status.inReplyToId != null && filterRemoveReplies || + status.reblog != null && filterRemoveReblogs || + filterModel.shouldFilterStatus(status.actionableStatus) + } + + private fun onPreferenceChanged(key: String) { + when (key) { + PrefKeys.TAB_FILTER_HOME_REPLIES -> { + val filter = sharedPreferences.getBoolean(PrefKeys.TAB_FILTER_HOME_REPLIES, true) + val oldRemoveReplies = filterRemoveReplies + filterRemoveReplies = kind == Kind.HOME && !filter + if (oldRemoveReplies != filterRemoveReplies) { + fullReload() + } + } + PrefKeys.TAB_FILTER_HOME_BOOSTS -> { + val filter = sharedPreferences.getBoolean(PrefKeys.TAB_FILTER_HOME_BOOSTS, true) + val oldRemoveReblogs = filterRemoveReblogs + filterRemoveReblogs = kind == Kind.HOME && !filter + if (oldRemoveReblogs != filterRemoveReblogs) { + fullReload() + } + } + Filter.HOME, Filter.NOTIFICATIONS, Filter.THREAD, Filter.PUBLIC, Filter.ACCOUNT -> { + if (filterContextMatchesKind(kind, listOf(key))) { + reloadFilters() + } + } + PrefKeys.ALWAYS_SHOW_SENSITIVE_MEDIA -> { + // it is ok if only newly loaded statuses are affected, no need to fully refresh + alwaysShowSensitiveMedia = + accountManager.activeAccount!!.alwaysShowSensitiveMedia + } + } + } + + private fun filterContextMatchesKind( + kind: Kind, + filterContext: List + ): Boolean { + // home, notifications, public, thread + return when (kind) { + Kind.HOME, Kind.LIST -> filterContext.contains( + Filter.HOME + ) + Kind.PUBLIC_FEDERATED, Kind.PUBLIC_LOCAL, Kind.TAG -> filterContext.contains( + Filter.PUBLIC + ) + Kind.FAVOURITES -> filterContext.contains(Filter.PUBLIC) || filterContext.contains( + Filter.NOTIFICATIONS + ) + Kind.USER, Kind.USER_WITH_REPLIES, Kind.USER_PINNED -> filterContext.contains( + Filter.ACCOUNT + ) + else -> false + } + } + + private fun handleEvent(event: Event) { + when (event) { + is FavoriteEvent -> handleFavEvent(event) + is ReblogEvent -> handleReblogEvent(event) + is BookmarkEvent -> handleBookmarkEvent(event) + is PinEvent -> handlePinEvent(event) + is MuteConversationEvent -> fullReload() + is UnfollowEvent -> { + if (kind == Kind.HOME) { + val id = event.accountId + removeAllByAccountId(id) + } + } + is BlockEvent -> { + if (kind != Kind.USER && kind != Kind.USER_WITH_REPLIES && kind != Kind.USER_PINNED) { + val id = event.accountId + removeAllByAccountId(id) + } + } + is MuteEvent -> { + if (kind != Kind.USER && kind != Kind.USER_WITH_REPLIES && kind != Kind.USER_PINNED) { + val id = event.accountId + removeAllByAccountId(id) + } + } + is DomainMuteEvent -> { + if (kind != Kind.USER && kind != Kind.USER_WITH_REPLIES && kind != Kind.USER_PINNED) { + val instance = event.instance + removeAllByInstance(instance) + } + } + is StatusDeletedEvent -> { + if (kind != Kind.USER && kind != Kind.USER_WITH_REPLIES && kind != Kind.USER_PINNED) { + removeStatusWithId(event.statusId) + } + } + is PreferenceChangedEvent -> { + onPreferenceChanged(event.preferenceKey) + } + } + } + + private fun reloadFilters() { + viewModelScope.launch { + val filters = try { + api.getFilters().await() + } catch (t: Exception) { + Log.e(TAG, "Failed to fetch filters", t) + return@launch + } + filterModel.initWithFilters( + filters.filter { + filterContextMatchesKind(kind, it.context) + } + ) + } + } + + private fun isExpectedRequestException(t: Exception) = t is IOException || t is HttpException + + private inline fun ifExpected( + t: Exception, + cb: () -> Unit + ) { + if (isExpectedRequestException(t)) { + cb() + } else { + throw t + } + } + + companion object { + private const val TAG = "TimelineVM" + internal const val LOAD_AT_ONCE = 30 + } + + enum class Kind { + HOME, PUBLIC_LOCAL, PUBLIC_FEDERATED, TAG, USER, USER_PINNED, USER_WITH_REPLIES, FAVOURITES, LIST, BOOKMARKS + } +} 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 624c15ac1..50e13ca25 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java +++ b/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java @@ -32,7 +32,7 @@ import java.io.File; @Database(entities = { DraftEntity.class, AccountEntity.class, InstanceEntity.class, TimelineStatusEntity.class, TimelineAccountEntity.class, ConversationEntity.class - }, version = 27) + }, version = 28) public abstract class AppDatabase extends RoomDatabase { public abstract AccountDao accountDao(); @@ -400,4 +400,61 @@ public abstract class AppDatabase extends RoomDatabase { database.execSQL("ALTER TABLE `ConversationEntity` ADD COLUMN `s_muted` INTEGER NOT NULL DEFAULT 0"); } }; + + public static final Migration MIGRATION_27_28 = new Migration(27, 28) { + @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`) )"); + + database.execSQL("CREATE TABLE IF NOT EXISTS `TimelineStatusEntity` (" + + "`serverId` TEXT NOT NULL," + + "`url` TEXT," + + "`timelineUserId` INTEGER NOT NULL," + + "`authorServerId` TEXT," + + "`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," + + "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/TimelineDao.kt b/app/src/main/java/com/keylesspalace/tusky/db/TimelineDao.kt index 6bbc08047..e2ba80214 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/TimelineDao.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/TimelineDao.kt @@ -1,24 +1,34 @@ +/* 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.db +import androidx.paging.PagingSource import androidx.room.Dao import androidx.room.Insert -import androidx.room.OnConflictStrategy.IGNORE import androidx.room.OnConflictStrategy.REPLACE import androidx.room.Query -import androidx.room.Transaction -import io.reactivex.rxjava3.core.Single @Dao abstract class TimelineDao { @Insert(onConflict = REPLACE) - abstract fun insertAccount(timelineAccountEntity: TimelineAccountEntity): Long + abstract suspend fun insertAccount(timelineAccountEntity: TimelineAccountEntity): Long @Insert(onConflict = REPLACE) - abstract fun insertStatus(timelineAccountEntity: TimelineStatusEntity): Long - - @Insert(onConflict = IGNORE) - abstract fun insertStatusIfNotThere(timelineAccountEntity: TimelineStatusEntity): Long + abstract suspend fun insertStatus(timelineStatusEntity: TimelineStatusEntity): Long @Query( """ @@ -26,7 +36,7 @@ SELECT s.serverId, s.url, s.timelineUserId, s.authorServerId, s.inReplyToId, s.inReplyToAccountId, s.createdAt, s.emojis, s.reblogsCount, s.favouritesCount, s.reblogged, s.favourited, s.bookmarked, s.sensitive, s.spoilerText, s.visibility, s.mentions, s.application, s.reblogServerId,s.reblogAccountId, -s.content, s.attachments, s.poll, s.muted, +s.content, s.attachments, s.poll, s.muted, s.expanded, s.contentShowing, s.contentCollapsed, s.pinned, a.serverId as 'a_serverId', a.timelineUserId as 'a_timelineUserId', a.localUsername as 'a_localUsername', a.username as 'a_username', a.displayName as 'a_displayName', a.url as 'a_url', a.avatar as 'a_avatar', @@ -34,51 +44,23 @@ a.emojis as 'a_emojis', a.bot as 'a_bot', rb.serverId as 'rb_serverId', rb.timelineUserId 'rb_timelineUserId', rb.localUsername as 'rb_localUsername', rb.username as 'rb_username', rb.displayName as 'rb_displayName', rb.url as 'rb_url', rb.avatar as 'rb_avatar', -rb.emojis as'rb_emojis', rb.bot as 'rb_bot' +rb.emojis as 'rb_emojis', rb.bot as 'rb_bot' FROM TimelineStatusEntity s LEFT JOIN TimelineAccountEntity a ON (s.timelineUserId = a.timelineUserId AND s.authorServerId = a.serverId) LEFT JOIN TimelineAccountEntity rb ON (s.timelineUserId = rb.timelineUserId AND s.reblogAccountId = rb.serverId) WHERE s.timelineUserId = :account -AND (CASE WHEN :maxId IS NOT NULL THEN -(LENGTH(s.serverId) < LENGTH(:maxId) OR LENGTH(s.serverId) == LENGTH(:maxId) AND s.serverId < :maxId) -ELSE 1 END) -AND (CASE WHEN :sinceId IS NOT NULL THEN -(LENGTH(s.serverId) > LENGTH(:sinceId) OR LENGTH(s.serverId) == LENGTH(:sinceId) AND s.serverId > :sinceId) -ELSE 1 END) -ORDER BY LENGTH(s.serverId) DESC, s.serverId DESC -LIMIT :limit""" +ORDER BY LENGTH(s.serverId) DESC, s.serverId DESC""" ) - abstract fun getStatusesForAccount(account: Long, maxId: String?, sinceId: String?, limit: Int): Single> - - @Transaction - open fun insertInTransaction( - status: TimelineStatusEntity, - account: TimelineAccountEntity, - reblogAccount: TimelineAccountEntity? - ) { - insertAccount(account) - reblogAccount?.let(this::insertAccount) - insertStatus(status) - } + abstract fun getStatusesForAccount(account: Long): PagingSource @Query( """DELETE FROM TimelineStatusEntity WHERE timelineUserId = :accountId AND - (LENGTH(serverId) < LENGTH(:maxId) OR LENGTH(serverId) == LENGTH(:maxId) AND serverId < :maxId) + (LENGTH(serverId) < LENGTH(:maxId) OR LENGTH(serverId) == LENGTH(:maxId) AND serverId <= :maxId) AND -(LENGTH(serverId) > LENGTH(:minId) OR LENGTH(serverId) == LENGTH(:minId) AND serverId > :minId) +(LENGTH(serverId) > LENGTH(:minId) OR LENGTH(serverId) == LENGTH(:minId) AND serverId >= :minId) """ ) - abstract fun deleteRange(accountId: Long, minId: String, maxId: String) - - @Query( - """DELETE FROM TimelineStatusEntity WHERE authorServerId = null -AND timelineUserId = :account AND -(LENGTH(serverId) < LENGTH(:maxId) OR LENGTH(serverId) == LENGTH(:maxId) AND serverId < :maxId) -AND -(LENGTH(serverId) > LENGTH(:sinceId) OR LENGTH(serverId) == LENGTH(:sinceId) AND serverId > :sinceId) -""" - ) - abstract fun removeAllPlaceholdersBetween(account: Long, maxId: String, sinceId: String) + abstract suspend fun deleteRange(accountId: Long, minId: String, maxId: String): Int @Query( """UPDATE TimelineStatusEntity SET favourited = :favourited @@ -124,4 +106,40 @@ AND serverId = :statusId""" WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId = :statusId)""" ) abstract fun setVoted(accountId: Long, statusId: String, poll: String) + + @Query( + """UPDATE TimelineStatusEntity SET expanded = :expanded +WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId = :statusId)""" + ) + abstract fun setExpanded(accountId: Long, statusId: String, expanded: Boolean) + + @Query( + """UPDATE TimelineStatusEntity SET contentShowing = :contentShowing +WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId = :statusId)""" + ) + abstract fun setContentShowing(accountId: Long, statusId: String, contentShowing: Boolean) + + @Query( + """UPDATE TimelineStatusEntity SET contentCollapsed = :contentCollapsed +WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId = :statusId)""" + ) + abstract fun setContentCollapsed(accountId: Long, statusId: String, contentCollapsed: Boolean) + + @Query( + """UPDATE TimelineStatusEntity SET pinned = :pinned +WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId = :statusId)""" + ) + abstract fun setPinned(accountId: Long, statusId: String, pinned: Boolean) + + @Query( + """DELETE FROM TimelineStatusEntity +WHERE timelineUserId = :accountId AND authorServerId IN ( +SELECT serverId FROM TimelineAccountEntity WHERE username LIKE '%@' || :instanceDomain +AND timelineUserId = :accountId +)""" + ) + abstract suspend fun deleteAllFromInstance(accountId: Long, instanceDomain: String) + + @Query("SELECT serverId FROM TimelineStatusEntity WHERE timelineUserId = :accountId ORDER BY LENGTH(serverId) DESC, serverId DESC LIMIT 1") + abstract suspend fun getTopId(accountId: Long): 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 4e2db4ff3..ce8169593 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/TimelineStatusEntity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/TimelineStatusEntity.kt @@ -1,3 +1,18 @@ +/* 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.db import androidx.room.Embedded @@ -50,15 +65,19 @@ data class TimelineStatusEntity( val bookmarked: Boolean, val favourited: Boolean, val sensitive: Boolean, - val spoilerText: String?, - val visibility: Status.Visibility?, + val spoilerText: String, + val visibility: Status.Visibility, val attachments: String?, val mentions: String?, val application: String?, val reblogServerId: String?, // if it has a reblogged status, it's id is stored here val reblogAccountId: String?, val poll: String?, - val muted: Boolean? + val muted: Boolean?, + val expanded: Boolean, // used as the "loading" attribute when this TimelineStatusEntity is a placeholder + val contentCollapsed: Boolean, + val contentShowing: Boolean, + val pinned: Boolean ) @Entity( 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 51596ac71..c360be367 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/AppComponent.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/AppComponent.kt @@ -35,7 +35,6 @@ import javax.inject.Singleton ServicesModule::class, BroadcastReceiverModule::class, ViewModelModule::class, - RepositoryModule::class, MediaUploaderModule::class ] ) 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 4cce3f447..b98d2c702 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt @@ -85,8 +85,8 @@ class AppModule { AppDatabase.MIGRATION_16_17, AppDatabase.MIGRATION_17_18, AppDatabase.MIGRATION_18_19, AppDatabase.MIGRATION_19_20, AppDatabase.MIGRATION_20_21, AppDatabase.MIGRATION_21_22, AppDatabase.MIGRATION_22_23, AppDatabase.MIGRATION_23_24, AppDatabase.MIGRATION_24_25, - AppDatabase.MIGRATION_26_27, - AppDatabase.Migration25_26(appContext.getExternalFilesDir("Tusky")) + AppDatabase.Migration25_26(appContext.getExternalFilesDir("Tusky")), + AppDatabase.MIGRATION_26_27, AppDatabase.MIGRATION_27_28 ) .build() } diff --git a/app/src/main/java/com/keylesspalace/tusky/di/RepositoryModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/RepositoryModule.kt deleted file mode 100644 index e94c55d19..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/di/RepositoryModule.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.keylesspalace.tusky.di - -import com.google.gson.Gson -import com.keylesspalace.tusky.components.timeline.TimelineRepository -import com.keylesspalace.tusky.components.timeline.TimelineRepositoryImpl -import com.keylesspalace.tusky.db.AccountManager -import com.keylesspalace.tusky.db.AppDatabase -import com.keylesspalace.tusky.network.MastodonApi -import dagger.Module -import dagger.Provides - -@Module -class RepositoryModule { - @Provides - fun providesTimelineRepository( - db: AppDatabase, - mastodonApi: MastodonApi, - accountManager: AccountManager, - gson: Gson - ): TimelineRepository { - return TimelineRepositoryImpl(db.timelineDao(), mastodonApi, accountManager, gson) - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt b/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt index 8cb0a172d..c9c6f1f07 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt @@ -11,7 +11,8 @@ import com.keylesspalace.tusky.components.drafts.DraftsViewModel import com.keylesspalace.tusky.components.report.ReportViewModel import com.keylesspalace.tusky.components.scheduled.ScheduledTootViewModel import com.keylesspalace.tusky.components.search.SearchViewModel -import com.keylesspalace.tusky.components.timeline.TimelineViewModel +import com.keylesspalace.tusky.components.timeline.viewmodel.CachedTimelineViewModel +import com.keylesspalace.tusky.components.timeline.viewmodel.NetworkTimelineViewModel import com.keylesspalace.tusky.viewmodel.AccountViewModel import com.keylesspalace.tusky.viewmodel.AccountsInListViewModel import com.keylesspalace.tusky.viewmodel.EditProfileViewModel @@ -99,8 +100,13 @@ abstract class ViewModelModule { @Binds @IntoMap - @ViewModelKey(TimelineViewModel::class) - internal abstract fun timelineViewModel(viewModel: TimelineViewModel): ViewModel + @ViewModelKey(CachedTimelineViewModel::class) + internal abstract fun cachedTimelineViewModel(viewModel: CachedTimelineViewModel): ViewModel + + @Binds + @IntoMap + @ViewModelKey(NetworkTimelineViewModel::class) + internal abstract fun networkTimelineViewModel(viewModel: NetworkTimelineViewModel): ViewModel // Add more ViewModels here } diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java index a15adc20f..091339599 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java @@ -188,7 +188,8 @@ public class NotificationsFragment extends SFragment implements return ViewDataUtils.notificationToViewData( notification, alwaysShowSensitiveMedia, - alwaysOpenSpoiler + alwaysOpenSpoiler, + true ); } else { return new NotificationViewData.Placeholder(input.asLeft().id, false); diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java index f7dd81591..c9395b38f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java @@ -300,7 +300,9 @@ public abstract class SFragment extends Fragment implements Injectable { return true; } case R.id.pin: { - timelineCases.pin(status.getId(), !status.isPinned()); + timelineCases.pin(status.getId(), !status.isPinned()) + .to(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))) + .subscribe(); return true; } case R.id.status_mute_conversation: { diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewThreadFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewThreadFragment.java index e9bbe5b66..7cfd81073 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewThreadFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewThreadFragment.java @@ -109,7 +109,8 @@ public final class ViewThreadFragment extends SFragment implements return ViewDataUtils.statusToViewData( input, alwaysShowSensitiveMedia, - alwaysOpenSpoiler + alwaysOpenSpoiler, + true ); } }); 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 dc5fd8f38..a8b1050c1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt +++ b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt @@ -85,17 +85,17 @@ interface MastodonApi { @GET("api/v1/timelines/home") fun homeTimeline( - @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("limit") limit: Int? = null ): Single>> @GET("api/v1/timelines/public") fun publicTimeline( - @Query("local") local: Boolean?, - @Query("max_id") maxId: String?, - @Query("since_id") sinceId: String?, - @Query("limit") limit: Int? + @Query("local") local: Boolean? = null, + @Query("max_id") maxId: String? = null, + @Query("since_id") sinceId: String? = null, + @Query("limit") limit: Int? = null ): Single>> @GET("api/v1/timelines/tag/{hashtag}") diff --git a/app/src/main/java/com/keylesspalace/tusky/pager/AccountPagerAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/pager/AccountPagerAdapter.kt index 2d01a32db..f7a69257c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/pager/AccountPagerAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/pager/AccountPagerAdapter.kt @@ -18,7 +18,7 @@ package com.keylesspalace.tusky.pager import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentActivity import com.keylesspalace.tusky.components.timeline.TimelineFragment -import com.keylesspalace.tusky.components.timeline.TimelineViewModel +import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel import com.keylesspalace.tusky.fragment.AccountMediaFragment import com.keylesspalace.tusky.interfaces.RefreshableFragment import com.keylesspalace.tusky.util.CustomFragmentStateAdapter 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 9b86db6d7..52d9713f4 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.kt @@ -23,29 +23,31 @@ import com.keylesspalace.tusky.viewdata.StatusViewData @JvmName("statusToViewData") fun Status.toViewData( - alwaysShowSensitiveMedia: Boolean, - alwaysOpenSpoiler: Boolean + isShowingContent: Boolean, + isExpanded: Boolean, + isCollapsed: Boolean ): StatusViewData.Concrete { val visibleStatus = this.reblog ?: this return StatusViewData.Concrete( status = this, - isShowingContent = alwaysShowSensitiveMedia || !visibleStatus.sensitive, + isShowingContent = isShowingContent, isCollapsible = shouldTrimStatus(visibleStatus.content), - isCollapsed = false, - isExpanded = alwaysOpenSpoiler, + isCollapsed = isCollapsed, + isExpanded = isExpanded, ) } @JvmName("notificationToViewData") fun Notification.toViewData( - alwaysShowSensitiveData: Boolean, - alwaysOpenSpoiler: Boolean + isShowingContent: Boolean, + isExpanded: Boolean, + isCollapsed: Boolean ): NotificationViewData.Concrete { return NotificationViewData.Concrete( this.type, this.id, this.account, - this.status?.toViewData(alwaysShowSensitiveData, alwaysOpenSpoiler) + this.status?.toViewData(isShowingContent, isExpanded, isCollapsed) ) } 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 new file mode 100644 index 000000000..dd97b17e4 --- /dev/null +++ b/app/src/test/java/com/keylesspalace/tusky/components/timeline/CachedTimelineRemoteMediatorTest.kt @@ -0,0 +1,468 @@ +package com.keylesspalace.tusky.components.timeline + +import android.os.Looper.getMainLooper +import androidx.paging.ExperimentalPagingApi +import androidx.paging.LoadType +import androidx.paging.PagingConfig +import androidx.paging.PagingSource +import androidx.paging.PagingState +import androidx.paging.RemoteMediator +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.viewmodel.CachedTimelineRemoteMediator +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.TimelineStatusWithAccount +import com.nhaarman.mockitokotlin2.anyOrNull +import com.nhaarman.mockitokotlin2.doReturn +import com.nhaarman.mockitokotlin2.mock +import io.reactivex.rxjava3.core.Single +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.runBlocking +import okhttp3.ResponseBody.Companion.toResponseBody +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import retrofit2.HttpException +import retrofit2.Response +import java.io.IOException + +@Config(sdk = [28]) +@RunWith(AndroidJUnit4::class) +class CachedTimelineRemoteMediatorTest { + + private val accountManager: AccountManager = mock { + on { activeAccount } doReturn AccountEntity( + id = 1, + domain = "mastodon.example", + accessToken = "token", + isActive = true + ) + } + + private lateinit var db: AppDatabase + + @Before + @ExperimentalCoroutinesApi + fun setup() { + shadowOf(getMainLooper()).idle() + + val context = InstrumentationRegistry.getInstrumentation().targetContext + db = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java) + .addTypeConverter(Converters(Gson())) + .build() + } + + @After + @ExperimentalCoroutinesApi + fun tearDown() { + db.close() + } + + @Test + @ExperimentalPagingApi + fun `should return error when network call returns error code`() { + + val remoteMediator = CachedTimelineRemoteMediator( + accountManager = accountManager, + api = mock { + on { homeTimeline(anyOrNull(), anyOrNull(), anyOrNull()) } doReturn Single.just(Response.error(500, "".toResponseBody())) + }, + db = db, + gson = Gson() + ) + + val result = runBlocking { 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()) + } + + @Test + @ExperimentalPagingApi + fun `should return error when network call fails`() { + + val remoteMediator = CachedTimelineRemoteMediator( + accountManager = accountManager, + api = mock { + on { homeTimeline(anyOrNull(), anyOrNull(), anyOrNull()) } doReturn Single.error(IOException()) + }, + db = db, + gson = Gson() + ) + + val result = runBlocking { remoteMediator.load(LoadType.REFRESH, state()) } + + assertTrue(result is RemoteMediator.MediatorResult.Error) + assertTrue((result as RemoteMediator.MediatorResult.Error).throwable is IOException) + } + + @Test + @ExperimentalPagingApi + fun `should not prepend statuses`() { + + val remoteMediator = CachedTimelineRemoteMediator( + accountManager = accountManager, + api = mock(), + db = db, + gson = Gson() + ) + + val state = state( + listOf( + PagingSource.LoadResult.Page( + data = listOf( + mockStatusEntityWithAccount("3") + ), + prevKey = null, + nextKey = 1 + ) + ) + ) + + val result = runBlocking { remoteMediator.load(LoadType.PREPEND, state) } + + assertTrue(result is RemoteMediator.MediatorResult.Success) + assertTrue((result as RemoteMediator.MediatorResult.Success).endOfPaginationReached) + } + + @Test + @ExperimentalPagingApi + fun `should refresh and insert placeholder`() { + + val statusesAlreadyInDb = listOf( + mockStatusEntityWithAccount("3"), + mockStatusEntityWithAccount("2"), + mockStatusEntityWithAccount("1"), + ) + + db.insert(statusesAlreadyInDb) + + val remoteMediator = CachedTimelineRemoteMediator( + accountManager = accountManager, + api = mock { + on { homeTimeline(limit = 20) } doReturn Single.just( + Response.success( + listOf( + mockStatus("8"), + mockStatus("7"), + mockStatus("5") + ) + ) + ) + on { homeTimeline(maxId = "3", limit = 20) } doReturn Single.just( + Response.success( + listOf( + mockStatus("3"), + mockStatus("2"), + mockStatus("1") + ) + ) + ) + }, + 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) + assertEquals(false, (result as RemoteMediator.MediatorResult.Success).endOfPaginationReached) + + db.assertStatuses( + listOf( + mockStatusEntityWithAccount("8"), + mockStatusEntityWithAccount("7"), + mockStatusEntityWithAccount("5"), + TimelineStatusWithAccount().apply { + status = Placeholder("4", loading = false).toEntity(1) + }, + mockStatusEntityWithAccount("3"), + mockStatusEntityWithAccount("2"), + mockStatusEntityWithAccount("1"), + ) + ) + } + + @Test + @ExperimentalPagingApi + fun `should refresh and not insert placeholders`() { + + val statusesAlreadyInDb = listOf( + mockStatusEntityWithAccount("3"), + mockStatusEntityWithAccount("2"), + mockStatusEntityWithAccount("1"), + ) + + db.insert(statusesAlreadyInDb) + + val remoteMediator = CachedTimelineRemoteMediator( + accountManager = accountManager, + api = mock { + on { homeTimeline(limit = 20) } doReturn Single.just( + Response.success( + listOf( + mockStatus("6"), + mockStatus("4"), + mockStatus("3") + ) + ) + ) + on { homeTimeline(maxId = "3", limit = 20) } doReturn Single.just( + Response.success( + listOf( + mockStatus("3"), + mockStatus("2"), + mockStatus("1") + ) + ) + ) + }, + 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) + assertEquals(false, (result as RemoteMediator.MediatorResult.Success).endOfPaginationReached) + + db.assertStatuses( + listOf( + mockStatusEntityWithAccount("6"), + mockStatusEntityWithAccount("4"), + mockStatusEntityWithAccount("3"), + mockStatusEntityWithAccount("2"), + mockStatusEntityWithAccount("1"), + ) + ) + } + + @Test + @ExperimentalPagingApi + fun `should not try to refresh already cached statuses when db is empty`() { + + val remoteMediator = CachedTimelineRemoteMediator( + accountManager = accountManager, + api = mock { + on { homeTimeline(limit = 20) } doReturn Single.just( + Response.success( + listOf( + mockStatus("5"), + mockStatus("4"), + mockStatus("3") + ) + ) + ) + }, + db = db, + gson = Gson() + ) + + val state = state( + listOf( + PagingSource.LoadResult.Page( + data = emptyList(), + prevKey = null, + nextKey = 0 + ) + ) + ) + + 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("5"), + mockStatusEntityWithAccount("4"), + mockStatusEntityWithAccount("3") + ) + ) + } + + @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), + ) + + db.insert(statusesAlreadyInDb) + + val remoteMediator = CachedTimelineRemoteMediator( + accountManager = accountManager, + api = mock { + on { homeTimeline(limit = 20) } doReturn Single.just( + Response.success(emptyList()) + ) + on { homeTimeline(maxId = "3", limit = 20) } doReturn Single.just( + Response.success( + listOf( + mockStatus("3"), + mockStatus("1") + ) + ) + ) + }, + 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) + assertTrue((result as RemoteMediator.MediatorResult.Success).endOfPaginationReached) + + db.assertStatuses( + listOf( + mockStatusEntityWithAccount("3", expanded = true), + mockStatusEntityWithAccount("1", expanded = false) + ) + ) + } + + @Test + @ExperimentalPagingApi + fun `should append statuses`() { + + val statusesAlreadyInDb = listOf( + mockStatusEntityWithAccount("8"), + mockStatusEntityWithAccount("7"), + mockStatusEntityWithAccount("5"), + ) + + db.insert(statusesAlreadyInDb) + + val remoteMediator = CachedTimelineRemoteMediator( + accountManager = accountManager, + api = mock { + on { homeTimeline(maxId = "5", limit = 20) } doReturn Single.just( + Response.success( + listOf( + mockStatus("3"), + mockStatus("2"), + mockStatus("1") + ) + ) + ) + }, + db = db, + gson = Gson() + ) + + val state = state( + listOf( + PagingSource.LoadResult.Page( + data = statusesAlreadyInDb, + prevKey = null, + nextKey = 0 + ) + ) + ) + + val result = runBlocking { remoteMediator.load(LoadType.APPEND, state) } + + assertTrue(result is RemoteMediator.MediatorResult.Success) + assertEquals(false, (result as RemoteMediator.MediatorResult.Success).endOfPaginationReached) + db.assertStatuses( + listOf( + mockStatusEntityWithAccount("8"), + mockStatusEntityWithAccount("7"), + mockStatusEntityWithAccount("5"), + mockStatusEntityWithAccount("3"), + mockStatusEntityWithAccount("2"), + mockStatusEntityWithAccount("1"), + ) + ) + } + + private fun state(pages: List> = emptyList()) = PagingState( + pages = pages, + anchorPosition = null, + config = PagingConfig( + pageSize = 20 + ), + leadingPlaceholderCount = 0 + ) + + private fun AppDatabase.insert(statuses: List) { + runBlocking { + statuses.forEach { statusWithAccount -> + timelineDao().insertAccount(statusWithAccount.account) + statusWithAccount.reblogAccount?.let { account -> + timelineDao().insertAccount(account) + } + timelineDao().insertStatus(statusWithAccount.status) + } + } + } + + private fun AppDatabase.assertStatuses( + expected: List, + forAccount: Long = 1 + ) { + val pagingSource = timelineDao().getStatusesForAccount(forAccount) + + val loadResult = runBlocking { + pagingSource.load(PagingSource.LoadParams.Refresh(null, 100, false)) + } + + val loadedStatuses = (loadResult as PagingSource.LoadResult.Page).data + + assertEquals(expected.size, loadedStatuses.size) + + for ((exp, prov) in expected.zip(loadedStatuses)) { + assertEquals(exp.status, prov.status) + if (exp.status.authorServerId != null) { // only check if no placeholder + assertEquals(exp.account, prov.account) + assertEquals(exp.reblogAccount, prov.reblogAccount) + } + } + } +} 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 new file mode 100644 index 000000000..2e67c6fe3 --- /dev/null +++ b/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelinePagingSourceTest.kt @@ -0,0 +1,59 @@ +package com.keylesspalace.tusky.components.timeline + +import androidx.paging.PagingSource +import com.keylesspalace.tusky.components.timeline.viewmodel.NetworkTimelinePagingSource +import com.keylesspalace.tusky.components.timeline.viewmodel.NetworkTimelineViewModel +import com.nhaarman.mockitokotlin2.doReturn +import com.nhaarman.mockitokotlin2.mock +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Test + +class NetworkTimelinePagingSourceTest { + + private val status = mockStatusViewData() + + private val timelineViewModel: NetworkTimelineViewModel = mock { + on { statusData } doReturn mutableListOf(status) + } + + @Test + fun `should return empty list when params are Append`() { + val pagingSource = NetworkTimelinePagingSource(timelineViewModel) + + val params = PagingSource.LoadParams.Append("132", 20, false) + + val expectedResult = PagingSource.LoadResult.Page(emptyList(), null, null) + + runBlocking { + assertEquals(expectedResult, pagingSource.load(params)) + } + } + + @Test + fun `should return empty list when params are Prepend`() { + val pagingSource = NetworkTimelinePagingSource(timelineViewModel) + + val params = PagingSource.LoadParams.Prepend("132", 20, false) + + val expectedResult = PagingSource.LoadResult.Page(emptyList(), null, null) + + runBlocking { + assertEquals(expectedResult, pagingSource.load(params)) + } + } + + @Test + fun `should return full list when params are Refresh`() { + val pagingSource = NetworkTimelinePagingSource(timelineViewModel) + + 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) + } + } +} 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 new file mode 100644 index 000000000..b44d2487f --- /dev/null +++ b/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRemoteMediatorTest.kt @@ -0,0 +1,293 @@ +package com.keylesspalace.tusky.components.timeline + +import androidx.paging.ExperimentalPagingApi +import androidx.paging.LoadType +import androidx.paging.PagingConfig +import androidx.paging.PagingSource +import androidx.paging.PagingState +import androidx.paging.RemoteMediator +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 com.nhaarman.mockitokotlin2.anyOrNull +import com.nhaarman.mockitokotlin2.doReturn +import com.nhaarman.mockitokotlin2.doThrow +import com.nhaarman.mockitokotlin2.mock +import kotlinx.coroutines.runBlocking +import okhttp3.ResponseBody.Companion.toResponseBody +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import retrofit2.HttpException +import retrofit2.Response +import java.lang.RuntimeException + +class NetworkTimelineRemoteMediatorTest { + + private val accountManager: AccountManager = mock { + on { activeAccount } doReturn AccountEntity( + id = 1, + domain = "mastodon.example", + accessToken = "token", + isActive = true + ) + } + + @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()) + } + + val remoteMediator = NetworkTimelineRemoteMediator(accountManager, timelineViewModel) + + val result = runBlocking { 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()) + } + + @Test + @ExperimentalPagingApi + fun `should return error when network call fails`() { + + val timelineViewModel: NetworkTimelineViewModel = mock { + on { statusData } doReturn mutableListOf() + onBlocking { fetchStatusesForKind(anyOrNull(), anyOrNull(), anyOrNull()) } doThrow RuntimeException() + } + + val remoteMediator = NetworkTimelineRemoteMediator(accountManager, timelineViewModel) + + val result = runBlocking { remoteMediator.load(LoadType.REFRESH, state()) } + + assertTrue(result is RemoteMediator.MediatorResult.Error) + assertTrue((result as RemoteMediator.MediatorResult.Error).throwable is RuntimeException) + } + + @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") + ) + ) + } + + val remoteMediator = NetworkTimelineRemoteMediator(accountManager, timelineViewModel) + + 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("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 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) + + 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"), + mockStatusViewData("7"), + StatusViewData.Placeholder("6", 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`() { + 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") + ) + ) + } + + val remoteMediator = NetworkTimelineRemoteMediator(accountManager, timelineViewModel) + + val state = state( + listOf( + PagingSource.LoadResult.Page( + data = listOf( + mockStatusViewData("8"), + mockStatusViewData("7"), + mockStatusViewData("5"), + ), + prevKey = null, + nextKey = "3" + ) + ) + ) + + val result = runBlocking { remoteMediator.load(LoadType.APPEND, state) } + + val newStatusData = mutableListOf( + mockStatusViewData("8"), + mockStatusViewData("7"), + mockStatusViewData("5"), + 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 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") + ) + ) + } + + val remoteMediator = NetworkTimelineRemoteMediator(accountManager, timelineViewModel) + + val state = state( + listOf( + PagingSource.LoadResult.Page( + data = listOf( + mockStatusViewData("8"), + mockStatusViewData("7"), + mockStatusViewData("5"), + ), + prevKey = null, + nextKey = "3" + ) + ) + ) + + val result = runBlocking { remoteMediator.load(LoadType.APPEND, state) } + + val newStatusData = mutableListOf( + mockStatusViewData("8"), + mockStatusViewData("7"), + mockStatusViewData("5"), + mockStatusViewData("3"), + mockStatusViewData("2"), + mockStatusViewData("1"), + ) + + assertTrue(result is RemoteMediator.MediatorResult.Success) + assertEquals(false, (result as RemoteMediator.MediatorResult.Success).endOfPaginationReached) + assertEquals(newStatusData, statuses) + } + + private fun state(pages: List> = emptyList()) = PagingState( + pages = pages, + anchorPosition = null, + config = PagingConfig( + pageSize = 20 + ), + leadingPlaceholderCount = 0 + ) +} 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 new file mode 100644 index 000000000..5798c90c1 --- /dev/null +++ b/app/src/test/java/com/keylesspalace/tusky/components/timeline/StatusMocker.kt @@ -0,0 +1,79 @@ +package com.keylesspalace.tusky.components.timeline + +import android.text.SpannedString +import com.google.gson.Gson +import com.keylesspalace.tusky.db.TimelineStatusWithAccount +import com.keylesspalace.tusky.entity.Account +import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.viewdata.StatusViewData +import java.util.ArrayList +import java.util.Date + +private val fixedDate = Date(1638889052000) + +fun mockStatus(id: String = "100") = Status( + id = id, + url = "https://mastodon.example/@ConnyDuck/$id", + account = Account( + id = "1", + localUsername = "connyduck", + username = "connyduck@mastodon.example", + displayName = "Conny Duck", + note = SpannedString(""), + url = "https://mastodon.example/@ConnyDuck", + avatar = "https://mastodon.example/system/accounts/avatars/000/150/486/original/ab27d7ddd18a10ea.jpg", + header = "https://mastodon.example/system/accounts/header/000/106/476/original/e590545d7eb4da39.jpg" + ), + inReplyToId = null, + inReplyToAccountId = null, + reblog = null, + content = SpannedString("Test"), + createdAt = fixedDate, + emojis = emptyList(), + reblogsCount = 1, + favouritesCount = 2, + reblogged = false, + favourited = true, + bookmarked = true, + sensitive = true, + spoilerText = "", + visibility = Status.Visibility.PUBLIC, + attachments = ArrayList(), + mentions = emptyList(), + application = Status.Application("Tusky", "https://tusky.app"), + pinned = false, + muted = false, + poll = null, + card = null +) + +fun mockStatusViewData(id: String = "100") = StatusViewData.Concrete( + status = mockStatus(id), + isExpanded = false, + isShowingContent = false, + isCollapsible = false, + isCollapsed = true, +) + +fun mockStatusEntityWithAccount( + id: String = "100", + userId: Long = 1, + expanded: Boolean = false +): TimelineStatusWithAccount { + val mockedStatus = mockStatus(id) + val gson = Gson() + + return TimelineStatusWithAccount().apply { + status = mockedStatus.toEntity( + timelineUserId = userId, + gson = gson, + expanded = expanded, + contentShowing = false, + contentCollapsed = true + ) + account = mockedStatus.account.toEntity( + accountId = userId, + gson = gson + ) + } +} diff --git a/app/src/test/java/com/keylesspalace/tusky/components/timeline/TimelineRepositoryTest.kt b/app/src/test/java/com/keylesspalace/tusky/components/timeline/TimelineRepositoryTest.kt deleted file mode 100644 index 55861fd4e..000000000 --- a/app/src/test/java/com/keylesspalace/tusky/components/timeline/TimelineRepositoryTest.kt +++ /dev/null @@ -1,355 +0,0 @@ -package com.keylesspalace.tusky.components.timeline - -import android.text.SpannableString -import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.google.gson.Gson -import com.keylesspalace.tusky.db.AccountEntity -import com.keylesspalace.tusky.db.AccountManager -import com.keylesspalace.tusky.db.TimelineDao -import com.keylesspalace.tusky.db.TimelineStatusWithAccount -import com.keylesspalace.tusky.entity.Account -import com.keylesspalace.tusky.entity.Status -import com.keylesspalace.tusky.network.MastodonApi -import com.keylesspalace.tusky.util.Either -import com.nhaarman.mockitokotlin2.isNull -import com.nhaarman.mockitokotlin2.verify -import com.nhaarman.mockitokotlin2.verifyNoMoreInteractions -import com.nhaarman.mockitokotlin2.whenever -import io.reactivex.rxjava3.core.Single -import io.reactivex.rxjava3.plugins.RxJavaPlugins -import io.reactivex.rxjava3.schedulers.Schedulers -import io.reactivex.rxjava3.schedulers.TestScheduler -import org.junit.Assert.assertEquals -import org.junit.Before -import org.junit.Test -import org.junit.runner.RunWith -import org.mockito.ArgumentMatchers.any -import org.mockito.ArgumentMatchers.anyInt -import org.mockito.ArgumentMatchers.anyLong -import org.mockito.Mock -import org.mockito.MockitoAnnotations -import org.robolectric.annotation.Config -import retrofit2.Response -import java.util.Date -import java.util.concurrent.TimeUnit - -@Config(sdk = [28]) -@RunWith(AndroidJUnit4::class) -class TimelineRepositoryTest { - @Mock - lateinit var timelineDao: TimelineDao - - @Mock - lateinit var mastodonApi: MastodonApi - - @Mock - private lateinit var accountManager: AccountManager - - private lateinit var gson: Gson - - private lateinit var subject: TimelineRepository - - private lateinit var testScheduler: TestScheduler - - private val limit = 30 - private val account = AccountEntity( - id = 2, - accessToken = "token", - domain = "domain.com", - isActive = true - ) - - @Before - fun setup() { - MockitoAnnotations.initMocks(this) - whenever(accountManager.activeAccount).thenReturn(account) - - gson = Gson() - testScheduler = TestScheduler() - RxJavaPlugins.setIoSchedulerHandler { testScheduler } - subject = TimelineRepositoryImpl(timelineDao, mastodonApi, accountManager, gson) - } - - @Test - fun testNetworkUnbounded() { - val statuses = listOf( - makeStatus("3"), - makeStatus("2") - ) - whenever(mastodonApi.homeTimeline(isNull(), isNull(), anyInt())) - .thenReturn(Single.just(Response.success(statuses))) - val result = subject.getStatuses(null, null, null, limit, TimelineRequestMode.NETWORK) - .blockingGet() - - assertEquals(statuses.map(Status::lift), result) - testScheduler.advanceTimeBy(100, TimeUnit.SECONDS) - - verify(timelineDao).deleteRange(account.id, statuses.last().id, statuses.first().id) - - verify(timelineDao).insertStatusIfNotThere(Placeholder("1").toEntity(account.id)) - for (status in statuses) { - verify(timelineDao).insertInTransaction( - status.toEntity(account.id, gson), - status.account.toEntity(account.id, gson), - null - ) - } - verify(timelineDao).cleanup(anyLong()) - verifyNoMoreInteractions(timelineDao) - } - - @Test - fun testNetworkLoadingTopNoGap() { - val response = listOf( - makeStatus("4"), - makeStatus("3"), - makeStatus("2") - ) - val sinceId = "2" - val sinceIdMinusOne = "1" - whenever(mastodonApi.homeTimeline(null, sinceIdMinusOne, limit + 1)) - .thenReturn(Single.just(Response.success(response))) - val result = subject.getStatuses( - null, sinceId, sinceIdMinusOne, limit, - TimelineRequestMode.NETWORK - ) - .blockingGet() - - assertEquals( - response.subList(0, 2).map(Status::lift), - result - ) - testScheduler.advanceTimeBy(100, TimeUnit.SECONDS) - verify(timelineDao).deleteRange(account.id, response.last().id, response.first().id) - // We assume for now that overlapped one is inserted but it's not that important - for (status in response) { - verify(timelineDao).insertInTransaction( - status.toEntity(account.id, gson), - status.account.toEntity(account.id, gson), - null - ) - } - verify(timelineDao).removeAllPlaceholdersBetween( - account.id, response.first().id, - response.last().id - ) - verify(timelineDao).cleanup(anyLong()) - verifyNoMoreInteractions(timelineDao) - } - - @Test - fun testNetworkLoadingTopWithGap() { - val response = listOf( - makeStatus("5"), - makeStatus("4") - ) - val sinceId = "2" - val sinceIdMinusOne = "1" - whenever(mastodonApi.homeTimeline(null, sinceIdMinusOne, limit + 1)) - .thenReturn(Single.just(Response.success(response))) - val result = subject.getStatuses( - null, sinceId, sinceIdMinusOne, limit, - TimelineRequestMode.NETWORK - ) - .blockingGet() - - val placeholder = Placeholder("3") - assertEquals(response.map(Status::lift) + Either.Left(placeholder), result) - testScheduler.advanceTimeBy(100, TimeUnit.SECONDS) - verify(timelineDao).deleteRange(account.id, response.last().id, response.first().id) - for (status in response) { - verify(timelineDao).insertInTransaction( - status.toEntity(account.id, gson), - status.account.toEntity(account.id, gson), - null - ) - } - verify(timelineDao).insertStatusIfNotThere(placeholder.toEntity(account.id)) - verify(timelineDao).cleanup(anyLong()) - verifyNoMoreInteractions(timelineDao) - } - - @Test - fun testNetworkLoadingMiddleNoGap() { - // Example timelne: - // 5 - // 4 - // [gap] - // 2 - // 1 - - val response = listOf( - makeStatus("5"), - makeStatus("4"), - makeStatus("3"), - makeStatus("2") - ) - val sinceId = "2" - val sinceIdMinusOne = "1" - val maxId = "3" - whenever(mastodonApi.homeTimeline(maxId, sinceIdMinusOne, limit + 1)) - .thenReturn(Single.just(Response.success(response))) - val result = subject.getStatuses( - maxId, sinceId, sinceIdMinusOne, limit, - TimelineRequestMode.NETWORK - ) - .blockingGet() - - assertEquals( - response.subList(0, response.lastIndex).map(Status::lift), - result - ) - testScheduler.advanceTimeBy(100, TimeUnit.SECONDS) - verify(timelineDao).deleteRange(account.id, response.last().id, response.first().id) - // We assume for now that overlapped one is inserted but it's not that important - for (status in response) { - verify(timelineDao).insertInTransaction( - status.toEntity(account.id, gson), - status.account.toEntity(account.id, gson), - null - ) - } - verify(timelineDao).removeAllPlaceholdersBetween( - account.id, response.first().id, - response.last().id - ) - verify(timelineDao).cleanup(anyLong()) - verifyNoMoreInteractions(timelineDao) - } - - @Test - fun testNetworkLoadingMiddleWithGap() { - // Example timelne: - // 6 - // 5 - // [gap] - // 2 - // 1 - - val response = listOf( - makeStatus("6"), - makeStatus("5"), - makeStatus("4") - ) - val sinceId = "2" - val sinceIdMinusOne = "1" - val maxId = "4" - whenever(mastodonApi.homeTimeline(maxId, sinceIdMinusOne, limit + 1)) - .thenReturn(Single.just(Response.success(response))) - val result = subject.getStatuses( - maxId, sinceId, sinceIdMinusOne, limit, - TimelineRequestMode.NETWORK - ) - .blockingGet() - - val placeholder = Placeholder("3") - assertEquals( - response.map(Status::lift) + Either.Left(placeholder), - result - ) - testScheduler.advanceTimeBy(100, TimeUnit.SECONDS) - // We assume for now that overlapped one is inserted but it's not that important - - verify(timelineDao).deleteRange(account.id, response.last().id, response.first().id) - - for (status in response) { - verify(timelineDao).insertInTransaction( - status.toEntity(account.id, gson), - status.account.toEntity(account.id, gson), - null - ) - } - verify(timelineDao).removeAllPlaceholdersBetween( - account.id, response.first().id, - response.last().id - ) - verify(timelineDao).insertStatusIfNotThere(placeholder.toEntity(account.id)) - verify(timelineDao).cleanup(anyLong()) - verifyNoMoreInteractions(timelineDao) - } - - @Test - fun addingFromDb() { - RxJavaPlugins.setIoSchedulerHandler { Schedulers.single() } - val status = makeStatus("2") - val dbStatus = makeStatus("1") - val dbResult = TimelineStatusWithAccount() - dbResult.status = dbStatus.toEntity(account.id, gson) - dbResult.account = status.account.toEntity(account.id, gson) - - whenever(mastodonApi.homeTimeline(any(), any(), any())) - .thenReturn(Single.just(Response.success((listOf(status))))) - whenever(timelineDao.getStatusesForAccount(account.id, status.id, null, 30)) - .thenReturn(Single.just(listOf(dbResult))) - val result = subject.getStatuses(null, null, null, limit, TimelineRequestMode.ANY) - .blockingGet() - assertEquals(listOf(status, dbStatus).map(Status::lift), result) - } - - @Test - fun addingFromDbExhausted() { - RxJavaPlugins.setIoSchedulerHandler { Schedulers.single() } - val status = makeStatus("4") - val dbResult = TimelineStatusWithAccount() - dbResult.status = Placeholder("2").toEntity(account.id) - val dbResult2 = TimelineStatusWithAccount() - dbResult2.status = Placeholder("1").toEntity(account.id) - - whenever(mastodonApi.homeTimeline(any(), any(), any())) - .thenReturn(Single.just(Response.success(listOf(status)))) - whenever(timelineDao.getStatusesForAccount(account.id, status.id, null, 30)) - .thenReturn(Single.just(listOf(dbResult, dbResult2))) - val result = subject.getStatuses(null, null, null, limit, TimelineRequestMode.ANY) - .blockingGet() - assertEquals(listOf(status).map(Status::lift), result) - } -} - -fun makeAccount(id: String): Account { - return Account( - id = id, - localUsername = "test$id", - username = "test$id@example.com", - displayName = "Example Account $id", - note = SpannableString("Note! $id"), - url = "https://example.com/@test$id", - avatar = "avatar$id", - header = "Header$id", - followersCount = 300, - followingCount = 400, - statusesCount = 1000, - bot = false, - emojis = listOf(), - fields = null, - source = null - ) -} - -fun makeStatus(id: String, account: Account = makeAccount(id)): Status { - return Status( - id = id, - account = account, - content = SpannableString("hello$id"), - createdAt = Date(), - emojis = listOf(), - reblogsCount = 3, - favouritesCount = 5, - sensitive = false, - visibility = Status.Visibility.PUBLIC, - spoilerText = "", - reblogged = true, - favourited = false, - bookmarked = false, - attachments = ArrayList(), - mentions = listOf(), - application = null, - inReplyToAccountId = null, - inReplyToId = null, - pinned = false, - muted = false, - reblog = null, - url = "http://example.com/statuses/$id", - poll = null, - card = null - ) -} diff --git a/app/src/test/java/com/keylesspalace/tusky/components/timeline/TimelineViewModelTest.kt b/app/src/test/java/com/keylesspalace/tusky/components/timeline/TimelineViewModelTest.kt index 9116f29fa..5269ebe69 100644 --- a/app/src/test/java/com/keylesspalace/tusky/components/timeline/TimelineViewModelTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/components/timeline/TimelineViewModelTest.kt @@ -1,792 +1,215 @@ package com.keylesspalace.tusky.components.timeline -import android.content.SharedPreferences -import com.keylesspalace.tusky.appstore.EventHub -import com.keylesspalace.tusky.components.timeline.TimelineViewModel.Companion.LOAD_AT_ONCE +import android.os.Looper +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.paging.AsyncPagingDataDiffer +import androidx.paging.ExperimentalPagingApi +import androidx.recyclerview.widget.ListUpdateCallback +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.appstore.EventHubImpl +import com.keylesspalace.tusky.components.timeline.TimelinePagingAdapter.Companion.TimelineDifferCallback +import com.keylesspalace.tusky.components.timeline.viewmodel.CachedTimelineViewModel +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.entity.Poll -import com.keylesspalace.tusky.entity.PollOption -import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.db.AppDatabase +import com.keylesspalace.tusky.db.Converters import com.keylesspalace.tusky.network.FilterModel import com.keylesspalace.tusky.network.MastodonApi -import com.keylesspalace.tusky.network.TimelineCases -import com.keylesspalace.tusky.util.Either -import com.keylesspalace.tusky.util.toViewData -import com.keylesspalace.tusky.viewdata.StatusViewData -import com.nhaarman.mockitokotlin2.clearInvocations +import com.keylesspalace.tusky.network.TimelineCasesImpl import com.nhaarman.mockitokotlin2.doReturn -import com.nhaarman.mockitokotlin2.eq -import com.nhaarman.mockitokotlin2.isNull import com.nhaarman.mockitokotlin2.mock -import com.nhaarman.mockitokotlin2.times -import com.nhaarman.mockitokotlin2.verify -import com.nhaarman.mockitokotlin2.whenever -import io.reactivex.rxjava3.annotations.NonNull -import io.reactivex.rxjava3.core.Observable import io.reactivex.rxjava3.core.Single -import io.reactivex.rxjava3.observers.TestObserver -import io.reactivex.rxjava3.subjects.PublishSubject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.TestCoroutineDispatcher +import kotlinx.coroutines.test.TestCoroutineScope +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import okhttp3.Headers +import org.junit.After import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue import org.junit.Before +import org.junit.Rule import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Shadows.shadowOf import org.robolectric.annotation.Config -import org.robolectric.shadows.ShadowLog import retrofit2.Response -import java.io.IOException +import java.util.concurrent.Executors +@ExperimentalCoroutinesApi @Config(sdk = [29]) +@RunWith(AndroidJUnit4::class) class TimelineViewModelTest { - lateinit var timelineRepository: TimelineRepository - lateinit var timelineCases: TimelineCases - lateinit var mastodonApi: MastodonApi - lateinit var eventHub: EventHub - lateinit var viewModel: TimelineViewModel - lateinit var accountManager: AccountManager - lateinit var sharedPreference: SharedPreferences + + @get:Rule + val instantRule = InstantTaskExecutorRule() + + private val testDispatcher = TestCoroutineDispatcher() + private val testScope = TestCoroutineScope(testDispatcher) + + private val accountManager: AccountManager = mock { + on { activeAccount } doReturn AccountEntity( + id = 1, + domain = "mastodon.example", + accessToken = "token", + isActive = true + ) + } + + private lateinit var db: AppDatabase @Before fun setup() { - ShadowLog.stream = System.out - timelineRepository = mock() - timelineCases = mock() - mastodonApi = mock() - eventHub = mock { - on { events } doReturn Observable.never() - } - val account = AccountEntity( - 0, - "domain", - "accessToken", - isActive = true, - ) + Dispatchers.setMain(testDispatcher) - accountManager = mock { - on { activeAccount } doReturn account - } - sharedPreference = mock() - viewModel = TimelineViewModel( - timelineRepository, - timelineCases, - mastodonApi, - eventHub, - accountManager, - sharedPreference, - FilterModel() - ) + shadowOf(Looper.getMainLooper()).idle() + + val context = InstrumentationRegistry.getInstrumentation().targetContext + db = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java) + .addTypeConverter(Converters(Gson())) + .setTransactionExecutor(Executors.newSingleThreadExecutor()) + .allowMainThreadQueries() + .build() + } + + @After + fun tearDown() { + Dispatchers.resetMain() + testDispatcher.cleanupTestCoroutines() + db.close() } @Test - fun `loadInitial, home, without cache, empty response`() { - val initialResponse = listOf() - setCachedResponse(initialResponse) + @ExperimentalPagingApi + fun shouldLoadNetworkTimeline() = runBlocking { - // loadAbove -> loadBelow - whenever( - timelineRepository.getStatuses( - maxId = null, - sinceId = null, - sincedIdMinusOne = null, - requestMode = TimelineRequestMode.ANY, - limit = LOAD_AT_ONCE - ) - ).thenReturn(Single.just(listOf())) - - runBlocking { - viewModel.loadInitial() - } - - verify(timelineRepository).getStatuses( - null, - null, - null, - LOAD_AT_ONCE, - TimelineRequestMode.ANY - ) - } - - @Test - fun `loadInitial, home, without cache, single item in response`() { - setCachedResponse(listOf()) - - val status = makeStatus("1") - whenever( - timelineRepository.getStatuses( - isNull(), - isNull(), - isNull(), - eq(LOAD_AT_ONCE), - eq(TimelineRequestMode.ANY) - ) - ).thenReturn( - Single.just( - listOf( - Either.Right(status) - ) - ) - ) - - val updates = viewModel.viewUpdates.test() - - runBlocking { - viewModel.loadInitial() - } - - verify(timelineRepository).getStatuses( - isNull(), - isNull(), - isNull(), - eq(LOAD_AT_ONCE), - eq(TimelineRequestMode.ANY) - ) - - assertViewUpdated(updates) - - assertHasList(listOf(status).toViewData()) - } - - @Test - fun `loadInitial, list`() { - val listId = "listId" - viewModel.init(TimelineViewModel.Kind.LIST, listId, listOf()) - val status = makeStatus("1") - - whenever( - mastodonApi.listTimeline( - listId, - null, - null, - LOAD_AT_ONCE, - ) - ).thenReturn( - Single.just( + val api: MastodonApi = mock { + on { publicTimeline(local = true, maxId = null, sinceId = null, limit = 30) } doReturn Single.just( Response.success( listOf( - status + mockStatus("6"), + mockStatus("5"), + mockStatus("4") + ), + Headers.headersOf( + "Link", "; rel=\"next\", ; rel=\"prev\"" ) ) ) + + on { publicTimeline(local = true, maxId = "1", sinceId = null, limit = 30) } doReturn Single.just( + Response.success(emptyList()) + ) + + on { getFilters() } doReturn Single.just(emptyList()) + } + + val viewModel = NetworkTimelineViewModel( + TimelineCasesImpl(api, EventHubImpl), + api, + EventHubImpl, + accountManager, + mock(), + FilterModel() ) - val updates = viewModel.viewUpdates.test() + viewModel.init(TimelineViewModel.Kind.PUBLIC_LOCAL, null, emptyList()) - runBlocking { - viewModel.loadInitial().join() - } - assertViewUpdated(updates) - - assertHasList(listOf(status).toViewData()) - assertFalse("loading", viewModel.isLoadingInitially) - } - - @Test - fun `loadInitial, home, without cache, error on load`() { - setCachedResponse(listOf()) - - whenever( - timelineRepository.getStatuses( - maxId = null, - sinceId = null, - sincedIdMinusOne = null, - limit = LOAD_AT_ONCE, - TimelineRequestMode.ANY, - ) - ).thenReturn(Single.error(IOException("test"))) - - val updates = viewModel.viewUpdates.test() - - runBlocking { - viewModel.loadInitial() - } - - verify(timelineRepository).getStatuses( - isNull(), - isNull(), - isNull(), - eq(LOAD_AT_ONCE), - eq(TimelineRequestMode.ANY) + val differ = AsyncPagingDataDiffer( + diffCallback = TimelineDifferCallback, + updateCallback = NoopListCallback(), + workerDispatcher = testDispatcher ) - assertViewUpdated(updates) - - assertHasList(listOf()) - assertEquals(TimelineViewModel.FailureReason.NETWORK, viewModel.failure) - } - - @Test - fun `loadInitial, home, with cache, error on load above`() { - val statuses = (5 downTo 1).map { makeStatus(it.toString()) } - setCachedResponse(statuses) - setInitialRefresh("6", statuses) - - whenever( - timelineRepository.getStatuses( - maxId = null, - sinceId = "5", - sincedIdMinusOne = "4", - limit = LOAD_AT_ONCE, - TimelineRequestMode.NETWORK, - ) - ).thenReturn(Single.error(IOException("test"))) - - val updates = viewModel.viewUpdates.test() - - runBlocking { - viewModel.loadInitial() + viewModel.statuses.take(2).collectLatest { + testScope.launch { + differ.submitData(it) + } } - assertViewUpdated(updates) - - assertHasList(statuses.toViewData()) - // No failure set since we had statuses - assertNull(viewModel.failure) - } - - @Test - fun `loadInitial, home, with cache, error on refresh`() { - val statuses = (5 downTo 2).map { makeStatus(it.toString()) } - setCachedResponse(statuses) - - // Error on refreshing cached - whenever( - timelineRepository.getStatuses( - maxId = "6", - sinceId = null, - sincedIdMinusOne = null, - limit = LOAD_AT_ONCE, - TimelineRequestMode.NETWORK, - ) - ).thenReturn(Single.error(IOException("test"))) - - // Empty on loading above - setLoadAbove("5", "4", listOf()) - - val updates = viewModel.viewUpdates.test() - - runBlocking { - viewModel.loadInitial() - } - - assertViewUpdated(updates) - - assertHasList(statuses.toViewData()) - assertNull(viewModel.failure) - } - - @Test - fun `loads above cached`() { - val cachedStatuses = (5 downTo 1).map { makeStatus(it.toString()) } - setCachedResponse(cachedStatuses) - setInitialRefresh("6", cachedStatuses) - - val additionalStatuses = (10 downTo 6) - .map { makeStatus(it.toString()) } - - whenever( - timelineRepository.getStatuses( - null, - "5", - "4", - LOAD_AT_ONCE, - TimelineRequestMode.NETWORK - ) - ).thenReturn(Single.just(additionalStatuses.toEitherList())) - - runBlocking { - viewModel.loadInitial() - } - - // We could also check refresh progress here but it's a bit cumbersome - - assertHasList(additionalStatuses.plus(cachedStatuses).toViewData()) - } - - @Test - fun refresh() { - val cachedStatuses = (5 downTo 1).map { makeStatus(it.toString()) } - setCachedResponse(cachedStatuses) - setInitialRefresh("6", cachedStatuses) - - val additionalStatuses = listOf(makeStatus("6")) - - whenever( - timelineRepository.getStatuses( - null, - "5", - "4", - LOAD_AT_ONCE, - TimelineRequestMode.NETWORK - ) - ).thenReturn(Single.just(additionalStatuses.toEitherList())) - - runBlocking { - viewModel.loadInitial() - } - - clearInvocations(timelineRepository) - - val newStatuses = (8 downTo 7).map { makeStatus(it.toString()) } - - // Loading above the cached manually - whenever( - timelineRepository.getStatuses( - null, - "6", - "5", - LOAD_AT_ONCE, - TimelineRequestMode.NETWORK - ) - ).thenReturn(Single.just(newStatuses.toEitherList())) - - runBlocking { - viewModel.refresh() - } - - val allStatuses = newStatuses + additionalStatuses + cachedStatuses - assertHasList(allStatuses.toViewData()) - } - - @Test - fun `refresh failed`() { - val cachedStatuses = (5 downTo 1).map { makeStatus(it.toString()) } - setCachedResponse(cachedStatuses) - setInitialRefresh("6", cachedStatuses) - setLoadAbove("5", "4", listOf()) - - runBlocking { - viewModel.loadInitial() - } - - clearInvocations(timelineRepository) - - // Loading above the cached manually - whenever( - timelineRepository.getStatuses( - null, - "6", - "5", - LOAD_AT_ONCE, - TimelineRequestMode.NETWORK - ) - ).thenReturn(Single.error(IOException("test"))) - - runBlocking { - viewModel.refresh().join() - } - - assertHasList(cachedStatuses.map { it.toViewData(false, false) }) - assertFalse("refreshing", viewModel.isRefreshing) - assertNull("failure is not set", viewModel.failure) - } - - @Test - fun loadMore() { - val cachedStatuses = (10 downTo 5).map { makeStatus(it.toString()) } - setCachedResponse(cachedStatuses) - setInitialRefresh("11", cachedStatuses) - - // Nothing above - setLoadAbove("10", "9", listOf()) - - runBlocking { - viewModel.loadInitial().join() - } - - clearInvocations(timelineRepository) - - val oldStatuses = (4 downTo 1).map { makeStatus(it.toString()) } - - // Loading below the cached - whenever( - timelineRepository.getStatuses( - "5", - null, - null, - LOAD_AT_ONCE, - TimelineRequestMode.ANY - ) - ).thenReturn(Single.just(oldStatuses.toEitherList())) - - runBlocking { - viewModel.loadMore().join() - } - - val allStatuses = cachedStatuses + oldStatuses - assertHasList(allStatuses.toViewData()) - } - - @Test - fun `loadMore parallel`() { - val cachedStatuses = (10 downTo 5).map { makeStatus(it.toString()) } - setCachedResponse(cachedStatuses) - setInitialRefresh("11", cachedStatuses) - - // Nothing above - setLoadAbove("10", "9", listOf()) - - runBlocking { - viewModel.loadInitial().join() - } - - clearInvocations(timelineRepository) - - val oldStatuses = (4 downTo 1).map { makeStatus(it.toString()) } - - val responseSubject = PublishSubject.create>() - // Loading below the cached - whenever( - timelineRepository.getStatuses( - "5", - null, - null, - LOAD_AT_ONCE, - TimelineRequestMode.ANY - ) - ).thenReturn(responseSubject.firstOrError()) - - clearInvocations(timelineRepository) - - runBlocking { - // Trigger them in parallel - val job1 = viewModel.loadMore() - val job2 = viewModel.loadMore() - // Send the response - responseSubject.onNext(oldStatuses.toEitherList()) - // Wait for both - job1.join() - job2.join() - } - - val allStatuses = cachedStatuses + oldStatuses - assertHasList(allStatuses.toViewData()) - - verify(timelineRepository, times(1)).getStatuses( - "5", - null, - null, - LOAD_AT_ONCE, - TimelineRequestMode.ANY - ) - } - - @Test - fun `loadMore failed`() { - val cachedStatuses = (10 downTo 5).map { makeStatus(it.toString()) } - setCachedResponse(cachedStatuses) - setInitialRefresh("11", cachedStatuses) - - // Nothing above - setLoadAbove("10", "9", listOf()) - - runBlocking { - viewModel.loadInitial().join() - } - - clearInvocations(timelineRepository) - - // Loading below the cached - whenever( - timelineRepository.getStatuses( - "5", - null, - null, - LOAD_AT_ONCE, - TimelineRequestMode.ANY - ) - ).thenReturn(Single.error(IOException("test"))) - - runBlocking { - viewModel.loadMore().join() - } - - assertHasList(cachedStatuses.toViewData()) - - // Check that we can still load after that - - val oldStatuses = listOf(makeStatus("4")) - whenever( - timelineRepository.getStatuses( - "5", - null, - null, - LOAD_AT_ONCE, - TimelineRequestMode.ANY - ) - ).thenReturn(Single.just(oldStatuses.toEitherList())) - - runBlocking { - viewModel.loadMore().join() - } - assertHasList((cachedStatuses + oldStatuses).toViewData()) - } - - @Test - fun loadGap() { - val status5 = makeStatus("5") - val status4 = makeStatus("4") - val status3 = makeStatus("3") - val status1 = makeStatus("1") - - val cachedStatuses: List = listOf( - Either.Right(status5), - Either.Left(Placeholder("4")), - Either.Right(status1) - ) - val laterFetchedStatuses = listOf( - Either.Right(status4), - Either.Right(status3), - ) - - setCachedResponseWithGaps(cachedStatuses) - setInitialRefreshWithGaps("6", cachedStatuses) - - // Nothing above - setLoadAbove("5", items = listOf()) - - whenever( - timelineRepository.getStatuses( - "5", - "1", - null, - LOAD_AT_ONCE, - TimelineRequestMode.NETWORK - ) - ).thenReturn(Single.just(laterFetchedStatuses)) - - runBlocking { - viewModel.loadInitial().join() - - viewModel.loadGap(1).join() - } - - assertHasList( - listOf( - status5, - status4, - status3, - status1 - ).toViewData() - ) - } - - @Test - fun `loadGap failed`() { - val status5 = makeStatus("5") - val status1 = makeStatus("1") - - val cachedStatuses: List = listOf( - Either.Right(status5), - Either.Left(Placeholder("4")), - Either.Right(status1) - ) - setCachedResponseWithGaps(cachedStatuses) - setInitialRefreshWithGaps("6", cachedStatuses) - - setLoadAbove("5", items = listOf()) - - whenever( - timelineRepository.getStatuses( - "5", - "1", - null, - LOAD_AT_ONCE, - TimelineRequestMode.NETWORK - ) - ).thenReturn(Single.error(IOException("test"))) - - runBlocking { - viewModel.loadInitial().join() - - viewModel.loadGap(1).join() - } - - assertHasList( - listOf( - status5.toViewData(false, false), - StatusViewData.Placeholder("4", false), - status1.toViewData(false, false), - ) - ) - } - - @Test - fun favorite() { - val status5 = makeStatus("5") - val status4 = makeStatus("4") - val status3 = makeStatus("3") - val statuses = listOf(status5, status4, status3) - setCachedResponse(statuses) - setInitialRefresh("6", statuses) - setLoadAbove("5", "4", listOf()) - - runBlocking { viewModel.loadInitial() } - - whenever(timelineCases.favourite("4", true)) - .thenReturn(Single.just(status4.copy(favourited = true))) - - runBlocking { - viewModel.favorite(true, 1).join() - } - - verify(timelineCases).favourite("4", true) - - assertHasList(listOf(status5, status4.copy(favourited = true), status3).toViewData()) - } - - @Test - fun reblog() { - val status5 = makeStatus("5") - val status4 = makeStatus("4") - val status3 = makeStatus("3") - val statuses = listOf(status5, status4, status3) - setCachedResponse(statuses) - setInitialRefresh("6", statuses) - setLoadAbove("5", "4", listOf()) - - runBlocking { viewModel.loadInitial() } - - whenever(timelineCases.reblog("4", true)) - .thenReturn(Single.just(status4.copy(reblogged = true))) - - runBlocking { - viewModel.reblog(true, 1).join() - } - - verify(timelineCases).reblog("4", true) - - assertHasList(listOf(status5, status4.copy(reblogged = true), status3).toViewData()) - } - - @Test - fun bookmark() { - val status5 = makeStatus("5") - val status4 = makeStatus("4") - val status3 = makeStatus("3") - val statuses = listOf(status5, status4, status3) - setCachedResponse(statuses) - setInitialRefresh("6", statuses) - setLoadAbove("5", "4", listOf()) - - runBlocking { viewModel.loadInitial() } - - whenever(timelineCases.bookmark("4", true)) - .thenReturn(Single.just(status4.copy(bookmarked = true))) - - runBlocking { - viewModel.bookmark(true, 1).join() - } - - verify(timelineCases).bookmark("4", true) - - assertHasList(listOf(status5, status4.copy(bookmarked = true), status3).toViewData()) - } - - @Test - fun voteInPoll() { - val status5 = makeStatus("5") - val poll = Poll( - "1", - expiresAt = null, - expired = false, - multiple = false, - votersCount = 1, - votesCount = 1, - voted = false, - options = listOf(PollOption("1", 1), PollOption("2", 2)), - ownVotes = null - ) - val status4 = makeStatus("4").copy(poll = poll) - val status3 = makeStatus("3") - val statuses = listOf(status5, status4, status3) - setCachedResponse(statuses) - setInitialRefresh("6", statuses) - setLoadAbove("5", "4", listOf()) - - runBlocking { viewModel.loadInitial() } - - val votedPoll = poll.votedCopy(listOf(0)) - whenever(timelineCases.voteInPoll("4", poll.id, listOf(0))) - .thenReturn(Single.just(votedPoll)) - - runBlocking { - viewModel.voteInPoll(1, listOf(0)).join() - } - - verify(timelineCases).voteInPoll("4", poll.id, listOf(0)) - - assertHasList(listOf(status5, status4.copy(poll = votedPoll), status3).toViewData()) - } - - private fun setLoadAbove( - above: String, - aboveMinusOne: String? = null, - items: List - ) { - whenever( - timelineRepository.getStatuses( - null, - above, - aboveMinusOne, - LOAD_AT_ONCE, - TimelineRequestMode.NETWORK - ) - ).thenReturn(Single.just(items)) - } - - private fun assertHasList(aList: List) { assertEquals( - aList, - viewModel.statuses.toList() + listOf( + mockStatusViewData("6"), + mockStatusViewData("5"), + mockStatusViewData("4") + ), + differ.snapshot().items ) } - private fun assertViewUpdated(updates: @NonNull TestObserver) { - assertTrue("There were view updates", updates.values().isNotEmpty()) - } + // ToDo: Find out why Room & coroutines are not playing nice here + // @Test + @ExperimentalPagingApi + fun shouldLoadCachedTimeline() = runBlocking { - private fun setInitialRefresh(maxId: String?, statuses: List) { - setInitialRefreshWithGaps(maxId, statuses.toEitherList()) - } - - private fun setCachedResponse(initialResponse: List) { - setCachedResponseWithGaps(initialResponse.toEitherList()) - } - - private fun setCachedResponseWithGaps(initialResponse: List) { - whenever( - timelineRepository.getStatuses( - isNull(), - isNull(), - isNull(), - eq(LOAD_AT_ONCE), - eq(TimelineRequestMode.DISK) + val api: MastodonApi = mock { + on { homeTimeline(limit = 30) } doReturn Single.just( + Response.success( + listOf( + mockStatus("6"), + mockStatus("5"), + mockStatus("4") + ) + ) ) - ) - .thenReturn(Single.just(initialResponse)) - } - private fun setInitialRefreshWithGaps(maxId: String?, statuses: List) { - whenever( - timelineRepository.getStatuses( - maxId, - null, - null, - LOAD_AT_ONCE, - TimelineRequestMode.NETWORK + on { homeTimeline(maxId = "1", sinceId = null, limit = 30) } doReturn Single.just( + Response.success(emptyList()) ) - ).thenReturn(Single.just(statuses)) - } - private fun List.toViewData(): List = map { - it.toViewData( - alwaysShowSensitiveMedia = false, - alwaysOpenSpoiler = false + on { getFilters() } doReturn Single.just(emptyList()) + } + + val viewModel = CachedTimelineViewModel( + TimelineCasesImpl(api, EventHubImpl), + api, + EventHubImpl, + accountManager, + mock(), + FilterModel(), + db, + Gson() + ) + + viewModel.init(TimelineViewModel.Kind.HOME, null, emptyList()) + + val differ = AsyncPagingDataDiffer( + diffCallback = TimelineDifferCallback, + updateCallback = NoopListCallback(), + workerDispatcher = testDispatcher + ) + + var x = 1 + viewModel.statuses.take(1000).collectLatest { + testScope.launch { + differ.submitData(it) + } + } + + assertEquals( + listOf( + mockStatusViewData("6"), + mockStatusViewData("5"), + mockStatusViewData("4") + ), + differ.snapshot().items ) } - - private fun List.toEitherList() = map { Either.Right(it) } +} + +class NoopListCallback : ListUpdateCallback { + override fun onChanged(position: Int, count: Int, payload: Any?) {} + override fun onMoved(fromPosition: Int, toPosition: Int) {} + override fun onInserted(position: Int, count: Int) {} + override fun onRemoved(position: Int, count: Int) {} } diff --git a/app/src/test/java/com/keylesspalace/tusky/db/TimelineDaoTest.kt b/app/src/test/java/com/keylesspalace/tusky/db/TimelineDaoTest.kt new file mode 100644 index 000000000..1f8dfee7e --- /dev/null +++ b/app/src/test/java/com/keylesspalace/tusky/db/TimelineDaoTest.kt @@ -0,0 +1,331 @@ +package com.keylesspalace.tusky.db + +import androidx.paging.PagingSource +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.appstore.CacheUpdater +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 +import org.robolectric.annotation.Config + +@Config(sdk = [28]) +@RunWith(AndroidJUnit4::class) +class TimelineDaoTest { + private lateinit var timelineDao: TimelineDao + private lateinit var db: AppDatabase + + @Before + fun createDb() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + db = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java) + .addTypeConverter(Converters(Gson())) + .allowMainThreadQueries() + .build() + timelineDao = db.timelineDao() + } + + @After + fun closeDb() { + db.close() + } + + @Test + fun insertGetStatus() = runBlocking { + val setOne = makeStatus(statusId = 3) + val setTwo = makeStatus(statusId = 20, reblog = true) + val ignoredOne = makeStatus(statusId = 1) + val ignoredTwo = makeStatus(accountId = 2) + + for ((status, author, reblogger) in listOf(setOne, setTwo, ignoredOne, ignoredTwo)) { + timelineDao.insertAccount(author) + reblogger?.let { + timelineDao.insertAccount(it) + } + timelineDao.insertStatus(status) + } + + val pagingSource = timelineDao.getStatusesForAccount(setOne.first.timelineUserId) + + val loadResult = pagingSource.load(PagingSource.LoadParams.Refresh(null, 2, false)) + + val loadedStatuses = (loadResult as PagingSource.LoadResult.Page).data + + assertEquals(2, loadedStatuses.size) + assertStatuses(listOf(setTwo, setOne), loadedStatuses) + } + + @Test + fun cleanup() = runBlocking { + val now = System.currentTimeMillis() + val oldDate = now - CacheUpdater.CLEANUP_INTERVAL - 20_000 + val oldThisAccount = makeStatus( + statusId = 5, + createdAt = oldDate + ) + val oldAnotherAccount = makeStatus( + statusId = 10, + createdAt = oldDate, + accountId = 2 + ) + val recentThisAccount = makeStatus( + statusId = 30, + createdAt = System.currentTimeMillis() + ) + val recentAnotherAccount = makeStatus( + statusId = 60, + createdAt = System.currentTimeMillis(), + accountId = 2 + ) + + for ((status, author, reblogAuthor) in listOf(oldThisAccount, oldAnotherAccount, recentThisAccount, recentAnotherAccount)) { + timelineDao.insertAccount(author) + reblogAuthor?.let { + timelineDao.insertAccount(it) + } + timelineDao.insertStatus(status) + } + + timelineDao.cleanup(now - CacheUpdater.CLEANUP_INTERVAL) + + val loadParams: PagingSource.LoadParams = PagingSource.LoadParams.Refresh(null, 100, false) + + val loadedStatusAccount1 = (timelineDao.getStatusesForAccount(1).load(loadParams) as PagingSource.LoadResult.Page).data + val loadedStatusAccount2 = (timelineDao.getStatusesForAccount(2).load(loadParams) as PagingSource.LoadResult.Page).data + + assertStatuses(listOf(recentThisAccount), loadedStatusAccount1) + assertStatuses(listOf(recentAnotherAccount), loadedStatusAccount2) + } + + @Test + fun overwriteDeletedStatus() = runBlocking { + + val oldStatuses = listOf( + makeStatus(statusId = 3), + makeStatus(statusId = 2), + makeStatus(statusId = 1) + ) + + timelineDao.deleteRange(1, oldStatuses.last().first.serverId, oldStatuses.first().first.serverId) + + for ((status, author, reblogAuthor) in oldStatuses) { + timelineDao.insertAccount(author) + reblogAuthor?.let { + timelineDao.insertAccount(it) + } + timelineDao.insertStatus(status) + } + + // status 2 gets deleted, newly loaded status contain only 1 + 3 + val newStatuses = listOf( + makeStatus(statusId = 3), + makeStatus(statusId = 1) + ) + + timelineDao.deleteRange(1, newStatuses.last().first.serverId, newStatuses.first().first.serverId) + + for ((status, author, reblogAuthor) in newStatuses) { + timelineDao.insertAccount(author) + reblogAuthor?.let { + timelineDao.insertAccount(it) + } + timelineDao.insertStatus(status) + } + + // make sure status 2 is no longer in db + + val pagingSource = timelineDao.getStatusesForAccount(1) + + val loadResult = pagingSource.load(PagingSource.LoadParams.Refresh(null, 100, false)) + + val loadedStatuses = (loadResult as PagingSource.LoadResult.Page).data + + assertStatuses(newStatuses, loadedStatuses) + } + + @Test + fun deleteAllForInstance() = runBlocking { + + val statusWithRedDomain1 = makeStatus( + statusId = 15, + accountId = 1, + domain = "mastodon.red", + authorServerId = "1" + ) + val statusWithRedDomain2 = makeStatus( + statusId = 14, + accountId = 1, + domain = "mastodon.red", + authorServerId = "2" + ) + val statusWithRedDomainOtherAccount = makeStatus( + statusId = 12, + accountId = 2, + domain = "mastodon.red", + authorServerId = "2" + ) + val statusWithBlueDomain = makeStatus( + statusId = 10, + accountId = 1, + domain = "mastodon.blue", + authorServerId = "4" + ) + val statusWithBlueDomainOtherAccount = makeStatus( + statusId = 10, + accountId = 2, + domain = "mastodon.blue", + authorServerId = "5" + ) + val statusWithGreenDomain = makeStatus( + statusId = 8, + accountId = 1, + domain = "mastodon.green", + authorServerId = "6" + ) + + for ((status, author, reblogAuthor) in listOf(statusWithRedDomain1, statusWithRedDomain2, statusWithRedDomainOtherAccount, statusWithBlueDomain, statusWithBlueDomainOtherAccount, statusWithGreenDomain)) { + timelineDao.insertAccount(author) + reblogAuthor?.let { + timelineDao.insertAccount(it) + } + timelineDao.insertStatus(status) + } + + timelineDao.deleteAllFromInstance(1, "mastodon.red") + timelineDao.deleteAllFromInstance(1, "mastodon.blu") // shouldn't delete anything + timelineDao.deleteAllFromInstance(1, "greenmastodon.green") // shouldn't delete anything + + val loadParams: PagingSource.LoadParams = PagingSource.LoadParams.Refresh(null, 100, false) + + val statusesAccount1 = (timelineDao.getStatusesForAccount(1).load(loadParams) as PagingSource.LoadResult.Page).data + val statusesAccount2 = (timelineDao.getStatusesForAccount(2).load(loadParams) as PagingSource.LoadResult.Page).data + + assertStatuses(listOf(statusWithBlueDomain, statusWithGreenDomain), statusesAccount1) + 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 status1 = makeStatus( + statusId = 4, + accountId = 1, + domain = "mastodon.test", + authorServerId = "1" + ) + val status2 = makeStatus( + statusId = 33, + accountId = 1, + domain = "mastodon.test", + authorServerId = "2" + ) + val status3 = makeStatus( + statusId = 22, + accountId = 1, + domain = "mastodon.test", + authorServerId = "2" + ) + + for ((status, author, reblogAuthor) in listOf(status1, status2, status3)) { + timelineDao.insertAccount(author) + reblogAuthor?.let { + timelineDao.insertAccount(it) + } + timelineDao.insertStatus(status) + } + + assertEquals("33", timelineDao.getTopId(1)) + } + + private fun makeStatus( + accountId: Long = 1, + statusId: Long = 10, + reblog: Boolean = false, + createdAt: Long = statusId, + authorServerId: String = "20", + domain: String = "mastodon.example" + ): Triple { + val author = TimelineAccountEntity( + authorServerId, + accountId, + "localUsername@$domain", + "username@$domain", + "displayName", + "blah", + "avatar", + "[\"tusky\": \"http://tusky.cool/emoji.jpg\"]", + false + ) + + val reblogAuthor = if (reblog) { + TimelineAccountEntity( + "R$authorServerId", + accountId, + "RlocalUsername", + "Rusername", + "RdisplayName", + "Rblah", + "Ravatar", + "[]", + false + ) + } else null + + val even = accountId % 2 == 0L + val status = TimelineStatusEntity( + serverId = statusId.toString(), + url = "https://$domain/whatever/$statusId", + timelineUserId = accountId, + authorServerId = authorServerId, + inReplyToId = "inReplyToId$statusId", + inReplyToAccountId = "inReplyToAccountId$statusId", + content = "Content!$statusId", + createdAt = createdAt, + emojis = "emojis$statusId", + reblogsCount = 1 * statusId.toInt(), + favouritesCount = 2 * statusId.toInt(), + reblogged = even, + favourited = !even, + bookmarked = false, + sensitive = even, + spoilerText = "spoier$statusId", + visibility = Status.Visibility.PRIVATE, + attachments = "attachments$accountId", + mentions = "mentions$accountId", + application = "application$accountId", + reblogServerId = if (reblog) (statusId * 100).toString() else null, + reblogAccountId = reblogAuthor?.serverId, + poll = null, + muted = false, + expanded = false, + contentCollapsed = false, + contentShowing = true, + pinned = false + ) + return Triple(status, author, reblogAuthor) + } + + private fun assertStatuses( + expected: List>, + provided: List + ) { + for ((exp, prov) in expected.zip(provided)) { + val (status, author, reblogger) = exp + assertEquals(status, prov.status) + assertEquals(author, prov.account) + assertEquals(reblogger, prov.reblogAccount) + } + } +}