Merge commit '643e012b11f20538fd17aa3ab888d8e739ebd0d0'

This commit is contained in:
kyori19 2022-03-04 02:18:40 +09:00
commit 8e49dd7329
41 changed files with 4163 additions and 3294 deletions

View File

@ -189,6 +189,8 @@ dependencies {
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'
implementation 'net.accelf:easter:1.0.2'
implementation 'org.jsoup:jsoup:1.13.1'
}

View File

@ -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')"
]
}
}

View File

@ -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<TimelineStatusEntity, TimelineAccountEntity, TimelineAccountEntity?> {
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)
}

View File

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

View File

@ -9,7 +9,7 @@ import autodispose2.androidx.lifecycle.autoDispose
import com.google.android.material.floatingactionbutton.FloatingActionButton
import com.keylesspalace.tusky.appstore.EventHub
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.di.ViewModelFactory
import com.keylesspalace.tusky.interfaces.ActionButtonActivity

View File

@ -24,7 +24,7 @@ import androidx.lifecycle.Lifecycle
import autodispose2.androidx.lifecycle.autoDispose
import com.keylesspalace.tusky.appstore.EventHub
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 com.keylesspalace.tusky.di.ViewModelFactory
import dagger.android.DispatchingAndroidInjector

View File

@ -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 */

View File

@ -1,12 +1,12 @@
package com.keylesspalace.tusky.appstore
import com.google.gson.Gson
import com.keylesspalace.tusky.components.timeline.toEntity
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.AppDatabase
import 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(
@ -20,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) {
@ -37,14 +43,8 @@ class CacheUpdater @Inject constructor(
val pollString = gson.toJson(event.poll)
timelineDao.setVoted(accountId, event.statusId, pollString)
}
is StreamUpdateEvent -> {
val status = event.status
timelineDao.insertInTransaction(
status.toEntity(accountId, gson),
status.account.toEntity(accountId, gson),
status.reblog?.account?.toEntity(accountId, gson),
)
}
is PinEvent ->
timelineDao.setPinned(accountId, event.statusId, event.pinned)
}
}
}
@ -61,4 +61,8 @@ class CacheUpdater @Inject constructor(
.subscribeOn(Schedulers.io())
.subscribe()
}
companion object {
val CLEANUP_INTERVAL = TimeUnit.DAYS.toMillis(14)
}
}

View File

@ -62,7 +62,7 @@ class SearchViewModel @Inject constructor(
private val loadedNotestockStatuses: MutableList<Pair<Status, StatusViewData.Concrete>> = 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)
}
@ -74,7 +74,7 @@ class SearchViewModel @Inject constructor(
it.hashtags
}
private val notestockStatusesPagingSourceFactory = SearchNotestockPagingSourceFactory(notestockApi) {
it.statuses.map { status -> Pair(status, status.toViewData(alwaysShowSensitiveMedia, alwaysOpenSpoiler)) }
it.statuses.map { status -> Pair(status, status.toViewData(alwaysShowSensitiveMedia, alwaysOpenSpoiler, true)) }
.apply {
loadedNotestockStatuses.addAll(this)
}

View File

@ -1,139 +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 <http://www.gnu.org/licenses>. */
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<T> {
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<StatusViewData> dataSource;
private StatusDisplayOptions statusDisplayOptions;
private final StatusActionListener statusListener;
public TimelineAdapter(AdapterDataSource<StatusViewData> 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(),
statusDisplayOptions.quoteEnabled()
);
}
@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();
}
}

View File

@ -22,10 +22,15 @@ 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.*
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.SimpleItemAnimator
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener
import at.connyduck.sparkbutton.helpers.Utils
import autodispose2.androidx.lifecycle.autoDispose
@ -33,24 +38,34 @@ 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.QuickReplyEvent
import com.keylesspalace.tusky.appstore.StatusComposedEvent
import com.keylesspalace.tusky.components.compose.CAN_USE_QUOTE_ID
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.*
import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.util.*
import com.keylesspalace.tusky.view.EndlessOnScrollListener
import com.keylesspalace.tusky.util.CardViewMode
import com.keylesspalace.tusky.util.ListStatusAccessibilityDelegate
import com.keylesspalace.tusky.util.StatusDisplayOptions
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.viewBinding
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
@ -72,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 ||
@ -116,11 +139,6 @@ class TimelineFragment :
isStreamingEnabled
)
viewModel.viewUpdates
.observeOn(AndroidSchedulers.mainThread())
.autoDispose(this)
.subscribe { this.updateViews() }
isSwipeToRefreshEnabled = arguments.getBoolean(ARG_ENABLE_SWIPE_TO_REFRESH, true)
val preferences = PreferenceManager.getDefaultSharedPreferences(activity)
@ -141,8 +159,7 @@ class TimelineFragment :
animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false),
quoteEnabled = CAN_USE_QUOTE_ID.contains(accountManager.activeAccount?.domain),
)
adapter = TimelineAdapter(
dataSource,
adapter = TimelinePagingAdapter(
statusDisplayOptions,
this
)
@ -159,8 +176,65 @@ 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 (binding.recyclerView.canScrollVertically(-1) || positionStart != 0) {
return
}
if (itemCount == 1) {
binding.recyclerView.scrollToPosition(0)
return
}
if (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() {
@ -171,7 +245,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)
@ -184,24 +260,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) {
@ -215,20 +283,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) {
@ -240,6 +297,10 @@ class TimelineFragment :
is PreferenceChangedEvent -> {
onPreferenceChanged(event.preferenceKey)
}
is StatusComposedEvent -> {
val status = event.status
handleStatusComposeEvent(status)
}
}
}
eventRegistered = true
@ -249,7 +310,7 @@ class TimelineFragment :
override fun onStart() {
super.onStart()
viewModel.firstOfStreaming = true
viewModel.isFirstOfStreaming = true
}
fun toggleStreaming(): Boolean =
@ -258,15 +319,14 @@ 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
if (viewModel.shouldReplyInQuick()) {
val status = adapter.peek(position)?.asStatusOrNull() ?: return
if (viewModel.shouldReplyInQuick) {
eventHub.dispatch(QuickReplyEvent(status.status))
} else {
super.reply(status.status)
@ -274,68 +334,74 @@ class TimelineFragment :
}
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 onQuote(position: Int) {
val status = viewModel.statuses[position].asStatusOrNull() ?: return
val status = adapter.peek(position)?.asStatusOrNull() ?: return
super.quote(status.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<Int>) {
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),
@ -344,7 +410,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)
}
@ -383,19 +449,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 {
@ -405,91 +484,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 && layoutManager?.findFirstVisibleItemPosition() == 0 && adapter.itemCount != count) {
if (count == 1) {
layoutManager?.scrollToPosition(0)
binding.recyclerView.stopScroll()
scrollListener?.reset()
}
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<StatusViewData> =
object : TimelineAdapter.AdapterDataSource<StatusViewData> {
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() {
@ -518,7 +512,9 @@ class TimelineFragment :
Observable.interval(1, TimeUnit.MINUTES)
.observeOn(AndroidSchedulers.mainThread())
.autoDispose(this, Lifecycle.Event.ON_PAUSE)
.subscribe { updateViews() }
.subscribe {
adapter.notifyDataSetChanged()
}
}
}
@ -526,12 +522,11 @@ class TimelineFragment :
if (isAdded) {
layoutManager!!.scrollToPosition(0)
binding.recyclerView.stopScroll()
scrollListener!!.reset()
}
}
override fun onReset() {
viewModel.fullyRefresh()
viewModel.fullReload()
}
override fun refreshContent() {
@ -572,33 +567,5 @@ class TimelineFragment :
fragment.arguments = arguments
return fragment
}
private val diffCallback: DiffUtil.ItemCallback<StatusViewData> =
object : DiffUtil.ItemCallback<StatusViewData>() {
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
}
}
}
}

View File

@ -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 <http://www.gnu.org/licenses>. */
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<StatusViewData, RecyclerView.ViewHolder>(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<StatusViewData>() {
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
}
}
}
}

View File

@ -1,464 +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<Placeholder, Status>
enum class TimelineRequestMode {
DISK, NETWORK, ANY
}
interface TimelineRepository {
fun getStatuses(
maxId: String?,
sinceId: String?,
sincedIdMinusOne: String?,
limit: Int,
requestMode: TimelineRequestMode
): Single<out List<TimelineStatus>>
fun addStatuses(
statuses: List<TimelineStatus>,
)
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<out List<TimelineStatus>> {
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)
}
}
override fun addStatuses(statuses: List<TimelineStatus>) {
val acc = accountManager.activeAccount ?: throw IllegalStateException()
val accountId = acc.id
statuses.forEach { either ->
when {
either.isRight() -> {
val status = either.asRight()
timelineDao.insertInTransaction(
status.toEntity(accountId, gson),
status.account.toEntity(accountId, gson),
status.reblog?.account?.toEntity(accountId, gson),
)
}
either.isLeft() -> {
val placeholder = either.asLeft()
timelineDao.insertStatusIfNotThere(placeholder.toEntity(accountId))
}
}
}
}
private fun getStatusesFromNetwork(
maxId: String?,
sinceId: String?,
sinceIdMinusOne: String?,
limit: Int,
accountId: Long,
requestMode: TimelineRequestMode
): Single<out List<TimelineStatus>> {
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<Either<Placeholder, Status>>,
maxId: String?,
sinceId: String?,
limit: Int,
requestMode: TimelineRequestMode
): Single<List<TimelineStatus>> {
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<out List<TimelineStatus>> {
return timelineDao.getStatusesForAccount(accountId, maxId, sinceId, limit)
.subscribeOn(Schedulers.io())
.map { statuses ->
statuses.map { it.toStatus() }
}
}
private fun saveStatusesToDb(
accountId: Long,
statuses: List<Status>,
maxId: String?,
sinceId: String?
): List<Either<Placeholder, Status>> {
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<Attachment> = gson.fromJson(
status.attachments,
object : TypeToken<List<Attachment>>() {}.type
) ?: ArrayList()
val mentions: List<Status.Mention> = gson.fromJson(
status.mentions,
object : TypeToken<List<Status.Mention>>() {}.type
) ?: listOf()
val application = gson.fromJson(status.application, Status.Application::class.java)
val emojis: List<Emoji> = gson.fromJson(
status.emojis,
object : TypeToken<List<Emoji>>() {}.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,
quote = 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,
quote = 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,
quote = null
)
}
return Either.Right(status)
}
}
private val emojisListTypeToken = object : TypeToken<List<Emoji>>() {}
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<Placeholder, Status> = Either.Right(this)

View File

@ -0,0 +1,259 @@
/* 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 <http://www.gnu.org/licenses>. */
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<ArrayList<Attachment>>() {}.type
private val emojisListType = object : TypeToken<List<Emoji>>() {}.type
private val mentionListType = object : TypeToken<List<Status.Mention>>() {}.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<Attachment> = gson.fromJson(status.attachments, attachmentArrayListType) ?: arrayListOf()
val mentions: List<Status.Mention> = gson.fromJson(status.mentions, mentionListType) ?: emptyList()
val application = gson.fromJson(status.application, Status.Application::class.java)
val emojis: List<Emoji> = 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,
quote = 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,
quote = 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,
quote = null,
)
}
return StatusViewData.Concrete(
status = status,
isExpanded = this.status.expanded,
isShowingContent = this.status.contentShowing,
isCollapsible = shouldTrimStatus(status.content),
isCollapsed = this.status.contentCollapsed
)
}

View File

@ -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 <http://www.gnu.org/licenses>. */
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<Int, TimelineStatusWithAccount>() {
private var initialRefresh = false
private val timelineDao = db.timelineDao()
private val activeAccount = accountManager.activeAccount!!
override suspend fun load(
loadType: LoadType,
state: PagingState<Int, TimelineStatusWithAccount>
): 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<Status>, state: PagingState<Int, TimelineStatusWithAccount>): 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
}
}

View File

@ -0,0 +1,239 @@
/* 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 <http://www.gnu.org/licenses>. */
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.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.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 net.accelf.yuito.streaming.StreamingManager
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,
streamingManager: StreamingManager,
) : TimelineViewModel(timelineCases, api, eventHub, accountManager, sharedPreferences, filterModel, streamingManager) {
@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 handleStreamUpdateEvent(status: Status) {
viewModelScope.launch {
val timelineDao = db.timelineDao()
val activeAccount = accountManager.activeAccount!!
db.withTransaction {
if (isFirstOfStreaming) {
val placeholderId = (timelineDao.getTopId(activeAccount.id) ?: "0").inc()
timelineDao.insertStatus(Placeholder(placeholderId, loading = false).toEntity(activeAccount.id))
isFirstOfStreaming = false
}
timelineDao.insertAccount(status.account.toEntity(activeAccount.id, gson))
status.reblog?.account?.toEntity(activeAccount.id, gson)?.let { rebloggedAccount ->
timelineDao.insertAccount(rebloggedAccount)
}
timelineDao.insertStatus(
status.toEntity(activeAccount.id,
gson,
expanded = activeAccount.alwaysOpenSpoiler,
contentShowing = activeAccount.alwaysShowSensitiveMedia || !status.actionableStatus.sensitive,
contentCollapsed = true
)
)
}
}
}
override fun fullReload() {
viewModelScope.launch {
val activeAccount = accountManager.activeAccount!!
db.runInTransaction {
db.timelineDao().removeAllForAccount(activeAccount.id)
db.timelineDao().removeAllUsersForAccount(activeAccount.id)
}
}
}
}

View File

@ -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 <http://www.gnu.org/licenses>. */
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<String, StatusViewData>() {
override fun getRefreshKey(state: PagingState<String, StatusViewData>): String? = null
override suspend fun load(params: LoadParams<String>): LoadResult<String, StatusViewData> {
return if (params is LoadParams.Refresh) {
val list = viewModel.statusData.toList()
LoadResult.Page(list, null, viewModel.nextKey)
} else {
LoadResult.Page(emptyList(), null, null)
}
}
}

View File

@ -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 <http://www.gnu.org/licenses>. */
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<String, StatusViewData>() {
override suspend fun load(
loadType: LoadType,
state: PagingState<String, StatusViewData>
): 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)
}
}
}

View File

@ -0,0 +1,329 @@
/* 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 <http://www.gnu.org/licenses>. */
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.components.timeline.Placeholder
import com.keylesspalace.tusky.components.timeline.toEntity
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 net.accelf.yuito.streaming.StreamingManager
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,
streamingManager: StreamingManager,
) : TimelineViewModel(timelineCases, api, eventHub, accountManager, sharedPreferences, filterModel, streamingManager) {
var currentSource: NetworkTimelinePagingSource? = null
val statusData: MutableList<StatusViewData> = 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 handleStreamUpdateEvent(status: Status) {
viewModelScope.launch {
val activeAccount = accountManager.activeAccount!!
if (isFirstOfStreaming) {
val placeholderId = when (val top = statusData.first()) {
is StatusViewData.Concrete -> top.id
is StatusViewData.Placeholder -> top.id
}
statusData.add(0, StatusViewData.Placeholder(placeholderId, isLoading = false))
isFirstOfStreaming = false
}
statusData.add(0, status.toViewData(
isShowingContent = activeAccount.alwaysShowSensitiveMedia,
isExpanded = activeAccount.alwaysOpenSpoiler,
isCollapsed = true,
))
currentSource?.invalidate()
}
}
override fun fullReload() {
statusData.clear()
currentSource?.invalidate()
}
suspend fun fetchStatusesForKind(
fromId: String?,
uptoId: String?,
limit: Int
): Response<List<Status>> {
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()
}
}

View File

@ -0,0 +1,369 @@
/* 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 <http://www.gnu.org/licenses>. */
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.*
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.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 net.accelf.yuito.streaming.StreamType
import net.accelf.yuito.streaming.StreamingManager
import net.accelf.yuito.streaming.Subscription
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,
private val streamingManager: StreamingManager,
) : ViewModel() {
abstract val statuses: Flow<PagingData<StatusViewData>>
var kind: Kind = Kind.HOME
private set
var id: String? = null
private set
var tags: List<String> = emptyList()
private set
protected var alwaysShowSensitiveMedia = false
protected var alwaysOpenSpoilers = false
private var filterRemoveReplies = false
private var filterRemoveReblogs = false
val shouldReplyInQuick by lazy {
when (kind) {
Kind.HOME,
Kind.PUBLIC_LOCAL,
Kind.PUBLIC_FEDERATED,
Kind.TAG,
Kind.FAVOURITES,
Kind.LIST,
-> true
Kind.BOOKMARKS,
Kind.USER,
Kind.USER_PINNED,
Kind.USER_WITH_REPLIES,
-> false
}
}
var isFirstOfStreaming = false
private val subscription by lazy {
when (kind) {
Kind.HOME -> Subscription(StreamType.USER)
Kind.PUBLIC_LOCAL -> Subscription(StreamType.LOCAL)
Kind.PUBLIC_FEDERATED -> Subscription(StreamType.PUBLIC)
Kind.LIST -> Subscription(StreamType.LIST, id?.toInt())
Kind.TAG,
Kind.USER,
Kind.USER_PINNED,
Kind.USER_WITH_REPLIES,
Kind.FAVOURITES,
Kind.BOOKMARKS,
-> {
throw NotImplementedError("streaming not implemented for this type")
}
}
}
var isStreamingEnabled = false
set(value) {
if (field != value) {
field = value
when (value) {
true -> {
streamingManager.subscribe(subscription)
isFirstOfStreaming = true
}
false -> {
streamingManager.unsubscribe(subscription)
}
}
}
}
fun init(
kind: Kind,
id: String?,
tags: List<String>,
isStreamingEnabled: Boolean,
) {
this.kind = kind
this.id = id
this.tags = tags
this.isStreamingEnabled = isStreamingEnabled
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<Int>, 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 handleStreamUpdateEvent(status: Status)
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<String>,
): 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 StreamUpdateEvent -> {
if (isStreamingEnabled && event.subscription == subscription) {
handleStreamUpdateEvent(event.status)
}
}
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
}
}

View File

@ -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`)");
}
};
}

View File

@ -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 <http://www.gnu.org/licenses>. */
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<List<TimelineStatusWithAccount>>
@Transaction
open fun insertInTransaction(
status: TimelineStatusEntity,
account: TimelineAccountEntity,
reblogAccount: TimelineAccountEntity?
) {
insertAccount(account)
reblogAccount?.let(this::insertAccount)
insertStatus(status)
}
abstract fun getStatusesForAccount(account: Long): PagingSource<Int, TimelineStatusWithAccount>
@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?
}

View File

@ -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 <http://www.gnu.org/licenses>. */
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(

View File

@ -35,7 +35,6 @@ import javax.inject.Singleton
ServicesModule::class,
BroadcastReceiverModule::class,
ViewModelModule::class,
RepositoryModule::class,
MediaUploaderModule::class
]
)

View File

@ -91,8 +91,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()
}

View File

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

View File

@ -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
@ -100,8 +101,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
@Binds
@IntoMap

View File

@ -191,7 +191,8 @@ public class NotificationsFragment extends SFragment implements
return ViewDataUtils.notificationToViewData(
notification,
alwaysShowSensitiveMedia,
alwaysOpenSpoiler
alwaysOpenSpoiler,
true
);
} else {
return new NotificationViewData.Placeholder(input.asLeft().id, false);

View File

@ -327,7 +327,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: {

View File

@ -111,7 +111,8 @@ public final class ViewThreadFragment extends SFragment implements
return ViewDataUtils.statusToViewData(
input,
alwaysShowSensitiveMedia,
alwaysOpenSpoiler
alwaysOpenSpoiler,
true
);
}
});

View File

@ -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<Response<List<Status>>>
@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<Response<List<Status>>>
@GET("api/v1/timelines/tag/{hashtag}")

View File

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

View File

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

View File

@ -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<PagingSource.LoadResult.Page<Int, TimelineStatusWithAccount>> = emptyList()) = PagingState(
pages = pages,
anchorPosition = null,
config = PagingConfig(
pageSize = 20
),
leadingPlaceholderCount = 0
)
private fun AppDatabase.insert(statuses: List<TimelineStatusWithAccount>) {
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<TimelineStatusWithAccount>,
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)
}
}
}
}

View File

@ -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<String>(null, 20, false)
val expectedResult = PagingSource.LoadResult.Page(listOf(status), null, null)
runBlocking {
val result = pagingSource.load(params)
assertEquals(expectedResult, result)
}
}
}

View File

@ -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<StatusViewData> = 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<StatusViewData> = 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<StatusViewData> = 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<StatusViewData> = 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<PagingSource.LoadResult.Page<String, StatusViewData>> = emptyList()) = PagingState(
pages = pages,
anchorPosition = null,
config = PagingConfig(
pageSize = 20
),
leadingPlaceholderCount = 0
)
}

View File

@ -0,0 +1,80 @@
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,
quote = 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
)
}
}

View File

@ -1,356 +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,
quote = null
)
}

View File

@ -1,790 +1,217 @@
package com.keylesspalace.tusky.components.timeline
import android.content.SharedPreferences
import android.net.ConnectivityManager
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.*
import io.reactivex.rxjava3.annotations.NonNull
import io.reactivex.rxjava3.core.Observable
import com.keylesspalace.tusky.network.TimelineCasesImpl
import com.nhaarman.mockitokotlin2.doReturn
import com.nhaarman.mockitokotlin2.mock
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 net.accelf.yuito.streaming.StreamingManager
import org.junit.Assert.*
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.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
lateinit var connectivityManager: ConnectivityManager
lateinit var streamingManager: StreamingManager
@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()
connectivityManager = mock()
streamingManager = mock()
viewModel = TimelineViewModel(
timelineRepository,
timelineCases,
mastodonApi,
eventHub,
accountManager,
sharedPreference,
FilterModel(),
connectivityManager,
streamingManager,
)
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<Status>()
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 = "1"
viewModel.init(TimelineViewModel.Kind.LIST, listId, listOf(), false)
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", "<https://mastodon.examples/api/v1/favourites?limit=30&max_id=1>; rel=\"next\", <https://mastodon.example/api/v1/favourites?limit=30&min_id=5>; 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(),
mock(),
)
val updates = viewModel.viewUpdates.test()
viewModel.init(TimelineViewModel.Kind.PUBLIC_LOCAL, null, emptyList(), false)
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<List<TimelineStatus>>()
// 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<TimelineStatus> = listOf(
Either.Right(status5),
Either.Left(Placeholder("4")),
Either.Right(status1)
)
val laterFetchedStatuses = listOf<TimelineStatus>(
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<TimelineStatus> = 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<TimelineStatus>
) {
whenever(
timelineRepository.getStatuses(
null,
above,
aboveMinusOne,
LOAD_AT_ONCE,
TimelineRequestMode.NETWORK
)
).thenReturn(Single.just(items))
}
private fun assertHasList(aList: List<StatusViewData>) {
assertEquals(
aList,
viewModel.statuses.toList()
listOf(
mockStatusViewData("6"),
mockStatusViewData("5"),
mockStatusViewData("4")
),
differ.snapshot().items
)
}
private fun assertViewUpdated(updates: @NonNull TestObserver<Unit>) {
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<Status>) {
setInitialRefreshWithGaps(maxId, statuses.toEitherList())
}
private fun setCachedResponse(initialResponse: List<Status>) {
setCachedResponseWithGaps(initialResponse.toEitherList())
}
private fun setCachedResponseWithGaps(initialResponse: List<TimelineStatus>) {
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<TimelineStatus>) {
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<Status>.toViewData(): List<StatusViewData> = 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(),
mock(),
)
viewModel.init(TimelineViewModel.Kind.HOME, null, emptyList(), false)
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<Status>.toEitherList() = map { Either.Right<Placeholder, Status>(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) {}
}

View File

@ -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<Int> = 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<Int> = 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<TimelineStatusEntity, TimelineAccountEntity, TimelineAccountEntity?> {
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<Triple<TimelineStatusEntity, TimelineAccountEntity, TimelineAccountEntity?>>,
provided: List<TimelineStatusWithAccount>
) {
for ((exp, prov) in expected.zip(provided)) {
val (status, author, reblogger) = exp
assertEquals(status, prov.status)
assertEquals(author, prov.account)
assertEquals(reblogger, prov.reblogAccount)
}
}
}