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 +- 新增界面语言支持:苏格兰盖尔语、加利西亚语、乌克兰语 +- 改进翻译