From 68f816d938ee202372801546aa8ab0c782ebd4dd Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Sat, 25 Jan 2025 14:21:21 +0100 Subject: [PATCH 01/19] refactor: Rename TimelineStatusEntity to StatusEntity --- .../viewmodel/CachedTimelineRemoteMediator.kt | 4 +- .../app/pachli/network/ContentFilterModel.kt | 4 +- .../components/timeline/StatusMocker.kt | 4 +- .../NotificationsRemoteMediator.kt | 6 +- .../15.json | 1980 +++++++++++++++++ .../app/pachli/core/database/AppDatabase.kt | 11 +- .../core/database/dao/NotificationDao.kt | 2 +- .../pachli/core/database/dao/TimelineDao.kt | 54 +- ...imelineStatusEntity.kt => StatusEntity.kt} | 10 +- .../database/model/TranslatedStatusEntity.kt | 2 +- .../core/database/dao/TimelineDaoTest.kt | 8 +- 11 files changed, 2035 insertions(+), 50 deletions(-) create mode 100644 core/database/schemas/app.pachli.core.database.AppDatabase/15.json rename core/database/src/main/kotlin/app/pachli/core/database/model/{TimelineStatusEntity.kt => StatusEntity.kt} (98%) diff --git a/app/src/main/java/app/pachli/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt b/app/src/main/java/app/pachli/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt index 5bb327072f..520aa8a75b 100644 --- a/app/src/main/java/app/pachli/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt +++ b/app/src/main/java/app/pachli/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt @@ -26,8 +26,8 @@ import app.pachli.core.database.dao.TimelineDao import app.pachli.core.database.di.TransactionProvider import app.pachli.core.database.model.RemoteKeyEntity import app.pachli.core.database.model.RemoteKeyEntity.RemoteKeyKind +import app.pachli.core.database.model.StatusEntity import app.pachli.core.database.model.TimelineAccountEntity -import app.pachli.core.database.model.TimelineStatusEntity import app.pachli.core.database.model.TimelineStatusWithAccount import app.pachli.core.network.model.Links import app.pachli.core.network.model.Status @@ -259,7 +259,7 @@ class CachedTimelineRemoteMediator( } timelineDao.upsertAccounts(accounts.map { TimelineAccountEntity.from(it, pachliAccountId) }) - timelineDao.upsertStatuses(statuses.map { TimelineStatusEntity.from(it, pachliAccountId) }) + timelineDao.upsertStatuses(statuses.map { StatusEntity.from(it, pachliAccountId) }) } companion object { diff --git a/app/src/main/java/app/pachli/network/ContentFilterModel.kt b/app/src/main/java/app/pachli/network/ContentFilterModel.kt index 82fdc3b52e..171666c4eb 100644 --- a/app/src/main/java/app/pachli/network/ContentFilterModel.kt +++ b/app/src/main/java/app/pachli/network/ContentFilterModel.kt @@ -1,7 +1,7 @@ package app.pachli.network import app.pachli.core.data.model.from -import app.pachli.core.database.model.TimelineStatusEntity +import app.pachli.core.database.model.StatusEntity import app.pachli.core.model.ContentFilter import app.pachli.core.model.FilterAction import app.pachli.core.model.FilterContext @@ -63,7 +63,7 @@ class ContentFilterModel(private val filterContext: FilterContext, v1ContentFilt } /** @return the [FilterAction] that should be applied to this status */ - fun filterActionFor(status: TimelineStatusEntity): FilterAction { + fun filterActionFor(status: StatusEntity): FilterAction { pattern?.let { pat -> // Patterns are expensive and thread-safe, matchers are neither. val matcher = pat.matcher("") ?: return FilterAction.NONE diff --git a/app/src/test/java/app/pachli/components/timeline/StatusMocker.kt b/app/src/test/java/app/pachli/components/timeline/StatusMocker.kt index e0aca2a724..b9dcc125c3 100644 --- a/app/src/test/java/app/pachli/components/timeline/StatusMocker.kt +++ b/app/src/test/java/app/pachli/components/timeline/StatusMocker.kt @@ -1,9 +1,9 @@ package app.pachli.components.timeline import app.pachli.core.data.model.StatusViewData +import app.pachli.core.database.model.StatusEntity import app.pachli.core.database.model.StatusViewDataEntity import app.pachli.core.database.model.TimelineAccountEntity -import app.pachli.core.database.model.TimelineStatusEntity import app.pachli.core.database.model.TimelineStatusWithAccount import app.pachli.core.database.model.TranslationState import app.pachli.core.network.model.Status @@ -99,7 +99,7 @@ fun mockStatusEntityWithAccount( val mockedStatus = mockStatus(id) return TimelineStatusWithAccount( - status = TimelineStatusEntity.from( + status = StatusEntity.from( mockedStatus, timelineUserId = userId, ), diff --git a/core/data/src/main/kotlin/app/pachli/core/data/repository/notifications/NotificationsRemoteMediator.kt b/core/data/src/main/kotlin/app/pachli/core/data/repository/notifications/NotificationsRemoteMediator.kt index 60fab301ba..613120c47f 100644 --- a/core/data/src/main/kotlin/app/pachli/core/data/repository/notifications/NotificationsRemoteMediator.kt +++ b/core/data/src/main/kotlin/app/pachli/core/data/repository/notifications/NotificationsRemoteMediator.kt @@ -32,8 +32,8 @@ import app.pachli.core.database.model.NotificationRelationshipSeveranceEventEnti import app.pachli.core.database.model.NotificationReportEntity import app.pachli.core.database.model.RemoteKeyEntity import app.pachli.core.database.model.RemoteKeyEntity.RemoteKeyKind +import app.pachli.core.database.model.StatusEntity import app.pachli.core.database.model.TimelineAccountEntity -import app.pachli.core.database.model.TimelineStatusEntity import app.pachli.core.database.model.TimelineStatusWithAccount import app.pachli.core.network.model.Links import app.pachli.core.network.model.Notification @@ -282,7 +282,7 @@ class NotificationsRemoteMediator( // Bulk upsert the discovered items. timelineDao.upsertAccounts(accounts.map { TimelineAccountEntity.from(it, pachliAccountId) }) - timelineDao.upsertStatuses(statuses.map { TimelineStatusEntity.from(it, pachliAccountId) }) + timelineDao.upsertStatuses(statuses.map { StatusEntity.from(it, pachliAccountId) }) notificationDao.upsertReports(reports.mapNotNull { NotificationReportEntity.from(pachliAccountId, it) }) notificationDao.upsertEvents( severanceEvents.mapNotNull { @@ -303,7 +303,7 @@ fun NotificationData.Companion.from(pachliAccountId: Long, notification: Notific account = TimelineAccountEntity.from(notification.account, pachliAccountId), status = notification.status?.let { status -> TimelineStatusWithAccount( - status = TimelineStatusEntity.from(status, pachliAccountId), + status = StatusEntity.from(status, pachliAccountId), account = TimelineAccountEntity.from(status.account, pachliAccountId), ) }, diff --git a/core/database/schemas/app.pachli.core.database.AppDatabase/15.json b/core/database/schemas/app.pachli.core.database.AppDatabase/15.json new file mode 100644 index 0000000000..c310f80e91 --- /dev/null +++ b/core/database/schemas/app.pachli.core.database.AppDatabase/15.json @@ -0,0 +1,1980 @@ +{ + "formatVersion": 1, + "database": { + "version": 15, + "identityHash": "3964d1676b1ccf34735e1c8c54e90313", + "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, `failedToSendNew` INTEGER NOT NULL, `scheduledAt` INTEGER, `language` TEXT, `statusId` TEXT, FOREIGN KEY(`accountId`) REFERENCES `AccountEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "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 + }, + { + "fieldPath": "failedToSendNew", + "columnName": "failedToSendNew", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "scheduledAt", + "columnName": "scheduledAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "statusId", + "columnName": "statusId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_DraftEntity_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DraftEntity_accountId` ON `${TABLE_NAME}` (`accountId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "accountId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "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, `clientId` TEXT NOT NULL, `clientSecret` TEXT NOT NULL, `isActive` INTEGER NOT NULL, `accountId` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `profilePictureUrl` TEXT NOT NULL, `profileHeaderPictureUrl` TEXT NOT NULL DEFAULT '', `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, `notificationsSignUps` INTEGER NOT NULL, `notificationsUpdates` INTEGER NOT NULL, `notificationsReports` INTEGER NOT NULL, `notificationsSeveredRelationships` INTEGER NOT NULL DEFAULT true, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `defaultPostLanguage` TEXT NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `alwaysOpenSpoiler` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `notificationMarkerId` TEXT NOT NULL DEFAULT '0', `emojis` TEXT NOT NULL, `tabPreferences` TEXT NOT NULL, `notificationsFilter` TEXT NOT NULL, `oauthScopes` TEXT NOT NULL, `unifiedPushUrl` TEXT NOT NULL, `pushPubKey` TEXT NOT NULL, `pushPrivKey` TEXT NOT NULL, `pushAuth` TEXT NOT NULL, `pushServerKey` TEXT NOT NULL, `locked` INTEGER NOT NULL DEFAULT 0, `notificationAccountFilterNotFollowed` TEXT NOT NULL DEFAULT 'NONE', `notificationAccountFilterYounger30d` TEXT NOT NULL DEFAULT 'NONE', `notificationAccountFilterLimitedByServer` TEXT NOT NULL DEFAULT 'NONE')", + "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": "clientId", + "columnName": "clientId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clientSecret", + "columnName": "clientSecret", + "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": "profileHeaderPictureUrl", + "columnName": "profileHeaderPictureUrl", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "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": "notificationsSignUps", + "columnName": "notificationsSignUps", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsUpdates", + "columnName": "notificationsUpdates", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsReports", + "columnName": "notificationsReports", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsSeveredRelationships", + "columnName": "notificationsSeveredRelationships", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "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": "defaultPostLanguage", + "columnName": "defaultPostLanguage", + "affinity": "TEXT", + "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": "notificationMarkerId", + "columnName": "notificationMarkerId", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'0'" + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tabPreferences", + "columnName": "tabPreferences", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationsFilter", + "columnName": "notificationsFilter", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "oauthScopes", + "columnName": "oauthScopes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unifiedPushUrl", + "columnName": "unifiedPushUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushPubKey", + "columnName": "pushPubKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushPrivKey", + "columnName": "pushPrivKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushAuth", + "columnName": "pushAuth", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushServerKey", + "columnName": "pushServerKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "locked", + "columnName": "locked", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "notificationAccountFilterNotFollowed", + "columnName": "notificationAccountFilterNotFollowed", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'NONE'" + }, + { + "fieldPath": "notificationAccountFilterYounger30d", + "columnName": "notificationAccountFilterYounger30d", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'NONE'" + }, + { + "fieldPath": "notificationAccountFilterLimitedByServer", + "columnName": "notificationAccountFilterLimitedByServer", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'NONE'" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "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": "InstanceInfoEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`instance` TEXT NOT NULL, `maxPostCharacters` INTEGER, `maxPollOptions` INTEGER, `maxPollOptionLength` INTEGER, `minPollDuration` INTEGER, `maxPollDuration` INTEGER, `charactersReservedPerUrl` INTEGER, `version` TEXT, `videoSizeLimit` INTEGER, `imageSizeLimit` INTEGER, `imageMatrixLimit` INTEGER, `maxMediaAttachments` INTEGER, `maxFields` INTEGER, `maxFieldNameLength` INTEGER, `maxFieldValueLength` INTEGER, `enabledTranslation` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`instance`))", + "fields": [ + { + "fieldPath": "instance", + "columnName": "instance", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "maxPostCharacters", + "columnName": "maxPostCharacters", + "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 + }, + { + "fieldPath": "videoSizeLimit", + "columnName": "videoSizeLimit", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "imageSizeLimit", + "columnName": "imageSizeLimit", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "imageMatrixLimit", + "columnName": "imageMatrixLimit", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxMediaAttachments", + "columnName": "maxMediaAttachments", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxFields", + "columnName": "maxFields", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxFieldNameLength", + "columnName": "maxFieldNameLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxFieldValueLength", + "columnName": "maxFieldValueLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "enabledTranslation", + "columnName": "enabledTranslation", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "instance" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "EmojisEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `emojiList` TEXT NOT NULL, PRIMARY KEY(`accountId`), FOREIGN KEY(`accountId`) REFERENCES `AccountEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "emojiList", + "columnName": "emojiList", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "accountId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "accountId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "StatusEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `url` TEXT, `timelineUserId` INTEGER NOT NULL, `authorServerId` TEXT NOT NULL, `inReplyToId` TEXT, `inReplyToAccountId` TEXT, `content` TEXT, `createdAt` INTEGER NOT NULL, `editedAt` INTEGER, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` INTEGER NOT NULL, `repliesCount` 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, `pinned` INTEGER NOT NULL, `card` TEXT, `language` TEXT, `filtered` TEXT, PRIMARY KEY(`serverId`, `timelineUserId`), FOREIGN KEY(`authorServerId`, `timelineUserId`) REFERENCES `TimelineAccountEntity`(`serverId`, `timelineUserId`) ON UPDATE NO ACTION ON DELETE NO ACTION DEFERRABLE INITIALLY DEFERRED)", + "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": true + }, + { + "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": "editedAt", + "columnName": "editedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "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": "repliesCount", + "columnName": "repliesCount", + "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": "pinned", + "columnName": "pinned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "card", + "columnName": "card", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "filtered", + "columnName": "filtered", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "timelineUserId" + ] + }, + "indices": [ + { + "name": "index_StatusEntity_authorServerId_timelineUserId", + "unique": false, + "columnNames": [ + "authorServerId", + "timelineUserId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_StatusEntity_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, `createdAt` INTEGER, `limited` INTEGER NOT NULL DEFAULT false, `note` TEXT NOT NULL DEFAULT '', PRIMARY KEY(`serverId`, `timelineUserId`), FOREIGN KEY(`timelineUserId`) REFERENCES `AccountEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "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 + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "limited", + "columnName": "limited", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "timelineUserId" + ] + }, + "indices": [ + { + "name": "index_TimelineAccountEntity_timelineUserId", + "unique": false, + "columnNames": [ + "timelineUserId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TimelineAccountEntity_timelineUserId` ON `${TABLE_NAME}` (`timelineUserId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "timelineUserId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ConversationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT NOT NULL, `order` INTEGER 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_editedAt` INTEGER, `s_emojis` TEXT NOT NULL, `s_favouritesCount` INTEGER NOT NULL, `s_repliesCount` 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_collapsed` INTEGER NOT NULL, `s_muted` INTEGER NOT NULL, `s_poll` TEXT, `s_language` TEXT, PRIMARY KEY(`id`, `accountId`), FOREIGN KEY(`accountId`) REFERENCES `AccountEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "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.editedAt", + "columnName": "s_editedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastStatus.emojis", + "columnName": "s_emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.favouritesCount", + "columnName": "s_favouritesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.repliesCount", + "columnName": "s_repliesCount", + "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.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 + }, + { + "fieldPath": "lastStatus.language", + "columnName": "s_language", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id", + "accountId" + ] + }, + "indices": [ + { + "name": "index_ConversationEntity_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationEntity_accountId` ON `${TABLE_NAME}` (`accountId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "accountId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "RemoteKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `timelineId` TEXT NOT NULL, `kind` TEXT NOT NULL, `key` TEXT, PRIMARY KEY(`accountId`, `timelineId`, `kind`), FOREIGN KEY(`accountId`) REFERENCES `AccountEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timelineId", + "columnName": "timelineId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "accountId", + "timelineId", + "kind" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "accountId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "StatusViewDataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `timelineUserId` INTEGER NOT NULL, `expanded` INTEGER NOT NULL, `contentShowing` INTEGER NOT NULL, `contentCollapsed` INTEGER NOT NULL, `translationState` TEXT NOT NULL DEFAULT 'SHOW_ORIGINAL', PRIMARY KEY(`serverId`, `timelineUserId`), FOREIGN KEY(`timelineUserId`) REFERENCES `AccountEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "expanded", + "columnName": "expanded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentShowing", + "columnName": "contentShowing", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentCollapsed", + "columnName": "contentCollapsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "translationState", + "columnName": "translationState", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'SHOW_ORIGINAL'" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "timelineUserId" + ] + }, + "indices": [ + { + "name": "index_StatusViewDataEntity_timelineUserId", + "unique": false, + "columnNames": [ + "timelineUserId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_StatusViewDataEntity_timelineUserId` ON `${TABLE_NAME}` (`timelineUserId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "timelineUserId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "TranslatedStatusEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `timelineUserId` INTEGER NOT NULL, `content` TEXT NOT NULL, `spoilerText` TEXT NOT NULL, `poll` TEXT, `attachments` TEXT NOT NULL, `provider` TEXT NOT NULL, PRIMARY KEY(`serverId`, `timelineUserId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "spoilerText", + "columnName": "spoilerText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "provider", + "columnName": "provider", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "timelineUserId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "LogEntryEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `instant` INTEGER NOT NULL, `priority` INTEGER, `tag` TEXT, `message` TEXT NOT NULL, `t` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "instant", + "columnName": "instant", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "priority", + "columnName": "priority", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "tag", + "columnName": "tag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "message", + "columnName": "message", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "t", + "columnName": "t", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "MastodonListEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `listId` TEXT NOT NULL, `title` TEXT NOT NULL, `repliesPolicy` TEXT NOT NULL, `exclusive` INTEGER NOT NULL, PRIMARY KEY(`accountId`, `listId`), FOREIGN KEY(`accountId`) REFERENCES `AccountEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "listId", + "columnName": "listId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "repliesPolicy", + "columnName": "repliesPolicy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "exclusive", + "columnName": "exclusive", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "accountId", + "listId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "accountId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ServerEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `serverKind` TEXT NOT NULL, `version` TEXT NOT NULL, `capabilities` TEXT NOT NULL, PRIMARY KEY(`accountId`), FOREIGN KEY(`accountId`) REFERENCES `AccountEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverKind", + "columnName": "serverKind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "capabilities", + "columnName": "capabilities", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "accountId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "accountId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ContentFiltersEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `version` TEXT NOT NULL, `contentFilters` TEXT NOT NULL, PRIMARY KEY(`accountId`), FOREIGN KEY(`accountId`) REFERENCES `AccountEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contentFilters", + "columnName": "contentFilters", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "accountId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "accountId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "AnnouncementEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `announcementId` TEXT NOT NULL, `announcement` TEXT NOT NULL, PRIMARY KEY(`accountId`), FOREIGN KEY(`accountId`) REFERENCES `AccountEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "announcementId", + "columnName": "announcementId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "announcement", + "columnName": "announcement", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "accountId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "accountId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "FollowingAccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`pachliAccountId` INTEGER NOT NULL, `serverId` TEXT NOT NULL, PRIMARY KEY(`pachliAccountId`, `serverId`), FOREIGN KEY(`pachliAccountId`) REFERENCES `AccountEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "pachliAccountId", + "columnName": "pachliAccountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "pachliAccountId", + "serverId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "pachliAccountId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "NotificationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`pachliAccountId` INTEGER NOT NULL, `serverId` TEXT NOT NULL, `type` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `accountServerId` TEXT NOT NULL, `statusServerId` TEXT, PRIMARY KEY(`pachliAccountId`, `serverId`), FOREIGN KEY(`pachliAccountId`) REFERENCES `AccountEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, FOREIGN KEY(`accountServerId`, `pachliAccountId`) REFERENCES `TimelineAccountEntity`(`serverId`, `timelineUserId`) ON UPDATE NO ACTION ON DELETE NO ACTION DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "pachliAccountId", + "columnName": "pachliAccountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountServerId", + "columnName": "accountServerId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "statusServerId", + "columnName": "statusServerId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "pachliAccountId", + "serverId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "pachliAccountId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "TimelineAccountEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "accountServerId", + "pachliAccountId" + ], + "referencedColumns": [ + "serverId", + "timelineUserId" + ] + } + ] + }, + { + "tableName": "NotificationReportEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`pachliAccountId` INTEGER NOT NULL, `serverId` TEXT NOT NULL, `reportId` TEXT NOT NULL, `actionTaken` INTEGER NOT NULL, `actionTakenAt` INTEGER, `category` TEXT NOT NULL, `comment` TEXT NOT NULL, `forwarded` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `statusIds` TEXT, `ruleIds` TEXT, `target_serverId` TEXT NOT NULL, `target_timelineUserId` INTEGER NOT NULL, `target_localUsername` TEXT NOT NULL, `target_username` TEXT NOT NULL, `target_displayName` TEXT NOT NULL, `target_url` TEXT NOT NULL, `target_avatar` TEXT NOT NULL, `target_emojis` TEXT NOT NULL, `target_bot` INTEGER NOT NULL, `target_createdAt` INTEGER, `target_limited` INTEGER NOT NULL DEFAULT false, `target_note` TEXT NOT NULL DEFAULT '', PRIMARY KEY(`pachliAccountId`, `serverId`), FOREIGN KEY(`pachliAccountId`, `serverId`) REFERENCES `NotificationEntity`(`pachliAccountId`, `serverId`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "pachliAccountId", + "columnName": "pachliAccountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "reportId", + "columnName": "reportId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "actionTaken", + "columnName": "actionTaken", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "actionTakenAt", + "columnName": "actionTakenAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "category", + "columnName": "category", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "comment", + "columnName": "comment", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "forwarded", + "columnName": "forwarded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "statusIds", + "columnName": "statusIds", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleIds", + "columnName": "ruleIds", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "targetAccount.serverId", + "columnName": "target_serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "targetAccount.timelineUserId", + "columnName": "target_timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "targetAccount.localUsername", + "columnName": "target_localUsername", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "targetAccount.username", + "columnName": "target_username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "targetAccount.displayName", + "columnName": "target_displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "targetAccount.url", + "columnName": "target_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "targetAccount.avatar", + "columnName": "target_avatar", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "targetAccount.emojis", + "columnName": "target_emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "targetAccount.bot", + "columnName": "target_bot", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "targetAccount.createdAt", + "columnName": "target_createdAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "targetAccount.limited", + "columnName": "target_limited", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "targetAccount.note", + "columnName": "target_note", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "pachliAccountId", + "serverId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "NotificationEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "pachliAccountId", + "serverId" + ], + "referencedColumns": [ + "pachliAccountId", + "serverId" + ] + } + ] + }, + { + "tableName": "NotificationViewDataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`pachliAccountId` INTEGER NOT NULL, `serverId` TEXT NOT NULL, `contentFilterAction` TEXT, `accountFilterDecision` TEXT, PRIMARY KEY(`pachliAccountId`, `serverId`), FOREIGN KEY(`pachliAccountId`) REFERENCES `AccountEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "pachliAccountId", + "columnName": "pachliAccountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contentFilterAction", + "columnName": "contentFilterAction", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "accountFilterDecision", + "columnName": "accountFilterDecision", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "pachliAccountId", + "serverId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "pachliAccountId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "NotificationRelationshipSeveranceEventEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`pachliAccountId` INTEGER NOT NULL, `serverId` TEXT NOT NULL, `eventId` TEXT NOT NULL, `type` TEXT NOT NULL, `purged` INTEGER NOT NULL, `followersCount` INTEGER NOT NULL, `followingCount` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, PRIMARY KEY(`pachliAccountId`, `serverId`, `eventId`), FOREIGN KEY(`pachliAccountId`, `serverId`) REFERENCES `NotificationEntity`(`pachliAccountId`, `serverId`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "pachliAccountId", + "columnName": "pachliAccountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "eventId", + "columnName": "eventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "purged", + "columnName": "purged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "followersCount", + "columnName": "followersCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "followingCount", + "columnName": "followingCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "pachliAccountId", + "serverId", + "eventId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "NotificationEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "pachliAccountId", + "serverId" + ], + "referencedColumns": [ + "pachliAccountId", + "serverId" + ] + } + ] + } + ], + "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, '3964d1676b1ccf34735e1c8c54e90313')" + ] + } +} \ No newline at end of file diff --git a/core/database/src/main/kotlin/app/pachli/core/database/AppDatabase.kt b/core/database/src/main/kotlin/app/pachli/core/database/AppDatabase.kt index 21091fd393..c8233199dc 100644 --- a/core/database/src/main/kotlin/app/pachli/core/database/AppDatabase.kt +++ b/core/database/src/main/kotlin/app/pachli/core/database/AppDatabase.kt @@ -26,6 +26,7 @@ import androidx.room.AutoMigration import androidx.room.Database import androidx.room.DeleteColumn import androidx.room.RenameColumn +import androidx.room.RenameTable import androidx.room.RoomDatabase import androidx.room.migration.AutoMigrationSpec import androidx.room.migration.Migration @@ -59,9 +60,9 @@ import app.pachli.core.database.model.NotificationReportEntity import app.pachli.core.database.model.NotificationViewDataEntity import app.pachli.core.database.model.RemoteKeyEntity import app.pachli.core.database.model.ServerEntity +import app.pachli.core.database.model.StatusEntity import app.pachli.core.database.model.StatusViewDataEntity import app.pachli.core.database.model.TimelineAccountEntity -import app.pachli.core.database.model.TimelineStatusEntity import app.pachli.core.database.model.TranslatedStatusEntity import app.pachli.core.model.ContentFilterVersion import java.text.SimpleDateFormat @@ -75,7 +76,7 @@ import java.util.TimeZone AccountEntity::class, InstanceInfoEntity::class, EmojisEntity::class, - TimelineStatusEntity::class, + StatusEntity::class, TimelineAccountEntity::class, ConversationEntity::class, RemoteKeyEntity::class, @@ -92,7 +93,7 @@ import java.util.TimeZone NotificationViewDataEntity::class, NotificationRelationshipSeveranceEventEntity::class, ], - version = 14, + version = 15, autoMigrations = [ AutoMigration(from = 1, to = 2, spec = AppDatabase.MIGRATE_1_2::class), AutoMigration(from = 2, to = 3), @@ -107,6 +108,7 @@ import java.util.TimeZone AutoMigration(from = 11, to = 12, spec = AppDatabase.MIGRATE_11_12::class), // 12 -> 13 is a custom migration AutoMigration(from = 13, to = 14, spec = AppDatabase.MIGRATE_13_14::class), + AutoMigration(from = 14, to = 15, spec = AppDatabase.MIGRATE_14_15::class), ], ) abstract class AppDatabase : RoomDatabase() { @@ -186,6 +188,9 @@ abstract class AppDatabase : RoomDatabase() { // lastVisibleHomeTimelineStatusId removed in favour of the REFRESH key in RemoteKeyEntity. @DeleteColumn("AccountEntity", "lastVisibleHomeTimelineStatusId") class MIGRATE_13_14 : AutoMigrationSpec + + @RenameTable("TimelineStatusEntity", "StatusEntity") + class MIGRATE_14_15 : AutoMigrationSpec } val MIGRATE_8_9 = object : Migration(8, 9) { diff --git a/core/database/src/main/kotlin/app/pachli/core/database/dao/NotificationDao.kt b/core/database/src/main/kotlin/app/pachli/core/database/dao/NotificationDao.kt index de108778af..405bf6745d 100644 --- a/core/database/src/main/kotlin/app/pachli/core/database/dao/NotificationDao.kt +++ b/core/database/src/main/kotlin/app/pachli/core/database/dao/NotificationDao.kt @@ -183,7 +183,7 @@ SELECT FROM NotificationEntity AS n LEFT JOIN TimelineAccountEntity AS a ON (n.pachliAccountId = a.timelineUserId AND n.accountServerId = a.serverId) -LEFT JOIN TimelineStatusEntity AS s ON (n.pachliAccountId = s.timelineUserId AND n.statusServerId = s.serverId) +LEFT JOIN StatusEntity AS s ON (n.pachliAccountId = s.timelineUserId AND n.statusServerId = s.serverId) LEFT JOIN TimelineAccountEntity AS sa ON (n.pachliAccountId = sa.timelineUserId AND s.authorServerId = sa.serverId) LEFT JOIN TimelineAccountEntity AS rb ON (n.pachliAccountId = rb.timelineUserId AND s.reblogAccountId = rb.serverId) LEFT JOIN diff --git a/core/database/src/main/kotlin/app/pachli/core/database/dao/TimelineDao.kt b/core/database/src/main/kotlin/app/pachli/core/database/dao/TimelineDao.kt index a1c5895cdf..9a7b90b0ab 100644 --- a/core/database/src/main/kotlin/app/pachli/core/database/dao/TimelineDao.kt +++ b/core/database/src/main/kotlin/app/pachli/core/database/dao/TimelineDao.kt @@ -27,9 +27,9 @@ import androidx.room.Transaction import androidx.room.TypeConverters import androidx.room.Upsert import app.pachli.core.database.Converters +import app.pachli.core.database.model.StatusEntity import app.pachli.core.database.model.StatusViewDataEntity import app.pachli.core.database.model.TimelineAccountEntity -import app.pachli.core.database.model.TimelineStatusEntity import app.pachli.core.database.model.TimelineStatusWithAccount import app.pachli.core.network.model.Poll @@ -44,10 +44,10 @@ abstract class TimelineDao { abstract suspend fun upsertAccounts(accounts: Collection) @Upsert - abstract suspend fun upsertStatuses(statuses: Collection) + abstract suspend fun upsertStatuses(statuses: Collection) @Upsert - abstract suspend fun insertStatus(timelineStatusEntity: TimelineStatusEntity): Long + abstract suspend fun insertStatus(statusEntity: StatusEntity): Long @Query( """ @@ -120,7 +120,7 @@ SELECT t.poll AS 't_poll', t.attachments AS 't_attachments', t.provider AS 't_provider' -FROM TimelineStatusEntity AS s +FROM StatusEntity AS s LEFT JOIN TimelineAccountEntity AS a ON (s.timelineUserId = a.timelineUserId AND s.authorServerId = a.serverId) LEFT JOIN TimelineAccountEntity AS rb ON (s.timelineUserId = rb.timelineUserId AND s.reblogAccountId = rb.serverId) LEFT JOIN @@ -148,9 +148,9 @@ FROM ( t1.timelineUserId, t1.serverId, COUNT(t2.serverId) - 1 AS rownum - FROM TimelineStatusEntity AS t1 + FROM StatusEntity AS t1 INNER JOIN - TimelineStatusEntity AS t2 + StatusEntity AS t2 ON t1.timelineUserId = t2.timelineUserId AND (LENGTH(t1.serverId) <= LENGTH(t2.serverId) AND t1.serverId <= t2.serverId) @@ -234,7 +234,7 @@ SELECT t.poll AS 't_poll', t.attachments AS 't_attachments', t.provider AS 't_provider' -FROM TimelineStatusEntity AS s +FROM StatusEntity AS s LEFT JOIN TimelineAccountEntity AS a ON (s.timelineUserId = a.timelineUserId AND s.authorServerId = a.serverId) LEFT JOIN TimelineAccountEntity AS rb ON (s.timelineUserId = rb.timelineUserId AND s.reblogAccountId = rb.serverId) LEFT JOIN @@ -253,7 +253,7 @@ WHERE @Query( """ DELETE -FROM TimelineStatusEntity +FROM StatusEntity WHERE timelineUserId = :accountId AND (LENGTH(serverId) < LENGTH(:maxId) OR LENGTH(serverId) == LENGTH(:maxId) AND serverId <= :maxId) AND (LENGTH(serverId) > LENGTH(:minId) OR LENGTH(serverId) == LENGTH(:minId) AND serverId >= :minId) @@ -263,7 +263,7 @@ WHERE timelineUserId = :accountId @Query( """ -UPDATE TimelineStatusEntity +UPDATE StatusEntity SET favourited = :favourited WHERE timelineUserId = :pachliAccountId AND (serverId = :statusId OR reblogServerId = :statusId) @@ -273,7 +273,7 @@ WHERE timelineUserId = :pachliAccountId AND (serverId = :statusId OR reblogServe @Query( """ -UPDATE TimelineStatusEntity +UPDATE StatusEntity SET bookmarked = :bookmarked WHERE timelineUserId = :pachliAccountId AND (serverId = :statusId OR reblogServerId = :statusId) @@ -283,7 +283,7 @@ WHERE timelineUserId = :pachliAccountId AND (serverId = :statusId OR reblogServe @Query( """ -UPDATE TimelineStatusEntity +UPDATE StatusEntity SET reblogged = :reblogged WHERE timelineUserId = :pachliAccountId AND (serverId = :statusId OR reblogServerId = :statusId) @@ -294,7 +294,7 @@ WHERE timelineUserId = :pachliAccountId AND (serverId = :statusId OR reblogServe @Query( """ DELETE -FROM TimelineStatusEntity +FROM StatusEntity WHERE timelineUserId = :pachliAccountId AND (authorServerId = :userId OR reblogAccountId = :userId) @@ -311,7 +311,7 @@ WHERE @Query( """ DELETE -FROM TimelineStatusEntity +FROM StatusEntity WHERE timelineUserId = :accountId AND serverId NOT IN ( @@ -344,7 +344,7 @@ WHERE timelineUserId = :accountId @Query( """ DELETE -FROM TimelineStatusEntity +FROM StatusEntity WHERE timelineUserId = :accountId AND serverId = :statusId @@ -353,7 +353,7 @@ WHERE abstract suspend fun delete(accountId: Long, statusId: String) /** - * Cleans the TimelineStatusEntity and TimelineAccountEntity tables from old entries. + * Cleans the StatusEntity and TimelineAccountEntity tables from old entries. * @param accountId id of the account for which to clean tables * @param limit how many statuses to keep */ @@ -366,7 +366,7 @@ WHERE } /** - * Deletes rows from [TimelineStatusEntity], keeping the newest [keep] + * Deletes rows from [StatusEntity], keeping the newest [keep] * statuses. * * @param accountId id of the account for which to clean statuses @@ -375,7 +375,7 @@ WHERE @Query( """ DELETE -FROM TimelineStatusEntity +FROM StatusEntity WHERE timelineUserId = :accountId AND serverId IN ( SELECT serverId FROM ( @@ -385,7 +385,7 @@ WHERE timelineUserId = :accountId AND serverId IN ( -- join returns a NULL notification ID (because the status has -- no associated notification) SELECT s.serverId - FROM TimelineStatusEntity AS s + FROM StatusEntity AS s LEFT JOIN NotificationEntity AS n ON (s.serverId = n.statusServerId AND s.timelineUserId = n.pachliAccountId) @@ -411,7 +411,7 @@ WHERE timelineUserId = :accountId AND serverId IN ( abstract suspend fun cleanupStatuses(accountId: Long, keep: Int) /** - * Cleans the TimelineAccountEntity table from accounts that are no longer referenced in the TimelineStatusEntity table + * Cleans the TimelineAccountEntity table from accounts that are no longer referenced in the StatusEntity table * @param accountId id of the user account for which to clean timeline accounts */ @Query( @@ -422,12 +422,12 @@ WHERE timelineUserId = :accountId AND serverId NOT IN ( SELECT authorServerId - FROM TimelineStatusEntity + FROM StatusEntity WHERE timelineUserId = :accountId ) AND serverId NOT IN ( SELECT reblogAccountId - FROM TimelineStatusEntity + FROM StatusEntity WHERE timelineUserId = :accountId AND reblogAccountId IS NOT NULL ) AND serverId NOT IN ( @@ -483,7 +483,7 @@ WHERE @Query( """ -UPDATE TimelineStatusEntity +UPDATE StatusEntity SET poll = :poll WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId = :statusId) @@ -519,7 +519,7 @@ WHERE @Query( """ -UPDATE TimelineStatusEntity +UPDATE StatusEntity SET pinned = :pinned WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId = :statusId) @@ -530,7 +530,7 @@ WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId = @Query( """ DELETE -FROM TimelineStatusEntity +FROM StatusEntity WHERE timelineUserId = :accountId AND authorServerId IN ( SELECT serverId FROM TimelineAccountEntity @@ -544,7 +544,7 @@ WHERE timelineUserId = :accountId AND authorServerId IN ( @Query( """ -UPDATE TimelineStatusEntity +UPDATE StatusEntity SET filtered = NULL WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId = :statusId) @@ -555,7 +555,7 @@ WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId = @Query( """ SELECT COUNT(*) -FROM TimelineStatusEntity +FROM StatusEntity WHERE timelineUserId = :accountId """, ) @@ -565,7 +565,7 @@ WHERE timelineUserId = :accountId @Query( """ SELECT serverId -FROM TimelineStatusEntity +FROM StatusEntity WHERE timelineUserId = :accountId ORDER BY LENGTH(serverId) DESC, serverId DESC LIMIT :count diff --git a/core/database/src/main/kotlin/app/pachli/core/database/model/TimelineStatusEntity.kt b/core/database/src/main/kotlin/app/pachli/core/database/model/StatusEntity.kt similarity index 98% rename from core/database/src/main/kotlin/app/pachli/core/database/model/TimelineStatusEntity.kt rename to core/database/src/main/kotlin/app/pachli/core/database/model/StatusEntity.kt index 8847bd85d8..21f3685d41 100644 --- a/core/database/src/main/kotlin/app/pachli/core/database/model/TimelineStatusEntity.kt +++ b/core/database/src/main/kotlin/app/pachli/core/database/model/StatusEntity.kt @@ -1,5 +1,5 @@ /* - * Copyright 2021 Tusky Contributors + * Copyright (c) 2025 Pachli Association * * This file is a part of Pachli. * @@ -62,7 +62,7 @@ import java.util.Date indices = [Index("authorServerId", "timelineUserId")], ) @TypeConverters(Converters::class) -data class TimelineStatusEntity( +data class StatusEntity( // id never flips: we need it for sorting so it's a real id val serverId: String, val url: String?, @@ -99,7 +99,7 @@ data class TimelineStatusEntity( val filtered: List?, ) { companion object { - fun from(status: Status, timelineUserId: Long) = TimelineStatusEntity( + fun from(status: Status, timelineUserId: Long) = StatusEntity( serverId = status.id, url = status.actionableStatus.url, timelineUserId = timelineUserId, @@ -230,7 +230,7 @@ enum class TranslationState { /** * The local view data for a status. * - * There is *no* foreignkey relationship between this and [TimelineStatusEntity], as the view + * There is *no* foreignkey relationship between this and [StatusEntity], as the view * data is kept even if the status is deleted from the local cache (e.g., during a refresh * operation). */ @@ -263,7 +263,7 @@ data class StatusViewDataEntity( data class TimelineStatusWithAccount( @Embedded - val status: TimelineStatusEntity, + val status: StatusEntity, @Embedded(prefix = "a_") val account: TimelineAccountEntity, // null when no reblog diff --git a/core/database/src/main/kotlin/app/pachli/core/database/model/TranslatedStatusEntity.kt b/core/database/src/main/kotlin/app/pachli/core/database/model/TranslatedStatusEntity.kt index 265301ba51..ecfccd1acd 100644 --- a/core/database/src/main/kotlin/app/pachli/core/database/model/TranslatedStatusEntity.kt +++ b/core/database/src/main/kotlin/app/pachli/core/database/model/TranslatedStatusEntity.kt @@ -26,7 +26,7 @@ import app.pachli.core.network.model.TranslatedPoll /** * Translated version of a status, see https://docs.joinmastodon.org/entities/Translation/. * - * There is *no* foreignkey relationship between this and [TimelineStatusEntity], as the + * There is *no* foreignkey relationship between this and [StatusEntity], as the * translation data is kept even if the status is deleted from the local cache (e.g., during * a refresh operation). */ diff --git a/core/database/src/test/kotlin/app/pachli/core/database/dao/TimelineDaoTest.kt b/core/database/src/test/kotlin/app/pachli/core/database/dao/TimelineDaoTest.kt index 12c7fbd80f..258aa6c877 100644 --- a/core/database/src/test/kotlin/app/pachli/core/database/dao/TimelineDaoTest.kt +++ b/core/database/src/test/kotlin/app/pachli/core/database/dao/TimelineDaoTest.kt @@ -21,8 +21,8 @@ import androidx.paging.PagingSource import androidx.test.ext.junit.runners.AndroidJUnit4 import app.pachli.core.database.AppDatabase import app.pachli.core.database.model.AccountEntity +import app.pachli.core.database.model.StatusEntity import app.pachli.core.database.model.TimelineAccountEntity -import app.pachli.core.database.model.TimelineStatusEntity import app.pachli.core.database.model.TimelineStatusWithAccount import app.pachli.core.network.model.Card import app.pachli.core.network.model.Emoji @@ -360,7 +360,7 @@ class TimelineDaoTest { authorServerId: String = "20", domain: String = "mastodon.example", cardUrl: String? = null, - ): Triple { + ): Triple { val author = TimelineAccountEntity( serverId = authorServerId, timelineUserId = accountId, @@ -398,7 +398,7 @@ class TimelineDaoTest { else -> Card(cardUrl, "", "", PreviewCardKind.LINK, providerName = "", providerUrl = "") } val even = accountId % 2 == 0L - val status = TimelineStatusEntity( + val status = StatusEntity( serverId = statusId.toString(), url = "https://$domain/whatever/$statusId", timelineUserId = accountId, @@ -435,7 +435,7 @@ class TimelineDaoTest { } private fun assertStatuses( - expected: List>, + expected: List>, provided: List, ) { for ((exp, prov) in expected.zip(provided)) { From b11be9e4aa537bf7f1c6882b2f999a2e476b5e00 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Sat, 25 Jan 2025 15:12:41 +0100 Subject: [PATCH 02/19] refactor: Create StatusDao for operations that act on individual statuses TimelineDao contained operations on timelines and statuses which made it large and confusing. Factor out the status-specific operations in to the new StatusDao class to make things more understandable. --- .../java/app/pachli/appstore/CacheUpdater.kt | 14 ++- .../timeline/CachedTimelineRepository.kt | 5 +- .../viewmodel/CachedTimelineRemoteMediator.kt | 4 +- .../CachedTimelineRemoteMediatorTest.kt | 8 +- .../NotificationsRemoteMediator.kt | 4 +- .../notifications/NotificationsRepository.kt | 17 +-- .../app/pachli/core/database/AppDatabase.kt | 2 + .../app/pachli/core/database/dao/StatusDao.kt | 111 ++++++++++++++++++ .../pachli/core/database/dao/TimelineDao.kt | 79 ------------- .../pachli/core/database/di/DatabaseModule.kt | 3 + .../core/database/dao/TimelineDaoTest.kt | 17 +-- .../core/testing/fakes/FakeDatabaseModule.kt | 3 + 12 files changed, 164 insertions(+), 103 deletions(-) create mode 100644 core/database/src/main/kotlin/app/pachli/core/database/dao/StatusDao.kt diff --git a/app/src/main/java/app/pachli/appstore/CacheUpdater.kt b/app/src/main/java/app/pachli/appstore/CacheUpdater.kt index 7beb25d479..3df7107e26 100644 --- a/app/src/main/java/app/pachli/appstore/CacheUpdater.kt +++ b/app/src/main/java/app/pachli/appstore/CacheUpdater.kt @@ -1,6 +1,7 @@ package app.pachli.appstore import app.pachli.core.data.repository.AccountManager +import app.pachli.core.database.dao.StatusDao import app.pachli.core.database.dao.TimelineDao import app.pachli.core.eventhub.BookmarkEvent import app.pachli.core.eventhub.EventHub @@ -21,6 +22,7 @@ class CacheUpdater @Inject constructor( eventHub: EventHub, accountManager: AccountManager, timelineDao: TimelineDao, + statusDao: StatusDao, ) { private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) @@ -30,20 +32,20 @@ class CacheUpdater @Inject constructor( val accountId = accountManager.activeAccount?.id ?: return@collect when (event) { is FavoriteEvent -> - timelineDao.setFavourited(accountId, event.statusId, event.favourite) + statusDao.setFavourited(accountId, event.statusId, event.favourite) is ReblogEvent -> - timelineDao.setReblogged(accountId, event.statusId, event.reblog) + statusDao.setReblogged(accountId, event.statusId, event.reblog) is BookmarkEvent -> - timelineDao.setBookmarked(accountId, event.statusId, event.bookmark) + statusDao.setBookmarked(accountId, event.statusId, event.bookmark) is UnfollowEvent -> timelineDao.removeAllByUser(accountId, event.accountId) is StatusDeletedEvent -> - timelineDao.delete(accountId, event.statusId) + statusDao.delete(accountId, event.statusId) is PollVoteEvent -> { - timelineDao.setVoted(accountId, event.statusId, event.poll) + statusDao.setVoted(accountId, event.statusId, event.poll) } is PinEvent -> - timelineDao.setPinned(accountId, event.statusId, event.pinned) + statusDao.setPinned(accountId, event.statusId, event.pinned) } } } diff --git a/app/src/main/java/app/pachli/components/timeline/CachedTimelineRepository.kt b/app/src/main/java/app/pachli/components/timeline/CachedTimelineRepository.kt index fed3699704..3996fe33d4 100644 --- a/app/src/main/java/app/pachli/components/timeline/CachedTimelineRepository.kt +++ b/app/src/main/java/app/pachli/components/timeline/CachedTimelineRepository.kt @@ -28,6 +28,7 @@ import app.pachli.components.timeline.viewmodel.CachedTimelineRemoteMediator.Com import app.pachli.core.common.di.ApplicationScope import app.pachli.core.data.model.StatusViewData import app.pachli.core.database.dao.RemoteKeyDao +import app.pachli.core.database.dao.StatusDao import app.pachli.core.database.dao.TimelineDao import app.pachli.core.database.dao.TranslatedStatusDao import app.pachli.core.database.di.TransactionProvider @@ -66,6 +67,7 @@ class CachedTimelineRepository @Inject constructor( val timelineDao: TimelineDao, private val remoteKeyDao: RemoteKeyDao, private val translatedStatusDao: TranslatedStatusDao, + private val statusDao: StatusDao, @ApplicationScope private val externalScope: CoroutineScope, ) : TimelineRepository { private var factory: InvalidatingPagingSourceFactory? = null @@ -97,6 +99,7 @@ class CachedTimelineRepository @Inject constructor( transactionProvider, timelineDao, remoteKeyDao, + statusDao, ), pagingSourceFactory = factory!!, ).flow @@ -152,7 +155,7 @@ class CachedTimelineRepository @Inject constructor( /** Clear the warning (remove the "filtered" setting) for the given status, for the active account */ suspend fun clearStatusWarning(pachliAccountId: Long, statusId: String) = externalScope.launch { - timelineDao.clearWarning(pachliAccountId, statusId) + statusDao.clearWarning(pachliAccountId, statusId) }.join() suspend fun translate(statusViewData: StatusViewData): NetworkResult { diff --git a/app/src/main/java/app/pachli/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt b/app/src/main/java/app/pachli/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt index 520aa8a75b..768f846d63 100644 --- a/app/src/main/java/app/pachli/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt +++ b/app/src/main/java/app/pachli/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt @@ -22,6 +22,7 @@ import androidx.paging.LoadType import androidx.paging.PagingState import androidx.paging.RemoteMediator import app.pachli.core.database.dao.RemoteKeyDao +import app.pachli.core.database.dao.StatusDao import app.pachli.core.database.dao.TimelineDao import app.pachli.core.database.di.TransactionProvider import app.pachli.core.database.model.RemoteKeyEntity @@ -48,6 +49,7 @@ class CachedTimelineRemoteMediator( private val transactionProvider: TransactionProvider, private val timelineDao: TimelineDao, private val remoteKeyDao: RemoteKeyDao, + private val statusDao: StatusDao, ) : RemoteMediator() { override suspend fun load( loadType: LoadType, @@ -259,7 +261,7 @@ class CachedTimelineRemoteMediator( } timelineDao.upsertAccounts(accounts.map { TimelineAccountEntity.from(it, pachliAccountId) }) - timelineDao.upsertStatuses(statuses.map { StatusEntity.from(it, pachliAccountId) }) + statusDao.upsertStatuses(statuses.map { StatusEntity.from(it, pachliAccountId) }) } companion object { diff --git a/app/src/test/java/app/pachli/components/timeline/CachedTimelineRemoteMediatorTest.kt b/app/src/test/java/app/pachli/components/timeline/CachedTimelineRemoteMediatorTest.kt index 336eb8307d..b96b20f714 100644 --- a/app/src/test/java/app/pachli/components/timeline/CachedTimelineRemoteMediatorTest.kt +++ b/app/src/test/java/app/pachli/components/timeline/CachedTimelineRemoteMediatorTest.kt @@ -103,6 +103,7 @@ class CachedTimelineRemoteMediatorTest { transactionProvider = transactionProvider, timelineDao = db.timelineDao(), remoteKeyDao = db.remoteKeyDao(), + statusDao = db.statusDao(), ) val result = runBlocking { remoteMediator.load(LoadType.REFRESH, state()) } @@ -123,6 +124,7 @@ class CachedTimelineRemoteMediatorTest { transactionProvider = transactionProvider, timelineDao = db.timelineDao(), remoteKeyDao = db.remoteKeyDao(), + statusDao = db.statusDao(), ) val result = runBlocking { remoteMediator.load(LoadType.REFRESH, state()) } @@ -140,6 +142,7 @@ class CachedTimelineRemoteMediatorTest { transactionProvider = transactionProvider, timelineDao = db.timelineDao(), remoteKeyDao = db.remoteKeyDao(), + statusDao = db.statusDao(), ) val state = state( @@ -177,6 +180,7 @@ class CachedTimelineRemoteMediatorTest { transactionProvider = transactionProvider, timelineDao = db.timelineDao(), remoteKeyDao = db.remoteKeyDao(), + statusDao = db.statusDao(), ) val state = state( @@ -228,6 +232,7 @@ class CachedTimelineRemoteMediatorTest { transactionProvider = transactionProvider, timelineDao = db.timelineDao(), remoteKeyDao = db.remoteKeyDao(), + statusDao = db.statusDao(), ) val state = state( @@ -286,6 +291,7 @@ class CachedTimelineRemoteMediatorTest { transactionProvider = transactionProvider, timelineDao = db.timelineDao(), remoteKeyDao = db.remoteKeyDao(), + statusDao = db.statusDao(), ) val state = state( @@ -335,7 +341,7 @@ class CachedTimelineRemoteMediatorTest { statusWithAccount.reblogAccount?.let { account -> timelineDao().insertAccount(account) } - timelineDao().insertStatus(statusWithAccount.status) + statusDao().insertStatus(statusWithAccount.status) } } } diff --git a/core/data/src/main/kotlin/app/pachli/core/data/repository/notifications/NotificationsRemoteMediator.kt b/core/data/src/main/kotlin/app/pachli/core/data/repository/notifications/NotificationsRemoteMediator.kt index 613120c47f..85e6461599 100644 --- a/core/data/src/main/kotlin/app/pachli/core/data/repository/notifications/NotificationsRemoteMediator.kt +++ b/core/data/src/main/kotlin/app/pachli/core/data/repository/notifications/NotificationsRemoteMediator.kt @@ -24,6 +24,7 @@ import androidx.paging.RemoteMediator import app.pachli.core.data.repository.notifications.NotificationsRepository.Companion.RKE_TIMELINE_ID import app.pachli.core.database.dao.NotificationDao import app.pachli.core.database.dao.RemoteKeyDao +import app.pachli.core.database.dao.StatusDao import app.pachli.core.database.dao.TimelineDao import app.pachli.core.database.di.TransactionProvider import app.pachli.core.database.model.NotificationData @@ -59,6 +60,7 @@ class NotificationsRemoteMediator( private val timelineDao: TimelineDao, private val remoteKeyDao: RemoteKeyDao, private val notificationDao: NotificationDao, + private val statusDao: StatusDao, ) : RemoteMediator() { override suspend fun load(loadType: LoadType, state: PagingState): MediatorResult { @@ -282,7 +284,7 @@ class NotificationsRemoteMediator( // Bulk upsert the discovered items. timelineDao.upsertAccounts(accounts.map { TimelineAccountEntity.from(it, pachliAccountId) }) - timelineDao.upsertStatuses(statuses.map { StatusEntity.from(it, pachliAccountId) }) + statusDao.upsertStatuses(statuses.map { StatusEntity.from(it, pachliAccountId) }) notificationDao.upsertReports(reports.mapNotNull { NotificationReportEntity.from(pachliAccountId, it) }) notificationDao.upsertEvents( severanceEvents.mapNotNull { diff --git a/core/data/src/main/kotlin/app/pachli/core/data/repository/notifications/NotificationsRepository.kt b/core/data/src/main/kotlin/app/pachli/core/data/repository/notifications/NotificationsRepository.kt index 445f63673b..38cbb64454 100644 --- a/core/data/src/main/kotlin/app/pachli/core/data/repository/notifications/NotificationsRepository.kt +++ b/core/data/src/main/kotlin/app/pachli/core/data/repository/notifications/NotificationsRepository.kt @@ -28,6 +28,7 @@ import app.pachli.core.common.di.ApplicationScope import app.pachli.core.data.model.StatusViewData import app.pachli.core.database.dao.NotificationDao import app.pachli.core.database.dao.RemoteKeyDao +import app.pachli.core.database.dao.StatusDao import app.pachli.core.database.dao.TimelineDao import app.pachli.core.database.di.TransactionProvider import app.pachli.core.database.model.AccountFilterDecisionUpdate @@ -97,6 +98,7 @@ class NotificationsRepository @Inject constructor( private val timelineDao: TimelineDao, private val notificationDao: NotificationDao, private val remoteKeyDao: RemoteKeyDao, + private val statusDao: StatusDao, private val eventHub: EventHub, ) { private var factory: InvalidatingPagingSourceFactory? = null @@ -126,6 +128,7 @@ class NotificationsRepository @Inject constructor( timelineDao, remoteKeyDao, notificationDao, + statusDao, ), pagingSourceFactory = factory!!, ).flow @@ -262,12 +265,12 @@ class NotificationsRepository @Inject constructor( } } - timelineDao.setBookmarked(pachliAccountId, statusId, bookmarked) + statusDao.setBookmarked(pachliAccountId, statusId, bookmarked) val result = deferred.await() result.onFailure { throwable -> - timelineDao.setBookmarked(pachliAccountId, statusId, !bookmarked) + statusDao.setBookmarked(pachliAccountId, statusId, !bookmarked) return@async Err(StatusActionError.Bookmark(throwable)) } @@ -298,12 +301,12 @@ class NotificationsRepository @Inject constructor( } } - timelineDao.setFavourited(pachliAccountId, statusId, favourited) + statusDao.setFavourited(pachliAccountId, statusId, favourited) val result = deferred.await() result.onFailure { throwable -> - timelineDao.setFavourited(pachliAccountId, statusId, !favourited) + statusDao.setFavourited(pachliAccountId, statusId, !favourited) return@async Err(StatusActionError.Favourite(throwable)) } @@ -334,12 +337,12 @@ class NotificationsRepository @Inject constructor( } } - timelineDao.setReblogged(pachliAccountId, statusId, reblogged) + statusDao.setReblogged(pachliAccountId, statusId, reblogged) val result = deferred.await() result.onFailure { throwable -> - timelineDao.setReblogged(pachliAccountId, statusId, !reblogged) + statusDao.setReblogged(pachliAccountId, statusId, !reblogged) return@async Err(StatusActionError.Reblog(throwable)) } @@ -370,7 +373,7 @@ class NotificationsRepository @Inject constructor( mastodonApi.voteInPoll(pollId, choices) .onSuccess { poll -> - timelineDao.setVoted(pachliAccountId, statusId, poll) + statusDao.setVoted(pachliAccountId, statusId, poll) eventHub.dispatch(PollVoteEvent(statusId, poll)) } .onFailure { throwable -> return@async Err(StatusActionError.VoteInPoll(throwable)) } diff --git a/core/database/src/main/kotlin/app/pachli/core/database/AppDatabase.kt b/core/database/src/main/kotlin/app/pachli/core/database/AppDatabase.kt index c8233199dc..15d9957b66 100644 --- a/core/database/src/main/kotlin/app/pachli/core/database/AppDatabase.kt +++ b/core/database/src/main/kotlin/app/pachli/core/database/AppDatabase.kt @@ -42,6 +42,7 @@ import app.pachli.core.database.dao.ListsDao import app.pachli.core.database.dao.LogEntryDao import app.pachli.core.database.dao.NotificationDao import app.pachli.core.database.dao.RemoteKeyDao +import app.pachli.core.database.dao.StatusDao import app.pachli.core.database.dao.TimelineDao import app.pachli.core.database.dao.TranslatedStatusDao import app.pachli.core.database.model.AccountEntity @@ -125,6 +126,7 @@ abstract class AppDatabase : RoomDatabase() { abstract fun announcementsDao(): AnnouncementsDao abstract fun followingAccountDao(): FollowingAccountDao abstract fun notificationDao(): NotificationDao + abstract fun statusDao(): StatusDao @DeleteColumn("TimelineStatusEntity", "expanded") @DeleteColumn("TimelineStatusEntity", "contentCollapsed") diff --git a/core/database/src/main/kotlin/app/pachli/core/database/dao/StatusDao.kt b/core/database/src/main/kotlin/app/pachli/core/database/dao/StatusDao.kt new file mode 100644 index 0000000000..2d52fdb0af --- /dev/null +++ b/core/database/src/main/kotlin/app/pachli/core/database/dao/StatusDao.kt @@ -0,0 +1,111 @@ +/* + * Copyright (c) 2025 Pachli Association + * + * This file is a part of Pachli. + * + * 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. + * + * Pachli 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 Pachli; if not, + * see . + */ + +package app.pachli.core.database.dao + +import androidx.room.Dao +import androidx.room.Query +import androidx.room.TypeConverters +import androidx.room.Upsert +import app.pachli.core.database.Converters +import app.pachli.core.database.model.StatusEntity +import app.pachli.core.network.model.Poll + +/** + * Operations on individual statuses, irrespective of the timeline they are + * part of. + */ +@Dao +@TypeConverters(Converters::class) +abstract class StatusDao { + @Upsert + abstract suspend fun upsertStatuses(statuses: Collection) + + @Upsert + abstract suspend fun insertStatus(statusEntity: StatusEntity): Long + + @Query( + """ +UPDATE StatusEntity +SET + favourited = :favourited +WHERE timelineUserId = :pachliAccountId AND (serverId = :statusId OR reblogServerId = :statusId) +""", + ) + abstract suspend fun setFavourited(pachliAccountId: Long, statusId: String, favourited: Boolean) + + @Query( + """ +UPDATE StatusEntity +SET + bookmarked = :bookmarked +WHERE timelineUserId = :pachliAccountId AND (serverId = :statusId OR reblogServerId = :statusId) +""", + ) + abstract suspend fun setBookmarked(pachliAccountId: Long, statusId: String, bookmarked: Boolean) + + @Query( + """ +UPDATE StatusEntity +SET + reblogged = :reblogged +WHERE timelineUserId = :pachliAccountId AND (serverId = :statusId OR reblogServerId = :statusId) +""", + ) + abstract suspend fun setReblogged(pachliAccountId: Long, statusId: String, reblogged: Boolean) + + @Query( + """ +DELETE +FROM StatusEntity +WHERE + timelineUserId = :accountId + AND serverId = :statusId +""", + ) + abstract suspend fun delete(accountId: Long, statusId: String) + + @Query( + """ +UPDATE StatusEntity +SET + poll = :poll +WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId = :statusId) +""", + ) + abstract suspend fun setVoted(accountId: Long, statusId: String, poll: Poll) + + @Query( + """ +UPDATE StatusEntity +SET + pinned = :pinned +WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId = :statusId) +""", + ) + abstract suspend fun setPinned(accountId: Long, statusId: String, pinned: Boolean) + + @Query( + """ +UPDATE StatusEntity +SET + filtered = NULL +WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId = :statusId) +""", + ) + abstract suspend fun clearWarning(accountId: Long, statusId: String): Int +} diff --git a/core/database/src/main/kotlin/app/pachli/core/database/dao/TimelineDao.kt b/core/database/src/main/kotlin/app/pachli/core/database/dao/TimelineDao.kt index 9a7b90b0ab..1642b2af8f 100644 --- a/core/database/src/main/kotlin/app/pachli/core/database/dao/TimelineDao.kt +++ b/core/database/src/main/kotlin/app/pachli/core/database/dao/TimelineDao.kt @@ -31,24 +31,16 @@ import app.pachli.core.database.model.StatusEntity import app.pachli.core.database.model.StatusViewDataEntity import app.pachli.core.database.model.TimelineAccountEntity import app.pachli.core.database.model.TimelineStatusWithAccount -import app.pachli.core.network.model.Poll @Dao @TypeConverters(Converters::class) abstract class TimelineDao { - @Insert(onConflict = REPLACE) abstract suspend fun insertAccount(timelineAccountEntity: TimelineAccountEntity): Long @Upsert abstract suspend fun upsertAccounts(accounts: Collection) - @Upsert - abstract suspend fun upsertStatuses(statuses: Collection) - - @Upsert - abstract suspend fun insertStatus(statusEntity: StatusEntity): Long - @Query( """ SELECT @@ -261,36 +253,6 @@ WHERE timelineUserId = :accountId ) abstract suspend fun deleteRange(accountId: Long, minId: String, maxId: String): Int - @Query( - """ -UPDATE StatusEntity -SET - favourited = :favourited -WHERE timelineUserId = :pachliAccountId AND (serverId = :statusId OR reblogServerId = :statusId) -""", - ) - abstract suspend fun setFavourited(pachliAccountId: Long, statusId: String, favourited: Boolean) - - @Query( - """ -UPDATE StatusEntity -SET - bookmarked = :bookmarked -WHERE timelineUserId = :pachliAccountId AND (serverId = :statusId OR reblogServerId = :statusId) -""", - ) - abstract suspend fun setBookmarked(pachliAccountId: Long, statusId: String, bookmarked: Boolean) - - @Query( - """ -UPDATE StatusEntity -SET - reblogged = :reblogged -WHERE timelineUserId = :pachliAccountId AND (serverId = :statusId OR reblogServerId = :statusId) -""", - ) - abstract suspend fun setReblogged(pachliAccountId: Long, statusId: String, reblogged: Boolean) - @Query( """ DELETE @@ -341,17 +303,6 @@ WHERE timelineUserId = :accountId ) abstract suspend fun removeAllTranslatedStatus(accountId: Long) - @Query( - """ -DELETE -FROM StatusEntity -WHERE - timelineUserId = :accountId - AND serverId = :statusId -""", - ) - abstract suspend fun delete(accountId: Long, statusId: String) - /** * Cleans the StatusEntity and TimelineAccountEntity tables from old entries. * @param accountId id of the account for which to clean tables @@ -481,16 +432,6 @@ WHERE ) abstract suspend fun cleanupTranslatedStatus(accountId: Long, limit: Int) - @Query( - """ -UPDATE StatusEntity -SET - poll = :poll -WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId = :statusId) -""", - ) - abstract suspend fun setVoted(accountId: Long, statusId: String, poll: Poll) - @Upsert abstract suspend fun upsertStatusViewData(svd: StatusViewDataEntity) @@ -517,16 +458,6 @@ WHERE StatusViewDataEntity, > - @Query( - """ -UPDATE StatusEntity -SET - pinned = :pinned -WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId = :statusId) -""", - ) - abstract suspend fun setPinned(accountId: Long, statusId: String, pinned: Boolean) - @Query( """ DELETE @@ -542,16 +473,6 @@ WHERE timelineUserId = :accountId AND authorServerId IN ( ) abstract suspend fun deleteAllFromInstance(accountId: Long, instanceDomain: String) - @Query( - """ -UPDATE StatusEntity -SET - filtered = NULL -WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId = :statusId) -""", - ) - abstract suspend fun clearWarning(accountId: Long, statusId: String): Int - @Query( """ SELECT COUNT(*) diff --git a/core/database/src/main/kotlin/app/pachli/core/database/di/DatabaseModule.kt b/core/database/src/main/kotlin/app/pachli/core/database/di/DatabaseModule.kt index 8eb7e9093b..09412d5570 100644 --- a/core/database/src/main/kotlin/app/pachli/core/database/di/DatabaseModule.kt +++ b/core/database/src/main/kotlin/app/pachli/core/database/di/DatabaseModule.kt @@ -90,6 +90,9 @@ object DatabaseModule { @Provides fun providesNotificationDao(appDatabase: AppDatabase) = appDatabase.notificationDao() + + @Provides + fun providesStatusDao(appDatabase: AppDatabase) = appDatabase.statusDao() } /** diff --git a/core/database/src/test/kotlin/app/pachli/core/database/dao/TimelineDaoTest.kt b/core/database/src/test/kotlin/app/pachli/core/database/dao/TimelineDaoTest.kt index 258aa6c877..a29287df2c 100644 --- a/core/database/src/test/kotlin/app/pachli/core/database/dao/TimelineDaoTest.kt +++ b/core/database/src/test/kotlin/app/pachli/core/database/dao/TimelineDaoTest.kt @@ -54,6 +54,9 @@ class TimelineDaoTest { @Inject lateinit var timelineDao: TimelineDao + @Inject + lateinit var statusDao: StatusDao + @Before fun setup() { hilt.inject() @@ -101,7 +104,7 @@ class TimelineDaoTest { reblogger?.let { timelineDao.insertAccount(it) } - timelineDao.insertStatus(status) + statusDao.insertStatus(status) } val pagingSource = timelineDao.getStatuses(setOne.first.timelineUserId) @@ -131,7 +134,7 @@ class TimelineDaoTest { reblogAuthor?.let { timelineDao.insertAccount(it) } - timelineDao.insertStatus(status) + statusDao.insertStatus(status) } timelineDao.cleanup(accountId = 1, limit = 3) @@ -190,7 +193,7 @@ class TimelineDaoTest { reblogAuthor?.let { timelineDao.insertAccount(it) } - timelineDao.insertStatus(status) + statusDao.insertStatus(status) } // status 2 gets deleted, newly loaded status contain only 1 + 3 @@ -207,7 +210,7 @@ class TimelineDaoTest { reblogAuthor?.let { timelineDao.insertAccount(it) } - timelineDao.insertStatus(status) + statusDao.insertStatus(status) } // make sure status 2 is no longer in db @@ -240,7 +243,7 @@ class TimelineDaoTest { reblogAuthor?.let { timelineDao.insertAccount(it) } - timelineDao.insertStatus(status) + statusDao.insertStatus(status) } assertEquals(3, timelineDao.deleteRange(1, "12", "14")) @@ -314,7 +317,7 @@ class TimelineDaoTest { reblogAuthor?.let { timelineDao.insertAccount(it) } - timelineDao.insertStatus(status) + statusDao.insertStatus(status) } timelineDao.deleteAllFromInstance(1, "mastodon.red") @@ -339,7 +342,7 @@ class TimelineDaoTest { reblogger?.let { timelineDao.insertAccount(it) } - timelineDao.insertStatus(status) + statusDao.insertStatus(status) } val pagingSource = timelineDao.getStatuses(setOne.first.timelineUserId) diff --git a/core/testing/src/main/kotlin/app/pachli/core/testing/fakes/FakeDatabaseModule.kt b/core/testing/src/main/kotlin/app/pachli/core/testing/fakes/FakeDatabaseModule.kt index df34ce0b38..2a310172e0 100644 --- a/core/testing/src/main/kotlin/app/pachli/core/testing/fakes/FakeDatabaseModule.kt +++ b/core/testing/src/main/kotlin/app/pachli/core/testing/fakes/FakeDatabaseModule.kt @@ -88,4 +88,7 @@ object FakeDatabaseModule { @Provides fun providesNotificationDao(appDatabase: AppDatabase) = appDatabase.notificationDao() + + @Provides + fun providesStatusDao(appDatabase: AppDatabase) = appDatabase.statusDao() } From ffa580bd423476cdcaed422b61cf318434dd8806 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Thu, 30 Jan 2025 11:46:58 +0100 Subject: [PATCH 03/19] wip: Works, needs cleaning up - Removing the restore-position-on-swipe-refresh code worked - Document the new database stuff - Modified some bits with row fetching --- .../timeline/CachedTimelineRepository.kt | 4 +- .../components/timeline/TimelineFragment.kt | 134 +- .../viewmodel/CachedTimelineRemoteMediator.kt | 98 +- .../viewmodel/CachedTimelineViewModel.kt | 11 +- .../16.json | 2014 +++++++++++++++++ .../app/pachli/core/database/AppDatabase.kt | 5 +- .../pachli/core/database/dao/TimelineDao.kt | 164 +- .../database/model/TimelineStatusEntity.kt | 40 + 8 files changed, 2334 insertions(+), 136 deletions(-) create mode 100644 core/database/schemas/app.pachli.core.database.AppDatabase/16.json create mode 100644 core/database/src/main/kotlin/app/pachli/core/database/model/TimelineStatusEntity.kt diff --git a/app/src/main/java/app/pachli/components/timeline/CachedTimelineRepository.kt b/app/src/main/java/app/pachli/components/timeline/CachedTimelineRepository.kt index 3996fe33d4..6aef69d053 100644 --- a/app/src/main/java/app/pachli/components/timeline/CachedTimelineRepository.kt +++ b/app/src/main/java/app/pachli/components/timeline/CachedTimelineRepository.kt @@ -83,12 +83,12 @@ class CachedTimelineRepository @Inject constructor( factory = InvalidatingPagingSourceFactory { timelineDao.getStatuses(account.id) } val initialKey = remoteKeyDao.remoteKeyForKind(account.id, RKE_TIMELINE_ID, RemoteKeyKind.REFRESH)?.key - val row = initialKey?.let { timelineDao.getStatusRowNumber(account.id, it) } ?: 0 + val row = initialKey?.let { timelineDao.getStatusRowNumber(account.id, it) } Timber.d("initialKey: %s is row: %d", initialKey, row) return Pager( - initialKey = (row - ((PAGE_SIZE * 3) / 2)).coerceAtLeast(0), + initialKey = row?.let { (row - ((PAGE_SIZE * 3) / 2)).coerceAtLeast(0) }, config = PagingConfig( pageSize = PAGE_SIZE, enablePlaceholders = true, diff --git a/app/src/main/java/app/pachli/components/timeline/TimelineFragment.kt b/app/src/main/java/app/pachli/components/timeline/TimelineFragment.kt index b3b67f1e29..0d65dbf71b 100644 --- a/app/src/main/java/app/pachli/components/timeline/TimelineFragment.kt +++ b/app/src/main/java/app/pachli/components/timeline/TimelineFragment.kt @@ -96,12 +96,14 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.conflate +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.zip import kotlinx.coroutines.launch import postPrepend import timber.log.Timber @@ -187,6 +189,7 @@ class TimelineFragment : isSwipeToRefreshEnabled = arguments.getBoolean(ARG_ENABLE_SWIPE_TO_REFRESH, true) adapter = TimelinePagingAdapter(this, viewModel.statusDisplayOptions.value) + adapter.stateRestorationPolicy = RecyclerView.Adapter.StateRestorationPolicy.PREVENT } override fun onCreateView( @@ -221,6 +224,26 @@ class TimelineFragment : } } + viewLifecycleOwner.lifecycleScope.launch { + adapter.onPagesUpdatedFlow.map { adapter.snapshot() }.distinctUntilChanged() + .combine(adapter.loadStateFlow.distinctUntilChanged()) { s, l -> + Pair(s, l) + }.collect { (snapshot, l) -> + Timber.e("snapshot-loadstate") + Timber.d(" loadstate: $l") + Timber.d(" loadstate.refresh: ${l.refresh}") + Timber.d(" loadstate.source.refresh: ${l.source.refresh}") + Timber.d(" loadstate.mediator.refresh: ${l.mediator?.refresh}") + Timber.d(" loadstate.prepend: ${l.prepend}") + Timber.d(" loadstate. append: ${l.append}") + Timber.d(" snapshot size = ${snapshot.items.size}") + Timber.d(" first ID: ${snapshot.items.firstOrNull()?.id}") + Timber.d(" last ID: ${snapshot.items.lastOrNull()?.id}") + Timber.d(" p before: ${snapshot.placeholdersBefore}") + Timber.d(" p after: ${snapshot.placeholdersAfter}") + } + } + viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { launch { @@ -231,6 +254,7 @@ class TimelineFragment : .take(1) .filterNotNull() .collect { key -> + Timber.d("First onPagesUpdatedFlow, restoring $key") val snapshot = adapter.snapshot() val index = snapshot.items.indexOfFirst { it.id == key } binding.recyclerView.scrollToPosition( @@ -240,7 +264,12 @@ class TimelineFragment : } } - launch { viewModel.statuses.collectLatest { adapter.submitData(it) } } + launch { + viewModel.statuses.collectLatest { + Timber.w("Calling submitData(), expect a pageflow update") + adapter.submitData(it) + } + } launch { viewModel.uiResult.collect(::bindUiResult) } @@ -393,7 +422,7 @@ class TimelineFragment : } (indexedViewData.value as StatusViewData).status = status - adapter.notifyItemChanged(indexedViewData.index) +// adapter.notifyItemChanged(indexedViewData.index) } // Refresh adapter on mutes and blocks @@ -434,13 +463,101 @@ class TimelineFragment : private fun refreshAdapterAndScrollToVisibleId() { getFirstVisibleStatus()?.id?.let { id -> viewLifecycleOwner.lifecycleScope.launch { - adapter.onPagesUpdatedFlow.conflate().take(1).collect { - binding.recyclerView.scrollToPosition( - adapter.snapshot().items.indexOfFirst { it.id == id }, - ) - } + // Trying with postPrepend + // + // Doesn't work -- sometimes the item ID is not found in + // snapshot.items.indexOfFirst (returns -1) +// adapter.postPrepend { +// val snapshot = adapter.snapshot() +// val index = snapshot.items.indexOfFirst { it.id == id } +// Timber.w("New scroll index: ${snapshot.placeholdersBefore} + $index") +// if (index == -1) return@postPrepend +// binding.recyclerView.scrollToPosition(index) +// } + + // Trying with different loadStateFlow code from + // https://github.com/android/codelab-android-paging/issues/149 + // + // Doesn't work -- it's never called after refresh +// adapter.loadStateFlow +// .distinctUntilChanged { old, new -> +// old.mediator?.prepend?.endOfPaginationReached.isTrue() == +// new.mediator?.prepend?.endOfPaginationReached.isTrue() +// } +// .filter { it.refresh is LoadState.NotLoading && it.prepend.endOfPaginationReached && !it.append.endOfPaginationReached } +// .collect { +// val snapshot = adapter.snapshot() +// val index = snapshot.items.indexOfFirst { it.id == id } +// Timber.w("New scroll index: ${snapshot.placeholdersBefore} + $index") +// if (index == -1) return@collect +// binding.recyclerView.scrollToPosition(index) +// } + + // From reddit thread, same problem as above, never called. +// adapter.loadStateFlow +// .distinctUntilChanged { old, new -> +// old.prepend.endOfPaginationReached == new.prepend.endOfPaginationReached +// } +// .filter { it.refresh is LoadState.NotLoading } +// .filter { it.prepend.endOfPaginationReached } +// .filter { !it.append.endOfPaginationReached } +// .collect { +// val snapshot = adapter.snapshot() +// val index = snapshot.items.indexOfFirst { it.id == id } +// Timber.w("New scroll index: ${snapshot.placeholdersBefore} + $index") +// if (index == -1) return@collect +// binding.recyclerView.scrollToPosition(index) +// } + + // Doesn't work, jumps around, with or without adding + // snapshot.placeholdersBefore +// adapter.onPagesUpdatedFlow.conflate() +// .map { +// val snapshot = adapter.snapshot() +// val index = snapshot.items.indexOfFirst { it.id == id } +// Pair(snapshot, index) +// } +// .filter { (_, index) -> index != -1 } +// .take(1) +// .collect { (snapshot, index) -> +// Timber.e("New scroll index: ${snapshot.placeholdersBefore} + $index") +// if (index == -1) return@collect +// // binding.recyclerView.scrollToPosition(snapshot.placeholdersBefore + index) +// binding.recyclerView.scrollToPosition(index) +// cancel(null) +// } + + // Just like on very first page load. + // Still loses place. +// adapter.onPagesUpdatedFlow +// .take(1) +// .collect { +// Timber.d("Post refresh onPagesUpdatedFlow, restoring $id") +// val snapshot = adapter.snapshot() +// val index = snapshot.items.indexOfFirst { it.id == id } +// binding.recyclerView.scrollToPosition( +// snapshot.placeholdersBefore + index, +// ) +// } +// adapter.loadStateFlow +// .filter { it.source.refresh is LoadState.NotLoading && it.mediator?.refresh is LoadState.NotLoading } +// .take(1) +// .combine(adapter.onPagesUpdatedFlow) { _, _ -> } +// .collect { +// Timber.d("Post refresh, restoring $id") +// val snapshot = adapter.snapshot() +// val index = snapshot.items.indexOfFirst { it.id == id } +// Timber.e("New scroll index: ${snapshot.placeholdersBefore} + $index") +// binding.recyclerView.scrollToPosition( +// snapshot.placeholdersBefore + index, +// ) +// cancel(null) +// } + + // Just log what happens } } + adapter.refresh() } @@ -472,7 +589,6 @@ class TimelineFragment : R.id.action_load_newest -> { Timber.d("Reload because user chose load newest menu item") viewModel.accept(InfallibleUiAction.LoadNewest) - refreshContent() true } else -> false diff --git a/app/src/main/java/app/pachli/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt b/app/src/main/java/app/pachli/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt index 768f846d63..ebb4d917a4 100644 --- a/app/src/main/java/app/pachli/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt +++ b/app/src/main/java/app/pachli/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt @@ -29,6 +29,7 @@ import app.pachli.core.database.model.RemoteKeyEntity import app.pachli.core.database.model.RemoteKeyEntity.RemoteKeyKind import app.pachli.core.database.model.StatusEntity import app.pachli.core.database.model.TimelineAccountEntity +import app.pachli.core.database.model.TimelineStatusEntity import app.pachli.core.database.model.TimelineStatusWithAccount import app.pachli.core.network.model.Links import app.pachli.core.network.model.Status @@ -58,61 +59,61 @@ class CachedTimelineRemoteMediator( Timber.d("load(), account ID: %d, LoadType = %s", pachliAccountId, loadType) return try { - val response = when (loadType) { - LoadType.REFRESH -> { - // Ignore the provided state, always try and fetch from the remote - // REFRESH key. - val statusId = remoteKeyDao.remoteKeyForKind( - pachliAccountId, - RKE_TIMELINE_ID, - RemoteKeyKind.REFRESH, - )?.key - Timber.d("Loading from item: %s", statusId) - getInitialPage(statusId, state.config.pageSize) - } + transactionProvider { + val response = when (loadType) { + LoadType.REFRESH -> { + // Ignore the provided state, always try and fetch from the remote + // REFRESH key. + val statusId = remoteKeyDao.remoteKeyForKind( + pachliAccountId, + RKE_TIMELINE_ID, + RemoteKeyKind.REFRESH, + )?.key + Timber.d("Refresh from item: %s", statusId) + getInitialPage(statusId, state.config.pageSize) + } - LoadType.PREPEND -> { - val rke = remoteKeyDao.remoteKeyForKind( - pachliAccountId, - RKE_TIMELINE_ID, - RemoteKeyKind.PREV, - ) ?: return MediatorResult.Success(endOfPaginationReached = true) - Timber.d("Loading from remoteKey: %s", rke) - mastodonApi.homeTimeline(minId = rke.key, limit = state.config.pageSize) - } + LoadType.PREPEND -> { + val rke = remoteKeyDao.remoteKeyForKind( + pachliAccountId, + RKE_TIMELINE_ID, + RemoteKeyKind.PREV, + ) ?: return@transactionProvider MediatorResult.Success(endOfPaginationReached = true) + Timber.d("Prepend from remoteKey: %s", rke) + mastodonApi.homeTimeline(minId = rke.key, limit = state.config.pageSize) + } - LoadType.APPEND -> { - val rke = remoteKeyDao.remoteKeyForKind( - pachliAccountId, - RKE_TIMELINE_ID, - RemoteKeyKind.NEXT, - ) ?: return MediatorResult.Success(endOfPaginationReached = true) - Timber.d("Loading from remoteKey: %s", rke) - mastodonApi.homeTimeline(maxId = rke.key, limit = state.config.pageSize) + LoadType.APPEND -> { + val rke = remoteKeyDao.remoteKeyForKind( + pachliAccountId, + RKE_TIMELINE_ID, + RemoteKeyKind.NEXT, + ) ?: return@transactionProvider MediatorResult.Success(endOfPaginationReached = true) + Timber.d("Append from remoteKey: %s", rke) + mastodonApi.homeTimeline(maxId = rke.key, limit = state.config.pageSize) + } } - } - val statuses = response.body() - if (!response.isSuccessful || statuses == null) { - return MediatorResult.Error(HttpException(response)) - } + val statuses = response.body() + if (!response.isSuccessful || statuses == null) { + return@transactionProvider MediatorResult.Error(HttpException(response)) + } - Timber.d("%d - # statuses loaded", statuses.size) + Timber.d("%d - # statuses loaded", statuses.size) - // This request succeeded with no new data, and pagination ends (unless this is a - // REFRESH, which must always set endOfPaginationReached to false). - if (statuses.isEmpty()) { - return MediatorResult.Success(endOfPaginationReached = loadType != LoadType.REFRESH) - } + // This request succeeded with no new data, and pagination ends (unless this is a + // REFRESH, which must always set endOfPaginationReached to false). + if (statuses.isEmpty()) { + return@transactionProvider MediatorResult.Success(endOfPaginationReached = loadType != LoadType.REFRESH) + } - Timber.d(" %s..%s", statuses.first().id, statuses.last().id) + Timber.d(" %s..%s", statuses.first().id, statuses.last().id) - val links = Links.from(response.headers()["link"]) + val links = Links.from(response.headers()["link"]) - transactionProvider { when (loadType) { LoadType.REFRESH -> { - remoteKeyDao.deletePrevNext(pachliAccountId, RKE_TIMELINE_ID) +// remoteKeyDao.deletePrevNext(pachliAccountId, RKE_TIMELINE_ID) timelineDao.deleteAllStatusesForAccount(pachliAccountId) remoteKeyDao.upsert( @@ -262,6 +263,15 @@ class CachedTimelineRemoteMediator( timelineDao.upsertAccounts(accounts.map { TimelineAccountEntity.from(it, pachliAccountId) }) statusDao.upsertStatuses(statuses.map { StatusEntity.from(it, pachliAccountId) }) + timelineDao.upsertStatuses( + statuses.map { + TimelineStatusEntity( + kind = TimelineStatusEntity.Kind.HOME, + pachliAccountId = pachliAccountId, + statusId = it.id, + ) + }, + ) } companion object { diff --git a/app/src/main/java/app/pachli/components/timeline/viewmodel/CachedTimelineViewModel.kt b/app/src/main/java/app/pachli/components/timeline/viewmodel/CachedTimelineViewModel.kt index 0d1735e00d..ae744e36f2 100644 --- a/app/src/main/java/app/pachli/components/timeline/viewmodel/CachedTimelineViewModel.kt +++ b/app/src/main/java/app/pachli/components/timeline/viewmodel/CachedTimelineViewModel.kt @@ -42,6 +42,8 @@ import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChangedBy +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map @@ -74,9 +76,12 @@ class CachedTimelineViewModel @Inject constructor( flow { emit(repository.getRefreshKey(it.data!!.id)) } } - override var statuses = accountFlow.flatMapLatest { - getStatuses(it.data!!) - }.cachedIn(viewModelScope) + override var statuses = accountFlow + .distinctUntilChangedBy { it.data!!.id } + .flatMapLatest { + Timber.w("Triggering getStatuses") + getStatuses(it.data!!) + }.cachedIn(viewModelScope) /** @return Flow of statuses that make up the timeline of [timeline] for [account]. */ private suspend fun getStatuses( diff --git a/core/database/schemas/app.pachli.core.database.AppDatabase/16.json b/core/database/schemas/app.pachli.core.database.AppDatabase/16.json new file mode 100644 index 0000000000..32a11787a4 --- /dev/null +++ b/core/database/schemas/app.pachli.core.database.AppDatabase/16.json @@ -0,0 +1,2014 @@ +{ + "formatVersion": 1, + "database": { + "version": 16, + "identityHash": "d1beca6e5800394037cfbdac9fb23916", + "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, `failedToSendNew` INTEGER NOT NULL, `scheduledAt` INTEGER, `language` TEXT, `statusId` TEXT, FOREIGN KEY(`accountId`) REFERENCES `AccountEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "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 + }, + { + "fieldPath": "failedToSendNew", + "columnName": "failedToSendNew", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "scheduledAt", + "columnName": "scheduledAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "statusId", + "columnName": "statusId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_DraftEntity_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DraftEntity_accountId` ON `${TABLE_NAME}` (`accountId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "accountId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "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, `clientId` TEXT NOT NULL, `clientSecret` TEXT NOT NULL, `isActive` INTEGER NOT NULL, `accountId` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `profilePictureUrl` TEXT NOT NULL, `profileHeaderPictureUrl` TEXT NOT NULL DEFAULT '', `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, `notificationsSignUps` INTEGER NOT NULL, `notificationsUpdates` INTEGER NOT NULL, `notificationsReports` INTEGER NOT NULL, `notificationsSeveredRelationships` INTEGER NOT NULL DEFAULT true, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `defaultPostLanguage` TEXT NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `alwaysOpenSpoiler` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `notificationMarkerId` TEXT NOT NULL DEFAULT '0', `emojis` TEXT NOT NULL, `tabPreferences` TEXT NOT NULL, `notificationsFilter` TEXT NOT NULL, `oauthScopes` TEXT NOT NULL, `unifiedPushUrl` TEXT NOT NULL, `pushPubKey` TEXT NOT NULL, `pushPrivKey` TEXT NOT NULL, `pushAuth` TEXT NOT NULL, `pushServerKey` TEXT NOT NULL, `locked` INTEGER NOT NULL DEFAULT 0, `notificationAccountFilterNotFollowed` TEXT NOT NULL DEFAULT 'NONE', `notificationAccountFilterYounger30d` TEXT NOT NULL DEFAULT 'NONE', `notificationAccountFilterLimitedByServer` TEXT NOT NULL DEFAULT 'NONE')", + "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": "clientId", + "columnName": "clientId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clientSecret", + "columnName": "clientSecret", + "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": "profileHeaderPictureUrl", + "columnName": "profileHeaderPictureUrl", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "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": "notificationsSignUps", + "columnName": "notificationsSignUps", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsUpdates", + "columnName": "notificationsUpdates", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsReports", + "columnName": "notificationsReports", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsSeveredRelationships", + "columnName": "notificationsSeveredRelationships", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "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": "defaultPostLanguage", + "columnName": "defaultPostLanguage", + "affinity": "TEXT", + "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": "notificationMarkerId", + "columnName": "notificationMarkerId", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'0'" + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tabPreferences", + "columnName": "tabPreferences", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationsFilter", + "columnName": "notificationsFilter", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "oauthScopes", + "columnName": "oauthScopes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unifiedPushUrl", + "columnName": "unifiedPushUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushPubKey", + "columnName": "pushPubKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushPrivKey", + "columnName": "pushPrivKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushAuth", + "columnName": "pushAuth", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushServerKey", + "columnName": "pushServerKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "locked", + "columnName": "locked", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "notificationAccountFilterNotFollowed", + "columnName": "notificationAccountFilterNotFollowed", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'NONE'" + }, + { + "fieldPath": "notificationAccountFilterYounger30d", + "columnName": "notificationAccountFilterYounger30d", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'NONE'" + }, + { + "fieldPath": "notificationAccountFilterLimitedByServer", + "columnName": "notificationAccountFilterLimitedByServer", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'NONE'" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "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": "InstanceInfoEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`instance` TEXT NOT NULL, `maxPostCharacters` INTEGER, `maxPollOptions` INTEGER, `maxPollOptionLength` INTEGER, `minPollDuration` INTEGER, `maxPollDuration` INTEGER, `charactersReservedPerUrl` INTEGER, `version` TEXT, `videoSizeLimit` INTEGER, `imageSizeLimit` INTEGER, `imageMatrixLimit` INTEGER, `maxMediaAttachments` INTEGER, `maxFields` INTEGER, `maxFieldNameLength` INTEGER, `maxFieldValueLength` INTEGER, `enabledTranslation` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`instance`))", + "fields": [ + { + "fieldPath": "instance", + "columnName": "instance", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "maxPostCharacters", + "columnName": "maxPostCharacters", + "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 + }, + { + "fieldPath": "videoSizeLimit", + "columnName": "videoSizeLimit", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "imageSizeLimit", + "columnName": "imageSizeLimit", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "imageMatrixLimit", + "columnName": "imageMatrixLimit", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxMediaAttachments", + "columnName": "maxMediaAttachments", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxFields", + "columnName": "maxFields", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxFieldNameLength", + "columnName": "maxFieldNameLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxFieldValueLength", + "columnName": "maxFieldValueLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "enabledTranslation", + "columnName": "enabledTranslation", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "instance" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "EmojisEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `emojiList` TEXT NOT NULL, PRIMARY KEY(`accountId`), FOREIGN KEY(`accountId`) REFERENCES `AccountEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "emojiList", + "columnName": "emojiList", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "accountId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "accountId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "StatusEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `url` TEXT, `timelineUserId` INTEGER NOT NULL, `authorServerId` TEXT NOT NULL, `inReplyToId` TEXT, `inReplyToAccountId` TEXT, `content` TEXT, `createdAt` INTEGER NOT NULL, `editedAt` INTEGER, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` INTEGER NOT NULL, `repliesCount` 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, `pinned` INTEGER NOT NULL, `card` TEXT, `language` TEXT, `filtered` TEXT, PRIMARY KEY(`serverId`, `timelineUserId`), FOREIGN KEY(`authorServerId`, `timelineUserId`) REFERENCES `TimelineAccountEntity`(`serverId`, `timelineUserId`) ON UPDATE NO ACTION ON DELETE NO ACTION DEFERRABLE INITIALLY DEFERRED)", + "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": true + }, + { + "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": "editedAt", + "columnName": "editedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "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": "repliesCount", + "columnName": "repliesCount", + "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": "pinned", + "columnName": "pinned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "card", + "columnName": "card", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "filtered", + "columnName": "filtered", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "timelineUserId" + ] + }, + "indices": [ + { + "name": "index_StatusEntity_authorServerId_timelineUserId", + "unique": false, + "columnNames": [ + "authorServerId", + "timelineUserId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_StatusEntity_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, `createdAt` INTEGER, `limited` INTEGER NOT NULL DEFAULT false, `note` TEXT NOT NULL DEFAULT '', PRIMARY KEY(`serverId`, `timelineUserId`), FOREIGN KEY(`timelineUserId`) REFERENCES `AccountEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "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 + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "limited", + "columnName": "limited", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "timelineUserId" + ] + }, + "indices": [ + { + "name": "index_TimelineAccountEntity_timelineUserId", + "unique": false, + "columnNames": [ + "timelineUserId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TimelineAccountEntity_timelineUserId` ON `${TABLE_NAME}` (`timelineUserId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "timelineUserId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ConversationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT NOT NULL, `order` INTEGER 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_editedAt` INTEGER, `s_emojis` TEXT NOT NULL, `s_favouritesCount` INTEGER NOT NULL, `s_repliesCount` 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_collapsed` INTEGER NOT NULL, `s_muted` INTEGER NOT NULL, `s_poll` TEXT, `s_language` TEXT, PRIMARY KEY(`id`, `accountId`), FOREIGN KEY(`accountId`) REFERENCES `AccountEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "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.editedAt", + "columnName": "s_editedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastStatus.emojis", + "columnName": "s_emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.favouritesCount", + "columnName": "s_favouritesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.repliesCount", + "columnName": "s_repliesCount", + "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.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 + }, + { + "fieldPath": "lastStatus.language", + "columnName": "s_language", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id", + "accountId" + ] + }, + "indices": [ + { + "name": "index_ConversationEntity_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationEntity_accountId` ON `${TABLE_NAME}` (`accountId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "accountId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "RemoteKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `timelineId` TEXT NOT NULL, `kind` TEXT NOT NULL, `key` TEXT, PRIMARY KEY(`accountId`, `timelineId`, `kind`), FOREIGN KEY(`accountId`) REFERENCES `AccountEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timelineId", + "columnName": "timelineId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "accountId", + "timelineId", + "kind" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "accountId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "StatusViewDataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `timelineUserId` INTEGER NOT NULL, `expanded` INTEGER NOT NULL, `contentShowing` INTEGER NOT NULL, `contentCollapsed` INTEGER NOT NULL, `translationState` TEXT NOT NULL DEFAULT 'SHOW_ORIGINAL', PRIMARY KEY(`serverId`, `timelineUserId`), FOREIGN KEY(`timelineUserId`) REFERENCES `AccountEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "expanded", + "columnName": "expanded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentShowing", + "columnName": "contentShowing", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentCollapsed", + "columnName": "contentCollapsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "translationState", + "columnName": "translationState", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'SHOW_ORIGINAL'" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "timelineUserId" + ] + }, + "indices": [ + { + "name": "index_StatusViewDataEntity_timelineUserId", + "unique": false, + "columnNames": [ + "timelineUserId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_StatusViewDataEntity_timelineUserId` ON `${TABLE_NAME}` (`timelineUserId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "timelineUserId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "TranslatedStatusEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `timelineUserId` INTEGER NOT NULL, `content` TEXT NOT NULL, `spoilerText` TEXT NOT NULL, `poll` TEXT, `attachments` TEXT NOT NULL, `provider` TEXT NOT NULL, PRIMARY KEY(`serverId`, `timelineUserId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "spoilerText", + "columnName": "spoilerText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "provider", + "columnName": "provider", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "timelineUserId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "LogEntryEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `instant` INTEGER NOT NULL, `priority` INTEGER, `tag` TEXT, `message` TEXT NOT NULL, `t` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "instant", + "columnName": "instant", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "priority", + "columnName": "priority", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "tag", + "columnName": "tag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "message", + "columnName": "message", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "t", + "columnName": "t", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "MastodonListEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `listId` TEXT NOT NULL, `title` TEXT NOT NULL, `repliesPolicy` TEXT NOT NULL, `exclusive` INTEGER NOT NULL, PRIMARY KEY(`accountId`, `listId`), FOREIGN KEY(`accountId`) REFERENCES `AccountEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "listId", + "columnName": "listId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "repliesPolicy", + "columnName": "repliesPolicy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "exclusive", + "columnName": "exclusive", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "accountId", + "listId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "accountId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ServerEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `serverKind` TEXT NOT NULL, `version` TEXT NOT NULL, `capabilities` TEXT NOT NULL, PRIMARY KEY(`accountId`), FOREIGN KEY(`accountId`) REFERENCES `AccountEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverKind", + "columnName": "serverKind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "capabilities", + "columnName": "capabilities", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "accountId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "accountId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ContentFiltersEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `version` TEXT NOT NULL, `contentFilters` TEXT NOT NULL, PRIMARY KEY(`accountId`), FOREIGN KEY(`accountId`) REFERENCES `AccountEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contentFilters", + "columnName": "contentFilters", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "accountId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "accountId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "AnnouncementEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `announcementId` TEXT NOT NULL, `announcement` TEXT NOT NULL, PRIMARY KEY(`accountId`), FOREIGN KEY(`accountId`) REFERENCES `AccountEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "announcementId", + "columnName": "announcementId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "announcement", + "columnName": "announcement", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "accountId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "accountId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "FollowingAccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`pachliAccountId` INTEGER NOT NULL, `serverId` TEXT NOT NULL, PRIMARY KEY(`pachliAccountId`, `serverId`), FOREIGN KEY(`pachliAccountId`) REFERENCES `AccountEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "pachliAccountId", + "columnName": "pachliAccountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "pachliAccountId", + "serverId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "pachliAccountId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "NotificationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`pachliAccountId` INTEGER NOT NULL, `serverId` TEXT NOT NULL, `type` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `accountServerId` TEXT NOT NULL, `statusServerId` TEXT, PRIMARY KEY(`pachliAccountId`, `serverId`), FOREIGN KEY(`pachliAccountId`) REFERENCES `AccountEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, FOREIGN KEY(`accountServerId`, `pachliAccountId`) REFERENCES `TimelineAccountEntity`(`serverId`, `timelineUserId`) ON UPDATE NO ACTION ON DELETE NO ACTION DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "pachliAccountId", + "columnName": "pachliAccountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountServerId", + "columnName": "accountServerId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "statusServerId", + "columnName": "statusServerId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "pachliAccountId", + "serverId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "pachliAccountId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "TimelineAccountEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "accountServerId", + "pachliAccountId" + ], + "referencedColumns": [ + "serverId", + "timelineUserId" + ] + } + ] + }, + { + "tableName": "NotificationReportEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`pachliAccountId` INTEGER NOT NULL, `serverId` TEXT NOT NULL, `reportId` TEXT NOT NULL, `actionTaken` INTEGER NOT NULL, `actionTakenAt` INTEGER, `category` TEXT NOT NULL, `comment` TEXT NOT NULL, `forwarded` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `statusIds` TEXT, `ruleIds` TEXT, `target_serverId` TEXT NOT NULL, `target_timelineUserId` INTEGER NOT NULL, `target_localUsername` TEXT NOT NULL, `target_username` TEXT NOT NULL, `target_displayName` TEXT NOT NULL, `target_url` TEXT NOT NULL, `target_avatar` TEXT NOT NULL, `target_emojis` TEXT NOT NULL, `target_bot` INTEGER NOT NULL, `target_createdAt` INTEGER, `target_limited` INTEGER NOT NULL DEFAULT false, `target_note` TEXT NOT NULL DEFAULT '', PRIMARY KEY(`pachliAccountId`, `serverId`), FOREIGN KEY(`pachliAccountId`, `serverId`) REFERENCES `NotificationEntity`(`pachliAccountId`, `serverId`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "pachliAccountId", + "columnName": "pachliAccountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "reportId", + "columnName": "reportId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "actionTaken", + "columnName": "actionTaken", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "actionTakenAt", + "columnName": "actionTakenAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "category", + "columnName": "category", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "comment", + "columnName": "comment", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "forwarded", + "columnName": "forwarded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "statusIds", + "columnName": "statusIds", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ruleIds", + "columnName": "ruleIds", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "targetAccount.serverId", + "columnName": "target_serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "targetAccount.timelineUserId", + "columnName": "target_timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "targetAccount.localUsername", + "columnName": "target_localUsername", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "targetAccount.username", + "columnName": "target_username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "targetAccount.displayName", + "columnName": "target_displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "targetAccount.url", + "columnName": "target_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "targetAccount.avatar", + "columnName": "target_avatar", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "targetAccount.emojis", + "columnName": "target_emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "targetAccount.bot", + "columnName": "target_bot", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "targetAccount.createdAt", + "columnName": "target_createdAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "targetAccount.limited", + "columnName": "target_limited", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "targetAccount.note", + "columnName": "target_note", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "pachliAccountId", + "serverId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "NotificationEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "pachliAccountId", + "serverId" + ], + "referencedColumns": [ + "pachliAccountId", + "serverId" + ] + } + ] + }, + { + "tableName": "NotificationViewDataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`pachliAccountId` INTEGER NOT NULL, `serverId` TEXT NOT NULL, `contentFilterAction` TEXT, `accountFilterDecision` TEXT, PRIMARY KEY(`pachliAccountId`, `serverId`), FOREIGN KEY(`pachliAccountId`) REFERENCES `AccountEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "pachliAccountId", + "columnName": "pachliAccountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contentFilterAction", + "columnName": "contentFilterAction", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "accountFilterDecision", + "columnName": "accountFilterDecision", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "pachliAccountId", + "serverId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "pachliAccountId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "NotificationRelationshipSeveranceEventEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`pachliAccountId` INTEGER NOT NULL, `serverId` TEXT NOT NULL, `eventId` TEXT NOT NULL, `type` TEXT NOT NULL, `purged` INTEGER NOT NULL, `followersCount` INTEGER NOT NULL, `followingCount` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, PRIMARY KEY(`pachliAccountId`, `serverId`, `eventId`), FOREIGN KEY(`pachliAccountId`, `serverId`) REFERENCES `NotificationEntity`(`pachliAccountId`, `serverId`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "pachliAccountId", + "columnName": "pachliAccountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "eventId", + "columnName": "eventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "purged", + "columnName": "purged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "followersCount", + "columnName": "followersCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "followingCount", + "columnName": "followingCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "pachliAccountId", + "serverId", + "eventId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "NotificationEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "pachliAccountId", + "serverId" + ], + "referencedColumns": [ + "pachliAccountId", + "serverId" + ] + } + ] + }, + { + "tableName": "TimelineStatusEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`kind` TEXT NOT NULL, `pachliAccountId` INTEGER NOT NULL, `statusId` TEXT NOT NULL, PRIMARY KEY(`kind`, `pachliAccountId`, `statusId`))", + "fields": [ + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pachliAccountId", + "columnName": "pachliAccountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "statusId", + "columnName": "statusId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "kind", + "pachliAccountId", + "statusId" + ] + }, + "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, 'd1beca6e5800394037cfbdac9fb23916')" + ] + } +} \ No newline at end of file diff --git a/core/database/src/main/kotlin/app/pachli/core/database/AppDatabase.kt b/core/database/src/main/kotlin/app/pachli/core/database/AppDatabase.kt index 15d9957b66..1490714e35 100644 --- a/core/database/src/main/kotlin/app/pachli/core/database/AppDatabase.kt +++ b/core/database/src/main/kotlin/app/pachli/core/database/AppDatabase.kt @@ -64,6 +64,7 @@ import app.pachli.core.database.model.ServerEntity import app.pachli.core.database.model.StatusEntity import app.pachli.core.database.model.StatusViewDataEntity import app.pachli.core.database.model.TimelineAccountEntity +import app.pachli.core.database.model.TimelineStatusEntity import app.pachli.core.database.model.TranslatedStatusEntity import app.pachli.core.model.ContentFilterVersion import java.text.SimpleDateFormat @@ -93,8 +94,9 @@ import java.util.TimeZone NotificationReportEntity::class, NotificationViewDataEntity::class, NotificationRelationshipSeveranceEventEntity::class, + TimelineStatusEntity::class, ], - version = 15, + version = 16, autoMigrations = [ AutoMigration(from = 1, to = 2, spec = AppDatabase.MIGRATE_1_2::class), AutoMigration(from = 2, to = 3), @@ -110,6 +112,7 @@ import java.util.TimeZone // 12 -> 13 is a custom migration AutoMigration(from = 13, to = 14, spec = AppDatabase.MIGRATE_13_14::class), AutoMigration(from = 14, to = 15, spec = AppDatabase.MIGRATE_14_15::class), + AutoMigration(from = 15, to = 16), ], ) abstract class AppDatabase : RoomDatabase() { diff --git a/core/database/src/main/kotlin/app/pachli/core/database/dao/TimelineDao.kt b/core/database/src/main/kotlin/app/pachli/core/database/dao/TimelineDao.kt index 1642b2af8f..eb37e8939d 100644 --- a/core/database/src/main/kotlin/app/pachli/core/database/dao/TimelineDao.kt +++ b/core/database/src/main/kotlin/app/pachli/core/database/dao/TimelineDao.kt @@ -30,11 +30,15 @@ import app.pachli.core.database.Converters import app.pachli.core.database.model.StatusEntity import app.pachli.core.database.model.StatusViewDataEntity import app.pachli.core.database.model.TimelineAccountEntity +import app.pachli.core.database.model.TimelineStatusEntity import app.pachli.core.database.model.TimelineStatusWithAccount @Dao @TypeConverters(Converters::class) abstract class TimelineDao { + @Upsert + abstract suspend fun upsertStatuses(entities: List) + @Insert(onConflict = REPLACE) abstract suspend fun insertAccount(timelineAccountEntity: TimelineAccountEntity): Long @@ -105,27 +109,31 @@ SELECT svd.contentShowing AS 'svd_contentShowing', svd.contentCollapsed AS 'svd_contentCollapsed', svd.translationState AS 'svd_translationState', - t.serverId AS 't_serverId', - t.timelineUserId AS 't_timelineUserId', - t.content AS 't_content', - t.spoilerText AS 't_spoilerText', - t.poll AS 't_poll', - t.attachments AS 't_attachments', - t.provider AS 't_provider' -FROM StatusEntity AS s + tr.serverId AS 't_serverId', + tr.timelineUserId AS 't_timelineUserId', + tr.content AS 't_content', + tr.spoilerText AS 't_spoilerText', + tr.poll AS 't_poll', + tr.attachments AS 't_attachments', + tr.provider AS 't_provider' +FROM TimelineStatusEntity AS t +LEFT JOIN StatusEntity AS s ON (t.pachliAccountId = :account AND (s.timelineUserId = :account AND s.serverId = t.statusId)) LEFT JOIN TimelineAccountEntity AS a ON (s.timelineUserId = a.timelineUserId AND s.authorServerId = a.serverId) LEFT JOIN TimelineAccountEntity AS rb ON (s.timelineUserId = rb.timelineUserId AND s.reblogAccountId = rb.serverId) LEFT JOIN StatusViewDataEntity AS svd ON (s.timelineUserId = svd.timelineUserId AND (s.serverId = svd.serverId OR s.reblogServerId = svd.serverId)) LEFT JOIN - TranslatedStatusEntity AS t - ON (s.timelineUserId = t.timelineUserId AND (s.serverId = t.serverId OR s.reblogServerId = t.serverId)) -WHERE s.timelineUserId = :account + TranslatedStatusEntity AS tr + ON (s.timelineUserId = tr.timelineUserId AND (s.serverId = tr.serverId OR s.reblogServerId = tr.serverId)) +WHERE t.kind = :timelineKind AND t.pachliAccountId = :account AND s.timelineUserId = :account ORDER BY LENGTH(s.serverId) DESC, s.serverId DESC """, ) - abstract fun getStatuses(account: Long): PagingSource + abstract fun getStatuses( + account: Long, + timelineKind: TimelineStatusEntity.Kind = TimelineStatusEntity.Kind.HOME, + ): PagingSource /** * @return Row number (0 based) of the status with ID [statusId] for [pachliAccountId]. @@ -136,13 +144,19 @@ ORDER BY LENGTH(s.serverId) DESC, s.serverId DESC """ SELECT rownum FROM ( + WITH statuses(timelineUserId, serverId) AS ( + SELECT s.timelineUserId, s.serverId + FROM TimelineStatusEntity t + LEFT JOIN StatusEntity s ON (t.statusId = s.serverId) + WHERE t.kind = :timelineKind AND t.pachliAccountId = :pachliAccountId + ) SELECT t1.timelineUserId, t1.serverId, COUNT(t2.serverId) - 1 AS rownum - FROM StatusEntity AS t1 + FROM statuses AS t1 INNER JOIN - StatusEntity AS t2 + statuses AS t2 ON t1.timelineUserId = t2.timelineUserId AND (LENGTH(t1.serverId) <= LENGTH(t2.serverId) AND t1.serverId <= t2.serverId) @@ -153,7 +167,11 @@ FROM ( WHERE serverId = :statusId """, ) - abstract suspend fun getStatusRowNumber(pachliAccountId: Long, statusId: String): Int + abstract suspend fun getStatusRowNumber( + pachliAccountId: Long, + statusId: String, + timelineKind: TimelineStatusEntity.Kind = TimelineStatusEntity.Kind.HOME, + ): Int @Query( """ @@ -240,6 +258,8 @@ WHERE AND s.authorServerId IS NOT NULL """, ) + // TODO: Probably doesn't need to use TimelineStatus. Does need a + // pachliAccountId abstract suspend fun getStatus(statusId: String): TimelineStatusWithAccount? @Query( @@ -251,39 +271,43 @@ WHERE timelineUserId = :accountId AND (LENGTH(serverId) > LENGTH(:minId) OR LENGTH(serverId) == LENGTH(:minId) AND serverId >= :minId) """, ) + // TODO: Needs to use TimelineStatus, only used in developer tools abstract suspend fun deleteRange(accountId: Long, minId: String, maxId: String): Int @Query( """ DELETE -FROM StatusEntity +FROM TimelineStatusEntity WHERE - timelineUserId = :pachliAccountId - AND (authorServerId = :userId OR reblogAccountId = :userId) + kind = :timelineKind + AND pachliAccountId = :pachliAccountId + AND statusId IN ( + SELECT serverId + FROM StatusEntity + WHERE timelineUserId = :pachliAccountId + AND (authorServerId = :userId or reblogAccountId = :userId) + ) """, ) - abstract suspend fun removeAllByUser(pachliAccountId: Long, userId: String) + abstract suspend fun removeAllByUser( + pachliAccountId: Long, + userId: String, + timelineKind: TimelineStatusEntity.Kind = TimelineStatusEntity.Kind.HOME, + ) /** - * Removes all statuses from the cached **home** timeline. - * - * Statuses that are referenced by notifications are retained, to ensure - * they show up in the Notifications list. + * Removes all statuses from [timelineKind] for [accountId] */ @Query( """ DELETE -FROM StatusEntity +FROM TimelineStatusEntity WHERE - timelineUserId = :accountId - AND serverId NOT IN ( - SELECT statusServerId - FROM NotificationEntity - WHERE statusServerId IS NOT NULL - ) + pachliAccountId = :accountId + AND kind = :timelineKind """, ) - abstract suspend fun deleteAllStatusesForAccount(accountId: Long) + abstract suspend fun deleteAllStatusesForAccount(accountId: Long, timelineKind: TimelineStatusEntity.Kind = TimelineStatusEntity.Kind.HOME) @Query( """ @@ -310,56 +334,29 @@ WHERE timelineUserId = :accountId */ @Transaction open suspend fun cleanup(accountId: Long, limit: Int) { - cleanupStatuses(accountId, limit) + cleanupStatuses(accountId) cleanupAccounts(accountId) cleanupStatusViewData(accountId, limit) cleanupTranslatedStatus(accountId, limit) } /** - * Deletes rows from [StatusEntity], keeping the newest [keep] - * statuses. + * Removes rows from [StatusEntity] that are not referenced elsewhere. * * @param accountId id of the account for which to clean statuses - * @param keep (1-based) how many statuses to keep */ @Query( """ DELETE FROM StatusEntity -WHERE timelineUserId = :accountId AND serverId IN ( - SELECT serverId - FROM ( - WITH statuses (serverId) AS ( - -- Statuses that are not associated with a notification. - -- Left join with notifications, filter to statuses where the - -- join returns a NULL notification ID (because the status has - -- no associated notification) - SELECT s.serverId - FROM StatusEntity AS s - LEFT JOIN - NotificationEntity AS n - ON (s.serverId = n.statusServerId AND s.timelineUserId = n.pachliAccountId) - WHERE n.statusServerId IS NULL AND s.timelineUserId = :accountId - ) - - -- Calculate the row number for each row, and exclude rows where - -- the row number < limit - SELECT - t1.serverId, - COUNT(t2.serverId) AS rownum - FROM statuses AS t1 - LEFT JOIN statuses AS t2 - ON - LENGTH(t1.serverId) < LENGTH(t2.serverId) - OR (LENGTH(t1.serverId) = LENGTH(t2.serverId) AND t1.serverId < t2.serverId) - GROUP BY t1.serverId - HAVING rownum > :keep - ) +WHERE timelineUserId = :accountId AND serverId NOT IN ( + SELECT statusId from TimelineStatusEntity WHERE pachliAccountId = :accountId + UNION + SELECT statusServerId FROM NotificationEntity WHERE pachliAccountId = :accountId ) """, ) - abstract suspend fun cleanupStatuses(accountId: Long, keep: Int) + abstract suspend fun cleanupStatuses(accountId: Long) /** * Cleans the TimelineAccountEntity table from accounts that are no longer referenced in the StatusEntity table @@ -460,27 +457,40 @@ WHERE @Query( """ -DELETE -FROM StatusEntity -WHERE timelineUserId = :accountId AND authorServerId IN ( - SELECT serverId - FROM TimelineAccountEntity - WHERE - username LIKE '%@' || :instanceDomain - AND timelineUserId = :accountId +WITH statuses(serverId) AS ( + -- IDs of statuses written by accounts from :instanceDomain + SELECT s.serverId + FROM StatusEntity s + LEFT JOIN TimelineAccountEntity a ON (s.timelineUserId = a.timelineUserId AND (s.authorServerId = a.serverId OR s.reblogAccountId = a.serverId)) + WHERE s.timelineUserId = :accountId AND a.username LIKE '%@' || :instanceDomain ) +DELETE +FROM TimelineStatusEntity +WHERE + kind = :timelineKind + AND pachliAccountId = :accountId + AND statusId IN (SELECT serverId FROM statuses) """, ) - abstract suspend fun deleteAllFromInstance(accountId: Long, instanceDomain: String) + abstract suspend fun deleteAllFromInstance( + accountId: Long, + instanceDomain: String, + timelineKind: TimelineStatusEntity.Kind = TimelineStatusEntity.Kind.HOME, + ) @Query( """ SELECT COUNT(*) -FROM StatusEntity -WHERE timelineUserId = :accountId +FROM TimelineStatusEntity +WHERE + kind = :timelineKind + AND pachliAccountId = :accountId """, ) - abstract suspend fun getStatusCount(accountId: Long): Int + abstract suspend fun getStatusCount( + accountId: Long, + timelineKind: TimelineStatusEntity.Kind = TimelineStatusEntity.Kind.HOME, + ): Int /** Developer tools: Find N most recent status IDs */ @Query( diff --git a/core/database/src/main/kotlin/app/pachli/core/database/model/TimelineStatusEntity.kt b/core/database/src/main/kotlin/app/pachli/core/database/model/TimelineStatusEntity.kt new file mode 100644 index 0000000000..46bf13bd11 --- /dev/null +++ b/core/database/src/main/kotlin/app/pachli/core/database/model/TimelineStatusEntity.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2025 Pachli Association + * + * This file is a part of Pachli. + * + * 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. + * + * Pachli 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 Pachli; if not, + * see . + */ + +package app.pachli.core.database.model + +import androidx.room.Entity +import androidx.room.TypeConverters +import app.pachli.core.database.Converters + +/** + * A timeline that contains items. + */ +@Entity( + primaryKeys = ["kind", "pachliAccountId", "statusId"], +) +@TypeConverters(Converters::class) +data class TimelineStatusEntity( + val kind: Kind, + val pachliAccountId: Long, + val statusId: String, +) { + /** Cacheable timeline kinds. */ + enum class Kind { + HOME, + } +} From 1ef22c75c3e30fc8cf01eed6b84c9a7eb4e0e060 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Fri, 31 Jan 2025 11:12:19 +0100 Subject: [PATCH 04/19] Delete dead refresh code --- .../components/timeline/TimelineFragment.kt | 112 +----------------- 1 file changed, 2 insertions(+), 110 deletions(-) diff --git a/app/src/main/java/app/pachli/components/timeline/TimelineFragment.kt b/app/src/main/java/app/pachli/components/timeline/TimelineFragment.kt index 0d65dbf71b..8217ffe55c 100644 --- a/app/src/main/java/app/pachli/components/timeline/TimelineFragment.kt +++ b/app/src/main/java/app/pachli/components/timeline/TimelineFragment.kt @@ -430,8 +430,7 @@ class TimelineFragment : is UiSuccess.Block, is UiSuccess.Mute, is UiSuccess.MuteConversation, - -> - refreshAdapterAndScrollToVisibleId() + -> adapter.refresh() is UiSuccess.StatusSent -> handleStatusSentOrEdit(it.status) is UiSuccess.StatusEdited -> handleStatusSentOrEdit(it.status) @@ -454,113 +453,6 @@ class TimelineFragment : } } - /** - * Refreshes the adapter, waits for the first page to be updated, and scrolls the - * recyclerview to the first status that was visible before the refresh. - * - * This ensures the user's position is not lost during adapter refreshes. - */ - private fun refreshAdapterAndScrollToVisibleId() { - getFirstVisibleStatus()?.id?.let { id -> - viewLifecycleOwner.lifecycleScope.launch { - // Trying with postPrepend - // - // Doesn't work -- sometimes the item ID is not found in - // snapshot.items.indexOfFirst (returns -1) -// adapter.postPrepend { -// val snapshot = adapter.snapshot() -// val index = snapshot.items.indexOfFirst { it.id == id } -// Timber.w("New scroll index: ${snapshot.placeholdersBefore} + $index") -// if (index == -1) return@postPrepend -// binding.recyclerView.scrollToPosition(index) -// } - - // Trying with different loadStateFlow code from - // https://github.com/android/codelab-android-paging/issues/149 - // - // Doesn't work -- it's never called after refresh -// adapter.loadStateFlow -// .distinctUntilChanged { old, new -> -// old.mediator?.prepend?.endOfPaginationReached.isTrue() == -// new.mediator?.prepend?.endOfPaginationReached.isTrue() -// } -// .filter { it.refresh is LoadState.NotLoading && it.prepend.endOfPaginationReached && !it.append.endOfPaginationReached } -// .collect { -// val snapshot = adapter.snapshot() -// val index = snapshot.items.indexOfFirst { it.id == id } -// Timber.w("New scroll index: ${snapshot.placeholdersBefore} + $index") -// if (index == -1) return@collect -// binding.recyclerView.scrollToPosition(index) -// } - - // From reddit thread, same problem as above, never called. -// adapter.loadStateFlow -// .distinctUntilChanged { old, new -> -// old.prepend.endOfPaginationReached == new.prepend.endOfPaginationReached -// } -// .filter { it.refresh is LoadState.NotLoading } -// .filter { it.prepend.endOfPaginationReached } -// .filter { !it.append.endOfPaginationReached } -// .collect { -// val snapshot = adapter.snapshot() -// val index = snapshot.items.indexOfFirst { it.id == id } -// Timber.w("New scroll index: ${snapshot.placeholdersBefore} + $index") -// if (index == -1) return@collect -// binding.recyclerView.scrollToPosition(index) -// } - - // Doesn't work, jumps around, with or without adding - // snapshot.placeholdersBefore -// adapter.onPagesUpdatedFlow.conflate() -// .map { -// val snapshot = adapter.snapshot() -// val index = snapshot.items.indexOfFirst { it.id == id } -// Pair(snapshot, index) -// } -// .filter { (_, index) -> index != -1 } -// .take(1) -// .collect { (snapshot, index) -> -// Timber.e("New scroll index: ${snapshot.placeholdersBefore} + $index") -// if (index == -1) return@collect -// // binding.recyclerView.scrollToPosition(snapshot.placeholdersBefore + index) -// binding.recyclerView.scrollToPosition(index) -// cancel(null) -// } - - // Just like on very first page load. - // Still loses place. -// adapter.onPagesUpdatedFlow -// .take(1) -// .collect { -// Timber.d("Post refresh onPagesUpdatedFlow, restoring $id") -// val snapshot = adapter.snapshot() -// val index = snapshot.items.indexOfFirst { it.id == id } -// binding.recyclerView.scrollToPosition( -// snapshot.placeholdersBefore + index, -// ) -// } -// adapter.loadStateFlow -// .filter { it.source.refresh is LoadState.NotLoading && it.mediator?.refresh is LoadState.NotLoading } -// .take(1) -// .combine(adapter.onPagesUpdatedFlow) { _, _ -> } -// .collect { -// Timber.d("Post refresh, restoring $id") -// val snapshot = adapter.snapshot() -// val index = snapshot.items.indexOfFirst { it.id == id } -// Timber.e("New scroll index: ${snapshot.placeholdersBefore} + $index") -// binding.recyclerView.scrollToPosition( -// snapshot.placeholdersBefore + index, -// ) -// cancel(null) -// } - - // Just log what happens - } - } - - adapter.refresh() - } - override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { menuInflater.inflate(R.menu.fragment_timeline, menu) @@ -693,7 +585,7 @@ class TimelineFragment : } binding.swipeRefreshLayout.isRefreshing = false - refreshAdapterAndScrollToVisibleId() + adapter.refresh() } override fun onReply(viewData: StatusViewData) { From 5c02d3d40c199d67287c6241b56b2699544f83ac Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Sat, 1 Feb 2025 10:36:40 +0100 Subject: [PATCH 05/19] Sync notif timeline with cachedtimeline code. Remove dead code --- .../notifications/NotificationsFragment.kt | 33 ++++----------- .../components/timeline/TimelineFragment.kt | 41 ++----------------- .../java/app/pachli/util/EmptyPagingSource.kt | 10 ----- .../notifications/NotificationsRepository.kt | 6 ++- 4 files changed, 15 insertions(+), 75 deletions(-) delete mode 100644 app/src/main/java/app/pachli/util/EmptyPagingSource.kt diff --git a/app/src/main/java/app/pachli/components/notifications/NotificationsFragment.kt b/app/src/main/java/app/pachli/components/notifications/NotificationsFragment.kt index 692c3490ef..9a0fbbece4 100644 --- a/app/src/main/java/app/pachli/components/notifications/NotificationsFragment.kt +++ b/app/src/main/java/app/pachli/components/notifications/NotificationsFragment.kt @@ -87,7 +87,6 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterNotNull @@ -261,6 +260,7 @@ class NotificationsFragment : } // Update the UI from the loadState + // TODO: Move to bindLoadState function adapter.loadStateFlow.distinctUntilChangedBy { it.refresh }.collect { loadState -> when (loadState.refresh) { is LoadState.Error -> { @@ -274,7 +274,6 @@ class NotificationsFragment : } LoadState.Loading -> { - /* nothing */ binding.statusView.hide() binding.progressIndicator.show() } @@ -347,12 +346,13 @@ class NotificationsFragment : // remove it. is NotificationActionSuccess.AcceptFollowRequest, is NotificationActionSuccess.RejectFollowRequest, - -> refreshAdapterAndScrollToVisibleId() + -> adapter.refresh() } } when (uiSuccess) { - is UiSuccess.Block, is UiSuccess.Mute, is UiSuccess.MuteConversation -> refreshAdapterAndScrollToVisibleId() + is UiSuccess.Block, is UiSuccess.Mute, is UiSuccess.MuteConversation, + -> adapter.refresh() is UiSuccess.LoadNewest -> { // Scroll to the top when prepending completes. @@ -371,25 +371,6 @@ class NotificationsFragment : } } - /** - * Refreshes the adapter, waits for the first page to be updated, and scrolls the - * recyclerview to the first notification that was visible before the refresh. - * - * This ensures the user's position is not lost during adapter refreshes. - */ - private fun refreshAdapterAndScrollToVisibleId() { - getFirstVisibleNotification()?.id?.let { id -> - viewLifecycleOwner.lifecycleScope.launch { - adapter.onPagesUpdatedFlow.conflate().take(1).collect { - binding.recyclerView.scrollToPosition( - adapter.snapshot().items.indexOfFirst { it.id == id }, - ) - } - } - } - adapter.refresh() - } - override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { menuInflater.inflate(R.menu.fragment_notifications, menu) menu.findItem(R.id.action_refresh)?.apply { @@ -438,7 +419,7 @@ class NotificationsFragment : } binding.swipeRefreshLayout.isRefreshing = false - refreshAdapterAndScrollToVisibleId() + adapter.refresh() clearNotificationsForAccount(requireContext(), pachliAccountId) } @@ -605,11 +586,11 @@ class NotificationsFragment : } override fun onMute(mute: Boolean, id: String, position: Int, notifications: Boolean) { - refreshAdapterAndScrollToVisibleId() + adapter.refresh() } override fun onBlock(block: Boolean, id: String, position: Int) { - refreshAdapterAndScrollToVisibleId() + adapter.refresh() } override fun onRespondToFollowRequest(accept: Boolean, accountId: String, position: Int) { diff --git a/app/src/main/java/app/pachli/components/timeline/TimelineFragment.kt b/app/src/main/java/app/pachli/components/timeline/TimelineFragment.kt index 8217ffe55c..7589ed7825 100644 --- a/app/src/main/java/app/pachli/components/timeline/TimelineFragment.kt +++ b/app/src/main/java/app/pachli/components/timeline/TimelineFragment.kt @@ -96,11 +96,9 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.take import kotlinx.coroutines.flow.zip @@ -189,7 +187,7 @@ class TimelineFragment : isSwipeToRefreshEnabled = arguments.getBoolean(ARG_ENABLE_SWIPE_TO_REFRESH, true) adapter = TimelinePagingAdapter(this, viewModel.statusDisplayOptions.value) - adapter.stateRestorationPolicy = RecyclerView.Adapter.StateRestorationPolicy.PREVENT +// adapter.stateRestorationPolicy = RecyclerView.Adapter.StateRestorationPolicy.PREVENT } override fun onCreateView( @@ -224,26 +222,6 @@ class TimelineFragment : } } - viewLifecycleOwner.lifecycleScope.launch { - adapter.onPagesUpdatedFlow.map { adapter.snapshot() }.distinctUntilChanged() - .combine(adapter.loadStateFlow.distinctUntilChanged()) { s, l -> - Pair(s, l) - }.collect { (snapshot, l) -> - Timber.e("snapshot-loadstate") - Timber.d(" loadstate: $l") - Timber.d(" loadstate.refresh: ${l.refresh}") - Timber.d(" loadstate.source.refresh: ${l.source.refresh}") - Timber.d(" loadstate.mediator.refresh: ${l.mediator?.refresh}") - Timber.d(" loadstate.prepend: ${l.prepend}") - Timber.d(" loadstate. append: ${l.append}") - Timber.d(" snapshot size = ${snapshot.items.size}") - Timber.d(" first ID: ${snapshot.items.firstOrNull()?.id}") - Timber.d(" last ID: ${snapshot.items.lastOrNull()?.id}") - Timber.d(" p before: ${snapshot.placeholdersBefore}") - Timber.d(" p after: ${snapshot.placeholdersAfter}") - } - } - viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { launch { @@ -254,7 +232,6 @@ class TimelineFragment : .take(1) .filterNotNull() .collect { key -> - Timber.d("First onPagesUpdatedFlow, restoring $key") val snapshot = adapter.snapshot() val index = snapshot.items.indexOfFirst { it.id == key } binding.recyclerView.scrollToPosition( @@ -264,12 +241,7 @@ class TimelineFragment : } } - launch { - viewModel.statuses.collectLatest { - Timber.w("Calling submitData(), expect a pageflow update") - adapter.submitData(it) - } - } + launch { viewModel.statuses.collectLatest { adapter.submitData(it) } } launch { viewModel.uiResult.collect(::bindUiResult) } @@ -299,6 +271,7 @@ class TimelineFragment : } } + // TODO: Move to bindLoadState function adapter.loadStateFlow.distinctUntilChangedBy { it.refresh }.collect { loadState -> when (loadState.refresh) { is LoadState.Error -> { @@ -312,7 +285,6 @@ class TimelineFragment : } LoadState.Loading -> { - /* nothing */ binding.statusView.hide() binding.progressIndicator.show() } @@ -320,9 +292,6 @@ class TimelineFragment : is LoadState.NotLoading -> { // Might still be loading if source.refresh is Loading, so only update // the UI when loading is completely quiet. - Timber.d("NotLoading .refresh: ${loadState.refresh}") - Timber.d(" NotLoading .source.refresh: ${loadState.source.refresh}") - Timber.d(" NotLoading .mediator.refresh: ${loadState.mediator?.refresh}") if (loadState.source.refresh !is LoadState.Loading) { binding.progressIndicator.hide() binding.swipeRefreshLayout.isRefreshing = false @@ -427,9 +396,7 @@ class TimelineFragment : // Refresh adapter on mutes and blocks when (it) { - is UiSuccess.Block, - is UiSuccess.Mute, - is UiSuccess.MuteConversation, + is UiSuccess.Block, is UiSuccess.Mute, is UiSuccess.MuteConversation, -> adapter.refresh() is UiSuccess.StatusSent -> handleStatusSentOrEdit(it.status) diff --git a/app/src/main/java/app/pachli/util/EmptyPagingSource.kt b/app/src/main/java/app/pachli/util/EmptyPagingSource.kt deleted file mode 100644 index 72afbd2d8f..0000000000 --- a/app/src/main/java/app/pachli/util/EmptyPagingSource.kt +++ /dev/null @@ -1,10 +0,0 @@ -package app.pachli.util - -import androidx.paging.PagingSource -import androidx.paging.PagingState - -class EmptyPagingSource : PagingSource() { - override fun getRefreshKey(state: PagingState): Int? = null - - override suspend fun load(params: LoadParams): LoadResult = LoadResult.Page(emptyList(), null, null) -} diff --git a/core/data/src/main/kotlin/app/pachli/core/data/repository/notifications/NotificationsRepository.kt b/core/data/src/main/kotlin/app/pachli/core/data/repository/notifications/NotificationsRepository.kt index 38cbb64454..dd1556aeae 100644 --- a/core/data/src/main/kotlin/app/pachli/core/data/repository/notifications/NotificationsRepository.kt +++ b/core/data/src/main/kotlin/app/pachli/core/data/repository/notifications/NotificationsRepository.kt @@ -57,6 +57,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.async import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.launch +import timber.log.Timber /** * Errors that can occur acting on a status. @@ -113,10 +114,10 @@ class NotificationsRepository @Inject constructor( // Room is row-keyed, not item-keyed. Find the user's REFRESH key, then find the // row of the notification with that ID, and use that as the Pager's initialKey. val initialKey = remoteKeyDao.remoteKeyForKind(pachliAccountId, RKE_TIMELINE_ID, RemoteKeyKind.REFRESH)?.key - val row = initialKey?.let { notificationDao.getNotificationRowNumber(pachliAccountId, it) } ?: 0 + val row = initialKey?.let { notificationDao.getNotificationRowNumber(pachliAccountId, it) } return Pager( - initialKey = (row - ((PAGE_SIZE * 3) / 2)).coerceAtLeast(0), + initialKey = row?.let { (row - ((PAGE_SIZE * 3) / 2)).coerceAtLeast(0) }, config = PagingConfig( pageSize = PAGE_SIZE, enablePlaceholders = true, @@ -145,6 +146,7 @@ class NotificationsRepository @Inject constructor( * refresh the newest notifications. */ suspend fun saveRefreshKey(pachliAccountId: Long, key: String?) = externalScope.async { + Timber.d("saveRefreshKey: $key") remoteKeyDao.upsert( RemoteKeyEntity(pachliAccountId, RKE_TIMELINE_ID, RemoteKeyKind.REFRESH, key), ) From 5312a0b4b72e0a1c0b016dc5e57af32739fedb4c Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Sat, 1 Feb 2025 15:19:18 +0100 Subject: [PATCH 06/19] wip: Kind as class --- .../core/database/model/TimelineStatusEntity.kt | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/core/database/src/main/kotlin/app/pachli/core/database/model/TimelineStatusEntity.kt b/core/database/src/main/kotlin/app/pachli/core/database/model/TimelineStatusEntity.kt index 46bf13bd11..6c0aab0f69 100644 --- a/core/database/src/main/kotlin/app/pachli/core/database/model/TimelineStatusEntity.kt +++ b/core/database/src/main/kotlin/app/pachli/core/database/model/TimelineStatusEntity.kt @@ -34,7 +34,16 @@ data class TimelineStatusEntity( val statusId: String, ) { /** Cacheable timeline kinds. */ - enum class Kind { - HOME, + // TODO: Eventually these have to be the timeline kind types for + // RemoteKeyEntity too. + sealed interface Kind { + data object Home: Kind + data object Local: Kind + data object Federated: Kind + // data class RemoteLocal(val serverDomain: String): K ? + data class Hashtag(val hashtag: String) : Kind + data class Link(val url: String) : Kind + data class List(val listId: String) : Kind + data object Direct : Kind } } From 40872683971e110ae0d9eb2be05c522499e79bec Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Sat, 1 Feb 2025 16:16:11 +0100 Subject: [PATCH 07/19] Renames --- .../viewmodel/CachedTimelineRemoteMediator.kt | 9 ++++--- .../pachli/usecase/DeveloperToolsUseCase.kt | 2 +- .../app/pachli/core/database/Converters.kt | 7 ++++++ .../pachli/core/database/dao/TimelineDao.kt | 17 +++++++------ .../database/model/TimelineStatusEntity.kt | 25 ++++++++++++++++++- 5 files changed, 47 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/app/pachli/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt b/app/src/main/java/app/pachli/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt index ebb4d917a4..b9a00a2a32 100644 --- a/app/src/main/java/app/pachli/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt +++ b/app/src/main/java/app/pachli/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt @@ -113,8 +113,8 @@ class CachedTimelineRemoteMediator( when (loadType) { LoadType.REFRESH -> { -// remoteKeyDao.deletePrevNext(pachliAccountId, RKE_TIMELINE_ID) - timelineDao.deleteAllStatusesForAccount(pachliAccountId) + timelineDao.deleteAllStatusesForAccountOnTimeline( + pachliAccountId, TimelineStatusEntity.Kind.Home) remoteKeyDao.upsert( RemoteKeyEntity( @@ -248,7 +248,8 @@ class CachedTimelineRemoteMediator( } /** - * Inserts `statuses` and the accounts referenced by those statuses in to the cache. + * Inserts `statuses` and the accounts referenced by those statuses in to the cache, + * then adds references to them in the Home timeline. */ private suspend fun insertStatuses(pachliAccountId: Long, statuses: List) { check(transactionProvider.inTransaction()) @@ -266,7 +267,7 @@ class CachedTimelineRemoteMediator( timelineDao.upsertStatuses( statuses.map { TimelineStatusEntity( - kind = TimelineStatusEntity.Kind.HOME, + kind = TimelineStatusEntity.Kind.Home, pachliAccountId = pachliAccountId, statusId = it.id, ) diff --git a/app/src/main/java/app/pachli/usecase/DeveloperToolsUseCase.kt b/app/src/main/java/app/pachli/usecase/DeveloperToolsUseCase.kt index 78b2f7831b..2d5341a0b1 100644 --- a/app/src/main/java/app/pachli/usecase/DeveloperToolsUseCase.kt +++ b/app/src/main/java/app/pachli/usecase/DeveloperToolsUseCase.kt @@ -33,7 +33,7 @@ class DeveloperToolsUseCase @Inject constructor( * Clear the home timeline cache. */ suspend fun clearHomeTimelineCache(accountId: Long) { - timelineDao.deleteAllStatusesForAccount(accountId) + timelineDao.deleteAllStatusesForAccountOnTimeline(accountId) } /** diff --git a/core/database/src/main/kotlin/app/pachli/core/database/Converters.kt b/core/database/src/main/kotlin/app/pachli/core/database/Converters.kt index f2801febdb..fa7b9aa03b 100644 --- a/core/database/src/main/kotlin/app/pachli/core/database/Converters.kt +++ b/core/database/src/main/kotlin/app/pachli/core/database/Converters.kt @@ -20,6 +20,7 @@ import androidx.room.ProvidedTypeConverter import androidx.room.TypeConverter import app.pachli.core.database.model.ConversationAccountEntity import app.pachli.core.database.model.DraftAttachment +import app.pachli.core.database.model.TimelineStatusEntity import app.pachli.core.model.AccountFilterDecision import app.pachli.core.model.ContentFilter import app.pachli.core.model.ServerOperation @@ -310,4 +311,10 @@ class Converters @Inject constructor( @TypeConverter fun jsonToAccountFilterDecision(s: String?) = s?.let { moshi.adapter().fromJson(it) } + + @TypeConverter + fun timelineKindToJson(kind: TimelineStatusEntity.Kind) = moshi.adapter().toJson(kind) + + @TypeConverter + fun jsonToTimelineKind(s: String?) = s?.let { moshi.adapter().fromJson(s) } } diff --git a/core/database/src/main/kotlin/app/pachli/core/database/dao/TimelineDao.kt b/core/database/src/main/kotlin/app/pachli/core/database/dao/TimelineDao.kt index eb37e8939d..54827f5466 100644 --- a/core/database/src/main/kotlin/app/pachli/core/database/dao/TimelineDao.kt +++ b/core/database/src/main/kotlin/app/pachli/core/database/dao/TimelineDao.kt @@ -132,11 +132,14 @@ ORDER BY LENGTH(s.serverId) DESC, s.serverId DESC ) abstract fun getStatuses( account: Long, - timelineKind: TimelineStatusEntity.Kind = TimelineStatusEntity.Kind.HOME, + timelineKind: TimelineStatusEntity.Kind = TimelineStatusEntity.Kind.Home, ): PagingSource /** - * @return Row number (0 based) of the status with ID [statusId] for [pachliAccountId]. + * @return Row number (0 based) of the status with ID [statusId] for [pachliAccountId] + * on [timelineKind]. + * + * Rows are ordered newest (0) to oldest. * * @see [app.pachli.components.timeline.viewmodel.CachedTimelineViewModel.statuses] */ @@ -170,7 +173,7 @@ WHERE serverId = :statusId abstract suspend fun getStatusRowNumber( pachliAccountId: Long, statusId: String, - timelineKind: TimelineStatusEntity.Kind = TimelineStatusEntity.Kind.HOME, + timelineKind: TimelineStatusEntity.Kind = TimelineStatusEntity.Kind.Home, ): Int @Query( @@ -292,7 +295,7 @@ WHERE abstract suspend fun removeAllByUser( pachliAccountId: Long, userId: String, - timelineKind: TimelineStatusEntity.Kind = TimelineStatusEntity.Kind.HOME, + timelineKind: TimelineStatusEntity.Kind = TimelineStatusEntity.Kind.Home, ) /** @@ -307,7 +310,7 @@ WHERE AND kind = :timelineKind """, ) - abstract suspend fun deleteAllStatusesForAccount(accountId: Long, timelineKind: TimelineStatusEntity.Kind = TimelineStatusEntity.Kind.HOME) + abstract suspend fun deleteAllStatusesForAccountOnTimeline(accountId: Long, timelineKind: TimelineStatusEntity.Kind = TimelineStatusEntity.Kind.Home) @Query( """ @@ -475,7 +478,7 @@ WHERE abstract suspend fun deleteAllFromInstance( accountId: Long, instanceDomain: String, - timelineKind: TimelineStatusEntity.Kind = TimelineStatusEntity.Kind.HOME, + timelineKind: TimelineStatusEntity.Kind = TimelineStatusEntity.Kind.Home, ) @Query( @@ -489,7 +492,7 @@ WHERE ) abstract suspend fun getStatusCount( accountId: Long, - timelineKind: TimelineStatusEntity.Kind = TimelineStatusEntity.Kind.HOME, + timelineKind: TimelineStatusEntity.Kind = TimelineStatusEntity.Kind.Home, ): Int /** Developer tools: Find N most recent status IDs */ diff --git a/core/database/src/main/kotlin/app/pachli/core/database/model/TimelineStatusEntity.kt b/core/database/src/main/kotlin/app/pachli/core/database/model/TimelineStatusEntity.kt index 6c0aab0f69..a38b1ebbea 100644 --- a/core/database/src/main/kotlin/app/pachli/core/database/model/TimelineStatusEntity.kt +++ b/core/database/src/main/kotlin/app/pachli/core/database/model/TimelineStatusEntity.kt @@ -20,6 +20,9 @@ package app.pachli.core.database.model import androidx.room.Entity import androidx.room.TypeConverters import app.pachli.core.database.Converters +import com.squareup.moshi.JsonClass +import dev.zacsweers.moshix.sealed.annotations.DefaultNull +import dev.zacsweers.moshix.sealed.annotations.TypeLabel /** * A timeline that contains items. @@ -35,15 +38,35 @@ data class TimelineStatusEntity( ) { /** Cacheable timeline kinds. */ // TODO: Eventually these have to be the timeline kind types for - // RemoteKeyEntity too. + // remotekeys + // TODO: See also core.model.Timeline + @DefaultNull + @JsonClass(generateAdapter = true, generator = "sealed:type") sealed interface Kind { + @TypeLabel("home") data object Home: Kind + + @TypeLabel("local") data object Local: Kind + + @TypeLabel("federated") data object Federated: Kind + // data class RemoteLocal(val serverDomain: String): K ? + + @TypeLabel("hashtag") + @JsonClass(generateAdapter = true) data class Hashtag(val hashtag: String) : Kind + + @TypeLabel("link") + @JsonClass(generateAdapter = true) data class Link(val url: String) : Kind + + @TypeLabel("list") + @JsonClass(generateAdapter = true) data class List(val listId: String) : Kind + + @TypeLabel("direct") data object Direct : Kind } } From 9a90a61c9810549dac4e76604bfd754fb65bea6f Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Sat, 1 Feb 2025 16:20:10 +0100 Subject: [PATCH 08/19] fmtsql --- .../pachli/core/database/dao/TimelineDao.kt | 42 +++++++++++++------ 1 file changed, 29 insertions(+), 13 deletions(-) diff --git a/core/database/src/main/kotlin/app/pachli/core/database/dao/TimelineDao.kt b/core/database/src/main/kotlin/app/pachli/core/database/dao/TimelineDao.kt index 54827f5466..9750b04f68 100644 --- a/core/database/src/main/kotlin/app/pachli/core/database/dao/TimelineDao.kt +++ b/core/database/src/main/kotlin/app/pachli/core/database/dao/TimelineDao.kt @@ -117,7 +117,9 @@ SELECT tr.attachments AS 't_attachments', tr.provider AS 't_provider' FROM TimelineStatusEntity AS t -LEFT JOIN StatusEntity AS s ON (t.pachliAccountId = :account AND (s.timelineUserId = :account AND s.serverId = t.statusId)) +LEFT JOIN + StatusEntity AS s + ON (t.pachliAccountId = :account AND (s.timelineUserId = :account AND t.statusId = s.serverId)) LEFT JOIN TimelineAccountEntity AS a ON (s.timelineUserId = a.timelineUserId AND s.authorServerId = a.serverId) LEFT JOIN TimelineAccountEntity AS rb ON (s.timelineUserId = rb.timelineUserId AND s.reblogAccountId = rb.serverId) LEFT JOIN @@ -147,12 +149,15 @@ ORDER BY LENGTH(s.serverId) DESC, s.serverId DESC """ SELECT rownum FROM ( - WITH statuses(timelineUserId, serverId) AS ( - SELECT s.timelineUserId, s.serverId - FROM TimelineStatusEntity t - LEFT JOIN StatusEntity s ON (t.statusId = s.serverId) + WITH statuses (timelineUserId, serverId) AS ( + SELECT + s.timelineUserId, + s.serverId + FROM TimelineStatusEntity AS t + LEFT JOIN StatusEntity AS s ON (t.statusId = s.serverId) WHERE t.kind = :timelineKind AND t.pachliAccountId = :pachliAccountId ) + SELECT t1.timelineUserId, t1.serverId, @@ -287,8 +292,9 @@ WHERE AND statusId IN ( SELECT serverId FROM StatusEntity - WHERE timelineUserId = :pachliAccountId - AND (authorServerId = :userId or reblogAccountId = :userId) + WHERE + timelineUserId = :pachliAccountId + AND (authorServerId = :userId OR reblogAccountId = :userId) ) """, ) @@ -353,9 +359,13 @@ WHERE timelineUserId = :accountId DELETE FROM StatusEntity WHERE timelineUserId = :accountId AND serverId NOT IN ( - SELECT statusId from TimelineStatusEntity WHERE pachliAccountId = :accountId + SELECT statusId + FROM TimelineStatusEntity + WHERE pachliAccountId = :accountId UNION - SELECT statusServerId FROM NotificationEntity WHERE pachliAccountId = :accountId + SELECT statusServerId + FROM NotificationEntity + WHERE pachliAccountId = :accountId ) """, ) @@ -460,19 +470,25 @@ WHERE @Query( """ -WITH statuses(serverId) AS ( +WITH statuses (serverId) AS ( -- IDs of statuses written by accounts from :instanceDomain SELECT s.serverId - FROM StatusEntity s - LEFT JOIN TimelineAccountEntity a ON (s.timelineUserId = a.timelineUserId AND (s.authorServerId = a.serverId OR s.reblogAccountId = a.serverId)) + FROM StatusEntity AS s + LEFT JOIN + TimelineAccountEntity AS a + ON (s.timelineUserId = a.timelineUserId AND (s.authorServerId = a.serverId OR s.reblogAccountId = a.serverId)) WHERE s.timelineUserId = :accountId AND a.username LIKE '%@' || :instanceDomain ) + DELETE FROM TimelineStatusEntity WHERE kind = :timelineKind AND pachliAccountId = :accountId - AND statusId IN (SELECT serverId FROM statuses) + AND statusId IN ( + SELECT serverId + FROM statuses + ) """, ) abstract suspend fun deleteAllFromInstance( From 61d9a9215fde83a9be5e2530e6c5049341c5ae14 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Sat, 1 Feb 2025 16:34:03 +0100 Subject: [PATCH 09/19] refactor: Extract bind loadstate functionality to a method --- .../notifications/NotificationsFragment.kt | 80 +++++++++-------- .../components/timeline/TimelineFragment.kt | 86 +++++++++++-------- 2 files changed, 92 insertions(+), 74 deletions(-) diff --git a/app/src/main/java/app/pachli/components/notifications/NotificationsFragment.kt b/app/src/main/java/app/pachli/components/notifications/NotificationsFragment.kt index 9a0fbbece4..b929feb4e1 100644 --- a/app/src/main/java/app/pachli/components/notifications/NotificationsFragment.kt +++ b/app/src/main/java/app/pachli/components/notifications/NotificationsFragment.kt @@ -36,6 +36,7 @@ import androidx.fragment.app.viewModels import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle +import androidx.paging.CombinedLoadStates import androidx.paging.LoadState import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.LinearLayoutManager @@ -260,42 +261,7 @@ class NotificationsFragment : } // Update the UI from the loadState - // TODO: Move to bindLoadState function - adapter.loadStateFlow.distinctUntilChangedBy { it.refresh }.collect { loadState -> - when (loadState.refresh) { - is LoadState.Error -> { - binding.progressIndicator.hide() - binding.statusView.setup((loadState.refresh as LoadState.Error).error) { - adapter.retry() - } - binding.recyclerView.hide() - binding.statusView.show() - binding.swipeRefreshLayout.isRefreshing = false - } - - LoadState.Loading -> { - binding.statusView.hide() - binding.progressIndicator.show() - } - - is LoadState.NotLoading -> { - // Might still be loading if source.refresh is Loading, so only update - // the UI when loading is completely quiet. - if (loadState.source.refresh !is LoadState.Loading) { - binding.progressIndicator.hide() - binding.swipeRefreshLayout.isRefreshing = false - if (adapter.itemCount == 0) { - binding.statusView.setup(BackgroundMessage.Empty()) - binding.recyclerView.hide() - binding.statusView.show() - } else { - binding.statusView.hide() - binding.recyclerView.show() - } - } - } - } - } + adapter.loadStateFlow.distinctUntilChangedBy { it.refresh }.collect(::bindLoadState) } } } @@ -371,6 +337,48 @@ class NotificationsFragment : } } + /** + * Binds [CombinedLoadStates] to the UI. + * + * Updates the UI based on the contents of [loadState.refresh][CombinedLoadStates.refresh] + * to show/hide Error, Loading, and NotLoading states. + */ + private fun bindLoadState(loadState: CombinedLoadStates) { + when (loadState.refresh) { + is LoadState.Error -> { + binding.progressIndicator.hide() + binding.statusView.setup((loadState.refresh as LoadState.Error).error) { + adapter.retry() + } + binding.recyclerView.hide() + binding.statusView.show() + binding.swipeRefreshLayout.isRefreshing = false + } + + LoadState.Loading -> { + binding.statusView.hide() + binding.progressIndicator.show() + } + + is LoadState.NotLoading -> { + // Might still be loading if source.refresh is Loading, so only update + // the UI when loading is completely quiet. + if (loadState.source.refresh !is LoadState.Loading) { + binding.progressIndicator.hide() + binding.swipeRefreshLayout.isRefreshing = false + if (adapter.itemCount == 0) { + binding.statusView.setup(BackgroundMessage.Empty()) + binding.recyclerView.hide() + binding.statusView.show() + } else { + binding.statusView.hide() + binding.recyclerView.show() + } + } + } + } + } + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { menuInflater.inflate(R.menu.fragment_notifications, menu) menu.findItem(R.id.action_refresh)?.apply { diff --git a/app/src/main/java/app/pachli/components/timeline/TimelineFragment.kt b/app/src/main/java/app/pachli/components/timeline/TimelineFragment.kt index 7589ed7825..ecf1de2587 100644 --- a/app/src/main/java/app/pachli/components/timeline/TimelineFragment.kt +++ b/app/src/main/java/app/pachli/components/timeline/TimelineFragment.kt @@ -35,6 +35,7 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import androidx.lifecycle.viewmodel.MutableCreationExtras +import androidx.paging.CombinedLoadStates import androidx.paging.LoadState import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView @@ -272,48 +273,12 @@ class TimelineFragment : } // TODO: Move to bindLoadState function - adapter.loadStateFlow.distinctUntilChangedBy { it.refresh }.collect { loadState -> - when (loadState.refresh) { - is LoadState.Error -> { - binding.progressIndicator.hide() - binding.statusView.setup((loadState.refresh as LoadState.Error).error) { - adapter.retry() - } - binding.recyclerView.hide() - binding.statusView.show() - binding.swipeRefreshLayout.isRefreshing = false - } - - LoadState.Loading -> { - binding.statusView.hide() - binding.progressIndicator.show() - } - - is LoadState.NotLoading -> { - // Might still be loading if source.refresh is Loading, so only update - // the UI when loading is completely quiet. - if (loadState.source.refresh !is LoadState.Loading) { - binding.progressIndicator.hide() - binding.swipeRefreshLayout.isRefreshing = false - if (adapter.itemCount == 0) { - binding.statusView.setup(BackgroundMessage.Empty()) - if (timeline == Timeline.Home) { - binding.statusView.showHelp(R.string.help_empty_home) - } - binding.recyclerView.hide() - binding.statusView.show() - } else { - binding.statusView.hide() - binding.recyclerView.show() - } - } - } - } - } + adapter.loadStateFlow.distinctUntilChangedBy { it.refresh }.collect(::bindLoadState) } } } + private fun bindUiResult(uiResult: Result) { // Show errors from the view model as snack bars. // @@ -420,6 +385,51 @@ class TimelineFragment : } } + /** + * Binds [CombinedLoadStates] to the UI. + * + * Updates the UI based on the contents of [loadState.refresh][CombinedLoadStates.refresh] + * to show/hide Error, Loading, and NotLoading states. + */ + private fun bindLoadState(loadState: CombinedLoadStates) { + when (loadState.refresh) { + is LoadState.Error -> { + binding.progressIndicator.hide() + binding.statusView.setup((loadState.refresh as LoadState.Error).error) { + adapter.retry() + } + binding.recyclerView.hide() + binding.statusView.show() + binding.swipeRefreshLayout.isRefreshing = false + } + + LoadState.Loading -> { + binding.statusView.hide() + binding.progressIndicator.show() + } + + is LoadState.NotLoading -> { + // Might still be loading if source.refresh is Loading, so only update + // the UI when loading is completely quiet. + if (loadState.source.refresh !is LoadState.Loading) { + binding.progressIndicator.hide() + binding.swipeRefreshLayout.isRefreshing = false + if (adapter.itemCount == 0) { + binding.statusView.setup(BackgroundMessage.Empty()) + if (timeline == Timeline.Home) { + binding.statusView.showHelp(R.string.help_empty_home) + } + binding.recyclerView.hide() + binding.statusView.show() + } else { + binding.statusView.hide() + binding.recyclerView.show() + } + } + } + } + } + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { menuInflater.inflate(R.menu.fragment_timeline, menu) From bbb949c063fa2abe0bb8213db674fd0ce8e794da Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Sat, 1 Feb 2025 16:34:52 +0100 Subject: [PATCH 10/19] ktlintformat --- .../java/app/pachli/components/timeline/TimelineFragment.kt | 1 - .../timeline/viewmodel/CachedTimelineRemoteMediator.kt | 4 +++- .../app/pachli/core/database/model/TimelineStatusEntity.kt | 6 +++--- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/app/pachli/components/timeline/TimelineFragment.kt b/app/src/main/java/app/pachli/components/timeline/TimelineFragment.kt index ecf1de2587..af4748d4c1 100644 --- a/app/src/main/java/app/pachli/components/timeline/TimelineFragment.kt +++ b/app/src/main/java/app/pachli/components/timeline/TimelineFragment.kt @@ -278,7 +278,6 @@ class TimelineFragment : } } - private fun bindUiResult(uiResult: Result) { // Show errors from the view model as snack bars. // diff --git a/app/src/main/java/app/pachli/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt b/app/src/main/java/app/pachli/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt index b9a00a2a32..567cd5f2e6 100644 --- a/app/src/main/java/app/pachli/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt +++ b/app/src/main/java/app/pachli/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt @@ -114,7 +114,9 @@ class CachedTimelineRemoteMediator( when (loadType) { LoadType.REFRESH -> { timelineDao.deleteAllStatusesForAccountOnTimeline( - pachliAccountId, TimelineStatusEntity.Kind.Home) + pachliAccountId, + TimelineStatusEntity.Kind.Home, + ) remoteKeyDao.upsert( RemoteKeyEntity( diff --git a/core/database/src/main/kotlin/app/pachli/core/database/model/TimelineStatusEntity.kt b/core/database/src/main/kotlin/app/pachli/core/database/model/TimelineStatusEntity.kt index a38b1ebbea..c8199b2597 100644 --- a/core/database/src/main/kotlin/app/pachli/core/database/model/TimelineStatusEntity.kt +++ b/core/database/src/main/kotlin/app/pachli/core/database/model/TimelineStatusEntity.kt @@ -44,13 +44,13 @@ data class TimelineStatusEntity( @JsonClass(generateAdapter = true, generator = "sealed:type") sealed interface Kind { @TypeLabel("home") - data object Home: Kind + data object Home : Kind @TypeLabel("local") - data object Local: Kind + data object Local : Kind @TypeLabel("federated") - data object Federated: Kind + data object Federated : Kind // data class RemoteLocal(val serverDomain: String): K ? From 96f1d58d12192833ebd7604ec872b3a3a089f328 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Sat, 1 Feb 2025 16:37:17 +0100 Subject: [PATCH 11/19] Remove obsolete comment --- .../main/java/app/pachli/components/timeline/TimelineFragment.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/app/src/main/java/app/pachli/components/timeline/TimelineFragment.kt b/app/src/main/java/app/pachli/components/timeline/TimelineFragment.kt index af4748d4c1..ce428120f0 100644 --- a/app/src/main/java/app/pachli/components/timeline/TimelineFragment.kt +++ b/app/src/main/java/app/pachli/components/timeline/TimelineFragment.kt @@ -272,7 +272,6 @@ class TimelineFragment : } } - // TODO: Move to bindLoadState function adapter.loadStateFlow.distinctUntilChangedBy { it.refresh }.collect(::bindLoadState) } } From 05ea390124f95accfaa28e07c268f923ff0e2c7a Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Sat, 1 Feb 2025 17:20:45 +0100 Subject: [PATCH 12/19] Add comment --- .../pachli/core/database/model/TimelineStatusEntity.kt | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/core/database/src/main/kotlin/app/pachli/core/database/model/TimelineStatusEntity.kt b/core/database/src/main/kotlin/app/pachli/core/database/model/TimelineStatusEntity.kt index c8199b2597..f5a33076a3 100644 --- a/core/database/src/main/kotlin/app/pachli/core/database/model/TimelineStatusEntity.kt +++ b/core/database/src/main/kotlin/app/pachli/core/database/model/TimelineStatusEntity.kt @@ -25,7 +25,8 @@ import dev.zacsweers.moshix.sealed.annotations.DefaultNull import dev.zacsweers.moshix.sealed.annotations.TypeLabel /** - * A timeline that contains items. + * M:N association between a [TimelineStatusEntity.Kind] and the statuses + * that make up the timeline. */ @Entity( primaryKeys = ["kind", "pachliAccountId", "statusId"], @@ -36,7 +37,12 @@ data class TimelineStatusEntity( val pachliAccountId: Long, val statusId: String, ) { - /** Cacheable timeline kinds. */ + /** + * Cacheable timeline kinds. + * + * Timelines that are not cacheable (e.g., search queries) do not belong + * here. + */ // TODO: Eventually these have to be the timeline kind types for // remotekeys // TODO: See also core.model.Timeline From 424459b337481c9bce81cd943b04fdbf45a708b4 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Sat, 1 Feb 2025 20:22:26 +0100 Subject: [PATCH 13/19] Be explicit about the return type --- .../src/main/kotlin/app/pachli/core/database/Converters.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/database/src/main/kotlin/app/pachli/core/database/Converters.kt b/core/database/src/main/kotlin/app/pachli/core/database/Converters.kt index fa7b9aa03b..3249441e81 100644 --- a/core/database/src/main/kotlin/app/pachli/core/database/Converters.kt +++ b/core/database/src/main/kotlin/app/pachli/core/database/Converters.kt @@ -313,7 +313,7 @@ class Converters @Inject constructor( fun jsonToAccountFilterDecision(s: String?) = s?.let { moshi.adapter().fromJson(it) } @TypeConverter - fun timelineKindToJson(kind: TimelineStatusEntity.Kind) = moshi.adapter().toJson(kind) + fun timelineKindToJson(kind: TimelineStatusEntity.Kind): String = moshi.adapter().toJson(kind) @TypeConverter fun jsonToTimelineKind(s: String?) = s?.let { moshi.adapter().fromJson(s) } From 8eb40dfbf22fd5db7dfd668decf65bb3c87efe6d Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Sat, 1 Feb 2025 20:45:58 +0100 Subject: [PATCH 14/19] Remove dead code --- .../java/app/pachli/components/timeline/TimelineFragment.kt | 3 --- .../timeline/viewmodel/CachedTimelineViewModel.kt | 6 ++---- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/app/pachli/components/timeline/TimelineFragment.kt b/app/src/main/java/app/pachli/components/timeline/TimelineFragment.kt index ce428120f0..2c178c2f43 100644 --- a/app/src/main/java/app/pachli/components/timeline/TimelineFragment.kt +++ b/app/src/main/java/app/pachli/components/timeline/TimelineFragment.kt @@ -188,7 +188,6 @@ class TimelineFragment : isSwipeToRefreshEnabled = arguments.getBoolean(ARG_ENABLE_SWIPE_TO_REFRESH, true) adapter = TimelinePagingAdapter(this, viewModel.statusDisplayOptions.value) -// adapter.stateRestorationPolicy = RecyclerView.Adapter.StateRestorationPolicy.PREVENT } override fun onCreateView( @@ -353,8 +352,6 @@ class TimelineFragment : is StatusActionSuccess.Translate -> statusViewData.status } (indexedViewData.value as StatusViewData).status = status - -// adapter.notifyItemChanged(indexedViewData.index) } // Refresh adapter on mutes and blocks diff --git a/app/src/main/java/app/pachli/components/timeline/viewmodel/CachedTimelineViewModel.kt b/app/src/main/java/app/pachli/components/timeline/viewmodel/CachedTimelineViewModel.kt index ae744e36f2..5aee06ab3d 100644 --- a/app/src/main/java/app/pachli/components/timeline/viewmodel/CachedTimelineViewModel.kt +++ b/app/src/main/java/app/pachli/components/timeline/viewmodel/CachedTimelineViewModel.kt @@ -78,10 +78,8 @@ class CachedTimelineViewModel @Inject constructor( override var statuses = accountFlow .distinctUntilChangedBy { it.data!!.id } - .flatMapLatest { - Timber.w("Triggering getStatuses") - getStatuses(it.data!!) - }.cachedIn(viewModelScope) + .flatMapLatest { getStatuses(it.data!!) } + .cachedIn(viewModelScope) /** @return Flow of statuses that make up the timeline of [timeline] for [account]. */ private suspend fun getStatuses( From 2d4befcb989dd5ace73b35ec9ac4ee0178f82eb9 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Sun, 2 Feb 2025 10:44:46 +0100 Subject: [PATCH 15/19] wip: Debugging test --- .../app/pachli/worker/PruneCacheWorker.kt | 3 +- .../CachedTimelineRemoteMediatorTest.kt | 8 + .../pachli/core/database/dao/TimelineDao.kt | 166 +++++++++++++++--- .../core/database/dao/TimelineDaoTest.kt | 90 +++++++++- 4 files changed, 240 insertions(+), 27 deletions(-) diff --git a/app/src/main/java/app/pachli/worker/PruneCacheWorker.kt b/app/src/main/java/app/pachli/worker/PruneCacheWorker.kt index 0d275228b2..cc491d9fc9 100644 --- a/app/src/main/java/app/pachli/worker/PruneCacheWorker.kt +++ b/app/src/main/java/app/pachli/worker/PruneCacheWorker.kt @@ -45,7 +45,7 @@ class PruneCacheWorker @AssistedInject constructor( override suspend fun doWork(): Result { for (account in accountManager.accounts) { Timber.d("Pruning database using account ID: %d", account.id) - timelineDao.cleanup(account.id, MAX_STATUSES_IN_CACHE) + timelineDao.cleanup(account.id) } return Result.success() } @@ -53,7 +53,6 @@ class PruneCacheWorker @AssistedInject constructor( override suspend fun getForegroundInfo() = ForegroundInfo(NOTIFICATION_ID_PRUNE_CACHE, notification) companion object { - private const val MAX_STATUSES_IN_CACHE = 1000 const val PERIODIC_WORK_TAG = "PruneCacheWorker_periodic" } } diff --git a/app/src/test/java/app/pachli/components/timeline/CachedTimelineRemoteMediatorTest.kt b/app/src/test/java/app/pachli/components/timeline/CachedTimelineRemoteMediatorTest.kt index b96b20f714..20f4fe626e 100644 --- a/app/src/test/java/app/pachli/components/timeline/CachedTimelineRemoteMediatorTest.kt +++ b/app/src/test/java/app/pachli/components/timeline/CachedTimelineRemoteMediatorTest.kt @@ -18,6 +18,7 @@ import app.pachli.core.database.di.TransactionProvider import app.pachli.core.database.model.AccountEntity import app.pachli.core.database.model.RemoteKeyEntity import app.pachli.core.database.model.RemoteKeyEntity.RemoteKeyKind +import app.pachli.core.database.model.TimelineStatusEntity import app.pachli.core.database.model.TimelineStatusWithAccount import app.pachli.core.network.json.BooleanIfNull import app.pachli.core.network.json.DefaultIfNull @@ -343,6 +344,13 @@ class CachedTimelineRemoteMediatorTest { } statusDao().insertStatus(statusWithAccount.status) } + timelineDao().upsertStatuses(statuses.map { + TimelineStatusEntity( + pachliAccountId = it.status.timelineUserId, + kind = TimelineStatusEntity.Kind.Home, + statusId = it.status.serverId + ) + }) } } diff --git a/core/database/src/main/kotlin/app/pachli/core/database/dao/TimelineDao.kt b/core/database/src/main/kotlin/app/pachli/core/database/dao/TimelineDao.kt index 9750b04f68..6ba5756943 100644 --- a/core/database/src/main/kotlin/app/pachli/core/database/dao/TimelineDao.kt +++ b/core/database/src/main/kotlin/app/pachli/core/database/dao/TimelineDao.kt @@ -19,6 +19,7 @@ package app.pachli.core.database.dao import androidx.paging.PagingSource import androidx.room.Dao +import androidx.room.Delete import androidx.room.Insert import androidx.room.MapColumn import androidx.room.OnConflictStrategy.Companion.REPLACE @@ -39,6 +40,12 @@ abstract class TimelineDao { @Upsert abstract suspend fun upsertStatuses(entities: List) + @Delete + abstract suspend fun delete(entity: TimelineStatusEntity) + + @Delete + abstract suspend fun delete(entities: List) + @Insert(onConflict = REPLACE) abstract suspend fun insertAccount(timelineAccountEntity: TimelineAccountEntity): Long @@ -128,7 +135,7 @@ LEFT JOIN LEFT JOIN TranslatedStatusEntity AS tr ON (s.timelineUserId = tr.timelineUserId AND (s.serverId = tr.serverId OR s.reblogServerId = tr.serverId)) -WHERE t.kind = :timelineKind AND t.pachliAccountId = :account AND s.timelineUserId = :account +WHERE t.kind = :timelineKind AND t.pachliAccountId = :account --AND s.timelineUserId = :account ORDER BY LENGTH(s.serverId) DESC, s.serverId DESC """, ) @@ -137,6 +144,117 @@ ORDER BY LENGTH(s.serverId) DESC, s.serverId DESC timelineKind: TimelineStatusEntity.Kind = TimelineStatusEntity.Kind.Home, ): PagingSource + @Query( +""" +SELECT s.* +FROM + TimelineStatusEntity AS t +LEFT JOIN + StatusEntity AS s + ON (t.pachliAccountId = :account AND (s.timelineUserId = :account AND t.statusId = s.serverId)) +WHERE + t.kind = :timelineKind AND t.pachliAccountId = :account --AND s.timelineUserId = :account +ORDER BY LENGTH(s.serverId) DESC, s.serverId DESC +""" + ) + abstract fun c( + account: Long, + timelineKind: TimelineStatusEntity.Kind = TimelineStatusEntity.Kind.Home, + ): PagingSource + + @Query( + """ +SELECT + s.serverId, + s.url, + s.timelineUserId, + s.authorServerId, + s.inReplyToId, + s.inReplyToAccountId, + s.createdAt, + s.editedAt, + s.emojis, + s.reblogsCount, + s.favouritesCount, + s.repliesCount, + s.reblogged, + s.favourited, + s.bookmarked, + s.sensitive, + s.spoilerText, + s.visibility, + s.mentions, + s.tags, + s.application, + s.reblogServerId, + s.reblogAccountId, + s.content, + s.attachments, + s.poll, + s.card, + s.muted, + s.pinned, + s.language, + s.filtered, + a.serverId AS 'a_serverId', + a.timelineUserId AS 'a_timelineUserId', + a.localUsername AS 'a_localUsername', + a.username AS 'a_username', + a.displayName AS 'a_displayName', + a.url AS 'a_url', + a.avatar AS 'a_avatar', + a.emojis AS 'a_emojis', + a.bot AS 'a_bot', + a.createdAt AS 'a_createdAt', + a.limited AS 'a_limited', + a.note AS 'a_note', + rb.serverId AS 'rb_serverId', + rb.timelineUserId AS 'rb_timelineUserId', + rb.localUsername AS 'rb_localUsername', + rb.username AS 'rb_username', + rb.displayName AS 'rb_displayName', + rb.url AS 'rb_url', + rb.avatar AS 'rb_avatar', + rb.emojis AS 'rb_emojis', + rb.bot AS 'rb_bot', + rb.createdAt AS 'rb_createdAt', + rb.limited AS 'rb_limited', + rb.note AS 'rb_note', + svd.serverId AS 'svd_serverId', + svd.timelineUserId AS 'svd_timelineUserId', + svd.expanded AS 'svd_expanded', + svd.contentShowing AS 'svd_contentShowing', + svd.contentCollapsed AS 'svd_contentCollapsed', + svd.translationState AS 'svd_translationState', + tr.serverId AS 't_serverId', + tr.timelineUserId AS 't_timelineUserId', + tr.content AS 't_content', + tr.spoilerText AS 't_spoilerText', + tr.poll AS 't_poll', + tr.attachments AS 't_attachments', + tr.provider AS 't_provider' +FROM TimelineStatusEntity AS t +LEFT JOIN + StatusEntity AS s + ON (t.pachliAccountId = :account AND (s.timelineUserId = :account AND t.statusId = s.serverId)) +LEFT JOIN TimelineAccountEntity AS a ON (s.timelineUserId = a.timelineUserId AND s.authorServerId = a.serverId) +LEFT JOIN TimelineAccountEntity AS rb ON (s.timelineUserId = rb.timelineUserId AND s.reblogAccountId = rb.serverId) +LEFT JOIN + StatusViewDataEntity AS svd + ON (s.timelineUserId = svd.timelineUserId AND (s.serverId = svd.serverId OR s.reblogServerId = svd.serverId)) +LEFT JOIN + TranslatedStatusEntity AS tr + ON (s.timelineUserId = tr.timelineUserId AND (s.serverId = tr.serverId OR s.reblogServerId = tr.serverId)) +WHERE t.kind = :timelineKind AND t.pachliAccountId = :account --AND s.timelineUserId = :account +ORDER BY LENGTH(s.serverId) DESC, s.serverId DESC +""", + ) + abstract fun getStatusesAsList( + account: Long, + timelineKind: TimelineStatusEntity.Kind = TimelineStatusEntity.Kind.Home, + ): List + + /** * @return Row number (0 based) of the status with ID [statusId] for [pachliAccountId] * on [timelineKind]. @@ -337,16 +455,16 @@ WHERE timelineUserId = :accountId abstract suspend fun removeAllTranslatedStatus(accountId: Long) /** - * Cleans the StatusEntity and TimelineAccountEntity tables from old entries. + * Removes cached data that is not part of any timeline. + * * @param accountId id of the account for which to clean tables - * @param limit how many statuses to keep */ @Transaction - open suspend fun cleanup(accountId: Long, limit: Int) { + open suspend fun cleanup(accountId: Long) { cleanupStatuses(accountId) cleanupAccounts(accountId) - cleanupStatusViewData(accountId, limit) - cleanupTranslatedStatus(accountId, limit) + cleanupStatusViewData(accountId) + cleanupTranslatedStatus(accountId) } /** @@ -401,8 +519,8 @@ WHERE abstract suspend fun cleanupAccounts(accountId: Long) /** - * Cleans the StatusViewDataEntity table of old view data, keeping the most recent [limit] - * entries. + * Removes rows from StatusViewDataEntity that reference statuses are that not + * part of any timeline. */ @Query( """ @@ -411,19 +529,21 @@ FROM StatusViewDataEntity WHERE timelineUserId = :accountId AND serverId NOT IN ( - SELECT serverId - FROM StatusViewDataEntity - WHERE timelineUserId = :accountId - ORDER BY LENGTH(serverId) DESC, serverId DESC - LIMIT :limit + SELECT statusId + FROM TimelineStatusEntity + WHERE pachliAccountId = :accountId + UNION + SELECT statusServerId + FROM NotificationEntity + WHERE pachliAccountId = :accountId ) """, ) - abstract suspend fun cleanupStatusViewData(accountId: Long, limit: Int) + abstract suspend fun cleanupStatusViewData(accountId: Long) /** - * Cleans the TranslatedStatusEntity table of old data, keeping the most recent [limit] - * entries. + * Removes rows from TranslatedStatusEntity that reference statuses that are not + * part of any timeline. */ @Query( """ @@ -432,15 +552,17 @@ FROM TranslatedStatusEntity WHERE timelineUserId = :accountId AND serverId NOT IN ( - SELECT serverId - FROM TranslatedStatusEntity - WHERE timelineUserId = :accountId - ORDER BY LENGTH(serverId) DESC, serverId DESC - LIMIT :limit + SELECT statusId + FROM TimelineStatusEntity + WHERE pachliAccountId = :accountId + UNION + SELECT statusServerId + FROM NotificationEntity + WHERE pachliAccountId = :accountId ) """, ) - abstract suspend fun cleanupTranslatedStatus(accountId: Long, limit: Int) + abstract suspend fun cleanupTranslatedStatus(accountId: Long) @Upsert abstract suspend fun upsertStatusViewData(svd: StatusViewDataEntity) diff --git a/core/database/src/test/kotlin/app/pachli/core/database/dao/TimelineDaoTest.kt b/core/database/src/test/kotlin/app/pachli/core/database/dao/TimelineDaoTest.kt index a29287df2c..439068edeb 100644 --- a/core/database/src/test/kotlin/app/pachli/core/database/dao/TimelineDaoTest.kt +++ b/core/database/src/test/kotlin/app/pachli/core/database/dao/TimelineDaoTest.kt @@ -23,6 +23,7 @@ import app.pachli.core.database.AppDatabase import app.pachli.core.database.model.AccountEntity import app.pachli.core.database.model.StatusEntity import app.pachli.core.database.model.TimelineAccountEntity +import app.pachli.core.database.model.TimelineStatusEntity import app.pachli.core.database.model.TimelineStatusWithAccount import app.pachli.core.network.model.Card import app.pachli.core.network.model.Emoji @@ -105,6 +106,15 @@ class TimelineDaoTest { timelineDao.insertAccount(it) } statusDao.insertStatus(status) + timelineDao.upsertStatuses( + listOf( + TimelineStatusEntity( + kind = TimelineStatusEntity.Kind.Home, + pachliAccountId = status.timelineUserId, + statusId = status.serverId + ) + ) + ) } val pagingSource = timelineDao.getStatuses(setOne.first.timelineUserId) @@ -135,10 +145,67 @@ class TimelineDaoTest { timelineDao.insertAccount(it) } statusDao.insertStatus(status) + timelineDao.upsertStatuses( + listOf( + TimelineStatusEntity( + pachliAccountId = status.timelineUserId, + kind = TimelineStatusEntity.Kind.Home, + statusId = status.serverId, + ) + ) + ) + } + + val l: PagingSource.LoadParams = PagingSource.LoadParams.Refresh(null, 100, true) +// val x = (timelineDao.c(1).load(l) as PagingSource.LoadResult.Page).data +// x.withIndex().forEach { +// println("${it.index}: ${it.value}") +// } +// +// val y = timelineDao.c2(1) +// y.withIndex().forEach { +// println("${it.index}: ${it.value}") +// } + + val getStatusesBeforeCleanup = (timelineDao.getStatuses(1).load(l) as PagingSource.LoadResult.Page).data + println("getStatuses() before calling cleanup()") + getStatusesBeforeCleanup.withIndex().forEach { + println("${it.index}: ${it.value}") } - timelineDao.cleanup(accountId = 1, limit = 3) - timelineDao.cleanupAccounts(accountId = 1) + println("getStatusesAsList() before calling cleanup()") + timelineDao.getStatusesAsList(1).withIndex().forEach { + println("${it.index}: ${it.value}") + } + + // Remove some statuses from the home timeline for account 1L. This makes + // them targets for the cleanup. + arrayOf("5", "3", "1").forEach { + timelineDao.delete( + TimelineStatusEntity( + pachliAccountId = 1L, + kind = TimelineStatusEntity.Kind.Home, + statusId = it + ) + ) + } + + timelineDao.cleanup(accountId = 1) + +// val y2 = timelineDao.c2(1) +// y2.withIndex().forEach { +// println("${it.index}: ${it.value}") +// } +// val l: PagingSource.LoadParams = PagingSource.LoadParams.Refresh(null, 100, true) +// val x = (timelineDao.c(1).load(l) as PagingSource.LoadResult.Page).data +// x.withIndex().forEach { +// println("${it.index}: ${it.value}") +// } +// val w2 = (timelineDao.getStatuses(1).load(l) as PagingSource.LoadResult.Page).data +// w2.withIndex().forEach { +// println("${it.index}: ${it.value}") +// } + val wantAccount1StatusesAfterCleanup = listOf( makeStatus(statusId = 100), @@ -150,11 +217,19 @@ class TimelineDaoTest { makeStatus(statusId = 2, accountId = 2, authorServerId = "5"), ) - val loadParams: PagingSource.LoadParams = PagingSource.LoadParams.Refresh(null, 100, false) + val loadParams: PagingSource.LoadParams = PagingSource.LoadParams.Refresh(null, 100, true) val gotAccount1StatusesAfterCleanup = (timelineDao.getStatuses(1).load(loadParams) as PagingSource.LoadResult.Page).data val gotAccount2StatusesAfterCleanup = (timelineDao.getStatuses(2).load(loadParams) as PagingSource.LoadResult.Page).data + println("Want") + wantAccount1StatusesAfterCleanup.withIndex().forEach { v -> + println("${v.index}: ${v.value}") + } + println("Got") + gotAccount1StatusesAfterCleanup.withIndex().forEach { v -> + println("${v.index}: ${v.value}") + } assertStatuses(wantAccount1StatusesAfterCleanup, gotAccount1StatusesAfterCleanup) assertStatuses(wantAccount2StatusesAfterCleanup, gotAccount2StatusesAfterCleanup) @@ -343,6 +418,15 @@ class TimelineDaoTest { timelineDao.insertAccount(it) } statusDao.insertStatus(status) + timelineDao.upsertStatuses( + listOf( + TimelineStatusEntity( + pachliAccountId = status.timelineUserId, + kind = TimelineStatusEntity.Kind.Home, + statusId = status.serverId + ) + ) + ) } val pagingSource = timelineDao.getStatuses(setOne.first.timelineUserId) From ab20528674c618bd87d2e5bfdcfb3de0573554c4 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Sun, 2 Feb 2025 19:50:52 +0100 Subject: [PATCH 16/19] Convert TimelineDaoTest to an instrumented test The local test was returning unsorted results for the PagingSource --- .../CachedTimelineRemoteMediatorTest.kt | 16 +- core/database/build.gradle.kts | 14 +- .../pachli/core/database/HiltTestRunner.kt | 36 +++++ .../core/database/dao/TimelineDaoTest.kt | 67 ++------- .../pachli/core/database/dao/TimelineDao.kt | 139 ++---------------- gradle/libs.versions.toml | 2 + 6 files changed, 85 insertions(+), 189 deletions(-) create mode 100644 core/database/src/androidTest/kotlin/app/pachli/core/database/HiltTestRunner.kt rename core/database/src/{test => androidTest}/kotlin/app/pachli/core/database/dao/TimelineDaoTest.kt (89%) diff --git a/app/src/test/java/app/pachli/components/timeline/CachedTimelineRemoteMediatorTest.kt b/app/src/test/java/app/pachli/components/timeline/CachedTimelineRemoteMediatorTest.kt index 20f4fe626e..b19a8b8d94 100644 --- a/app/src/test/java/app/pachli/components/timeline/CachedTimelineRemoteMediatorTest.kt +++ b/app/src/test/java/app/pachli/components/timeline/CachedTimelineRemoteMediatorTest.kt @@ -344,13 +344,15 @@ class CachedTimelineRemoteMediatorTest { } statusDao().insertStatus(statusWithAccount.status) } - timelineDao().upsertStatuses(statuses.map { - TimelineStatusEntity( - pachliAccountId = it.status.timelineUserId, - kind = TimelineStatusEntity.Kind.Home, - statusId = it.status.serverId - ) - }) + timelineDao().upsertStatuses( + statuses.map { + TimelineStatusEntity( + pachliAccountId = it.status.timelineUserId, + kind = TimelineStatusEntity.Kind.Home, + statusId = it.status.serverId, + ) + }, + ) } } diff --git a/core/database/build.gradle.kts b/core/database/build.gradle.kts index 9740d131db..30e6487a61 100644 --- a/core/database/build.gradle.kts +++ b/core/database/build.gradle.kts @@ -26,7 +26,15 @@ android { namespace = "app.pachli.core.database" defaultConfig { - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + testInstrumentationRunner = "app.pachli.core.database.HiltTestRunner" + } + + packaging { + resources.excludes.apply { + // Otherwise this error: + // "2 files found with path 'META-INF/versions/9/OSGI-INF/MANIFEST.MF' from inputs:" + add("META-INF/versions/9/OSGI-INF/MANIFEST.MF") + } } } @@ -48,4 +56,8 @@ dependencies { implementation(libs.semver)?.because("Converters has to convert Version") testImplementation(projects.core.testing) + + androidTestImplementation(libs.androidx.test.rules) + androidTestImplementation(libs.hilt.android.testing) + kspAndroidTest(libs.hilt.android) } diff --git a/core/database/src/androidTest/kotlin/app/pachli/core/database/HiltTestRunner.kt b/core/database/src/androidTest/kotlin/app/pachli/core/database/HiltTestRunner.kt new file mode 100644 index 0000000000..d3ede5f045 --- /dev/null +++ b/core/database/src/androidTest/kotlin/app/pachli/core/database/HiltTestRunner.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2025 Pachli Association + * + * This file is a part of Pachli. + * + * 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. + * + * Pachli 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 Pachli; if not, + * see . + */ + +package app.pachli.core.database + +import android.app.Application +import android.content.Context +import androidx.test.runner.AndroidJUnitRunner +import dagger.hilt.android.testing.HiltTestApplication + +/** + * Test runner that sets the [HiltTestApplication] as the as the + * test application in instrumented tests. + * + * See https://developer.android.com/training/dependency-injection/hilt-testing#instrumented-tests + * and the `testInstrumentationRunner` setting in `build.gradle.kts` for details. + */ +class HiltTestRunner : AndroidJUnitRunner() { + override fun newApplication(cl: ClassLoader?, name: String?, context: Context?): Application { + return super.newApplication(cl, HiltTestApplication::class.java.name, context) + } +} diff --git a/core/database/src/test/kotlin/app/pachli/core/database/dao/TimelineDaoTest.kt b/core/database/src/androidTest/kotlin/app/pachli/core/database/dao/TimelineDaoTest.kt similarity index 89% rename from core/database/src/test/kotlin/app/pachli/core/database/dao/TimelineDaoTest.kt rename to core/database/src/androidTest/kotlin/app/pachli/core/database/dao/TimelineDaoTest.kt index 439068edeb..256f943225 100644 --- a/core/database/src/test/kotlin/app/pachli/core/database/dao/TimelineDaoTest.kt +++ b/core/database/src/androidTest/kotlin/app/pachli/core/database/dao/TimelineDaoTest.kt @@ -111,9 +111,9 @@ class TimelineDaoTest { TimelineStatusEntity( kind = TimelineStatusEntity.Kind.Home, pachliAccountId = status.timelineUserId, - statusId = status.serverId - ) - ) + statusId = status.serverId, + ), + ), ) } @@ -151,33 +151,11 @@ class TimelineDaoTest { pachliAccountId = status.timelineUserId, kind = TimelineStatusEntity.Kind.Home, statusId = status.serverId, - ) - ) + ), + ), ) } - val l: PagingSource.LoadParams = PagingSource.LoadParams.Refresh(null, 100, true) -// val x = (timelineDao.c(1).load(l) as PagingSource.LoadResult.Page).data -// x.withIndex().forEach { -// println("${it.index}: ${it.value}") -// } -// -// val y = timelineDao.c2(1) -// y.withIndex().forEach { -// println("${it.index}: ${it.value}") -// } - - val getStatusesBeforeCleanup = (timelineDao.getStatuses(1).load(l) as PagingSource.LoadResult.Page).data - println("getStatuses() before calling cleanup()") - getStatusesBeforeCleanup.withIndex().forEach { - println("${it.index}: ${it.value}") - } - - println("getStatusesAsList() before calling cleanup()") - timelineDao.getStatusesAsList(1).withIndex().forEach { - println("${it.index}: ${it.value}") - } - // Remove some statuses from the home timeline for account 1L. This makes // them targets for the cleanup. arrayOf("5", "3", "1").forEach { @@ -185,28 +163,13 @@ class TimelineDaoTest { TimelineStatusEntity( pachliAccountId = 1L, kind = TimelineStatusEntity.Kind.Home, - statusId = it - ) + statusId = it, + ), ) } timelineDao.cleanup(accountId = 1) -// val y2 = timelineDao.c2(1) -// y2.withIndex().forEach { -// println("${it.index}: ${it.value}") -// } -// val l: PagingSource.LoadParams = PagingSource.LoadParams.Refresh(null, 100, true) -// val x = (timelineDao.c(1).load(l) as PagingSource.LoadResult.Page).data -// x.withIndex().forEach { -// println("${it.index}: ${it.value}") -// } -// val w2 = (timelineDao.getStatuses(1).load(l) as PagingSource.LoadResult.Page).data -// w2.withIndex().forEach { -// println("${it.index}: ${it.value}") -// } - - val wantAccount1StatusesAfterCleanup = listOf( makeStatus(statusId = 100), makeStatus(statusId = 10, authorServerId = "3"), @@ -222,14 +185,6 @@ class TimelineDaoTest { val gotAccount1StatusesAfterCleanup = (timelineDao.getStatuses(1).load(loadParams) as PagingSource.LoadResult.Page).data val gotAccount2StatusesAfterCleanup = (timelineDao.getStatuses(2).load(loadParams) as PagingSource.LoadResult.Page).data - println("Want") - wantAccount1StatusesAfterCleanup.withIndex().forEach { v -> - println("${v.index}: ${v.value}") - } - println("Got") - gotAccount1StatusesAfterCleanup.withIndex().forEach { v -> - println("${v.index}: ${v.value}") - } assertStatuses(wantAccount1StatusesAfterCleanup, gotAccount1StatusesAfterCleanup) assertStatuses(wantAccount2StatusesAfterCleanup, gotAccount2StatusesAfterCleanup) @@ -409,7 +364,7 @@ class TimelineDaoTest { } @Test - fun `preview card survives roundtrip`() = runTest { + fun previewCardSurvivesRoundtrip() = runTest { val setOne = makeStatus(statusId = 3, cardUrl = "https://foo.bar") for ((status, author, reblogger) in listOf(setOne)) { @@ -423,9 +378,9 @@ class TimelineDaoTest { TimelineStatusEntity( pachliAccountId = status.timelineUserId, kind = TimelineStatusEntity.Kind.Home, - statusId = status.serverId - ) - ) + statusId = status.serverId, + ), + ), ) } diff --git a/core/database/src/main/kotlin/app/pachli/core/database/dao/TimelineDao.kt b/core/database/src/main/kotlin/app/pachli/core/database/dao/TimelineDao.kt index 6ba5756943..4f00cc071f 100644 --- a/core/database/src/main/kotlin/app/pachli/core/database/dao/TimelineDao.kt +++ b/core/database/src/main/kotlin/app/pachli/core/database/dao/TimelineDao.kt @@ -144,117 +144,6 @@ ORDER BY LENGTH(s.serverId) DESC, s.serverId DESC timelineKind: TimelineStatusEntity.Kind = TimelineStatusEntity.Kind.Home, ): PagingSource - @Query( -""" -SELECT s.* -FROM - TimelineStatusEntity AS t -LEFT JOIN - StatusEntity AS s - ON (t.pachliAccountId = :account AND (s.timelineUserId = :account AND t.statusId = s.serverId)) -WHERE - t.kind = :timelineKind AND t.pachliAccountId = :account --AND s.timelineUserId = :account -ORDER BY LENGTH(s.serverId) DESC, s.serverId DESC -""" - ) - abstract fun c( - account: Long, - timelineKind: TimelineStatusEntity.Kind = TimelineStatusEntity.Kind.Home, - ): PagingSource - - @Query( - """ -SELECT - s.serverId, - s.url, - s.timelineUserId, - s.authorServerId, - s.inReplyToId, - s.inReplyToAccountId, - s.createdAt, - s.editedAt, - s.emojis, - s.reblogsCount, - s.favouritesCount, - s.repliesCount, - s.reblogged, - s.favourited, - s.bookmarked, - s.sensitive, - s.spoilerText, - s.visibility, - s.mentions, - s.tags, - s.application, - s.reblogServerId, - s.reblogAccountId, - s.content, - s.attachments, - s.poll, - s.card, - s.muted, - s.pinned, - s.language, - s.filtered, - a.serverId AS 'a_serverId', - a.timelineUserId AS 'a_timelineUserId', - a.localUsername AS 'a_localUsername', - a.username AS 'a_username', - a.displayName AS 'a_displayName', - a.url AS 'a_url', - a.avatar AS 'a_avatar', - a.emojis AS 'a_emojis', - a.bot AS 'a_bot', - a.createdAt AS 'a_createdAt', - a.limited AS 'a_limited', - a.note AS 'a_note', - rb.serverId AS 'rb_serverId', - rb.timelineUserId AS 'rb_timelineUserId', - rb.localUsername AS 'rb_localUsername', - rb.username AS 'rb_username', - rb.displayName AS 'rb_displayName', - rb.url AS 'rb_url', - rb.avatar AS 'rb_avatar', - rb.emojis AS 'rb_emojis', - rb.bot AS 'rb_bot', - rb.createdAt AS 'rb_createdAt', - rb.limited AS 'rb_limited', - rb.note AS 'rb_note', - svd.serverId AS 'svd_serverId', - svd.timelineUserId AS 'svd_timelineUserId', - svd.expanded AS 'svd_expanded', - svd.contentShowing AS 'svd_contentShowing', - svd.contentCollapsed AS 'svd_contentCollapsed', - svd.translationState AS 'svd_translationState', - tr.serverId AS 't_serverId', - tr.timelineUserId AS 't_timelineUserId', - tr.content AS 't_content', - tr.spoilerText AS 't_spoilerText', - tr.poll AS 't_poll', - tr.attachments AS 't_attachments', - tr.provider AS 't_provider' -FROM TimelineStatusEntity AS t -LEFT JOIN - StatusEntity AS s - ON (t.pachliAccountId = :account AND (s.timelineUserId = :account AND t.statusId = s.serverId)) -LEFT JOIN TimelineAccountEntity AS a ON (s.timelineUserId = a.timelineUserId AND s.authorServerId = a.serverId) -LEFT JOIN TimelineAccountEntity AS rb ON (s.timelineUserId = rb.timelineUserId AND s.reblogAccountId = rb.serverId) -LEFT JOIN - StatusViewDataEntity AS svd - ON (s.timelineUserId = svd.timelineUserId AND (s.serverId = svd.serverId OR s.reblogServerId = svd.serverId)) -LEFT JOIN - TranslatedStatusEntity AS tr - ON (s.timelineUserId = tr.timelineUserId AND (s.serverId = tr.serverId OR s.reblogServerId = tr.serverId)) -WHERE t.kind = :timelineKind AND t.pachliAccountId = :account --AND s.timelineUserId = :account -ORDER BY LENGTH(s.serverId) DESC, s.serverId DESC -""", - ) - abstract fun getStatusesAsList( - account: Long, - timelineKind: TimelineStatusEntity.Kind = TimelineStatusEntity.Kind.Home, - ): List - - /** * @return Row number (0 based) of the status with ID [statusId] for [pachliAccountId] * on [timelineKind]. @@ -529,13 +418,13 @@ FROM StatusViewDataEntity WHERE timelineUserId = :accountId AND serverId NOT IN ( - SELECT statusId - FROM TimelineStatusEntity - WHERE pachliAccountId = :accountId - UNION - SELECT statusServerId - FROM NotificationEntity - WHERE pachliAccountId = :accountId + SELECT statusId + FROM TimelineStatusEntity + WHERE pachliAccountId = :accountId + UNION + SELECT statusServerId + FROM NotificationEntity + WHERE pachliAccountId = :accountId ) """, ) @@ -552,13 +441,13 @@ FROM TranslatedStatusEntity WHERE timelineUserId = :accountId AND serverId NOT IN ( - SELECT statusId - FROM TimelineStatusEntity - WHERE pachliAccountId = :accountId - UNION - SELECT statusServerId - FROM NotificationEntity - WHERE pachliAccountId = :accountId + SELECT statusId + FROM TimelineStatusEntity + WHERE pachliAccountId = :accountId + UNION + SELECT statusServerId + FROM NotificationEntity + WHERE pachliAccountId = :accountId ) """, ) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ef85eee048..cada24a684 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -22,6 +22,7 @@ androidx-splashscreen = "1.2.0-alpha02" androidx-swiperefresh-layout = "1.1.0" androidx-testing = "2.2.0" androidx-test-core-ktx = "1.6.1" +androidx-test-rules = "1.6.1" androidx-transition = "1.5.1" androidx-viewpager2 = "1.1.0" androidx-webkit = "1.12.1" @@ -151,6 +152,7 @@ androidx-sharetarget = { module = "androidx.sharetarget:sharetarget", version.re androidx-swiperefreshlayout = { module = "androidx.swiperefreshlayout:swiperefreshlayout", version.ref = "androidx-swiperefresh-layout" } androidx-test-core-ktx = { module = "androidx.test:core-ktx", version.ref = "androidx-test-core-ktx" } androidx-test-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-junit" } +androidx-test-rules = { module = "androidx.test:rules", version.ref = "androidx-test-rules"} androidx-transition = { module = "androidx.transition:transition-ktx", version.ref = "androidx-transition" } androidx-viewpager2 = { module = "androidx.viewpager2:viewpager2", version.ref = "androidx-viewpager2" } androidx-webkit = { module = "androidx.webkit:webkit", version.ref = "androidx-webkit" } From 83bcf6451a3a58cfbec1e20950f95bf899163a07 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Sun, 2 Feb 2025 20:05:58 +0100 Subject: [PATCH 17/19] ci: Configure emulator tests in GitHub actions --- .github/workflows/ci.yml | 53 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 52b2d82c56..638e6142ec 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -148,3 +148,56 @@ jobs: - name: assemble ${{ matrix.color }}${{ matrix.store }}${{ matrix.type }} run: ./gradlew assemble${{ matrix.color }}${{ matrix.store }}${{ matrix.type }} + + # Connected tests are per-store-variant, debug builds only. + connected: + strategy: + matrix: + color: ["Orange"] + store: ["Fdroid", "Github", "Google"] + type: ["Debug"] + api-level: [31] + target: [default] + name: Android Emulator Tests + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + + - name: Enable KVM + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - uses: ./.github/actions/setup-build-env + with: + gradle-cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} + + - name: AVD cache + uses: actions/cache@v4 + id: avd-cache + with: + path: | + ~/.android/avd/* + ~/.android/adb* + key: avd-${{ matrix.api-level }} + + - name: Create AVD and generate snapshot for caching + if: steps.avd-cache.outputs.cache-hit != 'true' + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: ${{ matrix.api-level }} + force-avd-creation: false + emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + disable-animations: false + script: echo "Generated AVD snapshot for caching." + + - name: Run tests + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: ${{ matrix.api-level }} + target: ${{ matrix.target }} + arch: x86_64 + script: ./gradlew connected${{ matrix.color }}${{ matrix.store }}${{ matrix.type }}AndroidTest From 28dfadc4ee12ffabd917af43fe9536b47360a0b9 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Sun, 2 Feb 2025 20:17:50 +0100 Subject: [PATCH 18/19] arch = x86_64 --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 638e6142ec..af9c183023 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -188,6 +188,7 @@ jobs: if: steps.avd-cache.outputs.cache-hit != 'true' uses: reactivecircus/android-emulator-runner@v2 with: + arch: x86_64 api-level: ${{ matrix.api-level }} force-avd-creation: false emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none From a407da325fb3edffaab24ec90d44bd4208fb3f85 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Sun, 2 Feb 2025 21:03:46 +0100 Subject: [PATCH 19/19] Lint --- .../main/java/app/pachli/components/timeline/TimelineFragment.kt | 1 - .../components/timeline/viewmodel/CachedTimelineViewModel.kt | 1 - .../src/main/kotlin/app/pachli/core/database/dao/TimelineDao.kt | 1 - 3 files changed, 3 deletions(-) diff --git a/app/src/main/java/app/pachli/components/timeline/TimelineFragment.kt b/app/src/main/java/app/pachli/components/timeline/TimelineFragment.kt index 2c178c2f43..f411367b0e 100644 --- a/app/src/main/java/app/pachli/components/timeline/TimelineFragment.kt +++ b/app/src/main/java/app/pachli/components/timeline/TimelineFragment.kt @@ -102,7 +102,6 @@ import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.take -import kotlinx.coroutines.flow.zip import kotlinx.coroutines.launch import postPrepend import timber.log.Timber diff --git a/app/src/main/java/app/pachli/components/timeline/viewmodel/CachedTimelineViewModel.kt b/app/src/main/java/app/pachli/components/timeline/viewmodel/CachedTimelineViewModel.kt index 5aee06ab3d..2e4267e611 100644 --- a/app/src/main/java/app/pachli/components/timeline/viewmodel/CachedTimelineViewModel.kt +++ b/app/src/main/java/app/pachli/components/timeline/viewmodel/CachedTimelineViewModel.kt @@ -43,7 +43,6 @@ import javax.inject.Inject import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.distinctUntilChangedBy -import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map diff --git a/core/database/src/main/kotlin/app/pachli/core/database/dao/TimelineDao.kt b/core/database/src/main/kotlin/app/pachli/core/database/dao/TimelineDao.kt index 4f00cc071f..353a875560 100644 --- a/core/database/src/main/kotlin/app/pachli/core/database/dao/TimelineDao.kt +++ b/core/database/src/main/kotlin/app/pachli/core/database/dao/TimelineDao.kt @@ -164,7 +164,6 @@ FROM ( LEFT JOIN StatusEntity AS s ON (t.statusId = s.serverId) WHERE t.kind = :timelineKind AND t.pachliAccountId = :pachliAccountId ) - SELECT t1.timelineUserId, t1.serverId,