diff --git a/app/schemas/com.keylesspalace.tusky.db.AppDatabase/66.json b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/66.json new file mode 100644 index 0000000000..ae04564c38 --- /dev/null +++ b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/66.json @@ -0,0 +1,1346 @@ +{ + "formatVersion": 1, + "database": { + "version": 66, + "identityHash": "a17a9b196abd59db5104b46ea19c4d10", + "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, `profileHeaderUrl` 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, `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 DEFAULT 0, `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": "profileHeaderUrl", + "columnName": "profileHeaderUrl", + "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": "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, + "defaultValue": "0" + }, + { + "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, 'a17a9b196abd59db5104b46ea19c4d10')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/BaseActivity.java b/app/src/main/java/com/keylesspalace/tusky/BaseActivity.java deleted file mode 100644 index 2231e7ceaa..0000000000 --- a/app/src/main/java/com/keylesspalace/tusky/BaseActivity.java +++ /dev/null @@ -1,294 +0,0 @@ -/* Copyright 2017 Andrew Dawson - * - * This file is a part of Tusky. - * - * This program is free software; you can redistribute it and/or modify it under the terms of the - * GNU General Public License as published by the Free Software Foundation; either version 3 of the - * License, or (at your option) any later version. - * - * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even - * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General - * Public License for more details. - * - * You should have received a copy of the GNU General Public License along with Tusky; if not, - * see . */ - -package com.keylesspalace.tusky; - -import android.app.ActivityManager; -import android.content.Context; -import android.content.Intent; -import android.content.SharedPreferences; -import android.content.res.Configuration; -import android.graphics.Bitmap; -import android.graphics.BitmapFactory; -import android.graphics.Color; -import android.os.Bundle; -import android.util.Log; -import android.view.MenuItem; -import android.view.View; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.StringRes; -import androidx.appcompat.app.AppCompatActivity; -import androidx.lifecycle.ViewModelProvider; - -import com.google.android.material.color.MaterialColors; -import com.google.android.material.dialog.MaterialAlertDialogBuilder; -import com.google.android.material.snackbar.Snackbar; -import com.keylesspalace.tusky.adapter.AccountSelectionAdapter; -import com.keylesspalace.tusky.components.login.LoginActivity; -import com.keylesspalace.tusky.db.entity.AccountEntity; -import com.keylesspalace.tusky.db.AccountManager; -import com.keylesspalace.tusky.di.PreferencesEntryPoint; -import com.keylesspalace.tusky.interfaces.AccountSelectionListener; -import com.keylesspalace.tusky.settings.AppTheme; -import com.keylesspalace.tusky.settings.PrefKeys; -import com.keylesspalace.tusky.util.ActivityConstants; -import com.keylesspalace.tusky.util.ActivityExtensions; -import com.keylesspalace.tusky.util.ThemeUtils; - -import java.util.List; - -import javax.inject.Inject; - -import static com.keylesspalace.tusky.settings.PrefKeys.APP_THEME; - -import dagger.hilt.EntryPoints; - -/** - * All activities inheriting from BaseActivity must be annotated with @AndroidEntryPoint - */ -public abstract class BaseActivity extends AppCompatActivity { - - public static final String OPEN_WITH_SLIDE_IN = "OPEN_WITH_SLIDE_IN"; - - private static final String TAG = "BaseActivity"; - - @Inject - @NonNull - public AccountManager accountManager; - - @Inject - @NonNull - public SharedPreferences preferences; - - /** - * Allows overriding the default ViewModelProvider.Factory for testing purposes. - */ - @Nullable - public ViewModelProvider.Factory viewModelProviderFactory = null; - - @Override - protected void onCreate(@Nullable Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - - if (activityTransitionWasRequested()) { - ActivityExtensions.overrideActivityTransitionCompat( - this, - ActivityConstants.OVERRIDE_TRANSITION_OPEN, - R.anim.activity_open_enter, - R.anim.activity_open_exit - ); - ActivityExtensions.overrideActivityTransitionCompat( - this, - ActivityConstants.OVERRIDE_TRANSITION_CLOSE, - R.anim.activity_close_enter, - R.anim.activity_close_exit - ); - } - - /* There isn't presently a way to globally change the theme of a whole application at - * runtime, just individual activities. So, each activity has to set its theme before any - * views are created. */ - String theme = preferences.getString(APP_THEME, AppTheme.DEFAULT.getValue()); - Log.d("activeTheme", theme); - if (ThemeUtils.isBlack(getResources().getConfiguration(), theme)) { - setTheme(R.style.TuskyBlackTheme); - } else if (this instanceof MainActivity) { - // Replace the SplashTheme of MainActivity - setTheme(R.style.TuskyTheme); - } - - /* set the taskdescription programmatically, the theme would turn it blue */ - String appName = getString(R.string.app_name); - Bitmap appIcon = BitmapFactory.decodeResource(getResources(), R.mipmap.ic_launcher); - int recentsBackgroundColor = MaterialColors.getColor(this, com.google.android.material.R.attr.colorSurface, Color.BLACK); - - setTaskDescription(new ActivityManager.TaskDescription(appName, appIcon, recentsBackgroundColor)); - - int style = textStyle(preferences.getString(PrefKeys.STATUS_TEXT_SIZE, "medium")); - getTheme().applyStyle(style, true); - - if(requiresLogin()) { - redirectIfNotLoggedIn(); - } - } - - private boolean activityTransitionWasRequested() { - return getIntent().getBooleanExtra(OPEN_WITH_SLIDE_IN, false); - } - - @Override - protected void attachBaseContext(Context newBase) { - // injected preferences not yet available at this point of the lifecycle - SharedPreferences preferences = EntryPoints.get(newBase.getApplicationContext(), PreferencesEntryPoint.class).preferences(); - - // Scale text in the UI from PrefKeys.UI_TEXT_SCALE_RATIO - float uiScaleRatio = preferences.getFloat(PrefKeys.UI_TEXT_SCALE_RATIO, 100F); - - Configuration configuration = newBase.getResources().getConfiguration(); - - // Adjust `fontScale` in the configuration. - // - // You can't repeatedly adjust the `fontScale` in `newBase` because that will contain the - // result of previous adjustments. E.g., going from 100% to 80% to 100% does not return - // you to the original 100%, it leaves it at 80%. - // - // Instead, calculate the new scale from the application context. This is unaffected by - // changes to the base context. It does contain contain any changes to the font scale from - // "Settings > Display > Font size" in the device settings, so scaling performed here - // is in addition to any scaling in the device settings. - Configuration appConfiguration = newBase.getApplicationContext().getResources().getConfiguration(); - - // This only adjusts the fonts, anything measured in `dp` is unaffected by this. - // You can try to adjust `densityDpi` as shown in the commented out code below. This - // works, to a point. However, dialogs do not react well to this. Beyond a certain - // scale (~ 120%) the right hand edge of the dialog will clip off the right of the - // screen. - // - // So for now, just adjust the font scale - // - // val displayMetrics = appContext.resources.displayMetrics - // configuration.densityDpi = ((displayMetrics.densityDpi * uiScaleRatio).toInt()) - configuration.fontScale = appConfiguration.fontScale * uiScaleRatio / 100F; - - Context fontScaleContext = newBase.createConfigurationContext(configuration); - - super.attachBaseContext(fontScaleContext); - } - - @NonNull - @Override - public ViewModelProvider.Factory getDefaultViewModelProviderFactory() { - final ViewModelProvider.Factory factory = viewModelProviderFactory; - return (factory != null) ? factory : super.getDefaultViewModelProviderFactory(); - } - - protected boolean requiresLogin() { - return true; - } - - private static int textStyle(String name) { - int style; - switch (name) { - case "smallest": - style = R.style.TextSizeSmallest; - break; - case "small": - style = R.style.TextSizeSmall; - break; - case "medium": - default: - style = R.style.TextSizeMedium; - break; - case "large": - style = R.style.TextSizeLarge; - break; - case "largest": - style = R.style.TextSizeLargest; - break; - } - return style; - } - - @Override - public boolean onOptionsItemSelected(@NonNull MenuItem item) { - if (item.getItemId() == android.R.id.home) { - getOnBackPressedDispatcher().onBackPressed(); - return true; - } - return super.onOptionsItemSelected(item); - } - - protected void redirectIfNotLoggedIn() { - AccountEntity account = accountManager.getActiveAccount(); - if (account == null) { - Intent intent = LoginActivity.getIntent(this, LoginActivity.MODE_DEFAULT); - intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_NEW_TASK); - startActivity(intent); - finish(); - } - } - - protected void showErrorDialog(@Nullable View anyView, @StringRes int descriptionId, @StringRes int actionId, @Nullable View.OnClickListener listener) { - if (anyView != null) { - Snackbar bar = Snackbar.make(anyView, getString(descriptionId), Snackbar.LENGTH_SHORT); - bar.setAction(actionId, listener); - bar.show(); - } - } - - public void showAccountChooserDialog(@Nullable CharSequence dialogTitle, boolean showActiveAccount, @NonNull AccountSelectionListener listener) { - List accounts = accountManager.getAllAccountsOrderedByActive(); - AccountEntity activeAccount = accountManager.getActiveAccount(); - - switch(accounts.size()) { - case 1: - listener.onAccountSelected(activeAccount); - return; - case 2: - if (!showActiveAccount) { - for (AccountEntity account : accounts) { - if (activeAccount != account) { - listener.onAccountSelected(account); - return; - } - } - } - break; - } - - if (!showActiveAccount && activeAccount != null) { - accounts.remove(activeAccount); - } - AccountSelectionAdapter adapter = new AccountSelectionAdapter( - this, - preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false), - preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) - ); - adapter.addAll(accounts); - - new MaterialAlertDialogBuilder(this) - .setTitle(dialogTitle) - .setAdapter(adapter, (dialogInterface, index) -> listener.onAccountSelected(accounts.get(index))) - .show(); - } - - public @Nullable String getOpenAsText() { - List accounts = accountManager.getAllAccountsOrderedByActive(); - switch (accounts.size()) { - case 0: - case 1: - return null; - case 2: - for (AccountEntity account : accounts) { - if (account != accountManager.getActiveAccount()) { - return String.format(getString(R.string.action_open_as), account.getFullName()); - } - } - return null; - default: - return String.format(getString(R.string.action_open_as), "…"); - } - } - - public void openAsAccount(@NonNull String url, @NonNull AccountEntity account) { - accountManager.setActiveAccount(account.getId()); - Intent intent = MainActivity.redirectIntent(this, account.getId(), url); - - startActivity(intent); - finish(); - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/BaseActivity.kt b/app/src/main/java/com/keylesspalace/tusky/BaseActivity.kt new file mode 100644 index 0000000000..70b158e0a6 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/BaseActivity.kt @@ -0,0 +1,259 @@ +/* Copyright 2024 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ +package com.keylesspalace.tusky + +import android.app.ActivityManager.TaskDescription +import android.content.Context +import android.content.DialogInterface +import android.content.Intent +import android.content.SharedPreferences +import android.graphics.BitmapFactory +import android.graphics.Color +import android.os.Bundle +import android.view.MenuItem +import androidx.annotation.StyleRes +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.ViewModelProvider.Factory +import androidx.lifecycle.lifecycleScope +import com.google.android.material.color.MaterialColors +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.keylesspalace.tusky.MainActivity.Companion.redirectIntent +import com.keylesspalace.tusky.adapter.AccountSelectionAdapter +import com.keylesspalace.tusky.components.login.LoginActivity +import com.keylesspalace.tusky.components.login.LoginActivity.Companion.getIntent +import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.db.entity.AccountEntity +import com.keylesspalace.tusky.di.PreferencesEntryPoint +import com.keylesspalace.tusky.interfaces.AccountSelectionListener +import com.keylesspalace.tusky.settings.AppTheme +import com.keylesspalace.tusky.settings.PrefKeys +import com.keylesspalace.tusky.util.ActivityConstants +import com.keylesspalace.tusky.util.isBlack +import com.keylesspalace.tusky.util.overrideActivityTransitionCompat +import dagger.hilt.EntryPoints +import javax.inject.Inject +import kotlinx.coroutines.launch + +/** + * All activities inheriting from BaseActivity must be annotated with @AndroidEntryPoint + */ +abstract class BaseActivity : AppCompatActivity() { + @Inject + lateinit var accountManager: AccountManager + + @Inject + lateinit var preferences: SharedPreferences + + /** + * Allows overriding the default ViewModelProvider.Factory for testing purposes. + */ + var viewModelProviderFactory: Factory? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + if (activityTransitionWasRequested()) { + overrideActivityTransitionCompat( + ActivityConstants.OVERRIDE_TRANSITION_OPEN, + R.anim.activity_open_enter, + R.anim.activity_open_exit + ) + overrideActivityTransitionCompat( + ActivityConstants.OVERRIDE_TRANSITION_CLOSE, + R.anim.activity_close_enter, + R.anim.activity_close_exit + ) + } + + /* There isn't presently a way to globally change the theme of a whole application at + * runtime, just individual activities. So, each activity has to set its theme before any + * views are created. */ + val theme = preferences.getString(PrefKeys.APP_THEME, AppTheme.DEFAULT.value) + if (isBlack(resources.configuration, theme)) { + setTheme(R.style.TuskyBlackTheme) + } else if (this is MainActivity) { + // Replace the SplashTheme of MainActivity + setTheme(R.style.TuskyTheme) + } + + /* set the taskdescription programmatically, the theme would turn it blue */ + val appName = getString(R.string.app_name) + val appIcon = BitmapFactory.decodeResource(resources, R.mipmap.ic_launcher) + val recentsBackgroundColor = MaterialColors.getColor( + this, + com.google.android.material.R.attr.colorSurface, + Color.BLACK + ) + + setTaskDescription(TaskDescription(appName, appIcon, recentsBackgroundColor)) + + val style = textStyle(preferences.getString(PrefKeys.STATUS_TEXT_SIZE, "medium")) + getTheme().applyStyle(style, true) + + if (requiresLogin()) { + redirectIfNotLoggedIn() + } + } + + private fun activityTransitionWasRequested(): Boolean { + return intent.getBooleanExtra(OPEN_WITH_SLIDE_IN, false) + } + + override fun attachBaseContext(newBase: Context) { + // injected preferences not yet available at this point of the lifecycle + val preferences = + EntryPoints.get(newBase.applicationContext, PreferencesEntryPoint::class.java) + .preferences() + + // Scale text in the UI from PrefKeys.UI_TEXT_SCALE_RATIO + val uiScaleRatio = preferences.getFloat(PrefKeys.UI_TEXT_SCALE_RATIO, 100f) + + val configuration = newBase.resources.configuration + + // Adjust `fontScale` in the configuration. + // + // You can't repeatedly adjust the `fontScale` in `newBase` because that will contain the + // result of previous adjustments. E.g., going from 100% to 80% to 100% does not return + // you to the original 100%, it leaves it at 80%. + // + // Instead, calculate the new scale from the application context. This is unaffected by + // changes to the base context. It does contain contain any changes to the font scale from + // "Settings > Display > Font size" in the device settings, so scaling performed here + // is in addition to any scaling in the device settings. + val appConfiguration = newBase.applicationContext.resources.configuration + + // This only adjusts the fonts, anything measured in `dp` is unaffected by this. + // You can try to adjust `densityDpi` as shown in the commented out code below. This + // works, to a point. However, dialogs do not react well to this. Beyond a certain + // scale (~ 120%) the right hand edge of the dialog will clip off the right of the + // screen. + // + // So for now, just adjust the font scale + // + // val displayMetrics = appContext.resources.displayMetrics + // configuration.densityDpi = ((displayMetrics.densityDpi * uiScaleRatio).toInt()) + configuration.fontScale = appConfiguration.fontScale * uiScaleRatio / 100f + + val fontScaleContext = newBase.createConfigurationContext(configuration) + + super.attachBaseContext(fontScaleContext) + } + + override val defaultViewModelProviderFactory: Factory + get() = viewModelProviderFactory ?: super.defaultViewModelProviderFactory + + protected open fun requiresLogin(): Boolean = true + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + if (item.itemId == android.R.id.home) { + onBackPressedDispatcher.onBackPressed() + return true + } + return super.onOptionsItemSelected(item) + } + + private fun redirectIfNotLoggedIn() { + val currentAccounts = accountManager.accounts + + if (currentAccounts.isEmpty()) { + val intent = getIntent(this@BaseActivity, LoginActivity.MODE_DEFAULT) + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK) + startActivity(intent) + finish() + } + } + + fun showAccountChooserDialog( + dialogTitle: CharSequence?, + showActiveAccount: Boolean, + listener: AccountSelectionListener + ) { + val accounts = accountManager.accounts.toMutableList() + val activeAccount = accountManager.activeAccount + + when (accounts.size) { + 1 -> { + listener.onAccountSelected(activeAccount!!) + return + } + 2 -> if (!showActiveAccount) { + for (account in accounts) { + if (activeAccount !== account) { + listener.onAccountSelected(account) + return + } + } + } + } + if (!showActiveAccount && activeAccount != null) { + accounts.remove(activeAccount) + } + val adapter = AccountSelectionAdapter( + this, + preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false), + preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) + ) + adapter.addAll(accounts) + + MaterialAlertDialogBuilder(this) + .setTitle(dialogTitle) + .setAdapter(adapter) { _: DialogInterface?, index: Int -> + listener.onAccountSelected(accounts[index]) + } + .show() + } + + val openAsText: String? + get() { + val accounts = accountManager.accounts + when (accounts.size) { + 0, 1 -> return null + 2 -> { + for (account in accounts) { + if (account !== accountManager.activeAccount) { + return getString(R.string.action_open_as, account.fullName) + } + } + return null + } + + else -> return getString(R.string.action_open_as, "…") + } + } + + fun openAsAccount(url: String, account: AccountEntity) { + lifecycleScope.launch { + accountManager.setActiveAccount(account.id) + val intent = redirectIntent(this@BaseActivity, account.id, url) + + startActivity(intent) + finish() + } + } + + companion object { + const val OPEN_WITH_SLIDE_IN = "OPEN_WITH_SLIDE_IN" + + @StyleRes + private fun textStyle(name: String?): Int = when (name) { + "smallest" -> R.style.TextSizeSmallest + "small" -> R.style.TextSizeSmall + "medium" -> R.style.TextSizeMedium + "large" -> R.style.TextSizeLarge + "largest" -> R.style.TextSizeLargest + else -> R.style.TextSizeMedium + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt b/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt index f63da1551d..6464100bbf 100644 --- a/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt @@ -40,6 +40,7 @@ import android.view.MenuItem.SHOW_AS_ACTION_NEVER import android.view.View import android.widget.ImageView import androidx.activity.OnBackPressedCallback +import androidx.activity.viewModels import androidx.appcompat.app.AlertDialog import androidx.appcompat.content.res.AppCompatResources import androidx.coordinatorlayout.widget.CoordinatorLayout @@ -52,7 +53,6 @@ import androidx.core.view.forEach import androidx.core.view.isVisible import androidx.lifecycle.lifecycleScope import androidx.viewpager2.widget.MarginPageTransformer -import at.connyduck.calladapter.networkresult.fold import com.bumptech.glide.Glide import com.bumptech.glide.load.resource.bitmap.RoundedCorners import com.bumptech.glide.request.target.CustomTarget @@ -64,14 +64,8 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.tabs.TabLayout import com.google.android.material.tabs.TabLayout.OnTabSelectedListener import com.google.android.material.tabs.TabLayoutMediator -import com.keylesspalace.tusky.appstore.AnnouncementReadEvent import com.keylesspalace.tusky.appstore.CacheUpdater -import com.keylesspalace.tusky.appstore.ConversationsLoadingEvent import com.keylesspalace.tusky.appstore.EventHub -import com.keylesspalace.tusky.appstore.MainTabsChangedEvent -import com.keylesspalace.tusky.appstore.NewNotificationsEvent -import com.keylesspalace.tusky.appstore.NotificationsLoadingEvent -import com.keylesspalace.tusky.appstore.ProfileEditedEvent import com.keylesspalace.tusky.components.account.AccountActivity import com.keylesspalace.tusky.components.accountlist.AccountListActivity import com.keylesspalace.tusky.components.announcements.AnnouncementsActivity @@ -82,29 +76,19 @@ import com.keylesspalace.tusky.components.login.LoginActivity import com.keylesspalace.tusky.components.preference.PreferencesActivity import com.keylesspalace.tusky.components.scheduled.ScheduledStatusActivity import com.keylesspalace.tusky.components.search.SearchActivity -import com.keylesspalace.tusky.components.systemnotifications.NotificationHelper -import com.keylesspalace.tusky.components.systemnotifications.disableAllNotifications -import com.keylesspalace.tusky.components.systemnotifications.enablePushNotificationsWithFallback -import com.keylesspalace.tusky.components.systemnotifications.showMigrationNoticeIfNecessary import com.keylesspalace.tusky.components.trending.TrendingActivity import com.keylesspalace.tusky.databinding.ActivityMainBinding import com.keylesspalace.tusky.db.DraftsAlert import com.keylesspalace.tusky.db.entity.AccountEntity -import com.keylesspalace.tusky.di.ApplicationScope -import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.Notification -import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.interfaces.AccountSelectionListener import com.keylesspalace.tusky.interfaces.ActionButtonActivity -import com.keylesspalace.tusky.interfaces.FabFragment import com.keylesspalace.tusky.interfaces.ReselectableFragment import com.keylesspalace.tusky.pager.MainPagerAdapter import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.usecase.DeveloperToolsUseCase import com.keylesspalace.tusky.usecase.LogoutUsecase import com.keylesspalace.tusky.util.ActivityConstants -import com.keylesspalace.tusky.util.ShareShortcutHelper -import com.keylesspalace.tusky.util.deleteStaleCachedMedia import com.keylesspalace.tusky.util.emojify import com.keylesspalace.tusky.util.getDimension import com.keylesspalace.tusky.util.getParcelableExtraCompat @@ -145,8 +129,6 @@ import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.migration.OptionalInject import de.c1710.filemojicompat_ui.helpers.EMOJI_PREFERENCE import javax.inject.Inject -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @OptionalInject @@ -168,12 +150,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { @Inject lateinit var developerToolsUseCase: DeveloperToolsUseCase - @Inject - lateinit var shareShortcutHelper: ShareShortcutHelper - - @Inject - @ApplicationScope - lateinit var externalScope: CoroutineScope + private val viewModel: MainViewModel by viewModels() private val binding by viewBinding(ActivityMainBinding::inflate) @@ -183,8 +160,6 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { private var onTabSelectedListener: OnTabSelectedListener? = null - private var unreadAnnouncementsCount = 0 - // We need to know if the emoji pack has been changed private var selectedEmojiPack: String? = null @@ -231,6 +206,8 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { ) } + selectedEmojiPack = preferences.getString(EMOJI_PREFERENCE, "") + var showNotificationTab = false // check for savedInstanceState in order to not handle intent events more than once @@ -266,8 +243,6 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { binding.mainToolbar.show() } - loadDrawerAvatar(activeAccount.profilePictureUrl, true) - addMenuProvider(this) binding.viewPager.reduceSwipeSensitivity() @@ -283,88 +258,53 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { ) ) - /* Fetch user info while we're doing other things. This has to be done after setting up the - * drawer, though, because its callback touches the header in the drawer. */ - fetchUserInfo() + lifecycleScope.launch { + viewModel.accounts.collect(::updateProfiles) + } - fetchAnnouncements() + lifecycleScope.launch { + viewModel.unreadAnnouncementsCount.collect(::updateAnnouncementsBadge) + } // Initialise the tab adapter and set to viewpager. Fragments appear to be leaked if the // adapter changes over the life of the viewPager (the adapter, not its contents), so set // the initial list of tabs to empty, and set the full list later in setupTabs(). See // https://github.com/tuskyapp/Tusky/issues/3251 for details. - tabAdapter = MainPagerAdapter(emptyList(), this) + tabAdapter = MainPagerAdapter(emptyList(), this@MainActivity) binding.viewPager.adapter = tabAdapter - setupTabs(showNotificationTab) - lifecycleScope.launch { - eventHub.events.collect { event -> - when (event) { - is ProfileEditedEvent -> onFetchUserInfoSuccess(event.newProfileData) - is MainTabsChangedEvent -> { - refreshMainDrawerItems( - addSearchButton = hideTopToolbar, - addTrendingTagsButton = !event.newTabs.hasTab(TRENDING_TAGS), - addTrendingStatusesButton = !event.newTabs.hasTab(TRENDING_STATUSES) - ) - - setupTabs(false) - } - is AnnouncementReadEvent -> { - unreadAnnouncementsCount-- - updateAnnouncementsBadge() - } - is NewNotificationsEvent -> { - directMessageTab?.let { - if (event.accountId == activeAccount.accountId) { - val hasDirectMessageNotification = - event.notifications.any { - it.type == Notification.Type.MENTION && it.status?.visibility == Status.Visibility.DIRECT - } - - if (hasDirectMessageNotification) { - showDirectMessageBadge(true) - } - } - } - } - is NotificationsLoadingEvent -> { - if (event.accountId == activeAccount.accountId) { - showDirectMessageBadge(false) - } - } - is ConversationsLoadingEvent -> { - if (event.accountId == activeAccount.accountId) { - showDirectMessageBadge(false) - } - } - } + viewModel.tabs.collect(::setupTabs) + } + if (showNotificationTab) { + val tabs = activeAccount.tabPreferences + val position = tabs.indexOfFirst { it.id == NOTIFICATIONS } + if (position != -1) { + binding.viewPager.setCurrentItem(position, false) } } - externalScope.launch(Dispatchers.IO) { - // Flush old media that was cached for sharing - deleteStaleCachedMedia(applicationContext.getExternalFilesDir("Tusky")) + lifecycleScope.launch { + viewModel.showDirectMessagesBadge.collect { showBadge -> + updateDirectMessageBadge(showBadge) + } } - selectedEmojiPack = preferences.getString(EMOJI_PREFERENCE, "") - - onBackPressedDispatcher.addCallback(this, onBackPressedCallback) + onBackPressedDispatcher.addCallback(this@MainActivity, onBackPressedCallback) if ( Build.VERSION.SDK_INT >= 33 && - ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED + ContextCompat.checkSelfPermission(this@MainActivity, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED ) { ActivityCompat.requestPermissions( - this, + this@MainActivity, arrayOf(Manifest.permission.POST_NOTIFICATIONS), 1 ) } // "Post failed" dialog should display in this activity - draftsAlert.observeInContext(this, true) + draftsAlert.observeInContext(this@MainActivity, true) } override fun onNewIntent(intent: Intent) { @@ -460,18 +400,8 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { return false } - private fun showDirectMessageBadge(showBadge: Boolean) { - directMessageTab?.let { tab -> - tab.badge?.isVisible = showBadge - - // TODO a bit cumbersome (also for resetting) - lifecycleScope.launch(Dispatchers.IO) { - if (activeAccount.hasDirectMessageBadge != showBadge) { - activeAccount.hasDirectMessageBadge = showBadge - accountManager.saveAccount(activeAccount) - } - } - } + private fun updateDirectMessageBadge(showBadge: Boolean) { + directMessageTab?.badge?.isVisible = showBadge } override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { @@ -587,6 +517,10 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { startActivity(composeIntent) } + override fun finish() { + super.finish() + } + private fun setupDrawer( savedInstanceState: Bundle?, addSearchButton: Boolean, @@ -824,14 +758,14 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { isEnabled = true iconicsIcon = GoogleMaterial.Icon.gmd_developer_mode onClick = { - buildDeveloperToolsDialog().show() + showDeveloperToolsDialog() } } ) } } - private fun buildDeveloperToolsDialog(): AlertDialog { + private fun showDeveloperToolsDialog(): AlertDialog { return MaterialAlertDialogBuilder(this) .setTitle("Developer Tools") .setItems( @@ -849,14 +783,15 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { } } } - .create() + .show() } override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(binding.mainDrawer.saveInstanceState(outState)) } - private fun setupTabs(selectNotificationTab: Boolean) { + private fun setupTabs(tabs: List) { + println("setup tabs") val activeTabLayout = if (preferences.getString(PrefKeys.MAIN_NAV_POSITION, "top") == "bottom") { val actionBarSize = getDimension(this, androidx.appcompat.R.attr.actionBarSize) val fabMargin = resources.getDimensionPixelSize(R.dimen.fabMargin) @@ -873,8 +808,6 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { // Save the previous tab so it can be restored later val previousTab = tabAdapter.tabs.getOrNull(binding.viewPager.currentItem) - val tabs = activeAccount.tabPreferences - // Detach any existing mediator before changing tab contents and attaching a new mediator tabLayoutMediator?.detach() @@ -897,12 +830,13 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { directMessageTab = tab } }.also { it.attach() } + updateDirectMessageBadge(viewModel.showDirectMessagesBadge.value) // Selected tab is either // - Notification tab (if appropriate) // - The previously selected tab (if it hasn't been removed) // - Left-most tab - val position = if (selectNotificationTab) { + val position = if (false) { tabs.indexOfFirst { it.id == NOTIFICATIONS } } else { previousTab?.let { tabs.indexOfFirst { it == previousTab } } @@ -925,15 +859,8 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { binding.mainToolbar.title = tab.contentDescription - refreshComposeButtonState(tabAdapter, tab.position) - if (tab == directMessageTab) { - tab.badge?.isVisible = false - - if (activeAccount.hasDirectMessageBadge) { - activeAccount.hasDirectMessageBadge = false - accountManager.saveAccount(activeAccount) - } + viewModel.dismissDirectMessagesBadge() } } @@ -942,10 +869,8 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { override fun onTabReselected(tab: TabLayout.Tab) { val fragment = tabAdapter.getFragment(tab.position) if (fragment is ReselectableFragment) { - (fragment as ReselectableFragment).onReselect() + fragment.onReselect() } - - refreshComposeButtonState(tabAdapter, tab.position) } }.also { activeTabLayout.addOnTabSelectedListener(it) @@ -959,22 +884,6 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { ) as? ReselectableFragment )?.onReselect() } - - updateProfiles() - } - - private fun refreshComposeButtonState(adapter: MainPagerAdapter, tabPosition: Int) { - adapter.getFragment(tabPosition)?.also { fragment -> - if (fragment is FabFragment) { - if (fragment.isFabVisible()) { - binding.composeButton.show() - } else { - binding.composeButton.hide() - } - } else { - binding.composeButton.show() - } - } } private fun handleProfileClick(profile: IProfile, current: Boolean): Boolean { @@ -999,18 +908,18 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { private fun changeAccount( newSelectedId: Long, forward: Intent?, - ) { + ) = lifecycleScope.launch { cacheUpdater.stop() accountManager.setActiveAccount(newSelectedId) - val intent = Intent(this, MainActivity::class.java) + val intent = Intent(this@MainActivity, MainActivity::class.java) if (forward != null) { intent.type = forward.type intent.action = forward.action intent.putExtras(forward) } intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK - finish() startActivity(intent) + finish() } private fun logout() { @@ -1039,49 +948,8 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { .show() } - private fun fetchUserInfo() = lifecycleScope.launch { - mastodonApi.accountVerifyCredentials().fold( - { userInfo -> - onFetchUserInfoSuccess(userInfo) - }, - { throwable -> - Log.e(TAG, "Failed to fetch user info. " + throwable.message) - } - ) - } - - private fun onFetchUserInfoSuccess(me: Account) { - Glide.with(header.accountHeaderBackground) - .asBitmap() - .load(me.header) - .into(header.accountHeaderBackground) - - loadDrawerAvatar(me.avatar, false) - - accountManager.updateAccount(activeAccount, me) - NotificationHelper.createNotificationChannelsForAccount(activeAccount, this) - - // Setup push notifications - showMigrationNoticeIfNecessary( - this, - binding.mainCoordinatorLayout, - binding.composeButton, - accountManager - ) - if (NotificationHelper.areNotificationsEnabled(this, accountManager)) { - lifecycleScope.launch { - enablePushNotificationsWithFallback(this@MainActivity, mastodonApi, accountManager) - } - } else { - disableAllNotifications(this, accountManager) - } - - updateProfiles() - shareShortcutHelper.updateShortcuts() - } - @SuppressLint("CheckResult") - private fun loadDrawerAvatar(avatarUrl: String, showPlaceholder: Boolean) { + private fun loadDrawerAvatar(avatarUrl: String, showPlaceholder: Boolean = true) { val hideTopToolbar = preferences.getBoolean(PrefKeys.HIDE_TOP_TOOLBAR, false) val animateAvatars = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false) @@ -1167,22 +1035,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { } } - private fun fetchAnnouncements() { - lifecycleScope.launch { - mastodonApi.announcements() - .fold( - { announcements -> - unreadAnnouncementsCount = announcements.count { !it.read } - updateAnnouncementsBadge() - }, - { throwable -> - Log.w(TAG, "Failed to fetch announcements.", throwable) - } - ) - } - } - - private fun updateAnnouncementsBadge() { + private fun updateAnnouncementsBadge(unreadAnnouncementsCount: Int) { binding.mainDrawer.updateBadge( DRAWER_ITEM_ANNOUNCEMENTS, StringHolder( @@ -1191,12 +1044,24 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { ) } - private fun updateProfiles() { + private fun updateProfiles(accounts: List) { + if (accounts.isEmpty()) { + return + } + val activeProfile = accounts.first() + + loadDrawerAvatar(activeProfile.profilePictureUrl) + + Glide.with(header.accountHeaderBackground) + .asBitmap() + .load(activeProfile.profileHeaderUrl) + .into(header.accountHeaderBackground) + val animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) val profiles: MutableList = - accountManager.getAllAccountsOrderedByActive().map { acc -> + accounts.map { acc -> ProfileDrawerItem().apply { - isSelected = acc.isActive + isSelected = acc == activeProfile nameText = acc.displayName.emojify(acc.emojis, header, animateEmojis) iconUrl = acc.profilePictureUrl isNameShown = true @@ -1214,9 +1079,9 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { } header.clear() header.profiles = profiles - header.setActiveProfile(activeAccount.id) + header.setActiveProfile(activeProfile.id) binding.mainToolbar.subtitle = if (accountManager.shouldDisplaySelfUsername()) { - activeAccount.fullName + activeProfile.fullName } else { null } diff --git a/app/src/main/java/com/keylesspalace/tusky/MainViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/MainViewModel.kt new file mode 100644 index 0000000000..69b0ecf1d7 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/MainViewModel.kt @@ -0,0 +1,198 @@ +/* Copyright 2024 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + +package com.keylesspalace.tusky + +import android.content.Context +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import at.connyduck.calladapter.networkresult.fold +import com.keylesspalace.tusky.appstore.AnnouncementReadEvent +import com.keylesspalace.tusky.appstore.ConversationsLoadingEvent +import com.keylesspalace.tusky.appstore.EventHub +import com.keylesspalace.tusky.appstore.NewNotificationsEvent +import com.keylesspalace.tusky.appstore.NotificationsLoadingEvent +import com.keylesspalace.tusky.components.systemnotifications.NotificationHelper +import com.keylesspalace.tusky.components.systemnotifications.disableAllNotifications +import com.keylesspalace.tusky.components.systemnotifications.enablePushNotificationsWithFallback +import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.entity.Emoji +import com.keylesspalace.tusky.entity.Notification +import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.util.ShareShortcutHelper +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +@HiltViewModel +class MainViewModel @Inject constructor( + @ApplicationContext private val context: Context, + private val api: MastodonApi, + private val eventHub: EventHub, + private val accountManager: AccountManager, + private val shareShortcutHelper: ShareShortcutHelper +) : ViewModel() { + + private val activeAccount = accountManager.activeAccount + + val accounts: StateFlow> = accountManager.accountsFlow + .map { accounts -> + accounts.map { account -> + AccountViewData( + id = account.id, + isActive = account.isActive, + domain = account.domain, + accountId = account.accountId, + username = account.username, + displayName = account.displayName, + profilePictureUrl = account.profilePictureUrl, + profileHeaderUrl = account.profileHeaderUrl, + emojis = account.emojis + ) + } + } + .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + + val tabs: Flow> = accountManager.accountsFlow + .mapNotNull { accounts -> + accounts.find { activeAccount?.id == it.id }?.tabPreferences + } + .distinctUntilChanged() + + private val _unreadAnnouncementsCount = MutableStateFlow(0) + val unreadAnnouncementsCount: StateFlow = _unreadAnnouncementsCount.asStateFlow() + + val showDirectMessagesBadge: StateFlow = accountManager.accountsFlow + .map { accounts -> + accounts.find { activeAccount?.id == it.id }?.hasDirectMessageBadge == true + } + .stateIn(viewModelScope, SharingStarted.Eagerly, false) + + init { + loadAccountData() + fetchAnnouncements() + collectEvents() + } + + private fun loadAccountData() { + viewModelScope.launch { + api.accountVerifyCredentials().fold( + { userInfo -> + accountManager.updateAccount(activeAccount!!, userInfo) + + shareShortcutHelper.updateShortcuts() + + NotificationHelper.createNotificationChannelsForAccount(activeAccount, context) + + if (NotificationHelper.areNotificationsEnabled(context, accountManager)) { + viewModelScope.launch { + enablePushNotificationsWithFallback(context, api, accountManager) + } + } else { + disableAllNotifications(context, accountManager) + } + }, + { throwable -> + Log.w(TAG, "Failed to fetch user info.", throwable) + } + ) + } + } + + private fun fetchAnnouncements() { + viewModelScope.launch { + api.announcements() + .fold( + { announcements -> + _unreadAnnouncementsCount.value = announcements.count { !it.read } + }, + { throwable -> + Log.w(TAG, "Failed to fetch announcements.", throwable) + } + ) + } + } + + private fun collectEvents() { + viewModelScope.launch { + eventHub.events.collect { event -> + when (event) { + is AnnouncementReadEvent -> { + _unreadAnnouncementsCount.value-- + } + is NewNotificationsEvent -> { + if (event.accountId == activeAccount?.accountId) { + val hasDirectMessageNotification = + event.notifications.any { + it.type == Notification.Type.MENTION && it.status?.visibility == Status.Visibility.DIRECT + } + + if (hasDirectMessageNotification) { + accountManager.updateAccount(activeAccount) { copy(hasDirectMessageBadge = true) } + } + } + } + is NotificationsLoadingEvent -> { + if (event.accountId == activeAccount?.accountId) { + accountManager.updateAccount(activeAccount) { copy(hasDirectMessageBadge = false) } + } + } + is ConversationsLoadingEvent -> { + if (event.accountId == activeAccount?.accountId) { + accountManager.updateAccount(activeAccount) { copy(hasDirectMessageBadge = false) } + } + } + } + } + } + } + + fun dismissDirectMessagesBadge() { + viewModelScope.launch { + accountManager.updateAccount(activeAccount!!) { copy(hasDirectMessageBadge = false) } + } + } + + companion object { + private const val TAG = "MainViewModel" + } +} + +data class AccountViewData( + val id: Long, + val isActive: Boolean, + val domain: String, + val accountId: String, + val username: String, + val displayName: String, + val profilePictureUrl: String, + val profileHeaderUrl: String, + val emojis: List +) { + val fullName: String + get() = "@$username@$domain" +} diff --git a/app/src/main/java/com/keylesspalace/tusky/TabData.kt b/app/src/main/java/com/keylesspalace/tusky/TabData.kt index caefa0dac2..9f23f74f88 100644 --- a/app/src/main/java/com/keylesspalace/tusky/TabData.kt +++ b/app/src/main/java/com/keylesspalace/tusky/TabData.kt @@ -60,7 +60,7 @@ data class TabData( override fun hashCode() = Objects.hash(id, arguments) } -fun List.hasTab(id: String): Boolean = this.find { it.id == id } != null +fun List.hasTab(id: String): Boolean = this.any { it.id == id } fun createTabDataFromId(id: String, arguments: List = emptyList()): TabData { return when (id) { diff --git a/app/src/main/java/com/keylesspalace/tusky/TabPreferenceActivity.kt b/app/src/main/java/com/keylesspalace/tusky/TabPreferenceActivity.kt index 2e212b8ab4..b244eb1976 100644 --- a/app/src/main/java/com/keylesspalace/tusky/TabPreferenceActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/TabPreferenceActivity.kt @@ -34,7 +34,6 @@ import com.google.android.material.transition.MaterialContainerTransform import com.keylesspalace.tusky.adapter.ItemInteractionListener import com.keylesspalace.tusky.adapter.TabAdapter import com.keylesspalace.tusky.appstore.EventHub -import com.keylesspalace.tusky.appstore.MainTabsChangedEvent import com.keylesspalace.tusky.components.account.list.ListSelectionFragment import com.keylesspalace.tusky.databinding.ActivityTabPreferenceBinding import com.keylesspalace.tusky.databinding.DialogAddHashtagBinding @@ -46,7 +45,6 @@ import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.visible import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @AndroidEntryPoint @@ -65,8 +63,6 @@ class TabPreferenceActivity : BaseActivity(), ItemInteractionListener, ListSelec private lateinit var touchHelper: ItemTouchHelper private lateinit var addTabAdapter: TabAdapter - private var tabsChanged = false - private val selectedItemElevation by unsafeLazy { resources.getDimension(R.dimen.selected_drag_item_elevation) } @@ -337,19 +333,8 @@ class TabPreferenceActivity : BaseActivity(), ItemInteractionListener, ListSelec private fun saveTabs() { accountManager.activeAccount?.let { - lifecycleScope.launch(Dispatchers.IO) { - it.tabPreferences = currentTabs - accountManager.saveAccount(it) - } - } - tabsChanged = true - } - - override fun onPause() { - super.onPause() - if (tabsChanged) { lifecycleScope.launch { - eventHub.dispatch(MainTabsChangedEvent(currentTabs)) + accountManager.updateAccount(it) { copy(tabPreferences = currentTabs) } } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/ViewMediaActivity.kt b/app/src/main/java/com/keylesspalace/tusky/ViewMediaActivity.kt index de142c62da..596689b071 100644 --- a/app/src/main/java/com/keylesspalace/tusky/ViewMediaActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/ViewMediaActivity.kt @@ -43,6 +43,7 @@ import androidx.lifecycle.lifecycleScope import androidx.viewpager2.adapter.FragmentStateAdapter import androidx.viewpager2.widget.ViewPager2 import com.bumptech.glide.Glide +import com.google.android.material.snackbar.Snackbar import com.keylesspalace.tusky.BuildConfig.APPLICATION_ID import com.keylesspalace.tusky.components.viewthread.ViewThreadActivity import com.keylesspalace.tusky.databinding.ActivityViewMediaBinding @@ -91,11 +92,9 @@ class ViewMediaActivity : if (isGranted) { downloadMedia() } else { - showErrorDialog( - binding.toolbar, - R.string.error_media_download_permission, - R.string.action_retry - ) { requestDownloadMedia() } + Snackbar.make(binding.toolbar, getString(R.string.error_media_download_permission), Snackbar.LENGTH_SHORT) + .setAction(R.string.action_retry) { requestDownloadMedia() } + .show() } } diff --git a/app/src/main/java/com/keylesspalace/tusky/appstore/Events.kt b/app/src/main/java/com/keylesspalace/tusky/appstore/Events.kt index c0a02a3da6..7bc123437d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/appstore/Events.kt +++ b/app/src/main/java/com/keylesspalace/tusky/appstore/Events.kt @@ -1,6 +1,5 @@ package com.keylesspalace.tusky.appstore -import com.keylesspalace.tusky.TabData import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.Notification import com.keylesspalace.tusky.entity.Poll @@ -15,7 +14,6 @@ data class StatusComposedEvent(val status: Status) : Event data class StatusScheduledEvent(val scheduledStatusId: String) : Event data class ProfileEditedEvent(val newProfileData: Account) : Event data class PreferenceChangedEvent(val preferenceKey: String) : Event -data class MainTabsChangedEvent(val newTabs: List) : Event data class PollVoteEvent(val statusId: String, val poll: Poll) : Event data class DomainMuteEvent(val instance: String) : Event data class AnnouncementReadEvent(val announcementId: String) : Event diff --git a/app/src/main/java/com/keylesspalace/tusky/components/login/LoginActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/login/LoginActivity.kt index 9d73f5eedd..3ddb0f706f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/login/LoginActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/login/LoginActivity.kt @@ -77,17 +77,12 @@ class LoginActivity : BaseActivity() { if (savedInstanceState == null && BuildConfig.CUSTOM_INSTANCE.isNotBlank() && - !isAdditionalLogin() && !isAccountMigration() + !isAdditionalLogin() ) { binding.domainEditText.setText(BuildConfig.CUSTOM_INSTANCE) binding.domainEditText.setSelection(BuildConfig.CUSTOM_INSTANCE.length) } - if (isAccountMigration()) { - binding.domainEditText.setText(accountManager.activeAccount!!.domain) - binding.domainEditText.isEnabled = false - } - if (BuildConfig.CUSTOM_LOGO_URL.isNotBlank()) { Glide.with(binding.loginLogo) .load(BuildConfig.CUSTOM_LOGO_URL) @@ -107,7 +102,7 @@ class LoginActivity : BaseActivity() { } setSupportActionBar(binding.toolbar) - supportActionBar?.setDisplayHomeAsUpEnabled(isAdditionalLogin() || isAccountMigration()) + supportActionBar?.setDisplayHomeAsUpEnabled(isAdditionalLogin()) supportActionBar?.setDisplayShowTitleEnabled(false) } @@ -314,10 +309,6 @@ class LoginActivity : BaseActivity() { return intent.getIntExtra(LOGIN_MODE, MODE_DEFAULT) == MODE_ADDITIONAL_LOGIN } - private fun isAccountMigration(): Boolean { - return intent.getIntExtra(LOGIN_MODE, MODE_DEFAULT) == MODE_MIGRATION - } - companion object { private const val TAG = "LoginActivity" // logging tag private const val OAUTH_SCOPES = "read write follow push" @@ -329,9 +320,6 @@ class LoginActivity : BaseActivity() { const val MODE_DEFAULT = 0 const val MODE_ADDITIONAL_LOGIN = 1 - // "Migration" is used to update the OAuth scope granted to the client - const val MODE_MIGRATION = 2 - @JvmStatic fun getIntent(context: Context, mode: Int): Intent { val loginIntent = Intent(context, LoginActivity::class.java) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt index 168b9cf566..c9f4ae8fb1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt @@ -314,7 +314,7 @@ class NotificationsFragment : override fun onRespondToFollowRequest(accept: Boolean, id: String, position: Int) { val notification = notificationsAdapter?.peek(position) ?: return - viewModel.respondToFollowRequest(accept, accountId = id, notificationId = notification.id) + viewModel.respondToFollowRequest(accept, id = id, notificationId = notification.id) } override fun onViewReport(reportId: String) { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsRemoteMediator.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsRemoteMediator.kt index d3b71f7507..152662e088 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsRemoteMediator.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsRemoteMediator.kt @@ -26,6 +26,7 @@ import com.keylesspalace.tusky.components.timeline.toEntity import com.keylesspalace.tusky.components.timeline.util.ifExpected import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AppDatabase +import com.keylesspalace.tusky.db.entity.AccountEntity import com.keylesspalace.tusky.db.entity.NotificationDataEntity import com.keylesspalace.tusky.db.entity.TimelineStatusEntity import com.keylesspalace.tusky.entity.Notification @@ -35,10 +36,10 @@ import retrofit2.HttpException @OptIn(ExperimentalPagingApi::class) class NotificationsRemoteMediator( + private val viewModel: NotificationsViewModel, private val accountManager: AccountManager, private val api: MastodonApi, - private val db: AppDatabase, - var excludes: Set + private val db: AppDatabase ) : RemoteMediator() { private var initialRefresh = false @@ -46,16 +47,18 @@ class NotificationsRemoteMediator( private val notificationsDao = db.notificationsDao() private val accountDao = db.timelineAccountDao() private val statusDao = db.timelineStatusDao() - private val activeAccount = accountManager.activeAccount!! override suspend fun load( loadType: LoadType, state: PagingState ): MediatorResult { - if (!activeAccount.isLoggedIn()) { + val activeAccount = viewModel.activeAccountFlow.value + if (activeAccount == null) { return MediatorResult.Success(endOfPaginationReached = true) } + val excludes = viewModel.excludes.value + try { var dbEmpty = false @@ -79,7 +82,7 @@ class NotificationsRemoteMediator( val notifications = notificationResponse.body() if (notificationResponse.isSuccessful && notifications != null) { db.withTransaction { - replaceNotificationRange(notifications, state) + replaceNotificationRange(notifications, state, activeAccount) } } } @@ -106,7 +109,7 @@ class NotificationsRemoteMediator( } db.withTransaction { - val overlappedNotifications = replaceNotificationRange(notifications, state) + val overlappedNotifications = replaceNotificationRange(notifications, state, activeAccount) /* In case we loaded a whole page and there was no overlap with existing statuses, we insert a placeholder because there might be even more unknown statuses */ @@ -135,7 +138,11 @@ class NotificationsRemoteMediator( * @param notifications the new notifications * @return the number of old notifications that have been cleared from the database */ - private suspend fun replaceNotificationRange(notifications: List, state: PagingState): Int { + private suspend fun replaceNotificationRange( + notifications: List, + state: PagingState, + activeAccount: AccountEntity + ): Int { val overlappedNotifications = if (notifications.isNotEmpty()) { notificationsDao.deleteRange(activeAccount.id, notifications.last().id, notifications.first().id) } else { @@ -188,16 +195,13 @@ class NotificationsRemoteMediator( return overlappedNotifications } - private fun saveNewestNotificationId(notification: Notification) { - val account = accountManager.activeAccount - // make sure the account we are currently working with is still active - if (account == activeAccount) { + private suspend fun saveNewestNotificationId(notification: Notification) { + viewModel.activeAccountFlow.value?.let { activeAccount -> val lastNotificationId: String = activeAccount.lastNotificationId val newestNotificationId = notification.id if (lastNotificationId.isLessThan(newestNotificationId)) { - Log.d(TAG, "saving newest noti id: $lastNotificationId for account ${account.id}") - account.lastNotificationId = newestNotificationId - accountManager.saveAccount(account) + Log.d(TAG, "saving newest noti id: $lastNotificationId for account ${activeAccount.id}") + accountManager.updateAccount(activeAccount) { copy(lastNotificationId = newestNotificationId) } } } } 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 38d5b98540..7ddd26a9e0 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 @@ -48,6 +48,7 @@ import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.usecase.NotificationPolicyState import com.keylesspalace.tusky.usecase.NotificationPolicyUsecase import com.keylesspalace.tusky.usecase.TimelineCases +import com.keylesspalace.tusky.util.EmptyPagingSource import com.keylesspalace.tusky.util.deserialize import com.keylesspalace.tusky.util.serialize import com.keylesspalace.tusky.viewdata.NotificationViewData @@ -59,11 +60,13 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import retrofit2.HttpException @@ -79,19 +82,19 @@ class NotificationsViewModel @Inject constructor( private val notificationPolicyUsecase: NotificationPolicyUsecase ) : ViewModel() { + val activeAccountFlow = accountManager.activeAccount(viewModelScope) + private val accountId: Long = activeAccountFlow.value!!.id + private val refreshTrigger = MutableStateFlow(0L) - private val _excludes = MutableStateFlow( - accountManager.activeAccount?.let { account -> deserialize(account.notificationsFilter) } ?: emptySet() - ) - val excludes: StateFlow> = _excludes.asStateFlow() + val excludes: StateFlow> = activeAccountFlow + .map { account -> deserialize(account?.notificationsFilter ?: "[]") } + .stateIn(viewModelScope, SharingStarted.Eagerly, deserialize(activeAccountFlow.value?.notificationsFilter ?: "[]")) /** Map from notification id to translation. */ private val translations = MutableStateFlow(mapOf()) - private val account = accountManager.activeAccount!! - - private var remoteMediator = NotificationsRemoteMediator(accountManager, api, db, excludes.value) + private var remoteMediator = NotificationsRemoteMediator(this, accountManager, api, db) private var readingOrder: ReadingOrder = ReadingOrder.from(preferences.getString(PrefKeys.READING_ORDER, null)) @@ -104,7 +107,7 @@ class NotificationsViewModel @Inject constructor( ), remoteMediator = remoteMediator, pagingSourceFactory = { - db.notificationsDao().getNotifications(account.id) + db.notificationsDao().getNotifications(accountId) } ).flow .cachedIn(viewModelScope) @@ -149,14 +152,14 @@ class NotificationsViewModel @Inject constructor( } fun updateNotificationFilters(newFilters: Set) { - if (newFilters != _excludes.value) { + val account = activeAccountFlow.value + if (newFilters != excludes.value && account != null) { viewModelScope.launch { - account.notificationsFilter = serialize(newFilters) - accountManager.saveAccount(account) - remoteMediator.excludes = newFilters - db.notificationsDao().cleanupNotifications(account.id, 0) + accountManager.updateAccount(account) { + copy(notificationsFilter = serialize(newFilters)) + } + db.notificationsDao().cleanupNotifications(accountId, 0) refreshTrigger.value++ - _excludes.value = newFilters } } } @@ -164,8 +167,9 @@ class NotificationsViewModel @Inject constructor( private fun shouldFilterStatus(notificationViewData: NotificationViewData): Filter.Action { return when ((notificationViewData as? NotificationViewData.Concrete)?.type) { Notification.Type.MENTION, Notification.Type.POLL -> { + val account = activeAccountFlow.value notificationViewData.statusViewData?.let { statusViewData -> - if (statusViewData.status.account.id == account.accountId) { + if (statusViewData.status.account.id == account?.accountId) { return Filter.Action.NONE } statusViewData.filterAction = filterModel.shouldFilterStatus(statusViewData.actionable) @@ -178,23 +182,23 @@ class NotificationsViewModel @Inject constructor( } } - fun respondToFollowRequest(accept: Boolean, accountId: String, notificationId: String) { + fun respondToFollowRequest(accept: Boolean, id: String, notificationId: String) { viewModelScope.launch { if (accept) { - api.authorizeFollowRequest(accountId) + api.authorizeFollowRequest(id) } else { - api.rejectFollowRequest(accountId) + api.rejectFollowRequest(id) }.fold( onSuccess = { // since the follow request has been responded, the notification can be deleted. The Ui will update automatically. - db.notificationsDao().delete(account.id, notificationId) + db.notificationsDao().delete(accountId, notificationId) if (accept) { // refresh the notifications so the new follow notification will be loaded refreshTrigger.value++ } }, onFailure = { t -> - Log.e(TAG, "Failed to to respond to follow request from account id $accountId.", t) + Log.e(TAG, "Failed to to respond to follow request from account id $id.", t) } ) } @@ -239,33 +243,33 @@ class NotificationsViewModel @Inject constructor( fun changeExpanded(expanded: Boolean, status: StatusViewData.Concrete) { viewModelScope.launch { db.timelineStatusDao() - .setExpanded(account.id, status.id, expanded) + .setExpanded(accountId, status.id, expanded) } } fun changeContentShowing(isShowing: Boolean, status: StatusViewData.Concrete) { viewModelScope.launch { db.timelineStatusDao() - .setContentShowing(account.id, status.id, isShowing) + .setContentShowing(accountId, status.id, isShowing) } } fun changeContentCollapsed(isCollapsed: Boolean, status: StatusViewData.Concrete) { viewModelScope.launch { db.timelineStatusDao() - .setContentCollapsed(account.id, status.id, isCollapsed) + .setContentCollapsed(accountId, status.id, isCollapsed) } } fun remove(notificationId: String) { viewModelScope.launch { - db.notificationsDao().delete(account.id, notificationId) + db.notificationsDao().delete(accountId, notificationId) } } fun clearWarning(status: StatusViewData.Concrete) { viewModelScope.launch { - db.timelineStatusDao().clearWarning(account.id, status.actionableId) + db.timelineStatusDao().clearWarning(accountId, status.actionableId) } } @@ -273,7 +277,7 @@ class NotificationsViewModel @Inject constructor( viewModelScope.launch { api.clearNotifications().fold( { - db.notificationsDao().cleanupNotifications(account.id, 0) + db.notificationsDao().cleanupNotifications(accountId, 0) }, { t -> Log.w(TAG, "failed to clear notifications", t) @@ -304,13 +308,13 @@ class NotificationsViewModel @Inject constructor( notificationsDao.insertNotification( Placeholder(placeholderId, loading = true).toNotificationEntity( - account.id + accountId ) ) val response = db.withTransaction { - val idAbovePlaceholder = notificationsDao.getIdAbove(account.id, placeholderId) - val idBelowPlaceholder = notificationsDao.getIdBelow(account.id, placeholderId) + val idAbovePlaceholder = notificationsDao.getIdAbove(accountId, placeholderId) + val idBelowPlaceholder = notificationsDao.getIdBelow(accountId, placeholderId) when (readingOrder) { // Using minId, loads up to LOAD_AT_ONCE statuses with IDs immediately // after minId and no larger than maxId @@ -337,15 +341,20 @@ class NotificationsViewModel @Inject constructor( return@launch } + val account = activeAccountFlow.value + if (account == null) { + return@launch + } + val statusDao = db.timelineStatusDao() val accountDao = db.timelineAccountDao() db.withTransaction { - notificationsDao.delete(account.id, placeholderId) + notificationsDao.delete(accountId, placeholderId) val overlappedNotifications = if (notifications.isNotEmpty()) { notificationsDao.deleteRange( - account.id, + accountId, notifications.last().id, notifications.first().id ) @@ -354,18 +363,18 @@ class NotificationsViewModel @Inject constructor( } for (notification in notifications) { - accountDao.insert(notification.account.toEntity(account.id)) + accountDao.insert(notification.account.toEntity(accountId)) notification.report?.let { report -> - accountDao.insert(report.targetAccount.toEntity(account.id)) - notificationsDao.insertReport(report.toEntity(account.id)) + accountDao.insert(report.targetAccount.toEntity(accountId)) + notificationsDao.insertReport(report.toEntity(accountId)) } notification.status?.let { status -> val statusToInsert = status.reblog ?: status - accountDao.insert(statusToInsert.account.toEntity(account.id)) + accountDao.insert(statusToInsert.account.toEntity(accountId)) statusDao.insert( statusToInsert.toEntity( - tuskyAccountId = account.id, + tuskyAccountId = accountId, expanded = account.alwaysOpenSpoiler, contentShowing = account.alwaysShowSensitiveMedia || !status.sensitive, contentCollapsed = true @@ -374,7 +383,7 @@ class NotificationsViewModel @Inject constructor( } notificationsDao.insertNotification( notification.toEntity( - account.id + accountId ) ) } @@ -393,7 +402,7 @@ class NotificationsViewModel @Inject constructor( Placeholder( idToConvert, loading = false - ).toNotificationEntity(account.id) + ).toNotificationEntity(accountId) ) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/preference/AccountPreferencesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/preference/AccountPreferencesFragment.kt index aab700ce79..0e776ca753 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/preference/AccountPreferencesFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/preference/AccountPreferencesFragment.kt @@ -37,9 +37,7 @@ import com.keylesspalace.tusky.components.accountlist.AccountListActivity import com.keylesspalace.tusky.components.domainblocks.DomainBlocksActivity import com.keylesspalace.tusky.components.filters.FiltersActivity import com.keylesspalace.tusky.components.followedtags.FollowedTagsActivity -import com.keylesspalace.tusky.components.login.LoginActivity import com.keylesspalace.tusky.components.preference.notificationpolicies.NotificationPoliciesActivity -import com.keylesspalace.tusky.components.systemnotifications.currentAccountNeedsMigration import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.Status @@ -146,18 +144,6 @@ class AccountPreferencesFragment : PreferenceFragmentCompat() { } } - if (currentAccountNeedsMigration(accountManager)) { - preference { - setTitle(R.string.title_migration_relogin) - icon = icon(R.drawable.ic_logout) - setOnPreferenceClickListener { - val intent = LoginActivity.getIntent(context, LoginActivity.MODE_MIGRATION) - activity?.startActivityWithSlideInAnimation(intent) - true - } - } - } - preference { setTitle(R.string.pref_title_timeline_filters) icon = icon(R.drawable.ic_filter_24dp) @@ -204,10 +190,11 @@ class AccountPreferencesFragment : PreferenceFragmentCompat() { isPersistent = false // its saved to the account and shouldn't be in shared preferences setOnPreferenceChangeListener { _, newValue -> val newVisibility = DefaultReplyVisibility.fromStringValue(newValue as String) + icon = getIconForVisibility(newVisibility.toVisibilityOr(activeAccount.defaultPostPrivacy)) - activeAccount.defaultReplyPrivacy = newVisibility - accountManager.saveAccount(activeAccount) + viewLifecycleOwner.lifecycleScope.launch { + accountManager.updateAccount(activeAccount) { copy(defaultReplyPrivacy = newVisibility) } eventHub.dispatch(PreferenceChangedEvent(key)) } true @@ -337,11 +324,14 @@ class AccountPreferencesFragment : PreferenceFragmentCompat() { mastodonApi.accountUpdateSource(visibility, sensitive, language) .fold({ account: Account -> accountManager.activeAccount?.let { - it.defaultPostPrivacy = account.source?.privacy - ?: Status.Visibility.PUBLIC - it.defaultMediaSensitivity = account.source?.sensitive ?: false - it.defaultPostLanguage = language.orEmpty() - accountManager.saveAccount(it) + accountManager.updateAccount(it) { + copy( + defaultPostPrivacy = account.source?.privacy + ?: Status.Visibility.PUBLIC, + defaultMediaSensitivity = account.source?.sensitive == true, + defaultPostLanguage = language.orEmpty() + ) + } } }, { t -> Log.e("AccountPreferences", "failed updating settings on server", t) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/preference/NotificationPreferencesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/preference/NotificationPreferencesFragment.kt index 4b8eb03f6a..9812addd36 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/preference/NotificationPreferencesFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/preference/NotificationPreferencesFragment.kt @@ -16,6 +16,7 @@ package com.keylesspalace.tusky.components.preference import android.os.Bundle +import androidx.lifecycle.lifecycleScope import androidx.preference.PreferenceFragmentCompat import com.keylesspalace.tusky.R import com.keylesspalace.tusky.components.systemnotifications.NotificationHelper @@ -27,6 +28,7 @@ import com.keylesspalace.tusky.settings.preferenceCategory import com.keylesspalace.tusky.settings.switchPreference import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject +import kotlinx.coroutines.launch @AndroidEntryPoint class NotificationPreferencesFragment : PreferenceFragmentCompat() { @@ -44,7 +46,7 @@ class NotificationPreferencesFragment : PreferenceFragmentCompat() { isIconSpaceReserved = false isChecked = activeAccount.notificationsEnabled setOnPreferenceChangeListener { _, newValue -> - updateAccount { it.notificationsEnabled = newValue as Boolean } + updateAccount { copy(notificationsEnabled = newValue as Boolean) } if (NotificationHelper.areNotificationsEnabled(context, accountManager)) { NotificationHelper.enablePullNotifications(context) } else { @@ -64,7 +66,7 @@ class NotificationPreferencesFragment : PreferenceFragmentCompat() { isIconSpaceReserved = false isChecked = activeAccount.notificationsFollowed setOnPreferenceChangeListener { _, newValue -> - updateAccount { it.notificationsFollowed = newValue as Boolean } + updateAccount { copy(notificationsFollowed = newValue as Boolean) } true } } @@ -75,7 +77,7 @@ class NotificationPreferencesFragment : PreferenceFragmentCompat() { isIconSpaceReserved = false isChecked = activeAccount.notificationsFollowRequested setOnPreferenceChangeListener { _, newValue -> - updateAccount { it.notificationsFollowRequested = newValue as Boolean } + updateAccount { copy(notificationsFollowRequested = newValue as Boolean) } true } } @@ -86,7 +88,7 @@ class NotificationPreferencesFragment : PreferenceFragmentCompat() { isIconSpaceReserved = false isChecked = activeAccount.notificationsReblogged setOnPreferenceChangeListener { _, newValue -> - updateAccount { it.notificationsReblogged = newValue as Boolean } + updateAccount { copy(notificationsReblogged = newValue as Boolean) } true } } @@ -97,7 +99,7 @@ class NotificationPreferencesFragment : PreferenceFragmentCompat() { isIconSpaceReserved = false isChecked = activeAccount.notificationsFavorited setOnPreferenceChangeListener { _, newValue -> - updateAccount { it.notificationsFavorited = newValue as Boolean } + updateAccount { copy(notificationsFavorited = newValue as Boolean) } true } } @@ -108,7 +110,7 @@ class NotificationPreferencesFragment : PreferenceFragmentCompat() { isIconSpaceReserved = false isChecked = activeAccount.notificationsPolls setOnPreferenceChangeListener { _, newValue -> - updateAccount { it.notificationsPolls = newValue as Boolean } + updateAccount { copy(notificationsPolls = newValue as Boolean) } true } } @@ -119,7 +121,7 @@ class NotificationPreferencesFragment : PreferenceFragmentCompat() { isIconSpaceReserved = false isChecked = activeAccount.notificationsSubscriptions setOnPreferenceChangeListener { _, newValue -> - updateAccount { it.notificationsSubscriptions = newValue as Boolean } + updateAccount { copy(notificationsSubscriptions = newValue as Boolean) } true } } @@ -130,7 +132,7 @@ class NotificationPreferencesFragment : PreferenceFragmentCompat() { isIconSpaceReserved = false isChecked = activeAccount.notificationsSignUps setOnPreferenceChangeListener { _, newValue -> - updateAccount { it.notificationsSignUps = newValue as Boolean } + updateAccount { copy(notificationsSignUps = newValue as Boolean) } true } } @@ -141,7 +143,7 @@ class NotificationPreferencesFragment : PreferenceFragmentCompat() { isIconSpaceReserved = false isChecked = activeAccount.notificationsUpdates setOnPreferenceChangeListener { _, newValue -> - updateAccount { it.notificationsUpdates = newValue as Boolean } + updateAccount { copy(notificationsUpdates = newValue as Boolean) } true } } @@ -152,7 +154,7 @@ class NotificationPreferencesFragment : PreferenceFragmentCompat() { isIconSpaceReserved = false isChecked = activeAccount.notificationsReports setOnPreferenceChangeListener { _, newValue -> - updateAccount { it.notificationsReports = newValue as Boolean } + updateAccount { copy(notificationsReports = newValue as Boolean) } true } } @@ -168,7 +170,7 @@ class NotificationPreferencesFragment : PreferenceFragmentCompat() { isIconSpaceReserved = false isChecked = activeAccount.notificationSound setOnPreferenceChangeListener { _, newValue -> - updateAccount { it.notificationSound = newValue as Boolean } + updateAccount { copy(notificationSound = newValue as Boolean) } true } } @@ -179,7 +181,7 @@ class NotificationPreferencesFragment : PreferenceFragmentCompat() { isIconSpaceReserved = false isChecked = activeAccount.notificationVibration setOnPreferenceChangeListener { _, newValue -> - updateAccount { it.notificationVibration = newValue as Boolean } + updateAccount { copy(notificationVibration = newValue as Boolean) } true } } @@ -190,7 +192,7 @@ class NotificationPreferencesFragment : PreferenceFragmentCompat() { isIconSpaceReserved = false isChecked = activeAccount.notificationLight setOnPreferenceChangeListener { _, newValue -> - updateAccount { it.notificationLight = newValue as Boolean } + updateAccount { copy(notificationLight = newValue as Boolean) } true } } @@ -198,10 +200,11 @@ class NotificationPreferencesFragment : PreferenceFragmentCompat() { } } - private inline fun updateAccount(changer: (AccountEntity) -> Unit) { - accountManager.activeAccount?.let { account -> - changer(account) - accountManager.saveAccount(account) + private fun updateAccount(changer: AccountEntity.() -> AccountEntity) { + viewLifecycleOwner.lifecycleScope.launch { + accountManager.activeAccount?.let { account -> + accountManager.updateAccount(account, changer) + } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesFragment.kt index 58f7ee6c28..401f86f8b5 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesFragment.kt @@ -16,6 +16,7 @@ package com.keylesspalace.tusky.components.preference import android.os.Bundle +import androidx.lifecycle.lifecycleScope import androidx.preference.Preference import androidx.preference.PreferenceFragmentCompat import com.keylesspalace.tusky.R @@ -38,6 +39,7 @@ import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial import dagger.hilt.android.AndroidEntryPoint import de.c1710.filemojicompat_ui.views.picker.preference.EmojiPickerPreference import javax.inject.Inject +import kotlinx.coroutines.launch @AndroidEntryPoint class PreferencesFragment : PreferenceFragmentCompat() { @@ -271,8 +273,9 @@ class PreferencesFragment : PreferenceFragmentCompat() { notificationFilter.remove(Notification.Type.REBLOG) } - account.notificationsFilter = serialize(notificationFilter) - accountManager.saveAccount(account) + lifecycleScope.launch { + accountManager.updateAccount(account) { copy(notificationsFilter = serialize(notificationFilter)) } + } } true } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/systemnotifications/NotificationFetcher.kt b/app/src/main/java/com/keylesspalace/tusky/components/systemnotifications/NotificationFetcher.kt index 13ea0d9090..ca880a6d51 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/systemnotifications/NotificationFetcher.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/systemnotifications/NotificationFetcher.kt @@ -53,7 +53,7 @@ class NotificationFetcher @Inject constructor( private val eventHub: EventHub ) { suspend fun fetchAndShow() { - for (account in accountManager.getAllAccountsOrderedByActive()) { + for (account in accountManager.accounts) { if (account.notificationsEnabled) { try { val notificationManager = context.getSystemService( @@ -134,8 +134,6 @@ class NotificationFetcher @Inject constructor( notificationManager, account ) - - accountManager.saveAccount(account) } catch (e: Exception) { Log.e(TAG, "Error while fetching notifications", e) } @@ -221,8 +219,7 @@ class NotificationFetcher @Inject constructor( domain = account.domain, notificationsLastReadId = newMarkerId ) - account.notificationMarkerId = newMarkerId - accountManager.saveAccount(account) + accountManager.updateAccount(account) { copy(notificationMarkerId = newMarkerId) } } return notifications diff --git a/app/src/main/java/com/keylesspalace/tusky/components/systemnotifications/PushNotificationHelper.kt b/app/src/main/java/com/keylesspalace/tusky/components/systemnotifications/PushNotificationHelper.kt index de9bce2697..91ba6f7810 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/systemnotifications/PushNotificationHelper.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/systemnotifications/PushNotificationHelper.kt @@ -21,14 +21,8 @@ import android.app.NotificationManager import android.content.Context import android.os.Build import android.util.Log -import android.view.View -import androidx.preference.PreferenceManager import at.connyduck.calladapter.networkresult.onFailure import at.connyduck.calladapter.networkresult.onSuccess -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.android.material.snackbar.Snackbar -import com.keylesspalace.tusky.R -import com.keylesspalace.tusky.components.login.LoginActivity import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.entity.AccountEntity import com.keylesspalace.tusky.entity.Notification @@ -40,59 +34,6 @@ import org.unifiedpush.android.connector.UnifiedPush private const val TAG = "PushNotificationHelper" -private const val KEY_MIGRATION_NOTICE_DISMISSED = "migration_notice_dismissed" - -private fun anyAccountNeedsMigration(accountManager: AccountManager): Boolean = - accountManager.accounts.any(::accountNeedsMigration) - -private fun accountNeedsMigration(account: AccountEntity): Boolean = - !account.oauthScopes.contains("push") - -fun currentAccountNeedsMigration(accountManager: AccountManager): Boolean = - accountManager.activeAccount?.let(::accountNeedsMigration) ?: false - -fun showMigrationNoticeIfNecessary( - context: Context, - parent: View, - anchorView: View?, - accountManager: AccountManager -) { - // No point showing anything if we cannot enable it - if (!isUnifiedPushAvailable(context)) return - if (!anyAccountNeedsMigration(accountManager)) return - - val pm = PreferenceManager.getDefaultSharedPreferences(context) - if (pm.getBoolean(KEY_MIGRATION_NOTICE_DISMISSED, false)) return - - Snackbar.make(parent, R.string.tips_push_notification_migration, Snackbar.LENGTH_INDEFINITE) - .setAnchorView(anchorView) - .setAction( - R.string.action_details - ) { showMigrationExplanationDialog(context, accountManager) } - .show() -} - -private fun showMigrationExplanationDialog(context: Context, accountManager: AccountManager) { - MaterialAlertDialogBuilder(context).apply { - if (currentAccountNeedsMigration(accountManager)) { - setMessage(R.string.dialog_push_notification_migration) - setPositiveButton(R.string.title_migration_relogin) { _, _ -> - context.startActivity( - LoginActivity.getIntent(context, LoginActivity.MODE_MIGRATION) - ) - } - } else { - setMessage(R.string.dialog_push_notification_migration_other_accounts) - } - setNegativeButton(R.string.action_dismiss) { dialog, _ -> - val pm = PreferenceManager.getDefaultSharedPreferences(context) - pm.edit().putBoolean(KEY_MIGRATION_NOTICE_DISMISSED, true).apply() - dialog.dismiss() - } - show() - } -} - private suspend fun enableUnifiedPushNotificationsForAccount( context: Context, api: MastodonApi, @@ -123,18 +64,15 @@ fun disableUnifiedPushNotificationsForAccount(context: Context, account: Account fun isUnifiedPushNotificationEnabledForAccount(account: AccountEntity): Boolean = account.unifiedPushUrl.isNotEmpty() -private fun isUnifiedPushAvailable(context: Context): Boolean = +fun isUnifiedPushAvailable(context: Context): Boolean = UnifiedPush.getDistributors(context).isNotEmpty() -fun canEnablePushNotifications(context: Context, accountManager: AccountManager): Boolean = - isUnifiedPushAvailable(context) && !anyAccountNeedsMigration(accountManager) - suspend fun enablePushNotificationsWithFallback( context: Context, api: MastodonApi, accountManager: AccountManager ) { - if (!canEnablePushNotifications(context, accountManager)) { + if (!isUnifiedPushAvailable(context)) { // No UP distributors NotificationHelper.enablePullNotifications(context) return @@ -208,12 +146,15 @@ suspend fun registerUnifiedPushEndpoint( }.onSuccess { Log.d(TAG, "UnifiedPush registration succeeded for account ${account.id}") - account.pushPubKey = keyPair.pubkey - account.pushPrivKey = keyPair.privKey - account.pushAuth = auth - account.pushServerKey = it.serverKey - account.unifiedPushUrl = endpoint - accountManager.saveAccount(account) + accountManager.updateAccount(account) { + copy( + pushPubKey = keyPair.pubkey, + pushPrivKey = keyPair.privKey, + pushAuth = auth, + pushServerKey = it.serverKey, + unifiedPushUrl = endpoint + ) + } } } @@ -231,9 +172,9 @@ suspend fun updateUnifiedPushSubscription( buildSubscriptionData(context, account) ).onSuccess { Log.d(TAG, "UnifiedPush subscription updated for account ${account.id}") - - account.pushServerKey = it.serverKey - accountManager.saveAccount(account) + accountManager.updateAccount(account) { + copy(pushServerKey = it.serverKey) + } } } } @@ -251,12 +192,15 @@ suspend fun unregisterUnifiedPushEndpoint( .onSuccess { Log.d(TAG, "UnifiedPush unregistration succeeded for account " + account.id) // Clear the URL in database - account.unifiedPushUrl = "" - account.pushServerKey = "" - account.pushAuth = "" - account.pushPrivKey = "" - account.pushPubKey = "" - accountManager.saveAccount(account) + accountManager.updateAccount(account) { + copy( + pushPubKey = "", + pushPrivKey = "", + pushAuth = "", + pushServerKey = "", + unifiedPushUrl = "" + ) + } } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt index 73bc5e7144..13a47848f9 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt @@ -24,8 +24,8 @@ import androidx.room.withTransaction import com.keylesspalace.tusky.components.timeline.Placeholder import com.keylesspalace.tusky.components.timeline.toEntity import com.keylesspalace.tusky.components.timeline.util.ifExpected -import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AppDatabase +import com.keylesspalace.tusky.db.entity.AccountEntity import com.keylesspalace.tusky.db.entity.HomeTimelineData import com.keylesspalace.tusky.db.entity.HomeTimelineEntity import com.keylesspalace.tusky.db.entity.TimelineStatusEntity @@ -35,7 +35,7 @@ import retrofit2.HttpException @OptIn(ExperimentalPagingApi::class) class CachedTimelineRemoteMediator( - accountManager: AccountManager, + private val viewModel: CachedTimelineViewModel, private val api: MastodonApi, private val db: AppDatabase, ) : RemoteMediator() { @@ -45,13 +45,13 @@ class CachedTimelineRemoteMediator( private val timelineDao = db.timelineDao() private val statusDao = db.timelineStatusDao() private val accountDao = db.timelineAccountDao() - private val activeAccount = accountManager.activeAccount!! override suspend fun load( loadType: LoadType, state: PagingState ): MediatorResult { - if (!activeAccount.isLoggedIn()) { + val activeAccount = viewModel.activeAccountFlow.value + if (activeAccount == null) { return MediatorResult.Success(endOfPaginationReached = true) } @@ -77,7 +77,7 @@ class CachedTimelineRemoteMediator( val statuses = statusResponse.body() if (statusResponse.isSuccessful && statuses != null) { db.withTransaction { - replaceStatusRange(statuses, state) + replaceStatusRange(statuses, state, activeAccount) } } } @@ -104,7 +104,7 @@ class CachedTimelineRemoteMediator( } db.withTransaction { - val overlappedStatuses = replaceStatusRange(statuses, state) + val overlappedStatuses = replaceStatusRange(statuses, state, activeAccount) /* In case we loaded a whole page and there was no overlap with existing statuses, we insert a placeholder because there might be even more unknown statuses */ @@ -135,7 +135,8 @@ class CachedTimelineRemoteMediator( */ private suspend fun replaceStatusRange( statuses: List, - state: PagingState + state: PagingState, + activeAccount: AccountEntity ): Int { val overlappedStatuses = if (statuses.isNotEmpty()) { timelineDao.deleteRange(activeAccount.id, statuses.last().id, statuses.first().id) @@ -161,7 +162,7 @@ class CachedTimelineRemoteMediator( val expanded = oldStatus?.expanded ?: activeAccount.alwaysOpenSpoiler val contentShowing = oldStatus?.contentShowing ?: (activeAccount.alwaysShowSensitiveMedia || !status.actionableStatus.sensitive) - val contentCollapsed = oldStatus?.contentCollapsed ?: true + val contentCollapsed = oldStatus?.contentCollapsed != false statusDao.insert( status.actionableStatus.toEntity( diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt index e315d52525..9e26aaa507 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt @@ -86,9 +86,9 @@ class CachedTimelineViewModel @Inject constructor( config = PagingConfig( pageSize = LOAD_AT_ONCE ), - remoteMediator = CachedTimelineRemoteMediator(accountManager, api, db), + remoteMediator = CachedTimelineRemoteMediator(this, api, db), pagingSourceFactory = { - db.timelineDao().getHomeTimeline(account.id).also { newPagingSource -> + db.timelineDao().getHomeTimeline(accountId).also { newPagingSource -> this.currentPagingSource = newPagingSource } } @@ -118,27 +118,27 @@ class CachedTimelineViewModel @Inject constructor( override fun changeExpanded(expanded: Boolean, status: StatusViewData.Concrete) { viewModelScope.launch { db.timelineStatusDao() - .setExpanded(account.id, status.actionableId, expanded) + .setExpanded(accountId, status.actionableId, expanded) } } override fun changeContentShowing(isShowing: Boolean, status: StatusViewData.Concrete) { viewModelScope.launch { db.timelineStatusDao() - .setContentShowing(account.id, status.actionableId, isShowing) + .setContentShowing(accountId, status.actionableId, isShowing) } } override fun changeContentCollapsed(isCollapsed: Boolean, status: StatusViewData.Concrete) { viewModelScope.launch { db.timelineStatusDao() - .setContentCollapsed(account.id, status.actionableId, isCollapsed) + .setContentCollapsed(accountId, status.actionableId, isCollapsed) } } override fun clearWarning(status: StatusViewData.Concrete) { viewModelScope.launch { - db.timelineStatusDao().clearWarning(account.id, status.actionableId) + db.timelineStatusDao().clearWarning(accountId, status.actionableId) } } @@ -154,12 +154,12 @@ class CachedTimelineViewModel @Inject constructor( val accountDao = db.timelineAccountDao() timelineDao.insertHomeTimelineItem( - Placeholder(placeholderId, loading = true).toEntity(tuskyAccountId = account.id) + Placeholder(placeholderId, loading = true).toEntity(tuskyAccountId = accountId) ) val response = db.withTransaction { - val idAbovePlaceholder = timelineDao.getIdAbove(account.id, placeholderId) - val idBelowPlaceholder = timelineDao.getIdBelow(account.id, placeholderId) + val idAbovePlaceholder = timelineDao.getIdAbove(accountId, placeholderId) + val idBelowPlaceholder = timelineDao.getIdBelow(accountId, placeholderId) when (readingOrder) { // Using minId, loads up to LOAD_AT_ONCE statuses with IDs immediately // after minId and no larger than maxId @@ -184,12 +184,17 @@ class CachedTimelineViewModel @Inject constructor( return@launch } + val account = activeAccountFlow.value + if (account == null) { + return@launch + } + db.withTransaction { - timelineDao.deleteHomeTimelineItem(account.id, placeholderId) + timelineDao.deleteHomeTimelineItem(accountId, placeholderId) val overlappedStatuses = if (statuses.isNotEmpty()) { timelineDao.deleteRange( - account.id, + accountId, statuses.last().id, statuses.first().id ) @@ -198,14 +203,14 @@ class CachedTimelineViewModel @Inject constructor( } for (status in statuses) { - accountDao.insert(status.account.toEntity(account.id)) - status.reblog?.account?.toEntity(account.id) + accountDao.insert(status.account.toEntity(accountId)) + status.reblog?.account?.toEntity(accountId) ?.let { rebloggedAccount -> accountDao.insert(rebloggedAccount) } statusDao.insert( status.actionableStatus.toEntity( - tuskyAccountId = account.id, + tuskyAccountId = accountId, expanded = account.alwaysOpenSpoiler, contentShowing = account.alwaysShowSensitiveMedia || !status.actionableStatus.sensitive, contentCollapsed = true @@ -213,7 +218,7 @@ class CachedTimelineViewModel @Inject constructor( ) timelineDao.insertHomeTimelineItem( HomeTimelineEntity( - tuskyAccountId = account.id, + tuskyAccountId = accountId, id = status.id, statusId = status.actionableId, reblogAccountId = if (status.reblog != null) { @@ -239,7 +244,7 @@ class CachedTimelineViewModel @Inject constructor( Placeholder( idToConvert, loading = false - ).toEntity(account.id) + ).toEntity(accountId) ) } } @@ -252,7 +257,7 @@ class CachedTimelineViewModel @Inject constructor( } private suspend fun loadMoreFailed(placeholderId: String, e: Exception) { - Log.w("CachedTimelineVM", "failed loading statuses", e) + Log.w(TAG, "failed loading statuses", e) val activeAccount = accountManager.activeAccount!! db.timelineDao() .insertHomeTimelineItem(Placeholder(placeholderId, loading = false).toEntity(activeAccount.id)) @@ -266,16 +271,19 @@ class CachedTimelineViewModel @Inject constructor( } override fun saveReadingPosition(statusId: String) { - accountManager.activeAccount?.let { account -> - Log.d(TAG, "Saving position at: $statusId") - account.lastVisibleHomeTimelineStatusId = statusId - accountManager.saveAccount(account) + viewModelScope.launch { + accountManager.activeAccount?.let { account -> + Log.d(TAG, "Saving position at: $statusId") + accountManager.updateAccount(account) { + copy(lastVisibleHomeTimelineStatusId = statusId) + } + } } } override suspend fun invalidate() { // invalidating when we don't have statuses yet can cause empty timelines because it cancels the network load - if (db.timelineDao().getHomeTimelineItemCount(account.id) > 0) { + if (db.timelineDao().getHomeTimelineItemCount(accountId) > 0) { currentPagingSource?.invalidate() } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt index f19b2240f8..566274afba 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt @@ -21,7 +21,6 @@ import androidx.paging.LoadType import androidx.paging.PagingState import androidx.paging.RemoteMediator import com.keylesspalace.tusky.components.timeline.util.ifExpected -import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.util.HttpHeaderLink import com.keylesspalace.tusky.util.toViewData import com.keylesspalace.tusky.viewdata.StatusViewData @@ -29,7 +28,6 @@ import retrofit2.HttpException @OptIn(ExperimentalPagingApi::class) class NetworkTimelineRemoteMediator( - private val accountManager: AccountManager, private val viewModel: NetworkTimelineViewModel ) : RemoteMediator() { @@ -68,7 +66,7 @@ class NetworkTimelineRemoteMediator( return MediatorResult.Error(HttpException(statusResponse)) } - val activeAccount = accountManager.activeAccount!! + val activeAccount = viewModel.activeAccountFlow.value!! val data = statuses.map { status -> @@ -78,7 +76,7 @@ class NetworkTimelineRemoteMediator( val contentShowing = oldStatus?.isShowingContent ?: (activeAccount.alwaysShowSensitiveMedia || !status.actionableStatus.sensitive) val expanded = oldStatus?.isExpanded ?: activeAccount.alwaysOpenSpoiler - val contentCollapsed = oldStatus?.isCollapsed ?: true + val contentCollapsed = oldStatus?.isCollapsed != false status.toViewData( isShowingContent = contentShowing, diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt index ff95bc2146..261d0c19a5 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt @@ -96,7 +96,7 @@ class NetworkTimelineViewModel @Inject constructor( currentSource = source } }, - remoteMediator = NetworkTimelineRemoteMediator(accountManager, this) + remoteMediator = NetworkTimelineRemoteMediator(this) ).flow .map { pagingData -> pagingData.filter(Dispatchers.Default.asExecutor()) { statusViewData -> 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 ff915ea7b9..4030879f0c 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 @@ -46,7 +46,8 @@ abstract class TimelineViewModel( private val filterModel: FilterModel ) : ViewModel() { - protected val account = accountManager.activeAccount!! + val activeAccountFlow = accountManager.activeAccount(viewModelScope) + protected val accountId: Long = activeAccountFlow.value!!.id abstract val statuses: Flow> @@ -69,19 +70,18 @@ abstract class TimelineViewModel( this.id = id this.tags = tags + val activeAccount = activeAccountFlow.value!! + if (kind == Kind.HOME) { // Note the variable is "true if filter" but the underlying preference/settings text is "true if show" - filterRemoveReplies = - !(accountManager.activeAccount?.isShowHomeReplies ?: true) - filterRemoveReblogs = - !(accountManager.activeAccount?.isShowHomeBoosts ?: true) - filterRemoveSelfReblogs = - !(accountManager.activeAccount?.isShowHomeSelfBoosts ?: true) + filterRemoveReplies = !activeAccount.isShowHomeReplies + filterRemoveReblogs = !activeAccount.isShowHomeBoosts + filterRemoveSelfReblogs = !activeAccount.isShowHomeSelfBoosts } readingOrder = ReadingOrder.from(sharedPreferences.getString(PrefKeys.READING_ORDER, null)) - this.alwaysShowSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia - this.alwaysOpenSpoilers = accountManager.activeAccount!!.alwaysOpenSpoiler + this.alwaysShowSensitiveMedia = activeAccount.alwaysShowSensitiveMedia + this.alwaysOpenSpoilers = activeAccount.alwaysOpenSpoiler viewModelScope.launch { eventHub.events @@ -181,7 +181,7 @@ abstract class TimelineViewModel( protected fun shouldFilterStatus(statusViewData: StatusViewData): Filter.Action { val status = statusViewData.asStatusOrNull()?.status ?: return Filter.Action.NONE - if (status.actionableStatus.account.id == account.accountId) { + if (status.actionableStatus.account.id == activeAccountFlow.value?.accountId) { // never filter own posts return Filter.Action.NONE } @@ -198,9 +198,10 @@ abstract class TimelineViewModel( } private fun onPreferenceChanged(key: String) { + val activeAccount = activeAccountFlow.value when (key) { PrefKeys.TAB_FILTER_HOME_REPLIES -> { - val filter = accountManager.activeAccount?.isShowHomeReplies ?: true + val filter = activeAccount?.isShowHomeReplies != false val oldRemoveReplies = filterRemoveReplies filterRemoveReplies = kind == Kind.HOME && !filter if (oldRemoveReplies != filterRemoveReplies) { @@ -208,7 +209,7 @@ abstract class TimelineViewModel( } } PrefKeys.TAB_FILTER_HOME_BOOSTS -> { - val filter = accountManager.activeAccount?.isShowHomeBoosts ?: true + val filter = activeAccount?.isShowHomeBoosts != false val oldRemoveReblogs = filterRemoveReblogs filterRemoveReblogs = kind == Kind.HOME && !filter if (oldRemoveReblogs != filterRemoveReblogs) { @@ -216,7 +217,7 @@ abstract class TimelineViewModel( } } PrefKeys.TAB_SHOW_HOME_SELF_BOOSTS -> { - val filter = accountManager.activeAccount?.isShowHomeSelfBoosts ?: true + val filter = activeAccount?.isShowHomeSelfBoosts != false val oldRemoveSelfReblogs = filterRemoveSelfReblogs filterRemoveSelfReblogs = kind == Kind.HOME && !filter if (oldRemoveSelfReblogs != filterRemoveSelfReblogs) { diff --git a/app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt b/app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt index c286d47b13..2703e14754 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt @@ -17,14 +17,23 @@ package com.keylesspalace.tusky.db import android.content.SharedPreferences import android.util.Log +import androidx.room.withTransaction import com.keylesspalace.tusky.db.dao.AccountDao import com.keylesspalace.tusky.db.entity.AccountEntity +import com.keylesspalace.tusky.di.ApplicationScope import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.settings.PrefKeys import java.util.Locale import javax.inject.Inject import javax.inject.Singleton +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.runBlocking /** * This class caches the account database and handles all account related operations @@ -35,24 +44,39 @@ private const val TAG = "AccountManager" @Singleton class AccountManager @Inject constructor( - db: AppDatabase, - private val preferences: SharedPreferences + private val db: AppDatabase, + private val preferences: SharedPreferences, + @ApplicationScope private val applicationScope: CoroutineScope ) { - @Volatile - var activeAccount: AccountEntity? = null - private set + private val accountDao: AccountDao = db.accountDao() - var accounts: MutableList = mutableListOf() - private set + val accountsFlow: StateFlow> = runBlocking { + accountDao.allAccounts() + .stateIn(CoroutineScope(applicationScope.coroutineContext + Dispatchers.IO)) + } - private val accountDao: AccountDao = db.accountDao() + /** a list of all accounts in the database with the active account first */ + val accounts: List + get() = accountsFlow.value - init { - accounts = accountDao.loadAll().toMutableList() + /** the currently active account */ + val activeAccount: AccountEntity? + get() { + val a = accounts.firstOrNull() + Log.d(TAG, "returning active account with id ${a?.id}") + return a + } - activeAccount = accounts.find { acc -> acc.isActive } - ?: accounts.firstOrNull()?.also { acc -> acc.isActive = true } + /** Returns a StateFlow for updates to the currently active account. + * Note that the account will be null after it got logged out, + * and that always the same account will be returned, + * even if it is no longer active. */ + fun activeAccount(scope: CoroutineScope): StateFlow { + val activeAccount = activeAccount + return accountsFlow.map { accounts -> + accounts.find { account -> activeAccount?.id == account.id } + }.stateIn(scope, SharingStarted.Lazily, activeAccount) } /** @@ -64,19 +88,18 @@ class AccountManager @Inject constructor( * @param oauthScopes the oauth scopes granted to the account * @param newAccount the [Account] as returned by the Mastodon Api */ - fun addAccount( + suspend fun addAccount( accessToken: String, domain: String, clientId: String, clientSecret: String, oauthScopes: String, newAccount: Account - ) { + ) = db.withTransaction { activeAccount?.let { - it.isActive = false Log.d(TAG, "addAccount: saving account with id " + it.id) - accountDao.insertOrReplace(it) + accountDao.insertOrReplace(it.copy(isActive = false)) } // check if this is a relogin with an existing account, if yes update it, otherwise create a new one val existingAccountIndex = accounts.indexOfFirst { account -> @@ -89,9 +112,9 @@ class AccountManager @Inject constructor( clientSecret = clientSecret, oauthScopes = oauthScopes, isActive = true - ).also { accounts[existingAccountIndex] = it } + ) } else { - val maxAccountId = accounts.maxByOrNull { it.id }?.id ?: 0 + val maxAccountId = accounts.maxOfOrNull { it.id } ?: 0 val newAccountId = maxAccountId + 1 AccountEntity( id = newAccountId, @@ -102,10 +125,8 @@ class AccountManager @Inject constructor( oauthScopes = oauthScopes, isActive = true, accountId = newAccount.id - ).also { accounts.add(it) } + ) } - - activeAccount = newAccountEntity updateAccount(newAccountEntity, newAccount) } @@ -114,10 +135,11 @@ class AccountManager @Inject constructor( * New accounts must be created with [addAccount] * @param account the account to save */ - fun saveAccount(account: AccountEntity) { + suspend fun updateAccount(account: AccountEntity, changer: AccountEntity.() -> AccountEntity) { if (account.id != 0L) { - Log.d(TAG, "saveAccount: saving account with id " + account.id) - accountDao.insertOrReplace(account) + // get the newest version of the account to make sure no stale data gets re-saved to db + val acc = accounts.find { it.id == account.id } ?: return + accountDao.insertOrReplace(changer(acc)) } } @@ -125,21 +147,20 @@ class AccountManager @Inject constructor( * Logs an account out by deleting all its data. * @return the new active account, or null if no other account was found */ - fun logout(account: AccountEntity): AccountEntity? { - account.logout() - - accounts.remove(account) + suspend fun logout(account: AccountEntity): AccountEntity? = db.withTransaction { accountDao.delete(account) - if (accounts.size > 0) { - accounts[0].isActive = true - activeAccount = accounts[0] - Log.d(TAG, "logActiveAccountOut: saving account with id " + accounts[0].id) - accountDao.insertOrReplace(accounts[0]) + val otherAccount = accounts.find { it.id != account.id } + if (otherAccount != null) { + val otherAccountActive = otherAccount.copy( + isActive = true + ) + Log.d(TAG, "logActiveAccountOut: saving account with id " + otherAccountActive.id) + accountDao.insertOrReplace(otherAccountActive) + otherAccountActive } else { - activeAccount = null + null } - return activeAccount } /** @@ -147,59 +168,43 @@ class AccountManager @Inject constructor( * and saves it in the database. * @param accountEntity the [AccountEntity] to update * @param account the [Account] object which the newest data from the api + * @return the updated [AccountEntity] */ - fun updateAccount(accountEntity: AccountEntity, account: Account) { - accountEntity.accountId = account.id - accountEntity.username = account.username - accountEntity.displayName = account.name - accountEntity.profilePictureUrl = account.avatar - accountEntity.defaultPostPrivacy = account.source?.privacy ?: Status.Visibility.PUBLIC - accountEntity.defaultPostLanguage = account.source?.language.orEmpty() - accountEntity.defaultMediaSensitivity = account.source?.sensitive ?: false - accountEntity.emojis = account.emojis - accountEntity.locked = account.locked + suspend fun updateAccount(accountEntity: AccountEntity, account: Account): AccountEntity { + val newAccount = accountEntity.copy( + accountId = account.id, + username = account.username, + displayName = account.name, + profilePictureUrl = account.avatar, + profileHeaderUrl = account.header, + defaultPostPrivacy = account.source?.privacy ?: Status.Visibility.PUBLIC, + defaultPostLanguage = account.source?.language.orEmpty(), + defaultMediaSensitivity = account.source?.sensitive ?: false, + emojis = account.emojis, + locked = account.locked + ) Log.d(TAG, "updateAccount: saving account with id " + accountEntity.id) - accountDao.insertOrReplace(accountEntity) + accountDao.insertOrReplace(newAccount) + return newAccount } /** * changes the active account * @param accountId the database id of the new active account */ - fun setActiveAccount(accountId: Long) { + suspend fun setActiveAccount(accountId: Long) = db.withTransaction { + Log.d(TAG, "setActiveAccount $accountId") + val newActiveAccount = accounts.find { (id) -> id == accountId - } ?: return // invalid accountId passed, do nothing + } ?: return@withTransaction // invalid accountId passed, do nothing activeAccount?.let { - Log.d(TAG, "setActiveAccount: saving account with id " + it.id) - it.isActive = false - saveAccount(it) - } - - activeAccount = newActiveAccount - - activeAccount?.let { - it.isActive = true - accountDao.insertOrReplace(it) - } - } - - /** - * @return an immutable list of all accounts in the database with the active account first - */ - fun getAllAccountsOrderedByActive(): List { - val accountsCopy = accounts.toMutableList() - accountsCopy.sortWith { l, r -> - when { - l.isActive && !r.isActive -> -1 - r.isActive && !l.isActive -> 1 - else -> 0 - } + accountDao.insertOrReplace(it.copy(isActive = false)) } - return accountsCopy + accountDao.insertOrReplace(newActiveAccount.copy(isActive = true)) } /** 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 81597e00ce..aea248b84e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java +++ b/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java @@ -62,7 +62,7 @@ }, // Note: Starting with version 54, database versions in Tusky are always even. // This is to reserve odd version numbers for use by forks. - version = 64, + version = 66, autoMigrations = { @AutoMigration(from = 48, to = 49), @AutoMigration(from = 49, to = 50, spec = AppDatabase.MIGRATION_49_50.class), @@ -70,7 +70,8 @@ @AutoMigration(from = 51, to = 52), @AutoMigration(from = 53, to = 54), // hasDirectMessageBadge in AccountEntity @AutoMigration(from = 56, to = 58), // translationEnabled in InstanceEntity/InstanceInfoEntity - @AutoMigration(from = 62, to = 64) // filterV2Available in InstanceEntity + @AutoMigration(from = 62, to = 64), // filterV2Available in InstanceEntity + @AutoMigration(from = 64, to = 66) // added profileHeaderUrl to AccountEntity } ) public abstract class AppDatabase extends RoomDatabase { diff --git a/app/src/main/java/com/keylesspalace/tusky/db/DraftsAlert.kt b/app/src/main/java/com/keylesspalace/tusky/db/DraftsAlert.kt index 1f9508622b..743ddf8476 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/DraftsAlert.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/DraftsAlert.kt @@ -38,13 +38,13 @@ import kotlinx.coroutines.launch private const val TAG = "DraftsAlert" @Singleton -class DraftsAlert @Inject constructor(db: AppDatabase) { +class DraftsAlert @Inject constructor( + db: AppDatabase, + private val accountManager: AccountManager +) { // For tracking when a media upload fails in the service private val draftDao: DraftDao = db.draftDao() - @Inject - lateinit var accountManager: AccountManager - fun observeInContext(context: T, showAlert: Boolean) where T : Context, T : LifecycleOwner { accountManager.activeAccount?.let { activeAccount -> val coroutineScope = context.lifecycleScope diff --git a/app/src/main/java/com/keylesspalace/tusky/db/dao/AccountDao.kt b/app/src/main/java/com/keylesspalace/tusky/db/dao/AccountDao.kt index b310231734..eb38316ca9 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/dao/AccountDao.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/dao/AccountDao.kt @@ -21,15 +21,16 @@ import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import com.keylesspalace.tusky.db.entity.AccountEntity +import kotlinx.coroutines.flow.Flow @Dao interface AccountDao { @Insert(onConflict = OnConflictStrategy.REPLACE) - fun insertOrReplace(account: AccountEntity): Long + suspend fun insertOrReplace(account: AccountEntity): Long @Delete - fun delete(account: AccountEntity) + suspend fun delete(account: AccountEntity) - @Query("SELECT * FROM AccountEntity ORDER BY id ASC") - fun loadAll(): List + @Query("SELECT * FROM AccountEntity ORDER BY isActive DESC") + fun allAccounts(): Flow> } diff --git a/app/src/main/java/com/keylesspalace/tusky/db/entity/AccountEntity.kt b/app/src/main/java/com/keylesspalace/tusky/db/entity/AccountEntity.kt index a774788124..8f7e4ebadd 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/entity/AccountEntity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/entity/AccountEntity.kt @@ -37,86 +37,88 @@ import com.keylesspalace.tusky.settings.DefaultReplyVisibility ) @TypeConverters(Converters::class) data class AccountEntity( - @field:PrimaryKey(autoGenerate = true) var id: Long, + @field:PrimaryKey(autoGenerate = true) val id: Long, val domain: String, - var accessToken: String, + val accessToken: String, // nullable for backward compatibility - var clientId: String?, + val clientId: String?, // nullable for backward compatibility - var clientSecret: String?, - var isActive: Boolean, - var accountId: String = "", - var username: String = "", - var displayName: String = "", - var profilePictureUrl: String = "", - var notificationsEnabled: Boolean = true, - var notificationsMentioned: Boolean = true, - var notificationsFollowed: Boolean = true, - var notificationsFollowRequested: Boolean = false, - var notificationsReblogged: Boolean = true, - var notificationsFavorited: Boolean = true, - var notificationsPolls: Boolean = true, - var notificationsSubscriptions: Boolean = true, - var notificationsSignUps: Boolean = true, - var notificationsUpdates: Boolean = true, - var notificationsReports: Boolean = true, - var notificationSound: Boolean = true, - var notificationVibration: Boolean = true, - var notificationLight: Boolean = true, - var defaultPostPrivacy: Status.Visibility = Status.Visibility.PUBLIC, - var defaultReplyPrivacy: DefaultReplyVisibility = DefaultReplyVisibility.MATCH_DEFAULT_POST_VISIBILITY, - var defaultMediaSensitivity: Boolean = false, - var defaultPostLanguage: String = "", - var alwaysShowSensitiveMedia: Boolean = false, + val clientSecret: String?, + val isActive: Boolean, + val accountId: String = "", + val username: String = "", + val displayName: String = "", + val profilePictureUrl: String = "", + @ColumnInfo(defaultValue = "") val profileHeaderUrl: String = "", + val notificationsEnabled: Boolean = true, + val notificationsMentioned: Boolean = true, + val notificationsFollowed: Boolean = true, + val notificationsFollowRequested: Boolean = false, + val notificationsReblogged: Boolean = true, + val notificationsFavorited: Boolean = true, + val notificationsPolls: Boolean = true, + val notificationsSubscriptions: Boolean = true, + val notificationsSignUps: Boolean = true, + val notificationsUpdates: Boolean = true, + val notificationsReports: Boolean = true, + val notificationSound: Boolean = true, + val notificationVibration: Boolean = true, + val notificationLight: Boolean = true, + val defaultPostPrivacy: Status.Visibility = Status.Visibility.PUBLIC, + val defaultReplyPrivacy: DefaultReplyVisibility = DefaultReplyVisibility.MATCH_DEFAULT_POST_VISIBILITY, + val defaultMediaSensitivity: Boolean = false, + val defaultPostLanguage: String = "", + val alwaysShowSensitiveMedia: Boolean = false, /** True if content behind a content warning is shown by default */ - var alwaysOpenSpoiler: Boolean = false, + @ColumnInfo(defaultValue = "0") + val alwaysOpenSpoiler: Boolean = false, /** * True if the "Download media previews" preference is true. This implies * that media previews are shown as well as downloaded. */ - var mediaPreviewEnabled: Boolean = true, + val mediaPreviewEnabled: Boolean = true, /** * ID of the last notification the user read on the Notification, list, and should be restored * to view when the user returns to the list. * * May not be the ID of the most recent notification if the user has scrolled down the list. */ - var lastNotificationId: String = "0", + val lastNotificationId: String = "0", /** * ID of the most recent Mastodon notification that Tusky has fetched to show as an * Android notification. */ @ColumnInfo(defaultValue = "0") - var notificationMarkerId: String = "0", - var emojis: List = emptyList(), - var tabPreferences: List = defaultTabs(), - var notificationsFilter: String = "[\"follow_request\"]", + val notificationMarkerId: String = "0", + val emojis: List = emptyList(), + val tabPreferences: List = defaultTabs(), + val notificationsFilter: String = "[\"follow_request\"]", // Scope cannot be changed without re-login, so store it in case // the scope needs to be changed in the future - var oauthScopes: String = "", - var unifiedPushUrl: String = "", - var pushPubKey: String = "", - var pushPrivKey: String = "", - var pushAuth: String = "", - var pushServerKey: String = "", + val oauthScopes: String = "", + val unifiedPushUrl: String = "", + val pushPubKey: String = "", + val pushPrivKey: String = "", + val pushAuth: String = "", + val pushServerKey: String = "", /** * ID of the status at the top of the visible list in the home timeline when the * user navigated away. */ - var lastVisibleHomeTimelineStatusId: String? = null, + val lastVisibleHomeTimelineStatusId: String? = null, /** true if the connected Mastodon account is locked (has to manually approve all follow requests **/ @ColumnInfo(defaultValue = "0") - var locked: Boolean = false, + val locked: Boolean = false, @ColumnInfo(defaultValue = "0") - var hasDirectMessageBadge: Boolean = false, + val hasDirectMessageBadge: Boolean = false, - var isShowHomeBoosts: Boolean = true, - var isShowHomeReplies: Boolean = true, - var isShowHomeSelfBoosts: Boolean = true + val isShowHomeBoosts: Boolean = true, + val isShowHomeReplies: Boolean = true, + val isShowHomeSelfBoosts: Boolean = true ) { val identifier: String @@ -124,30 +126,4 @@ data class AccountEntity( val fullName: String get() = "@$username@$domain" - - fun logout() { - // deleting credentials so they cannot be used again - accessToken = "" - clientId = null - clientSecret = null - } - - fun isLoggedIn() = accessToken.isNotEmpty() - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - - other as AccountEntity - - if (id == other.id) return true - return domain == other.domain && accountId == other.accountId - } - - override fun hashCode(): Int { - var result = id.hashCode() - result = 31 * result + domain.hashCode() - result = 31 * result + accountId.hashCode() - return result - } } diff --git a/app/src/main/java/com/keylesspalace/tusky/di/StorageModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/StorageModule.kt index 5f5893b3b5..b17fdbb4e9 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/StorageModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/StorageModule.kt @@ -46,7 +46,6 @@ object StorageModule { fun providesDatabase(@ApplicationContext appContext: Context, converters: Converters): AppDatabase { return Room.databaseBuilder(appContext, AppDatabase::class.java, "tuskyDB") .addTypeConverter(converters) - .allowMainThreadQueries() .addMigrations( AppDatabase.MIGRATION_2_3, AppDatabase.MIGRATION_3_4, AppDatabase.MIGRATION_4_5, AppDatabase.MIGRATION_5_6, AppDatabase.MIGRATION_6_7, AppDatabase.MIGRATION_7_8, diff --git a/app/src/main/java/com/keylesspalace/tusky/interfaces/FabFragment.kt b/app/src/main/java/com/keylesspalace/tusky/interfaces/FabFragment.kt deleted file mode 100644 index 1189dd3b36..0000000000 --- a/app/src/main/java/com/keylesspalace/tusky/interfaces/FabFragment.kt +++ /dev/null @@ -1,22 +0,0 @@ -/* - * Copyright 2023 Tusky Contributors - * - * This file is a part of Tusky. - * - * This program is free software; you can redistribute it and/or modify it under the terms of the - * GNU General Public License as published by the Free Software Foundation; either version 3 of the - * License, or (at your option) any later version. - * - * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even - * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General - * Public License for more details. - * - * You should have received a copy of the GNU General Public License along with Tusky; if not, - * see . - */ - -package com.keylesspalace.tusky.interfaces - -interface FabFragment { - fun isFabVisible(): Boolean -} diff --git a/app/src/main/java/com/keylesspalace/tusky/receiver/NotificationBlockStateBroadcastReceiver.kt b/app/src/main/java/com/keylesspalace/tusky/receiver/NotificationBlockStateBroadcastReceiver.kt index 9df766bb44..fa906510ea 100644 --- a/app/src/main/java/com/keylesspalace/tusky/receiver/NotificationBlockStateBroadcastReceiver.kt +++ b/app/src/main/java/com/keylesspalace/tusky/receiver/NotificationBlockStateBroadcastReceiver.kt @@ -20,7 +20,7 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.os.Build -import com.keylesspalace.tusky.components.systemnotifications.canEnablePushNotifications +import com.keylesspalace.tusky.components.systemnotifications.isUnifiedPushAvailable import com.keylesspalace.tusky.components.systemnotifications.isUnifiedPushNotificationEnabledForAccount import com.keylesspalace.tusky.components.systemnotifications.updateUnifiedPushSubscription import com.keylesspalace.tusky.db.AccountManager @@ -45,7 +45,7 @@ class NotificationBlockStateBroadcastReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { if (Build.VERSION.SDK_INT < 28) return - if (!canEnablePushNotifications(context, accountManager)) return + if (!isUnifiedPushAvailable(context)) return val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager diff --git a/app/src/main/java/com/keylesspalace/tusky/settings/AccountPreferenceDataStore.kt b/app/src/main/java/com/keylesspalace/tusky/settings/AccountPreferenceDataStore.kt index d62731f010..bac5efbf39 100644 --- a/app/src/main/java/com/keylesspalace/tusky/settings/AccountPreferenceDataStore.kt +++ b/app/src/main/java/com/keylesspalace/tusky/settings/AccountPreferenceDataStore.kt @@ -30,18 +30,18 @@ class AccountPreferenceDataStore @Inject constructor( } override fun putBoolean(key: String, value: Boolean) { - when (key) { - PrefKeys.ALWAYS_SHOW_SENSITIVE_MEDIA -> account.alwaysShowSensitiveMedia = value - PrefKeys.ALWAYS_OPEN_SPOILER -> account.alwaysOpenSpoiler = value - PrefKeys.MEDIA_PREVIEW_ENABLED -> account.mediaPreviewEnabled = value - PrefKeys.TAB_FILTER_HOME_BOOSTS -> account.isShowHomeBoosts = value - PrefKeys.TAB_FILTER_HOME_REPLIES -> account.isShowHomeReplies = value - PrefKeys.TAB_SHOW_HOME_SELF_BOOSTS -> account.isShowHomeSelfBoosts = value - } - - accountManager.saveAccount(account) - externalScope.launch { + accountManager.updateAccount(account) { + when (key) { + PrefKeys.ALWAYS_SHOW_SENSITIVE_MEDIA -> copy(alwaysShowSensitiveMedia = value) + PrefKeys.ALWAYS_OPEN_SPOILER -> copy(alwaysOpenSpoiler = value) + PrefKeys.MEDIA_PREVIEW_ENABLED -> copy(mediaPreviewEnabled = value) + PrefKeys.TAB_FILTER_HOME_BOOSTS -> copy(isShowHomeBoosts = value) + PrefKeys.TAB_FILTER_HOME_REPLIES -> copy(isShowHomeReplies = value) + PrefKeys.TAB_SHOW_HOME_SELF_BOOSTS -> copy(isShowHomeSelfBoosts = value) + else -> this + } + } eventHub.dispatch(PreferenceChangedEvent(key)) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/viewmodel/EditProfileViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/viewmodel/EditProfileViewModel.kt index a61fe4806e..cf92650c08 100644 --- a/app/src/main/java/com/keylesspalace/tusky/viewmodel/EditProfileViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/viewmodel/EditProfileViewModel.kt @@ -25,6 +25,7 @@ import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.ProfileEditedEvent import com.keylesspalace.tusky.components.instanceinfo.InstanceInfo import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository +import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.StringField import com.keylesspalace.tusky.network.MastodonApi @@ -65,9 +66,12 @@ class EditProfileViewModel @Inject constructor( private val mastodonApi: MastodonApi, private val eventHub: EventHub, private val application: Application, + private val accountManager: AccountManager, instanceInfoRepo: InstanceInfoRepository ) : ViewModel() { + private val activeAccount = accountManager.activeAccount!! + private val _profileData = MutableStateFlow(null as Resource?) val profileData: StateFlow?> = _profileData.asStateFlow() @@ -169,8 +173,9 @@ class EditProfileViewModel @Inject constructor( diff.field4?.second?.toRequestBody(MultipartBody.FORM) ).fold( { newAccountData -> - _saveData.value = Success() + accountManager.updateAccount(activeAccount, newAccountData) eventHub.dispatch(ProfileEditedEvent(newAccountData)) + _saveData.value = Success() }, { throwable -> _saveData.value = Error(errorMessage = throwable.getServerErrorMessage()) diff --git a/app/src/main/java/com/keylesspalace/tusky/worker/PruneCacheWorker.kt b/app/src/main/java/com/keylesspalace/tusky/worker/PruneCacheWorker.kt index b735ddca2d..8426630a3c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/worker/PruneCacheWorker.kt +++ b/app/src/main/java/com/keylesspalace/tusky/worker/PruneCacheWorker.kt @@ -29,13 +29,14 @@ import com.keylesspalace.tusky.components.systemnotifications.NotificationHelper import com.keylesspalace.tusky.components.systemnotifications.NotificationHelper.NOTIFICATION_ID_PRUNE_CACHE import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.DatabaseCleaner +import com.keylesspalace.tusky.util.deleteStaleCachedMedia import dagger.assisted.Assisted import dagger.assisted.AssistedInject /** Prune the database cache of old statuses. */ @HiltWorker class PruneCacheWorker @AssistedInject constructor( - @Assisted appContext: Context, + @Assisted private val appContext: Context, @Assisted workerParams: WorkerParameters, private val databaseCleaner: DatabaseCleaner, private val accountManager: AccountManager @@ -50,6 +51,9 @@ class PruneCacheWorker @AssistedInject constructor( Log.d(TAG, "Pruning database using account ID: ${account.id}") databaseCleaner.cleanupOldData(account.id, MAX_HOMETIMELINE_ITEMS_IN_CACHE, MAX_NOTIFICATIONS_IN_CACHE) } + + deleteStaleCachedMedia(appContext.getExternalFilesDir("Tusky")) + return Result.success() } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index cf11607519..39f7652a47 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -71,7 +71,6 @@ Muted users Blocked users Hidden domains - Re-login for push notifications Follow requests Edit your profile Drafts @@ -768,10 +767,6 @@ Saving draft… - Re-login all accounts to enable push notification support. - In order to use push notifications via UnifiedPush, Tusky needs permission to subscribe to notifications on your Mastodon server. This requires a re-login to change the OAuth scopes granted to Tusky. Using the re-login option here or in "Account preferences" will preserve all of your local drafts and cache. - You have re-logged into your current account to grant push subscription permission to Tusky. However, you still have other accounts that have not been migrated this way. Switch to them and re-login one by one in order to enable UnifiedPush notifications support. - Delete this scheduled post? By logging in you agree to the rules of %1$s. diff --git a/app/src/test/java/com/keylesspalace/tusky/MainActivityTest.kt b/app/src/test/java/com/keylesspalace/tusky/MainActivityTest.kt index 94247c2f5f..0de8ce851b 100644 --- a/app/src/test/java/com/keylesspalace/tusky/MainActivityTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/MainActivityTest.kt @@ -3,7 +3,10 @@ package com.keylesspalace.tusky import android.app.Activity import android.app.NotificationManager import android.content.ComponentName +import android.content.Context import android.content.Intent +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import androidx.viewpager2.widget.ViewPager2 @@ -12,13 +15,15 @@ import at.connyduck.calladapter.networkresult.NetworkResult import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.components.accountlist.AccountListActivity import com.keylesspalace.tusky.components.systemnotifications.NotificationHelper +import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.entity.AccountEntity import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.Notification import com.keylesspalace.tusky.entity.TimelineAccount +import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.util.getSerializableExtraCompat import java.util.Date -import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.flow.MutableStateFlow import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull @@ -129,17 +134,20 @@ class MainActivityTest { private fun startMainActivity(intent: Intent): Activity { val controller = Robolectric.buildActivity(MainActivity::class.java, intent) val activity = controller.get() - activity.eventHub = EventHub() - activity.accountManager = mock { + val eventHub = EventHub() + activity.eventHub = eventHub + val accountManager: AccountManager = mock { + on { accounts } doReturn listOf(accountEntity) + on { accountsFlow } doReturn MutableStateFlow(listOf(accountEntity)) on { activeAccount } doReturn accountEntity } - activity.draftsAlert = mock {} - activity.shareShortcutHelper = mock {} - activity.externalScope = TestScope() - activity.mastodonApi = mock { + activity.accountManager = accountManager + activity.draftsAlert = mock { } + val api: MastodonApi = mock { onBlocking { accountVerifyCredentials() } doReturn NetworkResult.success(account) onBlocking { announcements() } doReturn NetworkResult.success(emptyList()) } + activity.mastodonApi = api activity.preferences = mock(defaultAnswer = { when (it.method.returnType) { String::class.java -> "test" @@ -147,6 +155,20 @@ class MainActivityTest { else -> null } }) + val viewModel = MainViewModel( + context = mock { + on { getSystemService(Context.NOTIFICATION_SERVICE) } doReturn mock() + }, + api = api, + eventHub = eventHub, + accountManager = accountManager, + shareShortcutHelper = mock() + ) + val testViewModelFactory = viewModelFactory { + initializer { viewModel } + } + activity.viewModelProviderFactory = testViewModelFactory + controller.create().start() return activity } diff --git a/app/src/test/java/com/keylesspalace/tusky/components/compose/ComposeActivityTest.kt b/app/src/test/java/com/keylesspalace/tusky/components/compose/ComposeActivityTest.kt index 0f1fdce8cd..f2a08ca681 100644 --- a/app/src/test/java/com/keylesspalace/tusky/components/compose/ComposeActivityTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/components/compose/ComposeActivityTest.kt @@ -44,6 +44,7 @@ import com.squareup.moshi.adapter import java.util.Locale import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow import okhttp3.ResponseBody import okhttp3.ResponseBody.Companion.toResponseBody import org.junit.Assert.assertEquals @@ -108,6 +109,8 @@ class ComposeActivityTest { activity = controller.get() accountManagerMock = mock { + on { accounts } doReturn listOf(account) + on { accountsFlow } doReturn MutableStateFlow(listOf(account)) on { activeAccount } doReturn account } diff --git a/app/src/test/java/com/keylesspalace/tusky/db/MigrationsTest.kt b/app/src/test/java/com/keylesspalace/tusky/db/MigrationsTest.kt index 4db567e7de..3253f68509 100644 --- a/app/src/test/java/com/keylesspalace/tusky/db/MigrationsTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/db/MigrationsTest.kt @@ -7,6 +7,8 @@ import com.keylesspalace.tusky.di.StorageModule import com.keylesspalace.tusky.entity.Emoji import com.squareup.moshi.Moshi import com.squareup.moshi.Types +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Rule import org.junit.Test @@ -24,7 +26,7 @@ class MigrationsTest { ) @Test - fun testMigrations() { + fun testMigrations() = runTest { /** the db name must match the one in [StorageModule.providesDatabase] */ val db = migrationHelper.createDatabase("tuskyDB", 10) val moshi = Moshi.Builder().build() @@ -73,7 +75,7 @@ class MigrationsTest { Converters(moshi) ) - val account = roomDb.accountDao().loadAll().first() + val account = roomDb.accountDao().allAccounts().first().first() roomDb.close()