diff --git a/app/schemas/com.keylesspalace.tusky.db.AppDatabase/60.json b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/60.json new file mode 100644 index 000000000..cce72b226 --- /dev/null +++ b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/60.json @@ -0,0 +1,1046 @@ +{ + "formatVersion": 1, + "database": { + "version": 60, + "identityHash": "7b388f5965262a4e73569a8c9e41e6da", + "entities": [ + { + "tableName": "DraftEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `accountId` INTEGER NOT NULL, `inReplyToId` TEXT, `content` TEXT, `contentWarning` TEXT, `sensitive` INTEGER NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT NOT NULL, `poll` TEXT, `failedToSend` INTEGER NOT NULL, `failedToSendNew` INTEGER NOT NULL, `scheduledAt` TEXT, `language` TEXT, `statusId` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "contentWarning", + "columnName": "contentWarning", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sensitive", + "columnName": "sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "failedToSend", + "columnName": "failedToSend", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "failedToSendNew", + "columnName": "failedToSendNew", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "scheduledAt", + "columnName": "scheduledAt", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "statusId", + "columnName": "statusId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "AccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `domain` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `clientId` TEXT, `clientSecret` TEXT, `isActive` INTEGER NOT NULL, `accountId` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `profilePictureUrl` TEXT NOT NULL, `notificationsEnabled` INTEGER NOT NULL, `notificationsMentioned` INTEGER NOT NULL, `notificationsFollowed` INTEGER NOT NULL, `notificationsFollowRequested` INTEGER NOT NULL, `notificationsReblogged` INTEGER NOT NULL, `notificationsFavorited` INTEGER NOT NULL, `notificationsPolls` INTEGER NOT NULL, `notificationsSubscriptions` INTEGER NOT NULL, `notificationsSignUps` INTEGER NOT NULL, `notificationsUpdates` INTEGER NOT NULL, `notificationsReports` INTEGER NOT NULL, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `defaultPostLanguage` TEXT NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `alwaysOpenSpoiler` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `notificationMarkerId` TEXT NOT NULL DEFAULT '0', `emojis` TEXT NOT NULL, `tabPreferences` TEXT NOT NULL, `notificationsFilter` TEXT NOT NULL, `oauthScopes` TEXT NOT NULL, `unifiedDistributorName` TEXT, `unifiedPushUrl` TEXT NOT NULL, `pushPubKey` TEXT NOT NULL, `pushPrivKey` TEXT NOT NULL, `pushAuth` TEXT NOT NULL, `pushServerKey` TEXT NOT NULL, `lastVisibleHomeTimelineStatusId` TEXT, `locked` INTEGER NOT NULL DEFAULT 0, `hasDirectMessageBadge` INTEGER NOT NULL DEFAULT 0, `isShowHomeBoosts` INTEGER NOT NULL, `isShowHomeReplies` INTEGER NOT NULL, `isShowHomeSelfBoosts` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clientId", + "columnName": "clientId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "clientSecret", + "columnName": "clientSecret", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profilePictureUrl", + "columnName": "profilePictureUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationsEnabled", + "columnName": "notificationsEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsMentioned", + "columnName": "notificationsMentioned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFollowed", + "columnName": "notificationsFollowed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFollowRequested", + "columnName": "notificationsFollowRequested", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsReblogged", + "columnName": "notificationsReblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFavorited", + "columnName": "notificationsFavorited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsPolls", + "columnName": "notificationsPolls", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsSubscriptions", + "columnName": "notificationsSubscriptions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsSignUps", + "columnName": "notificationsSignUps", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsUpdates", + "columnName": "notificationsUpdates", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsReports", + "columnName": "notificationsReports", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationSound", + "columnName": "notificationSound", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationVibration", + "columnName": "notificationVibration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationLight", + "columnName": "notificationLight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultPostPrivacy", + "columnName": "defaultPostPrivacy", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultMediaSensitivity", + "columnName": "defaultMediaSensitivity", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultPostLanguage", + "columnName": "defaultPostLanguage", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "alwaysShowSensitiveMedia", + "columnName": "alwaysShowSensitiveMedia", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "alwaysOpenSpoiler", + "columnName": "alwaysOpenSpoiler", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaPreviewEnabled", + "columnName": "mediaPreviewEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastNotificationId", + "columnName": "lastNotificationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationMarkerId", + "columnName": "notificationMarkerId", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'0'" + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tabPreferences", + "columnName": "tabPreferences", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationsFilter", + "columnName": "notificationsFilter", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "oauthScopes", + "columnName": "oauthScopes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unifiedDistributorName", + "columnName": "unifiedDistributorName", + "affinity": "TEXT", + "notNull": false + }, + { + "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, 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 + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "instance" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "TimelineStatusEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `url` TEXT, `timelineUserId` INTEGER NOT NULL, `authorServerId` TEXT, `inReplyToId` TEXT, `inReplyToAccountId` TEXT, `content` TEXT, `createdAt` INTEGER NOT NULL, `editedAt` INTEGER, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` INTEGER NOT NULL, `repliesCount` INTEGER NOT NULL, `reblogged` INTEGER NOT NULL, `bookmarked` INTEGER NOT NULL, `favourited` INTEGER NOT NULL, `sensitive` INTEGER NOT NULL, `spoilerText` TEXT NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT, `mentions` TEXT, `tags` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` TEXT, `poll` TEXT, `muted` INTEGER, `expanded` INTEGER NOT NULL, `contentCollapsed` INTEGER NOT NULL, `contentShowing` INTEGER NOT NULL, `pinned` INTEGER NOT NULL, `card` TEXT, `language` TEXT, `filtered` TEXT, PRIMARY KEY(`serverId`, `timelineUserId`), FOREIGN KEY(`authorServerId`, `timelineUserId`) REFERENCES `TimelineAccountEntity`(`serverId`, `timelineUserId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorServerId", + "columnName": "authorServerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToAccountId", + "columnName": "inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "editedAt", + "columnName": "editedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogsCount", + "columnName": "reblogsCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favouritesCount", + "columnName": "favouritesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "repliesCount", + "columnName": "repliesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reblogged", + "columnName": "reblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookmarked", + "columnName": "bookmarked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favourited", + "columnName": "favourited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sensitive", + "columnName": "sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "spoilerText", + "columnName": "spoilerText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "mentions", + "columnName": "mentions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "application", + "columnName": "application", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogServerId", + "columnName": "reblogServerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogAccountId", + "columnName": "reblogAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "muted", + "columnName": "muted", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "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": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "timelineUserId" + ] + }, + "indices": [ + { + "name": "index_TimelineStatusEntity_authorServerId_timelineUserId", + "unique": false, + "columnNames": [ + "authorServerId", + "timelineUserId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TimelineStatusEntity_authorServerId_timelineUserId` ON `${TABLE_NAME}` (`authorServerId`, `timelineUserId`)" + } + ], + "foreignKeys": [ + { + "table": "TimelineAccountEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "authorServerId", + "timelineUserId" + ], + "referencedColumns": [ + "serverId", + "timelineUserId" + ] + } + ] + }, + { + "tableName": "TimelineAccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `timelineUserId` INTEGER NOT NULL, `localUsername` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `url` TEXT NOT NULL, `avatar` TEXT NOT NULL, `emojis` TEXT NOT NULL, `bot` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `timelineUserId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localUsername", + "columnName": "localUsername", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "avatar", + "columnName": "avatar", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bot", + "columnName": "bot", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "timelineUserId" + ] + }, + "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": [] + } + ], + "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, '7b388f5965262a4e73569a8c9e41e6da')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt b/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt index 3adb29f2a..6f8c29f9c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt @@ -81,9 +81,7 @@ import com.keylesspalace.tusky.components.compose.ComposeActivity.Companion.canH import com.keylesspalace.tusky.components.drafts.DraftsActivity import com.keylesspalace.tusky.components.login.LoginActivity import com.keylesspalace.tusky.components.notifications.NotificationHelper -import com.keylesspalace.tusky.components.notifications.disableAllNotifications -import com.keylesspalace.tusky.components.notifications.enablePushNotificationsWithFallback -import com.keylesspalace.tusky.components.notifications.showMigrationNoticeIfNecessary +import com.keylesspalace.tusky.components.notifications.PushNotificationManager import com.keylesspalace.tusky.components.preference.PreferencesActivity import com.keylesspalace.tusky.components.scheduled.ScheduledStatusActivity import com.keylesspalace.tusky.components.search.SearchActivity @@ -175,6 +173,9 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje @ApplicationScope lateinit var externalScope: CoroutineScope + @Inject + lateinit var pushNotificationManager: PushNotificationManager + private val binding by viewBinding(ActivityMainBinding::inflate) private lateinit var header: AccountHeaderView @@ -1050,19 +1051,22 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje this ) - // Setup push notifications - showMigrationNoticeIfNecessary( - this, - binding.mainCoordinatorLayout, - binding.composeButton, - accountManager - ) + // Setup notifications if (NotificationHelper.areNotificationsEnabled(this, accountManager)) { + pushNotificationManager.showMigrationNoticeIfNecessary(binding.mainCoordinatorLayout, binding.composeButton) + lifecycleScope.launch { - enablePushNotificationsWithFallback(this@MainActivity, mastodonApi, accountManager) + if (pushNotificationManager.canEnablePushNotifications()) { + pushNotificationManager.enablePushNotifications() + } else { + NotificationHelper.enablePullNotifications(this@MainActivity) + } } } else { - disableAllNotifications(this, accountManager) + NotificationHelper.disablePullNotifications(this) + lifecycleScope.launch { + pushNotificationManager.disableAllNotifications() + } } updateProfiles() diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationFetcher.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationFetcher.kt index 91e97a41d..013a9b2f8 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationFetcher.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationFetcher.kt @@ -51,8 +51,12 @@ class NotificationFetcher @Inject constructor( private val context: Context, private val eventHub: EventHub ) { - suspend fun fetchAndShow() { + suspend fun fetchAndShow(accountId: Long?) { for (account in accountManager.getAllAccountsOrderedByActive()) { + if (accountId != null && account.id != accountId) { + continue + } + if (account.notificationsEnabled) { try { val notificationManager = context.getSystemService( diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/PushNotificationHelper.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/PushNotificationHelper.kt deleted file mode 100644 index c89823e6d..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/PushNotificationHelper.kt +++ /dev/null @@ -1,262 +0,0 @@ -/* Copyright 2022 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 . */ - -@file:JvmName("PushNotificationHelper") - -package com.keylesspalace.tusky.components.notifications - -import android.app.NotificationManager -import android.content.Context -import android.os.Build -import android.util.Log -import android.view.View -import androidx.appcompat.app.AlertDialog -import androidx.preference.PreferenceManager -import at.connyduck.calladapter.networkresult.onFailure -import at.connyduck.calladapter.networkresult.onSuccess -import com.google.android.material.snackbar.Snackbar -import com.keylesspalace.tusky.R -import com.keylesspalace.tusky.components.login.LoginActivity -import com.keylesspalace.tusky.db.AccountEntity -import com.keylesspalace.tusky.db.AccountManager -import com.keylesspalace.tusky.entity.Notification -import com.keylesspalace.tusky.network.MastodonApi -import com.keylesspalace.tusky.util.CryptoUtil -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -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) { - AlertDialog.Builder(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, - accountManager: AccountManager, - account: AccountEntity -) { - if (isUnifiedPushNotificationEnabledForAccount(account)) { - // Already registered, update the subscription to match notification settings - updateUnifiedPushSubscription(context, api, accountManager, account) - } else { - UnifiedPush.registerAppWithDialog( - context, - account.id.toString(), - features = arrayListOf(UnifiedPush.FEATURE_BYTES_MESSAGE) - ) - } -} - -fun disableUnifiedPushNotificationsForAccount(context: Context, account: AccountEntity) { - if (!isUnifiedPushNotificationEnabledForAccount(account)) { - // Not registered - return - } - - UnifiedPush.unregisterApp(context, account.id.toString()) -} - -fun isUnifiedPushNotificationEnabledForAccount(account: AccountEntity): Boolean = - account.unifiedPushUrl.isNotEmpty() - -private 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)) { - // No UP distributors - NotificationHelper.enablePullNotifications(context) - return - } - - val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - - accountManager.accounts.forEach { - val notificationGroupEnabled = Build.VERSION.SDK_INT < 28 || - nm.getNotificationChannelGroup(it.identifier)?.isBlocked == false - val shouldEnable = it.notificationsEnabled && notificationGroupEnabled - - if (shouldEnable) { - enableUnifiedPushNotificationsForAccount(context, api, accountManager, it) - } else { - disableUnifiedPushNotificationsForAccount(context, it) - } - } -} - -private fun disablePushNotifications(context: Context, accountManager: AccountManager) { - accountManager.accounts.forEach { - disableUnifiedPushNotificationsForAccount(context, it) - } -} - -fun disableAllNotifications(context: Context, accountManager: AccountManager) { - disablePushNotifications(context, accountManager) - NotificationHelper.disablePullNotifications(context) -} - -private fun buildSubscriptionData(context: Context, account: AccountEntity): Map = - buildMap { - val notificationManager = context.getSystemService( - Context.NOTIFICATION_SERVICE - ) as NotificationManager - Notification.Type.visibleTypes.forEach { - put( - "data[alerts][${it.presentation}]", - NotificationHelper.filterNotification(notificationManager, account, it) - ) - } - } - -// Called by UnifiedPush callback -suspend fun registerUnifiedPushEndpoint( - context: Context, - api: MastodonApi, - accountManager: AccountManager, - account: AccountEntity, - endpoint: String -) = withContext(Dispatchers.IO) { - // Generate a prime256v1 key pair for WebPush - // Decryption is unimplemented for now, since Mastodon uses an old WebPush - // standard which does not send needed information for decryption in the payload - // This makes it not directly compatible with UnifiedPush - // As of now, we use it purely as a way to trigger a pull - val keyPair = CryptoUtil.generateECKeyPair(CryptoUtil.CURVE_PRIME256_V1) - val auth = CryptoUtil.secureRandomBytesEncoded(16) - - api.subscribePushNotifications( - "Bearer ${account.accessToken}", - account.domain, - endpoint, - keyPair.pubkey, - auth, - buildSubscriptionData(context, account) - ).onFailure { throwable -> - Log.w(TAG, "Error setting push endpoint for account ${account.id}", throwable) - disableUnifiedPushNotificationsForAccount(context, account) - }.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) - } -} - -// Synchronize the enabled / disabled state of notifications with server-side subscription -suspend fun updateUnifiedPushSubscription( - context: Context, - api: MastodonApi, - accountManager: AccountManager, - account: AccountEntity -) { - withContext(Dispatchers.IO) { - api.updatePushNotificationSubscription( - "Bearer ${account.accessToken}", - account.domain, - buildSubscriptionData(context, account) - ).onSuccess { - Log.d(TAG, "UnifiedPush subscription updated for account ${account.id}") - - account.pushServerKey = it.serverKey - accountManager.saveAccount(account) - } - } -} - -suspend fun unregisterUnifiedPushEndpoint( - api: MastodonApi, - accountManager: AccountManager, - account: AccountEntity -) { - withContext(Dispatchers.IO) { - api.unsubscribePushNotifications("Bearer ${account.accessToken}", account.domain) - .onFailure { throwable -> - Log.w(TAG, "Error unregistering push endpoint for account " + account.id, throwable) - } - .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) - } - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/PushNotificationManager.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/PushNotificationManager.kt new file mode 100644 index 000000000..b852af494 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/PushNotificationManager.kt @@ -0,0 +1,311 @@ +package com.keylesspalace.tusky.components.notifications + +import android.app.NotificationManager +import com.keylesspalace.tusky.db.AccountManager +import android.content.Context +import android.content.SharedPreferences +import android.os.Build +import android.util.Log +import android.view.View +import androidx.appcompat.app.AlertDialog +import androidx.preference.Preference +import at.connyduck.calladapter.networkresult.fold +import at.connyduck.calladapter.networkresult.onFailure +import at.connyduck.calladapter.networkresult.onSuccess +import com.google.android.material.snackbar.Snackbar +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.components.login.LoginActivity +import com.keylesspalace.tusky.db.AccountEntity +import com.keylesspalace.tusky.entity.Notification +import com.keylesspalace.tusky.entity.NotificationSubscribeResult +import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.util.CryptoUtil +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.unifiedpush.android.connector.UnifiedPush +import retrofit2.HttpException +import javax.inject.Inject + +// TODO architecture-wise: the NotificationHelper should probably be a NotificationManager which either uses +// pull or push notifications (two detail implementations?). +// You can see current problems for example in the old NotificationPreferencesFragment.onCreatePreferences() +// which only would use pull notifications if the notifications option is enabled. +class PushNotificationManager @Inject constructor( + private val mastodonApi: MastodonApi, + private val accountManager: AccountManager, + private val sharedPreferences: SharedPreferences, + private val context: Context +): Preference.SummaryProvider { + + companion object { + const val TAG = "PushNotificationManager" + private const val KEY_PUSH_MIGRATION_NOTICE_DISMISSED = "migration_notice_dismissed" + } + + // TODO? must be changed/extended when distributors are installed or uninstalled on-the-fly? + // Or there must be an "restart app fully" possibility. + private val distributors: List = UnifiedPush.getDistributors(context) + + private fun isUnifiedPushAvailable(): Boolean { + return distributors.isNotEmpty() + } + + // TODO! there should be an actual decision (possibility) to say "I don't want to use push notifications for Tusky". + + fun canEnablePushNotifications(): Boolean = + isUnifiedPushAvailable() && !anyAccountNeedsMigration() + + fun hasPushNotificationsEnabled(account: AccountEntity): Boolean = + isUnifiedPushAvailable() && !account.unifiedDistributorName.isNullOrEmpty() + + suspend fun enablePushNotifications() { + if (!canEnablePushNotifications()) { + return + } + + val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + accountManager.accounts.forEach { + val notificationGroupEnabled = Build.VERSION.SDK_INT < 28 || + nm.getNotificationChannelGroup(it.identifier)?.isBlocked == false + val shouldEnable = it.notificationsEnabled && notificationGroupEnabled + + if (shouldEnable) { + enableUnifiedPushNotificationsForAccount(it) + } else { + disableUnifiedPushNotificationsForAccount(it) + } + } + } + + private suspend fun enableUnifiedPushNotificationsForAccount(account: AccountEntity) { + // TODO/NOTE these api request(s) here take quite some time (100-1000ms each for GET for my 3 instances) + + val currentSubscription = getActiveSubscription(account) + + if (currentSubscription != null && hasActiveDistributor(account)) { + val alertData = buildAlertsMap(account) + + if (alertData != currentSubscription.alerts) { + // Update the subscription to match notification settings + updateUnifiedPushSubscription(account) + } + } else { + // When changing the local UP distributor this is necessary first to enable the following callbacks (i. e. onNewEndpoint); + // make sure this is done in any inconsistent case (is not too often and doesn't hurt). + unregisterUnifiedPushEndpoint(account) + + UnifiedPush.registerAppWithDialog(context, account.id.toString(), features = arrayListOf(UnifiedPush.FEATURE_BYTES_MESSAGE)) + // TODO? if this does not result in a call to registerUnifiedPushEndpoint, something has failed + } + } + + private suspend fun getActiveSubscription(account: AccountEntity): NotificationSubscribeResult? { + mastodonApi.pushNotificationSubscription( + "Bearer ${account.accessToken}", + account.domain + ).fold({ + if (account.unifiedPushUrl.isNotEmpty() && it.endpoint != account.unifiedPushUrl) { + Log.w(TAG, "Server push endpoint does not match previously registered one: "+it.endpoint+" vs. "+account.unifiedPushUrl) + // TODO there should be a user information or at least an occurrence log entry + + return null + + // TODO / NOTE this case could also happen regularly if you use the same account on two different devices + // the server will only support (?) on subscription but you will need two for two devices (?) + } + + return it + }, { + if (!(it is HttpException && it.code() == 404)) { + Log.e(TAG, "Cannot get push subscription for account " + account.id + ": " + it.message, it) + + return null + } + + // else this is alright; there is no subscription on server + return null + }) + } + + suspend fun disableUnifiedPushNotificationsForAccount(account: AccountEntity) { + if (account.unifiedDistributorName == null) { + return + } + + unregisterUnifiedPushEndpoint(account) + + // this probably does nothing (distributor to handle this is missing) + UnifiedPush.unregisterApp(context, account.id.toString()) + } + + private fun hasActiveDistributor(account: AccountEntity): Boolean { + if (account.unifiedDistributorName.isNullOrEmpty()) { + return false + } + + val distributors = UnifiedPush.getDistributors(context) + + return distributors.find { it == account.unifiedDistributorName } != null + } + + private fun getDistributorUsedByApp(): String? { + return UnifiedPush.getDistributor(context).ifEmpty { null } + } + + suspend fun disableAllNotifications() { + accountManager.accounts.forEach { + disableUnifiedPushNotificationsForAccount(it) + } + } + + private fun buildAlertsMap(account: AccountEntity): Map = + buildMap { + val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + Notification.Type.visibleTypes.forEach { + put(it.presentation, NotificationHelper.filterNotification(notificationManager, account, it)) + } + } + + private fun buildAlertSubscriptionData(account: AccountEntity): Map = + buildAlertsMap(account).mapKeys { "data[alerts][${it.key}]" } + + // Called by UnifiedPush callback + suspend fun registerUnifiedPushEndpoint( + account: AccountEntity, + endpoint: String + ) = withContext(Dispatchers.IO) { + // Generate a prime256v1 key pair for WebPush + // Decryption is unimplemented for now, since Mastodon uses an old WebPush + // standard which does not send needed information for decryption in the payload + // This makes it not directly compatible with UnifiedPush + // As of now, we use it purely as a way to trigger a pull + val keyPair = CryptoUtil.generateECKeyPair(CryptoUtil.CURVE_PRIME256_V1) + val auth = CryptoUtil.secureRandomBytesEncoded(16) + + mastodonApi.subscribePushNotifications( + "Bearer ${account.accessToken}", + account.domain, + endpoint, + keyPair.pubkey, + auth, + buildAlertSubscriptionData(account) + ).onFailure { throwable -> + Log.w(TAG, "Error setting push endpoint for account ${account.id}", throwable) + disableUnifiedPushNotificationsForAccount(account) + }.onSuccess { + Log.d(TAG, "UnifiedPush registration succeeded for account ${account.id}") + + val distributor = UnifiedPush.getDistributor(context) + + // TODO? none of these are used ever again (except distributor name and endpoint) + account.unifiedDistributorName = distributor + account.pushPubKey = keyPair.pubkey + account.pushPrivKey = keyPair.privKey + account.pushAuth = auth + account.pushServerKey = it.serverKey + account.unifiedPushUrl = endpoint + + accountManager.saveAccount(account) + } + } + + // Synchronize the enabled / disabled state of notifications with server-side subscription + suspend fun updateUnifiedPushSubscription(account: AccountEntity) { + withContext(Dispatchers.IO) { + val alertsData = buildAlertSubscriptionData(account) + + mastodonApi.updatePushNotificationSubscription( + "Bearer ${account.accessToken}", + account.domain, + alertsData + ).onSuccess { + Log.d(TAG, "UnifiedPush subscription updated for account ${account.id}") + + account.pushServerKey = it.serverKey + accountManager.saveAccount(account) + } + } + } + + suspend fun unregisterUnifiedPushEndpoint(account: AccountEntity) { + withContext(Dispatchers.IO) { + // NOTE this is also possible (successful) when there is no subscription present on the server. + + mastodonApi.unsubscribePushNotifications("Bearer ${account.accessToken}", account.domain) + .onFailure { throwable -> + Log.w(TAG, "Error unregistering push endpoint for account " + account.id, throwable) + } + .onSuccess { + Log.d(TAG, "UnifiedPush unregistration succeeded for account " + account.id) + + account.unifiedDistributorName = null + account.unifiedPushUrl = "" + account.pushServerKey = "" + account.pushAuth = "" + account.pushPrivKey = "" + account.pushPubKey = "" + + accountManager.saveAccount(account) + } + } + } + + // TODO reduce this "migration feature" here; the code should probably also always check for "push" in the + // authorization as a normal feature - it could be missing by intent? + + private fun anyAccountNeedsMigration(): Boolean = + accountManager.accounts.any(::accountNeedsMigration) + + private fun accountNeedsMigration(account: AccountEntity): Boolean = + !account.oauthScopes.contains("push") + + fun currentAccountNeedsMigration(): Boolean = + accountManager.activeAccount?.let(::accountNeedsMigration) ?: false + + fun showMigrationNoticeIfNecessary( + parent: View, + anchorView: View? + ) { + // No point showing anything if we cannot enable it + if (!isUnifiedPushAvailable()) return + if (!anyAccountNeedsMigration()) return + + if (sharedPreferences.getBoolean(KEY_PUSH_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() } + .show() + } + + private fun showMigrationExplanationDialog() { + AlertDialog.Builder(context).apply { + // TODO what if another account needs migration? Only finally dismissing is possible? + + if (currentAccountNeedsMigration()) { + 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, _ -> + // NOTE there is a corresponding preference in AccountPreferencesFragment (only depending on currentAccountNeedsMigration()). + sharedPreferences.edit().putBoolean(KEY_PUSH_MIGRATION_NOTICE_DISMISSED, true).apply() + dialog.dismiss() + } + show() + } + } + + override fun provideSummary(preference: Preference): CharSequence? { + return when(val distributor = getDistributorUsedByApp()) { + "io.heckel.ntfy" -> "NTFY" + "org.unifiedpush.distributor.fcm" -> "UP-FCM" + "org.unifiedpush.distributor.nextpush " -> "NextPush" + else -> distributor + } + } +} 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 b9f77a1db..1667f1d8d 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 @@ -34,7 +34,7 @@ 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.notifications.currentAccountNeedsMigration +import com.keylesspalace.tusky.components.notifications.PushNotificationManager import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.entity.Account @@ -75,6 +75,9 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable { @Inject lateinit var accountPreferenceDataStore: AccountPreferenceDataStore + @Inject + lateinit var pushNotificationManager: PushNotificationManager + private val iconSize by unsafeLazy { resources.getDimensionPixelSize( R.dimen.preference_icon_size @@ -151,7 +154,7 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable { } } - if (currentAccountNeedsMigration(accountManager)) { + if (pushNotificationManager.currentAccountNeedsMigration()) { preference { setTitle(R.string.title_migration_relogin) setIcon(R.drawable.ic_logout) 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 a7dc4e92e..f4e86279a 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 @@ -19,6 +19,7 @@ import android.os.Bundle import androidx.preference.Preference import androidx.preference.PreferenceFragmentCompat import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.components.notifications.PushNotificationManager import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.entity.Notification @@ -49,6 +50,9 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable { @Inject lateinit var localeManager: LocaleManager + @Inject + lateinit var pushNotificationManager: PushNotificationManager + private val iconSize by unsafeLazy { resources.getDimensionPixelSize( R.dimen.preference_icon_size @@ -305,6 +309,15 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable { summaryProvider = ProxyPreferencesFragment.SummaryProvider } } + + if (pushNotificationManager.canEnablePushNotifications()) { + preferenceCategory(R.string.pref_title_push_notifications) { + preference { + setTitle(R.string.pref_title_push_notifications_distributor) + summaryProvider = pushNotificationManager + } + } + } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/db/AccountEntity.kt b/app/src/main/java/com/keylesspalace/tusky/db/AccountEntity.kt index 40098593c..3c99c544a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/AccountEntity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/AccountEntity.kt @@ -92,6 +92,7 @@ data class AccountEntity( // 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 unifiedDistributorName: String? = null, // TODO! there can be only one distributor per context (app); save as setting? var unifiedPushUrl: String = "", var pushPubKey: String = "", var pushPrivKey: String = "", 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 39261cb55..5d43be75f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java +++ b/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java @@ -44,14 +44,15 @@ import java.io.File; }, // Note: Starting with version 54, database versions in Tusky are always even. // This is to reserve odd version numbers for use by forks. - version = 58, + version = 60, autoMigrations = { @AutoMigration(from = 48, to = 49), @AutoMigration(from = 49, to = 50, spec = AppDatabase.MIGRATION_49_50.class), @AutoMigration(from = 50, to = 51), @AutoMigration(from = 51, to = 52), @AutoMigration(from = 53, to = 54), // hasDirectMessageBadge in AccountEntity - @AutoMigration(from = 56, to = 58) // translationEnabled in InstanceEntity/InstanceInfoEntity + @AutoMigration(from = 56, to = 58), // translationEnabled in InstanceEntity/InstanceInfoEntity + @AutoMigration(from = 58, to = 60) // AccountEntity gets a 'unifiedDistributorName' } ) public abstract class AppDatabase extends RoomDatabase { diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/NotificationSubscribeResult.kt b/app/src/main/java/com/keylesspalace/tusky/entity/NotificationSubscribeResult.kt index d0d84b93f..862debfb4 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/NotificationSubscribeResult.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/NotificationSubscribeResult.kt @@ -22,5 +22,6 @@ import com.squareup.moshi.JsonClass data class NotificationSubscribeResult( val id: Int, val endpoint: String, + val alerts: Map, @Json(name = "server_key") val serverKey: String ) diff --git a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt index 5498f5382..3874c2e5b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt +++ b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt @@ -660,6 +660,12 @@ interface MastodonApi { @Field("comment") note: String ): NetworkResult + @GET("api/v1/push/subscription") + suspend fun pushNotificationSubscription( + @Header("Authorization") auth: String, + @Header(DOMAIN_HEADER) domain: String + ): NetworkResult + @FormUrlEncoded @POST("api/v1/push/subscription") suspend fun subscribePushNotifications( 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 a2656d0f1..84d7864e7 100644 --- a/app/src/main/java/com/keylesspalace/tusky/receiver/NotificationBlockStateBroadcastReceiver.kt +++ b/app/src/main/java/com/keylesspalace/tusky/receiver/NotificationBlockStateBroadcastReceiver.kt @@ -20,9 +20,7 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.os.Build -import com.keylesspalace.tusky.components.notifications.canEnablePushNotifications -import com.keylesspalace.tusky.components.notifications.isUnifiedPushNotificationEnabledForAccount -import com.keylesspalace.tusky.components.notifications.updateUnifiedPushSubscription +import com.keylesspalace.tusky.components.notifications.PushNotificationManager import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.di.ApplicationScope import com.keylesspalace.tusky.network.MastodonApi @@ -32,11 +30,12 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.launch +/** + * This listens for changed notification channel settings (from the Android system) and updates an account's push + * subscription if active. + */ @DelicateCoroutinesApi class NotificationBlockStateBroadcastReceiver : BroadcastReceiver() { - @Inject - lateinit var mastodonApi: MastodonApi - @Inject lateinit var accountManager: AccountManager @@ -44,10 +43,13 @@ class NotificationBlockStateBroadcastReceiver : BroadcastReceiver() { @ApplicationScope lateinit var externalScope: CoroutineScope + @Inject + lateinit var notificationManager: PushNotificationManager + override fun onReceive(context: Context, intent: Intent) { AndroidInjection.inject(this, context) if (Build.VERSION.SDK_INT < 28) return - if (!canEnablePushNotifications(context, accountManager)) return + if (!notificationManager.canEnablePushNotifications()) return val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager @@ -63,16 +65,11 @@ class NotificationBlockStateBroadcastReceiver : BroadcastReceiver() { } ?: return accountManager.getAccountByIdentifier(gid)?.let { account -> - if (isUnifiedPushNotificationEnabledForAccount(account)) { + // TODO how did the changed (system) setting end up in the account object here (for example in field AccountEntity:notificationsMentioned)? + + if (notificationManager.hasPushNotificationsEnabled(account)) { // Update UnifiedPush notification subscription - externalScope.launch { - updateUnifiedPushSubscription( - context, - mastodonApi, - accountManager, - account - ) - } + externalScope.launch { notificationManager.updateUnifiedPushSubscription(account) } } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/receiver/UnifiedPushBroadcastReceiver.kt b/app/src/main/java/com/keylesspalace/tusky/receiver/UnifiedPushBroadcastReceiver.kt index 40fe48438..e132b5217 100644 --- a/app/src/main/java/com/keylesspalace/tusky/receiver/UnifiedPushBroadcastReceiver.kt +++ b/app/src/main/java/com/keylesspalace/tusky/receiver/UnifiedPushBroadcastReceiver.kt @@ -18,10 +18,10 @@ package com.keylesspalace.tusky.receiver import android.content.Context import android.content.Intent import android.util.Log +import androidx.work.Data import androidx.work.OneTimeWorkRequest import androidx.work.WorkManager -import com.keylesspalace.tusky.components.notifications.registerUnifiedPushEndpoint -import com.keylesspalace.tusky.components.notifications.unregisterUnifiedPushEndpoint +import com.keylesspalace.tusky.components.notifications.PushNotificationManager import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.di.ApplicationScope import com.keylesspalace.tusky.network.MastodonApi @@ -43,7 +43,7 @@ class UnifiedPushBroadcastReceiver : MessagingReceiver() { lateinit var accountManager: AccountManager @Inject - lateinit var mastodonApi: MastodonApi + lateinit var pushNotificationManager: PushNotificationManager @Inject @ApplicationScope @@ -57,21 +57,32 @@ class UnifiedPushBroadcastReceiver : MessagingReceiver() { override fun onMessage(context: Context, message: ByteArray, instance: String) { AndroidInjection.inject(this, context) Log.d(TAG, "New message received for account $instance") + + val data = Data.Builder() + data.putLong(NotificationWorker.KEY_ACCOUNT_ID, instance.toLongOrNull() ?: 0) + + val request = OneTimeWorkRequest + .Builder(NotificationWorker::class.java) + .setInputData(data.build()) + .build() + val workManager = WorkManager.getInstance(context) - val request = OneTimeWorkRequest.from(NotificationWorker::class.java) workManager.enqueue(request) + + // Do we want a rate limiting here? I think, yes. + // At least it puts network load on as long as the push notifications are not shown directly. + // And after that it should still be a setting. } override fun onNewEndpoint(context: Context, endpoint: String, instance: String) { AndroidInjection.inject(this, context) Log.d(TAG, "Endpoint available for account $instance: $endpoint") accountManager.getAccountById(instance.toLong())?.let { - externalScope.launch { - registerUnifiedPushEndpoint(context, mastodonApi, accountManager, it, endpoint) - } + externalScope.launch { pushNotificationManager.registerUnifiedPushEndpoint(it, endpoint) } } } + // TODO hm? override fun onRegistrationFailed(context: Context, instance: String) = Unit override fun onUnregistered(context: Context, instance: String) { @@ -79,7 +90,7 @@ class UnifiedPushBroadcastReceiver : MessagingReceiver() { Log.d(TAG, "Endpoint unregistered for account $instance") accountManager.getAccountById(instance.toLong())?.let { // It's fine if the account does not exist anymore -- that means it has been logged out - externalScope.launch { unregisterUnifiedPushEndpoint(mastodonApi, accountManager, it) } + externalScope.launch { pushNotificationManager.unregisterUnifiedPushEndpoint(it) } } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/usecase/LogoutUsecase.kt b/app/src/main/java/com/keylesspalace/tusky/usecase/LogoutUsecase.kt index 1bcab4179..19dac70c9 100644 --- a/app/src/main/java/com/keylesspalace/tusky/usecase/LogoutUsecase.kt +++ b/app/src/main/java/com/keylesspalace/tusky/usecase/LogoutUsecase.kt @@ -3,7 +3,7 @@ package com.keylesspalace.tusky.usecase import android.content.Context import com.keylesspalace.tusky.components.drafts.DraftHelper import com.keylesspalace.tusky.components.notifications.NotificationHelper -import com.keylesspalace.tusky.components.notifications.disableUnifiedPushNotificationsForAccount +import com.keylesspalace.tusky.components.notifications.PushNotificationManager import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.network.MastodonApi @@ -16,7 +16,8 @@ class LogoutUsecase @Inject constructor( private val db: AppDatabase, private val accountManager: AccountManager, private val draftHelper: DraftHelper, - private val shareShortcutHelper: ShareShortcutHelper + private val shareShortcutHelper: ShareShortcutHelper, + private val pushNotificationManager: PushNotificationManager ) { /** @@ -39,16 +40,16 @@ class LogoutUsecase @Inject constructor( } // disable push notifications - disableUnifiedPushNotificationsForAccount(context, activeAccount) + pushNotificationManager.disableUnifiedPushNotificationsForAccount(activeAccount) + + // clear notification channels + NotificationHelper.deleteNotificationChannelsForAccount(activeAccount, context) // disable pull notifications if (!NotificationHelper.areNotificationsEnabled(context, accountManager)) { NotificationHelper.disablePullNotifications(context) } - // clear notification channels - NotificationHelper.deleteNotificationChannelsForAccount(activeAccount, context) - // remove account from local AccountManager val otherAccountAvailable = accountManager.logActiveAccountOut() != null diff --git a/app/src/main/java/com/keylesspalace/tusky/worker/NotificationWorker.kt b/app/src/main/java/com/keylesspalace/tusky/worker/NotificationWorker.kt index a7362630c..50179ca5e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/worker/NotificationWorker.kt +++ b/app/src/main/java/com/keylesspalace/tusky/worker/NotificationWorker.kt @@ -40,7 +40,8 @@ class NotificationWorker( ) override suspend fun doWork(): Result { - notificationsFetcher.fetchAndShow() + val accountId = inputData.getLong(KEY_ACCOUNT_ID, 0).takeIf { it != 0L } + notificationsFetcher.fetchAndShow(accountId) return Result.success() } @@ -56,4 +57,8 @@ class NotificationWorker( return NotificationWorker(appContext, params, notificationsFetcher) } } + + companion object { + const val KEY_ACCOUNT_ID = "accountId" + } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 895288197..b864789ea 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -333,6 +333,9 @@ <not set> <invalid> + Push notifications + Distributor + Default post privacy Default posting language Always mark media as sensitive @@ -730,7 +733,7 @@ - Favorite/Boost/Follow notifications\n - Favorite/Boost count on posts\n - Follower/Post stats on profiles\n\n - Push-notifications will not be affected, but you can review your notification preferences manually. + Push notifications will not be affected, but you can review your notification preferences manually. Review Notifications Limit timeline notifications