From f2529a8e617ec93db4a84bc593d76240d040725a Mon Sep 17 00:00:00 2001 From: Konrad Pozniak Date: Mon, 28 Mar 2022 18:39:16 +0200 Subject: [PATCH] Fix Timeline not loading (#2398) * fix cached timeline * fix network timeline * delete unused inc / dec extensions * fix tests and bug in network timeline * add db migration * remove unused import * commit 31.json * improve placeholder inserting logic, add comment * fix tests * improve tests --- .../31.json | 809 ++++++++++++++++++ .../viewmodel/CachedTimelineRemoteMediator.kt | 10 +- .../viewmodel/CachedTimelineViewModel.kt | 19 +- .../NetworkTimelineRemoteMediator.kt | 3 +- .../viewmodel/NetworkTimelineViewModel.kt | 14 +- .../keylesspalace/tusky/db/AppDatabase.java | 13 +- .../com/keylesspalace/tusky/db/TimelineDao.kt | 9 + .../com/keylesspalace/tusky/di/AppModule.kt | 2 +- .../keylesspalace/tusky/util/StringUtils.kt | 45 - .../keylesspalace/tusky/StringUtilsTest.kt | 40 - .../CachedTimelineRemoteMediatorTest.kt | 91 +- .../NetworkTimelineRemoteMediatorTest.kt | 3 +- 12 files changed, 938 insertions(+), 120 deletions(-) create mode 100644 app/schemas/com.keylesspalace.tusky.db.AppDatabase/31.json diff --git a/app/schemas/com.keylesspalace.tusky.db.AppDatabase/31.json b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/31.json new file mode 100644 index 000000000..c705293ce --- /dev/null +++ b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/31.json @@ -0,0 +1,809 @@ +{ + "formatVersion": 1, + "database": { + "version": 31, + "identityHash": "a75615171612bdfc9e3d4201ebf6071a", + "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" + ], + "orders": [], + "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, `minPollDuration` INTEGER, `maxPollDuration` INTEGER, `charactersReservedPerUrl` 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": "minPollDuration", + "columnName": "minPollDuration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollDuration", + "columnName": "maxPollDuration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "charactersReservedPerUrl", + "columnName": "charactersReservedPerUrl", + "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, `tags` 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": "tags", + "columnName": "tags", + "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" + ], + "orders": [], + "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_tags` TEXT, `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.tags", + "columnName": "s_tags", + "affinity": "TEXT", + "notNull": false + }, + { + "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, 'a75615171612bdfc9e3d4201ebf6071a')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt index 1cd58f0a5..c4aa2c72d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt @@ -30,7 +30,6 @@ 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 @@ -102,9 +101,14 @@ class CachedTimelineRemoteMediator( db.withTransaction { val overlappedStatuses = replaceStatusRange(statuses, state) - if (loadType == LoadType.REFRESH && overlappedStatuses == 0 && statuses.isNotEmpty() && !dbEmpty) { + /* In case we loaded a whole page and there was no overlap with existing statuses, + we insert a placeholder because there might be even more unknown statuses */ + if (loadType == LoadType.REFRESH && overlappedStatuses == 0 && statuses.size == state.config.pageSize && !dbEmpty) { + /* This overrides the last of the newly loaded statuses with a placeholder + to guarantee the placeholder has an id that exists on the server as not all + servers handle client generated ids as expected */ timelineDao.insertStatus( - Placeholder(statuses.last().id.dec(), loading = false).toEntity(activeAccount.id) + Placeholder(statuses.last().id, loading = false).toEntity(activeAccount.id) ) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt index d71da9bb7..304b4e5a0 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt @@ -41,8 +41,6 @@ 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.delay import kotlinx.coroutines.flow.map @@ -149,9 +147,11 @@ class CachedTimelineViewModel @Inject constructor( timelineDao.insertStatus(Placeholder(placeholderId, loading = true).toEntity(activeAccount.id)) - val nextPlaceholderId = timelineDao.getNextPlaceholderIdAfter(activeAccount.id, placeholderId) - - val response = api.homeTimeline(maxId = placeholderId.inc(), sinceId = nextPlaceholderId, limit = LOAD_AT_ONCE).await() + val response = db.withTransaction { + val idAbovePlaceholder = timelineDao.getIdAbove(activeAccount.id, placeholderId) + val nextPlaceholderId = timelineDao.getNextPlaceholderIdAfter(activeAccount.id, placeholderId) + api.homeTimeline(maxId = idAbovePlaceholder, sinceId = nextPlaceholderId, limit = LOAD_AT_ONCE) + }.await() val statuses = response.body() if (!response.isSuccessful || statuses == null) { @@ -185,9 +185,14 @@ class CachedTimelineViewModel @Inject constructor( ) } - if (overlappedStatuses == 0 && statuses.isNotEmpty()) { + /* In case we loaded a whole page and there was no overlap with existing statuses, + we insert a placeholder because there might be even more unknown statuses */ + if (overlappedStatuses == 0 && statuses.size == LOAD_AT_ONCE) { + /* This overrides the last of the newly loaded statuses with a placeholder + to guarantee the placeholder has an id that exists on the server as not all + servers handle client generated ids as expected */ timelineDao.insertStatus( - Placeholder(statuses.last().id.dec(), loading = false).toEntity(activeAccount.id) + Placeholder(statuses.last().id, loading = false).toEntity(activeAccount.id) ) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt index 7a4c779d8..82cfd41d4 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt @@ -22,7 +22,6 @@ import androidx.paging.RemoteMediator import com.keylesspalace.tusky.components.timeline.util.ifExpected 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 @@ -93,7 +92,7 @@ class NetworkTimelineRemoteMediator( viewModel.statusData.addAll(0, data) if (insertPlaceholder) { - viewModel.statusData.add(statuses.size, StatusViewData.Placeholder(statuses.last().id.dec(), false)) + viewModel.statusData[statuses.size - 1] = StatusViewData.Placeholder(statuses.last().id, false) } } else { val linkHeader = statusResponse.headers()["Link"] diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt index 7a6df9d26..f70fdcc81 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt @@ -35,9 +35,7 @@ import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.network.FilterModel import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.TimelineCases -import com.keylesspalace.tusky.util.dec import com.keylesspalace.tusky.util.getDomain -import com.keylesspalace.tusky.util.inc import com.keylesspalace.tusky.util.isLessThan import com.keylesspalace.tusky.util.isLessThanOrEqual import com.keylesspalace.tusky.util.toViewData @@ -142,8 +140,10 @@ class NetworkTimelineViewModel @Inject constructor( statusData.indexOfFirst { it is StatusViewData.Placeholder && it.id == placeholderId } statusData[placeholderIndex] = StatusViewData.Placeholder(placeholderId, isLoading = true) + val idAbovePlaceholder = statusData.getOrNull(placeholderIndex - 1)?.id + val statusResponse = fetchStatusesForKind( - fromId = placeholderId.inc(), + fromId = idAbovePlaceholder, uptoId = null, limit = 20 ) @@ -157,7 +157,7 @@ class NetworkTimelineViewModel @Inject constructor( statusData.removeAt(placeholderIndex) val activeAccount = accountManager.activeAccount!! - val data = statuses.map { status -> + val data: MutableList = statuses.map { status -> status.toViewData( isShowingContent = activeAccount.alwaysShowSensitiveMedia || !status.actionableStatus.sensitive, isExpanded = activeAccount.alwaysOpenSpoiler, @@ -175,7 +175,7 @@ class NetworkTimelineViewModel @Inject constructor( data.mapIndexed { i, status -> i to statusData.firstOrNull { it.asStatusOrNull()?.id == status.id }?.asStatusOrNull() } .filter { (_, oldStatus) -> oldStatus != null } .forEach { (i, oldStatus) -> - data[i] = data[i] + data[i] = data[i].asStatusOrNull()!! .copy( isShowingContent = oldStatus!!.isShowingContent, isExpanded = oldStatus.isExpanded, @@ -190,7 +190,7 @@ class NetworkTimelineViewModel @Inject constructor( } } } else { - statusData.add(overlappedFrom, StatusViewData.Placeholder(statuses.last().id.dec(), isLoading = false)) + data[data.size - 1] = StatusViewData.Placeholder(statuses.last().id, isLoading = false) } } @@ -240,7 +240,7 @@ class NetworkTimelineViewModel @Inject constructor( } override fun fullReload() { - nextKey = statusData.firstOrNull { it is StatusViewData.Concrete }?.asStatusOrNull()?.id?.inc() + nextKey = statusData.firstOrNull { it is StatusViewData.Concrete }?.asStatusOrNull()?.id statusData.clear() currentSource?.invalidate() } diff --git a/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java b/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java index 0ab627d54..159a6f529 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java +++ b/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java @@ -29,10 +29,9 @@ import java.io.File; /** * DB version & declare DAO */ - @Database(entities = { DraftEntity.class, AccountEntity.class, InstanceEntity.class, TimelineStatusEntity.class, TimelineAccountEntity.class, ConversationEntity.class - }, version = 30) + }, version = 31) public abstract class AppDatabase extends RoomDatabase { public abstract AccountDao accountDao(); @@ -474,4 +473,14 @@ public abstract class AppDatabase extends RoomDatabase { database.execSQL("ALTER TABLE `InstanceEntity` ADD COLUMN `maxPollDuration` INTEGER"); } }; + + public static final Migration MIGRATION_30_31 = new Migration(30, 31) { + @Override + public void migrate(@NonNull SupportSQLiteDatabase database) { + + // no actual scheme change, but placeholder ids are now used differently so the cache needs to be cleared to avoid bugs + database.execSQL("DELETE FROM `TimelineAccountEntity`"); + database.execSQL("DELETE FROM `TimelineStatusEntity`"); + } + }; } diff --git a/app/src/main/java/com/keylesspalace/tusky/db/TimelineDao.kt b/app/src/main/java/com/keylesspalace/tusky/db/TimelineDao.kt index 7915ff998..dd59f2a3e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/TimelineDao.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/TimelineDao.kt @@ -186,6 +186,15 @@ AND timelineUserId = :accountId @Query("SELECT serverId FROM TimelineStatusEntity WHERE timelineUserId = :accountId AND authorServerId IS NULL ORDER BY LENGTH(serverId) DESC, serverId DESC LIMIT 1") abstract suspend fun getTopPlaceholderId(accountId: Long): String? + /** + * Returns the id directly above [serverId], or null if [serverId] is the id of the top status + */ + @Query("SELECT serverId FROM TimelineStatusEntity WHERE timelineUserId = :accountId AND (LENGTH(:serverId) < LENGTH(serverId) OR (LENGTH(:serverId) = LENGTH(serverId) AND :serverId < serverId)) ORDER BY LENGTH(serverId) ASC, serverId ASC LIMIT 1") + abstract suspend fun getIdAbove(accountId: Long, serverId: String): String? + + /** + * Returns the id of the next placeholder after [serverId] + */ @Query("SELECT serverId FROM TimelineStatusEntity WHERE timelineUserId = :accountId AND authorServerId IS NULL AND (LENGTH(:serverId) > LENGTH(serverId) OR (LENGTH(:serverId) = LENGTH(serverId) AND :serverId > serverId)) ORDER BY LENGTH(serverId) DESC, serverId DESC LIMIT 1") abstract suspend fun getNextPlaceholderIdAfter(accountId: Long, serverId: String): String? } diff --git a/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt index fb0bd650e..b0f28261e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt @@ -62,7 +62,7 @@ class AppModule { AppDatabase.MIGRATION_22_23, AppDatabase.MIGRATION_23_24, AppDatabase.MIGRATION_24_25, AppDatabase.Migration25_26(appContext.getExternalFilesDir("Tusky")), AppDatabase.MIGRATION_26_27, AppDatabase.MIGRATION_27_28, AppDatabase.MIGRATION_28_29, - AppDatabase.MIGRATION_29_30 + AppDatabase.MIGRATION_29_30, AppDatabase.MIGRATION_30_31 ) .build() } diff --git a/app/src/main/java/com/keylesspalace/tusky/util/StringUtils.kt b/app/src/main/java/com/keylesspalace/tusky/util/StringUtils.kt index 4ce67511d..7a4a3659d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/StringUtils.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/StringUtils.kt @@ -16,51 +16,6 @@ fun randomAlphanumericString(count: Int): String { return String(chars) } -// We sort statuses by ID. Something we need to invent some ID for placeholder. - -/** - * "Increment" string so that during sorting it's bigger than [this]. Inverse operation to [dec]. - */ -fun String.inc(): String { - val builder = this.toCharArray() - var i = builder.lastIndex - - while (i >= 0) { - if (builder[i] < 'z') { - builder[i] = builder[i].inc() - return String(builder) - } else { - builder[i] = '0' - } - i-- - } - return String( - CharArray(builder.size + 1) { index -> - if (index == 0) '0' else builder[index - 1] - } - ) -} - -/** - * "Decrement" string so that during sorting it's smaller than [this]. Inverse operation to [inc]. - */ -fun String.dec(): String { - if (this.isEmpty()) return this - - val builder = this.toCharArray() - var i = builder.lastIndex - while (i >= 0) { - if (builder[i] > '0') { - builder[i] = builder[i].dec() - return String(builder) - } else { - builder[i] = 'z' - } - i-- - } - return String(builder.copyOfRange(1, builder.size)) -} - /** * A < B (strictly) by length and then by content. * Examples: diff --git a/app/src/test/java/com/keylesspalace/tusky/StringUtilsTest.kt b/app/src/test/java/com/keylesspalace/tusky/StringUtilsTest.kt index c2809eb8e..6910a365f 100644 --- a/app/src/test/java/com/keylesspalace/tusky/StringUtilsTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/StringUtilsTest.kt @@ -1,10 +1,7 @@ package com.keylesspalace.tusky -import com.keylesspalace.tusky.util.dec -import com.keylesspalace.tusky.util.inc import com.keylesspalace.tusky.util.isLessThan import com.keylesspalace.tusky.util.isLessThanOrEqual -import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test @@ -38,41 +35,4 @@ class StringUtilsTest { val notLessList = lessList.filterNot { (l, r) -> l == r }.map { (l, r) -> r to l } notLessList.forEach { (l, r) -> assertFalse("not $l < $r", l.isLessThanOrEqual(r)) } } - - @Test - fun inc() { - listOf( - "10786565059022968z" to "107865650590229690", - "122" to "123", - "12A" to "12B", - "11z" to "120", - "0zz" to "100", - "zz" to "000", - "4zzbz" to "4zzc0", - "" to "0", - "1" to "2", - "0" to "1", - "AGdxwSQqT3pW4xrLJA" to "AGdxwSQqT3pW4xrLJB", - "AGdfqi1HnlBFVl0tkz" to "AGdfqi1HnlBFVl0tl0" - ).forEach { (l, r) -> assertEquals("$l + 1 = $r", r, l.inc()) } - } - - @Test - fun dec() { - listOf( - "" to "", - "107865650590229690" to "10786565059022968z", - "123" to "122", - "12B" to "12A", - "120" to "11z", - "100" to "0zz", - "000" to "zz", - "4zzc0" to "4zzbz", - "0" to "", - "2" to "1", - "1" to "0", - "AGdxwSQqT3pW4xrLJB" to "AGdxwSQqT3pW4xrLJA", - "AGdfqi1HnlBFVl0tl0" to "AGdfqi1HnlBFVl0tkz" - ).forEach { (l, r) -> assertEquals("$l - 1 = $r", r, l.dec()) } - } } diff --git a/app/src/test/java/com/keylesspalace/tusky/components/timeline/CachedTimelineRemoteMediatorTest.kt b/app/src/test/java/com/keylesspalace/tusky/components/timeline/CachedTimelineRemoteMediatorTest.kt index 4fbeb5d4b..462b0a4a0 100644 --- a/app/src/test/java/com/keylesspalace/tusky/components/timeline/CachedTimelineRemoteMediatorTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/components/timeline/CachedTimelineRemoteMediatorTest.kt @@ -139,7 +139,75 @@ class CachedTimelineRemoteMediatorTest { @Test @ExperimentalPagingApi - fun `should refresh and insert placeholder`() { + fun `should refresh and insert placeholder when a whole page with no overlap to existing statuses is loaded`() { + + val statusesAlreadyInDb = listOf( + mockStatusEntityWithAccount("3"), + mockStatusEntityWithAccount("2"), + mockStatusEntityWithAccount("1"), + ) + + db.insert(statusesAlreadyInDb) + + val remoteMediator = CachedTimelineRemoteMediator( + accountManager = accountManager, + api = mock { + on { homeTimeline(limit = 3) } doReturn Single.just( + Response.success( + listOf( + mockStatus("8"), + mockStatus("7"), + mockStatus("5") + ) + ) + ) + on { homeTimeline(maxId = "3", limit = 3) } doReturn Single.just( + Response.success( + listOf( + mockStatus("3"), + mockStatus("2"), + mockStatus("1") + ) + ) + ) + }, + db = db, + gson = Gson() + ) + + val state = state( + pages = listOf( + PagingSource.LoadResult.Page( + data = statusesAlreadyInDb, + prevKey = null, + nextKey = 0 + ) + ), + pageSize = 3 + ) + + 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"), + TimelineStatusWithAccount().apply { + status = Placeholder("5", loading = false).toEntity(1) + }, + mockStatusEntityWithAccount("3"), + mockStatusEntityWithAccount("2"), + mockStatusEntityWithAccount("1"), + ) + ) + } + + @Test + @ExperimentalPagingApi + fun `should refresh and not insert placeholder when less than a whole page is loaded`() { val statusesAlreadyInDb = listOf( mockStatusEntityWithAccount("3"), @@ -176,7 +244,7 @@ class CachedTimelineRemoteMediatorTest { ) val state = state( - listOf( + pages = listOf( PagingSource.LoadResult.Page( data = statusesAlreadyInDb, prevKey = null, @@ -195,9 +263,6 @@ class CachedTimelineRemoteMediatorTest { mockStatusEntityWithAccount("8"), mockStatusEntityWithAccount("7"), mockStatusEntityWithAccount("5"), - TimelineStatusWithAccount().apply { - status = Placeholder("4", loading = false).toEntity(1) - }, mockStatusEntityWithAccount("3"), mockStatusEntityWithAccount("2"), mockStatusEntityWithAccount("1"), @@ -207,7 +272,7 @@ class CachedTimelineRemoteMediatorTest { @Test @ExperimentalPagingApi - fun `should refresh and not insert placeholders`() { + fun `should refresh and not insert placeholders when there is overlap with existing statuses`() { val statusesAlreadyInDb = listOf( mockStatusEntityWithAccount("3"), @@ -220,7 +285,7 @@ class CachedTimelineRemoteMediatorTest { val remoteMediator = CachedTimelineRemoteMediator( accountManager = accountManager, api = mock { - on { homeTimeline(limit = 20) } doReturn Single.just( + on { homeTimeline(limit = 3) } doReturn Single.just( Response.success( listOf( mockStatus("6"), @@ -229,7 +294,7 @@ class CachedTimelineRemoteMediatorTest { ) ) ) - on { homeTimeline(maxId = "3", limit = 20) } doReturn Single.just( + on { homeTimeline(maxId = "3", limit = 3) } doReturn Single.just( Response.success( listOf( mockStatus("3"), @@ -250,7 +315,8 @@ class CachedTimelineRemoteMediatorTest { prevKey = null, nextKey = 0 ) - ) + ), + pageSize = 3 ) val result = runBlocking { remoteMediator.load(LoadType.REFRESH, state) } @@ -487,11 +553,14 @@ class CachedTimelineRemoteMediatorTest { ) } - private fun state(pages: List> = emptyList()) = PagingState( + private fun state( + pages: List> = emptyList(), + pageSize: Int = 20 + ) = PagingState( pages = pages, anchorPosition = null, config = PagingConfig( - pageSize = 20 + pageSize = pageSize ), leadingPlaceholderCount = 0 ) diff --git a/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRemoteMediatorTest.kt b/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRemoteMediatorTest.kt index 456171efc..74d0fe257 100644 --- a/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRemoteMediatorTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRemoteMediatorTest.kt @@ -217,8 +217,7 @@ class NetworkTimelineRemoteMediatorTest { val newStatusData = mutableListOf( mockStatusViewData("10"), mockStatusViewData("9"), - mockStatusViewData("7"), - StatusViewData.Placeholder("6", false), + StatusViewData.Placeholder("7", false), mockStatusViewData("3"), mockStatusViewData("2"), mockStatusViewData("1"),