mirror of https://github.com/tuskyapp/Tusky.git
Browse Source
* 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 improvementpull/2216/head
41 changed files with 3998 additions and 3125 deletions
@ -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) |
||||
} |
||||
@ -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(); |
||||
} |
||||
} |
||||
@ -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 |
||||
} |
||||
} |
||||
@ -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) |
||||
} |
||||
} |
||||
@ -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 |
||||
|
||||
@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, |
||||
) |
||||
|
||||
accountManager = mock { |
||||
on { activeAccount } doReturn account |
||||
} |
||||
sharedPreference = mock() |
||||
viewModel = TimelineViewModel( |
||||
timelineRepository, |
||||
timelineCases, |
||||
mastodonApi, |
||||
eventHub, |
||||
accountManager, |
||||
sharedPreference, |
||||
FilterModel() |
||||
) |
||||
} |
||||
|
||||
@Test |
||||
fun `loadInitial, home, without cache, empty response`() { |
||||
val initialResponse = listOf<Status>() |
||||
setCachedResponse(initialResponse) |
||||
|
||||
// loadAbove -> loadBelow |
||||
whenever( |
||||
timelineRepository.getStatuses( |
||||
maxId = null, |
||||
sinceId = null, |
||||
sincedIdMinusOne = null, |
||||
requestMode = TimelineRequestMode.ANY, |
||||
limit = LOAD_AT_ONCE |
||||
) |
||||
).thenReturn(Single.just(listOf())) |
||||
@get:Rule |
||||
val instantRule = InstantTaskExecutorRule() |
||||
|
||||
runBlocking { |
||||
viewModel.loadInitial() |
||||
} |
||||
private val testDispatcher = TestCoroutineDispatcher() |
||||
private val testScope = TestCoroutineScope(testDispatcher) |
||||
|
||||
verify(timelineRepository).getStatuses( |
||||
null, |
||||
null, |
||||
null, |
||||
LOAD_AT_ONCE, |
||||
TimelineRequestMode.ANY |
||||
private val accountManager: AccountManager = mock { |
||||
on { activeAccount } doReturn AccountEntity( |
||||
id = 1, |
||||
domain = "mastodon.example", |
||||
accessToken = "token", |
||||
isActive = true |
||||
) |
||||
} |
||||
|
||||
@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() |
||||
private lateinit var db: AppDatabase |
||||
|
||||
runBlocking { |
||||
viewModel.loadInitial() |
||||
} |
||||
@Before |
||||
fun setup() { |
||||
Dispatchers.setMain(testDispatcher) |
||||
|
||||
verify(timelineRepository).getStatuses( |
||||
isNull(), |
||||
isNull(), |
||||
isNull(), |
||||
eq(LOAD_AT_ONCE), |
||||
eq(TimelineRequestMode.ANY) |
||||
) |
||||
shadowOf(Looper.getMainLooper()).idle() |
||||
|
||||
assertViewUpdated(updates) |
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext |
||||
db = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java) |
||||
.addTypeConverter(Converters(Gson())) |
||||
.setTransactionExecutor(Executors.newSingleThreadExecutor()) |
||||
.allowMainThreadQueries() |
||||
.build() |
||||
} |
||||
|
||||
assertHasList(listOf(status).toViewData()) |
||||
@After |
||||
fun tearDown() { |
||||
Dispatchers.resetMain() |
||||
testDispatcher.cleanupTestCoroutines() |
||||
db.close() |
||||
} |
||||
|
||||
@Test |
||||
fun `loadInitial, list`() { |
||||
val listId = "listId" |
||||
viewModel.init(TimelineViewModel.Kind.LIST, listId, listOf()) |
||||
val status = makeStatus("1") |
||||
@ExperimentalPagingApi |
||||
fun shouldLoadNetworkTimeline() = runBlocking { |
||||
|
||||
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\"" |
||||
) |
||||
) |
||||
) |
||||
) |
||||
|
||||
val updates = viewModel.viewUpdates.test() |
||||
|
||||
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) |
||||
) |
||||
|
||||
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() |
||||
} |
||||
|
||||
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 |
||||
on { publicTimeline(local = true, maxId = "1", sinceId = null, limit = 30) } doReturn Single.just( |
||||
Response.success(emptyList()) |
||||
) |
||||
).thenReturn(Single.just(oldStatuses.toEitherList())) |
||||
|
||||
runBlocking { |
||||
viewModel.loadMore().join() |
||||
on { getFilters() } doReturn Single.just(emptyList()) |
||||
} |
||||
|
||||
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 |
||||
val viewModel = NetworkTimelineViewModel( |
||||
TimelineCasesImpl(api, EventHubImpl), |
||||
api, |
||||
EventHubImpl, |
||||
accountManager, |
||||
mock(), |
||||
FilterModel() |
||||
) |
||||
} |
||||
|
||||
@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()) |
||||
viewModel.init(TimelineViewModel.Kind.PUBLIC_LOCAL, null, emptyList()) |
||||
|
||||
// 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), |
||||
val differ = AsyncPagingDataDiffer( |
||||
diffCallback = TimelineDifferCallback, |
||||
updateCallback = NoopListCallback(), |
||||
workerDispatcher = testDispatcher |
||||
) |
||||
|
||||
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() |
||||
viewModel.statuses.take(2).collectLatest { |
||||
testScope.launch { |
||||
differ.submitData(it) |
||||
} |
||||
} |
||||
|
||||
assertHasList( |
||||
assertEquals( |
||||
listOf( |
||||
status5, |
||||
status4, |
||||
status3, |
||||
status1 |
||||
).toViewData() |
||||
mockStatusViewData("6"), |
||||
mockStatusViewData("5"), |
||||
mockStatusViewData("4") |
||||
), |
||||
differ.snapshot().items |
||||
) |
||||
} |
||||
|
||||
@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) |
||||
// ToDo: Find out why Room & coroutines are not playing nice here |
||||
// @Test |
||||
@ExperimentalPagingApi |
||||
fun shouldLoadCachedTimeline() = runBlocking { |
||||
|
||||
setLoadAbove("5", items = listOf()) |
||||
|
||||
whenever( |
||||
timelineRepository.getStatuses( |
||||
"5", |
||||
"1", |
||||
null, |
||||
LOAD_AT_ONCE, |
||||
TimelineRequestMode.NETWORK |
||||
val api: MastodonApi = mock { |
||||
on { homeTimeline(limit = 30) } doReturn Single.just( |
||||
Response.success( |
||||
listOf( |
||||
mockStatus("6"), |
||||
mockStatus("5"), |
||||
mockStatus("4") |
||||
) |
||||
) |
||||
) |
||||
).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), |
||||
on { homeTimeline(maxId = "1", sinceId = null, limit = 30) } doReturn Single.just( |
||||
Response.success(emptyList()) |
||||
) |
||||
) |
||||
} |
||||
|
||||
@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() |
||||
on { getFilters() } doReturn Single.just(emptyList()) |
||||
} |
||||
|
||||
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 viewModel = CachedTimelineViewModel( |
||||
TimelineCasesImpl(api, EventHubImpl), |
||||
api, |
||||
EventHubImpl, |
||||
accountManager, |
||||
mock(), |
||||
FilterModel(), |
||||
db, |
||||
Gson() |
||||
) |
||||
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() } |
||||
viewModel.init(TimelineViewModel.Kind.HOME, null, emptyList()) |
||||
|
||||
val votedPoll = poll.votedCopy(listOf(0)) |
||||
whenever(timelineCases.voteInPoll("4", poll.id, listOf(0))) |
||||
.thenReturn(Single.just(votedPoll)) |
||||
val differ = AsyncPagingDataDiffer( |
||||
diffCallback = TimelineDifferCallback, |
||||
updateCallback = NoopListCallback(), |
||||
workerDispatcher = testDispatcher |
||||
) |
||||
|
||||
runBlocking { |
||||
viewModel.voteInPoll(1, listOf(0)).join() |
||||
var x = 1 |
||||
viewModel.statuses.take(1000).collectLatest { |
||||
testScope.launch { |
||||
differ.submitData(it) |
||||
} |
||||
} |
||||
|
||||
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() |
||||
) |
||||
} |
||||
|
||||
private fun assertViewUpdated(updates: @NonNull TestObserver<Unit>) { |
||||
assertTrue("There were view updates", updates.values().isNotEmpty()) |
||||
} |
||||
|
||||
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) |
||||
) |
||||
) |
||||
.thenReturn(Single.just(initialResponse)) |
||||
} |
||||
|
||||
private fun setInitialRefreshWithGaps(maxId: String?, statuses: List<TimelineStatus>) { |
||||
whenever( |
||||
timelineRepository.getStatuses( |
||||
maxId, |
||||
null, |
||||
null, |
||||
LOAD_AT_ONCE, |
||||
TimelineRequestMode.NETWORK |
||||
) |
||||
).thenReturn(Single.just(statuses)) |
||||
} |
||||
|
||||
private fun List<Status>.toViewData(): List<StatusViewData> = map { |
||||
it.toViewData( |
||||
alwaysShowSensitiveMedia = false, |
||||
alwaysOpenSpoiler = false |
||||
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) {} |
||||
} |
||||
|
||||
@ -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 new issue