mirror of https://github.com/tuskyapp/Tusky.git
Browse Source
Fixes #793. This is an implementation for push notifications based on UnifiedPush for Tusky. No push gateway (other than UP itself) is needed, since UnifiedPush is simple enough such that it can act as a catch-all endpoint for WebPush messages. When a UnifiedPush distributor is present on-device, we will by default register Tusky as a receiver; if no UnifiedPush distributor is available, then pull notifications are used as a fallback mechanism. Because WebPush messages are encrypted, and Mastodon does not send the keys and IV needed for decryption in the request body, for now the push handler simply acts as a trigger for the pre-existing NotificationWorker which is also used for pull notifications. Nevertheless, I have implemented proper key generation and storage, just in case we would like to implement full decryption support in the future when Mastodon upgrades to the latest WebPush encryption scheme that includes all information in the request body. For users with existing accounts, push notifications will not be enabled until all of the accounts have been re-logged in to grant the new push OAuth scope. A small prompt will be shown (until dismissed) as a Snackbar to explain to the user about this, and an option is added in Account Preferences to facilitate re-login without deleting local drafts and cache.pull/2480/head^2
20 changed files with 1490 additions and 24 deletions
@ -0,0 +1,857 @@
|
||||
{ |
||||
"formatVersion": 1, |
||||
"database": { |
||||
"version": 36, |
||||
"identityHash": "1b7461c291f67fe0b21f77b95de6a6be", |
||||
"entities": [ |
||||
{ |
||||
"tableName": "DraftEntity", |
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `accountId` INTEGER NOT NULL, `inReplyToId` TEXT, `content` TEXT, `contentWarning` TEXT, `sensitive` INTEGER NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT NOT NULL, `poll` TEXT, `failedToSend` INTEGER NOT NULL)", |
||||
"fields": [ |
||||
{ |
||||
"fieldPath": "id", |
||||
"columnName": "id", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "accountId", |
||||
"columnName": "accountId", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "inReplyToId", |
||||
"columnName": "inReplyToId", |
||||
"affinity": "TEXT", |
||||
"notNull": false |
||||
}, |
||||
{ |
||||
"fieldPath": "content", |
||||
"columnName": "content", |
||||
"affinity": "TEXT", |
||||
"notNull": false |
||||
}, |
||||
{ |
||||
"fieldPath": "contentWarning", |
||||
"columnName": "contentWarning", |
||||
"affinity": "TEXT", |
||||
"notNull": false |
||||
}, |
||||
{ |
||||
"fieldPath": "sensitive", |
||||
"columnName": "sensitive", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "visibility", |
||||
"columnName": "visibility", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "attachments", |
||||
"columnName": "attachments", |
||||
"affinity": "TEXT", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "poll", |
||||
"columnName": "poll", |
||||
"affinity": "TEXT", |
||||
"notNull": false |
||||
}, |
||||
{ |
||||
"fieldPath": "failedToSend", |
||||
"columnName": "failedToSend", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
} |
||||
], |
||||
"primaryKey": { |
||||
"columnNames": [ |
||||
"id" |
||||
], |
||||
"autoGenerate": true |
||||
}, |
||||
"indices": [], |
||||
"foreignKeys": [] |
||||
}, |
||||
{ |
||||
"tableName": "AccountEntity", |
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `domain` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `isActive` INTEGER NOT NULL, `accountId` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `profilePictureUrl` TEXT NOT NULL, `notificationsEnabled` INTEGER NOT NULL, `notificationsMentioned` INTEGER NOT NULL, `notificationsFollowed` INTEGER NOT NULL, `notificationsFollowRequested` INTEGER NOT NULL, `notificationsReblogged` INTEGER NOT NULL, `notificationsFavorited` INTEGER NOT NULL, `notificationsPolls` INTEGER NOT NULL, `notificationsSubscriptions` INTEGER NOT NULL, `notificationsSignUps` INTEGER NOT NULL, `notificationsUpdates` INTEGER NOT NULL, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `alwaysOpenSpoiler` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `activeNotifications` TEXT NOT NULL, `emojis` TEXT NOT NULL, `tabPreferences` TEXT NOT NULL, `notificationsFilter` TEXT NOT NULL, `oauthScopes` TEXT NOT NULL, `unifiedPushUrl` TEXT NOT NULL, `pushPubKey` TEXT NOT NULL, `pushPrivKey` TEXT NOT NULL, `pushAuth` TEXT NOT NULL, `pushServerKey` TEXT NOT NULL)", |
||||
"fields": [ |
||||
{ |
||||
"fieldPath": "id", |
||||
"columnName": "id", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "domain", |
||||
"columnName": "domain", |
||||
"affinity": "TEXT", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "accessToken", |
||||
"columnName": "accessToken", |
||||
"affinity": "TEXT", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "isActive", |
||||
"columnName": "isActive", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "accountId", |
||||
"columnName": "accountId", |
||||
"affinity": "TEXT", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "username", |
||||
"columnName": "username", |
||||
"affinity": "TEXT", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "displayName", |
||||
"columnName": "displayName", |
||||
"affinity": "TEXT", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "profilePictureUrl", |
||||
"columnName": "profilePictureUrl", |
||||
"affinity": "TEXT", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "notificationsEnabled", |
||||
"columnName": "notificationsEnabled", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "notificationsMentioned", |
||||
"columnName": "notificationsMentioned", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "notificationsFollowed", |
||||
"columnName": "notificationsFollowed", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "notificationsFollowRequested", |
||||
"columnName": "notificationsFollowRequested", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "notificationsReblogged", |
||||
"columnName": "notificationsReblogged", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "notificationsFavorited", |
||||
"columnName": "notificationsFavorited", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "notificationsPolls", |
||||
"columnName": "notificationsPolls", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "notificationsSubscriptions", |
||||
"columnName": "notificationsSubscriptions", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "notificationsSignUps", |
||||
"columnName": "notificationsSignUps", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "notificationsUpdates", |
||||
"columnName": "notificationsUpdates", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "notificationSound", |
||||
"columnName": "notificationSound", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "notificationVibration", |
||||
"columnName": "notificationVibration", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "notificationLight", |
||||
"columnName": "notificationLight", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "defaultPostPrivacy", |
||||
"columnName": "defaultPostPrivacy", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "defaultMediaSensitivity", |
||||
"columnName": "defaultMediaSensitivity", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "alwaysShowSensitiveMedia", |
||||
"columnName": "alwaysShowSensitiveMedia", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "alwaysOpenSpoiler", |
||||
"columnName": "alwaysOpenSpoiler", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "mediaPreviewEnabled", |
||||
"columnName": "mediaPreviewEnabled", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "lastNotificationId", |
||||
"columnName": "lastNotificationId", |
||||
"affinity": "TEXT", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "activeNotifications", |
||||
"columnName": "activeNotifications", |
||||
"affinity": "TEXT", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "emojis", |
||||
"columnName": "emojis", |
||||
"affinity": "TEXT", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "tabPreferences", |
||||
"columnName": "tabPreferences", |
||||
"affinity": "TEXT", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "notificationsFilter", |
||||
"columnName": "notificationsFilter", |
||||
"affinity": "TEXT", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"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 |
||||
} |
||||
], |
||||
"primaryKey": { |
||||
"columnNames": [ |
||||
"id" |
||||
], |
||||
"autoGenerate": true |
||||
}, |
||||
"indices": [ |
||||
{ |
||||
"name": "index_AccountEntity_domain_accountId", |
||||
"unique": true, |
||||
"columnNames": [ |
||||
"domain", |
||||
"accountId" |
||||
], |
||||
"orders": [], |
||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_AccountEntity_domain_accountId` ON `${TABLE_NAME}` (`domain`, `accountId`)" |
||||
} |
||||
], |
||||
"foreignKeys": [] |
||||
}, |
||||
{ |
||||
"tableName": "InstanceEntity", |
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`instance` TEXT NOT NULL, `emojiList` TEXT, `maximumTootCharacters` INTEGER, `maxPollOptions` INTEGER, `maxPollOptionLength` INTEGER, `minPollDuration` INTEGER, `maxPollDuration` INTEGER, `charactersReservedPerUrl` INTEGER, `version` TEXT, PRIMARY KEY(`instance`))", |
||||
"fields": [ |
||||
{ |
||||
"fieldPath": "instance", |
||||
"columnName": "instance", |
||||
"affinity": "TEXT", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "emojiList", |
||||
"columnName": "emojiList", |
||||
"affinity": "TEXT", |
||||
"notNull": false |
||||
}, |
||||
{ |
||||
"fieldPath": "maximumTootCharacters", |
||||
"columnName": "maximumTootCharacters", |
||||
"affinity": "INTEGER", |
||||
"notNull": false |
||||
}, |
||||
{ |
||||
"fieldPath": "maxPollOptions", |
||||
"columnName": "maxPollOptions", |
||||
"affinity": "INTEGER", |
||||
"notNull": false |
||||
}, |
||||
{ |
||||
"fieldPath": "maxPollOptionLength", |
||||
"columnName": "maxPollOptionLength", |
||||
"affinity": "INTEGER", |
||||
"notNull": false |
||||
}, |
||||
{ |
||||
"fieldPath": "minPollDuration", |
||||
"columnName": "minPollDuration", |
||||
"affinity": "INTEGER", |
||||
"notNull": false |
||||
}, |
||||
{ |
||||
"fieldPath": "maxPollDuration", |
||||
"columnName": "maxPollDuration", |
||||
"affinity": "INTEGER", |
||||
"notNull": false |
||||
}, |
||||
{ |
||||
"fieldPath": "charactersReservedPerUrl", |
||||
"columnName": "charactersReservedPerUrl", |
||||
"affinity": "INTEGER", |
||||
"notNull": false |
||||
}, |
||||
{ |
||||
"fieldPath": "version", |
||||
"columnName": "version", |
||||
"affinity": "TEXT", |
||||
"notNull": false |
||||
} |
||||
], |
||||
"primaryKey": { |
||||
"columnNames": [ |
||||
"instance" |
||||
], |
||||
"autoGenerate": false |
||||
}, |
||||
"indices": [], |
||||
"foreignKeys": [] |
||||
}, |
||||
{ |
||||
"tableName": "TimelineStatusEntity", |
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `url` TEXT, `timelineUserId` INTEGER NOT NULL, `authorServerId` TEXT, `inReplyToId` TEXT, `inReplyToAccountId` TEXT, `content` TEXT, `createdAt` INTEGER NOT NULL, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` INTEGER NOT NULL, `reblogged` INTEGER NOT NULL, `bookmarked` INTEGER NOT NULL, `favourited` INTEGER NOT NULL, `sensitive` INTEGER NOT NULL, `spoilerText` TEXT NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT, `mentions` TEXT, `tags` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` TEXT, `poll` TEXT, `muted` INTEGER, `expanded` INTEGER NOT NULL, `contentCollapsed` INTEGER NOT NULL, `contentShowing` INTEGER NOT NULL, `pinned` INTEGER NOT NULL, `card` 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": "emojis", |
||||
"columnName": "emojis", |
||||
"affinity": "TEXT", |
||||
"notNull": false |
||||
}, |
||||
{ |
||||
"fieldPath": "reblogsCount", |
||||
"columnName": "reblogsCount", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "favouritesCount", |
||||
"columnName": "favouritesCount", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "reblogged", |
||||
"columnName": "reblogged", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "bookmarked", |
||||
"columnName": "bookmarked", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "favourited", |
||||
"columnName": "favourited", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "sensitive", |
||||
"columnName": "sensitive", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "spoilerText", |
||||
"columnName": "spoilerText", |
||||
"affinity": "TEXT", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "visibility", |
||||
"columnName": "visibility", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "attachments", |
||||
"columnName": "attachments", |
||||
"affinity": "TEXT", |
||||
"notNull": false |
||||
}, |
||||
{ |
||||
"fieldPath": "mentions", |
||||
"columnName": "mentions", |
||||
"affinity": "TEXT", |
||||
"notNull": false |
||||
}, |
||||
{ |
||||
"fieldPath": "tags", |
||||
"columnName": "tags", |
||||
"affinity": "TEXT", |
||||
"notNull": false |
||||
}, |
||||
{ |
||||
"fieldPath": "application", |
||||
"columnName": "application", |
||||
"affinity": "TEXT", |
||||
"notNull": false |
||||
}, |
||||
{ |
||||
"fieldPath": "reblogServerId", |
||||
"columnName": "reblogServerId", |
||||
"affinity": "TEXT", |
||||
"notNull": false |
||||
}, |
||||
{ |
||||
"fieldPath": "reblogAccountId", |
||||
"columnName": "reblogAccountId", |
||||
"affinity": "TEXT", |
||||
"notNull": false |
||||
}, |
||||
{ |
||||
"fieldPath": "poll", |
||||
"columnName": "poll", |
||||
"affinity": "TEXT", |
||||
"notNull": false |
||||
}, |
||||
{ |
||||
"fieldPath": "muted", |
||||
"columnName": "muted", |
||||
"affinity": "INTEGER", |
||||
"notNull": false |
||||
}, |
||||
{ |
||||
"fieldPath": "expanded", |
||||
"columnName": "expanded", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "contentCollapsed", |
||||
"columnName": "contentCollapsed", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "contentShowing", |
||||
"columnName": "contentShowing", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "pinned", |
||||
"columnName": "pinned", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "card", |
||||
"columnName": "card", |
||||
"affinity": "TEXT", |
||||
"notNull": false |
||||
} |
||||
], |
||||
"primaryKey": { |
||||
"columnNames": [ |
||||
"serverId", |
||||
"timelineUserId" |
||||
], |
||||
"autoGenerate": false |
||||
}, |
||||
"indices": [ |
||||
{ |
||||
"name": "index_TimelineStatusEntity_authorServerId_timelineUserId", |
||||
"unique": false, |
||||
"columnNames": [ |
||||
"authorServerId", |
||||
"timelineUserId" |
||||
], |
||||
"orders": [], |
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_TimelineStatusEntity_authorServerId_timelineUserId` ON `${TABLE_NAME}` (`authorServerId`, `timelineUserId`)" |
||||
} |
||||
], |
||||
"foreignKeys": [ |
||||
{ |
||||
"table": "TimelineAccountEntity", |
||||
"onDelete": "NO ACTION", |
||||
"onUpdate": "NO ACTION", |
||||
"columns": [ |
||||
"authorServerId", |
||||
"timelineUserId" |
||||
], |
||||
"referencedColumns": [ |
||||
"serverId", |
||||
"timelineUserId" |
||||
] |
||||
} |
||||
] |
||||
}, |
||||
{ |
||||
"tableName": "TimelineAccountEntity", |
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `timelineUserId` INTEGER NOT NULL, `localUsername` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `url` TEXT NOT NULL, `avatar` TEXT NOT NULL, `emojis` TEXT NOT NULL, `bot` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `timelineUserId`))", |
||||
"fields": [ |
||||
{ |
||||
"fieldPath": "serverId", |
||||
"columnName": "serverId", |
||||
"affinity": "TEXT", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "timelineUserId", |
||||
"columnName": "timelineUserId", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "localUsername", |
||||
"columnName": "localUsername", |
||||
"affinity": "TEXT", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "username", |
||||
"columnName": "username", |
||||
"affinity": "TEXT", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "displayName", |
||||
"columnName": "displayName", |
||||
"affinity": "TEXT", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "url", |
||||
"columnName": "url", |
||||
"affinity": "TEXT", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "avatar", |
||||
"columnName": "avatar", |
||||
"affinity": "TEXT", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "emojis", |
||||
"columnName": "emojis", |
||||
"affinity": "TEXT", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "bot", |
||||
"columnName": "bot", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
} |
||||
], |
||||
"primaryKey": { |
||||
"columnNames": [ |
||||
"serverId", |
||||
"timelineUserId" |
||||
], |
||||
"autoGenerate": false |
||||
}, |
||||
"indices": [], |
||||
"foreignKeys": [] |
||||
}, |
||||
{ |
||||
"tableName": "ConversationEntity", |
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT NOT NULL, `accounts` TEXT NOT NULL, `unread` INTEGER NOT NULL, `s_id` TEXT NOT NULL, `s_url` TEXT, `s_inReplyToId` TEXT, `s_inReplyToAccountId` TEXT, `s_account` TEXT NOT NULL, `s_content` TEXT NOT NULL, `s_createdAt` INTEGER NOT NULL, `s_emojis` TEXT NOT NULL, `s_favouritesCount` INTEGER NOT NULL, `s_favourited` INTEGER NOT NULL, `s_bookmarked` INTEGER NOT NULL, `s_sensitive` INTEGER NOT NULL, `s_spoilerText` TEXT NOT NULL, `s_attachments` TEXT NOT NULL, `s_mentions` TEXT NOT NULL, `s_tags` TEXT, `s_showingHiddenContent` INTEGER NOT NULL, `s_expanded` INTEGER NOT NULL, `s_collapsed` INTEGER NOT NULL, `s_muted` INTEGER NOT NULL, `s_poll` TEXT, PRIMARY KEY(`id`, `accountId`))", |
||||
"fields": [ |
||||
{ |
||||
"fieldPath": "accountId", |
||||
"columnName": "accountId", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "id", |
||||
"columnName": "id", |
||||
"affinity": "TEXT", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "accounts", |
||||
"columnName": "accounts", |
||||
"affinity": "TEXT", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "unread", |
||||
"columnName": "unread", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "lastStatus.id", |
||||
"columnName": "s_id", |
||||
"affinity": "TEXT", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "lastStatus.url", |
||||
"columnName": "s_url", |
||||
"affinity": "TEXT", |
||||
"notNull": false |
||||
}, |
||||
{ |
||||
"fieldPath": "lastStatus.inReplyToId", |
||||
"columnName": "s_inReplyToId", |
||||
"affinity": "TEXT", |
||||
"notNull": false |
||||
}, |
||||
{ |
||||
"fieldPath": "lastStatus.inReplyToAccountId", |
||||
"columnName": "s_inReplyToAccountId", |
||||
"affinity": "TEXT", |
||||
"notNull": false |
||||
}, |
||||
{ |
||||
"fieldPath": "lastStatus.account", |
||||
"columnName": "s_account", |
||||
"affinity": "TEXT", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "lastStatus.content", |
||||
"columnName": "s_content", |
||||
"affinity": "TEXT", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "lastStatus.createdAt", |
||||
"columnName": "s_createdAt", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "lastStatus.emojis", |
||||
"columnName": "s_emojis", |
||||
"affinity": "TEXT", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "lastStatus.favouritesCount", |
||||
"columnName": "s_favouritesCount", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "lastStatus.favourited", |
||||
"columnName": "s_favourited", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "lastStatus.bookmarked", |
||||
"columnName": "s_bookmarked", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "lastStatus.sensitive", |
||||
"columnName": "s_sensitive", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "lastStatus.spoilerText", |
||||
"columnName": "s_spoilerText", |
||||
"affinity": "TEXT", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "lastStatus.attachments", |
||||
"columnName": "s_attachments", |
||||
"affinity": "TEXT", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "lastStatus.mentions", |
||||
"columnName": "s_mentions", |
||||
"affinity": "TEXT", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "lastStatus.tags", |
||||
"columnName": "s_tags", |
||||
"affinity": "TEXT", |
||||
"notNull": false |
||||
}, |
||||
{ |
||||
"fieldPath": "lastStatus.showingHiddenContent", |
||||
"columnName": "s_showingHiddenContent", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "lastStatus.expanded", |
||||
"columnName": "s_expanded", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "lastStatus.collapsed", |
||||
"columnName": "s_collapsed", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "lastStatus.muted", |
||||
"columnName": "s_muted", |
||||
"affinity": "INTEGER", |
||||
"notNull": true |
||||
}, |
||||
{ |
||||
"fieldPath": "lastStatus.poll", |
||||
"columnName": "s_poll", |
||||
"affinity": "TEXT", |
||||
"notNull": false |
||||
} |
||||
], |
||||
"primaryKey": { |
||||
"columnNames": [ |
||||
"id", |
||||
"accountId" |
||||
], |
||||
"autoGenerate": false |
||||
}, |
||||
"indices": [], |
||||
"foreignKeys": [] |
||||
} |
||||
], |
||||
"views": [], |
||||
"setupQueries": [ |
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", |
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '1b7461c291f67fe0b21f77b95de6a6be')" |
||||
] |
||||
} |
||||
} |
||||
@ -0,0 +1,220 @@
|
||||
/* 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 <http://www.gnu.org/licenses>. */ |
||||
|
||||
@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 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 |
||||
import retrofit2.HttpException |
||||
|
||||
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, 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).apply { |
||||
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()) |
||||
} |
||||
} |
||||
|
||||
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<String, Boolean> = |
||||
buildMap { |
||||
Notification.Type.asList.forEach { |
||||
put("data[alerts][${it.presentation}]", NotificationHelper.filterNotification(account, it, context)) |
||||
} |
||||
} |
||||
|
||||
// Called by UnifiedPush callback |
||||
suspend fun registerUnifiedPushEndpoint(context: Context, api: MastodonApi, accountManager: AccountManager, account: AccountEntity, endpoint: String) { |
||||
// 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) |
||||
|
||||
withContext(Dispatchers.IO) { |
||||
api.subscribePushNotifications( |
||||
"Bearer ${account.accessToken}", account.domain, |
||||
endpoint, keyPair.pubkey, auth, |
||||
buildSubscriptionData(context, account) |
||||
).onFailure { |
||||
Log.d(TAG, "Error setting push endpoint for account ${account.id}") |
||||
Log.d(TAG, Log.getStackTraceString(it)) |
||||
Log.d(TAG, (it as HttpException).response().toString()) |
||||
|
||||
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 { |
||||
Log.d(TAG, "Error unregistering push endpoint for account " + account.id) |
||||
Log.d(TAG, Log.getStackTraceString(it)) |
||||
Log.d(TAG, (it as HttpException).response().toString()) |
||||
} |
||||
.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) |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,24 @@
|
||||
/* 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 <http://www.gnu.org/licenses>. */ |
||||
|
||||
package com.keylesspalace.tusky.entity |
||||
|
||||
import com.google.gson.annotations.SerializedName |
||||
|
||||
data class NotificationSubscribeResult( |
||||
val id: Int, |
||||
val endpoint: String, |
||||
@SerializedName("server_key") val serverKey: String, |
||||
) |
||||
@ -0,0 +1,67 @@
|
||||
/* 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 <http://www.gnu.org/licenses>. */ |
||||
|
||||
package com.keylesspalace.tusky.receiver |
||||
|
||||
import android.app.NotificationManager |
||||
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.db.AccountManager |
||||
import com.keylesspalace.tusky.network.MastodonApi |
||||
import dagger.android.AndroidInjection |
||||
import kotlinx.coroutines.DelicateCoroutinesApi |
||||
import kotlinx.coroutines.GlobalScope |
||||
import kotlinx.coroutines.launch |
||||
import javax.inject.Inject |
||||
|
||||
@DelicateCoroutinesApi |
||||
class NotificationBlockStateBroadcastReceiver : BroadcastReceiver() { |
||||
@Inject |
||||
lateinit var mastodonApi: MastodonApi |
||||
|
||||
@Inject |
||||
lateinit var accountManager: AccountManager |
||||
|
||||
override fun onReceive(context: Context, intent: Intent) { |
||||
AndroidInjection.inject(this, context) |
||||
if (Build.VERSION.SDK_INT < 28) return |
||||
if (!canEnablePushNotifications(context, accountManager)) return |
||||
|
||||
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager |
||||
|
||||
val gid = when (intent.action) { |
||||
NotificationManager.ACTION_NOTIFICATION_CHANNEL_BLOCK_STATE_CHANGED -> { |
||||
val channelId = intent.getStringExtra(NotificationManager.EXTRA_NOTIFICATION_CHANNEL_ID) |
||||
nm.getNotificationChannel(channelId).group |
||||
} |
||||
NotificationManager.ACTION_NOTIFICATION_CHANNEL_GROUP_BLOCK_STATE_CHANGED -> { |
||||
intent.getStringExtra(NotificationManager.EXTRA_NOTIFICATION_CHANNEL_GROUP_ID) |
||||
} |
||||
else -> null |
||||
} ?: return |
||||
|
||||
accountManager.getAccountByIdentifier(gid)?.let { account -> |
||||
if (isUnifiedPushNotificationEnabledForAccount(account)) { |
||||
// Update UnifiedPush notification subscription |
||||
GlobalScope.launch { updateUnifiedPushSubscription(context, mastodonApi, accountManager, account) } |
||||
} |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,80 @@
|
||||
/* 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 <http://www.gnu.org/licenses>. */ |
||||
|
||||
package com.keylesspalace.tusky.receiver |
||||
|
||||
import android.content.Context |
||||
import android.content.Intent |
||||
import android.util.Log |
||||
import androidx.work.OneTimeWorkRequest |
||||
import androidx.work.WorkManager |
||||
import com.keylesspalace.tusky.components.notifications.NotificationWorker |
||||
import com.keylesspalace.tusky.components.notifications.registerUnifiedPushEndpoint |
||||
import com.keylesspalace.tusky.components.notifications.unregisterUnifiedPushEndpoint |
||||
import com.keylesspalace.tusky.db.AccountManager |
||||
import com.keylesspalace.tusky.network.MastodonApi |
||||
import dagger.android.AndroidInjection |
||||
import kotlinx.coroutines.DelicateCoroutinesApi |
||||
import kotlinx.coroutines.GlobalScope |
||||
import kotlinx.coroutines.launch |
||||
import org.unifiedpush.android.connector.MessagingReceiver |
||||
import javax.inject.Inject |
||||
|
||||
@DelicateCoroutinesApi |
||||
class UnifiedPushBroadcastReceiver : MessagingReceiver() { |
||||
companion object { |
||||
const val TAG = "UnifiedPush" |
||||
} |
||||
|
||||
@Inject |
||||
lateinit var accountManager: AccountManager |
||||
|
||||
@Inject |
||||
lateinit var mastodonApi: MastodonApi |
||||
|
||||
override fun onReceive(context: Context, intent: Intent) { |
||||
super.onReceive(context, intent) |
||||
AndroidInjection.inject(this, context) |
||||
} |
||||
|
||||
override fun onMessage(context: Context, message: ByteArray, instance: String) { |
||||
AndroidInjection.inject(this, context) |
||||
Log.d(TAG, "New message received for account $instance") |
||||
val workManager = WorkManager.getInstance(context) |
||||
val request = OneTimeWorkRequest.from(NotificationWorker::class.java) |
||||
workManager.enqueue(request) |
||||
} |
||||
|
||||
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 { |
||||
// Launch the coroutine in global scope -- it is short and we don't want to lose the registration event |
||||
// and there is no saner way to use structured concurrency in a receiver |
||||
GlobalScope.launch { registerUnifiedPushEndpoint(context, mastodonApi, accountManager, it, endpoint) } |
||||
} |
||||
} |
||||
|
||||
override fun onRegistrationFailed(context: Context, instance: String) = Unit |
||||
|
||||
override fun onUnregistered(context: Context, instance: String) { |
||||
AndroidInjection.inject(this, context) |
||||
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 |
||||
GlobalScope.launch { unregisterUnifiedPushEndpoint(mastodonApi, accountManager, it) } |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,60 @@
|
||||
/* 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 <http://www.gnu.org/licenses>. */ |
||||
|
||||
package com.keylesspalace.tusky.util |
||||
|
||||
import android.util.Base64 |
||||
import org.bouncycastle.jce.ECNamedCurveTable |
||||
import org.bouncycastle.jce.interfaces.ECPrivateKey |
||||
import org.bouncycastle.jce.interfaces.ECPublicKey |
||||
import org.bouncycastle.jce.provider.BouncyCastleProvider |
||||
import java.security.KeyPairGenerator |
||||
import java.security.SecureRandom |
||||
import java.security.Security |
||||
|
||||
object CryptoUtil { |
||||
const val CURVE_PRIME256_V1 = "prime256v1" |
||||
|
||||
private const val BASE64_FLAGS = Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP |
||||
|
||||
init { |
||||
Security.removeProvider(BouncyCastleProvider.PROVIDER_NAME) |
||||
Security.addProvider(BouncyCastleProvider()) |
||||
} |
||||
|
||||
private fun secureRandomBytes(len: Int): ByteArray { |
||||
val ret = ByteArray(len) |
||||
SecureRandom.getInstance("SHA1PRNG").nextBytes(ret) |
||||
return ret |
||||
} |
||||
|
||||
fun secureRandomBytesEncoded(len: Int): String { |
||||
return Base64.encodeToString(secureRandomBytes(len), BASE64_FLAGS) |
||||
} |
||||
|
||||
data class EncodedKeyPair(val pubkey: String, val privKey: String) |
||||
|
||||
fun generateECKeyPair(curve: String): EncodedKeyPair { |
||||
val spec = ECNamedCurveTable.getParameterSpec(curve) |
||||
val gen = KeyPairGenerator.getInstance("EC", BouncyCastleProvider.PROVIDER_NAME) |
||||
gen.initialize(spec) |
||||
val keyPair = gen.genKeyPair() |
||||
val pubKey = keyPair.public as ECPublicKey |
||||
val privKey = keyPair.private as ECPrivateKey |
||||
val encodedPubKey = Base64.encodeToString(pubKey.q.getEncoded(false), BASE64_FLAGS) |
||||
val encodedPrivKey = Base64.encodeToString(privKey.d.toByteArray(), BASE64_FLAGS) |
||||
return EncodedKeyPair(encodedPubKey, encodedPrivKey) |
||||
} |
||||
} |
||||
Loading…
Reference in new issue