From 8a57bcc3f40a1dc1885ef89892129358b84d3977 Mon Sep 17 00:00:00 2001 From: Konrad Pozniak Date: Wed, 3 Jul 2024 21:18:09 +0200 Subject: [PATCH] only check once for filters v2 availability (#4539) Instead of calling the endpoint every time filters are needed, it will be called only once and the result cached. This will result in quite some requests less on instances supporting v2. I also tested v1 filters and made some small improvements. We should [remove filters v1 support](https://github.com/tuskyapp/Tusky/issues/4538) some time in the future though. --- .../64.json | 1338 +++++++++++++++++ .../keylesspalace/tusky/StatusListActivity.kt | 8 +- .../instanceinfo/InstanceInfoRepository.kt | 4 + .../notifications/NotificationsViewModel.kt | 8 +- .../timeline/viewmodel/TimelineViewModel.kt | 76 +- .../viewthread/ViewThreadViewModel.kt | 43 +- .../keylesspalace/tusky/db/AppDatabase.java | 5 +- .../keylesspalace/tusky/db/dao/InstanceDao.kt | 6 + .../tusky/db/entity/InstanceEntity.kt | 3 + .../keylesspalace/tusky/entity/FilterV1.kt | 7 - .../tusky/network/FilterModel.kt | 55 +- .../com/keylesspalace/tusky/FilterV1Test.kt | 38 +- .../viewthread/ViewThreadViewModelTest.kt | 6 +- 13 files changed, 1470 insertions(+), 127 deletions(-) create mode 100644 app/schemas/com.keylesspalace.tusky.db.AppDatabase/64.json diff --git a/app/schemas/com.keylesspalace.tusky.db.AppDatabase/64.json b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/64.json new file mode 100644 index 000000000..5f4645e66 --- /dev/null +++ b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/64.json @@ -0,0 +1,1338 @@ +{ + "formatVersion": 1, + "database": { + "version": 64, + "identityHash": "12c1f266e9fb1d7fea3dde12866eb338", + "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` TEXT, `language` TEXT, `statusId` TEXT)", + "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": "TEXT", + "notNull": false + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "statusId", + "columnName": "statusId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "AccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `domain` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `clientId` TEXT, `clientSecret` TEXT, `isActive` INTEGER NOT NULL, `accountId` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `profilePictureUrl` TEXT NOT NULL, `notificationsEnabled` INTEGER NOT NULL, `notificationsMentioned` INTEGER NOT NULL, `notificationsFollowed` INTEGER NOT NULL, `notificationsFollowRequested` INTEGER NOT NULL, `notificationsReblogged` INTEGER NOT NULL, `notificationsFavorited` INTEGER NOT NULL, `notificationsPolls` INTEGER NOT NULL, `notificationsSubscriptions` INTEGER NOT NULL, `notificationsSignUps` INTEGER NOT NULL, `notificationsUpdates` INTEGER NOT NULL, `notificationsReports` INTEGER NOT NULL, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultReplyPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `defaultPostLanguage` TEXT NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `alwaysOpenSpoiler` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT 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, `lastVisibleHomeTimelineStatusId` TEXT, `locked` INTEGER NOT NULL DEFAULT 0, `hasDirectMessageBadge` INTEGER NOT NULL DEFAULT 0, `isShowHomeBoosts` INTEGER NOT NULL, `isShowHomeReplies` INTEGER NOT NULL, `isShowHomeSelfBoosts` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clientId", + "columnName": "clientId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "clientSecret", + "columnName": "clientSecret", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profilePictureUrl", + "columnName": "profilePictureUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationsEnabled", + "columnName": "notificationsEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsMentioned", + "columnName": "notificationsMentioned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFollowed", + "columnName": "notificationsFollowed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFollowRequested", + "columnName": "notificationsFollowRequested", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsReblogged", + "columnName": "notificationsReblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFavorited", + "columnName": "notificationsFavorited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsPolls", + "columnName": "notificationsPolls", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsSubscriptions", + "columnName": "notificationsSubscriptions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsSignUps", + "columnName": "notificationsSignUps", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsUpdates", + "columnName": "notificationsUpdates", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsReports", + "columnName": "notificationsReports", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationSound", + "columnName": "notificationSound", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationVibration", + "columnName": "notificationVibration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationLight", + "columnName": "notificationLight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultPostPrivacy", + "columnName": "defaultPostPrivacy", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultReplyPrivacy", + "columnName": "defaultReplyPrivacy", + "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": "lastNotificationId", + "columnName": "lastNotificationId", + "affinity": "TEXT", + "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": "lastVisibleHomeTimelineStatusId", + "columnName": "lastVisibleHomeTimelineStatusId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "locked", + "columnName": "locked", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "hasDirectMessageBadge", + "columnName": "hasDirectMessageBadge", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isShowHomeBoosts", + "columnName": "isShowHomeBoosts", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isShowHomeReplies", + "columnName": "isShowHomeReplies", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isShowHomeSelfBoosts", + "columnName": "isShowHomeSelfBoosts", + "affinity": "INTEGER", + "notNull": true + } + ], + "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": "InstanceEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`instance` TEXT NOT NULL, `emojiList` TEXT, `maximumTootCharacters` INTEGER, `maxPollOptions` INTEGER, `maxPollOptionLength` INTEGER, `minPollDuration` INTEGER, `maxPollDuration` INTEGER, `charactersReservedPerUrl` INTEGER, `version` TEXT, `videoSizeLimit` INTEGER, `imageSizeLimit` INTEGER, `imageMatrixLimit` INTEGER, `maxMediaAttachments` INTEGER, `maxFields` INTEGER, `maxFieldNameLength` INTEGER, `maxFieldValueLength` INTEGER, `translationEnabled` INTEGER, `filterV2Supported` INTEGER NOT NULL DEFAULT false, PRIMARY KEY(`instance`))", + "fields": [ + { + "fieldPath": "instance", + "columnName": "instance", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojiList", + "columnName": "emojiList", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maximumTootCharacters", + "columnName": "maximumTootCharacters", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollOptions", + "columnName": "maxPollOptions", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollOptionLength", + "columnName": "maxPollOptionLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "minPollDuration", + "columnName": "minPollDuration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollDuration", + "columnName": "maxPollDuration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "charactersReservedPerUrl", + "columnName": "charactersReservedPerUrl", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "notNull": false + }, + { + "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": "translationEnabled", + "columnName": "translationEnabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "filterV2Supported", + "columnName": "filterV2Supported", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "instance" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "TimelineStatusEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `url` TEXT, `tuskyAccountId` INTEGER NOT NULL, `authorServerId` TEXT NOT NULL, `inReplyToId` TEXT, `inReplyToAccountId` TEXT, `content` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `editedAt` INTEGER, `emojis` TEXT NOT NULL, `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 NOT NULL, `mentions` TEXT NOT NULL, `tags` TEXT NOT NULL, `application` TEXT, `poll` TEXT, `muted` INTEGER NOT NULL, `expanded` INTEGER NOT NULL, `contentCollapsed` INTEGER NOT NULL, `contentShowing` INTEGER NOT NULL, `pinned` INTEGER NOT NULL, `card` TEXT, `language` TEXT, `filtered` TEXT NOT NULL, PRIMARY KEY(`serverId`, `tuskyAccountId`), FOREIGN KEY(`authorServerId`, `tuskyAccountId`) REFERENCES `TimelineAccountEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tuskyAccountId", + "columnName": "tuskyAccountId", + "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": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "editedAt", + "columnName": "editedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "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": true + }, + { + "fieldPath": "mentions", + "columnName": "mentions", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "application", + "columnName": "application", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "muted", + "columnName": "muted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "expanded", + "columnName": "expanded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentCollapsed", + "columnName": "contentCollapsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentShowing", + "columnName": "contentShowing", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pinned", + "columnName": "pinned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "card", + "columnName": "card", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "filtered", + "columnName": "filtered", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "tuskyAccountId" + ] + }, + "indices": [ + { + "name": "index_TimelineStatusEntity_authorServerId_tuskyAccountId", + "unique": false, + "columnNames": [ + "authorServerId", + "tuskyAccountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TimelineStatusEntity_authorServerId_tuskyAccountId` ON `${TABLE_NAME}` (`authorServerId`, `tuskyAccountId`)" + } + ], + "foreignKeys": [ + { + "table": "TimelineAccountEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "authorServerId", + "tuskyAccountId" + ], + "referencedColumns": [ + "serverId", + "tuskyAccountId" + ] + } + ] + }, + { + "tableName": "TimelineAccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `tuskyAccountId` INTEGER NOT NULL, `localUsername` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `url` TEXT NOT NULL, `avatar` TEXT NOT NULL, `emojis` TEXT NOT NULL, `bot` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `tuskyAccountId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tuskyAccountId", + "columnName": "tuskyAccountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localUsername", + "columnName": "localUsername", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "avatar", + "columnName": "avatar", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bot", + "columnName": "bot", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "tuskyAccountId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "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`))", + "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": [], + "foreignKeys": [] + }, + { + "tableName": "NotificationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tuskyAccountId` INTEGER NOT NULL, `type` TEXT, `id` TEXT NOT NULL, `accountId` TEXT, `statusId` TEXT, `reportId` TEXT, `loading` INTEGER NOT NULL, PRIMARY KEY(`id`, `tuskyAccountId`), FOREIGN KEY(`accountId`, `tuskyAccountId`) REFERENCES `TimelineAccountEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION , FOREIGN KEY(`statusId`, `tuskyAccountId`) REFERENCES `TimelineStatusEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION , FOREIGN KEY(`reportId`, `tuskyAccountId`) REFERENCES `NotificationReportEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "tuskyAccountId", + "columnName": "tuskyAccountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "statusId", + "columnName": "statusId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reportId", + "columnName": "reportId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loading", + "columnName": "loading", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id", + "tuskyAccountId" + ] + }, + "indices": [ + { + "name": "index_NotificationEntity_accountId_tuskyAccountId", + "unique": false, + "columnNames": [ + "accountId", + "tuskyAccountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_accountId_tuskyAccountId` ON `${TABLE_NAME}` (`accountId`, `tuskyAccountId`)" + }, + { + "name": "index_NotificationEntity_statusId_tuskyAccountId", + "unique": false, + "columnNames": [ + "statusId", + "tuskyAccountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_statusId_tuskyAccountId` ON `${TABLE_NAME}` (`statusId`, `tuskyAccountId`)" + }, + { + "name": "index_NotificationEntity_reportId_tuskyAccountId", + "unique": false, + "columnNames": [ + "reportId", + "tuskyAccountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_reportId_tuskyAccountId` ON `${TABLE_NAME}` (`reportId`, `tuskyAccountId`)" + } + ], + "foreignKeys": [ + { + "table": "TimelineAccountEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "accountId", + "tuskyAccountId" + ], + "referencedColumns": [ + "serverId", + "tuskyAccountId" + ] + }, + { + "table": "TimelineStatusEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "statusId", + "tuskyAccountId" + ], + "referencedColumns": [ + "serverId", + "tuskyAccountId" + ] + }, + { + "table": "NotificationReportEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "reportId", + "tuskyAccountId" + ], + "referencedColumns": [ + "serverId", + "tuskyAccountId" + ] + } + ] + }, + { + "tableName": "NotificationReportEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tuskyAccountId` INTEGER NOT NULL, `serverId` TEXT NOT NULL, `category` TEXT NOT NULL, `statusIds` TEXT, `createdAt` INTEGER NOT NULL, `targetAccountId` TEXT, PRIMARY KEY(`serverId`, `tuskyAccountId`), FOREIGN KEY(`targetAccountId`, `tuskyAccountId`) REFERENCES `TimelineAccountEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "tuskyAccountId", + "columnName": "tuskyAccountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "category", + "columnName": "category", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "statusIds", + "columnName": "statusIds", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "targetAccountId", + "columnName": "targetAccountId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "tuskyAccountId" + ] + }, + "indices": [ + { + "name": "index_NotificationReportEntity_targetAccountId_tuskyAccountId", + "unique": false, + "columnNames": [ + "targetAccountId", + "tuskyAccountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationReportEntity_targetAccountId_tuskyAccountId` ON `${TABLE_NAME}` (`targetAccountId`, `tuskyAccountId`)" + } + ], + "foreignKeys": [ + { + "table": "TimelineAccountEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "targetAccountId", + "tuskyAccountId" + ], + "referencedColumns": [ + "serverId", + "tuskyAccountId" + ] + } + ] + }, + { + "tableName": "HomeTimelineEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tuskyAccountId` INTEGER NOT NULL, `id` TEXT NOT NULL, `statusId` TEXT, `reblogAccountId` TEXT, `loading` INTEGER NOT NULL, PRIMARY KEY(`id`, `tuskyAccountId`), FOREIGN KEY(`statusId`, `tuskyAccountId`) REFERENCES `TimelineStatusEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION , FOREIGN KEY(`reblogAccountId`, `tuskyAccountId`) REFERENCES `TimelineAccountEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "tuskyAccountId", + "columnName": "tuskyAccountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "statusId", + "columnName": "statusId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogAccountId", + "columnName": "reblogAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loading", + "columnName": "loading", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id", + "tuskyAccountId" + ] + }, + "indices": [ + { + "name": "index_HomeTimelineEntity_statusId_tuskyAccountId", + "unique": false, + "columnNames": [ + "statusId", + "tuskyAccountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_HomeTimelineEntity_statusId_tuskyAccountId` ON `${TABLE_NAME}` (`statusId`, `tuskyAccountId`)" + }, + { + "name": "index_HomeTimelineEntity_reblogAccountId_tuskyAccountId", + "unique": false, + "columnNames": [ + "reblogAccountId", + "tuskyAccountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_HomeTimelineEntity_reblogAccountId_tuskyAccountId` ON `${TABLE_NAME}` (`reblogAccountId`, `tuskyAccountId`)" + } + ], + "foreignKeys": [ + { + "table": "TimelineStatusEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "statusId", + "tuskyAccountId" + ], + "referencedColumns": [ + "serverId", + "tuskyAccountId" + ] + }, + { + "table": "TimelineAccountEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "reblogAccountId", + "tuskyAccountId" + ], + "referencedColumns": [ + "serverId", + "tuskyAccountId" + ] + } + ] + } + ], + "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, '12c1f266e9fb1d7fea3dde12866eb338')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt b/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt index 778be4281..ca501aeba 100644 --- a/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt @@ -212,7 +212,7 @@ class StatusListActivity : BottomSheetActivity() { mastodonApi.getFiltersV1().fold( { filters -> mutedFilterV1 = filters.firstOrNull { filter -> - hashedTag == filter.phrase && filter.context.contains(FilterV1.HOME) + hashedTag == filter.phrase && filter.context.contains(Filter.Kind.HOME.kind) } updateTagMuteState(mutedFilterV1 != null) }, @@ -249,7 +249,7 @@ class StatusListActivity : BottomSheetActivity() { mastodonApi.createFilter( title = "#$tag", - context = listOf(FilterV1.HOME), + context = listOf(Filter.Kind.HOME.kind), filterAction = Filter.Action.WARN.action, expiresInSeconds = null ).fold( @@ -278,7 +278,7 @@ class StatusListActivity : BottomSheetActivity() { if (throwable.isHttpNotFound()) { mastodonApi.createFilterV1( hashedTag, - listOf(FilterV1.HOME), + listOf(Filter.Kind.HOME.kind), irreversible = false, wholeWord = true, expiresInSeconds = null @@ -355,7 +355,7 @@ class StatusListActivity : BottomSheetActivity() { mastodonApi.updateFilterV1( id = filter.id, phrase = filter.phrase, - context = filter.context.filter { it != FilterV1.HOME }, + context = filter.context.filter { it != Filter.Kind.HOME.kind }, irreversible = null, wholeWord = null, expiresInSeconds = null diff --git a/app/src/main/java/com/keylesspalace/tusky/components/instanceinfo/InstanceInfoRepository.kt b/app/src/main/java/com/keylesspalace/tusky/components/instanceinfo/InstanceInfoRepository.kt index ee2a9a7bf..4db2d0a45 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/instanceinfo/InstanceInfoRepository.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/instanceinfo/InstanceInfoRepository.kt @@ -107,6 +107,10 @@ class InstanceInfoRepository @Inject constructor( } }.toInfoOrDefault() + suspend fun saveFilterV2Support(filterV2Supported: Boolean) = dao.setFilterV2Support(instanceName, filterV2Supported) + + suspend fun isFilterV2Supported(): Boolean = dao.getFilterV2Support(instanceName) + private suspend fun InstanceInfoRepository.fetchAndPersistInstanceInfo(): NetworkResult = fetchRemoteInstanceInfo() .onSuccess { instanceInfoEntity -> diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModel.kt index 9c18ce971..d4f9b3f6f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModel.kt @@ -127,11 +127,17 @@ class NotificationsViewModel @Inject constructor( onPreferenceChanged(event.preferenceKey) } if (event is FilterUpdatedEvent && event.filterContext.contains(Filter.Kind.NOTIFICATIONS.kind)) { + filterModel.init(Filter.Kind.NOTIFICATIONS) refreshTrigger.value += 1 } } } - filterModel.kind = Filter.Kind.NOTIFICATIONS + viewModelScope.launch { + val needsRefresh = filterModel.init(Filter.Kind.NOTIFICATIONS) + if (needsRefresh) { + refreshTrigger.value++ + } + } } fun updateNotificationFilters(newFilters: Set) { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt index c7a3a38c0..8fe22419d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt @@ -21,10 +21,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.paging.PagingData import at.connyduck.calladapter.networkresult.NetworkResult -import at.connyduck.calladapter.networkresult.fold -import at.connyduck.calladapter.networkresult.getOrElse import at.connyduck.calladapter.networkresult.getOrThrow -import com.keylesspalace.tusky.appstore.Event import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.FilterUpdatedEvent import com.keylesspalace.tusky.appstore.PreferenceChangedEvent @@ -32,13 +29,11 @@ import com.keylesspalace.tusky.components.preference.PreferencesFragment.Reading import com.keylesspalace.tusky.components.timeline.util.ifExpected import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.entity.Filter -import com.keylesspalace.tusky.entity.FilterV1 import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.network.FilterModel import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.usecase.TimelineCases -import com.keylesspalace.tusky.util.isHttpNotFound import com.keylesspalace.tusky.viewdata.StatusViewData import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow @@ -73,7 +68,6 @@ abstract class TimelineViewModel( this.kind = kind this.id = id this.tags = tags - filterModel.kind = kind.toFilterKind() if (kind == Kind.HOME) { // Note the variable is "true if filter" but the underlying preference/settings text is "true if show" @@ -91,10 +85,27 @@ abstract class TimelineViewModel( viewModelScope.launch { eventHub.events - .collect { event -> handleEvent(event) } + .collect { event -> + when (event) { + is PreferenceChangedEvent -> { + onPreferenceChanged(event.preferenceKey) + } + is FilterUpdatedEvent -> { + if (filterContextMatchesKind(this@TimelineViewModel.kind, event.filterContext)) { + filterModel.init(kind.toFilterKind()) + fullReload() + } + } + } + } } - reloadFilters() + viewModelScope.launch { + val needsRefresh = filterModel.init(kind.toFilterKind()) + if (needsRefresh) { + fullReload() + } + } } fun reblog(reblog: Boolean, status: StatusViewData.Concrete): Job = viewModelScope.launch { @@ -208,11 +219,6 @@ abstract class TimelineViewModel( fullReload() } } - FilterV1.HOME, FilterV1.NOTIFICATIONS, FilterV1.THREAD, FilterV1.PUBLIC, FilterV1.ACCOUNT -> { - if (filterContextMatchesKind(kind, listOf(key))) { - reloadFilters() - } - } PrefKeys.ALWAYS_SHOW_SENSITIVE_MEDIA -> { // it is ok if only newly loaded statuses are affected, no need to fully refresh alwaysShowSensitiveMedia = @@ -224,50 +230,6 @@ abstract class TimelineViewModel( } } - private fun handleEvent(event: Event) { - when (event) { - is PreferenceChangedEvent -> { - onPreferenceChanged(event.preferenceKey) - } - is FilterUpdatedEvent -> { - if (filterContextMatchesKind(kind, event.filterContext)) { - fullReload() - } - } - } - } - - private fun reloadFilters() { - viewModelScope.launch { - api.getFilters().fold( - { - // After the filters are loaded we need to reload displayed content to apply them. - // It can happen during the usage or at startup, when we get statuses before filters. - invalidate() - }, - { throwable -> - if (throwable.isHttpNotFound()) { - // Fallback to client-side filter code - val filters = api.getFiltersV1().getOrElse { - Log.e(TAG, "Failed to fetch filters", it) - return@launch - } - filterModel.initWithFilters( - filters.filter { - filterContextMatchesKind(kind, it.context) - } - ) - // After the filters are loaded we need to reload displayed content to apply them. - // It can happen during the usage or at startup, when we get statuses before filters. - invalidate() - } else { - Log.e(TAG, "Error getting filters", throwable) - } - } - ) - } - } - abstract suspend fun translate(status: StatusViewData.Concrete): NetworkResult abstract fun untranslate(status: StatusViewData.Concrete) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadViewModel.kt index 7934f1cff..8f659c57b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadViewModel.kt @@ -35,12 +35,10 @@ import com.keylesspalace.tusky.components.timeline.util.ifExpected import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.entity.Filter -import com.keylesspalace.tusky.entity.FilterV1 import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.network.FilterModel import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.usecase.TimelineCases -import com.keylesspalace.tusky.util.isHttpNotFound import com.keylesspalace.tusky.util.toViewData import com.keylesspalace.tusky.viewdata.StatusViewData import com.keylesspalace.tusky.viewdata.TranslationViewData @@ -101,8 +99,6 @@ class ViewThreadViewModel @Inject constructor( } } } - - loadFilters() } fun loadThread(id: String) { @@ -110,6 +106,8 @@ class ViewThreadViewModel @Inject constructor( viewModelScope.launch { Log.d(TAG, "Finding status with: $id") + val filterCall = async { filterModel.init(Filter.Kind.THREAD) } + val contextCall = async { api.statusContext(id) } val statusAndAccount = db.timelineStatusDao().getStatusWithAccount(accountManager.activeAccount!!.id, id) @@ -152,6 +150,7 @@ class ViewThreadViewModel @Inject constructor( } } + filterCall.await() // make sure FilterModel is initialized before using it val contextResult = contextCall.await() contextResult.fold({ statusContext -> @@ -420,42 +419,6 @@ class ViewThreadViewModel @Inject constructor( return RevealButtonState.NO_BUTTON } - private fun loadFilters() { - viewModelScope.launch { - api.getFilters().fold( - { - filterModel.kind = Filter.Kind.THREAD - updateStatuses() - }, - { throwable -> - if (throwable.isHttpNotFound()) { - val filters = api.getFiltersV1().getOrElse { - Log.w(TAG, "Failed to fetch filters", it) - return@launch - } - - filterModel.initWithFilters( - filters.filter { filter -> filter.context.contains(FilterV1.THREAD) } - ) - updateStatuses() - } else { - Log.e(TAG, "Error getting filters", throwable) - } - } - ) - } - } - - private fun updateStatuses() { - updateSuccess { uiState -> - val statuses = uiState.statusViewData.filter() - uiState.copy( - statusViewData = statuses, - revealButton = statuses.getRevealButtonState() - ) - } - } - private fun List.filter(): List { return filter { status -> if (status.isDetailed) { diff --git a/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java b/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java index 879e168d8..2ade04712 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java +++ b/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java @@ -62,14 +62,15 @@ import java.io.File; }, // Note: Starting with version 54, database versions in Tusky are always even. // This is to reserve odd version numbers for use by forks. - version = 62, + version = 64, autoMigrations = { @AutoMigration(from = 48, to = 49), @AutoMigration(from = 49, to = 50, spec = AppDatabase.MIGRATION_49_50.class), @AutoMigration(from = 50, to = 51), @AutoMigration(from = 51, to = 52), @AutoMigration(from = 53, to = 54), // hasDirectMessageBadge in AccountEntity - @AutoMigration(from = 56, to = 58) // translationEnabled in InstanceEntity/InstanceInfoEntity + @AutoMigration(from = 56, to = 58), // translationEnabled in InstanceEntity/InstanceInfoEntity + @AutoMigration(from = 62, to = 64) // filterV2Available in InstanceEntity } ) public abstract class AppDatabase extends RoomDatabase { diff --git a/app/src/main/java/com/keylesspalace/tusky/db/dao/InstanceDao.kt b/app/src/main/java/com/keylesspalace/tusky/db/dao/InstanceDao.kt index 9f665e43f..b39c15b0b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/dao/InstanceDao.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/dao/InstanceDao.kt @@ -39,4 +39,10 @@ interface InstanceDao { @RewriteQueriesToDropUnusedColumns @Query("SELECT * FROM InstanceEntity WHERE instance = :instance LIMIT 1") suspend fun getEmojiInfo(instance: String): EmojisEntity? + + @Query("UPDATE InstanceEntity SET filterV2Supported = :filterV2Support WHERE instance = :instance") + suspend fun setFilterV2Support(instance: String, filterV2Support: Boolean) + + @Query("SELECT filterV2Supported FROM InstanceEntity WHERE instance = :instance LIMIT 1") + suspend fun getFilterV2Support(instance: String): Boolean } diff --git a/app/src/main/java/com/keylesspalace/tusky/db/entity/InstanceEntity.kt b/app/src/main/java/com/keylesspalace/tusky/db/entity/InstanceEntity.kt index fc8cde3b0..aac38217c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/entity/InstanceEntity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/entity/InstanceEntity.kt @@ -15,6 +15,7 @@ package com.keylesspalace.tusky.db.entity +import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.PrimaryKey import androidx.room.TypeConverters @@ -41,6 +42,8 @@ data class InstanceEntity( val maxFieldNameLength: Int?, val maxFieldValueLength: Int?, val translationEnabled: Boolean?, + // ToDo: Remove this again when filter v1 support is dropped + @ColumnInfo(defaultValue = "false") val filterV2Supported: Boolean = false ) @TypeConverters(Converters::class) diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/FilterV1.kt b/app/src/main/java/com/keylesspalace/tusky/entity/FilterV1.kt index 7a8c9b17e..40f48e1cd 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/FilterV1.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/FilterV1.kt @@ -28,13 +28,6 @@ data class FilterV1( val irreversible: Boolean, @Json(name = "whole_word") val wholeWord: Boolean ) { - companion object { - const val HOME = "home" - const val NOTIFICATIONS = "notifications" - const val PUBLIC = "public" - const val THREAD = "thread" - const val ACCOUNT = "account" - } override fun hashCode(): Int { return id.hashCode() diff --git a/app/src/main/java/com/keylesspalace/tusky/network/FilterModel.kt b/app/src/main/java/com/keylesspalace/tusky/network/FilterModel.kt index 3763fa548..c45070852 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/FilterModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/network/FilterModel.kt @@ -1,8 +1,13 @@ package com.keylesspalace.tusky.network +import android.util.Log +import at.connyduck.calladapter.networkresult.fold +import at.connyduck.calladapter.networkresult.getOrElse +import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.entity.FilterV1 import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.util.isHttpNotFound import com.keylesspalace.tusky.util.parseAsMastodonHtml import java.util.Date import java.util.regex.Pattern @@ -11,17 +16,54 @@ import javax.inject.Inject /** * One-stop for status filtering logic using Mastodon's filters. * - * 1. You init with [initWithFilters], this compiles regex pattern. + * 1. You init with [init], this checks which filter version to use and compiles regex pattern if needed. * 2. You call [shouldFilterStatus] to figure out what to display when you load statuses. */ -class FilterModel @Inject constructor() { +class FilterModel @Inject constructor( + private val instanceInfoRepo: InstanceInfoRepository, + private val api: MastodonApi +) { private var pattern: Pattern? = null private var v1 = false - lateinit var kind: Filter.Kind + private lateinit var kind: Filter.Kind - fun initWithFilters(filters: List) { - v1 = true - this.pattern = makeFilter(filters) + /** + * @param kind the [Filter.Kind] that should be filtered + * @return true when filters v1 have been loaded successfully and the currently shown posts may need to be filtered + */ + suspend fun init(kind: Filter.Kind): Boolean { + this.kind = kind + + if (instanceInfoRepo.isFilterV2Supported()) { + // nothing to do - Instance supports V2 so posts are filtered by the server + return false + } + + api.getFilters().fold( + { + instanceInfoRepo.saveFilterV2Support(true) + return false + }, + { throwable -> + if (throwable.isHttpNotFound()) { + val filters = api.getFiltersV1().getOrElse { + Log.w(TAG, "Failed to fetch filters", it) + return false + } + + this.v1 = true + + val activeFilters = filters.filter { filter -> filter.context.contains(kind.kind) } + + this.pattern = makeFilter(activeFilters) + + return activeFilters.isNotEmpty() + } else { + Log.e(TAG, "Error getting filters", throwable) + return false + } + } + ) } fun shouldFilterStatus(status: Status): Filter.Action { @@ -81,6 +123,7 @@ class FilterModel @Inject constructor() { } companion object { + private const val TAG = "FilterModel" private val ALPHANUMERIC = Pattern.compile("^\\w+$") } } diff --git a/app/src/test/java/com/keylesspalace/tusky/FilterV1Test.kt b/app/src/test/java/com/keylesspalace/tusky/FilterV1Test.kt index e6ceab6a7..b28447aba 100644 --- a/app/src/test/java/com/keylesspalace/tusky/FilterV1Test.kt +++ b/app/src/test/java/com/keylesspalace/tusky/FilterV1Test.kt @@ -18,7 +18,9 @@ package com.keylesspalace.tusky import androidx.test.ext.junit.runners.AndroidJUnit4 +import at.connyduck.calladapter.networkresult.NetworkResult import com.keylesspalace.tusky.components.filters.EditFilterActivity +import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.entity.FilterV1 @@ -26,14 +28,20 @@ import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.entity.PollOption import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.network.FilterModel +import com.keylesspalace.tusky.network.MastodonApi import java.time.Instant import java.util.Date +import kotlinx.coroutines.runBlocking +import okhttp3.ResponseBody.Companion.toResponseBody import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Test import org.junit.runner.RunWith +import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock import org.robolectric.annotation.Config +import retrofit2.HttpException +import retrofit2.Response @Config(sdk = [28]) @RunWith(AndroidJUnit4::class) @@ -43,12 +51,11 @@ class FilterV1Test { @Before fun setup() { - filterModel = FilterModel() val filters = listOf( FilterV1( id = "123", phrase = "badWord", - context = listOf(FilterV1.HOME), + context = listOf(Filter.Kind.HOME.kind), expiresAt = null, irreversible = false, wholeWord = false @@ -56,7 +63,7 @@ class FilterV1Test { FilterV1( id = "123", phrase = "badWholeWord", - context = listOf(FilterV1.HOME, FilterV1.PUBLIC), + context = listOf(Filter.Kind.HOME.kind, Filter.Kind.PUBLIC.kind), expiresAt = null, irreversible = false, wholeWord = true @@ -64,7 +71,7 @@ class FilterV1Test { FilterV1( id = "123", phrase = "@twitter.com", - context = listOf(FilterV1.HOME), + context = listOf(Filter.Kind.HOME.kind), expiresAt = null, irreversible = false, wholeWord = true @@ -72,7 +79,7 @@ class FilterV1Test { FilterV1( id = "123", phrase = "#hashtag", - context = listOf(FilterV1.HOME), + context = listOf(Filter.Kind.HOME.kind), expiresAt = null, irreversible = false, wholeWord = true @@ -80,7 +87,7 @@ class FilterV1Test { FilterV1( id = "123", phrase = "expired", - context = listOf(FilterV1.HOME), + context = listOf(Filter.Kind.HOME.kind), expiresAt = Date.from(Instant.now().minusSeconds(10)), irreversible = false, wholeWord = true @@ -88,7 +95,7 @@ class FilterV1Test { FilterV1( id = "123", phrase = "unexpired", - context = listOf(FilterV1.HOME), + context = listOf(Filter.Kind.HOME.kind), expiresAt = Date.from(Instant.now().plusSeconds(3600)), irreversible = false, wholeWord = true @@ -96,14 +103,27 @@ class FilterV1Test { FilterV1( id = "123", phrase = "href", - context = listOf(FilterV1.HOME), + context = listOf(Filter.Kind.HOME.kind), expiresAt = null, irreversible = false, wholeWord = false ) ) - filterModel.initWithFilters(filters) + val api: MastodonApi = mock { + onBlocking { getFiltersV1() } doReturn NetworkResult.success(filters) + onBlocking { getFilters() } doReturn NetworkResult.failure( + HttpException(Response.error(404, "".toResponseBody())) + ) + } + val instanceInfoRepo: InstanceInfoRepository = mock { + onBlocking { isFilterV2Supported() } doReturn false + } + + filterModel = FilterModel(instanceInfoRepo, api) + runBlocking { + filterModel.init(Filter.Kind.HOME) + } } @Test diff --git a/app/src/test/java/com/keylesspalace/tusky/components/viewthread/ViewThreadViewModelTest.kt b/app/src/test/java/com/keylesspalace/tusky/components/viewthread/ViewThreadViewModelTest.kt index 685468628..64266fc77 100644 --- a/app/src/test/java/com/keylesspalace/tusky/components/viewthread/ViewThreadViewModelTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/components/viewthread/ViewThreadViewModelTest.kt @@ -8,6 +8,7 @@ import androidx.test.platform.app.InstrumentationRegistry import at.connyduck.calladapter.networkresult.NetworkResult import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.StatusChangedEvent +import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository import com.keylesspalace.tusky.components.timeline.fakeStatus import com.keylesspalace.tusky.components.timeline.fakeStatusViewData import com.keylesspalace.tusky.db.AccountManager @@ -81,8 +82,11 @@ class ViewThreadViewModelTest { api = mock { onBlocking { getFilters() } doReturn NetworkResult.success(emptyList()) } + val instanceInfoRepo: InstanceInfoRepository = mock { + onBlocking { isFilterV2Supported() } doReturn false + } eventHub = EventHub() - val filterModel = FilterModel() + val filterModel = FilterModel(instanceInfoRepo, api) val timelineCases = TimelineCases(api, eventHub) val accountManager: AccountManager = mock { on { activeAccount } doReturn AccountEntity(