diff --git a/app/build.gradle b/app/build.gradle
index e014904f7..bf1996729 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -7,9 +7,13 @@ apply from: "../instance-build.gradle"
def getGitSha = {
def stdout = new ByteArrayOutputStream()
- exec {
- commandLine 'git', 'rev-parse', '--short', 'HEAD'
- standardOutput = stdout
+ try {
+ exec {
+ commandLine 'git', 'rev-parse', '--short', 'HEAD'
+ standardOutput = stdout
+ }
+ } catch (Exception e) {
+ return "unknown"
}
return stdout.toString().trim()
}
@@ -101,7 +105,7 @@ ext.roomVersion = '2.4.2'
ext.retrofitVersion = '2.9.0'
ext.okhttpVersion = '4.9.3'
ext.glideVersion = '4.13.1'
-ext.daggerVersion = '2.41'
+ext.daggerVersion = '2.42'
ext.materialdrawerVersion = '8.4.5'
ext.emoji2_version = '1.1.0'
ext.filemojicompat_version = '3.2.1'
@@ -143,7 +147,7 @@ dependencies {
kapt "androidx.room:room-compiler:$roomVersion"
implementation 'androidx.core:core-splashscreen:1.0.0-beta02'
- implementation "com.google.android.material:material:1.5.0"
+ implementation "com.google.android.material:material:1.6.0"
implementation "com.google.code.gson:gson:2.9.0"
@@ -190,6 +194,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..ce3581c57
--- /dev/null
+++ b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/36.json
@@ -0,0 +1,863 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 36,
+ "identityHash": "92ef93a129d5370539d6a62fd16f53d8",
+ "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, `quote` 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
+ },
+ {
+ "fieldPath": "quote",
+ "columnName": "quote",
+ "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, '92ef93a129d5370539d6a62fd16f53d8')"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 271eb2c16..0a52dde7c 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -15,6 +15,7 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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)
@@ -856,7 +853,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)
}
@@ -890,6 +887,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 7f621f9b5..5e7ba616d 100644
--- a/app/src/main/java/com/keylesspalace/tusky/SplashActivity.kt
+++ b/app/src/main/java/com/keylesspalace/tusky/SplashActivity.kt
@@ -46,7 +46,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/StatusListActivity.kt b/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt
index 4f1302a0b..99a144fc6 100644
--- a/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt
+++ b/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt
@@ -59,7 +59,7 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
Kind.FAVOURITES -> getString(R.string.title_favourites)
Kind.BOOKMARKS -> getString(R.string.title_bookmarks)
Kind.TAG -> getString(R.string.title_tag).format(hashtag)
- else -> getString(R.string.title_list_timeline)
+ else -> intent.getStringExtra(EXTRA_LIST_TITLE)
}
supportActionBar?.run {
@@ -94,6 +94,7 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
private const val EXTRA_KIND = "kind"
private const val EXTRA_LIST_ID = "id"
+ private const val EXTRA_LIST_TITLE = "title"
private const val EXTRA_HASHTAG = "tag"
fun newFavouritesIntent(context: Context) =
@@ -106,10 +107,11 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
putExtra(EXTRA_KIND, Kind.BOOKMARKS.name)
}
- fun newListIntent(context: Context, listId: String) =
+ fun newListIntent(context: Context, listId: String, listTitle: String) =
Intent(context, StatusListActivity::class.java).apply {
putExtra(EXTRA_KIND, Kind.LIST.name)
putExtra(EXTRA_LIST_ID, listId)
+ putExtra(EXTRA_LIST_TITLE, listTitle)
}
@JvmStatic
diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java b/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java
index 1ee28d35b..9aacfdc77 100644
--- a/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java
+++ b/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java
@@ -539,8 +539,13 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
message.setCompoundDrawablesWithIntrinsicBounds(icon, null, null, null);
String wholeMessage = String.format(format, displayName);
final SpannableStringBuilder str = new SpannableStringBuilder(wholeMessage);
- str.setSpan(new StyleSpan(Typeface.BOLD), 0, displayName.length(),
- Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
+ int displayNameIndex = format.indexOf("%s");
+ str.setSpan(
+ new StyleSpan(Typeface.BOLD),
+ displayNameIndex,
+ displayNameIndex + displayName.length(),
+ Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
+ );
CharSequence emojifiedText = CustomEmojiHelper.emojify(
str, notificationViewData.getAccount().getEmojis(), message, statusDisplayOptions.animateEmojis()
);
diff --git a/app/src/main/java/com/keylesspalace/tusky/components/account/AccountActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/account/AccountActivity.kt
index e60fa13fd..da35d7d61 100644
--- a/app/src/main/java/com/keylesspalace/tusky/components/account/AccountActivity.kt
+++ b/app/src/main/java/com/keylesspalace/tusky/components/account/AccountActivity.kt
@@ -84,6 +84,9 @@ import com.keylesspalace.tusky.view.showMuteAccountDialog
import dagger.android.DispatchingAndroidInjector
import dagger.android.HasAndroidInjector
import java.text.NumberFormat
+import java.text.ParseException
+import java.text.SimpleDateFormat
+import java.util.Locale
import javax.inject.Inject
import kotlin.math.abs
@@ -418,6 +421,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
updateToolbar()
updateMovedAccount()
updateRemoteAccount()
+ updateAccountJoinedDate()
updateAccountStats()
invalidateOptionsMenu()
@@ -427,6 +431,20 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
}
}
+ private fun updateAccountJoinedDate() {
+ loadedAccount?.let { account ->
+ try {
+ binding.accountDateJoined.text = resources.getString(
+ R.string.account_date_joined,
+ SimpleDateFormat("MMMM, yyyy", Locale.getDefault()).format(account.createdAt)
+ )
+ binding.accountDateJoined.visibility = View.VISIBLE
+ } catch (e: ParseException) {
+ binding.accountDateJoined.visibility = View.GONE
+ }
+ }
+ }
+
/**
* Load account's avatar and header image
*/
diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt
index d6abb1a8f..419a1a77f 100644
--- a/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt
+++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt
@@ -82,7 +82,6 @@ import com.keylesspalace.tusky.entity.Emoji
import com.keylesspalace.tusky.entity.NewPoll
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.settings.PrefKeys
-import com.keylesspalace.tusky.util.ComposeTokenizer
import com.keylesspalace.tusky.util.PickMediaFiles
import com.keylesspalace.tusky.util.ThemeUtils
import com.keylesspalace.tusky.util.afterTextChanged
@@ -361,7 +360,8 @@ class ComposeActivity :
ComposeAutoCompleteAdapter(
this,
preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false),
- preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false)
+ preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false),
+ preferences.getBoolean(PrefKeys.SHOW_BOT_OVERLAY, true)
)
)
binding.composeEditField.setTokenizer(ComposeTokenizer())
diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeAutoCompleteAdapter.java b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeAutoCompleteAdapter.java
deleted file mode 100644
index 8a4f0ce1f..000000000
--- a/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeAutoCompleteAdapter.java
+++ /dev/null
@@ -1,320 +0,0 @@
-/* Copyright 2017 Andrew Dawson
- *
- * This file is a part of Tusky.
- *
- * This program is free software; you can redistribute it and/or modify it under the terms of the
- * GNU General Public License as published by the Free Software Foundation; either version 3 of the
- * License, or (at your option) any later version.
- *
- * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
- * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
- * Public License for more details.
- *
- * You should have received a copy of the GNU General Public License along with Tusky; if not,
- * see . */
-
-package com.keylesspalace.tusky.components.compose;
-
-import android.content.Context;
-import android.view.LayoutInflater;
-import android.view.View;
-import android.view.ViewGroup;
-import android.widget.BaseAdapter;
-import android.widget.Filter;
-import android.widget.Filterable;
-import android.widget.ImageView;
-import android.widget.TextView;
-
-import com.bumptech.glide.Glide;
-import com.keylesspalace.tusky.R;
-import com.keylesspalace.tusky.entity.Emoji;
-import com.keylesspalace.tusky.entity.HashTag;
-import com.keylesspalace.tusky.entity.TimelineAccount;
-import com.keylesspalace.tusky.util.CustomEmojiHelper;
-import com.keylesspalace.tusky.util.ImageLoadingHelper;
-
-import java.util.ArrayList;
-import java.util.List;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-
-/**
- * Created by charlag on 12/11/17.
- */
-
-public class ComposeAutoCompleteAdapter extends BaseAdapter
- implements Filterable {
- private static final int ACCOUNT_VIEW_TYPE = 1;
- private static final int HASHTAG_VIEW_TYPE = 2;
- private static final int EMOJI_VIEW_TYPE = 3;
- private static final int SEPARATOR_VIEW_TYPE = 0;
-
- private final ArrayList resultList;
- private final AutocompletionProvider autocompletionProvider;
- private final boolean animateAvatar;
- private final boolean animateEmojis;
-
- public ComposeAutoCompleteAdapter(AutocompletionProvider autocompletionProvider, boolean animateAvatar, boolean animateEmojis) {
- super();
- resultList = new ArrayList<>();
- this.autocompletionProvider = autocompletionProvider;
- this.animateAvatar = animateAvatar;
- this.animateEmojis = animateEmojis;
- }
-
- @Override
- public int getCount() {
- return resultList.size();
- }
-
- @Override
- public AutocompleteResult getItem(int index) {
- return resultList.get(index);
- }
-
- @Override
- public long getItemId(int position) {
- return position;
- }
-
- @Override
- @NonNull
- public Filter getFilter() {
- return new Filter() {
- @Override
- public CharSequence convertResultToString(Object resultValue) {
- if (resultValue instanceof AccountResult) {
- return formatUsername(((AccountResult) resultValue));
- } else if (resultValue instanceof HashtagResult) {
- return formatHashtag((HashtagResult) resultValue);
- } else if (resultValue instanceof EmojiResult) {
- return formatEmoji((EmojiResult) resultValue);
- } else {
- return "";
- }
- }
-
- // This method is invoked in a worker thread.
- @Override
- protected FilterResults performFiltering(CharSequence constraint) {
- FilterResults filterResults = new FilterResults();
- if (constraint != null) {
- List results =
- autocompletionProvider.search(constraint.toString());
- filterResults.values = results;
- filterResults.count = results.size();
- }
- return filterResults;
- }
-
- @SuppressWarnings("unchecked")
- @Override
- protected void publishResults(CharSequence constraint, FilterResults results) {
- if (results != null && results.count > 0) {
- resultList.clear();
- resultList.addAll((List) results.values);
- notifyDataSetChanged();
- } else {
- notifyDataSetInvalidated();
- }
- }
- };
- }
-
- @Override
- @NonNull
- public View getView(int position, @Nullable View convertView, @NonNull ViewGroup parent) {
- View view = convertView;
- final Context context = parent.getContext();
-
- switch (getItemViewType(position)) {
- case ACCOUNT_VIEW_TYPE:
- AccountViewHolder accountViewHolder;
- if (convertView == null) {
- view = ((LayoutInflater) context
- .getSystemService(Context.LAYOUT_INFLATER_SERVICE))
- .inflate(R.layout.item_autocomplete_account, parent, false);
- }
- if (view.getTag() == null) {
- view.setTag(new AccountViewHolder(view));
- }
- accountViewHolder = (AccountViewHolder) view.getTag();
-
- AccountResult accountResult = ((AccountResult) getItem(position));
- if (accountResult != null) {
- TimelineAccount account = accountResult.account;
- String formattedUsername = context.getString(
- R.string.post_username_format,
- account.getUsername()
- );
- accountViewHolder.username.setText(formattedUsername);
- CharSequence emojifiedName = CustomEmojiHelper.emojify(account.getName(),
- account.getEmojis(), accountViewHolder.displayName, animateEmojis);
- accountViewHolder.displayName.setText(emojifiedName);
-
- int avatarRadius = accountViewHolder.avatar.getContext().getResources()
- .getDimensionPixelSize(R.dimen.avatar_radius_42dp);
-
- ImageLoadingHelper.loadAvatar(
- account.getAvatar(),
- accountViewHolder.avatar,
- avatarRadius,
- animateAvatar
- );
- }
- break;
-
- case HASHTAG_VIEW_TYPE:
- if (convertView == null) {
- view = ((LayoutInflater) context
- .getSystemService(Context.LAYOUT_INFLATER_SERVICE))
- .inflate(R.layout.item_autocomplete_hashtag, parent, false);
- }
-
- HashtagResult result = (HashtagResult) getItem(position);
- if (result != null) {
- ((TextView) view).setText(formatHashtag(result));
- }
- break;
-
- case EMOJI_VIEW_TYPE:
- EmojiViewHolder emojiViewHolder;
- if (convertView == null) {
- view = ((LayoutInflater) context
- .getSystemService(Context.LAYOUT_INFLATER_SERVICE))
- .inflate(R.layout.item_autocomplete_emoji, parent, false);
- }
- if (view.getTag() == null) {
- view.setTag(new EmojiViewHolder(view));
- }
- emojiViewHolder = (EmojiViewHolder) view.getTag();
-
- EmojiResult emojiResult = ((EmojiResult) getItem(position));
- if (emojiResult != null) {
- Emoji emoji = emojiResult.emoji;
- String formattedShortcode = context.getString(
- R.string.emoji_shortcode_format,
- emoji.getShortcode()
- );
- emojiViewHolder.shortcode.setText(formattedShortcode);
- Glide.with(emojiViewHolder.preview)
- .load(emoji.getUrl())
- .into(emojiViewHolder.preview);
- }
- break;
-
- case SEPARATOR_VIEW_TYPE:
- if (convertView == null) {
- view = ((LayoutInflater) context
- .getSystemService(Context.LAYOUT_INFLATER_SERVICE))
- .inflate(R.layout.item_autocomplete_divider, parent, false);
- }
- break;
- default:
- throw new AssertionError("unknown view type");
- }
-
- return view;
- }
-
- private static String formatUsername(AccountResult result) {
- return String.format("@%s", result.account.getUsername());
- }
-
- private static String formatHashtag(HashtagResult result) {
- return String.format("#%s", result.hashtag);
- }
-
- private static String formatEmoji(EmojiResult result) {
- return String.format(":%s:", result.emoji.getShortcode());
- }
-
- @Override
- public int getViewTypeCount() {
- return 4;
- }
-
- @Override
- public int getItemViewType(int position) {
- AutocompleteResult item = getItem(position);
-
- if (item instanceof AccountResult) {
- return ACCOUNT_VIEW_TYPE;
- } else if (item instanceof HashtagResult) {
- return HASHTAG_VIEW_TYPE;
- } else if (item instanceof EmojiResult) {
- return EMOJI_VIEW_TYPE;
- } else {
- return SEPARATOR_VIEW_TYPE;
- }
- }
-
- @Override
- public boolean areAllItemsEnabled() {
- // there may be separators
- return false;
- }
-
- @Override
- public boolean isEnabled(int position) {
- return !(getItem(position) instanceof ResultSeparator);
- }
-
- public abstract static class AutocompleteResult {
- AutocompleteResult() {
- }
- }
-
- public final static class AccountResult extends AutocompleteResult {
- private final TimelineAccount account;
-
- public AccountResult(TimelineAccount account) {
- this.account = account;
- }
- }
-
- public final static class HashtagResult extends AutocompleteResult {
- private final String hashtag;
-
- public HashtagResult(HashTag hashtag) {
- this.hashtag = hashtag.getName();
- }
- }
-
- public final static class EmojiResult extends AutocompleteResult {
- private final Emoji emoji;
-
- public EmojiResult(Emoji emoji) {
- this.emoji = emoji;
- }
- }
-
- public final static class ResultSeparator extends AutocompleteResult {}
-
- public interface AutocompletionProvider {
- List search(String mention);
- }
-
- private class AccountViewHolder {
- final TextView username;
- final TextView displayName;
- final ImageView avatar;
-
- private AccountViewHolder(View view) {
- username = view.findViewById(R.id.username);
- displayName = view.findViewById(R.id.display_name);
- avatar = view.findViewById(R.id.avatar);
- }
- }
-
- private class EmojiViewHolder {
- final TextView shortcode;
- final ImageView preview;
-
- private EmojiViewHolder(View view) {
- shortcode = view.findViewById(R.id.shortcode);
- preview = view.findViewById(R.id.preview);
- }
- }
-}
diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeAutoCompleteAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeAutoCompleteAdapter.kt
new file mode 100644
index 000000000..e825798cf
--- /dev/null
+++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeAutoCompleteAdapter.kt
@@ -0,0 +1,175 @@
+/* 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.components.compose
+
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.BaseAdapter
+import android.widget.Filter
+import android.widget.Filterable
+import androidx.annotation.WorkerThread
+import com.bumptech.glide.Glide
+import com.keylesspalace.tusky.R
+import com.keylesspalace.tusky.databinding.ItemAutocompleteAccountBinding
+import com.keylesspalace.tusky.databinding.ItemAutocompleteEmojiBinding
+import com.keylesspalace.tusky.databinding.ItemAutocompleteHashtagBinding
+import com.keylesspalace.tusky.entity.Emoji
+import com.keylesspalace.tusky.entity.TimelineAccount
+import com.keylesspalace.tusky.util.emojify
+import com.keylesspalace.tusky.util.loadAvatar
+import com.keylesspalace.tusky.util.visible
+
+class ComposeAutoCompleteAdapter(
+ private val autocompletionProvider: AutocompletionProvider,
+ private val animateAvatar: Boolean,
+ private val animateEmojis: Boolean,
+ private val showBotBadge: Boolean
+) : BaseAdapter(), Filterable {
+
+ private var resultList: List = emptyList()
+
+ override fun getCount() = resultList.size
+
+ override fun getItem(index: Int): AutocompleteResult {
+ return resultList[index]
+ }
+
+ override fun getItemId(position: Int): Long {
+ return position.toLong()
+ }
+
+ override fun getFilter(): Filter {
+ return object : Filter() {
+
+ override fun convertResultToString(resultValue: Any): CharSequence {
+ return when (resultValue) {
+ is AutocompleteResult.AccountResult -> formatUsername(resultValue)
+ is AutocompleteResult.HashtagResult -> formatHashtag(resultValue)
+ is AutocompleteResult.EmojiResult -> formatEmoji(resultValue)
+ else -> ""
+ }
+ }
+
+ @WorkerThread
+ override fun performFiltering(constraint: CharSequence?): FilterResults {
+ val filterResults = FilterResults()
+ if (constraint != null) {
+ val results = autocompletionProvider.search(constraint.toString())
+ filterResults.values = results
+ filterResults.count = results.size
+ }
+ return filterResults
+ }
+
+ override fun publishResults(constraint: CharSequence?, results: FilterResults) {
+ if (results.count > 0) {
+ resultList = results.values as List
+ notifyDataSetChanged()
+ } else {
+ notifyDataSetInvalidated()
+ }
+ }
+ }
+ }
+
+ override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
+ val itemViewType = getItemViewType(position)
+ val context = parent.context
+
+ val view: View = convertView ?: run {
+ val layoutInflater = LayoutInflater.from(context)
+ val binding = when (itemViewType) {
+ ACCOUNT_VIEW_TYPE -> ItemAutocompleteAccountBinding.inflate(layoutInflater)
+ HASHTAG_VIEW_TYPE -> ItemAutocompleteHashtagBinding.inflate(layoutInflater)
+ EMOJI_VIEW_TYPE -> ItemAutocompleteEmojiBinding.inflate(layoutInflater)
+ else -> throw AssertionError("unknown view type")
+ }
+ binding.root.tag = binding
+ binding.root
+ }
+
+ when (val binding = view.tag) {
+ is ItemAutocompleteAccountBinding -> {
+ val accountResult = getItem(position) as AutocompleteResult.AccountResult
+ val account = accountResult.account
+ binding.username.text = context.getString(R.string.post_username_format, account.username)
+ binding.displayName.text = account.name.emojify(account.emojis, binding.displayName, animateEmojis)
+ val avatarRadius = context.resources.getDimensionPixelSize(R.dimen.avatar_radius_42dp)
+ loadAvatar(
+ account.avatar,
+ binding.avatar,
+ avatarRadius,
+ animateAvatar
+ )
+ binding.avatarBadge.visible(showBotBadge && account.bot)
+ }
+ is ItemAutocompleteHashtagBinding -> {
+ val result = getItem(position) as AutocompleteResult.HashtagResult
+ binding.root.text = formatHashtag(result)
+ }
+ is ItemAutocompleteEmojiBinding -> {
+ val emojiResult = getItem(position) as AutocompleteResult.EmojiResult
+ val (shortcode, url) = emojiResult.emoji
+ binding.shortcode.text = context.getString(R.string.emoji_shortcode_format, shortcode)
+ Glide.with(binding.preview)
+ .load(url)
+ .into(binding.preview)
+ }
+ }
+ return view
+ }
+
+ override fun getViewTypeCount() = 3
+
+ override fun getItemViewType(position: Int): Int {
+ return when (getItem(position)) {
+ is AutocompleteResult.AccountResult -> ACCOUNT_VIEW_TYPE
+ is AutocompleteResult.HashtagResult -> HASHTAG_VIEW_TYPE
+ is AutocompleteResult.EmojiResult -> EMOJI_VIEW_TYPE
+ }
+ }
+
+ sealed class AutocompleteResult {
+ class AccountResult(val account: TimelineAccount) : AutocompleteResult()
+
+ class HashtagResult(val hashtag: String) : AutocompleteResult()
+
+ class EmojiResult(val emoji: Emoji) : AutocompleteResult()
+ }
+
+ interface AutocompletionProvider {
+ fun search(token: String): List
+ }
+
+ companion object {
+ private const val ACCOUNT_VIEW_TYPE = 0
+ private const val HASHTAG_VIEW_TYPE = 1
+ private const val EMOJI_VIEW_TYPE = 2
+
+ private fun formatUsername(result: AutocompleteResult.AccountResult): String {
+ return String.format("@%s", result.account.username)
+ }
+
+ private fun formatHashtag(result: AutocompleteResult.HashtagResult): String {
+ return String.format("#%s", result.hashtag)
+ }
+
+ private fun formatEmoji(result: AutocompleteResult.EmojiResult): String {
+ return String.format(":%s:", result.emoji.shortcode)
+ }
+ }
+}
diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ComposeTokenizer.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeTokenizer.kt
similarity index 98%
rename from app/src/main/java/com/keylesspalace/tusky/util/ComposeTokenizer.kt
rename to app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeTokenizer.kt
index 6fee42edf..7b3d208b9 100644
--- a/app/src/main/java/com/keylesspalace/tusky/util/ComposeTokenizer.kt
+++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeTokenizer.kt
@@ -13,7 +13,7 @@
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see . */
-package com.keylesspalace.tusky.util
+package com.keylesspalace.tusky.components.compose
import android.text.SpannableString
import android.text.Spanned
diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeViewModel.kt
index 0fa769cdf..893f350bf 100644
--- a/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeViewModel.kt
+++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeViewModel.kt
@@ -24,6 +24,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.asLiveData
import androidx.lifecycle.viewModelScope
import com.keylesspalace.tusky.components.compose.ComposeActivity.QueuedMedia
+import com.keylesspalace.tusky.components.compose.ComposeAutoCompleteAdapter.AutocompleteResult
import com.keylesspalace.tusky.components.drafts.DraftHelper
import com.keylesspalace.tusky.components.instanceinfo.InstanceInfo
import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository
@@ -38,6 +39,7 @@ import com.keylesspalace.tusky.service.ServiceClient
import com.keylesspalace.tusky.service.StatusToSend
import com.keylesspalace.tusky.util.combineLiveData
import com.keylesspalace.tusky.util.randomAlphanumericString
+import com.keylesspalace.tusky.util.result
import com.keylesspalace.tusky.util.toLiveData
import io.reactivex.rxjava3.core.Observable
import kotlinx.coroutines.Dispatchers
@@ -51,7 +53,6 @@ import kotlinx.coroutines.flow.updateAndGet
import kotlinx.coroutines.launch
import kotlinx.coroutines.rx3.rxSingle
import kotlinx.coroutines.withContext
-import java.util.Locale
import javax.inject.Inject
class ComposeViewModel @Inject constructor(
@@ -195,7 +196,7 @@ class ComposeViewModel @Inject constructor(
fun removeMediaFromQueue(item: QueuedMedia) {
mediaToJob[item.localId]?.cancel()
- media.update { mediaValue -> mediaValue.filter { it.localId == item.localId } }
+ media.update { mediaValue -> mediaValue.filter { it.localId != item.localId } }
}
fun toggleMarkSensitive() {
@@ -342,48 +343,39 @@ class ComposeViewModel @Inject constructor(
return true
}
- fun searchAutocompleteSuggestions(token: String): List {
+ fun searchAutocompleteSuggestions(token: String): List {
when (token[0]) {
'@' -> {
- return try {
- api.searchAccounts(query = token.substring(1), limit = 10)
- .blockingGet()
- .map { ComposeAutoCompleteAdapter.AccountResult(it) }
- } catch (e: Throwable) {
- Log.e(TAG, String.format("Autocomplete search for %s failed.", token), e)
- emptyList()
- }
+ return api.searchAccountsCall(query = token.substring(1), limit = 10)
+ .result()
+ .fold({ accounts ->
+ accounts.map { AutocompleteResult.AccountResult(it) }
+ }, { e ->
+ Log.e(TAG, "Autocomplete search for $token failed.", e)
+ emptyList()
+ })
}
'#' -> {
- return try {
- api.searchObservable(query = token, type = SearchType.Hashtag.apiParameter, limit = 10)
- .blockingGet()
- .hashtags
- .map { ComposeAutoCompleteAdapter.HashtagResult(it) }
- } catch (e: Throwable) {
- Log.e(TAG, String.format("Autocomplete search for %s failed.", token), e)
- emptyList()
- }
+ return api.searchCall(query = token, type = SearchType.Hashtag.apiParameter, limit = 10)
+ .result()
+ .fold({ searchResult ->
+ searchResult.hashtags.map { AutocompleteResult.HashtagResult(it.name) }
+ }, { e ->
+ Log.e(TAG, "Autocomplete search for $token failed.", e)
+ emptyList()
+ })
}
':' -> {
val emojiList = emoji.value ?: return emptyList()
+ val incomplete = token.substring(1)
- val incomplete = token.substring(1).lowercase(Locale.ROOT)
- val results = ArrayList()
- val resultsInside = ArrayList()
- for (emoji in emojiList) {
- val shortcode = emoji.shortcode.lowercase(Locale.ROOT)
- if (shortcode.startsWith(incomplete)) {
- results.add(ComposeAutoCompleteAdapter.EmojiResult(emoji))
- } else if (shortcode.indexOf(incomplete, 1) != -1) {
- resultsInside.add(ComposeAutoCompleteAdapter.EmojiResult(emoji))
- }
+ return emojiList.filter { emoji ->
+ emoji.shortcode.contains(incomplete, ignoreCase = true)
+ }.sortedBy { emoji ->
+ emoji.shortcode.indexOf(incomplete, ignoreCase = true)
+ }.map { emoji ->
+ AutocompleteResult.EmojiResult(emoji)
}
- if (results.isNotEmpty() && resultsInside.isNotEmpty()) {
- results.add(ComposeAutoCompleteAdapter.ResultSeparator())
- }
- results.addAll(resultsInside)
- return results
}
else -> {
Log.w(TAG, "Unexpected autocompletion token: $token")
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 1dafa9522..8ea2ce6cb 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
@@ -90,12 +90,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)
@@ -118,7 +123,7 @@ class LoginActivity : BaseActivity(), Injectable {
textView?.movementMethod = LinkMovementMethod.getInstance()
}
- if (isAdditionalLogin()) {
+ if (isAdditionalLogin() || isAccountMigration()) {
setSupportActionBar(binding.toolbar)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
supportActionBar?.setDisplayShowTitleEnabled(false)
@@ -133,7 +138,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)
}
}
@@ -223,7 +228,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
@@ -255,19 +260,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 050d4b2ab..ced64d11a 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();
@@ -542,4 +542,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 4f58d9871..ab063d7e8 100644
--- a/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt
+++ b/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt
@@ -70,6 +70,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/Account.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Account.kt
index db01a5f9b..547de4618 100644
--- a/app/src/main/java/com/keylesspalace/tusky/entity/Account.kt
+++ b/app/src/main/java/com/keylesspalace/tusky/entity/Account.kt
@@ -23,6 +23,7 @@ data class Account(
@SerializedName("username") val localUsername: String,
@SerializedName("acct", alternate = ["subject"]) val username: String,
@SerializedName("display_name") private val displayName: String?, // should never be null per Api definition, but some servers break the contract
+ @SerializedName("created_at") val createdAt: Date,
val note: String,
val url: String,
val avatar: String,
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..3a34169c3 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
@@ -286,6 +288,14 @@ interface MastodonApi {
@Query("following") following: Boolean? = null
): Single>
+ @GET("api/v1/accounts/search")
+ fun searchAccountsCall(
+ @Query("q") query: String,
+ @Query("resolve") resolve: Boolean? = null,
+ @Query("limit") limit: Int? = null,
+ @Query("following") following: Boolean? = null
+ ): Call>
+
@GET("api/v1/accounts/{id}")
fun account(
@Path("id") accountId: String
@@ -591,10 +601,48 @@ interface MastodonApi {
@Query("following") following: Boolean? = null
): Single
+ @GET("api/v2/search")
+ fun searchCall(
+ @Query("q") query: String?,
+ @Query("type") type: String? = null,
+ @Query("resolve") resolve: Boolean? = null,
+ @Query("limit") limit: Int? = null,
+ @Query("offset") offset: Int? = null,
+ @Query("following") following: Boolean? = null
+ ): Call
+
@FormUrlEncoded
@POST("api/v1/accounts/{id}/note")
fun updateAccountNote(
@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/CallExtensions.kt b/app/src/main/java/com/keylesspalace/tusky/util/CallExtensions.kt
new file mode 100644
index 000000000..809dcd2b4
--- /dev/null
+++ b/app/src/main/java/com/keylesspalace/tusky/util/CallExtensions.kt
@@ -0,0 +1,23 @@
+package com.keylesspalace.tusky.util
+
+import retrofit2.Call
+import retrofit2.HttpException
+
+/**
+ * Synchronously executes the call and returns the response encapsulated in a kotlin.Result.
+ * Since Result is an inline class it is not possible to do this with a Retrofit adapter unfortunately.
+ * More efficient then calling a suspending method with runBlocking
+ */
+fun Call.result(): Result {
+ return try {
+ val response = execute()
+ val responseBody = response.body()
+ if (response.isSuccessful && responseBody != null) {
+ Result.success(responseBody)
+ } else {
+ Result.failure(HttpException(response))
+ }
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+}
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/java/net/accelf/yuito/AccessTokenLoginActivity.java b/app/src/main/java/net/accelf/yuito/AccessTokenLoginActivity.java
index 9b8b44404..ec38e88ba 100644
--- a/app/src/main/java/net/accelf/yuito/AccessTokenLoginActivity.java
+++ b/app/src/main/java/net/accelf/yuito/AccessTokenLoginActivity.java
@@ -98,7 +98,7 @@ public class AccessTokenLoginActivity extends AppCompatActivity implements Injec
}
private void authSucceeded(String domain, String accessToken) {
- accountManager.addAccount(accessToken, domain);
+ accountManager.addAccount(accessToken, domain, "");
log("Completed. Enjoy!");
Intent intent = new Intent(this, MainActivity.class);
diff --git a/app/src/main/res/layout/activity_account.xml b/app/src/main/res/layout/activity_account.xml
index 31b37ad89..ff548dfca 100644
--- a/app/src/main/res/layout/activity_account.xml
+++ b/app/src/main/res/layout/activity_account.xml
@@ -235,6 +235,19 @@
tools:itemCount="2"
tools:listitem="@layout/item_account_field" />
+
+
@@ -27,8 +27,9 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:tabGravity="fill"
+ app:tabMaxWidth="0dp"
app:tabMode="fixed"
- app:tabTextAppearance="@style/TuskyTabAppearance"/>
+ app:tabTextAppearance="@style/TuskyTabAppearance" />
@@ -38,6 +39,6 @@
android:layout_height="match_parent"
app:layout_behavior="@string/appbar_scrolling_view_behavior" />
-
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/item_autocomplete_account.xml b/app/src/main/res/layout/item_autocomplete_account.xml
index 681f9919f..000bae534 100644
--- a/app/src/main/res/layout/item_autocomplete_account.xml
+++ b/app/src/main/res/layout/item_autocomplete_account.xml
@@ -1,48 +1,65 @@
-
+ android:paddingStart="16dp"
+ android:paddingTop="8dp"
+ android:paddingEnd="16dp"
+ android:paddingBottom="8dp">
-
+
-
+
-
+
-
-
-
\ No newline at end of file
+
diff --git a/app/src/main/res/layout/item_autocomplete_divider.xml b/app/src/main/res/layout/item_autocomplete_divider.xml
deleted file mode 100644
index f9b211b03..000000000
--- a/app/src/main/res/layout/item_autocomplete_divider.xml
+++ /dev/null
@@ -1,5 +0,0 @@
-
-
\ No newline at end of file
diff --git a/app/src/main/res/layout/item_autocomplete_emoji.xml b/app/src/main/res/layout/item_autocomplete_emoji.xml
index 2f9100402..fbc2f5c98 100644
--- a/app/src/main/res/layout/item_autocomplete_emoji.xml
+++ b/app/src/main/res/layout/item_autocomplete_emoji.xml
@@ -5,24 +5,24 @@
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal"
- android:padding="8dp">
+ tools:ignore="UseCompoundDrawables">
+ android:layout_marginStart="16dp"
+ android:layout_marginTop="8dp"
+ android:layout_marginBottom="8dp"
+ android:importantForAccessibility="no" />
+ tools:text="#Tusky" />
diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml
index 5ad736979..009614be9 100644
--- a/app/src/main/res/values-ar/strings.xml
+++ b/app/src/main/res/values-ar/strings.xml
@@ -269,7 +269,6 @@
إضافة حساب ماستدون جديد
القوائم
القوائم
- الخط الزمني للقائمة
لا يمكن إنشاء قائمة
لا يمكن إعادة تسمية القائمة
لا يمكن حذف القائمة
diff --git a/app/src/main/res/values-bg/strings.xml b/app/src/main/res/values-bg/strings.xml
index 092db2183..a58d3eaf8 100644
--- a/app/src/main/res/values-bg/strings.xml
+++ b/app/src/main/res/values-bg/strings.xml
@@ -151,7 +151,6 @@
Списъкът не можа да се изтрие
Списъкът не можа да се създаде
Списъкът не можа да се преименува
- Списъчна емисия
Списъци
Списъци
Добавяне на нов Mastodon акаунт
diff --git a/app/src/main/res/values-bn-rBD/strings.xml b/app/src/main/res/values-bn-rBD/strings.xml
index 7d87e3ed8..331e5e189 100644
--- a/app/src/main/res/values-bn-rBD/strings.xml
+++ b/app/src/main/res/values-bn-rBD/strings.xml
@@ -75,7 +75,6 @@
তালিকা মুছে ফেলা যায়নি
তালিকা নামকরণ করা যায়নি
তালিকা তৈরি করা যায়নি
- তালিকা টাইমলাইনে রাখুন
তালিকাসমূহ
তালিকাসমূহ
নতুন ম্যাস্টোডোন অ্যাকাউন্ট যোগ করুন
diff --git a/app/src/main/res/values-bn-rIN/strings.xml b/app/src/main/res/values-bn-rIN/strings.xml
index be1d306b6..4ca0623fc 100644
--- a/app/src/main/res/values-bn-rIN/strings.xml
+++ b/app/src/main/res/values-bn-rIN/strings.xml
@@ -275,7 +275,6 @@
নতুন ম্যাস্টোডোন অ্যাকাউন্ট যোগ করুন
তালিকাসমূহ
তালিকাসমূহ
- তালিকা টাইমলাইনে রাখুন
তালিকা তৈরি করা যায়নি
তালিকা নামকরণ করা যায়নি
তালিকা মুছে ফেলা যায়নি
diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml
index 38c4dc962..264cde857 100644
--- a/app/src/main/res/values-ca/strings.xml
+++ b/app/src/main/res/values-ca/strings.xml
@@ -275,7 +275,6 @@
Afegir un compte de Mastodont
Llistes
Llistes
- Cronologia de la llista
És impossible crear la llista
Impossible reanomenar la llista
És impossible suprimir la llista
diff --git a/app/src/main/res/values-ckb/strings.xml b/app/src/main/res/values-ckb/strings.xml
index ba275b0b4..985ec63a5 100644
--- a/app/src/main/res/values-ckb/strings.xml
+++ b/app/src/main/res/values-ckb/strings.xml
@@ -403,7 +403,6 @@
نەیتوانی لیستەکە بسڕێتەوە
نەیتوانی ناوی لیست بنووسرێ
نەیتوانی لیست دروست بکات
- لیستی تایم لاین
لیستەکان
لیستەکان
زیادکردنی ئەژمێری ماتۆدۆنی نوێ
diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml
index 71966926d..00fc1bacf 100644
--- a/app/src/main/res/values-cs/strings.xml
+++ b/app/src/main/res/values-cs/strings.xml
@@ -274,7 +274,6 @@
Přidat nový účet Mastodon
Seznamy
Seznamy
- Časová osa seznamu
Nelze vytvořit seznam
Nelze přejmenovat seznam
Nelze smazat seznam
diff --git a/app/src/main/res/values-cy/strings.xml b/app/src/main/res/values-cy/strings.xml
index 6ec7cca82..9ca60f540 100644
--- a/app/src/main/res/values-cy/strings.xml
+++ b/app/src/main/res/values-cy/strings.xml
@@ -236,7 +236,6 @@
Ychwanegu cyfrif Mastodon newydd
Rhestri
Rhestri
- Amserlen rhestri
Yn postio â chyfrif %1$s
Methu gosod pennawd
Pennu pennawd
diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml
index 21172ccea..cef8b8169 100644
--- a/app/src/main/res/values-de/strings.xml
+++ b/app/src/main/res/values-de/strings.xml
@@ -256,7 +256,6 @@
Neues Mastodon-Konto hinzufügen
Listen
Listen
- Liste
Liste erstellen
Liste umbenennen
Liste löschen
diff --git a/app/src/main/res/values-eo/strings.xml b/app/src/main/res/values-eo/strings.xml
index 922a385b9..429a20322 100644
--- a/app/src/main/res/values-eo/strings.xml
+++ b/app/src/main/res/values-eo/strings.xml
@@ -271,7 +271,6 @@
Aldoni novan Mastodon konton
Listoj
Listoj
- Tempolinio de la listo
Ne povis krei la liston
Ne povis ŝanĝi la nomon de la listo
Ne povis forigi la liston
diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml
index bfee420fa..3fc102842 100644
--- a/app/src/main/res/values-es/strings.xml
+++ b/app/src/main/res/values-es/strings.xml
@@ -251,7 +251,6 @@
Añadir cuenta de Mastodon
Listas
Listas
- Cronología de lista
Publicando con la cuenta %1$s
Error al añadir leyenda
diff --git a/app/src/main/res/values-eu/strings.xml b/app/src/main/res/values-eu/strings.xml
index 7a3a4ded2..c70b1ed6c 100644
--- a/app/src/main/res/values-eu/strings.xml
+++ b/app/src/main/res/values-eu/strings.xml
@@ -235,7 +235,6 @@
Mastodon kontua gehitu
Zerrendak
Zerrendak
- Zerrenda denbora-lerroa
%1$s kontuarekin tut egiten
Akatsa deskribapena eranstean
diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml
index 72b2453ba..5585f2d11 100644
--- a/app/src/main/res/values-fa/strings.xml
+++ b/app/src/main/res/values-fa/strings.xml
@@ -229,7 +229,6 @@
افزودن حساب ماستودون جدید
فهرستها
فهرستها
- خط زمانی فهرست
در حال فرستادن با حساب %1$s
شکست در تنظیم عنوان
diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml
index 62e667601..bdc3a4e01 100644
--- a/app/src/main/res/values-fr/strings.xml
+++ b/app/src/main/res/values-fr/strings.xml
@@ -275,7 +275,6 @@
Ajouter un nouveau compte Mastodon
Listes
Listes
- Fil de la liste
Impossible de créer la liste
Impossible de renommer la liste
Impossible de supprimer la liste
@@ -295,7 +294,7 @@
Mettre une légende
Supprimer le média
Verrouiller le compte
- Vous devez approuvez manuellement les abonnements
+ Vous devez approuver manuellement les abonnements
Enregistrer comme brouillon ?
Envoi du pouet…
Erreur lors de l’envoi du pouet
diff --git a/app/src/main/res/values-ga/strings.xml b/app/src/main/res/values-ga/strings.xml
index 2ea95dc97..382566c0a 100644
--- a/app/src/main/res/values-ga/strings.xml
+++ b/app/src/main/res/values-ga/strings.xml
@@ -310,7 +310,6 @@
Theip ar stádas a fháil
Seolfar an tuarascáil chuig do mhodhnóir freastalaí. Féadfaidh tú míniú a thabhairt ar an bhfáth go bhfuil tú ag tuairisciú an chuntais seo thíos:
Cuir Cuntas Mastodon nua leis
- Liostaigh amlíne
Níorbh fhéidir liosta a chruthú
Níorbh fhéidir an liosta a athainmniú
Níorbh fhéidir an liosta a scriosadh
diff --git a/app/src/main/res/values-gd/strings.xml b/app/src/main/res/values-gd/strings.xml
index e27b677aa..39e7d5a32 100644
--- a/app/src/main/res/values-gd/strings.xml
+++ b/app/src/main/res/values-gd/strings.xml
@@ -92,7 +92,7 @@
Brathan nuair a dh’fhoillsich cuideigin air a rinn mi fo-sgrìobhadh post ùr
Postaichean ùra
dh’fhoillsich cuideigin air a rinn mi fo-sgrìobhadh post ùr
- Tha %s air rud a phostadh
+ Phostaich %s rud
Chan eil brath-fios ann.
Brathan-fios
Chaidh a shàbhaladh!
@@ -270,7 +270,6 @@
Cha b’ urrainn dhuinn an liosta a sguabadh às
Cha b’ urrainn dhut ainm ùr a thoirt air an liosta
Cha b’ urrainn dhuinn an liosta a chruthachadh
- Loidhne-ama na liosta
Cuir cunntas Mastodon ùr ris
Cuir cunntas ris
An abairt ri chriathradh
@@ -295,7 +294,7 @@
Cuir post air an sgeideal
Faicsinneachd a’ phuist
Postaichean air an sgeideal
- Chuir %s am post agad ris na h-annsachdan
+ Is annsa le %s am post agad
Bhrosnaich %s am post agad
Postaichean air an sgeideal
Snàithlean
@@ -556,4 +555,5 @@
chaidh post a rinn mi conaltradh leis a deasachadh
Clàraich a-steach
Cha b’ urrainn dhuinn duilleag a’ chlàraidh a-steach fhosgladh.
+ A’ sàbhaladh na dreuchd…
\ No newline at end of file
diff --git a/app/src/main/res/values-gl/strings.xml b/app/src/main/res/values-gl/strings.xml
index 5b488df36..2b6be45eb 100644
--- a/app/src/main/res/values-gl/strings.xml
+++ b/app/src/main/res/values-gl/strings.xml
@@ -275,7 +275,6 @@
Non se puido eliminar a listaxe
Non se puido renomear a listaxe
Non se puido crear a listaxe
- Cronoloxía da listaxe
Listaxes
Listaxes
Engadir unha nova conta Mastodon
diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml
index 2ecee5413..dba9309b0 100644
--- a/app/src/main/res/values-hi/strings.xml
+++ b/app/src/main/res/values-hi/strings.xml
@@ -248,7 +248,6 @@
लिखने को सुरक्षित करें\?
खाता लॉक करें
कैप्शन सेट करें
- सूची टाइमलाइन
खाता जोड़ो
पूरा शब्द
फ़िल्टर संपादित करें
diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml
index 4bcd9a109..faa9b2ce8 100644
--- a/app/src/main/res/values-hu/strings.xml
+++ b/app/src/main/res/values-hu/strings.xml
@@ -336,7 +336,6 @@
%dmp múlva
Teljes szó
Ha a kulcsszó csak alfanumerikus karakterekből áll, csak teljes szóra fog illeszkedni
- Lista idővonal
Általad követettek keresése
Fiók hozzáadása a listához
Fiók eltávolítása a listából
diff --git a/app/src/main/res/values-is/strings.xml b/app/src/main/res/values-is/strings.xml
index 14a38b275..0177ee5c1 100644
--- a/app/src/main/res/values-is/strings.xml
+++ b/app/src/main/res/values-is/strings.xml
@@ -288,7 +288,6 @@
Frasi sem á að sía
Bæta við aðgang
Bæta við nýjum Mastodon-aðgangi
- Lista upp tímalínu
Ekki tókst að búa til lista
Ekki tókst að endurnefna lista
Ekki tókst að eyða lista
diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml
index 82604ae68..a55b5f223 100644
--- a/app/src/main/res/values-it/strings.xml
+++ b/app/src/main/res/values-it/strings.xml
@@ -269,7 +269,6 @@
Aggiungi un nuovo Account Mastodon
Liste
Liste
- Timeline della lista
Non è stato possibile creare la lista
Non è stato possibile rinominare la lista
Non è stato possibile eliminare la lista
diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml
index cf5c9f9b5..9981724f0 100644
--- a/app/src/main/res/values-ja/strings.xml
+++ b/app/src/main/res/values-ja/strings.xml
@@ -264,7 +264,6 @@
新しいMastodonアカウントを追加
リスト
リスト
- リストタイムライン
リスト名を変更できませんでした
リスト名の変更
%1$sで投稿
diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml
index fc81f2d3f..e8143bb95 100644
--- a/app/src/main/res/values-ko/strings.xml
+++ b/app/src/main/res/values-ko/strings.xml
@@ -282,7 +282,6 @@
마스토돈 계정을 추가합니다
리스트
리스트
- 리스트 타임라인
리스트를 만들 수 없습니다.
리스트의 이름을 변경할 수 없습니다.
리스트를 삭제할 수 없습니다.
diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml
index f746bef34..92c1611a9 100644
--- a/app/src/main/res/values-nl/strings.xml
+++ b/app/src/main/res/values-nl/strings.xml
@@ -259,7 +259,6 @@
Een nieuw Mastodonaccount toevoegen
Lijsten
Lijsten
- Tijdlijn lijst
Aan het publiceren met account %1$s
Toevoegen van beschrijving mislukt
diff --git a/app/src/main/res/values-no-rNB/strings.xml b/app/src/main/res/values-no-rNB/strings.xml
index 4549fadaa..1561efa98 100644
--- a/app/src/main/res/values-no-rNB/strings.xml
+++ b/app/src/main/res/values-no-rNB/strings.xml
@@ -242,7 +242,6 @@
Legg til ny Mastodon-konto
Lister
Lister
- Listetidslinje
Kunne ikke opprette liste
Kunne ikke gi liste nytt navn
Kunne ikke slette liste
diff --git a/app/src/main/res/values-oc/strings.xml b/app/src/main/res/values-oc/strings.xml
index 8c2557e58..84fc5abee 100644
--- a/app/src/main/res/values-oc/strings.xml
+++ b/app/src/main/res/values-oc/strings.xml
@@ -228,7 +228,6 @@
Apondre un nòu compte Mastodon
Listas
Listas
- Flux de la lista
Publicar amb lo compte %1$s
Fracàs en apondre una legenda
Apondre una legenda
diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml
index cf6b392aa..7b6fb6215 100644
--- a/app/src/main/res/values-pl/strings.xml
+++ b/app/src/main/res/values-pl/strings.xml
@@ -20,7 +20,7 @@
Strona główna
Powiadomienia
Lokalne
- Globalne
+ Sfederowane
Wątek
Wpisy
Z odpowiedziami
@@ -33,12 +33,12 @@
Edytuj profil
Szkice
Licencje
- %s podbił
- Wrażliwe treści
- Ukryto zawartość multimedialną
+ %s podbite
+ Treści wrażliwe
+ Ukryto multimedia
Naciśnij, aby wyświetlić
Pokaż więcej
- Ukryj
+ Pokaż mniej
Pusto tutaj. Pociągnij, aby odświeżyć!
%s podbił(-a) Twój wpis
%s dodał Twój post do ulubionych
@@ -95,13 +95,13 @@
Klawiatura emoji
Pobieranie %1$s
Skopiuj odnośnik
- Udostępnij odnośnik do wpisu…
+ Udostępnij URL do…
Udostępnij wpis do…
Wyślij!
Odblokowano użytkownika
Cofnięto wyciszenie użytkownika
Wyślij!
- Pomyślnie wysłano odpowiedź.
+ Odpowiedź wysłano pomyślnie.
Jaka instancja?
Co Ci chodzi po głowie?
Ostrzeżenie o zawartości
@@ -151,7 +151,7 @@
Używaj niestandardowych kart Chrome
Ukryj przycisk śledzenia podczas przewijania
Filtrowanie osi czasu
- Zakładki
+ Karty
Pokaż podbicia
Pokazuj odpowiedzi
Pokazuj podgląd zawartości multimedialnej
@@ -183,10 +183,10 @@
%1$s, %2$s, i %3$s
%1$s i %2$s
- - %d nowe powiadomienie
- - %d nowe powiadomienia
- - %d nowych powiadomień
- - %d nowych powiadomień
+ - %d nowa interakcja
+ - %d nowe interakcje
+ - %d nowych interakcji
+ - %d nowych interakcji
Konto zablokowane
O programie
@@ -229,7 +229,6 @@
Dodaj nowe Konto Mastodon
Listy
Listy
- Oś czasu listy
Publikowanie z konta %1$s
Nie udało się ustawić podpisu
Ustaw podpis
@@ -404,25 +403,25 @@
Głosowanie w którym brałeś(-aś) udział zakończyła się
Ankieta, którą stworzyłeś(aś), zakończyła się
- - Zostało %d dzień
+ - Został %d dzień
- Zostało %d dni
- Zostało %d dni
- Zostało %d dni
- - Zostało %d godzina
+ - Została %d godzina
- Zostało %d godziny
- Zostało %d godzin
- Zostało %d godzin
- - Zostało %d minuta
+ - Została %d minuta
- Zostało %d minuty
- Zostało %d minut
- Zostało %d minut
- - Zostało %d sekunda
+ - Została %d sekunda
- Zostało %d sekund
- Zostało %d sekund
- Zostało %d sekund
@@ -462,7 +461,7 @@
Zakładki
Dodaj do zakładek
Zakładki
- Dodane do zakładek
+ Dodany do zakładek
Wybierz listę
Lista
Pliki audio muszą być mniejsze niż 40MB.
@@ -493,8 +492,8 @@
Dół
Góra
- - Nie możesz przesłać więcej niż %1$d załącznika.
- - Nie możesz przesłać więcej niż %1$d załączników.
+ - Nie możesz przesłać więcej niż %1$d załącznik.
+ - Nie możesz przesłać więcej niż %1$d załączniki.
- Nie możesz przesłać więcej niż %1$d załączników.
- Nie możesz przesłać więcej niż %1$d załączników.
@@ -515,21 +514,21 @@
Włącz gest przesuwania by przełączać między zakładkami
Załączniki
Powiadomienia o prośbach o obserwowanie
- ktoś kogo zasubskrybowałem/zasubskrybowałam opublikował nowy wpis
+ ktoś zasubskrybowany opublikował nowy wpis
Wysłano prośbę o obserwowanie
Ogłoszenia
Zdrowie
Anuluj subskrypcję
Zasubskrybuj
Mimo tego, że twoje konto nie jest zablokowane, administracja %1$s uznała, że możesz chcieć ręcznie przejrzeć te prośby o możliwość śledzenia od tych kont.
- Wpis dla którego naszkicowałeś/naszkicowałaś odpowiedź został usunięty
+ Wpis dla którego naszkicowałeś/aś odpowiedź został usunięty
Usunięto szkic
Ukryj ilościowe statystyki na profilach
Ukryj ilościowe statystyki na postach
Przejrzyj powiadomienia
Zapisano!
Twoja prywatna notatka o tym koncie
- Nieskończona
+ Nieograniczony
Dźwięk
Powiadomienia o opublikowaniu nowego wpisu przez kogoś, kogo obserwujesz
Pozycja głównego paska nawigacji
@@ -552,9 +551,11 @@
%s zarejestrował(a) się
Rejestracje
Powiadomienia o nowych użytkownikach
- Powiadomienia o edycji wpisów z którymi interaktowałeś/aś
+ Powiadomienia o edycji wpisów z którymi dokonałeś/aś interakcji
ktoś zarejestrował się
- wpis, z którym interaktowałem/am został edytowany
+ wpis, z którym dokonałem/am interakcji został edytowany
%s edytował(a) swój wpis
Edycje wpisów
+ Zapisywanie szkicu…
+ Nie można załadować strony logowania.
\ No newline at end of file
diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml
index 5c558b3d7..68edd4896 100644
--- a/app/src/main/res/values-pt-rBR/strings.xml
+++ b/app/src/main/res/values-pt-rBR/strings.xml
@@ -246,7 +246,6 @@
Adicionar nova conta Mastodon
Listas
Listas
- Linha da lista
Usando a conta %1$s
Erro ao incluir descrição
Descrever
diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml
index 6be06b0c2..fa4b458e3 100644
--- a/app/src/main/res/values-pt-rPT/strings.xml
+++ b/app/src/main/res/values-pt-rPT/strings.xml
@@ -325,7 +325,7 @@
Listas
Não foi possível renomear a lista
Listas
- Cronologia da timeline
+
Não foi possível criar a lista
Não foi possível apagar a lista
Criar uma lista
diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml
index c980fb550..b35eb2c2d 100644
--- a/app/src/main/res/values-ru/strings.xml
+++ b/app/src/main/res/values-ru/strings.xml
@@ -296,7 +296,6 @@
Добавить новый акканут Mastodon
Списки
Списки
- Список лент
Не удалось создать список
Не удалось переименовать список
Не удалось удалить список
diff --git a/app/src/main/res/values-sa/strings.xml b/app/src/main/res/values-sa/strings.xml
index 32b651bf9..e304b1657 100644
--- a/app/src/main/res/values-sa/strings.xml
+++ b/app/src/main/res/values-sa/strings.xml
@@ -202,7 +202,6 @@
पुनः सूचिनामकरणं कर्तुमशक्यम्
सूचिनिर्माणं कर्तुमशक्यम्
अनुसरणानुरोधो नश्यताम् \?
- सूचेः समयतालिका
सूचयः
सूचयः
नवमास्टोडोनलेखा युज्यताम्
diff --git a/app/src/main/res/values-sl/strings.xml b/app/src/main/res/values-sl/strings.xml
index 8fae9fb3e..a068f8a63 100644
--- a/app/src/main/res/values-sl/strings.xml
+++ b/app/src/main/res/values-sl/strings.xml
@@ -247,7 +247,6 @@
Dodaj nov Mastodon račun
Seznami
Seznami
- Seznam časovnice
Seznama ni bilo mogoče ustvariti
Seznama ni bilo mogoče preimenovati
Seznama ni bilo mogoče izbrisati
diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml
index a4c9cabcd..461aa12c0 100644
--- a/app/src/main/res/values-sv/strings.xml
+++ b/app/src/main/res/values-sv/strings.xml
@@ -269,7 +269,6 @@
Lägg till ett nytt Mastodon-konto
Listor
Listor
- Lista tidslinje
Kunde inte skapa lista
Kunde inte byta namn på lista
Kunde inte radera lista
diff --git a/app/src/main/res/values-ta/strings.xml b/app/src/main/res/values-ta/strings.xml
index f92f66854..faebf8f71 100644
--- a/app/src/main/res/values-ta/strings.xml
+++ b/app/src/main/res/values-ta/strings.xml
@@ -216,7 +216,6 @@
புதிய Mastodon கணக்கைச் சேர்க்க
பட்டியல்கள்
பட்டியல்கள்
- காலவரிசை பட்டியல்
%1$s கணக்குடன் பதிவிட
தலைப்பை அமைக்க முடியவில்லை
தலைப்பை அமை
diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml
index 98e6ab1e1..e094542c9 100644
--- a/app/src/main/res/values-th/strings.xml
+++ b/app/src/main/res/values-th/strings.xml
@@ -144,7 +144,6 @@
ไม่สามารถลบรายการได้
ไม่สามารถเปลี่ยนชื่อรายการได้
ไม่สามารถสร้างรายการได้
- ไทม์ไลน์ในรายการ
เพิ่มบัญชี Mastodon ใหม่
เพิ่มบัญชี
วลีที่ต้องการกรอง
diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml
index 5694fe27d..f1a961dce 100644
--- a/app/src/main/res/values-tr/strings.xml
+++ b/app/src/main/res/values-tr/strings.xml
@@ -243,7 +243,6 @@
Yeni Mastodon hesabı ekle
Listeler
Listeler
- Zaman çizelgesini listele
%1$s hesabıyla gönderiliyor
- Görsel engelli için tanımla
diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml
index 6c5100970..6e6924a58 100644
--- a/app/src/main/res/values-uk/strings.xml
+++ b/app/src/main/res/values-uk/strings.xml
@@ -285,7 +285,6 @@
Не вдалося видалити список
Не вдалося перейменувати список
Не вдалося створити список
- Стрічка списку
Додати новий обліковий запис Mastodon
Додати обліковий запис
Фільтрувати фразу
@@ -550,4 +549,5 @@
Редакції допису
Вхід
Не вдалося завантажити сторінку входу.
+ Збереження чернетки…
\ No newline at end of file
diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml
index 0c8109db4..6788526a4 100644
--- a/app/src/main/res/values-vi/strings.xml
+++ b/app/src/main/res/values-vi/strings.xml
@@ -448,7 +448,6 @@
Xóa danh sách
Đổi tên danh sách
Tạo danh sách
- Danh sách bảng tin
Thêm tài khoản Mastodon
Thêm tài khoản
Thêm mô tả
@@ -517,4 +516,5 @@
Thông báo khi tút mà tôi tương tác bị sửa
Đăng nhập
Không thể tải trang đăng nhập.
+ Đang lưu nháp…
\ No newline at end of file
diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml
index 301493aaf..ed74d003e 100644
--- a/app/src/main/res/values-zh-rCN/strings.xml
+++ b/app/src/main/res/values-zh-rCN/strings.xml
@@ -277,7 +277,6 @@
添加新的 Mastodon 帐号
列表
列表
- 列表时间轴
无法新建列表
无法重命名列表
无法删除列表
@@ -536,4 +535,5 @@
嘟文编辑
当你进行过互动的嘟文被编辑时发出通知
无法加载登录页。
+ 正在保存草稿…
diff --git a/app/src/main/res/values-zh-rHK/strings.xml b/app/src/main/res/values-zh-rHK/strings.xml
index a07964f3b..01226dd7a 100644
--- a/app/src/main/res/values-zh-rHK/strings.xml
+++ b/app/src/main/res/values-zh-rHK/strings.xml
@@ -276,7 +276,6 @@
加入新的 Mastodon 帳號
列表
列表
- 列表時間軸
無法新建列表
無法重命名列表
無法刪除列表
diff --git a/app/src/main/res/values-zh-rMO/strings.xml b/app/src/main/res/values-zh-rMO/strings.xml
index 2a29b28fa..e53d9b1bf 100644
--- a/app/src/main/res/values-zh-rMO/strings.xml
+++ b/app/src/main/res/values-zh-rMO/strings.xml
@@ -270,7 +270,6 @@
加入新的 Mastodon 帳號
列表
列表
- 列表時間軸
無法新建列表
無法重命名列表
無法刪除列表
diff --git a/app/src/main/res/values-zh-rSG/strings.xml b/app/src/main/res/values-zh-rSG/strings.xml
index fb2b51ab1..b9fee4f85 100644
--- a/app/src/main/res/values-zh-rSG/strings.xml
+++ b/app/src/main/res/values-zh-rSG/strings.xml
@@ -274,7 +274,6 @@
添加新的 Mastodon 帐号
列表
列表
- 列表时间轴
无法新建列表
无法重命名列表
无法删除列表
diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml
index 476fee9c7..573e600a0 100644
--- a/app/src/main/res/values-zh-rTW/strings.xml
+++ b/app/src/main/res/values-zh-rTW/strings.xml
@@ -276,7 +276,6 @@
加入新的 Mastodon 帳號
列表
列表
- 列表時間軸
無法新建列表
無法重命名列表
無法刪除列表
@@ -525,4 +524,6 @@
總是顯示被標注為內容警告的嘟文
搜尋失敗
帳號
+ 登入
+ 無法載入登入頁面。
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 17a56282d..2a91779ea 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -44,6 +44,7 @@
Muted users
Blocked users
Hidden domains
+ Re-login for push notifications
Follow Requests
Edit your profile
Drafts
@@ -152,6 +153,8 @@
Open boost author
Show boosts
Show favorites
+ Dismiss
+ Details
Quote
Authorize Now!
Jump to top
@@ -413,7 +416,6 @@
Lists
Lists
- List timeline
Could not create list
Could not rename list
Could not delete list
@@ -672,6 +674,14 @@
Unsubscribe
Compose Post
+
+ Joined %1$s
+
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.
+
+
diff --git a/app/src/test/java/com/keylesspalace/tusky/ComposeActivityTest.kt b/app/src/test/java/com/keylesspalace/tusky/ComposeActivityTest.kt
index 3a8f2f23e..95d1bae0e 100644
--- a/app/src/test/java/com/keylesspalace/tusky/ComposeActivityTest.kt
+++ b/app/src/test/java/com/keylesspalace/tusky/ComposeActivityTest.kt
@@ -47,6 +47,8 @@ import org.robolectric.Robolectric
import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config
import org.robolectric.fakes.RoboMenuItem
+import java.util.Date
+import kotlin.collections.HashMap
/**
* Created by charlag on 3/7/18.
@@ -466,22 +468,23 @@ class ComposeActivityTest {
null,
listOf("en"),
Account(
- "1",
- "admin",
- "admin",
- "admin",
- "",
- "https://example.token",
- "",
- "",
- false,
- 0,
- 0,
- 0,
- null,
- false,
- emptyList(),
- emptyList()
+ id = "1",
+ localUsername = "admin",
+ username = "admin",
+ displayName = "admin",
+ createdAt = Date(),
+ note = "",
+ url = "https://example.token",
+ avatar = "",
+ header = "",
+ locked = false,
+ statusesCount = 0,
+ followersCount = 0,
+ followingCount = 0,
+ source = null,
+ bot = false,
+ emojis = emptyList(),
+ fields = emptyList(),
),
maximumLegacyTootCharacters,
null,
diff --git a/app/src/test/java/com/keylesspalace/tusky/ComposeTokenizerTest.kt b/app/src/test/java/com/keylesspalace/tusky/ComposeTokenizerTest.kt
index e203dde27..fa0bba94e 100644
--- a/app/src/test/java/com/keylesspalace/tusky/ComposeTokenizerTest.kt
+++ b/app/src/test/java/com/keylesspalace/tusky/ComposeTokenizerTest.kt
@@ -15,7 +15,7 @@
package com.keylesspalace.tusky
-import com.keylesspalace.tusky.util.ComposeTokenizer
+import com.keylesspalace.tusky.components.compose.ComposeTokenizer
import org.junit.Assert
import org.junit.Test
import org.junit.runner.RunWith
diff --git a/build.gradle b/build.gradle
index 3a5251fa0..725ab8da4 100644
--- a/build.gradle
+++ b/build.gradle
@@ -5,8 +5,8 @@ buildscript {
gradlePluginPortal()
}
dependencies {
- classpath "com.android.tools.build:gradle:7.1.2"
- classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.6.20"
+ classpath "com.android.tools.build:gradle:7.2.0"
+ classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.6.21"
classpath "org.jlleitschuh.gradle:ktlint-gradle:10.2.1"
}
}
diff --git a/fastlane/metadata/android/uk/changelogs/91.txt b/fastlane/metadata/android/uk/changelogs/91.txt
new file mode 100644
index 000000000..4132d1553
--- /dev/null
+++ b/fastlane/metadata/android/uk/changelogs/91.txt
@@ -0,0 +1,6 @@
+Tusky v18.0
+
+- Підтримка нових типів сповіщень Mastodon 3.5
+- Кращий вигляд позначки бота і розширений вибір тем
+- Текст тепер можна вибрати у докладному поданні допису
+- Виправлено безліч помилок, включно з тою, яка перешкоджала входу на Android 6 і старіших
diff --git a/fastlane/metadata/android/vi/changelogs/91.txt b/fastlane/metadata/android/vi/changelogs/91.txt
new file mode 100644
index 000000000..2835fdfcb
--- /dev/null
+++ b/fastlane/metadata/android/vi/changelogs/91.txt
@@ -0,0 +1,6 @@
+Tusky v18.0
+
+- Hỗ trợ những kiểu thông báo mới của Mastodon 3.5
+- Nhãn của tài khoản nhìn đẹp hơn và thay đổi theo chủ đề
+- Cho phép chọn và sao chép nội dung tút
+- Sửa lỗi chặn đăng nhập trên Android 6 trở xuống
diff --git a/fastlane/metadata/android/zh-Hans/changelogs/83.txt b/fastlane/metadata/android/zh-Hans/changelogs/83.txt
new file mode 100644
index 000000000..e8f7c36e0
--- /dev/null
+++ b/fastlane/metadata/android/zh-Hans/changelogs/83.txt
@@ -0,0 +1,3 @@
+Tusky v15.1
+
+此版本修复了给图片添加标题时会崩溃的问题
diff --git a/fastlane/metadata/android/zh-Hans/changelogs/87.txt b/fastlane/metadata/android/zh-Hans/changelogs/87.txt
new file mode 100644
index 000000000..06fcd290f
--- /dev/null
+++ b/fastlane/metadata/android/zh-Hans/changelogs/87.txt
@@ -0,0 +1,8 @@
+Tusky v16.0
+
+- 时间线加载逻辑完全重写,提升了流畅度、稳定性,更便于维护。
+- APNG和动画WebP格式的动态自定义表情符号。
+- 修正大量BUG
+- 支持Android 11
+- 新增界面语言支持:苏格兰盖尔语、加利西亚语、乌克兰语
+- 改进翻译