diff --git a/app/build.gradle b/app/build.gradle index 34e941ec..2806a81f 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 00000000..d009a9e3 --- /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 3b0a977e..b4969568 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 62f95162..f69aa5d1 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 bcbb4abf..8066482e 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 79586897..ce11ab1c 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 00000000..ec2c82ac --- /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 e6bf83fb..ff4380d3 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 400eb073..0b717120 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 3de34f55..2ddbe522 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 d5f023e5..25fe1a61 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 0861e9cf..85741762 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 b7213fa6..e071fc84 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 00000000..c6eb09be --- /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 7357293b..ef81ed11 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 00000000..20b18a9f --- /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 00000000..45a5ae2b --- /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 00000000..f4fa4b5b --- /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 d8ce8394..0d6712a7 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. +