diff --git a/app/build.gradle b/app/build.gradle
index 34e941ecd..2806a81fb 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -181,6 +181,9 @@ dependencies {
implementation "de.c1710:filemojicompat:$filemojicompat_version"
implementation "de.c1710:filemojicompat-defaults:$filemojicompat_version"
+ implementation "org.bouncycastle:bcprov-jdk15on:1.70"
+ implementation "com.github.UnifiedPush:android-connector:2.0.0"
+
testImplementation "androidx.test.ext:junit:1.1.3"
testImplementation "org.robolectric:robolectric:4.4"
testImplementation "org.mockito:mockito-inline:4.4.0"
diff --git a/app/schemas/com.keylesspalace.tusky.db.AppDatabase/36.json b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/36.json
new file mode 100644
index 000000000..d009a9e34
--- /dev/null
+++ b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/36.json
@@ -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')"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 3b0a977e3..b4969568c 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -146,6 +146,29 @@
android:name=".receiver.SendStatusBroadcastReceiver"
android:enabled="true"
android:exported="false" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
lifecycleScope.launch {
+ // Only disable UnifiedPush for this account -- do not call disableNotifications(),
+ // which unnecessarily disables it for all accounts and then re-enables it again at
+ // the next launch
+ disableUnifiedPushNotificationsForAccount(this@MainActivity, activeAccount)
NotificationHelper.deleteNotificationChannelsForAccount(activeAccount, this@MainActivity)
cacheUpdater.clearForUser(activeAccount.id)
conversationRepository.deleteCacheForAccount(activeAccount.id)
@@ -680,7 +682,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
NotificationHelper.disablePullNotifications(this@MainActivity)
}
val intent = if (newAccount == null) {
- LoginActivity.getIntent(this@MainActivity, false)
+ LoginActivity.getIntent(this@MainActivity, LoginActivity.MODE_DEFAULT)
} else {
Intent(this@MainActivity, MainActivity::class.java)
}
@@ -714,6 +716,16 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
accountManager.updateActiveAccount(me)
NotificationHelper.createNotificationChannelsForAccount(accountManager.activeAccount!!, this)
+ // Setup push notifications
+ showMigrationNoticeIfNecessary(this, binding.root, accountManager)
+ if (NotificationHelper.areNotificationsEnabled(this, accountManager)) {
+ lifecycleScope.launch {
+ enablePushNotificationsWithFallback(this@MainActivity, mastodonApi, accountManager)
+ }
+ } else {
+ disableAllNotifications(this, accountManager)
+ }
+
accountLocked = me.locked
updateProfiles()
diff --git a/app/src/main/java/com/keylesspalace/tusky/SplashActivity.kt b/app/src/main/java/com/keylesspalace/tusky/SplashActivity.kt
index 62f951626..f69aa5d19 100644
--- a/app/src/main/java/com/keylesspalace/tusky/SplashActivity.kt
+++ b/app/src/main/java/com/keylesspalace/tusky/SplashActivity.kt
@@ -43,7 +43,7 @@ class SplashActivity : AppCompatActivity(), Injectable {
val intent = if (accountManager.activeAccount != null) {
Intent(this, MainActivity::class.java)
} else {
- LoginActivity.getIntent(this, false)
+ LoginActivity.getIntent(this, LoginActivity.MODE_DEFAULT)
}
startActivity(intent)
finish()
diff --git a/app/src/main/java/com/keylesspalace/tusky/components/login/LoginActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/login/LoginActivity.kt
index bcbb4abf8..8066482eb 100644
--- a/app/src/main/java/com/keylesspalace/tusky/components/login/LoginActivity.kt
+++ b/app/src/main/java/com/keylesspalace/tusky/components/login/LoginActivity.kt
@@ -92,12 +92,17 @@ class LoginActivity : BaseActivity(), Injectable {
if (savedInstanceState == null &&
BuildConfig.CUSTOM_INSTANCE.isNotBlank() &&
- !isAdditionalLogin()
+ !isAdditionalLogin() && !isAccountMigration()
) {
binding.domainEditText.setText(BuildConfig.CUSTOM_INSTANCE)
binding.domainEditText.setSelection(BuildConfig.CUSTOM_INSTANCE.length)
}
+ if (isAccountMigration()) {
+ binding.domainEditText.setText(accountManager.activeAccount!!.domain)
+ binding.domainEditText.isEnabled = false
+ }
+
if (BuildConfig.CUSTOM_LOGO_URL.isNotBlank()) {
Glide.with(binding.loginLogo)
.load(BuildConfig.CUSTOM_LOGO_URL)
@@ -120,7 +125,7 @@ class LoginActivity : BaseActivity(), Injectable {
textView?.movementMethod = LinkMovementMethod.getInstance()
}
- if (isAdditionalLogin()) {
+ if (isAdditionalLogin() || isAccountMigration()) {
setSupportActionBar(binding.toolbar)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
supportActionBar?.setDisplayShowTitleEnabled(false)
@@ -135,7 +140,7 @@ class LoginActivity : BaseActivity(), Injectable {
override fun finish() {
super.finish()
- if (isAdditionalLogin()) {
+ if (isAdditionalLogin() || isAccountMigration()) {
overridePendingTransition(R.anim.slide_from_left, R.anim.slide_to_right)
}
}
@@ -230,7 +235,7 @@ class LoginActivity : BaseActivity(), Injectable {
domain, clientId, clientSecret, oauthRedirectUri, code, "authorization_code"
).fold(
{ accessToken ->
- accountManager.addAccount(accessToken.accessToken, domain)
+ accountManager.addAccount(accessToken.accessToken, domain, OAUTH_SCOPES)
val intent = Intent(this, MainActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
@@ -262,19 +267,28 @@ class LoginActivity : BaseActivity(), Injectable {
}
private fun isAdditionalLogin(): Boolean {
- return intent.getBooleanExtra(LOGIN_MODE, false)
+ return intent.getIntExtra(LOGIN_MODE, MODE_DEFAULT) == MODE_ADDITIONAL_LOGIN
+ }
+
+ private fun isAccountMigration(): Boolean {
+ return intent.getIntExtra(LOGIN_MODE, MODE_DEFAULT) == MODE_MIGRATION
}
companion object {
private const val TAG = "LoginActivity" // logging tag
- private const val OAUTH_SCOPES = "read write follow"
+ private const val OAUTH_SCOPES = "read write follow push"
private const val LOGIN_MODE = "LOGIN_MODE"
private const val DOMAIN = "domain"
private const val CLIENT_ID = "clientId"
private const val CLIENT_SECRET = "clientSecret"
+ const val MODE_DEFAULT = 0
+ const val MODE_ADDITIONAL_LOGIN = 1
+ // "Migration" is used to update the OAuth scope granted to the client
+ const val MODE_MIGRATION = 2
+
@JvmStatic
- fun getIntent(context: Context, mode: Boolean): Intent {
+ fun getIntent(context: Context, mode: Int): Intent {
val loginIntent = Intent(context, LoginActivity::class.java)
loginIntent.putExtra(LOGIN_MODE, mode)
return loginIntent
diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationHelper.java b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationHelper.java
index 795868976..ce11ab1cc 100644
--- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationHelper.java
+++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationHelper.java
@@ -57,6 +57,7 @@ import com.keylesspalace.tusky.entity.Notification;
import com.keylesspalace.tusky.entity.Poll;
import com.keylesspalace.tusky.entity.PollOption;
import com.keylesspalace.tusky.entity.Status;
+import com.keylesspalace.tusky.network.MastodonApi;
import com.keylesspalace.tusky.receiver.NotificationClearBroadcastReceiver;
import com.keylesspalace.tusky.receiver.SendStatusBroadcastReceiver;
import com.keylesspalace.tusky.util.StringUtils;
@@ -539,13 +540,18 @@ public class NotificationHelper {
}
}
- private static boolean filterNotification(AccountEntity account, Notification notification,
+ public static boolean filterNotification(AccountEntity account, Notification notification,
+ Context context) {
+ return filterNotification(account, notification.getType(), context);
+ }
+
+ public static boolean filterNotification(AccountEntity account, Notification.Type type,
Context context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
- String channelId = getChannelId(account, notification);
+ String channelId = getChannelId(account, type);
if(channelId == null) {
// unknown notificationtype
return false;
@@ -554,7 +560,7 @@ public class NotificationHelper {
return channel.getImportance() > NotificationManager.IMPORTANCE_NONE;
}
- switch (notification.getType()) {
+ switch (type) {
case MENTION:
return account.getNotificationsMentioned();
case STATUS:
@@ -580,7 +586,12 @@ public class NotificationHelper {
@Nullable
private static String getChannelId(AccountEntity account, Notification notification) {
- switch (notification.getType()) {
+ return getChannelId(account, notification.getType());
+ }
+
+ @Nullable
+ private static String getChannelId(AccountEntity account, Notification.Type type) {
+ switch (type) {
case MENTION:
return CHANNEL_MENTION + account.getIdentifier();
case STATUS:
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
new file mode 100644
index 000000000..ec2c82ac9
--- /dev/null
+++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/PushNotificationHelper.kt
@@ -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 . */
+
+@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 =
+ 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)
+ }
+ }
+}
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 e6bf83fbc..ff4380d32 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
@@ -23,6 +23,7 @@ import androidx.annotation.DrawableRes
import androidx.preference.PreferenceFragmentCompat
import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.AccountListActivity
+import com.keylesspalace.tusky.BaseActivity
import com.keylesspalace.tusky.BuildConfig
import com.keylesspalace.tusky.FiltersActivity
import com.keylesspalace.tusky.R
@@ -30,6 +31,8 @@ import com.keylesspalace.tusky.TabPreferenceActivity
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
import com.keylesspalace.tusky.components.instancemute.InstanceListActivity
+import com.keylesspalace.tusky.components.login.LoginActivity
+import com.keylesspalace.tusky.components.notifications.currentAccountNeedsMigration
import com.keylesspalace.tusky.db.AccountEntity
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.di.Injectable
@@ -139,6 +142,18 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable {
}
}
+ if (currentAccountNeedsMigration(accountManager)) {
+ preference {
+ setTitle(R.string.title_migration_relogin)
+ setIcon(R.drawable.ic_logout)
+ setOnPreferenceClickListener {
+ val intent = LoginActivity.getIntent(context, LoginActivity.MODE_MIGRATION)
+ (activity as BaseActivity).startActivityWithSlideInAnimation(intent)
+ true
+ }
+ }
+ }
+
preferenceCategory(R.string.pref_publishing) {
listPreference {
setTitle(R.string.pref_default_post_privacy)
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 400eb0730..0b717120c 100644
--- a/app/src/main/java/com/keylesspalace/tusky/db/AccountEntity.kt
+++ b/app/src/main/java/com/keylesspalace/tusky/db/AccountEntity.kt
@@ -64,7 +64,15 @@ data class AccountEntity(
var activeNotifications: String = "[]",
var emojis: List = emptyList(),
var tabPreferences: List = defaultTabs(),
- var notificationsFilter: String = "[\"follow_request\"]"
+ var notificationsFilter: String = "[\"follow_request\"]",
+ // Scope cannot be changed without re-login, so store it in case
+ // the scope needs to be changed in the future
+ var oauthScopes: String = "",
+ var unifiedPushUrl: String = "",
+ var pushPubKey: String = "",
+ var pushPrivKey: String = "",
+ var pushAuth: String = "",
+ var pushServerKey: String = "",
) {
val identifier: String
diff --git a/app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt b/app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt
index 3de34f55e..2ddbe5223 100644
--- a/app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt
+++ b/app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt
@@ -54,7 +54,7 @@ class AccountManager @Inject constructor(db: AppDatabase) {
* @param accessToken the access token for the new account
* @param domain the domain of the accounts Mastodon instance
*/
- fun addAccount(accessToken: String, domain: String) {
+ fun addAccount(accessToken: String, domain: String, oauthScopes: String) {
activeAccount?.let {
it.isActive = false
@@ -65,7 +65,10 @@ class AccountManager @Inject constructor(db: AppDatabase) {
val maxAccountId = accounts.maxByOrNull { it.id }?.id ?: 0
val newAccountId = maxAccountId + 1
- activeAccount = AccountEntity(id = newAccountId, domain = domain.lowercase(Locale.ROOT), accessToken = accessToken, isActive = true)
+ activeAccount = AccountEntity(
+ id = newAccountId, domain = domain.lowercase(Locale.ROOT),
+ accessToken = accessToken, oauthScopes = oauthScopes, isActive = true
+ )
}
/**
@@ -189,4 +192,15 @@ class AccountManager @Inject constructor(db: AppDatabase) {
id == accountId
}
}
+
+ /**
+ * Finds an account by its string identifier
+ * @param identifier the string identifier of the account
+ * @return the requested account or null if it was not found
+ */
+ fun getAccountByIdentifier(identifier: String): AccountEntity? {
+ return accounts.find {
+ identifier == it.identifier
+ }
+ }
}
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 d5f023e58..25fe1a61f 100644
--- a/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java
+++ b/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java
@@ -31,7 +31,7 @@ import java.io.File;
*/
@Database(entities = { DraftEntity.class, AccountEntity.class, InstanceEntity.class, TimelineStatusEntity.class,
TimelineAccountEntity.class, ConversationEntity.class
- }, version = 35)
+ }, version = 36)
public abstract class AppDatabase extends RoomDatabase {
public abstract AccountDao accountDao();
@@ -541,4 +541,16 @@ public abstract class AppDatabase extends RoomDatabase {
database.execSQL("ALTER TABLE `TimelineStatusEntity` ADD COLUMN `card` TEXT");
}
};
+
+ public static final Migration MIGRATION_35_36 = new Migration(35, 36) {
+ @Override
+ public void migrate(@NonNull SupportSQLiteDatabase database) {
+ database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `oauthScopes` TEXT NOT NULL DEFAULT ''");
+ database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `unifiedPushUrl` TEXT NOT NULL DEFAULT ''");
+ database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `pushPubKey` TEXT NOT NULL DEFAULT ''");
+ database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `pushPrivKey` TEXT NOT NULL DEFAULT ''");
+ database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `pushAuth` TEXT NOT NULL DEFAULT ''");
+ database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `pushServerKey` TEXT NOT NULL DEFAULT ''");
+ }
+ };
}
diff --git a/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt
index 0861e9cf0..85741762d 100644
--- a/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt
+++ b/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt
@@ -64,6 +64,7 @@ class AppModule {
AppDatabase.MIGRATION_26_27, AppDatabase.MIGRATION_27_28, AppDatabase.MIGRATION_28_29,
AppDatabase.MIGRATION_29_30, AppDatabase.MIGRATION_30_31, AppDatabase.MIGRATION_31_32,
AppDatabase.MIGRATION_32_33, AppDatabase.MIGRATION_33_34, AppDatabase.MIGRATION_34_35,
+ AppDatabase.MIGRATION_35_36,
)
.build()
}
diff --git a/app/src/main/java/com/keylesspalace/tusky/di/BroadcastReceiverModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/BroadcastReceiverModule.kt
index b7213fa64..e071fc84b 100644
--- a/app/src/main/java/com/keylesspalace/tusky/di/BroadcastReceiverModule.kt
+++ b/app/src/main/java/com/keylesspalace/tusky/di/BroadcastReceiverModule.kt
@@ -16,8 +16,10 @@
package com.keylesspalace.tusky.di
+import com.keylesspalace.tusky.receiver.NotificationBlockStateBroadcastReceiver
import com.keylesspalace.tusky.receiver.NotificationClearBroadcastReceiver
import com.keylesspalace.tusky.receiver.SendStatusBroadcastReceiver
+import com.keylesspalace.tusky.receiver.UnifiedPushBroadcastReceiver
import dagger.Module
import dagger.android.ContributesAndroidInjector
@@ -28,4 +30,10 @@ abstract class BroadcastReceiverModule {
@ContributesAndroidInjector
abstract fun contributeNotificationClearBroadcastReceiver(): NotificationClearBroadcastReceiver
+
+ @ContributesAndroidInjector
+ abstract fun contributeUnifiedPushBroadcastReceiver(): UnifiedPushBroadcastReceiver
+
+ @ContributesAndroidInjector
+ abstract fun contributeNotificationBlockStateBroadcastReceiver(): NotificationBlockStateBroadcastReceiver
}
diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/NotificationSubscribeResult.kt b/app/src/main/java/com/keylesspalace/tusky/entity/NotificationSubscribeResult.kt
new file mode 100644
index 000000000..c6eb09bec
--- /dev/null
+++ b/app/src/main/java/com/keylesspalace/tusky/entity/NotificationSubscribeResult.kt
@@ -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 . */
+
+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,
+)
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 7357293b5..ef81ed117 100644
--- a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt
+++ b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt
@@ -30,6 +30,7 @@ import com.keylesspalace.tusky.entity.MastoList
import com.keylesspalace.tusky.entity.MediaUploadResult
import com.keylesspalace.tusky.entity.NewStatus
import com.keylesspalace.tusky.entity.Notification
+import com.keylesspalace.tusky.entity.NotificationSubscribeResult
import com.keylesspalace.tusky.entity.Poll
import com.keylesspalace.tusky.entity.Relationship
import com.keylesspalace.tusky.entity.ScheduledStatus
@@ -47,6 +48,7 @@ import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.Field
+import retrofit2.http.FieldMap
import retrofit2.http.FormUrlEncoded
import retrofit2.http.GET
import retrofit2.http.HTTP
@@ -597,4 +599,32 @@ interface MastodonApi {
@Path("id") accountId: String,
@Field("comment") note: String
): Single
+
+ @FormUrlEncoded
+ @POST("api/v1/push/subscription")
+ suspend fun subscribePushNotifications(
+ @Header("Authorization") auth: String,
+ @Header(DOMAIN_HEADER) domain: String,
+ @Field("subscription[endpoint]") endPoint: String,
+ @Field("subscription[keys][p256dh]") keysP256DH: String,
+ @Field("subscription[keys][auth]") keysAuth: String,
+ // The "data[alerts][]" fields to enable / disable notifications
+ // Should be generated dynamically from all the available notification
+ // types defined in [com.keylesspalace.tusky.entities.Notification.Types]
+ @FieldMap data: Map
+ ): Result
+
+ @FormUrlEncoded
+ @PUT("api/v1/push/subscription")
+ suspend fun updatePushNotificationSubscription(
+ @Header("Authorization") auth: String,
+ @Header(DOMAIN_HEADER) domain: String,
+ @FieldMap data: Map
+ ): Result
+
+ @DELETE("api/v1/push/subscription")
+ suspend fun unsubscribePushNotifications(
+ @Header("Authorization") auth: String,
+ @Header(DOMAIN_HEADER) domain: String,
+ ): Result
}
diff --git a/app/src/main/java/com/keylesspalace/tusky/receiver/NotificationBlockStateBroadcastReceiver.kt b/app/src/main/java/com/keylesspalace/tusky/receiver/NotificationBlockStateBroadcastReceiver.kt
new file mode 100644
index 000000000..20b18a9f9
--- /dev/null
+++ b/app/src/main/java/com/keylesspalace/tusky/receiver/NotificationBlockStateBroadcastReceiver.kt
@@ -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 . */
+
+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) }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/keylesspalace/tusky/receiver/UnifiedPushBroadcastReceiver.kt b/app/src/main/java/com/keylesspalace/tusky/receiver/UnifiedPushBroadcastReceiver.kt
new file mode 100644
index 000000000..45a5ae2b6
--- /dev/null
+++ b/app/src/main/java/com/keylesspalace/tusky/receiver/UnifiedPushBroadcastReceiver.kt
@@ -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 . */
+
+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) }
+ }
+ }
+}
diff --git a/app/src/main/java/com/keylesspalace/tusky/util/CryptoUtil.kt b/app/src/main/java/com/keylesspalace/tusky/util/CryptoUtil.kt
new file mode 100644
index 000000000..f4fa4b5ba
--- /dev/null
+++ b/app/src/main/java/com/keylesspalace/tusky/util/CryptoUtil.kt
@@ -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 . */
+
+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)
+ }
+}
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index d8ce83941..0d6712a7b 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -40,6 +40,7 @@
Muted users
Blocked users
Hidden domains
+ Re-login for push notifications
Follow Requests
Edit your profile
Drafts
@@ -147,6 +148,8 @@
Open boost author
Show boosts
Show favorites
+ Dismiss
+ Details
Hashtags
Mentions
@@ -642,4 +645,8 @@
Compose Post
Saving draft…
+ Re-login all accounts to enable push notification support.
+ In order to use push notifications via UnifiedPush, Tusky needs permission to subscribe to notifications on your Mastodon server. This requires a re-login to change the OAuth scopes granted to Tusky. Using the re-login option here or in Account Preferences will preserve all of your local drafts and cache.
+ You have re-logged into your current account to grant push subscription permission to Tusky. However, you still have other accounts that have not been migrated this way. Switch to them and re-login one by one in order to enable UnifiedPush notifications support.
+