Timeline paging (#2238)
* first setup * network timeline paging / improvements * rename classes / move to correct package * remove unused class TimelineAdapter * some code cleanup * remove TimelineRepository, put mapper functions in TimelineTypeMappers.kt * add db migration * cleanup unused code * bugfix * make default timeline settings work again * fix pinning statuses from timeline * fix network timeline * respect account settings in NetworkTimelineRemoteMediator * respect account settings in NetworkTimelineRemoteMediator * update license headers * show error view when an error occurs * cleanup some todos * fix db migration * fix changing mediaPreviewEnabled setting * fix "load more" button appearing on top of timeline * fix filtering and other bugs * cleanup cache after 14 days * fix TimelineDAOTest * fix code formatting * add NetworkTimeline unit tests * add CachedTimeline unit tests * fix code formatting * move TimelineDaoTest to unit tests * implement removeAllByInstance for CachedTimelineViewModel * fix code formatting * fix bug in TimelineDao.deleteAllFromInstance * improve loading more statuses in NetworkTimelineViewModel * improve loading more statuses in NetworkTimelineViewModel * fix bug where empty state was shown too soon * reload top of cached timeline on app start * improve CachedTimelineRemoteMediator and Tests * improve cached timeline tests * fix some more todos * implement TimelineFragment.removeItem * fix ListStatusAccessibilityDelegate * fix crash in NetworkTimelineViewModel.loadMore * fix default state of collapsible statuses * fix default state of collapsible statuses -tests * fix showing/hiding media in the timeline * get rid of some not-null assertion operators in TimelineTypeMappers * fix tests * error handling in CachedTimelineViewModel.loadMore * keep local status state when refreshing cached statuses * keep local status state when refreshing network timeline statuses * show placeholder loading state in cached timeline * better comments, some code cleanup * add TimelineViewModelTest, improve code, fix bug * fix ktlint * fix voting in boosted polls * code improvement
This commit is contained in:
parent
224161caf1
commit
643e012b11
41 changed files with 4019 additions and 3146 deletions
|
@ -175,4 +175,6 @@ dependencies {
|
|||
androidTestImplementation "androidx.room:room-testing:$roomVersion"
|
||||
androidTestImplementation "androidx.test.ext:junit:1.1.2"
|
||||
testImplementation "androidx.arch.core:core-testing:2.1.0"
|
||||
|
||||
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.5.0'
|
||||
}
|
||||
|
|
777
app/schemas/com.keylesspalace.tusky.db.AppDatabase/28.json
Normal file
777
app/schemas/com.keylesspalace.tusky.db.AppDatabase/28.json
Normal 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')"
|
||||
]
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -5,7 +5,7 @@ import android.content.Intent
|
|||
import android.os.Bundle
|
||||
import com.google.android.material.floatingactionbutton.FloatingActionButton
|
||||
import com.keylesspalace.tusky.components.timeline.TimelineFragment
|
||||
import com.keylesspalace.tusky.components.timeline.TimelineViewModel
|
||||
import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel
|
||||
import com.keylesspalace.tusky.databinding.ActivityModalTimelineBinding
|
||||
import com.keylesspalace.tusky.interfaces.ActionButtonActivity
|
||||
import dagger.android.DispatchingAndroidInjector
|
||||
|
|
|
@ -20,7 +20,7 @@ import android.content.Intent
|
|||
import android.os.Bundle
|
||||
import androidx.fragment.app.commit
|
||||
import com.keylesspalace.tusky.components.timeline.TimelineFragment
|
||||
import com.keylesspalace.tusky.components.timeline.TimelineViewModel.Kind
|
||||
import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel.Kind
|
||||
import com.keylesspalace.tusky.databinding.ActivityStatuslistBinding
|
||||
import dagger.android.DispatchingAndroidInjector
|
||||
import dagger.android.HasAndroidInjector
|
||||
|
|
|
@ -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 */
|
||||
|
|
|
@ -6,6 +6,7 @@ import com.keylesspalace.tusky.db.AppDatabase
|
|||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.disposables.Disposable
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Inject
|
||||
|
||||
class CacheUpdater @Inject constructor(
|
||||
|
@ -19,6 +20,12 @@ class CacheUpdater @Inject constructor(
|
|||
|
||||
init {
|
||||
val timelineDao = appDatabase.timelineDao()
|
||||
|
||||
Schedulers.io().scheduleDirect {
|
||||
val olderThan = System.currentTimeMillis() - CLEANUP_INTERVAL
|
||||
appDatabase.timelineDao().cleanup(olderThan)
|
||||
}
|
||||
|
||||
disposable = eventHub.events.subscribe { event ->
|
||||
val accountId = accountManager.activeAccount?.id ?: return@subscribe
|
||||
when (event) {
|
||||
|
@ -36,6 +43,8 @@ class CacheUpdater @Inject constructor(
|
|||
val pollString = gson.toJson(event.poll)
|
||||
timelineDao.setVoted(accountId, event.statusId, pollString)
|
||||
}
|
||||
is PinEvent ->
|
||||
timelineDao.setPinned(accountId, event.statusId, event.pinned)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -52,4 +61,8 @@ class CacheUpdater @Inject constructor(
|
|||
.subscribeOn(Schedulers.io())
|
||||
.subscribe()
|
||||
}
|
||||
|
||||
companion object {
|
||||
val CLEANUP_INTERVAL = TimeUnit.DAYS.toMillis(14)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -56,7 +56,7 @@ class SearchViewModel @Inject constructor(
|
|||
private val loadedStatuses: 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)
|
||||
}
|
||||
|
|
|
@ -1,138 +0,0 @@
|
|||
/* Copyright 2017 Andrew Dawson
|
||||
*
|
||||
* This file is a part of Tusky.
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it under the terms of the
|
||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
|
||||
* Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
||||
* see <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()
|
||||
);
|
||||
}
|
||||
|
||||
@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();
|
||||
}
|
||||
}
|
|
@ -22,15 +22,13 @@ import android.view.View
|
|||
import android.view.ViewGroup
|
||||
import android.view.accessibility.AccessibilityManager
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.paging.LoadState
|
||||
import androidx.preference.PreferenceManager
|
||||
import androidx.recyclerview.widget.AsyncDifferConfig
|
||||
import androidx.recyclerview.widget.AsyncListDiffer
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.DividerItemDecoration
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.ListUpdateCallback
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.recyclerview.widget.SimpleItemAnimator
|
||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener
|
||||
|
@ -40,13 +38,17 @@ import com.keylesspalace.tusky.AccountListActivity
|
|||
import com.keylesspalace.tusky.AccountListActivity.Companion.newIntent
|
||||
import com.keylesspalace.tusky.BaseActivity
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.adapter.StatusBaseViewHolder
|
||||
import com.keylesspalace.tusky.appstore.EventHub
|
||||
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
|
||||
import com.keylesspalace.tusky.appstore.StatusComposedEvent
|
||||
import com.keylesspalace.tusky.components.timeline.viewmodel.CachedTimelineViewModel
|
||||
import com.keylesspalace.tusky.components.timeline.viewmodel.NetworkTimelineViewModel
|
||||
import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel
|
||||
import com.keylesspalace.tusky.databinding.FragmentTimelineBinding
|
||||
import com.keylesspalace.tusky.db.AccountManager
|
||||
import com.keylesspalace.tusky.di.Injectable
|
||||
import com.keylesspalace.tusky.di.ViewModelFactory
|
||||
import com.keylesspalace.tusky.entity.Status
|
||||
import com.keylesspalace.tusky.fragment.SFragment
|
||||
import com.keylesspalace.tusky.interfaces.ActionButtonActivity
|
||||
import com.keylesspalace.tusky.interfaces.RefreshableFragment
|
||||
|
@ -59,12 +61,12 @@ import com.keylesspalace.tusky.util.StatusDisplayOptions
|
|||
import com.keylesspalace.tusky.util.hide
|
||||
import com.keylesspalace.tusky.util.show
|
||||
import com.keylesspalace.tusky.util.viewBinding
|
||||
import com.keylesspalace.tusky.util.visible
|
||||
import com.keylesspalace.tusky.view.EndlessOnScrollListener
|
||||
import com.keylesspalace.tusky.viewdata.AttachmentViewData
|
||||
import com.keylesspalace.tusky.viewdata.StatusViewData
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.core.Observable
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Inject
|
||||
|
||||
|
@ -85,25 +87,33 @@ class TimelineFragment :
|
|||
@Inject
|
||||
lateinit var accountManager: AccountManager
|
||||
|
||||
private val viewModel: TimelineViewModel by viewModels { viewModelFactory }
|
||||
private val viewModel: TimelineViewModel by lazy {
|
||||
if (kind == TimelineViewModel.Kind.HOME) {
|
||||
ViewModelProvider(this, viewModelFactory).get(CachedTimelineViewModel::class.java)
|
||||
} else {
|
||||
ViewModelProvider(this, viewModelFactory).get(NetworkTimelineViewModel::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
private val binding by viewBinding(FragmentTimelineBinding::bind)
|
||||
|
||||
private lateinit var adapter: TimelineAdapter
|
||||
private lateinit var kind: TimelineViewModel.Kind
|
||||
|
||||
private lateinit var adapter: TimelinePagingAdapter
|
||||
|
||||
private var isSwipeToRefreshEnabled = true
|
||||
|
||||
private var eventRegistered = false
|
||||
|
||||
private var layoutManager: LinearLayoutManager? = null
|
||||
private var scrollListener: EndlessOnScrollListener? = null
|
||||
private var scrollListener: RecyclerView.OnScrollListener? = null
|
||||
private var hideFab = false
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
val arguments = requireArguments()
|
||||
val kind = TimelineViewModel.Kind.valueOf(arguments.getString(KIND_ARG)!!)
|
||||
kind = TimelineViewModel.Kind.valueOf(arguments.getString(KIND_ARG)!!)
|
||||
val id: String? = if (kind == TimelineViewModel.Kind.USER ||
|
||||
kind == TimelineViewModel.Kind.USER_PINNED ||
|
||||
kind == TimelineViewModel.Kind.USER_WITH_REPLIES ||
|
||||
|
@ -125,11 +135,6 @@ class TimelineFragment :
|
|||
tags,
|
||||
)
|
||||
|
||||
viewModel.viewUpdates
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.autoDispose(this)
|
||||
.subscribe { this.updateViews() }
|
||||
|
||||
isSwipeToRefreshEnabled = arguments.getBoolean(ARG_ENABLE_SWIPE_TO_REFRESH, true)
|
||||
|
||||
val preferences = PreferenceManager.getDefaultSharedPreferences(activity)
|
||||
|
@ -149,8 +154,7 @@ class TimelineFragment :
|
|||
hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false),
|
||||
animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false)
|
||||
)
|
||||
adapter = TimelineAdapter(
|
||||
dataSource,
|
||||
adapter = TimelinePagingAdapter(
|
||||
statusDisplayOptions,
|
||||
this
|
||||
)
|
||||
|
@ -167,8 +171,56 @@ class TimelineFragment :
|
|||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
setupSwipeRefreshLayout()
|
||||
setupRecyclerView()
|
||||
updateViews()
|
||||
viewModel.loadInitial()
|
||||
|
||||
adapter.addLoadStateListener { loadState ->
|
||||
if (loadState.refresh != LoadState.Loading) {
|
||||
binding.swipeRefreshLayout.isRefreshing = false
|
||||
}
|
||||
|
||||
binding.statusView.hide()
|
||||
binding.progressBar.hide()
|
||||
|
||||
if (adapter.itemCount == 0) {
|
||||
when (loadState.refresh) {
|
||||
is LoadState.NotLoading -> {
|
||||
if (loadState.append is LoadState.NotLoading) {
|
||||
binding.statusView.show()
|
||||
binding.statusView.setup(R.drawable.elephant_friend_empty, R.string.message_empty, null)
|
||||
}
|
||||
}
|
||||
is LoadState.Error -> {
|
||||
binding.statusView.show()
|
||||
|
||||
if ((loadState.refresh as LoadState.Error).error is IOException) {
|
||||
binding.statusView.setup(R.drawable.elephant_offline, R.string.error_network, null)
|
||||
} else {
|
||||
binding.statusView.setup(R.drawable.elephant_error, R.string.error_generic, null)
|
||||
}
|
||||
}
|
||||
is LoadState.Loading -> {
|
||||
binding.progressBar.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
adapter.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() {
|
||||
override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
|
||||
if (positionStart == 0 && adapter.itemCount != itemCount) {
|
||||
binding.recyclerView.post {
|
||||
if (isSwipeToRefreshEnabled) {
|
||||
binding.recyclerView.scrollBy(0, Utils.dpToPx(requireContext(), -30))
|
||||
} else binding.recyclerView.scrollToPosition(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
lifecycleScope.launch {
|
||||
viewModel.statuses.collectLatest { pagingData ->
|
||||
adapter.submitData(pagingData)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupSwipeRefreshLayout() {
|
||||
|
@ -179,7 +231,9 @@ class TimelineFragment :
|
|||
|
||||
private fun setupRecyclerView() {
|
||||
binding.recyclerView.setAccessibilityDelegateCompat(
|
||||
ListStatusAccessibilityDelegate(binding.recyclerView, this) { pos -> viewModel.statuses.getOrNull(pos) }
|
||||
ListStatusAccessibilityDelegate(binding.recyclerView, this) { pos ->
|
||||
adapter.peek(pos)
|
||||
}
|
||||
)
|
||||
binding.recyclerView.setHasFixedSize(true)
|
||||
layoutManager = LinearLayoutManager(context)
|
||||
|
@ -192,24 +246,16 @@ class TimelineFragment :
|
|||
binding.recyclerView.adapter = adapter
|
||||
}
|
||||
|
||||
private fun showEmptyView() {
|
||||
binding.statusView.show()
|
||||
binding.statusView.setup(R.drawable.elephant_friend_empty, R.string.message_empty, null)
|
||||
}
|
||||
|
||||
override fun onActivityCreated(savedInstanceState: Bundle?) {
|
||||
super.onActivityCreated(savedInstanceState)
|
||||
|
||||
/* This is delayed until onActivityCreated solely because MainActivity.composeButton isn't
|
||||
* guaranteed to be set until then. */
|
||||
scrollListener = if (actionButtonPresent()) {
|
||||
/* Use a modified scroll listener that both loads more statuses as it goes, and hides
|
||||
* the follow button on down-scroll. */
|
||||
if (actionButtonPresent()) {
|
||||
val preferences = PreferenceManager.getDefaultSharedPreferences(context)
|
||||
hideFab = preferences.getBoolean("fabHide", false)
|
||||
object : EndlessOnScrollListener(layoutManager) {
|
||||
scrollListener = object : RecyclerView.OnScrollListener() {
|
||||
override fun onScrolled(view: RecyclerView, dx: Int, dy: Int) {
|
||||
super.onScrolled(view, dx, dy)
|
||||
val composeButton = (activity as ActionButtonActivity).actionButton
|
||||
if (composeButton != null) {
|
||||
if (hideFab) {
|
||||
|
@ -223,20 +269,9 @@ class TimelineFragment :
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onLoadMore(totalItemsCount: Int, view: RecyclerView) {
|
||||
this@TimelineFragment.onLoadMore()
|
||||
}
|
||||
}.also {
|
||||
binding.recyclerView.addOnScrollListener(it)
|
||||
}
|
||||
} else {
|
||||
// Just use the basic scroll listener to load more statuses.
|
||||
object : EndlessOnScrollListener(layoutManager) {
|
||||
override fun onLoadMore(totalItemsCount: Int, view: RecyclerView) {
|
||||
this@TimelineFragment.onLoadMore()
|
||||
}
|
||||
}
|
||||
}.also {
|
||||
binding.recyclerView.addOnScrollListener(it)
|
||||
}
|
||||
|
||||
if (!eventRegistered) {
|
||||
|
@ -248,6 +283,10 @@ class TimelineFragment :
|
|||
is PreferenceChangedEvent -> {
|
||||
onPreferenceChanged(event.preferenceKey)
|
||||
}
|
||||
is StatusComposedEvent -> {
|
||||
val status = event.status
|
||||
handleStatusComposeEvent(status)
|
||||
}
|
||||
}
|
||||
}
|
||||
eventRegistered = true
|
||||
|
@ -255,75 +294,80 @@ class TimelineFragment :
|
|||
}
|
||||
|
||||
override fun onRefresh() {
|
||||
binding.swipeRefreshLayout.isEnabled = isSwipeToRefreshEnabled
|
||||
binding.statusView.hide()
|
||||
|
||||
viewModel.refresh()
|
||||
adapter.refresh()
|
||||
}
|
||||
|
||||
override fun onReply(position: Int) {
|
||||
val status = viewModel.statuses[position].asStatusOrNull() ?: return
|
||||
val status = adapter.peek(position)?.asStatusOrNull() ?: return
|
||||
super.reply(status.status)
|
||||
}
|
||||
|
||||
override fun onReblog(reblog: Boolean, position: Int) {
|
||||
viewModel.reblog(reblog, position)
|
||||
val status = adapter.peek(position)?.asStatusOrNull() ?: return
|
||||
viewModel.reblog(reblog, status)
|
||||
}
|
||||
|
||||
override fun onFavourite(favourite: Boolean, position: Int) {
|
||||
viewModel.favorite(favourite, position)
|
||||
val status = adapter.peek(position)?.asStatusOrNull() ?: return
|
||||
viewModel.favorite(favourite, status)
|
||||
}
|
||||
|
||||
override fun onBookmark(bookmark: Boolean, position: Int) {
|
||||
viewModel.bookmark(bookmark, position)
|
||||
val status = adapter.peek(position)?.asStatusOrNull() ?: return
|
||||
viewModel.bookmark(bookmark, status)
|
||||
}
|
||||
|
||||
override fun onVoteInPoll(position: Int, choices: List<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),
|
||||
|
@ -332,7 +376,7 @@ class TimelineFragment :
|
|||
}
|
||||
|
||||
override fun onViewThread(position: Int) {
|
||||
val status = viewModel.statuses[position].asStatusOrNull() ?: return
|
||||
val status = adapter.peek(position)?.asStatusOrNull() ?: return
|
||||
super.viewThread(status.actionable.id, status.actionable.url)
|
||||
}
|
||||
|
||||
|
@ -371,19 +415,32 @@ class TimelineFragment :
|
|||
val oldMediaPreviewEnabled = adapter.mediaPreviewEnabled
|
||||
if (enabled != oldMediaPreviewEnabled) {
|
||||
adapter.mediaPreviewEnabled = enabled
|
||||
updateViews()
|
||||
adapter.notifyDataSetChanged()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public override fun removeItem(position: Int) {
|
||||
viewModel.statuses.removeAt(position)
|
||||
updateViews()
|
||||
private fun handleStatusComposeEvent(status: Status) {
|
||||
when (kind) {
|
||||
TimelineViewModel.Kind.HOME,
|
||||
TimelineViewModel.Kind.PUBLIC_FEDERATED,
|
||||
TimelineViewModel.Kind.PUBLIC_LOCAL -> adapter.refresh()
|
||||
TimelineViewModel.Kind.USER,
|
||||
TimelineViewModel.Kind.USER_WITH_REPLIES -> if (status.account.id == viewModel.id) {
|
||||
adapter.refresh()
|
||||
}
|
||||
TimelineViewModel.Kind.TAG,
|
||||
TimelineViewModel.Kind.FAVOURITES,
|
||||
TimelineViewModel.Kind.LIST,
|
||||
TimelineViewModel.Kind.BOOKMARKS,
|
||||
TimelineViewModel.Kind.USER_PINNED -> return
|
||||
}
|
||||
}
|
||||
|
||||
private fun onLoadMore() {
|
||||
viewModel.loadMore()
|
||||
public override fun removeItem(position: Int) {
|
||||
val status = adapter.peek(position)?.asStatusOrNull() ?: return
|
||||
viewModel.removeStatusWithId(status.id)
|
||||
}
|
||||
|
||||
private fun actionButtonPresent(): Boolean {
|
||||
|
@ -393,86 +450,6 @@ class TimelineFragment :
|
|||
activity is ActionButtonActivity
|
||||
}
|
||||
|
||||
private fun updateViews() {
|
||||
differ.submitList(viewModel.statuses.toList())
|
||||
binding.swipeRefreshLayout.isEnabled = viewModel.failure == null
|
||||
|
||||
if (isAdded) {
|
||||
binding.swipeRefreshLayout.isRefreshing = viewModel.isRefreshing
|
||||
binding.progressBar.visible(viewModel.isLoadingInitially)
|
||||
if (viewModel.failure == null && viewModel.statuses.isEmpty() && !viewModel.isLoadingInitially) {
|
||||
showEmptyView()
|
||||
} else {
|
||||
when (viewModel.failure) {
|
||||
TimelineViewModel.FailureReason.NETWORK -> {
|
||||
binding.statusView.show()
|
||||
binding.statusView.setup(
|
||||
R.drawable.elephant_offline,
|
||||
R.string.error_network
|
||||
) {
|
||||
binding.statusView.hide()
|
||||
viewModel.loadInitial()
|
||||
}
|
||||
}
|
||||
TimelineViewModel.FailureReason.OTHER -> {
|
||||
binding.statusView.show()
|
||||
binding.statusView.setup(
|
||||
R.drawable.elephant_error,
|
||||
R.string.error_generic
|
||||
) {
|
||||
binding.statusView.hide()
|
||||
viewModel.loadInitial()
|
||||
}
|
||||
}
|
||||
null -> binding.statusView.hide()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val listUpdateCallback: ListUpdateCallback = object : ListUpdateCallback {
|
||||
override fun onInserted(position: Int, count: Int) {
|
||||
if (isAdded) {
|
||||
adapter.notifyItemRangeInserted(position, count)
|
||||
val context = context
|
||||
// scroll up when new items at the top are loaded while being in the first position
|
||||
// https://github.com/tuskyapp/Tusky/pull/1905#issuecomment-677819724
|
||||
if (position == 0 && context != null && adapter.itemCount != count) {
|
||||
if (isSwipeToRefreshEnabled) {
|
||||
binding.recyclerView.scrollBy(0, Utils.dpToPx(context, -30))
|
||||
} else binding.recyclerView.scrollToPosition(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRemoved(position: Int, count: Int) {
|
||||
adapter.notifyItemRangeRemoved(position, count)
|
||||
}
|
||||
|
||||
override fun onMoved(fromPosition: Int, toPosition: Int) {
|
||||
adapter.notifyItemMoved(fromPosition, toPosition)
|
||||
}
|
||||
|
||||
override fun onChanged(position: Int, count: Int, payload: Any?) {
|
||||
adapter.notifyItemRangeChanged(position, count, payload)
|
||||
}
|
||||
}
|
||||
private val differ = AsyncListDiffer(
|
||||
listUpdateCallback,
|
||||
AsyncDifferConfig.Builder(diffCallback).build()
|
||||
)
|
||||
|
||||
private val dataSource: TimelineAdapter.AdapterDataSource<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() {
|
||||
|
@ -501,7 +478,9 @@ class TimelineFragment :
|
|||
Observable.interval(1, TimeUnit.MINUTES)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.autoDispose(this, Lifecycle.Event.ON_PAUSE)
|
||||
.subscribe { updateViews() }
|
||||
.subscribe {
|
||||
adapter.notifyDataSetChanged()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -509,7 +488,6 @@ class TimelineFragment :
|
|||
if (isAdded) {
|
||||
layoutManager!!.scrollToPosition(0)
|
||||
binding.recyclerView.stopScroll()
|
||||
scrollListener!!.reset()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -548,33 +526,5 @@ class TimelineFragment :
|
|||
fragment.arguments = arguments
|
||||
return fragment
|
||||
}
|
||||
|
||||
private val diffCallback: DiffUtil.ItemCallback<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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,435 +0,0 @@
|
|||
package com.keylesspalace.tusky.components.timeline
|
||||
|
||||
import android.text.SpannedString
|
||||
import androidx.core.text.parseAsHtml
|
||||
import androidx.core.text.toHtml
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import com.keylesspalace.tusky.components.timeline.TimelineRequestMode.DISK
|
||||
import com.keylesspalace.tusky.components.timeline.TimelineRequestMode.NETWORK
|
||||
import com.keylesspalace.tusky.db.AccountManager
|
||||
import com.keylesspalace.tusky.db.TimelineAccountEntity
|
||||
import com.keylesspalace.tusky.db.TimelineDao
|
||||
import com.keylesspalace.tusky.db.TimelineStatusEntity
|
||||
import com.keylesspalace.tusky.db.TimelineStatusWithAccount
|
||||
import com.keylesspalace.tusky.entity.Account
|
||||
import com.keylesspalace.tusky.entity.Attachment
|
||||
import com.keylesspalace.tusky.entity.Emoji
|
||||
import com.keylesspalace.tusky.entity.Poll
|
||||
import com.keylesspalace.tusky.entity.Status
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.util.Either
|
||||
import com.keylesspalace.tusky.util.dec
|
||||
import com.keylesspalace.tusky.util.inc
|
||||
import com.keylesspalace.tusky.util.trimTrailingWhitespace
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import java.io.IOException
|
||||
import java.util.Date
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
data class Placeholder(val id: String)
|
||||
|
||||
typealias TimelineStatus = Either<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>>
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
val status = if (reblog != null) {
|
||||
Status(
|
||||
id = status.serverId,
|
||||
url = null, // no url for reblogs
|
||||
account = this.reblogAccount!!.toAccount(gson),
|
||||
inReplyToId = null,
|
||||
inReplyToAccountId = null,
|
||||
reblog = reblog,
|
||||
content = SpannedString(""),
|
||||
createdAt = Date(status.createdAt), // lie but whatever?
|
||||
emojis = listOf(),
|
||||
reblogsCount = 0,
|
||||
favouritesCount = 0,
|
||||
reblogged = false,
|
||||
favourited = false,
|
||||
bookmarked = false,
|
||||
sensitive = false,
|
||||
spoilerText = "",
|
||||
visibility = status.visibility!!,
|
||||
attachments = ArrayList(),
|
||||
mentions = listOf(),
|
||||
application = null,
|
||||
pinned = false,
|
||||
muted = status.muted,
|
||||
poll = null,
|
||||
card = null
|
||||
)
|
||||
} else {
|
||||
Status(
|
||||
id = status.serverId,
|
||||
url = status.url,
|
||||
account = account.toAccount(gson),
|
||||
inReplyToId = status.inReplyToId,
|
||||
inReplyToAccountId = status.inReplyToAccountId,
|
||||
reblog = null,
|
||||
content = status.content?.parseAsHtml()?.trimTrailingWhitespace()
|
||||
?: SpannedString(""),
|
||||
createdAt = Date(status.createdAt),
|
||||
emojis = emojis,
|
||||
reblogsCount = status.reblogsCount,
|
||||
favouritesCount = status.favouritesCount,
|
||||
reblogged = status.reblogged,
|
||||
favourited = status.favourited,
|
||||
bookmarked = status.bookmarked,
|
||||
sensitive = status.sensitive,
|
||||
spoilerText = status.spoilerText!!,
|
||||
visibility = status.visibility!!,
|
||||
attachments = attachments,
|
||||
mentions = mentions,
|
||||
application = application,
|
||||
pinned = false,
|
||||
muted = status.muted,
|
||||
poll = poll,
|
||||
card = null
|
||||
)
|
||||
}
|
||||
return Either.Right(status)
|
||||
}
|
||||
}
|
||||
|
||||
private val emojisListTypeToken = object : TypeToken<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)
|
|
@ -0,0 +1,256 @@
|
|||
/* Copyright 2021 Tusky Contributors
|
||||
*
|
||||
* This file is a part of Tusky.
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it under the terms of the
|
||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
|
||||
* Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
||||
* see <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
|
||||
)
|
||||
}
|
||||
val status = if (reblog != null) {
|
||||
Status(
|
||||
id = status.serverId,
|
||||
url = null, // no url for reblogs
|
||||
account = this.reblogAccount!!.toAccount(gson),
|
||||
inReplyToId = null,
|
||||
inReplyToAccountId = null,
|
||||
reblog = reblog,
|
||||
content = SpannedString(""),
|
||||
createdAt = Date(status.createdAt), // lie but whatever?
|
||||
emojis = listOf(),
|
||||
reblogsCount = 0,
|
||||
favouritesCount = 0,
|
||||
reblogged = false,
|
||||
favourited = false,
|
||||
bookmarked = false,
|
||||
sensitive = false,
|
||||
spoilerText = "",
|
||||
visibility = status.visibility,
|
||||
attachments = ArrayList(),
|
||||
mentions = listOf(),
|
||||
application = null,
|
||||
pinned = status.pinned,
|
||||
muted = status.muted,
|
||||
poll = null,
|
||||
card = null
|
||||
)
|
||||
} else {
|
||||
Status(
|
||||
id = status.serverId,
|
||||
url = status.url,
|
||||
account = account.toAccount(gson),
|
||||
inReplyToId = status.inReplyToId,
|
||||
inReplyToAccountId = status.inReplyToAccountId,
|
||||
reblog = null,
|
||||
content = status.content?.parseAsHtml()?.trimTrailingWhitespace()
|
||||
?: SpannedString(""),
|
||||
createdAt = Date(status.createdAt),
|
||||
emojis = emojis,
|
||||
reblogsCount = status.reblogsCount,
|
||||
favouritesCount = status.favouritesCount,
|
||||
reblogged = status.reblogged,
|
||||
favourited = status.favourited,
|
||||
bookmarked = status.bookmarked,
|
||||
sensitive = status.sensitive,
|
||||
spoilerText = status.spoilerText,
|
||||
visibility = status.visibility,
|
||||
attachments = attachments,
|
||||
mentions = mentions,
|
||||
application = application,
|
||||
pinned = status.pinned,
|
||||
muted = status.muted,
|
||||
poll = poll,
|
||||
card = null
|
||||
)
|
||||
}
|
||||
return StatusViewData.Concrete(
|
||||
status = status,
|
||||
isExpanded = this.status.expanded,
|
||||
isShowingContent = this.status.contentShowing,
|
||||
isCollapsible = shouldTrimStatus(status.content),
|
||||
isCollapsed = this.status.contentCollapsed
|
||||
)
|
||||
}
|
|
@ -1,940 +0,0 @@
|
|||
package com.keylesspalace.tusky.components.timeline
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.keylesspalace.tusky.appstore.BlockEvent
|
||||
import com.keylesspalace.tusky.appstore.BookmarkEvent
|
||||
import com.keylesspalace.tusky.appstore.DomainMuteEvent
|
||||
import com.keylesspalace.tusky.appstore.Event
|
||||
import com.keylesspalace.tusky.appstore.EventHub
|
||||
import com.keylesspalace.tusky.appstore.FavoriteEvent
|
||||
import com.keylesspalace.tusky.appstore.MuteConversationEvent
|
||||
import com.keylesspalace.tusky.appstore.MuteEvent
|
||||
import com.keylesspalace.tusky.appstore.PinEvent
|
||||
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
|
||||
import com.keylesspalace.tusky.appstore.ReblogEvent
|
||||
import com.keylesspalace.tusky.appstore.StatusComposedEvent
|
||||
import com.keylesspalace.tusky.appstore.StatusDeletedEvent
|
||||
import com.keylesspalace.tusky.appstore.UnfollowEvent
|
||||
import com.keylesspalace.tusky.db.AccountManager
|
||||
import com.keylesspalace.tusky.entity.Filter
|
||||
import com.keylesspalace.tusky.entity.Poll
|
||||
import com.keylesspalace.tusky.entity.Status
|
||||
import com.keylesspalace.tusky.network.FilterModel
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.network.TimelineCases
|
||||
import com.keylesspalace.tusky.settings.PrefKeys
|
||||
import com.keylesspalace.tusky.util.Either
|
||||
import com.keylesspalace.tusky.util.HttpHeaderLink
|
||||
import com.keylesspalace.tusky.util.LinkHelper
|
||||
import com.keylesspalace.tusky.util.RxAwareViewModel
|
||||
import com.keylesspalace.tusky.util.dec
|
||||
import com.keylesspalace.tusky.util.firstIsInstanceOrNull
|
||||
import com.keylesspalace.tusky.util.inc
|
||||
import com.keylesspalace.tusky.util.isLessThan
|
||||
import com.keylesspalace.tusky.util.toViewData
|
||||
import com.keylesspalace.tusky.viewdata.StatusViewData
|
||||
import io.reactivex.rxjava3.core.Observable
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.subjects.PublishSubject
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.rx3.asFlow
|
||||
import kotlinx.coroutines.rx3.await
|
||||
import retrofit2.HttpException
|
||||
import retrofit2.Response
|
||||
import java.io.IOException
|
||||
import javax.inject.Inject
|
||||
|
||||
class TimelineViewModel @Inject constructor(
|
||||
private val timelineRepo: TimelineRepository,
|
||||
private val timelineCases: TimelineCases,
|
||||
private val api: MastodonApi,
|
||||
private val eventHub: EventHub,
|
||||
private val accountManager: AccountManager,
|
||||
private val sharedPreferences: SharedPreferences,
|
||||
private val filterModel: FilterModel,
|
||||
) : RxAwareViewModel() {
|
||||
|
||||
enum class FailureReason {
|
||||
NETWORK,
|
||||
OTHER,
|
||||
}
|
||||
|
||||
val viewUpdates: Observable<Unit>
|
||||
get() = updateViewSubject
|
||||
|
||||
var kind: Kind = Kind.HOME
|
||||
private set
|
||||
|
||||
var isLoadingInitially = false
|
||||
private set
|
||||
var isRefreshing = false
|
||||
private set
|
||||
var bottomLoading = false
|
||||
private set
|
||||
var initialUpdateFailed = false
|
||||
private set
|
||||
var failure: FailureReason? = null
|
||||
private set
|
||||
var id: String? = null
|
||||
private set
|
||||
var tags: List<String> = emptyList()
|
||||
private set
|
||||
|
||||
private var alwaysShowSensitiveMedia = false
|
||||
private var alwaysOpenSpoilers = false
|
||||
private var filterRemoveReplies = false
|
||||
private var filterRemoveReblogs = false
|
||||
private var didLoadEverythingBottom = false
|
||||
|
||||
private var updateViewSubject = PublishSubject.create<Unit>()
|
||||
|
||||
/**
|
||||
* For some timeline kinds we must use LINK headers and not just status ids.
|
||||
*/
|
||||
private var nextId: String? = null
|
||||
|
||||
val statuses = mutableListOf<StatusViewData>()
|
||||
|
||||
fun init(
|
||||
kind: Kind,
|
||||
id: String?,
|
||||
tags: List<String>
|
||||
) {
|
||||
this.kind = kind
|
||||
this.id = id
|
||||
this.tags = tags
|
||||
|
||||
if (kind == Kind.HOME) {
|
||||
filterRemoveReplies =
|
||||
!sharedPreferences.getBoolean(PrefKeys.TAB_FILTER_HOME_REPLIES, true)
|
||||
filterRemoveReblogs =
|
||||
!sharedPreferences.getBoolean(PrefKeys.TAB_FILTER_HOME_BOOSTS, true)
|
||||
}
|
||||
this.alwaysShowSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia
|
||||
this.alwaysOpenSpoilers = accountManager.activeAccount!!.alwaysOpenSpoiler
|
||||
|
||||
viewModelScope.launch {
|
||||
eventHub.events
|
||||
.asFlow()
|
||||
.collect { event -> handleEvent(event) }
|
||||
}
|
||||
|
||||
reloadFilters()
|
||||
}
|
||||
|
||||
private suspend fun updateCurrent() {
|
||||
val topId = statuses.firstIsInstanceOrNull<StatusViewData.Concrete>()?.id ?: return
|
||||
// Request statuses including current top to refresh all of them
|
||||
val topIdMinusOne = topId.inc()
|
||||
val statuses = try {
|
||||
loadStatuses(
|
||||
maxId = topIdMinusOne,
|
||||
sinceId = null,
|
||||
sinceIdMinusOne = null,
|
||||
TimelineRequestMode.NETWORK,
|
||||
)
|
||||
} catch (t: Exception) {
|
||||
initialUpdateFailed = true
|
||||
if (isExpectedRequestException(t)) {
|
||||
Log.d(TAG, "Failed updating timeline", t)
|
||||
triggerViewUpdate()
|
||||
return
|
||||
} else {
|
||||
throw t
|
||||
}
|
||||
}
|
||||
|
||||
initialUpdateFailed = false
|
||||
|
||||
// When cached timeline is too old, we would replace it with nothing
|
||||
if (statuses.isNotEmpty()) {
|
||||
val mutableStatuses = statuses.toMutableList()
|
||||
filterStatuses(mutableStatuses)
|
||||
this.statuses.removeAll { item ->
|
||||
val id = when (item) {
|
||||
is StatusViewData.Concrete -> item.id
|
||||
is StatusViewData.Placeholder -> item.id
|
||||
}
|
||||
|
||||
id == topId || id.isLessThan(topId)
|
||||
}
|
||||
this.statuses.addAll(mutableStatuses.toViewData())
|
||||
}
|
||||
triggerViewUpdate()
|
||||
}
|
||||
|
||||
private fun isExpectedRequestException(t: Exception) = t is IOException || t is HttpException
|
||||
|
||||
fun refresh(): Job {
|
||||
return viewModelScope.launch {
|
||||
isRefreshing = true
|
||||
failure = null
|
||||
triggerViewUpdate()
|
||||
|
||||
try {
|
||||
if (initialUpdateFailed) updateCurrent()
|
||||
loadAbove()
|
||||
} catch (e: Exception) {
|
||||
if (isExpectedRequestException(e)) {
|
||||
Log.e(TAG, "Failed to refresh", e)
|
||||
} else {
|
||||
throw e
|
||||
}
|
||||
} finally {
|
||||
isRefreshing = false
|
||||
triggerViewUpdate()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** When reaching the end of list. WIll optionally show spinner in the end of list. */
|
||||
fun loadMore(): Job {
|
||||
return viewModelScope.launch {
|
||||
if (didLoadEverythingBottom || bottomLoading) {
|
||||
return@launch
|
||||
}
|
||||
if (statuses.isEmpty()) {
|
||||
loadInitial().join()
|
||||
return@launch
|
||||
}
|
||||
setLoadingPlaceholderBelow()
|
||||
|
||||
val bottomId: String? =
|
||||
if (kind == Kind.FAVOURITES || kind == Kind.BOOKMARKS) {
|
||||
nextId
|
||||
} else {
|
||||
statuses.lastOrNull { it is StatusViewData.Concrete }
|
||||
?.let { (it as StatusViewData.Concrete).id }
|
||||
}
|
||||
try {
|
||||
loadBelow(bottomId)
|
||||
} catch (e: Exception) {
|
||||
if (isExpectedRequestException(e)) {
|
||||
if (statuses.lastOrNull() is StatusViewData.Placeholder) {
|
||||
statuses.removeAt(statuses.lastIndex)
|
||||
}
|
||||
} else {
|
||||
throw e
|
||||
}
|
||||
} finally {
|
||||
triggerViewUpdate()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Load and insert statuses below the [bottomId]. Does not indicate progress. */
|
||||
private suspend fun loadBelow(bottomId: String?) {
|
||||
this.bottomLoading = true
|
||||
try {
|
||||
val statuses = loadStatuses(
|
||||
bottomId,
|
||||
null,
|
||||
null,
|
||||
TimelineRequestMode.ANY
|
||||
)
|
||||
addStatusesBelow(statuses.toMutableList())
|
||||
} finally {
|
||||
this.bottomLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
private fun setLoadingPlaceholderBelow() {
|
||||
val last = statuses.last()
|
||||
val placeholder: StatusViewData.Placeholder
|
||||
if (last is StatusViewData.Concrete) {
|
||||
val placeholderId = last.id.dec()
|
||||
placeholder = StatusViewData.Placeholder(placeholderId, true)
|
||||
statuses.add(placeholder)
|
||||
} else {
|
||||
placeholder = last as StatusViewData.Placeholder
|
||||
}
|
||||
statuses[statuses.lastIndex] = placeholder
|
||||
triggerViewUpdate()
|
||||
}
|
||||
|
||||
private fun addStatusesBelow(statuses: MutableList<Either<Placeholder, Status>>) {
|
||||
val fullFetch = isFullFetch(statuses)
|
||||
// Remove placeholder in the bottom if it's there
|
||||
if (this.statuses.isNotEmpty() &&
|
||||
this.statuses.last() !is StatusViewData.Concrete
|
||||
) {
|
||||
this.statuses.removeAt(this.statuses.lastIndex)
|
||||
}
|
||||
|
||||
// Removing placeholder if it's the last one from the cache
|
||||
if (statuses.isNotEmpty() && !statuses[statuses.size - 1].isRight()) {
|
||||
statuses.removeAt(statuses.size - 1)
|
||||
}
|
||||
|
||||
val oldSize = this.statuses.size
|
||||
if (this.statuses.isNotEmpty()) {
|
||||
addItems(statuses)
|
||||
} else {
|
||||
updateStatuses(statuses, fullFetch)
|
||||
}
|
||||
if (this.statuses.size == oldSize) {
|
||||
// This may be a brittle check but seems like it works
|
||||
// Can we check it using headers somehow? Do all server support them?
|
||||
didLoadEverythingBottom = true
|
||||
}
|
||||
}
|
||||
|
||||
fun loadGap(position: Int): Job {
|
||||
return viewModelScope.launch {
|
||||
// check bounds before accessing list,
|
||||
if (statuses.size < position || position <= 0) {
|
||||
Log.e(TAG, "Wrong gap position: $position")
|
||||
return@launch
|
||||
}
|
||||
|
||||
val fromStatus = statuses[position - 1].asStatusOrNull()
|
||||
val toStatus = statuses[position + 1].asStatusOrNull()
|
||||
val toMinusOne = statuses.getOrNull(position + 2)?.asStatusOrNull()?.id
|
||||
if (fromStatus == null || toStatus == null) {
|
||||
Log.e(TAG, "Failed to load more at $position, wrong placeholder position")
|
||||
return@launch
|
||||
}
|
||||
val placeholder = statuses[position].asPlaceholderOrNull() ?: run {
|
||||
Log.e(TAG, "Not a placeholder at $position")
|
||||
return@launch
|
||||
}
|
||||
|
||||
val newViewData: StatusViewData = StatusViewData.Placeholder(placeholder.id, true)
|
||||
statuses[position] = newViewData
|
||||
triggerViewUpdate()
|
||||
|
||||
try {
|
||||
val statuses = loadStatuses(
|
||||
fromStatus.id,
|
||||
toStatus.id,
|
||||
toMinusOne,
|
||||
TimelineRequestMode.NETWORK
|
||||
)
|
||||
replacePlaceholderWithStatuses(
|
||||
statuses.toMutableList(),
|
||||
isFullFetch(statuses),
|
||||
position
|
||||
)
|
||||
} catch (t: Exception) {
|
||||
if (isExpectedRequestException(t)) {
|
||||
Log.e(TAG, "Failed to load gap", t)
|
||||
if (statuses[position] is StatusViewData.Placeholder) {
|
||||
statuses[position] = StatusViewData.Placeholder(placeholder.id, false)
|
||||
}
|
||||
} else {
|
||||
throw t
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun reblog(reblog: Boolean, position: Int): Job = viewModelScope.launch {
|
||||
val status = statuses[position].asStatusOrNull() ?: return@launch
|
||||
try {
|
||||
timelineCases.reblog(status.actionableId, reblog).await()
|
||||
} catch (t: Exception) {
|
||||
ifExpected(t) {
|
||||
Log.d(TAG, "Failed to reblog status " + status.actionableId, t)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun favorite(favorite: Boolean, position: Int): Job = viewModelScope.launch {
|
||||
val status = statuses[position].asStatusOrNull() ?: return@launch
|
||||
|
||||
try {
|
||||
timelineCases.favourite(status.actionableId, favorite).await()
|
||||
} catch (t: Exception) {
|
||||
ifExpected(t) {
|
||||
Log.d(TAG, "Failed to favourite status " + status.actionableId, t)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun bookmark(bookmark: Boolean, position: Int): Job = viewModelScope.launch {
|
||||
val status = statuses[position].asStatusOrNull() ?: return@launch
|
||||
try {
|
||||
timelineCases.bookmark(status.actionableId, bookmark).await()
|
||||
} catch (t: Exception) {
|
||||
ifExpected(t) {
|
||||
Log.d(TAG, "Failed to favourite status " + status.actionableId, t)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun voteInPoll(position: Int, choices: List<Int>): Job = viewModelScope.launch {
|
||||
val status = statuses[position].asStatusOrNull() ?: return@launch
|
||||
|
||||
val poll = status.status.poll ?: run {
|
||||
Log.w(TAG, "No poll on status ${status.id}")
|
||||
return@launch
|
||||
}
|
||||
|
||||
val votedPoll = poll.votedCopy(choices)
|
||||
updatePoll(status, votedPoll)
|
||||
|
||||
try {
|
||||
timelineCases.voteInPoll(status.actionableId, poll.id, choices).await()
|
||||
} catch (t: Exception) {
|
||||
ifExpected(t) {
|
||||
Log.d(TAG, "Failed to vote in poll: " + status.actionableId, t)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updatePoll(
|
||||
status: StatusViewData.Concrete,
|
||||
newPoll: Poll
|
||||
) {
|
||||
updateStatusById(status.id) {
|
||||
it.copy(status = it.status.copy(poll = newPoll))
|
||||
}
|
||||
}
|
||||
|
||||
fun changeExpanded(expanded: Boolean, position: Int) {
|
||||
updateStatusAt(position) { it.copy(isExpanded = expanded) }
|
||||
triggerViewUpdate()
|
||||
}
|
||||
|
||||
fun changeContentHidden(isShowing: Boolean, position: Int) {
|
||||
updateStatusAt(position) { it.copy(isShowingContent = isShowing) }
|
||||
triggerViewUpdate()
|
||||
}
|
||||
|
||||
fun changeContentCollapsed(isCollapsed: Boolean, position: Int) {
|
||||
updateStatusAt(position) { it.copy(isCollapsed = isCollapsed) }
|
||||
triggerViewUpdate()
|
||||
}
|
||||
|
||||
private fun removeAllByAccountId(accountId: String) {
|
||||
statuses.removeAll { vm ->
|
||||
val status = vm.asStatusOrNull()?.status ?: return@removeAll false
|
||||
status.account.id == accountId || status.actionableStatus.account.id == accountId
|
||||
}
|
||||
}
|
||||
|
||||
private fun removeAllByInstance(instance: String) {
|
||||
statuses.removeAll { vd ->
|
||||
val status = vd.asStatusOrNull()?.status ?: return@removeAll false
|
||||
LinkHelper.getDomain(status.account.url) == instance
|
||||
}
|
||||
}
|
||||
|
||||
private fun triggerViewUpdate() {
|
||||
this.updateViewSubject.onNext(Unit)
|
||||
}
|
||||
|
||||
private suspend fun loadStatuses(
|
||||
maxId: String?,
|
||||
sinceId: String?,
|
||||
sinceIdMinusOne: String?,
|
||||
homeMode: TimelineRequestMode,
|
||||
): List<TimelineStatus> {
|
||||
val statuses = if (kind == Kind.HOME) {
|
||||
timelineRepo.getStatuses(maxId, sinceId, sinceIdMinusOne, LOAD_AT_ONCE, homeMode)
|
||||
.await()
|
||||
} else {
|
||||
val response = fetchStatusesForKind(maxId, sinceId, LOAD_AT_ONCE).await()
|
||||
if (response.isSuccessful) {
|
||||
val newNextId = extractNextId(response)
|
||||
if (newNextId != null) {
|
||||
// when we reach the bottom of the list, we won't have a new link. If
|
||||
// we blindly write `null` here we will start loading from the top
|
||||
// again.
|
||||
nextId = newNextId
|
||||
}
|
||||
response.body()?.map { Either.Right(it) } ?: listOf()
|
||||
} else {
|
||||
throw HttpException(response)
|
||||
}
|
||||
}.toMutableList()
|
||||
|
||||
filterStatuses(statuses)
|
||||
|
||||
return statuses
|
||||
}
|
||||
|
||||
private fun updateStatuses(
|
||||
newStatuses: MutableList<Either<Placeholder, Status>>,
|
||||
fullFetch: Boolean
|
||||
) {
|
||||
if (statuses.isEmpty()) {
|
||||
statuses.addAll(newStatuses.toViewData())
|
||||
} else {
|
||||
val lastOfNew = newStatuses.lastOrNull()
|
||||
val index = if (lastOfNew == null) -1
|
||||
else statuses.indexOfLast { it.asStatusOrNull()?.id === lastOfNew.asRightOrNull()?.id }
|
||||
if (index >= 0) {
|
||||
statuses.subList(0, index).clear()
|
||||
}
|
||||
|
||||
val newIndex =
|
||||
newStatuses.indexOfFirst {
|
||||
it.isRight() && it.asRight().id == (statuses[0] as? StatusViewData.Concrete)?.id
|
||||
}
|
||||
if (newIndex == -1) {
|
||||
if (index == -1 && fullFetch) {
|
||||
val placeholderId =
|
||||
newStatuses.last { status -> status.isRight() }.asRight().id.inc()
|
||||
newStatuses.add(Either.Left(Placeholder(placeholderId)))
|
||||
}
|
||||
statuses.addAll(0, newStatuses.toViewData())
|
||||
} else {
|
||||
statuses.addAll(0, newStatuses.subList(0, newIndex).toViewData())
|
||||
}
|
||||
}
|
||||
// Remove all consecutive placeholders
|
||||
removeConsecutivePlaceholders()
|
||||
this.triggerViewUpdate()
|
||||
}
|
||||
|
||||
private fun filterViewData(viewData: MutableList<StatusViewData>) {
|
||||
viewData.removeAll { vd ->
|
||||
vd.asStatusOrNull()?.status?.let { shouldFilterStatus(it) } ?: false
|
||||
}
|
||||
}
|
||||
|
||||
private fun filterStatuses(statuses: MutableList<Either<Placeholder, Status>>) {
|
||||
statuses.removeAll { status ->
|
||||
status.asRightOrNull()?.let { shouldFilterStatus(it) } ?: false
|
||||
}
|
||||
}
|
||||
|
||||
private fun shouldFilterStatus(status: Status): Boolean {
|
||||
return status.inReplyToId != null && filterRemoveReplies ||
|
||||
status.reblog != null && filterRemoveReblogs ||
|
||||
filterModel.shouldFilterStatus(status.actionableStatus)
|
||||
}
|
||||
|
||||
private fun extractNextId(response: Response<*>): String? {
|
||||
val linkHeader = response.headers()["Link"] ?: return null
|
||||
val links = HttpHeaderLink.parse(linkHeader)
|
||||
val nextHeader = HttpHeaderLink.findByRelationType(links, "next") ?: return null
|
||||
val nextLink = nextHeader.uri ?: return null
|
||||
return nextLink.getQueryParameter("max_id")
|
||||
}
|
||||
|
||||
private suspend fun tryCache() {
|
||||
// Request timeline from disk to make it quick, then replace it with timeline from
|
||||
// the server to update it
|
||||
val statuses =
|
||||
timelineRepo.getStatuses(null, null, null, LOAD_AT_ONCE, TimelineRequestMode.DISK)
|
||||
.await()
|
||||
|
||||
val mutableStatusResponse = statuses.toMutableList()
|
||||
filterStatuses(mutableStatusResponse)
|
||||
if (statuses.size > 1) {
|
||||
clearPlaceholdersForResponse(mutableStatusResponse)
|
||||
this.statuses.clear()
|
||||
this.statuses.addAll(mutableStatusResponse.toViewData())
|
||||
}
|
||||
}
|
||||
|
||||
fun loadInitial(): Job {
|
||||
return viewModelScope.launch {
|
||||
if (statuses.isNotEmpty() || initialUpdateFailed || isLoadingInitially) {
|
||||
return@launch
|
||||
}
|
||||
isLoadingInitially = true
|
||||
failure = null
|
||||
triggerViewUpdate()
|
||||
|
||||
if (kind == Kind.HOME) {
|
||||
tryCache()
|
||||
isLoadingInitially = statuses.isEmpty()
|
||||
updateCurrent()
|
||||
try {
|
||||
loadAbove()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Loading above failed", e)
|
||||
if (!isExpectedRequestException(e)) {
|
||||
throw e
|
||||
} else if (statuses.isEmpty()) {
|
||||
failure =
|
||||
if (e is IOException) FailureReason.NETWORK
|
||||
else FailureReason.OTHER
|
||||
}
|
||||
} finally {
|
||||
isLoadingInitially = false
|
||||
triggerViewUpdate()
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
loadBelow(null)
|
||||
} catch (e: IOException) {
|
||||
failure = FailureReason.NETWORK
|
||||
} catch (e: HttpException) {
|
||||
failure = FailureReason.OTHER
|
||||
} finally {
|
||||
isLoadingInitially = false
|
||||
triggerViewUpdate()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadAbove() {
|
||||
var firstOrNull: String? = null
|
||||
var secondOrNull: String? = null
|
||||
for (i in statuses.indices) {
|
||||
val status = statuses[i].asStatusOrNull() ?: continue
|
||||
firstOrNull = status.id
|
||||
secondOrNull = statuses.getOrNull(i + 1)?.asStatusOrNull()?.id
|
||||
break
|
||||
}
|
||||
|
||||
try {
|
||||
if (firstOrNull != null) {
|
||||
triggerViewUpdate()
|
||||
|
||||
val statuses = loadStatuses(
|
||||
maxId = null,
|
||||
sinceId = firstOrNull,
|
||||
sinceIdMinusOne = secondOrNull,
|
||||
homeMode = TimelineRequestMode.NETWORK
|
||||
)
|
||||
|
||||
val fullFetch = isFullFetch(statuses)
|
||||
updateStatuses(statuses.toMutableList(), fullFetch)
|
||||
} else {
|
||||
loadBelow(null)
|
||||
}
|
||||
} finally {
|
||||
triggerViewUpdate()
|
||||
}
|
||||
}
|
||||
|
||||
private fun isFullFetch(statuses: List<TimelineStatus>) = statuses.size >= LOAD_AT_ONCE
|
||||
|
||||
private fun fullyRefresh(): Job {
|
||||
this.statuses.clear()
|
||||
return loadInitial()
|
||||
}
|
||||
|
||||
private fun fetchStatusesForKind(
|
||||
fromId: String?,
|
||||
uptoId: String?,
|
||||
limit: Int
|
||||
): Single<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)
|
||||
}
|
||||
}
|
||||
|
||||
private fun replacePlaceholderWithStatuses(
|
||||
newStatuses: MutableList<Either<Placeholder, Status>>,
|
||||
fullFetch: Boolean,
|
||||
pos: Int
|
||||
) {
|
||||
val placeholder = statuses[pos]
|
||||
if (placeholder is StatusViewData.Placeholder) {
|
||||
statuses.removeAt(pos)
|
||||
}
|
||||
if (newStatuses.isEmpty()) {
|
||||
return
|
||||
}
|
||||
val newViewData = newStatuses
|
||||
.toViewData()
|
||||
.toMutableList()
|
||||
|
||||
if (fullFetch) {
|
||||
newViewData.add(placeholder)
|
||||
}
|
||||
statuses.addAll(pos, newViewData)
|
||||
removeConsecutivePlaceholders()
|
||||
triggerViewUpdate()
|
||||
}
|
||||
|
||||
private fun removeConsecutivePlaceholders() {
|
||||
for (i in 0 until statuses.size - 1) {
|
||||
if (statuses[i] is StatusViewData.Placeholder &&
|
||||
statuses[i + 1] is StatusViewData.Placeholder
|
||||
) {
|
||||
statuses.removeAt(i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun addItems(newStatuses: List<Either<Placeholder, Status>>) {
|
||||
if (newStatuses.isEmpty()) {
|
||||
return
|
||||
}
|
||||
statuses.addAll(newStatuses.toViewData())
|
||||
removeConsecutivePlaceholders()
|
||||
}
|
||||
|
||||
/**
|
||||
* For certain requests we don't want to see placeholders, they will be removed some other way
|
||||
*/
|
||||
private fun clearPlaceholdersForResponse(statuses: MutableList<Either<Placeholder, Status>>) {
|
||||
statuses.removeAll { status -> status.isLeft() }
|
||||
}
|
||||
|
||||
private fun handleReblogEvent(reblogEvent: ReblogEvent) {
|
||||
updateStatusById(reblogEvent.statusId) {
|
||||
it.copy(status = it.status.copy(reblogged = reblogEvent.reblog))
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleFavEvent(favEvent: FavoriteEvent) {
|
||||
updateActionableStatusById(favEvent.statusId) {
|
||||
it.copy(favourited = favEvent.favourite)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleBookmarkEvent(bookmarkEvent: BookmarkEvent) {
|
||||
updateActionableStatusById(bookmarkEvent.statusId) {
|
||||
it.copy(bookmarked = bookmarkEvent.bookmark)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handlePinEvent(pinEvent: PinEvent) {
|
||||
updateActionableStatusById(pinEvent.statusId) {
|
||||
it.copy(pinned = pinEvent.pinned)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleStatusComposeEvent(status: Status) {
|
||||
when (kind) {
|
||||
Kind.HOME, Kind.PUBLIC_FEDERATED, Kind.PUBLIC_LOCAL -> refresh()
|
||||
Kind.USER, Kind.USER_WITH_REPLIES -> if (status.account.id == id) {
|
||||
refresh()
|
||||
} else {
|
||||
return
|
||||
}
|
||||
Kind.TAG, Kind.FAVOURITES, Kind.LIST, Kind.BOOKMARKS, Kind.USER_PINNED -> return
|
||||
}
|
||||
}
|
||||
|
||||
private fun deleteStatusById(id: String) {
|
||||
for (i in statuses.indices) {
|
||||
val either = statuses[i]
|
||||
if (either.asStatusOrNull()?.id == id) {
|
||||
statuses.removeAt(i)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onPreferenceChanged(key: String) {
|
||||
when (key) {
|
||||
PrefKeys.TAB_FILTER_HOME_REPLIES -> {
|
||||
val filter = sharedPreferences.getBoolean(PrefKeys.TAB_FILTER_HOME_REPLIES, true)
|
||||
val oldRemoveReplies = filterRemoveReplies
|
||||
filterRemoveReplies = kind == Kind.HOME && !filter
|
||||
if (statuses.isNotEmpty() && oldRemoveReplies != filterRemoveReplies) {
|
||||
fullyRefresh()
|
||||
}
|
||||
}
|
||||
PrefKeys.TAB_FILTER_HOME_BOOSTS -> {
|
||||
val filter = sharedPreferences.getBoolean(PrefKeys.TAB_FILTER_HOME_BOOSTS, true)
|
||||
val oldRemoveReblogs = filterRemoveReblogs
|
||||
filterRemoveReblogs = kind == Kind.HOME && !filter
|
||||
if (statuses.isNotEmpty() && oldRemoveReblogs != filterRemoveReblogs) {
|
||||
fullyRefresh()
|
||||
}
|
||||
}
|
||||
Filter.HOME, Filter.NOTIFICATIONS, Filter.THREAD, Filter.PUBLIC, Filter.ACCOUNT -> {
|
||||
if (filterContextMatchesKind(kind, listOf(key))) {
|
||||
reloadFilters()
|
||||
}
|
||||
}
|
||||
PrefKeys.ALWAYS_SHOW_SENSITIVE_MEDIA -> {
|
||||
// it is ok if only newly loaded statuses are affected, no need to fully refresh
|
||||
alwaysShowSensitiveMedia =
|
||||
accountManager.activeAccount!!.alwaysShowSensitiveMedia
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// public for now
|
||||
fun filterContextMatchesKind(
|
||||
kind: Kind,
|
||||
filterContext: List<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 MuteConversationEvent -> fullyRefresh()
|
||||
is UnfollowEvent -> {
|
||||
if (kind == Kind.HOME) {
|
||||
val id = event.accountId
|
||||
removeAllByAccountId(id)
|
||||
}
|
||||
}
|
||||
is BlockEvent -> {
|
||||
if (kind != Kind.USER && kind != Kind.USER_WITH_REPLIES && kind != Kind.USER_PINNED) {
|
||||
val id = event.accountId
|
||||
removeAllByAccountId(id)
|
||||
}
|
||||
}
|
||||
is MuteEvent -> {
|
||||
if (kind != Kind.USER && kind != Kind.USER_WITH_REPLIES && kind != Kind.USER_PINNED) {
|
||||
val id = event.accountId
|
||||
removeAllByAccountId(id)
|
||||
}
|
||||
}
|
||||
is DomainMuteEvent -> {
|
||||
if (kind != Kind.USER && kind != Kind.USER_WITH_REPLIES && kind != Kind.USER_PINNED) {
|
||||
val instance = event.instance
|
||||
removeAllByInstance(instance)
|
||||
}
|
||||
}
|
||||
is StatusDeletedEvent -> {
|
||||
if (kind != Kind.USER && kind != Kind.USER_WITH_REPLIES && kind != Kind.USER_PINNED) {
|
||||
val id = event.statusId
|
||||
deleteStatusById(id)
|
||||
}
|
||||
}
|
||||
is StatusComposedEvent -> {
|
||||
val status = event.status
|
||||
handleStatusComposeEvent(status)
|
||||
}
|
||||
is PreferenceChangedEvent -> {
|
||||
onPreferenceChanged(event.preferenceKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private inline fun updateActionableStatusById(
|
||||
id: String,
|
||||
updater: (Status) -> Status
|
||||
) {
|
||||
val pos = statuses.indexOfFirst { it.asStatusOrNull()?.id == id }
|
||||
if (pos == -1) return
|
||||
updateStatusAt(pos) {
|
||||
if (it.status.reblog != null) {
|
||||
it.copy(status = it.status.copy(reblog = updater(it.status.reblog)))
|
||||
} else {
|
||||
it.copy(status = updater(it.status))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private inline fun updateStatusById(
|
||||
id: String,
|
||||
updater: (StatusViewData.Concrete) -> StatusViewData.Concrete
|
||||
) {
|
||||
val pos = statuses.indexOfFirst { it.asStatusOrNull()?.id == id }
|
||||
if (pos == -1) return
|
||||
updateStatusAt(pos, updater)
|
||||
}
|
||||
|
||||
private inline fun updateStatusAt(
|
||||
position: Int,
|
||||
updater: (StatusViewData.Concrete) -> StatusViewData.Concrete
|
||||
) {
|
||||
val status = statuses.getOrNull(position)?.asStatusOrNull() ?: return
|
||||
statuses[position] = updater(status)
|
||||
triggerViewUpdate()
|
||||
}
|
||||
|
||||
private fun List<TimelineStatus>.toViewData(): List<StatusViewData> = this.map {
|
||||
when (it) {
|
||||
is Either.Right -> it.value.toViewData(
|
||||
alwaysShowSensitiveMedia,
|
||||
alwaysOpenSpoilers
|
||||
)
|
||||
is Either.Left -> StatusViewData.Placeholder(it.value.id, false)
|
||||
}
|
||||
}
|
||||
|
||||
private fun reloadFilters() {
|
||||
viewModelScope.launch {
|
||||
val filters = try {
|
||||
api.getFilters().await()
|
||||
} catch (t: Exception) {
|
||||
Log.e(TAG, "Failed to fetch filters", t)
|
||||
return@launch
|
||||
}
|
||||
filterModel.initWithFilters(
|
||||
filters.filter {
|
||||
filterContextMatchesKind(kind, it.context)
|
||||
}
|
||||
)
|
||||
filterViewData(this@TimelineViewModel.statuses)
|
||||
}
|
||||
}
|
||||
|
||||
private inline fun ifExpected(
|
||||
t: Exception,
|
||||
cb: () -> Unit
|
||||
) {
|
||||
if (isExpectedRequestException(t)) {
|
||||
cb()
|
||||
} else {
|
||||
throw t
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "TimelineVM"
|
||||
internal const val LOAD_AT_ONCE = 30
|
||||
}
|
||||
|
||||
enum class Kind {
|
||||
HOME, PUBLIC_LOCAL, PUBLIC_FEDERATED, TAG, USER, USER_PINNED, USER_WITH_REPLIES, FAVOURITES, LIST, BOOKMARKS
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -0,0 +1,208 @@
|
|||
/* Copyright 2021 Tusky Contributors
|
||||
*
|
||||
* This file is a part of Tusky.
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it under the terms of the
|
||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
|
||||
* Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
||||
* see <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.network.FilterModel
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.network.TimelineCases
|
||||
import com.keylesspalace.tusky.util.dec
|
||||
import com.keylesspalace.tusky.util.inc
|
||||
import com.keylesspalace.tusky.viewdata.StatusViewData
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.rx3.await
|
||||
import retrofit2.HttpException
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* TimelineViewModel that caches all statuses in a local database
|
||||
*/
|
||||
class CachedTimelineViewModel @Inject constructor(
|
||||
timelineCases: TimelineCases,
|
||||
private val api: MastodonApi,
|
||||
eventHub: EventHub,
|
||||
accountManager: AccountManager,
|
||||
sharedPreferences: SharedPreferences,
|
||||
filterModel: FilterModel,
|
||||
private val db: AppDatabase,
|
||||
private val gson: Gson
|
||||
) : TimelineViewModel(timelineCases, api, eventHub, accountManager, sharedPreferences, filterModel) {
|
||||
|
||||
@ExperimentalPagingApi
|
||||
override val statuses = Pager(
|
||||
config = PagingConfig(pageSize = LOAD_AT_ONCE),
|
||||
remoteMediator = CachedTimelineRemoteMediator(accountManager, api, db, gson),
|
||||
pagingSourceFactory = { db.timelineDao().getStatusesForAccount(accountManager.activeAccount!!.id) }
|
||||
).flow
|
||||
.map { pagingData ->
|
||||
pagingData.map { timelineStatus ->
|
||||
timelineStatus.toViewData(gson)
|
||||
}
|
||||
}
|
||||
.map { pagingData ->
|
||||
pagingData.filter { statusViewData ->
|
||||
!shouldFilterStatus(statusViewData)
|
||||
}
|
||||
}
|
||||
.cachedIn(viewModelScope)
|
||||
|
||||
override fun updatePoll(newPoll: Poll, status: StatusViewData.Concrete) {
|
||||
// handled by CacheUpdater
|
||||
}
|
||||
|
||||
override fun changeExpanded(expanded: Boolean, status: StatusViewData.Concrete) {
|
||||
viewModelScope.launch {
|
||||
db.timelineDao().setExpanded(accountManager.activeAccount!!.id, status.id, expanded)
|
||||
}
|
||||
}
|
||||
|
||||
override fun changeContentShowing(isShowing: Boolean, status: StatusViewData.Concrete) {
|
||||
viewModelScope.launch {
|
||||
db.timelineDao().setContentShowing(accountManager.activeAccount!!.id, status.id, isShowing)
|
||||
}
|
||||
}
|
||||
|
||||
override fun changeContentCollapsed(isCollapsed: Boolean, status: StatusViewData.Concrete) {
|
||||
viewModelScope.launch {
|
||||
db.timelineDao().setContentCollapsed(accountManager.activeAccount!!.id, status.id, isCollapsed)
|
||||
}
|
||||
}
|
||||
|
||||
override fun removeAllByAccountId(accountId: String) {
|
||||
viewModelScope.launch {
|
||||
db.timelineDao().removeAllByUser(accountManager.activeAccount!!.id, accountId)
|
||||
}
|
||||
}
|
||||
|
||||
override fun removeAllByInstance(instance: String) {
|
||||
viewModelScope.launch {
|
||||
db.timelineDao().deleteAllFromInstance(accountManager.activeAccount!!.id, instance)
|
||||
}
|
||||
}
|
||||
|
||||
override fun removeStatusWithId(id: String) {
|
||||
// handled by CacheUpdater
|
||||
}
|
||||
|
||||
override fun loadMore(placeholderId: String) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val timelineDao = db.timelineDao()
|
||||
|
||||
val activeAccount = accountManager.activeAccount!!
|
||||
|
||||
timelineDao.insertStatus(Placeholder(placeholderId, loading = true).toEntity(activeAccount.id))
|
||||
|
||||
val response = api.homeTimeline(maxId = placeholderId.inc(), limit = 20).await()
|
||||
|
||||
val statuses = response.body()
|
||||
if (!response.isSuccessful || statuses == null) {
|
||||
loadMoreFailed(placeholderId, HttpException(response))
|
||||
return@launch
|
||||
}
|
||||
|
||||
db.withTransaction {
|
||||
|
||||
timelineDao.delete(activeAccount.id, placeholderId)
|
||||
|
||||
val overlappedStatuses = if (statuses.isNotEmpty()) {
|
||||
timelineDao.deleteRange(activeAccount.id, statuses.last().id, statuses.first().id)
|
||||
} else {
|
||||
0
|
||||
}
|
||||
|
||||
for (status in statuses) {
|
||||
timelineDao.insertAccount(status.account.toEntity(activeAccount.id, gson))
|
||||
status.reblog?.account?.toEntity(activeAccount.id, gson)?.let { rebloggedAccount ->
|
||||
timelineDao.insertAccount(rebloggedAccount)
|
||||
}
|
||||
timelineDao.insertStatus(
|
||||
status.toEntity(
|
||||
timelineUserId = activeAccount.id,
|
||||
gson = gson,
|
||||
expanded = activeAccount.alwaysOpenSpoiler,
|
||||
contentShowing = activeAccount.alwaysShowSensitiveMedia || !status.actionableStatus.sensitive,
|
||||
contentCollapsed = true
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (overlappedStatuses == 0) {
|
||||
timelineDao.insertStatus(
|
||||
Placeholder(statuses.last().id.dec(), loading = false).toEntity(activeAccount.id)
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (e: java.lang.Exception) {
|
||||
loadMoreFailed(placeholderId, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadMoreFailed(placeholderId: String, e: Exception) {
|
||||
Log.w("CachedTimelineVM", "failed loading statuses", e)
|
||||
val activeAccount = accountManager.activeAccount!!
|
||||
db.timelineDao().insertStatus(Placeholder(placeholderId, loading = false).toEntity(activeAccount.id))
|
||||
}
|
||||
|
||||
override fun handleReblogEvent(reblogEvent: ReblogEvent) {
|
||||
// handled by CacheUpdater
|
||||
}
|
||||
|
||||
override fun handleFavEvent(favEvent: FavoriteEvent) {
|
||||
// handled by CacheUpdater
|
||||
}
|
||||
|
||||
override fun handleBookmarkEvent(bookmarkEvent: BookmarkEvent) {
|
||||
// handled by CacheUpdater
|
||||
}
|
||||
|
||||
override fun handlePinEvent(pinEvent: PinEvent) {
|
||||
// handled by CacheUpdater
|
||||
}
|
||||
|
||||
override fun fullReload() {
|
||||
viewModelScope.launch {
|
||||
val activeAccount = accountManager.activeAccount!!
|
||||
db.runInTransaction {
|
||||
db.timelineDao().removeAllForAccount(activeAccount.id)
|
||||
db.timelineDao().removeAllUsersForAccount(activeAccount.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,302 @@
|
|||
/* Copyright 2021 Tusky Contributors
|
||||
*
|
||||
* This file is a part of Tusky.
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it under the terms of the
|
||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
|
||||
* Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
||||
* see <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.db.AccountManager
|
||||
import com.keylesspalace.tusky.entity.Poll
|
||||
import com.keylesspalace.tusky.entity.Status
|
||||
import com.keylesspalace.tusky.network.FilterModel
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.network.TimelineCases
|
||||
import com.keylesspalace.tusky.util.LinkHelper
|
||||
import com.keylesspalace.tusky.util.inc
|
||||
import com.keylesspalace.tusky.util.toViewData
|
||||
import com.keylesspalace.tusky.viewdata.StatusViewData
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.rx3.await
|
||||
import retrofit2.HttpException
|
||||
import retrofit2.Response
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* TimelineViewModel that caches all statuses in an in-memory list
|
||||
*/
|
||||
class NetworkTimelineViewModel @Inject constructor(
|
||||
timelineCases: TimelineCases,
|
||||
private val api: MastodonApi,
|
||||
eventHub: EventHub,
|
||||
accountManager: AccountManager,
|
||||
sharedPreferences: SharedPreferences,
|
||||
filterModel: FilterModel
|
||||
) : TimelineViewModel(timelineCases, api, eventHub, accountManager, sharedPreferences, filterModel) {
|
||||
|
||||
var currentSource: NetworkTimelinePagingSource? = null
|
||||
|
||||
val statusData: MutableList<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 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()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,316 @@
|
|||
/* Copyright 2021 Tusky Contributors
|
||||
*
|
||||
* This file is a part of Tusky.
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it under the terms of the
|
||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
|
||||
* Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
||||
* see <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.BlockEvent
|
||||
import com.keylesspalace.tusky.appstore.BookmarkEvent
|
||||
import com.keylesspalace.tusky.appstore.DomainMuteEvent
|
||||
import com.keylesspalace.tusky.appstore.Event
|
||||
import com.keylesspalace.tusky.appstore.EventHub
|
||||
import com.keylesspalace.tusky.appstore.FavoriteEvent
|
||||
import com.keylesspalace.tusky.appstore.MuteConversationEvent
|
||||
import com.keylesspalace.tusky.appstore.MuteEvent
|
||||
import com.keylesspalace.tusky.appstore.PinEvent
|
||||
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
|
||||
import com.keylesspalace.tusky.appstore.ReblogEvent
|
||||
import com.keylesspalace.tusky.appstore.StatusDeletedEvent
|
||||
import com.keylesspalace.tusky.appstore.UnfollowEvent
|
||||
import com.keylesspalace.tusky.db.AccountManager
|
||||
import com.keylesspalace.tusky.entity.Filter
|
||||
import com.keylesspalace.tusky.entity.Poll
|
||||
import com.keylesspalace.tusky.network.FilterModel
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.network.TimelineCases
|
||||
import com.keylesspalace.tusky.settings.PrefKeys
|
||||
import com.keylesspalace.tusky.viewdata.StatusViewData
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.rx3.asFlow
|
||||
import kotlinx.coroutines.rx3.await
|
||||
import retrofit2.HttpException
|
||||
import java.io.IOException
|
||||
|
||||
abstract class TimelineViewModel(
|
||||
private val timelineCases: TimelineCases,
|
||||
private val api: MastodonApi,
|
||||
private val eventHub: EventHub,
|
||||
protected val accountManager: AccountManager,
|
||||
private val sharedPreferences: SharedPreferences,
|
||||
private val filterModel: FilterModel
|
||||
) : ViewModel() {
|
||||
|
||||
abstract val statuses: Flow<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
|
||||
|
||||
fun init(
|
||||
kind: Kind,
|
||||
id: String?,
|
||||
tags: List<String>
|
||||
) {
|
||||
this.kind = kind
|
||||
this.id = id
|
||||
this.tags = tags
|
||||
|
||||
if (kind == Kind.HOME) {
|
||||
filterRemoveReplies =
|
||||
!sharedPreferences.getBoolean(PrefKeys.TAB_FILTER_HOME_REPLIES, true)
|
||||
filterRemoveReblogs =
|
||||
!sharedPreferences.getBoolean(PrefKeys.TAB_FILTER_HOME_BOOSTS, true)
|
||||
}
|
||||
this.alwaysShowSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia
|
||||
this.alwaysOpenSpoilers = accountManager.activeAccount!!.alwaysOpenSpoiler
|
||||
|
||||
viewModelScope.launch {
|
||||
eventHub.events
|
||||
.asFlow()
|
||||
.collect { event -> handleEvent(event) }
|
||||
}
|
||||
|
||||
reloadFilters()
|
||||
}
|
||||
|
||||
fun reblog(reblog: Boolean, status: StatusViewData.Concrete): Job = viewModelScope.launch {
|
||||
try {
|
||||
timelineCases.reblog(status.actionableId, reblog).await()
|
||||
} catch (t: Exception) {
|
||||
ifExpected(t) {
|
||||
Log.d(TAG, "Failed to reblog status " + status.actionableId, t)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun favorite(favorite: Boolean, status: StatusViewData.Concrete): Job = viewModelScope.launch {
|
||||
try {
|
||||
timelineCases.favourite(status.actionableId, favorite).await()
|
||||
} catch (t: Exception) {
|
||||
ifExpected(t) {
|
||||
Log.d(TAG, "Failed to favourite status " + status.actionableId, t)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun bookmark(bookmark: Boolean, status: StatusViewData.Concrete): Job = viewModelScope.launch {
|
||||
try {
|
||||
timelineCases.bookmark(status.actionableId, bookmark).await()
|
||||
} catch (t: Exception) {
|
||||
ifExpected(t) {
|
||||
Log.d(TAG, "Failed to favourite status " + status.actionableId, t)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun voteInPoll(choices: List<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 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 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
|
||||
}
|
||||
}
|
|
@ -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`)");
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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?
|
||||
}
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -35,7 +35,6 @@ import javax.inject.Singleton
|
|||
ServicesModule::class,
|
||||
BroadcastReceiverModule::class,
|
||||
ViewModelModule::class,
|
||||
RepositoryModule::class,
|
||||
MediaUploaderModule::class
|
||||
]
|
||||
)
|
||||
|
|
|
@ -85,8 +85,8 @@ class AppModule {
|
|||
AppDatabase.MIGRATION_16_17, AppDatabase.MIGRATION_17_18, AppDatabase.MIGRATION_18_19,
|
||||
AppDatabase.MIGRATION_19_20, AppDatabase.MIGRATION_20_21, AppDatabase.MIGRATION_21_22,
|
||||
AppDatabase.MIGRATION_22_23, AppDatabase.MIGRATION_23_24, AppDatabase.MIGRATION_24_25,
|
||||
AppDatabase.MIGRATION_26_27,
|
||||
AppDatabase.Migration25_26(appContext.getExternalFilesDir("Tusky"))
|
||||
AppDatabase.Migration25_26(appContext.getExternalFilesDir("Tusky")),
|
||||
AppDatabase.MIGRATION_26_27, AppDatabase.MIGRATION_27_28
|
||||
)
|
||||
.build()
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -11,7 +11,8 @@ import com.keylesspalace.tusky.components.drafts.DraftsViewModel
|
|||
import com.keylesspalace.tusky.components.report.ReportViewModel
|
||||
import com.keylesspalace.tusky.components.scheduled.ScheduledTootViewModel
|
||||
import com.keylesspalace.tusky.components.search.SearchViewModel
|
||||
import com.keylesspalace.tusky.components.timeline.TimelineViewModel
|
||||
import com.keylesspalace.tusky.components.timeline.viewmodel.CachedTimelineViewModel
|
||||
import com.keylesspalace.tusky.components.timeline.viewmodel.NetworkTimelineViewModel
|
||||
import com.keylesspalace.tusky.viewmodel.AccountViewModel
|
||||
import com.keylesspalace.tusky.viewmodel.AccountsInListViewModel
|
||||
import com.keylesspalace.tusky.viewmodel.EditProfileViewModel
|
||||
|
@ -99,8 +100,13 @@ abstract class ViewModelModule {
|
|||
|
||||
@Binds
|
||||
@IntoMap
|
||||
@ViewModelKey(TimelineViewModel::class)
|
||||
internal abstract fun timelineViewModel(viewModel: TimelineViewModel): ViewModel
|
||||
@ViewModelKey(CachedTimelineViewModel::class)
|
||||
internal abstract fun cachedTimelineViewModel(viewModel: CachedTimelineViewModel): ViewModel
|
||||
|
||||
@Binds
|
||||
@IntoMap
|
||||
@ViewModelKey(NetworkTimelineViewModel::class)
|
||||
internal abstract fun networkTimelineViewModel(viewModel: NetworkTimelineViewModel): ViewModel
|
||||
|
||||
// Add more ViewModels here
|
||||
}
|
||||
|
|
|
@ -188,7 +188,8 @@ public class NotificationsFragment extends SFragment implements
|
|||
return ViewDataUtils.notificationToViewData(
|
||||
notification,
|
||||
alwaysShowSensitiveMedia,
|
||||
alwaysOpenSpoiler
|
||||
alwaysOpenSpoiler,
|
||||
true
|
||||
);
|
||||
} else {
|
||||
return new NotificationViewData.Placeholder(input.asLeft().id, false);
|
||||
|
|
|
@ -300,7 +300,9 @@ public abstract class SFragment extends Fragment implements Injectable {
|
|||
return true;
|
||||
}
|
||||
case R.id.pin: {
|
||||
timelineCases.pin(status.getId(), !status.isPinned());
|
||||
timelineCases.pin(status.getId(), !status.isPinned())
|
||||
.to(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY)))
|
||||
.subscribe();
|
||||
return true;
|
||||
}
|
||||
case R.id.status_mute_conversation: {
|
||||
|
|
|
@ -109,7 +109,8 @@ public final class ViewThreadFragment extends SFragment implements
|
|||
return ViewDataUtils.statusToViewData(
|
||||
input,
|
||||
alwaysShowSensitiveMedia,
|
||||
alwaysOpenSpoiler
|
||||
alwaysOpenSpoiler,
|
||||
true
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
|
@ -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}")
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
)
|
||||
}
|
|
@ -0,0 +1,79 @@
|
|||
package com.keylesspalace.tusky.components.timeline
|
||||
|
||||
import android.text.SpannedString
|
||||
import com.google.gson.Gson
|
||||
import com.keylesspalace.tusky.db.TimelineStatusWithAccount
|
||||
import com.keylesspalace.tusky.entity.Account
|
||||
import com.keylesspalace.tusky.entity.Status
|
||||
import com.keylesspalace.tusky.viewdata.StatusViewData
|
||||
import java.util.ArrayList
|
||||
import java.util.Date
|
||||
|
||||
private val fixedDate = Date(1638889052000)
|
||||
|
||||
fun mockStatus(id: String = "100") = Status(
|
||||
id = id,
|
||||
url = "https://mastodon.example/@ConnyDuck/$id",
|
||||
account = Account(
|
||||
id = "1",
|
||||
localUsername = "connyduck",
|
||||
username = "connyduck@mastodon.example",
|
||||
displayName = "Conny Duck",
|
||||
note = SpannedString(""),
|
||||
url = "https://mastodon.example/@ConnyDuck",
|
||||
avatar = "https://mastodon.example/system/accounts/avatars/000/150/486/original/ab27d7ddd18a10ea.jpg",
|
||||
header = "https://mastodon.example/system/accounts/header/000/106/476/original/e590545d7eb4da39.jpg"
|
||||
),
|
||||
inReplyToId = null,
|
||||
inReplyToAccountId = null,
|
||||
reblog = null,
|
||||
content = SpannedString("Test"),
|
||||
createdAt = fixedDate,
|
||||
emojis = emptyList(),
|
||||
reblogsCount = 1,
|
||||
favouritesCount = 2,
|
||||
reblogged = false,
|
||||
favourited = true,
|
||||
bookmarked = true,
|
||||
sensitive = true,
|
||||
spoilerText = "",
|
||||
visibility = Status.Visibility.PUBLIC,
|
||||
attachments = ArrayList(),
|
||||
mentions = emptyList(),
|
||||
application = Status.Application("Tusky", "https://tusky.app"),
|
||||
pinned = false,
|
||||
muted = false,
|
||||
poll = null,
|
||||
card = null
|
||||
)
|
||||
|
||||
fun mockStatusViewData(id: String = "100") = StatusViewData.Concrete(
|
||||
status = mockStatus(id),
|
||||
isExpanded = false,
|
||||
isShowingContent = false,
|
||||
isCollapsible = false,
|
||||
isCollapsed = true,
|
||||
)
|
||||
|
||||
fun mockStatusEntityWithAccount(
|
||||
id: String = "100",
|
||||
userId: Long = 1,
|
||||
expanded: Boolean = false
|
||||
): TimelineStatusWithAccount {
|
||||
val mockedStatus = mockStatus(id)
|
||||
val gson = Gson()
|
||||
|
||||
return TimelineStatusWithAccount().apply {
|
||||
status = mockedStatus.toEntity(
|
||||
timelineUserId = userId,
|
||||
gson = gson,
|
||||
expanded = expanded,
|
||||
contentShowing = false,
|
||||
contentCollapsed = true
|
||||
)
|
||||
account = mockedStatus.account.toEntity(
|
||||
accountId = userId,
|
||||
gson = gson
|
||||
)
|
||||
}
|
||||
}
|
|
@ -1,355 +0,0 @@
|
|||
package com.keylesspalace.tusky.components.timeline
|
||||
|
||||
import android.text.SpannableString
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.google.gson.Gson
|
||||
import com.keylesspalace.tusky.db.AccountEntity
|
||||
import com.keylesspalace.tusky.db.AccountManager
|
||||
import com.keylesspalace.tusky.db.TimelineDao
|
||||
import com.keylesspalace.tusky.db.TimelineStatusWithAccount
|
||||
import com.keylesspalace.tusky.entity.Account
|
||||
import com.keylesspalace.tusky.entity.Status
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.util.Either
|
||||
import com.nhaarman.mockitokotlin2.isNull
|
||||
import com.nhaarman.mockitokotlin2.verify
|
||||
import com.nhaarman.mockitokotlin2.verifyNoMoreInteractions
|
||||
import com.nhaarman.mockitokotlin2.whenever
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.plugins.RxJavaPlugins
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import io.reactivex.rxjava3.schedulers.TestScheduler
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.mockito.ArgumentMatchers.any
|
||||
import org.mockito.ArgumentMatchers.anyInt
|
||||
import org.mockito.ArgumentMatchers.anyLong
|
||||
import org.mockito.Mock
|
||||
import org.mockito.MockitoAnnotations
|
||||
import org.robolectric.annotation.Config
|
||||
import retrofit2.Response
|
||||
import java.util.Date
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
@Config(sdk = [28])
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class TimelineRepositoryTest {
|
||||
@Mock
|
||||
lateinit var timelineDao: TimelineDao
|
||||
|
||||
@Mock
|
||||
lateinit var mastodonApi: MastodonApi
|
||||
|
||||
@Mock
|
||||
private lateinit var accountManager: AccountManager
|
||||
|
||||
private lateinit var gson: Gson
|
||||
|
||||
private lateinit var subject: TimelineRepository
|
||||
|
||||
private lateinit var testScheduler: TestScheduler
|
||||
|
||||
private val limit = 30
|
||||
private val account = AccountEntity(
|
||||
id = 2,
|
||||
accessToken = "token",
|
||||
domain = "domain.com",
|
||||
isActive = true
|
||||
)
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
MockitoAnnotations.initMocks(this)
|
||||
whenever(accountManager.activeAccount).thenReturn(account)
|
||||
|
||||
gson = Gson()
|
||||
testScheduler = TestScheduler()
|
||||
RxJavaPlugins.setIoSchedulerHandler { testScheduler }
|
||||
subject = TimelineRepositoryImpl(timelineDao, mastodonApi, accountManager, gson)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testNetworkUnbounded() {
|
||||
val statuses = listOf(
|
||||
makeStatus("3"),
|
||||
makeStatus("2")
|
||||
)
|
||||
whenever(mastodonApi.homeTimeline(isNull(), isNull(), anyInt()))
|
||||
.thenReturn(Single.just(Response.success(statuses)))
|
||||
val result = subject.getStatuses(null, null, null, limit, TimelineRequestMode.NETWORK)
|
||||
.blockingGet()
|
||||
|
||||
assertEquals(statuses.map(Status::lift), result)
|
||||
testScheduler.advanceTimeBy(100, TimeUnit.SECONDS)
|
||||
|
||||
verify(timelineDao).deleteRange(account.id, statuses.last().id, statuses.first().id)
|
||||
|
||||
verify(timelineDao).insertStatusIfNotThere(Placeholder("1").toEntity(account.id))
|
||||
for (status in statuses) {
|
||||
verify(timelineDao).insertInTransaction(
|
||||
status.toEntity(account.id, gson),
|
||||
status.account.toEntity(account.id, gson),
|
||||
null
|
||||
)
|
||||
}
|
||||
verify(timelineDao).cleanup(anyLong())
|
||||
verifyNoMoreInteractions(timelineDao)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testNetworkLoadingTopNoGap() {
|
||||
val response = listOf(
|
||||
makeStatus("4"),
|
||||
makeStatus("3"),
|
||||
makeStatus("2")
|
||||
)
|
||||
val sinceId = "2"
|
||||
val sinceIdMinusOne = "1"
|
||||
whenever(mastodonApi.homeTimeline(null, sinceIdMinusOne, limit + 1))
|
||||
.thenReturn(Single.just(Response.success(response)))
|
||||
val result = subject.getStatuses(
|
||||
null, sinceId, sinceIdMinusOne, limit,
|
||||
TimelineRequestMode.NETWORK
|
||||
)
|
||||
.blockingGet()
|
||||
|
||||
assertEquals(
|
||||
response.subList(0, 2).map(Status::lift),
|
||||
result
|
||||
)
|
||||
testScheduler.advanceTimeBy(100, TimeUnit.SECONDS)
|
||||
verify(timelineDao).deleteRange(account.id, response.last().id, response.first().id)
|
||||
// We assume for now that overlapped one is inserted but it's not that important
|
||||
for (status in response) {
|
||||
verify(timelineDao).insertInTransaction(
|
||||
status.toEntity(account.id, gson),
|
||||
status.account.toEntity(account.id, gson),
|
||||
null
|
||||
)
|
||||
}
|
||||
verify(timelineDao).removeAllPlaceholdersBetween(
|
||||
account.id, response.first().id,
|
||||
response.last().id
|
||||
)
|
||||
verify(timelineDao).cleanup(anyLong())
|
||||
verifyNoMoreInteractions(timelineDao)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testNetworkLoadingTopWithGap() {
|
||||
val response = listOf(
|
||||
makeStatus("5"),
|
||||
makeStatus("4")
|
||||
)
|
||||
val sinceId = "2"
|
||||
val sinceIdMinusOne = "1"
|
||||
whenever(mastodonApi.homeTimeline(null, sinceIdMinusOne, limit + 1))
|
||||
.thenReturn(Single.just(Response.success(response)))
|
||||
val result = subject.getStatuses(
|
||||
null, sinceId, sinceIdMinusOne, limit,
|
||||
TimelineRequestMode.NETWORK
|
||||
)
|
||||
.blockingGet()
|
||||
|
||||
val placeholder = Placeholder("3")
|
||||
assertEquals(response.map(Status::lift) + Either.Left(placeholder), result)
|
||||
testScheduler.advanceTimeBy(100, TimeUnit.SECONDS)
|
||||
verify(timelineDao).deleteRange(account.id, response.last().id, response.first().id)
|
||||
for (status in response) {
|
||||
verify(timelineDao).insertInTransaction(
|
||||
status.toEntity(account.id, gson),
|
||||
status.account.toEntity(account.id, gson),
|
||||
null
|
||||
)
|
||||
}
|
||||
verify(timelineDao).insertStatusIfNotThere(placeholder.toEntity(account.id))
|
||||
verify(timelineDao).cleanup(anyLong())
|
||||
verifyNoMoreInteractions(timelineDao)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testNetworkLoadingMiddleNoGap() {
|
||||
// Example timelne:
|
||||
// 5
|
||||
// 4
|
||||
// [gap]
|
||||
// 2
|
||||
// 1
|
||||
|
||||
val response = listOf(
|
||||
makeStatus("5"),
|
||||
makeStatus("4"),
|
||||
makeStatus("3"),
|
||||
makeStatus("2")
|
||||
)
|
||||
val sinceId = "2"
|
||||
val sinceIdMinusOne = "1"
|
||||
val maxId = "3"
|
||||
whenever(mastodonApi.homeTimeline(maxId, sinceIdMinusOne, limit + 1))
|
||||
.thenReturn(Single.just(Response.success(response)))
|
||||
val result = subject.getStatuses(
|
||||
maxId, sinceId, sinceIdMinusOne, limit,
|
||||
TimelineRequestMode.NETWORK
|
||||
)
|
||||
.blockingGet()
|
||||
|
||||
assertEquals(
|
||||
response.subList(0, response.lastIndex).map(Status::lift),
|
||||
result
|
||||
)
|
||||
testScheduler.advanceTimeBy(100, TimeUnit.SECONDS)
|
||||
verify(timelineDao).deleteRange(account.id, response.last().id, response.first().id)
|
||||
// We assume for now that overlapped one is inserted but it's not that important
|
||||
for (status in response) {
|
||||
verify(timelineDao).insertInTransaction(
|
||||
status.toEntity(account.id, gson),
|
||||
status.account.toEntity(account.id, gson),
|
||||
null
|
||||
)
|
||||
}
|
||||
verify(timelineDao).removeAllPlaceholdersBetween(
|
||||
account.id, response.first().id,
|
||||
response.last().id
|
||||
)
|
||||
verify(timelineDao).cleanup(anyLong())
|
||||
verifyNoMoreInteractions(timelineDao)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testNetworkLoadingMiddleWithGap() {
|
||||
// Example timelne:
|
||||
// 6
|
||||
// 5
|
||||
// [gap]
|
||||
// 2
|
||||
// 1
|
||||
|
||||
val response = listOf(
|
||||
makeStatus("6"),
|
||||
makeStatus("5"),
|
||||
makeStatus("4")
|
||||
)
|
||||
val sinceId = "2"
|
||||
val sinceIdMinusOne = "1"
|
||||
val maxId = "4"
|
||||
whenever(mastodonApi.homeTimeline(maxId, sinceIdMinusOne, limit + 1))
|
||||
.thenReturn(Single.just(Response.success(response)))
|
||||
val result = subject.getStatuses(
|
||||
maxId, sinceId, sinceIdMinusOne, limit,
|
||||
TimelineRequestMode.NETWORK
|
||||
)
|
||||
.blockingGet()
|
||||
|
||||
val placeholder = Placeholder("3")
|
||||
assertEquals(
|
||||
response.map(Status::lift) + Either.Left(placeholder),
|
||||
result
|
||||
)
|
||||
testScheduler.advanceTimeBy(100, TimeUnit.SECONDS)
|
||||
// We assume for now that overlapped one is inserted but it's not that important
|
||||
|
||||
verify(timelineDao).deleteRange(account.id, response.last().id, response.first().id)
|
||||
|
||||
for (status in response) {
|
||||
verify(timelineDao).insertInTransaction(
|
||||
status.toEntity(account.id, gson),
|
||||
status.account.toEntity(account.id, gson),
|
||||
null
|
||||
)
|
||||
}
|
||||
verify(timelineDao).removeAllPlaceholdersBetween(
|
||||
account.id, response.first().id,
|
||||
response.last().id
|
||||
)
|
||||
verify(timelineDao).insertStatusIfNotThere(placeholder.toEntity(account.id))
|
||||
verify(timelineDao).cleanup(anyLong())
|
||||
verifyNoMoreInteractions(timelineDao)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun addingFromDb() {
|
||||
RxJavaPlugins.setIoSchedulerHandler { Schedulers.single() }
|
||||
val status = makeStatus("2")
|
||||
val dbStatus = makeStatus("1")
|
||||
val dbResult = TimelineStatusWithAccount()
|
||||
dbResult.status = dbStatus.toEntity(account.id, gson)
|
||||
dbResult.account = status.account.toEntity(account.id, gson)
|
||||
|
||||
whenever(mastodonApi.homeTimeline(any(), any(), any()))
|
||||
.thenReturn(Single.just(Response.success((listOf(status)))))
|
||||
whenever(timelineDao.getStatusesForAccount(account.id, status.id, null, 30))
|
||||
.thenReturn(Single.just(listOf(dbResult)))
|
||||
val result = subject.getStatuses(null, null, null, limit, TimelineRequestMode.ANY)
|
||||
.blockingGet()
|
||||
assertEquals(listOf(status, dbStatus).map(Status::lift), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun addingFromDbExhausted() {
|
||||
RxJavaPlugins.setIoSchedulerHandler { Schedulers.single() }
|
||||
val status = makeStatus("4")
|
||||
val dbResult = TimelineStatusWithAccount()
|
||||
dbResult.status = Placeholder("2").toEntity(account.id)
|
||||
val dbResult2 = TimelineStatusWithAccount()
|
||||
dbResult2.status = Placeholder("1").toEntity(account.id)
|
||||
|
||||
whenever(mastodonApi.homeTimeline(any(), any(), any()))
|
||||
.thenReturn(Single.just(Response.success(listOf(status))))
|
||||
whenever(timelineDao.getStatusesForAccount(account.id, status.id, null, 30))
|
||||
.thenReturn(Single.just(listOf(dbResult, dbResult2)))
|
||||
val result = subject.getStatuses(null, null, null, limit, TimelineRequestMode.ANY)
|
||||
.blockingGet()
|
||||
assertEquals(listOf(status).map(Status::lift), result)
|
||||
}
|
||||
}
|
||||
|
||||
fun makeAccount(id: String): Account {
|
||||
return Account(
|
||||
id = id,
|
||||
localUsername = "test$id",
|
||||
username = "test$id@example.com",
|
||||
displayName = "Example Account $id",
|
||||
note = SpannableString("Note! $id"),
|
||||
url = "https://example.com/@test$id",
|
||||
avatar = "avatar$id",
|
||||
header = "Header$id",
|
||||
followersCount = 300,
|
||||
followingCount = 400,
|
||||
statusesCount = 1000,
|
||||
bot = false,
|
||||
emojis = listOf(),
|
||||
fields = null,
|
||||
source = null
|
||||
)
|
||||
}
|
||||
|
||||
fun makeStatus(id: String, account: Account = makeAccount(id)): Status {
|
||||
return Status(
|
||||
id = id,
|
||||
account = account,
|
||||
content = SpannableString("hello$id"),
|
||||
createdAt = Date(),
|
||||
emojis = listOf(),
|
||||
reblogsCount = 3,
|
||||
favouritesCount = 5,
|
||||
sensitive = false,
|
||||
visibility = Status.Visibility.PUBLIC,
|
||||
spoilerText = "",
|
||||
reblogged = true,
|
||||
favourited = false,
|
||||
bookmarked = false,
|
||||
attachments = ArrayList(),
|
||||
mentions = listOf(),
|
||||
application = null,
|
||||
inReplyToAccountId = null,
|
||||
inReplyToId = null,
|
||||
pinned = false,
|
||||
muted = false,
|
||||
reblog = null,
|
||||
url = "http://example.com/statuses/$id",
|
||||
poll = null,
|
||||
card = null
|
||||
)
|
||||
}
|
|
@ -1,792 +1,215 @@
|
|||
package com.keylesspalace.tusky.components.timeline
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import com.keylesspalace.tusky.appstore.EventHub
|
||||
import com.keylesspalace.tusky.components.timeline.TimelineViewModel.Companion.LOAD_AT_ONCE
|
||||
import android.os.Looper
|
||||
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
|
||||
import androidx.paging.AsyncPagingDataDiffer
|
||||
import androidx.paging.ExperimentalPagingApi
|
||||
import androidx.recyclerview.widget.ListUpdateCallback
|
||||
import androidx.room.Room
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.google.gson.Gson
|
||||
import com.keylesspalace.tusky.appstore.EventHubImpl
|
||||
import com.keylesspalace.tusky.components.timeline.TimelinePagingAdapter.Companion.TimelineDifferCallback
|
||||
import com.keylesspalace.tusky.components.timeline.viewmodel.CachedTimelineViewModel
|
||||
import com.keylesspalace.tusky.components.timeline.viewmodel.NetworkTimelineViewModel
|
||||
import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel
|
||||
import com.keylesspalace.tusky.db.AccountEntity
|
||||
import com.keylesspalace.tusky.db.AccountManager
|
||||
import com.keylesspalace.tusky.entity.Poll
|
||||
import com.keylesspalace.tusky.entity.PollOption
|
||||
import com.keylesspalace.tusky.entity.Status
|
||||
import com.keylesspalace.tusky.db.AppDatabase
|
||||
import com.keylesspalace.tusky.db.Converters
|
||||
import com.keylesspalace.tusky.network.FilterModel
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.network.TimelineCases
|
||||
import com.keylesspalace.tusky.util.Either
|
||||
import com.keylesspalace.tusky.util.toViewData
|
||||
import com.keylesspalace.tusky.viewdata.StatusViewData
|
||||
import com.nhaarman.mockitokotlin2.clearInvocations
|
||||
import com.keylesspalace.tusky.network.TimelineCasesImpl
|
||||
import com.nhaarman.mockitokotlin2.doReturn
|
||||
import com.nhaarman.mockitokotlin2.eq
|
||||
import com.nhaarman.mockitokotlin2.isNull
|
||||
import com.nhaarman.mockitokotlin2.mock
|
||||
import com.nhaarman.mockitokotlin2.times
|
||||
import com.nhaarman.mockitokotlin2.verify
|
||||
import com.nhaarman.mockitokotlin2.whenever
|
||||
import io.reactivex.rxjava3.annotations.NonNull
|
||||
import io.reactivex.rxjava3.core.Observable
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.observers.TestObserver
|
||||
import io.reactivex.rxjava3.subjects.PublishSubject
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.take
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.test.TestCoroutineDispatcher
|
||||
import kotlinx.coroutines.test.TestCoroutineScope
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import okhttp3.Headers
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.Shadows.shadowOf
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.shadows.ShadowLog
|
||||
import retrofit2.Response
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
@ExperimentalCoroutinesApi
|
||||
@Config(sdk = [29])
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class TimelineViewModelTest {
|
||||
lateinit var timelineRepository: TimelineRepository
|
||||
lateinit var timelineCases: TimelineCases
|
||||
lateinit var mastodonApi: MastodonApi
|
||||
lateinit var eventHub: EventHub
|
||||
lateinit var viewModel: TimelineViewModel
|
||||
lateinit var accountManager: AccountManager
|
||||
lateinit var sharedPreference: SharedPreferences
|
||||
|
||||
@get:Rule
|
||||
val instantRule = InstantTaskExecutorRule()
|
||||
|
||||
private val testDispatcher = TestCoroutineDispatcher()
|
||||
private val testScope = TestCoroutineScope(testDispatcher)
|
||||
|
||||
private val accountManager: AccountManager = mock {
|
||||
on { activeAccount } doReturn AccountEntity(
|
||||
id = 1,
|
||||
domain = "mastodon.example",
|
||||
accessToken = "token",
|
||||
isActive = true
|
||||
)
|
||||
}
|
||||
|
||||
private lateinit var db: AppDatabase
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
ShadowLog.stream = System.out
|
||||
timelineRepository = mock()
|
||||
timelineCases = mock()
|
||||
mastodonApi = mock()
|
||||
eventHub = mock {
|
||||
on { events } doReturn Observable.never()
|
||||
}
|
||||
val account = AccountEntity(
|
||||
0,
|
||||
"domain",
|
||||
"accessToken",
|
||||
isActive = true,
|
||||
)
|
||||
Dispatchers.setMain(testDispatcher)
|
||||
|
||||
accountManager = mock {
|
||||
on { activeAccount } doReturn account
|
||||
}
|
||||
sharedPreference = mock()
|
||||
viewModel = TimelineViewModel(
|
||||
timelineRepository,
|
||||
timelineCases,
|
||||
mastodonApi,
|
||||
eventHub,
|
||||
accountManager,
|
||||
sharedPreference,
|
||||
FilterModel()
|
||||
)
|
||||
shadowOf(Looper.getMainLooper()).idle()
|
||||
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
db = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java)
|
||||
.addTypeConverter(Converters(Gson()))
|
||||
.setTransactionExecutor(Executors.newSingleThreadExecutor())
|
||||
.allowMainThreadQueries()
|
||||
.build()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
Dispatchers.resetMain()
|
||||
testDispatcher.cleanupTestCoroutines()
|
||||
db.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `loadInitial, home, without cache, empty response`() {
|
||||
val initialResponse = listOf<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 = "listId"
|
||||
viewModel.init(TimelineViewModel.Kind.LIST, listId, listOf())
|
||||
val status = makeStatus("1")
|
||||
|
||||
whenever(
|
||||
mastodonApi.listTimeline(
|
||||
listId,
|
||||
null,
|
||||
null,
|
||||
LOAD_AT_ONCE,
|
||||
)
|
||||
).thenReturn(
|
||||
Single.just(
|
||||
val api: MastodonApi = mock {
|
||||
on { publicTimeline(local = true, maxId = null, sinceId = null, limit = 30) } doReturn Single.just(
|
||||
Response.success(
|
||||
listOf(
|
||||
status
|
||||
mockStatus("6"),
|
||||
mockStatus("5"),
|
||||
mockStatus("4")
|
||||
),
|
||||
Headers.headersOf(
|
||||
"Link", "<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()
|
||||
)
|
||||
|
||||
val updates = viewModel.viewUpdates.test()
|
||||
viewModel.init(TimelineViewModel.Kind.PUBLIC_LOCAL, null, emptyList())
|
||||
|
||||
runBlocking {
|
||||
viewModel.loadInitial().join()
|
||||
}
|
||||
assertViewUpdated(updates)
|
||||
|
||||
assertHasList(listOf(status).toViewData())
|
||||
assertFalse("loading", viewModel.isLoadingInitially)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `loadInitial, home, without cache, error on load`() {
|
||||
setCachedResponse(listOf())
|
||||
|
||||
whenever(
|
||||
timelineRepository.getStatuses(
|
||||
maxId = null,
|
||||
sinceId = null,
|
||||
sincedIdMinusOne = null,
|
||||
limit = LOAD_AT_ONCE,
|
||||
TimelineRequestMode.ANY,
|
||||
)
|
||||
).thenReturn(Single.error(IOException("test")))
|
||||
|
||||
val updates = viewModel.viewUpdates.test()
|
||||
|
||||
runBlocking {
|
||||
viewModel.loadInitial()
|
||||
}
|
||||
|
||||
verify(timelineRepository).getStatuses(
|
||||
isNull(),
|
||||
isNull(),
|
||||
isNull(),
|
||||
eq(LOAD_AT_ONCE),
|
||||
eq(TimelineRequestMode.ANY)
|
||||
val differ = AsyncPagingDataDiffer(
|
||||
diffCallback = TimelineDifferCallback,
|
||||
updateCallback = NoopListCallback(),
|
||||
workerDispatcher = testDispatcher
|
||||
)
|
||||
|
||||
assertViewUpdated(updates)
|
||||
|
||||
assertHasList(listOf())
|
||||
assertEquals(TimelineViewModel.FailureReason.NETWORK, viewModel.failure)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `loadInitial, home, with cache, error on load above`() {
|
||||
val statuses = (5 downTo 1).map { makeStatus(it.toString()) }
|
||||
setCachedResponse(statuses)
|
||||
setInitialRefresh("6", statuses)
|
||||
|
||||
whenever(
|
||||
timelineRepository.getStatuses(
|
||||
maxId = null,
|
||||
sinceId = "5",
|
||||
sincedIdMinusOne = "4",
|
||||
limit = LOAD_AT_ONCE,
|
||||
TimelineRequestMode.NETWORK,
|
||||
)
|
||||
).thenReturn(Single.error(IOException("test")))
|
||||
|
||||
val updates = viewModel.viewUpdates.test()
|
||||
|
||||
runBlocking {
|
||||
viewModel.loadInitial()
|
||||
viewModel.statuses.take(2).collectLatest {
|
||||
testScope.launch {
|
||||
differ.submitData(it)
|
||||
}
|
||||
}
|
||||
|
||||
assertViewUpdated(updates)
|
||||
|
||||
assertHasList(statuses.toViewData())
|
||||
// No failure set since we had statuses
|
||||
assertNull(viewModel.failure)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `loadInitial, home, with cache, error on refresh`() {
|
||||
val statuses = (5 downTo 2).map { makeStatus(it.toString()) }
|
||||
setCachedResponse(statuses)
|
||||
|
||||
// Error on refreshing cached
|
||||
whenever(
|
||||
timelineRepository.getStatuses(
|
||||
maxId = "6",
|
||||
sinceId = null,
|
||||
sincedIdMinusOne = null,
|
||||
limit = LOAD_AT_ONCE,
|
||||
TimelineRequestMode.NETWORK,
|
||||
)
|
||||
).thenReturn(Single.error(IOException("test")))
|
||||
|
||||
// Empty on loading above
|
||||
setLoadAbove("5", "4", listOf())
|
||||
|
||||
val updates = viewModel.viewUpdates.test()
|
||||
|
||||
runBlocking {
|
||||
viewModel.loadInitial()
|
||||
}
|
||||
|
||||
assertViewUpdated(updates)
|
||||
|
||||
assertHasList(statuses.toViewData())
|
||||
assertNull(viewModel.failure)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `loads above cached`() {
|
||||
val cachedStatuses = (5 downTo 1).map { makeStatus(it.toString()) }
|
||||
setCachedResponse(cachedStatuses)
|
||||
setInitialRefresh("6", cachedStatuses)
|
||||
|
||||
val additionalStatuses = (10 downTo 6)
|
||||
.map { makeStatus(it.toString()) }
|
||||
|
||||
whenever(
|
||||
timelineRepository.getStatuses(
|
||||
null,
|
||||
"5",
|
||||
"4",
|
||||
LOAD_AT_ONCE,
|
||||
TimelineRequestMode.NETWORK
|
||||
)
|
||||
).thenReturn(Single.just(additionalStatuses.toEitherList()))
|
||||
|
||||
runBlocking {
|
||||
viewModel.loadInitial()
|
||||
}
|
||||
|
||||
// We could also check refresh progress here but it's a bit cumbersome
|
||||
|
||||
assertHasList(additionalStatuses.plus(cachedStatuses).toViewData())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun refresh() {
|
||||
val cachedStatuses = (5 downTo 1).map { makeStatus(it.toString()) }
|
||||
setCachedResponse(cachedStatuses)
|
||||
setInitialRefresh("6", cachedStatuses)
|
||||
|
||||
val additionalStatuses = listOf(makeStatus("6"))
|
||||
|
||||
whenever(
|
||||
timelineRepository.getStatuses(
|
||||
null,
|
||||
"5",
|
||||
"4",
|
||||
LOAD_AT_ONCE,
|
||||
TimelineRequestMode.NETWORK
|
||||
)
|
||||
).thenReturn(Single.just(additionalStatuses.toEitherList()))
|
||||
|
||||
runBlocking {
|
||||
viewModel.loadInitial()
|
||||
}
|
||||
|
||||
clearInvocations(timelineRepository)
|
||||
|
||||
val newStatuses = (8 downTo 7).map { makeStatus(it.toString()) }
|
||||
|
||||
// Loading above the cached manually
|
||||
whenever(
|
||||
timelineRepository.getStatuses(
|
||||
null,
|
||||
"6",
|
||||
"5",
|
||||
LOAD_AT_ONCE,
|
||||
TimelineRequestMode.NETWORK
|
||||
)
|
||||
).thenReturn(Single.just(newStatuses.toEitherList()))
|
||||
|
||||
runBlocking {
|
||||
viewModel.refresh()
|
||||
}
|
||||
|
||||
val allStatuses = newStatuses + additionalStatuses + cachedStatuses
|
||||
assertHasList(allStatuses.toViewData())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `refresh failed`() {
|
||||
val cachedStatuses = (5 downTo 1).map { makeStatus(it.toString()) }
|
||||
setCachedResponse(cachedStatuses)
|
||||
setInitialRefresh("6", cachedStatuses)
|
||||
setLoadAbove("5", "4", listOf())
|
||||
|
||||
runBlocking {
|
||||
viewModel.loadInitial()
|
||||
}
|
||||
|
||||
clearInvocations(timelineRepository)
|
||||
|
||||
// Loading above the cached manually
|
||||
whenever(
|
||||
timelineRepository.getStatuses(
|
||||
null,
|
||||
"6",
|
||||
"5",
|
||||
LOAD_AT_ONCE,
|
||||
TimelineRequestMode.NETWORK
|
||||
)
|
||||
).thenReturn(Single.error(IOException("test")))
|
||||
|
||||
runBlocking {
|
||||
viewModel.refresh().join()
|
||||
}
|
||||
|
||||
assertHasList(cachedStatuses.map { it.toViewData(false, false) })
|
||||
assertFalse("refreshing", viewModel.isRefreshing)
|
||||
assertNull("failure is not set", viewModel.failure)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadMore() {
|
||||
val cachedStatuses = (10 downTo 5).map { makeStatus(it.toString()) }
|
||||
setCachedResponse(cachedStatuses)
|
||||
setInitialRefresh("11", cachedStatuses)
|
||||
|
||||
// Nothing above
|
||||
setLoadAbove("10", "9", listOf())
|
||||
|
||||
runBlocking {
|
||||
viewModel.loadInitial().join()
|
||||
}
|
||||
|
||||
clearInvocations(timelineRepository)
|
||||
|
||||
val oldStatuses = (4 downTo 1).map { makeStatus(it.toString()) }
|
||||
|
||||
// Loading below the cached
|
||||
whenever(
|
||||
timelineRepository.getStatuses(
|
||||
"5",
|
||||
null,
|
||||
null,
|
||||
LOAD_AT_ONCE,
|
||||
TimelineRequestMode.ANY
|
||||
)
|
||||
).thenReturn(Single.just(oldStatuses.toEitherList()))
|
||||
|
||||
runBlocking {
|
||||
viewModel.loadMore().join()
|
||||
}
|
||||
|
||||
val allStatuses = cachedStatuses + oldStatuses
|
||||
assertHasList(allStatuses.toViewData())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `loadMore parallel`() {
|
||||
val cachedStatuses = (10 downTo 5).map { makeStatus(it.toString()) }
|
||||
setCachedResponse(cachedStatuses)
|
||||
setInitialRefresh("11", cachedStatuses)
|
||||
|
||||
// Nothing above
|
||||
setLoadAbove("10", "9", listOf())
|
||||
|
||||
runBlocking {
|
||||
viewModel.loadInitial().join()
|
||||
}
|
||||
|
||||
clearInvocations(timelineRepository)
|
||||
|
||||
val oldStatuses = (4 downTo 1).map { makeStatus(it.toString()) }
|
||||
|
||||
val responseSubject = PublishSubject.create<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()
|
||||
)
|
||||
|
||||
viewModel.init(TimelineViewModel.Kind.HOME, null, emptyList())
|
||||
|
||||
val differ = AsyncPagingDataDiffer(
|
||||
diffCallback = TimelineDifferCallback,
|
||||
updateCallback = NoopListCallback(),
|
||||
workerDispatcher = testDispatcher
|
||||
)
|
||||
|
||||
var x = 1
|
||||
viewModel.statuses.take(1000).collectLatest {
|
||||
testScope.launch {
|
||||
differ.submitData(it)
|
||||
}
|
||||
}
|
||||
|
||||
assertEquals(
|
||||
listOf(
|
||||
mockStatusViewData("6"),
|
||||
mockStatusViewData("5"),
|
||||
mockStatusViewData("4")
|
||||
),
|
||||
differ.snapshot().items
|
||||
)
|
||||
}
|
||||
|
||||
private fun List<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) {}
|
||||
}
|
||||
|
|
331
app/src/test/java/com/keylesspalace/tusky/db/TimelineDaoTest.kt
Normal file
331
app/src/test/java/com/keylesspalace/tusky/db/TimelineDaoTest.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue