diff --git a/app/build.gradle b/app/build.gradle index b153ae0b..19e094d5 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() } @@ -21,7 +25,7 @@ android { minSdkVersion 21 targetSdkVersion 31 versionCode 87 - versionName "17.0-CW1" + versionName "18.0-CW1" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables.useSupportLibrary = true @@ -89,7 +93,7 @@ android { } } -ext.coroutinesVersion = "1.6.0" +ext.coroutinesVersion = "1.6.1" ext.lifecycleVersion = "2.4.1" ext.roomVersion = '2.4.2' ext.retrofitVersion = '2.9.0' @@ -97,11 +101,11 @@ ext.okhttpVersion = '4.9.3' ext.glideVersion = '4.13.1' ext.daggerVersion = '2.41' ext.materialdrawerVersion = '8.4.5' +ext.emoji2_version = '1.1.0' +ext.filemojicompat_version = '3.2.1' // if libraries are changed here, they should also be changed in LicenseActivity dependencies { - implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" - implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-rx3:$coroutinesVersion" @@ -115,8 +119,9 @@ dependencies { implementation "androidx.cardview:cardview:1.0.0" implementation "androidx.preference:preference-ktx:1.2.0" implementation "androidx.sharetarget:sharetarget:1.2.0-rc01" - implementation "androidx.emoji:emoji:1.1.0" - implementation "androidx.emoji:emoji-appcompat:1.1.0" + implementation "androidx.emoji2:emoji2:$emoji2_version" + implementation "androidx.emoji2:emoji2-views:$emoji2_version" + implementation "androidx.emoji2:emoji2-views-helper:$emoji2_version" implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycleVersion" implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycleVersion" implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycleVersion" @@ -127,7 +132,6 @@ dependencies { implementation "androidx.work:work-runtime:2.7.1" implementation "androidx.room:room-ktx:$roomVersion" implementation "androidx.room:room-paging:$roomVersion" - implementation "androidx.room:room-rxjava3:$roomVersion" kapt "androidx.room:room-compiler:$roomVersion" implementation 'androidx.core:core-splashscreen:1.0.0-beta02' @@ -138,6 +142,7 @@ dependencies { implementation "com.squareup.retrofit2:retrofit:$retrofitVersion" implementation "com.squareup.retrofit2:converter-gson:$retrofitVersion" implementation "com.squareup.retrofit2:adapter-rxjava3:$retrofitVersion" + implementation "at.connyduck:kotlin-result-calladapter:1.0.1" implementation "com.squareup.okhttp3:okhttp:$okhttpVersion" implementation "com.squareup.okhttp3:logging-interceptor:$okhttpVersion" @@ -173,12 +178,14 @@ dependencies { implementation "com.github.CanHub:Android-Image-Cropper:4.1.0" - implementation "de.c1710:filemojicompat:1.0.18" + implementation "de.c1710:filemojicompat-ui:$filemojicompat_version" + implementation "de.c1710:filemojicompat:$filemojicompat_version" + implementation "de.c1710:filemojicompat-defaults:$filemojicompat_version" testImplementation "androidx.test.ext:junit:1.1.3" testImplementation "org.robolectric:robolectric:4.4" - testImplementation "org.mockito:mockito-inline:3.6.28" - testImplementation "com.nhaarman.mockitokotlin2:mockito-kotlin:2.2.0" + testImplementation "org.mockito:mockito-inline:4.4.0" + testImplementation "org.mockito.kotlin:mockito-kotlin:4.0.0" androidTestImplementation "androidx.test.espresso:espresso-core:3.4.0" androidTestImplementation "androidx.room:room-testing:$roomVersion" diff --git a/app/schemas/com.keylesspalace.tusky.db.AppDatabase/32.json b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/32.json new file mode 100644 index 00000000..97ad414e --- /dev/null +++ b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/32.json @@ -0,0 +1,815 @@ +{ + "formatVersion": 1, + "database": { + "version": 32, + "identityHash": "c92343960c9d46d9cfd49f1873cce47d", + "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, `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)", + "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": "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 + } + ], + "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, 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 + } + ], + "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_collapsible` 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.collapsible", + "columnName": "s_collapsible", + "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, 'c92343960c9d46d9cfd49f1873cce47d')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.keylesspalace.tusky.db.AppDatabase/33.json b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/33.json new file mode 100644 index 00000000..e6d8ec7d --- /dev/null +++ b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/33.json @@ -0,0 +1,809 @@ +{ + "formatVersion": 1, + "database": { + "version": 33, + "identityHash": "920a0e0c9a600bd236f6bf959b469c18", + "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, `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)", + "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": "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 + } + ], + "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, 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 + } + ], + "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, '920a0e0c9a600bd236f6bf959b469c18')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.keylesspalace.tusky.db.AppDatabase/34.json b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/34.json new file mode 100644 index 00000000..c1354690 --- /dev/null +++ b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/34.json @@ -0,0 +1,815 @@ +{ + "formatVersion": 1, + "database": { + "version": 34, + "identityHash": "7f766d68ab5d72a7988cd81c183e9a9d", + "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)", + "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 + } + ], + "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, 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 + } + ], + "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, '7f766d68ab5d72a7988cd81c183e9a9d')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.keylesspalace.tusky.db.AppDatabase/35.json b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/35.json new file mode 100644 index 00000000..9b71adf2 --- /dev/null +++ b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/35.json @@ -0,0 +1,821 @@ +{ + "formatVersion": 1, + "database": { + "version": 35, + "identityHash": "9e6c0bb60538683a16c30fa3e1cc24f2", + "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)", + "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 + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_AccountEntity_domain_accountId", + "unique": true, + "columnNames": [ + "domain", + "accountId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_AccountEntity_domain_accountId` ON `${TABLE_NAME}` (`domain`, `accountId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "InstanceEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`instance` TEXT NOT NULL, `emojiList` TEXT, `maximumTootCharacters` INTEGER, `maxPollOptions` INTEGER, `maxPollOptionLength` INTEGER, `minPollDuration` INTEGER, `maxPollDuration` INTEGER, `charactersReservedPerUrl` INTEGER, `version` TEXT, PRIMARY KEY(`instance`))", + "fields": [ + { + "fieldPath": "instance", + "columnName": "instance", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojiList", + "columnName": "emojiList", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maximumTootCharacters", + "columnName": "maximumTootCharacters", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollOptions", + "columnName": "maxPollOptions", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollOptionLength", + "columnName": "maxPollOptionLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "minPollDuration", + "columnName": "minPollDuration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollDuration", + "columnName": "maxPollDuration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "charactersReservedPerUrl", + "columnName": "charactersReservedPerUrl", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "instance" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "TimelineStatusEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `url` TEXT, `timelineUserId` INTEGER NOT NULL, `authorServerId` TEXT, `inReplyToId` TEXT, `inReplyToAccountId` TEXT, `content` TEXT, `createdAt` INTEGER NOT NULL, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` INTEGER NOT NULL, `reblogged` INTEGER NOT NULL, `bookmarked` INTEGER NOT NULL, `favourited` INTEGER NOT NULL, `sensitive` INTEGER NOT NULL, `spoilerText` TEXT NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT, `mentions` TEXT, `tags` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` TEXT, `poll` TEXT, `muted` INTEGER, `expanded` INTEGER NOT NULL, `contentCollapsed` INTEGER NOT NULL, `contentShowing` INTEGER NOT NULL, `pinned` INTEGER NOT NULL, `card` TEXT, PRIMARY KEY(`serverId`, `timelineUserId`), FOREIGN KEY(`authorServerId`, `timelineUserId`) REFERENCES `TimelineAccountEntity`(`serverId`, `timelineUserId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorServerId", + "columnName": "authorServerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToAccountId", + "columnName": "inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogsCount", + "columnName": "reblogsCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favouritesCount", + "columnName": "favouritesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reblogged", + "columnName": "reblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookmarked", + "columnName": "bookmarked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favourited", + "columnName": "favourited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sensitive", + "columnName": "sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "spoilerText", + "columnName": "spoilerText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "mentions", + "columnName": "mentions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "application", + "columnName": "application", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogServerId", + "columnName": "reblogServerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogAccountId", + "columnName": "reblogAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "muted", + "columnName": "muted", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "expanded", + "columnName": "expanded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentCollapsed", + "columnName": "contentCollapsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentShowing", + "columnName": "contentShowing", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pinned", + "columnName": "pinned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "card", + "columnName": "card", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "serverId", + "timelineUserId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_TimelineStatusEntity_authorServerId_timelineUserId", + "unique": false, + "columnNames": [ + "authorServerId", + "timelineUserId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TimelineStatusEntity_authorServerId_timelineUserId` ON `${TABLE_NAME}` (`authorServerId`, `timelineUserId`)" + } + ], + "foreignKeys": [ + { + "table": "TimelineAccountEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "authorServerId", + "timelineUserId" + ], + "referencedColumns": [ + "serverId", + "timelineUserId" + ] + } + ] + }, + { + "tableName": "TimelineAccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `timelineUserId` INTEGER NOT NULL, `localUsername` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `url` TEXT NOT NULL, `avatar` TEXT NOT NULL, `emojis` TEXT NOT NULL, `bot` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `timelineUserId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localUsername", + "columnName": "localUsername", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "avatar", + "columnName": "avatar", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bot", + "columnName": "bot", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "serverId", + "timelineUserId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ConversationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT NOT NULL, `accounts` TEXT NOT NULL, `unread` INTEGER NOT NULL, `s_id` TEXT NOT NULL, `s_url` TEXT, `s_inReplyToId` TEXT, `s_inReplyToAccountId` TEXT, `s_account` TEXT NOT NULL, `s_content` TEXT NOT NULL, `s_createdAt` INTEGER NOT NULL, `s_emojis` TEXT NOT NULL, `s_favouritesCount` INTEGER NOT NULL, `s_favourited` INTEGER NOT NULL, `s_bookmarked` INTEGER NOT NULL, `s_sensitive` INTEGER NOT NULL, `s_spoilerText` TEXT NOT NULL, `s_attachments` TEXT NOT NULL, `s_mentions` TEXT NOT NULL, `s_tags` TEXT, `s_showingHiddenContent` INTEGER NOT NULL, `s_expanded` INTEGER NOT NULL, `s_collapsed` INTEGER NOT NULL, `s_muted` INTEGER NOT NULL, `s_poll` TEXT, PRIMARY KEY(`id`, `accountId`))", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accounts", + "columnName": "accounts", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unread", + "columnName": "unread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.id", + "columnName": "s_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.url", + "columnName": "s_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.inReplyToId", + "columnName": "s_inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.inReplyToAccountId", + "columnName": "s_inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.account", + "columnName": "s_account", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.content", + "columnName": "s_content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.createdAt", + "columnName": "s_createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.emojis", + "columnName": "s_emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.favouritesCount", + "columnName": "s_favouritesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.favourited", + "columnName": "s_favourited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.bookmarked", + "columnName": "s_bookmarked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.sensitive", + "columnName": "s_sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.spoilerText", + "columnName": "s_spoilerText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.attachments", + "columnName": "s_attachments", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.mentions", + "columnName": "s_mentions", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.tags", + "columnName": "s_tags", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.showingHiddenContent", + "columnName": "s_showingHiddenContent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.expanded", + "columnName": "s_expanded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.collapsed", + "columnName": "s_collapsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.muted", + "columnName": "s_muted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.poll", + "columnName": "s_poll", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id", + "accountId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '9e6c0bb60538683a16c30fa3e1cc24f2')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt b/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt index 5c7901c5..25b70240 100644 --- a/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt @@ -35,8 +35,7 @@ import androidx.appcompat.app.AlertDialog import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.core.content.ContextCompat import androidx.core.content.pm.ShortcutManagerCompat -import androidx.emoji.text.EmojiCompat -import androidx.emoji.text.EmojiCompat.InitCallback +import androidx.core.view.GravityCompat import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.preference.PreferenceManager @@ -114,6 +113,7 @@ import com.mikepenz.materialdrawer.util.updateBadge import com.mikepenz.materialdrawer.widget.AccountHeaderView import dagger.android.DispatchingAndroidInjector import dagger.android.HasAndroidInjector +import de.c1710.filemojicompat_ui.helpers.EMOJI_PREFERENCE import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.schedulers.Schedulers import kotlinx.coroutines.launch @@ -150,13 +150,8 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje private var accountLocked: Boolean = false - private val emojiInitCallback = object : InitCallback() { - override fun onInitialized() { - if (!isDestroyed) { - updateProfiles() - } - } - } + // We need to know if the emoji pack has been changed + private var selectedEmojiPack: String? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -271,11 +266,31 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje // Flush old media that was cached for sharing deleteStaleCachedMedia(applicationContext.getExternalFilesDir("Tusky")) } + + selectedEmojiPack = preferences.getString(EMOJI_PREFERENCE, "") } override fun onResume() { super.onResume() NotificationHelper.clearNotificationsForActiveAccount(this, accountManager) + val currentEmojiPack = preferences.getString(EMOJI_PREFERENCE, "") + if (currentEmojiPack != selectedEmojiPack) { + Log.d( + TAG, + "onResume: EmojiPack has been changed from %s to %s" + .format(selectedEmojiPack, currentEmojiPack) + ) + selectedEmojiPack = currentEmojiPack + recreate() + } + } + + override fun onStart() { + super.onStart() + // For some reason the navigation drawer is opened when the activity is recreated + if (binding.mainDrawerLayout.isOpen) { + binding.mainDrawerLayout.closeDrawer(GravityCompat.START, false) + } } override fun onBackPressed() { @@ -333,11 +348,6 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje } } - override fun onDestroy() { - super.onDestroy() - EmojiCompat.get().unregisterInitCallback(emojiInitCallback) - } - private fun forwardShare(intent: Intent) { val composeIntent = Intent(this, ComposeActivity::class.java) composeIntent.action = intent.action @@ -530,7 +540,6 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje } ) } - EmojiCompat.get().registerInitCallback(emojiInitCallback) } override fun onSaveInstanceState(outState: Bundle) { @@ -612,6 +621,8 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje binding.mainToolbar.setOnClickListener { (adapter.getFragment(activeTabLayout.selectedTabPosition) as? ReselectableFragment)?.onReselect() } + + updateProfiles() } private fun handleProfileClick(profile: IProfile, current: Boolean): Boolean { @@ -682,18 +693,15 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje } } - private fun fetchUserInfo() { - mastodonApi.accountVerifyCredentials() - .observeOn(AndroidSchedulers.mainThread()) - .autoDispose(this, Lifecycle.Event.ON_DESTROY) - .subscribe( - { userInfo -> - onFetchUserInfoSuccess(userInfo) - }, - { throwable -> - Log.e(TAG, "Failed to fetch user info. " + throwable.message) - } - ) + private fun fetchUserInfo() = lifecycleScope.launch { + mastodonApi.accountVerifyCredentials().fold( + { userInfo -> + onFetchUserInfoSuccess(userInfo) + }, + { throwable -> + Log.e(TAG, "Failed to fetch user info. " + throwable.message) + } + ) } private fun onFetchUserInfoSuccess(me: Account) { @@ -782,18 +790,18 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje } private fun fetchAnnouncements() { - mastodonApi.listAnnouncements(false) - .observeOn(AndroidSchedulers.mainThread()) - .autoDispose(this, Lifecycle.Event.ON_DESTROY) - .subscribe( - { announcements -> - unreadAnnouncementsCount = announcements.count { !it.read } - updateAnnouncementsBadge() - }, - { - Log.w(TAG, "Failed to fetch announcements.", it) - } - ) + lifecycleScope.launch { + mastodonApi.listAnnouncements(false) + .fold( + { announcements -> + unreadAnnouncementsCount = announcements.count { !it.read } + updateAnnouncementsBadge() + }, + { throwable -> + Log.w(TAG, "Failed to fetch announcements.", throwable) + } + ) + } } private fun updateAnnouncementsBadge() { @@ -803,11 +811,9 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje private fun updateProfiles() { val animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) val profiles: MutableList = accountManager.getAllAccountsOrderedByActive().map { acc -> - val emojifiedName = EmojiCompat.get().process(acc.displayName.emojify(acc.emojis, header, animateEmojis)) - ProfileDrawerItem().apply { isSelected = acc.isActive - nameText = emojifiedName + nameText = acc.displayName.emojify(acc.emojis, header, animateEmojis) iconUrl = acc.profilePictureUrl isNameShown = true identifier = acc.id diff --git a/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt b/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt index 0339a7bc..ded947a8 100644 --- a/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt +++ b/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt @@ -19,18 +19,18 @@ import android.app.Application import android.content.Context import android.content.res.Configuration import android.util.Log -import androidx.emoji.text.EmojiCompat import androidx.preference.PreferenceManager import androidx.work.WorkManager import autodispose2.AutoDisposePlugins import com.keylesspalace.tusky.components.notifications.NotificationWorkerFactory import com.keylesspalace.tusky.di.AppInjector -import com.keylesspalace.tusky.settings.PrefKeys -import com.keylesspalace.tusky.util.EmojiCompatFont import com.keylesspalace.tusky.util.LocaleManager import com.keylesspalace.tusky.util.ThemeUtils import dagger.android.DispatchingAndroidInjector import dagger.android.HasAndroidInjector +import de.c1710.filemojicompat_defaults.DefaultEmojiPackList +import de.c1710.filemojicompat_ui.helpers.EmojiPackHelper +import de.c1710.filemojicompat_ui.helpers.EmojiPreference import io.reactivex.rxjava3.plugins.RxJavaPlugins import org.conscrypt.Conscrypt import java.security.Security @@ -65,12 +65,10 @@ class TuskyApplication : Application(), HasAndroidInjector { val preferences = PreferenceManager.getDefaultSharedPreferences(this) - // init the custom emoji fonts - val emojiSelection = preferences.getInt(PrefKeys.EMOJI, 0) - val emojiConfig = EmojiCompatFont.byId(emojiSelection) - .getConfig(this) - .setReplaceAll(true) - EmojiCompat.init(emojiConfig) + // In this case, we want to have the emoji preferences merged with the other ones + // Copied from PreferenceManager.getDefaultSharedPreferenceName + EmojiPreference.sharedPreferenceName = packageName + "_preferences" + EmojiPackHelper.init(this, DefaultEmojiPackList.get(this), allowPackImports = false) // init night mode val theme = preferences.getString("appTheme", ThemeUtils.APP_THEME_DEFAULT) diff --git a/app/src/main/java/com/keylesspalace/tusky/ViewMediaActivity.kt b/app/src/main/java/com/keylesspalace/tusky/ViewMediaActivity.kt index 64d29577..fda2c82b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/ViewMediaActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/ViewMediaActivity.kt @@ -283,7 +283,6 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener } return@fromCallable false } - .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .doOnDispose { diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/AccountViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/AccountViewHolder.java index f4824389..6672fff3 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/AccountViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/AccountViewHolder.java @@ -45,8 +45,7 @@ public class AccountViewHolder extends RecyclerView.ViewHolder { ImageLoadingHelper.loadAvatar(account.getAvatar(), avatar, avatarRadius, animateAvatar); if (showBotOverlay && account.getBot()) { avatarInset.setVisibility(View.VISIBLE); - avatarInset.setImageResource(R.drawable.ic_bot_24dp); - avatarInset.setBackgroundColor(0x50ffffff); + avatarInset.setImageResource(R.drawable.bot_badge); } else { avatarInset.setVisibility(View.GONE); } 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 8f022909..c481bbf5 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java @@ -32,6 +32,8 @@ import android.widget.Button; import android.widget.ImageView; import android.widget.TextView; +import androidx.annotation.ColorRes; +import androidx.annotation.DrawableRes; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.core.content.ContextCompat; @@ -47,6 +49,7 @@ import com.keylesspalace.tusky.entity.TimelineAccount; import com.keylesspalace.tusky.interfaces.AccountActionListener; import com.keylesspalace.tusky.interfaces.LinkListener; import com.keylesspalace.tusky.interfaces.StatusActionListener; +import com.keylesspalace.tusky.util.AbsoluteTimeFormatter; import com.keylesspalace.tusky.util.CardViewMode; import com.keylesspalace.tusky.util.CustomEmojiHelper; import com.keylesspalace.tusky.util.ImageLoadingHelper; @@ -58,10 +61,8 @@ import com.keylesspalace.tusky.util.TimestampUtils; import com.keylesspalace.tusky.viewdata.NotificationViewData; import com.keylesspalace.tusky.viewdata.StatusViewData; -import java.text.SimpleDateFormat; import java.util.Date; import java.util.List; -import java.util.Locale; import at.connyduck.sparkbutton.helpers.Utils; @@ -90,6 +91,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter { private NotificationActionListener notificationActionListener; private AccountActionListener accountActionListener; private AdapterDataSource dataSource; + private final AbsoluteTimeFormatter absoluteTimeFormatter = new AbsoluteTimeFormatter(); public NotificationsAdapter(String accountId, AdapterDataSource dataSource, @@ -119,7 +121,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter { case VIEW_TYPE_STATUS_NOTIFICATION: { View view = inflater .inflate(R.layout.item_status_notification, parent, false); - return new StatusNotificationViewHolder(view, statusDisplayOptions); + return new StatusNotificationViewHolder(view, statusDisplayOptions, absoluteTimeFormatter); } case VIEW_TYPE_FOLLOW: { View view = inflater @@ -178,8 +180,16 @@ public class NotificationsAdapter extends RecyclerView.Adapter { case VIEW_TYPE_STATUS: { StatusViewHolder holder = (StatusViewHolder) viewHolder; StatusViewData.Concrete status = concreteNotificaton.getStatusViewData(); - holder.setupWithStatus(status, - statusListener, statusDisplayOptions, payloadForHolder); + if (status == null) { + /* in some very rare cases servers sends null status even though they should not, + * we have to handle it somehow */ + holder.showStatusContent(false); + } else { + if (payloads == null) { + holder.showStatusContent(true); + } + holder.setupWithStatus(status, statusListener, statusDisplayOptions, payloadForHolder); + } if (concreteNotificaton.getType() == Notification.Type.POLL) { holder.setPollInfo(accountId.equals(concreteNotificaton.getAccount().getId())); } else { @@ -192,6 +202,8 @@ public class NotificationsAdapter extends RecyclerView.Adapter { StatusViewData.Concrete statusViewData = concreteNotificaton.getStatusViewData(); if (payloadForHolder == null) { if (statusViewData == null) { + /* in some very rare cases servers sends null status even though they should not, + * we have to handle it somehow */ holder.showNotificationContent(false); } else { holder.showNotificationContent(true); @@ -201,7 +213,8 @@ public class NotificationsAdapter extends RecyclerView.Adapter { holder.setUsername(status.getAccount().getUsername()); holder.setCreatedAt(status.getCreatedAt()); - if (concreteNotificaton.getType() == Notification.Type.STATUS) { + if (concreteNotificaton.getType() == Notification.Type.STATUS || + concreteNotificaton.getType() == Notification.Type.UPDATE) { holder.setAvatar(status.getAccount().getAvatar(), status.getAccount().getBot()); } else { holder.setAvatars(status.getAccount().getAvatar(), @@ -226,7 +239,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter { case VIEW_TYPE_FOLLOW: { if (payloadForHolder == null) { FollowViewHolder holder = (FollowViewHolder) viewHolder; - holder.setMessage(concreteNotificaton.getAccount()); + holder.setMessage(concreteNotificaton.getAccount(), concreteNotificaton.getType() == Notification.Type.SIGN_UP); holder.setupButtons(notificationActionListener, concreteNotificaton.getAccount().getId()); } break; @@ -280,10 +293,12 @@ public class NotificationsAdapter extends RecyclerView.Adapter { } case STATUS: case FAVOURITE: - case REBLOG: { + case REBLOG: + case UPDATE: { return VIEW_TYPE_STATUS_NOTIFICATION; } - case FOLLOW: { + case FOLLOW: + case SIGN_UP: { return VIEW_TYPE_FOLLOW; } case FOLLOW_REQUEST: { @@ -335,10 +350,10 @@ public class NotificationsAdapter extends RecyclerView.Adapter { this.statusDisplayOptions = statusDisplayOptions; } - void setMessage(TimelineAccount account) { + void setMessage(TimelineAccount account, Boolean isSignUp) { Context context = message.getContext(); - String format = context.getString(R.string.notification_follow_format); + String format = context.getString(isSignUp ? R.string.notification_sign_up_format : R.string.notification_follow_format); String wrappedDisplayName = StringUtils.unicodeWrap(account.getName()); String wholeMessage = String.format(format, wrappedDisplayName); CharSequence emojifiedMessage = CustomEmojiHelper.emojify( @@ -382,19 +397,22 @@ public class NotificationsAdapter extends RecyclerView.Adapter { private final Button contentWarningButton; private final Button contentCollapseButton; // TODO: This code SHOULD be based on StatusBaseViewHolder private StatusDisplayOptions statusDisplayOptions; + private final AbsoluteTimeFormatter absoluteTimeFormatter; private String accountId; private String notificationId; private NotificationActionListener notificationActionListener; private StatusViewData.Concrete statusViewData; - private SimpleDateFormat shortSdf; - private SimpleDateFormat longSdf; private int avatarRadius48dp; private int avatarRadius36dp; private int avatarRadius24dp; - StatusNotificationViewHolder(View itemView, StatusDisplayOptions statusDisplayOptions) { + StatusNotificationViewHolder( + View itemView, + StatusDisplayOptions statusDisplayOptions, + AbsoluteTimeFormatter absoluteTimeFormatter + ) { super(itemView); message = itemView.findViewById(R.id.notification_top_text); statusNameBar = itemView.findViewById(R.id.status_name_bar); @@ -408,6 +426,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter { contentWarningButton = itemView.findViewById(R.id.notification_content_warning_button); contentCollapseButton = itemView.findViewById(R.id.button_toggle_notification_content); this.statusDisplayOptions = statusDisplayOptions; + this.absoluteTimeFormatter = absoluteTimeFormatter; int darkerFilter = Color.rgb(123, 123, 123); statusAvatar.setColorFilter(darkerFilter, PorterDuff.Mode.MULTIPLY); @@ -416,8 +435,6 @@ public class NotificationsAdapter extends RecyclerView.Adapter { itemView.setOnClickListener(this); message.setOnClickListener(this); statusContent.setOnClickListener(this); - shortSdf = new SimpleDateFormat("HH:mm:ss", Locale.getDefault()); - longSdf = new SimpleDateFormat("MM/dd HH:mm:ss", Locale.getDefault()); this.avatarRadius48dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_48dp); this.avatarRadius36dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_36dp); @@ -447,17 +464,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter { protected void setCreatedAt(@Nullable Date createdAt) { if (statusDisplayOptions.useAbsoluteTime()) { - String time; - if (createdAt != null) { - if (System.currentTimeMillis() - createdAt.getTime() > 86400000L) { - time = longSdf.format(createdAt); - } else { - time = shortSdf.format(createdAt); - } - } else { - time = "??:??:??"; - } - timestampInfo.setText(time); + timestampInfo.setText(absoluteTimeFormatter.format(createdAt, true)); } else { // This is the visible timestampInfo. String readout; @@ -481,6 +488,14 @@ public class NotificationsAdapter extends RecyclerView.Adapter { } } + Drawable getIconWithColor(Context context, @DrawableRes int drawable, @ColorRes int color) { + Drawable icon = ContextCompat.getDrawable(context, drawable); + if (icon != null) { + icon.setColorFilter(ContextCompat.getColor(context, color), PorterDuff.Mode.SRC_ATOP); + } + return icon; + } + void setMessage(NotificationViewData.Concrete notificationViewData, LinkListener listener) { this.statusViewData = notificationViewData.getStatusViewData(); @@ -493,41 +508,36 @@ public class NotificationsAdapter extends RecyclerView.Adapter { switch (type) { default: case FAVOURITE: { - icon = ContextCompat.getDrawable(context, R.drawable.ic_star_24dp); - if (icon != null) { - icon.setColorFilter(ContextCompat.getColor(context, - R.color.tusky_orange), PorterDuff.Mode.SRC_ATOP); - } - + icon = getIconWithColor(context, R.drawable.ic_star_24dp, R.color.tusky_orange); format = context.getString(R.string.notification_favourite_format); break; } case REBLOG: { - icon = ContextCompat.getDrawable(context, R.drawable.ic_repeat_24dp); - if (icon != null) { - icon.setColorFilter(ContextCompat.getColor(context, - R.color.chinwag_green), PorterDuff.Mode.SRC_ATOP); - } - + icon = getIconWithColor(context, R.drawable.ic_repeat_24dp, R.color.chinwag_green); format = context.getString(R.string.notification_reblog_format); break; } case STATUS: { - icon = ContextCompat.getDrawable(context, R.drawable.ic_home_24dp); - if (icon != null) { - icon.setColorFilter(ContextCompat.getColor(context, - R.color.chinwag_green), PorterDuff.Mode.SRC_ATOP); - } - + icon = getIconWithColor(context, R.drawable.ic_home_24dp, R.color.chinwag_green); format = context.getString(R.string.notification_subscription_format); break; } + case UPDATE: { + icon = getIconWithColor(context, R.drawable.ic_edit_24dp, R.color.chinwag_green); + format = context.getString(R.string.notification_update_format); + break; + } } 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() ); @@ -570,9 +580,8 @@ public class NotificationsAdapter extends RecyclerView.Adapter { if (statusDisplayOptions.showBotOverlay() && isBot) { notificationAvatar.setVisibility(View.VISIBLE); - notificationAvatar.setBackgroundColor(0x50ffffff); Glide.with(notificationAvatar) - .load(R.drawable.ic_bot_24dp) + .load(ContextCompat.getDrawable(notificationAvatar.getContext(), R.drawable.bot_badge)) .into(notificationAvatar); } else { diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/PollAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/PollAdapter.kt index 1a60d860..ef366795 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/PollAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/PollAdapter.kt @@ -19,7 +19,6 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.core.content.ContextCompat -import androidx.emoji.text.EmojiCompat import androidx.recyclerview.widget.RecyclerView import com.keylesspalace.tusky.R import com.keylesspalace.tusky.databinding.ItemPollBinding @@ -87,9 +86,8 @@ class PollAdapter : RecyclerView.Adapter>() { when (mode) { RESULT -> { val percent = calculatePercent(option.votesCount, votersCount, voteCount) - val emojifiedPollOptionText = buildDescription(option.title, percent, option.voted, resultTextView.context) + resultTextView.text = buildDescription(option.title, percent, option.voted, resultTextView.context) .emojify(emojis, resultTextView, animateEmojis) - resultTextView.text = EmojiCompat.get().process(emojifiedPollOptionText) val level = percent * 100 val optionColor = if (option.voted) { @@ -103,8 +101,7 @@ class PollAdapter : RecyclerView.Adapter>() { resultTextView.setOnClickListener(resultClickListener) } SINGLE -> { - val emojifiedPollOptionText = option.title.emojify(emojis, radioButton, animateEmojis) - radioButton.text = EmojiCompat.get().process(emojifiedPollOptionText) + radioButton.text = option.title.emojify(emojis, radioButton, animateEmojis) radioButton.isChecked = option.selected radioButton.setOnClickListener { pollOptions.forEachIndexed { index, pollOption -> @@ -114,8 +111,7 @@ class PollAdapter : RecyclerView.Adapter>() { } } MULTIPLE -> { - val emojifiedPollOptionText = option.title.emojify(emojis, checkBox, animateEmojis) - checkBox.text = EmojiCompat.get().process(emojifiedPollOptionText) + checkBox.text = option.title.emojify(emojis, checkBox, animateEmojis) checkBox.isChecked = option.selected checkBox.setOnCheckedChangeListener { _, isChecked -> pollOptions[holder.bindingAdapterPosition].selected = isChecked diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java index 1239ea71..2a5b3f2c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java @@ -20,6 +20,8 @@ import androidx.annotation.DrawableRes; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.AlertDialog; +import androidx.constraintlayout.widget.ConstraintLayout; +import androidx.core.content.ContextCompat; import androidx.core.text.HtmlCompat; import androidx.recyclerview.widget.DefaultItemAnimator; import androidx.recyclerview.widget.LinearLayoutManager; @@ -27,6 +29,7 @@ import androidx.recyclerview.widget.RecyclerView; import com.bumptech.glide.Glide; import com.bumptech.glide.RequestBuilder; +import com.bumptech.glide.load.engine.DiskCacheStrategy; import com.bumptech.glide.load.resource.bitmap.CenterCrop; import com.bumptech.glide.load.resource.bitmap.GranularRoundedCorners; import com.google.android.material.button.MaterialButton; @@ -40,6 +43,7 @@ import com.keylesspalace.tusky.entity.Emoji; import com.keylesspalace.tusky.entity.HashTag; import com.keylesspalace.tusky.entity.Status; import com.keylesspalace.tusky.interfaces.StatusActionListener; +import com.keylesspalace.tusky.util.AbsoluteTimeFormatter; import com.keylesspalace.tusky.util.CardViewMode; import com.keylesspalace.tusky.util.CustomEmojiHelper; import com.keylesspalace.tusky.util.ImageLoadingHelper; @@ -54,10 +58,8 @@ import com.keylesspalace.tusky.viewdata.PollViewDataKt; import com.keylesspalace.tusky.viewdata.StatusViewData; import java.text.NumberFormat; -import java.text.SimpleDateFormat; import java.util.Date; import java.util.List; -import java.util.Locale; import at.connyduck.sparkbutton.SparkButton; import at.connyduck.sparkbutton.helpers.Utils; @@ -77,6 +79,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { private SparkButton favouriteButton; private SparkButton bookmarkButton; private ImageButton moreButton; + private ConstraintLayout mediaContainer; protected MediaPreviewImageView[] mediaPreviews; private ImageView[] mediaOverlays; private TextView sensitiveMediaWarning; @@ -103,10 +106,8 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { private TextView cardUrl; private PollAdapter pollAdapter; - private SimpleDateFormat shortSdf; - private SimpleDateFormat longSdf; - private final NumberFormat numberFormat = NumberFormat.getNumberInstance(); + private final AbsoluteTimeFormatter absoluteTimeFormatter = new AbsoluteTimeFormatter(); protected int avatarRadius48dp; private int avatarRadius36dp; @@ -127,7 +128,8 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { bookmarkButton = itemView.findViewById(R.id.status_bookmark); moreButton = itemView.findViewById(R.id.status_more); - itemView.findViewById(R.id.status_media_preview_container).setClipToOutline(true); + mediaContainer = itemView.findViewById(R.id.status_media_preview_container); + mediaContainer.setClipToOutline(true); mediaPreviews = new MediaPreviewImageView[]{ itemView.findViewById(R.id.status_media_preview_0), @@ -170,9 +172,6 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { pollOptions.setLayoutManager(new LinearLayoutManager(pollOptions.getContext())); ((DefaultItemAnimator) pollOptions.getItemAnimator()).setSupportsChangeAnimations(false); - this.shortSdf = new SimpleDateFormat("HH:mm:ss", Locale.getDefault()); - this.longSdf = new SimpleDateFormat("MM/dd HH:mm:ss", Locale.getDefault()); - this.avatarRadius48dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_48dp); this.avatarRadius36dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_36dp); this.avatarRadius24dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_24dp); @@ -290,11 +289,10 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { if (statusDisplayOptions.showBotOverlay() && isBot) { avatarInset.setVisibility(View.VISIBLE); - avatarInset.setBackgroundColor(0x50ffffff); Glide.with(avatarInset) - .load(R.drawable.ic_bot_24dp) + // passing the drawable id directly into .load() ignores night mode https://github.com/bumptech/glide/issues/4692 + .load(ContextCompat.getDrawable(avatarInset.getContext(), R.drawable.bot_badge)) .into(avatarInset); - } else { avatarInset.setVisibility(View.GONE); } @@ -320,7 +318,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { protected void setCreatedAt(Date createdAt, StatusDisplayOptions statusDisplayOptions) { if (statusDisplayOptions.useAbsoluteTime()) { - timestampInfo.setText(getAbsoluteTime(createdAt)); + timestampInfo.setText(absoluteTimeFormatter.format(createdAt, true)); } else { if (createdAt == null) { timestampInfo.setText("?m"); @@ -333,21 +331,10 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { } } - private String getAbsoluteTime(Date createdAt) { - if (createdAt == null) { - return "??:??:??"; - } - if (DateUtils.isToday(createdAt.getTime())) { - return shortSdf.format(createdAt); - } else { - return longSdf.format(createdAt); - } - } - private CharSequence getCreatedAtDescription(Date createdAt, StatusDisplayOptions statusDisplayOptions) { if (statusDisplayOptions.useAbsoluteTime()) { - return getAbsoluteTime(createdAt); + return absoluteTimeFormatter.format(createdAt, true); } else { /* This one is for screen-readers. Frequently, they would mispronounce timestamps like "17m" * as 17 meters instead of minutes. */ @@ -736,9 +723,9 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { this.setupWithStatus(status, listener, statusDisplayOptions, null); } - public void setupWithStatus(StatusViewData.Concrete status, - final StatusActionListener listener, - StatusDisplayOptions statusDisplayOptions, + public void setupWithStatus(@NonNull StatusViewData.Concrete status, + @NonNull final StatusActionListener listener, + @NonNull StatusDisplayOptions statusDisplayOptions, @Nullable Object payloads) { if (payloads == null) { Status actionable = status.getActionable(); @@ -1028,7 +1015,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { return votesText; } else { if (statusDisplayOptions.useAbsoluteTime()) { - pollDurationInfo = context.getString(R.string.poll_info_time_absolute, getAbsoluteTime(poll.getExpiresAt())); + pollDurationInfo = context.getString(R.string.poll_info_time_absolute, absoluteTimeFormatter.format(poll.getExpiresAt(), false)); } else { pollDurationInfo = TimestampUtils.formatPollDuration(pollDescription.getContext(), poll.getExpiresAt().getTime(), timestamp); } @@ -1043,9 +1030,11 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { StatusDisplayOptions statusDisplayOptions, final StatusActionListener listener ) { - final Card card = status.getActionable().getCard(); + final Status actionable = status.getActionable(); + final Card card = actionable.getCard(); if (cardViewMode != CardViewMode.NONE && - status.getActionable().getAttachments().size() == 0 && + actionable.getAttachments().size() == 0 && + actionable.getPoll() == null && card != null && !TextUtils.isEmpty(card.getUrl()) && (!status.isCollapsible() || !status.isCollapsed())) { @@ -1067,7 +1056,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { // Statuses from other activitypub sources can be marked sensitive even if there's no media, // so let's blur the preview in that case // If media previews are disabled, show placeholder for cards as well - if (statusDisplayOptions.mediaPreviewEnabled() && !status.getActionable().getSensitive() && !TextUtils.isEmpty(card.getImage())) { + if (statusDisplayOptions.mediaPreviewEnabled() && !actionable.getSensitive() && !TextUtils.isEmpty(card.getImage())) { int topLeftRadius = 0; int topRightRadius = 0; @@ -1148,6 +1137,28 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { } } + public void showStatusContent(boolean show) { + int visibility = show ? View.VISIBLE : View.GONE; + avatar.setVisibility(visibility); + avatarInset.setVisibility(visibility); + displayName.setVisibility(visibility); + username.setVisibility(visibility); + timestampInfo.setVisibility(visibility); + contentWarningDescription.setVisibility(visibility); + contentWarningButton.setVisibility(visibility); + content.setVisibility(visibility); + cardView.setVisibility(visibility); + mediaContainer.setVisibility(visibility); + pollOptions.setVisibility(visibility); + pollButton.setVisibility(visibility); + pollDescription.setVisibility(visibility); + replyButton.setVisibility(visibility); + reblogButton.setVisibility(visibility); + favouriteButton.setVisibility(visibility); + bookmarkButton.setVisibility(visibility); + moreButton.setVisibility(visibility); + } + private static String formatDuration(double durationInSeconds) { int seconds = (int) Math.round(durationInSeconds) % 60; int minutes = (int) durationInSeconds % 3600 / 60; diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java index 56adfcad..bf2c05e0 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java @@ -1,14 +1,12 @@ package com.keylesspalace.tusky.adapter; -import android.content.ClipData; -import android.content.ClipboardManager; import android.content.Context; import android.graphics.drawable.Drawable; import android.text.method.LinkMovementMethod; import android.view.View; import android.widget.TextView; -import android.widget.Toast; +import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.recyclerview.widget.RecyclerView; @@ -101,10 +99,10 @@ class StatusDetailedViewHolder extends StatusBaseViewHolder { } @Override - public void setupWithStatus(final StatusViewData.Concrete status, - final StatusActionListener listener, - StatusDisplayOptions statusDisplayOptions, - @Nullable Object payloads) { + public void setupWithStatus(@NonNull final StatusViewData.Concrete status, + @NonNull final StatusActionListener listener, + @NonNull StatusDisplayOptions statusDisplayOptions, + @Nullable Object payloads) { super.setupWithStatus(status, listener, statusDisplayOptions, payloads); setupCard(status, CardViewMode.FULL_WIDTH, statusDisplayOptions, listener); // Always show card for detailed status if (payloads == null) { @@ -118,19 +116,6 @@ class StatusDetailedViewHolder extends StatusBaseViewHolder { setApplication(status.getActionable().getApplication()); - View.OnLongClickListener longClickListener = view -> { - TextView textView = (TextView) view; - ClipboardManager clipboard = (ClipboardManager) view.getContext().getSystemService(Context.CLIPBOARD_SERVICE); - ClipData clip = ClipData.newPlainText("toot", textView.getText()); - clipboard.setPrimaryClip(clip); - - Toast.makeText(view.getContext(), R.string.copy_to_clipboard_success, Toast.LENGTH_SHORT).show(); - - return true; - }; - - content.setOnLongClickListener(longClickListener); - contentWarningDescription.setOnLongClickListener(longClickListener); setStatusVisibility(status.getActionable().getVisibility()); } } diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java index b054aea9..93c47564 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java @@ -22,6 +22,7 @@ import android.view.View; import android.widget.Button; import android.widget.TextView; +import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.recyclerview.widget.RecyclerView; @@ -58,9 +59,9 @@ public class StatusViewHolder extends StatusBaseViewHolder { } @Override - public void setupWithStatus(StatusViewData.Concrete status, - final StatusActionListener listener, - StatusDisplayOptions statusDisplayOptions, + public void setupWithStatus(@NonNull StatusViewData.Concrete status, + @NonNull final StatusActionListener listener, + @NonNull StatusDisplayOptions statusDisplayOptions, @Nullable Object payloads) { if (payloads == null) { @@ -129,4 +130,9 @@ public class StatusViewHolder extends StatusBaseViewHolder { content.setFilters(NO_INPUT_FILTER); } } + + public void showStatusContent(boolean show) { + super.showStatusContent(show); + contentCollapseButton.setVisibility(show ? View.VISIBLE : View.GONE); + } } 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 d02c9cce..8f53645d 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 @@ -20,8 +20,6 @@ import android.content.Context import android.content.Intent import android.content.res.ColorStateList import android.graphics.Color -import android.graphics.PorterDuff -import android.graphics.PorterDuffColorFilter import android.os.Bundle import android.text.Editable import android.view.Menu @@ -39,7 +37,6 @@ import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat.Type.systemBars import androidx.core.view.updatePadding -import androidx.emoji.text.EmojiCompat import androidx.preference.PreferenceManager import androidx.recyclerview.widget.LinearLayoutManager import androidx.viewpager2.widget.MarginPageTransformer @@ -78,7 +75,7 @@ import com.keylesspalace.tusky.util.emojify import com.keylesspalace.tusky.util.getDomain import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.loadAvatar -import com.keylesspalace.tusky.util.openLink +import com.keylesspalace.tusky.util.parseAsMastodonHtml import com.keylesspalace.tusky.util.setClickableText import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.viewBinding @@ -374,13 +371,6 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI .show() } } - viewModel.accountFieldData.observe( - this, - { - accountFieldAdapter.fields = it - accountFieldAdapter.notifyDataSetChanged() - } - ) viewModel.noteSaved.observe(this) { binding.saveNoteInfo.visible(it, View.INVISIBLE) } @@ -395,11 +385,10 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI adapter.refreshContent() } viewModel.isRefreshing.observe( - this, - { isRefreshing -> - binding.swipeToRefreshLayout.isRefreshing = isRefreshing == true - } - ) + this + ) { isRefreshing -> + binding.swipeToRefreshLayout.isRefreshing = isRefreshing == true + } binding.swipeToRefreshLayout.setColorSchemeResources(R.color.chinwag_green) } @@ -410,10 +399,10 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI binding.accountUsernameTextView.text = usernameFormatted binding.accountDisplayNameTextView.text = account.name.emojify(account.emojis, binding.accountDisplayNameTextView, animateEmojis) - val emojifiedNote = account.note.emojify(account.emojis, binding.accountNoteTextView, animateEmojis) + val emojifiedNote = account.note.parseAsMastodonHtml().emojify(account.emojis, binding.accountNoteTextView, animateEmojis) setClickableText(binding.accountNoteTextView, emojifiedNote, emptyList(), null, this) - // accountFieldAdapter.fields = account.fields ?: emptyList() + accountFieldAdapter.fields = account.fields ?: emptyList() accountFieldAdapter.emojis = account.emojis ?: emptyList() accountFieldAdapter.notifyDataSetChanged() @@ -469,14 +458,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI */ private fun updateToolbar() { loadedAccount?.let { account -> - - val emojifiedName = account.name.emojify(account.emojis, binding.accountToolbar, animateEmojis) - - try { - supportActionBar?.title = EmojiCompat.get().process(emojifiedName) - } catch (e: IllegalStateException) { - supportActionBar?.title = emojifiedName - } + supportActionBar?.title = account.name.emojify(account.emojis, binding.accountToolbar, animateEmojis) supportActionBar?.subtitle = String.format(getString(R.string.post_username_format), account.username) } } @@ -501,13 +483,6 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI loadAvatar(movedAccount.avatar, binding.accountMovedAvatar, avatarRadius, animateAvatar) binding.accountMovedText.text = getString(R.string.account_moved_description, movedAccount.name) - - // this is necessary because API 19 can't handle vector compound drawables - val movedIcon = ContextCompat.getDrawable(this, R.drawable.ic_briefcase)?.mutate() - val textColor = ThemeUtils.getColor(this, android.R.attr.textColorTertiary) - movedIcon?.colorFilter = PorterDuffColorFilter(textColor, PorterDuff.Mode.SRC_IN) - - binding.accountMovedText.setCompoundDrawablesRelativeWithIntrinsicBounds(movedIcon, null, null, null) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/account/AccountFieldAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/account/AccountFieldAdapter.kt index 093dbcfb..86acb813 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/account/AccountFieldAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/account/AccountFieldAdapter.kt @@ -15,7 +15,6 @@ package com.keylesspalace.tusky.components.account -import android.text.method.LinkMovementMethod import android.view.LayoutInflater import android.view.ViewGroup import androidx.recyclerview.widget.RecyclerView @@ -23,12 +22,10 @@ import com.keylesspalace.tusky.R import com.keylesspalace.tusky.databinding.ItemAccountFieldBinding import com.keylesspalace.tusky.entity.Emoji import com.keylesspalace.tusky.entity.Field -import com.keylesspalace.tusky.entity.IdentityProof import com.keylesspalace.tusky.interfaces.LinkListener import com.keylesspalace.tusky.util.BindingHolder -import com.keylesspalace.tusky.util.Either -import com.keylesspalace.tusky.util.createClickableText import com.keylesspalace.tusky.util.emojify +import com.keylesspalace.tusky.util.parseAsMastodonHtml import com.keylesspalace.tusky.util.setClickableText class AccountFieldAdapter( @@ -37,7 +34,7 @@ class AccountFieldAdapter( ) : RecyclerView.Adapter>() { var emojis: List = emptyList() - var fields: List> = emptyList() + var fields: List = emptyList() override fun getItemCount() = fields.size @@ -47,32 +44,20 @@ class AccountFieldAdapter( } override fun onBindViewHolder(holder: BindingHolder, position: Int) { - val proofOrField = fields[position] + val field = fields[position] val nameTextView = holder.binding.accountFieldName val valueTextView = holder.binding.accountFieldValue - if (proofOrField.isLeft()) { - val identityProof = proofOrField.asLeft() + val emojifiedName = field.name.emojify(emojis, nameTextView, animateEmojis) + nameTextView.text = emojifiedName - nameTextView.text = identityProof.provider - valueTextView.text = createClickableText(identityProof.username, identityProof.profileUrl) - - valueTextView.movementMethod = LinkMovementMethod.getInstance() + val emojifiedValue = field.value.parseAsMastodonHtml().emojify(emojis, valueTextView, animateEmojis) + setClickableText(valueTextView, emojifiedValue, emptyList(), null, linkListener) + if (field.verifiedAt != null) { valueTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, R.drawable.ic_check_circle, 0) } else { - val field = proofOrField.asRight() - val emojifiedName = field.name.emojify(emojis, nameTextView, animateEmojis) - nameTextView.text = emojifiedName - - val emojifiedValue = field.value.emojify(emojis, valueTextView, animateEmojis) - setClickableText(valueTextView, emojifiedValue, emptyList(), null, linkListener) - - if (field.verifiedAt != null) { - valueTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, R.drawable.ic_check_circle, 0) - } else { - valueTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, 0, 0) - } + valueTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, 0, 0) } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/account/AccountViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/account/AccountViewModel.kt index 6fa988ac..664651eb 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/account/AccountViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/account/AccountViewModel.kt @@ -10,17 +10,13 @@ import com.keylesspalace.tusky.appstore.ProfileEditedEvent import com.keylesspalace.tusky.appstore.UnfollowEvent import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.entity.Account -import com.keylesspalace.tusky.entity.Field -import com.keylesspalace.tusky.entity.IdentityProof import com.keylesspalace.tusky.entity.Relationship import com.keylesspalace.tusky.network.MastodonApi -import com.keylesspalace.tusky.util.Either import com.keylesspalace.tusky.util.Error import com.keylesspalace.tusky.util.Loading import com.keylesspalace.tusky.util.Resource import com.keylesspalace.tusky.util.RxAwareViewModel import com.keylesspalace.tusky.util.Success -import com.keylesspalace.tusky.util.combineOptionalLiveData import io.reactivex.rxjava3.core.Single import io.reactivex.rxjava3.disposables.Disposable import retrofit2.Call @@ -40,13 +36,6 @@ class AccountViewModel @Inject constructor( val noteSaved = MutableLiveData() - private val identityProofData = MutableLiveData>() - - val accountFieldData = combineOptionalLiveData(accountData, identityProofData) { accountRes, identityProofs -> - identityProofs.orEmpty().map { Either.Left(it) } - .plus(accountRes?.data?.fields.orEmpty().map { Either.Right(it) }) - } - val isRefreshing = MutableLiveData().apply { value = false } private var isDataLoading = false @@ -106,22 +95,6 @@ class AccountViewModel @Inject constructor( } } - private fun obtainIdentityProof(reload: Boolean = false) { - if (identityProofData.value == null || reload) { - - mastodonApi.identityProofs(accountId) - .subscribe( - { proofs -> - identityProofData.postValue(proofs) - }, - { t -> - Log.w(TAG, "failed obtaining identity proofs", t) - } - ) - .autoDispose() - } - } - fun changeFollowState() { val relationship = relationshipData.value?.data if (relationship?.following == true || relationship?.requested == true) { @@ -314,7 +287,6 @@ class AccountViewModel @Inject constructor( return accountId.let { obtainAccount(isReload) - obtainIdentityProof() if (!isSelf) obtainRelationship(isReload) } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementAdapter.kt index 4b5e7aa5..70ebfc7d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementAdapter.kt @@ -32,6 +32,7 @@ import com.keylesspalace.tusky.interfaces.LinkListener import com.keylesspalace.tusky.util.BindingHolder import com.keylesspalace.tusky.util.EmojiSpan import com.keylesspalace.tusky.util.emojify +import com.keylesspalace.tusky.util.parseAsMastodonHtml import com.keylesspalace.tusky.util.setClickableText import java.lang.ref.WeakReference @@ -60,7 +61,7 @@ class AnnouncementAdapter( val chips = holder.binding.chipGroup val addReactionChip = holder.binding.addReactionChip - val emojifiedText: CharSequence = item.content.emojify(item.emojis, text, animateEmojis) + val emojifiedText: CharSequence = item.content.parseAsMastodonHtml().emojify(item.emojis, text, animateEmojis) setClickableText(text, emojifiedText, item.mentions, item.tags, listener) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementsViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementsViewModel.kt index d1ae0b9e..0934c48f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementsViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementsViewModel.kt @@ -18,31 +18,26 @@ package com.keylesspalace.tusky.components.announcements import android.util.Log import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import com.keylesspalace.tusky.appstore.AnnouncementReadEvent import com.keylesspalace.tusky.appstore.EventHub -import com.keylesspalace.tusky.components.compose.DEFAULT_MAXIMUM_URL_LENGTH -import com.keylesspalace.tusky.db.AccountManager -import com.keylesspalace.tusky.db.AppDatabase -import com.keylesspalace.tusky.db.InstanceEntity +import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository import com.keylesspalace.tusky.entity.Announcement import com.keylesspalace.tusky.entity.Emoji -import com.keylesspalace.tusky.entity.Instance import com.keylesspalace.tusky.network.MastodonApi -import com.keylesspalace.tusky.util.Either import com.keylesspalace.tusky.util.Error import com.keylesspalace.tusky.util.Loading import com.keylesspalace.tusky.util.Resource -import com.keylesspalace.tusky.util.RxAwareViewModel import com.keylesspalace.tusky.util.Success -import io.reactivex.rxjava3.core.Single +import kotlinx.coroutines.launch import javax.inject.Inject class AnnouncementsViewModel @Inject constructor( - accountManager: AccountManager, - private val appDatabase: AppDatabase, + private val instanceInfoRepo: InstanceInfoRepository, private val mastodonApi: MastodonApi, private val eventHub: EventHub -) : RxAwareViewModel() { +) : ViewModel() { private val announcementsMutable = MutableLiveData>>() val announcements: LiveData>> = announcementsMutable @@ -51,155 +46,130 @@ class AnnouncementsViewModel @Inject constructor( val emojis: LiveData> = emojisMutable init { - Single.zip( - mastodonApi.getCustomEmojis(), - appDatabase.instanceDao().loadMetadataForInstance(accountManager.activeAccount?.domain!!) - .map> { Either.Left(it) } - .onErrorResumeNext { - mastodonApi.getInstance() - .map { Either.Right(it) } - } - ) { emojis, either -> - either.asLeftOrNull()?.copy(emojiList = emojis) - ?: InstanceEntity( - accountManager.activeAccount?.domain!!, - emojis, - either.asRight().configuration?.statuses?.maxCharacters ?: either.asRight().maxTootChars, - either.asRight().configuration?.polls?.maxOptions ?: either.asRight().pollConfiguration?.maxOptions, - either.asRight().configuration?.polls?.maxCharactersPerOption ?: either.asRight().pollConfiguration?.maxOptionChars, - either.asRight().configuration?.polls?.minExpiration ?: either.asRight().pollConfiguration?.minExpiration, - either.asRight().configuration?.polls?.maxExpiration ?: either.asRight().pollConfiguration?.maxExpiration, - either.asRight().configuration?.statuses?.charactersReservedPerUrl ?: DEFAULT_MAXIMUM_URL_LENGTH, - either.asRight().version - ) + viewModelScope.launch { + emojisMutable.postValue(instanceInfoRepo.getEmojis()) } - .doOnSuccess { - appDatabase.instanceDao().insertOrReplace(it) - } - .subscribe( - { - emojisMutable.postValue(it.emojiList.orEmpty()) - }, - { - Log.w(TAG, "Failed to get custom emojis.", it) - } - ) - .autoDispose() } fun load() { - announcementsMutable.postValue(Loading()) - mastodonApi.listAnnouncements() - .subscribe( - { - announcementsMutable.postValue(Success(it)) - it.filter { announcement -> !announcement.read } - .forEach { announcement -> - mastodonApi.dismissAnnouncement(announcement.id) - .subscribe( - { - eventHub.dispatch(AnnouncementReadEvent(announcement.id)) - }, - { throwable -> - Log.d(TAG, "Failed to mark announcement as read.", throwable) - } - ) - .autoDispose() - } - }, - { - announcementsMutable.postValue(Error(cause = it)) - } - ) - .autoDispose() + viewModelScope.launch { + announcementsMutable.postValue(Loading()) + mastodonApi.listAnnouncements() + .fold( + { + announcementsMutable.postValue(Success(it)) + it.filter { announcement -> !announcement.read } + .forEach { announcement -> + mastodonApi.dismissAnnouncement(announcement.id) + .fold( + { + eventHub.dispatch(AnnouncementReadEvent(announcement.id)) + }, + { throwable -> + Log.d( + TAG, + "Failed to mark announcement as read.", + throwable + ) + } + ) + } + }, + { + announcementsMutable.postValue(Error(cause = it)) + } + ) + } } fun addReaction(announcementId: String, name: String) { - mastodonApi.addAnnouncementReaction(announcementId, name) - .subscribe( - { - announcementsMutable.postValue( - Success( - announcements.value!!.data!!.map { announcement -> - if (announcement.id == announcementId) { - announcement.copy( - reactions = if (announcement.reactions.find { reaction -> reaction.name == name } != null) { - announcement.reactions.map { reaction -> + viewModelScope.launch { + mastodonApi.addAnnouncementReaction(announcementId, name) + .fold( + { + announcementsMutable.postValue( + Success( + announcements.value!!.data!!.map { announcement -> + if (announcement.id == announcementId) { + announcement.copy( + reactions = if (announcement.reactions.find { reaction -> reaction.name == name } != null) { + announcement.reactions.map { reaction -> + if (reaction.name == name) { + reaction.copy( + count = reaction.count + 1, + me = true + ) + } else { + reaction + } + } + } else { + listOf( + *announcement.reactions.toTypedArray(), + emojis.value!!.find { emoji -> emoji.shortcode == name } + !!.run { + Announcement.Reaction( + name, + 1, + true, + url, + staticUrl + ) + } + ) + } + ) + } else { + announcement + } + } + ) + ) + }, + { + Log.w(TAG, "Failed to add reaction to the announcement.", it) + } + ) + } + } + + fun removeReaction(announcementId: String, name: String) { + viewModelScope.launch { + mastodonApi.removeAnnouncementReaction(announcementId, name) + .fold( + { + announcementsMutable.postValue( + Success( + announcements.value!!.data!!.map { announcement -> + if (announcement.id == announcementId) { + announcement.copy( + reactions = announcement.reactions.mapNotNull { reaction -> if (reaction.name == name) { - reaction.copy( - count = reaction.count + 1, - me = true - ) + if (reaction.count > 1) { + reaction.copy( + count = reaction.count - 1, + me = false + ) + } else { + null + } } else { reaction } } - } else { - listOf( - *announcement.reactions.toTypedArray(), - emojis.value!!.find { emoji -> emoji.shortcode == name } - !!.run { - Announcement.Reaction( - name, - 1, - true, - url, - staticUrl - ) - } - ) - } - ) - } else { - announcement + ) + } else { + announcement + } } - } + ) ) - ) - }, - { - Log.w(TAG, "Failed to add reaction to the announcement.", it) - } - ) - .autoDispose() - } - - fun removeReaction(announcementId: String, name: String) { - mastodonApi.removeAnnouncementReaction(announcementId, name) - .subscribe( - { - announcementsMutable.postValue( - Success( - announcements.value!!.data!!.map { announcement -> - if (announcement.id == announcementId) { - announcement.copy( - reactions = announcement.reactions.mapNotNull { reaction -> - if (reaction.name == name) { - if (reaction.count > 1) { - reaction.copy( - count = reaction.count - 1, - me = false - ) - } else { - null - } - } else { - reaction - } - } - ) - } else { - announcement - } - } - ) - ) - }, - { - Log.w(TAG, "Failed to remove reaction from the announcement.", it) - } - ) - .autoDispose() + }, + { + Log.w(TAG, "Failed to remove reaction from the announcement.", it) + } + ) + } } companion object { 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 2bb97356..a243dcef 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 @@ -51,6 +51,8 @@ import androidx.core.view.ContentInfoCompat import androidx.core.view.OnReceiveContentListener import androidx.core.view.isGone import androidx.core.view.isVisible +import androidx.lifecycle.asLiveData +import androidx.lifecycle.lifecycleScope import androidx.preference.PreferenceManager import androidx.recyclerview.widget.LinearLayoutManager import androidx.transition.TransitionManager @@ -65,6 +67,7 @@ import com.keylesspalace.tusky.components.compose.dialog.makeCaptionDialog import com.keylesspalace.tusky.components.compose.dialog.showAddPollDialog import com.keylesspalace.tusky.components.compose.view.ComposeOptionsListener import com.keylesspalace.tusky.components.compose.view.ComposeScheduleView +import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository import com.keylesspalace.tusky.databinding.ActivityComposeBinding import com.keylesspalace.tusky.db.AccountEntity import com.keylesspalace.tusky.db.DraftAttachment @@ -93,6 +96,7 @@ import com.mikepenz.iconics.IconicsDrawable import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial import com.mikepenz.iconics.utils.colorInt import com.mikepenz.iconics.utils.sizeDp +import kotlinx.coroutines.launch import kotlinx.parcelize.Parcelize import java.io.File import java.io.IOException @@ -123,8 +127,8 @@ class ComposeActivity : private var photoUploadUri: Uri? = null @VisibleForTesting - var maximumTootCharacters = DEFAULT_CHARACTER_LIMIT - var charactersReservedPerUrl = DEFAULT_MAXIMUM_URL_LENGTH + var maximumTootCharacters = InstanceInfoRepository.DEFAULT_CHARACTER_LIMIT + var charactersReservedPerUrl = InstanceInfoRepository.DEFAULT_CHARACTERS_RESERVED_PER_URL private val viewModel: ComposeViewModel by viewModels { viewModelFactory } @@ -328,11 +332,10 @@ class ComposeActivity : private fun subscribeToUpdates(mediaAdapter: MediaPreviewAdapter) { withLifecycleContext { - viewModel.instanceParams.observe { instanceData -> + viewModel.instanceInfo.observe { instanceData -> maximumTootCharacters = instanceData.maxChars charactersReservedPerUrl = instanceData.charactersReservedPerUrl updateVisibleCharactersLeft() - binding.composeScheduleButton.visible(instanceData.supportsScheduled) } viewModel.emoji.observe { emoji -> setEmojiList(emoji) } combineLiveData(viewModel.markMediaAsSensitive, viewModel.showContentWarning) { markSensitive, showContentWarning -> @@ -342,14 +345,17 @@ class ComposeActivity : viewModel.statusVisibility.observe { visibility -> setStatusVisibility(visibility) } - viewModel.media.observe { media -> - mediaAdapter.submitList(media) - if (media.size != mediaCount) { - mediaCount = media.size - binding.composeMediaPreviewBar.visible(media.isNotEmpty()) - updateSensitiveMediaToggle(viewModel.markMediaAsSensitive.value != false, viewModel.showContentWarning.value != false) + lifecycleScope.launch { + viewModel.media.collect { media -> + mediaAdapter.submitList(media) + if (media.size != mediaCount) { + mediaCount = media.size + binding.composeMediaPreviewBar.visible(media.isNotEmpty()) + updateSensitiveMediaToggle(viewModel.markMediaAsSensitive.value != false, viewModel.showContentWarning.value != false) + } } } + viewModel.poll.observe { poll -> binding.pollPreview.visible(poll != null) poll?.let(binding.pollPreview::setPoll) @@ -362,7 +368,7 @@ class ComposeActivity : } updateScheduleButton() } - combineOptionalLiveData(viewModel.media, viewModel.poll) { media, poll -> + combineOptionalLiveData(viewModel.media.asLiveData(), viewModel.poll) { media, poll -> val active = poll == null && media!!.size != 4 && (media.isEmpty() || media.first().type == QueuedMedia.Type.IMAGE) @@ -666,7 +672,7 @@ class ComposeActivity : private fun openPollDialog() { addMediaBehavior.state = BottomSheetBehavior.STATE_COLLAPSED - val instanceParams = viewModel.instanceParams.value!! + val instanceParams = viewModel.instanceInfo.value!! showAddPollDialog( this, viewModel.poll.value, instanceParams.pollMaxOptions, instanceParams.pollMaxLength, instanceParams.pollMinDuration, instanceParams.pollMaxDuration, @@ -779,11 +785,11 @@ class ComposeActivity : spoilerText = binding.composeContentWarningField.text.toString() } val characterCount = calculateTextLength() - if ((characterCount <= 0 || contentText.isBlank()) && viewModel.media.value!!.isEmpty()) { + if ((characterCount <= 0 || contentText.isBlank()) && viewModel.media.value.isEmpty()) { binding.composeEditField.error = getString(R.string.error_empty) enableButtons(true) } else if (characterCount <= maximumTootCharacters) { - if (viewModel.media.value!!.isNotEmpty()) { + if (viewModel.media.value.isNotEmpty()) { finishingUploadDialog = ProgressDialog.show( this, getString(R.string.dialog_title_finishing_media_upload), getString(R.string.dialog_message_uploading_media), true, true @@ -866,25 +872,15 @@ class ComposeActivity : } private fun pickMedia(uri: Uri) { - withLifecycleContext { - viewModel.pickMedia(uri).observe { exceptionOrItem -> - exceptionOrItem.asLeftOrNull()?.let { - val errorId = when (it) { - is VideoSizeException -> { - R.string.error_video_upload_size - } - is AudioSizeException -> { - R.string.error_audio_upload_size - } - is VideoOrImageException -> { - R.string.error_media_upload_image_or_video - } - else -> { - R.string.error_media_upload_opening - } - } - displayTransientError(errorId) + lifecycleScope.launch { + viewModel.pickMedia(uri).onFailure { throwable -> + val errorId = when (throwable) { + is VideoSizeException -> R.string.error_video_upload_size + is AudioSizeException -> R.string.error_audio_upload_size + is VideoOrImageException -> R.string.error_media_upload_image_or_video + else -> R.string.error_media_upload_opening } + displayTransientError(errorId) } } } @@ -971,8 +967,19 @@ class ComposeActivity : } private fun saveDraftAndFinish(contentText: String, contentWarning: String) { - viewModel.saveDraft(contentText, contentWarning) - finishWithoutSlideOutAnimation() + lifecycleScope.launch { + val dialog = if (viewModel.shouldShowSaveDraftDialog()) { + ProgressDialog.show( + this@ComposeActivity, null, + getString(R.string.saving_draft), true, false + ) + } else { + null + } + viewModel.saveDraft(contentText, contentWarning) + dialog?.cancel() + finishWithoutSlideOutAnimation() + } } override fun search(token: String): List { @@ -991,7 +998,7 @@ class ComposeActivity : } data class QueuedMedia( - val localId: Long, + val localId: Int, val uri: Uri, val type: Type, val mediaSize: Long, 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 66dacfb4..7faf1139 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 @@ -20,14 +20,15 @@ import android.util.Log import androidx.core.net.toUri import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.Observer +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.drafts.DraftHelper +import com.keylesspalace.tusky.components.instanceinfo.InstanceInfo +import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository import com.keylesspalace.tusky.components.search.SearchType import com.keylesspalace.tusky.db.AccountManager -import com.keylesspalace.tusky.db.AppDatabase -import com.keylesspalace.tusky.db.InstanceEntity import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.entity.Emoji import com.keylesspalace.tusky.entity.NewPoll @@ -35,19 +36,21 @@ import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.service.ServiceClient import com.keylesspalace.tusky.service.StatusToSend -import com.keylesspalace.tusky.util.Either -import com.keylesspalace.tusky.util.RxAwareViewModel -import com.keylesspalace.tusky.util.VersionUtils import com.keylesspalace.tusky.util.combineLiveData -import com.keylesspalace.tusky.util.filter -import com.keylesspalace.tusky.util.map import com.keylesspalace.tusky.util.randomAlphanumericString import com.keylesspalace.tusky.util.toLiveData -import com.keylesspalace.tusky.util.withoutFirstWhich import io.reactivex.rxjava3.core.Observable -import io.reactivex.rxjava3.core.Single -import io.reactivex.rxjava3.disposables.Disposable +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update +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 @@ -57,8 +60,8 @@ class ComposeViewModel @Inject constructor( private val mediaUploader: MediaUploader, private val serviceClient: ServiceClient, private val draftHelper: DraftHelper, - private val db: AppDatabase -) : RxAwareViewModel() { + private val instanceInfoRepo: InstanceInfoRepository +) : ViewModel() { private var replyingStatusAuthor: String? = null private var replyingStatusContent: String? = null @@ -72,19 +75,8 @@ class ComposeViewModel @Inject constructor( private var contentWarningStateChanged: Boolean = false private var modifiedInitialState: Boolean = false - private val instance: MutableLiveData = MutableLiveData(null) + val instanceInfo: MutableLiveData = MutableLiveData() - val instanceParams: LiveData = instance.map { instance -> - ComposeInstanceParams( - maxChars = instance?.maximumTootCharacters ?: DEFAULT_CHARACTER_LIMIT, - pollMaxOptions = instance?.maxPollOptions ?: DEFAULT_MAX_OPTION_COUNT, - pollMaxLength = instance?.maxPollOptionLength ?: DEFAULT_MAX_OPTION_LENGTH, - pollMinDuration = instance?.minPollDuration ?: DEFAULT_MIN_POLL_DURATION, - pollMaxDuration = instance?.maxPollDuration ?: DEFAULT_MAX_POLL_DURATION, - charactersReservedPerUrl = instance?.charactersReservedPerUrl ?: DEFAULT_MAXIMUM_URL_LENGTH, - supportsScheduled = instance?.version?.let { VersionUtils(it).supportsScheduledToots() } ?: false - ) - } val emoji: MutableLiveData?> = MutableLiveData() val markMediaAsSensitive = mutableLiveData(accountManager.activeAccount?.defaultMediaSensitivity ?: false) @@ -95,131 +87,104 @@ class ComposeViewModel @Inject constructor( val poll: MutableLiveData = mutableLiveData(null) val scheduledAt: MutableLiveData = mutableLiveData(null) - val media = mutableLiveData>(listOf()) + val media: MutableStateFlow> = MutableStateFlow(emptyList()) val uploadError = MutableLiveData() - private val mediaToDisposable = mutableMapOf() + private val mediaToJob = mutableMapOf() private val isEditingScheduledToot get() = !scheduledTootId.isNullOrEmpty() init { - - Single.zip( - api.getCustomEmojis(), api.getInstance() - ) { emojis, instance -> - InstanceEntity( - instance = accountManager.activeAccount?.domain!!, - emojiList = emojis, - maximumTootCharacters = instance.configuration?.statuses?.maxCharacters ?: instance.maxTootChars, - maxPollOptions = instance.configuration?.polls?.maxOptions ?: instance.pollConfiguration?.maxOptions, - maxPollOptionLength = instance.configuration?.polls?.maxCharactersPerOption ?: instance.pollConfiguration?.maxOptionChars, - minPollDuration = instance.configuration?.polls?.minExpiration ?: instance.pollConfiguration?.minExpiration, - maxPollDuration = instance.configuration?.polls?.maxExpiration ?: instance.pollConfiguration?.maxExpiration, - charactersReservedPerUrl = instance.configuration?.statuses?.charactersReservedPerUrl, - version = instance.version - ) + viewModelScope.launch { + emoji.postValue(instanceInfoRepo.getEmojis()) + } + viewModelScope.launch { + instanceInfo.postValue(instanceInfoRepo.getInstanceInfo()) } - .doOnSuccess { - db.instanceDao().insertOrReplace(it) - } - .onErrorResumeNext { - db.instanceDao().loadMetadataForInstance(accountManager.activeAccount?.domain!!) - } - .subscribe( - { instanceEntity -> - emoji.postValue(instanceEntity.emojiList) - instance.postValue(instanceEntity) - }, - { throwable -> - // this can happen on network error when no cached data is available - Log.w(TAG, "error loading instance data", throwable) - } - ) - .autoDispose() } - fun pickMedia(uri: Uri, description: String? = null): LiveData> { - // We are not calling .toLiveData() here because we don't want to stop the process when - // the Activity goes away temporarily (like on screen rotation). - val liveData = MutableLiveData>() - mediaUploader.prepareMedia(uri) - .map { (type, uri, size) -> - val mediaItems = media.value!! - if (type != QueuedMedia.Type.IMAGE && - mediaItems.isNotEmpty() && - mediaItems[0].type == QueuedMedia.Type.IMAGE - ) { - throw VideoOrImageException() - } else { - addMediaToQueue(type, uri, size, description) - } + suspend fun pickMedia(mediaUri: Uri, description: String? = null): Result = withContext(Dispatchers.IO) { + try { + val (type, uri, size) = mediaUploader.prepareMedia(mediaUri) + val mediaItems = media.value + if (type != QueuedMedia.Type.IMAGE && + mediaItems.isNotEmpty() && + mediaItems[0].type == QueuedMedia.Type.IMAGE + ) { + Result.failure(VideoOrImageException()) + } else { + val queuedMedia = addMediaToQueue(type, uri, size, description) + Result.success(queuedMedia) } - .subscribe( - { queuedMedia -> - liveData.postValue(Either.Right(queuedMedia)) - }, - { error -> - liveData.postValue(Either.Left(error)) - } - ) - .autoDispose() - return liveData + } catch (e: Exception) { + Result.failure(e) + } } - private fun addMediaToQueue( + private suspend fun addMediaToQueue( type: QueuedMedia.Type, uri: Uri, mediaSize: Long, description: String? = null ): QueuedMedia { - val mediaItem = QueuedMedia( - localId = System.currentTimeMillis(), - uri = uri, - type = type, - mediaSize = mediaSize, - description = description - ) - media.value = media.value!! + mediaItem - mediaToDisposable[mediaItem.localId] = mediaUploader - .uploadMedia(mediaItem) - .subscribe( - { event -> - val item = media.value?.find { it.localId == mediaItem.localId } - ?: return@subscribe + val mediaItem = media.updateAndGet { mediaValue -> + val mediaItem = QueuedMedia( + localId = (mediaValue.maxOfOrNull { it.localId } ?: 0) + 1, + uri = uri, + type = type, + mediaSize = mediaSize, + description = description + ) + mediaValue + mediaItem + }.last() + mediaToJob[mediaItem.localId] = viewModelScope.launch { + mediaUploader + .uploadMedia(mediaItem) + .catch { error -> + media.update { mediaValue -> mediaValue.filter { it.localId != mediaItem.localId } } + uploadError.postValue(error) + } + .collect { event -> + val item = media.value.find { it.localId == mediaItem.localId } + ?: return@collect val newMediaItem = when (event) { is UploadEvent.ProgressEvent -> item.copy(uploadPercent = event.percentage) is UploadEvent.FinishedEvent -> item.copy(id = event.mediaId, uploadPercent = -1) } - synchronized(media) { - val mediaValue = media.value!! - val index = mediaValue.indexOfFirst { it.localId == newMediaItem.localId } - media.postValue( - if (index == -1) { - mediaValue + newMediaItem + media.update { mediaValue -> + mediaValue.map { mediaItem -> + if (mediaItem.localId == newMediaItem.localId) { + newMediaItem } else { - mediaValue.toMutableList().also { it[index] = newMediaItem } + mediaItem } - ) + } } - }, - { error -> - media.postValue(media.value?.filter { it.localId != mediaItem.localId } ?: emptyList()) - uploadError.postValue(error) } - ) + } return mediaItem } private fun addUploadedMedia(id: String, type: QueuedMedia.Type, uri: Uri, description: String?) { - val mediaItem = QueuedMedia(System.currentTimeMillis(), uri, type, 0, -1, id, description) - media.value = media.value!! + mediaItem + media.update { mediaValue -> + val mediaItem = QueuedMedia( + localId = (mediaValue.maxOfOrNull { it.localId } ?: 0) + 1, + uri = uri, + type = type, + mediaSize = 0, + uploadPercent = -1, + id = id, + description = description + ) + mediaValue + mediaItem + } } fun removeMediaFromQueue(item: QueuedMedia) { - mediaToDisposable[item.localId]?.dispose() - media.value = media.value!!.withoutFirstWhich { it.localId == item.localId } + mediaToJob[item.localId]?.cancel() + media.update { mediaValue -> mediaValue.filter { it.localId != item.localId } } } fun toggleMarkSensitive() { @@ -255,31 +220,36 @@ class ComposeViewModel @Inject constructor( } } - fun saveDraft(content: String, contentWarning: String) { - viewModelScope.launch { - val mediaUris: MutableList = mutableListOf() - val mediaDescriptions: MutableList = mutableListOf() - media.value?.forEach { item -> - mediaUris.add(item.uri.toString()) - mediaDescriptions.add(item.description) - } - - draftHelper.saveDraft( - draftId = draftId, - accountId = accountManager.activeAccount?.id!!, - inReplyToId = inReplyToId, - content = content, - contentWarning = contentWarning, - sensitive = markMediaAsSensitive.value!!, - visibility = statusVisibility.value!!, - mediaUris = mediaUris, - mediaDescriptions = mediaDescriptions, - poll = poll.value, - failedToSend = false - ) + fun shouldShowSaveDraftDialog(): Boolean { + // if any of the media files need to be downloaded first it could take a while, so show a loading dialog + return media.value.any { mediaValue -> + mediaValue.uri.scheme == "https" } } + suspend fun saveDraft(content: String, contentWarning: String) { + val mediaUris: MutableList = mutableListOf() + val mediaDescriptions: MutableList = mutableListOf() + media.value.forEach { item -> + mediaUris.add(item.uri.toString()) + mediaDescriptions.add(item.description) + } + + draftHelper.saveDraft( + draftId = draftId, + accountId = accountManager.activeAccount?.id!!, + inReplyToId = inReplyToId, + content = content, + contentWarning = contentWarning, + sensitive = markMediaAsSensitive.value!!, + visibility = statusVisibility.value!!, + mediaUris = mediaUris, + mediaDescriptions = mediaDescriptions, + poll = poll.value, + failedToSend = false + ) + } + /** * Send status to the server. * Uses current state plus provided arguments. @@ -291,21 +261,23 @@ class ComposeViewModel @Inject constructor( ): LiveData { val deletionObservable = if (isEditingScheduledToot) { - api.deleteScheduledStatus(scheduledTootId.toString()).toObservable().map { } + rxSingle { api.deleteScheduledStatus(scheduledTootId.toString()) }.toObservable().map { } } else { Observable.just(Unit) }.toLiveData() - val sendObservable = media + val sendFlow = media .filter { items -> items.all { it.uploadPercent == -1 } } .map { - val mediaIds = ArrayList() - val mediaUris = ArrayList() - val mediaDescriptions = ArrayList() - for (item in media.value!!) { + val mediaIds: MutableList = mutableListOf() + val mediaUris: MutableList = mutableListOf() + val mediaDescriptions: MutableList = mutableListOf() + val mediaProcessed: MutableList = mutableListOf() + for (item in media.value) { mediaIds.add(item.id!!) mediaUris.add(item.uri) mediaDescriptions.add(item.description ?: "") + mediaProcessed.add(false) } val tootToSend = StatusToSend( @@ -324,44 +296,38 @@ class ComposeViewModel @Inject constructor( accountId = accountManager.activeAccount!!.id, draftId = draftId, idempotencyKey = randomAlphanumericString(16), - retries = 0 + retries = 0, + mediaProcessed = mediaProcessed ) serviceClient.sendToot(tootToSend) } - return combineLiveData(deletionObservable, sendObservable) { _, _ -> } + return combineLiveData(deletionObservable, sendFlow.asLiveData()) { _, _ -> } } - fun updateDescription(localId: Long, description: String): LiveData { - val newList = media.value!!.toMutableList() - val index = newList.indexOfFirst { it.localId == localId } - if (index != -1) { - newList[index] = newList[index].copy(description = description) - } - media.value = newList - val completedCaptioningLiveData = MutableLiveData() - media.observeForever(object : Observer> { - override fun onChanged(mediaItems: List) { - val updatedItem = mediaItems.find { it.localId == localId } - if (updatedItem == null) { - media.removeObserver(this) - } else if (updatedItem.id != null) { - api.updateMedia(updatedItem.id, description) - .subscribe( - { - completedCaptioningLiveData.postValue(true) - }, - { - completedCaptioningLiveData.postValue(false) - } - ) - .autoDispose() - media.removeObserver(this) + suspend fun updateDescription(localId: Int, description: String): Boolean { + val newMediaList = media.updateAndGet { mediaValue -> + mediaValue.map { mediaItem -> + if (mediaItem.localId == localId) { + mediaItem.copy(description = description) + } else { + mediaItem } } - }) - return completedCaptioningLiveData + } + + val updatedItem = newMediaList.find { it.localId == localId } + if (updatedItem?.id != null) { + return api.updateMedia(updatedItem.id, description) + .fold({ + true + }, { throwable -> + Log.w(TAG, "failed to update media", throwable) + false + }) + } + return true } fun searchAutocompleteSuggestions(token: String): List { @@ -443,7 +409,11 @@ class ComposeViewModel @Inject constructor( val draftAttachments = composeOptions?.draftAttachments if (draftAttachments != null) { // when coming from DraftActivity - draftAttachments.forEach { attachment -> pickMedia(attachment.uri, attachment.description) } + viewModelScope.launch { + draftAttachments.forEach { attachment -> + pickMedia(attachment.uri, attachment.description) + } + } } else composeOptions?.mediaAttachments?.forEach { a -> // when coming from redraft or ScheduledTootActivity val mediaType = when (a.type) { @@ -494,13 +464,6 @@ class ComposeViewModel @Inject constructor( scheduledAt.value = newScheduledAt } - override fun onCleared() { - for (uploadDisposable in mediaToDisposable.values) { - uploadDisposable.dispose() - } - super.onCleared() - } - private companion object { const val TAG = "ComposeViewModel" } @@ -508,25 +471,6 @@ class ComposeViewModel @Inject constructor( fun mutableLiveData(default: T) = MutableLiveData().apply { value = default } -const val DEFAULT_CHARACTER_LIMIT = 500 -private const val DEFAULT_MAX_OPTION_COUNT = 4 -private const val DEFAULT_MAX_OPTION_LENGTH = 50 -private const val DEFAULT_MIN_POLL_DURATION = 300 -private const val DEFAULT_MAX_POLL_DURATION = 604800 - -// Mastodon only counts URLs as this long in terms of status character limits -const val DEFAULT_MAXIMUM_URL_LENGTH = 23 - -data class ComposeInstanceParams( - val maxChars: Int, - val pollMaxOptions: Int, - val pollMaxLength: Int, - val pollMinDuration: Int, - val pollMaxDuration: Int, - val charactersReservedPerUrl: Int, - val supportsScheduled: Boolean -) - /** * Thrown when trying to add an image when video is already present or the other way around */ diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/DownsizeImageTask.java b/app/src/main/java/com/keylesspalace/tusky/components/compose/DownsizeImageTask.java deleted file mode 100644 index 880a4167..00000000 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/DownsizeImageTask.java +++ /dev/null @@ -1,154 +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.ContentResolver; -import android.graphics.Bitmap; -import android.graphics.BitmapFactory; -import android.net.Uri; -import android.os.AsyncTask; - -import com.keylesspalace.tusky.util.IOUtils; - -import java.io.File; -import java.io.FileNotFoundException; -import java.io.FileOutputStream; -import java.io.InputStream; -import java.io.OutputStream; - -import static com.keylesspalace.tusky.util.MediaUtilsKt.calculateInSampleSize; -import static com.keylesspalace.tusky.util.MediaUtilsKt.getImageOrientation; -import static com.keylesspalace.tusky.util.MediaUtilsKt.reorientBitmap; - -/** - * Reduces the file size of images to fit under a given limit by resizing them, maintaining both - * aspect ratio and orientation. - */ -public class DownsizeImageTask extends AsyncTask { - private int sizeLimit; - private ContentResolver contentResolver; - private Listener listener; - private File tempFile; - - /** - * @param sizeLimit the maximum number of bytes each image can take - * @param contentResolver to resolve the specified images' URIs - * @param tempFile the file where the result will be stored - * @param listener to whom the results are given - */ - public DownsizeImageTask(int sizeLimit, ContentResolver contentResolver, File tempFile, Listener listener) { - this.sizeLimit = sizeLimit; - this.contentResolver = contentResolver; - this.tempFile = tempFile; - this.listener = listener; - } - - @Override - protected Boolean doInBackground(Uri... uris) { - boolean result = DownsizeImageTask.resize(uris, sizeLimit, contentResolver, tempFile); - if (isCancelled()) { - return false; - } - return result; - } - - @Override - protected void onPostExecute(Boolean successful) { - if (successful) { - listener.onSuccess(tempFile); - } else { - listener.onFailure(); - } - super.onPostExecute(successful); - } - - public static boolean resize(Uri[] uris, int sizeLimit, ContentResolver contentResolver, - File tempFile) { - for (Uri uri : uris) { - InputStream inputStream; - try { - inputStream = contentResolver.openInputStream(uri); - } catch (FileNotFoundException e) { - return false; - } - // Initially, just get the image dimensions. - BitmapFactory.Options options = new BitmapFactory.Options(); - options.inJustDecodeBounds = true; - BitmapFactory.decodeStream(inputStream, null, options); - IOUtils.closeQuietly(inputStream); - // Get EXIF data, for orientation info. - int orientation = getImageOrientation(uri, contentResolver); - /* Unfortunately, there isn't a determined worst case compression ratio for image - * formats. So, the only way to tell if they're too big is to compress them and - * test, and keep trying at smaller sizes. The initial estimate should be good for - * many cases, so it should only iterate once, but the loop is used to be absolutely - * sure it gets downsized to below the limit. */ - int scaledImageSize = 1024; - do { - OutputStream stream; - try { - stream = new FileOutputStream(tempFile); - } catch (FileNotFoundException e) { - return false; - } - try { - inputStream = contentResolver.openInputStream(uri); - } catch (FileNotFoundException e) { - return false; - } - options.inSampleSize = calculateInSampleSize(options, scaledImageSize, scaledImageSize); - options.inJustDecodeBounds = false; - Bitmap scaledBitmap; - try { - scaledBitmap = BitmapFactory.decodeStream(inputStream, null, options); - } catch (OutOfMemoryError error) { - return false; - } finally { - IOUtils.closeQuietly(inputStream); - } - if (scaledBitmap == null) { - return false; - } - Bitmap reorientedBitmap = reorientBitmap(scaledBitmap, orientation); - if (reorientedBitmap == null) { - scaledBitmap.recycle(); - return false; - } - Bitmap.CompressFormat format; - /* It's not likely the user will give transparent images over the upload limit, but - * if they do, make sure the transparency is retained. */ - if (!reorientedBitmap.hasAlpha()) { - format = Bitmap.CompressFormat.JPEG; - } else { - format = Bitmap.CompressFormat.PNG; - } - reorientedBitmap.compress(format, 85, stream); - reorientedBitmap.recycle(); - scaledImageSize /= 2; - } while (tempFile.length() > sizeLimit); - } - return true; - } - - /** - * Used to communicate the results of the task. - */ - public interface Listener { - void onSuccess(File file); - - void onFailure(); - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/ImageDownsizer.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/ImageDownsizer.kt new file mode 100644 index 00000000..a0215847 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/ImageDownsizer.kt @@ -0,0 +1,101 @@ +/* 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.content.ContentResolver +import android.graphics.Bitmap +import android.graphics.Bitmap.CompressFormat +import android.graphics.BitmapFactory +import android.net.Uri +import com.keylesspalace.tusky.util.IOUtils +import com.keylesspalace.tusky.util.calculateInSampleSize +import com.keylesspalace.tusky.util.getImageOrientation +import com.keylesspalace.tusky.util.reorientBitmap +import java.io.File +import java.io.FileNotFoundException +import java.io.FileOutputStream + +/** + * @param uri the uri pointing to the input file + * @param sizeLimit the maximum number of bytes the output image is allowed to have + * @param contentResolver to resolve the specified input uri + * @param tempFile the file where the result will be stored + * @return true when the image was successfully resized, false otherwise + */ +fun downsizeImage( + uri: Uri, + sizeLimit: Int, + contentResolver: ContentResolver, + tempFile: File +): Boolean { + + val decodeBoundsInputStream = try { + contentResolver.openInputStream(uri) + } catch (e: FileNotFoundException) { + return false + } + // Initially, just get the image dimensions. + val options = BitmapFactory.Options() + options.inJustDecodeBounds = true + BitmapFactory.decodeStream(decodeBoundsInputStream, null, options) + IOUtils.closeQuietly(decodeBoundsInputStream) + // Get EXIF data, for orientation info. + val orientation = getImageOrientation(uri, contentResolver) + /* Unfortunately, there isn't a determined worst case compression ratio for image + * formats. So, the only way to tell if they're too big is to compress them and + * test, and keep trying at smaller sizes. The initial estimate should be good for + * many cases, so it should only iterate once, but the loop is used to be absolutely + * sure it gets downsized to below the limit. */ + var scaledImageSize = 1024 + do { + val outputStream = try { + FileOutputStream(tempFile) + } catch (e: FileNotFoundException) { + return false + } + val decodeBitmapInputStream = try { + contentResolver.openInputStream(uri) + } catch (e: FileNotFoundException) { + return false + } + options.inSampleSize = calculateInSampleSize(options, scaledImageSize, scaledImageSize) + options.inJustDecodeBounds = false + val scaledBitmap: Bitmap = try { + BitmapFactory.decodeStream(decodeBitmapInputStream, null, options) + } catch (error: OutOfMemoryError) { + return false + } finally { + IOUtils.closeQuietly(decodeBitmapInputStream) + } ?: return false + + val reorientedBitmap = reorientBitmap(scaledBitmap, orientation) + if (reorientedBitmap == null) { + scaledBitmap.recycle() + return false + } + /* Retain transparency if there is any by encoding as png */ + val format: CompressFormat = if (!reorientedBitmap.hasAlpha()) { + CompressFormat.JPEG + } else { + CompressFormat.PNG + } + reorientedBitmap.compress(format, 85, outputStream) + reorientedBitmap.recycle() + scaledImageSize /= 2 + } while (tempFile.length() > sizeLimit) + + return true +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaUploader.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaUploader.kt index 0e3ac9e8..f1debc98 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaUploader.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaUploader.kt @@ -26,15 +26,20 @@ import androidx.core.net.toUri import com.keylesspalace.tusky.BuildConfig import com.keylesspalace.tusky.R import com.keylesspalace.tusky.components.compose.ComposeActivity.QueuedMedia -import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.network.MediaUploadApi import com.keylesspalace.tusky.network.ProgressRequestBody import com.keylesspalace.tusky.util.MEDIA_SIZE_UNKNOWN import com.keylesspalace.tusky.util.getImageSquarePixels import com.keylesspalace.tusky.util.getMediaSize import com.keylesspalace.tusky.util.randomAlphanumericString -import io.reactivex.rxjava3.core.Observable -import io.reactivex.rxjava3.core.Single -import io.reactivex.rxjava3.schedulers.Schedulers +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MultipartBody import java.io.File @@ -70,63 +75,42 @@ class CouldNotOpenFileException : Exception() class MediaUploader @Inject constructor( private val context: Context, - private val mastodonApi: MastodonApi + private val mediaUploadApi: MediaUploadApi ) { - fun uploadMedia(media: QueuedMedia): Observable { - return Observable - .fromCallable { - if (shouldResizeMedia(media)) { - downsize(media) - } else media + + @OptIn(ExperimentalCoroutinesApi::class) + fun uploadMedia(media: QueuedMedia): Flow { + return flow { + if (shouldResizeMedia(media)) { + emit(downsize(media)) + } else { + emit(media) } - .switchMap { upload(it) } - .subscribeOn(Schedulers.io()) + } + .flatMapLatest { upload(it) } + .flowOn(Dispatchers.IO) } - fun prepareMedia(inUri: Uri): Single { - return Single.fromCallable { - var mediaSize = MEDIA_SIZE_UNKNOWN - var uri = inUri - var mimeType: String? = null + fun prepareMedia(inUri: Uri): PreparedMedia { + var mediaSize = MEDIA_SIZE_UNKNOWN + var uri = inUri + val mimeType: String? - try { - when (inUri.scheme) { - ContentResolver.SCHEME_CONTENT -> { + try { + when (inUri.scheme) { + ContentResolver.SCHEME_CONTENT -> { - mimeType = contentResolver.getType(uri) + mimeType = contentResolver.getType(uri) - val suffix = "." + MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType ?: "tmp") + val suffix = "." + MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType ?: "tmp") - contentResolver.openInputStream(inUri).use { input -> - if (input == null) { - Log.w(TAG, "Media input is null") - uri = inUri - return@use - } - val file = File.createTempFile("randomTemp1", suffix, context.cacheDir) - FileOutputStream(file.absoluteFile).use { out -> - input.copyTo(out) - uri = FileProvider.getUriForFile( - context, - BuildConfig.APPLICATION_ID + ".fileprovider", - file - ) - mediaSize = getMediaSize(contentResolver, uri) - } + contentResolver.openInputStream(inUri).use { input -> + if (input == null) { + Log.w(TAG, "Media input is null") + uri = inUri + return@use } - } - ContentResolver.SCHEME_FILE -> { - val path = uri.path - if (path == null) { - Log.w(TAG, "empty uri path $uri") - throw CouldNotOpenFileException() - } - val inputFile = File(path) - val suffix = inputFile.name.substringAfterLast('.', "tmp") - mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(suffix) - val file = File.createTempFile("randomTemp1", ".$suffix", context.cacheDir) - val input = FileInputStream(inputFile) - + val file = File.createTempFile("randomTemp1", suffix, context.cacheDir) FileOutputStream(file.absoluteFile).use { out -> input.copyTo(out) uri = FileProvider.getUriForFile( @@ -137,53 +121,74 @@ class MediaUploader @Inject constructor( mediaSize = getMediaSize(contentResolver, uri) } } - else -> { - Log.w(TAG, "Unknown uri scheme $uri") + } + ContentResolver.SCHEME_FILE -> { + val path = uri.path + if (path == null) { + Log.w(TAG, "empty uri path $uri") throw CouldNotOpenFileException() } - } - } catch (e: IOException) { - Log.w(TAG, e) - throw CouldNotOpenFileException() - } - if (mediaSize == MEDIA_SIZE_UNKNOWN) { - Log.w(TAG, "Could not determine file size of upload") - throw MediaTypeException() - } + val inputFile = File(path) + val suffix = inputFile.name.substringAfterLast('.', "tmp") + mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(suffix) + val file = File.createTempFile("randomTemp1", ".$suffix", context.cacheDir) + val input = FileInputStream(inputFile) - if (mimeType != null) { - val topLevelType = mimeType.substring(0, mimeType.indexOf('/')) - when (topLevelType) { - "video" -> { - if (mediaSize > STATUS_VIDEO_SIZE_LIMIT) { - throw VideoSizeException() - } - PreparedMedia(QueuedMedia.Type.VIDEO, uri, mediaSize) - } - "image" -> { - PreparedMedia(QueuedMedia.Type.IMAGE, uri, mediaSize) - } - "audio" -> { - if (mediaSize > STATUS_AUDIO_SIZE_LIMIT) { - throw AudioSizeException() - } - PreparedMedia(QueuedMedia.Type.AUDIO, uri, mediaSize) - } - else -> { - throw MediaTypeException() + FileOutputStream(file.absoluteFile).use { out -> + input.copyTo(out) + uri = FileProvider.getUriForFile( + context, + BuildConfig.APPLICATION_ID + ".fileprovider", + file + ) + mediaSize = getMediaSize(contentResolver, uri) } } - } else { - Log.w(TAG, "Could not determine mime type of upload") - throw MediaTypeException() + else -> { + Log.w(TAG, "Unknown uri scheme $uri") + throw CouldNotOpenFileException() + } } + } catch (e: IOException) { + Log.w(TAG, e) + throw CouldNotOpenFileException() + } + if (mediaSize == MEDIA_SIZE_UNKNOWN) { + Log.w(TAG, "Could not determine file size of upload") + throw MediaTypeException() + } + + if (mimeType != null) { + return when (mimeType.substring(0, mimeType.indexOf('/'))) { + "video" -> { + if (mediaSize > STATUS_VIDEO_SIZE_LIMIT) { + throw VideoSizeException() + } + PreparedMedia(QueuedMedia.Type.VIDEO, uri, mediaSize) + } + "image" -> { + PreparedMedia(QueuedMedia.Type.IMAGE, uri, mediaSize) + } + "audio" -> { + if (mediaSize > STATUS_AUDIO_SIZE_LIMIT) { + throw AudioSizeException() + } + PreparedMedia(QueuedMedia.Type.AUDIO, uri, mediaSize) + } + else -> { + throw MediaTypeException() + } + } + } else { + Log.w(TAG, "Could not determine mime type of upload") + throw MediaTypeException() } } private val contentResolver = context.contentResolver - private fun upload(media: QueuedMedia): Observable { - return Observable.create { emitter -> + private suspend fun upload(media: QueuedMedia): Flow { + return callbackFlow { var mimeType = contentResolver.getType(media.uri) val map = MimeTypeMap.getSingleton() val fileExtension = map.getExtensionFromMimeType(mimeType) @@ -200,11 +205,11 @@ class MediaUploader @Inject constructor( var lastProgress = -1 val fileBody = ProgressRequestBody( - stream, media.mediaSize, - mimeType.toMediaTypeOrNull() + stream!!, media.mediaSize, + mimeType.toMediaTypeOrNull()!! ) { percentage -> if (percentage != lastProgress) { - emitter.onNext(UploadEvent.ProgressEvent(percentage)) + trySend(UploadEvent.ProgressEvent(percentage)) } lastProgress = percentage } @@ -217,28 +222,15 @@ class MediaUploader @Inject constructor( null } - val uploadDisposable = mastodonApi.uploadMedia(body, description) - .subscribe( - { result -> - emitter.onNext(UploadEvent.FinishedEvent(result.id)) - emitter.onComplete() - }, - { e -> - emitter.onError(e) - } - ) - - // Cancel the request when our observable is cancelled - emitter.setDisposable(uploadDisposable) + val result = mediaUploadApi.uploadMedia(body, description).getOrThrow() + send(UploadEvent.FinishedEvent(result.id)) + awaitClose() } } private fun downsize(media: QueuedMedia): QueuedMedia { val file = createNewImageFile(context) - DownsizeImageTask.resize( - arrayOf(media.uri), - STATUS_IMAGE_SIZE_LIMIT, context.contentResolver, file - ) + downsizeImage(media.uri, STATUS_IMAGE_SIZE_LIMIT, contentResolver, file) return media.copy(uri = file.toUri(), mediaSize = file.length()) } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/CaptionDialog.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/CaptionDialog.kt index 0c15eff0..71789611 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/CaptionDialog.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/CaptionDialog.kt @@ -27,7 +27,7 @@ import android.widget.LinearLayout import android.widget.Toast import androidx.appcompat.app.AlertDialog import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.LiveData +import androidx.lifecycle.lifecycleScope import at.connyduck.sparkbutton.helpers.Utils import com.bumptech.glide.Glide import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy @@ -35,7 +35,7 @@ import com.bumptech.glide.request.target.CustomTarget import com.bumptech.glide.request.transition.Transition import com.github.chrisbanes.photoview.PhotoView import com.keylesspalace.tusky.R -import com.keylesspalace.tusky.util.withLifecycleContext +import kotlinx.coroutines.launch // https://github.com/tootsuite/mastodon/blob/c6904c0d3766a2ea8a81ab025c127169ecb51373/app/models/media_attachment.rb#L32 private const val MEDIA_DESCRIPTION_CHARACTER_LIMIT = 1500 @@ -43,7 +43,7 @@ private const val MEDIA_DESCRIPTION_CHARACTER_LIMIT = 1500 fun T.makeCaptionDialog( existingDescription: String?, previewUri: Uri, - onUpdateDescription: (String) -> LiveData + onUpdateDescription: suspend (String) -> Boolean ) where T : Activity, T : LifecycleOwner { val dialogLayout = LinearLayout(this) val padding = Utils.dpToPx(this, 8) @@ -77,12 +77,11 @@ fun T.makeCaptionDialog( input.filters = arrayOf(InputFilter.LengthFilter(MEDIA_DESCRIPTION_CHARACTER_LIMIT)) val okListener = { dialog: DialogInterface, _: Int -> - onUpdateDescription(input.text.toString()) - withLifecycleContext { - onUpdateDescription(input.text.toString()) - .observe { success -> if (!success) showFailedCaptionMessage() } + lifecycleScope.launch { + if (!onUpdateDescription(input.text.toString())) { + showFailedCaptionMessage() + } } - dialog.dismiss() } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/view/EditTextTyped.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/view/EditTextTyped.kt index dca696d8..2a1c7446 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/view/EditTextTyped.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/view/EditTextTyped.kt @@ -26,7 +26,7 @@ import androidx.core.view.OnReceiveContentListener import androidx.core.view.ViewCompat import androidx.core.view.inputmethod.EditorInfoCompat import androidx.core.view.inputmethod.InputConnectionCompat -import androidx.emoji.widget.EmojiEditTextHelper +import androidx.emoji2.viewsintegration.EmojiEditTextHelper class EditTextTyped @JvmOverloads constructor( context: Context, diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationAdapter.kt index 89c1ad0f..0c946514 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationAdapter.kt @@ -26,7 +26,7 @@ import com.keylesspalace.tusky.util.StatusDisplayOptions class ConversationAdapter( private val statusDisplayOptions: StatusDisplayOptions, private val listener: StatusActionListener -) : PagingDataAdapter(CONVERSATION_COMPARATOR) { +) : PagingDataAdapter(CONVERSATION_COMPARATOR) { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ConversationViewHolder { val view = LayoutInflater.from(parent.context).inflate(R.layout.item_conversation, parent, false) @@ -37,17 +37,13 @@ class ConversationAdapter( holder.setupWithConversation(getItem(position)) } - fun item(position: Int): ConversationEntity? { - return getItem(position) - } - companion object { - val CONVERSATION_COMPARATOR = object : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: ConversationEntity, newItem: ConversationEntity): Boolean { + val CONVERSATION_COMPARATOR = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: ConversationViewData, newItem: ConversationViewData): Boolean { return oldItem.id == newItem.id } - override fun areContentsTheSame(oldItem: ConversationEntity, newItem: ConversationEntity): Boolean { + override fun areContentsTheSame(oldItem: ConversationViewData, newItem: ConversationViewData): Boolean { return oldItem == newItem } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationEntity.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationEntity.kt index 88c9dbad..f585b4ea 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationEntity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationEntity.kt @@ -15,7 +15,6 @@ package com.keylesspalace.tusky.components.conversation -import android.text.Spanned import androidx.room.Embedded import androidx.room.Entity import androidx.room.TypeConverters @@ -27,7 +26,7 @@ import com.keylesspalace.tusky.entity.HashTag import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.TimelineAccount -import com.keylesspalace.tusky.util.shouldTrimStatus +import com.keylesspalace.tusky.viewdata.StatusViewData import java.util.Date @Entity(primaryKeys = ["id", "accountId"]) @@ -38,7 +37,16 @@ data class ConversationEntity( val accounts: List, val unread: Boolean, @Embedded(prefix = "s_") val lastStatus: ConversationStatusEntity -) +) { + fun toViewData(): ConversationViewData { + return ConversationViewData( + id = id, + accounts = accounts, + unread = unread, + lastStatus = lastStatus.toViewData() + ) + } +} data class ConversationAccountEntity( val id: String, @@ -67,7 +75,7 @@ data class ConversationStatusEntity( val inReplyToId: String?, val inReplyToAccountId: String?, val account: ConversationAccountEntity, - val content: Spanned, + val content: String, val createdAt: Date, val emojis: List, val favouritesCount: Int, @@ -80,95 +88,43 @@ data class ConversationStatusEntity( val tags: List?, val showingHiddenContent: Boolean, val expanded: Boolean, - val collapsible: Boolean, val collapsed: Boolean, val muted: Boolean, val poll: Poll? ) { - /** its necessary to override this because Spanned.equals does not work as expected */ - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - other as ConversationStatusEntity - - if (id != other.id) return false - if (url != other.url) return false - if (inReplyToId != other.inReplyToId) return false - if (inReplyToAccountId != other.inReplyToAccountId) return false - if (account != other.account) return false - if (content.toString() != other.content.toString()) return false - if (createdAt != other.createdAt) return false - if (emojis != other.emojis) return false - if (favouritesCount != other.favouritesCount) return false - if (favourited != other.favourited) return false - if (sensitive != other.sensitive) return false - if (spoilerText != other.spoilerText) return false - if (attachments != other.attachments) return false - if (mentions != other.mentions) return false - if (tags != other.tags) return false - if (showingHiddenContent != other.showingHiddenContent) return false - if (expanded != other.expanded) return false - if (collapsible != other.collapsible) return false - if (collapsed != other.collapsed) return false - if (muted != other.muted) return false - if (poll != other.poll) return false - - return true - } - - override fun hashCode(): Int { - var result = id.hashCode() - result = 31 * result + (url?.hashCode() ?: 0) - result = 31 * result + (inReplyToId?.hashCode() ?: 0) - result = 31 * result + (inReplyToAccountId?.hashCode() ?: 0) - result = 31 * result + account.hashCode() - result = 31 * result + content.toString().hashCode() - result = 31 * result + createdAt.hashCode() - result = 31 * result + emojis.hashCode() - result = 31 * result + favouritesCount - result = 31 * result + favourited.hashCode() - result = 31 * result + sensitive.hashCode() - result = 31 * result + spoilerText.hashCode() - result = 31 * result + attachments.hashCode() - result = 31 * result + mentions.hashCode() - result = 31 * result + tags.hashCode() - result = 31 * result + showingHiddenContent.hashCode() - result = 31 * result + expanded.hashCode() - result = 31 * result + collapsible.hashCode() - result = 31 * result + collapsed.hashCode() - result = 31 * result + muted.hashCode() - result = 31 * result + poll.hashCode() - return result - } - - fun toStatus(): Status { - return Status( - id = id, - url = url, - account = account.toAccount(), - inReplyToId = inReplyToId, - inReplyToAccountId = inReplyToAccountId, - content = content, - reblog = null, - createdAt = createdAt, - emojis = emojis, - reblogsCount = 0, - favouritesCount = favouritesCount, - reblogged = false, - favourited = favourited, - bookmarked = bookmarked, - sensitive = sensitive, - spoilerText = spoilerText, - visibility = Status.Visibility.DIRECT, - attachments = attachments, - mentions = mentions, - tags = tags, - application = null, - pinned = false, - muted = muted, - poll = poll, - card = null + fun toViewData(): StatusViewData.Concrete { + return StatusViewData.Concrete( + status = Status( + id = id, + url = url, + account = account.toAccount(), + inReplyToId = inReplyToId, + inReplyToAccountId = inReplyToAccountId, + content = content, + reblog = null, + createdAt = createdAt, + emojis = emojis, + reblogsCount = 0, + favouritesCount = favouritesCount, + reblogged = false, + favourited = favourited, + bookmarked = bookmarked, + sensitive = sensitive, + spoilerText = spoilerText, + visibility = Status.Visibility.DIRECT, + attachments = attachments, + mentions = mentions, + tags = tags, + application = null, + pinned = false, + muted = muted, + poll = poll, + card = null + ), + isExpanded = expanded, + isShowingContent = showingHiddenContent, + isCollapsed = collapsed ) } } @@ -202,7 +158,6 @@ fun Status.toEntity() = tags = tags, showingHiddenContent = false, expanded = false, - collapsible = shouldTrimStatus(content), collapsed = true, muted = muted ?: false, poll = poll diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewData.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewData.kt new file mode 100644 index 00000000..470675d1 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewData.kt @@ -0,0 +1,87 @@ +/* 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.conversation + +import com.keylesspalace.tusky.entity.Poll +import com.keylesspalace.tusky.viewdata.StatusViewData + +data class ConversationViewData( + val id: String, + val accounts: List, + val unread: Boolean, + val lastStatus: StatusViewData.Concrete +) { + fun toEntity( + accountId: Long, + favourited: Boolean = lastStatus.status.favourited, + bookmarked: Boolean = lastStatus.status.bookmarked, + muted: Boolean = lastStatus.status.muted ?: false, + poll: Poll? = lastStatus.status.poll, + expanded: Boolean = lastStatus.isExpanded, + collapsed: Boolean = lastStatus.isCollapsed, + showingHiddenContent: Boolean = lastStatus.isShowingContent + ): ConversationEntity { + return ConversationEntity( + accountId = accountId, + id = id, + accounts = accounts, + unread = unread, + lastStatus = lastStatus.toConversationStatusEntity( + favourited = favourited, + bookmarked = bookmarked, + muted = muted, + poll = poll, + expanded = expanded, + collapsed = collapsed, + showingHiddenContent = showingHiddenContent + ) + ) + } +} + +fun StatusViewData.Concrete.toConversationStatusEntity( + favourited: Boolean = status.favourited, + bookmarked: Boolean = status.bookmarked, + muted: Boolean = status.muted ?: false, + poll: Poll? = status.poll, + expanded: Boolean = isExpanded, + collapsed: Boolean = isCollapsed, + showingHiddenContent: Boolean = isShowingContent +): ConversationStatusEntity { + return ConversationStatusEntity( + id = id, + url = status.url, + inReplyToId = status.inReplyToId, + inReplyToAccountId = status.inReplyToAccountId, + account = status.account.toEntity(), + content = status.content, + createdAt = status.createdAt, + emojis = status.emojis, + favouritesCount = status.favouritesCount, + favourited = favourited, + bookmarked = bookmarked, + sensitive = status.sensitive, + spoilerText = status.spoilerText, + attachments = status.attachments, + mentions = status.mentions, + tags = status.tags, + showingHiddenContent = showingHiddenContent, + expanded = expanded, + collapsed = collapsed, + muted = muted, + poll = poll + ) +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewHolder.java index 436ba84e..ffb88a94 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewHolder.java @@ -28,11 +28,14 @@ import androidx.recyclerview.widget.RecyclerView; import com.keylesspalace.tusky.R; import com.keylesspalace.tusky.adapter.StatusBaseViewHolder; import com.keylesspalace.tusky.entity.Attachment; +import com.keylesspalace.tusky.entity.Status; +import com.keylesspalace.tusky.entity.TimelineAccount; import com.keylesspalace.tusky.interfaces.StatusActionListener; import com.keylesspalace.tusky.util.ImageLoadingHelper; import com.keylesspalace.tusky.util.SmartLengthInputFilter; import com.keylesspalace.tusky.util.StatusDisplayOptions; import com.keylesspalace.tusky.viewdata.PollViewDataKt; +import com.keylesspalace.tusky.viewdata.StatusViewData; import java.util.List; @@ -69,11 +72,12 @@ public class ConversationViewHolder extends StatusBaseViewHolder { return context.getResources().getDimensionPixelSize(R.dimen.status_media_preview_height); } - void setupWithConversation(ConversationEntity conversation) { - ConversationStatusEntity status = conversation.getLastStatus(); - ConversationAccountEntity account = status.getAccount(); + void setupWithConversation(ConversationViewData conversation) { + StatusViewData.Concrete statusViewData = conversation.getLastStatus(); + Status status = statusViewData.getStatus(); + TimelineAccount account = status.getAccount(); - setupCollapsedState(status.getCollapsible(), status.getCollapsed(), status.getExpanded(), status.getSpoilerText(), listener); + setupCollapsedState(statusViewData.isCollapsible(), statusViewData.isCollapsed(), statusViewData.isExpanded(), statusViewData.getSpoilerText(), listener); setDisplayName(account.getDisplayName(), account.getEmojis(), statusDisplayOptions); setUsername(account.getUsername()); @@ -84,7 +88,7 @@ public class ConversationViewHolder extends StatusBaseViewHolder { List attachments = status.getAttachments(); boolean sensitive = status.getSensitive(); if (statusDisplayOptions.mediaPreviewEnabled() && hasPreviewableAttachment(attachments)) { - setMediaPreviews(attachments, sensitive, listener, status.getShowingHiddenContent(), + setMediaPreviews(attachments, sensitive, listener, statusViewData.isShowingContent(), statusDisplayOptions.useBlurhash()); if (attachments.size() == 0) { @@ -95,7 +99,7 @@ public class ConversationViewHolder extends StatusBaseViewHolder { mediaLabel.setVisibility(View.GONE); } } else { - setMediaLabel(attachments, sensitive, listener, status.getShowingHiddenContent()); + setMediaLabel(attachments, sensitive, listener, statusViewData.isShowingContent()); // Hide all unused views. mediaPreviews[0].setVisibility(View.GONE); mediaPreviews[1].setVisibility(View.GONE); @@ -104,10 +108,10 @@ public class ConversationViewHolder extends StatusBaseViewHolder { hideSensitiveMediaWarning(); } - setupButtons(listener, account.getId(), status.getContent().toString(), + setupButtons(listener, account.getId(), statusViewData.getContent().toString(), statusDisplayOptions); - setSpoilerAndContent(status.getExpanded(), status.getContent(), status.getSpoilerText(), + setSpoilerAndContent(statusViewData.isExpanded(), statusViewData.getContent(), status.getSpoilerText(), status.getMentions(), status.getTags(), status.getEmojis(), PollViewDataKt.toViewData(status.getPoll()), statusDisplayOptions, listener); diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt index 21c6fef6..2f1c0366 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt @@ -153,24 +153,24 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res } override fun onFavourite(favourite: Boolean, position: Int) { - adapter.item(position)?.let { conversation -> + adapter.peek(position)?.let { conversation -> viewModel.favourite(favourite, conversation) } } override fun onBookmark(favourite: Boolean, position: Int) { - adapter.item(position)?.let { conversation -> + adapter.peek(position)?.let { conversation -> viewModel.bookmark(favourite, conversation) } } override fun onMore(view: View, position: Int) { - adapter.item(position)?.let { conversation -> + adapter.peek(position)?.let { conversation -> val popup = PopupMenu(requireContext(), view) popup.inflate(R.menu.conversation_more) - if (conversation.lastStatus.muted) { + if (conversation.lastStatus.status.muted == true) { popup.menu.removeItem(R.id.status_mute_conversation) } else { popup.menu.removeItem(R.id.status_unmute_conversation) @@ -189,14 +189,14 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res } override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) { - adapter.item(position)?.let { conversation -> - viewMedia(attachmentIndex, AttachmentViewData.list(conversation.lastStatus.toStatus()), view) + adapter.peek(position)?.let { conversation -> + viewMedia(attachmentIndex, AttachmentViewData.list(conversation.lastStatus.status), view) } } override fun onViewThread(position: Int) { - adapter.item(position)?.let { conversation -> - viewThread(conversation.lastStatus.id, conversation.lastStatus.url) + adapter.peek(position)?.let { conversation -> + viewThread(conversation.lastStatus.id, conversation.lastStatus.status.url) } } @@ -205,13 +205,13 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res } override fun onExpandedChange(expanded: Boolean, position: Int) { - adapter.item(position)?.let { conversation -> + adapter.peek(position)?.let { conversation -> viewModel.expandHiddenStatus(expanded, conversation) } } override fun onContentHiddenChange(isShowing: Boolean, position: Int) { - adapter.item(position)?.let { conversation -> + adapter.peek(position)?.let { conversation -> viewModel.showContent(isShowing, conversation) } } @@ -221,7 +221,7 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res } override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) { - adapter.item(position)?.let { conversation -> + adapter.peek(position)?.let { conversation -> viewModel.collapseLongStatus(isCollapsed, conversation) } } @@ -241,12 +241,12 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res } override fun onReply(position: Int) { - adapter.item(position)?.let { conversation -> - reply(conversation.lastStatus.toStatus()) + adapter.peek(position)?.let { conversation -> + reply(conversation.lastStatus.status) } } - private fun deleteConversation(conversation: ConversationEntity) { + private fun deleteConversation(conversation: ConversationViewData) { AlertDialog.Builder(requireContext()) .setMessage(R.string.dialog_delete_conversation_warning) .setNegativeButton(android.R.string.cancel, null) @@ -268,7 +268,7 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res } override fun onVoteInPoll(position: Int, choices: MutableList) { - adapter.item(position)?.let { conversation -> + adapter.peek(position)?.let { conversation -> viewModel.voteInPoll(choices, conversation) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsViewModel.kt index 396f8e48..9326a05c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsViewModel.kt @@ -16,16 +16,18 @@ package com.keylesspalace.tusky.components.conversation import android.util.Log +import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.paging.ExperimentalPagingApi import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.cachedIn +import androidx.paging.map import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.TimelineCases -import com.keylesspalace.tusky.util.RxAwareViewModel +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.coroutines.rx3.await import javax.inject.Inject @@ -35,7 +37,7 @@ class ConversationsViewModel @Inject constructor( private val database: AppDatabase, private val accountManager: AccountManager, private val api: MastodonApi -) : RxAwareViewModel() { +) : ViewModel() { @OptIn(ExperimentalPagingApi::class) val conversationFlow = Pager( @@ -44,104 +46,117 @@ class ConversationsViewModel @Inject constructor( pagingSourceFactory = { database.conversationDao().conversationsForAccount(accountManager.activeAccount!!.id) } ) .flow + .map { pagingData -> + pagingData.map { conversation -> conversation.toViewData() } + } .cachedIn(viewModelScope) - fun favourite(favourite: Boolean, conversation: ConversationEntity) { + fun favourite(favourite: Boolean, conversation: ConversationViewData) { viewModelScope.launch { try { timelineCases.favourite(conversation.lastStatus.id, favourite).await() - val newConversation = conversation.copy( - lastStatus = conversation.lastStatus.copy(favourited = favourite) + val newConversation = conversation.toEntity( + accountId = accountManager.activeAccount!!.id, + favourited = favourite ) - database.conversationDao().insert(newConversation) + saveConversationToDb(newConversation) } catch (e: Exception) { Log.w(TAG, "failed to favourite status", e) } } } - fun bookmark(bookmark: Boolean, conversation: ConversationEntity) { + fun bookmark(bookmark: Boolean, conversation: ConversationViewData) { viewModelScope.launch { try { timelineCases.bookmark(conversation.lastStatus.id, bookmark).await() - val newConversation = conversation.copy( - lastStatus = conversation.lastStatus.copy(bookmarked = bookmark) + val newConversation = conversation.toEntity( + accountId = accountManager.activeAccount!!.id, + bookmarked = bookmark ) - database.conversationDao().insert(newConversation) + saveConversationToDb(newConversation) } catch (e: Exception) { Log.w(TAG, "failed to bookmark status", e) } } } - fun voteInPoll(choices: List, conversation: ConversationEntity) { + fun voteInPoll(choices: List, conversation: ConversationViewData) { viewModelScope.launch { try { - val poll = timelineCases.voteInPoll(conversation.lastStatus.id, conversation.lastStatus.poll?.id!!, choices).await() - val newConversation = conversation.copy( - lastStatus = conversation.lastStatus.copy(poll = poll) + val poll = timelineCases.voteInPoll(conversation.lastStatus.id, conversation.lastStatus.status.poll?.id!!, choices).await() + val newConversation = conversation.toEntity( + accountId = accountManager.activeAccount!!.id, + poll = poll ) - database.conversationDao().insert(newConversation) + saveConversationToDb(newConversation) } catch (e: Exception) { Log.w(TAG, "failed to vote in poll", e) } } } - fun expandHiddenStatus(expanded: Boolean, conversation: ConversationEntity) { + fun expandHiddenStatus(expanded: Boolean, conversation: ConversationViewData) { viewModelScope.launch { - val newConversation = conversation.copy( - lastStatus = conversation.lastStatus.copy(expanded = expanded) + val newConversation = conversation.toEntity( + accountId = accountManager.activeAccount!!.id, + expanded = expanded ) saveConversationToDb(newConversation) } } - fun collapseLongStatus(collapsed: Boolean, conversation: ConversationEntity) { + fun collapseLongStatus(collapsed: Boolean, conversation: ConversationViewData) { viewModelScope.launch { - val newConversation = conversation.copy( - lastStatus = conversation.lastStatus.copy(collapsed = collapsed) + val newConversation = conversation.toEntity( + accountId = accountManager.activeAccount!!.id, + collapsed = collapsed ) saveConversationToDb(newConversation) } } - fun showContent(showing: Boolean, conversation: ConversationEntity) { + fun showContent(showing: Boolean, conversation: ConversationViewData) { viewModelScope.launch { - val newConversation = conversation.copy( - lastStatus = conversation.lastStatus.copy(showingHiddenContent = showing) + val newConversation = conversation.toEntity( + accountId = accountManager.activeAccount!!.id, + showingHiddenContent = showing ) saveConversationToDb(newConversation) } } - fun remove(conversation: ConversationEntity) { + fun remove(conversation: ConversationViewData) { viewModelScope.launch { try { api.deleteConversation(conversationId = conversation.id) - database.conversationDao().delete(conversation) + database.conversationDao().delete( + id = conversation.id, + accountId = accountManager.activeAccount!!.id + ) } catch (e: Exception) { Log.w(TAG, "failed to delete conversation", e) } } } - fun muteConversation(conversation: ConversationEntity) { + fun muteConversation(conversation: ConversationViewData) { viewModelScope.launch { try { - val newStatus = timelineCases.muteConversation( + timelineCases.muteConversation( conversation.lastStatus.id, - !conversation.lastStatus.muted + !(conversation.lastStatus.status.muted ?: false) ).await() - val newConversation = conversation.copy( - lastStatus = newStatus.toEntity() + val newConversation = conversation.toEntity( + accountId = accountManager.activeAccount!!.id, + muted = !(conversation.lastStatus.status.muted ?: false) ) database.conversationDao().insert(newConversation) @@ -151,7 +166,7 @@ class ConversationsViewModel @Inject constructor( } } - suspend fun saveConversationToDb(conversation: ConversationEntity) { + private suspend fun saveConversationToDb(conversation: ConversationEntity) { database.conversationDao().insert(conversation) } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftHelper.kt b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftHelper.kt index 7511dc3c..a6cd3fcd 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftHelper.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftHelper.kt @@ -30,7 +30,12 @@ import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.util.IOUtils import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import okio.buffer +import okio.sink import java.io.File +import java.io.IOException import java.text.SimpleDateFormat import java.util.Date import java.util.Locale @@ -38,6 +43,7 @@ import javax.inject.Inject class DraftHelper @Inject constructor( val context: Context, + val okHttpClient: OkHttpClient, db: AppDatabase ) { @@ -71,11 +77,11 @@ class DraftHelper @Inject constructor( val uris = mediaUris.map { uriString -> uriString.toUri() - }.map { uri -> - if (uri.isNotInFolder(draftDirectory)) { - uri.copyToFolder(draftDirectory) - } else { + }.mapNotNull { uri -> + if (uri.isInFolder(draftDirectory)) { uri + } else { + uri.copyToFolder(draftDirectory) } } @@ -114,6 +120,7 @@ class DraftHelper @Inject constructor( ) draftDao.insertOrReplace(draft) + Log.d("DraftHelper", "saved draft to db") } suspend fun deleteDraftAndAttachments(draftId: Int) { @@ -133,33 +140,55 @@ class DraftHelper @Inject constructor( } } - suspend fun deleteAttachments(draft: DraftEntity) { - withContext(Dispatchers.IO) { - draft.attachments.forEach { attachment -> - if (context.contentResolver.delete(attachment.uri, null, null) == 0) { - Log.e("DraftHelper", "Did not delete file ${attachment.uriString}") - } + suspend fun deleteAttachments(draft: DraftEntity) = withContext(Dispatchers.IO) { + draft.attachments.forEach { attachment -> + if (context.contentResolver.delete(attachment.uri, null, null) == 0) { + Log.e("DraftHelper", "Did not delete file ${attachment.uriString}") } } } - private fun Uri.isNotInFolder(folder: File): Boolean { + private fun Uri.isInFolder(folder: File): Boolean { val filePath = path ?: return true return File(filePath).parentFile == folder } - private fun Uri.copyToFolder(folder: File): Uri { + private fun Uri.copyToFolder(folder: File): Uri? { val contentResolver = context.contentResolver - val timeStamp: String = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date()) - val mimeType = contentResolver.getType(this) - val map = MimeTypeMap.getSingleton() - val fileExtension = map.getExtensionFromMimeType(mimeType) + val fileExtension = if (scheme == "https") { + lastPathSegment?.substringAfterLast('.', "tmp") + } else { + val mimeType = contentResolver.getType(this) + val map = MimeTypeMap.getSingleton() + map.getExtensionFromMimeType(mimeType) + } val filename = String.format("Tusky_Draft_Media_%s.%s", timeStamp, fileExtension) val file = File(folder, filename) - IOUtils.copyToFile(contentResolver, this, file) + + if (scheme == "https") { + // saving redrafted media + try { + val request = Request.Builder().url(toString()).build() + + val response = okHttpClient.newCall(request).execute() + + val sink = file.sink().buffer() + + response.body?.source()?.use { input -> + sink.use { output -> + output.writeAll(input) + } + } + } catch (ex: IOException) { + Log.w("DraftHelper", "failed to save media", ex) + return null + } + } else { + IOUtils.copyToFile(contentResolver, this, file) + } return FileProvider.getUriForFile(context, BuildConfig.APPLICATION_ID + ".fileprovider", file) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsActivity.kt index e580f554..db6a8a31 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsActivity.kt @@ -35,6 +35,7 @@ import com.keylesspalace.tusky.components.compose.ComposeActivity import com.keylesspalace.tusky.databinding.ActivityDraftsBinding import com.keylesspalace.tusky.db.DraftEntity import com.keylesspalace.tusky.di.ViewModelFactory +import com.keylesspalace.tusky.util.parseAsMastodonHtml import com.keylesspalace.tusky.util.visible import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import kotlinx.coroutines.flow.collectLatest @@ -100,7 +101,7 @@ class DraftsActivity : BaseActivity(), DraftActionListener { content = draft.content, contentWarning = draft.contentWarning, inReplyToId = draft.inReplyToId, - replyingStatusContent = status.content.toString(), + replyingStatusContent = status.content.parseAsMastodonHtml().toString(), replyingStatusAuthor = status.account.localUsername, draftAttachments = draft.attachments, poll = draft.poll, diff --git a/app/src/main/java/com/keylesspalace/tusky/components/instanceinfo/InstanceInfo.kt b/app/src/main/java/com/keylesspalace/tusky/components/instanceinfo/InstanceInfo.kt new file mode 100644 index 00000000..05e10b6b --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/instanceinfo/InstanceInfo.kt @@ -0,0 +1,25 @@ +/* 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.instanceinfo + +data class InstanceInfo( + val maxChars: Int, + val pollMaxOptions: Int, + val pollMaxLength: Int, + val pollMinDuration: Int, + val pollMaxDuration: Int, + val charactersReservedPerUrl: Int +) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/instanceinfo/InstanceInfoRepository.kt b/app/src/main/java/com/keylesspalace/tusky/components/instanceinfo/InstanceInfoRepository.kt new file mode 100644 index 00000000..8ed26d7b --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/instanceinfo/InstanceInfoRepository.kt @@ -0,0 +1,102 @@ +/* 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.instanceinfo + +import android.util.Log +import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.db.AppDatabase +import com.keylesspalace.tusky.db.EmojisEntity +import com.keylesspalace.tusky.db.InstanceInfoEntity +import com.keylesspalace.tusky.entity.Emoji +import com.keylesspalace.tusky.network.MastodonApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import javax.inject.Inject + +class InstanceInfoRepository @Inject constructor( + private val api: MastodonApi, + db: AppDatabase, + accountManager: AccountManager +) { + + private val dao = db.instanceDao() + private val instanceName = accountManager.activeAccount!!.domain + + /** + * Returns the custom emojis of the instance. + * Will always try to fetch them from the api, falls back to cached Emojis in case it is not available. + * Never throws, returns empty list in case of error. + */ + suspend fun getEmojis(): List = withContext(Dispatchers.IO) { + api.getCustomEmojis() + .onSuccess { emojiList -> dao.insertOrReplace(EmojisEntity(instanceName, emojiList)) } + .getOrElse { throwable -> + Log.w(TAG, "failed to load custom emojis, falling back to cache", throwable) + dao.getEmojiInfo(instanceName)?.emojiList.orEmpty() + } + } + + /** + * Returns information about the instance. + * Will always try to fetch the most up-to-date data from the api, falls back to cache in case it is not available. + * Never throws, returns defaults of vanilla Mastodon in case of error. + */ + suspend fun getInstanceInfo(): InstanceInfo = withContext(Dispatchers.IO) { + api.getInstance() + .fold( + { instance -> + val instanceEntity = InstanceInfoEntity( + instance = instanceName, + maximumTootCharacters = instance.configuration?.statuses?.maxCharacters ?: instance.maxTootChars, + maxPollOptions = instance.configuration?.polls?.maxOptions ?: instance.pollConfiguration?.maxOptions, + maxPollOptionLength = instance.configuration?.polls?.maxCharactersPerOption ?: instance.pollConfiguration?.maxOptionChars, + minPollDuration = instance.configuration?.polls?.minExpiration ?: instance.pollConfiguration?.minExpiration, + maxPollDuration = instance.configuration?.polls?.maxExpiration ?: instance.pollConfiguration?.maxExpiration, + charactersReservedPerUrl = instance.configuration?.statuses?.charactersReservedPerUrl, + version = instance.version + ) + dao.insertOrReplace(instanceEntity) + instanceEntity + }, + { throwable -> + Log.w(TAG, "failed to instance, falling back to cache and default values", throwable) + dao.getInstanceInfo(instanceName) + } + ).let { instanceInfo: InstanceInfoEntity? -> + InstanceInfo( + maxChars = instanceInfo?.maximumTootCharacters ?: DEFAULT_CHARACTER_LIMIT, + pollMaxOptions = instanceInfo?.maxPollOptions ?: DEFAULT_MAX_OPTION_COUNT, + pollMaxLength = instanceInfo?.maxPollOptionLength ?: DEFAULT_MAX_OPTION_LENGTH, + pollMinDuration = instanceInfo?.minPollDuration ?: DEFAULT_MIN_POLL_DURATION, + pollMaxDuration = instanceInfo?.maxPollDuration ?: DEFAULT_MAX_POLL_DURATION, + charactersReservedPerUrl = instanceInfo?.charactersReservedPerUrl ?: DEFAULT_CHARACTERS_RESERVED_PER_URL + ) + } + } + + companion object { + private const val TAG = "InstanceInfoRepo" + + const val DEFAULT_CHARACTER_LIMIT = 500 + private const val DEFAULT_MAX_OPTION_COUNT = 4 + private const val DEFAULT_MAX_OPTION_LENGTH = 50 + private const val DEFAULT_MIN_POLL_DURATION = 300 + private const val DEFAULT_MAX_POLL_DURATION = 604800 + + // Mastodon only counts URLs as this long in terms of status character limits + const val DEFAULT_CHARACTERS_RESERVED_PER_URL = 23 + } +} 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 d8a52a27..e52da81c 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 @@ -34,7 +34,6 @@ import com.keylesspalace.tusky.MainActivity import com.keylesspalace.tusky.R import com.keylesspalace.tusky.databinding.ActivityLoginBinding import com.keylesspalace.tusky.di.Injectable -import com.keylesspalace.tusky.entity.AppCredentials import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.util.getNonNullString import com.keylesspalace.tusky.util.rickRoll @@ -70,7 +69,9 @@ class LoginActivity : BaseActivity(), Injectable { // Authorization failed. Put the error response where the user can read it and they // can try again. setLoading(false) - binding.domainTextInputLayout.error = getString(R.string.error_authorization_denied) + // Use error returned by the server or fall back to the generic message + binding.domainTextInputLayout.error = + result.errorMessage.ifBlank { getString(R.string.error_authorization_denied) } Log.e( TAG, "%s %s".format( @@ -180,32 +181,33 @@ class LoginActivity : BaseActivity(), Injectable { setLoading(true) lifecycleScope.launch { - val credentials: AppCredentials = try { - mastodonApi.authenticateApp( - domain, getString(R.string.app_name), oauthRedirectUri, - OAUTH_SCOPES, getString(R.string.tusky_website) - ) - } catch (e: Exception) { - binding.loginButton.isEnabled = true - binding.domainTextInputLayout.error = - getString(R.string.error_failed_app_registration) - setLoading(false) - Log.e(TAG, Log.getStackTraceString(e)) - return@launch - } + mastodonApi.authenticateApp( + domain, getString(R.string.app_name), oauthRedirectUri, + OAUTH_SCOPES, getString(R.string.tusky_website) + ).fold( + { credentials -> + // Before we open browser page we save the data. + // Even if we don't open other apps user may go to password manager or somewhere else + // and we will need to pick up the process where we left off. + // Alternatively we could pass it all as part of the intent and receive it back + // but it is a bit of a workaround. + preferences.edit() + .putString(DOMAIN, domain) + .putString(CLIENT_ID, credentials.clientId) + .putString(CLIENT_SECRET, credentials.clientSecret) + .apply() - // Before we open browser page we save the data. - // Even if we don't open other apps user may go to password manager or somewhere else - // and we will need to pick up the process where we left off. - // Alternatively we could pass it all as part of the intent and receive it back - // but it is a bit of a workaround. - preferences.edit() - .putString(DOMAIN, domain) - .putString(CLIENT_ID, credentials.clientId) - .putString(CLIENT_SECRET, credentials.clientSecret) - .apply() - - redirectUserToAuthorizeAndLogin(domain, credentials.clientId) + redirectUserToAuthorizeAndLogin(domain, credentials.clientId) + }, + { e -> + binding.loginButton.isEnabled = true + binding.domainTextInputLayout.error = + getString(R.string.error_failed_app_registration) + setLoading(false) + Log.e(TAG, Log.getStackTraceString(e)) + return@launch + } + ) } } @@ -238,29 +240,28 @@ class LoginActivity : BaseActivity(), Injectable { setLoading(true) - val accessToken = try { - mastodonApi.fetchOAuthToken( - domain, clientId, clientSecret, oauthRedirectUri, code, - "authorization_code" - ) - } catch (e: Exception) { - setLoading(false) - binding.domainTextInputLayout.error = - getString(R.string.error_retrieving_oauth_token) - Log.e( - TAG, - "%s %s".format(getString(R.string.error_retrieving_oauth_token), e.message), - ) - return - } + mastodonApi.fetchOAuthToken( + domain, clientId, clientSecret, oauthRedirectUri, code, "authorization_code" + ).fold( + { accessToken -> + accountManager.addAccount(accessToken.accessToken, domain) - accountManager.addAccount(accessToken.accessToken, domain) - - val intent = Intent(this, MainActivity::class.java) - intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK - startActivity(intent) - finish() - overridePendingTransition(R.anim.explode, R.anim.explode) + val intent = Intent(this, MainActivity::class.java) + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + startActivity(intent) + finish() + overridePendingTransition(R.anim.explode, R.anim.explode) + }, + { e -> + setLoading(false) + binding.domainTextInputLayout.error = + getString(R.string.error_retrieving_oauth_token) + Log.e( + TAG, + "%s %s".format(getString(R.string.error_retrieving_oauth_token), e.message), + ) + } + ) } private fun setLoading(loadingState: Boolean) { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/login/LoginWebViewActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/login/LoginWebViewActivity.kt index 01f6c3b0..2ed38720 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/login/LoginWebViewActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/login/LoginWebViewActivity.kt @@ -16,10 +16,13 @@ import android.webkit.WebStorage import android.webkit.WebView import android.webkit.WebViewClient import androidx.activity.result.contract.ActivityResultContract +import androidx.core.net.toUri import com.keylesspalace.tusky.BaseActivity import com.keylesspalace.tusky.BuildConfig -import com.keylesspalace.tusky.databinding.LoginWebviewBinding +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.databinding.ActivityLoginWebviewBinding import com.keylesspalace.tusky.di.Injectable +import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.viewBinding import kotlinx.parcelize.Parcelize @@ -75,7 +78,7 @@ sealed class LoginResult : Parcelable { /** Activity to do Oauth process using WebView. */ class LoginWebViewActivity : BaseActivity(), Injectable { - private val binding by viewBinding(LoginWebviewBinding::inflate) + private val binding by viewBinding(ActivityLoginWebviewBinding::inflate) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -86,7 +89,9 @@ class LoginWebViewActivity : BaseActivity(), Injectable { setSupportActionBar(binding.loginToolbar) supportActionBar?.setDisplayHomeAsUpEnabled(true) - supportActionBar?.setDisplayShowTitleEnabled(false) + supportActionBar?.setDisplayShowTitleEnabled(true) + + setTitle(R.string.title_login) val webView = binding.loginWebView webView.settings.allowContentAccess = false @@ -102,20 +107,34 @@ class LoginWebViewActivity : BaseActivity(), Injectable { val oauthUrl = data.oauthRedirectUrl webView.webViewClient = object : WebViewClient() { + override fun onPageFinished(view: WebView?, url: String?) { + binding.loginProgress.hide() + } + override fun onReceivedError( - view: WebView?, - request: WebResourceRequest?, + view: WebView, + request: WebResourceRequest, error: WebResourceError ) { Log.d("LoginWeb", "Failed to load ${data.url}: $error") - finish() + sendResult(LoginResult.Err(getString(R.string.error_could_not_load_login_page))) } override fun shouldOverrideUrlLoading( view: WebView, request: WebResourceRequest ): Boolean { - val url = request.url + return shouldOverrideUrlLoading(request.url) + } + + /* overriding this deprecated method is necessary for it to work on api levels < 24 */ + @Suppress("OVERRIDE_DEPRECATION") + override fun shouldOverrideUrlLoading(view: WebView?, urlString: String?): Boolean { + val url = urlString?.toUri() ?: return false + return shouldOverrideUrlLoading(url) + } + + fun shouldOverrideUrlLoading(url: Uri): Boolean { return if (url.scheme == oauthUrl.scheme && url.host == oauthUrl.host) { val error = url.getQueryParameter("error") if (error != null) { @@ -130,6 +149,7 @@ class LoginWebViewActivity : BaseActivity(), Injectable { } } } + webView.setBackgroundColor(Color.TRANSPARENT) if (savedInstanceState == null) { @@ -153,10 +173,14 @@ class LoginWebViewActivity : BaseActivity(), Injectable { super.onDestroy() } + override fun finish() { + super.finishWithoutSlideOutAnimation() + } + override fun requiresLogin() = false private fun sendResult(result: LoginResult) { setResult(Activity.RESULT_OK, OauthLogin.makeResultIntent(result)) - finish() + finishWithoutSlideOutAnimation() } } 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 6b9afce1..79586897 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 @@ -16,6 +16,9 @@ package com.keylesspalace.tusky.components.notifications; +import static com.keylesspalace.tusky.util.StatusParsingHelper.parseAsMastodonHtml; +import static com.keylesspalace.tusky.viewdata.PollViewDataKt.buildDescription; + import android.app.NotificationChannel; import android.app.NotificationChannelGroup; import android.app.NotificationManager; @@ -73,8 +76,6 @@ import java.util.concurrent.TimeUnit; import io.reactivex.rxjava3.core.Single; import io.reactivex.rxjava3.schedulers.Schedulers; -import static com.keylesspalace.tusky.viewdata.PollViewDataKt.buildDescription; - public class NotificationHelper { private static int notificationId = 0; @@ -116,6 +117,8 @@ public class NotificationHelper { public static final String CHANNEL_FAVOURITE = "CHANNEL_FAVOURITE"; public static final String CHANNEL_POLL = "CHANNEL_POLL"; public static final String CHANNEL_SUBSCRIPTIONS = "CHANNEL_SUBSCRIPTIONS"; + public static final String CHANNEL_SIGN_UP = "CHANNEL_SIGN_UP"; + public static final String CHANNEL_UPDATES = "CHANNEL_UPDATES"; /** * WorkManager Tag @@ -340,7 +343,7 @@ public class NotificationHelper { Status status = body.getStatus(); String citedLocalAuthor = status.getAccount().getLocalUsername(); - String citedText = status.getContent().toString(); + String citedText = parseAsMastodonHtml(status.getContent()).toString(); String inReplyToId = status.getId(); Status actionableStatus = status.getActionableStatus(); Status.Visibility replyVisibility = actionableStatus.getVisibility(); @@ -392,6 +395,8 @@ public class NotificationHelper { CHANNEL_FAVOURITE + account.getIdentifier(), CHANNEL_POLL + account.getIdentifier(), CHANNEL_SUBSCRIPTIONS + account.getIdentifier(), + CHANNEL_SIGN_UP + account.getIdentifier(), + CHANNEL_UPDATES + account.getIdentifier(), }; int[] channelNames = { R.string.notification_mention_name, @@ -401,6 +406,8 @@ public class NotificationHelper { R.string.notification_favourite_name, R.string.notification_poll_name, R.string.notification_subscription_name, + R.string.notification_sign_up_name, + R.string.notification_update_name, }; int[] channelDescriptions = { R.string.notification_mention_descriptions, @@ -410,6 +417,8 @@ public class NotificationHelper { R.string.notification_favourite_description, R.string.notification_poll_description, R.string.notification_subscription_description, + R.string.notification_sign_up_description, + R.string.notification_update_description, }; List channels = new ArrayList<>(6); @@ -560,6 +569,10 @@ public class NotificationHelper { return account.getNotificationsFavorited(); case POLL: return account.getNotificationsPolls(); + case SIGN_UP: + return account.getNotificationsSignUps(); + case UPDATE: + return account.getNotificationsUpdates(); default: return false; } @@ -582,6 +595,8 @@ public class NotificationHelper { return CHANNEL_FAVOURITE + account.getIdentifier(); case POLL: return CHANNEL_POLL + account.getIdentifier(); + case SIGN_UP: + return CHANNEL_SIGN_UP + account.getIdentifier(); default: return null; } @@ -663,6 +678,10 @@ public class NotificationHelper { } else { return context.getString(R.string.poll_ended_voted); } + case SIGN_UP: + return String.format(context.getString(R.string.notification_sign_up_format), accountName); + case UPDATE: + return String.format(context.getString(R.string.notification_update_format), accountName); } return null; } @@ -671,6 +690,7 @@ public class NotificationHelper { switch (notification.getType()) { case FOLLOW: case FOLLOW_REQUEST: + case SIGN_UP: return "@" + notification.getAccount().getUsername(); case MENTION: case FAVOURITE: @@ -679,13 +699,13 @@ public class NotificationHelper { if (!TextUtils.isEmpty(notification.getStatus().getSpoilerText())) { return notification.getStatus().getSpoilerText(); } else { - return notification.getStatus().getContent().toString(); + return parseAsMastodonHtml(notification.getStatus().getContent()).toString(); } case POLL: if (!TextUtils.isEmpty(notification.getStatus().getSpoilerText())) { return notification.getStatus().getSpoilerText(); } else { - StringBuilder builder = new StringBuilder(notification.getStatus().getContent()); + StringBuilder builder = new StringBuilder(parseAsMastodonHtml(notification.getStatus().getContent())); builder.append('\n'); Poll poll = notification.getStatus().getPoll(); List options = poll.getOptions(); diff --git a/app/src/main/java/com/keylesspalace/tusky/components/preference/EmojiPreference.kt b/app/src/main/java/com/keylesspalace/tusky/components/preference/EmojiPreference.kt deleted file mode 100644 index 47cb37ae..00000000 --- a/app/src/main/java/com/keylesspalace/tusky/components/preference/EmojiPreference.kt +++ /dev/null @@ -1,240 +0,0 @@ -package com.keylesspalace.tusky.components.preference - -import android.app.AlarmManager -import android.app.PendingIntent -import android.content.Context -import android.content.Intent -import android.os.Build -import android.util.Log -import android.view.LayoutInflater -import android.view.View -import android.widget.RadioButton -import android.widget.Toast -import androidx.appcompat.app.AlertDialog -import androidx.preference.Preference -import androidx.preference.PreferenceManager -import com.keylesspalace.tusky.R -import com.keylesspalace.tusky.SplashActivity -import com.keylesspalace.tusky.components.notifications.NotificationHelper -import com.keylesspalace.tusky.databinding.DialogEmojicompatBinding -import com.keylesspalace.tusky.databinding.ItemEmojiPrefBinding -import com.keylesspalace.tusky.util.EmojiCompatFont -import com.keylesspalace.tusky.util.EmojiCompatFont.Companion.BLOBMOJI -import com.keylesspalace.tusky.util.EmojiCompatFont.Companion.FONTS -import com.keylesspalace.tusky.util.EmojiCompatFont.Companion.NOTOEMOJI -import com.keylesspalace.tusky.util.EmojiCompatFont.Companion.SYSTEM_DEFAULT -import com.keylesspalace.tusky.util.EmojiCompatFont.Companion.TWEMOJI -import com.keylesspalace.tusky.util.hide -import com.keylesspalace.tusky.util.show -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers -import io.reactivex.rxjava3.disposables.Disposable -import okhttp3.OkHttpClient -import kotlin.system.exitProcess - -/** - * This Preference lets the user select their preferred emoji font - */ -class EmojiPreference( - context: Context, - private val okHttpClient: OkHttpClient -) : Preference(context) { - - private lateinit var selected: EmojiCompatFont - private lateinit var original: EmojiCompatFont - private val radioButtons = mutableListOf() - private var updated = false - private var currentNeedsUpdate = false - - private val downloadDisposables = MutableList(FONTS.size) { null } - - override fun onAttachedToHierarchy(preferenceManager: PreferenceManager) { - super.onAttachedToHierarchy(preferenceManager) - - // Find out which font is currently active - selected = EmojiCompatFont.byId( - PreferenceManager.getDefaultSharedPreferences(context).getInt(key, 0) - ) - // We'll use this later to determine if anything has changed - original = selected - summary = selected.getDisplay(context) - } - - override fun onClick() { - val binding = DialogEmojicompatBinding.inflate(LayoutInflater.from(context)) - - setupItem(BLOBMOJI, binding.itemBlobmoji) - setupItem(TWEMOJI, binding.itemTwemoji) - setupItem(NOTOEMOJI, binding.itemNotoemoji) - setupItem(SYSTEM_DEFAULT, binding.itemNomoji) - - AlertDialog.Builder(context) - .setView(binding.root) - .setPositiveButton(android.R.string.ok) { _, _ -> onDialogOk() } - .setNegativeButton(android.R.string.cancel, null) - .show() - } - - private fun setupItem(font: EmojiCompatFont, binding: ItemEmojiPrefBinding) { - // Initialize all the views - binding.emojiName.text = font.getDisplay(context) - binding.emojiCaption.setText(font.caption) - binding.emojiThumbnail.setImageResource(font.img) - - // There needs to be a list of all the radio buttons in order to uncheck them when one is selected - radioButtons.add(binding.emojiRadioButton) - updateItem(font, binding) - - // Set actions - binding.emojiDownload.setOnClickListener { startDownload(font, binding) } - binding.emojiDownloadCancel.setOnClickListener { cancelDownload(font, binding) } - binding.emojiRadioButton.setOnClickListener { radioButton: View -> select(font, radioButton as RadioButton) } - binding.root.setOnClickListener { - select(font, binding.emojiRadioButton) - } - } - - private fun startDownload(font: EmojiCompatFont, binding: ItemEmojiPrefBinding) { - // Switch to downloading style - binding.emojiDownload.hide() - binding.emojiCaption.visibility = View.INVISIBLE - binding.emojiProgress.show() - binding.emojiProgress.progress = 0 - binding.emojiDownloadCancel.show() - font.downloadFontFile(context, okHttpClient) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe( - { progress -> - // The progress is returned as a float between 0 and 1, or -1 if it could not determined - if (progress >= 0) { - binding.emojiProgress.isIndeterminate = false - val max = binding.emojiProgress.max.toFloat() - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - binding.emojiProgress.setProgress((max * progress).toInt(), true) - } else { - binding.emojiProgress.progress = (max * progress).toInt() - } - } else { - binding.emojiProgress.isIndeterminate = true - } - }, - { - Toast.makeText(context, R.string.download_failed, Toast.LENGTH_SHORT).show() - updateItem(font, binding) - }, - { - finishDownload(font, binding) - } - ).also { downloadDisposables[font.id] = it } - } - - private fun cancelDownload(font: EmojiCompatFont, binding: ItemEmojiPrefBinding) { - font.deleteDownloadedFile(context) - downloadDisposables[font.id]?.dispose() - downloadDisposables[font.id] = null - updateItem(font, binding) - } - - private fun finishDownload(font: EmojiCompatFont, binding: ItemEmojiPrefBinding) { - select(font, binding.emojiRadioButton) - updateItem(font, binding) - // Set the flag to restart the app (because an update has been downloaded) - if (selected === original && currentNeedsUpdate) { - updated = true - currentNeedsUpdate = false - } - } - - /** - * Select a font both visually and logically - * - * @param font The font to be selected - * @param radio The radio button associated with it's visual item - */ - private fun select(font: EmojiCompatFont, radio: RadioButton) { - selected = font - radioButtons.forEach { radioButton -> - radioButton.isChecked = radioButton == radio - } - } - - /** - * Called when a "consistent" state is reached, i.e. it's not downloading the font - * - * @param font The font to be displayed - * @param binding The ItemEmojiPrefBinding to show the item in - */ - private fun updateItem(font: EmojiCompatFont, binding: ItemEmojiPrefBinding) { - // There's no download going on - binding.emojiProgress.hide() - binding.emojiDownloadCancel.hide() - binding.emojiCaption.show() - if (font.isDownloaded(context)) { - // Make it selectable - binding.emojiDownload.hide() - binding.emojiRadioButton.show() - binding.root.isClickable = true - } else { - // Make it downloadable - binding.emojiDownload.show() - binding.emojiRadioButton.hide() - binding.root.isClickable = false - } - - // Select it if necessary - if (font === selected) { - binding.emojiRadioButton.isChecked = true - // Update available - if (!font.isDownloaded(context)) { - currentNeedsUpdate = true - } - } else { - binding.emojiRadioButton.isChecked = false - } - } - - private fun saveSelectedFont() { - val index = selected.id - Log.i(TAG, "saveSelectedFont: Font ID: $index") - PreferenceManager - .getDefaultSharedPreferences(context) - .edit() - .putInt(key, index) - .apply() - summary = selected.getDisplay(context) - } - - /** - * User clicked ok -> save the selected font and offer to restart the app if something changed - */ - private fun onDialogOk() { - saveSelectedFont() - if (selected !== original || updated) { - AlertDialog.Builder(context) - .setTitle(R.string.restart_required) - .setMessage(R.string.restart_emoji) - .setNegativeButton(R.string.later, null) - .setPositiveButton(R.string.restart) { _, _ -> - // Restart the app - // From https://stackoverflow.com/a/17166729/5070653 - val launchIntent = Intent(context, SplashActivity::class.java) - val mPendingIntent = PendingIntent.getActivity( - context, - 0x1f973, // This is the codepoint of the party face emoji :D - launchIntent, - NotificationHelper.pendingIntentFlags(false) - ) - val mgr = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager - mgr.set( - AlarmManager.RTC, - System.currentTimeMillis() + 100, - mPendingIntent - ) - exitProcess(0) - }.show() - } - } - - companion object { - private const val TAG = "EmojiPreference" - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/preference/NotificationPreferencesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/preference/NotificationPreferencesFragment.kt index 4d8ba84f..6fdc1e8a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/preference/NotificationPreferencesFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/preference/NotificationPreferencesFragment.kt @@ -122,6 +122,28 @@ class NotificationPreferencesFragment : PreferenceFragmentCompat(), Injectable { true } } + + switchPreference { + setTitle(R.string.pref_title_notification_filter_sign_ups) + key = PrefKeys.NOTIFICATION_FILTER_SIGN_UPS + isIconSpaceReserved = false + isChecked = activeAccount.notificationsSignUps + setOnPreferenceChangeListener { _, newValue -> + updateAccount { it.notificationsSignUps = newValue as Boolean } + true + } + } + + switchPreference { + setTitle(R.string.pref_title_notification_filter_updates) + key = PrefKeys.NOTIFICATION_FILTER_UPDATES + isIconSpaceReserved = false + isChecked = activeAccount.notificationsUpdates + setOnPreferenceChangeListener { _, newValue -> + updateAccount { it.notificationsUpdates = newValue as Boolean } + true + } + } } preferenceCategory(R.string.pref_title_notification_alerts) { category -> diff --git a/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesFragment.kt index 61f86627..61d828c1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesFragment.kt @@ -38,14 +38,11 @@ import com.mikepenz.iconics.IconicsDrawable import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial import com.mikepenz.iconics.utils.colorInt import com.mikepenz.iconics.utils.sizePx -import okhttp3.OkHttpClient +import de.c1710.filemojicompat_ui.views.picker.preference.EmojiPickerPreference import javax.inject.Inject class PreferencesFragment : PreferenceFragmentCompat(), Injectable { - @Inject - lateinit var okhttpclient: OkHttpClient - @Inject lateinit var accountManager: AccountManager @@ -65,11 +62,7 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable { icon = makeIcon(GoogleMaterial.Icon.gmd_palette) } - emojiPreference(okhttpclient) { - setDefaultValue("system_default") - setIcon(R.drawable.ic_emoji_24dp) - key = PrefKeys.EMOJI - setSummary(R.string.system_default) + emojiPreference(requireActivity()) { setTitle(R.string.emoji_style) icon = makeIcon(GoogleMaterial.Icon.gmd_sentiment_satisfied) } @@ -300,6 +293,12 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable { } } + override fun onDisplayPreferenceDialog(preference: Preference) { + if (!EmojiPickerPreference.onDisplayPreferenceDialog(this, preference)) { + super.onDisplayPreferenceDialog(preference) + } + } + companion object { fun newInstance(): PreferencesFragment { return PreferencesFragment() diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/ReportViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/ReportViewModel.kt index f8991282..9f99da53 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/report/ReportViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/report/ReportViewModel.kt @@ -21,6 +21,7 @@ import androidx.lifecycle.viewModelScope import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.cachedIn +import androidx.paging.map import com.keylesspalace.tusky.appstore.BlockEvent import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.MuteEvent @@ -34,11 +35,13 @@ import com.keylesspalace.tusky.util.Loading import com.keylesspalace.tusky.util.Resource import com.keylesspalace.tusky.util.RxAwareViewModel import com.keylesspalace.tusky.util.Success +import com.keylesspalace.tusky.util.toViewData import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.schedulers.Schedulers import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import javax.inject.Inject @@ -74,6 +77,11 @@ class ReportViewModel @Inject constructor( pagingSourceFactory = { StatusesPagingSource(accountId, mastodonApi) } ).flow } + .map { pagingData -> + /* TODO: refactor reports to use the isShowingContent / isExpanded / isCollapsed attributes from StatusViewData.Concrete + instead of StatusViewState */ + pagingData.map { status -> status.toViewData(false, false, false) } + } .cachedIn(viewModelScope) private val selectedIds = HashSet() @@ -155,7 +163,7 @@ class ReportViewModel @Inject constructor( .observeOn(AndroidSchedulers.mainThread()) .subscribe( { relationship -> - val muting = relationship?.muting == true + val muting = relationship.muting muteStateMutable.value = Success(muting) if (muting) { eventHub.dispatch(MuteEvent(accountId)) @@ -180,7 +188,7 @@ class ReportViewModel @Inject constructor( .observeOn(AndroidSchedulers.mainThread()) .subscribe( { relationship -> - val blocking = relationship?.blocking == true + val blocking = relationship.blocking blockStateMutable.value = Success(blocking) if (blocking) { eventHub.dispatch(BlockEvent(accountId)) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusViewHolder.kt index 1b3b0de6..82dbf163 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusViewHolder.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusViewHolder.kt @@ -26,6 +26,7 @@ import com.keylesspalace.tusky.entity.Emoji import com.keylesspalace.tusky.entity.HashTag import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.interfaces.LinkListener +import com.keylesspalace.tusky.util.AbsoluteTimeFormatter import com.keylesspalace.tusky.util.StatusDisplayOptions import com.keylesspalace.tusky.util.StatusViewHelper import com.keylesspalace.tusky.util.StatusViewHelper.Companion.COLLAPSE_INPUT_FILTER @@ -37,6 +38,7 @@ import com.keylesspalace.tusky.util.setClickableMentions import com.keylesspalace.tusky.util.setClickableText import com.keylesspalace.tusky.util.shouldTrimStatus import com.keylesspalace.tusky.util.show +import com.keylesspalace.tusky.viewdata.StatusViewData import com.keylesspalace.tusky.viewdata.toViewData import java.util.Date @@ -45,20 +47,22 @@ class StatusViewHolder( private val statusDisplayOptions: StatusDisplayOptions, private val viewState: StatusViewState, private val adapterHandler: AdapterHandler, - private val getStatusForPosition: (Int) -> Status? + private val getStatusForPosition: (Int) -> StatusViewData.Concrete? ) : RecyclerView.ViewHolder(binding.root) { + private val mediaViewHeight = itemView.context.resources.getDimensionPixelSize(R.dimen.status_media_preview_height) private val statusViewHelper = StatusViewHelper(itemView) + private val absoluteTimeFormatter = AbsoluteTimeFormatter() private val previewListener = object : StatusViewHelper.MediaPreviewListener { override fun onViewMedia(v: View?, idx: Int) { - status()?.let { status -> - adapterHandler.showMedia(v, status, idx) + viewdata()?.let { viewdata -> + adapterHandler.showMedia(v, viewdata.status, idx) } } override fun onContentHiddenChange(isShowing: Boolean) { - status()?.id?.let { id -> + viewdata()?.id?.let { id -> viewState.setMediaShow(id, isShowing) } } @@ -66,57 +70,57 @@ class StatusViewHolder( init { binding.statusSelection.setOnCheckedChangeListener { _, isChecked -> - status()?.let { status -> - adapterHandler.setStatusChecked(status, isChecked) + viewdata()?.let { viewdata -> + adapterHandler.setStatusChecked(viewdata.status, isChecked) } } binding.statusMediaPreviewContainer.clipToOutline = true } - fun bind(status: Status) { - binding.statusSelection.isChecked = adapterHandler.isStatusChecked(status.id) + fun bind(viewData: StatusViewData.Concrete) { + binding.statusSelection.isChecked = adapterHandler.isStatusChecked(viewData.id) updateTextView() - val sensitive = status.sensitive + val sensitive = viewData.status.sensitive statusViewHelper.setMediasPreview( - statusDisplayOptions, status.attachments, - sensitive, previewListener, viewState.isMediaShow(status.id, status.sensitive), + statusDisplayOptions, viewData.status.attachments, + sensitive, previewListener, viewState.isMediaShow(viewData.id, viewData.status.sensitive), mediaViewHeight ) - statusViewHelper.setupPollReadonly(status.poll.toViewData(), status.emojis, statusDisplayOptions) - setCreatedAt(status.createdAt) + statusViewHelper.setupPollReadonly(viewData.status.poll.toViewData(), viewData.status.emojis, statusDisplayOptions) + setCreatedAt(viewData.status.createdAt) } private fun updateTextView() { - status()?.let { status -> + viewdata()?.let { viewdata -> setupCollapsedState( - shouldTrimStatus(status.content), viewState.isCollapsed(status.id, true), - viewState.isContentShow(status.id, status.sensitive), status.spoilerText + shouldTrimStatus(viewdata.content), viewState.isCollapsed(viewdata.id, true), + viewState.isContentShow(viewdata.id, viewdata.status.sensitive), viewdata.spoilerText ) - if (status.spoilerText.isBlank()) { - setTextVisible(true, status.content, status.mentions, status.tags, status.emojis, adapterHandler) + if (viewdata.spoilerText.isBlank()) { + setTextVisible(true, viewdata.content, viewdata.status.mentions, viewdata.status.tags, viewdata.status.emojis, adapterHandler) binding.statusContentWarningButton.hide() binding.statusContentWarningDescription.hide() } else { - val emojiSpoiler = status.spoilerText.emojify(status.emojis, binding.statusContentWarningDescription, statusDisplayOptions.animateEmojis) + val emojiSpoiler = viewdata.spoilerText.emojify(viewdata.status.emojis, binding.statusContentWarningDescription, statusDisplayOptions.animateEmojis) binding.statusContentWarningDescription.text = emojiSpoiler binding.statusContentWarningDescription.show() binding.statusContentWarningButton.show() - setContentWarningButtonText(viewState.isContentShow(status.id, true)) + setContentWarningButtonText(viewState.isContentShow(viewdata.id, true)) binding.statusContentWarningButton.setOnClickListener { - status()?.let { status -> - val contentShown = viewState.isContentShow(status.id, true) + viewdata()?.let { viewdata -> + val contentShown = viewState.isContentShow(viewdata.id, true) binding.statusContentWarningDescription.invalidate() - viewState.setContentShow(status.id, !contentShown) - setTextVisible(!contentShown, status.content, status.mentions, status.tags, status.emojis, adapterHandler) + viewState.setContentShow(viewdata.id, !contentShown) + setTextVisible(!contentShown, viewdata.content, viewdata.status.mentions, viewdata.status.tags, viewdata.status.emojis, adapterHandler) setContentWarningButtonText(!contentShown) } } - setTextVisible(viewState.isContentShow(status.id, true), status.content, status.mentions, status.tags, status.emojis, adapterHandler) + setTextVisible(viewState.isContentShow(viewdata.id, true), viewdata.content, viewdata.status.mentions, viewdata.status.tags, viewdata.status.emojis, adapterHandler) } } } @@ -152,7 +156,7 @@ class StatusViewHolder( private fun setCreatedAt(createdAt: Date?) { if (statusDisplayOptions.useAbsoluteTime) { - binding.timestampInfo.text = statusViewHelper.getAbsoluteTime(createdAt) + binding.timestampInfo.text = absoluteTimeFormatter.format(createdAt) } else { binding.timestampInfo.text = if (createdAt != null) { val then = createdAt.time @@ -169,8 +173,8 @@ class StatusViewHolder( /* input filter for TextViews have to be set before text */ if (collapsible && (expanded || TextUtils.isEmpty(spoilerText))) { binding.buttonToggleContent.setOnClickListener { - status()?.let { status -> - viewState.setCollapsed(status.id, !collapsed) + viewdata()?.let { viewdata -> + viewState.setCollapsed(viewdata.id, !collapsed) updateTextView() } } @@ -189,5 +193,5 @@ class StatusViewHolder( } } - private fun status() = getStatusForPosition(bindingAdapterPosition) + private fun viewdata() = getStatusForPosition(bindingAdapterPosition) } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesAdapter.kt index 76ed2ebe..314513eb 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesAdapter.kt @@ -22,16 +22,16 @@ import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView import com.keylesspalace.tusky.components.report.model.StatusViewState import com.keylesspalace.tusky.databinding.ItemReportStatusBinding -import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.util.StatusDisplayOptions +import com.keylesspalace.tusky.viewdata.StatusViewData class StatusesAdapter( private val statusDisplayOptions: StatusDisplayOptions, private val statusViewState: StatusViewState, private val adapterHandler: AdapterHandler -) : PagingDataAdapter(STATUS_COMPARATOR) { +) : PagingDataAdapter(STATUS_COMPARATOR) { - private val statusForPosition: (Int) -> Status? = { position: Int -> + private val statusForPosition: (Int) -> StatusViewData.Concrete? = { position: Int -> if (position != RecyclerView.NO_POSITION) getItem(position) else null } @@ -50,11 +50,11 @@ class StatusesAdapter( } companion object { - val STATUS_COMPARATOR = object : DiffUtil.ItemCallback() { - override fun areContentsTheSame(oldItem: Status, newItem: Status): Boolean = + val STATUS_COMPARATOR = object : DiffUtil.ItemCallback() { + override fun areContentsTheSame(oldItem: StatusViewData.Concrete, newItem: StatusViewData.Concrete): Boolean = oldItem == newItem - override fun areItemsTheSame(oldItem: Status, newItem: Status): Boolean = + override fun areItemsTheSame(oldItem: StatusViewData.Concrete, newItem: StatusViewData.Concrete): Boolean = oldItem.id == newItem.id } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledStatusViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledStatusViewModel.kt index cd3e5ac0..766ed44a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledStatusViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledStatusViewModel.kt @@ -25,7 +25,6 @@ import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.entity.ScheduledStatus import com.keylesspalace.tusky.network.MastodonApi import kotlinx.coroutines.launch -import kotlinx.coroutines.rx3.await import javax.inject.Inject class ScheduledStatusViewModel @Inject constructor( @@ -43,12 +42,14 @@ class ScheduledStatusViewModel @Inject constructor( fun deleteScheduledStatus(status: ScheduledStatus) { viewModelScope.launch { - try { - mastodonApi.deleteScheduledStatus(status.id).await() - pagingSourceFactory.remove(status) - } catch (throwable: Throwable) { - Log.w("ScheduledTootViewModel", "Error deleting scheduled status", throwable) - } + mastodonApi.deleteScheduledStatus(status.id).fold( + { + pagingSourceFactory.remove(status) + }, + { throwable -> + Log.w("ScheduledTootViewModel", "Error deleting scheduled status", throwable) + } + ) } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/SearchActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/SearchActivity.kt index d833b432..8ca7248c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/SearchActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/SearchActivity.kt @@ -84,6 +84,10 @@ class SearchActivity : BottomSheetActivity(), HasAndroidInjector { return true } + override fun finish() { + super.finishWithoutSlideOutAnimation() + } + private fun getPageTitle(position: Int): CharSequence { return when (position) { 0 -> getString(R.string.title_posts) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchFragment.kt index 2d86f5f2..97e4f617 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchFragment.kt @@ -111,9 +111,13 @@ abstract class SearchFragment : } } - override fun onViewAccount(id: String) = startActivity(AccountActivity.getIntent(requireContext(), id)) + override fun onViewAccount(id: String) { + bottomSheetActivity?.startActivityWithSlideInAnimation(AccountActivity.getIntent(requireContext(), id)) + } - override fun onViewTag(tag: String) = startActivity(StatusListActivity.newHashtagIntent(requireContext(), tag)) + override fun onViewTag(tag: String) { + bottomSheetActivity?.startActivityWithSlideInAnimation(StatusListActivity.newHashtagIntent(requireContext(), tag)) + } override fun onViewUrl(url: String) { bottomSheetActivity?.viewUrl(url) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt index 23ff1b07..2e7849c1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt @@ -97,7 +97,7 @@ class SearchStatusesFragment : SearchFragment(), Status } override fun onReply(position: Int) { - searchAdapter.peek(position)?.status?.let { status -> + searchAdapter.peek(position)?.let { status -> reply(status) } } @@ -199,8 +199,8 @@ class SearchStatusesFragment : SearchFragment(), Status fun newInstance() = SearchStatusesFragment() } - private fun reply(status: Status) { - val actionableStatus = status.actionableStatus + private fun reply(status: StatusViewData.Concrete) { + val actionableStatus = status.actionable val mentionedUsernames = actionableStatus.mentions.map { it.username } .toMutableSet() .apply { @@ -216,10 +216,10 @@ class SearchStatusesFragment : SearchFragment(), Status contentWarning = actionableStatus.spoilerText, mentionedUsernames = mentionedUsernames, replyingStatusAuthor = actionableStatus.account.localUsername, - replyingStatusContent = actionableStatus.content.toString() + replyingStatusContent = status.content.toString() ) ) - startActivity(intent) + bottomSheetActivity?.startActivityWithSlideInAnimation(intent) } private fun more(status: Status, view: View, position: Int) { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt index f9175052..54183888 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt @@ -172,7 +172,7 @@ class TimelineFragment : setupRecyclerView() adapter.addLoadStateListener { loadState -> - if (loadState.refresh != LoadState.Loading) { + if (loadState.refresh != LoadState.Loading && loadState.source.refresh != LoadState.Loading) { binding.swipeRefreshLayout.isRefreshing = false } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineTypeMappers.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineTypeMappers.kt index 252b9880..12422a95 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineTypeMappers.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineTypeMappers.kt @@ -15,22 +15,18 @@ package com.keylesspalace.tusky.components.timeline -import android.text.SpannedString -import androidx.core.text.parseAsHtml -import androidx.core.text.toHtml import com.google.gson.Gson import com.google.gson.reflect.TypeToken import com.keylesspalace.tusky.db.TimelineAccountEntity import com.keylesspalace.tusky.db.TimelineStatusEntity import com.keylesspalace.tusky.db.TimelineStatusWithAccount import com.keylesspalace.tusky.entity.Attachment +import com.keylesspalace.tusky.entity.Card import com.keylesspalace.tusky.entity.Emoji import com.keylesspalace.tusky.entity.HashTag import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.TimelineAccount -import com.keylesspalace.tusky.util.shouldTrimStatus -import com.keylesspalace.tusky.util.trimTrailingWhitespace import com.keylesspalace.tusky.viewdata.StatusViewData import java.util.Date @@ -101,7 +97,8 @@ fun Placeholder.toEntity(timelineUserId: Long): TimelineStatusEntity { expanded = loading, contentCollapsed = false, contentShowing = false, - pinned = false + pinned = false, + card = null, ) } @@ -119,7 +116,7 @@ fun Status.toEntity( authorServerId = actionableStatus.account.id, inReplyToId = actionableStatus.inReplyToId, inReplyToAccountId = actionableStatus.inReplyToAccountId, - content = actionableStatus.content.toHtml(), + content = actionableStatus.content, createdAt = actionableStatus.createdAt.time, emojis = actionableStatus.emojis.let(gson::toJson), reblogsCount = actionableStatus.reblogsCount, @@ -141,7 +138,8 @@ fun Status.toEntity( expanded = expanded, contentShowing = contentShowing, contentCollapsed = contentCollapsed, - pinned = actionableStatus.pinned == true + pinned = actionableStatus.pinned == true, + card = actionableStatus.card?.let(gson::toJson), ) } @@ -156,6 +154,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson): StatusViewData { val application = gson.fromJson(status.application, Status.Application::class.java) val emojis: List = gson.fromJson(status.emojis, emojisListType) ?: emptyList() val poll: Poll? = gson.fromJson(status.poll, Poll::class.java) + val card: Card? = gson.fromJson(status.card, Card::class.java) val reblog = status.reblogServerId?.let { id -> Status( @@ -165,8 +164,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson): StatusViewData { inReplyToId = status.inReplyToId, inReplyToAccountId = status.inReplyToAccountId, reblog = null, - content = status.content?.parseAsHtml()?.trimTrailingWhitespace() - ?: SpannedString(""), + content = status.content.orEmpty(), createdAt = Date(status.createdAt), emojis = emojis, reblogsCount = status.reblogsCount, @@ -184,7 +182,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson): StatusViewData { pinned = false, muted = status.muted, poll = poll, - card = null + card = card, ) } val status = if (reblog != null) { @@ -195,7 +193,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson): StatusViewData { inReplyToId = null, inReplyToAccountId = null, reblog = reblog, - content = SpannedString(""), + content = "", createdAt = Date(status.createdAt), // lie but whatever? emojis = listOf(), reblogsCount = 0, @@ -223,8 +221,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson): StatusViewData { inReplyToId = status.inReplyToId, inReplyToAccountId = status.inReplyToAccountId, reblog = null, - content = status.content?.parseAsHtml()?.trimTrailingWhitespace() - ?: SpannedString(""), + content = status.content.orEmpty(), createdAt = Date(status.createdAt), emojis = emojis, reblogsCount = status.reblogsCount, @@ -242,14 +239,13 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson): StatusViewData { pinned = status.pinned, muted = status.muted, poll = poll, - card = null + card = card, ) } return StatusViewData.Concrete( status = status, isExpanded = this.status.expanded, isShowingContent = this.status.contentShowing, - isCollapsible = shouldTrimStatus(status.content), isCollapsed = this.status.contentCollapsed ) } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt index 304b4e5a..7158a7b3 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt @@ -42,7 +42,10 @@ import com.keylesspalace.tusky.network.FilterModel import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.TimelineCases import com.keylesspalace.tusky.viewdata.StatusViewData +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.asExecutor import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.coroutines.rx3.await @@ -79,15 +82,13 @@ class CachedTimelineViewModel @Inject constructor( } ).flow .map { pagingData -> - pagingData.map { timelineStatus -> + pagingData.map(Dispatchers.Default.asExecutor()) { timelineStatus -> timelineStatus.toViewData(gson) - } - } - .map { pagingData -> - pagingData.filter { statusViewData -> + }.filter(Dispatchers.Default.asExecutor()) { statusViewData -> !shouldFilterStatus(statusViewData) } } + .flowOn(Dispatchers.Default) .cachedIn(viewModelScope) init { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt index f70fdcc8..ca7988bb 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt @@ -40,6 +40,9 @@ import com.keylesspalace.tusky.util.isLessThan import com.keylesspalace.tusky.util.isLessThanOrEqual import com.keylesspalace.tusky.util.toViewData import com.keylesspalace.tusky.viewdata.StatusViewData +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.asExecutor +import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.coroutines.rx3.await @@ -79,10 +82,11 @@ class NetworkTimelineViewModel @Inject constructor( remoteMediator = NetworkTimelineRemoteMediator(accountManager, this) ).flow .map { pagingData -> - pagingData.filter { statusViewData -> + pagingData.filter(Dispatchers.Default.asExecutor()) { statusViewData -> !shouldFilterStatus(statusViewData) } } + .flowOn(Dispatchers.Default) .cachedIn(viewModelScope) override fun updatePoll(newPoll: Poll, status: StatusViewData.Concrete) { 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 0c25cbbc..400eb073 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/AccountEntity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/AccountEntity.kt @@ -50,6 +50,8 @@ data class AccountEntity( var notificationsFavorited: Boolean = true, var notificationsPolls: Boolean = true, var notificationsSubscriptions: Boolean = true, + var notificationsSignUps: Boolean = true, + var notificationsUpdates: Boolean = true, var notificationSound: Boolean = true, var notificationVibration: Boolean = true, var notificationLight: Boolean = true, 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 159a6f52..d5f023e5 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 = 31) + }, version = 35) public abstract class AppDatabase extends RoomDatabase { public abstract AccountDao accountDao(); @@ -483,4 +483,62 @@ public abstract class AppDatabase extends RoomDatabase { database.execSQL("DELETE FROM `TimelineStatusEntity`"); } }; + + public static final Migration MIGRATION_31_32 = new Migration(31, 32) { + @Override + public void migrate(@NonNull SupportSQLiteDatabase database) { + database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `notificationsSignUps` INTEGER NOT NULL DEFAULT 1"); + } + }; + + public static final Migration MIGRATION_32_33 = new Migration(32, 33) { + @Override + public void migrate(@NonNull SupportSQLiteDatabase database) { + + // ConversationEntity lost the s_collapsible column + // since SQLite does not support removing columns and it is just a cache table, we recreate the whole table. + database.execSQL("DROP TABLE `ConversationEntity`"); + database.execSQL("CREATE TABLE IF NOT EXISTS `ConversationEntity` (" + + "`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`))"); + } + }; + + public static final Migration MIGRATION_33_34 = new Migration(33, 34) { + @Override + public void migrate(@NonNull SupportSQLiteDatabase database) { + database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `notificationsUpdates` INTEGER NOT NULL DEFAULT 1"); + } + }; + + public static final Migration MIGRATION_34_35 = new Migration(34, 35) { + @Override + public void migrate(@NonNull SupportSQLiteDatabase database) { + database.execSQL("ALTER TABLE `TimelineStatusEntity` ADD COLUMN `card` TEXT"); + } + }; } diff --git a/app/src/main/java/com/keylesspalace/tusky/db/ConversationsDao.kt b/app/src/main/java/com/keylesspalace/tusky/db/ConversationsDao.kt index 393a2392..fe093bd0 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/ConversationsDao.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/ConversationsDao.kt @@ -17,7 +17,6 @@ package com.keylesspalace.tusky.db import androidx.paging.PagingSource import androidx.room.Dao -import androidx.room.Delete import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query @@ -31,8 +30,8 @@ interface ConversationsDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insert(conversation: ConversationEntity): Long - @Delete - suspend fun delete(conversation: ConversationEntity): Int + @Query("DELETE FROM ConversationEntity WHERE id = :id AND accountId = :accountId") + suspend fun delete(id: String, accountId: Long): Int @Query("SELECT * FROM ConversationEntity WHERE accountId = :accountId ORDER BY s_createdAt DESC") fun conversationsForAccount(accountId: Long): PagingSource diff --git a/app/src/main/java/com/keylesspalace/tusky/db/Converters.kt b/app/src/main/java/com/keylesspalace/tusky/db/Converters.kt index c9daec0a..34ff6474 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/Converters.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/Converters.kt @@ -15,9 +15,6 @@ package com.keylesspalace.tusky.db -import android.text.Spanned -import androidx.core.text.parseAsHtml -import androidx.core.text.toHtml import androidx.room.ProvidedTypeConverter import androidx.room.TypeConverter import com.google.gson.Gson @@ -31,10 +28,8 @@ import com.keylesspalace.tusky.entity.HashTag import com.keylesspalace.tusky.entity.NewPoll import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.entity.Status -import com.keylesspalace.tusky.util.trimTrailingWhitespace import java.net.URLDecoder import java.net.URLEncoder -import java.util.ArrayList import java.util.Date import javax.inject.Inject import javax.inject.Singleton @@ -140,22 +135,6 @@ class Converters @Inject constructor ( return Date(date) } - @TypeConverter - fun spannedToString(spanned: Spanned?): String? { - if (spanned == null) { - return null - } - return spanned.toHtml() - } - - @TypeConverter - fun stringToSpanned(spannedString: String?): Spanned? { - if (spannedString == null) { - return null - } - return spannedString.parseAsHtml().trimTrailingWhitespace() - } - @TypeConverter fun pollToJson(poll: Poll?): String? { return gson.toJson(poll) diff --git a/app/src/main/java/com/keylesspalace/tusky/db/InstanceDao.kt b/app/src/main/java/com/keylesspalace/tusky/db/InstanceDao.kt index 52fc3aa8..9b190bc7 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/InstanceDao.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/InstanceDao.kt @@ -19,13 +19,19 @@ import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query -import io.reactivex.rxjava3.core.Single @Dao interface InstanceDao { - @Insert(onConflict = OnConflictStrategy.REPLACE) - fun insertOrReplace(instance: InstanceEntity) + + @Insert(onConflict = OnConflictStrategy.REPLACE, entity = InstanceEntity::class) + suspend fun insertOrReplace(instance: InstanceInfoEntity) + + @Insert(onConflict = OnConflictStrategy.REPLACE, entity = InstanceEntity::class) + suspend fun insertOrReplace(emojis: EmojisEntity) @Query("SELECT * FROM InstanceEntity WHERE instance = :instance LIMIT 1") - fun loadMetadataForInstance(instance: String): Single + suspend fun getInstanceInfo(instance: String): InstanceInfoEntity? + + @Query("SELECT * FROM InstanceEntity WHERE instance = :instance LIMIT 1") + suspend fun getEmojiInfo(instance: String): EmojisEntity? } diff --git a/app/src/main/java/com/keylesspalace/tusky/db/InstanceEntity.kt b/app/src/main/java/com/keylesspalace/tusky/db/InstanceEntity.kt index dd8e85d0..01767f32 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/InstanceEntity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/InstanceEntity.kt @@ -23,7 +23,7 @@ import com.keylesspalace.tusky.entity.Emoji @Entity @TypeConverters(Converters::class) data class InstanceEntity( - @field:PrimaryKey var instance: String, + @PrimaryKey val instance: String, val emojiList: List?, val maximumTootCharacters: Int?, val maxPollOptions: Int?, @@ -33,3 +33,20 @@ data class InstanceEntity( val charactersReservedPerUrl: Int?, val version: String? ) + +@TypeConverters(Converters::class) +data class EmojisEntity( + @PrimaryKey val instance: String, + val emojiList: List? +) + +data class InstanceInfoEntity( + @PrimaryKey val instance: String, + val maximumTootCharacters: Int?, + val maxPollOptions: Int?, + val maxPollOptionLength: Int?, + val minPollDuration: Int?, + val maxPollDuration: Int?, + val charactersReservedPerUrl: Int?, + val version: String? +) diff --git a/app/src/main/java/com/keylesspalace/tusky/db/TimelineDao.kt b/app/src/main/java/com/keylesspalace/tusky/db/TimelineDao.kt index dd59f2a3..2c6ef188 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/TimelineDao.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/TimelineDao.kt @@ -36,7 +36,7 @@ SELECT s.serverId, s.url, s.timelineUserId, s.authorServerId, s.inReplyToId, s.inReplyToAccountId, s.createdAt, s.emojis, s.reblogsCount, s.favouritesCount, s.reblogged, s.favourited, s.bookmarked, s.sensitive, s.spoilerText, s.visibility, s.mentions, s.tags, s.application, s.reblogServerId,s.reblogAccountId, -s.content, s.attachments, s.poll, s.muted, s.expanded, s.contentShowing, s.contentCollapsed, s.pinned, +s.content, s.attachments, s.poll, s.card, s.muted, s.expanded, s.contentShowing, s.contentCollapsed, s.pinned, a.serverId as 'a_serverId', a.timelineUserId as 'a_timelineUserId', a.localUsername as 'a_localUsername', a.username as 'a_username', a.displayName as 'a_displayName', a.url as 'a_url', a.avatar as 'a_avatar', diff --git a/app/src/main/java/com/keylesspalace/tusky/db/TimelineStatusEntity.kt b/app/src/main/java/com/keylesspalace/tusky/db/TimelineStatusEntity.kt index 41b122c3..2c4d45c3 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/TimelineStatusEntity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/TimelineStatusEntity.kt @@ -78,7 +78,8 @@ data class TimelineStatusEntity( val expanded: Boolean, // used as the "loading" attribute when this TimelineStatusEntity is a placeholder val contentCollapsed: Boolean, val contentShowing: Boolean, - val pinned: Boolean + val pinned: Boolean, + val card: String?, ) @Entity( 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 b0f28261..0861e9cf 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt @@ -62,7 +62,8 @@ class AppModule { AppDatabase.MIGRATION_22_23, AppDatabase.MIGRATION_23_24, AppDatabase.MIGRATION_24_25, AppDatabase.Migration25_26(appContext.getExternalFilesDir("Tusky")), AppDatabase.MIGRATION_26_27, AppDatabase.MIGRATION_27_28, AppDatabase.MIGRATION_28_29, - AppDatabase.MIGRATION_29_30, AppDatabase.MIGRATION_30_31 + AppDatabase.MIGRATION_29_30, AppDatabase.MIGRATION_30_31, AppDatabase.MIGRATION_31_32, + AppDatabase.MIGRATION_32_33, AppDatabase.MIGRATION_33_34, AppDatabase.MIGRATION_34_35, ) .build() } diff --git a/app/src/main/java/com/keylesspalace/tusky/di/NetworkModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/NetworkModule.kt index 7bda6ef7..90dd3026 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/NetworkModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/NetworkModule.kt @@ -18,14 +18,13 @@ package com.keylesspalace.tusky.di import android.content.Context import android.content.SharedPreferences import android.os.Build -import android.text.Spanned +import at.connyduck.calladapter.kotlinresult.KotlinResultCallAdapterFactory import com.google.gson.Gson -import com.google.gson.GsonBuilder import com.keylesspalace.tusky.BuildConfig import com.keylesspalace.tusky.db.AccountManager -import com.keylesspalace.tusky.json.SpannedTypeAdapter import com.keylesspalace.tusky.network.InstanceSwitchAuthInterceptor import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.network.MediaUploadApi import com.keylesspalace.tusky.util.getNonNullString import dagger.Module import dagger.Provides @@ -51,11 +50,7 @@ class NetworkModule { @Provides @Singleton - fun providesGson(): Gson { - return GsonBuilder() - .registerTypeAdapter(Spanned::class.java, SpannedTypeAdapter()) - .create() - } + fun providesGson() = Gson() @Provides @Singleton @@ -111,10 +106,25 @@ class NetworkModule { .client(httpClient) .addConverterFactory(GsonConverterFactory.create(gson)) .addCallAdapterFactory(RxJava3CallAdapterFactory.create()) + .addCallAdapterFactory(KotlinResultCallAdapterFactory.create()) .build() } @Provides @Singleton fun providesApi(retrofit: Retrofit): MastodonApi = retrofit.create() + + @Provides + @Singleton + fun providesMediaUploadApi(retrofit: Retrofit, okHttpClient: OkHttpClient): MediaUploadApi { + val longTimeOutOkHttpClient = okHttpClient.newBuilder() + .readTimeout(100, TimeUnit.SECONDS) + .writeTimeout(100, TimeUnit.SECONDS) + .build() + + return retrofit.newBuilder() + .client(longTimeOutOkHttpClient) + .build() + .create() + } } 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 672bd5aa..bf5431ee 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Account.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Account.kt @@ -15,7 +15,6 @@ package com.keylesspalace.tusky.entity -import android.text.Spanned import com.google.gson.annotations.SerializedName import java.util.Date @@ -24,7 +23,7 @@ data class Account( @SerializedName("username") val localUsername: String, @SerializedName("acct") val username: String, @SerializedName("display_name") val displayName: String?, // should never be null per Api definition, but some servers break the contract - val note: Spanned, + val note: String, val url: String, val avatar: String, val header: String, @@ -46,56 +45,6 @@ data class Account( } else displayName fun isRemote(): Boolean = this.username != this.localUsername - - /** - * overriding equals & hashcode because Spanned does not always compare correctly otherwise - */ - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - other as Account - - if (id != other.id) return false - if (localUsername != other.localUsername) return false - if (username != other.username) return false - if (displayName != other.displayName) return false - if (note.toString() != other.note.toString()) return false - if (url != other.url) return false - if (avatar != other.avatar) return false - if (header != other.header) return false - if (locked != other.locked) return false - if (followersCount != other.followersCount) return false - if (followingCount != other.followingCount) return false - if (statusesCount != other.statusesCount) return false - if (source != other.source) return false - if (bot != other.bot) return false - if (emojis != other.emojis) return false - if (fields != other.fields) return false - if (moved != other.moved) return false - - return true - } - - override fun hashCode(): Int { - var result = id.hashCode() - result = 31 * result + localUsername.hashCode() - result = 31 * result + username.hashCode() - result = 31 * result + (displayName?.hashCode() ?: 0) - result = 31 * result + note.toString().hashCode() - result = 31 * result + url.hashCode() - result = 31 * result + avatar.hashCode() - result = 31 * result + header.hashCode() - result = 31 * result + locked.hashCode() - result = 31 * result + followersCount - result = 31 * result + followingCount - result = 31 * result + statusesCount - result = 31 * result + (source?.hashCode() ?: 0) - result = 31 * result + bot.hashCode() - result = 31 * result + (emojis?.hashCode() ?: 0) - result = 31 * result + (fields?.hashCode() ?: 0) - result = 31 * result + (moved?.hashCode() ?: 0) - return result - } } data class AccountSource( @@ -107,7 +56,7 @@ data class AccountSource( data class Field( val name: String, - val value: Spanned, + val value: String, @SerializedName("verified_at") val verifiedAt: Date? ) diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Announcement.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Announcement.kt index 400e9764..00d5659d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Announcement.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Announcement.kt @@ -15,13 +15,12 @@ package com.keylesspalace.tusky.entity -import android.text.Spanned import com.google.gson.annotations.SerializedName import java.util.Date data class Announcement( val id: String, - val content: Spanned, + val content: String, @SerializedName("starts_at") val startsAt: Date?, @SerializedName("ends_at") val endsAt: Date?, @SerializedName("all_day") val allDay: Boolean, diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Card.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Card.kt index 52011f3d..29fe7f8e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Card.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Card.kt @@ -15,13 +15,12 @@ package com.keylesspalace.tusky.entity -import android.text.Spanned import com.google.gson.annotations.SerializedName data class Card( val url: String, - val title: Spanned, - val description: Spanned, + val title: String, + val description: String, @SerializedName("author_name") val authorName: String, val image: String, val type: String, @@ -31,9 +30,7 @@ data class Card( val embed_url: String? ) { - override fun hashCode(): Int { - return url.hashCode() - } + override fun hashCode() = url.hashCode() override fun equals(other: Any?): Boolean { if (other !is Card) { diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/IdentityProof.kt b/app/src/main/java/com/keylesspalace/tusky/entity/IdentityProof.kt deleted file mode 100644 index 98af734b..00000000 --- a/app/src/main/java/com/keylesspalace/tusky/entity/IdentityProof.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.keylesspalace.tusky.entity - -import com.google.gson.annotations.SerializedName - -data class IdentityProof( - val provider: String, - @SerializedName("provider_username") val username: String, - @SerializedName("profile_url") val profileUrl: String -) diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Notification.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Notification.kt index ae2d74a9..f6e38150 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Notification.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Notification.kt @@ -37,7 +37,10 @@ data class Notification( FOLLOW("follow"), FOLLOW_REQUEST("follow_request"), POLL("poll"), - STATUS("status"); + STATUS("status"), + SIGN_UP("admin.sign_up"), + UPDATE("update"), + ; companion object { @@ -49,7 +52,7 @@ data class Notification( } return UNKNOWN } - val asList = listOf(MENTION, REBLOG, FAVOURITE, FOLLOW, FOLLOW_REQUEST, POLL, STATUS) + val asList = listOf(MENTION, REBLOG, FAVOURITE, FOLLOW, FOLLOW_REQUEST, POLL, STATUS, SIGN_UP, UPDATE) } override fun toString(): String { diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt index f75ce4e7..19cb7aa6 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt @@ -16,9 +16,9 @@ package com.keylesspalace.tusky.entity import android.text.SpannableStringBuilder -import android.text.Spanned import android.text.style.URLSpan import com.google.gson.annotations.SerializedName +import com.keylesspalace.tusky.util.parseAsMastodonHtml import java.util.ArrayList import java.util.Date @@ -29,7 +29,7 @@ data class Status( @SerializedName("in_reply_to_id") var inReplyToId: String?, @SerializedName("in_reply_to_account_id") val inReplyToAccountId: String?, val reblog: Status?, - val content: Spanned, + val content: String, @SerializedName("created_at") val createdAt: Date, val emojis: List, @SerializedName("reblogs_count") val reblogsCount: Int, @@ -134,8 +134,9 @@ data class Status( } private fun getEditableText(): String { - val builder = SpannableStringBuilder(content) - for (span in content.getSpans(0, content.length, URLSpan::class.java)) { + val contentSpanned = content.parseAsMastodonHtml() + val builder = SpannableStringBuilder(content.parseAsMastodonHtml()) + for (span in contentSpanned.getSpans(0, content.length, URLSpan::class.java)) { val url = span.url for ((_, url1, username) in mentions) { if (url == url1) { @@ -149,71 +150,6 @@ data class Status( return builder.toString() } - /** - * overriding equals & hashcode because Spanned does not always compare correctly otherwise - */ - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - other as Status - - if (id != other.id) return false - if (url != other.url) return false - if (account != other.account) return false - if (inReplyToId != other.inReplyToId) return false - if (inReplyToAccountId != other.inReplyToAccountId) return false - if (reblog != other.reblog) return false - if (content.toString() != other.content.toString()) return false - if (createdAt != other.createdAt) return false - if (emojis != other.emojis) return false - if (reblogsCount != other.reblogsCount) return false - if (favouritesCount != other.favouritesCount) return false - if (reblogged != other.reblogged) return false - if (favourited != other.favourited) return false - if (bookmarked != other.bookmarked) return false - if (sensitive != other.sensitive) return false - if (spoilerText != other.spoilerText) return false - if (visibility != other.visibility) return false - if (attachments != other.attachments) return false - if (mentions != other.mentions) return false - if (tags != other.tags) return false - if (application != other.application) return false - if (pinned != other.pinned) return false - if (muted != other.muted) return false - if (poll != other.poll) return false - if (card != other.card) return false - return true - } - - override fun hashCode(): Int { - var result = id.hashCode() - result = 31 * result + (url?.hashCode() ?: 0) - result = 31 * result + account.hashCode() - result = 31 * result + (inReplyToId?.hashCode() ?: 0) - result = 31 * result + (inReplyToAccountId?.hashCode() ?: 0) - result = 31 * result + (reblog?.hashCode() ?: 0) - result = 31 * result + content.toString().hashCode() - result = 31 * result + createdAt.hashCode() - result = 31 * result + emojis.hashCode() - result = 31 * result + reblogsCount - result = 31 * result + favouritesCount - result = 31 * result + reblogged.hashCode() - result = 31 * result + favourited.hashCode() - result = 31 * result + bookmarked.hashCode() - result = 31 * result + sensitive.hashCode() - result = 31 * result + spoilerText.hashCode() - result = 31 * result + visibility.hashCode() - result = 31 * result + attachments.hashCode() - result = 31 * result + mentions.hashCode() - result = 31 * result + (tags?.hashCode() ?: 0) - result = 31 * result + (application?.hashCode() ?: 0) - result = 31 * result + (pinned?.hashCode() ?: 0) - result = 31 * result + (muted?.hashCode() ?: 0) - result = 31 * result + (poll?.hashCode() ?: 0) - result = 31 * result + (card?.hashCode() ?: 0) - return result - } - data class Mention( val id: String, val url: String, diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java index e9581e24..56291a21 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java @@ -15,6 +15,10 @@ package com.keylesspalace.tusky.fragment; +import static com.keylesspalace.tusky.util.StringUtils.isLessThan; +import static autodispose2.AutoDispose.autoDisposable; +import static autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from; + import android.app.Activity; import android.content.Context; import android.content.DialogInterface; @@ -111,10 +115,6 @@ import kotlin.Unit; import kotlin.collections.CollectionsKt; import kotlin.jvm.functions.Function1; -import static autodispose2.AutoDispose.autoDisposable; -import static autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from; -import static com.keylesspalace.tusky.util.StringUtils.isLessThan; - public class NotificationsFragment extends SFragment implements SwipeRefreshLayout.OnRefreshListener, StatusActionListener, @@ -707,6 +707,10 @@ public class NotificationsFragment extends SFragment implements return getString(R.string.notification_poll_name); case STATUS: return getString(R.string.notification_subscription_name); + case SIGN_UP: + return getString(R.string.notification_sign_up_name); + case UPDATE: + return getString(R.string.notification_update_name); default: return "Unknown"; } diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java index b1a47ad8..ad81abe3 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java @@ -15,6 +15,8 @@ package com.keylesspalace.tusky.fragment; +import static com.keylesspalace.tusky.util.StatusParsingHelper.parseAsMastodonHtml; + import android.Manifest; import android.app.DownloadManager; import android.content.ClipData; @@ -56,6 +58,7 @@ import com.keylesspalace.tusky.entity.Status; import com.keylesspalace.tusky.network.MastodonApi; import com.keylesspalace.tusky.network.TimelineCases; import com.keylesspalace.tusky.util.LinkHelper; +import com.keylesspalace.tusky.util.StatusParsingHelper; import com.keylesspalace.tusky.view.MuteAccountDialog; import com.keylesspalace.tusky.viewdata.AttachmentViewData; @@ -150,7 +153,7 @@ public abstract class SFragment extends Fragment implements Injectable { composeOptions.setContentWarning(contentWarning); composeOptions.setMentionedUsernames(mentionedUsernames); composeOptions.setReplyingStatusAuthor(actionableStatus.getAccount().getLocalUsername()); - composeOptions.setReplyingStatusContent(actionableStatus.getContent().toString()); + composeOptions.setReplyingStatusContent(parseAsMastodonHtml(actionableStatus.getContent()).toString()); Intent intent = ComposeActivity.startIntent(getContext(), composeOptions); getActivity().startActivity(intent); @@ -226,7 +229,7 @@ public abstract class SFragment extends Fragment implements Injectable { String stringToShare = statusToShare.getAccount().getUsername() + " - " + - statusToShare.getContent().toString(); + StatusParsingHelper.parseAsMastodonHtml(statusToShare.getContent()).toString(); sendIntent.putExtra(Intent.EXTRA_TEXT, stringToShare); sendIntent.putExtra(Intent.EXTRA_SUBJECT, statusUrl); sendIntent.setType("text/plain"); diff --git a/app/src/main/java/com/keylesspalace/tusky/interfaces/StatusActionListener.java b/app/src/main/java/com/keylesspalace/tusky/interfaces/StatusActionListener.java index 116e582c..ec37680c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/interfaces/StatusActionListener.java +++ b/app/src/main/java/com/keylesspalace/tusky/interfaces/StatusActionListener.java @@ -38,7 +38,7 @@ public interface StatusActionListener extends LinkListener { void onOpenReblog(int position); void onExpandedChange(boolean expanded, int position); void onContentHiddenChange(boolean isShowing, int position); - void onLoadMore(int position); + void onLoadMore(int position); /** * Called when the status {@link android.widget.ToggleButton} responsible for collapsing long diff --git a/app/src/main/java/com/keylesspalace/tusky/json/SpannedTypeAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/json/SpannedTypeAdapter.kt deleted file mode 100644 index 60af6134..00000000 --- a/app/src/main/java/com/keylesspalace/tusky/json/SpannedTypeAdapter.kt +++ /dev/null @@ -1,54 +0,0 @@ -/* Copyright 2020 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.json - -import android.text.Spanned -import android.text.SpannedString -import androidx.core.text.HtmlCompat -import androidx.core.text.parseAsHtml -import androidx.core.text.toHtml -import com.google.gson.JsonDeserializationContext -import com.google.gson.JsonDeserializer -import com.google.gson.JsonElement -import com.google.gson.JsonParseException -import com.google.gson.JsonPrimitive -import com.google.gson.JsonSerializationContext -import com.google.gson.JsonSerializer -import com.keylesspalace.tusky.util.trimTrailingWhitespace -import java.lang.reflect.Type - -class SpannedTypeAdapter : JsonDeserializer, JsonSerializer { - @Throws(JsonParseException::class) - override fun deserialize(json: JsonElement, typeOfT: Type, context: JsonDeserializationContext): Spanned { - return json.asString - /* Mastodon uses 'white-space: pre-wrap;' so spaces are displayed as returned by the Api. - * We can't use CSS so we replace spaces with non-breaking-spaces to emulate the behavior. - */ - ?.replace("
", "
 ") - ?.replace("
", "
 ") - ?.replace("
", "
 ") - ?.replace(" ", "  ") - ?.parseAsHtml() - /* Html.fromHtml returns trailing whitespace if the html ends in a

tag, which - * most status contents do, so it should be trimmed. */ - ?.trimTrailingWhitespace() - ?: SpannedString("") - } - - override fun serialize(src: Spanned?, typeOfSrc: Type, context: JsonSerializationContext): JsonElement { - return JsonPrimitive(src!!.toHtml(HtmlCompat.TO_HTML_PARAGRAPH_LINES_INDIVIDUAL)) - } -} 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 28d83eca..7357293b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt +++ b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt @@ -24,7 +24,6 @@ import com.keylesspalace.tusky.entity.Conversation import com.keylesspalace.tusky.entity.DeletedStatus import com.keylesspalace.tusky.entity.Emoji import com.keylesspalace.tusky.entity.Filter -import com.keylesspalace.tusky.entity.IdentityProof import com.keylesspalace.tusky.entity.Instance import com.keylesspalace.tusky.entity.Marker import com.keylesspalace.tusky.entity.MastoList @@ -77,10 +76,10 @@ interface MastodonApi { fun getLists(): Single> @GET("/api/v1/custom_emojis") - fun getCustomEmojis(): Single> + suspend fun getCustomEmojis(): Result> @GET("api/v1/instance") - fun getInstance(): Single + suspend fun getInstance(): Result @GET("api/v1/filters") fun getFilters(): Single> @@ -143,27 +142,25 @@ interface MastodonApi { @POST("api/v1/notifications/clear") fun clearNotifications(): Single - @Multipart - @POST("api/v2/media") - fun uploadMedia( - @Part file: MultipartBody.Part, - @Part description: MultipartBody.Part? = null - ): Single - @FormUrlEncoded @PUT("api/v1/media/{mediaId}") - fun updateMedia( + suspend fun updateMedia( @Path("mediaId") mediaId: String, @Field("description") description: String - ): Single + ): Result + + @GET("api/v1/media/{mediaId}") + suspend fun getMedia( + @Path("mediaId") mediaId: String + ): Response @POST("api/v1/statuses") - fun createStatus( + suspend fun createStatus( @Header("Authorization") auth: String, @Header(DOMAIN_HEADER) domain: String, @Header("Idempotency-Key") idempotencyKey: String, @Body status: NewStatus - ): Call + ): Result @GET("api/v1/statuses/{id}") fun status( @@ -249,12 +246,12 @@ interface MastodonApi { ): Single> @DELETE("api/v1/scheduled_statuses/{id}") - fun deleteScheduledStatus( + suspend fun deleteScheduledStatus( @Path("id") scheduledStatusId: String - ): Single + ): Result @GET("api/v1/accounts/verify_credentials") - fun accountVerifyCredentials(): Single + suspend fun accountVerifyCredentials(): Result @FormUrlEncoded @PATCH("api/v1/accounts/update_credentials") @@ -265,7 +262,7 @@ interface MastodonApi { @Multipart @PATCH("api/v1/accounts/update_credentials") - fun accountUpdateCredentials( + suspend fun accountUpdateCredentials( @Part(value = "display_name") displayName: RequestBody?, @Part(value = "note") note: RequestBody?, @Part(value = "locked") locked: RequestBody?, @@ -279,7 +276,7 @@ interface MastodonApi { @Part(value = "fields_attributes[2][value]") fieldValue2: RequestBody?, @Part(value = "fields_attributes[3][name]") fieldName3: RequestBody?, @Part(value = "fields_attributes[3][value]") fieldValue3: RequestBody? - ): Call + ): Result @GET("api/v1/accounts/search") fun searchAccounts( @@ -367,11 +364,6 @@ interface MastodonApi { @Query("id[]") accountIds: List ): Single> - @GET("api/v1/accounts/{id}/identity_proofs") - fun identityProofs( - @Path("id") accountId: String - ): Single> - @POST("api/v1/pleroma/accounts/{id}/subscribe") fun subscribeAccount( @Path("id") accountId: String @@ -447,7 +439,7 @@ interface MastodonApi { @Field("redirect_uris") redirectUris: String, @Field("scopes") scopes: String, @Field("website") website: String - ): AppCredentials + ): Result @FormUrlEncoded @POST("oauth/token") @@ -458,7 +450,7 @@ interface MastodonApi { @Field("redirect_uri") redirectUri: String, @Field("code") code: String, @Field("grant_type") grantType: String - ): AccessToken + ): Result @FormUrlEncoded @POST("api/v1/lists") @@ -544,26 +536,26 @@ interface MastodonApi { ): Single @GET("api/v1/announcements") - fun listAnnouncements( + suspend fun listAnnouncements( @Query("with_dismissed") withDismissed: Boolean = true - ): Single> + ): Result> @POST("api/v1/announcements/{id}/dismiss") - fun dismissAnnouncement( + suspend fun dismissAnnouncement( @Path("id") announcementId: String - ): Single + ): Result @PUT("api/v1/announcements/{id}/reactions/{name}") - fun addAnnouncementReaction( + suspend fun addAnnouncementReaction( @Path("id") announcementId: String, @Path("name") name: String - ): Single + ): Result @DELETE("api/v1/announcements/{id}/reactions/{name}") - fun removeAnnouncementReaction( + suspend fun removeAnnouncementReaction( @Path("id") announcementId: String, @Path("name") name: String - ): Single + ): Result @FormUrlEncoded @POST("api/v1/reports") diff --git a/app/src/main/java/com/keylesspalace/tusky/network/MediaUploadApi.kt b/app/src/main/java/com/keylesspalace/tusky/network/MediaUploadApi.kt new file mode 100644 index 00000000..c7e9633f --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/network/MediaUploadApi.kt @@ -0,0 +1,19 @@ +package com.keylesspalace.tusky.network + +import com.keylesspalace.tusky.entity.MediaUploadResult +import okhttp3.MultipartBody +import retrofit2.http.Multipart +import retrofit2.http.POST +import retrofit2.http.Part + +/** endpoints defined in this interface will be called with a higher timeout than usual + * which is necessary for media uploads to succeed on some servers + */ +interface MediaUploadApi { + @Multipart + @POST("api/v2/media") + suspend fun uploadMedia( + @Part file: MultipartBody.Part, + @Part description: MultipartBody.Part? = null + ): Result +} diff --git a/app/src/main/java/com/keylesspalace/tusky/receiver/SendStatusBroadcastReceiver.kt b/app/src/main/java/com/keylesspalace/tusky/receiver/SendStatusBroadcastReceiver.kt index 6101dd84..14f82e8b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/receiver/SendStatusBroadcastReceiver.kt +++ b/app/src/main/java/com/keylesspalace/tusky/receiver/SendStatusBroadcastReceiver.kt @@ -100,7 +100,8 @@ class SendStatusBroadcastReceiver : BroadcastReceiver() { accountId = account.id, draftId = -1, idempotencyKey = randomAlphanumericString(16), - retries = 0 + retries = 0, + mediaProcessed = mutableListOf() ) ) diff --git a/app/src/main/java/com/keylesspalace/tusky/service/SendStatusService.kt b/app/src/main/java/com/keylesspalace/tusky/service/SendStatusService.kt index a2709f97..e50f4f4f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/service/SendStatusService.kt +++ b/app/src/main/java/com/keylesspalace/tusky/service/SendStatusService.kt @@ -11,6 +11,7 @@ import android.content.Intent import android.os.Build import android.os.IBinder import android.os.Parcelable +import android.util.Log import androidx.core.app.NotificationCompat import androidx.core.app.ServiceCompat import androidx.core.content.ContextCompat @@ -29,13 +30,12 @@ import com.keylesspalace.tusky.network.MastodonApi import dagger.android.AndroidInjection import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.parcelize.Parcelize -import retrofit2.Call -import retrofit2.Callback -import retrofit2.Response +import retrofit2.HttpException import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.TimeUnit import javax.inject.Inject @@ -55,7 +55,7 @@ class SendStatusService : Service(), Injectable { private val serviceScope = CoroutineScope(Dispatchers.Main + supervisorJob) private val statusesToSend = ConcurrentHashMap() - private val sendCalls = ConcurrentHashMap>() + private val sendJobs = ConcurrentHashMap() private val notificationManager by lazy { getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager } @@ -64,12 +64,9 @@ class SendStatusService : Service(), Injectable { super.onCreate() } - override fun onBind(intent: Intent): IBinder? { - return null - } + override fun onBind(intent: Intent): IBinder? = null override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int { - if (intent.hasExtra(KEY_STATUS)) { val statusToSend = intent.getParcelableExtra(KEY_STATUS) ?: throw IllegalStateException("SendStatusService started without $KEY_STATUS extra") @@ -129,82 +126,94 @@ class SendStatusService : Service(), Injectable { statusToSend.retries++ - val newStatus = NewStatus( - statusToSend.text, - statusToSend.warningText, - statusToSend.inReplyToId, - statusToSend.visibility, - statusToSend.sensitive, - statusToSend.mediaIds, - statusToSend.scheduledAt, - statusToSend.poll - ) + sendJobs[statusId] = serviceScope.launch { + try { + var mediaCheckRetries = 0 + while (statusToSend.mediaProcessed.any { !it }) { + delay(1000L * mediaCheckRetries) + statusToSend.mediaProcessed.forEachIndexed { index, processed -> + if (!processed) { + // Mastodon returns 206 if the media was not yet processed + statusToSend.mediaProcessed[index] = mastodonApi.getMedia(statusToSend.mediaIds[index]).code() == 200 + } + } + mediaCheckRetries ++ + } + } catch (e: Exception) { + Log.w(TAG, "failed getting media status", e) + retrySending(statusId) + return@launch + } - val sendCall = mastodonApi.createStatus( - "Bearer " + account.accessToken, - account.domain, - statusToSend.idempotencyKey, - newStatus - ) + val newStatus = NewStatus( + statusToSend.text, + statusToSend.warningText, + statusToSend.inReplyToId, + statusToSend.visibility, + statusToSend.sensitive, + statusToSend.mediaIds, + statusToSend.scheduledAt, + statusToSend.poll + ) - sendCalls[statusId] = sendCall + mastodonApi.createStatus( + "Bearer " + account.accessToken, + account.domain, + statusToSend.idempotencyKey, + newStatus + ).fold({ sentStatus -> + statusesToSend.remove(statusId) + // If the status was loaded from a draft, delete the draft and associated media files. + if (statusToSend.draftId != 0) { + draftHelper.deleteDraftAndAttachments(statusToSend.draftId) + } - val callback = object : Callback { - override fun onResponse(call: Call, response: Response) { - serviceScope.launch { + val scheduled = !statusToSend.scheduledAt.isNullOrEmpty() - val scheduled = !statusToSend.scheduledAt.isNullOrEmpty() + if (scheduled) { + eventHub.dispatch(StatusScheduledEvent(sentStatus)) + } else { + eventHub.dispatch(StatusComposedEvent(sentStatus)) + } + + notificationManager.cancel(statusId) + }, { throwable -> + Log.w(TAG, "failed sending status", throwable) + if (throwable is HttpException) { + // the server refused to accept the status, save status & show error message statusesToSend.remove(statusId) + saveStatusToDrafts(statusToSend) - if (response.isSuccessful) { - // If the status was loaded from a draft, delete the draft and associated media files. - if (statusToSend.draftId != 0) { - draftHelper.deleteDraftAndAttachments(statusToSend.draftId) - } - - if (scheduled) { - response.body()?.let(::StatusScheduledEvent)?.let(eventHub::dispatch) - } else { - response.body()?.let(::StatusComposedEvent)?.let(eventHub::dispatch) - } - - notificationManager.cancel(statusId) - } else { - // the server refused to accept the status, save status & show error message - saveStatusToDrafts(statusToSend) - - val builder = NotificationCompat.Builder(this@SendStatusService, CHANNEL_ID) - .setSmallIcon(R.drawable.ic_notify) - .setContentTitle(getString(R.string.send_post_notification_error_title)) - .setContentText(getString(R.string.send_post_notification_saved_content)) - .setColor( - ContextCompat.getColor( - this@SendStatusService, - R.color.notification_color - ) + val builder = NotificationCompat.Builder(this@SendStatusService, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_notify) + .setContentTitle(getString(R.string.send_post_notification_error_title)) + .setContentText(getString(R.string.send_post_notification_saved_content)) + .setColor( + ContextCompat.getColor( + this@SendStatusService, + R.color.notification_color ) + ) - notificationManager.cancel(statusId) - notificationManager.notify(errorNotificationId--, builder.build()) - } - stopSelfWhenDone() + notificationManager.cancel(statusId) + notificationManager.notify(errorNotificationId--, builder.build()) + } else { + // a network problem occurred, let's retry sending the status + retrySending(statusId) } - } - - override fun onFailure(call: Call, t: Throwable) { - serviceScope.launch { - var backoff = TimeUnit.SECONDS.toMillis(statusToSend.retries.toLong()) - if (backoff > MAX_RETRY_INTERVAL) { - backoff = MAX_RETRY_INTERVAL - } - - delay(backoff) - sendStatus(statusId) - } - } + }) + stopSelfWhenDone() } + } - sendCall.enqueue(callback) + private suspend fun retrySending(statusId: Int) { + // when statusToSend == null, sending has been canceled + val statusToSend = statusesToSend[statusId] ?: return + + val backoff = TimeUnit.SECONDS.toMillis(statusToSend.retries.toLong()).coerceAtMost(MAX_RETRY_INTERVAL) + + delay(backoff) + sendStatus(statusId) } private fun stopSelfWhenDone() { @@ -218,8 +227,8 @@ class SendStatusService : Service(), Injectable { private fun cancelSending(statusId: Int) = serviceScope.launch { val statusToCancel = statusesToSend.remove(statusId) if (statusToCancel != null) { - val sendCall = sendCalls.remove(statusId) - sendCall?.cancel() + val sendJob = sendJobs.remove(statusId) + sendJob?.cancel() saveStatusToDrafts(statusToCancel) @@ -263,6 +272,7 @@ class SendStatusService : Service(), Injectable { } companion object { + private const val TAG = "SendStatusService" private const val KEY_STATUS = "status" private const val KEY_CANCEL = "cancel_id" @@ -319,5 +329,6 @@ data class StatusToSend( val accountId: Long, val draftId: Int, val idempotencyKey: String, - var retries: Int + var retries: Int, + val mediaProcessed: MutableList ) : Parcelable diff --git a/app/src/main/java/com/keylesspalace/tusky/settings/SettingsConstants.kt b/app/src/main/java/com/keylesspalace/tusky/settings/SettingsConstants.kt index c59ba58b..6540601a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/settings/SettingsConstants.kt +++ b/app/src/main/java/com/keylesspalace/tusky/settings/SettingsConstants.kt @@ -59,6 +59,8 @@ object PrefKeys { const val NOTIFICATION_FILTER_FOLLOW_REQUESTS = "notificationFilterFollowRequests" const val NOTIFICATIONS_FILTER_FOLLOWS = "notificationFilterFollows" const val NOTIFICATION_FILTER_SUBSCRIPTIONS = "notificationFilterSubscriptions" + const val NOTIFICATION_FILTER_SIGN_UPS = "notificationFilterSignUps" + const val NOTIFICATION_FILTER_UPDATES = "notificationFilterUpdates" const val TAB_FILTER_HOME_REPLIES = "tabFilterHomeReplies" const val TAB_FILTER_HOME_BOOSTS = "tabFilterHomeBoosts" diff --git a/app/src/main/java/com/keylesspalace/tusky/settings/SettingsDSL.kt b/app/src/main/java/com/keylesspalace/tusky/settings/SettingsDSL.kt index 1569cb15..85270081 100644 --- a/app/src/main/java/com/keylesspalace/tusky/settings/SettingsDSL.kt +++ b/app/src/main/java/com/keylesspalace/tusky/settings/SettingsDSL.kt @@ -1,7 +1,9 @@ package com.keylesspalace.tusky.settings import android.content.Context +import androidx.activity.result.ActivityResultRegistryOwner import androidx.annotation.StringRes +import androidx.lifecycle.LifecycleOwner import androidx.preference.CheckBoxPreference import androidx.preference.EditTextPreference import androidx.preference.ListPreference @@ -10,8 +12,7 @@ import androidx.preference.PreferenceCategory import androidx.preference.PreferenceFragmentCompat import androidx.preference.PreferenceScreen import androidx.preference.SwitchPreference -import com.keylesspalace.tusky.components.preference.EmojiPreference -import okhttp3.OkHttpClient +import de.c1710.filemojicompat_ui.views.picker.preference.EmojiPickerPreference class PreferenceParent( val context: Context, @@ -32,8 +33,9 @@ inline fun PreferenceParent.listPreference(builder: ListPreference.() -> Unit): return pref } -inline fun PreferenceParent.emojiPreference(okHttpClient: OkHttpClient, builder: EmojiPreference.() -> Unit): EmojiPreference { - val pref = EmojiPreference(context, okHttpClient) +inline fun PreferenceParent.emojiPreference(activity: A, builder: EmojiPickerPreference.() -> Unit): EmojiPickerPreference + where A : Context, A : ActivityResultRegistryOwner, A : LifecycleOwner { + val pref = EmojiPickerPreference.get(activity) builder(pref) addPref(pref) return pref diff --git a/app/src/main/java/com/keylesspalace/tusky/util/AbsoluteTimeFormatter.kt b/app/src/main/java/com/keylesspalace/tusky/util/AbsoluteTimeFormatter.kt new file mode 100644 index 00000000..7d46388b --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/AbsoluteTimeFormatter.kt @@ -0,0 +1,59 @@ +/* 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 java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Date +import java.util.Locale +import java.util.TimeZone + +class AbsoluteTimeFormatter @JvmOverloads constructor(private val tz: TimeZone = TimeZone.getDefault()) { + private val sameDaySdf = SimpleDateFormat("HH:mm", Locale.getDefault()).apply { this.timeZone = tz } + private val sameYearSdf = SimpleDateFormat("MM-dd HH:mm", Locale.getDefault()).apply { this.timeZone = tz } + private val otherYearSdf = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).apply { this.timeZone = tz } + private val otherYearCompleteSdf = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()).apply { this.timeZone = tz } + + @JvmOverloads + fun format(time: Date?, shortFormat: Boolean = true, now: Date = Date()): String { + return when { + time == null -> "??" + isSameDate(time, now, tz) -> sameDaySdf.format(time) + isSameYear(time, now, tz) -> sameYearSdf.format(time) + shortFormat -> otherYearSdf.format(time) + else -> otherYearCompleteSdf.format(time) + } + } + + companion object { + + private fun isSameDate(dateOne: Date, dateTwo: Date, tz: TimeZone): Boolean { + val calendarOne = Calendar.getInstance(tz).apply { time = dateOne } + val calendarTwo = Calendar.getInstance(tz).apply { time = dateTwo } + + return calendarOne.get(Calendar.YEAR) == calendarTwo.get(Calendar.YEAR) && + calendarOne.get(Calendar.MONTH) == calendarTwo.get(Calendar.MONTH) && + calendarOne.get(Calendar.DAY_OF_MONTH) == calendarTwo.get(Calendar.DAY_OF_MONTH) + } + + private fun isSameYear(dateOne: Date, dateTwo: Date, timeZone1: TimeZone): Boolean { + val calendarOne = Calendar.getInstance(timeZone1).apply { time = dateOne } + val calendarTwo = Calendar.getInstance(timeZone1).apply { time = dateTwo } + + return calendarOne.get(Calendar.YEAR) == calendarTwo.get(Calendar.YEAR) + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/EmojiCompatFont.kt b/app/src/main/java/com/keylesspalace/tusky/util/EmojiCompatFont.kt deleted file mode 100644 index 385be6c1..00000000 --- a/app/src/main/java/com/keylesspalace/tusky/util/EmojiCompatFont.kt +++ /dev/null @@ -1,364 +0,0 @@ -package com.keylesspalace.tusky.util - -import android.content.Context -import android.util.Log -import android.util.Pair -import androidx.annotation.DrawableRes -import androidx.annotation.StringRes -import androidx.annotation.VisibleForTesting -import com.keylesspalace.tusky.R -import de.c1710.filemojicompat.FileEmojiCompatConfig -import io.reactivex.rxjava3.core.Observable -import io.reactivex.rxjava3.core.ObservableEmitter -import io.reactivex.rxjava3.schedulers.Schedulers -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.Response -import okhttp3.ResponseBody -import okhttp3.internal.toLongOrDefault -import okio.Source -import okio.buffer -import okio.sink -import java.io.EOFException -import java.io.File -import java.io.FilenameFilter -import java.io.IOException -import kotlin.math.max - -/** - * This class bundles information about an emoji font as well as many convenient actions. - */ -class EmojiCompatFont( - val name: String, - private val display: String, - @StringRes val caption: Int, - @DrawableRes val img: Int, - val url: String, - // The version is stored as a String in the x.xx.xx format (to be able to compare versions) - val version: String -) { - - private val versionCode = getVersionCode(version) - - // A list of all available font files and whether they are older than the current version or not - // They are ordered by their version codes in ascending order - private var existingFontFileCache: List>>? = null - - val id: Int - get() = FONTS.indexOf(this) - - fun getDisplay(context: Context): String { - return if (this !== SYSTEM_DEFAULT) display else context.getString(R.string.system_default) - } - - /** - * This method will return the actual font file (regardless of its existence) for - * the current version (not necessarily the latest!). - * - * @return The font (TTF) file or null if called on SYSTEM_FONT - */ - private fun getFontFile(context: Context): File? { - return if (this !== SYSTEM_DEFAULT) { - val directory = File(context.getExternalFilesDir(null), DIRECTORY) - File(directory, "$name$version.ttf") - } else { - null - } - } - - fun getConfig(context: Context): FileEmojiCompatConfig { - return FileEmojiCompatConfig(context, getLatestFontFile(context)) - } - - fun isDownloaded(context: Context): Boolean { - return this === SYSTEM_DEFAULT || getFontFile(context)?.exists() == true || fontFileExists(context) - } - - /** - * Checks whether there is already a font version that satisfies the current version, i.e. it - * has a higher or equal version code. - * - * @param context The Context - * @return Whether there is a font file with a higher or equal version code to the current - */ - private fun fontFileExists(context: Context): Boolean { - val existingFontFiles = getExistingFontFiles(context) - return if (existingFontFiles.isNotEmpty()) { - compareVersions(existingFontFiles.last().second, versionCode) >= 0 - } else { - false - } - } - - /** - * Deletes any older version of a font - * - * @param context The current Context - */ - private fun deleteOldVersions(context: Context) { - val existingFontFiles = getExistingFontFiles(context) - Log.d(TAG, "deleting old versions...") - Log.d(TAG, String.format("deleteOldVersions: Found %d other font files", existingFontFiles.size)) - for (fileExists in existingFontFiles) { - if (compareVersions(fileExists.second, versionCode) < 0) { - val file = fileExists.first - // Uses side effects! - Log.d( - TAG, - String.format( - "Deleted %s successfully: %s", file.absolutePath, - file.delete() - ) - ) - } - } - } - - /** - * Loads all font files that are inside the files directory into an ArrayList with the information - * on whether they are older than the currently available version or not. - * - * @param context The Context - */ - private fun getExistingFontFiles(context: Context): List>> { - // Only load it once - existingFontFileCache?.let { - return it - } - // If we call this on the system default font, just return nothing... - if (this === SYSTEM_DEFAULT) { - existingFontFileCache = emptyList() - return emptyList() - } - - val directory = File(context.getExternalFilesDir(null), DIRECTORY) - // It will search for old versions using a regex that matches the font's name plus - // (if present) a version code. No version code will be regarded as version 0. - val fontRegex = "$name(\\d+(\\.\\d+)*)?\\.ttf".toPattern() - val ttfFilter = FilenameFilter { _, name: String -> name.endsWith(".ttf") } - val foundFontFiles = directory.listFiles(ttfFilter).orEmpty() - Log.d( - TAG, - String.format( - "loadExistingFontFiles: %d other font files found", - foundFontFiles.size - ) - ) - - return foundFontFiles.map { file -> - val matcher = fontRegex.matcher(file.name) - val versionCode = if (matcher.matches()) { - val version = matcher.group(1) - getVersionCode(version) - } else { - listOf(0) - } - Pair(file, versionCode) - }.sortedWith { a, b -> - compareVersions(a.second, b.second) - }.also { - existingFontFileCache = it - } - } - - /** - * Returns the current or latest version of this font file (if there is any) - * - * @param context The Context - * @return The file for this font with the current or (if not existent) highest version code or null if there is no file for this font. - */ - private fun getLatestFontFile(context: Context): File? { - val current = getFontFile(context) - if (current != null && current.exists()) return current - val existingFontFiles = getExistingFontFiles(context) - return existingFontFiles.firstOrNull()?.first - } - - private fun getVersionCode(version: String?): List { - if (version == null) return listOf(0) - return version.split(".").map { - it.toIntOrNull() ?: 0 - } - } - - fun downloadFontFile( - context: Context, - okHttpClient: OkHttpClient - ): Observable { - return Observable.create { emitter: ObservableEmitter -> - // It is possible (and very likely) that the file does not exist yet - val downloadFile = getFontFile(context)!! - if (!downloadFile.exists()) { - downloadFile.parentFile?.mkdirs() - downloadFile.createNewFile() - } - val request = Request.Builder().url(url) - .build() - - val sink = downloadFile.sink().buffer() - var source: Source? = null - try { - // Download! - val response = okHttpClient.newCall(request).execute() - - val responseBody = response.body - if (response.isSuccessful && responseBody != null) { - val size = response.length() - var progress = 0f - source = responseBody.source() - try { - while (!emitter.isDisposed) { - sink.write(source, CHUNK_SIZE) - progress += CHUNK_SIZE.toFloat() - if (size > 0) { - emitter.onNext(progress / size) - } else { - emitter.onNext(-1f) - } - } - } catch (ex: EOFException) { - /* - This means we've finished downloading the file since sink.write - will throw an EOFException when the file to be read is empty. - */ - } - } else { - Log.e(TAG, "Downloading $url failed. Status code: ${response.code}") - emitter.tryOnError(Exception()) - } - } catch (ex: IOException) { - Log.e(TAG, "Downloading $url failed.", ex) - downloadFile.deleteIfExists() - emitter.tryOnError(ex) - } finally { - source?.close() - sink.close() - if (emitter.isDisposed) { - downloadFile.deleteIfExists() - } else { - deleteOldVersions(context) - emitter.onComplete() - } - } - } - .subscribeOn(Schedulers.io()) - } - - /** - * Deletes the downloaded file, if it exists. Should be called when a download gets cancelled. - */ - fun deleteDownloadedFile(context: Context) { - getFontFile(context)?.deleteIfExists() - } - - override fun toString(): String { - return display - } - - companion object { - private const val TAG = "EmojiCompatFont" - - /** - * This String represents the sub-directory the fonts are stored in. - */ - private const val DIRECTORY = "emoji" - - private const val CHUNK_SIZE = 4096L - - // The system font gets some special behavior... - val SYSTEM_DEFAULT = EmojiCompatFont( - "system-default", - "System Default", - R.string.caption_systememoji, - R.drawable.ic_emoji_34dp, - "", - "0" - ) - val BLOBMOJI = EmojiCompatFont( - "Blobmoji", - "Blobmoji", - R.string.caption_blobmoji, - R.drawable.ic_blobmoji, - "https://tusky.app/hosted/emoji/BlobmojiCompat.ttf", - "14.0.1" - ) - val TWEMOJI = EmojiCompatFont( - "Twemoji", - "Twemoji", - R.string.caption_twemoji, - R.drawable.ic_twemoji, - "https://tusky.app/hosted/emoji/TwemojiCompat.ttf", - "14.0.0" - ) - val NOTOEMOJI = EmojiCompatFont( - "NotoEmoji", - "Noto Emoji", - R.string.caption_notoemoji, - R.drawable.ic_notoemoji, - "https://tusky.app/hosted/emoji/NotoEmojiCompat.ttf", - "14.0.0" - ) - - /** - * This array stores all available EmojiCompat fonts. - * References to them can simply be saved by saving their indices - */ - val FONTS = listOf(SYSTEM_DEFAULT, BLOBMOJI, TWEMOJI, NOTOEMOJI) - - /** - * Returns the Emoji font associated with this ID - * - * @param id the ID of this font - * @return the corresponding font. Will default to SYSTEM_DEFAULT if not in range. - */ - fun byId(id: Int): EmojiCompatFont = FONTS.getOrElse(id) { SYSTEM_DEFAULT } - - /** - * Compares two version codes to each other - * - * @param versionA The first version - * @param versionB The second version - * @return -1 if versionA < versionB, 1 if versionA > versionB and 0 otherwise - */ - @VisibleForTesting - fun compareVersions(versionA: List, versionB: List): Int { - val len = max(versionB.size, versionA.size) - for (i in 0 until len) { - - val vA = versionA.getOrElse(i) { 0 } - val vB = versionB.getOrElse(i) { 0 } - - // It needs to be decided on the next level - if (vA == vB) continue - // Okay, is version B newer or version A? - return vA.compareTo(vB) - } - - // The versions are equal - return 0 - } - - /** - * This method is needed because when transparent compression is used OkHttp reports - * [ResponseBody.contentLength] as -1. We try to get the header which server sent - * us manually here. - * - * @see [OkHttp issue 259](https://github.com/square/okhttp/issues/259) - */ - private fun Response.length(): Long { - networkResponse?.let { - val header = it.header("Content-Length") ?: return -1 - return header.toLongOrDefault(-1) - } - - // In case it's a fully cached response - return body?.contentLength() ?: -1 - } - - private fun File.deleteIfExists() { - if (exists() && !delete()) { - Log.e(TAG, "Could not delete file $this") - } - } - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/StatusParsingHelper.kt b/app/src/main/java/com/keylesspalace/tusky/util/StatusParsingHelper.kt new file mode 100644 index 00000000..2ac4782c --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/StatusParsingHelper.kt @@ -0,0 +1,63 @@ +/* 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("StatusParsingHelper") +package com.keylesspalace.tusky.util + +import android.text.SpannableStringBuilder +import android.text.Spanned +import androidx.core.text.parseAsHtml + +/** + * parse a String containing html from the Mastodon api to Spanned + */ +fun String.parseAsMastodonHtml(): Spanned { + return this.replace("
", "
 ") + .replace("
", "
 ") + .replace("
", "
 ") + .replace(" ", "  ") + .parseAsHtml() + /* Html.fromHtml returns trailing whitespace if the html ends in a

tag, which + * most status contents do, so it should be trimmed. */ + .trimTrailingWhitespace() +} + +fun replaceCrashingCharacters(content: Spanned): Spanned { + return replaceCrashingCharacters(content as CharSequence) as Spanned +} + +fun replaceCrashingCharacters(content: CharSequence): CharSequence? { + var replacing = false + var builder: SpannableStringBuilder? = null + val length = content.length + for (index in 0 until length) { + val character = content[index] + + // If there are more than one or two, switch to a map + if (character == SOFT_HYPHEN) { + if (!replacing) { + replacing = true + builder = SpannableStringBuilder(content, 0, index) + } + builder!!.append(ASCII_HYPHEN) + } else if (replacing) { + builder!!.append(character) + } + } + return if (replacing) builder else content +} + +private const val SOFT_HYPHEN = '\u00ad' +private const val ASCII_HYPHEN = '-' diff --git a/app/src/main/java/com/keylesspalace/tusky/util/StatusViewHelper.kt b/app/src/main/java/com/keylesspalace/tusky/util/StatusViewHelper.kt index 60ac73f4..0752c4e5 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/StatusViewHelper.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/StatusViewHelper.kt @@ -34,20 +34,16 @@ import com.keylesspalace.tusky.viewdata.PollViewData import com.keylesspalace.tusky.viewdata.buildDescription import com.keylesspalace.tusky.viewdata.calculatePercent import java.text.NumberFormat -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale import kotlin.math.min class StatusViewHelper(private val itemView: View) { + private val absoluteTimeFormatter = AbsoluteTimeFormatter() + interface MediaPreviewListener { fun onViewMedia(v: View?, idx: Int) fun onContentHiddenChange(isShowing: Boolean) } - private val shortSdf = SimpleDateFormat("HH:mm:ss", Locale.getDefault()) - private val longSdf = SimpleDateFormat("MM/dd HH:mm:ss", Locale.getDefault()) - fun setMediasPreview( statusDisplayOptions: StatusDisplayOptions, attachments: List, @@ -295,7 +291,7 @@ class StatusViewHelper(private val itemView: View) { context.getString(R.string.poll_info_closed) } else { if (useAbsoluteTime) { - context.getString(R.string.poll_info_time_absolute, getAbsoluteTime(poll.expiresAt)) + context.getString(R.string.poll_info_time_absolute, absoluteTimeFormatter.format(poll.expiresAt, false)) } else { TimestampUtils.formatPollDuration(context, poll.expiresAt!!.time, timestamp) } @@ -330,18 +326,6 @@ class StatusViewHelper(private val itemView: View) { } } - fun getAbsoluteTime(time: Date?): String { - return if (time != null) { - if (android.text.format.DateUtils.isToday(time.time)) { - shortSdf.format(time) - } else { - longSdf.format(time) - } - } else { - "??:??:??" - } - } - companion object { val COLLAPSE_INPUT_FILTER = arrayOf(SmartLengthInputFilter) val NO_INPUT_FILTER = arrayOfNulls(0) diff --git a/app/src/main/java/com/keylesspalace/tusky/util/VersionUtils.java b/app/src/main/java/com/keylesspalace/tusky/util/VersionUtils.java deleted file mode 100644 index dceef0f3..00000000 --- a/app/src/main/java/com/keylesspalace/tusky/util/VersionUtils.java +++ /dev/null @@ -1,44 +0,0 @@ -/* Copyright 2019 kyori19 - * - * 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 androidx.annotation.NonNull; - -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -public class VersionUtils { - - private int major; - private int minor; - private int patch; - - public VersionUtils(@NonNull String versionString) { - String regex = "([0-9]+)\\.([0-9]+)\\.([0-9]+).*"; - Pattern pattern = Pattern.compile(regex); - Matcher matcher = pattern.matcher(versionString); - if (matcher.find()) { - major = Integer.parseInt(matcher.group(1)); - minor = Integer.parseInt(matcher.group(2)); - patch = Integer.parseInt(matcher.group(3)); - } - } - - public boolean supportsScheduledToots() { - return (major == 2) ? ( (minor == 7) ? (patch >= 0) : (minor > 7) ) : (major > 2); - } - -} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.kt b/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.kt index 52d9713f..fef9c0bb 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.kt @@ -27,12 +27,9 @@ fun Status.toViewData( isExpanded: Boolean, isCollapsed: Boolean ): StatusViewData.Concrete { - val visibleStatus = this.reblog ?: this - return StatusViewData.Concrete( status = this, isShowingContent = isShowingContent, - isCollapsible = shouldTrimStatus(visibleStatus.content), isCollapsed = isCollapsed, isExpanded = isExpanded, ) diff --git a/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.kt b/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.kt index d8f27157..8ac212d9 100644 --- a/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.kt +++ b/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.kt @@ -15,9 +15,11 @@ package com.keylesspalace.tusky.viewdata import android.os.Build -import android.text.SpannableStringBuilder import android.text.Spanned import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.util.parseAsMastodonHtml +import com.keylesspalace.tusky.util.replaceCrashingCharacters +import com.keylesspalace.tusky.util.shouldTrimStatus /** * Created by charlag on 11/07/2017. @@ -32,13 +34,6 @@ sealed class StatusViewData { val status: Status, val isExpanded: Boolean, val isShowingContent: Boolean, - /** - * Specifies whether the content of this post is allowed to be collapsed or if it should show - * all content regardless. - * - * @return Whether the post is collapsible or never collapsed. - */ - val isCollapsible: Boolean, /** * Specifies whether the content of this post is currently limited in visibility to the first * 500 characters or not. @@ -51,6 +46,14 @@ sealed class StatusViewData { override val id: String get() = status.id + /** + * Specifies whether the content of this post is allowed to be collapsed or if it should show + * all content regardless. + * + * @return Whether the post is collapsible or never collapsed. + */ + val isCollapsible: Boolean + val content: Spanned val spoilerText: String val username: String @@ -74,45 +77,17 @@ sealed class StatusViewData { init { if (Build.VERSION.SDK_INT == 23) { // https://github.com/tuskyapp/Tusky/issues/563 - this.content = replaceCrashingCharacters(status.actionableStatus.content) + this.content = replaceCrashingCharacters(status.actionableStatus.content.parseAsMastodonHtml()) this.spoilerText = replaceCrashingCharacters(status.actionableStatus.spoilerText).toString() this.username = replaceCrashingCharacters(status.actionableStatus.account.username).toString() } else { - this.content = status.actionableStatus.content + this.content = status.actionableStatus.content.parseAsMastodonHtml() this.spoilerText = status.actionableStatus.spoilerText this.username = status.actionableStatus.account.username } - } - - companion object { - private const val SOFT_HYPHEN = '\u00ad' - private const val ASCII_HYPHEN = '-' - fun replaceCrashingCharacters(content: Spanned): Spanned { - return replaceCrashingCharacters(content as CharSequence) as Spanned - } - - fun replaceCrashingCharacters(content: CharSequence): CharSequence? { - var replacing = false - var builder: SpannableStringBuilder? = null - val length = content.length - for (index in 0 until length) { - val character = content[index] - - // If there are more than one or two, switch to a map - if (character == SOFT_HYPHEN) { - if (!replacing) { - replacing = true - builder = SpannableStringBuilder(content, 0, index) - } - builder!!.append(ASCII_HYPHEN) - } else if (replacing) { - builder!!.append(character) - } - } - return if (replacing) builder else content - } + this.isCollapsible = shouldTrimStatus(this.content) } /** Helper for Java */ diff --git a/app/src/main/java/com/keylesspalace/tusky/viewmodel/EditProfileViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/viewmodel/EditProfileViewModel.kt index f3539f8d..17aa38c7 100644 --- a/app/src/main/java/com/keylesspalace/tusky/viewmodel/EditProfileViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/viewmodel/EditProfileViewModel.kt @@ -20,6 +20,7 @@ import android.net.Uri import androidx.core.net.toUri import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.ProfileEditedEvent import com.keylesspalace.tusky.entity.Account @@ -31,8 +32,7 @@ import com.keylesspalace.tusky.util.Loading import com.keylesspalace.tusky.util.Resource import com.keylesspalace.tusky.util.Success import com.keylesspalace.tusky.util.randomAlphanumericString -import io.reactivex.rxjava3.disposables.CompositeDisposable -import io.reactivex.rxjava3.kotlin.addTo +import kotlinx.coroutines.launch import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MultipartBody import okhttp3.RequestBody @@ -40,9 +40,7 @@ import okhttp3.RequestBody.Companion.asRequestBody import okhttp3.RequestBody.Companion.toRequestBody import org.json.JSONException import org.json.JSONObject -import retrofit2.Call -import retrofit2.Callback -import retrofit2.Response +import retrofit2.HttpException import java.io.File import javax.inject.Inject @@ -63,24 +61,20 @@ class EditProfileViewModel @Inject constructor( private var oldProfileData: Account? = null - private val disposables = CompositeDisposable() - - fun obtainProfile() { + fun obtainProfile() = viewModelScope.launch { if (profileData.value == null || profileData.value is Error) { profileData.postValue(Loading()) - mastodonApi.accountVerifyCredentials() - .subscribe( - { profile -> - oldProfileData = profile - profileData.postValue(Success(profile)) - }, - { - profileData.postValue(Error()) - } - ) - .addTo(disposables) + mastodonApi.accountVerifyCredentials().fold( + { profile -> + oldProfileData = profile + profileData.postValue(Success(profile)) + }, + { + profileData.postValue(Error()) + } + ) } } @@ -151,34 +145,34 @@ class EditProfileViewModel @Inject constructor( return } - mastodonApi.accountUpdateCredentials( - displayName, note, locked, avatar, header, - field1?.first, field1?.second, field2?.first, field2?.second, field3?.first, field3?.second, field4?.first, field4?.second - ).enqueue(object : Callback { - override fun onResponse(call: Call, response: Response) { - val newProfileData = response.body() - if (!response.isSuccessful || newProfileData == null) { - val errorResponse = response.errorBody()?.string() - val errorMsg = if (!errorResponse.isNullOrBlank()) { - try { - JSONObject(errorResponse).optString("error", null) - } catch (e: JSONException) { + viewModelScope.launch { + mastodonApi.accountUpdateCredentials( + displayName, note, locked, avatar, header, + field1?.first, field1?.second, field2?.first, field2?.second, field3?.first, field3?.second, field4?.first, field4?.second + ).fold( + { newProfileData -> + saveData.postValue(Success()) + eventHub.dispatch(ProfileEditedEvent(newProfileData)) + }, + { throwable -> + if (throwable is HttpException) { + val errorResponse = throwable.response()?.errorBody()?.string() + val errorMsg = if (!errorResponse.isNullOrBlank()) { + try { + JSONObject(errorResponse).optString("error", "") + } catch (e: JSONException) { + null + } + } else { null } + saveData.postValue(Error(errorMessage = errorMsg)) } else { - null + saveData.postValue(Error()) } - saveData.postValue(Error(errorMessage = errorMsg)) - return } - saveData.postValue(Success()) - eventHub.dispatch(ProfileEditedEvent(newProfileData)) - } - - override fun onFailure(call: Call, t: Throwable) { - saveData.postValue(Error()) - } - }) + ) + } } // cache activity state for rotation change @@ -208,15 +202,11 @@ class EditProfileViewModel @Inject constructor( return File(application.cacheDir, filename) } - override fun onCleared() { - disposables.dispose() - } - - fun obtainInstance() { + fun obtainInstance() = viewModelScope.launch { if (instanceData.value == null || instanceData.value is Error) { instanceData.postValue(Loading()) - mastodonApi.getInstance().subscribe( + mastodonApi.getInstance().fold( { instance -> instanceData.postValue(Success(instance)) }, @@ -224,7 +214,6 @@ class EditProfileViewModel @Inject constructor( instanceData.postValue(Error()) } ) - .addTo(disposables) } } } diff --git a/app/src/main/res/drawable/bot_badge.xml b/app/src/main/res/drawable/bot_badge.xml new file mode 100644 index 00000000..6f857df5 --- /dev/null +++ b/app/src/main/res/drawable/bot_badge.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_briefcase.xml b/app/src/main/res/drawable/ic_briefcase.xml index eeb80619..6df5b88b 100644 --- a/app/src/main/res/drawable/ic_briefcase.xml +++ b/app/src/main/res/drawable/ic_briefcase.xml @@ -4,6 +4,6 @@ android:viewportHeight="24" android:viewportWidth="24"> \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_edit_24dp.xml b/app/src/main/res/drawable/ic_edit_24dp.xml new file mode 100644 index 00000000..2844bafe --- /dev/null +++ b/app/src/main/res/drawable/ic_edit_24dp.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/layout/activity_account.xml b/app/src/main/res/layout/activity_account.xml index 78865410..28e12cad 100644 --- a/app/src/main/res/layout/activity_account.xml +++ b/app/src/main/res/layout/activity_account.xml @@ -112,7 +112,7 @@ app:layout_constraintStart_toStartOf="@id/guideAvatar" app:layout_constraintTop_toTopOf="@+id/accountFollowButton" /> - - - - - + tools:visibility="visible"> - + - + - + + + + + + app:layout_constraintTop_toBottomOf="@id/accountMovedView"> + app:layout_constraintTop_toBottomOf="@id/accountMovedView"> + app:layout_constraintTop_toBottomOf="@id/accountMovedView"> - - + android:layout_height="wrap_content" /> + + diff --git a/app/src/main/res/layout/dialog_emojicompat.xml b/app/src/main/res/layout/dialog_emojicompat.xml deleted file mode 100644 index 6850e045..00000000 --- a/app/src/main/res/layout/dialog_emojicompat.xml +++ /dev/null @@ -1,36 +0,0 @@ - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/item_account.xml b/app/src/main/res/layout/item_account.xml index a7b3a0ef..c1565e09 100644 --- a/app/src/main/res/layout/item_account.xml +++ b/app/src/main/res/layout/item_account.xml @@ -32,7 +32,7 @@ tools:src="#000" tools:visibility="visible" /> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - تم الاحتفاظ بنسخة مِن التبويق في مسوداتك
حرر لا يحتوي مثيل خادومكم %s على أية حزمة إيموجي مخصصة - تم نسخه إلى الحافظة نوع الإيموجي الإفتراضي في النظام يجب عليك أولا تنزيل حزمة الإيموجي هذه @@ -552,4 +551,10 @@ 180 يومًا 365 يومًا تحرير منشور + حسابات جديدة + لِج + قام %s بإنشاء حساب + أحدهم أنشأ حسابا جديدا + منشورات تم تعديلها + قام %s بتعديل منشوره \ No newline at end of file diff --git a/app/src/main/res/values-bg/strings.xml b/app/src/main/res/values-bg/strings.xml index a6ca7a65..092db218 100644 --- a/app/src/main/res/values-bg/strings.xml +++ b/app/src/main/res/values-bg/strings.xml @@ -123,7 +123,6 @@ Извършва се търсене… По подразбиране от системата Стил на емоджи - Копирано в клипборда Инстанцията ви %s няма персонализирани емоджита Композиране Копие от публикацията е запазено във вашите чернови diff --git a/app/src/main/res/values-bn-rBD/strings.xml b/app/src/main/res/values-bn-rBD/strings.xml index 79ecc214..8354954e 100644 --- a/app/src/main/res/values-bn-rBD/strings.xml +++ b/app/src/main/res/values-bn-rBD/strings.xml @@ -48,7 +48,6 @@ আপনাকে প্রথমে এই ইমোজি সেটগুলি ডাউনলোড করতে হবে সিস্টেমের ডিফল্ট ইমোজি স্টাইল - ক্লিপবোর্ডে অনুলিপি করা হয়েছে আপনার ইনস্ট্যান্স %s এর কোনো কাস্টম ইমোজিস নেই রচনা টুট এর একটি কপি আপনার ড্রাফটে সংরক্ষণ করা হয়েছে diff --git a/app/src/main/res/values-bn-rIN/strings.xml b/app/src/main/res/values-bn-rIN/strings.xml index 92390c48..1233cc2b 100644 --- a/app/src/main/res/values-bn-rIN/strings.xml +++ b/app/src/main/res/values-bn-rIN/strings.xml @@ -304,7 +304,6 @@ টুট এর একটি কপি আপনার ড্রাফটে সংরক্ষণ করা হয়েছে রচনা আপনার ইনস্ট্যান্স %s এর কোনো কাস্টম ইমোজিস নেই - ক্লিপবোর্ডে অনুলিপি করা হয়েছে ইমোজি স্টাইল সিস্টেমের ডিফল্ট আপনাকে প্রথমে এই ইমোজি সেটগুলি ডাউনলোড করতে হবে diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index 27fa8122..40c74c9b 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -20,8 +20,8 @@ Notificacions Local Federació - Toot - Posts + Fil + Publicacions Seguits Seguidors Preferits @@ -31,19 +31,19 @@ Edita el perfil Esborranys \@%s - %s tootejat + %s ha impulsat Contingut sensible Fes clic per a visualitzar-lo Mostra\'n més Mostra\'n menys No hi res aquí. Llisca avall per a actualitzar! - %s ha impulsat el teu toot - %s ha marcat com a preferit el teu toot + %s ha impulsat la teva publicació + %s ha marcat com a preferida la teva publicació %s et segueix Denuncia @%s Cap comentari addicional? Respon - Retooteja + Impulsa Preferit Més Escriure @@ -55,8 +55,8 @@ Deixa de blocar Denuncia Elimina - TOOT - TOOT! + PUBLICA + PUBLICA! Torna a intentar-ho Tanca Perfil @@ -83,8 +83,8 @@ Esborranys S\'està baixant %1$s Copia l\'enllaç - Comparteix l\'URL del toot a… - Comparteix el toot a… + Comparteix l\'URL de la publicació a… + Comparteix la publicació a… Enviat! Usuari desblocat Usuari sense silenciar @@ -132,10 +132,10 @@ Amaga el botó de redacció en desplaçament Filtre de la cronologia Pestanyes - Mostra els retoots + Mostra els impulsos Mostra les respostes Mostra les previsualitzacions - Privacitat predeterminada dels toots + Privacitat per defecte de les publicacions Publicació Pública Sense llistar @@ -145,7 +145,7 @@ Notificacions sobre mencions noves Seguidors nous Notificacions sobre nous seguidors - Retoots + Impulsos Notificacions si retootejents els teus toots Preferits Notificacions si marquen com a preferits els teus toots @@ -172,8 +172,8 @@ https://git.chinwag.org/chinwag/chinwag-android/issues
Perfil del Tusky - Comparteix el contingut del toot - Comparteix l\'enllaç al toot + Comparteix el contingut de la publicació + Comparteix l\'enllaç a la publicació Imatges Vídeo @@ -193,7 +193,7 @@ En resposta a @%s carrega\'n més Vota - S\'ha produït un error en enviar el tut. + S\'ha produït un error en publicar. Pestanyes Llicències Amplia @@ -209,11 +209,11 @@ Multimèdia amagada Amaga Estàs segur de tancar la sessió de %1$s\? - Amaga els retoots + Amaga els impulsos Mostra els impulsos Elimina i reecririu Obre el menú - Visibilitat del toot + Visibilitat de la publicació Contingut sensible Afegir una pestanya Enllaços @@ -227,17 +227,17 @@ Baixa el fitxer Compartir la imatge a … Enviat! - S\'ha enviat la petició de seguiment + Petició enviada Amb respostes Teclat d\'emojis Obrir el media #%d - Obrir com %s + Obre com a %s S\'està Descarregant media Resposta enviada correctament. Resposta … Revocar la petició de seguiment\? Vols eliminar aquest toot\? - Esborrar i reescriure aquest toot\? + Vols eliminar i reescriure aquesta publicació\? Finalització de les enquetes Tema Cronologia @@ -267,7 +267,7 @@ Eliminar Afegir un compte Obre l\'autor de l\'impuls - Mostra els retoots + Mostra els impulsos Notificacions d\'enquestes que han finalitzat Línia de temps públiques Actualització @@ -297,20 +297,19 @@ Protegir el compte S\'haurà d\'admetre els seguidors manualment Guardar l\'esborrany\? - Enviant toot… - Error enviant el toot - Enviant toots + S\'està publicant… + Error en publicar + S\'esatan enviant les publicacions Envio anul·lat - Una copia del toot s\'ha guardat a esborranys + S\'ha guardat una còpia de la publicació als esborranys Escriure La teva instància %s no te emojis personalitzats - Copia al porta papers Estil dels emojis Sistema per defecte Hauràs de descarregar el joc d\'emojis Cercant… Expandir/ocultar tots els estats - Obrir toot + Obre la publicació Cal reiniciar l\'aplicació Has de reiniciar l\'aplicació per tal d\'aplicar aquests canvis Més tard @@ -361,7 +360,7 @@ Netejar Filtrar Aplicar - Escriure un toot + Escriure una publicació Escriure Mostra l\'indicador dels bots Vols netejar totes les notificacions permanentment\? @@ -375,8 +374,8 @@ L\'enquesta on has votat està tancada La enquesta que heu creat ha finalitzat Advertència: %s - Toot fixat - Toot no fixat + Fixat + No fixis Fixar Respost Accions per a la imatge %s @@ -386,7 +385,7 @@ Silenciar %s %s visible Amagar el domini sencer - Obrir sempre els toots marcats amb contingut sensible + Mostra sempre obertes les publicacions marcades amb avisos de contingut Paraula sencera Ventall actual d\'emojis de Google Enquesta amb opcions: %1$s, %2$s, %3$s, %4$s; %5$s @@ -419,12 +418,12 @@ Múltiples tries Tria %d Preferits - Toots programats + Publicacions programades Preferit Edita Preferits - Toots programats - Programar el toot + Publicacions programades + Programa la publicació Reiniciar Desenvolupat per Tusky S\'ha afegit a les adreces d\'interès @@ -450,7 +449,7 @@ Silenciar @%s\? Bloquejar @%s\? No silenciar la conversació - Conversació muda + Silencia la conversa %s ha sol·licitat seguir-te A baix A dalt @@ -488,15 +487,15 @@ Adjuncions Àudio Notificacions quan algú a qui esteu subscrit publica un tut nou - Tuts nous + Publicacions noves emojis personalitzats animats algú a qui estic subscrit acaba de publicar un tut nou %s acaba de fer una publicació Avisos - S\'ha esborrat el tut del qual en vau fer un esborrany de resposta + S\'ha eliminat la publicació a la qual vau fer un esborrany de resposta S\'ha eliminat l\'esborrany No s\'ha pogut carregar la informació de la resposta - No s\'ha pogut enviar aquest tut! + No s\'ha pogut publicar! Segur que voleu esborrar la llista %s\? No podeu pujar més de %1$d adjunts multimèdia. diff --git a/app/src/main/res/values-ckb/strings.xml b/app/src/main/res/values-ckb/strings.xml index 8dd86758..ba275b0b 100644 --- a/app/src/main/res/values-ckb/strings.xml +++ b/app/src/main/res/values-ckb/strings.xml @@ -23,7 +23,7 @@ هاشتاگی پیشاندانی دڵخوازەکان پیشاندانی بەهێزکردنەکان - کردنەوەی بەهێزکردنی نووسەر + پۆستکەرەوەکە ببینە هاشتاگ ئاماژەکان بەستەرەکان @@ -57,14 +57,14 @@ وێنە بگرە زیادکردنی ڕاپرسی زیادکردنی میدیا - کردنەوە لە وێبگەڕ + لە وێبگەڕ بیکەوە میدیا بەدواداچونی داواکاریەکان بکە دۆمەینە شاراوەکان بەکارهێنەرە بلۆککراوەکان بەکارهێنەرە گۆڕاوەکان نیشانەکان - دڵخوازەکان + بەدڵبوونەکان پەسەندکراوەکانی ئەژمێر پەسەندەکان پرۆفایل @@ -76,12 +76,12 @@ سڕینەوە دەستکاری گوزارشەکان - پیشاندانی بەهێزکردنەکان + پۆستکردنەوەکان نیشان بدە شاردنەوەی بەهێزکردنەکان بەربەست کردن لاببە بلۆک بەدوادانەچو - بەدواداکەوتن + شوێنی بکەوە ئایا دڵنیایت لەوەی دەتەوێت بچیتەدەرەوە لە هەژماری %1$s؟ چوونەدەرەوە چوونەژوورەوە لەگەڵ ماستۆدۆن @@ -90,7 +90,7 @@ لابردنی دڵخوازەکان نیشانه دڵخواز - لابردنی بەهێزکردن + پۆستکردنەوەکە بگەڕێنەوە بەهێزکردن وەڵام وەڵامدانەوەی خێرا @@ -110,7 +110,7 @@ کرتە بکە بۆ بینین میدیا شاراوە ناوەڕۆکی هەستیار - %s بەرزکرا + %s پۆستی کردەوە \@%s مۆڵەتەکان ڕاگه یه نراوەکان @@ -122,12 +122,12 @@ بەکارهێنەرە بێدەنگ نیشانەکان دڵخوازەکان - شوێنکەوتوان - بەدوادا + شوێنکەوتوو + شوێنکەوتنەکان چەسپا لەگەڵ وەڵامەکان - بابەتەکان - توت + پۆست + زنجیرە سەرخشتەکان نامە ڕاستەوخۆکان گشتی @@ -140,19 +140,19 @@ مۆڵەت بۆ پاشکەوتکردنی میدیا پێویستە. مۆڵەت بۆ خوێندنەوەی میدیا پێویستە. ئەم فایلە ناتوانرێت بکرێتەوە. - ناتوانرێت ئەو جۆرە فایلە باربکرێت. - فایلەکانی دەنگ دەبێت کەمتر بێت لە ٤٠MB. - پێویستە فایلەکانی ڤیدیۆ کەمتر لە 40 مێگابایت بن. - فایلەکە دەبێت کەمتر بێت لە 8 مێگابایت. - ڕەستە زۆر درێژە! + ناتوانیت لەم جۆرە فایلانە بەرز بکەیتەوە. + دەبێت فایلە دەنگییەکان لە 40 مێگابایت گەورەتر نەبن. + دەبێت ڤیدیۆکان لە 40 مێگابایت گەورەتر نەبن. + فایلەکە دەبێت لە 8 مێگابایت بچووکتر بێت. + ئەم نووسینە زۆر درێژە! سەرکەوتوو نەبوو لە بەدەستهێنانی نیشانەی چوونەژوورەوە. ڕێپێدان ڕەتکرایەوە. هەڵەیەک بۆ مۆڵەتدانی نەناسراو ڕووی دا. نەیتوانی وێبگەڕبدۆزێتەوە بۆ بەکارهێنان. سەرکەوتوو نەبوو، ڕاستکردنەوە لەگەڵ ئەم نمونەیە. - دۆمەینی نادروست تێنووسکرا - ئەمە ناتوانێت بەتاڵ بێت. - هەڵەیەک لە تۆڕ ڕوویدا! تکایە پەیوەندیت بپشکنە و دوبارە هەوڵ بدە! + دۆمەینێکی نادروستت نووسیوە + ناکرێت ئەمە بەتاڵ بێت. + هەڵەیەک لە پەیوەندییەکەدا ڕوویدا. تکایە دڵنیا ببەوە لە بەردەستبوونی هێڵی ئینتەرنێت. هەڵەیەک ڕوویدا. تایبەتمەندی بابەت گریمانەیی دەرگای پرۆکسی HTTP @@ -249,7 +249,7 @@ \n \nکارتێکردنی ئاگانامەکانی پاڵپێوەنان، بەڵام دەتوانیت بە پەسەندکردنە ئاگانامەکانت دا بخشێنیەوە بە دەستی.
ڕزگارکرا - تێبینی تایبەتی تۆ دەربارەی ئەم ئەژمێرە + تێبینیی تایبەتیت بۆ ئەم هەژمارە Wellbeing شاردنەوەی ناونیشانی شریتی ئامڕازی سەرەوە پیشاندانی دیالۆگی دووپاتکردنەوە پێش بەهێزکردن @@ -294,7 +294,7 @@ ڕاپرسییەک کە دروستت کردووە کۆتایی هات ڕاپرسییەک کە دەنگی پێداویت کۆتایی هات دەنگ - داخراوە + کۆتایی هاتووە کۆتایی دێت لە %s %s کەس @@ -336,10 +336,10 @@ %1$s و %2$s %1$s پەسەندکراوە لەلایەن - بەرزکراوە لەلایەن + پۆست کراوەتەوە لەلایەن - %s بەهێزکردن - %s بەهێزکردن + %s پۆستکردنەوە + %s پۆستکردنەوە %1$s دڵخواز @@ -375,7 +375,6 @@ تۆ پێویستە سەرەتا ئەم سێتە ئیمۆجییانە دابگریت سیستەمی بنەڕەت شێوازی ئیمۆجی - ڕوونووسکراوە بۆ کلیپ بۆرد نموونەکەت %s هیچ ئیمۆجییەکی ئاسایی نییە دروستکردن کۆپیەکی دەستنووسەکە خەزن کراوە بۆ ڕەشنووسەکانت diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 7840aa9c..badd5f21 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -302,7 +302,6 @@ Kopie vašeho tootu byla uložena do vašich konceptů Napsat Vaše instance %s nemá žádná vlastní emoji - Zkopírováno do schránky Styl emoji Výchozí nastavení systému Musíte si nejprve stáhnout tyto sady emoji diff --git a/app/src/main/res/values-cy/strings.xml b/app/src/main/res/values-cy/strings.xml index ca22d127..4b9d9a62 100644 --- a/app/src/main/res/values-cy/strings.xml +++ b/app/src/main/res/values-cy/strings.xml @@ -251,7 +251,6 @@ Cadwyd copi o\'r tŵt i\'ch drafftiau Creu Nid oes gan eich achos %s emoji bersonol - Copïwyd i\'r clipfwrdd Arddull emoji Rhagosodiad system Bydd angen i chi lawrlwytho\'r setiau emoji hyn yn gyntaf diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index fef59401..fe8f871a 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -25,7 +25,7 @@ Föderiert Direktnachrichten Tabs - Beitrag + Konversation Beiträge mit Antworten Angeheftet @@ -208,7 +208,7 @@ Neue Erwähnungen Benachrichtigungen über neue Erwähnungen Neue Folgende - Benachrichtigunen über neue Folgende + Benachrichtigungen über neue Folgende Geteilte Beiträge Benachrichtigungen, wenn deine Beiträge geteilt werden Favorisierte Beiträge @@ -279,7 +279,6 @@ Eine Kopie des Beitrags wurde in deine Entwürfe gespeichert Beitrag erstellen Deine Instanz %s hat keine Emojis definiert - In die Zwischenablage kopiert Emoji-Stil System-Standard Du musst diese Emoji-Sets zunächst herunterladen @@ -491,7 +490,7 @@ Für immer Anhänge Audio - Benachrichtigungen, wenn jemand, den ich abonniert habe, etwas Neues veröffentlicht + Benachrichtigungen, wenn jemand, den ich abonniert habe, eine neue Nachricht veröffentlicht Neue Beiträge GIF-Emojis animieren Jemand, den ich abonniert habe, hat etwas Neues veröffentlicht @@ -528,4 +527,14 @@ 14 Tage 180 Tage Beitrag erstellen + %s hat den Beitrag bearbeitet + Ein Beitrag, mit dem ich interagiert habe, wurde bearbeitet + Registrierungen + Benachrichtigungen über neue Profile + %s hat sich registriert + Jemand hat sich registriert + Benachrichtigungen, wenn Beiträge bearbeitet werden, mit denen du interagiert hast + Anmelden + Die Anmeldeseite konnte nicht geladen werden. + Beitragsbearbeitungen \ No newline at end of file diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index 4734fea9..85583a2b 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -3,4 +3,106 @@ Αυτό δεν μπορεί να είναι κενό. Προέκυψε σφάλμα δικτύου! Παρακαλώ ελέγξτε τη σύνδεσή σας και προσπαθήστε ξανά! Προέκυψε ένα σφάλμα. + Αποκλεισμένοι χρήστες + Ακύρωση αιτήματος ακολούθησης; + Διαγραφή αυτής της συζήτησης; + Δεν υπάρχουν αποτελέσματα + Επεργασία προφίλ + Ακολουθεί + Επαναφορά + ο/η %s σας ακολούθησε + Χρήστες σε σίγαση + Αποσύνδεση + Μην ακολουθείτε + Άρση σίγασης του %s + Διαγραφή και αναδιατύπωση + Επεξεργασία προφίλ + Κοινοποίηση + Άδειες + Ανοίξτε σε browser + Αιτήματα ακολούθησης + Προσθήκη σελιδοδείκτη + Περισσότερα + Σελιδοδείκτες + Σελιδοδείκτες + Ακόλουθοι + Άρση αποκλεισμού + Αγαπημένα + Η δημοσίευση είναι πολύ μεγάλη! + Πληκτρολόγιο emoji + Είστε σίγουροι ότι θέλετε να αποσυνδεθείτε από τον λογαριασμό %1$s; + Προσχέδια + Αγαπημένα + Απάντηση… + Απόρριψη + Αποκλεισμένοι χρήστες + Αφαίρεση προώθησης + Επεξεργασία + Σίγαση του %s + Αποκλεισμός + Αναίρεση + ο/η %s ζήτησε να σας ακολουθήσει + Απάντηση + Καρτέλες + ο/η %s προώθησε τη δημοσίευσή σας + στον/στην %s άρεσε η δημοσίευσή σας + Ακολουθήστε + Αναφορά + Σίγαση + Τα μουσικά αρχεία πρέπει να είναι μικρότερα από 40MB. + Αφαίρεση αγαπημένου + Αναφορά του/της %s + Προτιμήσεις Λογαριασμού + Προσθήκη καρτέλας + Αντιγραφή συνδέσμου + Αναζήτηση… + Αποδοχή + Εμφάνιση προωθήσεων + Προφίλ + Αιτήματα ακολούθησης + Αναζήτηση + Διαγραφή συζήτησης + Διαγραφή + ο/η %s μόλις δημοσίευσε + Αποθήκευση + Γρήγορη Απάντηση + Χρήστες σε σίγαση + Το αρχείο πρέπει να είναι μικρότερο από 8MB. + Απόκρυψη προωθήσεων + Προτιμήσεις + Σύνδεση + Ανακοινώσεις + Προσχέδια + ο/η %s έκανε εγγραφή + Προσπαθήστε ξανά + Διαγραφή αυτής της δημοσίευσης; + Άρση σίγασης + Αγαπημένο + Σύνδεσμοι + Κλείσιμο + Ειδοποιήσεις + Γράψτε + Σύνδεση με Mastodon + Επεξεργασία + Προώθηση + Άρση ακολούθησης αυτού του λογαριασμού; + Απόκρυψη ειδοποιήσεων + Αφαίρεση σελιδοδείκτη + Προειδοποίηση περιεχομένου + Σύνδεσμοι + Σύνδεση… + Προγραμματισμένες δημοσιεύσεις + Προγραμματισμός δημοσίευσης + Προγραμματισμένες δημοσιεύσεις + Δημοσιεύσεις + Καρφιτσωμένο + Ευαίσθητο περιεχόμενο + Κρυμμένα μέσα + ο/η %s το προώθησε + Με απαντήσεις + Δείτε περισσότερα + Δείτε λιγότερα + Κλικ για να δείτε + ο/η %s επεξεργάστηκε τη δημοσίευσή του/της + Διαγραφή και αναδιατύπωση αυτής της δημοσίευσης; \ No newline at end of file diff --git a/app/src/main/res/values-eo/strings.xml b/app/src/main/res/values-eo/strings.xml index 5b47ccd0..891c96b8 100644 --- a/app/src/main/res/values-eo/strings.xml +++ b/app/src/main/res/values-eo/strings.xml @@ -299,7 +299,6 @@ Kopio de la mesaĝo estis konservita en viaj malnetoj Verki Via nodo %s ne havas proprajn emoĝiojn - Kopiita en tondujo Stilo de emoĝioj Sistema valoro Vi unue devos elŝuti ĉi tiujn emoĝiarojn diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 43b072a5..788d643f 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -269,7 +269,6 @@ Una copia del estado se ha guardado en borradores Redactar Su instancia %s no ofrece emojis personalizados - Copiado al portapapeles Estilo de los emojis Sistema Tendrás que descargarlos primero diff --git a/app/src/main/res/values-eu/strings.xml b/app/src/main/res/values-eu/strings.xml index 791575b7..fd0c057a 100644 --- a/app/src/main/res/values-eu/strings.xml +++ b/app/src/main/res/values-eu/strings.xml @@ -253,7 +253,6 @@ Tutaren kopia zirriborroetan sartu da Idatzi %s instantziak ez ditu emoji pertsonalizatuak eskaintzen - Arbelean kopiatua Emojien estiloa Sistema Lehenago jaitsi beharko dituzu diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index 880e0e4a..d0b2556d 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -248,7 +248,6 @@ رونوشتی از بوق در پیش‌نویس‌هایتان ذخیره شد ایجاد نمونه‌تان %s هیچ اموجی سفارشی‌ای ندارد - در تخته‌گیره رونوشت شد سبک اموجی پیش‌گزیدهٔ سامانه نخست باید این مجموعه‌های اموجی را بارگیری کنید diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index a7f320a4..987e777b 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -295,7 +295,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 @@ -304,7 +304,6 @@ Une copie du pouet a été sauvegardée dans vos brouillons Écrire Votre instance %s n’a pas d’émojis personnalisés - Copié dans le presse-papier Style d’émojis Par défaut du système Vous devez commencer par télécharger ces jeux d’émojis @@ -540,4 +539,13 @@ 14 jours 180 jours Rédiger un message + %s a créé un compte + Nouveaux comptes + Notifications quand quelqu\'un crée un nouveau compte + un nouveau compte a été créé + %s a modifié son message + un message avec lequel j\'ai interagi est modifié + Messages modifiés + Notifications quand un post avec lequel vous avez interagi est modifié + Se connecter \ No newline at end of file diff --git a/app/src/main/res/values-fy/strings.xml b/app/src/main/res/values-fy/strings.xml index 97ad4052..f902d742 100644 --- a/app/src/main/res/values-fy/strings.xml +++ b/app/src/main/res/values-fy/strings.xml @@ -4,7 +4,6 @@ Dit mei net leech wêze. Systeem standert Emoji styl - Nei it klemboerd kopiearre Gearstelle Ferstjoeren ôfbrutsen Toots oan it ferstjoeren diff --git a/app/src/main/res/values-ga/strings.xml b/app/src/main/res/values-ga/strings.xml index 9342edf9..121ed937 100644 --- a/app/src/main/res/values-ga/strings.xml +++ b/app/src/main/res/values-ga/strings.xml @@ -339,7 +339,6 @@ Sábháladh cóip den tút ar do dhréachtaí Cum Níl aon emojis saincheaptha ag do shampla %s - Cóipeáladh chuig an gearrthaisce Stíl Emoji Réamhshocrú an chórais Beidh ort na tacair emoji seo a íoslódáil ar dtús diff --git a/app/src/main/res/values-gd/strings.xml b/app/src/main/res/values-gd/strings.xml index bf2a2d52..317331dc 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! @@ -239,7 +239,6 @@ Feumaidh tu na seataichean seo de dh’Emojis a luchdadh a-nuas an toiseach Bun-roghainn an t-siostaim Stoidhle nan Emojis - Chaidh lethbhreac dheth a chur air an stòr-bhòrd Chan eil Emojis gnàthaichte aig an ionstans %s agad Chaidh lethbhreac dhen phost agad a shàbhaladh ’na dhreachd Chaidh sgur dhen chur @@ -249,10 +248,16 @@ A bheil thu airson a shàbhaladh ’na dhreachd\? Feumaidh tu gabhail ri luchd-leantainn ùr a làimh Glais an cunntas - Suidhidh am fo-thiotal + Suidhich am fo-thiotal Mìnich e dhan fheadhainn air a bheil cion-lèirsinn -\n(%d caractar(an) air a char as fhaide) +\n(%d charactar air a char as fhaide) + Mìnich e dhan fheadhainn air a bheil cion-lèirsinn +\n(%d charactar air a char as fhaide) + Mìnich e dhan fheadhainn air a bheil cion-lèirsinn +\n(%d caractaran air a char as fhaide) + Mìnich e dhan fheadhainn air a bheil cion-lèirsinn +\n(%d caractar air a char as fhaide) Cha deach leinn am fo-thiotal a shuidheachadh A’ postadh leis a’ chunntas %1$s @@ -290,7 +295,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 @@ -323,7 +328,7 @@ an ceann %du an ceann %dl an ceann %db - Iarrar leantainn orm + Iarrtas leantainn air Videothan Dealbhan Pròifil Tusky @@ -541,4 +546,15 @@ 14 làithean 60 latha Sgrìobh post + Chlàraich %s + Clàraidhean + Brathan mu cleachdaichean ùra + chlàraich cuideigin + Dheasaich %s am post aca + Deasachadh puist + Brathan nuair a thèid postaichean a rinn thu conaltradh leotha a dheasachadh + 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 bfabe4c8..5b488df3 100644 --- a/app/src/main/res/values-gl/strings.xml +++ b/app/src/main/res/values-gl/strings.xml @@ -247,7 +247,6 @@ Deberás descargar primeiro estos conxuntos de emojis Por defecto no sistema Estilo dos emoji - Copiado ao portapapeis A túa instancia %s non ten emojis personalizados Redactar Gardouse unha copia do toot nos borradores @@ -519,4 +518,8 @@ 180 días 365 días Redactar publicación + %s rexistrouse + hai unha nova usuaria + Rexistros + Notificacións sobre novas usuarias \ No newline at end of file diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index 16882504..8f537f12 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -306,7 +306,6 @@ बाद में एप्लिकेशन को पुनः आरंभ की आवश्यकता है आपको पहले इस इमोजी सेट को डाउनलोड करना होगा - क्लिपबोर्ड पर कॉपी किया गया लिखें टूट की एक प्रति आपके ड्राफ्ट में सहेज ली गई है टूट भेजने में त्रुटि diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index 7371cbe7..728f9ae9 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -9,26 +9,26 @@ Azonosítatlan engedélyezési hiba történt. Engedély megtagadva. Bejelentkezési token megszerzése sikertelen. - Túl hosszú a tülkölés! + Túl hosszú a bejegyzés! A fájlnak kisebbnek kell lennie, mint 8 MB. A videofájloknak kisebbnek kell lenniük, mint 40 MB. Ilyen típusú fájlt nem lehet feltölteni. Fájl megnyitása sikertelen. Média olvasási engedély szükséges. Média tárolási engedély szükséges. - Képek és videók egyszerre nem csatolhatók ugyanazon tülköléshez. + Képek és videók egyszerre nem csatolhatóak ugyanazon bejegyzéshez. Feltöltés sikertelen. - Nem sikerült elküldeni a tülköt. + Nem sikerült elküldeni a bejegyzést. Kezdőlap Értesítések Helyi Föderációs Közvetlen üzenetek Fülek - Tülk - Tülkök + Szál + Bejegyzések Válaszokkal - Rögzített + Kitűzött Követett Követő Kedvencek @@ -40,17 +40,17 @@ Licenszek \@%s %s megtolta - Kényes tartalom + Érzékeny tartalom Rejtett média - Kattints a megnézéshez + Kattints a megtekintéshez Mutass többet Mutass kevesebbet Kibontás Összecsukás Nincs itt semmi. Üres tartalom. Húzd le a frissítéshez! - %s megtolta a tülködet - %s kedvencnek jelölte tülködet + %s megtolta a bejegyzésedet + %s kedvencnek jelölte a bejegyzésedet %s bekövetett \@%s jelentése Egyéb megjegyzés? @@ -100,7 +100,7 @@ Elutasítás Keresés Piszkozatok - Tülkök láthatósága + Bejegyzés láthatósága Tartalom figyelmeztetés Emoji billentyűzet Fül hozzáadása @@ -115,8 +115,8 @@ Link másolása Megnyitás mint %s Megosztás mint … - Tülk URL megosztása… - Tülk megosztása… + Bejegyzés URL megosztása… + Bejegyzés megosztása… Elküldve! Felhasználó letiltása feloldva Felhasználó némítása feloldva @@ -132,7 +132,7 @@ Válasz… Profilkép Fejléc - Mi az a szerver\? + Mi az a példány\? Csatlakozás… Bármely példány címét vagy domain nevét beírhatod ide, mint a mastodon.social, az icosahedron.website, a social.tchncs.de és mások! \n @@ -146,11 +146,11 @@ Letöltés Visszavonod a követési kérelmet? Követés megszüntetése? - Törlöd ezt a tülköt? - Nyilvános: Tülkölés nyilvános idővonalra + Törlöd ezt a bejegyzést\? + Nyilvános: Bejegyzés nyilvános idővonalra Listázatlan: Nem jelenik meg a nyilvános idővonalon - Csak követőknek: Tülkölés csak követőknek - Közvetlen: Tülkölés csak a megemlített felhasználóknak + Csak követőknek: Bejegyzés csak követőknek + Közvetlen: Bejegyzés csak a megemlített felhasználóknak Értesítések Értesítések Figyelmeztetések @@ -160,8 +160,8 @@ Értesítsen, ha megemlítettek bekövettek - tülkömet megtolták - tülkömet kedvenccé tették + bejegyzésemet megtolták + bejegyzésemet kedvencnek jelölték Megjelenés Idővonalak Sötét @@ -181,13 +181,13 @@ HTTP proxy engedélyezése HTTP proxy szerver HTTP Proxy port - Tülkök alapértelmezett láthatósága + Bejegyzések alapértelmezett láthatósága Minden média kényesnek jelölése A beállítások szinkronizálása nem sikerült Nyilvános Listázatlan Csak követőknek - Tülkölés szöveg mérete + Bejegyzés szövegének mérete Legkisebb Kicsi Közepes @@ -198,9 +198,9 @@ Új követők Értesítések új követőkről Megtolások - Értesítések tülkjeid megtolása esetén + Értesítések bejegyzéseid megtolása esetén Kedvencek - Értesítések mikor tülkjeidet kedvencnek jelölik + Értesítések amikor a bejegyzéseidet kedvencnek jelölik %s megemlített téged %1$s, %2$s, %3$s és még %4$d %1$s, %2$s meg %3$s @@ -224,10 +224,10 @@ Hibajelentés & új funkciók igénylése: \n https://git.chinwag.org/chinwag/chinwag-android/issues Tusky profilja - Tülk tartalmának megosztása - Tülk linkjének megosztása + Bejegyzés tartalmának megosztása + Bejegyzés hivatkozásának megosztása Képek - Videók + Videó Követés kérelmezve Követ téged @@ -241,19 +241,18 @@ Törlés Fiók zárolása Elmented a piszkozatot\? - Tülk elküldése… - A tülk elküldése nem sikerült - Tülkök elküldése + Bejegyzés küldése… + A bejegyzés elküldése sikertelen + Bejegyzések elküldése Küldés megszakítva - A tülk másolatát elmentettük a piszkozataid közé + A bejegyzés másolatát elmentettük a piszkozataid közé Szerkesztés - A %s szervernek nincsenek egyedi emoji-jai - Vágólapra másolva + A %s példánynak nincsenek egyedi emoji-jai Emoji stílus Rendszer alapértelmezés Először le kell töltened ezeket az emoji készleteket Keresés… - Tülk megnyitása + Bejegyzés megnyitása Az app újraindítása szükséges A beállítások érvényesítéséhez újra kell indítani a Tuskyt Később @@ -280,8 +279,7 @@ elérted a fülek maximális számát (%1$d) elérted a fülek maximális számát (%1$d) - Nincs leírás - + Nincs leírás Nyilvános Követők Kedvenc eltávolítása @@ -290,7 +288,7 @@ Média letöltése Média letöltése Média megosztása következővel… - Törlöd és újraírod ezt a tülköt\? + Törlöd és újraírod ezt a bejegyzést\? befejeződött egy szavazás Szűrők Rendszer téma használata @@ -342,7 +340,7 @@ Általad követettek keresése Fiók hozzáadása a listához Fiók eltávolítása a listából - Tülkölés %1$s fiókkal + Bejegyzés %1$s fiókkal Cím beállítása nem sikerült Leírás látássérülteknek @@ -350,7 +348,7 @@ Cím beállítása Minden követődet külön engedélyezned kell - Minden tülk kibontása/összecsukása + Összes bejegyzés kibontása/összecsukása A Google jelenlegi emodzsi készlete Megtolás az eredeti közönségnek Megtolás visszavonása @@ -368,7 +366,7 @@ %1$s %1$s, %2$s és még %3$d Média: %s - Tartalom figyelmeztetés: %s + Tartalomfigyelmeztetés: %s Megtolt Kedvelt Listázatlan @@ -379,7 +377,7 @@ Törlés Szűrés Alkalmaz - Tülk szerkesztése + Bejegyzés létrehozása Szerkesztés Biztos, hogy minden értesítésedet véglegesen törlöd\? Műveletek a(z) %s képpel @@ -416,11 +414,11 @@ Egyéb megjegyzések Továbbítás neki %s Nem sikerült a bejelentés - Nem sikerült a tülkök letöltése + Sikertelen a bejegyzések letöltése A bejelentést a szervered moderátorának küldjük el. Alább megadhatsz egy magyarázatot arra, hogy miért jelented be ezt a fiókot: A fiók egy másik szerverről származik. Küldjünk oda is egy anonimizált másolatot a bejelentésről\? Értesítések szűrőjének mutatása - Tartalom-figyelmeztetéssel ellátott tülkök kifejtése mindig + Tartalomfigyelmeztetéssel ellátott bejegyzések kinyitása mindig Fiókok Sikertelen keresés Szavazás hozzáadása @@ -436,12 +434,12 @@ Több lehetőség Válasz %d Szerkesztés - Időzített tülkök + Időzített bejegyzések Szerkesztés - Időzített tülkök - Tülk Időzítése + Időzített bejegyzések + Bejegyzés Időzítése Visszaállítás - Nem találjuk ezt a tülköt %s + Nem találjuk ezt a bejegyzést %s Könyvjelzők Könyvjelzőzés Könyvjelzők @@ -451,7 +449,7 @@ Lista A hangfájloknak kisebbnek kell lenniük, mint 40 MB. Nincs egy piszkozatod sem. - Nincs egy ütemezett tülköd sem. + Nincs egy ütemezett bejegyzésed sem. A Mastodonban a legrövidebb ütemezhető időintervallum 5 perc. Követési kérelmek Jóváhagyó ablak mutatása megtolás előtt @@ -484,34 +482,34 @@ Saját, mások számára nem látható megjegyzés erről a fiókról Nincsenek közlemények. Közlemények - A tülköt, melyre válaszul piszkozatot készítettél törölték + A bejegyzést, melyre válaszul piszkozatot készítettél törölték Piszkozat törölve Nem sikerült a Válasz információit betölteni - Ez a tülk nem küldődött el! + Ezt a bejegyzést nem tudtuk elküldeni! Tényleg le akarod törölni a %s listát\? Nem tölthetsz fel %1$d médiacsatolmányból többet. Nem tölthetsz fel %1$d médiacsatolmányból többet. Profilok mérőszámainak elrejtése - Tülkök mérőszámainak elrejtése + Bejegyzések mérőszámainak elrejtése Idővonali értesítések korlátozása Értesítések Áttekintése - Pár információ, ami befolyásolhatja a mentális egészségedet rejtve marad. Ilyenek pl.: + Pár információ, ami befolyásolhatja a mentális jóllétedet rejtve marad. Ilyenek pl.: \n \n - Kedvenc/Megtolás/Bekövetés értesítései -\n - Kedvenc/Megtolás számlálók a tülkökön -\n - Követő/Tülk statisztikák a profilokon +\n - Kedvenc/Megtolás számlálók a bejegyzéseken +\n - Követő/Bejegyzés statisztikák a profilokon \n \nA Push-értesítéseket ez nem befolyásolja, de kézzel átállíthatod az értesítési beállításaidat. Végtelen Időtartam Csatolmányok Audio - Értesítések általam követett személy új tülkjeiről - Új tülkök - valaki, akit követek újat tülkölt - %s épp tülkölt + Értesítések általam követett személy új bejegyzéseiről + Új bejegyzések + valaki, akit követek új bejegyzést tett közzé + %s épp bejegyzést írt Jóllét Egyedi emojik animálása Leiratkozás @@ -521,4 +519,19 @@ Beszélgetés törlése Könyvjelző törlése Jóváhagyás mutatása kedvencnek jelölés előtt + %s szerkesztette a bejegyzését + szerkesztették a bejegyzést, mellyel dolgod volt + %s regisztrált + valaki regisztrált + Regisztrációk + Értesítések új felhasználókról + 14 nap + 30 nap + 60 nap + 90 nap + 180 nap + 365 nap + Bejegyzések szerkesztése + Értesítések olyan bejegyzések szerkesztéséről, melyekkel már dolgod volt + Bejegyzés Létrehozása \ No newline at end of file diff --git a/app/src/main/res/values-is/strings.xml b/app/src/main/res/values-is/strings.xml index 2018dc85..f6241b86 100644 --- a/app/src/main/res/values-is/strings.xml +++ b/app/src/main/res/values-is/strings.xml @@ -317,7 +317,6 @@ Afrit af tístinu þínu hefur verið vistað drögunum þínum Semja skilaboð Tilvikið þitt %s er ekki með nein sérsniðin tjáningartákn - Afritað á klippispjald Stíll tjáningartákna Sjálfgefið í kerfinu Þú þarft fyrst að ná í þessi táknmyndasett @@ -519,4 +518,12 @@ 365 dagar 14 dagar Semja færslu + %s skráði sig + einhver skráði sig + %s breytti færslunni sinni + færsla sem ég hef átt við er breytt + Nýskráningar + Tilkynningar um nýja notendur + Breytingar á færslum + Tilkynningar þegar færslum sem þú hefur átt við er breytt \ No newline at end of file diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index af39264f..fcd93b10 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -2,33 +2,33 @@ Si è verificato un errore. Si è verificato un errore di rete! Per favore controlla la tua connessione e riprova! - Questo non può esser vuoto. - Inserito un dominio non valido - Autenticazione fallita con quell\'istanza. - Non riesco a trovare un browser web da usare. + Questo non può essere vuoto. + Inserito dominio non valido + Autenticazione con quell\'istanza fallita. + Nessun browser web utilizzabile trovato. Si è verificato un errore di autenticazione non identificato. - L\'autorizzazione è stata negata. - Errore nell\'acquisizione del token di accesso. - Lo stato è troppo lungo! - La dimensione dei file immagine deve essere inferiore a 8 MB. - La dimensione dei file video deve essere inferiore a 40 MB. - Questo tipo di file non può essere caricato. - Questo file non può essere aperto. - Il permesso di lettura della scheda sd è richiesto. - È richiesta l\'autorizzazione di archiviazione. - Immagini e video non possono essere allegati allo stesso stato. - Il caricamento non è riuscito. - Errore nell\'invio del toot. + Autorizzazione negata. + Acquisizione token di accesso fallita. + Il post è troppo lungo! + Il file deve essere più piccolo di 8 MB. + I video devono essere più piccoli di 40 MB. + Quel tipo di file non può essere caricato. + Non è stato possibile aprire quel file. + È richiesto il permesso di leggere file. + È richiesto il permesso di salvare file. + Non è possibile allegare allo stesso post immagini e video. + Il caricamento è fallito. + Errore nell\'invio del post. Home Notifiche Locale Federata - Messaggi Diretti + Messaggi diretti Schede - Toot + Conversazione Post Con risposte - Fissati in alto + Fissati Seguiti Seguono Preferiti @@ -43,15 +43,15 @@ Contenuto sensibile Media nascosto Clicca per visualizzare - Mostra di Più - Mostra Meno + Mostra di più + Mostra di meno Espandi Riduci - Qui non c\'è niente. - Qui non c\'è niente. Trascina verso il basso per aggiornare! - %s ha boostato il tuo toot - %s ha messo il tuo toot nei preferiti - %s ti segue + Qui non c\'è nulla. + Qui non c\'è nulla. Trascina verso il basso per aggiornare! + %s ha boostato il tuo post + %s ha messo il tuo post nei preferiti + %s ti ha seguito Segnala @%s Commenti aggiuntivi? Risposta veloce @@ -79,7 +79,7 @@ Chiudi Profilo Preferenze - Preferenze Account + Preferenze account Preferiti Utenti silenziati Utenti bloccati @@ -102,14 +102,14 @@ Rifiuta Cerca Bozze - Visibilità dei toot - Avviso per il contenuto + Visibilità dei post + Avviso di contenuto sensibile Tastiera emoji - Aggiungi Scheda + Aggiungi scheda Collegamenti Menzioni Hashtag - Apri autore del boost + Vai all\'autore del boost Mostra boost Mostra preferiti Hashtag @@ -117,11 +117,11 @@ Collegamenti Apri media #%d Scaricando %1$s - Copia il link + Copia link Apri come %s Condividi come … - Condividi URL del toot su… - Condividi toot su… + Condividi URL del post su… + Condividi post su… Condividi media su… Inviato! Utente sbloccato @@ -132,7 +132,7 @@ Cosa succede? Avviso di contenuto sensibile Mostra nome - Bio + Biografia Cerca… Nessun risultato Rispondi… @@ -152,22 +152,22 @@ Scarica Revocare la richiesta di seguire? Smettere di seguire questo account? - Eliminare questo toot? + Eliminare questo post\? Pubblico: visibile sulla timeline pubblica - Non Elencato: non visibile sulla timeline pubblica e locale - Solo Follower: visibile solo dai tuoi follower + Non in elenco: non visibile sulla timeline pubblica e locale + Solo follower: visibile solo dai tuoi follower Diretto: visibile solo agli utenti menzionati - Modifica Notifiche + Notifiche Notifiche Allarmi Notifica con suoneria Notifica con vibrazione Notifica con luce Notificami quando - sono stato menzionato - sono stato seguito - i miei post sono boostati - i miei post sono messi nei preferiti + vengo menzionato + vengo seguito + i miei post vengono boostati + i miei post vengono messi nei preferiti Aspetto Tema dell\'app Timeline @@ -176,10 +176,10 @@ Chiaro Nero Automatico al tramonto - Usa Tema di Sistema + Usa tema di sistema Browser - Usa Tab Personalizzate di Chrome - Nascondi il pulsante componi mentre scorri + Usa Custom Tabs di Chrome + Nascondi il pulsante Componi mentre scorri Lingua Filtraggio della timeline Schede @@ -193,25 +193,25 @@ Porta proxy HTTP Privacy di default dei post Segna sempre media come contenuto sensibile - Pubblicando (sincronizzato con il server) + Pubblicazione (sincronizzato con il server) Sincronizzazione delle impostazioni fallita Pubblico - Non elencato - Solo per chi ti segue - Dimensione del testo degli stati + Non in elenco + Solo follower + Dimensione del testo dei post Piccolissimo Piccolo Normale Grande Grandissimo - Nuove Menzioni - Notifiche quando qualcuno ti menziona - Nuove persone che ti seguono - Notifiche su nuove persone che ti seguono + Nuove menzioni + Notifiche di quando vieni menzionato da qualcuno + Nuovi follower + Notifiche su nuovi follower Boost - Notifiche quando i tuoi toot vengono boostati + Notifiche sui tuoi post che vengono boostati Preferiti - Notifiche quando i tuoi toot vengono segnati come preferiti + Notifiche sui tuoi post che vengono segnati come preferiti %s ti ha menzionato %1$s, %2$s, %3$s e %4$d altri %1$s, %2$s e %3$s @@ -234,14 +234,14 @@ --> Sito web del progetto:\n https://chinwag.org - Segnala problemi & richiedi funzionalità:\n - https://git.chinwag.org/chinwag/chinwag-android/issues + Segnala problemi e richiedi funzionalità: +\n https://git.chinwag.org/chinwag/chinwag-android/issues Profilo di Tusky - Condividi contenuto del toot - Condividi link al toot + Condividi contenuto del post + Condividi link al post Immagini Video - In attesa di approvazione + Richiesta inviata in %d a in %dg @@ -250,14 +250,14 @@ in %ds %da %dg - %d o - %d min - %d s - Seguono te - Mostra sempre tutto il contenuto sensibile + %do + %dmin + %ds + Ti segue + Mostra sempre tutti i contenuti sensibili Media Rispondendo a @%s - carica di più + carica altri Timeline pubbliche Conversazioni Aggiungi filtro @@ -265,7 +265,7 @@ Rimuovi Aggiorna Frase da filtrare - Aggiungi Account + Aggiungi account Aggiungi un nuovo Account Mastodon Liste Liste @@ -288,30 +288,29 @@ Inserisci descrizione Rimuovi Blocca account - Richiede la tua approvazione manuale di chi ti segue + Richiedi una tua approvazione manuale per seguirti Salvare bozza? - Inviando il Toot… + Inviando il post… Errore durante l\'invio - Invio Toot + Invio post Invio annullato - Una copia del toot è stata salvata nelle tue bozze + Una copia del post è stata salvata nelle tue bozze Componi La tua istanza %s non ha nessuna emoji personalizzata - Copiato negli appunti - Stile di emoji - Predefiniti del sistema + Stile delle emoji + Predefinite del sistema Dovrai prima scaricare questo pacchetto di emoji - Eseguendo una ricerca… - Espandi/Riduci tutti gli stati - Apri toot + Ricerca in corso… + Espandi/riduci tutti i post + Apri post Riavvio dell\'app richiesto Devi riavviare Tusky per applicare queste modifiche Più tardi Riavvia Le emoji predefinite del tuo dispositivo - Le emoji Blob conosciute da Android 4.4-7.1 + Le emoji Blob di Android 4.4-7.1 Le emoji standard di Mastodon - Scaricamento fallito + Download fallito Bot %1$s si è spostato su: Boost con la visibilità del post di origine @@ -324,63 +323,60 @@ aggiungi dati Etichetta Contenuto - Usa tempo assoluto + Usa ora assoluta Il profilo dell\'utente mostrato qui sotto potrebbe essere incompleto. Premi per aprire il profilo completo nel browser. - Non fissare + Smetti di fissare Fissa - %1$s Mi piace - %1$s Mi piace + %1$s Preferito + %1$s Preferiti <b>%s</b> Boost <b>%s</b> Boost Boostato da - Preferito da + Aggiunto ai preferiti da %1$s %1$s e %2$s %1$s, %2$s ed altri %3$d - limite massimo di %1$d tab raggiunto - limite massimo di %1$d tab raggiunto + limite massimo di %1$d scheda raggiunto + limite massimo di %1$d schede raggiunto - Media: %s - + Media: %s Contenuto sensibile: %s Nessuna descrizione Ribloggato - Apprezzato - + Messo nei preferiti Pubblico - Non elencato - - Seguaci + Non in elenco + Solo follower Diretti Nome della lista Scarica media Scaricando media - Componi Toot + Componi post Hashtag senza # Componi - Pulisci + Svuota Filtra Applica - Mostra indicatore per bot + Mostra indicatore bot Sei sicuro di voler permanentemente eliminare tutte le tue notifiche\? Cancella e riscrivi - Cancellare e riscrivere questo toot\? + Cancellare e riscrivere questo post\? %s voto %s voti - termina alle %s - terminato + si conclude alle %s + concluso Vota Domini nascosti Domini nascosti @@ -388,37 +384,37 @@ %s mostrati Sei sicuro di voler bloccare tutto %s\? Non vedrai nessun contenuto da quel dominio in nessuna timeline pubblica o nelle tue notifiche. I tuoi seguaci che stanno in quel dominio saranno rimossi. Nascondi l\'intero dominio - Le votazioni sono finite - Mostra le animazioni delle GIF negli avatar + dei sondaggi si sono conclusi + Riproduci animazioni avatar Votazioni - Notifiche sulle votazioni che sono concluse + Notifiche sulle votazioni che si sono concluse Parola intera Quando la parola chiave o la frase sono composte da soli caratteri alfanumerici, sarà applicata solo se corrisponde alla parola completa - Insieme di emoji di Google + Set di emoji di Google Segnalibri Segnalibro Modifica Segnalibri Aggiungi sondaggio - Fatto con Tusky - Espandi sempre i toot segnalati come contenuto sensibile - Messo nei segalibri + Fatto usando Tusky + Espandi sempre i post segnalati come contenuto sensibile + Messo nei segnalibri Sondaggio con scelte: %1$s, %2$s, %3$s, %4$s; %5$s Scegli lista Lista Azioni per l\'immagine %s - Un sondaggio che hai votato è terminato - Un sondaggio che hai creato è terminato + Un sondaggio che hai votato si è concluso + Un sondaggio che hai creato si è concluso - %d giorno rimasti + %d giorno rimasto %d giorni rimasti - %d ora rimasti + %d ora rimasta %d ore rimasti - %d minuto rimasti + %d minuto rimasto %d minuti rimasti @@ -428,12 +424,12 @@ Continua Indietro Fatto - Inviato con successo @%s + Segnalato @%s con successo Altri commenti Inoltra a %s - Errore durante l\'invio - Errore durante lo scaricamento degli aggiornamenti - La segnalazione sarà inviata al moderatore del tuo server. Puoi spiegare perchè vuoi segnalare questo utente qui sotto: + Segnalazione fallita + Scaricamento dei post fallito + La segnalazione sarà inviata al moderatore del tuo server. Puoi spiegare perchè stai segnalando questo utente qui sotto: L\'utente è su un altro server. Mandare una copia della segnalazione anche lì\? Utenti Errore durante la ricerca @@ -451,10 +447,10 @@ Scelta %d Modifica Errore nella ricerca del post %s - Toot programmati - Toot programmati - Programma un toot - RIpristina + Post programmati + Post programmati + Programma un post + Ripristina %1$s • %2$s Non hai bozze. @@ -465,66 +461,66 @@ Aggiungi hashtag Silenziare @%s\? Bloccare @%s\? - Non silenziare più %s - Smetti di silenziare conversazione + Smetti di silenziare %s + Smetti di silenziare la conversazione Silenzia conversazione %s ha chiesto di seguirti - La dimensione dei file audio deve essere inferiore a 40 MB. + I file audio devono essere più piccoli di 40 MB. Smetti di silenziare %s Richieste di seguirti Salvato! La tua nota privata su questo account Nascondi il titolo della barra degli strumenti in alto - Mostra la finestra di dialogo di conferma prima del boosting + Mostra la finestra di conferma prima di boostare Mostra le anteprime dei collegamenti nelle timelines - Mastodon ha un intervallo minimo di programmazione di 5 minuti. + Mastodon ha un intervallo di programmazione minimo di 5 minuti. Non ci sono annunci. - Non hai stati pianificati. + Non hai post pianificati. Abilita il gesto di scorrimento per passare da una scheda all\'altra Notifiche sulle richieste di essere seguiti - Parte inferiore + In fondo In cima - Posizione di navigazione principale - Mostra sfumature colorate per i media nascosti + Posizione barra di navigazione principale + Mostra gradienti colorati per i media nascosti Nascondi notifiche Disattiva le notifiche da %s Riattiva le notifiche da %s Annunci - Richieste di seguirti + mi viene richiesto di seguirmi Nascondi statistiche quantitative sui profili Nascondi le statistiche quantitative sui post - Limita le notifiche della timeline - Revisiona le notifiche + Limita le notifiche dalla timeline + Rivedi le notifiche Benessere - Notifiche di quando qualcuno a cui sei iscritto ha pubblicato un nuovo toot - Nuovi toots - qualcuno a cui sono iscritto ha pubblicato un nuovo toot - %s appena pubblicato + Notifiche di nuovi post di qualcuno a cui sei iscritto + Nuovi post + qualcuno che seguo ha pubblicato un nuovo post + %s ha appena pubblicato Non puoi caricare più di %1$d allegato multimediale. Non puoi caricare più di %1$d allegati multimediali. - Il toot a cui hai scritto una risposta è stato rimosso - Bozza cancellata - L\'invio di questo toot è fallito! + Il post a cui hai scritto una risposta è stato rimosso + Bozza eliminata + L\'invio di questo post è fallito! Sei sicuro di voler cancellare la lista %s\? Indefinita Durata Allegati Audio - Mostra le animazioni delle emojis personalizzate + Riproduci emoji animate Iscriviti Rimuovere questa conversazione\? - Errore nel recuperare le informazioni sulla risposta + Errore nel recupero delle informazioni sulla risposta Disiscriviti - Rimuovi conversazione + Elimina conversazione Alcune informazioni che potrebbero influenzare il tuo benessere mentale saranno nascoste. Questo include: \n \n - Notifiche riguardo a Preferiti/Boost/Following -\n - Conteggio dei Preferiti/Boost nei toot -\n - Statistiche riguardo a Preferiti e Post nei profili +\n - Conteggio dei Preferiti/Boost nei post +\n - Statistiche riguardo a Preferiti/Post nei profili \n -\n Le notifiche push non saranno influenzate, ma puoi rivedere le tue impostazioni delle notifiche manualmente. +\n Le notifiche push non saranno influenzate, ma puoi modificare le tue impostazioni delle notifiche manualmente. Rimuovi segnalibro Chiedi conferma prima di boostare 14 giorni @@ -533,5 +529,16 @@ 90 giorni 180 giorni 365 giorni - Anche se il tuo account non è bloccato, lo staff di %1$s ha pensato che potresti voler controllare queste richieste di following da parte questi account manualmente. + Anche se il tuo account non è bloccato, lo staff di %1$s ha pensato che potresti voler verificare le richieste di seguirti da parte questi account manualmente. + %s si è registrato + qualcuno si è registrato + Login + %s ha modificato il suo post + un post con cui ho interagito è stato modificato + Componi post + Registrazioni + Notifiche di quando qualcuno si è registrato + Modifiche ai post + Notifiche di quando i post con cui hai interagito vengono modificati + Non è stato possibile caricare la pagina di login. \ No newline at end of file diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index bb8c892f..ff978bf3 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -275,7 +275,6 @@ トゥートのコピーが下書きに保存されました 新規投稿 インスタンス %s にはカスタム絵文字がありません - クリップボードにコピーされました 絵文字スタイル システムのデフォルト 最初にこれらの絵文字セットをダウンロードする必要があります diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index e413d1af..6921eb4b 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -311,7 +311,6 @@ 복사본이 임시 저장에 저장되었습니다 글쓰기 이 인스턴스 %s 은(는) 커스텀 이모지가 없습니다. - 클립보드에 복사되었습니다 이모지 스타일 시스템 기본 시스템 기본 외의 이모지를 설정하시려면 우선 다운로드해야 합니다 diff --git a/app/src/main/res/values-night/theme_colors.xml b/app/src/main/res/values-night/theme_colors.xml index 1054b5ee..59f71288 100644 --- a/app/src/main/res/values-night/theme_colors.xml +++ b/app/src/main/res/values-night/theme_colors.xml @@ -24,4 +24,7 @@ false + @color/white + @color/tusky_grey_10 + \ No newline at end of file diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 1acee464..0552be7f 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -277,7 +277,6 @@ Een kopie van de toot werd opgeslagen als concept Toot schrijven Jouw server %s heeft geen lokale emojis - Naar het klembord gekopieerd Emojistijl Systeemstandaard Je moet eerst deze emoji-sets downloaden diff --git a/app/src/main/res/values-no-rNB/strings.xml b/app/src/main/res/values-no-rNB/strings.xml index b9203d7a..8890c9dd 100644 --- a/app/src/main/res/values-no-rNB/strings.xml +++ b/app/src/main/res/values-no-rNB/strings.xml @@ -278,7 +278,6 @@ En kopi av tootet er lagret i kladdene dine Skriv Instansen %s har ingen egendefinerte emojis - Kopiert til utklippstavlen Emoji-stil Systemstandard Du må laste ned emoji-samlingene før de kan brukes @@ -519,4 +518,14 @@ 365 dager 14 dager Komponer toot + %s registrerte seg + noen registrerte seg + Registreringer + Varslinger om nye brukere + %s redigerte innlegget sitt + et innlegg jeg har hatt en interaksjon med er redigert + Redigerte innlegg + Varslinger når et innlegg du har hatt en interaksjon med er redigert + Innlogging + Klarte ikke å laste innloggingssiden. \ No newline at end of file diff --git a/app/src/main/res/values-oc/strings.xml b/app/src/main/res/values-oc/strings.xml index c41fe5a2..89cbd0dc 100644 --- a/app/src/main/res/values-oc/strings.xml +++ b/app/src/main/res/values-oc/strings.xml @@ -243,7 +243,6 @@ Una còpia del tut es estat salvat dins los borrolhons Redactar L’instància %s es pas compatibla amb los emoji personalizats - Copiat al quichapapièr Estil dels Emoji Çò del sistèma D’en primièr vos cal telecargar los emojis seguents @@ -337,8 +336,8 @@ %1$s Favorits - %s partatge - %s partatges + %s Partatge + %s Partatges Partejat per Aimat per diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index f2aa559d..e6573c15 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 @@ -244,7 +244,6 @@ Kopia wpisu została zapisana jako szkic Nowy wpis Twoja instancja %s nie używa żadnych niestandardowych emoji - Skopiowano do schowka Styl emoji Domyślny systemu Musisz najpierw pobrać te zestawy emoji @@ -405,25 +404,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 @@ -463,7 +462,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. @@ -494,8 +493,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. @@ -516,21 +515,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 - Samopoczucie + 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 - Czas nieokreślony + Nieograniczony Dźwięk Powiadomienia o opublikowaniu nowego wpisu przez kogoś, kogo obserwujesz Pozycja głównego paska nawigacji @@ -549,4 +548,15 @@ 180 dni 365 dni Utwórz wpis + Login + %s zarejestrował(a) się + Rejestracje + Powiadomienia o nowych użytkownikach + Powiadomienia o edycji wpisów z którymi dokonałeś/aś interakcji + ktoś zarejestrował się + 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 5f703152..e6af0372 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -261,7 +261,6 @@ Uma cópia do toot foi salva nos seus rascunhos Compor A sua instância %s não possui emojis personalizados - Copiado para a área de transferência Estilo de emoji Padrão do sistema É necessário baixar estes pacotes de emojis primeiro diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml new file mode 100644 index 00000000..6be06b0c --- /dev/null +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -0,0 +1,533 @@ + + + Maior + Toots novos + Criação de contas + %1$s e %2$s + + %d nova interação + %d novas interações + + A responder a @%s + Editar a lista + Necessita de aprovar manualmente os seguidores + Guardar rascunho\? + Depois + Desafixar + %1$s e %2$s + Bem-estar + Escrever Toot + Pretende remover a lista %s\? + Apesar do seu perfil não ser privado, %1$s exige que você reveja manualmente as solicitações para te seguir destes perfis. + Subscrever + Remover subscrição + Autorização negada. + Erro ao adquirir token de login. + O toot é muito extenso! + O ficheiro deve ter menor de 8MB. + Os ficheiros de vídeo devem ter menor de 40MB. + Os ficheiros de áudio devem ter menor de 40MB. + Esse tipo de ficheiro não pode ser enviado. + Não foi possível abrir esse ficheiro. + É necessária permissão para ler o armazenamento. + É necessária permissão para escrever no armazenamento. + Não é possível anexar imagens e vídeos no mesmo toot. + Erro ao enviar. + Erro ao publicar o toot. + Página inicial + Notificações + Local + Federada + Mensagens Diretas + Separadores + Conversa + Toots + Com respostas + Fixado + Segue + Seguidores + Favoritos + Itens guardados + Utilizadores silenciados + Utilizadores bloqueados + Instâncias bloqueadas + Seguidores Pendentes + Conteúdo sensível + Editar perfil + Conteúdo multimédia ocultado + Rascunhos + Toque para ver + Mostrar Mais + Mostrar Menos + Expandir + Contrair + Toots agendados + Anúncios + Licenças + \@%s + %s fez boost + Nada aqui. + Nada para ver aqui. Arraste para baixo para atualizar! + %s fez boost ao seu toot + %s adicionou o seu toot aos favoritos + %s está a seguir-te + %s pediu para te seguir + %s criou conta + %s acabou de publicar um toot + %s editou um toot + Denunciar @%s + Comentários adicionais\? + Resposta Rápida + Responder + Fazer boost + Desfazer boost + Adicionar aos favoritos + Remover dos favoritos + Guardar + Remover dos itens guardados + Mais + Escrever + Entrar com Mastodon + Sair + Tem a certeza que deseja sair da conta %1$s\? + Seguir + Deixar de seguir + Bloquear + Desbloquear + Esconder boosts + Mostrar boosts + Denunciar + Editar + Apagar + Apagar conversa + Apagar e criar novo rascunho + TOOT + TOOT! + Tentar novamente + Fechar + Perfil + Configurações + Configurações da Conta + Favoritos + Itens Guardados + Utilizadores silenciados + Utilizadores bloqueados + Instâncias bloqueadas + Seguidores Pendentes + Conteúdo multimédia + Abrir no navegador + Adicionar conteúdo multimédia + Adicionar votação + Tirar foto + Partilhar + Silenciar + Remover silêncio + Remover %s do silêncio + Remover notificações de %s do silêncio + Silencie notificações de %s + Silenciar %s + Remover %s do silêncio + Silenciar conversa + Remover conversa do silêncio + Mencionar + Esconder conteúdo multimédia + Abrir menu + Pesquisar + Rascunhos + Toots agendados + Privacidade do toot + Aviso de conteúdo + Teclado de emojis + Agendar Toot + Redefinir + Adicionar Separador + Hiperligações + Menções + Hashtags + Ver autor do boost + Mostrar boosts + Mostrar favoritos + Hashtags + Menções + Hiperligações + Abrir conteúdo multimédia #%d + A descarregar %1$s + Copiar a hiperligação + Abrir como %s + Partilhar como… + Descarregar conteúdo multimédia + A descarregar conteúdo multimédia + Partilhar hiperligação do toot via… + Partilhar toot via… + Partilhar conteúdo multimédia via… + Enviado! + Utilizador desbloqueado + Utilizador removido do silêncio + %s desbloqueada + Enviado! + Resposta enviada com sucesso. + Que instância\? + Em que está a pensar\? + Aviso de conteúdo + Nome + Biografia + Pesquisar… + Sem resultados + Responder… + Avatar + Cabeçalho + O que é uma instância\? + A ligar… + O endereço IP ou domínio de qualquer instância pode ser inserido aqui, como por exemplo mastodon.social, masto.pt, pleroma.pt ou qualquer outro! +\n +\nSe ainda não tem uma conta, insira o nome da instância onde pretende participar e crie uma conta lá. +\n +\nUma instância é o local onde sua conta é criada, mas pode facilmente seguir e comunicar com pessoas de outras instâncias como se estivessem todos no mesmo site. +\n +\nMais informações disponíveis em joinmastodon.org. + A Terminar Envio de Conteúdo Multimédia + A enviar… + Descarregar + Cancelar o pedido para seguir\? + Deixar de seguir esta conta\? + Apagar este toot\? + Apagar e rescrever este toot\? + Apagar esta conversa\? + Tem a certeza que pretende bloquear a instância %s\? Deixará de poder ver quaisquer conteúdos dessa instância em qualquer timeline pública ou nas suas notificações. Os seus seguidores dessa instância serão removidos. + Bloquear instância + Bloquear @%s\? + Silenciar @%s\? + Esconder notificações + Público: Publicar em timelines públicas + Não listado: Não publicar em timelines públicas + Privado: Publicar apenas para os seguidores + Direto: Publicar apenas para os utilizadores mencionados + Notificações + Notificações + Alertas + Notificar com som + Notificar com vibração + Notificar com luz + Notifique-me quando + for mencionado + for seguido + alguém para quem ativei os alertas publicar um toot novo + fizerem pedido para me seguir + fizerem boosts aos meus toots + adicionarem os meus toots aos favoritos + votações terminarem + alguém criar conta + um toot com o qual interagi for editado + Aparência + Tema da Aplicação + Timelines + Filtros + Escuro + Claro + AMOLED + Automático ao pôr-do-sol + Usar o Tema do Sistema + Navegador + Usar Separadores Personalizados do Chrome + Esconder o botão de criação de toots ao fazer scroll + Idioma + Mostrar indicador para bots + Reproduzir avatars em GIF + Mostrar desfocagem em conteúdo multimédia sensível + Animar emojis personalizados + Filtro da timeline + Separadores + Mostrar boosts + Mostrar respostas + Mostrar pré-visualização de conteúdo multimédia + Proxy + Proxy HTTP + Ativar proxy HTTP + Servidor da proxy HTTP + Privacidade padrão dos toots + Classificar sempre conteúdo multimédia como sensível + Toots (sincronizados com a instância) + Erro ao sincronizar configurações + Posição do menu principal + Superior + Inferior + Público + Porta da proxy HTTP + Não listado + Privado + Menor + Pequeno + Médio + Grande + Menções Novas + Notificações para menções novas + Novos Seguidores + Tamanho do texto do toot + Notificações para seguidores novos + Seguidores Pendentes + Notificações para seguidores pendentes + Boosts + Votações + Notificações para votações terminadas + Notificações quando alguém para quem ativou os alertas publicar um toot novo + Notificações para novos utilizadores + Edições de toots + Notificações para boosts recebidos + Favoritos + Notificações quando os seus toots são adicionados aos favoritos + Notificações quando toots com os quais interagiu forem editados + %s mencionou-te + %1$s, %2$s, %3$s e %4$d outros + %1$s, %2$s e %3$s + Perfil Privado + Sobre + Tusky %s + A correr o Tusky + Atualizar + Tusky é um software livre e de código aberto, licenciado com a versão 3 da GNU General Public License. Pode ler a licença aqui: https://www.gnu.org/licenses/gpl-3.0.pt-br.html + Página do projeto: +\n https://tusky.app + Reporte de erros e pedidos de funcionalidades: +\n https://github.com/tuskyapp/Tusky/issues + Perfil do Tusky + Partilhar conteúdo do toot + Partilhar hiperligação do toot + Imagens + Vídeo + Áudio + Anexos + Pedido para seguir enviado + em %dy + em %dd + em %dh + em %dm + em %ds + %dy + %dd + %dh + %dm + %ds + Segue-te + Mostrar sempre conteúdo multimédia sensível + Expandir sempre toots com Aviso de Conteúdo + Palavra completa + Conteúdo Multimédia + carregar mais + Timelines públicas + Conversas + Criar filtro + Editar filtro + Remover + Se a palavra ou frase for alfanumérica, só será aplicado se corresponder à palavra completa + Frase para filtrar + Adicionar Conta + Adicionar nova Conta Mastodon + 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 + Renomear a lista + Apagar a lista + Pesquisar pessoas que você segue + Adicionar conta à lista + Remover conta da lista + Publicar com a conta %1$s + Erro ao incluir descrição + + Descrição para deficientes visuais +\n(até %d letra) + Descrição para deficientes visuais +\n(até %d caracteres) + + Escrever descrição + Remover + Bloquear perfil + A enviar o toot… + Erro ao enviar o toot + A Enviar os Toots + Envio cancelado + Uma cópia do toot foi guardada nos seus rascunhos + Escrever + A sua instância, %s, não tem emojis personalizados + Estilo dos emojis + Padrão do sistema + É necessário descarregar estes pacotes de emojis primeiro + A fazer pesquisa… + Expandir/Contrair todos os toots + Abrir toot + É necessário reiniciar a aplicação + É necessário reiniciar o Tusky para aplicar as alterações + Reiniciar + Pacote de emojis padrão do seu dispositivo + Emojis padrão do Android 4.4 até ao 7.1 + Pacote de emojis padrão do Mastodon + Pacote de emojis atuais da Google + Erro ao descarregar + Robô + %1$s mudou-se para: + Dar boost para o público inicial + Desfazer boost + O Tusky contém código e recursos dos seguintes projetos de código aberto: + Licenciado sob a licença Apache (cópia abaixo) + CC-BY 4.0 + CC-BY-SA 4.0 + Metadados do perfil + adicionar dados + Rótulo + Conteúdo + Usar data absoluta + As informações abaixo podem refletir, de forma incompleta, o perfil do utilizador. Toque aqui para abrir o perfil completo no navegador. + Fixar + + %1$s Favorito + %1$s Favoritos + + + %s Boost + %s Boosts + + Boost dado por + Adicionado aos favoritos por + %1$s + %1$s, %2$s e %3$d mais + + atingiu o máximo de %1$d separador + atingiu o máximo de %1$d separadores + + Conteúdo multimédia: %s + Aviso de Conteúdo: %s + Sem descrição + Replicado + Adicionado aos favoritos + Guardado + Público + Não-listado + Privado + Direto + Votação com as opções: %1$s, %2$s, %3$s, %4$s; %5$s + Nome da lista + Adicionar hashtag + Hashtag sem # + Hashtags + Selecionar lista + Lista + Limpar + Filtrar + Aplicar + Escrever toot + Escrever + Tem certeza que pretende limpar permanentemente todas as suas notificações\? + Opções para imagem %s + %1$s • %2$s + + %s voto + %s votos + + + %s pessoa + %s pessoas + + termina em %s + terminada + Votar + Uma votação em que votou terminou + A sua votação terminou + + %d dia restante + %d dias restantes + + + %d hora restante + %d horas restantes + + + %d minuto restante + %d minutos restantes + + + %d segundo restante + %d segundos restantes + + Continuar + Retroceder + Feito + \@%s denunciado com sucesso + Comentários adicionais + Encaminhar para %s + Erro ao denunciar + Erro ao carregar toots + A denúncia será enviada aos moderadores da instância. Pode adicionar abaixo uma explicação para a sua denúncia: + A conta está noutra instância. Quer enviar uma cópia anónima da denúncia para lá\? + Contas + Erro ao pesquisar + Mostrar Filtro das Notificações + Ativar gesto de deslizar para alternar entre separadores + Votação + Duração + Indefinido + 5 minutos + 30 minutos + 1 hora + 6 horas + 1 dia + 3 dias + 7 dias + 14 dias + 30 dias + 60 dias + 90 dias + 180 dias + 365 dias + Adicionar opção + Escolha múltipla + Opção %d + Editar + Erro ao pesquisar toot %s + Não tem rascunhos. + Não tem toots agendados. + Guardado! + Algumas informações que podem afetar seu bem-estar serão ocultadas. Isso inclui: +\n +\n- Notificações de favoritos, boosts e seguidores +\n- Número de favoritos e boosts nos toots +\n- Status de toots e seguidores nos perfis +\n +\nNotificações push não serão afetadas, mas é possível rever as configurações das notificações manualmente. + Rever Notificações + Limitar notificações da timeline + Sem anúncios. + O Mastodon tem um intervalo mínimo de agendamento de 5 minutos. + Mostrar pré-visualização de hiperligações nas timelines + Mostrar janela de confirmação antes de dar boost + Mostrar janela de confirmação antes de adicionar aos favoritos + Esconder o título da barra superior + Nota pessoal sobre este perfil + Esconder estatísticas quantitativas nos toots + Esconder estatísticas quantitativas nos perfis + + Não é possível enviar mais de %1$d arquivo de conteúdo multimédia. + Não é possível enviar mais de %1$d arquivos de conteúdo multimédia. + + Erro ao enviar o toot! + Erro ao carregar informação de resposta + Rascunho apagado + O toot para o qual escreveu um rascunho foi apagado + Ocorreu um erro. + Ocorreu um erro de conetividade! Por favor, verifique a sua ligação e tente novamente! + Isto não pode estar vazio. + A instância inserida é inválida + Erro ao autenticar com esta instância. + Não foi possível encontrar um navegador. + Ocorreu um erro de autorização não identificado. + Entrar + Guardar + Editar perfil + Editar + Desfazer + Aceitar + Rejeitar + Não foi possível carregar a página de login + \ No newline at end of file diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 184497b8..67af8cc7 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -324,7 +324,6 @@ Копия поста сохранена в ваши черновики Сочинить У вашего узла %s нет собственных эмодзи - Скопировано в буфер обмена Стиль эмодзи Системный Сперва эти наборы эмодзи нужно скачать diff --git a/app/src/main/res/values-sa/strings.xml b/app/src/main/res/values-sa/strings.xml index bb411871..4c8ac044 100644 --- a/app/src/main/res/values-sa/strings.xml +++ b/app/src/main/res/values-sa/strings.xml @@ -317,7 +317,6 @@ प्राक्तु भावचिह्नसमूहोऽयमवारोप्यः प्रणाल्यां पूर्वनिविष्टम् भावचिह्नशैली - अंशफलकेऽनुसृतम् भवदीयं विशिष्टस्थलं %s स्वीयानुकूलभावचिह्नरहितं वर्तते लिख्यताम् दौत्यप्रतिलिपिस्तत्र विकर्षेसु रक्षिता diff --git a/app/src/main/res/values-si/strings.xml b/app/src/main/res/values-si/strings.xml index df654a29..7ba70498 100644 --- a/app/src/main/res/values-si/strings.xml +++ b/app/src/main/res/values-si/strings.xml @@ -171,7 +171,6 @@ \n https://tusky.app
පිළිගන්න පැ. %d කින් - පසුරුපුවරුවට පිටපත් විය මතවිමසුම ඉවත් කරන්න මාධ්‍ය එකතු කරන්න diff --git a/app/src/main/res/values-sk/strings.xml b/app/src/main/res/values-sk/strings.xml index 15d05945..e06555c7 100644 --- a/app/src/main/res/values-sk/strings.xml +++ b/app/src/main/res/values-sk/strings.xml @@ -77,7 +77,7 @@ Autentizácia servru zlyhala. Nepodarilo sa nájsť použiteľný webový prehliadač. Vyskytla sa neidentifikovaná chyba autorizácie. - Toot je príliš dlhý! + Príspevok je príliš dlhý! Tento typ súboru nemôže byť nahraný. Chyba pri odosielaní tootu. Toot diff --git a/app/src/main/res/values-sl/strings.xml b/app/src/main/res/values-sl/strings.xml index 4919b3ec..f6d6caad 100644 --- a/app/src/main/res/values-sl/strings.xml +++ b/app/src/main/res/values-sl/strings.xml @@ -276,7 +276,6 @@ Kopija tuta je bila shranjena v osnutke Sestavi Vaše vozlišče %s nima emotikonov po meri - Kopirano v odložišče Slog emotikonov Privzete nastavitve sistema Najprej boste morali prenesti te emotikone diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 1bbc64a8..f9908d87 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -297,7 +297,6 @@ En kopia av tooten har sparats i dina utkast Skriv Din instans %s har inga anpassade emojis - Kopierat till urklipp Emojis Systemstandard Du behöver ladda ned dessa emojis först diff --git a/app/src/main/res/values-ta/strings.xml b/app/src/main/res/values-ta/strings.xml index 6625a19e..f199adf4 100644 --- a/app/src/main/res/values-ta/strings.xml +++ b/app/src/main/res/values-ta/strings.xml @@ -231,7 +231,6 @@ நகலெடுக்கபட்ட toot வரைவில் சேமிக்கபட்டது எழுது தங்கள் %s instance(களம்)-ல் எந்தவொரு custom emojis-ம் இல்லை - பிடிப்புப்பலகையில் நகலெடுக்க Emoji பாணி அமைப்பின் இயல்புநிலை தாங்கள் முதலில் இந்த Emoji sets-னை பதிவிறக்கவேண்டும் diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index fa27fbe0..781a432b 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -116,7 +116,6 @@ ต้องดาวน์โหลดชุดเอโมจิเหล่านี้ก่อน ค่าปริยายของระบบ รูปแบบเอโมจิ - คัดลอกไปยังคลิบบอร์ดแล้ว Instance %s ไม่มีเอโมจิแบบกำหนดเอง เขียน สำเนา Toot บันทึกเป็นฉบับร่างแล้ว diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 25a5efcb..dea620be 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -261,7 +261,6 @@ Tootun bir kopyası taslaklara kaydedildi Oluştur %s örneğinizin herhangi bir özel ifadesi yok - Panoya kopyalandı İfade stili Sistem varsayılanı Önce bu ifade paketini indirmeniz gerekecek diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 0031334b..5998c9b0 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -100,7 +100,7 @@ %s надсилає запит на підписку %s підписується на вас Тут нічого немає. Потягніть вниз, щоб оновити! - Тут нічого немає. + Тут порожньо. Згорнути Розгорнути Натисніть для перегляду @@ -181,7 +181,6 @@ Спочатку потрібно буде завантажити ці набори емодзі Типовий системний Стиль емодзі - Скопійовано до буфера обміну Ваш сервер %s не має власних емодзі Зберегти чернетку\? Вимагає затвердження підписників власноруч @@ -541,4 +540,15 @@ 180 днів 365 днів Створити допис + %s реєструється + хтось реєструється + Реєстрації + Сповіщення про нових користувачів + %s редагує свій допис + допис, з яким у мене була взаємодія, відредаговано + Сповіщення, коли редагується повідомлення, з яким ви взаємодіяли + Редакції допису + Вхід + Не вдалося завантажити сторінку входу. + Збереження чернетки… \ 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 d228c6be..125e452e 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -192,7 +192,7 @@ Ghim Trả lời Tút - Nội dung tút + Tút Xếp tab Tin nhắn Thế giới @@ -413,7 +413,7 @@ Tusky có sử dụng mã nguồn từ những dự án mã nguồn mở sau: Hủy đăng lại Đăng lại công khai - %1$s đã dời sang: + %1$s đã chuyển sang: Tài khoản Bot Tải về thất bại Emoji của Google @@ -430,7 +430,6 @@ Bạn cần tải về bộ emoji này trước Mặc định của thiết bị Emoji - Đã chép vào clipboard Viết Lưu nháp\? Tự bạn sẽ phê duyệt người theo dõi @@ -508,4 +507,15 @@ 180 ngày 365 ngày Viết tút + ai đó đăng ký trên máy chủ + %s đăng ký + Đăng ký + Thông báo về người dùng mới đăng ký + %s đã sửa tút của họ + khi một tút mà tôi tương tác bị sửa + Sửa tút + 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 0866fef3..2e6a8042 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -27,7 +27,7 @@ 标签页 嘟文 嘟文 - 嘟文和回复 + 有回复 已置顶 正在关注 关注者 @@ -42,7 +42,7 @@ %s 转嘟了 敏感内容 已隐藏的照片或视频 - 点击显示 + 点击查看 显示更多 折叠内容 展开 @@ -156,7 +156,7 @@ 移除关注请求? 不再关注此用户? 删除这条嘟文? - 删除并重新编辑这条嘟文? + 删除并重新起草这条嘟文? 公开:所有人可见,并会出现在公共时间轴上 不公开:所有人可见,但不会出现在公共时间轴上 仅关注者:只有经过你确认后关注你的用户可见 @@ -203,7 +203,7 @@ 公开 不公开 仅关注者 - 字体大小 + 嘟文字体大小 最小 标准 @@ -299,14 +299,13 @@ 保护你的帐户(锁嘟) 你需要手动审核所有关注请求 保存为草稿? - 正在发送… - 发送失败 + 正在发送嘟文… + 嘟文发送出错 嘟文发送中 已取消发送 - 嘟文已保存为草稿 + 嘟文副本已保存为草稿 发表嘟文 当前实例 %s 没有自定义表情符号 - 已复制到剪贴板 表情符号风格 系统默认 需要下载表情符号数据 @@ -353,16 +352,10 @@ 标签页不能超过 %1$d 个 媒体:%s - 内容提醒:%s - - 没有媒体描述信息 - - - 被转嘟 - - - 被收藏 - + 内容警告:%s + 没有描述信息 + 被转嘟 + 被收藏 公开 @@ -435,7 +428,7 @@ 附加留言 转发到 %s 举报失败 - 无法获取状态 + 无法获取嘟文 该报告将发送给给您的服务器管理员。您可以在下面提供有关回报此帐户的原因的说明: 该帐户来自其他服务器。向那里发送一份匿名的报告副本? 账户 @@ -533,4 +526,15 @@ 14 天 365 天 撰写嘟文 + %s 已注册 + 某人进行了注册 + 新用户通知 + 注册 + 登录 + %s 编辑了他们的嘟文 + 我进行过互动的嘟文被编辑了 + 嘟文编辑 + 当你进行过互动的嘟文被编辑时发出通知 + 无法加载登录页。 + 正在保存草稿… \ No newline at end of file diff --git a/app/src/main/res/values-zh-rHK/strings.xml b/app/src/main/res/values-zh-rHK/strings.xml index 2877900a..5ac0003a 100644 --- a/app/src/main/res/values-zh-rHK/strings.xml +++ b/app/src/main/res/values-zh-rHK/strings.xml @@ -304,7 +304,6 @@ 嘟文已儲存為草稿 新嘟文 當前站點 %s 沒有自訂表情符號 - 已複製到剪貼簿 表情符號風格 系統預設 你需要先下載這些表情符號包 diff --git a/app/src/main/res/values-zh-rMO/strings.xml b/app/src/main/res/values-zh-rMO/strings.xml index 49f1cb3f..4ea493d6 100644 --- a/app/src/main/res/values-zh-rMO/strings.xml +++ b/app/src/main/res/values-zh-rMO/strings.xml @@ -298,7 +298,6 @@ 嘟文已儲存為草稿 發表新嘟文 當前站點 %s 沒有自訂表情符號 - 已複製到剪貼簿 表情符號風格 系統預設 你需要先下載這些表情符號包 diff --git a/app/src/main/res/values-zh-rSG/strings.xml b/app/src/main/res/values-zh-rSG/strings.xml index 66c0a842..dc729241 100644 --- a/app/src/main/res/values-zh-rSG/strings.xml +++ b/app/src/main/res/values-zh-rSG/strings.xml @@ -302,7 +302,6 @@ 嘟文已保存为草稿 新嘟文 当前实例 %s 没有自定义表情符号 - 已复制到剪贴板 表情符号风格 系统默认 需要下载表情符号数据 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index e972d3a1..b5776edd 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -304,7 +304,6 @@ 嘟文已儲存為草稿 發表新嘟文 當前站點 %s 沒有自訂表情符號 - 已複製到剪貼簿 表情符號風格 系統預設 你需要先下載這些表情符號包 @@ -526,4 +525,6 @@ 總是顯示被標注為內容警告的嘟文 搜尋失敗 帳號 + 登入 + 無法載入登入頁面。 \ No newline at end of file diff --git a/app/src/main/res/values/donottranslate.xml b/app/src/main/res/values/donottranslate.xml index 9545b0dd..86dfb26a 100644 --- a/app/src/main/res/values/donottranslate.xml +++ b/app/src/main/res/values/donottranslate.xml @@ -57,6 +57,7 @@ Occitan Polski Português (Brasil) + Português (Portugal) Slovenščina Svenska Taqbaylit @@ -106,6 +107,7 @@ oc pl pt-BR + pt-PT sl sv kab diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3cc43d4e..44160f62 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -9,6 +9,7 @@ An unidentified authorization error occurred. Authorization was denied. Failed getting a login token. + Could not load the login page. The post is too long! The file must be less than 8MB. Video files must be less than 40MB. @@ -21,6 +22,7 @@ The upload failed. Error sending post. + Login Home Notifications Local @@ -62,7 +64,9 @@ %s favorited your post %s followed you %s requested to follow you + %s signed up %s just posted + %s edited their post Report @%s Additional comments? @@ -228,6 +232,8 @@ my posts are favorited polls have ended somebody I\'m subscribed to published a new post + somebody signed up + a post I\'ve interacted with is edited Appearance App Theme Timelines @@ -295,6 +301,10 @@ Notifications about polls that have ended New posts Notifications when somebody you\'re subscribed to published a new post + Sign ups + Notifications about new users + Post edits + Notifications when posts you\'ve interacted with are edited %s mentioned you %1$s, %2$s, %3$s and %4$d others @@ -403,7 +413,6 @@ Compose Your instance %s does not have any custom emojis - Copied to clipboard Emoji style System default You\'ll need to download these emoji sets first @@ -632,5 +641,6 @@ Register New Account Compose Post + Saving draft… diff --git a/app/src/main/res/values/theme_colors.xml b/app/src/main/res/values/theme_colors.xml index bab199ea..cbc8d33d 100644 --- a/app/src/main/res/values/theme_colors.xml +++ b/app/src/main/res/values/theme_colors.xml @@ -24,4 +24,7 @@ true + @color/tusky_grey_20 + @color/white + \ No newline at end of file diff --git a/app/src/test/java/com/keylesspalace/tusky/BottomSheetActivityTest.kt b/app/src/test/java/com/keylesspalace/tusky/BottomSheetActivityTest.kt index ef6d2632..beb6af9b 100644 --- a/app/src/test/java/com/keylesspalace/tusky/BottomSheetActivityTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/BottomSheetActivityTest.kt @@ -15,16 +15,11 @@ package com.keylesspalace.tusky -import android.text.SpannedString -import android.widget.LinearLayout import androidx.arch.core.executor.testing.InstantTaskExecutorRule -import com.google.android.material.bottomsheet.BottomSheetBehavior import com.keylesspalace.tusky.entity.SearchResult import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.TimelineAccount import com.keylesspalace.tusky.network.MastodonApi -import com.nhaarman.mockitokotlin2.doReturn -import com.nhaarman.mockitokotlin2.mock import io.reactivex.rxjava3.android.plugins.RxAndroidPlugins import io.reactivex.rxjava3.core.Single import io.reactivex.rxjava3.plugins.RxJavaPlugins @@ -39,8 +34,8 @@ import org.junit.runner.RunWith import org.junit.runners.Parameterized import org.mockito.ArgumentMatchers.anyBoolean import org.mockito.Mockito.eq -import org.mockito.Mockito.mock -import java.util.ArrayList +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock import java.util.Date import java.util.concurrent.TimeUnit @@ -74,7 +69,7 @@ class BottomSheetActivityTest { inReplyToId = null, inReplyToAccountId = null, reblog = null, - content = SpannedString("omgwat"), + content = "omgwat", createdAt = Date(), emojis = emptyList(), reblogsCount = 0, @@ -306,7 +301,7 @@ class BottomSheetActivityTest { init { mastodonApi = api @Suppress("UNCHECKED_CAST") - bottomSheet = mock(BottomSheetBehavior::class.java) as BottomSheetBehavior + bottomSheet = mock() } override fun openLink(url: String) { diff --git a/app/src/test/java/com/keylesspalace/tusky/ComposeActivityTest.kt b/app/src/test/java/com/keylesspalace/tusky/ComposeActivityTest.kt index e7b3a1a9..3a8f2f23 100644 --- a/app/src/test/java/com/keylesspalace/tusky/ComposeActivityTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/ComposeActivityTest.kt @@ -17,38 +17,32 @@ package com.keylesspalace.tusky import android.content.Intent import android.os.Looper.getMainLooper -import android.text.SpannedString import android.widget.EditText import androidx.test.ext.junit.runners.AndroidJUnit4 import com.keylesspalace.tusky.components.compose.ComposeActivity import com.keylesspalace.tusky.components.compose.ComposeViewModel -import com.keylesspalace.tusky.components.compose.DEFAULT_CHARACTER_LIMIT -import com.keylesspalace.tusky.components.compose.DEFAULT_MAXIMUM_URL_LENGTH -import com.keylesspalace.tusky.components.compose.MediaUploader -import com.keylesspalace.tusky.components.drafts.DraftHelper +import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository import com.keylesspalace.tusky.db.AccountEntity import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AppDatabase +import com.keylesspalace.tusky.db.EmojisEntity import com.keylesspalace.tusky.db.InstanceDao -import com.keylesspalace.tusky.db.InstanceEntity +import com.keylesspalace.tusky.db.InstanceInfoEntity import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.Instance import com.keylesspalace.tusky.entity.InstanceConfiguration import com.keylesspalace.tusky.entity.StatusConfiguration import com.keylesspalace.tusky.network.MastodonApi -import com.keylesspalace.tusky.service.ServiceClient -import com.nhaarman.mockitokotlin2.any -import io.reactivex.rxjava3.core.Single -import io.reactivex.rxjava3.core.SingleObserver import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith -import org.mockito.Mockito.`when` -import org.mockito.Mockito.mock +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock import org.robolectric.Robolectric import org.robolectric.Shadows.shadowOf import org.robolectric.annotation.Config @@ -94,49 +88,55 @@ class ComposeActivityTest { val controller = Robolectric.buildActivity(ComposeActivity::class.java) activity = controller.get() - accountManagerMock = mock(AccountManager::class.java) - `when`(accountManagerMock.activeAccount).thenReturn(account) + accountManagerMock = mock { + on { activeAccount } doReturn account + } - apiMock = mock(MastodonApi::class.java) - `when`(apiMock.getCustomEmojis()).thenReturn(Single.just(emptyList())) - `when`(apiMock.getInstance()).thenReturn(object : Single() { - override fun subscribeActual(observer: SingleObserver) { - val instance = instanceResponseCallback?.invoke() + apiMock = mock { + onBlocking { getCustomEmojis() } doReturn Result.success(emptyList()) + onBlocking { getInstance() } doReturn instanceResponseCallback?.invoke().let { instance -> if (instance == null) { - observer.onError(Throwable()) + Result.failure(Throwable()) } else { - observer.onSuccess(instance) + Result.success(instance) } } - }) + } - val instanceDaoMock = mock(InstanceDao::class.java) - `when`(instanceDaoMock.loadMetadataForInstance(any())).thenReturn( - Single.just(InstanceEntity(instanceDomain, emptyList(), null, null, null, null, null, null, null)) - ) + val instanceDaoMock: InstanceDao = mock { + onBlocking { getInstanceInfo(any()) } doReturn + InstanceInfoEntity(instanceDomain, null, null, null, null, null, null, null) + onBlocking { getEmojiInfo(any()) } doReturn + EmojisEntity(instanceDomain, emptyList()) + } - val dbMock = mock(AppDatabase::class.java) - `when`(dbMock.instanceDao()).thenReturn(instanceDaoMock) + val dbMock: AppDatabase = mock { + on { instanceDao() } doReturn instanceDaoMock + } + + val instanceInfoRepo = InstanceInfoRepository(apiMock, dbMock, accountManagerMock) val viewModel = ComposeViewModel( apiMock, accountManagerMock, - mock(MediaUploader::class.java), - mock(ServiceClient::class.java), - mock(DraftHelper::class.java), - dbMock + mock(), + mock(), + mock(), + instanceInfoRepo ) activity.intent = Intent(activity, ComposeActivity::class.java).apply { putExtra(ComposeActivity.COMPOSE_OPTIONS_EXTRA, composeOptions) } - val viewModelFactoryMock = mock(ViewModelFactory::class.java) - `when`(viewModelFactoryMock.create(ComposeViewModel::class.java)).thenReturn(viewModel) + val viewModelFactoryMock: ViewModelFactory = mock { + on { create(ComposeViewModel::class.java) } doReturn viewModel + } activity.accountManager = accountManagerMock activity.viewModelFactory = viewModelFactoryMock controller.create().start() + shadowOf(getMainLooper()).idle() } @Test @@ -187,7 +187,7 @@ class ComposeActivityTest { fun whenMaximumTootCharsIsNull_defaultLimitIsUsed() { instanceResponseCallback = { getInstanceWithCustomConfiguration(null) } setupActivity() - assertEquals(DEFAULT_CHARACTER_LIMIT, activity.maximumTootCharacters) + assertEquals(InstanceInfoRepository.DEFAULT_CHARACTER_LIMIT, activity.maximumTootCharacters) } @Test @@ -238,7 +238,7 @@ class ComposeActivityTest { val url = "https://www.google.dk/search?biw=1920&bih=990&tbm=isch&sa=1&ei=bmDrWuOoKMv6kwWOkIaoDQ&q=indiana+jones+i+hate+snakes+animated&oq=indiana+jones+i+hate+snakes+animated&gs_l=psy-ab.3...54174.55443.0.55553.9.7.0.0.0.0.255.333.1j0j1.2.0....0...1c.1.64.psy-ab..7.0.0....0.40G-kcDkC6A#imgdii=PSp15hQjN1JqvM:&imgrc=H0hyE2JW5wrpBM:" val additionalContent = "Check out this @image #search result: " insertSomeTextInContent(additionalContent + url) - assertEquals(activity.calculateTextLength(), additionalContent.length + DEFAULT_MAXIMUM_URL_LENGTH) + assertEquals(activity.calculateTextLength(), additionalContent.length + InstanceInfoRepository.DEFAULT_CHARACTERS_RESERVED_PER_URL) } @Test @@ -247,7 +247,7 @@ class ComposeActivityTest { val url = "https://www.google.dk/search?biw=1920&bih=990&tbm=isch&sa=1&ei=bmDrWuOoKMv6kwWOkIaoDQ&q=indiana+jones+i+hate+snakes+animated&oq=indiana+jones+i+hate+snakes+animated&gs_l=psy-ab.3...54174.55443.0.55553.9.7.0.0.0.0.255.333.1j0j1.2.0....0...1c.1.64.psy-ab..7.0.0....0.40G-kcDkC6A#imgdii=PSp15hQjN1JqvM:&imgrc=H0hyE2JW5wrpBM:" val additionalContent = " Check out this @image #search result: " insertSomeTextInContent(shortUrl + additionalContent + url) - assertEquals(activity.calculateTextLength(), additionalContent.length + (DEFAULT_MAXIMUM_URL_LENGTH * 2)) + assertEquals(activity.calculateTextLength(), additionalContent.length + (InstanceInfoRepository.DEFAULT_CHARACTERS_RESERVED_PER_URL * 2)) } @Test @@ -255,7 +255,7 @@ class ComposeActivityTest { val url = "https://www.google.dk/search?biw=1920&bih=990&tbm=isch&sa=1&ei=bmDrWuOoKMv6kwWOkIaoDQ&q=indiana+jones+i+hate+snakes+animated&oq=indiana+jones+i+hate+snakes+animated&gs_l=psy-ab.3...54174.55443.0.55553.9.7.0.0.0.0.255.333.1j0j1.2.0....0...1c.1.64.psy-ab..7.0.0....0.40G-kcDkC6A#imgdii=PSp15hQjN1JqvM:&imgrc=H0hyE2JW5wrpBM:" val additionalContent = " Check out this @image #search result: " insertSomeTextInContent(url + additionalContent + url) - assertEquals(activity.calculateTextLength(), additionalContent.length + (DEFAULT_MAXIMUM_URL_LENGTH * 2)) + assertEquals(activity.calculateTextLength(), additionalContent.length + (InstanceInfoRepository.DEFAULT_CHARACTERS_RESERVED_PER_URL * 2)) } @Test @@ -470,7 +470,7 @@ class ComposeActivityTest { "admin", "admin", "admin", - SpannedString(""), + "", "https://example.token", "", "", @@ -490,7 +490,7 @@ class ComposeActivityTest { ) } - fun getCustomInstanceConfiguration(maximumStatusCharacters: Int? = null, charactersReservedPerUrl: Int? = null): InstanceConfiguration { + private fun getCustomInstanceConfiguration(maximumStatusCharacters: Int? = null, charactersReservedPerUrl: Int? = null): InstanceConfiguration { return InstanceConfiguration( statuses = StatusConfiguration( maxCharacters = maximumStatusCharacters, diff --git a/app/src/test/java/com/keylesspalace/tusky/FilterTest.kt b/app/src/test/java/com/keylesspalace/tusky/FilterTest.kt index 03fff5ee..91ea38d3 100644 --- a/app/src/test/java/com/keylesspalace/tusky/FilterTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/FilterTest.kt @@ -1,6 +1,5 @@ package com.keylesspalace.tusky -import android.text.SpannedString import androidx.test.ext.junit.runners.AndroidJUnit4 import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.entity.Filter @@ -8,12 +7,12 @@ import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.entity.PollOption import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.network.FilterModel -import com.nhaarman.mockitokotlin2.mock import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith +import org.mockito.kotlin.mock import org.robolectric.annotation.Config import java.util.ArrayList import java.util.Date @@ -22,7 +21,7 @@ import java.util.Date @RunWith(AndroidJUnit4::class) class FilterTest { - lateinit var filterModel: FilterModel + private lateinit var filterModel: FilterModel @Before fun setup() { @@ -162,7 +161,7 @@ class FilterTest { inReplyToId = null, inReplyToAccountId = null, reblog = null, - content = SpannedString(content), + content = content, createdAt = Date(), emojis = emptyList(), reblogsCount = 0, diff --git a/app/src/test/java/com/keylesspalace/tusky/StatusComparisonTest.kt b/app/src/test/java/com/keylesspalace/tusky/StatusComparisonTest.kt index ed06e27c..3086036a 100644 --- a/app/src/test/java/com/keylesspalace/tusky/StatusComparisonTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/StatusComparisonTest.kt @@ -1,10 +1,8 @@ package com.keylesspalace.tusky -import android.text.Spanned import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.google.gson.GsonBuilder +import com.google.gson.Gson import com.keylesspalace.tusky.entity.Status -import com.keylesspalace.tusky.json.SpannedTypeAdapter import com.keylesspalace.tusky.viewdata.StatusViewData import org.junit.Assert.assertEquals import org.junit.Assert.assertNotEquals @@ -39,9 +37,7 @@ class StatusComparisonTest { assertEquals(createStatus(note = "Test"), createStatus(note = "Test 123456")) } - private val gson = GsonBuilder().registerTypeAdapter( - Spanned::class.java, SpannedTypeAdapter() - ).create() + private val gson = Gson() @Test fun `two equal status view data - should be equal`() { @@ -49,14 +45,12 @@ class StatusComparisonTest { status = createStatus(), isExpanded = false, isShowingContent = false, - isCollapsible = false, isCollapsed = false ) val viewdata2 = StatusViewData.Concrete( status = createStatus(), isExpanded = false, isShowingContent = false, - isCollapsible = false, isCollapsed = false ) assertEquals(viewdata1, viewdata2) @@ -68,14 +62,12 @@ class StatusComparisonTest { status = createStatus(), isExpanded = true, isShowingContent = false, - isCollapsible = false, isCollapsed = false ) val viewdata2 = StatusViewData.Concrete( status = createStatus(), isExpanded = false, isShowingContent = false, - isCollapsible = false, isCollapsed = false ) assertNotEquals(viewdata1, viewdata2) @@ -87,14 +79,12 @@ class StatusComparisonTest { status = createStatus(content = "whatever"), isExpanded = true, isShowingContent = false, - isCollapsible = false, isCollapsed = false ) val viewdata2 = StatusViewData.Concrete( status = createStatus(), isExpanded = false, isShowingContent = false, - isCollapsible = false, isCollapsed = false ) assertNotEquals(viewdata1, viewdata2) diff --git a/app/src/test/java/com/keylesspalace/tusky/TuskyApplication.kt b/app/src/test/java/com/keylesspalace/tusky/TuskyApplication.kt index 7724ba76..9598f2c1 100644 --- a/app/src/test/java/com/keylesspalace/tusky/TuskyApplication.kt +++ b/app/src/test/java/com/keylesspalace/tusky/TuskyApplication.kt @@ -18,16 +18,16 @@ package com.keylesspalace.tusky import android.app.Application import android.content.Context import android.content.res.Configuration -import androidx.emoji.text.EmojiCompat import com.keylesspalace.tusky.util.LocaleManager -import de.c1710.filemojicompat.FileEmojiCompatConfig +import de.c1710.filemojicompat_defaults.DefaultEmojiPackList +import de.c1710.filemojicompat_ui.helpers.EmojiPackHelper // override TuskyApplication for Robolectric tests, only initialize the necessary stuff class TuskyApplication : Application() { override fun onCreate() { super.onCreate() - EmojiCompat.init(FileEmojiCompatConfig(this, "")) + EmojiPackHelper.init(this, DefaultEmojiPackList.get(this)) } override fun attachBaseContext(base: Context) { diff --git a/app/src/test/java/com/keylesspalace/tusky/components/timeline/CachedTimelineRemoteMediatorTest.kt b/app/src/test/java/com/keylesspalace/tusky/components/timeline/CachedTimelineRemoteMediatorTest.kt index 462b0a4a..2778f8c2 100644 --- a/app/src/test/java/com/keylesspalace/tusky/components/timeline/CachedTimelineRemoteMediatorTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/components/timeline/CachedTimelineRemoteMediatorTest.kt @@ -17,9 +17,6 @@ import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.db.Converters import com.keylesspalace.tusky.db.TimelineStatusWithAccount -import com.nhaarman.mockitokotlin2.anyOrNull -import com.nhaarman.mockitokotlin2.doReturn -import com.nhaarman.mockitokotlin2.mock import io.reactivex.rxjava3.core.Single import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.runBlocking @@ -31,6 +28,9 @@ import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock import org.robolectric.Shadows.shadowOf import org.robolectric.annotation.Config import retrofit2.HttpException diff --git a/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelinePagingSourceTest.kt b/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelinePagingSourceTest.kt index 2e67c6fe..33215e67 100644 --- a/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelinePagingSourceTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelinePagingSourceTest.kt @@ -1,14 +1,19 @@ package com.keylesspalace.tusky.components.timeline import androidx.paging.PagingSource +import androidx.test.ext.junit.runners.AndroidJUnit4 import com.keylesspalace.tusky.components.timeline.viewmodel.NetworkTimelinePagingSource import com.keylesspalace.tusky.components.timeline.viewmodel.NetworkTimelineViewModel -import com.nhaarman.mockitokotlin2.doReturn -import com.nhaarman.mockitokotlin2.mock import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.robolectric.annotation.Config +@Config(sdk = [28]) +@RunWith(AndroidJUnit4::class) class NetworkTimelinePagingSourceTest { private val status = mockStatusViewData() diff --git a/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRemoteMediatorTest.kt b/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRemoteMediatorTest.kt index 74d0fe25..eabf744c 100644 --- a/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRemoteMediatorTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRemoteMediatorTest.kt @@ -12,11 +12,6 @@ import com.keylesspalace.tusky.components.timeline.viewmodel.NetworkTimelineView import com.keylesspalace.tusky.db.AccountEntity import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.viewdata.StatusViewData -import com.nhaarman.mockitokotlin2.anyOrNull -import com.nhaarman.mockitokotlin2.doReturn -import com.nhaarman.mockitokotlin2.doThrow -import com.nhaarman.mockitokotlin2.mock -import com.nhaarman.mockitokotlin2.verify import kotlinx.coroutines.runBlocking import okhttp3.Headers import okhttp3.ResponseBody.Companion.toResponseBody @@ -24,6 +19,11 @@ import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test import org.junit.runner.RunWith +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.doThrow +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify import org.robolectric.annotation.Config import retrofit2.HttpException import retrofit2.Response @@ -331,7 +331,6 @@ class NetworkTimelineRemoteMediatorTest { mockStatusViewData("2"), mockStatusViewData("1"), ) - verify(timelineViewModel).nextKey = "0" assertTrue(result is RemoteMediator.MediatorResult.Success) assertEquals(false, (result as RemoteMediator.MediatorResult.Success).endOfPaginationReached) diff --git a/app/src/test/java/com/keylesspalace/tusky/components/timeline/StatusMocker.kt b/app/src/test/java/com/keylesspalace/tusky/components/timeline/StatusMocker.kt index f7c998b5..cc6a90bd 100644 --- a/app/src/test/java/com/keylesspalace/tusky/components/timeline/StatusMocker.kt +++ b/app/src/test/java/com/keylesspalace/tusky/components/timeline/StatusMocker.kt @@ -1,6 +1,5 @@ package com.keylesspalace.tusky.components.timeline -import android.text.SpannedString import com.google.gson.Gson import com.keylesspalace.tusky.db.TimelineStatusWithAccount import com.keylesspalace.tusky.entity.Status @@ -25,7 +24,7 @@ fun mockStatus(id: String = "100") = Status( inReplyToId = null, inReplyToAccountId = null, reblog = null, - content = SpannedString("Test"), + content = "Test", createdAt = fixedDate, emojis = emptyList(), reblogsCount = 1, @@ -50,7 +49,6 @@ fun mockStatusViewData(id: String = "100") = StatusViewData.Concrete( status = mockStatus(id), isExpanded = false, isShowingContent = false, - isCollapsible = false, isCollapsed = true, ) diff --git a/app/src/test/java/com/keylesspalace/tusky/db/TimelineDaoTest.kt b/app/src/test/java/com/keylesspalace/tusky/db/TimelineDaoTest.kt index 889e5f98..ed652418 100644 --- a/app/src/test/java/com/keylesspalace/tusky/db/TimelineDaoTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/db/TimelineDaoTest.kt @@ -369,13 +369,36 @@ class TimelineDaoTest { assertEquals("99", timelineDao.getTopPlaceholderId(1)) } + @Test + fun `preview card survives roundtrip`() = runBlocking { + val setOne = makeStatus(statusId = 3, cardUrl = "https://foo.bar") + + for ((status, author, reblogger) in listOf(setOne)) { + timelineDao.insertAccount(author) + reblogger?.let { + timelineDao.insertAccount(it) + } + timelineDao.insertStatus(status) + } + + val pagingSource = timelineDao.getStatuses(setOne.first.timelineUserId) + + val loadResult = pagingSource.load(PagingSource.LoadParams.Refresh(null, 2, false)) + + val loadedStatuses = (loadResult as PagingSource.LoadResult.Page).data + + assertEquals(1, loadedStatuses.size) + assertStatuses(listOf(setOne), loadedStatuses) + } + private fun makeStatus( accountId: Long = 1, statusId: Long = 10, reblog: Boolean = false, createdAt: Long = statusId, authorServerId: String = "20", - domain: String = "mastodon.example" + domain: String = "mastodon.example", + cardUrl: String? = null, ): Triple { val author = TimelineAccountEntity( serverId = authorServerId, @@ -403,6 +426,10 @@ class TimelineDaoTest { ) } else null + val card = when (cardUrl) { + null -> null + else -> "{ url: \"$cardUrl\" }" + } val even = accountId % 2 == 0L val status = TimelineStatusEntity( serverId = statusId.toString(), @@ -433,7 +460,8 @@ class TimelineDaoTest { expanded = false, contentCollapsed = false, contentShowing = true, - pinned = false + pinned = false, + card = card, ) return Triple(status, author, reblogAuthor) } diff --git a/app/src/test/java/com/keylesspalace/tusky/util/AbsoluteTimeFormatterTest.kt b/app/src/test/java/com/keylesspalace/tusky/util/AbsoluteTimeFormatterTest.kt new file mode 100644 index 00000000..57f3bed4 --- /dev/null +++ b/app/src/test/java/com/keylesspalace/tusky/util/AbsoluteTimeFormatterTest.kt @@ -0,0 +1,46 @@ +package com.keylesspalace.tusky.util + +import org.junit.Assert.assertEquals +import org.junit.Test +import java.time.Instant +import java.util.Date +import java.util.TimeZone + +class AbsoluteTimeFormatterTest { + + private val formatter = AbsoluteTimeFormatter(TimeZone.getTimeZone("UTC")) + private val now = Date.from(Instant.parse("2022-04-11T00:00:00.00Z")) + + @Test + fun `null handling`() { + assertEquals("??", formatter.format(null, true, now)) + assertEquals("??", formatter.format(null, false, now)) + } + + @Test + fun `same day formatting`() { + val tenTen = Date.from(Instant.parse("2022-04-11T10:10:00.00Z")) + assertEquals("10:10", formatter.format(tenTen, true, now)) + assertEquals("10:10", formatter.format(tenTen, false, now)) + } + + @Test + fun `same year formatting`() { + val nextDay = Date.from(Instant.parse("2022-04-12T00:10:00.00Z")) + assertEquals("04-12 00:10", formatter.format(nextDay, true, now)) + assertEquals("04-12 00:10", formatter.format(nextDay, false, now)) + val endOfYear = Date.from(Instant.parse("2022-12-31T23:59:00.00Z")) + assertEquals("12-31 23:59", formatter.format(endOfYear, true, now)) + assertEquals("12-31 23:59", formatter.format(endOfYear, false, now)) + } + + @Test + fun `other year formatting`() { + val firstDayNextYear = Date.from(Instant.parse("2023-01-01T00:00:00.00Z")) + assertEquals("2023-01-01", formatter.format(firstDayNextYear, true, now)) + assertEquals("2023-01-01 00:00", formatter.format(firstDayNextYear, false, now)) + val inTenYears = Date.from(Instant.parse("2032-04-11T10:10:00.00Z")) + assertEquals("2032-04-11", formatter.format(inTenYears, true, now)) + assertEquals("2032-04-11 10:10", formatter.format(inTenYears, false, now)) + } +} diff --git a/app/src/test/java/com/keylesspalace/tusky/util/EmojiCompatFontTest.kt b/app/src/test/java/com/keylesspalace/tusky/util/EmojiCompatFontTest.kt deleted file mode 100644 index 5dd5ea84..00000000 --- a/app/src/test/java/com/keylesspalace/tusky/util/EmojiCompatFontTest.kt +++ /dev/null @@ -1,47 +0,0 @@ -package com.keylesspalace.tusky.util - -import org.junit.Assert.assertEquals -import org.junit.Test - -class EmojiCompatFontTest { - - @Test - fun testCompareVersions() { - - assertEquals( - -1, - EmojiCompatFont.compareVersions( - listOf(0), - listOf(1, 2, 3) - ) - ) - assertEquals( - 1, - EmojiCompatFont.compareVersions( - listOf(1, 2, 3), - listOf(0, 0, 0) - ) - ) - assertEquals( - -1, - EmojiCompatFont.compareVersions( - listOf(1, 0, 1), - listOf(1, 1, 0) - ) - ) - assertEquals( - 0, - EmojiCompatFont.compareVersions( - listOf(4, 5, 6), - listOf(4, 5, 6) - ) - ) - assertEquals( - 0, - EmojiCompatFont.compareVersions( - listOf(0, 0), - listOf(0) - ) - ) - } -} diff --git a/app/src/test/java/com/keylesspalace/tusky/util/VersionUtilsTest.kt b/app/src/test/java/com/keylesspalace/tusky/util/VersionUtilsTest.kt deleted file mode 100644 index 2731228a..00000000 --- a/app/src/test/java/com/keylesspalace/tusky/util/VersionUtilsTest.kt +++ /dev/null @@ -1,35 +0,0 @@ -package com.keylesspalace.tusky.util - -import org.junit.Assert.assertEquals -import org.junit.Test -import org.junit.runner.RunWith -import org.junit.runners.Parameterized - -@RunWith(Parameterized::class) -class VersionUtilsTest( - private val versionString: String, - private val supportsScheduledToots: Boolean -) { - - companion object { - @JvmStatic - @Parameterized.Parameters - fun data() = listOf( - arrayOf("2.0.0", false), - arrayOf("2a9a0", false), - arrayOf("1.0", false), - arrayOf("error", false), - arrayOf("", false), - arrayOf("2.6.9", false), - arrayOf("2.7.0", true), - arrayOf("2.00008.0", true), - arrayOf("2.7.2 (compatible; Pleroma 1.0.0-1168-ge18c7866-pleroma-dot-site)", true), - arrayOf("3.0.1", true) - ) - } - - @Test - fun testVersionUtils() { - assertEquals(VersionUtils(versionString).supportsScheduledToots(), supportsScheduledToots) - } -} diff --git a/build.gradle b/build.gradle index c9311701..3a5251fa 100644 --- a/build.gradle +++ b/build.gradle @@ -1,5 +1,4 @@ buildscript { - ext.kotlin_version = '1.6.10' repositories { google() mavenCentral() @@ -7,12 +6,12 @@ buildscript { } dependencies { classpath "com.android.tools.build:gradle:7.1.2" - classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" - classpath "org.jlleitschuh.gradle:ktlint-gradle:10.1.0" + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.6.20" + classpath "org.jlleitschuh.gradle:ktlint-gradle:10.2.1" } } plugins { - id "org.jlleitschuh.gradle.ktlint" version "10.1.0" + id "org.jlleitschuh.gradle.ktlint" version "10.2.1" } allprojects { diff --git a/fastlane/metadata/android/de/changelogs/89.txt b/fastlane/metadata/android/de/changelogs/89.txt new file mode 100644 index 00000000..cb92453b --- /dev/null +++ b/fastlane/metadata/android/de/changelogs/89.txt @@ -0,0 +1,7 @@ +Tusky v17.0 + +- "Öffnen als..." ist jetzt im Menü in Konto Profilen auch verfügbar, wenn mehrere Konten genutzt werden +- Die Anmeldung wird jetzt über die WebView innerhalb der App abgewickelt +- Unterstützung für Android 12 +- Unterstützung für die neue Mastodon instance configuration API +- und einige andere kleine Fehlerbehebungen und Verbesserungen diff --git a/fastlane/metadata/android/en-US/changelogs/91.txt b/fastlane/metadata/android/en-US/changelogs/91.txt new file mode 100644 index 00000000..e1d98303 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/91.txt @@ -0,0 +1,6 @@ +Tusky v18.0 + +- Support for new Mastodon 3.5 notification types +- The bot badge now looks better and adjusts to the selected theme +- Text can now be selected on the post detail view +- Fixed a lot of bugs, including one that prevented logins on Android 6 and lower diff --git a/fastlane/metadata/android/fr/changelogs/89.txt b/fastlane/metadata/android/fr/changelogs/89.txt new file mode 100644 index 00000000..9c8ae3b9 --- /dev/null +++ b/fastlane/metadata/android/fr/changelogs/89.txt @@ -0,0 +1,7 @@ +Tusky v17.0 + +- L'option « Ouvrir comme… » disponible quand plusieurs comptes sont connectés est maintenant aussi accessible depuis le menu sur les profils +- L'identification se fait maintenant par une WebView dans l'application +- Android 12 est pris en charge +- La nouvelle API Mastodon de configuration d'instance est prise en charge +- et beaucoup d'autres petites corrections et améliorations diff --git a/fastlane/metadata/android/hu/changelogs/89.txt b/fastlane/metadata/android/hu/changelogs/89.txt new file mode 100644 index 00000000..e7803769 --- /dev/null +++ b/fastlane/metadata/android/hu/changelogs/89.txt @@ -0,0 +1,7 @@ +Tusky v17.0 + +- "Megnyit, mint..." már a fiókok profiljainak menüjében is elérhető, amikor több fiókot használsz +- A bejelentkezés az appon belül már WebView-ban működik +- Android 12 támogatása +- új Mastodon szerverkonfigurációs API támogatása +- sok más kisebb javítás és fejlesztés diff --git a/fastlane/metadata/android/is/changelogs/89.txt b/fastlane/metadata/android/is/changelogs/89.txt new file mode 100644 index 00000000..e379fc72 --- /dev/null +++ b/fastlane/metadata/android/is/changelogs/89.txt @@ -0,0 +1,7 @@ +Tusky útg.17.0 + +- "Opna sem..." er núna líka á valmyndinni í notendasniðum þegar verið er að nota marga aðganga +- Innskráning er núna meðhöndluð í WebView innan forritsins +- Stuðningur við Android 12 +- Stuðningur við API-kerfisviðmót fyrir nýja uppsetningu Mastodon-tilvika +- og mökkur af smærri endurbótum og lagfæringum diff --git a/fastlane/metadata/android/pl/changelogs/89.txt b/fastlane/metadata/android/pl/changelogs/89.txt new file mode 100644 index 00000000..edfc6f0a --- /dev/null +++ b/fastlane/metadata/android/pl/changelogs/89.txt @@ -0,0 +1,7 @@ +Tusky v17.0 + +- "Otwórz jako..." teraz jest także dostępne w menu na profilach kont gdy używane jest kilka kont +- Login teraz jest obsługiwany w WebView w aplikacji +- Wsparcie dla Androida 12 +- Wsparcie nowego API konfiguracji instancji Mastodon +- i wiele innych małych poprawek i ulepszeń diff --git a/fastlane/metadata/android/pt-PT/changelogs/58.txt b/fastlane/metadata/android/pt-PT/changelogs/58.txt new file mode 100644 index 00000000..24aad2f2 --- /dev/null +++ b/fastlane/metadata/android/pt-PT/changelogs/58.txt @@ -0,0 +1,12 @@ +Tusky v6.0 + +- Os filtros de timeline passaram para "Preferências da Conta" e sincronizam com servidor +- Pode ter uma hashtag personalizada como separador +- Suporte a edição de listas +- O editor sugere emojis personalizados ao escrever +- Nova configuração: "seguir tema do sistema" +- Melhor acessibilidade da timeline +- O Tusky ignora notificações desconhecidas, deixando de crashar +- Nova opção: trocar o idioma do sistema por outro +- Novas traduções +- Muitas outras melhorias e correções diff --git a/fastlane/metadata/android/pt-PT/changelogs/61.txt b/fastlane/metadata/android/pt-PT/changelogs/61.txt new file mode 100644 index 00000000..3cc7097e --- /dev/null +++ b/fastlane/metadata/android/pt-PT/changelogs/61.txt @@ -0,0 +1,7 @@ +Tusky v7.0 + +- Suporte para mostragem de votações, para votação e notificação de votações +- Botões novos para filtrar notificações e excluí-las +- Exclua e rascunhe os seus toots +- Novo indicador que mostra, na foto de perfil, se uma conta é um bot (pode ser desativado nas preferências) +- Novas traduções: Norueguês, Bokmål e Esloveno. diff --git a/fastlane/metadata/android/pt-PT/changelogs/67.txt b/fastlane/metadata/android/pt-PT/changelogs/67.txt new file mode 100644 index 00000000..5d3d3849 --- /dev/null +++ b/fastlane/metadata/android/pt-PT/changelogs/67.txt @@ -0,0 +1,9 @@ +Tusky v9.0 + +- Agora pode criar votações no Tusky +- Pesquisa melhorada +- Nova opção em "Preferências da Conta": "Expandir sempre os toots com Aviso de Conteúdo" +- Avatars em formato quadrado com cantos arredondados +- Agora é possível denunciar utilizadores, mesmo que não tenham toots +- O Tusky vai recusar a ligação através de ligações simples (não encriptadas) em Android 6+ +- Muitas outras pequenas melhorias e correções de bugs diff --git a/fastlane/metadata/android/pt-PT/changelogs/68.txt b/fastlane/metadata/android/pt-PT/changelogs/68.txt new file mode 100644 index 00000000..91792113 --- /dev/null +++ b/fastlane/metadata/android/pt-PT/changelogs/68.txt @@ -0,0 +1,3 @@ +Tusky v9.1 + +Esta atualização garante compatibilidade com Mastodon 3 e melhora a performance e estabilidade. diff --git a/fastlane/metadata/android/pt-PT/changelogs/70.txt b/fastlane/metadata/android/pt-PT/changelogs/70.txt new file mode 100644 index 00000000..6ac528b1 --- /dev/null +++ b/fastlane/metadata/android/pt-PT/changelogs/70.txt @@ -0,0 +1,8 @@ +Tusky v10.0 + +- Agora é possível adicionar toots aos favoritos e ver a lista de favoritos no Tusky. +- Já pode agendar toots, no entanto é necessário agendá-los para pelo menos 5 minutos depois do momento da escrita. +- Já pode adicionar listas na barra lateral do Tusky! +- Já pode partilhar ficheiros de som nos teus toots! + +E muitas outras pequenas melhorias e correções de bugs! diff --git a/fastlane/metadata/android/pt-PT/changelogs/72.txt b/fastlane/metadata/android/pt-PT/changelogs/72.txt new file mode 100644 index 00000000..f42b0a8e --- /dev/null +++ b/fastlane/metadata/android/pt-PT/changelogs/72.txt @@ -0,0 +1,11 @@ +Tusky v11.0 + +- Notificações de seguidores pendentes quando a conta está trancada! +- Novas funcionalidades nas "Preferências": + * desativação do gesto que alterna entre separadores + * diálogo de confirmação antes de dar boost + * mostragem da pré-visualização de links nas timelines +- As conversas agora podem ser silenciadas +- As votações passam a ser calculadas pelo número de votantes e não pelo número de votos +- Várias correções relacionadas com a escrita de toots + - Traduções melhoradas diff --git a/fastlane/metadata/android/pt-PT/changelogs/74.txt b/fastlane/metadata/android/pt-PT/changelogs/74.txt new file mode 100644 index 00000000..9595cb1f --- /dev/null +++ b/fastlane/metadata/android/pt-PT/changelogs/74.txt @@ -0,0 +1,8 @@ +Tusky v.12.0 + +- Interface principal melhorada - passa a ser possível mover os separadores para baixo! +- Ao silenciar um utilizador, pode também escolher se também pretende silenciar as notificações +- Agora dá para seguir quantas hashtags quiser num único separador! +- A exibição da descrição dos conteúdos multimédia foi melhorada para suportar descrições super longas + +Registo completo de alterações: https://github.com/tuskyapp/Tusky/releases diff --git a/fastlane/metadata/android/pt-PT/changelogs/77.txt b/fastlane/metadata/android/pt-PT/changelogs/77.txt new file mode 100644 index 00000000..01c57b33 --- /dev/null +++ b/fastlane/metadata/android/pt-PT/changelogs/77.txt @@ -0,0 +1,10 @@ +Tusky v13.0 + +- Suporte para anotações em perfis (novidade do Mastodon 3.2.0) +- Suporte para anúncios do(s) administrador(es) de instâncias (novidade do Mastodon 3.1.0) + +- O avatar da sua conta selecionada passa a ficar visível na barra de ferramentas principal (canto superior esquerdo) +- Tocar no nome de utilizador na timeline abrirá o perfil em questão + +- Várias pequenas melhorias e correções +- Traduções melhoradas diff --git a/fastlane/metadata/android/pt-PT/changelogs/80.txt b/fastlane/metadata/android/pt-PT/changelogs/80.txt new file mode 100644 index 00000000..866dc8e5 --- /dev/null +++ b/fastlane/metadata/android/pt-PT/changelogs/80.txt @@ -0,0 +1,7 @@ +Tusky v14.0 + +- Receba notificações quando um utilizador que segue publicar um toot - basta clicar no ícone do sino (novidade do Mastodon 3.3.0) +- O suporte para rascunhos do Tusky foi reescrito para ser mais rápido, simples e menos propenso a erros. +- Foi adicionado uma funcionalidade de bem-estar, que permite limitar algumas funcionalidades no Tusky. +- O Tusky já consegue animar os emojis personalizados +Registo completo de alterações: https://github.com/tuskyapp/Tusky/releases diff --git a/fastlane/metadata/android/pt-PT/changelogs/82.txt b/fastlane/metadata/android/pt-PT/changelogs/82.txt new file mode 100644 index 00000000..0ee9e876 --- /dev/null +++ b/fastlane/metadata/android/pt-PT/changelogs/82.txt @@ -0,0 +1,5 @@ +Tusky v15.0 + +- O menu principal passa a mostrar uma opção para ver os utilizadores que pediram para o seguir! +- O relógio para agendar toots ganhou um aspeto mais consistente com o resto do Tusky +Registo completo de alterações: https://github.com/tuskyapp/Tusky/releases diff --git a/fastlane/metadata/android/pt-PT/changelogs/83.txt b/fastlane/metadata/android/pt-PT/changelogs/83.txt new file mode 100644 index 00000000..4c71e64d --- /dev/null +++ b/fastlane/metadata/android/pt-PT/changelogs/83.txt @@ -0,0 +1,3 @@ +Tusky v15.1 + +O Tusky já não crasha ao adicionar descrição às imagens diff --git a/fastlane/metadata/android/pt-PT/changelogs/87.txt b/fastlane/metadata/android/pt-PT/changelogs/87.txt new file mode 100644 index 00000000..79a81157 --- /dev/null +++ b/fastlane/metadata/android/pt-PT/changelogs/87.txt @@ -0,0 +1,8 @@ +Tusky v16.0 + +- O algoritmo de carregamento da timeline foi completamente reescrito para ser mais rápida, mais estável e mais fácil de manter. +- O Tusky passa a poder animar emojis personalizados no formato APNG & WebP Animated. +- Muitas correções de bugs +- Suporte para Android 11 +- Novas traduções: gaélico escocês, galego, ucraniano +- Traduções melhoradas diff --git a/fastlane/metadata/android/pt-PT/changelogs/89.txt b/fastlane/metadata/android/pt-PT/changelogs/89.txt new file mode 100644 index 00000000..28cebc1b --- /dev/null +++ b/fastlane/metadata/android/pt-PT/changelogs/89.txt @@ -0,0 +1,7 @@ +Tusky v17.0 + +- "Abrir como..." está disponível no menu de perfis de contas quando estão várias contas configuradas +- O login passa a ser feito numa WebView dentro da aplicação +- Suporte para Android 12 +- Suporte para a nova API de configuração de instâncias do Mastodon +- Várias pequenas melhorias e correções diff --git a/fastlane/metadata/android/pt-PT/full_description.txt b/fastlane/metadata/android/pt-PT/full_description.txt new file mode 100644 index 00000000..52d67d81 --- /dev/null +++ b/fastlane/metadata/android/pt-PT/full_description.txt @@ -0,0 +1,12 @@ +Tusky é um cliente leve para Mastodon, um servidor de rede social de código aberto e livre. + +• Design Material +• Maioria das APIs do Mastodon implementadas +• Suporte para várias contas +• Temas diurno e noturno, com possibilidade de troca automática de acordo com o horário +• Rascunhos - Escreva os seus toots e guarde-os para mais tarde +• Escolha entre estilos diferentes de emoji +• Otimizado para todos os tamanhos de ecrã +• Código totalmente aberto, sem dependências não-livres como Google Play Services + +Para ler mais sobre o Mastodon, visite o endereço https://joinmastodon.org/ diff --git a/fastlane/metadata/android/pt-PT/short_description.txt b/fastlane/metadata/android/pt-PT/short_description.txt new file mode 100644 index 00000000..38a439d8 --- /dev/null +++ b/fastlane/metadata/android/pt-PT/short_description.txt @@ -0,0 +1 @@ +Um cliente multi-contas para a rede social Mastodon diff --git a/fastlane/metadata/android/pt-PT/title.txt b/fastlane/metadata/android/pt-PT/title.txt new file mode 100644 index 00000000..0238ffc0 --- /dev/null +++ b/fastlane/metadata/android/pt-PT/title.txt @@ -0,0 +1 @@ +Tusky diff --git a/fastlane/metadata/android/uk/changelogs/91.txt b/fastlane/metadata/android/uk/changelogs/91.txt new file mode 100644 index 00000000..4132d155 --- /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 00000000..2835fdfc --- /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 00000000..e8f7c36e --- /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 00000000..06fcd290 --- /dev/null +++ b/fastlane/metadata/android/zh-Hans/changelogs/87.txt @@ -0,0 +1,8 @@ +Tusky v16.0 + +- 时间线加载逻辑完全重写,提升了流畅度、稳定性,更便于维护。 +- APNG和动画WebP格式的动态自定义表情符号。 +- 修正大量BUG +- 支持Android 11 +- 新增界面语言支持:苏格兰盖尔语、加利西亚语、乌克兰语 +- 改进翻译